MVI(Model-View-Intent)は、単方向データフローと不変状態に基づくリアクティブアーキテクチャパターンです。ViewModelが複数のStateFlowsを持つことができるMVVMとは異なり、MVIは単一の状態(State)、不変の意図(Intent)、および純粋なリデューサー関数(Reducer)を定義します。MVIは任意の時点での画面状態の予測可能性を保証します。このパターンはMosbyおよびOrbitライブラリによってAndroidコミュニティで普及しました。詳細はArkadii IvanovのMVIKotlinをご覧ください。
重要ポイント
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 → State | fun 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は、IntentとStateにsealedクラス、MVIロジックを持つViewModel、およびリアクティブレンダリングのためのJetpack Composeを使用して実装されます。ViewModelはViewからIntentを受け取り、副作用をMiddlewareに委任し、Reducerを実行し、StateFlowを介して新しい状態を公開します。Jetpack Composeは状態が変化するとUIを再レンダリング — MVIサイクルに最適です。
// 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は、Combine-ViewModelなしで、Intent → Stateサイクルを通じて実装されます。Viewはクロージャを介してIntentを送信し、Reducerは純粋関数、Stateは不変フィールドを持つ構造体です。Stateが変更されるとSwiftUIはViewを再レンダリングし、追加の@PublishedプロパティなしでMVIサイクルに完全に適合します。iOSのMVIは、Redux(JavaScript)から移行したSwiftUI開発者の間で特に人気があります。
// 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は同じ問題を解決します — プレゼンテーション層の整理 — しかし、状態管理へのアプローチが異なります。MVVMは複数のリアクティブソース(LiveData、@Published)を許可し、不整合を引き起こす可能性があります。MVIは任意の時点で正確に1つの状態を保証し、より厳格で予測可能ですが、コード量が増加します。
| 基準 | MVVM | MVI |
|---|---|---|
| 状態 | 複数の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を使用。
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は単一の不変なsealed StateクラスとReducerを介した単方向データフローを使用します。MVVMは双方向バインディングを持つ複数のLiveData/StateFlowを許可します。MVIは型レベルで状態の一貫性を保証します — loading=trueとuser=nullが同時に発生することは不可能です。MVVMは開発者の規律に依存します。
主要なもの: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を書くことができます。
いいえ — sealed Intent + sealed State + ViewModel + StateFlowで依存関係なしに機能するMVIが得られます。ライブラリ(MVIKotlin、Orbit、TCA)はMiddleware、副作用テスト、DI統合を追加します。シンプルなプロジェクトでは、ライブラリの重みは正当化されません。20+画面の複雑なプロジェクトでは、ライブラリは構造化されたエフェクト処理で価値を発揮します。
MVIはTCA(The Composable Architecture) — SwiftUIコミュニティで最も人気のあるアーキテクチャ — を通じてiOSで非常にうまく機能します。TCAは基本的にMVI + Redux + Combineです。iOSでは、ObservableObjectと純粋なリデューサー関数を使用してTCAなしでMVIを実装できます。不変Stateを持つSwiftUIはMVIサイクルに完全に適合します。
Reducerは純粋関数として単体テストでテストされます:初期Stateを設定し、Intentを送信し、結果のStateを確認します。Middlewareはモックリポジトリでテストされます:LoadUserの後にgetUserが呼び出されたことを確認します。ViewModelテスト:Intentを送信し、StateFlowを確認します。MVIはReducerが隠れた依存関係のない純粋関数であるため、MVVMよりもテストが簡単です。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。