料理教室のデザインリニューアルを支えた技術

料理教室事業部の長(@s_osa_)です。最近読んで面白かった漫画は『ランウェイで笑って』です。

クックパッド料理教室では今年10月にデザインの全面リニューアルを行ないました。

Before After
f:id:s_osa:20171219172624p:plain f:id:s_osa:20171219171826p:plain

ユーザー向けページの HTML, CSS, JavaScript を約1ヶ月でまるっと書き換えるプロジェクトでした。

今回はそんなデザインリニューアルを支えた仕組みについて書きたいと思います。

全面リニューアルの大変さ

「全面リニューアル」

聞いただけで大変さがにじみ出る言葉ですが、具体的に何が大変なのか少し考えてみます。

主な大変さは2つあると考えています。

スコープが大きい

デザインの全面リニューアルという性質上、全ページが対象になります。 クックパッド料理教室のコードはそれほど大きくない Rails ですが、それでも対象の view ファイルは約200ほどあります。

もちろん、これら200個のファイルだけを変更するわけではなく関連するファイルも同時に修正する必要があるため、実際の作業量はもっと大きくなります。

リリースブランチの長期間運用とビッグバンマージ

リニューアルにともなってデザインを大きく変更するため、全ページのデザインを一度に切り替える必要があります。 また、プロジェクトを進める一方で、バグ修正をはじめとして日常的にコードに変更を入れていく必要もあります。 これら2つの目的を果たすためにプロジェクトの期間中ずっとリリースブランチをメンテナンスしていく必要が生じます。

リリースブランチを長期間にわたってメンテナンスしていくのも大変ですが、その後、master にマージするのも大変です。 差分が大きくなればなるほど、バグが入り込む可能性は大きく、バグが起こったときの原因究明も難しくなります。

大変じゃない全面リニューアルを考えてみる

大変な理由がわかったところで、その大変さを取り除くことを考えます。

スコープをできるだけ小さくする

デザインの全面リニューアルである以上、すべての HTML, CSS, JavaScript, Image を書き換えることは避けがたいです。 しかし、それ以外の箇所は触らないようにしました。

「せっかくリニューアルするなら」と新機能の追加や機能改善をしたくなりますが、そこはグッと我慢して粛々と画面だけを書き換えます。 ただでさえ大きいスコープをさらに膨らませてリリースが遅れるくらいなら、可能な限り早くリリースした後に小さく扱いやすいスコープで機能追加や改善を行なうという方向性をプロジェクト開始時にチームで合意しました。

また、デザインの刷新だけでもユーザーにとっては大きな変更であり戸惑いが生じるので、機能については据え置くことで少しでも戸惑いを減らしたいという意図もありました。

リリースブランチをつくらない

身も蓋もないことを言ってしまうと、リリースブランチをなくせばリリースブランチの長期間運用もビッグバンマージも発生しません。 そこで、リリースブランチをつくるのはやめて、書いたコードは順次 master に入れるようにします。

しかし、先述のとおり全ページのデザインを一度に切り替える必要があるので、単純に既存の view を書き換えるという手段は使えません。

そこで、「全ページのデザインを一度に切り替える」と「順次 master にマージする」を両立するための仕組みをつくりました。

柔軟なデザイン切り替えを実現するために

つくりたい状況は

  • master に新旧2つのデザインが共存している
  • 2つのデザインを柔軟に切り替えられる

というものです。

上記2点を実現するための方法についてそれぞれ考えていきます。

master に新旧2つのデザインを共存させる

まず、master に新旧両方のデザインを共存させる方法を考えます。

プロジェクト期間中は一時的に共存期間が必要ですが、プロジェクト完了後は新しいデザインのみが使用され古いデザインが必要になることはありません。

そこで、古いデザインのためのファイルをまとめたディレクトリを作ります。 具体的には app/views, app/assets/images, app/assets/javascripts, app/assets/stylesheets の中に旧デザインのためのファイルを置くためのディレクトリを掘って、既存のファイルをそちらに移動し、プロジェクト完了後にまるっと削除できるようにします。

イメージとしては以下のようなディレクトリ構成になります。

# tree app
app
├── assets
│   ├── images
│   │   └── old
│   ├── javascripts
│   │   ├── application
│   │   │   └── foo.js
│   │   ├── application.js
│   │   ├── application_old
│   │   │   └── foo.js
│   │   └── application_old.js
│   └── stylesheets
│       ├── application
│       │   └── foo.scss
│       ├── application.scss
│       ├── application_old
│       │   └── foo.scss
│       └── application_old.scss
└── views
    ├── foos
    │   └── index.html.haml
    ├── layouts
    │   └── application.html.haml
    └── old
        ├── foos
        │   └── index.html.haml
        └── layouts
            └── application.html.haml

また、asset precompile 時に新旧両方の asset を作成するようにし、新旧それぞれの layout ファイルから読み分けるようにします。

# config/initializers/assets.rb
Rails.application.config.assets.precompile += %w(application application_old)
# app/views/layouts/application.html.haml
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'

# app/views/layouts/application_old.html.haml
= stylesheet_link_tag 'application_old', media: 'all'
= javascript_include_tag 'application_old'

2つのデザインを柔軟に切り替える

ここまでで新旧両方のファイルを master に共存させることができました。

あとは render するテンプレートをいい感じに切り替えることさえできれば当初の目的を達成することができます。

Rails のテンプレート探索

テンプレートを望む通りに切り替えるためにはテンプレートがどのように探索されているかを知る必要があります。

Rails がどうやってテンプレートを探索しているかについては以下のエントリが詳しいです。

リンク先にあるようにテンプレート探索の仕組みは結構複雑なのでここでその詳細を解説することはしませんが、今回作ろうとしている仕組みは Rails がテンプレート探索に使用している resolver の仕組みを利用します。ここでは resolver についてのみ簡単に説明します。

Resolver

