はじめに
本記事は Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事です。
空いていたので参加しました。
iOSアプリ開発において、環境ごとに変数の値を切り替えるベストプラクティスを紹介します。
背景
私が開発しているアプリで、APIの接続先が3つ(開発用・ステージング用・リリース用)必要になりました。
Build Configurations(以下「ビルド構成」と呼ぶ)に Staging
を追加し、 #if
で分岐する方法をよく見かけます。
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種類を前提としており、ビルド構成を追加するとライブラリ周りで問題が発生するようです。
StagingみたいなBuild Configurationを作ってユーザー定義変数を設定して#if STAGINGみたいにして分岐する、ようなのはずっと昔はそれで別に良かったんだけど今はXcodeのビルドシステムがDebug/Releaseの2種類が前提で早晩ライブラリ周りで問題が起こって詰むので基本触ってはいけない。
— kishikawa katsumi (@k_katsumi) December 8, 2020
ちなみに私はDebug/Release以外のビルド構成を見つけたら消す仕事をけっこういろんなところでしていて、今もしています。
— kishikawa katsumi (@k_katsumi) December 8, 2020
XcodeGenを使っている場合、環境ごとに設定を変えてプロジェクトを生成するのがいいとのことなので、その方法を紹介します。
はい、XcodeGen使ってるならスキーマを変えるように ENVIRONMENT=STAGING HOST=staging.example .com xcodegen --spec ... みたいな感じで(例えばです)Staging構成の設定のプロジェクトを適宜作って使う、っていうのができるのでそれがいいと思います。
— kishikawa katsumi (@k_katsumi) December 8, 2020
最も伝えたいこと
本記事で最も伝えたいことは、 ビルド構成をいじらず、それ以外の方法で環境ごとに設定を変えよう です。
「それ以外の方法」として、XcodeGenを使うと比較的かんたんに実現できる、ということです。
といいつつも、実はビルド構成をいじることによる具体的な問題を把握していないので、知っていたら教えていただけると嬉しいです
ただ、私の認識は以下のツイートの通りなので、問題の有無にかかわらずビルド構成とは別の方法で設定を変えるのに賛成です。
Build Configurations(ビルド構成)
— ウホーイ (@the_uhooi) December 16, 2020
Debug: Xcode で Run するとき
Release: .ipa ファイルを作るとき
という認識なので、そもそもAPI接続先などの環境と関係ないよね?
この認識は極端かな?
前提条件
- XcodeGenを使っている
使っていない場合、おまけが参考になるかもしれない
環境
- OS:macOS Catalina 10.15.7
- Xcode:12.2 (12B45b)
- Swift:5.3.1
- XcodeGen:2.18.0
実装
環境ごとに設定を変えられるよう実装します。
Makefileの作成(任意)
プロジェクト内で使いたい値を環境変数としてエクスポートし、XcodeGenでプロジェクトを生成するコマンドを準備します。
エクスポートする環境変数を環境ごとに変えるため、 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の修正
エクスポートした環境変数をプロジェクトに注入します。
targets:
{製品ターゲット名}:
# {中略}
settings:
base:
+ ENVIRONMENT: ${ENVIRONMENT}
XcodeGenでは、環境変数を ${環境変数名}
で取得できます。
ここでは ENVIRONMENT
という名前でUser-Definedの設定を作成し、先ほどエクスポートした環境変数を注入しています。
ここまで実装して make generate-xcodeproj-release
を実行すると、以下のUser-Definedが作成されます。
見てわかる通り、ビルド構成にかかわらずすべて RELEASE
の値が入っています。
つまり 環境とビルド構成は互いに独立している ということであり、「リリース環境でデバッグビルド」や「ステージング環境でリリースビルド」などができるようになります。
Info.plistにUser-Definedの設定を追加
Info.plist
に project.yml
で定義したUser-Definedの設定を追加します。
Key | Type | Value |
---|---|---|
任意 | String |
$(User-Definedの設定名) |
Keyは任意ですが、わかりやすいようにUser-Definedの設定名に近い名前がいいと思います。
EnvironmentVariables.swiftの追加
Info.plist
に追加したことで、 Bundle.main.object(forInfoDictionaryKey: "キー")
を呼び出してSwiftファイルから設定を取得できるようになりました。
私は環境変数を一元管理したいため、1ファイルにまとめています。
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
をケースなしの列挙型にしているのは、単純に名前空間が欲しいためです。
使い方
使い方は以下の通りです。
-
make generate-xcodeproj-○○
で環境変数のエクスポートとプロジェクトを生成する -
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
@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 というモック生成ライブラリを使っているため、プロトコルに /// @mockable
コメントを付けています。
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
内で呼び出す例は以下のように変わります。
@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します。
final class ApiClient {
private let apiBaseUri: String
init(environmentVariables: EnvironmentVariablesProtocol) {
self.apiBaseUri = environmentVariables.apiBaseUri
}
}
これだと EnvironmentVariablesProtocol
の全プロパティやメソッドがイニシャライザ内で呼び出せるため、 apiBaseUri
のみDIするのもありだと思います。
environment
のみ使いたい場合、 Environment
をDIすると余計なプロパティやメソッド(今回だと apiBaseUri
プロパティ)を呼び出せなくなってわかりやすいです。
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
おわりに
これで環境ごとに設定を変えてほしい要求が来ても安心です!
他にいい方法があれば、コメントなどで教えていただけると嬉しいです
以上、 Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事でした。
翌日も @uhooi の記事です。