基本の Android View 実装ドキュメントの紹介

モバイル基盤部の吉田です。 先日 Android アプリのリニューアル時に社内向けに用意した画面実装ドキュメントの内容を補足を交えてご紹介します。

用意した経緯

Cookpad の Android アプリの現在のコードベースは 2014 年に初回リリースされました。しかし当時の実装が 2020 年でもベストプラクティスであることは稀です。 Android 開発は日進月歩で様変わりしています。様々な時代のコードが入り交じるレポジトリで大規模なリファクタリングと新たなメンバーによる開発が始まるということで、新規実装の指針となる View 周りの実装ドキュメントの必要性を感じたので用意しました。

今回のドキュメントが View にフォーカスした理由は、全体設計に関しては既に VIPER の詳細なドキュメントが用意されていましたので、残りは View 周りの具体的な実装方針があればチームで大きなブレがない開発が出来ると考えたためです。

View のドキュメント以外にも、実装に必要な情報や slack 上の議論で決まった事項はdocs以下に明文化する文化があり GitHub Pages でいつでも読める状態を整えています。

View への参照方法

新しいコードでは ViewBinding を採用することにしました。 2020 年の夏の段階で私達のレポジトリでは DataBinding と ViewBinding と synthetics(KotlinAndroidExtension) の3つのツールが View への参照に使われていました。 昔から利用してきた DataBinding は 多機能なため他2つのツールが導入されても完全に置き換える意思決定が難しかったのですが、VIPER アーキテクチャの導入によって View に求められる役割が明確になったことで ViewBinding に統一することが出来ました。
また私達の意思決定とは無関係ですが、先日 synthetics は正式に非推奨なツールになったので ViewBinding への乗り換えが推奨されています。

Migrate from Kotlin synthetics to Jetpack view binding

レイアウトファイルの命名規則

レイアウト XML のファイル名は{component_type}_{screen_name}.xmlという命名規則としました。例えば RecipeActivity の場合、レイアウトファイル activity_recipe.xmlとなります。

コンポーネント 命名規則
Activity activity_xxx
Fragment fragment_xxx
CustomView view_xxx
ItemView item_view_xxx

ID の命名規則

実装からアクセスしたいビューオブジェクトには ID 属性で名前を付ける必要があります。この際ビューオブジェクトに割り振る ID 属性は camelCase で命名することにしました。 ViewBinding から View にアクセスする際は自動で View の ID が camelCase に変換する仕様があるため、XML 側でも camelCase で記述することで対象アイテムを見つけやすくしています。

<TextView
  android:id="@+id/recipeName" />

ConstraintLayout の活用

ConstraintLayout は以前から導入していましたが、利用箇所が限定的で十分に活用できていなかったので、新規 View を作成する際は ConstraintLayout で View の配置の指定するように定めました。 ConstraintLayout も非常に多機能ですべての機能は紹介しきれないですが、基本的となる考え方と私達が頻繁に利用する便利な機能を紹介します。

MATCH_CONSTRAINT について

ConstraintLayout は width や height に 0dp を指定してレイアウトすることがあります。これはMATCH_CONSTRAINTという状態で制約に従って最大の大きさにレイアウトすることを示しています。 意外に知られていませんが、ConstraintLayout で MATCH_PARENT を利用するのは非推奨であり下記で紹介する便利な制約のいくつかが正しく動作しない可能性があるので初めて使う際は覚えておきましょう。

Important: MATCH_PARENT is not recommended for widgets contained in a ConstraintLayout. Similar behavior can be defined by using MATCH_CONSTRAINT with the corresponding left/right or top/bottom constraints being set to "parent".

また maxWidthminWidth の代わりにlayout_constraintWidth_maxlayout_constraintWidth_min を利用する必要があるのもハマリポイントの一つです。

ConstraintLayout: Widgets dimension constraints(developer.android.com)

基本的な制約

ConstraintLayout が View の位置を決定するための制約方法は様々ですが、他の View との相対的な位置関係を使った制約を覚えると大体のレイアウトを組むことが出来ます。 相対的な位置関係を決める対象には id が振られている他の View と自分の親 View(parent)が指定可能です。制約は矛盾しない限りいくつでも追加できるので、例えば下記の例では2つ制約を組み合わせると水平方向の中央寄せを表現しています。

<RecipeView
        android:id="@+id/recipeView"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        />
属性 説明
layout_constraintTop_toTopOf 自分の上辺を指定した View の上辺の位置に合わせる
layout_constraintTop_toBottomOf 自分の上辺を指定した View の下辺の位置に合わせる
layout_constraintBottom_toTopOf 自分の下辺を指定した View の上辺の位置に合わせる
layout_constraintBottom_toBottomOf 自分の下辺を指定した View の下辺の位置に合わせる
layout_constraintStart_toEndOf 自分の左辺を指定した View の右辺の位置に合わせる
layout_constraintStart_toStartOf 自分の左辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toStartOf 自分の右辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toEndOf 自分の右辺を指定した View の右辺の位置に合わせる

