既存実装を活用しつつJetpack Composeを用いてクックパッドAndroidアプリの買い物機能を高速に開発している話

こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。 2019年新卒で入社後、クックパッドの新規サービスである「クックパッドマート」の開発に従事しており、2020年からはレシピサービス「クックパッド」iOSアプリの買い物機能を開発していました。

レシピサービス「クックパッド」の買い物機能は、iOSアプリ(以下: クックパッドiOS)で先行リリースしており、現在Androidアプリ(以下: クックパッドAndroid)でも同様の機能開発を行っています。 本稿ではクックパッドAndroidの買い物機能の開発について紹介します。

買い物機能とは

生鮮食品ECサービス「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1

詳しくはiOSアプリ向けの買い物機能の開発について紹介している​​ SwiftUI を活用した「レシピ」×「買い物」の新機能開発 をご覧ください。

画面スクリーンショット
買い物機能の画面例

なぜやるのか

クックパッドで目指している「毎日の料理を楽しみにする」の実現のためです。

現状のレシピサービスでは、料理に制約があります。冷蔵庫にある材料からレシピを探し、冷蔵庫にあるもので料理をつくる ━━ この流れは非常によくある形ですが、「冷蔵庫にある食材」に制約された窮屈な体験とも言えます。

あり物の食材からレシピを探している図
冷蔵庫にある材料からレシピを探す体験

楽しみが広がる、まったく新しい「買い物」を

クックパッドマートは、流通の仕組みから開発しているまったく新しい生鮮食品ECサービスです。

  • 1品からでも送料無料
  • 価格も割安
  • 新鮮
  • 品揃えも豊富
  • 受け取りも楽にできる

cookpad-mart.com

━━ そのような新たな買い物手段があることで、これまでになかった料理の選択肢や楽しみが広がります。

この新たな「買い物」をレシピサービスにうまく融合させることで、今まで以上に料理を楽しみにできると考えています。

例えば、「めずらしい野菜で作るハロウィン料理、気になる!」「バターナッツかぼちゃ美味しそう!食べてみたい!」のように、食べたいものを起点に料理を作りたくなる体験もその一つです。

ハロウィン料理にぴったりの珍しい野菜が載っているスクリーンショット
食べたいものを起点に料理を作りたくなる体験

この新しい「レシピ」×「買い物」の体験は現在クックパッドiOSでのみ提供していますが、既に多くのユーザーから好評をいただいています。もっと多くのユーザーさんに利用していただけるようにするため、今回クックパッドAndroidにも買い物機能を追加することになりました。

買い物機能を最速でリリースするために選択したこと

「レシピ」×「買い物」の新しい体験はまだまだ成熟しておらず、日々チーム内で新しいアイデアを議論したり、ユーザーインタビューを繰り返しています。

note.com

チームとしてはまだまだ体験を突き詰めたいフェーズなので、利用できるユーザーを増やすためのAndroidアプリ開発だけに全リソースを投入できる状況ではありません。そのため、最低限のリソースで最速にリリースを行い、リリースされてからの体験改善に時間やリソースを使いたいと考えています。

再利用可能なAndroid実装の活用

冒頭でも紹介した通り、今回開発中の買い物機能は生鮮食品ECサービス「クックパッドマート」の仕組みを利用しています。

クックパッドマートの利用に特化した専用アプリ(以下: マートAndroid)も既に開発されています。

下のスクリーンショットは、マートAndroidとクックパッドAndroidの買い物機能のそれぞれの商品詳細画面です。

マートAndroid
クックパッドAndroid

画面の表示内容や方法はデザイン上、似通っていることがわかると思います。 画面構成が同じ箇所に関しては実装の使い回しができるのではないかと考えました。

宣言的UIフレームワークの活用

SwiftUI を活用した「レシピ」×「買い物」の新機能開発 にもあるように、クックパッドiOSの買い物機能は SwiftUI を用いて開発されています。約1年半 SwiftUI を用いてサービス開発をしていたこともあり、チーム開発で宣言的UIフレームワークを活用するためのノウハウも溜まってきていました。

一方、Android界隈でも Jetpack Compose が stable release され、クックパッドAndroidの開発にも Jetpack Compose が既に利用され始めていました。

SwiftUI でのノウハウを踏まえて、Jetpack Compose を活用することで実装コード量を減らすことや、プレビューを用いてビルドサイクルを短くすることで、素早く開発を進められるのではないかと考えました。

方針

これらの状況を踏まえて、クックパッドAndroidの買い物機能では Jetpack Compose を利用することで

  • マートAndroidの既存リソースを再利用できる
  • クックパッドiOSの買い物機能で培った宣言的UIフレームワーク活用の知見を利用できる

というメリットを活かす形で開発を始めました。

