swiftasync-awaitappkitswift-concurrency

Swift Concurrency: Notification Callbacks on @MainActor Objects


Context

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() {
        ...
    }
}

Problem

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()
        }
    }
}

Question

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.


Solution

  • 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) { … }
    }