クックパッド一同は、RubyKaigi 2019でみなさんにお会いできることを楽しみにしています!

こんにちは! 広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。

先月公開した【RubyKaigi 2019 参加者に捧ぐ】福岡で起業した男が本気で書いた福岡グルメまとめ は見ていただけましたでしょうか? 私は、ブログへの掲載はありませんでしたが、福岡の餃子には牛肉をタネに使っているお店があるとの情報を入手したので、食べに行くぞと意気込んでいます。

クックパッド株式会社は、2019年4月18日(木)〜20日(土)に福岡にて開催される RubyKaigi 2019に、Ruby Committers’ SponsorとWi-Fi Sponsorとして協賛します。

Wi-Fi Sponsorに関しては、調達・設計・構築・運用などを、昨年に引き続き @sorah が担当しております。

クックパッドに所属する4名が登壇し、 @asonas@sorah が運営として関わってくれています。

本ブログでは、登壇する社員のセッションのスケジュールや、ブースで行う登壇者へのQ&Aタイム、Cookpad Daily Ruby Puzzles など限定企画の紹介をいたします。RubyKaigi 2019に参加する弊社メンバーは内定者を含め約30名! みなさまと交流することを楽しみにしています。

参加社員一覧

@mirakui, @tapster, @kanny, @takai, @hogelog, @l15n, @ko1, @mame, @hokaccha, @eisuke, @giga811, @riseshia, @inohiro, @ukstudio, @hfm, @davidstosik, @aadityataoaria, @sikachu, @asonas, @sorah, @pndcat, @sankichi92, @kojitaniguchi, @to9nariyui

登壇スケジュール

はじめに、社員が登壇するセッションのスケジュールを紹介します。

1日目 4月18日(木)

  • 14:20-15:00 笹田耕一(@ko1): Write a Ruby interpreter in Ruby for Ruby 3
    本発表では、RubyインタプリタをRubyで記述するために必要となる要素技術についてご紹介します。Rubyで、といっても、コア部分は相変わらずCで書く必要がありますが、組込メソッドをなるべくRubyで置き換えていきたいという話になります。ここで、課題になるのは、(1) Ruby だと呼び出しが遅いかもしれない (2) Ruby だと読み込みが遅いかも知れない、の二つです。本発表では、これらをどのように解決するかについて議論します。
  • 15:40-16:20 遠藤侑介(@mame): A Type-level Ruby Interpreter for Testing and Understanding
    Ruby 3の静的解析技術の1案として、Rubyプログラムを型レベルで仮想的に実行するRuby処理系を提案します。すでに提案されている他の静的解析と異なり、型注釈がなくてもなんとなく検査・推定ができるところが特徴です。仮想的な実行の過程で発見された型エラーの可能性や、メソッド呼び出しの引数や返り値の型を報告することで、ユーザのテストやプログラム理解を支援することを目指しています。本発表では、詳しいアイデアと、他手法との比較、現在どこまで実装できているかなどを説明します。

2日目 4月19日(金)

  • 17:20-18:20 ライトニングトーク
    • 井上寛之(@inohiro): Write ETL or ELT data processing jobs with bricolage.

3日目 4月20日(土)

  • 10:00-11:10
    • Cookpad Presents: Ruby Committers vs the World こちらのお時間では、Cookpad Ltd CTOの Miles Woodroffe がご挨拶いたします。また、笹田耕一と遠藤侑介が司会を務めます。
  • 11:20-12:00 Sangyong Sim(@riseshia): Cleaning up a huge ruby application
    cookpad.com を支える巨大なレポジトリから未使用コードの削除を進めています。この作業は比較的コストが高い割にリターンが見えづらいです。この問題をどういった仕組みで解決しようとしているのかについて、お話しします。 Ruby 2.6 で導入された Oneshot coverage などを利用し、本番で実行されたコードを記録する仕組みも紹介します。

ブース

