クックパッドの検索反映時間を 1/288 にしたシステム改修

こんにちは。レシピ事業部の新井(@SpicyCoffee)です。

クックパッドではこれまで、レシピを投稿してから検索結果に反映されるまで最長で 24 時間程度の時間がかかっていました。今回、この時間を 5 分程度、最長でも 10 分程度に短縮することに成功しました。本記事では、プロジェクトオーナーの立場で関わった私が代表してその開発について紹介します。

プロジェクトの目的と数値目標

本プロジェクトでは上記の「レシピを投稿してから検索結果に反映されるまでの時間短縮」が目的とされました。しかし、時間短縮といっても現状 24 時間であるものを "1 時間" にするのか、"1 分" にするのか、"1 秒" にするのかでは話が全然違います。この数値目標は設計を始めとした後の意思決定に大きく影響を与えるため、しっかりとした意図を持った状態で明確に定めておく必要がありました。

そこで、私とプロダクトオーナー*1が議論を重ね、まずは ”今回のプロジェクトで実現したいユーザー体験" を定めました。その体験から必要となる検索結果の反映頻度を逆算し、最終的な数値目標を「中央値 5 分程度、最大でも 10 分以内の検索結果への反映」であると定めることとなりました。同時に定めたプロジェクトのスケジュールは 6 週間であり、見積もりの第一印象としてはかなりギリギリの設定でした。

この記事では、今後本プロジェクトで実現された「検索結果が反映されるまでの時間の短縮」を “short-period indexing” と呼称することにします。

旧システムの概要

プロジェクト発足時点での検索周りのシステム(以下旧システム)を以下に示します。

旧システムの構成

旧システムの肝は以下の2点です。

検索インデックスを生成する日次バッチ

旧システムでは、検索結果の更新を 24 時間に一度でいいと割り切り、日次バッチでインデックスの更新を行っていました。レシピに関する各種メタデータを集め、必要に応じて加工することでドキュメントを生成し、そのドキュメントを Solr に送信することでインデックスを生成します。生成されたインデックスは後ほど説明する ECS を利用したデプロイメントのために S3 に配置されます。

日次更新でよいという割り切りの元に、およそ 100 を超える field の情報を数百万レシピについて毎日生成しており、中には機械学習を用いてレシピにスコアを付与するような処理も含まれていたため、その実行時間は 90 分程度になっていました。

ちなみに、このバッチ自体も 5 年ほど前に旧システムから分離・リプレイスされたものになります。当時の様子は以下の記事に記載してあるため、よろしければあわせてご覧ください。

ECS を利用したデプロイメント

旧システムでは、ECS のタスクとして Solr を起動していました。一般に、検索エンジンのようなステートフルなミドルウェアと ECS は相性がよくないとされています。しかし、旧システムでは S3 にインデックスを配置してタスクの起動時にそれをダウンロードしてくることでステートをコンテナの外に出し、その相性の悪さを解消しています。

この設計は「ステート(= インデックス)の更新頻度が十分に低い」という前提に基づいているものであり、本プロジェクトの目的を達成するためにはリプレイスする必要性が出てくる可能性もある箇所でした。

この開発についての詳細は以下の記事で解説されていますので、よろしければこちらもご覧ください。

目標達成のための課題

旧システムを考察することで、目標を達成するためには以下のような課題があることがわかってきました。

short-period indexing に適した Solr の使い方を再考する

旧システムでは、常在的に起動している Solr は "参照系" のみであり、index の更新時には spot instance として "更新系" の Solr を立ち上げて index を生成していました。index の更新が日次であればこの方法でも問題ありませんが、これが数分以下のオーダーになるなら "更新系" の Solr も常に稼働し、かつ複数の "参照系" Solr に更新を同期する必要がありそうです。

更新の同期方法については、たとえば、Solr にはクラスタを組んで replication を実行するための機能があります。しかし、この機能が ECS に Solr を乗せている状態でも問題なく動作するかは自明ではありません。ECS を活用することによるデプロイやスケーリングの容易性といったメリットは可能な限り残したい*2ものの、そのためには新しい要件に合わせた調査や工夫が必要になりそうです。

そもそも S3 を介してインデックスを配布するやり方が適しているかも含め Solr 周りの構成・設計は大幅に考え直す必要がありそうでした。

インデックスする情報を選別する

前述したように、レシピのドキュメントは 100 前後の field を持っており、中には機械学習を用いて付与されたスコアのようなものも含まれます。これら全ての情報をインデックスしようとすると、そもそもその処理に時間がかかる可能性が高く、short-period indexing のタイムスパンでこれを実行することは困難だと考えられます。したがって、ユーザー体験に立ち返って short-period indexing のスコープに含める field を定義する必要がありました。

また、クックパッドのレシピはユーザー投稿物です。したがって、何のチェックもせずにレシピをインデックスしてしまうと、明らかに料理ではない写真を用いたレシピなどの、不適切な投稿の露出が増えてしまう可能性があります。このことを考えると、インデックスする情報に加えて「どのレシピをインデックスするか」という判定が必要になると予想されました*3

日次バッチによる更新と short-period indexing による更新を同居させる

