一 . 原始代码
为什么要Isolate,我们先看一段比较简单的代码:
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
class TestWidget extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return TestWidgetState();
}
}
class TestWidgetState extends State<TestWidget> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Material(
child: Center(
child: Column(
children: <Widget>[
Container(
width: 100,
height: 100,
child: CircularProgressIndicator(),
),
FlatButton(
onPressed: () async {
_count = countEven(1000000000);
setState(() {});
},
child: Text(
_count.toString(),
)),
],
mainAxisSize: MainAxisSize.min,
),
),
);
}
//计算偶数的个数
static int countEven(int num) {
int count = 0;
while (num > 0) {
if (num % 2 == 0) {
count++;
}
num--;
}
return count;
}
}
UI包含两个部分,一个不断转圈的progress指示器,一个按钮,当点击按钮的时候,找出比某个正整数n小的数的偶数的个数(请忽视具体算法,故意做耗时计算用,哈哈)。我们来运行一下代码看看效果:
可以看到,本来是很流畅的转圈,当我点击按钮计算的时候,UI出现了卡顿,为什么会出现卡顿,因为我们的计算默认是在UI线程中的,当我们调用countEven的时候,这个计算需要耗时,而在这期间,UI是没有机会去调用刷新的,因此会卡顿,计算完成后,UI恢复正常刷新。
二. 使用async优化
那么有些同学就会说了,在dart中,有async关键字,我们可以用异步计算,这样就不会影响UI的刷新了,事实真的是这样吗?我们一起来修改一下代码:
a. 将count改为asyncCountEven
static Future<int> asyncCountEven(int num) async{
int count = 0;
while (num > 0) {
if (num % 2 == 0) {
count++;
}
num--;
}
return count;
}
b. 调用:
_count = await asyncCountEven(1000000000);
我们继续运行一下代码,看现象:
仍然卡顿,说明异步是解决不了问题的,为什么?因为我们仍旧是在同一个UI线程中做运算,异步只是说我可以先运行其他的,等我这边有结果再返回,但是,记住,我们的计算仍旧是在这个UI线程,仍会阻塞UI的刷新,异步只是在同一个线程的并发操作。
三. 使用compute优化
那么我们怎么解决这个问题呢,其实很简单,我们知道卡顿的原因是在同一个线程中导致的,那我们有没有办法将计算移到新的线程中呢,当然是可以的。不过在dart中,这里不是称呼线程,是Isolate,直译叫做隔离,这么古怪的名字,是因为隔离不共享数据,每个隔离中的变量都是不同的,不能相互共享。
但是由于dart中的Isolate比较重量级,UI线程和Isolate中的数据的传输比较复杂,因此flutter为了简化用户代码,在foundation库中封装了一个轻量级compute操作,我们先看看compute,然后再来看Isolate。
要使用compute,必须注意的有两点,一是我们的compute中运行的函数,必须是顶级函数或者是static函数,二是compute传参,只能传递一个参数,返回值也只有一个,我们先看看本例中的compute优化吧:
真的很简单,只用在使用的时候,放到compute函数中就行了。
_count = await compute(countEven, 1000000000);
再次运行,我们来看看效果吧:
可以看到,现在的计算并不会导致UI卡顿,完美解决问题。
四. 使用Isolate优化
但是,compute的使用还是有些限制,它没有办法多次返回结果,也没有办法持续性的传值计算,每次调用,相当于新建一个隔离,如果调用过多的话反而会适得其反。在某些业务下,我们可以使用compute,但是在另外一些业务下,我们只能使用dart提供的Isolate了,我们先看看Isolate在本例中的使用:
a. 增加这两个函数
static Future<dynamic> isolateCountEven(int num) async {
final response = ReceivePort();
await Isolate.spawn(countEvent2, response.sendPort);
final sendPort = await response.first;
final answer = ReceivePort();
sendPort.send([answer.sendPort, num]);
return answer.first;
}
static void countEvent2(SendPort port) {
final rPort = ReceivePort();
port.send(rPort.sendPort);
rPort.listen((message) {
final send = message[0] as SendPort;
final n = message[1] as int;
send.send(countEven(n));
});
}
b. 使用
_count = await isolateCountEven(1000000000);
相对于compute复杂了很多,效果就不贴了,和compute一样,毫无卡顿。。
代价是什么
对于我们来说,其实是把多线程当做一种计算资源来使用的。我们可以通过创建新的 isolate 计算 heavy work,从而减轻 UI 线程的负担。但是这样做的代价是什么呢?
时间
通常来说,当我们使用多线程计算的时候,整个计算的时间会比单线程要多,额外的耗时是什么呢?
- 创建 Isolate
- Copy Message
当我们按照上面的代码执行一段多线程代码时,经历了 isolate 的创建以及销毁过程。下面是一种我们在解析 json 中这样编写代码可能的方式。
static BSModel toBSModel(String json){}
parsingModelList(List<String> jsonList) async{
for(var model in jsonList){
BSModel m = await compute(toBSModel, model);
}
}
复制代码
在解析 json 的时候,我们可能通过 compute 把解析任务放在新的 isolate 中完成,然后把值传过来。这时候我们会发现,整个解析会变得异常的慢。这是由于我们每次创建 BSModel
的时候都经历了一次 isolate 的创建以及销毁过程。这将会耗费约 50-150ms 的时间。
在这之中,我们传递 data 也经历了 Network -> Main Isolate -> New Isolate (result) -> Main Isolate,多出来两次 copy 的操作。如果我们是在 Main 线程之外的 isolate 下载的数据,那么就可以直接在该线程进行解析,最后只需要传回 Main Isolate 即可,省下了一次 copy 操作。(Network -> New Isolate (result)-> Main Isolate)
空间
Isolate 实际上是比较重的,每当我们创建出来一个新的 Isolate 至少需要 2mb 左右的空间甚至更多,取决于我们具体 isolate 的用途。
OOM 风险
我们可能会使用 message 传递 data 或 file。而实际上我们传递的 message 是经历了一次 copy 过程的,这其实就可能存在着 OOM 的风险。
如果说我们想要返回一个 2GB 的 data,在 iPhone X(3GB ram)上,我们是无法完成 message 的传递操作的。
Tips
上面已经介绍了使用 isolate 进行多线程操作会有一些额外的 cost,那么是否可以通过一些手段减少这些消耗呢。我个人建议从两个方向上入手。
- 减少 isolate 创建所带来的消耗。
- 减少 message copy 次数,以及大小。
使用 LoadBalancer
如何减少 isolate 创建所带来的消耗呢。自然一个想法就是能否创建一个线程池,初始化到那里。当我们需要使用的时候再拿来用就好了。
实际上 dart team 已经为我们写好一个非常实用的 package,其中就包括 LoadBalancer
。
我们现在 pubspec.yaml 中添加 isolate 的依赖。
isolate: ^2.0.2
复制代码
然后我们可以通过 LoadBalancer
创建出指定个数的 isolate。
Future<LoadBalancer> loadBalancer = LoadBalancer.create(2, IsolateRunner.spawn);
复制代码
这段代码将会创建出一个 isolate 线程池,并自动实现了负载均衡。
由于 dart 天生支持顶层函数,我们可以在 dart 文件中直接创建这个 LoadBalancer
。下面我们再来看看应该如何使用 LoadBalancer
中的 isolate。
int useLoadBalancer() async {
final lb = await loadBalancer;
int res = await lb.run<int, int>(_doSomething, 1);
return res;
}
复制代码
我们关注的只有 Future<R> run<R, P>(FutureOr<R> function(P argument), argument,
方法。我们还是需要传入一个 function
在某个 isolate 中运行,并传入其参数 argument
。run 方法将会返回我们执行方法的返回值。
整体和 compute 使用感觉上差不多,但是当我们多次使用额外的 isolate 的时候,不再需要重复创建了。
并且 LoadBalancer
还支持 runMultiple,可以让一个方法在多线程中执行。具体使用请查看 api。
LoadBalancer
经过测试,它会在第一次使用其 isolate 的时候初始化线程池。
当应用打开后,即使我们在顶层函数中调用了 LoadBalancer.create,但是还是只会有一个 Isolate。
当我们调用 run 方法时,才真正创建出了实际的 isolate。