在给 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,有三个原因:
我的理解 Catcher 有如下特征:
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); }
效果展示图:
如果设置了 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 流程图。
如上整个流程:
这里可以盲猜下,如上步骤 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, );
从上面步骤中我们知道,关心的 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 流传下去。
来看看下一个接力对象。
@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