ArkTS TaskPool 返回数据后 UI 不更新怎么办:Sendable、DTO 和主线程合并边界怎么拆

ArkTS TaskPool 返回数据后 UI 不更新怎么办

很多 HarmonyOS 页面一开始数据量不大,排序、分组、筛选都直接写在页面里,跑起来也没什么问题。后面数据一多,问题就会变得很明显:列表滑动变卡、点击筛选后半天才刷新、旧的计算结果把新的筛选结果覆盖掉,甚至 TaskPool 里算完了,页面就是不动。

这个问题表面看是“异步任务没刷新 UI”,实际要拆成三件事:

  • 子线程负责重计算,不负责改页面状态;
  • TaskPool 返回的数据尽量是轻量 DTO,不要把页面对象、组件对象、复杂 class 一股脑丢过去;
  • 主线程合并结果时要有版本号,避免慢任务回来覆盖快任务。

这篇只围绕这个边界讲清楚,不把它写成 API 摘抄。

先把几个边界说清楚

官方文档里 TaskPool、Worker、Sendable 都有各自的使用范围。落到开发时,可以先按下面这条线判断:

名称 更适合做什么 不适合做什么
TaskPool 短时间、可拆分的后台任务,比如排序、分组、压缩、解析 长驻任务、频繁双向通信
Worker 长时间运行、需要持续通信的后台任务 为一个简单排序专门建一套通信
DTO 跨线程返回给 UI 的轻量数据结果 直接承载页面里的所有状态
Sendable 需要跨并发实例共享的数据结构 用来逃避对象边界,把复杂页面对象强行共享

我的判断方式比较直接:如果后台任务只是为了算出一份“页面可以展示的数据”,优先返回 DTO;如果确实要跨线程共享对象,再考虑 Sendable。不要一上来就把 ViewModel、Repository、组件状态都塞进 TaskPool。

问题怎么发生

看一个很常见的写法:页面上有一个搜索词,用户连续输入,页面每次都去重新筛选、排序、统计。

async onKeywordChange(keyword: string) {
  this.keyword = keyword
  const rows = await buildRowsInTaskPool(this.rawItems, keyword)
  this.visibleRows = rows
}

这段代码最容易出两个问题。

第一个问题是旧任务覆盖新结果。比如用户先输入 a,马上又输入 abab 的任务先算完,页面已经显示新结果;但是 a 的任务慢一点回来,又把页面改回旧结果。

第二个问题是返回对象太重。TaskPool 里如果返回复杂 class,里面带方法、闭包、页面引用,或者混着不可跨线程的数据,后面要么序列化失败,要么主线程拿到后也不知道应该怎么合并。

所以后台任务不要直接“改 UI”,它只应该输出一份干净结果。

案例一:旧任务回来后覆盖新筛选结果

先看错误写法。

async search(keyword: string) {
  const result = await runTask(keyword)
  this.rows = result.rows
  this.summary = result.summary
}

这段代码没有区分“哪个任务是最新的”。只要任务返回,就直接写页面状态。

更稳的写法是给每次请求一个版本号:

class SearchState {
  requestVersion: number = 0
  rows: SearchRowDTO[] = []
  summary: string = ''
  error: string = ''
}

async function search(keyword: string) {
  const version = ++this.state.requestVersion

  try {
    const dto: SearchResultDTO = await runTaskPoolBuildRows(keyword, version)
    if (dto.requestVersion !== this.state.requestVersion) {
      return
    }

    this.state.rows = dto.rows
    this.state.summary = dto.summary
    this.state.error = ''
  } catch (err) {
    if (version === this.state.requestVersion) {
      this.state.error = `${err}`
    }
  }
}

这里的重点不是 requestVersion 这个名字,而是合并结果前必须判断:这份结果是不是当前页面还需要的结果。

本地验证里我模拟了两个任务:

  • 第一个任务慢,代表旧搜索词;
  • 第二个任务快,代表新搜索词;
  • 新任务先合并;
  • 旧任务回来后被忽略。

验证输出如下:

{
  "staleResultCase": {
    "fastMerge": "merged-current-result",
    "slowMerge": "ignored-stale-result",
    "finalFirstId": 3,
    "summary": "fresh:2, basic:1"
  }
}

这个结果说明旧任务没有把页面改回去。页面最后保留的是最新那次计算。

案例二:后台任务失败时,不要把上一轮可用数据清空

另一个容易忽略的点是失败兜底。

有些页面为了“状态干净”,会在发起异步任务时先清空列表:

this.rows = []
this.summary = ''
const result = await runTaskPoolBuildRows(keyword)
this.rows = result.rows

如果这次任务失败,用户看到的就是一个空页面。更糟的是,页面还没有明确告诉用户失败原因。

