大規模プロジェクトにおけるモバイル基盤の取り組み

こんにちは。モバイル基盤部のこやまカニ大好き(id:nein37)です。

モバイル基盤部では普段CI環境の改善やアプリのビルド速度改善といったモバイルアプリを開発しやすくする様々な取り組みを行っていますが、大規模なサービス開発をサポートするため、直接プロジェクトに参加する場合もあります。

クックパッドAndroidアプリでは10月に大規模なリニューアルを行いました。 モバイル基盤部でも数カ月間このリニューアル作業に関わったので、今回は大規模プロジェクトにおけるモバイル基盤部の役割について書いてみることにします。

リニューアル前 リニューアル後
f:id:nein37:20201203195817p:plain f:id:nein37:20201203195915p:plain

リニューアルプロジェクトの概要

3月に書かれたテストケース作成を仕様詳細化の手段とする実験という記事でも少し触れられていますが、クックパッドiOSアプリは半年ほど前に先行して同様の大規模リニューアルを行っていました。

今回のAndroidアプリのリニューアルプロジェクトは先行するiOSアプリの機能や画面構成を元にAndroidで違和感のないように再設計し、6人のAndroidアプリエンジニアを3ヶ月程度投入してプラットフォーム間の機能を揃えるというクックパッドアプリとしてはかなり大規模なプロジェクトでした。

このプロジェクトの実施は実際に機能開発を行う数ヶ月前から告知されていたので、モバイル基盤部ではプロジェクトに先行して準備期間を設定し、アプリ全体の開発効率を引き上げるための取り組みを行いました。 この記事では主にこの準備期間にモバイル基盤が行った作業について説明していきます。

なお、このリニューアルに際してアーキテクチャは大きく変更していないので、記事中に登場するVIPERアーキテクチャ関連の用語に関しては2020年のクックパッドAndroidアプリのアーキテクチャ事情を参照していただくとわかりやすいと思います。

やったこと

まず最初に、大規模リニューアルプロジェクトの実施に先駆けて、事前にやっておいたほうが良いことをissueで議論しました。

f:id:nein37:20201203195951p:plain

以下に出てくる内容もほとんどはこの issue で議論されてタスクとして設定されたものです。 実際に準備期間で行わなかったことでも今後の改善内容として意識することができたので、特に大きなプロジェクトがない場合でも定期的にこういったissueを立てて議論すると良いかもしれません。

minSdkVersion 23

2月に開催された Cookpad.apk #4 で3月から minSdkVersion 23 にしますという話をしていたのですが、その後の情勢の変化により一時的に全ユーザーに人気順検索を開放することになったため、この施策で支援できるユーザーを減らしてしまう minSdkVersion の繰り上げは延期されていました。 人気順検索開放施策の終了後もしばらく minSdkVersion 21 だったのですが、今回のリニューアルプロジェクト準備施策の一環として再検討を行い、6月には minSdkVersion 23 にすることができました。

クックパッドにおけるminSdkVersion 23 にすることの利点は主に以下になります。

  • Drawable への tint 挙動を揃えることができる
    • Android では Drawable リソースをメモリに展開して使い回すようになっていますが、5.x系のOSでは tint 適用後のリソースを再利用してしまうため、 本来は tint を適用したくない箇所でも tint が適用され見た目がおかしくなる場合があります。
    • この挙動はDrawableのドキュメントNote: として書いているだけだったので当初は原因がわからず調査が大変でした。
  • android:foreground による ViewGroup へのタッチフィードバック実装
    • API21, 22 では FrameLayout 以外の ViewGroupforeground が正しく反映されないため、foreground を利用してタッチフィードバック(ripple)を実装するとうまく反映されません。
    • stackoverflowの類似投稿
    • material-components のリポジトリにもForegroundLinearLayoutが存在しているので、他プロジェクトでも不便そうだなと思っています。

マルチモジュール関連

Cookpad.apk #1去年のブログ記事 でもクックパッドアプリのマルチモジュール化についてお話していますが、現在でも多くの画面実装は :legacy モジュールという巨大なモジュールに残っている状態でした。 :legacy モジュールがあるとついつい :legacy に依存したモジュールを作成してしまうのですが、これだといつまでも :legacy モジュールを無くせないので、準備期間の間に :legacy に依存しない VIPER シーンモジュール、 :feature モジュールを作れるように整備しました。

