以下の内容はhttps://techblog.zozo.com/entry/migrating-zozofit-from-mvvm-to-mviより取得しました。


ZOZOFIT Androidで進めたMVVMからMVIへの移行と独自MVIライブラリの開発

ZOZOFIT Androidで進めたMVVMからMVIへの移行と独自MVIライブラリの開発

はじめに

こんにちは。グローバルプロダクト開発本部 グローバルアプリ部 アプリ基盤ブロックの桂川です。普段はZOZOFIT・ZOZOMETRYなどの計測アプリのAndroid開発に携わっています。本記事ではZOZOFITのAndroidアプリで取り組んだMVVMからMVIへの移行と、独自MVIライブラリの開発について紹介します。なお、独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。

目次

用語

まず、本記事で使用する用語を整理します。

ZOZOFIT

ZOZOFITは、自宅で手軽に高精度な3Dボディスキャンができる体型管理サービスです。ZOZOSUITと専用スマートフォンアプリを活用し、全身3Dスキャンが可能です。計測データに基づき、体の変化を3Dモデルと数値で可視化できます。栄養素を記録・分析するフードジャーナル機能など、計測以外の機能でも総合的な健康管理をサポートしています。本記事ではアメリカなど海外で展開しているZOZOFITのAndroidアプリでの改善についてお話しします。

zozofit.com

MVVM

MVVM(Model-View-ViewModel)は、UIの状態を管理するアーキテクチャスタイルの1つです。Model・View・ViewModelの3要素で構成され、ViewModelがModelとViewの仲介役を担います。ViewはViewModelが公開する状態を監視して画面に反映し、ユーザー操作はViewModelのメソッドを呼び出すことで処理されます。Androidアプリ開発で広く採用されているアーキテクチャです。データの流れは次のとおりです。

  1. Viewがユーザー操作をViewModelのメソッド呼び出しとして送る
  2. ViewModelが状態を更新し、StateFlowで公開する
  3. ViewがStateFlowを購読して画面に反映する

MVVMアーキテクチャの構成図。ViewからViewModelへメソッド呼び出し、ViewModelからViewへStateFlowで状態を通知する

SSOT

SSOT(Single Source of Truth)は、各データ型に対して唯一の信頼できるデータソースを持つ考え方です。SSOTだけがデータを変更でき、不変の型で公開します。これによりデータの変更が1箇所に集約され、他の型による改ざんを防ぎ、バグの追跡を容易にします。

SSOTの概念図。複数のModuleが唯一のSourceを参照する構造

UDF

UDF(Unidirectional Data Flow)は、SSOTと組み合わせて使用されるパターンです。状態(データ)は上位から下位へ一方向に流れ、状態を変更するイベントはその逆方向に流れます。具体的には次の流れでデータが更新されます。Android公式ドキュメントでも、堅牢なアーキテクチャの原則としてSSOTとUDFが示されています。この2つをセットで守ることで、データの整合性が保たれ、デバッグ・テスト・レビューがしやすくなります。本記事で紹介するMVIアーキテクチャもこの原則に基づいており、SSOTとUDFの理解が必要です。

  1. ユーザー操作(ボタン押下など)が下位スコープで発生する
  2. イベントが下位スコープから上位スコープ(SSOT)へ向かって流れる
  3. SSOTでデータが変更され、不変の型として公開される
  4. 変更された状態が上位スコープから下位スコープへ流れる
  5. 下位スコープが新しい状態を受け取り、表示を更新する

UDFの概念図。状態は上位から下位へ一方向に流れ、イベントは逆方向に流れる

MVI

MVI(Model-View-Intent)は、UDFの原則に基づいてUIの状態を管理するアーキテクチャスタイルの1つです。データの流れが一方向に固定されるため、状態変更の起点と結果が追跡しやすくなります。MVIの名前はModel・View・Intentの頭文字に由来しており、以下の3要素で構成されます。なお、本記事では用語の紛らわしさを避けるため、以降ModelをState、IntentをActionと呼びます。

要素 役割
Model(State) 画面の現在状態を表すデータ。UIはこの値のみから構築される。
View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。
Intent(Action) ユーザー操作や外部イベントなど、状態更新のきっかけとなる入力。
  1. Viewがユーザーの操作をActionとして発行する
  2. ActionをもとにStateが更新される
  3. 更新されたStateがViewへ通知され、画面に反映される

