🚗

CarPlay/AndroidAuto起動時にFlutterEngineをBackgroundで動作させる

2023/03/04に公開

はじめに

アプリがCarPlay/AndroidAutoによってバックグランド起動されたときにFlutterEngineを動作させる方法をまとめています。

背景

CarPlay/AndroidAuto対応のアプリをFlutter開発していました。CarPlay/AndroidAutoは、接続時にアプリが終了していてもアプリ/サービスをバックグランドで起動してくれます。

Flutterの実装(Riverpod・GraphQLなどによるビジネスロジックや通信処理)をCarPlay/AndroidAutoでのバックグランド起動時でも動作するものと思っていましたが、検証時にFlutterの実装が全く動作しないことがわかりました。

なぜバックグランド起動で通常のFlutterEngineが動かないのか?

iOS

CarPlayのCPWindowはCarPlay Sceneで管理されます。そのため、SceneDelegate対応が必須です。調査した結果、このSceneDelegateによってFlutterEngineが起動していませんでした。

通常のアプリ起動では、UIWindowSceneDelegateがまず実行されます。FlutterEngineは、FlutterViewControllerによって動きますが、そのViewControllerはそのSceneDelegateのUIWindowSceneからwindow?.rootViewControllerへ割り当てられています(個別に作成してアタッチも可能ですが細かい話は省略します)。そして、CarPlayに接続するとCPTemplateApplicationSceneDelegateが追加で実行されます。

一方で、CarPlayでバックグランド起動されたケースでは、CPTemplateApplicationSceneDelegateのみ実行されます。 通常のアプリ起動で実行されるUIWindowSceneDelegateが実行されません。

よって、UIWindowSceneは生成されないのでFlutterViewControllerによるFlutterEngineが動きません。

これがFlutterEngineが動かない主な要因です。

ただ他にも要因があります。詳しいことは検討した解決策 を参照してください。

Andriod

AndroidAutoは、CarAppServiceによって管理されます。AndroidAutoが接続するとCarAppServiceが起動します。

一方で、AndroidAutoによるバックグランド起動時はCarAppServiceのみ立ち上がり、FlutterActivityが起動しません。

よって、FlutterActivityのFlutterEngineが動きませんでした。

解決策

試行錯誤の結果、FlutterEngineを動作させることができました。以下が解決策です。

  1. バックグランド起動時に、Callback dispatcherを利用してmain関数と別のエントリーポイントを用意します。
  2. iOS/Androidでやり方が異なりますが、ともにHeadless FlutterEngine(以後、Headless engine)をこのエントリーポイントに対して作成・起動します。
  3. このエントリーポイントでは、バックグランド実行の権限制約を加味した起動シーケンスで実装します。

以上の方法で、CarPlay/AndroidAutoのバックグランド起動時にFlutterのコードを実行できるようになります。バックグランド起動後にアプリを開いても、通常のFlutterEngineが動作してアプリを利用できます。

バックグランドプロセスの起動フロー

Callback dispatcherの登録

まず、通常起動時に、バックグランド起動用Headline engineのエントリーポイントを登録します。

PluginUtilities.getCallbackHandleに、@pragma('vm:entry-point')を宣言した関数を渡します。

サンプルコードは以下です。

('vm:entry-point')
void callbackDispatcher() {
  WidgetsFlutterBinding.ensureInitialized();

  const MethodChannel _callbackChannel =
      MethodChannel(fleetCallbackChannelName);
  _callbackChannel.setMethodCallHandler((call) async {
    switch (call.method) {
      case "setupBackgroundService":
        {
          debugPrint("called setupBackgroundService");
          BackgroundService.instance.setupBackgroundService(call.arguments);
          break;
        }
    }
}

class BackgroundService {
 final _mainChannel = const MethodChannel(mainChannelName);
  ...
  Future<void> register() async {
    final callback = PluginUtilities.getCallbackHandle(callbackDispatcher);
    if (callback != null) {
      await _mainChannel.invokeMethod(
          'registerCallbackDispatcherHandle', callback.toRawHandle());
    } else {
      debugPrint("Could not initialize the callback dispatcher");
    }
  }
  ...
  void setupBackgroundService(String envKey, String userId) {
    debugPrint("FleetMethodChannel.startBackgroundService $envKey $userId");
    final env = Environments.fromName(envKey);
    _setupContainer(env, userId);
  }
}

Headless engineの生成と実行

iOS

CarPlaySceneDelegate.swift
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                  didConnect interfaceController: CPInterfaceController) {
        // Check the application launched in background by carplay
        if UIApplication.shared.connectedScenes.subtracting([templateApplicationScene]).isEmpty {
            if UIApplication.shared.applicationState == .background {
                FlutterBackgroundService.shared.startBackgroundService()
            }
        }
        ...
    }
    ...
}
FlutterBackgroundService.swift
class FlutterBackgroundService {
    ...
    func startBackgroundService() -> Bool {
        guard let handle = callbackDispatcherHandle else {
            // Not registered the callback handle
            return false
        }
        guard let info = FlutterCallbackCache.lookupCallbackInformation(handle) else {
            // failed to find the callback information
            return false
        }

        // Run the headless engine
        let engine = FlutterEngine(
            name: "com.example.flutter_background_service",
            project: nil,
            allowHeadlessExecution: true
        )
        defer { headlessEngine = engine }

        engine.run(withEntrypoint: info.callbackName, libraryURI: info.callbackLibraryPath)

        establishMainChannel(with: engine.binaryMessenger)
        establishCallbackChannel(with: engine.binaryMessenger)

        return true
    }
    ...
}