RubyKaigi 2019にて出展するクックパッドブースでは、福岡県にゆかりがある料理の限定レシピ集をお配りするほか、 @mame@ko1 が考案した Cookpad Daily Ruby Puzzles を一日3問ずつ公開いたします。各問題に最小数の文字を追加し、 "Hello world" を出力してください。

# example
def foo
  "Hello world" if
    false
end

puts foo

早く回答できた方にはCookpad Pad RubyKaigi 2019 Edition など、数量限定で特別なプレゼントをご用意しています。みなさんの挑戦お待ちしております! また、下記スケジュールの通り、登壇者へのQ&Aタイムなどを予定しております。グッズの配布や昨年好評だった豆つかみもバージョンアップして行いますので、ぜひお立ち寄りくださいね。

f:id:tokunarigyozadaisuki:20190415112930j:plain

1日目 4月18日(木)

  • 15:10-15:40 【ブースイベント】午後休憩: Q&Aタイム by @ko1
    この時間は、クックパッドブースに @ko1 がおりますので、1日目 14:20-15:00 Write a Ruby interpreter in Ruby for Ruby 3 に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。

2日目 4月19日(金)

  • 12:30-13:00 【ブースイベント】ランチ休憩: Q&Aタイム by @mame
    この時間は、クックパッドブースに @mame がおりますので、1日目 15:40-16:20 A Type-level Ruby Interpreter for Testing and Understanding に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:00-15:40 【ブースイベント】午後休憩: クックパッド子会社 ウミーベ株式会社タイム
    昨年、クックパッドグループに加わったウミーベ株式会社。ウミーベのオフィスはRubyKaigi 2019の開催地、福岡県福岡市の海辺にあります。本時間には、ウミーベCTO @Nia がブースにおりますので、ウミーベのサービスについてご質問がある方、福岡で働くことに興味がある方はぜひお気軽に話しを聞いてみてください。

3日目 4月20日(土)

  • 12:30-13:00 【ブースイベント】ランチ休憩:Q&Aタイム by @riseshia
    この時間は、クックパッドブースに @riseshia がおりますので、3日目 11:20-12:00 Find out potential dead codes from diff に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。

  • 15:10-15:40 【ブースイベント】午後休憩:Cookpad Daily Ruby Puzzlesの解説
    @mame より、ブースにて三日間に渡って出題した全9問の Cookpad Daily Ruby Puzzles の解説を行います。解けた方も解けなかった方も、考案者からの解説を聞いてスッキリしてください! 

おわりに

クックパッドでは、料理で世界に挑戦する仲間を探しています。クックパッドで働くことにご興味のある方は、お気軽にブースにお越しください。また、会場でクックパッド社員をお見かけの際には、お声がけいただけますと嬉しいです! みなさまにお会いできることを社員一同楽しみにしております。

新規事業のIoTプロダクト開発に必要なこと【連載:クックパッドマート開発の裏側 vol.5】

クックパッド 買物事業部の篠原 @shanonim です。社内の新規事業「クックパッドマート」でエンジニアをやっています。

このエントリは、連載シリーズ【連載:クックパッドマート開発の裏側】の第5回目です。本日が最終回となります。 以前のエントリはこちらからご参照ください。

今回はクックパッドマートのIoTプロダクト開発について、開発の概要、これまでの歴史、そしてプロダクトのこれからについてご紹介したいと思います。

このエントリに書いてあること

  • クックパッドマートについて
  • IoTプロダクトの開発経緯
  • 開発の歴史
  • 開発を通して得た学び

このエントリで紹介しないこと

  • IoTプロダクトを構成する個々のデバイスに関する詳しい説明

クックパッドマートの仕組み

クックパッドマートは、生鮮食品に特化したECサービスです。 商品を自宅に直接届けるのではなく、マートステーションと呼ばれる生鮮宅配ロッカー(冷蔵庫)に商品を配送します。
マートステーションは街の様々な場所に設置されており、ユーザーは自分の注文した商品を自分で取りに行くことができます。

