ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter异步单测秒级提速:fake_async在鸿蒙/OpenHarmony的实战

Flutter异步单测秒级提速:fake_async在鸿蒙/OpenHarmony的实战 做 Flutter 开发这几年我越来越觉得单测是个“真香”工程但异步单测是块硬骨头。前段时间把一个 Flutter 项目往 OpenHarmony 上迁移业务里有不少超时重连、轮询心跳、搜索防抖这类逻辑单测跑起来简直折磨人——一个“30 秒没收到服务端心跳就自动重连”的用例跑一次测试真的得在终端里等 30 秒。全量单测四百多个用例一半时间都耗在 sleep 和等待上。后来我把项目里的异步测试全部切到了 fake_async 这个 Dart 官方维护的纯逻辑库同一套用例从十七八分钟跑进了 3 秒速度快到第一次跑完我以为是测试被 skip 了。这个东西不挑平台纯 Dart 实现在 Flutter for OpenHarmony 的项目里同样能用。这篇文章把我在鸿蒙适配实战里总结的 fake_async 用法、踩坑点和几个高频异步场景的测试写法整理出来给正在被异步单测折磨的 Flutter 开发者做个参考。1. 异步单测为什么慢问题出在事件循环与真实等待1.1 单线程模型下异步代码是怎么排队的Dart 是单线程模型但配合事件循环可以实现非阻塞。你可以把 Dart 的运行环境想象成一个只有一位演员的舞台这位演员同一时间只能演一个节目其他节目都在后台排队。事件循环就是后台的调度员它维护两个队列微任务队列microtask和事件队列event queue。微任务队列Future.then的回调、async/await 的恢复点默认都进微任务队列。微任务的特点是“当前任务结束之后立刻处理”优先级极高。事件队列Timer、Timer.periodic、Socket 事件、平台消息等属于事件队列。事件队列里的任务按到达顺序排队而且每个事件处理完之后会先把微任务队列清空才会处理下一个事件。这套机制让 Dart 写异步代码非常顺手但它也带来一个测试层面的副作用异步逻辑的结果依赖“某个时刻有没有事件到达”。比如你要测试一段“等待 3 秒后执行”的逻辑最朴素的写法就是await Future.delayed(Duration(seconds: 3))。这只做了一件事——把真实世界的 3 秒引入测试。真实时间是不可压缩的这才是所有异步单测慢的根源。1.2 三种“假等待”场景跑一次都是煎熬在我这次鸿蒙项目适配里最耗时的其实就是三类场景第一类是超时控制。业务里有一个Future.timeout(Duration(seconds: 30))用于在请求迟迟不返回时做兜底。要覆盖“真的超时”这个分支测试只能眼睁睁等 30 秒因为只有等真实时间到期Future.timeout才会触发。第二类是轮询重试。一个连接状态检查逻辑每 2 秒跑一次最多重试 5 次。如果按真实时间跑一组用例就是 10 秒而且每次重试之间还有一堆状态断言跑完一遍人都麻了。第三类是心跳检测。这个最痛苦检测周期通常是 30 秒甚至 60 秒想覆盖“连续 N 个周期没收到心跳就重连”的场景一轮下来就是几分钟。如果测试代码里还有大量的await Future.delayed来做等待那问题会叠加。400 个测试用例一半涉及这种等待测试时间是指数级增加的。更难受的是真实等待本身不稳定机器负载高的时候3 秒可能变成 5 秒偶尔回调排队延迟断言还会偶发失败。真实时间引入的抖动会让单测从“确定性验证”变成“概率游戏”。这也是我后来下定决心把这类测试全部切到 fake_async 的原因——不仅仅是为了快更是为了稳定。2. fake_async 如何做到时间快进2.1 Zone 与假时钟把异步代码放进可控时间轴fake_async 是 Dart 官方维护的纯 Dart 包发布在 pub.dev 上核心思想是利用 Dart 的 Zone 机制。Zone 是 Dart 里一个很容易被忽略但非常强大的概念。简单说Zone 是一个“调度边界”每个异步回调都在某个 Zone 里执行同一个 Zone 里的代码共享一套环境配置包括 Timer 的创建方式、微任务的调度方式、异常处理方式等。fake_async 做的事情就是创建一个“假 Zone”在这个 Zone 里把 Timer、延迟调度等全部替换成它自己维护的一套可控队列。用大白话说你在fakeAsync代码块里创建的Timer以及大多数由Timer驱动的异步 future全都被放进一个虚拟的“沙盒时间轴”。这个时间轴上 1 秒是否真的过去不需要操作系统配合只需要调用elapse(Duration(seconds: 1))就能瞬间把时钟拨到目标时间点并触发所有到期的回调。测试再也不需要等待真实时间。这里有个必须注意的坑DateTime.now()不会被 fake_async 自动接管。fake_async 是通过 Zone 提供假的Clock对象来替换时间的如果你的业务代码里到处直接写DateTime.now()这些调用拿到的依然是真实世界的时间。要享受 fake_async 的完整能力业务逻辑里获取当前时间尽量走package:clock的clock.now()或者干脆把时间源抽成可注入的函数。后面心跳检测的例子里我会再演示这个点。2.2 核心 API 速查fake_async 的 API 不算多常用核心 API 如下API作用FakeAsync.run(callback)或顶层函数fakeAsync(callback)创建假 Zone 并执行测试主体async.elapse(Duration duration)把假时钟前进到指定时间点并触发到期的 timer 与 microtaskasync.flushMicrotasks()清空当前微任务队列async.flushTimers()执行当前所有到期的 timersasync.now/async.clock当前假时间 / 假时钟对象async.periodicTimerCount当前存活的周期定时器数量常用于泄漏检查相比于testWidgets里用tester.pump(Duration(...))来快进时间fake_async 的优势在于它不依赖任何 Widget 环境非常适合纯逻辑层、服务层和状态管理的单测。Flutter 的testWidgets内部其实也包了一层类似的假时钟机制但如果你只是测一个定时器、一个超时逻辑、一个状态类用 fake_async 更轻量、更可控。两者不要嵌套混用否则 Zone 会打架行为会很诡异。2.3 elapse 内部到底发生了什么很多人用 fake_async 时最困惑的一点是为什么elapse之后有些东西变了有些东西没变答案是elapse并不是简单地把时钟拨快它是一个“逐轮处理”的过程。一次elapse(duration)做了这么几件事先把假时钟的目标时间设定为当前时间 duration。反复处理微任务队列直到清空当前所有微任务。反复检查事件队列里“到期时间小于等于目标时间”的 timer执行它们每执行一个 timer可能又会产生新的微任务或新的 timer。重复第 2、3 步直到没有更多到期任务或者时间轴到达目标时间点。正因为如此一次elapse往往能“顺带”跑完一串异步链路某个 Timer 到点执行后内部又触发了Future.then这些微任务会被同一轮elapseflush 掉。但如果 Timer 回调里又注册了一个新的 1 秒后的 Timer而你已经 elapse 完了那这个新 Timer 不会自动执行需要再补一记elapse(Duration(seconds: 1))。这个理解特别重要因为排查问题的时候90% 的“elapse 之后没生效”案例本质上都是“新 Timer 还没到它的到期时间”。3. Flutter for OpenHarmony 环境下的接入与设计3.1 先让单测在鸿蒙项目里跑起来Flutter for OpenHarmony 目前的接入方式通常是使用社区维护的 Flutter ohos 分支。基本流程是 clone 对应分支、配置 PATH 环境变量、跑flutter doctor确认 OpenHarmony toolchain 识别正常。不同适配版本的命令细节会有差异这里不展开。关键在于一个很多人没意识到的事实在这套 SDK 下flutter test依然是纯主机运行的不需要连接鸿蒙设备也不需要启动模拟器或者 DevEco Studio。单测的流程是先编译测试代码成 Dart kernel然后在本地 Dart VM 里执行全程不触碰平台层。这跟 iOS、Android 项目的单测是同一个道理。所以 fake_async 在鸿蒙项目里用起来跟普通 Flutter 项目没有任何区别纯 Dart 依赖零平台通道跑起来非常干净。3.2 依赖引入与测试结构在项目的pubspec.yaml的dev_dependencies里加上dev_dependencies: flutter_test: sdk: flutter fake_async: ^1.3.1然后执行flutter pub get。如果你要在业务代码里统一使用可控时间源建议再显式引入dependencies: clock: ^1.1.1测试目录我习惯按业务模块组织异步相关用例集中放一片test/ unit/ timeout_test.dart polling_test.dart debounce_test.dart heartbeat_test.dart运行单个异步测试文件flutter test test/unit/heartbeat_test.dart运行全部单测flutter test test/unit/这一套在 ohos 分支的 Flutter SDK 下是通用的命令行参数和普通 Flutter 项目完全一样。3.3 为什么鸿蒙适配期反而更依赖单测鸿蒙的 Flutter 插件生态还处于发展期很多第三方插件在 ohos 平台上要么有兼容问题要么压根调不通。平台通道MethodChannel也经常不是一次就能跑通的。这种环境下真机集成测试的成本很高反而让单测的重要性凸显出来了。我是怎么处理的呢把平台能力全部抽象成接口。支付、图库、定位、日志上报都定义抽象类业务层只依赖接口测试里传一个 fake 实现。这样一来完全不需要鸿蒙设备就能把“超时控制 重试 心跳 断线重连”这些核心业务逻辑测得很扎实。CI 阶段也只需要一个装好 ohos 分支 Flutter SDK 的 Linux 容器就能跑极其轻量。这也是我特别想强调的一点在鸿蒙适配期单测不是“没钱做集成测试的替代品”而是提前把确定性最高的业务逻辑锁死在测试里把真机测试的时间留给真正需要平台能力的场景。4. 四个高频异步场景的测试代码实战4.1 场景一请求超时控制第一个要解决的就是开头说的痛点接口 5 秒没响应就触发超时。以前测试要等满 5 秒现在用 fake_async 直接拨时钟import dart:async; import package:fake_async/fake_async.dart; import package:flutter_test/flutter_test.dart; FutureString requestWithTimeout({ required FutureString Function() request, Duration timeout const Duration(seconds: 5), void Function()? onTimeout, }) async { try { return await request().timeout(timeout); } on TimeoutException { onTimeout?.call(); rethrow; } } void main() { test(5 秒内没有返回则触发超时回调, () { fakeAsync((async) { var timeoutCalled false; // 永不完成的 Completer模拟服务端卡死 final never CompleterString(); final future requestWithTimeout( request: () never.future, onTimeout: () timeoutCalled true, ); expect(timeoutCalled, isFalse); // 推进到 4 秒还没有到期 async.elapse(const Duration(seconds: 4)); expect(timeoutCalled, isFalse); // 再推进 1 秒到达超时点 async.elapse(const Duration(seconds: 1)); expect(timeoutCalled, isTrue); var caught false; future.catchError((_) { caught true; return ; }); async.flushMicrotasks(); expect(caught, isTrue); }); }); test(5 秒内正常返回则不走超时逻辑, () { fakeAsync((async) { var timeoutCalled false; final future requestWithTimeout( request: () Future.value(ok), onTimeout: () timeoutCalled true, ); // Future.value 的结果走微任务需要 flush 才能断言 async.flushMicrotasks(); expect(timeoutCalled, isFalse); var result; future.then((value) result value); async.flushMicrotasks(); expect(result, ok); }); }); }这里有几个关键点CompleterString()创建了一个永远不会完成的 Future用来模拟服务端无响应。这种方式比 mock 一个异常接口更真实因为它是真的“挂起”了。先推进 4 秒断言没有触发再推进 1 秒断言触发目的是验证边界确保不是在第 4 秒就提前超时也不是完全没触发。第二个用例里Future.value(ok)的结果要经过微任务队列所以先flushMicrotasks()再断言。这个习惯要养成后面很多现场都是这个坑。4.2 场景二轮询重试轮询重试是业务里特别常见的模式每 2 秒检查一次连接状态直到成功或重试满 3 次。这个用例如果真实跑最少也要 6 秒才能跑完三次但我用 fake_async 只需要一次 elapsetest(每 2 秒轮询一次第 3 次重试后成功, () { fakeAsync((async) { var attempts 0; var connected false; Timer.periodic(const Duration(seconds: 2), (timer) { attempts; if (attempts 3) { connected true; timer.cancel(); } }); // 注意这里要推进到 7 秒而不是 5 秒 async.elapse(const Duration(seconds: 7)); expect(attempts, 3); expect(connected, isTrue); // 确认没有残留定时器 expect(async.periodicTimerCount, 0); }); });为什么是 7 秒而不是 5 秒因为Timer.periodic(Duration(seconds: 2))第一次触发在第 2 秒第二次在第 4 秒第三次在第 6 秒。5 秒的时候只发生了两次重试第三次还没到。推进到 7 秒才能保证第三次重试执行完。这种边界计算在真实等待里根本注意不到因为你只会觉得“多等一两秒无所谓”但在 fake_async 里时间轴是精确的你反而要算清楚每个回调到底在哪一秒触发。用例最后断言periodicTimerCount 0用来确认 timer 被 cancel 了没有泄漏。这算是我自己的一个小私心——每次写 Timer.periodic 的测试我都会顺手检查一下没有残留定时器防止后面用例被意外影响。4.3 场景三搜索防抖搜索框的防抖逻辑用户在 300ms 内连续输入只触发最后一次搜索请求。真实测试你很难控制“连续输入”的节奏但 fake_async 可以精确模拟test(300ms 防抖连续输入只触发最后一次, () { fakeAsync((async) { var searchCount 0; Timer? debounce; void onSearchChanged(String keyword) { debounce?.cancel(); debounce Timer(const Duration(milliseconds: 300), () { searchCount; }); } onSearchChanged(a); async.elapse(const Duration(milliseconds: 100)); onSearchChanged(ab); async.elapse(const Duration(milliseconds: 100)); onSearchChanged(abc); async.elapse(const Duration(milliseconds: 100)); expect(searchCount, 0); // 最后一次输入后 300ms搜索才真正触发 async.elapse(const Duration(milliseconds: 300)); expect(searchCount, 1); }); });这个用例最妙的地方在于它精确模拟了“用户在 100ms 内连续打字”的场景。每次输入都 cancel 掉上一个 Timer再重新注册一个 300ms 的 Timer。所以哪怕你前面已经推进了三次 100ms只要还没到 300mssearchCount 始终是 0。只有最后一次输入之后完整走过 300ms搜索才真正触发。如果代码里忘记 cancel 旧 Timer这个测试会直接失败searchCount 会变成 3。这就是单测的价值——防抖逻辑里最容易犯的“漏掉 cancel”错误在真实等待场景里非常难发现但在 fake_async 下分分钟现形。4.4 场景四心跳检测与断线重连这个场景最贴合我这次鸿蒙项目的痛点客户端每 10 秒检查一次距上次服务器心跳的间隔如果超过 30 秒没有心跳就触发重连。为了能让 fake_async 完全接管时间我这里特意演示了时间源注入的写法import dart:async; import package:clock/clock.dart; import package:fake_async/fake_async.dart; import package:flutter_test/flutter_test.dart; class HeartbeatManager { HeartbeatManager({DateTime Function()? now}) : _now now ?? clock.now { _lastHeartbeat _now(); } final DateTime Function() _now; late DateTime _lastHeartbeat; int reconnectCount 0; void start() { Timer.periodic(const Duration(seconds: 10), (_) { if (_now().difference(_lastHeartbeat) const Duration(seconds: 30)) { reconnectCount; // 模拟重连成功重置心跳时间 _lastHeartbeat _now(); } }); } void onHeartbeatReceived() { _lastHeartbeat _now(); } } void main() { test(30 秒没有心跳10 秒一次的巡检会触发重连, () { fakeAsync((async) { final manager HeartbeatManager(now: () async.clock.now()); manager.start(); // 前 20 秒正常收到心跳 async.elapse(const Duration(seconds: 10)); manager.onHeartbeatReceived(); expect(manager.reconnectCount, 0); async.elapse(const Duration(seconds: 10)); manager.onHeartbeatReceived(); expect(manager.reconnectCount, 0); // 连续 40 秒没有心跳超过 30 秒阈值 async.elapse(const Duration(seconds: 40)); expect(manager.reconnectCount, 1); }); }); }这里必须解释一下时间线manager 启动后从 t0 开始计时。第一次巡检在 t10 触发此时距上次心跳t0 初始值正好 10 秒小于 30 秒不重连。但紧接着你调用了onHeartbeatReceived把心跳时间更新成了 t10。第二次巡检在 t20距 t10 只有 10 秒也不重连。之后你不再发心跳t30 巡检时距 t20 只有 10 秒t40 巡检时距 t20 是 20 秒t50 巡检时距 t20 是 30 秒注意条件是“大于 30 秒”30 秒整不算t60 巡检时距 t20 是 40 秒这才真正触发重连。所以推进 40 秒后重连次数是 1。这种精确到秒的时间线推理如果在真实等待环境下你根本不会去算只会傻等。fake_async 逼着我把业务的时间边界彻底想清楚写出来的测试也就更有说服力。5. 常见问题排查与避坑清单5.1 fakeAsync 里发真实网络请求会怎样fake_async 只接管 Timer 类调度不会自动 mock 网络。如果你在fakeAsync里写http.get(...)它会真的尝试发请求。本地环境能通就通不能通就挂在那边测试会一直卡住直到超时。这跟普通 Flutter 项目是一样的但鸿蒙适配中有个额外风险有些插件在 ohos 分支上没法正确初始化一旦触碰平台通道可能直接抛异常。正确做法是把网络层抽象出来注入一个 mock 实现。比如用package:http/testing.dart的MockClient或者更直接一点构造一个永远 pending 的 clientfinal client MockClient( (request) Completerhttp.Response().future, );这个 client 的作用和前面Completer的例子是一个套路让请求挂起然后用elapse去触发超时分支。注意MockClient返回的 future 也是走微任务队列的断言前记得 flush。5.2 elapse 之后还是没断言上补一记 flushMicrotasks这是 fake_async 使用里最高频的翻车现场。很多人写完后发现elapse明明已经推进到超时点了业务变量还是旧值。原因通常是异步链太长。比如 Timer 回调里执行了一个 async 函数这个函数内部有连续的await每个await的恢复都是一个微任务。虽然elapse内部会 flush 微任务但如果你在代码里混用了某些不会自动 flush 的场景——比如 mock 库返回的 future 是在另一个 zone 里完成的——就可能出现“链路没走完”的情况。稳妥的写法是在断言前手动补一记async.elapse(const Duration(seconds: 5)); async.flushMicrotasks(); expect(timeoutCalled, isTrue);不要担心这行是多余的“显式 flush”是 fake_async 推荐的稳健习惯。它不会让你的测试变慢也不会引入额外不确定性只是把“确保 microtask 全部跑完”这件事明明白白写在代码里。5.3 周期 Timer 的无限触发隐患Timer.periodic在 fake_async 的 elapse 推进下有可能被触发很多次。如果你不小心 elapse 了一整年而周期只有 1ms那它会在内部触发 300 多亿次直接把测试进程拖垮。这虽然不是死循环但效果和死循环差不多。所以在测试里尽量精确推进到“刚好覆盖目标次数”的时间点而不是随手写一个大数字。周期性 Timer 在完成使命后要记得 cancel。断言一下async.periodicTimerCount确认没有残留定时器这是一种有效的自我检查。5.4 与 mocktail 搭配的 zone 细节mocktail 本身不感知 fake_async但好消息是 mock 方法返回的 future 也是普通 Dart future会走当前 Zone 的微任务队列fake_async 可以正常 flush。这个组合我一直在用没有遇到兼容性问题。要注意的是Stream类型的 mock。如果你的业务里有Stream.periodic或者 mock 一个事件流流的每个事件都在自己的调度周期里你需要配合 elapse 来控制事件发射而不是靠flushMicrotasks。另外verify调用次数的检查也要放在 elapse 之后否则 Timer 回调还没执行verify 自然看不到调用。5.5 常见问题速查表现象原因解决办法elapse 后业务变量没变化Timer 到期后又注册了新 Timer多推进一次 elapse或改用 while 循环推进到目标状态Future.value 立即断言就失败microtask 还没被 flush断言前先调用async.flushMicrotasks()DateTime.now() 拿到的是真实时间fake_async 只替换 Zone 内的 Clock业务代码用clock.now()或注入时间源周期 Timer 测试卡住elapse 范围太大触发次数过多精确计算周期倍数结束前 cancel网络请求真实发出fake_async 不管 Socket IO注入 MockClient 或永远 pending 的 CompletertestWidgets 里再套 fakeAsync两个 Zone 嵌套冲突纯逻辑用 fake_asyncWidget 测试用tester.pump我在实际项目里还有一个体会引入 fake_async 之后测试代码本身会变得更“较真”。因为时间轴是精确可控的你会主动去计算每个回调的触发时间、每个 Timer 的周期边界这逼着你把业务逻辑的时序彻底想清楚。这种“被测试反向要求”的经历比单纯地跑通用例要有价值得多。鸿蒙适配这条路还很长但先把异步单测的速度和确定性解决了后面跑集成测试的时候会从容很多。
返回列表