MVIアーキテクチャの概念図。ViewからActionがStateHolderへ流れ、StateがViewへ流れる一方向のデータフロー

私たちのMVVMアーキテクチャの問題点

ZOZOFITのAndroidアプリは2022年のリリース当初からJetpack Composeを採用しており、当時からMVVMアーキテクチャを採用して開発を続けていました。私たちのMVVMアーキテクチャではViewModelで定義したStateFlowをViewで購読し、ViewModelのメソッドをViewから呼び出して状態を更新する、というシンプルな設計でした。

ZOZOFITで採用していたMVVMアーキテクチャの構成図。ViewからViewModelへメソッド呼び出し、ViewModelからViewへStateFlowで状態を通知する

class CounterViewModel : ViewModel() {
    private val _counter = MutableStateFlow(0)
    val counter: StateFlow<Int> = _counter.asStateFlow()

    fun increment() {
        _counter.value += 1
    }

    fun decrement() {
        _counter.value -= 1
    }

    fun reset() {
        _counter.value = 0
    }
}

しかし開発が進み画面数や機能が増えるにつれて、Jetpack ComposeとMVVMの組み合わせにおいて、いくつかの問題が顕在化していきました。特にStateFlowの管理やイベント通知の設計がチーム内で統一されておらず、不具合やレビュー負荷の増加につながっていました。具体的には以下のような課題がありました。

ViewModelでのState管理が複雑に

表示データごとに個別のStateFlowを定義していたため、画面が複雑になるほどFlow.mapcombineによる合成が増えていきました。各Flowの更新タイミングが把握しづらくなり、意図しない再Composeや画面のチラつきが発生していました。

// CounterViewModel.kt: 表示データごとに個別のFlowが定義されている
class CounterViewModel : ViewModel() {
    private val _counter = MutableStateFlow(0)
    val counter: StateFlow<Int> = _counter.asStateFlow()

    // Flow.mapで派生StateFlowを作成 → 更新タイミングが分かりにくい
    val doubleCount: StateFlow<Int> = _counter.map { it * 2 }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)
    val tripleCount: StateFlow<Int> = _counter.map { it * 3 }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)
}

またView側のComposable関数でも引数が増えていく傾向がありました。View側のコードに多くのcollectAsStateが定義され、見通しが悪く、管理が難しいコードになることも多々ありました。

// CounterScreen.kt: Flowごとに個別にcollectし、引数が増えていく
@Composable
fun CounterScreen(viewModel: CounterViewModel, navController: NavController) {
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
    val counter by viewModel.counter.collectAsStateWithLifecycle()
    val doubleCount by viewModel.doubleCount.collectAsStateWithLifecycle()
    val tripleCount by viewModel.tripleCount.collectAsStateWithLifecycle()

    CounterScreenContent(
        isLoading = isLoading,
        counter = counter,
        doubleCount = doubleCount,
        tripleCount = tripleCount,
        onIncrement = { /* ... */ },
        onDecrement = { /* ... */ },
        onReset = viewModel::reset,
        // ...
    )
}

ViewとViewModelの責務が曖昧に

ViewがViewModelの構造を知りすぎるコードになりがちで、本来ViewModelで完結すべきロジックがView側に漏れ出していました。ViewModelのプロパティを直接読み取って条件分岐する実装や、複数メソッドを特定の組み合わせで呼び出す実装が各所に存在していました。

// ViewがViewModelのプロパティを直接読み取ってToast表示を制御している
val context = LocalContext.current
Button(
    onClick = {
        viewModel.increment()
        if (viewModel.currentCount == 10) {
            Toast.makeText(context, "10に到達しました", Toast.LENGTH_SHORT).show()
        }
    }
) {
    Text("Increment")
}
// 1つのユーザー操作に対してView側が複数メソッドを組み合わせて呼んでいる
Button(
    onClick = {
        viewModel.increment()
        viewModel.checkLimit()
    }
) {
    Text("Increment")
}

このようにViewがViewModelの構造を知りすぎているため、機能変更時の影響範囲が広がりやすくなり、レビュー負荷や不具合の原因になっていました。

イベント通知と画面遷移の不統一

Toast表示や画面遷移といった一度きりの処理について、実装パターンが明確に統一されていませんでした。Toast表示ではViewModelからイベントを発行してView側で購読するパターンと、View側でStateを直接監視して処理するパターンが混在していました。

