LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

Jetpack Composeにおいてコールバックで肥大化する関数にどう立ち向かうか?(Yahoo!フリマでの改善事例)

こんにちは。Yahoo!フリマのAndroid開発を担当している寳田(たからだ)です。

私たちのチームでは、Android開発では一般的なViewModel + Fragmentの構成を採用しています。従来はXMLレイアウトを用いてUIを構築していましたが、新規に作成する画面にはJetpack Composeを採用し、既存のXMLレイアウトもJetpack Composeへ移行することで生産性の向上を図っています。

Jetpack Composeへの移行を進める中で、課題の一つにComposable関数およびプレビュー関数の肥大化がありました。これを解消するためにどのようなアプローチを採用したか、検討した案にも触れつつ実際のコードを基に説明したいと思います。

Composable関数でのよくあるコールバック定義

Composable関数とは @Composable アノテーションが付与された関数であり、Jetpack Compose UIを宣言的に構築するための基本となる要素です。Composable関数でクリック処理などのUIイベントを扱う際、関数の引数にコールバックを定義するのが一般的です。

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
)

ViewModel + Fragment構成を採用している場合、Fragmentでコールバックを受け取り、ViewModelの関数を呼び出すことがシンプルな実装だと考えられます。

肥大化するComposable関数

昨年リリースされたグッズ交換機能の募集フォーム画面ではViewModel + Fragment構成を採用しつつ、UIはJetpack Composeで実装しました。募集フォームでは、以下のような機能が存在します。

  • 下書き一覧:タップで下書き一覧画面に遷移
  • カメラボタン:タップでカメラ撮影画面に遷移
  • テキスト入力:ViewModelに入力データを保持、文字数超過などバリデーションチェックも行う
  • ハッシュタグ:「×」ボタンをタップで削除
  • その他いろいろ

グッズ交換の募集フォーム画面

募集フォームのような、ユーザーからの入力を受ける要素が多い画面ではイベントを扱うためのコールバックが増え、Composable関数が肥大化し可読性の低下を招いていました。

// グッズ交換機能 募集フォーム(改修前)
@Composable
fun ExchangeFormScreen(
  uiState: ExchangeFormUiState,
  onClickDraftList: () -> Unit,
  onClickPastList: () -> Unit,
  onChangeExchangeName: (String) -> Unit,
  onClickOpenCamera: () -> Unit,
  onChangeGive: (String) -> Unit,
  onChangeTake: (String) -> Unit,
  onChangeComment: (String) -> Unit,
  onClickAddHashtags: () -> Unit,
  // 以下略
)

また、プレビュー関数も同様に肥大化するため、変更があるたびに空のコールバックの記述や削除が必要となり、手間が増えていました。

@Preview
@Composable
private fun ExchangeFormScreenPreview() {
  AppTheme {
    ExchangeFormScreen(
      uiState = ExchangeFormUiState.Default,
      onClickDraftList = {},
      onClickPastList = {},
      onChangeTitle = {},
      onClickOpenCamera = {},
      onChangeGive = {},
      onChangeTake = {},
      onChangeComment = {},
      openCamera = {},
      openPictureViewer = {},
      onClickAddHashtags = {},
      // 以下略
    )
  }
}

そして、Pagerなど子ビューを持つ画面では、子ビューにコールバックを追加・削除するたびに親ビューも変更しなければならず、手間がかかっていました。

ExchangeTradeFetchedScreen(
  sendTabUiState: ExchangeTradeUiState.LoadState.Fetched.SendTabUiState,
  receiveTabUiState: ExchangeTradeUiState.LoadState.Fetched.ReceiveTabUiState,
  onExchangeQuestionClick: () -> Unit,
  onClickReceiverPaymentReceipt: () -> Unit,
  // 子ビューに変更が入るたびに修正が必要
) {
  val pagerState = rememberPagerState()
  val coroutineScope = rememberCoroutineScope()

  Column {
    // TabRowなど

    HorizontalPager(
      pageCount = ExchangeTradeTab.values().size,
      state = pagerState,
    ) { index ->
      when (index) {
        ExchangeTradeTab.SENDER.ordinal -> {
          ExchangeTradeSendTabScreen(
            uiState = sendTabUiState,
            onExchangeQuestionClick = onExchangeQuestionClick,
            // 以下略
          )
        }

        ExchangeTradeTab.RECEIVER.ordinal -> {
          ExchangeTradeReceiveTabScreen(
            uiState = receiveTabUiState,
            onClickPaymentReceipt = onClickReceiverPaymentReceipt,
            // 以下略
          )
        }
      }
    }
  }
}

このように、全体的に変更に対して細かな手間がかかり、課題を感じていました。

改善案:sealed interface化

