Help us understand the problem. What is going on with this article?

Xcodeプロジェクトの生成ツール「XcodeGen」のセットアップ&操作方法

はじめに

もともと気になっており、先日の勉強会でXcodeGenのLTを聞いて、触ってみようと思いました。

触ってみた結果、業務でも導入したいと考えています。
「プロジェクトファイルがコンフリクトしない」という特徴は、複数人による開発では非常に大きなメリットになるためです。

「XcodeGen」とは?

Xcodeプロジェクトファイル( .xcodeproj )を定義ファイル( project.yml )から生成する、Swift製のコマンドラインツールです。

YAMLでプロジェクトの設定を書くことで、プロジェクトファイルが自動生成されます。
そのため、プロジェクトファイルを自分たちで編集する必要がなくなります。

xcodegen.png

XcodeGenが行うことは「プロジェクトファイルを自動生成する」に尽きる ので、それがわかるとメリットやこの先の説明が理解しやすいです。

用語解説

本記事では用語を以下のように定義します。

  • プロジェクトファイル
    .xcodeproj ファイルを指す
  • ワークスペース
    .xcworkspace ファイルを指す
    CocoaPodsの使用時に必要となる
  • 定義ファイル
    project.yml を指す
    公式では「project spec」と呼んでいる

XcodeGenのメリット

XcodeGenの導入によるメリットを紹介します。

プロジェクトファイルがコンフリクトしなくなる

一番のメリットといっても過言ではないと思います。
複数人によるiOSアプリ開発で誰もが経験する「ファイル追加によるプロジェクトファイルのコンフリクト」が発生しなくなります。

理由は、定義ファイルの source: でフォルダを指定すると、フォルダ内のファイルを自動的にプロジェクトへ追加するためです。

project.yml
targets:
  Foo:
    sources:
      - Foo # 「Foo」フォルダ内のファイルが自動的に追加される

つまり、ファイルを追加・リネーム・削除するのみでは定義ファイルを変更する必要がありません。

プロジェクトツリーが自動ソートされる

実際のファイルパスから自動的にプロジェクトツリーを生成します。
その際、ファイル名順に自動でソートされます。

プロジェクトの設定を簡単に変更できる

ターゲットやCarthageで管理しているライブラリを簡単に追加できます。
定義ファイルはテキストなので、その他の設定も簡単に変更できます。

XcodeGenのデメリット

学習コストがかかる

ライブラリ全般にいえるデメリットですが、XcodeGenの概要や使い方のキャッチアップにコストが掛かります。
本記事で多少コストが下がれば幸いです。

プロジェクトファイルの生成に手間がかかる

メリットで「ファイル変更時に定義ファイルの修正不要」と説明しましたが、プロジェクトファイルを生成し直す必要はあります。
CocoaPodsを使っていると、さらにプロジェクトファイルからワークスペースを生成しなければいけません。

私はMakefileを作って手間を削減しています。
https://qiita.com/uhooi/items/16a870eaae24b46103fb#プロジェクトファイルの生成

XcodeGenを導入すべきケース

XcodeGenのメリットやデメリットがわかったところで、どういうケースでXcodeGenを導入すべきなのか紹介します。
あくまで私の意見なので、参考程度に捉えてください。

プロジェクトファイルのコンフリクトに悩んでいる

大人数による開発などでプロジェクトファイルのコンフリクトに悩んでいる場合、導入を検討する価値があると思います。

新規開発時

定義ファイルの作成経験があればコピペして使い回せるため、新規開発時のプロジェクト作成時間を短縮できます。
また、新規開発時はファイルの追加を頻繁に行うため、コンフリクトを防げます。

環境

  • OS:macOS Mojave 10.14.6
  • Swift:5.1.3
  • Xcode:11.3.1 (11C504)
  • XcodeGen:2.11.0

セットアップ

XcodeGenのインストール

Mintからインストールします。

Mintfile
+ yonaskolb/xcodegen@2.11.0
$ mint bootstrap

