フル Compose に LifecycleEventObserver を持ち込むな

Composable のライフサイクルと Android(Activity/Fragment) のライフサイクルを分けて考える

フル Compose でアプリを作っていると、LifecycleEventObserver を使って画面のライフサイクルを監視したくなることがあります。

たしかに動きます。

しかし、そのコードが本当に必要としているのは Activity/Fragment のライフサイクルなのか、それとも Composable のライフサイクル なのか を一度考えてみるべきです。

この2つは似ていますが、別のものです。

 

🧑🏻‍💻 ComposableのLifecycle と Android Lifecycle は別物

Composable には、Composition に入る・再コンポーズされる・Composition から離れる、というライフサイクルがあります。

一方、Android には Activity や Fragment などの LifecycleOwner が持つライフサイクルがあります。


Composable
    │
    ├─ enters Composition
    ├─ recomposes
    └─ leaves Composition


Activity / Fragment
    │
    ├─ ON_CREATE
    ├─ ON_START
    ├─ ON_RESUME
    ├─ ON_PAUSE
    ├─ ON_STOP
    └─ ON_DESTROY

フル Compose だからといって Android ライフサイクルがなくなるわけではありません。

Composable のライフサイクルを扱うために Android ライフサイクルを使う必要はありません。

 

🧑🏻‍💻 LifecycleEventObserver がコードを複雑にする理由

例えば、画面が Composition に入ったときに何かを登録し、画面から離れたら解除したいとします。

Android ライフサイクルを使うと、こう書けます。

よくある書き方:


@Composable
fun MyScreen() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> {
                    // start
                }

                Lifecycle.Event.ON_STOP -> {
                    // stop
                }

                else -> Unit
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

もちろん正しく動きます。

でも、ここで必要なのが単純に

「この Composable が存在している間だけ登録したい」

ということなら、Android ライフサイクルまで持ち込む必要はありません。

Composable のライフサイクルに合わせる:


@Composable
fun MyScreen() {
    DisposableEffect(Unit) {
        // start

        onDispose {
            // stop
        }
    }
}

こちらのほうが、コードから意図が直接読み取れます。

 

🧑🏻‍💻 Composable のライフサイクルなら DisposableEffect

DisposableEffect は、Composable が Compositionに 入ったときに処理を開始し、Composition から離れたときに後始末できます。

例えばリスナーの登録です。

Before:


@Composable
fun MyScreen() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                startListening()
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
            stopListening()
        }
    }
}

After:


@Composable
fun MyScreen() {
    DisposableEffect(Unit) {
        startListening()

        onDispose {
            stopListening()
        }
    }
}

「Composable が存在する間だけ必要なリソース」というだけなら、これで十分です。

 

🧑🏻‍💻 Coroutine なら LaunchedEffect

Coroutine を Composable のライフサイクルに合わせたい場合も、LifecycleObserver を使う必要はありません。

Before:


@Composable
fun MyScreen() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                // coroutineを開始
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

After:


@Composable
fun MyScreen() {
    LaunchedEffect(Unit) {
        // coroutine
    }
}

LaunchedEffect の Coroutine は、その Effect が Composition から外れると自動的にキャンセルされます。

つまり、


Composable
    │
    └── LaunchedEffect
            │
            └── Coroutine

という関係です。

 

🧑🏻‍💻 FlowならcollectAsStateWithLifecycle()

Flow を UI で表示するだけなら、LifecycleObserver を自分で書く必要はありません。

Before:


@Composable
fun MyScreen(viewModel: MyViewModel) {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                // Flowをcollect
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

After:


@Composable
fun MyScreen(viewModel: MyViewModel) {
    val uiState by viewModel.uiState
        .collectAsStateWithLifecycle()

    Content(uiState)
}

Flow の Lifecycle-aware な収集は、専用の API に任せます。

自分でライフサイクルを監視して Flow を開始・停止するより、意図がはっきりします。

 

🧑🏻‍💻 Lifecycle イベントが必要なら LifecycleEventEffect

ここまでとは違い、本当に Android ライフサイクル のイベントそのものが必要なケースもあります。

例えば ON_RESUME や ON_PAUSE を明示的に扱いたい場合です。

その場合は、LifecycleObserver を自分で組み立てるより、ライフサイクルイベント用の API を使えます。

Before:


@Composable
fun MyScreen() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                refresh()
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

After:


@Composable
fun MyScreen() {
    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        refresh()
    }
}

