MVI — モバイルアプリにおけるModel-View-Intentパターンを理解する

著者: IT Sectr 公開日: 2026-02-16 読了時間: 10 分

MVI(Model-View-Intent)は、単方向データフローと不変状態に基づくリアクティブアーキテクチャパターンです。ViewModelが複数のStateFlowsを持つことができるMVVMとは異なり、MVIは単一の状態(State)、不変の意図(Intent)、および純粋なリデューサー関数(Reducer)を定義します。MVIは任意の時点での画面状態の予測可能性を保証します。このパターンはMosbyおよびOrbitライブラリによってAndroidコミュニティで普及しました。詳細はArkadii IvanovのMVIKotlinをご覧ください。

重要ポイント

  • MVI — Model(状態)、View(表示)、Intent(ユーザーの意図)— リアクティブサイクル
  • Unidirectional data flow — データは一方向に流れる:Intent → Reducer → State → View
  • Immutable State — 画面状態は不変オブジェクトであり、変更のたびに再作成される
  • Reducer — 現在の状態とIntentを受け取り、新しい状態を返す純粋関数
  • Side effects — 副作用(ネットワーク、DB)はReducerから分離してMiddlewareを介して処理される

MVIとは:Model-View-Intentパターンの本質

MVI(Model-View-Intent)は、ReduxとCycle.jsの原則に基づいて構築されたリアクティブアーキテクチャパターンです。Modelは不変の画面状態、Intentはユーザーまたはシステムの意図、Viewは状態を購読してIntentsを送信します。データはサイクルで流れます:ユーザーがViewと対話 → ViewがIntentを作成 → IntentがReducerによって処理 → Reducerが新しい状態を作成 → Viewが新しい状態を受け取って再レンダリング。

MVIとMVVMの主な違いは単一の真実源(Single Source of Truth)です。MVVMでは、ViewModelは複数のLiveData/StateFlow(userState、loadingState、errorState)を持つことができ、loading=trueとuser=nullが同時に発生する不整合を引き起こす可能性があります。MVIでは、画面全体の状態を記述するsealed class/interface Stateが正確に1つ存在します。任意の時点で、画面状態は一意に決定されます — データが既にロードされているときにloading=trueになることは不可能です。IT Sectrでは、状態の予測可能性が重要な複雑なロジックを持つ画面 — 注文フォーム、マルチステップ登録、金融画面 — にMVIを適用しています。

コンポーネントMVIでの役割
Intentユーザーまたはシステムの意図LoadUser, Refresh, SubmitForm
State不変の画面状態sealed class UserState
Reducer純粋関数:State + Intent → Statefun reduce(state, intent) -> state
Middleware副作用の処理ネットワークリクエスト、DB書き込み

MVIサイクルは5つのステップで構成されます:1)ViewがIntentを送信(例:LoadUser(42));2)Middleware(EffectHandler)が副作用を実行 — ネットワークリクエスト;3)結果が新しいIntentとしてシステムに返される;4)Reducerが現在の状態とIntentを受け取り、新しい状態を作成;5)Viewが新しい状態を受け取って再レンダリング。各ステップは予測可能で、分離してテスト可能です。

AndroidのMVI:KotlinでのIntent、Reducer、State

AndroidのMVIは、IntentとStateにsealedクラス、MVIロジックを持つViewModel、およびリアクティブレンダリングのためのJetpack Composeを使用して実装されます。ViewModelはViewからIntentを受け取り、副作用をMiddlewareに委任し、Reducerを実行し、StateFlowを介して新しい状態を公開します。Jetpack Composeは状態が変化するとUIを再レンダリング — MVIサイクルに最適です。

kotlin
// Intent — ユーザーの意図
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — 単一の画面状態
sealed interface UserState {
    data object Idle : UserState
    data object Loading : UserState
    data class Success(val user: User) : UserState
    data class Error(val message: String) : UserState
}

// Reducer — 純粋関数
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// MVIを使用したViewModel
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow<UserState>(UserState.Idle)
    val state: StateFlow<UserState> = _state.asStateFlow()

    fun process(intent: UserIntent) {
        val newState = UserReducer.reduce(_state.value, intent)
        _state.value = newState
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.userId)
            is UserIntent.Refresh -> loadUser(/* 前のID */)
        }
    }

    private fun loadUser(userId: Int) {
        viewModelScope.launch {
            repository.getUser(userId)
                .onSuccess { user ->
                    _state.value = UserState.Success(user)
                }
                .onFailure { e ->
                    _state.value = UserState.Error(e.message ?: "Error")
                }
        }
    }
}

// ViewがIntentを送信
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.state.collectAsState()
    LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
    when (state) {
        is UserState.Loading -> CircularProgressIndicator()
        is UserState.Success -> UserCard((state as UserState.Success).user)
        is UserState.Error -> ErrorView((state as UserState.Error).message)
        is UserState.Idle -> Text("Press to load")
    }
}

