☎️

Flutter Headless EngineによるバックグラウンドVoIPプッシュ通知処理

に公開

Flutter Headless EngineによるバックグラウンドVoIPプッシュ通知処理

はじめに

株式会社IVRyでソフトウェアエンジニアをしているamemiyaです。

私たちは「アイブリー」というFlutter製のVoIP通話アプリを開発しています。VoIP(Voice over Internet Protocol)は、従来の電話回線ではなくインターネット回線を使って音声データを送受信する技術です。VoIP通話機能の実装にはTwilioを使用していますが、Twilio公式のFlutter SDKは存在しないため、Android SDKとiOS SDKをラップしたFlutterパッケージを内製しています。

ここで課題となったのが、バックグラウンドでの着信通知処理です。通話アプリという性質上、ユーザーがアプリを閉じている状態でも着信通知を受け取り、即座に着信画面を表示する必要があります。しかし、ネイティブ側でプッシュ通知を受け取った時点では、Dartコードを実行するための通常のFlutter Engineはバックグラウンドでは起動できません。とはいえ、ユーザーの利便性を高める登録済みの電話帳情報の表示や企業名表示といったビジネスロジックはFlutterで共通化したい。この矛盾をどう解決するかが大きな課題でした。

そこで、Flutter Headless Engine(以下、Headless Engine) を活用して、バックグラウンドでもDartコードを実行できる仕組みを実装しました。本記事では、Headless Engineを使った実装方法と、AndroidとiOSでのアプローチの違い、そして実際の運用で得られた効果について解説します。

なぜ通常のFlutter Engineが起動していないのか、起動できないのか

Flutter Engineのライフサイクル

少し深掘りしていくと、以下のような流れになります。

まず、フォアグラウンド時は通常のFlutter Engineが起動しており、UIを描画しています。この時点では通常のFlutter Engineで処理できるため、Headless Engineは不要です。

次に、ユーザーがアプリを閉じると(タスクキルまたはスワイプで削除)、Flutter Engineも終了します。

そして、バックグラウンドでプッシュ通知を受信した時点では、Flutter Engineは既に終了しています。しかし、Dartコードを実行する必要があるため、ここでHeadless Engineが必要になります。

バックグラウンドでのプッシュ通知受信の仕組み

OSは、アプリが閉じられていてもプッシュ通知を受信し、ネイティブコードを起動できます。しかし、この時点で実行されるのはネイティブコードのみです。

Android

// FirebaseMessagingService(ネイティブコード)が起動される
class VoiceFirebaseMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        // この時点でFlutter Engineは起動していない
    }
}

iOS

// PKPushRegistryDelegate(ネイティブコード)が呼ばれる
func pushRegistry(_ registry: PKPushRegistry, 
                 didReceiveIncomingPushWith payload: PKPushPayload) {
    // この時点でFlutter Engineは起動していない
}

通常のFlutter Engineのバックグラウンドでの制約

通常のFlutter Engineには以下の特徴があります。

  1. UIと密接に結びついている
    • Activity(Android)やViewController(iOS)が必要
  2. UIレンダリング機能を含む
    • 画面描画のための重い処理が含まれる
  3. OS制約
    • バックグラウンドではUIを表示できない

つまり、バックグラウンド処理にはUIレンダリング機能を持たない、軽量なエンジンが必要です。

Flutter Headless Engineとは

Flutter Headless Engineとは、UI(Widget)を伴わないFlutter Engineのインスタンスです。Dartコードを実行できますが画面描画は行わず、バックグラウンド専用のDart実行環境として動作します。通常のFlutter Engineとは別のプロセス・インスタンスとして起動されます。

公式パッケージでの使用例

実は、よく使われているFlutter packageもHeadless Engineを内部的に使用しています。

firebase_messaging

Firebase Cloud Messaging(FCM)を使ってプッシュ通知を受信するための公式パッケージです。
Flutter側で書いたコードが内部的にHeadless Engineで実行されます。

https://github.com/firebase/flutterfire/blob/firebase_messaging-v16.0.4/packages/firebase_messaging/firebase_messaging/example/lib/main.dart#L47-L55

以下は、firebase_messagingパッケージの内部実装を説明用に簡略化したコードです。

内部実装(Android - Kotlin)

