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 は、アーキテクチャそのものではなく、アーキテクチャをコードに落とし込むための非常に便利な道具なのです。

 

🤔 参考


Jetpack Compose 移行完了チェック:旧 AndroidView システムを安全に完全削除できたか確認する

プロダクション環境のAndroidアプリを Jetpack Compose へ移行する作業は、一朝一夕で完了するものではありません。

中〜大規模のコードベースでは、移行は段階的に進むのが一般的です。そのため、XMLレイアウト、ComposeView、AndroidView が、モダンなComposableと数ヶ月〜数年にわたって共存することになります。

段階的な移行は最も安全なアプローチです。しかし、移行が完了したあとも古いインフラを残したままにしておくと、無駄な複雑さが増すだけになってしまいます。

ここで生まれるのが、

「本当に移行が完了したと、どうやって判断すればいいのか?」

という疑問です。

この記事では、アプリから旧Viewシステムのコードを安全に削除し、完全移行を果たすための実践的なチェック方法を紹介します。

 

🧑🏻‍💻 ビルド設定(buildFeatures)をオフにする

まずは一番わかりやすいビルド設定から見直しましょう。

View Binding や Data Binding なしでビルドできれば、旧Viewシステムへの大きな依存を断ち切れた証拠になります。

モジュールレベルの build.gradle.kts を確認・修正します。


android {
    // ...

    buildFeatures {
        compose = true

        viewBinding = false
        dataBinding = false
    }
}

続いて、依存関係を見直し、不要になったライブラリを削除します。


dependencies {
    // 不要になった旧Viewシステムの依存関係を削除

    // implementation("com.google.android.material:compose-theme-adapter:...")
    // implementation("androidx.appcompat:appcompat:...")
    // implementation("androidx.constraintlayout:constraintlayout:...")
    // implementation("androidx.navigation:navigation-fragment-ktx:...")
    // implementation("androidx.navigation:navigation-ui-ktx:...")
}

 

🧑🏻‍💻 エントリーポイントを Compose 単一にする

完全移行したアプリでは、UIをホストするためだけに FragmentActivityNavHostFragment を使う必要はありません。

メインのエントリーポイントは、シンプルな ComponentActivity で十分です。


class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            AppTheme {
                AppNavigation()
            }
        }
    }
}

もちろん、以下のようなコードは完全に不要になります。


// 不要!
setContentView(R.layout.activity_main)

Activityは、XMLレイアウトのコンテナではなく、Compose UIへのエントリーポイントとして機能するようになります。

 

🧑🏻‍💻 相互運用(Interop)のためのブリッジコードを削除する

移行期間中、ComposeViewAndroidView といったAPIは非常に強力な味方でした。これらのおかげで、View システムとCompose の境界線を段階的にまたぐことができたからです。

しかし、移行が終わったのであれば、これらの「架け橋」は役目を終えています。

プロジェクト全体で以下を検索してみてください。

- ComposeView

- AndroidView

- androidx.fragment.app.Fragment

目指すべき残存数は「0」です。

例えば、以下のようなコードは存在しなくなるはずです。


// これらはもう不要
val composeView = ComposeView(context).apply {
    setContent {
        MainScreen()
    }
}

また、こちらも同様です。


// これらも不要
AndroidView(
    factory = { context ->
        CustomLegacyView(context)
    }
)

重要な考え方はシンプルです。

「相互運用(Interop)は足場であって、目的地ではない」 ということです。

 

🧑🏻‍💻 デザインシステムを Compose 純正にする

完全移行したアプリでは、デザイントークンをKotlinコード内で直接定義できます。


@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme =
        if (darkTheme) DarkColorScheme
        else LightColorScheme

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

カラー、タイポグラフィ、シェイプなどを Compose に適用するためだけに、res/values/styles.xml を参照する必要はもうありません。

UIテクノロジーと信頼できる唯一の情報源(Source of Truth)が同じ Kotlin コード上に並ぶため、デザインシステムの保守性・見通しが大幅に向上します。

 

🧑🏻‍💻 ナビゲーションを宣言型にする

画面遷移(ナビゲーション)についても、XMLの Navigation Graph や Fragment トランザクションから脱却しましょう。

ルート(Route)、引数(Arguments)、画面(Destinations)は、Compose Navigation のセットアップ内で直接管理できます。


@Serializable
data object Home : NavKey

@Serializable
data class Detail(val id: String) : NavKey

@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenDetail = { id ->
                        backStack.add(Detail(id))
                    }
                )
            }

            entry<Detail> { key ->
                DetailScreen(
                    id = key.id
                )
            }
        }
    )
}

ここでの目的は、単に「XMLをKotlinに置き換える」ことではありません。

