Flutter异常捕获

无论我们的应用写得多么完美、测试得多么全面,总是无法完全避免线上的异常问题。

这些异常,可能是因为不充分的机型适配、用户糟糕的网络状况;也可能是因为 Flutter 框架自身的 Bug,甚至是操作系统底层的问题。这些异常一旦发生,Flutter 应用会无法响应用户的交互事件,轻则报错,重则功能无法使用甚至闪退,这对用户来说都相当不友好,是开发者最不愿意看到的。

所以,我们要想办法去捕获用户的异常信息,将异常现场保存起来,并上传至服务器,这样我们就可以分析异常上下文,定位引起异常的原因,去解决此类问题了。

所以我们就来聊聊 Flutter 异常的捕获和信息采集,以及对应的数据上报处理。

Flutter 异常

Flutter 异常指的是,Flutter 程序中 Dart 代码运行时意外发生的错误事件。我们可以通过与 Swift 类似的 try-catch 机制来捕获它。但与 Swift 不同的是,Dart 程序不强制要求我们必须处理异常。

这是因为,Dart 采用事件循环的机制来运行任务,所以各个任务的运行状态是互相独立的。也就是说,即便某个任务出现了异常我们没有捕获它,Dart 程序也不会退出,只会导致当前任务后续的代码不会被执行,用户仍可以继续使用其他功能。

Dart 异常,根据来源又可以细分为 App 异常和 Framework 异常。Flutter 为这两种异常提供了不同的捕获方式。

App 异常的捕获方式

App 异常,就是应用代码的异常,通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序,App 异常可以分为两类,即同步异常和异步异常:同步异常可以通过 try-catch 机制捕获,异步异常则需要采用 Future 提供的 catchError 语句捕获。

这两种异常的捕获方式,如下代码所示:

// 使用 try-catch 捕获同步异常
try {
    throw SYReportException('发生一个dart 同步异常');
}
catch(e) {
  print(e);
}
 
// 使用 catchError 捕获异步异常
Future.delayed(Duration(seconds: 1)).then((e) {
  if (sendFlag) {
    print('异步异常发生之前 >>>>>>>>>>>');
    throw SYReportException('发生一个dart 异步异常');
  }
  print('异步异常后执行的代码 <<<<<<<<<<<');
});
    
// 注意,以下代码无法捕获异步异常
try {
    Future.delayed(Duration(seconds: 1)).then((e) {
      if (sendFlag) {
        print('异步异常发生之前 >>>>>>>>>>>');
        throw SYReportException('发生一个dart 异步异常');
      }
      print('异步异常后执行的代码 <<<<<<<<<<<');
    });
} catch (e) {
    print("这是不会执行的. ");
}

需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用 try-catch 去捕获一个异步调用所抛出的异常的。

同步的 try-catch 和异步的 catchError,为我们提供了直接捕获特定异常的能力,而如果我们想集中管理代码中的所有异常,Flutter 也提供了 Zone.runZoned 方法。

我们可以给代码执行对象指定一个 Zone,在 Dart 中,Zone 表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常,沙盒提供了 onError 回调函数,拦截那些在代码执行对象中的未捕获异常。

在下面的代码中,我们将可能抛出异常的语句放置在了 Zone 里。可以看到,在没有使用 try-catch 和 catchError 的情况下,无论是同步异常还是异步异常,都可以通过 Zone 直接捕获到:

runZoned(() {
  // 同步抛出异常
  throw SYReportException('发生一个dart 同步异常');
}, onError: (dynamic e, StackTrace stack) {
  print('zone捕获到了同步异常');
});
 
runZoned(() {
  // 异步抛出异常
  Future.delayed(Duration(seconds: 1))
      .then((e) => throw SYReportException('发生一个dart 异步异常'));
}, onError: (dynamic e, StackTrace stack) {
  print('zone捕获到了异步异常');
});

因此,如果我们想要集中捕获 Flutter 应用中的未处理异常,可以把 main 函数中的 runApp 语句也放置在 Zone 中。这样在检测到代码中运行异常时,我们就能根据获取到的异常上下文信息,进行统一处理了:

