Flutter add-to-appを用いた既存ネイティブアプリのフラター段階的移行
4月9日追記:会社のロゴとリンクを追加しました。
Flutter熱いですね! Flutter 2.0が少し前に発表されWeb, MacOS, Windows, Linuxなどでstableチャンネルでサポートされるようになりました。個人的にはGoogleが投資をさらに推し進めていくと思うのでフラターは拡がりこそすれしばらくは衰退はしないと思います。そこのプログラマーになりたいあなた、今学習するのであればフラターですよ!
さて年末から今年はじめにかけてとあるかなり大きい商業アプリにadd-to-appを使ってフラターを組み込むProof of Concept(以下PoC)を実装していました。その商業アプリはiOSではSwift、AndroidではKotlinを使われて書かれていてこのPoCでは将来的には全部をフラターで書くことは可能なのかを追求するために不確実性が高かったネイティブとフラターの境目を主に実装するという実証実験でした。(ネタバレをすると可能です。)
add-to-appはまだあまり使われている機能ではないので様々なドキュメンテーションでの瑕疵や記述漏れがあり結構苦労したので他のエンジニアの皆さんのために学びや注意点を以下に書き記します。
Add-to-appとは読んで字の如く既存のネイティブアプリにフラターを組み込む仕組みです。詳しくはフラターの公式ページをご覧ください。
* 昔の話ですが2020年3月ごろReactNativeのアプリにadd-to-appがFlutter 1.12で出た時試してみたのですがまだバグだらけだったのでまともに動作しませんでした。
さてメインの実装は公式ページを参考にしてもらうとしましてここでは色々試行錯誤の果てに採用したベストだと思った実装方法を紹介します。
ネイティブからフラター画面の遷移ポイントごとにFlutterEngineを作る
普通のフラターアプリはFlutterEngineが一つあって一つのFlutterViewController(iOS), もしくはFlutterActivity(Android)の上にアプリが描画されます。Add-to-appでは既存アプリフローにFlutterViewController/FlutterActivityなどを組み込むことになるのでネイティブアプリの中にいくつもフラターアプリが内包される形となります。ここで私はメモリーをケチって同じFlutterEngineを違うフラター画面で使いまわせないかと試してみましたが挙動がうまくいかなかったのでおとなしくフラターエントリー画面ごとにFlutterEngineを使った方が良さそうです。
FlutterEngineはこうやって作ります。
iOS
Android
作成したFlutterEngineの利用法は次のセクションをご覧ください。
UIアーキテクチャーではFlutterViewController(iOS), FlutterFragment(Android)一択
Flutter add-to-appの実装はiOSとAndroidでは微妙に違います。しかしせっかくなのでなるべく似せたアーキテクチャーにした方がメンテもしやすいと思います。
このPoCではネイティブのUITabBar(iOS)/Bottom Navigation(Android), UINavigationBar(iOS)/ToolBar(Android)などにフラター画面が内包されるのが絶対条件でしたのでネイティブと共存できるフラターUIが必要でした。結論として使うべきなのはiOSではFlutterViewController、AndroidではFlutterFragmentです。
iOSでは他に選択肢がないしガイドにも書かれてないのでFlutterViewControllerを使いましょう。iOSではUIViewControllerの中に別のUIViewControllerをchildViewControllerとして組み込めますので理論上は画面のどの部分をフラターにするのも自由自在です。z-indexも意図した通りになりますので収まりがいいです。
AndroidではFlutterActivity, FlutterFragment, ガイドにはないですがドキュメンテーションにはFlutterViewなどもあります。ずばりFlutterFragmentを使いましょう。これもiOSと同じようにFragmentの中にchildFragment入れられますので一番融通が利きます。ただiOSよりはもうちっと汗をかく必要があります。
以下はPoCで作った画面の概念図です。
UINavigationBar (iOS) / ToolBar (Android), UITabBar (iOS) / Bottom Navigation (Android) はネイティブです。右上のⓘボタンを押して出てくるアラートもネイティブです。セルをタップすると飛ぶべきURLをAPIから取得してブラウザーが開きます。その間フラターで描画されたスピナーが出ます。
このようなハイブリッド画面をいくつも作ると仮定して共通する処理はなるべくまとめた方がいいでしょう。
ネイティブ側のextension作成
iOSでは以下のようにUIViewControllerにextensionを作ります
Androidでも同様にFragmentにextensionを加えます
extensionで作成した関数を使用します
iOS
Android
対応するレイアウトxml
* 実はAndroidにおいてはBottom Navigationの真ん中にある出っ張ったボタンがフラターの下に描画されてしまうという問題がありました。それはフラターの画面の下の部分を透明にすることで対処しましたがコンテンツがたっぷりあって画面の下の方も埋まるのであれば違うやり方が必要となるでしょう。
データの受け渡し
上の概念図の画面はネイティブーフラター間で全くデータの受け渡しが必要ないという稀有な画面ですが他の多くの画面ははそうはいきません。フラターからネイティブ、ネイティブからフラターのmethod channelを作成する必要があります。
フラターからネイティブへデータを渡す場合はネイティブ側でチャンネルを開きメッセージが来たときの対応を定義します。この場合はフラター側でスイッチがあり変更時にネイティブでキャッチしたいと仮定します。
iOS
Android
逆の場合もあります。ネイティブ側でのカメラ権限などが変わった時にフラター側を再描画したいなどの場合はフラター側でメッセージの対応を定義します。
この辺はかなり端折っていますのでフラター公式をご参考ください。
他の大事なポイント
FlutterEngineにおいてはエントリーポイントは使わない。
Add-to-appの公式ページには長々とDart entrypointおよびFlutterEngine initialRouteの説明が書いてあったのですが全く動作しませんでした。というかiOSはコンパイルすらしなかったです。というわけで時間は無駄にしないでください。(Flutter2では未検証)
私はフラター側でMaterialAppのonGenerateRouteでrouteを定義してアニメーション遷移なしで画面をプッシュしてentrypointを模倣しました。
shared_preferencesはAndroid側とフラター側で違うファイルを使うので同じファイルから読み書きできない
このスタックオーバーフローの質問にありますがSharedPreferencesはAndroidネイティブ側とフラター側では全く違うファイルを使います。よってファイルを通したデータ共有などは無理です。method channelを使いましょう。
local_authの指紋認証はiOSでクラッシュする
local_authはGoogleによって書かれたプラグインなのですがクラッシュします。ファーストパーティーのプラグインでこのバグはひどいですね。しかもそれを直すプルリクエストは何ヶ月も前からあるというのにいまだにマージされてません。
この問題に対しては我が社のリポジトリーにlocal_authをフォークして修正を当てました。
iOSはビルドがよく失敗する
特にフラターアプリに新しいパッケージを追加した時によく起こります。これはべつに大きな問題ではないですがターミナルで
flutter clean
およびiOSアプリのディレクトリーで
rm -rf Pods
pod install
することが必要です。
パフォーマンス
デバッグビルドを使った時立ち上げ時にiPhone 7Sでは2秒ほどフラター遅かったです。Samsung Galaxy Flip Zでは違いが検知できなかったのでデバイスが良ければ無視できる可能性あり。リリースビルドで実測はしてません。
ちなみにReactNativeデバッグビルド使うとホーム画面が立ち上がるのにネイティブ5秒ReactNative60秒とか余裕でかかります。でもリリースだと格段に早いですのでひょっとしたらリリースビルドならフラターでも問題ないのかもしれません。
この実装における必須事項
この商業アプリには以下のハードウェアで提供されている機能が必要でした。それらはフラターでしっかり作れるのかどうかという検証も必要でした。
指紋および顔認証
前述の通りlocal_authのフォークを作成しiOS, Android共にできました。使い方もasync, await使えるのでネイティブよりだいぶ楽でした。
QRコードスキャン
qr_code_scannerのパッケージを使用しました。iOS、Android共に良好に動きます。コードに合致する座標が戻ってこないので若干UI変わりましたが問題ないです。permission_handlerでカメラの権限を取得する必要があります。実装時にはpermission_handlerはバグがあったのですがFlutter2以降に修正されました。権限はネイティブではコールバックで処理する必要があるのでasync、awaitを使えるのはロジックの単純化および可読性に貢献します。
総評
UIはネイティブからフラターに移行してもほぼ完全に同一のものができます。
add-to-appはまあまあよくできているので既存のネイティブアプリをフラターに徐々に移行するというのも選択肢の一つとなり得ると思います。
完全移行後はコードベースが一つですみますので当然メンテナンスコストの低減が予想されます。ロジックもprovider、async/awaitを使うことにより単純化できます。完全移行前でも新規フィーチャーをフラターで作るのであればフラターの方が格段にコードを書くのが簡単ですから新規採用エンジニアが戦力になるのに要する期間が短くなると思います。
Flutter側で問題となるのは(ファーストおよび)サードパーティーのプラグイン対応。プラグインとはデバイス側のハードウェアの機能を使うパッケージです。(カメラ、指紋認証など)場合によっては挙動が全く同じにはならないしバグやクラッシュなどもあり得ます。
あとはフラターは進化がかなり早いのでそれに対応してコード改変作業が多くなる可能性があります。とはいえFlutter 1.22から Flutter 2.0は多少作業が必要でした。firebase_analyticsのパッケージが数日しないと対応されない問題もありました。