Skip to content

Catch SwiftUI model updates from bad threads before they crash your app

It hasn’t yet been reflected in the official SwiftUI documentation (yay, betas), but the WWDC presentations and further guidance from Apple engineers have made it very clear about one point of SwiftUI: as with UIKit, you must send SwiftUI updates only from the main thread. The more things change…

The bad thing about this is that if you don’t make your updates on the main thread, you get away with it.

Most of the time.

The reality: updating the UI outside the main thread — in UIKit and in the newer SwiftUI — is going to be a source of hard to debug bugs, possible race conditions when updates are made to the UI, and at worst, app crashes.

Your BindableObjects may have many attributes, all that require you to do a willChange.send(). With so many places where your asynchronous code may need to trigger an update to your Views, what can you do to catch those missed main thread migrations before they become headaches? Xcode can save your sanity with its built-in Main Thread Checker debug functionality. Let’s walk through a basic example.


Starting with a standard SwiftUI project, we’ll define this as the ContentView, which is going to be a simple button that calls off to a function in our view model, which will call an asynchronous Combine workflow:

import SwiftUI

import Combine



struct ContentView: View {

    @ObjectBinding var viewModel = ContentViewModel()


    
var body: some View {

        VStack {

            Button(action: {self.viewModel.calculateResult()}) {

                Text(“Calculate Result”)

            }

            Text(viewModel.result)

        }

    }

}



class ContentViewModel: BindableObject {

    var result: String = "" {

        willSet {

            willChange.send()

        }

    }

    var willChange = PassthroughSubject<Void, Never>()

    var calculate = PassthroughSubject<String, Never>()

    
init() {

        calculate.delay(for: 1.0, scheduler: DispatchQueue.global(qos: .background)).sink(receiveValue: { value in self.result = value })

    }



    func calculateResult() {

        calculate.send("The result is: 5")

    }

}


Many operations are asynchronous and can happen on separate threads: notifications, data retrieval from an API, timer events, and so on. In this case, we’re going to simulate one when the button is tapped, delaying for a second, using a separate DispatchQueue.

This code builds normally, and if you live preview it, you might even think that it was successful: it displays the appropriate “The result is: 5” String in the interface. But if you’ll notice in the Combine workflow defined in init(), you see no place where we move execution back to the main thread before setting our result property. This means the UI updates were made in the context of a separate thread. No good.

The good news is, if you run the app on the Simulator or an attached device, Xcode has your back. Run this app in the Simulator, and then tap the button. After the result is displayed, you receive not only an error in the log:

But even better, a purple warning also shows up alongside your build errors and warnings, highlighting the exact line of the willChange.send() that was executed outside of the main thread.

From there, it should be no more than a hop or two to figure out exactly where execution needs to be pushed back to the main thread.

In our case, if you change the Combine chain, adding a receive(on:) to force execution back to the main thread:

calculate.delay(for: 1.0, scheduler: DispatchQueue.global(qos: .background)).receive(on: RunLoop.main).sink(receiveValue: { value in self.result = value })


And rerun, the warning goes away. Your code is ready to do its asynchronous work, and update the UI in the main thread: no bugs, no crashes.

If you’d like to check out this code for yourself, check out the repo on GitHub.


UPDATE: This post was written for iOS 13 beta 4. Beta 5 has deprecated BindableObject, but the main thread checker still works, and even better than before, because it highlights the line with the Combine workflow itself. No backtracking required! Check out the GitHub repo for updated code.

Comments are closed.