下記の例では 「buttonB の左端が buttonA の右端になる」制約をつけることでボタン A,B が横並びに表示されています。(RTL 環境では左右が入れ替わります)

<Button android:id="@+id/buttonA" ... />
<Button android:id="@+id/buttonB" ...
        app:layout_constraintStart_toEndOf="@+id/buttonA" />

f:id:kazy1991:20201207140915p:plain
ConstraintLayout (developer.android.com)

覚えておくと便利な機能

縦横比の指定

ConstraintLayout 以下の View では layout_constraintDimensionRatioが利用可能で View の縦横比を自由に制御できます。例えば"1:1"と指定すれば正方形の View を組むことが出来ます。 蛇足ですが意外にも正方形の View を組むのは大変で、昔はXxxSquareViewのようなカスタムクラスを用意する必要がありました。

<Button android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintDimensionRatio="1:1" />

もう少し発展的な利用方法を紹介すると、constraintDimensionRatioは縦横どちらを基準に比率を決めるか指定することが出来ます。h,1:1 とすると高さを基準にして 1:1、w,1:1とすると横幅を基準に 1:1 の大きさにレイアウトします。 また、縦横どちらもMATCH_CONSTRAINTの場合にconstraintDimensionRatioを利用すると条件を満たす最も大きなレイアウト方法で描画されるため、明示的に基準となる向きを指定するのがおすすめです。

<Button android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintDimensionRatio="H,16:9"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

バイアスの指定

基本的な制約の説明の際に中央寄せの例を出しました。これは ConstraintLayout 下では通常均等に制約の影響を受ける仕様を生かして簡単に中央寄せも表現できています。 位置を中央から調整したいケースも対応が簡単でconstraint(Horizontal|Vertical)_bias というプロパティが用意されているので、"0" を指定すると左の側の空間がなくなり左寄りにレイアウトされ、"1" を指定すると右寄りのレイアウトが可能です。

<!--- 左右の余白を3:7に調整したい場合 -->
<RecipeView
        android:id="@+id/recipeView"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintHorizontal_bias="0.3"
        />

文字列のベースラインを揃える制約

基本的な制約では Top や Bottom を利用した位置調整を紹介しましたが、文字列の高さ(ベースライン)を基準に制約をつけることも可能で、layout_constraintBaseline_toBaselineOf というプロパティが用意されています。

View のグループ化

Layer は要素のグループ化を行う疑似要素です。これまでは XML のネストによって View のグルーピングを表現していましたが、それらの代わりとして Layer を使う事ができます。 Layer は View を継承しているのでタップイベントなどのコールバックを受け取ることが出来ます。View なので background の指定も可能なのですが、私達が開発で利用していた2.0.0-beta6の時点では表示領域がおかしくなるケースがあったため、背景の指定は避けるようにしています。

同じような機能を持つものに Group というものがあります。Group は View オブジェクトの Visibility をまとめて制御するための仕組みです。 ConstraintLayout の 1.1 から使える仕組みのため古い記事では Group を利用しているものが見つかるかもしれないですが、使い分ける必要はなくグループ化には Layer の利用を推奨しています。

<androidx.constraintLayout.helper.widget.Layer
        android:id="@+id/recipe_layer"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="?android:attr/selectableItemBackgroundBorderless"
        app:constraint_referenced_ids="recipe_count_label,recipe_count"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />
<TextView
        android:id="@+id/recipe_count_label"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="@string/my_kitchen_label_recipe"
        />

<TextView
        android:id="@+id/recipe_count"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="@string/no_count"
        />

その他の便利な機能について

ここまで紹介した機能だけで ConstraintLayout を活用して基本的なレイアウトは組めるはずです。少し慣れてきたら BarrierGuidelineFlow を使ってみたり、 ChainStyle の違いなどの理解を深めてより複雑なレイアウトに挑戦してみたり、崩れにくいレイアウトの組み方について考えてみると良いでしょう。

Material Components (for Android)

もしあなたのチームが Material Components (for Android) を導入していなかったら真っ先に導入することをおすすめします。 MaterialComponent は Theme を利用することで Button などの View コンポーネントを置き換えすることも出来ます。Theme の置き換えが簡単にいかない場合もフルパスを指定することで部分的に MaterialComponent の View を利用することが出来ます。

Material Components の Theme の導入に関しては 大規模プロジェクトにおけるモバイル基盤の取り組み で詳しく書かれているのであわせて御覧ください。この記事では実装時に特に重宝した ShapeableImageView と MaterialCardView について紹介します。

ShapeableImageView

角丸や円形のユーザーアイコンを表示する際は画像読み込みライブラリ側で調整していましたが、ShapeableImageView を使うと XML 上でデザインを表現できます。下記の例は自分で Overlay を定義して円形の画像を表示するコードですが、MaterialComponent が提供している Shape が多数あるのでShape Theming を参照して下さい。

<!-- styles.xml -->
<style name="ShapeAppearance.Circle" parent="">
   <item name="cornerFamily">rounded</item>
   <item name="cornerSize">50%</item>
</style>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/image_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
        app:srcCompat="@tools:sample/avatars" />

