并发与隔离区
使用 Dart Isolate 在 Flutter 中实现多线程。
所有 Dart 代码都在 Isolate 中运行。Isolate 类似于线程,但不同之处在于它们拥有各自独立的内存。它们不会以任何方式共享状态,只能通过消息进行通信。默认情况下,Flutter 应用的所有工作都在单个 Isolate(即主 Isolate)上执行。在大多数情况下,这种模型允许更简单的编程,并且足够快,不会导致应用 UI 失去响应。
然而,有时应用需要执行非常繁重的计算,这可能会导致“UI 卡顿”(不流畅的动画)。如果你的应用因此出现卡顿,可以将这些计算转移到辅助 Isolate。这允许底层运行时环境与主 UI Isolate 的工作并发执行计算,并利用多核设备的优势。
每个 Isolate 都有自己的内存和事件循环。事件循环按事件添加到事件队列的顺序处理事件。在主 Isolate 上,这些事件包括处理用户在 UI 上的点击、执行函数以及在屏幕上绘制帧等。下图显示了一个包含 3 个等待处理事件的示例事件队列。
为了实现流畅的渲染,Flutter 每秒会将“绘制帧”事件添加到事件队列中 60 次(对于 60Hz 设备)。如果这些事件未及时处理,应用就会出现 UI 卡顿,或者更糟糕的是,完全失去响应。
每当某个进程无法在帧间隙(即两帧之间的时间)内完成时,最好将工作卸载到另一个 Isolate,以确保主 Isolate 能够保持每秒 60 帧的输出。当你在 Dart 中生成一个 Isolate 时,它可以在不阻塞主 Isolate 的情况下与其并发处理工作。
你可以阅读 Dart 文档中的 并发页面,了解更多关于 Isolate 和事件循环在 Dart 中如何工作的信息。
Isolate 的常见用例
#关于何时应该使用 Isolate,只有一个硬性规则:当繁重的计算导致 Flutter 应用出现 UI 卡顿,且计算时间超过了 Flutter 的帧间隙时,就应该使用它们。
任何进程都有可能需要更长的时间来完成,具体取决于实现方式和输入数据,因此无法列出所有需要考虑使用 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.run 和 compute 在底层都使用了 Isolate.exit。
短生命周期 Isolate
#在 Flutter 中将进程转移到 Isolate 的最简单方法是使用 Isolate.run 方法。该方法会生成一个 Isolate,向其中传递一个回调以开始计算,返回计算结果,并在计算完成后关闭该 Isolate。这一切都与主 Isolate 并发进行,不会阻塞它。
Isolate.run 方法需要一个参数,即在新 Isolate 上运行的回调函数。该回调函数的签名必须恰好包含一个必需的、未命名的参数。当计算完成时,它会将回调的值返回给主 Isolate,并退出所生成的 Isolate。
例如,考虑一段从文件中加载大型 JSON 数据并将其转换为自定义 Dart 对象的代码。如果 JSON 解码过程没有被卸载到新的 Isolate 中,该方法会导致 UI 失去响应数秒。
// 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.spawn()和Isolate.exit() -
ReceivePort和SendPort send()方法
当你使用 Isolate.run 方法时,新 Isolate 在向主 Isolate 返回单个消息后会立即关闭。有时,你需要长生命周期的 Isolate,以便它们能够随着时间的推移相互传递多条消息。在 Dart 中,你可以使用 Isolate API 和 Ports 来实现这一点。这些长生命周期的 Isolate 通常被称为后台工作线程(background workers)。
当某个特定进程需要在应用的整个生命周期内重复运行,或者当某个进程需要运行一段时间并需要向主 Isolate 返回多个值时,长生命周期的 Isolate 非常有用。
或者,你也可以使用 worker_manager 来管理长生命周期的 Isolate。
ReceivePort 和 SendPort
#使用两个类(除了 Isolate 之外)来设置 Isolate 之间的长生命周期通信:ReceivePort 和 SendPort。这些端口是 Isolate 相互通信的唯一途径。
Ports 的行为类似于 Streams,其中 StreamController 或 Sink 在一个 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 包的示例。
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 上的 并发文档。
无法访问 rootBundle 或 dart: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 创建过程的包。
- 阅读有关
BackgroundIsolateBinaryMessengerAPI 的更多信息,请查看 官方公告。