こんにちは。ZOZOTOWN本部 ZOZOアプリ部 Androidチームの高橋です。ZOZOTOWN Androidチームでは、Jetpack Composeを導入しました。
この取り組みは、つい先日、Android Meetup【ZOZOテクノロジーズ × サイバーエージェント × GMOペパボ】でもご紹介しています。
この記事は、上の資料を補完するものです。資料の内容に加えて、登壇ではお話できなかった技術的な補足をいたします。
Jetpack Composeとは
Jetpack ComposeはGoogleからリリースされているUI実装のツールキットです。Jetpack ComposeではComposableアノテーションを付与した関数(以下Composableと呼ぶ)をKotlinのコード上に記述してUIを定義します。
Jetpack Composeの特徴は宣言的UIフレームワークであることです。宣言的UIとは、状態をUIに変換するという考え方です。これまで、UIの更新はViewに定義されているメソッドを明示的に呼び出して行うのが一般的な方法でしたが、Jetpack Composeでは、再コンポーズの仕組みによって自動的にUIが更新されます。
背景
ZOZOTOWN Androidには、商品の検索結果を表示する「検索画面」と、商品の詳細を表示する「商品画面」が存在します。
これらの画面は高頻度で機能追加や改修が行われており、UIの表示制御や状態管理が複雑化していました。また、UI実装の複雑化に伴って、UIの更新実装漏れなどによる不具合が度々発生していました。さらに、商品画面では巨大なレイアウトによるパフォーマンスの低下も問題となっていました。
ZOZOTOWN Androidチームでは、Jetpack Composeを導入することで複雑なUI実装が簡素化され、UIに関する不具合を抑えることができると考えました。また、パフォーマンスに関してもJetpack ComposeのLazyColumnやLazyRowなどを使用することで改善できると考えました。
以上の理由からZOZOTOWN AndroidチームではJetpack Composeの導入に取り組みました。
Jetpack Compose導入時の課題
検索画面や商品画面などの主要画面へのJetpack Composeの導入の前段階として、技術検証を実施しました。
技術検証は、プロダクトへの影響が少なく、以前から改修が検討されていたデバッグメニューのUI実装をJetpack Composeに置き換える形で行いました。
検証の結果、2つの課題が明らかになりました。
- ZOZOTOWN Androidで採用されているUIの状態管理の方法がJetpack Composeに適していない
- 無秩序なComposable作成によるComposableの可読性・再利用性の低下
課題1. ZOZOTOWN Androidで採用されているUIの状態管理の方法がJetpack Composeに適していない
既存のZOZOTOWN Androidで採用されているUIの状態管理の方法では、Jetpack Composeを導入することが困難でした。これについて、既存のZOZOTOWN AndroidのUI更新フローと問題になった箇所を説明します。
Jetpack ComposeでのUIの更新
Jetpack ComposeではUI要素を階層的に表現します。
引用:https://developer.android.com/jetpack/compose/mental-model
各Composableに表示するデータは、最上位のComposableから伝搬されます。UIに表示するデータを受け取ったComposableは、再コンポーズの仕組みによってUIを自動で更新します。
ZOZOTOWN AndroidのUI更新の流れ
以下は既存のZOZOTOWN AndroidでのUI更新フローです。
それぞれのステップについて説明します。
1. Eventの発行
ZOZOTOWN Androidでは、ユーザーインタラクションなどによって発生するEventをViewEventというsealed classで定義しています。
sealed class ViewEvent { object ClickItem : ViewEvent() object ShopClick : ViewEvent() }
ユーザーインタラクションが発生すると、FragmentからViewEventを発行します。
2. アプリケーションの状態更新
ViewModelはViewEventを受け取ると、API通信などの処理を行い、アプリケーションの状態を更新します。
ZOZOTOWN Androidでは、UIの状態をViewDataとViewStateという2つのクラスによって定義しています。アプリケーションの状態はこれらのクラスにマッピングされ、UIに通知されます。
data class ItemViewData( val name: String, val price: String, )
ViewDataはカスタムビュー毎に作成されるdata classで、UIに表示するデータを保持します。
sealed class ViewState { object Initial : ViewState() data class Initialized( val itemViewDataList: List<ItemViewData>, val shopViewData: ShopViewData, ) : ViewState() data class ItemSelected( val itemViewData: ItemViewData, ) : ViewState() }
ViewStateは画面単位で作成されるsealed classです。subclassはViewDataを保持します。
API通信やユーザーインタラクションなどによってアプリケーションの状態が変化すると、ViewModelはViewStateを作成します。
3. 更新差分の通知
ViewStateはFragmentに対して以下のように通知されます。
class ItemViewModel : ViewModel() { private val _viewState = MutableStateFlow<ViewState>(ViewState.Initial) val viewState: StateFlow<ViewState> = _viewState ... fun onSelectItem(id: Int) { ... _viewState.value = ViewState.ItemSelected(itemViewData) } ... }
ViewModelは作成したViewStateをFlow/LiveDataによってFragmentに通知します。 通知されるViewStateは、それぞれの状態で更新のあったViewDataのみを保持しています。
4. UIの手動更新
FragmentはViewStateを受け取ると、更新が必要なViewを明示的に更新します。
Jetpack Composeを導入し、再コンポーズによるUIの差分更新を利用するためには、最上位のComposableが常に画面全体の表示データを受け取る必要があります。しかし、既存のViewModelでは更新差分のある表示データのみをUIに通知するため、そのままではJetpack Composeが導入出来ませんでした。
課題2. 無秩序なComposable作成によるComposableの可読性・再利用性の低下
巨大なComposableは、UI実装の可読性を低下させ開発効率の悪化を引き起こします。また、1つのComposableに多くのUI要素が定義されることでComposableの再利用性が低下します。
今後、チームでJetpack Composeを使用してUIを実装するためには、このような問題が発生することを防ぐ仕組みが必要でした。
課題の解決
UIの状態管理の方法の見直し
ZOZOTOWN AndroidへJetpack Composeを導入するため、UIの状態管理の方法を見直し、Jetpack Composeを使用したリファレンス実装を作成しました。 見直しはchrisbanes/tiviを参考に行いました。
見直し後のUI更新の流れ
以下は見直し後のUI更新フローです。
それぞれのステップについて説明します。
1. Eventの発行
EventはComposableから以下のように発行されます。
@Composable fun ItemScreen(viewModel: SampleViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.viewState, lifecycleOwner) { viewModel.viewState.flowWithLifecycle( lifecycleOwner.lifecycle, Lifecycle.State.STARTED, ) }.collectAsState(ViewState.Empty) // ViewEventを発行するlambdaを下層のComposableに伝搬する ItemScreen( viewState = viewState, onItemClick = { viewModel.dispatchViewEvent(ViewEvent.ItemClick) }, onShopClick = { viewModel.dispatchViewEvent(ViewEvent.ShopClick) } ) } @Composable fun ItemScreen( viewState: ViewState, onItemClick: () -> Unit, onShopClick: () -> Unit, ) { Column { Item(viewState.itemViewData, onItemClick) Shop(viewState.shopViewData, onShopClick) } } @Composable fun Item(viewData: ItemViewData, onClick: () -> Unit) { // 伝搬されたlambdaをComposableのクリックリスナーから実行する Column(modifier = Modifier.clickable { onClick.invoke() }) { Text(viewData.name) Text(viewData.price) } } @Composable fun Shop(viewData: ShopViewData, onClick: () -> Unit) { Text( modifier = Modifier.clickable { onClick.invoke() }, text = viewData.name, ) }
ユーザーインタラクションなどによってEventが発生すると、その内容がlambdaを介して上位のComposableへと伝搬されます。
modifier = Modifier.clickable { onClick.invoke() }
最上位のComposableはViewEventをViewModelに発行します。ViewEventへの参照は最上位のComposableのみが持つため、下位のComposableはViewEventを意識することがなく、高い再利用性を維持することが可能になります。
2. アプリケーションの状態更新
アプリケーションの状態は以下のように更新されます。
class SampleViewModel : ViewModel() { private val itemState = MutableStateFlow(Item.Empty) private val shopState = MutableStateFlow(Shop.Empty) private val viewEvent = MutableSharedFlow<ViewEvent>() ... init { viewModelScope.launch { viewEvent.collect { when(it) { is ViewEvent.ItemClick -> { updateItem() } is ViewEvent.ShopClick -> { updateShop() } } } } } fun updateItem() { ... itemState.value = newItem } fun updateShop() { ... shopState.value = newShop } }
アプリケーションの様々な状態はFlowで管理します。 ViewModelはViewEventを受け取ると、API通信などの処理を行い、Flowの値を更新します。
3. 画面全体の情報通知
ViewStateはComposableに対して以下のように通知されます。
data class ViewState( val itemViewDataList: ItemViewData, val shopViewData: ShopViewData, ) { companion object { val Empty = ViewState( itemViewDataList = ItemViewData.Empty, shopViewData = ShopViewData.Empty, ) } }
画面全体の表示データをUIに通知するため、ViewDataは1つのViewStateに集約されます。
ViewStateはViewModelで以下のように作成されます。
class ItemViewModel : ViewModel() { private val itemState = MutableStateFlow(Item.Empty) private val shopState = MutableStateFlow(Shop.Empty) val viewState = combine(itemState, shopState) { item, shop -> ViewState( item.mapToViewData(), shop.mapToViewData(), ) } }
ViewModelではアプリケーションの状態更新によってFlowに値が流れると、combineメソッドでViewStateを作成し、UIに通知します。
4. UIの自動更新
ComposableでのUI更新は以下のように行われます。
@Composable fun ItemScreen(viewModel: ItemViewModel) { // ViewModelからViewStateを受け取る val lifecycleOwner = LocalLifecycleOwner.current val viewState by remember(viewModel.viewState, lifecycleOwner) { viewModel.viewState.flowWithLifecycle( lifecycleOwner.lifecycle, Lifecycle.State.STARTED, ) }.collectAsState(ViewState.Empty) ItemScreen(viewState) } @Composable fun ItemScreen(viewState: ViewState) { // 下層のComposableにUIの表示データを分配する Column { Item(viewState.itemViewData) Shop(viewState.shopViewData) } } @Composable fun Item(viewData: ItemViewData) { Column { Text(viewData.name) Text(viewData.price) } } @Composable fun Shop(viewData: ShopViewData) { Text(viewData.name) }
ViewStateは最上位のComposableで受け取ります。
ComposableでのViewStateの受け取りには、lifecycle-runtime-ktx:2.4.0-alpha01で追加されたflowWithLifecycleを使用しました。flowWithLifecycleを使用することで、アプリケーションがバックグラウンドにある状態でのFlowの収集を停止できます。
ViewDataが1つのViewStateに集約されたことで、最上位のComposableは常に画面全体の表示データを受け取ることが可能になりました。最上位のComposableはViewStateを受け取ると、下位のComposableにViewDataを分配します。UIの更新は、再コンポーズの仕組みによって自動で行われます。
Composable設計ルールの制定
チームでのJetpack Composeを使用した開発に向けて、無秩序なComposable作成を防ぐためのルールを制定しました。
Atomic Design
Atomic Designは、UIの要素(コンポーネント)を6種類に分類して定義するデザイン手法です。
Atomic Designでは、コンポーネントを階層化して管理します。
上層のコンポーネントは下層のコンポーネントに依存できますが、下層のコンポーネントは上層のコンポーネントに依存できません。そうすることで、下層のコンポーネントに変更を加える際の影響から、上層のコンポーネントを守ることが可能になります。
UIの実装にAtomic Designを適用することで、UI要素を適切に分割することが可能になり、チームでの開発効率の向上が期待できます。また、Atomic DesignはReactなどのWebフロントの宣言的UIフレームワークと共に採用された実績も多くあります。以上の理由から、ZOZOTOWN Androidチームでは、Atomic DesignをベースとしたComposable設計ルールを制定しました。
以下に各層のコンポーネントの定義と検索画面への適用例を示します。
Atoms
Atomsは機能的に分割できる最小単位のコンポーネントです。ZOZOTOWN Androidでは、Jetpack Composeによって提供されているTextやButton、RowなどのComposableをAtomsとして定義しました。また、独自に作成したComposableの内、吹き出しやアイコン等のそれ以上分割できないものについてもAtomsとして分類しました。
Molecules
MoleculesはAtomsを組み合わせて作成するコンポーネントで、Atomsが持つ機能に意味や意図を与えます。ZOZOTOWN Androidでは、TextやRowなどを組み合わせて作成したComposableをMoleculesとして定義しました。
Organisms
OrganismsはMoleculesや他のOrganismsと組み合わせて作成するコンポーネントです。Organismsは単体で明確な役割を持ちます。ZOZOTOWN Androidでは、AtomsやMolecules、他のOrganismsを組み合わせて作成したComposableをOrganismsとして定義しました。
Templates
Templatesはページのレイアウトを定義するコンポーネントです。ZOZOTOWN Androidでは、画面全体のコンポーネントを保持し、各UI要素の表示に必要なViewDataを分配するComposableをTemplatesとして定義しました。
Pages
Pagesは実際のデータをUIに反映するコンポーネントです。ZOZOTOWN Androidでは、ViewModelへの参照を持ち、UI上に表示するデータをTemplatesに渡す役割を持ったComposableをPagesと定義しました。また、PagesはViewModelに対してViewEventを発行する役割も担っています。
今後の課題
以上の取り組みによって、ZOZOTOWN AndroidでのJetpack Composeを使用した開発の方針を決めることができました。
しかし、今後さらに大規模な画面でJetpack Composeを使用するためには、加えて解決すべき課題があります。
アプリケーションの状態管理
新たな設計では、アプリケーションの状態がFlowで定義され、それを各画面のViewModelで管理しています。
しかし、検索画面などの主要画面では多くの状態が相互に影響するため、それら全てをViewModelで管理するとそれが肥大化します。この問題を解決するためには、ViewModel以下のレイヤーで適切にアプリケーションの状態を管理する仕組みが必要です。
今後は、主要画面へのJetpack Composeの導入に向けて、よりZOZOTOWN Androidに適したアプリケーションの状態管理の方法を検討する予定です。
ViewModelの扱い
新たな設計では、ViewStateの管理やEventの処理のためにAndroid Architecture Component(AAC)のViewModelを使用しています。しかし、AACのViewModelはComposableのライフサイクルと対応していません。プロダクトにJetpack Composeを本格的に導入し、FragmentからComposableへの置き換えを行うためにはこの問題を解決する必要があります1。
今後は、この問題の解決策についてAACのViewModelの使用廃止も視野に検討する予定です。
Composable設計ルールの活用
新たに検討したComposable設計ルールは、まだチームでの運用には至っていません。今後はチームでの運用を通して、よりZOZOTOWN Androidチームに適した形へとブラッシュアップする予定です。
また、Atomic DesignについてはComposable設計ルールだけでなく、デザイナー・エンジニア間の共通言語としての活用も検討する予定です。
まとめ
本記事では、ZOZOTOWN AndroidへのJetpack Compose導入時の課題と、その解決策についてご紹介しました。今後は、新たに検討した設計やルールを元に、より大規模な画面でJetpack Composeを使用した開発をしたいと考えています。
最後に
ZOZOテクノロジーズではAndroidエンジニアを募集しています。ご興味のある方はこちらからご応募ください。