Switching environments in iOS
April 21, 2020When working on your project you often need to switch environments. Whether it is check a bug on production or to use the latest API on your dev server this is a quite common scenario. But doing it properly and easily in iOS has always been challenging. Let's see how we can handle it.
What is an environment anyway?
Talking about environment in iOS might suggest talking about Schemes, Configurations or Build Settings which are all related subjects. Here environment mean variables used at runtime and which (might) change depending on selected Configuration. Some examples:
- Your server URL
- A service credentials
- Feature toggling
On the other hand these are not environment variables but Build Settings:
- build number
- build identifier
The common way: Plist
Before seeing my solution let's have a look at perhaps the most common way of handling environment values in iOS: Plist. Put it simply using Plist can be resumed in four steps:
- Create a
xcconfig
file per Configuration (Debug.xcconfig, ...) - Add custom Build Settings inside it (
SERVER_URL
, ...) - Add new entries inside Info.plist to reference our custom Build Settings (
myServerUrl => $(SERVER_URL)
) - Define an
Environment
type to lookup for settings from Info.plist
Let's take a closer look at some of these steps
Custom Build Settings, Oops I mistyped it again
While there is nothing wrong with defining custom Build Settings it is easy to mispell them. Especially here where we define them in one placee (.xcconfig) to use them in another one (Info.plist).
What's the cost? Your code will compile but fail at runtime 😈
You may think it's not that serious. "I'll see it while developing". Most of the time you will. But if you mispelled (or even forgot to declare) the setting in only one Configuration then the issue might been left unnoticed. And guess which environment might indeed have that sort if issue? Yep, production! 😏
// Debug.xcconfig
// APII_KEY? Or maybe was it API_KEY?
APII_KEY = xxx
SERVER_URL = https://swiftunwrap.com
// Release.xcconfig
// Boom, a production app with no SERVER_URL defined! 🚨
API_KEY = xxx
The Environment type is verbose
While writing your Environment
type you will may notice that it is kind of verbose compared to what you expect it to do.
public enum Environment {
static let serverURL: URL = {
guard let serverURLString = Bundle.main.object(forInfoDictionaryKey: "SERVER_URL") as? String else {
fatalError("Server URL not set in plist for this environment")
}
guard let url = URL(string: serverURLString) else {
fatalError("Server URL is invalid")
}
return url
}()
}
All that code just to define one attribute making our code hard to read. We may mitigate that issue using some of Swift powers:
public enum Environment {
static let serverURL: URL = {
return (Bundle.main
.requiredObject(forInfoDictionaryKey: "SERVER_URL") as String)
.asURL()
}()
}
extension Bundle {
func requiredObject<T>(forInfoDictionaryKey key: String) -> T {
guard let value = object(forInfoDictionaryKey: "SERVER_URL") as? T else {
fatalError("\(key) not set in plist for this environment")
}
return value
}
}
extension String {
func asURL() -> URL {
guard let url = URL(string: self) else {
fatalError("URL is invalid")
}
return url
}
}
It is still quite verbose for one attribute but get better when having multiples. However we don't have any guarantee on our code. object(forInfoDictionaryKey:)
return Any?
so it is up to you to check and cast the type. Hence our fatalError
all around the place.
Did you also notice that after casting into String
we are mapping to URL
? Why not just making a URL
in first place? Because with Plist you are limited in possible types: String
, Number
, Bool
, Array
or Dictionary
. That's it. Nothing less. Neither URL
, tuples nor custom types 🤷.
Let's recap. Using Plist we may:
- mispell/forget a value in .xcconfig or Info.plist
- mispell the key in our
Environment
type - use the wrong type and have a failing cast
- limit ourselves to five primtive types
Surely we can do better than that!
The less common way: Swift code
No need for complexity
Let's start by modeling what we want to do. We want to have an environment with some properties:
struct Environment {
let serverURL: URL
let apiKey: String
}
We have our model and it is very simple. No extra lines to define values. However question remain whether or not we are able to define an environment per configuration. We actually can:
extension Environment {
private static let debug = Self(serveurURL: ..., apiKey: ...)
private static let release = Self(serveurURL: ..., apiKey: ...)
}
With these few lines we just did what we were doing in Build Settings and Plist 💪. How to use them? You either use dependency injection or determine environment at compile time:
extension Environment {
#if DEBUG
static let current: Environment = .debug
#else
static let current: Environment = .release
#endif
}
And we're done. Our environment is ready. Compared to previous solution we gained:
- Concise code. And adding a new environment attribute is just... declaring a new struct attribute 😍
- All attributes are available at compile time. You can't distribute your app with forgetting one 🎉
- You can easily use any type we want 🚀
- Best of all, we only wrote Swift code! 🧁
We do have one drawback however: all our environments are shipped in our application even in production. This actually might be useful in dev because with some code we can add environment switching functionality. But in production we want environment to be frozen.
Excluding environments
To exclude environments we can rely on a non very known Build Setting {INCLUDED/EXCLUDED}_SOURCE_FILE_NAMES
that allow to include/exclude files at compile time. Let's split our environments into multiple files and just include the ones we need:
// Common.xcconfig
ENV = $(CONFIGURATION)
// we first exclude all files from Env
EXCLUDED_SOURCE_FILE_NAMES = Env/**/*
// and then re-include only some
INCLUDED_SOURCE_FILE_NAMES = Env/Env.swift Env/$(ENV)/*
We then change the way to determine current environment:
// Env/Env.swift
protocol EnvProvider {
static var current: Env { get }
}
struct Environment: EnvProvider { ... }
// Env/Debug/Debug.swift
extension Env {
private static let debug = Self(...)
static let current = .debug
}
// Env/Release/Release.swift
extension Env {
private static let release = Self(...)
static let current = .release
}
Let's recap:
- We first create our
Environment
struct - We define once instance per Configuration
- We write (once) our include/exclude file
Having used both methods on projects the later one is, from my experience, easier and safer way to manage environments than with Plist. Adding new environment attribute should not only be fast but also not risky. Finally I think it makes more sense for a new developer coming on the project to look to data in Swift code rather than in an obscure Plist file.
Hoping to see many projects to adopt this methodology! 🧑🚀🚀