Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Swift Macroを事前コンパイルしてビルドを高速化!.xcodeprojとSwift Packageへの導入方法

iOSチームの福山です。 Mirrativ iOSの開発ではSwift Macroを使ってイニシャライザやXcode Previews用のダミー要素を生成するなど活用しています。

しかし、ビルドが成功するにもかかわらず、Swift Macroが展開するコードを利用する部分でエラーが表示されることがありました。XcodeのコンパイルチェックとSwift Macroの展開するタイミングがずれて上手く機能していないのではないかと調べているうちに、Swift Macroをバイナリ化して使う方法があることを知りました。

結果的に表示のみのエラー解消に加えてクリーンビルドの時間を1分以上(約16%)短縮できました。Swift Macroを使う上で必須となるSwift Syntaxのビルドに時間がかかっていたのが原因です。

MyApp プロジェクトルート
├── 📦 MyLibrary (プロジェクト内のSwift Package)
└── 🛠️ MyApp.xcodeproj

Mirrativ iOSは上記のような構造を持っています。.xcodeprojにはSwift Macroのバイナリをすんなり導入できたのですが、.xcodeprojに読み込んでいるSwift Packageの中でもSwift Macroを使っており、導入に試行錯誤が必要だったためその方法を共有します。

使用するXcodeのバージョンは16.2です。バイナリを利用するには、開発メンバー全員およびCIが同じアーキテクチャ(例: Apple Silicon搭載Mac / arm64)を使用していることが前提となります。

Swift Macroとバイナリの生成

既存のプロジェクトへ移動
Swift Macro用Swift Packageのフォルダを作成 (名前は任意)

cd /path/to/your_project
mkdir SwiftMacro

SwiftMacro用フォルダへ移動しボイラープレートを作成

cd SwiftMacro
swift package init --type macro

ひとまずボイラープレートをコンパイル (2,3分程かかる)

swift build -c release

SwiftMacroMacros-toolという名前の実行ファイルが.gitignore対象の場所にできているので、 実行ファイル用のフォルダ bin (名前は任意) を作成し、そこへ -tool 部分をリネームしつつコピーします。
(swiftのバージョンによっては SwiftMacroMacros-tool ではなく SwiftMacroMacros となる場合があります)

mkdir ./bin
cp $(swift build --show-bin-path -c release)/SwiftMacroMacros-tool ./bin/SwiftMacroMacros

ここまででファイル構造は以下のようになっています (一部省略)。
📦 SwiftMacroはXcode上のプロジェクトナビゲーターには表示されないです。

MyApp プロジェクトルート
├── 📦 MyLibrary (プロジェクト内のSwift Package)
├── 🛠️ MyApp.xcodeproj
└── 📦 SwiftMacro
    ├── 🗂️ Sources
    │   └── 🗂️ SwiftMacroMacros (Swift Macro 実装)
    └── 🗂️ bin
        └── SwiftMacroMacros (Swift Macro 実行ファイル)

.xcodeprojへの導入

.xcodeprojの Build Settings > Other Swift Flags に以下の2つを追加します。

  • -load-plugin-executable
  • $(SRCROOT)/SwiftMacro/bin/SwiftMacroMacros#SwiftMacroMacros

Xcode Build SettingsにSwift Macro実行ファイルを読み込む設定

読み込んだSwift Macroの定義を.xcodeprojの任意の場所に書きます。

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftMacroMacros", type: "StringifyMacro")

.xcodeprojでボイラープレートに実装されている #stringify が使えるようになります。

