Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

社内ライブラリを Swift Package Manager に対応させた話 その1 ~Swift, Cベースの言語, MLModel が混在するプロジェクト編~

はじめに

こんにちは、 Mobile Application Group で iOS アプリエンジニアをやっている多鹿です。
過去にもいくつかこのブログに投稿してきましたが、 iOS アプリエンジニアらしい記事を書くのは初めてかもしれません。
今回は、 Sansan / Eight の iOS アプリにて共通で使っている社内ライブラリを Swift Package Manager (以降 SwiftPM) に対応させた話をしていこうと思います。 二つのライブラリを SwiftPM に対応したので「その1」と「その2」の二回に渡ってそれぞれのライブラリの対応について共有できればと思います。

SwiftPM 対応した社内ライブラリについて

Sansan / Eight の両 iOS アプリケーションでは、どちらも「名刺」を端末のカメラで撮影する機能があります。
カメラに投影した「名刺」をリアルタイムで認識する機能と、撮影した画像から名刺部分を切り抜く機能が別のライブラリになっており、それぞれ研究開発部が開発したものを iOS アプリケーションからは Carthage を使用して xcframework にしたものを使用しています。 今回 SwiftPM に対応したのはこの「名刺認識」ライブラリと「名刺の切り抜き」ライブラリの二つになりますが、本記事ではこれら二つのうち「名刺認識」ライブラリについて書いていきます。

また、本記事にて「名刺認識」ライブラリそのもののリアルタイム名刺認識の技術を掘り下げることはしませんが、研究開発部による下記の記事で触れられているので、興味がある方は併せてご覧ください。

buildersbox.corp-sansan.com

SwiftPM 対応の要件

  • Sansan iOS アプリケーションおよび Eight iOS アプリケーションにおいて、 SwiftPM を利用して名刺認識ライブラリを利用できるようになること
  • SwiftPM 対応後も従来通り Carthage を利用したライブラリの使用ができること*1

対象リポジトリのディレクトリ構成

今回の対象である名刺認識ライブラリは、ベースになる処理が C++ のコードベースになっており、 iOS / Android 共通で利用できるようになっています。
そのため、このリポジトリの構成は「共通の C++ コードベース」「Android アプリに組み込むフレームワーク」「iOS アプリに組み込むフレームワーク」がディレクトリによって分かれており、やや複雑な構成になっています。

.
├── AndroidApp                  # Android アプリに組み込むためのフレームワークを管理するディレクトリ
├── ...                         # いくつかの C++ のコードベースがある複数のディレクトリ
├── CardDetectionCocoa          # iOS 用のもう一つのライブラリ。 Xcode プロジェクトになっている。
├── CardDetectionV2             # ★ 今回の対象。Xcode プロジェクトになっている。
├── CardDetectionV2.xcworkspace # CardDetectionCocoa と CardDetectionV2 を管理する workspace
├── CoreML                      # iOS アプリケーションで利用する CoreML のモデル
└── iOS                         # 今回の対象である CardDetectionV2 から参照しているコード群

複数チームで触っているリポジトリであることや、メンテナンスの頻度が高くないこと、古くから存在していることなどが相まって、ディレクトリ構成は整理されているとは言いづらい状況ではあります。
今回の SwiftPM 対応に併せて、ディレクトリを変更することも考えましたが、今回は Carthage サポートを継続することなども踏まえて、既存の Xcode プロジェクトが参照切れによってリグレッションするリスクを抑えたかったこともあり、現状のディレクトリ構成のデメリットを受け入れ、極力ディレクトリ構成は変えずに進めることにしました。

対応手順と注意点

対応手順を見る前に、先に完成した Package.swift (記事用に一部改変したもの)をお見せします。

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

import PackageDescription

let sharedExclude: [String] = [
    "README.md",
    "AndroidApp",
    // その他いくつかのディレクトリ/ファイル,
]

let package = Package(
    name: "CardDetection",
    products: [
        .library(
            name: "CardDetectionV2",
            targets: [
                "CardDetectionV2",
                "CardDetectionWrapper",
            ]),
    ],
    dependencies: [
    ],
    targets: [
        .target(
            name: "CardDetectionWrapper",
            path: ".",
            exclude: sharedExclude + [
                // いくつかの使用しないディレクトリ/ファイル,
            ],
            sources: [
                "iOS/CardDetectionWrapper",
                "v2/CardDetectionV2Cpp/NoaiImageProcessing",
                "v2/CardDetectionV2Cpp/ToolsToCardDetectionV2",
            ],
            publicHeadersPath: "iOS/include",
            cxxSettings: [
                .headerSearchPath("v2/CardDetectionV2Cpp/ToolsToCardDetectionV2"),
            ]),
        .target(
            name: "CardDetectionV2",
            dependencies: [
                "CardDetectionWrapper",
            ],
            path: ".",
            exclude: sharedExclude + [
                // いくつかの使用しないディレクトリ/ファイル,
            ],
            sources: [
                "iOS/CardDetector",
                "models",
            ],
            resources: [
                .process("CoreML/DetectionModel_bgr.mlmodel"),
                .process("CoreML/SegmentationModel_mlarray-out.mlmodel"),
            ]),
    ],
    cLanguageStandard: .gnu11,
    cxxLanguageStandard: .gnucxx14
)