日次バッチによるインデックス更新は、更新頻度と引き換えではありましたが、緊急時にロールバックが容易になるといったメリットもありました。検索結果に不具合が生じた際、インデックスのバージョンを巻き戻すことで前日時点のインデックスを用いて検索機能を提供することが容易で、これは検索システムそのものの頑健性を支える一つの要素になっています。

この「セーブポイントをつくる」機能は有用なため可能であれば残したく、そうなると日次バッチによる更新と short-period indexing による更新が並列することになります。こうなるとインデックスの更新経路が複数になるため、その際にコンフリクトが起こらないようにシステムを設計する必要がありそうでした。

キャッシュが検索結果の更新を阻害しないようにする

前述した構成図では表現されていませんでしたが、検索システムの周辺には多種多様のキャッシュが存在しています。クライアントアプリからのリクエストを受け付ける API や、検索サーバーからのリクエストを受け付ける Solr と、複数箇所にキャッシュが存在しており、検索インデックスの更新時にはこれらを破棄しなければ検索結果が変化しません。

単純にキャッシュを剥がせば各サービスへの負荷増大は避けられず、まずは現状のヒット率等を調査して剥がせるなら少しずつ剥がす、難しそうならサーバーを増やすなどの対応が必要になりそうでした。

新システムの概要

以上に挙げた課題を解決するために、以下の図に示すような全体像のシステムを設計・開発しました。

開発の流れとしては、全体設計についてはプロジェクトメンバーの 4 名全員で議論しながら固め、必要な開発がある程度特定された後に、各位の専門領域に合わせて調査や実装を割り振る形にしました。

新システムの構成

新システムの特徴を以下に示します。

1. User-Managed Index Replication を利用した Solr cluster の構築

新システムでは Solr が提供する User-Managed Index Replication の仕組みを利用して "更新系" と "参照系" を組み合わせた Solr cluster を構築しました。

このモードでは Solr インスタンスは update リクエストを受け付ける 1 台の leader と、検索リクエストを受け付ける複数台の follower に分かれます。follower は設定した時間ごとに leader に対してポーリングを行い、差分をダウンロードします*4。それぞれの Solr は旧システムと変わらず Hako を用いて ECS Task として起動しています。

細かな要件としては、更新がコンフリクトしないように同時に起動している leader は最大 1 task に抑える必要があり、これは ECS の minimumHealthyPercentmaximumPercent を設定することで保証しています。

また、follower は起動時に日次バッチで生成された index を S3 からダウンロードし、その後 leader が保持している更新分を replicate し終わったタイミングで自身の status を healthy としてサービスインします。こうすることで、ヘルスチェックを成功させるタイミングをコントロールし、起動後 replication 途中の follower にアクセスが集中すると、アクセス毎に検索結果が変わってしまうといった問題を防いでいます。

2. EFS を利用した index の永続化

新システムにおいては、leader Solr が再起動や deploy をした場合においても index の状態を保ち、update と replication が正しく動作する状態を保証する必要があります。

これを実現するために、AWS のネットワークストレージサービスである EFS が利用できます。EFS を ECS にアタッチすることで、永続的なストレージをマウントすることができます。しかし、EFS はネットワーク越しにアクセスするストレージであるため、レイテンシ等の性能は ECS のエフェメラルストレージに対して少し劣るものとなってしまいます。

そこで、update リクエストを受け付けて index を永続化する必要のある leader のストレージには EFS を使い、ユーザーからの検索リクエストを受け付けて素早く応答する必要がある follower のストレージには tmpfs を利用することとしています。

また、新システムにおいても、旧システムと同様に日次で計算・付与される field は存在するため、日次バッチで生成された index で EFS の中身を差し替える処理が実行されています。

このとき、index の差し替えや leader/follower の再起動順序によっては replication の整合性が取れなくなり様々な問題が発生することがわかったため、依存関係を丁寧に整理して各処理の実行順序を制御しています*5

3. index update batch の定期実行

index の更新は 5 分ごとに定期実行するバッチで実現しています。その定期実行ごとに「直近 1 時間で更新があったレシピの情報」を取得し、その情報を元に必要な処理を施してドキュメントを生成し、leader に update のリクエストを投げるという流れです。

5 分に一度 update がリクエストされている様子

このとき「そのレシピが不適切な投稿である確率はどのくらいか」を ML によって判定する API へのリクエストを挟むことで、不適切投稿の露出が増えることを防いでいます*6

定期実行バッチにするのではなく、レシピの投稿・更新にフックさせてイベントを発行・キューイングして都度処理する方針も考えましたが、

  • イベントの発行数が多くなり既存の社内基盤を利用することができるかどうかが明らかでなかった
  • リトライ処理の実装が複雑になる
  • そこまでのリアルタイム性が求められていない

ことから採用を見送っています。

本番環境への展開

プロジェクトの完遂には、システムの構築とは別に展開に向けた各種作業も必要です。今回は SRE のメンバーの協力によって、以下に挙げるような作業を事前にキャッチアップ・進行してもらうことができ、非常にスムーズに展開を終えることができました。

キャッシュの整理

システムを構築しても、既存のキャッシュ構成は日次での検索結果更新を前提としていたため、TTL が数時間単位のものになっていました。このままでは、キャッシュの更新間隔が検索結果の更新間隔よりも長くなってしまいます。

