はじめに
Diverse Advent Calendar 2019 16日目の記事です。
みなさんこんにちは。
株式会社Diverseではたらいているabuiです。
今回はDiverseのとあるiOSプロジェクトでビルド速度の改善に取り組んだお話をします。
いきなり結論
結論から言いますと、framework分割をすることによって差分ビルドの高速化とサジェストがほぼ効かない状況が改善されました。というと簡単なように思えますが、その過程にはいろいろと大変なことがあるので、同じような状況にある方は以下を参考にしてください。
高速化のためにいろいろ調査
iOSプロジェクトのビルド高速化というと検索してよく見つかるのが
- Podライブラリへの依存を少なくし、Carthageへ置き換える
- Build設定を見直す
- コンパイルに時間がかかっているコードを分析して最適化する
- 並列コンパイル数をマシンの環境に合わせて最適化する
- ビルドするマシンの性能を上げる
- New Build Systemを使う
などがあり、可能な限り対応をしましたし、元から対応されているものもありました(マシンの性能を上げることに関してはコストが青天井なので試し切れていませんが)。
しかし、せいぜい5sec〜10sec程度変わるくらいであり、劇的にビルド速度が改善されることはありませんでした。
最後の望みとしてframework分割という方法があり、おそらく劇的に改善できるであろうという見込みでしたが扱っているファイル数も多く、しかも当時のプロジェクトは全てのファイルが一つのカテゴリに属していたため作業量と難易度で考えると二の足を踏むような状況でした。
framework分割にトライするまでの高い障壁
その当時のプロジェクトでは2000ファイルほどが一つのカテゴリの中に存在していました。
このファイル数はおそらく同程度の規模のアプリと比較しても多い方だと思います。
元々がテストしやすく、ビジネスの変化によって機能もすぐに置き換えられることを想定した作りにすることを思想としていたこともあるのでほぼ全ての機能がクラス化されており、疎結合であり、テストがしやすいという利点はありますがファイル数が際限なく増えていくことによって以下にあげる問題が日に日に顕著になってきました。
-
差分コンパイルが効きにくく毎回フルビルドが走るようになる
-> 少しの修正をして再ビルドするだけで常にコーヒータイムに突入します -
サジェストの候補がいつまでたっても表示されない
-> 複数回コンパイルすることによってたまに
表示されます。ランダム性が高くて実質上はじめからサジェストに期待しないようになってました。
このような環境で作業していくと自ずとエンジニアの士気も下がることはどんなiOSエンジニアの目にも明らかな状況でした。
しかし我慢さえすれば何とかなる状況かつ、プロジェクトのタスクが差し迫ってくるような状況ですと、優先順位として消化可能なタスクの方が優先されることになるので、効果も保証されないかつ作業期間も見積もるのが難しいものに着手するのに二の足を踏むような状況でした。
そんな中で、次の大きなタスクが始まるまである程度の余裕ができるかもしれないとのことでこのタイミングで一念発起してframework分割にチャレンジすることになりました。
framework分割するにあたって守るべき制約
framework分割をするにあたって守るべきルールがいくつか存在します。
新規にプロジェクトを作成した際に属するframeworkを便宜的にここでは メイン(プロジェクト名)
と呼びます。
メインのコードはframeworkからは参照できない
メイン
public final class ClassA {}
frameworkA
public final class ClassB {}
ClassAとClassBがメインとframeworkに分割されている場合は、メインからはframeworkAをimportしてClassBを参照することはできますがframeworkAからはメインにあるClassAは参照することができません。
framework同士が相互参照することはできない
frameworkA
import frameworkB
public final class ClassA {
private let object: ClassB
public init(
dependency object: ClassB
) {
self.object = object
}
}
frameworkB
public final class ClassB {}
この場合、ClassAからはframeworkBをインポートしてClassBを参照することはできますが、ClassBからはすでにframeworkAがframeworkBをインポートしている場合はframeworkAをインポートしてClassAを参照することはできません。
メイン・他のframeworkから参照するときはpublicのみしか参照できない
アクセス修飾子を何もつけない場合は internal がデフォルトになりますが、internalだと同じframeworkに属するものしか参照することはできません。
メイン
import frameworkA
public final class ClassA {
}
frameworkA
public final class ClassB {}
final class ClassC {}
この場合はClassAからはpublicであるClassBしか参照できません。ClassBからはClassCを参照することはできます。
classやstructがpublicである場合、その中に存在するclassやstructもpublicである必要がある
frameworkA
public final class ClassA {
private let classB: ClassB
public init(
classB: ClassB
) {
self.classB = classB
}
}
frameworkB
public final class ClassB {
private let classC: ClassC
public init(
classC: ClassC
) {
self.classC = classC
}
}
internal final class ClassC {
internal init() {}
}
この場合はClassCがinternalであるためにClassBからしかClassCを参照できないため、ClassAでClassBを保持しようとしてもエラーになってしまいます。
ClassCもpublicにする必要があります。
structの中の初期化にはinitが必要になる
メイン
private struct Hoge {
let integer: Int
let string: String
}
こういったstructがあったとしてメインにあるコードから初期化する際には Hoge(integer: 3, string: "string")
と書くことができますがframeworkをメイン以外に移動してメインから初期化しようとすると書けなくなります。別途initが必要になります。
ほぼすべてのstructにinitが存在していなかったので、ここはXcode Extensionの力を借りるなどしました。
Extensionの力を借りるとプロパティーをコピーしてペーストするだけでinitを自動生成してくれます。
私は PasteTheType
というものを使いましたが現在AppStoreで見つけることができなくなってしまいました。他にも同様のものがいくつかあるはずなので探してみてください。
いざ実践へ
これらの制約を踏まえた上でまずすべきことはメインの中に無数にあるclass, structの中で他のclass, structを参照していないものを洗い出して一つずつ地道に移動させていくことでした。
幸いにも分割を開始するまでに、共通して使われる機能とビジネスロジックにあたる機能はグループ分けされていたので、まずは共通して使われる機能を移動させました。
基本的な作業の流れとしては依存のすくないものを見つけ、移動した後に、また同じように依存の少ない or すでに移動済みのものが含まれているものを移動するという流れになります。
移動すればするほど移動可能なファイルも増えるので徐々に楽になっていくようなイメージです(本当に?)
分割する作業に関してはひたすら
- classやその中に属するプロパティー、関数のpublic化
- ファイルの物理移動・グループ移動
- structのinit設置
- import文追加
- エラーがでないかお試しビルド
を繰り返します。
一つのファイルを移動すると100以上ものエラーが発生するときがあるので、強い精神力を持って取り組みましょう(結局はimport文を追加すれば収まるので)。
最終的に一段落するまで4500ものファイルの変更があったようです。
分割の単位
最終的には以下の分類でframework分割することになりました。
[メイン]
- 移動されていないファイル
[Core]
- 共通して使われる処理
[Const]
- 固定値
[Domain]
- APIのレスポンス結果などを表現するEntity
[Store]
- Model, Repository, LocalStorageなど
[Analyze]
- Firebase Analyticsなど分析系
[Purchase]
- 購入処理
[Bag]
- 参照できると便利な入れ物
[Controller]
- ViewとModel・Repositoryの接続など
[View]
- カスタムView, ViewControllerなど
移動していく中で名前を変更したり、相互参照できない制約のために目的にあってないファイルが存在してしまっていたりしましたがおおよそはうまく分類できたと思っています。プロジェクトによってどの単位で分割した方がいいのかは変わるので、他のプロジェクトも参考にするなどして考えてみてください。
外部フレームワークについて
cocoapodやcarthageなどの外部フレームワークがアプリ内のframeworkで必要な場合はそれぞれのframework側にインポートする必要があります。
cocoapodの例
target 'Project' do
pod 'IBAnimatable'
target 'View' do
inherit! :search_paths
end
target 'Store' do
inherit! :search_paths
pod 'Crashlytics', '~> 3.13.1'
end
end
carthage
それぞれのframeworkで必要なcarthageフレームワークをインポートしてください。
またメイン側でcopy-frameworkしていれば、それぞれのframework側でcopy-frameworkする必要はありません。
framework側のsigning設定
framework側のsigning設定は特にする必要はありません。
デフォルトではメインと同じsigning設定がされていることがあるので、以下の画像の通りにする必要があります。
結果どうなったか
冒頭でもお話しましたが、差分コンパイル時のビルド速度とサジェストがほぼ効かない状況が改善されました。
サジェストに関してはframework内に存在するコンパイル対象となるclass, struct数と比例して効きづらくなるようです。いい具合に分散することができたので、framework側のファイルを編集する時はほぼさくさくサジェストが効いています。
肝心のビルド時間ですが、元々差分コンパイルする際に150sec〜180secかかっていたものが分割することによって80sec〜90secまで短縮されました。編集するファイルによっては10sec〜30secになるなど劇的に改善されたといってもいいような結果となりました。
まだ終わってはいない
現在までにかなりのファイルを移動しましたが、まだ半分ほど移動できていないファイルが残っています。
それらのファイルはViewControllerなどの他のクラスへの依存が強いファイルであったり、カスタムViewなど内部で画像を使用していて、移動するとユーザーインターフェースに影響を与えるため、目視での確認や仕様を整合させる必要があるものです。それらはタスクの合間に移動させるくらいでいいかというところで落ち着いています。
もちろんこれから新規に作成するファイルはframework分割することを前提として作成されています。
最後に
以上、全てのファイルがメインの中に大量に作成されてしまったことによって肥大化し、ビルド速度の低下とサジェストの消失といった問題に付随してエンジニアの士気までも低下させかねなかったプロジェクトを、地道にframework分割することによって復活することができたお話をしました。もちろんこれから新規に開発されるプロジェクトははじめからframework分割することを前提として開発されることを大変おすすめします。
ありがとうございました。