Swiftプロジェクトのビルド時間を計測・改善するxcprofilerを作った話

技術部モバイル基盤グループの@です。

我々のチームでは、iOS/Androidアプリの認証、決済、ロギングと言った基幹部分の開発のほか、各事業部のモバイルエンジニアの開発効率を上げるための業務改善を日々行っています。

その一環として、さまざまなモバイル開発上の指標を収集・監視し、問題の発見や、施策への効果計測に利用できるようにしています。 例として、iOS/AndroidのCIの実行時間や、開発期間中のissueの量の変化、コード全体のSwift対応率などがあります。

f:id:gigi-net:20161228111203p:plain

f:id:gigi-net:20161228111221p:plain

収集したデータは、オープンソースのデータビジュアライゼーションツールであるGrafana上にダッシュボードを作成し、監視しています。

この記事では、iOS版クックパッドアプリでビルド時間を計測、改善をした事例についてご紹介します。

コマンドごとの実行時間の計測

まず、CIサーバーで実行されている各Shellコマンドについて、コマンドごとの実行時間を調べ、合計時間の経過を監視するようにしました。

しかし、コマンドごとに見てみると、この計測方法では、xcodebuildに大部分の時間がかかっていることが判明し、これだけでは具体的な改善が難しい状況でした。

f:id:gigi-net:20161228111238p:plain

ビルド時間の内訳について詳細に把握する方法はないのでしょうか。

コンパイラフラグによるビルド時間の計測

この問題はXcode標準のデバッグ機能を利用することで解決します。

Xcodeプロジェクトに以下のコンパイラフラグを追加すると、それぞれのメソッドについてのビルド時間を計測してくれるようになります。

-Xfrontend -debug-time-function-bodies

f:id:gigi-net:20161228111139p:plain

しかし、結果はプレーンテキストとして出力され、閲覧、収集のしづらいデータフォーマットでした。

また、このログを整形して表示してくれるGUIツールはありましたが、CI上で実行することができず、運用が難しい問題もありました。

xcprofiler

そこで、Swiftコードのビルド時間をメソッド単位で計測して様々な形式で出力するユーティリティ「xcprofiler」を開発しました。

xcprofilerはRubyGemsから導入できます。

gem install xcprofiler

xcprofilerは最新のビルドログを自動で取得し、整形して表示してくれるCLIを提供します。

例えば、以下はCookpadのiOSアプリで実行した例です。

$ xcprofiler Cookpad -l 10
+-------------------------------------------------------+------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+
| File                                                  | Line | Method name                                                                                                                                                   | Time(ms) |
+-------------------------------------------------------+------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift              | 9    | final didSet {}                                                                                                                                               | 5003.8   |
| xxxxxxxxx.swift                                       | 44   | init(title: String?, message: String?, content: Content?, actions: [AlertAction])                                                                             | 143.8    |
| xxxxxxxxxxxxxxxxxxxxxxxxx.swift                       | 11   | func cellHeightFromConstraints<CellType : UITableViewCell>(tableView: UITableView, createFromNib: Bool = default, cellUpdater: ((CellType) -> ())) -> CGFloat | 117.0    |
| xxxxxxxxxxxxxxxxxxxxx.swift                           | 55   | @objc func myFolderDidRemoveRecipeNotification(_ notification: Notification)                                                                                  | 115.9    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift                    | 136  | private func assets(in rects: [CGRect]) -> [PHAsset]?                                                                                                         | 115.4    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift | 4    | private static func makeInconsistentNotificationSettingAlert() -> AlertViewController                                                                         | 114.0    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift           | 5    | @objc(showFromViewController:completion:) static func show(from viewController: UIViewController, completion: @escaping (Bool) -> Void)                       | 107.9    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift                 | 8    | @objc override init(frame: CGRect)                                                                                                                            | 102.5    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift                 | 31   | private final func configureAppearance()                                                                                                                      | 102.1    |
| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.swift           | 7    | private static func makeAlertController() -> AlertViewController                                                                                              | 102.1    |
+-------------------------------------------------------+------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+

