Sansan Tech Blog

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

社内ライブラリを Swift Package Manager に対応させた話 その2 ~OpenCV に依存したライブラリ編~

はじめに

こんにちは、 Mobile Application Group で iOS アプリエンジニアをやっている多鹿です。

前回は Sansan / Eight の iOS アプリにて共通で使っている社内ライブラリを Swift Package Manager (以降 SwiftPM) に対応させた話の「その1」を公開しました。

buildersbox.corp-sansan.com

今回はもう一つの社内ライブラリを SwiftPM に対応させた話になります。

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

さて、前回の記事で次のような話をしました。

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

今回は後者の「名刺切り抜きライブラリ」を SwiftPM に対応させた話になります。

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

今回の対象である名刺切り抜きライブラリは、前回記事で紹介した名刺認識ライブラリと同じリポジトリに存在しています。
そして、こちらもベースになる処理が C++ のコードベースになっており、 iOS / Android 共通で利用できるようになっています。
従って、前回紹介したライブラリ同様に「共通の C++ コードベース」「Android アプリに組み込むフレームワーク」「iOS アプリに組み込むフレームワーク」がディレクトリによって分かれています。

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

今回対象となるライブラリは CardDetectionCocoa と呼ばれており、同名のディレクトリの中に Xcode プロジェクトとして存在しています。

また、今回のライブラリはソースコードに C++, Objective-C, Objective-C++ が使われており、前回紹介したライブラリのように Swift との混在はしていないため ClangTarget として定義していくことになります。

対応手順と注意点

今回も、手順を見る前に完成した Package.swift をお見せします。
今回の対象ライブラリは前回 SwiftPM 対応させたライブラリと同じ 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: "randd_CardDetection",
    products: [
        .library(
            name: "CardDetectionCocoa",
            targets: [
                "CardDetectionCocoa",
            ]),
    ],
    dependencies: [
    ],
    targets: [
        .binaryTarget(
            name: "opencv2",
            path: "iOS/Frameworks/opencv2.xcframework"),
        .target(
            name: "CardDetectionCocoa",
            dependencies: [
                "opencv2",
            ],
            path: ".",
            exclude: sharedExclude + [
                // いくつかの使用しないディレクトリ/ファイル,
            ],
            sources: [
                "CardDetectionCocoa/CardDetectionCocoa",
                "CalcContrast", // 共通の C++ のコードベース
                "BcDetection",  // 共通の C++ のコードベース
            ],
            publicHeadersPath: "CardDetectionCocoa/include",
            cxxSettings: [
                .headerSearchPath("BcDetection"),
                .headerSearchPath("CalcContrast"),
            ]),
    ],
    cLanguageStandard: .gnu11,
    cxxLanguageStandard: .gnucxx14
)

1. ライブラリが依存する OpenCV の xcframework 化

さて、完成した Package.swift を見ていただくとお気付きかもしれませんが、今回の対象ライブラリは OpenCV に依存しています。
既存の Xcode プロジェクトで管理している同ライブラリは、 OpenCV の GitHub リリースの Assets から取得できる opencv2.framework を使用していました。
しかし、 SwiftPM にて OpenCV を依存追加するためにはこのままではダメなので、一つの解決方法として opencv2.frameworkopencv2.xcframework にして Target.binaryTarget(name:path:) として定義し、対象ライブラリの依存関係に追加するという方法があります。

まずはこの OpenCV の xcframework 化の対応をしていきましょう。

OpenCV の xcframework 生成スクリプトを利用する

OpenCV 4.5.1 以降であれば、 OpenCV 側で用意された xcframework 生成のための Python スクリプトがあるので、それを利用します。

github.com

今回は次のような Shell Script を作成し、上記の Python スクリプトを叩いて xcframework を作成しました。

#!/bin/bash
set -e

OPENCV_VERSION=4.5.1
DEPLOYMENT_TARGET="14.0"
FRAMEWORK_NAME=opencv2
XCFRAMEWORK="${FRAMEWORK_NAME}.xcframework"
SCRIPT_DIR="$(cd $(dirname $0) && pwd)"
PYTHON_VERSION="$(cat ${SCRIPT_DIR}/.python-version)"
OPENCV_DIR="${SCRIPT_DIR}/opencv"
OUTPUT_DIR="${SCRIPT_DIR}/../Frameworks"
build_xcframework="${OPENCV_DIR}/platforms/apple/build_xcframework.py"

cd "${SCRIPT_DIR}"

# ① install tools needed to build OpenCV

brew list cmake || brew install cmake
brew list pyenv || brew install pyenv
pyenv install ${PYTHON_VERSION} --skip-existing
eval "$(pyenv init --path)"

