☰
OpenHarmony真机Flutter应用错误处理与异常管理实战指南
2026/10/10 3:14:16 网站建设 项目流程

在OpenHarmony真机上跑Flutter,最磨人的不是写页面,而是排错。原因很简单:你在模拟器里跑得好好的逻辑,一旦上了真机,摄像头权限、传感器驱动、系统省电策略、通知开关,任何一环出问题,整个App就可能不吭一声地哑掉。我们这个视力保护提醒App的代号是“护眼卫士”,业务很直白——每隔一段时间提醒你休息,检测你是不是离屏幕太近、有没有长时间不眨眼。功能看起来简单,但因为一半逻辑跑在Dart侧,另一半要依赖OpenHarmony的系统能力,中间还隔着一条平台通道,所以错误处理做得粗一点,线上就是一堆“无响应”“没提醒”“闪退”的差评。

这篇文章不聊页面怎么写,也不聊状态管理选型,专门讲错误处理与异常管理。我会从异常体系梳理开始,带着你一步步把全局捕获、业务降级、日志上报、问题排查这几层补全。如果你正准备在OpenHarmony上做Flutter应用,或者你的App也依赖摄像头、传感器、后台定时这类敏感能力,这篇实战记录值得你花十分钟看完。

1. 项目背景与错误处理设计思路

1.1 视力保护提醒App的业务闭环

先把这个App的完整业务链路摆出来,因为不理解业务,就很难理解后面每一个异常处理决策的动机。

护眼卫士的核心功能有四块:

  • 久坐定时提醒:每25分钟弹一次提醒,建议用户远眺休息。这个用Dart侧的Timer就能做基础版本,但要处理App进后台、系统休眠、断网等场景。
  • 距离检测:通过前置摄像头周期性抽帧,估算人脸与屏幕的距离,低于阈值就提醒“离屏幕太近了”。
  • 眨眼频率检测:同样依赖摄像头,统计一段时间内的眨眼次数,如果过低就提示“眨眼频率偏低,记得多眨眼”。
  • 环境光检测:调用光线传感器,环境过暗或过亮时调整提醒策略。

这四块能力里有三块强依赖OpenHarmony的系统服务或硬件驱动。每个能力背后都有一个独立的错误空间:权限被拒、摄像头被其他应用占用、传感器没数据、定时器被系统挂起、通知被用户关闭……任何一个错误没处理好,用户的直观感受就是“这个App失灵了”。

所以我从一开始就给这个项目定了一条铁律:所有依赖系统能力的模块,必须有对应的降级方案。摄像头不可用时,至少保留定时提醒;通知权限被关时,至少用App内弹窗让用户知道当前状态;传感器没数据时,明确提示而不是静默失败。错误处理不是上线前补的,而是产品功能的一部分。

1.2 错误处理在“后台常驻+传感器识别”场景下的特殊难度

普通工具类App的错误处理相对好做,因为大多数操作都是用户主动发起的,出错时弹个toast就行。但视力保护App完全反过来,核心价值在于“后台自动运行、设备状态感知、到点主动提醒”。这类App的错误处理难在哪?我总结为四个字:不可预期。

用户可能正在开会,摄像头权限被系统回收;可能App切到后台后,被系统的省电策略杀了进程;可能前一天还好好的传感器,第二天驱动异常,托管服务没有任何回调。这些问题在开发环境里极难复现,但真实用户一定会踩到。你没法要求用户“重新打开App试试”,那等于承认产品不可靠。

另外,自动驾驶式的容错也有代价。错误处理代码写得太多,反而可能掩盖真正的bug。比如摄像头初始化失败,如果一律降级成“仅定时提醒”,那用户永远不反馈摄像头测距坏了,因为你根本没让他知道。这会导致线上反馈消失,但问题一直存在。后来我调整了策略:能降级的功能必须降级,但降级行为必须被记录、被上报、被用户感知。这样既保证用户体验不中断,又让开发端能及时发现问题。

