Composeのコンポーネントツリーにおけるバケツリレーを回避する方法

深くネストされたUIツリーの可読性と保守性を維持するための4つのComposeパターンを紹介します。

Jetpack Composeでは、小さく再利用可能なコンポーザブルを組み合わせてUIを構築することが推奨されています。しかし、アプリケーションが成長するにつれて、それらのコンポーザブルは自然と深くネスト(階層化)されていくものです。

その結果としてよく起こるのが、「プロップドリル(データのバケツリレー)」です。これは、特定のデータやコールバックを、それらを実際には必要としない中間層のコンポーザブルをいくつも経由して、下層へと引き渡していく現象を指します。


Parent
  ↓
Root
  ↓
Content
  ↓
Card
  ↓
UserName

この例では、RootContentCard は単に UserName にパラメータを転送しているだけです。彼ら自身はそのデータを利用しておらず、単なる「中継役」として機能しています。

Composeがこの問題を完全に消し去ってくれるわけではありませんが、問題を軽減または回避するための洗練された方法がいくつか用意されています。今回は、私が特によく使う4つのパターンを見ていきましょう。

 

🤔 レイアウト用コンポーネントには「Slot API」を使う

中間層のコンポーザブルが単にレイアウト(配置)を定義しているだけなら、通常は「Slot API」を使うのがもっともクリーンな解決策です。

対応前
すべてのコンポーザブルが、同じパラメータをひたすらバケツリレーしています。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    Root(userName)
}

@Composable
fun Root(userName: String) {
    Content(userName)
}

@Composable
fun Content(userName: String) {
    Card(userName)
}

@Composable
fun Card(userName: String) {
    UserName(userName)
}

@Composable
fun UserName(userName: String) {
    Text(userName)
}

実際には、UserName だけが userName を必要としています。

対応後
代わりに、親(呼び出し側)に子コンポーザブルを組み立てさせます。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    CardLayout {
        UserName(userName)
    }
}

@Composable
fun CardLayout(
    content: @Composable () -> Unit
) {
    Card {
        content()
    }
}

これでレイアウト用コンポーネント CardLayout は、userName について何も知る必要がなくなりました。単に、受け取ったコンテンツを「どこに表示するか」を決めているだけです。

ScaffoldLazyColumnButton といったComposeの標準APIが、何十個ものパラメータを個別に公開するのではなく、スロット(content ラムダ)を採用しているのはまさにこれが理由です。

コンポーザブルの役割が「データ」の処理ではなく「レイアウト」である場合は、いつでもSlot APIの採用を検討しましょう。

 

🤔 共有オブジェクトには「CompositionLocal」を使う

オブジェクトの中には、特定のUIブランチ(階層)だけでなく、コンポジション全体で共有されるべきものがあります。

たとえば以下のようなものです。

- Navigator(画面遷移)
- Theme(テーマ・デザインシステム)
- Analytics(ログ分析ツール)
- User session(ユーザーセッション情報)
- Density(画面密度)

これらをすべてのコンポーザブルに引数で渡そうとすると、すぐに同じコードの繰り返しになってしまいます。

対応前


@Composable
fun ParentScreen() {
    Root(navigator)
}

@Composable
fun Root(navigator: Navigator) {
    Content(navigator)
}

@Composable
fun Content(navigator: Navigator) {
    Detail(navigator)
}

