🪣

Composeのバケツリレーを救いたい

2024/06/27に公開

はじめに

スペースマーケットでAndroidエンジニアをしておりますseoです。

Jetpack ComposeになってUI作成のスピードが上がったものの、引数の多さには目を背けたくなるときがあります😣

そこで今回はCompositionLocalを使って、いくつかの引数を消してバケツリレーを少しだけ解消する方法を考えたいと思います。

CompositionLocalについて

CompositionLocalは明示的にパラメーターを渡すことなく、下層のComposableにパラメーターを渡すことのできる方法です。

  • CompositionLocalに値を指定する

https://github.com/android/snippets/blob/0e3348719a5c82ec0463ddf87180585798ea351b/compose/snippets/src/main/java/com/example/compose/snippets/state/CompositionLocalSnippets.kt#L142-L164

  • CompositionLocalの使用

https://github.com/android/snippets/blob/0e3348719a5c82ec0463ddf87180585798ea351b/compose/snippets/src/main/java/com/example/compose/snippets/state/CompositionLocalSnippets.kt#L168-L175

これを使うことによって、中間のComposableに値を渡すことなく、必要な箇所で参照することができます。

https://developer.android.com/develop/ui/compose/compositionlocal?hl=ja#providing-values

イベント測定メソッドを例に挙げて

今回はCompositionLocalを使って、イベント計測をするメソッドを下層のComposableに伝える方法をしてみたいと思います。

CompositionLocalを使わない場合

まずは、明示的にパラメーターを渡す方法でやってみます。

HomeFragmentにComposable関数が乗っているViewです。

HomeFragment.kt
@AndroidEntryPoint
class HomeFragment : Fragment() {

    private val homeViewModel: HomeViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        homeViewModel.fetchHomeData()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setContent {
                val uiState by homeViewModel.uiState.collectAsState()
                HomeScreen(
                    uiState = uiState,
                    sendErrorResult = homeViewModel::sendErrorResult,
                    sendTapReloadButton = homeViewModel::sendTapReloadButton,
                    fetchData = homeViewModel::fetchData,
                    sendHomeScreen = homeViewModel::sendHomeScreen,
                )
            }
        }
    }
}

HomeViewModelでTrackingHelperクラスをDIしています。
TrackingHelperクラスにFirebaseやAmplitudeといった計測イベントのメソッドが定義されています。

HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: Repository,
    private val trackingHelper: TrackingHelper,
) {
    private val _uiState: MutableStateFlow<HomeUiState> = MutableStateFlow(HomeUiState())
    val uiState = _uiState.asStateFlow()

    fun fetchHomeData() {
        repositry.fetch().onEach {
            _uiState.update { it }
        }.launchIn(viewModelScope)
    }

    fun sendShowErrorResult() {
        trackingHelper.showErrorResult()
    }

    fun sendTapReloadButton() {
        trackingHelper.tapReloadButton()
    }

    fun sendShowHomeScreen() {
        trackingHelper.showHomeScreen()
    }
}

こちらが子Composeです。孫Composeで必要となるパラメーターを渡します。

HomeScreen.kt
@Composable
fun HomeScreen(
    uiState: HomeUiState,
    sendErrorResult: () -> Unit,
    sendTapReloadButton: () -> Unit,
    fetchData: () -> Unit,
    sendHomeScreen: () -> Unit,
) {
    Scaffold {
        when (uiState) {
            is Loading -> LoadingIndicatorView()
            is Error -> ErrorView(
                sendErrorResult = sendErrorResult,
                sendTapReloadButton = sendTapReloadButton,
                fetchData = fetchData,
            )
            is Success -> SuccessView(
                sendHomeScreen = sendHomeScreen
            )
        }
    }
}

こちらが孫composeです。

ErrorView.kt
@Composable
fun ErrorView(
    sendErrorResult: () -> Unit,
    sendTapReloadButton: () -> Unit,
    fetchData: () -> Unit
) {
    LaunchedEffect(Unit) {
        sendErrorResult()
    }
    Column {
        Text('エラーが発生しました')
        Button(
            text = 'リロード',
            onClick = {
                fetchData()
                sendTapReloadButton()
        )
    }
}

SuccessView.kt
@Composable
fun SuccessView(
    sendHomeScreen: () -> Unit
) {
    LaunchedEffect(Unit) {
        sendHomeScreen()
    }
    Column {
        ...
    }
}

CompositionLocalを使ってイベント関数を渡す場合

次に、CompositionLocalを使って、必要な箇所でtrackingHelperインスタンスを参照できるようにしましょう。

はじめに、CompositionalLocalの定義を行い、孫まで渡したいパラメーターをCompositionLocalProviderを使ってCompositionalLocalに値を設定します。

HomeFragment.kt
+ val LocalTrackingHelper = staticCompositionLocalOf<TrackingHelper> { error("No TrackingHelper provided") }

@AndroidEntryPoint
class HomeFragment : Fragment() {

    private val homeViewModel: HomeViewModel by viewModels()

+   @Inject
+   lateinit var trackingHelper: TrackingHelper

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        homeViewModel.fetchHomeData()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setContent {
+               CompositionLocalProvider(LocalTrackingHelper provides trackingHelper) {
                    val uiState by homeViewModel.uiState.collectAsState()
                    HomeScreen(
                        uiState = uiState,
                        sendErrorResult = homeViewModel::sendErrorResult,
                        sendTapReloadButton = homeViewModel::sendTapReloadButton,
                        fetchData = homeViewModel::fetchData,
                        sendHomeScreen = homeViewModel::sendHomeScreen,
                    )
+               }
            }
        }
    }
}

