Using Swift’s CombineLatest Effectively

Apple’s Combine Framework was probably the second most surprising new framework of 2019’s WWDC, only outshone by SwiftUI – the UI framework it was created to complement.

What made it all the more surprising, however, was that Apple was planting it’s flag in Reactive Programming, a paradigm that is not only highly prescriptive with regards to software architecture and conventions, but one that a significant number of iOS developers have had little exposure to.

I was one of those developers and, while I was immediately happy to lap up SwiftUI – maybe a little too happily after years of using storyboards and auto-layout – I approached Combine with caution.

However I was wrong to do so. Combine is extremely powerful and straightforward to understand provided you have a solid foundational knowledge of asynchronous programming. Within a few hours of prototyping I was able to build complex data flows, handling everything from HTTP requests and web sockets to data validation and UI updates, all using Combine.

While it’s been a fascinating and rewarding learning experience I’ve encountered more than a few gotchas that gave me unexpected results; results that produced issues which were often very difficult to diagnose.

One such gotcha occurred when using the CombineLatest publisher to combine the values of two distinct publishers. The first publisher receives MQTT messages being sent over a socket, the second a timer publisher set to fire every 5 seconds.

Using these, the CombineLatest publisher produces some text saying how long it has been since the last MQTT message had been received. To accomplish this, the timer publisher fires every 5 seconds, which ensures that the text is updated at least as often as an MQTT message is received. In practice, the MQTT messages are received about once every 15 to 30 seconds so you would expect at least two timer updates for each message received.

The code looked approximately like this:

let lastUpdatedTextPublisher = timerPublisher.combineLatest(messagePublisher)
    .map { (current: Date, last: Date) in
        return abs(current.timeIntervalSince(last))
    }.map {
        return "Last updated \(Int(round($0)))s ago"
    }

Having used CombineLatest publishers in a few other areas of my code I figured this would work pretty much as expected. I was surprised then to find that when I ran the code, no text was displayed for the first 15 or so seconds.

I added some breakpoints and prints to check that the timer was being fired and it was, but breakpoints and prints after the map operators did nothing.

To narrow things down I created a simple playground to simulate the issue:

import Combine
import Foundation

let timer1 = Timer.publish(every: 1.0, on: .main, in: .default).autoconnect()
let c1 = timer1.sink { _ in print("timer 1 fired") }
let timer2 = Timer.publish(every: 5.0, on: .main, in: .default).autoconnect()
let c2 = timer2.sink { _ in print("timer 2 fired") }

let combined = timer1.combineLatest(timer2).sink { _, _ in
    print("combined fired")
}

The above generated the following output:

timer 1 fired
timer 1 fired
timer 1 fired
timer 1 fired
timer 1 fired
timer 2 fired
combined fired
timer 1 fired
combined fired
timer 1 fired
combined fired
timer 1 fired
combined fired
timer 1 fired
combined fired
timer 1 fired
combined fired
timer 2 fired
combined fired

This led me to realise that the CombineLatest publisher only fires when it has received at least one message from each of it’s upstream publishers. Although I couldn’t find specific documentation detailing this, it made sense. After all, with both publishers having non-optional outputs, CombineLatest couldn’t possibly provide a value for each.

So far so good with the diagnosis, but that didn’t solve the problem of updating the text in my original code. I needed a way to provide default values for both the timer and message publishers.

One way to accomplish this would have been to keep two Date fields with an @State wrapper added to each. While a viable option, this didn’t feel right. The fields would be @State annotated but wouldn’t be bound to anything in the view, and another computed property would be required to generate the actual display text – all in all, this would create several additional layers of indirection.

I felt there must be a way to do this strictly within the domain and control flow of the publishers I’d already set up. This line of reasoning eventually led me to CurrentValueSubject.

CurrentValueSubject acts as both a subscriber and a publisher, similarly to the more common PassthroughSubject. However, it also takes a default value as well as maintaining a buffer of it’s most recently received element – perfect for ensuring that we always have at least one element available from both the timer and message publishers.

After modification, the code looked something like this:

let messageSubject = CurrentValueSubject<Date, Never>(Date())
let timerSubject = CurrentValueSubject<Date, Never>(Date())

...

timerPublisher.subscribe(timerSubject).store(in: &cancelBag)
messagePublisher.map { Date() }.subscribe(messageSubject).store(in: &cancelBag)
let lastUpdatedTextPublisher = timerSubject.combineLatest(messageSubject)
    .map { (current: Date, last: Date) in
        return abs(current.timeIntervalSince(last))
    }.map {
        return "Last updated \\(Int(round($0)))s ago"
    }.eraseToAnyPublisher()

This solved the problem and – best of all – scaled well when I had several views all displaying update text in parallel.

One thought on “Using Swift’s CombineLatest Effectively”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.