如果你是 HarmonyOS 开发者,肯定遇到过这样的问题:APP 里要同时处理好几个任务,比如一边加载数据一边更新 UI,结果主线程卡得不行,用户体验直接拉垮。别慌,今天咱们要聊的 TaskPool,就是解决这类问题的 “多线程神器”。

一、先搞明白:TaskPool 到底是个啥?

简单说,TaskPool 就是 HarmonyOS 给咱们提供的 “线程池” 工具。它能帮咱们管理一堆工作线程,咱们只需要把要做的任务扔给它,不用操心线程怎么创建、什么时候销毁,系统会自动搞定这些琐事。

举个例子:你开了家奶茶店,主线程是前台点单的店员,TaskPool 就是后台的制作间。客人点单(任务)来了,前台店员(主线程)不用自己做奶茶,直接把订单(任务)传到制作间(TaskPool),里面的师傅(工作线程)做好了再把奶茶(结果)送回来。这样前台永远有空接待新客人,效率自然高。

TaskPool 的核心优势就是:降低资源消耗、提高系统性能,尤其适合处理多个耗时任务的时候。

二、TaskPool 是怎么干活的?运作机制大揭秘

想用好 TaskPool,得先知道它内部是怎么运作的。简单来说,就三步:

  1. 扔任务:咱们在主线程把任务打包好,丢进 TaskPool 的 “任务队列” 里;
  2. 分任务:系统里的 “任务分发管理器” 会看哪个工作线程有空,把任务分给它;
  3. 返结果:工作线程做完任务,把结果传回主线程。

这里有个很智能的点:工作线程会 “动态伸缩”。比如刚开始只有 1 个工作线程,任务多了,它会自动增加(最多跟设备的物理核数相关);如果长时间没任务,又会自动减少,绝不浪费资源。

举个场景:你用 APP 同时下载 3 个文件,TaskPool 会启动 3 个工作线程分别处理;下载完了,线程就会慢慢 “休息”,不会占着内存不放。

三、必须掌握的核心:@Concurrent 装饰器

用 TaskPool 的第一步,就是给你的任务函数戴上 “身份证”——@Concurrent 装饰器。少了它,任务根本跑不起来,系统会直接报错。

重点 1:@Concurrent 的基本用法

@Concurrent 只能修饰普通函数或 async 函数,不能修饰箭头函数、类方法、generator 函数这些。而且,它只在.ets 文件里能用,还得是 Stage 模型的工程。

来看个最简单的例子:计算两个数的和,用 TaskPool 来执行。

// 导入taskpool模块
import { taskpool } from '@kit.ArkTS';

// 用@Concurrent标记任务函数,这是必须的!
@Concurrent
function add(num1: number, num2: number): number {
  return num1 + num2;
}

// 执行任务的函数
async function ConcurrentFunc() {
  try {
    // 创建任务,传入函数和参数
    let task: taskpool.Task = new taskpool.Task(add, 1, 2);
    // 执行任务并获取结果
    let result = await taskpool.execute(task);
    console.info("计算结果:" + result); // 会输出“计算结果:3”
  } catch (e) {
    console.error("任务执行出错:" + e);
  }
}