Jetpack Composeの実装を進めていく上で、特に以下の問題を抱えていました。

  • Composable関数が肥大化していく
  • 実装関数の変更に伴いプレビュー関数の変更が必要
  • 子ビューの変更とともに親ビューの変更も必要

この問題を解消するため手探りの中で以下の3案を検討していました。

  • 空のラムダをデフォルト値として持つ
  • クラスにラムダをまとめて定義
  • コールバックをsealed interfaceに定義し直す

3案とも検討し実際に試してみましたが、最終的にsealed interfaceに定義し直す方式が最もよさそうだと結論づけました。

Actionインターフェース

sealed interfaceとしてインターフェースを定義します。これを、Actionと呼ぶことにします。このActionを継承し、コールバックに対応するdata object/data classを定義します。元のコールバックが引数を取る場合はdata classとして、引数を取らない場合はdata objectとそれぞれ使い分けをします。

コールバックAction
openCamera: () -> Unitdata object OnClickOpenCamera
onChangeGiveItem: (String) -> Unitdata class OnChangeGiveItem
sealed interface ExchangeFormAction {
  data object OnClickSelectDraft : ExchangeFormAction
  data object OnClickExchangeList : ExchangeFormAction
  data class OnChangeExchangeName(val value: String) : ExchangeFormAction
  data object OnClickOpenCamera : ExchangeFormAction
  data class OnChangeGiveItem(val value: String) : ExchangeFormAction
  // 以下略
}

このActionを、Composable関数からFragment、FragmentからViewModelへ伝え、ViewModelでそのActionをハンドリングします。

UI、Fragment、ViewModelのActionの流れ

Composable関数 (UI)

Composable関数ではcallback関数をイベントごとに定義するのではなく、Actionを引数とするラムダ式を引数として1つ定義します。こうすることで関数がコンパクトになって可読性が向上し、またプレビュー関数を修正する手間もなくなります。

@Composable
fun ExchangeFormScreen(
  uiState: ExchangeFormUiState,
  action: (ExchangeFormAction) -> Unit
)
@Preview
@Composable
private fun ExchangeFormScreenPreview() {
  AppTheme {
    ExchangeFormScreen(
      uiState = ExchangeFormUiState.Default,
      action = {},
    )
  }
}

UIからFragmentへ、FragmentからViewModelへ

FragmentではActionのハンドリングは行わず、ViewModelへActionを渡すことに専念します。

// Fragment

override fun onCreateView(...): View {
  return ComposeView(requireContext()).apply {
    setContent {
      setContent {
        val uiState by viewModel.uiState.collectAsState()

        AppTheme {
          ExchangeFormScreen(
            uiState = uiState,
            action = { action ->
              viewModel.dispatch(action)
            }
          )
        }
      }
    }
  }
}

そして、画面遷移などのUIイベント処理は、ViewModelからFlowを通じてイベントを受け取り、ハンドリングします。このイベントをViewEventと呼ぶことにします。

// ViewModel

override fun onViewCreated(view: View) {
  viewModel.viewEvent.collect(viewLifecycleOwner) {
    when (it) {
      is ViewEvent.OpenPictureViewer -> findNavController().tryNavigate(
        id = R.id.picture_viewer,
        args = PictureViewerFragmentArgs(
          pictures = it.pictures.toTypedArray(),
        ).toBundle()
      )

      is ViewEvent.OpenWebView -> findNavController().tryNavigate(
        id = R.id.navigation_web,
        args = WebViewFragmentArgs(web = Arguments.Web(url = it.url)).toBundle()
      )
    }
  }
}

ViewModelでのハンドリング

ViewModelではFragmentからActionを受け取り、whenで分岐して処理します。分岐内で状態更新やAPI呼び出しとともに、画面遷移などのUIイベントに対応するViewEventをFlowを通じてFragmentに送ります。

前述の通りFragmentではViewEventを受信し、画面遷移などの処理を行います。

ViewEventを含めたUI、Fragment、ViewModelのActionの流れ

class ExchangeFormViewModel () : ViewModel() {
  sealed class ViewEvent {
    data class OpenCamera(val pictures: List<SellArguments.Media.Picture>) : ViewEvent()
    data object OpenSelectDraft : ViewEvent()
    // 以下略
  }

  private val _viewEvent = Channel<ViewEvent>()
  val viewEvent = _viewEvent.receiveAsFlow()

  private fun openCamera() =
    viewModelScope.launch { _viewEvent.send(ViewEvent.OpenCamera(_form.value.pictures)) }

  private fun onClickSelectDraft() {
    if (_form.value != emptyFormValue) {
      openDialog(Dialog.SelectDraft)
    } else {
      viewModelScope.launch {
        _viewEvent.send(ViewEvent.OpenSelectDraft)
      }
    }
  }

  fun dispatch(action: ExchangeFormAction) {
    when (action) {
      OpenCamera -> openCamera()
      OnClickSelectDraft -> onClickSelectDraft()
      // 以下略
    }
  }
}