f:id:shanonim:20190412174027j:plain
クックパッドマートの仕組み

クックパッドマートのIoTプロダクト

現在、クックパッドマートには大きく2つのIoTプロダクトがあります。

一つは、上図「② 商品の準備」に必要なラベルプリンターです。 こちらについては、2019/4/10に投稿された @imashin_ の記事に詳しい説明があります。

techlife.cookpad.com

もう一つは、上図「④ 商品の受け取り」に関連するスマートロックです。 クックパッドマートのアプリからマートステーションの鍵を操作することで、商品を受け取るユーザーだけがマートステーションにアクセスできる仕組みを作っています。

f:id:shanonim:20190412174146j:plain:w400

このエントリでは、スマートロック開発についてご紹介します。

スマートロックの必要性

マートステーションには大きく、

  • 有人ステーション
  • 無人ステーション

の2つの形式があります。
前者は、街なかのドラッグストアや酒店・カラオケ店など、有人店舗の店内に設置されています。現在オープンしているマートステーションはすべて有人ステーションです。

これに加えて、よりユーザーにとって便利な選択肢を増やすために計画しているのが無人ステーションです。

  • 例えば、駅の構内に無人ステーションがあれば、帰宅途中に最寄り駅で生鮮食品をピックアップすることができます。
  • 例えば、マンションの共用部に無人ステーションがあれば、建物の外に出なくても食品を受け取ることができます。

無人ステーションが解決しなければいけない問題の一つに、セキュリティ問題があります。 有人監視のない無人ステーションの場合、悪意ある第三者による商品へのいたずらや盗難といったリスクを否定できません。「特定の人だけがマートステーションにアクセスできる」仕組みが必要です。

そこで、無人ステーションの本格的展開に先駆けて、マートステーション向けのスマートロックを開発することになりました。

開発の方向性

初めからスマートロック付きの冷蔵庫を買ってきて導入できれば話は早いのですが、私たちのニーズにマッチした商品はなかなか見つかりませんでした。 そこで、既存の冷蔵庫を改修してスマートロックを開発することにしました。

f:id:shanonim:20190412174328j:plain
スマートロック実装前のマートステーション

改修と言っても勝手に冷蔵庫を分解して改造することはできません。
今回の開発は冷蔵庫を分解・改造せずに、スマートロックの機構だけを外付け実装するという条件で進めています。

スマートロックの仕組み

鍵の仕組みと一言で言っても、世の中には様々な方法が存在します。物理鍵を鍵穴に差し込んで回すもの、ダイヤル式の番号を合わせるもの、カードをかざして開け閉めするもの...
マートステーションに必要な鍵の条件は、次の2つでした。

  • 遠隔で操作できる仕組みが作れること
  • 物理的な施錠能力が高いこと

条件に合う仕組みを探した結果、電磁錠に辿り着きました。

f:id:shanonim:20190412174434j:plain

電磁錠は、電磁石の特性を利用した鍵です。金属と電磁石を重ねて設置して通電すると、磁石の力でロックされます。 (通電時施錠型と通電時解錠型がありますが、マートステーションでは前者を使用しています。) 通電状態を遠隔でコントロールできれば、施錠状態をコントロールすることができそうです。
また、物理的な施錠能力も非常に高く、大人が思いっきりドアを引っ張ってもビクともしないくらいの強度があります。

スマートロックは、この電磁錠を中心として開発が進められています。

開発の歴史

現在進行中の開発も加え、これまで5つのプロトタイプを製作してきました。それぞれの世代にはコードネームとして貝の名前がつけられています。(貝の甲羅が開いたり閉じたりする様子が鍵の開け閉めを連想するという @_litmon_ のアイディアです。)

第一世代: シジミ

f:id:shanonim:20190412174717j:plain f:id:shanonim:20190412174642j:plain

鍵の制御用デバイス 鍵の切り替え用デバイス
電磁錠(通電時施錠型) M5Stack Grove - Dry-Reed Relay