// CounterScreen.kt: ViewModelのイベント経由でToast表示
LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
        }
    }
}

Button(onClick = { viewModel.increment() }) {
    Text("Increment")
}
// CounterScreen.kt: View側でStateを直接監視してToast表示
val counter by viewModel.counter.collectAsStateWithLifecycle()

LaunchedEffect(counter) {
    if (counter >= 10) {
        Toast.makeText(context, "10に到達しました", Toast.LENGTH_SHORT).show()
    }
}

Button(onClick = { viewModel.increment() }) {
    Text("Increment")
}

画面遷移についてもViewModelのイベント経由で遷移するパターンと、Composable関数から直接Navigatorを呼び出すパターンが混在していました。

// CounterScreen.kt: ViewModelのイベント経由で画面遷移
LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is CounterEvent.NavigateSetting -> navController.navigateSetting()
        }
    }
}

Button(onClick = { viewModel.navigateSetting() }) {
    Text("Setting")
}
// CounterScreen.kt: Composable関数から直接Navigatorを呼び出して画面遷移
Button(onClick = { navController.navigateSetting() }) {
    Text("Setting")
}

方式が統一されていないため、新しい画面を実装する際にどの方式へ合わせるべきか判断しづらく、開発者ごとの実装のばらつきを招いていました。さらにStateを直接監視する方式では、画面に戻ってきた際にイベントが再発火して意図しない動作が発生する不具合も起きていました。

私たちのMVVMアーキテクチャの改善方針

これらの問題を放置すれば開発効率・品質ともに低下し続けるため、各課題に対して以下のような解決方針を考え、まずは既存のMVVMアーキテクチャの枠組みの中で改善できないか検討を進めました。

課題 解決方針
State管理の複雑化 画面の状態を1つのdata classに集約し、単一のStateFlowで管理する
ViewとViewModelの責務が曖昧 ユーザー操作をイベントとして定義し、処理をViewModel内に集約する
イベント通知と画面遷移の不統一 イベント通知をChannelに統一し、画面遷移もイベント経由に統一する

UiStateによるState管理の単純化

SSOTの原則に従い、画面の状態を1つのdata classに集約して単一のStateFlowで管理する方針を考えました。Viewは信頼できる唯一のソースを購読して画面に反映するだけのシンプルな構造になります。また状態の更新が_state.updateに集約されるため、Flow.mapcombineによる合成が不要になり、更新タイミングも制御しやすくなると考えました。

// CounterUiState.kt: 画面の状態を1つのdata classに集約し、派生値もdata class内で計算する
data class CounterUiState(
    val count: Int = 0,
) {
    val doubleCount: Int get() = count * 2
    val tripleCount: Int get() = count * 3
}
// CounterViewModel.kt: 単一のStateFlowで管理し、ユーザー操作ごとにメソッドを定義
class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(CounterUiState())
    val state: StateFlow<CounterUiState> = _state.asStateFlow()

    fun onIncrementClicked() {
        _state.update { it.copy(count = it.count + 1) }
    }
}
// CounterScreen.kt: View側は単一のStateを購読するだけ
@Composable
fun CounterScreen(viewModel: CounterViewModel, /* ... */) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    CounterScreenContent(
        state = state,
        onIncrement = viewModel::onIncrementClicked,
        // ...
    )
}

ユーザー操作ごとのメソッド定義による責務の明確化

UDFの原則に従い、ViewからのAction(ユーザー操作)に反応してStateが更新されるシンプルな構造を考えました。ユーザー操作ごとにメソッドを定義し、関連する更新処理をすべてそのメソッド内に集約します。これによりView側はユーザー操作をViewModelに伝えるだけの役割になり、具体的な処理はすべてViewModel側で完結するため、責務が明確になると考えました。

// CounterViewModel.kt: ユーザー操作(Action)ごとにメソッドを定義し、処理をViewModel内に集約
class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(CounterUiState())
    val state: StateFlow<CounterUiState> = _state.asStateFlow()

    fun onIncrementClicked() {
        viewModelScope.launch {
            _state.update { it.copy(count = it.count + 1) }
            checkLimit()
        }
    }

    private suspend fun checkLimit() { /* ... */ }
}
// CounterScreen.kt: ViewはActionを発行するだけ
CounterScreenContent(
    state = state,
    onIncrement = viewModel::onIncrementClicked,
    onDecrement = viewModel::onDecrementClicked,
    onReset = viewModel::onResetClicked,
)

