以下の内容はhttps://techblog.zozo.com/entry/epoxy-to-composeより取得しました。


AI支援でEpoxyを撤去しCompose化:置き換えの背景と効果

AI支援でEpoxyを撤去しCompose化:置き換えの背景と効果

はじめに

こんにちは、ブランドソリューション開発本部WEAR開発部Androidブロックの武永です。普段はファッションコーディネートアプリ「WEAR」のAndroidアプリを開発しています。

背景

本記事では、Epoxy(Airbnb製のRecyclerView向けUI構築ライブラリ)を撤去し、Jetpack Compose(Compose)へ移行した背景を整理します。

移行の直接のきっかけは、当時の構成でKotlin Annotation Processing Tool(kapt)依存が残っていたことです。kapt依存が残っていたため、Kotlin Symbol Processing(KSP)への移行が進みませんでした。その結果、KotlinおよびDagger Hilt(Hilt)の更新判断も先送りされていました。

一方で、Epoxy自体はKSP対応が進んでいました。しかし、Data Binding用プロセッサはKSP未対応で、Data Bindingを使う構成ではkaptが必要でした(Epoxy 5.0.0beta02のリリースノート)。ビルド時間でもボトルネックになっていました。その際、EpoxyのData Bindingを外して使い続ける案も検討しましたが、UIをComposeに統一するメリットが大きいと判断し、修正量は増えてもCompose化に踏み切りました。

本記事の範囲

あわせて、進め方と得られた効果もまとめます。Data Bindingを使っている場合はkaptを外すための置き換えも必要です。この記事ではその前提を踏まえ、EpoxyのCompose化にフォーカスします。

記載方針

本記事では当時の実体験と現在の判断を分けて整理します。当時はAIエージェント(GitHub Copilot)をVisual Studio Codeで使っていました。現在は選択肢が増えていますが、内容は当時の作業基準に基づいています。以降の「リファクタリングする画面の問題点」から「Epoxy削除とCompose移行を進める」までは当時の判断をもとに記載します。一方、「改善の余地があった点」は現在の視点で振り返った内容です。

※コードは社外向けに匿名化・簡略化しています。

目次

リファクタリングする画面の問題点

KotlinおよびHiltのバージョンアップが止まっていた

kapt依存のある構成ではKotlinおよびHiltの更新が進まず、関連するライブラリやビルド設定の更新にも踏み切れませんでした。結果としてアップデート作業が停滞していました。

kapt依存がボトルネックになっていた

kapt依存はビルド時間の増加にもつながり、見直しのきっかけになりました。事前に簡易計測したところ、kaptの削減だけでも少なくとも30〜60秒は短縮できそうだと見込めました。

  • Kotlin Annotation Processing Tool(kapt)はJavaベースのアノテーション処理で、ビルド時にJavaのstubファイルを生成してから処理するため時間がかかる
  • KSPはKotlin専用のアノテーション処理で、Kotlinコードを直接処理できるため、kaptより高速
  • EpoxyのData Binding用プロセッサがkapt依存だったため、Data Bindingを使う構成ではKSP移行が進まず、ビルド時間にも影響していた

Epoxy削除とCompose移行を進める

  • Epoxyを撤去し、UIをComposeへ移行
  • XMLレイアウト(ConstraintLayout中心)とEpoxyRecyclerViewを廃止し、ComposeのScaffold + LazyVerticalGridで再構成
  • AIエージェントの自動置換を活用し、確認と微調整をしながら置換作業を進めた

特に重視したのは見た目の完全一致と依存の排除です。EpoxyやXMLのConstraintLayoutを避ける構成にし、Composeの標準的なレイアウト(Column/Row)で組み直しました。

同等性の検証方法

移行前後で同一条件のスクリーンショット(同一端末・同一画像サイズ)を撮り、目視で比較しました。差分が出た場合は余白・文字サイズ・色の順で確認しました。今回は目視確認でしたが、Compose化したことで今後はスクリーンショットテストの導入も検討したいと考えています。

画面全体のCompose化

画面全体をComposeに置き換える場合は、EpoxyRecyclerView中心の構造をComposeの画面構成へ移す方針で進めました。

プロンプト

TestGridLayoutFragment の EpoxyRecyclerViewの構成を Compose に置き換えてください。
- デザインは既存UIと完全に一致させること
- Epoxyの使用は禁止
- ComposeのConstraintLayoutは使わず、シンプルなレイアウトで実装する
- @Preview を必ず作成する

差分(抜粋)

1) FragmentのView生成(XML→ComposeView)

Before(XML + Data Binding)

_binding = DataBindingUtil.inflate(inflater, R.layout.fragment_test_grid_layout, container, false)
return binding.root

After(ComposeView)

return ComposeView(requireContext()).apply {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        AppTheme {
            DetailScreen()
        }
    }
}

2) グリッド構築(Epoxy→LazyVerticalGrid)

Before(Epoxy + RecyclerView)

binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), SPAN_COUNT)
binding.recyclerView.withModels {
    // Epoxy models...
}

After(Scaffold + LazyVerticalGrid)

Scaffold(topBar = { AppTopBar.Default(...) }) { paddingValues ->
    LazyVerticalGrid(
        columns = GridCells.Fixed(SPAN_COUNT),
        state = gridState,
        modifier = Modifier.fillMaxSize().padding(paddingValues),
    ) {
        item { /* Section */ }
    }
}

Epoxyの1モデルを1コンポーネントに置き換える

画面全体の置き換えだけでなく、Epoxyの「モデル単体」をComposeに置き換える方法も進めました。Epoxyのモデルクラス(@EpoxyModelClass)とXMLレイアウトを削除し、同じ役割のComposableを用意して既存の表示フローに組み込みます。

プロンプト

ActionCompletedModel をComposeのUIコンポーネントに置き換えてください。
- デザインは既存UIと完全に一致させること
- EpoxyモデルとXMLレイアウトは削除する
- Epoxyの使用は禁止
- ConstraintLayoutは使わず、シンプルなレイアウトで実装する
- @Preview を必ず作成する

差分(抜粋)

Before(Epoxyモデル + XML)

@EpoxyModelClass
abstract class ActionCompletedModel :
    DataBindingModel<ActionCompletedBinding>() {

    @EpoxyAttribute
    lateinit var actionInfo: ActionInfo

    @EpoxyAttribute(DoNotHash)
    var onPrimaryActionClick: View.OnClickListener? = null

    override fun getDefaultLayout(): Int = R.layout.action_completed

    override fun bind(binding: ActionCompletedBinding, context: Context) {
        binding.actionButton.setOnClickListener(onPrimaryActionClick)
        binding.actionTitle.text = actionTitle(context, actionInfo)
        binding.actionSubtitle.text = actionSubtitle(context, actionInfo)
    }

    override fun unbind(binding: ActionCompletedBinding) {
        binding.actionButton.setOnClickListener(null)
    }
}

After(Composable + 組み込み)

LazyColumn {
    item {
        ActionCompletedContent(
            actionInfo = actionInfo,
            onPrimaryActionClick = { onPrimaryAction(actionInfo) },
        )
    }
}

Before(XML)

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <com.example.RoundRectLayout
        android:id="@+id/actionButton"
        android:layout_width="match_parent"
        android:layout_height="56dp">
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">
            <TextView android:id="@+id/actionTitle" />
            <ImageView android:id="@+id/actionIcon" />
            <TextView android:id="@+id/actionSubtitle" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </com.example.RoundRectLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

After(Compose / ActionCompletedContent 内の抜粋)

@Composable
fun ActionCompletedContent(
    actionInfo: ActionInfo,
    onPrimaryActionClick: () -> Unit,
) {
    val context = LocalContext.current
    Column(modifier = Modifier.fillMaxWidth()) {
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .height(56.dp),
            shape = RoundedCornerShape(12.dp),
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .clickable(onClick = onPrimaryActionClick),
            ) {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center,
                ) {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Text(text = actionTitle(context, actionInfo))
                        Icon(
                            painter = painterResource(id = R.drawable.ic_action),
                            contentDescription = null,
                        )
                    }
                    Text(text = actionSubtitle(context, actionInfo))
                }
            }
        }
    }
}

AI支援による効率化

ざっくりした記録ですが、AIに置換を任せたPRは合計28件でした。1件あたりの作業時間は、人力だと3〜5時間、AI支援後は1〜2時間くらいで終わるケースが多かったです。単純計算で1件あたり2〜4時間短縮できたので、合計では約56〜112時間(7〜14人日)ほどの短縮になりました。

副次効果として、反復系の単純作業をAIに任せることで、人間が差分を照合するときのケアレスミスを減らせた実感がありました。

改善の余地があった点

プロンプトで「完全一致」と指示しても既存UIを完全には再現できず、細かい余白・フォント・色差で手戻りが発生しました。色定義をテーマから使うべきところで個別指定になり、見た目の統一にも手戻りが出ました。XMLのConstraintLayoutで組まれていたレイアウトが多く、Composeでも旧来の発想に引っ張られ、Column/Row前提で組み直す場面もありました。

今振り返ると、AIエージェント向け作業ガイドを整備しておけば、こうした手戻りを減らせたと思います。例えば、画面ごとのUI要素の対応表、余白/フォント/色の参照先、Composeでの置き換えルール、スクリーンショット比較の手順を1枚にまとめる、といった形です。

まとめ

Epoxy削除によりkapt依存の主要因を外し、KSP移行に向けて前進できました。KotlinおよびHiltのアップデート準備も進みました。依存解消を目的にUI移行を進めると判断がぶれにくく、AIエージェントは置換作業の加速に向いていると感じました。Compose移行は「更新可能性」と「開発体験」の改善にもつながりました。

最後までご覧いただきありがとうございました。ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com




以上の内容はhttps://techblog.zozo.com/entry/epoxy-to-composeより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14