华为鸿蒙开发高级篇04-多线程与并发:TaskPool/Worker 实战(图片批量压缩案例)
高级系列第 4 篇 · 案例:相册批量压缩工具——一次选择 50 张图,压缩、保存、进度展示,UI 全程不卡顿。 目标:搞清楚 ArkTS 并发模型(TaskPool vs Worker),掌握耗时任务下放子线程、线程间数据传递与进度回传的完整套路。
一、案例背景
图片压缩、大文件解析、模型推理这类 CPU 密集任务如果在主线程(UI 线程)执行,结果就是"点击后白屏、滑动掉帧、甚至 ANR"。正确的做法是:UI 线程只负责交互与渲染,重活交给并发线程,结果回传刷新。
ArkTS 提供两套并发方案:
| 方案 | 适用 | 特点 |
|---|---|---|
| TaskPool | 一次性/短任务、可复用线程池 | 自动调度、任务级并发、推荐首选 |
| Worker | 常驻长任务、需要独立生命周期 | 手动创建/销毁、适合后台持续作业 |
图片批量压缩是典型的"短任务队列",用 TaskPool 最合适;文末给 Worker 的对照实现。
二、核心原理:TaskPool 工作方式
主线程 ── execute(任务函数, 参数) ──> TaskPool(系统线程池) <── onReceiveData(进度) ─── 子线程回调 <── Promise<结果> ───────── 任务完成要点:
- 任务函数必须是独立函数或独立 @Concurrent 函数,不能依赖闭包捕获主线程对象。
- 传递的参数和返回值必须可序列化(基本类型、数组、普通对象;不能传自定义类实例/方法)。
- 进度回传用
sendData/onReceiveData,或直接返回聚合结果分批展示。
三、实际开发代码:图片批量压缩
3.1 并发任务函数(@Concurrent)
// concurrency/compressTask.ets import { taskpool } from '@kit.ArkTS' // @Concurrent:标记为可在线程池执行的函数 // 入参/返回值必须可序列化:这里传图片路径 + 压缩参数 @Concurrent function compressOne(arg: CompressArg): CompressResult { const { src, quality, index } = arg // 真实场景:读取 image source → 解码 → 压缩编码 → 写回文件 // 这里用伪实现模拟耗时与结果,替换为实际图像编解码代码即可 const start = Date.now() // 模拟压缩耗时 50~120ms while (Date.now() - start < 60 + (index % 60)) { // busy wait 模拟 CPU 密集 } const outPath = src.replace('.jpg', `_q${quality}.jpg`) return { index, outPath, savedBytes: 1024 * (quality < 60 ? 320 : 180) // 模拟压缩后大小 } } export interface CompressArg { src: string quality: number index: number } export interface CompressResult { index: number outPath: string savedBytes: number }3.2 批量调度:TaskPool + 并发限制 + 进度
// service/CompressService.ets import { taskpool } from '@kit.ArkTS' import { compressOne, CompressArg, CompressResult } from '../concurrency/compressTask' export class CompressService { // 一次最多并发 3 个任务,避免资源打满 private readonly maxConcurrent = 3 // 返回进度回调:onProgress(index, total) async compressAll( sources: string[], quality: number, onProgress: (done: number, total: number) => void ): Promise<CompressResult[]> { const results: CompressResult[] = [] let done = 0 // 任务队列:用 Promise 手动控制并发数(简单版信号量) const queue = sources.map((src, index) => { const arg: CompressArg = { src, quality, index } return taskpool.execute(compressOne, arg).then((r) => r as CompressResult) }) // 分批 await,控制同时执行的个数 for (let i = 0; i < queue.length; i += this.maxConcurrent) { const batch = queue.slice(i, i + this.maxConcurrent) const batchResults = await Promise.all(batch) results.push(...batchResults) done += batchResults.length onProgress(done, sources.length) // 进度回传 UI } return results } }说明:上述"分批 Promise"是最易读的并发限制写法;追求极致吞吐可改用计数信号量(start 一批后立即补充)。真实压缩代码请使用
@kit.ImageKit(ImagePacker/ImageSource)完成解码与编码。
3.3 UI 层:进度展示 + 不卡顿
// pages/CompressPage.ets import { CompressService } from '../service/CompressService' @Entry @Component struct CompressPage { private service: CompressService = new CompressService() // 模拟 50 张图片路径 private sources: string[] = Array.from({ length: 50 }, (_, i) => `/data/img_${i + 1}.jpg`) @State progress: number = 0 @State totalText: string = '' @State running: boolean = false private async startCompress() { if (this.running) return this.running = true this.progress = 0 this.totalText = '开始压缩...' try { const results = await this.service.compressAll(this.sources, 60, (done, total) => { // 进度回调:更新状态,UI 自动刷新;主线程空闲,滑动依旧流畅 this.progress = Math.round((done / total) * 100) this.totalText = `已压缩 ${done}/${total} 张` }) this.totalText = `完成!共 ${results.length} 张,本批省下约 ${this.sumBytes(results)} KB` } catch (e) { this.totalText = `压缩失败:${(e as Error).message}` } finally { this.running = false } } private sumBytes(results: { savedBytes: number }[]): number { return Math.round(results.reduce((s, r) => s + r.savedBytes, 0) / 1024) } build() { Column({ space: 24 }) { Text('相册批量压缩').fontSize(24).fontWeight(FontWeight.Bold) // 进度环(复用第 1 篇的自绘组件思路,这里用系统组件) Progress({ value: this.progress, total: 100 }) .width('100%').color('#007DFF') Text(this.totalText).fontSize(14).fontColor('#86909C') Button(this.running ? '压缩中...' : '开始压缩 50 张', { type: ButtonType.Capsule }) .width('100%') .enabled(!this.running) .onClick(() => this.startCompress()) // 说明:压缩期间可以来回拖动这个列表,验证 UI 不卡 List() { ForEach(Array.from({ length: 20 }, (_, i) => `滑动测试项 ${i + 1}`), (item: string) => { ListItem() { Text(item).fontSize(14).padding(10) } }, (item: string) => item) } .layoutWeight(1) .width('100%') } .padding(24) } }3.4 Worker 对照(常驻任务场景)
TaskPool 之外的场景(长驻后台、独立生命周期)用 Worker:
// workers/workerDemo.ets —— 子线程入口 import { worker, ThreadWorkerGlobalScope } from '@kit.ArkTS' const workerPort = worker.workerPort as ThreadWorkerGlobalScope workerPort.onmessage = (e) => { const { src, quality } = e.data // 执行压缩... const result = { src, quality, ok: true } // 回传结果 workerPort.postMessage(result) } workerPort.onmessageerror = () => { workerPort.close() }// 主线程使用 import { worker } from '@kit.ArkTS' const wk = new worker.ThreadWorker('entry/ets/workers/workerDemo.ets') wk.onmessage = (e) => { /* 接收子线程结果 */ } wk.postMessage({ src: '/data/a.jpg', quality: 60 }) // 用完释放 // wk.terminate()选型建议:默认 TaskPool(自动管理、代码简洁);需要长期存活、手动控制生命周期的后台任务才上 Worker。
四、优化与踩坑
| 问题 | 处理 |
|---|---|
| 报"taskpool execute 参数不可序列化" | 传基本类型/可序列化对象;自定义类实例不能直接传 |
| 任务函数报 undefined | 并发函数必须独立定义(或 @Concurrent),不能引用外部闭包变量 |
| 进度回调不刷新 | 回调里更新@State;若频率过高可节流(如每 5 条一次) |
| 并发开太大反而变慢 | 压测找最优并发数(本例 3~5);压缩任务本身也吃 CPU/IO |
| Worker 消息丢失 | postMessage 是异步的,业务侧做好回调幂等;异常走 onmessageerror 收尾 |
| 真机内存暴涨 | 分批处理 + 及时释放 ImageSource;避免一次性解码全部大图 |
五、小结与延伸
- 并发选型:短任务队列 → TaskPool;长驻后台 → Worker。
- 三要素:@Concurrent 独立任务函数、可序列化参数、进度回传(回调/Promise)。
- 案例沉淀:
CompressService(并发受限的批量任务调度器)+ 进度 UI,可直接套用于文件转换、批量下载、数据迁移等场景。 - 延伸:任务取消(检查取消标志)、任务优先级、与分布式(第 8 篇)结合做跨端任务下发。
下一篇预告:网络层架构实战——统一请求层设计(拦截器/缓存/重试/取消,新闻客户端案例)。