我最近在一台 RK3568 的开发板上,用 Flutter 给 OpenHarmony 做了一款视力保护提醒 App。功能不算复杂:定时弹提醒、检测连续用眼时长、给出休息建议。但真正让我花掉大半开发时间的,不是界面布局,也不是状态管理,而是错误处理与异常管理。第一版跑起来时,定时器一到点,App 经常直接闪退,没留下任何堆栈信息,仿佛什么都没发生过。后来我彻底梳理了 Dart 层、平台通道、系统服务三个层面的异常链路,才让这个项目真正稳定下来。这篇文章就把我在 Flutter for OpenHarmony 这条路上踩过的异常坑、用到的处理方案,以及一次完整崩溃的排查过程,一次性讲清楚。
1. 视力提醒类开发板应用为什么总在"最不该崩"的时候崩
1.1 提醒类 App 的本质:系统能力调度器
视力保护提醒 App 这类应用,从工程结构上看,本质是一个"系统能力调度器"。它不依赖复杂 UI,但需要在正确的时间点,正确地把设备上的若干系统能力串联起来。拿我的项目举例,核心链路大致是这样:
- 用
Timer.periodic维护用眼时长计时; - 到点后调用系统通知能力,弹出提醒;
- 如果启用了摄像头或传感器检测用眼距离,还得拉起相机/传感器通道;
- 在用户切换前后台时,需要感知生命周期并调整定时策略。
这套链路里,定时器本身只是一个毫秒级精度的调度源,真正的风险全部集中在外部依赖上。通知服务可能没准备好,传感器可能被占用,平台通道可能在前一个页面销毁后被继续调用。任何一个环节出问题,如果代码里没有兜底,异常就会一路冒泡到顶层,最后表现为整个 App 闪退。
我第一版代码里最常见的写法是:
dart复制Timer.periodic(const Duration(minutes: 20), (timer) async {
await notificationService.sendReminder();
});
当时我觉得这写法没什么问题。直到某天真正跑起来,通知服务偶发抛异常,Timer 回调里又没人接住,整个应用直接白屏退出。我才意识到一个问题:提醒类 App 的"提醒"发生在后台、发生在用户没盯着屏幕的时候,这里的异常,恰恰是最容易被忽略、又最致命的那一类。
1.2 OpenHarmony 设备差异放大了异常概率
在 OpenHarmony 上做 Flutter 开发,遇到的情况比 Android 更复杂。Android 至少机型多但系统服务相对统一,而 OpenHarmony 当前的开发板生态很杂:RK3568、RK3588、DAYU200、各种定制板子,各自的系统裁剪程度、驱动完备度、权限策略都可能不一样。
网上经常有人问"RK3568 有许多设备树到底咋选",这其实就是一个信号:设备树选错,驱动加载就不完整;驱动不完整,传感器服务、通知服务这些系统能力就可能是残缺的。我在一块板子上跑得好好的通知通道,换到另一块板子上直接抛异常,原因就是那台设备的通知服务没有正常注册。
这意味着什么?意味着做错误处理时,不能假设系统能力一定存在、一定可用。代码里必须有一套机制,能在"能力缺失"或"服务异常"时优雅降级,而不是直接崩溃。
1.3 分层的异常管理总思路:异常不该在一个地方全处理
经历几次闪退之后,我重新设计了项目里的异常处理架构。核心思路是分层,不把所有异常都塞到某个全局函数里。
- UI 层:只处理"如何把错误呈现给用户"——弹提示、显示重试按钮、或者静默降级。
- 业务层:用统一的
Result/ 错误码模型来表达失败,不在业务代码里到处裸抛字符串。 - 平台层:把
MethodChannel抛出的PlatformException、MissingPluginException转换成业务层可识别的异常类型。
三层的目标不一样:UI 层要体验,业务层要可控,平台层要防泄漏。这样任何一层出问题,都不会直接捅到最外层导致闪退。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Dart 层异常拦截:三层兜底与业务异常建模
2.1 runZonedGuarded:在 main() 里筑第一道墙
Dart 的异常模型和很多语言不太一样。try-catch 只能接住同步代码和显式 await 的异步异常,而全局未捕获异常,最终会走到 Zone 的 handleUncaughtError。如果不做任何处理,这个未捕获异常就会直接导致 isolate 终止,表现就是 App 闪退。
所以我在 main() 里第一件事,就是用 runZonedGuarded 包住整个应用:
dart复制void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const VisionGuardApp());
}, (error, stackTrace) {
// 这里接住所有未捕获异常
CrashReporter.instance.reportError(error, stackTrace, 'zone');
});
}
这里有个细节值得说明:WidgetsFlutterBinding.ensureInitialized() 必须放在 runZonedGuarded 的回调里面。因为 Flutter 引擎初始化过程中的一部分异步任务也跑在同一个 Zone 里,放在外面的话,这些异步任务产生的未捕获异常,runZonedGuarded 反而接不住。
runZonedGuarded 相当于给整个应用铺了一张安全网。它的价值不在于替代 try-catch,而在于兜住那些"没想到会抛"的异常——比如平台通道异常、引擎内部回调异常。有了这张网,App 至少不会因为一个未处理异常直接闪退,而是能先把错误记录下来。
2.2 FlutterError.onError 与 PlatformDispatcher.onError:框架层和引擎层的兜底
runZonedGuarded 能接住大部分 Dart 侧的未捕获异常,但 Flutter 框架层自己也会处理一些异常,这些异常不会自动走到 Zone 的兜底逻辑里。典型的是布局错误、渲染错误、生命周期回调中抛出的异常。
这就是第二层兜底要干的事:
dart复制void setupGlobalErrorHandlers() {
FlutterError.onError = (FlutterErrorDetails details) {
// 框架层异常统一到这里
CrashReporter.instance.reportError(
details.exception,
details.stack ?? StackTrace.current,
'flutter-framework',
);
};
PlatformDispatcher.instance.onError = (error, stackTrace) {
// 引擎层回调异常,返回 true 表示已处理
CrashReporter.instance.reportError(error, stackTrace, 'engine');
return true;
};
}
注意 PlatformDispatcher.instance.onError 的返回值:返回 true 时,Flutter 引擎会认为这个异常已经被处理,不会进一步上报;返回 false 则会把异常继续向外抛。我一般返回 true,因为我已经记录并上报了,不需要引擎再向上抛一次导致额外崩溃。
实践中我遇到过一种情况:Fluuter 框架层报了一个布局异常,FlutterError.onError 里捕获到,但因为我还调用了 FlutterError.presentError(没有的话,debug 下会红屏),release 下可能没有视觉效果。后来我调整了策略:框架层异常只记录,不打断当前 UI 渲染;只有真正导致 App 无法继续运行的异常,才考虑弹提示或重启。
2.3 异步异常:你写的 try-catch 为什么接不住
这是新手最容易踩的坑:try-catch 包住了一个包含异步操作的函数,结果异常还是没接住。
dart复制// 错误示例
try {
Timer.periodic(const Duration(minutes: 20), (timer) async {
await notificationService.sendReminder(); // 如果这里抛异常,外面 try 接不住
});
} catch (e) {
// 这里的 catch 是多余的,Timer.periodic 回调里的异常不会走到这里
}
原因很简单:Timer.periodic 的回调是在事件循环的后续 tick 里执行的,它不属于 try 块所在的那个同步执行流。回调里的 async 函数抛异常时,异常会进入当前 Zone,而不是回到外层的 try-catch。
正确的做法是在异步回调内部自己处理:
dart复制Timer.periodic(const Duration(minutes: 20), (timer) async {
try {
await notificationService.sendReminder();
} catch (e, stackTrace) {
CrashReporter.instance.reportError(e, stackTrace, 'timer-reminder');
// 根据异常类型决定是否要降级提示
if (e is PermissionDeniedException) {
// 权限被拒,降级为应用内提示
}
}
});
这是一个非常关键的习惯:每当你写 async 回调、then 链、或者任何"延迟执行"的代码时,先问一句:这个回调里的异常,最终由谁接住?如果答案不明确,就立刻在回调内部补上 try-catch 或者 .catchError。
2.4 业务异常建模:统一错误码、Result 返回、避免异常裸奔
早期项目里,我经常看到类似这样的代码:
dart复制// 返回 null 表示失败
String? result = await someService.sendReminder();
if (result == null) {
// 提示失败
}
这段代码的问题在于:null 丢失了失败原因。到底是权限被拒,还是通知服务不可用,还是系统偶发错误?调用方只能给用户一个笼统的"操作失败"提示,而排障时也要靠猜。
我后来引入了一套简单的统一异常模型:
dart复制class AppException implements Exception {
final int code;
final String message;
final String? detail;
const AppException(this.code, this.message, {this.detail});
@override
String toString() => 'AppException(code: $code, message: $message, detail: $detail)';
}
// 权限相关
class PermissionDeniedException extends AppException {
const PermissionDeniedException(String message, {String? detail})
: super(4001, message, detail: detail);
}
// 平台通道/系统服务异常
class PlatformChannelException extends AppException {
const PlatformChannelException(String message, {String? detail})
: super(4002, message, detail: detail);
}
// 配置缺失等
class ConfigMissingException extends AppException {
const ConfigMissingException(String message, {String? detail})
: super(4003, message, detail: detail);
}
配合 Dart 3 的 sealed class,我在业务层定义了一个 Result 类型:
dart复制sealed class Result<T> {}
class Success<T> extends Result<T> {
final T data;
Success(this.data);
}
class Failure<T> extends Result<T> {
final AppException error;
Failure(this.error);
}
之后所有业务方法尽量返回 Result<T>,而不是直接抛异常。只有在"这个异常不需要调用方处理、只是想记一笔"的情况下,才在内部直接 catch 并记录。
这套模型带来的好处是:调用方代码逻辑非常清晰,一个 switch 就能覆盖成功和所有失败分支:
dart复制final result = await reminderService.startMonitoring();
switch (result) {
case Success(:final data):
// 正常开始监测
case Failure(:final error):
// 集中处理错误:提示、降级、或记录
}
3. 平台通道的异常治理:把原生侧的意外变成可恢复的逻辑
3.1 平台通道异常的三种典型来源
Flutter 与 OpenHarmony 原生侧通信走的是 MethodChannel。这层通信远比很多人想象的脆弱,我总结下来异常来源主要有三类:
- 原生侧主动抛异常:比如
NotificationService在原生侧发现权限没开,直接向 Flutter 侧抛了一个PlatformException。 - 通道未注册:某个插件在当前系统版本上不可用,或者插件初始化失败,Flutter 侧调用时抛出
MissingPluginException。 - 数据格式不匹配:原生侧返回了 Flutter 侧无法识别的数据类型,比如非标准 Map 结构、超大二进制数据等在传输过程中失败。
第一类和第三类相对好排查,第二类在 OpenHarmony 上尤其常见。因为 OpenHarmony 的插件体系还在快速演进,很多插件在不同版本上可用性完全不一样。
3.2 捕获并转换平台异常:不让原生错误直接穿透
我现在的做法是:所有平台通道调用,都走一个统一的封装方法。不直接让 PlatformException 满天飞,而是在封装层转换成业务异常。
dart复制class NotificationChannel {
static const MethodChannel _channel = MethodChannel('vision_guard/notification');
Future<void> sendReminder(String message) async {
try {
await _channel.invokeMethod<void>('sendReminder', {'message': message});
} on PlatformException catch (e) {
// 原生侧抛出的业务错误
throw PlatformChannelException(
'通知服务异常:${e.message ?? e.code}',
detail: e.code,
);
} on MissingPluginException catch (e) {
// 当前系统/设备不支持该通道
throw PlatformChannelException('当前设备不支持通知能力', detail: e.message);
} catch (e) {
// 其他意外异常(比如通道传输异常)
throw PlatformChannelException('通知通道调用失败', detail: e.toString());
}
}
}
这样转换后,调用方只需要面对 AppException 及其子类,不需要去判断到底是 PlatformException 还是 MissingPluginException。同时,e.code 这样的原始错误码被保留在 detail 里,后续排障时还能查出来自原生侧的原因。
这里有一个经验:PlatformException 的 code 字段不要丢掉。原生侧可能约定了一系列错误码(比如 PERMISSION_DENIED、SERVICE_NOT_READY),这些在集成第三方 SDK 时尤其有用。
3.3 权限场景:动态授权被拒后的降级设计
视力提醒 App 里,权限是绕不开的话题。通知权限、相机权限(如果用摄像头测距)、传感器权限,每一项都可能被用户拒绝、被系统限制、或者因为系统裁剪而根本不存在。
我把权限相关的异常单独归类,按下表设计降级策略:
| 权限类型 | 异常场景 | 降级策略 |
|---|---|---|
| 通知权限 | 用户拒绝 | 在 App 内提示开启,但不阻断核心计时功能;提示方式改为应用内弹窗 |
| 相机权限 | 无法打开摄像头 | 关闭测距功能,仅保留定时提醒 |
| 传感器权限 | 传感器未启用或缺失 | 自动降级为基于时间的提醒,不依赖距离检测 |
| 后台运行 | 进程被系统回收 | 利用生命周期感知,在回到前台时自动恢复定时任务 |
权限被拒后,硬生生弹一个"请去设置打开权限"并不友好。我的做法是:第一次拒绝时,记录状态并静默降级;再次触发核心功能需要该权限时,用非阻断的方式引导用户。因为视力提醒是一个轻量工具,用户如果不想给权限,核心的计时提醒应该还能用,只是少了一些附加能力。
3.4 原生侧返回规范:错误码契约
Flutter 侧定义了异常模型后,原生侧也得配合。如果原生侧只是简单地把错误信息拼进 result.error,那 Flutter 侧的转换逻辑就得靠猜。
我的做法是,在原生侧(OpenHarmony 的 ArkTS 或 C++ 侧)也约定一套返回格式:
json复制{
"code": 4001,
"message": "Permission denied",
"detail": "notification permission was rejected"
}
原生侧成功时返回 {"code": 0, "data": ...};失败时返回非零码。这样 Flutter 侧拿到结果后,先检查 code,再决定抛哪种业务异常。
在 ArkTS 侧大约是这样:
typescript复制try {
await notificationService.publish(notificationRequest);
return { code: 0, data: true };
} catch (err) {
return {
code: 4001,
message: 'publish notification failed',
detail: JSON.stringify(err)
};
}
Flutter 侧收到后,解析 code 并映射为对应的 AppException。这是最简单也最稳妥的跨语言错误传递方式。
4. 定时器崩溃完整复盘:一个竞态问题是怎么从崩溃现场挖出来的
接下来把一次真实崩溃的完整排查过程写出来。这是我在开发中最有价值的一段经历,因为它把前面所有异常处理的理论都串了起来。
4.1 崩溃现象:每 20 分钟提醒,偶发闪退
第一版测试时,App 每 20 分钟弹一次提醒。测试人员反馈:提醒弹出后,如果用户快速返回桌面或者关闭提醒页面,App 有较大概率直接闪退。但没有稳定复现路径,有时候连续操作 20 次都不崩,有时候 1 次就崩。
更麻烦的是,release 包崩溃后,OpenHarmony 的系统日志里只看到一条类似 SIGSEGV 的底层崩溃记录,Dart 侧完全没有堆栈。我当时第一反应是:这问题可能出在平台通道或原生侧,而不是纯 Dart 逻辑。
4.2 排查链路:日志、复现、隔离
遇到这种"偶发崩溃 + 无堆栈"的问题,第一件事不是猜,而是加日志。我做了三件事:
- 在
Timer.periodic回调入口、调用通知通道前后、页面销毁回调里,分别加了日志并写入本地文件; - 手动触发提醒,然后立刻执行"返回桌面、再进入 App"的循环操作,尝试稳定复现;
- 把有嫌疑的模块拆开,单独测试:只测定时器不通知、只测通知不测定时器。
日志加上后,问题很快浮出水面。崩溃前的日志序列是:
text复制[Timer] tick, sending reminder...
[Channel] invoking sendReminder...
[Lifecycle] page disposed
[Channel] invokeMethod completed with error
关键在第三行:页面已经销毁,但定时器的回调还在继续执行。虽然 Flutter 侧代码里理论上应该能收到回调的结果(成功或失败),但在 OpenHarmony 平台上,当页面销毁导致平台通道对象被释放时,后续再调用 invokeMethod 会出现底层访问已释放对象的问题,表现形式就是原生侧崩溃。
4.3 根因分析:Timer 生命周期与页面生命周期不同步
这是个典型的生命周期异步问题。Timer.periodic 是全局的、独立于页面生命周期的。当用户在提醒弹出后快速关闭页面,页面 dispose 了,但 Timer 还在跑;下一次 tick 到来时,代码尝试通过 MethodChannel 调用通知,而该通道所属的上下文已经被销毁。
打个比方:闹钟响了要出门,你走到宿舍门口却发现门已经在关门倒计时了,你强行往外冲,结果被门夹住。App 的崩溃,就是这个"被门夹住"的过程。
这类问题在 Android 上一般不致命,因为 Android 的 Flutter 引擎和组件生命周期处理比较成熟;但 OpenHarmony 平台上的插件实现还比较早期,生命周期管理不完全一致,所以把竞态问题直接放大成了原生崩溃。
4.4 修复方案:生命周期同步 + 通道可用性检查 + 异常兜底
修复分了三步:
第一步,把定时器从页面级提升到应用级。既然提醒功能是全局的,就不该绑定在某个页面的生命周期上。我把 Timer.periodic 移到专门的 ReminderService 里,由它统一管理,页面只负责订阅通知、展示 UI。
第二步,在页面销毁时取消对提醒的监听,并在平台通道封装里增加"通道是否可用"的检查:
dart复制class ReminderService {
Timer? _timer;
bool _channelReady = false;
Future<void> start() async {
_channelReady = await _initChannel();
_timer = Timer.periodic(const Duration(minutes: 20), (timer) async {
if (!_channelReady) {
// 通道不可用时,降级为应用内提示
_fallbackToLocalReminder();
return;
}
try {
await _notificationChannel.sendReminder('该休息了');
} catch (e) {
// 这里已经是最后的兜底
CrashReporter.instance.reportError(e, StackTrace.current, 'reminder-send');
_fallbackToLocalReminder();
}
});
}
Future<void> stop() async {
_timer?.cancel();
_timer = null;
_channelReady = false;
}
}
第三步,确保 stop() 在应用退出或用户关闭提醒功能时被调用。这里有个关键点:不是在页面 dispose 时停,而是在应用真正进入后台/退出时停。如果只是退到桌面,我会保留定时器,但把通知方式从系统通知降级为应用内提示。
4.5 从这次排错中沉淀的通用排查方法
这次崩溃排查虽然没有太多技术含量,但它验证了一个重要的方法:当崩溃无法从堆栈直接定位时,日志打点是最高效的手段。
具体步骤可以总结为三条:
- 先复现,再定位:如果无法稳定复现,就先找出最小触发条件。把"用户操作"拆成"提醒弹出 + 快速返回 + 再进入"三步,逐步缩小范围。
- 日志要带时间线:日志不只是记录错误,还要记录正常流程的关键节点。这样才能看出"哪一步之后崩溃了"。
- 优先怀疑生命周期:偶发崩溃十有八九是生命周期不同步。先检查 Timer、Stream、ValueNotifier 这类非页面生命周期对象是否被页面生命周期错误地管理着。
5. 监控上报与自愈降级:错误处理链路的最后一公里
5.1 本地崩溃日志:离线采集与会话关联
在 OpenHarmony 开发板上跑正式测试时,网络环境不固定,我不能依赖实时上报。所以我把崩溃日志先写到本地文件,下次启动时再尝试上报。
日志模块的核心是"追加写 + 会话 ID"。每次 App 启动生成一个会话 ID,同一次运行中的日志都带着这个 ID,这样后续排查时能把一次崩溃前后的日志串起来。
dart复制class CrashReporter {
static final CrashReporter instance = CrashReporter._();
final String _sessionId = DateTime.now().millisecondsSinceEpoch.toString();
final List<String> _buffer = [];
void reportError(Object error, StackTrace stack, String source) {
final entry = '''
[${DateTime.now().toIso8601String()}]
[session=$_sessionId] [source=$source]
error: $error
stack: $stack
---''';
_buffer.add(entry);
_appendToFile(entry);
}
Future<void> flush() async {
// 读取本地日志文件,尝试上报到自己的日志服务
}
}
_appendToFile 我直接用 dart:io 的 File 完成,写入路径放在应用沙箱目录下。注意加个大小上限,比如单文件超过 1MB 就轮转,避免日志文件无限膨胀。
5.2 日志卫生:别把敏感信息写进日志
日志不能什么都记。我之前在日志里输出过摄像头权限状态、用户设置中的提醒时间、设备型号等,后来检查时发现这些信息对排查没有直接帮助,却可能涉及用户隐私。现在我在日志模块里做了两层过滤:
- 第一层是字段级别:只记录异常类型、错误码、堆栈、会话 ID、来源模块,不记录业务数据具体值。
- 第二层是字符串级别:在写入前对可能包含敏感信息的长字符串做截断或脱敏处理,比如设备序列号、通知内容、用户输入等,统一替换为
[redacted]。
这不是过度设计,在开发板上调试时,日志文件很容易被别人看到;保持干净,也是一种职业习惯。
5.3 自愈降级:捕获异常后,App 不闪退不等于能正常工作
有了日志,只是"知道坏了";还需要"坏了之后能尽量继续工作"。我实现了三个级别的自愈策略:
- 局部重试:通知通道异常时,延迟 3 秒重试一次。如果重试还是失败,就降级为应用内提示。
- 任务重建:定时器回调连续异常超过 3 次,不再依赖原定时器,而是重新创建一个新的
Timer,同时把提醒方式从系统通知改为应用内弹窗。 - 全局重置:如果检测到多个模块同时异常(比如通知、传感器、计时链全都挂了),重置整个应用状态机,回到初始页,并提示用户重新开启监测。
自愈的关键是"有界重试"。不能无限重试,否则在系统服务持续异常时,重试会不断消耗资源。我一般加一个异常计数器,连续失败达到上限后,直接放弃本轮重试,记录日志并展示降级 UI。
5.4 针对 OpenHarmony 开发板的测试手段:模拟权限撤销与进程回收
我最后补充一个测试相关问题。在 OpenHarmony 开发板上,很多异常场景需要手动模拟。
我用得最多的几个手段:
- 模拟权限撤销:通过
hdc shell命令撤销通知权限,验证 App 在权限被拒时的降级逻辑; - 模拟进程被回收:在后台直接杀掉 App 进程,再次进入时,验证定时器和提醒任务能否自动恢复;
- 频繁切换前后台:脚本循环执行"启动 -> 退后台 -> 再启动",验证生命周期竞态问题是否真的被修复。
这些模拟手段比依赖用户真实操作要高效得多。每次改完异常处理逻辑,我都先跑一遍这些场景,再正常使用一段时间,确认没有引入新的问题。
做 Flutter for OpenHarmony 开发,错误处理不是一个"后面再补"的模块,而是从第一天起就要跟着架构走的东西。这段时间的实践让我最深的体会是:异常管理不是"怎么接住异常",而是"设计一套机制,让异常发生时,用户不困惑,App 不崩溃,数据不丢"。捕获异常只是开始,关键是要让用户感知到发生了什么、还能怎么办,并且让开发者在后续能定位到根因。这一点在 OpenHarmony 这种还在快速演进的平台上,尤其重要。
