跳到主内容

并发与隔离区

使用 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 的更多信息,请查看 官方公告