Android

class AndroidAutoService : CarAppService() {
    ....
    override fun onCreateSession(): Session {
        Log.d(TAG, "onCreateSession() called")

        FleetMethodChannel.startBackgroundService(this)

        return NavigationSession()
    }
}
class FlutterBackgroundService {
    ...
    fun startBackgroundService(context: Context) {
        flutterPrefernces =
            context.getSharedPreferences(FLUTTER_SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)

        if (headlessEngine == null) {
            val callbackHandle = flutterPrefernces.getLong(PREF_KEY_CALLBACK_DISPATCHER_HANDLE, 0)
            if (callbackHandle == 0L) {
                Log.e(TAG, "no callback registered")
                return
            }
            val flutterLoader = FlutterLoader().apply {
                startInitialization(context)
                ensureInitializationComplete(context, arrayOf())
            }

            val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)

            if (callbackInfo == null) {
                Log.e(TAG, "failed to find callback info")
                return
            }

            val args = DartExecutor.DartCallback(
                context.getAssets(),
                flutterLoader.findAppBundlePath(),
                callbackInfo
            )

            headlessEngine = FlutterEngine(context).apply {
                getDartExecutor().executeDartCallback(args)
            }
        }

        headlessEngine?.also {
            establishMainChannel(it)
            establishCallbackChannel(it)
        }
    }
    ...
}

参考にした記事[1]ではFlutterMainを使っていましたが、これはすでにDeprecatedになっていました。代わりに、FlutterLoader()を使うようにしました。

FlutterLoaderの取得に、FlutterInjector.instance().flutterLoader()を使った例も多数見受けられましたが、これはテスト用に作られたクラスであり、本来適切な使い方ではありません。また、実装を読んでもFlutterLoaderの直接の初期化と同等であることが確認できました。

ポイントは以下でLoaderをセットアップすることです。

startInitialization(context)
ensureInitializationComplete(context, arrayOf())

これはflutterJNIの初期化を行うためです。実施しないとFlutterEngineが起動しません。
JNIを通じてFlutterの実行コードをやりとりしているんですね。

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "mainChannelInitialized" -> {
                onInitialized()
                result.success(true)
            }
            "registerCallbackDispatcherHandle" -> {
                if (call.arguments is Long) {
                    callbackDispatcherHandle = call.arguments as Long
                    Log.d(TAG, "$LOG_PREFIX:  registered the callback dispatcher handle")
                    result.success(true)
                } else {
                    Log.d(TAG, "callback handler is not found")
                    result.success(false)
                }
            }
            "callbackChannelInitialized" -> {
                setupBackgroundService()
                result.success(true)
            }
	    ...
        }
    }

Callback method channelでの2way handshake

ネイティブでmain/callback method channelを初期化した状態ではFlutterEngineと疎通ができていません。

そのため、main/callback method channelを疎通させるためには、必ずcallback channelを用いた2 way handshakeが必要です。つまり、setMethodHandleを設定した後に適当なイベント送信しそれに応答しないといけません。(前述のシーケンス図を参照してください)

よって、通常main関数で行うような処理はcallback method channelnのmethod callで実装することになります。

