クックパッドマートアプリの開発体験改善に向けたJetpack Compose導入の検討と実践、そして新たな課題

こんにちは、買物プロダクト開発部のYuto Koguchi(@10llip0p)です。2020年に新卒入社し現在はクックパッドマートのAndroidアプリ開発に従事しています。 クックパッドマートアプリ(Android)はリリースから約2年半が経ち、おかげさまで日々多くのユーザーにご利用いただいております。それに伴って実装の規模や複雑さも増していることから開発効率向上のために日々様々な改善を行っています。 その上で本稿ではクックパッドマートアプリのUI実装の課題とその改善に向けたJetpack Compose導入の検討、そして実際に導入に取り組んだことで直面している課題について紹介します。また本稿はAfter Party DroidKaigi 2021での発表とその後日談が主な内容になります。

これまでのクックパッドマートアプリの画面実装

クックパッドマートアプリでは画面実装にGroupieを使うことで全ての画面をRecyclerViewで実装していました。Groupieの概要や技術選定の詳細はリリース当初に公開したクックパッドマートAndroidアプリの画面実装を最高にした話をご覧ください。こうして最高な実装方式を採用していたわけですが、一方で開発やメンテナンスを続けているうちに徐々にその最高も感じづらいものになっていました。具体的にGroupieでいまいちに感じていた点は以下です。

画面単位でのプレビューができない

Groupieでは画面を構成するコンポーネント(GroupieItem)が行単位で分割されることでActivityやFragmentの肥大化を防止できることが利点の1つでした。しかしこれによりActivityやFragmentには基本的にRecyclerViewだけを置くことになり、レイアウトエディター上のプレビューだけではどんな画面が表示されるのかわかりづらくなっていました。例えば以下に示す画像は商品詳細画面のスクリーンショットとそのFragmentのプレビューですが、比較するとほとんどプレビューとして機能していないことがわかると思います。

f:id:u_10llip0p:20211221171956p:plain
プレビューが機能していない例

さらにECサービスという特性上商品や配送といったデータは時系列等で変動するため同じ画面でも状態に応じて表示内容が変わります。 例えば以下の画像は全て商品受け取り画面のスクリーンショットですが、注文方式の違いや注文キャンセルなどに応じて異なる表示をしています。 このようにデータの内容が画面上のどの部分の表示に影響するのかを調べるには各GroupieItemの実装を読み解く必要があり、また実際にアプリをビルドして動かすまで画面全体の表示を確認できないことを手間に感じていました。

通常注文 自宅配送オプション 注文キャンセル
f:id:u_10llip0p:20211221171945p:plain f:id:u_10llip0p:20211221171949p:plain f:id:u_10llip0p:20211221171952p:plain

ネストされたRecyclerView構成の複雑さ

GroupieはRecyclerViewの実装を簡素化できるため縦一方向にスクロールするUIを構築する上ではとても便利です。しかし画面の一部でコンテンツが横並びするような表示が必要になった際にはRecyclerView in RecyclerViewな構成を避けることができず実装が複雑になってしまいます。 例えば以下のスクリーンショットではカルーセルのような表示のRecyclerViewと画面全体のRecyclerViewがネストした構成となります(矢印方向がRecyclerViewでの表示)。

ネストしたRecyclerViewの例

現在のクックパッドマートアプリでは商品の一覧表示などで横並びするコンテンツが多用されているためこのような構成での実装が多く存在しています。またRecyclerViewの階層が深くなることで先述したプレビューのしづらさも相まって実装の見通しの悪さにも繋がっていました。

Jetpack Composeの登場と導入の検討

Groupieに対しての細かな不満感が募りつつある中、今年ついにAndroidの新しいUIライブラリJetpack Composeが正式リリースされました。今ではDroidKaigiやQiita, Zennなどで活発に知見が共有されており既に各社様々なプロダクトで採用事例があるため、読者のAndroidエンジニアの方々にももう馴染み深いものになっているかもしれません。クックパッドでもJetpack Composeを使った試みには意欲的に取り組んでおり先日も 既存実装を活用しつつJetpack Composeを用いてクックパッドAndroidアプリの買い物機能を高速に開発している話 という記事を公開しています。こちらもぜひご覧になってください。 Jetpack ComposeがAndroidアプリのUI実装のトレンドとなっていく中でクックパッドマートアプリでもJetpack Composeの導入を検討しました。具体的にJetpack Composeに対して導入のモチベーションとなったのは以下の点です。

  • ColumnやRowなどのリスト形式の表示を基本としたUI構築
    • RecyclerViewベースで構築したアプリ構成を大きく崩さずに移行を進められる
    • RecyclerView in RecyclerViewで実装していた複雑なUIをシンプルな実装に置き換えられる
  • 強力なプレビュー機能の標準サポート
    • コンポーネントが分割されていても画面全体のプレビューが簡単にできる
    • 本番データと同じデータモデルを使って実際の表示パターンの確認ができる
  • LiveDataやFlowなどのJetpackライブラリとの組み合わせやMVVMアーキテクチャを想定した設計
    • 同様の技術選定で実装しているクックパッドマートアプリと相性が良い

