Android TVアプリの自動化されたテストの小話

技術部の松尾(@Kazu_cocoa)です。

クックパッドでは、2年程前からAndroid TVに対してアプリをリリースしています。以前、Cookpad Android TV Appのデザインで考えたことにて触れられたこともあります。

f:id:kazucocoa:20170622175302p:plain

みなさんがGoogle Play Storeからダウンロードするクックパッドアプリには、1つのバイナリ(または apk)にスマートフォン/タブレット向けの実装とAndroid TV向けの実装が含まれています。そのため、スマートフォン/タブレット向けのクックパッドアプリのリリースサイクルと同じ周期でAndroid TV向けのアプリも更新されています。

1つのパッケージに全てのプラットフォームの実装を含めることで、どのプラットフォームにおいてもユーザーはただひとつのクックパッドアプリを探してインストールすれば良くなります。開発者側としても、パッケージ管理が煩雑にならずに済むという利点があります。一方で、例えばスマートフォン/タブレット向けの対応だと思っていたものや、共通して利用しているライブラリの更新などによりTV向けの機能が意図せず壊れる可能性があります。(ここはトレードオフになりますね)

クックパッドでは、そのような破壊が含まれないように、スマートフォン/タブレット同様、自動化されたいくつかのテストを実施しています。Android TV向けに関しては、日頃行うリリースフローの中では人間の手による確認は不要となっています。

この記事では、そのようなAndroid TV向けの、少しニッチな世界のテストコードのお話をします。

アプリの変更頻度

Android TV向けのUI変更を含んだ機能開発は、ここ1年以上の間、スマートフォン/タブレットに比べてほとんどありません。また、アプリの画面遷移数や機能も、スマートフォン/タブレット版と比べるとはるかに少ないです。そのため、リリース頻度はそれなりにあるが、リリース毎の変更はない状態を保っていました。

不具合の発見

ここ半年程度を振り返ると、2回ほど、スマートフォン/タブレット側の修正に影響され、Android TVでクックパッドアプリを操作した時にクラッシュを引き起こす不具合が混入していることがありました。1つは画像ライブラリに関係するもの、もう1つは不要な画像リソースを減らす時の対応漏れです。それらは、あらかじめ用意していた自動化されたテストだけで検出されています。

社内のエンジニアの多くは、通常はスマートフォン/タブレットを使い開発していますし、全ての開発においてほとんど影響のないAndroid TVの確認も要求することは効率的ではありません。(Android TV向けの多くのコードは分離されているため)そのため、あらかじめ想定していた戦略に沿って、期待したタイミングでちゃんと不具合を見つけることができました。

なお、そのテストコードのメンテナンスコストを考える方もいるかと思いますが、Android TV向けのテストシナリオだけを必要に迫られて修正した回数は2年間で1回です。他にはEspresso全体で共通して使ってるメソッドの置換などです。(例えば以下を見ると、fixと修正しているのは1年程度前のものだけ)

f:id:kazucocoa:20170622175321p:plain

このように、シェアが低い・対応優先度が低いものに対してテスト実行する量を減らすではなく、自動化に倒してリグレッションテストとして不具合をリリース前に検出できる形にしていました。

自動化されたテストの種類

ここからは、少し具体例を混ぜながらどのようなテストコードを書いているのかを載せていきます。以下ではテストサイズ の区分を元に言葉を使っています。合わせて軽く補足を足しますが、もう少し区分を把握したい方は先ほどのリンク先を参照ください。

Sサイズ/Mサイズのテスト

広く単体テストと呼ばれるような粒度の自動テストです。これらは、スマートフォン/タブレット側と共通して使っているもの以外はほとんど書かれていません。これは、Android TV自体がログイン機能を持たないなど、最小限の機能だけを持っているため、複雑な内部ロジックを持たないビューアになっていたためです。そのため、このサイズではあまり多くをカバーせず、後述する範囲で必要なぶんだけの領域をカバーしています。

Lサイズの単体・シナリオテスト

UIの単体テストとして、もしくは一連の短い画面遷移ベースのシナリオをもとに各画面の確認をLサイズのテストとして実施しています。ここではEspressoベースのテストコードに書き上げています。

キーマッピング定義

Android TVではリモコンの上下・左右などに対して以下のようにKeyEventがあり当てられていました。そのため、Android TVの文脈における用語の対応を用意し、Espressoのシナリオを記述するときに表現が実際のTVの操作に近づくようにしました。

これは、シナリオコードの可読性をあげるためのちょっとした工夫ですね。

public enum Keys {
    TV_UP(KeyEvent.KEYCODE_DPAD_UP),
    TV_LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
    TV_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT),
    TV_DOWN(KeyEvent.KEYCODE_DPAD_DOWN),
    TV_ENTER(KeyEvent.KEYCODE_DPAD_CENTER),
    TV_BACK(KeyEvent.KEYCODE_BACK),
    TV_HOME(KeyEvent.KEYCODE_HOME),
    DEVICE_BACK(KeyEvent.KEYCODE_BACK);

    private final int keyCode;

    Keys(int pKeyCode) {
        this.keyCode = pKeyCode;
    }

    public ViewAction press() {
        return pressKey(keyCode);
    }
}