Channelによるイベント通知と画面遷移の統一

イベント通知と画面遷移の方式をChannelに統一する方針を考えました。一度限りのイベントをsealed classで定義し、Channelで配信することで、StateFlowのように状態として保持されず再受信による不具合を防げます。

画面遷移もイベントの一種として扱い、すべてViewModel経由で発行する形に統一します。単純な遷移であればViewから直接呼び出す方がシンプルですが、実際には遷移前の条件チェックやパラメータの組み立てが必要になるケースが多いです。そのためViewModel側に集約する方が一貫性を保ちやすいと判断しました。

// CounterEvent.kt: イベントと画面遷移をsealed classで定義
sealed class CounterEvent {
    data class ShowToast(val message: String) : CounterEvent()
    data object NavigateSetting : CounterEvent()
}
// CounterViewModel.kt: イベント通知と画面遷移をChannelで統一的に配信
class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(CounterUiState())
    val state: StateFlow<CounterUiState> = _state.asStateFlow()

    private val _event = Channel<CounterEvent>(Channel.BUFFERED)
    val event: Flow<CounterEvent> = _event.receiveAsFlow()

    fun onIncrementClicked() {
        viewModelScope.launch {
            _state.update { it.copy(count = it.count + 1) }
            checkLimit()
        }
    }

    fun onSettingClicked() {
        viewModelScope.launch {
            _event.send(CounterEvent.NavigateSetting)
        }
    }

    private suspend fun checkLimit() {
        val count = _state.value.count
        if (count >= 10) {
            _event.send(CounterEvent.ShowToast("10に到達しました"))
        }
    }
}
// CounterScreen.kt: イベントをChannelで統一的に購読し、画面遷移やToastを一元的に処理
LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
            CounterEvent.NavigateSetting -> onNavigateSetting()
        }
    }
}

私たちのMVVMアーキテクチャの改善方針を運用できるか

ここまで紹介した改善方針は、SSOTに基づくState集約、UDFに基づくAction定義、Channelによるイベント通知の統一です。これらは既存のMVVMアーキテクチャの枠組みで実現できることがわかりました。しかしルールとして定めるだけでは、複数人開発の中で徐々に形骸化していくことが課題としてありました。

  • UiStateにまとめるルールがあっても、急ぎの対応で新しいStateFlowが追加され、元の設計に戻ってしまう
  • ユーザー操作ごとにメソッドを定義する方針でも、View側から複数メソッドを直接呼び出す実装がレビューをすり抜けてしまう
  • Channelに統一するルールがあっても、既存コードを参考にStateFlowでイベント通知を実装してしまう

また改善方針を各画面で愚直に実装すると、StateFlowやChannelの定義・購読といったボイラープレートが画面ごとに増加することも課題でした。

MVIアーキテクチャの導入と独自ライブラリの作成

これらの課題から、ルールではなく仕組みとして正しい実装に導かれるよう、MVIアーキテクチャを導入することにしました。

MVIアーキテクチャの導入にあたり、既存のOSSライブラリも検討しました。しかし私たちが必要としているのはシンプルなMVIのデータフローであり、既存のOSSライブラリは多機能で学習コストが高いと感じました。実現に必要なコード量も少なく自分たちで開発できる規模だったため、プロジェクトの特性に合わせた独自MVIライブラリを作成することにしました。

データフロー

独自MVIライブラリでは、前述の改善方針をMVIの設計思想に沿って整理することにしました。MVIのState・View・Actionに加えて、画面遷移やToast表示といった一度限りのイベントを扱うSideEffectを導入しています。

要素 役割 対応する改善方針
State 画面の現在状態を表す単一のdata class。UIはこの値のみから構築される。 SSOTに基づくState集約
View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 -
Action ユーザー操作をViewからViewModelへ伝える入力。 UDFに基づくAction定義
SideEffect 画面遷移やToast表示など、一度限りのイベント。ChannelでViewに配信される。 Channelによるイベント通知統一

ViewからActionが送信されると、ViewModelがそれを受け取ってStateを更新するか、SideEffectを発行します。このシンプルなデータフローにより、ユーザー操作がどのように処理されるかを一貫した流れで追えるようにしています。

