分析用ログデータに対する品質保証としての異常検知

クックパッドでデータにまつわるあれやこれやをずっとやってる佐藤です。分析・調査に仮説検証にデータパイプラインにと色々やってました。ちなみに先日はCyberpunk2077休暇をとるなどという呑気なことをしていたら、この記事でやりたかったことがほぼできそうなサービスがAWSから発表されて頭を抱えながら書いています。

そのログはどこまで信頼できるのか

クックパッドではサービス改善のためにWebサイトやアプリからログを収集して開発を行っています。これらのログは集計された後、ダッシュボードの形で可視化されてサービス開発者たちの意思決定を支えています。
クックパッドのログ基盤はログ送信側(クライアントサイド)もログ格納側(DWHサイド)も十分に整っており、いつでも必要であれば簡単にログを送信・集計するだけの仕組みができあがっています。

f:id:ragi256:20201216120246p:plain
アプリログにおける大雑把なログ収集の図
(注:例として上図を載せましたが当記事の内容はアプリに限りません)

しかし一方で、送り続けているログの管理・保守にはここ数年課題を感じています。例えば、iOSアプリのダッシュボードを見ていて、去年の6月に突然トップページのDAUだけが激増していることに気付いたとします。しかし、この原因を見付けることは非常に難しいのです。
クックパッドではWebもアプリも多くの人々が開発者として関わっています。このため、誰かがいつかどこかに加えた変化によってアプリのログへ気づかないうちに影響を及ぼしていたということが起こりえます。自分たちの担当する領域で普段は見ない数値を確認してみたら、実は半年前に大きく動いていた。だが特に何かをした記憶がない。こういったケースではどのように原因を特定すればよいでしょうか? KPIに直結しない数値・特定の条件に限定して算出した数値・実数ではなく比率に変換した数値などで、後になってから気づくことが多くありました。

より快適なサービス開発を行うためには、安心してサービスに関わる数値を確認できる状態でなければなりません。そのためにはこういったログに関する課題は解決する必要があります。そこでまず、ログの品質を保証するためにどんなことができるか考えた結果、ログデータの異常検知に取り組むこととなりました。

どうやって開発をすすめるのか

今回、異常検知をやるにあたっていくつか当初から決めていたことがありました。

  1. 作り込みすぎない、とりあえず使える状態を目指す
  2. 全体をパーツとして作ってできる限り交換可能にする
  3. 異常検知そのものだけではなく全体フローの最適化を重視する

これらは異常検知という仕組みが、あくまでもログの品質維持の取り組みのひとつに過ぎないことが理由となっています。もし試してみて全然だめそうだったり、より有望そうな他の手段が思いつけばいつでもピボットしたいと考えていました。
一方で既存の研究分野で培われた時系列モデルやアルゴリズムは、いつかどこかで試してみるタイミングがやってくるとも考えていました。そうなった時、いつでも任意のポイントに対する差し替えが可能となるよう、機能分割のタイミングを逃さないよう開発を進めることとしました。
こういった事情があり、最初から「scikit-leanで回帰モデルを試す」「Prophetを利用する」といった手法ありきの取り組みや「異常を検知したらそれで終わり」といった姿勢を取らないように注意していました。全体的な検知フローを重視していかにしてログの品質保証に繋がるかを考えての方針です。

この方針のもと、異常検知の仕組みは次の3つのステップの順で開発を行っています。

Step.1 MVPを作って自分で試す

まず本当に異常検知すると嬉しいのかどうかを半信半疑になって確認する必要があります。ログの異常検知をすると決まった時点で、DWHに蓄積された各種ログの集計内容を監視して上振れ・下振れなどの変化を監視することは決定していました。ただし、この時点では変化点検知(change point detect)か外れ値検知(outlier detect)かはまだ決まっていません。

