1. 先搞清楚Dart的事件循环,Future才能学得明白
1.1 同步代码的阻塞问题
在我自己的学习笔记序列里,前面大部分篇幅都在记语法、类、集合这些"同步世界"的东西,到了Future这一篇,明显感觉Dart开始跟别的语言不太一样了。刚接触异步的人,最容易产生的一个错误直觉是:Future是不是像其它语言的线程一样,在后台并行跑一个任务?还真不是。
先看个最简单的例子。假设你要从本地存储里读一个大文件,再对内容做解析处理:
void main() { final data = loadBigFile(); // 假设要花500ms final result = parseData(data); // 再花300ms print(result); }这段代码在Dart里是串行执行的,如果遇到网络请求、文件IO这种耗时的操作,后面所有代码都得排队等着。在移动端上的体验就是界面直接卡住,用户划不动屏幕。其它语言处理这个问题,通常会引入线程或线程池,把耗时任务丢到别的线程去跑。但Dart不一样:Dart默认跑在一个单线程的isolate里,它没有像Java那种共享内存的多线程模型,处理异步用的是"事件循环 + Future"这套组合拳。
理解这件事的意义在于:你在Dart里不存在"加一把锁保护共享变量"这种思路,所有并发问题的解法其实都围绕"事件什么时候被安排、按什么顺序被执行"展开。把隔离区、消息传递这些概念往后放一放,先搞定事件循环,后面学Stream、学Isolate都不会太吃力。
1.2 事件循环:微任务队列与事件队列
理解Dart异步,只需要抓住两个队列:微任务队列(microtask queue)和事件队列(event queue)。Dart的事件循环会先把当前同步代码执行完,然后反复做一件事:先把微任务队列清空,再从事件队列里取一个事件处理,处理完继续清空微任务队列,如此循环往复。
void main() { print('A'); Future(() => print('B')); // 事件队列 scheduleMicrotask(() => print('C')); // 微任务队列 Future.microtask(() => print('D')); // 微任务队列 print('E'); }猜一下输出顺序。实际输出是:A → E → C → D → B。同步代码A和E先跑完,C和D因为挂在微任务队列里,所以它们会在事件队列里的B之前执行。这个顺序在我第一次跑的时候确实吓了一跳,因为它直接决定了你写Future时的行为预期——不是"后写的先跑"或者"先写的先跑",而是看任务落在哪个队列里。
| 队列 | 放入方式 | 执行时机 | 典型用途 |
|---|---|---|---|
| 微任务队列 | scheduleMicrotask、Future.microtask、async函数内部恢复 | 当前同步代码结束后、事件队列之前 | 需要尽早执行的轻量任务 |
| 事件队列 | Future()构造器、Future.delayed、计时器、IO回调 | 微任务清空之后 | 耗时任务、外部事件 |
为什么要设计两个队列?我的理解是:微任务队列用来保证"当前逻辑的收尾工作"能尽快完成,比如一个Future链上的下一个then;事件队列则留给真正的耗时任务和外部事件。两者严格分优先级,Dart才能让异步代码既高效又可控。
1.3 Future不是线程,它是一个"快递单"
搞清楚了队列,再看Future就清晰了。我把Future理解成一张快递单:你现在没有拿到实际包裹(结果),但你先拿到了这张单子,单子上的物流状态将来会变成"已送达",也可能变成"配送失败"。Dart里这张单子有三种状态:未完成、已完成(带值)、已完成(带错误)。
值得强调的是,Future本身并不做任何耗时计算,它只是把一段计算安排到某个队列里,并承诺将来给你结果。真正干活的是事件循环。这也是很多人学Future时最大的误区:以为用了Future就"异步了",其实你只是把任务交了出去,计算到底什么时候执行、按什么顺序执行,仍然由事件循环说了算。想清楚这一点,后面所有链式调用、错误处理的行为才能解释得通。
打个比方:你去餐厅吃饭,点完菜拿到一张小票,小票本身不能让你吃饱,但它代表"厨房会给你做"。Future就是这张小票,厨房(事件循环)什么时候炒你的菜,取决于排在你前面的订单有多少。
2. 创建Future的五个入口,各管一摊
2.1 Future()构造器:把计算丢给事件队列
最直白的创建方式就是Future(...),传入一个会在将来执行的函数:
Future<String> fetchInfo() { return Future(() { // 这中间可以做耗时的事情 return '模拟拿到的数据'; }); }Future(() {...})会把函数体作为一个事件丢进事件队列,也就是说它不会立即运行,至少要等当前同步代码和微任务都处理完。这里有个容易踩的认知差:函数体里的代码是"将来执行"的,不是"马上执行"的。如果你在构造函数里需要立刻捕获同步异常,那就得换Future.sync了,这一点在讲错误处理时会专门展开。
2.2 Future.delayed:延时任务的正确姿势
定时延时是使用频率特别高的场景,比如轮询、防抖、或者模拟网络延迟,写法是:
Future.delayed(Duration(seconds: 2), () { return '两秒后返回'; });第一个参数是等待时间,第二个参数是要执行的回调。如果只想延时但不需要返回值,也可以省略第二个参数,直接await Future.delayed(Duration(seconds: 1))。这里有个容易被忽略的细节:Future.delayed不是精确计时。如果事件循环当前很忙,回调实际执行时间可能比指定时间更晚。它保证的是"至少等这么久",不是"恰好等这么久"。所以别拿它做需要严格秒级同步的逻辑,比如动画帧同步或者精密倒计时,那会需要更底层的时钟方案。
2.3 Future.value / Future.error / Future.sync / Future.microtask
剩下几种创建方式各有各的用处,我在实际代码里几乎天天遇到。
Future.value(x):立刻创建一个已完成且值为x的Future。典型场景是给函数一个统一的Future返回类型,比如某个接口在缓存命中时直接返回缓存值:
Future<CacheData> getData() { final hit = cache.read(); if (hit != null) { return Future.value(hit); // 已经是结果了,直接包装 } return fetchFromNetwork(); }Future.error(e):立刻创建一个"已完成但带错误"的Future,适合在异步接口里主动上报失败,比如参数校验不过:
Future<User> login(String name, String pwd) { if (name.isEmpty || pwd.isEmpty) { return Future.error(ArgumentError('参数不能为空')); } return doLogin(name, pwd); }Future.sync(fn):立即同步执行fn,然后把结果包装成Future。它和Future(...)最大的区别在于执行时机:sync在调用那一刻就跑函数体,而不是排队。这个特性后面会讲到,它能把同步异常正确包装成Future的错误。
Future.microtask(fn):把fn丢进微任务队列,比事件队列先执行。适合"虽然异步、但要尽快跑"的轻量任务。
快速对比一下:
final a = Future.value(42); // 已完成,值42 final b = Future.error('出错了'); // 已完成,带错误 final c = Future.sync(() { // 函数体立刻执行 return computeSomething(); }); final d = Future.microtask(() { // 进微任务队列,尽快执行 return 'microtask'; });2.4 创建方式选型对照表
我把这几种方式整理成一个选型表,日常写代码对着选就行:
| 创建方式 | 执行时机 | 同步异常捕获 | 适用场景 |
|---|---|---|---|
| Future(fn) | 事件队列 | 异步捕获 | 真正的耗时任务 |
| Future.delayed | 定时事件 | 异步捕获 | 延时、模拟网络 |
| Future.value | 立即完成 | 不适用 | 默认值、统一返回类型 |
| Future.error | 立即失败 | 不适用 | 主动报错 |
| Future.sync(fn) | 立即执行 | 同步捕获 | 纯计算但想统一异步接口 |
| Future.microtask | 微任务队列 | 异步捕获 | 轻量、尽早执行的任务 |
这里要特别提醒一个知识点的关联:在一个async函数体里直接throw,这个错误会被自动包装成Future.error返回给调用方。所以你写同步代码抛异常时,外面用try/catch包住await就能捕获,不会直接把进程带崩。这个机制看起来简单,但它依赖的正是"async函数最终返回Future"这条规则。
3. then链与async/await:两种消费方式,一种执行秩序
3.1 then/catchError/whenComplete的执行链路
拿到Future之后,最基础的消费方式是.then():
Future.value(20) .then((value) => value * 2) .then((value) => value + 1) .then((value) => print(value)); // 输出41每个then都在上一个Future完成后执行,前一个回调的返回值会成为下一个回调的入参。整条链是顺序执行的,并不会并行。值得记住的是:then里的回调本身是作为一个微任务被调度的,所以每经过一个then,代码会回到事件循环的微任务队列,把排在它前面的微任务处理完,再继续往下走。
链上还有两个常用的方法:
catchError:捕获前面任意一环抛出的错误。whenComplete:不管成功还是失败都会执行,类似其它语言的finally。
fetchData() .then((data) => render(data)) .catchError((e) => print('出错了: $e')) .whenComplete(() => loading = false);这里有个细节,很多人以为whenComplete一定要写在最后,其实它写在中间也可以,只是执行时机同样是"前面的Future链处理完以后"。这个方法的定位就是收尾,不管是失败还是成功,该释放的资源、该恢复的状态都放这里。
3.2 async/await:语法糖背后的then
相比链式调用,async/await是我日常写的主力写法,因为它把嵌套回调拉平了:
Future<void> loadPage() async { try { final user = await fetchUser(); final posts = await fetchPosts(user.id); show(posts); } catch (e) { showError(e); } finally { loading = false; } }从原理上讲,await就是then的语法糖:编译器会把"等待结果再继续"的逻辑转成一个回调挂到then上。所以你用链式写法还是await写法,最终的执行秩序是同一套事件循环规则,不存在"await更快"这种说法。但await有个实际好处:错误处理用的是熟悉的try/catch/finally,不用时刻惦记着catchError的返回值类型问题。
我在学习过程中专门测试过一个点:await之后如果抛错,catch能捕获到;但如果根本没加try/catch,错误会变成一个未处理的异步错误,直接交给Zone处理。在Flutter里通常表现为控制台打印一大段未捕获异常,或者调试模式下直接红屏。这个行为跟JavaScript里的unhandled rejection很像,排查起来往往比同步异常更费劲,因为出错现场已经过去了。
3.3 链式写法与await混用时的类型陷阱
如果你把链式写法跟await混着用,要小心catchError的返回类型。看一个实际项目里很常见的坑:
Future<int> fetch() async { throw Exception('网络错误'); } final result = await fetch() .then((v) => v + 1) .catchError((e) => '重试'); // 类型从int变成了String print(result); // result实际是String "重试"这个例子编译可能不报错,但catchError返回的'重试'会直接顶替掉整个链路的值,下游如果还当int处理就会出问题。处理办法是让catchError返回和上游一致的类型,或者干脆在catchError里只记录日志,再重新throw原错误:
final result = await fetch() .then((v) => v + 1) .catchError((Object e) { print('记录日志: $e'); return 0; // 和上游int保持一致 });我把这个教训记在了笔记里:catchError不只是"处理错误的函数",它还是一个参与链路值传递的回调。忽略返回类型,就是在给自己埋雷。
4. Future.wait、Future.any与串行拼接:多任务协作的三种姿势
4.1 Future.wait:并行聚合
业务里最常见的场景是"同时发好几个请求,全部返回后再做后续处理"。用Future.wait可以一次性等待多个Future:
final results = await Future.wait([ fetchUserInfo(), fetchBannerList(), fetchRecommendList(), ]); final userInfo = results[0] as UserInfo; final banners = results[1] as List<BannerItem>;Future.wait传入一个Future列表,返回一个以列表为结果的Future。注意:这几个任务是同时被安排出去的,它们运行在同一个事件循环里,但因为耗时操作通常是IO等待,CPU并没有被阻塞,所以相比逐个等待能明显缩短总耗时。
这里有一个需要精确理解的行为:如果其中任意一个Future抛错,Future.wait会以这个错误作为最终结果失败,但默认情况下它并不会立刻失败——它会先等所有任务都跑完,再把第一个遇到的错误抛出来。这个"等齐再报错"的行为由eagerError参数控制,默认是false。如果你希望某个任务一失败就立刻返回、不要等其它任务,可以显式传eagerError: true:
try { final results = await Future.wait(tasks, eagerError: true); } catch (e) { // 出现第一个错误就立刻走到这里 }另外,即使Future.wait已经以失败告终,其它还没跑完的任务也不会被取消,它们会在后台自行结束。如果你有清理需求,得自己接管那些"没人等的Future"。
4.2 Future.any:谁先回来用谁
竞速场景比并行聚合少见,但一旦遇到就离不了。比如你有两个数据源,想要最快返回的那个:
final first = await Future.any([ requestFromCache(), requestFromNetwork(), ]);Future.any会返回第一个完成的Future的结果,其它Future的结果会被忽略。我实际项目里用它做过一种"本地缓存快速展示,网络数据到了再刷新"的交互:先用Future.any拿一个最快的兜底数据渲染首屏,回头再通过其它方式把网络数据补上。不过要提醒一句,Future.any只关心"谁先完成",所以如果有任务一直卡着不完成,它会一直等下去。这种场景下最好给Future.any组合一个timeout,把最长等待时间也封死。
4.3 动态串行:链式拼接与Future.forEach
并非所有并发都是想要的。假设你要逐个上传一批文件,为了避免同时打满连接,通常选择串行。串行的标准姿势有两种写法。第一种是不断缩小链式调用的"当前Future":
Future<void> uploadAll(List<File> files) async { var current = Future.value(); for (final file in files) { current = current.then((_) => uploadOne(file)); } await current; }第二种是更直观的循环直接await:
for (final file in files) { await uploadOne(file); }两者的执行效果基本等价,都是上一批完成再发起下一批。区别只在于写法:第二种更直观,第一种能在循环中随时插入额外处理,比如打印进度。
另外还有一个Future.forEach,它可以对集合做串行遍历。注意它的执行是逐个串行的,不是并行的,别被名字里的forEach骗了——它和List.forEach的语义本质区别在于带Future的处理要等回调完成才能继续下一个。
5. 错误处理中那些容易"吞掉"异常的坑
5.1 没人消费的错误等于没处理
Future捕获异常的"能力"是有边界的。先看这段代码:
Future<void> run() { return Future(() { throw StateError('运行时抛错'); }); } void main() { run(); print('main结束'); }Future(() ...)内部抛出的StateError会被捕获并转成Future的错误,但你没有接catchError也没有await,于是它就成了一个未处理的异步错误,最终冒泡到Zone层面被打印出来。这个流程本身是正确的,但代价是:你丢失了处理它的机会。所以在生产代码里,每次创建Future后都要想清楚错误消费链路,没有例外。
我见过一种隐蔽的写法:在一个不返回Future的void函数里调用了异步方法,又不加await。这种"发射后不管"在错误处理上等于裸奔,一旦异步方法里抛错,排查时只能靠看控制台日志,还经常定位不到业务上下文。正确的做法是,要么await它,要么在函数内部就把错误消费掉。
5.2 同步代码抛错与Future.sync的关系
如果把耗时计算包装进Future,要注意写法带来的差异:
Future<void> run2() { return Future(() => throw Exception('异步异常')); }Future(...)的函数体是在事件队列里执行的,所以抛错是异步的,能被捕获。可如果你用Future.value(compute())这种形式,compute()是在参数求值阶段同步执行的,此时抛错不会变成Future.error,而是直接冒出来。要解决"把同步计算包装成Future,同时保留同步异常捕获",就得用Future.sync:
Future<int> safeCompute() { return Future.sync(() { // 这里的异常会被包装成 Future.error return riskyCompute(); }); }这个细节我在排查一个偶发崩溃时花了不少时间,最后发现就是因为在Future.value(...)的参数里做了同步计算,异常压根没进入Future的捕获范围。如果你要让一段代码"以Future形式对外暴露",优先用Future.sync而不是Future.value(表达式)。
5.3 catchError的返回值必须匹配下游类型
前面提过一次,这里再展开讲透。catchError回调如果不注意返回类型,很容易制造出新的类型错误:
Future<int> query() async => throw Exception('query失败'); final value = await query() .then((v) => v + 1) .catchError((Object e) { print('记录日志: $e'); // 注意:这里如果直接return null或者不return // 下游拿到的类型就会变成null/dynamic });正确的恢复写法是返回一个同类型兜底值,或在catchError内部继续throw同一个异常,交给更外层的await处理。我自己习惯的策略是:能用try/catch就用try/catch,尽量避免链式catchError,因为catchError的类型匹配太容易被忽略,尤其是在链式调用较长、回调返回类型不明显的场景。
5.4 错误恢复的三条路
实际项目里处理Future错误,我一般按三种情形选择策略:
- 直接失败:catch里记录日志,然后重新throw,把错误交给调用方统一处理。适合业务上需要上层感知错误的场景。
- 兜底值:返回默认值,比如网络请求失败返回空列表,保证UI不至于崩溃。适合"宁可展示旧数据也不能白屏"的场景。
- 重试降级:失败后重试几次,再不行就走缓存或备用数据源。
重试的简单实现可以写成一个通用工具函数:
Future<T> retry<T>(Future<T> Function() task, {int times = 3}) async { for (var i = 0; i < times; i++) { try { return await task(); } catch (e) { if (i == times - 1) rethrow; await Future.delayed(Duration(milliseconds: 200 * (i + 1))); } } throw StateError('不可能走到这里'); }三层策略都做对之后,Future的错误处理才算真正闭环。别指望所有错误都能被"某个全局配置"统一兜住,该在业务层处理的,一定要在业务层处理。
6. 查漏补缺:超时控制、测试技巧与Stream的边界
6.1 给Future加超时:timeout的正确用法
任何Future都可以用.timeout()限定最长等待时间:
final data = await fetchData() .timeout(Duration(seconds: 3), onTimeout: () => defaultData);这里有一个必须知道的坑:timeout超时后并不会取消原来的Future,它只是让等待方先返回结果。原来那个Future会在后台继续跑,完成后进入"无人认领"的状态。如果它最终抛错,仍可能触发未处理错误。所以超时后如果原任务有副作用,比如写日志、清理资源,最好在onTimeout回调里额外处理,或者在上游Future内部自己加上取消逻辑。
6.2 测试Future时怎么跳过真实等待
单元测试里如果直接写await Future.delayed(...),测试会真的等那么久,既不科学也拖慢CI。最常用的办法是把延时通过参数注入,让测试环境可以替换:
Future<String> fetchWithDelay(Duration delay) { return Future.delayed(delay, () => 'result'); } // 测试里传 Duration.zero final result = await fetchWithDelay(Duration.zero); expect(result, 'result');如果是Flutter的widget测试,则可以用tester.pumpAndSettle()推进虚拟时钟,而不是真的等待。测试异步代码的另一个要点是:test函数本身要标记为async,并且在断言前await所有Future,否则测试可能在Future完成之前就结束了。这一点我在早期写测试时吃过不少亏。
6.3 Future与Stream的分界线
学习Future的时候有个无法回避的问题:什么时候该用Stream?我的判断标准很简单:一次性结果用Future,多个持续到来的结果用Stream。比如一次查询、一次登录、一次文件读取,全部是Future;而实时数据推送、进度上报、用户输入事件,这些一直在产生数据,就该用Stream。很多刚入门的人把Future当万能工具用,遇到轮询也硬用Future定时递归,代码很快就绕成一团。记住,Future是单次契约,Stream是持续契约,边界清晰了,代码自然清爽。
6.4 回头再看:学习Future的三点心得
写这篇笔记时我重新翻了不少之前写的异步代码,给自己总结了三条经验。
第一条,先画执行顺序,再写代码。凡是涉及多个Future交互的场景,先在脑子里过一遍事件循环的队列顺序,能避免大量"奇怪执行顺序"的bug。比如你写了一个Future,下一秒又更新UI,结果UI更新反而先执行了,这就是没想清楚微任务和事件队列的顺序。
第二条,错误处理永远比正常流程更值得提前写。Future的错误一旦没人消费,排查起来比同步异常痛得多,因为它不会在出错那一行停下来。我现在的习惯是,任何对外暴露Future的函数,都要在文档注释里写明"什么情况会抛错",并且保证调用方有明确的消费路径。
第三条,不要因为简单就绕过原理。async/await写起来太顺手了,顺手到很多人忘了它背后还是事件循环。真遇到诡异问题时,回到底层队列模型去推演,往往几分钟就能定位。
后面我打算把Stream单独开一篇笔记来写,因为连续数据流的消费、广播、订阅管理等跟Future完全是另一套思维。先把Future这一篇沉淀下来,也算是给异步这块画上一个阶段性的句号。