It’s best practice to have separate environments for your iOS apps, especially if they are communicating with any servers. For instance, consider an iOS app and three different available web backend environments: development, staging, and production.
What’s the best way to switch in the iOS app to use these different servers? What about all the other potential key/value pairs of information, like API keys – and what if some of that information needs to be secure and not checked into version control?
The simplest way is to create a file to store these environment variables as a
string or other constants in the native language, like Swift
. However, this
may lead to numerous issues - whether it be for security reasons or overall bad
practices involving hardcoding flag values or relying on macros. Instead, let’s
use separate files that we can avoid checking into version control that
explicitly outline the properties of the app that will change on an
environmental basis.
Using .xcconfig Files
.xcconfig
files are supplemental files that aid in configuring a specific
build type. You can easily edit these files outside of Xcode. They are plain
text files that define and override the build settings for a particular build
configuration of a project or target. Although they have very specific
formatting rules, they make build settings easier to understand. In addition,
they can serve as reusable template between projects. There’s less chance of
human error, and less fussing around with the Build Settings pane in Xcode (and
who doesn’t love that?). Overall, it’s a straightforward process with a
considerable upside - so let’s get started!
Configurations
In Xcode, a project can have multiple configurations. By default, a new Xcode
project will have two configurations: Debug
and Release
. Sometimes,
these two configurations may suffice. However, that’s usually not my experience.
For this walkthrough, we’ll define three environments, Development
, Staging
and Production
. Each of these will have a corresponding configuration,
debug
and release
. Let’s create these now.
In the Navigator Area, select the project file (the top-most file). Then, from within the Editor Area, make sure the project is selected as well as the Info tab. Take a look at the Configurations section.
Creating a new configuration is straightforward. Since we’ll have three
environments, each with a Debug
and Release
configuration, we’ll create the
following:
- Debug (Development)
- Debug (Staging)
- Debug (Production)
- Release (Development)
- Release (Staging)
- Release (Production)
Select the + icon at the bottom of the Configurations section and Duplicate “Debug” Configuration
Repeat this and rename the existing Configurations until you have the following:
Schemes
Xcode projects also come with one scheme by default that’s named after the project. An Xcode scheme defines a collection of configurations to use when building, as well as tests to execute and a collection of targets to build. Schemes are accessible via the scheme selector in the toolbar. For this project, we’ll have one scheme per environment. Let’s create the other two now.
From the Xcode toolbar, head to the Scheme selector. Open the Scheme pop-up menu and select Manage Schemes…
In the Scheme manager we see a list of schemes available in the project, what container they are in and whether or not they are shared. Click the current scheme, and at the bottom, click the cog to open the settings pop-up. Select Duplicate
This scheme we’re creating will be the Development scheme. Ensure that the appropriate configuration is set for each action like so:
Create another scheme for Staging and ensure the correct configurations are set as well. Finally, verify that the default scheme is using Production configurations, and make sure all are marked as Shared so that they are not strictly local to only your own Xcode environment, but available project-wide. If this is not checked, others opening this project cannot utilize these schemes! You should have something like this when done:
Project Structure
Having a clean project structure is important as it helps organize files of similar categories and responsibilities. It can be a big help when you can’t remember what a particular file was called and Open Quickly can’t seem to track it down. Another benefit is serving as documentation for new team members on the project, expediting the onboarding process.
For this walkthrough, I’ll share an example recommended project structure for the project. Structures of production-ready apps change relative to their architecture and team preferences, but largely follow the same principle of not abandoning files in a root folder and grouping related files together. In larger apps, these folders may be segmented further, splitting into different screens and flows of the application.
We’ve created a few folders and generally split them up by type of object detailed in the files.
- Configs
- Controllers
- Models
- Resources
- Storyboards
- ViewControllers
Creating xcconfig files
Now that a simple project structure is set up, let’s create the xcconfig files our Configurations will be using. Within the Config folder, add a new file and select the Configuration Settings File.
Name the file Development, and ensure that no Target is selected - you don’t want these in the application bundle as they are not being compiled.
Repeat the above steps for Staging and Production.
Now, let’s actually enter configuration variables.
These files are used to store key-value pairs of settings. Our keys will be
xcconfig variables. Variables are assigned in xcconfig files using the =
operator after a variable name, such as MY_FLAG = bar
(whitespace will be
ignored on either side of the equals sign). In our example, each environment
will have a different ROOT_URL
, API_KEY
, APP_NAME
and
BUNDLE_IDENTIFIER
. Having different app names and bundle identifiers lets us
have a different copy of the app on a device or simulator for each
environment…all at the same time!
Here’s what Development.xcconfig
looks like:
// Development.xcconfig
// Server URL
ROOT_URL = http:/$()/localhost:3000
// Keys
API_KEY = 783u9djd8a_hkzos7jd803001nd
// App Settings
APP_NAME = MyTestApp (dev)
APP_BUNDLE_ID = com.thoughtbot.MyTestApp.dev
Repeat the above for Staging and Production environments, changing the settings as follows:
// Staging.xcconfig
// Server URL
ROOT_URL = https:/$()/www.staging.mytestapp.thoughtbot.com
// Keys
API_KEY = 89dhdyd93380dkqmoe_hd830dhq
// App Settings
APP_NAME = MyTestApp (staging)
APP_BUNDLE_ID = com.thoughtbot.MyTestApp.staging
// Production.xcconfig
// Server URL
ROOT_URL = https:/$()/www.mytestapp.thoughtbot.com
// Keys
API_KEY = 9ud0930djd_md9zdjdko3830lb0d
// App Settings
APP_NAME = MyTestApp
APP_BUNDLE_ID = com.thoughtbot.MyTestApp
Note: You may have noticed some strangeness with the ROOT_URL
formatting.
This is due to the way characters are escaped in xcconfig files. In order to
have the //
in https://
, we need to split it with an empty variable
substitution via $()
. A minor annoyance, but simple when you know to do it.
Setting xcconfig files for Configurations
Now that we have our xcconfig files set up, we need to set the appropriate file for each Configuration we created previously.
Head back to the Project Info tab in the Editor view. Click the disclosure indicator for a given Configuration, and notice that the target has no configuration set. From the dropdown on the right, select the appropriate configuration file. Repeat until all the Configurations are set as follows:
CocoaPods note for existing projects: If you’re using CocoaPods with an existing project and want to follow along, you’ll have to do a tiny bit of extra work to have it set up as CocoaPods has it’s own xcconfig files.
- Delete the
.xcworkspace
file - Delete the
Podfile.lock
file andPods/
directory - Keep the Podfile
- Rerun
pod install
You’ll see in Terminal that CocoaPods did not set the configuration since we already set custom configurations. CocoaPods provides a link to be included in each
- Open new
.xcworkspace
- Include .xcconfig path for CocoaPods in your .xcconfig files by prepending an
#include
statement like the following: #include “Pods/Target Support Files/Pods-MyTestApp/Pods-MyTestApp.release.xcconfig”
Accessing configuration values from project settings
To actually use our new configuration settings for our project, let’s begin with editing the Info.plist.
Recall that we intend each environment to have a different APP_NAME
and
APP_BUNDLE_ID
. We can use variable substitution in the .plist for the
appropriate keys, substituting our custom variables from the xcconfig files.
Here, we’ve changed Bundle name
‘s value to $(APP_NAME)
and Bundle
Identifier
to $(APP_BUNDLE_ID)
.
With these changes, build and run the app using different schemes from the Scheme selector. The result is three different versions of the app, each using their corresponding environment.
Accessing configuration values from code
While we’ve used variable substitution in our Info.plist file to specify
some project settings per-environment, we also need to be able to access some of
our config variables from actual code - such as our ROOT_URL
and API_KEY
.
Let’s begin by adding them to our Info.plist.
Since Xcode projects don’t include a ROOT_URL
or API_KEY
key in the plist by
default, we’ll have to add two new entries. To do so, tap the plus button
anywhere in the file. Set the Type to String and the value to the
substituted value from our xcconfig files like so:
Now that these variables are in our plist, we can access them from Swift. One
recommended way of doing so is creating an Environment.swift
file. This will
contain an enum with no cases to serve as a namespace to access the plist and
the variables contained within. We’ll create two static properties, rootURL
and apiKey
that will return a URL
and a String
respectively. We’ll
initialize them in a closure to contain the logic of retrieving them from the
plist, and call fatalError:
with the appropriate message.
Note that we’ve explicitly decided to call fatalError:
in this case as it
signifies a programming error, rather than encountering an expected nil state.
We do not intend the xcconfig files or Info.plist to not have these values,
and crashing the app while providing this helpful message will give context and
allow for correction by the programmer.
Add this file to the Configs folder:
// Environment.swift
import Foundation
public enum Environment {
private static let infoDictionary: [String: Any] = {
guard let dict = Bundle.main.infoDictionary else {
fatalError("Plist file not found")
}
return dict
}()
static let rootURL: URL = {
guard let rootURLstring = Environment.infoDictionary["ROOT_URL"] as? String else {
fatalError("Root URL not set in plist for this environment")
}
guard let url = URL(string: rootURLstring) else {
fatalError("Root URL is invalid")
}
return url
}()
static let apiKey: String = {
guard let apiKey = Environment.infoDictionary["API_KEY"] as? String else {
fatalError("API Key not set in plist for this environment")
}
return apiKey
}()
}
Now we’re capable of accessing the rootURL
and apiKey
throughout our Swift
code. But let’s make one more adjustment. It’s often considered a best practice
to remove stringly-typed code as much as possible, and we have potential to do
so when accessing the values within our infoDictionary
. Let’s create an enum
that will contain these plist keys and add it to our Environment.swift
file:
// Environment.swift
import Foundation
public enum Environment {
// MARK: - Keys
enum Keys {
enum Plist {
static let rootURL = "ROOT_URL"
static let apiKey = "API_KEY"
}
}
// MARK: - Plist
private static let infoDictionary: [String: Any] = {
guard let dict = Bundle.main.infoDictionary else {
fatalError("Plist file not found")
}
return dict
}()
// MARK: - Plist values
static let rootURL: URL = {
guard let rootURLstring = Environment.infoDictionary[Keys.Plist.rootURL] as? String else {
fatalError("Root URL not set in plist for this environment")
}
guard let url = URL(string: rootURLstring) else {
fatalError("Root URL is invalid")
}
return url
}()
static let apiKey: String = {
guard let apiKey = Environment.infoDictionary[Keys.Plist.apiKey] as? String else {
fatalError("API Key not set in plist for this environment")
}
return apiKey
}()
}
We’re now able to access entries of the infoDictionary
using our Keys.Plist
enum’s static properties. While it may not appear as a large change on the
surface, it helps to have a single point of configuration and eliminate
potential confusion. It’s apparently obvious to the programmer reading this file
that we’re intentionally accessing the rootURL
of the infoDictionary
as
we’ve explicitly added a property to do so.
Now that we’ve created a structured way to access these values, let’s test it out!
In the stock ViewController.swift
file, add the following line to
viewDidLoad
to print the API_KEY
and ROOT_URL
:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
print(Environment.apiKey)
print(Environment.rootURL.absoluteString)
}
}
Select a scheme, build and run and observe the console output!
Look familiar?
Security Considerations
Since these files will potentially contain secure information, such as
API_KEY
, I’d recommend not checking them into version control and instead
using a secure file storage system like 1Password to contain copies of
Development.xcconfig
, Staging.xcconfig
and Production.xcconfig
.
Final thoughts
Using Xcode configuration files is an elegant and powerful solution for configuring different build settings. While we’ve covered a considerable amount of project tweaking here to get our environments set up the way we want them to be, it’s largely formulaic after you’ve grown comfortable doing so. Feel free to take the template we’ve worked on here and customize it to fit your project’s needs! It’s always worthwhile spending a bit more time configuring things just once than to constantly have to revisit and fix things in the future. 🚀