('vm:entry-point')
void callbackDispatcher() {
  WidgetsFlutterBinding.ensureInitialized(); // Required

  const MethodChannel _callbackChannel =
      MethodChannel(fleetCallbackChannelName);
  _callbackChannel.setMethodCallHandler((call) async {
    switch (call.method) {
      case "setupBackgroundService":
        {
          debugPrint("called setupBackgroundService");
          BackgroundService.instance.setupBackgroundService(call.arguments);
          break;
        }
    }
  _callbackChannel.invokeMethod("callbackChannelInitialized");
}

class BackgroundService {
  ...
  void setupBackgroundService(dynamic arguments) {
      ...
      final container = ProviderContainer();
      ...
      _mainChannel.setMethodCallHandler(_mainChannelHandler);
      _mainChannel.invokeMethod("mainChannelInitialized");
  }
}

上記のフローを踏むと、通常のmain関数でmain method channelを疎通した後と同じようにmain method channelを使用できます。

Headless engineの停止

Headless engineと通常のFlutter engineが両方動いてほしくないので、通常のFlutter engineが動作するときは、Headless engineを停止させます。

iOS

SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard
            let _ = scene as? UIWindowScene,
            let flutterViewController = window?.rootViewController as? FlutterViewController
        else { return }
        GeneratedPluginRegistrant.register(with: flutterViewController)
        BackgroundService.shared.stopBackgroundService()
        FleetMethodChannel.shared.register(with: flutterViewController)
    }
}
FlutterBackgroundService.swift
class FlutterBackgroundService {
    ...
    func stopBackgroundService() {
        // Stop the background service
        headlessEngine?.destroyContext()
        headlessEngine = nil
        mainChannel = nil
        callbackChannel = nil
    }
    ...
}

Android

MainActivity.kt
class MainActivity: FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        FleetMethodChannel.stopBackgroundService()
        FleetMethodChannel.register(flutterEngine, this)
    }
}
FlutterBackgroundService.kt
class FlutterBackgroundService {
    ...
    fun stopBackgroundService() {
        // Stop the background service
        headlessEngine?.destroy()
        mainChannel = null
        callbackChannel = null
    }
    ...
}

注意事項

この解決策における問題は、Headless engineは、通常のengineと別のIsolateであり、メモリによるデータ共有はできない点です。そのため、File/Server等を使ってデータの共有が必要になります。

さらに、iOSのバックグランド処理は非常に制限が厳しく、なぜかFlutterからだとApp Sandbox内のDocument/Preferenceでさえアクセスができません。よって、ネイティブ実装でそれらにアクセスする必要があります。

今回はデータの同期にshared_preference packageを使い、iOS/AndroidのネイティブからUserDefaults/SharedPreferencesで直接取得するようにしました。

ちなみにこのファイルアクセスの制限は各処でエラーを発生させます。

例えば、graphql_flutterです。本アプリでは、通信処理にGraphQLを使っており、graphql_flutter packageを使っていました。こちらが利用しているHiveは、InMemoryStoreでないと動きません。さらにinitHiveStore()を実行すると、Documentフォルダのパスを取得しに行くのでエラーになってしまいます。

検討した解決策

iOS

実は、AppDelegateでFlutterEngineを初期化し、UIWindowSceneでFlutterViewControllerにアタッチすることで、CarPlayでバックグランド起動時にFlutter Engineを動作させるはできます。

しかし、これには欠点があります。アプリをフォアグランドにすると画面がブラックアウト、つまり全く描画されないのです。アプリを再起動すると復帰します。これはCarPlayでバックグランド起動されたとき、ファイルアクセス制限によって、main関数が正常に動作せず、画面が描画されないことが原因でした。

そのため、別のエントリーポイントを用意して実行内容を修正することで、前述のアクセス制限を回避する必要がありました。

余談

Callback dispatcherについて

実は、iOSにおいてこちらの登録は必須ではありません。というのも、ここでcallbackDispatcher()への参照ポインター(のようなもの?)を登録しているわけですが、この値はHeadless engineを動かすときに必要なcallbackNamelibraryURIの値を取得するためです。(Androidは、FlutterCallbackInformationのコンストラクターがprivateのためにできません)

これらの値は単なる文字列なので登録処理をしなくても固定文字列を与えることで呼び出すことはできます。

Adding a Flutter screen to an iOS app > Launch options > Dart library
Why file not found when I invoke runWithEntrypoint:libraryURI: on a FlutterEngine?

しかし、callbackDispatcher()の関数名、そして記述されたファイル名やパッケージ名に依存してしまうので、こちらのステップを踏む方が良いでしょう。

Riverpodについて

これまで解説してきた解決策は、RiverpodがWidget treeに紐づいていないことが重要な要件になっています。

もしWidget treeが必須になっていたら、Providerで実装した通信周りの処理は全く再利用できませんでした。

RiverpodがFlutterから独立しているからこそ取ることができた解決策なのです 👏

リファレンス

脚注
  1. Executing Dart in the Background with Flutter Plugins and Geofencing ↩︎

Discussion