Composeのバケツリレーが限界? CompositionLocalでスマートに解決

etpack Composeで階層が深くなると発生する「バケツリレー(Prop Drilling)」。

「自分(中間コンポーネント)は使わないのに、孫に渡すためだけに引数を定義する」という不毛な作業は、CompositionLocal でスマートにショートカットしましょう。

🧑🏻‍💻 1. CompositionLocal の仕組み

通常、データは「親 → 子 → 孫」と引数で手渡ししますが、CompositionLocal はツリー全体にデータを「漂わせる」イメージです。

下の階層にいるコンポーネントは、必要なときにそのデータを「キャッチ」するだけで済みます。

🧑🏻‍💻 2. 実装例:2つのデータ(名前と色)を孫まで飛ばす

「ユーザー名」と「テーマカラー」の2つのデータを、中間の「子」を介さずに「孫」へ届けます。

① データの「鍵」を定義する

まず、共有したいデータの種類ごとに CompositionLocal オブジェクトを作成します。


import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color

// 1. 各データの「鍵」を作成(デフォルト値を設定)
val LocalUserName = staticCompositionLocalOf { "ゲスト" }
val LocalUserColor = staticCompositionLocalOf { Color.Black }

② 親・子・孫の実装

中間の ChildView が引数を一つも受け取っていない点に注目してください。


@Composable
fun ParentScreen() {
    val name = "桃太郎"
    val brandColor = Color(0xFFFF5722) // オレンジ

    // 2. CompositionLocalProvider で値を注入(複数一気に指定可能)
    CompositionLocalProvider(
        LocalUserName provides name,
        LocalUserColor provides brandColor
    ) {
        // 子を呼び出す(引数で渡す必要なし!)
        ChildView()
    }
}

// --- 中間のコンポーネント ---
@Composable
fun ChildView() {
    // 自身はデータを使わないので、引数も処理もスッキリ!
    println("ChildView: 私は何も知りません。")
    GrandChildView()
}

// --- データの使い道がある末端(孫) ---
@Composable
fun GrandChildView() {
    // 3. .current を使って必要なデータだけを直接取得
    val name = LocalUserName.current
    val color = LocalUserColor.current

    Column {
        Text(text = "こんにちは、${name}さん!", color = color)
        Text(text = "親から直接データを受け取りました。")
    }
}

🧑🏻‍💻 3. なぜ「2方向」でも楽なのか?

バケツリレーの場合、渡す項目が「名前」「色」「権限」「ID」と増えるたびに、ルートから末端までの全関数の引数を書き直す必要があります。

CompositionLocal なら:
親: provides を追加するだけ

子: 修正不要(ここが最大のメリット!)

孫: .current で取り出すだけ

🧑🏻‍💻 4. 注意点:使いすぎに注意!

魔法のように便利な CompositionLocal ですが、使いすぎると「このコンポーネントは何に依存しているのか?」がコードから読み取りにくくなります。

推奨: アプリ全体のテーマ(色・フォント)、ログインユーザー情報、ロケール設定など。

非推奨: その画面内の特定のボタンでしか使わないような一時的なフラグ。

バケツリレーが3階層を超え、中間コンポーネントが「ただの運び屋」になっていたら、CompositionLocal への切り替え時かもしれません。


Hiltのナビゲーション引数で SavedStateHandle から卒業する

なぜ Hilt 2.49+ と @AssistedInject が、完全な型安全性を備えた引数渡しの「正解」と言えるのか。

 

🤔 問題点:静的な依存関係と動的なデータの混在

Android開発において、ViewModelにランタイム引数(実行時引数)を注入することは、常に議論の的となってきました。

その本質は、「静的依存関係」(DIによって管理されるリポジトリなど)と、「動的データ」(画面遷移時に渡されるナビゲーション引数など)の対立にあります。

適切なDIパターンを適用せずにこの問題に対処しようとすると、高確率でアンチパターンに陥ります。たとえば、一時的な状態をシングルトン(Singleton)内に保持してしまうと、状態の汚染や画面間でのデータ漏洩といった致命的なバグを引き起こす危険性があります。

 