Jetpack Compose にはナビゲーションを行うためのコンポーネントなどもありますが、それらは使わずに素朴なUIコンポーネントのみをView部分で利用し、画面遷移などは既存のVIPERアーキテクチャ*2にのっかる方針をとっています。

これはクックパッドiOSの買い物機能と同様の設計方針です。VIPERアーキテクチャの View 部分にのみ SwiftUI を利用する設計を1年半続けた上で大きなハマりもなくメリットを享受できているためです。(詳しくは: SwiftUI を活用した「レシピ」×「買い物」の新機能開発

また、今回開発速度を重視するに当たって、画面のUIを意図的にクックパッドiOSの買い物機能ではなくマートAndroidに寄せている部分があります。

マートAndroidはほぼ全ての画面で RecyclerView を利用しており、各アイテムは Layout XML を用いた従来の View で実装されています。(詳しくは: クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

この特徴を生かして、Jetpack Compose における相互運用 API として用意されているAndroidView/AndroidViewBinding*3を用いることで、マートAndroid で画面を構成している Layout XML をそのままクックパッドAndroidで利用することで開発速度の向上を図っています。

実装について

Jetpack Compose版VIPERベースのアーキテクチャ

Jetpack Compose版アーキテクチャに関して、2020年のクックパッドAndroidアプリのアーキテクチャ事情 から一部変更している箇所があるので、変更点について紹介します。

アーキテクチャの図
Jetpack Compose版アーキテクチャ

Presentationレイヤーについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情 ではInteractor や Routing との連係は Presenter が行っており、ViewModel は View 実装の一部という扱いで Contract には処理を記載していませんでした。Jetpack Compose版アーキテクチャでは Presenter は廃止され、Interactor や Routing との連係は新たにContract に記載された ViewModel(実態は AAC の ViewModel)が行うようになっています。

データフローについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情 では Rx を用いてレイヤー間の連係を行っていましたが、Jetpack Compose 版アーキテクチャでは Kotlin Coroutines を使用しています。

基盤実装としての ApiClient は Rx ベースで実装されているため、DataStore で kotlinx.coroutines.rx2.await を用いて Rx の Single を suspend 関数に変換しています

import kotlinx.coroutines.rx2.await
import javax.inject.Inject

class ApiProductsDataStore @Inject constructor(
    private val apiClient: ApiClient
) : ProductsDataStore {
    override suspend fun getProducts(): List<Product> {
        return apiClient.get(path = "/products")
            .await()
            .decodeJSONArray()
    }
}

UI層でのみJetpack Composeを活用する実装に関して

上述のようなアーキテクチャの差分を踏まえた上で、ViewとViewModelの連係について紹介します。

View層の概略図
Jetpack Compose を組み込んだVIPER View 層の概略図

Activity/Fragment が ComposeView として Screen-suffix の Composable 関数を持つような形をとっています。 Screen-suffix の Composable 関数には ViewModel を渡しており、Composable 関数からのクリックイベントなどは ViewModel の関数を呼び出すようにしています。また画面表示のために、ViewModel は StateFlow を公開しており、StateFlow の状態変化に応じて画面を更新できるようにしています(詳しくは後述)。

UILayerの実装に関して

クックパッドAndroidでは Activity/Fragment を用いた View ベースの設計になっています。そのため、Jetpack Composeの相互運用 API として用意されている setContent 関数を呼び出して、Compose ベースの UI を追加しています。

Activity

class ProductListActivity : RoboAppCompatActivity() {

    private val viewModel: ProductListCreateViewModel
        by lazy { ViewModelProvider(this).get(ProductListCreateViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ProductListScreen(viewModel)
        }
    }
}

Fragment

@AndroidEntryPoint
class ProductListFragment : Fragment() {
    @Inject
    lateinit var viewModelFactory: ViewModelFactoryProvider<ProductListViewModel>

    private val viewModel: ProductListViewModel by viewModels { viewModelFactory }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                ProductListScreen(viewModel)
            }
        }
    }

    companion object {
        fun newInstance() = ProductListFragment()
    }
}

ViewModelの実装に関して

ViewModel では Interactor/Routingの連係と、UI の状態と表示するコンテンツを管理しています。 実装クラスは ViewModel (Jetpack) が担当し、ViewModel interface に基づいた画面を実装します。 MutableStateFlow によって画面の状態を管理しますが、状態の変更は内部で行い外部には StateFlow を公開しています。