ここではじめて、Android ライフサイクル を使う理由が明確になります。

「Composable が存在する間」ではなく、「Android ライフサイクル の ON_RESUME が発生したとき」が必要だからです。

 

🤔 LifecycleEventObserver が必要になるケース

だからといって、LifecycleEventObserver が悪いわけではありません。

次のようなケースでは、直接使う意味があります。

・ 複数のライフサイクルイベントをまとめて監視したい
・ 独自のライフサイクル連携を実装している
・ Lifecycle-aware なライブラリやAPIと統合する
・ LifecycleEventEffect では表現しにくい処理がある

重要なのは、

LifecycleEventObserver を使うな、ではなく、必要のないところに持ち込まない

ということです。

 

🧑🏻‍💻 8. まとめ

迷ったら、まず「この処理はどの寿命に属しているのか」を考えます。

・ Composable のライフサイクル → DisposableEffect
・ Composable に紐づく Coroutine → LaunchedEffect
・ Flow を UIで収集 → collectAsStateWithLifecycle()
・ Activity/Fragment のライフサイクルイベント → LifecycleEventEffect
・ 複雑なライフサイクル連携 → LifecycleEventObserver

つまり、


何のライフサイクル・イベントを扱っている?
        │
        ├─ Composable
        │    ├─ Resource → DisposableEffect
        │    └─ Coroutine → LaunchedEffect
        │
        ├─ Flow → collectAsStateWithLifecycle()
        │
        └─ Android Lifecycle
             ├─ Simple event → LifecycleEventEffect
             └─ Complex integration → LifecycleEventObserver

フル Compose では、Android ライフサイクルが使えるからといって、すぐに LifecycleEventObserver を持ち込む必要はありません。

処理が属しているライフサイクルに、一番近い API を選ぶ。

それだけで、Compose のコードはかなりシンプルになります。


Jetpack Composeで DisposableEffect に任せるべき処理

Jetpack Compose では UI のライフサイクル管理がかなり簡単になりました。

しかし、それでも明示的な後始末が必要な処理があります。

たとえば、

・ Listener を登録する
・ Connection を開始する
・ 外部リソースを取得する
・ 後から停止する必要がある処理を開始する
・ UI が消えたときに登録解除やリソース解放を行う

といった処理です。

このような処理を Composition のライフサイクルに結び付けるのが DisposableEffect です。

基本的には、次のように考えると分かりやすいです。


Composition に入る
       ↓
register / start / acquire
       ↓
Composition に存在している間
       ↓
Composition から出る
       ↓
onDispose
       ↓
unregister / stop / release

 

🤔 remember はリソースを解放しない

例えば、次のコードを考えてみます。


@Composable
fun PlayerScreen() {
    val player = remember {
        MediaPlayer()
    }
}

remember は再コンポジションをまたいでオブジェクトを保持します。

しかし、MediaPlayer が何なのか、いつ release() すべきなのかまでは知りません。

明示的な解放の API を持つオブジェクトなら、その解放処理を適切なライフサイクルに結び付ける必要があります。


@Composable
fun PlayerScreen() {
    val player = remember {
        MediaPlayer()
    }

    DisposableEffect(player) {
        onDispose {
            player.release()
        }
    }
}

ここで重要なのは、


remember
  ↓
オブジェクトを保持する


DisposableEffect
  ↓
オブジェクトに関連する副作用のライフサイクルを管理する

という違いです。

つまり、remember 自体がメモリーリークを起こすわけではありません。

問題になるのは、明示的な解放が必要なリソースを、適切なタイミングで終了させないことです。

 

🤔 よくあるパターン

DisposableEffect を使う処理は、ほとんど同じ形になります。


DisposableEffect(key) {

    resource.register()

    onDispose {
        resource.unregister()
    }
}

名前は変わっても、考え方は同じです。


// LifecycleObserver

DisposableEffect(lifecycle) {
    lifecycle.addObserver(observer)

    onDispose {
        lifecycle.removeObserver(observer)
    }
}


// BroadcastReceiver

DisposableEffect(receiver) {
    context.registerReceiver(receiver, filter)

    onDispose {
        context.unregisterReceiver(receiver)
    }
}


// Sensor

