Swift PackageでC/C++コードだけのtargetからxcframeworkを作る

17837 ワード

私が作っているUnrar.swiftというRAR書庫の展開する機能を提供するSwift Package Manager形式のライブラリがあります。

ある日、このライブラリに「Swift Playgroundsで動かしたいのだけれどC++のコンパイラがない (iPadってそうなんですね) ので、プリコンパイルバイナリで配布してくれたらうれしい」というissueがたてられました。
Include C parts as a pre-compiled library · Issue #4 · mtgto/Unrar.swift · GitHub

たしかにXcode 12からSwift Packageでもプリコンパイル形式に対応しているようですし (Distributing Binary Frameworks as Swift Packages) ゴールデンウィークを使って挑戦してみました。
もっと簡単にできないか試行錯誤したんですが、結局Package.swiftから swift package generate-xcodeproj でxcodeprojファイルを生成し、手動でxcodeprojの設定を変更しxcodebuildでframework生成&xcframework生成することでなんとかなりました。

対象のUnrar.swiftはunrarのC/C++コードを利用しています。そのためSwift PackageではunrarのC/C++部分のコードをもつtarget "Cunrar" と、Swiftから利用可能にするためのインターフェイスを提供するtarget "Unrar" の二つを持っています。
Swiftから直接C++を呼び出すことはできませんが、unrarではCのインターフェイスが提供されているためその部分を "unrar.h" に定義しています。詳しくはGitHubのソースコードを参照してください。

Package.swift
// swift-tools-version:5.3

import PackageDescription

let package = Package(
  name: "Unrar",
  products: [
    .library(
      name: "Unrar",
      targets: ["Unrar"])
    ],
    targets: [
      .target(
        name: "Unrar",
        dependencies: ["Cunrar"]),
      .target(
        name: "Cunrar",
        exclude: [ ... ],
        sources: [ ... ],
        cSettings: [ ... ]),
      .testTarget(
        name: "UnrarTests",
        dependencies: ["Unrar"],
        resources: [.process("fixture")]),
    ]
)

今回のゴールはPackage.swiftでtarget "Cunrar" を binaryTargetに変更し、Swift Playgroundでlibrary "Unrar" が利用できるようにすることです。

Package.swiftでbinaryTargetに指定するものは複数のframeworkをまとめたXCFrameworkへのパス、もしくはそれをzipでまとめたもののURLです。今回はインターネットで配布したいのでXCFrameworkをzipにしてGitHubのリリースタグにzipファイルを配置することにします。

調査した環境

  • macOS 12.3.1 on MacBook Air (M1, 2020)
  • Xcode 13.3.1

swift-create-xcframeworkを使う (失敗)

unsignedapps/swift-create-xcframeworkというツールがあります。これはSwift Packageのルートディレクトリで swift create-xcframework コマンドを実行することでXCFrameworksをビルドしてくれる便利なツールです。READMEによると内部でXcodeプロジェクトの作成とxcodebuildでのXCFrameworks作成を自動でやってくれるそうです。
Swift PackageはC/C++ソースのtargetも指定することができ、実際swift-create-xcframeworkでもCunrarのxcframeworkを作ることはできます。

$ swift create-xcframework --platform macos Cunrar

(iPadのSwift Playgroundで試す前に、ひとまずmacOS上で動くかどうかの確認)

xcodebuildが裏で走り、"Cunrar.xcframework" がカレントディレクトリに生成されました。

$ file Cunrar.xcframework/macos-arm64_x86_64/Cunrar.framework/Cunrar
Cunrar.xcframework/macos-arm64_x86_64/Cunrar.framework/Cunrar: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit dynamically linked shared library x86_64
- Mach-O 64-bit dynamically linked shared library x86_64] [arm64:Mach-O 64-bit dynamically linked shared library arm64
- Mach-O 64-bit dynamically linked shared library arm64]
Cunrar.xcframework/macos-arm64_x86_64/Cunrar.framework/Cunrar (for architecture x86_64):        Mach-O 64-bit dynamically linked shared library x86_64
Cunrar.xcframework/macos-arm64_x86_64/Cunrar.framework/Cunrar (for architecture arm64): Mach-O 64-bit dynamically linked shared library arm64

ちゃんとIntel/Apple SiliconのUniversal Binaryになっているようです。

別のSwift Package "ExampleUseCunrar" を作り、そこからCunrar.xcframeworkをbinaryTargetとして追加してみます。

Package.swift
// swift-tools-version: 5.6

import PackageDescription

let package = Package(
    name: "ExampleUseCunrar",
    dependencies: [],
    targets: [
        .target(
            name: "ExampleUseCunrar",
            dependencies: ["Cunrar"]),
        .binaryTarget(
            name: "Cunrar",
            path: "/path/to/Cunrar.xcframework"
        )
    ]
)
Sources/ExampleUseCunrar/main.swift
import Cunrar

print("\(ERAR_SUCCESS)")

この状態でコンパイルしてみるとCunrarモジュールがないと言われてしまいました。どうやらCunrar.xcframeworkはライブラリファイル自体はもっているもののSwiftモジュールをもっていないと認識されビルドにこけたようです。

$ swift build
Building for debugging...
/path/to/ExampleUseCunrar/Sources/ExampleUseCunrar/main.swift:1:8: error: no such module 'Cunrar'
import Cunrar
       ^
/path/to/ExampleUseCunrar/Sources/ExampleUseCunrar/main.swift:1:8: error: no such module 'Cunrar'
import Cunrar

Swift Packageからxcodeprojを生成、xcodebuildでframeworkを作成→frameworkからxcframeworkを作成 (とりあえずmacOSだけ)

XCFrameworkを他のSwiftプロジェクトから参照するにはSwiftモジュールとしてFrameworkを生成する必要があることがわかりました。
自作のmodulemapを組み込んだFrameworkの作り方 を参考にmodulemapファイルを作成し、Frameworkを作る、という手順を踏めばよさそうです。

最終的にはiOS Playgroundsで動くようなxcframeworkを生成しますが、まずは簡単に動作確認ができるmacOS用のxcframeworkを作成します。

xcframeworkやその中身であるframeworkを作成するためにはxcodebuildコマンドを利用します[1]

1. modulemapファイルの作成

modulemapの書き方は ClangのModulesドキュメント を参考にしつつエラーが出ないかどうかを試行錯誤しました。

Sources/Cunrar/module.modulemap
framework module Cunrar {
   umbrella header "unrar.h"
   export *
}

大事なところとして、umbrella header "unrar.h" のように"unrar.h"はmodulemapファイルからの相対パスにする必要はありません。相対パスで書いてしまうとFramework内に配置されるパスがHeadersとModulesで別のところになるため "unrar.hが見つからない" というコンパイルエラーが発生することになります。

2. Package.swiftからxcodeprojファイルを生成しプロジェクト設定を修正

swift package generate-xcodeproj を実行することで Package.swift からXcodeプロジェクトファイルを生成します。このままxcodebuildでFrameworkを生成するとSwiftモジュールにならないため、以下の修正を行います。

Build Settings - Build Options の修正

  • Build Libraries for Distribution (BUILD_LIBRARY_FOR_DISTRIBUTION) をNOからYESに変更

Build Settings - Packaging の修正

  • Defines Module (DEFINES_MODULE) をNOからYESに変更
  • Module Map File (MODULEMAP_FILE) に "Sources/Cunrar/module.modulemap" を指定 ($SRCROOTからの相対パス)

Build Phases の修正

現状 (Xcode 12.3.1) ではPackage.swiftでtargetのpublicHeadersPathが指定されていてもgenerate-xcodeprojで生成されるxcodeprojではHeaderコピーのBuild Phaseが設定されないようです。
なのでNew Headers Phaseを追加し、Sources/Cunrar/include/unrar.h を必ず Public に追加します。

※これをやらないとframeworkは生成できても "unrar.h" が梱包されず、Cunrar.xcframeworkを使用するプログラムのビルド時に

.build/arm64-apple-macosx/debug/Cunrar.framework/Modules/module.modulemap:2:20: error: umbrella header 'unrar.h' not found
main.swift:1:8: error: could not build Objective-C module 'Cunrar'

のようにコンパイルエラーが発生します。