また、Android TVではその特性上、特定の要素に対してスマートフォンなどでいう タップ 操作がありません。基本的にはKeyEventを繰り返し入力することでカーソルを移動させたり、決定したりする必要があります。そのため、例えば以下のようにViewを特定するための一連の操作を1つのメソッドにまとめて記述し、確認したいシナリオに対してノイズになるような表現を減らしたりもしました。

    private ViewInteraction onRelatedRecipeCardOnMainActivity() {
        return onView(isRoot())
                .perform(Keys.TV_DOWN.press())
                .perform(Keys.TV_DOWN.press())
                .perform(Keys.TV_DOWN.press())
                .perform(Keys.TV_DOWN.press())
                .perform(Keys.TV_DOWN.press());
    }

Eテストとアプリの更新テスト

アプリ更新時に何らかのマイグレーションなどの処理が実行されたりする場合や、ローカルデータの不整合などに出くわすとアプリが更新直後にクラッシュしたり、次回以降の起動に失敗するなど発生することがあります。 そのため、Android TVに対しても、すでにスマートフォン/タブレットに対して行なっている自動化されたUIテストの中から1つ前のアプリバージョンからのアプリ更新の確認を自動化し、検証されるようにしています。Android TVはネットワークに接続されていればほぼ強制的にアプリの更新が実行されるため、スマートフォン/タブレットでは過去7バージョンに対する更新確認を行っている一方で、1つ前の公開されているバージョンのみの確認にとどめています。

ここで行っているバージョンアップの確認は非常に簡単で、以下のコマンドのように1つ前のバージョンがローカルに保存したデータを保持したまま、新しいバージョンに更新するというものです。マイグレーション処理などがうまくいかないなどあれば、この更新した後のアプリ起動の時に処理がおかしくなります。

# 1つ古いバージョンのアプリをインストールする
$ adb shell install com.example.app
$ adb shell am start -n com.example.app/.Main

# 新しい、テストしたいアプリをインストールする
$ adb shell install -r com.example.app
$ adb shell am start -n com.example.app/.Main

まとめ

少しニッチな話題として、Android TVにおける自動化されたテスト環境、それらがどの程度のメンテナンスコストで行われているのかを書きました。このように、大きく機能追加はないが継続してリリースする必要のある機能に対しては自動化されたテストは非常に効率的に機能します。そんな状態になっているアプリの一例でした。

TVに関するとちょっとしたよもやま話

最後に、ちょっとしたAndroid TVに関わる知見を共有しておきます。UI_MODE_TYPE_TELEVISIONの値に関してです。UI_MODE_TYPE_TELEVISION でAndroid TVかどうか判定する場合、実はAndroid TV ではない 4.x 系のセットトップボックス端末とかが引っかかることがあります。そのため、UI_MODE_TYPE_TELEVISIONをもつがAndroid TVではない環境下で、Android TV向けのAPIを呼んでしまった場合、アプリがクラッシュしてしまいます。国内ではいくつかこの状態になる端末が存在するらしく、私たちも再現するまで気づかなかったのですがこのような状態になるようです。(ただ、 UI_MODE_TYPE_TELEVISION による判定はGoogle公式にも書かれている方法ではあるのですが)

Kuroko2の近況

技術部開発基盤グループの大石です。

先日、弊社主催のイベント CookpadTechKitchen#8 〜舞台裏を支える黒衣たち〜 にて、「Kuroko2の近況とクックパッドのバッチ周りの概況」というテーマで発表させて頂きました。今回はこの発表内容の中でも Kuroko2 についてピックアップして紹介したいと思います (今回の記事ではクックパッドのバッチの概況については特に触れませんが下記資料を参照ください)。

Kuroko2 とは

Kuroko2とは、Ruby製のWebベースのジョブスケジューラーです。2014年にクックパッド社内で開発され、2016年の秋にオープンソースとして公開しました

詳細については、当ブログの クックパッドのジョブ管理システム Kuroko2 の紹介Kuroko2 リポジトリのドキュメント をご覧ください。

また、Kuroko2 のオリジナル作者である弊社高井の社内用のLT資料 The Design Philosophy of Kuroko2 が公開されており、kuroko1 の事例を元に Kuroko2 が採用しているアーキテクチャの背景を知ることができます。 Kuroko2 の内部についても説明されているため、Kuroko2 へコントリビュートする際にとても参考になる良い資料だと思います。

今回はこれらの資料にある背景に加えて Kuroko2 をOSS化するにあたって意識していた Kuroko2 の方針、そして実際に Kuroko2 をカスタマイズする方法を紹介したいと思います。

