STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

99%の人が知る必要がないSwift Package物語

はじめに

はじめまして、都内ITベンチャーに勤める中肉中背の男です。簡単に経歴を紹介すると、少年時代にみんながジャンプでワンピースを読んでる中、サンデーで神のみぞしる世界を読んでいたため、運命に導かれるかのようにエンジニアとしてのキャリアをスタートする事になりました。なお、少年時代は無肉中背で、あまりにも細かったので母親からはエヴァンゲリオンと呼ばれていました。

さて、私はSTORESで決済SDKの開発にiOSエンジニアとして携わっています。当社のSDKには、クレジットカードや電子マネーを使う決済機能が含まれており、決済に使う端末に関する処理やレシート印刷に関する処理は、Android版のSDKと共有して利用するためC言語(厳密にはC++の実装も含むが、以降、特に強く使い分けない)で実装されています。この実装はSDKリポジトリとは別のリポジトリで開発・運用されており、iOSSDKからはこれらのファイルをGit Submoduleを使って導入し、Xcode Projectの管理ファイルに含めていました。下図はその構成を示しており、Printer(印刷に関する処理)とTerminal(決済に使う端末に関する処理)という2つの処理がSDKのFrameworkに含まれています。

SDK Frameworkに含まれるSDKソースコード、Printerのソースコード、およびTerminalのFrameworkを示す。PrinterにはFrameworkがなくソースコードをそのままアサインしているが、TerminalはFrameworkをEmbedしている。

今回、PrinterとTerminalの処理をそれぞれSwift PackageとしてBundleし、Swift Package Managerを介してSDKに導入できるように変更しました。この変更によって、Git Submoduleを使わずに他のライブラリと同様の方法でPrinterとTerminalの処理を管理できる上、それらの処理をSDKとは別のモジュールとして切り離すことができます。この記事では、C言語で実装された処理をどのようにSwift Package化したかや、それを利用したSDKを配布する上で直面した課題と解決策について紹介します。おそらくどちらのニーズも殆どのプロジェクトにないと思いますが、興味のある人はお付き合い頂けたらと思います。

C言語のライブラリとSwift Package

C言語をSwift Packageにする、というのはなんだか日本語が崩壊しているように見えますが、公式ドキュメントでもSwift Packageは以下のように定義されています。

Swift packages are reusable components of Swift, Objective-C, Objective-C++, C, or C++ code.

https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode

また、以下のドキュメントではヘッダーファイルがある事以外はSwiftと同様の方法でPackage化できるというように書かれているように見えますが、実際にやる上ではいくつかエラーがありました。

developer.apple.com

以降、TerminalのSwift Package化を例にそれらのエラーを含めてPackage化の手順について説明していきます。 なお、例として分かりやすくするために実際の実装や構造よりも簡略化して説明している点をご了承ください。

Package.swiftを作成する

まず、Package.swiftを作成します。以下のコマンドを使うとgitignoreやREADMEなどの関連ファイルと共にPackage.swiftを生成できますが、既存のプロジェクトをPackage対応する際はほとんど不要になるかと思います。

$ swift package init
Creating library package: Terminal
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/Terminal/Terminal.swift
Creating Tests/
Creating Tests/TerminalTests/
Creating Tests/TerminalTests/TerminalTests.swift

コンパイルは通らないと思いますが)いったん、以下のようなPackage.swiftとgitignoreがあれば十分です。

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

import PackageDescription

