Flutter異常監控 - 貳 | 框架Catcher原理分析

聽蟬發表於2022-12-31

前言

在給 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 錯誤 - 掘金](https://juejin.cn/post/684490...),這裡說下基本使用。

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:默默承受下所有的人

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

Untitled 7.png

ReportHander 子類

很容易看到,我們可以支援上報 Report 到哪裡,你甚至可以透過 SentryHandler 報到 Sentry 後臺,透過 HttpHandler 報到自己家後臺。從 ReportHandler 定義知道,其實這些上報策略的關鍵點就在 Future<bool> 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 的範圍。
歡迎搜尋公眾號:【碼裡特別有禪】 裡面整理收集了最詳細的Flutter進階與最佳化指南。關注我,獲取我的最新文章~

參考連結

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/post/684490...)

本文由mdnice多平臺釋出

相關文章