runZonedGuarded(() {
    runApp(MyApp());
}, (error, stackTrace) {
    // 这个闭包中发生的Exception是捕获不到的 @山竹
    SYExceptionReportChannel.reportException(error, stackTrace);
}, zoneSpecification: ZoneSpecification(
    print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
  // 记录所有的打印日志
  parent.print(zone, "line是啥:$line");
},
));

接下来,我们再看看 Framework 异常应该如何捕获吧。

Framework 异常的捕获方式

Framework 异常,就是 Flutter 框架引发的异常,通常是由应用代码触发了 Flutter 框架底层的异常判断引起的。比如,当布局不合规范时,Flutter 就会自动弹出一个触目惊心的红色错误界面,如下所示:

framework_error.png

这其实是因为,Flutter 框架在调用 build 方法构建页面时进行了 try-catch 的处理,并提供了一个 ErrorWidget,用于在出现异常时进行信息提示:

@override
void performRebuild() {
  Widget built;
  try {
    // 创建页面
    built = build();
  } catch (e, stack) {
    // 使用 ErrorWidget 创建页面
    built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
    ...
  } 
  ...
}

这个页面反馈的信息比较丰富,适合开发期定位问题。但如果让用户看到这样一个页面,就很糟糕了。因此,我们通常会重写 ErrorWidget.builder 方法,将这样的错误提示页面替换成一个更加友好的页面。

下面的代码演示了自定义错误页面的具体方法。在这个例子中,我们自定义了错误页面,显示导航栏和可滚动的错误信息:

// 重写 ErrorWidget 的builder,显示地优雅一些
ErrorWidget.builder = (FlutterErrorDetails details) {
  print('错误widget详细的错误信息为:' + details.toString());

  return MaterialApp(
    title: 'Error Widget',
    theme: ThemeData(
      primarySwatch: Colors.red,
    ),
    home: Scaffold(
      appBar: AppBar(
        title: Text('Widget渲染异常!!!'),
      ),
      body: _createBody(details),
    ),
  );
};

运行效果如下所示:

custom_error_widget.png

比起之前触目惊心的红色错误页面,自定义的看起来优雅一些,当然也可以找UI帮忙设计更友好的界面。需要注意的是,ErrorWidget.builder 方法提供了一个参数 details 用于表示当前的错误上下文,为避免用户直接看到错误信息,这里我们并没有将它展示到界面上。但是,我们不能丢弃掉这样的异常信息,需要提供统一的异常处理机制,用于后续分析异常原因。

为了集中处理框架异常,Flutter 提供了 FlutterError 类,这个类的 onError 属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。

在下面的代码中,我们使用 Zone 提供的 handleUncaughtError 语句,将 Flutter 框架的异常统一转发到当前的 Zone 中,这样我们就可以统一使用 Zone 去处理应用内的所有异常了:

// framework异常捕获,转发到当前的 Zone
FlutterError.onError = (FlutterErrorDetails details) async {
    Zone.current.handleUncaughtError(details.exception, details.stack);
};
异常上报

到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。

三方,我们一般都是用bugly。如果公司有自研的bug系统,那就更好了。

这些异常上报,我们将使用MethodChannel推送给Native,由Native上报到bugly或自研的异常系统。

这里只展示Dart的代码实现,至于Native怎么实现Channel,自行Google即可

Dart实现

代码如下:

/// flutter exception channel
class SYExceptionReportChannel {
  static const MethodChannel _channel =
      const MethodChannel('sy_exception_channel');

  // 上报异常
  static reportException(dynamic error, dynamic stack) {
    print('捕获的异常类型 >>> : ${error.runtimeType}');
    print('捕获的异常信息 >>> : $error');
    print('捕获的异常堆栈 >>> : $stack');

    Map reportMap = {
      'type': "${error.runtimeType}",
      'title': error.toString(),
      'description': stack.toString()
    };

    // 得使用这个
    print('这是通过convert转的json');
    print(jsonEncode(reportMap));

    _channel.invokeListMethod('reportException', reportMap);
  }
}

