別把耗時任務都丟進 async:HarmonyOS 裡 TaskPool 和 Worker 的邊界感

上個月做一個資料整理頁,頁面本身不複雜:從本地資料庫撈一批記錄,按規則清洗,再生成一份可展示的分組列表。邏輯寫起來很順,`async/await` 一套下來,程式碼看著也挺規整。

問題是,上真機之後不對勁。

頁面第一次進入會有一個很短的卡頓,列表滾動到一半偶爾掉幀,點篩選時按鈕回饋慢半拍。一開始我還以為是 ArkUI 列表寫得不夠克制,後來把日誌打細一點才發現,真正拖後腿的是那段「看起來只是處理陣列」的同步計算。

async 不是多執行緒。這個坑,做前端或者行動端的人應該都踩過。它能把非同步流程寫得舒服一點,但 CPU 真在主執行緒上跑的時候,UI 該卡還是卡。

後來這塊我拆成了兩層:短任務走 TaskPool,長活兒交給 Worker。不是為了顯得架構高級,純粹是被卡頓逼出來的。

image.png

為什麼這事值得單獨拿出來講

HarmonyOS 裡談並發,很多文章會直接給一個 TaskPool 範例:寫一個 @Concurrent 函式,丟給 taskpool.execute(),拿到結果更新 UI。這個例子沒問題,但如果專案稍微複雜一點,真正難的不是「怎麼調 API」,而是下面幾個問題:

  • 哪些任務適合 TaskPool,哪些任務別塞進去;
  • 並發任務裡傳什麼資料,別把 UI 狀態、Context、複雜物件亂丟;
  • 任務結果回來時,頁面可能已經銷毀了,怎麼避免回寫髒狀態;
  • 使用者連續點篩選、搜尋、重新整理時,舊任務怎麼處理;
  • Worker 用完不釋放,記憶體和執行緒會悄悄把你坑了。

我現在的判斷比較簡單:

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';
}

一開始我犯過一個懶:從頁面狀態裡直接拿陣列,塞給背景任務。後面越改越彆扭,因為頁面物件裡混進了不少展示狀態,比如是否展開、是否選取、臨時高亮欄位。這些東西對計算沒用,傳過去還容易把邊界搞髒。

後來我改成了三步:

  1. 主執行緒只準備「純輸入資料」;
  2. TaskPool 只做純計算,不知道頁面存在;
  3. 結果回來後,再由頁面決定是否更新狀態。

這個拆法有點囉嗦,但後面排問題會輕鬆很多。

用 TaskPool 處理一次短計算

先看一個最小可用的版本。

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,有時候不會馬上炸,但它會把狀態鏈路搞得很髒。尤其在複雜頁面裡,後面會出現一些莫名其妙的刷新。

多個短任務:TaskGroup 比自己 Promise.all 更穩一點

如果一批資料特別大,我不太建議把整個大陣列一次性塞進去。更穩的方式是按業務邊界切塊,比如按月份、按類型、按檔案批次拆開。

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;
  }
}

這裡有個小經驗:不要為了並發而切得太碎。

我試過把幾千條記錄拆成幾十個小任務,結果並沒有更快,調度、序列化、結果聚合的開銷反而上來了。後來按「每塊幾百到一兩千條」粗粒度切,整體更穩。

這個數字不是標準答案,要看資料結構、演算法複雜度和設備效能。我的習慣是先保守切,真有效能問題再用日誌和耗時統計說話。

Worker:別拿它當高級版 setTimeout

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(),頁面退出要釋放,任務取消也要釋放。否則看不出明顯報錯,但記憶體和執行緒資源會被占著。

image.png

TaskPool 和 Worker 的邊界,我一般這麼定

專案裡我會用下面這幾個問題判斷。

任務是不是短時間就能結束?

能結束,優先 TaskPool。比如排序、分組、規則計算、資料壓縮前處理。

如果任務天然要跑很久,比如持續同步、批次匯入、背景佇列,就別硬塞 TaskPool。TaskPool 適合把任務交給系統調度,不適合自己在裡面寫一個長期迴圈。

任務有沒有自己的狀態?

沒有狀態,或者狀態只來自輸入參數,TaskPool 很舒服。

