MVI で sealed interface を使う理由

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 :  AndroidDevelopmemtJetpackComposeKMPKotlinKotlin Multiplatform Mobile