本質は、Fragmentベースのナビゲーション層を取り払い、UIと同じ「宣言型アーキテクチャ」にナビゲーション構造を一致させることにあります。

 

🧑🏻‍💻 最後にリポジトリ全体を走査する

「移行完了!」と宣言する前に、古いUIシステムを引き戻してしまうようなコードが残っていないか、リポジトリ全体を検索して確認しましょう。

ターミナルで以下のコマンドを実行してみるのがおすすめです。


# 残っているUI用XMLレイアウトのチェック
find app/src/main/res/layout -name "*.xml"

# Compose / View 相互運用コードの検索
grep -rn "ComposeView" app/src/main/java/
grep -rn "AndroidView" app/src/main/java/

# Fragment の使用箇所の検索
grep -rn "androidx.fragment.app.Fragment" app/src/main/java/

理想的な状態は以下の通りです。

・ res/layout にUIレイアウト用のXMLが存在しない

・ ComposeView の使用箇所が 0 である

・ AndroidView の使用箇所が 0 である

・ Fragment ベースの画面が存在しない

・ View Binding および Data Binding が無効化されている

・ テーマがXMLスタイルに依存していない

・ ナビゲーションが Fragment インフラに依存していない

 

🧑🏻‍💻 まとめ

100% Jetpack Compose への移行とは、単に XML ファイルを Composable 関数に書き換えることではありません。

本当のゴールは、「旧 View システムがアプリの UI アーキテクチャから完全に消え去ること」 です。

【移行前】


Activity
 └─ Fragment
     └─ XML
         └─ View
             └─ ComposeView
                 └─ Compose

【移行後】


Activity
 └─ Compose
     ├─ Theme
     ├─ Navigation
     └─ Screens

私たちの目標は、単に「Compose を使っている」と言うことではありません。

「もうViewシステムを必要としない」 状態を作ることこそが、本当のゴールなのです。


Google推奨 MVVM と MVI の違い

Android の UI アーキテクチャでは、MVVM と MVI のどちらを選ぶべきかという議論がよくあります。

ただし、現在の Google の公式ガイドを見ると、重要なのは「MVVM か MVI か」という名前ではありません。

Google が推奨している中心的な考え方は、UDF(Unidirectional Data Flow), UI State・State Holder, ViewModel です。

その上で、MVI はより厳密に「すべての入力を Action として集約し、Reducer で State を生成する」方向へ進めたアーキテクチャとして考えることができます。

 

🧑🏻‍💻 1. Input Processing — Event の処理

MVVM: ViewModel のメソッドを直接呼び出す

UI から発生したイベントごとに、対応する ViewModel のメソッドを呼び出します。


Button(
    onClick = { viewModel.onSubmitClicked() }
)


fun onSubmitClicked() {
    viewModelScope.launch {
        ...
    }
}

このスタイルでは、UI イベントと ViewModel の API が直接対応します。

Google の Compose アーキテクチャガイドでも、UI イベントを ViewModel の onSignIn() のようなメソッドで処理する例が示されています。

MVI: Single Entry Point

MVI では、UI からの入力を Intent や Action に変換し、1つの入口に集約することがあります。


Button(
    onClick = { viewModel.onIntent(UserIntent.Submit) }
)


sealed interface UserIntent {
    data object Submit : UserIntent
    data object Refresh : UserIntent
}

fun onIntent(intent: UserIntent) {
    ...
}

これにより、UI → ViewModel の入力経路を1本化できます。

すべての入力を同じ形式で扱えるため、ログやテスト、イベント追跡を統一しやすくなります。

 

🧑🏻‍💻 2. State Reduction — State の更新

MVVM: ViewModel の処理から State を更新する

MVVM では、ViewModel の各メソッドが非同期処理を行い、その結果を UiState に反映する構成が一般的です。


fun loadData() {
    viewModelScope.launch {
        val result = repository.getData()

        _uiState.update {
            it.copy(data = result)
        }
    }
}

この場合、State を変更する場所は ViewModel 内の複数のメソッドに分散する可能性があります。

その代わり、コードが比較的シンプルで、処理の流れを直接追いやすいというメリットがあります。

MVI: Reducer で State を計算する

MVI では、現在の State と Action から次の State を計算する reduce を中心に据えることができます。


fun reduce(
    oldState: UiState,
    action: Action
): UiState =
    when (action) {
        is Action.DataLoaded ->
            oldState.copy(data = action.data)

        is Action.Loading ->
            oldState.copy(isLoading = true)
    }

ここで重要なのは、


Current State + Action
          ↓
       Reducer
          ↓
      Next State

という形にすることです。

Reducer を純粋関数として設計すれば、同じ State と Action から常に同じ State を生成するという性質を持たせられます。

そのため、State 遷移のテストやログの再現が容易になります。

 