如果任務裡有佇列、重試次數、暫停恢復、進度回調、快取狀態,Worker 更清楚。因為這時候你已經不是在跑一個函式了,而是在維護一個背景執行單元。

是否需要頻繁和主執行緒通訊?

TaskPool 也能做任務和宿主執行緒通訊,但如果是持續進度、階段回傳、使用者取消、錯誤恢復這一類,我更傾向 Worker。寫起來沒那麼「漂亮」,但狀態關係比較直。

輸入輸出是不是乾淨?

背景執行緒最怕傳一堆複雜物件。我的原則是:

text 体验AI代码助手 代码解读复制代码能傳 number/string/boolean/普通陣列/普通物件,就別傳帶行為的物件。
能傳 id,就別傳整個業務實體。
能傳快照,就別傳還會被 UI 修改的參照。

這不是潔癖,是為了少踩坑。

常見坑位

1. 把 async 當成多執行緒

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,要麼在資料源階段就減小計算量。

2. 背景任務直接操作 UI

不要在 TaskPool 函式或者 Worker 裡直接改 @State,也不要傳元件實例進去。背景只負責算,UI 更新回到頁面層做。

這個邊界一旦破了,後面程式碼會非常難維護。

3. 任務回傳順序覆蓋新狀態

搜尋框輸入、篩選條件切換、下拉重新整理,都可能造成多個任務同時在路上。不要假設後發的任務一定後回來。

requestSeq 這種寫法雖然土,但好用。

ts 体验AI代码助手 代码解读复制代码const seq = ++this.requestSeq;
const result = await this.computeService.buildSections(records);
if (seq !== this.requestSeq) {
  return;
}
this.sections = result;

4. Worker 忘記 terminate

Worker 不是臨時 Promise。頁面消失、任務完成、任務失敗、使用者取消,都要考慮釋放。

ts 体验AI代码助手 代码解读复制代码aboutToDisappear(): void {
  this.importWorkerClient.cancel();
}

當然,cancel() 裡不要只發一個取消訊息,最好兜底 terminate(),否則異常路徑裡很容易漏。

5. 任務切得太碎

並發不是越多越快。行動端尤其明顯,調度、通訊、資料拷貝都有成本。

我一般先找「業務上天然可切」的邊界,比如檔案、月份、類型、批次。不要為了追求並發,把 1000 條資料切成 1000 個任務。

6. 錯誤只打日誌,不回傳狀態

背景任務失敗時,頁面應該知道失敗原因。尤其是批次處理類功能,如果只在 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,是為了不要讓使用者卡死在一個失敗狀態裡。

日誌要帶 jobId / taskName

並發問題最怕日誌沒上下文。

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 應用:

  • 圖片、文字、音訊類素材整理工具;
  • 本地知識庫、截圖管理器、筆記分析工具;
  • 大列表篩選、分組、排序較重的業務頁;
  • 本地檔案批次處理、匯入匯出、格式轉換;
  • 不想把所有耗時邏輯都塞進 UIAbility 的中大型應用。

如果你的頁面只是發個網路請求、顯示個表單,那沒必要一上來就 Worker。並發能力不是裝飾品,用早了反而增加複雜度。

但只要你發現頁面卡頓來自 CPU 計算,而不是網路等待、元件繪製或者資料庫查詢,那就該考慮把計算拆出去了。

結尾

TaskPool 和 Worker 這兩個東西,真正用順之後,會改變一點寫 HarmonyOS 頁面的習慣。

以前寫頁面,很容易把資料查詢、規則計算、狀態更新、錯誤處理都揉在一個元件裡。短期確實快,後面只要資料量一上來,卡頓、競態、髒狀態就會一起冒出來。

現在我更願意把頁面當成「狀態展示層」:它發起任務,接收結果,處理使用者回饋;至於那些費 CPU、耗時間、還可能失敗的活兒,放到 TaskPool 或 Worker 後面去。

這不是為了追求所謂架構感。行動端開發很多時候就是這樣,不卡的頁面看起來沒什麼技術含量,真卡起來才知道前面省掉的邊界,後面都要還。


原文出處:https://juejin.cn/post/7633709567295012900


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝2   💬10   ❤️1
140
🥈
我愛JS
💬2  
8
🥉
Gigi
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登