UICollectionView の Layout で悩んだら

こんにちは、サービス開発部の氏です。
主にiOSのクックパッドアプリの開発を担当しています。

UICollectionViewLayout みなさん使ってますか?
UICollectionView でレイアウトを組む際、実際触り始めると実装するための選択肢が複数あり、どれが最適なのか悩ましい場面に遭遇する人もいるのではないかと思います。
今回は、自分が業務で触れた際に得た知見について軽くお話したいと思います。

UICollectionVIewLayout とは

UICollectionView は Cellのサイズや余白等のレイアウトを管理するため、プロパティとして、 UICollectionViewLayout を所持しています。
この UICollectionViewLayout に手をいれることによって、レイアウトを好きな形に変更することができます。

レイアウトを組み立てるときの複数の選択肢

実際に UICollectionViewLayout をいじろうとすると、大きく分けて三つの選択肢が出てきます。

  1. UICollectionViewFlowLayout を調整する
  2. UICollectionViewDelegateFlowLayout を実装する
  3. UICollectionViewLayout (Custom) を作成する

つづけて、各Layoutで出来ること、出来ないことを挙げていきたいと思います。
どんなレイアウトの組み上げ方をすればよいか等、判断に困った際の参考にしていただければ幸いです。

1. UICollectionViewFlowLayout を調整する

一つ目は UICollectionViewFlowLayout をそのまま利用する方法です。
InterfaceBuilderで UICollectionView を設置すると、初期値としてこの UICollectionViewFlowLayout が設定されています。
UICollectionViewFlowLayout では、CellやHeader/FooterのSize等がプロパティとして用意されており、それを変更するだけで良い感じに組み上げてくれます。

let flowLayout = UICollectionViewFlowLayout()  
let margin: CGFloat = 3.0  
flowLayout.itemSize = CGSize(width: 100.0, height: 100.0)  
flowLayout.minimumInteritemSpacing = margin  
flowLayout.minimumLineSpacing = margin  
flowLayout.sectionInset = UIEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)  
let collectionViewController = CollectionViewController(collectionViewLayout: flowLayout)  

ですが、Cellの大きさを決める itemSize では、動的な変更が行なえません。
全てのCellを同じ大きさで表示するのであれば、UICollectionViewFlowLayout を利用すると良いでしょう。

2. UICollectionViewDelegateFlowLayout を実装する

二つ目は UICollectionViewDelegateFlowLayout を実装する方法です。 UICollectionViewDelegateFlowLayoutUICollectionViewDelegate を継承した Protocolになっており、各種便利メソッドが用意されています。
基本的には、 UICollectionViewFlowLayout のプロパティと同等なものが準備されています。