冷蔵庫の上部に電磁ロックを取り付けています。アプリからインターネットを介して制御装置(M5Stack)に解錠コマンドを送ると、冷蔵庫の扉を開けることができます。

課題

  • 熱問題: 電磁錠を連続可動させると、本体が熱を持ってしまう問題がありました。機器が壊れるほどの温度ではありませんが、連続稼働に不安があります。
  • 耐久性: 鍵自体の耐久性に問題がありました。鍵がかかっていることを知らずに冷蔵庫の扉を開けたユーザーが鍵を壊してしまう事件がありました。

第二世代: アサリ

f:id:shanonim:20190412174743j:plain f:id:shanonim:20190412174801j:plain

部品構成は第一世代と同じですが、電磁錠の設置場所を冷蔵庫上部から冷蔵庫内部に変更しています。 これにより、前世代の熱問題を解決することができました。

課題

  • 耐久性: 冷蔵庫内部に固定した電磁錠が時折ずれてしまい、うまく鍵が閉まらない事象が頻発しました。

第三世代: ハマグリ

f:id:shanonim:20190412174830j:plain

第三世代では、電磁錠の設置場所が冷蔵庫下部に変更されています。 冷蔵庫の製造メーカーに設置方法を相談したり、スマートロックの先行事例を研究したり、様々な試行錯誤の末にようやく落ち着いた仕様でした。

第四世代: ホタテ

f:id:shanonim:20190412174903j:plain:w400

現在マートステーションの一部で稼働しているスマートロックは、この第四世代です。 この世代では、物理鍵以外のデバイスを刷新しました。

鍵の制御用デバイス 鍵の切り替え用デバイス
電磁錠(通電時ロック型) Androidタブレット スマートプラグ

鍵の制御用デバイスの刷新

これまで鍵の制御に使っていたマイコン(M5Stack)には2つの課題がありました。

  • 安定性: 長時間の連続稼働が難しく、物理的な故障リスクが高い。
  • 視認性: 前面のディスプレイが小さく、鍵がかかっているのかいないのか分かりにくい。

これらを解決するために、マイコンをAndroidタブレットに変更しました。

鍵の切り替え用デバイスの刷新

これまで、物理鍵の制御(電流の制御)は自分で手作りしたデバイスを使っていました。動作的には問題ないのですが、一般的に市販されているデバイスと比べてみると、安全性や耐久性にやや不安が残ります。

f:id:shanonim:20190412174931j:plain:w600
手作りしていた鍵の切り替え用デバイス

そのため、このデバイスを市販のスマートプラグに変更しました。

第五世代: サザエ

第五世代は、現在開発中の次世代プロダクトです。 デバイスの耐久性をより強固にしたり、プロダクトの状態を外部から常時監視できる仕組みを作りたいと思っています。

新規事業のIoTプロダクトの開発に必要なこと

アイディアを片っ端から試す

今回のスマートロック開発では、プロトタイピングのために必要な資材を買って、試して、ダメなら壊す、を高速で繰り返してきました。 日々手探りの連続ですが、思いついたアイディアを片っ端から試していくことが大切です。
思いついた時点では微妙だなーと思うアイディアも、実際に作ってみると案外悪くなかったり、逆にこれでいける!!と思ったアイディアも作ってみるとダメだったりします。
初期の開発においては「雑でもいい、まだプロトタイプなんだから」と割り切ってとにかく手を動かして学びを得ていくのが一番の近道だと思います。

実際に使ってもらう

実際にプロトタイプが形になったら、とにかくいろんな人に使ってもらうことをおすすめします。 これはソフトウェアのサービス開発におけるユーザーインタビューと同じ概念かもしれません。

マートステーションはクックパッド社内にも設置しており、スマートロック開発の際はここを物理ステージング環境として使いました。

f:id:shanonim:20190412175017j:plain

今もユーザー(クックパッド社員)に意見をもらいながら、日々改善を繰り返しています。