🤔 解決策:ハイブリッド注入を実現する @AssistedInject

この「静的」と「動的」のギャップを埋めるための最適なツールが、@AssistedInject です。これを使用することで、DI(Hilt)が管理するオブジェクトと、実行時に受け取るランタイムパラメータをスマートに組み合わせた「ハイブリッド注入」が可能になります。

通常の @Inject は不要 :
@AssistedInject を使用する場合、コンストラクタに通常の @Inject を付ける必要はありません(というか、付けてはいけません)。

関心の分離 :
リポジトリなどの依存関係は Hilt が自動で供給し、ナビゲーション引数などの動的なデータだけを手動で安全に渡す、という明確な役割分担(関心の分離)が実現します。

クリーンなコードへ :
これにより、バグの温床になりがちだった危険な lateinit var による後からの初期化コードとは、完全におさらばできます。


// ViewModel 実装

@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class) 
class  RouteBViewModel  @AssistedInject constructor( 
    private val repository: MyRepository,
    @Assisted val navKey: RouteB,
) : ViewModel() { 

    @AssistedFactory
    interface Factory { 
        fun create (navKey: RouteB) : RouteBViewModel 
    } 
}

 

🤔 実装:assistedFactory と hiltViewModel の組み合わせ

公式の nav3-recipes リポジトリでは、Hilt 2.49 以降で導入された最新の標準的な実装パターンが示されています。

ファクトリの宣言 :
@HiltViewModel(assistedFactory = ...) を使用して、Assisted Factory を ViewModel に直接リンクさせます。

2つの型パラメータ: Compose 側では、hiltViewModel() のように両方の型を明示的に指定して呼び出します。

安全なライフサイクル管理 :
creationCallback を利用することで、ナビゲーションのバックスタック管理(Lifecycle)を壊すことなく、安全にランタイム引数を渡すことができます。


// UI(ナビゲーション定義)

 entry<RouteB> { key -> 
    val viewModel = hiltViewModel<RouteBViewModel, RouteBViewModel.Factory>( 
        creationCallback = { factory -> 
            factory.create(key) 
        } 
    ) 
    ScreenB(viewModel = viewModel) 
}

 

🤔 メリット:100%の型安全性を実現し、SavedStateHandle のボイラープレートを完全に排除

このアーキテクチャ(Navigation 3 + @AssistedInject)に移行することで、コードベースの品質は大幅に向上します。

完全な型安全性(100% Type-Safe):
かつての文字列ベースのキー(string のキー指定)は過去のものです。ナビゲーション引数は、Route(Serializable等で定義された型)として、厳密に型指定されたオブジェクトのまま直接安全に渡されます。

SavedStateHandle からの解放 :
単に次の画面へ引数を渡すだけのために、SavedStateHandle を使ってごにょごにょと値を書き出したり読み出したりする定型文(ボイラープレート)はもう一切不要になります。

圧倒的なリファクタリングのしやすさ :
すべてがコンパイル時にチェックされるため、引数の追加・削除・変更といったリファクタリングが、一瞬かつ確実に(ランタイムエラーの心配なく)行えるようになります。

 

🤔 まとめ

Hilt と最新の Jetpack Navigation の相乗効果により、画面間の引数の受け渡しは驚くほどスムーズかつ洗練されたものになりました。もはや、型安全性を犠牲にしたり、不自然なアーキテクチャで回避策(ワークアラウンド)を講じたりする必要はありません。

@AssistedInject と、進化した hiltViewModel() API を組み合わせることで、「完璧な型安全性」と「クリーンなハイブリッド依存性注入(DI)」を今すぐあなたのプロジェクトに導入できます。


【Jetpack Compose Navigation3】EntryDecorator と ViewModel の「key」の深い関係

Jetpack Compose の Navigation3 では、画面遷移の引数として RouteB(val id: String) のようなデータクラスを渡します。

