なんちゃってMVIから真のMVIを目指して - Android大規模アプリでの実践と学び

なんちゃってMVIから真のMVIを目指して - Android大規模アプリでの実践と学び

はじめに

対象読者: MVVMでの開発経験があり、MVIを実践投入しようとしているAndroid開発者

こんにちは、エクストーンの石原です。 MVIアーキテクチャは、Androidアプリの状態管理を予測可能にし、テスタビリティを向上させる優れたパターンです。以前の記事では、MVIの基本概念や歴史的背景を詳しく解説しました。 design-tech.xtone.co.jp

本記事はその実践編として、私が実際にMVIを大規模アプリのコンテンツ管理機能に導入した際の経験を共有します。

MVIの基本原則を学び、Intentクラスを定義し、ViewModelを実装しました。技術記事を何本も読み、サンプルコードも参考にしました。完成したコードを見て、「よし、これでMVIだ」と自信を持っていました。

ところが、コードレビューで厳しい指摘を受けます。「これは本当にMVIなのか?Intentが単なるメソッド呼び出しのラッパーになっている」。その瞬間、ハッとしました。改めて実装を見直すと、確かにその通りでした。Intentという名前のクラスは作りましたが、実態は単なるメソッド呼び出しのラッパー。状態管理はViewModelに直接書かれ、副作用と純粋関数が混在していました。

MVIの基本(おさらい)

MVIの基本概念については前回の記事で詳述していますが、本記事で扱う実装を理解するために、簡単におさらいします。

  • Intent: ユーザーの意図を表現する型安全なアクション
  • Model: 単一の状態として管理されるアプリケーション状態
  • View: 状態を購読し、UIを描画する
  • 単方向データフロー: Intent → Model → View という一方向の流れ

本記事では、この基本を踏まえた上で、「なんちゃってMVI」から「真のMVI」へと移行した過程と、そこから得られた学びを共有します。

なぜMVIを選んだのか

コンテンツ管理機能(いわゆるマイページ的機能)の実装にあたり、MVIを選択した理由を説明します。

課題:複雑な状態管理

この機能では、以下のような複雑な状態管理が必要でした

  • お気に入りと閲覧履歴の同時管理: 2種類のリストを独立して操作
  • タブ切り替えによる状態遷移: タブごとに適切な初期状態を決定
  • 削除と元に戻す操作: 一時的な削除状態の保持と復元
  • 複数データソースの同期: Roomデータベースからのリアルタイム更新

従来のMVVMでの課題

以前のMVVM実装では、これらの複雑な状態を複数のStateFlowで管理していました

// MVVMでの状態管理(問題あり)
private val _favoriteArticles = MutableStateFlow<List<Article>>(emptyList())
val favoriteArticles: StateFlow<List<Article>> = _favoriteArticles

private val _browsingHistory = MutableStateFlow<List<Article>>(emptyList())
val browsingHistory: StateFlow<List<Article>> = _browsingHistory

private val _selectedTab = MutableStateFlow<Tab>(Tab.FAVORITE)
val selectedTab: StateFlow<Tab> = _selectedTab

private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading

private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error

この方式では、複数の状態が独立して更新されるため、「Loading=trueなのにDataが表示される」といった矛盾した状態が発生しやすく、デバッグが困難でした。また、タブ切り替え時に複数のStateFlowを個別に更新する必要があり、状態の同期が複雑化していました。

MVIに期待したこと

MVIの導入により、以下を実現しようとしました

  1. 単一状態(Single Source of Truth): 矛盾のない一貫した状態管理
  2. 予測可能な状態遷移: どこでどう状態が変わるかが明確
  3. テスタビリティ: 状態変更ロジックを純粋関数として分離
  4. スケーラビリティ: 機能追加・変更に強い設計

この期待を実現するため、MVIを導入することにしました。

しかし、最初の実装は「なんちゃってMVI」でした...

なんちゃってMVIの実態

改善できた点:UI層の責務分離

なんちゃってMVIにも良い点はありました。UI層では確実に改善があったのです。

  • Intentによる意図の明示化: ViewからViewModelへの操作が型安全に表現されました
  • 単一状態(UiState)の導入: 従来のMVVMで複数のLiveDataを個別管理していた状態から、単一のStateFlowで一元管理できるようになりました
  • 状態の矛盾解消: 「Loading=true なのに Data が表示される」といった矛盾した状態が発生しにくくなりました