class ProductListViewModel @Inject constructor(
    private val interactor: ProductListContract.Interactor,
    private val routing: ProductListContract.Routing
) : ViewModel(), ProductListContract.ViewModel {

    private var _state = MutableStateFlow<ScreenState>(ScreenState.Loading)

    override val state = _state.asStateFlow()

    init {
        fetchProducts()
    }

    override fun onProductDetailPageRequested(productId: Long) {
        routing.navigateProductDetail(productId)
    }

    private fun fetchProducts() = viewModelScope.launch {
        runCatching {
            interactor.fetchProducts()
        }
        .fold(
            onSuccess = {
                _state.value = ScreenState.Idle(
                    ProductListContract.ViewModel.ScreenContent(
                        products = it
                    ) 
                )
            },
            onFailure = {
                _state.value = ScreenState.Error(
                    reason = it.errorStatus.reason,
                    reloadAction = ::reload
                )
            }
        )
    }
}

Screenの実装に関して

Activity/Fragment が ComposeView として表示している Composable 関数を Screen という命名にしています。

@Composable
fun ProductListScreen(
    viewModel: ProductListContract.ViewModel
) {
    val state = viewModel.state.collectAsState()
    AsyncLoadSurface(state = state.value) { content: ProductListContract.ViewModel.ScreenContent ->
        ProductListScreenContent(
            products = screenState.products,
            onTapProduct = viewModel::onProductDetailPageRequested
        )
    }
}
 
@Composable
private fun ProductListScreenContent(
    products: List<Product>,
    onTapProduct: (Long) -> Unit
) {
    LazyColumn {
        item {
            HeaderSection()
        }
        items(products) { product ->
            ProductSection(
                product = product,
                onTapProduct = onTapProduct
            )
        }
        item {
            FooterSection()
        }
    }
}

@Composable
private fun ProductSection(product: Product, onTapProduct: (Long) -> Unit) {
    AndroidViewBinding(
        factory = ViewProductSectionBinding::inflate,
        modifier = Modifier
            .background(CookpadColor.Ivory)
            .padding(horizontal = 20.dp)
    ) {
        textView.text = product.name
        button.setOnClickListener {
            onTapProduct(product.id)
        }
    }
}

買い物機能は基本縦スクロールの画面構成となるため、画面実装は LazyColumn をベースにした画面実装にしています。 意味のあるまとまりで画面を要素分割し、LazyColumn の item に要素ごとのまとまりを入れています。この item の各要素をSection という単位で分割しています。

画面をSectionで分割しているスクリーンショット
LazyColumnのitem単位の分割

Section の中で実装している画面は、既存リソースがある場合は AndroidView/AndroidViewBinding を用いて再利用を行い、新規で実装が必要な部分に関しては Jetpack Compose を利用してレイアウトを作成しています。

実際どうだったのか

うまくいった点

  • Jetpack Compose ベースのUI構築をしたことで、RecyclerView を用いる際の Adapter/ViewHolder を書く必要がなくなり、記述量が削減されることで開発速度の向上に繋がった
  • LazyColumn の中身を Section という単位で分割する指針を早いうちに決められた
    • これによって、AndroidView/AndroidViewBinding を用いて Layout XML や Android View などの既存リソースを使う場合も、RecyclerView を利用する際のリストアイテムを苦労なくJetpack Composeベースのレイアウトに組み込むことができた
  • SwiftUI でチーム開発している時に課題となったコンポーネントの粒度について早めに共通認識をつくることで、読みやすく保守しやすい状態を維持して開発できた*4

うまくいかなかった点

  • Jetpack Compose に慣れてない段階では、既存の Layout XML をコピーして一部を変更する方が速かったので、AndroidViewBinding を活用してレイアウトできることがメリットになっていた
    • しかし、Jetpack Compose に慣れてくると、該当画面を探してコピーしてくるより、一から Jetpack Compose で書く方が速くなり、恩恵を受けていたのは最初のうちだけだった

まとめ

買い物機能を最速でリリースするために、利用可能な既存実装の再利用や Jetpack Compose を用いて、速度向上を図っている事例を紹介しました。買い物機能は2022年頭にファーストリリースを目指して絶賛開発中です。今後も開発状況を発信していきたいと思っていますので、ぜひ応援よろしくお願いします。 リリースされたらぜひ使ってみてくださいね!

クックパッドでは一緒に働く仲間を募集しています!

今回は買い物機能の開発にあたっての工夫や Jetpack Compose の活用事例についてご紹介しました。

新しいフレームワークを活用して、高速にサービス開発を進めることで事業をドライブしたいエンジニアを大募集しています。

カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:クックパッドAndroidアプリはVIPERアーキテクチャを用いて開発されています。 詳しくは 2020年のクックパッドAndroidアプリのアーキテクチャ事情 を参照ください。

*3:AndroidView/AndroidViewBindingはJetpack Composeの相互運用 API として用意されているもので、AndroidView や XML Layout を Compose UI のレイアウト階層に含めることができる。

*4:コンポーネント粒度に関して共通認識をつくるための取り組みに関して チームでSwiftUIを書くために ~読みやすく保守しやすい設計について考えたこと~ にまとめていますので、こちらも参照ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/