Search code examples
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()
        }
    }
    

    FWIW, rather than the observer pattern, in Swift concurrency, we would probably 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) { … }
    }