Rails が render するテンプレートを探索するために使用しているオブジェクトです。 現在のアプリケーションが持っている resolver の一覧は rails console で以下のメソッドを呼ぶことで確認できます。

ApplicationController.new.view_paths

メソッドの返り値は resolver が入った配列で、デフォルトでは以下のような resolver だけが入っています。

#<ActionView::OptimizedFileSystemResolver:0x007fe41c5d88a8
  @cache=#<ActionView::Resolver::Cache:0x7fe41c5d8bf0 keys=0 queries=0>,
  @path="/Users/shunsuke-osa/projects/cooking_school/app/views",
  @pattern=":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}">

何らかの view を追加するタイプの gem を使用している場合はその gem が提供する view を探索対象に含むための resolver が追加されているはずです。 *1

Path

resolver が持っている @path は文字通り探索対象のディレクトリを指し示す path です。デフォルトの resolver には app/views が指定されており、普段 Rails がこのディレクトリを対象にテンプレートの探索を行なっていることがわかります。

Pattern

resolver の @pattern からなんとなく察せるとおり、普段 Rails がやっている locale (e.g. ja, en), format (e.g. html, json), handler (e.g. haml, erb) に応じたテンプレートの切り替えも resolver によって行われています。 *2

パターン定義の中に含まれる :hoge はテンプレート探索で用いられる LookupContext において detail と呼ばれているもので、探索 path を動的に生成するために使用されます。現在使用されている detail の一覧は以下のメソッドで確認できます。

ActionView::LookupContext.registered_details
# => [:locale, :formats, :variants, :handlers]

また、パターン中に含まれる {} はブレース展開されます。

柔軟なテンプレート探索を実現する

Rails のテンプレート探索の仕組みを調べた結果、

  • Rails はテンプレート探索に使うための resolver を持っている
  • 適切な path や pattern を指定した resolver を追加すれば任意のテンプレートを render する仕組みをつくることができる

ということがわかりました。

方針が決まったので実装していきます。

シンプルなケース

view_paths に独自 resolver が追加されていない Rails に対してテンプレート探索の対象ディレクトリを追加するのは簡単です。 ActionView::ViewPaths が提供している prepend_view_pathappend_view_path を使用することで任意の @path を持った resolver を追加することができます。

# app/controllers/application_controller.rb
before_action :fallback_to_old_templates

private

def fallback_to_old_templates
  if prefer_new_template?
    append_view_path('app/views/old')
  else
    prepend_view_path('app/views/old')
  end
end

独自 resolver が使用されているケース

我々のアプリケーションでは jpmobile を使用していました。

jpmobile は resolver の pattern に :mobile という detail を追加して PC 向けとモバイル端末向けのテンプレートを切り替えています。 つまり、jpmobile が提供する端末ごとのテンプレート切り替えに対応しつつ、今回追加する新旧デザインの切り替えにも対応する必要があるため、前述の prepend_view_path, append_view_path に path を渡す方法では目的を果たすことができません。

そこで、jpmobile が提供する resolver を拡張した resolver を用意する必要があります。

つくりたい resolver は以下のようなものです。

  • jpmobile の提供する :mobile という detail に対応している
    • ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
  • 探索ディレクトリを柔軟に変更するための detail として :directories のようなものを持っている
    • '{:directories}:prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'

つまり、両者を同時に満たすために '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}' という pattern を持つ resolver である必要があります。

Detail

detail の追加は非常に簡単で、ActionView::LookupContext.register_detail を使用します。

# config/initializers/action_view.rb
ActionView::LookupContext.register_detail(:directories) { [] }

こうして detail を登録することによって、controller で self.lookup_context.directories= を呼んで、resolver の pattern にある :directories に値を渡すことができるようになります。

Pattern

本来であれば、jpmobile を拡張して pattern が '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}' となる resolver を作成して prepend_view_path に渡すべきなのですが、

  • jpmobile が既存の resolver それぞれに対して resolver を作成しており、すべてに対応するのが面倒なわりにメリットが薄い
  • 今回はプロジェクト中のみ一時的に使用する

といった点を考慮し、jpmobile にモンキーパッチを当てることにしました。

# config/initializers/monkey_patches/jpmobile.rb
module Jpmobile
  class Resolver
    # Original: ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
    DEFAULT_PATTERN = '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
  end
end

あまり褒められた方法ではありませんが、今回行ないたいのはデザインの全面リニューアルであり、そのための一時的な仕組みに対してあまり時間をかけたくなかったため割り切った判断をしました。

実際に切り替える

ここまで出来たらあとは実際に切り替えるだけです。

切り替え自体は controller で lookup_context.directories= を呼ぶだけなので非常に簡単です。

# app/controllers/application_controller.rb
before_action :set_view_template_directories

private

def set_view_template_directories
  case preferred_template # returns 'new' or 'old'
  when 'new'
    self.lookup_context.directories = ['', 'old/']
  when 'old'
    self.lookup_context.directories = ['old/', '']
  end
end

新旧どちらのテンプレートを優先するかを指定するメソッドを用意し、その返り値によってテンプレート探索の優先順位を切り替えています。

プロジェクト初期には新しいテンプレートは存在しません。しかし、その都度例外を吐かれると開発しにくいので、新テンプレートが見つからない場合には旧テンプレートにフォールバックするようにしています。

実際の手順

これまでの説明ではわかりやすさのため順番を前後させてきましたが、実際の作業手順としては以下のような順番でした。

  1. テンプレート切り替えの仕組みを実装する
    • preferred_template = 'old'
    • この時点では旧テンプレートが app/views にある
    • old -> new のフォールバックによってアプリケーションは正常に動き続ける
  2. 旧テンプレートや関連リソースを移動する
    • 旧テンプレートが app/views/old に移動
  3. 新テンプレートを実装していく
    • preferred_template = 'new' すると新テンプレートが優先的に render される
    • 新テンプレートが未実装のページは旧テンプレートにフォールバックする

