在我真正理解Dart是如何处理并发处理的之前,我已经编写Flutter应用程序超过了一年。我知道如何使用`await`,也熟悉`FutureBuilder`和`StreamBuilder`,能够让程序正常运行。但我并不真正明白背后发生的原理:为什么有些代码会按照特定的顺序执行,为什么某些操作会导致用户界面冻结,又为什么流式订阅会不断引发我无法追踪的内存泄漏问题。
直到我认真去学习事件循环的相关知识,所有这些疑问才迎刃而解。我明白了为什么`mounted`方法的检测机制会起作用,为什么会有`compute()`这个函数,以及为什么流式数据的行为会随着监听器数量的改变而发生变化。这些知识点并不是需要单独记忆的独立内容,它们其实都是同一底层模型所导致的不同结果。
这篇文章正是我早些时候就应该了解的内容。我们将深入探讨Dart的事件循环究竟是如何工作的,流式数据如何帮助我们控制那些随时间陆续到达的数据,以及如何在需要真正并行处理的时候利用“隔离机制”来突破单线程的限制——文中还会穿插许多实际的Flutter开发案例。
## 目录
- Dart的单线程模型是如何工作的
- 事件循环及其两个队列
- async/await机制是如何融入这一框架的
- 流式数据:如何控制随时间陆续到达的数据
- StreamTransformers与高级流式数据处理技术
- 隔离机制:如何突破单线程的限制
- 在Flutter中整合这些概念
- 最后的思考
## Dart的单线程模型是如何工作的
大多数编程语言都允许代码在多个线程上同时运行:一个线程负责处理网络请求,另一个线程处理用户输入,还有一个线程负责渲染用户界面——所有这些操作都是并行进行的。
但Dart并非如此。它所有的任务都只在单个线程上执行,而且总是依次进行,从不同时处理多项任务。
当我最初了解到这一点时,觉得这简直是一种限制。一个单线程怎么可能同时处理网络请求、用户点击操作以及每秒60帧的界面渲染工作呢?其实答案是:它并不会同时处理这些任务,而是由事件循环来负责按顺序执行它们。
可以把这个过程想象成一位厨师在厨房里独自工作。一位厨师只有一双手,他们不可能同时切菜和搅拌。但一个熟练的厨师不会干等着水烧开,他们会先准备蔬菜,等水烧开后再继续下一步操作,然后切换到下一项任务。通过这种方式,他们能够在不同的任务之间灵活切换,从而保持高效的工作状态。
Dart中的事件循环就是那个“厨师”——它负责决定接下来应该执行哪项任务。
事件循环及其两个队列
在整个Dart应用程序的运行过程中,事件循环都在持续工作。它的职责很简单:不断检查是否有任务需要执行,如果有的话就立即执行这些任务,然后再重新进行检查。这个过程会一直循环进行,直到应用程序结束。
在Dart中,任务的执行并不会立即发生。当某个任务准备好被执行时——比如网络请求得到了响应、定时器触发了、或者某个`.then()`回调函数完成了执行——它就会被添加到相应的队列中。事件循环会依次处理这些队列中的任务。
Dart一共有两个队列,理解这两个队列的区别,对于那些使用异步编程的开发者来说,是区分他们与真正掌握异步编程原理的开发者的关键所在。
微任务队列
这个队列具有最高的优先级。事件循环在处理其他任何任务之前,都会先清空这个队列中的所有任务。`.then()`回调函数以及`Future.microtask()`产生的任务都会被放入这个队列中。
可以把这个队列想象成“快速通道”:那些优先级极高、需要尽快执行的短时任务,都会在当前的同步代码执行完毕后立即被处理。
事件队列
所有外部产生的任务——比如定时器回调、网络请求结果、用户输入事件、流数据,以及`Future.delayed()`完成的操作——都会被放入这个队列中。事件循环会先处理这个队列中的第一个任务,然后再去检查微任务队列,之后再处理下一个任务。
下面是这种处理顺序在实际编程中的体现:
void main() {
print('1 — 同步任务,立即执行');
// 被放入事件队列
Future.delayed(Duration.zero, () {
print('4 — 事件队列');
});
// 被放入微任务队列
Future.microtask(() {
print('3 — 微任务队列');
});
print('2 — 同步任务,立即执行');
}
// 输出结果:
// 1 — 同步任务,立即执行
// 2 — 同步任务,立即执行
// 3 — 微任务队列
// 4 — 事件队列
任务1和2会首先被执行,因为它们是同步任务,不需要排队等待,可以直接执行。而尽管任务3和4的延迟时间都是零,但3会先于4被执行,因为微任务总是会在事件任务之前被处理。
这种处理顺序其实非常重要。当你连续调用多个`.then()`回调函数时,这些回调函数都会被放入微任务队列中。因此,它们会立即被执行,而且总会先于任何定时器或I/O操作被执行,即使这些操作的延迟时间也是零。
void main() {
Future(() => print('事件1');
Future(() => print('事件2');
Future.microtask(() => print('微任务1');
Future.microtask(() => print('微任务2');
print('同步任务');
}
// 输出结果:
// 同步任务
// 微任务1
// 微任务2
// 事件1
// 事件2
无论这些微任务的调度顺序如何,它们都会在这两个事件发生之前被执行。
异步/等待机制是如何融入这一框架的
async/await并不会创建新的线程,也不会让任务并行运行。它其实是一种基于事件循环的语法糖,这种写法更符合Dart的单线程并发模型。
下面是我认为理解这个概念的最佳方式:想象你是一家餐厅的服务员,而且你是当前值班的唯一服务员。你一次只能做一件事,但你不需要站在厨房前等待食物准备好。你可以将订单交给厨房,然后去做其他事情,比如去倒水、接新的订单或者清理桌子。当厨房传来信号时,你再去取食物并送到顾客手中。
await就相当于将订单交给厨房后离开的那一刻。你并没有阻塞程序的执行,而是暂停了当前这个任务,并告诉事件循环:“等这个任务完成后再来处理我。”在网络请求、文件读取或计时器运行期间,事件循环可以继续处理其他任务。
当被等待的操作完成后,你的函数的剩余部分会被添加到队列中,等到事件循环再次处理它时才会被执行。
Future loadUser() async {
print('A — 在await之前');
// Dart会在这里暂停执行,将控制权交还给事件循环。
// 在网络请求进行期间,事件循环可以继续处理其他任务,
// 例如渲染帧、处理其他异步操作等。
final user = await dio.get('/user');
// 这段代码只有当网络请求完成、事件循环再次执行到这里时才会被运行。
print('B — 在await之后,获取到了用户信息');
}
void main() {
loadUser();
// 因为loadUser()在await处暂停了执行,所以这段代码会在B之前被运行。
print('C — 主程序继续执行');
}
// 输出结果:
// A — 在await之前
// C — 主程序继续执行
// B — 在await之后,获取到了用户信息
为什么阻塞事件循环会导致Flutter界面卡顿
Flutter的UI渲染是在与Dart代码相同的线程中进行的。为了能够以60帧每秒的速度渲染画面,引擎需要确保事件循环每隔大约16毫秒就能正常运行一次。任何耗时超过这个时间的同步操作都会完全阻塞事件循环——此时既不会有新的画面被渲染,也不会有用户操作被处理,从而导致界面冻结。
// 在Flutter中,这种做法是非常危险的。
// 如果同步解析一个庞大的JSON数据,在性能较低的设备上可能需要100到300毫秒的时间。
// 这整个过程中事件循环都会被完全阻塞,
// 结果就是应用程序会停止渲染画面,用户看到的就会是一块冻结的屏幕。
final users = (response.data as List)
.map((json) => User.fromJson(json))
.ToList();
在这种情况下,await并不能起到任何帮助作用,因为这种操作完全依赖于CPU的计算能力——CPU在整个过程中都在满负荷工作,因此不存在让事件循环有机会暂停的时机。而这正是隔离执行机制存在的意义所在,我们稍后会进一步讨论这一点。
流:控制随时间陆续到达的数据
Future会一次性交付一个值然后完成执行。而Stream则会随着时间的推移逐步传递多个值,并且会一直保持开放状态,直到被取消或数据传输完毕。
如果把Future》比作在餐厅点餐——你只需要等待一次,就会收到一份食物,整个流程就此结束;那么Stream>就相当于订阅新闻通讯。新的内容会不断陆续送达,你会一直收到这些新内容,直到你取消订阅为止。
// 这个流会以每秒一个数字的频率从1数到5。
// async*表示这是一个生成流的函数。
// yield用于将值添加到流中,然后暂停执行,
// 直到监听器准备好接收下一个值为止。
Stream countStream() async* {
for (int i = 1; i <= 5; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
// 当循环结束时,流会自动关闭。
}
你可以使用await for或.listen()来处理这些流:
// 方法1 — await for:简单情况下使用,代码简洁易读
await for (final number in countStream()) {
print(number); // 会依次输出1、2、3、4、5,每秒一个数字
}
// 方法2 — listen():提供更多控制选项,可以中途取消订阅
final subscription = countStream().listen(
(number) => print(number),
onError: (error) =>> print('错误:$error'),
onDone: () =>> print('流已关闭'),
);
// 3秒后取消订阅——停止接收数据
await Future.delayed(const Duration(seconds: 3));
subscription.cancel();
单次订阅流与广播流
这个区别经常让很多Flutter开发者感到困惑,但理解它能够帮助你避免一系列令人困扰的错误。
单次订阅流一次只能有一个监听器。这是默认设置。大多数流,比如文件读取内容或HTTP响应体,都属于单次订阅流。如果你尝试同时为同一个流设置两个监听器,就会收到StateError错误。
final stream = countStream();
stream.listen(print); // 可以正常工作
stream.listen(print); // 会抛出错误:该流已经被其他监听器订阅了
广播流可以同时有任意数量的监听器。所有这些监听器都会接收到相同的数据。对于那些需要在应用程序的多个部分中产生响应的事件,或者用户交互行为,使用广播流是非常合适的。
// StreamController.broadcast()用于创建一个可以被任意数量监听器订阅的流。
final controller = StreamController.broadcast();
controller.stream.listen((v) =>> print('监听器1:$v'));
controller.stream.listen((v) =>> print('监听器2:$v');
// 两个监听器都会收到这个值
controller.sink.add('Hello');
// 监听器1:Hello
// 监听器2:Hello
// 使用完流后一定要关闭它,否则会无限占用系统资源。
controller.close();
使用StreamController手动创建数据流
StreamController允许你完全手动控制数据流的生成过程。你可以自行决定何时向数据流中添加数据,何时发送错误信息,以及何时关闭该数据流。通过这种方式,你可以从零开始构建响应式的数据源。
class LocationService {
// 使用广播功能,让多个组件能够同时接收位置更新信息。
final _controller = StreamController.broadcast();
// 只公开数据流本身,而控制器本身保持私有状态,
// 因此只有这个类才能向其中添加新数据。
Stream get locationStream => _controller.stream;
void startTracking() {
Timer-periodic(const Duration(seconds: 2), (_) {
final position = Position(lat: 0.3476, lng: 32.5825);
// 使用sink.add()方法将数据添加到数据流中,
// 所有正在监听的组件都会立即收到这些数据。
_controller.sink.add(position);
});
}
void dispose() {
// 使用完数据流后,一定要关闭它,否则会导致内存泄漏。
_controller.close();
}
}
在Flutter中使用StreamBuilder处理数据流
StreamBuilder是Flutter提供的一个组件,它允许你在用户界面中直接使用数据流。每当有新数据到达时,该组件会自动重新构建界面。
StreamBuilder>&(
stream: firestore
.collection('messages')
.snapshots()
.map((snapshot) => snapshotdocs
.map((doc) => Message.fromJson(doc.data()))
.ToList()),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('错误:${snapshot.error}`);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Text('目前还没有消息');
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return MessageBubble(message: snapshot.data![index]);
},
);
},
)
在dispose方法中务必取消数据流订阅
这是Flutter应用程序中最常见的内存泄漏问题之一,其根本原因在于人们对数据流的原理理解不够透彻。
当某个组件订阅了数据流后,相应的回调函数会一直处于活跃状态。即使该组件已经被从界面中移除,如果订阅仍然存在,这些回调函数仍会继续执行,导致setState方法在dispose之后被调用,从而使得那些本应被释放的对象继续占用内存。
class _ChatScreenState extends State {
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = messageStream.listen((message) {
if (mounted) setState(() => messages.add(message));
});
}
@override
void dispose() {
// 使用cancel()方法取消数据流订阅,
// 这样就可以防止回调函数在组件被移除后继续执行。
_subscription?.cancel();
super.dispose();
}
}
流转换器与高级流控制
一旦你了解了流的概念,就会很快发现,原始的流数据很少能直接满足你的需求。你需要对数据进行处理、进行转换、抑制频繁的数据发送,或者将多个流合并起来。这时,流操作符和`StreamTransformer`就派上用场了。
Dart的`Stream`类提供了丰富的内置转换方法:
final stream = countStream();
// map — 在数据传达到监听器之前对其进行转换
stream
.map((number) => number * 2)
.listen(print); // 输出:2, 4, 6, 8, 10
// where — 过滤掉不符合条件的数据
stream
.where((number) => number.isEven)
.listen(print); // 输出:2, 4
// take — 只输出前N个数据,然后停止发送
stream
.take(3)
.listen(print); // 输出:1, 2, 3
// skip — 忽略前N个数据
stream
.skip(2)
.listen(print); // 输出:3, 4, 5
// distinct — 只有当数据与上一次输入的数据不同时才输出
Stream.fromIterable([1, 1, 2, 2, 3])
.distinct()
.listen(print); // 输出:1, 2, 3
对于更复杂的转换需求,你可以自定义`StreamTransformer`。当内置操作符无法满足你的需求时,就可以使用这种方法——比如当你需要以某种需要在数据发送过程中维护状态的方式来处理数据时。
// 这个StreamTransformer只会输出超过特定阈值的值,并在每个值前面添加标签
StreamTransformer aboveThreshold(int threshold) {
return StreamTransformer.fromHandlers(
handleData: (value, sink) {
// sink.add()会将转换后的值发送下去。
// 如果不调用sink.add(),这个值就会被忽略。
if (value > threshold) {
sink.add('超过阈值:$value');
}
},
handleError: (error, stackTrace, sink) {
// 将错误原封不动地发送下去。
sink.addError(error, stackTrace);
},
handleDone: (sink) {
// 当输入流关闭时,关闭输出流。
sink.close();
},
);
}
// 使用方法
countStream()
.transform(aboveThreshold(3))
.listen(print); // 输出:超过阈值:4, 超过阈值:5
在Flutter中利用流实现去抖动功能
在Flutter应用中,最实用的流处理模式之一就是为搜索框实现去抖动功能。如果不使用去抖动机制,每次用户按键都会触发一次API调用;而通过去抖动,可以等到用户停止输入后再进行请求。
class _SearchScreenState extends State {
final _searchController = TextEditingController();
final _searchStream = StreamController();
StreamSubscription? _subscription;
List _results = [];
@override
void initState() {
super.initState();
_subscription = _searchStream.stream
// 在用户最后一次按键后等待300毫秒再发送数据。
// 如果在300毫秒内又有新的输入,计时器会重新开始计算。
// 这样就可以避免每次按键都触发API调用。
.asyncExpand((query) async* {
await Future.delayed(const Duration(milliseconds: 300));
yield query;
})
// 忽略重复的查询请求——如果用户又输入了相同的内容,就没有必要再次发送请求。
.distinct()
// 对每个查询请求,都会调用API并获取结果。
// 如果在之前的请求还未完成之前又有新的请求到来,asyncMap会取消之前的请求。
.asyncMap((query) => _repository.search(query))
.listen((results) {
if (mounted) setState(() => _results = results);
});
_searchController.addListener(() {
_searchStream.add(_searchController.text);
});
}
@override
void dispose() {
_searchController.dispose();
_subscription?.cancel();
_searchStream.close();
superdispose();
}
}
隔离执行:摆脱单线程的限制
Dart是单线程语言,但这并不意味着你永远只能使用一个线程。Dart的“隔离执行”机制允许你在完全独立的线程中运行代码——不过这与其他语言中的线程机制存在一个重要区别。
在大多数编程语言中,线程会共享内存。两个线程可以同时读写同一个变量,这就容易导致竞争条件,因此需要通过锁定机制来避免这些问题。
然而Dart的隔离执行机制根本不会共享内存。每个隔离执行单元都有自己独立的内存堆。两个隔离执行单元之间只能通过传递消息来进行通信——这就好比是通过墙上的缝隙传递信息,而不是共用一块白板。
这种设计使得隔离执行单元具有很高的安全性。由于不存在共享内存,因此也就不会发生竞争条件;每个隔离执行单元都能完全掌控自己的数据。
主隔离执行单元 工作线程隔离执行单元
───────────────── ─────────────────
拥有独立的内存堆 拥有独立的内存堆
拥有独立的事件循环 拥有独立的事件循环
负责UI渲染 负责繁重计算任务
接收用户输入 无法访问UI界面
│ │
│──── 发送数据 ──────────────→│
│ │ (各自独立运行)
│←─── 接收结果 ──────────│
何时真正需要使用隔离执行单元
关键的区别在于任务是依赖于CPU的计算还是依赖于I/O操作的:
-
依赖I/O的操作:比如等待网络响应、读取文件等——在这种情况下可以直接使用
await。在等待期间,CPU处于空闲状态,因此事件循环依然可以正常运行。 -
依赖CPU的计算任务:比如进行复杂的数据处理、解析大型文件等——这类任务需要使用隔离执行单元。因为在这些操作过程中CPU会一直保持忙碌状态,所以
await无法起到帮助作用。
如果解析API响应需要200毫秒的时间,那么使用await也无法节省时间——在这200毫秒里,事件循环仍然会被阻塞。因此,这类任务必须被放到单独的隔离执行单元中来处理。
Isolate.run()”——现代化的解决方案
Isolate.run()这一功能是在Dart 2.19版本中添加的,它是在后台隔离执行单元中运行一次性任务的最简洁方式。该函数会创建一个隔离执行单元,运行你的代码,返回结果,然后自动关闭这个隔离执行单元。
// 在你的代码库中:
Future> getUsers() async {
// 第一步——网络请求属于I/O操作,可以使用await等待。
final response = await dio.get('/users');
// 第二步——解析大量用户数据需要依赖CPU计算,因此需要使用隔离执行单元。
final users = await Isolate.run(() {
final data = response.data as List;
return data
.map((json) => User.fromJson(json as Map
compute()——Flutter内置的帮助函数
compute()是Flutter为隔离执行环境提供的封装函数,其出现时间早于Isolate.run()。它至今仍被广泛使用,且运行效果良好,但有一个限制:你传入的函数必须是顶层函数或静态函数,而不能是捕获局部变量的闭包。
传递的函数必须是顶层函数或静态函数,
不能是闭包,因为能够捕获状态的闭包无法在隔离执行环境之间传递。
List parseUsers(dynamic data) {
return (data as List)
.map((json) => User.fromJson(json as Map
对于大多数使用场景来说,Isolate.run()更为简单且灵活。不过,如果你需要支持低于2.19版本的Flutter,compute()仍然会派上用场。
使用SendPort和ReceivePort》实现完整的隔离通信
对于那些需要长时间运行的后台任务来说,如果你需要频繁地发送和接收消息——比如后台同步服务、实时数据处理器或文件监控程序——那么你就需要使用带有SendPort和ReceivePort的完整隔离执行环境。
void main() async {
// ReceivePort用于接收来自工作线程的消息。
final receivePort = ReceivePort();
// 创建工作线程隔离执行环境,并为其提供SendPort,以便它能够向我们发送消息。
await Isolate.spawn(
workerFunction,
receivePort.sendPort,
);
// 监听工作线程发送的消息。
receivePort.listen((message) {
print('主线程收到消息:$message');
});
}
// 这个函数完全在工作线程隔离执行环境中运行,
// 它拥有自己独立的内存堆,与主线程完全隔离开来,
// 因此无法直接访问主线程中的任何变量。
void workerFunction(SendPort sendPort) {
for (int i = 0; i < 5; i++) {
// sendPort.send()用于向主线程发送消息。
// 这些消息是会被复制传递的,而不是共享内存,
// 因此不存在共享内存的问题。
sendPort.send('已处理的项目编号 $i');
}
}
选择合适的方法
| 使用场景 | 适用方法 |
|---|---|
| 一次性后台任务 | Isolate.run() |
| 需要支持低于2.19版本的Flutter | compute() |
| 长时间运行的后台工作线程 | 使用带有SendPort的完整隔离执行环境 |
| 需要等待网络请求或文件I/O操作完成 | 只需使用await即可,无需使用隔离执行环境 |
import 'dart:isolate';
import 'package:flutter/material.dart';
// 模型
class SearchResult {
final String id;
final String title;
const SearchResult({required this.id, required this.title});
}
// 最高级函数——Isolate.run()需要这个函数,
// 因为它不能是一个闭包
List
// 模拟复杂的解析过程
return data.map((item) => SearchResult(
id: item['id'].toString(),
title: item['title'] as String,
)).ToList();
}
// 数据存储类
class SearchRepository {
// 假设的数据——在真实应用中,这会是一个网络请求
final List
Future> search(String query) async {
// 模拟网络延迟
await Future.delayed(const Duration(milliseconds: 500));
// 过滤假数据
final filtered = _mockData
.where((item) =>
(item['title'] as String)
.toLowerCase()
.contains(query.toLowerCase())
).ToList();
// 在后台线程中执行解析操作,以保持主线程的事件循环正常运行
return Isolate.run(() => parseResults(filtered));
}
}
// 用户界面类
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State
}
class _SearchScreenState extends State
final _controller = TextEditingController();
final _repository = SearchRepository();
bool _isLoading = false;
List
String? _error;
Future
if (query.trim().isEmpty) {
setState(() => _results = []);
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await _repository.search(query);
// 检查用户是否已经离开当前页面
if (!mounted) return;
setState(() {
_results = results;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '搜索失败。请重试。';
_isLoading = false;
});
}
}
@override
void dispose() {
_controller.dispose();
superdispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('搜索'),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: '搜索',
border: OutlineInputBorder(),
prefixIcon: IconIcons.search),
),
onChanged: _search,
),
),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _search(_controller.text),
child: const Text('重试'),
),
],
),
);
}
if (_results.isEmpty) {
return const Center(child: Text('未找到任何结果。'));
}
return ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final result = _results[index];
return ListTile(
leading: Text(result.id),
title: Text(result.title),
);
},
);
}
}
void main() {
runApp(const MaterialApp(home: SearchScreen));
}
这个例子综合了我们之前讨论过的所有内容:
-
事件循环在模拟网络延迟期间确保用户界面能够正常响应——
await会将控制权交还给事件循环,因此Flutter会继续渲染帧。 -
隔离线程负责在后台进行解析工作,这样一来,即使处理的结果量很大,主线程也不会被占用。
-
“已挂载检查”可以防止在搜索进行过程中组件被销毁。
-
四种UI状态(加载中、出错、为空以及显示结果)都得到了明确的处理。
最后的思考
理解事件循环、流以及隔离线程的概念,有助于你弄清楚Dart为何会表现出这样的行为。一旦建立了这种思维模型,很多之前看起来随意的设计原理就会变得清晰起来。
为什么需要mounted检查呢?因为await会暂停函数的执行并将控制权交还给事件循环,所以在函数恢复执行之前,组件有可能被销毁。那么compute()为何能帮助解决帧率波动的问题呢?因为那些消耗大量CPU资源的任务会阻塞事件循环,而将这类任务放到隔离线程中处理,就可以让事件循环继续正常工作、持续渲染画面了。为什么会有广播流这种设计呢?因为默认的单次订阅流只允许一个监听者,但有些数据源需要同时为应用程序的多个部分提供数据。
这些并不是需要死记硬背的独立规则。它们其实都是同一单线程并发模型的必然结果,只有当你从基础层面真正理解了这个模型之后,才能把这些原理融会贯通。
如果你已经熟悉await和FutureBuilder>,那么这周就可以选择这篇文章中的某个概念进行深入研究。比如可以尝试实现流去抖动的效果,或者在你自己的应用程序中,用Isolate.run()>来处理实际的解析任务,然后观察Flutter DevTools中显示的帧率变化。当你亲眼看到这些原理在自己的代码中发挥作用时,理解就会快得多。