iOSアプリで環境ごとに設定を変えるベストプラクティス(Swift)

## はじめに

本記事は [Swift/Kotlin愛好会 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/love_swift_kotlin) の5日目の記事です。
空いていたので参加しました。

iOSアプリ開発において、環境ごとに変数の値を切り替えるベストプラクティスを紹介します。

## 背景

私が開発しているアプリで、APIの接続先が3つ（開発用・ステージング用・リリース用）必要になりました。
Build Configurations（以下「ビルド構成」と呼ぶ）に `Staging` を追加し、 `#if` で分岐する方法をよく見かけます。

```swift:EnvironmentVariables.swift
enum EnvironmentVariables {
#if DEBUG
    static let apiBaseUri = "https://example.com/debug/"
#elseif STAGING
    static let apiBaseUri = "https://example.com/staging/"
#elseif RELEASE
    static let apiBaseUri = "https://example.com/release/"
#endif
```

しかし、最近はXcodeのビルドシステムが `Debug` と `Release` の2種類を前提としており、ビルド構成を追加するとライブラリ周りで問題が発生するようです。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">StagingみたいなBuild Configurationを作ってユーザー定義変数を設定して#if STAGINGみたいにして分岐する、ようなのはずっと昔はそれで別に良かったんだけど今はXcodeのビルドシステムがDebug/Releaseの２種類が前提で早晩ライブラリ周りで問題が起こって詰むので基本触ってはいけない。</p>&mdash; kishikawa katsumi (@k_katsumi) <a href="https://twitter.com/k_katsumi/status/1336331476212695048?ref_src=twsrc%5Etfw">December 8, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ちなみに私はDebug/Release以外のビルド構成を見つけたら消す仕事をけっこういろんなところでしていて、今もしています。</p>&mdash; kishikawa katsumi (@k_katsumi) <a href="https://twitter.com/k_katsumi/status/1336335368514273280?ref_src=twsrc%5Etfw">December 8, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

XcodeGenを使っている場合、環境ごとに設定を変えてプロジェクトを生成するのがいいとのことなので、その方法を紹介します。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">はい、XcodeGen使ってるならスキーマを変えるように ENVIRONMENT=STAGING HOST=staging.example .com xcodegen --spec ... みたいな感じで（例えばです）Staging構成の設定のプロジェクトを適宜作って使う、っていうのができるのでそれがいいと思います。</p>&mdash; kishikawa katsumi (@k_katsumi) <a href="https://twitter.com/k_katsumi/status/1336340305663037446?ref_src=twsrc%5Etfw">December 8, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 最も伝えたいこと

本記事で最も伝えたいことは、 __ビルド構成をいじらず、それ以外の方法で環境ごとに設定を変えよう__ です。
「それ以外の方法」として、XcodeGenを使うと比較的かんたんに実現できる、ということです。

といいつつも、実はビルド構成をいじることによる具体的な問題を把握していないので、知っていたら教えていただけると嬉しいです :bow: 

ただ、私の認識は以下のツイートの通りなので、問題の有無にかかわらずビルド構成とは別の方法で設定を変えるのに賛成です。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Build Configurations（ビルド構成）<br> Debug: Xcode で Run するとき<br> Release: .ipa ファイルを作るとき<br>という認識なので、そもそもAPI接続先などの環境と関係ないよね？<br>この認識は極端かな？</p>&mdash; ウホーイ (@the_uhooi) <a href="https://twitter.com/the_uhooi/status/1339089767342862336?ref_src=twsrc%5Etfw">December 16, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 前提条件

- XcodeGenを使っている  
使っていない場合、おまけが参考になるかもしれない

## 環境

- OS：macOS Catalina 10.15.7
- Xcode：12.2 (12B45b)
- Swift：5.3.1
- XcodeGen：2.18.0

## 実装

環境ごとに設定を変えられるよう実装します。

### Makefileの作成（任意）

プロジェクト内で使いたい値を環境変数としてエクスポートし、XcodeGenでプロジェクトを生成するコマンドを準備します。
エクスポートする環境変数を環境ごとに変えるため、 `make` などを使ってタスク化するのがオススメです。

私は以下のような `Makefile` を作成しています。

```make:Makefile
DEBUG_ENVIRONMENT := DEBUG
STAGING_ENVIRONMENT := STAGING
RELEASE_ENVIRONMENT := RELEASE

.PHONY: generate-xcodeproj-debug
generate-xcodeproj-debug: # Generate project with XcodeGen for debug
	$(MAKE) generate-xcodeproj ENVIRONMENT=${DEBUG_ENVIRONMENT}

.PHONY: generate-xcodeproj-staging
generate-xcodeproj-staging: # Generate project with XcodeGen for staging
	$(MAKE) generate-xcodeproj ENVIRONMENT=${STAGING_ENVIRONMENT}

.PHONY: generate-xcodeproj-release
generate-xcodeproj-release: # Generate project with XcodeGen for release
	$(MAKE) generate-xcodeproj ENVIRONMENT=${RELEASE_ENVIRONMENT}

.PHONY: generate-xcodeproj
generate-xcodeproj:
	mint run xcodegen xcodegen generate
```

