ArkTS 并发实战:UI 主线程与业务线程的解耦之道(TaskPool vs Worker)

前言

在鸿蒙应用开发中,我们常遇到这样一种场景:进入账单详情页要解析几万条流水、上传前对图片做压缩、AI 识别前先对样本做预处理。很多开发者第一反应是写一个 async 函数,用 await “等结果”,以为这样就不会卡 UI。然而 async/await 只解决“等待不阻塞”,它并不会把 CPU 密集型计算从 UI 主线程移走。一旦主线程被占用,页面就会掉帧、按钮无响应、Progress 进度条停滞,严重时触发系统 ANR 检测。

HarmonyOS 提供了两条官方并发路径:TaskPool(一次性独立任务)和 Worker(常驻工作线程)。本文通过一个真实复现案例,把两者的适用场景、踩坑点与工程化写法一次性讲清楚。


问题描述

故障现象

某记账类应用在“导入 CSV”功能中,点击“开始导入”后:

  • 页面出现明显卡顿,倒计时 Progress 暂停 2~3 秒;
  • 返回手势无响应,点击“取消”按钮延迟很久;
  • DevEco Profiler 显示 UI 主线程出现 长任务(Long Task > 100 ms) 告警。

原始代码

@Entry
@Component
struct ImportPage {
  @State status: string = '空闲'
  @State progress: number = 0

  private async parseCSV(raw: string) {
    // ❌ 错误:await 只让调用链“看起来异步”,计算仍跑在主线程
    const rows = raw.split('\n')
    for (let i = 0; i < rows.length; i++) {
      this.validateRow(rows[i]) // CPU 密集型校验
      this.progress = Math.floor((i / rows.length) * 100)
    }
  }

  validateRow(row: string) {
    // 模拟复杂正则 + 数值计算
    return /^\d{4}-\d{2}-\d{2},.+,.+/.test(row) && Math.sqrt(row.length) > 2
  }

  build() {
    Column({ space: 20 }) {
      Text(this.status).fontSize(18)
      Progress({ value: this.progress, total: 100, type: ProgressType.Ring })
        .width(120).height(120)
      Button('开始导入').onClick(() => {
        this.status = '处理中'
        this.parseCSV(mockCSV())
      })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
  }
}

function mockCSV(): string {
  return Array.from({ length: 200000 }, (_, i) => `2026-01-01,支出,餐饮,${i}`).join('\n')
}

复现条件

  • 数据量大于 1 万行,或单次循环内有正则、数学运算、JSON 解析等 CPU 操作;
  • 在按钮回调、生命周期(aboutToAppear)或 build() 中直接执行;
  • 使用 async/await 但没有真正把任务投递到并发线程。

细节解析

1. 为什么 async/await 不能解决卡顿?

async 函数在事件循环中仍然运行在创建它的线程(通常是 UI 主线程)上。await 只是让出当前调用栈,等待后续的 Promise 解析;如果 Promise 内部是同步计算,主线程依旧被占满,帧率暴跌。

2. TaskPool 与 Worker 怎么选?

维度 TaskPool Worker
生命周期 一次性任务,执行完自动回收 常驻,需显式 terminate()
典型场景 单次 JSON/CSV 解析、图片压缩、数学运算 持续消息循环(IM 接收、串口数据、长连接)
数据传递 结构化克隆 / Sendable / Transferable 结构化克隆 / Transferable
执行时长 普通任务有系统约束,超长任务需走长时任务 更适合长时运行
使用成本 低:一个 @Concurrent 函数即可 高:需维护线程、消息协议、生命周期

3. 跨线程数据传递限制

TaskPool 与 Worker 都使用 结构化克隆(Structured Clone),以下类型可直接传递:

  • 基础类型、Array、Object、ArrayBuffer、TypedArray、Map、Set、Date 等。
  • 不可传递:函数、@State 对象、Context、UI 组件实例。

对于大 ArrayBuffer,使用 Transferable 转移所有权,可避免一次内存拷贝。

4. TaskPool 普通任务的时长约束

HarmonyOS 对 TaskPool 普通任务有默认执行时长限制,遇到真正长时间运行(如持续十几分钟)的任务,应使用 taskpool.executeLongTask,或改用 Worker + 长时任务机制,不要靠死循环占着线程。


示例代码

修复方案 A:TaskPool 一次性计算(推荐)

import { taskpool } from '@kit.ArkTS';

// 必须标注 @Concurrent,函数内不能访问 UI / @State
@Concurrent
function parseCSVConcurrent(raw: string): { total: number; valid: number } {
  const rows = raw.split('\n')
  let valid = 0
  for (let i = 0; i < rows.length; i++) {
    if (/^\d{4}-\d{2}-\d{2},.+,.+/.test(rows[i]) && Math.sqrt(rows[i].length) > 2) {
      valid++
    }
  }
  return { total: rows.length, valid }
}

@Entry
@Component
struct ImportPage {
  @State status: string = '空闲'
  @State progress: number = 0
  @State result: string = ''
  private task?: taskpool.Task