Middlewareと副作用 — MVIでは、純粋なReducerはネットワークリクエストを実行できません。Middleware(EffectHandlerまたはBootstrapperとも呼ばれる)はIntentを処理し、副作用を実行し、新しいIntentをサイクルに戻します。Orbit MVIおよびMVIKotlinライブラリは、テスト可能なエフェクトを備えた組み込みのMiddlewareサポートを提供します。Middlewareがないと、MVIは追加のIntentおよびState構造を持つMVVMに退化します。

Arkadii IvanovのMVIKotlinは、Kotlin Multiplatform向けの最も人気のあるMVIライブラリです。Android、iOS、ウェブ、JVMをサポートしています。Store(ViewModel)、Bootstrapper(初期エフェクト)、Reducer、Middlewareのコンポーネントを提供します。2025年10月時点で、このライブラリはGitHubで2.5Kのスターを集めており、ロシアの大手銀行のアプリケーションを含む商用プロジェクトで使用されています。IT Sectrでは、共有ビジネスロジックを持つクロスプラットフォームKMPプロジェクトにMVIKotlinを使用しています。

iOSのMVI:Swiftでの単方向フロー

iOSのMVIは、Combine-ViewModelなしで、Intent → Stateサイクルを通じて実装されます。Viewはクロージャを介してIntentを送信し、Reducerは純粋関数、Stateは不変フィールドを持つ構造体です。Stateが変更されるとSwiftUIはViewを再レンダリングし、追加の@PublishedプロパティなしでMVIサイクルに完全に適合します。iOSのMVIは、Redux(JavaScript)から移行したSwiftUI開発者の間で特に人気があります。

swift
// State — 不変構造
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — 意図を持つenum
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — 純粋関数
func userReducer(state: UserState, intent: UserIntent) -> UserState {
    var newState = state
    switch intent {
    case .loadUser, .refresh:
        newState.isLoading = true
        newState.errorMessage = nil
    case .userLoaded(let user):
        newState.isLoading = false
        newState.user = user
    case .loadFailed(let error):
        newState.isLoading = false
        newState.errorMessage = error.localizedDescription
    }
    return newState
}

// Store — 状態を所有しエフェクトを管理
final class UserStore: ObservableObject {
    @Published private(set) var state = UserState()
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func dispatch(_ intent: UserIntent) {
        // 1. Reducerが状態を更新
        state = userReducer(state: state, intent: intent)
        // 2. Side effects(必要な場合)
        switch intent {
        case .loadUser(let id), .refresh:
            service.fetchUser(id: id) { [weak self] result in
                switch result {
                case .success(let user):
                    self?.dispatch(.userLoaded(user))
                case .failure(let error):
                    self?.dispatch(.loadFailed(error))
                }
            }
        default: break
        }
    }
}

TCA(The Composable Architecture)は、Point-FreeによるiOS向けの最も人気のあるMVI実装であり、SwiftUIとCombine上に構築されています。TCAはStore、Reducer、Effect、Environmentを提供します。2025年10月時点で、GitHubのスターは13Kを超えています — iOS上のMVIの事実上の標準です。TCAはStarbucksアプリ、Airbnb(部分的に)、および多くの個人プロジェクトで使用されています。カスタムMVIとは異なり、TCAはテスト、ナビゲーション、副作用をすぐに解決します。

iOSでのMVI vs MVVM — TCA/MVIは状態の予測可能性を提供しますが、より多くのボイラープレートコード(Reducer、State、Action)が必要です。@Publishedを使用したMVVMは基本的な画面ではよりシンプルです。IT Sectrでは、80%の画面にMVVM、20%の複雑な画面 — 金融取引、マルチステップフォーム、ドラッグ&ドロップインターフェース — にMVI(TCA)を使用しています。状態エラーがユーザーの金銭的損失につながる可能性がある場合です。

MVIとMVVMの比較:MVIを選ぶべき時

MVIとMVVMは同じ問題を解決します — プレゼンテーション層の整理 — しかし、状態管理へのアプローチが異なります。MVVMは複数のリアクティブソース(LiveData、@Published)を許可し、不整合を引き起こす可能性があります。MVIは任意の時点で正確に1つの状態を保証し、より厳格で予測可能ですが、コード量が増加します。

基準MVVMMVI
状態複数のLiveData/StateFlow単一のsealed class State
データフロー双方向(View → ViewModel、LiveData → View)単方向(Intent → Reducer → State → View)
副作用ViewModel内で直接Middleware/EffectHandler経由
テストViewModelの単体テストReducer + Middlewareの単体テスト
ボイラープレートコード最小限Reducer + State + Intent + Middleware

MVIを選ぶべき時 — 状態が厳密に決定論的でなければならない画面:金融操作、ショッピングカート、各ステップで検証を行うマルチステップフォーム。これらのシナリオでは、状態エラーのコスト(例えば、2つのLiveData間の競合状態により1つのアイテムが欠落したカート合計を表示する)が追加コードのコストを上回ります。MVVMではチームの規律に依存しますが、MVIではアーキテクチャに依存します。