検索結果の更新頻度に合わせてキャッシュの TTL を短縮したいですが、調査が不十分のまま進めるとキャッシュの裏側にあるサービスへの負荷が増大し、障害を引き起こしてしまう可能性があります。

そこでまずはキャッシュの設定変更が与えている影響を観測できるように、Prometheus + prometheus_exporter gem を用い、キャッシュのヒット率などを計測するようにしました*7。次にそれらの変化や各サービスの負荷を確認しながらキャッシュの TTL を徐々に短くする変更を行い、最終的に、サービス障害を起こすことなく TTL を 5 分にまで短縮できました。

負荷試験と段階ロールアウト

検索機能の変更はクックパッドのほぼ全ユーザーに影響を与える大規模なものになります。本番展開前の負荷試験は、展開後の障害発生率を抑えることができるのはもちろん、開発者が安心して展開を行えるようになります。

また、展開自体を一度に行うのではなく、徐々にユーザーリクエストを流すような段階ロールアウトの手順を踏むことで、大規模障害の発生率を抑えることができます。

今回は以下の手順で負荷試験と段階ロールアウトを行いました。

  1. 本番の Solr に届いているリクエストをミラーリングし、新 Solr cluster でもリクエストを問題なく捌けるかを確認する(負荷試験)
    負荷試験の概要
  2. 実際に一部のレスポンスを新システムからのものに差し替え、徐々にその割合を大きくしていく(段階ロールアウト)
    段階ロールアウトの概要
  3. 全てのレスポンスが新システムからのものになった後、short-period indexing を有効にする

このうち、1 の負荷試験は Envoy のRequestMirrorPolicyを、 2 の段階ロールアウトは Envoy のWeightedClusterを使って実現しています。

まとめと振り返り

本プロジェクトでは、従来の検索システムではレシピ投稿から結果への表示までに最長 24 時間かかっていたものを、5 分程度にまで短縮することに成功しました。課題の特定から解決までを 6 週間でおこなうというタイトなスケジュールではありましたが、事業要件に過不足のない開発を事故なく完遂することができたのではないかと思います。

振り返ってみると成功の要因としては

  • プロジェクト冒頭にプロダクトの実現すべき体験からブレークダウンする形で要件をしっかりと定義した
    • 後の意思決定に軸が通り、手戻りも少なくなった
  • プロダクトからインフラサイドまで、各領域について高い専門性を持つメンバーが集まった
    • 全体の要件定義やざっくりとした設計は全員で行い、そこから先の詳細開発は各メンバーが担当した
    • プロジェクトのため臨時に結成されたチームだったが、期間中は週2回の check-in MTG を設定してスムーズに同期と相談をおこなえるようにした
  • スポットで機械学習エンジニアなど、他チームの助力も得ることができた

ことが大きかったのではないかと思います。

組織として達成したいミッションがあり、そのための事業・プロダクトがあり、それが実現したい体験を阻んでいる障壁があるところに技術をぶつけてそれを取り除くという仕事は、やはりとてもやりがいのあるものだと改めて実感しました。それぞれに高い専門性を持つメンバーから成るチームで仕事ができたことも含めて、個人的には入社以来もっともおもしろい仕事の一つであったように思います。

Acknowledgements

本プロジェクトは 4 名のメインメンバー+周辺部署のメンバーが関わり、それぞれ力を発揮したことで完遂することのできたプロジェクトです。私一人の力では到底実現できなかったであろう課題解決を共に推進してくれたことに改めて感謝します。

最後に、メインメンバーの 4 名について、各作業をどのように担当したかを明記します。

  • @SpicyCoffee(筆者)
    • 検索エンジニア
    • 担当:プロジェクト全体の統括・最終意思決定 / indexing application の実装
  • @osyoyu
    • 検索エンジニア
    • 担当:Solr Cluster と Persistent Storage 周りの設計・開発
  • @s4ichi
    • SRE
    • 担当:Solr Cluster と Persistent Storage 周りの設計・開発 / 負荷試験とロールアウト
  • @eagletmt
    • SRE
    • 担当:キャッシュの調査と最適化 / indexing application の実装

この記事が、日々技術を用いてユーザー課題を解決しているみなさまのお役に立てば幸いです。

*1:今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。

*2:当時の ECS & 社内基盤 Hako という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。

*3:人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。

*4:leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。

*5:現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。

*6:この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。

*7:クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の multi process modeを用いました。

クックパッドのフロントエンド CSS in JS をゼロランタイムに切り替えました

こんにちは。レシピ事業部のkaorun343です。我々のチームではレシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログにて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSをEmotionからゼロランタイムのvanilla-extractへ変更した話です。

vanilla-extract.style

背景

