1. 异步任务编排的痛点与 Completer 的破局思路
做 Flutter 开发的朋友大概率都遇到过这种场景:你有一批任务需要按顺序执行,但每个任务的完成时机并不由你控制——可能是等用户点击确认,可能是等某个第三方 SDK 回调,也可能是等一个原生平台通道返回结果。这时候你手里攥着一个for循环,想让它"走一步、停一步",等外部信号来了再继续下一轮。直接用await会发现根本没有东西可以 await,因为触发完成的那个动作在另一个回调里,跟当前代码不在同一条时间线上。
这就是Completer出场的时刻。它本质上是一个"承诺的遥控器"——你可以把future交给等待方,把complete方法留给触发方,两边解耦,各干各的。配合for循环使用,就能实现一种非常干净的"挂起-等待-唤醒"模式:循环体在await completer.future处暂停,外部逻辑在合适的时机调用completer.complete(),循环继续往下走。
这套模式解决的核心问题是异步流程的主动控制权归属。传统的Future链式调用是"我发起、我等待、我处理结果",控制权在发起方;而Completer模式是"我发起、我挂起、你来决定何时完成",控制权交给了外部事件源。这个反转在很多交互密集、回调驱动的场景里极其关键。
适合读这篇内容的人:已经写过一些 Flutter 异步代码、用过async/await但对Completer还停留在"知道有这么个东西"阶段的开发者;或者正在做需要串行编排异步任务、被回调地狱折磨过的朋友。我会从设计思路讲到实操细节,再到踩坑记录,尽量把每个"为什么这么写"都讲透。
2. Completer 的核心机制与设计取舍
2.1 Completer 到底是什么,跟 Future 什么关系
先把这个概念捋清楚。Future是一个"只读的承诺"——你拿到它,只能等它完成,不能主动让它完成。Completer则是这个承诺的"写端"——它持有一个future属性(读端),同时提供complete()和completeError()方法(写端)。一个Completer对象创建出来的时候,内部的future处于 pending 状态;一旦你调用了complete(value),这个future就变成 fulfilled 状态,所有await它的代码都会被唤醒。
用生活化的类比:Future就像你在餐厅拿到的取餐号,你只能等着叫号;Completer则是后厨手里的那个叫号按钮,厨师做好了按一下,前台就知道该叫你了。取餐号(future)和叫号按钮(complete)是同一个东西的两面。
final completer = Completer<String>(); // 等待方:挂起在这里 final result = await completer.future; print('收到结果: $result'); // 触发方:在某个回调里 completer.complete('任务完成');这段代码里,await completer.future之后的逻辑不会立即执行,而是等到complete被调用后才继续。这就是"挂起"的本质。
2.2 为什么不用 Stream 或回调嵌套
有人会问:这种"等一个信号再继续"的需求,用Stream不行吗?用回调嵌套不行吗?
Stream适合的是"多次事件"的场景,比如数据流、状态变化流。而Completer适合的是"一次性完成"的场景——一个任务完成了就是完成了,不会再来第二次。用Stream处理一次性事件属于杀鸡用牛刀,而且StreamController的关闭时机管理比Completer复杂得多。
回调嵌套的问题更明显。假设你有 5 个任务要串行执行,每个都等外部回调,用嵌套写法就是 5 层回调套在一起,代码往右缩进到屏幕外面去了。而且错误处理、中途取消这些逻辑会变得极其难写。Completer+for循环的组合把嵌套拍平成了线性代码,可读性和可维护性完全不是一个量级。
2.3 for 循环里挂起的执行模型
这里有个容易混淆的点需要说清楚:for循环本身是同步的,但循环体里如果有await,整个循环所在的函数就变成了异步函数,循环会在每个await处暂停,让出执行权,等 future 完成后再从暂停点恢复。
Future<void> runTasks(List<Task> tasks) async { for (final task in tasks) { final completer = Completer<void>(); // 发起任务,把 completer 传出去 startTask(task, onDone: () => completer.complete()); // 挂起,等回调 await completer.future; print('${task.name} 完成,继续下一个'); } print('全部任务完成'); }关键点在于:每次循环都创建一个新的Completer。这一点极其重要,如果你在循环外面创建一个Completer反复用,第二次await的时候会发现 future 已经完成了,直接穿透过去,根本不会挂起。这是新手最容易踩的坑,我在第 4 节会详细展开。
3. 完整实操:从零搭建挂起式任务队列
3.1 场景设定与基础结构
假设我们要做一个"分步确认"的流程:用户需要依次确认三个操作,每次确认后执行对应动作,全部确认完才算结束。确认动作由 UI 按钮触发,跟任务执行逻辑不在同一个调用栈里。这个场景完美契合Completer+for循环的模式。
先定义任务模型和整体骨架:
class StepTask { final String title; final String description; StepTask({required this.title, required this.description}); } class StepRunner { final List<StepTask> steps; StepRunner(this.steps); // 每个步骤对应的 Completer,由外部触发完成 final Map<int, Completer<bool>> _pending = {}; Future<void> run() async { for (var i = 0; i < steps.length; i++) { final step = steps[i]; final completer = Completer<bool>(); _pending[i] = completer; print('等待确认: ${step.title}'); final confirmed = await completer.future; _pending.remove(i); if (!confirmed) { print('用户取消,流程终止'); return; } print('${step.title} 已确认,执行下一步'); } print('所有步骤完成'); } // 外部调用这个方法触发某个步骤的完成 void confirm(int index, bool ok) { final completer = _pending[index]; if (completer != null && !completer.isCompleted) { completer.complete(ok); } } }这段代码里,run()方法在await completer.future处挂起,UI 层拿到StepRunner实例后,在按钮点击事件里调用confirm(index, true),循环就继续往下走。整个流程是线性的、可读的,没有嵌套回调。
3.2 参数传递与返回值处理
Completer的泛型参数决定了future完成时携带的数据类型。用Completer<void>表示只关心"完成了"这个事实,不关心返回值;用Completer<bool>可以传递确认/取消;用Completer<Map<String, dynamic>>可以传递复杂的结果数据。
选择泛型类型的时候有个原则:能明确类型就不要用 dynamic。因为await completer.future拿到的值会直接参与后续逻辑,类型不明确的话,运行时错误很难排查。我见过有人图省事全用Completer<dynamic>,结果在某个分支里complete了一个字符串,另一个分支里complete了一个整数,下游代码直接崩掉。
// 推荐:类型明确 final completer = Completer<ApiResult>(); completer.complete(ApiResult(status: 200, data: {...})); // 不推荐:类型模糊 final completer = Completer(); completer.complete('随便什么');3.3 超时保护与异常兜底
挂起等待最大的风险是"永远等不到"。如果外部回调因为某种原因没触发,await completer.future就会一直挂着,整个流程卡死。所以生产环境里必须加超时保护。
Future<void> runWithTimeout() async { for (var i = 0; i < steps.length; i++) { final completer = Completer<bool>(); _pending[i] = completer; try { final confirmed = await completer.future.timeout( const Duration(seconds: 30), onTimeout: () { print('步骤 ${steps[i].title} 超时'); return false; }, ); if (!confirmed) return; } finally { _pending.remove(i); } } }timeout方法会在指定时间后让 future 以onTimeout的返回值完成。注意这里有个细节:超时后原来的completer其实还处于 pending 状态,如果之后外部又调用了confirm,会触发complete一个已经"被超时接管"的 future,虽然不会报错(因为Completer的complete只能调用一次,第二次会抛异常),但逻辑上已经乱了。所以我在confirm里加了!completer.isCompleted的判断。
提示:
Completer.complete()对同一个 completer 调用两次会抛出StateError。务必在调用前检查isCompleted,或者在设计上保证触发方只会触发一次。
3.4 与 UI 层的对接方式
在 Flutter 里,StepRunner通常由某个StatefulWidget的 State 持有,confirm方法在按钮的onPressed里调用。这里要注意setState的时机——confirm触发后,run()里的循环会继续执行,但 UI 更新需要在setState里做。
class _MyPageState extends State<MyPage> { late final StepRunner _runner; @override void initState() { super.initState(); _runner = StepRunner([ StepTask(title: '确认协议', description: '请阅读并同意'), StepTask(title: '确认支付', description: '请确认金额'), StepTask(title: '确认提交', description: '最后一步'), ]); _runner.run(); } @override Widget build(BuildContext context) { return Column( children: [ ElevatedButton( onPressed: () { setState(() { _runner.confirm(0, true); }); }, child: const Text('确认第一步'), ), // ... 其他按钮 ], ); } }实际项目里,按钮的索引通常跟当前步骤绑定,不会写死。可以用一个currentStep状态变量来跟踪,按钮点击时把currentStep传给confirm。
4. 常见问题与排查技巧实录
4.1 Completer 复用导致的"穿透"问题
这是最高频的坑。看下面这段错误代码:
// 错误示范 final completer = Completer<void>(); for (final task in tasks) { startTask(task, onDone: () => completer.complete()); await completer.future; // 第二次循环直接穿透! }问题在于completer在循环外创建,第一次complete之后,它的future已经完成了。第二次循环await的时候,future 是已完成状态,await立即返回,根本不会挂起。表现就是"第一个任务正常等待,后面的任务全部瞬间跳过"。
修复方法很简单:把Completer的创建移到循环体内,每次迭代都创建新的。
// 正确写法 for (final task in tasks) { final completer = Completer<void>(); startTask(task, onDone: () => completer.complete()); await completer.future; }4.2 忘记处理 completeError 导致异常吞没
Completer除了complete还有completeError。如果外部任务失败了,应该调用completeError而不是complete,否则await方拿到的是一个"成功"的结果,错误信息丢失。
startTask( task, onDone: () => completer.complete(), onError: (e, st) => completer.completeError(e, st), ); try { await completer.future; } catch (e) { print('任务失败: $e'); }如果只调用了complete而没处理错误路径,任务失败时await会一直挂着(因为没人调用complete或completeError),最终触发超时。这种"静默挂起"比直接报错更难排查,因为你看不到任何异常信息。
4.3 在 dispose 之后触发 complete 引发的问题
Flutter 里页面销毁后,如果外部回调还在,可能会在dispose之后调用confirm,进而触发complete。这时候run()里的循环会继续执行,可能访问已经销毁的 State,导致报错。
解决办法是在dispose里主动取消所有 pending 的 completer:
@override void dispose() { for (final completer in _pending.values) { if (!completer.isCompleted) { completer.completeError('页面已销毁'); } } _pending.clear(); super.dispose(); }这样run()里的await会以异常形式结束,配合 try-catch 就能优雅退出。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 循环第一次就穿透 | Completer 在循环外创建 | 检查 Completer 创建位置 | 移到循环体内 |
| 任务失败但无报错 | 只调了 complete 没调 completeError | 检查错误回调路径 | 补上 completeError |
| 流程永久卡死 | 外部回调未触发 | 加日志确认回调是否执行 | 加 timeout 保护 |
| 第二次 complete 抛 StateError | 重复触发 | 检查 isCompleted | 调用前判断状态 |
| dispose 后报错 | 销毁后仍触发 complete | 检查生命周期 | dispose 里清理 pending |
提示:调试这类问题时,在
complete和await前后各打一条日志,能快速定位是"没触发"还是"触发了但没等到"。
5. 进阶玩法与性能考量
5.1 批量并发与串行挂起的混合模式
有时候需求不是纯串行,而是"分批串行、批内并发"。比如 10 个任务分成 3 批,每批内的任务同时执行,等这批全部完成后再进入下一批。这种模式可以用Completer配合Future.wait实现。
Future<void> runBatched(List<List<StepTask>> batches) async { for (final batch in batches) { final completers = batch.map((_) => Completer<void>()).toList(); for (var i = 0; i < batch.length; i++) { startTask(batch[i], onDone: () => completers[i].complete()); } await Future.wait(completers.map((c) => c.future)); print('一批完成,进入下一批'); } }这里每个任务有自己的Completer,Future.wait等所有 future 完成。批与批之间是串行的,批内是并发的。这种模式在批量上传、批量校验等场景很实用。
5.2 用 Completer 实现简易的互斥锁
Completer还能用来做轻量级的互斥控制。比如某个资源同一时间只能被一个任务占用,可以用一个Completer队列来实现排队。
class SimpleLock { Completer<void>? _current; Future<void> acquire() async { while (_current != null) { await _current!.future; } _current = Completer<void>(); } void release() { _current?.complete(); _current = null; } }这个实现比较粗糙,但思路是清晰的:用Completer表示"锁被占用"这个状态,等待方await这个 future,释放方complete它。实际项目里如果需要更完善的锁,建议用现成的包,但理解这个原理对排查并发问题很有帮助。
5.3 内存与生命周期注意事项
每个Completer对象都会持有future和内部的回调链,如果大量创建又不及时释放,会有内存压力。在循环场景里,每次迭代创建的Completer在await完成后就应该被回收。但如果外部还持有引用(比如存在 Map 里没删),就会泄漏。
我的习惯是在await之后立即从管理容器里移除:
final completer = Completer<bool>(); _pending[i] = completer; try { final result = await completer.future; // 处理结果 } finally { _pending.remove(i); // 确保移除 }用finally保证无论正常完成还是异常退出,引用都会被清理。这个习惯能避免很多"看起来莫名其妙"的内存问题。
5.4 与状态管理方案的配合
在大型 Flutter 项目里,StepRunner这类逻辑通常不会直接写在 Widget 里,而是放在 ViewModel 或 Bloc 里。Completer的complete方法可以通过事件流暴露给 UI 层。比如在 Bloc 里:
class StepBloc extends Bloc<StepEvent, StepState> { Completer<bool>? _pendingCompleter; StepBloc() : super(StepInitial()) { on<ConfirmStep>((event, emit) { _pendingCompleter?.complete(event.confirmed); }); } Future<void> runSteps() async { for (final step in steps) { _pendingCompleter = Completer<bool>(); emit(WaitingConfirm(step)); final ok = await _pendingCompleter!.future; if (!ok) break; } } }这样 UI 层只需要 dispatch 事件,不需要直接接触Completer,职责划分更清晰。不过要注意 Bloc 的close时机,确保在 Bloc 销毁前完成所有 pending 的 completer,否则会有类似的泄漏问题。
6. 我踩过的坑与实战心得
说几个文档里不会写、但实际项目里一定会遇到的细节。
第一个是关于Completer.sync的。Completer有个命名构造函数Completer.sync(),它创建的 completer 在调用complete时会同步执行等待方的回调,而不是放到微任务队列里。大多数场景下用默认的异步版本就好,sync版本只在极少数需要严格控制执行顺序的场景下用,用错了会导致难以追踪的时序问题。我建议除非你明确知道为什么要用sync,否则一律用默认的。
第二个是关于await之后的代码执行时机。completer.complete()调用后,await completer.future之后的代码不会立即执行,而是被放到微任务队列里,等当前同步代码执行完才跑。这意味着如果你在complete之后紧接着写了依赖"等待方已恢复"的逻辑,可能会出错。理解这一点对排查时序 bug 很关键。
第三个是错误堆栈的保留。调用completeError的时候,尽量把StackTrace一起传进去,否则下游 catch 到的错误堆栈会丢失原始信息,排查起来很痛苦。
try { await doSomething(); completer.complete(); } catch (e, st) { completer.completeError(e, st); // 带上堆栈 }第四个心得是关于代码可读性的。Completer模式虽然优雅,但如果一个函数里混用了多个Completer和普通await,读起来会有点绕。我的做法是把每个"挂起点"封装成独立的方法,方法名直接体现意图,比如waitForUserConfirm()、waitForSdkCallback(),这样主流程读起来就像自然语言一样顺畅。
这套模式我用在过表单分步提交、原生能力调用编排、游戏关卡流程控制等多个场景,实测下来稳定性很好,代码量比回调嵌套少一半以上。唯一需要警惕的就是前面说的生命周期和超时问题,把这两块处理好,基本不会出幺蛾子。