🧑🏻‍💻 3. Output Processing — UI 更新と Side Effect

MVVM: State を中心に UI を更新する

Google の現在の Android アーキテクチャガイドは、ViewModel が UI State を生成し、UI がそれを購読する UDF を推奨しています。


val uiState: StateFlow<UiState> =
    _uiState.asStateFlow()

val state by viewModel.uiState
    .collectAsStateWithLifecycle()

例えば Snackbar を UI State に含める実装では、UI が State の変化を監視して表示できます。


LaunchedEffect(state.userMessage) {
    state.userMessage?.let {
        snackbarHostState.showSnackbar(it)
        viewModel.onMessageShown()
    }
}

この方式では、


ViewModel
    ↓
 UiState
    ↓
   UI

という一本の State Flow に寄せることができます。

なお、「一度だけ実行したいイベントを State に入れるべきか」については、現在の Google のガイドラインでは以前より明確に State 中心へ寄っています。

Google は ViewModel から UI へ直接イベントを送るのではなく、イベントの結果を UI State に反映することを推奨しています。

MVI: State と Effect を分離する

一方、MVI では永続的な UI State と、一度だけ実行したい Side Effect を分離する設計も一般的です。


private val _effect = Channel<UiEffect>()

val effect = _effect.receiveAsFlow()

val state by viewModel.state
    .collectAsStateWithLifecycle()

UI 側では、


LaunchedEffect(Unit) {
    viewModel.effect.collect { effect ->
        when (effect) {
            is UiEffect.ShowSnackbar ->
                snackbarHostState.showSnackbar(
                    effect.message
                )
        }
    }
}

のように State と Effect を別々に処理します。


      ┌───────────┐
      │ ViewModel │
      └─────┬─────┘
            │
   ┌────────┴────────┐
   ↓                 ↓
UiState           UiEffect
   ↓                 ↓
  UI            Side Effect

この設計のメリットは、「画面を表現する State」と「UI に一度だけ何かをさせる Effect」の責務を明確に分離できることです。

一方で、Channel や SharedFlow、Effect の lifecycle や delivery guarantee まで設計する必要があり、複雑さも増えます。

 

🧑🏻‍💻 4. MVVM と MVI の違いを整理する

 

🧑🏻‍💻 5. 「MVVM vs MVI」ではなく「どこまで厳密にするか」

ここで重要なのは、MVVM と MVI は完全に対立するものではないということです。


              UDF
               │
     ┌─────────┴─────────┐
     ↓                   ↓
   MVVM                  MVI
     │                   │
ViewModel          Intent / Action
     │                   │
UI State             Reducer
     │                   │
     └─────────┬─────────┘
               ↓
              UI

Google の公式ガイドが推奨しているのは、MVI という名前ではなく、UDF によって UI State を一方向に流し、ViewModel などの State Holder がユーザーイベントを処理する構造です。

そのため、ViewModel に複数のイベントハンドラを持たせること自体が「悪い MVVM」なのではありません。

むしろ Google の公式サンプルでも、onSignIn() のようなイベントハンドラを ViewModel に公開する形が使われています。

MVI はそこからさらに一歩進めて、

UI Event


   ↓
Intent / Action
   ↓
Reducer
   ↓
UiState
   ↓
UI

という形に統一することで、状態遷移そのものをアーキテクチャの中心に置く考え方だと言えます。

 

🧑🏻‍💻 6. どちらを選ぶべきか

シンプルな画面

- MVVM + UDF が適しています。
- ViewModel のメソッドを直接呼び出すだけで十分です。

状態遷移が複雑な画面

- MVI の考え方が有効です。
- Action と Reducer を明確に分離することで、状態遷移を追いやすくできます。

大規模チーム

- MVI のような厳密なルールが、チーム内の実装パターンを統一する助けになる場合があります。
- ただし、Reducer や Intent、Effect を増やすこと自体が目的になってはいけません。

重要なのはアーキテクチャの名前ではありません。

- State をどこに置くのか。
- 誰が State を変更するのか。
- Event をどこで処理するのか。
- UI に何を State として公開するのか。
- UI Effect をどのように扱うのか。

これらを明確にすることが、MVVM と MVI を選択する本質です。

 

🧑🏻‍💻 まとめ

MVVM は、少ないコードで UDF を実現しやすい実用的な選択肢です。

MVI は、Intent → Action → Reducer → State という流れを厳密に定義することで、State 遷移の予測可能性を高めます。

Google の現在の公式 Android ガイドは、MVI を推奨しているわけではなく、UDF + UI State + State Holder + ViewModel を中心に説明しています。

したがって、現代の Android では、


MVVM
  ↓
UDF + State
  ↓
必要なら MVI 的な Reducer / Action を導入