これらは決して小さくない前進でした。

しかし、ViewModel層に問題があった

問題は、ViewModel層の実装にありました。最初の実装はこのようなものでした。

// ViewModel
class ContentViewModel @Inject constructor(
    private val getContentUseCase: GetContentUseCase,
    private val removeContentUseCase: RemoveContentUseCase,
) : ViewModel() {

    private val _uiState = MutableStateFlow<ContentUiState>(ContentUiState.Loading)
    val uiState: StateFlow<ContentUiState> = _uiState.asStateFlow()

    // Intentを受け取るメソッド
    fun onIntent(intent: ContentIntent) {
        when (intent) {
            is ContentIntent.Initialize -> initialize()
            is ContentIntent.RemoveItem -> removeItem(intent.itemId)
            // 他のIntentも同様...
        }
    }

    // 実際の処理は通常のメソッド
    private fun initialize() {
        viewModelScope.launch {
            _uiState.value = ContentUiState.Loading
            getContentUseCase().collect { items ->
                _uiState.value = ContentUiState.Success(items)
            }
        }
    }

    private fun removeItem(itemId: String) {
        viewModelScope.launch {
            removeContentUseCase(itemId)
            // 状態更新はUseCaseのFlowに任せる
        }
    }
}

何が問題だったのか

一見するとMVIのように見えますが、いくつかの致命的な問題がありました。

1. Intentが単なるメソッド呼び出しのラッパー

onIntentメソッドは、Intentを受け取って対応するメソッドを呼び出しているだけです。これでは、直接メソッドを呼べばいいのに、わざわざIntentを経由する意味がありません。

2. 状態管理がViewModelに直接実装されている

状態変更のロジックがViewModelに散在しており、どこで状態が変わるのか追いにくくなっています。また、ViewModelが肥大化する原因にもなります。

3. 副作用と純粋な状態変換が混在

removeItemメソッドではUseCaseの呼び出し(副作用)と状態管理が混在しており、テストが困難です。モックが多く必要になり、テストコードが複雑化します。

4. テスタビリティの低さ

状態変更のロジックが純粋関数として分離されていないため、状態遷移のテストにViewModelのモックが必要になります。これは本来MVIが解決すべき課題でした。

この実装は、MVIの形だけを取り入れた「なんちゃってMVI」だったのです。

真のMVIへの再設計

アーキテクチャの全体像

真のMVIを実現するために、以下の責務分離を行いました。

なんちゃってMVIのフロー(問題あり)

データフロー

  • View → Intent → ViewModel
  • ViewModel内で状態管理と副作用が混在
  • ViewModel → State → View (再描画)

問題点

  • onIntent() がメソッドを直接呼び出しているだけ
  • 状態管理と副作用が混在
  • テストが困難(モックだらけ)
  • 責務が分離されておらず、ViewModelが肥大化

真のMVIのフロー(改善後)

graph TD View1["View"] -->|"Intent
ユーザーの意図"| ViewModel["ViewModel
薄い仲介役"] ViewModel -->|Intent| StateManager["StateManager
Intent→Action変換"] StateManager -->|Action| ActionCheck{"Actionの種類"} ActionCheck -->|純粋な状態変更| Reducer["Reducer
純粋関数
副作用なし"] Reducer -->|新しいState| StateManager2["StateManager
状態を更新"] ActionCheck -->|副作用が必要| ViewModel2["ViewModel
副作用Actionを処理"] ViewModel2 -->|UseCase呼び出し| Presenter["Presenter
副作用処理"] Presenter -->|Result| ViewModel3["ViewModel
Result→Action変換"] ViewModel3 -->|Action| StateManager3["StateManager
Reducerで状態更新"] StateManager2 -->|State更新| View2["View 再描画"] StateManager3 -->|State更新| View2 style View1 fill:#e6f3ff,stroke:#0066cc,stroke-width:2px style View2 fill:#e6f3ff,stroke:#0066cc,stroke-width:2px style ViewModel fill:#d4edda,stroke:#28a745,stroke-width:2px style ViewModel2 fill:#d4edda,stroke:#28a745,stroke-width:2px style ViewModel3 fill:#d4edda,stroke:#28a745,stroke-width:2px style StateManager fill:#fff3cd,stroke:#ffc107,stroke-width:2px style StateManager2 fill:#fff3cd,stroke:#ffc107,stroke-width:2px style StateManager3 fill:#fff3cd,stroke:#ffc107,stroke-width:2px style Reducer fill:#cce5ff,stroke:#004085,stroke-width:2px style Presenter fill:#f8d7da,stroke:#721c24,stroke-width:2px style ActionCheck fill:#e2e3e5,stroke:#383d41,stroke-width:2px