いろいろな切り替え方

RAILS_ENV

一番わかりやすい切り替え方だと思います。

RAILS_ENV=development では新しいテンプレートを優先し、RAILS_ENV=production では古いテンプレートを優先するなどができます。

Query String

URL に ?template=new などを付加することによって、RAILS_ENV によるテンプレート指定を手軽に上書きする手段を提供します。production での確認などに利用していました。

Session

query string によるテンプレート指定は手軽で便利でしたが、ページ遷移を伴う場合に不便でした。そこで、プロジェクト後半にはページを遷移してもテンプレート指定が保たれるように session を用いたテンプレート指定も使用していました。 *3

外部データストアから設定を読み込み

全面リニューアルを実際にリリースする直前になると、リリース時に万一事故が起こったときの切り戻しを考えるようになりました。

しかし、我々のアプリケーションではデプロイやロールバックのために数分程度かかってしまいます。 つまり、リリース後にページが見れなくなるなどの問題が起こってしまった場合には数分間にわたってユーザーに迷惑がかかってしまいます。

そこで、デプロイなしでリリースするために外部のデータストアから指定するテンプレートを読み込めるようにしました。 Redis や memchached などの書き換えが容易なデータストアにテンプレート指定を保存し、リクエストごとに読み込むようにすることによって切り戻しにかかる時間を数秒程度まで短くすることができます。

パフォーマンスなど注意すべき点はありますが、比較的小規模なアプリケーションであることやリリース前後のみ使用するということを考慮して採用しました。

組み合わせると

それぞれのテンプレート指定方法を組み合わせて以下のような形で運用していました。

def preferred_template
  # preferred_template_by_* は 'new', 'old', nil のいずれかを返す
  # 優先順位を query, session, data store, env の順に設定
  preferred_template_by_query || preferred_template_by_session || preferred_template_by_configuration || preferred_template_by_env || 'old'
end

応用:段階的リリース

リクエストごとにテンプレート指定を柔軟に変更できるようになると「スタッフのアカウントに対して一足先に新デザインをリリースする」「ユーザー ID の末尾2桁が10以下のユーザーに対してのみ新デザインをリリースする」といったようにリリース対象を少しずつ広げるというようなことも可能になります。

おわりに

規模が大きくなることを避けられないデザインの全面リニューアルをスムーズに行なうために使用したテンプレートの柔軟な切り替え方法を紹介しました。

この方法を用いた結果、開発者以外のメンバーも含めて早い段階から production で新しいデザインを確認することができ、バグの早期発見に繋げることができました。 また、リリースまでに新しいデザインを触る時間を十分取れたため、リリース規模のわりには安心してリリースすることができましたし、事実としてもリリース後に大きな不具合は起こりませんでした。

ここまで触れませんでしたが、画面に関連しているものとしてテストがあります。しかし、古いテストを隔離してテストの中で指定するテンプレートを切り替えるという方針は同じです。

リリース後しばらくして問題なく動いていることを確認できたら、テンプレートを切り替えの仕組みを削除して新しいデザインだけを使用するようにした上で、はじめにつくった /old ディレクトリを rm -rf してリニューアル完了です。

影響範囲が大きいリニューアルをすることはあまり多くはないと思いますが、もし同じような状況に置かれている方の参考になれば幸いです。

*1:我々のアプリケーションでは kaminari, letter_opener_web などが含まれていました。

*2:デフォルトパターンは https://github.com/rails/rails/blob/6a902d43c76a8b5bc2ddd00b7c8af38f9fb82bdb/actionview/lib/action_view/template/resolver.rb#L209 で定義されています。

*3:切り替え方法とは別に session の書き換え方法を別途用意する必要があります

Xcode のビルドログの読込

モバイル基盤グループのヴァンサン(@vincentisambart)です。

開発者がどれくらいアプリのビルドを待っているのか気になったことありませんか?計測してみたらおもしろいかもしれません。どうすれば Xcode でビルド時間を計測できるのでしょうか。

プロジェクトの Build Phases の一番上と一番下にスクリプトを入れたら、ある程度計測できそうですが、制限が多そうですね。失敗したビルドや途中で止められたビルドは計測できないし、ビルドのどういうところに時間が掛かったのか詳しく分かりません。

ビルド時に Xcode がログを取っているはずなので、ログの中に時間が入っていないかな…?

最初から複雑なプロジェクトで試すのは不便でしかないので、始める前に Xcode (現時点で 9.1 ) で新規のプロジェクト(例えば iOS の Single View App)を作って、いじらずに1〜2回ビルドします。以下の調査はそのビルドで生成されたファイルを見ます。

ビルドログの在り処

求めているデータが入っているのを確認するために、まずどこに保存されているのを探す必要があります。

既に知っている開発者が多いかと思いますが、 Xcode はビルド時に生成する殆どのファイルを ~/Library/Developer/Xcode/DerivedData/<アプリ名>-<ID> に入れます。そのディレクトリの中を見てみると、 Logs/Build にビルドログが入っていそうですね。最近ビルドされたプロジェクトの場合、そこに Cache.db というファイルと、拡張子が xcactivitylog のファイルが入っています。

因みに、ビルドログがビルド終了後に更新されるので、ビルドの途中は前のビルドのログしか見られないようです。

Cache.db

Cache.db の中身をエディターなどで見てみると、バイナリファイルではありますが、頭に bplist があります。バイナリ plist なのでは?ターミナルで plutil -p を使って中身を見てみましょう。

