Quantcast
Channel: クックパッド開発者ブログ
Viewing all 804 articles
Browse latest View live

KomercoとFirebaseの話【後編】 - Firebase運用の仕組化

$
0
0

Komercoの高橋です。

昨日は前編でFirestoreの設計パターンについてお話しましたが、後編はFirebase運用の仕組化についてです。

前の記事でも述べたように、昨年はWeb版のリリースや送料無料イベントもあり、ユーザ数がさらに増加してきています。 サービス規模が大きくなるにつれて運用コストも大きくなり、その効率化も求められるようになってきました。

ここではKomercoで行っている、運用に関する仕組みについてご紹介します。

Komercoの構成

まず前提としてKomercoの構成について簡単にご紹介します。

f:id:yosuke403:20210421170018p:plain

Komercoは器や料理道具、食材や調味料を扱うECサービスで、商品の販売者から直接購入ができるC2Cサービスとなっています。 商品を購入するユーザを「カスタマー」、販売するユーザを「クリエイター」と呼んでいます。

Komercoではこのカスタマー、クリエイターそれぞれに対してiOS版、Web版のアプリを提供しています。Web版についてはCloud FunctionsでNext.jsを動作させて実装しています。

各アプリはFirestoreやCloud Functionsにアクセスしてデータの取得や更新を行います。

Firestoreのデータやユーザの行動ログはBigQueryに貯まるようになっていて、Google スプレッドシートやTableauで分析しています。

もちろん他にもFirebaseの様々な機能を利用しています。

定期的にFirestoreのデータをBigQueryにエクスポート

Komercoでは毎日FirestoreのデータをBigQueryにエクスポートし、分析に使用しています。 Komerco発足時はエクスポートする手段がなかったので独自でツールを作っていましたが、今はgcloudを使ったエクスポート方式に移行しています。

やり方については次の通りです。

サービスアカウントの作成

ここを参考にロールを設定します。

バッチを動かす

バッチで叩くコマンドは次のようになります。

# サービスアカウントのキーファイルをセット
gcloud auth activate-service-account --key-file key.json

# プロジェクトのセット
gcloud config set project my-firebase-project# collection groupごとにFirestore→Storageへデータをエクスポート
gcloud firestore export gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00 --collection-ids=user,shop,product

# collection groupごとにBigQueryのテーブルに入れる
bq load --source_format=DATASTORE_BACKUP--replace=true--projection_fields=name firestore.user gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_user/all_namespaces_kind_user.export_metadata
bq load --source_format=DATASTORE_BACKUP--replace=true--projection_fields=name,owners firestore.shop gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_shop/all_namespaces_kind_shop.export_metadata
bq load --source_format=DATASTORE_BACKUP--replace=true--projection_fields=name,price firestore.product gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_product/all_namespaces_kind_product.export_metadata

projection_fieldsオプションがあるおかげで、個人情報など取り込みたくない情報はフィルタ可能です。

Dockerfileを使う場合は、gcloudを使うためのGoogle公式イメージがあるので、これを利用するのが便利です。

今からサービス公開するならFirebase Extension

もし今の時点でサービスが未公開の場合はFirebase Extensionを使う選択肢があるかもしれません。

firebase.google.com

こちらの場合、すでにFirestoreに入っているデータについてはエクスポートされないため、今回はgcloudの手法を選択しています。

AuthのデータをGoogle スプレッドシートにエクスポート

ユーザにメールを送りたいときなどに、Authからメールアドレスを抽出する必要があります。 個人の環境でデータを取得するのは情報の取り扱い上問題があるため、バッチでGoogle スプレッドシートに直接書き込むようにしています。

Google スプレッドシートに書き込むことのメリットとして、アクセス管理が簡単なことや、リストのフィルタなどがその場でできることが挙げられます。

サンプルはこちらで、このプロジェクトをバッチで実行します。 https://github.com/yosan/FirebaseAuth2Sheet

ロールバック

Cloud Functionsにはロールバックの仕組みがないため、デプロイ後に不具合が発覚した場合にはもう一度過去のバージョンをデプロイする必要があります。 Web版のKomercoもNext.jsをCloud Functionsで実行している関係上、Hostingのロールバックだけでは意味がありません。

Komercoの本番環境にデプロイする際は、個人の環境からはできず、クックパッドで利用されているRundeck+hakoの仕組みを使ってデプロイしています。 masterブランチに変更が入ると、JenkinsでCloud FunctionsやWebアプリをビルドし、それが入ったDockerイメージをAmazon ECRにプッシュします。 デプロイ時はRundeckからhakoを使うことで、プッシュされたDockerイメージを取得して firebase deployコマンドを実行します。 Rundeckは基本的にruboty経由で呼ぶため、Slackと上で ruboty deploy komerco-cloud-functionsのように発言して指示します。

f:id:yosuke403:20210420173149p:plain

ECRにプッシュされたDockerイメージにはタグがついていて、hakoではこのタグをオプションで指定することで過去のビルド成果物を使ってデプロイができます。 ロールバック時は前回デプロイした際のDockerイメージのタグを特定して、再度デプロイします。

f:id:yosuke403:20210420173528p:plain

Cloud FunctionsのCI環境改善

サービスの機能が増えるに連れてCloud Functionsの数は増えていき、Komercoでは100を超えるCloud Functionが動いています。 Firebaseの機能拡張が進み、その運用コストも下がってきました。

オフラインテスト

Komercoリリース時点ではFirebaseに関するテスト環境はまだまだ充実していなかったため、CIは実際のFirestoreを使ってオンラインテストを行っていました。 しかしこれにはテスト用のプロジェクトが必要だったり、CIの度にデプロイが必要だったりといろいろ不便な点がありました。 現在ではCIのテストは全てオフラインに切り替わっており、CIにかかる時間は大幅に短縮されています。

テストを実行する際は、firebase emulators:execコマンドから、FirestoreやCloud Functionsのエミュレータを起動させています。 起動中はAdmin SDKの書き込みは自動的にエミュレータのFirestoreの方を向くため、オンラインテストの実装をほぼそのままオフラインに移行できました。

https://firebase.google.com/docs/functions/local-emulator#interactions_with_other_services

Firebase Admin SDK を使用して Cloud Firestore に書き込む Cloud Functions がある場合、Cloud Firestore エミュレータが実行されていれば、この書き込みはエミュレータに送信されます。この書き込みによってさらに Cloud Functions がトリガーされると、それらは Cloud Functions エミュレータ内で実行されます。

ここで、もともとオンラインで行っていたテストには、

  • 単体のCloud Functionsに対して行うテスト
  • カスタマーが商品を購入するまでの一連のFirestoreへの操作を再現してテストするシナリオテスト

の2種類がありました。

Cloud FunctionsとFirestoreエミュレータが同時に起動している場合、Firestoreへの書き込み時にFirestore Event TriggerのCloud Functionsがエミュレータ上で発火するようになります。 シナリオテストはこのFirestore Event Triggerの動作も含めて、仕様通りに更新が行われるかをチェックします。 一方で単体のCloud FunctionsのテストにおいてはFirestore Event Triggerは不要で、発火してしまうとテストにかかる時間が伸びてしまいます。

そこで、シナリオテストのみ他のテストとフォルダ単位で分離してテストを別々に実行しています。 シナリオテストの場合のみCloud Functionsエミュレータを起動し、Firestore Event Triggerを発火させてテストします。

Cloud Functions名の定義ファイル

KomercoではCloud Function名やその実体のパス、グループなどが書かれた定義ファイルをfunctionDefinitions.tsとして用意しています。

この理由は「Cloud Functions実行時のモジュールの動的ロード」と「同時デプロイ数を制限したフルデプロイ」において、このファイルをimportして使用するためです。

定義ファイルの例です。

type FunctionProperty ={
  name: stringmodule: string
  implementation: string}type FunctionDefinition =
  | ({type: 'single'}& FunctionProperty)
  | {type: 'group'; groupName: string; functions: FunctionProperty[]}exportconst functionDefinitions: FunctionDefinition[]=[{type: 'single',
    name: 'createShop',module: './shop',
    implementation: 'onCreateShopCalled',},{type: 'group',
    groupName: 'product',
    functions: [{
        name: 'create',module: './product',
        implementation: 'onProductCreated',},{
        name: 'update',module: './product',
        implementation: 'onProductUpdated',},],},]exportdefault functionDefinitions

typeはグルーピングされたCloud Functionsか単独かを表します。 nameは関数名、 moduleimplementationが実装先を表します。

Cloud Functions実行時のモジュールの動的ロード

Cloud Functionsは実行時に必要なモジュールのみロードするようにしないと、初回のコールドスタート時の実行時間に影響が出ます。 そこでKomercoでは実行されたCloud Functionsによって、動的にロードするモジュールを切り替える仕組みを入れています。 定義ファイルからCloud Function名と実装先を読み取ってロードします。

import functionDefinitions from'./functionDefinitions'const shouldExport =(functionName: string): boolean=>{const currentFunctionName = process.env.K_SERVICE
  return(
    currentFunctionName ===undefined|| currentFunctionName === functionName
  )}

functionDefinitions.forEach((definition)=>{switch(definition.type){case'single':
      if(shouldExport(definition.name)){
        exports[definition.name]= require(definition.module)[
          definition.implementation
        ]}breakcase'group': {const groupedFuncs = definition.functions.reduce((previous, current)=>{const functionName =[definition.groupName, current.name].join('-')return shouldExport(functionName)
          ? {
              ...previous,[current.name]: require(current.module)[current.implementation],}
          : previous
      },{})

      exports[definition.groupName]= groupedFuncs
      break}}})
フルデプロイ時の書き込み制限問題

サービスが拡充されるに連れてCloud Functionsの数は増え、現在では100ほどのCloud Functionsが作成されています。 Komercoでは本番にデプロイする際は、基本的にmasterブランチにあるFirebaseプロジェクトの内容をすべてデプロイします。 ここで、Cloud Functionsが多くなるにつれて、デプロイの制限にひっかかって頻繁にデプロイエラーを起こしていました。

https://firebase.google.com/docs/functions/manage-functions

多くの関数をデプロイすると、標準の割り当てを超過し、HTTP 429 または 500 エラー メッセージが表示されることがあります。これを解決するには、10 個以下のグループで関数をデプロイします。

そこで定義ファイルから全関数のリストを取得し、最大10個ずつデプロイする仕組みを作っていました。

次のようなデプロイ用スクリプトをTypescriptで実装を作成し、 ts-nodeで実行してしました。