まとめ

マートステーションのスマートロック開発を通して得た「IoTプロダクト開発に必要なこと」をご紹介しました。 最初から完璧なIoTプロダクトを作ることはほぼ不可能です。仕様の検討や実装に時間がかかってしまうため、現実的ではありません。
必要なのは、「次世代版までの時間を稼ぐ現世代版を開発する繰り返し」だと思っています。

  • まずは1~10個世代を作る
  • その世代での学びを、次の10~100個世代の開発に活かす
  • さらにその先の運用に耐える100~1000個世代世代を開発する

現在のマートステーションもまだ完成形ではありません。これからも着実かつ高速に、この繰り返しを続けていきたいと思っています。

cookpad mart Meetup

このエントリでお伝えしきれなかった技術的な話やもっと深い話を、4/24に開催されるミートアップでご紹介する予定です。 cookpad.connpass.com

ご興味のある方はぜひご応募ください!

クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

こんにちは。 連載シリーズ4日目を担当します、買物事業部 Androidエンジニアの門田(twitter: @_litmon_ )です。

↓↓↓以前の3日分のエントリはこちらから参照ください↓↓↓

買物事業部では、クックパッドの生鮮食品ECサービス「クックパッドマート」の開発を行っています。 今日は、先日リリースしたばかりのクックパッドマートAndroidアプリを開発する上で、画面実装の工夫について紹介しようと思います。

クックパッドマートAndroidアプリはこちらからダウンロードできます。ぜひ実際に触りながら記事を読んでみてください。 play.google.com

クックパッドマートAndroidアプリの画面実装

クックパッドマートAndroidアプリの主な画面は、大きく分けて3つに分類されます。この分類は、多少の違いはあれど一般的なAndroidアプリに対しても同様のことが言えるのではないでしょうか。

  • 一覧画面: データのリストを一覧表示する画面
  • 詳細画面: 一覧画面の特定のデータに対して詳細を表示する画面
  • 入力画面: データを登録したり追加したりするために入力を行う画面
一覧画面 詳細画面 入力画面
f:id:litmon:20190411123251p:plain f:id:litmon:20190411123345p:plain f:id:litmon:20190411123414p:plain

現代のAndroidアプリ開発において、一覧画面にはRecyclerViewが使われるのが一般的です。RecyclerViewは、同一のレイアウトを複数持つ一覧画面において非常に高いパフォーマンスを発揮するViewですが、AdapterやViewHolderなど実装するものが多く、若干扱いにくいのが難点です。

詳細画面の実装に関しては、スマートフォンのディスプレイは縦に長く、スクロールの方向も縦になるアプリが多いため、 ScrollViewやNestedScrollViewを使ってその中にレイアウトを組むのが一般的だと思います。

入力画面には、EditTextやCheckBoxなどを利用して入力欄を用意すると思います。また、入力項目が多くなった場合には詳細画面同様にScrollViewなどを使ってスクロール出来るように実装することが多いのではないでしょうか。

クックパッドマートAndroidアプリでは、上の例に漏れず一覧画面ではRecyclerViewを使い、詳細画面ではScrollViewを使うというスタイルを取っていたのですが、 このレイアウト手法で開発を進めていくのが大変になっていきました。 特に、詳細画面の実装をScrollViewで行っていくことに関して非常に苦しい思いをした例を紹介します。

レイアウトエディタでのプレビューが活用しづらい

ScrollViewを使って縦に伸びるレイアウトを組む場合、縦に伸びれば伸びるほどレイアウトエディタのプレビュー機能が使いにくくなっていきます。 また、レイアウトファイルも肥大化し、非常に見通しの悪い実装になりがちです。

詳細画面の実装がActivity, Fragmentに集中して肥大化しやすい

