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

很多 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,马上又输入 ab。ab 的任务先算完,页面已经显示新结果;但是 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 不需要把所有字段都带回来。它只带页面要展示和合并的结果。
interface SearchRowDTO {
id: number
title: string
score: number
category: string
}
interface SearchResultDTO {
requestVersion: number
rows: SearchRowDTO[]
summary: string
}
这样做有几个好处:
- 跨线程传输更清楚;
- 主线程合并时只改需要改的字段;
- 页面不会拿到一堆带方法、带引用、带隐式状态的复杂对象;
- 后续要替换 TaskPool、Worker 或普通 Promise,页面层改动也小。
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
更多推荐



所有评论(0)