【共创稿事节】HarmonyOS7 老旧照片修复:端侧超分、滑动对比与保存
文章目录
前言
手上有些老旧照片,分辨率低、细节模糊、噪点明显,想用 AI 提升画质,又不想调云端接口、处理网络异常。HarmonyOS 的 CoreVisionKit 提供了端侧图像超分辨率能力(imageSuperResolution),输入一张 PixelMap,输出一张分辨率增强后的 PixelMap,全程本地完成,无需联网。
这篇文章围绕一个"老旧照片修复"完整案例,把选图 → 加载 → 超分处理 → 滑动对比 → 保存入库的全链路拆开讲。重点包括:ImageSRAnalyzer 的创建与销毁时机、PhotoViewPicker 选图后的 URI→fd→PixelMap 转换、Stack 叠放 + clip 裁剪实现滑动对比、PanGesture 偏移量到 sliderOffset 的映射、SaveButton 安全控件 + MediaAssetChangeRequest 写入相册。每个环节都会说明"为什么这样写",文末附完整源码。
效果预览

主要流程
整个流程压缩成 5 步:
ImageSRAnalyzer.create()初始化端侧超分引擎,aboutToAppear创建、aboutToDisappear销毁。PhotoViewPicker选图 → URI →fileIo.open拿 fd →ImageSource.createPixelMap()得到输入 PixelMap。analyzer.process(request)执行超分,输出增强后的 PixelMap。- Stack 叠放:底层修复图 + 上层原图 clip 裁剪 + 白色分割线,PanGesture 驱动
sliderOffset实现滑动对比。 SaveButton安全控件授权 →ImagePacker.packing打包 JPEG →MediaAssetChangeRequest.createImageAssetRequest写入系统相册 → 清理临时文件。
下面逐步展开。
ImageSRAnalyzer 的创建与销毁
创建时机
private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null;
async aboutToAppear(): Promise<void> {
try {
this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer created');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Failed to create analyzer: ${err.code}, ${err.message}`);
}
}
ImageSRAnalyzer.create() 是异步工厂方法,不是构造函数。为什么?因为引擎初始化涉及模型加载、GPU 资源分配等重操作,同步构造会阻塞 UI 线程。create() 返回 Promise,在后台完成初始化后 resolve。
初始化放在 aboutToAppear 而不是 build 里,因为 aboutToAppear 在组件生命周期中只调用一次,而 build 可能因状态变化多次执行。
销毁时机
async aboutToDisappear(): Promise<void> {
if (this.analyzer) {
try {
await this.analyzer.destroy();
hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer destroyed');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Failed to destroy analyzer: ${err.code}, ${err.message}`);
}
}
}
destroy() 释放引擎占用的 GPU 和内存资源。必须在 aboutToDisappear 中调用,否则模型和缓冲区会一直驻留内存。即使页面切换了,analyzer 如果没 destroy,资源不会回收。
注意 if (this.analyzer) 的判空——如果 create() 失败了,analyzer 仍然是 null,此时调用 destroy() 会崩溃。
选图与加载——从 URI 到 PixelMap
PhotoViewPicker 选图
private async selectImage(): Promise<void> {
try {
let options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
let photoPicker = new picker.PhotoViewPicker();
let result: picker.PhotoSelectResult = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
let uri = result.photoUris[0];
this.inputUri = uri;
await this.loadImage(uri);
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Select image failed: ${err.code}, ${err.message}`);
this.statusText = '选择图片失败';
}
}
PhotoViewPicker 是系统提供的图片选择器,弹出一个半屏面板让用户从相册选图。MIMEType 限定只选图片(不选视频),maxSelectNumber = 1 限定只选一张。
返回的 result.photoUris 是 URI 数组,格式类似 file://media/Photo/xxx。这个 URI 不能直接给 Image 组件用(Image 组件支持 file:// 本地路径,但不支持媒体库 URI),需要转换。
URI → fd → ImageSource → PixelMap
private async loadImage(uri: string): Promise<void> {
try {
let fileSource = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
this.inputImage = await imageSource.createPixelMap();
this.outputImage = undefined;
this.isRestored = false;
this.sliderOffset = 0.5;
this.savedFilePath = '';
this.statusText = '已选择照片,点击开始修复';
await fileIo.close(fileSource);
await imageSource.release();
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Load image failed: ${err.code}, ${err.message}`);
this.statusText = '加载图片失败';
}
}
转换链路:URI → fileIo.open 拿到 fd → ImageSource(fd) → createPixelMap()。
为什么不能直接用 URI 创建 ImageSource?因为媒体库的 URI 是一种逻辑标识,不是文件系统路径。fileIo.open 把它转成文件描述符 fd,ImageSource 才能读取。
两个资源的释放顺序:先 fileIo.close(fileSource) 关闭文件,再 imageSource.release() 释放图像解码器。PixelMap 已经从 ImageSource 中独立出来了,释放 ImageSource 不影响 PixelMap 的使用。
加载完后的状态重置很重要:outputImage = undefined、isRestored = false、sliderOffset = 0.5。如果用户第二次选图但不重置,界面上会残留上一次的修复结果和对比滑块。
超分处理——analyzer.process
private async startRestore(): Promise<void> {
if (!this.inputImage || !this.analyzer) {
this.statusText = '请先选择照片';
return;
}
this.isProcessing = true;
this.statusText = '正在修复中,请稍候...';
try {
let imageData: visionBase.ImageData = {
pixelMap: this.inputImage!
};
let request: visionBase.Request = {
inputData: imageData
};
let response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
this.outputImage = response.pixelMap;
this.isRestored = true;
this.savedFilePath = '';
this.statusText = '修复完成!可滑动对比查看,或保存到相册';
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Process failed: ${err.code}, ${err.message}`);
this.statusText = '修复失败,请重试';
}
this.isProcessing = false;
}
请求结构是标准的 CoreVisionKit 三层封装:
ImageData:包装输入数据,当前只支持pixelMap字段。Request:包装ImageData,后续版本可能扩展配置项(比如超分倍数)。ISPResponse:返回结果,pixelMap是增强后的图像。
为什么用三层而不是直接 analyzer.process(pixelMap)?因为 CoreVisionKit 的所有视觉能力(人脸检测、文字识别、图像超分等)都遵循统一的 Request/Response 模式,多一层抽象方便框架做通用调度(比如 GPU 资源排队、多模型并行等)。
isProcessing 的作用
isProcessing = true 在 process 前设置,isProcessing = false 在 finally 语义的位置(try/catch 之后)设置。它控制两件事:
- 按钮的
enabled状态——处理中禁止重复点击 - 按钮文案——“开始修复"变成"修复中…”
注意它没有放在 finally 块里,而是放在 try/catch 之后。这在 ArkTS 中是等价的——无论 try 还是 catch 执行完,后面的代码都会执行。但如果你后续加了 return 或 throw,就需要用 finally 了。
滑动对比——Stack 叠放 + clip 裁剪 + PanGesture
这是本案例最核心的 UI 部分,效果是:底图层显示修复后的图,上图层显示原图但只露出左半部分,手指左右滑动改变分界线位置,白色竖线标记分界点。
Stack 叠放结构
Stack() {
// 第 1 层:修复后全图(底层)
if (this.outputImage) {
Image(this.outputImage)
.objectFit(ImageFit.Contain)
.width('100%')
.height('100%')
}
// 第 2 层:原图裁剪(上层,只显示左侧 sliderOffset 比例部分)
if (this.inputImage) {
Row() {
Column() {
Image(this.inputImage)
.objectFit(ImageFit.Contain)
.width('100%')
.height('100%')
}
.width(`${this.sliderOffset * 100}%`)
.height('100%')
.clip(true)
}
.width('100%')
.height('100%')
}
// 第 3 层:标签(原图 / 修复后)
// 第 4 层:白色分割线
}
Stack 的层叠顺序:先写的组件在底层,后写的在上层。所以:
- 第 1 层:修复图(全宽显示)
- 第 2 层:原图(被 clip 裁剪,只露左侧部分)
- 第 3 层:标签文字
- 第 4 层:白色竖线
clip(true) 的关键作用
Column() {
Image(this.inputImage)
.objectFit(ImageFit.Contain)
.width('100%')
.height('100%')
}
.width(`${this.sliderOffset * 100}%`)
.height('100%')
.clip(true)
这行的逻辑:Column 的宽度是 sliderOffset * 100%(比如 50%),Image 的宽度是 100%(即 Column 的 100%,也就是 Stack 的 50%)。clip(true) 让 Column 裁剪超出自身范围的内容。
为什么要用 Row 包一层?因为 Stack 里的子组件默认撑满整个 Stack。如果直接给 Column 设 width('50%'),Image 的 width('100%') 是 Column 的 100%,即 Stack 的 50%——图片会被压缩到半宽,不是我们想要的效果。
用 Row 包裹后,Row 撑满 Stack 全宽,Column 只占 Row 的 sliderOffset 比例,Image 在 Column 内是 width('100%')(即 Column 全宽),objectFit(ImageFit.Contain) 保证图片不变形。clip(true) 裁掉 Column 右侧溢出的部分。
换句话说:Image 始终按完整宽度渲染,只是被 Column 裁剪了显示范围。这就是滑动对比效果的核心——原图和修复图都是完整尺寸渲染,裁剪范围不同而已。
标签定位
Row() {
Column() {
Text('原图')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#00000088')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
}
.width(`${this.sliderOffset * 100}%`)
.justifyContent(FlexAlign.Start)
.padding({ left: 12, top: 8 })
Column() {
Text('修复后')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#4A90D988')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
.padding({ right: 12, top: 8 })
}
.width('100%')
.height(40)
.position({ x: 0, y: 0 })
标签 Row 和裁剪 Row 使用相同的 sliderOffset 分割逻辑——左侧"原图"标签跟随裁剪区宽度,右侧"修复后"标签占据剩余空间。position({ x: 0, y: 0 }) 把标签固定在 Stack 顶部。
白色分割线
Column()
.width(2)
.height('100%')
.backgroundColor('#FFFFFF')
.position({ x: `${this.sliderOffset * 100}%`, y: 0 })
.markAnchor({ x: 1 })
分割线是一个 2px 宽、全高的 Column,用 position 绝对定位到 sliderOffset 位置。
markAnchor({ x: 1 }) 是关键——它把定位锚点右移 1px(即线宽的一半),这样分割线精确居中在分界线上,而不是偏左。没有 markAnchor 的话,线的左边缘对齐分界线,视觉上偏左 1px。
PanGesture 驱动 sliderOffset
.gesture(
PanGesture()
.onActionStart(() => {
this.lastOffset = this.sliderOffset;
})
.onActionUpdate((event: GestureEvent) => {
if (this.containerWidth > 0) {
let deltaX = event.offsetX;
let newOffset = this.lastOffset + deltaX / this.containerWidth;
this.sliderOffset = Math.max(0.05, Math.min(0.95, newOffset));
}
})
)
滑动逻辑拆解:
onActionStart:记录开始滑动时的sliderOffset到lastOffset。为什么不用增量累加?因为event.offsetX是从手势开始到当前的累计偏移,不是每帧增量。如果每帧累加增量,手势抖动会导致漂移。onActionUpdate:event.offsetX是从手势起点到当前触摸点的水平偏移(像素)。除以containerWidth转为 0~1 的比例,加到lastOffset上得到新的sliderOffset。Math.max(0.05, Math.min(0.95, newOffset)):钳位到 5%~95%,防止滑到极端值时原图或修复图完全不可见。
containerWidth 通过 onAreaChange 获取:
.onAreaChange((_oldArea: Area, newArea: Area) => {
this.containerWidth = Number(newArea.width);
})
为什么不直接用屏幕宽度?因为图片区域可能不是全屏的(顶部有标题栏、底部有控制面板),用 onAreaChange 获取组件实际宽度更准确。
保存到相册——SaveButton 安全控件 + MediaAssetChangeRequest
为什么用 SaveButton
HarmonyOS 对写入相册有严格权限控制。传统方式需要在 module.json5 声明 ohos.permission.WRITE_IMAGEVIDEO 权限,还要弹授权弹窗让用户同意。SaveButton 是安全控件——系统信任它的点击事件来自真实用户操作,点击时自动获得一次性写入授权,不需要声明运行时权限。
保存流程
SaveButton({
icon: SaveIconStyle.FULL_FILLED,
text: SaveDescription.SAVE_TO_GALLERY,
buttonType: ButtonType.Capsule
})
.onClick(async (_event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result !== SaveButtonOnClickResult.SUCCESS) {
this.statusText = '保存授权失败';
return;
}
let filePath = await this.prepareFileForSave();
if (filePath === '') {
this.statusText = '文件准备失败';
return;
}
try {
let context: Context = getContext(this) as common.UIAbilityContext;
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, filePath);
await phAccessHelper.applyChanges(assetChangeRequest);
this.statusText = '已保存到相册';
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Save failed: ${err.code}, ${err.message}`);
this.statusText = '保存失败,请重试';
}
try {
fileIo.unlinkSync(filePath);
} catch (_e) {}
})
保存分三步:
result校验:SaveButtonOnClickResult.SUCCESS表示用户真实点击,系统授权成功。如果不是 SUCCESS,后续写入操作会被拒绝。prepareFileForSave:把 PixelMap 打包成 JPEG 文件写入应用沙箱。MediaAssetChangeRequest.createImageAssetRequest+applyChanges:把沙箱文件导入系统相册。
prepareFileForSave 详解
private async prepareFileForSave(): Promise<string> {
if (!this.outputImage) {
return '';
}
try {
let context: Context = getContext(this) as common.UIAbilityContext;
let imagePacker = image.createImagePacker();
let packOpts: image.PackingOption = {
format: 'image/jpeg',
quality: 95
};
let packingData: ArrayBuffer = await imagePacker.packing(this.outputImage!, packOpts);
let fileName = `PhotoRestored_${Date.now()}.jpg`;
let filePath = `${context.filesDir}/${fileName}`;
let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.writeSync(file.fd, packingData);
fileIo.closeSync(file);
await imagePacker.release();
return filePath;
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Prepare file failed: ${err.code}, ${err.message}`);
return '';
}
}
这个方法做了 PixelMap → JPEG 文件的转换:
ImagePacker.packing(pixelMap, option)把 PixelMap 编码为 JPEG 的 ArrayBuffer。quality: 95是 JPEG 压缩质量,95 是高质量档位。- 文件名用
Date.now()时间戳,避免重复。 context.filesDir是应用沙箱的文件目录,写入后其他应用不可见。fileIo.openSync/writeSync/closeSync是同步写入,因为数据量不大(一张 JPEG),异步反而增加复杂度。
临时文件清理
try {
fileIo.unlinkSync(filePath);
} catch (_e) {}
applyChanges 把沙箱文件复制到系统相册后,沙箱里的临时文件就没用了,unlinkSync 删除它。放在 catch 里吞掉异常,因为清理失败不影响主流程(保存已经成功了)。
完整源码
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
import { picker } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';
const DOMAIN = 0x0000;
const TAG = 'PhotoRestoration';
@Entry
@Component
struct PhotoRestoration {
@State inputImage: PixelMap | undefined = undefined;
@State outputImage: PixelMap | undefined = undefined;
@State statusText: string = '请选择一张老旧照片';
@State isProcessing: boolean = false;
@State isRestored: boolean = false;
@State sliderOffset: number = 0.5;
@State containerWidth: number = 0;
@State savedFilePath: string = '';
private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null;
private inputUri: string = '';
private lastOffset: number = 0.5;
async aboutToAppear(): Promise<void> {
try {
this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer created');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Failed to create analyzer: ${err.code}, ${err.message}`);
}
}
async aboutToDisappear(): Promise<void> {
if (this.analyzer) {
try {
await this.analyzer.destroy();
hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer destroyed');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Failed to destroy analyzer: ${err.code}, ${err.message}`);
}
}
}
private async selectImage(): Promise<void> {
try {
let options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
let photoPicker = new picker.PhotoViewPicker();
let result: picker.PhotoSelectResult = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
let uri = result.photoUris[0];
this.inputUri = uri;
await this.loadImage(uri);
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Select image failed: ${err.code}, ${err.message}`);
this.statusText = '选择图片失败';
}
}
private async loadImage(uri: string): Promise<void> {
try {
let fileSource = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
this.inputImage = await imageSource.createPixelMap();
this.outputImage = undefined;
this.isRestored = false;
this.sliderOffset = 0.5;
this.savedFilePath = '';
this.statusText = '已选择照片,点击开始修复';
await fileIo.close(fileSource);
await imageSource.release();
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Load image failed: ${err.code}, ${err.message}`);
this.statusText = '加载图片失败';
}
}
private async startRestore(): Promise<void> {
if (!this.inputImage || !this.analyzer) {
this.statusText = '请先选择照片';
return;
}
this.isProcessing = true;
this.statusText = '正在修复中,请稍候...';
try {
let imageData: visionBase.ImageData = {
pixelMap: this.inputImage!
};
let request: visionBase.Request = {
inputData: imageData
};
let response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
this.outputImage = response.pixelMap;
this.isRestored = true;
this.savedFilePath = '';
this.statusText = '修复完成!可滑动对比查看,或保存到相册';
hilog.info(DOMAIN, TAG, 'Super resolution completed');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Process failed: ${err.code}, ${err.message}`);
this.statusText = '修复失败,请重试';
}
this.isProcessing = false;
}
private async prepareFileForSave(): Promise<string> {
if (!this.outputImage) {
return '';
}
try {
let context: Context = getContext(this) as common.UIAbilityContext;
let imagePacker = image.createImagePacker();
let packOpts: image.PackingOption = {
format: 'image/jpeg',
quality: 95
};
let packingData: ArrayBuffer = await imagePacker.packing(this.outputImage!, packOpts);
let fileName = `PhotoRestored_${Date.now()}.jpg`;
let filePath = `${context.filesDir}/${fileName}`;
let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.writeSync(file.fd, packingData);
fileIo.closeSync(file);
await imagePacker.release();
return filePath;
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Prepare file failed: ${err.code}, ${err.message}`);
return '';
}
}
@Builder
titleBar() {
Row() {
Text('老旧照片修复')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.backgroundColor('#1A1A2E')
}
@Builder
imageCompareArea() {
Stack() {
if (this.outputImage) {
Image(this.outputImage)
.objectFit(ImageFit.Contain)
.width('100%')
.height('100%')
}
if (this.inputImage) {
Row() {
Column() {
Image(this.inputImage)
.objectFit(ImageFit.Contain)
.width('100%')
.height('100%')
}
.width(`${this.sliderOffset * 100}%`)
.height('100%')
.clip(true)
}
.width('100%')
.height('100%')
}
if (this.isRestored) {
Row() {
Column() {
Text('原图')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#00000088')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
}
.width(`${this.sliderOffset * 100}%`)
.justifyContent(FlexAlign.Start)
.padding({ left: 12, top: 8 })
Column() {
Text('修复后')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#4A90D988')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
.padding({ right: 12, top: 8 })
}
.width('100%')
.height(40)
.position({ x: 0, y: 0 })
}
if (this.isRestored) {
Column()
.width(2)
.height('100%')
.backgroundColor('#FFFFFF')
.position({ x: `${this.sliderOffset * 100}%`, y: 0 })
.markAnchor({ x: 1 })
}
}
.width('100%')
.layoutWeight(1)
.clip(true)
.onAreaChange((_oldArea: Area, newArea: Area) => {
this.containerWidth = Number(newArea.width);
})
.gesture(
PanGesture()
.onActionStart(() => {
this.lastOffset = this.sliderOffset;
})
.onActionUpdate((event: GestureEvent) => {
if (this.containerWidth > 0) {
let deltaX = event.offsetX;
let newOffset = this.lastOffset + deltaX / this.containerWidth;
this.sliderOffset = Math.max(0.05, Math.min(0.95, newOffset));
}
})
)
}
@Builder
placeholderArea() {
Column() {
Text('\u{1F4F7}')
.fontSize(64)
.margin({ bottom: 16 })
Text('点击下方按钮选择老旧照片')
.fontSize(16)
.fontColor('#AAAAAA')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#0D0D1A')
}
@Builder
saveButton() {
Column() {
SaveButton({
icon: SaveIconStyle.FULL_FILLED,
text: SaveDescription.SAVE_TO_GALLERY,
buttonType: ButtonType.Capsule
})
.onClick(async (_event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result !== SaveButtonOnClickResult.SUCCESS) {
this.statusText = '保存授权失败';
return;
}
let filePath = await this.prepareFileForSave();
if (filePath === '') {
this.statusText = '文件准备失败';
return;
}
try {
let context: Context = getContext(this) as common.UIAbilityContext;
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, filePath);
await phAccessHelper.applyChanges(assetChangeRequest);
this.statusText = '已保存到相册';
hilog.info(DOMAIN, TAG, 'Image saved to gallery');
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `Save failed: ${err.code}, ${err.message}`);
this.statusText = '保存失败,请重试';
}
try {
fileIo.unlinkSync(filePath);
} catch (_e) {}
})
}
.layoutWeight(1)
.height(44)
.margin({ left: 12 })
.justifyContent(FlexAlign.Center)
}
@Builder
controlPanel() {
Column() {
Text(this.statusText)
.fontSize(14)
.fontColor('#CCCCCC')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 16, bottom: 12 })
Row() {
Button('选择照片')
.type(ButtonType.Capsule)
.fontColor('#FFFFFF')
.backgroundColor('#4A90D9')
.layoutWeight(1)
.height(44)
.fontSize(16)
.enabled(!this.isProcessing)
.onClick(() => {
void this.selectImage();
})
if (!this.isRestored) {
Button(this.isProcessing ? '修复中...' : '开始修复')
.type(ButtonType.Capsule)
.fontColor('#FFFFFF')
.backgroundColor(this.isProcessing ? '#555555' : '#FF6B35')
.layoutWeight(1)
.height(44)
.fontSize(16)
.margin({ left: 12 })
.enabled(!this.isProcessing && this.inputImage !== undefined)
.onClick(() => {
void this.startRestore();
})
}
if (this.isRestored) {
this.saveButton()
}
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 20 })
if (this.isRestored) {
Text('\u2190 滑动对比新旧照片 \u2192')
.fontSize(12)
.fontColor('#888888')
.padding({ bottom: 16 })
}
}
.width('100%')
.backgroundColor('#16162A')
.borderRadius({ topLeft: 20, topRight: 20 })
}
build() {
Column() {
this.titleBar()
if (this.inputImage) {
this.imageCompareArea()
} else {
this.placeholderArea()
}
this.controlPanel()
}
.width('100%')
.height('100%')
.backgroundColor('#0D0D1A')
}
}
总结
老旧照片修复案例的核心技术点有三块:
- 端侧超分引擎的生命周期:
ImageSRAnalyzer.create()在aboutToAppear中初始化,destroy()在aboutToDisappear中释放。初始化失败时 analyzer 为 null,后续流程需要判空跳过。 - 滑动对比的 Stack 叠放方案:底层全宽修复图 + 上层原图 Row/Column/clip 裁剪 + 白色分割线 position 定位 + PanGesture 偏移量映射。关键理解——Image 始终按完整尺寸渲染,clip 控制可见范围,不是压缩图片宽度。
markAnchor({ x: 1 })让分割线视觉居中。 - SaveButton 安全控件保存链路:安全控件点击授权 → ImagePacker 打包 JPEG 到沙箱 → MediaAssetChangeRequest 导入相册 → unlinkSync 清理临时文件。整条链路不需要
WRITE_IMAGEVIDEO运行时权限,用户体验更流畅。
三个容易踩坑的地方:URI 转 PixelMap 必须走 fd 中转,不能跳过;PanGesture 的 offsetX 是累计值不是增量值,需要配合 lastOffset 计算;SaveButton 的 onClick 回调必须检查 result 参数,否则授权失败时写入操作会被系统静默拒绝。
更多推荐



所有评论(0)