出典: FlutterFirebaseMessagingBackgroundService.java

// firebase_messaging パッケージ内部
class FlutterFirebaseMessagingBackgroundService : JobIntentService() {
    
    override fun onHandleWork(intent: Intent) {
        // 1. FlutterEngineを作成
        val flutterEngine = FlutterEngine(applicationContext)
        
        // 2. DartExecutorを取得
        val dartExecutor = flutterEngine.dartExecutor
        
        // 3. コールバックハンドルを取得
        val callbackHandle = intent.getLongExtra(CALLBACK_HANDLE_KEY, 0)
        val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
        
        // 4. Dartコールバックを実行
        val args = DartExecutor.DartCallback(
            applicationContext.assets,
            FlutterInjector.instance().flutterLoader().findAppBundlePath(),
            callbackInfo
        )
        dartExecutor.executeDartCallback(args)
        
        // 5. メッセージデータをDart側に渡す
        // Method Channelを通じて通信
    }
}

内部実装(iOS - Objective-C)

出典: FLTFirebaseMessagingPlugin.m

// firebase_messaging パッケージ内部
class FLTFirebaseMessagingPlugin {
    
    func application(_ application: UIApplication, 
                    didReceiveRemoteNotification userInfo: [AnyHashable : Any],
                    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        
        // 1. FlutterEngineを作成
        let flutterEngine = FlutterEngine(name: "background_isolate")
        
        // 2. コールバックハンドルを取得
        let callbackHandle = UserDefaults.standard.object(forKey: "callback_handle") as! Int64
        let callbackInfo = FlutterCallbackCache.lookupCallbackInformation(callbackHandle)
        
        // 3. Dartコールバックを実行
        flutterEngine.run(
            withEntrypoint: callbackInfo?.callbackName,
            libraryURI: callbackInfo?.callbackLibraryPath
        )
        
        // 4. メッセージデータをDart側に渡す
        // Method Channelを通じて通信
        
        completionHandler(.newData)
    }
}

workmanager

定期的なバックグラウンドタスクを実行するためのパッケージです。
以下はタスクを登録するサンプルコードです。

('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    // 定期的なバックグラウンド処理
    await syncData();
    return Future.value(true);
  });
}

void main() {
  Workmanager().initialize(callbackDispatcher);
  Workmanager().registerPeriodicTask(
    "1",
    "syncTask",
    frequency: Duration(hours: 1),
  );
  runApp(MyApp());
}

workmanagerfirebase_messagingと同様の仕組みで、内部的にHeadless Engineを起動してDartコールバックを実行しています。

動作の仕組み

前述したパッケージのコードと被る箇所がありますが、Headless Engineは以下の流れで動作します。

アプリ起動時にコールバックを登録

void main() {
  // コールバック関数のハンドル(数値ID)を取得
  final handle = PluginUtilities.getCallbackHandle(callbackDispatcher);
  
  // ネイティブ側に保存(SharedPreferencesやUserDefaults)
  await saveCallbackHandle(handle.toRawHandle());
  
  runApp(MyApp());
}

PluginUtilities.getCallbackHandle()は、Dart関数を一意に識別する数値IDを返します。このIDをネイティブ側(SharedPreferencesやUserDefaults)に保存します。

バックグラウンドでエンジンを起動

// 1. FlutterEngineインスタンスを作成
val headlessEngine = FlutterEngine(applicationContext)

// 2. 保存しておいたコールバックハンドルを取得
val callbackHandle = preferences.getLong("callback_handle", 0)

// 3. ハンドルからコールバック情報を取得
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)

// 4. Dartコールバックを実行
val args = DartExecutor.DartCallback(assets, bundlePath, callbackInfo)
headlessEngine.dartExecutor.executeDartCallback(args)

Method Channelで通信

Headless Engineとネイティブコードは、通常のMethod Channelなどで通信できます。

// ネイティブ → Dart
methodChannel.invokeMethod("onHandleIncomingCall", arguments)

// Dart → ネイティブ
result.success(data)

Headless Engineの利点・欠点

利点

バックグラウンドで即座にDartで処理できる

最大の利点は、バックグラウンドでDartコードを実行できることです。

