欢迎光临
我们一直在努力

HarmonyOS 7 TaskPool 实战:别让耗时任务把 UI 线程拖慢

项目里有个图片批量处理功能,单张图片压缩、加水印、生成缩略图,处理一张也就几十毫秒,感觉挺快的。后来用户一次选了五十张照片一起处理,问题就来了——页面开始卡,进度条不动,按钮点了没反应,有时候直接 ANR。

我一开始以为是图片处理算法太慢,优化了半天压缩逻辑,效果不大。后来打日志才发现,所有图片处理都跑在 UI 线程上,五十张图片串行处理,每张几十毫秒加起来就是好几秒,这几秒里 UI 线程完全被占满,页面当然卡。

说白了,这里要解决的就是"耗时任务别占着 UI 线程"这个问题。TaskPool 就是 HarmonyOS 提供的解决方案,把耗时任务丢到后台线程去跑,UI 线程只负责渲染和交互。这篇就讲讲从"能跑"到"跑着不卡"中间踩的那些坑。

一、几十张图片一起处理为什么会把页面卡死

先说说这个问题的根源。

HarmonyOS 的 UI 渲染和事件响应都跑在主线程(也就是 UI 线程)上。页面刷新、按钮点击、列表滚动,这些操作都需要主线程来处理。如果主线程被一个耗时任务占住了,页面就没法刷新,用户的操作也响应不了,看起来就是"卡了"。

单张图片处理几十毫秒,人眼感觉不到。但五十张串行处理就是几秒,这几秒里主线程一直在做图片压缩,没时间管 UI。用户看到的就是进度条卡住、按钮没反应、页面划不动。

有人可能会说,那我用 async/await 异步处理不就行了?不行。async/await 只是让代码写法看起来像同步,本质上还是跑在同一个线程上。图片处理这种 CPU 密集型任务,异步不异步都占主线程。要真正不卡 UI,必须把任务放到另一个线程去跑。

TaskPool 的作用就是这个:它维护了一个后台线程池,你把任务丢给它,它在后台线程里执行,执行完了把结果返回给主线程。主线程在任务执行期间可以继续处理 UI 交互,页面就不会卡了。

二、TaskPool 的基本用法和任务拆分

TaskPool 的用法不复杂,核心就是三步:定义任务函数、创建 Task 对象、执行任务。

任务函数是一个普通的函数,但有个要求:它必须能在独立线程里运行,不能引用主线程的变量。HarmonyOS 用 @Concurrent 装饰器或者独立文件来标记这种函数。我的做法是把任务函数放在单独的 .ets 文件里,文件顶部用 @Concurrent 标记,这样文件里导出的函数都可以作为 TaskPool 的任务函数。

任务拆分是关键。五十张图片不能作为一个任务丢进去,那样后台线程跑几秒,虽然不卡 UI 了,但用户只能等全部处理完才能看到结果。正确的做法是每张图片一个任务,逐个提交给 TaskPool,处理完一张就更新一次进度。

但也不能拆得太细。如果任务太小,线程切换和任务调度的开销可能比任务本身还大。我的经验是:单个任务执行时间在几十毫秒到几百毫秒之间比较合适。图片处理正好符合这个粒度,每张图片一个任务刚刚好。

下面这段代码放在 ImageTask.ets 里,定义了图片处理的任务函数。它是一个独立的并发文件,被 TaskPool 调用时在后台线程执行。

@Concurrent
export async function processImage(input: string, output: string, quality: number): Promise<boolean> {
try {
// 图片压缩、加水印、生成缩略图
// 具体实现依赖 image 模块,此处省略
console.info(`[ImageTask] processed: ${input}`);
return true;
} catch (e) {
console.error(`[ImageTask] failed: ${input}, error: ${JSON.stringify(e)}`);
return false;
}
}

这段代码要解决的问题:processImage 是一个并发函数,可以被 TaskPool 在后台线程调用;输入是图片路径和输出参数,输出是处理结果;函数内部不引用任何外部状态,保证线程安全。

实际运行时要注意:@Concurrent 标记的函数不能引用闭包变量,所有依赖必须通过参数传入。如果函数里用到了模块级别的变量,可能会导致线程安全问题。另外,任务函数里不能直接操作 UI,所有 UI 更新必须在结果回传后由主线程完成。

三、任务提交、结果回传和进度更新

任务函数定义好以后,就可以在页面里提交任务了。

我的做法是建一个 ImageProcessor 类,负责管理任务的提交、进度跟踪和结果回传。页面只调用 processor.start(images),不需要关心 TaskPool 的细节。

下面这段代码放在 ImageProcessor.ets 里,封装了批量图片处理的逻辑。它在用户点击"开始处理"时调用 start,处理过程中通过回调更新进度,全部完成后通知页面。

import { taskpool } from '@kit.PerformanceKit';
import { processImage } from './ImageTask';

export interface ProcessResult {
index: number;
success: boolean;
output?: string;
}

export class ImageProcessor {
private tasks: taskpool.Task[] = [];
private completed: number = 0;
private total: number = 0;

async start(images: Array<{input: string, output: string}>, onProgress: (done: number, total: number) => void): Promise<ProcessResult[]> {
this.total = images.length;
this.completed = 0;
const results: ProcessResult[] = [];

const promises = images.map((img, index) => {
const task = new taskpool.Task(processImage, img.input, img.output, 80);
this.tasks.push(task);
return taskpool.execute(task).then((success) => {
this.completed++;
onProgress(this.completed, this.total);
results.push({ index, success: success as boolean, output: img.output });
return { index, success: success as boolean };
}).catch((e) => {
this.completed++;
onProgress(this.completed, this.total);
results.push({ index, success: false });
return { index, success: false };
});
});

await Promise.all(promises);
return results;
}

cancelAll(): void {
this.tasks.forEach(task => {
try {
taskpool.cancel(task);
} catch (e) {
console.warn(`[ImageProcessor] cancel failed: ${JSON.stringify(e)}`);
}
});
this.tasks = [];
}
}