手動でインストールするには、公式ドキュメントをご参照ください。
https://github.com/yonaskolb/XcodeGen#installing

XcodeGenのセットアップ自体は、インストールのみで完了します。

定義ファイルの作成

定義ファイルをプロジェクトのルートフォルダに新規作成します。

本記事では「Foo」をプロジェクト名およびプロジェクトのルートフォルダとします。

$ cd Foo
$ touch project.yml

定義ファイルの説明

ここからは定義ファイルに最低限必要な記述をひとつずつ説明します。

設定できる全ての内容までは説明し切れないので、詳細は公式ドキュメントをご参照ください。
https://github.com/yonaskolb/XcodeGen/blob/master/Docs/ProjectSpec.md

name

プロジェクト名を指定します。

project.yml
name: Foo

指定した文字列がプロジェクトファイル名になります。
スクリーンショット 2020-01-29 12.15.50.png

options

プロジェクトのオプションを設定します。

bundleIdPrefix

Product bundle identifier(以下「バンドルID」と呼ぶ)のプリフィクスを指定します。

本記事では「com.theuhooi」をプリフィクスとします。

project.yml
options:
  bundleIdPrefix: com.theuhooi

プリフィクスを指定すると、各ターゲットのバンドルIDが自動で「{プリフィクス}.{ターゲット名}」に設定されます。
そのため、ターゲットごとに PRODUCT_BUNDLE_IDENTIFIER を指定する必要がなくなります。

project.yml
options:
  bundleIdPrefix: com.theuhooi

targets:
  Foo:
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.theuhooi.Foo # 不要

  FooTests:
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.theuhooi.FooTests # 不要

  FooUITests:
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.theuhooi.FooUITests # 不要

マルチプラットフォームなどでターゲット名とバンドルIDを合わせない場合は、明示的に指定してください。

developmentLanguage

LocalizationsのDevelopment Languageを指定します。

デフォルトは英語で、日本語にする場合は ja を指定します。

project.yml
options:
  developmentLanguage: ja

project.pbxproj 上のキーは developmentRegion なので、指定を間違えないようご注意ください。

deploymentTarget

デプロイメントターゲットをプラットフォームごとに指定します。

iOSアプリ開発の場合、「iOS」のみ指定すればOKです。

project.yml
options:
  deploymentTarget:
    iOS: 13.0

xcodeVersion

Xcodeのバージョンを指定します。

project.yml
options:
  xcodeVersion: "11.3.1" # 変わらない?

指定してもプロジェクトファイルに反映されなかったので、何か条件があるか、私が意味を理解していないかのどちらかです。

コメント で教えていただいたのですが、ダブルクォーテーションで括らないと不正な値として扱われ、デフォルト値が使われるとのことです。

findCarthageFrameworks

Carthageで管理しているフレームワークのインストール時、依存しているフレームワークも自動でインストールするかどうかの設定です。

project.yml
options:
  findCarthageFrameworks: true

フレームワークのつくりや、私の使い方の問題かもしれませんが、依存しているのに自動でインストールされなかったり、余計なフレームワークまでインストール(テストターゲットのみで使うのに製品ターゲットにもインストール)されたりしたため、私は指定していません。

carthageExecutablePath

Carthageの実行パスを指定します。

CarthageをMintで管理している場合、以下のように指定する必要があります。

project.yml
options:
  carthageExecutablePath: mint run Carthage/Carthage carthage

デフォルトが carthage なので、CarthageをHomebrewでインストールした場合は不要です。

settings

全ターゲット共通で使うBuild Settingsを設定します。
個別に設定する場合はターゲット内の settings に記述します。

私はバージョンとビルド番号、Development Team、Debug Information Formatを指定しています。
Debug Information FormatはBuild Configurations(以下「ビルド構成」と呼ぶ)が Debug (以下「デバッグビルド」と呼ぶ)時のみ指定しています。

