上個月做一個資料整理頁,頁面本身不複雜:從本地資料庫撈一批記錄,按規則清洗,再生成一份可展示的分組列表。邏輯寫起來很順,`async/await` 一套下來,程式碼看著也挺規整。
問題是,上真機之後不對勁。
頁面第一次進入會有一個很短的卡頓,列表滾動到一半偶爾掉幀,點篩選時按鈕回饋慢半拍。一開始我還以為是 ArkUI 列表寫得不夠克制,後來把日誌打細一點才發現,真正拖後腿的是那段「看起來只是處理陣列」的同步計算。
async 不是多執行緒。這個坑,做前端或者行動端的人應該都踩過。它能把非同步流程寫得舒服一點,但 CPU 真在主執行緒上跑的時候,UI 該卡還是卡。
後來這塊我拆成了兩層:短任務走 TaskPool,長活兒交給 Worker。不是為了顯得架構高級,純粹是被卡頓逼出來的。
HarmonyOS 裡談並發,很多文章會直接給一個 TaskPool 範例:寫一個 @Concurrent 函式,丟給 taskpool.execute(),拿到結果更新 UI。這個例子沒問題,但如果專案稍微複雜一點,真正難的不是「怎麼調 API」,而是下面幾個問題:
我現在的判斷比較簡單:
TaskPool 適合「短、散、可切分」的計算任務。Worker 適合「長、獨立、有自己狀態」的背景任務。
比如:
場景更合適的方式原因列表資料清洗、排序、分組TaskPool任務短,輸入輸出清晰,不想維護執行緒生命週期多段文字規則匹配TaskPool / TaskGroup可以拆成多份並行處理,再聚合結果長時間日誌解析Worker任務持續時間長,可能需要進度、暫停、取消持續 OCR 佇列、檔案同步佇列Worker有佇列狀態,生命週期獨立,不能每次都臨時起任務UI 動畫、元件狀態修改主執行緒背景執行緒不要直接碰 UI這篇不打算寫成 API 字典。就按一個「本地資料整理頁」的例子,把我最後落地的寫法拆出來。
當時頁面裡的資料大概長這樣:
ts 体验AI代码助手 代码解读复制代码export interface RawRecord {
id: string;
title: string;
type: string;
createdAt: number;
rawText: string;
score?: number;
}
export interface ViewSection {
groupName: string;
count: number;
items: ViewItem[];
}
export interface ViewItem {
id: string;
title: string;
summary: string;
level: 'low' | 'middle' | 'high';
}
一開始我犯過一個懶:從頁面狀態裡直接拿陣列,塞給背景任務。後面越改越彆扭,因為頁面物件裡混進了不少展示狀態,比如是否展開、是否選取、臨時高亮欄位。這些東西對計算沒用,傳過去還容易把邊界搞髒。
後來我改成了三步:
這個拆法有點囉嗦,但後面排問題會輕鬆很多。
先看一個最小可用的版本。
ts 体验AI代码助手 代码解读复制代码// common/model/record.ts
export interface RawRecord {
id: string;
title: string;
type: string;
createdAt: number;
rawText: string;
score?: number;
}
export interface ViewItem {
id: string;
title: string;
summary: string;
level: string;
}
export interface ViewSection {
groupName: string;
count: number;
items: ViewItem[];
}
ts 体验AI代码助手 代码解读复制代码// common/worker/record_task.ts
import { RawRecord, ViewItem, ViewSection } from '../model/record';
function buildSummary(text: string): string {
if (text.length <= 42) {
return text;
}
return `${text.substring(0, 42)}...`;
}
function calcLevel(score: number): string {
if (score >= 80) {
return 'high';
}
if (score >= 50) {
return 'middle';
}
return 'low';
}
// 注意:TaskPool 執行的函式要標註 @Concurrent。
// 這裡盡量保持純函式:不讀頁面狀態,不操作 UI,不拿 Context。
@Concurrent
export function buildRecordSections(records: RawRecord[]): ViewSection[] {
const map = new Map<string, ViewItem[]>();
for (const record of records) {
const groupName = record.type.length > 0 ? record.type : '未分類';
const item: ViewItem = {
id: record.id,
title: record.title,
summary: buildSummary(record.rawText),
level: calcLevel(record.score ?? 0)
};
const list = map.get(groupName) ?? [];
list.push(item);
map.set(groupName, list);
}
const sections: ViewSection[] = [];
map.forEach((items: ViewItem[], groupName: string) => {
items.sort((a: ViewItem, b: ViewItem) => a.title.localeCompare(b.title));
sections.push({
groupName,
count: items.length,
items
});
});
sections.sort((a: ViewSection, b: ViewSection) => b.count - a.count);
return sections;
}
頁面裡不要直接到處散落 taskpool.execute()。我一般會再包一層服務類,這樣後面做取消、降級、日誌都會方便一點。
ts 体验AI代码助手 代码解读复制代码// common/service/RecordComputeService.ts
import { taskpool } from '@kit.ArkTS';
import { RawRecord, ViewSection } from '../model/record';
import { buildRecordSections } from '../worker/record_task';
export class RecordComputeService {
async buildSections(records: RawRecord[]): Promise<ViewSection[]> {
if (records.length === 0) {
return [];
}
// 只傳純資料。這裡不要傳 this,不要傳元件物件,不要傳 UI 狀態。
const task = new taskpool.Task('build-record-sections', buildRecordSections, records);
const result = await taskpool.execute(task, taskpool.Priority.MEDIUM);
return result as ViewSection[];
}
}
頁面呼叫時,要特別注意「結果回來時頁面還在不在」。這個問題很常見,尤其是使用者快速返回、切 tab、重複進入頁面的時候。
ts 体验AI代码助手 代码解读复制代码// pages/RecordPage.ets
import { RecordComputeService } from '../common/service/RecordComputeService';
import { RawRecord, ViewSection } from '../common/model/record';
@Entry
@Component
struct RecordPage {
private computeService: RecordComputeService = new RecordComputeService();
private alive: boolean = true;
private requestSeq: number = 0;
@State loading: boolean = false;
@State sections: ViewSection[] = [];
@State errorText: string = '';
aboutToDisappear(): void {
this.alive = false;
}
async reload(records: RawRecord[]): Promise<void> {
const seq = ++this.requestSeq;
this.loading = true;
this.errorText = '';
try {
const result = await this.computeService.buildSections(records);
// 頁面走了,或者後一次請求已經發出,舊結果就不要回寫了。
if (!this.alive || seq !== this.requestSeq) {
return;
}
this.sections = result;
} catch (err) {
if (this.alive && seq === this.requestSeq) {
this.errorText = `資料整理失敗:${JSON.stringify(err)}`;
}
} finally {
if (this.alive && seq === this.requestSeq) {
this.loading = false;
}
}
}
build() {
Column() {
if (this.loading) {
Text('整理中...')
.fontSize(14)
.opacity(0.7)
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontColor(Color.Red)
.fontSize(13)
}
List() {
ForEach(this.sections, (section: ViewSection) => {
ListItem() {
Column() {
Text(`${section.groupName} · ${section.count}`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
ForEach(section.items, item => {
Text(`${item.title} - ${item.summary}`)
.fontSize(13)
.opacity(0.75)
}, item => item.id)
}
}
}, (section: ViewSection) => section.groupName)
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding(16)
}
}
這段程式碼看著普通,但有兩個點是我後來才養成習慣的:
一個是 requestSeq。 只要頁面上有搜尋、篩選、重新整理這種連續觸發的入口,就別相信非同步回傳順序。舊任務慢一點回來,把新結果覆蓋掉,這種 bug 很煩,而且不好重現。
另一個是 alive。 頁面消失之後繼續更新 @State,有時候不會馬上炸,但它會把狀態鏈路搞得很髒。尤其在複雜頁面裡,後面會出現一些莫名其妙的刷新。
如果一批資料特別大,我不太建議把整個大陣列一次性塞進去。更穩的方式是按業務邊界切塊,比如按月份、按類型、按檔案批次拆開。
ts 体验AI代码助手 代码解读复制代码// common/worker/record_task.ts
@Concurrent
export function buildRecordSectionsByChunk(records: RawRecord[], chunkName: string): ViewSection[] {
const sections = buildRecordSections(records);
// 給結果帶一點來源資訊,方便聚合和排查。
return sections.map((section: ViewSection) => {
return {
groupName: `${chunkName}/${section.groupName}`,
count: section.count,
items: section.items
} as ViewSection;
});
}
ts 体验AI代码助手 代码解读复制代码// common/service/RecordComputeService.ts
import { taskpool } from '@kit.ArkTS';
import { RawRecord, ViewSection } from '../common/model/record';
import { buildRecordSectionsByChunk } from '../worker/record_task';
export interface RecordChunk {
name: string;
records: RawRecord[];
}
export class RecordComputeService {
async buildSectionsByChunks(chunks: RecordChunk[]): Promise<ViewSection[]> {
if (chunks.length === 0) {
return [];
}
const group = new taskpool.TaskGroup();
for (const chunk of chunks) {
// 每一塊都是獨立輸入,避免任務之間共享可變物件。
group.addTask(buildRecordSectionsByChunk, chunk.records, chunk.name);
}
const result = await taskpool.execute(group, taskpool.Priority.MEDIUM) as Object[];
const merged: ViewSection[] = [];
for (const item of result) {
const sections = item as ViewSection[];
merged.push(...sections);
}
return merged;
}
}
這裡有個小經驗:不要為了並發而切得太碎。
我試過把幾千條記錄拆成幾十個小任務,結果並沒有更快,調度、序列化、結果聚合的開銷反而上來了。後來按「每塊幾百到一兩千條」粗粒度切,整體更穩。
這個數字不是標準答案,要看資料結構、演算法複雜度和設備效能。我的習慣是先保守切,真有效能問題再用日誌和耗時統計說話。
TaskPool 用起來省心,但它不適合所有場景。
比如有一個截圖整理類功能:使用者匯入一批圖片,背景要做 OCR、規則匹配、去重、寫庫,還要持續回傳進度。這個任務不是「算一下就結束」,它有自己的佇列、有狀態、有重試,還可能持續幾十秒。
這種我會放到 Worker。
目錄大概這樣:
text 体验AI代码助手 代码解读复制代码entry/src/main/ets/
├── pages/
│ └── ImportPage.ets
├── workers/
│ └── ImportWorker.ets
└── common/
├── model/
└── service/
主執行緒建立 Worker:
ts 体验AI代码助手 代码解读复制代码// common/service/ImportWorkerClient.ts
import { worker, MessageEvents, ErrorEvent } from '@kit.ArkTS';
export interface ImportJob {
jobId: string;
files: string[];
}
export interface ImportProgress {
jobId: string;
current: number;
total: number;
message: string;
}
export class ImportWorkerClient {
private threadWorker?: worker.ThreadWorker;
private currentJobId: string = '';
start(job: ImportJob, onProgress: (progress: ImportProgress) => void, onDone: () => void, onError: (msg: string) => void): void {
this.currentJobId = job.jobId;
// Stage 模型下注意 worker 檔案路徑,不要寫成 src/main/ets 的完整路徑。
this.threadWorker = new worker.ThreadWorker('entry/ets/workers/ImportWorker.ets', {
name: 'import-worker'
});
this.threadWorker.onmessage = (event: MessageEvents) => {
const data = event.data as Record<string, Object>;
const type = data['type'] as string;
const jobId = data['jobId'] as string;
// 舊任務或者髒訊息直接丟掉。
if (jobId !== this.currentJobId) {
return;
}
if (type === 'progress') {
onProgress(data['payload'] as ImportProgress);
} else if (type === 'done') {
onDone();
this.release();
} else if (type === 'error') {
onError(data['message'] as string);
this.release();
}
};
this.threadWorker.onerror = (error: ErrorEvent) => {
onError(`Worker 異常:${error.message}`);
this.release();
};
this.threadWorker.postMessage({
type: 'start',
jobId: job.jobId,
files: job.files
});
}
cancel(): void {
this.threadWorker?.postMessage({
type: 'cancel',
jobId: this.currentJobId
});
this.release();
}
release(): void {
this.threadWorker?.terminate();
this.threadWorker = undefined;
this.currentJobId = '';
}
}
Worker 檔案裡只處理背景邏輯:
ts 体验AI代码助手 代码解读复制代码// workers/ImportWorker.ets
import { worker, MessageEvents } from '@kit.ArkTS';
const workerPort = worker.workerPort;
let canceled = false;
function postProgress(jobId: string, current: number, total: number, message: string): void {
workerPort.postMessage({
type: 'progress',
jobId,
payload: {
jobId,
current,
total,
message
}
});
}
async function handleImport(jobId: string, files: string[]): Promise<void> {
canceled = false;
for (let i = 0; i < files.length; i++) {
if (canceled) {
workerPort.postMessage({
type: 'error',
jobId,
message: '使用者取消匯入'
});
return;
}
const file = files[i];
postProgress(jobId, i + 1, files.length, `正在處理:${file}`);
// 這裡放真正的耗時邏輯:OCR、規則匹配、去重、寫臨時結果等。
// 範例裡只保留結構,不硬湊一個假的演算法。
await doOneFile(file);
}
workerPort.postMessage({
type: 'done',
jobId
});
}
async function doOneFile(file: string): Promise<void> {
// 實際專案裡建議繼續拆服務,別把所有邏輯堆在 worker 檔案裡。
// 這裡可以做檔案讀取、文字分析、批次寫入前的資料準備。
console.info(`processing file: ${file}`);
}
workerPort.onmessage = (event: MessageEvents) => {
const data = event.data as Record<string, Object>;
const type = data['type'] as string;
const jobId = data['jobId'] as string;
if (type === 'start') {
const files = data['files'] as string[];
handleImport(jobId, files).catch((err: Error) => {
workerPort.postMessage({
type: 'error',
jobId,
message: err.message
});
});
} else if (type === 'cancel') {
canceled = true;
}
};
Worker 的麻煩點不是建立,而是收尾。
很多問題都出在「我以為它自己會停」。實際上 Worker 更像一個你手動養出來的背景執行緒:用完要 terminate(),頁面退出要釋放,任務取消也要釋放。否則看不出明顯報錯,但記憶體和執行緒資源會被占著。
專案裡我會用下面這幾個問題判斷。
能結束,優先 TaskPool。比如排序、分組、規則計算、資料壓縮前處理。
如果任務天然要跑很久,比如持續同步、批次匯入、背景佇列,就別硬塞 TaskPool。TaskPool 適合把任務交給系統調度,不適合自己在裡面寫一個長期迴圈。
沒有狀態,或者狀態只來自輸入參數,TaskPool 很舒服。
如果任務裡有佇列、重試次數、暫停恢復、進度回調、快取狀態,Worker 更清楚。因為這時候你已經不是在跑一個函式了,而是在維護一個背景執行單元。
TaskPool 也能做任務和宿主執行緒通訊,但如果是持續進度、階段回傳、使用者取消、錯誤恢復這一類,我更傾向 Worker。寫起來沒那麼「漂亮」,但狀態關係比較直。
背景執行緒最怕傳一堆複雜物件。我的原則是:
text 体验AI代码助手 代码解读复制代码能傳 number/string/boolean/普通陣列/普通物件,就別傳帶行為的物件。
能傳 id,就別傳整個業務實體。
能傳快照,就別傳還會被 UI 修改的參照。
這不是潔癖,是為了少踩坑。
async/await 只是讓非同步程式碼更像同步流程,它不會自動把 CPU 計算挪到背景執行緒。你在 async 函式裡寫一個很重的 for 迴圈,主執行緒照樣要扛。
我現在看到下面這種程式碼就會警惕:
ts 体验AI代码助手 代码解读复制代码async function refresh(): Promise<void> {
const rows = await queryRows();
// 這裡如果資料量大,本質還是主執行緒同步計算。
const sections = buildBigSections(rows);
this.sections = sections;
}
要麼把 buildBigSections 拆到 TaskPool,要麼在資料源階段就減小計算量。
不要在 TaskPool 函式或者 Worker 裡直接改 @State,也不要傳元件實例進去。背景只負責算,UI 更新回到頁面層做。
這個邊界一旦破了,後面程式碼會非常難維護。
搜尋框輸入、篩選條件切換、下拉重新整理,都可能造成多個任務同時在路上。不要假設後發的任務一定後回來。
requestSeq 這種寫法雖然土,但好用。
ts 体验AI代码助手 代码解读复制代码const seq = ++this.requestSeq;
const result = await this.computeService.buildSections(records);
if (seq !== this.requestSeq) {
return;
}
this.sections = result;
Worker 不是臨時 Promise。頁面消失、任務完成、任務失敗、使用者取消,都要考慮釋放。
ts 体验AI代码助手 代码解读复制代码aboutToDisappear(): void {
this.importWorkerClient.cancel();
}
當然,cancel() 裡不要只發一個取消訊息,最好兜底 terminate(),否則異常路徑裡很容易漏。
並發不是越多越快。行動端尤其明顯,調度、通訊、資料拷貝都有成本。
我一般先找「業務上天然可切」的邊界,比如檔案、月份、類型、批次。不要為了追求並發,把 1000 條資料切成 1000 個任務。
背景任務失敗時,頁面應該知道失敗原因。尤其是批次處理類功能,如果只在 Worker 裡 console.error,使用者看到的就是一個永遠轉圈的 loading。
建議統一訊息結構:
ts 体验AI代码助手 代码解读复制代码export interface WorkerMessage<T> {
type: 'progress' | 'done' | 'error';
jobId: string;
payload?: T;
message?: string;
}
別到處臨時拼物件,後期很難查。
別把資料庫查出來的完整物件一股腦傳給任務。很多欄位背景根本用不上。先在主執行緒做一層輕量對映,只保留計算必需欄位。
ts 体验AI代码助手 代码解读复制代码const input = rows.map((row): RawRecord => {
return {
id: row.id,
title: row.title,
type: row.type,
createdAt: row.createdAt,
rawText: row.rawText,
score: row.score
};
});
看著多寫了幾行,換來的是任務邊界清楚,資料傳輸也更輕。
使用者不怕等幾秒,怕的是不知道你在幹嘛。長任務放 Worker 時,階段性回傳進度很有必要。
ts 体验AI代码助手 代码解读复制代码postProgress(jobId, current, total, '正在分析文字');
postProgress(jobId, current, total, '正在去重');
postProgress(jobId, current, total, '正在寫入本地結果');
別小看這幾行,體驗差很多。
背景任務失敗時,能不能退回主執行緒簡化處理?能不能只顯示部分結果?能不能讓使用者重新觸發?
我一般會給服務層留一個 fallback:
ts 体验AI代码助手 代码解读复制代码export class RecordComputeService {
async safeBuildSections(records: RawRecord[]): Promise<ViewSection[]> {
try {
return await this.buildSections(records);
} catch (err) {
console.error(`TaskPool failed: ${JSON.stringify(err)}`);
// 資料量很小時可以退回同步計算,大資料量不要硬退。
if (records.length <= 100) {
return this.buildSectionsOnMainThread(records);
}
throw err;
}
}
private buildSectionsOnMainThread(records: RawRecord[]): ViewSection[] {
// 可以複用同一套純函式,或者做一個簡化版本。
// 注意:這裡只適合小資料兜底。
return [];
}
}
降級不是為了掩蓋 bug,是為了不要讓使用者卡死在一個失敗狀態裡。
並發問題最怕日誌沒上下文。
ts 体验AI代码助手 代码解读复制代码console.info(`[import:${jobId}] start, total=${files.length}`);
console.info(`[import:${jobId}] progress ${current}/${total}`);
console.error(`[import:${jobId}] failed: ${message}`);
線上排查時,這種日誌比「start、done、error」有用太多。
我覺得 TaskPool + Worker 最適合下面幾類 HarmonyOS 應用:
如果你的頁面只是發個網路請求、顯示個表單,那沒必要一上來就 Worker。並發能力不是裝飾品,用早了反而增加複雜度。
但只要你發現頁面卡頓來自 CPU 計算,而不是網路等待、元件繪製或者資料庫查詢,那就該考慮把計算拆出去了。
TaskPool 和 Worker 這兩個東西,真正用順之後,會改變一點寫 HarmonyOS 頁面的習慣。
以前寫頁面,很容易把資料查詢、規則計算、狀態更新、錯誤處理都揉在一個元件裡。短期確實快,後面只要資料量一上來,卡頓、競態、髒狀態就會一起冒出來。
現在我更願意把頁面當成「狀態展示層」:它發起任務,接收結果,處理使用者回饋;至於那些費 CPU、耗時間、還可能失敗的活兒,放到 TaskPool 或 Worker 後面去。
這不是為了追求所謂架構感。行動端開發很多時候就是這樣,不卡的頁面看起來沒什麼技術含量,真卡起來才知道前面省掉的邊界,後面都要還。