$ plutil -p Cache.db
{
  "logs" => {
    "4E46321A-9204-42C9-AC76-BF6F01B77E64" => {
      "timeStartedRecording" => 532831205.501172
      "timeStoppedRecording" => 532831210.725163
      "domainType" => "Xcode.IDEActivityLogDomainType.BuildLog"
      "title" => "Build BlogTest"
      "signature" => "Build BlogTest"
      "schemeIdentifier-schemeName" => "BlogTest"
      "schemeIdentifier-containerName" => "BlogTest project"
      "schemeIdentifier-sharedScheme" => 1
      "documentTypeString" => "<nil>"
      "highLevelStatus" => "S"
    }
    "A6D6AD38-4367-439C-8021-31156A579B81" => {
      "timeStartedRecording" => 532831597.574763
      "timeStoppedRecording" => 532831597.597417
      "domainType" => "Xcode.IDEActivityLogDomainType.BuildLog"
      "title" => "Build BlogTest"
      "signature" => "Build BlogTest"
      "schemeIdentifier-schemeName" => "BlogTest"
      "schemeIdentifier-containerName" => "BlogTest project"
      "schemeIdentifier-sharedScheme" => 1
      "documentTypeString" => "<nil>"
      "highLevelStatus" => "S"
    }
  }
  "logFormatVersion" => 8
}

時間

timeStartedRecordingtimeStoppedRecording が興味深いですね。 time という名前だけど、浮動小数点数のようですね。よく考えてみると、 Swift で Date を浮動小数点数から作成する方法が幾つかあります:

  • Date(timeIntervalSinceNow: TimeInterval)
  • Date(timeIntervalSince1970: TimeInterval)
  • Date(timeIntervalSinceReferenceDate: TimeInterval)

Date(timeIntervalSinceNow:) は呼ばれるタイミングによって結果が変わるので、違うはずですね。

全般的に、タイムスタンプは 1970 年からの秒数がよく使われるので、 Playground で試してみましょう。

Date(timeIntervalSince1970: 532831205.501172)
"Nov 20, 1986 at 9:40 AM"

ビルドしたばかりなので、 1986 年のはずがない(笑)

Date(timeIntervalSinceReferenceDate:) だとどうなるんだろう。

Date(timeIntervalSinceReferenceDate: 532831205.501172)
"Nov 20, 2017 at 9:40 AM"

お、丁度いい!実際ビルドにどれくらい掛かったのかは timeStoppedRecordingtimeStartedRecording を引けば秒数が分かるので Date にする必要ないのですが(笑)

因みに、 timeIntervalSinceReferenceDate が Apple 独自のものだとはいえ、 Ruby でも簡単にできます。

APPLE_REFERENCE_DATE = Time.new(2001, 1, 1, 0, 0, 0, 0) # 2001/01/01 00:00:00 UTC
def time_from_time_interval_since_reference_date(time_interval)
  APPLE_REFERENCE_DATE + time_interval
end

time_from_time_interval_since_reference_date(532831205.501172).getlocal
# => 2017-11-20 09:40:05 +0900

他の項目

Cache.db の他の項目は分かりやすいものが多いですね。

logs に入っている GUID が同じディレクトリに入っている xcactivitylog ファイルのファイル名と一致しています。

logFormatVersion は Xcode のバージョンによるもののようです。 Xcode 8.3.3 が生成した Cache.dblogFormatVersion は 7 ですが、 Xcode 9.0~9.1 が生成したやつはlogFormatVersion が 8 です。でも logFormatVersion 7 も 8 も Cache.db の中身が同じのようです。

これでビルド時間が正確に分かります。ただし、詳細が分かりませんし、ビルドが成功したのかどうか分かりません。

xcactivitylog

もっと詳しくは xcactivitylog ファイルの中身を見る必要があるかもしれません。少しネットで調べてみたら、 xcactivitylog の中身が gzip で圧縮されているらしいことが分かりました。

でも gzip -cd で展開してみると、テキストファイルに見えなくもないが、変な文字が入っているし、改行がおかしいし、時間らしいものが見当たりません…一応ファイルの最後を見ると Build stopped-Build failed-Build succeeded- でビルドの結果が分かります。ファイル名と Cache.db に入っている GUID が一致するので、情報を合わせるとビルド時間とビルド結果が分かりますけど、詳細がまだ…

トークン読込

ネットでもう少し調べてみたら Haskell で書かれた xcactivitylog を読み込むコードがありました。結局テキストファイルじゃなかった。

Haskell はよく分からないけど、 Haskell でのコードやそのコメントを見ながら、 xcactivitylog を Ruby スクリプトで読み込もうとして試行錯誤で分かった形式は以下の通りです。

まず、ファイルが SLF0 で始まって、その後はトークンのリストが並んでいるだけです。

トークンは以下の7種類のようです。