project.yml
settings:
  base:
    MARKETING_VERSION: 1.0.0
    CURRENT_PROJECT_VERSION: 1
    DEVELOPMENT_TEAM: XXXXXXXXXX
  config:
    debug:
      DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym"

MARKETING_VERSIONCURRENT_PROJECT_VERSION をそれぞれバージョンとビルド番号に紐付けるには、 Info.plist の修正が必要です。
(TARGETS > {製品ターゲット} > General の Version , Build を手動で変更すると、自動で修正されます)

Info.plist
        <key>CFBundleShortVersionString</key>
-       <string>1.0</string>
+       <string>$(MARKETING_VERSION)</string>
        <key>CFBundleVersion</key>
-       <string>1</string>
+       <string>$(CURRENT_PROJECT_VERSION)</string>

バージョンとビルド番号を全ターゲットで共通にしているのは、製品とApp Extensionのバージョンを合わせないと警告が発生するためです。

packages

Swift Package Manager(以下「SwiftPM」と呼ぶ)で管理するライブラリを指定します。

SwiftPMはCocoaPodsの Podfile のようにライブラリのバージョンを管理するファイルがなく、本来はXcode上で操作してプロジェクトファイルで管理します。
XcodeGenを使うとGUIで管理しなくて済みます。

構造は以下の通りです。

project.yml
packages:
  {パッケージ名}:
    url: {GitHubなどのURL}
    version: {バージョン}

バージョンを指定する方法は他にもありますが、他の開発者とバージョンを揃えたいので version でバージョンを指定しています。

私は以下のように指定しています。

project.yml
packages:
  Rswift:
    url: https://github.com/mac-cain13/R.swift.Library
    version: 5.3.0
  Gedatsu:
    url: https://github.com/bannzai/Gedatsu
    version: 1.2.0
  SwiftPrettyPrint:
    url: https://github.com/YusukeHosonuma/SwiftPrettyPrint
    version: 1.0.0

targets

各ターゲットの設定です。
構造は以下の通りです。

project.yml
targets:
  {ターゲット名}:
   {設定…}

type

ターゲットのタイプを指定します。

project.yml
targets:
  Foo:
    type: application

  FooTests:
    type: bundle.unit-test

  FooUITests:
    type: bundle.ui-testing

よく指定するタイプの一覧です。

type 説明
application 製品ターゲット
bundle.unit-test 単体テストターゲット
bundle.ui-testing UIテストターゲット

platform

対象とするプラットフォームを指定します。

project.yml
targets:
  Foo:
    platform: iOS

配列で指定するとターゲット名の末尾にプラットフォーム名が付くため、対象プラットフォームが1つのみで bundleIdPrefix を使う場合は配列にしない方がいいです。

project.yml
targets:
  Foo:
    platform: [iOS] # ターゲット名が「Foo-iOS」となる

sources

ターゲットに含むファイルまたはフォルダを指定します。

基本的にはターゲットと同名のフォルダのみ指定すればOKです。

project.yml
targets:
  Foo:
    sources:
      - Foo

  FooTests:
    sources:
      - FooTests

  FooUITests:
    sources:
      - FooUITests

注意点として、 すでに存在するファイルを元にプロジェクトツリーを生成する ため、プロジェクトファイルの生成時に存在しないファイル(R.swiftやMockoloなどで自動生成するファイル)が追加されません。

クックパッドさんのブログの通りに指定することでこの問題を回避できます。
https://techlife.cookpad.com/entry/2019/04/26/110000

project.yml
targets:
  Foo:
    sources:
      - Foo
      # Mockoloで生成するファイル
      - path: "Foo/Generated/MockResults.swift"
        optional: true
        type: file
      # R.swiftで生成するファイル
      - path: "Foo/Generated/R.generated.swift"
        optional: true
        type: file

settings

ターゲットのBuild Settingsを設定します。

私は Info.plist のパス、 {プロジェクト名}.entitlements のパス、開発言語、Other Linker Flags、Other Swift Flagsを指定しています。