簡略化していますが、だいたい以下のようなモジュール依存関係になっています。

f:id:nein37:20201203200009p:plain

赤枠の app と書かれた部分がクックパッドアプリのアプリケーションモジュール、青枠の feature と書かれた部分がVIPERシーンで構成された :feature モジュール、そして 緑色の library と書かれた部分がVIPERよりも低レイヤーの :library モジュールです。 :feature モジュールは画面機能ごとに完全に独立していますが、 :library モジュールは共通の画面実装機能を定義する:library:ui 、画面遷移処理を定義する :library:navigation 、認証・通信機能を実装する :library:network など役割に応じて分割され、必要に応じて :library 同士でも依存関係を持っています。

モジュール階層の整理

モジュールの依存整理と直接関係のない変更ですが、 Android Studio 3.6 (当時はまだbeta)から /library/network のような階層化されたモジュールを正しく Project ウィンドウで扱えるようになったため、モジュールの配置を種類に応じて階層化しました。

f:id:nein37:20201203200027p:plain

Android Studio(Android Gradle Plugin) 更新は基本的に安定版が出るたびに随時行っていますが、 beta を先行して利用したい場合などは以下のように突然 Slack で方針を決める場合もあります。

f:id:nein37:20201203201228p:plain

feature モジュールで必要な機能の移動

:legacy モジュールには CookpadMainActivity と呼ばれる2000行程度の ActivityCookpadMainActivity が管理する ActionBar 、サイドメニュー実装なども含まれています。 これらの機能を :feature モジュールから :legacy に依存させずに呼び出すため、 :library:ui モジュールに必要な実装を切り出しました。 その他の細かい Util 系クラスも役割に応じて :library:infra:library:navigation といったモジュールに移動させています。

分離が必要な処理は :feature モジュールを実装してみるまでわからない場合も多いので、事前準備した部分だけでなくあとから必要になって :legacy から分離した機能もかなりあります。 今後も必要に応じて素早く :legacy からの機能分離ができるようにコード理解に努めていきたいと思います。

モジュール間の画面遷移設計

クックパッドアプリでは、 ボトムタブごとにFragmentの遷移履歴を残すために Primary navigation fragment) という仕組みを利用しています。 Primary navigation fragment には長い間公式の詳しいドキュメントがなかったのですが、最近のFragmentドキュメント刷新によってわかりやすくなりました。 (Primary navigation fragment については長くなるので省略します。Navigation コンポーネントの NavHostFragment と同じようなことを自前でやっていると思ってください)

クックパッドアプリ内の画面遷移ではこの primary navigation fragment が管理している FragmentManager を利用して主に Fragment による画面遷移を行っています。 ここで問題になってくるのが遷移先 Fragment インスタンスの生成方法です。 基本的に :feature モジュール同士は画面遷移がある場合でもお互いに依存を持つことが出来ません。もし画面遷移が必要な場合にモジュール間の依存で解決しようとした場合、互いの画面を行き来するような :feature モジュールが循環参照になってしまいます。 :feature モジュール間で画面遷移を行うためには遷移先の画面が実装されたモジュールに依存しないようにしつつ、遷移先画面のインスタンスを生成しなくてはいけません。

この問題を解決するため、クックパッドアプリでは低レイヤーの :library:navigation モジュールに配置した AppFragmentFactory という interface にほぼすべての Fragment の生成メソッドを定義して抽象化しています。 AppFragmentFactory の実装はすべての :feature モジュールへの参照を持つアプリケーションモジュールで行っており、各画面が扱う画面遷移用のパラメータに関しては :library:navigation モジュール内に専用の data class を持つようにしています。

また、今回のリニューアルから結果を返す Activity への画面遷移については ActivityResultContract を利用するように変更しました。 これまでは Activity の処理結果が必要な場合も AppActivityIntentFactory という interface から Intent を返していたため startActivity()で呼び出すべきか startActivityForResult() で呼び出すべきかわかりませんでしたが、この変更によって結果を返す Activity への画面遷移は AppActivityResultContractFactory に分離することができ、画面遷移実装の難易度を少し下げられました。

画面遷移に関しては将来的には公式実装である Navigation コンポーネントに置き換えていくことになると思いますが、クックパッドアプリでは :library:navigaion モジュールの存在によって将来的に別の仕組みにも移行しやすく無理のない実装になっていると思います。