</androidx.constraintlayout.widget.ConstraintLayout>

f:id:kazy1991:20201210074324p:plain
黒背景は実際には描画されません

MaterialCardView

MaterialCardView はこれまで面倒だった内部要素を含めた角丸化したデザインが用意に組めるようになります。また strokeColorstrokeWidth を利用することで外枠を表現することも出来ます。不要であればtransparentを指定して隠すことも可能です。

<com.google.android.material.card.MaterialCardView 
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:cardCornerRadius="@dimen/image_rounded_corner"
        app:strokeColor="@android:color/transparent"
        app:strokeWidth="0dp"
        >
....
</com.google.android.material.card.MaterialCardView>

社内アイコンフォントの利用廃止

クックパッドには社内 FontAwsome のような便利なアイコンセットがあり Web、モバイルアプリなどの様々なプラットフォームで利用されています。 以前はフォントファイルでの配布だったのですが、先日に複合的な理由でフォント形式でのアイコンセットの運用が終わり、代わりに SVG が提供される事になったため Android では VectorDrawable でサポートすることにしました。

アイコンフォントを TextView で表示していた頃と比べて VectorDrawable に移行したことでいくつか改善した事柄あります。 これまで Drawable しか利用できない箇所(OptionMenu のアイコンなど)はアイコンを画像に書き出して対応していましたがこのような対応が不要になりました。 デザイナーとエンジニアが協力して画面を組み立てる状況において、画面のどこでアイコンセットが使えて、どこが画像切り出しが必要か考えなくてよいのは小さい改善ですが開発効率に繋がります。

また Material Components が提供する app:icon は非常に便利なので XML で Drawable して参照できる VectorDrawable は非常に快適です。その他にはこれまで一部端末でアイコンフォントを利用すると正しく表示されないケースが報告されていたのですがそのようなケースに無くなると考えています。

SVG から VectorDrawable に変換する手法は公式には Android Studio の Vector Asset Studio という GUI ツールしかありませんが、AOSP(Android オープンソース プロジェクト)のレポジトリをチェックアウトすることで、vd-tool という CLI ツールが利用可能です。 クックパッドではvd-tool を使って生成した VectorDrawable を AAR のライブラリ形式にパッケージして社内 Maven レポジトリから入手可能にしています。 vd-tool の詳細については過去に個人ブログにまとめたのでそちらをご確認ください。

RecyclerView

クックパッドのレシピサービスのアプリでは EpoxyGroupie を使用せずに直接 RecyclerView を利用しています。RecyclerView の実装に関しては模索している部分が多いですが、既に慣習になっている部分のみ明文化しました。 上述の通りクックパッドでは VIPER アーキテクチャに沿って実装しているのですが、View から Presenter を呼ぶ際のコールバック扱いと ConcatAdapter を利用して積極的に Adapter を分解する実装手法を推奨しています。

コールバックの扱い

RecyclerView 内で発生したタップイベントを Presenter まで伝えるための callback は RecyclerView.Adapter を継承したクラスの先頭にエイリアスを利用して定義します。

//ReycerView.Adapter
typealias RecipePageRequest = () -> Unit

class RecipeListAdapter(
    private val recipePageRequest: RecipePageRequest
) : RecyclerView.Adapter<RecipeListViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeListViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = ItemViewRecipeListBinding.inflate(layoutInflater, parent, false)
        return RecipeListViewHolder(
            binding = binding,
            recipePageRequest = recipePageRequest
        )
  }
}

//Fragment
class RecipeListFragment : CookpadBaseFragment(), RecipeListContract.View {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        recipeListAdapter = RecipeListAdapter(
          recipePageRequest = presenter::onRecipePageRequested
        )
    }
}

ConcatAdapter

これまでヘッダーやフッターを持つ RecyclerView は非常に実装が厄介でしたが、ConcatAdapter の登場によって直列に複数の Adapter を繋ぐことが可能になリました。Adapter を ViewType 毎に分割すると単一の Adapter と比べて、引数がシンプルになり ViewType による内部実装の分岐処理が必要がなくなります。

val concatAdapter = ConcatAdapter(headerAdapter, pagedListAdapter, footerAdapter)
binding.recyclerView.adapter = concatAdapter

その他の取り組みについて

ドキュメントの整備の他の取り組みとして、 PullRequest の CI で「未使用リソースのチェック」と 「ktlint によるフォーマットの確認」を自動化させています。

おわり

2020 年に Android 開発 で View について知っておきたいことはある程度網羅出来たと思います。何かしら参考になっていたら幸いです。 もしかすると 2021 年末には Jetpack Compose が デファクトスタンダードになりこの記事は無意味な情報になるかもしれません。そのような未来も非常に楽しみですね。

補足すると ConstraintLayout は Jetpack でも利用出来ますし MaterialComponent の Jetpack Compose サポートのニュースも最近あったので知識が全て無駄になることはありません。 今後もAndroidの開発環境は少しずつ良くなっていくと思うので、また社内ドキュメントを大きく見直す機会がありましたらまた記事にして紹介したいと思います。

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