1. 主线程卡顿的根因:渲染流水线为什么要“排队”
先还原一个真实到不能再真实的场景:用户丢过来一句话,“你这 APP 一加载就卡成 PPT”。我拿到手机一看,好家伙,列表滚不动,转圈一直转。查了半天,不是网络慢,不是后端挂了,就是代码里一个批量数据处理的大循环,结结实实跑在了主线程上。
为什么主线程不能跑大循环?这不是什么“规范要求”,而是底层机制决定的。
1.1 主线程到底在忙什么
在鸿蒙应用里,UIAbility 所在的主线程几乎是“全栈”的。它要处理用户输入事件,要执行 ArkTS 的业务逻辑,要跑布局计算,要负责绘制渲染,还得每帧去响应 vsync 信号。一个典型的帧周期只有 16.6ms,60Hz 刷新率下留给应用的总预算就这么多。
你随便打开 DevEco Studio 里的 Profiler,录一段操作,就能看到主线程的时间片被分成一堆细碎的片段:输入事件、原生回调、ArkUI 渲染、布局、动画、任务调度。每一块都是“过来排队”的状态。主线程本质上是一个巨大的事件循环,所有任务共享同一条车道。
这时候一个大循环进来,比如“循环 2 万次,每次解析一条 JSON,再逐条建对象塞进数组”,它在主线程上是同步执行的。意味着什么?意味着这个循环不跑完,后面排队的所有任务——包括用户点击事件、列表滚动事件、动画帧回调——全部被挡住。用户手指已经滑了,但事件进不了处理队列,界面自然就僵住了。
1.2 掉帧是怎么“肉眼可见”的
常见的误区是:“我这个循环也就 100ms,不至于多卡吧。” 算一笔账:100ms 意味着在 60Hz 刷新率下,直接丢掉了大约 6 个帧周期。任何投入市场的机型,一旦主线程阻塞 50ms 以上,用户就能感受到反应迟钝;超过 100ms,基本就是“卡死”的体验。
而且 ArkUI 是声明式 UI,状态变量一变,框架要重新执行 diff 和渲染流程。如果你的大循环在循环体内还去改状态变量(比如往一个数组里 push 元素,这个数组被 @State 装饰),那问题更严重——你以为只是最后刷新一次,实际上每一帧都可能触发脏检查、依赖收集和部分重绘。循环体和渲染流程在互相抢时间,卡顿只会加倍。
我见过最典型的反例是:在主线程里循环生成大量图片缩略图,每生成一张就 onSet 一下状态,整个相册页加载的时候 CPU 直接跑满,帧率掉到个位数。这种代码“本地跑两次觉得还好”,一上真机就是灾难。
1.3 为什么很多开发者还是会这么写
老实说,这里面有“思维惯性”的成分。不少开发者是从传统脚本语言或者嵌入式裸机开发转过来的,潜意识里认为“代码就是这样一行一行顺序跑的”,多线程意味着麻烦、锁、不确定。还有一部分是业务进度压力——需求紧急,先把功能跑通,“线程后面再优化”。结果功能是跑通了,体验却没通。
更隐蔽的原因是:在华为 DevEco Studio 里新建工程,默认模板不会逼你去想并发问题。你写完一个同步函数,编译能过、能跑,就自然认为“没问题”。直到真机上一慢,才回头看架构。
所以下一章,我把鸿蒙并发模型的基本盘彻底摆开。它的设计目标,恰恰是帮你把“并发”这事从“会不会”变成“该怎么选”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙并发模型的基本盘:Actor 是根,Worker 和 TaskPool 各管一摊
鸿蒙的并发模型和传统线程模型有个根本差异:它是基于 Actor 模型构建的。
2.1 Actor 模型的核心理念
Actor 模型用一句话概括就是:并发实体之间不共享内存,只通过消息传递来通信。每个 Actor 有自己独立的状态,别人拿不到你内部的数据,你想要某个结果,就得发消息请求,等它回消息。
这个设计和传统“多线程 + 共享内存 + 锁”相比,最大的优势在于:从根上掐死了数据竞争。两个线程同时改一个全局变量的情况,在 Actor 体系里根本不会发生,因为你连对方的变量都摸不到。代价也很明显:消息传递有开销,所以“小而频繁”的数据交换不适合走 Actor,适合“大而独立”的任务切分。
鸿蒙的并发能力就是在这个思想上长出来的。它不是简单给你一个 Thread,而是给你几种不同粒度的并发执行器,让你根据任务特点去选。
2.2 Worker:有独立线程、有完整生命周期的“常驻搬运工”
Worker 是鸿蒙最接近“传统线程”的并发工具。它会真正创建一个独立的线程,并且这个线程可以长期存活。你用 new worker.Worker('workers/xxx.js') 创建一个实例,通过 postMessage 传参数,onmessage 接收结果,干完活儿可以手动 terminate(),也可以让它继续待命。
适合 Worker 的场景,通常是这几种:
- 持续在后台运行的任务,比如音视频转码、流式数据处理。
- 需要保活、需要维护状态的任务,比如维护一个计算引擎的上下文。
- 任务被多次调用,每次都重新创建线程不划算。
但 Worker 有个现实问题:你得自己管生命周期。你创建了线程,就得负责销毁;页面销毁了线程没关,就泄漏;并发实例开多了,CPU 和内存开销都不小。它能力完整,但对使用者的自觉性要求也高。
2.3 TaskPool:任务级调度,用完即走的“临时工”
TaskPool 是另一种思路。它不让你接触线程,而是让你提交任务。你只需要写一个普通的函数(加上 @Concurrent 装饰器),调用 taskpool.execute(),它就会自动把任务丢进一个调度池子,由系统根据当前负载分配线程执行。
TaskPool 适合什么场景?
- 一次性的、偶发的计算密集任务,比如“把这张图压缩一下”“处理一批数据”。
- 任务之间没有状态依赖,提交完就等结果。
- 不想手动管理线程的生命周期,希望系统帮忙调度和回收。
TaskPool 的最大优势是“省心”。你不用关心线程池里有几个线程,不用关心异常退出,不用关心销毁,把任务交出去就行。代价是两个:第一,任务函数的参数和返回值必须是可序列化的(因为要走消息传递);第二,它不是一个常驻容器,不适合需要长时间驻留后台的服务型任务。
下面这张表是我日常选型时最常用的对照参考:
| 对比项 | Worker | TaskPool |
|---|---|---|
| 底层实现 | 独立线程 | 系统线程池调度 |
| 生命周期 | 需要手动创建/销毁 | 自动管理 |
| 状态保持 | 支持,线程可常驻 | 不支持,任务执行完即结束 |
| 数据传递 | 结构化克隆 + 可转移对象 | 结构化克隆 + 可转移对象 |
| 适用场景 | 长任务、有状态任务、流式处理 | 短任务、一次性任务、计算密集任务 |
| 异常处理 | 有 onerror 回调 | 通过 Promise 捕获 |
| 资源开销 | 较高,线程常驻 | 较低,按需调度 |
2.4 除了这两种,还有“软并发”
严格说,UI 线程上还有一类手段,不是真正多线程,却能明显改善卡顿,比如把一个 200ms 的大任务拆成 20 个 10ms 的小任务,放到 setTimeout、setInterval 或者 requestAnimationFrame 里分片执行。这样每个时间片都很短,事件循环不会被堵死,用户交互就不会被长时间阻断。
这类“软并发”在某些场景下比硬开线程更合适。比如你有一个内存量很大的对象要在主线程里处理,硬要 postMessage 到 Worker 反而可能因为序列化开销变得更慢。此时分片处理反而更平滑。
理解完基本盘,接下来用一次真实的压力场景,把主线程大循环的改造过程从头到尾演示一遍。
3. 把“2 万条数据解析”从主线程挪走的完整改造过程
3.1 场景还原:相册加载为什么会卡
我调试过一个独立开发者的相册类应用。它的功能逻辑大概是:应用启动后,从本地存储读取一个索引文件,里面有 2 万条图片元数据,每条包含路径、拍摄时间、经纬度、尺寸、滤镜参数等字段。代码的处理方式非常直接:
typescript复制// 改造前:主线程同步暴力解析
let allRecords: PhotoRecord[] = [];
for (let i = 0; i < rawRecords.length; i++) {
const item = rawRecords[i];
const record = new PhotoRecord();
record.path = item.path;
record.timestamp = parseTimestamp(item.timeStr);
record.location = parseLocation(item.gps);
record.size = parseSize(item.width, item.height);
record.tags = item.tags ? item.tags.split(',') : [];
allRecords.push(record);
}
this.photoList = allRecords;
这段代码本地跑,耗时约 120ms 到 180ms,看起来“也就一拍”。但问题是用户一旦回到相册页,每次一进页面就要全部重新解析,加上 UI 初始化、首帧布局、图片缩略图请求,整个页面在低端机上要卡 1 到 2 秒,中端机上滚动时也会偶发掉帧。用 Profiler 抓帧,主线程在启动阶段有一大片“空心”区域,全是阻塞。
3.2 在动手改之前,先定改造目标
我给自己定了三个验收标准:
- 解析过程不得阻塞主线程超过 5ms。
- 改造后的 UI 反馈保持流畅,掉帧率控制在 5% 以内。
- 回调回来后列表数据要“无缝”渲染,不能有一闪而过的空壳和 loading 抖动。
这个标准很重要——很多并发改造最大的败笔在于:任务不卡了,但数据回来的时候 UI 已经切走了,或者用户点进去的时候列表还是空的,体验反而更差。
3.3 方案一:分片处理,把大循环“切碎”
第一个思路最容易理解,也是门槛最低的:不换线程,把一个 200ms 的循环劈成一条条 10ms 的小任务,穿插到事件循环里执行。
实现方式是写一个简单的分批执行器:
typescript复制function runInChunks<T>(
items: T[],
chunkSize: number,
process: (item: T, index: number) => void,
onComplete: () => void
) {
let index = 0;
const step = () => {
const start = Date.now();
while (index < items.length && Date.now() - start < 8) {
process(items[index], index);
index++;
}
if (index < items.length) {
setTimeout(step, 0);
} else {
onComplete();
}
};
step();
}
使用起来就是在主线程上分批消费数据,每一批控制在 8ms 以内,事件循环不会被完全堵死。
这个方案的优点是改动小、没有跨线程的数据序列化开销,特别适合对象体型大、不适合 postMessage 的场景。缺点也很突出:总耗时并没有减少,反而因为分片和任务切换变长了一些,只是“卡顿感”被平均化成了肉眼几乎不可见的细微停顿。
在我这次的相册场景里,分片方案虽然能解决“进页面白屏卡死”,但滚动时偶发掉帧依然存在,而且解析时间从 160ms 拉长到 400ms 左右,数据出的太慢。所以我心里清楚——这只是及格方案,不是最优解。
3.4 方案二:TaskPool 接管一次性重活
遇到这种“一次跑完、结果一次性返回、不需要持续通信”的任务,最合适的选择就是 TaskPool。
做法很简单,把解析逻辑抽成一个独立函数,并加上 @Concurrent 装饰器:
typescript复制import { taskpool } from '@kit.ArkTS';
@Concurrent
function parsePhotoRecords(rawRecords: string[]): PhotoRecord[] {
// 这里的代码运行在线程池中的某个线程里
const result: PhotoRecord[] = [];
for (let i = 0; i < rawRecords.length; i++) {
const item = JSON.parse(rawRecords[i]);
const record = new PhotoRecord();
record.path = item.path;
record.timestamp = parseTimestamp(item.timeStr);
record.location = parseLocation(item.gps);
record.size = parseSize(item.width, item.height);
record.tags = item.tags ? item.tags.split(',') : [];
result.push(record);
}
return result;
}
// 主线程侧提交任务
const task = new taskpool.Task(parsePhotoRecords, rawRecords);
const result = await taskpool.execute(task);
this.photoList = result;
注意几个关键点:
- 任务函数的入参 rawRecords 必须是可序列化的,比如纯数组、纯对象。如果传入的是 class 实例或者带方法的对象,序列化时会出问题。
- 返回值同样要能结构化克隆。
@Concurrent装饰的函数不能访问外部的这个、全局状态或主线程上下文,所有输入都要通过参数传。- execute 返回的是一个 Promise,在 async 函数里 await 就行。
改造后的结果让我很满意:原本 160ms 的解析工作挪到线程池后,主线程上只有提交和接收结果两段极短操作,加起来不到 3ms。帧率和 CPU 占用大幅改善。TaskPool 的调度器会自己决定用哪个线程跑,跑完自动回收,不需要我碰任何销毁逻辑。
3.5 方案三:Worker 常驻后台做连续计算
如果任务不是“一次跑完”,而是长期存在于后台、需要反复调用,比如相册界面上持续接收新照片流、不断更新索引,或者做一个后台滤镜引擎,那就该用 Worker 了。
我这里简单写一个 Worker 骨架,供你参考:
typescript复制// main.ets 侧
import { worker } from '@kit.ArkTS';
const workerInstance = new worker.Worker('entry/src/main/ets/workers/photo_worker.ts');
workerInstance.onmessage = (e) => {
const parsedRecords = e.data as PhotoRecord[];
this.photoList = parsedRecords;
};
workerInstance.onerror = (e) => {
console.error('Worker error:', JSON.stringify(e));
};
workerInstance.postMessage(rawRecords);
// 页面销毁时一定要终止
aboutToDisappear() {
workerInstance.terminate();
}
Worker 内部(photo_worker.ts)长这样:
typescript复制import { worker } from '@kit.ArkTS';
const parentPort = worker.workerPort;
parentPort.onmessage = (e) => {
const rawRecords = e.data as string[];
const parsedRecords = parseRawRecords(rawRecords);
parentPort.postMessage(parsedRecords);
};
function parseRawRecords(rawRecords: string[]): PhotoRecord[] {
// 真正的解析逻辑
return result;
}
Worker 和 TaskPool 在这个场景上的差别,一句话说清楚:TaskPool 是“点外卖”,提交订单,等餐,吃完走人;Worker 是“雇一个专职厨师”,长期驻店,随时能接单。前者省心,后者灵活。如果任务频率低、一次就完事,用 Worker 纯属浪费;如果任务是持续性的、状态要保留,TaskPool 就有点力不从心。
3.6 三个方案怎么选,我总结了一张决策表
| 判断条件 | 推荐方案 |
|---|---|
| 任务耗时 20ms 以内,频次低 | 直接放主线程即可,不必并发 |
| 任务耗时几十到几百毫秒,一次性 | TaskPool,最省心 |
| 任务需要分批执行、渐进反馈 | 主线程分片处理或 TaskPool 分批回调 |
| 任务常驻后台、需保持状态、多轮交互 | Worker |
| 任务涉及大量不可序列化大对象 | 分片处理或考虑数据重组 |
实测下来的结论是:绝大多数“大循环”问题,TaskPool 是第一候选。它把线程管理和生命周期都隐藏掉了,你的代码从一个“同步大循环”变成“异步提交 + 等待结果”,主线程瞬间就轻了。
4. 并发改造后躲不掉的坑:数据竞争、泄漏、取消不了的任务
改完并发只是第一步。真正让人头秃的,是并发改造完成后暴露出来的一堆边界问题。
4.1 坑一:postMessage 到底是拷贝还是转移
无论是 Worker 还是 TaskPool,数据传递默认都是“结构化克隆”。说白了就是深拷贝一份,传过去的是副本,两边各改各的,互不影响。
但当你传 ArrayBuffer 这类二进制对象时,大部分运行库支持“转移”语义——把这块内存的所有权直接转给目标线程,发送方这边就清空了。这个机制性能很好,但是有陷阱:如果你在发送后又去读这个 ArrayBuffer,拿到的是一个空壳。
我踩过一次。传一段二进制数据给 Worker 做处理,发送方在主线程还留了个引用,处理完 Worker 发消息回来说“请读取这段数据”,主线程拿到的却是 0 长度。排查了半小时,发现发送时默认触发了转移。解决方案是,发送方在做完 postMessage 之后,立刻置空本地引用,明确所有权已经移交,一了百了。
4.2 坑二:任务函数里不能用闭包捕获
TaskPool 的 @Concurrent 函数和 Worker 里的回调,都执行在另一个线程。它们无法访问主线程里定义的全局变量、单例、UI 相关对象。
有一个非常经典的翻车现场:
typescript复制@Concurrent
function processData(data: string[]) {
// 这里想调用一个定义在主线程的 util 工具类
return globalUtils.analyze(data); // 报错:无法访问
}
你看着编译能过,一运行就抛异常。原因是并发函数体在另一个线程,根本没有主线程的上下文。正确的姿势是:把任何依赖都收进函数内部,或者作为参数传进去。我建议在设计并发任务时,把函数写的“无脑纯”,所有依赖显式传参,这样既符合 Actor 模型,也方便单元测试。
4.3 坑三:Worker 不销毁就是内存泄漏
Worker 是能常驻的,但“能常驻”不是“必须常驻”。很多新手在页面里创建一个 Worker,收完数据就忘了 terminate。结果页面销毁了,线程还在后台活着,里面持有的一堆对象也跟着泄漏。
一个稳妥的做法:在页面的 aboutToDisappear 里统一清理,同时给 Worker 加超时保险——比如任务完成后 10 秒没有新消息,就自动关闭。
typescript复制aboutToDisappear() {
if (this.workerInstance) {
this.workerInstance.terminate();
this.workerInstance = undefined;
}
}
注意,terminate() 之后这个 Worker 实例就不能再用了,再 postMessage 会直接报错。所以销毁后要立即置空引用,避免悬空调用。
4.4 坑四:Context 被并发任务间接持有
这个坑比较隐蔽。如果你在 TaskPool 任务里通过参数传入了 UIAbility 的 Context,或者任务的回调里闭包捕获了页面对象,那一整个页面实例就会被并发线程间接持有。任务不结束,页面内存就释放不了,页面销毁了任务还在跑,越积越多,最后就是 OOM。
排查这类问题,可以借助 DevEco Studio 的内存分析工具,看 GC Root 路径。如果是 Context 泄漏,通常能看到一条从任务线程到 UIAbility 实例的强引用链。解决方法是:不要往任务函数里传 Context,任务里如果非要读资源,传必要的字符串或数值参数进来。
4.5 坑五:TaskPool 的任务怎么取消
大多数版本的 TaskPool API 没有一个直接的 cancel(task) 方法。这意味着你提交了一个耗时任务,中途用户退出了页面,你也没法强行把它拽回来。
我的做法是“业务层面取消”:给任务传入一个共享的取消标志(比如一个普通对象),任务在循环体里每隔一段检查这个标志,发现被置位就提前 return。因为对象是深拷贝传过去的,拷贝的标志在主线程里改不到任务里的那一份,所以需要一个可共享的数据载体,或者用一个更聪明的方案——把任务拆小,分多批提交,每批之间检查是否还有必要继续。
另一个更实用的思路:TaskPool 任务本身是短任务,通常几百毫秒跑完。如果页面销毁时任务还在跑,那说明这个任务的粒度设计不对,应该拆成更小的任务,而不是把 3 秒的活儿一次性丢进去。
4.6 坑六:消息回来时页面已经没了
异步回调的经典问题:任务是在页面 A 发起的,等结果回来时用户已经切到页面 B,this.photoList 所在的组件上下文已经销毁。此时直接更新状态,轻则报错,重则崩溃。
规范的写法是:在回调里先判断页面是否仍在活动,或者用生命周期感知的组件去接收结果。我在相册场景里用了一个简单的“代数”机制:每次提交任务时生成一个自增 ID,回调回来时对比当前页面持有的 ID 是否一致,不一致就丢弃结果。这个套路很小,但非常可靠。
5. 拿数据说话:用帧率和 CPU 占用证明“不卡了”
代码改完,UI 手感和肉眼体验明显好了,但你得拿数据证明,不然改天需求方说“我觉得还是卡”,你又回到原点。性能优化领域最怕“我觉得”,所以量化是关键。
5.1 用 Profiler 抓帧率和掉帧
最直接的工具是 DevEco Studio 自带的 Profiler。连上真机,录制一段时间,它会给出每一帧的耗时曲线。重点关注两个指标:
- 掉帧数:一帧耗时超过 16.6ms 即为掉帧,超过 33ms 就是严重卡顿。
- 主线程繁忙区间:看主线程在哪些时间段被长时间任务阻塞。
改造前,我的相册场景在加载阶段掉帧率大概在 30% 以上,页面启动那块有个明显的大凹槽。改造后,同样场景下掉帧率降到 5% 以内,主线程的时间线也干净了很多,只有零星的细小任务。
5.2 SmartPerf Host 和命令行抓取
如果要做更细粒度的分析,可以用 SmartPerf Host 抓取 trace 数据。它抓的是系统级的调度信息,能看到线程切换、任务执行时长、锁的等待时间等。
抓 trace 的命令大致长这样(具体细节取决于配套工具链):
bash复制hdc shell htrace start -k -t 10 -o /data/local/tmp/trace.htrace
# 操作手机 10 秒
hdc shell htrace stop
然后把 trace 文件导出来,在 SmartPerf Host 里打开。可以直观地看到主线程的 Runnable、Interruptible 状态,以及每个时刻在跑什么任务。凡是主线程上长时间 Running 的区间,全都是排查重点。
5.3 我建立的性能回归基线
在项目里,我习惯给关键路径设定性能阈值,防止并发改造被后续迭代悄悄破坏。比如:
- 冷启动首帧时间不超过 500ms。
- 进入相册页的解析过程,主线程阻塞不超过 5ms。
- 操作过程中掉帧率不超过 5%。
每次发版前,用脚本跑一遍相同的场景,输出掉帧率和主线程最大阻塞时间,超过阈值就打回。这个习惯帮我挡住过很多次“后来者改了代码,性能悄悄倒退”的情况。
5.4 打点日志辅助定位
Profiler 适合事后分析,日常开发里我还会在关键路径埋点:任务提交、任务执行开始、任务执行结束、回调接收。日志格式统一,用 tag 区分线程,这样在日志里能清楚地看到“任务是在后台线程跑的,主线程只等了多久”。
typescript复制console.info(`[Concurrent] task submit at ${Date.now()} ms, thread=${getCurrentThreadId()}`);
const result = await taskpool.execute(task);
console.info(`[Concurrent] task done at ${Date.now()} ms, thread=${getCurrentThreadId()}, resultCount=${result.length}`);
不要小看这几行日志,它们能帮你在用户反馈“卡”但本地复现不了的时候,快速定位到是不是某个异步操作把负载拉高了。
我个人在实际开发里的体会是,并发模型不是“要不要用”的问题,而是“怎么选”的问题。凡是一次性重活,默认丢给 TaskPool;凡是常驻后台服务,用 Worker;凡是轻量高频但可拆的任务,优先考虑主线程分片。别赌“用户的手机很快”,把并发模型的优势真正用起来,主线程才会从“一条拥堵的单行道”变回“秩序井然的调度中心”。
最后分享一个我到现在都保留的小习惯:每次写完一段可能耗时超过 50ms 的同步逻辑,我都会下意识地问一句——这段一定要在主线程跑吗?如果不是,那就别让它留在这儿。这一问,帮我挡掉了很多卡顿问题。