MVIデータフロー

実装

インタフェースの定義

まず、MVIの各要素に対応するマーカーインタフェースとしてMVIStateMVIActionMVISideEffectを定義しました。各画面のState・Action・SideEffectクラスへこれらを実装させることで、型パラメータの制約として利用し、誤った型の組み合わせをコンパイル時に検出できます。

次に、MVIのデータフローを実現するためのMVIインタフェースを定義しました。Stateの購読(state)、Actionの受け取り(onAction)、Stateの更新(update)、SideEffectの発行(sideEffect)を集約しています。

interface MVIState
interface MVIAction
interface MVISideEffect

interface MVI<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> {
    val state: StateFlow<State>
    val currentState: State
    val sideEffect: Flow<SideEffect>

    fun onAction(action: Action)
    suspend fun update(block: suspend (State) -> State)
    suspend fun sideEffect(effect: SideEffect)
}

移譲を用いたインタフェースの実装

次に、このインタフェースの実装クラスとしてMVIDelegateを用意しました。内部ではStateをMutableStateFlowで管理し、SideEffectをChannelで配信しています。ViewModelではKotlinのデリゲートパターン(by mvi(...))を使うことで、MVIインタフェースの機能をViewModelへ追加できるようにしました。

class MVIDelegate<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect>(
    initialState: State,
) : MVI<State, Action, SideEffect> {
    private val _state = MutableStateFlow(initialState)
    override val state: StateFlow<State> = _state.asStateFlow()
    override val currentState: State get() = _state.value

    private val _sideEffect by lazy { Channel<SideEffect>() }
    override val sideEffect: Flow<SideEffect> by lazy { _sideEffect.receiveAsFlow() }

    override fun onAction(action: Action) {}
    override suspend fun sideEffect(effect: SideEffect) { ... }
    override suspend fun update(block: suspend (State) -> State) { ... }
}

fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> mvi(
    initialUiState: State,
): MVI<State, Action, SideEffect> = MVIDelegate(
    initialState = initialUiState,
    savedStateHandle = null,
    savedStateName = null,
)

また、Jetpack ComposeとMVIを接続するためのMviContentコンポーザブルも提供しています。内部でStateとSideEffectを購読し、Content層にはstateonActionのみが渡されます。開発者は購読の仕方を意識せず純粋なComposable関数を書くだけで済むようにしました。

@Composable
fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> MviContent(
    viewModel: MVI<State, Action, SideEffect>,
    sideEffect: suspend (SideEffect) -> Unit,
    content: @Composable (state: State, onMviAction: (Action) -> Unit) -> Unit,
) {
    LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect(it) } }
    val state by viewModel.state.collectAsStateWithLifecycle()
    content(state, viewModel::onAction)
}

MVIアーキテクチャを独自MVIライブラリで実装する

ここからは、独自MVIライブラリを使って実際にCounter画面をMVIアーキテクチャで実装した例を紹介します。Contract・ViewModel・Screen・テストの順に、改善方針がどのようにコードに反映されるかを確認していきます。

Contract: State・Action・SideEffectの定義

画面に必要なState・Action・SideEffectを、1つのContractファイルにまとめて定義します。SSOTの原則に従い画面の状態をCounterStateに集約し、UDFの原則に従いユーザー操作をCounterActionとして列挙しています。一度限りのイベントはCounterSideEffectとして定義します。画面が扱うデータの全体像がこのファイルだけで把握できます。

// CounterContract.kt

// SSOT: 画面の状態を1つのdata classに集約
data class CounterState(
    val count: Int = 0,
) : MVIState {
    val doubleCount: Int get() = count * 2
    val tripleCount: Int get() = count * 3

    companion object {
        val initialState = CounterState()
    }
}

// UDF: ユーザー操作をActionとして型で定義
sealed class CounterAction : MVIAction {
    data object Increment : CounterAction()
    data object Decrement : CounterAction()
    data object Reset : CounterAction()
    data object ClickSetting : CounterAction()
}

// Channel: 一度限りのイベントと画面遷移をSideEffectとして定義
sealed class CounterSideEffect : MVISideEffect {
    data class ShowToast(val message: String) : CounterSideEffect()
    data object NavigateSetting : CounterSideEffect()
}

