以下の内容はhttps://product.st.inc/entry/2025/06/30/090000より取得しました。


ViewModel での複雑な状態管理への処方箋

こんにちは!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




以上の内容はhttps://product.st.inc/entry/2025/06/30/090000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14