内部では、Xcodeの出力したビルドログを取得、デコードし、ビルド時間についての出力のみを抽出しています。

カスタムレポーターを実装して、メトリクスを監視する

xcprofilerにはReporterという仕組みがあり、独自のReporterを実装することで、解析結果を様々な方法で出力することができます。

今回は、実行結果をGrafana上で閲覧するために、データソースであるInfluxDB向けのReporterを実装し、最新の実行結果をテーブルにしてみました。

#!/usr/bin/env ruby
require 'xcprofiler'
require 'influxdb'
include Xcprofiler

INFLUX_DB_TABLE_NAME = 'table_name'

class InfluxDBReporter < AbstractReporter
  def report!(executions)
    client ||= InfluxDB::Client.new(
      ENV['INFLUXDB_DB_NAME'],
      host: ENV['INFLUXDB_HOST'],
      port: ENV['INFLUXDB_PORT'],
      username: ENV['INFLUXDB_USERNAME'],
      password: ENV['INFLUXDB_PASSWORD'],
    )

    payload = executions.map { |e|
      key = "#{e.filename}:#{e.line}:#{e.column} #{e.method_name}"
      Hash[*[key, e.time]]
    }.reduce(&:merge)
    client.write_point(INFLUX_DB_TABLE_NAME, {values: payload})
  end
end

profiler = Profiler.by_product_name('Cookpad')
profiler.reporters = [
  StandardOutputReporter.new(limit: 20, order: :time),
  InfluxDBReporter.new(limit: 20),
]
profiler.report!

このようなスクリプトを実装し、CI上から実行すれば、最新のビルド時間をGrafana上で定常的に監視することができます。

f:id:gigi-net:20161228111315p:plain

Swiftのビルド時間を高速化する

では実際に、これらのデータを元にSwiftコードのビルド時間をカイゼンしてみましょう。

一番遅いメソッドは、ビルドに5003ms、約5秒もの時間がかかっています。 この値はCPU時間で、実時間ではないのでしょうが、他のメソッドと比べると特異的に遅いヶ所であることがわかります。

該当箇所のコードを実際に見てみましょう。

// 日付ラベルを表示するために、日付の区切りとなる写真のインデックスを求める
// (例) [photo(10月30日), photo(10月29日), photo(10月29日), photo(10月28日), photo(10月28日), photo(10月27日)]
// という配列の場合、日付の区切りとなるインデックスは [0, 1, 3, 5]
let indicesShowDateLabel = [0] + photos.reduce([]) { (photosGroupedByDate, photo) -> [[Photo]] in
    var result = photosGroupedByDate
    if let previousPhotoDate = photosGroupedByDate.last?.last?.cookedDate, calendar.isDate(photo.date, inSameDayAs: previousPhotoDate) {
        result[result.count - 1].append(photo)
    } else {
        result.append([photo])
    }
    return result
}
.map { $0.count }
.reduce([]) { (result, element) -> [Int] in
    return result + [(result.last ?? 0) + element]
}
.dropLast()

このビルド時間の遅さは、このように大量のメソッドチェイニングが行われていることにより、型推論が複雑になっていることが原因だと推測できます。

試しに、メソッドチェイニングをやめ、適宜テンポラリな変数に格納し、型情報を与えてみました。

// 型を明示する
let groupedPhotos: [[Photo]] = photos.reduce([]) { (photosGroupedByDate, photo) -> [[Photo]] in
    var result = photosGroupedByDate
    if let previousPhotoDate = photosGroupedByDate.last?.last?.date, calendar.isDate(photo.date, inSameDayAs: previousPhotoDate) {
        result[result.count - 1].append(photo)
    } else {
        result.append([photo])
    }
    return result
}
let counts: [Int] = groupedPhotos.map { $0.count }
indicesShowDateLabel = [0] + counts.reduce([]) { (result, element) -> [Int] in
    return result + [(result.last ?? 0) + element]
}
.dropLast()

わずかこれだけの変更でビルド時間が5003msから152msに、 つまり 97% も短縮することができました。 この値は、複数の開発者が毎日アプリをビルドし続けることを考えると大きな差と言えます。