またJetpack ComposeはGroupieの利点であった差分更新の機能も備えています。以上のことからJetpack Composeを導入することでGroupieで得ていたUI実装のメリットと同様の恩恵を保ちつつ、Groupieで感じていた開発体験の課題改善を期待できるためクックパッドマートアプリへのJetpack Compose導入を進めることにしました。

既存のUI実装からの段階的なJetpack Compose移行

プロダクションでサービス運用しているアプリでは日々様々な機能開発や施策を進めており、GroupieからJetpack Composeへの移行のような大きなアーキテクチャ移行を一度に行うには以下のような難しさがあります。

  • 大規模な実装変更、コードレビュー、動作検証など人的・時間的リソースが大量に必要
  • アーキテクチャ移行へのリソース投入により新規開発・施策進行をストップさせる事業上の判断が必要
  • 事業上の意思決定を行うために各ステークホルダーとの相談・合意が必要

そのためクックパッドマートアプリでは日常の開発の中で段階的に既存のUI実装をJetpack Composeに移行できる方法を模索しました。

既存のUI実装構成からの移行アプローチ

Jetpack Composeへの移行にあたっては公式ドキュメント(Compose をアプリに導入する)でもいくつかのアプローチが例示されていますが、Groupieのような特殊なケースは想定していないためそのままは参考にできません。そのためクックパッドマートアプリでは独自のアプローチでJetpack Compose移行を進めることにしました。 Groupieを使った実装の構成は模式図にすると以下のようになります。(Compose の思想宣言型パラダイム シフトの図を真似てます)

Groupieを使った実装構成

画面上のUIを行単位で個別のGroupieItemに実装しRecyclerView上に各Itemを縦に並べる構造です。コード例で示すと以下のようになります。

// 画面一行分のUI
data class HogeItem(
    val hogeData: HogeData,
) : BindableItem<ItemHogeBinding>() {
    override fun getLayout(): Int = R.layout.item_hoge

    override fun getId(): Long = layout.toLong()

    override fun initializeViewBinding(view: View): ItemHogeBinding =
        ItemHogeBinding.bind(view) 

    override fun bind(viewBinding: ItemHogeBinding, position: Int) {
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    override fun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter 

        adapter.update(mutableListOf<Group>().apply {
            add(HogeItem(hogeData)) 
            add(FugaItem()) // HogeItemの下の行のUI
        })
    }
}

そこでこの構造をベースとして、まずは各Itemの中のUI実装だけをJetpack Composeで置き換えていくことで段階的に移行していけると考えました。

Jetpack Compose移行後のGroupieを使った実装構成

具体的にはGroupieItemにComposeViewのみをbindしてComposable functionの実行環境として使用する方法です。既存のGroupieItem実装からは元実装のレイアウトXMLとAndroidViewBinding)を使うことで一旦簡易的に移行できますし、移行途中で新規にGroupieItemの追加が必要になった場合にも画面全体の移行を待つことなくJetpack ComposeでUIを実装することができます。以上のことからクックパッドマートアプリではJetpack Compose移行に次のようなアプローチの採用を決めました。

  1. 各GroupieItemのUI実装を順次Jetpack Compose(Composable function)で置き換える
  2. 1つの画面のGroupieItemが全てJetpack Compose移行を完了したらGroupie層をJetpack ComposeのLazyColumn/Columnに置き換える

f:id:u_10llip0p:20211221172029p:plain
GroupieからJetpack Composeへの段階的移行アプローチ

段階的移行を支援するための汎用GroupieItem実装