import{ functions }from'./src/functions'const deploy =async(option: { only?: string; except?: string})=>{let command: string | undefinedif(option.only){   
    command =`yarn run firebase deploy --force --only ${option.only}`}elseif(option.except){ 
    command =`yarn run firebase deploy --force --except ${option.except}`}if(command){const stdout = execSync(command)    
    console.log(stdout.toString())}}const main =async()=>{const chunkedFunctions = ... // functionsを最大10個ずつに分割await deploy({ except: 'functions'})// Cloud Functions以外をデプロイfor(const funcs of chunkedFunctions){await deploy({ only: funcs.map(f =>`functions:${f}`).join(',')})// 分割されたfunctionsをデプロイ}}    

main().catch(e =>{ 
  console.error(e)  
  process.exit(1)})

Cloud Functionがグルーピングされているされている場合、そのグループのCloud Functionsは一度にデプロイするようにしています。 これは、デプロイにONLYオプションを使う場合に、グループ内で減ったCloud Functionがある場合に自動で削除が可能なためです。 グルーピングされていないものについては自動削除できなそうなので、これからCloud Functionが増えることを考えて初期の段階からグルーピングを使った方がいいかもしれません。

これによってデプロイエラーは無くなったのですが、デプロイに非常に時間がかかるようになってしまいました。

firebase-tools v9.9.0でデプロイのリトライが追加

実はこの記事を書いているうちfirebase-tools v9.9.0がリリースされ、先に述べたデプロイエラーが発生したときに自動的にリトライする仕組みが入りました。そのため現状は一度にデプロイする関数を10個以上に指定しています。

ただし今でも、全て一度にデプロイしようとするとエラーになることがあるようで、引き続きこの仕組が必要になりそうです。

まとめ

Komercoで導入しているFirebase運用の仕組みについてご紹介しました。 こういった仕組み化により、エンジニアは少ないながらも高速に開発ができています。 ご興味ある方は、ぜひ弊社に遊びに来てください。

info.cookpad.com

ここで告知

実は本日4/22(木)から5/5(水)まで春のオンライン陶器市を開催しています。 なんと期間中は送料無料です。 これに加えて、本日からお茶・紅茶・珈琲カテゴリが追加されました!

ぜひこの機会にお買い求めください。

komer.co


Cookpad Summer Internship 2021 (10 day Tech コース)を開催します!

$
0
0

f:id:fufufukakaka:20210426121451j:plain

研究開発部の深澤 (@fukkaa1225) です。今年になってエンジニアの立場から新卒採用を担当しています。

クックパッドでは、毎年恒例のサマーインターンシップを今年も開催します!本記事では、エンジニアコースについてご紹介いたします。

以下のインターンシップ特別サイトからご応募いただけます。

日程と講義内容について

エンジニアコースは、今年は 10 日間の日程で行います。

前半 5 日間は講義・ハンズオン形式です。 クックパッドのアプリケーション開発技術や、サービス開発のノウハウについて学びます。 開発技術として、モダンなフロントエンド化のために採用した React、TypeScript、Next.js、クックパッドを長く支えているRuby on Rails、AWS などを題材に講義を実施します。 サービス開発講義では、クックパッドにおけるユーザーの課題解決のための考え方をインプットし、それを体感していきます。

後半 5 日間は実践的なプログラムとして、OJT プログラムと PBL プログラムの2種類を実施します。 OJT プログラムではクックパッドの部署に配属され、メンターの指導のもとサービス開発を実践します。 PBL プログラムでは社員エンジニアのサポートのもと、身近な課題を解決するサービスを自ら提案し実装していきます。

今年は以下の日程で開催されます。

  • 8/16(月) 〜 8/27(金)

昨年の様子については、以下の記事をご覧ください。

開催形式について

前半の講義はオンラインで行われますが、後半の実践プログラムでは OJT プログラムをオンサイト、PBL プログラムをオンライン、オンサイトのいずれかを選択できる形式で実施することを予定しています。

クックパッドオフィスは 2021 年 5 月より、恵比寿からみなとみらいに移転します。今回のインターンは移転後初のサマーインターンということもあり、参加される方々が新しいオフィスの雰囲気、そこで働く社員の人たちとの交流をできる限り体感できるようにしたいと考えました。 COVID-19 の感染拡大状況を注視しつつ、オンライン、オンサイト両方の準備を現在進行中です。


参加してくださる皆さまのため、サマーインターンシップには毎年会社を挙げて取り組んでいます。 クックパッドのエンジニアになった「未来の自分」を体験できた、と参加して頂く皆様に実感してもらえることを目指して準備を進めていきます。

また、長期の就業型インターンシップも通年で募集しています。 興味のある方は、以下のページからご応募ください。

皆さまのご応募をお待ちしています!

emruby: ブラウザで動くMRI

$
0
0

こんにちは、フルタイムRubyコミッタの遠藤です。

Ruby 3.0が出てもう4ヶ月経ってしまいました。最近のTypeProfの開発ですが、vscode拡張として使えるようにするために、一生懸命Language Server Protocolをいじって遊んでるところです。

こっちのほうはまだ実験段階なので、まとまったころに説明するとして、今回は、Ruby 3.0リリース後にほそぼそとやっていたemrubyをご紹介してみます。

emrubyとは

ブラウザの上で動くMRI(Matz Ruby Interpreter)です。

「エムルビー」だと組み込み向けRuby実装の mrubyと紛らわしいので、「イーエムルビー」と読んでください(とmatzに言われています)。

デモ

このページを開いてみてください。

mame.github.io

"Code"の下のエディタ部分にRubyコードを書き、Runのボタンを押すと、実行結果が"Result"の下に出てきます。

f:id:ku-ma-me:20210430180447p:plain

たとえば

p 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10

と書いて実行すると 55と出てきます。

require"stringio"
p StringIO.new("foobar").read(3)

のように(一部の)拡張ライブラリも使えます。

JavaScriptでは考えにくいですが、同期的なウェイトも可能です。

puts "waiting..."
sleep 1
puts "done"

とても実験的ですが、JavaScriptコードを呼び出すことも可能です。

p emscripten_run_script_int(<<JAVASCRIPT)
(function() {  var sum = 0;  for (var i = 1; i <= 100; i++) sum += i;  return sum;})();JAVASCRIPT

JavaScriptで1から100まで足して、その結果をRubyのKernel#pメソッドで出力します(いまのところ、intの返り値しか対応してません)。

技術的な話

原理的には、C言語ソースコードをWebAssemblyにコンパイルしてくれるEmscriptenを使ってMRIをコンパイルしただけです。

しかし現実的には、コンパイルだけでも細々とした修正や対応が必要でした。

  • Emscriptenでは使えないC APIをいろいろケアした(たとえばIO.popenはNotImplementedErrorにしたとか)
  • 保守的GCがそのままでは動かないので、Emscriptenが提供している保守的GC用のAPIを使うようにした *1
  • 動的リンクは実験的サポートらしかったので、必要な拡張ライブラリを静的リンクするようにした
  • その他こまごまとコンパイルオプションを調整した

これらの変更はRubyソースコードへのパッチにする必要がありますが、幸いコミット権限を持っているので、すでにmasterを更新してあります。パッチを管理しなくてすんでよかった。

コンパイルしてみたい人は、emscriptenを使えるようにして(Emscriptenのドキュメント参照)、Rubyのmasterブランチを次のようにコンパイルすると ruby.wasm などができるはずです。

$ ./configure \
  --build x86_64-pc-linux-gnu \
  --host wasm32-unknown-emscripten \
  --with-static-linked-ext \
  --with-ext=ripper,date,strscan,io/console,…,psych \
  optflags=-Os debugflags=-g0 \
  CC=emcc LD=emcc AR=emar RANLIB=emranlib

$ make

フロントエンド、といってもemrubyは全部フロントエンドですが、特にユーザインターフェイスの部分はNext.jsxterm.jscodemirrorで自作しています。↓のHackarade(社内ハッカソン)に乗っかってエイヤと作りました。

techlife.cookpad.com

想定問答

なんで作ったの?

RubyがWebフロントエンド市場に本格的に進出する足がかり!という意気込みが当初は少しだけありました。実際、WASMが話題になった2017年ごろは、ブラウザでも適材適所で言語を選べるのでは、という期待感があったと思います。が、最近のTypeScriptの隆盛を見ると、なかなかそういう感じにはなってないですね。やっぱりJavaScriptは強い。

しかしそれでも、RustやGoやKotlinなど、最近の言語はWASM対応をうたっていることが多いです。どの言語がどのマーケットをとるかは運ですが、動いてない言語は候補にもならないので、動くようにしておくことは大切かなと思っています。宝くじを買う気分。*2

などと適当なことをいいましたが、正直に言えばJust for funなところが大きかったです。ブラウザの上でRubyが動くのはそれだけで楽しい。

完成度は?

rubygemsやirbも含めて一応動いています。意外と動くなあ、という感じです。

https://mame.github.io/emruby/irb/

とはいえ、やはりEmscriptenは普通の環境ではないので、普通の環境のMRIに比べるといろいろ問題があります。たとえば、Fiberが動かない*3、Threadも動かない、など。もし興味のある人がいたら一緒に改良しましょう。

(ちなみにirbのデモではSharedArrayBufferを使っているのですが、これはChrome 91から制限が強化されるらしいので、Chrome 90以前でしか動かない見込みです。Chrome 91は5月にリリースされるらしいので、つまり、今しか動きません……)

なお、もし今すぐブラウザの上で動くRubyを書きたいなら、Opalを検討するのがよいと思います。他には、次の記事も参考になると思います。

blog.unasuke.com

まとめ

ブラウザの上で動くMRI、emrubyを紹介しました。遊んでもらえるとうれしいです。

先日銀座Rails#32でもemrubyについて話したので、発表資料を貼っておきます。Rubyのビルドプロセスや、Emscripten自体に興味がある人は面白いと思います。

www.slideshare.net

*1:マシンスタックやレジスタを走査してオブジェクトの参照を探すということをするのですが、そのためにスタックの先頭と終端を得るAPI emscripten_scan_stack やレジスタをスキャンするためのAPI emscripten_scan_registers がEmscriptenによって提供されていたので、それらを使うようにしました。

*2:似たような気持ちで、AndroidエミュレータでもRubyをCIテストしてたりします。こっちは意外と全テストが完走するのですごい。だれかiOSもやらないかな。

*3:emscripten_fiber_init や emscripten_fiber_swap などのAPIを使っているので、動くはずなのですが、minirubyでは動くけどrubyでは動かない状態です。原因もよくわからないので、デバッグが必要。

アプリリニューアルを楽しくやりきる話

$
0
0

こんにちは。レシピ事業開発部 クロスファンクショナルグループの @kaaです。

クックパッドAndroidアプリは昨年秋にフルリニューアルを実施しました。 リニューアル内容としては半年ほど先にiosでリニューアルを実施したものを導入になります。 弊社はこのリニューアルプロジェクト開始の2ヶ月ほど前からリモート主体の勤務体制になっており、期間中は週1で出社という状況でした。まだリモート勤務にもみんなが慣れているとは言えないタイミングでの実施となりました。

このプロジェクトのため3ヶ月程度の期間、部署横断でメンバーを集め進めました。 体制は以下の通り。

  • アプリエンジニア6名(1名はAPI兼任)+決済・基盤・テストのサポートに2名
  • デザイナー2名(デザインディレクション+1名)
  • ディレクター3名(PdM,PjM,開発ディレクション)

このプロジェクトについて、開発ディレクションの立場から楽しくやるために行っていたことをお話します。

モチベーションに関する課題

評価に対する不安

部署横断プロジェクトにありがちな課題ですが、メンバー本人の年初にたてた個人目標・チーム目標とは直接関係がない仕事をすることになります。 自分の仕事に対して評価が行われるのかわからない、最終評価者は詳しく見ていない、さらには自分がやりたいと考えていた今年の仕事とは異なる可能性もある。

この課題の対応のため、開発進行をしている人(自分ですが)が期末の追加の評価者として開発メンバー全員の評価にディレクションの立場から参加するということを事前に決めました。

主体性を持ってもらうための仕組み

人がパフォーマンスを出す条件として、次の3点を考えています。

  • やりたい仕事であること
  • 裁量があること
  • 仕事に集中できること

やりたい仕事であること

初耳の、3ヶ月拘束される本来の仕事と違うプロジェクトでいきなりパフォーマンスが出せる人はまずいません。 さらに今回は別プラットフォームでリリース済みのことを実装なので新規性もありません。 どうやって自分のやりたい仕事だと思ってもらうかが必要になります。

まず、参加する開発メンバーにいまのアプリ開発で改善したい点をだしてもらいました。

参考:大規模プロジェクトにおけるモバイル基盤の取り組み

長く運用しているアプリですので棚卸ししたい負債や制定したい実装ルールは溜まっています。 minSdkVersionあげたい、マルチモジュール化すすめたい、遷移ルールの統一、スタイルの再定義などなど。やりたいけど実施タイミングがとりにくいものはたくさんです。 これらの話題を事前に時間とって話し合い、共通の理想とする実装方法・改善をまとめます。今回は普段は基盤チーム(弊社ではサービス開発と開発基盤は部署として分かれています)に所属しているメンバーも多く参加しておりプロジェクトの事前準備の段階から関わっていました。

そしてこれらを実現できるプロジェクトとして進めました。他の部の仕事にお手伝い参加ではなくいま自分達の考える理想のアプリ開発に近づける仕事にしていきます。

裁量があること

各メンバーに対して、任せたタスクについてはほぼ一任しました。 仕様は事前に細かく詰める、でも仕様の実現方法については任せきります。 条件はコードレビューが通ること。 (クックパッドではPRのマージには一人以上のapprovedが必要です)

これだけでは実装方法にばらつきが発生しそうですが、事前に実装方法についての方針を開発陣で話し合って方針を決めているため、そこから外れることはありません。自分達の考えたルールに縛られるのはつらいことではなく自分達の理想を実行している状態ですので、やりたいようにやりつつ統一された実装方法で進めている、という状態になります。

また、仕様の実現が難しい場合はいつでも相談が可能としました。 実装して触ってみないとわからない問題はどうしても起きるものですしその画面について一番時間を使っているのは実装者本人なので気付ける改善も必ずあります。

ただし、相談には本人のどうしたらいいと思うかをあわせて伝える、というルールです。 どうすればよくなるか考えるきっかけにもなり、同時に仕様策定者に対する別の視点の提供になります。実際ほとんどの場合は開発者の意見をそのまま採用しました。 NGの場合は明確な理由があるものなので説明します。サービスの方向性の目線合わせもかねて。

集中できること

基本的に、人は考える必要のあることが少ない状態にあるほどパフォーマンスが上がると考えています。 考えてもすぐに結論のでないことはいくらでもありますし、違う話を理解するのも脳と時間のコストがかなり持っていかれます。スイッチングコストは本当にたいへん。

今回は2週間単位にフェーズを切り、タスクは画面単位に分けました。 その期間中、または完了するまでの間、他の人のコードレビュー以外で担当画面以外のことを考える必要がない状態を目指しています。

タスク単位を判りやすくしたのは、実装者の成果をわかりやすくする意味合いもあります。〜〜をやりました、とわかりやすく言えると評価に限らず仕事の実感としても感じやすいことを狙っています。 画面単位の実装段階は2つにわけ、機能実装と最終デザイン反映を別にしました。これは複数人でフェーズ単位の進め方をすると各実装のデザイン確認の時期がフェーズ終盤に集中し実装者に待ちが発生するのを避けるためです。機能実装が完了した段階ではデザイナーのチェックをなくし(スタイルは反映されているため大きく破綻はしません)、エンジニアは次のタスクに移ることが可能になります。

弊社では通常時はUI変更がある場合にデザイナー確認なしでのPRのマージは禁止ですがこのプロジェクト中は例外としました。

リモートにまつわる課題

リモートではただでさえ距離感が難しくなりますが、今回はそもそも所属している部も異なるメンバーが集まっています。エンジニア同士であればリポジトリ上、slack上でのやりとりはしていたとはいえ、職種が変わるとこれまであまり関わりがない人もいます。実際に開発ディレクションの自分とほとんど話したことのない開発メンバーもいました。

レスポンスを確約する

質問したいことがあった際にすぐ反応がもらえるか、も重要ですが今回は質問に対していつ答えを出すかを事前に決めました。 その日のうちに質問しておけば、翌日の昼会で決定した内容を伝えます。昼会で話題にする、相談するでなく決定状態にする、です。持ち越しダメゼッタイ。ディレクションのメンバーもきっちり仕事していると見せないといけません。すぐに反応するとか相談できるではなく確定させるタイミングをルール化します。 いつ決定した仕様がわかるのかが事前にわかっていれば実装の段取りもたてやすくなります。 どんなことでも知りたいことは翌日には確定したものがわかる、という状況を作ります。もちろん即答できるものは即答です。

情報の公開

実装に集中すればするほど、slack追ってらんないし関係ないかもしれないzoom色々はいっていられない。 しかしいまプロジェクトでなにが話題になっているか知りたい時に知れる状態を目指します。 これが所属している部での仕事であればミーティングのルール整備といったことになりますがこれは3ヶ月の短期プロジェクトですので整備している暇はありません。

ではどうするか。ほとんどすべてのミーティングに参加している人が1人はいますよね、その人が全部やりましょう。今回の場合自分です。 大丈夫数ヶ月の短期プロジェクトならこれでなんとかなる。意地で議事録取り続ける、ミーティング終わったら整理してslackで共有。すべてを明文化です。

楽しくやる

いいものができてきていると言い続ける

リモートによらないことでもありますが、リモート主体であるからこそいま自分達がいいものを作っているという実感を得ながらやっていきたいものです。チームの皆が頑張っている様子を可視化します。

基本姿勢はホメ&ポジティブです。なにかできていたらこれすげーいいじゃんとかばっかり言ってました。 ディレクションとしては内心スケジュール間に合うかとか考慮もれがないかとか不安が色々と発生し続けてますが、それらはあくまで自分の責任であって、それをうまくやるのが仕事なので特に周りに伝える必要はありません、不安なとこ見せない。

メンバーにこのプロジェクトうまくいくか不安なんて伝えても集中できなくなるだけですし、不安は伝搬してしまうし自分の多少のストレス軽減になるだけでなにもいい効果を生んでくれません。 であればぶっちゃけるタイミング以外では不安を周囲に伝える必要はありません。

裏目標を作る

最後に、自分自身のモチベーションの話。 以前に別プラットフォームで行ったリニューアルの導入プロジェクト、どうしても単なる移植になりがちでモチベーションの確立が難しい部分があります。 このプラットフォームにあわせたUIを設計し、仕様に落とし込むのですがその際、先にリリースした時よりもさらに考えることは難しい。OSにあわせて自然に、という意識がどうしても働きます。要は無難なことをしがち。

なので意識的にプラットフォームごとにUIを変更する部分において、いくつかの指標について先にリリースしたものの数字を超えることを目標としました、個人的に。 とはいっても特殊なことをするのはダメですし以前のプラットフォーム(今回でいうとiOS)のものをそのまま導入するよりandroidにマッチしたUIである必要があります。プラットフォームへの最適化と数値目標の両立を目指します。導入するのはリニューアルの体験・コンセプトであって焼き直しや単なるOS最適化ではありません。

これにより画面仕様策定の楽しさが格段に上がります。別プラットフォームですでにリリースしたアプリとそのアプリでの数字は既に知っています。これから作るプラットフォームでのアプリでは先にリリースした時よりも理解が進んでいてもっといいものができますよね?

最後に

今回は短期・社内横断プロジェクトでのモチベーションの観点で話しました。

クックパッドではチーム開発をやっていきたいメンバーを職種限らず募集しております。興味のある方はぜひご気軽にご連絡ください。

音声インターフェースに最適なビジュアルインタラクションを実現するための APL テクニック

$
0
0

こんにちは、 CTO 室の 山田 (@y_am_a_da) です。今回は、 Amazon Presentation Language (APL) という、Amazon Alexa 向けのアプリケーション (以下、スキルと記述します) 上で主にビジュアルの表現や、音声や液晶操作によるユーザーとのインタラクションを実現するために使われている言語についての簡単な紹介と、最近のクックパッドでの実装テクニックについてお話させていただきたいと思います。

APL とは

APL とは、先に述べた通り、 Amazon Alexa 向けのスキル上で主にビジュアルの表現などを実現するために使われている言語です。画面に表示するコンポーネントの定義やデザイン、 ユーザーとの簡単なインタラクションを実現するためのロジックを以下のような JSON 形式で記述することができます。

{"document": {"type": "APL",
        "version": "1.6",
        "theme": "dark",
        "mainTemplate": {"parameters": ["payload"
            ],
            "items": [{"type": "Container",
                "width": "100%",
                "height": "100%",
                "items": [{"type": "Text",
                    "text": "こんにちは"
                }]}]}}}

Amazon 社の販売する Amazon Echo は、数年前から液晶付きの端末を何種類か販売しており、もちろんそこで動くスキルも音声だけでなく画面に表示をすることでユーザーとやりとりをすることができます。例えば、クックパッドスキルでは 2021 年 5 月現在、ユーザーが指定したレシピの作り方をナビゲーションする機能を提供しており、その際には以下のような画面を Echo 上に表示しています。

f:id:yoshiaki-0614:20210527142123p:plain
クックパッドスキルの画面

APL 自体はただの言語とそれを評価する仕組みでしかなく、https://github.com/alexa/apl-core-libraryで OSS として公開されているため、 Web ブラウザやモバイルアプリケーション上でもレンダリングの仕組みさえ用意すれば APL を導入することが可能です。

また、 APL は Amazon Alexa 向けに提供していることもあり、 Amazon Alexa や、音声インターフェース特有の技術課題や要望を満たす上で役立つ特徴をいくつか備えています。

なお、今回の記事執筆時点での APL の最新バージョンは 1.6 です。可能な限り詳細に書きますが、スキルを開発したことが無いと理解が難しい内容も含んでいます。

APL の優れている点

データサイズを小さくするための仕組みが提供されている

Amazon Alexa 上で動くスキルは、予め用意したサーバーにリクエストを送信し、そのレスポンスをもとに発話や画面表示などのインタラクションをする。という仕組みで動いています。 サーバーから返すレスポンスにはセッション変数、 Alexa に読み上げさせるテキスト、 APL によるビジュアルの記述などを含めることができるのですが、合計で 24KB 以内に収める必要があります (つい先日、仕様の変更によりついに 120KB まで許容されるようになりましたが、スキルがクラッシュしなくなるだけで応答はレスポンスのデータサイズが大きくなるほど遅くなるため、対話的なインタラクションを実現したいならば今まで通りに抑えておいたほうが良いと思います)。

この限られた容量に収められるように、 APL では下の例の documentdatasourcesのように、画面に表示するコンポーネントとそこで利用したいデータを分離して記述できる仕組みがあります。これにより、コンポーネント内で何度か同じデータを参照する必要があったとしても、一度 datasourcesに定義するだけで使い回すことができます。

{"document": {"type": "APL",
        "version": "1.6",
        "theme": "dark",
        "mainTemplate": {"parameters": ["payload"
            ],
            "items": [{"type": "Container",
                "width": "100%",
                "height": "100%",
                "items": [{"type": "Text",
                    "text": "${payload.helloWorldData.helloWorld}"
                }]}]}},
    "datasources": {"helloWorldData": {"helloWorld": "Hello World"
        }}}

この他にも、とにかくデータサイズを小さくするための様々な仕組みが提供されています。

音声によるアウトプットに特化した仕組みが提供されている

Amazon Alexa において、視覚的なフィードバックはあくまでも音声でのフィードバックを補助する立場にあります。そのため、音声でのアウトプットと可能な限り同期的に視覚的なインタラクションを実行させる仕組みが提供されています。

APL で動的なインタラクションを実現する場合には、コマンドと呼ばれるものを使う必要があるのですが、例えば以下のように記述することで「DescriptionFrameというコンポーネントの背景色を黄色にし、 SpeakComponentの内容を読み上げた後、背景色を白色に戻す」ということができます。

{"type": "Sequential",
    "commands": [{"type": "SetValue",
            "componentId": "DescriptionFrame",
            "property": "backgroundColor",
            "value": "yellow"
        },
        {"type": "SpeakItem",
            "componentId": "SpeakComponent"
        },
        {"type": "SetValue",
            "componentId": "DescriptionFrame",
            "property": "backgroundColor",
            "value": "white"
        }]}

また、例えば以下のように記述をすることで「SpeakComponentの内容を読み上げるのと並行して DescriptionFrameの背景色を 500ms おきに黄色に 2 回点滅させる」ということもできます。このように、音声によるフィードバックに合わせたインタラクションを実現しやすくなっていることも APL の特徴といえるかと思います。

{"type": "Parallel",
    "commands": [{"type": "Sequential",
            "repeatCount": 2,
            "commands": [{"type": "Idle",
                    "delay": 500},
                {"type": "SetValue",
                    "componentId": "DescriptionFrame",
                    "property": "backgroundColor",
                    "value": "yellow"
                },
                {"type": "Idle",
                    "delay": 500},
                {"type": "SetValue",
                    "componentId": "DescriptionFrame",
                    "property": "backgroundColor",
                    "value": "white"
                }]},
        {"type": "SpeakItem",
            "componentId": "SpeakComponent"
        }]}

このように、 APL にはいくつか強みといえる特徴がいくつかあるのですが、 Public Beta 版になったのが 2018 年後半とまだ産まれてそんなに経っていないこともあり、不便な点も多くあります。

APL の不便な点

画面を構成する要素の記述が宣言的であるものの、その状態の監視や更新の仕組みが貧弱

APL では、画面を構成する要素 (以下コンポーネントと記述します) をいわゆる宣言的に記述できます。 bindプロパティに定義した変数の値によりコンポーネントの内容を変化させることができます。 例えば、以下のように記述をすることで画面に「こんにちは」と描画をすることができます。 GreetingTextの値を変更することで画面に表示するテキストも動的に変化させることができます。 ${}で囲むことでその内部の変数の展開や式の評価などができます。

{"id": "MainContainer",
    "type": "Container",
    "width": "100%",
    "height": "100%",
    "bind": [{"name": "GreetingText",
        "value": "こんにちは",
        "type": "string"
    }],
    "items": [{"type": "Text",
        "text": "${GreetingText}"
    }]}

APL において bindに定義されている値を明示的に変化させる手段は SetValueコマンドただ 1 つです。例えば以下のように記述します。

{"type": "SetValue",
    "componentId": "MainContainer",
    "property": "GreetingText",
    "value": "Hello"
}

componentIdに変更したい対象の id を設定し、 propertyに変更したい変数名 (正確にいうと widthheightなどのプロパティも設定できます)、 valueにその値をセットできます。 例示したような簡単なコンポーネントであれば問題は無いのですが、例えば、あるコンポーネントの bindに定義されている値を他のコンポーネントで監視したい場合には工夫が必要になります。

例えば、あるボタンをタップした時に別のコンポーネント内のテキストを表示させたい状況を考えてみます。

{"mainTemplate": {"parameters": ["payload"
        ],
        "items": [{"type": "Container",
            "items": [{"id": "PressedStatusContainer",
                    "type": "Container",
                    "items": [{"type": "Text",
                        "text": "ボタンがタップされました"
                    }],
                    "display": "${IsPressed ? 'normal' : 'none'}"
                },
                {"type": "TouchWrapper",
                    "bind": [{"name": "IsPressed",
                        "value": false}],
                    "onPress": [{"type": "SetValue",
                        "property": "IsPressed",
                        "value": "true"
                    }],
                    "items": [{"type": "Text",
                        "text": "ボタン"
                    }]}]}]}}

上記の例では TouchWrapperにバインドされている IsPressedの値により display値を変化させようとしていますが、これは想定通りの振る舞いをしません。なぜなら PressedStatusContainerIsPressedを参照できないからです。以下のように記述をする必要があります。

{"mainTemplate": {"parameters": ["payload"
        ],
        "items": [{"type": "Container",
            "bind": [{"name": "IsPressed",
                "value": false}],
            "items": [{"id": "PressedStatusContainer",
                    "type": "Container",
                    "items": [{"type": "Text",
                        "text": "ボタンがタップされました"
                    }],
                    "display": "${IsPressed ? 'normal' : 'none'}"
                },
                {"type": "TouchWrapper",
                    "onPress": [{"type": "SetValue",
                        "property": "IsPressed",
                        "value": "true"
                    }],
                    "items": [{"type": "Text",
                        "text": "ボタン"
                    }]}]}]}}

IsPressedTouchWrapperではなくその上位のコンポーネントにバインドする必要があります。 APL では、あるコンポーネントが別のコンポーネントに定義されている bindを参照したい場合、ただ一つのルールに則って可能となっています。「bindに定義された値は、後続のコンポーネントでも参照ができる」です。 これをコンポーネント視点で言い換えると「あるコンポーネントは、自分とその親の bindに定義されている値を参照できる」になります。そのため、複数のコンポーネントから監視したい値がある場合、それら全ての親コンポーネントとなるコンポーネントで bindで定義する必要があります。

これは非常に取り扱いの難しい仕組みです。例の IsPressedTouchWrapperが押されたかどうかを表現しているわけなので TouchWrapperに定義するほうが直感的です。しかし値の定義場所はそのコンポーネントだけの都合ではなく参照しうる全てのコンポーネント間の関係性によって決定する必要があります。 しばらくした後にコードを読み返した時、もしくは多くの開発者によって頻繁にコンポーネントの構造や挙動が修正される環境で、その修正に問題が無いかを判断するにはその画面の全体像やそのコンテキストを深く理解した人間による属人的な判断が頼りになるであろうことは想像に難くありません。

上記の例でしたら直接 PressedStatusContainerdisplayの値を変更する手段もとれますが、コンポーネントの数が増えてくるごとに難しくなっていくためおすすめできません。

また、これに近い話になりますが、 bindの値を監視する時だけでなく、値を動的に変更したい場合にも似たような困難にぶつかります。 例えばボタンがタップされた時に TextBoxという idのテキストボックスに入力された値を MessageTextというテキストコンポーネントで表示する例を考えてみましょう。

APL において値の更新に利用できるのは SetValueただひとつです。そして、 SetValueでは、コンポーネントを 1 つしか指定できません。 では、「TextBoxの値を MessageTextに表示する」をどうやって表現すれば良いのでしょうか。

このようにすることで実現できます。

{"mainTemplate": {"parameters": ["payload"
        ],
        "items": [{"type": "Container",
            "bind": [{"name": "Message",
                    "value": ""
                }],
            "items": [{"type": "Container",
                    "items": [{"id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]},
                {"id": "TextBox",
                    "type": "EditText"
                },
                {"type": "TouchWrapper",
                    "onPress": [{"type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{"type": "Text",
                            "text": "ボタン"
                        }]}]}]}}

以下の部分が実際に値を変更しているコマンドになります。一言で書くと「設定したい値を持つコンポーネントを componentIdに指定し、そのコンポーネントと、値を利用したいコンポーネントの両方が参照できる位置にある bind値を propertyに指定して更新する」となります。

{"type": "SetValue",
    "componentId": "TextBox",
    "property": "Message",
    "value": "${event.target.text}"
}

このように、 bindを定義する位置はその値の監視、更新の都合に強く依存します。そのため、 bindの修正や、例えばある 1 つのコンポーネントの修正をするだけでも、その箇所だけではなくより広い範囲について深い理解を必要とします。

また、 APL のコンポーネントは layoutsというプロパティ内にモジュールとして定義し、呼び出すことが可能なのですが、この制約がモジュールの作成を非常に難しくしています。例えば前の例に倣うと以下のような例が考えられます。前の例の TouchWrapperSendButtonという名前で呼び出せるようになっています。

{"layouts": {"SendButton": {"items": [{"type": "TouchWrapper",
                    "onPress": [{"type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{"type": "Text",
                            "text": "ボタン"
                        }]}]}},
    "mainTemplate": {"parameters": ["payload"
        ],
        "items": [{"type": "Container",
            "bind": [{"name": "Message",
                    "value": ""
                }],
            "items": [{"type": "Container",
                    "items": [{"id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]},
                {"id": "TextBox",
                    "type": "EditText"
                },
                {"type": "SendButton"
                }]}]}}

SendButton内には Messageプロパティの定義は無いものの、ボタンをタップした際には Messageプロパティに値を設定しようとします。別の開発者がこのコンポーネントを再利用したい場合、 SendButtonよりも上位のコンポーネントで Messageを定義する必要があることに気づく必要があります。そもそも、そのモジュール内で挙動が完結しないモジュールはモジュールとしてどうなんだという話もあります。

モジュールを定義する場合には、この点を考慮してモジュールの粒度を考える必要が出てきます。

さて、便利な点も不便な点について理解したところで、次はクックパッドスキルで実際に利用しているテクニックについて紹介をしていきたいと思います。

クックパッドでの APL 実装テクニック

ここまで述べてきた通り、 APL には様々な特徴が備わっています。これを上手く使いこなし、 VUI (Voice User Interface) ならではの視覚体験を提供するために、クックパッドでは以下のようなテクニックを用いています。 今回主に以下の 2 つを達成するためのテクニックを紹介します。

  • GUI による操作でも VUI による操作でも一貫性のある体験を提供する
  • APL の bindの仕組みを上手く使いこなす

コマンドでコンポーネントのスタイルを直接変更しない

APL では「コンポーネントまでスクロールさせる (ScrollToComponent)」や「コンポーネントに紐づくテキストを読み上げる (SpeakItem)」など、直接コンポーネントを指定してコマンドを実行することがよくあります。

コマンド実行時にコンポーネントのスタイルを変更したい場合、ついコマンドで直接スタイルを書き換えたくなりますが、 APL では UI を宣言的に記述した方が良いので可能な限り避けるべきです。スタイルはあらかじめ bindに紐付けるようにしておき、コマンドでは bindの値を変更するに留めておきましょう。

これには以下のメリットがあります。

  • スタイルに関する記述がコンポーネント内に閉じ込められるため、見通しが良くなり、挙動を把握しやすくなる
  • 画面操作による要求でも発話による要求でも一貫した振る舞いをすることが容易になる

例えば、クックパッドスキルではこのテクニックを利用して以下のようなインタラクションを実現しています。

f:id:yoshiaki-0614:20210527142235g:plain
クックパッドスキルでのスクロール時のインタラクション

左下の「材料」ボタンをタップすると、材料の一覧までスクロールし、一部の文字がハイライトされたり、「上へ」のボタンが登場しています。 これらのスタイルは、全てスクロール位置に紐付いて変更させています。ボタンをタップした時に発火するコマンドは材料の一覧に対する ScrollToComponentのみです。 そのため、例えばユーザーが「材料を教えて」と声で尋ねてきたとしても同じく ScrollToComponentコマンドを発行するだけで同じ振る舞いをすることができます。

GUI と VUI が同居する世界観では、 GUI のみの場合と違いユーザーは同じ要求をボタンのタップだけでなく音声で求めてくることもあります。 コンポーネントのビジュアルを bindに依存させることで、要求のインターフェースが何であってもユーザーの意図していることが同じであれば同じ結果を返すことが容易になります。 これにより、振る舞いの一貫性を保ち、ユーザーに余計な認知負荷を与えないインタラクションが実現できます。

React や Flutter など、他のプラットフォームで似たフレームワークを利用した経験がある方からすれば当たり前のことのように聞こえるかもしれませんが、 APL では意外とやりがちなので意識しておくと良いと思います。

SendEvent によるリクエストの処理は Intent Request に合わせる

APL には SendEventという、サーバーにリクエストを送信するコマンドが用意されています。例えば画面遷移をしたい場合にはサーバーにリクエストを送信して新しい画面を受け取る必要があるため、このコマンドを使うことになります。

SendEventは、 arguments内に好きな値を入れてサーバーに渡すことができます。

{"type": "SendEvent",
    "arguments": []}

argumentsは自由度が高すぎるため、そのまま使うとサーバー側で値を利用する際のロジックがとても複雑になり得ます。例えば SendEventを送信するボタンが画面に複数個存在していた場合、 argumentsだけ渡されてもそれが何のパラメータなのかを理解するだけで一苦労です。複数個のさまざまな型の値が含まれうる argumentsを想定しなければならない状況は作りたくありません、

そのため、クックパッドスキルでは以下のように Intent 名とそのパラメータで統一しています。すなわち、サーバー側では発話による Intent Request と同じ扱いができるようなフォーマットにしています。 前の話と被りますが、ユーザーは GUI で実現できることは音声でも求めてくることがあり、また、スキルはそれに応えられるべきだと考えています。そしてその場合、インターフェースの種類に限らず意図が同じであれば同じような結果が期待されることがほとんどです。であれば、 GUI によるリクエストは発話によるリクエストと同じ扱いを受けても問題はないはずです。

{"type": "SendEvent",
    "arguments": ["SelectRecipeIntent", "${params}"]}

クックパッドスキルでは APL が登場した当初からずっとこのルールで SendEventを利用していますが、今の所困ったことはありません。細かい振る舞いの微調整が欲しいことはあるので、サーバー側で最低限リクエストが GUI 由来かどうかを区別できるようにはしていますが、その程度です。

カスタムコマンドを活用する

APL におけるコマンドは、 APL 内だけでなくサーバー側からも発行することができます。前に述べた「材料を教えて」というユーザーからの発話に対して画面に ScrollToComponentコマンドを送信するのはサーバー側からのコマンドで実現しています。これは、レスポンスに ExecuteCommandディレクティブを挿入し、そこにコマンドを記述します。この場合、コマンドの記述はいわゆるサーバー側の特にリクエストを処理するロジックか、それに近いところで記述することになるかと思います。 一方で、先ほど登場した材料一覧のコンポーネントにスクロールするボタンは、そのコマンドを APL のコンポーネント内に記述することになります。この 2 つのコマンドは全く同じ振る舞いを期待しているため、可能な限り共通化をしたいです。

カスタムコマンドを利用して両者が実行するコマンドを共通化することには以下のメリットがあります。

  • コマンドの具体的な挙動が一箇所に集まるため、見通しがよくなり、修正をしやすくなる
  • APL のコンポーネントからでも、サーバー側の ExecuteCommand からでも同じコマンドを実行させることができるので、挙動の一貫性を保つことができる

特に APL のコマンドは、バグの検知が難しく、期待通りに動いているのかをテストするコストがなかなか大きいです。そのため、コード内や APL のコンポーネント内に色々なコマンドを直書きするのではなく、カスタムコマンドとして定義し抽象化することがとても重要だと考えています。

意味ある場所に bindを定義できない場合、いっそのことグローバル変数のようにしてしまう

APL の不便な点で述べたとおり、 bindに値を定義する場合、定義する場所はコンポーネント間の関係性に依存します。あまりにも多くの場所でその値を利用する場合、いっそのこと最上位に bindの値を定義するだけのコンポーネントを用意し、そこにまとめてしまっても良いかもしれません。

{"id": "GlobalVaribalesContainer",
    "type": "Container",
    "bind": [
        ...
    ],
    "items": [{"id": "Body",
            "type": "Container",
            "items": [
                ...
            ]}]}

一般的にグローバル変数を使う時のデメリットと同様に、気をつけるべきことはたくさんあるため慎重に採用するべきですが、コンポーネントの保守運用の点では他の開発者が bindの値を見つけやすくなったり、本質的でない場所に bindが定義されてしまうことによりモジュール化が難しくなることを避けることができます。

場合によっては意味が無くても引数を使う

layouts以下に定義されるモジュールには parametersで引数を渡すことができます。これを利用することにより、例えば前で例に出した SendButtonを利用したコンポーネントは以下のように書き換えることができます。

{"layouts": {"SendButton": {"parameters": ["Message"],
            "items": [{"type": "TouchWrapper",
                    "onPress": [{"type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{"type": "Text",
                            "text": "ボタン"
                        }]}]}},
    "mainTemplate": {"parameters": ["payload"
        ],
        "items": [{"type": "Container",
            "bind": [{"name": "Message",
                    "value": ""
                }],
            "items": [{"type": "Container",
                    "items": [{"id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]},
                {"id": "TextBox",
                    "type": "EditText"
                },
                {"type": "SendButton",
                    "Message": "${Message}"
                }]}]}}

これで SendButtonには Messageという値を利用することが明文化できました。また、上位の Containerbindされている MessageSendButtonで利用していることもひと目でわかるようになりました。

修正前と比べて挙動は変わらないのでシステム的に全く意味はありません。人間が挙動を理解するのに少し役立つ程度です。これが良い書き方かというと少し疑問は感じますが、コンポーネントが巨大になった時に bindの定義場所と実際に利用するコンポーネントが遠く離れることは珍しくなく、その時に挙動を理解するための手がかりとしてしばしば助けられたのでここに紹介しました。

まとめ

今回は APL という、 Amazon Alexa 向けのスキルでビジュアルインタラクションを実現するための仕組みについて紹介をさせていただきました。 APL はまだ産まれて間もないこともあり、実践的な内容を記している記事はまだそう多くないと感じております。 この記事が皆さまの日々の APL ライフに役立てばと思います。

弊社ではこのように色々な技術スタックを持ったエンジニアが数多く在籍しております。絶賛エンジニア募集しておりますのでご興味ありましたらぜひこちらのサイトをご覧ください。

https://info.cookpad.com/careers/

全国のスーパーに置かれる storeTV 端末の情報の取得にかかる時間を15分から10秒にした話

$
0
0

こんにちは。
メディアプロダクト開発部の柴原です。
普段は CookpadTV のサービスである storeTV や storeLive の Android アプリを開発を担当しています。

storeTV では現在、サービスを高品質に継続開発・運用するための仕組みづくりをしており、この記事ではその中の1つである「端末が再生した動画をリアルタイムで把握する機能」について紹介します。
このリアルタイムログの導入でもともと実際の端末で動画再生がされているのかを確認するのに15分かかっていたものが10秒程度に短縮できました。

storeTV とは

storeTV は、スーパーで料理動画を流すサービスで、店頭に独自の Android 端末を設置し、その売り場に適したレシピ動画を再生するサービスです。 より詳しいサービス概要にについては、弊社メンバーの Cookpad TechConf 2018 における以下の発表スライドを御覧ください。

動画の再生順序の概要

storeTV では、「手順動画15秒 * 4回 + 広告動画30秒 * 1回」の90秒を1ロールとし動画をループ再生しています。

f:id:Nshiba:20210607140255j:plain
1ロールの動画再生順序

背景

storeTV では通常のアプリよりも端末の状態把握が重要です。 なぜなら「storeTV」は、買い物客の「今日何作ろう?」と、店舗の「この食材がおすすめ!」をマッチングさせるサービスなので、サービスとして動き続けることが重要だからです。 万が一停止していると問い合わせが発生した場合こちらで端末の状態を把握し回答をする必要があります。
そのため、いくつか端末の状態を把握するための仕組みがあり、主に端末から送信しているログと端末管理システムから取得できる情報があります。

端末から送信しているログについては、ログが送信されてから実際に見れるようになるまで約15分程度の時間がかかります。 また、端末管理システムから取得できる情報はアプリバージョンやOSバージョンといった基本的なものと、1台ずつですがその時のスクリーンショットを取得することが可能です。

よって、端末の状態を把握するにはログが来るまでの15分程度待つか、端末管理システムで1台ずつ能動的にスクリーンショットを確認していく必要がありました。

このような状況を改善するためにリアルタイムで各端末の動画再生ログを収集するリアルタイムログを導入しました。

構成


リアルタイムログを実現するために以下のような構成を採用しました。

f:id:Nshiba:20210607140435j:plain
構成図

IoT

まずログの受け取りは AWS IoT の Rule Action を用いています。
なぜ AWS IoT を用いているかというと、端末管理システムは内製したもので AWS IoT を用いて開発しており、今回のリアルタイムログも端末管理システムの一部機能として実装しました。
端末管理システムのより詳しい話は、以下のAWS導入事例の記事を御覧ください。

aws.amazon.com

Rule Action とは、特定の Topic に対してデータが送られてきた際に特定の動作を行わせることができる機能です。 例として、受け取ったデータを DynamoDB や S3 への書き込みや Lambda Function の起動といったことが可能になってます。

しかし今回のリアルタイムログでは、受け取ったデータを Kinesis に流す構成を採用しています。

Kinesis & Lambda

IoT Rule Action で受け取ったデータは一度 Kinesis に入れ Lambda から DynamoDB に書き込みます。 これは、大量のデータを DynamoDB に直接書き込んでしまうと大量の WCU が必要になってしまうため、書き込み量を制御するためです。
 構成図を見ると分かる通り、最終的に DynamoDB にログを書き込みます。
IoT の Rule Action ではそのまま DynamoDB に書き込むことも可能ですが、今回は一度 Kinesis を挟んでから DynamoDB に書き込むようにしています。
これは、そのまま DynamoDB に書き込んでしまうと秒間の書き込みの最大が端末数に依存してしまうためです。
現在でも約5000台程度の端末から約15秒に1件のログが送られてきます。さらに端末数は今後増えていく見込みであるためシステム側で DynamoDB への書き込み量を制御するために Kinesis を挟み、一度ハンドリングしてから DynamoDB に書き込むようにしています。

DynamoDB

データは storeTV 端末から以下のような JSON が送られてきます。
thing_name とは端末を識別するためにそれぞれ割り当てられている固有の値です。

{
    "thing_name": #{thingName}, 
    “creative_id”: 1, 
    “creative_type”: 1,
    "published_at": "2021-06-01 10:00:00"
    “project”: “store_tv”   
}

これを thing_name を Hash Key とし、受け取ったものから上書き保存という形で DynamoDB に書き込んで行きます。

nametypeschemaexample
thing_nameStringHash Keything_001
creative_typeIntSort Ket1
creative_idInt1
published_atString2021-06-01 10:00:00
projectStringstore_tv

大量のログをさばくためにしたこと

この機能を実現しようとしたときに、まずはじめに困ったことは大量のログが常に送信され続ける、という点です。
そのため、この機能を実装する際に大量のログをさばくためにいくつか対策をしました。

実際どの程度のログが送信されてくるかというと、現在 storeTV が稼働している端末で、端末管理システムが導入できている端末は当時5000台程度になります。
また storeTV は主に15秒の動画をループ再生しているため、1分間に送信されうるログの量は 5000 x 4 で20000件のログが送信されてくることになります。

IoT Rule Action では受け取ったデータをそのまま DynamoDB に書き込むことも可能ですが、この量のログをそのまま書き込んでしまうと大量の WCU が必要になってしまい運用にかかるコストが莫大になります。
そのため、まずは直接 DynamoDB に書き込むのではなく、 Kinesis でバッファし Lambda で DynamoDB に書き込むログの量を制御できるようにしています。

さらに DynamoDB のテーブル構成を見ると thing_name を Hash Key としています。
そのため、このテーブル仕様では1つの端末が sort key 毎に1行のログしか持てないことになります。
これは今回のリアルタイムログでは、端末が「最後に動画を再生したのはいつか」が把握できれば良いという方針で設計を行ったからです。
こうすることで Kinesis に溜まったデータを Lambda で DynamoDB に書き込む際に、同じ thing_name のログは最新のログだけ DynamoDB に書き込むようにすることで書き込む量を削減し大量のログをさばけるようにしています。

また、今回のログは端末側がオフラインのときなどにログが送信できなかった場合、再送などの処理は行っていません。
そのため、もともと正確性はそれほど高くならないような設計になっており、広告動画の再生回数の集計などには使えないものになっていますが、集計のためのログはすでに別の方法で取得し集計しているため今回は必要ありませんでした。

DynamoDB のコスト最適化

今回のリアルタイムログでは、 Scheduled Action を用いて 8:00~22:00 の間だけ DynamoDB の WCU を必要な分確保しています。 これは storeTV は 8:00~22:00 の営業時間というのが設定されており、営業時間外の 22:01~翌日7:59まではアプリは動作しておらず、ログが来るのは 8:00~22:00 の間のみに限定されるからです。

ちなみに、当初は Auto Scaling のみで DynamoDB の WCU を調整するようにしていましたが、これだと 8:00 の段階で急に書き込みが増えるため Auto Scaling が間に合わず数時間にわたりログの遅延が発生する状態に陥っており、これを解消するためにも Scheduled Action を用いるようにしました。
また、オンデマンドでも試しましたがサービスの稼働時間が固定されている状況もあり、 Auto Scaling のほうがコスト的にメリットが大きかったため Auto Scaling を採用しています。

まとめ

今回リアルタイムログを実装し端末ごとに今なんの動画が再生されているのかが迅速に把握できるようになりました。
ちなみにログの遅延具合は約5~10秒程度、遅くても1分以内に再生した動画は確認できるようになりました。
ただし、まだ端末ごとに再生した動画が確認できるだけなのでこのリアルタイムログを活用した新たな機能や、障害対応時を想定した端末内のより詳しいログを欲しいときだけ取得する機能など、端末の状態をより詳しく把握するための仕組みづくりを検討しています。

また、私はアプリエンジニアでしたが、サービスに何が必要なのかを自分で考えそれが自分の得意分野でなかったですが興味のある分野であったため、今回設計から実装までを任せてもらい、実際に Go でプログラム書いたり Lambda のデプロイ環境の準備や DynamoDB のテーブル設計を行いました。
そんなメディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、ぜひ採用情報を御覧ください。

info.cookpad.com

料理動画サービスに強く興味がある方は以下のリンクから「料理動画サービス」のついた項目を御覧ください。

info.cookpad.com

データ分析 SQL とその実行結果を共有・検索できるアプリ Bdash Server を作りました

$
0
0

こんにちは。クックパッドでエンジニアをしている @morishinです。Bdash Serverというデータ分析 SQL を共有するアプリケーションを作って社内で使い始めたのでその紹介をします。

クックパッドのサービス開発は「仮説を立てる」→「作ってリリース」→「効果検証」の繰り返しで進んでいます。ここで言う効果検証というのは作ったサービスが狙い通りの使われ方をし、ユーザーに価値を提供できているかどうかの確認のことです。その手段は複数あり、実際に使っていただいたユーザーさんにインタビューをさせていただく場合もあればアプリケーションから送信されたアクセスログ等を分析することで評価する場合もあります。この記事では後者の定量分析を効率化するためのツールを作った話をします。

アプリの概要

まずはアプリの機能を紹介します。Bdash Serverは、Bdashという同僚 (@hokaccha) が作った SQL クライアントアプリと連携し、クエリと実行結果をウェブページとして共有することができるアプリケーションです。

Bdash クライアントアプリのついての詳細はこちらをご覧ください。

hokaccha.hatenablog.com

Bdash クライアントアプリを社内のデータウェアハウス (社内の巨大な分析用データベースで、クックパッドでは Amazon Redshift を使っている) に接続し SQL を書いて実行すると、このように実行結果がクライアントに表示されその結果のテーブルをグラフにすることもできます。

f:id:morishin127:20210610215433p:plain
Bdash クライアントアプリの画面

そして Bdash クライアントの共有機能から Bdash Server を選択すると次のようなウェブページが生成されブラウザで開かれます。共有されたクエリのページにはハッシュ値を含む個別の URL が振られ、簡単に社内共有することができます。

f:id:morishin127:20210609002638p:plain
Bdash Server の画面

Bdash Server にはクエリ共有機能に加えて次の目玉機能があります。

  • クエリに説明文が書ける🙌
  • ユーザー個別のクエリ一覧ページができる🙌
  • 他の人が書いたクエリが検索できる🙌

上のスクリーンショットでも SQL セクションの上に説明文が書かれていますね。ここに issue ページへのリンクを貼ったり分析に関する説明を書いたりすることができます。

ユーザーページではそのユーザーがこれまで共有したクエリが一覧できます。社内のデータ分析が得意なメンバーのページは分析 SQL 知見の宝庫であり様々なテクニックに出会うことができます。

ページ上部には検索窓があり、ここから共有されたクエリを検索することができます。検索スコープはクエリタイトル、説明文、SQL文となっており、「レシピ投稿数」や「DAU」といった日本語キーワードから「pv_logs」といったテーブル名、「partition by」といった SQL 関数のキーワードなどで過去の分析事例を見つけることができます。

リリース時の機能としては以上で、リリース後に有志によりクエリをお気に入りに登録する機能、クエリを手元で実行するためにクリップボードにコピーする機能などが追加されました。

ちなみに Bdash Server も Bdash も OSS です。Bdash Server はインフラを用意してデプロイする必要がありますが、Bdash クライアントアプリは単体でダウンロードして即お使いいただけるので是非ご活用ください。

github.comgithub.com

開発の動機

さてこのアプリを作った動機ですが、業務で日常的に書いている分析 SQL をもっと楽に書きたかったからです。SQL を書きながら「こんな数字絶対過去に誰かが出したことあるやん...」などと思うことが多く、できるならば過去に誰かが書いたやつをコピペしてちょっといじって完成させたいなと思っていました。また例えば「コンテンツ投稿数の移動平均」「機能利用のリテンションレート」「ある週の7日間のうちアプリを利用した日数の分布」などクエリに起こそうとするとウッと手が止まるような難しめの数字を出そうとした時に、誰かが出したやつがあれば参考にしたい〜〜と思っていました。

クックパッド社内で日常的に分析 SQL を書いているのは実はエンジニア職でない人も多数います。データ分析を専門で行う人が社内にいるわけではなく、サービス開発に携わるエンジニアやディレクター・デザイナーまでもが日々 SQL を書いて数値を睨んでサービスの方向性を考えています。SQL にまあまあ馴染みのあるエンジニアでさえ上述のようなことを思うのに、いわんやディレクター・デザイナーをやという感じです。余談ですが時々ディレクターの方がウィンドウ関数なんかも駆使した100行を超える SQL をバーンと書いているのを見かけてビビることがあります。

過去に人々が書いた分析 SQL が蓄積される場がありそこでキーワード検索ができれば毎回ゼロから分析 SQL を考えなくとも過去の事例を参考に効率的に分析をすることができるのではと考えました。実は Bdash クライアントには元々クエリと実行結果を GitHub (または GitHub Enterprise) の Gist で共有する機能がありとても便利に使っていましたが、クエリ単体を共有することはできるものの蓄積と検索は実現できていませんでした。

この想いを温め続けること約2年、今年の3月に社内で「Hackarade」というハッカソンイベントが開催され、この時間を使えば作れるのではと思い着手したのがきっかけとなりました。

開発の過程

ハッカソンイベント期間中に基礎的な実装をし、その後は業務の合間を縫って機能の拡充、社内デプロイ、OSS 化といった作業を進めていきました。

作りたい機能はたくさんあって構想は広がる一方ですが、ハッカソンということもあり時間は限られています。作り始める前に構想を複数のリリースフェーズに分け、最初に作り切る最小限の機能を定義しました。次の画像が実装を始める前に書いたメモで、ハッカソン中にフェーズ1まで作ることを目標にしていました。

f:id:morishin127:20210609003058p:plain
開発前のメモ

このような個人開発に限らず普段の業務でも仕様というものは膨らみがち。速くユーザーに価値を届けるためにも膨らんだ仕様を削ぎ落としたりリリースフェーズを分けてフィードバックを早く得るということは重要ですよね。

お客様の声

社内で利用され始め、毎日20件程度のクエリが共有されています。やっぱりみんな日常的に SQL 書いてるんですね。

f:id:morishin127:20210610220004p:plain
お客様の声

使用技術

Bdash Server の実装には Blitz.jsというフレームワークを使いました。Blitz.js は Next.jsPrismaを内包した Ruby on Rails 風の開発体験を提供するフルスタックフレームワークとなっています。Next.js と Prisma の機能を使うことに加え、rails generate 風なコード生成機能があったり "Zero-API"という思想で作られたデータベースクライアント実装があったりします。"Zero-API"は面白くて、サーバーサイドの実装でもクライアントサイドの実装でも同じように db.user.findUnique({ where: { id: userId }})といった実装でデータを取得できます。もちろんクライアントサイド(ブラウザ)から直接データベースサーバーが叩けるわけではなくて、ドキュメントによると

You may be wondering how that can work since it's importing server code into your component: At build time, the direct function import is swapped out with a network call. So the query function code is never included in your client code.

とのことでした。この辺りの思想は自分には結構合っていて Blitz.js の開発体験は良かったです。

クックパッドは Ruby on Rails をよく使っているし Rails 風フレームワークを使うなら Rails で良いのではという意見もありそう、かつ僕自身も Rails は好きなのですが、Rails の上でフロントエンドの JavaScript を書こうとするとどうしてもつらい。Webpacker が登場したけどそれでもかなりつらい。個人プロダクトや他所の会社のプロダクトでも幾度となく消耗してきました。消耗しながらフロントエンド実装がしやすいウェブフレームワークは無いものかと思っていた中、社内に Next.js 製のクックパッドのレシピページ実装が登場し Next.js の良さを知り、しかし Rails でいう ActiveRecord 的なもの (DB へのコネクションを管理してくれるやつ & OR マッパー)は必要やねんなと思っていたところ Prisma というのが流行っているのを知り、Next.js + Prisma で何か作ってみるかと思ってたら Blitz.js というのがあるのを知り、今回使ってみた次第でした。

Prisma も開発体験は良くてクエリ実行結果に型が付くし、スキーマ管理も Rails 風にできて使い慣れた感じでした。欲を言えば Rails 風ではなくて ridgepole (社内製のスキーマ管理ツール) になって欲しい。それで言うと ridgepole は Rails 標準になって欲しいと思っていて、Rails 開発をしている方には大変おすすめなので是非使ってみてください。詳しく調べてはいませんが、Primsa はまだ複数データベースサーバーへの接続や Read-write splitting はできなそうに見えました。Rails も複数 DB 接続が標準機能になったのは最近のことですが...。

UI の実装には React と Chakra UIを利用しました。Chakra UI については好みが分かれるところだと思いますが、JSX(TSX) を書きながらそのまま DOM の attribute としてスタイルを宣言していく書き方は高速に UI を構築することができ便利だと思いました。また用意されているコンポーネントも豊富で、簡単にシュッとした見た目に整えることができました。

こんな感じ: https://github.com/bdash-app/bdash-server/blob/267e243a9830f1cb0fff8526858e7feb68cae86b/app/pages/query/%5BbdashQueryIdHash%5D.tsx#L168-L276

Blitz.js もそれを構成する Next.js と Prisma もバリバリ開発されておりどんどん変わっていくので開発が長期に渡る大規模なプロダクトで採用するには不安がありますがこういった小さなアプリで試す分にはかなり開発体験が良く気に入りました。余談ですが Blitz.js は Next.js を fork する案が出ていてちょっと不穏な気配を感じています 😅

github.com

さいごに

お読みいただきありがとうございました。Bdash Server や Bdash クライアントアプリは OSS でありどなたでもご利用可能なので、よければ日々の分析業務にお役立てください。GitHub Issue 上でのご意見・ご要望や Pull Request も歓迎です。

最後にこの記事を読んでくださった分析好きのあなたにおすすめのコンテンツを紹介します。

クックパッドは自らの手でデータを分析しながらサービスを改善していけるエンジニアの仲間を募集しています!応募するほどではないけどちょっと興味があるので社員と話してみたいという方にはカジュアル面談(オンライン)なども実施していますのでお気軽にご連絡ください。雑に @morisin127に「記事見ました!話を聞いてみたいです」と DM をしていただくので大丈夫です🙆

👇️👇️👇️詳しい採用情報はこちら!👇️👇️👇️

info.cookpad.com

コード生成を用いたiOSアプリマルチモジュール化のための依存解決

$
0
0

こんにちは、モバイル基盤部の@giginetです。

iOS版のクックパッドアプリでは、2019年頃より、大規模なアプリを複数のモジュールに分割するマルチモジュールの導入を進めてきました。

今回はクックパッドアプリのマルチモジュール化の戦略について、主に依存関係の解決という点に焦点を当てて紹介します。

クックパッドアプリとマルチモジュールプロジェクト

iOS版のクックパッドアプリはコード量が多く、膨大なビルド時間が問題となっていました。また、同時に関わる開発者も多く、それぞれの機能間を疎結合にしたいという需要が大きくありました。

この問題を解決するために、2019年の初頭からiOSアプリのマルチモジュール化プロジェクト*1を開始しました。 以来、ここ2年で、モジュール分離を前提とした開発が大きく進みました。

現在では、アプリ全体のコードのうち、半分以上がモジュール分離され、アプリ全体が約25個のモジュールに分割されています。

これまでのマルチモジュールの取り組みは、2019年に開催されたCookpad Tech Conf 2019の講演「〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」で紹介しています。

以下は上記の講演動画の書き起こし記事です。この記事を読む前にご覧いただけると、より理解しやすくなると思います。

モジュール分離には、分割の方法や粒度、移行プロセスなど、様々なトピックがありますが、この記事ではとりわけ、大きな障害となるモジュール間の相互の依存関係解決のための仕組みについてお伝えします。

クックパッドアプリのモジュール構成

まず始めに、前提となるアプリケーション全体の構成について説明します。

クックパッドアプリのモジュール構成は概ね以下のような図で表すことができます。

f:id:gigi-net:20210615192428j:plain
クックパッドアプリのモジュール構成

下の層はCoreモジュールと呼ばれています。図中でCookpadCoreやCookpadComponentと呼ばれているモジュールです。 Coreモジュールは抽象化のためのインターフェイスや、アプリ内で共通して使うUIコンポーネントを提供しています。

その上の層はFeature Moduleと呼ばれる層です。検索、レシピ投稿、つくれぽ、買い物機能など、機能単位をひとまとまりとするモジュールに分離されています。 図中にある黄色い四角は、シーンと呼ばれる単位です。アプリ内の1画面に相当します。 各シーンは、VIPERアーキテクチャにより実装されています。Feature Moduleは、ドメイン層を共有する複数の(数個の)シーンの集まりということができます。

f:id:gigi-net:20210615192457p:plain
Feature ModuleにおけるVIPERアプリケーションの構成

最上位のCookpadはアプリケーションです。 Xcodeプロジェクトにおける、アプリケーションターゲットに相当します。アプリケーションは全てのモジュールの実装を知ることができます。

単機能ビルドを重視したモジュール構成

モジュール分離の議論でしばしば話題に挙がるのが、アプリケーション全体をどのように分割し、再構成するかという点です。

我々のモジュール分割の大目的はビルド時間の削減にありました。

そのため、アプリ全体をビルドせずとも開発できるように、モジュール1つを単体起動できる構造を重視しています。

このような構成になっていることで、Feature Module単体のみを取り出し、個別にビルドすることが可能となりました。

この構成を生かした結果が、Sandboxアプリという動作確認用のミニアプリです。Feature Aの開発を行う場合は、CoreモジュールとFeature Aのビルドのみで動作確認ができるようになりました。

f:id:gigi-net:20210615192522p:plain
Sandboxアプリの構成

これにより、単機能のみのビルドと実行を行うことができるようになり、開発効率が大きく改善されています。

詳しくは以下の記事で紹介しています。併せてご覧ください。

マルチモジュール化における依存関係解決の必要性

巨大なアプリを複数のモジュールに分割する際に問題になるのは、依存関係の解決です。

f:id:gigi-net:20210615192553p:plain
Feature Module同士の参照は制限されている

我々のアーキテクチャでは、同じレイヤー上のモジュールが、お互いに参照を持つことを制限しています。もし両方向に依存してしまうと即座に循環参照が発生してしまうからです。その代わり、循環参照を避けるための依存抽象化の仕組みを導入しています。

複数のFeature Moduleが互いに連携する例を考えてみましょう。 例えばBモジュールで実装されているシーンを、Aモジュールで表示するというのが代表的な例です。

これを実現するために、どのような抽象化を実現しているのでしょう。

Environment 〜モジュール外への依存を簡単に注入できるためにするコンテナ〜

各シーンはEnvironmentという、依存関係を取り出すためのDIコンテナにアクセスすることができます。

Environmentは、外部とのI/Oや外部ライブラリ、モジュール間の連携といった、別のモジュールとの連携を持つ必要がある実装を抽象化する役目を果たしています。 各シーンは、他のモジュールの実装を用いたいときや、ネットワーク通信など、外部との入出力が発生するとき、Environmentを用いて依存にアクセスします。

アプリケーションターゲットは、Environmentに具体的な実装を注入する役目を持ちます。

f:id:gigi-net:20210615192615p:plain
Environmentを使った依存関係解決の仕組み

Environmentは動作環境によって差し替えることができます。例えばアプリ起動時やテスト開始時など、環境の起動時に生成して全てのシーンに渡されます。 これにより、フルビルドしたアプリケーション、Sandboxアプリ、ユニットテスト、UIテストなど、様々な環境で異なるEnvironmentを用意することで、依存の注入を簡略化しています。

ResolverとDescriptor 〜依存を抽象化して取り出すための仕組み〜

先に述べたとおり、それぞれのFeature Moduleはお互いを知ることができないため、他のモジュールの実装を取り出すためには、Environmentを経由する必要があります。 この仕組みをResolverと呼んでいます。

同時に、Resolverに特定の実装を示すためのマーカーをDescriptorと呼んでいます。このDescriptorはCoreモジュールに存在します。 そのため、あるモジュールは他のモジュールにある実装を知ることができませんが、その実装を示すDescriptorはどこからでも知ることができます。

他のモジュール上にある実装を取り出したい場合、Descriptorを用いて、Environmentに実装の取得を要求します。 アプリケーションターゲットは全てのモジュール上の実装を知っていることを利用し、アプリケーションターゲットは具体的な実装をEnvironmentに注入し、protocolで抽象化して返します。 その結果、実装を取り出したいモジュールからは、protocolを用いて抽象化された実装を取り出すことができます。

開発しやすいResolverのための課題

このResolverの仕組みを、シンプルに、ミス無く維持するためには何が必要でしょうか。大きく、以下のような点が議論に挙がりました。

型安全性

Resolverから取得した値を任意の型として型安全に使いたい

コンパイル時の網羅性の検証

あるDescriptorに対応したResolverの実装漏れを自動的に検出できるようにしたい

利用の簡便さ

開発時のグルーコードの記述量を減らしたい。また、実装に迷わないようにしたい

コード生成を用いた型安全なResolver

これらを満たすために、コード生成を用いてResolverをある程度自動生成する仕組みを実現しました。

ここからは、その仕組みを使った、モジュール間依存解決の例を見ていきましょう。

Coreモジュール

まず、開発者はDescriptor構造体をCoreモジュールに定義します。

これは、レシピIDを元に、該当のレシピ詳細シーン(RecipeDetails)のUIViewControllerを取り出すためのDescriptorです。

extensionViewDescriptor {
    publicstructRecipeDetailsDescriptor:TypedDescriptor {
        publictypealiasOutput= UIViewController
        publicvarrecipeID:Int64publicinit(recipeID:Int64) {
            self.recipeID = recipeID
        }
    }
}

Descriptor構造体は、依存を取り出すために必要なパラメータと、取りだしたい依存の型(Output)を持ちます。

コードの自動生成

ビルドシステムは、ビルド時に、Coreモジュールに含まれるDescriptorを探索し、それらを使うResolverを実装するように促すprotocol、ConcreteViewResolverを自動生成します。 ConcreteViewResolverは、定義されている全てのDescriptorに関するresolveメソッドを持つprotocolです。

// 自動生成されるpublicprotocolConcreteViewResolver {
    funcresolveConcrete(_ descriptor:ViewDescriptor.RecipeDetailsDescriptor) ->ViewDescriptor.RecipeDetailsDescriptor.Output
}

コード生成にはSourceryという、Swiftのコード生成を行うユーティリティを利用しています。

この仕組みにより、存在する全てのDescriptorに対してのResolverの実装が、コンパイル時に強制されるため、Resolverの実装漏れを防ぐことができます。

アプリケーションターゲット(Cookpad)

次に、開発者はアプリケーションターゲットにおいて、ConcreteViewResolverに適合したEnvironmentを実装します。

extensionCookpadEnvironment:ConcreteViewResolver {
    funcresolveConcrete(_ descriptor:ViewDescriptor.RecipeDetailsDescriptor) ->ViewDescriptor.RecipeDetailsDescriptor.Output {
        RecipeDetailsViewBuilder.build(with:descriptor, environment:self) // レシピ詳細シーンを生成する
    }
}
利用したいモジュール

最後に利用したいモジュールからResolver経由で実装を取り出します。

letdestinationViewController:UIViewController= environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID:42))
currentViewController.present(destinationViewController, animated:true, completion:nil)

このとき、取り出した型はDescriptor.Outputに指定した型にダウンキャストされます。これにより、利用者は型安全に他のモジュールの実装を取り出すことができます。

型安全に実装を取り出すための仕組み

ところで、上記のようなダウンキャストはどのように動作するのでしょうか。これも自動生成されたextensionで実現されています。

ConcreteViewResolver protocolを生成するタイミングで、Environmentの方には、任意のDescriptorをダウンキャストするresolveメソッドが自動生成されます。

このメソッドは、先ほど実装したresolveConcreteをそれぞれのDescriptorごとに呼び出し、Descriptor.Outputにダウンキャストします。

アプリケーションターゲット(Cookpad)
// このコードも自動生成されるextensionCookpadEnvironment {
    funcresolve<Descriptor: TypedDescriptor>(_ descriptor:Descriptor) ->Descriptor.Output {
        switch descriptor {
        caseletrecipeDetailsDescriptoras ViewDescriptor.RecipeDetailsDescriptor:return resolveConcrete(recipeDetailsDescriptor) as! Descriptor.Output
        caseletanotherDescriptoras ViewDescriptor.AnotherDescriptor:return resolveConcrete(anotherDescriptor) as! Descriptor.Output
        // 以下省略...default:// 全てのケースをコード生成で網羅しているので、ここには到達し得ないはず
            fatalError("Unknown descriptor!")
        }
    }
}

この巨大なswitch文をコード生成することで、存在する全てのDescriptorがケースとして網羅されます。 これにより、コンパイル時に、全てのDescriptorに対するResolverの実装が保証されるのです。

型安全なインターフェイスの取得

この際、実際にResolverから返されるViewControllerは RecipeDetailsViewControllerですが、これを取得した他のモジュールからは単なるUIViewControllerとして見えます。

これは、RecipeDetailsViewControllerを含むモジュール内でしか、この具体的な型を知ることはできないためです。

各ViewControllerに機能を持たせたい場合、この仕組みにより、任意のインターフェイスを公開し、取得することもできます。

Coreモジュール(CookpadCore)

Coreモジュール内に、ViewControllerのうち、公開したいインターフェイスのみを含むprotocolを追加しましょう。

publicprotocolRecipeDetailsViewControllerProtocol {
    varrecipeID:Int64 { get }
}

このとき、具体的な実装はこのViewControllerが実装されているFeature Module内に存在します。Coreモジュールは実装を持っていません。

その後、先ほどのようにDescriptorを実装します。このとき、Outputの型としてRecipeDetailsViewControllerProtocolを返すように指定しています。

extensionViewDescriptor {
    publicstructRecipeDetailsDescriptor:TypedDescriptor {
        publictypealiasOutput= UIViewController & RecipeDetailsViewControllerProtocol

        // ...
}
利用したいモジュール

同様に利用したいモジュールからはresolverを用いて実装にアクセスできます。このとき、返却される型は Descriptor.Outputに指定した型として扱われます。

letdestinationViewController:UIViewController& RecipeDetailsViewControllerProtocol = environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID:42))
destinationViewController.recipeID // 42

これにより、他のモジュールはResolver経由で実装を任意のprotocolに適合した形として取り出すことができました。


このように、Resolverの実装にコード生成を用いることで、型安全性を保ちながら、コンパイル時に網羅性を担保し、実装ミスが起こらないようにすることができました。

また、開発者はDescriptorと、それに対応するResolverを用意するだけでよいので、Resolverの実装コストを減らすこともできました。

ドメイン層へのDescriptorの拡張

今回の例では、簡単のため、画面遷移にResolverを使う例をご紹介しました。

我々のアプリでは、DataStoreやUseCaseといった、ドメイン層の依存解決のためにもResolverを解放しています。

例えばAモジュールで実装されているUseCaseをBモジュール内の実装で扱いたいという例です。 Viewの例と同様に、公開したいインターフェイスのみをprotocolとして抽象化し、DataStoreDescriptorとして同様の仕組みを実現しています。

この仕組みにより、モジュール間での任意の依存解決を簡単に実現できるようになりました。

さらにマルチモジュールについて知りたい方へ

今回の記事では一部しか紹介することができませんでしたが、いくつかの記事やイベントで、クックパッドアプリのマルチモジュール戦略についてご紹介しています。

冒頭に紹介した「Cookpad TechConf 2019 〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」では、今回紹介したマルチモジュール戦略の全体像を説明しています。

先日、5/26には、「Cookpad Lounge #3 クックパッド iOS アプリを爆速で開発できるようにする話」というイベントで、座談会形式でマルチモジュールの話をしました。 視聴者の方から多くの質問を頂き、ありがとうございました。アーカイブが以下で公開されています。

来る6/16 19:30より、メルペイさんが主催の「iOS Tech Talk 〜 Multi module 戦略座談会 〜」というイベントで、各社のマルチモジュール戦略について座談会を行います。 この中でもクックパッドアプリのマルチモジュール戦略についてご紹介するつもりです。ご興味のある方はぜひ遊びに来てください。

まとめ

今回は、クックパッドアプリのマルチモジュール化のうち、特に依存関係解決の仕組みにフォーカスしてご紹介しました。

マルチモジュールは、iOS界隈でも関心度の高いトピックなため、今後も発信の機会を作っていきます。

また、ご質問がある場合は@giginetまでお気軽にお寄せください😄

クックパッドでは、大規模なアプリのリアーキテクチャを行いたいエンジニアを募集しています。

*1:霞が関プロジェクトと呼んでいます


マイクロマネジメントは悪か?よりよい組織をつくるためのマネジメント形態についての考察

$
0
0

レシピ事業サービス基盤部で部長をやっています、新井(@SpicyCoffee66)です。引越しを機に MtG のカードをほとんど売ったはずなのに、そのときは存在しなかったポケモンカードのデッキが手元にあります。なぜ?

私は 2017 卒のエンジニアとしてクックパッドに入社し、様々な業務を経験した後に 2020 年の 8 月から部長となりました*1。最近はコードを書いていないので Techlife の執筆内容に迷ったのですが、今自分の中にある「優れた組織づくりについての考え方」をまとめてみることとしました。部長になる前にも、グループ長として小規模なチームマネジメントの経験があるとはいえ、それを含めても2年弱のマネージャー経験しか持っていないので、これが絶対の正解というわけではなく一つの考えとして読んでいただけると幸いです。

組織の存在理由

優れた組織づくりについて考えるために、まずは組織の存在理由について考えます。組織の目的については、書籍の数だけ種類が存在するほど多様な記述がありますが、この記事ではシンプルに「ある目標を達成すること」を組織の目的として考えます。多くの会社ではこの目標がミッションになるでしょう。クックパッドで言うと「毎日の料理を楽しみにする」ことが組織の目的となります*2

一般に、目標達成に重要な要素は、目標に向かう主体の "速度"と "方向"になります。したがって、目標に向かう速度と方向について、主体が「個人」であるよりも「組織」である方が優位であることが、組織をつくる理由の根幹となり、また、この優位性の大きい方が優れた組織ということになります。

f:id:spicycoffee:20210726151631p:plain
個人と組織の比較

速度についてはわかりやすいでしょう。人が集まる方がマンパワーが増えるため出力が増加します。また、これまでの組織に存在しない専門性を持ったメンバーが加入することで、組織全体のキャパシティが広がっていきます。

方向については、人数が多くなるほどブレる・間違いが多くなるといった印象を抱く方もいるかもしれません。「船頭多くして船山に上る」という言葉もあります。しかし、人間の認知能力には限界があるため、個人の視野では方向性を決定するための情報や、存在する課題を拾いきれないケースが多くあります。このことから、少なくとも成熟した組織においては、方向性の決定についても個人に対して優位性があると考えられます。

ここまでに、組織で目標に向かうことについて、個人でそれをおこなう際と比較した優位性を確認してきました。この考え方を延長して、優れた組織を

  1. 方向性の決定においてより精度が高く
  2. 決定した方向性に沿ってより速い速度で進むことができる

ものであると定義します。

優れた組織をつくるためのマネジメントについての考察

ここからは、前節の定義に沿って優れた組織をつくるためのマネジメントについて考察していきます。今回は、マネジメント形態の分類として一般に用いられる「マイクロ/マクロマネジメント」という概念をベースに考えを深め、最終的に優れた組織をつくるためのマネジメントについての考察をまとめます。

マネジメント形態の分類

「マイクロ/マクロマネジメント」という形態は、白黒二値にハッキリ分類できるものではなく、各組織でのやり方がグラデーション的に分布している前提のもと、それぞれの特徴を考えます。

マイクロマネジメント

マイクロマネジメントでは、マネージャーがメンバーに対して課題を細かくブレークダウンして伝達し、より詳細・具体の部分にまで指示を出します。そのため、マネージャーからメンバーへのコミュニケーションは作業内容の指示が中心となります。その特性上、施策の方向性がマネージャーの意図からブレることは少なくなりますし、トップダウンの施策決定が多いため、意思決定が速くなる傾向も見られます。

一方で、マネージャーの認知能力が組織の認知能力の限界値となるため、特に方向性決定の精度において、組織であることのメリットを活かしづらい形態であると言えます。課題の解釈や解決方法のアプローチに幅をもたせづらく、マネージャー自身がその設定をミスった際に、組織全体でリカバリーするような自浄作用は働きません。速度についても、詳細にまで細かく指示を出すような極端な例を考えると、マネージャーの想定以上の生産性が発揮されるケースは少なくなります。

また、より具体にまで目を届かせる必要がある以上、マネージャーの目線がより内向き・近視眼的になることも、方向性の決定について精度を落とす要因となります。組織の外や未来のことに目を向ける余裕を持てないと、限られた情報で部分最適に近い選択肢を取ってしまう可能性が高くなるからです。

f:id:spicycoffee:20210726152936p:plain
マイクロマネジメントにおけるマネージャーの意識

マクロマネジメント

対してマクロマネジメントでは、マネージャーはメンバーに現状の課題や目的を伝達するにとどめ、解決へのアプローチについてはメンバーに裁量を持たせます。したがって、マネージャーからメンバーへのコミュニケーションは、課題の共有やより俯瞰した視点での整合性の確認が中心となります。

課題のブレークダウンをなるべく抑えてそのままの粒度でメンバーに伝達することで、マネージャーにより近い粒度で課題を捉えるメンバーが増え、マネージャーの認知能力が組織のボトルネックになりづらくなります。具体的には、マネージャーの課題設定にミスがあった際にメンバーからのフィードバックで再設定が可能になるケースが増えたり、メンバーが独自の観点で課題を解釈することにより、マネージャーが思いつかないような解決策を発案することのできる可能性が高くなります。

より広い範囲の具体についてメンバーに移譲できているため、マネージャーは組織外のことや未来のことに目を向ける余裕ができ、全体最適となる選択肢を取るために必要な情報を集めることができるようになります。

一方で、課題や目的に向き合う視点が増える分、それをまとめ上げるはたらきが必要になります。この整備・工夫を怠ると、意思決定を形成するのに長く時間がかかったり、その方向性がブレる、あるいは全ての意見の間を取った中途半端なものになってしまうといった危険性があります。

f:id:spicycoffee:20210726153003p:plain
マクロマネジメントにおけるマネージャーの意識

マネジメント形態の比較

先ほどまでに述べてきた各マネジメント形態の特徴を以下の表(図)にまとめます。

f:id:spicycoffee:20210726152335p:plain
マネジメント形態の比較

対になる性質は様々ありますが、総じてマイクロマネジメントは、組織の行動における不確実性の低さと意思決定の速度に、マクロマネジメントは、"方向"についての意思決定の精度と実行部分の "速度"に優位性があると言えそうです。ここで一点注意すべきなのは、マイクロマネジメントにおける不確実性の低さはあくまで組織の行動に対してのもの(つまりは内情が把握しやすいということ)であり、目的に対しての不確実性を低減している(つまり正しい方向に進んでいる)ことを保証するものではないということです。同様に、意思決定の速度は組織が進む速度とイコールではなく、間違ったアプローチをとった場合、実行全体で見たときの速度は下がる可能性もあります。

これらのことを考えると、冒頭に定義した優れた組織を実現するためには、マクロマネジメントを採用する方が適していると考えられます。

マクロマネジメントがうまく機能するための条件

前節の比較により、優れた組織の実現のためには、可能であればマクロマネジメントを採用する方が適しているとの考えを得ました。しかし、マクロマネジメントがうまく機能するためには、その特徴から

  • メンバーが組織の目標や状況について正しく理解している
    • メンバーが理解できるように伝達する能力をマネージャーが有しているということでもあります
  • メンバーの実行能力がマネージャーのそれよりも優れている

という二つの条件を満たす必要があります。

前者については、主に組織の進む "方向"に関係する条件です。マクロマネジメントの特徴として、マネージャーの認知能力が組織のボトルネックになりづらくなることを挙げましたが、これは裏を返すと、メンバーがマネージャーの視野の外にある課題や解決策を探索・発見できるという前提に立っています。そのためには、メンバーがマネージャーと同じ解像度で組織の持っている目標や置かれている状況について理解しておく必要があります。一般的には "視座"と表現されることの多い素養ですが、これらの情報を主に握っているのはマネージャーである以上、マネージャーにはこれを自身の意図と合わせて伝達し続けることでメンバーに理解してもらう責任があります。一概に「視座の高さはメンバーの能力である」と捉えるのは間違いで、逆に「マネージャーが情報を下ろしてこないのが悪い」と主張し続けるのも建設的ではなく、双方の歩み寄りによって満たすべき条件であると考えます*3*4

後者については、主に組織の進む "速度"に関する条件です。マネージャーはマネージャーとして、メンバーはメンバーとしてそれぞれ専門性を持って働いている以上、実行の詳細についてはメンバーに移譲した方が速度が出るのが一般的かとは思います。しかし、中には「プレイヤーから転向したばかりのマネージャー x ジュニアメンバー」といった組み合わせ等、詳細についてもマネージャーから細かく指示を出した方が速度が出るケースもあります。そういった場合、少なくとも短期的にはマイクロマネジメントの方が速度が出ることになるため、メンバーの実行能力が十分に高いということも、マクロマネジメントが優位性を発揮するための条件になります。

より優れた組織をつくるためのマネジメント形態の移行

前節の比較から、より優れた組織を作るためにはマクロマネジメントを採用する方がよいが、それが有効に働くためには組織の成熟度に関する条件が存在することがわかりました。「ジュニアメンバーを中心に構成されている組織において、抽象的な指示しか出さない」「成熟したメンバーが揃っている組織において、詳細についても指示を出し続ける」といった例を考えてみると、それぞれ組織活動が上手くいかないことが想像できるため、直感的にも正しい理解であると言えるでしょう。

この考えを押し進めることで、マイクロマネジメントとマクロマネジメントをマネジメントのステージとして捉え、なるべく マイクロマネジメントからマクロマネジメントへ移行していくことで、より優れた組織をつくることができるという考えに至ることができます。組織やメンバーの成熟度によってマネジメントの形態を使い分けながら、なるべくマクロなマネジメントに移行していくことで、組織の生産性を上げることができるという考え方です。現実的にマイクロマネジメントを採用する方が生産性が高くなる状況は存在しますが、それを「組織が成熟していない」状態だと捉える考え方であるとも言えるでしょう*5

f:id:spicycoffee:20210726152420p:plain
マネジメント形態のステージ

マネジメント形態の移行を実現するために必要なことについての考察

前節に提示したマネジメント形態の移行を実現するために、マネージャーとメンバーそれぞれに求められることを考え、記事を締め括ります。

マネージャーに求められること

マネジメント形態の選択と適した速度での移行

組織の舵取りについての責任がマネージャーにある以上、マネジメント形態の選択についても主にはマネージャーの意志のもとおこなわれることになります。組織とメンバーの成熟度を考えながら、どの粒度まで指示を出すべきなのか、どの粒度までの権限移譲が可能なのかを考え、メンバーとコミュニケーションを取る。その過程で少しずつ権限の移譲範囲を広げていき、よりマクロなマネジメント形態への移行を実現することが求められます。文字にするのは簡単ですが実行は非常に難しく、私も「移譲したつもりで詳細の把握を怠っていたら手戻りにつながった」など、これまで何度も失敗してきました。マネージャーに求められる能力は多様なものがありますが、この判断精度を上げることは、明確にマネージャーとしての成長につながると思っています。

メンバーの成長支援

"マネジメント形態の比較"節において、マクロマネジメントがより有効性を発揮するための条件として、

  • メンバーが組織の目標や状況について正しく理解している
  • メンバーの実行能力がマネージャーのそれよりも優れている

の二点をあげました。つまり、マネジメント形態の移行を実現するためには、マネージャーとしてこの二点にコミットする必要があるということになります。メンバーが成長するとうれしいといったような心理的な要因をあえて除き、組織論の観点からドライに考えたとしても、メンバーの成長を支援する理由がマネージャーには存在するのです。

前者については、組織の目標やその意義、置かれている状況について繰り返し伝達する必要があります。組織の定例、普段の雑談、メンバーの提案した施策 issue へのコメント等、折に触れて伝え続けることが重要です。特にメンバーの提案をリジェクトする場合、その原因となるマネージャーとメンバーの観点や持っている情報の差について丁寧に説明することで、お互いの思考を同期する機会とすることが可能です。

後者については、メンバーの成長になるような挑戦の機会を業務の中で設計したり、自己研鑽のための投資を推奨することが効果的になります。

どちらについても、実行におけるミスや足元の速度低下についてある程度のリスクを飲む必要はあるでしょう。メンバーの成長支援が中長期的な投資であることをしっかりと認識し、提供する挑戦機会やスケジュールのバッファなどを通して、リスクコントロールをすることもマネージャーの役割であると考えます。

メンバーに求められること

自身の成長

マネジメント形態の移行についてメンバーに求められることは、何よりも自身の成長になります。自身の成長が組織の成熟につながることをしっかりと認識し、能力を高めていくこと。もう一歩踏み込んだ話をすると、組織の成熟につながるような成長を目指すことが求められます*6。その成長についても、速度を上げるための実現能力の成長はもちろんのこと、方向性の精度を上げるための視野・視座についても、より広く、より高いものにしていくことが望ましいと考えられます。マネージャーから伝達された課題や状況といった情報を一旦咀嚼し、理解しきれなかったところは積極的に議論を持ちかけるなど、メンバーからの歩み寄りも重要になります。特に自分の提案がリジェクトされたタイミングにおいては「マネージャーの理解と自分の理解との間に大きな差がある」ことがほぼ確定するので、その差について積極的に議論し、吸収することが求められます。

最後に

この記事では、最初に組織の存在理由と優れた組織について定義し、それを実現するための方法を、組織の観点からマネジメント形態と、個人の観点からメンバー・マネージャーにそれぞれ求められることの両方について考えました。私自身は、組織を良くするのもあくまでより大きな価値をより早く提供するためというスタンスを持っていますが、同時に健全なプロダクトは健全な組織からしか生まれないという信念も持っています。

冒頭にも述べたように、この記事で述べたことが正解であるというつもりはないので、気になること等がありましたら是非 Twitter 等でコメントをいただけるとうれしいです。また、Meety に以下のカジュアル面談を掲載していますので「直接聞いてみたいことがある」「クックパッドに興味がある」といった方は気軽にご応募ください。 

meety.net

クックパッドでは、チーム開発で大きな価値を世に届けたい開発者を絶賛大募集中です。転職を考えている方は以下の採用サイトから、そこまでではないがクックパッドに興味が出てきた方は、上記のカジュアル面談から是非ご連絡いただけますと幸いです。 

info.cookpad.com

*1:過去の仕事から生まれた記事はこちら → spicycoffee66 の検索結果 - クックパッド開発者ブログ

*2:この辺りの組織の意義や構造に関する話は『すぐれた組織の意思決定』という書籍も参考になるかと思います

*3:"視座"については 専門職と視座. こんにちは。ミクシィでスポーツやライブエンタメ関連の技術部長を担当している石井で… | by Kunzo Ishii | mixi developers | Mediumも参考になります

*4:視座は能力だけの問題じゃないよというような話は オープンでフラットな組織が突然「閉鎖的」と言われるとき|柴田史郎|noteにも

*5:組織形態を「ステージ」として捉えるという考え方は『ティール組織』に出てくる考え方に近い発想かもしれません

*6:ここでは「個人の市場価値を高める」という目的については述べていません

大規模配信に耐える広告新商品「材料ジャック」の設計と開発

$
0
0

こんにちは! メディアプロダクト開発部の名渡山 ( @pndcat ) です。

業務では広告システム全般の新規開発・保守・運用を担当しています。 本稿では、クックパッドが企業向けに販売している広告商品の開発について紹介します。

クックパッドの広告プラットフォーム

クックパッドの広告は、ネットワーク広告と、自社でシステムを開発し、企業が枠を一定期間買い取り掲載をする 純広告があります。 過去に Header Bidding 導入によるネットワーク広告改善の開発事情Prebid.js 導入による Header Bidding 改善の舞台裏などでネットワーク広告に関する投稿があったのですが、今回はクックパッド純広告について紹介します。

クックパッド純広告の商品は多岐にわたります。 企業の商品紹介ページに遷移する通常のバナー広告はもちろん、商品を使ったレシピコンテストなど、クックパッドならではのメニューを揃えています。 中でも人気のある商品が「カテゴリジャックバナー」です。 これはクックパッドで特定のキーワードが検索されたとき、ページ内の広告すべてを1社独占 (ジャック) で表示するものです。

f:id:marin72_com:20210730140635p:plain:w170

材料ジャックとは

2019年に、純広告の商品として「材料ジャック」を開発しました。 材料ジャックは、ターゲティングしたい材料が使われているレシピページに広告をジャック配信する商品です。 例えば、お酢の広告を出したい場合でも、「お酢」というキーワードで検索するユーザーは少ないので、先述のカテゴリジャックバナーは不向きです。 しかし、検索キーワードではなく、レシピの材料に連動する材料ジャックならば、「お酢」が使われる様々な種類のレシピで広告を出すことができます。 調味料など検索頻度が少ないキーワードに対してターゲティングをしたいニーズに応えた商品が材料ジャックです。

f:id:marin72_com:20210730140807p:plain:w170

基本的な入稿と配信について

材料ジャックの設計に踏み込む前に、クックパッドの広告配信システムの概観について説明します。 ユーザに広告を表示するまでに必要なものは、広告の入稿と、広告の配信の2つがあります。

入稿

クックパッドではバナー広告やカテゴリジャックバナーの配信を行うために、入稿担当者が商品名や画像、リンク先、ターゲティング情報などを入稿します。 入稿用のアプリがこの情報をクライアント (Webブラウザなど) 用の JSON に変換し、配信 DB に保存します。

f:id:marin72_com:20210730001506p:plain:w500

配信

配信サーバーはクライアント (Web/Android/iPhone) から HTTP リクエストを受け、検索キーワードなどのターゲティングを考慮しながら広告を抽選します。 抽選された広告の JSON をクライアントに返し、クライアントが JSON からビューを組み立て、広告を画面に表示する、という仕組みになっています。

f:id:marin72_com:20210730001726p:plain:w300

材料ジャックの課題

冒頭でも述べましたが、材料ジャックは、レシピページの材料欄にターゲティングしたい材料があるときに広告を表示する商品です。 言葉にすると簡単そうですが、実際には

  • 既存の広告入稿・配信の仕組みを変更しない
  • 材料名の表記ゆれに対応する
  • 入稿者の作業内容を大幅に増やさないようにする
  • 既存の広告のレイテンシを保ったまま、材料ジャックも配信する
  • 材料ジャックの障害が起きたときに、通常の広告配信には影響が出ないようにすぐに切り戻しができる

といった大きな課題がいくつもありました。 また、今回は設計から実装まで1ヶ月の短期スケジュールでの爆速開発が求められたため、手戻りが起きないようにきちんと設計をする必要がありました。 これらの課題をどうやって解決したのかを紹介します。

入稿実装

材料名の表記ゆれへの対応

既存のカテゴリジャックでは「ジャックしたいキーワードが、検索キーワードに含まれている時広告を表示する」というルールベースの広告です。 例えば、きんぴらに対するカテゴリジャックを行う際は「きんぴら」「キンピラ」「金平」のキーワードを登録します。

材料ジャックは、レシピに書いてある材料を元に広告を表示するので、カテゴリジャックと比べてはるかに多様な揺れに対応できる必要がありました。 例えば、塩の場合は、「しお」や、漢字の「塩」や、別表記の「食塩」、記号が含まれている「★塩」など、250種類以上のパターンが存在します。 カテゴリジャックで用いられたようなルールベースの方法を用いると、さまざまなパターンの材料名で登録する必要があり、非常に手間が大きく困難な作業になります。

表記ゆれの対応をするために最初に思い浮かんだことは、材料名の中から記号を取り除くデータクレンジングを行い、形態素解析ツールである Mecab を使う手法でした。 しかし、機械学習グループに相談したところ、機械学習グループが開発した材料名正規化テーブルを使って名寄せすることを提案してもらいました。

材料名正規化テーブル

材料名正規化テーブルでは、正規化後の材料名、ユーザが入力する材料名、正規化後の材料名に対して一意に定まるID (concept_id) の3つのカラムがあります。 このテーブルを使うことで、レシピの材料名から正規化後の材料名と concept_id を取得することができます。 正規化の精度*1は70%〜ですが、調味料はほぼ100%になります。 よく使われる材料名はたくさんのパターンの材料名が登録されており、例えば塩に対応する材料名は251行存在します。

正規化後の材料名 ユーザ入力する材料名 concept_id
100
しお 100
食塩 100
★塩 100
材料名正規化テーブルを使った材料ジャック入稿

入稿時に材料名の表記ゆれパターンを網羅させるのは現実的ではないので、材料名正規化テーブルを採用しました。 材料名正規化テーブルを使うことで入構の手間を減らし、材料のカバー率も上げることができました。

広告 登録するジャック対象 ターゲティングするデータ例
カテゴリジャック 検索キーワード 塩(完全一致でカテゴリジャックが発動)
材料ジャック 材料の concept_id 100 (100に対応する「塩」「しお」など多くの表現で材料ジャックが発動)

最終的な材料ジャックの入稿実装

入稿の全体図はこのようになりました。 配信 DB には、広告の JSON を書き込みます。 配信キャッシュストアには、材料名をキー、concept_id を値とした材料名正規化テーブル(2万件)をまるごと書き込みます。 というのも、材料名正規化テーブルは Redshift 上にありオンラインでクエリをすることができないためです。 また、詳細は後述しますが、広告の抽選を高速化するためという狙いもありました。

f:id:marin72_com:20210730001749p:plain:w500

配信実装

先述したとおり、ターゲティング条件として登録されているのは材料名そのものではなく、材料の concept_id です。 広告の配信サーバーにおいては、いま表示しているレシピに含まれている材料名が材料ジャックを発動させるかを判定するために、広告を抽選する前に材料名を concept_id に変換する必要があります。 そこで、配信サーバーは先ほど配信用キャッシュストアに入れておいた材料名正規化テーブルを使って材料名から concept_id を引きます。こうすることで、DB を介することなく高速に材料名と concept_id の変換を行えます。 concept_id を求めたあとは、通常の広告の配信と同じフローになります。

f:id:marin72_com:20210730002011p:plain:w400

さらにキャッシュストアを活用した実装

材料名から concept_id をキャッシュストアから引くこと以外にも、

  • recipe_id から concept_ids (concept_id の配列 = 材料名の配列) を引く
  • recipe_id を key に、concept_ids を値にキャッシュする

処理を加えました。 この2つの工程を追加することで、さらにキャッシュを活用し高速に concept_id を求めることができました。

recipe_id を key に、concept_ids を値に持つようなテーブルとは、以下のような表です。

key (recipe_id) value (concept_ids) 含まれている材料
1 [100, 200, 300] 塩、豚バラ、サラダ油
2 [] 材料がない、もしくは、材料名正規化テーブルにはない表記の材料名のみ使われている

事前にすべての recipe_id の concepte_ids をキャッシュすることができればよいのですが、cookpad 全レシピ (350万以上) の concept_ids をキャッシュするのは現実的ではないため、アクセスがあったときにキャッシュをすることにしました *2

初回アクセス

配信サーバーはキャッシュストアに recipe_id から concept_ids を引きにいきますが、初回アクセスではキャッシュされていないため concept_ids は返ってきません (図: 赤線部)。 次に、材料名で concept_id を引きます。 次回以降 recipe_id から concept_id で引けるように、recipe_id をキー、concept_ids を値としたハッシュをキャッシュしておきます (図: 青線部)。 レシピに concept_id が一つも含まれていない場合は、空配列をキャッシュします。 最後は配信 DB に広告をリクエストし広告 JSON を受け取り、クライアントに返します。

2回目以降のアクセス

配信サーバーからキャッシュストアに recipe_id を投げると concept_ids が返ってきます (図:赤線部) 。 材料名を一つひとつ concept_id に変換することなく、すぐに配信サーバーは広告を抽選することができます。 また、recipe_id を投げて空配列が返ってくる際は、レシピに concept_id が含まれていないと判断できるため、空配列をキャッシュさせるのも重要です。 これらの工夫によって、材料ジャック広告追加後もレイテンシをキープすることに成功しました。

f:id:marin72_com:20210730002038p:plain:w400

材料ジャックの入稿から配信のすべての実装

こちらが入稿から配信までの全容になります。 いろいろな課題があった材料ジャックですが、材料名正規化テーブルの使用やキャッシュストアの利用により、レイテンシを保ちつつ、既存実装を変更なく追加実装のみで実現することができました。

f:id:marin72_com:20210730002112p:plain:w600

設計で考慮したこと

入稿・配信の実装を書きましたが、最後に設計時に考慮したことを紹介します。

使用するデータやミドルウェアの事前調査

まず、配信サーバーが使っている ELB と Nginx のヘッダーサイズの制限と現在の使用量を確認しました。 ELBの制限は 16KB、Nginxの制限は 8KB で、現在のヘッダ使用量は 4〜5 KB であるため、材料ジャックにおいては最大 1KB 程度使用しても問題ないと結論しました。 次に、材料名に関する調査を行いました。 クライアントから配信サーバーへ材料名を送信するといっても、1レシピに対してどのくらいの材料があるのか、材料名を連結するとどのくらいの長さになるのかということはネットワーク通信を考える際に重要な項目です。 材料数20個以下のレシピが全体の約99%、材料名の連結バイト数は 350Byte 未満のレシピが全体の約99%であることがわかりました。 そこで今回は、材料数20個以下、材料名を 350Byte までの材料を配信サーバーに送信することにしました。

f:id:marin72_com:20210730002500p:plain:w300 f:id:marin72_com:20210730002447p:plain:w300

キャッシュ戦略

ひとくちに『キャッシュ』と言っても、何をキャッシュするか、生存期間をどの程度に設定するか、などによってそのパフォーマンスは大きく変わります。材料ジャックの設計にあたって、考慮した点を紹介します。

配信サーバーはクックパッドのアクセスと同量のリクエストがある、広告の表示とインプレッション・クリック数の集計機能をもつ重要なサービスです。 大量のリクエストを捌くために Amazon ElastiCache (Memcached) を使用しており、ほとんどのリクエストはキャッシュで返しています。 材料ジャックの実装で雑にキャッシュを使ってしまった結果、キャッシュが溢れて広告全体のレスポンスに影響があるということも考えられます。 そこで、AWS 公式の ElastiCache のモニタリングすべきメトリクスを参考に事前調査を行いました。 CPU Utilization, Swap Usage, Evictions, CurrConnections のすべてを調べたところ ElastiCache には余裕がありました。 そこで今回は、材料名正規化テーブル (2万件) と、アクセスのあった一部のレシピのみキャッシュをしました。 必要最低限のものをキャッシュすることで、キャッシュも溢れることなく、配信サーバーは高速にレシピから concept_ids へ変換することができました。

キャッシュを使ったメリット・デメリット

材料名正規化テーブルをキャッシュストアに全部乗せて、配信 DB との通信の抑制を図ったことに関するメリット・デメリットは以下のようになっています。

メリット
  • パフォーマンス観点:配信 DB に問い合わせることがないので、DB への負荷が変わらない
  • 監視運用観点:新しいミドルウェアを追加していないので、監視対象が増えない
  • リスク観点:材料ジャックで障害が起きても (実装ミスにより concept_id が引けない、recipe_id のキャッシュができないなど) 既存の純広告やネットワーク広告は表示できる
  • 開発効率観点:従来のターゲット配信の仕組みに乗せることができた
デメリット
  • 材料名を変更しても、キャッシュが切れるまでは材料ジャックに反映されない
  • 材料名正規化テーブルのキャッシュが消えた場合、材料ジャックが表示されなくなる

上記2点のデメリットがあるのですが、材料ジャックは長期売の商品であることと、新しいレシピの追加、既存レシピで材料の変更に即追従する必要はないという品質 (=条件にマッチした場合 100% 掲出保証ではない) の商品なので、材料名正規化テーブルはキャッシュにのみ乗せることにし、配信 DB へのアクセスは最低限に抑えることにしました。 今回の開発を通じて、商品の特性に合わせて設計をすることが大事だと学びました。

今回は実施しなかった他の案について

本実装では材料一覧をリクエストヘッダーに含める方針を取りましたが、Pantry (クックパッドの API サーバー) にアクセスをしてレシピ ID から材料一覧を取る手法もありました。 この方針であればリクエストヘッダーサイズは小さくできますが、引き換えに Pantry へのリクエストが急増してしまいます。Pantry はクックパッドのサービス全体を支える API サーバーなので、キャパシティの調整やパフォーマンスへの影響の検証を入念に行う必要が出てきてしまいます。 検討の結果、影響範囲の最小化と工数の削減のため、Pantry を使用せず、広告配信サーバーに材料名をパラメータで直接渡す手法を採用しました。

おわりに

本稿では、材料ジャックの設計と開発について紹介しました。商品の特性を理解し、使用しているミドルウェアや扱うデータ (レシピ) に関する調査を行うことで、設計後大きな手戻りもなく実装を行うことができました。

クックパッドの広告開発チームでは、大規模トラフィックをさばく配信サーバーの開発や純広告・ネットワーク広告の運用、管理画面の開発から、広告の新商品開発まで幅広い開発を行っています。
広告に興味がある! toB 向けの新商品を開発したい! お金儲けに興味がある! 大規模なトラフィックをさばくサービスを開発したい! に当てはまる方も当てはまらない方も、ぜひ一緒に広告開発をしてみませんか?

クックパッドでは一緒に働く仲間を募集しておりますので、ご興味ありましたらこちらのサイトをご覧ください。

info.cookpad.com

*1:2017年時テストデータで行った Encoder-Decoder の正答率

*2:現在のキャッシュの Expired の設定は24時間

ECS インフラの変遷

$
0
0

技術部 SRE グループの鈴木 (id:eagletmt) です。
クックパッドでは Amazon ECS をオーケストレータとして Docker を利用しています。Docker 自体は2014年末から本番環境にも導入を始めていましたが当時はまだ ECS が GA になっておらず、別のしくみを作って運用していました。2015年4月に GA となった ECS の検討と準備を始め、2016年より本格導入へと至りました。クックパッドでは当初から Hakoというツールを用いて ECS を利用しており、Hako の最初のコミットは2015年9月でした。
https://github.com/eagletmt/hako/commit/7f95497505ef78491f3f68e9d648204c7c9bb5e2
当時は ECS に機能が足りずに自前で工夫していた部分も多かったのですが、ECS やその周辺サービスのアップデートにより不要になったものもいくつかあります。今回はその中からいくつか紹介しようと思います。

クレデンシャルを環境変数に設定する

データベースに接続するときのパスワードのように、アプリケーションコードにハードコードすべきではなく、安全に保存しなければならないクレデンシャルが存在します。そういった値を扱うために、クックパッドではクレデンシャルを Vaultに保存し、Hako の定義ファイル上で Vault に保存された値を参照する記法を導入し、デプロイ時には Hako が Vault から値を取得して ECS のタスク定義に注入するようにしました。 f:id:eagletmt:20210805113108j:plainこのようにすることでクレデンシャルが Git リポジトリ内に入ることを防ぎ、また ECS のタスク定義を閲覧する権限を絞ることでクレデンシャルに対する権限管理を行うようにしました。

このしくみは長いこと運用されてきており今もまだ残っていますが、現在では ECS のアップデートによりクレデンシャルを与えるための機能が追加されたので、それを利用するようになっています。
https://docs.aws.amazon.com/AmazonECS/latest/userguide/specifying-sensitive-data.html
これにより Parameter Store や Secrets Manager、KMS によってクレデンシャルの安全な保存と権限管理を達成でき、タスク定義の閲覧を制限する必要がなくなったり、Vault を自前で運用したり Hako のようなツールで工夫する必要もなくなりました。

1つのロードバランサーを複数のアプリで共有する

ECS には ELB と連携する機能が当初からありましたが、1つの ECS サービスに対して1つのロードバランサーしか関連付けることができませんでした。つまり1つのアプリに対して1つのロードバランサーが必要でした。これは機能的には問題無いのですが、ELB のロードバランサーには数に比例した料金が設定されており、どんなに利用が少なくても一定の料金がかかります。公開されている Web アプリの場合は24時間アクセスがきますが、社員が時々使うだけのようなスタッフ向けアプリの場合はロードバランサーの分の料金の割合が高くなります。そのため、できるだけロードバランサーを共有して料金を抑えたいという事情がありました。一方でロードバランサーを共有するとロードバランサー毎のログやメトリクスが複数のアプリで混ざったものになってしまう問題があるため、これを許容できるようなワークロードに限ってロードバランサーを共有することにしました。

そこでロードバランサーを共有するケースでは ECS と ELB の連携機能を使わず、独自にサービスディスカバリを実装し1つのロードバランサーから複数のスタッフ向けアプリにプロキシするようなしくみを作りました。1つのロードバランサーから EC2 上に起動した nginx にプロキシし、その nginx の設定は consul-template から生成されるようにしてホスト名に応じて各アプリにプロキシされるようにしています。 f:id:eagletmt:20210805113305j:plainこのしくみのもう少し詳しい説明は https://speakerdeck.com/eagletmt/ecs-woli-yong-sitadepuroihuan-jing?slide=28にあります。

以前はこのような工夫でロードバランサーの料金を抑えていたのですが、2016年に ALB が登場し、2019年に ALB が Host ヘッダによってルーティングを変更できるようになったことで状況が変わりました。これにより ECS サービスに対応するターゲットグループを1つのロードバランサーに複数関連付け、Host ヘッダの値でルールを作成することで、ロードバランサーを複数アプリで共有することができます。 f:id:eagletmt:20210805113432j:plainこのようなしくみにすることですべて AWS の機能で済ませることができるようになり、Consul を運用したり各アプリにプロキシする nginx を運用したりする必要がなくなりました。

gRPC サーバを ECS で動かす

クックパッドではマイクロサービス化の過程で gRPC を導入し、Ruby や Go 等で実装された gRPC サーバが動いています。gRPC サーバを動かし始めた当時は ALB がサポートしていなかったため、gRPC サーバと通信するためのしくみを自前で作る必要がありました。ここで必要なものは gRPC サーバのサービスディスカバリとプロキシで、前述のロードバランサーを共有したい状況と似ています。しかしこの時点で社内にサービスメッシュが整備されていたため、gRPC のプロキシには Envoy を利用できそうでした。gRPC サーバ向けに Envoy の SDS API *1を実装すればサービスディスカバリについても実現できそうだったので、そのようなしくみを作って gRPC サーバを ECS で動かすようにしました。また、非 gRPC のサーバではエラーレートやレイテンシといった基本的なメトリクスが ALB に依存していたので、gRPC サーバの手前にも Envoy を置くことで Prometheus にメトリクスを入れることにしました。 f:id:eagletmt:20210805113504j:plainこのしくみの詳細は https://techlife.cookpad.com/entry/2018/05/08/080000https://logmi.jp/tech/articles/320715を参照してください。

このように gRPC サーバを ECS で動かすには工夫が必要だったのですが、2020年に ALB が gRPC をサポートしたことにより独自のしくみ無しで gRPC サーバを動かせるようになりました。現時点で ALB では gRPC レベルのステータスコードをメトリクスからもログからも確認することができないため、間に Envoy を置いてメトリクスやログをとっている点は変わってませんが、https://github.com/cookpad/sdsやそれに登録するためのしくみを運用することなく gRPC サーバを ECS で動かせるようになっています。 f:id:eagletmt:20210805113614j:plain

まとめ

ここまでで紹介したもの以外にも、コンテナインスタンスの drain がサポートされたことにより ECS クラスタのスケールインが容易になったり、タスク内のコンテナ間に依存関係を定義できるようになったことでサイドカーコンテナのヘルスチェックが通るまでメインのコンテナの起動を遅らせることができるようになったり、daemon scheduling strategy でデプロイしたタスクが drain 対象になったときに最後に停止されるようになったことでたとえば daemon scheduling strategy でデプロイしている cAdvisor で最後までメトリクスをとれるようになったりと、ECS が GA になった当時と比べるととても使いやすくなっています。

このようにクックパッドでは AWS を活用しつつも自分たちのニーズに合わせてしくみを自作し、社内の基盤を構築してきました。そして AWS のアップデートにより自作のしくみが不要になったときは積極的に自作を廃止し、できるだけ AWS の機能だけで済むようにしてきました。自分たちの目的のために必要であれば自作できることも大切ですが、メンテナンスや運用の手間をできるだけ減らすために、目的を達成できる範囲内でできるだけ自作を減らすことも大切だと考えています。我々 SRE グループはそのようなバランス感覚を持ちながら必要なものを開発していける仲間を募集しています。
https://cookpad.jobs

*1:v1 当時の名前。現在では v3 EDS API になっている

クックパッドマートにおける宣言的ラベル生成

$
0
0

クックパッドマート流通基盤アプリケーション開発グループのオサ(@s_osa_)です。

少し前にクックパッドマートのラベル生成の仕組みを刷新したので紹介します。

クックパッドマートにおけるラベル

クックパッドマートは「美味しい食材を生産者や市場から直接ユーザーにお届けする」サービスです。

食材をユーザーのもとまで届けるためには流通の仕組みが欠かせません。クックパッドマートでは「1品から送料無料」をはじめとするサービスを実現するために独自の流通網を構築しています。

そんなクックパッドマートですが、流通の現場で実際に商品を運ぶドライバーに対しては、主に2つの手段で情報を提供しています。

ひとつはアプリであり、スマホ向けアプリの画面を通してその日の配送計画を伝えたり配送状況の追跡をおこなったりしています。

そして、もうひとつがラベルです。ラベルは何種類かありますが、たとえば商品に貼り付ける「商品ラベル」は以下のようなものです。

f:id:s_osa:20210817115720j:plain
商品ラベルの様子

流通の現場では多くの物理的な「モノ」を扱う必要がありますが、目の前にあるモノとアプリの画面を見比べながら配送業務をおこなうことは業務効率の観点から現実的ではありません。そこで、目の前のモノについての情報は印刷したラベルシールをモノ自体に貼り付けることによって伝えています。

ラベルとアプリという2種類の情報伝達手段ですが、商品名など目の前のモノに紐付く情報はラベル、ルート情報などの俯瞰的・概念的な情報はアプリ、という適材適所の使い分けをしています。ラベルとアプリの両輪による情報提供があってクックパッドマートの流通オペレーションは成り立っています。

既存のラベル生成が抱えていた問題

そんなラベルですが、重要なだけあってサービスの初期から使われています。一方、サービスを取り巻く環境が当時とは変わってきたこともあり、いくつかつらい点を抱えるようになっていました。

素朴な実装

ラベルプリンタへ送るデータは印字するテキストのほかに制御用のコマンドを含むバイナリなのですが、そのバイナリを素朴で手続き的な文字列操作で生成していました。単純化していますが、イメージとしては以下のようなものです。

binary = ""
binary << "#{item.name}\n"
binary << begin_bold_command
binary << "#{user.name}\n"
binary << end_bold_command

実際には、コードや DB では UTF-8 で扱っている文字列をプリンタが要求する Shift_JIS に変更するなどの処理も必要です。当初は素朴なラベル生成コードでしたが、サービスが成長するにつれて要求が複雑になり、少しずつ見通しの悪いコードになっていきました。

分離されていないデータ生成と印刷

ラベル生成と印刷はバッチや非同期ジョブなどでおこなっていたのですが、そのバッチやジョブの中にラベル生成のロジックがベタ書きされているケースがありました。

ベタ書きされていることによってテストを書きにくいほか、他の箇所で同じラベル生成ロジックを再利用することが困難になっていました。

複数実装の維持

当初はラベルプリンタによるラベル印刷だけをおこなっていましたが、同じ内容を通常のプリンタでも出力したいという要求が出てきました*1

しかし、上記の素朴な実装で生成したバイナリはラベルプリンタ用のデータであって通常のプリンタで印刷できるものではありません。そこで、ラベルと同じ内容を含む HTML を生成してから PDF に変換するという方法を取ることになりましたが、そのための HTML はラベルプリンタ用バイナリとは完全に別の仕組みで生成されることになりました。

結果として、ラベル生成ロジックに変更を加える際には2種類のラベル生成ロジックを不整合なく同時に変更する必要が生まれました。

変更しにくいラベル

流通オペレーションはラベルの存在を前提にして組まれているため、ラベルの生成ができない状況が発生すると流通が止まってしまいます。また、ラベルはその物理的な性質から、一度印刷されて流通に乗ってしまうと修正が(現実的には)不可能になります。

つまり、ラベル生成には不具合が発生した際の影響が大きい上にリカバリが難しいという性質があります。

一方、冒頭でも述べたとおり、クックパッドマートの流通においてラベルによる情報提供は非常に重要です。

これらの性質が複合した結果、ラベルは重要であるにもかかわらず、改善サイクルを回しにくいという状態になってしまっていました。

解決方法

方針

これまでに述べた問題を解決するために、以下の設計目標を立てました。

  • 見通しが良くメンテナンスしやすいラベル構造の定義
  • ラベル生成と印刷の分離
  • 単一実装によるラベルプリンタ用バイナリと HTML 両方の生成

これらの設計目標を満たすため、ラベル構造を表現する木構造のオブジェクトを組み立てて、そのオブジェクトからバイナリや HTML の表現を生成する方針にしました。

ここから先は実装の話なので、サンプルコードを中心に説明します。ただし、わかりやすさのために多少簡略化しています。

ラベル要素の実装

はじめに、ラベルに含まれる要素を表現するためのクラスを定義します。HTML におけるタグを思い浮べてもらうのがわかりやすいと思います*2

また、必要なクラスを定義していく際、すべてのクラスに to_binaryto_htmlメソッドを持たせます。

たとえば、ラベル中の文字列を表わす要素 Textだとこんな感じになります。下のサンプルコードにあるエンコーディングの変換や HTML のエスケープ処理のほか、実際には UTF-8 から Shift_JIS に変換できない文字の対処などもこのクラスで実行しています。

classLabelElement::Text<< LabelElement::Base# @param text [String] UTF-8definitialize(text)
    @text = text
  end# @return [String] Binarydefto_binary@text.encode(Encoding::Shift_JIS)
  end# @return [String] HTML string in UTF-8defto_html
    escaped_text = CGI.escapeHTML(@text)
    %Q|<span class="label-element__text">#{escaped_text}</span>|endend

また、太字を表わす要素 Boldなどは基本的に木構造の内部ノードになるので、太字にする対象の子要素を持てるようにします。

classLabelElement::Bold<< LabelElement::Baseattr_reader:childrendefinitialize@children = []
  end# @return [String] Binarydefto_binary
    [
      begin_bold_command,
      @children.map(&:to_binary).join,
      end_bold_command,
    ].join
  end# @return [String] HTML string in UTF-8defto_html%Q|<span class="label-element__bold">#{@children.map(&:to_html).join}</span>|end# @param element [LabelElement::Base]def<<(element)
    @children<< element
  endend

これらのクラスのほか、クックパッドマートのラベルで利用している要素をそれぞれクラスとして定義しました。具体的には、改行、フォントサイズ変更、文字寄せ(左・中央・右)、上線、下線、白黒反転、QR コード、ラベルのカット、ラベルシートそのものなどです。

また、上のサンプルコードでは省略していますが、オブジェクト同士の値としての等価性を判定する ==メソッドなども適切に定義します。

これらのクラスが準備できると以下のような形式でラベル構造を定義できるようになります。to_binary, to_htmlそれぞれのメソッドが木構造の根ノードから葉ノードまで順に呼び出されていくことによって、最終的にバイナリ・HTML それぞれの表現が得られます。

label = LabelElement::Sheet.new
label.children << LabelElement::Text.new(item.name)
label.children << LabelElement::NewLine.new

bold = LabelElement::Bold.new
bold.children << LabelElement::Text.new("#{user.name}")
bold.children << LabelElement::NewLine.new

label.children << bold

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現

ラベル構造の定義を簡単にする

上に書いたラベル構造の定義はお世辞にも読み書きしやすいものではないので、もう少し人間にやさしいインターフェイスを定義します。React.createElement()に対する JSX のようなイメージです。

今回の実装は Ruby でおこなっているのでブロックを用いて以下のように定義しました。jbuilder などと似た、Ruby ではわりとよく見る記法になっています。

label = LabelBuilder.new.build do |l|
  l.text_line(item.name)
  l.bold do
    l.text_line("#{user.name}")
  endend

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

要素を直接作るスタイルと比べて、人間が読み書きしやすいインターフェイスになったと思います。

ドメインに基づいたラベルを定義する

ここまでで汎用的なラベル生成の仕組みができました。

実際のアプリケーションではドメイン内のモデルからラベルを作りたいことがほとんどなので、そのためのテンプレートを作成します。テンプレートの中身は上のブロックを用いたインターフェイスで書きます。

商品ラベルであれば主に OrderItemというクラスのインスタンスから生成されるので、テンプレートを使って以下のように生成できるようにします。

template = LabelTemplate::OrderItemLabel.new(order_item)
label = template.build_label

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

このテンプレートができたことにより、ラベル生成ロジックが一元化され、アプリケーションの任意の箇所で簡単にラベルデータを生成できるようになります。また、それぞれのラベルの構造を知りたいときはブロック記法で把握できるようになっています。

効果

上記の実装により以下のような効果が得られました。

宣言的に記述されたラベル構造

ブロック記法を用いてラベルのテンプレートを定義したことにより、文字列とコマンドの羅列ではなく、階層化された構造としてラベルを読み書きできるようになりました。

また、ラベル構造を記述する際には「どういう内容をどういう装飾で表示するか」を記述するだけで良くなり、そのデータを「どのように生成するか」は考える必要がなくなりました。

ラベル生成と印刷の完全な分離

ラベル生成のロジックをテンプレートに切り出して宣言したことにより、ラベル生成は印刷から完全に分離され、アプリケーションの任意の箇所で再利用できるようになりました。

単一実装による複数表現の生成

単一のラベル構造から to_binary, to_htmlという2つのメソッドを用いて2種類の表現を生成できるようになりました。さらに、他の表現形式としてテストや簡易的なログ用途のプレーンテキスト表現が欲しくなったのですが、各ラベル要素に to_plain_textというメソッドを追加することで簡単に実現できています。

また、すべてのラベルに無料で HTML 表現がついてくるようになったため、管理画面上で印刷プレビューを表示したり印刷ログを HTML でも保存・表示したりといったことが簡単にできるようになりました。これは開発時の簡易的なチェックやデバッグに便利なだけでなく、流通オペレーションやカスタマーサポートなどの運用・調査でも参照する機会が多く、非常に役立っています。

f:id:s_osa:20210817170236p:plain:w300
プレビューの様子

変更しやすいラベル

個々のラベルがテンプレートに切り出されたことによりテストがしやすくなったほか、ラベル生成の仕組み自体を具体的なラベルから切り離してテストすることが可能になりました。

さらに、ラベル生成と印刷が完全に分離されたことにより、今後印刷する予定のラベル生成を dry run で走らせることが可能になりました。dry run の実現によって、万一変更内容に問題があった際にも実際に問題が起こって流通が止まる前に対処できるようになり、ラベル変更にともなうリスクが大きく下がったため、改善サイクルを回しやすくなりました。

厳密な比較ではなく参考程度の情報にはなりますが、刷新の前後で pull request の数を調べてみたところ、同じ期間あたりの pull request 数が2倍以上になっています。

おわりに

サービス開発初期のすべてが不確かな状況で書かれたラベル生成コードを現在の状況に合わせて書き直したという話を書いてきました。当初はこのエントリのサンプルコードにあたる部分を OSS として公開しようと考えていたのですが、絶妙に業務ロジックが絡みついていて公開できる形に抽象化できなかったのでサンプルコードでの紹介になりました。

流通という領域ではここまで書いてきたようなコードによる問題解決のほか、実際にモノを動かす現場のオペレーションも非常に重要です。事実、今回のラベル生成刷新も現場オペレーションの改善サイクルを高速化するための刷新です。

現場のオペレーションは、良い仕組みを考えたと思っても実際には実行が困難だったり、そうでなくても人間は間違えたりします。そういったソフトウェアだけに留まらない問題に対して、ソフトウェアを軸に挑んでいくのは困難であるとともに挑戦的で楽しいことだと感じています。

流通という裏側の仕組みはイメージしにくいところも多いと思いますが、少しでも興味が湧いた方がいたらご連絡ください。採用サイトからの正規ルートでももちろん良いですし、@s_osa_まで雑に DM していただくなどでも大丈夫です。よろしくお願いします。

info.cookpad.com

*1:万一ラベルプリンタが故障しても出荷・流通が止まらないようにしたいというサービス可用性の観点のほか、販売者数拡大のためにラベルプリンタなしでの出店を可能にしたいといった動機が背景です。

*2:厳密には DOM element のほうがメタファーとして適切だと思います。

レガシーとなった TLS 1.0/1.1 廃止までの道のり

$
0
0

SRE 兼よろず屋の id:sora_hです。最近は本社移転プロジェクトをやっています。趣味は Web *1です。

さて、クックパッドでは 2020 年 12 月に TLS 1.0 および TLS 1.1 (以後 "Legacy TLS") を廃止しました。

Legacy TLS は RFC 7457でまとめられているような既知の脆弱性の存在などから、Chrome, Firefox といった主要ブラウザを含め各所でのサポートが打ち切られつつあります。また、現在では IETF においても Legacy TLS は deprecated と RFC 8996にて宣言されました。  

クックパッドでもセキュリティ対策およびレガシーな技術と向き合う一環で廃止を進めました。我々は歴史の長いサービスも提供しているため、古い Android や Internet Explorer などからのアクセスもありましたが、トラフィック傾向や各種ベンダーによるサポート状況を見ながら慎重に進めていきました。

全サービスでの廃止にあたっては関わる人数や影響範囲も大きくなるため、丁寧に進める必要があります。それはモチベーションの整理から新しい TLS 設定の検討といった下準備、design doc の作成とステークホルダーへの提案、そして実際の廃止作業といった短くない道のりを辿りました。本稿ではこの一連のプロセスについて振り返ります。

廃止のモチベーション

まず、Legacy TLS を廃止したいモチベーションとしては下記が挙げられます。

  • セキュリティ上のリスク: Legacy TLS は RFC 7457に挙げられているように既知の脆弱性が存在する。その上、新たに深刻な脆弱性が発見された場合のリスクは低くない。
  • コスト最適化:一部サービスは非 SNI サポートに有償オプション *2を必要としている。これは TLS 1.2 以上を前提とすることで、クライアントの SNI サポートも前提とできるため、Legacy TLS 無効化によりそのオプションを廃止できる。
  • 最新の技術やサービスを利用できない潜在的なリスク: TLS 1.2 以上や SNI を前提とする技術やサービスはこれから増え続けていくと考えられる。

特にセキュリティ上のリスクは現状よりも今後新たな脆弱性が発見された際のリスクを高く評価しました。2017 年に完全 HTTPS 化を行った際の理由でも触れられていますが、我々はユーザーさんにセキュアなサービスを提供したいと考えています。

その一環として、2014 年に公開された SSL 3.0 の POODLE 攻撃に対する対応を振り返ります。クックパッドでは脆弱性情報の公開後、速やかに SSL 3.0 を無効化しました。しかし、当時のトラフィック状況では唐突にサポートを打ち切るには時期尚早で、ユーザーさんへの影響が想定以上に発生してしまいました。そのため、再度有効化の上あらためて無効化に向けて動く対応を行ったことがあります。

この POODLE の教訓から、今後 Legacy TLS に重大な脆弱性が新たに見つかった時、我々は迅速にユーザー影響なしに無効化できるのか、という疑問が発生しました。セキュアなサービスを提供し続けることを考えれば無効化はせざるを得ませんが、一方で脆弱性の対応により突然クックパッドのサービスを利用できなくなってしまうユーザーさんの発生も避けたいです。そのため、早期に準備して Legacy TLS の廃止を進めるべきだと考えました。  

また、最新の技術やサービスが利用できないことも大きな足枷となります。特にクックパッドのグローバル向けサービスでは Fastly をサイト全体の CDN として利用しており、Fastly がサービス全体での Legacy TLS 廃止を進めていたこと、また TLS 1.3 と Legacy TLS が排他だったのは大きな決め手でした *3

そして実トラフィックを確認すると、Legacy TLS の利用率は 2017 年で 3% だったところ廃止を提案した 2020 年 8 月頃では 0.5% まで減少していました。

また、同じ頃にクライアント (ブラウザ) でも Legacy TLS のサポートを打ち切る計画 *4があり、徐々に無効化され始めていました *5。これらも廃止を後押しする理由となります。

廃止のための準備

まずは前節で述べた理由を元に社内ブログへ所信表明を投稿しました。廃止のためにはエンジニアリングチームを含めビジネスサイドのステークホルダーの説得、またサポートチームの協力を得る必要があります。まずはやっていき宣言を発出した後、準備を整えて協力や意思決定を貰いに行くことにします。

トラフィック状況のモニタツールを用意

一言で廃止すると言っても、クックパッドは複数のサービスを提供していて、システムはその数以上に存在します。

Legacy TLS の利用状況についても、 Internet Explorer や古い Android からのトラフィックが存在しないような歴史の浅いサービスから、そうではないサービス、はたまた toB 向けに提供していて Chrome, Firefox, Edge といったモダンブラウザの割合が少ないサービスまで存在し、全体で一気に廃止するというのは良い判断ではありません。

そこでまずは SRE で状況を把握するため、また最終的にはエンジニアリングチームが各々判断できるようにするため、各システムのトラフィック状況をモニタするための社内ツール TLSitrep *6を作成しました。

https://twitter.com/sora_h/status/1285189591096410112  TLSitrep のスクリーンショット

TLSitrep では週次で ELB ログを集計し、その結果を閲覧できるようになっています。これはほぼ全てのシステムが AWS 上で稼動しロードバランサーとして ELB を利用していて、また全 ELB のアクセスログを Amazon Athena でクエリできるようテーブルとパーティションを自動で維持してくれる社内システムが存在しており、サクッと実装することができて便利でした。

新しい TLS 設定ポリシーと移行ガイドの作成

次に、廃止後に設定する cipher suite の方針などを決定しました。TLS 1.2/1.3 で利用することができる cipher suite から、Mozilla の Server Side TLS ポリシー, CRYPTREC GL-3001-3.0.1*7, NIST SP 800-52 Rev. 2等を参考に検討します。具体的には下記のように定義しました。

  • Modern TLS: TLS 1.2+, AEAD (AES-GCM, ChaCha20-Poly1305), Forward Secrecy (TLS 1.2: ECDHE, 1.3: any)
  • Moderate TLS: TLS 1.2, 非 AEAD, Forward Secrecy
  • Legacy TLS: (Insecure TLS を除く) TLS 1.0/1.1, 非 AEAD, 非 Forward Secrecy
  • Insecure TLS: SSL 3.0 以下 or AES/ChaCha20-Poly1305以外 or AEAD/HMAC-SHA256,384以外

ただし、実際には ALB では規定のポリシーの中から選択することになる他、CloudFront など CDN でも同様となるため、この基準をそれにマッピングすると下記の通りです:

  • Modern: ELBSecurityPolicy-FS-1-2-Res-2019-08
  • Moderate (推奨): ELBSecurityPolicy-TLS-1-2-2017-01
  • Legacy: ELBSecurityPolicy-2016-08

TLSitrep においてもこの基準で集計しています。そして、変更した際のインパクトを把握しつつ移行先のポリシーがサジェストされるようにしました:

f:id:sora_h:20210819052222p:plain
図: 表示される移行先ポリシーの提案
f:id:sora_h:20210819052420p:plain
図: Cipher Suite 単位のアクセス傾向

また、ポリシーの策定にあたっては実際のクライアントからの接続可否と、影響の大きさを調査するため、 testssl.shを用いて実際に各種 security policy を設定した ALB にテストを行い、念の為に実際の古い Windows や IE からもテストを行います。

実際のところ、ALB デフォルトの ELBSecurityPolicy-2016-08 から ELBSecurityPolicy-TLS-1-2-2017-01 へ移行した場合のインパクトは下記の通りとなりました:

  • Windows: 2009 年以前の PC は接続不可 (Windows Vista 以前)。
  • macOS: 2013 年 10 月以前は接続不可 (macOS 10.8 以前)。ただし同年リリースの Chrome/Firefox から TLS 1.2 が有効なので 10.8 以前でも OK の場合あり。
  • Android: 2013 年以前は接続不可 (Android 4.4.2 以前)。
  • iOS: 2011 年以前は接続不可 (iOS 5)。

iOS/Android 向けアプリ版ともに影響を受ける端末はすでにサポート対象外ですが、やはり実際のトラフィックでは初期の Android 4.x 系や古い Internet Explorer からのアクセスが影響範囲として目立つことが分かりました *8

また、前述のようにシステムによって傾向が違うことも明らかになりました。例えば cookpad storeTVの関連システムでは販売店舗等からのアクセスが多いためか、古い Internet Explorer や OS など Legacy TLS や非 FS/AEAD cipher suite の利用率が他と比べて目立っていたなどが挙げられます。

説明と説得のための design doc の作成

各所へ説明するため、モチベーションや移行手段、新しい TLS 設定について記載した design doc を作成しました *9。この時点で、社内のセキュリティチームからもレビューを貰っています。

筆が乗ってしっかりした文章になってしまった *10ため、最終的には事業部から「ここまでやってもらったらやるだけですね」などと評価されたようで、ちゃんと書いてよかったなと思っています。

また、国内他社事例などもできる限り調査して現状を記載しました。その際、 Yahoo! JAPAN さんの廃止などはかなり参考にさせていただきました。

f:id:sora_h:20210819052657p:plain
図: 用意した design doc の ToC と冒頭部分

アナウンスと説得

前節で用意した design doc を元に筆者が主体的にエンジニアリングチームやステークホルダーへ提案、意思決定を仰ぎました。

幸いにも Legacy TLS のトラフィックレートが十分低下しており、そして design doc でリスクと利点を説明、その他の準備でサービスチームの作業も最低限にしたことからスムーズに受け入れてもらえました。また、自分が動かずとも doc を抱えて他チームのマネージャが意思決定者まで話を持っていってくれる、事業側のエンジニアリングチームが実際の影響ユーザー数の算出を行ってくれるといった協力を貰う事ができたので、備えて良かったなと感じます。

cookpad.com ドメインでの廃止作業

無事に廃止が決まったため、残るは設定の移行作業となります。ここまでくれば技術的にはやるだけです。

基本的に design doc 上で合意済みの対応方針と移行方法をベースに全社へアナウンスをし、各チームに対応を一任しました。また、新規システムがデフォルトで利用する TLS 設定もこのタイミングで切り替えました。

その結果、速やかに無効化されていったようです (筆者はそれを毎週確認しているだけでした)。TLSitrep が変更先の設定を提案するようにした他、ポリシーも分かりやすく提示したため、変更作業としてはアナウンス通りに ALB 設定変更のデプロイで済むことがほとんどでした。前述のように廃止については受け入れられていたため、スムーズに廃止が進行しました。

そして、関わるチーム数が多い cookpad.com ドメインおよび PC およびスマートフォンブラウザ版クックパッド (以後 "レシピサービス") については筆者が実作業も担当しました。この節では以降、レシピサービスにおける Legacy TLS 廃止について説明します。

対象のユーザーさんへ警告を表示するための仕組み

まず、レシピサービスが他サービスと比較して大きなユーザー影響が見込まれたため、ユーザーさんへの告知が必要でした。

クックパッドのサービスは全て常時 HTTPS となっており、 Legacy TLS 廃止後は非対応の環境から完全に接続できなくなります。そのため、実際に接続できなくなるユーザーさんに対して、サポート外になる警告を表示しました。

具体的には、Legacy TLS 廃止後の SecurityPolicy を設定した ALB と CloudFront distribution を用意して、CloudFront への接続に失敗した場合 ALB へ接続試行、その結果を元に警告を表示する JavaScript を準備しました。

 

f:id:sora_h:20210819052810p:plain
図: 警告のイメージ (実際にリリースした際は廃止日時が記載されていました)

この検証作業や前述の SecurityPolicy 接続テストの一環で Windows XP/IE6 や Vista/IE7 の環境も構築してみたんですが、Windows Update さえ実施できず本当に何もできない感じだったのが記憶に残っています。最新のパッチまで当ててたらもう少し何か出来たりするのかな.........。

サポートチームとの連携

以上の変更を抱えてサポートチームへも相談し、文言や問合せ等の対応方針を検討しました。ここでも最初の design doc で背景を理解してもらうのがスムーズに進み、やはり文章は便利だなという気持ちが強まります。

グローバルチームとの連携

cookpad.com ドメインはクックパッドのグローバル向けサービスと共有しているため、このドメインの Legacy TLS 廃止にあたってはグローバル側のサービスチームとも連携が必要でした。

こちらは歴史も浅く Web フロントエンドが古いプラットフォームでそもそも動かなかったりする他、iOS/Android アプリについても日本国内版に比べ古い OS のサポートを早期に打ち切っている関係で背景の説明をするまでもなく合意を得ることができました。

なお、日本国外からの cookpad.com ドメインへのアクセスは CDN として Fastly を経由するようになっているため、Fastly 側での設定変更を行いました。Fastly は CloudFront ほど柔軟な設定はなく、基本的に cipher suite についてはお任せとなります。TLS 1.0/1.1 を無効化し 1.2/1.3 のみとなった設定を準備しました。

Legacy TLS の無効化

告知から一定期間置いて、順次 Legacy TLS を無効化する設定をデプロイしました。念の為ブラウザ版で表示している告知へ誘導するため  iOS/Android 版クックパッドアプリで利用されている API から先に無効化を進めましたが、幸い大きな混乱はなく廃止完了となりました。

cookpad.com ドメインにおける廃止が終わる頃には、他システムでも既に廃止が完了していたため、cookpad.com での廃止を以てクックパッドが提供するほぼ全てのサービス *11で Legacy TLS の廃止が完了したことになります。

まとめ

f:id:sora_h:20210819052858p:plain
図: Qualys SSL Labs にて A を獲得することができた様子 
  廃止から半年以上経過しますが、社内外どちらも大きな混乱はありませんでした。ビジネスサイド含め複数のステークホルダーやチームと連携する必要がありましたが、丁寧にリスクを説明できるようにし、サービスチームの対応コストも下げることによってスムーズに受け入れてもらうことができました。また、ユーザーさんからの反応も想定より小さいものとなったようです。

そして、このプロジェクトで用意した TLSitrep については維持し、ALB の SecurityPolicy が更新された場合速やかに新しい推奨設定を検討できるようにしています。直近では ALB の TLS 1.3 サポート *12、非 AEAD/FS を許容する TLS 1.2 only の policy が来ないかな、などど思いながら過ごしています。

最後に繰り返しとなりますが、レガシーな環境や設定の存在はサービス提供者とユーザーさん双方のリスクとなるだけでなく新しい技術の足枷となりかねません。クックパッドは先に述べたように歴史の長いサービスで、サーバーサイドからクライアントサイドまでそれが起きやすい環境です。そんなクックパッドではレガシーな環境に立ち向かいながら新しい技術や industry standard へ積極的に追従する仲間を募集しています。 https://cookpad.jobs

謝辞

本プロジェクトは複数のチームの協力によって成り立ちました。また、特に古い環境向けの JavaScript をサクッと書いてくれた id:hokaccha、TLS 設定や対応方針のレビューをしてくれたセキュリティチームの id:kani_bと  id:mztnex (@m_mizutani) にこの場を借りて感謝します。

*1:I-DIntent to ship, crbug.com, chromium-review, standards-positionsなどを眺めること

*2:例: CloudFront の dedicated IP address オプション、プロジェクト当時存在した Fastly の shared/dedicated certificate オプション

*3:Fastly は 2016 年からサービス全体での Legacy TLS 廃止を進めていますが、現時点でもまだ既存のユーザーに対する完全な廃止はされていないようです。

*4:打ち切りの宣言: Chrome, Firefox, IE/Edge, Safari

*5:打ち切りの様子: Chrome, Firefox

*6:TLS + Sitrep; situation report

*7:2020年7月公開、タイムリーにリリースされていて便利

*8:プロジェクト当時、ブラウザ版でも Android 4.x のサポートは終了しているが Internet Explorer は引続きサポートしている状況でした

*9:実際には平行して書いていました

*10:Google Docs A4 で 13 ページ、15,000 文字 / TLS 設定の方針なども記載したため、実際にステークホルダーが読むべきところはその半分くらい

*11:本稿執筆現在、フィーチャフォン向けサービスでの Legacy TLS 提供が残っています...。

*12:CloudFront には来たのに無い...

AWSフル活用!クッキングLiveアプリ「cookpadLive」を支える技術

$
0
0

メディアプロダクト開発部の長田(@osadake212)です。
私の主な仕事は、CookpadTV 株式会社のサービス開発をすることです。CookpadTV ではたくさんのサービスを同時に開発しており、今回の記事ではそのたくさんのサービスの中の一つである cookpadLive とそれを支える技術について、利用している AWS サービスを中心に紹介します。

cookpadLive チームメンバーによる、過去の発表や記事と被る箇所もあるのですが、この記事では全体を眺めることができるように紹介していきます。

cookpadLive とは

cookpadLive

cookpadLive とは、料理上手な有名人と Live 配信で一緒に料理が楽しめるクッキング Live アプリです。視聴者は Live 配信中にコメント機能を使って、料理のわかりづらいポイントを質問したり、作って欲しい料理のリクエストをすることができます。
普段あまり料理をしないユーザーも楽しめる Live 配信になっていて、 cookpadLive をきっかけに料理を作るユーザーもいます。

Live 配信中にはコメント以外にも多くの機能があります。

  • コメント・ハート・スタンプ(スタンプは有料会員であるゴールド会員限定)
  • Live 配信後半にゴールド会員だけが視聴できる スペシャルTIME
  • 広角カメラによる配信映像で Live を楽しめる スタジオ観覧モード
  • Live 配信中に料理やレシピカードを購入できる スペシャルSHOP
  • Live 配信中に出演者と生電話ができる スペシャルTALK

これらの機能は Live 配信によって取り外しが可能になっていて、企画に合わせて最適な機能を組み合わせることができるようになっています。

また Live 配信以外にも、アーカイブやスペシャル VIDEO のような機能もあります。

cookpadLive の歴史

cookpadLive は 2018年にリリースしました。リリース当時は Live 配信を視聴する機能しか備わっておらず、アプリの UI、バックエンドの構成も現在とは大きく異なっています。

特徴的な変遷を紹介すると

  • コメント配信システムを Firebase から AWS へ乗り換え
  • ユーザーが増えたことによるスパイクに耐えるための仕組みの整備
  • ゴールド会員という月額有料プランを用意し、スペシャル TIME, スタジオ観覧モードのようなゴールド会員向けの機能を実装
  • 都度課金にもチャレンジしてマイクロサービスを作ったが、うまく利用できなかったのでマイクロサービスの撤退
  • Live 配信リソースを効率よく利用するために Live 配信システムを刷新した
  • スペシャル TALK、スペシャル SHOP の機能を実装

こんな感じで、小さくリリースしてから、積極的に新しいことにチャレンジし、失敗しながらも前に進むために継続的に開発をしています。

※こちらの記事にスパイクに耐えるための仕組みの詳細について記載しているので、合わせて読むと分かりやすいです。
cookpadTV ライブ配信サービスの”突貫” Auto Scaling 環境構築

cookpadLive のシステム概要

クックパッドでは AWS を積極的に利用しています。

Rails や go などのアプリケーションサーバーは、社内で Amazon ECS を使って動かす環境が整っているので、 cookpadLive のアプリケーションサーバーもその仕組みを使って動かしています。( Dockerfile と jsonnet を書くと本番環境で動き始める素晴らしい仕組みです。*1
基本的な Web アプリケーションであれば上記の仕組みだけで十分なのですが、 cookpadLive のように映像を扱ったり、リアルタイムでなにかをしたりする場合には工夫が必要です。AWS にはさまざまなサービスがあり、 cookpadLive の新機能を実装するときには、まず AWS のサービスをうまく使うことで実現できないか、という観点で技術調査・設計・実装を進めています。

細かいところは省略しつつも、 cookpadLive システムの構成はだいたい以下のようになっています。

クックパッドには強力な開発基盤があり、さまざまな社内アプリケーションから利用できるように設計されています。
cookpadLive もその開発基盤の上で開発しており、特に認証基盤・決済基盤・DWH はその中でも重要な役割を担ってくれています。

次のセクションでは、AWS を使った特徴的な部分について紹介します。

cookpadLive のシステム的な特徴

以下の3つについて紹介します。

  1. Live 配信
  2. Live 配信中のメッセージ
  3. スペシャル TALK

1. Live 配信

Live 配信・アーカイブ生成などの映像を扱うために AWS メディアサービスを利用しており、その中の AWS Elemental MediaLive*2, AWS Elemental MediaStore*3, AWS Elemental MediaTailor*4 AWS Elemental MediaConvert*5を利用しています。

ワークフロー

Live 配信は MediaLive -> MediaStore -> MediaTailor -> CloudFront のように構成しています。
MediaLive で配信スタジオからの RTMP Push を受け付けて、 MediaStore を destination に HLS 形式にエンコードしたものを出力させます。
また、CloudFront の手前に MediaTailor を挟んでおくことで、ゴールド会員限定のスペシャル TIME を実現できるようにしています。

アーカイブは MediaLive の Output にアーカイブ用のものを追加しており、 RTMP Push を受け付けた時に S3 を destination に MPEG2-TS 形式で出力し、Live 配信終了時にアーカイブ生成処理を実行します。
出力された ts ファイルは一度1つの ts ファイルに結合された後、 MediaConvert を使って HLS 形式にエンコードし、 CloudFront 経由で配信しています。

MediaTailor の部分が若干特殊になっているものの、一般的なワークフローで Live/アーカイブ配信を実現しています。

配信リソース管理のためのマイクロサービス

cookpadLive では配信リソースを管理するマイクロサービスを用意したのにはいくつか理由があり、その1つを紹介します。
※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
cookpadLiveのライブ配信基盤 Cookpad Tech Kitchen #23

cookpadLive では、1配信1系統毎に MediaLive のインスタンスを立ち上げています。なので通常配信に加え、別カメラの映像を流すスタジオ観覧モードを追加するなど、1つの配信でも別系統の映像を配信したい場合は追加でインスタンスを立ち上げています。つまり、同じ時間帯に3つの配信あり、さらにそれぞれ通常・スタジオ観覧モードの2系統があった場合、6つの MediaLive のインスタンスが必要になります。また、それぞれの配信でリハーサル配信をする場合もあったりして、1日にいくつものインスタンスを必要とする場合があります。

MediaLive のインスタンスは作成してから数分〜数十分経過しないと利用可能にならないので、あらかじめ作成しておく必要があるのですが、作成タイミングによって MediaLive のインスタンス作成上限に引っかかったり、 Live 配信の開始時刻までに利用可能にならないリスクがあります。

リソース確保に伴うこれらの課題をマイクロサービスを用意して抽象化することで、 Live 配信に関するビジネスロジックの実装に集中できる環境を作りました。

2. Live 配信中のメッセージ

cookpadLive の開発では、コメント・ハート・スタンプなどのリアルタイムの情報をまとめてメッセージと呼んでいます。
Live 配信中にリアルタイムにサーバーからクライアントにメッセージを送信するために AWS AppSync を利用しています。

AppSync は GraphQL のインターフェースで AWS DynamoDB などのデータソースにアクセスすることができるサービスです。
GraphQL には3つのオペレーションがあり、クエリ・ミューテーション・サブスクリプションがあります。 cookpadLive のメッセージ配信は、 AppSync のサブスクリプションのオペレーションを使って実現しています。

クライアントが直接 AppSync を利用していないのにはいくつか理由があります。

まず1つ目は、送られたメッセージをそのままファンアウトさせるのではなく、アプリケーションサーバーである程度処理を加えたかったからです。
具体例を挙げると、なりすましが難しい形でユーザー属性をメッセージに付け加えたかったり、不適切なコメントをフィルタリングしたり、といった処理をしたかったからです。
AppSync の Resolver や datasource や Cognito などを工夫することで実現することも可能だとは思うのですが、システム全体の整合性を考えると現実的ではないと判断したので cookpadLive ではアプリケーションサーバーを経由して AppSync にミューテーションする方式を採用しました。

余談ですが、 AppSync はサーバーアプリケーションからオペレーションすることをあまり考慮しておらず、言語によっては SDK が用意されていません。なので cookpadLive では AppSync との通信部分を独自に実装しました。

2つ目の理由は AppSync への書き込み流量をサーバー側で制御したかったからです。
Live 配信は決まった時間にユーザーがいっきに集まりますし、配信の盛り上がりや企画次第ではハート・コメント・スタンプが連打され、リクエストのスパイクが起きやすいという性質があります。

AppSync に対するオペレーションの Rate limit も存在するのですが、例えばデータソースに DynamoDB を指定している場合はテーブルのキャパシティプランニングも合わせて必要になります。
サービスの成長や出演者の人気度の変化に柔軟に対応するためにも、アプリケーションサーバーを経由して AppSync に書き込みする構成にしています。

※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
クックパッドの動画事業での AWS AppSync 活用事例

コメントの永続化

図をみると message サーバーから AppSync へ書き込むフローと、 S3 に書き込むフローにわかれている部分があるのですが、前者は Live 配信中に他ユーザーにファンアウトするためのフローで、後者はコメントデータの永続化のためのフローになっています。
シンプルに考えると、 AppSync のデータソースを DynamoDB にすればいいのではないか、と思われるかもしれないのですが、 負荷試験の結果 cookpadLive のコメントの流量で DynamoDB に書き込みをしようとすると期待するパフォーマンスがでないことがわかりました。
コメントデータはアーカイブの生成が完了したタイミングで準備ができていればよいので、パフォーマンスチューニングをするのではなく、多少遅延してもよいのでパフォーマンスを気にせず永続化できる仕組みを構築しました。(AppSync のデータソースは type: NONE を利用しています。)

※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
アーカイブ配信でもライブ感を味わいたい / cookpad_tech_kitchen#23

余談ですが、この課題は AppSync のデータソースに Amazon Kinesis を指定できるようになると解決するので、是非 Kinesis をサポートしてほしいです。

3. Live 配信中の通話機能(スペシャル TALK)

スペシャル TALK は、Live 配信中にキャストと1対1で生電話ができる機能で、配信スタジオからは映像と音声の両方、ユーザーからは音声だけの通話ができます。
この機能を実現するために Amazon Chime と AWS AppSync を利用しています。

まずユーザーはスペシャル TALK への応募を行います。応募が揃ったら通話するそのユーザーに対して AppSync のサブスクリプションを使って、サーバーからアプリに対してイベントを送信します。アプリはこのイベントを受け取ると API を叩き Amazon Chime の接続情報を取得します。取得した接続情報をもとに Amazon Chime に接続し、接続が完了すると通話が開始される、というフローになります。

配信スタジオとの統合

システム構成ですがこの形に辿り着くまでに多くの議論を重ねました。
今まで開発したことないタイプの機能でしたし、既存の配信スタジオのマイク・スピーカー・カメラ・スイッチャーとどのように統合するのかであったり、 Live 配信の企画として成立させるためには通話以外にどういう機能が必要なのかを検討したりと、エンジニアチームだけではなく、撮影技術チーム・Live 配信ディレクターチームも巻き込んで今の形に仕上げました。

また、開発やデバッグにはとても苦労しました。 通話機能なので相手が居ないと正しく動いているかどうかが判断しづらく、離れたところで音楽を鳴らして擬似通話したり、ある程度完成したら複数人でデバッグしてみたりと、試行錯誤しながら開発をしました。

また、Amazon Chime は他の AWS リソースと違ってコンソールでリソースの操作ができず API でしか操作できなかったので、今どうなっているか、などの状態をパッと把握するのが難しいです。
この課題については現在も残っていて、チームメンバーと議論しながら解決に向けて進めている最中です。

余談ですが、結合テストについては、コロナ禍で全員リモートだったこともあり、実際の利用環境に近い形でテストすることができ、ユーザーの気持ちや問題に早く気づくことができました。

今後の予定

今回の記事では主にサーバーサイドの技術について触れました。cookpadLive では iOS・Android・FireTV のプラットフォームでアプリを展開しており、これらのアプリでも技術的な挑戦をしています。
アプリのチャレンジについては、次の機会で執筆しようと思いますので、乞うご期待ください。

cookpadLive では引き続き技術的なチャレンジをしていきます。
直近では API のパフォーマンス改善と、 JSON API を GraphQL に置き換えることを検討しています。そのうち情報発信していきますので、こちらも乞うご期待ください。

記事では書ききれなかったことも多く「もっと cookpadLive について知りたいよ」という方がいらっしゃいましたら、是非 @osadake212までご連絡ください。

info.cookpad.com

参考記事

*1:ECS インフラの変遷 https://techlife.cookpad.com/entry/2021/08/05/114810

*2:ライブ動画処理サービスです。公式サイト: https://aws.amazon.com/jp/medialive/

*3:メディア向けに最適化された AWS ストレージサービスです。公式サイト: https://aws.amazon.com/jp/mediastore/

*4:動画ストリームにターゲット広告を個別に挿入できます。公式サイト https://aws.amazon.com/jp/mediatailor/

*5:ファイルベースの動画変換サービスです。 公式サイト: https://aws.amazon.com/jp/mediaconvert/

Cookpad Summer Internship 2021 10 Day Techコースを開催しました!

$
0
0

f:id:fufufukakaka:20210426121451j:plain

研究開発部の深澤(@fukkaa1225)です。今年はエンジニアの立場から新卒採用も担当しています。

4月の記事で告知したサマーインターンシップのうち、10 Day Techコースを8月16日〜8月27日で開催しました。この記事ではその内容を紹介します。

3 Day Product Designコースについては、以下の記事をご覧ください。

10 Day Techコースは、前半5日間が講義形式、後半5日間が実践形式でした。 前半は技術講義とサービス開発講義の2本立てです。 後半はOJTプログラムとPBL(Project-Based Learning)プログラムのそれぞれに分かれて、サービス開発の実践に取り組みます。

昨年はオンラインのみでの開催でした。今年は前半の講義パートをオンラインのみ、後半の実践パートではオフィスに来訪されることを希望した方にはオフィスで、それ以外の方々は前半から引き続きオンラインで参加する形式を取りました。例年と同様、多くの学生の方々にご参加いただきました。本当にありがとうございました。

前半

まずは前半の講義について簡単にご紹介致します。

1日目: フロントエンド講義

初日は1時間弱ほど、イントロダクションとしてクックパッドの取り組みの紹介や自己紹介を行いました。

その後、今年の技術講義のテーマを発表しました。今年のテーマは「ミニクックパッドマートを作る」でした。クックパッドマートを題材として、クックパッドの中で使われている技術スタックを一気に学習・実践することが狙いでした。

イントロダクションを終えた後、フロントエンド講義からいよいよ技術講義がスタートしました。 2021年の春インターンでも用いた動画を見るという事前準備を前提として、午前中の1時間でクライアントサイドから見たGraphQLに関する講義を行いました。

その後、午後はミニクックパッドマートのweb画面を作ることを目標として、基礎課題・発展課題に取り組みました。

2日目: iOS講義

2日目のiOS講義は1日目に実装したミニクックパッドマートの画面を、SwiftUIで実装するという内容でした。iOS未経験の方がほとんどで、環境構築のところが難所ではあったものの、 SwiftUIを実際に触ってみて使いやすいと感じた人が多かったようです。ほとんどの人が基礎課題を完了させるところまで実装を完了させていました。

3日目: サーバサイド講義

3日目はサーバサイド講義でした。例年の講義では、APIを前半に自分たちで作ってから、その自作APIを使って後半の講義で画面を作るという流れでした。しかし、各自の進捗度合いなどによって想定仕様と微妙に異なる自作APIとなってしまい、画面を作る際に苦労してしまうことがありました。

これを踏まえて、今年はあらかじめ完成しているAPIサーバをこちらで立てて、前半に画面を作る講義を配置してAPIの仕様を把握しながら画面を作りました。その後、3日目のサーバサイド講義で、これまで使ってきたAPIを自分たちで実装してみる、という流れをとりました。

実際に作成してみるのは、GraphQLのAPIサーバです。これをRubyで実装する講義でした。また、決済を担当する別サービス(minifinancier)とのサービス間通信をgRPCで実装するなど、発展的な内容も多く盛り込まれた講義でした。非常にボリュームの多い内容で、多くの方々が苦労されていましたが、それでも何とか乗り切っていました。

4日目: インフラ講義

4日目のインフラ講義では、クックパッドにおけるSREが果たしている役割とその歴史を、実例を交えつつ紹介しました。演習パートでは、基礎課題としてTerraform・hakoを用いて、ミニクックパッドマートをデプロイした他、minifinancierをデプロイしてgRPCのサービス間通信を張る、発展課題として静的ファイルをS3+Cloudfront構成で配信するなどの課題に取り組みました。

スライド: https://static.cookpad.com/techlife/cookpad_summer_internship_2021_infra/main.pdf

補助資料

5日目: サービス開発講義

前半最終日はサービス開発講義を実施しました。午前はクックパッドのサービス開発に対する考え方や開発プロセスを座学形式で学びました。 午後は、午前で学んだことをベースにインターン生同士でチームを組み、ZoomやFigmaを活用したオンラインでのグループワークを行いました。グループワークでは、与えられたテーマを元にユーザーインタビューや価値仮説、アイデア出し、プロトタイプの作成までを一貫して実践しました。講義の終盤では実際に作成したプロトタイプをユーザーにテストしてもらい、それら結果をまとめて各チームでの成果発表を行いました。

「丸1日コードを書かない講義です」とアナウンスしたときからどんな講義なんだろう?と皆さん気になっていたようでした。実際に取り組んでみて、サービス開発について体系的に学び実践する機会がなかなかないので、貴重な良い経験ができたと好評でした。

後半

後半はPBLコースとOJTコースそれぞれに分かれての実践パートでした。

f:id:fufufukakaka:20210906103359j:plain
講師との壁打ちの様子

f:id:fufufukakaka:20210906104529j:plain
雑談していた様子

PBLでは前週のサービス開発講義の内容を元にして、サービス開発の実習を行いました。「一人暮らしの料理」に関する課題を見出し、それを解決するアプリケーションを提案、実装・デプロイしきる、という工程を5日間という短い時間でやりきるというタイトなものでした。 仮説検証、実装時の技術相談などを社員がサポートしつつ着実に進めていった結果、最終的にはほぼ全員がデプロイまでやりきり、無事に成果を発表できていました。

f:id:fufufukakaka:20210906103047j:plain
PBL成果発表の様子、 撮影:WeWork Oceangate Minatomirai

最終講評はCTOの成田、レシピ事業部部長など4名が成果物を真剣に審査しました。実際にそれで課題を解決することができているのか、技術力をどれだけアピールできているか、など複数の視点から評価を行いました。

f:id:fufufukakaka:20210906102845j:plain
講評者のCTO成田、 撮影:WeWork Oceangate Minatomirai

厳正なる審査の結果、技術観点・サービス観点から優秀だった方にはそれぞれ賞を贈らせていただきました。どちらもそれぞれ、特別な賞品を贈呈いたしました。

OJTではクックパッドの各部署に配属され、メンターの指導を受けながらサービス開発を実践してもらいました。レシピサービス、クックパッドマート、基盤系の部署などさまざまな部署に配属された後、みなさんそれぞれのタスクをやり遂げていただきました。 最終日にはPBLコースと合わせて、各自が取り組んだタスクを発表してもらいました。ほとんど全員がPRのマージ、本番環境へのデプロイまでこなしており、発表を聞いていた人全員がみなさんの偉業に驚いていました。

f:id:fufufukakaka:20210906115758j:plain
OJT成果発表の様子、 撮影:WeWork Oceangate Minatomirai

インターンシップを終えて

簡単にではありますが、10日間のサマーインターンシップを振り返させていただきました。 昨年はすべてオンラインでの開催でしたが、今年は後半の実践プログラムについてオンラインとオフィスを選択希望制にしました。昨今の情勢もあり全員にご来訪いただくことは叶わない難しい状況でしたが、恵比寿からみなとみらいに移転した新しいオフィスを、参加者の方々に体感いただく機会を設けられて良かったと感じています。 全体を通じてリモート中心の設計という点は昨年と同様であったため、Zoom、Slack、Kibelaを最大限に活用しました。また、これまでの取り組みを踏まえて、より一層双方向のコミュニケーションを意識しました。具体的にはSlackでpollやreactionによる双方向的でカジュアルなコミュニケーションを織り交ぜたり、Zoom上でブレイクアウトルームを活用したチームごとの成果発表やワークを取り入れる講義などがありました。 今回得られた知見を、次回以降の取り組みにも活かしていきたいと思います。

ノベルティ

f:id:fufufukakaka:20210906103238j:plain
ノベルティセット

f:id:fufufukakaka:20210906103300j:plain

f:id:fufufukakaka:20210906103327j:plain
オフィス招待カード

写真のノベルティセット(アメ、ラムネ、スマートフォンスタンド、マルシェバッグ、うちわ、ステッカー)を事前に送付しました。 また、全日リモート参加を選択された方には、別の機会で改めてオフィス見学に来ていただけるように、オフィス招待カードも送付させていただきました。 この他にサマーインターンシップのロゴが入ったZoomのバーチャル背景用の画像も配布しています。

まとめ

以上が、Cookpad Summer Internship 2021 10 Day Techコースの開催報告です。 ご参加いただいた皆さま、本当にありがとうございました!

今年のサマーインターンシップは終わってしまいましたが、クックパッドでは就業型インターンシップを通年で募集しています。 興味のある方はぜひご応募ください!


iOSDC Japan 2021 に社員7名が登壇します

$
0
0

こんにちは、モバイル基盤部の茂呂(@slightair)です。 発表される前は何も考えていなかったのに、あたらしい iPad mini の紹介ページを見ていたらちょっと欲しくなってきてしまいました。う〜む。

さて、毎年Appleの新製品やらOSアップデートの一般公開やらワクワクすることが続く季節ですが、ついにやってきましたね! iOSDC Japan 2021が今週末 9/17(金)〜9/19(日)、オンラインで開催されます!

iosdc.jp

トークのご紹介

クックパッドは、ゴールドスポンサーをさせていただいております。

今回クックパッドからは7名のトークを採択いただき、登壇することになりました! ここで紹介させてください。

Day1

9/18(土) 11:30〜

Track C(40分)

  • 登壇者: あおい / @aomathwift
  • タイトル: 機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話

fortee.jp

Track D(40分)

  • 登壇者: 生井智司 / @ainame
  • タイトル: App Store用スクリーンショットの自動生成をアラビア語対応してSwiftUIで実装してみた

fortee.jp

9/18(土) 13:30〜

Track E(20分)

  • 登壇者: uzzu / @uzzu
  • タイトル: StoreKit のこれまでとこれから

fortee.jp

9/18(土) 17:35〜

Track A(5分)

  • 登壇者: あつや / @n_atmark
  • タイトル: SwiftUI.Textを使いこなす5分間

fortee.jp

Day2

9/19(日) 10:50〜

Track E(20分)

  • 登壇者: yujif / @yujif_
  • タイトル: 自己管理の夢と Screen Time API

fortee.jp

9/19(日) 11:30〜

Track A(40分)

  • 登壇者: giginet / @giginet
  • タイトル: 大規模なアプリのマルチモジュール構成の実践

fortee.jp

9/19(日) 13:30〜

Track B(20分)

  • 登壇者: いまじん / @mrimjn
  • タイトル: MultipeerConnectivityを使った動画のリアルタイム端末間共有 〜料理動画撮影アプリの事例〜

fortee.jp

After Party iOSDC Japan 2021 を開催します!

またiOSDC の開催後、「After Party iOSDC Japan 2021」というイベントをクックパッド主催で 10/1(金)19:00からオンラインにて開催します!

cookpad.connpass.com

このイベントでは、カンファレンスでは惜しくも採択されなかったトーク、発表を終えての裏話、発表には入りきらなかった話などをお話しします。クックパッドのiOS開発ってどうなっているんだろう?どんな社員が働いているんだろう?という疑問のある方、クックパッドに興味のある方、なんかイベントがあるならとりあえず行くぜ!という方がいましたら、ぜひこちらのイベントにもご参加ください。お待ちしております。

※このイベントは「iOSDC Japan 2021実行委員会」が運営するものではありません。

おわりに

カンファレンスには、他にも多くの社員が参加する予定です。トークに関すること、After Party などに関してご質問やご感想などございましたら、お気軽にお声がけください!

クックパッドでは、iOSのサービス開発に一緒に取り組んでくれる仲間を募集しています。トークを見て少しでも興味を持っていただいた方にはこちらをご参照いただけましたら幸いです。

info.cookpad.com

それでは、カンファレンスでお会いしましょう!

今すぐ ALB のアクセスログをクエリする

$
0
0

クックパッドマートでサーバーサイドなどのソフトウェアエンジニアをしている石川です。

この記事では、クックパッドマートとは全然関係なく、私が正社員として新卒入社する前、技術部 SRE グループで就業型インターンをしていた頃に実装したシステムについてご紹介します。

ALB のアクセスログ

弊社では AWS の Elastic Load Balancing (ELB) を多用しており、Application Load Balancer (ALB) が多くのウェブアプリケーションで利用されています。

ところで ALB はアクセスログを Amazon S3保存することができます。このアクセスログにはアクセス先の IP アドレスやリクエスト URL の他、レスポンスのステータスコード、レスポンスまでにかかった時間、User-Agent などの情報が記録されています。

これらの情報は分析の役に立ちます。ロードバランサーは典型的にはユーザーからのアクセスが最初に到達する場所であるため、たとえばエラーとなったアクセスがどこでエラーになったかの調査に ALB のアクセスログが役に立つ場合があります。

また、他にも社内だと以下の用途で ALB のアクセスログが使われたことがありました。

  • 特定のエンドポイントのレスポンスタイム分布の分析
  • 特定の User-Agent を持つリクエスト数の調査
  • 負荷試験のアクセスパターンを作る際の参考データ
  • 非推奨にしたエンドポイントへのアクセス数の調査
  • 499 Client Closed Request の頻度調査

(ただし、ALB のアクセスログはベストエフォートで記録されていることには注意が必要です。ドキュメントにも “We recommend that you use access logs to understand the nature of the requests, not as a complete accounting of all requests.” と書かれています。)

Athena を使ったクエリ

では実際にアクセスログを分析したいとなったとき、どうすれば良いでしょうか。ログが記録されたテキストファイルをダウンロードして手元でスクリプトを実行することでも分析できますが、このようなやり方を毎回繰り返すのは煩わしいですし、素朴な実装だと実行時間もそれなりにかかります。

そこで、ここでは Amazon Athenaを使う方法について考えます。Athena は S3 に置かれた大量のデータファイル群に対して SQL で柔軟かつ高速にクエリができるサービスです。S3 に置かれている大量の ALB ログを素早く分析したいという今回のケースにはぴったりです。

実際、AWS のドキュメントには Athena で ALB ログをクエリするためにテーブルを作る SQL のサンプルが書かれています: https://docs.aws.amazon.com/athena/latest/ug/application-load-balancer-logs.html

このようにテーブルを作っておくと、たとえば「GET /users の最近のレスポンスタイムの平均」をクエリしたり「POST /a/deprecated/endpoint に最近目立ったアクセスがあるかどうか」をクエリしたりでき、便利です。具体的には以下のような SQL を書くことになります *1

selectcount(*) as log_count
    , date(from_iso8601_timestamp(logs.time)) as log_date
from
    alb_access_logs.cookpad as logs
wheredate(from_iso8601_timestamp(logs.time)) >= date'2021-09-01'and logs.request_verb = 'POST'and logs.request_url like'%/a/deprecated/endpoint'groupby2orderby2

注意点として、Athena の料金はクエリ時にスキャンされたデータサイズについての従量課金制です。不必要に過去のログすべてに渡ってスキャンすると無駄に課金されてしまいます。クエリにかかる時間を考えても無駄です。

そこでパーティションを利用します。ALB ログが保存されている S3 key には year/month/day が含まれているので、ここについてパーティションを作り、クエリ時に year/month/day について絞り込むことでスキャンサイズを落とすことができます。*2

とはいえ、ALB ログを分析したくなる度にその ALB について Athena でテーブルを作ってパーティションを作って……とするのは面倒です。あらかじめ作ってあった方が便利ですし、日常的な調査をより機敏に行うことができるでしょう。

ということで ALB ログをいつでも Athena で分析できるようにこのあたりを自動化しよう、というのが、私がインターンで取り組んだタスクでした。

テーブルおよびパーティション作成の自動化

さて、それでは自動化いたしましょう。やることは単純で「まだテーブルが作られていない ALB についてテーブルを作り、まだ作られていないパーティションについてパーティションを作る」というバッチを実装すれば良いです *3。今回パーティションは year/month/day 単位で付けようとしているため、実装したバッチは日次で実行すれば良いでしょう。

実装について考えます。テーブル作成部分については Athena で行う方法を先述しましたが、今回のバッチでは Athena の API を使うのではなく、Athena と統合して利用できるAWS Glueの API を使うことにしました。これは、生の SQL を実行することになる Athena よりも「テーブルを作る」などの操作ごとに API が用意されている Glue の方がより細かく権限管理できるためです。また今回の使い方だと Glue の利用にかかる料金は非常に安く、費用面でも問題になりませんでした。

この方針で、テーブル作成とパーティション作成を行ってくれるバッチを Ruby で実装しました。実装するにあたって一番複雑であろう、ログをパースするための正規表現が先述のとおり AWS のドキュメントに掲載されているため、後は AWS SDK を使って実装していくのみでした。このバッチは、社内のバッチ実行基盤である kuroko2を使って日次で実行されるように設定しました。

このように作成したバッチは私の就業型インターンの期間中に運用が始まり、現在に至るまで特に大きな問題もなく動き続けています。

まとめ

この記事では、ALB のアクセスログを Athena でクエリしやすくするためにバッチを書いた話をご紹介しました。このシステムによって、日々の業務の中でほんの時々必要となるちょっとした作業を減らすことができました。同時に、Athena や Glue にそこまで詳しくなくても SQL がある程度書ければアクセスログをクエリできるという状態を作ることもできました。

このように、インターンの中で現実の問題を解決でき、社内のエンジニアリング環境を少し向上できた、面白いインターンであったと今更ながら考えています。

最後に、クックパッドでは、サービス開発や基盤開発にチャレンジする就業型インターンを通年募集しています。気になった方は是非ウェブサイトよりご応募ください:

info.cookpad.com

*1:すぐ下に書いてあるように、更にパーティションについても絞り込む必要はあります。

*2:更に、ALB ログは 1 行 1 アクセスログで保存されているテキストファイルなので、Parquet などのデータフォーマットに変換することでよりスキャンサイズを落とせる可能性があります。ただし今回は変換にかかる金銭コストと ALB ログへのクエリ頻度を天秤にかけ、この変換までは行っていません。

*3:もっと言うと不要になったテーブルやパーティションを削除しても良いです。

DroidKaigi 2021にクックパッド社員が登壇します &アフターイベントを開催します!

$
0
0

こんにちは、モバイル基盤部の茂呂(@slightair)です。

エンジニアが主役のAndroidカンファレンス、DroidKaigi 2021 の開催がいよいよ来週にせまってきました。 今年は10/19(火)から3日間、オンラインで開催されます。

droidkaigi.jp

クックパッドは、本カンファレンスにゴールドスポンサーとして協賛しています。 そして、クックパッドに所属するこやまカニ大好きが登壇します。登壇スケジュールと内容を紹介させてください。

登壇情報

Day2

10/20(水) 14:40〜 (25分)

2020年代の WebView 実装 / こやまカニ大好き

Jetpack Compose 時代になってもおそらく無くならない、古くて最新の View の話をします。

歴史あるアプリには大抵、 WebView でしかアクセスできない画面や機能があると思います。 WebView で開くページにセッション情報を引き継ぐための認証の仕組みや、カスタム JavaScript を動かすための仕組みもあるかもしれません。 WebView の履歴を Fragment 内で goBack()するための特殊な実装や、ドキュメントには存在しない謎の openFileChooser()メソッドを見かけた開発者もいるかもしれません。

本セッションでは8年の歴史を持つクックパッドアプリの秘伝の WebView 実装を最新化した経験を元に、現在の WebView 実装のベストプラクティスと古い WebView の改善方法について説明します。

発表者のコメント

こやまカニ大好きです。好きなカニはトラフカラッパです。 本セッションでは歴史あるクックパッドアプリの経験を元に、WebViewとうまく付き合っていくための工夫についてお話します。 WebViewが好きな方も嫌いな方も興味がない方もWebViewとの付き合いは止められないと思うので、この機会にぜひWebViewの話を聞いてみて下さい。

After Party DroidKaigi 2021 を開催します!

また DroidKaigi の後夜祭として、「After Party DroidKaigi 2021」というイベントをクックパッド主催で10/28(木) 19:00 からオンラインにて開催します!

cookpad.connpass.com

本イベントは YouTube Live にて配信し、アーカイブ視聴も可能にする予定です。ご都合のつく形での参加をぜひご検討ください。

イベントではクックパッド社員がカンファレンスでは採択されなかったトークを発表したり、DroidKaigi に参加してどんなことを感じたか皆さんのコメントや質問を拾いながらお話しする予定です。 DroidKaigi の余韻に浸りたい、発表を見てクックパッドのAndroid開発についてもっと知りたいという方がいましたら、ぜひこちらのイベントにもご参加ください。お待ちしております。

※ 本イベントはDroidKaigi実行委員会より名称利用の許可を受け、DroidKaigi 2021に協賛しているクックパッド株式会社が主催するものです。

おわりに

カンファレンスには、多くの社員が参加する予定です。トークに関すること、After Party などに関してご質問やご感想などございましたら、お気軽にお声がけください!

クックパッドでは、Android のサービス開発に一緒に取り組んでくれる仲間を募集しています。トークを見て少しでも興味を持っていただいた方にはこちらをご参照いただけましたら幸いです。

info.cookpad.com

カジュアル面談も行っていますので、クックパッド社員と話をしてみたいという方はお気軽にお申し込みください。

docs.google.com

それでは、カンファレンスでお会いしましょう!

クックパッドマートの多種多様な商品名から、扱いやすい「食材キーワード」を予測する

$
0
0

研究開発部の山口 (@altescy) です.今回は最近開発したクックパッドマートの商品の「食材キーワード」を予測する機械学習モデルを紹介します.

商品の食材キーワード予測とは?

クックパッドマートでは日々様々な食材が多くの販売者から出品されています.出品される商品の情報は販売者によって登録されるため,多様な表記が存在します.「じゃがいも」の商品名を例に挙げると,「ジャガイモ」「じゃが芋」といった表記の揺れや,「メークイン」「インカのめざめ」といった品種名が書かれているもの,「農家直送」や「お徳用」のようなキャッチコピーがついたもの,など様々です.一方で,商品の検索や推薦を行う際にはその商品がいったい何なのかを簡潔に表す情報が欲しくなります.

そこで登場するのが「食材キーワード」です.商品名や商品説明とは別に,その商品がどんな食材なのかを表すキーワードを設定しておくことで,商品名の表記揺れによる検索精度の低下を抑えたり,その食材を利用したレシピの提案をしたりするなどの応用が可能になります.

設定すると便利な食材キーワードですが,出品されている商品は非常に多く種類も多様であるため,これまで出品されてた商品に対して手動でひとつずつ設定していく作業は大きな負担となります.そこで今回は商品の食材キーワードの設定作業を支援するために,商品名から食材キーワードの候補を予測する機械学習モデルの開発を行いました.

データセットの準備

機械学習モデルを学習・評価するために約5,000件の商品に対して食材キーワードのアノテーションを行いました.その結果,作成したデータセットに含まれるユニークな食材キーワードの数は1,300以上あり,その分布もロングテールであることがわかりました.データセットのサイズに対してキーワードの数が多く,事例が2〜3個しかないキーワードも多いため,通常の分類モデルを用いた手法で十分な精度を出すことは難しそうです.

一見難しそうな分類問題に見えますが,実際にデータを見てみると商品名の中には食材キーワードに対応する言葉が入っている場合がほとんどです.例えば,食材キーワードとして「にんじん」が設定された商品名をみると「にんじん」「人参」「キャロット」などの単語が入っていたり,「えび」がキーワードに設定されている商品名なら「エビ」「海老」あるいは「ブラックタイガー」のような単語が含まれています.

このような性質から,食材キーワードを単なるラベルとして予測するよりも,商品名と食材キーワードの類似性や対応関係を考慮して予測を行った方が良い結果が得られそうです.そこで,今回は商品名とキーワードの類似性に基づいて 距離*1を学習するようなモデルを試すことにしました.

食材キーワード予測モデル

モデルの仕組み

作成したモデルは以下の図のような構造です.商品名・キーワードをそれぞれニューラルネットワークを用いてベクトルに変換して,ベクトル同士の距離を商品名・キーワード間の距離として利用します.

モデルの構造
モデルの構造

処理の流れ

  1. 商品名の前処理 (全角・半角を揃える,数値やストップワードの削除など)
  2. 商品名・キーワードを単語に分割
  3. 2で分割した単語を fastText を用いて対応する単語ベクトルに変換
  4. 3で作った商品名・キーワードの単語ベクトル系列をそれぞれ LSTM を用いて変換
  5. 4で変換した商品名の単語ベクトルを,キーワードの単語ベクトル列を使って重み付け (後述)
  6. 商品名・キーワードの単語ベクトル系列をそれぞれ平均して商品名・キーワードのベクトルを作る
  7. 6で作った商品名・キーワードのベクトル同士のコサイン距離を商品名とキーワードの距離とする

fastText はクックパッドで公開されている350万レシピを用いて事前に学習したものを利用しました.

学習手法

ペアになる商品名・キーワード同士の距離が近くなるようにモデルの学習を行います.ただし,単純にペア同士の距離が近づくように学習するとすべての商品名・キーワードを同じ1点に集めれば距離を0にできてしまうため,負例サンプリングを行ってペアにならない商品・キーワード同士は距離がなるべく離れるように学習します.

商品名の単語の重み付け(注意機構)について

商品名の単語の重み付け(注意機構)について詳しく説明します.

商品名の単語に対して重み付けをする動機は,商品名に含まれている「◯◯県産」や「農家直送」など,キーワード予測においては本質的でない単語の影響をなるべく取り除きたいからです.簡単なものであれば前処理の段階でルールに基づい て取り除くことができますが,「昔ながらの...」や「肉の日限定10%オフ!」など,商品名に含まれるパターンは多様であり,事前にルールのみで対処することはなかなか困難です.

そこで,今回のモデルではキーワードとの類似度を利用して商品名の単語に対する重みを計算し,キーワードに関連のある単語の影響が強くなるようにしました.下の図が商品名の単語の重みを計算する様子です.

単語の重み付けの仕組み
単語の重み付けの仕組み

商品名とキーワードの単語同士で類似度を計算したあと,キーワードの単語方向に平均した結果が商品名の単語の重みになります.こうすることでキーワードに含まれる単語に似た単語の重みは大きくなり,関連性の低い単語は相対的に重みが小さくなります.例えば,この図では「カラー」や「にんじん」といった単語の重みが大きくなり,「淡路島」などキーワードと関連性の低い単語の影響を小さくできそうです.

モデルの精度

アノテーションしたデータのうち,約1,500件をテストデータとしてモデルを評価した結果が以下の表になります.LSTM分類器はベースラインとして fastText + LSTM で商品名から直接食材キーワードを分類したモデル,距離学習モデルが上で述べた手法,距離学習モデル(注意機構なし)が距離学習モデルから注意機構による単語の重み付けをなくしたモデルになります.

Accuracy@1 Accuracy@5 MRR
LSTM分類器 62.5 72.5 67.1
距離学習モデル (注意機構なし) 75.2 91.3 82.3
距離学習モデル 78.5 95.5 85.7

最も精度の高い距離学習モデルの上位5件の正解率 ( Accuracy@5 ) は 95.5% でした.この結果から,最も関連があると予測されたキーワード数個を選んだ場合に高い割合で正解のキーワードを含むモデルになっていることがわかります.

距離学習モデルの結果をLSTM分類器のものと比較すると Accuracy@5 では +20% 以上の改善がみられました.これは商品名と食材キーワードの類似性を捉える距離学習の仕組みが役に立ったと言えそうです.

また,注意機構ありとなしの場合で比較してみるとそれぞれの指標で数ポイントずつですが注意機構を導入した場合の方が良い結果が得られました.

考察

今回作成したモデルの性質についてわかったことをまとめます.

まず、良いと感じた性質について述べます:

  • 表記揺れの吸収: レシピデータで学習したfastTextは,その単語が使われる文脈に含まれる他の単語との共起性に基づいて作られるため,「人参/にんじん」や「鯵/アジ/あじ」といった表記の揺れを吸収することができます.また「サンふじ/りんご」や「五郎島金時/さつまいも」のような品種名と一般名詞との関係もある程度考慮できているようです.
  • キーワードの拡張性: このモデルはキーワードを直接予測する分類モデルと異なり商品名とキーワードをそれぞれ与えて距離を測るため,再学習することなくキーワードを拡張することができます.もちろん予測したいキーワードが学習データに含まれていた方が好ましい結果が得られやすいとは思いますが,学習データに含まれていないキーワードであってもある程度妥当な距離が計算できそうです.

一方で,このモデルにはいくつかの課題があると感じています:

  • 推論速度: このモデルは商品名が与えられたときにキーワード群の全てのキーワードとペアを作って距離を計算する必要があります .そのため,キーワード数や推論対象の商品が多くなった場合には計算量が多くなり推論速度の点でボトルネックになりそうです.
  • ハブネス問題: fastTextでは多くの文脈で登場する単語ベクトル同士は互いに近くなります.そのため高頻度で現れる単語が商品名 ・キーワードに含まれていると,商品の性質に関わらず互いに近くなる傾向があります.例として「白」が含まれる単語同士は互いに近くにあるらしく「白ワイン」に対する推薦結果に「白なす」や「白玉ねぎ」が含まれたりします.このように区別したい性質を超え て多くのデータ点と近いベクトルが現れることは「ハブネス問題」と呼ばれていて,特に高次元ベクトルの近傍を利用するモデルにおいて課題になることが知られています(Radovanović+, 2010)

食材キーワード予測モデルの活用

今回作成した食材キーワード予測モデルは,現在社内向けサービスとして食材キーワードの設定を支援するために利用しています.予測結果の上位5件をキーワード設定フォームの上部にボタンとして表示し,これをクリックすると選択したキーワードが入力される,という感じです.設定者が適切な候補を考える手間が省けるため,効率的に食材キーワードの設定ができるようになったと思います.

食材キーワードの設定画面
食材キーワードの設定画面

まとめ

クックパッドマートの商品の食材キーワードを予測するタスクについて紹介しました.大量のレシピデータで学習した fastText と注意機構を活用することで,比較的小規模なデータセットであっても1,000クラス以上の分類問題をある程度の精度で解くことができるようになりました.

今回作成したモデルは現在社内向けのサービスとしてのみ利用していますが,今後は販売者向けの管理画面などにも利用できるようにモデルの計算量の削減や予測性能性能の向上を模索したいと思います.

*1:注意機構が商品名と食材キーワードで非対称なので厳密には距離ではないですね

UINavigationControllerをカスタマイズ 〜OSの影響を受けづらいカスタムナビゲーションの実装〜

$
0
0

こんにちは、モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS 15とXcode 13がリリースされました。最新のiOS SDKでビルドしてみたら、カスタマイズされたナビゲーションバーに修正が必要だったアプリが少なくなかったようです。しかし、iOS版のクックパッドアプリでは大きくカスタマイズされているナビゲーションバーを使ってはいるものの、iOS 15に合わせてナビゲーションバーに手を入れる必要は特になかったです。

iOS版のクックパッドアプリは最近様々な形のナビゲーションバーを使っています。例えばおすすめタブはスクロールするとナビゲーションバーの高さが変わります。

f:id:vincentisambart:20211027155756p:plain:w100f:id:vincentisambart:20211027155853p:plain:w100f:id:vincentisambart:20211027160039p:plain:w100

また、さがすタブは画面によってナビゲーションバーの中身や高さが違いますし、レシピ詳細ではスクロールするとレシピ名がナビゲーションバーに入ります。

f:id:vincentisambart:20211027160107p:plain:w100f:id:vincentisambart:20211027160124p:plain:w100f:id:vincentisambart:20211027160140p:plain:w100f:id:vincentisambart:20211027160153p:plain:w100

なぜiOS版のクックパッドアプリには修正が必要なかったのでしょうか。 この記事では、OSの変更の影響をあまり受けない大きくカスタマイズされたナビゲーションバーをiOS版のクックパッドアプリでどうやって実装したのか説明しようと思います。でもその前に、大切な注意事項があります。

注意事項

iOSの標準のナビゲーションバーは大きくカスタマイズされるように作られていません。Appleが用意した限られた設定以上にカスタマイズしようとすると、OSが更新されるたびに壊れやすいです。

正直にいうと、ナビゲーションバーのカスタマイズをおすすめできません。この記事で紹介している仕組みは壊れるリスクが低いと思いますが、今後どうなるのか分かりません。

iOSクックパッドのナビゲーションバーの歴史

iOSクックパッドでいまのナビゲーションバーの実装に至るまで、仕組みが何回か変わりました。

最初の仕組み

僕が2014年に入社した時には、カスタムなナビゲーションバーが既に実装されていました。カスタマイズされていたのは見た目とサブビューの配置でした。なぜ配置のカスタマイズが必要かと言いますと、iOS標準のUINavigationBarの真ん中にtitleViewを入れるとき、そのtitleViewがあまり大きくなりません。なので、真ん中に大きい検索ボックスを入れたければ、カスタマイズする必要です。

どうやって実装されていたと言いますと、ナビゲーションバーのボタンの作成をシステムに任せるけど、layoutSubviewsでシステムの決めたボタンの配置を変えていました。

改修

上記の仕組みはOSの更新で調整が定期的に必要でした。Xcode 9 (2017)のiOS 11 SDKでアプリをビルドした時、ナビゲーションバーがまた壊れて、もう少し壊れにくい仕組みを実装できないのか挑戦してみました。

新しい仕組みでは、システムの作成したボタンは今回触れないで、その上に載せたビューで隠して、自分の作成したボタンをさらに上に載せていました。OSの扱っているtitleViewも触れたくないので、UINavigationItem.titleViewを使っていた画面に少し不自然なワークアラウンドが必要でしたが、結果的に狙い通り以前の仕組みより頑丈でした。

最新の仕組み

2019年の上半期のデザイン案に半透明なナビゲーションバーを導入したい要望が現れました。以前の仕組みでは、システムのものの上にビューやボタンを載せているので、それを透過させると、システムのものが見えてしまいます。システムのものをいじって完全に透過させたら実装できたかもしれませんが、最初の仕組みのようなもっと壊れやすい状態に戻ってしまいそうでした。

少し前から考えていたアイデアを試すきっかけに見えました。どういうアイデアかといいますと、UINavigationControllerは使うけどUINavigationBarは使わないことです😁。実装は試行錯誤で何週間も掛かりましたが、いま使われている仕組みができました。

なぜUINavigationControllerは使うのか

UINavigationControllerは使うけどUINavigationBarは使わない」といったうちのなぜUINavigationControllerを使うのか、という部分を説明します。

UINavigationControllerを使わないで、ゼロからナビゲーションコントローラーを独自実装した方が壊れにくいのではないでしょうか。ゼロから作って挙動をシステム標準に合わせるのがとても大変ではありますし、その上でOS標準のビューコントローラーにはAppleしか実装できないところがあります。

分かりやすいところでいうと、UIViewController.navigationControllerはシステムが提供しているものです。どうしてもというなら一応swizzlingを使って挙動を変えることはできるかもしれないけど、色々壊れるリスクがあるし、戻り値はUINavigationControllerでなければいけません。代わりに自分でUIViewController.myCustomNavigationControllerのような似たメソッドを用意できるけど、既存のコードを変えなければいけません。

その他に、UINavigationControllerと全く同じ標準のアニメーションや遷移中のシャドーを再実装するのも大変そうでした。アニメーションはできるだけシステムに任せたいです。

なぜUINavigationBarは使わないのか

UINavigationBarを自由にカスタマイズできないなら、使わなければ良いだけです。UINavigationControllersetNavigationBarHidden(_:animated:)がまさにそのためにあります。

UINavigationBarを使わないといっても、多くの画面でナビゲーションバーが表示されてほしいので、ナビゲーションバー相当の機能を普通のUIViewControllerにやらせます。そのナビゲーションバー相当ののビューコントローラーをナビゲーションコントローラーの中に表示したいので、ナビゲーションスタックには画面本来のビューコントローラーが直接入るのではなく、ナビゲーションバー相当のビューコントローラーと、画面本来のビューコントローラー両方ともを管理するラッパービューコントローラーが入ります。

画面3つがプッシュされてあるナビゲーションコントローラーの親子関係は以下のようなイメージです。

NoBarNavigationController
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController1
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController2
 |
 +- FixedHeightToolbarProvidingContainerViewController
     +- EmbeddedNavigationToolbarViewController
     +- ScreenViewController3

既存のコードをあまり変えたくないし、ビューコントローラーをプッシュするたびに手動でFixedHeightToolbarProvidingContainerViewControllerにラップする必要があったら面倒なので、ラップは自動的にやる仕組みが必要です。

では実装に入りましょう。量が多いので、クラスと機能で以下のように分けました。

  • ナビゲーションバーを使わないナビゲーションコントローラーNoBarNavigationController
    • NoBarNavigationController.init
    • NoBarNavigationController.viewDidLoad
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のdelegateの扱いと経緯
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のナビゲーションバーを隠すisNavigationBarHidden
      • スワイプで戻るジェスチャーを扱うinteractivePopGestureRecognizer
    • ナビゲーションコントローラーにプッシュされるビューコントローラーを自動的にコンテナーにラップする仕組み
      • ラップを希望しないと示すプロトコルAdditionalToolbarNotNeeded
      • どうラップされたいのか明記できるプロトコルAdditionalToolbarNeeded
      • AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合
    • UINavigationControllerDelegateの準拠の詳細
  • ナビゲーションコントローラーにプッシュされるビューコントローラーをラップして、ツールバーをその上に入れくれるコンテナーFixedHeightToolbarProvidingContainerViewController
  • ツールバー自体の表示
    • ツールバーを管理しているビューコントローラーEmbeddedNavigationToolbarViewController
    • EmbeddedNavigationToolbarViewControllerのビューEmbeddedNavigationToolbar

ナビゲーションコントローラー

最初に見るのは肝心のナビゲーションコントローラー自体です。

注意:ここで紹介する実装は最初からこうできたわけではなく、ここに辿り着くには試行錯誤で時間かかりましたし、使ってみたら見つけた細かい問題の修正も入っています。

NoBarNavigationController.init

まずは、initの定義に不自然なところがあまりないと思います。気になるであろうwrapIfNeeded()は後ほどで説明します。init?(coder:)は需要が特になかったので実装されていません。

publicfinalclassNoBarNavigationController:UINavigationController {
    overridepublicinit(rootViewController:UIViewController) {
        letwrappedRootViewController=Self.wrapIfNeeded(rootViewController)
        super.init(nibName:nil, bundle:nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

NoBarNavigationController.viewDidLoad

もっと興味深いところ、viewDidLoadの実装を見てみましょう

privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
        super.viewDidLoad()
        delegate =self// このナビゲーションコントローラーが自分のナビゲーションバーをもっていません。// 必要であれば、プッシュされるビューコントローラーが別のビューコントローラーにラップされて、// その別のビューコントローラーがナビゲーションバー相当の機能を提供してくれます。
        isNavigationBarHidden =true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)

        ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
            // 戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、// `UINavigationController`が自分の`interactivePopGestureRecognizer`を無効にしています。// 改めて有効にするために、自作の`delegate`を代入します。
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

コードが長いわけでもないのですが、だいぶ複雑なので、細かく見てみましょう。

delegate

overridepublicfuncviewDidLoad() {
        super.viewDidLoad()
        delegate =self

まず自分を自分のdelegateにしています。delegatenavigationController(_:willShow:animated:)のタイミングでやりたい処理があるので、こうするしかありませんでした。delegateのメソッドでやることはあとで説明します。

delegateを自分で使っているけど、アプリが別の用途でdelegateを使いたい時もあるので、delegateが間違って上書きされないようにassertを入れておきましたし、別のdelegate(additionalDelegate)を設定できるようにしました。

publicweakvaradditionalDelegate:NoBarNavigationControllerDelegate?overridepublicvardelegate:UINavigationControllerDelegate? {
        didSet {
            assert(delegate ===self, "delegateが必要であれば、additionalDelegateをご利用ください")
        }
    }

additionalDelegateの使っているNoBarNavigationControllerDelegateにはこのナビゲーションコントローラーが対応しているUINavigationControllerDelegateからとったメソッドが入っているだけです。

publicprotocolNoBarNavigationControllerDelegate:AnyObject {
    funcnoBarNavigationController(_ navigationController:NoBarNavigationController, willShow viewController:UIViewController, animated:Bool)
    funcnoBarNavigationController(_ navigationController:NoBarNavigationController, didShow viewController:UIViewController, animated:Bool)
}

viewDidLoad()の中で、delegate代入の次はナビゲーションバーを隠します。

isNavigationBarHidden

privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
        // (中略)
        isNavigationBarHidden =true

UINavigationBarを使わないと既に説明したので、isNavigationBarHidden = trueは自然だと思います。ただし、isNavigationBarHiddenが何かの理由でfalseに戻されたら、変な表示になりそうですね。もともと間違った変更を防ぐためにassert()を入れてありましたが、SwiftUIのビューが入ったUIHostingControllerをプッシュしてみたら、そのassert()が引っかかっていました。SwiftUIで明示的にナビゲーションバーを隠すようにしても、SwiftUIが一瞬表示したがっているので、強引ではありますが、有効にできないようにするしかありませんでした。

overridepublicfuncsetNavigationBarHidden(_ hidden:Bool, animated:Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated:animated)
        }
    }

var isNavigationBarHidden: Boolは裏でsetNavigationBarHidden(newValue, animated: false)を読んでいるだけみたいなので、overridesetNavigationBarHidden(_:animated:)だけで良さそうです。

viewDidLoad()の中で、ナビゲーションバーを隠したあとにinteractivePopGestureRecognizerに手をつけます。

interactivePopGestureRecognizer

privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
        // (中略)
        interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)

        ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

ここのinteractivePopGestureRecognizerの扱いがUINavigationControllerの細かい挙動に依存していて、この実装の一番壊れやすい部分の気がします。とはいえ、試してみたどのiOSバージョンでも問題なさそうでした。

UINavigationControllerは戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、自分のinteractivePopGestureRecognizerを無効にしています。このinteractivePopGestureRecognizerがスワイプで前の画面に戻る動作を扱うUIGestureRecognizerです。

ナビゲーションバーが隠れて、無効になったinteractivePopGestureRecognizerdelegateを自分で設定すると、改めて有効になります。

UIGestureRecognizerDelegateの準拠はNoBarNavigationController自身ではなく、別のクラスにしたのは、UINavigationControllerがやっていることとぶつかるリスクを最低限にしたかったからです。この準拠を見てみましょう。

privatefinalclassInteractivePopGestureHandler:NSObject, UIGestureRecognizerDelegate {
    // 循環参照を避けるために`weak`weakvarnavigationController:UINavigationController!init(controller:UINavigationController) {
        navigationController = controller
    }

    funcgestureRecognizerShouldBegin(_ gestureRecognizer:UIGestureRecognizer) ->Bool {
        return navigationController.viewControllers.count >1
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer:UIGestureRecognizer) ->Bool {
        // 表示されているビューコントローラーにスクロールビューが入っているとき、// スワイプで前の画面に戻ろうとしていると同時に指を上下に動かすと、スクロールビューも上下にスクロールしないためにreturnfalse
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer:UIGestureRecognizer) ->Bool {
        // ウェブビューが最初の読み込み中、そうしないと読み込みが終わるまでスワイプで前の画面に戻れませんreturn otherGestureRecognizer isUIPanGestureRecognizer
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldReceive touch:UITouch) ->Bool {
        returntrue
    }
}

このinteractivePopGestureRecognizerの扱いが壊れやすいなら、なぜ自分で作成した新しいジェスチャーレコグナイザーを使わなかったのでしょうか。残念ながら、そうしようとすると、複雑そうな遷移のアニメーションの扱い全部を再実装しなければいけません。難易度と大変さがグンと上がります。その上で、調べてみた時、子ビューコントローラー間の遷移のアニメーションに関するドキュメントが少なくて、本当に自分で完全に実装できるか疑問だった部分もありました。既存のinteractivePopGestureRecognizerを使った方が良いという結論に至りました。

ラップ

ナビゲーションコントローラー自体に戻って、NoBarNavigationController.initの話をした時に飛ばしたナビゲーションスタックに入るビューコントローラーのラップの仕組みの話をしましょう。

initに渡されたビューコントローラーはSelf.wrapIfNeeded(_:)を使ってラップしていましたが、他の方法で挿入されるビューコントローラーもラップされます。

overridepublicfuncsetViewControllers(_ viewControllers:[UIViewController], animated:Bool) {
        letwrappedViewControllers= viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated:animated)
    }

    overridepublicfuncpushViewController(_ viewController:UIViewController, animated:Bool) {
        letwrappedViewController=Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated:animated)
    }

関心の処理をしているwrapIfNeeded(_:)を見てみましょう。気をつけるべき点はwrapIfNeeded(wrapIfNeeded(viewController))wrapIfNeeded(viewController)と同じ値を返すべきところです。そうでないと、viewControllers配列に変更を加えるとき、変えていないビューが二重にラップされる可能性が出てきます。

privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
        letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
            assert(!(originalViewController isAdditionalToolbarNeeded), "AdditionalToolbarNeededとAdditionalToolbarNotNeeded両方に準拠していて矛盾がある")
            // ラップする必要がありません
            viewController = originalViewController
        } elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // ビューコントローラーに特別な指定がないので、ツールバーをつけておきます
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded:originalViewController,
                toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
            )
        }

        // wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)を保証
        assert(viewController isAdditionalToolbarNotNeeded, "戻り値がAdditionalToolbarNotNeededに準拠していないとwrapIfNeeded(wrapIfNeeded(viewController))で二重ラップが起きる恐れがある")
        return viewController
    }