このように現在のSwiftコンパイラによる型推論は、特定の場合においてビルド時間に多大な影響を与えてしまうことが見て取れます。 コードの簡潔さを取るか、ビルド時間を取るかはトレードオフであり、今後の課題と言えそうです。

まとめ

今回開発したxcprofilerは、どのプロジェクトでも簡単に利用でき、ワンショットで実行してみるほか、定常的な監視にも利用することができます。 ぜひご自分のプロジェクトに導入してみてください。Pull Requestもお待ちしております。

クックパッドのモバイル基盤グループでは、開発者のための問題解決が好きなモバイルエンジニアを募集しています。

iOS/Android アプリエンジニア | クックパッド株式会社 採用情報

Interface Builderをデザイナーさんに触ってもらうにあたってやったこと

こんにちは、投稿開発部の市川です(@masaichi)
主に、クックパッドiOSアプリの投稿周りの機能を担当しています。

はじめに

みなさんはiOSアプリを開発する際に、どうやってレイアウトを調整していますか? クックパッドでは大体の場合は、デザイナーにZeplinなどでレイアウトの指示書を貰いエンジニアが実装するという流れで組んでいます。 しかし、このやり方の場合、終盤の細かなデザインの調整の際に、修正と確認が細かく発生してしまい、デザイナーとエンジニアの時間を細切れに使ってしまう、という問題がありました。

今回、この解決の手段として、終盤のデザインの調整をデザイナーさん自身にInterface Builderで調整をしてもらうトライを行いました。 10月頃、iOSアプリに追加した「みんなの投稿」機能を題材に、この過程と効果を紹介します。

なぜInterface Builderを触ってもらうことにしたのか

「終盤の細かなデザインの調整の際に、修正と確認が細かく発生してしまい、デザイナーとエンジニアの時間を細かく使ってしまう」という問題の解決として、時間を決めてペアプログラミングをする、というのもあります。 これも短い時間で集中して対応することが出来るのでうまくワークします。

とはいえ、この手段もエンジニアが実装をしてデザイナーが指示を出し、というやり方になるので、こだわりきれない点もあるのでは・・と考えていました。

そんな時、夏にあったiOSDCというカンファレンスで「デザイナーにStoryboardをお任せする技術」という話を聞いたことと、一緒に仕事をしているデザイナーさんからInterface Builderを覚えたいという話があったため、トライしてみよう、ということになりました。

みんなの投稿画面

今回対象にした「みんなの投稿」は以下のような画面です。

f:id:masarusanjp:20161226102904p:plain:w320

1つのタブの中身はUITableViewを使ったリストで、リスト中の1セルは高さも固定、表示する要素も条件で出たり消えたりしない、という画面設計になっています。
Interface Builderを使った最初のトライにはこのようなシンプルな画面が適してると考え、これを選択しました。

以下、実際に機能が取り込まれるまでに行ったことを書いていきます。 「デザイナーにStoryboardをお任せする技術」に沿った内容となっています。

1. ViewControllerとView, Autolayoutのことを教える

まずは座学とペアプロでViewController, View, AutoLayoutの事をざっくりと学んでもらいました。
Interface Builderはグラフィカルで取っ付き易いツールだと思いますが、 iOSアプリケーションの構造を知らずに触っても、何が起こるのか想像がつかず、混乱するだろうと考えたからです。

事前に資料を渡し、ホワイトボードで図を交えて教え、ペアプログラミングをして実際に動かしてもらいました。

f:id:masarusanjp:20161226103058p:plain

いきなり全部を覚えて貰うのは、新しいことが多すぎるので難しいと考えて、これは2回に分けて行いました。
それぞれを1時間〜1時間半くらいで2日に分けました。

本題に入る前に補足をしますと、座学がたったの2日で合計3時間というのはかなりすんなりと行ったと思っています。 その背景として今回一緒にトライを行ったデザイナーさんは 普段からCookpad本体のRailsアプリケーションのCSSやHTMLに関しては自ら修正してGitHubでPRを投げており、ターミナルでの作業や開発フローに慣れている。 また、その開発を行う関係で「手元にXcodeなどの必要なツールキットも揃っている」というのがあると思います。