ViewModel: Actionの処理とState更新

ViewModelではMVIインタフェースをデリゲートパターン(by mvi(...))で利用します。by mvi()を使うことでStateFlowを用いたState管理とChannelを通じたSideEffect配信がライブラリ側で強制されるため、開発者が独自にFlowを定義する余地がなくなります。すべてのユーザー操作はonActionで一元的に受け取ります。Actionの種類に応じてupdateでStateを更新し、sideEffectを通じてイベントを送信します。

// CounterViewModel.kt
@HiltViewModel
class CounterViewModel @Inject constructor() :
    ViewModel(),
    MVI<CounterState, CounterAction, CounterSideEffect> by mvi(CounterState.initialState) {

    override fun onAction(action: CounterAction) {
        viewModelScope.launch {
            when (action) {
                CounterAction.Increment -> reduceIncrement()
                CounterAction.Decrement -> reduceDecrement()
                CounterAction.Reset -> reduceReset()
                CounterAction.ClickSetting -> sideEffect(CounterSideEffect.NavigateSetting)
            }
        }
    }

    private suspend fun reduceIncrement() {
        update { it.copy(count = it.count + 1) }
        checkLimit()
    }

    private suspend fun reduceDecrement() {
        update { it.copy(count = it.count - 1) }
    }

    private suspend fun reduceReset() {
        update { CounterState.initialState }
    }

    private suspend fun checkLimit() {
        val count = currentState.count
        if (count == 10) {
            sideEffect(CounterSideEffect.ShowToast("10に到達しました"))
        }
    }
}

ViewからActionが送信され、onAction内でそのActionに対する処理がすべて完結します。View側が複数メソッドを組み合わせて呼び出す必要がなくなり、呼び忘れや順序ずれが構造的に発生しなくなります。画面遷移もSideEffectとしてonAction内から発行されるため、遷移の起点がViewModel側に集約されます。

View: MviContentによるCompose連携

この例では、View層をScreenとContentに分けて実装しています。ScreenではMviContentを使ってStateの購読とSideEffectの処理を接続します。MviContentの内部でStateとSideEffectの購読が行われるため、ContentにはstateonActionのみが渡されます。ContentはStateを表示してActionを送信するだけの純粋なComposable関数になります。

// CounterScreen.kt
@Composable
fun CounterScreen(
    mvi: MVI<CounterState, CounterAction, CounterSideEffect>,
    onNavigateSetting: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    MviContent(
        viewModel = mvi,
        sideEffect = { effect ->
            when (effect) {
                is CounterSideEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                CounterSideEffect.NavigateSetting -> onNavigateSetting()
            }
        },
    ) { state, onAction ->
        CounterScreenContent(
            state = state,
            onIncrement = { onAction(CounterAction.Increment) },
            onDecrement = { onAction(CounterAction.Decrement) },
            onReset = { onAction(CounterAction.Reset) },
            onSettingClick = { onAction(CounterAction.ClickSetting) },
            modifier = modifier,
        )
    }
}

@Composable
private fun CounterScreenContent(
    state: CounterState,
    onIncrement: () -> Unit,
    onDecrement: () -> Unit,
    onReset: () -> Unit,
    onSettingClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(modifier = modifier) {
        Text(text = "Count: ${state.count}", fontSize = 32.sp)
        Button(onClick = onIncrement) { Text(text = "+") }
        Button(onClick = onDecrement) { Text(text = "-") }
        Button(onClick = onReset) { Text(text = "Reset") }
        Button(onClick = onSettingClick) { Text(text = "Setting") }
    }
}

FlowごとにcollectAsStateを並べる必要がなくなり、View側がnavControllerやViewModelの内部状態に依存する構造も解消されます。画面遷移やToast表示はすべてSideEffect経由のコールバックに統一されるため、Contentの責務がシンプルに保たれます。ViewModelに依存しないComposable関数を用意することで、Preview関数も定義しやすくなります。

テスト: Actionを送信してState・SideEffectを検証

MVIアーキテクチャではデータフローが一方向に固定されているため、テストも「Actionを送信して、Stateの変化またはSideEffectの発行を検証する」というパターンに統一されます。テスト対象の入力と出力が明確なので、何をテストすべきかが自然と定まります。