正規表現 種類 頭の数字が表しているもの
- nil
[0-9]+# 数字
[0-9]+" 文字列 文字列の長さ
[0-9]+\( リスト リストに入っている項目の数
[0-9]+% クラス名 クラス名の長さ
[0-9]+@ オブジェクト クラス名の番号(% で定義された最初のクラス名が 1 となる)
[a-f0-9]{16}\^ 浮動小数点数 16進法でメモリ上のリトルエンディアンの64-bitの浮動小数点数(double)

では、 Ruby で読み込むスクリプトを書きましょう。まず gzip で圧縮されたデータを展開します。

require 'zlib'

raise "Syntax: #{$0} file.xcactivitylog" unless ARGV.length == 1
file_path = ARGV[0]
raw_data = Zlib::GzipReader.open(file_path, encoding: Encoding::BINARY) { |gzip| gzip.read }

その後、トークンを1個ずつ読み込みます。

require 'strscan'
scanner = StringScanner.new(raw_data)

# なぜか StringScanner に特定の文字数を読み込むメソッドはないので生やす
def scanner.read(length)
  string = peek(length)
  self.pos += length
  string
end

raise 'Invalid format' unless scanner.scan(/SLF0/)
class_names = []
tokens = []

while !scanner.eos?
  if scanner.scan(/([0-9]+)#/) # integer
    value = scanner[1].to_i # 頭の数字が値
    tokens << { type: :int, value: value }
  elsif scanner.scan(/([0-9]+)%/) # class name
    length = scanner[1].to_i # 頭の数字がクラス名の長さ
    name = scanner.read(length)
    raise "Class name #{name} should not be present multiple times" if class_names.include?(name)
    class_names << name.to_sym
  elsif scanner.scan(/([0-9]+)@/) # object
    class_index = scanner[1].to_i # 頭の数字がクラスの番号(最初に定義されたクラスが 1)
    raise "Unknown class reference #{class_index} - Known classes are #{class_names.join(', ')}" if class_index > class_names.length
    tokens << { type: :object, class_name: class_names[class_index-1] }
  elsif scanner.scan(/([0-9]+)"/) # string
    length = scanner[1].to_i # 頭の数字が文字列の長さ
    string = scanner.read(length)
    tokens << { type: :string, value: string }
  elsif scanner.scan(/([0-9]+)\(/) # list
    # 頭の数字がリストの項目数
    count = scanner[1].to_i
    tokens << { type: :list, count: count }
  elsif scanner.scan(/([a-f0-9]+)\^/) # double
    hexadecimal = scanner[1] # 16進法でメモリ上のリトルエンディアンのdouble
    # "cf4c80e55bc2bf41" -> ["cf", "4c", "80", "e5", "5b", "c2", "bf", "41"]
    characters_grouped_by_2 = hexadecimal.each_char.each_slice(2).map(&:join)
    # ["cf", "4c", "80", "e5", "5b", "c2", "bf", "41"] -> [207, 76, 128, 229, 91, 194, 191, 65]
    bytes = characters_grouped_by_2.map { |hex| hex.to_i(16) }
    # [207, 76, 128, 229, 91, 194, 191, 65] -> "\xCFL\x80\xE5[\xC2\xBFA" -> [532831205.501172] -> 532831205.501172
    double = bytes.pack('C*').unpack('E').first
    tokens << { type: :double, value: double }
  elsif scanner.scan(/-/) # nil
    tokens << { type: :nil }
  else
    raise "unknown data #{scanner.peek(30).inspect}"
  end
end

require 'pp'
pp tokens

シンプルなプロジェクトのビルドで生成された xcactivitylog ファイルを上記のスクリプトに読み込ませると以下のような出力が出ます。

[{:type=>:int, :value=>8},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>0},
 {:type=>:string, :value=>"Xcode.IDEActivityLogDomainType.BuildLog"},
 {:type=>:string, :value=>"Build BlogTest"},
 {:type=>:string, :value=>"Build BlogTest"},
 {:type=>:double, :value=>532831205.501172},
 {:type=>:double, :value=>532831210.725163},
 {:type=>:list, :count=>1},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>1},
 {:type=>:string,
  :value=>"Xcode.IDEActivityLogDomainType.target.product-type.tool"},
 {:type=>:string, :value=>"Build target BlogTest"},
 {:type=>:string, :value=>"BlogTest-ehwnkjvfrwpvqwdylenlszdndskk"},
 {:type=>:double, :value=>532831205.611886},
 {:type=>:double, :value=>532831210.71247},
 {:type=>:list, :count=>7},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:double, :value=>532831205.611923},
 {:type=>:double, :value=>532831205.613694},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>1},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Compile Swift source files"},
 {:type=>:string,
  :value=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler"},
 {:type=>:double, :value=>532831205.61325},
 {:type=>:double, :value=>532831209.491755},
 {:type=>:list, :count=>2},
 (略)
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"4E46321A-9204-42C9-AC76-BF6F01B77E64"},
 {:type=>:string, :value=>"Build succeeded"},
 {:type=>:nil}]

もっと多くの情報が取れそう。でも上記のスクリプトと出力に不自然だと思われるところがあるかもしれません。なぜリストは作らずに項目数を取っておくだけ?オブジェクトはクラス名は分かるけど中身は?

実はオブジェクトトークンは「ここからこのクラスのオブジェクトが始まる」ことを表しています。オブジェクトの属性はその直後に来るいくつかトークンです。ただし属性の種類や数は分かりません。 Xcode はもちろん各クラスの属性を分かっているでしょうけど、僕らは色々調査してみるしかありません。

リストは入っているオブジェクトの属性の数が分からないと各オブジェクトがどこまでなのか分からないのでまだ作れません。

属性の種類や数は少し時間掛かるけどそこまで難しくありません。

ログバージョン

オブジェクトに入っている属性に集中する前に、まずファイルの最初のトークンを見ましょう。8Cache.db に入っていた logFormatVersion と同じ。偶然? Xcode 8.3 でアプリをビルドしてみて、生成されたログでは、 Cache.dblogFormatVersion 同様 7 になります。やっぱり、 logFormatVersion でしょう。因みに、 xcactivitylog は見てみた限りでは、 78 で変わった部分が1ヶ所があります(具体的には IDEActivityLogSection の最後に項目が1つ追加された)。

分かりやすさのため、以下は Xcode 9 のログ形式バージョン 8 だけに集中します。

オブジェクトの属性を調査

属性はどうしましょう。試行錯誤するしかないですね。トークンのリストを見て仮説をたてて、その仮説を元にスクリプトを変えて、スクリプトをいくつかの xcactivitylog ファイルに処理させてみて、結果によって仮説とスクリプトを調整する、の繰り返しです。