ViewControllerとView

初回ではViewControllerViewについて学んでもらいました。
大事な点は2つあると考えています。

  • iOSアプリケーションで、ViewControllerViewがどういう役割で、どう見えているのか
  • Interface Builder上ではそれらはどう見えているのか

1つめについては、View Controller Programming Guide for iOSに十分な内容があり、こちらを利用して説明を行いました。
具体的にはiOSのアプリケーションは1つの画面に最低でも1つのViewControllerがあり、複数のViewViewControllerの持つViewの上に配置されることで、画面を構成している、というような内容です。

2つめについては、1つめの点を踏まえてペアプログラミングをしながら実際に手を動かして学んでもらいました。 題材はMaster-Detail Applicationにしています。初期状態で画面遷移があり、1つの画面に最低でも1つのViewControllerというのが動かしながら見せられるためです。

AutoLayout

次にAutoLayoutについてです。 AutoLayout制約をViewに与えることでレイアウトを決める、Appleの機構です。 ここでの大事な点は以下3点だと考えています。

  • AutoLayoutのレイアウトのための考え方
  • Interface Builder上での設定の仕方
  • Interface Builder上で発生し得るエラーとその対処方法

1つ目のAutoLayoutの考え方については、Auto Layout ガイドAutolayoutの考え方のページに詳しく書かれています。
ホワイトボードで図や式を書きながら、AutoLayoutの制約に基づいたレイアウトの考え方を教えます。「デザイナーにStoryboardをお任せする技術」でもお話がありますが、数式や図を交えて説明をすると、すんなりと腹落ちをしてくれていました。

2つ目はInterface Builder上での制約の設定の仕方です
急に粒度の細かな話しになりますが、大事だと考えています。Interface Builder上での制約の設定は結構クセがあって混乱の元になるからです。(例えば、8px空けていたところを12pxにしたいと考えて、pinを選択して12と入れると、同じ属性に対する制約が複数出来てエラーになる、というのはありがちでわかりにくいミスだと思います)
ここからは、ペアプログラミングをしながら、実際のツール上で触って設定の仕方を教えています。

3つ目は、Interface Builder上で発生するAutoLayoutに関するエラーがどういうケースで発生するのかとその対応について教えました。
エラーの場合は、AutoLayoutが座標を決められない状態なので、エラーの内容を見て決められるように制約を設定しようとか、ワーニングは単に制約とInterface Builder上の位置がズレているので、制約か配置のどちらかを合わせてあげれば問題ないよ、といった内容です。 Interface Builderで制約を設定する過程でどうしてもエラーが発生する事があり、それぞれの対応方法を教えておくと、デザイナーが作業するときに、エラーに悩まされて不安になることがなく、良いと思います。

2. 実装

座学が終わったので実装に入ってもらいました。

エンジニア側で「あとはInterface Builderでレイアウトの調整をすればmasterにPRが出せる」という状態にまでして、 GitHubのissueにどのファイルを編集する必要があるのか書いて受け渡しています。

f:id:masarusanjp:20161226103110p:plain

ファイルの受け渡しはFeatureブランチを切っておいて、それをpullしてもらう形です。 ファイルの内容の細かなところは口頭やSlackで説明しています。

渡したときには画面はこんな状態でした。

f:id:masarusanjp:20161226102916p:plain:w320

これをSlackや口頭で相談を受けながら整えていってもらいます。

f:id:masarusanjp:20161226105553p:plain

3. PRを送ってもらう

整ったところで、FeatureブランチにPRを投げ貰いました。

f:id:masarusanjp:20161226102938p:plain

xibのレビューも含めて行います。例えば、misplacedが残っていないか、下位互換を考慮すると厳しいものが入っていないか等。 一通りの修正をしてもらい、終わったところで、Featureブランチにマージします。 その後、エンジニアがFeatureブランチからmasterにPRを出し他のエンジニアのレビューも受けてmasterにマージしました。

f:id:masarusanjp:20161226102951p:plain

効果