RecyclerViewを使うと、Viewの実装の大半はViewHolderクラスに分離することが出来ます。 しかし、詳細画面ではScrollViewを使っているため、データをViewに紐付ける処理をどうしてもActivity, Fragment内に書くことが多くなります。 DataBindingやMVVMアーキテクチャなどを使ってViewの実装をActivity, Fragmentから分離する手法などもありますが、プロジェクトによってはあまり適さないケースもあるでしょう。 また、RecyclerViewを使う一覧画面と実装差異が出てしまい、処理の共通化などが難しくなってしまいます。

なにより実装していて苦しい

詳細画面のような複雑なレイアウト構成を1つのレイアウトファイルに対して上から順に実装していくのは、精神的にも苦しいものがあります。 長くなればなるほどレイアウトエディタ, XML両方の編集作業が難しくなっていくため、細かい単位でレイアウトを分割できるRecyclerViewのような仕組みが欲しくなってきます。

includeタグ?知らない子ですね……

すべての画面をRecyclerView化する

そこで、RecyclerViewをうまく使うことで詳細画面もうまく組み立てることが出来るのでは?と考えました。RecyclerViewは、レイアウトを行ごとに分割して作成することが出来るし、ViewHolderへViewの実装を委譲出来るため、ActivityやFragmentの肥大化を防ぐことが出来ます。 ただ、RecyclerViewの実装には、AdapterとViewHolderの実装が必要で、特に複雑な画面になるほどAdapterの実装が面倒になっていきます。

class DeliveryDetailOrderItemsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    override fun getItemCount(): Int =
        1 + 1 + items.size + 1

    override fun getItemViewType(position: Int): Int {
        if (position == 0) {
            return R.layout.item_delivery_detail_header_label
        }

        if (position == 1) {
            return R.layout.item_delivery_detail_order_item_note
        }

        if (position == itemCount - 1) {
            return R.layout.item_delivery_detail_order_item_footer
        }

        return R.layout.item_delivery_detail_order_item
    }
}

RecyclerViewで受け取り詳細画面を実装したときの一部を抜粋してきました。 表示するpositionに応じてそれぞれのitemViewTypeを変える必要があるのですが、全く直感的ではなく、頭を使って実装する必要があります。 また、Viewを追加したいという変更があったときに、他の部分にも影響が出る場合があるので、保守性も高くありません。

すべての画面でこのような複雑なRecyclerView.Adapterを実装するのは気が滅入りますし、現実的ではありません。 しかも、読み込んだデータに応じて表示を変えなければいけない、となるとより面倒になるのは必至です。 そのため、RecyclerView.Adapterの実装を簡素に行うためのライブラリを導入することにしました。

Groupieを使う

RecyclerView.Adapterの面倒な実装を便利にしてくれるライブラリは巷にいくつかありますが、今回はGroupieを使うことにしました。 同様の仕組みを持つEpoxyというライブラリも候補に上がっていましたが、判断の決め手となったのは以下の点でした。

  • EpoxyはannotationProcessorを使ったコード生成機構が備わっており、DataBindingと連携させるととても便利だが、クックパッドマートAndroidアプリではDataBindingを使っておらずオーバースペックだった
  • GroupieはRecyclerView.Adapterを置き換えるだけで使えるので非常に簡素で、今回のユースケースにマッチしていた

例えば、Groupieを使って一覧画面のようなデータのリストを表示させるために必要なコードは以下です。

data class Data(val name: String)

class DataItem(val data: Data) : Item<ViewHolder>() {
   override fun getLayoutId(): Int = R.layout.item_data

   override fun bind(viewHolder: ViewHolder, position: Int) {
       viewHolder.root.name.text = name
   }
}

val items = listOf<Data>() // APIから返ってきたリストとする
val adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    items.forEach {
      add(DataItem(it))
    }
})

たったこれだけです。とても簡単ですね。

詳細画面の場合、データの有無で表示する/しないを切り替える必要があったりしますよね。 例えば、クックパッドマートAndroidアプリのカート画面では、カートに商品が追加されていない場合と追加されている場合で表示が異なります。