1.3 适配OpenHarmony时先要搞清楚的几件事

做OpenHarmony的Flutter适配,和Android上写Flutter的体验有一个明显差异:中间层的错误会以更粗糙的方式暴露。在Android上,平台通道奔溃时你还能看到比较清晰的异常信息;在OpenHarmony上,由于底层运行时和Flutter引擎之间的桥接方式不同,部分异常会被包装成笼统的PlatformException,甚至直接表现为一次通道断开。

所以动手写业务前,建议先做三件事:

  1. 把平台通道的通信协议固定下来:方法名、参数类型、返回结构,最好用一个集中的常量文件管理,别在业务代码里到处写字符串。通道名一旦写错,Dart侧不会报编译错,运行时才暴露,排查成本极高。
  2. 确认系统权限动态申请的完整流程:OpenHarmony的权限模型有自己的规则,部分高危权限(摄像头、麦克风等)需要用户动态授权,且授权状态可能在用户主动拒绝后立即改变。要针对“申请中”“已授权”“已拒绝”“永久拒绝”四种状态分别处理。
  3. 提前想好后台运行方案:Dart的Timer在应用进入后台一段时间后会被系统调度策略挂起,这点在OpenHarmony上表现得更明显。要在设计阶段就决定是用系统提供的延迟任务调度,还是用前台服务保活,否则写到一半会发现定时提醒根本不触发。

这三件事想清楚,后面的错误处理框架才有地方落。

2. Flutter异常体系全景与OpenHarmony侧的特殊性

2.1 Dart异常的两大类别:Error与Exception

很多初学者写Flutter错误处理时,习惯把所有异常都catch住,然后打个日志就完事。但Dart的异常体系里有一个关键区分,不搞清楚会埋大坑:Error和Exception是两类不同的东西。

  • Error(比如TypeError、RangeError、FormatException、StateError)表示程序自身的问题,通常是代码bug。这类错误在逻辑上不该被捕获,应该让它在开发期暴露出来,而不是在线上被吞掉。在OpenHarmony真机上,这类错误经常表现为“Dart侧类型不匹配”——比如平台通道返回了一个Map,你强行当成List用,立刻就是运行时Error。

  • Exception(比如TimeoutException、SocketException、PlatformException)表示运行环境的问题,是“可预期”的外部失败。这类异常应该被捕获,并且触发降级、重试、提示等业务逻辑。

我给自己定的处理原则是:catch住Exception,限制住Error。Exception在业务层尽量“接住”,Error只做全局兜底记录,不在业务代码里瞎catch。很多人喜欢写catch (e)把一切包起来,实际上这是在给上线埋雷:真bug被吞了,用户反馈又测不出来,最后只能靠日志一点一点翻。

2.2 异步异常的捕获模型与三道防线

Flutter是单线程事件循环模型,异常不会“跳出”事件循环,而是被投递到当前Zone的handleUncaughtError。所以异步异常的处理方式,和Java、Python那种有线程边界的语言完全不同。

我整理出一个“三道防线”模型,实践下来比较稳:

防线对应机制捕获范围备注
第一道业务代码try-catch / Future.catchError可预期的异步异常主动处理,决定降级还是重试
第二道runZonedGuarded全局Zone未捕获的异步异常兜底,记录日志,防止App直接退出
第三道FlutterError.onError框架层渲染、布局异常单独处理,不影响业务逻辑

第一道防线好理解,第三道也不难——FlutterError.onError专门接管Render阶段抛出的异常,比如布局溢出、widget类型错误。第二道防线是很多人忽略的:main()里的runApp()如果直接跑,所有的异步异常会默认交给Flutter引擎处理,用户看到的就是红屏或者App闪退。用runZonedGuarded包一层,就能在异常丢失前抓下来存日志。

