☰
HarmonyOS 7 Image Kit + TaskPool:批量照片 EXIF 朝向归一与 PixelMap 资源闭环【鸿蒙心迹】
2026/10/4 21:01:03 网站建设 项目流程

相册里看着正常的照片,进入批量处理队列后却横躺着;更麻烦的是,处理到第 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'exportinterfaceProbeResult{path:stringwidth:numberheight:numberorientation:number}exportasyncfunctionprobeImage(path:string):Promise<ProbeResult>{constsource=image.createImageSource(path)try{constinfo=awaitsource.getImageInfo(0)constraw=awaitsource.getImageProperty(image.PropertyKey.ORIENTATION)constorientation=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。

interfaceTransformPlan{rotate:numberflipX:booleanflipY:boolean}constPLAN: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}}exportasyncfunctionnormalizePixels(path:string,orientation:number):Promise<image.PixelMap>{constsource=image.createImageSource(path)try{constinfo=awaitsource.getImageInfo(0)constscale=Math.min(1,1440/Math.max(info.size.width,info.size.height))constpixelMap=awaitsource.createPixelMap({editable:true,desiredPixelFormat:image.PixelMapFormat.RGBA_8888,desiredSize:{width:Math.round(info.size.width*scale),height:Math.round(info.size.height*scale)}})constplan=PLAN[orientation]??PLAN[1]if(plan.flipX||plan.flipY)awaitpixelMap.flip(plan.flipX,plan.flipY)if(plan.rotate!==0)awaitpixelMap.rotate(plan.rotate)returnpixelMap}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 的释放绑到单个任务。即使写文件失败,资源计数也必须回到零。

exportasyncfunctionencodeOne(job:ProbeResult,outputPath:string):Promise<void>{letpixelMap:image.PixelMap|undefinedletpacker:image.ImagePacker|undefinedtry{pixelMap=awaitnormalizePixels(job.path,job.orientation)packer=image.createImagePacker()constdata=awaitpacker.packing(pixelMap,{format:'image/jpeg',quality:88})awaitatomicWrite(outputPath,data)hilog.info(0x0000,'PhotoNormalize',`encoded orientation=${job.orientation}path=${outputPath}`)}finally{if(packer)packer.release()if(pixelMap)pixelMap.release()}}exportasyncfunctionrunQueue(jobs:ProbeResult[]):Promise<void>{constpending=[...jobs]constworkers=Array.from({length:3},async()=>{while(pending.length>0){constjob=pending.shift()if(job)awaitencodeOne(job,buildOutputPath(job.path))}})awaitPromise.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 承担真正的像素变换,队列负责资源何时创建和释放。当这三层各自可观测,横图、镜像和内存上涨就不再是三个零散现象,而是一条能被测试、回放和关闭的处理链。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询