相册里看着正常的照片,进入批量处理队列后却横躺着;更麻烦的是,处理到第 60 张附近内存开始持续上涨。两个现象最后指向了同一件事:不能把“显示正确”和“像素已经归一”当成一回事。

一、相册没错,导出的缩略图错了

这次的 Demo 叫 PhotoNormalize Lab,业务很小:从一组巡检照片中生成统一方向的 1440 px 长边预览图,供离线报告使用。会话 ID 是 photo_20261001_11,输入 96 张 JPEG/HEIF,里面有 27 张带非 1 的 EXIF Orientation。单张预览全部正常,但批量导出后有 9 张旋转 90°,还有 3 张出现左右镜像。

第一反应是给 ArkUI 的 Image 组件加自动方向。这样页面确实正常了,导出的文件却没有任何变化,因为组件只决定怎么显示,它不会替我们重写像素。报告服务读取的是重新编码后的文件,那里仍是未经变换的原始像素。

第二个症状更像资源问题:并发开到 6 时,峰值内存达到 612 MB,队列第 63 张偶发创建 PixelMap 失败。日志里每个任务都打印了“完成”,但完成只代表文件写完,并不代表 ImageSource、PixelMap 和 ImagePacker 已释放。我们需要一条明确的生命周期链,而不是等 GC 猜什么时候合适。

最终结果是:96 张全部完成,27 张方向归一,失败重试 0;并发限制为 3,峰值内存降到 238 MB;资源计数 source=0 / pixelMap=0 / packer=0,状态为 CLOSED。这篇记录的是从 EXIF 语义到像素变换,再到资源闭环的完整过程。

二、先读取方向,不急着解码全图

EXIF Orientation 不是简单的旋转角度。1 表示无需变换,3/6/8 对应常见旋转,2/4/5/7 还包含镜像。如果代码只处理 6 和 8,竖拍图大多能过,但前置摄像头或编辑软件写入的镜像方向仍会出错。

当前要解决的是“在分配大块像素内存之前先决定处理计划”。下面的探测函数只创建 ImageSource、读取图片信息和 image.PropertyKey.ORIENTATION,把字符串值规整成 1~8。无论读取成功还是抛错,都在 finally 中释放 Source。

import { image } from '@kit.ImageKit'

export interface ProbeResult {
  path: string
  width: number
  height: number
  orientation: number
}

export async function probeImage(path: string): Promise<ProbeResult> {
  const source = image.createImageSource(path)
  try {
    const info = await source.getImageInfo(0)
    const raw = await source.getImageProperty(image.PropertyKey.ORIENTATION)
    const orientation = Number.parseInt(raw, 10)
    return {
      path,
      width: info.size.width,
      height: info.size.height,
      orientation: orientation >= 1 && orientation <= 8 ? orientation : 1
    }
  } catch (err) {
    hilog.warn(0x0000, 'PhotoNormalize', `probe fallback path=${path}`)
    return { path, width: 0, height: 0, orientation: 1 }
  } finally {
    source.release()
  }
}

探测阶段只产出小对象,不把 PixelMap 带出方法。方向字段不存在或格式异常时回退为 1,意味着“不主动变换”,而不是让整个批次失败。这里的边界要说清楚:回退可以保证队列继续,但不能证明图片本身一定正向,所以结果页会把它标成 metadataFallback,正式项目可要求人工抽检。

getImageProperty 依赖源图包含可读 EXIF,JPEG、PNG、HEIF 的支持范围和设备解码能力也可能不同。源文件来自临时 URI 时,应先在授权有效期内打开并交给 ImageSource,不能把 URI 字符串存进队列后隔天再处理。Demo 使用应用沙箱路径,是为了把权限变量从本次问题里拿掉。

三、把八种方向折成可审计的变换表

开始时我们写了几个 if:6 旋转 90°,8 旋转 270°,3 旋转 180°。后来遇到 Orientation=5 的样本,导出结果方向对了,文字却镜像。继续补条件很快会失控,所以改成数据表,让每个方向值都映射到“旋转 + 水平/垂直翻转”。

下面这段代码解决像素真正归一的问题。createPixelMap 时先按 1440 px 长边计算目标尺寸,随后按计划调用变换;完成后返回的 PixelMap 已经是方向 1 的视觉结果,后续编码不再依赖 EXIF。

interface TransformPlan {
  rotate: number
  flipX: boolean
  flipY: boolean
}

const PLAN: Record<number, TransformPlan> = {
  1: { rotate: 0, flipX: false, flipY: false },
  2: { rotate: 0, flipX: true, flipY: false },
  3: { rotate: 180, flipX: false, flipY: false },
  4: { rotate: 0, flipX: false, flipY: true },
  5: { rotate: 90, flipX: true, flipY: false },
  6: { rotate: 90, flipX: false, flipY: false },
  7: { rotate: 270, flipX: true, flipY: false },
  8: { rotate: 270, flipX: false, flipY: false }
}

