Conditional Compilation, Part 4: Deployment Targets

Part 4 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

Recently I was thinking about the idea I’d posted on simplifying backwards compatibility in Swift, and was also thinking about some of the principles of kindness that I wrote about in my article on API design.

As I was mulling these over, an idea occurred to me: I can improve the process of removing backwards compatibility shims by using conditional compilation to remind me when they’re no longer necessary!

The Premise

SwiftUI was introduced in iOS 13/macOS 10.15, and we commonly refer to that release as “SwiftUI 1.0”. Over the intervening years, we’ve had SwiftUI 2.0 and SwiftUI 3.0. Each release has added more features, as well as provided additional opportunities for app developers to back-deploy features as they’re building apps and adopting new APIs. In my blog post on backwards compatibility, I introduced the idea of a Backport type to serve as a namespace for these sorts of compatibility shims.

But … when those shims are no longer necessary, how do we remember that we should take them out? It’s really easy to forget that they’re there and allow unnecessary cruft to build up in a codebase over time.

Wouldn’t it be cool if we could use the compiler to help us know when the code wasn’t necessary anymore?

We’ve seen in previous posts how we can provide “compilation conditions” to use with #if statements in our codebase, like #if BUILDING_FOR_DEVICE, #if BUILDING_FOR_APP_EXTENSION, and so on. We’re going to come up with a way to that will allow us to specify #if TARGETING_SWIFTUI_1 or #if TARGETING_SWIFTUI_2 in our code, and use that to leave messages to our future selves.

Build Setting Transformations

Every app you build in Xcode has a “deployment target”, which is the minimum operating system version you allow your app to run on. This value is defined by the MACOSX_DEPLOYMENT_TARGET build setting (or IPHONEOS_DEPLOYMENT_TARGET, TVOS_DEPLOYMENT_TARGET, or WATCHOS_DEPLOYMENT_TARGET settings, depending on the platform you’re targeting). The value of this build setting is the operating system version number, like 10.15 or 8.3 or whatever.

We can append this value to other build settings via substitution, but we quickly run in to some issues:

_SWIFTUI_VERSION_10.15 = 1
_SWIFTUI_VERSION_11.0 = 2
_SWIFTUI_VERSION_12.0 = 3

_SWIFTUI_VERSION = $(_SWIFTUI_VERSION_$(MACOSX_DEPLOYMENT_TARGET))

If we do this, we get a compilation error! As it turns out, . is not a legal value to put into build setting names. Fortunately, we can transform the build setting value before substituting it.

Transformation operators are appended to the build setting name, after a : character. The list of supported operators is below¹.

Operator Transformation
identifier A C identifier representation suitable for use in source code.
c99extidentifier Like identifier, but with support for extended characters allowed by C99.
rfc1034identifier A representation suitable for use in a DNS name.
quote A representation suitable for use as a shell argument.
lower A lowercase representation.
upper An uppercase representation.
standardizepath The equivalent of calling -stringByStandardizingPath on the string.
base The base name of a path - the last path component with any extension removed.
dir The directory portion of a path.
file The file portion of a path.
suffix The extension of a path including the . divider.

And, these operators can be chained by concatenating another : and operator name. We’ll use these transformations to come up with a better format for our deployment target value.

If we look at the value, such as 10.15, we’ll see that it kind of looks like a file name: a file named 10 with an extension of 15. We can abuse leverage some of the file-based operators to extract the major and minor values:

DEPLOYMENT_TARGET_NUMBER_MAJOR = $(MACOSX_DEPLOYMENT_TARGET:base)
DEPLOYMENT_TARGET_NUMBER_MINOR = $(MACOSX_DEPLOYMENT_TARGET:suffix:c99extidentifier)
DEPLOYMENT_TARGET_NUMBER = $(DEPLOYMENT_TARGET_NUMBER_MAJOR)$(DEPLOYMENT_TARGET_NUMBER_MINOR)

If we do this, we end up with the DEPLOYMENT_TARGET defined as 10_15. Unfortunately, we can’t use c99extidentifier directly, because that strips the leading number of the deployment target. So we resort to this “file” approach (getting the “basename” of the value and its “suffix”, and then using c99extidentifier to turn the . into a _) to get our transformed value.

The Setup

Now that we can transform our deployment target into a safe value, we can build up our settings:

_SWIFTUI_VERSION_10_15 = 1
_SWIFTUI_VERSION_11_0 = 2
_SWIFTUI_VERSION_12_0 = 3

_SWIFTUI_VERSION = $(_SWIFTUI_VERSION_$(DEPLOYMENT_TARGET_NUMBER))

For completeness, we can define these values for every platform:

_PLATFORM =
_PLATFORM[sdk=mac*] = MACOSX
_PLATFORM[sdk=iphone*] = IPHONEOS
_PLATFORM[sdk=appletv*] = TVOS
_PLATFORM[sdk=watch*] = WATCHOS