デモアプリモジュールの実装

:legacy に依存しない :feature モジュールを作成できるようになったことで、特定の :feature モジュールのみに依存するアプリモジュール、デモアプリモジュールも作成できるようになりました。 :legacy に依存していてもデモアプリモジュールを作ることはできるのですが、 :legacy への依存が入るとビルド速度がどうしても遅くなってしまうため、これまではデモアプリモジュールをあまり検討していませんでした。

デモアプリの仕組みはiOS アプリで先行してSandboxアプリとして実装されているものとほぼ同じです。 Androidプロジェクトでは demo という名前のモジュールで作られていることが多いので、クックパッドのAndroidアプリでも :demo:○○_demo というモジュールで作成しています。 大体以下のような構造になっています。

f:id:nein37:20201203200124p:plain

デモアプリモジュールは demo:app_base への依存を持ち、このモジュール内で :library:navigation:library:network 系モジュールで定義された interface の空実装(stub と呼んでいます)を定義しています。 各デモアプリモジュールは必要に応じて stub を継承し、自分が参照する :feature モジュールへの依存や特定の DataSource が返す結果など必要な処理だけを上書きしています。 デモアプリモジュールではこの仕組によってユーザー状態やネットワークレスポンスをモックすることで様々な表示テストや挙動確認を行うことができる他、巨大な legacy モジュールにも依存していないため、ビルド時間も非常に高速です。 手元の環境で同一差分を :feature モジュールに与えてビルドしてみた所、通常のクックパッドアプリでのビルドは54秒かかるのに対しデモアプリのビルドは16秒でした。

実際に今回のプロジェクトでもつくれぽ送信画面改修時に demo:tsukurepo_demo モジュールでビルドされたアプリが非常に活躍しました。 demo:tsukurepo_demo は画像選択を行う Activity への遷移処理をモックして固定の画像を返す機能をもっているため、画像の複数枚選択時の挙動を簡単に試すことができます。 以下のアニメーションがデモアプリで画像選択機能をモックして固定の画像を返すようにしているときの動作です。

デモアプリと直接関係のない変更でもデモアプリモジュールが依存している interface を編集するたびに stub の修正が必要になってしまうという欠点はありますが、デモアプリがうまく利用できる場面では開発効率が非常に良くなるため今後もデモアプリの運用を改善していく予定です。

スタイル再定義

これまでクックパッドアプリでは2016年頃に定義したスタイルやThemeを少しずつメンテナンスしながら使っていました。 2016年から現在までデザインの大きな変更がなかったため、アプリ全体の Theme/Style も当時のまま AppCompat をベースにしたものを利用していましたが、今回のリニューアルにより Material Components を利用したほうが効率的に実装できる箇所が増えたたため、 Theme.MaterialComponents.* ベースで Theme/Style を再定義することにしました。

ボタン定義

クックパッドアプリでは ButtonTextView の左端にアイコンを置くデザインをよく使っています。 Android のボタンには上下左右にアイコンを表示するための android:drawableStart 属性があり、これまではクックパッドアプリでもこの属性を利用してアイコンを表示していました。 android:drawableStart を利用した場合、以下のようにボタンの左端にアイコンが表示されます。

f:id:nein37:20201203200137p:plain

これまでは上記のデザインで問題なかったのですが、新しいデザインではこのアイコンを文字に揃えて中央寄せにしたいという要望がありました。

f:id:nein37:20201203200154p:plain

これを解決するため、 Material Components の部品である MaterialButton を利用することにしました。 この部品は先述の android:drawableStart とは別に app:icon 属性を持っており、これによってより細かいアイコン描画の制御を行うことが出来ます。 同時に app:iconSize による表示サイズの制御や app:iconTint による表示色の変更もできるようになり、より柔軟な表示ができるようになりました。

MaterialButton はアイコン表示の他にも app:cornerRadiusapp:strokeWidth といったこれまで背景画像や Shape を利用して描画していた角丸・枠線を描画する属性も備えており、より再利用性しやすい Style を定義することが可能になりました。

実装時に遭遇した問題として、当時の MaterialButton 実装にバグが有り、android:background に drawable リソースを指定すると正しく反映されないという問題がありました。 これは簡単に回避する方法がなかったので背景色を android:backgroundTint + color state リソースにして解決しました。 角丸や枠線をすべて属性だけで解決できる MaterialButton では drawable リソースを android:background に指定するケースはほとんどないので、結果的に背景リソースがシンプルになってよかったと思います。