まだ話していないプロトコルが2つ登場しています:AdditionalToolbarNotNeededAdditionalToolbarNeeded。ここで「ツールバー」はナビゲーションバー相当のものです。命名は「NavigationBar」ではなく「Toolbar」にしたのは本物のナビゲーションバー(UINavigationBar)と区別をつけるためです。

AdditionalToolbarNotNeeded

privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
        // (中略)if originalViewController isAdditionalToolbarNotNeeded {

ビューコントローラーがAdditionalToolbarNotNeededに準拠していると、ツールバーをつけるべきではないと意味します。画面全体で表示したいビューコントローラーでも使えますが、一番のユースケースはツールバーをつけてくれるラッパービューコントローラーです。そのラッパービューコントローラーはAdditionalToolbarNotNeededに準拠することで二重ラップされるのを防ぎます。

定義がとてもシンプルで、メソッドがありません。

publicprotocolAdditionalToolbarNotNeeded:UIViewController {}

AdditionalToolbarNeeded

privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
        letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
            // (中略)
        } elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()

AdditionalToolbarNeededはどのビューコントローラーにラップされてほしいのか明示的に指定するためのプロトコルです。メソッドはwrapInContainer()1つだけです。wrapInContainer()の中で自分をラップしているラッパービューコントローラーを作成して返すだけです。メソッドが1つだけだけど、その戻り値にまだ登場していなかったプロトコルも使われています。

