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. 流程清晰简单。

中文介绍详见[译] 使用 Catcher 处理 Flutter 错误 - 掘金,这里说下基本使用。

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对象中即可完成异常上报和监控。

效果展示图:

如果设置了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设计思路

Catcher流程图。

如上整个流程:

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

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

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

runZonedGuarded

Isolate.current.addErrorListener

FlutterError.onError

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;
  }

  ///...
}

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 其中流程比较简单可以自行查看源码。

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

来看看下一个接力对象。

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

@override
  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;
  }
}

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后发现堆栈不打印业务相关的行数。解决办法如下:

https://github.com/jhomlala/catcher/pull/225

优点

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

缺点

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

参考链接

Report errors to a service | Flutter

jhomlala/catcher: Flutter error catching & handling plugin. Handles and reports exceptions in your app!

[译] 使用 Catcher 处理 Flutter 错误 - 掘金

本文参与 腾讯云自媒体分享计划 ,欢迎热爱写作的你一起参与!

本文分享自作者个人站点/博客:https://juejin.cn/user/272334612863431

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
Flutter异常监控 | 框架Catcher原理分析
在给Flutter应用做异常监控的时候,一开始我是拒绝滴,如果不考虑Flutter Engine和native侧的监控,用我另一篇文章中不得不知道的Flutte...
<<上一篇
下一篇>>