この記事は NewsPicks Advent Calendar 2024 の18日目の記事です。
ソーシャル経済メディア「NewsPicks」(VP of Mobile Engineering) の石井です。
今回は、10年もののNewsPick AndroidアプリでのCompose化をビジネスを止めずにどう進めているかを書いていきます。
タイトルは当たり前な気がしますが、他社の話を聞くと意外と既存コードを活かしてないこともありそうだったので参考になるかもしれないと思い書くことにしました。
既存のアーキテクチャ
Android Viewで構築されているアーキテクチャは基本的には、ざっくりと書くと次の通りです。レイヤー間はHiltでDIされ、Flowを使っています。よくある構成かと思います。
弊社のAndroid View部分のアーキテクチャで特徴的なのは、主に以下の通りです。
- 汎用的なRecyclerView.Adapterの独自クラスがあり、ViewHolderなどを全く意識せずに実装できるようになっている
- たとえば、一覧の中に複数のデザインがある場合、そのデザイン1つにViewModelが対応しており、その1つにxmlレイアウトが対応している
- DataBindingをフル活用しており、ほとんどの処理がViewModelに書かれている
- トランザクション系の機能(一覧取得APIを呼ぶとかそんなこと)ではもちろん使ってない
- 利用用途は、画面表示用に整形したデータに変換したり、Viewで発生したイベントがViewに依存しない形でViewModelに処理が流れてくる
まあま洗練されたアーキテクチャだったので、Compose化することでの生産性面での大きなメリットは他社ほど多くはないように感じていますが、
- 開発者のモチベーション (枯れた技術使い続けても...)
- ビルドスピードの改善(完全KSP化したいのにDataBinding邪魔...)
- そのうち色々なものが非推奨になるだろうなという予感
- xmlでもプレビューできるけど、ComposeのPreviewの方が事細かくできるよね
というためにCompose化を進めています。
進め方
Databindingなどで共通化されていたものはComposeで利用できる専用のものが最初に作られている前提で書きます。
詳細は割愛しますが、この時のポイントは既存Android Viewで利用していたクラスとCompose用に作成したクラスはほとんど同じインターフェイスにすることです。できればパッケージだけを変えるぐらいで対応できるようにしておくとViewModelをほとんど変更せずにCompose化できます。
リストのアイテムから変えていく
まずは、一覧のアイテムを変換します。赤い枠線部分は見ての通りデザインが違います。これの1つをCompose化してリリースし、それをデザインアイテムごとにリリースすることを繰り返します。
それができる仕組みになっていれば、気軽にCompose化が進められるでしょう。
GitHub Copilotでサクッとコンポーザブル関数へ変換
xml -> Composeへの変換は、GitHub Copilotを使ってます。サクッと10秒で終わります。
想定通りにいかないこともよくあるのでその場合は直します。
特に、ConstraintLayout
を使っているとCompose化したときはLazyColumn
やLazyRow
などを使った方が直感的なので、大事なところは自分で全部書き直すこともあります。
めちゃくちゃ古い実装だとConstraintLayoutが使われてないのでCopilotだけでできるので楽でした。
ViewModelをUiStateに変換する(ここが一番の肝)
ViewModelはそのままコンポーザブル関数には使えないのはご存知ですよね。Previewが表示されないとか色々問題が出てきます。
そこで、ViewModelのロジックはそのまま変更せずにUiStateに変換できたら簡単にCompose化することが可能です。
ViewModelにある処理を説明すると、主に以下の通りです。
1. Viewから呼び出していたViewModelのアクション(onClickの処理など).
2. Viewで発生したリスナーなどのイベント(onScrollなど).
3. 1や2など何かしらのイベントによってのAPI呼び出し.
これらは、アイテムレベルでCompose化するにあたり、ほぼ変更が不要です。
画面全体をCompose化しない限りViewModelに大きな変更は必要ありません。
(弊社の場合は画面全体をCompose化したとしてもViewModelの変更はほとんど不要ではありました)
たとえば、onClickの処理であれば、以下のような汎用的なクラスを作成しておきます。
@Stable class StableOnClickItemInvoker( private val invoker: () -> Unit ) { fun invoke() { invoker() } }
で、それをUiStateが保持します。onClickItemInvoker
の部分がそれです。
@Stable class SearchResultNewsItemUiState( val title: String, val image: String, private val onClickItemInvoker: StableOnClickItemInvoker, ) : SearchResultCollectionItemUiState { fun onClick() { onClickItemInvoker.invoke() } }
で、コンポーザブル関数でUiStateに入れます。Previewは引数UiStateの方を使えばいいでしょう。これはComposeでよくやる方法だと思います。
(アイテムの場合はこうやりますが、画面全体をやる場合はViewModelへの変換は別の箇所でやります。それは後で説明します)
fun SearchResultTitleItem(viewModel: SearchResultNewsItemViewModel) SearchResultNewsItemUiState( title = viewModel.title, image = viewModel.image, onClickItemInvoker = StableOnClickItemInvoker { viewModel.onClick() } ) ・・・ fun SearchResultTitleItem(uiState: SearchResultNewsItemUiState) { ・・・ }
で、コンポーザブル関数にUiStateを渡して、onClickメソッドを呼び出します。
@Composable fun SearchResultNewsItem(uiState: SearchResultNewsItemUiState) { Row( modifier = Modifier .fillMaxWidth() .clickable { uiState.onClick(false) }, ・・・
では、2のonScrollなどのViewModleに流れてきた処理はどうでしょうか。
基本的には、これも上記のStableOnClickItemInvoker
と同じような仕組みを使えます。
たとえば、カルーセルでスクロールでページングをする場合は、独自のPagingLazyScroll
を作っておき、ViewModelで呼びたい処理をラムダで渡せるようにしときます。
PagingLazyScroll
はこんな感じです。
@Composable fun PagingLazyRow( pageLoadPosition: (() -> Unit)? = null, ・・・ LaunchedEffect(listState) { snapshotFlow { val totalCount = listState.layoutInfo.totalItemsCount if (totalCount > 0) { val visibleCount = listState.layoutInfo.visibleItemsInfo.size val firstPosition = listState.layoutInfo.visibleItemsInfo.first().index totalCount <= 10 + firstPosition || totalCount == visibleCount + firstPosition } else { showLoadingIndicator = false false } }.distinctUntilChanged() .filter { it }.collect { showLoadingIndicator = true pageLoadPosition?.invoke() // ページングすべき箇所に来たら呼ばれる } }
StableOnClickItemInvokerと同じようにページング処理ラムダ用のクラスを用意して、ViewModelにあるページングをするときの処理を呼び出します。
@Stable class StableReachedLoadPageInvoker( private val invoker: () -> Unit ) { operator fun invoke() { invoker() } }
割愛しますが、ViewModelには独自のページング用のクラスが呼び出されており、StableReachedLoadPageInvokerのinvoke()が呼ばれると次のページのAPIが呼ばれます。
RecyclerViewへの接続
そして、弊社の場合は、RecyclerViewへアイテムの1つをCompose化したい場合は以下のようにFragmentでRecyclerViewに設定していた箇所を変えるだけです。
is SearchResultTitleItemViewModel -> R.layout.list_item_search_result_section_title
is SearchResultTitleItemViewModel -> { { SearchResultTitleItem(modelCollectionItemViewModel) } // <- コンポーザブル関数 }
見ての通り、ほとんどViewModelは変更せずにCompose化されます。
アイテム内の動作によって変更されるアイテムの場合
表示するだけだったら、上記の方法だけで良いです。が、以下のボタンのように押下してデザインが変わる場合は気を付ける必要があります。
この場合は、アイテムのViewModelに以下のようにコンポーザブル関数で監視できるように処理を追加し、それをアイテムのコンポーザブル関数で見る必要があります。
Compose化がアイテムからFragment全体にする時にこの処理は、大元のViewModelに持っていくことになるのでそこは注意が必要です。
private val _followedCount = MutableStateFlow(entity.followedCount.toInt()) private val _isFollowing = MutableStateFlow(entity.followed) val followFlow: StateFlow<Pair<Int, Boolean>> = combine(_followedCount, _isFollowing) { followedCount, isFollowing -> followedCount to isFollowing }.stateIn( scope, SharingStarted.WhileSubscribed(5000), _followedCount.value to _isFollowing.value )
@Composable fun SearchResultNewsFollowItem(viewModel: SearchResultNewsFollowItemViewModel) { val follow = viewModel.followFlow.collectAsStateWithLifecycle() SearchResultNewsFollowItem( SearchResultNewsFollowItemUiState( keyword = viewModel.keyword, followedCount = follow.value.first, isFollowing = follow.value.second, onClickItemInvoker = StableOnClickItemInvoker(viewModel::onClickFollowButton), ) ) }
Fragment全体をCompose化していく
上記のリストのアイテムがコンポーズされていれば、ここも簡単です。
Android ViewでRecyclerViewを使っていたレイアウトxmlをCompose化します。アイテム部分とは違って元々のxmlはRecyclerViewがメインだと思います。そこを独自のPagingLazyColumn
を使うようします。これは、上記で説明したPagingLazyRow
のColumn版です。
こんな感じです。Android View時代は、Fragmentで違うデザインアイテムごとのif文を書いてましたが、これがコンポーザブル関数に移動する感じです。
PagingLazyColumn( modifier = Modifier.fillMaxWidth(), scrollPosition = scrollPosition, pageLoadPosition = uiState::reachedPageLoadPosition, ) { items(count = uiState.items.count()) { index -> val item = uiState.items[index] // アイテムのデザイン数だけif文が続く if (item is SearchResultTitleItemUiState) { SearchResultTitleItem(item) } else if (item is SearchResultFooterItemUiState) { SearchResultFooterItem(item) } else if ・・・ ・・・
ViewModelからUiStateへの入れ替えは、コンポーザブル関数でしてましたが、それをViewModelのそれぞれのアイテムで入れ替えるようにします。
以下のようになりますが、元々は、ViewModelのリストを返してたけど、それをUiStateに必要なものだけに入れ替えます。VIewModelを残してるのは、ViewModelのロジックをそのまま使うためです。ViewModelにはRepositoryや大量のStableじゃない状態を保持しているので、当然コンポーザブル関数に直接渡しません。
override fun create(): List<CollectionItemUiState> { return dto.contents.mapIndexed { index, item -> val viewModel = SearchResultNewsItemViewModel( repository = repository, analytics = analytics, isPaidMember = item.isPaidMember, title = item.title, image = item.image, ) SearchResultNewsItemUiState( isPaidMember = viewModel.isPaidMember, title = viewModel.title, image = viewModel.image, onClickItemInvoker = StableOnClickItemInvoker { viewModel.onClick() } ) } }
大元のViewModelでは、こんな感じでFlowを公開しときます。fetchSearchTop()メソッドの中ではAPI呼び出しをして、結果をUiStateに入れ替えてます。ここでも元々あったViewModelのメソッドをStableOnClickItemInvoker
などでラムダで持たせておきます。
val uiState: StateFlow<SearchResultNewsUiState?> = combine( keywordFlow.filter { it.isNotBlank() }, criteriaFlow, nextCursor ) { keyword, criteria, cursor -> fetchSearchTop(keyword, criteria, cursor).firstOrNull() }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = null )
そして、Fragmentの代わりになったコンポーザブル関数では、大元の処理を入れ替えたUiStateをcollectAsStateWithLifecycle()
で監視するようにします。
ここまでくるとよく見るコンポーズのソースに近づいていると思います。
@Composable fun SearchResultNewsScreen( viewModel: SearchResultNewsViewModel ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle()
で、FragmentでComposeViewを作って終わりです。
override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { initObserver() return ComposeView(requireContext()).apply { setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) setContent { ComposeTheme { SearchResultNewsScreen(resultNewsViewModel) } } } }
次にやること
このあとは、たとえば、
* VIewPager2でできた上タブをHorizontalPagerに変える.
* Activityなどを削除していき、NavHostControllerに移行していく.
* Slack CircuitのようにViewModelを意識させないアーキテクチャにしていく.
などが考えられますが、まずは少しづつ移行していくのがビジネスを考えるとよいと思います。
終わりに
実は、書いていくうちに前提条件や割愛が多過ぎるので、これはNewsPicksのAndroidエンジニアしか役に立たないのではないかと思い始めてましたw.
ただ、元のコードがしっかりしていれば、Compose化はただのViewの変更なだけで実装を大きく変えなくてもできるという例になったかもしれません。
それが、良いアーキテクチャーだと思い、もし、そうできないのあれば、あまり良いアーキテクチャではないので再考をした方がいいんじゃないかなと思います。そして、ただの自己満足なブログになってしまったとも思い終わりにしたいと思います