Conditional Compilation, Part 4: Deployment Targets
Part 4 in a series on conditional compilation:
- Conditional Compilation, Part 1: Precise Feature Flags
- Conditional Compilation, Part 2: Including and Excluding Source Files
- Conditional Compilation, Part 3: App Extensions
- 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:
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. ↑