BASE開発チームブログ

Eコマースプラットフォーム「BASE」( https://thebase.in )の開発チームによるブログです。開発メンバー積極募集中! https://www.wantedly.com/companies/base/projects

Embedded Frameworkを導入して、iOS アプリのビルドを爆速にした話

f:id:yuhei_kagaya:20180509103406j:plain

iOSエンジニアの大木です。

日々の開発で、ちょっとした微修正でメソッドを追加・削除すると、差分コンパイルが効かずビルド10分待ちとなり、開発効率の低下が問題となっていました。それを解決するためEmbedded Frameworkを導入したところ、差分ビルドが成功し1-2分になったというお話です。

私が入社したのは、2017年の2月でした。そして、アプリはもともとObjective-Cで書かれていて、Swiftコードの占める割合は10%以下だったようです。それから1年近くで、90%以上Swiftコードに置き換えました。

その時点で、ビルドにかかる時間の長さが目立つようになり、ちょっとした不具合の調査や微調整しているだけなのに10分近く待たなければいけないケースが目立つようになってきました。

問題としては以下の2つがありました。

  • 1つのメソッドを追加しただけなのに、何故か10分近くかかるため、微調整するのに不向き
  • タイプミスしたので一旦途中キャンセルして修正し再ビルドしたが、差分ビルドが効かず10分待ち

前提

日々の開発で行うDebugビルドを対象としています。そのため、AdHoc配信などでビルドする場合のケース(Archive)はCircle CIでおこなっているため特に対象にしていません。差分ビルドが効くようにして開発効率を改善していきます。

環境

  • MacBook Pro (15-inch, 2016)
  • macOS High Sierra(10.13.4)
  • Xcode 9.2
  • Swift 4.0
  • Swift:Objective-Cの比率は、9:1
    • ビルド対象のSwiftファイルは約850ファイル
  • 一般的なビルド設定やSwift-Objective-Cのブリッジングに対する改善はすでに行なっていた
  • New build systemを使っている

改善の流れ

  1. ビルド時間計測
  2. ビルドが遅くなっている原因コードを探して修正する
  3. これ以上速く出来ない?
  4. コードをモジュール単位に分割
  5. 差分ビルドが効くようになり、開発効率改善!

ビルド時間計測

パイク: Cプログラミングに関する覚え書き にもある通り、推測するのではなく、どこで時間がかかっているか計測してみます。

XcodeのBuildSettingsの OTHER_SWIFT_FLAGS-Xfrontend -debug-time-function-bodies を追加すると、閾値以上のビルド時間がかかっているメソッドを、Xcode上でWarningレベルで報告してくれます。

ちなみにBASEアプリでは、App Extensionsを使用していることもあり、設定の抜け漏れを防ぐため、XcodeのGUI上ではなく、xcconfigファイルで設定しています。

// Debug.xcconfig

#include "Shared.xcconfig"
#include "DebugPlugins.xcconfig"

SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG;
// 計測用フラグ
OTHER_SWIFT_FLAGS = -enable-bridging-pch -Xfrontend -debug-time-function-bodies -Xfrontend -debug-time-compilation -Xfrontend -warn-long-function-bodies=200 -Xfrontend -warn-long-expression-type-checking=200;
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) DEBUG=1;

計測結果が含まれるビルドログはXcode上で全ては見えないのと、出力されたファイルを読み解く時間が惜しいという場合は、BuildTimeAnalyzer-for-Xcode を使うとよいでしょう。

ビルドが遅くなっている原因コードを探して修正する

さて計測結果を眺めてみたところ、注文内容と支払い情報を送信するためのHTTPBodyの生成structを返す処理で10秒前後ビルドにかかっていました。 その処理は、Structに (String, Any) のタプルの配列で必要なパラメーターをセットしているのですが、型情報がわからないままなため時間がかかっていたようです。

そこで、一旦変数に格納することによって、型推論が効くようになり、ビルド時間も大幅に改善しました。

