4

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.

Bryan
  • 4,628
  • 3
  • 36
  • 62

2 Answers2

6

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) { … }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thanks! Do you know if there's an open Radar to have the compiler handle these callbacks more gracefully? I've already explicitly specified that the closure will run on the main queue in -addObserver(), it just seems like the compiler can't currently reason about that. – Bryan Dec 09 '22 at 05:33
  • Frankly, in Swift concurrency, we probably would avoid the closure-based pattern, anyway, and instead use the asynchronous sequence, `notifications(named:object:)`. I'm not sure if it warrants a radar, especially when we probably wouldn't use the observer pattern anymore... – Rob Dec 09 '22 at 06:23
  • I see. To use that approach in this example, would I just wrap the `for await _ in ...` inside a `Task {}` within the `init()` method? – Bryan Dec 09 '22 at 08:20
  • 1
    @Bryan - I modified it to do it in `init`. – Rob Dec 10 '22 at 21:14
3

An alternative is to use Combine

import Combine

@MainActor
final class AppController: NSObject, ObservableObject
{
    @Published private(set) var foo: String = ""
    @Published private(set) var bar: Int = 0
    
    private var cancellable : AnyCancellable?
    
    private func doStuff() {
        //
    }
    
    override init()
    {
        super.init()
        cancellable = NSWorkspace.shared.notificationCenter
            .publisher(for: NSWorkspace.willSleepNotification)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] note in
                self?.doStuff()
            }
    }
}
vadian
  • 274,689
  • 30
  • 353
  • 361
  • Interesting! It looks like the compiler can reason about Combine subscriptions that are set to receive on the main thread, but can't introspect the older NSNotificationCenter APIs that specify the queue on which to run. – Bryan Dec 09 '22 at 05:42