// Sanitize the numeric deployment target value
_DEPLOYMENT_TARGET = $($(_PLATFORM)_DEPLOYMENT_TARGET)
DEPLOYMENT_TARGET_NUMBER_MAJOR = $(_DEPLOYMENT_TARGET:base)
DEPLOYMENT_TARGET_NUMBER_MINOR = $(_DEPLOYMENT_TARGET:suffix:c99extidentifier)
DEPLOYMENT_TARGET_NUMBER = $(DEPLOYMENT_TARGET_NUMBER_MAJOR)$(DEPLOYMENT_TARGET_NUMBER_MINOR)

// The naming scheme is "_SWIFTUI_VERSION_" + platform name + "_" + os version
_SWIFTUI_VERSION_MACOSX_10_15 = 1
_SWIFTUI_VERSION_MACOSX_11_0 = 2
_SWIFTUI_VERSION_MACOSX_12_0 = 3

_SWIFTUI_VERSION_IPHONEOS_13_0 = 1
_SWIFTUI_VERSION_IPHONEOS_14_0 = 2
_SWIFTUI_VERSION_IPHONEOS_15_0 = 3

_SWIFTUI_VERSION_TVOS_13_0 = 1
_SWIFTUI_VERSION_TVOS_14_0 = 2
_SWIFTUI_VERSION_TVOS_15_0 = 3

_SWIFTUI_VERSION_WATCHOS_6_0 = 1
_SWIFTUI_VERSION_WATCHOS_7_0 = 2
_SWIFTUI_VERSION_WATCHOS_8_0 = 3

// Get the SwiftUI version based on the platform and deployment target
_SWIFTUI_VERSION = $(_SWIFTUI_VERSION_$(_PLATFORM)_$(DEPLOYMENT_TARGET_NUMBER))

// Define the values to be used as compilation conditions, based on the SwiftUI version
_SWIFTUI_1 = TARGETING_SWIFTUI_1 TARGETING_SWIFTUI_2 TARGETING_SWIFTUI_3
_SWIFTUI_2 = TARGETING_SWIFTUI_2 TARGETING_SWIFTUI_3
_SWIFTUI_3 = TARGETING_SWIFTUI_3

SWIFTUI = $(_SWIFTUI_$(_SWIFTUI_VERSION))

SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(SWIFTUI)

Whew, that’s a lot! But, we’ve got something pretty cool now. Let’s put it to use!

Usage

With values like TARGETING_SWIFTUI_1 or TARGETING_SWIFTUI_3 in the SWIFT_ACTIVE_COMPILATION_CONDITIONS, we can use them as part of #if conditionals:

extension Backport where Content: View {

    #if TARGETING_SWIFTUI_2 || TARGETING_SWIFTUI_1
    // we're deploying to macOS < 12
    @ViewBuilder func badge(_ count: Int) -> some View {
        if #available(macOS 12, *) {
            content.badge(count)
        } else {
            content
        }
    }
    #else
        #error("We're only targeting SwiftUI 3+. Backporting `.badge(_:)` is unnecessary and should be removed.")
    #endif

}

Now as we adjust our deployment target, the active compilation conditions will change depending on the OS version (and platform) we’re targeting. If we move our deployment target up such that we’re no longer targeting SwiftUI 2 (ie, macOS 11.0, iOS 14, tvOS 14, or watchOS 7), then the compiler will stop building this badge(_:) method and instead will produce an error telling us to clean up the unnecessary code.

This screenshot shows what happens when we update our deployment target to macOS 12:

After adjusting the deployment target, our code now produces a compilation error.

This does mean that the first time we change our deployment target, we’ll get a bunch of compilation errors. But given the nature of how Backport is implemented, this should be a relatively quick process to move past. (And of course, you’re welcome to use #warning instead of #error).

Shortcomings

There are a couple of small drawbacks with this specific approach.

First, every new SwiftUI version will need new values in your configuration file. As new SDKs come, there’s a small amount of bookkeeping necessary to make sure the various condition values get defined.

Second, if you’re targeting specific minor OS version (macOS 12.3, for example), then you also have to fill out more values for the SwiftUI versions. You could probably work around this by only keying off the major OS version number, but that’s a decision that’s dependent on your use-case and how far back you need to deploy.

Finally, using #error (as demonstrated above) means that the task of updating a deployment target now becomes a little tedious: you have to fix all of these build errors before continuing; adopting changes in a piecemeal fashion becomes more difficult (although this can be mitigated by using #warning instead).

Wrapping Up

When we write code, it’s always nice to leave things for future maintainers to guide them down the correct path and avoid pitfalls. This typically takes the form of comments, but with a bit of clever application we can use the compiler to help as well. This allows us to leave guideposts that can keep our code clean, or leave warnings and reminders. Maybe you want to see if a particular workaround is still necessary in a system framework? Leave a #if in your code like this that reminds you to check the next time you update your base SDK (SDK_VERSION). Working around a specific bug in Xcode? Leave a reminder for yourself based on the XCODE_VERSION_MAJOR (or XCODE_VERSION_MINOR or XCODE_VERSION_ACTUAL) to check if it’s still necessary. Maybe you want to remind yourself to revisit some code when your MARKETING_VERSION goes from 1.x to 2.0? Leave yourself a compiler note!

Your future self will thank you.


¹ - This table was taken from Matt Stevens’ blog post here: http://codeworkshop.net/posts/xcode-build-setting-transformations.


Related️️ Posts️

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