HarmonyOS TaskPool EventHub 边界

TaskPool 的价值很明确:把耗时逻辑从页面线程挪出去,让页面先稳住。问题也经常出在这里:代码写着写着,就把后台任务当成页面线程的一部分了。

这篇按 HarmonyOS 5.0.0 及以上版本的 ArkTS 并发开发来写,重点放在 TaskPool、可传递数据和页面线程更新边界,不讨论老项目里把回调对象到处传的写法。

最常见的一种写法,是在 TaskPool 里拿着页面里的 EventHub 去发事件,或者直接改页面状态。刚开始数据少、设备快,看起来没问题;一旦计算量上来,或者页面被返回、重新进入、重复触发,就会出现几类现象:

  • 进度条偶尔不动,最后突然跳到 100%;
  • 旧任务已经过期,但结果又把新页面覆盖了;
  • 列表数据算出来了,UI 却没有跟着刷新;
  • 排查日志里能看到后台算完了,但页面侧没有收到稳定结果。

这不是 TaskPool 本身不稳,而是线程边界没有拆清楚。我的处理原则很简单:TaskPool 只交付可传递的数据,页面线程再决定怎么通知 UI。

先把边界说清楚

在页面里,EventHub 很适合做模块内事件通知,比如“筛选条件变了”“数据刷新完成”“弹窗关闭了”。但它应该站在页面线程这一侧,而不是跟着对象一起传进后台任务。

我会把链路拆成三层:

层级 负责什么 不负责什么
TaskPool 任务 纯计算、清洗数据、聚合结果、生成 DTO 不操作 UI,不发页面事件,不保存组件引用
调度层 创建任务、记录 requestId、接收结果、丢弃过期结果 不把页面状态直接塞进任务
页面层 更新 @State、触发 EventHub、展示进度和错误 不做大批量同步计算

这样拆完以后,后台任务就变成了一个“输入参数 -> 输出结果”的函数。它不关心当前页面还在不在,也不关心 UI 怎么展示。页面线程拿到结果后,再判断这次结果是不是还有效。

错误案例:把 EventHub 直接带进 TaskPool

下面这段代码很容易写出来:

import { taskpool } from '@kit.ArkTS'

@Concurrent
function buildIndexInTask(payload: SearchPayload): SearchIndexResult {
  payload.eventHub.emit('progress', 50)
  const rows = payload.items.map((item) => item.name.toLowerCase())
  payload.eventHub.emit('progress', 100)
  return { rows }
}

这段代码的问题不是语法好不好看,而是 payload 里混进了页面线程对象。后台任务应该拿到的是普通数据,不能依赖页面的事件中心、组件实例、Controller 或回调函数。

如果页面刚好还在,可能只是进度不稳定;如果页面已经返回,旧任务还在跑,就更容易把事件打到一个过期页面上。排查起来会很难,因为问题不一定每次复现。

案例一:搜索索引在后台算,结果回到页面再发事件

先看一个更稳的版本。后台任务只接收数组和关键词配置,只返回 DTO。

import { taskpool } from '@kit.ArkTS'

export interface SearchSourceItem {
  id: string
  name: string
  tags: string[]
}

export interface SearchIndexResult {
  requestId: number
  rows: string[]
  elapsedMs: number
}

@Concurrent
export function buildSearchIndexTask(
  requestId: number,
  items: SearchSourceItem[]
): SearchIndexResult {
  const start = Date.now()
  const rows = items.map((item) => {
    return `${item.id}|${item.name}|${item.tags.join(',')}`.toLowerCase()
  })
  return {
    requestId,
    rows,
    elapsedMs: Date.now() - start
  }
}

页面侧负责调度和接结果:

@State progressText: string = '等待构建索引'
@State indexRows: string[] = []
private currentRequestId: number = 0

async rebuildIndex(items: SearchSourceItem[]): Promise<void> {
  const requestId = ++this.currentRequestId
  this.progressText = '正在构建索引'

  const task = new taskpool.Task(buildSearchIndexTask, requestId, items)
  const result = await taskpool.execute(task) as SearchIndexResult

  if (result.requestId !== this.currentRequestId) {
    return
  }

  this.indexRows = result.rows
  this.progressText = `索引完成:${result.rows.length} 条,耗时 ${result.elapsedMs}ms`
  this.getUIContext().getEventHub().emit('searchIndexReady', result.rows.length)
}

这里 EventHub 仍然可以用,但它只在页面线程使用。TaskPool 不需要知道页面里有什么事件,也不需要知道 UI 怎么更新。

案例二:用户连续点两次刷新,旧任务不能覆盖新结果

第二个问题更隐蔽。用户连续点两次刷新,第一次任务比较慢,第二次任务比较快。如果没有 requestId,第一次任务最后返回时,可能把第二次的新结果覆盖掉。

调度层需要保留一个“当前有效任务”的编号:

