解決在 Flutter 使用 Catcher 時遇到 WidgetsFlutterBinding 初始化的問題

最近引入 Catcher 來搜集 app 發生的錯誤訊息。按照 github 上的範例修改了 main function,初始化 Catcher:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  Catcher(
    debugConfig: CatcherOptions(SilentReportMode(), [
      ConsoleHandler(
        enableApplicationParameters: false,
        enableDeviceParameters: false,
        enableCustomParameters: false,
        enableStackTrace: true,
      ),
    ]),
    releaseConfig: CatcherOptions(
      SilentReportMode(),
      [
        HttpHandler(
          HttpRequestType.post,
          Uri.parse('https://x.y.z'),
          enableApplicationParameters: true,
          enableCustomParameters: false,
          enableDeviceParameters: true,
          enableStackTrace: false,
        ),
      ],
    ),
    runAppFunction: initApp,
    navigatorKey: myRootNavKey,
  );
}

Future<void> initApp() async {
  /*
    此處省略一萬個 sdk 初始化步驟 .......
  */

  runApp(const RootApp());
}

結果運行時 console 顯示錯誤:

flutter: [2023-12-29 20:47:21.680324 | Catcher | INFO] Setup localization lazily!
flutter: [2023-12-29 20:47:21.694254 | Catcher | INFO] ============================ CATCHER LOG ============================
flutter: [2023-12-29 20:47:21.694391 | Catcher | INFO] Crash occurred on 2023-12-29 20:47:21.687161
flutter: [2023-12-29 20:47:21.694430 | Catcher | INFO]
flutter: [2023-12-29 20:47:21.694508 | Catcher | INFO] ---------- ERROR ----------
flutter: [2023-12-29 20:47:21.694635 | Catcher | INFO] Zone mismatch.
The Flutter bindings were initialized in a different zone than is now being used. This will likely cause confusion and bugs as any zone-specific configuration will inconsistently use the configuration of the original binding initialization zone or this zone based on hard-to-predict factors such as which zone was active when a particular callback was set.
It is important to use the same zone when calling `ensureInitialized` on the binding as when calling `runApp` later.
To make this warning fatal, set BindingBase.debugZoneErrorsAreFatal to true before the bindings are initialized (i.e. as the first statement in `void main() { }`).
flutter: [2023-12-29 20:47:21.694679 | Catcher | INFO]
flutter: [2023-12-29 20:47:21.694899 | Catcher | INFO] ------- STACK TRACE -------
flutter: [2023-12-29 20:47:21.695005 | Catcher | INFO] #0      BindingBase.debugCheckZone.<anonymous closure> (package:flutter/src/foundation/binding.dart:503:29)
flutter: [2023-12-29 20:47:21.695041 | Catcher | INFO] #1      BindingBase.debugCheckZone (package:flutter/src/foundation/binding.dart:508:6)
flutter: [2023-12-29 20:47:21.695076 | Catcher | INFO] #2      runApp (package:flutter/src/widgets/binding.dart:1093:18)
flutter: [2023-12-29 20:47:21.695120 | Catcher | INFO] #3      initApp (package:arena/main.dart:225:3)
flutter: [2023-12-29 20:47:21.695152 | Catcher | INFO] <asynchronous suspension>
flutter: [2023-12-29 20:47:21.695183 | Catcher | INFO] #4      main.<anonymous closure>.<anonymous closure> (package:arena/main.dart:108:37)
flutter: [2023-12-29 20:47:21.695213 | Catcher | INFO] <asynchronous suspension>
flutter: [2023-12-29 20:47:21.695270 | Catcher | INFO]
flutter: [2023-12-29 20:47:21.695330 | Catcher | INFO] ======================================================================
flutter: [2023-12-29 20:47:21.695767 | Catcher | INFO] Report result: true

一番 google 後發現,這是因為 WidgetsFlutterBinding.ensureInitialized()runApp 在不同的 zone 裡。

在 stackoverflow 上看到一篇相關:How do I fix this Sentry Zone mismatch error?。這篇的解法是:在 main 中運行 runZonedGuarded。然後在 runZonedGuarded 運行 WidgetsFlutterBinding.ensureInitialized()、Sentry 初始化以及runApp

但是這個方式不適合 Catcher (或是說不適合用在我的 app 裡),因為創建 Catcher 時一定要指定 runAppFunction 或者 rootWidget 其中任一個參數。然而在我的 app 裡,runApp 前有很多初始化工作,我希望一併也用 Catcher 接收錯誤訊息。所以我用不了 rootWidget 參數;而用 runAppFunction 就會遇到這個問題。

Catcher 是有 ensureInitialized 這個參數。它的說明是:should Catcher run WidgetsFlutterBinding.ensureInitialized() during initialization。但我設定了也沒用。

看了 Flutter 官方的文件:“Zone mismatch” message。其中下面有一個程式片段:

void main() {
  var myValue = Mutable<double>(0.0);
  Zone.current.fork(
    zoneValues: {
      'myKey': myValue,
    }
  ).run(() {
    WidgetsFlutterBinding.ensureInitialized();
    var newValue = ...; // obtain value from plugin
    myValue.value = newValue; // update value in Zone
    runApp(...);
  });
}

讓我忽然有了一個靈感。

解決方法

解決方法就是:在 runAppFunction 內使用 main 的 zone 的 run 去運行 runApp 即可。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final zone = Zone.current;

  Catcher(
    debugConfig: CatcherOptions(SilentReportMode(), [
      ConsoleHandler(
        enableApplicationParameters: false,
        enableDeviceParameters: false,
        enableCustomParameters: false,
        enableStackTrace: true,
      ),
    ]),
    releaseConfig: CatcherOptions(
      SilentReportMode(),
      [
        HttpHandler(
          HttpRequestType.post,
          Uri.parse('https://x.y.z'),
          enableApplicationParameters: true,
          enableCustomParameters: false,
          enableDeviceParameters: true,
          enableStackTrace: false,
        ),
      ],
    ),
    runAppFunction: () async => await initApp(zone),
    navigatorKey: myRootNavKey,
  );
}

Future<void> initApp(Zone rootZone) async {
  /*
    此處省略我的相關 sdk 初始化步驟
  */

  rootZone.run(() => runApp(const RootApp()));
}

似乎直接在 initApp 裡面使用 Zone.root.run() 也是可以的。只是不確定這個方式會不會有問題。

這樣就解決了。

Reference