diff --git a/CartClient.swift b/CartClient.swift
index 9e2b4c060..93a882f19 100644
--- a/CartClient.swift
+++ b/CartClient.swift
@@ -42,6 +42,11 @@ class CartClient: APIClient {
         case .checkout(paymentType: let `type`, payment: let payment, cart: let cart):
             let orderRequestInfo = cart.orderRequestInfo
+            // 一旦変数に格納して型情報を用意する
+            // 住所とメールアドレスは必須なのでforce unwrappingで取り出しても問題ないはず
+            let familyName = payment.shippingContact!.name?.familyName ?? ""
+            let givenName = payment.shippingContact!.name?.givenName ?? ""
+            let tel = payment.shippingContact!.phoneNumber?.stringValue ?? ""
             return Parameters([
                 ("payment", type.paymentMethod),
@@ -49,14 +54,14 @@ class CartClient: APIClient {
                 ("item_id", orderRequestInfo.itemId),
                 ("shop_id", orderRequestInfo.shopId),
                 ("variation", orderRequestInfo.variationId),
                 ("amount", orderRequestInfo.amount),
-                ("last_name", payment.shippingContact!.name?.familyName ?? ""),
-                ("first_name", payment.shippingContact!.name?.givenName ?? ""),
+                ("last_name", familyName),
+                ("first_name", givenName),
                 ("postal_code", payment.shippingContact!.postalAddress!.postalCode),
                 ("state", payment.shippingContact!.postalAddress!.state),
                 ("address", payment.shippingContact!.postalAddress!.city),
-                ("address2", payment.shippingContact!.postalAddress?.street ?? ""),
-                ("tel", payment.shippingContact!.phoneNumber?.stringValue ?? ""),
-                ("mail_address", payment.shippingContact!.emailAddress ?? ""),
+                ("address2", payment.shippingContact!.postalAddress!.street),
+                ("tel", tel),
+                ("mail_address", payment.shippingContact!.emailAddress!),
                 ("version", appVersion())])
         default:
             break

改善結果は以下の通りです。

コンパイル時間 内容
9323.63ms 元のコード
265.4ms(-97%) 追加する値を一旦変数に保存(Optionalのまま)
16.6ms(-99.8%) さらにNonOptionalにして保存

他にも修正していくらか改善しましたが、劇的な改善には至りませんでした。

  • if boolValue = true { のような比較をやめて if boolValue { に変更して中身の判定を行う
  • 条件に一致したコレクションの要素を取得するメソッド filter() を使うだけで条件を満たせるのに compactMap() 使って余計にビルド時間がかかっていた。

100ms以内に収まり喜んだのもつかの間、再度ビルドすると200ms以上時間がかかってしまう問題もあり、これ以上は誤差でしかないと考え修正を打ち切りました。

これ以上速く出来ない?

Swiftコードコンパイル時間短縮も大事なのですが、最初に述べた 差分ビルドが効くようにして開発効率を改善 達成するのが第一の目標です。

考えてみると、全てのソースファイルをアプリケーションターゲットでビルドするのは無駄です。APIからデータを取得するコードやデータモデルなどは、最初に定義してしまえばほとんど修正することはありません。

モジュールとして切り出せば、関係ないコードのビルドはFramework側で行うことになり、ビルド時間の大幅な改善が見込めそうです。

iOSには、Cocoa Touch Frameworkとしてコードを共有する仕組みがあります。さらに、Embedded Frameworkという仕組みを使ってXcodeのワークスペースに直接組み込むこともできます。

コードをモジュール分割

Embedded Frameworkを使って、コードをモジュールとして分割していきます。

レイヤー化アーキテクチャー等で、各々理想のアーキテクチャーはあると思いますが、ビルド時間の解決に使うことにします。

  • iOS SDKにのみ依存し単体で動作する共通コード
  • アプリで定義したデータモデルやAPIクライアントコード等、複数箇所で利用し変更が頻繁に発生しないコード

上記両方に共通していえることですが、設定ファイルの読み込みが必要な部分はアプリケーションターゲット側で読み込み、その結果オブジェクトをフレームワーク側で定義したデータモデルに変換して渡すようにした方がテストもしやすいですし、後々依存関係のエラーで悩む必要も無くなります。

また、再利用可能にすることを考えると、APIのエンドポイントやストレージのパスなども、アプリケーションターゲットから渡すようにした方が良さそうです。

最終的には下記のようにモジュール分割しました。

UIExtensionKitとAppKitという名前のフレームワークを作成し、アプリケーションターゲットはそれをインポートして使うようにしました。

frame "Application Target" {
    [AppContext]
    [App]
}

package "UIExtensionKit" {
    [Extensions]
}

package "AppKit" {
    [AppContext Interface]
    [Registory]
    [Data Structures&Logics]
}
[App] . [AppContext] : Init
[App] ... [Extensions] : Use
[AppContext Interface] <|.. [AppContext] : Implement
[Registory] *- [AppContext Interface]: DI
[Data Structures&Logics] . [Registory] : Use
[App] . [Data Structures&Logics] : Use

図のApplication Targetというのはアプリケーションのエントリーポイントとなるターゲットです。アプリを起動する場合はこのターゲットを起動します。

UIExtensionKitは、iOS SDKでのみ実装してアプリの業務ロジックは含まないそれ単体で動作するコードです。カラーパレットの定義やViewコンポーネントが配置されているセルのIndexPathを取得する便利メソッドなどがあります。

AppKitは、アプリで定義したデータモデルやそれらを操作するビジネスロジックなどを含みます。 APIリクエストのクライアントだったり、APIのレスポンスを加工したりローカルのストレージにシリアライズしたデータを保存したりするコードがあります。

結果

改善結果は以下の通りです。差分ビルドの結果は、 UIViewControllerに viewWillAppear(_:) を追加してビルドした結果です。

コンパイル時間 ビルド種別 内容
494.4s<約8分> フルビルド 分割していない元のプロジェクト
247.055s<4分8秒> フルビルド フレームワークを使ってモジュール分割したプロジェクト
232.8s<3分52秒> 差分ビルド(成功時) 分割していない元のプロジェクト
86.924s<1分27秒> 差分ビルド フレームワークを使ってモジュール分割したプロジェクト

まとめ

差分ビルドが失敗してビルドに時間がかかるという場合は、Frameworkを使ってモジュール分割してみたら改善できたというお話でした。これによって、無駄に残業して仕事をすることもなくなり、効率よく開発できるようになったと思います。

おまけ(モジュール分割に関して)

モジュール分割に対応するためには、技術的な課題がいくつかありそれを解決しなければなりません。 ざっと挙げますと

  • アプリケーションターゲットでしか使えないもしくは実体が存在しないオブジェクトやメソッドが存在し、Frameworkやテストターゲットから利用したい場合に簡単にアクセスできそうにない
  • フレームワーク内のコードを動作させるには、アプリケーションターゲットに配置した設定ファイルを読み込む必要がある
  • Objective-Cで書かれた広告や解析SDKを利用する場合、複数のモジュールでインポートすることができない

今回の場合フレームワーク側は、一方的に利用される側なので、何らかの方法でこういった依存関係を解消する必要があります。

それには、依存関係逆転の原則(DIP)という方法を使いこの問題を解決しました。フレームワーク側にAppContext Interfaceという抽象インターフェースを定義することにより、アプリケーションの詳細に立ち入らず依存関係のエラーを防ぐことができるようになりました。

フレームワーク側に用意する抽象インタフェースの例は、下記のようなコードです。

これを、アプリケーションターゲットやテストターゲットが実装すれば、フレームワーク側は抽象インタフェースを通して処理を行えるようになりました。不必要にSDKをインポートして依存関係のエラーになることもありません。APIのエンドポイントも渡すようにすれば、切り替えのたびにフレームワーク側をビルドし直す必要もありません。

// AppKit.frameowrk
public protocol AppContext {
    var apiConfig: APIConfig { get }
    var tracker: Tracker { get }
    var legacyImageSliceFetcher: LegacyObjCImageSliceFetchable { get }
    var bundleResolver: BundleResolvable { get }
    var keychainResolver: KeychainResolvable { get }
    var realmContainer: RealmContainer { get }
    var legacyAPIBridging: LegacyObjCAPIBridging { get }
    var appConfig: AppConfig { get }
    var traits: Traits { get }
    var userDefaultsResolver: UserDefaultsResolvable { get }
    var activity: Activity { get }
}

public enum TrackerActions {
    case setUser(userId: String?)
    case error(Error)
    case errorWithAdditionalUserInfo(Error, userInfo: [String: Any]?)
}

// FabricのCrashlyticsはFrameworkの中に重複してリンクできないので、アクションを通じて処理を行わせる(処理の詳細実装自体はこのフレームワークを利用する側にある)
public protocol Tracker {
    func dispatch(action: TrackerActions)
}

フレームワークを利用する側(アプリケーションターゲットやテストターゲット)は、下記のようにこのインタフェースを実装すれば良いことになります。

// アプリケーションターゲット
import AppKit

final class AppContext: AppKit.AppContext {
    var apiConfig: AppAPIConfig = APIConfig()
// [...]
}

// テストターゲット
import AppKit

final class MockAppContext: AppKit.AppContext {
    var apiConfig: AppAPIConfig = MockAPIConfig()
// [...]
}

フレームワーク側で解決できない依存関係は、利用する側で抽象インタフェースを実装することにより解決できるようになりました。

しかしこの実体をどう渡せば良いのでしょうか?

下記のようなコンテナをフレームワーク側に用意し、それを利用するようにすればどこからでも呼び出せるようになります。

public struct Registory {
    private static var context: AppContext!
    public static func setContext(_ context: AppContext) {
        self.context = context
    }
    static func resolve<D>(keyPath: KeyPath<AppContext, D>) -> D {
        return context[keyPath: keyPath]
    }
}

これでフレームワークの外側から必要な処理を行えるようになりました。

参考文献