publicprotocolAdditionalToolbarNeeded:UIViewController {
    funcwrapInContainer() ->AdditionalToolbarProvidingContainer
}

publicprotocolAdditionalToolbarProvidingContainer:AdditionalToolbarNotNeeded {
    varprovidedToolbarViewController:UIViewController { get }
    varembeddedViewController:UIViewController { get }
}

AdditionalToolbarProvidingContainerAdditionalToolbarNotNeededを必要にしているのはAdditionalToolbarNotNeededの話をした時に話した通り二重ラップを防ぐためです。

AdditionalToolbarProvidingContainerにあるprovidedToolbarViewControllerembeddedViewControllerはラッパー(別名コンテナー)に入った2つのビューコントローラーを直接取り出すためです:providedToolbarViewControllerはツールバーを表示してくれるビューコントローラーであって、embeddedViewControllerはラップされている画面の本来のビューコントローラーです。

AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合

privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
        letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
            // (中略)
        } elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
            // (中略)
        } else {
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded:originalViewController,
                toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
            )
        }

ラップされるビューコントローラーがAdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないときに使われるFixedHeightToolbarProvidingContainerViewControllerはもちろんAdditionalToolbarProvidingContainerに準拠しています。

AdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないのは以下のようにAdditionalToolbarNeededに準拠している場合と同じ挙動にです。