let package = Package(
    name: "Terminal",
    products: [
        .library(
            name: "Terminal",
            targets: ["Terminal"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "Terminal",
            dependencies: [])
    ]
)
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

C言語ソースコードを含むTargetを作る

以下のようにTargetを作ります。SwiftのTargetを作る時と同様に任意のTargetの名前と、ソースファイルが配置されているディレクトリのパスを指定します(Targetの名前がデフォルトで参照されます)。ただし、public headerがあるディレクトリの指定が必要な点で異なります。この指定は必須ではないですが、指定しない場合にはincludeという名前のディレクトリにヘッダーを配置する必要があります。

.target(
            name: "Terminal",
            path: "iOS/hoge/fuga/Sources",
            publicHeadersPath: "Public"
        )

複数の言語を同じTargetに含めることは現在(2023/04/20)できないため、もしC言語と他の言語のファイルを同じディレクトリに配置している場合は、ディレクトリやTargetの整理を行う必要があります。 例えば、Objective-CC言語のファイルを同じTargetのパスに含めていた場合はコンパイルエラーになるため、それぞれを別のTargetに切り分ける事になります。 今回は以下のように、C言語の実装のみを含んだTerminalCoreと、Objective-Cの実装のみを含んだTerminalというようにTargetを分けました。

.target(
            name: "Terminal",
            path: "iOS/hoge/fuga/Sources"
            dependencies: ["TerminalCore"],
            publicHeadersPath: "Public"
),
.target(
            name: "TerminalCore",
            path: "Shared/hoge/fuga/Sources"
)

なお、この仕様をなくす提案(および、実装)はすでにあり、将来的には複数の言語を含むTargetを作れるようになるかもしれません。

github.com

不要なファイルを除外する

Targetが含むソースファイルやヘッダーのパスを指定する方法を先述しました。しかし、Androidでは利用するがiOSからは利用しないファイルがそのパスに存在する場合、それらは除外した方が簡潔なTargetを作る事ができます。 除外するには以下のような指定をTargetに追加します。

exclude: [
  "hoge/fuga.c",
  "include/hoge.h",
]

C/C++のバージョンを指定する

開発に利用しているC/C++のバージョンのバージョンを以下のように指定します。もし、Xcode Projectを使ってC/C++ソースコードをバンドルして開発を行っている場合、同様の設定がXcode Projectにあるのでそれと同じ指定をすれば良いです。ここで異なる値を指定すると、従来と別のバージョンのコンパイラコンパイルされてしまい、コンパイルエラーが発生する可能性があります。

let package = Package(
    ...
    cLanguageStandard: .c89,
    cxxLanguageStandard: .cxx11
)

設定可能な値は以下が詳しいです。

github.com

main.*のような命名をしているファイルを取り除く・改名する

main.*のような命名なファイルが含まれている場合には以下のようなエラーが発生しPackageを作成することができません。

library product 'Terminal' should not contain executable targets (it has 'Terminal')

(おそらく)mainという名前のファイルがTargetに含まれているとexecutableTargetだと解釈されてしまうからだと思うのですが、targetで宣言しているのにそのように解釈されるのは疑問です。 これは改名によって解決できます。

ヘッダーしかない場合はダミーのファイルを追加する

軽量な実装しかない場合やヘッダーだけ提供すればSwiftから機能が利用できる場合には、.c.cppファイルが存在しないヘッダーファイルのみのTargetを作りたいかと思います。しかし、ヘッダーファイルのみを含んだTargetはSwift Package Managerの不具合によって現時点(2023/04/20)ではできないため、ダミーの.c.cppファイルを作る必要があります。たとえば、appleが運用するswift-numericsでは以下のようなファイルが使われています。(実装時、自分はダミーファイルを作る発想がなく、他のTargetのheaderに追加する形で実装してしまった....)

github.com

この不具合は年末を生贄に以下のPRで修正したので、いつかリリースされると信じています。

github.com

Swift Packageの利用方法

以上までで、下記のようなSwift Packageを作成しました。

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

import PackageDescription

let package = Package(
    name: "Terminal",
    products: [
        .library(
            name: "Terminal",
            targets: ["Terminal"]
        ),
    ],
    dependencies: [],
    targets: [
      .target(
            name: "Terminal",
            path: "iOS/hoge/fuga/Sources"
            dependencies: ["TerminalCore"],
            publicHeadersPath: "Public"
      ),
      .target(
            name: "TerminalCore",
            path: "Shared/hoge/fuga/Sources"
            exclude: [
             "hoge/fuga.c",
             "include/hoge.h",
           ]
      )
    ],
    cLanguageStandard: .gnu11,
    cxxLanguageStandard: .gnucxx14
)

この差分をGitHubリポジトリにPushすると、Xcodeの以下の画面からPackageをimportできるようになります。

ローカル環境にあるSwift Packageも左下にある"Add Local"ボタンから追加することができます。開発中はLocal環境でデバッグする方が効率がいいのでおすすめです。自分が試した際は、Local Packageに限りこの画面からimportするだけでは不十分で、以下のようにPackageを利用するTargetでEmbedの指定をする必要がありました。

リリースフローを策定する

Packageを作るにあたりリリースフローを策定しないと、継続的なデリバリーが行われなくなる可能性があります。おそらく、Androidとコードを共有しているライブラリの場合、Androidチームとも合意形成を行う必要があります。リリースフローについてはissueで論点をまとめつつ関係者と議論し内容を決めました。内容については、チームが合意できるものであれば最初の段階では割となんでも良いと思います。

インターナルなSwift Packageを依存に含むSDKを配布する際の注意点

Swift Package Managerに限らず、依存管理のソフトウェアを利用した事がある方は想像できるかと思いますが、依存の解決を行う際に依存は再帰的に解決されます。そのため、今回のようにTerminalというライブラリがあり、そのライブラリを参照するSDKという名前のライブラリがあった際に、SDKの依存を解決する際にTerminalの依存も解決する必要があります。このような仕組みはシンボルの衝突を避けたりバージョンを調整する際に有益だと思いますが、今回のように社内でしか利用していないインターナルなSDKを利用する場合は厄介です。SDKをXCFrameworkとして配布し、それを任意のアプリでimportすると以下のようなエラーがでてしまいます。

import SDK // Missing required module: `Terminal`

この問題に対しては以下のようにSDK内でTerminalをimportする際に@_implementationOnlyを使ってマークすることによって対応しました。

@_implementationOnly import Terminal 

この機能を利用することによって内部で依存しているAPIがあっても外部から参照しなくなります。普段アプリ開発をしていて目にする機会はあまりないですが、Firebaseでも利用されている機能です。アンダースコアから始まる命名はまだ安定版ではないAPIであることを指していますが、すでに導入事例がいくつか見つかったので一定確信をもって導入できました。

github.com

余談ですが、Underscored Attributesは他にもいくつかあり、以下にまとめられています。

github.com

おわりに

以上までで、C言語のSwift Packageを作る方法や、インターナルなSwift Packageの依存を持つSDKを配布する上での注意点などをまとめました。 個人的にもC言語のSwift Packageを作るという珍しい経験をできただけでなく、Swiftの知らない言語仕様を使って問題解決できたり、Swift Package Managerへのコントリビュートに繋げることができたり、リリースフローなどの運用面も策定できたりと、いろんな体験ができて面白いプロジェクトでした。 正直、C言語を利用して開発しているプロジェクトやSDKを開発している人も少ないと思います。ただ、このあたりは情報も少なくハマると対応が長期化する可能性もあるので、自分たちと同様なニッチなニーズがある環境に何か有益な情報を提供できていたら嬉しいです。 以上!