Posted at

XcodeGen で project.pbxproj のコンフリクトとおさらばする

More than 1 year has passed since last update.


概要

XcodeGen を使うと pbxproj 自体をなくすことができます


背景

iOS アプリなど Xcode のプロジェクトを git を使って複数人で開発しているとプロジェクトファイルがコンフリクトすることがよくあります。

私はここ最近は特に swift に書き直したり Embedded Framework に移行したりとプロジェクトファイルが大規模に書き換わることが多かったので、業務時間をリベース時のコンフリクト解決に費やす日々が続いていました。

コンフリクトが起きやすい原因として、プロジェクトファイルはコードや画像など必要なファイルの参照をすべてリストとして持っていることと、それぞれに内部で使う UUID を生成していて同じファイルでも異なる UUID が生成されることなどがあります。

解決法のひとつ、xUnique は UUID を相対ファイルパスのハッシュに置き換えるツールで、これによって同じ場所のファイルはいつでも同じハッシュになって不要なコンフリクトを防ぎます。

しかしこれをチームで使うために python で書かれた xUnique を全員のマシンにインストールしたり、git のフックや Build Phase の Run Script にこれを走らせる処理を入れたりする必要があるので少しイケてない感じがしました。


XcodeGen

そこで今回紹介する XcodeGen は、以下のような yaml からプロジェクトファイルを生成するツールです。

name: My Project

targets:
MyApp:
type: application
platform: iOS
sources: MyApp
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.example.myapp
dependencies:
- target: MyFramework
MyFramework:
type: framework
platform: iOS
sources: MyFramework

これをプロジェクトのディレクトリに配置し、xcodegen コマンドを実行するとプロジェクトファイルが生成されます。

このようにして、yaml を置いて project.pbxproj を削除することで、根本的に問題を解決できます。spec 自体のコンフリクトが起きる可能性がありますが、spec は可読性が高いため解決は容易です、

XcodeGen は swift で書かれており、Xcode がインストールされていればよいので開発環境のセットアップも簡単です。

ワークフローとしては、pod install していた所が xcodegen && pod install になるくらいの感じです。実行に数十秒くらいかかりますがコンフリクト地獄に何十分もかけるのとは比べるべくもないと思いました。


Tips


CocoaPods, Carthage

両方利用しているプロジェクトでも問題なく XcodeGen を使うことができます。

CocoaPods の場合は、pod install する前に xcodegen を実行すればよく、Carthage の場合はさらに簡単で spec に記述するだけです。


spec の書き方

現在、プロジェクトから spec を生成する機能が XcodeGen には無いので地道に spec を書く必要があります。基本的には簡単なのですが、細かいビルド設定を書くときにコツがあります。

指定したいビルド設定を Xcode 上で指定してから、エディタで project.xcodeproj から buildSettings を検索すると、以下のような項目が見つかります。

