Davide De Rosa

Cross-platform Swift: Platform specifics

Cross-platform Swift: Platform specifics

part of a series: < prev | next >

Table of contents (ongoing)

  1. Introduction
  2. Combine
  3. Core libraries
  4. Platform specifics

Very few developers treat the SwiftPM manifest, i.e. the Package.swift file, as what its extension suggests. If you are used to enumerate static products and targets, remember that the SwiftPM manifest is a fully legit Swift program, and as such it allows for plenty of control.

Conditionals in Package.swift

In a library whose aim is to target multiple platforms, and try different paths along the way, a flexible manifest is of great help. Partout uses generic Swift routines overall, but in some areas it needs to differentiate how things are done based on the platform it’s running on.

Think of:

  • DNS resolution (CFHost vs POSIX)
  • Pseudo-random number generation (SecRandom vs getrandom)
  • Access to the filesystem (FileManager vs FILE *)

In some cases, even provide additional behavior that is not available on other platforms. If you started our Swift library on Apple platforms, like it’s often the case, you may have used plenty of frameworks that are tighly bound to Apple. Examples:

  • UserDefaults
  • The keychain
  • iCloud
  • Camera

Building on non-Apple

While it’s true that the Foundation framework may provide some platform-agnostic API (e.g. UserDefaults), it’s better to take full control of what’s being distributed based on our choices. If you care to split platform-specific code into separate targets, you can leverage the condition parameter of a dependency to fine-tune per-platform dependencies:

.target(
    name: "SomeMultiCode",
    dependencies: [
        .target(name: "SomeiOSDep", condition: .when(platforms: [.iOS])),
        .target(name: "SomeWindowsDep", condition: .when(platforms: [.windows])),
        // ...
        // same with .product
    ]
)

Invoking swift build --target <some> will comply with the platform conditionals, whereas this (still) seems to be a problem for tests. That’s because SwiftPM attempts to build all targets regardless of the conditionals, then compose the filtered targets to assemble the final products, whereas it shouldn’t build some targets in the first place.

A mitigation for this problem is to wrap conditional code in a canImport condition:

// make sure that the import is available
#if canImport(Security)
import Security

// do stuff with the Apple Security framework
#endif

This is acceptable inside the library, but how should consumers deal with all these inconvenient differences?

Dependency factory

A simple solution to avoid conditionals in consumer apps is the use of a factory to instantiate the right dependencies for our environment. The factory would internally pick the suitable implementation for the current platform, lifting the burden of the choice from the user of the library.

Imagine that we want to provide a common interface for the persistent storage of the library. A natural choice on Apple would be Core Data, and maybe Realm or plain SQLite on Windows and Linux. Furthermore, we may want to support CloudKit synchronization on Apple devices, and omit that feature elsewhere.

In Package.swift:

.target(
    name: "MyPersistence"
),
.target(
    name: "MyApplePersistence",
    dependencies: ["MyPersistence"]
),
.target(
    name: "MyOtherPersistence",
    dependencies: ["MyPersistence"]
),
.target(
    name: "MyLibrary",
    dependencies: [
        .target(name: "MyApplePersistence", condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS])),
        .target(name: "MyOtherPersistence", condition: .when(platforms: [.windows, .linux, .android])),
    ]
)

In MyPersistence we declare the generic persistence API:

public protocol Persistence {
    var supportsSynchronization: Bool { get }

    func save<T>(_ object: T) throws
}

that we then implement in CoreDataPersistence with Core Data, in the MyApplePersistence target:

import CoreData

public final class CoreDataPersistence: Persistence {
    init(path: String) {
        // ...
    }

    var supportsSynchronization: Bool {
        true
    }

    func save<T>(_ object: T) throws {
        // ...
    }
}

and RealmPersistence with Realm, in the MyOtherPersistence target:

import Realm

public final class RealmPersistence: Persistence {
    init(path: String) {
        // ...
    }

    var supportsSynchronization: Bool {
        false
    }

    func save<T>(_ object: T) throws {
        // ...
    }
}

In the MyLibrary umbrella target:

public enum Factories {
}

public protocol PersistenceFactory {
    func newPersistence(at path: String) throws -> Persistence
}

extension Factories {
    public static let persistence = PersistenceFactoryImpl()
}

#if canImport(MyApplePersistence)

// uses Core Data
private final class PersistenceFactoryImpl: PersistenceFactory {
    func newPersistence(at path: String) throws -> Persistence {
        try CoreDataPersistence(path: path)
    }
}

#elseif canImport(MyOtherPersistence)

// uses Realm
private final class PersistenceFactoryImpl: PersistenceFactory {
    func newPersistence(at path: String) throws -> Persistence {
        try RealmPersistence(path: path)
    }
}

#else

// unsupported platform

#endif

Finally, in the consumer app we would do this regardless of the operating system:

func someFunction() {
    let path = "SomeFile.db"
    let persistence = Factories.persistence.newPersistence(at: path)
    // ...
    do {
        print("Supports synchronization: \(persistence.supportsSynchronization)")
        try persistence.save("SomeString")
    } catch {
        // ...
    }
}

The library would just take care of the different implementations.

Bottom line

It’s easy to overcome the platform differences with SwiftPM conditionals and simple design patterns. If you need even more customizations, don’t be afraid to tweak the manifest further because it’s good old Swift, not static YAML. Even just an if may do wonders and resolve convoluted package layouts.

In the next article, we’ll start the long but inevitable journey of Swift interoperability with C and other languages.