WWDC 2019 did, without a doubt, mark a major milestone in the continued evolution of Apple’s suite of developer tools and frameworks. iPad apps are coming to the Mac through Catalyst, reactive programming is now built-in with Combine, and — perhaps most significant of all — Apple is moving into the world of declarative UI development with the launch of SwiftUI.
While many of the new frameworks and APIs are incredibly exciting, adopting them within an existing project could prove to be quite challenging — especially if we’re working on a product that we’re looking to keep shipping as we start implementing some of these new technologies.
Since all of the new APIs announced at WWDC require the Xcode 11 beta, and only work on the latest versions of Apple’s various operating system — we’ll most likely need to figure out how to set up a workflow that’ll both let us keep shipping to production with Xcode 10, while still being able to work on new features using Xcode 11.
An initial idea on how to set up such a workflow might be to use multiple branches. Let master keep using the Xcode 10-based tools, and create a new xcode11 branch for experimentation and adoption of the new APIs. While doing so does have a number of benefits (for example, it leaves the currently shipping code base completely free of any non-production code) — it also comes with a major downside — those two code bases are bound to drift apart, sooner rather than later.
That might not be a huge issue right now, but come September (or whenever Xcode 11 will be released out of beta), we’ll probably be looking at a quite enormous effort to merge all of that new code — based on completely new APIs — into our existing master codebase, which will also most likely introduce a fair amount of bugs.
Parallel Xcode versions
Instead, let’s explore how we could keep working only on master — both on our shipping code, and on the code that’ll adopt any of the new APIs and frameworks — while still being able to maintain a smooth workflow.
Our first step will be to ensure that our Continuous Integration setup will build our app using both Xcode 10 and 11 on every change. That’s really important, since if we keep running our CI using only Xcode 10, then chances are high that we’ll keep breaking the Xcode 11-based builds when introducing various changes — which would be really frustrating for whoever is working on adopting the new system features.
Thankfully, Bitrise supports setting up multiple instances of any given app — meaning that we can run two separate CI pipelines in parallel — one for Xcode 10, and one for Xcode 11. That way everyone on the team will have to make sure that the changes they submit are compatible with both Xcode versions, which helps keep both builds green at all times.
However, while Bitrise’s web UI is a fantastic tool for setting up and configuring workflows, we wouldn’t necessarily want to go through the process of setting up our entire app one more time — especially if we have created a highly custom workflow that would take a while to replicate.
The good news is that Bitrise supports defining workflows using a bitrise.yml file, which can be placed in the root directory of any project’s repository. Even better is that we can easily export any workflow or build that was set up using the visual web editor as such a yml file, and then simply place that file in our repository.
Once that’s done, we can then set up a new Bitrise app based on our already existing app’s repository, and it’ll pick up our yml file — giving us two distinct copies of our app’s workflow. To be able to tell our two app instances apart within Bitrise’s web UI, we can rename the new one we just created something like “MyApp-Xcode11”. Finally, we’ll simply change our new app’s stack to Xcode 11.0.x, and we’re ready to start building using the Xcode 11 beta.
Note that if you have 2 or more concurrencies you can achieve the same setup without having to create duplicate apps, by using concurrent builds according to the steps outlined in this article.
Two builds, one code base
Every time we’ll make a change, both of our Bitrise apps’ builds will now be triggered, giving us one build for each version of Xcode that we’re currently working with — perfect. However, since we’re using the same code base for both builds, we can’t simply start to use any of the new Xcode 11-specific APIs and frameworks, since those aren’t available when building using Xcode 10.
Thankfully, Swift has a number of built-in features to help us deal with situations like this. For example, say we want to start adopting SwiftUI in an iOS app. To be able to do that in a way that’s still 100% Xcode 10-compatible, we can use the canImport compiler check, in combination with the @available attribute — like this:
What the above does is to strip out all of the code between #if and #endif in case SwiftUI can’t be imported (which will be the case for our Xcode 10-based builds), and the @available attribute ensures that our MyContentView type can only be used within code paths that are guaranteed to only be executed on iOS 13 (or above).
Using the above technique we can now define iOS 13-specific types and functions, but how about using them within our existing code? That’s where if #available comes in, which lets us set up code paths that’ll only get executed when running on certain OS versions. For example, here’s how we could use that feature to replace our app’s current UIKit-based profile view with a SwiftUI-based one — only when running on iOS 13:
The beauty of the above approach is that we can make local tweaks and overrides wherever we want to start using some of the new SDK features — while leaving the rest of our code base intact. While it does require us to introduce a few conditionals and compiler checks here and there, it lets us keep evolving a single code base — rather than having to keep maintaining multiple ones through branches or forks.
There’s of course no silver bullet when it comes to project setup — especially when it comes to handling quite tricky situations like supporting multiple Xcode versions. However, whenever possible, having just a single source of truth when it comes to our code base gives us some really nice benefits — like avoiding huge merge conflicts in the future, not having to duplicate bug fixes, and ensuring that our app’s overall features and behaviors stay the same across versions.
Using two Xcode versions in parallel does come with a fair amount of extra work — such as having to use checks like canImport and #available wherever we want to start using new SDK features — but unless we’re planning to completely drop support for all older OS versions once the new ones ship this fall, it might be worth that extra work in order to ensure full compatibility.
What do you think? How do you usually work with multiple versions of Xcode, and do you prefer using separate branches, or running everything based on the same code base? Let me know on Twitter @johnsundell. And if you’re looking for a service to use for your continuous integration and automation needs, look no further than Bitrise — it’s my personal favorite.
Thanks for reading! 🚀