2.4 异步与 Isolate:界面不卡的秘密


2.4 异步与 Isolate:界面不卡的秘密

本节摘要:界面卡不卡,本质是"界面线程有没有被占住"。本节讲清 Dart 的事件循环如何让 await 不占线程、Future 与 Stream 各自的适用场景、以及单线程模型的天花板在哪——哪些活儿必须搬进工作 Isolate,怎么搬,搬的代价(不能共享内存、只能传消息)如何影响设计。这是第 5 章网络与 JSON、第 6 章性能优化的前置。

事件循环:await 为什么不卡界面

先纠正一个流传很广的误解:async-await 不是"开一个后台线程"。Dart 代码默认跑在一个 Isolate(可理解为带独立内存的执行单元)里,Flutter 的界面工作也在这同一个 Isolate。那 await 期间界面为什么还能动?

答案在事件循环(event loop)。Dart 的 Isolate 内部维护两条队列:事件队列(触摸、IO 完成回调、定时器)与微任务队列(await 的续体、调度到微任务的工作)。执行模型一句话:跑完当前这小段代码,先把微任务队列清空,再从事件队列取一件处理,如此往复。await 的本质是"把后续代码登记为微任务,然后让出执行权"——让出之后,事件循环就能去处理触摸与绘制事件,界面自然不卡。

推论也很清晰:await 之后的代码不占线程,await 之前的同步代码占。在 async 函数里写一个五百万次的同步循环,或者在 build 回调里同步解析大 JSON,事件循环一样被堵死——async 关键字救不了同步代码。这也是上一节"build 里睡 50 毫秒"实验的真正病因。

Future 与 Stream 的分工由此而来:Future 是"未来会到达的一个值"(一次网络响应、一次读盘结果),Stream 是"陆续到达的一串值"(搜索框每敲一下的输入、下载进度、传感器数据)。选错不会编译报错,但会在错误的地方付出代价——把连续输入接成 Future 串,就要自己处理"上一笔还没完下一笔又来了"的竞态,而 Stream 天然按序推送。

// 事件循环体感实验:两条输出交错出现 void main() { print('1:同步代码先跑'); Future(() => print('4:事件队列任务')); scheduleMicrotask(() => print('3:微任务先于事件任务')); print('2:同步代码跑完才轮到队列'); } // 输出顺序:1、2、3、4

单线程的天花板与 Isolate 的搬法

事件循环保证了"等待不卡",但保证不了"计算不卡"。解析一份几 MB 的同步报文、压缩一批图片、加密整库数据——这些活儿没有"等待",只有实打实的 CPU 时间,留在界面 Isolate 就是掉帧。

Dart 的答案是 Isolate:每个 Isolate 有独立内存堆,之间不共享任何可变状态,通信只靠消息传递(发送方复制或转移数据,接收方拿到副本)。没有共享内存,就没有锁,也就没有死锁——代价是大数据在 Isolate 间拷贝有成本,能传递的对象类型也有限制(基础类型、List、Map 等可复制对象可以,含闭包回调的复杂对象不行)。

日常业务用高层封装就够:compute(Flutter 提供)与 Isolate.run(Dart 官方)都是"传个函数进去,拿个 Future 回来",短命任务首选。长命任务(持续接收消息的工作者)才需要手工管理 Isolate 与收发端口。

三个使用要点,来自轻记账的真实教训:

  1. 门槛感:不是所有耗时都值得开 Isolate。Isolate 的创建、数据复制各有开销,毫秒级的活儿搬过去可能得不偿失。经验线是"同步部分预计超过十几毫秒再搬",拿不准就先用 DevTools 量(第 6 章)。
  2. 函数与参数要"可搬运":顶层函数或静态方法可以传进 Isolate,实例方法带着 this 就不行;参数里的对象也遵循同一限制。把解析逻辑写成顶层纯函数,是顺手的好习惯。
  3. 结果粒度:解析一万条账单再回传,不如分批解析分批回传——首屏只等首批数据,这就是"搬运也要讲排班"。

动手:把 JSON 解析挪出界面线程

轻记账同步接口返回整月账单,JSON 文本约几 MB。先看错误写法,再看正确写法:

import 'dart:convert'; import 'package:flutter/foundation.dart' show compute; // 顶层纯函数:可被搬进工作 Isolate,不依赖任何实例状态 List<ParsedBill> parseBills(String rawJson) { final data = jsonDecode(rawJson) as List<dynamic>; return data.map((e) => ParsedBill.fromMap(e as Map<String, dynamic>)).toList(); } Future<void> loadMonthlyBills() async { final raw = await fetchLedgerJson(); // 网络等待本身不占线程 // 错误示范:jsonDecode 是同步 CPU 计算,几 MB 报文会卡住界面几十毫秒以上 // final bills = parseBills(raw); // 正确做法:整个解析搬进工作 Isolate,界面线程只等一个 Future final bills = await compute(parseBills, raw); debugPrint('解析完成:${bills.length} 条,全程未挡帧'); } class ParsedBill { final String title; final double amount; const ParsedBill({required this.title, required this.amount}); factory ParsedBill.fromMap(Map<String, dynamic> map) => ParsedBill( title: map['title'] as String, amount: (map['amount'] as num).toDouble(), ); }

判定与搬运动作可以固化成一句检查清单:await 的不搬(网络、磁盘本来就是等待),同步算的不留(CPU 密集挪进 Isolate)。这条线划清之后,第 5 章所有数据进出代码都按它写。

本节要点回顾

  • 事件循环两条队列:微任务优先清空,事件任务逐条处理;await 让出执行权,界面因此不卡;
  • async 不开线程:await 前的同步代码照样占住界面 Isolate;
  • Future 是一个未来的值,Stream 是一串陆续的值,连续输入选 Stream 免竞态;
  • Isolate 之间不共享内存、只传消息,适合 CPU 密集任务,有创建与拷贝开销;
  • 用 compute 或 Isolate.run 处理短命任务,传顶层纯函数,大数据考虑分批回传。

骨架四讲到此收齐:分层、管线、三棵树、事件与异步。从下一章开始,我们把这套骨架用起来——先从最直观的部分下手:把轻记账的界面一屏一屏画出来。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U