2020年のクックパッドAndroidアプリのアーキテクチャ事情

こんにちは、モバイル基盤部の加藤です。普段はモバイルアプリの基盤技術の整備や品質管理の業務に携わっています。 今回はクックパッドAndroidアプリ(以後クックパッドアプリ)の2020年時点でのアーキテクチャの紹介をしたいと思います。

アーキテクチャ導入以前のクックパッドアプリ

2017年以前クックパッドアプリにはアーキテクチャと呼べるようなものが存在していませんでした。大まかに API 通信や DB 操作等のデータ取得箇所を分離し、複雑なロジックを持つ場合は Manager, Util 等の強いオブジェクトが生成されていましたが、それ以外は Activity / Fragment に処理を直接記述することがほとんどでした。

そういった状況の中で今後もアプリを継続的に開発可能にすることを目的にアーキテクチャの導入が始まりました。クックパッドアプリでは iOS/Android 両プラットフォームで VIPER アーキテクチャを採用し、現在に至ります。

VIPER アーキテクチャ

クックパッドアプリで VIPER アーキテクチャを選定した理由を説明する前に、簡単に VIPER アーキテクチャを紹介します。

VIPER は View, Interactor, Presenter, Entity, Routing の頭文字を並べたもので、アーキテクチャはこれらの要素と Contract (契約)を元に構成されます。クックパッドアプリでは VIPER の要素を画面(Activity / Fragment)ごとにまとめ、VIPER の1かたまりを シーン(Scnene) と読んでいます。 これらの要素は大まかにそれぞれ以下のような責務を持ちます(フローに合わせて順序を変えています)。

  • View
    • Entityを描画する。実装クラス(Activity / Fragment)は UI を更新し、UI操作をもとに Presenter を呼び出す。
  • Presenter
    • Presentation Logic の起点を示す。実装クラスは Interactor, Routing を呼び出してPresentation Logic を実装する 。
  • Interactor
    • Presentation Logicを実現する為のBusiness Logicを示す。実装クラスは Presenter からリクエストを受け、ビジネスロジックを処理し、結果を Presenter に返す。
  • Routing
    • 発生しうる画面遷移を示す。実装クラスは Presenter からリクエストを受け画面遷移を行う。
    • 一部の記事では Router となっていますが、社内では Routing と読んでいます。
  • Entity
    • VIPER シーン中で利用されるデータそのもの。
  • Contract
    • 上記の要素を内容を定義する VIPER の核。

例えばレシピを描画するような画面の場合、以下のように Contract を定義し VIPER を構築します。

interface RecipeContract {
    interface View {
        fun renderRecipe(recipe: Recipe)
    }

    interface Presenter {
        fun onRecipeRequested(recipeId: Long)
        fun onNavigateNextRecipe(recipeId: Long)
    }

    interface Interactor {
        fun fetchRecipe(recipeId: Long): Single<Recipe>
    }

    interface Routing {
        fun navigateNextRecipe(recipeId: Long)
    }

    data class Recipe(
        id: Long,
        title: String
    )
}

さらに詳しい内容については https://www.objc.io/issues/13-architecture/viper/ 等の他記事を参照してください。 クックパッドアプリではこの VIPER を少し拡張して利用しています。具体的に拡張した箇所については後述します。

また先日サマーインターンシップでクックパッドの VIPER を題材にした技術講義を行ったので、より詳しい実装についてはこちらを参照してください。

スライド: https://speakerdeck.com/ksfee684/cookpad-summer-internship-2020-android

リポジトリ: https://github.com/cookpad/cookpad-internship-2020-summer-android

選定理由

クックパッドアプリで VIPER を採用した理由は主に3つありました。

5年先を見据えて選定

Android というプラットフォームは常に進化を続けています。プラットフォームの進化はアーキテクチャにも大きく関わり、新たな要素が使いづらいようなアーキテクチャでは継続的に開発を行うことは難しいです。実際にアーキテクチャ選定時の2017年から今までで Jetpack Compose や Kotlin Coroutines 等、Android アプリ開発において新たな要素が登場しています。こういった新たな要素を吸収することが可能であり長期的に開発を継続することが可能なアーキテクチャ、具体的には 5年 を見据えて選定を行いました。

VIPER アーキテクチャが5年耐えると判断した根拠は後述の2つの要素が中心となっています。

Contract による制約

VIPER は上述したように VIPER の各要素の内容を Contract として定義し、それに基づいて実装します。この Contract による制約は他のアーキテクチャではほとんど見られない要素であり、各要素の責務とその内容を可視化し、非常に見通しのよいコードを実現できます。

またプラットフォームの進化に合わせて VIPER の概念を拡張する場合には、この Contract を拡張すればよく、Contract でいかに定義するかを考えながらチームで議論することで、よりよいアーキテクチャを育てていける非常に拡張性の高いアーキテクチャだと判断しました。

View を中心としたイベントフロー

VIPER は View(UI) のイベントトリガーを中心にフローが構成されています。View を中心にしてフローを構築する場合、ユーザの操作仕様を直接反映するようにコードを実装する必要があります。そこで VIPER アーキテクチャに合わないような実装が必要となった場合、ユーザ体験を損ねる状態になっていると判断ができることを期待し、VIPER を選択しました。