改善前・改善後で比較

Action方式を採用したことでコールバック定義をActionに寄せ、FragmentでのコールバックハンドリングはViewModelとViewEventのハンドリングに寄せました。

抱えていた問題を解決できたか?

この方式を採用することで、チームが抱えていた以下の問題は解決され、細かな修正の手間が省けたと感じています。

  • Composable関数が肥大化していく → 改善後:Actionとしてdata class/data objectに定義することで解消した
  • 実装関数の変更に伴いプレビュー関数の変更が必要 → 改善後:Actionを修正するだけで済むようになった
  • 子ビューの変更とともに親ビューの変更も必要 → 改善後:同様に、Actionを修正するだけで済むようになった

デメリット

改善前はUI -> Fragmentで完結していたイベントもありましたが、改善後は全てViewModelを介するため、UI -> Fragment -> ViewModel -> Fragmentとコードがやや追いにくくなるケースがあります。また、ViewEventで受け取るイベントが多くなるため、ViewModelの変更頻度も上昇します。

Composable関数内でのコールバック呼び出しがやや冗長になる点もありますが、コード補完などの支援機能で対応しています。

副次的効果

Fragmentでハンドリングを行わずViewModelが担うことで、以下の副次的メリットも感じています。

  • Fragmentへのロジックの混入可能性がかなり減少する(例えば、ViewModelの値を見て分岐処理を書くなどが減る)
  • ViewModelでハンドリングを行うことで単体テストを行いやすくなる

この副次的効果もあり、私たちのチームではメリットがデメリットを上回ると判断し、Actionを採用して開発を進めています。

検討した別案:空のラムダをデフォルト値として持つ

Composable関数のコールバックにデフォルト引数として空のラムダを定義し、プレビューの保守性向上を図る案も検討しました。しかし、この案はFragmentでのハンドリング忘れが起こる懸念が大きく、採用を見送りました。

@Composable
fun SampleScreen(
  onClickItem1: () -> Unit = {},
  ...
)
@Preview
@Composable
fun SampleScreenPreview() {
  SampleScreen()
}
override fun onCreateView(...): View {
  return ComposeView(requireContext()).apply {
    setContent {
      // 呼び出し忘れてもビルドが通り、警告も出ない
      SampleScreen()
    }
  }
}

検討した別案:クラスにラムダをまとめて定義

また、クラスにコールバックをまとめることで、Composable関数の見通しの悪さやプレビューの保守性向上を図る案も検討しました。この案は改善当初に採用されましたが、プレビュー用に定義した空コールバックの集合であるEMPTYの修正の手間が依然として課題でした。

data class ExchangeHashtagListeners(
  val onBack: () -> Unit,
  val onClickRelatedHashtag: (Int, String) -> Unit,
  val onClickItem: (Int, ExchangeHashtagItem) -> Unit,
  val onClickSearchMore: () -> Unit,
) {
  companion object {
    val EMPTY = ExchangeHashtagListeners(
      onBack = {},
      onClickRelatedHashtag = { _, _ -> },
      onClickItem = { _, _ -> },
      onClickSearchMore = {},
    )
  }
}

さらに、listenersクラスのコールバック呼び出し時にカッコをつけ忘れる問題もあり、この理由からもAction方式を採用することにしました。

@Composable
fun ExchangeHashtagScreen(
  uiState: ExchangeHashtagUiState,
  listeners: ExchangeHashtagListeners
) {
  val items = uiState.hashtagItems.collectAsLazyPagingItems()

  Scaffold(
    topBar = {
      SparkleTopAppBar(
        title = "#${uiState.hashtag}",
        navigationIcon = Icons.AutoMirrored.Filled.ArrowBack,
        onClickNavigation = {
          // 期待される呼び出し方
          listeners.onClickItem1()

          // ()を忘れでもビルドが通り、warningも出ない
          listeners.onBack()
        }
      )
    }
  ) {
    // ...
  }
}

おわりに

Jetpack Composeへの移行において、私たちのチームはComposable関数の肥大化という課題に直面しました。この問題を解決するために、sealed interfaceを用いたAction方式を採用しました。このアプローチにより、コールバックの定義が簡潔化され、可読性が向上し、プレビュー関数の保守性も改善されました。

Action方式にはいくつかのデメリットもありますが、メリットも多く、チーム全体としての生産性向上に寄与すると考えました。他の検討した案と比較しても、Action方式が最も効果的であると結論づけました。

私たちは今後もこの方式を基に、さらに効率的な開発を進めていきます。この取り組みが、皆様の参考になれば幸いです。

Yahoo!フリマAndroidチームでは、現在中途社員も募集しています。興味のある方はぜひ公式採用ページから詳細をご確認ください。

参考文献(外部サイト)