  private async runImport() {
    this.status = '处理中'
    const raw = mockCSV()
    this.task = new taskpool.Task(parseCSVConcurrent, raw)
    try {
      const res = await taskpool.execute(this.task) as { total: number; valid: number }
      this.result = `${res.total} 行,有效 ${res.valid}`
      this.status = '完成'
      this.progress = 100
    } catch (e) {
      this.status = `失败:${JSON.stringify(e)}`
    }
  }

  aboutToDisappear(): void {
    if (this.task) taskpool.cancel(this.task)
  }

  build() {
    Column({ space: 20 }) {
      Text(this.status).fontSize(18)
      Progress({ value: this.progress, total: 100, type: ProgressType.Ring })
        .width(120).height(120)
      Text(this.result).fontSize(14).fontColor('#666')
      Button('开始导入').onClick(() => this.runImport())
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
  }
}

修复方案 B:Worker + 进度回传(适合需要持续反馈)

// worker.ts
import { worker } from '@kit.ArkTS';

const parentPort = worker.workerPort;

parentPort.onmessage = (e: { data: string }) => {
  const rows = e.data.split('\n');
  let valid = 0;
  for (let i = 0; i < rows.length; i++) {
    if (/^\d{4}-\d{2}-\d{2},.+,.+/.test(rows[i]) && Math.sqrt(rows[i].length) > 2) {
      valid++;
    }
    // 每 5000 行回传一次进度
    if (i % 5000 === 0) {
      parentPort.postMessage({ type: 'progress', current: i, total: rows.length });
    }
  }
  parentPort.postMessage({ type: 'done', total: rows.length, valid });
};
// 主线程
import { worker } from '@kit.ArkTS';

async function runWithWorker(raw: string,
  onProgress: (p: number) => void): Promise<{ total: number; valid: number }> {
  return new Promise((resolve, reject) => {
    const w = new worker.ThreadWorker('entry/ets/worker.ts');
    w.onmessage = (e) => {
      const d = e.data as Record<string, number | string>;
      if (d.type === 'progress') onProgress(Math.floor((d.current as number / d.total as number) * 100));
      if (d.type === 'done') {
        w.terminate();
        resolve({ total: d.total as number, valid: d.valid as number });
      }
    };
    w.onerror = (err) => { w.terminate(); reject(err); };
    w.postMessage(raw);
  });
}

修复方案 C:Transferable 大 ArrayBuffer

const buffer = new ArrayBuffer(10 * 1024 * 1024);
const task = new taskpool.Task(processBuffer, [buffer, true]);
// 注意:Transferable 在 TaskPool 参数包装中由框架处理,
// Worker 中可用:w.postMessage(buffer, [buffer]);

总结

避坑要点

  1. 不要把 async/await 当成并发:只有 TaskPool / Worker / 系统后台任务才是真正切换线程。
  2. @Concurrent 函数里禁止访问 UI 状态、Context、@State:传入所有需要的纯数据。
  3. 大内存数据用 Transferable:避免主线程 ↔ 工作线程之间的拷贝开销。
  4. Worker 必须主动 terminate:否则常驻线程会持续占用资源。
  5. 页面退出 / 重复点击要取消任务:避免内存泄漏和结果覆盖旧 UI。

后续预防方案

  • 静态扫描门禁:在 CI 中扫描 build() 与生命周期里是否出现长循环、JSON.parse、正则批处理;推荐用 rg -n "JSON\.parse|for\s*\(.*length\)" 做初步过滤。
  • 性能监控:DevEco Profiler 定期抓取主线程 Long Task,设定 100 ms 红线。
  • 代码审查清单:每次新增耗时操作都回答“这条逻辑在主线程吗?能放 TaskPool 吗?”

把“重活”搬出主线程,是鸿蒙应用保持 60 FPS 体验的基本功。TaskPool 覆盖 80% 的一次性计算场景,Worker 解决剩余的常驻任务场景,二者配合即可构建流畅、稳定的高性能应用。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