Flutter 异常
Flutter 异常指的是,Flutter 程序中 Dart 代码运行时意外发生的错误事件。我们可以通过与 Swift 类似的 try-catch
机制来捕获它。但 与 Swift 不同的是,Dart 程序不强制要求我们必须处理异常。
这是因为,Dart 采用事件循环的机制来运行任务,所以各个任务的运行状态是互相独立的。也就是说,即便某个任务出现了异常我们没有捕获它,Dart
程序也不会退出,只会导致当前任务后续的代码不会被执行,用户仍可以继续使用其他功能。
Dart 异常,根据来源又可以细分为 App 异常和 Framework 异常。Flutter 为这两种异常提供了不同的捕获方式。
App 异常的捕获方式
App 异常,就是应用代码的异常,通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序,App
异常可以分为两类,即同步异常和异步异常:同步异常可以通过 try-catch 机制捕获,异步异常则需要采用 Future 提供的 catchError
语句捕获。
这两种异常的捕获方式,如下代码所示:
代码语言:txt复制// 使用 try-catch 捕获同步异常
代码语言:txt复制try {
代码语言:txt复制 throw SYReportException('发生一个dart 同步异常');
代码语言:txt复制}
代码语言:txt复制catch(e) {
代码语言:txt复制 print(e);
代码语言:txt复制}
代码语言:txt复制// 使用 catchError 捕获异步异常
代码语言:txt复制Future.delayed(Duration(seconds: 1)).then((e) {
代码语言:txt复制 if (sendFlag) {
代码语言:txt复制 print('异步异常发生之前 >>>>>>>>>>>');
代码语言:txt复制 throw SYReportException('发生一个dart 异步异常');
代码语言:txt复制 }
代码语言:txt复制 print('异步异常后执行的代码 <<<<<<<<<<<');
代码语言:txt复制});
代码语言:txt复制// 注意,以下代码无法捕获异步异常
代码语言:txt复制try {
代码语言:txt复制 Future.delayed(Duration(seconds: 1)).then((e) {
代码语言:txt复制 if (sendFlag) {
代码语言:txt复制 print('异步异常发生之前 >>>>>>>>>>>');
代码语言:txt复制 throw SYReportException('发生一个dart 异步异常');
代码语言:txt复制 }
代码语言:txt复制 print('异步异常后执行的代码 <<<<<<<<<<<');
代码语言:txt复制 });
代码语言:txt复制} catch (e) {
代码语言:txt复制 print("这是不会执行的. ");
代码语言:txt复制}
需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用 try-catch 去捕获一个异步调用所抛出的异常的。
同步的 try-catch 和异步的 catchError,为我们提供了直接捕获特定异常的能力,而如果我们想集中管理代码中的所有异常,Flutter
也提供了 Zone.runZoned 方法。
我们可以给代码执行对象指定一个 Zone,在 Dart 中,Zone
表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常,沙盒提供了 onError
回调函数,拦截那些在代码执行对象中的未捕获异常。
在下面的代码中,我们将可能抛出异常的语句放置在了 Zone 里。可以看到,在没有使用 try-catch 和 catchError
的情况下,无论是同步异常还是异步异常,都可以通过 Zone 直接捕获到:
代码语言:txt复制runZoned(() {
代码语言:txt复制 // 同步抛出异常
代码语言:txt复制 throw SYReportException('发生一个dart 同步异常');
代码语言:txt复制}, onError: (dynamic e, StackTrace stack) {
代码语言:txt复制 print('zone捕获到了同步异常');
代码语言:txt复制});
代码语言:txt复制runZoned(() {
代码语言:txt复制 // 异步抛出异常
代码语言:txt复制 Future.delayed(Duration(seconds: 1))
代码语言:txt复制 .then((e) => throw SYReportException('发生一个dart 异步异常'));
代码语言:txt复制}, onError: (dynamic e, StackTrace stack) {
代码语言:txt复制 print('zone捕获到了异步异常');
代码语言:txt复制});
因此,如果我们想要集中捕获 Flutter 应用中的未处理异常,可以把 main 函数中的 runApp 语句也放置在 Zone
中。这样在检测到代码中运行异常时,我们就能根据获取到的异常上下文信息,进行统一处理了:
代码语言:txt复制runZonedGuarded(() {
代码语言:txt复制 runApp(MyApp());
代码语言:txt复制}, (error, stackTrace) {
代码语言:txt复制 // 这个闭包中发生的Exception是捕获不到的 @山竹
代码语言:txt复制 SYExceptionReportChannel.reportException(error, stackTrace);
代码语言:txt复制}, zoneSpecification: ZoneSpecification(
代码语言:txt复制 print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
代码语言:txt复制 // 记录所有的打印日志
代码语言:txt复制 parent.print(zone, "line是啥:$line");
代码语言:txt复制},
代码语言:txt复制));
接下来,我们再看看 Framework 异常应该如何捕获吧。
Framework 异常的捕获方式
Framework 异常,就是 Flutter 框架引发的异常,通常是由应用代码触发了 Flutter
框架底层的异常判断引起的。比如,当布局不合规范时,Flutter 就会自动弹出一个触目惊心的红色错误界面,如下所示:
framework_error.png
这其实是因为,Flutter 框架在调用 build 方法构建页面时进行了 try-catch 的处理,并提供了一个
ErrorWidget,用于在出现异常时进行信息提示:
代码语言:txt复制@override
代码语言:txt复制void performRebuild() {
代码语言:txt复制 Widget built;
代码语言:txt复制 try {
代码语言:txt复制 // 创建页面
代码语言:txt复制 built = build();
代码语言:txt复制 } catch (e, stack) {
代码语言:txt复制 // 使用 ErrorWidget 创建页面
代码语言:txt复制 built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
代码语言:txt复制 ...
代码语言:txt复制 }
代码语言:txt复制 ...
代码语言:txt复制}
这个页面反馈的信息比较丰富,适合开发期定位问题。但如果让用户看到这样一个页面,就很糟糕了。因此,我们通常会重写 ErrorWidget.builder
方法,将这样的错误提示页面替换成一个更加友好的页面。
下面的代码演示了自定义错误页面的具体方法。在这个例子中,我们自定义了错误页面,显示导航栏和可滚动的错误信息:
代码语言:txt复制// 重写 ErrorWidget 的builder,显示地优雅一些
代码语言:txt复制ErrorWidget.builder = (FlutterErrorDetails details) {
代码语言:txt复制 print('错误widget详细的错误信息为:' details.toString());
代码语言:txt复制 return MaterialApp(
代码语言:txt复制 title: 'Error Widget',
代码语言:txt复制 theme: ThemeData(
代码语言:txt复制 primarySwatch: Colors.red,
代码语言:txt复制 ),
代码语言:txt复制 home: Scaffold(
代码语言:txt复制 appBar: AppBar(
代码语言:txt复制 title: Text('Widget渲染异常!!!'),
代码语言:txt复制 ),
代码语言:txt复制 body: _createBody(details),
代码语言:txt复制 ),
代码语言:txt复制 );
代码语言:txt复制};
运行效果如下所示:
custom_error_widget.png
比起之前触目惊心的红色错误页面,自定义的看起来优雅一些,当然也可以找UI帮忙设计更友好的界面。需要注意的是,ErrorWidget.builder
方法提供了一个参数 details
用于表示当前的错误上下文,为避免用户直接看到错误信息,这里我们并没有将它展示到界面上。但是,我们不能丢弃掉这样的异常信息,需要提供统一的异常处理机制,用于后续分析异常原因。
为了集中处理框架异常,Flutter 提供了 FlutterError 类,这个类的 onError
属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。
在下面的代码中,我们使用 Zone 提供的 handleUncaughtError 语句,将 Flutter 框架的异常统一转发到当前的 Zone
中,这样我们就可以统一使用 Zone 去处理应用内的所有异常了:
代码语言:txt复制// framework异常捕获,转发到当前的 Zone
代码语言:txt复制FlutterError.onError = (FlutterErrorDetails details) async {
代码语言:txt复制 Zone.current.handleUncaughtError(details.exception, details.stack);
代码语言:txt复制};
异常上报
到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。
三方,我们一般都是用bugly。如果公司有自研的bug系统,那就更好了。
这些异常上报,我们将使用MethodChannel推送给Native,由Native上报到bugly或自研的异常系统。
这里只展示Dart的代码实现,至于Native怎么实现Channel,自行Google即可
Dart实现
代码如下:
代码语言:txt复制/// flutter exception channel
代码语言:txt复制class SYExceptionReportChannel {
代码语言:txt复制 static const MethodChannel _channel =
代码语言:txt复制 const MethodChannel('sy_exception_channel');
代码语言:txt复制 // 上报异常
代码语言:txt复制 static reportException(dynamic error, dynamic stack) {
代码语言:txt复制 print('捕获的异常类型 >>> : ${error.runtimeType}');
代码语言:txt复制 print('捕获的异常信息 >>> : $error');
代码语言:txt复制 print('捕获的异常堆栈 >>> : $stack');
代码语言:txt复制 Map reportMap = {
代码语言:txt复制 'type': "${error.runtimeType}",
代码语言:txt复制 'title': error.toString(),
代码语言:txt复制 'description': stack.toString()
代码语言:txt复制 };
代码语言:txt复制 // 得使用这个
代码语言:txt复制 print('这是通过convert转的json');
代码语言:txt复制 print(jsonEncode(reportMap));
代码语言:txt复制 _channel.invokeListMethod('reportException', reportMap);
代码语言:txt复制 }
代码语言:txt复制}
我们捕获到的异常后,由channel推送给Native,包含三个信息:
- 异常的类型信息
- 异常的简要说明信息(即error的toString的值)
- 异常的堆栈信息
优化、封装及问题点
综合上述的阐述,我们将代码做一些封装和优化。
- 优化: 异常捕获后,在debug和release的模式下是不一样的处理,debug模式,直接打印到控制台是最直观的,release模式下,无法感知哪里出了问题,所以我们需要上报,然后分析问题。
区分当前是debug还是release,有一个比较巧妙的方式,代码及注释如下:
代码语言:txt复制// 比较巧妙的一种方式判定是否是debug模式
代码语言:txt复制static bool get isInDebugMode {
代码语言:txt复制 bool inDebugMode = false;
代码语言:txt复制 // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
代码语言:txt复制 assert(inDebugMode = true);
代码语言:txt复制 return inDebugMode;
代码语言:txt复制}
基于上述的思路,我们将未捕获的异常转发到zone做一个判断:
代码语言:txt复制// framework异常捕获,转发到当前的 Zone
代码语言:txt复制 FlutterError.onError = (FlutterErrorDetails details) async {
代码语言:txt复制 // debug模式
代码语言:txt复制 if (ExceptionReportUtil.isInDebugMode) {
代码语言:txt复制 // 打印到控制台
代码语言:txt复制 FlutterError.dumpErrorToConsole(details);
代码语言:txt复制 // release模式
代码语言:txt复制 } else {
代码语言:txt复制 // 转发到zone
代码语言:txt复制 Zone.current.handleUncaughtError(details.exception, details.stack);
代码语言:txt复制 }
代码语言:txt复制 };
- 封装: main函数中的代码,自然是越简练越好,但将未捕获的异常转发到zone及错误Widget重写必须放在main中,所以抽取一个工具类ExceptionReportUtil:
/// 工具类
代码语言:txt复制class ExceptionReportUtil {
代码语言:txt复制 // 比较巧妙的一种方式判定是否是debug模式
代码语言:txt复制 static bool get isInDebugMode {
代码语言:txt复制 bool inDebugMode = false;
代码语言:txt复制 // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
代码语言:txt复制 assert(inDebugMode = true);
代码语言:txt复制 return inDebugMode;
代码语言:txt复制 }
代码语言:txt复制 // 初始化异常捕获配置
代码语言:txt复制 static void initExceptionCatchConfig() {
代码语言:txt复制 // framework异常捕获,转发到当前的 Zone
代码语言:txt复制 FlutterError.onError = (FlutterErrorDetails details) async {
代码语言:txt复制 // debug模式
代码语言:txt复制 if (ExceptionReportUtil.isInDebugMode) {
代码语言:txt复制 // 打印到控制台
代码语言:txt复制 FlutterError.dumpErrorToConsole(details);
代码语言:txt复制 // release模式
代码语言:txt复制 } else {
代码语言:txt复制 // 转发到zone
代码语言:txt复制 Zone.current.handleUncaughtError(details.exception, details.stack);
代码语言:txt复制 }
代码语言:txt复制 };
代码语言:txt复制 // 重写 ErrorWidget 的builder,显示地优雅一些
代码语言:txt复制 ErrorWidget.builder = (FlutterErrorDetails details) {
代码语言:txt复制 print('错误widget详细的错误信息为:' details.toString());
代码语言:txt复制 return MaterialApp(
代码语言:txt复制 title: 'Error Widget',
代码语言:txt复制 theme: ThemeData(
代码语言:txt复制 primarySwatch: Colors.red,
代码语言:txt复制 ),
代码语言:txt复制 home: Scaffold(
代码语言:txt复制 appBar: AppBar(
代码语言:txt复制 title: Text('Widget渲染异常!!!'),
代码语言:txt复制 ),
代码语言:txt复制 body: _createBody(details),
代码语言:txt复制 ),
代码语言:txt复制 );
代码语言:txt复制 };
代码语言:txt复制 }
代码语言:txt复制 // 创建错误widget body
代码语言:txt复制 static Widget _createBody(dynamic details) {
代码语言:txt复制 // 正确代码
代码语言:txt复制 return Container(
代码语言:txt复制 color: Colors.white,
代码语言:txt复制 child: SingleChildScrollView(
代码语言:txt复制 child: Padding(
代码语言:txt复制 padding: const EdgeInsets.all(16.0),
代码语言:txt复制 child: Text(
代码语言:txt复制 details.toString(),
代码语言:txt复制 style: TextStyle(color: Colors.red),
代码语言:txt复制 ),
代码语言:txt复制 ),
代码语言:txt复制 ),
代码语言:txt复制 );
代码语言:txt复制 }
代码语言:txt复制}
- 问题点: 在runZonedGuarded函数的闭包中接收未捕获的异常,然后上报,如果执行该闭包中的代码发生异常,是无法捕获的:
代码及注释如下:
代码语言:txt复制main(List<String> args) {
代码语言:txt复制 // 初始化Exception 捕获配置
代码语言:txt复制 ExceptionReportUtil.initExceptionCatchConfig();
代码语言:txt复制 runZonedGuarded(() {
代码语言:txt复制 runApp(MyApp());
代码语言:txt复制 }, (error, stackTrace) {
代码语言:txt复制 // 这个闭包中发生的Exception是捕获不到的 @山竹
代码语言:txt复制 SYExceptionReportChannel.reportException(error, stackTrace);
代码语言:txt复制 }, zoneSpecification: ZoneSpecification(
代码语言:txt复制 print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
代码语言:txt复制 // 记录所有的打印日志
代码语言:txt复制 parent.print(zone, "line是啥:$line");
代码语言:txt复制 },
代码语言:txt复制 ));
代码语言:txt复制}
我们通过SYExceptionReportChannel.reportException(error,
stackTrace)将错误上报给Native,但在Native如果没有实现channel的链接,那么必然会报MissingPluginException,这个异常是不在当前的zone中的,所以无法捕获。
missingPluginException.png
通过一个例子来验证我们的异常捕获
写了一个例子,来演示这个功能的实现,以及具体的效果:
demo_page.png
在点击第三个按钮之前,前面两个按钮都是正常工作,不会发生异常,点击之后就会产生异常。
通过打印信息,我们来看下每种异常具体捕获到了哪些信息:
- Dart同步异常:
dart同步异常.png
- Dart异步异常:
dart异步异常.png
- flutter framework异常:
flutter_framework异常.png
通过异常类型、异常信息和异常的具体堆栈,对异常的定位将起到很大的帮助。
总结
对于 Flutter 应用的异常捕获,可以分为单个异常捕获和多异常统一拦截两种情况。
其中,单异常捕获,使用 Dart 提供的同步异常 try-catch,以及异步异常 catchError
机制即可实现。而对多个异常的统一拦截,可以细分为如下两种情况:一是 App 异常,我们可以将代码执行块放置到 Zone 中,通过 onError
回调进行统一处理;二是 Framework 异常,我们可以使用 FlutterError.onError 回调进行拦截。
在捕获到异常之后,我们需要上报异常信息,用于后续分析定位问题。
需要注意的是,Flutter 提供的异常拦截只能拦截 Dart 层的异常,而无法拦截 Engine 层的异常。这是因为,Engine 层的实现大部分是
C 的代码,一旦出现异常,整个程序就直接 Crash 掉了。不过通常来说,这类异常出现的概率极低,一般都是 Flutter 底层的
Bug,与我们在应用层的实现没太大关系,所以我们也无需过度担心。