最も手軽に異常検知をしようと思った時、DWHからデータを引っ張ってきて、既存の異常検知ライブラリを使って判定するのは時間がかかりすぎるように感じました。
そこでまずMVPとして、SQLのみで異常検知することにしました。最も基本的な時系列モデルはちょっとしたSQLで書くことができるため、ここをベースラインとしてまず仕組み全体を作り上げてしまうことを考えます。
ベースラインとして採用したのは過去n日間の平均・標準偏差を利用した予測です。

f:id:ragi256:20201216120545p:plain
仮に過去平均7日間、σ係数を3と置いたときの図

これは集計済みテーブルさえ用意されていればwindow関数で手軽に書くことができます。もし予測範囲に収まらなかった場合、(少々行儀が悪いですが)ゼロ割を使って無理やりSQLをエラーにします。

  select
    data_date
    , uu
    -- uu range: μ ± 3 * σ
    , case when uu between week_avg - 3 * week_stdev and week_avg + 3 * week_stdev
      then 1    -- pass
      else uu/0 -- assert(ゼロ割)
    end alert
  from (
      select
        data_date -- 対象テーブルにある日付カラム
        , uu      -- 異常検知を行いたい対象となる数値のカラム(ここでは仮にuu)
        -- ↓平均と標準偏差の計算に当日は含まないため微妙にずれる
        , avg(uu) over (partition by uu order by data_date rows between 8 preceding and 1 preceding) as avg
        , stddev_pop(uu) over (partition by uu order by data_date rows between 8 preceding and 1 preceding) as stddev

      from
        $alert_target_table -- 異常検知をしかける対象のテーブル
      where
        data_date >= current_date - interval '8 days'
  )
  where
    data_date = current_date

このSQLをバッチジョブとして毎朝実行させ1、ジョブがゼロ割エラーでコケたらSlackに通知を流すようにしました。

f:id:ragi256:20201216121116p:plain
バッチがコケるととりあえずこのエラー通知がSlackに流れる

MVPだけあって当初は大量に誤報が鳴り、ほぼ毎回アラートがあがるのでこのままでは使い物にならないことがわかりました。ですが、このときアラートの精度に関しては一切考えず一旦ワークフローを固めることを考えました。仮にこの誤報が減り、今鳴っているアラートが正しい異常検知の結果であったとした場合、自分は次に何をしたくなるだろうかと考えます。
実際、自分がアラートを見た時には「これは誤報か?確報か?」と毎回調べていましたので次に何をするかは「アラートが何故なったのかを調べる」ということがわかっていました。異常検知アラートの作成者以外も「なぜ異常検知のアラートが鳴ったのか?」を容易に知ることができる状態にしておく必要があります。そこで異常検知している様子がわかりやすくなるように下記のようなグラフを作成し、自動更新がされるように準備しました。

f:id:ragi256:20201216121245p:plain
異常検知の様子をわかりやすく可視化するためのグラフ、オレンジと青が上限・下限で緑がn日間平均

同時にこのグラフを作ったことで何故こんなにも誤報が大量発生したのかも発覚しました。過去に収集していたが今はもう使わなくなったログ・送信条件が厳しく流量の少ないログなどが多く含まれていたため、異常検知に用いるには欠損点が多く不安定な時系列データとなっていたためでした。そこで品質を保証する意義のあるログは「多くのユーザーに」「ある一定期間は使われている」ログと見なして流量と取得日数をフィルターすることにしました。
Step1の始めに「外れ値検知か変化点検値か決めていない」と書きましたが、この時点で外れ値検知ではなく変化点検知を行うことに決定しました。この異常検知システムによって検知したいのは後から対処しようのないサービスの瞬間的な異常ではなく、ログに関する実装の修正を必要とするような開発時点でのエラー・修正ミス・抜け漏れなどを捉えたかったためです。

\ グラフ
f:id:ragi256:20201216121500p:plain
f:id:ragi256:20201216121519p:plain

