Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。

Ruby 3 は「静的型解析」を備えることが目標の 1 つになっています。遠藤が開発してる TypeProf は Ruby 3 の静的型解析エコシステムの中の 1 ツールです。しかし Ruby 3 の静的解析というと、RBS、TypeProf、Steep、Sorbet などいろいろなツール名が出てきてよくわからない、という声を何回か聞いたので、かんたんにまとめておきます。

3 行まとめ

  • RBS:Ruby の型情報を扱う言語。Ruby 3 にバンドルされる。
  • TypeProf:型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。
  • Steep/Sorbet:Ruby で静的型付けのプログラミングができるツール。

詳しくはそれぞれ以下で解説します。

RBS とは

RBS は、Ruby 3 の型を扱うための基盤です。おおよそ、次の 4 種類のものからなります。

  • RBS 言語:Ruby プログラムの型情報を記述するための記法(拡張子 .rbs)
  • Ruby 組み込みライブラリの型情報:Ruby の組み込みクラス(ArrayStringなど)の型情報を書いた.rbsファイル群
  • RBS ライブラリ:.rbs ファイルのパースや解析などをするライブラリ
  • rbs コマンド:.rbs ファイルを扱うための便利コマンド

いろいろありますが、Ruby プログラマが直接意識するのは 1 つめの RBS 言語だけだと思います *1 。よって、単に RBS と言ったら「RBS 言語」、または「RBS 言語で書かれたソースコード」を指すと考えるのがよいと思います。

RBS 言語の例を示しておきます(core/string.rbs よりものすごく抜粋)。Ruby っぽいですが、Ruby ではない別の言語になっています。

class String
  def empty?: () -> bool
end

この記述は、Ruby の組み込みクラスである String クラスの型情報(RBS言語で書かれている)の抜粋で、empty? という無引数のメソッドを持っていて、bool を返すということを表しています。 RBS ライブラリを使うと、こういう .rbs ファイルをパースして抽象構文木を得ることができます。 rbs コマンドは、.rbs ファイルを読んでメソッドを検索するなどができます。

RBS はそれ単体で何かをするものではなく *2 、Ruby 3 の型情報を扱うツールが共通で使いたくなるものを集めた gem になっています。この gem は Ruby 3 に同梱されます。しかし基本的には型解析ツール向けの gem であり、普通の Ruby プログラマは RBS 言語を読み書きすることはあっても、RBS gem を直接使うことはあまりないと思います。

TypeProf とは

TypeProf は、型注釈のない Ruby コードを無理やり解析する静的型解析器です。Ruby 3 にバンドルされます。TypeProf は RBS 基盤を活用して作られています。

TypeProf の特徴はなんといっても、「型注釈を書かなくてもなんとなく型解析っぽいことができる」という性質に極振りして設計されているところです。 キーポイントは、メソッド呼び出しの情報を活用して解析するところです。 これにより、たとえばdef hello(user) ... endという型注釈が一切ないメソッドに対しても、hello(User.new)という呼び出しがあれば「メソッドhelloUserインスタンスを引数に取る」ということを推論します。

また、一部のクラスに RBS 言語で型情報を書いて TypeProf に与えることもできます。TypeProf はユーザが明示した型情報を無条件に信用するので、解析精度や解析速度が向上します。

TypeProf について詳しくは別の記事で解説しています。

techlife.cookpad.com

Steep/Sorbet とは

Steep は、Ruby の静的型検査器です。RBS を使って、伝統的な漸進的型付けによる型検査を行うことができます。 単に型エラーを検出できるだけではなく、LSP を実装しているので、エディタ上での型エラー表示、補完、ドキュメント表示なども実装されています。 現状で RBS を使って便利さを実感できるのは、Steep だけです。 このへんがわかりやすい記事にリンクしておきます。

qiita.com

