こんにちは。モバイル基盤部のこやまカニ大好き(id:nein37)です。
モバイル基盤部では普段CI環境の改善やアプリのビルド速度改善といったモバイルアプリを開発しやすくする様々な取り組みを行っていますが、大規模なサービス開発をサポートするため、直接プロジェクトに参加する場合もあります。
クックパッドAndroidアプリでは10月に大規模なリニューアルを行いました。 モバイル基盤部でも数カ月間このリニューアル作業に関わったので、今回は大規模プロジェクトにおけるモバイル基盤部の役割について書いてみることにします。
リニューアル前 | リニューアル後 |
---|---|
リニューアルプロジェクトの概要
3月に書かれたテストケース作成を仕様詳細化の手段とする実験という記事でも少し触れられていますが、クックパッドiOSアプリは半年ほど前に先行して同様の大規模リニューアルを行っていました。
今回のAndroidアプリのリニューアルプロジェクトは先行するiOSアプリの機能や画面構成を元にAndroidで違和感のないように再設計し、6人のAndroidアプリエンジニアを3ヶ月程度投入してプラットフォーム間の機能を揃えるというクックパッドアプリとしてはかなり大規模なプロジェクトでした。
このプロジェクトの実施は実際に機能開発を行う数ヶ月前から告知されていたので、モバイル基盤部ではプロジェクトに先行して準備期間を設定し、アプリ全体の開発効率を引き上げるための取り組みを行いました。 この記事では主にこの準備期間にモバイル基盤が行った作業について説明していきます。
なお、このリニューアルに際してアーキテクチャは大きく変更していないので、記事中に登場するVIPERアーキテクチャ関連の用語に関しては2020年のクックパッドAndroidアプリのアーキテクチャ事情を参照していただくとわかりやすいと思います。
やったこと
まず最初に、大規模リニューアルプロジェクトの実施に先駆けて、事前にやっておいたほうが良いことをissueで議論しました。
以下に出てくる内容もほとんどはこの 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
以外のViewGroup
でforeground
が正しく反映されないため、foreground
を利用してタッチフィードバック(ripple)を実装するとうまく反映されません。 - stackoverflowの類似投稿
- material-components のリポジトリにもForegroundLinearLayoutが存在しているので、他プロジェクトでも不便そうだなと思っています。
- API21, 22 では
マルチモジュール関連
Cookpad.apk #1や 去年のブログ記事でもクックパッドアプリのマルチモジュール化についてお話していますが、現在でも多くの画面実装は :legacy
モジュールという巨大なモジュールに残っている状態でした。
:legacy
モジュールがあるとついつい :legacy
に依存したモジュールを作成してしまうのですが、これだといつまでも :legacy
モジュールを無くせないので、準備期間の間に :legacy
に依存しない VIPER シーンモジュール、 :feature
モジュールを作れるように整備しました。
簡略化していますが、だいたい以下のようなモジュール依存関係になっています。
赤枠の app と書かれた部分がクックパッドアプリのアプリケーションモジュール、青枠の feature と書かれた部分がVIPERシーンで構成された :feature
モジュール、そして 緑色の library と書かれた部分がVIPERよりも低レイヤーの :library
モジュールです。
:feature
モジュールは画面機能ごとに完全に独立していますが、 :library
モジュールは共通の画面実装機能を定義する:library:ui
、画面遷移処理を定義する :library:navigation
、認証・通信機能を実装する :library:network
など役割に応じて分割され、必要に応じて :library
同士でも依存関係を持っています。
モジュール階層の整理
モジュールの依存整理と直接関係のない変更ですが、 Android Studio 3.6 (当時はまだbeta)から /library/network
のような階層化されたモジュールを正しく Project ウィンドウで扱えるようになったため、モジュールの配置を種類に応じて階層化しました。
Android Studio(Android Gradle Plugin) 更新は基本的に安定版が出るたびに随時行っていますが、 beta を先行して利用したい場合などは以下のように突然 Slack で方針を決める場合もあります。
feature モジュールで必要な機能の移動
:legacy
モジュールには CookpadMainActivity
と呼ばれる2000行程度の Activity
と CookpadMainActivity
が管理する 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
というモジュールで作成しています。
大体以下のような構造になっています。
デモアプリモジュールは 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 を再定義することにしました。
ボタン定義
クックパッドアプリでは Button
や TextView
の左端にアイコンを置くデザインをよく使っています。
Android のボタンには上下左右にアイコンを表示するための android:drawableStart属性があり、これまではクックパッドアプリでもこの属性を利用してアイコンを表示していました。
android:drawableStart
を利用した場合、以下のようにボタンの左端にアイコンが表示されます。
これまでは上記のデザインで問題なかったのですが、新しいデザインではこのアイコンを文字に揃えて中央寄せにしたいという要望がありました。
これを解決するため、 Material Components の部品である MaterialButtonを利用することにしました。
この部品は先述の android:drawableStart
とは別に app:icon属性を持っており、これによってより細かいアイコン描画の制御を行うことが出来ます。
同時に app:iconSizeによる表示サイズの制御や app:iconTintによる表示色の変更もできるようになり、より柔軟な表示ができるようになりました。
MaterialButton
はアイコン表示の他にも app:cornerRadiusや app:strokeWidthといったこれまで背景画像や Shape を利用して描画していた角丸・枠線を描画する属性も備えており、より再利用性しやすい Style を定義することが可能になりました。
実装時に遭遇した問題として、当時の MaterialButton
実装にバグが有り、android:background に drawable リソースを指定すると正しく反映されないという問題がありました。
これは簡単に回避する方法がなかったので背景色を android:backgroundTint
+ color state リソースにして解決しました。
角丸や枠線をすべて属性だけで解決できる MaterialButton
では drawable リソースを android:background
に指定するケースはほとんどないので、結果的に背景リソースがシンプルになってよかったと思います。
他にもToggleButtonが MaterialButton
と Style を共通化できなくなるなどの問題もありましたが、 ToggleButton
自体の利用箇所が少なかったため、専用の Style を定義しなおして再実装できました。
上記のような問題がありつつも無事 MaterialButton
への乗り換えができたので、 Hyperion のデバッグメニューからアクセス可能なボタンStyleのプレビュー画面を作成しました。こういった画面を作っておくとレイアウトXMLを実装サンプルとしても使えるので便利です。
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 の推奨
MaterialButton
やShapeableImageView
の利用方法について書いています
ConstraintLayout
の使い方- よく使う機能や注意点についてまとめています
- シンボルフォントの利用方法
- クックパッドアプリでは一部のアイコン表示のためにカスタムフォント(ttf)を利用しています。
- これを利用するための
CookpadSymbolSpan
というMetricAffectingSpan
とそれを参照する style を用意しているため、その利用方法について書いています。
- SampleData の利用方法
- Android Studio が提供する
@tools:sample/avatars
や@tools:sample/full_names
といったレイアウトのプレビュー実装について書いています。
- Android Studio が提供する
余談ですが、View実装ドキュメントをリポジトリに入れるPRのレビューにはクックパッドアプリだけでなくクックパッドマートアプリやcookpadLive アプリの開発者もレビューに参加してくれていて、非常に良い雰囲気のPRでした。
統一ログ基盤の準備
@giginetさんがドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築という記事で iOSアプリのログ基盤について説明してくれていますが、 リニューアルプロジェクトの実施にあたりAndroidアプリでも同様のログ基盤を整備しました。
これにより、Android アプリでも iOS と同じ定義でログを実装できるようになったため、ログの実装や確認作業がかなり楽になりました。
ふりかえり
ここまでの施策を振り返ると目的別に振り返ると大体以下のような作業を行っていました。 画面構成が大きく変わるため、特にView実装の省力化にフォーカスしていることがわかります。
- ビルド速度改善
- feature モジュール依存整理
- 画面遷移遷移再設計
- デモアプリモジュール導入
- feature モジュール依存整理
- 画面実装の省力化
- minSdkVersion 23
- Material Components 導入
- Theme/Style 整備
- 高効率な実装が可能なViewの導入
- ドキュメント整備
- 統一ログ基盤の実装
やってよかった施策
デモアプリモジュール
クックパッドアプリ全体の依存関係で見るとデモアプリモジュールというよりも:legacy
モジュールと :feature
モジュールの分離が達成できたというのが大きな成果でした。
個人的にはデモアプリモジュールは副産物としてしか見ていなかったのですが、実際にうまく活用できるケースでは実装時間や確認の手間を圧倒的に削減できたので、マルチモジュールプロジェクトでは取り組む価値はあると思います。
画面実装ドキュメント
画面実装は人によって実装方針がバラバラになりがちなので、記法方針をまとめたドキュメントがあることは実装・レビューの両方で時間の短縮に繋がり非常に良かったと思います。 リニューアルプロジェクトに向けて整備したドキュメントでしたが、今でもドキュメントを見て複雑な部分はまだ改善の余地があるということなので、今後の改善ツールとしても使っていける良い仕組みでした。
もうちょっと工夫できたなと思う施策
デモアプリモジュール
デモアプリはツールとしては非常に強力なのですが、クックパッドアプリではうまく動作させるための大量のモック実装(stub)が必要になってしまいます。
ほとんどの stub は :demo:app_base
に作成済みとはいえ、新規 :feature
モジュールとセットで demo モジュールを作る作業はかなり大変なので省力化していく必要があると感じています。
リソースの命名規則
画面実装ドキュメントに書いておけば良かった項目の一つがリソースの命名規則です。
モジュール間でリソース名の重複が置きた場合、最後に解決されたモジュールのリソースで同名リソースがすべて上書きされてしまうため、 recipe_background.xml
のようなありがちな命名をしてしまうと意図せず他の画面のデザインを壊してしまう可能性があります。
クックパッドアプリではVIPERシーンという画面ごとの区切りがあるため、これを prefix として必ず入れるルールにすべきでした。 マルチモジュール構成のプロジェクトではありがちな事故なので、みなさんも気をつけてください。
おわりに
今回は大規模リニューアルプロジェクトを控えた状態で主に画面実装の効率を改善するための取り組みについて紹介しました。 リニューアルプロジェクトの作業も面白いですがこういう効率化のための裏方の作業もまた違った面白さがあるので、大きな改修を控えている場合は検討してみるのも良いと思います。
モバイル基盤部では他のエンジニアの開発効率を引き上げられるような取り組みについて常に考えています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。