You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
22 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 23 | 单线程模型怎么保证UI运行流畅
你好,我是陈航。
在上一篇文章中我带你一起学习了如何在Flutter中实现动画。对于组件动画Flutter将动画的状态与渲染进行了分离因此我们需要使用动画曲线生成器Animation、动画状态控制器AnimationController与动画进度监听器一起配合完成动画更新而对于跨页面动画Flutter提供了Hero组件可以实现共享元素变换的页面切换效果。
在之前的章节里我们介绍了很多Flutter框架出色的渲染和交互能力。支撑起这些复杂的能力背后实际上是基于单线程模型的Dart。那么与原生Android和iOS的多线程机制相比单线程的Dart如何从语言设计层面和代码运行机制上保证Flutter UI的流畅性呢
因此今天我会通过几个小例子循序渐进地向你介绍Dart语言的Event Loop处理机制、异步处理和并发编程的原理和使用方法从语言设计和实践层面理解Dart单线程模型下的代码运行本质从而懂得后续如何在工作中使用Future与Isolate优化我们的项目。
## Event Loop机制
首先,我们需要建立这样一个概念,那就是**Dart是单线程的**。那单线程意味着什么呢这意味着Dart代码是有序的按照在main函数出现的次序一个接一个地执行不会被其他代码中断。另外作为支持Flutter这个UI框架的关键技术Dart当然也支持异步。需要注意的是**单线程和异步并不冲突。**
那为什么单线程也可以异步?
这里有一个大前提那就是我们的App绝大多数时间都在等待。比如等用户点击、等网络请求返回、等文件IO结果等等。而这些等待行为并不是阻塞的。比如说网络请求Socket本身提供了select模型可以异步查询而文件IO操作系统也提供了基于事件的回调机制。
所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。
等待这个行为是通过Event Loop驱动的。事件队列Event Queue会把其他平行世界比如Socket完成的需要主线程响应的事件放入其中。像其他语言一样Dart也有一个巨大的事件循环在不断的轮询事件队列取出事件比如键盘事件、I\\O事件、网络事件等在主线程同步执行其回调函数如下图所示
![](https://static001.geekbang.org/resource/image/0c/ec/0cb6e6d34295cef460e48d139bc944ec.png)
图1 简化版Event Loop
## 异步任务
事实上图1的Event Loop示意图只是一个简化版。在Dart中实际上有两个队列一个事件队列Event Queue另一个则是微任务队列Microtask Queue。在每一次事件循环中Dart总是先去第一个微任务队列中查询是否有可执行的任务如果没有才会处理后续的事件队列的流程。
所以Event Loop完整版的流程图应该如下所示
![](https://static001.geekbang.org/resource/image/70/bc/70dc4e1c222ddfaee8aa06df85c22bbc.png)
图2 Microtask Queue与Event Queue
接下来,我们分别看一下这两个队列的特点和使用场景吧。
首先,我们看看微任务队列。微任务顾名思义,表示一个短时间内就会完成的异步任务。从上面的流程图可以看到,微任务队列在事件循环中的优先级是最高的,只要队列中还有任务,就可以一直霸占着事件循环。
微任务是由scheduleMicroTask建立的。如下所示这段代码会在下一个事件循环中输出一段字符串
```
scheduleMicrotask(() => print('This is a microtask'));
```
不过一般的异步任务通常也很少必须要在事件队列前完成所以也不需要太高的优先级因此我们通常很少会直接用到微任务队列就连Flutter内部也只有7处用到了而已比如手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景
异步任务我们用的最多的还是优先级更低的Event Queue。比如I/O、绘制、定时器这些异步事件都是通过事件队列驱动主线程执行的。
**Dart为Event Queue的任务建立提供了一层封装叫作Future**。从名字上也很容易理解,它表示一个在未来时间才会完成的任务。
把一个函数体放入Future就完成了从同步任务到异步任务的包装。Future还提供了链式调用的能力可以在异步任务执行完毕后依次执行链路上的其他函数体。
接下来,我们看一个具体的代码示例:分别声明两个异步任务,在下一个事件循环中输出一段字符串。其中第二个任务执行完毕之后,还会继续输出另外两段字符串:
```
Future(() => print('Running in Future 1'));//下一个事件循环输出字符串
Future(() => print(Running in Future 2'))
.then((_) => print('and then 1'))
.then((_) => print('and then 2));//上一个事件循环结束后,连续输出三段字符串
```
当然这两个Future异步任务的执行优先级比微任务的优先级要低。
正常情况下一个Future异步任务的执行是相对简单的在我们声明一个Future时Dart会将异步任务的函数执行体放入事件队列然后立即返回后续的代码继续同步执行。而当同步执行的代码执行完毕后事件队列会按照加入事件队列的顺序即声明顺序依次取出事件最后同步执行Future的函数体及后续的then。
这意味着,**then与Future函数体共用一个事件循环**。而如果Future有多个then它们也会按照链式调用的先后顺序同步执行同样也会共用一个事件循环。
如果Future执行体已经执行完毕了但你又拿着这个Future的引用往里面加了一个then方法体这时Dart会如何处理呢面对这种情况Dart会将后续加入的then方法体放入微任务队列尽快执行。
下面的代码演示了Future的执行规则先加入事件队列或者先声明的任务先执行then在Future结束后立即执行。
* 在第一个例子中由于f1比f2先声明因此会被先加入事件队列所以f1比f2先执行
* 在第二个例子中由于Future函数体与then共用一个事件循环因此f3执行后会立刻同步执行then 3
* 最后一个例子中Future函数体是null这意味着它不需要也没有事件循环因此后续的then也无法与它共享。在这种场景下Dart会把后续的then放入微任务队列在下一次事件循环中执行。
```
//f1比f2先执行
Future(() => print('f1'));
Future(() => print('f2'));
//f3执行后会立刻同步执行then 3
Future(() => print('f3')).then((_) => print('then 3'));
//then 4会加入微任务队列尽快执行
Future(() => null).then((_) => print('then 4'));
```
说了这么多规则,可能大家并没有完全记住。那我们通过一个综合案例,来把之前介绍的各个执行规则都串起来,再集中学习一下。
在下面的例子中我们依次声明了若干个异步任务Future以及微任务。在其中的一些Future内部我们又内嵌了Future与microtask的声明
```
Future(() => print('f1'));//声明一个匿名Future
Future fx = Future(() => null);//声明Future fx其执行体为null
//声明一个匿名Future并注册了两个then。在第一个then回调里启动了一个微任务
Future(() => print('f2')).then((_) {
print('f3');
scheduleMicrotask(() => print('f4'));
}).then((_) => print('f5'));
//声明了一个匿名Future并注册了两个then。第一个then是一个Future
Future(() => print('f6'))
.then((_) => Future(() => print('f7')))
.then((_) => print('f8'));
//声明了一个匿名Future
Future(() => print('f9'));
//往执行体为null的fx注册了了一个then
fx.then((_) => print('f10'));
//启动一个微任务
scheduleMicrotask(() => print('f11'));
print('f12');
```
运行一下,上述各个异步任务会依次打印其内部执行结果:
```
f12
f11
f1
f10
f2
f3
f5
f4
f6
f9
f7
f8
```
看到这儿你可能已经懵了。别急我们先来看一下这段代码执行过程中Event Queue与Microtask Queue中的变化情况依次分析一下它们的执行顺序为什么会是这样的
![](https://static001.geekbang.org/resource/image/8a/8b/8a1106a01613fa999a35911fc5922e8b.gif)
图3 Event Queue与Microtask Queue变化示例
* 因为其他语句都是异步任务所以先打印f12。
* 剩下的异步任务中微任务队列优先级最高因此随后打印f11然后按照Future声明的先后顺序打印f1。
* 随后到了fx由于fx的执行体是null相当于执行完毕了Dart将fx的then放入微任务队列由于微任务队列的优先级最高因此fx的then还是会最先执行打印f10。
* 然后到了fx下面的f2打印f2然后执行then打印f3。f4是一个微任务要到下一个事件循环才执行因此后续的then继续同步执行打印f5。本次事件循环结束下一个事件循环取出f4这个微任务打印f4。
* 然后到了f2下面的f6打印f6然后执行then。这里需要注意的是这个then是一个Future异步任务因此这个then以及后续的then都被放入到事件队列中了。
* f6下面还有f9打印f9。
* 最后一个事件循环打印f7以及后续的f8。
上面的代码很是烧脑万幸我们平时开发Flutter时一般不会遇到这样奇葩的写法所以你大可放心。你只需要记住一点**then会在Future函数体执行完毕后立刻执行无论是共用同一个事件循环还是进入下一个微任务。**
在深入理解Future异步任务的执行规则之后我们再来看看怎么封装一个异步函数。
## 异步函数
对于一个异步函数来说其返回时内部执行动作并未结束因此需要返回一个Future对象供调用者使用。调用者根据Future对象来决定是在这个Future对象上注册一个then等Future的执行体结束了以后再进行异步处理还是一直同步等待Future执行体结束。
对于异步函数返回的Future对象如果调用者决定同步等待则需要在调用处使用await关键字并且在调用处的函数体使用async关键字。
在下面的例子中异步方法延迟3秒返回了一个Hello 2019在调用处我们使用await进行持续等待等它返回了再打印
```
//声明了一个延迟3秒返回Hello的Future并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:3), () => "Hello")
.then((x) => "$x 2019");
main() async{
print(await fetchContent());//等待Hello 2019的返回
}
```
也许你已经注意到了我们在使用await进行等待的时候在等待语句的调用上下文函数main加上了async关键字。为什么要加这个关键字呢
因为**Dart中的await并不是阻塞等待而是异步等待**。Dart会将调用体的函数也视作异步函数将等待语句的上下文放入Event Queue中一旦有了结果Event Loop就会把它从Event Queue中取出等待代码继续执行。
接下来,为了帮助你加深印象,我准备了两个具体的案例。
我们先来看下这段代码。第二行的then执行体f2是一个Future为了等它完成再进行下一步操作我们使用了await期望打印结果为f1、f2、f3、f4
```
Future(() => print('f1'))
.then((_) async => await Future(() => print('f2')))
.then((_) => print('f3'));
Future(() => print('f4'));
```
实际上当你运行这段代码时就会发现打印出来的结果其实是f1、f4、f2、f3
我来给你分析一下这段代码的执行顺序:
* 按照任务的声明顺序f1和f4被先后加入事件队列。
* f1被取出并打印然后到了then。then的执行体是个future f2于是放入Event Queue。然后把await也放到Event Queue里。
* 这个时候要注意了Event Queue里面还有一个f4我们的await并不能阻塞f4的执行。因此Event Loop先取出f4打印f4然后才能取出并打印f2最后把等待的await取出开始执行后面的f3。
由于await是采用事件队列的机制实现等待行为的所以比它先在事件队列中的f4并不会被它阻塞。
接下来我们再看另一个例子在主函数调用一个异步函数去打印一段话而在这个异步函数中我们使用await与async同步等待了另一个异步函数返回字符串
```
//声明了一个延迟2秒返回Hello的Future并注册了一个then返回拼接后的Hello 2019
Future<String> fetchContent() =>
Future<String>.delayed(Duration(seconds:2), () => "Hello")
.then((x) => "$x 2019");
//异步函数会同步等待Hello 2019的返回并打印
func() async => print(await fetchContent());
main() {
print("func before");
func();
print("func after");
}
```
运行这段代码我们发现最终输出的顺序其实是“func before”“func after”“Hello 2019”。func函数中的等待语句似乎没起作用。这是为什么呢
同样,我来给你分析一下这段代码的执行顺序:
* 首先第一句代码是同步的因此先打印“func before”。
* 然后进入func函数func函数调用了异步函数fetchContent并使用await进行等待因此我们把fetchContent、await语句的上下文函数func先后放入事件队列。
* await的上下文函数并不包含调用栈因此func后续代码继续执行打印“func after”。
* 2秒后fetchContent异步任务返回“Hello 2019”于是func的await也被取出打印“Hello 2019”。
通过上述分析你发现了什么现象那就是await与async只对调用上下文的函数有效并不向上传递。因此对于这个案例而言func是在异步等待。如果我们想在main函数中也同步等待需要在调用异步函数时也加上await在main函数也加上async。
经过上面两个例子的分析你应该已经明白await与async是如何配合完成等待工作的了吧。
介绍完了异步我们再来看在Dart中如何通过多线程实现并发。
## Isolate
尽管Dart是基于单线程模型的但为了进一步利用多核CPU将CPU密集型运算进行隔离Dart也提供了多线程机制即Isolate。在Isolate中资源隔离做得非常好每个Isolate都有自己的Event Loop与Queue**Isolate之间不共享任何资源只能依靠消息机制通信因此也就没有资源抢占问题**。
和其他语言一样Isolate的创建非常简单我们只要给定一个函数入口创建时再传入一个参数就可以启动Isolate了。如下所示我们声明了一个Isolate的入口函数然后在main函数中启动它并传入了一个字符串参数
```
doSth(msg) => print(msg);
main() {
Isolate.spawn(doSth, "Hi");
...
}
```
但更多情况下我们的需求并不会这么简单不仅希望能并发还希望Isolate在并发执行的时候告知主Isolate当前的执行结果。
对于执行结果的告知Isolate通过发送管道SendPort实现消息通信机制。我们可以在启动并发Isolate时将主Isolate的发送管道作为参数传给它这样并发Isolate就可以在任务执行完毕后利用这个发送管道给我们发消息了。
下面我们通过一个例子来说明在主Isolate里我们创建了一个并发Isolate在函数入口传入了主Isolate的发送管道然后等待并发Isolate的回传消息。在并发Isolate中我们用这个管道给主Isolate发了一个Hello字符串
```
Isolate isolate;
start() async {
ReceivePort receivePort= ReceivePort();//创建管道
//创建并发Isolate并传入发送管道
isolate = await Isolate.spawn(getMsg, receivePort.sendPort);
//监听管道消息
receivePort.listen((data) {
print('Data$data');
receivePort.close();//关闭管道
isolate?.kill(priority: Isolate.immediate);//杀死并发Isolate
isolate = null;
});
}
//并发Isolate往管道发送一个字符串
getMsg(sendPort) => sendPort.send("Hello");
```
这里需要注意的是在Isolate中发送管道是单向的我们启动了一个Isolate执行某项任务Isolate执行完毕后发送消息告知我们。如果Isolate执行任务时需要依赖主Isolate给它发送参数执行完毕后再发送执行结果给主Isolate这样**双向通信的场景我们如何实现呢**答案也很简单让并发Isolate也回传一个发送管道即可。
接下来,我们以一个**并发计算阶乘**的例子来说明如何实现双向通信。
在下面的例子中我们创建了一个异步函数计算阶乘。在这个异步函数内创建了一个并发Isolate传入主Isolate的发送管道并发Isolate也回传一个发送管道主Isolate收到回传管道后发送参数N给并发Isolate然后立即返回一个Future并发Isolate用参数N调用同步计算阶乘的函数返回执行结果最后主Isolate打印了返回结果
```
//并发计算阶乘
Future<dynamic> asyncFactoriali(n) async{
final response = ReceivePort();//创建管道
//创建并发Isolate并传入管道
await Isolate.spawn(_isolate,response.sendPort);
//等待Isolate回传管道
final sendPort = await response.first as SendPort;
//创建了另一个管道answer
final answer = ReceivePort();
//往Isolate回传的管道中发送参数同时传入answer管道
sendPort.send([n,answer.sendPort]);
return answer.first;//等待Isolate通过answer管道回传执行结果
}
//Isolate函数体参数是主Isolate传入的管道
_isolate(initialReplyTo) async {
final port = ReceivePort();//创建管道
initialReplyTo.send(port.sendPort);//往主Isolate回传管道
final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道)
final data = message[0] as int;//参数
final send = message[1] as SendPort;//回传结果的管道
send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果
}
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果
```
看完这段代码你是什么感觉呢?我们只是为了并发计算一个阶乘,这样是不是太繁琐了?
没错确实太繁琐了。在Flutter中像这样执行并发计算任务我们可以采用更简单的方式。Flutter提供了支持并发计算的compute函数其内部对Isolate的创建和双向通信进行了封装抽象屏蔽了很多底层细节我们在调用时只需要传入函数入口和函数参数就能够实现并发计算和消息通知。
我们试着用compute函数改造一下并发计算阶乘的代码
```
//同步计算阶乘
int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1);
//使用compute函数封装Isolate的创建和结果的返回
main() async => print(await compute(syncFactorial, 4));
```
可以看到用compute函数改造以后整个代码就变成了两行现在并发计算阶乘的代码看起来就清爽多了。
## 总结
好了今天关于Dart的异步与并发机制、实现原理的分享就到这里了我们来简单回顾一下主要内容。
Dart是单线程的但通过事件循环可以实现异步。而Future是异步任务的封装借助于await与async我们可以通过事件循环实现非阻塞的同步等待Isolate是Dart中的多线程可以实现并发有自己的事件循环与Queue独占资源。Isolate之间可以通过消息机制进行单向通信这些传递的消息通过对方的事件循环驱动对方进行异步处理。
在UI编程过程中异步和多线程是两个相伴相生的名词也是很容易混淆的概念。对于异步方法调用而言代码不需要等待结果的返回而是通过其他手段比如通知、回调、事件循环或多线程在后续的某个时刻主动或被动地接收执行结果。
因此从辩证关系上来看异步与多线程并不是一个同等关系异步是目的多线程只是我们实现异步的一个手段之一。而在Flutter中借助于UI框架提供的事件循环我们可以不用阻塞的同时等待多个异步任务因此并不需要开多线程。我们一定要记住这一点。
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/23_dart_async)中,你可以下载下来,反复运行几次,加深理解。
## 思考题
最后,我给你留下两道思考题吧。
1. 在通过并发Isolate计算阶乘的例子中我在asyncFactoriali方法里先后发给了并发Isolate两个SendPort。你能否解释下这么做的原因可以只发一个SendPort吗
2. 请改造以下代码在不改变整体异步结构的情况下实现输出结果为f1、f2、f3、f4。
```
Future(() => print('f1'))
.then((_) async => await Future(() => print('f2')))
.then((_) => print('f3'));
Future(() => print('f4'));
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。