为了账号安全,请及时绑定邮箱和手机立即绑定

Flutter异常监控 - 贰 | 框架Catcher原理分析

前言

在给 Flutter 应用做异常监控的时候,一开始我是拒绝滴,如果不考虑 Flutter Engine 和 native 侧的监控,用我另一篇文章中[不得不知道的 Flutter 异常捕获知识点]提到的方法基本可以搞定所有 Dart 侧异常,关键代码也不多,复杂不到哪里去。如下(有不清楚原理的可以看下原文,这里就不赘叙了):

void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    Zone.current.handleUncaughtError(details.exception, details.stack);//Tag1
    //或customerReport(details);
  };

  //Tag2
  Isolate.current.addErrorListener(
      RawReceivePort((dynamic pair) async {
        final isolateError = pair as List<dynamic>;
        customerReport(details);
      }).sendPort,
    );

  runZoned(
    () => runApp(MyApp()),
    zoneSpecification: ZoneSpecification(
      print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
            report(line)
      },
    ),
    onError: (Object obj, StackTrace stack) {
      //Tag3
      customerReport(e, stack);
    }
  );
}

为什么会找到 Catcher,有三个原因:

  1. 纯粹是带着猎奇的心态想了解下这么简单的功能人家还能玩出花样来。
  2. 官方推荐 的 Sentry 最后还是会通过 MethodChannel 方式给到对端原生来报这种天生太依赖对端的行为我不太认同我想找一个纯 Dart 实现的库提高异常监控的可移植性。
  3. Catcher 简单读起来可以提高自信心。

Catcher 简介

我的理解 Catcher 有如下特征:

  1. 针对 Flutter 侧异常收集的一个纯 Dart 库,天然支持各种平台包括对 Web 侧的支持。
  2. 支持异常 UI 自定义显示及扩展,默认支持对话框,终端,或者页面形式等。
  3. 支持自定义异常的上报策略,默认支持异常到文件上传到网络,Sentry 等。
  4. 流程清晰简单。
main() {
  /// STEP 1. Create catcher configuration.
  /// Debug configuration with dialog report mode and console handler. It will show dialog and once user accepts it, error will be shown   /// in console.
  CatcherOptions debugOptions =
      CatcherOptions(DialogReportMode(), [ConsoleHandler()]);

  /// Release configuration. Same as above, but once user accepts dialog, user will be prompted to send email with crash to support.
  CatcherOptions releaseOptions = CatcherOptions(DialogReportMode(), [
    EmailManualHandler(["support@email.com"])
  ]);

  /// STEP 2. Pass your root widget (MyApp) along with Catcher configuration:
  Catcher(rootWidget: MyApp(), debugConfig: debugOptions, releaseConfig: releaseOptions);
}
  1. 通过 CatcherOptions 创建两个配置,一个 debug,一个 release。
  2. 将配置设置到 Catcher 对象中即可完成异常上报和监控。

效果展示图:

Untitled.png

如果设置了 ConsoleHandler , 日志输出如下:

I/flutter ( 7457): [2019-02-09 12:40:21.527271 | ConsoleHandler | INFO] ============================== CATCHER LOG ==============================
I/flutter ( 7457): [2019-02-09 12:40:21.527742 | ConsoleHandler | INFO] Crash occured on 2019-02-09 12:40:20.424286
I/flutter ( 7457): [2019-02-09 12:40:21.527827 | ConsoleHandler | INFO]
I/flutter ( 7457): [2019-02-09 12:40:21.527908 | ConsoleHandler | INFO] ------- DEVICE INFO -------
I/flutter ( 7457): [2019-02-09 12:40:21.528233 | ConsoleHandler | INFO] id: PSR1.180720.061
I/flutter ( 7457): [2019-02-09 12:40:21.528337 | ConsoleHandler | INFO] androidId: 726e4abc58dde277
I/flutter ( 7457): [2019-02-09 12:40:21.528431 | ConsoleHandler | INFO] board: goldfish_x86
I/flutter ( 7457): [2019-02-09 12:40:21.528512 | ConsoleHandler | INFO] bootloader: unknown
I/flutter ( 7457): [2019-02-09 12:40:21.528595 | ConsoleHandler | INFO] brand: google
I/flutter ( 7457): [2019-02-09 12:40:21.528694 | ConsoleHandler | INFO] device: generic_x86
I/flutter ( 7457): [2019-02-09 12:40:21.528774 | ConsoleHandler | INFO] display: sdk_gphone_x86-userdebug 9 PSR1.180720.061 5075414 dev-keys
I/flutter ( 7457): [2019-02-09 12:40:21.528855 | ConsoleHandler | INFO] fingerprint: google/sdk_gphone_x86/generic_x86:9/PSR1.180720.061/5075414:userdebug/dev-keys
I/flutter ( 7457): [2019-02-09 12:40:21.528939 | ConsoleHandler | INFO] hardware: ranchu
I/flutter ( 7457): [2019-02-09 12:40:21.529023 | ConsoleHandler | INFO] host: vped9.mtv.corp.google.com
I/flutter ( 7457): [2019-02-09 12:40:21.529813 | ConsoleHandler | INFO] isPsychicalDevice: false
I/flutter ( 7457): [2019-02-09 12:40:21.530178 | ConsoleHandler | INFO] manufacturer: Google
I/flutter ( 7457): [2019-02-09 12:40:21.530345 | ConsoleHandler | INFO] model: Android SDK built for x86
I/flutter ( 7457): [2019-02-09 12:40:21.530443 | ConsoleHandler | INFO] product: sdk_gphone_x86
I/flutter ( 7457): [2019-02-09 12:40:21.530610 | ConsoleHandler | INFO] tags: dev-keys
I/flutter ( 7457): [2019-02-09 12:40:21.530713 | ConsoleHandler | INFO] type: userdebug
I/flutter ( 7457): [2019-02-09 12:40:21.530825 | ConsoleHandler | INFO] versionBaseOs:
I/flutter ( 7457): [2019-02-09 12:40:21.530922 | ConsoleHandler | INFO] versionCodename: REL
I/flutter ( 7457): [2019-02-09 12:40:21.531074 | ConsoleHandler | INFO] versionIncremental: 5075414
I/flutter ( 7457): [2019-02-09 12:40:21.531573 | ConsoleHandler | INFO] versionPreviewSdk: 0
I/flutter ( 7457): [2019-02-09 12:40:21.531659 | ConsoleHandler | INFO] versionRelase: 9
I/flutter ( 7457): [2019-02-09 12:40:21.531740 | ConsoleHandler | INFO] versionSdk: 28
I/flutter ( 7457): [2019-02-09 12:40:21.531870 | ConsoleHandler | INFO] versionSecurityPatch: 2018-08-05
I/flutter ( 7457): [2019-02-09 12:40:21.532002 | ConsoleHandler | INFO]
I/flutter ( 7457): [2019-02-09 12:40:21.532078 | ConsoleHandler | INFO] ------- APP INFO -------
I/flutter ( 7457): [2019-02-09 12:40:21.532167 | ConsoleHandler | INFO] version: 1.0
I/flutter ( 7457): [2019-02-09 12:40:21.532250 | ConsoleHandler | INFO] appName: catcher_example
I/flutter ( 7457): [2019-02-09 12:40:21.532345 | ConsoleHandler | INFO] buildNumber: 1
I/flutter ( 7457): [2019-02-09 12:40:21.532426 | ConsoleHandler | INFO] packageName: com.jhomlala.catcherexample
I/flutter ( 7457): [2019-02-09 12:40:21.532667 | ConsoleHandler | INFO]
I/flutter ( 7457): [2019-02-09 12:40:21.532944 | ConsoleHandler | INFO] ---------- ERROR ----------
I/flutter ( 7457): [2019-02-09 12:40:21.533096 | ConsoleHandler | INFO] Test exception
I/flutter ( 7457): [2019-02-09 12:40:21.533179 | ConsoleHandler | INFO]
I/flutter ( 7457): [2019-02-09 12:40:21.533257 | ConsoleHandler | INFO] ------- STACK TRACE -------
I/flutter ( 7457): [2019-02-09 12:40:21.533695 | ConsoleHandler | INFO] #0      ChildWidget.generateError (package:catcher_example/file_example.dart:62:5)
I/flutter ( 7457): [2019-02-09 12:40:21.533799 | ConsoleHandler | INFO] <asynchronous suspension>
I/flutter ( 7457): [2019-02-09 12:40:21.533879 | ConsoleHandler | INFO] #1      ChildWidget.build.<anonymous closure> (package:catcher_example/file_example.dart:53:61)
I/flutter ( 7457): [2019-02-09 12:40:21.534149 | ConsoleHandler | INFO] #2      _InkResponseState._handleTap (package:flutter/src/material/ink_well.dart:507:14)
I/flutter ( 7457): [2019-02-09 12:40:21.534230 | ConsoleHandler | INFO] #3      _InkResponseState.build.<anonymous closure> (package:flutter/src/material/ink_well.dart:562:30)
I/flutter ( 7457): [2019-02-09 12:40:21.534321 | ConsoleHandler | INFO] #4      GestureRecognizer.invokeCallback (package:flutter/src/gestures/recognizer.dart:102:24)
I/flutter ( 7457): [2019-02-09 12:40:21.534419 | ConsoleHandler | INFO] #5      TapGestureRecognizer._checkUp (package:flutter/src/gestures/tap.dart:242:9)
I/flutter ( 7457): [2019-02-09 12:40:21.534524 | ConsoleHandler | INFO] #6      TapGestureRecognizer.handlePrimaryPointer (package:flutter/src/gestures/tap.dart:175:7)
I/flutter ( 7457): [2019-02-09 12:40:21.534608 | ConsoleHandler | INFO] #7      PrimaryPointerGestureRecognizer.handleEvent (package:flutter/src/gestures/recognizer.dart:315:9)
I/flutter ( 7457): [2019-02-09 12:40:21.534686 | ConsoleHandler | INFO] #8      PointerRouter._dispatch (package:flutter/src/gestures/pointer_router.dart:73:12)
I/flutter ( 7457): [2019-02-09 12:40:21.534765 | ConsoleHandler | INFO] #9      PointerRouter.route (package:flutter/src/gestures/pointer_router.dart:101:11)
I/flutter ( 7457): [2019-02-09 12:40:21.534843 | ConsoleHandler | INFO] #10     _WidgetsFlutterBinding&BindingBase&GestureBinding.handleEvent (package:flutter/src/gestures/binding.dart:180:19)
I/flutter ( 7457): [2019-02-09 12:40:21.534973 | ConsoleHandler | INFO] #11     _WidgetsFlutterBinding&BindingBase&GestureBinding.dispatchEvent (package:flutter/src/gestures/binding.dart:158:22)
I/flutter ( 7457): [2019-02-09 12:40:21.535052 | ConsoleHandler | INFO] #12     _WidgetsFlutterBinding&BindingBase&GestureBinding._handlePointerEvent (package:flutter/src/gestures/binding.dart:138:7)
I/flutter ( 7457): [2019-02-09 12:40:21.535136 | ConsoleHandler | INFO] #13     _WidgetsFlutterBinding&BindingBase&GestureBinding._flushPointerEventQueue (package:flutter/src/gestures/binding.dart:101:7)
I/flutter ( 7457): [2019-02-09 12:40:21.535216 | ConsoleHandler | INFO] #14     _WidgetsFlutterBinding&BindingBase&GestureBinding._handlePointerDataPacket (package:flutter/src/gestures/binding.dart:85:7)
I/flutter ( 7457): [2019-02-09 12:40:21.535600 | ConsoleHandler | INFO] #15     _rootRunUnary (dart:async/zone.dart:1136:13)
I/flutter ( 7457): [2019-02-09 12:40:21.535753 | ConsoleHandler | INFO] #16     _CustomZone.runUnary (dart:async/zone.dart:1029:19)
I/flutter ( 7457): [2019-02-09 12:40:21.536008 | ConsoleHandler | INFO] #17     _CustomZone.runUnaryGuarded (dart:async/zone.dart:931:7)
I/flutter ( 7457): [2019-02-09 12:40:21.536138 | ConsoleHandler | INFO] #18     _invoke1 (dart:ui/hooks.dart:170:10)
I/flutter ( 7457): [2019-02-09 12:40:21.536271 | ConsoleHandler | INFO] #19     _dispatchPointerDataPacket (dart:ui/hooks.dart:122:5)
I/flutter ( 7457): [2019-02-09 12:40:21.536375 | ConsoleHandler | INFO]
I/flutter ( 7457): [2019-02-09 12:40:21.536539 | ConsoleHandler | INFO] ======================================================================