project.yml
targets:
  Foo:
    settings:
      base:
        INFOPLIST_FILE: Foo/Info.plist
        CODE_SIGN_ENTITLEMENTS: Foo/Foo.entitlements
        DEVELOPMENT_LANGUAGE: jp
        OTHER_LDFLAGS: $(inherited) $(OTHER_LDFLAGS) -ObjC
      configs:
        debug:
          OTHER_SWIFT_FLAGS: $(inherited) -Xfrontend -warn-long-expression-type-checking=500 -Xfrontend -warn-long-function-bodies=500

  FooTests:
    settings:
      base:
        INFOPLIST_FILE: FooTests/Info.plist

  FooUITests:
    settings:
      base:
        INFOPLIST_FILE: FooUITests/Info.plist

XcodeGenでは Info.plist の生成も行えますが、コンフリクトしにくいので私は既存のファイルを読み込むのみに留めています。

CODE_SIGN_ENTITLEMENTS について、NFCタグの読み取りなど、特別に許可している場合のみ設定が必要です。

DEVELOPMENT_LANGUAGE について、 jp を指定しないとiOSの使用言語が日本語でも UIActivityViewController が日本語表記にならなかったので指定しています。

OTHER_LDFLAGS について、Firebase Analyticsで必要です。

OTHER_SWIFT_FLAGS について、型推論と関数のビルドに時間がかかる場合に警告を発生するようにしています。

dependencies

各ターゲットが依存しているフレームワークなどを指定します。
Carthageで管理しているフレームワークを指定する場合、 - carthage: {フレームワーク名} と記述するのみで済むのが便利です。
同様にSwiftPMで管理しているライブラリは - package: {ライブラリ名} と記述します。

私の場合、製品ターゲットはCarthageやSwiftPMで管理しているフレームワーク、テストターゲットは製品ターゲットのみを指定しています。

project.yml
targets:
  Foo:
    dependencies:
      - package: Rswift
      - package: Gedatsu
      - package: SwiftPrettyPrint
      - carthage: Alamofire

  FooTests:
    dependencies:
      - target: Foo

  FooUITests:
    dependencies:
      - target: Foo

preBuildScripts

Build Phasesの最初に実行するスクリプトを指定します。

私の場合、製品ターゲットでR.swiftとMockoloのファイル生成スクリプトを記述しています。

project.yml
targets:
  Foo:
    preBuildScripts:
      # R.swiftでリソースファイルの生成
      - path: ./Scripts/XcodeGen/rswift.sh
        name: Generate Resources with R.swift
        inputFiles:
          - $TEMP_DIR/rswift-lastrun
        outputFiles:
          - $SRCROOT/$TARGET_NAME/Generated/R.generated.swift

      # Mockoloでモックの生成
      - path: ./Scripts/XcodeGen/mockolo.sh
        name: Generate Mocks with Mockolo
        outputFiles:
          - $SRCROOT/$TARGET_NAME/Generated/MockResults.swift

script で直接スクリプトを記述できますが、定義ファイルが長くなるため、私はルートフォルダに Scripts/XcodeGen フォルダを作成し、そこにXcodeGenで使うスクリプトをまとめて格納しています。
その場合、 path でスクリプトのパスを指定します。

./Scripts/XcodeGen/rswift.sh
if which mint >/dev/null; then
  xcrun --sdk macosx mint run R.swift rswift generate "$SRCROOT/$TARGET_NAME/Generated/R.generated.swift"
else
  echo "warning: Mint not installed, download from https://github.com/yonaskolb/Mint"
fi
./Scripts/XcodeGen/mockolo.sh
if which mint >/dev/null; then
  rm -f $SRCROOT/$TARGET_NAME/Generated/MockResults.swift
  xcrun --sdk macosx mint run uber/mockolo mockolo --sourcedirs $SRCROOT/$TARGET_NAME --destination $SRCROOT/$TARGET_NAME/Generated/MockResults.swift --mock-final
