鸿蒙Next全面铺开之后,Flutter应用往鸿蒙迁移已经不是什么新鲜话题了。但真正把旧项目跑起来之后,最先让人头疼的不是UI渲染,也不是路由适配,而是异常捕获。原来在Android和iOS上用了几年的错误处理方案,搬上鸿蒙之后,要么拿不到可靠的堆栈,要么根本捕获不到ArkTS层的异常,线上出了问题只能靠用户反馈猜。我们团队就是在这样的背景下,把Flutter端的错误处理库anyhow做了一轮完整的鸿蒙适配,借这个库的链式捕捉模式,把业务异常和上下文信息串成一条完整的链路,现在看这个决定确实值。
anyhow这个名字是从Rust生态借来的思路。Flutter原生有一套try/catch和ErrorWidget,但面对复杂业务时,这些机制给的信息太碎片化,只能告诉你"哪里崩了",很难告诉你"用户当时在干什么、经过了哪些模块、关键参数是什么"。anyhow要解决的正是这个问题:每次捕获异常时,自动把当前模块的上下文信息挂到异常上,形成一条可以完整回溯的调用链。下面我把整个适配过程和踩过的坑完整讲一遍,内容包括框架设计思路、ArkTS与Dart的异常桥接方式、并发场景下的追踪断裂问题,以及上线后如何把这些错误信息变成监控平台里可直接阅读的报告。
1. 为什么说Flutter的try/catch在业务异常面前不够用
1.1 原生异常信息的残缺程度远超你的预期
先从一个最常见的问题说起。Dart的try/catch捕获到异常后,我们能拿到什么?一个异常对象,一个可选的StackTrace,仅此而已。问题在于,StackTrace在release模式下经常是空的,或者经过混淆之后行号完全对不上。我见过线上日志里堆栈显示#0 _compactString (dart:core)这种情况,排查了半天发现是框架内部调用,跟业务代码没有直接关系。这种堆栈对业务异常的定位几乎没有帮助。
更麻烦的是业务异常不像崩溃那样有明确的"挂掉"特征。用户点击登录按钮,接口返回了一个业务错误码,你抛了一个LoginException出去,catch住之后能看到的只有"用户名或密码错误"这样一条message。但这条message是谁产生的?是登录页直接调接口产生的,还是经过了一个统一认证网关、中间还经历了刷新token、重试、轮询这些环节?这些信息,原生try/catch一概不提供。
1.2 鸿蒙化之后问题只会更复杂
鸿蒙Next的Flutter运行方案和Android/iOS完全不同。Dart代码跑在Flutter引擎里,而业务真正依赖的很多系统能力,比如网络状态、账户信息、推送通道,都来自ArkTS层的鸿蒙SDK。这两个层面的异常是两套体系:
- Dart层异常:Flutter框架自带的try/catch可以处理,但信息残缺。
- ArkTS层异常:在鸿蒙侧的代码中抛出,如果做Flutter适配时不显式桥接,Dart侧基本捕获不到,或者只能收到一个笼统的"调用失败"。
我举个例子。我们做原生日志上传功能时,调用了鸿蒙的@ohos.file.fs去读取文件,如果文件被占用会抛一个BusinessError。如果不做桥接,Dart侧catch到的是一个PlatformException,message里只有一句"file is busy",具体是哪个文件、什么操作方式、业务模块是上传还是清理,这些信息全靠猜。后面团队总结出现阶段的一句口头禅:Flutter应用上鸿蒙之后,异常捕获不是从Dart开始的,而是从ArkTS桥接开始的。
1.3 需要的是"带着业务上下文"的错误,而不是裸异常
所以真正的诉求不是"捕获"本身,而是让异常在层层传递的过程中不断累积上下文。这就需要一个链式的、可以逐层附加信息的异常模型。下面是传统try/catch和链式捕捉框架的直观对比:
| 维度 | 原生try/catch | anyhow链式捕捉 |
|---|---|---|
| 堆栈信息 | 行号/函数调用栈 | 业务模块链路 + 函数栈 |
| 业务上下文 | 无 | 每层调用可挂载参数与状态 |
| 跨端桥接 | PlatformException笼统信息 | ArkTS异常字段归一化透传 |
| 链路追踪 | 无法关联用户会话 | 可绑定TraceId追踪完整请求 |
| 可读性 | 开发者要看原始堆栈 | 监控平台直接展示业务语义 |
"带着业务上下文的错误"和"裸异常"之间的区别,在线上排查时是决定性的。前者三分钟能定位问题,后者需要重新拉日志、翻代码、模拟复现,运气不好还要发版本加日志。anyhow的设计目标,就是让每一个异常在被捕获时都自动带上当时发生了什么。
2. anyhow的链式模型:Catch、Context、Trace三级链路设计
2.1 三级模型的基本操作
anyhow框架在Flutter端定义了三级模型:Catch负责捕获原始异常,Context负责逐层挂载业务上下文,Trace负责关联全局唯一的追踪会话。
先看Catch层的基本形态。任何一个可能抛异常的异步方法,都可以用tryCatch包装:
final result = await anyhow.tryCatch( () => authService.login(username, password), onError: (error, ctx) => ctx .tag('operation', 'login') .tag('username', username) .tag('source', 'login_page'), );这段代码的意义在于,当login方法内部任何一个环节出现异常,错误对象都会被传递到onError回调中,此时可以通过Context给异常附加三个标签:操作类型是login、当前用户是谁、发生位置是登录页。哪怕内部的堆栈信息完全丢失,只要这三个标签还在,线上就能定位问题。
再看Context层。实际的业务调用往往是多层的,登录接口内部可能要经过token刷新、请求重试、响应解析等多个环节。每个环节都有自己关心的上下文信息,于是代码会变成这样:
final result = await anyhow.tryCatch( () => loginService.refreshToken(), onError: (error, ctx) => ctx .inherit() // 保留外层上下文 .tag('stage', 'refresh_token') .tag('retry_count', 3), );inherit()这个API是整个链式模型的关键。它会把前面环节已经附加的所有标签和新产生的标签合并,形成一个完整的上下文链。最终用户看到的错误报告,不是冷冰冰的行号堆栈,而是一条带业务过程的描述:
登录失败 └─ 来源: login_page ├─ 用户名: test_user_001 ├─ 阶段: refresh_token ├─ 重试次数: 3 └─ 原始错误: Token已过期且刷新失败2.2 Trace层的设计目标:把同一个用户请求的错误串起来
Trace层的定位和上下文链不太一样。上下文链解决的是"这个异常经历了哪些业务阶段",Trace解决的是"这个用户会话中,不同异常之间有何关联"。在支付场景里,用户可能先遇到一个网络抖动导致的超时,重试后又遇到一个验签失败。这两个异常如果分开看,都是正常的偶发问题;但如果把它们挂到同一个TraceId下,就能发现这是一个连续故障链,是用户会话状态异常导致的连锁反应。
Trace的典型用法是这样的:
final traceId = anyhow.beginTrace('user_payment_trace'); await anyhow.tryCatch(() => paymentService.pay(), onError: (error, ctx) => ctx .inheritTrace(traceId) // 关联用户支付会话 .tag('stage', 'pay'), ); await anyhow.tryCatch(() => paymentService.verify(), onError: (error, ctx) => ctx .inheritTrace(traceId) // 同一会话中的验签 .tag('stage', 'verify'), );2.3 为什么是链式而不是树状
早期设计时团队有人提出过树状结构的方案,也就是用一个异常聚合器把多个子异常挂成一棵树。后来在实践中放弃了,原因是业务异常的发生路径在绝大多数场景下是线性的:一个请求进入系统,经过网关、鉴权、业务处理、持久化这四个阶段,任意阶段失败都会终止流程。树状结构适合表示“一个操作产生多个分支异常”的场景,比如批量任务同时处理几十个文件,每个文件都报错。这种场景我们也有,但极少。
链式模型的优势在于简洁。每次只需要append一个Context节点,时间复杂度O(1),内存占用也可控。更关键的是,链式模型与用户认知一致——一个请求就是一条线,出了问题顺着线去找就行。树状结构在超过两层之后,定位问题的成本反而上升,因为你得先判断哪个分支是主路径。
3. 鸿蒙适配核心:ArkTS与Dart之间的异常桥接与上下文透传
3.1 Flutter在鸿蒙下的运行结构
Flutter应用要在鸿蒙Next上跑起来,目前主流方案是使用OpenHarmony社区的flutter_flutter与flutter_engine分支,通过OpenHarmony的ohos平台通道完成Dart与ArkTS的交互。整个结构大致是:
- Dart层:Flutter业务代码,运行在Dart虚拟机中。
- Platform Channel:通过MethodChannel/EventChannel与原生侧通信。
- ArkTS层:鸿蒙原生代码,承载系统能力调用,如网络、存储、推送。
这套结构和Android/iOS上的Flutter插件机制很像,但有一个本质区别:ArkTS层的异常体系是BusinessError和Error,与Dart的Error/Exception不是同一套体系。所以在适配anyhow时,不能直接把ArkTS的异常塞进MethodChannel的返回值,必须经过一个归一化的过程。
3.2 适配第一步:定制平台通道传递异常
我们在鸿蒙侧为anyhow单独开了一个专属通道,专门用来传递异常信息。没有走常规的业务通道,是因为异常传递和普通方法调用不一样——异常往往发生在方法调用失败后,对应的MethodChannel返回值已经是一个error状态了,如果再往业务通道里去塞异常详情,既耦合啰嗦又容易丢数据。
鸿蒙侧的适配代码大致是这样:
// ArkTS侧:捕获任意业务异常并转成可序列化结构 let errorMap = new Map<string, Object>(); try { const result = await this.fileService.readFile(path); return result; } catch (err) { let businessError = err as BusinessError; errorMap.set('code', businessError.code); errorMap.set('message', businessError.message); errorMap.set('stack', businessError.stack?.toString() ?? ''); errorMap.set('module', 'file_service'); throw new Error(JSON.stringify(errorMap)); }注意最后一步,我们用throw new Error(JSON.stringify(errorMap))。这里必须把结构化错误对象序列化成字符串再抛出,因为MethodChannel的标准错误传输只支持字符串message和可选的details。如果直接把BusinessError对象返回,Dart侧只能拿到一个字符串化的异常信息,字段结构无法保证。
3.3 Dart侧解析归一化错误
Dart侧的解析逻辑,对应地要把ArkTS传来的JSON字符串还原成结构化的错误内容,并塞进Catch里继续走链式上下文:
const MethodChannel _nativeErrorChannel = MethodChannel('flutter/anyhow/native_error'); @override Future<dynamic> forwardNativeError(Map<String, dynamic> nativeError) async { final code = nativeError['code']?.toString() ?? 'UNKNOWN'; final message = nativeError['message']?.toString() ?? ''; final stack = nativeError['stack']?.toString() ?? ''; final module = nativeError['module']?.toString() ?? ''; final error = NativeError( code: code, message: message, stack: stack, module: module, ); return anyhow.report(error); // 进入上下文链 }这里有一个容易踩的坑:ArkTS里BusinessError.stack通常在测试环境是完整的,但在release包中可能是一段经过压缩的代码映射信息,甚至可能为空。我们不能把堆栈作为唯一判断依据,必须把code和message作为主定位信息,stack只做辅助参考。
3.4 上下文透传最容易翻车的两个细节
第一个是序列化格式的不一致。ArkTS的JSON.stringify输出的Map结构,键值对类型必须与Dart侧解析时保持一致。比如businessError.code在ArkTS里是number,Dart侧一解析就拿到int;但message可能包含中文,如果不统一编码,跨通道传输后中文会乱码。我们的做法是:在ArkTS侧统一把异常字段转成字符串再传,Dart侧全部按字符串接收后自行转型。
第二个是上下文链的截断问题。一个复杂的业务请求,理论上可以挂几十个上下文标签。但如果某个标签是一个长文本(比如接口返回的完整报文),整个错误信息会变得极其臃肿。线上日志系统普遍有单条日志长度上限,超长会被直接截断,结果就是最关键的头部信息反而丢了。anyhow在鸿蒙适配时专门加了一个maxContextLength参数,默认单条错误链总长度不超过4096字符,超长部分会在序列化时省略并追加一个"truncated"标记。
3.5 时序问题:异步回调里的异常容易整个丢失
鸿蒙适配中我们遇到的最严重的问题是异步回调异常丢失。场景是这样的:Dart侧调用ArkTS的一个方法,这个方法内部发起了一个异步任务,结果没有用await等待,而是用了回调方式返回。当异步任务失败时,回调里抛出的异常根本不会被Dart侧的tryCatch捕获到,因为整个错误没有被传回Dart侧,而是在ArkTS层的异步上下文里被吞掉了。
这个问题排查了很久,最后的解决方案是:凡是跨鸿蒙的异步方法,一律强制走单向事件通道上报异常。
// ArkTS侧异步回调失败 asyncCall() .then((result) => { // 成功处理 }) .catch((err: BusinessError) => { // 将异常信息通过事件通道上报 this.eventEmitter.emit('nativeError', { code: err.code, message: err.message, }); });// Dart侧监听事件通道 EventChannel('flutter/anyhow/native_error_event') .receiveBroadcastStream() .listen((event) { final errorData = Map<String, dynamic>.from(event as Map); anyhow.report(NativeError.fromMap(errorData)); });这样设计之后,异步异常就永远不会丢失了,只是上报时机可能略有延迟。但监控告警多几秒延迟完全可以接受,关键是不能丢。
4. 实测中的三个硬坑:并发追踪断裂、泛型擦除、性能损耗
4.1 坑一:并发异步任务同时失败时traceId被覆盖
4.1.1 现象
上线之后第一次出现的问题是:监控平台上同一个TraceId下出现了两个不相干的错误。一个是用户在小程序端授权失败,另一个是用户在设置页改了头像上传失败。这两个操作在逻辑上完全不相关,但错误报告的TraceId是同一个。
4.1.2 排查过程
最初的猜测是TraceId生成重复了,查了生成逻辑发现用的是UUID加时间戳,理论上碰撞概率极低。后来重新梳理了代码,发现问题不在ID生成,而在全局单例状态被并发覆盖:
anyhow最初版本的Trace绑定是用一个全局变量_currentTraceId来维护的。Dart是单线程事件循环模型,但异步操作会让多个任务交错执行:用户授权失败的任务A还没结束,设置页上传的任务B就开始执行了,两个任务在同一事件循环里交替推进。任务A设置_currentTraceId = 'A',任务B紧接着设置_currentTraceId = 'B',当A的任务真正失败上报时,读取到的是B的traceId。
4.1.3 修复方案
不能用全局变量,必须让上下文链成为异步任务自带的状态。Dart的Zone提供了zone-local静态变量能力,每个异步任务都在自己的Zone里运行,相互之间不干扰。
final zone = Zone.current.fork( zoneValues: { traceKey: traceId, }, ); await zone.run(() { return anyhow.tryCatch( () => repository.fetchData(), onError: (error, ctx) => ctx .tag('trace', Zone.current[traceKey]), ); });改成Zone方案后,并发错误关联的TraceId严格按任务隔离,再没出现过跨任务串号的情况。这个排查得到的经验是:做链式上下文追踪,一定要把状态绑定到异步任务的执行环境上,不能依赖任何全局单例。
4.2 坑二:Dart泛型擦除导致Catch拿不到原始异常类型
anyhow提供了catchTyped<T>方法,希望按异常类型做精细化处理,比如网络错误统一走重试逻辑,业务错误统一走提示逻辑。但实测发现,catchTyped<NetworkException>在鸿蒙的release模式下经常匹配不上,走不到预期的重试分支,而是直接落到兜底错误处理里。
问题出在Dart泛型擦除。catchTyped<T>的T在运行时会被擦除,无法用error is T精确判断类型。在debug模式还勉强能工作,因为VM保留了部分类型信息,但release模式AOT编译之后就不稳定了。
修复方案是放弃泛型,改用注册函数闭包。用闭包捕获期望类型,在需要判断时通过闭包调用完成类型安全:
// 做法1:注册handler时绑定关键类型 final snapshot = catchSpec; final bool Function(Object) typeChecker = (e) => e is NetworkException; await anyhow.tryCatch( () => client.post(url), onError: (error, ctx) { if (typeChecker(error)) { ctx.tag('type', 'network_exception'); // 走网络错误重试逻辑 } }, );顺带说一句,这个问题在Android和iOS上其实也会发生,只是因为鸿蒙适配时大家对release模式的验证更仔细才先暴露出来。建议框架性的类型判断一律用闭包,不要依赖泛型。
4.3 坑三:上下文挂在对象上导致内存泄漏和额外开销
4.3.1 现象
anyhow第三版之前支持把上下文直接挂到Error对象上,用error.context = {...}这种写法。这个方法在功能上没问题,但在鸿蒙上跑了一段时间后,发现内存占用逐步上升,最终触发了OOM告警。
4.3.2 原因分析
原因有两层。第一层是上下文是强引用持有,一个业务异常如果被全局处理器长时间引用,它挂载的上下文里包含的用户信息、请求参数、业务对象都得不到释放,GC要怎么回收都收不掉。第二层是鸿蒙侧Flutter引擎的内存管理模式与Android不同,对一次又一次追加上下文导致的内存抖动更敏感。
4.3.3 修复:上下文链独立于错误对象存储
做法是让上下文链只保存弱引用关系,核心字段全部转成不可变字符串,错误被标记为"已报告"之后就释放引用。
class _ContextChain { final String tag; final String value; _ContextChain? next; }; class AnywhereContext { final List<_ContextChain> _chain = []; // 不持有异常对象引用,只收集附加信息 } // 错误被report之后,立即释放链对象 void report(CatchError error) { final chain = error.detachContext(); // 上报给监控系统 _uploader.upload(chain); }4.3.4 性能数据
修复前后我们做了对比测试,场景是1000次异常上报,链路深度为5层上下文,结果如下:
| 方案 | 上报耗时 | 峰值内存占用 |
|---|---|---|
| 旧版(上下文挂Error对象) | 平均81ms | 18.5MB |
| 新版(链独立存储+字符串快照) | 平均67ms | 10.2MB |
内存占用下降了接近一半,上报耗时也略有改善。所以后来框架定了一条规矩:任何Context字段都不能持有对象引用,进链之前必须转成可序列化快照。这样不仅省内存,也方便上报系统直接消费。
5. 上线之后怎么用:让业务错误在监控平台里直接可读
5.1 统一错误报告的JSON结构
anyhow这种链式错误处理的真正价值,要等到接上监控平台才能完全体现。我们在后端定义了一套统一的上报格式,鸿蒙端、Android端、iOS端全部对齐同一套结构。
{ "traceId": "c9f5e7a2-3d41-4f0e-9a2b-3e6f8c0d5a72", "timestamp": 1735723640881, "appModule": "user_center", "errorCode": "AUTH_TOKEN_EXPIRED", "message": "刷新token失败,原始错误堆栈暂缺", "contextChain": [ { "tag": "operation", "value": "login" }, { "tag": "username", "value": "test_user_001" }, { "tag": "stage", "value": "refresh_token" }, { "tag": "retry_count", "value": "3" } ], "deviceInfo": { "os": "HarmonyOS", "version": "5.0.0", "engine": "flutter", "sdkVersion": "3.22.0" } }这个结构里最核心的是contextChain数组,每一条都是键值对形式的上下文快照。后端的告警系统接到之后不需要再翻日志,直接按errorCode + contextChain就可以聚合出同一类问题的用户影响面。
5.2 对接监控平台时的做法
我们的上报链路分两条。它自带一条轻量的本地日志通道,同时通过现有的APM插件把数据直接推送云端。轻量通道负责本地调试,APM通道负责上线之后的监控。配置如下:
anyhow.configure( reporter: ApnReporter( projectId: 'flutter_app', apiKey: 'your_api_key', uploadUrl: 'https://apm.example.com/api/v1/error/upload', ), maxContextLength: 4096, filter: (ctx) { // 过滤掉敏感字段 return ctx.without('password').without('access_token'); }, );filter这个参数真的要重视。上下文里tag往往包含用户名、请求参数、订单号甚至是安全性敏感信息,直接全量上报到第三方监控平台有泄漏风险。我们的策略是默认过滤账号密码类字段,业务侧再按需覆盖。
5.3 实践体会:先统一错误码再上框架是错的
我们最开始的时候走了一个弯路。当时团队认为,既然要接框架,最好先把全App的错误码统一整理一遍,错误码规范了之后,框架接上去效果才好。结果错误码整理了将近一个月,各种历史遗留问题层出不穷,框架完全没动。
后来想明白了,应该是先把框架接上,让所有异常先有上下文,再在监控平台上去归纳错误码。有了真实的链路数据和异常分布,哪些错误码该合并、哪些该拆分,数据说了算,根本不需要人工拍脑袋。这个顺序调换之后,整个推进速度快了好几倍。
5.4 后续还能怎么扩展
目前anyhow在鸿蒙端的能力已经够用到生产环境,但还有两个方向可以继续深挖。一个是把Trace从单次请求扩展到完整用户会话,这样用户在一次操作中的多个错误可以串联成一个自然语言可读的"操作失败路径",对客服和产品团队极其友好。另一个是让ArkTS侧的非Flutter模块也接入同一套上报格式,把系统级错误和Flutter业务错误在监控平台合并分析。这两块我们团队正在陆续铺开,后续有进展再单独写文章分享。
最后再分享一个小建议。鸿蒙适配这件事,千万不要一开始就把所有模块都切换到anyhow。先挑一个高频且踩坑多的业务模块,比如登录认证或者支付链路,把这条链路的错误上下文完全打通,让团队看到一次线上问题从"猜半天"变成"打开报告直接定位"的变化。有了这个成功样板,再往其他模块复制会顺畅得多。一口气贪多,反而容易因为新旧两套错误机制混用,把线上问题水搅得更浑。