extensionMyScreenViewController:AdditionalToolbarNeeded {
    funcwrapInContainer() ->AdditionalToolbarProvidingContainer {
        return FixedHeightToolbarProvidingContainerViewController(
            embedded:self,
            toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:self)
        )
    }
}

UINavigationControllerDelegate

ナビゲーションコントローラーに関してまだ残っているのはあとUINavigationControllerDelegateの準拠だけです。

extensionNoBarNavigationController:UINavigationControllerDelegate {
    publicfuncnavigationController(_ navigationController:UINavigationController, didShow viewController:UIViewController, animated:Bool) {
        assert(self== navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow:viewController, animated:animated)
    }

navigationController(_:didShow:animated:)additionalDelegateの同じメソッドを呼んでいるだけです。

publicfuncnavigationController(_ navigationController:UINavigationController, willShow viewController:UIViewController, animated:Bool) {
        assert(self== navigationController)

        // 遷移の前後が`FixedHeightToolbarProvidingContainerViewController`のインスタンスの場合のみ、カスタムなトランジションを使いますif animated,
           lettransitionCoordinator=self.transitionCoordinator,
           letsource= transitionCoordinator.viewController(forKey: .from) as?FixedHeightToolbarProvidingContainerViewController,
           letdestination= transitionCoordinator.viewController(forKey: .to) as?FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from:source,
                to:destination,
                inside:self,
                coordinatedBy:transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow:viewController, animated:animated)
    }
}

