はじめに
Android アプリのアーキテクチャとして長らく主流だった MVVM に加えて、近年は MVI(Model-View-Intent)を採用するプロジェクトも増えてきました。特に Jetpack Compose と組み合わせる文脈で「MVIっぽい設計」を耳にする機会が多いのではないでしょうか。
しかし両者は考え方が近い部分もあり、
- MVVMなのに複数のStateFlowを公開していて、実質MVIっぽくなっている
- 「MVIにした」つもりが、単にViewModelの中でwhen分岐が増えただけ
というように、境界が曖昧なまま使われているケースも少なくありません。本記事では、同じ機能(ログイン画面)を MVVM と MVI それぞれで実装し、実例ベースで違いを整理します。
結論を先に
| MVVM | MVI | |
|---|---|---|
| 状態の持ち方 | 複数のプロパティ(StateFlowが複数) | 単一のImmutableな State オブジェクト |
| Viewからの入力 | メソッド呼び出し(viewModel.onLoginClick()) |
Intent/Actionというデータクラスの送信 |
| 状態の変化経路 | 各メソッドが個別にStateを更新 | 単一のReducer(あるいはwhen式)が一元的にStateを更新 |
| 予測可能性 | メソッドが増えるほど変化の経路が分散しやすい | すべての変化が1箇所に集約されるため追いやすい |
| 学習コスト | 低い(Android開発者に馴染み深い) | やや高い(Intent/Reducerという概念が必要) |
一言でまとめると、
MVVMは「Viewの操作 = ViewModelのメソッド呼び出し」、MVIは「Viewの操作 = 一意のIntentとしてReducerに流し込む」
という設計思想の違いです。
MVVM: メソッド呼び出しで個別にStateを更新する
MVVM では、View(Composable)がユーザー操作をトリガーに ViewModel の各メソッドを直接呼び出し、ViewModel がそれぞれの StateFlow や MutableState を個別に更新するのが一般的な形です。
実例: ログイン画面(MVVM版)
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email.asStateFlow()
private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
fun onEmailChange(value: String) {
_email.value = value
}
fun onPasswordChange(value: String) {
_password.value = value
}
fun onLoginClick() {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
val result = authRepository.login(_email.value, _password.value)
_isLoading.value = false
if (result.isFailure) {
_errorMessage.value = "ログインに失敗しました"
}
}
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val email by viewModel.email.collectAsState()
val password by viewModel.password.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
Column {
TextField(value = email, onValueChange = viewModel::onEmailChange)
TextField(value = password, onValueChange = viewModel::onPasswordChange)
errorMessage?.let { Text(it, color = Color.Red) }
Button(onClick = viewModel::onLoginClick, enabled = !isLoading) {
Text(if (isLoading) "ログイン中..." else "ログイン")
}
}
}
この形は直感的で書きやすい反面、State が4つのプロパティに分散しています。機能が増えるたびに _xxx と xxx のペアが増え、「今どの組み合わせのStateがあり得るのか」をコードから追いにくくなっていくのが弱点です。例えば「ローディング中なのにエラーメッセージが表示されている」といった、本来あり得ないはずの状態の組み合わせもコンパイラは防いでくれません。
MVI: Intentを介して単一のStateを更新する
MVI では、Viewからの操作を Intent(Action) という単一の型で表現し、それを ViewModel(あるいは Reducer/Store)に送信します。ViewModel は Intent を受け取り、単一のStateオブジェクトを新しく生成して返します。
実例: ログイン画面(MVI版)
// 単一のImmutableなState
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
)
// Viewから送られてくる操作をすべて表現するIntent
sealed interface LoginIntent {
data class EmailChanged(val value: String) : LoginIntent
data class PasswordChanged(val value: String) : LoginIntent
data object LoginClicked : LoginIntent
}
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.EmailChanged ->
_state.update { it.copy(email = intent.value) }
is LoginIntent.PasswordChanged ->
_state.update { it.copy(password = intent.value) }
is LoginIntent.LoginClicked -> login()
}
}
private fun login() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
val result = authRepository.login(_state.value.email, _state.value.password)
_state.update {
if (result.isSuccess) {
it.copy(isLoading = false)
} else {
it.copy(isLoading = false, errorMessage = "ログインに失敗しました")
}
}
}
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
Column {
TextField(
value = state.email,
onValueChange = { viewModel.onIntent(LoginIntent.EmailChanged(it)) }
)
TextField(
value = state.password,
onValueChange = { viewModel.onIntent(LoginIntent.PasswordChanged(it)) }
)
state.errorMessage?.let { Text(it, color = Color.Red) }
Button(
onClick = { viewModel.onIntent(LoginIntent.LoginClicked) },
enabled = !state.isLoading
) {
Text(if (state.isLoading) "ログイン中..." else "ログイン")
}
}
}
State がすべて LoginState という一つのdata classに集約されているため、「今どんな状態になり得るか」がその型定義を見るだけで一目瞭然です。また、Viewから送られてくる操作もすべて LoginIntent という sealed interface で表現されるので、when の網羅性チェックによって「対応漏れ」がコンパイル時に検出できます。
実例で見る違いのポイント
ポイント1: Stateの分散 vs 集約
MVVM 版では email・password・isLoading・errorMessage が別々の StateFlow として存在するため、Composable側で4つの collectAsState() が必要でした。MVI 版では state.email のように単一のオブジェクトからアクセスするだけで済み、UIテストで「特定の状態をまとめて再現する」ことも LoginState(isLoading = true) のように1行で書けます。
ポイント2: 変更経路の分散 vs 集約
MVVM 版では onEmailChange・onPasswordChange・onLoginClick という3つのメソッドがそれぞれ独立してStateを書き換えています。機能追加のたびにメソッドが増え、どのメソッドがどのStateに影響するかを個別に把握する必要があります。
MVI 版では onIntent という単一の入口を通り、when 式の中にすべての状態遷移が列挙されます。ロジックが1メソッドに集まる分、そのメソッドが肥大化しやすいというトレードオフはありますが、「すべての状態変化はここを見ればわかる」という予測可能性は高くなります。
ポイント3: テストのしやすさ
MVI 版は「ある LoginIntent を投げたら、Stateがどう変わるか」という純粋な入出力としてテストを書きやすいという特徴があります。
@Test
fun `メールアドレス入力でStateが更新される`() = runTest {
val viewModel = LoginViewModel(fakeAuthRepository)
viewModel.onIntent(LoginIntent.EmailChanged("test@example.com"))
assertEquals("test@example.com", viewModel.state.value.email)
}
MVVM 版でも同様のテストは可能ですが、検証すべき StateFlow がプロパティごとに分かれているため、機能が増えるにつれてテストコードも分散しがちです。
完全なMVIフレームワークのデメリット
ここまで見ると「State管理も入力もMVIに寄せた方が予測可能性が高くて良さそう」と思えるかもしれません。しかし、Intent/Reducerを愚直に徹底する「完全なMVI」を実プロジェクトに導入すると、以下のようなデメリットが表面化しやすくなります。
-
ボイラープレートの増加: すべての操作をIntentのdata classとして定義する必要があり、単純な1行のプロパティ変更でも
Intentクラス、whenの分岐、Reducer側の処理を書く手間が発生する -
Reducerの肥大化: 画面の操作が増えるほど、単一の
when式(あるいはReducer関数)が数百行規模まで膨れ上がりやすく、結局は可読性を損なう - 学習コストの高さ: Intent・Reducer・Effect(副作用)・Result といった独自の語彙をチームに浸透させる必要があり、特にAndroid経験者ほど「なぜ素直にメソッドを呼べないのか」という抵抗感を持ちやすい
- ライブラリ依存のリスク: 専用フレームワークを導入すると、それ自体の学習コストやアップデート追従のコストが追加でかかる
- 過剰設計になりやすい: 単純なCRUD画面や状態遷移がほとんどない画面にまでIntent/Reducerを適用すると、素直なMVVMより明らかに実装量が増えてしまう
これらのデメリットから、実務では「厳密なMVI」をそのまま採用するプロジェクトは意外と少なく、MVI風のMVVMという中間的な設計に落ち着くケースが多く見られます。
実務での落とし所: MVI風MVVM
MVI風MVVMとは、Stateだけを単一のImmutableなdata classに集約しつつ、Viewからの入力はMVVMのまま素直なメソッド呼び出しにするというアプローチです。今回のログイン画面であれば、以下のような形になります。
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
// IntentのData classやwhen分岐は作らず、メソッド呼び出しのまま
fun onEmailChange(value: String) {
_state.update { it.copy(email = value) }
}
fun onPasswordChange(value: String) {
_state.update { it.copy(password = value) }
}
fun onLoginClick() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
val result = authRepository.login(_state.value.email, _state.value.password)
_state.update {
if (result.isSuccess) {
it.copy(isLoading = false)
} else {
it.copy(isLoading = false, errorMessage = "ログインに失敗しました")
}
}
}
}
}
Viewからは viewModel.onEmailChange(it) のようにこれまで通り直感的に呼び出せる一方、Composable側は state.email のように単一のStateオブジェクトから値を取り出せるため、MVIの最大の恩恵である「あり得ない状態の組み合わせを防ぐ」というメリットはそのまま享受できます。Intentのdata classやReducerの when 分岐を用意する手間がない分、MVVMに近い学習コストと実装量で運用できるのが利点です。
この設計は「MVIの思想を一部だけ取り入れたMVVM」とも言えますが、実際に多くのAndroidプロジェクトで採用されているのはこのパターンです。操作のログ収集・リプレイのしやすさなど、Intent自体を型として持つメリットが本当に必要な場面でなければ、無理に完全なMVIへ移行する必要はありません。
どちらを選ぶべきか
- 画面の状態がシンプルで、メソッド単位で分かりやすく管理できる規模 → MVVMで十分なことが多い
- Stateの組み合わせ爆発を防ぎたいが、Intent/Reducerのボイラープレートは避けたい → MVI風MVVM(State集約 + メソッド呼び出し)が現実的な落とし所
- 操作のログ収集・リプレイ・Undo/Redoなど、Intentを型として持つ必然性がある → 完全なMVIの導入を検討する価値がある
まとめ
- MVVM は メソッド呼び出しで個別にStateを更新する、直感的で学習コストの低い設計
- MVI は Intentという単一の入力と単一のStateオブジェクトによって状態遷移を一箇所に集約する設計だが、ボイラープレートの増加やReducerの肥大化といったデメリットも大きい
- 実務では完全なMVIをそのまま採用するより、**StateだけMVIのように単一オブジェクトに集約し、入力はMVVMのままメソッド呼び出しにする「MVI風MVVM」**が採用されることが多い
「MVIか、MVVMか」の二択で考えるのではなく、MVIの思想のうち何を取り入れれば自分たちのプロジェクトにとって割に合うのかを見極めることが、現実的なアーキテクチャ選定のポイントです。