Sorbet は、また別の Ruby の静的型検査器です。 こちらは RBS ではなく RBI という独自形式の型注釈を使います(RBS から RBI への変換器も開発されています)。 ものすごくざっくり言ってしまうと、できることは Steep とおおよそ同じです。 とはいえ、Stripe や Shopify という大企業ですでに数年ほど経験を積んでいるので、完成度はとても高いです。 解析器はC++で書かれていて、解析速度をものすごく重視しています。

Steep も Sorbet も Ruby 3 にバンドルされる予定はありません。 Ruby の設計者である matz が、「型注釈を書くことを Ruby本体として推進しない」と判断した結果です *3 。この判断と相性の良い TypeProf は将来の期待とともに Ruby 3 にバンドルされますが、型注釈を書くことをいとわない人は Steep や Sorbet を使うとよいと思います。

なお、Steep 自体はバンドルこそされませんが、RBS はもともと Steep の型注釈言語でした。 Ruby 組み込みライブラリや gem の型情報を各種ツール間で共通化したかったので、RBS という形で共通基盤として切り離され、Ruby本体に同梱されることになりました。

再度まとめ

  • RBS: Ruby 3 の型情報を扱う言語を始めとする基盤。Ruby 3 にバンドルされる。
  • TypeProf: 型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。現状の主機能は Ruby コードからの RBS スタブ生成。
  • Steep/Sorbet: Ruby の静的型検査器。型注釈を書く必要はあるが、Ruby で静的型の便利なプログラミング体験ができる。IDE での補完やドキュメント表示も。

*1:厳密に言うと、rbs コマンドは触ることもあるかも。

*2:厳密に言うと、単体でも動的型検査機能が使えます。

*3:これについてはいろいろな意見があると思いますが、個人的にはそういう言語も面白いと思っていて、そのために苦しみながら TypeProf を開発しています。

Ruby 3 の静的解析ツール TypeProf の使い方

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。昨日、Ruby 3.0.0-preview2がリリースされました!

このリリースには、遠藤が開発している Ruby の静的型解析ツール TypeProf が初めて同梱されています。これの使い方をかんたんにご紹介したいと思います。

デモ

TypeProf は、型注釈のない Ruby コードを無理やり型解析するツールです。とりあえずデモ。

# user.rb
class User
  def initialize(name:, age:)
    @name = name
    @age = age
  end

  attr_reader :name, :age
end

User.new(name: "John", age: 20)

typeprof コマンドは、Ruby 2.7 で gem install typeprof でインストールできます *1 。TypeProf にこの Ruby コードを与え、typeprof user.rb -o user.rbs と実行してください。次のような内容の user.rbs が生成されているはずです。

# Classes
class User
  attr_reader name: String
  attr_reader age: Integer
  def initialize: (name: String, age: Integer) -> Integer
end

TypeProf は、与えられた Ruby コードの型情報を推定して出力します。user.rbs は、Ruby ではなく、RBS という Ruby 3 標準の型情報記述の言語で書かれています。雰囲気でなんとなく読めるかと思いますが、たとえば def initialize: (name: String, age: Integer) -> Integername というキーワード引数が String インスタンスを受け取り、age というキーワード引数が Integer インスタンスを受け取る、ということを表現しています。返り値は Integer ですが、initialize の返り値はあまり意味がないですね。

TypeProf の特徴は、メソッド呼び出しの情報をフル活用するところです。これは従来のふつうの型解析と本質的に異なるところです。これにより、def initialize(name:, age:) ... end という型注釈が一切ない定義に対しても、User.new(name: "John", age: 20) という呼び出しで渡される型を見てそれっぽい型情報を推定します。

もうひとつデモ

TypeProf は、Ruby コードだけでなく RBS も合わせて解析できます。デモ。

# test.rb
def hello_message(user)
  "The name is " + user.name
end

def type_error_demo(user)
  "The age is " + user.age
end

user = User.new(name: "John", age: 20)

hello_message(user)
type_error_demo(user)
# user.rbs
class User
  def initialize: (name: String, age: Integer) -> void

  attr_reader name: String
  attr_reader age: Integer
end

user.rbs は、前の例の出力を少しだけ手修正したものです。これと test.rb をあわせて解析します。 typeprof -v test.rb user.rbs と実行してください。

