Let's Set Up Your iOS Environments

Patrick Montalto

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 and Pods/ 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. 🚀