你可能觉得"视力保护提醒App"这种项目没什么技术含量,无非是定个时、发个通知、调一下光线传感器。我在OpenHarmony设备上做这个Flutter实战项目的前两周也是这么想的,直到第三次真机测试时发现一个诡异现象:锁屏五分钟后重新打开,所有提醒都消失了。App还活着、界面还能滑、日志也正常,但任何一个定时任务都不再触发。
这个现象直接把我拽进了错误处理与异常管理的深水区。排查了一整天才确定:根因不在Dart代码逻辑,而是OpenHarmony对后台任务有自己的一套调度策略,任务被挂起后Flutter侧的Timer并没有感知,整个提醒链路处于"半死状态"。更麻烦的是,这种故障不抛异常、不闪退、日志不报错,完全静默。
这类项目的真正难点从来不在UI画得漂不漂亮,而在于从传感器读数到提醒通知,一条链路上任何一个环节悄悄失效,用户得到的结果都是两个字:没用。本篇是Flutter for OpenHarmony视力保护提醒App实战的异常管理篇,我把项目里踩过的坑、设计过的错误处理机制、以及最后沉淀下来的排查方法论一次性讲清楚。适合两类读者:一是正在把已有Flutter应用向OpenHarmony上移植的开发者,二是准备从零做OpenHarmony跨端应用、希望少走弯路的人。
1. 为什么视力保护提醒App的异常管理,比业务逻辑更耗精力
1.1 从20-20-20法则说起:功能越简单,异常越致命
视力保护App最常见的就是20-20-20法则提醒:每用眼20分钟,看远方20秒。听起来就是一个Timer加一个通知,代码量不超过一百行。但如果要把体验做到"真的能用",事情完全不是这样。
真实链路会变成:获取当前屏幕使用状态、读取光线传感器数值、判断用户是否正在注视屏幕、检查是否已经进入过休息状态、决定要不要触发提醒、发送本地通知、把本次提醒结果写进本地记录、最后刷新统计页面。任何一步失败,用户都收不到提醒。
这里有个本质区别:普通App的失败是可被用户感知并重试的。列表加载失败,页面有重试按钮;登录失败,有错误提示。但提醒类App的失败是不可见的。用户不会知道"本来该提醒我休息,但系统没提醒",他只会觉得这个App没用,然后默默卸载。所以在这种项目里,静默失败比闪退还可怕——闪退至少有用户反馈,静默失败连反馈都没有。
1.2 Flutter与OpenHarmony双栈的特殊复杂度
这个项目天然是双技术栈。Flutter负责界面渲染和业务状态,OpenHarmony负责传感器、通知、后台调度这些系统能力,两者通过MethodChannel和EventChannel通信。这意味着一次简单的"判断距离并提醒"动作,要跨两个技术栈完成。
Dart侧逻辑写得再严谨,OpenHarmony侧某个API因为权限不足、设备差异、资源占用等因素返回了一个完全没预料到的结果,异常照样产生。而且OpenHarmony的设备生态跨度很大,传感器型号不统一,系统版本行为不一致,厂商定制策略更多样。模拟器跑通不等于真机可用,一台真机可用不等于所有真机可用。
我在这类项目里体会最深的一点:错误处理不是上线前补的防御工事,而是架构阶段就要设计的骨架。如果你把异常管理当时项目都写完了再回来加,那基本等于把整栋楼的地基挖了重浇。
2. 先摸清家底:OpenHarmony环境中Flutter异常的完整分类
2.1 Dart层异常:业务代码里最容易被漏掉的部分
Dart层的同步异常最好处理,try-catch直接接住就行。真正麻烦的是异步异常。Future里的异常可以用catchError或者try-catch接到,但回调函数里的异常特别容易漏。
举个例子,我在做休息状态监听时最开始写过这样的代码:
// 错误写法:异步回调里的异常,外层catch根本接不住 try { restMonitor.onRestTriggered((duration) { throw StateError('unexpected rest state'); }); } catch (e) { // 这里永远执行不到 }Dart的异常捕获是基于调用栈的,回调是稍后才执行的,外层调用早就返回了。正确的做法是在回调内部处理:
restMonitor.onRestTriggered((duration) { try { _handleRestTriggered(duration); } catch (e, stack) { _reportError('rest_trigger', e, stack); } });Stream的监听同理。没有挂onError监听的Stream,异常会进入未处理状态,在debug模式下直接导致zone错误,release模式下可能被吞掉。我排查过一个bug:光线传感器的EventChannel数据流偶尔抛出一个类型转换错误,因为没有onError,Dart侧完全不知道,但内存里飘着一个已损坏的订阅状态,后续数据全部丢失。
2.2 平台通道异常:PlatformException只是冰山一角
MethodChannel调用OpenHarmony原生能力时,最常见的异常确实是PlatformException。但OpenHarmony侧返回的错误格式不一定规范。有的接口返回的是带code的完整错误对象,有的只返回一个笼统的字符串,有的在失败时直接返回null——而null在Dart侧可能被当成"成功但无数据"处理,继续往下走,然后在业务层制造一个更难排查的空值错误。
EventChannel的断连问题更要命。当应用进入后台、通道被系统回收、或者传感器被其他高优先级任务抢占时,事件流会静默断开。Dart侧不会有任何回调、任何异常、任何提示。我是在测试环境发现"光线传感器数据突然停了"的时候才意识到这个问题的。后来加了心跳机制:原生侧每五秒推一个心跳事件,Dart侧十五秒没收到心跳就判定通道异常,触发重连。
2.3 生命周期与状态异常:Flutter on OpenHarmony特有的时序问题
OpenHarmony对Flutter应用的生命周期管理有自己的逻辑,跟Android不完全一样。WidgetsBindingObserver里的didChangeAppLifecycleState在部分OpenHarmony设备上触发时机会有延迟,甚至出现前后台状态跳变:从paused直接到resumed,中间的inactive和hidden被跳过。
这个时序问题直接影响提醒调度。我在恢复计时器时依赖生命周期回调,结果发现有时候App已经在前台了,计时器还没恢复。后来改用双源校验:生命周期回调只作为参考,真正决定计时器状态的是一系列互相校验的时间戳和活动标记。
这里我给个排查建议:所有生命周期回调入口都打时间戳日志,把App从启动到锁屏再到恢复的完整事件流打印出来。你会惊讶地发现,常态表现和你的直觉完全不同。
3. 异步异常捕获实战:三层保险,别指望一个try-catch走天下
3.1 第一层:异步入口统统做规范化收口
我在这类项目里最大的改变,是给所有异步入口函数定了一个统一约定:不直接抛异常,而是返回一个带状态的结果对象。这样做的好处是业务代码逻辑统一,不会在任何一个异步点漏掉异常。
sealed class LoadResult<T> { const LoadResult(); } class LoadSuccess<T> extends LoadResult<T> { const LoadSuccess(this.data); final T data; } class LoadFailure<T> extends LoadResult<T> { const LoadFailure(this.code, this.message, [this.detail]); final String code; final String message; final Object? detail; }调用方长这样:
Future<LoadResult<RestStat>> loadRestStat() async { try { final stat = await _repo.fetch(); return LoadSuccess(stat); } on TimeoutException { return const LoadFailure('timeout', '查询超时,请稍后重试'); } on PlatformException catch (e) { _saveLog('rest_stat', 'platform_error', e); return LoadFailure('platform_${e.code}', e.message ?? '系统能力暂不可用'); } catch (e, stack) { _saveLog('rest_stat', 'unknown_error', e, stack); return const LoadFailure('unknown', '发生未知错误'); } }你会发现所有异常都变成了数据流,UI层只需要判断LoadResult是成功还是失败,失败时展示哪个message,逻辑清晰得接近无脑。代价是要为每个接口写一点点样板代码,但这个成本在排查问题时会成倍赚回来。异常转数据之后,你可以在日志里留下完整的错误码、原始异常和堆栈,而不是让异常穿越层级然后消失。
3.2 第二层:Zone兜底与FlutterError重定向
try-catch能覆盖掉大部分业务异常,但总有漏网之鱼。比如某个第三方组件内部异步回调里的异常、某个平台通道的底层错误、某个我没考虑到的边界触发。这层要用runZonedGuarded做全局兜底。
void main() { runZonedGuarded(() { WidgetsFlutterBinding.ensureInitialized(); FlutterError.onError = (details) { _handleFlutterError(details); }; runApp(const EyeCareApp()); }, (error, stack) { _handleFlutterError(FlutterErrorDetails( exception: error, stack: stack, library: 'app_zone', )); }); }_handleFlutterError里做的事很关键:写日志、尝试上报、但不影响当前UI。这样至少保证"异常发生了我们有记录",而不是让异常直接触发红屏或者闪退。
这个做法还有个额外好处:release模式下,FlutterError.onError默认行为是把错误打印到控制台,但用户在真机上根本看不到控制台。重定向之后,错误会被写进本地文件,配合用户反馈就能定位问题。
3.3 第三层:ErrorWidget.builder做最后一道视觉兜底
如果某个组件在build阶段抛了异常,Flutter会用它内置的ErrorWidget代替组件渲染,默认是一个灰底红条。这个在开发阶段还行,给用户看到就太粗暴了。
我替换成了统一风格的错误占位:
ErrorWidget.builder = (details) { _saveLog('widget_error', details.exception, details.stack); return const Material( child: Center( child: Padding( padding: EdgeInsets.all(16), child: Text( '这个区域出了点问题,我们已记录原因', textAlign: TextAlign.center, ), ), ), ); };注意ErrorWidget.builder只兜住build阶段的异常,事件回调、异步操作里的异常它管不了。所以这三层是层层包裹关系:第一层处理业务异步流,第二层处理全局漏网异常,第三层处理组件渲染异常。三层都齐了,才敢说异常处理体系基本成型。
4. 传感器与硬件调用:最容易出现"静默故障"的地方
4.1 传感器数据的可信度校验
视力保护App离不开传感器:光线传感器、距离传感器、可能还有加速度传感器。这些硬件数据有一个共性:不是每次读数都可信。
光线传感器在部分设备上返回的是lux值,但不同厂商的数值范围差异巨大。有的设备在室内正常光线下返回50,有的返回5000。更麻烦的是,有些传感器在弱光环境下会返回0或一个极小的值,如果直接拿来判断"环境过暗"然后疯狂提醒用户开灯,体验会非常糟糕。
距离传感器的坑更多。部分设备在"无法测量距离"时返回-1,这个值如果进入判断逻辑,用户明明正常坐姿,也会被判定为"距离过近"。我是用过滤策略解决的:连续读三次数,去掉无效值,取有效值的中位数作为本次判断依据。如果连续五次都没有有效值,就判定传感器不可用,自动降级。
4.2 权限与可用性降级:把失败变成可理解的策略
传感器权限被用户拒绝这件事,几乎是必然发生的。谁都不喜欢一个App突然要读这么多传感器数据。这里的关键不是代码怎么处理异常,而是产品策略怎么设计。
我最终采用的是软降级方案:距离传感器不可用,就只做时长提醒;光线传感器不可用,就用固定默认亮度策略;权限被拒绝,设置页给用户清晰的说明"距离提醒需要传感器权限,未授权时该功能不可用",而不是弹一个系统风格的报错框。
实现上很简单:
class DistanceMonitor { DistanceMonitor(this._sensor); Future<bool> isUsable() async { try { final hasPermission = await _sensor.checkPermission(); if (!hasPermission) { return false; } final status = await _sensor.getStatus(); return status == SensorStatus.available; } on PlatformException catch (e) { _saveLog('sensor', 'check_failed', e); return false; } catch (e) { _saveLog('sensor', 'check_unknown', e); return false; } } }所有异常都收敛成一个bool值。上层业务拿到false就降级,逻辑非常干净。
4.3 平台通道的错误码映射:让问题可诊断
最开始我的做法是把OpenHarmony侧的错误字符串直接透传给Dart层。结果就是排查问题时看到一堆毫无规律的乱码,根本不知道错误来自哪个模块。
后来我设计了一套错误码规范:模块-层级-码值。比如SCN_LIGHT_1001表示传感器模块光线传感器权限拒绝,NTF_SEND_2002表示通知模块发送超时。OpenHarmony侧把错误码和附加信息拼成一个Map:
// OpenHarmony侧(ArkTS)返回给Dart的结构示意 { "code": "SCN_LIGHT_1001", "message": "light sensor permission denied" }Dart侧统一解析这个结构,落到日志和用户提示里。这套规范让排查效率提了一个量级。你不需要再猜错误是什么意思,只看前缀就能锁定模块范围。
5. 提醒调度与后台任务的异常战场
5.1 定时任务的悬挂与恢复
提醒类App最核心的机制是定时调度。我用Timer.periodic做轮询调度,每个30秒检查一次时间是否到达下一个提醒节点。这个方案比单个Timer更稳,因为单Timer在系统挂起后一旦失效就彻底没戏了,轮询Timer至少可以在App恢复活跃后第一时间发现"该提醒的没提醒"。
但真正的挑战是OpenHarmony的后台策略。应用进入后台或锁屏后,系统可能暂停应用进程的某些活动。我在生命周期回调里做了恢复逻辑,在resumed时重新校准所有计时器:
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _rescheduleAllTimers(); } } void _rescheduleAllTimers() { final now = DateTime.now(); if (_nextRestAt != null && now.isAfter(_nextRestAt!)) { _triggerRestReminder(); } }这里有个细节:不能直接把Timer重置为20分钟,应该重新计算当前时间到原定提醒节点的差值。这样用户只是短暂切走App,回来后如果已经超过提醒时间,立刻触发提醒,而不是重新计时。
5.2 通知发送的失败重试与幂等
通知是提醒链路的最终出口。通知发送失败的原因很多:用户关闭了通知权限、系统请勿打扰模式、当天通知配额已满、应用在后台时通知受限。如果发送失败就放弃,链路上所有努力都白费了。
我加了一个重试和幂等机制。每个提醒都有一个reminderId,发送成功后记录该ID已发送;发送失败则进入待重试队列,下次App启动或网络恢复时重试。核心原则是:同一个reminderId只能发成功一次,避免用户收到重复提醒。
这个设计解决了两个问题。一是网络或系统短暂故障导致的失败不再丢失提醒;二是重复触发不会轰炸用户。
5.3 从"静默失败"到"可感知降级"
前面强调过,静默失败是提醒类App最大的敌人。我的应对方式是做一个"健康状态自检"功能,放在设置页的显眼位置。
App启动时跑一次自检:检查通知权限是否开启、距离传感器是否可用、光线传感器是否可用、后台运行权限是否正常。每个状态项直接展示在设置页,用"正常"或"不可用"表示,并给出原因说明。这样即使某个功能因为系统原因失效,用户也看得清清楚楚。
这块的代码核心就是一个状态聚合器:
class HealthReport { final bool notificationEnabled; final bool distanceSensorUsable; final bool lightSensorUsable; final bool backgroundAllowed; } Future<HealthReport> generateHealthReport() async { final results = await Future.wait([ _notificationService.checkEnabled(), _distanceSensor.isUsable(), _lightSensor.isUsable(), _backgroundService.isAllowed(), ]); return HealthReport( notificationEnabled: results[0] as bool, distanceSensorUsable: results[1] as bool, lightSensorUsable: results[2] as bool, backgroundAllowed: results[3] as bool, ); }让异常可见、让降级可见,用户至少知道发生了什么事。这比任何隐藏的容错逻辑都重要。
6. 崩溃防御与问题复盘:不追求"不崩",追求"崩得明白"
6.1 日志链路设计:关键节点打卡式记录
我见过太多项目把日志当回事的时候已经是线上事故之后了。这个项目里我从第一天就要求所有关键节点写日志。不是简单的"进函数打一条",而是结构化的事件流。
每个提醒生命周期都有固定日志点:提醒计划生成、定时器校准、传感器数据读取、提醒条件判断、通知发送请求、发送结果回调。任何一步异常,顺着这次提醒的唯一ID就能把整条链路串起来。
日志存储上我用的是分级策略:debug环境输出到控制台,release环境写本地文件,文件按天切割,保留最近七天。注意一个细节:日志写入本身也要做异常保护。文件写失败、磁盘满、权限被收回,这些异常不能反过来把日志系统搞崩。日志IO全部在独立的事件循环里执行,主流程不等待也不感知。
6.2 真机矩阵与"逃生舱"设计
OpenHarmony设备差异带来的异常,靠单元测试和模拟器永远测不出来。我最后整理了一个真机测试矩阵:至少覆盖三台不同厂商的设备,分别评估它们的传感器数据表现、后台策略差异、生命周期回调时机。每次发版前用这个矩阵做冒烟验证,成本不大但能挡掉大多数设备相关异常。
还有一个经验是"逃生舱"。每类传感器功能都做了一个远程配置开关,后端配置中心可以随时关闭某个功能的开关。比如某批设备突然出现距离传感器误报,可以通过配置直接停掉距离提醒,而不是让用户在误报轰炸中把App卸载。这个机制上线后救过我至少两次。
最后补充一个实用技巧:在开发阶段给App加一个隐藏的"异常记录"页面,把所有错误事件按时间倒序列出来。真机测试时随手翻一翻,很多偶发异常当场就能发现,不需要等用户反馈。
做完这个项目,我对异常管理的理解有了很大变化。以前写普通App,异常处理的核心目的是防止闪退;现在做这种强依赖系统能力的应用,异常管理其实就是产品逻辑的一部分。用户视力保护的效果,直接取决于提醒链路每个环节的可靠性。与其说我们在写业务代码,不如说我们在设计一套"即便某个环节失败、整体依然体面可用"的系统。如果你正在做Flutter on OpenHarmony相关的应用,祝你少踩一些我踩过的坑,也希望这些经验能帮你把错误处理这层地基打得更牢。