跳到主內容

併發與隔離區

使用 Dart Isolate 在 Flutter 中實現多執行緒。

所有 Dart 程式碼都在 Isolate 中執行。Isolate 類似於執行緒,但不同之處在於它們擁有各自獨立的記憶體。它們不會以任何方式共享狀態,只能透過訊息進行通訊。預設情況下,Flutter 應用的所有工作都在單個 Isolate(即主 Isolate)上執行。在大多數情況下,這種模型允許更簡單的程式設計,並且足夠快,不會導致應用 UI 失去響應。

然而,有時應用需要執行非常繁重的計算,這可能會導致“UI 卡頓”(不流暢的動畫)。如果你的應用因此出現卡頓,可以將這些計算轉移到輔助 Isolate。這允許底層執行時環境與主 UI Isolate 的工作併發執行計算,並利用多核裝置的優勢。

每個 Isolate 都有自己的記憶體和事件迴圈。事件迴圈按事件新增到事件佇列的順序處理事件。在主 Isolate 上,這些事件包括處理使用者在 UI 上的點選、執行函式以及在螢幕上繪製幀等。下圖顯示了一個包含 3 個等待處理事件的示例事件佇列。

The main isolate diagram

為了實現流暢的渲染,Flutter 每秒會將“繪製幀”事件新增到事件佇列中 60 次(對於 60Hz 裝置)。如果這些事件未及時處理,應用就會出現 UI 卡頓,或者更糟糕的是,完全失去響應。

Event jank diagram

每當某個程序無法在幀間隙(即兩幀之間的時間)內完成時,最好將工作解除安裝到另一個 Isolate,以確保主 Isolate 能夠保持每秒 60 幀的輸出。當你在 Dart 中生成一個 Isolate 時,它可以在不阻塞主 Isolate 的情況下與其併發處理工作。

你可以閱讀 Dart 文件中的 併發頁面,瞭解更多關於 Isolate 和事件迴圈在 Dart 中如何工作的資訊。

在 YouTube 新標籤頁中觀看:“Isolates and the event loop | Flutter in Focus”

Isolate 的常見用例

#

關於何時應該使用 Isolate,只有一個硬性規則:當繁重的計算導致 Flutter 應用出現 UI 卡頓,且計算時間超過了 Flutter 的幀間隙時,就應該使用它們。

Event jank diagram

任何程序都有可能需要更長的時間來完成,具體取決於實現方式和輸入資料,因此無法列出所有需要考慮使用 Isolate 的情況。

話雖如此,Isolate 通常用於以下場景:

  • 從本地資料庫讀取資料
  • 傳送推送通知
  • 解析和解碼大型資料檔案
  • 處理或壓縮照片、音訊檔案和影片檔案
  • 轉換音訊和影片檔案
  • 使用 FFI 時需要非同步支援的情況
  • 對複雜列表或檔案系統應用過濾

Isolate 之間的訊息傳遞

#

Dart 的 Isolate 是 Actor 模型的一種實現。它們只能透過訊息傳遞進行相互通訊,這是透過 Port 物件完成的。當訊息在它們之間“傳遞”時,通常會從傳送方 Isolate 複製到接收方 Isolate。這意味著傳遞給 Isolate 的任何值,即使在該 Isolate 中被修改,也不會改變原始 Isolate 中的值。

當傳遞給 Isolate 時,唯一 不會被複制的物件是本來就不可變的物件,例如 String 或不可修改的位元組流。當你向 Isolate 傳遞不可變物件時,為了獲得更好的效能,會透過埠傳送該物件的引用,而不是複製物件。由於不可變物件無法更新,這有效地保持了 Actor 模型的行為。

此規則的一個例外是當 Isolate 使用 Isolate.exit 方法傳送訊息並退出時。由於傳送方 Isolate 在傳送訊息後將不復存在,它可以將訊息的所有權從一個 Isolate 轉移到另一個,從而確保只有一個 Isolate 能訪問該訊息。

傳送訊息的兩個最底層原語是 SendPort.send(在傳送時複製可變訊息)和 Isolate.exit(傳送對訊息的引用)。Isolate.runcompute 在底層都使用了 Isolate.exit

短生命週期 Isolate

#

在 Flutter 中將程序轉移到 Isolate 的最簡單方法是使用 Isolate.run 方法。該方法會生成一個 Isolate,向其中傳遞一個回撥以開始計算,返回計算結果,並在計算完成後關閉該 Isolate。這一切都與主 Isolate 併發進行,不會阻塞它。

Isolate diagram

Isolate.run 方法需要一個引數,即在新 Isolate 上執行的回撥函式。該回調函式的簽名必須恰好包含一個必需的、未命名的引數。當計算完成時,它會將回調的值返回給主 Isolate,並退出所生成的 Isolate。

例如,考慮一段從檔案中載入大型 JSON 資料並將其轉換為自定義 Dart 物件的程式碼。如果 JSON 解碼過程沒有被解除安裝到新的 Isolate 中,該方法會導致 UI 失去響應數秒。

dart
// Produces a list of 211,640 photo objects.
// (The JSON file is ~20MB.)
Future<List<Photo>> getPhotos() async {
  final String jsonString = await rootBundle.loadString('assets/photos.json');
  final List<Photo> photos = await Isolate.run<List<Photo>>(() {
    final List<Object?> photoData = jsonDecode(jsonString) as List<Object?>;
    return photoData.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
  });
  return photos;
}

有關在後臺使用 Isolate 解析 JSON 的完整演練,請參閱 此 cookbook 指南

有狀態的、長生命週期 Isolate

#