MVVMで十分な時 — 標準的な画面の80%:ユーザー一覧、プロフィール、設定、ニュースフィード。ここでは単一状態は過剰であり、追加のMVI構造は開発を遅くします。IT Sectrのルールは:画面に3つ以上の可能な状態と遷移(ローディング → データ → エラー → 再試行 → ローディング → データ)がある場合 — MVIを使用。画面に1〜2の非同期操作がある場合 — MVVMを使用。

MVIのベストプラクティスとよくある間違い

Sealed State — MVIのベストプラクティス。状態は、Loading、Success(data)、Error(message)のバリアントを持つsealed class/interfaceとして定義されます。これにより、Viewが不整合な状態にならないことが保証されます — LoadingとSuccessは異なるクラスであるため、loading=trueの場合にデータを表示することはできません。状態に関連するすべてのデータはsealedバリアント内に存在します:Successはユーザーを含み、Errorはエラーメッセージを含みます。

Reducerは純粋関数のままであるべき — API、DB、SharedPreferencesの呼び出しなし。純粋関数はStateとIntentを受け取り、Stateを返します。副作用(ネットワーク、DB、ナビゲーション、トースト)は、MiddlewareまたはReducerを呼び出した後のStore.dispatchで処理されます。Reducerが副作用で汚染されると、MVIはテスト可能性と予測可能性を失います — 利点のない追加構造を持つMVVMになります。

よくある間違い — sealed classの代わりにnullableフィールドを持つdata classとしてStateを宣言:data class UserState(val user: User?, val isLoading: Boolean, val error: String?)。これはMVIではなくMVVMと同等です — Viewは有効性のためにフィールドの組み合わせをチェックする必要があります。sealedアプローチでは、無効な組み合わせ(isLoading=trueかつuser!=null)は型レベルで不可能です。2つ目の間違いは、ビジネスロジックをMiddlewareに配置する代わりに、Intent(Intent.LoadUserBeforeXHours)にビジネスロジックを配置することです。

よくある質問

MVIとMVVMの主な違いは何ですか?

MVIは単一の不変なsealed StateクラスとReducerを介した単方向データフローを使用します。MVVMは双方向バインディングを持つ複数のLiveData/StateFlowを許可します。MVIは型レベルで状態の一貫性を保証します — loading=trueとuser=nullが同時に発生することは不可能です。MVVMは開発者の規律に依存します。

Android用のMVIライブラリには何がありますか?

主要なもの:MVIKotlin(Arkadii Ivanov、2.5Kスター、Kotlin Multiplatform)、Orbit MVI(BabyJ、1.3Kスター)、Mobius(Spotify、Kotlin/Java)。MVIKotlinはKotlinで最も人気があり、Orbitは最も学習が簡単です。3つともテスト可能なReducerとMiddlewareをサポートしています。Jetpack Composeの場合は、sealed State + Reducerを使用してライブラリなしでシンプルなMVIを書くことができます。

MVIには別のライブラリが必要ですか?

いいえ — sealed Intent + sealed State + ViewModel + StateFlowで依存関係なしに機能するMVIが得られます。ライブラリ(MVIKotlin、Orbit、TCA)はMiddleware、副作用テスト、DI統合を追加します。シンプルなプロジェクトでは、ライブラリの重みは正当化されません。20+画面の複雑なプロジェクトでは、ライブラリは構造化されたエフェクト処理で価値を発揮します。

MVIはiOSに適していますか、それともAndroid専用のパターンですか?

MVIはTCA(The Composable Architecture) — SwiftUIコミュニティで最も人気のあるアーキテクチャ — を通じてiOSで非常にうまく機能します。TCAは基本的にMVI + Redux + Combineです。iOSでは、ObservableObjectと純粋なリデューサー関数を使用してTCAなしでMVIを実装できます。不変Stateを持つSwiftUIはMVIサイクルに完全に適合します。

MVIをテストするには?

Reducerは純粋関数として単体テストでテストされます:初期Stateを設定し、Intentを送信し、結果のStateを確認します。Middlewareはモックリポジトリでテストされます:LoadUserの後にgetUserが呼び出されたことを確認します。ViewModelテスト:Intentを送信し、StateFlowを確認します。MVIはReducerが隠れた依存関係のない純粋関数であるため、MVVMよりもテストが簡単です。

まとめ

  • MVI(Model-View-Intent)— 単方向フローと単一状態を持つリアクティブパターン
  • Sealed State — 型レベルで一貫性を保証し、無効な組み合わせを排除
  • Reducer — 純粋関数 State + Intent → State、モックオブジェクトなしでテスト可能
  • Middleware — 副作用(ネットワーク、DB、ナビゲーション)のための個別レイヤー
  • MVI vs MVVM — MVIはより厳格で予測可能、MVVMはよりシンプルで高速
  • Android — 複雑な画面にはMVIKotlinまたはOrbit;シンプルな画面にはMVVM
  • iOS — TCA(The Composable Architecture)がSwiftUIのMVI標準

ターンキー方式のモバイルアプリケーションを開発します

IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。

プロジェクトについて相談

こちらもお読みください