こんにちは。Yahoo!フリマのAndroid開発を担当している寳田(たからだ)です。
私たちのチームでは、Android開発では一般的なViewModel + Fragmentの構成を採用しています。従来はXMLレイアウトを用いてUIを構築していましたが、新規に作成する画面にはJetpack Composeを採用し、既存のXMLレイアウトもJetpack Composeへ移行することで生産性の向上を図っています。
Jetpack Composeへの移行を進める中で、課題の一つにComposable関数およびプレビュー関数の肥大化がありました。これを解消するためにどのようなアプローチを採用したか、検討した案にも触れつつ実際のコードを基に説明したいと思います。
Composable関数でのよくあるコールバック定義
Composable関数とは @Composable
アノテーションが付与された関数であり、Jetpack Compose UIを宣言的に構築するための基本となる要素です。Composable関数でクリック処理などのUIイベントを扱う際、関数の引数にコールバックを定義するのが一般的です。
ViewModel + Fragment構成を採用している場合、Fragmentでコールバックを受け取り、ViewModelの関数を呼び出すことがシンプルな実装だと考えられます。
肥大化するComposable関数
昨年リリースされたグッズ交換機能の募集フォーム画面ではViewModel + Fragment構成を採用しつつ、UIはJetpack Composeで実装しました。募集フォームでは、以下のような機能が存在します。
- 下書き一覧:タップで下書き一覧画面に遷移
- カメラボタン:タップでカメラ撮影画面に遷移
- テキスト入力:ViewModelに入力データを保持、文字数超過などバリデーションチェックも行う
- ハッシュタグ:「×」ボタンをタップで削除
- その他いろいろ
募集フォームのような、ユーザーからの入力を受ける要素が多い画面ではイベントを扱うためのコールバックが増え、Composable関数が肥大化し可読性の低下を招いていました。
また、プレビュー関数も同様に肥大化するため、変更があるたびに空のコールバックの記述や削除が必要となり、手間が増えていました。
そして、Pagerなど子ビューを持つ画面では、子ビューにコールバックを追加・削除するたびに親ビューも変更しなければならず、手間がかかっていました。
このように、全体的に変更に対して細かな手間がかかり、課題を感じていました。
改善案: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 |
このAction
を、Composable関数からFragment、FragmentからViewModelへ伝え、ViewModelでそのAction
をハンドリングします。
Composable関数 (UI)
Composable関数ではcallback関数をイベントごとに定義するのではなく、Actionを引数とするラムダ式を引数として1つ定義します。こうすることで関数がコンパクトになって可読性が向上し、またプレビュー関数を修正する手間もなくなります。
UIからFragmentへ、FragmentからViewModelへ
FragmentではActionのハンドリングは行わず、ViewModelへActionを渡すことに専念します。
そして、画面遷移などのUIイベント処理は、ViewModelからFlowを通じてイベントを受け取り、ハンドリングします。このイベントをViewEvent
と呼ぶことにします。
ViewModelでのハンドリング
ViewModelではFragmentからAction
を受け取り、when
で分岐して処理します。分岐内で状態更新やAPI呼び出しとともに、画面遷移などのUIイベントに対応するViewEvent
をFlowを通じてFragmentに送ります。
前述の通りFragmentではViewEvent
を受信し、画面遷移などの処理を行います。
改善前・改善後で比較
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関数の見通しの悪さやプレビューの保守性向上を図る案も検討しました。この案は改善当初に採用されましたが、プレビュー用に定義した空コールバックの集合であるEMPTY
の修正の手間が依然として課題でした。
さらに、listenersクラスのコールバック呼び出し時にカッコをつけ忘れる問題もあり、この理由からもAction方式を採用することにしました。
おわりに
Jetpack Composeへの移行において、私たちのチームはComposable関数の肥大化という課題に直面しました。この問題を解決するために、sealed interfaceを用いたAction
方式を採用しました。このアプローチにより、コールバックの定義が簡潔化され、可読性が向上し、プレビュー関数の保守性も改善されました。
Action
方式にはいくつかのデメリットもありますが、メリットも多く、チーム全体としての生産性向上に寄与すると考えました。他の検討した案と比較しても、Action方式が最も効果的であると結論づけました。
私たちは今後もこの方式を基に、さらに効率的な開発を進めていきます。この取り組みが、皆様の参考になれば幸いです。
Yahoo!フリマAndroidチームでは、現在中途社員も募集しています。興味のある方はぜひ公式採用ページから詳細をご確認ください。