前言

手上有些老旧照片,分辨率低、细节模糊、噪点明显,想用 AI 提升画质,又不想调云端接口、处理网络异常。HarmonyOS 的 CoreVisionKit 提供了端侧图像超分辨率能力(imageSuperResolution),输入一张 PixelMap,输出一张分辨率增强后的 PixelMap,全程本地完成,无需联网。

这篇文章围绕一个"老旧照片修复"完整案例,把选图 → 加载 → 超分处理 → 滑动对比 → 保存入库的全链路拆开讲。重点包括:ImageSRAnalyzer 的创建与销毁时机、PhotoViewPicker 选图后的 URI→fd→PixelMap 转换、Stack 叠放 + clip 裁剪实现滑动对比、PanGesture 偏移量到 sliderOffset 的映射、SaveButton 安全控件 + MediaAssetChangeRequest 写入相册。每个环节都会说明"为什么这样写",文末附完整源码。

效果预览

主要流程

整个流程压缩成 5 步:

  1. ImageSRAnalyzer.create() 初始化端侧超分引擎,aboutToAppear 创建、aboutToDisappear 销毁。
  2. PhotoViewPicker 选图 → URI → fileIo.open 拿 fd → ImageSource.createPixelMap() 得到输入 PixelMap。
  3. analyzer.process(request) 执行超分,输出增强后的 PixelMap。
  4. Stack 叠放:底层修复图 + 上层原图 clip 裁剪 + 白色分割线,PanGesture 驱动 sliderOffset 实现滑动对比。
  5. 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 = undefinedisRestored = falsesliderOffset = 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 三层封装:

  1. ImageData:包装输入数据,当前只支持 pixelMap 字段。
  2. Request:包装 ImageData,后续版本可能扩展配置项(比如超分倍数)。
  3. ISPResponse:返回结果,pixelMap 是增强后的图像。

为什么用三层而不是直接 analyzer.process(pixelMap)?因为 CoreVisionKit 的所有视觉能力(人脸检测、文字识别、图像超分等)都遵循统一的 Request/Response 模式,多一层抽象方便框架做通用调度(比如 GPU 资源排队、多模型并行等)。

isProcessing 的作用

isProcessing = true 在 process 前设置,isProcessing = false 在 finally 语义的位置(try/catch 之后)设置。它控制两件事:

  1. 按钮的 enabled 状态——处理中禁止重复点击
  2. 按钮文案——“开始修复"变成"修复中…”

注意它没有放在 finally 块里,而是放在 try/catch 之后。这在 ArkTS 中是等价的——无论 try 还是 catch 执行完,后面的代码都会执行。但如果你后续加了 returnthrow,就需要用 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));
      }
    })
)

滑动逻辑拆解:

  1. onActionStart:记录开始滑动时的 sliderOffsetlastOffset。为什么不用增量累加?因为 event.offsetX 是从手势开始到当前的累计偏移,不是每帧增量。如果每帧累加增量,手势抖动会导致漂移。
  2. onActionUpdateevent.offsetX 是从手势起点到当前触摸点的水平偏移(像素)。除以 containerWidth 转为 0~1 的比例,加到 lastOffset 上得到新的 sliderOffset
  3. 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) {}
  })

保存分三步:

  1. result 校验SaveButtonOnClickResult.SUCCESS 表示用户真实点击,系统授权成功。如果不是 SUCCESS,后续写入操作会被拒绝。
  2. prepareFileForSave:把 PixelMap 打包成 JPEG 文件写入应用沙箱。
  3. 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 文件的转换:

  1. ImagePacker.packing(pixelMap, option) 把 PixelMap 编码为 JPEG 的 ArrayBuffer。quality: 95 是 JPEG 压缩质量,95 是高质量档位。
  2. 文件名用 Date.now() 时间戳,避免重复。
  3. context.filesDir 是应用沙箱的文件目录,写入后其他应用不可见。
  4. 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')
  }
}

总结

老旧照片修复案例的核心技术点有三块:

  1. 端侧超分引擎的生命周期ImageSRAnalyzer.create()aboutToAppear 中初始化,destroy()aboutToDisappear 中释放。初始化失败时 analyzer 为 null,后续流程需要判空跳过。
  2. 滑动对比的 Stack 叠放方案:底层全宽修复图 + 上层原图 Row/Column/clip 裁剪 + 白色分割线 position 定位 + PanGesture 偏移量映射。关键理解——Image 始终按完整尺寸渲染,clip 控制可见范围,不是压缩图片宽度。markAnchor({ x: 1 }) 让分割线视觉居中。
  3. SaveButton 安全控件保存链路:安全控件点击授权 → ImagePacker 打包 JPEG 到沙箱 → MediaAssetChangeRequest 导入相册 → unlinkSync 清理临时文件。整条链路不需要 WRITE_IMAGEVIDEO 运行时权限,用户体验更流畅。

三个容易踩坑的地方:URI 转 PixelMap 必须走 fd 中转,不能跳过;PanGesture 的 offsetX 是累计值不是增量值,需要配合 lastOffset 计算;SaveButton 的 onClick 回调必须检查 result 参数,否则授权失败时写入操作会被系统静默拒绝。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