How do you structure one codebase for several Apple platforms: shared cores, UI leaves, #if os, canImport and @available?
Predict first
The same source is built for the tvOS simulator. What does it print?
#if os(tvOS)
print("tvOS")
#elseif os(watchOS)
print("watchOS")
#elseif os(visionOS)
print("visionOS")
#elseif os(iOS)
print("iOS")
#endif
#if targetEnvironment(simulator)
print("simulator")
#endif
#if canImport(UIKit)
print("UIKit")
#endif
#if canImport(WatchKit)
print("WatchKit")
#endif
- tvOS, simulator, UIKit
- iOS, simulator, UIKit
- tvOS, UIKit, WatchKit
Predict first
The same source built for the watchOS simulator:
#if os(tvOS)
print("tvOS")
#elseif os(watchOS)
print("watchOS")
#elseif os(visionOS)
print("visionOS")
#elseif os(iOS)
print("iOS")
#endif
#if targetEnvironment(simulator)
print("simulator")
#endif
#if canImport(UIKit)
print("UIKit")
#endif
#if canImport(WatchKit)
print("WatchKit")
#endif
- watchOS, simulator, UIKit, WatchKit
- watchOS, simulator, WatchKit
- iOS, simulator, UIKit
Predict first
And for the iOS simulator:
#if os(tvOS)
print("tvOS")
#elseif os(watchOS)
print("watchOS")
#elseif os(visionOS)
print("visionOS")
#elseif os(iOS)
print("iOS")
#endif
#if targetEnvironment(simulator)
print("simulator")
#endif
#if canImport(UIKit)
print("UIKit")
#endif
#if canImport(WatchKit)
print("WatchKit")
#endif
- iOS, simulator, UIKit
- iOS, UIKit, WatchKit
- iOS, simulator
Mental model. A multiplatform app is a house with one foundation and plumbing (the core) and different rooms for different guests: the TV room has no light switches (no touch), the watch room is a closet with one shelf. The conditions are the signs on the doors saying which room you are in.
The workable shape is a shared core with thin platform leaves. The core (domain models, business rules, networking and persistence, view models, formatting, feature flags, analytics interfaces) is plain Swift in one or more Swift packages and compiles for every platform. The leaves are the app targets (iOS, iPadOS, macOS, tvOS, watchOS, visionOS) containing navigation, input handling and the views, because those are where the platforms genuinely differ: touch versus a focus engine versus the Digital Crown versus a pointer, a few hundred pixels versus a 10-foot display, a process that runs for hours versus seconds. SwiftUI makes more of the UI shareable (views, state, previews), but a shared view still needs platform-specific adaptation, and some leaves (a tvOS player, a watch complication) are better written natively. Keep the shared core free of UI frameworks (no import UIKit in the core) so it builds everywhere, and put anything that touches UIApplication, AppKit, WatchKit or camera and sensors in the leaf, behind a protocol the core defines. Use packages with platform declarations (platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)]) so the compiler tells you when a dependency is not available on a platform.
Three language tools handle differences, and each has a different job. #if os(...) selects code by operating system at compile time: os(iOS), os(tvOS), os(watchOS), os(macOS), os(visionOS) (the same source prints “tvOS” when built for Apple TV and “watchOS” for a watch). #if canImport(Module) selects by whether a framework exists for this build, which is more precise than naming the OS (canImport(WatchKit) is true on watchOS, canImport(AppKit) only on macOS, and notice canImport(UIKit) is true on iOS, tvOS, visionOS, Catalyst and, since watchOS ships a small UIKit, on the watch too). #if targetEnvironment(simulator) and macCatalyst distinguish the build environment. @available(tvOS 17, *) and if #available check the OS version at run time and annotate APIs; @available(watchOS, unavailable) marks your own code as not for a platform. Principles: prefer canImport to os() when the real condition is a framework; keep conditionals at the edges (a small platform file per feature) instead of sprinkling them through logic; avoid #if inside view bodies where a protocol or an Environment value would do; remember that code in the unbuilt branch is not type-checked, so build every platform in CI; and when you shrink the CI matrix, test the core on one platform (fast, on macOS), build every leaf for every platform, and run UI tests on the platforms where behaviour differs.
The tvOS branch is selected, it is a simulator build, UIKit exists on tvOS and WatchKit does not.
The watchOS branch is selected. UIKit and WatchKit are both importable on watchOS.
iOS has UIKit but not WatchKit.
Common traps
- Importing UIKit or SwiftUI into the shared core. It stops compiling on some platforms and leaks UI into logic; keep the core UI-free.
- Using
#if oswherecanImportor a protocol would express the real condition. You hard-code assumptions that break on the next platform.
They might follow up with
- Why build every platform in CI? The code in a branch that is not built is not type-checked, so a platform can break unnoticed.
- What does
canImport(WatchKit)test? Whether that framework exists for the current build, which is true on watchOS.
Related questions
- 20.1.2 How do the platforms differ in input, process model and capabilities, and which entitlements and APIs change…
- 20.1.3 How do you run one shared core with several UI leaves on a single release train?
- 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…
How do the platforms differ in input, process model and capabilities… →