今回は必要ないですが、別のtargetに依存したtargetのframeworkを作る場合には、generate-xcodeprojで生成されるxcodeprojは依存先Frameworkをコピーする設定になっていないのでCopy Filesフェーズを追加してあげる必要があります。

3. XCFrameworkをビルドする

上記1, 2の修正をしたxcodeprojを元に、xcodebuildでXCFrameworkをビルドします。

$ xcodebuild -sdk macosx -target Cunrar build
(中略)
Touch /path/to/Unrar.swift/build/Release/Cunrar.framework (in target 'Cunrar' from project 'Unrar')
    cd /path/to/Unrar.swift
    /usr/bin/touch -c /path/to/Unrar.swift/build/Release/Cunrar.framework

** BUILD SUCCEEDED **

$ xcodebuild -create-xcframework -output Cunrar.xcframework -framework build/Release/Cunrar.framework

これで生成したCunrar.xcframeworkであれば、binaryTargetとしてちゃんと利用できます。

$ swift run
Building for debugging...
[3/3] Linking ExampleUseCunrar
Build complete! (0.25s)
0

Swift Packageからxcodeprojを生成、xcodebuildでframeworkを作成→frameworkからxcframeworkを作成 (macOS & iOS)

あとはxcodebuildでiOS, iPhone Simulator用のframeworkも生成し、xcframeworkとしてまとめてしまえばOKです。
簡単なMakefileを作りました。

Makefile
TARGET=Cunrar.xcframework
ZIP=Cunrar.zip
FRAMEWORK_MACOS=build/Release/Cunrar.framework
FRAMEWORK_IPHONEOS=build/Release-iphoneos/Cunrar.framework
FRAMEWORK_IPHONESIMULATOR=build/Release-iphonesimulator/Cunrar.framework

all: $(TARGET)

clean:
	rm -rf $(TARGET) $(FRAMEWORK_MACOS) $(FRAMEWORK_IPHONEOS) $(FRAMEWORK_IPHONESIMULATOR)

$(ZIP): $(TARGET)
	zip -r $@ $<
	shasum -a 256 $@

$(TARGET): $(FRAMEWORK_MACOS) $(FRAMEWORK_IPHONEOS) $(FRAMEWORK_IPHONESIMULATOR)
	xcodebuild -create-xcframework -output $@ -framework $(FRAMEWORK_MACOS) -framework $(FRAMEWORK_IPHONEOS) -framework $(FRAMEWORK_IPHONESIMULATOR)

$(FRAMEWORK_MACOS):
	xcodebuild -sdk macosx -target Cunrar build

$(FRAMEWORK_IPHONEOS):
	xcodebuild -sdk iphoneos -target Cunrar build

$(FRAMEWORK_IPHONESIMULATOR):
	xcodebuild -sdk iphonesimulator -target Cunrar build

binaryTargetとしてリモートのファイルを使うためにはxcframeworkをzip書庫にしてアップロードしてあげればOKです (くわしくはAppleのドキュメント参照)。

おわり

ここまでの処理をやることで別のSwift PackageからbinaryTarget形式でのCunrarを利用できるようになりました。iPadを持ってないためSwift Playgrouds 4で利用できるようになったのかわからないのですが、一旦GitHub Releasesページにxcframeworkをzipにしたものを置いて試してもらうことにしました。

調べてみてわかったこととして、frameworkやxcframeworkを作るには現状だとxcodebuild前提の処理が多いということでした。swift packageコマンドだけで解決すればとても楽になるので早くそういうサポートが来てほしいですね [2]

参考にしたもの

脚注
  1. Create an XCFramework にあるようにxcodebuildによるxcframework作成にはframeworkを指定する方法とライブラリファイルとヘッダーファイルを指定する方法があります。今回はframeworkを指定する方法を選択していますがライブラリ&ヘッダーからでもSwift Moduleであるxcframeworkを作成することができます。 ↩︎

  2. swift package generate-xcodeprojを実行すると "warning: Xcode can open and build Swift Packages directly. 'generate-xcodeproj' is no longer needed and will be deprecated soon." と怒られるのでxcodeproj生成ができなくなる前にはframework作成の方法ができるといいなと思います。 ↩︎