以前書いた レシピサービスのフロントエンドに CSS in JS を採用した話 - クックパッド開発者ブログでは、CSS in JSライブラリとして Emotion(@emotion/react)を採用した経緯と開発環境整備を紹介しました。採用理由としては以下の通りでした。

  • セレクタに一意なIDが割り振られるので、スタイルを適用した要素とは別の要素への、意図しないスタイル適用を防ぐことができる。
  • ESLintやTypeScriptコンパイラといったJavaScriptの静的解析ツールの恩恵を受けることができ、タイポや機能削除時の削除漏れに気づきやすくなる。
  • styled-componentsのようなスタイルではJSXのツリーを見たときに、機能を持つコンポーネントなのか装飾されたコンポーネントなのかわからず、コードレビューがしにくい。
  • 通常のCSSの記法に慣れたメンバーが多いので、String Styles、すなわちタグ付きテンプレートリテラルを採用する。

このような方針でEmotionの導入を決め、stylelintやeslintを導入し、必要に応じてカスタムルールを作成して機能開発を進めました。

しかしながら、Emotionを導入してから2年ほど経った結果、以下のような課題や懸念を抱えるようになりました。

  1. ページサイズ:SSR時には初期表示用のCSSをEmotionが作るわけですが、このCSSは .css ファイルとしてブラウザに届くのではなく Next.jsから配信されるHTMLに埋め込まれた状態でブラウザに届きます。そのため、ロードバランサーを通過するHTMLのサイズが増加してしまいます。CSSのデータがCDNを通らないため、パフォーマンスの面でもコストの面で問題です。実際、background-imageにbase64の画像URLを埋め込んだときには、その影響が強く出てしまいました。
  2. 動的生成による肥大化:Next.jsのSSR時にうっかりCSSのバリエーションを増やしてしまうと、Next.jsプロセスのメモリ使用量が増大し、アプリケーションが落ちてしまいます。これは、Emotionがインメモリのキャッシュ機構を備えており、一度生成したCSSデータを保持し続けるためです。過去の事例では、レシピごとに異なるbackground-imageを設定するCSSをEmotionで書いたときにこの問題が生じました*1
  3. クライアント側のオーバーヘッド:CSS生成のためにブラウザ上でJavaScriptが実行されるため、ページのパフォーマンスへ影響しうる懸念があります。EmotionはCSSの記述内容の解析、古いブラウザ向けの記述の追加、CSSの合成、そしてスタイルのDOMへの挿入をブラウザ上で実行します。Emotionのcss関数を使えば使うほどCSSに関する処理の実行時間が増えていきます。また、これらの処理をブラウザ上で実行するためのJSのコードが必要となるため、バンドルサイズが増加してしまいます。新システムがホストしているページはスマートフォン向けのページであり、パフォーマンスやバンドルサイズは特に注視しています。

そこで、Emotionから別の CSS 環境への移行を検討しました。

技術選定

上記の課題を踏まえ、以下の要件で新しいCSS 環境を検討しました。

  • Emotionに近い開発体験:Emotionと同様に、CSS クラス名を自分でつける必要がないこと
  • CDNの活用:ビルド時に CSS ファイルが生成されて CDN から静的に配信できること
  • 低いオーバーヘッド:ゼロランタイムであること(ビルド時にCSSを生成し、ブラウザに送られるJavaScriptにはCSSを生成するコードを含まないこと)
  • 将来性:Server Components導入を見据えて、Server Componentsに対応していると嬉しい

検討した結果、これらの要件を満たすライブラリとしてvanilla-extractが挙がりました。 vanilla-extractではCSSをJavaScriptのオブジェクトとして .css.[jt]s という拡張子のファイルに記述します。これをvanilla-extractの各種バンドラに対応したプラグインがCSSに変換し、CSSファイルを生成します。また、それぞれのスタイルは一意なクラス名をセレクタとしており、Emotionと同じように意図しないスタイル適用を防ぐことができます。

Emotionとvanilla-extractの比較を表にすると以下のようになり、技術選定で重視した項目を満たしています。

比較項目 Emotion vanilla-extract
クラス名の自動付与
CDNから配布可能 (HTMLに埋め込まれる)
ゼロランタイム (ブラウザ)
Server Components対応 (CSRのみ)
コンポーネントと同じファイルに書ける (.css.[jt]sに書く必要がある)
CSSの書き方 String Styles、Object Styles Object Stylesのみ
Stylelint (未対応)
スナップショットテスト (なし)
ベンダープレフィクスの自動付与 (なし)

(比較当時、@emotion/reactはv11.10.0、@vanilla-extract/cssはv1.9.1でした)

筆者がvanilla-extractを提案した際は、記述方法の違いやビルド成果物の差がわかるように、実際のページを書き換えたプルリクエストを例示しました。CSSのコード量が小さいOGP画像生成用のページを対象にしました。

vanilla-extractのデメリット

一方で、vanilla-extractにはデメリットも存在します。

CSSの書き方

まず、これまで通りString Stylesで記述することができなくなりました。この点について懸念点がないかデザイナーの方に伺ったところ、「CSSを書ければ問題ない」とのことでした。vanilla-extractは .css.[jt]s に記述する必要がありますが、この点についても、チームメンバーから合意をもらいました。

Stylelint

加えて記法が変わったことによりStylelintで検査できなくなりました。しかしながら、CSSのプロパティや値のタイポ・プロパティの重複はTypeScriptで見つけられますし、我々のアプリケーションでは詳細度に関連して困るような書き方をしていないので、Stylelintを廃止するデメリットは小さいと判断しました。

スナップショットテスト