// UI界面,点击文本触发任务
@Entry
@Component
struct Index {
  @State message: string = '点我算加法';
  build() {
    Row() {
      Column() {
        Text(this.message)
          .fontSize(30)
          .onClick(() => {
            // 点击时执行任务
            ConcurrentFunc();
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}

上面的代码里,add函数被 @Concurrent 标记,然后通过taskpool.Task创建任务,最后用taskpool.execute执行。这就是最基本的用法,记牢了!

重点 2:@Concurrent 函数里不能用 “闭包”!

这是个大坑!@Concurrent 修饰的函数里,只能用两种变量:函数内部的局部变量,或者通过 import 导入的变量。绝对不能用同文件里其他地方定义的函数、类,不然会报错。

看个反面例子(会报错的代码):

// 同文件里定义的函数,在@Concurrent函数里用会报错
function localAdd(arg: number) {
  return arg + 1;
}

@Concurrent
function testFunc() {
  // 这里直接调用localAdd会报错!因为它是闭包变量
  // localAdd(1); // 报错:Only imported variables and local variables can be used...
}

正确的做法是:把要用的函数或类放到另一个文件,再 import 进来。

// 另一个文件 Test.ets
export function testAdd(arg: number) {
  return arg + 1;
}

// 当前文件 Index.ets
import { testAdd } from './Test';

@Concurrent
function testFunc() {
  // 用import的函数就没问题
  console.info("结果:" + testAdd(1)); // 输出“结果:2”
}

四、这些坑千万别踩!TaskPool 注意事项大盘点

用 TaskPool 时,稍不注意就会掉坑里。这部分全是干货,一定要记牢!

重点 1:任务耗时不能超过 3 分钟!

TaskPool 里的任务,在工作线程的执行时间绝对不能超过 3 分钟(注意:不算网络请求、文件读写这些异步操作的时间)。超过了,系统会直接把任务 “杀” 掉,还会报错。

比如你写了个循环计算的任务,跑了 4 分钟,结果肯定是失败:

@Concurrent
function longTimeTask() {
  // 模拟耗时操作,比如循环很多次
  let sum = 0;
  for (let i = 0; i < 10000000000; i++) { // 这个循环可能超过3分钟
    sum += i;
  }
  return sum;
}

// 执行后会报错,因为耗时超过3分钟

避坑建议:如果确实需要处理超过 3 分钟的任务,可以拆成多个小任务,分批执行。

重点 2:数据传递有讲究,序列化是关键

TaskPool 的工作线程和主线程之间传递数据,必须经过 “序列化”—— 简单说,就是数据得能转换成可传输的格式。而且有两个硬限制:

  • 入参和返回值必须是支持序列化的类型(比如 number、string、简单对象等,具体看官方文档);
  • 传递的数据量不能超过 16MB,太大了传不过去。

另外,从 API version 11 开始,如果要传递带方法的对象,这个类必须用 @Sendable 装饰器标记。

举个例子,自定义类的传递:

// 用@Sendable标记类,才能跨线程传递
@Sendable
class User {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

@Concurrent
function printUser(user: User): string {
  return `姓名:${user.name},年龄:${user.age}`;
}

// 执行任务
async function test() {
  let user = new User("小明", 18);
  let task = new taskpool.Task(printUser, user);
  let result = await taskpool.execute(task);
  console.info(result); // 输出“姓名:小明,年龄:18”
}

如果 User 类没加 @Sendable,执行时就会报错!

重点 3:线程安全第一,UI 相关的库绝对不能用

工作线程和主线程是两个 “世界”,上下文完全不同。所以工作线程里只能用线程安全的库,像 UI 相关的库(比如更新界面的组件)都是非线程安全的,绝对不能用,用了就会出各种奇怪的错。

反面例子(会报错):

@Concurrent
function badTask() {
  // UI相关的操作,在工作线程里执行会报错
  // Text("Hello") // 报错!UI库不能在工作线程用
}

避坑建议:工作线程里只做数据处理、计算等纯逻辑操作,别碰界面相关的任何东西。

重点 4:Promise 跨线程传递有坑

Promise 不能直接跨线程传递!如果任务返回的是:

  • pending 状态的 Promise:失败;
  • rejected 状态的 Promise:失败;
  • fulfilled 状态的 Promise:系统会解析结果,返回成功。

看几个例子就明白了:

@Concurrent
function testPromise1(): Promise<number> {
  // 返回fulfilled状态的Promise,没问题
  return Promise.resolve(100);
}

@Concurrent
function testPromise2(): Promise<number> {
  // 返回pending状态的Promise,会失败
  return new Promise((resolve) => {
    setTimeout(() => resolve(100), 1000); // 这里是pending状态
  });
}

@Concurrent
function testPromise3(): Promise<number> {
  // 返回rejected状态的Promise,会失败
  return Promise.reject(new Error("出错了"));
}

// 执行测试
async function test() {
  let task1 = new taskpool.Task(testPromise1);
  let task2 = new taskpool.Task(testPromise2);
  let task3 = new taskpool.Task(testPromise3);

  await taskpool.execute(task1).then(res => {
    console.info("task1成功:" + res); // 输出100
  });

  await taskpool.execute(task2).catch(e => {
    console.error("task2失败:" + e); // 会走到这里
  });

  await taskpool.execute(task3).catch(e => {
    console.error("task3失败:" + e); // 会走到这里
  });
}

重点 5:ArrayBuffer 默认会 “转移” 所有权

如果任务的参数是 ArrayBuffer,默认情况下,它的所有权会从主线程 “转移” 到工作线程,主线程里就不能再用这个 ArrayBuffer 了。如果想自己控制哪些数据转移,可以用setTransferList方法。

例子:

@Concurrent
function processBuffer(buffer: ArrayBuffer): number {
  let arr = new Uint8Array(buffer);
  return arr.reduce((a, b) => a + b, 0); // 计算总和
}

async function test() {
  let buffer = new ArrayBuffer(10); // 创建一个10字节的buffer
  let task = new taskpool.Task(processBuffer, buffer);

  // 默认情况下,buffer会转移到工作线程,主线程之后不能用
  // 如果不想转移,可以设置转移列表为空(但ArrayBuffer建议转移,效率高)
  // task.setTransferList([]); 

  let result = await taskpool.execute(task);
  console.info("总和:" + result);

  // 这里如果再用buffer,会报错,因为所有权已经转移了
  // console.info(buffer.byteLength); // 报错!
}

重点 6:优先级别乱设,IDLE 优先级很特殊

TaskPool 里的任务可以设优先级,其中IDLE 优先级最特殊:它的级别最低,只有当所有线程都空闲时才会执行,而且只占一个线程。适合用来做数据同步、备份这些后台任务。

例子:

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

@Concurrent
function backupData() {
  // 模拟数据备份
  console.info("开始备份数据...");
  // ...备份逻辑
}

async function test() {
  let task = new taskpool.Task(backupData);
  // 设置优先级为IDLE
  task.setPriority(Priority.IDLE);
  await taskpool.execute(task);
}

这个任务只会在设备空闲时悄悄执行,绝不影响前台的操作,很贴心吧?

五、总结一下,TaskPool 到底该怎么用?

用 TaskPool 的正确姿势:

  1. 写一个任务函数,用 @Concurrent 装饰;
  2. 确保函数的入参、返回值支持序列化,数据量不超过 16MB;
  3. 任务耗时别超 3 分钟,别用 UI 相关的库;
  4. 创建 Task,设置优先级(可选)、转移列表(可选);
  5. 用 taskpool.execute 执行任务,处理结果或错误。

掌握了这些,你就能在 HarmonyOS 里轻松玩转多线程,让 APP 跑得又快又流畅,用户体验直接上一个台阶!

最后再强调一句:TaskPool 的核心是帮咱们简化多线程开发,不用自己管理线程,但也别忘了那些注意事项 —— 毕竟,好用的工具也需要正确使用才能发挥最大威力呀!

Logo

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

更多推荐