else
  echo "warning: Mint not installed, download from https://github.com/yonaskolb/Mint"
fi

postCompileScripts

Build PhasesでCompile Sourcesの直後に実行するスクリプトを指定します。

私の場合、製品ターゲットでSwiftLintとSpellCheckerのチェックスクリプトを記述しています。

project.yml
targets:
  Foo:
    postCompileScripts:
      # SwiftLintで静的解析と自動修正
      - path: ./Scripts/XcodeGen/swiftlint.sh
        name: Run SwiftLint

      # SpellCheckerでスペルチェック
      - path: ./Scripts/XcodeGen/spellchecker.sh
        name: Run SpellChecker
./Scripts/XcodeGen/swiftlint.sh
if which mint >/dev/null; then
  xcrun --sdk macosx mint run swiftlint swiftlint autocorrect --format
  xcrun --sdk macosx mint run swiftlint swiftlint
else
  echo "warning: Mint not installed, download from https://github.com/yonaskolb/Mint"
fi
./Scripts/XcodeGen/spellchecker.sh
if ! which mint >/dev/null; then
  echo "warning: Mint not installed, download from https://github.com/yonaskolb/Mint"
  exit 0
fi

git_path=/usr/local/bin/git
files=$($git_path diff --diff-filter=d --name-only -- "*.swift" "*.h" "*.m")
if (test -z $files) || (test ${#files[@]} -eq 0); then
  echo "no files changed."
  exit 0
fi

options=""
for file in $files
do
  options="$options $SRCROOT/$file"
done

xcrun --sdk macosx mint run SpellChecker SpellChecker --yml $SRCROOT/spell-checker.yml -- $options

schemes

スキームの設定です。

私はデフォルト(製品ターゲットと同名)のスキームのみ設定しており、 説明に疲れてきた 定義ファイルの全体像とXcode上のスキーム編集画面を見比べた方がわかりやすいので、本記事で詳細な解説はしません。

project.yml
schemes:
  Foo:
    build:
      targets:
        Foo: all
    run:
      config: Debug
    test:
      config: Debug
      gatherCoverageData: true
      coverageTargets:
        - Foo
      targets:
        - name: FooTests
          parallelizable: true
          randomExecutionOrder: true
        - name: FooUITests
          parallelizable: true
          randomExecutionOrder: true
    profile:
      config: Release
    analyze:
      config: Debug
    archive:
      config: Release

定義ファイルの全体像

以上を踏まえ、私の定義ファイルの全体像を載せます。

project.yml
name: Foo

options:
  bundleIdPrefix: com.theuhooi
  deploymentTarget:
    iOS: 13.0
  developmentLanguage: ja
  xcodeVersion: "11.3.1"
  carthageExecutablePath: mint run Carthage/Carthage carthage

settings:
  base:
    MARKETING_VERSION: 1.0.0
    CURRENT_PROJECT_VERSION: 1
    DEVELOPMENT_TEAM: XXXXXXXXXX
  configs:
    debug:
      DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym"

packages:
  Rswift:
    url: https://github.com/mac-cain13/R.swift.Library
    version: 5.3.0
  Gedatsu:
    url: https://github.com/bannzai/Gedatsu
    version: 1.2.0
  SwiftPrettyPrint:
    url: https://github.com/YusukeHosonuma/SwiftPrettyPrint
    version: 1.0.0

targets:
  Foo:
    type: application
    platform: iOS
    sources:
      - Foo
      - path: "Foo/Generated/MockResults.swift"
        optional: true
        type: file
      - path: "Foo/Generated/R.generated.swift"
        optional: true
        type: file
    settings:
      base:
        INFOPLIST_FILE: Foo/Info.plist
        CODE_SIGN_ENTITLEMENTS: Foo/Foo.entitlements
        DEVELOPMENT_LANGUAGE: jp
        OTHER_LDFLAGS: $(inherited) $(OTHER_LDFLAGS) -ObjC
      configs:
        debug:
          OTHER_SWIFT_FLAGS: $(inherited) -Xfrontend -warn-long-expression-type-checking=500 -Xfrontend -warn-long-function-bodies=500
    dependencies:
      - package: Rswift
      - package: Gedatsu
      - package: SwiftPrettyPrint
      - carthage: Alamofire
    preBuildScripts:
      - path: ./Scripts/XcodeGen/rswift.sh
        name: Generate Resources with R.swift
        inputFiles:
          - $TEMP_DIR/rswift-lastrun
        outputFiles:
          - $SRCROOT/$TARGET_NAME/Generated/R.generated.swift
      - path: ./Scripts/XcodeGen/mockolo.sh
        name: Generate Mocks with Mockolo
        outputFiles:
          - $SRCROOT/$TARGET_NAME/Generated/MockResults.swift
    postCompileScripts:
      - path: ./Scripts/XcodeGen/swiftlint.sh
        name: Run SwiftLint
      - path: ./Scripts/XcodeGen/spellchecker.sh
        name: Run SpellChecker

  FooTests:
    type: bundle.unit-test
    platform: iOS
    sources:
      - FooTests
    settings:
      base:
        INFOPLIST_FILE: FooTests/Info.plist
    dependencies:
      - target: Foo

  FooUITests:
    type: bundle.ui-testing
    platform: iOS
    sources:
      - FooUITests
    settings:
      base:
        INFOPLIST_FILE: FooUITests/Info.plist
    dependencies:
      - target: Foo

schemes:
  Foo:
    build:
      targets:
        Foo: all
    run:
      config: Debug
    test:
      config: Debug
      gatherCoverageData: true
      coverageTargets:
        - Foo
      targets:
        - name: FooTests
          parallelizable: true
          randomExecutionOrder: true
        - name: FooUITests
          parallelizable: true
          randomExecutionOrder: true
    profile:
      config: Release
    analyze:
      config: Debug
    archive:
      config: Release

GitHub Gistにも上げています。
https://gist.github.com/uhooi/730c7aa286e730b392796a159e4d75f5

バージョン管理から無視する

プロジェクトファイルのコンフリクトを防ぐため、生成されたプロジェクトファイルとワークスペースをバージョン管理の対象外にします。

Gitを使っている場合、以下を「.gitignore」に追加するのみでOKです。

.gitignore
+ *.xcodeproj
+ *.xcworkspace

操作方法

プロジェクトファイルの生成

定義ファイルが存在するフォルダへ移動し、 xcodegen generate を実行するのみです。

Mintで管理している場合は、先頭に mint run xcodegen を付けます。

$ mint run xcodegen xcodegen generate

私はMakefileを作成し、 make generate-xcodeproj を実行すると「プロジェクトファイルの生成→CocoaPods用ワークスペースの生成→ワークスペースをXcodeで開く」を一括で行うようにしています。

Makefile
PRODUCT_NAME := Foo

.PHONY: generate-xcodeproj
generate-xcodeproj:
    mint run xcodegen xcodegen generate
    bundle exec pod install
    make open

.PHONY: open
open:
    open ./${PRODUCT_NAME}.xcworkspace

注意:Xcode 11.5以上でMintを使うにはワークアラウンドが必要

2020/08/03現在、Xcode 11.5以上でMintをRun Script Phaseで使う場合、ワークアラウンドが必要です。
Mintの記事に詳細を記載したのでご参照ください。
https://qiita.com/uhooi/items/6a41a623b13f6ef4ddf0#注意xcode-115以上のrun-script-phaseで使うにはワークアラウンドが必要

おわりに

最後まで読み、XcodeGenを使ってみようと思っていただければ幸いです☺️

まだ業務で使っておらず、個人でも使い倒していないため、問題などありましたらご指摘くださると嬉しいです。

参考リンク

uhooi
iOSアプリ開発とSwiftが好きです✨ 趣味:テニス、アナログゲーム
https://theuhooi.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away