ArkTS TaskPool:图像超分任务队列预检限流与过期隔离【鸿蒙心迹】
图片放大功能一旦允许批量选择,界面上最容易出现的麻烦往往不是算法本身,而是任务还没准备好就被连续送了出去。用户选中三张图,立即换一批,又返回上一页;旧请求的回调如果比新请求晚到,就可能把已经清空的任务列表重新填满。更隐蔽的问题是预检也会占用执行资源:尺寸读取、切片估算、格式判断全部同时触发,页面可能因此出现不必要的等待。这篇只处理真正调用图像超分引擎之前的任务调度层。Demo 名为 PixelQueue,页面为 QueuePage.ets,核心文件是 SuperResQueue.ets。文中的预检数据来自设计好的样例输入,不代表已经调用某款超分算法,也不代表手机上实测得到的处理时间或画质。把这个边界放在开头,是为了避免把“预检完成”误写成“图像增强完成”。
## 一、先把三个状态分开业务同事通常只关心三个按钮:“加入队列”“开始处理”“取消”。从工程角度看,这三个动作背后至少涉及三个生命周期:候选图片是否可接受、某次预检是否还属于当前页面、真正的增强任务是否已经交给算法模块。把它们压缩成一个 isLoading 布尔值,后期的异常处理一定会越来越难。PixelQueue 中一条候选任务使用编号 SR-017,原图宽高设置为 2048×3072,规划切片边长为 512。按向上取整的方式,横向四块、纵向六块,共 24 块。这只是规划阶段的整数估算;实际超分算法是否需要重叠边缘、补边、分批合成,必须由后续接入的算法决定,不能把 24 当成最终推理次数。页面采用五个容易被理解的业务状态:waiting 等待预检、checking 正在预检、ready 预检完成、failed 预检失败,以及不再进入当前数据视图的旧代任务。ready 在 UI 上显示为“待算法接入”。它不等于“完成增强”,也不会出现凭空生成的成功率或百分比。限流规则同样很明确:同时运行的预检最多 2 个。这条限制由业务队列负责,而不是猜测 TaskPool 内部会启用几个线程。官方使用规范也提醒开发者依据业务粒度和性能数据控制提交并发;对于很轻的操作,并不意味着拆进线程池必然更快。## 二、让 TaskPool 只接触可传输的输入第一个要解决的问题,是避免把页面对象、@State、图片控件或数据库句柄整个传到工作线程。官方 TaskPool 文档对 @Concurrent 函数、数据序列化和 UI 模块限制已有明确约束。这里让并发函数只接收三个数字,输出一个数字;任务越纯粹,越容易排查序列化错误。ts// SuperResPrecheck.ets:独立模块,不导入ArkUI组件import { taskpool } from '@kit.ArkTS';@Concurrentfunction countTiles(width: number, height: number, edge: number): number { if (width <= 0 || height <= 0 || edge <= 0) { throw new Error('invalid image size'); } return Math.ceil(width / edge) * Math.ceil(height / edge);}export async function precheckImage(width: number, height: number): Promise<number> { const task = new taskpool.Task(countTiles, width, height, 512); return await taskpool.execute(task) as number;}````taskpool.Task` 承载本次并发函数和参数,`taskpool.execute` 返回异步结果。此处没有返回 `PixelMap`,更没有把图像数据放进参数。这样写既减轻跨线程传输成本,也让失败类型较少。真实应用还应在入队前读取文件元数据,并单独核对图片格式、文件大小和可用内存;示例为了突出调度规则,没有假装实现这一整套检查。另外,这个算术任务本身很轻,放进 TaskPool 只是为了演示未来把可并行的预检扩展成独立阶段时的结构,**不是性能优化结论**。如果业务只是做两次整数除法,直接在主线程运行通常更简单。后续真的加入解码或复杂检查时,才应该通过实际耗时和帧稳定性判断任务粒度。## 三、限流和过期结果不能互相替代任务队列需要解决两个不同问题。并发上限防止瞬间提交太多工作;版本号 `epoch` 防止一批已经失效的异步结果回写当前页面。假设当前批次是 `epoch=3`,用户点“清空并换图”时提升到 4。此时旧任务可以继续把其计算过程结束,但结果不再允许修改新批次的列表。这里采用**逻辑失效**,并未宣称底层 TaskPool 任务被物理取消。下面的队列保留最核心的调度逻辑;`onUpdate` 是交给页面的一条通知通道,生产环境可替换成状态仓库或事件总线。tsimport { precheckImage } from ‘./SuperResPrecheck’;export interface PreviewJob { id: string; width: number; height: number; epoch: number; state: string; tiles: number;}export class SuperResQueue { private active: number = 0; private epoch: number = 3; private readonly maxActive: number = 2; private waiting: PreviewJob[] = []; onUpdate: (job: PreviewJob) => void = (_job: PreviewJob) => {}; currentEpoch(): number { return this.epoch; } add(job: PreviewJob): void { this.waiting.push(job); this.pump(); } reset(): void { this.epoch++; this.waiting = []; } private pump(): void { while (this.active < this.maxActive && this.waiting.length > 0) { const job = this.waiting.shift(); if (job === undefined) { break; } this.active++; job.state = ‘checking’; this.onUpdate(job); void this.evaluate(job); } } private async evaluate(job: PreviewJob): Promise { try { const count = await precheckImage(job.width, job.height); if (job.epoch !== this.epoch) { return; } job.tiles = count; job.state = ‘ready’; this.onUpdate(job); console.info([Queue] ${job.id} tiles=${count} epoch=${job.epoch} state=ready); } catch (error) { if (job.epoch === this.epoch) { job.state = ‘failed’; this.onUpdate(job); } } finally { this.active–; this.pump(); } }}这段代码值得注意的不是 `while`,而是 `finally`。不论工作线程返回成功、抛错,还是结果已经失效,活动计数都必须回收,否则剩余的等待项可能一直卡住。`reset()` 只清空等待列表并递增代次;已经执行中的任务会完成其自身生命周期。要实现真正的停止推理,还必须评估算法接口是否提供取消能力、取消是否幂等、底层缓冲是否要释放。不能仅凭页面不再显示,就认为计算已经停止。还有一个工程细节:`onUpdate` 不应在异步回调里直接保存旧页面引用。页面销毁时要解绑;如果业务允许后台继续预检,更适合把队列放在更长生命周期的服务中,再由前台页面订阅。任务池和页面生命周期相同只是本 Demo 的简化选择。## 四、界面只承认当前代次PixelQueue 的展示数据固定如下:任务 ID `SR-017`,图片 `2048×3072`,切片边长 `512`,估算块数 `24`,代次 `3`,并发上限 `2`。DevEco 演示图只展示这组字段和 `[Queue] SR-017 tiles=24 epoch=3 state=ready` 这一条**预期日志样例**,不把它描述成实际设备捕获的 HiLog。页面的关键动作是将业务状态转成用户看得懂的文字,同时在页面消失时解除通知。下面的代码片段展示状态接线,并省略布局装饰。实际的模块拆分可以参考图中的 `entry/src/main/ets/pages/QueuePage.ets` 与 `entry/src/main/ets/service/SuperResQueue.ets`。ts@Entry@Componentstruct QueuePage { @State status: string = ‘待预检’; @State tileCount: number = 0; private queue: SuperResQueue = new SuperResQueue(); aboutToAppear(): void { this.queue.onUpdate = (job: PreviewJob): void => { if (job.id !== ‘SR-017’) { return; } this.tileCount = job.tiles; this.status = job.state === ‘ready’ ? ‘待算法接入’ : ‘正在预检’; }; } aboutToDisappear(): void { this.queue.reset(); this.queue.onUpdate = (_job: PreviewJob): void => {}; } build() { Column({ space: 16 }) { Text(SR-017 · ${this.status}) Text(预估切片:${this.tileCount} 块) Button(‘开始预检’).onClick(() => { this.queue.add({ id: ‘SR-017’, width: 2048, height: 3072, epoch: this.queue.currentEpoch(), state: ‘waiting’, tiles: 0 }); }) }.padding(20) }}```这段接线也暴露出一个现实问题:如果用户能连续点两次“开始预检”,相同任务 ID 仍可能被重复入队。因此完整产品还应加一层基于任务 ID 的去重,或者在 checking 期间禁用按钮。本文聚焦队列入口、回填权限和生命周期,不应拿省略的用户交互防护冒充完整生产代码。## 五、调试应该验证什么检查队列是否可靠,不需要伪造“推理用了 12.4 秒”这样的精确结果。更有效的做法是构造三类可控输入:连续提交三条任务以检查 active 是否始终不超过 2;在第一个任务等待返回时触发 reset() 以确认 epoch=3 的返回不会污染代次 4;让预检函数对不合法尺寸抛错以检查 finally 是否放行下一条。调试日志至少需要包含任务 ID、代次、状态和预检输出。推荐把业务日志格式统一为 [Queue] SR-017 tiles=24 epoch=3 state=ready;真正接入时再补错误码和耗时。日志文字和插图中的样例完全一致,方便读者对照,但插图是示意画面,并不是现场运行证据。
## 六、这条边界划清以后,扩展反而简单预检队列不能代替超分引擎,也不负责产生清晰度对比图。真正接入模型或系统图像能力后,需要另外回答输出尺寸、像素格式、显存/内存预算、图像裁块重叠、合成误差和失败重试是否安全等问题。旧版 HiAI 文档中的某些能力及数值不应直接等同于当前 HarmonyOS ArkTS 可用接口;具体可用性要按目标设备与官方能力文档重新确认。如果只能记住一个工程判断,我倾向于这句:用业务代次约束结果回填,用限流约束资源争用,别让 UI 的消失被误解为任务已经取消。 只有三者分别成立,批量图像任务才有继续往真实超分处理链扩展的空间。参考资料:华为开发者文档《同步任务开发指导(TaskPool和Worker)》、 《TaskPool使用规范》(2026-08、2026-09 修订):https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/sync-task-development 、https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/task-pool-usage-guidelines 。
更多推荐


所有评论(0)