このとき、同じ RouteB でも id が違えば「別の画面(別の ViewModel)」として扱いたいですよね。

ここで重要になるのが EntryDecoratorviewModel(key = ...) の関係です。

 

🤔 結論:Decorator が「鍵」を管理してくれるか否か

一言でいうと、こうなります。

Decorator を使わない場合:
手動で viewModel(key = "unique_id") を指定する必要がある。

Decorator を使えば:
viewModel() の引数は不要。
Decorator が自動で各エントリに個別の ViewModelStore を割り当ててくれる。

 

🤔 1. Decorator を使わないパターン(手動管理)

Navigation3 の基本機能 NavDisplay を使う場合、ViewModel の生存期間はデフォルトの「Activity 全体」に紐づきます。

同じ RouteB でも id ごとに ViewModel を作り分けたい場合、以下のように手動で key を渡して、内部のキャッシュを分ける必要があります。


entry<RouteB> { key ->
    // key(RouteBのインスタンス)の id を使って、ViewModelを区別する
    val vm = viewModel(
        key = key.id, // ← これが必要!
        factory = RouteBViewModel.Factory(key)
    )
    ScreenB(vm)
}

これを忘れると、id = "1" の画面から id = "2" の画面へ遷移しても、同じ ViewModel インスタンスが使い回されてしまい、表示内容が更新されない というバグに繋がります。

 

🤔 2. Decorator を使うパターン(Navigation3 の推奨)

サンプルの BasicViewModelsActivity で採用されている方法です。

NavDisplay の設定に rememberViewModelStoreNavEntryDecorator() を追加します。


NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator() // ← これが魔法のスパイス
    ),
    entryProvider = entryProvider {
        entry<RouteB> { key ->
            // key 指定が不要になる!
            val vm = viewModel(factory = RouteBViewModel.Factory(key))
            ScreenB(vm)
        }
    }
)

なぜ key が不要になるのか?

ViewModelStoreNavEntryDecorator は、バックスタックにある 「各エントリ(NavEntry)」ごとに独立した ViewModelStore を生成してくれます。

RouteB("1") のエントリ ➔ 専用の ViewModelStore A が用意される

RouteB("2") のエントリ ➔ 専用の ViewModelStore B が用意される

viewModel() 関数は、その時点の LocalViewModelStoreOwner(Decorator が提供するエントリごとのストア)を参照します。そのため、わざわざ key を指定しなくても、エントリが違えば自動的に別の ViewModel が作られる仕組みです。

 

🤔 まとめ:どっちを使うべき?

基本的には「Decorator を使う」のが正解です。

理由1:
コードがシンプルになる(key の指定漏れがなくなる)。

理由2:
画面を戻ったときに ViewModel が正しく破棄されるなど、ライフサイクル管理が Navigation3 のエントリと完全に同期する。

「この画面だけは特殊な管理をしたい」という場合を除き、rememberViewModelStoreNavEntryDecorator() をセットアップして、型安全な key をそのまま Factory に渡すスタイルを基本にしましょう。


SavedInstanceState 不要!? Navigation3 の NavKey がもたらす Jetpack Compose 開発の変革

Android 開発者を長年悩ませてきた「画面回転」や「プロセス死」に伴う状態保存。SavedInstanceStateSavedStateHandle と格闘する日々は、もう過去のものになろうとしています。

Jetpack Navigation3 のソースコードを読み解くと、新しく登場した NavKey という仕組みが、Compose 時代の状態保存をいかにスマートに変革しようとしているかが見えてきました。

 

🤔 1. NavKey:単なる「マーカー」ではない、型安全の要


/**
 * Marker interface for keys.
 *
 * Objects and classes that extend this class must be marked with the [Serializable] annotation in
 * order to be saved with by the [rememberNavBackStack] function.
 *
 * This class is required because [Serializable] is only an annotation and does not provide a way to
 * link classes marked with the annotation together and provide a serializable that works with all
 * of them, resulting it making it impossible to properly save and restore.
 */