Kuroko2の方針

ジョブ管理、ワークフロー管理への要求はトレンドや新たな技術の登場によって細かな要求が今後も変わってくることが予想されます。 そのため、Kuroko2 はメンテナンスや拡張が行いやすいシンプルな設計を保つこと、様々な現場にあわせた拡張性を担保するために本体はシンプルに保っていくべきだと考えています。

そのために以下を方針として意識しました。

1. 現実的な運用を見据えた安定した設計を保つ

The Design Philosophy of Kuroko2 p.19 にある通り、Kuroko2 は実際にクックパッドのバッチ運用を通して現実的な現場を意識して作られたものです。 闇雲に最先端の技術を採用するのではなく、あえて管理のしやすさを優先して枯れたアーキテクチャを採用し、安定した運用ができるような設計を保っていくべきだと考えています。

2. 煩雑になりがちなバッチジョブ管理の問題をUIで解決する

Kuroko2 の特徴として、煩雑になりがちなバッチ管理の方法をUIがフレームワーク的にアシストしていることが挙げられます。 具体的には、ジョブ定義の際にジョブの説明を書くためのテンプレートが用意されていたり、ジョブのお気に入り機能、自分の管理するジョブが一覧で見れるダッシュボードなどクックパッドでの運用を通して必要とされたものが実装されています。

この点は Kuroko2 の良い点であり今後も重点的に改善を進めていくべき点だと思っています。ただし、当初よりフロントエンドのアーキテクチャが古くなっており、その改良は課題であると思っています。

3. スケジューラーと汎用性の高いワークフロー管理に徹する

Kuroko2 のコアになる役割は、スケジューラーとワークフロー管理に限定し、新しい役割を定義はしないようにします。 また本体にあるタスク(docs/task.md) は汎用性の高いものだけを定義し、Kuroko2 が利用されているドメインに特化したものはカスタムタスクを利用することで柔軟性を確保するようにします。

4. うまく外の機構と連携する

ワーカーが実行する処理については、現在 command-executor からシェル経由でコマンドが実行されるのみです。この部分についても特定の機構に依存するものを本体にいれるべきではなく、うまく外の機構と連携できるように目指すべきだと考えています。 弊社が採用している例として、DWHに関連する処理についてはSQLバッチフレームワークである Bricolage を利用し、AWS ECS タスクの実行は Hako を利用することでそれぞれのドメインに合わせた連携を行っています。

ただしこの点において、シェル経由は起動コストが高いという問題があり、高頻度のジョブなどで問題が出ているため command-executor と kuroko2 console との間の連携の抽象化をしてもう少し効率的な連携が行えるような改善の必要性があると認識しています。

5. 必要な部分だけに開かれた拡張性

Kuroko2 は Rails の Mountable Engine になっており、それを gem として配布しています。 この方法を採用した理由は、Kuroko2本体の機能は常にアップデート可能なように管理できて、かつ必要に応じてカスタマイズしたかったためです。 具体的には、先述したカスタムタスクの定義や、後述する Kuroko2::ApplicationController を拡張という機能を想定しています。

Kuroko2 はバッチを管理する上で生じるドメインに特化した細かなカスタマイズを行えるようにして、様々な現場に合わせるための拡張性を適切に提供できるようにしたいと考えています。

Kuroko2 を拡張してみる

それでは、先述したカスタムタスクと Kuroko2::ApplicationController への拡張の具体的な方法のサンプルを紹介したいと思います。

カスタムタスク

まず kuroko2 gem をマウントしているRailsアプリケーション内にカスタムタスクを置く場所を作ります (後述する kuroko2.yml の設定でnamespaceは任意に分けることはできますが、今回は簡単のため Kuroko2::Workflow::Task 以下にしています)。

$ cd your_kurko2_rails_apps/
$ mkdir -p lib/kuroko2/workflow/task/

次にカスタムタスク本体のコードを上記のディレクトリ以下に置きます。

module Kuroko2
  module Workflow
    module Task
      class MyProjectRunner < Execute
        def chdir
          '/home/alice/my_project'
        end

        def shell
          "./bin/rails runner -e production #{Shellwords.escape(option)}"
        end
      end
    end
  end
end

この例では、ワーカーに設置された任意のRailsアプリケーションの中で option で渡された任意のコードを実行できます。

このカスタムタスクを kuroko2.yml で設定して、利用可能な状態にします。

....
  custom_tasks:
    my_project_runner: MyProjectRunner
....

以上の設定で、kuroko2 script 内で

 env: VAL1=A
 env: VAL2=B
 my_project_runner: MyProject::Batch.run

のようにKuroko2とは別のRailsアプリケーション内のバッチを実行するための専用のカスタムタスクを設定することができます。