上記アプローチでJetpack Compose対応したGroupieItemは基本的にComposeViewを表示するだけの役割になります。またJetpack ComposeをRecyclerView上で利用するにあたってはViewHolder, Adapterの実装でいくつか注意点があり、Groupieで同様の役割を担うGroupieItemの実装でも考慮する必要があります。したがってJetpack Compose対応したGroupieItemの実装は大部分が冗長なボイラープレートコードになるため、移行段階でも極力Jetpack Composeの実装だけに集中できるように共通コンポーネントとしてComposeItem(汎用GroupieItem)を用意しました。

ComposeItemの具体的な実装は以下のようになります。

data class ComposeItem<T>(
    private val data: T,
    private val composable: @Composable () -> Unit
) : BindableItem<ItemComposeBinding>() {

    override fun getLayout(): Int = R.layout.item_compose

    override fun getId(): Long = layout.toLong()

    override fun initializeViewBinding(view: View): ItemComposeBinding =
        ItemComposeBinding.bind(view).also {
            it.composeView.setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
        }

    override fun bind(viewBinding: ItemComposeBinding, position: Int) {
        viewBinding.composeView.setContent(composable)
    }

    override fun hasSameContentAs(other: Item<GroupieViewHolder>): Boolean =
        other is ComposeItem<*> && other.data == this.data

    override fun unbind(viewHolder: com.xwray.groupie.viewbinding.GroupieViewHolder<ItemComposeBinding>) {
        viewHolder.binding.composeView.disposeComposition()
        super.unbind(viewHolder)
    }
}

特筆する点としてはViewBindingの初期化処理でComposeViewのViewCompositionStrategyDisposeOnViewTreeLifecycleDestroyedを指定しています。またGroupieItemがunbind(recycle)される際にComposeViewを明示的にdisposeComposition)しています。 これらの実装はRecyclerViewのComposeの実装手法を踏襲しており、RecyclerViewをhostするFragmentが破棄された際やComposeItemが画面外に移動した際に適切に描画リソースをメモリから解放してくれるようになります。

またGroupieは表示するデータを更新した際にGroupieItemのインスタンスの同値性を見てよしなに差分更新してくれるのですが、GroupieItemの共通化によりこの仕組みがうまく機能しなくなります。そこで表示するデータの同値性から判定するようにhasSameContentAsをoverrideすることでこの問題を解決しています。

以上によりJetpack Composeへの移行段階でも表示するComposable functionだけを作れば良い状態になりました。実際にGroupieでComposeitemを使った実装例はこのようになります。

@Composable
fun HogeSection(
    hogeData: HogeData,
) {
    AndroidViewBinding(ItemHogeBinding::inflate) {
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    override fun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter 

        adapter.update(mutableListOf<Group>().apply {
            add(
                ComposeItem(
                   data = hogeData,
                ) {
                    HogeSection(hogeData)
                }
            ) 
            add(FugaItem())
        })
    }
}

プレビュー活用に向けたモジュール構成

Jetpack Composeのプレビュー機能はパラメータの変更などに対しては即座に表示を更新してくれますが、新規にComposable functionを実装した際やUI要素を追加した際は更新にprojectをbuildする必要があります。Jetpack Compose導入以前のクックパッドマートアプリのアーキテクチャでは(一部のレイヤを除き)単一のappモジュールに実装する構成となっていたため、buildの完了に数分程度かかることが欠点でした。プレビュー機能はJetpack Compose導入のモチベーションの1つであり、開発体験の改善を目的とする上で快適に利用できることは必須です。そこでアプリのモジュール構成を整理することで高速にプレビューできる開発環境を構築しました。

整理後のアーキテクチャでは旧appモジュールをuiモジュールとappモジュールの2つに分割したマルチモジュール構成にしました。それぞれのモジュールの役割やファイル構成は以下のようになります。

  • uiモジュール
    • 画面表示に関わる実装・リソースを集約
    • appモジュールの実装に依存しない
    • 含めるファイル
      • Composable function
      • Composable functionで表示するデータの定義
      • UI関連のresource(layout, drawable, font, color, etc…)
  • appモジュール
    • uiモジュール以外のアプリケーション本体やビジネスロジックに関わる実装を集約
    • uiモジュールの実装を参照して画面表示や状態管理をする
    • 含めるファイル
      • Application
      • Activity, Fragment
      • ViewModel

プレビュー活用を意識したmodule構成

この構成変更によりプレビューを更新する際はuiモジュールだけのbuildで完結するようになり、実行時間はおおよそ数秒程度に短縮することができました。