Emotionでは@emotion/jestがスナップショットテストにCSSの記述を表示する仕組みを提供していました。しかしvanilla-extractでは提供されておらず、スナップショットテストでCSSの記述を確認することもできなくなりました。スナップショットテストについては、運良くバグを検知できるほどのメリットしかないと判断し、使えなくなるデメリットは小さいと判断しました。

ベンダープレフィクスの自動付与

Emotionはベンダープレフィクスを自動で付与してくれるのですが、Emotionではライブラリ利用者がブラウザのバージョンを指定できないため、クックパッドの推奨環境より古いブラウザを対象としたプロパティも追加されていました。クックパッドの推奨環境も鑑み、自動付与がなくなるデメリットは小さいと判断しました。

vanilla-extractへの移行

CSSの記述をvanilla-extractへ移行することを決定した後、移行作業にとりかかりました。

最初はすべて手作業で書き換えていたのですが、途中から正規表現を使った簡素な変換ツールを導入して移行作業がスピードアップしました。 Emotionとvanilla-extractは共存できたため、手が空いているときに手分けをして少しずつ移行していきました。また、Next.jsアプリケーション本体だけではなく、共通コンポーネントパッケージ、そして社内のデザインシステムのReactライブラリもvanilla-extractに移行しました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = css`
  flex: 1;
  box-sizing: border-box;
  background-color: white;
`

const linkDisableStyle = css`
  ${linkStyle}
  background-color: gray;
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle}>
        Link
      </a>
      <a href="#" css={linkDisableStyle}>
        Disabled Link
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

export const linkStyle = style({
  flex: 1,
  boxSizing: 'border-box',
  backgroundColor: 'white',
})

export const linkDisabledStyle = style([
  linkStyle,
  {
    backgroundColor: 'gray',
  },
])

// MyComponent.js

import { linkDisabledStyle, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a href="#" className={linkStyle}>
        Link
      </a>
      <a href="#" className={linkDisabledStyle}>
        Disabled Link
      </a>
    </section>
  )
}

動的にスタイルを生成していた箇所については、 @vanilla-extract/dynamic や @vanilla-extract/recipesを利用して問題なく置き換えられました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = (size) => css`
  width: ${size};
  height: ${size};
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle('100px')}>
        Link 1
      </a>
      <a href="#" css={linkStyle('200px')}>
        Link 2
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

import { createVar, style } from '@vanilla-extract/css'

export const sizeVar = createVar()

export const linkStyle = style({
  width: sizeVar,
  height: sizeVar,
})

// MyComponent.js

import { assignInlineVars } from '@vanilla-extract/dynamic'
import { sizeVar, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a 
        href="#"
        className={linkStyle} 
        style={assignInlineVars({ [sizeVar]: '100px' })}
      >
        Link 1
      </a>
      <a
        href="#"
        className={linkStyle}
        style={assignInlineVars({ [sizeVar]: '200px' })}
      >
        Link 2
      </a>
    </section>
  )
}

移行した結果、Emotionで課題や懸念に感じていたことを解消できました。

  1. ページサイズ:CSSファイルにページ全体のCSSが含まれるようになり、CDNから配布できるようになりました。background-imageとしてbase64の画像ファイルを埋め込んだ場合でもロードバランサーを通るHTMLのサイズが大きくなることはありません。
  2. 動的生成による肥大化:メモリ使用量が増加してNext.jsプロセスが落ちることはなくなりました。
  3. クライアント側のオーバーヘッド:CSSの生成はビルド時にのみおこなわれるようになり、生成のためのJavaScriptは@vanilla-extract/dynamicや@vanilla-extract/recipesだけになりました。また、Emotionのランタイム削除によりバンドルサイズはgzipで10kB弱減少しました。

エンジニアやデザイナーからも、特にネガティブな意見は出ていません。初めてvanilla-extractを触るメンバーも、問題なくCSSを変更できています。

さいごに

今回はレシピサービスの新システムにおける ゼロランタイムCSS in JS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。

*1:このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです

iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作る

こんにちは、クックパッドマートプロダクト開発部の佐藤(@n_atmark)です。

普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。

動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。

フレームインスペクタの動作の様子
フレームインスペクタの動作の様子 (gif)

アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマージンを表示するようにしています。

開発の背景

私が普段開発に従事しているクックパッドマートiOSアプリでは元々5の倍数マージンを採用していたのですが、これを4の倍数マージンに変えたいという背景がありました。

後発のクックパッドマートAndroidアプリで4の倍数マージンを採用しており、デザイナーが画面デザインを作成するために5の倍数マージン / 4の倍数マージンを切り替えてデザインを作らないといけないという課題があり、どちらかに統一したいという要望がありました。クックパッド社内の他のiOSアプリでも4の倍数マージンを採用していることもあり、クックパッドマートiOSアプリも4の倍数マージンに合わせることになりました。

しかし、マージン値を機械的に置き換えるのは難しく、現在は気づいた箇所から徐々に置き換えていく方針で進めています。 クックパッドマートiOSアプリにはUIKit (AutoLayout) で作られた画面もあればSwiftUIで作られた画面もあり、マージンを直接設定している箇所や変数に置いている箇所、アニメーションのためにマージン値を切り替えている箇所などがあるため、統一した方法で置き換えができないためです。また、10ptの箇所を8ptに置き換えるべきか12ptに置き換えるべきかといった問題もあります。