クックパッドでもこのようなアプリケーション毎に設定などをまとめた専用のカスタムタスクを定義していたり、Hako を利用した Docker アプリケーションへのオプション定義のショートカットとして利用しています。 また、実験的なタスクを試したりすることもできるので、Kuroko2 本体にコントリビュートする際にも事前に検証などが行なえます。

Kuroko2::ApplicationController の拡張

次に Kuroko2::ApplicationController を拡張する方法を紹介します。

ここでなぜこのような拡張が必要なのかという理由を先に述べておきます。 クックパッドではアプリケーションのエラートラッキングに Sentry を採用しており、kuroko2 gem をマウントしたアプリケーションも他のアプリケーションと同様にエラーが発生した場合に Sentry で管理したいということがあったためです。

それでは、実際に拡張する例を書いてみます。

カスタムタスクと同様に以下の拡張コードを your_kurko2_rails_apps/lib 以下に置きます。(アプリケーションがロードできる場所であればどこでもよいです)

module ControllerExtention
  extend ActiveSupport::Concern
  included do
    before_action :additional_before_action
  end

  private

  def additional_before_action
    if signed_in?
      # do something
    end
  end
end

次に、こちらもカスタムタスクと同様に kuroko2.yml に設定を行います。

...
extentions:
  controller:
    - ControllerExtention
...

今回の例はユーザーがログインしている場合になにかを行うような例にしました。 先述したクックパッドで行っている Sentry を用いたエラートラッキングでは、before_action でログインユーザーやエラーが発生したときに必要なコンテキストを設定するようなことを行っています。

Mountable Engine を採用した利点として、 kuroko2 gem がマウントされたRailsアプリケーションからある程度のカスタマイズが可能になる点です。 いまは ApplicationController だけですが、必要があればこのような拡張性はある程度確保したいと考えています。

Kuroko2 のこれから

方針の中でもすこし触れましたが、具体的には、

  • UIをモダンに改善する
  • command-executor と kuroko2 console 間の連携の抽象化
  • command-executor 自体を Rails に依存しないようにして、軽量化する
  • ドキュメントの充実

など、Kuroko2 の方針に追いつけていない部分がまだまだあると認識しています。 ひとまずはこれらの課題に対して取り組んでいく必要があると考えています。

イベントで出た一部の質問とその回答

権限管理は実装しないのか

実装する予定はいまのところありません。

ジョブの実行前は確認のモーダルウィンドウが出るので誤って実行されにくいUIをしています。 さらにクックパッドではジョブを管理しているチーム以外にもたとえば障害が起きたときなど SRE や開発基盤がジョブの description をみて適宜リトライを行うようなオペレーションを行っています。DWHの領域では部署をまたがったジョブの連携が行われておりお互いにアクセスできる必要があります。またジョブに対する操作が行われた場合、いつ誰が実行・リトライしたかをログとして記録しています。 このため厳しく権限管理をするよりも性善説にたって誰でもアクセスできるようにしておいたほうがメリットが大きいと考えています。

Kuroko2::ApplicationController の拡張を使ってカスタマイズという形で実装することは可能だと思うので、必要があればこちらの方法をおすすめします。

補足: 現在認証については Google の G Suite のみがサポートされていますが、この点については将来的に他の認証方法などの対応は必要になってくるのかなとは感じています。

command-executor がメモリを食う

Railsに依存しすぎている部分がたしかにあるので対応したいです。 必要以上にRailsに依存しないようにすることと、方針の中で触れた command-executor の抽象化も含めて今後の課題だと認識しています。

最後に

Kuroko2 の設計方針とこれからの課題、拡張方法について紹介しました。 この記事で興味を持たれたり、導入してみたいという方は是非 Kuroko2 までコントリビュートお待ちしています。 また、実際に導入したなどの事例やフィードバックなどもご連絡頂けると幸いです。

f:id:eisuke-oishi:20170614172135p:plain

Android アプリのリソース定義ポリシーを整備した話

技術部モバイル基盤グループの児山(@nein37)です。 モバイル基盤グループではモバイルアプリの開発だけでなく、開発環境の整備や開発効率の向上も重要な目的の一つとしています。

昨年、開発効率向上の一環として行っているアプリのリソース整理の取り組みについてAndroidアプリのリソースを整理して開発効率を改善した話という記事で紹介させて頂きました。 今回はそれから1年が経過してリソース整理の状況がどのように変わったか説明していきたいと思います。

前回のあらすじ

詳細は前回の記事に書いてありますが、大体以下のような取り組みを行いました。

  • 未使用リソースの削除
  • Theme の定義
  • Style の整理
  • TextAppearance の定義

これらの作業により、無法地帯だったクックパッドアプリのリソースを整理し、開発効率を大幅に改善することができました。 (と、その当時は思っていました…)

その後発生した様々な問題

トップ画面の大規模変更

去年の9月ごろ行った更新により、アプリのトップ画面の構成が大きく変化しました。

変更前 変更後
f:id:nein37:20170601193414p:plain f:id:nein37:20170601193420p:plain