シンプルなライブラリにおける Package.swift に比べると、少し煩雑に見えるかもしれません。以降の対応手順でこれらについて触れていこうと思います。

1. Package.swift の作成

まず SwiftPM 対応に必須となる Package.swift ファイルをリポジトリのルートに作成します。
今回はリポジトリのディレクトリ構成を大きく変えない方針にしたことから Sources ディレクトリなども使用しないことにしました。したがって swift package init コマンドも使用せず、直接ファイルを作成することにしました。

Sources ディレクトリを使わない場合、パッケージの各 Target に必要なソースファイルは Target.target(name:dependencies:path:exclude:sources:publicHeadersPath:)path 引数と sources 引数を設定してあげることで指定することができます。

2. Target の定義

次にライブラリで使用する Target を定義していきます。

と、その前に、今回の対象ライブラリにおいては注意点がありました。それは、 C ベースの言語(C, C++, Objective-C, Objective-C++)と Swift ファイルが同一ディレクトリ内に混ざったライブラリになっていたことです。

もちろん、これまでは Xcode プロジェクトで管理されたライブラリだったので、それでも問題ありませんでしたが、 SwiftPM (5.7 時点) においては、 C ベースの言語と Swift が混在した Target を作ることはできません。*2

そこで、 C ベースの言語の Target (ClangTarget) と Swift の Target (SwiftTarget) を分ける必要がありました。 Target を分けるということは、これらのファイルが混在しているディレクトリも明確に分ける必要があり、下記のように変更しました。

before after

これを踏まえて、 Target としては次の二つとなりました。(命名については過去の経緯もありイメージしづらいかもしれませんがここでは目を瞑っていただければと思います)

  • CardDetectionWrapper
    • C ベースの言語で構成された ClangTarget
  • CardDetectionV2
    • Swift で構成された SwiftTarget
    • ClangTarget である CardDetectionWrapper を内部的に使用している(CardDetectionWrapper に依存している)
    • 各 iOS アプリケーションからはこの Target で定義された API を使用する
.target(
    name: "CardDetectionWrapper",
    path: ".",
    exclude: sharedExclude + [
        // いくつかの使用しないディレクトリ/ファイル,
    ],
    sources: [
        "iOS/CardDetectionWrapper",                     // ★ C ベースの言語だけを集約させたディレクトリ
        "v2/CardDetectionV2Cpp/NoaiImageProcessing",    // プラットフォーム共通の C++ のコード
        "v2/CardDetectionV2Cpp/ToolsToCardDetectionV2", // プラットフォーム共通の C++ のコード
    ]),
.target(
    name: "CardDetectionV2",
    dependencies: [
        "CardDetectionWrapper", // ★ ClangTarget に依存している
    ],
    path: ".",
    exclude: sharedExclude + [
        // いくつかの使用しないディレクトリ/ファイル,
    ],
    sources: [
        "iOS/CardDetector", // ★ Swift だけを集約させたディレクトリ
        "models",
    ]),

3. CardDetectionWrapper Target (ClangTarget 側) を完成させる

さて、一旦 Target を定義しましたが、まだいくつかやることがあるので、 ClangTarget として定義した CardDetectionWrapper を完成させていきます。

i. パッケージで使う C および C++ のスタンダードを指定する

対象のライブラリはもともと Xcode プロジェクトで管理しており、そこではビルド設定として GCC_C_LANGUAGE_STANDARD および CLANG_CXX_LANGUAGE_STANDARD を指定していました。
従って、 SwiftPM に対応する際にも上記に相当する値を設定します。
これはパッケージ全体での設定になり、 Package.cLanguageStandard プロパティと Package.cxxLanguageStandard プロパティにそれぞれ値を入れてあげます。

let package = Package(
    name: "CardDetection",
    products: [
        // ~~ 略 ~~
    ],
    dependencies: [
    ],
    targets: [
        // ~~ 略 ~~
    ],
+   cLanguageStandard: .gnu11,
+   cxxLanguageStandard: .gnucxx14
)