这段代码要解决的问题:start 方法把每张图片作为一个独立任务提交给 TaskPool,用 Promise.all 等待全部完成;每个任务完成后调用 onProgress 回调更新进度,进度更新在主线程执行;cancelAll 方法取消所有未完成的任务,用户点击"取消"时调用。

实际运行时要注意:TaskPool 有并发数限制,系统会根据设备性能自动调度,不是所有任务都会同时执行。提交大量任务时不需要自己做并发控制,TaskPool 会排队处理。但如果任务数量特别大(比如几百张),建议分批提交,避免内存中堆积太多 Task 对象。另外,cancel 只能取消还没开始执行的任务,已经在执行的任务不会被中断,所以取消后可能还会有几个任务继续完成。

四、任务取消和异常处理

用户处理到一半不想等了,点了取消,这时候怎么办?

TaskPool 提供了 cancel 方法,可以取消指定的任务。但有个前提:任务必须还没开始执行。如果任务已经在后台线程里跑了,cancel 是中断不了的——因为后台线程正在执行代码,没法强制停下来。所以取消后,已经在跑的那几个任务会继续跑完,排队中的任务会被取消。

我的做法是:取消时先调用 cancelAll 取消所有排队任务,然后设置一个 cancelled 标记。已经在执行的任务完成后,检查 cancelled 标记,如果已取消就不更新 UI、不保存结果。这样用户看到的效果就是"取消后很快停止",虽然实际上可能还有一两个任务在后台跑完。

异常处理方面,任务执行过程中可能抛出异常。比如图片文件损坏、存储空间不足、格式不支持。我的做法是在任务函数内部用 try-catch 捕获异常,返回 false 表示失败,而不是让异常直接抛到主线程。这样单个任务失败不会影响其他任务,页面可以显示"成功 48 张,失败 2 张"。

但有一种异常需要特别注意:TaskPool 本身的异常。比如任务函数不是并发函数、参数类型不匹配、任务数量超过限制。这些异常会在 execute 时抛出,需要在调用层捕获。我的建议是在 ImageProcessor 里统一捕获,给用户一个明确的提示,而不是让应用崩溃。

五、TaskPool 和 Worker 怎么选

说到后台线程,HarmonyOS 还有一个 Worker API。很多人会问:TaskPool 和 Worker 有什么区别?什么时候用哪个?

我的理解是:TaskPool 适合短任务、大量任务、不需要常驻的场景。比如批量图片处理、数据计算、文件解析。TaskPool 由系统管理线程池,你不需要关心线程的创建和销毁,提交任务就行。系统会自动复用线程,开销小。

Worker 适合长任务、需要持续运行、需要和主线程频繁通信的场景。比如实时音频处理、长连接管理、持续的数据同步。Worker 是一个独立的线程,你创建它以后它就一直在,你可以随时给它发消息、它也可以随时给你发消息。但 Worker 的创建和销毁开销比较大,不适合频繁创建。

简单的判断标准:任务执行时间在几秒以内、任务数量多、不需要持续通信,用 TaskPool。任务需要持续运行、需要双向频繁通信,用 Worker。

我这个图片批量处理的场景,每张图片处理几十毫秒,总共五十张,明显是 TaskPool 的适用场景。如果用 Worker,还得自己管理任务队列和并发控制,反而更麻烦。

六、任务粒度和性能边界

最后说几个需要注意的边界情况。

任务粒度。前面说过,单个任务几十毫秒到几百毫秒比较合适。如果任务太大(比如把五十张图片作为一个任务),后台线程跑太久,用户只能等全部完成才能看到结果,体验不好。如果任务太小(比如把图片处理拆成"读取文件"、"压缩"、"保存"三个任务),任务调度的开销会很大,反而更慢。需要根据实际情况找到合适的粒度。

内存占用。TaskPool 的任务在后台线程执行,但参数和返回值需要在线程之间传递。如果参数很大(比如把整张图片的像素数据作为参数传入),序列化和反序列化的开销会很大,还可能导致内存溢出。我的做法是只传文件路径和参数,不传大数据。任务函数里自己读取文件、处理、保存,结果只返回成功或失败。

结果回传频率。每个任务完成后都回传结果更新进度,如果任务很多、完成很快,频繁的结果回传可能会让主线程忙于更新 UI。我的做法是合并进度更新,比如每完成 5 个任务才更新一次进度条,或者用 requestAnimationFrame 节流。

页面销毁。用户在处理过程中退出了页面,这时候任务还在后台跑。如果任务完成后回调里引用了已经销毁的页面组件,可能会导致异常。我的做法是在页面的 aboutToDisappear 里调用 cancelAll,并且在回调里检查页面是否还存在,不存在就不更新 UI。

API 版本兼容性。TaskPool 的 API 在不同 HarmonyOS 版本之间可能有差异,比如 Task 构造函数的参数、execute 的返回值类型、cancel 的行为。上面的代码是基于通用写法,实际接入时需要对照当前 API 版本(API 26)的文档确认。特别是 @Concurrent 装饰器的用法和限制,不同版本可能有变化,需要在真机上验证。

把耗时任务从 UI 线程搬到 TaskPool,看起来就是换了个执行方式,但实际上涉及到任务拆分、进度更新、取消处理、异常捕获、内存管理等多个环节。把这些环节理顺了,用户不管选多少张图片一起处理,页面都能保持流畅。

赞(0)
未经允许不得转载:171主机测评 » HarmonyOS 7 TaskPool 实战:别让耗时任务把 UI 线程拖慢
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址