実際にアーキテクチャに適合しないような無理のある実装があった場合には、実装やそもそもの機能仕様に問題が無いかを考えるきっかけとなっており、他のアーキテクチャではなかなか実現できなかったことだと捉えています。

2020年のクックパッドアプリのアーキテクチャ

VIPER アーキテクチャを拡張させながらクックパッドアプリに最適なアーキテクチャを今も模索しています。現在のクックパッドアプリのアーキテクチャは大雑把に以下の図のようになっていますが、その中でもアーキテクチャ導入時点からクックパッドアプリで導入した内容についていくつか紹介します。

f:id:ksfee:20201116204359p:plain

Rx によるデータフロー

VIPER の概要を説明した際に記述したように、Presenter からリクエストをうけた Interactor はデータを Presenter に返す必要があります。クックパッドではこの処理を Rx を利用してフローを構築しており、Interactor からは Observable が返ります。Presenter は受け取った Observable を subscribe し、そこで流れる Entity を View に受け渡し、UI の更新を促します。後述する Interactor から先の Domain レイヤーでも同様に Rx を利用してフローが構築されています。

最近では一部の実装で Kotlin Coroutines も利用されていますが、まだ Rx から乗り換えるという判断までは至っていません。今後 Kotlin Coroutines / Flow 等の Jetpack コンポーネントでのサポートが拡大した際には乗り換えるかもしれません(Presenter, Interactor 間のやり取りは非常に簡素なものが多いため、Coroutines への乗り換えも比較的簡単に行えるようになっています)。

Domain レイヤー

VIPER から下、具体的には Interactor から下のレイヤーについて説明します。

Interactor は Presenter からリクエストを受けた際、必要なデータを集め Presenter に返します。この時必要なデータを API や DB などから取得しますが、クックパッドアプリではここを Domain レイヤーとしてレイヤー構造を築いています。レイヤーは DataSource, DataStore, UseCase の3つからなり、それぞれ以下のように役割を分けています。

  • DataSource
    • API / DB / メモリからデータの操作を行う
    • 例: API の CRUD 操作
  • DataStore
    • 同じデータに対して複数の DataSource を参照する場合、それらの操作を抽象化して操作を行う
    • 例: API とインメモリキャッシュの操作を抽象化
  • UseCase
    • 共通化したいビジネスロジックを Interactor から切り出したもの
    • 例: 複雑な条件のダイアログ表示の判定

Interactor は DataSource, DataStore, UseCase からそれぞれ必要なデータを取得し、ビジネスロジックを構築します。Domain レイヤーとVIPERレイヤーで世界を分断することで、互いに及ぼす影響を最小限に抑えることでできるよう、Domain レイヤーで扱うデータ型と各 VIPER シーンで利用するデータ型(Entity) は異なっており、Interactor で VIPER の Entity への置き換えが行われています。

Paging の追加

2018年に発表された AAC の1つである Paging ライブラリはページング処理を RecyclerView で扱う際に非常に便利です。クックパッドアプリでもこの Paging ライブラリによるページネーションを実装していますが、Paging の DataSource (以降 PagingDataSource)をどのように実装するかが議論になりました。

PagingDataSource から返る PagedList は直接 Adapter に読み込むため、VIPER では PagedList で扱うオブジェクトは Entity である必要があります。通常であればこのような変換は Interactor で行いますが、Interactor は Presenter からのみ呼び出すことにしており PagingDataSource のどこで変換を行うかが問題になりました。

そこで Domain レイヤーから返るオブジェクトの変換と周辺ロジックをまとめて Paging という新たな VIPER の要素として定義し、ページング処理が必要となる画面はそれほど多くないため必要な画面のみ Paging を別途用意する方針としました。

今後の課題

現在のアーキテクチャは決して完璧なものではなく、開発を続けていくなかでいくつも課題は出てきます。その中でも現在進行系で直面している課題について少し紹介します。

ViewModel の位置付け

現在クックパッドアプリでは AAC の ViewModel は、Activity / Fragment でのみ利用する状態管理オブジェクトとして利用されています。先程紹介した Paging を持つ ViewModel も存在しますが、状態管理オブジェクト以上の責務を持つことはありません。

しかしただの状態管理オブジェクトとしては Android 開発において存在が大きく開発の混乱の元となってしまっており、現在アーキテクチャへ組み込む方法を検討しています。今のところは View を廃止して ViewModel に実装を寄せ、Paging を ViewModel に取り込むという意見が強く、今後議論を重ねてさらにアーキテクチャを拡張する予定です。

ボイラープレートの多さ

Contract を定義する事でコードの見通しが良くなるというメリットはありますが、その一方でVIPER は構成する要素が多く、新たに VIPER シーンを構築するために多くのファイル及び実装が必要となります。

この課題に対して AndroidStudio の LiveTemplate でファイル生成の簡略化を試みましたが、コストがかかるのは実装であり、あまりコストの軽減にはつながらずうまく行きませんでした。これはユニットテストにおいても同様のことがあり、こちらについては自動生成を行うことでコストの軽減につなげる余地がありそうなため今後検討していきたいと考えています。

まとめ

クックパッドでは今回紹介したようなアーキテクチャの改善を開発に関わる全てのメンバーで共有しながら進めています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。

https://info.cookpad.com/careers/

/* */ @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;*/ /*}*/