一、一个隐藏在数据库操作中的 UI 卡顿
企业 IM 的会话列表,需要先展示本地数据库中已有的 2000+ 条会话记录,再静默同步服务端增量数据。我们最初的实现是同步读取数据库:
1 | List<Conversation> loadConversations() { |
在 Profile 模式下,这段代码耗时约 300ms。问题是它跑在主 Isolate 的 UI 线程上——300ms 内 Platform 层收不到新帧的提交,用户看到的就是会话列表卡住不动。
排查出这个问题后,我们把数据库操作移到后台 Isolate。结果列表不卡了,但新的问题来了:数据在后台 Isolate 处理好、发回主 Isolate 后,setState 报 setState() called after dispose()——因为用户在操作加载中退出了会话页,后台任务回来时 Widget 已经销毁了。
这个案例涉及三个 Dart 核心概念:Event Loop(事件循环)、Future/async-await(异步调度)、Isolate(并发隔离)。本文逐一展开。
二、Event Loop:Dart 的心跳
2.1 单线程模型的设计哲学
Dart 采用单线程事件循环模型,和 JavaScript 一脉相承。Dart 团队选择单线程的核心理由有三:
- 避免竞态条件:不存在「两个线程同时修改同一个变量」的问题,开发者不需要处理锁、信号量、原子操作。
- 符合 UI 框架需求:Flutter 的 Widget/Element/RenderObject 树都是非线程安全的,多线程操作这些树会导致不可预料的崩溃。单线程模型天然保护了框架内部状态。
- 异步非阻塞:通过 Event Loop + Future 的组合,单线程同样能处理高并发 I/O,不需要为每个连接开一个线程。
代价是:任何长时间同步计算都会阻塞 Event Loop,冻结 UI。 这迫使开发者将耗时操作交给 Future 或 Isolate。
2.2 两个队列:Microtask Queue 和 Event Queue
Dart 的 Event Loop 维护两个队列,按严格的优先级顺序消费:
1 | Microtask Queue(优先级高) |
Microtask Queue:用于必须「尽快在当前 Event 完成后、下一个 Event 开始前」运行的短任务。通过 scheduleMicrotask() 添加。
Event Queue:所有 I/O 事件、Timer 回调、Gesture 事件、Future.then() 回调都会进入这个队列。这是 Dart 异步的主力军。
2.3 一个容易写错的例子
1 | void main() { |
理解这个顺序的关键:print('main') 在同步代码路径中直接执行。Microtask 先于 Event 执行。Timer.run 和 Future 的默认构造函数都进入 Event Queue。
三、Future 和 async-await 的调度机制
3.1 Future 的三种执行时机
1 | // 1. 立即计算,回调进入 Event Queue |
需要注意:Future.value() 的值计算是同步的,但 .then() 的回调永远是异步的——它会进入 Microtask Queue(如果 Future 已经完成)或等待 Future 完成后进入 Microtask Queue。
3.2 async-await 的底层等价转换
1 | // await 写法 |
await 之后的代码被拆分为一个 .then() 回调,放入 Microtask Queue 挂在当前 Future 上。这意味着 await 之后的代码不会在当前同步调用栈中执行。
3.3 业务陷阱:微任务堆积导致的优先级倒挂
在企业 IM 的消息同步流程中,我们曾错误地在循环中使用 await:
1 | // 错误:串行处理,每条消息 await 一次 |
100 条新消息产生 200 个挂起的 Future 链,虽不会阻塞 Event Loop(因为每个回调执行很快),但使得其他高优先级的微任务(如渲染相关的微任务)排在这 200 个之后。正确的做法是批量插入,减少 Future 链的深度:
1 | await database.batchInsert(newMessages); // 一条 SQL,一个 Future |
四、Isolate:Dart 的并发模型
4.1 Isolate 是什么
Isolate 是 Dart 的并发单元。每个 Isolate 拥有独立的 Event Loop、独立的堆(Heap)、独立的运行状态。Isolate 之间不共享内存——这是与 Java Thread 的根本区别。
1 | // 创建一个 Isolate |
不共享内存意味着:没有锁、没有死锁、没有数据竞争。代价是跨 Isolate 通信只能通过消息传递(SendPort/ReceivePort),消息必须可序列化。
4.2 什么时候用 Isolate
在企业 IM 中,Isolate 的典型使用场景包括:
数据库操作:会话列表从 SQLite 加载 2000+ 条记录,包含多表 JOIN 的查询耗时可达 200-500ms。这个时间如果跑在主 Isolate,UI 冻结半秒。
JSON 解析:服务端返回的增量数据可能是 200+ KB 的 Protobuf/JSON,解析耗时 50-100ms。对于高端机型来说不算长,但中低端设备上可能导致掉帧。
图片解码前的尺寸计算:聊天气泡需要提前计算图片素材的宽高比。上千张缩略图的尺寸计算不应阻塞 UI。
不应该用 Isolate 的场景:轻量计算、频繁的小通信(Isolate 间的消息传递有序列化开销)、需要频繁访问 UI 状态的操作。
4.3 Compute 函数的便捷封装
Flutter 提供了 compute 函数作为 Isolate 的简化用法:
1 | final result = await compute(parseProtoBuf, rawBytes); |
compute 自动管理 ReceivePort 的生命周期。但它每次调用都会创建和销毁一个新的 Isolate,适用于一次性的重计算任务。对于需要持久运行的场景(如持续监听消息通道),应直接使用 Isolate.spawn 并保持 Isolate 存活。
4.4 Isolate 通信的业务模式
在企业 IM 的消息预加载功能中,我们使用了一个常驻的「数据库 Isolate」:
1 | class DatabaseIsolate { |
关键设计是用 ReceivePort 作为返回值通道,每次请求创建一次性的 ReceivePort,确保返回结果精准路由到对应的调用方。同时用 Completer 将消息回调桥接到 Future,让上层代码能以 await 方式使用。
五、总结
Dart 的异步与并发模型,本质上是一条精心设计的折中路线:
- Event Loop:单线程 + 双队列(Microtask/Event),在保证 UI 安全的前提下实现异步非阻塞。
- Future/async-await:将回调地狱转换为顺序可读的代码,底层仍是微任务调度。
- Isolate:通过不共享内存的并发单元,在需要时突破单线程瓶颈,代价是消息传递的序列化开销。
在企业 IM 的实际开发中,最常犯的错误不是用错了 API,而是把耗时操作留在了 Event Queue 里,而不是推到另一个 Isolate 中。Event Loop 只是调度器,不是算力来源——当一个 Future 的回调本身就要 200ms,Event Loop 唯一能做的就是等到它执行完,期间 UI 完全冻结。理解这一点,是做出正确异步决策的起点。
扫描二维码,分享此文章