图像超分的结果在预览页看着清楚,并不代表这张图已经安全地成为一份可交付文件。用户离开页面、存储空间不足、编码过程中再次点击导出,都可能让预览正常而文件不完整。对图片工具来说,最不该出现的情况是:输出失败后,原始图片却被覆盖,或者列表里出现一个看起来成功、实际无法解码的文件。

这篇不谈超分模型的推理接口,也不重复计算 PixelMap 的内存预算。关注的是另一段链路:已经拿到一份可用的超分结果 PixelMap 后,如何从预览态进入编码态、落到临时路径,再决定是否发布为应用自己的导出文件。示例项目叫 ExportGuard,页面为 ExportReviewPage,示例任务为 EG-071。本文出现的状态、时间与日志全是预先约定的演示夹具,并非真机实测。

一、导出的起点不是“点保存”,而是确定所有权

有些页面在拿到模型返回的图像后,直接把同一个 PixelMap 同时交给预览组件、缩略图生成器和文件编码器。表面上省了复制,实际把三个生命周期绑在了一起:页面隐藏时释放了图像,编码器可能还没有完成异步写入;用户又触发新模型任务时,旧对象还可能被替换。

在 ExportGuard 中,约定模型层把完成的结果交给导出协调器时,导出协调器获得一段明确的只读使用期。在 packToFile() 的 Promise 完成之前,不修改、不裁剪、不释放该 PixelMap。页面只显示任务号与阶段,不负责提前销毁编码器正在读取的输入对象。旧图是否需要克隆,应由上游的资源所有权协议决定,而不是靠导出函数默默复制一份。

示例图像设为 2048×1536,输出选择 JPEG,质量参数为 88。这些数字只是示例配置,不是设备性能实测。文件名 EG-071.jpg 对应应用沙箱中的新副本,绝不拿原图 URI 当写入路径。若源文件来自相册选择器,它还可能是受权限控制的内容 URI,不能当作任意可写本地文件。

二、先让导出计划成为可以检查的数据

业务状态采用 PREVIEW_READY → PACKING → EXPORTED,失败时落到 EXPORT_FAILED。只靠一个 loading 布尔值不够,因为按钮禁用、临时路径清理、弹窗提示与重复请求需要不同的信息。先定义纯数据检查,让 UI 和编码器对同一份计划做决策。

下段 ArkTS 代码只处理业务输入。它拒绝空任务、尺寸异常和没有预览确认的导出,不涉及未验证的系统能力。实际输出路径放到编码时才决定,避免把 URI 和业务状态揉成一个字符串。

export interface ExportPlan {
  taskId: string;
  width: number;
  height: number;
  quality: number;
  previewConfirmed: boolean;
}

export function inspectPlan(plan: ExportPlan): string {
  if (!/^EG-[0-9]{3}$/.test(plan.taskId)) return 'BAD_TASK_ID';
  if (plan.width <= 0 || plan.height <= 0) return 'BAD_SIZE';
  if (plan.width > 8192 || plan.height > 8192) return 'SIZE_REVIEW';
  if (plan.quality < 1 || plan.quality > 100) return 'BAD_QUALITY';
  if (!plan.previewConfirmed) return 'WAIT_PREVIEW_CONFIRM';
  return 'READY_TO_PACK';
}

8192 只是本 Demo 的保守业务阈值,不是 Image Kit 的官方硬上限;不能反过来据此宣布设备支持或不支持某个分辨率。演示夹具 EG-071 / 2048×1536 / quality=88 / previewConfirmed=true 的静态判断为 READY_TO_PACK。若预览未确认,即使 PixelMap 已存在,也不应偷偷落盘。把这层规则做成纯函数,既便于覆盖边界,也避免一启动页面就依赖真机媒体权限。

三、编码完成不等于文件已对外可见

华为 Image Kit 的官方示例给出的流程是 image.createImagePacker()、设置 PackingOption、调用 packToFile() 并等待 Promise。它与“调用以后立即释放 PixelMap”不是同一件事。这里刻意把写入目标放到应用私有 filesDir,不冒充向用户相册保存,也不请求相册写入权限。

下面的核心函数假定传入的是调用方保持有效的 PixelMap,只在此函数内创建并释放 ImagePacker。示例约定任务号 EG-071 在本次导出中不复用;临时文件和最终文件都位于同一沙箱目录。生产工程需要在任务注册阶段为每次导出分配唯一 ID,并持久化去重,防止再次进入时覆盖旧导出文件。文件系统调用的异常需要归入导出失败,不可包装成成功。

import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