更好的做法是:发起任务时保留上一轮可用数据,只更新加载态;任务成功后再替换结果,任务失败就显示错误,但不要直接清空旧数据。

async function refreshRows() {
  const version = ++this.state.requestVersion
  this.loading = true

  try {
    const dto = await runTaskPoolBuildRows(this.keyword, version)
    if (dto.requestVersion !== this.state.requestVersion) {
      return
    }

    this.state.rows = dto.rows
    this.state.summary = dto.summary
    this.state.error = ''
  } catch (err) {
    if (version === this.state.requestVersion) {
      this.state.error = '本次计算失败,页面保留上一轮结果'
    }
  } finally {
    if (version === this.state.requestVersion) {
      this.loading = false
    }
  }
}

本地验证也覆盖了这个场景:

{
  "failureCase": {
    "keptRows": 1,
    "keptFirstId": 8,
    "error": "task 4 failed while parsing input"
  }
}

这说明失败任务没有擦掉上一轮可用列表。实际页面里,用户看到的是旧结果加错误提示,而不是突然空白。

DTO 应该长什么样

DTO 不需要把所有字段都带回来。它只带页面要展示和合并的结果。

interface SearchRowDTO {
  id: number
  title: string
  score: number
  category: string
}

interface SearchResultDTO {
  requestVersion: number
  rows: SearchRowDTO[]
  summary: string
}

这样做有几个好处:

  • 跨线程传输更清楚;
  • 主线程合并时只改需要改的字段;
  • 页面不会拿到一堆带方法、带引用、带隐式状态的复杂对象;
  • 后续要替换 TaskPool、Worker 或普通 Promise,页面层改动也小。

Sendable 什么时候再上

Sendable 不是“所有跨线程问题的万能胶”。如果后台任务只是算出一份展示结果,DTO 已经够了。

更适合考虑 Sendable 的场景是:

  • 多个并发实例确实要共享同一份可控数据;
  • 对象结构比较稳定,生命周期清楚;
  • 你能明确说明哪些字段会被共享,哪些字段只能在主线程改;
  • 项目已经有并发访问规范,不会把 UI 状态和后台数据混在一起。

如果只是列表筛选、排序、统计,我更倾向于先用 DTO。它笨一点,但是边界清楚,排查问题也快。

几种方案怎么选

方案 优点 风险 适合场景
页面里直接计算 简单,开发快 数据一多会卡 UI 小列表、低频操作
TaskPool 返回 DTO 边界清楚,容易验证 要写合并逻辑 排序、筛选、分组、解析
Worker 常驻通信 能处理长任务和持续通信 代码结构更重 长时间任务、持续消息
Sendable 共享对象 可做并发共享 边界设计要求高 明确需要共享对象的场景

我的选择顺序是:先判断任务是不是会拖慢主线程;如果会,再判断结果是不是只用于页面展示;如果只是展示,优先 DTO;只有 DTO 解决不了共享问题时,再考虑 Sendable。

可以封装成一个小工具

页面里不要到处手写版本号判断,可以抽一个 helper。

class LatestOnlyRunner<T> {
  private version: number = 0

  async run(task: (version: number) => Promise<T>, apply: (value: T) => void, fail: (err: Error) => void) {
    const current = ++this.version

    try {
      const value = await task(current)
      if (current !== this.version) {
        return
      }
      apply(value)
    } catch (err) {
      if (current !== this.version) {
        return
      }
      fail(err as Error)
    }
  }
}

页面只关心三件事:怎么发起任务、成功后怎么合并、失败后怎么提示。

this.runner.run(
  (version) => runTaskPoolBuildRows(this.keyword, version),
  (dto) => {
    this.rows = dto.rows
    this.summary = dto.summary
  },
  (err) => {
    this.error = err.message
  }
)

这个封装不复杂,但能挡住很多“旧请求回来覆盖新页面”的问题。

以后怎么避免

我的检查清单是这样的:

  • TaskPool 里只做计算,不直接改 UI;
  • 返回结果优先设计成 DTO;
  • 每次异步请求都带版本号;
  • 合并结果前先判断是不是最新请求;
  • 失败时保留上一轮可用数据;
  • 需要共享对象时再考虑 Sendable,不要一开始就把复杂页面对象跨线程传;
  • 文章或代码里提到“性能优化”时,必须能说清楚优化的是主线程卡顿、旧结果覆盖,还是跨线程对象边界。

这样写下来,TaskPool 就不是“把代码丢到后台跑”这么简单,而是把后台计算、结果传输、主线程合并这三件事分清楚。分清楚以后,页面刷新问题就好排查很多。

参考资料

  • HarmonyOS TaskPool 官方文档:https://developer.huawei.com/consumer/en/doc/harmonyos-guides/taskpool-introduction
  • HarmonyOS Sendable 官方文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/sendable-object
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