给一批图片排队做超分时,界面最容易掩盖的并不是计算慢,而是上一批的计算结果比下一批更晚回来。用户先选了三张图,随后修改任务参数,页面已经显示新的批次,后台却还可能交回旧任务的校验信息。如果只写 await taskpool.execute(...),回调顺序就可能替你决定当前状态。这篇文章只聚焦任务清单的轻量签名计算与回包归属,不实现模型推理,更不把示例签名说成图像质量评分。

一、先定义“哪批结果有资格上屏”

演示工程叫 BatchStamp,任务号 BS-029,当前有效批次 B-06。清单中有三个业务 ID:SR-21、SR-22、SR-23。另一份先前提交的批次叫 B-05。为了说明回包竞争,我们预设先提交 B-05,后提交 B-06,但 B-06 首先返回;页面应该只接收 B-06,丢弃之后到达的 B-05。这不是一次真实并发压测,而是一条可以用受控延迟复现的逻辑序列。

真正的超分链路至少还包含图片读取、模型能力调用、产物生成和资源清理。这些环节的 API、输入上限和设备能力必须由具体引擎决定。本文只做推理开始前的清单签名:它能够发现清单变更、给批次建立可追踪标识,却不能证明图片内容一样,更不能替代文件哈希、像素校验或模型输出正确性。

为什么需要两种 ID?BS-029 是用户可见的工作任务,跨多次配置修改仍可以保持不变;B-06 则是一次配置快照的版本。两者混用后,日志中就很难判断晚到回包属于哪次计算。这里规定只要批次变化,代次号也必须增加;界面允许显示最新批次的签名,旧批次只能进入诊断记录。

二、把纯计算留在 TaskPool 的边界内

官方 TaskPool 指导推荐用 @Concurrent 声明可分发的任务函数,再通过 taskpool.Task 和 taskpool.execute() 执行。示例刻意只传入字符串数组,不把 PixelMap、页面组件、数据库连接或原生推理句柄直接传给任务池。这样既能说明并发基础结构,也避开复杂对象跨线程可传输性的额外限制。

下面的签名函数对 ID 串使用一个简单的滚动余数。计算规则固定为按顺序拼接 SR-21|SR-22|SR-23,逐字符 signature=(signature*31+字符码)%9973,演示结果为 6949。它只是一个可读的教学校验码,没有抗碰撞性,不可用于文件完整性、安全认证和正式缓存命中判断。代码解决的是“把轻量计算与当前 UI 生命周期解耦”的第一步。

import { taskpool } from '@kit.ArkTS';

@Concurrent
export function rollingSignature(ids: string[]): number {
  const joined: string = ids.join('|');
  let signature: number = 0;
  for (let i: number = 0; i < joined.length; i++) {
    signature = (signature * 31 + joined.charCodeAt(i)) % 9973;
  }
  return signature;
}

export async function signBatch(ids: string[]): Promise<number> {
  const snapshot: string[] = ids.slice();
  const task: taskpool.Task = new taskpool.Task(rollingSignature, snapshot);
  const value = await taskpool.execute(task);
  return value as number;
}

这段代码不依赖页面状态,返回值只有一个数字,故意减少了线程通信复杂度。slice() 取得一份清单快照;如果后面允许用户拖动任务顺序,就要先决定顺序是否参与签名。现在它参与,所以同一组三张图换一个排列也可能产生不同签名。未来若业务语义是集合而非队列,应在快照时排序,但不能随意改变现有签名规则,否则历史记录无法比较。

还需要提醒一点:TaskPool 是任务调度机制,不是保证后台运行时长的许可。任务被分发到工作线程不意味着应用退后台后就能无限执行,更不能把它当成系统长期后台任务能力。模型推理是否适合 TaskPool,要看引擎是否支持线程间对象传递和实例生命周期,并结合官方能力边界重新设计。

三、完成得早不等于可以提交得早

接下来把“什么时候返回”和“是否仍属于当前批次”分开。以下控制器只保留必要状态:latestSeq 表示当前有效代次,alive 表示页面能否继续接收结果;acceptedBatch、signature 用于展示。每次提交先递增代次,再等待结果。无论 B-05、B-06 谁先完成,都必须经过代次比对才能更新页面。这段代码解决的是过期 Promise 覆盖新 UI,不是中断正在运行的工作线程。

class BatchGate {
  latestSeq: number = 0;
  alive: boolean = true;
  acceptedBatch: string = '';
  signature: number = 0;

  async submit(batchId: string, ids: string[]): Promise<string> {
    const mySeq: number = ++this.latestSeq;
    try {
      const value: number = await signBatch(ids);
      if (!this.alive || mySeq !== this.latestSeq) {
        return `DROP ${batchId} stale`;
      }
      this.acceptedBatch = batchId;
      this.signature = value;
      return `ACCEPT ${batchId} signature=${value}`;
    } catch (error) {
      if (!this.alive || mySeq !== this.latestSeq) return 'DROP stale error';
      return `ERROR ${batchId}: ${String(error)}`;
    }
  }

  invalidate(): void {
    this.alive = false;
    ++this.latestSeq;
  }
}

