Android Test-Driven Development by Tutorials,
Second Edition – Now Updated!

Build testable, sustainable Android apps via JUnit, Mockito, and Espresso
by diving into test-driven development (TDD) in this newly-updated book.

Home iOS & Swift Tutorials

Building Your App Using Build Configurations and .xcconfig

Use Xcode build settings and .xcconfig files to change your app’s settings and icon with different build configurations.

5/5 4 Ratings

Version

  • Swift 5, iOS 14, Xcode 12

Debug, test, release — these are the phases most apps go through. In each phase, the app has different build settings, definitions and constants. Developers build an app with debug back-end URLs and settings. Testers test beta builds with production-like settings. Customers use the app with the final production settings.

Managing these settings across different environments in Xcode is time-consuming — not to mention the added work when you have multiple targets. Fortunately, Apple has provided a much better way to work with these settings: Xcode build configuration files, or .xcconfig files.

In this tutorial, you’ll:

  • Work with Xcode build configuration files.
  • Manage build settings across multiple environments and targets.
  • Access build settings from code.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Open the project. Build and run.

NinjaCounter app

The app you’ll work on, NinjaCounter, helps biologists and enthusiasts count turtle hatchlings. The app has one view: CounterView.swift, where users record the hatchlings.

In the NinjaCounter group, you’ll find the following:

  • Hatchling: A simple struct that has the hatchling record attributes.
  • UserDefaultsHelper: A UserDefaults helper that provides methods to store and load hatchling records.

Build and run. In the tag text field, enter Leonardo. Tap the + Hatchling button.

New hatchling recorded added

With that, you created a new record with the hatchling tag and hatch time.

Now that you get the gist of the starter project, you’ll set up the app widget.

Setting Up Widget with App Group

Open Widget.swift and take a look at the code. It creates a simple widget that shows the number of hatchlings counted and the tag of the last hatchling reported.

In getTimeline(in:completion:), the widget uses UserDefaultsHelper‘s getRecordsCount() and getRecords() to get the data from UserDefaults.

Select the WidgetExtension scheme. Build and run.

App widget showing 0 hatchlings

Currently, the widget doesn’t show any data, even though you just recorded Leonardo. That’s because the extensions don’t have access to the app’s UserDefaults. To solve this, you’ll add the app and widget to an app group.

Select the NinjaCounter project in the Project navigator to show the Project Editor. Select NinjaCounter target. Open the Signing & Capabilities tab.

Select a development team for signing.

Change the Bundle Identifier to something unique to you such as com.myorg.NinjaCounter. Remember this as you’ll need again momentarily.

Click + Capability. Double-click App Groups.

Adding App Group capability

Now that you’ve added App Groups to the capabilities, click the + button to create an app group. You’ll see a prompt for the group name.

Enter group.. Click OK.

Now, perform the same steps to the Widget Extension target. Be sure the bundle identifier ends with .widget, but use the same app group that you created for the main app. This allows data sharing between the host app and the widget extension.

Now that you’ve created your App Group and added your targets to it, it’s time to let the app group access the UserDefaults suite.

Open UserDefaultsHelper.swift and replace the declaration of defaults with:

static private let defaults = UserDefaults(
  suiteName: "<#the app group name you defined#>")
  ?? .standard

With this code, you ensure the app saves and reads data from the UserDefaults suite that the app group shares. This lets the widget access the hatchling data.

Change the active scheme to NinjaCounter. Build and run.

App showing main page, no data

The record you added isn’t there anymore because you’re using a different UserDefaults suite. Add Leonardo again!

Change the active scheme to WidgetExtention. Build and run.

App widget showing one record

You can see the widget shows the added record now. Congratulations!

In the next section, you’ll explore build settings in Xcode.

Demystifying Build Settings and Build Configurations in Xcode

In this section, you’ll see how Xcode displays and resolves build settings. Open the Project Editor. Locate the TARGETS list. Select NinjaCounter as the app target.

Select the Build Settings tab. Select the All and Levels build settings filter options.

NinjaCounter app's build settings