実装移行後に発覚した新しい課題

以上の内容までがAfter Party DroidKaigi 2021で発表したものになります。その後クックパッドマートアプリでは上記方針で実際にGroupieからJetpack ComposeへのUI実装移行に取り組み、期待通りの開発体験の改善を得ることができました。 一方で実装移行を進めていくうちに移行作業の障害となる当初予想していなかった問題に直面しました。そのため現在はアプリのユーザービリティを最優先と考え、やむを得ず以下の方針で開発を行っています。

  • 実装移行中の既存画面を一旦元のGroupie実装に戻してGroupieでの実装を継続する
  • 新規の画面はJetpack Composeを使って実装する

これらの対応が必要となった主な問題を紹介します。

スクロールが引っかかる

画面をスクロール中にAndroidView in Jetpack Composeで構成されたGroupieItemに指が触れた際にスクロールが停止する挙動が見つかりました。最下層のAndroidViewにクリックイベントが設定されていた場合に再現し、focusableをfalseにするなど思いつく対策は試しましたが解決することはできませんでした。そもそもAndroidView in Jetpack Compose in AndroidView in RecyclerViewのような歪な構造になっているのが良くなく、スタンダードな実装からも外れているため将来的なJetpack Composeのアップデートでも改善は期待できないと思っています。

スクロールが引っかかる様子

スクロール中に表示がずれる

画面をスクロールしてComposeItemが表示領域に入った瞬間に表示コンテンツがガクッとずれる現象が起きていました。これはComposeItemがRecyclerViewによって再描画される際にComposeViewの表示 → composable functionの順に実行され、一瞬だけheightが0dp(wrap_content)な状態が描画されるのが原因です。対策として

  • ComposeItemの高さを保存して再描画時に復元する
  • RecyclerViewがItemをリサイクルしないようにする

といったことで解決できますが、だいぶ無理やりなworkaroundですし長大なコンテンツ表示やページネーション等でOOMのリスクがあります。

スクロールがガクッとずれる様子

既存画面実装をComposeItemを使った実装体験に近づける工夫

実用上の課題によりGroupieとJetpack Composeを混在させる方式で既存画面のUI実装を置き換えていくのは難しいことがわかりました。 一方で段階的な実装移行を試みたことで、特にComposeItemを使った実装の感触から、GroupieItemのボイラープレートコードを削減するだけでもGroupieでの実装の生産性を向上できる気づきがありました。 そのため今後も既存実装のJetpack Compose移行を念頭に起きつつ、 まずは目下の課題である開発面の改善に向けて素のGroupieItemの実装体験をComposeItem(+ AndroidViewBinding)を使った時の実装体験に近ける工夫を行っているので紹介します。

ViewBindingItem

ComposeItemと同様にGroupieItemの共通化を既存画面実装でも可能にするため、非Jetpack Compose用の汎用GroupieItemとして ViewBindingItem を作りました。 以下がそのコード例です。基本的にはComposeItemと同じような実装をしています。

class ViewBindingItem<T : ViewBinding>(
    @LayoutRes private val layoutResource: Int,
    private val callInitializeViewBinding: (View) -> T,
    private val content: Any?,
    private val id: Long = layoutResource.toLong(),
    private val callUnbind: T.() -> Unit = { },
    private val callBind: T.(Int) -> Unit = { },
) : BindableItem<T>() {
    override fun getLayout(): Int = layoutResource

    override fun getId(): Long = id

    override fun hasSameContentAs(other: Item<*>): Boolean =
        if (other is ViewBindingItem<*>) {
            content == other.content
        } else {
            super.hasSameContentAs(other)
        }

    override fun initializeViewBinding(view: View): T = callInitializeViewBinding(view)

    override fun bind(viewBinding: T, position: Int) {
        viewBinding.callBind(position)
    }

    override fun unbind(viewHolder: GroupieViewHolder<T>) {
        super.unbind(viewHolder)
        viewHolder.binding.callUnbind()
    }
}

ViewBindingItem を使ったActivity/Fragment上での表示処理は以下のようになり、非Jetpack Composeな実装でもComposeItemと同様に画面の構築(ViewBinding)だけに集中できるようになりました。

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    override fun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerview.adapter = adapter

        adapter.update(mutableListOf<Group>().apply {
            add(
                ViewBindingItem(
                   layoutResource = R.layout.item_hoge,
                   callInitializeViewBinding = ItemHogeBinding::bind,
                   content = hogeData,
                ) {
                   // this is ItemHogeBinding
                   hogeTitle.text = hogeData.title
                   hogeMessage.text = hogeData.message
                }
            ) 
        })
    }
}