この `Makefile` では `ENVIRONMENT` 環境変数に以下の値をエクスポートしています。

|環境|値|
|:--|:--|
|デバッグ| `DEBUG` |
|ステージング| `STAGING` |
|リリース| `RELEASE` |

環境変数の名前は `ENVIRONMENT` でなくても問題ありません。

例えば `API_BASE_URI` として、直接APIの接続先を渡すこともできます。
しかし、それだと他にも環境ごとに変えたい設定が出てきた場合、そのたびに環境変数をエクスポートしなければいけません。
私は環境を判定する値を1つのみエクスポートし、プロジェクト内でAPIの接続先を変えるようにします。

2020/12/16 追記
例えばリリース時に開発やステージング環境の設定をどうしてもバイナリに含めたくない場合、設定値を直接エクスポートするのもありだと思います。

### project.ymlの修正

エクスポートした環境変数をプロジェクトに注入します。

```diff_yaml:project.yml
targets:
  {製品ターゲット名}:
    # {中略}
    settings:
      base:
+       ENVIRONMENT: ${ENVIRONMENT}
```

XcodeGenでは、環境変数を `${環境変数名}` で取得できます。
ここでは `ENVIRONMENT` という名前でUser-Definedの設定を作成し、先ほどエクスポートした環境変数を注入しています。

ここまで実装して `make generate-xcodeproj-release` を実行すると、以下のUser-Definedが作成されます。
![スクリーンショット_2020-12-15_17_09_31.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/90cb479b-0499-e895-30aa-be3f8437e106.jpeg)

見てわかる通り、ビルド構成にかかわらずすべて `RELEASE` の値が入っています。
つまり __環境とビルド構成は互いに独立している__ ということであり、「リリース環境でデバッグビルド」や「ステージング環境でリリースビルド」などができるようになります。

### Info.plistにUser-Definedの設定を追加

`Info.plist` に `project.yml` で定義したUser-Definedの設定を追加します。
![スクリーンショット 2020-12-15 17.18.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/6e323380-72e2-b668-8870-f7559490c173.png)

|Key|Type|Value|
|:--|:--|:--|
|任意| `String` | `$(User-Definedの設定名)` |

Keyは任意ですが、わかりやすいようにUser-Definedの設定名に近い名前がいいと思います。

### EnvironmentVariables.swiftの追加

`Info.plist` に追加したことで、 `Bundle.main.object(forInfoDictionaryKey: "キー")` を呼び出してSwiftファイルから設定を取得できるようになりました。

私は環境変数を一元管理したいため、1ファイルにまとめています。

```swift:EnvironmentVariables.swift
import Foundation

enum Environment: String {
    case debug = "DEBUG"
    case staging = "STAGING"
    case release = "RELEASE"
}

enum EnvironmentVariables {
    static var environment: Environment {
        guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String,
              let environment = Environment(rawValue: environmentString)
        else {
            fatalError("Fail to load `Environment` from `Info.plist`.")
        }
        return environment
    }

    static var apiBaseUri: String {
        switch environment {
        case .debug:
            return "https://example.com/debug/"
        case .staging:
            return "https://example.com/staging/"
        case .release:
            return "https://example.com/release/"
        }
    }
}
```

これで実装は完了です。

今後環境ごとに変えたい設定が増えた場合、 `apiBaseUri` と同様に実装すればOKです。
`EnvironmentVariables.swift` 以外の修正は不要です。

ちなみに `EnvironmentVariables` をケースなしの列挙型にしているのは、単純に名前空間が欲しいためです。

## 使い方

使い方は以下の通りです。

1. `make generate-xcodeproj-○○` で環境変数のエクスポートとプロジェクトを生成する
2. `EnvironmentVariables.××` で設定値をSwiftで呼び出す

例として `make generate-xcodeproj-staging` を実行し、 `AppDelefate.swift` 内で設定値を呼び出します。

```shell-session
$ make generate-xcodeproj-staging 
/Applications/Xcode.app/Contents/Developer/usr/bin/make generate-xcodeproj ENVIRONMENT=STAGING
mint run xcodegen xcodegen generate
⚙️  Generating plists...
⚙️  Generating project...
⚙️  Writing project...
Created project at /Users/{ユーザー名}/{中略}/{プロジェクト名}.xcodeproj
```

