Currently, Secrets has six different build configurations: Debug, Beta, Staging, Testing, App Store and Setapp1. All of these use a different set of parameters to customize the build. For example, whether or not In-App Purchases are included, what’s the API token to report crashes, should debug logging be enabled, what’s the bundle identifier, should the app check for updates, etc. For so many build configurations and parameters, sifting through the Xcode’s build settings view quickly becomes cumbersome. And because Xcode stores them inside the project file they are harder to track and much easier to commit unintended changes to source control.
Fortunately, we can use Xcode configuration files (.xcconfig) for a more manageable solution. But that’s just half the battle. Your code can quickly become hard to read and maintain when you have several preprocessor instructions intertwined.
#if BETA
[[BITHockeyManager sharedHockeyManager] configureWithIdentifier:@"aaaa…"];
#elif APPSTORE
[[BITHockeyManager sharedHockeyManager] configureWithIdentifier:@"bbbb…"];
#elif SETAPP
[[BITHockeyManager sharedHockeyManager] configureWithIdentifier:@"ccccc…"];
#endif
This is ugly. We can do better.
Quick intro to Xcode configuration files
If you’re not familiar with Xcode configuration files, they are simple plain text files with the .xcconfig extension that you can include in your project. You then assign them to your build configurations and/or targets to affect their build settings.
The syntax is very simple, and you can quickly copy settings from Xcode’s project editor and paste them on the configuration file to get started.
#include "Common.xcconfig"
API_TOKEN = 2a47bc8b8ea02a85d86968bea5768cc14d08d891
But the best thing about .xcconfig files is that you can include other configuration files with the #include
directive. This allows you, for example, to have one file, say Common.xcconfig, with default settings and then simply override what you need for each build configuration.
Worth noting is that the precedence for evaluation configuration options is (from highest to lowest precedence): Target, .xcconfig for the Target, Project, .xcconfig for the Project file, Platform defaults. So if you notice some setting you placed on your configuration file isn’t being applied, remember to check if it has been overridden. For a more detailed guide on Xcode configuration files see “The Unofficial Guide to xcconfig files”.
Making the most of configuration files
Now that we know how to create and apply Xcode configuration files lets explore how they can be used.
Compiler and build options
Obviously they can be used to set compiler and build options like Optimization Level, Deployment Target and Product Bundle Identifier. For example, Secrets has different bundle identifiers for Beta, App Store and Setapp build configurations.
Conditional compilation
Another common scenario is for conditional compilation of code. You’ve probably already written or witnessed code like this:
#ifdef DEBUG
NSLog(@"Some debug message");
#endif
Xcode’s project templates already define de DEBUG
preprocessor macro for the Debug build configuration. But when you have many different build configurations, and not just Debug and Release, that’s usually not enough. Using configuration files we can easily define a RELEASE_TYPE
for each.
We can add all the different release types to the Common.xcconfig file:
// Common.xcconfig
// treat the following as a variable
RELEASE_TYPE = 0
// treat the following as constants
DEBUG_RELEASE = 1
BETA_RELEASE = 2
APPSTORE_RELEASE = 3
And then simply override RELEASE_TYPE
on the build configuration specific files. Take “Beta.xcconfig” for example:
// Beta.xcconfig
#include "Common.xcconfig"
RELEASE_TYPE = $(BETA_RELEASE)
To let the compiler know about these macros we just set the GCC_PREPROCESSOR_DEFINITIONS
on “Common.xcconfig”:
GCC_PREPROCESSOR_DEFINITIONS = RELEASE_TYPE=$(RELEASE_TYPE) DEBUG_RELEASE=$(DEBUG_RELEASE) BETA_RELEASE=$(BETA_RELEASE) APPSTORE_RELEASE=$(APPSTORE_RELEASE)
Finally, you can test these in code with:
#if RELEASE_TYPE == BETA_RELEASE
NSLog(@"This is a beta build");
#endif
Passing values
Last but not least, you can use values defined on .xcconfig files at runtime. Lets take the API_TOKEN
example from above. If you’ve already added that to the GCC_PREPROCESSOR_DEFINITIONS
setting then we can actually assign that variable to an NSString constant and use it at runtime. We just need some macros to transform that preprocessor definition into an NSString
first.
#define QUOTE(name) #name
#define STR(macro) QUOTE(macro)
#ifndef API_TOKEN
#error "API_TOKEN preprocessor macro must be defined"
#else
#define API_TOKEN_VALUE STR(API_TOKEN)
#endif
NSString * const kAPIToken = @""API_TOKEN_VALUE;
Here we first transform the API_TOKEN
value into a C string and finally use the handy string concatenation feature of the compiler to immediately assign it to an NSString
constant.
Also note we throw an error if API_TOKEN
isn’t defined. I definitely recommend doing this type of check for every value you pass in via the preprocessor just in case you inadvertently delete it or forget to define it for a new build configuration.
The build configuration class
Instead of having these floating around your code, what I like to do is tie them all together under a BuildConfig
class. It’s my one stop shop for all build related options. Here’s an example:
typedef NS_ENUM(NSUInteger, ReleaseType) {
ReleaseTypeDebug = DEBUG_RELEASE,
ReleaseTypeBeta = BETA_RELEASE,
ReleaseTypeAppStore = APPSTORE_RELEASE,
};
@interface BuildConfig : NSObject
@property(class, nonatomic, readonly) BuildConfig *currentConfig;
@property (strong, readonly) NSString *apiToken;
@property (readonly) ReleaseType releaseType;
@end
With this in place the ugly code mentioned in the beginning now becomes much cleaner:
[[BITHockeyManager sharedHockeyManager] configureWithIdentifier:BuildConfig.currentConfig.apiToken];
What about Swift?
Swift doesn’t have a preprocessor like C does. You can still do conditional compilation via the SWIFT_ACTIVE_COMPILATION_CONDITIONS
setting but you can’t pass any values.
For example, if you want the compiler to only include some code for the AppStore build configuration you need to add a compilation condition, such as APPSTORE
.
// AppStore.xcconfig
#include "Common.xcconfig"
RELEASE_TYPE = $(APPSTORE_RELEASE)
SWIFT_ACTIVE_COMPILATION_CONDITIONS = APPSTORE
And test for it in your Swift code.
#if APPSTORE
SKStoreReviewController.requestReview()
#endif
To actually pass values to be used at runtime like the API_TOKEN
mentioned above, we simply need to expose the BuildConfig
Objective-C class to Swift by including it in the Objective-C Bridging Header file.
That’s it! You can find a sample project demonstrating all that was discussed above here. And if you like this post or have any comments, let me know on Twitter.