短生命週期 Isolate 使用方便,但生成新 Isolate 以及在 Isolate 之間複製物件會帶來效能開銷。如果你重複使用 Isolate.run 執行相同的計算,透過建立不立即退出的 Isolate 可能會獲得更好的效能。

為此,你可以使用 Isolate.run 所抽象化的一些更底層的 Isolate 相關 API:

當你使用 Isolate.run 方法時,新 Isolate 在向主 Isolate 返回單個訊息後會立即關閉。有時,你需要長生命週期的 Isolate,以便它們能夠隨著時間的推移相互傳遞多條訊息。在 Dart 中,你可以使用 Isolate API 和 Ports 來實現這一點。這些長生命週期的 Isolate 通常被稱為後臺工作執行緒(background workers)。

當某個特定程序需要在應用的整個生命週期內重複執行,或者當某個程序需要執行一段時間並需要向主 Isolate 返回多個值時,長生命週期的 Isolate 非常有用。

或者,你也可以使用 worker_manager 來管理長生命週期的 Isolate。

ReceivePort 和 SendPort

#

使用兩個類(除了 Isolate 之外)來設定 Isolate 之間的長生命週期通訊:ReceivePortSendPort。這些埠是 Isolate 相互通訊的唯一途徑。

Ports 的行為類似於 Streams,其中 StreamControllerSink 在一個 Isolate 中建立,而監聽器在另一個 Isolate 中設定。在這個類比中,StreamController 稱為 SendPort,你可以使用 send() 方法“新增”訊息。ReceivePort 是監聽器,當這些監聽器收到新訊息時,它們會呼叫提供的回撥,並將訊息作為引數。

關於設定主 Isolate 與工作執行緒 Isolate 之間雙向通訊的深入解釋,請參考 Dart 文件中的示例。

在 Isolate 中使用平臺外掛

#

你可以在後臺 Isolate 中使用平臺外掛。這使得外掛能夠將繁重的、依賴於平臺的計算解除安裝到不會阻塞 UI 的 Isolate 中。例如,假設你正在使用原生宿主 API(如 Android 上的 Android API,iOS 上的 iOS API 等)加密資料。以前,將資料 序列化(marshaling) 到宿主平臺可能會浪費 UI 執行緒時間,現在可以在後臺 Isolate 中完成。

平臺通道 Isolate 使用 BackgroundIsolateBinaryMessenger API。以下程式碼片段展示了在後臺 Isolate 中使用 shared_preferences 包的示例。

dart
import 'dart:isolate';

import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  // Identify the root isolate to pass to the background isolate.
  RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
  Isolate.spawn(_isolateMain, rootIsolateToken);
}

Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async {
  // Register the background isolate with the root isolate.
  BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

  // You can now use the shared_preferences plugin.
  SharedPreferences sharedPreferences = await SharedPreferences.getInstance();

  print(sharedPreferences.getBool('isDebug'));
}

Isolate 的侷限性

#

如果你是從支援多執行緒的語言轉到 Dart 的,可能會期望 Isolate 的行為像執行緒一樣,但事實並非如此。Isolate 擁有自己的全域性欄位,只能透過訊息傳遞進行通訊,從而確保 Isolate 中的可變物件僅在單個 Isolate 中可訪問。因此,Isolate 受限於其自身記憶體的訪問許可權。例如,如果你有一個名為 configuration 的全域性可變變數,它會被複製為生成的 Isolate 中的一個新全域性欄位。如果你在該生成的 Isolate 中更改該變數,主 Isolate 中的變數將保持不變。即使你將 configuration 物件作為訊息傳遞給新 Isolate,情況也是如此。這就是 Isolate 的設計方式,在考慮使用 Isolate 時務必記住這一點。

Web 平臺與 compute

#

Dart Web 平臺(包括 Flutter Web)不支援 Isolate。如果你正在針對 Web 開發 Flutter 應用,可以使用 compute 方法確保程式碼能夠編譯。compute() 方法在 Web 上在主執行緒上執行計算,但在移動裝置上會生成一個新執行緒。在移動和桌面平臺上,await compute(fun, message) 等同於 await Isolate.run(() => fun(message))

有關 Web 上併發的更多資訊,請檢視 dart.dev 上的 併發文件

無法訪問 rootBundledart:ui 方法

#

所有的 UI 任務和 Flutter 本身都與主 Isolate 繫結。因此,你無法在生成的 Isolate 中使用 rootBundle 訪問資源,也無法在生成的 Isolate 中執行任何 Widget 或 UI 工作。

從宿主平臺到 Flutter 的外掛訊息有限

#

利用後臺 Isolate 平臺通道,你可以在 Isolate 中使用平臺通道向宿主平臺(例如 Android 或 iOS)傳送訊息,並接收這些訊息的響應。但是,你無法接收來自宿主平臺的未經請求的訊息。

例如,你不能在後臺 Isolate 中設定長生命週期的 Firestore 監聽器,因為 Firestore 使用平臺通道將更新推送給 Flutter,這些是未經請求的訊息。不過,你可以在後臺查詢 Firestore 以獲取響應。

更多資訊

#

有關 Isolate 的更多資訊,請檢視以下資源

  • 如果你使用大量 Isolate,請考慮使用 Flutter 中的 IsolateNameServer 類,或使用為不使用 Flutter 的 Dart 應用克隆此功能的 pub 包。
  • Dart 的 Isolate 是 Actor 模型的一種實現。
  • isolate_agents 是一個抽象了 Ports 並簡化了長生命週期 Isolate 建立過程的包。
  • 閱讀有關 BackgroundIsolateBinaryMessenger API 的更多資訊,請檢視 官方公告