この変更にあわせて、以下のようなリソース修正が行われました。

  • 新しいトップ画面にあわせて Style や TextAppearance を追加した
  • 新しいトップ画面に関する大量の Dimen 定義が追加された
  • 元々の定義の一部が他の画面から利用されていたため消さずに残した

画面ごとの Style の乱立

アプリ全体で利用できる汎用的な Style や TextAppearance に関しては前回の作業で定義済みだったのですが、実際にアプリの更新をしていく上で汎用的なリソースでは表現できない様々なデザイン上の例外が追加されていきました。

  • この画面のこの場所は目立たせたいので 16sp にしたい
  • この場所は普通のグレーじゃなくて暖かみのあるグレーにしたい… などなど

この当時の命名規則が細かく定まっていなかったため、ある Style がどの画面用のものなのかわからなくなってしまうという問題も発生しました。 Style のスコープがわからないため元々ある Style を再利用しても良いのかどうか判断できず、似たような Style 定義がどんどん追加されていくだけの状態になっていました。

Style 定義の度に質問が飛んでくる

すでに説明してきたとおり、 Style を新規追加するときの命名規則が緩く、定義を追加するかどうか判断するためのフローもなかったため、以下のような質問が押し寄せてくることになりました。

  • この定義を追加したいんですが良いですか?
  • この Style のはこの名前で良いですか?
  • 継承元これで良いですか?

などなど。 それぞれ考えるとちゃんと答えは出るものの、毎回エンジニアもデザイナーも悩ませてしまい、単純な Style の追加作業に関して手間取らせてしまっていました。

改善に向けて

上記のように本当にいろいろな問題が出てきましたが、これまでのポリシーでも追加するときは既存の定義を真似して追加されていくので一定の秩序はありました。 すくなくとも、去年までの無法地帯よりははるかにマシです。

そこで、これまでのポリシーをベースにしながら今回出てきた問題を踏まえてこれまでの運用で問題が出たところを見直し、新しいポリシーを整備することにしました。 ここまでで出てきた反省点を改めてまとめると以下のようなものです。

  • 汎用リソースだけではカバーできない部分が多い
    • 例外の存在を前提にする必要があった
  • 例外的なリソースを定義する方法が未定
    • リソースの定義方法(命名規則、定義場所)を決める必要があった
    • Style(TextAppearance) 以外のリソースでも決めておいたほうが良かった
  • あたらしくリソース定義をした場合、どれが汎用リソースでどれが例外リソースなのかわからない
    • 命名規則や記述ファイルで区別可能にしておく必要があった

これらの反省点を踏まえ、以下のような方針でリソースの定義ポリシーを整えることにしました。

  • 本当に汎用的に使える部分のみ汎用リソースとして定義する
    • 汎用リソースは記述ファイル名、命名規則などで容易に判別可能にする
    • 将来的に汎用にできるかも…などの曖昧な根拠で汎用リソースにしない
  • 汎用リソースで表現できないものに関しては「画面ごとのリソース」として定義する
    • 画面ごとのリソースは記述ファイル名、命名規則などで適用範囲を判別可能にする
    • 他の「画面ごとのリソース」と同じ定義をしようとしている場合、そのリソースの汎用リソース化を検討する

いままでのポリシーとの違いは主に「アプリ全体で利用するリソースとある画面のみで利用するリソースを明確に区別できるようにする」「あるリソースを汎用リソースにするかどうかを決めるタイミングを明確化する」という2点です。

次項ではリソースの種類ごとに実際の命名規則について説明していきます。

実際の定義ポリシー

Color

Color リソースはその名前の通り色の定義を行うものです。 前項と若干矛盾しますが、 Color リソースに関しては定義数が多くなく今後も一括管理可能な範囲に収まるという認識で画面ごとのスコープは設けていません。

クックパッドアプリでは Color リソースの定義に以下のような制約を持たせています。

  1. 実装上の制限がない限り、色の指定は Color リソースを参照させる
  2. アプリ内で多用される基本的な色には orange 、 green などの汎用的な名前をつける
  3. 各色については用途別に名前をつける(2.で定義した色を参照しても良い)
  4. ColorStateList 、gradient 用の色は名前を揃える(2.で定義した色を参照しても良い)

実際の定義は以下のようになります。

<!-- 2. で言及している基本的な色群 -->
<color name="green">#8bad00</color>
<color name="orange">#ff7f00</color>
<color name="red">#ef6074</color>

<!-- 3. で言及している用途別の色群 -->
<color name="recipe">@color/green</color>
<color name="ranking_arrow_up">@color/orange</color>
<color name="ranking_arrow_stay">#b8af93</color>
<color name="ranking_arrow_down">#32a9c0</color>