export async function normalizePixels(path: string,
  orientation: number): Promise<image.PixelMap> {
  const source = image.createImageSource(path)
  try {
    const info = await source.getImageInfo(0)
    const scale = Math.min(1, 1440 / Math.max(info.size.width, info.size.height))
    const pixelMap = await source.createPixelMap({
      editable: true,
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      desiredSize: {
        width: Math.round(info.size.width * scale),
        height: Math.round(info.size.height * scale)
      }
    })
    const plan = PLAN[orientation] ?? PLAN[1]
    if (plan.flipX || plan.flipY) await pixelMap.flip(plan.flipX, plan.flipY)
    if (plan.rotate !== 0) await pixelMap.rotate(plan.rotate)
    return pixelMap
  } finally {
    source.release()
  }
}

数据在这里经历两次收敛:大图先按长边限制解码,减少峰值内存;八种元数据语义再变成真正的像素方向。对 90° 和 270° 旋转,输出宽高会交换,编码后的文件不应继续携带旧 Orientation,否则某些阅读器会再次旋转。Demo 的导出策略是生成新文件并将方向视为 1,不覆盖原图。

这段代码返回 PixelMap,因此调用者必须成为它的明确所有者。ImageSource 已在内部释放,但 PixelMap 不能在这里释放,否则编码拿到的是失效对象。生命周期所有权必须沿方法签名移动:谁接收资源,谁负责最终释放。正式项目还需要用实拍样本验证 5 和 7 的翻转顺序,不要只靠带箭头的测试图判断。

四、并发不是越高越快,队列要看内存水位

96 张照片全部丢进 Promise.all 时,代码非常短,但会同时创建大量解码器和 RGBA 缓冲。1440×1080 的 RGBA_8888 单张约占 6 MB,还没算源图、编码缓冲和框架开销。并发 6 并没有让总耗时减半,反而触发内存抖动。

当前代码解决两件事:把最大并发固定为 3,并用 try/finally 把 PixelMap 与 ImagePacker 的释放绑到单个任务。即使写文件失败,资源计数也必须回到零。

export async function encodeOne(job: ProbeResult,
  outputPath: string): Promise<void> {
  let pixelMap: image.PixelMap | undefined
  let packer: image.ImagePacker | undefined
  try {
    pixelMap = await normalizePixels(job.path, job.orientation)
    packer = image.createImagePacker()
    const data = await packer.packing(pixelMap, {
      format: 'image/jpeg',
      quality: 88
    })
    await atomicWrite(outputPath, data)
    hilog.info(0x0000, 'PhotoNormalize',
      `encoded orientation=${job.orientation} path=${outputPath}`)
  } finally {
    if (packer) packer.release()
    if (pixelMap) pixelMap.release()
  }
}

export async function runQueue(jobs: ProbeResult[]): Promise<void> {
  const pending = [...jobs]
  const workers = Array.from({ length: 3 }, async () => {
    while (pending.length > 0) {
      const job = pending.shift()
      if (job) await encodeOne(job, buildOutputPath(job.path))
    }
  })
  await Promise.all(workers)
}

每个 worker 同一时间只持有一套 PixelMap 和 Packer,任务完成后才领取下一张。队列从 96 递减到 0,UI 进度则从完成计数推导,不直接用 pending.length,否则失败重试会让进度倒退。Demo 中最大并发是经验值 3,正式产品可以结合设备内存等级和目标尺寸动态计算,但不要在任务执行中频繁改变并发,容易造成抖动和难以复现的峰值。

release() 不应重复调用,也不能依赖组件 aboutToDisappear 统一清理,因为页面退出时后台任务可能仍在编码。资源属于任务,不属于页面;页面只持有取消令牌。收到取消后不再领取新任务,当前正在编码的任务走完 finally,这样不会留下半写文件或悬空句柄。

五、从“看起来正常”改成可量化验收

方向问题最怕人工扫一遍缩略图就宣布完成。我们准备了 8 张带字母 F 的方向基准图,每张分别写入 1~8 的 Orientation,再混入 88 张真实巡检照片。每个输出都重新打开,检查宽高、方向字段、文件长度和摘要,同时把四类资源的活动计数写进 HiLog。

最终一轮数据是:输入 96,完成 96;识别到非 1 方向 27,实际变换 27;错误方向从 12 降到 0;峰值内存从 612 MB 降到 238 MB;平均单张 84 ms,P95 为 131 ms;重试 0。资源面板显示 source 0 / pixelMap 0 / packer 0,任务状态从 PROBING → NORMALIZING → VERIFYING → CLOSED。