navigationController(_:willShow:animated:)も匹敵するadditionalDelegateのメソッドを呼んでいますが、その前に遷移がFixedHeightToolbarProvidingContainerViewControllerからFixedHeightToolbarProvidingContainerViewControllerへの場合のみ、特別なアニメーションの準備をします。

iPhoneを手にとってください。Apple標準のアプリ(例えば設定アプリ)でも、サードパーティーのいくつかのアプリでも、ナビゲーションコントローラーで遊んでみてください。アニメーションに気をつけながら、ビューコントローラーをプッシュして、スワイプで前の画面をゆっくり戻って、改めてプッシュして、を繰り返してみましょう。よく見ると画面間のトランジションが意外と複雑です。設定アプリのトップ画面のようにナビゲーションバーのタイトルが画面のビューコントローラー自体に溶け込んでいる場合は普通のナビゲーションバーとまた少し違います。ナビゲーションバーをカスタマイズしている一部の第三者アプリでトランジションがスムーズでない時もあります。

この記事のようなナビゲーションバーのないナビゲーションコントローラーの場合、特別なことをしない限り、ツールバーがシステムにとって表示されているビューコントローラーの一部でしかないので、トランジションはビューコントローラー全体が滑り込むだけです。そこまで悪くもないのですが、もう少しこだわれると思います。標準のナビゲーションバーのトランジションが複雑なので、結局iOSクックパッドではフェードイン・フェードアウトだけにしました。ナビゲーションバーの高さが変わる場合がさらに複雑なので標準の全体滑り込むアニメーションだけになります。詳細は後ほどFixedHeightToolbarProvidingContainerViewControllerの話をする時にしましょう。

