Setting a custom NSWindowDelegate
on NSWindow
in your macOS SwiftUI app can introduce memory leaks. To mitigate this, save the original delegate before setting your custom one and restore it before the window closes.
Context
It was another beautiful development day. I opened Xcode, ran the app, tested everything in the most extreme ways I could imagine, and then out of curiosity, I thought it would be fun to check the memory graph to see how good my computer memory management skills are (since my real-life memory management skills are not great). You can only imagine how surprised I was when I saw that everything had leaked.
For those who enjoy puzzles and challenges, I’ll set the stage for you to give you a chance to unravel this mystery before even reaching the next section. Here is the setup:
- macOS SwiftUI app
- Two
WindowGroup
builders (the home screen and the editor) - The editor
WindowGroup
handles the creation and editing of documents - Since we are in SwiftUI territory, and at the time of writing, there is no native way to override window closing behavior, we are using a custom
NSWindowDelegate
to help with that
Keeping all the above in mind, when we open the editor to either preview an existing document or create a new one and then close it, we see that all the editor objects remain in memory. What gives?
Issue
One of the first things I tried was to open the memory graph debugger in Xcode and check my EditorViewModel
, since it’s the top @StateObject
and was the most likely culprit for causing reference cycles. There are many things I like about SwiftUI, but those giant generic structs aren’t one of them. When I started investigating strong references, I had to go through gems like this:
SwiftUI.LocationBox<SwiftUI.ObservableObjectLocation<Editor.EditorViewModel, Swift.Optional<StorageInterface.ComponentConfiguration & Swift.AnyObject>>>
That just rolls off the tongue. These massive tongue twisters, along with the substantial number of references from SwiftUI to EditorViewModel
didn’t make this process fast or easy. Nevertheless, after following the breadcrumbs, I came up empty-handed. Since my entire view hierarchy leaked, the next target to investigate was NSWindow
.
And oh boy, if the @StateObject
debugging wasn’t pleasant, I couldn’t see how going through 229 objects referencing NSWindow
would be any different. Although most of them didn’t have strong references expanding and investigating them didn’t seem like the best use of my time, so it was time to go back to first principles.
Since NSWindow
was leaking and I was setting my custom delegate on it, this seemed to be the most likely offender. So I decided to remove my custom WindowManager
. Guess what? Memory leak? Gone! That’s great news as it narrows down the scope. What’s not great is that my beautiful "Do you want to save your changes?" dialog also vanished.
After carefully dissecting various parts of my code in the WindowManager
, I found that the culprit was self.window?.delegate = self
line:
final class WindowManager: NSObject, NSWindowDelegate {
private weak var window: NSWindow?
private var shouldClose: (() -> Bool)?
private var onShouldClose: (() -> Void)?
@AppStorage(for: .editorWindowFrame) private var editorWindowFrame
func setBehaviour(
for window: NSWindow?,
shouldClose: @escaping () -> Bool,
onShouldClose: @escaping () -> Void
) {
guard self.window == nil else {
return
}
self.window = window
self.shouldClose = shouldClose
self.onShouldClose = onShouldClose
self.window?.delegate = self
}
// more stuff
}
Since WindowManager
is holding a weak
reference to window
and window.delegate
should be holding a weak
reference to WindowManager
, we shouldn’t have a reference cycle. So what is going on? Looking at delegate
property documentation, I saw the following:
A window object’s delegate is inserted in the responder chain after the window itself and is informed of various actions by the window through delegation messages.
This is one time when documentation threw me in the wrong direction. Seeing this, I thought, "Oh, maybe this is the reason: window is doing some weird magic to retain the delegate, and I need to find some magic method to undo that". The worst part was that, going through Stackoverflow and Apple forum results, I found cases where people saw similar behavior. After quite some time of trying to find the holy grail method that would allow NSWindow
to die in a piece, I gave up.
However, at some point, a random idea struck me in the head:
Hmm, I’m curious if there is some other delegate set on
NSWindow
before I set mine?
Well, it’s pretty easy to check, let’s build the app, put a breakpoint, trigger window creation, inspect NSWindow
, and ... oh, what’s that:
SwiftUI.AppKitWindowController: 0x1410658e0
So indeed, SwiftUI has its own delegate set on NSWindow
. Well, what would happen if we tried to do something sneaky, like saving that delegate and then, before the window closes, setting it back so SwiftUI
can do some magic cleaning? So we change our code to the following when we set the delegate:
final class WindowManager: NSObject, NSWindowDelegate {
private weak var window: NSWindow?
private weak var originalDelegate: NSWindowDelegate?
private var shouldClose: (() -> Bool)?
private var onShouldClose: (() -> Void)?
@AppStorage(for: .editorWindowFrame) private var editorWindowFrame
func setBehaviour(
for window: NSWindow?,
shouldClose: @escaping () -> Bool,
onShouldClose: @escaping () -> Void
) {
guard self.window == nil else {
return
}
self.window = window
self.shouldClose = shouldClose
self.onShouldClose = onShouldClose
self.originalDelegate = window?.delegate
self.window?.delegate = self
}
// more stuff
}
And then, in our custom closeWindow()
and windowShouldClose(_:)
methods, just before the window is about to close, we reset the delegate like so:
final class WindowManager: NSObject, NSWindowDelegate {
// more stuff
func closeWindow() {
window?.delegate = originalDelegate
window?.close()
}
func askToCloseWindow() {
window?.performClose(nil)
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
let shouldClose = shouldClose?() ?? true
if !shouldClose {
onShouldClose?()
}
window?.delegate = originalDelegate
return shouldClose
}
}
Drumrolls, please... The memory leak is fixed! When we close the window, SwiftUI
does some magic that releases the NSWindow
, which as a result, releases all objects contained in that window. The best part is that our custom saving dialog still works.
Conclusion
First things first. It’s important to note that although the solution above works now, it may break in any future releases, so proceed with caution if you choose this path.
This was another fun SwiftUI journey, and had I not spent so much time searching for an answer, I wouldn’t have written this post. However, given that it’s not rare to set a custom NSWindowDelegate
in your SwiftUI macOS app when you need functionality currently not provided with SwiftUI, I thought this information might prove useful to other explorers who stumble into the same rabbit hole. If you are one of those people reading this, I hope that you’re smiling by now.
When it comes to SwiftUI, I can’t be mad. I went the rogue way, playing with fragile unsupported building blocks, and had to face some challenges as a result. However, this brings a few wishes I have for the next WWDC that is just around the corner:
- More SwiftUI APIs for window management. I understand that this might not be a top priority, but it’s crucial if we want to build Mac apps that feel like real Mac apps.
- Improved debugging. SwiftUI is wonderful, and I like it a lot. However, when it comes to interacting with it via debuggers (be it Memory Graph or View Hierarchy), the experience is far from great. What is hidden from us with the magic of opaque return types during build time comes back in tongue twisters during debug time.