今まで終盤で起こりがちだった、「iPhone6では良いのだけど、iPhone5や4sではレイアウトを調整をしたほうが良い」というケースがあります。 ここに細かくデザイナーとエンジニアの時間を細かく使ってしまっていました。
しかし、今回はデザイナー自身がシミュレータで確認をしながら調整をしてくれたので、この問題が解消されました。

今回のケースは「iPhone5の場合にサムネイルが大きすぎる」という問題でした。
左のレシピ作者のアイコンを少し小さくする等の調整はAutoLayoutの制約を利用して、デザイナーさん自信が調整をしてくれています。
(※ セルの高さのみコードで調整しています)

Before After
f:id:masarusanjp:20161226103043p:plain:w240 f:id:masarusanjp:20161226103052p:plain:w240

感想と振り返り

実施後、KPTを用いて振り返りを行い、以下のことがでてきました。

エンジニア

エンジニアとしては、まず問題が解決出来たことも良かったと考えいます。 また自身のAutoLayoutへの知見も深まりました。leadingleftの違いや、equal以外の関係性の使い所等。ついコードを書いて解決してしまっていたところを、一緒にAutoLayoutの制約でなんとか出来ないかを考えて解決できたのは、良い経験でした。

デザイナー

みんなの投稿のデザイン調整を行うことができ、Interface Builderを触れるようになりました。 目的が、リソースがいっぱいいっぱいのエンジニアの細かいデザイン調整の時間を減らすことだったのでそれを達成できたのは大きかったです。逐一エンジニアの席にいって「あと数ptずらしてください…あっやっぱり戻してください…」のような時間がかかるコミュニケーションが無くなり自分自身で調整して、シミュレーターで確認できたのは大きかったです。Interface Builderを使いこなせてないので、今後もInterface Builderが使える場面では積極的に触っていこうと思います。

課題

まだ簡単な画面をトライしただけなので「他の画面ではどうなのか」「結局のところデザイン確認がコードレビューに逆転しただけになってしまっているのでは」など、考えられる課題はたくさんありそうです。

また、デザイナーにInterface Builderを触ってもらうのでなく、エンジニアがSketchを受け取ってレイアウトを調整する取り組みもありそうです。 実際にフリルさんでは、その取り組みについて記事が書かれています。(フリルのiOSアプリ開発におけるエンジニアとデザイナーの作業分担について)

Sympli のような、SketchのデータをInterface Builderへいい感じに取り込んでくれるプラグインも出てきており、試してみたいです。

どの手段を取るにせよ、大切なことはエンジニアとデザイナーがお互いを尊重して歩み寄っていく姿勢だと私は考えています。

まとめ

今回のトライでは、目論見通りエンジニアもデザイナーも双方で細かな調整と確認で時間が取られることがなく、終盤の細かなデザインの修正が行えて良かったのではないかと考えています。

アプリを開発をする上での1つの事例として参考になれば幸いです。

EarlGreyを使った画面操作を伴う自動テスト

こんにちは、技術部品質向上グループの茂呂一子です。

品質向上グループではモバイルアプリにおける自動化されたテストの一環として、画面操作を伴うテストを実施しています。 例えば、古くからAppiumを使い、その結果を判定するという施策を行っています。(参考: iOSアプリデザインリニューアルの舞台裏の舞台裏)

クックパッドではAppiumも利用していますが、より高速に実行できるツールとして、EarlGreyというGoogle製のフレームワークの導入を試みています。

この記事では、EarlGreyの導入と現状についてまとめます。

内容は、iOS Test Night #1 でLT発表した内容からの抜粋です。LT資料はこちら

画面操作を伴うテストの自動化

クックパッドでは、 モバイルアプリの最低限の機能を確認する用途で画面操作を伴うテストを自動化しています。 このテストは、Android/iOSアプリのテストの区分戦略にて定義しているサイズをもとにすると、L/Eサイズに当たるような領域にを対象にしています。 ユーザーに提供する体験を簡易的なシナリオとしてまとめ、 それが完遂されることとレイアウトのくずれが発生しないことをみて、最低限のアプリの価値を確認しています。