そうすることで孫composeでは直接trackingHelperインスタンスを召喚することができます。

ErrorView.kt
 @Composable
 fun ErrorView(
-    sendErrorResult: () -> Unit,
-    sendTapReloadButton: () -> Unit,
     fetchData: () -> Unit
) {
+    val trackingHelper = LocalTrackingHelper.current
     LaunchedEffect(Unit) {
         trackingHelper.sendErrorResult()
     }
     Column {
         Text('エラーが発生しました')
         Button(
             text = 'リロード',
             onClick = {
                 fetchData()
-                sendTapReloadButton()
+                trackingHelper.sendTapReloadButton()
         )
     }
 }

SuccessView.kt
 @Composable
 fun SuccessView(
-    sendHomeScreen: () -> Unit
 ) {
+    val trackingHelper = LocalTrackingHelper.current
     LaunchedEffect(Unit) {
-         sendHomeScreen()
+         trackingHelper.sendHomeScreen()
     }
     Column {
         ...
     }
 }

子ComposeとViewModelについても、不要な引数は削除することができました!

HomeScreen.kt
 @Composable
 fun HomeScreen(
     uiState: HomeUiState,
-    sendErrorResult: () -> Unit,
-    sendTapReloadButton: () -> Unit,
     fetchData: () -> Unit,
-    sendHomeScreen: () -> Unit,
) {
    Scaffold {
        when (uiState) {
            is Loading -> LoadingIndicatorView()
            is Error -> ErrorView(
-                sendErrorResult = sendErrorResult,
-                sendTapReloadButton = sendTapReloadButton,
                 fetchData = fetchData,
            )
            is Success -> SuccessView(
-                sendHomeScreen = sendHomeScreen
            )
        }
    }
}
HomeViewModel.kt
 @HiltViewModel
 class HomeViewModel @Inject constructor(
     private val repository: Repository,
-    private val trackingHelper: TrackingHelper,
 ) {
     private val _uiState: MutableStateFlow<HomeUiState> = MutableStateFlow(HomeUiState())
     val uiState = _uiState.asStateFlow()

     fun fetchHomeData() {
         repositry.fetch().onEach {
             _uiState.update { it }
         }.launchIn(viewModelScope)
     }

-     fun sendShowErrorResult() {
-         trackingHelper.showErrorResult()
-     }

-     fun sendTapReloadButton() {
-         trackingHelper.tapReloadButton()
-     }

-     fun sendShowHomeScreen() {
-         trackingHelper.showHomeScreen()
-     }
}

だいぶスッキリしましたね😊

CompositionLocalの弊害

ただしCompositionLocalにはデメリットもあり、公式でも下記の通り、多様は勧めていないようです。

ただし、CompositionLocal が常に最適な解決手段だとは限りません。CompositionLocal の乱用はおすすめしません。次のようなデメリットがあるためです。
CompositionLocal を使用すると、コンポーザブルの動作を理解することが難しくなります。暗黙的な依存関係ができるため、それを使用するコンポーザブルの呼び出し元で、すべての CompositionLocal の値が満たされていることを確認する必要があります。

そのため、チーム内でこういった場合はCompositionLocalで渡して、それ以外は明示的にパラメーターを渡す、など約束事を決めて運用するのがいいのではないかと思います。

最後に

スペースマーケットでは一緒に働く仲間を募集しています!
カジュアルに話を聞きたいだけという方でも大歓迎ですので、ちょっとでも興味があれば以下からご応募お待ちしております!

▼インフラエンジニア
https://herp.careers/v1/spmhr/qZ-3RxrPtDgM

▼Webエンジニア
https://herp.careers/v1/spmhr/9zYSnsOQ0UMA

▼アプリシニアエンジニア(EM候補)
https://herp.careers/v1/spmhr/3R1WuEBp6TlT

▼バックエンドエンジニア(EM候補)
https://herp.careers/v1/spmhr/f8x6AkIueBSb

▼エンジニア採用ページ(迷ったらこちらからどうぞ!)

スペースマーケット Engineer Blog

Discussion