このたび STORES では STORES モバイルオーダー というサービスをリリースしました。
名前からも想像できるように、店頭から離れた場所からもスマートフォンでテイクアウト注文できる特徴をもったサービスです。
注文するお客様にとって、移動中に注文をして待ち時間を短縮できるとても利便性が良いサービスですが、お店にとっても行列を見てお客様が返ってしまう機会損失をなくしたり、電話を使わずに新規の注文を受け付けられるなどメリットがあります。
では、お客様が注文したオーダーをお店の方はどのように把握し、調理を開始するのでしょうか。また、調理が終わったら、どのように受取に来てほしいことをお客様に伝えるのでしょうか。
それに対するソリューションとして店舗の注文管理アプリ(以下、キッチンディスプレイといいます)を作りました。 新しいオーダー情報が届いたときに画面を見なくても音で知らせてくれたり、オーダー情報のステータスを変更することで、お客様にその進捗を伝えられます。
今日はそんなキッチンディスプレイの開発を担当したモバイルチーム(@error96num, @kogoto, @tomorrowkey)が、どのようにアプリを作ってきたかご紹介します。
技術選定
既存のアプリと比べて新しいアプリを作るときは、新しい技術を取り入れるチャンスがあります。キッチンディスプレイアプリを作るにあたってどのような技術を採用するかチームで議論をかさねました。
新しい技術を採用するにあたっての不安
新しい技術を採用できるのはとてもワクワクすることですが、長期にわたって保守していくことを考えると、ワクワクだけでは意思決定できません。技術が新しすぎて不安定だったり、今後保守やサポート、ライブラリなどの共有資産が増えそうかどうか、将来の見立てをする必要があります。
KMP/CMPを採用しました
一番大きな議論の的となったのは、開発フレームワークです。 iOS/Android 両方ともネイティブで開発されることが、それぞれのプラットフォームに最適な体験を提供できる手段ではありますが、キッチンディスプレイが持つ背景や、開発リソース、今後の技術投資に関しても議論があり、クロスプラットフォームも検討されました。
最終的には KMP(Kotlin Multiplatform) と CMP (Compose Multiplatform) を採用することが決まりました。*1
KMPの採用については採用事例を多く見かけるため、多くを語る必要はないですが、CMPは採用事例も少なく、思い切った意思決定になったのではないかと思います。
採用事例が少ない理由としては、Compose Multiplatform for iOSがまだAlpha版で、プロダクション向けの利用は推奨されていなかったことがあります。さすがにキッチンディスプレイでも見送りかなと思っていましたが、 技術選定している時期にちょうどAlpha版からBeta版に移行し、採用の可能性がでてきました。
さきにも書いたとおり、新しい技術を採用するには不安が伴いますが、KMP/CMPについては、その開発のスピード感や、そのために書き直されたといっても過言ではないKotlin 2.0のコンパイラの存在から、GoogleとJetBrainsの本気度が伝わってきたので、今後の開発に関しても一定の信頼感があると判断しました。
いきなり作れる?
KMP/CMPを使って開発しようという方向性はありますが、その知見を持っている人は少ないために、すべての不安を払拭させることは難しかったです。 これは、議論する人が情報の解像度が低い状態なので、意思決定に足る情報が足りない、といった状況です。 この状況を打破するためにプロトタイプを作りました。技術選定の章の内容と多少前後してしまう部分もありますが、次の章では、平行して進めたプロトタイプ作りについて紹介します。
プロトタイプ
プロトタイプの目的
このプロジェクトでは、Android版アプリのリリースを第一のマイルストーンとして設定していました。一方で、後にiOS版アプリをリリースする可能性も見込んでおり、トータルの開発コストを抑える方法を模索していました。
この課題に対処するために、以下の比較を行い、適切な技術選定を進めました:
- マルチプラットフォームを採用しない場合
- Android版のリリースまでにかかる工数 (A): 純粋なAndroidネイティブ開発に必要な工数。
- その後のiOS版のリリースまでにかかる工数 (B): Android版での実装を参考にしつつ、純粋なiOSネイティブ開発を行う工数。
- Android版およびiOS版を継続的に開発していく工数 (C): 両プラットフォームを個別に開発・メンテナンスする工数。
- マルチプラットフォームを採用する場合
- Android版のリリースまでにかかる工数 (A'): 共通コードやプラットフォーム固有のコードを構築しながら、KMPやCMPを用いてAndroid向けに開発する工数。
- その後のiOS版のリリースまでにかかる工数 (B'): 共通コードを活用しつつ、iOS固有部分を追加で実装する工数。
- Android版およびiOS版を継続的に開発していく工数 (C'): 共通コードを活用しながら、両プラットフォームを開発・メンテナンスする工数。
マルチプラットフォームを採用することで B' < B
および C' < C
となることが見込まれました。一方で、A' > A
となることは確実であり、共通コードの構築やマルチプラットフォーム対応の調整による追加工数が必要となることが想定され、その増加幅がどの程度なのかが重要なポイントでした。
特に、KMPやCMPを採用することで、チームに馴染みのない技術を扱う負荷や、純粋なAndroidネイティブ開発と比較した開発体験の違いを検証する必要がありました。こうした懸念を検証するため、プロトタイプを作成しました。
プロトタイプの内容
プロトタイプでは具体的に、以下の内容を試しました。
オーダー一覧画面の実装
アプリの主要機能であるオーダー一覧画面をCMPで実装しました。UIの中核部分であるこの画面を選ぶことで、実際の開発における課題や、マルチプラットフォーム環境での開発効率を具体的に検証しました。
マルチプラットフォーム特有の実装パターン
expect/actual
パターンの検証: AndroidX DataStoreを使用し、プラットフォームごとのインスタンス生成方法の違いをexpect/actual
パターンで試しました。DataStoreはKMPに対応済みであり、最小限の実装でexpect/actual
を試せる対象として適していました。リソースの共通化:
composeResources
を用いて、両プラットフォーム間で文字列リソースを共通化しました。この実装により、Compose Multiplatformでリソースを共通化する基本的な方法を確認しました。
Koinを用いたDIの導入
KMP環境でDI(依存性注入)を導入する場合、Koinを採用することが多いと思います。このプロトタイプでも、ViewModelやRepositoryのDIを試すにあたってKoinを用いました。プロトタイプで試した理由は以下の通りです:
- チームメンバー全員がKoin未経験であり、早い段階で習熟する必要があった。
- Koinを導入した際の具体的な実装コストや運用課題をプロトタイプの中で確認することで、本実装の効率を高める狙いがあった。
プロトタイプの段階でDIライブラリを導入することで、事前に課題を把握し、本実装の効率を高める準備が整いました。
ビルドフローの検証
プロトタイプでは、AndroidとiOSの両プラットフォームでのビルドフローを検証しました。この過程を通じて、IDE(Android StudioやFleetなど)の設定やマルチプラットフォーム環境でのビルドプロセスを把握することができました。
プロトタイプから得られた成果と課題
成果
プロトタイプを通じて、純粋なAndroidネイティブ開発と比較して、KMPおよびCMPを活用する場合の実装コスト感を掴むことができました。感覚値ながら、コードの共通化に伴う工数増加や、プラットフォーム固有の調整を反映した結果、およそ1.3倍程度の実装コストになると結論づけました。
また、プロトタイプで試したプラットフォーム固有の実装(expect/actual
パターンなど)は、実際の本実装でも役立ちました。
課題
一方で、KMPやCMP特有の実装に対応するため、開発メンバーがこれらの技術にキャッチアップする必要がある点が課題として上がりました。純粋なAndroidネイティブ開発にはない新しい技術要素への理解が求められるため、以下の取り組みを行いました:
- チュートリアルの実施: 各自がKotlin MultiplatformとCompose Multiplatformのチュートリアルを実施し、マルチプラットフォームに特有の構文やAPIの理解を深めました。
- 外部イベントへの参加: DroidKaigi 2024で開催されたワークショップ「From 0 to 100 with Kotlin and Compose Multiplatform」に参加し、さらに実践的な知識を深めました。
これらの取り組みにより、チーム全体がKMPおよびCMPでの開発手法を習得し、本実装を進めるための準備が整いました。
技術的なTips
ここからはアプリ開発を通じて得られた技術的なTipsを何点かご紹介します。
初回リリースまでのモジュール戦略
モジュール分割は、アプリ開発において重要な設計要素となります。本プロジェクトでは、開発の初期段階で適切なモジュール戦略を選択するため、以下のような検討を行いました。
マルチモジュール化への期待
まず、私たちは次のようなメリットを期待してマルチモジュール化を検討しました。
- 役割の明確化と一貫した実装: モジュールごとの責務を明確にすることで、コード全体を整理し統一感を保つ。
- ビルド効率の向上: モジュールごとの並列ビルドやキャッシュを活用することで、Gradleビルド速度を改善する。
- 開発フローの効率化: 「どこに実装すべきか」の判断基準を明確にすることで、開発フローをスムーズにする。
マルチモジュール化の課題
一方で、マルチモジュール化の課題についても議論されました。
- 初期設定の煩雑さ: Gradle設定や依存関係管理など、導入時の負担が大きい。
- 柔軟性の制約: 設計変更が必要になった場合、モジュールの制約が原因で変更の小回りが効かなくなる。
- 不適切な設計のリスク: モジュールの役割が曖昧だと、過剰な依存関係やワークアラウンドが増加し、コードの品質がかえって低下する。
採用したモジュール構成
これらの期待と課題のバランスを考慮し、私たちは次のようなシンプルなモジュール構成で開発をスタートすることにしました。
以下は採用したモジュール構成と各モジュールの役割を示しています。*2
graph LR :androidApp -.->|implementation| :sharedUi -->|api| :core :iosApp* -.->|implementation| :sharedUi
名前 | 責務 | 主なクラスと例 |
---|---|---|
androidApp |
Androidアプリのエントリーポイント。 | Application , MainActivity |
iosApp |
iOSアプリのエントリーポイント。 | MainViewController |
sharedUi |
AndroidとiOSで共通利用できるUIコンポーネントやViewModelを管理。 | App (Composableのエントリーポイント) , Compose Screens, ViewModels |
core |
複数のモジュールで共有するドメインロジックやデータ層を管理。 | Repositories, UseCases |
多くのAndroid開発者に馴染みのある、より厳密なモジュール分割(例: nowinandroid のような構成)も検討しました。しかし、初回リリースまでの段階ではシンプルな構成を選択することとし、以下のような方針をチームで合意しました:
- 開発初期の柔軟性を確保: 設計変更が頻繁な初期段階では、大まかなモジュール構成を維持し、必要に応じて柔軟に対応することを優先しました。
- パッケージによる分割を活用: モジュール内で適切にパッケージを分割し、後のモジュール細分化に備えました。
- 最低限の依存関係を維持: 「アプリのエントリーポイント → UI → ドメイン・データ」という依存方向を確保し、モジュール間の役割分担を明確化しました。
- ルールは柔軟に運用: 固定的なルールに縛られず、都度PRレビューで議論し、必要に応じて改善を行いました。
これにより、設計の一貫性を保ちながら、頻繁な変更が必要となる開発初期の柔軟性を確保するという、相反する要件を満たすことができました。この戦略は開発初期における効率を最大化するものであり、今後の拡張やリファクタリングにおいても重要な基盤となると考えています。
Koinのリロード
アプリのライフサイクルにおいてはDIで注入したインスタンスをクリーンナップしたいタイミングがあるかと思います。例えばキッチンディスプレイではログアウトと同時にAPIサーバーのアクセストークンを保持したDataSourceやネットワーククライアントを破棄・再生成する必要がありました。
シンプルな方法としてはアプリを再起動することです。例えばAndroidであればMainActivityの再起動が可能です。ただマルチプラットフォーム(iOS)での動作も想定した場合はこれは簡単な方法ではありません。
そこで下記のようなKoinの初期化とリロードをする機構を用意しました。
object KoinDispatcher { private var koinApplication: KoinApplication? = null private lateinit var koinModules: List<Module> fun start( modules: List<Module>, config: KoinAppDeclaration? = null, ) { koinModules = modules koinApplication = startKoin { config?.invoke(this) } loadKoinModules(koinModules) } fun reload() { if (koinApplication == null) return unloadKoinModules(koinModules) loadKoinModules(koinModules) } }
これを使うことで任意のタイミングでインスタンスの再注入をできるようにしました。
class KitchenDisplayApplication: Aplication() { override fun onCreate() { super.oncreate() koinDispatcher.start( modules = listOf( /* DIするモジュール */ ), config = { androidContext(this@KitchenDisplayApplication) }, ) } } fun logout() { koinDispatcher.reload() // DIしたモジュールの再注入 }
ComposeMutliplatformでPreviewする
キッチンディスプレイの開発におけるCMPのCompose Previewの実現方法について紹介します。
CMPの開発においても @Preview
アノテーションを使ってPreview Composableを実装することができます。ただしAndroidネイティブの開発と異なりAndroid Studioのレイアウトエディターで直接プレビューを見ることができません。これはorg.jetbrains.compose.ui.tooling.preview.Preview
を使用しているためです。
代替としてJetbrains Fleetを使うことでプレビューすることができますが、現状は開発体験の面でAndroidStudioを使わない選択肢は取りたくなく、別の方法でのプレビューの実現を模索しました。
そこでRoborazziを使うことでAndroidStudio上でもCMPのプレビューをできるようにしました。この方法はDroidKaigiの conference-app-2024 でも使われている仕組みです。
プレビューの実行と自動更新
RoborazziはJVM/Androidのスクリーンショットテスティングライブラリであり、テストの過程でスクリーンショットが画像ファイルとして保存されます。このアプローチではRoborazzi Pluginを使ってスクリーンショット画像を表示することでCompose Previewを代替しています。
プレビューの更新には下記のようなテストコマンドの実行が必要になります。
./gradlew recordRoborazziDebug
実装を変更する度に都度テストコマンドを実行するのはとても手間です。
そこでfswatchを使ってソースコードの変更監視とテスト実行を自動化することで、Androidネイティブでの開発と遜色なくリアルタイムにプレビューを更新できるようにしています。
brew install fswatch
fswatch -o -e ".*" -i "\\.kt$" ./ | xargs -n1 -I{} ./gradlew recordRoborazziDebug
PreviewParameterProviderのサポート
RoborazziのREADMEではCompose Previewのセットアップとしてsergio-sastre/ComposablePreviewScannerを使う方法が紹介されています。
キッチンディスプレイの開発でも当初はComposablePreviewScannerを使用してスクリーンショットテストを動かしていましたが、ComposablePreviewScannerは @PreviewParameterProvider
アノテーションをまだサポートしていないため様々な表示パターンを確認するのには不便でした。
そこでテスト対象のComposable functionの抽出にComposablePreviewScannerではなくリフレクションを使って行う実装に変更し、CMPのプレビューでも PreviewParameterProviderを利用できるようにしました。この実装には takahirom/roborazzi-usage-example の実装 を参考にしました。
UI実装
マルチプラットフォームを想定したライブラリ選定や大画面デバイスでの動作を考慮した実装など、キッチンディスプレイのUI実装で行なった工夫についていくつか抜粋して紹介します。
BottomSheetの実装
キッチンディスプレイではデザインコンポーネントとしてMaterial2を使用しています。Material2では ModalBottomSheetLayout
を使うことでBottomSheetの実装が可能ですが ModalBottomSheetLayout
は画面全体のUI実装に影響します。キッチンディスプレイは大画面で様々な情報を同時に表示するアプリであるため画面全体の実装に影響しない形でBottomSheetを実装したいと考えました。
そこでBottomSheetの実装には skydoves/FlexibleBottomSheet を使用しました。
FlexibleBottomSheet
はマルチプラットフォームに対応したBottomSheetライブラリです。Material3の ModalBottomSheet
のようにシンプルにBottomSheetを実装することが可能でありキッチンディスプレイの開発においては十分な機能を持っています。
ただし一部の動作にはまだ不安定な部分があるため工夫して実装する必要がありました。
例えば BottomSheetState
の初期状態を Hidden
として実装していてもアプリ起動直後から Expanded
で表示される挙動が発生していました。
そのためBottomSheetの表示ボタンをタップするまではBottomSheetをコンポジションしないようにフラグで制御することで対策しました。
var hasButtonClicked by remember { mutableStateOf(false) } val bottomSheetState = rememberFlexibleBottomSheetState() if (hasButtonClicked) { FlexibleBottomSheet( sheetState = bottomSheetState, ){ /* BottomSheet */ } }
ただこの対策だと状態管理が二重になるので実装的には微妙ですし、最初のコンポジションの際はアニメーションがちゃんと効かないのも違和感があるため今後改善したい点です。
尚、この問題についてはFlexibleBottomSheetのリポジトリでも同様のissueが報告されているようです。 https://github.com/skydoves/FlexibleBottomSheet/issues/25
NavigationDrawerの実装
NavigationDrawerは素朴に Scaffold
の drawerContent
を使って実装しています。このあたりは従来のJetpackComposeでのNavigationDrawer実装と変わりありません。
Scaffold(
drawerContent = { OrderBoardDrawer() },
drawerShape = OrderBoardDrawerShape(),
) {
/* Content */
}
NavigationDrawerは標準では画面全体を覆うUIですが、キッチンディスプレイのようにタブレット等の大画面デバイスで使うアプリではそれほど大きく表示しなくて良いケースもあります。
その場合は drawerContent
と drawerShape
の両方でサイズを設定する必要があります。
private const val NAVIGATION_DRAWER_WIDTH = 480 @Composable private fun OrderBoardDrawer() { Column( modifier = Modifier.width(NAVIGATION_DRAWER_WIDTH.dp) ) { /* drawer content */ } } @Composable private inline fun OrderBoardDrawerShape(): Shape { return remember { object : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density, ): Outline = Outline.Rectangle( Rect( Offset.Zero, Offset( with(density) { NAVIGATION_DRAWER_WIDTH.dp.toPx() }, size.height, ), ), ) } } }
ちなみに drawerContent
だけサイズを変更すると標準の大きさ分のSurfaceが下に表示されてしまいます。
オーダー一覧のポーリングの設計と実装:リアルタイム更新の仕組み
ポーリングの背景と目的
キッチンディスプレイアプリでは、リアルタイムに注文状況を確認することが、飲食店のオペレーション効率を保つために重要です。例えば、Webアプリからオーダーが送信された際、これを速やかに反映し、キッチンスタッフがタイムリーに対応できるようにする必要があります。
このため、サーバーに保存されたオーダー一覧データを一定間隔で取得するポーリングを実装しました。これにより、リアルタイム性とサーバー負荷のバランスを両立しています。
リアルタイム更新の手法選択
ポーリング以外の方法として、GraphQLのSubscriptionを利用する選択肢もありました。しかし結果として、以下の理由からクライアント側で一定間隔でQueryを実行するポーリングを採用しました。
- リソースの効率化: Subscriptionはサーバーリソースや接続の安定性に対する要求が高く、特に多数のクライアントが同時接続する場合に負荷が大きくなる。
- 運用の簡易性: ポーリングは実装が比較的シンプルで、クライアント側で完結するため、運用コストを抑えられる。
要件
ポーリングは、次の要件を満たすよう設計されています。
- 定期取得: 一定間隔でサーバーからデータを取得。
- 即時リトライ: Mutation発生時に即座にデータを更新。
- リアクティブな画面反映: 最新のデータをUIに即時反映。
次の図は、例としてポーリング間隔を30秒とした場合の、Queryの実行スケジュールの例を示しています。
gantt title オーダー一覧Queryの実行スケジュール dateFormat HH:mm:ss axisFormat %S秒 section Query 初回Query実行 (t=0) :active, q1, 00:00:00, 1s 定期Query実行 (t=30) :active, q2, 00:00:30, 1s Mutation後のQuery実行 (t=41) :active, mq1, 00:00:41, 1s 定期Query実行 (t=71) :active, q3, 00:01:11, 1s section 待機 ポーリング間隔 :wait1, 00:00:00, 30s ポーリング間隔 :wait2, after wait1, 10s ポーリング間隔 :wait3, 00:00:41, 30s section Mutation Mutation発生 (t=40) :crit, m1, 00:00:40, 1s
- 初回Query実行後、30秒ごとにデータを取得します。
- t=40秒でMutationが発生し、その直後にQueryを実行。次回のポーリングが30秒後にリセットされます。
実装上の課題
定期ポーリングとMutation後の即時Queryの結果を一元管理する必要がありました。具体的には以下の課題がありました。
- データの競合: 定期Queryと即時Queryが重なることで、古いデータが一瞬表示される可能性がありました。
- リソース効率: 両方を分離して実装する場合、データの取得頻度が過剰になる可能性がありました。
解決策
以下に示すRetryableFlowTrigger
を活用し、Mutation後の即時Queryと定期ポーリングを統一的に管理しました。この仕組みにより、実装上の課題を解決し、要件を満たすような振る舞いを実現しました。
以下は実装例です。
class ObserveOrdersUseCase( private val getOrdersUseCase: GetOrdersUseCase, ) { companion object { private const val POLLING_INTERVAL_MILLIS = 30000L } @OptIn(ExperimentalCoroutinesApi::class) operator fun invoke( shopIdentifier: String, retryPollingOrdersTrigger: RetryableFlowTrigger = RetryableFlowTrigger(), ): Flow<List<Order>> = retryPollingOrdersTrigger.retryableFlow { // 1. Flow<List<Order>> をemit // 2. POLLING_INTERVAL_MILLIS ごとに1を繰り返す createPollingFlow(pollingIntervalMillis = POLLING_INTERVAL_MILLIS) .flatMapLatest { getOrdersUseCase(shopIdentifier = shopIdentifier) // オーダー一覧Queryの実行 } } private fun createPollingFlow(pollingIntervalMillis: Long): Flow<Unit> = flow { while (currentCoroutineContext().isActive) { emit(Unit) delay(pollingIntervalMillis) } } }
Mutation実行後にRetryableFlowTrigger.retry
をコールすることで、Queryの即時リトライとポーリングスケジュールのリセットを実現します。
class RetryableFlowTrigger { internal val retryEvent: MutableStateFlow<RetryEvent> = MutableStateFlow(RetryEvent.INITIAL) fun retry() { retryEvent.value = RetryEvent.RETRYING } } @OptIn(ExperimentalCoroutinesApi::class) fun <T> RetryableFlowTrigger.retryableFlow(flowProvider: RetryableFlowTrigger.() -> Flow<T>): Flow<T> = retryEvent .onSubscription { retryEvent.value = RetryEvent.INITIAL } .filter { it == RetryEvent.RETRYING || it == RetryEvent.INITIAL } .flatMapLatest { flowProvider.invoke(this) } .onEach { retryEvent.value = RetryEvent.IDLE } internal enum class RetryEvent { RETRYING, INITIAL, IDLE, }
UIイベントを起因とするポーリング間隔の変更といった複雑な処理でもFlowを使えばシンプルに実装をカプセル化できました。
データの保存について
キッチンディスプレイではキャッシュを目的としたデータベースや設定値の保存を目的にデータを永続化しています。そのなかでも「データの保存」についてフォーカスしてお話します。
技術選定のときの見立てとしてはKMPにDataStoreがあるから、それで設定値を保存しておけばいいやと考えていたのですが、それだけでは問題がでてきました。通信に使うトークンは通常のDataStoreに保存するべきではありません。Androidでは EncryptedSharedPreferences
で暗号化して保存すること、iOSでは Keychain
に保存することが適切とされているため、DataStoreをそのまま使うことはできませんでした。
そこでキッチンディスプレイでは russhwolf/multiplatform-settings を使って設定値やトークンの保存を行うことにしました。
このライブラリでは、データの保存先としてKeychainを選択できるので、単純なコードでKeychainにトークンを保存できます。
fun createAuthorizationSettings() = KeychainSettings("authorization")
また、EncryptedSharedPreferences
をそのままサポートしているわけではありませんが、SharedPreference
をデータ保存先として使える SharedPreferencesSettings
は保存先の SharedPreference
オブジェクトを渡すことができるので、そこに EncryptedSharedPreferences
をいれることで、解決できました。
fun createAuthorizationSettings(context: Context): SharedPreferencesSettings { val fileName = "authorization_preferences" val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) val delegate: SharedPreferences = EncryptedSharedPreferences.create( fileName, masterKeyAlias, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) return SharedPreferencesSettings(delegate) }
DataStoreがそのまま使えなかったのはすこし残念ですが、これで安心してデータを保存できました。
わたしたちの技術選定は正しかったのか
半年前にやってきた技術選定からはじまって、実際に開発を続けてきて、そこから得られた知見をご紹介しました。 この記事を最後まで読んでくださった方々には伝わっているかと思いますが、あのときの私たちと比べていまはKMP/CMPの知見はだいぶ蓄積されています。いま思うとあの技術選定は間違っていなかったのでしょうか。
そこで開発チームの他に技術選定に関わったメンバーも含めて公開で振り返りをおこないました。
すべての意見をご紹介できないのですが、なかでも技術選定や開発してみた感想をご紹介いたします。
- Jetpack Composeの開発経験があったので、CMPでの実装で迷うことは少なかった
- KMP/CMP開発経験がないAndroidエンジニアであれば、通常のAndroidアプリ開発コストの1.3倍くらいかなと思っていたが、感覚値としては間違ってなかった
- CMPを採用したことでほとんどのコードを共通化できたので、プラットフォーム固有の実装をほとんどなくせた
- 小さなアプリケーションなのでビジネスロジックが少ないことを考えると、ネイティブで開発したとしてもプラットフォーム間の実装差異はおきないのでは?という仮説もあったが、とはいえ実装していくなかで実装差異になりそうなポイントはあったので、そういったところが実装差異がなくなることを考えるととてもよかった。
- iOS版はあとから開発なので、「最終的に実装コストは安かったのか」「技術選定が正しかった」は現時点では判断できなさそう
- CIでiOS版をビルドしていなかったので、気がつくとビルドできなくなることがあった
- iOS固有の実装で特に知識が必要なところはAndroidエンジニアに実装は難しかった
- まだライブラリが成熟しているとは言いがたく、トラブルの原因調査に時間をとられることがあった
さいごに
この記事では、STORES モバイルオーダーで使われているキッチンディスプレイの開発について紹介しました。 STORES では既存の技術を使った挑戦だけでなく、新しい技術の採用についても積極的に検討しながら、日々開発をしています。この記事だけでは語りきれなかったことがたくさんあるので、後日別の記事での紹介や勉強会で知見を共有できればなと思っています。 また、この記事を読んでSTORES に興味をもっていただけた方がいらっしゃいましたら、転職意欲の有無にかかわらず、ぜひカジュアルに面談しましょう。