他にもToggleButtonMaterialButton と Style を共通化できなくなるなどの問題もありましたが、 ToggleButton 自体の利用箇所が少なかったため、専用の Style を定義しなおして再実装できました。

上記のような問題がありつつも無事 MaterialButton への乗り換えができたので、 Hyperion のデバッグメニューからアクセス可能なボタンStyleのプレビュー画面を作成しました。こういった画面を作っておくとレイアウトXMLを実装サンプルとしても使えるので便利です。

f:id:nein37:20201203200107p:plain:w320

MaterialTheme の導入

MaterialButton を利用することにしました」とさらっと書きましたが、MaterialButtonはアプリの Theme が Theme.MaterialComponents.* を継承している場合しかうまく動作しません。 そのため、アプリの Theme にも手を入れる必要があります。この作業は本当に大変でした。

クックパッドアプリはこれまで Theme.MaterialComponents.Light を継承していましたが、基本的なボタンなどの Style などは整備されており、その中で StateListDrawable による背景色切り替えをタッチフィードバックとして利用していました。 長い間、 colorPrimary すら定義されない状態のまま長年運用してきていたのです。

しかし、 Theme.MaterialComponents.* ベースのアプリではそういうわけにはいきません。 colorPrimary 未指定でも色々な箇所にリップルエフェクトがかかり、謎の紫色の tint が適用されます。デフォルトカラーなのかなんなのかわかりませんが、クックパッドアプリが部分的に紫色になってしまうのです。 これを直すために theme の color* 系属性を指定し、いろいろな View のデフォルト style を整備し、実装のよくないレイアウトファイルを直しました。 おそらくすべて直せたと思っていますが、もしクックパッドアプリに変な紫のボタンやタッチフィードバックを見かけたら、それは僕の実装漏れです。こっそり教えて下さい。

幸いなことに Material Components の各属性の定義ドキュメントは本当にしっかりしているので、慣れると短期間で色々な箇所を実装できるようになりました。 後述する MaterialCardView など非常に素晴らしいView実装もあるため、これまでの AppCompat ベースの実装よりも実装効率が良いと思います。 ボタン Style の整備も含めて Material Components の完全導入には2週間以上掛かっていますが、これはやっておいて良かった変更でした。

もしまだ AppCompat ベースの theme を利用しているプロジェクトがあれば Material Components への切り替えをおすすめします。

MaterialCardView

Material Components を導入し、 Theme.MaterialComponents.* に切り替えたおかげで MaterialCardView が利用できるようになりました。 このViewは本当に便利で、これまで複雑なViewを組んだり shape drawable + clipToOutline を用意して実現していたことを View 階層ひとつで解決してくれます。

  • 角丸がつけられる
    • これは普通の CardView でも実現できました
    • 内部のViewを自動的に切り取ってくれるので Glide での角丸処理などが不要で便利になりました
  • 枠線がつけられる
    • 角丸+枠線がこれひとつで出来ます。便利
  • ドキュメントから属性が探しやすい
    • Material Components の部品はすべてそうですが、実装例と属性が詳しく書いてあるので非常に実装しやすいです
    • 標準View の属性も Android Developers を見れば書いてありますが、あまりわかりやすくなかったのでこれは嬉しい変更です

たいていのレイアウトは MaterialCardView + ConstraintLayout で組めるので本当に便利になりました。

ShapeableImageView

ShapeableImageViewも Material Components を導入したおかげで使えるようになったView要素です。 Shape による画像の切り抜きや枠線をつけることができる ImageView で、これまで Glide でやっていた処理をレイアウト側の定義だけで行えるようになりました。

画面実装ドキュメント整備