# cleanup

rm -rf "${OPENCV_DIR}"
rm -rf "${OUTPUT_DIR}/${XCFRAMEWORK}"

# ② download OpenCV source code

curl -L -o opencv.zip "https://github.com/opencv/opencv/archive/refs/tags/${OPENCV_VERSION}.zip"
unzip -q opencv.zip && rm opencv.zip
mv "opencv-${OPENCV_VERSION}" opencv

# ③ build xcframework

cd "${OPENCV_DIR}"

python ${build_xcframework} \
  --iphoneos_archs arm64 \
  --iphonesimulator_archs x86_64,arm64 \
  --build_only_specified_archs \
  --iphoneos_deployment_target ${DEPLOYMENT_TARGET} \
  --out ${OPENCV_DIR} \
  --framework_name ${FRAMEWORK_NAME} \
  --without video \
  --without dnn \
  --without gapi \
  --without stitching \
  --without ml \
  --without highgui \
  --without cudaimgproc \
  --without cudaarithm \
  --without cudalegacy \
  --without cudawarping \
  --without cudafeatures2d \
  --without photo \
  --without objdetect \
  --without ts \
  --without videoio

mv -f "${OPENCV_DIR}/${XCFRAMEWORK}" "${OUTPUT_DIR}"

# cleanup

cd "${SCRIPT_DIR}"
rm -rf "${OPENCV_DIR}"

このスクリプトについて少し説明をします。

① Python スクリプトを動かすのに必要な依存ツールのインストール

この Python スクリプトの readme にも記載されている通り、次のような依存ツールが必要になります。

  • Python 3.6 or later
  • CMake 3.18.5/3.19.0 or later (make sure the cmake command is available on your PATH)

そのため、 Homebrew から cmakepyenv をインストールし、スクリプトを叩く準備をします。

② OpenCV のソースコードをダウンロード

次に、 Python スクリプトそのものと、 Python スクリプトが参照しているものを取得するために、 OpenCV のソースコードを一度ローカルにダウンロードしてきています。

③ Python スクリプトを叩いて xcframework を生成

OpenCV のソースコードに含まれる Python スクリプトを叩いて xcframework を生成します。スクリプトにいくつかオプションを渡しているので、それらの説明をしていきます。

  • --iphoneos_archs
    • iOS 実機でサポートするアーキテクチャを指定する
    • ここでは arm64 を指定した
  • --iphonesimulator_archs
    • iOS シミュレータでサポートするアーキテクチャを指定する
    • ここでは x86_64arm64 を指定した
    • 元々 opencv2.framework を使っていたため Apple Silicon 搭載 Mac で登場する arm64 シミュレータでのビルドがサポートできない状態だったが、この設定によって arm64 アーキテクチャのシミュレータでもビルドできるようになる
  • --build_only_specified_archs
    • 指定したアーキテクチャのみビルドするフラグ
    • これを指定しないと、今回明示的に指定していない macOS や Catalyst のビルドも作られてしまう
  • --out
    • 生成した xcframework のアウトプットディレクトリを指定する
  • --framework_name
    • 生成した xcframework の名前を指定する
  • --without
    • OpenCV をビルドする際に不要なモジュールを指定する
    • 今回の対象ライブラリで使っていないモジュールを一通り指定した
    • 指定したモジュールを含まない分、生成されたバイナリのサイズを抑えることができる
      • 元々 GitHub リリースの Asset から取得した opencv2.framework 内のバイナリサイズが 100 MB 程だったが、この設定で生成したバイナリサイズが 40 MB 程度になった
    • こちらの記事も参考にしつつ OpenCV のモジュールの依存関係を見ながら、不要なものを判断していった

M1 MacBook Pro を以てしても、一回の OpenCV のビルドで数分はかかっていました。そのため、試行錯誤を重ね何度もビルドを回すことで、思いの外多くの時間を費やすことになりました。しかし、最終的には前述のようなスクリプトによって(完全ではないですが)ある程度再現性のある xcframework 生成スクリプトになりました。
実際には一度 xcframework を生成してしまえば、そう何度も必要になるスクリプトではないのですが、どのように生成されたか(使用した OpenCV のバージョンは何か?ビルドに含めなかった OpenCV のモジュールがどういうものか?等)が明確になるので、この Shell Script 自体もリポジトリに残しています。

このスクリプトを回すことによって、次のような xcframework が出来上がりました。

Frameworks
└── opencv2.xcframework # ★ 生成された xcframework
    ├── Info.plist
    ├── ios-arm64 # ★ iOS 実機向けの framework
    │   └── opencv2.framework
    └── ios-arm64_x86_64-simulator
        └── opencv2.framework # ★ iOS シミュレータ向けの framework。 arm64 と x86_64 の Architecture に対応。