データフロー

  1. View → Intent → ViewModel → StateManager
  2. StateManager: Intent を複数の Action に変換
  3. Action の種類により分岐:
    • 純粋な状態変更Action: StateManager → Reducer → StateManager(状態更新)
    • 副作用が必要なAction: StateManager → ViewModel → Presenter → (Result) → ViewModel → Action → StateManager
  4. StateManager → View (状態更新により再描画)

重要なポイント

  • 全てのActionはStateManagerを経由します
  • ViewModelは副作用Actionを検出した場合のみPresenterを呼び出します
  • Presenterの結果は必ずActionに変換され、StateManagerに戻ります
  • 純粋な状態変更は常にReducerで処理されます

改善点

  • 全てのActionがStateManagerを経由
  • 各コンポーネントの責務が明確
  • テストが容易

各コンポーネントの責務

1. Intent - ユーザーの意図を表現

sealed interface ContentIntent : MviIntent {
    /** 画面初期化 */
    data object Initialize : ContentIntent

    /** タブ選択 */
    data class SelectTab(val tab: Tab) : ContentIntent

    /** アイテム削除 */
    data class RemoveItem(val itemId: String) : ContentIntent

    /** アイテム削除の取り消し */
    data class UndoRemoveItem(val item: ContentItem) : ContentIntent
}

Intentはユーザーの操作や画面の意図を表現するだけです。実装の詳細は含みません。

2. Action - 内部的な状態変更命令

sealed interface ContentAction : MviAction {
    // ===== 純粋な状態変更 =====
    data object ShowLoading : ContentAction

    data class UpdateItems(
        val items: List<ContentItem>,
    ) : ContentAction

    data class ChangeTab(
        val tab: Tab,
        val isUserAction: Boolean = false, // ユーザーが手動でタブを変更したか
    ) : ContentAction

    // ===== 副作用トリガー =====
    data object RequestInitialLoad : ContentAction

    data class RequestRemoveItem(
        val itemId: String,
    ) : ContentAction

    // ===== 副作用完了通知 =====
    data class ItemRemoved(
        val itemId: String,
    ) : ContentAction

    data class RemovalFailed(
        val error: String,
    ) : ContentAction
}

ActionはIntentよりも細かく、実装の詳細を含みます。1つのIntentから複数のActionが生成されることもあります。

3. StateManager - フロー制御の中心

class ContentStateManager @Inject constructor(
    coroutineScope: CoroutineScope,
    private val reducer: ContentReducer,
) : StateManager<ContentIntent, ContentAction, ContentUiState>(
    coroutineScope = coroutineScope,
    initialState = ContentUiState.Loading,
) {
    // IntentをActionに変換
    override fun processIntent(intent: ContentIntent): List<ContentAction> =
        when (intent) {
            is ContentIntent.Initialize -> {
                listOf(
                    ContentAction.ShowLoading,
                    ContentAction.RequestInitialLoad,
                )
            }
            is ContentIntent.SelectTab -> {
                listOf(ContentAction.ChangeTab(intent.tab, isUserAction = true))
            }
            // 他のIntentも同様のパターン...
        }

    // Actionを処理
    override suspend fun handleAction(action: ContentAction) {
        // Reducerで純粋な状態更新
        val newState = reducer.reduce(getCurrentState(), action)
        updateState(newState)
    }
}

StateManagerは、Intentの解釈とReducerの呼び出しを行う中心的な役割です。Presenterとの直接的な依存はなく、ViewModelが仲介することでシンプルな設計を保っています。

4. Reducer - 純粋な状態更新