在OpenHarmony上还要多留一个心眼:部分异常发生在Platform调度层,可能不经过Dart侧的Zone。所以不能完全依赖Dart侧捕获,还要在系统侧(ArkTS/NAPI)做一层自己的try-catch,把错误转成标准错误码传回Dart。这块内容后面结合通道实现细说。

2.3 平台通道异常:MethodChannel与EventChannel的坑

Flutter与OpenHarmony原生侧的通信主要有两种方式:MethodChannel(一次性调用的请求-响应模式)和EventChannel(持续的数据流推送模式)。两种通道的异常表现差异很大。

MethodChannel的异常还算友好。Dart侧调用原生方法时,原生侧抛出的异常会被包装成PlatformException传递回来,代码里有code、message、details三个字段。但有个细节很容易踩:原生侧如果不规范地抛出带业务错误码的异常,Dart侧拿到的code会是一串长英文文案,没法直接用于逻辑判断。所以我在项目里约定,原生侧所有错误码必须在代码里严格定义,比如camera_permission_denied、camera_busy、sensor_unavailable,Dart侧根据这些码做分支处理。

EventChannel的坑比MethodChannel多得多。EventChannel本质是一条持续的流,它没有“返回值”,只有事件回调。一旦原生侧在推送过程中崩溃,Dart侧接收到的是流断开事件,而不是一个标准的PlatformException。流断开之后还要不要重连?怎么重连?重连频率多高?这些都是要自己设计的。我实测发现,OpenHarmony上EventChannel流断开后,系统不会自动恢复,必须由Dart侧主动重新创建通道并重新订阅。

还有一类坑和通道本身的生命周期有关:页面销毁了但EventChannel没取消,会导致内存泄漏、回调僵尸、甚至重复订阅。护眼卫士里的眨眼检测流就遇到过这个问题。起初我只是在dispose()里取消了订阅,没有把channel对象本身置空,结果切屏几次后回调被触发了五六次,日志看过去直接崩溃。后面我把“创建流-订阅流-取消流”封装成一个可控的生命周期工具类,彻底解决了这类问题。

2.4 生命周期异常与后台任务的边界

App进入后台后,Flutter引擎不会马上挂起,但系统的调度策略会逐步收紧资源。护眼卫士的“25分钟定时提醒”在后台场景就遇到过诡异的问题:Dart侧Timer看起来还在跑,但实际触发时间比预期晚了十几分钟。

这不是Timer坏掉了,而是系统为了省电,延迟了应用侧代码的执行。正确的做法不是靠Dart侧硬扛,而是把后台提醒的职责交给系统级的调度任务。我们在OpenHarmony侧接到这个需求后,把25分钟式的定时提醒换成了系统延迟任务调度,把任务参数(时长、提醒内容、通知渠道)传给原生侧,由原生侧在系统调度框架里注册。这样即使Flutter引擎被系统完全挂起,提醒依然能准时触发。

但这带来一个新的错误处理点:系统调度任务和Dart侧的状态可能不一致。比如用户在设置里关闭了App的通知权限,系统调度任务依然会运行,但通知弹不出来。这时候Dart侧必须感知“通知不可用”这个错误状态,然后改用App内浮窗或声音提醒。所以生命周期错误处理的重要原则是:不要相信“一个模块正常”就等于“整个App正常”,要时刻校验每个依赖项的可用状态。

3. 视力提醒App错误处理落地实现

3.1 全局异常捕获器搭建

先看最基础的全局捕获。护眼卫士的main.dart入口是这样的:

void main() { final FlutterError oldOnError = FlutterError.onError!; FlutterError.onError = (FlutterErrorDetails details) { FlutterError.presentError(details); _handleError(details.exceptionAsString(), details.stack?.toString() ?? ''); }; runZonedGuarded(() { WidgetsFlutterBinding.ensureInitialized(); runApp(const VisionGuardApp()); }, (Object error, StackTrace stack) { _handleError(error.toString(), stack.toString()); }); } void _handleError(String message, String stack) { AppLogger.error('global', message, stack); ErrorReporter.report(message, stack); }

