uiState を宣言的に組み立てる Flow オペレーター ベスト10

みんなはいくつ使っていますか。

 

🧑🏻‍💻 1. stateIn: Flow を StateFlow に変換する

宣言的な uiState の締めくくりです。WhileSubscribed(5_000) にしておくと、画面回転では再購読せず、バックグラウンドでは上流を止められます。


val uiState: StateFlow<UiState> = flow
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading
    )

 

🧑🏻‍💻 2. combine: 複数ソースを 1 つの UiState に合成する

MutableStateFlow を何本も手で更新する代わりに、「ソースの合成結果 = UiState」と宣言できます。


combine(repo.items, query, filter) { items, q, f ->
    UiState.Success(items.filter { it.matches(q, f) })
}

 

🧑🏻‍💻 3. map: ドメインモデルから UI モデルへの変換

整形や表示用の変換はここに集約します。


repo.user.map { it.toUiModel() }

 

🧑🏻‍💻 4. flatMapLatest: 入力の変化で上流を切り替える

検索クエリや選択 ID が変わると、前のリクエストを自動キャンセルして新しい Flow に切り替えます。


query.flatMapLatest { q -> repo.search(q) }

 

🧑🏻‍💻 5. catch: エラーを UiState に変換する

例外を UI の状態として扱えます。上流のエラーだけを捕捉する点が try/catch との違いです。


.catch { emit(UiState.Error(it.message)) }

 

🧑🏻‍💻 6. onStart: Loading の初期値を流す

購読開始時に Loading を流す定番の書き方です。


repo.items
    .map<_, UiState> { UiState.Success(it) }
    .onStart { emit(UiState.Loading) }

 

🧑🏻‍💻 7. distinctUntilChanged: 無駄な再描画を防ぐ

StateFlow 自体も同値を弾きますが、中間の Flow で重い処理の前に挟むと効果的です。


query.distinctUntilChanged()
    .flatMapLatest { repo.search(it) }

 

🧑🏻‍💻 8. debounce: 入力の連打を間引く

検索ボックスなどで使います。debounce は同値の弾き込みはしないため、distinctUntilChanged と併用します。


query.debounce(300)
    .distinctUntilChanged()

 

🧑🏻‍💻 9. scan / runningFold: 状態を畳み込む

ページング風の追加読み込みや、イベントから状態を積み上げる MVI 的な設計で使います。


events.scan(UiState()) { state, event -> reduce(state, event) }

 

🧑🏻‍💻 10. flowOn: 上流の実行スレッドを指定する

重い変換や IO を Main から外します。上流にだけ効く点に注意してください。


repo.items
    .map { heavyTransform(it) }
    .flowOn(Dispatchers.Default)

 

🧑🏻‍💻 よくある組み合わせ例


class SearchViewModel(repo: SearchRepository) : ViewModel() {
    private val query = MutableStateFlow("")
    private val filter = MutableStateFlow(Filter.All)

    val uiState: StateFlow<UiState> = combine(
        query.debounce(300).distinctUntilChanged()
            .flatMapLatest { repo.search(it) },
        filter
    ) { results, f ->
        UiState.Success(results.filter(f::accepts)) as UiState
    }
        .onStart { 
            emit(UiState.Loading) 
        }
        .catch { 
            emit(UiState.Error(it.message)) 
        }
        .flowOn(Dispatchers.Default)
        .stateIn(
            viewModelScope, 
            SharingStarted.WhileSubscribed(5_000), 
            UiState.Loading
        )

    fun onQueryChange(q: String) { 
        query.value = q 
    }
}

 

🧑🏻‍💻 参考


UI Action は UiState を直接変更しない

UiState は入力から導出する

UI = f(State) は、宣言的 UI の基本的な考え方です。

では、UIからのActionによってStateをどう変えるのでしょうか?

ルールはシンプルです。

UI Action から public な UiState を直接変更しない。

UI Action は ViewModel が持つ private な入力を更新します。

そして、UiState はその入力から導出します。


UI Action
  ↓
Private Flow
  ↓
UiState
  ↓
UI

例えば、検索画面なら次のように書けます。


private val _query = MutableStateFlow("")

val uiState = combine(
    _query,
    repository.items
) { query, items ->
    UiState(
        query = query,
        items = items
    )
}

fun onQueryChanged(query: String) {
    _query.value = query
}

次のように UiState を直接変更することもできます。


fun onQueryChanged(query: String) {
    _uiState.update {
        it.copy(query = query)
    }
}

この場合、UI Action 自身が画面の State を管理することになります。

一方、private な入力を更新するだけなら、責務が明確になります。


UI Action
    ↓
入力を変更する
    ↓
UiState を導出する
    ↓
UI を描画する

つまり、


UiState = f(Inputs)

であり、


UI Action → UiState を変更する

ではありません。

UI Action の役割は、「何が起きたか」を ViewModel に伝えることです。

そして、「その結果どのようなUiStateになるか」は Flow の依存関係によって決まります。

UI Action は UiState を変更する命令ではなく、入力を変更するトリガーである。

 

🧑🏻‍💻 参考


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

これ。

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

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

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

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

 

🧑🏻‍💻 考え方

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


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

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

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

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

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

 

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

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

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

 

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

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

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

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

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


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

 

🧑🏻‍💻 出来上がり

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

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

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


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をホストするためだけに FragmentActivity や NavHostFragment を使う必要はありません。

メインのエントリーポイントは、シンプルな 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)のためのブリッジコードを削除する

移行期間中、ComposeView や AndroidView といった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システムを必要としない」 状態を作ることこそが、本当のゴールなのです。


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

 

🤔 参考