
一张图放大两倍并不难难的是用户连续换图、取消、退到后台以后页面仍然只接受属于当前任务的结果而且不把已经失效的PixelMap留在内存里。本文用一个可复现的示例工程ScaleQueue拆解这层任务管理。示例数据用于说明状态机并不冒充设备实测任务号为sr_20261001_06输入图poster_720p.jpg尺寸1280 × 720目标为2560 × 1440界面时间统一为12:14。需要先把能力边界说清。HarmonyOS Image Kit 负责图像源解码、PixelMap表示和编码等基础能力本文的SuperResolutionEngine是项目自己的算法适配接口可以连接经验证的本地模型、服务端能力或供应商 SDK。它不是系统内置 API。这样写虽然少了一个“神奇方法”却能让代码在替换算法实现时仍然成立也不会把论坛方案里的特定实现误写成通用系统能力。一、真正难处理的是旧结果而不是放大按钮ScaleQueue的页面叫SuperResolutionPage。队列中有三张图当前处理第 2 张。用户先启动任务进度到 64% 时重新选择一张输入图或者把倍率从 2 倍改成 4 倍。算法调用不会因为界面变了就自动消失旧 Promise 仍可能在稍后完成。如果回调直接给State result赋值旧图会覆盖新图页面显示的任务号和结果像素也会对不上。这类问题常被误判成“异步回调顺序偶发异常”。更准确的说法是调用方没有给结果定义所有权。一次操作至少要有三个身份字段业务任务 ID、页面代次generation、队列项序号。任务 ID 方便排查代次决定回调是否仍有效序号决定进度属于哪一项。三个字段不能互相代替。示例状态采用下面这条路径READY → DECODING → RUNNING → COMMITTING → DONE取消不直接跳回READY而是进入CANCELLED。页面只在旧资源释放完毕后才允许开始新代次。算法适配器如果支持主动取消可以同步通知如果不支持代次检查仍能阻止晚到结果污染页面。1. 先定义项目接口不虚构系统能力这段代码解决超分实现来源不同、返回时序不一致的问题。import{image}fromkit.ImageKit;exporttypeScaleFactor2|4;exportinterfaceScaleRequest{taskId:string;generation:number;factor:ScaleFactor;input:image.PixelMap;}exportinterfaceScaleResult{taskId:string;generation:number;output:image.PixelMap;width:number;height:number;}exportinterfaceSuperResolutionEngine{upscale(request:ScaleRequest,onProgress:(value:number)void):PromiseScaleResult;cancel?(taskId:string):Promisevoid;}接口只描述项目确实需要的契约没有声明image.superResolution()之类未经核对的方法。输入与输出仍使用 Image Kit 的PixelMap算法层因此可以被替换。generation同时放进请求和结果是为了让适配器经过线程、进程或网络边界后仍能原样带回身份只闭包捕获页面变量在复杂实现中很容易丢失上下文。容易忽略的地方是所有权。这里约定调用方拥有输入PixelMap适配器返回的新PixelMap由调用方接管。适配器不能私自释放输入页面也不能在算法读取期间提前释放。若接入的 SDK 会复制输入应在具体适配器文档中修改约定而不是靠猜。二、解码阶段就要建立资源账本输入文件来自应用沙箱或用户授权后的可读位置。ImageSource与PixelMap是两层对象前者负责从数据源创建像素图后者持有可供处理和显示的像素数据。两者的生命周期不同不能因为最终只显示PixelMap就忘了关闭图像源。这段代码解决文件描述符、ImageSource 与 PixelMap 的成对管理问题。import{fileIoasfs}fromkit.CoreFileKit;import{image}fromkit.ImageKit;exportasyncfunctiondecodeInput(path:string):Promiseimage.PixelMap{constfilefs.openSync(path,fs.OpenMode.READ_ONLY);letsource:image.ImageSource|undefined;try{sourceimage.createImageSource(file.fd);constpixelMapawaitsource.createPixelMap({desiredSize:{width:1280,height:720},desiredPixelFormat:image.PixelMapFormat.RGBA_8888});returnpixelMap;}finally{if(source!undefined){awaitsource.release();}fs.closeSync(file);}}try/finally的意义不只是“写得严谨”。创建PixelMap失败时文件仍要关闭创建成功时PixelMap已成为独立资源返回后由上层账本接管。desiredSize在示例中固定为1280 × 720与页面和配图一致但项目里不应盲目把所有输入压成同一宽高。更稳妥的策略是先读取图像信息依据最大边、内存预算和模型输入约束计算尺寸并保留原始宽高比。这里没有把路径直接传给算法。解码集中在仓储层后算法只看到PixelMap页面不会同时承担 URI、文件描述符、色彩格式和任务队列四种责任。若输入来自系统选择器还要遵守授权 URI 的可访问范围不能把临时访问能力当成永久文件路径。这个问题已在上一批素材中单独讨论本篇不重复展开。项目结构按职责拆开而不是按“页面里放所有代码”的方式堆叠pages/SuperResolutionPage.ets渲染任务与按钮。model/ScaleSession.ets维护代次、状态和资源账本。engine/SuperResolutionEngine.ets定义算法适配接口。repository/ImageRepository.ets解码和输出落盘。components/ProgressCard.ets展示任务sr_20261001_06的 64% 进度。下图是示意配图不是 DevEco Studio 实测截图。左侧目录、中间状态收敛代码、右侧模拟器以及底部 HiLog 使用同一组示例字段目的是让调试路径一眼可对照。三、用代次把晚到回调挡在状态之外取消令牌并不总能让底层计算立即停下。很多模型推理一旦提交就只能等待返回。因此页面需要第二道闸门每次开始、换图、改倍率或销毁页面都递增generation。回调只有在代次、任务 ID 和页面存活状态同时匹配时才能提交。这段代码解决连续操作时旧进度和旧结果覆盖当前任务的问题。import{image}fromkit.ImageKit;typeRunStateREADY|DECODING|RUNNING|COMMITTING|DONE|CANCELLED|FAILED;exportclassScaleSession{privategeneration:number6;privatealive:booleantrue;privateinput?:image.PixelMap;privateoutput?:image.PixelMap;state:RunStateREADY;progress:number0;asyncstart(taskId:string,engine:SuperResolutionEngine,input:image.PixelMap):Promisevoid{constminethis.generation;// 本轮为 7this.inputinput;this.stateRUNNING;this.progress0;constresultawaitengine.upscale({taskId,generation:mine,factor:2,input},(value:number){if(this.aliveminethis.generation){this.progressMath.min(100,Math.max(0,value));}});awaitthis.commitIfCurrent(result);}privateasynccommitIfCurrent(result:ScaleResult):Promisevoid{if(!this.alive||result.generation!this.generation){awaitresult.output.release();return;}this.stateCOMMITTING;awaitthis.output?.release();this.outputresult.output;this.progress100;this.stateDONE;}}这一轮从generation6递增到7。当进度为 64% 时只有第 7 代回调能更新界面。假设第 6 代稍后返回它的输出不会进入this.output而是立即release()。这一步很重要丢弃结果不等于释放结果。只写return页面表面上没有串图资源仍可能累积。状态先切到COMMITTING再释放旧输出并接管新输出是为了避免“界面已经宣称完成资源替换却还没结束”的时间缝隙。实际项目还要考虑release()失败后的日志策略。释放异常通常不该把已经得到的新结果改成业务失败但必须带上任务号、代次和资源类型记录便于识别持续泄漏。下面的手机运行画面仍是演示配图。它显示当前队列2 / 3、任务sr_20261001_06、输入与目标尺寸、RUNNING状态和 64% 进度。红色箭头只标代次和进度没有把每个控件都圈一遍。四、取消是一次资源结算不是换个按钮文字页面上的“取消”通常有三种含义用户想停止等待、底层任务确实已停止、资源已经可回收。三者发生时间可能不同。为了避免误导示例点击取消后先递增代次使所有旧回调失效然后尽力通知引擎最后释放当前持有的输入和输出。即使cancel()不可用UI 也不会再接受旧结果。这段代码解决取消、离页与重新开始之间的资源结算问题。exportclassScaleSession{// 其余字段与前文一致asynccancel(taskId:string,engine:SuperResolutionEngine):Promisevoid{this.generation;this.stateCANCELLED;this.progress0;try{awaitengine.cancel?.(taskId);}finally{awaitthis.input?.release();this.inputundefined;awaitthis.output?.release();this.outputundefined;}}asyncdispose(taskId:string,engine:SuperResolutionEngine):Promisevoid{this.alivefalse;awaitthis.cancel(taskId,engine);}}这段实现强调成对关系start()接管输入cancel()或完成后的保存流程释放它commitIfCurrent()接管输出下一次提交、取消或页面销毁释放它。项目中可以把输入在算法返回后更早释放但前提是适配器已经明确不再读取。不能仅凭 Promise 完成就推断某个原生组件没有延迟访问。dispose()先把alive设为false再走取消逻辑。这样即便取消过程中又到一条进度回调它也无法更新页面。ArkUI 组件销毁回调里不适合堆很长的阻塞流程可让 session 自己异步结算并把异常写入统一日志。若引擎持有线程或原生句柄还需要在适配器中增加自己的dispose()与页面 session 的生命周期分层处理。诊断页需要回答四个问题当前是谁、旧结果去了哪里、哪些资源仍被持有、能否安全重试。示例诊断数据固定为generation7状态从RUNNING进入CANCELLED丢弃晚到结果1个输入与输出均显示RELEASED下一步为READY TO RETRY。这比只打印“取消成功”更有用。五、调试时按事件序列核对不盯单条日志任务串图很难靠一条错误日志定位。建议每条日志至少包含taskId、generation、队列序号、状态和事件名。不要把整个图片路径、用户相册名称或模型返回对象全量写进日志既增加噪声也可能暴露不必要的信息。示例的期望序列可以写成START tasksr_20261001_06 gen7 item2/3。PROGRESS tasksr_20261001_06 gen7 value64。用户换图后出现INVALIDATE oldGen7 newGen8。旧计算返回记录DROP_LATE_RESULT gen7 count1。旧输出被释放记录RELEASE output gen7。新任务才有资格进入COMMITTING和DONE。如果只看最后的DONE无法知道中间是否有一次旧图短暂闪现。调试时应把状态转换与资源事件放在同一时间线里。对压力场景可以连续执行“开始—换倍率—换图—取消—返回页面”检查代次只增不减且所有失效输出最终都有释放事件。还有一个常见误区是用进度值判断新旧任务。两个任务都可能到 64%进度不是身份。文件名也不是身份同一文件可以用不同倍率、不同裁剪区域重复处理。只有调用方分配且不复用的代次才能稳定表达“这是当前页面认可的那一次”。六、交付前需要明确的边界这套方案解决的是应用侧任务一致性与资源管理不证明某个超分模型的画质、时延或功耗。算法能力要在目标设备、目标分辨率和目标场景上单独验证。尤其是 4 倍放大输出像素数会显著上升即使模型支持也不代表页面能同时保留输入、输出、预览副本和编码缓存。演示中的1280 × 720 → 2560 × 1440、64% 和丢弃 1 个晚到结果是为了让正文与图片拥有可核对的数据不是性能基准。真正的性能结论至少应记录设备、系统版本、模型版本、冷热启动、输入格式、内存峰值和统计方法。没有这些条件不应写“提升多少”之类数字。上线前还应补齐四项检查一是切后台或页面销毁时能否结算二是编码或保存失败时输出是否仍可释放三是并发上限是否由统一队列控制四是第三方算法或模型的许可证、隐私与数据流向是否明确。任务状态机不能替代这些合规工作但能让责任边界更清楚。最后留下一个判断图像超分页面最值得先写的不是漂亮的比较滑杆而是结果所有权。只要“谁能提交、谁负责释放、何时失效”三件事没有答案增加并发、缓存或动画都会放大偶发问题。把系统 Image Kit 能力与项目算法适配层分开再用代次收住晚到回调页面才有继续扩展的基础。七、把失败拆成能恢复和必须重建两类失败如果只有一个FAILED页面很难决定按钮应该显示“重试”还是“重新选择”。更实用的划分依据不是异常类名而是当前资源还能不能继续用。比如编码输出失败时超分得到的PixelMap仍可能有效用户可以换一个目标路径再次保存输入解码失败时连算法入口都没有应该回到选图模型进程断开或原生上下文失效时本轮 session 往往需要重建。可以给失败事件加三个字段阶段、资源结算情况、建议动作。阶段包括DECODE、INFERENCE、COMMIT、ENCODE资源情况只描述应用现在持有什么不猜底层状态建议动作使用RETRY_CURRENT、RESELECT_INPUT、RECREATE_ENGINE。这些字段适合日志和诊断页不一定全部展示给普通用户。界面文案仍应简洁比如“处理未完成可重试”而不是把内部错误码直接丢出来。以sr_20261001_06为例若算法在 64% 报错session 先核对generation7是否仍为当前代次。如果用户已经换图错误属于旧代次只记录DROP_LATE_ERROR不能把新任务页面改成失败如果仍是当前代次则进入FAILED释放输入并保留可重建引擎的上下文。错误回调与成功回调一样需要身份检查这是不少实现遗漏的地方。重试也不能复用旧 generation。用户点一次重试就是一次新意图必须生成第 8 代。否则第 7 代某个更晚的成功回调仍可能穿过检查。任务 ID 可以保留以便把重试串在同一业务链路中但日志应增加attempt2或新代次两者分别表达“同一个业务任务”和“不同的一次执行”。网络型超分适配器还要考虑上传已经完成、响应没有回来时的取消。应用侧可以停止等待并让第 7 代失效却未必能撤回服务器计算。此时隐私说明、服务端保留策略和计费语义都属于适配器边界不能用一个可选cancel()掩盖。本文的接口允许尽力取消但明确不承诺远端立即停止。八、用一张验收表覆盖时序而不是只点一次成功路径任务管理的缺陷通常需要特定顺序才出现。手工验收至少应覆盖以下组合正常完成进度过程中换倍率进度过程中换输入连续点击开始取消后立即重试算法成功但页面已销毁保存失败后再次保存三项队列在第 2 项失败应用进入后台后恢复。每个组合都检查页面状态、当前代次、持有资源与日志事件而不只是看有没有结果图。正常完成的期望是第 7 代从RUNNING进入COMMITTING再到DONE进度最终为 100旧输出若存在必须先释放。换输入的期望是旧代次失效新输入解码完成后生成新代次旧结果到达时仅增加丢弃计数。连续点击开始则应由按钮禁用或 session 幂等控制不能产生两个都自称当前的第 7 代。页面销毁场景尤其值得单独跑。销毁发生时alivefalse之后无论进度、成功还是失败回调都不能更新 ArkUI 状态。晚到的成功结果要释放晚到错误只写受控日志。如果回调里捕获了整个页面对象即使有alive判断也可能延长页面生命周期适配层最好回调到轻量 session再由页面订阅可见状态。队列第 2 项失败时还要明确第 3 项是否继续。批量修图通常有“失败即停”和“跳过继续”两种策略。ScaleQueue示例采用失败即停因为用户需要先确认失败原因如果产品选择跳过继续队列状态应记录每项结果而不是让总进度从 64% 突然跳到下一张而没有失败标记。验收记录中不建议只写“通过”。更可复核的格式是操作序列、期望状态序列、最终持有资源、是否出现晚到事件、截图或日志片段。例如“开始第 7 代—64% 换图—旧结果返回”对应“第 7 代失效、第 8 代开始、旧输出释放、当前任务不变”。这类记录以后替换算法实现时还能继续复用。九、内存预算应从同时存活的对象推算一张RGBA_8888像素图的基础数据量可以粗略按宽乘高乘四字节估算。1280 × 720约为三点五兆字节2560 × 1440约为十四兆字节但这只是像素缓冲的量级不包括解码器、模型张量、图形纹理、编码缓存和对齐开销。页面同时保留原图、结果图、比较视图副本和算法中间张量时峰值会明显高于两张图相加。因此资源账本不应只有“有没有释放”还要知道“为什么仍要持有”。输入在算法完全结束后如果比较视图只需要输出和缩略图就可以释放原始大图预览可以使用单独的小尺寸PixelMap保存时再使用完整输出批量队列不要提前解码全部原图。每个选择都会影响交互速度与峰值内存应该由场景取舍而不是照搬一个固定数字。还要防止 UI 组件在资源释放后继续引用同一个PixelMap。释放前先让可观察状态脱离该对象等待界面下一次构建接管占位内容再执行释放会更安全。具体时序要结合当前组件和 SDK 行为验证本文不把某个延迟值写成通用答案。重要的是让“从 UI 移除引用”和“释放原生资源”成为一对可追踪事件。当工程引入缓存时缓存也必须参加所有权协议。缓存拿到的是独立副本、共享引用还是可重新解码的文件路径三种策略完全不同。共享PixelMap最容易出现一方释放、另一方仍显示的问题。没有引用计数或明确转移语义时宁可缓存输出文件与缩略图也不要把大像素对象在多个页面之间随意传递。十、参考资料与核对说明HarmonyOS Image 模块 API 参考https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-imageHarmonyOS 图像超分社区主题用于了解方向具体接口仍以当前官方文档与项目依赖为准https://developer.huawei.com/consumer/cn/forum/topic/0208219078052443133HarmonyOS 应用开发入口https://developer.huawei.com/consumer/cn/harmonyos/develop/本文不声明未经设备验证的性能结果。图片均为本篇字段一致的界面演示图不是实际 IDE 或真机测试证据。