作者:李游
这次做的不是"把一张图变清晰"的展示页,而是一个会连续处理十二张旧照片的批任务。单张调用一直正常,批量跑到第七张却开始抖动,偶尔还会把上一张预览留在页面上。真正需要收拾的是 PixelMap 的所有权、内存峰值和结果提交时机。

一、单张成功,不代表批处理可以直接排队
Demo 叫 ClearBatch Lab ,任务编号固定为 sr_20261001_06。输入图统一缩放到 1280×720,再交给图像超分能力生成 2560×1440 的结果。最初的实现很直白:从相册取十二张图,循环创建输入 PixelMap,调用分析器,得到输出后立刻更新预览。
前两张看不出问题。处理到第七张时,页面滚动开始掉帧;第九张偶发能力调用失败;更麻烦的是,失败后列表仍显示"已完成",点进去却是上一张图片。HiLog 中没有业务异常,因为 Promise 的确 resolve 过,只是 UI 持有的对象已经不再属于当前条目。
我把链路按对象拆开后,发现一次任务至少可能同时存在四份大对象:解码后的输入、超分返回值、列表缩略图和当前预览。1280×720 的 RGBA 数据约 3.5 MB,2560×1440 约 14 MB;如果十二项无节制并发,峰值不是十二乘十四这么简单,解码、推理中间态和 UI 引用都会叠加。
这次验收不再用"十二张都能出图"作为终点,而是固定四条数据:12/12 有明确终态,峰值不超过 188 MB,失败降级 1 张,最终只保留 1 个预览 PixelMap。页面离开后,预览也必须释放。
二、先给 PixelMap 建一本账
项目目录没有把所有逻辑塞进页面:engine 隔离超分能力,budget 管内存令牌,pipeline 负责状态机,store 只接收已提交结果。这样做的好处是,UI 重建不会重新创建整条处理链。
text
entry/src/main/ets
├── pages/ClearBatchPage.ets
├── components/SrQueuePanel.ets
├── engine/SrEngineAdapter.ets
├── budget/PixelBudget.ets
├── pipeline/SrBatchPipeline.ets
├── model/SrJob.ets
└── store/PreviewStore.ets
第一个要解决的问题是"并发数相同,图片尺寸不同时内存仍然失控"。固定开两个任务并不等于固定内存,所以预算器按估算字节数发放令牌,而不是按任务个数计数。
ts
export class PixelBudget {
private usedBytes: number = 0
private readonly waiters: Array<() => void> = []
constructor(private readonly limitBytes: number) {}
async acquire(bytes: number): Promise<void> {
while (this.usedBytes + bytes > this.limitBytes) {
await new Promise<void>((resolve) => this.waiters.push(resolve))
}
this.usedBytes += bytes
}
release(bytes: number): void {
this.usedBytes = Math.max(0, this.usedBytes - bytes)
this.waiters.shift()?.()
}
get usedMB(): number {
return Math.round(this.usedBytes / 1024 / 1024)
}
}
acquire() 发生在解码前,申请值包含输入、预计输出和一段安全余量。拿不到令牌的条目保持 WAITING_MEMORY,不会提前解码后再排队。release() 必须放在任务的 finally,成功、失败和取消都走同一出口;如果只在成功分支释放,失败一次以后预算就会永久减少。
这个实现是 Demo 版,因此等待队列只做先进先出。正式项目还要处理页面优先级,例如当前可见条目可以插队,但不能饿死后台任务。另一个边界是单张图片估算值已经超过总预算,此时不能永远等待,应该在入队前直接降采样或拒绝,并给出可理解的原因。
预算估算不能只拿宽乘高。解码目标的像素格式、行对齐、倍率和能力内部的临时缓冲都会改变实际占用。我在 Demo 中用"输入字节 + 输出字节 + 百分之二十五余量"作为保守值,再用实机峰值反推系数。它不是精确的内存分析器,却能把不可控的十二路抢占变成可解释的两到三路推进。如果图片带旋转信息,先读元数据再计算宽高,避免用未旋转尺寸低估输出。
队列面板还区分"等待内存"和"正在处理"。这不是文案细节:用户看到第八张停留在等待,可以继续浏览已有结果;若所有条目都显示处理中,他会把预算限流误判为卡死。调试日志同步记录等待时长,超过三秒时输出当前占用者的条目 ID,便于定位某个任务是否拿到令牌后迟迟没有释放。
三、能力对象包在适配器里,避免页面感知版本差异
Core Vision Kit 在 HarmonyOS 7 / API 26 提供图像超分能力,输出仍以 PixelMap 进入后续显示和保存链路。页面不直接调用版本相关接口,而是依赖 SrEngine。适配器内部负责创建分析器、调用实际能力并统一错误码,这样能力签名变化不会蔓延到队列和 UI。
下面这段代码解决"输入和输出究竟由谁释放"的问题。规则很简单:调用者移交输入对象后,流水线负责释放;返回的输出只有在原子提交成功后才交给 PreviewStore,否则仍由当前方法释放。
ts
async runOne(item: SrItem): Promise<SrResult> {
const estimate = this.estimateBytes(item, 2)
await this.budget.acquire(estimate)
let input: image.PixelMap | undefined
let output: image.PixelMap | undefined
try {
item.state = SrJobState.DECODING
input = await this.decoder.decode(item.uri, 1280, 720)
item.state = SrJobState.ENHANCING
output = await this.engine.enhance(input, 2)
const committed = this.store.commitIfCurrent(item.id, item.generation, output)
if (!committed) {
await output.release()
output = undefined
return { state: SrJobState.STALE, degraded: false }
}
output = undefined
item.state = SrJobState.COMPLETED
return { state: item.state, degraded: false }
} catch (error) {
return await this.fallback(item, error)
} finally {
await input?.release()
await output?.release()
this.budget.release(estimate)
}
}
output = undefined 不是多余赋值,而是所有权转移的标记。提交成功后,PreviewStore 成为唯一持有者;提交失败时,当前方法释放输出。这样不会出现 store 和 pipeline 都释放同一个 PixelMap,也不会因为忘记清空局部变量导致 finally 误释放正在显示的结果。
generation 用来处理同一条目被重新执行的情况。用户在第六张上点了"重试",旧请求晚回来时,commitIfCurrent() 会发现代次不一致并拒绝提交。它可以丢弃晚到结果,却不能替代资源释放,因此拒绝以后仍要主动 release()。
四、降级不是把失败伪装成成功
批任务里有一张图片故意注入能力不可用错误。第一版 catch 里直接返回原图,列表仍标成 COMPLETED,这会让后续质量统计失真。现在把终态拆成 COMPLETED、DEGRADED、FAILED 和 CANCELLED,原图回退属于 DEGRADED,并记录 fallbackCount = 1。
下面的代码解决"降级结果覆盖旧预览"和"重复重试累计对象"的问题。回退图先生成候选对象,只有当前 generation 仍有效才替换;替换完成后释放旧预览。
ts
private async fallback(item: SrItem, reason: unknown): Promise<SrResult> {
const candidate = await this.decoder.decode(item.uri, 1280, 720)
const old = this.store.swapIfCurrent(item.id, item.generation, candidate)
if (old === undefined) {
await candidate.release()
item.state = SrJobState.STALE
return { state: item.state, degraded: false }
}
await old?.release()
item.state = SrJobState.DEGRADED
item.message = this.errorMapper.toMessage(reason)
this.metrics.fallbackCount++
return { state: item.state, degraded: true }
}
这里的"原子"不是数据库事务,而是业务上的不可分割替换:校验代次、换入新对象、取出旧对象必须由 store 在一次同步方法里完成。若先更新 ID、稍后再更新 PixelMap,ArkUI 重绘可能刚好读到半成品。
正式项目不能对所有错误都降级。输入损坏、解码失败没有可展示原图,应进入 FAILED;能力暂时不可用可以回退;用户取消则是 CANCELLED,不应弹错误。错误分类先于 UI 文案,否则"已取消"很容易被统计成失败。
保存到沙箱也放在提交之后。候选输出先写临时文件,校验尺寸和编码结果,再用最终文件名替换;中途失败只删除临时文件,不覆盖上一版可用结果。PixelMap 的原子替换与文件的原子替换是两层事务:前者保证页面不读半成品,后者保证应用重启后不读到零字节文件。两层任意一层失败,条目状态都不能提前标绿。