我々のVoIPアプリでは、着信通知を受け取った瞬間に、着信画面に表示する情報を準備し、着信画面を表示し、通話処理を開始するといった一連の処理を即座に行う必要があります。これらの処理をFlutterで書きたい、かつバックグラウンドで即座に実行したいという要求を満たせます。

コードベースの統一

Android/iOSで共通のDartコードを書けます。
以下は電話を発信した人の情報を取得しているコードです。
この処理をDart側で書くことでコードの分散を防げます。

// この関数はAndroid/iOS両方で使える
Future<CallerInfo> fetchCallerInfo(String phoneNumber) async {
  final response = await getCallerInfo(phoneNumber);
  return CallerInfo.fromJson(response.data);
}

公式にサポートされている

firebase_messagingworkmanagerなど、多くの公式パッケージで実績があります。

欠点

起動時間のオーバーヘッド

通常のFlutter Engineと比べて軽量とは言えど、Headless Engineの起動には時間がかかります。Flutter frameworkの初期化、Firebaseの初期化、パッケージの読み込み、そしてメモリ使用量の増加といった要因により、通常、数百ms〜数秒程度の起動時間が必要です。

デバッグの難しさ

バックグラウンドで実行されるため、デバッグが困難です。ブレークポイントが使いにくく、ログに頼る必要があります。また、バックグラウンド特有の問題は再現が難しい場合があります。

Top-level関数/Static関数の制約

Headless Engineでは、DIやStateの共有が困難です。

// Top-level関数
('vm:entry-point')
Future<void> backgroundHandler(RemoteMessage message) async {
  // ...
}

プラットフォーム固有のセットアップが必要

Android/iOS それぞれでネイティブコードのセットアップが必要です。

我々のトレードオフ判断(Androidの場合)

Flutterで処理を共通化することで開発が簡単になるという点を重視しました。

起動時間のオーバーヘッドはありますが、Android/iOSで共通のコードを書ける利点の方が遥かに大きいと判断しました。

VoIPアプリにおける実装方法

ここまで一般的なHeadless Engineの話をしてきましたが、実際のアイブリーアプリではAndroidではHeadless Engineを使用してバックグラウンドでDartを実行し、iOSでは通常のFlutter Engineを使用してアプリを起動しフォアグラウンドで処理します。なぜこの違いがあるのか、それぞれ見ていきます。

Android側の実装(Headless Engine使用)

ここでは、前述した「動作の仕組み」をVoIPアプリに適用した実装例を示します。

全体の流れ

まずアプリ起動時にDartコールバックを登録します。その後、バックグラウンドでネイティブ側がEngineを起動し、保存したコールバックを実行します。最後にMethod Channelでデータを受け渡します。

Dart側のバックグラウンドハンドラ

VoIP通話処理のためのバックグラウンドハンドラは、以下のような構造になります。

// Top-level関数として定義
('vm:entry-point')
void callbackDispatcher() {
  // プラグインの初期化
  // Method Channelを通じてネイティブ側からイベントを受け取る
  initializePlugin();
}

重要なポイント @pragma('vm:entry-point')アノテーションを付けることで、Dart AOTコンパイル時にコードが削除されないようにします。また、Top-level関数として定義する必要があります。

Android側でHeadless Engineを起動

class FirebaseMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        if (remoteMessage.data.isNotEmpty()) {
            if (!isApplicationForeground(this)) {
                // バックグラウンドの場合、Headless Engineを起動
                startBackgroundEngine(remoteMessage)
            } else {
                // フォアグラウンドの場合の処理
                ...
            }
        }
    }

    private fun startBackgroundEngine(remoteMessage: RemoteMessage) {
        val applicationContext: Context = this.applicationContext

        // 1. FlutterLoaderの初期化
        val flutterLoader = FlutterLoader().apply {
            startInitialization(applicationContext)
            ensureInitializationComplete(applicationContext, arrayOf())
        }

        // 2. Headless Engine作成
        val headlessEngine = FlutterEngine(applicationContext)
        
        // 3. プラグインに通知データを渡す
        val plugin = headlessEngine.plugins.get(YourPlugin::class.java) as? YourPlugin
        plugin?.setRemoteMessage(remoteMessage)

        // 4. 保存されたcallbackHandleを取得
        val callbackHandle = LocalPreferences(applicationContext).getCallBackHandle()

        // 5. Dartコールバックを実行
        val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
        val args = DartExecutor.DartCallback(
            applicationContext.assets,
            flutterLoader.findAppBundlePath(),
            callbackInfo
        )
        headlessEngine.dartExecutor.executeDartCallback(args)
    }
}