よりJetpack Compose likeな表示実装

実際のクックパッドマートアプリでのGroupie実装ではGroupAdapterを簡易に扱うためのユーティリティクラス GroupBuilder を使っています。 そこで ViewBindingItemの生成処理を GroupeBuilder の拡張関数にすることで、GroupieItemをComposable functionのような関数コンポーネントとして実装できるようにしました。

fun GroupAdapter<*>.updateTo(function: GroupBuilder.() -> Unit) {
    update(GroupBuilder().apply(function).build())
}

class GroupBuilder {
    private val groups = mutableListOf<Group>()

    fun add(item: Group) {
        groups.add(item)
    }

    fun build(): List<Group> = groups
}

fun <T : ViewBinding> GroupBuilder.viewBindingItem(
    @LayoutRes layoutResource: Int,
    initializeViewBinding: (View) -> T,
    content: Any?,
    id: Long = layoutResource.toLong(),
    unbind: T.() -> Unit = { },
    bind: T.(Int) -> Unit = { },
) {
    add(
        ViewBindingItem(
            layoutResource = layoutResource,
            callInitializeViewBinding = initializeViewBinding,
            content = content,
            id = id,
            callUnbind = unbind,
            callBind = bind,
        )
    )
}

これを使うことで先述の ViewBindingItem を使った実装例は次のように書き換えることができ、よりJetpack Composeに近い表現で実装することが可能になりました。

fun GroupBuilder.hogeSection(hogeData: HogeData) {
    viewBindingItem(
        layoutResource = R.layout.item_hoge,
        initializeViewBinding = ItemHogeBinding::bind,
        content = hogeData,
    ) {
        // this is ItemHogeBinding
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    override fun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter

        adapter.updateTo {
            hogeSection()
        }
    }
}

/* Jetpack Composeを使った場合の実装例
@Composable
fun hogeSection(hogeData: HogeData) { 
    AndroidViewBinding(ItemHogeBinding::inflate) {
        // this is ItemHogeBinding
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

@Composable
fun hogeScreen() {
    Column {
        hogeSection()
    }
}
*/

また ViewBindingItem のインタフェースをComposable function(AndroidViewBinding)に近づけたことで今後GroupieをJetpack Composeに置き換える際も最小限の変更差分で済むようになりました。

既存画面の今後の実装移行について

以上の工夫により既存画面でのGroupieを使った実装の開発効率の改善し、同時にJetpack Composeを使っている新規画面の実装とのコード表現の統一を実現しました。 ただあくまでこれらはJetpack Compose移行に向けた繋ぎの対応であり実装移行の戦略は並行して練っています。 実装移行を前提とした開発環境の整備は将来的にスムーズな実装移行に繋がると考えており、既存画面実装の工夫は今後も続けていく予定です。

また既存画面の実装移行にあたり過去の実装資産を活かすにはAndroidViewBindingが役立つため積極的に活用したいと考えています。 一方でAndroidViewBindingにはリソースが適切に解放されずメモリリークする不具合を確認しており、アップデートで改善されるまでは多用するべきでないと判断しました。 したがってJetpack Composeを取り巻く環境がより成熟するまでは、再び手戻りがないように既存画面の実装移行は慎重にタイミングを探っているのが現状です。

まとめ

クックパッドマートアプリのGroupieでのUI実装課題を踏まえたJetpack Compose導入について、段階的な実装移行の方針とそれに向けた実装環境の整備について紹介しました。実際の実装移行については予想外のトラブルで計画通りに進められていないのが現状ですが、Jetpack Composeへの移行を諦めたわけではありません。 Jetpack Composeを使うことによる実装効率や生産性の恩恵は既にチームメンバー全体が実感しており、実装移行の機運はますます高まっています。課題は多いですが様々な工夫をしつつ移行に向けたトライは現在も続けています。

最後に

今回紹介したJetpack Composeだけでなくクックパッドでは新しい技術を積極的に活用してAndroidアプリ開発に取り組んでいます。技術的な挑戦やサービス開発に興味のあるAndroidエンジニアを絶賛募集してますのでお気軽にご応募ください。

info.cookpad.com