これにて opencv2.xcframework の生成が完了です。

2. Package.swift にターゲットを定義

Package.swift は前回対応したライブラリと同じものを使用するので、そこに今回のライブラリに関するマニフェストを追加していきます。
まずはターゲットを定義していきましょう。

targets: [
    .target(
        name: "CardDetectionCocoa",
        dependencies: [
        ],
        path: ".",
        exclude: sharedExclude + [
            // いくつかの使用しないディレクトリ/ファイル,
        ],
        sources: [
            "CardDetectionCocoa/CardDetectionCocoa",
            "CalcContrast", // 共通の C++ のコードベース
            "BcDetection",  // 共通の C++ のコードベース
        ]),
],

前回のライブラリ同様、こちらもディレクトリ構造を大きく変えない方針で進めているため、 Sources ディレクトリなどを使わず、 Target の path や sources を使うことで必要なソースコードを明示的に指定しました。

3. opencv2.xcframework を依存に追加する

さて、先ほど生成した opencv2.xcframework を binaryTarget として定義し、対象ライブラリの依存に追加します。

targets: [
+   .binaryTarget(
+       name: "opencv2",
+       path: "iOS/Frameworks/opencv2.xcframework"),
    .target(
        name: "CardDetectionCocoa",
        dependencies: [
+           "opencv2",
        ],
        path: ".",
        exclude: sharedExclude + [
            // いくつかの使用しないディレクトリ/ファイル,
        ],
        sources: [
            "CardDetectionCocoa/CardDetectionCocoa",
            "CalcContrast", // 共通の C++ のコードベース
            "BcDetection",  // 共通の C++ のコードベース
        ]),
],

これで CardDetectionCocoa ターゲットから opencv の API を使うことができるようになりました。

4. CardDetectionCocoa を完成させる

i. headerSerachPath の指定

さて、ここまでの定義で CardDetectionCocoa ターゲットをビルドすると、 C++ のコードベースを include している箇所でヘッダーファイルが見つからないエラーが出てしまったので、必要なヘッダーの場所を指定するようにしましょう。

targets: [
    .binaryTarget(
        name: "opencv2",
        path: "iOS/Frameworks/opencv2.xcframework"),
    .target(
        name: "CardDetectionCocoa",
        dependencies: [
            "opencv2",
        ],
        path: ".",
        exclude: sharedExclude + [
            // いくつかの使用しないディレクトリ/ファイル,
        ],
        sources: [
            "CardDetectionCocoa/CardDetectionCocoa",
            "CalcContrast", // 共通の C++ のコードベース
            "BcDetection",  // 共通の C++ のコードベース
+       ],
+       cxxSettings: [
+           .headerSearchPath("BcDetection"),
+           .headerSearchPath("CalcContrast"),
        ]),
],

cxxSettings に CXXSetting.headerSearchPath(_:_:) を追加し、ヘッダーが含まれるパスを指定します。今回は共通の C++ のコードベースが含まれるディレクトリと同じディレクトリにヘッダーが入っていたので、それらを指定しました。

ii. publicHeadersPath の指定

さて、ここまでで CardDetectionCocoa ターゲット単体でのビルドが通るようになりました。
しかし、実際に iOS アプリケーションからこのライブラリを使用する際にはヘッダーファイルが公開されていないと API にアクセスできません。
そこで publicHeadersPath を指定して外部に公開するヘッダーファイルの場所を指定します。

targets: [
    .binaryTarget(
        name: "opencv2",
        path: "iOS/Frameworks/opencv2.xcframework"),
    .target(
        name: "CardDetectionCocoa",
        dependencies: [
            "opencv2",
        ],
        path: ".",
        exclude: sharedExclude + [
            // いくつかの使用しないディレクトリ/ファイル,
        ],
        sources: [
            "CardDetectionCocoa/CardDetectionCocoa",
            "CalcContrast", // 共通の C++ のコードベース
            "BcDetection",  // 共通の C++ のコードベース
        ],
+       publicHeadersPath: "CardDetectionCocoa/include",
        cxxSettings: [
            .headerSearchPath("BcDetection"),
            .headerSearchPath("CalcContrast"),
        ]),
],

今回は CardDetectionCocoa/include という場所にヘッダーファイルを置くことにしました。

ただし、実際にはヘッダーファイルの実体ではなくシンボリックリンクで参照する形にしています。