ポイント解説

  1. FlutterLoaderの初期化
    • Flutterアプリのアセット(DartコードのAOTスナップショット)を読み込む
  2. Headless Engine作成
    • FlutterEngine(applicationContext)で作成(Activityを渡さないのがポイント)
  3. プラグインへのデータ受け渡し
    • Headless Engineにも通常のEngineと同様にプラグインがアタッチされる
  4. コールバックハンドル取得
    • アプリ起動時に保存したハンドルを取得
  5. Dartコールバック実行
    • executeDartCallback()で実際にDartコードを実行

iOS側の実装(通常のFlutter Engine使用、Headless Engineなし)

なぜHeadless Engineを使わないのか

iOSでは、VoIPプッシュに厳しい要件があります。

iOS 13以降の要件 VoIPプッシュを受信したら、即座にCallKitで着信UIを表示する必要があります。表示しない場合、システムによってVoIPプッシュが遮断され、アプリがクラッシュとして扱われます。
Headless Engineの起動には数百ms〜数秒かかる可能性があり、実際にエラーが発生しており要件を満たせなかったため採用していません。

VoIPプッシュ受信

class PKPushRegistryDelegateObject: NSObject, PKPushRegistryDelegate {
    
    func pushRegistry(_ registry: PKPushRegistry, 
                     didReceiveIncomingPushWith payload: PKPushPayload, 
                     for type: PKPushType, 
                     completion: @escaping () -> Void) {
        if (type == PKPushType.voIP) {
            self.delegate?.onIncomingPushReceived(payload: payload, completion: completion)
        }
    }
}

Twilio SDKに渡してCallKitを表示

func handleRemoteMessage(payload: PKPushPayload, completion: (() -> Void)?) {
    // Twilio SDKにプッシュペイロードを渡す
    // これにより即座にCallKitが表示される
    TwilioVoiceSDK.handleNotification(
        payload.dictionaryPayload,
        delegate: self,
        delegateQueue: nil
    )
    
    completion?()
}

まとめ

Headless Engineを選んだ理由(Androidの場合)

Androidで Headless Engineを選んだ理由は3つあります。まず、通常のFlutter Engineはバックグラウンドで起動できないというOS制約があります。次に、Flutterで処理を共通化することで開発が簡単になり、Android/iOSで同じDartコードを書けます。そして、バックグラウンドでFlutterのロジックを実行できる唯一の方法であり、公式にサポートされている手法です。

iOSでHeadless Engineを使わない理由

iOSでHeadless Engineを使わない理由も3つあります。まず、VoIPプッシュの厳しいタイミング要件があり、iOS 13以降は即座にCallKitを表示する必要があります。次に、アプリが起動されて通常のFlutter Engineが使えるようになるため、VoIPプッシュによりアプリがフォアグラウンドで起動します。そして、処理速度も通常のFlutter Engineで十分であり、そもそもフォアグラウンドなのでHeadless Engineは不要です。

実際の運用での効果

Headless Engine(Android)と通常のFlutter Engine(iOS)、それぞれのプラットフォームに最適な実装を選択した結果、ビジネスロジックをDartで共通化しながら、プラットフォーム固有の要件も満たすという理想的な構成を実現できました。

API通信、着信状態の管理といったビジネスロジックはすべてDartで実装されており、Android/iOS両方で同じコードが動作します。これにより、バグ修正や新機能追加がDartで完結し、両OSに反映されるようになりました。テストやコードレビューも1箇所で済む場合が多いため、開発・保守の効率が向上しています。

We are hiring!

IVRyでは「イベントや最新ニュース、募集ポジションの情報を受け取りたい」「会社について詳しく話を聞いてみたい」といった方に向けて、キャリア登録やカジュアル面談の機会をご用意しています。ご興味をお持ちいただけた方は、ぜひ以下のページよりご登録・お申し込みください。

https://ivry-jp.notion.site/209eea80adae800483a9d6b239281f1b


参考リソース

IVRyテックブログ

Discussion