という考え方のほうが、「MVVM か MVI か」という二択よりも実際の設計に近いと言えるでしょう。

 

🧑🏻‍💻 参考


UI だけで完結する処理は、Compose の拡張関数へ

すべての UI 副作用を ViewModel Event に変換する必要はない

Composeでは、すべてのUI操作を


UI
 ↓
ViewModel
 ↓
Event
 ↓
UI
 ↓
Effect

という経路に乗せる必要はありません。

UI だけで意味が完結する処理は、Compose 側に残して、必要なら拡張関数として共通化する方が自然です。

例えば以下です。


├── Navigation
├── Snackbar
├── Toast
├── Scroll
├── Focus
├── Keyboard
├── Clipboard
├── Haptic feedback
├── Share
├── Open URL
├── Permission launcher
└── File picker

これらは「副作用」ではありますが、副作用だから ViewModel Event にする必要があるわけではありません。

 

🤔 ViewModel Event にすることの問題

Navigation 3を例にします。


sealed interface UiEvent {
    data class NavigateToDetail(
        val id: String
    ) : UiEvent
}


fun onItemClick(id: String) {
    viewModelScope.launch {
        _events.emit(
            UiEvent.NavigateToDetail(id)
        )
    }
}

UI 側では Event を受け取ります。


LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.NavigateToDetail -> {
                backStack.add(
                    Detail(event.id)
                )
            }
        }
    }
}

このパターン自体が間違いというわけではありません。
しかし、ここで一つ質問できます。

Detail へ移動するために、ビジネスロジックは必要でしょうか?

もし必要ないなら、UI 操作を ViewModel に渡して、再び UI に戻しています。

 

🤔 Navigation 3 は UI で完結できる

Navigation 3 では、BackStack が明示的に存在します。


Button(
    onClick = {
        backStack.add(
            Detail(id)
        )
    }
) {
    Text("Open")
}

拡張関数にすると、より読みやすくできます。


fun NavBackStack<NavKey>.navigateTo(
    destination: NavKey
) {
    add(destination)
}

UIはこうなります。


Button(
    onClick = {
        backStack.navigateTo(
            Detail(id)
        )
    }
) {
    Text("Open")
}

ここには、


UiEvent
Channel
SharedFlow
LaunchedEffect

が必要ありません。

UI → Navigation 3 で完結します。

 

🤔 Snackbar の場合

従来なら、


ViewModel
 ↓
ShowSnackbar Event
 ↓
UI
 ↓
showSnackbar()

とします。

これを、UI側の処理を拡張関数にできます。


suspend fun String.showSnackbar(
    hostState: SnackbarHostState
) {
    hostState.showSnackbar(this)
}


LaunchedEffect(uiState.message) {
    uiState.message?.showSnackbar(
        snackbarHostState
    )
}

この UI に対して ViewModel は「大事な状態」を持っています。


data class UiState(
    val message: String? = null
)

 

🤔 重要なのは「UI の責務」と「ビジネスロジック」の境界

例えば、次の2つは明確に違います。

これはUI側に置けます。


backStack.navigateTo(
    Detail(id)
)

これは ViewModel / UseCase側です。


viewModel.save()

 

🤔 まとめ

- UIだけで完結する処理は、UIに置く。
- 繰り返し使うUI操作は、Compose の拡張関数として共通化する。

 

🤔 参考


OkHttp・Retrofit等の主要ライブラリ、コントリビューションへのLLM使用を禁止方針に

View on Mastodon

本日より、Lysineプロジェクト(OkHttp、Okio、Retrofit、SQL Delightファミリー)では、コードの作成、およびPR(プルリクエスト)/ Issueの記述やコメントにおいてLLM(大規模言語モデル/生成AI)の使用を禁止するコントリビューションポリシーを導入します。

ただし、利用を明示することを条件に、翻訳補助としての使用は例外として認められます。

リサーチ、分析、あるいは実験の目的でローカル環境でLLMを使用されることについて、私たちがそれを止めることはできません。しかし、プロジェクトへ貢献する際にはあなた自身がコードを書き、やり取りの相手もあなた自身であることを要求します。

 

🤔 まとめ

対象プロジェクト:
OkHttp、Okio、Retrofit、SQL Delightシリーズなど(Lysine配下のプロジェクト)

禁止事項:
提出コードの作成、PR/Issueの本文やコメントにおけるLLM(生成AI)の使用

許可される例外:
翻訳補助としての利用(ただし使用した旨の明記が必要)

個人での利用:
手元での調査・分析・実験的なコード試作におけるLLM使用は制約外

コアメッセージ:
コントリビューションにおいては「AIではなく開発者本人が直接書くこと」および「人間同士で対話すること」を重視する方針

そもそも、LLMで勘違いしてるやつ、多いですよね?