并发与隔离区
所有 Dart 代码都在隔离区中运行,隔离区类似于线程,但不同之处在于隔离区拥有自己独立的内存。它们不以任何方式共享状态,只能通过消息传递进行通信。默认情况下,Flutter 应用的所有工作都在一个隔离区上完成——主隔离区。在大多数情况下,这种模型可以简化编程,并且速度足够快,以至于应用程序的用户界面不会变得无响应。
但有时,应用程序需要执行异常庞大的计算,这可能导致“UI 卡顿”(卡顿的运动)。如果您的应用因此出现卡顿,您可以将这些计算转移到一个辅助隔离区。这使得底层运行时环境可以与主 UI 隔离区的工作并发运行计算,并利用多核设备。
每个隔离区都有自己的内存和事件循环。事件循环按照事件添加到事件队列的顺序处理事件。在主隔离区上,这些事件可以是处理用户在 UI 中的点击、执行函数,或是在屏幕上绘制帧。下图显示了一个包含 3 个待处理事件的事件队列示例。
为了流畅渲染,Flutter 会每秒向事件队列添加 60 次“绘制帧”事件(对于 60Hz 设备)。如果这些事件未能及时处理,应用程序就会出现 UI 卡顿,甚至完全无响应。
每当一个进程无法在帧间隙(两个帧之间的时间)内完成时,最好将工作分流到另一个隔离区,以确保主隔离区每秒可以生成 60 帧。当您在 Dart 中生成一个隔离区时,它可以与主隔离区并发处理工作,而不会阻塞它。
您可以在 Dart 文档的并发页面上阅读更多关于 Dart 中隔离区和事件循环如何工作的信息。
隔离区的常见用例
#何时使用隔离区只有一个硬性规定,那就是当大型计算导致您的 Flutter 应用程序出现 UI 卡顿的时候。当任何计算耗时超过 Flutter 的帧间隙时,就会发生这种卡顿。
任何进程都可能需要更长的时间才能完成,这取决于实现和输入数据,因此无法创建一份详尽的列表,说明何时需要考虑使用隔离区。
尽管如此,隔离区通常用于以下情况:
- 从本地数据库读取数据
- 发送推送通知
- 解析和解码大型数据文件
- 处理或压缩照片、音频文件和视频文件
- 转换音频和视频文件
- 在使用 FFI 时需要异步支持
- 对复杂列表或文件系统应用过滤
隔离区之间的消息传递
#Dart 的隔离区是Actor 模型的一种实现。它们只能通过消息传递进行通信,这通过Port
对象完成。当消息在它们之间“传递”时,它们通常从发送隔离区复制到接收隔离区。这意味着传递给隔离区的任何值,即使在该隔离区上被修改,也不会改变原始隔离区上的值。
唯一在传递时不会被复制到隔离区的是不可变对象,它们无论如何都无法更改,例如 String 或不可修改的字节。当您在隔离区之间传递不可变对象时,发送的是该对象的引用,而不是复制对象本身,以获得更好的性能。由于不可变对象无法更新,这有效地保留了 Actor 模型的行为。
此规则的一个例外是,当一个隔离区使用 Isolate.exit
方法发送消息时退出。因为发送隔离区在发送消息后将不再存在,它可以将消息的所有权从一个隔离区传递到另一个隔离区,确保只有一个隔离区可以访问该消息。
发送消息的两个最低级原语是 SendPort.send
(在发送时复制可变消息)和 Isolate.exit
(发送消息的引用)。Isolate.run
和 compute
在底层都使用 Isolate.exit
。
短生命周期隔离区
#在 Flutter 中将进程移动到隔离区最简单的方法是使用 Isolate.run
方法。此方法会生成一个隔离区,将回调传递给生成的隔离区以启动一些计算,从计算中返回一个值,然后在计算完成后关闭该隔离区。这一切都与主隔离区并发发生,并且不会阻塞它。
Isolate.run
方法需要一个参数,即一个在新隔离区上运行的回调函数。此回调的函数签名必须只有一个必需的、无命名的参数。当计算完成时,它将回调的值返回给主隔离区,并退出生成的隔离区。
例如,考虑这段代码,它从文件中加载一个大型 JSON blob,并将该 JSON 转换为自定义的 Dart 对象。如果 JSON 解码过程未被卸载到新的隔离区,此方法将导致 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;
}
有关在后台使用隔离区解析 JSON 的完整演练,请参阅此 Cookbook 教程。
有状态的、长生命周期隔离区
#短生命周期隔离区使用方便,但生成新隔离区以及在隔离区之间复制对象会产生性能开销。如果您使用 Isolate.run
反复执行相同的计算,通过创建不会立即退出的隔离区可能会获得更好的性能。
为此,您可以使用 Isolate.run
抽象的一些低级隔离区相关 API:
当您使用 Isolate.run
方法时,新隔离区在向主隔离区返回一条消息后会立即关闭。有时,您需要长生命周期的隔离区,它们可以随着时间的推移相互传递多条消息。在 Dart 中,您可以通过 Isolate API 和 Ports 来实现这一点。这些长生命周期隔离区通常被称为后台工作线程。
长生命周期隔离区在以下情况下非常有用:您有一个需要在应用程序整个生命周期内重复运行的特定进程,或者您有一个在一段时间内运行并需要向主隔离区返回多个值的进程。
或者,您可以使用 worker_manager 来管理长生命周期隔离区。
ReceivePort 和 SendPort
#使用两个类(除了 Isolate 之外)在隔离区之间建立长生命周期通信:ReceivePort
和 SendPort
。这些端口是隔离区之间相互通信的唯一方式。
Port
的行为类似于 Stream
,其中 StreamController
或 Sink
在一个隔离区中创建,而监听器在另一个隔离区中设置。在这个类比中,StreamConroller
被称为 SendPort
,您可以使用 send()
方法“添加”消息。ReceivePort
是监听器,当这些监听器收到新消息时,它们会调用提供的回调函数,并将消息作为参数。
有关在主隔离区和工作隔离区之间建立双向通信的深入解释,请参阅 Dart 文档中的示例。
在隔离区中使用平台插件
#从 Flutter 3.7 开始,您可以在后台隔离区中使用平台插件。这为将繁重、依赖平台的计算卸载到不会阻塞您 UI 的隔离区提供了许多可能性。例如,假设您正在使用原生宿主 API(例如 Android 上的 Android API、iOS 上的 iOS API 等)加密数据。以前,数据编组到宿主平台可能会浪费 UI 线程时间,而现在可以在后台隔离区中完成。
平台通道隔离区使用 BackgroundIsolateBinaryMessenger
API。以下代码片段展示了在后台隔离区中使用 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'));
}
隔离区的局限性
#如果您从一种支持多线程的语言转到 Dart,您可能会合理地期望隔离区表现得像线程,但事实并非如此。隔离区有自己的全局字段,并且只能通过消息传递进行通信,这确保了隔离区中的可变对象只能在单个隔离区中访问。因此,隔离区受限于其对自身内存的访问。例如,如果您有一个应用程序,其中包含一个名为 configuration
的全局可变变量,它将在生成的隔离区中作为新的全局字段进行复制。如果您在生成的隔离区中修改该变量,它在主隔离区中将保持不变。即使您将 configuration
对象作为消息传递给新隔离区,情况也是如此。这就是隔离区的设计工作方式,在考虑使用隔离区时记住这一点很重要。
Web 平台和 compute
#Dart web 平台,包括 Flutter web,不支持隔离区。如果您的 Flutter 应用面向 web,您可以使用 compute
方法来确保您的代码能够编译。在 web 上,compute()
方法在主线程上运行计算,但在移动设备上会生成一个新线程。在移动和桌面平台上,await compute(fun, message)
等同于 await Isolate.run(() => fun(message))
。
有关 web 上并发的更多信息,请查阅 dart.dev 上的并发文档。
无法访问 rootBundle
或 dart:ui
方法
#所有 UI 任务和 Flutter 本身都与主隔离区耦合。因此,您不能在生成的隔离区中使用 rootBundle
访问资产,也不能在生成的隔离区中执行任何 widget 或 UI 工作。
从宿主平台到 Flutter 的有限插件消息
#通过后台隔离区平台通道,您可以在隔离区中使用平台通道向宿主平台(例如 Android 或 iOS)发送消息,并接收这些消息的响应。但是,您无法接收来自宿主平台的主动(未经请求的)消息。
举例来说,您不能在后台隔离区中设置一个长生命周期的 Firestore 监听器,因为 Firestore 使用平台通道将更新推送到 Flutter,而这些更新是主动的(未经请求的)。但是,您可以在后台查询 Firestore 以获取响应。
更多信息
#有关隔离区的更多信息,请查阅以下资源:
- 如果您正在使用许多隔离区,请考虑 Flutter 中的 IsolateNameServer 类,或者复制该功能的适用于不使用 Flutter 的 Dart 应用程序的 pub 包。
- Dart 的隔离区是 Actor 模型的一种实现。
- isolate_agents 是一个抽象了 Port 并使创建长生命周期隔离区变得更容易的包。
- 阅读更多关于
BackgroundIsolateBinaryMessenger
API 公告的信息。