public interface NavKey

Navigation3 でバックスタックを管理する rememberNavBackStack を使う際、避けて通れないのが NavKey インターフェースの実装です。一見すると中身のないマーカーインターフェースですが、これこそが 「型安全な自動保存」 の鍵を握っています。

従来の Navigation では、引数の受け渡しや状態保存の裏側で Bundle が使われてきました。しかし、Bundle は何でも入る反面、型安全性が欠け、実行時のエラーを招きやすいという課題がありました。


/**
 * Provides a [NavBackStack] that is automatically remembered in the Compose hierarchy across
 * process death and configuration changes.
 *
 * This overload **does not take a [SavedStateConfiguration]**. It relies on the platform default:
 * on **Android**, state is saved/restored using a **reflection-based serializer**; on **other
 * platforms this will fail at runtime**. If you target non-Android platforms, use the overload that
 * accepts a [SavedStateConfiguration] and register your serializers explicitly.
 *
 * ### When to use this overload
 * - You are on **Android only** and want a simple API that uses reflection under the hood.
 * - Your back stack elements use **closed polymorphism** (sealed hierarchies) or otherwise work
 *   with Android’s reflective serializer.
 *
 * ### Serialization requirements
 * - Each element placed in the [NavBackStack] must be `@Serializable`.
 * - For **closed polymorphism** (sealed hierarchies), the compiler knows all subtypes and generates
 *   serializers; Android’s reflection will also work.
 * - For **open polymorphism** (interfaces or non-sealed hierarchies):
 *     - On Android, the reflection path can handle subtypes without manual registration.
 *     - On non-Android, this overload is **unsupported**; use the configuration overload and
 *       register all subtypes of [NavKey] in a [SerializersModule].
 *
 * @sample androidx.navigation3.runtime.samples.rememberNavBackStack_withReflection
 * @param elements The initial keys of this back stack.
 * @return A [NavBackStack] that survives process death and configuration changes on Android.
 * @see NavBackStackSerializer
 */
@Composable
public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack<NavKey> {
    return rememberSerializable(
        serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
    ) {
        NavBackStack(*elements)
    }
}

NavKey は、バックスタックに入るすべての要素が「シリアライズ可能であること」をコンパイル時に保証するための型上の制約として機能します。

 

🤔 2. なぜ「Serializable アノテーション」だけでは不十分か

Kotlin Serialization を使っているなら、@Serializable だけで十分だと思うかもしれません。しかし、ソースコードのコメントには重要な洞察が記されています。

Serializable はあくまでアノテーションであり、クラス同士をコード上でリンクさせる手段を提供しない。そのため、すべての要素を適切に保存・復元することが不可能になる。

NavKey という共通のインターフェースを介することで、Navigation 側は「このバックスタックの中身はすべて、共通の手順でシリアライズできる仲間である」と認識できるようになります。これが、「何も考えなくても状態が復元される」 という体験の裏側にあるロジックです。

 

🤔 3. Android 限定の「リフレクション」がもたらす魔法

Navigation3 の rememberNavBackStack には、Android 開発者にとって非常に強力な恩恵があります。

Android プラットフォーム上では、リフレクションベースのシライライザーがデフォルトで動作します。これにより、開発者はシリアライザーを個別に登録する手間(ボイラープレート)から解放されます。

  • 開発者: NavKey を継承し、@Serializable を付ける。
  • Navigation3: process death の際、リフレクションを使ってバックスタックをまるごと保存し、復帰時に自動で復元する。

これこそが、「さらば SavedInstanceState」と言える最大の理由です。

 

🤔 4. Navigation3 時代の実装スタイル

これからの Compose 開発では、以下のように NavKey を実装した sealed interface を定義するのが標準になるでしょう。


@Serializable
sealed interface Screen : NavKey {
    @Serializable
    data object Home : Screen

    @Serializable
    data class UserProfile(val userId: String) : Screen
}

// Composable 内での利用
val backStack = rememberNavBackStack(Screen.Home)

