
こんにちは!Android エンジニアの naberyo(@error96num)です。 私が現在開発に携わっている STORES モバイルオーダー では、モバイルオーダーから入った注文を飲食店のキッチンで管理するための「キッチンディスプレイアプリ」をネイティブアプリとして提供しています。*1
本記事では、このキッチンディスプレイアプリの ViewModel をリファクタした話について共有します。
背景 ─ 機能追加による UI のリッチ化
キッチンディスプレイアプリには、モバイルオーダーで入った注文を一覧表示する機能があります。これまでは即時注文(今すぐ受け渡す注文)しか扱わなかったため、画面にすべてのオーダーを一覧表示するだけで済んでいました。

しかし、モバイルオーダーの予約注文に対応したことで状況が一変しました。 商品の受け渡し予定時刻ごとにタイムスロット(16:00–16:30 などの時間枠)を設け、「今〜30分後」「30 分後〜1時間後」…と絞り込んでオーダーを表示する UI が必要になりました。最終的な画面は、左サイドバーにタイムスロットを並べ、それ以外の領域にオーダーを一覧表示するレイアウトへとアップデートしています。

ここで問題になるのが 画面の状態管理 です。これまでは店舗情報とオーダー一覧さえあれば良かった ViewModel に、タイムスロットを表示するための状態が追加されました。以下ではタイムスロット表示の追加前後の状態管理について、詳細に解説します。
タイムスロット導入前の状態管理
背景を踏まえたところで、まずは予約注文(=タイムスロットの表示)に対応する前の ViewModel がどれだけシンプルだったかを振り返ります。ここを押さえておくと、後述するリファクタの必然性が腑に落ちるはずです。
画面で管理している状態は主に次の2つです。
shop:店舗名・営業時間など、UI のヘッダーやメニューで使う店舗情報。ShopRepositoryが API から取得する。orders:キッチンで捌くオーダー一覧。OrderRepositoryが API からポーリングして取得する。
// --- 実コードから抜粋して簡略化 --- data class OrdersState( val shop: Shop? = null, val orders: List<Order> = emptyList(), ) class OrdersViewModel( shopRepository: ShopRepository, orderRepository: OrderRepository, ) : ViewModel() { private val _state = MutableStateFlow(OrdersState()) val state: StateFlow<OrdersState> = _state.asStateFlow() init { // ① 店舗情報 shopRepository.shopFlow.onEach { shop -> _state.update { it.copy(shop = shop) } }.launchIn(viewModelScope) // ② オーダー一覧 orderRepository.ordersFlow.onEach { orders -> _state.update { it.copy(orders = orders) } }.launchIn(viewModelScope) } }
_state.update { copy(...) } を直に呼ぶようなシンプルな実装で、特に問題を感じていませんでした。
タイムスロット追加で Flow が増加
ここにタイムスロットの表示が加わることで画面で扱う OrdersState は次のように膨らみました。
// --- 実コードから抜粋して簡略化 --- data class OrdersState( val shop: Shop? = null, val orders: List<Order> = emptyList(), // ▼ タイムスロット UI 用に追加 val timeSlots: List<TimeSlot> = emptyList(), val selectedTimeSlotKey: Instant? = null, ) data class TimeSlot( val start: Instant, val end: Instant, val orderIds: List<String>, ) { val key: Instant get() = start }
機能をまず動かすことを優先し、①店舗情報 ②オーダー一覧 ③現在時刻 の各 Flow が変わるたびに
- ①営業時間が変わるかもしれない → タイムスロットの枠組みを作り直す
- ②新しい注文が来る → 注文をタイムスロットに再割り当てする
- ③分単位で時間が進む → 過去のタイムスロットを捨て未来のタイムスロットを追加する
という理由で、すべて _state.update { … } に寄せる方針で OrdersViewModel を書いたのが次の実装です。
// --- 実コードから抜粋して簡略化 --- class OrdersViewModel( shopRepository: ShopRepository, orderRepository: OrderRepository, timeProvider: TimeProvider, ) : ViewModel() { private val _state = MutableStateFlow(OrdersState()) val state: StateFlow<OrdersState> = _state.asStateFlow() init { // ① 店舗情報の変化 → タイムスロット再生成 shopRepository.shopFlow .onEach { shop -> _state.update { it.copy( shop = shop, timeSlots = it.timeSlots.reload(updatedShop = shop) ) } } .launchIn(viewModelScope) // ② オーダー一覧の変化 → タイムスロット再生成 orderRepository.ordersFlow .onEach { orders -> _state.update { it.copy( orders = orders, timeSlots = it.timeSlots.reload(updatedOrders = orders) ) } } .launchIn(viewModelScope) // ③ 現在時刻の変化(1分おきに更新される) → タイムスロット再生成 timeProvider.now .onEach { now -> _state.update { it.copy(timeSlots = it.timeSlots.reload(updatedNow = now)) } } .launchIn(viewModelScope) } // ユーザーがタイムスロットを選択したとき fun selectTimeSlot(key: Instant) = viewModelScope.launch { _state.update { it.copy(selectedTimeSlotKey = key) } } // ---- タイムスロット再計算ヘルパ ---- private fun List<TimeSlot>.reload( updatedShop: Shop? = null, updatedOrders: List<Order>? = null, updatedNow: Instant? = null, ): List<TimeSlot> { // ... 詳細は割愛 // 営業時間を基に、予め設定された間隔で区切って1日分のタイムスロットを生成 // 現在時刻より前のスロットは捨て、未来分だけ残す // orders を受け取り予定時刻でグルーピングし、該当するタイムスロットに割り当てる } }
状態更新のトリガーが増えたことで、 _state.update { … } の記述が増えています。まだ読める範疇ですが、今後さらに Flow や UI 操作のパターン が増えた場合に、更新のトリガーが追いにくくなったり更新の競合が発生することが予想されます。
combine の限界
更新トリガーが散在して読みにくいなら、そもそも 1 カ所で合成してしまえば良いのではないか?そこで最初に試したのが、Kotlin Flow の combine を使って整合の取れた OrdersState をワンショットで作る方針です。
なお、この combine で状態を一括合成するスタイルは、Google 公式サンプル Now in Android*2 などでも採用されている 王道パターンです。
// UI 操作で変化する値も Flow 化する private val selectedKey = MutableStateFlow<Instant?>(null) fun selectTimeSlot(key: Instant) = viewModelScope.launch { selectedKey.value = key } val state: StateFlow<OrdersState> = combine( shopRepository.shopFlow, // ① 店舗情報 orderRepository.ordersFlow, // ② オーダー一覧 timeProvider.now, // ③ 現在時刻 selectedKey, // ④ UI が emit する選択キー ) { shop, orders, now, selected -> val timeSlots = buildTimeSlots(shop, orders, now) OrdersState( shop = shop, orders = orders, timeSlots = timeSlots, selectedTimeSlotKey = selected ) }.stateIn(viewModelScope, SharingStarted.Eagerly, OrdersState())
combine は 「複数の Flow → 1 つの整合済み State」 を作るのに便利な反面、次のような課題が見えてきました。
- arity=5 制限
Kotlin Flow のcombineには「引数の Flow は最大5本まで」という制限があります。例えば UI がさらにリッチになり6本目の Flow をcombineに渡そうとしても、対応するオーバーロードが存在しないため渡せません。 - 中間 State の増殖
上限を回避する典型的な手段は、次のように中間 State に Flow を集約してネストすることです。しかし、Flow が増えるほど中間 State やcombineの呼び出しが増加して、保守コストが跳ね上がってしまいます。
val intermediateState1 = combine(flow1, flow2, flow3) { … } val intermediateState2 = combine(flow4, flow5, flow6) { … } val state = combine(intermediateState1, intermediateState2) { … }
- 差分の意図が不明瞭
combineでは、毎回すべての Flow の最新値を受け取ってOrdersStateを丸ごと作り直す必要があります。そのため 「どの Flow が発火したときに State のどこが更新されるか」 がコード上で追いにくく、テストやデバッグ時のトレーサビリティが下がります。
もちろん、小〜中規模の画面であれば combine による状態の合成は十分に機能します。しかし、ここで挙げた理由から、今回のような複雑な画面では、より柔軟な状態更新の仕組みが必要だと考えました。
状態更新を Mutation で表現 ─ Δstate を流して累積
そこで、状態更新の差分を累積するパターンを採用して実装しました。*3
式で表現すると newState = currentState + Δstate のようになります。そして Δstate は、次のように (State) -> State のラムダに落とし込むことができます。
typealias Mutation<State> = State.() -> State internal inline fun <State> mutation(crossinline block: State.() -> State): Mutation<State> = { block() }
この考え方を今回の OrdersViewModel にあてはめると、次のようになります。① データソースの更新を Mutation に変換 → ② merge で 1 本に集約 → ③ scan で累積して適用 という3ステップで画面状態の更新が完了します。
// --- 実コードから抜粋して簡略化 --- class OrdersViewModel( shopRepository: ShopRepository, orderRepository: OrderRepository, timeProvider: TimeProvider, ) : ViewModel() { // ---------- データソース ---------- private val shop: StateFlow<Shop?> = shopRepository.shopFlow // 店舗情報 private val orders: StateFlow<List<Order>> = orderRepository.ordersFlow // オーダー一覧 private val now: StateFlow<Instant> = timeProvider.now // 現在時刻 // ---------- ① データソースの Flow を Mutation に変換 ---------- private val shopChanges: Flow<Mutation<OrdersState>> = shop.map { mutation { copy(shop = it, timeSlots = timeSlots.reload(updatedShop = it)) } } private val ordersChanges: Flow<Mutation<OrdersState>> = orders.map { mutation { copy(orders = it, timeSlots = timeSlots.reload(updatedOrders = it)) } } private val nowChanges: Flow<Mutation<OrdersState>> = now.map { mutation { copy(timeSlots = timeSlots.reload(updatedNow = it)) } } // ---------- UI 操作による状態更新も Mutation として流す ---------- private val uiEvents = MutableSharedFlow<Mutation<OrdersState>>() // ---------- 画面の State に集約 ---------- val state: StateFlow<OrdersState> = merge( // ② すべての Mutation を一本化 shopChanges, ordersChanges, nowChanges, uiEvents ) .scan(OrdersState()) { state, mutation -> // ③ Mutation を累積して適用 mutation(state) } .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = OrdersState(), ) } // ユーザーがタイムスロットを選択したとき fun selectTimeSlot(key: Instant) = viewModelScope.launch { uiEvents.emit { copy(selectedTimeSlotKey = key) } } // ---- タイムスロット再計算ヘルパ ---- private fun List<TimeSlot>.reload( updatedShop: Shop? = null, updatedOrders: List<Order>? = null, updatedNow: Instant? = null, ): List<TimeSlot> { // ... } }
このパターンを採用して大きく実感したメリットは、Flow が何本増えても merge に 1 行追加するだけで拡張できることです。combine のような Flow の本数制限を気にせず、API 追加や UI 改修があっても
状態ソースを低コストかつ安全に増やせるようになりました。
最後に
この Mutation / merge / scan を使ったパターンにより
- Flow 本数の上限を気にせず拡張できるようになった
- 更新経路が 1 本に集約され、コードの見通しが向上した
- どの Flow が発火したときに State のどこが更新されるかが明確になった
といった効果を得られました。
残る課題として、UI アニメーションのように Flow が長時間走るケースでは、Mutation が衝突して最新値が欠落したり UI がチラつく可能性があります。その場合、Job.cancel() で処理を明示的に打ち切るなどの工夫が必要になります。こういったプラクティスについては、参考にしたブログ*3 の「Conflict resolution in state production」の章がわかりやすいので、 詳しく知りたい方はぜひ目を通してみてください。
複雑化した ViewModel への処方箋について知見をお持ちの方は、ぜひフィードバックをお願いします。最後までお読みいただき、ありがとうございました!
*1:STORES モバイルオーダー、キッチンディスプレイアプリ については過去の記事で詳しく紹介していますproduct.st.inc
*2:GitHub - android/nowinandroid: A fully functional Android app built entirely with Kotlin and Jetpack Compose
*3:Mutation を使った実装パターンの詳細は次の記事が参考になります tunjid.com