XCBC37128501 /* Debug */ = {

isa = XCBuildConfiguration;
baseConfigurationReference = FR6334256101 /* config.xcconfig */;
buildSettings = {
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
INFOPLIST_FILE = TestProject/TestProject-Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.test.TestProject;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};

settings のキーと値は project.xcodeproj の buildSettings 内のものと同じなので、上の指定は次のような spec で表すことができます。

name: My Project

targets:
MyApp:
type: application
platform: iOS
sources: MyApp
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.myapp
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
INFOPLIST_FILE: TestProject/TestProject-Info.plist
dependencies:
- target: MyFramework
MyFramework:
type: framework
platform: iOS
sources: MyFramework

ASSETCATALOG_COMPILER_APPICON_NAME などは自動生成されるので基本的には指定する必要はありませんが、プロジェクトを作った Xcode のバージョンが古かったり、複数の Target をもつプロジェクトなどでは必要になります。


feather の spec

拙作のアプリ feather for Twitter で使っている spec ファイルです。

まだ XcodeGen を使ったバージョンでのリリースはありませんが、現状以下のようになっています。無料版と有料版でコードを共有しているので settingGroups を使って設定を共通化しています。dependencies に関しては yaml の alias を使って共通化しています。

既に秘伝のタレ化している雰囲気がありますが、コメントが書けない、diff が分かりづらい pbxproj よりはだいぶマシだと思います。

name: feather

options:
bundleIdPrefix: com.covelline
settingGroups:
featherBaseSettings:
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME: LaunchImage
SWIFT_OBJC_BRIDGING_HEADER: feather/feather-Bridging-Header.h
SWIFT_OBJC_INTERFACE_HEADER_NAME: feather-Swift.h
SWIFT_WHOLE_MODULE_OPTIMIZATION: YES
GCC_PREFIX_HEADER: feather/feather-prefix.pch
DEVELOPMENT_TEAM: K45ES832VB
TARGETED_DEVICE_FAMILY: 1
IPHONEOS_DEPLOYMENT_TARGET: 9.3
frameworkBaseSettings:
SKIP_INSTALL: YES
DYLIB_INSTALL_NAME_BASE: "@rpath"
IPHONEOS_DEPLOYMENT_TARGET: 9.3
GCC_PRECOMPILE_PREFIX_HEADER: YES
SWIFT_WHOLE_MODULE_OPTIMIZATION: YES
GCC_PREFIX_HEADER: $(TARGET_NAME)/$(TARGET_NAME)-prefix.pch
MODULEMAP_FILE: $(TARGET_NAME)/$(TARGET_NAME).modulemap
frameworkTestBaseSettings:
SWIFT_WHOLE_MODULE_OPTIMIZATION: YES
SWIFT_OBJC_BRIDGING_HEADER: $(TARGET_NAME)/$(TARGET_NAME)-Bridging-Header.h
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/feather.app/feather"
featherDependencies: &featherDependencies
- target: Prelude
- target: PreludeUIKit
- target: Library
- target: Shared
- target: TwitterAPI
- target: TwitterService
- carthage: IDZSwiftCommonCrypto
- carthage: MBProgressHUD
- carthage: NowPlayingFormatter
- carthage: OnePasswordExtension
- carthage: PINCache
- carthage: Popover
- carthage: Realm
- carthage: RealmSwift
- carthage: STPopup
- carthage: SwiftyAppearance
- carthage: TextAttributes
- carthage: Unbox
- carthage: XCGLogger
- carthage: HMSegmentedControl
- carthage: Bolts
- carthage: DZNEmptyDataSet
- carthage: SDWebImage
targets:
feather:
type: application
platform: iOS
sources:
- feather
- Vendor
settings:
groups:
- featherBaseSettings
base:
PRODUCT_BUNDLE_IDENTIFIER: com.covelline.feather
INFOPLIST_FILE: feather/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
configs:
Debug:
CODE_SIGN_IDENTITY: "iPhone Developer"
CODE_SIGN_IDENTITY[sdk=iphoneos*]: "iPhone Developer"
CODE_SIGN_ENTITLEMENTS: feather/development.entitlements
PROVISIONING_PROFILE_SPECIFIER: "match Development com.covelline.feather"
Release:
CODE_SIGN_IDENTITY: "iPhone Distribution"
CODE_SIGN_IDENTITY[sdk=iphoneos*]: "iPhone Distribution"
CODE_SIGN_ENTITLEMENTS: feather/production.entitlements
PROVISIONING_PROFILE_SPECIFIER: "match AdHoc com.covelline.feather"
dependencies:
*featherDependencies
scheme:
testTargets:
- featherTests
- "feather freeTests"
- PreludeTests
- LibraryTests
- TwitterAPITests
- TwitterServiceTests
"feather free":
type: application
platform: iOS
sources:
- feather
- "feather free"
- Vendor
settings:
groups:
- featherBaseSettings
base:
PRODUCT_BUNDLE_IDENTIFIER: com.covelline.feather-free
INFOPLIST_FILE: "feather free/Info.plist"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon-free
GCC_PREPROCESSOR_DEFINITIONS:
- "$(inherited)"
- FEATHER_FREE=1
OTHER_SWIFT_FLAGS: -DFEATHER_FREE
configs:
Debug:
CODE_SIGN_IDENTITY: "iPhone Developer"
CODE_SIGN_IDENTITY[sdk=iphoneos*]: "iPhone Developer"
CODE_SIGN_ENTITLEMENTS: feather/development.entitlements
PROVISIONING_PROFILE_SPECIFIER: "match Development com.covelline.feather-free"
Release:
CODE_SIGN_IDENTITY: "iPhone Distribution"
CODE_SIGN_IDENTITY[sdk=iphoneos*]: "iPhone Distribution"
CODE_SIGN_ENTITLEMENTS: feather/production.entitlements
PROVISIONING_PROFILE_SPECIFIER: "match AdHoc com.covelline.feather-free"
dependencies:
*featherDependencies
scheme:
testTargets:
- "feather freeTests"
featherTests:
type: bundle.unit-test
platform: iOS
sources: featherTests
settings:
GCC_PREFIX_HEADER: $(TARGET_NAME)/$(TARGET_NAME)-prefix.pch
SWIFT_OBJC_BRIDGING_HEADER: $(TARGET_NAME)/$(TARGET_NAME)-Bridging-Header.h
SWIFT_WHOLE_MODULE_OPTIMIZATION: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/feather.app/feather"
HEADER_SEARCH_PATHS:
- "$(inherited)"
- "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources" # feather-Swift.h を参照するために必要
FRAMEWORK_SEARCH_PATHS:
- "$(inherited)"
- "$PODS_CONFIGURATION_BUILD_DIR/STTwitter" # テストに必要
dependencies:
- target: TestHelper
- target: feather
"feather freeTests":
type: bundle.unit-test
platform: iOS
sources: "feather freeTests"
settings:
SWIFT_OBJC_BRIDGING_HEADER: $(TARGET_NAME)/$(TARGET_NAME)-Bridging-Header.h
SWIFT_WHOLE_MODULE_OPTIMIZATION: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/feather free.app/feather free"
dependencies:
- target: TestHelper
- target: "feather free"
Prelude:
type: framework
platform: iOS
sources: Prelude
settings:
groups:
- frameworkBaseSettings
scheme:
testTargets:
- PreludeTests
PreludeTests:
type: bundle.unit-test
platform: iOS
sources: PreludeTests
settings:
groups:
- frameworkTestBaseSettings
dependencies:
- target: feather
PreludeUIKit:
type: framework
platform: iOS
sources: PreludeUIKit
settings:
groups:
- frameworkBaseSettings
scheme:
testTargets:
- PreludeUIKitTests
PreludeUIKitTests:
type: bundle.unit-test
platform: iOS
sources: PreludeUIKitTests
settings:
groups:
- frameworkTestBaseSettings
dependencies:
- target: feather
TestHelper:
type: framework
platform: iOS
sources: TestHelper
settings:
groups:
- frameworkBaseSettings
Library:
type: framework
platform: iOS
sources: Library
settings:
groups:
- frameworkBaseSettings
scheme:
testTargets:
- LibraryTests
dependencies:
- target: Prelude
LibraryTests:
type: bundle.unit-test
platform: iOS
sources: LibraryTests
settings:
groups:
- frameworkTestBaseSettings
dependencies:
- target: Library
- target: TestHelper
- target: feather
Shared:
type: framework
platform: iOS
sources: Shared
settings:
groups:
- frameworkBaseSettings
TwitterAPI:
type: framework
platform: iOS
sources: TwitterAPI
settings:
groups:
- frameworkBaseSettings
dependencies:
- target: Prelude
scheme:
testTargets:
- TwitterAPITests
TwitterAPITests:
type: bundle.unit-test
platform: iOS
sources: TwitterAPITests
settings:
groups:
- frameworkTestBaseSettings
dependencies:
- target: TwitterAPI
- target: feather
TwitterService:
type: framework
platform: iOS
sources: TwitterService
settings:
groups:
- frameworkBaseSettings
dependencies:
- target: TwitterAPI
- carthage: PINCache
scheme:
testTargets:
- TwitterServiceTests
TwitterServiceTests:
type: bundle.unit-test
platform: iOS
sources: TwitterServiceTests
settings:
groups:
- frameworkTestBaseSettings
dependencies:
- target: TwitterService
- target: feather


CI (Fastlane) での利用

Fastfile

## xcodegen を取得して実行

sh("mint run yonaskolb/xcodegen@1.3.0 \"xcodegen --spec ../project.yml --project ../\" ")

## 毎回 pod install を行う (xcodegen でプロジェクトが生成されるので)
cocoapods(repo_update: true)

XcodeGen の作者の @yonaskolb 氏が作っている Mint というツールで、XcodeGen のバージョンを指定して取得・実行が一発でできます。CI サーバーには Mint だけ入れておけばよいことになります。

XcodeGen は活発にアップデートされているので、このようにして spec の yaml ファイルと XcodeGen 自体のバージョンを紐付けておけば Pull-Request ごとにハチャメチャになるのを防ぐことが出来ます。


pbxproj の構造については .xcodeproj/project.pbxproj を解読する がとても参考になりました。