This article is the 5th day article of Swift/Kotlin Lovers Association Advent Calendar 2020. I participated because it was vacant.
Introducing best practices for switching variable values for each environment in iOS app development.
The app I'm developing requires three API connections (for development, staging, and release).
We often see how to add Staging
to Build Configurations (hereafter referred to as" build configurations ") and branch with #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
However, these days, Xcode's build system assumes two types, Debug
and Release
, and adding a build configuration seems to cause problems around the library.
Create a Build Configuration like Staging, set user-defined variables and branch like #if STAGING. In the past, that was fine, but now that the Xcode build system is based on two types, Debug/Release, problems will occur around the library sooner or later, so don't touch it.
& mdash; kishikawa katsumi (@k_katsumi) December 8, 2020
By the way, I've been doing a lot of work to delete build configurations other than Debug/Release, and I'm still doing it. ..
& mdash; kishikawa katsumi (@k_katsumi) December 8, 2020
If you are using XcodeGen, it is better to change the settings for each environment and generate a project, so I will show you how to do it.
Yes, if you are using XcodeGen, change the schema ENVIRONMENT = STAGING HOST = staging.example .com xcodegen --spec .. I think it's good because you can create and use a project with Staging configuration settings as appropriate (for example).
& mdash; kishikawa katsumi (@k_katsumi) December 8, 2020
The most important thing to tell you in this article is __ let's not mess with the build configuration and otherwise change the settings for each environment __. The "other way" is that it's relatively easy to do with XcodeGen.
However, I don't really understand the specific problems caused by tweaking the build configuration, so I'd appreciate it if you could tell me: bow:
However, my perception is as follows, so I agree with changing the settings in a different way from the build configuration regardless of whether there is a problem.
< script async src = "https://platform.twitter.com/widgets.js" charset = "utf-8">Build Configurations
& mdash; Uhoi (@the_uhooi) December 16, 2020
Debug: When running with Xcode
Release: When creating an .ipa file Since it is recognized as
, it has nothing to do with the environment such as the API connection destination, right?
Is this recognition extreme?
--I'm using XcodeGen If you're not using it, the bonus may be helpful
Implement so that the settings can be changed for each environment.
Export the value you want to use in the project as an environment variable, and prepare a command to generate the project with XcodeGen.
Since the environment variables to be exported change for each environment, it is recommended to use make
etc. to make it a task.
I am creating a Makefile
like this:
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
This Makefile
exports the following values to the ENVIRONMENT
environment variable.
environment | value |
---|---|
debug | DEBUG |
Staging | STAGING |
release | RELEASE |
The name of the environment variable does not have to be ENVIRONMENT
.
For example, you can pass the API connection destination directly as API_BASE_URI
.
However, in that case, if there are other settings that you want to change for each environment, you have to export the environment variables each time.
I export only one value that determines the environment and change the API connection destination within the project.
2020/12/16 postscript For example, if you really don't want to include the development and staging environment settings in the binary at the time of release, you may want to export the settings directly.
Inject the exported environment variables into your project.
project.yml
targets:
{Product target name}:
# {Omission}
settings:
base:
+ ENVIRONMENT: ${ENVIRONMENT}
In XcodeGen, you can get environment variables with $ {environment variable name}
.
Here, we created a User-Defined setting with the name ENVIRONMENT
and injected the environment variables we exported earlier.
If you implement so far and execute make generate-xcodeproj-release
, the following User-Defined will be created.
As you can see, all have a value of RELEASE
regardless of the build configuration.
In other words, environment and build configuration are independent of each other, and you can do "debug build in release environment" and "release build in staging environment".
Add the User-Defined setting defined in project.yml
to Info.plist
.
Key | Type | Value |
---|---|---|
Any | String |
$(User-Defined setting name) |
The Key is arbitrary, but for the sake of clarity, a name close to the User-Defined setting name is preferable.
By adding it to Info.plist
, you can now callBundle.main.object (forInfoDictionaryKey: "key")
to get the settings from the Swift file.
I want to centrally manage environment variables, so I put them together in one file.
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/"
}
}
}
This completes the implementation.
If the number of settings you want to change for each environment increases in the future, you can implement it in the same way as apiBaseUri
.
No modifications are required except for EnvironmentVariables.swift
.
By the way, I made EnvironmentVariables
an enumeration without cases simply because I want a namespace.
The usage is as follows.
make generate-xcodeproj- ○○
to export environment variables and generate a project.EnvironmentVariables. ××
As an example, run make generate-xcodeproj-staging
to call the settings in 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/{username}/{Omission}/{Project name}.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
}
}
I was able to get the setting values for each environment!
By not setting environment
to private
in EnvironmentVariables.swift
, you can branch the process for each environment.
With the above implementation, it is difficult to replace the values during unit testing, so we will make it possible to mock the protocol by biting it. If it is an enumeration type, it cannot be instantiated without a case, so I am changing it to a structure.
I'm using a mock generation library called Mockolo, so I've added a /// @mockable
comment to the protocol.
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/"
}
}
}
The example to call in AppDelefate.swift
changes as follows.
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
}
}
It takes more effort to instantiate, but I prefer to bite the protocol because it's testable.
As an example, DI apiBaseUri
via the initializer.
ApiClient.swift
final class ApiClient {
private let apiBaseUri: String
init(environmentVariables: EnvironmentVariablesProtocol) {
self.apiBaseUri = environmentVariables.apiBaseUri
}
}
In this case, all the properties and methods of EnvironmentVariablesProtocol
can be called in the initializer, so I think it is possible to DI only apiBaseUri
.
If you want to use only environment
, DI of Environment
makes it easy to understand because you can not call extra properties and methods (in this case, apiBaseUri
property).
Foo.swift
final class Foo {
private let environment: Environment
init(environment: Environment) {
self.environment = environment
}
func foo() {
if environment == .staging {
//Specific processing in a staging environment
}
}
}
By using BuildConfig.swift developed by @ 417_72ki, you can change the settings for each environment even in projects that do not use XcodeGen.
Please refer to the slide below for details. https://speakerdeck.com/417_72ki/management-of-environment-variables-with-yamls-ver-dot-2
With this, you can rest assured that you will receive a request to change the settings for each environment! If there is another good way, please let me know in the comments etc .: relaxed:
This is the article on the 5th day of Swift/Kotlin Lovers Association Advent Calendar 2020. The next day is also an article by @uhooi.
Recommended Posts