DisposableEffect(sensor) {
    sensorManager.registerListener(
        listener,
        sensor,
        SensorManager.SENSOR_DELAY_NORMAL
    )

    onDispose {
        sensorManager.unregisterListener(listener)
    }
}


// Location

DisposableEffect(locationCallback) {
    locationClient.requestLocationUpdates(
        request,
        locationCallback,
        Looper.getMainLooper()
    )

    onDispose {
        locationClient.removeLocationUpdates(locationCallback)
    }
}


// NetworkCallback

DisposableEffect(networkCallback) {
    connectivityManager.registerNetworkCallback(
        request,
        networkCallback
    )

    onDispose {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}


// MediaPlayer

val player = remember {
    MediaPlayer()
}

DisposableEffect(player) {
    onDispose {
        player.release()
    }
}


// WebSocket

val socket = remember {
    createWebSocket()
}

DisposableEffect(socket) {
    socket.connect()

    onDispose {
        socket.close()
    }
}


// Listener

DisposableEffect(manager) {
    manager.addListener(listener)

    onDispose {
        manager.removeListener(listener)
    }
}


// TextWatcher

DisposableEffect(textView) {
    textView.addTextChangedListener(watcher)

    onDispose {
        textView.removeTextChangedListener(watcher)
    }
}

 

🤔 ViewModel の場合はどうなる?

Composition が所有するリソースなら、DisposableEffect で解放できます。


Composition
    │
    └── resource
          │
          └── DisposableEffect
                  ↓
               onDispose()

一方、ViewModel が所有するリソースは、ViewModel のライフサイクルに従います。


ViewModel
    │
    └── resource
          │
          └── onCleared()

例えば、


class PlayerViewModel : ViewModel() {

    private val player = MediaPlayer()

    override fun onCleared() {
        player.release()
        super.onCleared()
    }
}

ViewModel がリソースを保持すること自体に問題があるわけではありません。

重要なのは、

「このリソースの所有者は誰で、その所有者の寿命はいつ終わるのか?」

ということです。

MediaPlayerが 画面に属するなら、DisposableEffect は自然な選択です。

また、Composition が破棄されても再生を継続したいなど、MediaPlayer を ViewModel の寿命まで保持したいのであれば、ViewModel が所有する設計も考えられます。

 

🤔 メモリーリークとは別の話

ここで、リソースリークメモリーリーク を混同しないことも重要です。

典型的なメモリーリークは、長く生存するオブジェクトが、本来なら破棄されるはずのオブジェクトを参照し続けることで起こります。

例えば、ViewModel が Activity への参照を保持しているとします。

Activity が画面から破棄された後も ViewModel がその Activity を参照していれば、Activity を GC できなくなる可能性があります。

これはメモリーリークです。

一方、リソースリークは少し違います。


Object
   │
   └──→ MediaPlayer
            │
            └── 外部リソース

もう使わないのに release() を呼ばなければ、外部リソースを明示的に解放していないことが問題になります。

両者が同時に発生することはありますが、同じものではありません。

 

🤔 気づくための簡単なルール

Composable の中に、次のような処理があったら考えてみてください。

・ register()
・ addListener()
・ connect()
・ start()
・ acquire()

そして、

「これに対応するクリーンアップはどこにある?」

と考えます。

例えば、


register()    →  unregister()
addListener() →  removeListener()
connect()     →  close()
start()       →  stop()
acquire()     →  release()

です。

もし、その処理が Composable が Composition に存在している間だけ必要なら、DisposableEffect は有力な選択肢です。

 

🤔 DisposableEffect の本質

DisposableEffect は、Composition のライフサイクルに合わせて副作用の開始と終了を管理します。

remember:
「再コンポジションをまたいで オブジェクトを保持する」

DisposableEffect:
「Compositionの存在期間に合わせて副作用を管理する」

onDispose:
「もう必要ないので明示的に後始末する」

結局、DisposableEffect を使うかどうかを考えるときに重要なのは、

「このリソースの所有者は誰で、その寿命はいつ終わるのか?」

という1つの質問です。

この視点を持つと、DisposableEffect を単なる API としてではなく、リソースのライフサイクルを Composition に接続するための仕組みとして理解できます。


State は宣言的に、ユーザーの操作は命令的に

 

🤔 ViewModelで「Stateの生成」と「ユーザー操作」を分けて考える

Jetpack Composeでは、UIを宣言的に書くことが当たり前になりました。

「UIをどう更新するか」を細かく指示するのではなく、

「このStateなら、UIはこう見える」

という関係をコードで表現します。

この考え方は、ViewModelにもそのまま応用できます。

私はViewModelを書くとき、次のシンプルなルールで考えると整理しやすいと思っています。

・ Stateの生成は宣言的に。
・ ユーザーの意図への反応は命令的に。

この2つを分けるだけで、ViewModelはかなり読みやすくなります。

 

🤔 State は宣言的に生成する

たとえば、Repositoryがユーザー一覧をFlowとして公開しているとします。


Repository
    │
    │ users: Flow<List<User>>
    ▼
   map
    │
    ▼
 UiState
    │
    ▼
 Compose

この場合、ViewModelではFlowからUiStateをそのまま導出できます。


class UserViewModel(
    repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> =
        repository.users
            .map { users ->
                UiState.Success(users)
            }
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5_000),
                UiState.Loading
            )
}

ここには init がありません。

load() もありません。

ViewModel がやっているのは、

uiStaterepository.users から導出される

という関係を定義することだけです。

Repository から新しい値が流れてくれば、それに応じて uiState も変わります。

ここには「最初にこれを実行して、そのあとこれを実行する」という手順がありません。

あるのは、


users → UiState

という関係だけです。

これが「宣言的な State 生成」です。

 

🤔 命令的なコードにも、もちろん役割がある

だからといって、命令的なコードをなくす必要はありません。

たとえば「更新」ボタンを考えてみます。


fun refresh() {
    viewModelScope.launch {
        repository.refresh()
    }
}

これは自然なコードです。

ユーザーが「更新したい」と明示的に操作したからです。

処理の流れも明確です。


ユーザーがRefreshをタップ
        ↓
     refresh()
        ↓
Repositoryが更新処理を実行
        ↓
     Stateが変化

これは命令的な処理です。

そして、こういう場所こそ命令的なコードが適しています。

同じことは、たとえば次のような操作にも当てはまります。


fun retry()
fun deleteUser(id: String)
fun submit()

これらはすべて、

「ユーザーがこうしてほしい」

という意図を表しています。

そのため、命令として表現するのが自然です。

 

🤔 問題は、この2つを混ぜてしまうこと

少し気になるのは、State を生成するためだけに命令的なコードを使うケースです。

たとえば、こんな ViewModel です。


class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> = ...

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            repository.loadUsers()
        }
    }
}

このコード自体が間違っているわけではありません。

ただ、ここで一度考えてみたいことがあります。

なぜ load() が必要なのでしょうか?

すでに uiState を宣言的に定義しているのに、その State を作るために別の命令を実行しなければなりません。

構造としては、こうなっています。



ViewModelの生成
      ↓
    init
      ↓
    load()
      ↓
 Stateが生成・更新

つまり、State を得るために「いつ load() を呼ぶか」という別の問題が発生します。

もし Repository がデータを Flow として公開できるのであれば、この命令自体が不要になるかもしれません。

 

🤔 LaunchedEffect に移しても、本質は変わらない

「それなら load() を Compose 側から呼べばいい」と考えることもできます。


@Composable
fun UserScreen(
    viewModel: UserViewModel
) {
    LaunchedEffect(Unit) {
        viewModel.load()
    }

    // UI
}

UI そのものは宣言的です。

しかし、ここでは別の命令が追加されています。

「この画面がCompositionに入ったらload()を呼ぶ」

つまり、


Screen enters Composition
          ↓
   LaunchedEffect
          ↓
       load()
          ↓
      State changes

という流れです。

もちろん、これは常に悪いわけではありません。

明示的な副作用が必要なケースでは、LaunchedEffect は適切な選択です。

ただし、

「Flowとして表現できるStateを最初に作るためだけ」

に LaunchedEffect を使っているのであれば、一度設計を見直してみる価値があります。

 

🤔 ViewModel の init も同じ視点で考える

ここで、ViewModel の init についても考えてみます。


init {
    load()
}

これは、

「ViewModelが生成されたら、この処理を開始する」

という命令です。

処理の順番は明確です。


ViewModelが生成される
        ↓
       init
        ↓
      load()
        ↓
    Stateが変化

