
如果你维护过一个跑了两年多的 Flutter 业务项目第一次听到要把组件适配到鸿蒙时心里最先跳出来的大概率不是 UI 怎么排而是那些在 Android/iOS 上行之有效的异常处理到鸿蒙上还能不能靠得住。我这边陆续适配过首页信息流、扫码、地图、文件上传等几个模块最直观的感受是跨端边界是异常最容易失真的地方而且失真之后Dart 侧拿到的往往根本不是一个异常而是一个奇怪的空值、一个把原始错误吞掉的通用错误码甚至是一段断成两截的堆栈字符串。这篇文章想分享的就是我们在这个适配过程中沉淀下来的一套组合方案用类型化结果包装Result把鸿蒙桥接层的异常变成数据流再基于错误码分级做重试、熔断、缓存兜底和状态重建最终让组件具备一定程度的逻辑自愈能力。内容偏工程实战适合正在做多端适配、或者已经遇到“调用原生方法后什么都没发生”这类诡异问题的 Flutter 团队参考。1. 鸿蒙适配里最常见的三种“假成功”异常并没有被安全传递1.1 跨语言调用栈被截断Debug 信息直接失去意义Flutter 跑在鸿蒙上业务代码在 Dart 虚拟机里原生能力在鸿蒙的原生侧实现。中间隔着 MethodChannel 的序列化、反序列化、消息路由和结果回调。这一层一旦出错最典型的现象就是Dart 侧捕获到的异常并不是“原生方法抛出来的那个异常”而是“通道在传递异常时包装出来的另一个异常”。比如在 Android 上你抛出一个带错误码的自定义异常Flutter 侧收到的是PlatformExceptioncode 还能对上message 也能看到。换到鸿蒙适配层之后原生侧抛出的异常往往在边界被转成了一段字符串或者直接变成了一个泛化的失败回调原始堆栈已经断掉。你在 Debug 面板里看到的那几行堆栈并不是真正出错的堆栈而是消息回调被拒绝时的堆栈。这种情况下沿用“哪里不对就 try/catch 一把”的习惯会发现 catch 到的信息没有足够线索去定位问题。我一度遇到过这样的场景Harmony 上一个系统服务偶发不可用原生侧捕获到异常后返回给 Dart 侧的是一个 null 值而 null 恰好又是业务层允许出现的合法状态于是页面就走了一个异常分支看起来是“成功完成了”实际上数据根本没回来。1.2 空返回值与类型坍缩是比异常更隐蔽的坑鸿蒙适配初期很多 Flutter 组件直接调用原生方法时完全没有想过原生侧会“不抛异常但返回一个空”。空值本身不可怕可怕的是它在序列化层会被压缩成null然后 Dart 侧再想当然地把null转换成一个强类型对象。我之前遇到的一个真实情况是调用原生扫码能力正常情况下返回一个Map包含二维码内容、类型、时间戳。在鸿蒙适配层没有注册对应实现时通道返回的并不是MissingPluginException而是一个空结果。Dart 侧代码拿到空结果后走了解析分支Map为 null解析器抛NullCheckError这个错误又出现在 UI 线程的某个异步回调里直接导致页面崩掉。这类问题比显式的PlatformException难查得多因为表面上没有任何一个环节告诉你“这个能力没接上”。你只会在线上看到一堆莫名其妙的空指针崩溃却看不到真正的根因。1.3 通道静默失败回调没有结果也没有异常还有一种更恼人的情况通道调用发出去之后既不返回成功也不返回失败而是彻底没有回调。原生侧可能因为在异步初始化过程中某个资源没准备好先把调用忽略了也可能是后台线程异常退出没有走完 result 回调。Dart 侧await一直挂着页面永远停在加载中。这类问题靠 try/catch 根本挡不住因为连异常都没有。你要么给所有通道调用加超时保护要么在架构上把“无回调”定义成一种可观测的结果类型。后续的方案里我们把超时和空回调统一并入了 Result 的失败分支才真正把这类问题管住。2. 从 try/catch 到数据流我为什么在鸿蒙适配中先做结果包装2.1 先写最基础的 Ok/Err再决定要不要换 sealed class结果包装不是什么新概念Kotlin 里有 ResultSwift 里有 ResultDart 生态里也有各种封装。核心思想很简单让函数返回一个“要么成功、要么失败”的容器对象而不是靠异常来传递失败。在鸿蒙适配项目里我最开始并没有直接引入第三方库而是自己写了一个非常小的 Result。因为适配阶段桥接面很杂先用最小实现跑通后面再根据业务反馈迭代才更稳妥。sealed class ResultT { const Result(); } class OkT extends ResultT { final T value; const Ok(this.value); } class ErrT extends ResultT { final Failure failure; const Err(this.failure); }如果你们项目对应的 Flutter 分支还不支持 Dart 3 的 sealed class退一步用抽象类加两个子类完全够用关键是“成功值和失败信息必须同时出现在返回类型里”这个原则。2.2 Failure 里面到底装什么才有诊断价值很多团队写 Result 的时候只放一个 message 字符串这在跨端适配里远远不够。因为 message 很可能是原生侧翻译过的文案原始错误码、原始堆栈、序列化后的 details 都得保留下来才能在后端日志里还原问题。class Failure { final int code; final String message; final Object? raw; final StackTrace? stack; const Failure( this.code, this.message, { this.raw, this.stack, }); }code 必须是稳定定义的错误码不能直接用PlatformException.code返回的字符串。因为同一个字符串在 Android、iOS、鸿蒙上可能完全不同统一映射之后业务层才能写重试逻辑。raw 字段用来装原生侧传过来的原始 details可能是一个 Map、一段 JSON 字符串也可能是一个整数保留它的意义在于如果错误码映射得不准至少还能从原始材料里找线索。2.3 “不抛异常”的收益在鸿蒙多模块适配中被放大在单平台上try/catch 的问题还不算大最多是堆栈浅一点。但在鸿蒙适配中一次业务操作可能横跨 Flutter 组件、通道层、原生侧 SDK、系统服务四个层面任意一层抛异常异常都必须穿透三层边界。穿透一次信息就丢一层。改成 Result 之后异常不再需要“穿透”而是在每一层边界被捕获、转换成错误码然后继续作为正常返回值向后传递。每一层都有机会补充上下文信息也可以根据错误码决定是否直接终止链路。这个收益在调用链越深、参与层越多的时候越明显。3. 桥接层固化MethodChannel 的错误码表、空值守卫与载荷还原3.1 原生侧只做一件事把异常变成结构化 Map鸿蒙适配过程中我最先改造的不是业务代码而是所有自建平台的通道封装。原生侧统一在函数入口处捕获所有异常输出结构化的 Map 结果而不是抛PlatformException。原生侧简化后的返回结构只有两种// 成功 { ok: true, data: ... } // 失败 { ok: false, errorCode: 10005, message: service unavailable, raw: ... }这个约定可以避免很多不必要的争议原生侧不用关心 Flutter 侧怎么解析只管保证任何异常路径都必须返回一个合法的 Map绝不抛出。原生侧什么时候可以抛异常我的建议是永远不要对 Flutter 的调用抛异常因为 Dart 侧解析跨语言异常的成本远高于解析一个 Map。3.2 Dart 侧统一封装 invokeWrappedDart 侧我写了一个统一封装替换了原先所有直接调用invokeMethod的地方。extension MethodChannelResultExt on MethodChannel { FutureResultT invokeWrappedT( String method, { Object? arguments, T Function(Object? json)? decoder, }) async { try { final raw await invokeMethodObject?(method, arguments); if (raw null) { return const Err(Failure( ErrorCodes.nullReturn, native returned null, )); } if (decoder null) { return Ok(raw as T); } return Ok(decoder(raw)); } on PlatformException catch (e) { return Err(Failure( ErrorCodes.fromPlatform(e.code), e.message ?? platform exception, raw: e.details, )); } on MissingPluginException catch (e) { return Err(Failure( ErrorCodes.missingPlugin, e.message ?? missing plugin, )); } catch (e, st) { return Err(Failure( ErrorCodes.unknown, e.toString(), raw: e, stack: st, )); } } }这里最关键的是两点一是先把 null 挡在门外避免解析层再报空指针二是 decoder 参数原生返回的原始数据先经过它转换成强类型对象转换失败也归一到 Err 分支而不是让类型转换异常逃逸出去。3.3 错误码分级哪些可以重试哪些直接放弃有了统一封装下一步就是定义错误码表。我们在项目里用了五位数分码有一个关键动作把错误码按照“是否可以重试”做了分级。这直接影响后续自愈策略的走向。错误码含义是否可重试典型场景10001平台能力未注册否对应插件未适配鸿蒙10002原生超时是网络、相机启动慢10003权限被拒是需先引导定位、相机、文件权限10004参数非法否序列化参数不匹配10005系统服务不可用是服务在启动或恢复中10006数据解析失败否原生返回格式变化“是否可重试”这个信息非常重要因为如果对“权限被拒”这类错误做自动重试用户会看到多次弹窗如果对“参数非法”做重试永远都是无效消耗。错误码分级之后后续的重试器只需要查一张表不需要业务方传一个复杂的规则对象。4. 函数式异常处理的组合子集合map、flatMap、waitAllResult4.1 让业务代码用 fold 收敛分支Result 封装完之后最大的风险是业务代码在每个地方都写 if/else 判断结果比原来更啰嗦。要解决这个问题就必须给 Result 配上几个基础组合子。extension ResultOpsT on ResultT { ResultR mapR(R Function(T value) transform) { return fold( ok: (v) Ok(transform(v)), err: (f) ErrR(f), ); } ResultR flatMapR(ResultR Function(T value) transform) { return fold( ok: transform, err: (f) ErrR(f), ); } T getOrElse(T fallback) { return fold(ok: (v) v, err: (_) fallback); } }有了这些组合子业务代码就能从层层嵌套的 if/else 里走出来。原来你可能要写“先判断第一个调用是否失败再决定要不要发起第二个调用”现在 flatMap 直接把失败链路短路掉链条之后的代码天然不需要处理失败因为失败已经在链条上被携带了。4.2 并行加载多个原生能力的聚合鸿蒙适配里一个页面同时拉取多个原生能力的场景非常多比如首页卡片既要读本机设备信息又要拉账号状态还要读缓存配置。如果复用 Future.wait只要其中一个原生能力失败整个 Future.wait 就会短路这不符合“部分失败也可以展示”的业务诉求。我写了一个简单的waitAllResultFutureResultListT waitAllResultT( IterableFutureResultT tasks, ) async { final settled await Future.wait(tasks); final failures settled.whereTypeErrT().toList(); if (failures.isNotEmpty) { return Err(Failure( ErrorCodes.batchFailed, ${failures.length} sub tasks failed, raw: failures, )); } final values settled .whereTypeOkT() .map((result) result.value) .toList(); return Ok(values); }它的语义是只要有一个子任务失败整体返回失败但失败信息里会带上所有子任务的失败明细不会像 Future.wait 那样直接丢失其余结果。如果业务需要“部分成功也能展示”我建议再扩展一个方法返回一个包含每个子项结果的列表由 UI 层分别展示成功和失败区块。4.3 老组件过渡期如何处理很多存量 Flutter 组件不可能一夜之间全部改成 Result。过渡期的做法是双轨并行新桥接方法全部走 Result老组件继续用 try/catch 把 Result 再转回异常给业务方留一个迁移缓冲期。FutureT unwrapOrThrowT(FutureResultT resultFuture) async { final result await resultFuture; return result.fold( ok: (value) value, err: (failure) throw failure, ); }这个兼容函数的意义在于渠道侧可以先推广 Result不需要一次性推动所有业务代码改造。等新组件上线验证稳定后再从核心链路逐层替换。我测过一段时间的双轨运行只要桥接层统一两边结果是一致的不会出现行为分裂。5. 逻辑自愈重试、熔断、兜底缓存和状态重建5.1 出错重试不能盲目要按错误码分级有了 Result 和错误码分级重试逻辑终于可以做得有依据了。最简单有效的是指数退避重试第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多三次中间只要返回 Ok 或返回错误码不可重试立即停止。FutureResultT retryWithBackoffT( FutureResultT Function() task, { int maxAttempts 3, Duration baseDelay const Duration(seconds: 1), }) async { var attempt 0; while (attempt maxAttempts) { final result await task(); if (result is Ok) { return result; } final failure (result as Err).failure; if (!failure.isRetryable) { return result; } attempt; if (attempt maxAttempts) { return result; } await Future.delayed(baseDelay * attempt); } return task(); }这里注意一点不要在重试期间无脑打印日志。每重试一次就输出一条 WARN 日志用户只要遇到一次网络波动日志量会翻好几倍。更合理的做法是最后一次失败才输出完整日志前几次失败只更新一个计数器。5.2 端侧轻量熔断器避免雪崩重试能解决瞬时故障但害怕的是“系统服务已经彻底不可用”。这个时候如果所有页面都按自己的重试策略疯狂重试原生侧会被打成一团这就是典型的雪崩。端侧不需要把熔断做得太复杂我实现了一个非常轻量的版本连续失败达到阈值后接下来的 N 秒内直接短路所有的调用立刻返回一个不可重试的失败不再真正访问原生侧。class CircuitBreaker { final int failureThreshold; final Duration openTimeout; int _failureCount 0; bool _opened false; DateTime _openedAt DateTime.now(); CircuitBreaker({ this.failureThreshold 3, this.openTimeout const Duration(seconds: 10), }); bool get canAttempt !_opened || _openedAt.add(openTimeout).isBefore(DateTime.now()); void onSuccess() { _failureCount 0; _opened false; } void onFailure() { _failureCount; if (_failureCount failureThreshold) { _opened true; _openedAt DateTime.now(); } } }熔断器必须和错误码分级协同。10002原生超时、10005系统服务不可用这类错误才计入失败计数“权限被拒”这种用户行为导致的失败不应该触发熔断否则会误伤正常的业务功能。我在接入时是把熔断器挂在单个通道的封装层而不是全局共享避免一个模块的故障冻掉所有模块。5.3 本地缓存兜底与状态重建重试和熔断解决的是“调用失败后怎么办”还有一种场景是“调用失败后组件还应该保持可用”。这时候本地缓存兜底就非常有效。我的做法是维护一个内存缓存键是方法名加参数签名值是上次成功返回的强类型结果。class FallbackCacheT { final MapString, T _cache {}; FutureResultT withFallback( FutureResultT Function() task, String cacheKey, ) async { final result await task(); if (result is OkT) { _cache[cacheKey] result.value; return result; } final cached _cache[cacheKey]; if (cached ! null) { return Ok(cached); } return result; } }注意缓存兜底和正常的返回成功要区分开。如果直接返回 Ok业务层会以为这是最新数据可能导致用户在界面上看到过时信息。所以缓存兜底返回的概念应该是“降级成功”我在实战里给 Result 再包了一层isFromCache标记让 UI 层知道可以展示但必须提示“离线数据”。状态重建也是自愈的重要部分。有些组件内部维护了一个状态机异步任务失败后旧状态没有被清理下一个动作又基于错误状态继续执行越走越歪。自愈的关键在于任何异步调用返回 Err 时组件都必须明确告诉自己“我现在处于哪个状态”是初始态、加载态、错误态还是降级态。我倾向于把所有状态都声明为 sealed class强制编译器帮我堵住遗漏的分支。sealed class FeedState { const FeedState(); } class FeedInitial extends FeedState { const FeedInitial(); } class FeedLoading extends FeedState { const FeedLoading(); } class FeedData extends FeedState { final ListObject items; const FeedData(this.items); } class FeedError extends FeedState { final Failure failure; const FeedError(this.failure); }5.4 自愈表现如何看见把 Result 接入埋点逻辑自愈不是写完就完了你还需要知道它是真的在自我修复还是只是把问题掩盖了。我们的做法是给 Result 增加一个统一的观测入口每次返回 Err 时把错误码、是否重试、是否熔断、是否命中缓存兜底这些信息上报。持续观测下来有几个很有价值的指标自愈成功率重试后成功次数 / 总失败次数、熔断触发率、缓存兜底命中率。如果某个接口的缓存兜底命中率居高不下说明真实网络成功率已经很低这个接口应该优先排查根因而不是继续依赖自愈兜底。自愈是用来缓冲问题的不是用来消灭问题的。6. 一次完整改造复盘首页信息流组件从频繁异常到自动恢复6.1 症状诊断没人抛异常但页面频繁卡死这里分享一个具体的改造案例。某个首页信息流组件在适配鸿蒙后线上反馈不断症状非常一致进入页面后偶尔会发生“卡死”既不报错也不崩溃界面停留在加载动画上没有任何按钮响应。初期排查很痛苦因为日志里没有任何异常堆栈。后来我们把组件的外层包了一层runCatching加上链路追踪才发现问题出在一个原生能力调用上组件每次进页面要同时拉取推荐位配置、账号状态和用户画像三个调用并行执行其中一个能力在鸿蒙原生侧偶发回调丢失导致对应的 Future 永远 pending信息流首屏就一直等在这个调用上。异常没有抛出来try/catch 当然接不住。这就是前面提到的“通道静默失败”的典型形态。6.2 分阶段改造顺序这个组件没有被我们推倒重写而是按顺序做了四步改造。第一步把所有并行调用从Future.wait换成waitAllResult同时给每个调用加 5 秒超时。超时返回错误码 10002立刻解决了“永远 pending”的问题。这一步效果立竿见影页面不再无休止卡住。第二步将三个调用统一接入 Result。这一步看起来只是换返回类型实际价值在于日志里终于能看到“哪个子任务失败、错误码是什么、原始 details 是什么”了。定位速度提高了不少。第三步按错误码接入重试和熔断。10002 和 10005 进入重试流程连续失败三次后熔断 10 秒熔断期间直接使用本地缓存展示上次成功数据UI 顶部显示“内容可能不是最新”的降级标识。第四步把组件状态改成 sealed class 的状态机Err 不再被直接吞掉而是统一收敛到 FeedError 状态。用户下拉刷新时FeedError 状态允许再次发起异步调用用户在错误态点击重试时行为也是一致且可预期的。6.3 上线后的测量与踩坑上线两周后测下来的变化信息流首屏卡死率降到接近零原来偶发的一类“莫名白屏”也彻底消失。故障发生时的日志完整度从原来的不到 40% 提升到了 100%每一条失败都能在埋点里看到错误码、重试次数和兜底情况。踩坑也有两处。第一处是超时时间设置最初设成了 3 秒鸿蒙原生侧在启动阶段有时会超过 3 秒才能返回导致超时误报。后来改成“首次 5 秒、重试时 8 秒”两档误报大幅减少。第二处是缓存兜底的数据源最初直接把内存缓存作为唯一兜底App 重启后缓存全丢降级就失效了。后来加了持久化缓存兜底效果才稳定。最后再分享一个小技巧在实际操作中我发现这套方案最忌讳的是“只改桥不还债”。如果桥接层全部换成 Result业务层却还在到处 unwrap 后再转回异常等于白做。理想的节奏是每换一个原生调用就把调用它的核心链路一起改到 Result 风格不要把技术债留到后面。另外建议给所有底层通道封装加一个测试开关模拟返回null、模拟返回超时、模拟返回错误码 10005分别验证组件在对应情况下的状态迁移是否合理。这个开关在后面对接真实鸿蒙设备的各种复杂情况时能帮你省掉大量联调时间。