そこで、マージンの違いに気づきやすくするという目的で今回のツールを開発しました。 QA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みとして用意しています。

実装の紹介

実装の全体は https://gist.github.com/natmark/ef27845aff19059e74916df421223b79 に置いてあります。

final class DebugFrameInspectorView: UIView {
   init() {
        super.init(frame: .zero)
        backgroundColor = .clear
        isUserInteractionEnabled = false
    }
   // … 略
}

DebugFrameInspectorView がマージンやフレームサイズを表示しているViewです。 backgroundColor = .clear かつ isUserInteractionEnabled = false なViewとなっていて、これを keyWindow に対して addSubView(_:) して利用してもらう想定です。

func setup() {
    let singleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didSingleLongPress(_:)))
    singleLongPressGestureRecognizer.minimumPressDuration = 0.2
    singleLongPressGestureRecognizer.numberOfTouchesRequired = 1
    window?.addGestureRecognizer(singleLongPressGestureRecognizer)

    let doubleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didDoubleLongPress(_:)))
    doubleLongPressGestureRecognizer.minimumPressDuration = 0.2
    doubleLongPressGestureRecognizer.numberOfTouchesRequired = 2
    window?.addGestureRecognizer(doubleLongPressGestureRecognizer)
}

setup() メソッドの中で UILongPressGestureRecognizerUIWindow に追加して長押しのジェスチャーを補足できるようにしています。

@objc private func didSingleLongPress(_ sender: UILongPressGestureRecognizer) {
     if sender.state == .began {
         let positionInWindow = sender.location(in: window)
         if let hitView = window?.hitTest(positionInWindow, with: nil) {
             let positionInHitView = sender.location(in: hitView)
             let globalRect = CGRect(
                 x: positionInWindow.x - positionInHitView.x,
                 y: positionInWindow.y - positionInHitView.y,
                 width: hitView.frame.size.width,
                 height: hitView.frame.size.height
             )
             singlePressValue = SinglePressValue(
                 viewWireframe: .init(
                     rect: globalRect,
                     cornerRadius: hitView.layer.cornerRadius,
                     maskedCorners: hitView.layer.maskedCorners
                 )
             )
             setNeedsDisplay()
         }
     } else if sender.state == .ended {
         singlePressValue = nil
         setNeedsDisplay()
     }
 }

 @objc private func didDoubleLongPress(_ sender: UILongPressGestureRecognizer) {
    // 略
 }

長押し時の処理がこちらになります。1本指で長押しするか2本指で長押しするかによって didSingleLongPress(_:) didDoubleLongPress(_:) と実装を分けていますが、内容としてはほぼ同じ処理になります。

let positionInWindow = sender.location(in: window)keyWindow 内におけるタッチ位置を取得した後 hitTest(_:with:) を用いてタップされたViewを特定しています。 (let hitView = window?.hitTest(positionInWindow, with: nil) の箇所)

その後タップされたViewのframe (superviewを基準にした相対位置) を keyWindow 内における座標に変換したいので、hitView 内でのタッチ位置の座標を取得し ( let positionInHitView = sender.location(in: hitView) の箇所) 、 positionInWindow から positionInHitView の座標分ずらすことで keyWindow 内における座標を取得しています。

let globalRect = CGRect(
   x: positionInWindow.x - positionInHitView.x,
   y: positionInWindow.y - positionInHitView.y,
   width: hitView.frame.size.width,
   height: hitView.frame.size.height
)

singlePressValuedoublePressValue という変数に値を保持しておいて setNeedsDisplay() を呼び出すことで draw(_:) メソッドを呼び出し、フレーム境界やマージンなどの線の描画を行っています。

線の描画に関しては詳しく触れませんが、CoreGraphicsを用いてゴリゴリ記述しています。

利用方法

/// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }

    let window: UIWindow
    // 以下はアプリの構成によって変わるので必要箇所だけ入れてください
    if let keyWindow = windowScene.keyWindow {
        // keyWindowがある場合 (SwiftUI.App利用時)
        window = keyWindow
    } else if let firstWindow = windowScene.windows.first {
        // keyWindowはないが、windowが存在する場合 (Main Storyboard利用時)
        window = firstWindow
        window.makeKeyAndVisible()
    } else {
        // window自体存在しない場合 (Storyboard不使用時)
        window = UIWindow(windowScene: windowScene)
        window.makeKeyAndVisible()
        window.rootViewController = MyRootViewController()
        self.window = window
    }

    #if DEBUG
    let debugFrameInspectorView = DebugFrameInspectorView()
    window.addSubview(debugFrameInspectorView)
    debugFrameInspectorView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        window.leadingAnchor.constraint(equalTo: debugFrameInspectorView.leadingAnchor),
        window.trailingAnchor.constraint(equalTo: debugFrameInspectorView.trailingAnchor),
        window.topAnchor.constraint(equalTo: debugFrameInspectorView.topAnchor),
        window.bottomAnchor.constraint(equalTo: debugFrameInspectorView.bottomAnchor),
    ])
    debugFrameInspectorView.setup()
    #endif
}