Here, you see the build settings of the app target. The build settings are separated into four columns displaying the settings values in different scopes.

  • Resolved: The actual values after resolving precedence.
  • NinjaCounter (target): Displays the values set at the target level. Target build settings have a higher precedence than the project’s. By default, targets inherit values from the project build settings.
  • Ninja Counter (project): Shows the values set in the project’s build settings. General build settings are available at the project level, others are only available for targets.
  • iOS Default: Shows the iOS default value of a setting.
Note: Build settings follow the precedence below, from lowest to highest:
  • Platform defaults
  • Project.xcconfig file
  • Project file build settings
  • Target .xcconfig file
  • Target build settings

Select the WidgetExtension target. Look at the build settings.

Widget build settings

You can see the same settings but at the widget’s target level.

Settings have multiple values, one for each Build Configuration. Check Base SDK, for example. A build configuration is like an environment.

You define Build Configurations globally, at the project level. Xcode creates two configurations for you: Debug and Release.

The default values for the build settings are different for these environments. For example, Clang Optimization Level is set to None -O0 in Debug, letting you debug and inspect the code. Meanwhile, in Release, it defaults to Fastest, Smallest -Os for maximum code optimization and the smallest executable size.

Now that you’ve covered the build settings, it’s time to go over targets and schemes.

Understanding Targets and Schemes

A target specifies a single product with its build settings and files. A target can be an app, extension, framework, iMessage app, App Clip and so on.

When the widget was created, Xcode created a new target. You configured the development team and capabilities for both the app and the widget targets.

Apple defines an Xcode scheme as follows: “An Xcode scheme defines a collection of targets to build, a configuration to use when building, and a collection of tests to execute.”

Creating a new target automatically creates a new scheme that goes with it.

To see this, click the active scheme. Click Edit Scheme….

Select Edit scheme...

Pro tip: Go directly to the scheme editor without passing the menu by Option-clicking the active scheme.

You’ll see the scheme editor:

Scheme editor

The WidgetExtention scheme, for instance, defines how Xcode will build, run, test, profile, analyze and archive the widget target. It also defines which build configurations to use with these actions.

You can see that Run defaults to the Debug configuration, while Archive defaults to Release. The Build Configuration drop-down menu is where you change the selected build configuration.

Close the scheme editor. Next, you’ll create a new build configuration for the staging environment.

Creating a Staging Environment Configuration

To configure and create test builds of the app, you need a staging build configuration.

Open the Project Editor and select the NinjaCounter PROJECT. Open the Info tab.

Info tab from project editor

This is where you define build configurations. They’re global and shared among targets. Click + to add a new one.

Adding a new build configuration

Select Duplicate “Debug” Configuration. Name the new configuration Staging.

The new staging build configuration

You’ll see the new Staging configuration. Select the NinjaCounter target and switch to the Build Settings tab.

Build settings showing the new staging configuration.

The settings now have new values for the new build configuration. You duplicated the build configuration from Debug, so they have the same values.

Now that you’ve created your staging environment, it’s time to add .xcconfig files.

Creating Configuration Settings Files

Using the Build Settings tab to modify settings has some disadvantages. Searching through long lists with different scopes is time-consuming, especially when you have multiple targets, build configurations and settings to manage. .xcconfig files are an alternative that simplifies this process.

Select the NinjaCounter group in Project navigator. Create a new group. Name the group Config Files. This is where you’ll place your configuration files.

Click File ▸ New ▸ File….

Select Configuration Settings File in the file templates list.

Adding a new Configuration Settings file

Name the file Debug.xcconfig. For this app, you need to create an .xcconfig file for each configuration. Create Staging.xcconfig and Release.xcconfig.

Note: Don’t add .xcconfig files to target memberships. Configuration settings files are meant to be development dependencies. They should not be among the resources included in the app archive.

File target memberships not selected

Next, you’ll set your configuration files in Xcode.

Working With Configuration Settings Files

To use configuration files you need to set them in Xcode. Before that, add a setting for the app display name.

Open Debug.xcconfig. Add the setting below and save.

APP_NAME = Ninja Counter

