Androidで簡単にCompose化したいときは既存のコードを活かし段階的に移行しよう

この記事は 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化したときはLazyColumnLazyRowなどを使った方が直感的なので、大事なところは自分で全部書き直すこともあります。
めちゃくちゃ古い実装だと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の変更なだけで実装を大きく変えなくてもできるという例になったかもしれません。
それが、良いアーキテクチャーだと思い、もし、そうできないのあれば、あまり良いアーキテクチャではないので再考をした方がいいんじゃないかなと思います。そして、ただの自己満足なブログになってしまったとも思い終わりにしたいと思います

Page top