24/7 twenty-four seven

iOS/OS X application programing topics.

CocoaPodsをWorkspaceに自動統合せずに利用する

背景

現在の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_DIRPODS_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