Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

Swiftのビルド時間を減らしてCI環境でのデプロイも早くしたお話

iOSエンジニアのすーです!

あちらこちらでWWDC2016ネタで盛り上がっている中、それとは関係のない記事を投稿させて頂きます...!

現在弊社のFammでは、もともとObjective-C Onlyで書かれたプロジェクトでしたが、 新規機能は基本的にSwiftで書くようにして、少しずつ既存のコードをSwift化することで、Swiftがプロジェクト全体の約45%を占めるようになりました。

そこで浮上してきた問題が、 ビルド時間 です。。
差分ビルドが有効とはいえ、開発中のブランチを行ったり来たりしたり、ちょっとObjective-C側のコードを書き換えたりしてタイミングが悪いと差分ビルドが働かずまた一からSwiftのソースコードコンパイルしてってなって...。
まだ、自分の使っているマシンの性能が良い方なので、そこまで気にはならないのですが、これがCI環境となるともっと悩ましいことになります。
(弊社ではBitrise CIを使っていますが、若干他のCIと比べて遅いような...。)

約2ヶ月前、Swiftの割合がまだ2割に満たない頃は、CI環境でUnitTest+Deployで20分弱で済んでいたのが、最近では30分かかるようになってしまい、 これではまずい と思いました。

そこで色々策を講じ、最終的にCI環境でデプロイまでにかかる時間を最終的に約半分削減することができたので、その手法を紹介します。
(※ : 主にプロジェクト側の設定に関するものなので、この記事ではCI上のチューニングに関しては触れていません。)

1. SwiftのOptimization Levelを見直す

XcodeのBuild Settingの中に、LLVMとSwiftに関して、Optimization Levelを指定できる箇所があります。 スクリーンショット 2016-06-15 11.50.36.png (59.7 kB) (検索窓で「Optimization Level」と調べるとすぐにでてきます)

このOptimization Levelは各TargetのConfiguration毎に指定する事が可能で、プロジェクト作成直後は、

  • Debug : None[-Onone]
  • Release : Fast[-O]