NoBarNavigationControllerはこれですべてのコードに目を通したので、次はデフォルトで使われるラッパー/コンテナーを見ようと思います。

FixedHeightToolbarProvidingContainerViewController

FixedHeightToolbarProvidingContainerViewControllerはデフォルトで使われるコンテナーです。ラップされているビューコントローラーとその上のツールバーの表示・管理をしているビューコントローラーを束ねているだけです。FixedHeightToolbar命名の通りツールバーの高さが作成時に決まって、その後に変わることがありません。透過のツールバーも対応されていません。iOSクックパッドでは透過しているツールバーは別のコンテナーが使われますが、基礎は同じです。

構成は本当にシンプルです。

publicfinalclassFixedHeightToolbarProvidingContainerViewController:UIViewController {
    privateletembedded:UIViewControllerprivatelettoolbarViewController:FixedHeightToolbarViewControllerpublicinit(
        embedded:UIViewController,
        toolbarViewController:FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName:nil, bundle:nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent:self)

        addChild(embedded)
        embedded.didMove(toParent:self)
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

toolbarViewControllerの定義をよく見ると、FixedHeightToolbarViewControllerというプロトコルが使われているのですが、このプロトコルには複雑なところが特にないと思います(命名が似ていて少し分かりにくいかもしれませんが、このツールバービューコントローラーのプロトコル名はコンテナーのクラス名からProvidingContainerを外したものです)

publicprotocolFixedHeightToolbarViewController:UIViewController {
    // このビューコントローラーが表示されている間にナビゲーションコントローラーの`popViewController()`を呼べるかどうか// (基本的に戻るボタンを表示すべきか)varcanPop:Bool { getset }
    // ツールバーの高さ(決まってから変わるべきでない)vartoolbarHeight:CGFloat { get }
    // ツールバーの背景色vartoolbarBackgroundColor:UIColor { get }
}

コンテナーはもちろんAdditionalToolbarNeededの話をした時に説明したAdditionalToolbarProvidingContainerには準拠しています。

extensionFixedHeightToolbarProvidingContainerViewController:AdditionalToolbarProvidingContainer {
    publicvarprovidedToolbarViewController:UIViewController { return toolbarViewController }
    publicvarembeddedViewController:UIViewController { return embedded }
}

戻るボタンを表示すべきかどうかはツールバーのビューコントローラーが自分で判断するのが難しいので、コンテナーがviewWillAppearviewDidAppearのタイミングで伝えます。

privatevarcanPop:Bool {
        // 自分がナビゲーションスタックの一番最初のビューコントローラーの場合だけポップできませんreturn navigationController?.viewControllers.first !=self
    }

    overridepublicfuncviewWillAppear(_ animated:Bool) {
        super.viewWillAppear(animated)
        // 表示される度に`toolbarViewController.canPop`を更新します。// 以前表示されてからナビゲーションスタックが変わった可能性があります。letcanPop=self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    overridepublicfuncviewDidAppear(_ animated:Bool) {
        super.viewDidAppear(animated)
        // `toolbarViewController.canPop`の更新は`viewWillAppear`のタイミングだけで良さそうに感じるかもしれないが、// 色々試したら`viewWillAppear`のタイミングで`navigationController?.viewControllers`が最新状態になっていないこともあったので、// 念のために`viewDidAppear`でもやりますletcanPop=self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

ビューの配置も複雑なことが特にありません。

overridepublicfuncviewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints =false// ツールバーより後ろになるために`embedded`の`view`を最初に追加します// (自分の`bounds`を超えるやんちゃなビューコントローラーがいる)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive =true
        embedded.view.trailingAnchor.constraint(equalTo:view.trailingAnchor).isActive =true
        embedded.view.bottomAnchor.constraint(equalTo:view.bottomAnchor).isActive =true// ツールバーはセーフエリア内にとどまるので、その外でツールバーの背景色を出すのは`toolbarBackgroundView`lettoolbarBackgroundView= UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints =false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo:view.topAnchor).isActive =true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive =true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo:view.trailingAnchor).isActive =true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo:view.safeAreaLayoutGuide.topAnchor, constant:toolbarViewController.toolbarHeight).isActive =true
        embedded.view.topAnchor.constraint(equalTo:toolbarBackgroundView.bottomAnchor).isActive =true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints =false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant:toolbarViewController.toolbarHeight).isActive =true
        toolbarViewController.view.topAnchor.constraint(equalTo:view.safeAreaLayoutGuide.topAnchor).isActive =true
        toolbarViewController.view.leadingAnchor.constraint(equalTo:view.safeAreaLayoutGuide.leadingAnchor).isActive =true
        toolbarViewController.view.trailingAnchor.constraint(equalTo:view.safeAreaLayoutGuide.trailingAnchor).isActive =true
    }

FixedHeightToolbarProvidingContainerViewControllerはあとトランジションの話に出ていたanimateAlongsideTransitionだけです。以前説明した通り、ツールバーの部分だけ、フェードイン・フェードアウトをします。システムが既にやっているトランジションとぶつかりたくないので、既存のビューをできるだけいじらないで、代わりにスナップショットを撮って、独自アニメーションはスナップショットだけを使います。少し心配だった部分あったのでassertを多めです。