// CounterViewModelTest.kt
class CounterViewModelTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private lateinit var target: CounterViewModel

    @BeforeTest
    fun setup() {
        target = CounterViewModel()
    }

    // Stateの変化を検証
    @Test
    fun `Action - Increment - increases count by 1`() = runTest {
        target.state.test {
            assertEquals(0, awaitItem().count)

            target.onAction(CounterAction.Increment)
            val state = awaitItem()
            assertEquals(1, state.count)
            assertEquals(2, state.doubleCount)
            assertEquals(3, state.tripleCount)
        }
    }

    // SideEffectの発行を検証
    @Test
    fun `Action - ClickSetting - emits NavigateSetting side effect`() = runTest {
        target.sideEffect.test {
            target.onAction(CounterAction.ClickSetting)
            assertEquals(CounterSideEffect.NavigateSetting, awaitItem())
        }
    }

    // State更新とSideEffectの組み合わせを検証
    @Test
    fun `Action - Increment - emits ShowToast when count reaches 10`() = runTest {
        repeat(9) { target.onAction(CounterAction.Increment) }

        target.sideEffect.test {
            target.onAction(CounterAction.Increment)
            assertEquals(CounterSideEffect.ShowToast("10に到達しました"), awaitItem())
        }
    }
}

MVVMアーキテクチャからMVIアーキテクチャに移行してみて

このような独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。既存画面を一括で移行するのではなく、「新規画面は原則MVI」「既存画面は改修タイミングで置き換え」というルールにより画面単位で段階的に進めています。これにより開発を止めることなく移行を進められ、画面ごとのリスクを小さく保ったまま適用範囲を広げることができており、2026年2月現在も段階的な移行を継続しています。

2024年9月 2025年4月 2025年10月 現在
MVI 1(2.2%) 11(24.4%) 21(38.9%) 31(50.8%)
MVVM 44(97.8%) 34(75.6%) 33(61.1%) 30(49.2%)
合計 45 45 54 61

このようにMVIの実装が徐々に増える中で、前述のアーキテクチャ上の課題が解消されたことに加え、開発工程そのものにも以下のようなメリットが出てきています。

チーム全体で一貫した実装ができるようになった

独自MVIライブラリを作り実装方針を決め、あわせてドキュメントを整備・公開したことで、ライブラリとドキュメントの両面からチーム全体で一貫した実装を進められるようになりました。

新しいメンバーが加わった際も、1つの画面のContract・ViewModel・Viewを読めばプロジェクト全体の実装パターンを理解できます。オンボーディングの負荷も軽減されていると感じています。

独自MVIライブラリのドキュメントサイト。Getting Startedページにアーキテクチャの概要やセットアップ手順が記載されている

PRレビューの質が向上した

チーム全体で実装方針を統一できるようになり、基本的なデータフローに関する指摘は大きく減りました。以前は、実装パターンの統一に関するコメントがレビューの多くを占めていました。MVIライブラリによってこれらが構造的に解消されたことで、レビューの焦点が変わりました。現在は、仕様の妥当性の確認やコードのブラッシュアップに、より多くの時間を使えるようになりました。

PRレビューコメントのBefore。導入前はデータフローに関する指摘が中心

PRレビューコメントのAfter。導入後は仕様確認やコード改善の指摘が中心

AIコーディングエージェントとの協業がしやすくなった

現在、AIコーディングエージェントのDevinを活用した既存画面のMVI移行にもチャレンジしています。MVIアーキテクチャではState・Action・SideEffectという明確な構造があるため、Devinが生成したコードでも処理の流れを追いやすく、レビューしやすいです。アーキテクチャが統一されていることは、人間同士の開発だけでなく、AIとの協業においても大きなメリットになると感じています。

Devinとの協業フロー

まとめ

本記事では、ZOZOFITのAndroidアプリにおけるMVVMアーキテクチャの課題と、MVIアーキテクチャへの移行、独自MVIライブラリの開発について紹介しました。MVIアーキテクチャは、ユーザー体験の低下を未然に防ぐ仕組みとしても機能していると感じています。ZOZOFITの利用者が日々増えるなかでも体験を安定して支えられるよう、これからもアーキテクチャの改善を進めていきます。最後までお読みいただき、ありがとうございました。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com




以上の内容はhttps://techblog.zozo.com/entry/migrating-zozofit-from-mvvm-to-mviより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

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