Catcher 设计思路

Untitled 1.png

Catcher 流程图。

如上整个流程:

  1. 应用运行过程中产生了 Error,这些 Error 被 Catcher 捕捉到构造成新的对象 Report。
  2. Report 被发送给了 Reporter,Reporter 会决定对 Report 的处理策略:取消还是接受。
  3. 如果接受 Report,那么 Report 会交给 handers 继续处理直至完成。

1. Catcher 异常捕获时机与 Report 构造

这里可以盲猜下,如上步骤 1 其实相当于前言中的个人基础版本代码,负责收集 Error 过程。看下 Catcher 收集 Error 的代码三个关键点分别如下,基本跟我们代码处理是一样的。

runZonedGuarded

Untitled 2.png

Isolate.current.addErrorListener

Untitled 3.png

FlutterError.onError

Untitled 4.png

Report 构造

void _reportError(
    dynamic error,
    dynamic stackTrace, {
    FlutterErrorDetails? errorDetails,
  }) async {
    //.....

    final Report report = Report(
      error,
      stackTrace,
      //额外添加字段如下:
      DateTime.now(),
      _deviceParameters,
      _applicationParameters,
      _currentConfig.customParameters,
      errorDetails,
      _getPlatformType(),
      screenshot,
    );

2. Reporter 接收和决策 Report

从上面步骤中我们知道,关心的 error 和 stackTrace 被包装到了 Report 中,我们主要关注 Report 流向即可跟踪主流程。这里说下为啥不直接处理 error 和 stackTrace 搞个包装类 Report。因为将异常保持到本地或者服务器后台中我们免不了要添加额外数据方便定位问题,比如机型信息,应用信息和平台等信息,能更加有效的还原 error 出现的场景。

看源码可以发现找不到一个叫做 Reporter 的对象,那么这个对象为啥要接收和决策 Report 呢?它想干嘛?Reporter 对象其实是 ReportMode 对象及其子类,ReportMode 是具有显示和决策 Report 对象的能力,接收 Report 就是为了显示,决策就是可以取消继续处理 Report 或者继续处理它。说白了就是一个给用户可查看异常的视图接口。

//这个类主要作用
//1. 呈现异常堆栈不同UI给用户操作:比如是以对话框,还是以页面,还是以通知栏,还是以终端日志
//2. 其他设置都是为显示1中UI服务的,比如当前UI是什么语言显示,当前UI出现是否需要上下文等。
abstract class ReportMode {
  late ReportModeAction _reportModeAction;
  LocalizationOptions? _localizationOptions;

  // ignore: use_setters_to_change_properties
  /// Set report mode action.
  void setReportModeAction(ReportModeAction reportModeAction) {
    _reportModeAction = reportModeAction;
  }

  /// Code which should be triggered if new error has been caught and core
  /// creates report about this.
  ///该方法下就会实现对应的UI,如弹框就会在这里弹出来。
  void requestAction(Report report, BuildContext? context);

  /// On user has accepted report
  ///这个会被上述UI中类似”接收”的按钮统一调用
  void onActionConfirmed(Report report) {
    _reportModeAction.onActionConfirmed(report);
  }

  /// On user has rejected report
  ///这个会被上述UI中类似”取消”的按钮统一调用
  void onActionRejected(Report report) {
    _reportModeAction.onActionRejected(report);
  }

  /// Check if given report mode requires context to run
  ///当前模式下UI是否需要上下文支持。即Context
  bool isContextRequired() {
    return false;
  }

  ///...
}

Untitled 5.png

ReportMode 子类

从上面不难看出,为什么 Catcher 可以支持异常多种 UI 显示效果都是 ReportMode 的功劳,你可以扩展它让它实现你想要的样式。这里涉及一个常规是设计思想,抽象。 因为需求是呈现不一样的 UI,有对话框样式,有通知栏样式,还有页面样式,这几个样式里面相同的就是接收同样的 Report 数据,公共的接收和拒绝按钮。于是相同东西可以被抽到父类中,于是有了 requestAction,onActionConfirmed 和 onActionRejected 的行为。

认识上面 ReportMode 关键的 UI 接口,继续主流程:

void _reportError(
    dynamic error,
    dynamic stackTrace, {
    FlutterErrorDetails? errorDetails,
  }) async {

    //...
    final Report report = Report(
      error,
      stackTrace,
      //....
     );

    //...
    if (reportMode.isContextRequired()) {
      if (_isContextValid()) {
        reportMode.requestAction(report, _getContext());
      } else {
        _logger.warning(
          "Couldn't use report mode because you didn't provide navigator key. Add navigator key to use this report mode.",
        );
      }
    } else {
      reportMode.requestAction(report, null);
    }
  }

上面 Report 构造完之后流向了 Reporter(也就是 ReportMode), 这里注意下 isContextRequired()和_isContextValid(), 这两个方法的作用:你在 UI 显示的时候是不是需要上下文呢,buildContext,比如 dialog 方式显示的时候,page 显示的时候,有才能显示出来。但是如果你不打算显示在 UI 上,只是显示在终端上,你就不需要 context 了,这就是 ReportMode 设计这两个方法的作用。

那么问题来了,这个 Context 到底如何设置的呢? 答案是通过 Catcher 中可选参数navigatorKey 其中流程比较简单可以自行查看源码。

Untitled 6.png

如果用户设置了 DialogReportMode 之后,呈现出来的就是上面效果,用户点击 Cancel 就没后文了,点击 Accept 就会继续把当前 Report 流传下去。

来看看下一个接力对象。

3. ReportHandler:默默承受下所有的人


  void onActionConfirmed(Report report) {
    ///...
    for (final ReportHandler handler in _currentConfig.handlers) {
      _handleReport(report, handler);
    }
  }

  void _handleReport(Report report, ReportHandler reportHandler) {
    reportHandler
        .handle(report, _getContext())
        .catchError((dynamic handlerError) {
      _logger.warning(
        "Error occurred in ${reportHandler.toString()}: ${handlerError.toString()}",
      );
    }).then((result) {

    }).timeout(

    );
  }

点击了步骤 2 中的接收,最后会到 Catcher 的 onActionConfirmed, 这里 Report 会被 CatcherOptions 中提供的 handlers 列表中每个元素依次处理。Catcher 会日志中打印出相关的处理结果和超时等。

/// Handlers that should be used
  final List<ReportHandler> handlers;

/// Builds catcher options instance
  CatcherOptions(
    this.reportMode,
    this.handlers, //...);

这里重点说下 ReportHandler 的设计跟哪个有关? 没错,就是你为所欲为的上报策略,你可以报给后台,也可以只是显示在控制台,也可以存储到文件。

/// 主要作用是用来处理report的,比如这个report是保持到文件还是上传到服务器,还是显示在终端。
abstract class ReportHandler {
  ///Logger instance
  late CatcherLogger logger;

  /// Method called when report has been accepted by user
  ///上报处理结果,比如上传到服务器或者保持到文件,成功会返回true,失败返回false
  Future<bool> handle(Report error, BuildContext? context);

  /// Get list of supported platforms
  List<PlatformType> getSupportedPlatforms();

  ///Location settings
  LocalizationOptions? _localizationOptions;

  /// Get currently used localization options
  LocalizationOptions get localizationOptions =>
      _localizationOptions ?? LocalizationOptions.buildDefaultEnglishOptions();

  // ignore: use_setters_to_change_properties
  /// Set localization options (translations) to this report mode
  void setLocalizationOptions(LocalizationOptions? localizationOptions) {
    _localizationOptions = localizationOptions;
  }

  /// Check if given report mode requires context to run
  bool isContextRequired() {
    return false;
  }

  /// Check whether report mode should auto confirm without user confirmation.
  bool shouldHandleWhenRejected() {
    return false;
  }
}

Untitled 7.png

ReportHander 子类

很容易看到,我们可以支持上报 Report 到哪里,你甚至可以通过 SentryHandler 报到 Sentry 后台,通过 HttpHandler 报到自己家后台。从 ReportHandler 定义知道,其实这些上报策略的关键点就在 Future handle(Report error, BuildContext? context) 的不同实现。无非就是对 Report error 参数的一个转换过程不同而已,你想报到 Sentry 就直接把我们的 error 转换成 Sentry Sdk 支持的实体类格式,你想把 Error 报到自己后台就转换成自己后台支持格式用 http 来 post。

总结

读完 Catcher 了解其中核心原理,可以回答前言中几个问题了,Catcher 代码实现确实简单,掰着手指你都知道 Catcher,Reportmode,ReportHander CatcherOption 其他类都可以干掉丝毫不影响整个框架正常运行。对 reportmode 和 reporthandler 的开闭原则设计上堪称无敌。

如果从工作量上来说的话前言里面的个人基础版本只能算完成了监控的 1/3 ,还有 2/3 的工作没做,只能算刚刚开始而已,所以有时候真的是你眼中的完美在大佬面前只是井底视野。。。

设计模式

继承和多态:Reportmode 和它的子类们,reportHandler 和它的子类们 都是通过多态来让程序更有弹性。

遇到的问题

上传到 Sentry 后发现堆栈不打印业务相关的行数。解决办法如下:

优点

  1. 整个流程连贯清晰,reportMode 和 reportHandler,CacherOptions 三个关键对象符合开闭原则,扩展性强。
  2. CatcherOptions 中的字段设计精细,考虑到了不同需求场景,比如支持指定异常的 Handler 处理,支持忽略某些指定异常,支持增加异常日志添加额外信息,支持屏蔽掉设备信息中敏感字段,感觉作者考虑得好细。
  3. 支持异常存储到文件和上传到网络,支持传输到其他知名 flutter 后台,如 Sentry 等。

缺点

  1. 异常处理和上传过程在 main 线程中,对处理和上报操作都做了时间间隔限制进行去重和丢弃处理。是否可以将其放到子线程中。
  2. 超时处理的 report 未序列化到数据库中,以备后续上传,上传都是一次性的。
  3. Report 包装过程太固定无法自定义,比如我需要自定义设备信息的获取过程这样就需要修改源码了。
  4. 没有考虑 Flutter engine 和 Native 异常的扩展处理情况,虽然他们不属于 Flutter Error 的范围。
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消