class ContentReducer @Inject constructor() {
    fun reduce(
        state: ContentUiState,
        action: ContentAction,
    ): ContentUiState =
        when (action) {
            is ContentAction.ShowLoading -> ContentUiState.Loading

            is ContentAction.UpdateItems -> {
                ContentUiState.Success(
                    selectedTab = (state as? ContentUiState.Success)?.selectedTab
                        ?: Tab.SAVED,
                    items = action.items.toImmutableList(),
                )
            }

            is ContentAction.ChangeTab -> {
                (state as? ContentUiState.Success)?.copy(
                    selectedTab = action.tab,
                ) ?: state
            }

            // 他のActionも同様のパターン...
            // 副作用トリガーActionはReducerで処理しない
            else -> state
        }
}

Reducerは完全な純粋関数です。以下のことは一切行いません。

  • 外部APIへのアクセス
  • データベース操作
  • ログ出力
  • Analytics送信

これにより、状態遷移のテストが極めて簡単になります。

5. Presenter - 副作用処理

class ContentPresenter @Inject constructor(
    private val getContentUseCase: GetContentUseCase,
    private val removeItemUseCase: RemoveItemUseCase,
    private val analyticsHelper: ContentAnalyticsHelper,
) {
    // データロード(結果をFlowで返す)
    fun loadInitialData(): Flow<LoadResult> =
        getContentUseCase()
            .map { items ->
                LoadResult.Success(items) as LoadResult
            }
            .catch { e ->
                emit(LoadResult.Error(e.message ?: "Unknown error"))
            }

    // アイテム削除(結果を返す)
    suspend fun removeItem(itemId: String): RemovalResult =
        removeItemUseCase(itemId)
            .fold(
                onSuccess = { RemovalResult.Success(itemId) },
                onFailure = { e ->
                    RemovalResult.Error(e.message ?: "Unknown error")
                }
            )

    // Analytics送信
    fun sendScreenEvent(tab: Tab) {
        analyticsHelper.sendScreenEvent(tab)
    }
}

Presenterは副作用(データ取得、削除処理、Analyticsなど)を担当します。StateManagerへの依存はなく、結果を返すだけのシンプルな設計です。ViewModelが結果を受け取り、StateManagerにActionを送信します。

6. ViewModel - 薄い調整層

@HiltViewModel
class ContentViewModel @Inject constructor(
    private val stateManager: ContentStateManager,
    private val presenter: ContentPresenter,
) : ViewModel() {

    val uiState: StateFlow<ContentUiState> = stateManager.state

    fun onIntent(intent: ContentIntent) {
        viewModelScope.launch {
            stateManager.processIntent(intent).forEach { action ->
                when (action) {
                    // 副作用トリガーの例: アイテム削除
                    is ContentAction.RequestRemoveItem -> {
                        when (val result = presenter.removeItem(action.itemId)) {
                            is RemovalResult.Success -> {
                                stateManager.handleAction(
                                    ContentAction.ItemRemoved(result.itemId)
                                )
                            }
                            is RemovalResult.Error -> {
                                stateManager.handleAction(
                                    ContentAction.RemovalFailed(result.message)
                                )
                            }
                        }
                    }
                    // その他の副作用トリガーも同様のパターン
                    // 純粋な状態変更ActionはStateManagerへ
                    else -> {
                        stateManager.handleAction(action)
                    }
                }
            }
        }
    }

    // Analytics専用メソッド(後述)
    fun sendScreenEvent(tab: Tab) {
        presenter.sendScreenEvent(tab)
    }
}

ViewModelは非常に薄くなり、Presenterの結果をStateManagerのActionに変換する仲介役に徹しています。この設計により、StateManagerとPresenterは互いに依存せず、テストしやすい構造になっています。

ViewModelのテストでは、PresenterとStateManagerをモック化して、仲介ロジック(結果をActionに変換する部分)が正しく機能するかを検証します。ただし、ViewModelが非常に薄いため、統合テストで十分カバーできる場合もあります。

データフローの実例

タブ選択の流れを見てみましょう。

1. [View] ユーザーがタブをタップ
   ↓
2. viewModel.onIntent(ContentIntent.SelectTab(tab))
   ↓
3. [StateManager] processIntent
   → ContentAction.ChangeTab(tab, isUserAction = true)
   ↓
4. [Reducer] reduce
   → state.copy(selectedTab = tab)
   ↓
5. [StateManager] updateState
   → _state.value = newState
   ↓
6. [View] uiStateの変更を検知して再描画

全てのデータフローが一方向で、予測可能になりました。

