Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

ホーム画面を既存のFragmentやViewを再利用しながらJetpack Composeに移行する

この記事はRetty AdventCalendarの2日目の記事です。
https://adventar.org/calendars/7716

はじめに

アプリチームの@matsudamperです。
普段は書きたい時に書くので時期に合わせて記事を温めたりしないのですが、ちょうどAndroidアプリのホーム画面のリニュアルを11月に行った為、AdventCalendarとして記事を書きました。

リニュアルにあたって基本的にJetpack Composeを使用し、使い回せる部分は既存実装のViewやFragmentを使い回し、少ないコストで実装を行いました。この記事ではどの程度流用できるのか、実装中にハマった事などを共有します。

アプリの構成

まずは、旧ホーム画面と新ホーム画面の大まかな違いを見ていきます。

旧画面はViewPager2が使用されており、ほぼ全てがViewで作成されています。
新画面では、タブが上から下に変更されており、「さがす」画面が新しくできています。
「さがす」画面については新規画面なので、全てComposeで実装されています。新着投稿は投稿やニュースのリスト部分が同一です。マイリストは全てが同じになっています。

実装

基本実装

タブ部分はComposeのNavHostとBottomNavigationで実装し、各画面の中身はComposeやFragmentで実装します。

LazyListでのViewの使い回し

リストのアイテムのUIは変わらないのでリストのアイテムだけ流用し、他はCompose新規実装しました。

ComposeのUIは別のモジュールに実装してあるので、AndroidViewを引数から貰います。
AndroidViewをListで使う点でパフォーマンスについて懸念がありましたが、特に問題はなく既存のRecyclerViewの実装と較べても差は感じられませんでした。

private enum class ItemType {
    UserPost,
    News,
    ;
}

public sealed interface Item : Serializable {
    public class UserPost(
        // TODO
    ) : Item
    public class News(
        // TODO
    ) : Item
}

@Composable
public fun NewPostScreen(
    modifier: Modifier,
    items: List<Item>,
    userPostContent: @Composable (Item.UserPost) -> Unit,
    newsContent: @Composable (Item.News) -> Unit,
) {
    LazyColumn(modifier = modifier) {
        items(
            items,
            contentType = {
                when (it) {
                    is Item.News -> ItemType.UserPost
                    is Item.UserPost -> ItemType.News
                }
            }
        ) { item ->
            when(item) {
                is Item.UserPost -> { userPostContent(item) }
                is Item.News -> { newsContent(item) }
            }
        }
    }
}

TransactionTooLargeException

しかしこの実装には別途問題があります。画面遷移を行う際に自動でUIの状態が保存されますが、その容量が大きくクラッシュします。久しぶりに見ました。TransactionTooLargeException。
AndroidViewを使わない場合は問題が無かった為、AndroidViewの容量はそこそこに大きいようです。
ただ問題はライブラリの方にあり、LazyListのアイテムの状態を全て保存していた為です。こちらはCompose Foundation 1.3で修正されている為、そちらを使用すれば問題ありません。
https://issuetracker.google.com/issues/242589959

Fragmentの使い回し

マイリスト画面は全てそのままで中身を触らない為、Fragmentを埋め込む事にしました。

Fragmentの状態を保存する

NavHostのスコープで状態を保存したい為、ComposeでFragmentの状態を保存できるようにしなければいけません。
SaveableFragmentManager で、Composeのライフサイクルに合わせてFragmentの状態を保存できるようにしました。
これでページを切り替えても、スクロール状態等が保存されるようになります。

@Composable
public fun <T : Fragment> rememberFragment(
    vararg inputs: Any?,
    fragmentManager: FragmentManager,
    initial: () -> T,
): T {
    return rememberSaveable(
        inputs = inputs,
        saver = Saver(
            save = { fragment ->
                if (fragment in fragmentManager.fragments) {
                    fragmentManager
                        .saveFragmentInstanceState(fragment)
                } else {
                    null
                }
            },
            restore = { savedState ->
                initial().also { fragment ->
                    fragment.setInitialSavedState(savedState)
                }
            },
        ),
    ) {
        initial()
    }
}

もしFragmentの中でComposeを使用する場合は、 DisposeOnViewTreeLifecycleDestroyed を設定する必要があります。
https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
    return ComposeView(requireContext()).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    }
}

タブを切り替える

タブを切り替える時は以下のように行い、状態を復元するようにします。

navController.navigate("mylist") {
    popUpTo(navController.graph.findStartDestination().id) {
        saveState = true
    }
    this.restoreState = true
    this.launchSingleTop = true
}

FragmentをComposeに埋め込む

AndroidViewを使用してViewを追加し、それに対してFragmentをcommitします。
こちらもFragmentは呼ぶ側のモジュールに定義してある為、引数でFragmentを受け取ります。

@Composable
public fun MainScreen(
    modifier: Modifier,
    fragmentManagerProvider: () -> FragmentManager,
    myListFragmentProvider: () -> Fragment,
) {
    NavHost(
        modifier = modifier,
        navController = rememberNavController(),
        startDestination = "/",
    ) {
        composable("/") {}
        composable("mylist") {
            var container: FragmentContainerView? by remember {
                mutableStateOf(null)
            }
            val myListFragment = rememberFragment(fragmentManager = fragmentManagerProvider()) {
                myListFragmentProvider()
            }

            LaunchedEffect(Unit) {
                fragmentManagerProvider().commit(allowStateLoss = true) {
                    replace(container!!.id, myListFragment, null)
                }
            }
            AndroidView(
                modifier = Modifier
                    .fillMaxSize(),
                factory = { context ->
                    FragmentContainerView(context).also { container ->
                        container.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
                        container.id = View.generateViewId()
                    }.also {
                        container = it
                    }
                }
            )
        }
    }
}

おわりに

これらの実装によりViewのコードの追加は行わず、少ないコストで実装を行うことができました。
最初はホーム画面という複数の画面を切り替えられる場所を、一部だけComposeにできるのか不安でした。実際は問題なくアプリが本番稼働しています。
ホーム画面のリニュアルはまだ全て終わったわけでは無いので、今後もユーザーさんに最速で価値を届けられるように実装していきます。