トークンのリストを見ると、 IDEActivityLogSection がいつも以下のような項目で始まるようですね。その仮説を検証してみましょう。

 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Compile Swift source files"},
 {:type=>:string,
  :value=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler"},
 {:type=>:double, :value=>532831205.61325},
 {:type=>:double, :value=>532831209.491755},
 {:type=>:list, :count=>2},
 {:type=>:object, :class_name=>:IDEActivityLogSection},

仮説を検証するために、期待していない値がある時点ですぐ raise (例外発生)をしましょう。以前のスクリプトが出したトークンのリストを見れば属性の型はある程度分かるけど、名前は分からないので一旦 fieldXXX にします。 IDEActivityLogSection を幾つか見てみると7番目に入るリストは nil になることもあるようなのでそれに対応しました。最初からそれに気づかなくても問題ありません。実行したらエラーが出て、直して、また実行する、の繰り返しなので。あと開発中、コード内にデバッグ出力のため ppp をよく使いますが、読む時はノイズになるので以下のコードではそれを省きました。また、このブログが長くなりすぎないように、細かい試行錯誤については省略しています。

class TokenReader
  def initialize(tokens)
    @tokens = tokens.dup
  end

  def tokens_left_count
    @tokens.length
  end

  def read(expected_type, args = {})
    token = @tokens.shift
    return nil if token[:type] == :nil && args[:nullable]
    raise "Expecting token of type #{expected_type.inspect} but got #{token.inspect}" if token[:type] != expected_type

    case expected_type
    when :list
      expected_class_name = args[:class_name]
      (0...token[:count]).map { read(:object, class_name: expected_class_name) }

    when :object
      expected_class_name = args[:class_name]
      class_name = token[:class_name]
      raise "Expected an object of class #{expected_class_name} but got an instance of #{class_name}" if class_name != expected_class_name
      fields = { class_name: class_name }
      case class_name
      when 'IDEActivityLogSection'
        fields[:field1] = read(:int)
        fields[:field2] = read(:string)
        fields[:field3] = read(:string)
        fields[:field4] = read(:string)
        fields[:field5] = read(:double)
        fields[:field6] = read(:double)
        fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)

      else
        raise "Unknown class name #{class_name}"
      end
      
      fields

    else
      token[:value]
    end
  end
end

# tokens は上記のスクリプトで生成したもの
reader = TokenReader.new(tokens)
log_format_version = reader.read(:int)
raise "Unknown log format version #{log_format_version}" if log_format_version != 8
pp reader.read(:object, class_name: :IDEActivityLogSection)
p reader.tokens_left_count

実行してみたら Expecting token of type :object but got {:type=>:nil} と怒られました。スタックトレースを見ると、リストを読み込もうとしている時です。もう少し調査してみると、7つめの属性である IDEActivityLogSection のリストは1項目が無事に読み込まれたけど2項目目を読もうとしている時にエラーが起こります。リストの全項目が同じ型を想定していましたが、 IDEActivityLogSection の直後に nil が入っている。リストにオブジェクトに混ざって nil が入っていると考えにくいので、理由は別にありそうです。

リストの始めからエラーが起きた少しあとまでのトークンを見てみましょう。

 {:type=>:list, :count=>7},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:double, :value=>532831205.611923},
 {:type=>:double, :value=>532831205.613694},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>1},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:object, :class_name=>:IDEActivityLogSection},

このリストには項目が7つもある。どの項目も IDEActivityLogSection の可能性が高い。なら少し下にある IDEActivityLogSection はリストの2項目目なのでは?別のオブジェクトの属性の可能性もありますが、まずそれで試してみましょう。

fields[:field1] = read(:int)
fields[:field2] = read(:string)
fields[:field3] = read(:string)
fields[:field4] = read(:string)
fields[:field5] = read(:double)
fields[:field6] = read(:double)
fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)
fields[:field8] = read(:nil)
fields[:field9] = read(:nil)
fields[:field10] = read(:int)
fields[:field11] = read(:int)
fields[:field12] = read(:int)
fields[:field13] = read(:nil)
fields[:field14] = read(:nil)
fields[:field15] = read(:string)
fields[:field16] = read(:string)
fields[:field17] = read(:nil)
fields[:field18] = read(:nil)

また実行してみましょう。field14 を読み込もうとする時に以下のエラーが出ました。

Expecting token of type :nil but got {:type=>:object, :class_name=>:DVTDocumentLocation}

field14nil の場合もあれば、 DVTDocumentLocation のインスタンスの場合もあるようですね。

fields[:field14] = read(:object, nullable: true, class_name: DVTDocumentLocation)

DVTDocumentLocation の中身も探る必要がありますね。

 {:type=>:object, :class_name=>:DVTDocumentLocation},
 {:type=>:string,
  :value=>
   "file:///Users/vincent-isambart/Desktop/BlogTest/BlogTest/main.swift"},
 {:type=>:double, :value=>0.0},
 {:type=>:string,
  :value=>"CompileSwift normal x86_64 (略)"},
 {:type=>:string, :value=>"1D50F5EA-D2D1-4F45-9017-8D2CEFE85CBC"},
 (略)
 {:type=>:object, :class_name=>:DVTDocumentLocation},
 {:type=>:string,
  :value=>"file:///Users/vincent-isambart/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftmodule"},
 {:type=>:double, :value=>140736883871744.0},
 {:type=>:string,
  :value=>"MergeSwiftModule normal x86_64 (略)"},
 {:type=>:string, :value=>"D19441A3-B3BE-4814-B29D-173A5F24F876"},

DVTDocumentLocation の属性はどこまででしょうか。ログファイル全体のトークンのリストを見て、ヒントになりそうなところを探しましょう。 IDEActivityLogSectionfield14nil の場合がありますね。その時、直後の field15field16 が以下の通りだったところがあります。

 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},

DVTDocumentLocation の属性が2つだったらうまくいきそうです。それでやってみましょう。

when :DVTDocumentLocation
  fields[:field1] = read(:string)
  fields[:field2] = read(:double)

その後出ていた nullable 関連のエラーをちょこっと直したら、テストで使っていたすごくシンプルなプロジェクトのビルドログが無事に解析できました。オブジェクトの属性の読込が以下のようになりました。

