はじめに
こんにちは、モバイルエンジニアの中村(@Kyomesuke)です。
私が担当している kintone のモバイルアプリ(iOS)では、現在脱レガシーを目指して幾つかの課題に取り組んでいます。 その一つとして、パッケージマネージャーを CocoaPods から Swift Package Manager に移行するリファクタリングに挑戦していたのですが、この移行に際して一つ課題がありました。
kintone モバイルやサイボウズ Office 新着通知アプリなど、サイボウズのモバイルアプリには依存している外部ライブラリのライセンスを一覧表示する画面があるのですが、これまでその機能をAcknowListというライブラリを使用して実現していました。
しかし AcknowList は CocoaPods にしか対応していないため、Swift Package Manager に対応するために代替を見つける必要がありました。
そこで、検索するとLicensePlistというライブラリがすぐに見つかったのですが、これでは代替できない理由もすぐに判明しました。
サイボウズ Office 新着通知というアプリは、Swift Package を用いたマルチモジュール構成になっているのですが、この構成では外部の Swift Package を解決する際にPackage.resolved
が出力されません。
LicensePlist はPackage.resolved
を基にライセンス情報を抽出する仕組みのため要件を満たしません。
したがって、Package.resolved
に依らないライセンス情報抽出の仕組みを作る必要があることがわかりました。
紆余曲折あって、Package.resolved
に依らず Swift Package ライブラリのライセンスを抽出して一覧表示する仕組みを作ることができたので、LicenseListというライブラリとして公開しました!
今回は LicenseList の仕組みと利用方法について解説していきたいと思います。
LicenseList の仕組み
ライセンス情報を抽出する流れ
まず、Package.resolved
以外でライセンス情報を抽出できる方法を見つける必要がありましたが、代わりにいいものを見つけました。
Xcode 上でResolve Packages
しても、xcodebuild
コマンドでビルドしてもDerivedData
の中にSourcePackages
というディレクトリが生成されます。
そして、その中にworkspace-state.json
というファイルが出力されるのですが、このファイルからはPackage.resolved
とだいたい同様の情報が取得できます(具体的には、パッケージの名前やリポジトリの URL、バージョン情報が取得できます)。
ライブラリ名の一覧さえ取得できてしまえば、SourcePackages/checkouts/
の中にはライブラリのソースがリポジトリから丸ごと取得できているため、その中の LICENSE ファイルを見つけ出して解析してあげることでライセンス情報は抽出できます。
そして、抽出したライセンス情報を plist 形式で保存し、アプリから読み込めるようにしておき、アプリの実行時に Swift の構造体に流し込み、見た目を整形して表示します。
LicenseList に含まれる各 product の役割
LicenseList の Package には spp (SourcePackagesParser)という executable と LicenseList という library の2つの product があります。
- spp はアプリの Build Phases にて前述の
workspace-state.json
を解析してSourcePackages/checkouts
内の全てのライブラリのライセンスを抽出してlicense-list.plist
というファイルに出力する役割を担います。 - LicenseList (library)はアプリの実行時に
license-list.plist
を読み込み、ライセンス情報を構造体に流し込んで SwiftUI でレイアウトして表示する役割を担います。
LicenseList の利用方法
動作条件
- iOS 13 以上
- Xcode 13.0 以上
- パッケージマネージャーとして Swift Package Manager だけを利用していること
- SwiftUI でビュー構成されていても Storyboard や UIKit のコードベースでビュー構成されていても OK
事前準備
- Swift Package Manager で LicenseList を導入します。
- Xcode 上で
File > Add Packages...
https://github.com/cybozu/LicenseList.git
を検索- パッケージを追加(⚠️この時、spp はターゲットに紐付けせず、LicenseList だけターゲットに紐付けます)
- Xcode 上で
プロジェクトの
SRCROOT
にlicense-list.plist
を追加します。$ cd [source root path] $ echo '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict></dict></plist>' > license-list.plist
Add Files to ...
でlicense-list.plist
をアプリのバンドルに紐づけます(⚠️このとき、Copy items if needed のチェックを外すこと!)。Build Phases に Run Script を追加して、spp を実行するスクリプトを登録します。
SOURCE_PACKAGES_PATH=`echo ${BUILD_DIR%Build/*}SourcePackages` # Build SourcePackagesParser xcrun --sdk macosx swift build -c release \ --package-path ${SOURCE_PACKAGES_PATH}/checkouts/LicenseList \ --product spp # Run SourcePackagesParser ${SOURCE_PACKAGES_PATH}/checkouts/LicenseList/.build/release/spp ${SRCROOT} ${SOURCE_PACKAGES_PATH}
SwiftUI での使い方の例
import LicenseList struct ContentView: View { let fileURL = Bundle.main.url(forResource: "license-list", withExtension: "plist")! var body: some View { NavigationView { NavigationLink("License") { LicenseListView(fileURL: fileURL) .navigationTitle("LICENSE") .navigationBarTitleDisplayMode(.inline) } } } }
UIKit での使い方の例
import LicenseList // UINavigationControllerが有効なViewControllerの中で let fileURL = Bundle.main.url(forResource: "license-list", withExtension: "plist")! let vc = LicenseListViewController(fileURL: fileURL) vc.title = "LICENSE" navigationController?.pushViewController(vc, animated: true)
LicenseList の開発中躓いたポイント
- ライブラリによって LICENSE ファイルの拡張子が揃っていない
.md
や.txt
や拡張子がついていなかったり様々なので、拡張子を無視してファイル名だけで判別しました。
- ライセンスの記述に規格がないため、種類を判別する方法がライセンス本文の部分一致を見るしかない
- Build Phases の Run Script で spp を実行するタイミング
- 成果物である
license-list.plist
をバンドルに含めるため、Copy Bundle Resources より前にスクリプトを実行する必要がありました。
- 成果物である
- iOS 13, 14, 15 で SwiftUI の挙動に差がある
- iOS 13 には
ListStyle.insetGrouped
がなかったり、iOS 15 未満ではAttributedString
が利用できなかったりと、SwiftUI は OS のバージョンで使える API に差が激しいため、バージョンによる分岐を賢く書く必要がありました。こちらは別途 Zenn で記事にしています(SwiftUI: チェーンメソッドの途中で、iOS バージョンにより処理を分けたいときどうする?)。 ScrollView
の中に動的に更新される要素を入れている場合、iOS 14 以上では空要素から要素が後から入った際に幅が再計算されますが、iOS 13 では幅が0のままで内容物が表示されない不具合がありました。これは要素に対して.frame(maxWidth: .infinity)
を入れることで解決できました。
- iOS 13 には
- UIKit の
UINavigationController
と SwiftUI のNavigationView
は相入れない- UINavigationController の
pushViewController(_:animated:)
を用いて SwiftUI の View に遷移する場合(UIHostingController(rootView:)
を介する)、その遷移先の View 内でNavigationView
やNavigationLink
を用いるとナビゲーションが破綻することがあります(遷移で進むのは良いが元の画面に戻れなくなる、iOS 13 だと画面の回転で遷移先が多重化するなど)。 - SwiftUI の世界でも UINavigationController を扱って画面遷移をすることで、
NavigationView
に起因する不具合を排除することができました(関連記事:SwiftUI の View から UIKit の navigationController にアクセスする方法)。
- UINavigationController の
最後に
今回は、アプリが依存している Swift Package ライブラリのライセンス一覧を表示するライブラリを作った件について、その仕組みと使用方法を紹介しました。
ライブラリの開発を通して、Swift Package Manager の仕組みや仕様、SwiftUI と UIKit の連携方法について学ぶことができました。
Swift Package を用いたマルチモジュール構造を実用していて、ライブラリのライセンスを表示する要件があるという方は是非 #LicenseList
を活用してみてください。
また、LicenseList にはテストターゲットでしか利用していないライブラリも一覧の対象になってしまうなど課題がありますので、OSS の強みを活かしてコミットしていただけると幸いです。