こんにちは。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: () -> Unit | data object OnClickOpenCamera |
onChangeGiveItem: (String) -> Unit | data 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
をハンドリングします。
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
を受信し、画面遷移などの処理を行います。
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チームでは、現在中途社員も募集しています。興味のある方はぜひ公式採用ページから詳細をご確認ください。