Material Components の導入による画面実装の変化やリニューアル実施前の相談によって決まった画面実装方針についてドキュメントをまとめました。 今回のリニューアルプロジェクトではAndroidアプリをこれまで開発していなかったメンバーも開発に参加することになったため、初学者にもわかりやすい内容と公式へのリンクをまとめました。 この内容については吉田さんが後日techlifeに記事を書いてくれる予定なので、主な内容だけ列挙しておきます。

  • ViewBinding の利用
    • 時期的にまだ Kotlin View Binding のサポート終了は告知されていませんでしたが、対象レイアウトファイルの取り違えが起きやすい等の問題があったため ViewBinding の利用を推奨していました
    • クックパッドアプリでは主に学習コストの問題から DataBinding はほとんど利用していません
  • Material Components の推奨
    • MaterialButtonShapeableImageView の利用方法について書いています
  • ConstraintLayout の使い方
    • よく使う機能や注意点についてまとめています
  • シンボルフォントの利用方法
    • クックパッドアプリでは一部のアイコン表示のためにカスタムフォント(ttf)を利用しています。
    • これを利用するための CookpadSymbolSpan という MetricAffectingSpan とそれを参照する style を用意しているため、その利用方法について書いています。
  • SampleData の利用方法

余談ですが、View実装ドキュメントをリポジトリに入れるPRのレビューにはクックパッドアプリだけでなくクックパッドマートアプリcookpadLive アプリの開発者もレビューに参加してくれていて、非常に良い雰囲気のPRでした。

統一ログ基盤の準備

@giginet さんがドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築という記事で iOSアプリのログ基盤について説明してくれていますが、 リニューアルプロジェクトの実施にあたりAndroidアプリでも同様のログ基盤を整備しました。

これにより、Android アプリでも iOS と同じ定義でログを実装できるようになったため、ログの実装や確認作業がかなり楽になりました。

ふりかえり

ここまでの施策を振り返ると目的別に振り返ると大体以下のような作業を行っていました。 画面構成が大きく変わるため、特にView実装の省力化にフォーカスしていることがわかります。

  • ビルド速度改善
    • feature モジュール依存整理
      • 画面遷移遷移再設計
    • デモアプリモジュール導入
  • 画面実装の省力化
    • minSdkVersion 23
    • Material Components 導入
      • Theme/Style 整備
      • 高効率な実装が可能なViewの導入
    • ドキュメント整備
  • 統一ログ基盤の実装

やってよかった施策

デモアプリモジュール

クックパッドアプリ全体の依存関係で見るとデモアプリモジュールというよりも:legacy モジュールと :feature モジュールの分離が達成できたというのが大きな成果でした。 個人的にはデモアプリモジュールは副産物としてしか見ていなかったのですが、実際にうまく活用できるケースでは実装時間や確認の手間を圧倒的に削減できたので、マルチモジュールプロジェクトでは取り組む価値はあると思います。

画面実装ドキュメント

画面実装は人によって実装方針がバラバラになりがちなので、記法方針をまとめたドキュメントがあることは実装・レビューの両方で時間の短縮に繋がり非常に良かったと思います。 リニューアルプロジェクトに向けて整備したドキュメントでしたが、今でもドキュメントを見て複雑な部分はまだ改善の余地があるということなので、今後の改善ツールとしても使っていける良い仕組みでした。

もうちょっと工夫できたなと思う施策

デモアプリモジュール

デモアプリはツールとしては非常に強力なのですが、クックパッドアプリではうまく動作させるための大量のモック実装(stub)が必要になってしまいます。 ほとんどの stub は :demo:app_base に作成済みとはいえ、新規 :feature モジュールとセットで demo モジュールを作る作業はかなり大変なので省力化していく必要があると感じています。

リソースの命名規則

画面実装ドキュメントに書いておけば良かった項目の一つがリソースの命名規則です。 モジュール間でリソース名の重複が置きた場合、最後に解決されたモジュールのリソースで同名リソースがすべて上書きされてしまうため、 recipe_background.xml のようなありがちな命名をしてしまうと意図せず他の画面のデザインを壊してしまう可能性があります。

クックパッドアプリではVIPERシーンという画面ごとの区切りがあるため、これを prefix として必ず入れるルールにすべきでした。 マルチモジュール構成のプロジェクトではありがちな事故なので、みなさんも気をつけてください。

おわりに

今回は大規模リニューアルプロジェクトを控えた状態で主に画面実装の効率を改善するための取り組みについて紹介しました。 リニューアルプロジェクトの作業も面白いですがこういう効率化のための裏方の作業もまた違った面白さがあるので、大きな改修を控えている場合は検討してみるのも良いと思います。

モバイル基盤部では他のエンジニアの開発効率を引き上げられるような取り組みについて常に考えています。こういった開発スタイルに興味がある 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;*/ /*}*/