
刚开始学Flutter的时候我对异步这个词有点抵触总觉得它是JS那套回调地狱的翻版。结果第一次动手写登录页就翻车了点了按钮后整个界面卡住转圈转了好几秒才跳转体验差到想摔手机。后来才明白Flutter的异步并不等于多线程它真正依赖的是一套事件循环机制。把网络请求丢进async方法之后界面立刻变流畅了。这篇Flutter基础系列第5篇我打算一次性把异步讲透事件循环、Future、async/await、Stream、Isolate外加几个我踩过的真坑。没装环境的朋友建议先把Flutter环境搞定配好编辑器之后再看这篇会顺很多。1. 先搞懂事件循环再来谈Flutter异步1.1 Dart是单线程的为什么还能异步很多刚接触Flutter的人有个误区以为Flutter的异步就是悄悄开了新线程去处理任务。其实Dart默认是妥妥的单线程模型你的所有业务代码包括UI绘制、手势响应、网络回调、动画帧更新全都在同一个线程上轮流跑。那问题来了单线程怎么能同时处理网络请求、动画、用户点击答案是根本不同时。它靠的是事件循环Event Loop不断从队列里取任务、执行任务、再取下一个任务形成一种给用户错觉是并发处理的效果。就像只有一个员工的奶茶店虽然每次只能做一杯但他把接单、制作、结账拆成一个个小任务按顺序排队执行只要每步够快顾客感觉服务是连续的。卡顿的本质就是某一杯奶茶做太久后面排着的客人全在等。在Dart里排队分为两个队列微任务队列Microtask queue和事件队列Event queue。微任务优先级更高事件队列排在后面。Future.then和async/await的恢复逻辑多数时间走的是微任务队列而IO完成回调、手势事件、定时器这类走的是事件队列。这两个队列的优先级差异解释了很多异步顺序看起来反直觉的现象。比如你写Future(() print(1)); Future.microtask(() print(2));实际输出顺序是2在前1在后。因为微任务队列永远优先于事件队列被执行。1.2 Future只是排队中的任务不是新线程Future在Dart里的本质是一个占位符代表将来某个时刻会有一个结果。当你执行Future(() _doHeavyWork())它并没有立刻开一个线程去忙而是把这个任务注册到事件循环里等当前执行栈空了之后再按调度顺序去执行。所以如果任务本身是CPU密集型的——比如大数组排序、图片压缩、JSON序列化——就算包在Future里它依然会占用UI线程的时间。这块特别容易踩坑很多人以为我用了Future界面就不会卡了结果大图片一压缩还是卡成PPT。真正需要多线程的场景必须去找Isolate第四章会重点讲。那Future到底解决了什么它解决的是等待的问题。比如网络请求发起后你不可能让程序停在原地干等服务器响应而是先往下走、继续渲染界面等结果回来了再去处理数据。Future就是这样一个回调容器它不干重活它只是帮你安排什么时候回来拿结果。2. Future和async/await的几种写法和它们之间的区别2.1 创建Future的五种常见方式写Flutter代码你会不断遇见这几种Future构造方式Future(() { ... })创建一个异步执行的任务进入事件队列Future.delayed(Duration(seconds: 2), () { ... })延迟指定时间后执行Future.microtask(() { ... })放进微任务队列优先于普通事件执行Future.value(42)直接返回一个已完成的Future常用于占位或测试Future.error(Exception(boom))直接返回一个失败的Future用于主动抛出异步异常我实际用下来Future.delayed在模拟网络延迟、做轮询间隔时特别常用。面试里也经常问它和Future.microtask的区别前者走事件队列后者走微任务队列执行时机完全不同。2.2 then、catchError、whenComplete链式调用三件套Future最原始的用法是链式回调比如下面这个模拟用户信息请求的例子FutureString fetchUser() { return Future.delayed(const Duration(seconds: 2), () { return 李雷; }); } void main() { fetchUser() .then((name) print(用户$name)) .catchError((err) print(出错$err)) .whenComplete(() print(请求结束)); }这种链式写法的执行顺序是先then再catchError最后whenComplete类似try-catch-finally。但catchError有个容易搞错的点它只能捕获它前面那一段回调链里抛出的异常。如果你在then里又抛了一个新异常而catchError又挂在它后面的位置那可以接住但如果你把catchError放在某个then之前后面的then里的错误它接不到。所以我的习惯是链式调用的最后才放catchError别在中间乱插。2.3 async/await把异步代码读成同步逻辑很多人说async/await只是语法糖这句话没错。但对业务代码而言它的价值在于把原本可能嵌套三层的回调拍平让逻辑按从上到下的顺序阅读。还是用上面的例子改写成async/awaitFuturevoid main() async { try { final name await fetchUser(); print(用户$name); } catch (e) { print(出错$e); } finally { print(请求结束); } }这里最需要理解的一点await是在等待但它并没有阻塞UI线程。你在await期间动画照常播放、按钮照常响应、其他事件照常执行。这是Flutter异步最核心的认知突破——你写的代码看起来是同步的但底层并没有占住线程。2.4 FutureBuilder把Future结果安全地渲染到Widget上在Flutter里你不能在build方法里直接写await因为build方法必须是同步返回Widget的。这时候就需要FutureBuilder。它是Future和Widget之间的桥根据Future的执行状态返回不同的UIlate FutureString _future; override void initState() { super.initState(); _future fetchUser(); } override Widget build(BuildContext context) { return FutureBuilderString( future: _future, builder: (context, snapshot) { if (snapshot.connectionState ConnectionState.done) { if (snapshot.hasError) { return Text(加载失败${snapshot.error}); } return Text(用户${snapshot.data}); } return const CircularProgressIndicator(); }, ); }这里有个非常常见的坑不要把future: fetchUser()写在build里。每次build都会触发新的Future创建导致FutureBuilder误以为收到了新任务结果界面反复回到loading状态就是那种闪烁一下又转圈的体验。正确的做法是在initState里创建并保存这个Future或者把Future缓存在State字段里。3. Stream流不是一次性结果而是一串持续事件3.1 为什么Future解决不了所有问题Future只代表一次性结果网络请求返回后就是完整的数据。但实际开发里有大量反复产生数据的场景下载进度从10%到50%再到100%、聊天界面实时收到对方消息、蓝牙模块不断上传传感器数据。这类场景用Future是接不住的——你不可能等所有数据都齐了再一次性返回用户要的是一个持续的进度反馈。Stream就是为这种场景设计的。如果把Future比作点一次外卖最后收到一份饭那Stream更像是订阅了一个频道频道里不断有节目推送到你面前。你要做的不是等而是订阅它、监听它每个数据进来时实时处理。3.2 StreamController手动创建一条流最基础的创建方式是StreamControllerfinal controller StreamControllerint(); void main() { controller.stream.listen((value) { print(收到$value); }); controller.add(1); controller.add(2); controller.add(3); }这里一个比较关键的概念StreamController默认创建的是单订阅流也就是同一个Stream只能有一个listener。如果你加第二个listener会直接报错Stream has already been listened to。解决方案是创建广播流让多个地方可以同时订阅final controller StreamControllerint.broadcast();两者的区别有点像一对一电话和广播电台单订阅流适合一个生产者、一个消费者的场景能保证事件不被多个订阅者瓜分广播流适合一个生产者、多个消费者的场景比如全局状态同步、通知分发。3.3 StreamBuilder让数据驱动UI自动更新StreamBuilder是Stream版的FutureBuilder它监听一个Stream当数据到达时自动重建Widget不需要手动setState。比如做一个实时进度条StreamBuilderint( stream: controller.stream, builder: (context, snapshot) { final progress snapshot.data ?? 0; return LinearProgressIndicator(value: progress / 100); }, )用StreamBuilder最舒服的地方是数据源一旦有事件界面自动刷新不用你手动管理状态机。我做过一个图片上传功能上传进度通过Stream传回界面进度条自动往前走代码非常简洁。3.4 流的常用操作过滤、转换、防抖Stream提供了一组类似集合的操作方法比如where、map、distinct使用起来非常顺手controller.stream .where((value) value 0) .map((value) value * 2) .listen(print);如果你想做搜索框的防抖Stream配合Timer可以实现得很优雅用户每输入一个字符不直接发请求而是等300ms如果期间又有新输入就取消上一个定时器重新计时。这样请求次数从每敲一个字母一次降为用户停顿后一次效果立竿见影。写法的核心就是把用户输入放进Stream用一个Timer延迟转发延迟期间有新人进来就重置计时器。4. Isolate和computeUI线程忙不过时就把活分出去4.1 什么时候该用Isolate我前面反复强调Future不等于新线程。那到底什么时候需要真正的并发答案是遇到纯计算型、CPU密集型的任务时。举个例子你要把一张相机拍的大图进行裁剪和压缩。这个操作如果放在主线程上即使包在Future里依然会卡住UI——因为计算本身占用了CPU事件循环一时半会儿轮转不到绘制任务。用户体验就是拍照后界面僵硬一两秒甚至触发系统无响应提示。Dart的Isolate就是为了应对这种情况。每个Isolate有自己独立的内存堆和事件循环两个Isolate之间不能共享变量只能通过消息通信。这跟传统多线程的共享内存模型很不一样好处是你不用去考虑线程锁、死锁这些问题坏处是传大量数据时会有拷贝开销。打个比方主线程是一个主厨Isolate是另一个独立厨房。两个厨房不能共用一口锅主厨把菜谱交给另一个厨房对方做好了再把菜端回来。菜谱的传递需要拷贝所以分量特别大的菜传递成本也高。4.2 compute最轻量的Isolate入口Flutter在foundation库里提供了compute函数用来跑一个顶层函数它的本质是创建一个临时Isolate、传递参数、执行、返回结果、销毁Isolate。用法很简单import package:flutter/foundation.dart; int doHeavyWork(int value) { var sum 0; for (var i 0; i value; i) { sum i; } return sum; } void main() async { final result await compute(doHeavyWork, 100000000); print(result); }compute的两个限制传入的函数必须是顶层函数或静态函数不能是闭包不能捕获外部变量传参和返回值都必须是可拷贝的。所以它只适合给一个独立参数、拿一个独立结果的场景。我的经验是能用compute解决的绝不手动开Isolate因为compute帮你省掉了消息通信的样板代码。4.3 手动创建Isolate处理更复杂的通信需求更复杂的场景需要手动创建Isolate通过ReceivePort和SendPort通信。一个最小可运行的例子import dart:isolate; void worker(SendPort sendPort) { sendPort.send(准备好了); } void main() { final receivePort ReceivePort(); Isolate.spawn(worker, receivePort.sendPort); receivePort.listen((message) { print(收到消息$message); }); }手动Isolate的价值在于通信是双向的主Isolate可以给子Isolate发消息子Isolate也能给主Isolate回传进度。比如边下载边解析文件、后台数据同步这类长任务适合常驻一个Isolate。但日常开发中这类需求不多优先用compute省心。4.4 不要频繁创建Isolate创建Isolate是有开销的一个包含独立内存堆和事件循环的隔离区启动成本比普通函数高得多。如果你在滑动列表时频繁触发任务、每次都创建新Isolate反而会让性能更差。正确的思路是短任务用compute没问题长时间、频繁的任务要复用一个常驻Isolate通过消息来驱动它干活避免反复创建销毁。5. 实战一个带超时、重试和并发控制的请求封装5.1 网络请求为什么要考虑这三点异步编程最终要落到实战。我以最常用的网络请求为例普通网络请求直接写async/await就行但真实项目里你会碰到超时无响应、服务器抖动、多个接口同时发出导致竞态问题。这三个问题不处理异步代码再规范用户体感还是转圈、卡顿、偶尔白屏。我平时用dio比较多下面这套方案的套路对http、chopper等请求库也通用。5.2 用async/await把网络请求写成同步逻辑最基础的请求长这样FutureString fetchData() async { final res await dio.get(https://api.example.com/data); return res.data[name]; }这里的await让代码读起来像同步的但主线程并没有被阻塞。用户等结果期间依然可以上下滑动页面loading动画也能正常转。这个体验差异是异步编程最直接的价值。5.3 超时和重试怎么设计dio本身支持超时配置dio.options.connectTimeout const Duration(seconds: 5); dio.options.receiveTimeout const Duration(seconds: 5);但一个请求超时之后很多时候是服务端临时抖动直接报错给用户并不友好。我习惯封装一个带重试的请求方法FutureT requestWithRetryT( FutureT Function() request, { int retries 3, }) async { for (var i 0; i retries; i) { try { return await request(); } catch (e) { if (i retries - 1) rethrow; await Future.delayed(Duration(milliseconds: 300 * (i 1))); } } throw Exception(unreachable); }这里我特意让重试间隔按次数递增第一次延时300ms第二次600ms第三次900ms。如果同时有几十个请求都在重试无脑同步重试会给服务器造成二次压力这种递增间隔能起到简单的退避效果。5.4 loading状态和错误处理请求发出后界面要有一个loading反馈失败时要有错误提示成功时才能展示数据。我用StatefulWidget管理这几种状态bool _loading false; String? _error; String? _data; Futurevoid _load() async { setState(() { _loading true; _error null; }); try { final result await requestWithRetry(fetchData); if (mounted) { setState(() { _data result; _loading false; }); } } catch (e) { if (mounted) { setState(() { _error e.toString(); _loading false; }); } } }我特意在每个setState前面加了mounted判断。原因是await期间用户可能已经离开了页面Widget被销毁了。此时再调用setState轻则报setState() called after dispose()重则崩溃。这是Async编程最常见的运行时错误没有之一。5.5 并发请求和顺序依赖怎么取舍如果多个接口互相独立比如首屏同时要拉用户信息和订单列表用Future.wait并发执行final results await Future.wait([ dio.get(https://api.example.com/user), dio.get(https://api.example.com/orders), ]);如果B接口依赖A接口的返回结果那就必须用await串行final user await dio.get(https://api.example.com/user); final orders await dio.get( https://api.example.com/orders?userId${user.data[id]}, );我记得有次做登录流程一边要调登录接口一边要调用户配置接口我第一反应写成了两个await串行结果每次登录都明显慢一拍。后来发现这两个接口完全无依赖改成Future.wait之后总耗时直接降到了最慢的那个接口的耗时而不是两个接口的时间之和。判断并发还是串行的标准很简单数据之间有没有依赖。有依赖必须串行没依赖尽量并发。6. 异步踩坑记录从setState崩溃到FutureBuilder闪烁6.1 setState在async之后别忘了mounted这已经是本章第二次提到了但单独拉出来说是因为它实在太常见。几乎每个写Flutter异步的人都或多或少遇到过“setState() called after dispose()”。原因很典型用户在等待网络返回时按了返回键Widget被销毁然后网络响应到了发起请求的那个方法继续执行调用了setState。我现在的习惯是所有包含await的方法里只要之后会用到State相关内容先判断mounted。这个检查几乎零成本却可以避免一类非常隐蔽的crash。如果页面销毁后你还想执行某些逻辑比如更新缓存、埋点上报可以在mounted判断之外单独处理。6.2 FutureBuilder闪烁的根源未来构建器闪烁的问题我在2.4节已经点过一次。这里补充一个更隐蔽的情况就算你把Future存到了State字段里如果State被系统重建比如切后台再回来、主题变化导致上层重建这个Future对象虽然还在但FutureBuilder可能会因为快照状态变化而重新走loading。解决方法是给FutureBuilder加一个合理的key或者考虑用缓存策略避免网络请求重复触发。6.3 异步异常必须捕获别让它静默丢失在Dart里一个没有捕获的Future异常会被当作未处理异常处理表现为控制台里多了一行Error信息但你的try/catch完全接不到因为异常发生在链式调用的某个分支里。我曾经排查过一个线上问题远端配置拉取失败时功能静默失效了但用户界面没有任何提示日志里也没有有效记录最后发现是Future对象的异常没有统一收集。我的建议是入口级别的异步方法必须用try/catch包住如果确实不打算处理也要在catch里打日志至少让异常痕迹可查。更进一步可以在main方法里用FlutterError.onError或runZonedGuarded统一收集全局的未捕获异常遇到问题能第一时间从日志平台发现。6.4 Stream和Timer不取消内存泄漏迟早找上门StreamController、StreamSubscription和Timer这些在上层注册到全局或异步回调里的对象一定要在dispose阶段清理。我见过一个下载进度页退出页面后下载任务还在跑进度回调还往已经销毁的SetState里塞数据结果界面破图、内存持续增长。正确的做法是在State里保存订阅句柄然后在dispose里释放StreamSubscriptionint? _subscription; Timer? _timer; override void dispose() { _subscription?.cancel(); _timer?.cancel(); super.dispose(); }6.5 大量并发Future会把事件循环挤满Future.wait虽然能把并发请求写得非常简洁但一次塞几十个请求进去很容易把带宽打满也会给服务器带来瞬时压力。更麻烦的是大量Future任务挤在事件队列里会延后其他事件的处理用户会感觉界面响应变慢。我做的比较笨但有效的方案是限制并发数量比如同一时间最多跑5个请求其余排队。用一个简单的计数器加队列就能实现原理类似限流信号量。后来项目接入了隔离的并发池但思路没变——异步不是越多越好而是越可控越好。踩过这些坑之后我对异步的理解已经不太一样了。它不是一个需要死记的概念而是一种编程思维不是在等结果而是知道结果会在某个时刻回来在那之前先把界面准备好让用户感觉流畅。刚开始学Flutter的朋友不用急着把Stream和Isolate都背下来先把Future和async/await这一套练熟遇到耗时计算再上compute等做实时进度功能时再去碰Stream每一步都是自然过渡。代码写得多了你会发现异步其实是最不该怕的那部分——真正可怕的是那些没有做异步处理的界面卡顿。