アプリが依存している Swift Package ライブラリのライセンスを一覧表示するためにライブラリを作りました

はじめに

こんにちは、モバイルエンジニアの中村(@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 の仕組みと利用方法について解説していきたいと思います。

github.com

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

事前準備

  1. Swift Package Manager で LicenseList を導入します。
    1. Xcode 上でFile > Add Packages...
    2. https://github.com/cybozu/LicenseList.gitを検索
    3. パッケージを追加(⚠️この時、spp はターゲットに紐付けせず、LicenseList だけターゲットに紐付けます)
  2. プロジェクトのSRCROOTlicense-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
    
  3. Add Files to ...license-list.plistをアプリのバンドルに紐づけます(⚠️このとき、Copy items if needed のチェックを外すこと!)。

  4. 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)を入れることで解決できました。
  • UIKit のUINavigationControllerと SwiftUI のNavigationViewは相入れない
    • UINavigationController のpushViewController(_:animated:)を用いて SwiftUI の View に遷移する場合(UIHostingController(rootView:)を介する)、その遷移先の View 内でNavigationViewNavigationLinkを用いるとナビゲーションが破綻することがあります(遷移で進むのは良いが元の画面に戻れなくなる、iOS 13 だと画面の回転で遷移先が多重化するなど)。
    • SwiftUI の世界でも UINavigationController を扱って画面遷移をすることで、NavigationViewに起因する不具合を排除することができました(関連記事:SwiftUI の View から UIKit の navigationController にアクセスする方法)。

最後に

今回は、アプリが依存している Swift Package ライブラリのライセンス一覧を表示するライブラリを作った件について、その仕組みと使用方法を紹介しました。 ライブラリの開発を通して、Swift Package Manager の仕組みや仕様、SwiftUI と UIKit の連携方法について学ぶことができました。 Swift Package を用いたマルチモジュール構造を実用していて、ライブラリのライセンスを表示する要件があるという方は是非 #LicenseList を活用してみてください。 また、LicenseList にはテストターゲットでしか利用していないライブラリも一覧の対象になってしまうなど課題がありますので、OSS の強みを活かしてコミットしていただけると幸いです。