keyWindow となるUIWindowに対して addSubView(_:) した後、 setup() を呼び出すことで利用できます。

デバッグ用の機能なのでCompilation conditionsを使ってbuild configurationが DEBUG の時のみ有効にするなどしておくことをオススメします。

実際に開発したツールを使ってもらって

開発の背景の箇所にもある通り、今回のツールの目的としてはQA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みを用意することでした。

チームメンバーに実際に使ってもらうと、以下のようなコメントをもらいました。

ポジティブな意見

  • (エンジニア) 実際のアプリのマージンを実装を見に行かなくても確かめられるのは嬉しい
  • (デザイナー) Webだとインスペクタですぐ要素を確認できるのにネイティブアプリだと見ることができないので、アプリでもフレーム値を確認できるのがありがたい

追加要望

  • (デザイナー) 実装されているフォントサイズや色も確認できると嬉しい

ネガティブな意見

  • (エンジニア) ボタンの領域を長押しした時に、ボタン内の要素のフレームサイズが見れない
  • (エンジニア) フレームサイズが実装を行った際に意図したサイズと違って表示されることがある

実装されたアプリ上でマージン値をサクッと確かめられることに対して一定効果がありそうなことが分かりました。 また、フォントサイズや色も確認できるとアプリに実装されたデザインが正しいかどうかを確認する用途で、より便利に使えそうです。

一方で、前後に重なった要素に対してはフレームサイズの確認がうまくできないという課題や、実際の実装値と表示される値が違うことがあるという課題も浮き彫りになりました。

実装が難しい部分の紹介

ここまで開発したフレームインスペクタの紹介をしましたが、できないことも結構あります。 先ほどチームメンバーから要望のあった「実装されているフォントサイズや色」といった要素や、「実装値と表示値が違う」という課題の解決も現状難しいと感じている点です。

その難しさの元になるのがSwiftUIで実装された画面です。

UIKitとSwiftUIで以下のような画面を実装したとします。

UIKit

SwiftUI

UIWindowsubViews を辿って階層構造を表示するとそれぞれ下のようになります。

UIKit SwiftUI
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ UIView
      └ UIStackView
      ├ UIStackView
      │ ├ UIImageView ()
      │ └ UILabel (タイトル)
      └ UILabel (テキストテキストテキスト)
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

UIKitの場合は UIImageViewUILabel といったお馴染みのViewクラスなので、 UIView 型の subview をキャストすれば簡単にプロパティを確認できますが、SwiftUIの場合はSwiftUIのView構造ではなく、描画用のクラスである SwiftUI._UIGraphicsViewSwiftUI.DisplayList.ViewUpdater.Platform.CGDrawingView といったprivateなクラスが利用されます。

これが一つ目の難しいポイントで、SwiftUIでViewを組み立てた時にどういったModifierが適用されたかといった情報を持たないため、これらからフォントサイズや設定された色を取り出すことは困難です。

また、UIKitではViewの階層構造が保持されるのに対して、SwiftUIでは _UIHostingView というクラスの配下にフラットに展開されてしまいます。 SwiftUIのViewを組み立てる時に利用した VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れません。 これが二つ目の難しいポイントです。

VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れない」というのがどう難しさにつながっているのか説明するために以下の図を用意してみました。

UIKit SwiftUI

これは UIWindowsubviews を辿ってそれぞれの CALayer に対して枠線を表示したものです。

SwiftUI側の実装で、「タイトル」および「テキストテキストテキスト」には .frame(maxWidth: .infinity, alignment: .leading) をつけているものの CGDrawingView が表現するフレームはテキストが文字幅に縮んでしまっていることがわかると思います。

元々VStackに設定していた .padding(.horizontal, 20) のうち、trailing 側に関しては正しく効いているかどうか、今回のフレームインスペクタでは上手く確認することができません。

補足

ちなみに VStack および HStack.background(Color.white) を追加するとVStack/HStackの領域が描画され、下のように画面幅にフレームが広がっているのを確認できます。

View構造 フレームレイアウト
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView (HStackのbackground)
    ├ _UIGraphicsView (VStackのbackground)
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

今後の展望

今回作ったフレームインスペクタではSwiftUIで作った画面表示にまだ課題があることを紹介しました。 ところでXcodeには Debug View Hierarchy というデバッグ機能があることはよく知られていると思います。

この Debug View Hierarchy を用いるとSwiftUIで開発された画面に関しても Horizontal StackText といったSwiftUIで組み立てたViewの構造や、テキストのフォントといったModifierも確認することができます。

どうにか Debug View Hierarchy で表示しているような情報を取得できると、今回開発したフレームインスペクタに機能追加することができそうです。

_UIHostingView._viewDebugData()

SwiftUI._UIHostingView に非公開APIとして _viewDebugData() というメソッドが存在します。これを用いるとデバッグ用にSwiftUIのView構造を解析できそうです。 (https://apurin.me/articles/swiftui-secrets/ を参考にさせていただきました)

SwiftUIのView構造をもう一度示すのですが

UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

階層を辿ることで _UIHostingView を取得できそうです。

struct ViewDebugData {
    let data: [_ViewDebug.Property: Any]
    let childData: [ViewDebugData]
}
protocol DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData]
}
extension _UIHostingView: DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData] {
        let _viewDebugData = _viewDebugData()
        return unsafeBitCast(_viewDebugData, to: [ViewDebugData].self)
    }
}

