Java 脳のまま Jetpack Compose を書いてはいけない (2/2)

👉 Java 脳のまま Jetpack Compose を書いてはいけない (1/2) の続きです。

 

🧑🏻‍💻 画面遷移を if 文だけで管理してはいけない理由

Composition から外れると remember の状態はどうなるのか

小さな UI なら、条件分岐で表示を切り替える方法も有効です。


@Composable
fun App() {
    var showDetail by remember {
        mutableStateOf(false)
    }

    if (showDetail) {
        DetailScreen(
            onBack = { showDetail = false }
        )
    } else {
        HomeScreen(
            onOpenDetail = { showDetail = true }
        )
    }
}

しかし、HomeScreen が Composition から外れると、その内部の remember で保持していた状態は通常失われます。

例えば、ホーム画面でスクロールし、詳細画面へ移動して戻ると、ホーム画面を再構築したときにスクロール位置が初期化されることがあります。

この挙動自体がバグというわけではありません。単純な条件分岐は、複数画面の履歴や状態を管理する仕組みではないからです。

画面履歴、戻る操作、状態の保存・復元が必要になったら、Navigation の導入を検討すべきです。

navBackStack は単なる画面履歴ではない

Navigation 3 では、バックスタックをアプリ側が所有し、画面を識別するキーのリストとして扱います。NavDisplay はその状態を監視し、対応する画面を表示します。

👉 基本を理解して実装する  |  App architecture  |  Android Developers

概念的には、次のような流れです。


ユーザー操作
    |
    v
Back Stack の変更
    |
    v
NavDisplay が状態を反映
    |
    v
表示する画面が変わる

Navigation 3 の基本的な例を見てみましょう。


@Serializable
data object Home : NavKey

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

画面を表すキーを定義します。実際の画面コンテンツそのものではなく、画面を特定するための情報です。

次にバックスタックを作成します。


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

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

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

この例では、ホーム画面から詳細画面に移動すると、バックスタックに Detail が追加されます。戻る操作では最後のエントリーが削除されます。

コードのポイントは、画面の表示を手動で切り替えていないことです。バックスタックというナビゲーション状態を変更し、その状態を Navigation が UI に反映します。

なお、この例は Navigation 3 の API を使用しています。必要な依存関係やシリアライザーの設定は、プロジェクトの採用バージョンに合わせて追加してください。

バックスタックだけで状態がすべて保持されるわけではない

ここは誤解しやすいポイントです。

バックスタックに画面のキーが残っていることと、画面内部の状態が自動的にすべて復元されることは同じではありません。

Navigation 3 で状態を適切に保持するには、必要に応じて保存可能なバックスタック、Saveable State、ViewModel のスコープなどを構成します。公式ドキュメントでも、rememberNavBackStack やエントリー用のデコレーターを利用した状態管理が説明されています。

👉 Save and manage navigation state  |  App architecture  |  Android Developers

例えば、画面エントリーに ViewModel のスコープを与える場合は、対応する Navigation 3 の ViewModel 統合を利用します。


NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {
        entry<Home> {
            HomeScreen()
        }

        entry<Detail> { key ->
            DetailScreen(itemId = key.itemId)
        }
    },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    )
)

これはエントリーごとの Saveable State と ViewModel の管理を組み込む例です。利用する依存関係と API のシグネチャは、採用バージョンの公式ドキュメントで確認してください。

Navigation 3 が重要なのは、単に戻るボタンを実装できるからではありません。画面履歴を明示的な State として表現し、画面の表示や状態管理をそのモデルと連携させられることにあります。

 

🧑🏻‍💻 State・Runtime・Navigation を一つの設計モデルで考える

ここまでの話は、別々のテクニックに見えるかもしれません。しかし、共通する考え方があります。


アプリの状態
    |
    +----------------------+
    |                      |
    v                      v
画面の UI State        Navigation State
    |                      |
    v                      v
Compose Runtime        Back Stack
    |                      |
    v                      v
Composition          表示する画面の決定
    |                      |
    +-----------+----------+
                |
                v
          ユーザーに見える UI

この図は責務の関係を概念的に示したものです。実際には Navigation の画面表示も Compose Runtime 上の Composition として実行されます。

設計では、次の 3 点を区別します。

・ State: UI に何を表示するかを決めるデータ。
・ Compose Runtime: State の読み取りを追跡し、Composition の更新を管理する仕組み。
・ Navigation: 画面履歴やエントリーを管理し、ナビゲーション状態を UI に反映する仕組み。

それぞれの責務を分けると、コードも自然に整理されます。

例えば、次のような実装は避けたいものです。


// Avoid: UI state and navigation are mixed together.
var currentScreen by mutableStateOf("home")
var detailTitle by mutableStateOf("")
var isLoading by mutableStateOf(false)

// Every action manually synchronizes several values.
fun openDetail(title: String) {
    currentScreen = "detail"
    detailTitle = title
    isLoading = false
}

複数の変数を手動で同期すると、更新漏れや矛盾が生まれやすくなります。

代わりに、画面のデータは UiState として導出し、画面遷移はバックスタックの変更として表現します。


// UI state is derived from data sources.
val uiState: StateFlow<DetailUiState> =
    combine(repository.detail, selectedItemId) {
            detail, selectedId ->
        DetailUiState(
            detail = detail,
            selectedItemId = selectedId
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        DetailUiState()
    )

// Navigation is handled separately.
backStack.add(Detail(itemId))

これは概念例であり、selectedItemId の定義や combine の型はアプリに合わせる必要があります。重要なのは、UI データと画面履歴を一つの巨大な可変オブジェクトに詰め込まないことです。

 

🧑🏻‍💻 まとめ: Compose 化とは、構文ではなく設計モデルを変えること

Compose を使えば、自動的に宣言型の設計になるわけではありません。従来の命令型 UI の考え方を持ち込めば、状態の手動同期や画面管理の複雑さは残ります。

移行時には、次の点を確認してみてください。

・ UI を View のプロパティのように直接変更していないか。
・ UI の状態を複数の場所で手動同期していないか。
・ State をどこで所有し、どこで読み取るかが明確か。
・ Composition の寿命と ViewModel の寿命を区別しているか。
・ 画面履歴と戻る操作を、単純な条件分岐だけに任せていないか。
・ 状態の保存・復元を、利用する仕組みに合わせて設計しているか。

Compose の本質は、UI のコードを Kotlin に書き換えることではありません。

State と UI の依存関係を宣言し、Runtime の仕組みに沿って Composition を更新し、Navigation の状態と画面のライフサイクルを適切に連携させることです。

Compose を本当に使いこなすには、Composable の書き方を覚えるだけでなく、Compose Runtime が前提としている設計モデルに合わせる必要があります。

 

🧑🏻‍💻 参考

👉 Lifecycle of composables  |  Jetpack Compose  |  Android Developers
👉 State and Jetpack Compose  |  Android Developers
👉 Understand and implement the basics  |  App architecture  |  Android Developers
👉 Save and manage navigation state  |  App architecture  |  Android Developers


Related Categories :  Android・Developmemt・JetpackCompose・KMP・Kotlin・Kotlin Multiplatform Mobile