f:id:litmon:20190411123600p:plain:h300 f:id:litmon:20190411123620p:plain:h300

このようなレイアウトになるようにRecyclerView.Adapterを自前で実装しようとすると、 getItemViewType() メソッドの実装に苦しむ姿が簡単に想像できますね……絶対にやりたくありません。

しかしこれも、Groupieで表現すると以下のように簡単に表現することができます。簡略化のため、表示が変わる部分のみを表現します。

class Cart(
    val products: List<Product>
) {
    class Product
}

class CartEmptyItem : Item<ViewHolder>() { /* 省略 */ }
class CartProductItem(val product: Cart.Product) : Item<ViewHolder>() { /* 省略 */ }

val cart = Cart() // APIから返ってきたカートオブジェクト
val adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    if (cart.products.isEmpty()) {
        add(CartEmptyItem()) // 商品が追加されていない旨を表示する
    } else {
        cart.products.forEach {
            add(CartProductItem(it)) // カートの商品をリストで表示する
        }
    }
})

非常にコンパクトな実装に収まります。 前述の例とあわせて見ると、getItemViewType() を実装するのに比べて直感的になることも理解できると思います。

LiveDataと組み合わせて使う

LiveDataと組み合わせて使う場合もとても簡単です。Fragment内で使うことを例に挙げてみましょう。

class CartFragment : Fragment() {

    class CartViewModel : ViewModel {
        val data = MutableLiveData<Cart>()
    }

    val viewModel by lazy {
        ViewModelProviders.of(this).get<CartViewModel>()
    }

    val adapter = GroupAdapter<ViewHolder>()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_cart, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        recycler_view.adapter = adapter

        viewModel.data.observe(this, Observer {
            it?.let { cart ->
                adapter.update(mutableListOf<Group>().apply {
                    cart.products.forEach { product ->
                        CartProductItem(product)
                    }
                })
            }
        })
    }
}

非常に簡単ですね。 Groupieはupdate時に内部でDiffUtilsを使って差分更新を行ってくれるため、APIリクエストを行った結果をLiveDataで流すだけで簡単に更新が出来ます。

その際、GroupieのItemに対して以下の2点を見ておく必要があります。

  • id が同一になるようになっているか
  • equals が実装されているか

idの設定は、getId() メソッドをoverrideすると良いでしょう。 もしくは、Itemクラスのコンストラクタ引数にidを渡すことも出来ます。

equals() メソッドの実装は、Kotlinならばdata classで簡単に実装することが出来ます。 また、引数を持たないようなItemで、特に中の内容も変わらないような場合は自前で実装してしまっても良いでしょう。

data class CartProductItem(val product: Cart.Product) : Item<ViewHolder>() {

    override fun getLayoutId(): Int = R.layout.item_data

    override fun getId(): Int = product.id

    override fun bind(viewHolder: ViewHolder, position: Int) {
        viewHolder.root.name.text = name
    }
}

// idをコンストラクタで指定
class CartEmptyItem : Item<ViewHolder>(0) {

    override fun getLayoutId(): Int = R.layout.item_data

    override fun hashCode(): Int = 0

    // 同じItemなら同じと判定して良い
    override fun equals(other: Object): Boolean =
        (other instanceOf CartEmptyItem)

    override fun bind(viewHolder: ViewHolder, position: Int) {
        // ignore
    }
}

これらの設定がうまくいっていない場合、更新されたときに無駄なアニメーションが走ってしまうため、できるだけ全てのItemに実装しておくことをオススメします。

実際にクックパッドマートAndroidアプリでは、ほぼすべての画面がこの構成を取って実装していて、画面回転時やFragmentのView再生成にも問題なく状態を再現してくれるのでとても便利な構成になっています。

Groupieを使うことで良くなった点

