In a Mac app built with Swift 5.x and Xcode 14, I have a controller object. This object has several @Published
properties that are observed by SwiftUI views, so I have placed the object on @MainActor
like this:
@MainActor
final class AppController: NSObject, ObservableObject
{
@Published private(set) var foo: String = ""
@Published private(set) var bar: Int = 0
private func doStuff() {
...
}
}
This app needs to take certain actions when the Mac goes to sleep, so I subscribe to the appropriate notification in the init()
method, but because AppController
is decorated with @MainActor
, I get this warning:
override init()
{
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] note in
self?.doStuff() // "Call to main actor-isolated instance method 'doStuff()' in a synchronous nonisolated context; this is an error in Swift 6"
}
}
So, I attempted to isolate it. But (of course) the compiler has something new to complain about. This time an error:
override init()
{
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] note in
Task { @MainActor in
self?.doStuff() // "Reference to captured var 'self' in concurrently-executing code
}
}
}
So I did this to solve that:
override init()
{
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] note in
let JUSTSHUTUP: AppController? = self
Task { @MainActor in
JUSTSHUTUP?.doStuff()
}
}
}
The last bit produces no compiler errors and seems to work. But I have NO idea if it's correct or best-practice.
I do understand why the compiler is complaining and what it's trying to protect me from, but attempting to adopt Swift Concurrency in an existing project is...painful.
You can use your Task { @MainActor in ... }
pattern, but add the [weak self]
capture list to the Task
:
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main
) { [weak self] note in
Task { @MainActor [weak self] in
self?.doStuff()
}
}
Or, perhaps better, because we know this will be called on the main thread, is to avoid creating a new Task
, and instead use MainActor.assumeIsolated {…}
:
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main
) { [weak self] note in
MainActor.assumeIsolated {
self?.doStuff()
}
}
This is mentioned in SE-0424:
… if the current thread is not running a task, isolation checking will succeed if the target actor is the
MainActor
and the current thread is the main thread.
FWIW, rather than the observer pattern, in Swift concurrency, we can forgo the old completion-handler-based observer, and instead use the asynchronous sequence, notifications(named:object:)
:
@MainActor
final class AppController: ObservableObject {
private var notificationTask: Task<Void, Never>?
deinit {
notificationTask?.cancel()
}
init() {
notificationTask = Task { [weak self] in
let sequence = NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.willSleepNotification)
for await notification in sequence {
self?.doStuff(with: notification)
}
}
}
private func doStuff(with notification: Notification) { … }
}