Conditional Compilation, Part 3: App Extensions

Part 3 in a series on conditional compilation:

  1. Conditional Compilation, Part 1: Precise Feature Flags
  2. Conditional Compilation, Part 2: Including and Excluding Source Files
  3. Conditional Compilation, Part 3: App Extensions
  4. Conditional Compilation, Part 4: Deployment Targets

App Extensions tend to somewhat problematic when it comes to conditional compilation, because there are methods and functionality that are not available in app extensions. For example, app extensions don’t have a UIApplication instance, and so the UIApplication.shared property is marked as NS_EXTENSION_UNAVAILABLE_IOS(...).

Usually this isn’t too bad, but when you’re writing an extension, you can come across situations where you’re trying to share code between a primary app target and its extensions, and your usage of UIApplication comes back to haunt you. Maybe you’ve written your code to use things like UIApplication.beginIgnoringInteractionEvents() or to modify the network activity indicator visibility.

Yes, you can refactor your code to inject some sort of “network activity indicator controller” or “touch interaction manager” instead of calling out to the UIApplication singleton. But it’s also true that sometimes the cost of refactoring to a more “pure” pattern outweighs the cost of actually having the more “pure” architecture.

(As an iOS architect who’s all about clean code, that last sentence was kind of painful to admit to myself and write 😉)

However, because of the way that the NS_EXTENSION_UNAVAILABLE macro works, you can only use it on types, methods, and properties. You can’t do something like:


func mySharedCode() {

    #if !BUILDING_FOR_APP_EXTENSION
    UIApplication.shared.beginIgnoringInteractionEvents()
    #endif
    
    // do some view stuff

}

In this post, we’re going to figure out how we can do exactly that.

Discovering the Condition

If you dig in to the NS_EXTENSION_UNAVAILABLE macro, you’ll see that it eventually boils down to a compiler attribute (__attribute__...), which is matched to a flag passed in to the compiler (-application-extension for swiftc, and -fapplication-extension for clang). That, unfortunately, doesn’t help us.

However, if we can find an environment variable that is different based on whether we’re building an extension target or an app target, then we can get Clever™.

To do so, create a new Xcode project that has a bare iOS application target, and add an “Action” extension to it. We’re going to use this as a way to discover the differences in how compilation happens between the two targets.

Next, go in to the “Build Phases” of each target and add a “Run Script Build Phase” to each one. We don’t need any script to actually run, because by default, a “Run Script” build phase will dump out all the environment variables in to the build logs for us to inspect.

Now, compile both the app and the action extension.

What we’re looking for next is in the “Report Navigator” (⌘9): the build logs. If you look in the compilation steps for each target, you’ll a see single line that says “Run custom shell script ‘Run Script’ 0.1 seconds”. This is the step during building where it ran that “Run Script” build phase we just added.

Expand that step by clicking on the button on the right side of that line, and you’ll see a huge list of export lines, like this:

export ACTION=build
export AD_HOC_CODE_SIGNING_ALLOWED=YES
export ALTERNATE_GROUP=staff
export ALTERNATE_MODE=u+w,go-w,a+rX
export ALTERNATE_OWNER=dave
export ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=NO
export ALWAYS_SEARCH_USER_PATHS=NO
export ALWAYS_USE_SEPARATE_HEADERMAPS=NO
...

This is the list of environment variables available to scripts and compilation for us to dig in to and inspect.

If you compare the two lists of environment variables from each “Run Script” phase, you’ll see a few differences. However, one that should immediately jump out to you is this line:

export APPLICATION_EXTENSION_API_ONLY=...

For extension targets, this variable gets exported with a value of YES, and for regular targets this gets exported with a value of NO.

This is what we need.

Building the Flags

We’re going to use this APPLICATION_EXTENSION_API_ONLY flag to build some conditional build settings in an xcconfig file.

In previous posts, we’ve seen conditional compilation happen by using the “subscripting” syntax on flags to conditionalize based on the SDK or the architecture. That subscripting syntax doesn’t let you conditionalize based on an arbitrary build setting, but there’s another way we can do that.

In our xcconfig file, we want to add a couple of lines:

_APP_EXTENSION_NO = 0
_APP_EXTENSION_YES = 1

This just defines some basic “on/off” values for us.

Next, we’re going to pick one of these based on the build setting we discovered:

APP_EXTENSION = $(_APP_EXTENSION_$(APPLICATION_EXTENSION_API_ONLY))

This is the magic line! The right-hand side gets evaluated twice, because we’re nesting substitutions. As the compiler loads the file, it first finds $(_APP_EXTENSION_$(APPLICATION_EXTENSION_API_ONLY)). The compiler then starts iteratively resolving this value. It first finds the inner-most substitution, and replaces it:

APP_EXTENSION = $(_APP_EXTENSION_$(APPLICATION_EXTENSION_API_ONLY))

This gets resolved to:

APP_EXTENSION = $(_APP_EXTENSION_YES) // or $(_APP_EXTENSION_NO), if we're building a regular target

Because the compiler sees more substitutions remain, it resolves again:

APP_EXTENSION = 1 // or 0, if we're building a regular target

From here, we can refer back to the previous posts on SWIFT_ACTIVE_COMPILATION_CONDITIONS and GCC_PREPROCESSOR_DEFINITIONS to add conditions for Swift and Objective-C (respectively).

When you do the work for both languages, you end up with this:

_APP_EXTENSION_GCC_YES = 1
_APP_EXTENSION_GCC_NO = 0
APP_EXTENSION_GCC = BUILDING_FOR_APP_EXTENSION=$(_APP_EXTENSION_GCC_$(APPLICATION_EXTENSION_API_ONLY))
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) $(APP_EXTENSION_GCC)

_APP_EXTENSION_SWIFT_YES = BUILDING_FOR_APP_EXTENSION
_APP_EXTENSION_SWIFT_NO =
APP_EXTENSION_SWIFT = $(_APP_EXTENSION_SWIFT_$(APPLICATION_EXTENSION_API_ONLY))
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(APP_EXTENSION_SWIFT)

Thus, when we’re building for an extension, we’ll have BUILDING_FOR_APP_EXTENSION=1 exported to Clang, and BUILDING_FOR_APP_EXTENSION exported to Swift.

And when we’re building for a regular target, we’ll have BUILDING_FOR_APP_EXTENSION=0 exported to Clang, and   (an empty setting) exported to Swift.

Using the Condition

Because we’re exporting the =1 or =0 version in Objective-C, we can write our conditions in Objective-C just like we would in Swift:

#if BUILDING_FOR_APP_EXTENSION
// do something specific for app extensions
#else
// do something specific for regular app targets
#endif

Conclusion

There are a couple things I want to leave you with:

  1. Please use this sparingly. Having power to conditionalize code like this is a two-edged sword. It makes it trivially easy to put in “band-aid” fixes for situations. However, if taken too far, it also makes for extremely messy and hard-to-maintain code. Some of the most complex and “hairy” code I’ve ever worked on was because it had these sorts of checks scattered everywhere. It was extremely difficult to reason about what code path would actually get run.

  2. The trick of adding an empty “Run Script Build Phase” to a target’s build phases is a really handy trick. It makes it really easy to dig through and find variables and values to help you modify your compilation behavior.


Related️️ Posts️

Conditional Compilation, Part 4: Deployment Targets
Conditional Compilation, Part 2: Including and Excluding Source Files
Conditional Compilation, Part 1: Precise Feature Flags