Environment objects injected on a TabView that contains a NavigationStack never deallocate

Originator:argentumko
Number:rdar://FB13687535 Date Originated:
Status:Open Resolved:
Product:SwiftUI Product Version:iOS 17.4
Classification:Incorrect/Unexpected Behavior Reproducible:Always
 
In a SwiftUI app that contains a TabView with one or more NavigationStack's inside it, using `environment(...)` or `environmentObject(...)` modifier causes the injected objects to leak, not being released and deallocated even once the tab view goes away. We've been able to diagnose that the root cause of it is that environment values/objects are inserted in the corresponding UIKit trait environment, and under the above conditions the trait collection gets captured by:
1. A globally cached (and thus never deallocated) UILabel created by the first UINavigationBar in the app.
2. A private pointer interaction object created by UINavigationBar, registered with the UIWindow, but under some circumstances never deregistered (and thus leaking).

Please refer to the attached demo project for a code sample that demonstrates the problem. We've also included the workarounds we've found to the above issues which may shed some light on the problem. This issue occurs at least on iOS 16.4 and iOS 17.2-17.4 (both device and simulator), but may have existed in many past iOS releases too. Building using Xcode 15.2-15.3.

Comments

Contents of the attached sample project:

@main struct MemoryLeakDemoApp: App { var body: some Scene { WindowGroup { ContentView() } } }

struct ContentView: View {

@State private var showsNavstack = true

var body: some View {
    if showsNavstack {
        LeakyView(isDisplayed: $showsNavstack)
    } else {
        CheckerView()
    }
}

init() {
    #warning("comment this out to exhibit another leak")
    UIApplication.inoculateAgainstSwiftUIEnvironmentMemoryLeakType1
}

}

struct LeakyView: View {

@Binding var isDisplayed: Bool
@StateObject private var canary = Canary()

var body: some View {
    // THIS CAUSES THE LEAKS
    // If the navstack is not embedded in a tab view (directly or indirectly), neither leak gets triggered
    TabView {
        NavigationStack {
            ScrollView {
                VStack(spacing: 16) {
                    Text("Scroll me **up and down**")
                    Button("Then Hide Me") { isDisplayed = false }
                }
                .frame(minHeight: 500)
            }
            // THIS CAUSES ONE OF THE LEAKS – somehow only triggered after scrolling the content a bit
            // However the navbar is also hidden automatically because there's no nav title
            .toolbar(.hidden, for: .navigationBar)
        }
    }
    // THIS LEAKS THE CANARY
    .environmentObject(canary)
}

}

struct CheckerView: View {

@State private var text = ""

var body: some View {
    VStack(spacing: 16) {
        Button("Check Canary") {
            if Canary.instance == nil {
                text = "Canary is dead ✅"
            } else {
                text = "Canary is still alive ❌"
            }
        }
        Text(text)

        Button("Trigger a Workaround") {
            UIApplication.workAroundSwiftUIEnvironmentMemoryLeakType2()
            text = "Check again now ⬆️"
        }
    }
}

}

final class Canary: ObservableObject {

private(set) static weak var instance: Canary?

init() {
    Self.instance = self
}

}

/// As of iOS 13-17.2 (and likely in newer versions too), SwiftUI environment (both values and objects) applied to a TabView that contains a NavigationView/Stack leaks due to being injected into a UIKit trait collection of the underlying navigation controller, which then gets captured by: /// - A UILabel cached globally by UINavigationBar for the purposes of large title label measurement (only if it's the first navigation bar created in the entire app). /// - A pointer interaction's helper view that gets registered with the window by UINavigation but never deregistered. /// Members of this extension work around these leaks. extension UIApplication {

/// This method works around the memory leak caused by the global `UILabel` by creating a dummy navigation bar and triggering a layout pass on it, which forces the label to be created (via function `_UINavigationBarLargeTitleViewLabelForMeasuring`). This prevents navigation bars created _later on_ from capturing the real trait environment, which includes the SwiftUI environment.
///
/// In order to be effective, this method must be called early on in the application lifecycle, before any navigation views are created, but after at least one window scene has been created.
static let inoculateAgainstSwiftUIEnvironmentMemoryLeakType1: Void = {
    guard let scene = shared.windowScene else {
        assertionFailure("Can't work around the memory leak: no window scenes connected")
        return
    }

    let bar = UINavigationBar(frame: CGRect(x: 0, y: 0, width: 320, height: 88))
    bar.prefersLargeTitles = true

    let item = UINavigationItem()
    bar.pushItem(item, animated: false)

    let window = UIWindow(windowScene: scene)
    window.addSubview(bar)

    bar.layoutIfNeeded()

    // The window is removed from the scene automatically as it deallocates
}()

/// This method works around the memory leak caused by a leftover pointer interaction still registered in the window after the navigation bar deallocates.
///
/// Navigation bar creates one or more `_UIPointerInteractionAssistant` (subclass of `UIPointerInteraction`) objects, which are registered with the window. `_UIPointerInteractionAssistantEffectContainerView`, owned by each interaction, is a subview of the navigation bar, and thus captures the trait collection from it. Normally, these interactions get deregistered from the window as the navigation bar goes off-screen, but sometimes they're not, which leaks the SwiftUI environment as the trait collection stored in the view outlives its superview. This is generally triggered by showing/hiding the navigation bar as part of a push/pop.
///
/// In order to avoid unintended side effects, this method must only be called while there's no important UI on the screen.
static func workAroundSwiftUIEnvironmentMemoryLeakType2() {
    for window in shared.windowScene?.windows ?? [] {
        workAroundSwiftUIEnvironmentMemoryLeakType2(in: window)
    }
}

private static func workAroundSwiftUIEnvironmentMemoryLeakType2(in window: UIWindow) {
    // WARNING: THIS IS PRIVATE API
    // UIWindow holds "subtree monitors" – including _UIPointerInteractionAssistant objects (a UIPointerInteraction subclass) – in a nullable NSMutableSet ivar called _subtreeMonitors. It's modified in two methods: _registerSubtreeMonitor and _unregisterSubtreeMonitor. We use the latter to unregister all monitors of the pointer interaction class, thus hopefully removing the only remaining references to them.
    let selector = Selector(("_unregisterSubtreeMonitor:"))
    let ivar = class_getInstanceVariable(UIWindow.self, "_subtreeMonitors")!
    let monitors = object_getIvar(window, ivar) as! NSSet?

    // Remove all monitors of the relevant class
    for case let monitor as UIPointerInteraction in monitors ?? NSSet() {
        window.perform(selector, with: monitor)
    }
}

}

// MARK: - Private extensions

private extension UIApplication {

var windowScene: UIWindowScene? {
    connectedScenes.compactMap { $0 as? UIWindowScene }.first
}

}

By argentumko at March 15, 2024, 4:45 a.m. (reply...)

Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!