DevEco Studio 图里,左侧就是上述目录,中间打开 SrBatchPipeline.ets,右侧模拟器显示 ClearBatch Lab ,底部 HiLog 固定为:job=sr_20261001_06、queue=12、input=1280x720、output=2560x1440、done=12/12、fallback=1、peak=188MB、retainedPreview=1 state=COMPLETED。图片与正文使用同一组状态和数值。
五、性能数据要看峰值,也要看峰值之后能否回落
调试时我先跑了无预算版本:十二项同时解码,峰值越过 400 MB,完成后仍停在 230 MB 左右。加入令牌以后,处理速度没有线性下降,因为真正的推理能力本来也不能无限并行;峰值稳定在 188 MB,队列从 WAITING_MEMORY 逐项进入 DECODING。
更关键的是回落。任务完成后保留一个 2560×1440 预览,其余输入、输出候选和缩略图中间态都释放。页面切到后台时不立即销毁当前预览,避免短暂返回重新解码;页面真正离开、store 无观察者后再释放最后一个对象。这个策略把"可见性"和"所有权"分开,生命周期更清楚。
为了验证重复调用,我连续执行十轮,每轮十二张,并在第三轮中途取消。usedBytes 每轮都回到零,retainedPreview 在页面内稳定为一,退出页面后为零。若数字只增不减,即使系统暂时没有 OOM,也说明引用链还没有断干净。
取消测试放在三个阶段:等待预算时取消,任务应从 waiters 移除;解码后取消,要释放输入并归还令牌;能力返回后、提交前取消,则要释放候选输出并拒绝更新预览。第一版只覆盖了第二种,所以列表偶尔会留下永远等待的条目。修复后,取消不是简单设置一个布尔值,而是让每个资源边界都检查同一个 token,并且由统一出口写入终态。
前后台切换也单独测了一轮。短暂进入后台时,正在推理的单项允许完成,但不创建下一项;回到前台后队列继续。若系统回收进程,任务清单从持久化数据恢复,所有未提交条目重新进入待处理,绝不复用上次进程留下的句柄或 PixelMap 引用。这样会多算一次,却比猜测 native 中间态是否仍有效可靠得多。
TaskPool 适合放解码前的元数据计算和纯数据整理,但 PixelMap 是否能跨线程传递要按当前 API 的可传输规则验证,不能把普通对象、NativeBinding 对象和 ArrayBuffer 混为一谈。本 Demo 把能力调用留在受控服务中,只把轻量状态回传页面,减少序列化成本和线程归属误判。
六、手机页保留能解释结果的数据
运行页没有放十二张大图,而是用当前预览、批次进度和资源指标回答三个问题:结果是否真的放大、失败是否被识别、内存是否收住。06:18 的最终页面显示输入 1280×720、输出 2560×1440、12/12 完成、1 张降级、峰值 188 MB、保留预览 1、状态 COMPLETED。