こういった工夫により、平均と標準偏差という最も素朴な基準でも誤報を減らすことができるようになりました。ハイパーパラメータはモデルではなくモデルに投入するデータのほうにもあったようです。ここでフィルターに用いる各種パラメータをSQLから分離させて対象とするテーブルごとに変更できるようにしておきます。

Step.2 他の人にも使ってもらえるように触りやすい仕組みを整える

Step1の状態では異常検知の仕組みを作った自分しか扱い方がわからず、アラートがきても何をどうしたら良いかわからない状態でした。この仕組みをサービスの開発にも活かすためには、多くの人に使ってもらえるようアラートが鳴ったらどうするかわかりやすいインターフェイスにする必要があります。
そこで、より異常検知した状況をつかみやすく、その後のリアクションをとりやすくするために通知内容を改善することにしました。

通知内容を改善するにあたって、これまでのただエラーを流していただけの状態を改修する必要がありました。そこでまず、バッチジョブの中身を修正し2つの処理に分割することにします。この分割で片方の処理の持つ責務を「異常を検知すること」、もう片方の処理の持つ責務を「検知した内容をどうにかして伝えること」にわけます。こうすることで仮にSlack以外のツールに通知を流す場合や、通知先はSlackのまま異常検知方法を切り替えるといった作業をしやすくなります。
そして、Slackへの通知を行う処理としてSlack WorkflowのWebhookを利用することにしました。このSlack WorkflowはSlack上でステップやタスクを実施してもらうことで定形的なプロセスを自動処理しやすくする仕組みです。また、外部アプリやサービスとの連携も豊富なため、Workflow内のステップで起こしたアクションを外部に渡すことができます。 通常のWebhookでは単純に情報をSlackへ流すだけとなってしまい、検知に対するアクションをとってもらいにくいと考えてWorkflowを採用しました。

f:id:ragi256:20201216121713p:plain
Workflow builderで作成、フォームを2つ加えて後述のGoogleSpreadsheetと連携させている

上図のようにフローを組むことでSlackへ情報を流すとともにリアクションをとってもらえるようになります。今回このWorkflowで設定した異常検知に対するリアクションとは「対応の方針を決める」「なぜその方針に決定したのか理由を書く」の2つです。異常検知が正しく働いたとして、それでも何も対応する必要のないケースもあります。なので「このアラートは無視する、古いバージョンからのログなので放って置いても困らないため」「このアラートはきちんと調査をする、重要指標が減少していてもしも本当に落ち込んでいたら緊急事態のため」と書き込んでもらうことにしました。

f:id:ragi256:20201216121946p:plain
アラートが鳴ったときに流れるメッセージ
f:id:ragi256:20201216122007p:plain
上記メッセージのボタンに反応した後に続くメッセージ

この通知内容によって開発者にログの異常に関するリアクションをとってもらって、「このログ異常は何故起きたのだろう?」「このアラートは無視していいものだろうか」と考えてもらおうというのが狙いです。
このSlack Workflowへの通知切り替えを再度自分でも使ってみて、アラート量的にも対応負荷的にも問題なさそうと感じたあたりでStep3に移りました。

Step.3 他の人にも使ってもらう

いよいよ自分だけではなく誰か別の人にも使ってもらう段階です。手始めに社内用ブログに上記取り組みを投稿して軽く共有し、クックパッドアプリのiOSやAndroidエンジニアが集まるチャンネルで使ってもらうこととしました。
その週にはアラートが鳴り、何度かアラートの対応をしてもらうことができました。しかしすべてがスムーズに進んだというわけもなく、いくつかの改善点がSlackでの会話から浮かび上がりました。

f:id:ragi256:20201216122135p:plain
早速フィードバックがもらえている様子

Slack Workflowという多くの人の目に見える形でアラートをセットしたことで、このようなやりとりをSlack上でこなすことができるようになりました。
また、リアクションをしてもらった結果は自動でGoogleSpreadsheetに溜め込まれていきます。こちらのシートに溜まった知見を元に今後のアラートの改善にもつなげていこうと考えています。  