func doSomething() {
    print(#stringify(1 + 2)) // 出力結果: (3, "1 + 2")
}

Swift Packageへの導入

.xcodeprojに読み込んでいるSwift Packageの中でもSwift Macroを使えるようにします。

MyApp プロジェクトルート
└── 📦 MyLibrary (プロジェクト内のSwift Package)
    ├── 📝 Package.swift
    └── 🗂️ Sources
        ├── 🗂️ MyLibrary
        │   └── 📝 MyLibrary.swift
        └── 🗂️ MacroInterface
            └── 📝 MacroDefinition.swift (Swift Macro 読み込み・定義)

Package.swiftのswiftSettingsにunsafeFlagsを指定し、Swift Macroを使うすべてのtargetに読み込めるようにします。
設定を反映させるには、 File > Packages > Reset Package Caches では消えないので、DerivedDataの削除とXcodeの再起動が必要です。

MyLibrary/Package.swift

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let swiftSettings: [PackageDescription.SwiftSetting] = [
    .unsafeFlags([
        // コンパイル済みのSwift Macroを読み込む。
        // 開発メンバー全員およびCIが同じアーキテクチャ(例: Apple Silicon搭載Mac / arm64)を使用していること。
        // パス変更時は DerivedDataを削除し、Xcodeを再起動すること。
        "-load-plugin-executable",
        "${SRCROOT}/../SwiftMacro/bin/SwiftMacroMacros#SwiftMacroMacros"
    ])
]

let package = Package(
    name: "MyLibrary",
    platforms: [.macOS(.v15), .iOS(.v16)],
    products: [
        .library(
            name: "MyLibrary",
            targets: ["MyLibrary"]
        )
    ],
    targets: [
        .target(
            name: "MyLibrary",
            dependencies: ["MacroInterface"],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "MacroInterface",
            swiftSettings: swiftSettings
        )
    ]
)

unsafeFlags-load-plugin-executable とパスを書くことで解決しましたが、現時点では他の方法は見つかりませんでした。 unsafeFlagsの説明も載せておきます。

unsafeFlags(_:_:)
「unsafe(安全でない)」という語の使用が示すように、Swift Package Manager はビルドフラグがビルドに悪影響を及ぼすかどうかを安全に判断することができません。これは、特定のフラグがビルドの挙動を変更する可能性があるためです。

一部のビルドフラグは、非対応または悪意のある挙動を引き起こすために悪用される可能性があるため、安全でないフラグを使用すると、このターゲットを含む製品は他のパッケージで使用する資格を失います。

次にSwift Packageで読み込んだSwift Macroの定義を行います。 ここで定義した #stringify2 は.xcodeprojで使うこともできます。

MyLibrary/Sources/MacroInterface/MacroDefinition.swift

@freestanding(expression)
public macro stringify2<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftMacroMacros", type: "StringifyMacro")

Swift Packageでボイラープレートに実装されている #stringify2 が使えるようになります。

MyLibrary/Sources/MyLibrary/MyLibrary.swift

import SwiftUI
import MacroInterface

public struct MyLibraryView: View {
    public init() {
        print(#stringify2(2 + 3))
    }

    public var body: some View {
        Text("MyLibraryView")
    }
}

Swift Macro実行ファイル更新用のスクリプト

こちらはSwift Macroに何かしらの変更を加えた際に実行ファイルへ反映させるスクリプトです。
SwiftMacro/bin/SwiftMacroMacrosを更新します。

#!/usr/bin/env bash

project_root="$(git rev-parse --show-toplevel)"
package_name="SwiftMacro"
macro_dir="${project_root}/${package_name}"
macro_name="SwiftMacroMacros"
macro_bin_dir="${macro_dir}/bin"
macro_bin_file="${macro_bin_dir}/${macro_name}"

cd "${macro_dir}"

echo "🧹 Cleaning build..."
swift package clean

echo "🔨 Building Swift package..."
swift build -c release

# Update new Swift Macro binary
new_macro_bin_file="$(swift build --show-bin-path -c release)/${macro_name}-tool"
if [[ ! -f "${new_macro_bin_file}" ]]; then
  echo "Error: ❌ Built Swift Macro binary not found at ${new_macro_bin_file}"
  exit 1
fi

echo "🗑️ Removing old macro binary..."
rm -f "${macro_bin_file}"
cp "${new_macro_bin_file}" "${macro_bin_file}"

echo "Updated Swift Macro binary at ${macro_bin_file}"

reflect_swift_macro_changes.sh など任意の名前を付け、プロジェクト内の任意の場所に保存し、実行権限を付与すると使えるようになります。

おわりに

以下のRecent Build TimelineのようにSwift Macroのバイナリ化によりMirrativ iOSではクリーンビルドの時間を1分以上(約16%)短縮できました。Swift Macroを使っている方は是非事前コンパイルを検討してみてください。

参考

www.polpiella.dev

We are hiring!

ミラティブでは一緒に開発してくれる iOS エンジニアを募集しています! 少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、お気軽にご連絡ください。

hrmos.co

mirrativ.notion.site

インターンも募集中です!

hrmos.co