```swift:AppDelefate.swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/"

        if EnvironmentVariables.environment == .staging { // true
            print("Environment is staging.")
        }

        return true
    }
}
```

環境ごとの設定値を取得することができました！

`EnvironmentVariables.swift` で `environment` を `private` にしないことで、環境ごとに処理を分岐することができます。

## 発展: プロトコルを噛ませてモック化できるようにする

上記の実装だと単体テスト時に値を差し替えづらいので、プロトコルを噛ませてモック化できるようにします。
列挙型だとケースがないとインスタンス化できないので、構造体に変更しています。

私は [Mockolo](https://github.com/uber/mockolo) というモック生成ライブラリを使っているため、プロトコルに `/// @mockable` コメントを付けています。

```diff_swift:EnvironmentVariables.swift
import Foundation
+ 
+ /// @mockable
+ protocol EnvironmentVariablesProtocol {
+     var environment: Environment { get }
+     var apiBaseUri: String { get }
+ }

enum Environment: String {
    case debug = "DEBUG"
    case staging = "STAGING"
    case release = "RELEASE"
}

- enum EnvironmentVariables {
-     static var environment: Environment {
+ struct EnvironmentVariables: EnvironmentVariablesProtocol {
+     var environment: Environment {
        guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String,
              let environment = Environment(rawValue: environmentString)
        else {
            fatalError("Fail to load `Environment` from `Info.plist`.")
        }
        return environment
    }

-     static var apiBaseUri: String {
+     var apiBaseUri: String {
        switch environment {
        case .debug:
            return "https://example.com/debug/"
        case .staging:
            return "https://example.com/staging/"
        case .release:
            return "https://example.com/release/"
        }
    }
}
```

`AppDelefate.swift` 内で呼び出す例は以下のように変わります。

```diff_swift:AppDelefate.swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

+         let environmentVariables: EnvironmentVariablesProtocol = EnvironmentVariables()
-         print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/"
+         print(environmentVariables.apiBaseUri) // "https://example.com/staging/"

-         if EnvironmentVariables.environment == .staging { // true
+         if environmentVariables.environment == .staging { // true
            print("Environment is staging.")
        }

        return true
    }
}
```

インスタンス化する手間は増えますが、私はプロトコルを噛ませるほうがテスタブルで好みです。

例として、 `apiBaseUri` をイニシャライザ経由でDIします。

```swift:ApiClient.swift
final class ApiClient {
    private let apiBaseUri: String
    
    init(environmentVariables: EnvironmentVariablesProtocol) {
        self.apiBaseUri = environmentVariables.apiBaseUri
    }
}
```

これだと `EnvironmentVariablesProtocol` の全プロパティやメソッドがイニシャライザ内で呼び出せるため、 `apiBaseUri` のみDIするのもありだと思います。

`environment` のみ使いたい場合、 `Environment` をDIすると余計なプロパティやメソッド（今回だと `apiBaseUri` プロパティ）を呼び出せなくなってわかりやすいです。

```swift:Foo.swift
final class Foo {
    private let environment: Environment
    
    init(environment: Environment) {
        self.environment = environment
    }

    func foo() {
        if environment == .staging {
            // ステージング環境で特有の処理
        }
    }
}
```

## おまけ: BuildConfig.swiftを使う

@417_72ki さんが開発している [BuildConfig.swift](https://github.com/417-72KI/BuildConfig.swift) を使えば、XcodeGenを使っていないプロジェクトでも環境ごとに設定を変えられます。

詳細は以下のスライドをご参照ください。
https://speakerdeck.com/417_72ki/management-of-environment-variables-with-yamls-ver-dot-2

## おわりに

これで環境ごとに設定を変えてほしい要求が来ても安心です！
他にいい方法があれば、コメントなどで教えていただけると嬉しいです :relaxed: 

以上、 [Swift/Kotlin愛好会 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/love_swift_kotlin) の5日目の記事でした。
翌日も @uhooi の記事です。

## 参考リンク

- [Allow using env vars in project.yml · Issue #587 · yonaskolb/XcodeGen](https://github.com/yonaskolb/XcodeGen/issues/587)
- [Add environment variable expansion by tomquist · Pull Request #594 · yonaskolb/XcodeGen](https://github.com/yonaskolb/XcodeGen/pull/594)
- [XcodeでDevelop/Staging/Release環境を上手に切り分ける方法 - Qiita](https://qiita.com/Todate/items/a2e6a26731c79bd23e02)
- [【勉強会】「potatotips #61 (iOS/Android開発Tips共有会)」まとめ | by ウホーイ | Medium](https://uhooi.medium.com/potatotips-61-c26d327b147c)