となっています。なのでDebug時には最適化が行われず、ビルド時間が短くなるはず...なのですが、Fammでは何故かDebugでも Fast になっていました。(汗 こちらを適切に指定してあげることで、ビルドにかかる時間を早くすることができます。 もちろん、本番環境用のビルドや申請用のipaを書き出す時は Fastや、Fast, Whole Module Optimization を指定して、最適化がかかるようにしましょう。

Fammでは、 - Debugビルド時、Develop環境でのbitrise配信 : None - Staging環境でのbitrise配信、本番環境 : Fast

にしています。

あれ?と思ったら設定を見てみると良いかもしれません。

2. ビルド時間が極端に遅い箇所を特定して、ビルド時間を短縮させる

Swiftの優れている点の1つとして、型推論が挙げられますが、これに頼りまくると ビルド時間が遅くなる場合があります。 必ずしも遅くなるわけではないのですが、例えば以下の様なケースで、時間がかかってしまう可能性があります。

配列や辞書の型を省略した場合

let array = ["aaa", "bbb", "ccc"]
// ↓ 型の情報を与えてあげる
let array: [String] = ["aaa", "bbb", "ccc"]

配列や辞書に関しては、どういう型の配列、辞書なのかを与えてあげると早くなるようです。 以下の記事でも紹介されていました。

Optional Bidingや、??演算子の使いすぎで複雑になっている

以下のコードは、Fammの中の修正前のコードのだったのですが、ここの部分だけで、10000ms(10秒)近くかかっていました。。

let imageViewFrame :CGRect = CGRect(
    x: contentInsets.left + imageInsets.left, 
    y: (frame.size.height - (contentInsets.top + contentInsets.bottom + imageInsets.top + imageInsets.bottom + (self.imageView?.image?.size.height ?? 0.0))) / 2.0, 
    width: (self.imageView?.image?.size.width ?? 0.0), 
    height: (self.imageView?.image?.size.height ?? 0.0)
)
let titleLabelFrame :CGRect = CGRect(
    x: imageViewFrame.maxX + imageInsets.right + titleInsets.left, 
    y: (frame.size.height - (contentInsets.top + contentInsets.bottom + titleInsets.top + titleInsets.bottom + (self.titleLabel?.frame.size.height ?? 0.0))) / 2.0, 
    width: self.titleLabel?.frame.size.width ?? 0.0, 
    height: self.titleLabel?.frame.size.height ?? 0.0
)

frameの計算なので、演算自体も長くて複雑ですが、self.imageView?.image?.size.width ?? 0.0みたいに、Optional Bidingを使っているし、更に ?? 演算子も使ったりと複雑になっているのがあって、コンパイラが解釈するのに時間がかかってしまっていたようです。 これを以下のように修正したところ、ビルド時間が大幅に減りました。

let imageSize: CGSize = self.imageView?.image?.size ?? .zero
let titleLabelSize: CGSize = self.titleLabel?.frame?.size ?? .zero
let imageViewFrame :CGRect = CGRect(
    x: contentInsets.left + imageInsets.left,
    y: (frame.size.height - (contentInsets.top + contentInsets.bottom + imageInsets.top + imageInsets.bottom + imageSize.height)) / 2.0,
    width: imageSize.width,
    height: imageSize.height
 )
let titleLabelFrame :CGRect = CGRect(
    x: imageViewFrame.maxX + imageInsets.right + titleInsets.left,
    y: (frame.size.height - (contentInsets.top + contentInsets.bottom + titleInsets.top + titleInsets.bottom + titleLabelSize.height)) / 2.0,
    width: titleLabelSize.width,
    height: titleLabelSize.height
)

ポイントとしては、何度もOptional Bidingや??演算子を使っていた箇所を1箇所にまとめておいて、使う時はCGSize?型ではなく、CGSize型として扱えるようにしたところです。

ついつい、OptionalBidingや??演算子が便利でババっと並べてしまいがちですが、何度も同じように書く場所があれば、最適化してみるのもありかもしれません。

closureの引数の型を書かない($0とか使っている)

例えば、String型の配列と、Int型の配列をzip関数で結合して扱う場合に、

let strings = ["a", "b", "c"]
let numbers = [3, 4, 5]

zip(strings, numbers).forEach {
    print($0.0, $0.1)
}

こんな感じで型推論を用いて$0.0, $0.1のようにアクセスしたりできますが、場合によってはここもビルド時間が遅くなる可能性があります。 なので、closureの引数に適切な型を明示してあげることで、ビルド時間を削減することができます。

zip(strings, numbers).forEach { (str: String, num: Int) in
    print(str, num)
}

文字列の連結をたくさんしている

let name: String? = "Taro"
let message = "Hello" + ", " + "\(name ?? "World")" + "." 
// => "Hello, Taro."

場合にもよりけりですが、これもビルドに時間がかかる可能性があります。 できれば、以下のように1つにまとめると、ビルド時間が短縮できたりします。

let name: String? = "Taro"
let message = "Hello, \(name ?? "World")." 
// => "Hello, Taro."

必ずしも型を書くようにしなきゃとかそういうことは推奨していませんが、もし一部で極端にビルド時間がかかっている場合は型を明示的に書いてあげることでコンパイラへの負担を減らしてビルド時間を削ることができるかと思います。 会社によってはコーディング規約を設けていたりすると思うので、そちらとも相談してみてください。 型を明示しまくるあまり冗長的になったり、可読性が下がる可能性もありうるので、ケースバイケースになるかと思います。

ちなみに、各メソッドでビルドにかかった時間を計測するには、BuildTimeAnalyzer-for-XcodeというXcodeプラグインを使うと便利です。

スクリーンショット 2016-06-15 19.38.03.png (245.7 kB)

こんな感じで、各メソッドでビルドに要した時間を遅い順に表示してくれます。 先ほど紹介したケースでわざとビルド時間が遅くなるように書いてみるとこんな感じで3000msほどかかっているのがわかります。。

また、こちらの記事も大変参考になると思います。


3 Swiftのファイルをビルド前に1つのファイルに結合してからビルドする

ファイル数が増えれば増えるほどビルドに時間がかかるので、1つのswiftファイルに集約するとビルド時間を大幅に早くすることができます。 ただ、開発中に全てのclassを1つのファイルに集約してコーディングしていくのは無理があるので、ビルド時にプロジェクト内のswiftファイル内の記述を1つに集約して、それだけをビルドするようにします。

そのやり方をこれから紹介していきます。

1.Target、Schemeを分ける

現状の開発SchemeやTargetに変更を加えるのは怖いので、普段使っているDebug用のTargetやSchemeを複製していきます。

例えば、Schemeが「Development」、Targetが「SampleProject」なら、「DevelopmentFastBuild」,「SampleProjectFastBuild」といった形で分かりやすい名前で複製(Duplicate)します。

スクリーンショット 2016-06-16 17.07.19.png (27.6 kB)

その後、Targetは違えど、同一のアプリとして実行できるようにするため、Product Module Nameを変更します。 おそらく規定値で$(PRODUCT_NAME:c99extidentifier)が入っていますが、これを手入力で、複製元のProduct Module Nameと同じ名前を入力します。 この例だと、$(PRODUCT_NAME:c99extidentifier)SampleProjectに変更します。

基本的にはbundle identifierが同じなら同じアプリとして認識されるのですが、ビルドした時にプロジェクトのModule名が変わってしまうため、予期せぬ所でクラッシュしたりします。 Fammでは、Swiftで書いたクラスをNSCodingに適合させて、archiveとunarchiveできるようにしてアプリ内に保存していたのですが、Module名が変わってしまうことでクラッシュするという問題に遭遇しました。。

あとは、Schemeの設定でExecutableでこのTargetを選択しておけば大丈夫です。 以降はこのSchemeとTargetを使っていくことにします。

2.空のmerge.swiftを作成する

一度プロジェクトのルート(あるいは任意の場所)に、merge.swiftというファイル名で、空のファイルを作成します。 この時、Targetは「SampleProjectFastBuild」のみを選択します。 スクリーンショット 2016-06-16 18.24.15.png (130.6 kB)

その後、Target設定のBuild Phasesを開き、Compile Sourcesの欄で、merge.swift以外のswiftファイルをこの項目から外しますObjective-Cのファイルがある場合はそのままにしておきます。

こんな感じになるかと思います。 以降も、新規swiftファイル追加する場合はこのTargetにはチェックを付けずにプロジェクトに追加します。

3.ビルドが実行時に、指定したswiftファイルをmerge.swiftに集約する

あとは、ビルド実行時に、merge.swiftにソースが集約されれば、それをビルドするだけで済むようになります。 そこでRunScriptを組んで、ビルド実行時にソースが集約されるようにするのですが、Build PhasesのRunScriptでは実行のタイミングが遅いです。 そこで、Schemeの設定を開き、Buildの欄にあるPre-actionsにRunScriptを追加して、ビルド開始より前にRunScriptが走るようにします。

スクリーンショット 2016-06-16 19.27.36.png (128.4 kB)

[EditScheme]→[Build]→[Pre-actions]と進み、下の+で、New Run Script Actionを指定します。

scriptはこのGistを参考にしつつ、少し編集して組んでみました。

MERGEFILE=$PROJECT_DIR/merge.swift
TMPFILE=$PROJECT_DIR/tmp_merge.swift
touch $TMPFILE
touch $MERGEFILE
$MERGEFILE; find $PROJECT_DIR/{プロジェクト名} -iname '*.swift' -not -name merge.swift -exec cat {} >> $TMPFILE \;
diff $TMPFILE $MERGEFILE >/dev/null
if [[ $? != 0 ]] ; then
  echo "Remove previous merge.swift"
  rm $MERGEFILE
  cp $TMPFILE $MERGEFILE
fi

rm $TMPFILE

{プロジェクト名}の部分は必要に応じて変更してください。 大体の場合、プロジェクトのルート/プロジェクト名以下のディレクトリに、ファイルを配置することが多いかと思います。

スクリーンショット 2016-06-16 19.37.45.png (93.7 kB)

findコマンドを使ってプロジェクト中のswiftファイルを探して、その内容を一旦tmpファイルにcatコマンドと標準出力で連結し、merge.swiftとtmpに差分がある時に内容をmerge.swiftに移すようにしています。

スクリーンショット 2016-06-16 19.39.38.png (210.0 kB)

こんな感じでスクリプトを貼り付け、Provide build settings fromも設定してあげれば完了です。

4.ビルドしてみる

ここまで準備できたら、DevelopmentFastBuildのSchemeでビルドしてみます。 問題がなければ先ほどのscriptが実行され、merge.swiftを更新したあと、merge.swiftをビルドします。

ちなみにFammでは、 - 通常:220 s - FastBuild: 115s といった感じで、かなり短縮することができました。

ただ、この方法をそのまま本番のアプリのビルドに用いてしまうと、Fabric等のサービスでクラッシュした箇所を特定しようとすると、このmerge.swiftの何行目、みたいに表示されてしまって不具合を追うのが難しくなるのでおすすめしません。

また、1つのファイルに集約されるので、各ファイルでprivateで宣言していたenumの型名等が重複するとエラーになることもあるので、

  • それぞれ固有の名前を付ける
  • 1つのファイルに集約するのをプロジェクト内のExtensionやHelperクラス系のみに限定する

といった形で調整する必要があります。

さらに、1ファイルにしたため、ファイル単位での差分ビルドが有効にならないので、変更がある度にビルドが走ったりします。 ビルド時間が速いとはいえ、都度merge.swiftをビルドしなおす、といった感じになります。 なので、CI環境にはもってこいですね。

他にも

CocoaPodsで入れていたライブラリのうち、Carthage対応しているものはCarthageに移して、ビルド時間の削減を行ったりしました。 CocoaPodsも一度ビルドしてしまえば時間はかからないとはいえ、Cleanを実行する度にビルドが必要になるし、CI環境は必ずビルドが必要になるので、可能ならCarthageに切り替えて、frameworkを読み込む形にすると早くなります。 更に、FammではCarthageをgitignoreに含めていないので、CI環境下でcarthage updateをする必要がないので、そこでも時間の短縮ができています。

ここまでやってためしてみると

試行錯誤し、エラーにも負けずためしてみると、変更前では、 スクリーンショット 2016-06-09 15.22.04.png (16.7 kB)

これくらいかかっていたものが最終的には

スクリーンショット 2016-06-09 15.22.22.png (16.6 kB)

ここまで減りました。若干バラツキが有るのですが、30分程度かかっていたものが約15分程度に収まったので、50%くらいの改善になりました。

まとめ

swiftのソースコードを1つにまとめるのは少しトリッキーなので試すのは難しいかもしれないですが、それ以外は普段から気をつけていれば、少しでも早く早くと改善していくことができると思います。

積極採用中!!

子育て家族アプリFamm、カップル専用アプリPairyを運営する Timers inc. では、現在エンジニアを積極採用中! 急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください! 採用HP : http://timers-inc.com/engineerings

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる