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 の拡張関数として共通化する。

 

🤔 参考


2026年8月31日 API レベル36+ の件

もう疲れました。

Google PlayアプリのターゲットAPIレベル要件
2026年8月31日から開始:

・ Google Playに提出される新規アプリおよびアプリのアップデートは、Android 16(APIレベル36)以降をターゲットにする必要があります。ただし、Wear OSおよびAndroid Automotive OSアプリはAndroid 15(APIレベル35)以降をターゲットに、Android TVおよびAndroid XRアプリはAndroid 14(APIレベル34)以降をターゲットにする必要があります。

・既存のアプリは、アプリのターゲットAPIレベルよりも高いAndroid OSを実行しているデバイスで新規ユーザーが引き続き利用できるようにするには、Android 15(APIレベル35)以上をターゲットにする必要があります。Android 14(APIレベル34)以下をターゲットとするアプリ(Wear OSおよびAndroid TV向けのAndroid 13(APIレベル33)以下、Android XR向け、Android Automotive OS向けのAndroid 12(APIレベル31)以下を含む)は、アプリのターゲットAPIレベルと同じかそれ以下のAndroid OSを実行しているデバイスでのみ利用可能です。

アプリのアップデートにさらに時間が必要な場合は、2026年11月1日まで延長を申請できます 。アプリの延長申請フォームは、今年後半にPlay Consoleからアクセスできるようになります。

👉 Target API level requirements for Google Play apps - Play Console Help

実質のユーザ利用の端末OSレベルが API 36+ で 22.3% ということなのですが。

アプリ開発側だけが辛い感じで。

みなさんは、どう思ってますか。


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で勘違いしてるやつ、多いですよね?


Jetpack Compose で巨大化した UiState をどう整理するか

見通しが悪くなった UiState を、責務ごとに整理する7つの方法

 

🧑🏻‍💻 1. Loading / Error / Success が混ざっているなら sealed interface にする

まず、ありがちなパターンです。

変更前:


data class UiState(
    val isLoading: Boolean,
    val error: String?,
    val items: List<Item>
)

- isLoading、error、items の組み合わせで画面の状態を表しています。
- しかし、これでは「Loading なのに items が入っている」「Error なのに isLoading が true」といった、あり得ない状態も作れてしまいます。

変更後:


sealed interface UiState {
    data object Loading : UiState
    data class Error(val message: String) : UiState
    data class Success(val items: List<Item>) : UiState
}

- Loading、Error、Success を、そのまま画面の状態として表現します。
- 状態を Boolean や nullable なプロパティの組み合わせで表現する必要がなくなります。

 

🧑🏻‍💻 2. フィールドが増えすぎたら、Sub-Stateにまとめる

UiState にプロパティを追加していった結果、何がどこに関係しているのか分からなくなることがあります。

変更前:


data class UiState(
    val title: String,
    val query: String,
    val items: List<Item>,
    val selectedCategory: Category?
)

変更後:


data class UiState(
    val header: HeaderState,
    val filter: FilterState,
    val list: ListState
)

data class HeaderState(
    val title: String
)

data class FilterState(
    val query: String,
    val category: Category?
)

data class ListState(
    val items: List<Item>
)


-「ヘッダー」「検索・フィルター」「リスト」という画面上のまとまりで整理します。
- UiState の中身を見ただけで、どの状態がどの役割なのか分かるようになります。

 

🧑🏻‍💻 3. UIだけで使う状態はComposableに閉じ込める

すべての状態をViewModelの UiState に入れる必要はありません。

変更前:


data class UiState(
    val items: List<Item>,
    val isExpanded: Boolean
)

変更後:


data class UiState(
    val items: List<Item>
)

@Composable
fun Screen(state: UiState) {
    var isExpanded by rememberSaveable {
        mutableStateOf(false)
    }
}

- isExpanded が画面の一時的な表示状態にすぎないなら、ViewModelに持たせる必要はありません。
- remember / rememberSaveable に移すことで、UiState をシンプルにできます。

例えば次のようなものです。

・一時的な展開状態
・アニメーションの状態
・Tooltipの表示状態
・フォーカス状態
・スクロール位置

ただし、スクロール位置などを画面復元やビジネスロジック上の理由で ViewModel 側に保持する必要がある場合は例外です。

 

🧑🏻‍💻 4. 計算できる値はStateとして持たない

