序言
2021年的最后一天, Dart 官方发布了 dart 2.15
版本,该版本优化了很多内容,今天我们要重点说说 isolate
工作器。官方推文链接
在探索新变化之前,我们来回忆巩固一下 isolate 的使用。
isolate 的作用
问题:Flutter 基于单线程模式使用协程进行开发,为什么还需要 isolate ?
首先我们要明确 并行(isolate)
与并发(future)
的区别。下面我们通过简单的例子来进行说明 。Demo 是一个简单的页面,中间放置一个不断转圈的 progress
和一个按键,按键用来触发耗时方法。
///计算偶数个数(具体的耗时操作)下面示例代码中会用到
static int calculateEvenCount(int num) {
int count = 0;
while (num > 0) {
if (num % 2 == 0) {
count++;
}
num--;
}
return count;
}
///按键点击事件
onPressed: () {
//触发耗时操作
doMockTimeConsume();
}
- 方式一: 我们将耗时操作使用
future
的方式进行封装
///使用future的方式封装耗时操作
static Future<int> futureCountEven(int num) async {
var result = calculateEvenCount(num);
return Future.value(result);
}
///耗时事件
void doMockTimeConsume() async {
var result = await futureCountEven(1000000000);
_count = result;
setState(() {});
}
结果如下:
结论:使用
future
的方式来消费耗时操作,由于仍然是单线程在进行工作,异步只是在同一个线程的并发操作,仍会阻塞UI的刷新。
- 方式二: 使用
isolate
开辟新线程,避开主线程,不干扰UI刷新
//模拟耗时操作
void doMockTimeConsume() async {
var result = await isolateCountEven(1000000000);
_count = result;
setState(() {});
}
///使用isolate的方式封装耗时操作
static Future<dynamic> isolateCountEven(int num) async {
final p = ReceivePort();
///发送参数
await Isolate.spawn(_entryPoint, [p.sendPort, num]);
return (await p.first) as int;
}
static void _entryPoint(List<dynamic> args) {
SendPort responsePort = args[0];
int num = args[1];
///接收参数,进行耗时操作后返回数据
responsePort.send(calculateEvenCount(num));
}
结果如下:
结论:使用
isolate
实现了多线程并行,在新线程中进行耗时操作不会干扰UI线程的刷新。
isolate 的局限性,为什么需要优化?
iso 有两点较为重要的局限性。
- isolate 消耗较重,除了创建耗时,每次创建还至少需要2Mb的空间,有OOM的风险。
- isolate 之间的内存空间各自独立,当参数或结果跨 iso 相互传递时需要深度拷贝,拷贝耗时,可能造成UI卡顿。
isolate 新特性
Dart 2.15 更新, 给 iso 添加了组的概念,isolate 组
工作特征可简单总结为以下两点:
- Isolate 组中的 isolate 共享各种内部数据结构
- Isolate 组
仍然阻止
在 isolate 间共享访问可变对象,但由于 isolate 组使用共享堆实现,这也让其拥有了更多的功能。
官方推文中举了一个例子:
工作器 isolate 通过网络调用获得数据,将该数据解析为大型 JSON 对象图,然后将这个 JSON 图返回到主 isolate 中。
Dart 2.15 之前
:执行该操作需要深度复制,如果复制花费的时间超过帧预算时间,就会导致界面卡顿。
使用 Dart 2.15
:工作器 isolate 可以调用Isolate.exit()
,将其结果作为参数传递。然后,Dart 运行时将包含结果的内存数据从工作器 isolate 传递到主 isolate 中,无需复制,且主 isolate 可以在固定时间内接收结果。
重点:提供 Isolate.exit() 方法,将包含结果的内存数据从工作器 isolate 传递到主 isolate ,过程无需复制。
附注: 使用 Dart 新特性,需将 flutter sdk 升级到 2.8.0 以上 链接。
exit 和 send 的区别及用法
Dart 更新后,我们将数据从 工作器 isolate(子线程)
回传到 主 isolate(主线程)
有两种方式。
- 方式一: 使用
send
responsePort.send(data);
点击进入 send 方法查看源码注释,看到这样一句话:
结论:send 本身不会阻塞,会立即发送,但可能需要线性时间成本用于复制数据。
- 方式二:使用
exit
Isolate.exit(responsePort, data);
官网 给出的解释如下:
结论:隔离之间的消息传递通常涉及数据复制,因此可能会很慢,并且会随着消息大小的增加而增加。但是
exit()
,则是在退出隔离中保存消息的内存,不会被复制,而是被传输到主 isolate。这种传输很快,并且在恒定的时间内完成。
我们把上面 demo 中的 _entryPoint 方法做一下优化修改:
static void _entryPoint(SendPort port) {
SendPort responsePort = args[0];
int num = args[1];
///接收参数,进行耗时操作后返回数据
//responsePort.send(calculateEvenCount(num));
Isolate.exit(responsePort, calculateEvenCount(num));
}
总结:使用 exit()
替代 SendPort.send
,可规避数据复制,节省耗时。
isolate 组
如何创建一个 isolate 组?官方给出的解释如下:
When an isolate calls
Isolate.spawn()
, the two isolates have the same executable code and are in the same isolate group. Isolate groups enable performance optimizations such as sharing code; a new isolate immediately runs the code owned by the isolate group. Also,Isolate.exit()
works only when the isolates are in the same isolate group.
当在 isolate 中调用另一个 isolate 时,这两个 isolate 具有相同的可执行代码,并且位于同一隔离组。
PS: 小轰暂时也没有想到具体的使用场景,先暂放一边吧。
实践:isolate 如何处理连续数据
结合上面的耗时方法calculateEvenCount
,isolate 处理连续数据需要结合 stream 流 的设计。具体 demo 如下:
///测试入口
static testContinuityIso() async {
final numbs = [10000, 20000, 30000, 40000];
await for (final data in _sendAndReceive(numbs)) {
log(data.toString());
}
}
///具体的iso实现(主线程)
static Stream<Map<String, dynamic>> _sendAndReceive(List<int> numbs) async* {
final p = ReceivePort();
await Isolate.spawn(_entry, p.sendPort);
final events = StreamQueue<dynamic>(p);
// 拿到 子isolate传递过来的 SendPort 用于发送数据
SendPort sendPort = await events.next;
for (var num in numbs) {
//发送一条数据,等待一条数据结果,往复循环
sendPort.send(num);
Map<String, dynamic> message = await events.next;
//每次的结果通过stream流外露
yield message;
}
//发送 null 作为结束标识符
sendPort.send(null);
await events.cancel();
}
///具体的iso实现(子线程)
static Future<void> _entry(SendPort p) async {
final commandPort = ReceivePort();
//发送一个 sendPort 给主iso ,用于 主iso 发送参数给 子iso
p.send(commandPort.sendPort);
await for (final message in commandPort) {
if (message is int) {
final data = calculateEvenCount(message);
p.send(data);
} else if (message == null) {
break;
}
}
}
抛砖引玉,这只是一个思路~