<!-- 4. で言及している用途別の色群 -->
<color name="button_background_primary">@color/orange</color>
<color name="button_background_primary_pressed">#da6e00</color>
<color name="button_background_primary_disabled">#ffc17d</color>
<color name="button_text_primary">@color/white</color>
<color name="button_text_primary_pressed">#d9d9d9</color>
<color name="button_text_primary_disabled">#ffe5c9</color>

Dimen

Dimen リソースはViewのサイズや文字サイズ、マージンなどを定義するためのものです。

こちらは画面ごとの定義が非常に多いため、画面ごとのスコープを導入し、以下のような制約を持たせています

  1. 実装上の制限がない限りサイズ指定は Dimen リソースを参照させる
  2. アプリ全体で利用する Dimen の基本単位は dimens_base.xml に記述する
    • このとき定義するリソース名は dimen_xxdp とする
  3. アプリ全体で利用する汎用的な定義は dimens_base.xml に記述する
    • このとき定義するリソース名は general_用途名 とする
    • 値の定義は可能な限り dimen_xxdp を参照する
  4. 画面ごとの定義は dimens.xml に記述する
    • このとき定義するリソース名は 画面名_用途名 とする
    • 値の定義は可能な限り dimen_xxdp を参照する

dimens_base.xml の定義は以下のようになります。

<!-- 2. で言及している Dimen の基本単位群 -->
<dimen name="dimen_2dp">2dp</dimen>
<dimen name="dimen_4dp">4dp</dimen>
<dimen name="dimen_8dp">8dp</dimen>
<dimen name="dimen_12dp">12dp</dimen>
<dimen name="dimen_16dp">16dp</dimen>
<dimen name="dimen_20dp">20dp</dimen>
<dimen name="dimen_24dp">24dp</dimen>
<dimen name="dimen_32dp">32dp</dimen>
<dimen name="dimen_48dp">48dp</dimen>
<dimen name="dimen_56dp">56dp</dimen>
<dimen name="dimen_64dp">64dp</dimen>

<!-- 3. で言及している汎用的な定義 -->
<dimen name="general_padding">@dimen/dimen_12dp</dimen>
<dimen name="general_text_padding">@dimen/dimen_12dp</dimen>
<dimen name="general_card_padding">@dimen/dimen_12dp</dimen>
<dimen name="general_text_drawable_padding">@dimen/dimen_8dp</dimen>
<dimen name="general_dialog_padding">@dimen/dimen_8dp</dimen>

dimens.xml の定義は以下のようになります。

<!-- 4. で言及している画面ごとの定義 -->
<dimen name="user_registration_activity_top_margin">@dimen/dimen_24dp</dimen>
<dimen name="user_registration_activity_bottom_margin">@dimen/dimen_24dp</dimen>
<dimen name="user_registration_contents_margin_top">@dimen/dimen_12dp</dimen>
<dimen name="user_registration_paragraph_margin_top">@dimen/dimen_32dp</dimen>
<dimen name="user_registration_paragraph_margin_top_small">@dimen/dimen_24dp</dimen>

わざわざ dimen_xxdp という Dimen リソースを定義しているのが不思議に思えるかもしれませんが、これはデザイナーが画面設計をする際に値をなるべく選択肢の中から選ぶようにすることで統一感を出しやすくするための仕組みです。 また、dimen_xxdp を参照していないリソースを「設定値からみても明らかに例外として定義されているもの」として判別することができるようになり、後のリソース整理でも役立てることができ(る予定になってい)ます。

Style

Styleリソースはある種類の View の属性をまとめて定義するためのものです。 詳しい定義ポリシーに入る前に、 Style 特有の注意点についていくつか説明していこうと思います。

再利用性を高めるために

アプリデザインの統一感を保つ上で、 Style は非常に重要です。 複数の画面で同一の Style を使いまわすことで View の見た目を揃えることができるため、 Style 定義においては定義内容の再利用性を高めることが重要になります。 個人的に再利用性を高めるためになるべく Style に定義しないほうが良いと考えている属性は以下のようなものです。

  • layout_gravity
  • layout_weight
  • layout_above
  • layout_below(など、配置に関するもの全般)

これらの layout_* という属性は View ではなく LayoutParams の属性で、親Viewの種類や属性によっては機能しなかったり意図しない動作になったりします。 layout_width, layout_height, layout_margin あたりは大抵の ViewGroup で動作しますが、上に挙げたような属性は特定の ViewGroup でしか動作しません。 (このあたりは 各 LayoutParams の継承を見るとわかりやすいです) Android ではレイアウトファイルの include による再利用もできるようになっているので、特定のView階層構造に依存している場合はincludeを活用するなど再利用性を高める工夫をしていくと良いと思います。

継承の仕組み

Style リソースは「 parent 指定による継承」と「名前による継承」の2つの仕組みを持っています。 この2つを組み合わせることで様々な Style 定義を効率的に行うことができます。

parent 指定による継承

parent 指定による継承では親 Style を直接継承して派生 Style を作ることができます。 これは主に Android やサポートライブラリの Style を継承する場合に利用します。