「元のStateから計算できる値」まで UiState に入れてしまうと、Stateがどんどん増えていきます。

変更前:


data class UiState(
    val items: List<Item>,
    val filteredItems: List<Item>,
    val isEmpty: Boolean
)

変更後:


data class UiState(
    val items: List<Item>,
    val query: String
)

val filteredItems =
    state.items.filter { it.name.contains(state.query) }

val isEmpty = filteredItems.isEmpty()

- filteredItems は items と query から計算できます。
- isEmpty も filteredItems から計算できます。
- つまり、わざわざStateとして保存する必要がありません。
- ViewModelで StateFlow を組み合わせるなら combine や map、Compose 内で Compose State から値を導出するなら derivedStateOf などを使います。

 

🧑🏻‍💻 5. ViewModelまで巨大になったら、ViewModelを分ける

UiState が巨大になった原因が、そもそもViewModelがいろいろな責務を持ちすぎているケースもあります。

変更前:


class MainViewModel : ViewModel() {
    val headerState = ...
    val searchState = ...
    val contentState = ...
}

Stateを分けていても、ViewModel自体がすべてを管理しています。

変更後:


class HeaderViewModel : ViewModel() {
    val state = ...
}

class ContentViewModel : ViewModel() {
    val state = ...
}

@Composable
fun MainScreen(
    header: HeaderViewModel,
    content: ContentViewModel
) {
    HeaderSection(header.state)
    ContentSection(content.state)
}

- 「State を分割する」だけではなく、責務そのものを分けるという考え方です。
- ただし、Composable ごとに ViewModel を作る、という意味ではありません。
- 独立した責務やライフサイクルがある場合に検討します。

 

🧑🏻‍💻 6. 大量のデータを UiState に詰め込まない

数千件、数万件のデータをそのまま UiState に持たせるケースでは、そもそもStateの持ち方を見直します。

変更前:


data class UiState(
    val items: List<Item>
)

val uiState = repository.loadAllItems()

変更後:


data class UiState(
    val query: String
)
val items =
    repository.items()
        .cachedIn(viewModelScope)

- 検索条件などの画面状態と、大量のデータを分離します。
- Paging が適しているケースでは、Paging を利用して必要なデータを段階的に扱います。

 

🧑🏻‍💻 7. 1つの巨大な StateFlow に全部詰め込まない

すべての状態を1つの UiState にまとめることが、かえって分かりにくくなるケースもあります。

変更前:


data class UiState(
    val header: HeaderState,
    val search: SearchState,
    val content: ContentState
)

val uiState: StateFlow<UiState> = ...

変更後:


val headerState: StateFlow<HeaderState> = ...
val searchState: StateFlow<SearchState> = ...
val contentState: StateFlow<ContentState> = ...

@Composable
fun Screen(viewModel: MainViewModel) {
    Header(viewModel.headerState)
    Search(viewModel.searchState)
    Content(viewModel.contentState)
}

- 各 State が本当に独立しているなら、無理に1つへまとめる必要はありません。
- それぞれの UI が必要な State だけを受け取る形にできます。
- ただし、何でも StateFlow に分ければいいわけではありません。

 

🧑🏻‍💻 まとめ - どう整理するか


巨大な UiState
      │
      ├─ Loading / Error / Success が混在
      │       → sealed interface
      │
      ├─ フィールドが増えすぎた
      │       → Sub-State
      │
      ├─ UIだけで使う状態
      │       → remember / rememberSaveable
      │
      ├─ 計算すれば求められる値
      │       → Derived State
      │
      ├─ ViewModelの責務が多すぎる
      │       → ViewModelを分割
      │
      ├─ 大量のデータを保持している
      │       → Pagingなどを検討
      │
      └─ 独立した状態が混在している
              → StateFlowを分割

重要なのは、「UiStateの行数を減らす」ことではありません。

まず、今 UiState に入っているものを見て、


これは本当にStateなのか?
        ↓
UIだけのStateでは?
        ↓
計算できる値では?
        ↓
別の責務では?
        ↓
大量データでは?

と整理していくことです。

つまり、巨大な UiState は、Stateの責務を見直すきっかけと考えると分かりやすいです。

注意点:

- UiState が大きいこと自体が悪いわけではありません。
- StateFlow を細かく分けすぎると、今度は State 同士の関係が分かりにくくなります。
- sealed interface は、Loading / Error / Successのように本当に相互排他的な状態に使います。
- rememberSaveable に移すか ViewModel に残すかは、画面再生成やプロセス終了後にも復元する必要があるかも判断材料になります。
- Pagingも「List が大きいから必ず使う」というものではありません。

 

🤔 参考


Android アーキテクチャは Event-Driven UI から State-Driven UI へ移行している

Google の公式 Android ガイダンスは 2026年5月18日に更新され、ViewModel のイベントを UI State に変換することを明示的に推奨するようになった

これは、もはや単なる昔からのアーキテクチャ論争ではありません。

Google の Android ドキュメントは 2026年5月18日 に更新され、現在では明確に次のように述べています。

ViewModel events should always result in a UI state update.

ViewModel のイベントは、常に UI State の更新につながるべきです。

これは、ViewModel と UI の間の通信をどのように考えるべきかという点で、大きな変化です。

これまで Android では、次のようなパターンが一般的でした。


ViewModel
   │
   ├── UiState
   │
   └── UiEvent
          ↓
         UI

UiEvent は、Snackbar の表示、画面遷移、ダイアログの表示など、一度だけ実行したい処理によく使われていました。

しかし現在のガイダンスは、次の方向へ移りつつあります


ViewModel
   │
   │ UI State
   ↓
  UI

ここでは、簡単な Snackbar の例を使って、この違いを見てみましょう。

 

🤔 Event-Driven アプローチ

従来の実装は、例えば次のようになります。


sealed interface UiEvent {
    data class ShowSnackbar(
        val message: String
    ) : UiEvent
}

private val _events = Channel<UiEvent>()
val events = _events.receiveAsFlow()

fun save() {
    viewModelScope.launch {
        repository.save()

        _events.send(
            UiEvent.ShowSnackbar("Saved successfully")
        )
    }
}

UI 側では、このイベントを消費します。


LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar ->
                snackbarHostState.showSnackbar(event.message)
        }
    }
}

この場合、ViewModel は実質的に UI に対して次のように指示しています。


"Snackbar を表示してください。"

これが Event-Driven なアプローチです。

 

🤔 State-Driven アプローチ

現在の Android ガイダンスは、上記とは異なる方向を示しています。

ShowSnackbar を送るのではなく、ViewModel が UI State を更新します。


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


private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
        
fun save() {
    viewModelScope.launch {
        repository.save()

        _uiState.update {
            it.copy(userMessage = "Saved successfully")
        }
    }
}

fun userMessageShown() {
    _uiState.update {
        it.copy(userMessage = null)
    }
}

UI は State を監視します。


LaunchedEffect(uiState.userMessage) {
    uiState.userMessage?.let { message ->
        snackbarHostState.showSnackbar(message)
        viewModel.userMessageShown()
    }
}

この場合、ViewModel は次のように言っているわけではありません。


"Snackbar を表示してください。"

代わりに、次のように伝えています。


"現在、表示すべきメッセージがあります。"

どのように表示するのかは UI が決定します。

 

🤔 重要な違い

この違いは、単純に


Channel → StateFlow

という API の置き換えではありません。

本質的には次の違いです。

Event-Driven:


ViewModel
    ↓
"これを実行して"
    ↓
UI

State-Driven:


ViewModel
    ↓
"これが現在の状態です"
    ↓
UI

Google は、この違いを次のようにも表現しています。

「State は存在する。Events は発生する。」

もちろん、イベントそのものがなくなったわけではありません。

ボタンのクリックは、今でもイベントです。


User
 ↓
Button.onClick
 ↓
ViewModel
 ↓
State
 ↓
UI

重要な変化は、ViewModel → UI の通信が、一度きりのイベントではなく State を中心とする方向へ進んでいることです。

 

🤔 では、Event を使うのをやめるべきなのか?

公式ガイダンスは、Channel、SharedFlow、あるいはイベントそのものを禁止しているわけではありません。

むしろ、ViewModel のイベントを UI State として表現できないかを改めて考えることを求めています。

ViewModel を設計するときには、次の問いが役立ちます。

- ViewModel は UI に「何をすべきか」を伝える必要があるのか?

- 「現在何が起きているのか」を State として表現するだけでよいのか?

この小さな考え方の変化が、Android の UI アーキテクチャの設計を大きく変える可能性があります。

 

🤔 参考