这段代码同时接管了渲染层异常和未捕获的异步异常。oldOnError保存原有回调是为了不覆盖Flutter框架自身的默认行为,FlutterError.presentError(details)还会把错误在debug模式下显示到红屏上,方便开发期定位。

我用了两个月后发现一个细节:全局捕获只能兜底,不能替代业务层的主动捕获。因为全局捕获拿到的stack往往已经很深,定位成本高。所以在业务代码里,我还是坚持对每一个平台调用做显式的try-catch,全局捕获只是最后一道保险。

3.2 权限异常与功能降级

权限问题是护眼卫士在OpenHarmony上遇到的最高频异常。以摄像头为例,系统权限弹窗用户一旦选了“拒绝”,任何后续的摄像头初始化都会直接抛PlatformException。我们的处理逻辑是这样的:

Future<void> _initializeWithDegradation() async { _monitorState.transition(MonitorState.starting); final permissionOk = await _permissionService.checkCamera(); if (!permissionOk) { _monitorState.transition(MonitorState.degraded); _useTimerOnly(); _showStatusBanner('摄像头权限未开启,距离与眨眼检测不可用'); return; } try { await _cameraService.initialize(); await _blinkStreamService.start(); _monitorState.transition(MonitorState.running); } on PlatformException catch (e) { if (e.code == 'camera_busy') { // 摄像头被其他应用占用,执行指数退避重试 _scheduleRetry( attempt: _retryCount++, onRetry: _initializeWithDegradation, ); } else if (e.code == 'camera_capture_failed') { _monitorState.transition(MonitorState.degraded); _useTimerOnly(); } else { _monitorState.transition(MonitorState.fatal); ErrorReporter.report('camera_init_error', e.code); } } }

注意这里没有写catch (e)一把抓,因为不同的错误码对应不同的恢复策略:

  • camera_busy:摄像头被占用,属于临时状态,可以重试,但要退避,不能每秒钟疯狂拍系统。
  • camera_capture_failed:初始化通过但采集崩溃,说明硬件或驱动有问题,此时重复重试意义不大,直接降级更务实。
  • 未知错误码:这是最危险的,因为不知道哪里出了问题,应该进入fatal状态并上报完整堆栈。

降级不是“糊弄用户”。每次降级我们都会在界面上显示当前保护模式的状态条,让用户明确知道哪些功能可用、哪些不可用。这样用户如果在意距离检测,会主动去开权限;如果不处理,至少不会被“静默失灵”骗过。

3.3 定时器与传感器状态机保护

前面提到过Timer后台挂起问题。除了把后台提醒交给系统调度,我还给整个“监测功能”设计了一个小型状态机,用它统一管理定时器、传感器和摄像头三者的协作关系。

enum MonitorState { idle, starting, running, degraded, fatal } class MonitorManager { MonitorState _state = MonitorState.idle; void transition(MonitorState target) { if (_state == target) return; debugPrint('[MonitorManager] $_state -> $target'); _state = target; _stateListeners.forEach((listener) => listener(target)); } bool get isRunning => _state == MonitorState.running; bool get isDegraded => _state == MonitorState.degraded; }

状态机的价值在于,当多个模块并发出错时,不会出现“摄像头重启了三次、定时器又崩了”这种混乱局面。比如摄像头初始化失败时,我们进入degraded态,此时定时器照跑,眨眼检测流不启动,等重试成功后尝试恢复running态。

还有一个很容易被忽略的点:定时器和传感器需要联动校验。有一次线上反馈“提醒触发但用户没看到”,排查下来发现是通知权限在后台被系统收回,而定时器任务照常触发,只是通知没弹出来。后来我在定时器触发回调里加了一个前置检查:触发前先检查当前系统通知权限、前台应用状态,如果不满足,就改用声音提醒或浮窗提醒。这个设计本质上是把“不满足触发条件”当成一个业务级别的异常来处理。

3.4 通知与前台服务异常的兜底处理

通知权限在OpenHarmony上有自己的规则,用户可以在系统设置里随时关闭某个App的通知。这种状态不像摄像头权限那样有一个“弹窗询问-用户选择”的过程,而是完全由用户主动操作,App可能完全没有感知。

护眼卫士初始版本的做法是:在定时提醒触发时直接调用系统通知接口,如果通知接口抛了异常,就在日志里记录一条然后放弃。后来被用户吐槽“App一点反应也没有”,才把这块补上:

Future<void> _sendReminder() async { try { final enabled = await _notificationService.isNotificationEnabled(); if (!enabled) { _useInAppAlert('提醒被系统拦截:请开启通知权限'); return; } await _notificationService.show(reminderContent); } on PlatformException catch (e) { _useInAppAlert('通知发送失败(${e.code})'); ErrorReporter.report('notification_failed', e.code); } }

这里的关键是:系统权限被关闭的消息,不能只在触发那一刻才处理。护眼卫士会在App启动时、每次页面从后台恢复时主动检查一遍通知状态。如果用户刚关掉通知并切回App,就能立刻看到“通知权限已关闭,请前往设置开启”的提示条,而不是等到25分钟后才发现提醒没弹出来。

另外,前台服务的稳定性也值得关注。某些状态下,系统会要求App以前台服务的方式保持能力,比如“正在进行摄像头测距”。如果前台服务启动失败,后面所有依赖该服务的功能都会出问题。我在项目里把前台服务的启动和停止也纳入状态机管理,服务启动失败就自动降级为纯定时器+App内提醒模式,避免“App页面还在,但后台服务全没了”的半死状态。

4. 错误上报与本地日志设计

4.1 错误分级与脱敏

错误捕获到了,如果不上报、不记录,等于白抓。护眼卫士的上报方案从设计之初就考虑了三个问题:哪些错误要上报?上报的数据里有没有用户隐私?上报失败怎么办?

我先把错误按严重程度分成了四级:

  • debug:开发期调试用,不上报。
  • info:用户主动操作、功能切换,比如通知权限被用户打开/关闭,这类信息对分析用户行为很有用。
  • warn:发生了降级但App还能用,比如摄像头不可用转为纯定时器模式。
  • error:导致功能完全不可用的异常,必须上报,并且要带上完整堆栈。

脱敏是很多人容易忽略的点。日志里如果直接记录完整的文件路径、设备型号、传感器原始数据,一方面有隐私风险,另一方面会产生大量重复、扰乱的噪声。我做的脱敏规则有三条:

  1. 堆栈信息只保留前30行,base64编码存储,避免在日志文件里明文堆栈刷屏。
  2. 不记录用户独有的身份信息,比如设备唯一标识、MAC地址、相册内容。涉及摄像头的日志只记录“帧分辨率、耗时、错误码”,绝不记录图像本身。
  3. 时间戳统一使用设备本地时间的时区偏移量,用于日志排序,但不直接记录用户的精确地理位置。

4.2 本地持久化与滚动覆盖

护眼卫士的日志不是一次性写到云端就完事,而是先落在本地文件里。原因很简单:用户网络不稳定,而且很多错误是在离线状态下发生的(比如在电梯里、地铁里,网络瞬间断开)。本地日志能保证数据不丢失。

我用的方案是轻量级文件存储,没有上数据库,因为日志写入频率不高、数据结构也简单。每条日志是一行JSON,按天切分文件,最多保留7天,超过7天的文件自动删除。核心的写入代码如下:

class AppLogger { static Future<void> write(String level, String tag, String message) async { final entry = jsonEncode({ 't': DateTime.now().toIso8601String(), 'l': level, 'g': tag, 'm': message, }); final file = File('${_logDir.path}/${_todayFileName()}'); await file.writeAsString('$entry\n', mode: FileMode.append); } }

文件写多了以后,我发现一个坑:日志文件不能只在内存里攒够了再写,因为App一旦被系统杀掉,内存里的日志就全丢了。所以我选择了每条日志原子追加写,损失一点性能,换取稳定性。实际跑下来,这个App每天产生的日志量也就几百KB,完全可接受。

4.3 错误上报的合并与重试机制

日志本地存了之后,怎么上传也有讲究。如果每条日志都单独发送一个网络请求,一是浪费资源,二是容易触发服务端限流。我采用了“批量合并上报”策略:

  • 触发条件:本地缓存达到50条,或者距上次上报超过30分钟。
  • 上报内容:把缓存的日志按一个批次打包,压缩后POST到自己的上报接口。
  • 失败处理:上报失败不删除本地日志,下个批次继续带上。连续失败超过3次,停止上报,等网络恢复后由下一次主动上报流程顺带重试。

上报重试有个大坑:如果上报接口本身不稳定,或者服务端解析有问题,会导致日志越积越多,反而挤占宝贵的本地存储空间。所以我给上报任务加了一个“熔断”逻辑,连续失败时就暂停上报,只做本地存储,等用户网络状态恢复后再重新启动上报。这个机制和摄像机Busy的重试逻辑是同一个思路,本质都是指数退避加熔断。

有了这套本地日志+批量上报,后面每次收到用户反馈,我们都能相对准确地还原现场,而不是全靠猜。

5. 常见问题与排查技巧实录

5.1 平台通道偶发超时怎么定位

护眼卫士上线大概第三周,开始陆续收到“提醒有时候没弹”的反馈。看日志发现,有个camera_get_frame_count的MethodChannel调用偶发超时,超时时间设的是2秒,但OpenHarmony原生侧的摄像头能耗优化有时会让调用在3秒后才返回。

排查步骤是这样的:

  1. 在Dart侧给通道调用包统一的超时控制:
Future<T> _channelCall<T>(String method, [arguments]) async { return _methodChannel .invokeMethod<T>(method, arguments) .timeout(const Duration(seconds: 3)); }
  1. 在原生侧同样打印调用耗时,对比数据后发现不是Dart侧假死,而是原生侧确实响应慢。
  2. 把超时时间从2秒放宽到5秒,同时做了一层“超时后用缓存数据兜底”的逻辑。对“取帧数”这种非关键调用,超时后直接返回上次的缓存值,不影响主流程。

这个问题的本质是:平台通道调用的超时策略必须考虑原生侧的真实耗时分布。拍脑袋设一个2秒,不如先在真机上采样50次调用看分布。这是经验层面的大实话:平台通道调用的超时不能设太激进的阈值,尤其不能和UI动画的帧数要求混为一谈。

5.2 定时器在后台休眠后失灵

这个问题前面提过,但在排障实录里再说一遍细节。用户反馈“晚上开着护眼卫士,一小时没收到提醒”,日志显示五分钟内的定时器触发点还有记录,之后就没有了,同时App进程还活着。

定位过程是这样的:

  1. 先看系统日志确认进程没被杀。
  2. 再看Dart侧时间戳,发现最后一次Timer回调离预期时间差了40分钟。
  3. 确认是系统调度把后台执行窗口压缩了。

解决方案是把“25分钟提醒”这类低频率任务,从Dart Timer迁移到系统级延迟任务调度。这个改动看起来技术含量不高,但涉及两侧配合:Dart侧负责计算任务时间、拼装提醒参数,原生侧负责把任务注册到系统调度框架,并且监听调度回调。到今天为止,这类后台提醒的准时率已经和Android侧差不多了。

这里的经验是:不要和不信任的运行时对抗。Dart Timer在后台的不可靠是系统策略决定的,你再怎么调优化参数都改变不了底层逻辑。最靠谱的方式是让对的工具干对的事,把后台提醒交给系统调度。

5.3 通知权限变更导致的崩溃

有一种崩溃是“只在发布版本里出现,debug版本死活复现不了”,护眼卫士也遇到过。现象是:App启动时读取通知状态、尝试初始化通知渠道,但那个时候用户恰好刚在系统设置里关掉了通知权限,代码读取到空值后没做空安全判断,直接崩了。

这类崩溃和平台通道本身没关系,纯粹是状态边界没处理好。后来我把“启动初始化”重构为“先请求状态、再基于状态分派任务”,并且加了一个全局的权限状态通知机制。用户在系统设置里改了权限,回到App时能通过生命周期回调重新同步。

排查这类问题的技巧是:要主动去系统设置里模拟“极端但合理”的用户行为。只在App内部测试流程是做不完的,你得真的开飞行模式、真的关通知权限、真的把摄像头权限改成“仅本次允许”,才能暴露出那些平时不会触发的填空题。

5.4 内存抖动与OOM

护眼卫士的摄像头帧流处理是内存大户。OpenHarmony的摄像头采集回调频率较高,如果Dart侧还在处理上一帧、新的帧又来了,人眼看起来没什么,但内存曲线会一路爬升。

我这个项目的处理方式有三个:

  1. 帧处理器加了背压控制:处理不过来时直接丢弃新帧,而不是无限排队。
  2. EventChannel流订阅结束后,主动取消,并置空channel实例,防止僵尸订阅。
  3. 用Dart DevTools的真实内存采样,在OpenHarmony真机上跑10分钟,观察内存是否有“锯齿状”上升趋势。

排查时遇到过一个有意思的问题:内存曲线看着正常,但连续开关摄像头20次之后,内存还是没有完全回收。后来定位到是原生侧的纹理资源没释放,与Dart侧无关。这提醒我,内存问题的排查不能只看Dart侧,还要检查原生侧的接口实现是否正确释放资源。

5.5 问题排查速查表

现象可能原因排查路径解决方案
平台通道调用偶发超时原生侧响应慢或通道被阻塞两侧分别打点,对比耗时分布设置合理超时阈值,超时后用缓存降级
后台定时提醒延迟或丢失Dart Timer被系统挂起日志中对比目标时间与实际触发时间迁移到系统延迟任务调度
摄像头初始化失败权限被拒或摄像头被占用查看错误码,区分临时态与永久态按错误码分支处理:重试/降级/上报
通知权限被关闭导致无提醒权限状态与App内缓存不同步前后台切换时主动同步权限状态启动和回前台时检查权限,App内提示
内存持续上涨帧流背压缺失或纹理未释放DevTools采样内存曲线,原生侧日志加背压,主动注销通道与纹理资源
全局捕获无日志但用户反馈闪退异常发生在原生侧/调度层检查原生侧日志链路原生侧也要登记错误码,接入Dart日志

6. 测试与回归保障:让错误处理不被后续改动破坏

6.1 异常注入测试怎么设计

错误处理代码写了不少,但如果没有针对性的测试,很容易回归出问题。我的做法是封装一个专门用于测试的Mock插件,在测试环境里把真实的平台通道替换成可控的“故障注入器”。

核心思路是:不要等到真实手机上摄像头坏了才发现降级逻辑不对,而是主动让平台通道返回各种异常,验证App在异常状态下不会崩溃。

class FailureInjector { static Map<String, Exception> plannedFailures = {}; static Future<Object?> mockCall(MethodCall call) async { final planned = plannedFailures[call.method]; if (planned != null) { if (planned is PlatformException) { throw planned; } throw planned; } return _realImplementation.invokeMethod(call.method, call.arguments); } }

测试用例至少要覆盖这些场景:

  • 摄像头权限拒绝 → 确认进入degraded态,定时提醒仍可用。
  • 摄像头摄像头被占用 → 确认会按退避策略重试,不会疯狂请求。
  • 通知权限关闭 → 确认弹App内提示条,而不是崩溃。
  • 平台通道超时 → 确认有兜底数据和日志记录。
  • 传感器事件流断开 → 确认重新创建通道并恢复订阅。

6.2 权限拒绝与弱网模拟

异常注入是“软件层面”的故障,权限拒绝和弱网这类“环境层面”的故障,还需要在真机上做一轮手动回归。我总结出一套护眼卫士专用的回归清单:

  1. 权限拒绝回归:在系统设置里分别关闭摄像头、通知权限,然后打开App,确认每个功能模块都有对应提示,并且App本体不崩、不卡死。
  2. 弱网回归:把手机切到飞行模式,再手动开关一次护眼卫士,确认错误上报能写进本地日志而不是直接把日志丢到内存里。
  3. 前后台切换回归:让App在“后台→前台→再后台”之间循环10次,观察所有EventChannel订阅是否都能恢复,通知任务是否还活着。
  4. 摄像头被占用的实测:先开一个系统相机,再打开护眼卫士,确认App不会因为摄像头被占用而卡在启动页。

这套回归看起来费时间,但只要跑完一遍,心里就踏实很多。真实的线上问题往往就藏在那些“开发时不会主动模拟”的边界场景里。

6.3 回归自动化与发版检查

真机手工回归要做,但不能每次发版都靠人肉。护眼卫士的自动化回归分三层:

  • 单元测试层:针对状态机、日志脱敏、上报合并这些纯逻辑模块,跑快速的单测。
  • 组件测试层:用Mock平台通道,模拟权限异常、超时异常、流断开异常,验证业务层降级逻辑是否符合预期。
  • 集成冒烟层:在真机上执行一组核心场景用例,包括启动、权限授权、摄像头初始化、定时提醒触发、通知显示、日志写盘,作为发版前的最后一道关卡。

我个人的体会是:错误处理模块是典型的“重构容易、验证难”代码。如果不写自动化测试,改一版状态机逻辑,很可能要花两三天去回归所有功能。有了自动化测试打底,改完立刻能发现哪里回归了,省下的时间非常可观。

写在最后的实操体会

护眼卫士这个项目维护了大半年,如果让我重来一遍,让我自己评价最重要的教训,我会选三件事。

第一件事:错误处理必须从项目启动第一天就做,不能等模块写完了再补。我们初期先把摄像头、定时器、通知几个核心模块写完,然后才搭错误处理框架,结果就是每个模块的错误处理风格完全不一样,后面统一花了不少时间。

第二件事:平台通道的错误码规范早定比晚定好。原生侧和Dart侧如果各写各的错误码,排查问题就像看两本不同的字典,效率极低。护眼卫士后来把所有错误码集中到一个枚举文件里,谁都不许改,只许加。这个约定到现在极大提升了跨端协查速度。

第三件事:用户可感知的降级提示,是错误处理最容易被低估的部分。一开始我倾向于“静默修复”,后来发现用户其实很敏锐,他们能感觉到“这个App好像没在干活”,但如果你不告诉他们是权限问题,他们会直接卸载而不是去设置里开权限。一条清晰的状态提示条,比任何一个自动修复逻辑都值钱。

最后再分享一个小技巧:给全局错误上报加一个死循环检测。如果同一个异常在短时间内连续上报超过20次,直接把它当作异常风暴处理——停掉上报,只打本地日志,同时弹一个“检测到异常循环,已暂停部分后台功能”的提示。这个设计看起来不起眼,却帮我们挡住过几次摄像头驱动异常导致的崩溃连环轰炸。

错误处理这件事,本质上和做产品是一样的:你不能保证每个环节永远正常,但你可以保证即使某个环节挂掉了,用户依然知道发生了什么、下一步该做什么。护眼卫士也许还远不算完美,但至少,它不会再“死得无声无息”了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询