f:id:ragi256:20201216122221p:plain
2つのフォームから書き込まれた内容が貯まるシート

これから

冒頭にも書きましたとおり今年の re:inventでAWSからAmazon Lookout for Metricsが発表されました。こちらはまだプレビュー版ですが、今回作った異常検知フローをそのまま置き換えることができるかもしれません。幸いにして今回のフローはアルゴリズムやチューニングに注力することなく、最小の労力をもって「ログの品質を保つためにはどんな仕組みが必要となるか?」の模索した解決案の一つに留まっていました。このため最終的な唯一の課題解決手法ではなく、むしろ課題を理解するためのプロトタイプに近く、実際に運用してみることで品質維持のために求められる多くの要素を知ることができました。

  1. 古いバージョンのログをどうするか
  2. 流量の多いログと少ないログの両方同時に監視すると発生する変化量の差をどうするか
  3. 既存の時系列解析や異常検知の研究手法で使われているアルゴリズムやモデルをいつ・どうやって・どう判断しながら組み込んでいくか
  4. そもそも「異常検知」では応急措置的な対応しかできないが、品質維持のために根本的対策や事前防止策をとることはできないか
  5. (他多数)

これらの要素を元に今回作成したシステムとAmazon Lookout for Metricsを比較することでより良い解決策と改善フローを実行できると考えています。

ログの異常というのは本来は起きてほしくない状況ではあります。知らず知らずの内にそのログ異常が起こっていて後から困るという自体を防ぐために、変化点検知作業を自動化する仕組みを整えることができました。まだまだ実用上では粗い点もありますが、漸進的開発をしやすい開発方針をとってきたのでこれからも徐々に改善していくことで「クックパッドではこうやってログの品質を保証しています」と言い切れるデータ基盤を目指していきます。


  1. SQLとバッチジョブの実行に関しては弊社OSSのKuroko2bricolageを利用しています。

キッチンでの微細な重量変化を捉えるには?

こんにちは.研究開発部の鈴本 (@_meltingrabbit) です.

クックパッドの研究開発部では,ユーザーの課題を解決する手段をスマホの中からスマホの外(実世界)に拡張しようとチャレンジしています. 特に,料理を「作る」時の課題を解決するため,様々なデバイスを開発し,調理に関する知識と組み合わせることで,新たな調理支援の方策を切り開こうとしています.

その中の一例として,キッチンのワークトップやコンロでの微細な重量変化が取得できるデバイスをフルスクラッチで構築しました. 本稿ではその取り組みについてご紹介します.

調理中/調理後に知りたい情報?

調理において,重要な情報とは何でしょうか?

  • 分量(重量,体積)
  • 火加減(熱量,温度)
  • 加熱時間(時間)
  • 味付けの濃さ(塩分などの濃度,調味料などの重さ)
  • 焼き色(色,温度)

... などなど,様々なことに目を配りながら,日々調理していると思います.

今回は重量に着目してみます. 調理中の重量変化が取得できると,どういった調理支援が考えられるでしょうか?

いくつか考えてみます.

まな板上にあるカット中の肉や野菜の重量がわかれば,何人前の料理を作っているかがわかります. それによって,その分量に合わせて微修正されたレシピを提示する,といったサポートが考えられます.

また,鍋やフライパンなどに入れた調味料の分量が自動で計量されていたら,今回の味付けが記録できるかもしれません. 食べた後に,「今回はちょっと味が濃かったなぁ」など感じたときに,その時の調理を振り返られるかもしれません.

さらに,例えば唐揚げは,揚げているときに鶏肉の重量が徐々に減少していくことが知られています. この重量変化をつぶさに捉えることができれば,「今がベストな揚げ終わりのタイミングだ!」などと教えてあげることができるかもしれません.

以上のようなモチベーションから,キッチンワークトップの重量を高精度に取得してみよう,と思い立ちました.