<!-- AppCompatのボタンStyle定義を継承してクックパッドのボタンStyle定義を行う例 -->
<style name="CookpadStyle.General.Button" parent="Widget.AppCompat.Button">
    <item name="android:background">@drawable/button_background_default</item>
    <item name="android:textAppearance">@style/CookpadFont.General.Button.Text</item>
    <item name="android:paddingTop">@dimen/button_padding_vertical</item>
    <item name="android:paddingBottom">@dimen/button_padding_vertical</item>
    <item name="android:paddingLeft">@dimen/button_padding_horizontal</item>
    <item name="android:paddingRight">@dimen/button_padding_horizontal</item>
    <item name="android:drawablePadding">@dimen/button_drawable_padding</item>
    <item name="android:minWidth">@dimen/button_min_width</item>
    <item name="android:minHeight">@dimen/button_min_height</item>
    <item name="android:textColor">@color/button_text_state</item>
</style>

名前による継承

すでに存在する Style 名に .(ドット) で続けて別の名前を与えることでその Style を継承することができます。 これは主に定義済みの Style のバリエーションを増やすために利用します。

<!-- CookpadStyle.General.Button を継承して Primaryボタン用のstyle定義を行う例 -->
<style name="CookpadStyle.General.Button.Primary">
    <item name="android:background">@drawable/button_background_primary</item>
    <item name="android:textColor">@color/button_text_primary_state</item>
</style>

クックパッドにおける Style 定義のポリシー

以上の注意点を踏まえたクックパッドの Style 定義は以下のようになります。

  1. 汎用的な Style 定義は styles_widget.xml に記述し、以下の要素をドットで繋いだリソース名とする
    • CookpadStyle(prefix)
    • General(汎用Style定義)
    • View(対象Viewの種類)
    • Variation(バリエーション、必須ではなく複数でも可)
  2. 汎用 Style 定義のうち、バリエーションが特に多いものに関しては View 種類ごとにファイルを切り出す
    • styles_button.xml など
  3. それ以外の Style 定義については styles.xml に記述し、以下の要素をドットで繋いだリソース名とする
    • CookpadStyle(prefix)
    • Screen(画面)
    • View(対象Viewの種類)
    • Variation(バリエーション、必須ではなく複数でも可)

汎用 Style 定義の例

<!-- 汎用のボタンStyleのうち、最も基本的なもの -->
<style name="CookpadStyle.General.Button" parent="Widget.AppCompat.Button">
    <item name="android:background">@drawable/button_background_default</item>
    <item name="android:textAppearance">@style/CookpadFont.General.Button.Text</item>
    <item name="android:paddingTop">@dimen/button_padding_vertical</item>
    <item name="android:paddingBottom">@dimen/button_padding_vertical</item>
    <item name="android:paddingLeft">@dimen/button_padding_horizontal</item>
    <item name="android:paddingRight">@dimen/button_padding_horizontal</item>
    <item name="android:drawablePadding">@dimen/button_drawable_padding</item>
    <item name="android:minWidth">@dimen/button_min_width</item>
    <item name="android:minHeight">@dimen/button_min_height</item>
    <item name="android:textColor">@color/button_text_state</item>
</style>

<!-- 汎用のレシピボタンStyle -->
<style name="CookpadStyle.General.Button.Recipe">
    <item name="android:background">@drawable/button_background_recipe</item>
    <item name="android:textColor">@color/button_text_recipe_state</item>
</style>

<!-- 汎用のレシピボタンStyleにマージンを付与したもの -->
<style name="CookpadStyle.General.Button.Recipe.WithMargin">
    <item name="android:layout_marginTop">@dimen/button_margin_vertical</item>
    <item name="android:layout_marginBottom">@dimen/button_margin_vertical</item>
    <item name="android:layout_marginRight">@dimen/button_margin_horizontal</item>
    <item name="android:layout_marginLeft">@dimen/button_margin_horizontal</item>
</style>

画面ごとの Style 定義の例

<!-- ユーザー登録画面用のEditText Style -->
<style name="CookpadStyle.UserRegistration.EditText">
    <item name="colorControlActivated">@color/orange</item>
    <item name="android:textCursorDrawable">@drawable/edit_text_cursor_orange</item>
    <item name="android:background">?android:attr/editTextBackground</item>
    <item name="android:textColorHint">@color/extra_light_gray</item>
</style>

汎用 Style 定義と画面ごとの Style 定義を比べてみると、まず第2句が General かどうかで汎用 Style かどうか判別可能になっていることがわかると思います。 General であればアプリ全体に適用可能な汎用 Style 、そうでなければ画面ごとのスコープをもった Style です。 また、第4句以降が存在している場合、元々存在する Style のバリエーションであることもわかり、 Style 名から定義内容を追う上で役立ちます。

TextAppearance