Settings in .xcconfig files use the following syntax: SETTING_NAME = VALUE.

Note: You don’t surround the string values with quotation marks, even when they contain spaces, like Ninja Counter does. The exception is when the string that contains the space is in a string list, which is a space-separated list of string values.

Open the Project Editor and select the NinjaCounter project. Click the Info tab.

The Configurations section is where you set configuration files.

Build configurations in project editor showing the new widget target

As you can see, you can use configuration files at the project level as well as at each target level, for each environment.

Under Based on Configuration File, click on a configuration file option.

Changing configuration file setting

Xcode shows you the list of .xcconfig files you’ve created. Set the corresponding configuration file for each build configuration to the app and widget targets, as shown below:

  • Debug
    • NinjaCounter: Debug
    • WidgetExtension: Debug
  • Staging
    • NinjaCounter: Staging
    • WidgetExtension: Staging
  • Release
    • NinjaCounter: Release
    • WidgetExtension: Release

Configuration files set for targets

At this point, you’ve created a configuration file for each environment. Good job! Next, you’ll customize the settings for each environment.

Creating a User-Defined Setting to Change the Bundle Display Name

As a developer, you use and work with your app in all its phases. When you switch between different builds, it can be confusing to know which build you currently have installed. For that reason, having different display names is useful.

Open Debug.xcconfig. Change APP_NAME‘s value to Ninja CounterDev. This is the app display name you’ll see when you install a debug build.

Now, you’ll know at a glance that this is the development build that you’re working on with the top-secret and next-gen features! :]

Next, you need to change the names for the other configurations. Open Staging.xcconfig and add the following line:

APP_NAME = Ninja CounterQA

Finally, open Release.xcconfig and update the setting with the production display name:

APP_NAME = Ninja Counter

Next, select the project itself in the Project navigator. Select the NinjaCounter app target.

Open the Build Settings tab. Scroll to the end of list.

Build settings showing the new, user-defined setting

Now, you can see your user-defined build setting APP_NAME and a new scope of settings that shows values at the configuration files level. If you see the values you set in the configuration file here, you’re on the right track!

The last step to changing the app display name is to set Bundle display name to refer to your user-defined setting.

Open NinjaCounter’s Info.plist. Add the Bundle display name property. Set the value of the property to $(APP_NAME).

Optionally, you can also add it from the Info tab from the Project Editor.

Info.plist

Make sure you set NinjaCounter as the active scheme.

Build and run. Press Shift-Command-H to enter to the home screen.

iOS Simulator showing home screen

Now, you can see your development build app name.

It’s time to test the app in the Staging environment. Option-click the the run button in the Xcode task bar.

Scheme editor

Next, select the Run action, then the Info tab and click the Build Configuration drop-down menu. Your new staging configuration is in the list. Select Staging as the building configuration.

Click Run, wait for the app to start, then return the iOS home screen.

iOS Simulator showing home screen

The app display name is now NinjaCounterQA, showing you immediately which build you are using.

Next, you’ll create a base configuration file to avoid redundant values.

Retaining Values With Inheritance

In your configuration file, you set the APP_NAME for your different builds to Ninja CounterDev, Ninja CounterQA and Ninja Counter. The release build name, NinjaCounter, is the base value, which you repeat in each name.

It’s a best practice to set general build settings at the project level. Then, reuse those settings in different configurations to avoid redundancy. In this case, you’ll change APP_NAME in each build configuration to inherit from the base value.

Right-click the Config files group. Select New file…. Create a new configuration file named Base.xcconfig and add the following line:

APP_NAME = Ninja Counter

Next, replace the content of the other configuration files as follows below.

Debug.xcconfig:

#include "Base.xcconfig"

APP_NAME = $(inherited)Dev

Staging.xcconfig:

#include "Base.xcconfig"

APP_NAME = $(inherited)QA

Release.xcconfig:

#include "Base.xcconfig"

Here, you changed the three configuration files to inherit and reuse the base settings from Base.xcconfig. You did this by using $(inherited) and appending the part of the name specific to each build.