CardDetectionCocoa
├── CardDetectionCocoa
│   ├── CardDetection
│   │   ├── OpenCVOperator.cpp
│   │   ├── OpenCVOperator.h
│   │   ├── OpenCVWrapper.h # --- ★ ここを参照
│   │   ├── OpenCVWrapper.mm
│   │   ├── SquareRectPoint.h # - ★ ここを参照
│   │   └── SquareRectPoint.m
│   ├── CardDetectionCocoa.h # -- ★ ここを参照
│   └── Info.plist
└── include
    └── CardDetectionCocoa
        ├── CardDetectionCocoa.h -> ../../CardDetectionCocoa/CardDetectionCocoa.h
        ├── OpenCVWrapper.h -> ../../CardDetectionCocoa/CardDetection/OpenCVWrapper.h
        └── SquareRectPoint.h -> ../../CardDetectionCocoa/CardDetection/SquareRectPoint.h

各ヘッダーファイルの中で他のヘッダーファイルを相対パスで参照しており、不用意にファイルを移動させることが難しかったため、このように対応しました。

ちなみにですが、 Realm (v10.34.1 時点) でも publicHeadersPath 内のヘッダーファイルをシンボリックリンクで参照する形になっているようです。

github.com

5. Library Product として公開する

アプリケーションがアクセスするヘッダーの設定ができたので最後に Product.library(name:type:targets:) を使って Library Product として公開し、各 iOS アプリケーションから SwiftPM を使ってこのライブラリを参照できるようにします。

// ~~ 略 ~~

let package = Package(
    name: "randd_CardDetection",
+   products: [
+       .library(
+           name: "CardDetectionCocoa",
+           targets: [
+               "CardDetectionCocoa",
+           ]),
+   ],
    dependencies: [
    ],
    targets: [
        .binaryTarget(
            name: "opencv2",
            path: "iOS/Frameworks/opencv2.xcframework"),
        .target(
            name: "CardDetectionCocoa",
            dependencies: [
                // ~~ 略 ~~
            ]),
    ]
)

これにて、無事このライブラリの SwiftPM 対応が完了しました。

6. [番外編] リポジトリ内の git-lfs 管理のファイルを削除

実は上記の対応を行なった上で、いざ iOS アプリケーションの方から Xcode が管理する SwiftPM の機能を使ってライブラリを追加したところ、次のようなエラーが発生し、リポジトリの checkout ができませんでした。。

エラーの様子

Xcode 管理の SwiftPM が git-lfs に対応しておらず、リポジトリの checkout に失敗したようでした。

developer.apple.com

上記の Apple Developer Forum やそこからリンクされる Swift Forum を確認してみましたが、特に有効そうな解決策が見つからず、、
最終的にはリポジトリ内の git-lfs 管理のファイルを削除し、 git-lfs 管理を止めることにしました。

今回は git-lfs 管理していたものが過去に使用していたファイルであったり、自前で xcframework 化する前に使用していた opencv2.framework (カスタムビルドしていないのでバイナリのサイズが大きい)だったりして、たまたま削除可能なものだったので事なきを得ました。

もし git-lfs 管理をやめられない場合は今回 SwiftPM 対応させたライブラリ自体を xcframework にしてしまう等、別の方法を検討する必要があったかもしれません。

対応のポイント

最後に SwiftPM に対応させた際のポイントをまとめます。

  • OpenCV を xcframework にするために OpenCV が提供する Python スクリプトを利用した
    • 不要なモジュールを除き、カスタムビルドすることでバイナリサイズを減らせる副次的な効果も得られた
    • opencv2.framework を使用していた時は arm64 シミュレータのビルドをサポートしていなかったが、 xcframework 化で arm64 シミュレータでのビルドをサポートできるようになった
  • 生成した xcframework を binaryTarget にして Target の dependencies に追加することで OpenCV への依存を実現した
  • publicHeadersPath で公開するヘッダーファイルをシンボリックリンクで参照した
  • リポジトリ内に git-lfs 管理のファイルがあると Xcode でパッケージの checkout ができなかったので、 git-lfs 管理をやめた

おわりに

今回は SwiftPM への対応もさることながら、 OpenCV の xcframework 化に関してもこれまでにない知識が求められました。
しかしながら、 OpenCV のバイナリサイズを減らすことができたり、 arm64 シミュレータのビルドをサポートすることができたりと、 SwiftPM への対応以上の副産物もあり、対応できて良かったと感じています。
とりわけ arm64 シミュレータへの対応についてはサポートできて良かったと感じています。このライブラリが arm64 シミュレータに対応していないがために、このライブラリを使用する iOS アプリケーションも arm64 シミュレータでのビルドをサポートできず、いろいろなところで弊害が出ていました。
それらが解消されることで開発体験が向上し、より良いプロダクトの開発ができることを期待しています。

© Sansan, Inc.