☰
Dart Future 异步编程:事件循环与 async/await 实战解析
2026/10/14 1:11:18 网站建设 项目流程

记笔记的都知道,Dart 里的Future是个绕不过去的坎。这东西本质上就是个“异步结果容器”,你把它想成外卖订单:你下了单(发起异步任务),手里有个取件码(Future 对象),等骑手送到(任务完成),你再拿着取件码去取餐(拿到结果)。Dart 是单线程语言,但靠事件循环 + Future 这套机制,照样能把网络请求、文件读写这些耗时的活儿干得漂漂亮亮,还不卡界面。这篇笔记是系列第七篇,专门把 Future 掏出来揉碎了讲,适合已经掌握 Dart 基础语法、正准备碰网络请求或者写 Flutter 界面的同学。我尽量不写教科书那套,全是用过之后踩出来的经验。

1. 为什么 Dart 需要 Future:单线程里的异步逻辑

1.1 事件循环:Dart 的“单线程”到底怎么跑起来的

Dart 程序默认跑在一个 isolate 里,也就是一个独立的“线程”。但光有线程不够,要处理网络、文件 IO、定时器这些操作,总不能发个请求就把 UI 卡死。所以 Dart 内置了一套事件循环机制:所有任务都被扔进队列,一个一个轮流执行。

事件循环有两个队列:一个是“微任务队列”,一个是“事件队列”。微任务优先,事件队列靠后。Future的回调、async函数恢复执行的逻辑,会被塞进微任务队列;而 IO 事件、定时器、用户点击这些外部事件,才走事件队列。这也是Future在 Dart 里表现“又快又准”的根本原因。

你可以这样理解隔离区的运行规则:先把手头同步代码跑完,再检查微任务队列,清空后才能去处理事件队列。这就是为什么你在main函数里写Future.delayed,后面的普通打印语句会先执行的底层原因。我刚开始学的时候经常因为执行顺序猜错而写出一堆 bug,后来直接把这套队列优先级记成“同步先跑、微任务紧随、事件队列靠后”,正确率瞬间拉满。

1.2 Future 不是什么:它不是并发,也不是新线程

这里必须纠正一个非常常见的误解:Future不是并发,更不会帮你开一个新线程。它只是把一个耗时的操作包装成了“稍后会给你结果”的形式,真正干活的还是事件循环自己。你要是想着用Future去解决 CPU 密集计算导致卡顿的问题,方向就错了。CPU 密集任务应该用Isolate(比如compute()),而Future适合的是 IO 密集操作,例如网络请求、磁盘读写、数据库查询等。

我当时在一个模拟项目里写了个图片压缩功能,直接套Future,结果压一张大图界面还是卡成了幻灯片。后来把压缩逻辑丢到Isolate里,才算真正解决了问题。所以这里必须记牢:Future 解决的是“等待”,不是“并行”。等网络返回、等文件打开、等定时器触发,这些“等”就是 Future 的管辖范围。

2. Future 的核心用法:从最基础的 then 到链式调用

2.1 最基本形态:Future 构造器与 then

先看一个最原始的Future用法:

Future<String> fetchData() { return Future(() { // 模拟耗时操作 return '模拟数据'; }); } void main() { fetchData().then((data) { print('拿到数据: $data'); }); print('我在 Future 之前就执行了'); }

运行顺序是“我在 Future 之前就执行了”先打印,然后等Future内部的耗时操作跑完,then回调才执行。原因是Future的构造函数会把传入的函数丢到事件队列里,而main剩下的代码是同步的,当然先跑完。

then接收一个回调函数,参数就是异步任务完成后的返回值。这里有个小细节:Future构造器支持两种写法,一种是上面这种,传入一个返回具体值的函数;另一种是传入一个返回Future的函数。Dart 会自动“展开”这种嵌套,等里面的Future完成后再往下走。

2.2 链式调用:避免回调地狱的经典姿势

真正让Future强大的是它可以一直.then下去:

fetchUser().then((user) { return fetchOrders(user.id); }).then((orders) { return computeTotalPrice(orders); }).then((total) { print('总价: $total'); });

每个then里都可以返回一个新值或者新Future,只要返回的是Future,下一步then就会等它完成。这样一层层接下去,代码是从上往下读的,逻辑也顺了,不容易陷入“回调地狱”。

写链式调用时要注意一点:then里的返回值决定了下游拿到的数据类型。如果你想保持链路上都是同一种类型,就要注意每层返回的是值还是Future,一旦类型前后对不上,编辑器马上就给你标红。我记得刚开始写项目时,常在then里忘记写return,结果整个链条断掉,下一个then接到的成了null,排查了半天才发现问题。

2.3 异常处理:catchError 和 whenComplete 怎么用

异步任务不是每次都能成功,网络超时、数据解析失败、权限被拒,都可能让你的Future变成一个“带错误的结果”。Dart 提供了catchError来兜底,用法是这样:

fetchData() .then((data) { print('成功: $data'); }) .catchError((error) { print('出错了: $error'); return '默认数据'; });

注意,catchError不是随便挂在任何位置的。如果你想捕获整条链路上的错误,就要挂在最末端;如果只想捕获某一段的错误,就得在这一段后面先接一个catchError。我在一个项目里踩过坑:把catchError放在then前面,结果上游的错误没被捕获,而下游的错误却被兜住了,这个顺序问题翻来覆去调了好久才搞明白。

whenComplete和 try-catch-finally 里的finally意思差不多,不管成功失败都会执行,适合做关闭资源、加载结束隐藏动画这类收尾操作:

fetchData() .then((data) => print(data)) .catchError((err) => print(err)) .whenComplete(() { print('不管结果如何,这里的代码一定会执行'); });

2.4 创建已完成或已失败的 Future:Future.value 与 Future.error

有时你不需要真的去异步执行什么,只是想快速得到一个Future。比如封装了一个接口,某些条件下可以直接返回“空数据”,不用发起真正的网络请求。这时候可以用Future.value和Future.error:

Future<String> getCachedData(String key) { final cache = cacheMap[key]; if (cache != null) { return Future.value(cache); // 直接返回一个已经完成的 Future } return Future.error('缓存不存在'); // 直接返回一个失败的 Future }

Future.value创建出来的是一个已经处于完成状态的Future,.then会在下一轮微任务里执行,而不是立即同步执行,这一点跟直接调用回调函数不一样。想验证的话,可以写一行Future.value(1).then(print);后面再跟个print('同步代码'),你会发现同步代码先打印。很多初学者以为这样写会立刻拿到值,其实还是得等事件循环切回去。

3. async/await:让异步代码写起来像同步

3.1 语法糖到底怎么工作的

用.then链写多了会发现,逻辑一旦复杂,缩进套娃还是很难看。Dart 提供了async/await语法糖,它不会改变 Future 的执行方式,但能把代码写得跟同步一样直白:

Future<void> processData() async { try { final data = await fetchData(); print('拿到数据: $data'); } catch (e) { print('出错了: $e'); } }

await会“暂停”当前函数,等到Future完成后,结果直接通过赋值表达式传给你。这里强调“暂停”而不是“阻塞”,是因为 Dart 的事件循环并没有被卡住,其他任务(UI 刷新、定时器、别的异步回调)照样可以跑。这个特性在 Flutter 里极其重要——你能在await等待网络请求的同时,界面还保持流畅,手势还能响应。

我之前给别人讲这个机制时打了个比方:await就像你在餐厅点完菜,服务员先去做别的桌子的活儿,等你的菜好了再端过来,而不是傻站着等厨房做好这一桌才管下一桌。这样整个餐厅(事件循环)一直处于运转状态,效率才高。

3.2 同步部分与异步部分的边界

写async函数时有个非常容易踩的坑:async函数并不是从头到尾都是异步执行。在函数体内第一个await出现之前,代码都是同步执行的。

Future<void> test() async { print('第一行,同步执行'); await Future.delayed(Duration(seconds: 1)); print('第二行,等 1 秒后执行'); } void main() { test(); print('main 里最后的输出'); }

打印顺序是:第一行,同步执行→main 里最后的输出→ 等 1 秒 →第二行,等 1 秒后执行。第一行print虽然写在async函数里,但它不依赖任何await,所以它跟普通同步代码一样,立刻执行。如果你把耗时的初始化逻辑写在第一个await之前,那这段逻辑还是会把 UI 卡住的。

3.3 try-catch-finally 对应 Future 的三件套

async/await的错误处理,就直接用try-catch-finally了,跟写同步代码一样自然:

Future<String> fetchDataSafe() async { try { final response = await httpGet('/api/data'); return response.body; } on TimeoutException catch (e) { return '超时默认值'; } catch (e) { return '其他错误: $e'; } finally { print('清理工作'); } }

这里catch里可以写多个on子句来分类型捕获。Dart 的异常对象不像其他语言那样强制你声明“可抛出的异常”,所以凡是没被catch捕获的异常,就会一路冒泡到最外层。在 Flutter 里,如果不处理,可能直接报告给FlutterError或者直接崩溃,所以只要是面向外部使用的async方法,尽量在边界处把异常揽住。

4. Future 的组合与并行:等所有都完成,还是等第一个完成

4.1 Future.wait:并发执行,等待全部结果

实际项目中经常遇到“并发发三个请求,全部完成后再统一刷新 UI”的需求。如果一个个await去调,时间是串起来的,浪费机会。Dart 提供了Future.wait:

final results = await Future.wait([ fetchUserProfile(), fetchUserOrders(), fetchUserSettings(), ]); final profile = results[0] as UserProfile; final orders = results[1] as List<Order>; final settings = results[2] as UserSettings;

三个请求是同时发起的(不是等第一个完成再发第二个),然后一起等全部返回。总耗时约等于最慢的那个请求,这对体验提升非常明显。假如三个请求每个都要 2 秒,串行要 6 秒,用Future.wait就只要 2 秒左右。

Future.wait多个Future时,只要其中一个抛异常,整个wait就会立刻以错误结束,其余没完成的结果也被丢弃。如果需求是“所有请求各自容错,然后合并成功的结果”,那你得给每个子任务先套一层catchError,把错误转成默认值,再丢进wait。这是我做接口聚合时常用的套路:

Future<List<String>> fetchAllWithFallback() { final tasks = [ fetchA().catchError((e) => 'A 默认值'), fetchB().catchError((e) => 'B 默认值'), fetchC().catchError((e) => 'C 默认值'), ]; return Future.wait(tasks); }

4.2 Future.any:谁先完成用谁

跟wait相对的是Future.any,它返回第一个完成的结果,其他Future的结果会被忽略。这个在某些“抢最快可用节点”的场景里很好用,比如你有多个数据源,哪个先返回就用哪个:

final fastestData = await Future.any([ fetchFromCache(), fetchFromNetwork(), ]);

但要注意,Future.any只保证返回第一个完成的结果,不保证其他任务被取消。那些“落选”的Future还在继续跑,如果会产生副作用(比如写文件、上报日志),要事先想清楚能不能接受。

4.3 串行与并行的选择不是拍脑袋

有些操作必须串行。比如你要先拿 token,再带 token 去请求数据,这两步有明确的依赖关系,只能一层层await。但有些操作看似串行,实际可以并行。我见过有人把三个互不依赖的请求写成三次await,结果每一个都等着上一个完成,白白的两倍耗时。判别标准很简单:如果请求 B 的入参不依赖请求 A 的结果,就把它们并行。

如果想进一步控制并发数量(比如同时最多只有 3 个请求在飞),那就需要自己写个简单的信号量。Dart 标准库没有直接提供FuturePool,但可以自己用计数器 +Completer实现。Completer是个手动控制完成时机的工具,后面会讲到。

5. 定时器、延迟与手动控制:延迟执行和 Completer

5.1 Future.delayed:让任务延后再跑

Future.delayed是写模拟场景、定时轮询、做动画时序控制时最常用的函数之一:

await Future.delayed(Duration(milliseconds: 500));

注意这里的await会把当前函数挂起 500 毫秒,但不会阻塞其他任务。如果想在延迟后拿到结果:

final result = await Future.delayed( Duration(seconds: 2), () => '延迟 2 秒后的数据', );

第二个参数返回什么,Future.delayed返回的Future最终就是什么。一个容易忽略的点是:Future.delayed只是“延迟了把这个任务放到事件队列”的时间,而不是精确到毫秒的定时。如果前面有非常耗时的同步任务卡住了事件循环,延迟会被进一步推后。所以别拿它当精确计时器用。

5.2 Completer:手动控制 Future 的完成时机

Completer是个特殊工具,它本身不是Future,但能创造一个由你手动决定何时完成的Future:

Completer<String> completer = Completer<String>(); void main() { final future = completer.future; future.then((value) { print('完成: $value'); }); // 模拟某个外部事件 Timer(Duration(seconds: 1), () { completer.complete('手动完成'); }); }

这种方式适合对接回调风格的第三方 API。比如某些老式库只提供回调函数,不返回Future,你就可以在外面包一层Completer,在回调里调用complete。同样,如果回调里拿到的是错误,就调用completer.completeError(error)。

使用Completer要注意:一个Completer只能complete一次。第二次调用会直接抛异常。这一点在设计重试逻辑时很容易踩雷,每次重试都应该新建一个Completer,而不是复用同一个。

6. async* 和 Stream(Future 的进阶扩展)

6.1 从单个结果到多个结果

Future代表的是一个“未来会到达的一个值”,如果未来会有多个值,比如进度条、WebSocket 推送、按键事件流,就需要用到Stream。Stream可以理解成异步的“数据管道”,数据像水流一样,一个一个流过来,你可以监听并逐个处理。

Dart 里创建一个简单的Stream可以直接写:

Stream<int> countStream(int max) async* { for (int i = 1; i <= max; i++) { yield i; } }

注意关键字是async*和yield,yield会把一个值“吐”到流里,函数的类型从Future变成Stream。监听方式:

await for (final value in countStream(5)) { print(value); }

6.2 Future 和 Stream 在选择上的平衡

什么时候用Future,什么时候用Stream,判断标准不复杂——只要是一次性结果,就是Future;只要结果可能多次到达,或者你要持续监听,就是Stream。

我在实际做模拟项目时,下载进度这类场景就明显适合Stream,因为进度会有很多次回调;而下载完成后的文件路径,就是一个Future。还有一点经验:如果只是为了在一串数据上做异步转换,可以用Stream.map、Stream.where等操作符;但这些操作符链和Future链一样,别写太长,太长了可读性会下降,拆成几个有语义的变量反而更好维护。

7. 常见问题与排查技巧实录

7.1 Unhandled Exception 是怎么回事

用Future最常见的报错就是Unhandled Exception。出现的原因是某个Future内部抛了异常,但整条链路上没有任何一个catchError或者try-catch去接。Dart 不像 Java 那样有强制检查异常机制,所以很容易漏。

排查方法很简单:找出那个没被捕获的Future,在链尾加上.catchError((e) { ... }),或者把对应的await包进try-catch。如果你是在写框架类代码,建议在最外层边界统一捕获,并打上日志,千万别让异常静默吞掉——否则线上出问题,你都不知道是哪一层吞了错误。

7.2 then 链里忘记 return,值是 null

这是一个非常隐蔽的坑。看下面的代码:

fetchUser().then((user) { fetchOrders(user.id); // 忘了 return }).then((orders) { print(orders); // 这里是 null });

第二个then拿到的orders是null,因为第一个then没有返回值,默认返回null,而内部那个fetchOrders的Future根本没有被return出去。这个问题的排查方式就是看每个then的箭头函数或花括号函数里有没有显式return。我写链式调用时,凡是函数体内有多行代码的,一律用花括号 + 显式return,一眼就能看出有没有漏。

7.3 await 后置导致顺序错乱

async函数里如果没有await,那这个函数的行为跟普通同步函数完全一致,你加上async只会让它被包装进Future里。某些场景下你写完async但里面全是同步代码,调用方却要用.then才能拿结果,这就不合理了。正确做法是:没有异步操作的函数,不要乱加async。

反过来还有个情况,就是“以为 await 会等,结果代码提前走了”。比如:

void main() async { await Future.delayed(Duration(seconds: 1)); print('延迟完成'); } print('外面的输出');

如果你的print('外面的输出')写在main外面(实际上不可能),但如果在main里写在await之后,它就不会提前走。真正容易错的是,你在一个非async函数里调用了async函数,但没有await它,比如:

void helper() { fetchData(); // 没有 await,立即返回,逻辑继续往下走 }

这会让你误以为helper会等待数据完成,实际执行顺序乱得一塌糊涂。解决方式是把helper也改成async并await fetchData(),或者用.then保证顺序。

7.4 microtask 与事件队列的执行顺序导致逻辑 bug

为了验证自己对 Future 的理解,可以跑一下这段顺序测试:

void main() { print('1'); Future(() => print('2')); Future.microtask(() => print('3')); Future.delayed(Duration.zero, () => print('4')); print('5'); }

打印顺序是1、5、3、2、4。Future.microtask进入微任务队列,比Future(() {})(走事件队列)更早被执行;Future.delayed即使延迟是零,也要等事件循环从事件队列里取出来,所以排在最后。这个顺序在工作中直接影响你写的代码是否按预期顺序执行,尤其是涉及全局状态变更时,顺序不一样,结果就天差地别。

7.5 排查 Future 问题的小技巧

如果你实在定位不了问题,我的习惯是三步走。第一步,简化:把链路上的.then和.catchError注释掉,只留最外层,看能不能正常拿到数据。第二步,加日志:每个then和catchError里都加一句print,标注当前阶段。第三步,用超时兜底:在关键位置加.timeout(),防止某些Future永远不完成导致程序挂起。

.timeout是Future自带的超时方法,非常实用:

final data = await fetchData().timeout(Duration(seconds: 3), onTimeout: () { return '超时默认值'; });

这样即使网络请求卡死,程序也不会永远等下去。我在写模拟接口时经常给所有网络请求统一加上超时,省下了无数排查时间。

8. 性能细节与最佳实践

8.1 不要滥用 async 和 await

加async不必花钱,但会在函数返回时多一层Future包装。如果一个函数内部没有真正的异步操作,强行加async会让调用方觉得它在做异步事情,带来误导和性能损耗。代码整洁意义上,只给真正有异步行为的函数加async。

8.2 大量并行任务要控制并发数

Future.wait虽然好用,但你一次性丢上百个请求进去,事件循环和服务器都可能崩溃。我自己的经验是:并发数控制在 5 到 10 个比较稳,具体看任务耗时和服务器能力。手动控制可以用Semaphore思路,或者直接分批处理:

Future<void> processInBatches<T>(List<T> items, int batchSize, Future<void> Function(T) action) async { for (int i = 0; i < items.length; i += batchSize) { final batch = items.sublist(i, i + batchSize); await Future.wait(batch.map(action)); } }

分批处理也方便打日志、做进度上报。

8.3 空安全与 Future

Dart 的空安全机制对Future同样生效。比如Future<String?>表示这个Future可能返回 null,await出来的值是String?类型,用之前最好判空。最常见的坑是把Future<String>直接当成String来用,或者把空安全类型和普通String混在一起拼接,编辑器会直接标错。

如果项目是从迁移前的老代码升级到空安全的,检查所有Future相关代码尤其重要。老代码里可能有很多.then((data) { ... })而data本身是 nullable 的,升级后直接拿出来用会被编译器拒绝,你需要在回调里做判空或提供默认值。

8.4 Future 之外的思考:什么时候上 Isolate

再强调一次,Future不解决 CPU 密集计算问题。如果你的异步操作里跑的是大量循环计算、图片处理、JSON 大对象解析,即使套上Future,事件循环照样被卡住。这时就要考虑Isolate,Dart 里compute函数就是最简入口:

final result = await compute(heavyComputeTask, data);

compute会把这个任务丢到另一个 isolate 里执行,不抢占主 isolate 的事件循环,所以界面保持流畅。但compute也有代价:每次调用都要创建/销毁 isolate,有额外开销。如果你的任务是持续性的,最好自己维护一个常驻 isolate。

个人实操里的一点体会

这篇笔记写到这里,最想强调的还是“实践出真知”。我用 Dart 写过模拟接口请求、数据处理管道、下载进度展示,前前后后栽过不少跟头,尤其是Future.wait的异常传染和.then链的返回类型问题,几乎每个项目都要碰一遍。但等你真正理解事件循环的调度顺序,把Future的链式调用和async/await语法糖在脑子里自动“翻译”成队列操作,写 Dart 的流畅度会明显上个大台阶。

如果接着往下学,建议下一步直接看Stream和Isolate,这两个是Future在“多值异步”和“真并行”方向上的自然延伸。理解Future是地基,地基打牢了,上面的东西都不难搭。个人经验是找几个真实的小场景(比如接口请求加超时、下载进度通知、并发数据聚合)反复练习,比自己干背语法记得牢得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询