こんにちは。 久々に朝の電車に乗りました。モバイルクライアントグループ テックリードの若宮(id:D_R_1009)です。
DroidKaigi 2022お疲れ様でした! 10月5日から7日にかけて、フルで楽しむことができました。 まさかお会いできるとは、という方なんかともお話しできて、とてもエネルギーに満ちています。
今回「Add-to-appの戦い方」と題しまして、スタディプラスで運用しているAdd-to-appの知見を発表しました。 本ブログでは、セッションの補足として、直面した課題に対応している方法などを紹介します。
Add-to-appの戦い方
Day 2の16:05〜16:30のセッションに登壇しました。 セッションの様子については、DroidKaigiのYoutubeチャンネル上で公開される予定です。
スタディプラスとFlutter、Add-to-app
セッション内で紹介している通り、スタディプラスはFlutterを比較的長く採用している企業です。
そして、2021年末から提供を始めた「Studyplusブック読み放題」にて、Add-to-appを本番環境で採用しました。
また、つい先日提供を開始した機能においても、Add-to-appを採用しています。
このような背景があるため、Add-to-appの採用や活用に関して、経験を積むことができました。 セッションでは、この経験をもとに、Add-to-appの"良い活用"について紹介しています。
Add-to-appのメリット、デメリット
Add-to-appを活用すると、既存のアプリケーションを維持したまま、AndroidとiOSに新機能を追加できます。 複数のプラットフォームに対して、少ない開発コストで、高速にリリースできる仕組みは非常に魅力的です。 こうした、開発速度と既存アプリケーションへの影響の少なさが、Add-to-appのメリットと言えます。
一方でFlutterを採用する以上に、Add-to-appを採用することは技術的に難しい挑戦をすることになります。 これが、Add-to-appを安易にお勧めできないデメリットです。
go_router
によるrouting
セッションで紹介した通り、Flutter Engineのキャッシュ利用はほぼ必須です。 このため、Add-to-appで開く画面を、Add-to-appの外部から制御する必要があります。
スタディプラスでは、この問題をMethod Channelとgo_routerで実現しています。
前提として、スタディプラスでは現在、Add-to-appで表示するべき画面が2つあります。
ここでは、仮に movie
と music
の2つの機能を表示する処理を書きます。
まず、下記のようなroutesを作ります。
final routes = [ GoRoute( path: '/movie', pageBuilder: (context, state) => MaterialPage( key: state.pageKey, child: const MovieScreen(), ), ), GoRoute( path: '/music', pageBuilder: (context, state) => MaterialPage( key: state.pageKey, child: const MusicScreen(), ), ), ];
続いて、このrouteを持つRouterをProvideし、Method Channelを実装するクラスが参照できるようにします。
final routerProvider = Provider( (_) => GoRouter( routes: routes, ), ); final moduleRepositoryProvider = Provider( (ref) => ModuleRepository( router: ref.watch(routerProvider), ), ); class ModuleRepository { ModuleRepository({ required this.router, }) { _channel.setMethodCallHandler(_handleMessage); } final _channel = const MethodChannel('com.example.app/module'); final GoRouter router; @override void dispose() { _channel.setMethodCallHandler(null); super.dispose(); } Future<dynamic> _handleMessage(MethodCall call) async { switch (call.method) { case 'route': router.go('${call.arguments}'); break; /// 他の処理を実装 } } }
あとは、Method Channelで外部から「今表示したい」画面をリクエストできるようにします。 Method Channelのやりとりを行うクラスに、下記のようなメソッドを追加するイメージです。
private fun navigateToMovie() { channel.invokeMethod("route", "/movie") } private fun navigateToMusic() { channel.invokeMethod("route", "/music") }
private func navigateToMovie() { methodChannel.invokeMethod("route", arguments: "/movie") } private func navigateToMusic() { methodChannel.invokeMethod("route", arguments: "/music") }
これらのメソッドを呼び出した後に、Add-to-appの画面へ遷移することで、表示したい画面を切り替えられます。 なおiOSの場合には、Method Channelへのリクエスト後にdelayが必要かもしれません。 Androidでは問題が起きにくいのですが、iOSでは画面の切り替え前に処理をした場合、1frame程度のdelayを入れないと反映が完了していないことがありました。 細かくOSの違いが影響するので、十分な動作チェックをしましょう。
SwiftPMによるxcframeworksの結合
Add-to-appにおいて、Flutterのモジュールをネイティブに読み込ませる方法はいくつかあります。 しかし、環境変数をAdd-to-appのコードの中で利用する場合、利用できる仕組みは限られてきます。
Add-to-appの運用において、一番厄介な問題は「検証環境と本番環境のリクエスト先サーバー切り替え」です。
Flutterにおける、debug
と release
はネイティブアプリケーションの文脈における「開発環境」と「本番環境」ではありません。
Flutterのコードをデバックするための、ビルドモードの違いと解釈した方が良いものです。
このため、Add-to-appのビルド時に debug
と release
のどちらかを選択することは、開発環境と本番環境のどちらを向いているのかと一致しません。
Flutterのアプリケーションの場合、この処理はビルド時の引数に dart-define
を指定することで解決します。
しかし、Add-to-appではこの処理が最近になって実装されたものであり、改善の余地がいくつかあるものになります。
今回は、iOSのプロダクトにおける dart-define
の扱い方を紹介します。
iOSでは flutter build ios-framework
を利用することで、dart-define
の指定ができます。
このため、導入ドキュメント的には、BかCのoptionを選択することになります。
しかし、iOSアプリケーションではSwift Package Managerの採用をおこなっていることも多く、CocoaPodsへの依存を強めたくはありません。 Option Cを選択すると、「Add-to-app内でも参照しているライブラリは、全てCocoaPodsで管理する」必要が生じます。 CocoaPodsはビルドに時間がかかりがちなため、すでにSwift Package Managerへ移行し切っているプロジェクトでは、少し悲しい気持ちになります。
そこで、今回はSwift Package Managerのbinary targetを利用する方法を紹介します。
まずAdd-to-appを、module_flutter
というディレクトリで開発します。
そして、Swift Package Managerとして認識できるように、Package.swift
ファイルを作成します。
/ Studyplus.xcodeproj /Studyplus /App /module_flutter Package.swift create_framework.sh pubspec.yaml pubspec.lock /lib
まず、frameworkを作成するための create_framework.sh
を次のように指定します。
#!/bin/sh -x rm -rfv build flutter pub get flutter build ios-framework --output=build --no-debug --no-profile
このようにすると、releaseビルドでbuildディレクトリ内に最新のframeworkが作成されるようになります。
ディレクトリが指定されているので、次のような設定を Package.swift
に記載します。
// swift-tools-version:5.3 import PackageDescription let items = [ "App", "firebase_auth", "firebase_core", // "FirebaseAuth", // "FirebaseCore", // "FirebaseCoreDiagnostics", // "FirebaseCoreInternal", "Flutter", "FlutterPluginRegistrant", "FMDB", // "GoogleDataTransport", // "GoogleUtilities", // "GTMSessionFetcher", // "nanopb", "shared_preferences_ios", ] let package = Package( name: "module_flutter", defaultLocalization: "en_us", platforms: [ .iOS(.v11) ], products: [ .library( name: "module_flutter", targets: items ), ], dependencies: [ .package(url: "https://github.com/firebase/firebase-ios-sdk.git", .exact("9.5.0")), ], targets: items.map({ name in Target.binaryTarget( name: name, path: "build/Release/\(name).xcframework" ) }) )
あとは、XcodeのSwift Package Managerにて、/module_flutter/Package.swift
を認識させればOKです。
なお、一部のライブラリ(FirebaseAuth
など)はネイティブアプリケーション側でも利用しているため、依存関係を再整理するためにコメントアウトしています。
重なっているライブラリがある場合、Swift Package Managerが複数の依存があるときに解決ができなくなるため、注意してください。
おわりに
簡単ではありますが、セッションについてコード的に紹介しました。 Add-to-appを採用することがあれば、ぜひ参考にしていただければと思っています。
Add-to-appは非常に便利なツールですが、上記のような工夫が必要になります。 これらの対応は参考にできる情報が少なく、手探りで検討が必要になりがちです。 Add-to-appのメリットとデメリットについて、本ブログが把握の助けになれば幸いです。