extension CollectionViewController: UICollectionViewDelegateFlowLayout {  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {  
        if indexPath.row % 3 == 0 {  
            return CGSize(width: 100.0, height: 100.0)  
        }  
         return CGSize(width: 60.0, height: 60.0)  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {  
        return UIEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {  
        return margin  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {  
        return margin  
    }  
}  

このレイアウトの利点としては、 indexPath の情報を参照出来るため、動的なCellのサイズ変更が可能です。
ですが、Protocolとして用意されているものでしか変更を行うことが出来ないため、アニメーションを伴う変化には余り適していないと思います。

3. UICollectionViewLayout (Custom) を作成する

最後は UICollectionViewLayout を継承した独自レイアウトを作成する手段です。

自由にレイアウトを組める反面、今までに挙げた2通りの様に良しなにレイアウトを組んでもらえません。
CellやSectionなど各要素の配置先を計算する必要があり手間がかかりますが、その分動的なサイズ変更やレイアウト変更を好きなように行うことができます。(自分で書くので当然ですが…)

UICollectionViewLayout を継承して利用するには、下記の処理を実装する必要があります。

collectionViewContentSize: CGSize

UICollectionViewcontentSize を返します。
UICollectionView は、この contentSize をもとにスクロール量を判断します。
その為、ここでは表示させたい要素に応じた正確な contentSize を返さないと思った通りの位置までスクロールをしてくれません。

layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

IndexPath に応じたCellの UICollectionViewLayoutAttributes を返します。
UICollectionViewLayoutAttributesIndexPath に応じたセルのレイアウト属性です。
この layoutAttributes にCellのサイズと座標を指定しておくと、指定通りの座標に表示されます。
この中でCellのサイズ計算等を行う場合、時間がかかる処理などがあるとカクつきの原因となります。
よくある方法として、prepare() でレイアウト情報を先に計算して配列などに用意しておきここではその情報を返すだけとするケースが多いです。

layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

範囲内に含まれる UICollectionReusableView (cellやsupplementary view)の UICollectionViewLayoutAttributes の配列を返します。
基本的には、その範囲に含まれる layoutAttributesForItem(at indexPath: IndexPath) を取得してくる形になるでしょう。

アニメーションについて

レイアウトを作っていると、アニメーションを求められるケースがそれなりにあるかと思います。
レイアウト変更時のアニメーションは下記の様な形でアニメーションを行うことができます。

collectionView.setCollectionViewLayout(newLayout, animated: true)  

他には、Cellの生成時や削除時のレイアウト属性を返すメソッドがあり、それを実装することで insertItems, deleteItems でもアニメーションをさせる事ができます。

  • initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath)
  • finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath)

また、「UICollectionViewController, UINavigationController の組み合わせでのみ」という制限がありますが、
UICollectionViewControlleruseLayoutToLayoutNavigationTransitions の値を true にしてpushさせると UINavigationBar と連携した遷移が可能です。

let viewController = UICollectionViewController(collectionViewLayout: newLayout)  
viewController.useLayoutToLayoutNavigationTransitions = true  
navigationController?.pushViewController(viewController, animated: true)  

UICollectionViewController 以外への遷移アニメーションは、 UIViewControllerAnimatedTransitioning を実装してあげると良いでしょう。

まとめ

UICollectionView のレイアウトを作るに当たって、ほとんどのケースでは UICollectionViewFlowLayoutUICollectionDelegateFlowLayout で事足りるかと思います。

独自レイアウトを採用するケースとしては、行ベース、グリッドベース以外のレイアウトが必要なケースや、各要素のレイアウトが頻繁に変化する場合に必要になってきます。(カバーフローのようなものだったり)

UICollectionView はiOS10から prefetchUICollectionViewFlowLayoutAutomaticSize 等の新しい機能も追加され、表現の幅も増えています。

クックパッド内で利用するのはまだ少し先かもしれませんが、ユーザーがより良い体験を提供できるよう常に心がけていきたいですね。

Android開発のコードレビューbotを乗り換えた話

モバイル開発で利用しているコードレビューbotを最近乗り換えた話をします。

コードレビューbotとは

コードレビューbotはPull Request(以下PR)に対して、静的解析した結果などをコメントする機能を持つプログラムの事を指します。 コードレビューbotを導入すると、些末な内容はbotが勝手に指摘してくれるため、レビューワーがより重要な内容のレビューに時間を使うことが期待できます。 有名なサービスにHoundSideCIなどがあります。

Android開発でのレビューbotの役割

CookpadのAndroid開発では、下記の項目をPR毎に実行しています。

今まではこれら全てをDokumiと呼ばれるツールで行っていました。(上記の通りDokumiではコードレビューだけではなくdeploygateへのアップロードなども受け持っていたため、 コードレビューツールと呼ぶのが適切かもしれません)

Dokumi時代

CookpadのiOS/Android開発では、自社製のDokumiというレビューbot(レビューツール)を長年愛用してきました。詳しくは下記のエントリに紹介されています。
Dokumi (日本語)

長い間お世話になってきたDokumiも、利用していく間にいくつか問題点が生まれました。

  • 依存関係が難しい
    • OSSであるdokumiと社内で利用しているdokumi-cookpad-customというmoduleの関係性が複雑だった
  • セットアップが難しい
  • デプロイ手順が煩雑(gemifyされていない)
  • Dokumi以外の仕組みで動かしてる機能もあり、それらを統合したい
    • Dokumiはプラグイン機構がなく機能追加が容易ではない
  • dokumiの設定がAndroidの開発レポジトリと別で扱いづらかった
    • dokumi-cookpad-customレポジトリ内に設定が管理されていた

このような理由で、iOSの開発チームが@giginetを中心にDangerへの乗り換えを進めていました。 それに続くようにAndroid開発の環境もDangerに移行する運びになりました。 Android版Cookpadのアプリの開発環境をDokumiからDangerに移行する作業は@_litmon_が対応してくれました。

Dangerの特徴

Danger - Stop Saying "You Forgot To…" in Code Review

