複数TARGETを用いて、単一Xcodeプロジェクトで複数のアプリをビルドする
2020年 新卒
初めまして。リクルート新卒一年目の大塚悠貴、佐藤万莉、河内友佑です!
新卒研修で、「異なるユーザ層に特化した複数のアプリを、最大限コードを共通モジュール化することで実現する」という課題に取り組んだので、その内容について紹介します。
コードを共有して複数アプリを作成するには大きく以下のような方法が挙げられます。
- 単一のXcodeプロジェクトでビルドTARGETを複数作成する
- 複数のXcodeプロジェクトを作成する
今回はビルドTARGETを複数作成する方法を検証したので、本ブログではこの方法を採用するにあたり発生した問題とその解決策、およびメンテナンス性を意識した実装について紹介します。
複数TARGETを作成・管理する
複数TAEGETの問題点
複数のTARGETでアプリを作る際は通常大まかに以下のような手順で行います。
- ビルドTARGETを複製する
- 各ファイルにTARGETを設定する
まずビルドTARGETは以下のように、既存のTARGETをDuplicateすることで作成します。
次に以下のように、チェックボックスを入れることで、各ファイルにTARGETを指定します。
しかし、上記のようなTARGETの指定の方法では、TARGETを複数安全に管理していくにあたり、大きな問題が2点あります。
- 各ファイルに対してTARGETを指定することが非常に手間かつミスが起こりうる
- TARGETが正しく指定されていることをレビューすることが困難である
既存の大量のファイルに対して、新しく作成したTARGETを追加することは手間ですし、TARGETの選択を人の手で行うことは間違いを生みやすいため安全とは言えません。
また、あるTARGETにファイルを追加した際はxcodeprojファイルに差分が生まれますが、人間に読めるものではありません。
XcodeGenの導入
ここで私たちはXcodeGenを導入することにしました。
XcodeGenは、yaml形式の定義ファイルからxcodeprojファイルを生成します。
これは、本来xcodeprojファイルのコンフリクトを避けるといった目的で導入されることが多いですが、複数TARGETを安全に管理することにも大きく役に立ちます。
XcodeGenではこの定義ファイルで、プロジェクトが持つTARGETを作成し、それぞれのTARGETに属するファイルやディレクトリを指定します。
例えば、2つのTAEGET、App1とApp2を考えます。
App1, App2というTARGETを作成し、 以下のようにTARGETを割り当てます。
- Sharedディレクトリ以下のファイル: App1とApp2
- App1ディレクトリ以下のファイル: App1
- App2ディレクトリ以下のファイル: App2
この場合、以下のように定義ファイルを記述します。
1 2 3 4 5 6 7 8 9 |
targets: App1: sources: - path: Shared - path: App1 App2: sources: - path: Shared - path: App2 |
このように"あるディレクトリ以下のファイルのTARGETは〇〇"とすることで、適切なディレクトリにファイルを作成さえすれば、TARGETの選択は考慮する必要がありません。
結果、各ファイルに対するTARGETの指定の手間を省くことができます。
さらに、レビューの観点でも、xcodeprojファイルをレビューする必要がなくなり、正しいディレクトリにファイルが作られていることだけ確認すればよいため、TARGETが正しく指定されていることをレビューすることが困難であるという問題も解決できます。
XcodeGen導入のTips
既存アプリにXcodeGenを導入する際に、いきなり定義ファイルを1から作ることは手間です。
この章では、そのような場合にXcodeGenを導入するTipsを2点簡単に紹介します。
1. xcconfig-extracterを用いてBuild Settingsをxcconfigに抜き出す
既存のプロジェクトではすでに大量のBuild Settingが存在している場合があります。これらを全てyamlファイルに正しく反映していくのは手間がかかります。このような場合はxcconfig-extracterを用いて、Build Setttingをxcconfigに抜き出し、そのファイルをyamlから参照することで解決できます。
1 2 3 4 5 6 |
// xcconfig-extracterのインストール // mintがない場合は brew install mint mint install toshi0383/xcconfig-extractor // xcconfig-extracterを用いてBuild Settingsをxcconfigに抜き出す xcconfig-extractor /path/to/Your.xcodeproj Configurations |
1 2 3 |
configFiles: Debug: configs/Debug.xcconfig Release: configs/Release.xcconfig |
2. Spec Generationを用いて、既存のxcodeprojからXcodeGenの定義ファイルを生成する
本ブログ記事執筆時点ではDraftのPRであるSpec generation を用いて、既存のxcodeprojファイルからXcodeGenの定義ファイルを生成することができます。
1 2 3 4 5 6 7 |
// spec-generationのブランチを指定してclone & install git clone -b spec-generation https://github.com/yonaskolb/XcodeGen.git cd XcodeGen make install // 既存のプロジェクトファイルから定義ファイルを生成 xcodegen migrate --project /path/to/ExistingProject.xcodeproj --spec project.yml |
ブログ執筆時点では、まだ完全な定義ファイルが生成されるわけではなく、
- Schemeが作成されない
- DebugとReleaseのBuild Configurationしか作成されない
- sourcesに一つ一つのファイルが吐き出される
といった問題がありましたが、既存のプロジェクトに導入する際には十分有用であると感じています。
fastlaneでTARGETごとに異なる設定値を用いる
fastlaneで各TARGETで異なる値を扱いたい場合には、環境変数を用いるという方法があります。
まず、各TARGETごとに.env.App1と.env.App2のようにそれぞれファイルを作成し、それぞれに以下のような設定値を記載します。
1 2 3 4 5 6 |
# Fastfileで使う値 SCHEME = "App1" # DeliverFileで使う値 metadata_path ENV['METADATA_PATH'] screenshots_path ENV['SCREENSHOTS_PATH'] |
上記で設定した環境変数は以下のように用います。
1 2 3 4 5 6 7 |
lane :inhouse do # do something gym( scheme: ENV["SCHEME"] ... ) end |
実際に使用する際は以下のように環境変数ファイルを指定して実行します。
1 |
fastlane inhouse --env App1 |
分割方法の観点
設計観点
TARGETを安全に分割できたので、次はコードを共通化して機能の差分を実装していきます。
機能を実装するためには、コードのどの部分を分割するか考えなくてはなりません。
このとき、変更が既存部分にマイナスの影響を及ぼさないことを保証すべきです。
エンハンスで苦労しないためにはどこに変更を局所化するのがよいか
まず、エンハンスの前提として、様々な変更に適応し、手戻りを減らす必要があります。
変更の多い部分はできるだけ共通化し、変更の少ない部分を分割することで、実装の差分を気にすることなく共通の機能を開発しやすくなります。逆に、変更が多い部分を分割してしまうと、共通部分が減っていく一方です。また、変更の度に両方のアプリの実装を気にしながら実装しなければならず、開発者の負担が増えてしまいます。
UIに近い部分の変化が大きいなら、できるだけUIに近い部分を分割し、ドメインに近い方を共通化する方針が適当です。逆に、UI/UX改善によってアプリをエンハンスするなら、できるだけドメインに近い部分を分割、UIに近い部分を共通化する方針を立てました。
実装の方針は3つあります。
- Shared以下のProtocol Extensionで共有する機能を実装し、App1, App2以下のProtocolの具象クラスで機能の差分を実装する。
- Shared以下のファイルで共有する機能を実装し、App1, App2以下のExtensionで機能の差分を実装する。
- App1, App2ディレクトリ以下のファイルでクラスそのものを実装する。
引き続き2つのアプリApp1とApp2を同一リポジトリ内で実装することを想定して、それぞれの方針を説明します。
Shared以下のProtocol Extensionで共有する機能を実装し、App1, App2以下のProtocolの具象クラスで機能の差分を実装する
Protocolで定義した変数や関数の具体的な処理をProtocol Extensionで実装することができます。また、Protocolに準拠した具象クラスでProtocol Extensionの内容をオーバーライドすることができます。
例えば、Hogeクラスの関数defaultHogeの値を以下のように出し分けたいとします。
App1→App1はHogeクラスの変数hogeValueに入った値を返す(デフォルト実装)
App2→決め打ちの値を返す(カスタム実装)
SharedディレクトリにHogeProtocol、App2のディレクトリにHogeProtocolに準拠したクラスHogeを作成します。
1 2 3 4 5 6 7 8 9 |
├── App1 │ └── Model │ └── Hoge.swift ├── App2 │ └── Model │ └── Hoge.swift └── Shared └── Protocol └── HogeProtocol.swift |
HogeProtocolで変数の宣言、Protocol Extensionで変数を実装します。
1 2 3 4 5 6 7 8 9 |
protocol HogeProtocol { var defaultHoge: HogeEntity { get } } extension HogeProtocol { var defaultHoge: HogeEntity { return hogeValue } } |
App1のHogeクラスはデフォルトの実装をそのまま使います。
1 2 3 |
class Hoge: HogeProtocol { } |
App2のHogeクラスはdefaultHogeの実装をオーバーライドします。
1 2 3 4 5 |
class Hoge: HogeProtocol { var defaultHoge: HogeEntity { return "default" } } |
このように、共有する機能をShared以下のProtocol Extensionをデフォルトの実装、App1, App2の具象クラスをアプリに特化した実装として使い分けることができます。 Protocolを用いた方針は、見通しがよく、型に制約を持たせながら実装できる一方で、アプリの要件に必要な機能の実装を忘れてしまったとしても、デフォルトの実装が動いてしまうので気づかないという懸念点があります。
Shared以下のファイルで共有する機能を実装し、App1, App2以下のExtensionで機能の差分を実装する
Stored property以外のクラスメンバーはExtensionで定義できます。例えば、Hogeクラスの変数defaultHogeの値を以下のように出し分けたいとします。
App1 →App1はHogeクラスの変数hogeValueに入った値
App2→決め打ちの値
1 2 3 4 5 |
class HogeViewController { hoge = Hoge.defaultHoge } |
SharedディレクトリにViewとView Controller、App1、App2それぞれのディレクトリにModelのExtensionを作成します。
1 2 3 4 5 6 7 8 9 10 11 |
├── App1 │ └── Model │ └── HogeExtension.swift ├── App2 │ └── Model │ └── HogeExtension.swift └── Shared ├── Model │ └── Hoge.swift └── ViewController └── HogeViewController.swift |
defaultHogeをExtensionで定義します。
1 2 3 4 5 |
extension Hoge { class var defaultHoge: HogeEntity { return hogeValue } } |
1 2 3 4 5 |
extension Hoge { class var defaultHoge: HogeEntity { return "defaultValue" } } |
このように、TARGETに対応するHogeExtension.swiftのdefaultHogeが呼ばれ、機能の差分を実装することができました。
Extensionを用いた方針は、Protocolのように型に制約を持たせることはできませんが、デフォルト引数を使いたい場合には適していると言えます。また、機能に差分が生まれる部分は両方のTARGETで実装しなければならないので要件の実装漏れがなくなります。
App1, App2ディレクトリ以下のファイルでクラスそのものを実装する
StoryboardやXibファイルなどのView、Asset Catalogに入っている画像やProperty ListなどのリソースはExtensionで分割できないのでそれぞれのディレクトリにファイルを作成します。
例えば、同じ機能を実装するのに別のUIを使うときは、App1, App2それぞれのディレクトリにViewとView Controllerを作成、実装します。
1 2 3 4 5 6 7 8 9 10 11 |
├── App1 │ ├── View │ │ └── Hoge.storyboard │ └── ViewControllers │ └── HogeViewController.swift ├── App2 │ ├── View │ │ └── Hoge.storyboard │ └── ViewControllers │ └── HogeViewController.swift └── Shared |
実装方法ごとのメリット/デメリットの軸
このプロジェクトでは、共有するメインロジックをSharedディレクトリに配置した上で、そこから呼び出すプロパティやメソッドをExtensionとしてTARGETごとに実装していきます。
まず、Extensionをどういった形で配置するのがいいのかということを考えました。
大きく考えられる方法は以下の2つです。
- ファイルを分割して、Extension用のHogeExtension.swiftを実装する
- Hoge.swift内でExtensionをTARGETごとに実装する
参考までに、メソッド内で処理をTARGETごとに分岐させる方法についても後述します。
別ファイルでExtension分割
序盤で解説した通り、XcodeではファイルごとにTARGETを定義します。したがって、実装者観点ではあるファイル『Hoge.swift』の中のあらゆるメンバーはそのファイルのTARGETだけに向いているものだと考えるということが自然でしょう。
そのため、同一リポジトリで複数のAppを管理する際に、実装差分として定義をするのであれば分割したファイルの中でExtensionを実装することで、『App1/HogeExtension.swift』はApp1がTARGETのものだと自明に認識できることになり、TARGETの管理を実装者が特に意識することなく実装することができるようになります。
まとめると、一目でTARGETを把握できるという点においてExtensionを実装するファイルを分割するという方針は非常に合理的ではないでしょうか。
他方で、この実装方針には重大なデメリットがあります。それは、ファイルを分割することによって、拡張元クラスのprivateメンバーにアクセスできなくなることです。同様にExtension内で定義したprivateメンバーに拡張元のクラスからはアクセスできなくなります。
Swift4以降では、privateの基本挙動がfileprivateとなり、それによって同一ファイル内のExtensionが有効な手段として用いられるようになりましたが、その恩恵に授かれないというのがファイル分割をする最も大きなデメリットと言えるでしょう。
アクセスコントロールを適切に扱う為に、Extensionから呼び出したいprivateメンバーを全てExtensionファイル内に記述するということも考えられますが、『共通部分を尊重したい』という意図からはかけ離れた実装になってしまいます。
1 2 3 4 5 |
class Hoge { func printTarget() { print( self.target ) } } |
1 2 3 4 5 |
extension Hoge { class var target : String { return "AppOne" } } |
同一ファイル内でExtension分割
ファイル内でターゲットごとにExtensionを実装することもできます。Swiftの条件付きコンパイルを用いることで、複数TARGETの挙動を同一のファイルで管理ができるようになります。開発環境と本番環境で挙動を制御したいときに用いられることが多いSwiftの条件付きコンパイルを用います。
https://docs.swift.org/swift-book/ReferenceManual/Statements.html#ID539
まずは、TARGETごとにSwiftのフラグを設定します。
Build Settings から、 Swift Compiler -Custom Flags を参照します。
Other Swift Flags に -D APPONE と追記します。
すると、TARGET名をAPPONEと指定することで、条件付きコンパイル文を用いて以下のように表現をすることができます。
1 2 3 4 5 |
#if APPONE // 任意のExtension #endif |
この方法の1番のメリットはファイル分割ではできなかったアクセスレベルの制御ができるということです。同一ファイル内であれば、Extensionと拡張元のクラスの間でprivateメンバーにアクセスしあうことができます。
デメリットはソースコードの見通しが悪くなることです。同一のソースファイル内で挙動が異なる同じ名前の関数やプロパティを持つことになるので、TARGETの数が増えるほど実装中に意識をしないといけなくなる部分が増えることが想定されます。また、Xcodeの機能の定義ジャンプ時に全ての関数がサジェストされるので、実際に修正・確認したいソースがどのTARGETであるかを確認する必要が生まれます。
対策としては、以下のように、Xcodeで認識してくれるコメントを適切に用いることなどが考えられます。
1 |
// MARK: - TARGET APPONEに対するExtension |
AppTargetで分割
前項の条件付きコンパイルによる挙動の分岐はExtension以外にも活用することができます。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Hoge { private func fuga() { /// APP ONEの場合 #if APPONE // do something #endif /// APP TWOの場合 #if APPTWO // do something #endif } } |
しかし、ソースコードの中で、条件付きコンパイルが頻発するのは極めて可読性を損ねる結果になるので、AppTargetをenumで管理をしておくという手段も考えられます。
1 2 3 4 5 6 7 8 9 10 11 |
enum AppTarget { case app1, app2 var current: AppTarget { #if APPONE return .app1 #else return .app2 #endif } } |
こうすることで、先ほどのコードは以下のように簡潔にすることができます。
1 2 3 4 5 6 7 8 9 10 |
class Hoge { private func fuga() { switch AppTarget.current { case .app1: // do something case .app2: // do something } } } |
このようにenumを定義することで、switch文で分岐ができるようになり部分的に可読性が上がりますが、ファイル内の複数箇所でswitchを実装すると全体的な可読性は下がります。
この手法のメリットは、どちらか一方のアプリケーションには処理を加え、もう一方には何もしたくないというときに、breakを追加することができるというところにあります。
これは、Extensionで実装するためには処理をしない空のプロパティやメソッドを定義しないといけなかったことと比較すると、簡潔な実装方法で可読性にも優れていると言えるのではないでしょうか。
しかしながら、ファイル内の複数箇所でswitchを実装すると全体的な可読性は下がりますし、実装者は実装内容だけでなく常にTARGETのことを意識して実装しないといけなくなるので、この方法は安易に選択するべきではないと考えました。
Extensionで実装する方法はないか、UIごと分割するべきではないかなどを考慮し棄却した上で、最終的に利用する方法としてAppTargetによる分割は有益でしょう。
まとめ
別ファイルExtensionと同一ファイル内Extensionは一長一短という感じです。
アクセスコントロールを維持した上で実装するならば、別ファイルExtensionでは機能の最小分割ができません。一方で、TARGETの認識容易性やレビューのしやすさなどの観点から考えると、同一ファイル内のExtensionにはやや難があります。
考え方として、Sharedをライブラリのように捉え、Shared内は全てpublicに実装するという手段も考えられますが、今回の研修プロジェクトのように既存のアクセスコントロールがある場合はなかなかそのような大胆な選択を取ることができません。プロジェクトの現状や方向性を意識しながらメリットデメリットを検討して選択していく必要があることを学びました。
XCTestによるユニットテスト
まず、テストのディレクトリ構成について説明します。
テストコードはアプリケーションコードと対応した位置にあると探しやすいので、SharedTests, App1Tests, App2Testsのディレクトリを用意します。SharedTestsには共通部分のテスト、App1TestsとApp2TestsにはExtension部分のテストを置きます。
プロジェクトのyamlファイルでTest TargetのsourcesにSharedTestsディレクトリを追加し、 App1, App2両方でSharedTestsを実行できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
############ # Targets: TownworkTests ############ targets: App1Tests: platform: iOS type: bundle.unit-test sources: - path: SharedTests - path: App1Tests |
アプリケーションのコードとテストコードのある場所、TargetとTest Targetをそれぞれ対応させることができました。
次に、2つ以上のTargetを割り当てられたテストの実装方法について紹介します。
Swiftでは@testable import TARGET_NAMEでimport先のTargetに属するクラスを呼び出すことができます。
Sharedディレクトリに置いたコードのテストは、Targetの設定だけでなく、testable importのimport先を条件付きコンパイルを用いて指定する必要があります。
1 2 3 4 5 |
#if APPONE @testable import App1 #else @testable import App2 #endif |
終わりに
この記事では、新卒研修で行った「ソースコードを共有しながら複数のiOSアプリのビルドについて検証する」というプロジェクトに対して、「ターゲット分割とExtensionでの差分機能実装」および「XcodeGen」でプロジェクトファイルの生成を管理するという方針で目的が達成できるということを紹介してきました。
新卒3名のチームで1か月半チーム開発を行ったのですが、コロナ禍ということで、一度も顔を合わさずに全てオンラインでの実施となりました。しかしながら、ツールを最大限活用したことや、メンターによる指導や様々な人のバックアップのおかげで、特に不安を感じることなく最後のアウトプットまで出し切れたと思います。