背景
現在のiOSアプリ開発におけるパッケージマネージャのデファクトスタンダード(事実上の標準)としてCocoaPodsとCarthageがあります。Xcode 11からはSwift Pacakge ManagerがXcodeに統合されて利用できますが、ライブラリ側の対応が必要ということや、ベンダーライブラリなどを考えるとCocoaPodsは少なくとも当面は使われ続けるでしょう。
Carthageと違って、CocoaPodsは.xcodeproj
に依存せず独自のビルドシステムを持つことや、(デフォルトでは)Workspaceにライブラリのプロジェクトを自動的に統合するので、リンクに関する設定をやらなくて済むという特徴があります。
しかし、これは長所でもあり短所でもあります。
リンクの設定を自動で追加するために、プロジェクトのビルド設定がCocoaPodsが自動で追加する記述によって非常に複雑になり、ライブラリの追加削除による.xcodeproj
ファイルのコンフリクトの解決は非常に大変です。
また、ワークスペースにCocoaPodsが管理するプロジェクトとして導入されるので、クリーンビルドの際はライブラリがすべて再ビルドされるため、非常に時間がかかってしまう問題があります。
(そしてiOSアプリの開発では謎のエラーによってクリーンビルドをしなければならないことがそこそこの頻度で発生します。)
Carthageではビルド済みライブラリとして導入されるので、クリーンビルドの問題はおこりませんが、Firebaseなど公式にはCarthageの導入をサポートしていないライブラリもあります(実験的にCarhtageインストールも用意されています)。
この問題は、CocoaPodsをWorkspaceに自動統合しない設定で利用することにより解決できます。
デメリットは、Workspaceが自動統合されないため、導入したライブラリをリンクする設定は自分で書く必要があることです。
CocoaPodsが自動的に行っていたライブラリのリンク設定を1つずつ記述していくのはそれなりに大変ですが、CocoaPodsの自動設定は前述のように.xcodeproj
ファイルを複雑にしてコンフリクトの解決を不可能にするという問題もあります。
この問題はリンク設定をマニュアルで行う場合は.xcodeproj
ファイルではなく.xcconfig
に記述できるので、.xcodeproj
ファイルをシンプルに保つことができます。
また、Firebaseのように多くの依存関係を持つStatic FrameworkをCocoaPodsで導入する場合、複数のターゲットが存在する場合は、重複してリンクしてしまう問題を簡単には避けられません。
マニュアルですべてのライブラリをリンクしていくなら、確実に必要なターゲットを選んでリンクできるのでこの問題も解決できます。
(リンクに関する知識がそれなりに必要ですが、複数のターゲットにライブラリを導入するくらい複雑になってくると、どのみちリンクの知識は必要なので、CocoaPodsのバッドノウハウと格闘するよりは素直にリンクについて勉強する方が良いと思います。)
実践
Podfile
CocoaPodsのライブラリをWorkspaceに自動統合しないようにするには、Podfileに次の記述を追加します。
integrate_targets: false
例えば、下記のようになります。
platform :ios, '10.3'
install! 'cocoapods', generate_multiple_pod_projects: true, incremental_installation: true, integrate_targets: false
inhibit_all_warnings!
...
integrate_targets: false
とした場合、CocoaPodsは特定のプロジェクトと関係しなくなるので、自由にターゲットを構成してグループ化できます。
platform :ios, '10.3'
install! 'cocoapods', generate_multiple_pod_projects: true, incremental_installation: true, integrate_targets: false
inhibit_all_warnings!
use_modular_headers!
target 'Shared' do
use_frameworks!
pod 'FolioAPI', git: 'git@github.com:FOLIO-Mobile/Folio-Mobile-API-Swagger.git', tag: '1.58.1' , inhibit_warnings: false
current_target_definition.swift_version = '5.0'
pod 'Firebase/Messaging'
pod 'Firebase/InAppMessagingDisplay'
pod 'Firebase/Performance'
pod 'FirebaseAnalytics'
pod 'Fabric'
pod 'Crashlytics'
pod 'Marketo-iOS-SDK', podspec: 'Marketo-iOS-SDK.podspec'
pod 'DeallocationChecker'
current_target_definition.swift_version = '4.2'
end
target 'Shared-Static' do
pod 'Shimmer'
end
target 'Tests' do
use_frameworks!
pod 'Mockingjay'
pod 'iOSSnapshotTestCase'
current_target_definition.swift_version = '4.2'
end
pod 'SwiftGen'
pod 'SwiftLint'
pod 'LicensePlist'
上記の例では、Shared
ターゲットはFramework形式でビルドされます。DeallocationChecker
はSwift 4.2モードでビルドされます。
Shared-Static
ターゲットはStaticライブラリとしてビルドされます。
Tests
はFramework形式でビルドされます。iOSSnapshotTestCase
はSwift 4.2モードでビルドされます。
このように、各ライブラリごとに柔軟に設定を変更してビルドできるのもCocoaPodsとCocoaPodsをWorkspaceに統合しない利点です。
ビルド
integrate_targets: false
を設定した場合、ライブラリのプロジェクトはPods
ディレクトリに展開されるだけなので、自分でビルドする必要があります。
Pods
ディレクトリには導入した各ライブラリのプロジェクトやソースコードの他にPods.xcodeproj
が作られています。
Pods.xcodeproj
は導入したPodがすべて含まれているプロジェクトです。
Pods.xcodeproj
をビルドするとすべてのライブラリがビルドされます。
下記はPods.xcodeproj
をiOSとSimulatorの両方で特定のディレクトリ以下にビルドするスクリプトです。
#!/bin/bash
set -exo pipefail
PROJECT_ROOT=$(cd $(dirname $0); cd ..; pwd)
PODS_ROOT="$PROJECT_ROOT/Pods"
PODS_PROJECT="$PODS_ROOT/Pods.xcodeproj"
SYMROOT="$PODS_ROOT/Build"
(cd "$PROJECT_ROOT"; bundle exec pod repo update)
(cd "$PROJECT_ROOT"; COCOAPODS_DISABLE_STATS=true bundle exec pod install)
xcodebuild -project "$PODS_PROJECT" \
-sdk iphoneos -configuration Release -alltargets \
ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=NO SYMROOT="$SYMROOT" \
CLANG_ENABLE_MODULE_DEBUGGING=NO \
BITCODE_GENERATION_MODE=bitcode | bundle exec xcpretty
xcodebuild -project "$PODS_PROJECT" \
-sdk iphonesimulator -configuration Release -alltargets \
ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=NO SYMROOT="$SYMROOT" \
CLANG_ENABLE_MODULE_DEBUGGING=NO | bundle exec xcpretty
このスクリプトでビルドした成果物はPods/Build/Release-iphoneos
またはRelease-iphonesimulator
に出力されます。
このディレクトリは下記のようにEFFECTIVE_PLATFORM_NAME
変数を用いて自動的に切り替わるように変数化しておきます。
PODS_ROOT = $(SRCROOT)/Pods
PODS_CONFIGURATION_BUILD_DIR = $(PODS_ROOT)/Build/Release$(EFFECTIVE_PLATFORM_NAME)
リンクの設定
上記で変数化したPODS_CONFIGURATION_BUILD_DIR
とPODS_ROOT
を用いて各ライブラリを手作業でリンクします。
下記のxcconfig
ファイルは本体アプリケーションのビルド設定の抜粋です。
Firebaseはこのアプリケーションが動的リンクするフレームワークに静的リンクされるので、アプリケーションには直接リンクせず参照の設定があるだけになっています。
FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/Carthage/Build/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FolioAPI" "$(PODS_CONFIGURATION_BUILD_DIR)/Keys-framework" "$(PODS_ROOT)/Marketo-iOS-SDK" "$(PODS_ROOT)/Crashlytics/iOS" "$(PODS_ROOT)/Fabric/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseCore" "$(PODS_ROOT)/FirebaseAnalytics/Frameworks" "$(PODS_ROOT)/FirebaseABTesting/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging" "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessaging" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInstanceID" "$(PODS_ROOT)/FirebasePerformance/Frameworks" "$(PODS_ROOT)/FirebaseRemoteConfig/Frameworks"
HEADER_SEARCH_PATHS = "$(PODS_ROOT)/Headers/Public" "$(PODS_ROOT)/Headers/Public/Shimmer" "$(PODS_ROOT)/Firebase/CoreOnly/Sources" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging/FirebaseMessaging.framework/Headers" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay/FirebaseInAppMessagingDisplay.framework/Headers"
LIBRARY_SEARCH_PATHS = "$(PODS_CONFIGURATION_BUILD_DIR)/Shimmer"
OTHER_LDFLAGS = -ObjC -l"Shimmer" -framework "Marketo"
OTHER_SWIFT_FLAGS = -Xcc -fmodule-map-file="$(PODS_ROOT)/Headers/Public/Shimmer/Shimmer.modulemap"
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules"
下記のxcconfig
ファイルは本体アプリケーションに動的リンクされるEmbedded Frameworkです。このフレームワークにFirebaseを静的リンクしています。
Firebaseをアプリケーションにもリンクしてしまうと、シンボルの重複が発生します。
FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/Carthage/Build/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FolioAPI" "$(PODS_CONFIGURATION_BUILD_DIR)/Keys-framework" "$(PODS_ROOT)/Marketo-iOS-SDK" "$(PODS_ROOT)/Crashlytics/iOS" "$(PODS_ROOT)/Fabric/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseCore" "$(PODS_ROOT)/FirebaseAnalytics/Frameworks" "$(PODS_ROOT)/FirebaseABTesting/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging" "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessaging" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInstanceID" "$(PODS_ROOT)/FirebasePerformance/Frameworks" "$(PODS_ROOT)/FirebaseRemoteConfig/Frameworks" "$(PODS_ROOT)/GoogleAppMeasurement/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/GTMSessionFetcher" "$(PODS_CONFIGURATION_BUILD_DIR)/GoogleToolboxForMac" "$(PODS_CONFIGURATION_BUILD_DIR)/GoogleUtilities" "$(PODS_CONFIGURATION_BUILD_DIR)/Protobuf" "$(PODS_CONFIGURATION_BUILD_DIR)/nanopb"
HEADER_SEARCH_PATHS = "$(PODS_ROOT)/Firebase/CoreOnly/Sources"
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules"
OTHER_LDFLAGS = -ObjC -framework "FolioAPI" -framework "AdjustSdk" -framework "Crashlytics" -framework "Fabric" -framework "FirebaseCore" -framework "FirebaseCoreDiagnostics" -framework "FirebaseAnalytics" -framework "FirebaseInstanceID" -framework "FirebaseMessaging" -framework "FirebaseABTesting" -framework "FirebasePerformance" -framework "FirebaseRemoteConfig" -framework "GoogleAppMeasurement" -framework "GoogleUtilities" -framework "GTMSessionFetcher" -framework "GoogleToolboxForMac" -framework "GoogleUtilities" -framework "Protobuf" -framework "nanopb" -framework "AdSupport" -framework "AddressBook" -framework "CoreData" -framework "CoreTelephony" -framework "JavaScriptCore"
フレームワークのコピー
Dynamic Frameworkは実行時にリンクされるのでアプリケーション本体にバンドルされてなければなりません。Dynamic Frameworkがある場合は、下記のスクリプトをBuild Phaseで実行してアプリケーションバンドルにコピーします。
code_sign() {
# Use the current code_sign_identitiy
echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}"
echo "/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements $1"
/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements "$1"
}
if [ "$ACTION" = "install" ]; then
echo "Copy .bcsymbolmap files to .xcarchive"
find . -name '*.bcsymbolmap' -type f -exec mv {} "${CONFIGURATION_BUILD_DIR}" \;
fi
echo 'Copying frameworks'
if [ $SCRIPT_INPUT_FILE_LIST_COUNT -ne 0 ]; then
for i in $(seq 0 $(expr $SCRIPT_INPUT_FILE_LIST_COUNT - 1)); do
inputFileListVar="SCRIPT_INPUT_FILE_LIST_${i}"
inputFileList="${!inputFileListVar}"
cat "${inputFileList}" | while read inputFile; do
cp -rf "$inputFile" "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/"
for file in $(find ${inputFile} -type f -perm +111); do
# Skip non-dynamic libraries
if ! [[ "$(file "$file")" == *"dynamically linked shared library"* ]]; then
continue
fi
if [ "${CODE_SIGNING_REQUIRED}" == "YES" ]; then
code_sign "${file}"
fi
done
done
done
fi
リソースのコピー
CocoaPodsはリソースもサポートしているので、リソースをコピーする必要があるライブラリがあるかもしれません。
この例ではFirebaseがそうなので、Build Phaseで下記のようなスクリプトを実行して解決します。
#!/bin/bash
set -ex
cp -rf "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessagingDisplay/InAppMessagingDisplayResources.bundle" \
"${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/"
おわりに
このようにすることで、CocoaPodsで導入したライブラリであっても、Carthageのようにビルド済みライブラリとして取り扱えます。
プロジェクトにCocoaPods関連の設定が入らないので、.xcodeproj
をクリーンに保てることや、ビルド済みライブラリとして取り回せるのでクリーンビルドのさいもライブラリまですべてビルドされてしまうことはありません。
CocoaPodsが自動設定していたものを手作業で設定していく手間は増えますが、変更管理もできるようになるので、デメリットばかりでもありません。
副次的なメリットとして、ライブラリのキャッシュのコントロールがやりやすくなりました。
ビルド済みライブラリをアーカイブすればいいので、CIではライブラリのビルドする回数を大幅に削減できた上、現在は手元の環境もビルド済みライブラリをキャッシュからダウンロードするだけになり、手元でライブラリをビルドするということがほとんど必要なくなりました。
CIやビルド済みライブラリのキャッシュについては、また別の記事で解説します。
下記の記事でも少し触れています。
blog.kishikawakatsumi.com