Switching environments in iOS

Tue, 21 Apr 2020 00:00:00 +0000

When 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:

1. Create a xcconfig file per Configuration (Debug.xcconfig, ...)

2. Add custom Build Settings inside it (SERVER_URL, ...)

3. Add new entries inside Info.plist to reference our custom Build Settings (myServerUrl => $(SERVER_URL))

4. 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:

1. We first create our Environment struct

2. We define once instance per Configuration

3. 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! 🧑‍🚀🚀