# Errors
test.rb:7: [error] failed to resolve overload: String#+

# Classes
class Object
  private
  def hello_message: (User) -> String
  def type_error_demo: (User) -> untyped
end

コマンドライン引数の -v はエラーの可能性を表示させるオプションです。 このため、今度は # Errors という出力があります。 test.rb の 7 行目を見てみると、"The age is " + user.age という計算をしていますが、これは StringInteger を結合しようとしています。 TypeProf はこれをバグとして警告しています。

class Object から end は、先程と同様に test.rb の型情報を推定したものです。 なお、7 行目に型エラーの可能性があって型の追跡ができなくなったため、type_error_demo メソッドの返り値は untyped となってます。

Ruby TypeProf Playground

TypeProf は Ruby TypeProf Playground でブラウザ上で試せます *2

左上が Ruby コード、左下が RBS(書かなくても良い)で、Analyze ボタンを押すと右側に解析結果が表示されます。 Ruby コードをいじって解析することもできるので、期待に反する挙動を見つけたら Report bug ボタンでぜひ報告してください *3

TypeProf の課題と現状

TypeProf は「型注釈を書かない選択肢を Ruby に残す」ということを至上命題とした極端な設計になっているので、様々な問題点もあります。

  • とにかく解析が遅い *4
  • まともな解析のためにはテストコードが必要 *5
  • 現時点では解析精度が低く、誤検知や見逃しがとても多い
  • 手本になる前例がなく、TypeProf 自体の設計に試行錯誤が必要

そのため Ruby 3.0 の TypeProf では、型注釈のない Ruby コードに対して RBS スタブを生成する、という機能にフォーカスして設計・実装を進めました *6 。 RBS スタブ生成機能として経験を積みながら、高速化や解析精度向上を進め、将来的にはかんたんな型検査器として使えるものにできたらいいなと思っています。

まとめ

Ruby 3 に同梱される型解析ツール TypeProf をご紹介しました。かんたんな使い方にフォーカスして書いたので、もう少し詳しいことはドキュメントや過去の発表をご参照ください。

github.com

rubykaigi.org

TypeProf の現状の完成度としては、Ruby パッケージに含まれるすべての .rb ファイルで解析が通る *7 ことを確認できた程度です。出力の精度評価や速度向上はまだまだこれから頑張っていきます。ぜひ遊んでみて、気づいたことがあったらバグ報告でも感想でもいただけると泣いて喜びます。

RBS と TypeProf の関係は? Steep や Sorbet というのも聞いたが?

Ruby 3 の静的解析は固有名詞が多くてややこしいので、関係を別記事にまとめました。

techlife.cookpad.com

*1:ruby 3.0.0-preview2 なら gem install なしで typeprof コマンドが利用可能です。

*2:社内で「こういうのを作りたい」と語ったら、id:koba789 さんが 1 時間で作ってくれました。

*3:とても適当に運用しているので、サーバが落ちたらごめんなさい。

*4:ふつうの型解析は基本的にメソッド単位で解析を行いますが、TypeProf は呼び出し元をたどる必要があるため、大変になりがちです。なお、RBS 言語を部分的に手書きすればするほど(理論上は)早くなります。

*5:解析のカバレッジを上げるため、スタブ実行というヒューリスティクスを実装しています。これは、どこからも呼ばれなかったメソッドに untyped な引数を与えて無理やり呼び出すものです。これにより、テストがなくても一通りの解析は行えるようになっています。

*6:なお、rbs コマンドにも Ruby コードから RBS スタブを生成する機能がありますが、こちらは基本的にすべての引数を untyped として出力するものです。やることが単純な分、速くて安定しているというメリットもあります。

*7:TypeProf が理不尽に例外終了しないことを確かめた程度ですが、やんちゃなコードだらけの test/ruby や ruby/spec はそれだけでも地獄の苦しみでした。

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

こんにちは。モバイル基盤部のこやまカニ大好き(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/