Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
43
Help us understand the problem. What are the problem?
@uhooi

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

はじめに

本記事は Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事です。
空いていたので参加しました。

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

背景

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

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のビルドシステムが DebugRelease の2種類を前提としており、ビルド構成を追加するとライブラリ周りで問題が発生するようです。


XcodeGenを使っている場合、環境ごとに設定を変えてプロジェクトを生成するのがいいとのことなので、その方法を紹介します。

最も伝えたいこと

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

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

ただ、私の認識は以下のツイートの通りなので、問題の有無にかかわらずビルド構成とは別の方法で設定を変えるのに賛成です。

前提条件

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

環境

  • OS:macOS Catalina 10.15.7
  • Xcode:12.2 (12B45b)
  • Swift:5.3.1
  • XcodeGen:2.18.0

実装

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

Makefileの作成(任意)

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

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

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の修正

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

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

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

ここまで実装して make generate-xcodeproj-release を実行すると、以下のUser-Definedが作成されます。
スクリーンショット_2020-12-15_17_09_31.jpg

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

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

Info.plistproject.yml で定義したUser-Definedの設定を追加します。
スクリーンショット 2020-12-15 17.18.29.png

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

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

EnvironmentVariables.swiftの追加

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

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

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 内で設定値を呼び出します。

$ 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
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.swiftenvironmentprivate にしないことで、環境ごとに処理を分岐することができます。

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

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

私は Mockolo というモック生成ライブラリを使っているため、プロトコルに /// @mockable コメントを付けています。

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 内で呼び出す例は以下のように変わります。

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します。

ApiClient.swift
final class ApiClient {
    private let apiBaseUri: String

    init(environmentVariables: EnvironmentVariablesProtocol) {
        self.apiBaseUri = environmentVariables.apiBaseUri
    }
}

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

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

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 を使えば、XcodeGenを使っていないプロジェクトでも環境ごとに設定を変えられます。

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

おわりに

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

以上、 Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事でした。
翌日も @uhooi の記事です。

参考リンク

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
43
Help us understand the problem. What are the problem?