調理支援のために必要な重量センサとは

調理支援のための重量センサについて考えます.

上で記したようなことを実現しようとするのであれば,少なくとも小さじ1杯,つまり5 g程度の重量測定精度が不可欠です. そして,ワークトップにのる可能性がある物(コンロや鍋や食材,加えて人間の押し込み荷重など)を考えていくと,最大荷重は最低でも50 kg程度が求められます.

とすると,,,最大荷重は50 kgと仮定して,ダイナミックレンジが1/10,000!?の重量センサを欲している,というわけです. そんな精度でかつキッチンに設置できるものなど,そう簡単には手に入りません.

残された道は,フルスクラッチでの自作. 自作するために,重量計測に対する要求仕様を以下のように策定しました.

  • 測定精度は5 g以下で,最小測定分解能はその1/10以上
  • ダイナミックレンジは可能な限り広く.最大許容荷重は50 kgを超えることを目標
  • サンプリング周波数は高ければ高いほどよい.1,000 Hz程度は超えたい
  • 精度要求が厳しければ,リニアリティとオフセットドラフト,温度特性は妥協する.一方でヒステリシス特性やリピータビリティは重視する

また,既存のシステムキッチンを改造して重量センサを埋め込むのはハードルが高いため,すでにあるキッチンワークトップの上に設置し,その上で調理することにしました. そうすると,次のような構造的な要求が発生します.

  • 既存のキッチンワークトップに設置し,その上で調理するため,可能な限り背の低い形状にする
  • IHコンロとまな板とその他をその上に置くことを想定するので,天板の大きさは1,000 x 600 mm程度にする

得られたデータはリアルタイムに解析し,調理支援という形で調理する人にフィードバックできる必要があります. 高精度・高頻度に計測されたデータを後から抽出でき,解析できる,といったシステムでは,想定している調理支援には使えません. そこで,さらに次のようなシステム要求が追加されました.

  • 取得データはリアルタイムで社内システムで利用できる(例えばAWS IoT Coreに送信できる,など)

これらの要求を満たす重量センサをフルスクラッチしていきます.

実装

最初に実装結果を記します.

下図のようなものが完成しました.

先述したとおり,システムキッチン自体を改造するのは難しいため,既存のキッチンのワークトップに乗せて使用します. IHコンロ,まな板,はかりが乗っているアルミ天板の上の重量が高精度かつ高頻度に測定できます.

f:id:meltingrabbit:20201209030436j:plain
開発した重量センサ

達成されたスペックは,次のとおりです.

  • 測定精度は,設計値は4 gで,実際に使ってみると2 g程度はありそう
  • 許容最大荷重の実効値が54 kgで,絶対最大定格の実効値が84 kg
  • 最高計測周波数が100 kHz(実質的に使えるのはせいぜい数kHz程度か?)

構造の概要は下図に示すとおりで,上から,超低頭ネジ,アルミ天板,カーボンロッド,アルミ治具,荷重センサ(ロードセル),ネジ,アルミ治具,ネジ,アルミフレーム,です. 天板の四隅を荷重センサで支持,計量する構造となっています. (したがって,今後のソフトウェアの改良で重量分布も取得可能になる予定)

f:id:meltingrabbit:20201209030534p:plain
構造図

この重量センサを開発するのには,いくつかのハードルがありました.

まずは,高精度な荷重センサの選定です.いろいろと検討した結果,工場などで用いられる産業用のロードセルを用いていますが,もともとのセンサの想定使用方法通りには使われていません.

また,ダイナミックレンジを大きくするためには,天板をできるだけ軽く作る必要がありました. なぜなら,ロードセルの許容最大荷重が例えば60 kgだとしても,天板の重さが20 kgもあると,実質的に計量可能な最大荷重は40 kgになってしまうからです. 一方で,軽く作ることに集中し,天板の剛性が十分でないと,荷重によって天板がたわみ,余計な力がロードセルに加わってしまい,正確な重量計測ができなくなります.