TextAppearance は文字表示に関する属性だけをまとめた Style のサブセットです。 継承などの仕組みは Style に準じていますが、クックパッドアプリではStyleとの大きな違いとして Base TextAppearance という概念が導入されているので先にそちらを説明します。

Base TextAppearance の定義

前回のリソース整理記事でもちらっと触れていますが、クックパッドアプリでは基本的な文字サイズ・文字色・文字スタイルの組み合わせを Base TextAppearance として定義しています。 これは dimen_xxdp 同様、デザイナーの新しい TextAppearance に制約を課すために使われています。

  1. Base TextAppearance は text_appearance_base.xml に記述し、以下の要素をドットで繋いだリソース名とする
    • CookpadFont.Base(prefix)
    • 文字サイズ(5種類:ExtraLarge/Large/Default/Small/ExtraSmall)
    • 文字色(7種類:未指定(Black)/Gray/LightGray/Green/Orange/Red/White)
    • 文字スタイル(2種類:未指定(標準)/Bold)
  2. 上記の定義について、すべての組み合わせを定義する
  3. Base TextAppearance は これを継承した TextAppearance を定義する以外の目的で利用しない
    • レイアウトファイル内などで直接参照しないこと

Base TextAppearance の定義例

<style name="CookpadFont.Base.ExtraLarge">
    <item name="android:textColor">@color/black</item>
    <item name="android:textSize">@dimen/extraLargeTextSize</item>
</style>

...

<style name="CookpadFont.Base.ExtraLarge.Gray">
    <item name="android:textColor">@color/gray</item>
</style>

...

<style name="CookpadFont.Base.ExtraLarge.Gray.Bold">
    <item name="android:textStyle">bold</item>
</style>

という感じで名前による継承を使いつつ70種類を網羅しています。

TextAppearance の定義

  1. TextAppearance は text_appearance.xml に記述する
  2. 汎用的なTextAppearance 定義は以下の要素をドットで繋いだリソース名とする
    • CookpadFont(prefix)
    • General(汎用)
    • Purpose(目的)
    • Style(書体:Emphasis/Main/Sub/Weaken)
  3. それ以外の TextAppearance 定義は以下の要素をドットで繋いだリソース名とする
    • CookpadFont(prefix)
    • Screen(画面)
    • Purpose(目的)
    • Style(書体:Emphasis/Main/Sub/Weaken)

Purpose(目的) はその TextAppearance を何の表示のために定義するかを書くところで、例えば「RecipeTitle」や「UserName」といったものが入ります。 Style(書体)はその TextAppearance が同一のスコープ、目的の中でどういう位置づけにあるかを示しています。 このStyle(書体)の概念は Base TextAppearance の文字サイズや太字などとは無関係に定義されるもので、「このスコープ、目的に関してはこの組み合わせが Main 」「この組み合わせが Sub 」という定義をデザイナーが相談しながら決定しています。

TextAppearance の定義例

<!-- 汎用のレシピタイトル -->
<style name="CookpadFont.General.RecipeTitle.Main" parent="CookpadFont.Base.Large.Green.Bold" />

<!-- 汎用のレシピタイトル(小) -->
<style name="CookpadFont.General.RecipeTitle.Sub" parent="CookpadFont.Base.Default.Green.Bold" />

<!-- 汎用のレシピタイトル(強調) -->
<style name="CookpadFont.General.RecipeTitle.Emphasis" parent="CookpadFont.Base.ExtraLarge.Green.Bold" />

<!-- レシピ一覧画面におけるレシピタイトル -->
<style name="CookpadFont.RecipeList.RecipeTitle.Main" parent="CookpadFont.Base.Small.Green.Bold" />

Styleと同様に、第2句の内容を見ることでその TextAppearance が汎用定義なのか画面ごとの定義なのかを知ることができるようになりました。 TextAppearance はすべての定義が Base TextAppearance を parent によって継承するため名前による継承が行われないので少しシンプルですね。

まとめ

これまで説明してきたようなリソースの定義ポリシーを策定したことで、各リソースのスコープや意味合いについて格段に管理がしやすくなりました。 アプリの改修によってリソース自体が増殖していくという問題は引き続き発生しますが、画面スコープなどの導入によって今後無秩序な状態に陥ることはなくなったと思います。 こういった Style や TextAppearance のルールづくりは非常に地味で面倒な作業ですが、長期的にはエンジニア・デザイナー双方の作業を効率化することができます。 今回定義ポリシーを策定するにあたってはデザイナーとつきっきりで「どういう場合に例外リソースの定義が必要か」「例外はどういったスコープで捉えれば良いか」というような事について話しながら進めました。 特に dimen_xxdp による制約や TextAppearance の文字サイズの定義と連動しない4段階の Style(書式) といった概念はデザイナーの提案から生まれたものです。 こういった細かい部分にまでデザイナーの思想を反映していくことでエンジニア・デザイナーの意思疎通が少しでも簡単になればと思っています。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://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('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/