红色细箭头只标出"降级 1 张"和"保留预览 1",因为这两项最容易被一个全绿的完成状态掩盖。用户可以继续点开降级项查看原因,也可以重试单项;重试会增加 generation,不会重新执行整个批次。
七、这条链路真正交付的是可控性
图像超分最容易写成能力展示:选图、调用、显示。但放进相册修复、商品图增强或离线素材处理后,内存、取消、晚到结果和失败分级才决定它能否长期运行。ClearBatch Lab 最终形成了明确边界:预算器决定何时开始,适配器隔离能力,流水线管理所有权,store 只接收当前代次的完整结果。
这套 Demo 仍有边界。它没有实现断点续跑,进程被回收后只保存任务清单,不保存 PixelMap;也没有把峰值阈值写死到所有设备,而是示例采用 188 MB 的观测结果。正式项目应根据设备内存等级、图片格式和输出倍率动态配置,并用真机压力测试校准。
质量验收也不能只放大看锐度。我给每个结果保留输入尺寸、输出尺寸、耗时、降级原因和 generation,方便定位"图变清楚了但条目对应错了"这类数据问题。批任务最终报告中,12/12 表示十二项都有终态,不等于十二项全部使用了超分输出;旁边的 fallback=1 必须一起阅读。这种口径能避免运营侧把降级当成百分之百能力成功率。
我最后留下两条日志断言:任何条目结束后都不能处于 DECODING 或 ENHANCING;页面释放后预算必须为零、预览计数必须为零。十二张图片看起来都变清晰只是功能完成,资源和状态都能收口,才算工程完成。
参考资料: