16
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Objective-Cからでも使えるSwift Packageを作る

Posted at

解決したい問題

アプリ内にSwiftとObjective-Cのコードが混在しているときに、Swiftのクラスに @objc をつけることでObjective-Cからでもそのクラスを使うことができます。

MySwiftClass.swift
import Foundation

@objc
class MySwiftClass: NSObject {
    @objc(addX:andY:)
    func add(x: Int, y: Int) -> Int {
        return x + y
    }
}
MyObjcClass.m
#import "MyObjcClass.h"
#import "MyApp-Swift.h"

@implementation MyObjcClass

- (NSInteger)compute {
    MySwiftClass *mySwift = [[MySwiftClass alloc] init];
    return [mySwift addX:10 andY:20];
}

@end

ところが、Swift Package Manager(以下 SwiftPM)のパッケージとして公開しているライブラリで、同じように @objc を付けても、そのままではライブラリを利用するアプリのObjective-Cコードからは呼び出すことができません。

OurSwiftClass.swift
import Foundation

// "OurLibrary" という名前のSwift Packageの中にあるクラス
@objc
public class OurSwiftClass: NSObject {
    @objc(addX:andY:)
    public func add(x: Int, y: Int) -> Int {
        return x + y
    }
}
MyObjcClass.m
#import "MyObjcClass.h"

// パッケージを利用するアプリの中にあるクラス
@implementation MyObjcClass

- (NSInteger)compute {
    OurSwiftClass *ourSwift = [[OurSwiftClass alloc] init];
        // error: Use of undeclared identifier 'OurSwiftClass'
    return [ourSwift addX:10 andY:20];
}

@end

これをなんとか解決してみたいと思います。

注意: 最初に書いておきますが、あまりスマートには解決できませんでした… :cry:

なぜ呼び出すことができないのか

呼び出すことができない理由は、ズバリ、 インポートができないから です。

そういえば、Swiftのコードからこのクラスを使うときでも import を行いますね。

MySwift.swift
import Foundation
import OurLibrary // ← これ

func compute() -> Int {
    let ourSwift = OurSwiftClass()
    return ourSwift.add(x: 10, y: 20)
}

では、Objective-Cの方にもインポートを追加してみましょう。

……えっと、何を追加したらいいんでしたっけ? :sweat:

MyObjcClass.m
#import "OurLibrary"
    // error: 'OurLibrary' file not found
MyObjcClass.m
#import "OurLibrary.h"
    // error: 'OurLibrary.h' file not found
MyObjcClass.m
@import OurLibrary;
    // error: Module 'OurLibrary' not found

どれもエラーになってしまいました :weary:

気を取り直して、アプリ内のSwiftのクラスを使うときはどうしてましたっけ?
そうです、アプリ内のSwiftのクラスを使うときは「(プロジェクト名)-Swift.h」をインポートしていました。

MyObjcClass.m
#import "MyApp-Swift.h" // ← これ

そうか!「(パッケージ名)-Swift.h」をインポートすれば!

MyObjcClass.m
#import "OurLibrary-Swift.h"
    // error: 'OurLibrary-Swift.h' file not found

まあ、ダメですよね :pensive:

「(プロジェクト名)-Swift.h」とはなんなのか

デタラメにトライ&エラーを繰り返してもうまくいかないので、もう少しきちんと考えてみます。

アプリ内で使える「(プロジェクト名)-Swift.h」って何でしょう?
Xcodeで #import "MyApp-Swift.h" の部分をCommandキーを押しながらクリックして、[Jump to Definition]を実行すると、こんなヘッダファイルに飛びます。

MyApp-Swift.h
// Generated by Apple Swift version 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
  ...

SWIFT_CLASS("_TtC5MyApp12MySwiftClass")
@interface MySwiftClass : NSObject
- (NSInteger)addX:(NSInteger)x andY:(NSInteger)y SWIFT_WARN_UNUSED_RESULT;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

  ...

1行目のコメントにもあるように、SwiftのクラスからObjective-C用のヘッダファイルが生成されていることがわかります。

このファイルはいつ生成されているのでしょうか? 答えはビルドログの中にあります。
Xcodeの左側にあるNavigatorのうち、右端のReport Navigatorを表示します(ショートカットキーは ⌘9
適当に最新のBuildを選択して、アプリターゲットのログの「Compile Swift source files」の部分を開いてみます。

MyAppCompileSwiftSourceFiles.jpg

文字がいっぱいでわかりづらいですが、一部だけ抜き出したものが以下です。

CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'MyApp' from project 'MyApp')
    ...
    /Applications/Xcode.app/.../bin/swiftc ...
    -emit-objc-header -emit-objc-header-path .../MyApp-Swift.h
    ...

Swiftコンパイラの swiftc-emit-objc-header-emit-objc-header-path オプションが渡され、それにより「(プロジェクト名)-Swift.h」が生成されていることがわかります。

$ swiftc --help
OVERVIEW: Swift compiler
...
OPTIONS:
  ...
  -emit-objc-header-path <path>
                          Emit an Objective-C header file to <path>
  -emit-objc-header       Emit an Objective-C header file
  ...

ということは、Swift Packageのライブラリでも、ソースコードからこのヘッダファイルを生成し、それが利用側でインポートできるようになれば、今回の問題が解決できそうです :grinning: :bulb:

SwiftPMの仕組みを考えてみる

SwiftPMは、大雑把に言えば、パッケージのソースコードをgitリポジトリから取得してきたあと、それをローカルでコンパイルして一緒にリンクするようにしてくれるものです。

先ほどのアプリのビルドログにも、取ってきたパッケージのビルドフェーズが出力されています。

OurLibraryCompileSwiftSourceFiles.jpg

なるほど。ここを見ても、 -emit-objc-header-emit-objc-header-path オプションは渡されていません。

ここにオプションを渡すことはできるでしょうか?

Package.swiftに書くPackageのドキュメント を読むと、Swiftコンパイル設定としては、 define によるコンパイルマクロの定義と、 unsafeFlags による、任意のコンパイルオプションの引き渡しの2つがあることが書かれています。
後者の unsafeFlags を使えば、 -emit-objc-header-emit-objc-header-path オプションを渡せそうですね!

早速、やってみましょう。

Package.swift
// swift-tools-version:5.1
...
let package = Package(
    ...
    targets: [
        .target(
            name: "OurLibrary",
            dependencies: [],
            // ↓これを追加
            swiftSettings: [
                .unsafeFlags(["-emit-objc-header", "-emit-objc-header-path", "OurLibrary-Swift.h"])
            ]),
        ...
    ]
)

ヘッダファイルのパスですが、とりあえずファイル名だけ指定してみました。
Xcodeでこのパッケージを開いてビルドすると、OutLibrary直下(Package.swiftと同じ場所)に「OurLibrary-Swift.h」が出力されました!

ところがこのパッケージを利用しようとすると、アプリ側ではこんなビルドエラーが出ます。

error: The package product 'OurLibrary' cannot be used as a dependency of this target because it uses unsafe build flags. (in target 'MyApp' from project 'MyApp')

先ほどのPackageのドキュメント をよく読むと、あらやだ、 unsafeFlags を使うと他のパッケージからこのターゲットが利用不可になると書かれてました。

/// As some build flags could be exploited for unsupported or malicious
/// behavior, the use of unsafe flags make the products containing this
/// target ineligible to be used by other packages.

ですが、もしこれが利用可能だとしても、どこに「OurLibrary-Swift.h」を出力したら良いのかわかりません。Package.swiftと同じところに出力されても、利用するアプリからその場所を知る術がないのです。

Cターゲットのパッケージにする

ちょっと行き詰まってしまった感があるので、ここで方針を変えてみます。

SwiftPMは、Swiftターゲットだけでなく、 Cターゲット も作成できます。
Cターゲットのライブラリであれば、Objective-Cから利用できます。

それでは、Cターゲットを作成してみましょう。Cターゲットは慣習的に名前を「C」から始めるようなので「COurLibrary」としました。
また、「COurLibrary」はSwiftターゲットである「OurLibrary」に依存するようにしました(なお、先ほどの unsafeFlags は削除しました)。

Package.swift
// swift-tools-version:5.1
...
let package = Package(
    ...
    targets: [
        .target(
            name: "OurLibrary",
            dependencies: []),
        // ↓これを追加
        .target(
            name: "COurLibrary",
            dependencies: ["OurLibrary"]),
        ...
    ]
)

それから、パッケージで公開するライブラリを、Cターゲットの方にしてしまいましょう(ここはSwift向けとC向けの2つをライブラリとして公開しても別にいいと思いますが)。

Package.swift
// swift-tools-version:5.1
...
let package = Package(
    ...
    products: [
        // Products define the executables and libraries produced by a package, and make them visible to other packages.
        .library(
            name: "OurLibrary",
            targets: ["COurLibrary"]), // ← Cターゲットを公開
    ],
    ...
)

そして、COurLibraryターゲットに含めるソースファイルを用意します。

  • Sourcesフォルダの下にCOurLibraryフォルダを作成して、
  • その中に COurLibrary.m という空のファイルを置きます。
  • また、includeというフォルダを作成して、先ほど unsafeFlags を用いて生成された OurLibrary-Swift.h をそこに入れます(ついでにCOurLibrary.hにリネームしました)。
Sources.jpg

COurLibrary.m は空のファイルですが、COurLibraryがCターゲットだとSwiftPMに認識させるために必要です。中に入っているソースファイルの拡張子でターゲットの種類を認識しているのです。

これで、Objective-Cからも利用できるライブラリになりました。パッケージ側の作業はここまでです。