DebuggableSwiftUIView というプロトコルを用意して _UIHostingView に準拠させています。

private func digDebuggableSwiftUIView(from view: UIView) -> (any DebuggableSwiftUIView)? {
     if let debuggableView = view as? DebuggableSwiftUIView {
         return debuggableView
     } else {
         for subView in view.subviews {
             if let debuggableView = debuggableView(from: subView) {
                 return debuggableView
             }
         }
         return nil
     }
}

DebuggableSwiftUIViewに準拠したViewを探索するメソッドを用意して

guard let window else { return }
let viewDebugData = digDebuggableSwiftUIView(from: window)?.viewDebugData()
print(viewDebugData)

viewDebugData() を呼び出すことで、_UIHostingView._viewDebugData() の結果を確認できそうです。

_viewDebugData() の出力はかなり大きいので出力の全体は https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118 に置いておきます。

SwiftUI._ViewDebug.Property.type の部分だけ階層表示すると

_SafeAreaInsetsModifier
  └ ModifiedContent, EditModeScopeModifier>, HitTestBindingModifier>
    └ HitTestBindingModifier
      └ ModifiedContent, EditModeScopeModifier>
        └ EditModeScopeModifier
          └ ModifiedContent<_ViewModifier_Content, TransformModifier>
            └ TransformModifier
              └ _ViewModifier_Content
                └ ModifiedContent
                  └ RootModifier
                    └ ModifiedContent, RootEnvironmentModifier>, PresentedSceneValueInputModifier>
                      └ PresentedSceneValueInputModifier
                        └ ModifiedContent<_ViewModifier_Content, RootEnvironmentModifier>
                          └ RootEnvironmentModifier
                            └ _ViewModifier_Content
                              └ AnyView
                                └ ContentView
                                  └ ModifiedContent>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>, _PaddingLayout>
                                    └ _PaddingLayout
                                      └ VStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                        └ Tree<_VStackLayout, TupleView<(HStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                          ├ _FlexFrameLayout
                                          ├  └ Text
                                          ├    └ AccessibilityStyledTextContentView
                                          ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                          ├        └ AccessibilityLargeContentViewModifier
                                          ├          └ ModifiedContent
                                          ├            └ AccessibilityAttachmentModifier
                                          ├              └ StyledTextContentView
                                          └ HStack>>, _FrameLayout>, ModifiedContent)>>
                                            └ Tree<_HStackLayout, TupleView<(ModifiedContent>>, _FrameLayout>, ModifiedContent)>>
                                              ├ _FlexFrameLayout
                                              ├  └ Text
                                              ├    └ AccessibilityStyledTextContentView
                                              ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                              ├        └ AccessibilityLargeContentViewModifier
                                              ├          └ ModifiedContent
                                              ├            └ AccessibilityAttachmentModifier
                                              ├              └ StyledTextContentView
                                              └ _FrameLayout
                                                └ Image
                                                  └ ModifiedContent
                                                    └ AccessibilityAttachmentModifier
                                                      └ Resolved

のようになっており、Viewの階層構造が取れそうです。 また、SwiftUI._ViewDebug.Property.type: SwiftUI.Text の箇所だけピックアップしてみると

SampleSwiftUIView.ViewDebugData(
  data: [
    SwiftUI._ViewDebug.Property.value: SwiftUI.Text(
        storage: SwiftUI.Text.Storage.anyTextStorage(<LocalizedTextStorage: 0x00006000017f54a0>: \"テキストテキストテキスト\"), 
        modifiers: [SwiftUI.Text.Modifier.font(Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $105d9f910).FontBox<SwiftUI.Font.(unknown context at $105e5aae8).SystemProvider>)))]
    ), 
    SwiftUI._ViewDebug.Property.type: SwiftUI.Text, 
    SwiftUI._ViewDebug.Property.size: (191.0, 20.333333333333332), 
    SwiftUI._ViewDebug.Property.position: (20.0, 442.5)
  ],
  childData: [] // 略
)

Modifierに関しても確認することができました。

_viewDebugData の内容を実際にデバッグツールに活用しようと思うとかなり骨が折れる作業になりそうですが、SwiftUIのView構造をここまで詳細に取得できれば、Viewのデバッグツールを作るにあたってできることは広がりそうです。

まとめ

今回、アプリに実装されたUI要素のフレームサイズやマージンを簡単に確認できるツールを作って紹介しました。

チームメンバーに使ってもらって、サクッとフレームサイズやマージンを確認する用途であれば便利に使えそうなことが分かった一方で、より完成度の高いツールを目指そうとした際に、SwiftUI製のViewのインスペクタ実装は難しい部分が多いことも分かりました。

_UIHostingView のprivate APIである _viewDebugData() を使うと詳細なSwiftUIのView構造を利用できそうなこと分かったため、 _viewDebugData() のデータを活用したデバッグツールの改善に関しても引き続き検討してみようと考えています。

今回の記事が快適なデバッグ環境構築の参考になれば嬉しいです。