Note: $(inherited) values are referred from the included files, if any, and follow the precedence mentioned earlier. Similarly , if you inherit a system build setting in a configuration file, the resolved value will be set based on the precedence.

Finally, build and run the app in Staging.

iOS Simulator showing home screen

The app name displays correctly. Note that you didn’t need to add the APP_NAME definition to Release.xcconfig. That’s because the settings fall back to the inherited values.

To see how the settings display in Xcode, open the Project Editor. Select the NinjaCounter target, then Build Settings. Scroll to the User-defined section.

App build settings

The resolved values don’t look correct despite displaying correctly when the app runs. That’s because Base.xcconfig isn’t set in the Configurations part of the Project Editor. However, the inheritance resolves as expected in runtime.

To fix this, select the project in the project editor. Under Configurations, set Base.xcconfig at the project level of all three configurations.

Setting project configuration file

Now, go back to the Build Settings of the app target.

App build settings

The values display correctly now in Xcode. Note the new scope shows the project-level configuration file.

Next, you’ll add more custom settings.

Referencing Values of Other Settings

Now that your environments are ready, it’s time to add more settings. Add the following to Base.xcconfig. Be sure to substitute your bundle identifier:

BASE_BUNDLE_IDENTIFIER = <your bundle identifier>

PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_IDENTIFIER)

BASE_BUNDLE_IDENTIFIER is a user-defined setting that contains the project’s base bundle identifier, while PRODUCT_BUNDLE_IDENTIFIER is an Xcode build setting that specifies the bundle identifier.

Here, you’ve referenced BASE_BUNDLE_IDENTIFIER‘s value. The reference syntax is: $(OTHER_BUILD_SETTING_NAME).

Note: You use the same reference syntax when you refer to a build setting from Info.plist or in a .entitlements file.

Now, if you were to change the value of BASE_BUNDLE_IDENTIFIER in Base.xcconfig, you wouldn’t see the change reflected in the build settings. That’s because the bundle identifier is currently hard-coded in the target’s settings. To fix this, return to the Project Editor and select the NinjaCounter target. In the search field, type bundle to narrow down the number of settings show. Double-click the target build setting and change it’s value to:

$(inherited)

change target bundle identifier to use setting value

Previously, you changed the code in UserDefaultsHelper.swift to use the app group identifier as the UserDefaults suite name, as below.

UserDefaults(suiteName: "group.<your bundle identifier>")

To avoid hard coding values like the suite name, add the following setting below PRODUCT_BUNDLE_IDENTIFIER in Base.xcconfig:

USER_DEFAULTS_SUITE_NAME = group.$(BASE_BUNDLE_IDENTIFIER)

Since the app group identifier in NinjaCounter depends on the bundle identifier, you created USER_DEFAULTS_SUITE_NAME. It references BASE_BUNDLE_IDENTIFIER inline and appends the “group.” prefix. In a moment, you’ll update your code to use this new setting but first you must update the app to use the setting. In the Project navigator, you’ll find two .entitlements files. Open WidgetExtension.entitlements and click the disclosure arrows to open the settings. Change the value of Item 0 to:

$(USER_DEFAULTS_SUITE_NAME)

Next, open NinjaCounter.entitlements and do the same thing.

You ensured consistency of build settings that depend on other settings by referencing their values. Next, you’ll use that setting in your code and remove the hard-coded one.

Accessing Settings From Code

To access a build setting from code, you first need to add a reference property in Info.plist. Open NinjaCounter’s Info.plist and add a custom property named:

USER_DEFAULTS_SUITE_NAME

With the value:

$(USER_DEFAULTS_SUITE_NAME)

Do the same to the widget’s Info.plist because the widget needs to access the settings as well.

Info.plist

Next, create a new Swift file in the NinjaCounter group and name it Config.swift. Open the file and add the following code:

enum Config {
  static func stringValue(forKey key: String) -> String {
    guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String
    else {
      fatalError("Invalid value or undefined key")
    }
    return value
  }
}

stringValue(forKey:) is a helper method that simplifies retrieving values from Info.plist. It calls Bundle‘s object(forInfoDictionaryKey:) to obtain a string value.