export async function commitJpeg(
  ctx: common.UIAbilityContext, source: image.PixelMap,
  taskId: string): Promise<string> {
  const finalPath = `${ctx.filesDir}/${taskId}.jpg`;
  const pending = `${finalPath}.pending`;
  const out = fs.openSync(pending, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
  const packer = image.createImagePacker();
  let fileClosed: boolean = false;
  let committed: boolean = false;
  try {
    await packer.packToFile(source, out.fd, {
      format: 'image/jpeg', quality: 88
    });
    fs.closeSync(out);
    fileClosed = true;
    fs.renameSync(pending, finalPath);
    committed = true;
    return finalPath;
  } finally {
    if (!fileClosed) { try { fs.closeSync(out); } catch (_) {} }
    if (!committed) { try { fs.unlinkSync(pending); } catch (_) {} }
    packer.release();
  }
}

pending 的作用是区分“还在写”和“可以出现在应用导出清单里”的文件;renameSync 成功后才更新业务记录。这是同目录内的提交式组织方法,不等于向任意文件系统承诺断电级原子持久化。真正需要强一致性时,还要结合文件同步、数据库事务和恢复扫描策略,不能把一次重命名当成通用事务。

编码器在 finally 中释放;输入的 PixelMap 不在此释放,因为它的所有权仍属于调用方。若图片尺寸大或设备负载重,压缩耗时可能比页面动画长,切忌 UI 离开就无条件销毁输入。这里也不使用未经验证的“ImagePacker 直接输出 ZIP”能力;ImagePacker 处理的是图像编码,不是归档压缩工具。

四、按钮要反映任务事实,不要伪造已完成

用户看到的主要操作是“导出 JPG”,进入 PACKING 后按钮禁用;编码与提交都完成才显示 EXPORTED。为了让界面结构尽量简单,示例页面只负责发布状态,不伪称自己生产了超分图。resultPixelMap 必须由真实模型或上游测试资源注入;没有它时,页面应保持预览待就绪,而不是用占位图调用编码器。

第三段代码展示状态门禁与异常处理。runExport() 是一个页面级协调方法,在业务层串接上面的 inspectPlan() 和 commitJpeg();若模型任务已经被取消,应再增加任务代次检查,阻止迟到响应改写新任务的结果。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct ExportReviewPage {
  @State phase: string = 'PREVIEW_READY';
  @State message: string = '等待导出确认';
  private resultPixelMap: image.PixelMap | undefined = undefined;

  async runExport(): Promise<void> {
    if (this.phase === 'PACKING' || !this.resultPixelMap) return;
    const plan: ExportPlan = {
      taskId: 'EG-071', width: 2048, height: 1536,
      quality: 88, previewConfirmed: true
    };
    if (inspectPlan(plan) !== 'READY_TO_PACK') return;
    this.phase = 'PACKING';
    try {
      const ctx = getContext(this) as common.UIAbilityContext;
      await commitJpeg(ctx, this.resultPixelMap, plan.taskId);
      this.phase = 'EXPORTED';
      this.message = 'EG-071.jpg 已写入应用沙箱';
    } catch (error) {
      this.phase = 'EXPORT_FAILED';
      this.message = '导出未完成,请保留原图';
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('ExportGuard · 超分结果导出')
      Text(`任务 EG-071 · ${this.phase}`)
      Text(this.message)
      Button('导出 JPG').enabled(this.phase !== 'PACKING')
        .onClick(() => { this.runExport(); })
    }.padding(20)
  }
}

这段代码是可审阅的关键接线示例,不是承诺可独立编译的完整工程:resultPixelMap 的接入、发布清单的持久化和用户相册保存入口都需要实际产品另行实现。特别要注意,界面上的 EG-071.jpg 是展示用逻辑文件名;上面函数实际返回的是带时间戳的路径,生产界面应从返回值读取真实名称,不要硬编码显示结果。本例将 EG-071 视为只执行一次的演示任务,因而文案与实际目标文件名一致。正式产品应读取 commitJpeg() 的返回值显示文件名,并在开始任务前完成唯一任务号或已有文件的判重,不能把本例当成可任意重试的防覆盖机制。

五、把三类失败拆开观察

调试时先看有没有进入 PACKING,再看 packToFile() 是否完成,最后看临时文件是否成功改名。把所有异常都写成“保存失败”会让下一位排查的人无从判断。可以在本地测试夹具中分别模拟输入 PixelMap 无效、写入空间不足和最终提交失败,逐条验证状态是否回到 EXPORT_FAILED,残留 .pending 是否被清理,以及原始内容 URI 是否保持未写。

演示日志约定为 EG-071 READY_TO_PACK、EG-071 PACKING quality=88、EG-071 EXPORTED file=EG-071.jpg;这三条描述的是设计上的理想顺序,不是实际 HiLog 捕获。技术配图中的手机屏幕只呈现目标导出结果,不把它当成压缩耗时、节省空间比例或 JPEG 画质的实测证明。若程序中真的需要统计时延,应从开始编码到提交成功分开埋点,并保存错误码,不能从模拟器画面推断。

六、这个方案的边界在哪里

第一,沙箱内文件不等于用户可在相册中找到的图片;公开保存应调用官方建议的用户文件操作能力,考虑授权与目的地的差异。第二,质量参数 88 不代表输出字节数固定,透明度、色彩信息和原图细节都会影响编码结果。第三,PixelMap 的释放权始终由约定的一方持有,复制一个变量引用不等于复制图像资源。

最后,版本判断以项目使用的 HarmonyOS SDK 类型声明和运行设备支持为准。本文可核对的是 Image Kit 编码 API 的基本调用与资源释放顺序,不是某款设备的性能背书。若在发布工程中补充并行任务和取消操作,应同步引入任务代次、唯一目标名与恢复扫描,而不应仅在按钮上多加一个 loading 状态。

参考一手资料: 华为《Using ImagePacker to Encode Pictures》、Image Kit《常见错误实践》、《用户文件概述》。本文所有图像均为合成教学演示,不构成真实运行证据。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