手机页面故意没有做成照片墙,而是显示批次、方向分布、当前文件、内存水位和资源计数。它是调试工具,不是相册。第 63 张的失败在修复前很有戏剧性,但工程上更有价值的是结束后四个计数都归零,因为这证明生命周期闭合,而不是“这次刚好没崩”。

六、留给正式项目的几个边界

首先,组件的自动方向适合展示,不等于像素归一。只要产物还要交给服务端、PDF 引擎或第三方库,就应该明确选择:保留原像素并保留 EXIF,还是变换像素并把方向归一为 1,不能两边都做。

其次,原图可能包含定位、设备型号等敏感 EXIF。Demo 输出只保留业务需要的宽高与批次标签,不复制全部元数据。若产品确实需要拍摄时间,应建立白名单,而不是把源文件的全部属性原样带走。

最后,解码能力与格式、设备有关。探测失败要区分“元数据缺失”和“源文件损坏”;编码成功后仍要重新打开产物验证,不能把 packing() 返回当成最终正确。对 HDR 或超大图,应该有独立策略,不能把本次 1440 px SDR 预览链路无限外推。

1. 原子写入比“编码成功”更接近完成

早期实现把 packing() 返回的字节直接写到最终路径,应用在写入中途被杀后,目录里会留下名称正确但内容不完整的 JPEG。下次扫描只检查文件是否存在,于是把坏文件当成缓存命中。现在的 atomicWrite 先写入同目录的 .part 文件,执行刷新并校验长度后再重命名;只有重命名成功,进度才增加。

每个临时文件名包含批次 ID 和任务序号,例如 photo_20261001_11_063.part。应用重启后先扫描残留临时文件:如果对应任务仍在清单里就重新处理,否则删除。这里不能仅凭修改时间清理,因为设备时间可能被调整。正式项目还会把源文件摘要、目标参数和输出摘要写入清单,避免同名源图被替换后仍复用旧预览。

写入失败时,finally 仍会释放 PixelMap 与 Packer,但任务状态不会进入完成,而是标记 WRITE_FAILED。本轮没有触发重试,所以页面显示 0;我们另外注入过一次磁盘空间不足,资源计数仍能在 42 ms 内回到零,临时文件也不会被当成成品。这说明资源闭环与业务成功是两条不同的判断,二者都需要日志。

2. 任务取消不是强行打断解码器

TaskPool 适合把批量任务从 UI 线程搬走,但取消语义仍要自己设计。我们没有在任意 await 点强行销毁正在使用的像素对象,而是采用协作式取消:页面写入 cancelRequested=true,worker 在领取任务前和编码完成后检查;已经进入 packing() 的任务允许走完原子写入与释放,再停止领取下一项。

这样做会让“取消”最多延迟一张照片的处理时间,换来的是确定的资源释放顺序。用户快速离开页面时,UI 解除进度订阅,不再接收 96 次更新,但后台容器保留到三个 worker 全部退出。若业务要求立即停止,还需确认具体解码接口是否提供可安全中断的能力,不能简单把 Promise 丢弃;Promise 不再被等待,并不代表底层工作已经停止。

我们在第 48 张请求取消,最终完成数停在 50:三个 worker 当时各自持有一张任务,其中两张已进入编码。状态按 NORMALIZING → CANCELLING → CLOSED 变化,没有出现 CANCELLED 后仍继续增长的进度。再次启动同一批次时,清单跳过 50 个已校验产物,从第 51 张继续。

3. 校验样本要覆盖语义,而不只是数量

96 张都处理成功并不能证明方向正确。测试集必须包含八种 Orientation、横竖两种原始像素尺寸、带文字的非对称内容,以及没有方向字段的图片。纯风景图即使左右镜像,人眼也可能看不出来;带字母和箭头的基准图可以直接暴露翻转顺序错误。

产物验证时,我们不比较整个 JPEG 字节,因为不同编码器版本可能产生不同压缩结果。验证项是可见尺寸、四角颜色标记、方向字段是否归一、解码是否成功、感知摘要是否落在阈值内。资源验证则独立检查活动计数和峰值内存。内容正确与资源释放分开判定,才能避免“图片都对但跑久了会崩”或者“内存稳定但输出镜像”的假通过。

这次修复最大的收获并不是记住 8 个方向值,而是重新划清三层责任:EXIF 决定源图该如何解释,PixelMap 承担真正的像素变换,队列负责资源何时创建和释放。当这三层各自可观测,横图、镜像和内存上涨就不再是三个零散现象,而是一条能被测试、回放和关闭的处理链。

Logo

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

更多推荐