このサイズですと,板厚3 mm程度のアルミ板ですらおよそ5 kgの重量にもなりますが,両端単純支持では中央に20 kg程度の負荷をかけただけで50 mm程度はたわんでしまうのです. 軽い金属のアルミですら,こんなもんです. この軽量と高剛性という相反する要求を満たすために,最適な天板の厚みを計算しました. そして,アルミ板のみでは剛性が確保できないので,カーボンロッドで補強するなどしています.

カーボンロッドとアルミ天板との接着には,アクリル樹脂系の2液式接着剤を使いましたが,これも大変でした. 2液式接着剤とは,接着前に2種類の溶剤を自分で混ぜて,それが固化する前に接着面に塗りつけ接着するというものです. 今回用いた接着剤は,混合から固化までの時間が90秒だった(カーボンとアルミが接着可能で入手性の良いものがこれしかなかった)ので,小さなパーツの接着ならまだしも,1 m弱の大きなロッドに対して,

  • そこそこ大量の溶剤を均一に混ぜ合わせる
  • それをカーボンロッドとアルミ板に均一に塗る(均一でないとそこで応力集中してしまう)
  • 位置を合わせて仮固定する

を90秒以内に終わらせるというタイムアタックをしなくてはいけないのがきつかったです.

さらには,ロードセルの計測値をリアルタイムに収集し,処理するのためのソフトウェアも重要です.

今回用いたロードセルは,工場などに組み込むことを前提として,RS232Cで計測信号を出力するインターフェイスはメーカーから提供を受けることが可能でした. しかし,RS232Cなので高頻度計測にはボーレートの問題もあるし,4つのセルからの信号を時刻同期しつつ高速に収集するミドルウェアを書くのはめんどくさいなぁ,と思ってしまいました.

最終的には,ロードセルの実体はひずみゲージのブリッジ回路なので,それを駆動して出力をAD変換できるロガーを別途購入し,そこからリアルタイムにデータを取得し,適切な信号処理を施し, 社内の調理支援システムへリアルタイムへデータを送信する,というソフトウェアを自作しました.

いざ,計測

要求仕様の出どころであった小さじ1杯を計測してみました.ワークトップ上に小皿を置き,そこに小さじ1杯を投入していったときの計測結果が下の2図です.

信号処理のフィルタのチューニングを変えていて,1枚目は高頻度な成分を抽出できるような設定になっており, 2枚目はかなり強めのLPFを挟むことで質量の増分を分解しやすい設定になっています.

そのため,1枚目では,水を小皿に垂らしたときの振動がよく計測されている一方で, 2枚目ではおよそ1,5,15秒のところでおおよそ大さじ1杯の水が投入されたということが明瞭に分かる結果となっています.

同じ操作を異なる信号処理で観察すると,違った特徴が見えるのは面白いですね.

次の図は,唐揚げを揚げたときの結果です. 3つの唐揚げを鍋に投入し,それをおよそ2分半揚げ,ひとつずつ取り出した履歴です.

0〜0.5分では,ひとつひとつの唐揚げの重さが分解できています. また,0.4〜2.7分頃では,揚げている最中に唐揚げ内部の水分が蒸発して軽くなっている様が観察できます.

まとめと今後

このように,これからの研究開発のための1センシングデバイスとしての重量センサが,無事に目指していたスペックを満たして実装することができました. 実は,クックパッドではソフトウェア開発のみならず,こういったハードウェアの開発も行っているのです. 今後はこれらを使い,調理中の様々なイベントを収録し,そして社内の様々な知見や技術(ハードもソフトも)を総動員して,「毎日の料理が楽しくなる」ような調理支援の開発を目指していきます!

基本の 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の開発環境は少しずつ良くなっていくと思うので、また社内ドキュメントを大きく見直す機会がありましたらまた記事にして紹介したいと思います。