class IndexBuildCoordinator {
  private currentRequestId: number = 0

  nextRequestId(): number {
    this.currentRequestId += 1
    return this.currentRequestId
  }

  isCurrent(requestId: number): boolean {
    return requestId === this.currentRequestId
  }
}

页面调用时这样处理:

private coordinator: IndexBuildCoordinator = new IndexBuildCoordinator()

async refreshIndex(items: SearchSourceItem[]): Promise<void> {
  const requestId = this.coordinator.nextRequestId()
  this.progressText = `${requestId} 次刷新开始`

  const task = new taskpool.Task(buildSearchIndexTask, requestId, items)
  const result = await taskpool.execute(task) as SearchIndexResult

  if (!this.coordinator.isCurrent(result.requestId)) {
    this.progressText = `忽略过期结果:${result.requestId}`
    return
  }

  this.indexRows = result.rows
  this.progressText = `${result.requestId} 次刷新完成`
}

这个做法能解决两个问题:

  • 旧任务返回时,不会覆盖新任务结果;
  • 页面上的事件、状态、日志都能对应到同一个 requestId,排查时不用猜。

进度更新怎么做

有些任务确实需要进度,比如导入大量数据、生成搜索索引、批量压缩图片。我的做法不是在 TaskPool 里频繁发 EventHub,而是把任务切成可观察的小段。

如果任务可以分批执行,就让页面层调度多次后台任务:

async buildByChunks(chunks: SearchSourceItem[][]): Promise<void> {
  const requestId = this.coordinator.nextRequestId()
  const rows: string[] = []

  for (let index = 0; index < chunks.length; index++) {
    const task = new taskpool.Task(buildSearchIndexTask, requestId, chunks[index])
    const result = await taskpool.execute(task) as SearchIndexResult
    if (!this.coordinator.isCurrent(requestId)) {
      return
    }

    rows.push(...result.rows)
    this.progressText = `已处理 ${index + 1}/${chunks.length}`
  }

  this.indexRows = rows
  this.getUIContext().getEventHub().emit('searchIndexReady', rows.length)
}

这比在后台任务里一直回调页面稳定。任务粒度稍微变小一点,页面就能在每一批结束时拿到明确进度;如果页面取消或重新进入,也能靠 requestId 丢掉过期结果。

几种方案怎么选

方案 适合场景 风险
页面线程直接算 数据很少、计算很轻 数据量变大后容易卡首屏
TaskPool 返回完整 DTO 搜索索引、批量清洗、聚合统计 结果太大时要控制字段和批次
页面分批调度 TaskPool 需要进度、需要取消、需要分段展示 调度代码稍多,但边界清楚
后台任务拿页面对象发事件 不建议使用 页面对象跨边界,生命周期和线程都难控

我更推荐“TaskPool 返回 DTO + 页面线程发事件”。如果任务很长,再加一层分批调度。这样 UI、事件、状态都留在页面侧,后台任务只负责算。

可以封装成一个小调度器

后面页面多了,不要每个页面都手写 requestId。可以封装一个轻量调度器:

export class LatestTaskGuard {
  private id: number = 0

  start(): number {
    this.id += 1
    return this.id
  }

  accept(id: number): boolean {
    return id === this.id
  }
}

页面侧只保留两句:

const requestId = this.guard.start()
const result = await taskpool.execute(task) as SearchIndexResult
if (!this.guard.accept(result.requestId)) {
  return
}

这类封装不复杂,但能少掉很多偶现问题。尤其是搜索、筛选、导入、批处理这些高频页面,过期结果覆盖新状态是很常见的坑。

本地验证结果

我用一个独立脚本跑了两个场景:

{
  "badCase": {
    "blocked": true,
    "reason": "background task should not emit page event directly"
  },
  "goodCase": {
    "rows": 3,
    "accepted": true,
    "eventCount": 1
  },
  "staleCase": {
    "oldAccepted": false,
    "newAccepted": true
  }
}

这个验证覆盖了三件事:

  • 后台任务不直接发页面事件;
  • 正常任务返回后,由页面线程发出完成事件;
  • 旧任务晚回来时,不会覆盖新的页面状态。

排查时先看四个点

如果 TaskPool 结果回来了但 UI 不更新,我会按这个顺序看:

  • 后台任务有没有混入页面对象、Controller、EventHub 或闭包回调;
  • 返回结果是不是普通 DTO,字段是不是够小、够清楚;
  • 页面侧有没有 requestId,是否可能被旧任务覆盖;
  • EventHub 是不是只在页面线程触发,状态更新是不是只在页面侧发生。

很多 TaskPool 问题不是“线程不会用”,而是“边界没守住”。只要守住一条线:后台任务只算数据,页面线程再更新 UI 和发事件,后面加进度、取消、重试、缓存都更容易做稳。

Logo

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

更多推荐