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 怎么展示。页面线程拿到结果后,再判断这次结果是不是还有效。
下面这段代码很容易写出来:
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 和发事件,后面加进度、取消、重试、缓存都更容易做稳。
更多推荐



所有评论(0)