
MVI では、ユーザー操作を Intent として表現することがよくあります。
Kotlin では、この Intent を表現するために sealed interface がとても相性のよい選択肢になります。
sealed interface UiIntent {
data object Refresh : UiIntent
data object Retry : UiIntent
data class SelectItem(val id: Long) : UiIntent
}
こうすると、UI から ViewModel への入力を1つの入口にまとめられます。
fun onIntent(intent: UiIntent)
では、なぜ MVI で sealed interface が便利なのでしょうか。
🤔 Intent を1つの型にまとめられる
通常の ViewModel では、ユーザー操作ごとにメソッドを用意することがあります。
fun refresh()
fun retry()
fun selectItem(id: Long)
Intent を使えば、これらを1つの入口にまとめられます。
fun onIntent(intent: UiIntent)
UI から ViewModel への入力は、次のようになります。
UI
↓ UiIntent
ViewModel
これによって、UDF における入力側の流れが分かりやすくなります。
🤔 どんな Intent が存在するのか明示できる
sealed interface を見るだけで、その画面で扱う Intent が分かります。
sealed interface UiIntent {
data object Refresh : UiIntent
data object Retry : UiIntent
data class SelectItem(val id: Long) : UiIntent
}
つまり、Intent そのものが画面の入力モデルになります。
コードを読めば、
・ Refresh できる
・ Retry できる
・ Item を選択できる
ということが分かります。
🤔 when の網羅性をコンパイラにチェックさせられる
sealed interface の大きなメリットの1つがこれです。
when (intent) {
UiIntent.Refresh -> refresh()
UiIntent.Retry -> retry()
is UiIntent.SelectItem -> selectItem(intent.id)
}
Intent を追加すると、
data object LoadMore : UiIntent
その処理が必要な when をコンパイラが知らせてくれます。
つまり、Intent の追加が処理漏れにつながりにくいわけです。
🤔 Intent ごとに必要なデータを型で表現できる
Intent によって必要なデータは異なります。
data object Refresh : UiIntent
data object Retry : UiIntent
data class SelectItem(val id: Long) : UiIntent
Refresh にはデータは必要ありません。
一方、SelectItem には id が必要です。
これを Intent 自身の型として表現できます。
SelectItem(id)
「この操作にはこのデータが必要」という契約が、型として明確になります。
🤔 「何が起きたか」と「どう処理するか」を分離できる
例えば、
viewModel.selectItem(id)
と書くと、UI が ViewModel の具体的なメソッドを直接呼んでいます。
Intent を使うと、
onIntent(UiIntent.SelectItem(id))
となります。
UI は、
Item が選択された
という何が起きたかを伝えます。
それをどう処理するかは ViewModel 側が決めます。
UI
↓ SelectItem(id)
ViewModel
↓ 処理
State
この分離は、MVI の考え方と非常に相性がよいものです。
🤔 UDF の入力側をコードで表現しやすい
MVI の流れは、シンプルに考えるとこうなります。
UI
↓ Intent
State Processor
↓ State
UI
もちろん、sealed interface が UDF を作っているわけではありません。
しかし、
UI → Intent → State → UI
という流れをコード上で明確に表現できます。
アーキテクチャは、重要な関係がコードから読み取れることが大切です。
その意味で sealed interface は非常に便利です。
🤔 Intent の追加を「設計上の変更」として扱える
例えば、
data object LoadMore : UiIntent
を追加したとします。
これは単に ViewModel へ新しいメソッドを追加するのとは少し違います。
sealed interface UiIntent {
...
data object LoadMore : UiIntent
}
とすることで、
この画面が新しい入力を受け付けるようになったことが明確になります。
さらに when の網羅性チェックによって、対応すべき場所も見つけやすくなります。
UiIntent が、ある意味で画面の入力に対するチェックリストとして機能するわけです。
🤔 MVVM と MVIの違いも見えやすくなる
この考え方を使うと、MVVM と MVI の違いもコード上で比較しやすくなります。
MVVM
UI
│
├── refresh()
├── retry()
└── selectItem(id)
↓
ViewModel
一方、Intent を使う MVI では、
MVI
UI
│
└── onIntent(intent)
↓
sealed interface
├── Refresh
├── Retry
└── SelectItem(id)
↓
ViewModel
となります。
ここで重要なのは、
MVI だから sealed interface を使う
ということではありません。
むしろ、
Intent を明示的な型として扱いたいので、sealed interface が適している
と考える方が正確です。
🤔 まとめ
MVI で sealed interface を使うと、次のメリットがあります。
・ Intent を1つの型にまとめられる
・ 可能な Intent を明示できる
・ when による網羅性チェックを利用できる
・ Intent ごとのデータを型安全に表現できる
・ UI と ViewModel の入力契約が明確になる
・ 「何が起きたか」と「どう処理するか」を分離しやすい
・ UDF の入力側をコードとして表現しやすい
そして最も重要なのは、
Intent-driven な設計を、型安全かつ明示的に表現するのに sealed interface が適している。
ということです。
MVI において sealed interface は、アーキテクチャそのものではなく、アーキテクチャをコードに落とし込むための非常に便利な道具なのです。
🤔 参考
Related Categories : Android・Developmemt・JetpackCompose・KMP・Kotlin・Kotlin Multiplatform Mobile