  • Ruby製
  • 多くのCIシステムをサポートしている
  • 多くのコードホスティングサービスをサポートしている
  • 設定をDangerfileとしてレポジトリに含める

コアは小さく、様々な機能はプラグインによって提供されています。 Dangerのプラグインは充実しているとはいえませんが、iOS環境は比較的揃っている印象です。Android関連ではFindBugs,AndroidLint,JUnitのプラグインがあります。

pluginの作成

DokumiからDangerへ移行するに当たりfindbugsプラグインがDanger側に無かったため、findbugs-dangerを作成しました。 Dangerはpluginのテンプレートをコマンドラインで作成できるなどサポートが手厚く、下記の記事を読み進めていくとrubyやruby-gemsのエコシステムに詳しくなくてもハードルは高くない印象でした。

Creating your first Plugin

Dangerの導入

導入手順は下記のページにまとめられていますが、簡単に紹介します。

Getting Set Up

Dangerはgemとして公開されています。gem install danger としても利用できますが、bundlerを経由した利用が推奨されているのでGemfileをプロジェクト直下に用意しましょう。

# frozen_string_literal: true
source "https://rubygems.org"

gem 'danger'

danger init を実行すると初期セットアップが行われDangerfileが作成され、セットアップウィザードのようなものが表示されます。

bundle install
bundle exec danger init

ひたすら長文のメッセージが流れてくるので、enterで進めていきましょう。

  • Step 1はDangerFileを作成したというメッセージが表示されます。
  • Step 2はbot用のGithubアカウントを作成を促されます。クールなアイコン画像の設定をすることを忘れてはいけません。
  • Step 3でbotアカウントでアクセストークンを作るように言われます。Publicなレポジトリの場合 public_repo の権限だけで問題ないそうです。
  • Step 4でCI側の設定を求められますが、詳しい案内はないので、setting-up-danger-to-run-on-your-ciを見ると良さそうです。
  • 全てのセットアップが完了したら、CIからbundle exec dangerを実行する様にセットアップしましょう。

danger init で作成されるDangerFileはとてもシンプルなので必要に応じてカスタマイズしましょう。導入に成功するとbotがPRに対してコメントを投げてくれるようになります。

※ FindBugsの結果からdangerが指摘した例

CookpadのDangerfile

2017/6末地点で、Android開発で利用しているDangerfileを公開します。導入の際はぜひ参考にしてください。

####
#
# github comment settings
#
####
github.dismiss_out_of_range_messages

####
#
# for PR
#
####
if github.pr_title.include? "[WIP]" || github.pr_labels.include?("WIP")
  warn("PR is classed as Work in Progress") 
end

# Warn when there is a big PR
warn("a large PR") if git.lines_of_code > 300

# Warn when PR has no milestone
warn("A pull request must have a milestone set") if github.pr_json["milestone"].nil?

# Warn when PR has no assignees
warn("A pull request must have some assignees") if github.pr_json["assignee"].nil?

####
#
# Findbugs
#
####
findbugs.report_file = "your_module/build/reports/findbugs/findbugs.xml"
findbugs.gradle_module = "your_module_name"
findbugs.report(true)

####
#
# Android Lint
#
####
android_lint.gradle_task = "your_module:lint"
android_lint.report_file = "your_module/build/reports/lint/lint-result.xml"
android_lint.filtering = true
android_lint.lint(inline_mode: true)

まとめ

Cookpadにおけるコードレビューbotの役割や、ツールを乗り換えた経緯、Dangerの特徴などを紹介などをしました。コードレビューbotをもし導入されていないのであればDangerはおすすめ出来ます。手軽にセットアップが可能なのでぜひお試しください。

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公式にも書かれている方法ではあるのですが)

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