我最近把一个模型资源下载页重新做了一遍。旧版本看起来没什么问题:点“开始下载”,进度条往前走,下载结束后直接打开文件。真正麻烦的是用户把应用切到后台、暂停一次再恢复,或者任务已经完成但页面还保留着旧状态时,页面和系统下载任务会慢慢分叉。

有一次我看到 UI 还停在 68%,系统任务其实已经完成;另一次更隐蔽,文件已经落盘,但旧的 RequestAgent 任务对象没有清理,下一次进入页面又把同一个 TID 当成可恢复任务。

所以这次我不再把重点放在“怎么下文件”,而是处理三个工程点:TID 怎么对账、完成文件怎么从临时态切成正式态、任务结束后谁负责 remove。

Demo 叫 DownloadRecoverLab。最终会话固定为 dl_recover_20261001_15,TID 为 481205,下载 148 MB 的 model_pack_v5.zip,中途恢复 1 次,最终校验通过,临时文件不存在,系统任务也被移除。

一、页面状态不是下载任务状态,TID 才是两边的连接点

RequestAgent 创建任务以后会返回 task.tid。我以前只是把它打印出来,没有真正存进页面状态。这样页面重建后只能根据“进度条是不是 100%”猜任务状态,问题迟早会出现。

现在创建任务后第一件事是保存 TID:

private async createDownload(): Promise<void> {
  const config: request.agent.Config = {
    action: request.agent.Action.DOWNLOAD,
    url: this.downloadUrl,
    mode: request.agent.Mode.BACKGROUND,
    overwrite: true,
    gauge: true,
    saveas: './model_pack_v5.zip.part'
  }

  const task = await request.agent.create(
    getContext(this),
    config
  )

  this.task = task
  this.tid = task.tid
  await this.checkpointStore.saveTid(task.tid)

  task.on('progress', this.onProgress)
  task.on('completed', this.onCompleted)

  await task.start()
}

这段代码解决的是“系统任务和业务页面怎么绑定”。TID=481205 不是我自己生成的业务 ID,而是系统任务标识。正式项目里我还会额外保存业务资源 ID,例如模型版本号 model_pack_v5,两者不要混成同一个字段。

任务对象不持久化,只保存 TID、目标文件名、业务版本和预期摘要。

二、回到前台以后,先 show 对账,再决定是不是 resume

第二个改动解决“页面回来以后不要盲目 resume”。

RequestAgent 提供任务信息查询能力。我会先根据 TID 查看系统侧状态,再映射成页面状态:

private async reconcileTask(): Promise<void> {
  if (!this.tid) {
    return
  }

  const info = await request.agent.show(this.tid)

  switch (info.progress.state) {
    case request.agent.State.PAUSED:
      this.state = 'PAUSED'
      await this.task?.resume()
      this.resumeCount++
      this.state = 'RESUMING'
      break

    case request.agent.State.RUNNING:
      this.state = 'RUNNING'
      break

    case request.agent.State.COMPLETED:
      await this.finalizeCompletedTask()
      break

    default:
      this.state = 'WAITING'
      break
  }
}

关键是“先查,再操作”。页面一进入前台就 resume(),可能遇到系统任务已经完成而 UI 仍走恢复动画。本 Demo 聚焦同进程的后台暂停/恢复,把 TID 对账独立出来;进程被杀后的恢复应另做持久化控制。

三、下载完成不代表文件已经能给业务使用

下载任务先写 model_pack_v5.zip.part。业务层看到 model_pack_v5.zip 时,我希望它一定已经完整且通过校验。

完成回调里我做三件事:校验、原子重命名、移除任务。

private onCompleted = async (): Promise<void> => {
  this.state = 'VERIFYING'

  const tempPath =
    `${this.context.filesDir}/model_pack_v5.zip.part`
  const finalPath =
    `${this.context.filesDir}/model_pack_v5.zip`

  const passed =
    await this.verifyChecksum(tempPath)

  if (!passed) {
    this.state = 'CHECKSUM_FAILED'
    return
  }

  fs.renameSync(tempPath, finalPath)

  this.checksum = 'PASS'
  this.finalFile = 'model_pack_v5.zip'
  this.tempExists = false

  await request.agent.remove(this.tid)

  this.taskRemoved = true
  this.state = 'COMPLETED'
}

官方示例明确提醒:RequestAgent 的任务生命周期需要开发者管理,任务完成后要调用 remove 释放任务对象。这个动作我放在文件校验和重命名之后,而不是 completed 一到就立刻 remove。

因为“系统传输完成”和“业务文件可用”是两层状态,摘要未通过前不能把资源当正式版本。

四、原子落盘之前,失败分支也要有清理规则

checksum 失败时不改名,也不覆盖旧版本。这个 Demo 直接删除 .part:

private handleChecksumFailed(
  tempPath: string
): void {
  if (fs.accessSync(tempPath)) {
    fs.unlinkSync(tempPath)
  }

  this.tempExists = false
  this.state = 'FAILED'
}

失败清理和成功重命名必须互斥。若还要断点续传,应先确认任务已进入最终失败态,再决定是否删除缓存。

五、进度回调只负责展示

进度事件只更新 processed、百分比和速度,不用 processed === total 自己判断完成;业务完成只认 completed 加后续校验。这样能避开进度和最终状态之间的竞态。

六、调试信息只保留能对账的字段

工程把页面、RequestAgent 协调器、TID checkpoint 和文件完成态处理分开。HiLog 重点保留 tid=481205、暂停态、恢复次数、148 MB、checksum、rename 和 remove,方便从一条日志链直接还原任务生命周期。

七、最终运行结果不是“100%”,而是四件事同时成立

最终页面显示:

  • Session:dl_recover_20261001_15
  • TID:481205
  • State:COMPLETED
  • Mode:BACKGROUND
  • Progress:100%
  • Processed:148 MB
  • Resume Count:1
  • Checksum:PASS
  • Final File:model_pack_v5.zip
  • Temp Exists:false
  • Task Removed:true
  • Last Event:15:06:42

真正的完成条件不是 100%,而是:传输完成、校验通过、正式文件存在、任务被回收。

八、几个正式项目里容易忽略的边界

正式项目还要明确 Wi-Fi/蜂窝策略、检查目标目录空间,并处理旧版本正在被读取的情况。若 show 明确提示任务不存在,就把 checkpoint 标记失效并重新创建任务,不要拿陈旧 TID 无限重试。

九、这次真正补上的,是下载任务的“收尾”

这次我把重点放在后半段:恢复前查真实状态、完成后校验文件、最后释放任务。链路固定成:

CREATED → PAUSED → RESUMING → VERIFYING → COMPLETED → REMOVED

稳定下载的关键,是系统任务和业务文件都有明确结束位置。

Logo

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

更多推荐