画面操作の自動化を支援するツールとして、Appiumを聞くことが増えてきたように思います。

クックパッドでもAppiumは利用していますが、開発ラインに組込むにはよりCIへの組込みが容易で、より高速に実行できるツールが望ましいです。 その候補となるツールには、XCUITest、EarlGreyなどがあります。

Xcode 7が出た頃にXCUITestを試しましたが、テストプロセスの起動に失敗したり、画面上の要素(ボタンなど)の検出に不安定さがあり、実用を目指すには至りませんでした。 その後、EarlGreyが登場し、試用をすすめています。

EarlGreyを使ったテスト

EarlGreyは、Google製のUI Automation Test フレームワークです。 Xcode の提供する XCTest Framework の上で動き、Xcode上でコーディングして実行することができます。

Appiumに比べると、 実装が製品コードに近い場所にあり、実行速度が速く、XCTestの拡張なのでCIへの組込みが容易です。 一方、Swiftコードで記述するために可読性が下がるほか、 テストのライフサイクルが狭いためにシミュレータの初期化できず、前後のテスト実行の成否に影響を受けやすいです。

EarlGreyは、基本的な機能がそろっており、特定の要素が表示されるまでスクロールするなどの便利な機能も提供されています。 しかし、クックパッドアプリで使うにはひとつ足りなくて困ることがあります。

SystemAlertを操作したい

iOSには、カメラの使用やプッシュ通知の許可など、ユーザーに許諾を求めるダイアログがあります。(以下システムアラート) システムアラートは、許可する/しないを選択する必要があり、画面操作をする上で避けて通ることができません。

f:id:ichiko_revjune:20161209203230p:plain

XCUITestでは、システムアラートの操作が可能となっていますが、EarlGreyはXCTest上で動くため、この操作ができません。 EarlGreyのリポジトリにissueがありますが、すぐに解決ができない状態のようです。 このissueの中に、Facebook/WebDriverAgentが使えたというコメントがありました。 これをヒントに、WebDriverAgentLibを使用して実現を試みました。

以下の試みは、EarlGrey v1.3とXcode 7.3.1、Swift 2.2を使用しています。

WebDriverAgentを使ってシステムアラートを操作する

WebDriverAgentLibは、Carthage対応されていますが、試した時点ではコンパイルできないなどの不具合があり、一部のコードをインポートする形で導入しました。

WebDriverAgentLibの導入には手間がかかりましたが、これによってシステムアラートの操作は可能になりました。 以下のようなコードで、システムアラートの項目を選択することができます。 (FBAlertは、WebDriverAgentLibが提供するクラス)

let alert = FBAlert(application: XCUIApplication(privateWithPath: nil, bundleID: "BUNDLE_ID"))
if let alertElement = alert.alertElement() {
    let buttons = alertElement.descendantsMatchingType(XCUIElementType.Button).allElementsBoundByIndex
    let button = buttons.filter { $0.label == label }.first
    if let button = button {
        button.tap()
    } else {
        GREYFailWithDetails("Labled Button '\(label)' on Alert Dialog was not found.", details: "")
    }
} else {
    GREYFailWithDetails("Alert view was not found.", details: "FBAlert.alertElement returned nil.")
}

残念なことに、製品コードの開発が Xcode 8 に移行してからは、FBAlertを使ってシステムアラートを操作することができなくなりました。 WebDriverAgentはPrivate APIを使用するため、Xcodeのバージョンが変わることで整合性がくずれたのでしょう。

しっかり使って育てていきたい

EarlGreyはOSS(オープンソースソフトウェア)として公開されているフレームワークなので、必要な実装は追加していくことが可能です。 今回は、コミットするところまでいけませんでしたが、自分たちが使っていきたいものなので、まだ育てるフェーズと思ってどんどん試していきましょう。 クックパッドでは業務時間中のOSSへの貢献も認められているので、フレームワークへも貢献しつつ、それを活用していきたいですね。

クックパッドではこのような取り組みを共に実施し、より良いサービスを提供し続ける為にエンジニアを募集しています。ご興味のある方は、是非とも覗いてみてください。