case class_name
when :IDEActivityLogSection
  fields[:field1] = read(:int)
  fields[:field2] = read(:string)
  fields[:field3] = read(:string)
  fields[:field4] = read(:string)
  fields[:field5] = read(:double)
  fields[:field6] = read(:double)
  fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)
  fields[:field8] = read(:nil)
  fields[:field9] = read(:nil)
  fields[:field10] = read(:int)
  fields[:field11] = read(:int)
  fields[:field12] = read(:int)
  fields[:field13] = read(:string, nullable: true)
  fields[:field14] = read(:object, nullable: true, class_name: :DVTDocumentLocation)
  fields[:field15] = read(:string, nullable: true)
  fields[:field16] = read(:string)
  fields[:field17] = read(:string, nullable: true)
  fields[:field18] = read(:nil)

when :DVTDocumentLocation
  fields[:field1] = read(:string)
  fields[:field2] = read(:double)

else
  raise "Unknown class name #{class_name}"
end

IDEActivityLogSection を読み込んだあとに残っているトークンを見ようとしたら、トークンが残っていないので、ファイルに入っているのはログバージョンと1つの IDEActivityLogSection だけのようですね。もちろんその IDEActivityLogSection には色々入っています。

もう少し複雑なビルドログで同じことを繰り返したら、こんな感じになりました。

命名

オブジェクトを読み込めたのはいいのですが、オブジェクトに入っている属性に名前がまだありません。どう付ければいいのでしょうか。

まず、 IDEActivityLogSection に入っている2つの double に簡単に名前を付けられます。最初に読み込もうとした xcactivitylog ファイルでは最初の2つ double532831205.501172532831210.725163 でした。見た覚えあるような…そう、 Cache.db に入っていた timeStartedRecordingtimeStoppedRecording と同じ値なので、 Cache.db に入っていた名前を使えばいいです。

同様、 Cache.db の中身と比べて domainTypetitlesignature も分かります(titlesignature は値が同じなのでどっちがどっちか逆になってしまうかもしれませんが)。

あとはクラス名や値自体を元に名前を付けてみましょう。何もないよりマシです。DVTDocumentLocation の最初の項目が file:///Users/... で始まる文字列なので名前は url で良さそう。 DVTDocumentLocation が入る属性は location でいいんじゃないかな。 IDEClangDiagnosticActivityLogMessageIDEActivityLogMessage のリストは messages でいかが。

一部の項目に名前を付けたスクリプトのバージョンがこちらで見られます。因みにログバージョン 7 にも対応しています。

もっと多くの属性に名前を付けるには方法が色々ありそうです。例えば意図的にビルドログに影響ありそうなもの(ビルド結果、警告、エラー)を変えて、何が変わったのかを見て名前を付けられそうですね。僕は目的がビルド時間だけだったのでそこまでやっていませんが。

名前を付けているスクリプトをシンプルなログに実行すると以下のような出力が出ます。読みやすさのためにクラス名、 nil な値、各 Swift ファイルのビルド詳細、を省いておきました。各ステップにどれくらい時間が掛かったのかがよく分かります。

