Objective-C
Xcode
Swift
XcodeGen

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 を解読する がとても参考になりました。