Note: You can store and obtain other data types from property lists, like Booleans and arrays. For simplicity, because NinjaCounter only needs string values, this helper method is for strings only.

Then, in the File inspector, select WidgetExtension as a target in addition to NinjaCounter.

Open UserDefaultsHelper.swift and again replace the declaration of defaults with:

static private let defaults = UserDefaults(
  suiteName: Config.stringValue(forKey: "USER_DEFAULTS_SUITE_NAME"))
  ?? .standard

Here, you changed UserDefaultsHelper‘s code to get the suite name from the build setting. You used Config‘s method, stringValue(forKey:), to fetch the value.

Next, you’ll add conditional settings.

Adding Conditions

You can add conditions to build settings to target a specific SDK, architecture or build configuration. This is especially useful when adding project-wide settings that aren’t specific to a target.

In Base.xcconfig, add the following line:

ONLY_ACTIVE_ARCH[config=Debug][sdk=*][arch=*] = YES

Here, you set ONLY_ACTIVE_ARCH to YES when the app builds in Debug configuration. As a result, it speeds up build times by building only the active architecture.

Conditional settings follows the following syntax:

BUILD_SETTING_NAME[condition=value] = VALUE.

Setting the conditional value to an asterisk means any value. The setting you added above applies for any sdk and any architecture, but only for the Debug configuration.

In the next section, you’ll customize the app’s behaviors across the different environments.

Creating Record Stores for Each Environment

Now that your environments are ready, you’ll store the hatchlings’ records in different UserDefaults keys — one per environment. Since the app stores data locally, UserDefaults here is the app’s “back end”.

Time to add the UserDefaults key in your environment’s configuration files.

In Debug.xcconfig, add:

USER_DEFAULTS_RECORDS_KEY = HatchlingsRecords-Debug

In Staging.xcconfig, add:

USER_DEFAULTS_RECORDS_KEY = HatchlingsRecords-Staging

In Release.xcconfig, add:

USER_DEFAULTS_RECORDS_KEY = HatchlingsRecords

The key name changes in each environment.

Next, add the following property below to the Info.plist of NinjaCounter and extension.

Key:

USER_DEFAULTS_RECORDS_KEY

Value:

$(USER_DEFAULTS_RECORDS_KEY)

Info.plist

After that, open UserDefaultsHelper.swift and replace the declaration of recordsKey with:

static private let recordsKey = Config
  .stringValue(forKey: "USER_DEFAULTS_RECORDS_KEY")

Just as you obtained the UserDefaults suite name from the build settings, here, in the changes above, you replaced the hard-coded UserDefaults key with the corresponding build settings values using stringValue(forKey:).

Finally, you’re ready to test your changes! Change the active scheme’s Build Configuration to Debug.

Build and run. Add a Donatello record.

NinjaCounter app showing data

Next, change the Build Configuration to Release. Build and run.

NinjaCounter app

You don’t see Donatello‘s record because you stored it in the Debug store. Change the Build Configuration back to Debug. Build and run.

NinjaCounter app showing data

The record you stored while running with the debug build configuration is there. Now, you know your environments work as intended.

Does this remind you of a different approach? Look at the code below.

#if DEBUG
let recordsKey = "HatchlingsRecords-Debug"
#elseif STAGING
let recordsKey = "HatchlingsRecords-Staging"
#else
let recordsKey = "HatchlingsRecords"
#endif

Conditional compilation is a popular practice. However, using it to manage constants across environments has a downside where you mix configurable constants with the code. Using build settings to store these values and use them in code is a good alternative.

That was easy! Having settings in configuration files not only simplifies managing build settings, but also lets you externalize settings from Xcode. This makes them portable and makes tracking changes in source control more convenient.

In the next section, you’ll create configuration files for each target.

Using Configuration Files for Each Target

The app is now fully functional in all three environments. But what if you need to create or externalize a target-specific build setting for the WidgetExtention? Next, you’ll see how to create configuration files for the widget target.