{:domain_type=>"Xcode.IDEActivityLogDomainType.BuildLog",
 :title=>"Build BlogTest",
 :signature=>"Build BlogTest",
 :time_started_recording=>532831205.501172,
 :time_stopped_recording=>532831210.725163,
 :result=>"Build succeeded",
 :subsections=>
  [{:domain_type=>"Xcode.IDEActivityLogDomainType.target.product-type.tool",
    :title=>"Build target BlogTest",
    :signature=>"BlogTest-ehwnkjvfrwpvqwdylenlszdndskk",
    :time_started_recording=>532831205.611886,
    :time_stopped_recording=>532831210.71247,
    :subsections=>
     [{:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Check dependencies",
       :signature=>"Check dependencies",
       :time_started_recording=>532831205.611923,
       :time_stopped_recording=>532831205.613694},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Compile Swift source files",
       :signature=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler",
       :time_started_recording=>532831205.61325,
       :time_stopped_recording=>532831209.491755,
       :subsections=>[(略)]
       :location=>{:url=>"file:///Users/user-name/Desktop/BlogTest/BlogTest/main.swift"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest-Swift.h",
       :time_started_recording=>532831209.492459,
       :time_stopped_recording=>532831209.500314,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Link /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :signature=>"Ld /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest normal x86_64",
       :time_started_recording=>532831209.500942,
       :time_stopped_recording=>532831210.568323},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftdoc",
       :time_started_recording=>532831209.50099,
       :time_stopped_recording=>532831209.507525,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftmodule",
       :time_started_recording=>532831209.50093,
       :time_stopped_recording=>532831209.507456,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Sign /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :signature=>"CodeSign /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :time_started_recording=>532831210.571077,
       :time_stopped_recording=>532831210.711349,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest"}}]}]}

集計

ログファイルを読み込めたのは良いけど、それでどうやって開発者のビルド時間を集計できるのでしょうか。僕はプロジェクトの Build Phases で Xcode にスクリプトを実行させています。スクリプトがまだ処理されていないログファイルから必要なデータだけを抽出してサーバーに送ります。ビルドが終わるまでログファイルが生成されないので、データは1個前のビルドになりますが、実行された日時が入っているのでデータが少し遅れて送られれても問題ありません。

この仕組は制限が色々あります。1個前のビルドログなので、処理が走る前にログが削除されたらデータがなくなります。 DerivedData 内のデータを自分で消さなくても、例えば別のログバージョンを使う Xcode で同じプロジェクトを開くとビルドログが全部削除されるようです。

でもビルド時間の計測が完璧じゃなくていいのではないでしょうか。

まとめ

どうやって Xcode のビルド時間を計測できるのか考えてみたら、 Xcode のログファイルからできないのか試してみました。結果的にビルドの各ステップの時間まで取得できるようになりました。

弊社では、集計されたビルド時間をグラフ化して、開発者が毎日どれくらいビルドを待っているのか、何回ビルドを実行しているのか、ビルドに平均でどれくらい時間が掛かるのか、が見えるようにしています。

そのデータでビルド時間短縮の必要性を証明できるようになったと思います。

サービスイメージをより魅力的に見せる写真撮影

国内事業開発部のデザイナー、木村です。私が現在携わっている「おうちレッスン」*というサービス上で利用する写真を撮影するために、久しぶりにプロのカメラマンさんとお仕事をする機会がありました。

今回は、外部のカメラマンさんとサービスのイメージ写真を撮影する際に参考になりそうなことをブログに残そうと思います。

ヒアリング

まず写真撮影の目的を定めます。どういった目的で、ユーザーにどんな印象を与えたいか、ゴールはなにか、といった項目を洗い出し、オーナーと話し合いました。

今回は「ランディングページやサービス上で、サービスの魅力をユーザーに伝えたい」「楽しさや親しみやすさが増幅されるようなイメージ」「気取らない、日常の延長線上で」といった要望が上がりました。

イメージを固める

ヒアリングが終わったら、次はサービスのイメージを固めます。

以下は私がチームに参加した際に、競合・類似サービスなどをまとめ、ポジショニングしたデザインのマトリクス図です。

f:id:mura24:20171201152034p:plain

また、撮影する写真のイメージをキーワードとして書き出し、イメージに齟齬はないかも合わせて確認しました。

f:id:mura24:20171201152041p:plain

これらのすり合わせの作業をどこまで深掘りするかは、プロジェクトへの携わり方で調整するとよいでしょう(今回はサービス全体設計からの参加だったので結構がっちりやっています)。

トンマナのすり合わせ

並行して、写真のトンマナのサンプルをPinterestのシークレットボードを利用して収集しました。方向性をオーナーに確認してもらい、問題なさそうであれば、具体的なカットのラフ作業へ進みます。

f:id:mura24:20171201152044p:plain

絵コンテの作成

サービスのイメージを踏まえながら、ユーザーストーリーに沿って、デザイン上必要なカットを割り出し、絵コンテに起こしていきます。

f:id:mura24:20171201174301p:plain

テキストを交えながら、オーナーやカメラマンに、おうちレッスンのリラックスした和やかな雰囲気が伝わるよう気を配りました。

なお、イラストが不得手、イメージ通りのサンプル写真が用意できない場合などは、既存のストックフォトをコラージュしたり、自分やメンバーを素材としてスマホで撮影してコンテを用意するのもよいでしょう。

方法はなんでもよいので、なるべくコストをかけず、頭の中のイメージをスピーディーにメンバーに共有できる方法を選ぶことが重要です。

実際に写真をデザインに当て込んだデザインカンプなどがあれば、より完成形をイメージしやすくなるので、準備しておくとよいでしょう。

カメラマンとの打ち合わせ

写真のイメージ・必要カットが確定したらカメラマンと打ち合わせをします。

サービスの概要説明、写真のイメージ、絵コンテなどを元に、スケジュール、撮影場所や衣装、必要な機材などの相談と確認を行いました。

画角について

今回は、一部、9:16(スマートフォン縦サイズ)での利用を検討しておりましたが、コンテでその場合の指定が不十分であったことが判明しました。

複数の画角で撮影を予定している場合は、予めリストアップし、そのフレームに応じてコンテを切るようにしましょう。

撮影場所・衣装・小物について

今回は実際のユーザーさんのお宅にお邪魔して撮影することになっていたので、当日の間取りの確認、ユーザーさんの衣装(服装や髪形のかぶりがないか、サービスのイメージに沿った服装の依頼など)、当日のメニューの確認などを行いました。

撮影

撮影日当日は、プロダクトオーナー・カメラマン・デザイナーの3名でユーザーさんのお宅に伺い、撮影に協力してくださるユーザーさんに、絵コンテを見せながら撮影のイメージを伝え、撮影に望みました。

撮影立会時に、今回は私が撮影の進行管理・写真の確認などを担当しました。

進行管理では、撮影順の調整、カットの抜け漏れがないかのチェック、時間帯や天候により撮影状況が変わってしまった際の判断(カット数の増減、ほかのシチュエーションへの変更)なども行います。今回の撮影では、天候に恵まれ滞りなく進行できました。

カットごとに撮影時間を確保する

今回はユーザーさんが実際にお料理する流れに沿って、リラックスした自然な様子を撮影したいと考え、撮影に望みました。

ですが、被写体が複数人の場合、状況をコントロールしないと、誰かが目をつぶってしまっている、画面に対し立ち位置が左右一方に寄りすぎてしまうということが発生しがちです。

サービスの利用シーンを撮影する場合は、手順ごとに手を止めて、撮影時間を確保してから進行させたほうが結果的にスムーズに撮影が進みました。

写真確認

撮影終了後、追ってカメラマンから確認用のデータが送られてくるので、その中から納品用のカットを指定します。

f:id:mura24:20171201152053j:plain

私は昔から利用しているAdobe Bridgeを使用して、写真選別を行っております。Finderでも似たようなことは可能ですが、ビューワー機能のあるツールを利用し、効率よく写真を選別してゆくのがオススメです。

納品

納品してもらう写真が決まったら、納品形式(ファイル形式、カラープロファイル、画像サイズなど)を指定して、撮影データを受け取ります。

進行・撮影ともに、カメラマン、そして参加してくださったユーザーさんにかなり助けていただだいたおかげで、めちゃんこエモい写真に仕上がったので、一部公開します。

f:id:mura24:20171201152517j:plain

Photo by 福田 栄美子

おわりに

目的を定め、最小限のコストでチーム内で合意を取りながらユーザーに届ける…というプロセスは、写真撮影でもサービス開発でも、そう違いはありません。

自社サービス以外にも、採用募集やイベント用の写真などで、デザイナーに写真撮影の相談が入ることも多いと思います。みなさまの参考になれば幸いです。


*…「おうちレッスン」は現在クローズドテスト中のCtoC料理教室プラットフォームです。