はじめに
こんにちは、ZOZOTOWN開発2部Androidブロックの大江です。普段はZOZOTOWN Androidの開発を担当しています。
ZOZOTOWN Androidはリリースから10年以上経過し、現在のソースコードは9年近く開発されています。そのため、複数のアーキテクチャが混ざった状態になっていて、開発速度の向上を妨げる要因になっていました。
そこで今ZOZOTOWNにある3つのAndroidブロックから、それぞれテックリードなどの代表者を立て、アーキテクチャを統一するための座談会を週に1度開催しています。その座談会の成果としてまずはViewModel部分の実装方針を決めることができました。
本記事では座談会で決められたViewModelの実装方針と狙いをご紹介します。
目次
ViewModelの実装方針を決めるための要件
ViewModelの詳細な実装方針を検討するにあたって、まずは現状のZOZOTOWN Androidが抱えている課題を整理し、それらを解決するための要件を決定しました。
具体的には以下の要件に基づいてViewModelの実装方針を決めることにしました。
- UIの実装をシンプルにする
- 一般的にUIのテストを実装するコストは大きく、ZOZOTOWN AndroidでもUIのテストは十分に実装できていない
- UIの実装をシンプルにすることでテストが実装でき、それによって実装ミスを減らしたい
- 一般的にUIのテストを実装するコストは大きく、ZOZOTOWN AndroidでもUIのテストは十分に実装できていない
- 機能やレイアウトの改修を簡単に行える
- ZOZOTOWN Androidは大人数かつ複数のチームが同じ機能や画面を改修することがある
- その際のコンフリクトの発生や改修自体のコストを減らしたい
- ZOZOTOWN Androidは大人数かつ複数のチームが同じ機能や画面を改修することがある
- UIのFrameworkに依存しない
- ZOZOTOWN AndroidはAndroid ViewとJetpack Composeが混在している
- Android ViewをJetpack Composeに置き換えていく予定だが、その際にViewModelは変更しなくて済むようにしたい
- ZOZOTOWN AndroidはAndroid ViewとJetpack Composeが混在している
サンプルの説明
今回のViewModelの実装方針を紹介するため、サンプルのお知らせ一覧画面を用意しました。
お知らせをタップしたらお知らせ詳細画面に遷移または外部ブラウザを起動するような仕様を想定します。
ViewModelの実装方針の紹介
ViewModelとUI双方向の処理の流れを明確にして、ViewModelの実装方針を決めました。
- ViewModelからUIに向かう処理はUI StateとEffectとして実装する
- UIからViewModelに向かう処理はEventとして実装する
それぞれについて解説します。
UI StateとEffect
UI StateはUIに表示する内容を1つにまとめた値を表すdata classであり、StateFlowで管理します。
この実装はAndroid Developersのアプリ アーキテクチャ ガイドのUI レイヤのページで紹介されている内容に則っています。
しかし、アプリ アーキテクチャ ガイドで紹介されている画面遷移などのイベントをUI Stateで管理するという部分は採用しませんでした。
ZOZOTOWN Androidでは画面遷移などのイベントはUI Stateとは別にEffectというsealed classを定義してSharedFlowで管理する方針を採用しました。
画面遷移などのイベントはSharedFlowで管理した方がシンプルな実装になるためです。
例えば、次のような場合にStateFlowを用いると値をクリアする必要がありますが、SharedFlowを用いると値をクリアする必要はありません。
- 同じイベントを連続で実行したい場合
- 画面の再生成時にイベントを再発火させたくない場合
お知らせの一覧を表示する画面のAndroid Viewを使ったサンプルコードを載せておきます。
data class InformationListUiState( val informationList: List<Information> ) sealed class InformationListEffect { data object ShowInformationDetail : InformationListEffect() data object OpenExternalBrowser : InformationListEffect() } class InformationListViewModel : ViewModel() { private val _uiState = MutableStateFlow(InformationListUiState(emptyList())) private val _effect = MutableSharedFlow<InformationListEffect>() val uiState = _uiState.asStateFlow() val effect = _effect.asSharedFlow() } class InformationListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { uiState -> // Adapterを更新してRecyclerViewでお知らせの一覧を表示する adapter.update(uiState.informationList) } } } viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.effect.collect { effect -> when (effect) { InformationListEffect.ShowInformationDetail -> { // 詳細画面に遷移する } InformationListEffect.OpenExternalBrowser -> { // 外部ブラウザを起動する } } } } } } }
Event
EventはUIの操作をViewModelに伝えるためのinterfaceです。
ViewModelにはEventを受け取るためのdispatch()
を定義します。
EventにはViewModelの拡張関数としてconsume()
を定義して、dispatch()
の内部ではconsume()
を実行するだけにしました。
class InformationListViewModel : ViewModel() { fun dispatch(event: Event) { event.run { this@InformationListViewModel.consume() } } interface Event { fun InformationListViewModel.consume() } data object Event1 : Event { override fun InformationListViewModel.consume() { // Event1がdispatchされたときに実行したい処理 } } data object Event2 : Event { override fun InformationListViewModel.consume() { // Event2がdispatchされたときに実行したい処理 } } }
このような実装にすることでレイアウトや機能を改修する際には、そのレイアウトや機能に対応するEventのみを変更すればよく、他のEventに干渉することなく安全に作業できます。
次にEventの定義の粒度について説明します。
Eventをどのような粒度で定義するかについて、次の2つの案が挙がりました。
- ViewModelに実行してほしい処理の内容を表す
- UIの操作をそのまま表す
この2つの案について検討した結果、UIの実装をシンプルにするという観点からUIの操作をそのまま表す案を採用することにしました。
ViewModelに実行してほしい処理の内容を表すようにEventを定義する案
class InformationListViewModel : ViewModel() { fun dispatch(event: Event) { event.run { this@InformationListViewModel.consume() } } interface Event { fun InformationListViewModel.consume() } data class ShowInformationDetail(val informationID: InformationID) : Event { override fun InformationListViewModel.consume() { // お知らせ詳細画面に遷移する viewModelScope.launch { _effect.emit(InformationListEffect.ShowInformationDetail) } } } data class OpenBrowser(val url: String) : Event { override fun InformationListViewModel.consume() { // 外部ブラウザを起動する viewModelScope.launch { _effect.emit(InformationListEffect.OpenExternalBrowser) } } } } class InformationListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { adapter.setOnItemClickListener { index -> val information = _uiState.value.informationList[index] if (information.isExternalLink) { viewModel.dispatch(InformationListViewModel.OpenBrowser(information.url)) } else { viewModel.dispatch(InformationListViewModel.ShowInformationDetail(information.informationID)) } } } }
UIの操作をそのまま表すようにEventを定義する案
class InformationListViewModel : ViewModel() { fun dispatch(event: Event) { event.run { this@InformationListViewModel.consume() } } interface Event { fun InformationListViewModel.consume() } data class OnItemClick(val index: Int) : Event { override fun InformationListViewModel.consume() { val information = _uiState.value.informationList[index] if (information.isExternalLink) { // 外部ブラウザを起動する viewModelScope.launch { _effect.emit(InformationListEffect.OpenExternalBrowser) } } else { // お知らせ詳細画面に遷移する viewModelScope.launch { _effect.emit(InformationListEffect.ShowInformationDetail) } } } } } class InformationListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { adapter.setOnItemClickListener { index -> viewModel.dispatch(InformationListViewModel.OnItemClick(index)) } } }
それぞれの案の実装を比較してもらうと後者のUIの操作をそのまま表す案の方がFragmentの実装がシンプルになっていることがわかると思います。
アプリ アーキテクチャ ガイドではViewModelのfunの名前が前者の案であるViewModelに実行してほしい処理の内容を表しています。しかしZOZOTOWN AndroidではUIの実装をシンプルにするため、後者のUIの操作をそのまま表す案を採用しました。
Android ViewからJetpack Composeへの移行
Android ViewからJetpack Composeへ移行する場合は以下のようにUI部分を変更するだけです。
以下の理由でViewModelの実装を変更する必要はありません。
- UI State、Effect、EventがAndroid ViewやJetpack Composeに関連するクラスを使用していない
- UI State、Effect、EventがAndroid ViewやJetpack Composeを連想させる命名になっていない
@Composable fun InformationListScreen( viewModel: InformationListViewModel, ) { val uiState by viewModel.uiState.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(viewModel, lifecycleOwner) { viewModel.effect .flowWithLifecycle(lifecycleOwner.lifecycle) .onEach { effect -> when (effect) { InformationListEffect.ShowInformationDetail -> { // 詳細画面に遷移する } InformationListEffect.OpenExternalBrowser -> { // 外部ブラウザを起動する } } } .launchIn(this) } InformationListContent( uiState = uiState, dispatch = viewModel::dispatch, ) } @Composable fun InformationListContent( uiState: InformationListUiState, dispatch: (InformationListViewModel.Event) -> Unit, ) { // LazyColumnでお知らせの一覧を表示する LazyColumn { itemsIndexed(uiState.informationList) { index, information -> // お知らせの項目を表示する InformationItem( information = information, onClick = { dispatch(InformationListViewModel.OnItemClick(index)) } ) } } }
まとめ
本記事ではZOZOTOWN Androidで採用することになったViewModelの実装方針を紹介しました。このViewModelの実装方針をチーム全体に浸透させることで開発速度の向上を期待しています。AndroidでViewModelの実装方針を検討している方がいれば、ぜひ参考にしてみてください。今後はUseCaseやRepositoryについても実装方針を決めていきたいと考えています。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。