In the Config files group, create the following configuration files:

  • WidgetBase.xcconfig:
    #include "Base.xcconfig"
    
    PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_IDENTIFIER).widget
    
  • WidgetDebug.xcconfig:
    #include "Debug.xcconfig"
    #include "WidgetBase.xcconfig"
    
  • WidgetStaging.xcconfig:
    #include "Staging.xcconfig"
    #include "WidgetBase.xcconfig"
    
  • WidgetRelease.xcconfig:
    #include "Release.xcconfig"
    #include "WidgetBase.xcconfig"
    

Here, you set the widget PRODUCT_BUNDLE_IDENTIFIER by appending BASE_BUNDLE_IDENTIFIER to your base setting.

Next, set the files to their respective environments for the WidgetExtension target in the Project Editor as below.

  • Debug
    • Project: Base
    • NinjaCounter: Debug
    • WidgetExtension: WidgetDebug
  • Staging
    • Project: Base
    • NinjaCounter: Staging
    • WidgetExtension: WidgetStaging
  • Release
    • Project: Base
    • NinjaCounter: Release
    • WidgetExtension: WidgetRelease

Build configuration with custom widget configuration

Note: You don’t need to set WidgetBase.xcconfig because the other files include it.

Finally, return to the Project Editor and change the Product Bundle Extension setting for the WidgetExtension target to:

$(inherited)

After that, change the active scheme to WidgetExtension. Ensure the build configuration is Debug. Build and run.

Simulator showing the Widget. The Widget show the latest record added

The widget now shows the latest record you added for Donatello. Even though WidgetDebug.xcconfig doesn’t contain any settings, it includes the settings from WidgetBase.xcconfig and Debug.xcconfig. That’s why it worked when you set Debug.xcconfig for the widget.

Differentiating the App Icon for Non-Release Builds

Some stakeholders, like testers and QA engineers, use multiple builds of an app. For example, they might use the App Store build and a Staging build provided by developers or TestFlight. To make the lives of your colleagues easier, your next step will be to change the app icon to distinguish Debug and Staging builds from Release builds.

In the project, you’ll find another app icon set called AppIcon-NonProd in the app’s Assets.xcassets.

Assets catalog

To set the name of the asset catalog app icon set, you need to modify ASSETCATALOG_COMPILER_APPICON_NAME in the configuration files.

In Debug.xcconfig and Staging.xcconfig, add:

ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-NonProd

In Release.xcconfig, add:

ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
Note: As you’ve seen in the Project Editor’s Build Settings tab and in Info.plist, there are numerous build settings. You don’t have to remember the names of each one. Instead, use the build settings reference from the Apple documentation.

Set the active scheme to the NinjaCounter. Set the Build Configuration to Debug. Build and run. Enter the home screen.

iOS simulator home screen

The icon didn’t change because the Target Build Settings have higher precedence. To fix this, go to the Build Settings of NinjaCounter’s target from the project editor and locate Asset Catalog App Icon Set Name.

App build settings

In the configuration file’s scope, you can see the values you added to the configuration files. However, the setting resolves to the value at the target scope.

To fix this, change the parent value at the app target scope to $(inherited).

Changing build setting value to $(inherited)

The resolved values match the configuration files now.

Build and run. Enter the home screen.

iOS simulator home screen

The app now shows the icons from the AppIcon-NonProd app icon set.

Note: In the widget’s asset catalog, there’s an empty app icon set named AppIcon-NonProd. Although it’s not used, it solves a compile time error where Xcode will complain that AppIcon-NonProd doesn’t exist in widget’s asset catalog. This is because the widget target inherits ASSETCATALOG_COMPILER_APPICON_NAME from the app target and Xcode will check the value at compile time.

Where to Go From Here?

You can download the final project using the Download Materials button at the top or bottom of this tutorial.

In this tutorial, you used .xcconfig files to externalize build settings. You covered:

  • Managing settings across different build configurations.
  • Multiple approaches to using configuration files for different environments and targets.
  • Accessing build settings from code.

For more about build configuration and app distribution best practices, check out our iOS App Distribution & Best Practices book.

Here are some more helpful resources from Apple’s documentation:

I hope you’ve enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

4 ratings

More like this

Contributors

Comments