この実装だけで、userId を含めた遷移状態が、OSによるメモリ回収の後でも完璧に復元されます。もはや Bundle を意識するシーンはほとんどなくなるはずです。

 

🤔 まとめ

Navigation3 における NavKey の導入は、Google が掲げる開発思想の現れのように感じます。

内部の複雑なシリアライズ処理を NavKey という一つのインターフェースに集約し、開発者には最小限の実装(アノテーションと継承)だけで最大限の恩恵(自動状態保存)を与える。

「状態保存に怯える開発」はもう終わりです。Navigation3 を武器に、より本質的な UI 実装に集中していきましょう。


Jetpack Compose における State と Effect の境界線:ワンショットイベントに Channel を採用する理由

Jetpack Compose で開発をしていると、必ず直面する問いがあります。

「これは State として保持すべきか、それとも Effect(副作用)として処理すべきか?」

という問題です。

Compose の宣言的 UI パラダイムにおいて、この境界線を曖昧にすると、画面回転時の二重トーストや、意図しない画面遷移といったバグを招きます。

本記事では、その明確な使い分けと、イベント制御における Kotlin Channel の有効性について解説します。

 

🧑🏻‍💻 1. 「状態 (State)」と「副作用 (Effect)」の本質的な違い

使い分けの基準はシンプルです。

「そのデータは、UI のスナップショットの一部か?」

と自問してください。

State:UI の「今」を表すもの

State は、再構成(Recomposition)によって何度読み込まれても同じ結果を示すべきものです。

  • 例: テキストフィールドの入力値、読み込み中フラグ、リストデータ
  • 性質: 保持(Retention)

Effect:UI の「外」で起きる一回きりのこと

Effect は、Compose のレンダリングサイクルとは独立して実行される処理です。

  • 例: ログ出力、アナリティクス送信、タイマーの開始
  • 性質: 実行(Execution)

 

🧑🏻‍💻 2. ワンショットイベントの罠:StateFlow vs Channel

ここで問題になるのが、トースト表示や画面遷移のような「一度だけ実行したいアクション」です。

これらを StateFlow で管理しようとすると、Android 特有のライフサイクル問題にぶつかります。

StateFlow の限界

StateFlow は常に「最新の状態」を保持します。

1. エラーが発生し、State を ErrorMessage("Failed") に更新。
2. UI がそれを検知してトーストを表示。
3. ここで画面を回転させる。
4. 新しい Activity が StateFlow を購読し、最新の "Failed" を再び受け取ってしまう。
5. トーストが二重に表示される。

これを防ぐために「フラグを戻す」処理を挟むのは、シンプルではありません。

 

🧑🏻‍💻 3. Channel は「消費されるイベント」に最適である

そこで登場するのが Channel です。Channel は、「土管」のような振る舞いをします。

  • 一度きりの配送: 誰かがイベントを受け取った(消費した)瞬間、そのイベントは Channel から消えます。
  • 画面回転に強い: 新しい Activity が再購読しても、古いイベントは既に消費されているため、二重実行は発生しません。
  • バッファの活用: Channel.BUFFERED を使うことで、アプリがバックグラウンドにいる間に発生したイベントも、フォアグラウンドに戻った瞬間に安全に処理できます。

 

🧑🏻‍💻 4. 実装のベストプラクティス

私のプロジェクトでは、以下のような棲み分けを徹底しています。


// ViewModel

// UI の状態(表示データ)
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()

// UI へのイベント(ワンショット)
private val _eventChannel = Channel<UiEvent>(Channel.BUFFERED)
val events = _eventChannel.receiveAsFlow()


// UI (Compose)

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

 

🧑🏻‍💻 まとめ

  • 永続的な見た目に関わるなら State (StateFlow)
  • 一過性の挙動に関わるなら Effect (Channel)

複雑なフラグ管理でコードを汚す前に、ツールが持つ「自然な性質」を利用しましょう。

Channel を使うことは、Compose におけるイベントハンドリングを最もシンプルにする考え方の一つです。