@Composable
fun Detail(navigator: Navigator) {
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

対応後


// 1. CompositionLocalを定義する
val LocalNavigator = staticCompositionLocalOf<Navigator> { 
    error("No Navigator provided") 
}

@Composable
fun ParentScreen() {
    // 2. 最上位で値をプロバイドする
    CompositionLocalProvider(LocalNavigator provides navigator) {
        Root()
    }
}

@Composable fun Root() { Content() }
@Composable fun Content() { Detail() }

@Composable
fun Detail() {
    // 3. 必要な場所で直接呼び出す
    val navigator = LocalNavigator.current
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

これにより、中間層にあるすべてのコンポーザブルが依存関係のチェーンから解放され、コードがすっきりします。

ただし、トレードオフとして「依存関係が暗黙的(コードの表面上は見えにくく)になる」という点には注意が必要です。そのため、CompositionLocal の使用は、UIの大部分で本当に広く共有される値だけに限定するのがベストです。

 

🤔 状態(State)とイベント(Event)をまとめる

プロップドリルが問題になるのは、状態(データ)を渡すときだけではありません。

実は、コールバック(関数)のバケツリレーのほうが、より大きな問題になりがちです。

対応前
引数が増えるたびに、中間層のすべてのコンポーザブルで同じコールバックを転送し続けなければなりません。


Child(
    userName = state.userName,
    isLoading = state.isLoading,
    onRefresh = viewModel::refresh,
    onRetry = viewModel::retry,
    onDelete = viewModel::delete,
    onRename = viewModel::rename,
    onLogout = viewModel::logout
)


@Composable
fun Content(
    userName: String,
    isLoading: Boolean,
    onRefresh: () -> Unit,
    onRetry: () -> Unit,
    onDelete: () -> Unit,
    onRename: (String) -> Unit,
    onLogout: () -> Unit
) {
    Child(
        userName,
        isLoading,
        onRefresh,
        onRetry,
        onDelete,
        onRename,
        onLogout
    )
}

対応後
複数のコールバックを個別に公開するのではなく、単一の「イベントディスパッチャー(イベント通知用ラムダ)」にまとめます。


sealed interface ScreenEvent {
    data object Refresh : ScreenEvent
    data object Retry : ScreenEvent
    data object Delete : ScreenEvent
    data object Logout : ScreenEvent
    data class Rename(val name: String) : ScreenEvent
}


Child(
    state = state,
    onEvent = viewModel::onEvent
)


Button(
    onClick = {
        onEvent(ScreenEvent.Refresh)
    }
) {
    Text("Refresh")
}

これにより、コンポーザブルが公開するAPI(引数)が圧倒的にすっきりします。さらに、新しいユーザーアクションを追加したくなったときも、中間層の関数をすべて書き直す必要がなくなるのが大きなメリットです。

 

🤔 巨大なViewModelを分割する

時には、プロップドリル(バケツリレー)が根本的な原因ではなく、別の問題から生じている「症状」に過ぎないこともあります。

もし、単一の ScreenViewModel が画面全体のあらゆる状態(State)を管理しているとしたら、すべてのデータがその1つのオブジェクトから流れ出すことになるため、バケツリレーが発生するのは当然と言えます。

対応前
すべての下層コンポーネントが、同じ単一のViewModelに依存しています。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │   ├── BottomSheet
│   │   └── Dialog

対応後
ViewModelを分割し、UIの各パーツにスコープを合わせます。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │
│   ├── BottomSheetViewModel
│   │     ↓
│   │   BottomSheet
│   │
│   └── DialogViewModel
│         ↓
│       Dialog


@Composable
fun BottomSheet() {
    val viewModel: BottomSheetViewModel = viewModel()

    val state by viewModel.state.collectAsState()

    // ...
}

Navigation 3やネストされたナビゲショングラフ(Nested Navigation Graphs)の登場により、UIのより小さな単位(パーツ)に対してViewModelのスコープを制限することが、以前よりもはるかに簡単になりました。

その結果、不要な共有状態(State)が減り、プロップドリルも大幅に解消されます。

 

🧑🏻‍💻 まとめ

プロップドリル(データのバケツリレー)が起きているからといって、必ずしも設計(アーキテクチャ)が悪いとは限りません。UIツリーが成長していく過程で、自然と発生してしまうケースも多々あります。

ここで重要になるのは、「なぜそのデータが、これほど多くの階層を経由しているのか」を掘り下げて考えることです。

これらのパターンは、どれか一つしか選べないというものではありません。事実、洗練されたComposeのコードベースでは、適材適所でこれら4つのアプローチがすべて組み合わされて使われています。


Related Categories :  AndroidDevelopmemtJetpackComposeKotlinNewbie