通知のボタンを押すと即クラッシュ、ログは無言 — 犯人は 2 つ目の FlutterEngine だった

| 学び | プラットフォーム

タグ: #Flutter #iOS #Swift #通知 #個人開発

通知にアクションボタンを追加したら、iOS でどのボタンを押しても即クラッシュ。しかもファイルログには 1 行も痕跡が残りません。犯人は flutter_local_notifications がバックグラウンドで起動する 2 つ目の FlutterEngine と、そこへのプラグイン登録コールバックの見落としでした。

どのボタンを押しても、即落ちる

開発中のアプリで、リマインダー通知にアクションボタンを追加しました。通知を長押しすると「完了にする」「後で通知し直す」といった操作が、アプリを開かずにその場でできる機能です。

Android では一発で動きました。ところが iOS では、どのボタンを押してもアプリが即クラッシュします。ボタンごとの処理は全部違うのに、全滅です。

ここで一つだけ分かりました。壊れているのは個々のアクションのロジックではない。経路そのものです。

ログに何も残らないクラッシュ

このアプリには、バックグラウンド動作の調査用にファイルへ書き出すロガーを仕込んであります。通知アクションを処理するハンドラの先頭でも、真っ先にログを書く実装にしていました。

ところが、クラッシュ後にログファイルを見ると1 行も残っていない。ハンドラの先頭行にすら到達していません。Dart のコードが 1 文字も実行される前に死んでいます。つまりネイティブ層です。

切り分けとして、通知の本体タップも試しました。アクションボタンではなく、通知自体をタップしてアプリを開く経路です。こちらは正常に動きます。

同じ通知なのに、本体タップは生きていて、アクションボタンだけが死ぬ。この 2 つの経路の違いを調べたことが、解決につながりました。

通知アクションは「2 つ目の FlutterEngine」で処理される

flutter_local_notifications のドキュメントを読み直しました。そこで、見落としていた仕様に気づきます。

アプリがバックグラウンドや終了状態のときに通知アクションが押されると、このプラグインは通常の UI 用エンジンとは別の FlutterEngine を headless で起動します。そして onDidReceiveBackgroundNotificationResponse に渡したトップレベル関数を、別 isolate で呼び出します。

Dart 側の標準形はこうです。

@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse response) {
  // 別 isolate で呼ばれる。UI の状態には一切アクセスできない
}

await flutterLocalNotificationsPlugin.initialize(
  initializationSettings,
  onDidReceiveNotificationResponse: onForegroundTap,
  onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);

@pragma('vm:entry-point') は必須のアノテーションです。AOT コンパイル時のツリーシェイキングで、この関数が消されないようにするためのものです。ここまではドキュメントどおりに書いてありました。問題はネイティブ側でした。

プラグイン未登録のエンジンは、Dart に到達する前に死ぬ

新しく起動された 2 つ目のエンジンには、普段メインエンジンに対して行われているプラグイン登録が自動では行われません。iOS では、AppDelegate でプラグイン登録用のコールバックを明示的に渡しておく必要があります。

import flutter_local_notifications

override func application(
  _ application: UIApplication,
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
  // バックグラウンド FlutterEngine が起動されたとき、
  // そのエンジンにプラグイン一式を登録するためのコールバック
  FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { registry in
    GeneratedPluginRegistrant.register(with: registry)
  }
  return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

これが無いままアクションボタンが押されると、プラグイン未登録のエンジンが起動します。そして Dart のエントリーポイントに到達する前に、ネイティブでクラッシュします。ログに何も残らなかったのは、このためです。

修正はこの数行だけでした。README にも書いてある、教科書どおりの 1 手です(先にドキュメントを最後まで読んでいれば、それで済んだ話です)。

なぜ見落としたか — 「見慣れた 1 行」が無かった

言い訳を分解しておきます。原因はプロジェクトの構成にありました。

従来の Flutter iOS テンプレートでは、didFinishLaunchingWithOptions の中に GeneratedPluginRegistrant.register(with: self) という 1 行があります。プラグイン登録という概念が、コード上に見えているわけです。

一方このアプリは、比較的新しい implicit engine 系のライフサイクル(FlutterImplicitEngineDelegate)を採用しています。メインエンジンへのプラグイン登録は、エンジン初期化後のデリゲートメソッド側で行われます。didFinishLaunchingWithOptions に、プラグイン登録の気配がありません。そこで「登録まわりはフレームワークがよしなにやってくれる」という思い込みが生まれていました。

実際にフレームワークが面倒を見るのは、メインエンジンだけです。プラグインが自前で起動する 2 つ目のエンジンは対象外でした。

別 isolate ならではの後始末

クラッシュが直った後も、この経路には設計上の注意点が残ります。バックグラウンドハンドラは別 isolate です。メイン isolate とはメモリを共有しません。

  • ハンドラ内で他のプラグインも使うなら、先頭で DartPluginRegistrant.ensureInitialized() を呼んで Dart 実装側のプラグイン初期化を済ませる(必要かどうかは、使うプラグインと isolate の起動経路による)
  • このアプリで使っていたローカル DB の監視方式では、別 isolate からの書き込みがメイン isolate 側で購読中のストリームに通知されない(マルチ isolate 対応の有無は DB ライブラリと監視方式による)。処理した対象の ID を SharedPreferences に積んでおき、次にアプリが前面化したタイミングでメイン isolate 側が再読込・再同期する
  • クラウド同期のような重い処理はこの経路では行わず、ローカル更新に絞る

「通知のボタンで完了にする」。一見、小さな機能です。実際には 2 つのエンジン・2 つの isolate をまたぐ分散処理になっている。それがこの仕組みの本質でした。

教訓

「ログに何も残らない」は、それ自体が情報です。 ハンドラ先頭のログすら出ないなら、その言語ランタイムに到達していません。調べる層を 1 つ下、この場合はネイティブに降ろす。その判断材料になります。

もう 1 つ。バックグラウンドで別エンジンを起動する系のプラグインでは、ネイティブ側の登録経路を必ず確認する。 flutter_local_notifications に限りません。位置情報系や workmanager など、headless 実行を持つプラグインには同種の登録手順があります。

そして、テンプレート由来の「見慣れた 1 行」が無い構成を採用するとき。その 1 行が担っていた責務がどこへ移ったのか、移った先が全ケースをカバーしているのかを、一度は確認しておくべきでした。11 行の修正にたどり着くまでの遠回りは、だいたい思い込みの中にあります。