Table of contents (ongoing)
The very first goal of porting may sound simple: a successful build. Once you manage to just compile your package, the worst is definitely behind you.
Keep in mind that this is not a series about how to port Apple frameworks or SwiftUI to other platforms, it’s about the bare Swift language. I want to show you how Xcode may trick you into thinking that some patterns are a key part of Swift, whereas they should be avoided if you plan to leave the Apple ecosystem at some point.
Today, I’ll talk about a kind of infamous framework for Swift developers: Combine.
Combine is unofficially obsolete
When the compiler suddenly stopped at some occurrence of import Combine
, I was beaten. Combine has been a fundamental piece of reactive programming for the last 5+ years, and any recent Swift codebase uses Combine to some extent. The scary question was: to what extent was I using it?
Let me digress a moment. There is a big problem with Combine, and it’s not about the developers using it. Apple is well-known for disrupting its own frameworks regardless of any backward-compatibility, and Combine is one of those examples where Apple took a different turn without offering a full replacement. Initially, it seemed that SwiftUI was all about Combine, but the introduction of Concurrency made the poor framework an outcast. Unsurprisingly, ObservableObject
and @Published
are also unavailable outside Apple Swift, but if you’ve been a diligent programmer, you’ve probably learned that those constructs only make sense with SwiftUI.
The fact that Combine has never been integrated into the Swift language, reveals that Concurrency is how Swift (and Apple) wants you to perform asynchronous programming from now on. Does Swift natively offer a substitute for the long list of Combine operators? Hell, no, and that’s why people still use Combine.
Back to my question. Luckily, my use of Combine in Partout was quite basic, except for one .combineLatest3()
that was worth half the effort.
Porting to AsyncSequence
If you remember, the purpose of Combine is manipulating a sequence of asynchronous values. Swift offers implementations of AsyncSequence
like AsyncStream
and AsyncThrowingStream
to accomplish the same in a linear fashion, typical of the async/await model.
What in Combine was:
subscription = somePublisherOfStrings() // AnyPublisher<String, Never>
.removeDuplicates()
.sink { [weak self] in
print("String: \($0)")
}
becomes this with AsyncStream
:
subscription = Task { [weak self] in
var previous: String?
for await string in someStreamOfStrings() { // AsyncStream<String>
guard string != previous else {
continue
}
print("String: \($0)")
previous = string
}
}
Now, a typical way to spawn Combine publishers is with subjects (PassthroughSubject
and CurrentValueSubject
), that are multicast emitters of values. Multiple programs can subscribe to a subject, listen to its sequence of values, and manipulate them before delivery with the rich offer of Combine operators. We lack such a counterpart in Swift, so I went to roll out my own.
My PassthroughStream
and CurrentValueStream
implement a simple pub/sub pattern with AsyncStream
and strict Swift 6.1 Concurrency. They have become the building blocks of all my asynchronous publishers in cross-platform Swift, and by keeping behavior and naming close to Combine (e.g. the .send()
method), the refactoring was easier to manage.
Steps:
- Replace Combine subjects with subject streams
- Return an
AsyncStream
from a subject with.subscribe()
- Replace
AnyCancellable
withTask
andfor [try] await
loops (weak self
here)
Before:
final class RandomGenerator {
private let generator = PassthroughSubject<Int, Never>()
var publisher: AnyPublisher<Int, Never> {
generator.eraseToAnyPublisher()
}
func run() {
generator.send(.random(in: 1...1000))
}
}
...
let prng = RandomGenerator()
var subscription = prng.publisher.sink { [weak self] value in
...
}
After:
final class RandomGenerator {
private let generator = PassthroughStream<Int>()
var publisher: AsyncStream<Int> {
generator.subscribe()
}
func run() {
generator.send(.random(in: 1...1000))
}
}
...
let prng = RandomGenerator()
var subscription = Task { [weak self] in
for await value in prng.publisher {
...
}
}
Bottom line
Leaving Combine behind is a disruptive step towards both Swift 6 and cross-platform. Personally, I still don’t fully trust the behavior of AsyncSequence
, but what are we left with? Apple is forcing developers towards Concurrency, and soon there will be no choice but to embrace it. And I’m glad, because it’s finally bringing consistency to the language.
We’re close to building the Partout core on both Windows and Linux. In the next article, I will cover some quirks you will face with SwiftPM.