最後に、利用側のアプリのObjective-Cソースで、C向けのターゲットとして作られたCOurLibraryをインポートします。

MyObjcClass.m
#import "MyObjcClass.h"
@import COurLibrary;  // ← これを追加

// パッケージを利用するアプリの中にあるクラス
@implementation MyObjcClass

- (NSInteger)compute {
    OurSwiftClass *ourSwift = [[OurSwiftClass alloc] init];
    return [ourSwift addX:10 andY:20];
}

@end

やった!これでビルドが通りました:tada:

なお、これ、全部Staticリンクされているから大丈夫なだけのような気もしていますが、もしDynamicならどうなるのかはよくわかりません :thinking:
また、CターゲットのCOurLibraryをライブラリとして公開しましたが、Swiftソースからも、 import OurLibrary (Swiftターゲットの方をインポート)をすることで、そのまま使うことができています。COurLibraryがOurLibraryに依存しているから可能なのかなと思っていますが、これもDynamicリンクなら(以下略)

ヘッダを更新する

さて、一見、うまく解決したかのようですが、上に書いたやりかたでは、Cターゲットのincludeフォルダに置いたCOurLibrary.hもソースファイルの一部なので、必要に応じてこれをメンテナンスする必要があります。つまり、Sources/OurLibrary以下のSwiftソースを変更するたびに、COurLibrary.hを更新しないといけないわけです。

ヘッダの更新は、 -emit-objc-header を付けてSwiftコードをコンパイルすることでできます。しかし、SwiftPMによるSwiftコードのコンパイルに -emit-objc-header を渡すには unsafeFlags を使う必要があります。そして、それを付けたターゲットは他から利用できないわけですね。

…そこで思いつきました。
パッケージで公開するターゲットと、公開しないターゲットの2種類を作っておいて、公開しないターゲットの方に unsafeFlags を使えばいいのでは? 開発中はそちらのターゲットをビルドすれば、ヘッダファイルを更新できます。
やってみましょう!

Package.swift
// swift-tools-version:5.1
...
let package = Package(
    ...
    targets: [
        .target(
            name: "OurLibrary",
            dependencies: []),
        // ↓これを追加
        .target(
            name: "UpdateHeader",
            dependencies: [],
            path: "Sources/OurLibrary",
            swiftSettings: [
                .unsafeFlags(["-emit-objc-header", "-emit-objc-header-path", "Sources/COurLibrary/include/COurLibrary.h"])
            ]),
        ...
    ]
)

ターゲットには path 引数があり、指定しないとデフォルトで Sources/(ターゲット名) になっているのですが、それではコンパイルするソースファイルがないので、この引数で敢えてOurLibraryのソースを指定してみました。すると、

target 'UpdateHeader' has sources overlapping sources: 

という無慈悲なエラーが。そうか、ダメなのか… :unamused:

となると、つまるところ、同じターゲットでやるしかないわけで、そうすると、ビルド環境によって挙動を変えるくらいしか手がない。そう、環境変数です。

Package.swiftはSwiftソースなので、 Package オブジェクトを作るところはSwiftのコードが使えます。そこで、環境変数で挙動を変えるようにしてみます。

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

import PackageDescription
import Foundation // ←ProcessInfoを使うために追加

// 環境変数を見てヘッダを更新するかどうかを判断
let updateHeader = ProcessInfo.processInfo.environment["UPDATE_OBJC_HEADER"] != nil

let package = Package(
    ...
    targets: [
        .target(
            name: "OurLibrary",
            dependencies: [],
            // ↓ヘッダを更新する場合にのみunsafeFlagsを使う
            swiftSettings: updateHeader
                ? [.unsafeFlags(["-emit-objc-header", "-emit-objc-header-path", "Sources/COurLibrary/include/COurLibrary.h"])]
                : nil),
        ...
    ]
)

これで、UPDATE_OBJC_HEADER という環境変数が定義されているときだけ、ビルド時にヘッダも更新されるようになりました。

とりあえず、この環境変数を定義して、コマンドラインからビルドを行うという下記のシェルスクリプトを作りましたが、結局、Swiftソースを更新した後に、これを何らかの方法で呼び出す必要があります。CIでフォローするとか、やりようはありそうですが、そこはまだ特には解決していません… :rolling_eyes:

update_header.sh
#/bin/sh

export UPDATE_OBJC_HEADER=1
xcodebuild -scheme OurLibrary -destination "name=iPhone 11"

謝辞というか宣伝というか

ここに書いたようなことを試行錯誤していたときに、Discordというチャットのswift-developers-japanサーバーで色々と助けていただきました。ありがとうございました。相談に乗っていただいたみなさまのおかげで、ここまでたどり着きました!

SwiftやiOS/Macアプリの開発に興味がある方は、↓ここからswift-developer-japanへ、ぜひどうぞ!
https://medium.com/swift-column/discord-ios-20d586e373c0

16
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?