staticfuncanimateAlongsideTransition(
        from source:FixedHeightToolbarProvidingContainerViewController,
        to destination:FixedHeightToolbarProvidingContainerViewController,
        inside navigationController:NoBarNavigationController,
        coordinatedBy coordinator:UIViewControllerTransitionCoordinator
    ) {
        // 高さが違っていれば、標準のアニメーションだけにしますif source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guardletsourceSnapshot= source.toolbarViewController.view.snapshotView(afterScreenUpdates:false) else { return }
        letdestinationSnapshot:UIView?// `destination`のビューがまだ表示されていなくて、ビューのヒエラルキーに入っていないはずif destination.view.superview ==nil {
            // `destination`の親が`navigationController`でなければ、`destination.view`を`navigationController.view`に追加したらクラッシュしてしまいます// (子ビューコントローラーのビューがその親ビューコントローラーのビューのサブビューであるべきなので)
            assert(destination.parent == navigationController, "予期しない状態")
            // `destination.view`がビューのヒエラルキーに入っていないとスナップショットを撮れないので、一時的に`navigationController.view`に追加します
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController`がまだ表示されていないので、`afterScreenUpdates`を`true`にしないとスナップショットが撮れません
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:true)
            // `destination`を元の状態に戻します
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("予期しない状態")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:false)
        }

        // アニメーションの最初の状態// `toolbarBackgroundView`が本当のツールバーを隠してくれますlettoolbarBackgroundView= UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x:0,
            y:0,
            width:source.toolbarViewController.view.bounds.width,
            height:source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha =1ifletdestinationSnapshot= destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha =0
        } else {
            assertionFailure("予期しない状態")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // アニメーションの最後の状態
            destinationSnapshot?.alpha =1
            sourceSnapshot.alpha =0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in// アニメーションのために追加していた`toolbarBackgroundView`とそのサブビューであるスナップショットを外します
            toolbarBackgroundView.removeFromSuperview()
        })
    }

ツールバー

あと残るのはツールバー自体だけです。iOSクックパッドは本来多くの画面で使われるツールバーに機能が豊富です。真ん中に表示されるのは画面によってタイトルだけ、タイトルとサブタイトル、検索ボックス。検索ボックスをタップすると表示させる検索ビューコントローラーの扱いもツールバーのビューコントローラーに入っています。この記事が既に複雑で長いので、ここでシンプルなタイトルを表示するだけにしようと思います。

f:id:vincentisambart:20211027160223p:plain:w300f:id:vincentisambart:20211027160305p:plain:w300

ツールバーといっても、ビューコントローラー(EmbeddedNavigationToolbarViewController)とビュー(EmbeddedNavigationToolbar)に分かれています。

EmbeddedNavigationToolbarViewController

EmbeddedNavigationToolbarViewControllerは単にEmbeddedNavigationToolbarを表示して、FixedHeightToolbarProvidingContainerViewControllerEmbeddedNavigationToolbarの仲介をしているだけです。

finalclassEmbeddedNavigationToolbarViewController:UIViewController, FixedHeightToolbarViewController {
    privateletviewController:UIViewControllerinit(viewController:UIViewController) {
        self.viewController = viewController
        super.init(nibName:nil, bundle:nil)
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    overridefuncloadView() {
        letnavigationToolbar= EmbeddedNavigationToolbar(
            viewController:viewController,
            canPop:canPop
        )
        navigationToolbar.delegate =self
        view = navigationToolbar
    }

    privatevartoolbar:EmbeddedNavigationToolbar {
        guardlettoolbar= view as?EmbeddedNavigationToolbarelse {
            fatalError("ビューがEmbeddedNavigationToolbarのインスタンスのはず")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewControllervarcanPop:Bool=false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    lettoolbarHeight= EmbeddedNavigationToolbar.height
    lettoolbarBackgroundColor= EmbeddedNavigationToolbar.backgroundColor
}

やっている一番ビューコントローラーらしいことは戻るボタンのタップの扱いかもしれません。

extensionEmbeddedNavigationToolbarViewController:EmbeddedNavigationToolbarDelegate {
    funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton) {
        navigationController?.popViewController(animated:true)
    }
}

EmbeddedNavigationToolbar

あとはEmbeddedNavigationToolbarViewControllerviewであるEmbeddedNavigationToolbarだけです。EmbeddedNavigationToolbarは普通のビューですが、一番重要なのが左右のボタンと真ん中のtitleViewの扱いです。

でもサブビューの話の前には定数の定義を見ましょう。

finalclassEmbeddedNavigationToolbar:UIView {
    privatestaticlethorizontalMargin:CGFloat=7.0privatestaticlettitleViewVerticalMargin:CGFloat=7.0privatestaticlethorizontalSpacing:CGFloat=3.0privatestaticlettitleViewHorizontalMargin:CGFloat=7.0privatestaticletitemsStackViewMinimumWidth:CGFloat=2.0staticletheight:CGFloat= {
        // 実は標準のナビゲーションバーの高さはそんなにシンプルではありません。// 基本的にiPadでは50ptであって、iPhoneでは44ptですが、iPhoneのモーダルの場合は56ptのようです。// それをこの仕組みで実現できないか検証する予定ではありますが、まだやっていません。if UIDevice.current.userInterfaceIdiom == .pad {
            return50.0
        } else {
            return44.0
        }
    }()
    staticletbackgroundColor:UIColor= .lightGray

高さの問題を除いて、特に目立つことはなかったと思います。左右のボタンの配置はスタックビューを使用します。

privateletleftItemsStackView:UIStackView= {
        letstackView= UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    privateletrightItemsStackView:UIStackView= {
        letstackView= UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

initはインスタンス変数の初期化やAuto Layoutの制約が特別なことをやっていないのですが、気になりそうなのはobserveの使い方だと思います。コメントで経緯を説明します。

privateletnavigationItem:UINavigationItemprivatevartitleView:UIView?varcanPop:Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    privatevarsideButtonItemsObservations:[NSKeyValueObservation]= []
    privatevartitleViewObservation:NSKeyValueObservation?weakvardelegate:EmbeddedNavigationToolbarDelegate?// `viewController`がこのツールバーの下に表示されるビューコントローラーです。ツールバーに表示される情報がこのビューコントローラーが元です。// `canPop`は上記に説明した通り、`viewController`を自分のナビゲーションコントローラーからポップできるのか、// すなわちナビゲーションコントローラーのナビゲーションスタックの最初のビューコントローラーじゃないことを示します。init(
        viewController:UIViewController,
        canPop:Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor =Self.backgroundColor
        clipsToBounds =true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo:centerYAnchor).isActive =true
        leftItemsStackView.leadingAnchor.constraint(equalTo:leadingAnchor, constant:Self.horizontalMargin).isActive =true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo:centerYAnchor).isActive =true
        rightItemsStackView.trailingAnchor.constraint(equalTo:trailingAnchor, constant:-Self.horizontalMargin).isActive =true// ツールバーの真ん中らに入る`titleView`の指定が変わったら、すぐ反映されるためにKVOを使います。// `options: .initial`が指定されているので、変更の時だけではなく、クロージャーが最初の状態でも呼ばれます。
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weakself, weak viewController] navigationItem, _ inguardletself=self, letviewController= viewController else { return }
            self.setUpTitleView(navigationItem.titleView ??self.makeDefaultTitleView(for:viewController))
        }

        // ツールバーに影響のある`UINavigationItem`のプロパティもKVOで監視します。// すべてのクロージャーが同じことをやっていますが、値の型がいくつかあるので、全部を1つのクロージャーにまとめられません。
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weakself] _, _ inself?.recreateButtons() },
        ]

        // 左右のボタンを最初の状態で作成しておきます。
        recreateButtons()
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // 高さが固定なので、AutoLayoutにその高さを教えておきましょう。overridevarintrinsicContentSize:CGSize {
        return CGSize(width:UIView.noIntrinsicMetric, height:Self.height)
    }

KVOはSwift時代でユースケースが限られていますが、ここでは活用する必要があります。

delegateは戻るボタンの扱いだけに使われています。

protocolEmbeddedNavigationToolbarDelegate:AnyObject {
    funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton)
}

その戻るボタンを表示すべきかどうかはUINavigationItemの標準の仕様に合わせています。

privatevarshouldDisplayBackButton:Bool {
        if!canPop {
            returnfalse
        }
        if navigationItem.hidesBackButton {
            returnfalse
        }
        return (navigationItem.leftBarButtonItem ==nil|| navigationItem.leftItemsSupplementBackButton)
    }

その戻る含む左右のボタンの作成はrecreateButtons()が担当しています。

privatefuncrecreateButtons() {
        // ボタンを作り直すので、以前のボタンをまず排除する必要があります。
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        varleftBarButtonItems= navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at:0)
        }

        Self.makeButtons(from:leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // `rightBarButtonItems`には一番右のボタンが最初に入っているので、スタックビューにボタンを入れる際は並び順を逆にする必要があります。Self.makeButtons(from:navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

recreateButtons()は長くないのですが、ツールバーの他のメソッドをいくつか呼んでいるのでそのメソッドを見てみましょう。まず本当のボタンをUIBarButtonItemから作成するmakeButtons(from:)があります。

privatestaticfuncmakeButtons(from barButtonItems:[UIBarButtonItem]?) ->[NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item inletbutton= NavigationToolbarButton(barButtonItem:item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

NavigationToolbarButtonはあとで見ましょう。setContentHuggingPriority(_:for:)はボタンが必要以上に大きくならないためです。

スタックビューが空っぽの場合、幅の定義がないのでシンプルのUIView同様制約によってどの幅にもなれます。特に真ん中のtitleViewの左右が左右のスタックビューに結びついている場合、titleViewが取ってほしいスペースを取ってくれないので、それを避けるために、スタックビューが空の場合、幅固定(2pt)のシンプルなビューを入れておきます。

privatestaticfuncpreventFromExpandingHorizontally(_ stackView:UIStackView) {
        assert(stackView.axis == .horizontal, "垂直のスタックビューに対応していない")
        if!stackView.arrangedSubviews.isEmpty {
            return
        }
        letview= UIView()
        view.widthAnchor.constraint(equalToConstant:itemsStackViewMinimumWidth).isActive =true
        stackView.addArrangedSubview(view)
    }

戻るボタンは見た目が普通のleftBarButtonItemsと同じなので、直接作るのではなく、UIBarButtonItemを作って、ボタンが普通のleftBarButtonItemsと一緒に作成されるようにしました。

privatefuncmakeBackButtonItem() ->UIBarButtonItem {
        // `UIBarButtonItem(systemItem:)`に渡される`systemItem`は作成後に知るすべがありません。// `NavigationToolbarButton`が`UIBarButtonItem`を元に作成される時、表示されてほしい画像を入手する必要があるので、`UIBarButtonItem(systemItem:)`を使えません。// 代わりにSF Symbolを活用して、シンボルから普通の画像を生成します。letbackButtonImage= UIImage(
            systemName:"chevron.backward",
            withConfiguration:UIImage.SymbolConfiguration(pointSize:23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        letbackButtonItem= UIBarButtonItem(
            image:backButtonImage,
            style: .plain,
            target:self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel ="戻る"return backButtonItem
    }

    @objcprivatefuncdidTapBackButton(_ sender:UIButton) {
        // 自分の`delegate`を呼ぶだけです。
        delegate?.navigationToolbar(self, didTapBackButton:sender)
    }

titleViewの作成と配置はシンプルでです。余談ですが、実は、iOSクックパッドはtitleView配置にモードが2つあります(centerfill)。全体のコードが既に十分複雑なので、ここはtitleViewに画面の全ての幅を取らせるfillだけにしました。

privatefuncsetUpTitleView(_ titleView:UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo:topAnchor, constant:Self.titleViewVerticalMargin).isActive =true
        titleView.bottomAnchor.constraint(equalTo:bottomAnchor, constant:-Self.titleViewVerticalMargin).isActive =true
        titleView.leadingAnchor.constraint(
            equalTo:leftItemsStackView.trailingAnchor,
            constant:Self.titleViewHorizontalMargin
        ).isActive =true
        titleView.trailingAnchor.constraint(
            equalTo:rightItemsStackView.leadingAnchor,
            constant:-Self.titleViewHorizontalMargin
        ).isActive =true
    }

    privatefuncmakeDefaultTitleView(for viewController:UIViewController) ->UIView {
        lettitleView= EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController:viewController)
        return titleView
    }
}

デフォルトのtitleViewであるEmbeddedNavigationToolbarTitleOnlyTitleViewでやっている時別なことはviewControllernavigationItemtitleを監視しているところくらいです。

finalclassEmbeddedNavigationToolbarTitleOnlyTitleView:UIView {
    privatelettitleLabel= UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints =false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo:topAnchor).isActive =true
        titleLabel.leadingAnchor.constraint(equalTo:leadingAnchor).isActive =true
        titleLabel.trailingAnchor.constraint(equalTo:trailingAnchor).isActive =true
        titleLabel.bottomAnchor.constraint(equalTo:bottomAnchor).isActive =true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines =2
        titleLabel.adjustsFontSizeToFitWidth =true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    privatevartitleObservation:NSKeyValueObservation?funcobserve(viewController:UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options:[.initial]) { [weakself] navigationItem, _ inself?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

最後は左右のボタンに使われるNavigationToolbarButtonです。UIBarButtonItemを元に普通のボタンを作成しています。戻るボタンの話をした時も書きましたが、残念ながらUIBarButtonItem(systemItem:)で作成されたUIBarButtonItemは渡されたsystemItemをあとで分かるAPIがないので、対応できません。makeBackButtonItem()同様SF Symbolsを活用するのが一番無難かと思います。

publicfinalclassNavigationToolbarButton:UIButton {
    privatestaticletbuttonHorizontalPadding:CGFloat=2.0privateletbarButtonItem:UIBarButtonItemprivatevarenabledObservation:NSKeyValueObservation?publicinit(barButtonItem:UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        ifletimage= barButtonItem.image {
            setImage(image, for: .normal)
        } elseiflettitle= barButtonItem.title {
            setTitle(title, for: .normal)
            iflettintColor= barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            iflettitleTextAttributes= barButtonItem.titleTextAttributes(for: .normal),
               letfont= titleTextAttributes[.font] as?UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize:16)
            }
        } else {
            // ここがこの仕組みの制限の1つです。// なぜか`UIBarButtonItem`作成時に渡された`systemItem`をあとで取り出す方法がないので、ボタンを作れません。// また、`UIBarButtonItem`作成時に渡された`customView`に関しては取り出せるようですが、需要がなかったのでここで対応していません。
            fatalError("このボタンアイテムの種類に対応していない:\(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top:0,
            left:Self.buttonHorizontalPadding,
            bottom:0,
            right:Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        iflettarget= barButtonItem.target, letaction= barButtonItem.action {
            addTarget(target, action:action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options:[.initial]) { [weakself] item, _ inself?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled =true
        }
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extensionUIColor {
    fileprivatevarrgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        varred:CGFloat=0.0vargreen:CGFloat=0.0varblue:CGFloat=0.0varalpha:CGFloat=0.0

        getRed(&red, green:&green, blue:&blue, alpha:&alpha)

        return (red, green, blue, alpha)
    }

    fileprivatevarheavilyHighlighted:UIColor {
        letratio:CGFloat=0.85let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red:red* ratio,
            green:green* ratio,
            blue:blue* ratio,
            alpha:alpha
        )
    }
}

最後に

ナビゲーションコントローラーのツールバーを自由に定義できる仕組みは結局必要だったコードの量がそれなりにありました。

iOSクックパッドはどの画面でもこのナビゲーションコントローラーを使っています。透過しているツールバーはEmbeddedNavigationToolbarViewController/EmbeddedNavigationToolbarを改造して作られています。

実装はできたけど、懸念点は少なくありません。

  • 標準のナビゲーションバーの高さがモーダルの時に変わるのは現時点で対応していません。
  • スワイプで戻れる動作が動くために、ドキュメントされていない挙動に頼っています。
  • ツールバーの左右のボタンの定義はUIBarButtonItem(systemItem:)を使えません。
  • ツールバーの遷移アニメーションが標準のと違います。
  • SwiftUIが勝手にナビゲーションバーを表示しようとしているので、それを無視していることでいずれ不具合が発生する可能性があります。

懸念点の一部はもっと頑張れば対応できると思いますが、一部はApple側で変更が必要です。最近Apple側でナビゲーションバーをもっと柔軟にカスタマイズできる動きがあるように見えないので、システムを自分のデザインに合わせるのではなく、自分のデザインをシステム標準のものに合わせた方がおすすめです。

頻繁にこんな細かいコードを書いているわけではありませんが、iOSエンジニアの仲間は募集しているので、興味ある方はぜひご応募ください https://info.cookpad.com/careers/

// This project is licensed under the MIT license.//// Copyright (c) 2021 Cookpad Inc.//// Permission is hereby granted, free of charge, to any person obtaining a copy// of this software and associated documentation files (the "Software"), to deal// in the Software without restriction, including without limitation the rights// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell// copies of the Software, and to permit persons to whom the Software is// furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in// all copies or substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN// THE SOFTWARE.import UIKit

publicprotocolAdditionalToolbarNotNeeded:UIViewController {}

// If a view controller does conform to neither `AdditionalToolbarNeeded` nor `AdditionalToolbarNotNeeded`,// the behavior is the same as if it conformed to `AdditionalToolbarNeeded` and defined `wrapInContainer()` as follows:// func wrapInContainer() -> AdditionalToolbarProvidingContainer {//     return FixedHeightToolbarProvidingContainerViewController(//         embedded: self,//         toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: self)//     )// }publicprotocolAdditionalToolbarNeeded:UIViewController {
    funcwrapInContainer() ->AdditionalToolbarProvidingContainer
}

publicprotocolAdditionalToolbarProvidingContainer:AdditionalToolbarNotNeeded {
    varprovidedToolbarViewController:UIViewController { get }
    varembeddedViewController:UIViewController { get }
}

publicprotocolNoBarNavigationControllerDelegate:AnyObject {
    funcnoBarNavigationController(_ navigationController:NoBarNavigationController, willShow viewController:UIViewController, animated:Bool)
    funcnoBarNavigationController(_ navigationController:NoBarNavigationController, didShow viewController:UIViewController, animated:Bool)
}

publicfinalclassNoBarNavigationController:UINavigationController {
    overridepublicinit(rootViewController:UIViewController) {
        letwrappedRootViewController=Self.wrapIfNeeded(rootViewController)
        super.init(nibName:nil, bundle:nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
        super.viewDidLoad()
        delegate =self// This navigation controller does not have its own navigation bar.// If needed, a view controller pushed will get wrapped by an other view controller// that will provide an equivalent to the navigation bar.
        isNavigationBarHidden =true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)

        ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
            // If the back button is hidden (that includes the navigation bar being hidden),// `UINavigationController` will disable its `interactivePopGestureRecognizer`.// To reenable it, we assign the recognizer's delegate to be own custom handler.
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

    publicweakvaradditionalDelegate:NoBarNavigationControllerDelegate?overridepublicvardelegate:UINavigationControllerDelegate? {
        didSet {
            assert(delegate ===self, "If you need to use a delegate, use additionalDelegate instead")
        }
    }

    // Make sure the navigation bar is always hidden.// (SwiftUI tends to set it to false)overridepublicfuncsetNavigationBarHidden(_ hidden:Bool, animated:Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated:animated)
        }
    }

    overridepublicfuncsetViewControllers(_ viewControllers:[UIViewController], animated:Bool) {
        letwrappedViewControllers= viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated:animated)
    }

    overridepublicfuncpushViewController(_ viewController:UIViewController, animated:Bool) {
        letwrappedViewController=Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated:animated)
    }

    privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
        letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
            assert(!(originalViewController isAdditionalToolbarNeeded), "A view controller cannot at the same time want a navigation controller and not want one")
            // No need to wrap.
            viewController = originalViewController
        } elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // The view controller does not specify anything special, so we create a simple toolbar.
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded:originalViewController,
                toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
            )
        }

        // Ensure that wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)
        assert(viewController isAdditionalToolbarNotNeeded, "A return value not conforming to AdditionalToolbarNotNeeded risks being doubly wrapped when wrapIfNeeded is called once again on it")
        return viewController
    }
}

privatefinalclassInteractivePopGestureHandler:NSObject, UIGestureRecognizerDelegate {
    // `weak` to prevent circular references.weakvarnavigationController:UINavigationController!init(controller:UINavigationController) {
        navigationController = controller
    }

    funcgestureRecognizerShouldBegin(_ gestureRecognizer:UIGestureRecognizer) ->Bool {
        return navigationController.viewControllers.count >1
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer:UIGestureRecognizer) ->Bool {
        // When the view controller displayed contains a scroll view,// so that when swiping to get back to the previous view controller,// swiping up/down does not also scroll the scroll view.returnfalse
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer:UIGestureRecognizer) ->Bool {
        // When a web view is doing its first loading, so that we can swipe back to the previous view controller.return otherGestureRecognizer isUIPanGestureRecognizer
    }

    funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldReceive touch:UITouch) ->Bool {
        returntrue
    }
}

extensionNoBarNavigationController:UINavigationControllerDelegate {
    publicfuncnavigationController(_ navigationController:UINavigationController, didShow viewController:UIViewController, animated:Bool) {
        assert(self== navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow:viewController, animated:animated)
    }

    publicfuncnavigationController(_ navigationController:UINavigationController, willShow viewController:UIViewController, animated:Bool) {
        assert(self== navigationController)

        // Use a custom transition only when transitioning between 2 instances of `FixedHeightToolbarProvidingContainerViewController`.if animated,
           lettransitionCoordinator=self.transitionCoordinator,
           letsource= transitionCoordinator.viewController(forKey: .from) as?FixedHeightToolbarProvidingContainerViewController,
           letdestination= transitionCoordinator.viewController(forKey: .to) as?FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from:source,
                to:destination,
                inside:self,
                coordinatedBy:transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow:viewController, animated:animated)
    }
}

publicfinalclassFixedHeightToolbarProvidingContainerViewController:UIViewController {
    privateletembedded:UIViewControllerprivatelettoolbarViewController:FixedHeightToolbarViewControllerpublicinit(
        embedded:UIViewController,
        toolbarViewController:FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName:nil, bundle:nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent:self)

        addChild(embedded)
        embedded.didMove(toParent:self)
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    privatevarcanPop:Bool {
        // Cannot pop only if you are at the start of the navigation stack.return navigationController?.viewControllers.first !=self
    }

    overridepublicfuncviewWillAppear(_ animated:Bool) {
        super.viewWillAppear(animated)
        // Update `toolbarViewController.canPop` every time this view appears,// as it's possible that the navigation stack changed since last time.letcanPop=self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    overridepublicfuncviewDidAppear(_ animated:Bool) {
        super.viewDidAppear(animated)
        // It seems like updating `toolbarViewController.canPop` in `viewWillAppear` should be enough,// but it turns out that in some cases `navigationController?.viewControllers` is not in// its final state when `viewWillAppear` is called, so just in case also do it here.// We are not doing it only in `viewDidAppear` because you can have an incorrect appearance for a split second.letcanPop=self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    overridepublicfuncviewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints =false// Add the embedded view controller before the toolbar so that the toolbar is above.// (some badly behaving view controllers go beyond their bounds)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive =true
        embedded.view.trailingAnchor.constraint(equalTo:view.trailingAnchor).isActive =true
        embedded.view.bottomAnchor.constraint(equalTo:view.bottomAnchor).isActive =true// The toolbar stays in the safe area, so create a `toolbarBackgroundView` to get our background color outside of it.lettoolbarBackgroundView= UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints =false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo:view.topAnchor).isActive =true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive =true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo:view.trailingAnchor).isActive =true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo:view.safeAreaLayoutGuide.topAnchor, constant:toolbarViewController.toolbarHeight).isActive =true
        embedded.view.topAnchor.constraint(equalTo:toolbarBackgroundView.bottomAnchor).isActive =true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints =false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant:toolbarViewController.toolbarHeight).isActive =true
        toolbarViewController.view.topAnchor.constraint(equalTo:view.safeAreaLayoutGuide.topAnchor).isActive =true
        toolbarViewController.view.leadingAnchor.constraint(equalTo:view.safeAreaLayoutGuide.leadingAnchor).isActive =true
        toolbarViewController.view.trailingAnchor.constraint(equalTo:view.safeAreaLayoutGuide.trailingAnchor).isActive =true
    }

    staticfuncanimateAlongsideTransition(
        from source:FixedHeightToolbarProvidingContainerViewController,
        to destination:FixedHeightToolbarProvidingContainerViewController,
        inside navigationController:NoBarNavigationController,
        coordinatedBy coordinator:UIViewControllerTransitionCoordinator
    ) {
        // If the height is different, use the default animation.if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guardletsourceSnapshot= source.toolbarViewController.view.snapshotView(afterScreenUpdates:false) else { return }
        letdestinationSnapshot:UIView?// Expecting `destination`'s view to not be in the view hierarchy yet.if destination.view.superview ==nil {
            // If the parent of `destination` is not `navigationController` adding `destination.view` to `navigationController.view` would make the app crash.// (as the view of a child view controller should be a subview of its parent's view)
            assert(destination.parent == navigationController, "Unexpected state")
            // If `destination.view` is not in the view hierarchy, we cannot take a snapshot// of it or of its subviews, so temporarily add it to `navigationController.view`.
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController` has not been displayed yet,// so `afterScreenUpdates` has to be `true` to be able to get a snapshot.
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:true)
            // `destination`を元の状態に戻す
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("Unexpected state")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:false)
        }

        // Animation start state// `toolbarBackgroundView` hides the real toolbar.lettoolbarBackgroundView= UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x:0,
            y:0,
            width:source.toolbarViewController.view.bounds.width,
            height:source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha =1ifletdestinationSnapshot= destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha =0
        } else {
            assertionFailure("Unexpected state")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // Animation end state
            destinationSnapshot?.alpha =1
            sourceSnapshot.alpha =0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in// Remove `toolbarBackgroundView` and its subviews as they were just for the animation.
            toolbarBackgroundView.removeFromSuperview()
        })
    }
}

extensionFixedHeightToolbarProvidingContainerViewController:AdditionalToolbarProvidingContainer {
    publicvarprovidedToolbarViewController:UIViewController { return toolbarViewController }
    publicvarembeddedViewController:UIViewController { return embedded }
}

publicprotocolFixedHeightToolbarViewController:UIViewController {
    // Indicates if the navigation controller's `popViewController()` can be called while this view controller is displayed.// (basically should be back button be displayed or not)varcanPop:Bool { getset }
    // Height of the tool bar (once decided it should not change)vartoolbarHeight:CGFloat { get }
    // Background color of the toolbarvartoolbarBackgroundColor:UIColor { get }
}

finalclassEmbeddedNavigationToolbarViewController:UIViewController, FixedHeightToolbarViewController {
    privateletviewController:UIViewControllerinit(viewController:UIViewController) {
        self.viewController = viewController
        super.init(nibName:nil, bundle:nil)
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    overridefuncloadView() {
        letnavigationToolbar= EmbeddedNavigationToolbar(
            viewController:viewController,
            canPop:canPop
        )
        navigationToolbar.delegate =self
        view = navigationToolbar
    }

    privatevartoolbar:EmbeddedNavigationToolbar {
        guardlettoolbar= view as?EmbeddedNavigationToolbarelse {
            fatalError("The view should be an instance of EmbeddedNavigationToolbar")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewControllervarcanPop:Bool=false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    lettoolbarHeight= EmbeddedNavigationToolbar.height
    lettoolbarBackgroundColor= EmbeddedNavigationToolbar.backgroundColor
}

extensionEmbeddedNavigationToolbarViewController:EmbeddedNavigationToolbarDelegate {
    funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton) {
        navigationController?.popViewController(animated:true)
    }
}

protocolEmbeddedNavigationToolbarDelegate:AnyObject {
    funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton)
}

finalclassEmbeddedNavigationToolbar:UIView {
    privatestaticlethorizontalMargin:CGFloat=7.0privatestaticlettitleViewVerticalMargin:CGFloat=7.0privatestaticlethorizontalSpacing:CGFloat=3.0privatestaticlettitleViewHorizontalMargin:CGFloat=7.0privatestaticletitemsStackViewMinimumWidth:CGFloat=2.0staticletheight:CGFloat= {
        // In fact, the height of the OS's navigation bar is not that simple.// It's generally 50pt on iPad and 44pt on iPhone, but when displayed in a modal, it seems to be 56pt.// I do plan to check if the system presented here would allow this, but have not started yet.if UIDevice.current.userInterfaceIdiom == .pad {
            return50.0
        } else {
            return44.0
        }
    }()
    staticletbackgroundColor:UIColor= .lightGray

    privateletleftItemsStackView:UIStackView= {
        letstackView= UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    privateletrightItemsStackView:UIStackView= {
        letstackView= UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    privateletnavigationItem:UINavigationItemprivatevartitleView:UIView?varcanPop:Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    privatevarsideButtonItemsObservations:[NSKeyValueObservation]= []
    privatevartitleViewObservation:NSKeyValueObservation?weakvardelegate:EmbeddedNavigationToolbarDelegate?// `viewController` is the view controller that is displayed below this toolbar.// The information to display in this toolbar come from it.// `canPop` indicates if `viewController` can be popped out of its navigation controller,// in other words it indicates that `viewController` is not the first in the navigation controller's navigation stack.init(
        viewController:UIViewController,
        canPop:Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor =Self.backgroundColor
        clipsToBounds =true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo:centerYAnchor).isActive =true
        leftItemsStackView.leadingAnchor.constraint(equalTo:leadingAnchor, constant:Self.horizontalMargin).isActive =true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo:centerYAnchor).isActive =true
        rightItemsStackView.trailingAnchor.constraint(equalTo:trailingAnchor, constant:-Self.horizontalMargin).isActive =true// Using KVO so that changes to `titleView`, the view to display in the middle of the toolbar,// are reflected as soon as they happen.// Using `options: .initial` so that the closure is called not only on changes but also with the current value.
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weakself, weak viewController] navigationItem, _ inguardletself=self, letviewController= viewController else { return }
            self.setUpTitleView(navigationItem.titleView ??self.makeDefaultTitleView(for:viewController))
        }

        // Observing with KVO `UINavigationItem` properties that have an effect on the toolbar.// All the closures look the same, but their parameters have different types so we cannot just use one closure.
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weakself] _, _ inself?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weakself] _, _ inself?.recreateButtons() },
        ]

        // Create the buttons on both sides from the starting state.
        recreateButtons()
    }

    @available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // The height is fixed so give it to AutoLayout.overridevarintrinsicContentSize:CGSize {
        return CGSize(width:UIView.noIntrinsicMetric, height:Self.height)
    }

    privatevarshouldDisplayBackButton:Bool {
        if!canPop {
            returnfalse
        }
        if navigationItem.hidesBackButton {
            returnfalse
        }
        return (navigationItem.leftBarButtonItem ==nil|| navigationItem.leftItemsSupplementBackButton)
    }

    privatefuncrecreateButtons() {
        // As we are recreating the buttons, first remove the previous ones.
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        varleftBarButtonItems= navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at:0)
        }

        Self.makeButtons(from:leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // Buttons specified with `rightBarButtonItems` starts from the right, so we have to reverse the order// before adding corresponding buttons to the stack view.Self.makeButtons(from:navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

    privatestaticfuncmakeButtons(from barButtonItems:[UIBarButtonItem]?) ->[NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item inletbutton= NavigationToolbarButton(barButtonItem:item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

    privatestaticfuncpreventFromExpandingHorizontally(_ stackView:UIStackView) {
        assert(stackView.axis == .horizontal, "Vertical stack view are not supported")
        if!stackView.arrangedSubviews.isEmpty {
            return
        }
        letview= UIView()
        view.widthAnchor.constraint(equalToConstant:itemsStackViewMinimumWidth).isActive =true
        stackView.addArrangedSubview(view)
    }

    privatefuncmakeBackButtonItem() ->UIBarButtonItem {
        // The `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation.// To be able to create a button from a `UIBarButtonItem` we have to be able to get its image,// so we cannot use `UIBarButtonItem(systemItem:)`.// Instead, making use of SF Symbols, we create a standard image from the symbol.letbackButtonImage= UIImage(
            systemName:"chevron.backward",
            withConfiguration:UIImage.SymbolConfiguration(pointSize:23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        letbackButtonItem= UIBarButtonItem(
            image:backButtonImage,
            style: .plain,
            target:self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel ="戻る"return backButtonItem
    }

    @objcprivatefuncdidTapBackButton(_ sender:UIButton) {
        // Just calling the delegate.
        delegate?.navigationToolbar(self, didTapBackButton:sender)
    }

    privatefuncsetUpTitleView(_ titleView:UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo:topAnchor, constant:Self.titleViewVerticalMargin).isActive =true
        titleView.bottomAnchor.constraint(equalTo:bottomAnchor, constant:-Self.titleViewVerticalMargin).isActive =true
        titleView.leadingAnchor.constraint(
            equalTo:leftItemsStackView.trailingAnchor,
            constant:Self.titleViewHorizontalMargin
        ).isActive =true
        titleView.trailingAnchor.constraint(
            equalTo:rightItemsStackView.leadingAnchor,
            constant:-Self.titleViewHorizontalMargin
        ).isActive =true
    }

    privatefuncmakeDefaultTitleView(for viewController:UIViewController) ->UIView {
        lettitleView= EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController:viewController)
        return titleView
    }
}

finalclassEmbeddedNavigationToolbarTitleOnlyTitleView:UIView {
    privatelettitleLabel= UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints =false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo:topAnchor).isActive =true
        titleLabel.leadingAnchor.constraint(equalTo:leadingAnchor).isActive =true
        titleLabel.trailingAnchor.constraint(equalTo:trailingAnchor).isActive =true
        titleLabel.bottomAnchor.constraint(equalTo:bottomAnchor).isActive =true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines =2
        titleLabel.adjustsFontSizeToFitWidth =true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    privatevartitleObservation:NSKeyValueObservation?funcobserve(viewController:UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options:[.initial]) { [weakself] navigationItem, _ inself?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

publicfinalclassNavigationToolbarButton:UIButton {
    privatestaticletbuttonHorizontalPadding:CGFloat=2.0privateletbarButtonItem:UIBarButtonItemprivatevarenabledObservation:NSKeyValueObservation?publicinit(barButtonItem:UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        ifletimage= barButtonItem.image {
            setImage(image, for: .normal)
        } elseiflettitle= barButtonItem.title {
            setTitle(title, for: .normal)
            iflettintColor= barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            iflettitleTextAttributes= barButtonItem.titleTextAttributes(for: .normal),
               letfont= titleTextAttributes[.font] as?UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize:16)
            }
        } else {
            // Here is one limitation of this system.// For some reason, a `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation, so we cannot create a button for it.// Also, we should be able to support a `UIBarButtonItem`using a `customView` but we did not have any need for it.
            fatalError("Unsupported button item type \(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top:0,
            left:Self.buttonHorizontalPadding,
            bottom:0,
            right:Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        iflettarget= barButtonItem.target, letaction= barButtonItem.action {
            addTarget(target, action:action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options:[.initial]) { [weakself] item, _ inself?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled =true
        }
    }

    @available(*, unavailable)requiredinit?(coder:NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extensionUIColor {
    fileprivatevarrgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        varred:CGFloat=0.0vargreen:CGFloat=0.0varblue:CGFloat=0.0varalpha:CGFloat=0.0

        getRed(&red, green:&green, blue:&blue, alpha:&alpha)

        return (red, green, blue, alpha)
    }

    fileprivatevarheavilyHighlighted:UIColor {
        letratio:CGFloat=0.85let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red:red* ratio,
            green:green* ratio,
            blue:blue* ratio,
            alpha:alpha
        )
    }
}
Viewing all 804 articles
Browse latest View live