Categories
iOS Swift SwiftUI

Wrapping delegates for @MainActor consumers in Swift

Sometimes we need to handle delegates in a class which has the @MainActor annotation. Often it can be a view model where we expect that code runs on the main thread. Therefore, view models have the @MainActor annotation, since we want that their methods run on the main thread when interacting with other async code. In an example below, we’ll be looking into integrating a delegate based ImageBatchLoader class which calls delegate methods on a background thread. The end goal is to handle the delegate in a view model and making sure it runs on the main thread.

final class ImageBatchLoader {
weak var delegate: ImageBatchLoaderDelegate?
init(delegate: ImageBatchLoaderDelegate) {
self.delegate = delegate
}
func start() {
DispatchQueue.global().async {
self.delegate?.imageLoader(self, didLoadBatch: [UIImage()])
}
}
}
protocol ImageBatchLoaderDelegate: AnyObject {
func imageLoader(_ imageLoader: ImageBatchLoader, didLoadBatch batch: [UIImage])
}
An example ImageBatchLoader with stubbed out start method.

This is an example of a class which uses delegates and calls delegate methods from background threads. If we have a view model with @MainActor annotation, then we just can’t conform to that delegate since the delegate does not use any async-await support. Xcode would show a warning saying that the protocol is non-isolated. A protocol would be isolated if it would have, for example, @MainActor annotation as well for that protocol. Let’s say this is not possible and it is a third party code instead.

The solution I have personally settled with is creating a wrapper class which conforms to that delegate and then uses main thread bound closures to notify when any of the delegate callbacks happen.

final class ImageBatchLoaderHandler: ImageBatchLoaderDelegate {
var didLoadBatch: @MainActor ([UIImage]) -> Void = { _ in }
func imageLoader(_ imageLoader: ImageBatchLoader, didLoadBatch batch: [UIImage]) {
print("isMainThread", Thread.isMainThread, #function)
Task {
await didLoadBatch(batch)
}
}
}

Here we can see a class which conforms to the ImageBatchLoaderDelegate and provides a didLoadBatch closure which has an @MainActor annotation. Since we use @MainActor and tap into the async-await concurrency, then we need an async context as well, which the Task provides.

@MainActor final class ViewModel: ObservableObject {
private let imageLoader: ImageBatchLoader
private let imageLoaderHandler: ImageBatchLoaderHandler
init() {
imageLoaderHandler = ImageBatchLoaderHandler()
imageLoader = ImageBatchLoader(delegate: imageLoaderHandler)
imageLoaderHandler.didLoadBatch = handleBatch
imageLoader.start()
}
func handleBatch(_ batch: [UIImage]) {
print("isMainThread", Thread.isMainThread, #function)
// redacted
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

Finally we have hooked up the image loader, its handler and also forwarding the didLoadBatch to a separate function which is part of the view model. With a little bit of code, we achieved what we wanted: listening to delegate callbacks and forwarding them to the view model on the main thread. If we ran the code we would see that the delegate callback runs on a background thread but the view model method runs on the main thread.

isMainThread false imageLoader(_:didLoadBatch:)
isMainThread true handleBatch(_:)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.