我们捕获到的异常后,由channel推送给Native,包含三个信息:

  • 异常的类型信息
  • 异常的简要说明信息(即error的toString的值)
  • 异常的堆栈信息
优化、封装及问题点

综合上述的阐述,我们将代码做一些封装和优化。

  • 优化:异常捕获后,在debug和release的模式下是不一样的处理,debug模式,直接打印到控制台是最直观的,release模式下,无法感知哪里出了问题,所以我们需要上报,然后分析问题。

区分当前是debug还是release,有一个比较巧妙的方式,代码及注释如下:

// 比较巧妙的一种方式判定是否是debug模式
static bool get isInDebugMode {
    bool inDebugMode = false;
    // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
    assert(inDebugMode = true);
    return inDebugMode;
}

基于上述的思路,我们将未捕获的异常转发到zone做一个判断:

// framework异常捕获,转发到当前的 Zone
    FlutterError.onError = (FlutterErrorDetails details) async {
      // debug模式
      if (ExceptionReportUtil.isInDebugMode) {
        // 打印到控制台
        FlutterError.dumpErrorToConsole(details);

        // release模式
      } else {
        // 转发到zone
        Zone.current.handleUncaughtError(details.exception, details.stack);
      }
    };
  • 封装:main函数中的代码,自然是越简练越好,但将未捕获的异常转发到zone及错误Widget重写必须放在main中,所以抽取一个工具类ExceptionReportUtil:
/// 工具类
class ExceptionReportUtil {
  // 比较巧妙的一种方式判定是否是debug模式
  static bool get isInDebugMode {
    bool inDebugMode = false;
    // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
    assert(inDebugMode = true);
    return inDebugMode;
  }

  // 初始化异常捕获配置
  static void initExceptionCatchConfig() {
    // framework异常捕获,转发到当前的 Zone
    FlutterError.onError = (FlutterErrorDetails details) async {
      // debug模式
      if (ExceptionReportUtil.isInDebugMode) {
        // 打印到控制台
        FlutterError.dumpErrorToConsole(details);

        // release模式
      } else {
        // 转发到zone
        Zone.current.handleUncaughtError(details.exception, details.stack);
      }
    };

    // 重写 ErrorWidget 的builder,显示地优雅一些
    ErrorWidget.builder = (FlutterErrorDetails details) {
      print('错误widget详细的错误信息为:' + details.toString());

      return MaterialApp(
        title: 'Error Widget',
        theme: ThemeData(
          primarySwatch: Colors.red,
        ),
        home: Scaffold(
          appBar: AppBar(
            title: Text('Widget渲染异常!!!'),
          ),
          body: _createBody(details),
        ),
      );
    };
  }

  // 创建错误widget body
  static Widget _createBody(dynamic details) {
    // 正确代码
    return Container(
      color: Colors.white,
      child: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            details.toString(),
            style: TextStyle(color: Colors.red),
          ),
        ),
      ),
    );
  }
}
  • 问题点:在runZonedGuarded函数的闭包中接收未捕获的异常,然后上报,如果执行该闭包中的代码发生异常,是无法捕获的:

代码及注释如下:

main(List args) {
  // 初始化Exception 捕获配置
  ExceptionReportUtil.initExceptionCatchConfig();

  runZonedGuarded(() {
    runApp(MyApp());
  }, (error, stackTrace) {
    // 这个闭包中发生的Exception是捕获不到的 @山竹
    SYExceptionReportChannel.reportException(error, stackTrace);
  }, zoneSpecification: ZoneSpecification(
    print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
      // 记录所有的打印日志
      parent.print(zone, "line是啥:$line");
    },
  ));
}

我们通过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,与我们在应用层的实现没太大关系,所以我们也无需过度担心。

后记

上述代码的DEMO,传送门

你可能感兴趣的:(Flutter异常捕获)