Groupieを使うことで、RecyclerViewの面倒な実装を簡略化でき、かつすべての画面の実装を定型化することが出来ました。 これにより、以下のような効果が生まれました。

  • Fragmentの実装をすべての画面でほぼ定形化出来るため、精神的に楽に実装できるようになり、かつ処理の共通化が簡単になった
  • RecyclerView.Adapterに比べて、複雑なレイアウトを組むのが非常に簡単なので、実装工数を大幅に削減することが出来た
  • レイアウトが強制的にItem単位になるため、シンプルにレイアウトを作成することが出来るようになった

1つ目の処理の共通化には、例えばエラー画面が挙げられます。 読み込みエラー時の画面表示をGroupieのItemで用意することで、非常に簡単に全ての画面で同一のエラー画面を用意することが出来ます。

class ErrorItem(val throwable: Throwable): Item<ViewHolder>() {
    /* 省略 */
}

apiRequest()
    .onSuccess { data ->
        adapter.update(mutableListOf<Group>().apply {
            add(DataItem(data))
        })
    }
    .onError { throwable ->
        adapter.update(mutableListOf<Group>().apply {
            add(ErrorItem(throwable))
        })
    }

また、アプリ内のItemの間に表示されている罫線も、RecyclerViewのItemDecorationを使うことでアプリ全体で簡単に共通化することが出来ました。 RecyclerViewにLinearLayoutManagerとあわせてdividerを設定することがとても多かったため、RecyclerViewに以下の拡張関数を実装して使うようにしています。

fun RecyclerView.applyLinearLayoutManager(orientation: Int = RecyclerView.VERTICAL, withDivider: Boolean = true) {
    layoutManager = LinearLayoutManager(context).apply { this.orientation = orientation }
    if (withDivider) {
        addItemDecoration(DividerItemDecoration(context, orientation).apply {
            ContextCompat.getDrawable(context, R.drawable.border)?.let(this::setDrawable)
        })
    }
}

Groupieで難しかった点

Groupieを使っていて、難しかった点もいくつかあります。 例えば、受け取り場所の詳細画面には地図を表示しているのですが、MapViewにはMapFragmentをアタッチする必要があります。 単純にaddするだけの実装だと、スクロールして戻ってきたときにクラッシュしてしまうので、unbind時にFragmentを取り除く必要がありました。 苦肉の策ですが、現状は以下のような実装になっています。

data class SelectAreaDetailMapItem(
    val item: Location,
    val mapFragment: SupportMapFragment,
    val fragmentManager: FragmentManager
) : Item<ViewHolder>() {
    override fun getLayout(): Int = R.layout.item_select_area_detail_header

    override fun getId(): Long = layout.toLong()

    override fun bind(viewHolder: ViewHolder, position: Int) {
        val markerPosition = LatLng(item.latitude, item.longitude)
        fragmentManager.beginTransaction()
            .add(R.id.item_select_area_detail_map, mapFragment)
            .commit()
        mapFragment.getMapAsync { map ->
            map.addMarker(MarkerOptions().position(markerPosition))
            map.moveCamera(CameraUpdateFactory.newLatLng(markerPosition))
            map.setMinZoomPreference(15f)
        }
    }

    override fun unbind(holder: ViewHolder) {
        super.unbind(holder)
        fragmentManager.beginTransaction()
            .remove(mapFragment)
            .commit()
    }
}

すべての画面でGroupieを使うことで実装が簡単になった、アプリ全体で処理を共通化出来たというメリットはありましたが、こういう風に扱いに困るケースもあるため、用法用量を守って正しくお使いください。

まとめ

  • Androidアプリ開発において主要な画面はだいたいRecyclerViewで表現できる
  • Groupieを使うと実装も簡単になって最高
  • 難しい画面もあるので適材適所で使い分けよう

おしらせ

4/24(木)に、買物事業部のエンジニアによる発表とエンジニアとのミートアップを開催します!!! cookpad.connpass.com

そこでは、今回語らなかったAndroidアプリの技術的な部分を紹介していこうと思います。 実際のソースコードも見せたり……あるかもしれませんね。 ぜひぜひ!!ご興味のある方は参加お待ちしています!