不要把 mySeq 当作 TaskPool 自带的任务 ID;它是业务层生成的递增序号。即使系统执行完成,mySeq !== latestSeq 时也不能再写当前页。最容易漏的是失败分支:旧批次如果在新批次之后报错,同样不能把错误横幅覆盖当前成功态。因此 catch 里也做代次校验。

alive=false 代表当前控制器退出消费阶段,并非任务已经被物理取消。页面重进时应重新创建控制器,不能把已经失效的对象简单设置回 true,否则还没返回的旧任务可能重新得到写入许可。复杂工程应把 TaskPool 任务句柄纳入专门队列管理,并评估 taskpool.cancel() 对不同执行阶段的实际行为;已经运行的计算是否可协作停止,需要根据任务特性明确,不应凭取消调用直接宣称结束。

四、把业务日志设计成可复盘的因果序列

为了观察门禁是否生效,BatchStamp 预设四个关键事件:21:40:21 dispatch=B-05 ids=3、21:40:22 dispatch=B-06 ids=3、21:40:23 accept=B-06 signature=6949、21:40:24 drop=B-05 stale。这四行仅表示设计好的事件顺序,不是运行环境真实 HiLog。观察点不是哪一秒更快,而是最后一次提交的版本才拥有写入权。

在页面上显示 B-06、三张任务卡片与 6949,但不显示所谓“超分完成耗时”“画质提升倍率”等没有测量的数据。签名只覆盖任务 ID;实际的超分参数——倍率、模型版本、目标尺寸、图像 URI 的有效性——如果会影响产物,也必须纳入正式批次指纹。这里先不虚构这些字段的 SDK 语义。

为了处理页面销毁与重新进入,组件层只持有当前控制器,并在消失时使其失效。以下是第三段核心代码,用来明确 UI 到业务门禁的接线,以及 @State 只能由仍有效的结果更新的原则。

@Entry
@Component
struct BatchStampPage {
  @State statusText: string = 'B-06 · 待校验';
  private gate: BatchGate = new BatchGate();
  private readonly ids: string[] = ['SR-21', 'SR-22', 'SR-23'];

  aboutToAppear(): void {
    this.gate = new BatchGate();
  }
  async verifyCurrent(): Promise<void> {
    const result: string = await this.gate.submit('B-06', this.ids);
    if (this.gate.alive && result.startsWith('ACCEPT')) {
      this.statusText = 'B-06 · 签名 6949';
    }
  }
  aboutToDisappear(): void {
    this.gate.invalidate();
  }
  build() {
    Column({ space: 12 }) {
      Text('BatchStamp · BS-029')
      Text(this.statusText)
      Button('校验当前批次').onClick(() => { this.verifyCurrent(); })
    }.padding(16)
  }
}

这里的 verifyCurrent() 只演示单次 B-06 的入口,前述 B-05/B-06 乱序需要测试用的受控延迟驱动来构造,不能从这个按钮代码推断已经测到乱序。实际工程还要考虑同一页反复点击产生多个 B-06 请求的问题:若不希望重复提交,应禁用按钮,或者把同一批次的请求做合并,并在失败后允许显式重试。示例省略了这些产品细节,避免把一个小门禁写成整个任务平台。

五、用三种失败路径衡量方案的完整度

第一种是“旧批次成功得很晚”。只要 B-05 晚于 B-06,就应该记录 DROP B-05,但不能覆盖 B-06 的签名。第二种是“旧批次在新批次成功后抛异常”。页面不应显示过期错误,但诊断日志仍应说明异常来自哪个批次。第三种是“用户在签名任务返回之前退出页面”。这时可以保留任务内部的计算记录,却不能访问已经消失的页面对象或弹出一个迟到的成功提示。

演示截图把 B-06、SR-21、SR-22、SR-23、签名 6949、失效的 B-05 放在一张屏幕里,时间统一为 21:40。图里没有真实超分产物,只有任务清单校验结果。它的作用是校对文图字段,而不是证明耗时、线程数或后台续航水平。

如果要进一步测试,我会在 DevEco 中先让两个批次按顺序完成,再人为给旧批次增加等待,让它最后返回,然后把页面退出插在中间。每次记录 seq、业务 batchId、任务数、签名与消费决策,不要只打印“TaskPool success”。把三种路径都打通以后,才有资格讨论能否接入真正的模型调用与资源回收。

六、停止在哪里,也是架构的一部分

这套方案解决的是“哪一个异步结果有权更新当前页面”。它不能代替真正的任务取消、图片解码释放、模型资源复用、超分效果验证或设备侧性能评测。校验码 6949 不代表任何安全保证;如果要做正式去重与产物缓存,应根据清单和参数采用合适的哈希算法,并控制输入的可复现性。

更重要的是别混淆“任务完成”和“页面仍需要它”。一个可靠的鸿蒙应用可以让旧任务计算完成,但拒绝消费其结果;也可以在支持安全取消的条件下回收队列工作。两者不是同一件事。官方 TaskPool 文档明确了 @Concurrent、Task 与 execute() 的使用方式及与 Worker 的差异,跨线程对象约束也值得对照阅读。本文未在真实设备执行签名任务,也不主张通过模拟器画面得出 TaskPool 实机结论。

资料核对: 华为 TaskPool 同步任务开发指导、华为 ArkTS 简介与并发能力说明。

Logo

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

更多推荐