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 アプリエンジニア | クックパッド株式会社 採用情報