元の Xcode プロジェクト側で GNU11 と GNU++14 が指定されていたので、 SwiftPM 側でもその値を使用するようにしました。

ii. CxxSetting.headerSearchPath(_:_:) を指定する

上記 Target の定義のままビルドすると、ヘッダーが見つからずエラーになってしまいます。
そこで、ヘッダーファイルを見つけられるように Target に CxxSetting.headerSearchPath(_:_:) を設定し、必要なヘッダーファイルがあるディレクトリを指定してあげます。

// CardDetectionWrapper Target 部分のみ抜粋
.target(
    name: "CardDetectionWrapper",
    path: ".",
    exclude: sharedExclude + [
        // いくつかの使用しないディレクトリ/ファイル,
    ],
    sources: [
        "iOS/CardDetectionWrapper",
        "v2/CardDetectionV2Cpp/NoaiImageProcessing",
        "v2/CardDetectionV2Cpp/ToolsToCardDetectionV2",
+   ],
+   cxxSettings: [
+       .headerSearchPath("v2/CardDetectionV2Cpp/ToolsToCardDetectionV2"),
    ]),

Xcode プロジェクトの Build Settings でいうところの HEADER_SEARCH_PATHS に相当するものという理解です。

これで CardDetectionWrapper Target としてのビルドは通るようになりました。

iii. publicHeadersPath で外部に公開するヘッダーのパスを記載

Target としてビルドできるようにはなりましたが、この Target は他の Target から利用されるので、外部に公開するヘッダーファイルへのパスを publicHeadersPath で指定します。
今回は、新しくディレクトリを切って、そこに外部公開したいヘッダーファイルを配置しました。

// CardDetectionWrapper Target 部分のみ抜粋
.target(
    name: "CardDetectionWrapper",
    path: ".",
    exclude: sharedExclude + [
        // いくつかの使用しないディレクトリ/ファイル,
    ],
    sources: [
        "iOS/CardDetectionWrapper",
        "v2/CardDetectionV2Cpp/NoaiImageProcessing",
        "v2/CardDetectionV2Cpp/ToolsToCardDetectionV2",
    ],
+   publicHeadersPath: "iOS/include",
    cxxSettings: [
        .headerSearchPath("v2/CardDetectionV2Cpp/ToolsToCardDetectionV2"),
    ]),

これによって、このターゲットを利用する側が公開されたヘッダーにアクセスできるようになります。

4. CardDetectionV2(SwiftTarget 側)を完成させる

それでは次に SwiftTarget である CardDetectionV2 と呼んでいる Target を完成させていきます。
こちらも前述の定義だけでは不足があるのでいくつかの設定を追加していきます。

i. Core ML への対応

前述の 研究開発部の記事 でも触れられていますが、このライブラリは Core ML を使用しています。
Xcode プロジェクトでは、 mlmodel を Target に追加することで Xcode が自動で mlmodel をコンパイルして mlmodelc を生成してくれていました。
そして、これを Swift のコードから参照できるようになっていました。

SwiftPM では(Xcode 14 以降であれば)Target の resources に mlmodel を追加しておくことで、 Target のコンパイル時に mlmodel も一緒にコンパイルしてくれます。
Xcode 14 未満で使用する場合は工夫が必要そうでしたが、今回はこのライブラリを使用する各アプリケーションが Xcode 14 以降で動作させる前提で良かったので、 Xcode 14 以降でサポートされた方法で Core ML への対応を進めます。

// CardDetectionV2 Target 部分のみを抜粋
.target(
    name: "CardDetectionV2",
    dependencies: [
        "CardDetectionWrapper",
    ],
    path: ".",
    exclude: sharedExclude + [
        // いくつかの使用しないディレクトリ/ファイル,
    ],
    sources: [
        "iOS/CardDetector",
        "models",
+   ],
+   resources: [
+       .process("CoreML/DetectionModel_bgr.mlmodel"),
+       .process("CoreML/SegmentationModel_mlarray-out.mlmodel"),
    ]),

Xcode 14 での SwiftPM の mlmodel サポートについては公式情報がうまく見つけれらませんでしたが、 WWDC 2022 の Optimize your Core ML usage セッションにてほんの少し触れられていたのでそちらをご参照ください。

developer.apple.com

ii. 切り分けた ClangTarget の import を行う

ここまでの対応で、 SwiftTarget である CardDetectionV2 は ClangTarget である CardDetectionWrapper に依存する形になっています。
それに伴って、 CardDetectionV2 側の Swift のコードで CardDetectionWrapper のコードを参照する箇所には import CardDetectionWrapper を明記する必要が出てきたので、適宜コードに追加していきます。

さて、ここまで行うと、 SwiftTarget である CardDetectionV2 Target もビルドが通るようになりました。

5. 作成した Target を Library Product として公開する

それぞれ必要な Target の定義が完了したので、これらをアプリケーションから使用するために Product として公開します。
SwiftPM (5.7 時点) の Product としては Library Product, Executable Product, Plugin Product の三種類がありますが、今回は Library Product として使用されるので Product.library(name:type:targets:) を使って Product を定義します。

// ~~ 略 ~~
let package = Package(
    name: "CardDetection",
    products: [
        // ★ Product として公開
+       .library(
+           name: "CardDetectionV2",
+           targets: [
+               "CardDetectionV2",
+               "CardDetectionWrapper",
+           ]),
    ],
    dependencies: [
    ],
    targets: [
        // ~~ 略 ~~
    ],
    // ~~ 略 ~~
)

さて、以上で iOS アプリケーションから SwiftPM を利用して今回対応したライブラリを利用できるようになりました!
これにて SwiftPM 対応としては完了になります。

6. Carthage からのインストールサポートに関する作業

無事 SwiftPM 対応ができたのですが、今回は従来サポートしていた Carthage からのインストールを継続してサポートするという要件がありました。

これまでの SwiftPM 対応の手順によって Carthage が参照する Xcode プロジェクト側でビルドエラーが発生するようになったのでそれらを修正していきます。

i. ディレクトリ変更による影響

SwiftPM 対応の中で ClangTarget 用のディレクトリと SwiftTarget 用のディレクトリを分けました。
これによって Xcode プロジェクトから参照していたファイルが見つからなくなり、ビルドエラーが発生したので適宜ファイルの参照先を調整していきます。

ii. import CardDetectionWrapper がエラーになる

SwiftPM の方では ClangTarget と SwiftTarget を分ける必要があったので、 CardDetectionWrapper という ClangTarget を切り分け、必要に応じて CardDetectionWrapper を import するような変更を加えました。
一方で、従来から利用している Xcode プロジェクトでは、 C ベース言語のファイルも一つの Target に集約されて参照していたため、 CardDetectionWrapper というフレームワーク自体が存在しません。
SwiftPM に合わせて実際にフレームワークとして切り出すのも手だったかもしれませんが、今回は CardDetectionWrapper を import している箇所を次のように修正することで対応しました。

+ #if canImport(CardDetectionWrapper)
  import CardDetectionWrapper
+ #endif

これによって、

  • SwiftPM では CardDetectionWrapper Target が定義されているので import できる
  • Xcode プロジェクトでは CardDetectionWrapper は存在しないので無視される

という挙動になり、 SwiftPM でも Xcode プロジェクトでもエラーにならないようになりました。

以上で Carthage のサポートもしつつ、 SwiftPM への対応が完了となります。

対応のポイント

  • 既存のディレクトリ構成を大きく変えないために Sources ディレクトリを使わず、 Target の path や sources, publicHeadersPath は明示的に指定した
  • SwiftPM (5.7 時点) では Swift と C ベース言語を一つの Target に混在できないので、 ClangTarget と SwiftTarget を分けた
  • Xcode 14 以降をサポート対象にすることで Core ML への対応がスムーズにできた
  • Carthage と SwiftPM で Target の切り方が異なることで発生した import のエラーを canImport を使って解決した

おわりに

今回は二つある社内ライブラリのうちの片方を SwiftPM に対応した際の手順や注意点を記しました。
C ベースの言語と Swift が混在したライブラリであったことや、 Core ML への対応など、今回の対応を通して、普段シンプルな Swift パッケージを作っているとあまり意識しないような SwiftPM の仕様にも出会いました。
元々私自身は C++ や Objective-C, Objective-C++ の言語仕様に詳しくなかったので、 SwiftPM 以外の面でも input することが多くあり、大変な面はありつつも個人的にはとても興味深い経験となりました。

今回の記事は「その1」としていますが「その2」では、もう片方のライブラリの SwiftPM 対応について書いていこうと思います。そちらもいくつか一筋縄ではいかなかった点があったので共有できればと思います。

*1:[Carthage サポートを継続した理由について] 同時に SwiftPM 対応したもう一つの「名刺の切り抜き」ライブラリ側で、 SwiftPM 対応以外に arm64 シミュレータに対応するための修正を行いました。また、Sansan / Eight ともにこの対応時点で SwiftPM を利用していない状況であったことから、アプリケーション側として「SwiftPM はまだ使わないけど arm64 シミュレータ対応は取り込みたい」という選択ができるように Carthage サポートを継続したまま SwiftPM に対応することにしました。

*2:記事執筆時点で混在した Target を作成できるようにする Pitch が作成されていました

© Sansan, Inc.