🪣

バケツリレーしないComposeのイベント設計

に公開

はじめに

こんにちは!
皆さん、Jetpack Composeは使っていますか?クラシルリワードではUI実装にJetpack Composeを全面的に採用しています。

Jetpack Composeでは、宣言的UIをベースとした開発スタイルが採用されています。
これにより、UIの状態管理やデータフローが直感的かつシンプルになる一方で、UIイベントの取り扱いには独特の難しさも生じます。

特に、UIイベントを処理するための関数をコンポーネント間で引き渡していく際、いわゆるバケツリレーのような状態になり、結果としてどこで何が発火しているのかが見えづらくなってしまう問題に直面することがあると思います。

本記事では、クラシルリワードにおいて、このバケツリレー問題への対策として導入した仕組みをご紹介します。

同じようにComposeのイベント伝達で悩まれている方にとって、少しでも参考になれば嬉しいです!🙌

バケツリレー問題

Jetpack Composeでは、UIイベントを処理するために各コンポーネント間で関数(ラムダ)を引き渡すスタイルが一般的です。
単方向データフローを保つ設計として理想的ではありますが、画面構成が複雑になると次第にイベントを渡すだけの中間コンポーネントが増えてしまう問題に直面します。

@Composable
fun Screen(viewModel: MyViewModel) {
    Section(
        onButtonClick = { viewModel.onButtonClicked() }
    )
}

@Composable
fun Section(onButtonClick: () -> Unit) {
    Column {
        SubSection(onButtonClick = onButtonClick)
    }
}

@Composable
fun SubSection(onButtonClick: () -> Unit) {
    ContentArea(onButtonClick = onButtonClick)
}

@Composable
fun ContentArea(onButtonClick: () -> Unit) {
    RowWithButton(onButtonClick = onButtonClick)
}

@Composable
fun RowWithButton(onButtonClick: () -> Unit) {
    Button(onClick = onButtonClick) {
        Text("Click me")
    }
}

ここでは、ユーザーがボタンをタップしたときにViewModelへイベントを伝えるため、複数のコンポーネントでラムダのバケツリレーが発生しています。

まだラムダの数が1つで分かりやすいですが、実際のプロジェクトではラムダが複数あり、なおかつそれぞれ発火する箇所も異なる場合がほとんどだと思います。

プロジェクトで採用した対策

クラシルリワードでは、CompositionLocalを活用してUIイベントのディスパッチャーを提供する仕組みを導入しました。

これにより、各コンポーネント間でイベント処理用の関数を直接受け渡しする必要がなくなり、イベント発火の経路をシンプルかつ明確に管理できるようになりました。

まず、ベースとなる実装はこちらです。
次のコードだけでイベント伝達のための基盤を構築しています。

private val LocalUiEventDispatch = staticCompositionLocalOf<((UiEvent) -> Unit)?> { null }

@Composable
fun UiEventHandler(
    handle: (event: UiEvent) -> Unit,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(LocalUiEventDispatch provides { handle(it) }) {
        content()
    }
}

@Composable
fun rememberUiEventDispatcher(): (event: UiEvent) -> Unit {
    val dispatch = LocalUiEventDispatch.current ?: error("UiEventHandler is not provided")
    return remember { { event -> dispatch(event) } }
}

interface UiEvent

実際の使用例

これだけではイメージがつかないかと思うので、先ほどの例に当てはめてみます。

@Composable
fun Screen(viewModel: MyViewModel) {
    UiEventHandler(handle = { event ->
        when (event) {
            is UiEvent.OnButtonClick -> {
                viewModel.onButtonClicked()
            }
        }
    }) {
        Section()
    }
}

@Composable
fun Section() {
    Column {
        SubSection()
    }
}

@Composable
fun SubSection() {
    ContentArea()
}

@Composable
fun ContentArea() {
    val dispatch = rememberUiEventDispatcher()
    RowWithButton(
        onButtonClick = { dispatch(UiEvent.OnButtonClick) }
    )
}

@Composable
fun RowWithButton(
    onButtonClick: () -> Unit
) {
    Button(onClick = onButtonClick) {
        Text("Click me")
    }
}

private sealed interface UiEvent {
    data object OnButtonClick : UiEvent
}

中間のコンポーネントでは一切UIイベントを意識する必要がなくなり、実際にイベントを発火させる必要のあるコンポーネント(RowWithButton)にだけ、必要なラムダを渡すシンプルな構成を実現できます。

このアプローチのメリットと注意点

メリット

このアプローチのメリットは以下です。

中間コンポーネントからイベント引数を排除できる

従来のように、中間コンポーネント(SectionやSubSectionなど)に無理やりUIイベント用の関数を渡す必要がなくなります。
これにより、中間コンポーネントは純粋にレイアウトや表示内容だけに集中できるようになり、コードの見通しが大幅に向上しました。

UIイベントの発火場所が明確になる

UIイベントを発火したいコンポーネントだけが、rememberUiEventDispatcher()を呼び出して明示的にUIイベントを送信します。
そのため、「どのコンポーネントがどのロジックを呼び出しているか?」が一目で分かるようになりました。

イベントハンドリングの一元管理ができる

画面上位のUiEventHandlerでイベント処理をまとめて記述できるため、UIイベントに対する振る舞いを一箇所で管理できるようになりました。

注意点

このアプローチを採用するにあたって、いくつか意識している運用ルールと注意点もあります。

深すぎるコンポーネントでrememberUiEventDispatcher()を呼ばない

上述のメリットと逆行する形になりますが、UiEventDispatcherの取得はなるべく浅い場所で行い、深いコンポーネントではシンプルなコールバック受け渡しを続けるようにしています。

これには、再利用可能なコンポーネントを特定のUiEventに強く依存させない意図があります。
小さなコンポーネントで直接UiEventを発火させてしまうと、その部品自体が特定の画面固有のイベントに密結合してしまいます。

たとえば、再利用したい小さなButtonComposableが特定の画面用のUiEventを発火してしまうと他画面への転用が難しくなり、結果としてコンポーネントとしての再利用性・汎用性が低下してしまいます。

コンポーネントを削除した際のUiEvent消し忘れに注意する

この設計では、イベントハンドリング自体は上位のUiEventHandlerにまとめられているため、コンポーネントを削除しても、イベントハンドリングは消えません。

そのため、画面からボタンやリンクなどの要素を削除した際に、対応するUiEventの発行コードやUiEventHandlerでのハンドリングロジックを手動で削除する必要があります。

特に、使われていないUiEventがコードに残りやすくなるため、コードレビュー時などで「このUiEventはまだ必要なのか?」と意識的にチェックする運用を心がけています。

まとめ

本記事では、Jetpack Composeにおけるバケツリレー問題に対して、クラシルリワードで導入した対策方法をご紹介しました。

UiEventHandlerとrememberUiEventDispatcher()を活用することで、中間Composableへのイベント引数渡しを排除し、UI構造をシンプルに保つことができました。

一方で、UI部品を特定のUiEventに依存させない工夫や使われなくなったUiEventの整理といった運用上の注意点も意識しながら設計・運用を行っています。

もしこの記事が、Jetpack Composeでイベント設計に悩んでいる方の参考になれば幸いです!🙌

dely Tech Blog

Discussion