一方、State を宣言的なパイプラインとして表現できるなら、


Repository State
       ↓
      map
       ↓
     UiState

と直接書けます。

ここでは「いつ State を作るか」をコードで指示していません。

State がどのように導出されるかだけを記述しています。

この違いは小さく見えますが、ViewModel の責務を考えるうえで重要です。

 

🤔 すべてを宣言的にする必要はない

ここが一番重要なポイントかもしれません。

この考え方は、

「ViewModelの処理を全部Flowにしよう」

という話ではありません。

たとえばユーザーが削除ボタンを押した場合、


fun deleteUser(id: String) {
    viewModelScope.launch {
        repository.deleteUser(id)
    }
}

で何も問題ありません。

ユーザーが「このユーザーを削除したい」と意図を示したので、その意図を命令的な処理として表現するのは自然です。

重要なのは、

・ すべてを宣言的にすること

ではありません。

むしろ、

・ Stateは宣言的に。
・ ユーザーの意図は命令的に。

という境界を持つことです。

境界があると、ViewModel のコードを読むときにも、

・ Stateを作っているコードなのか
・ ユーザーの操作に反応しているコードなのか

を簡単に区別できます。

 

🤔 init { load() } を疑ってみる

この考え方をすると、これまで何となく書いていたコードも少し違って見えてきます。


init {
    load()
}

あるいは、


LaunchedEffect(Unit) {
    viewModel.load()
}

を見たとき、

「初期ロードだから、とりあえずこれを書く」

ではなく、

「そもそも、この load() は必要なのだろうか?」

と考えられるようになります。

もちろん、必要な場合もあります。

外部 API を明示的に叩く必要があったり、ユーザーの操作によって処理を開始する必要があったりするなら、命令的な処理は適切です。

ただ、State そのものが Flow から自然に導出できるのであれば、State を作るためだけの load() は必要ないかもしれません。

 

🧑🏻‍💻 まとめ

宣言的プログラミングと命令的プログラミングのどちらかを選ぶ必要はありません。

ViewModelの中でも、それぞれに適した場所があります。


State Generation
      ↓
  Declarative

Flow → map → UiState


User Intent
      ↓
  Imperative

大切なのは、この2つを混ぜないことです。

State の生成は宣言的に。
ユーザーの意図への反応は命令的に。

この境界を意識すると、init { load() } LaunchedEffect { viewModel.load() } を「お決まりの初期化パターン」として使うのではなく、

本当にこの load() は必要なのか?

と考えられるようになります。

そして、ときには初期化問題を解決する最善の方法は、load() を別の場所に移動することではありません。

そもそも load() が必要な設計なのかを見直すことです。

 

🧑🏻‍💻 参考記事


Jetpack Compose で 反転フラップ式案内表示機 (split-flap display) を実装する

これ。

👉 反転フラップ式案内表示機 - Wikipedia
👉 Split-flap display - Wikipedia

まあ、AI で動くものはすぐにできる。

が、なんか微妙なよくわからないコード。

どんなロジックになっているのかな、と興味あったので、ロジックをみながら整理してみました。

 

🧑🏻‍💻 考え方

切り替える文字列を以下に設定。


"各駅電車", "急行", "快速", "特急", "御座候"

そして、2つのレイヤーを重ねる。

・上下半分に分けた文字板
・アニメーションでめくれていく文字板

この考え方がいいようです。

重ね合わせには、BoxModifier.zIndex() を使います。

 

🧑🏻‍💻 上下半分に分けた文字板

下のレイヤーに配置する。

同じタイミングで、上下半分ずつの文字を切り替える。

 

🧑🏻‍💻 アニメーションでめくれていく文字板

上のレイヤーに配置する。

これを下のレイヤーに被せて、同期的にアニメーションさせる。

対象の Composable で Mofifier.graphicsLayer を使うと便利。

意図する回転方向と原点を指定するだけで良い。


modifier = Modifier
    .graphicsLayer {
        this.transformOrigin = TransformOrigin(...)
        this.rotationX = ...
    }

 

🧑🏻‍💻 出来上がり

とりあえず、ある程度まで整理。

アニメーション知識なく AI で生成したコードから入ると、ある程度ロジックを理解するまでは不具合修正はもちろん、調整さえ自在にできない。

厳しい時代かもしれません。


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

 

🤔 参考