実用的な判断: Analyticsの扱い

MVIの原則と現実のバランス

MVIの厳格な原則では、全ての操作はIntentを経由すべきです。しかし、実際の開発では実用的な判断も必要です。

私は、Analytics送信については直接呼び出しを許容することにしました。その理由は以下の通りです。

  1. 状態に影響を与えない: Analytics送信は純粋な観測的副作用であり、アプリの状態を一切変更しません
  2. ビジネスロジックを含まない: 単なる計測・追跡であり、アプリの振る舞いには影響しません
  3. 頻繁に変更される: 分析要件は頻繁に変わるため、Intent化すると変更コストが高くなります

判断基準

以下の基準で、Intent経由か直接呼び出しかを判断します。

操作 パターン 理由
タブ選択 Intent 状態を変更する
アイテム削除 Intent 状態を変更し、副作用がある
Screen表示イベント送信 直接呼び出し 状態に影響なし、観測のみ
タブ切り替えイベント送信 直接呼び出し 状態に影響なし、観測のみ

簡単な判断基準 - 状態を変更する → Intent必須 - 純粋な観測(Analytics等) → 直接呼び出しOK

Analytics実装例

// View (Compose)
@Composable
fun ContentScreen(
    viewModel: ContentViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 状態変更: 必ずIntentを使用
    Tab(
        selected = tab == uiState.selectedTab,
        onClick = {
            viewModel.onIntent(ContentIntent.SelectTab(tab))
        }
    )

    // Analytics: 直接呼び出しOK
    LaunchedEffect(uiState.selectedTab) {
        uiState.selectedTab?.let {
            viewModel.sendScreenEvent(it)
        }
    }
}

この実用的な例外により、MVIの本質的な利点(テスタビリティ、予測可能性)を保ちつつ、現実的な開発効率を実現できました。

実装結果と得られた価値

コードの大幅な削減

ViewModelの責務を分離した結果、コード行数が大幅に削減されました。

  • ViewModel: 大幅に削減
  • 状態管理ロジックが明確に分離
  • 各コンポーネントの役割が一目瞭然

テスタビリティの飛躍的向上

最も大きな成果は、テストが極めて書きやすくなったことです。

// Reducerのテスト - モック不要の純粋関数テスト
@Test
fun `タブ変更アクションで選択タブが更新される`() {
    val initialState = ContentUiState.Success(
        selectedTab = Tab.SAVED,
        items = persistentListOf(),
    )

    val newState = reducer.reduce(initialState, ContentAction.ChangeTab(Tab.HISTORY))

    assertThat((newState as ContentUiState.Success).selectedTab)
        .isEqualTo(Tab.HISTORY)
}

// Presenterのテスト - 結果の検証
@Test
fun `アイテム削除成功時にSuccessが返る`() = runTest {
    coEvery { removeItemUseCase(any()) } returns Result.success(Unit)

    val result = presenter.removeItem("item-1")

    assertThat(result).isInstanceOf(RemovalResult.Success::class.java)
    assertThat((result as RemovalResult.Success).itemId).isEqualTo("item-1")
}

モックが最小限で済み、テストコードが簡潔になりました。

横展開の可能性

この設計により、他の大規模な画面への適用も見えてきました。

  • 基盤クラス(MviPresenter、StateManager等)の再利用
  • 責務分離パターンの横展開
  • チーム全体での統一されたアーキテクチャ

まとめ

「なんちゃってMVI」から「真のMVI」への移行は、単なるリファクタリングではなく、アーキテクチャの本質を理解する学びのプロセスでした。

重要なのは、形だけMVIを取り入れるのではなく、以下を実現することです。

  • 責務の完全な分離
  • 純粋関数としてのReducer
  • 副作用の明確な管理
  • 実用的な判断との両立

また、理想的なアーキテクチャと現実的な開発効率のバランスも重要です。Analyticsのような純粋な観測は直接呼び出しを許容するなど、柔軟な判断がプロジェクトの成功につながります。

次のステップとして、この設計を他の複雑な画面にも適用していく予定です。MVIの真価は、小規模な機能ではなく、大規模で複雑な状態管理を持つ画面でこそ発揮されると考えています。

本記事の評価は筆者の経験と観測に基づくものです。技術選択は、プロジェクトや企業により大きく異なります。