🚀

@_exportedを用いたマルチモジュールにおける段階的なビルド時間改善

に公開

はじめに

現在開発しているサービスでは、Swift Package Managerを使ったマルチモジュール構成を採用しています。モジュール化により、コードの責務を明確にし、テスタビリティや再利用性を向上させることができます。

しかし、運用を続ける中で、ビルド時間の増加やプレビューの遅延といった課題に直面しました。本記事では、ビルド時間を改善しつつ段階的に実プロダクトに対してどのように解決したかを共有します。

先にサマリー

  1. ImplementationとInterfaceに分離:重い依存関係を持つ実装ロジックのモジュール(Implementation)と、外部公開する型定義のモジュール(Interface)を切り離しました

  2. 依存関係をInterfaceに集約:Featureモジュールの依存先を軽量なInterfaceモジュールに限定しました

  3. @_exported importを活用:既存コードのimport文の変更を最小限に抑えました

課題: Featureモジュールのビルドに時間がかかる

登場人物

  • Featureモジュール: 画面や機能単位で分割されたモジュール(例: OnboardingFeature
  • Coreモジュール: 共通機能やユーティリティを提供するモジュール(例: APIClient, EventLogger

初期のモジュール構成

当初の構成では、各CoreモジュールをInterfaceImplementationに分離せず、単一のCoreモジュールとして実装していました。特に問題となったのが、共通のロギングを担う EventLogger モジュールでした。

例えば、OnboardingFeatureは以下のように多くの依存関係を持っており、特にEventLoggerが複数のThird Party SDK(Firebase AnalyticsAdjustSDKなど)に依存していました。

// Package.swift(イメージ)
.target(
    name: "OnboardingFeature",
    dependencies: [
        /* ...その他の依存 */
        "EventLogger", // 実装モジュールに直接依存
    ]
)
.target(
    name: "EventLogger",
    dependencies: [
        .product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"),
        .product(name: "AdjustSDK", package: "ios_sdk")
        // ...その他多数の重い依存
   ]
)

発生していた問題

この構成で開発を進める中で、以下のような問題が顕在化してきました。

1. 不必要な依存モジュールのビルド

Featureモジュールに依存するすべてのSwift Package Managerモジュールをビルドする必要がありビルド時間が長くなっていました。
依存関係の流れ: Firebase → EventLogger → OnboardingFeature

2. 開発体験の低下

また、ビルド時間が長くなることで、SwiftUIのプレビューの待ち時間が増加も発生していました。それによってUIの確認サイクルが遅延し、開発効率が低下するようになり逐一Simulatorでビルドする作業が発生していました。

解決策: InterfaceとImplementationの分離

解決策として、以下のアプローチを採用しました。

1. Interfaceモジュールへの切り分け

対象のCoreモジュールをInterfaceImplementationに分離しました。

  • Interfaceモジュール: プロトコルや型定義のみを含む(例: EventLoggerInterface
  • Implementationモジュール: 実際の実装ロジックを含む(例: EventLogger

2. @_exportedの活用

Implementationモジュールで@_exported importを使い、Interfaceモジュールを再エクスポートしました。これにより、既存コードのimport文の変更を最小限に抑えることができました。

実装のポイント

1. Interfaceモジュールの定義

Interfaceモジュールでは、プロトコルや型定義のみを定義します。

EventLoggerInterfaceモジュールの例:

// EventLoggerInterface/EventLogger.swift

// イベントを表す構造体
public struct Event {
    public let name: String
    public let parameters: [String: Any]
    public let token: String?

    public init(name: String, parameters: [String: Any] = [:], token: String? = nil) {
        self.name = name
        self.parameters = parameters
        self.token = token
    }
}

// EventLoggerの定義
public struct EventLogger {
    private let sendEvent: (Event) -> Void

    public init(sendEvent: @escaping (Event) -> Void) {
        self.sendEvent = sendEvent
    }

    public func send(_ event: Event) {
        sendEvent(event)
    }
}

2. Implementationモジュールでの@_exportedの使用

Implementationモジュールでは、@_exportedを使ってInterfaceモジュールを再エクスポートします。

// EventLogger/EventLogger.swift
@_exported import EventLoggerInterface // これが重要
import FirebaseAnalytics
import AdjustSDK

// 実装の詳細
extension EventLogger {
    public static func live() -> EventLogger {
        EventLogger(
            sendEvent: { event in
                // Firebase AnalyticsやAdjustへの送信
            }
        )
    }
}

3. Package.swiftでの依存関係の定義

OnboardingFeature の依存先を EventLoggerから、依存を持たない軽量な EventLoggerInterface に切り替えました。

// Package.swift
.target(
    name: "EventLoggerInterface",
    dependencies: []  // 依存なし、軽量
),
.target(
    name: "EventLogger",
    dependencies: [
        "EventLoggerInterface", // Interfaceモジュールに依存
        .product(
            name: "FirebaseAnalytics",
            package: "firebase-ios-sdk"
        ),
        .product(
            name: "AdjustSDK",
            package: "ios_sdk"
        ),
        // ...実装に必要な重い依存関係
    ]
),
.target(
    name: "OnboardingFeature",
    dependencies: [
        // 🚨 依存先を Interface に変更(最終形)
        "EventLoggerInterface",
        // ...他にも多数
    ]
)
.target(
    name: "HomeFeature",
    dependencies: [
        // 🚨 他のFeatureモジュールは変更しない
        "EventLogger",
    ]
)

これによって、OnboardingFeature はSDKの詳細から完全に切り離すことができました。

// OnboardingFeature/OnboardingLogic.swift
import EventLoggerInterface // 🚨Interfaceをimport

@Observable
final class OnboardingLogic {
    let eventLogger: EventLogger

    init(eventLogger: EventLogger) {
        self.eventLogger = eventLogger
    }

    func onButtonTapped() {
        eventLogger.send(Event(
            name: "onboarding_button_tapped",
            parameters: ["button_type": "get_started"]
        ))
    }
}


// HomeFeature/HomeLogic.swift
import EventLogger // 🚨Implementationをimport
@Observable final class HomeLogic {
    let eventLogger: EventLogger // EventLoggerを参照可能

    func someAction() {
        eventLogger.send(Event(name: "button_tapped"))
    }
}

また@_exported により、import EventLogger をするだけで HomeFeatureにおいても、EventLoggerInterface の型が利用可能になるため、大規模な import の書き換えを避けることができています。

手順のおさらい

改めて手順のおさらいです。
以下の手順で段階的にモジュール分離を進めました。

  1. Interfaceモジュールの作成

    • 既存モジュールから型定義(プロトコル、構造体など)を抽出
    • 新規にEventLoggerInterfaceモジュールを作成
  2. @_exported importの追加

    • Implementationモジュールに@_exported import EventLoggerInterfaceを追加
    • これにより既存のimport文を変更せずに移行可能
  3. 依存関係の変更

    • Package.swiftで優先度の高いFeatureモジュールから依存先をEventLoggerInterfaceモジュールに変更
  4. 段階的な展開

    • 他のFeatureモジュールも順次同様の変更を適用
  5. @_exportedの削除

    • すべての移行が完了後、@_exportedを削除
    • 完全なInterface/Implementation分離を実現

成果

ビルド時間の短縮

Featureモジュールが重いSDKを含む依存から解放されたことで、対象モジュールのビルド時間が大幅に短縮されました。

実際にOnboardingFeatureモジュールではクリーンビルドは26秒、インクリメンタルビルドは7秒短縮されました。(3回の平均値)
これにより、開発サイクルの高速化に加えて、ユニットテストの実行時間も短縮され、ローカル環境でのテスト実行やCI/CDパイプライン全体の効率化にも貢献しました。

SwiftUIプレビューの高速化

依存モジュールの軽量化により、SwiftUIプレビュー待ち時間も短縮されました。これにより今までプレビューを諦めSimulatorで確認する作業が大幅に減りました。

段階的な移行のしやすさ

@_exportedを活用することで、すべてのFeatureモジュールを一度に移行する必要がなく、優先度の高いモジュールから段階的に移行できました。EventLoggerをimportしている既存のFeatureモジュールは、コード変更なしにそのまま動作し続けるため、リスクを抑えながら改善を進めることができました。

まとめ

重いサードパーティSDKに依存するモジュールをInterface/Implementationに分けることで、Featureモジュールは軽量なInterfaceのみに依存するようになり、再ビルドの頻度とコストを大幅に下げることができます。さらに、@_exported を活用することで、既存コードへの影響を最小限に抑えつつ、段階的にアーキテクチャを改善できるという実践的な知見を得ることができました。

参考資料

Kurashiru Tech Blog

Discussion