Lukáš Kubánek
Lukáš Kubánek

Reputation: 1150

SwiftUI sheets not correctly deinitializing associated instances

I’ve run into a puzzling behavior in SwiftUI related to sheet presentation. When dismissing a sheet, I noticed that associated instances (view models held by the sheet’s view) don’t seem to get deinitialized properly.

From my tests, the only scenario in which deinit gets called as expected is when using @StateObject. In contrast, both @ObservedObject and the new @Observable macro don’t seem to trigger the deinit call.

Below, I’ve provided a set of examples showcasing various scenarios. Each attempts to supply the view model in a different way. To test the dismissal, you can simply swipe down on the presented sheet:

import SwiftUI

// ============================================================================ //
// MARK: - App
// ============================================================================ //

@main
struct SwiftUISheetDeinitIssueApp: App {
    var body: some Scene {
        WindowGroup {
            CaseA_ContentView()
        }
    }
}

// ============================================================================ //
// MARK: - Case A: @StateObject (Works!)
// ============================================================================ //

struct CaseA_ContentView: View {
    @State var isPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            self.isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            CaseA_SheetView()
        }
    }
}

struct CaseA_SheetView: View {
    @StateObject var model = CaseA_SheetViewModel()
    
    var body: some View {
        Text("Sheet")
    }
}

final class CaseA_SheetViewModel: ObservableObject {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

// ============================================================================ //
// MARK: - Case B: @ObservedObject & Inline Model Creation (Doesn't Work!)
// ============================================================================ //

struct CaseB_ContentView: View {
    @State var isPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            self.isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            CaseB_SheetView(model: CaseB_SheetViewModel())
        }
    }
}

struct CaseB_SheetView: View {
    @ObservedObject var model: CaseB_SheetViewModel
    
    init(model: CaseB_SheetViewModel) {
        self.model = model
    }
    
    var body: some View {
        Text("Sheet")
    }
}

final class CaseB_SheetViewModel: ObservableObject {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

// ============================================================================ //
// MARK: - Case C: @ObservedObject + @State in Parent (Doesn't Work!)
// ============================================================================ //

struct CaseC_ContentView: View {
    @State var sheetViewModel: CaseC_SheetViewModel?
    
    var body: some View {
        Button("Show Sheet") {
            self.sheetViewModel = CaseC_SheetViewModel()
        }
        .sheet(item: self.$sheetViewModel) { model in
            CaseC_SheetView(model: model)
        }
    }
}

struct CaseC_SheetView: View {
    @ObservedObject var model: CaseC_SheetViewModel
    
    init(model: CaseC_SheetViewModel) {
        self.model = model
    }
    
    var body: some View {
        Text("Sheet")
    }
}

final class CaseC_SheetViewModel: ObservableObject, Identifiable {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

// ============================================================================ //
// MARK: - Case D: Content @StateObject + Sheet @ObservedObject (Doesn't Work!)
// ============================================================================ //

struct CaseD_ContentView: View {
    @StateObject var model = CaseD_ContentViewModel()
    
    var body: some View {
        Button("Show Sheet") {
            self.model.sheetViewModel = CaseD_SheetViewModel()
        }
        .sheet(item: self.$model.sheetViewModel) { model in
            CaseD_SheetView(model: model)
        }
    }
}

final class CaseD_ContentViewModel: ObservableObject, Identifiable {
    @Published
    var sheetViewModel: CaseD_SheetViewModel?
}

struct CaseD_SheetView: View {
    @ObservedObject var model: CaseD_SheetViewModel
    
    init(model: CaseD_SheetViewModel) {
        self.model = model
    }
    
    var body: some View {
        Text("Sheet")
    }
}

final class CaseD_SheetViewModel: ObservableObject, Identifiable {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

// ============================================================================ //
// MARK: - Case E: @Observable
// ============================================================================ //

struct CaseE_ContentView: View {
    @State
    var sheetViewModel: CaseE_SheetViewModel?
    
    var body: some View {
        Button("Show Sheet") {
            self.sheetViewModel = CaseE_SheetViewModel()
        }
        .sheet(item: self.$sheetViewModel) { model in
            CaseE_SheetView(model: model)
        }
    }
}

struct CaseE_SheetView: View {
    let model: CaseE_SheetViewModel
    
    init(model: CaseE_SheetViewModel) {
        self.model = model
    }
    
    var body: some View {
        Text("Sheet")
    }
}

@Observable
final class CaseE_SheetViewModel: Identifiable {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

// ============================================================================ //
// MARK: - Case G
// ============================================================================ //

struct CaseG_ContentView: View {
    @State var isPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            self.isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            CaseG_SheetView(model: CaseG_SheetViewModel())
        }
    }
}

struct CaseG_SheetView: View {
    @StateObject var model: CaseG_SheetViewModel
    
    init(model: CaseG_SheetViewModel) {
        self._model = StateObject(wrappedValue: model)
    }
    
    var body: some View {
        Text("Sheet")
    }
}

final class CaseG_SheetViewModel: ObservableObject {
    init() { print("\(Self.self).\(#function)") }
    deinit { print("\(Self.self).\(#function)") }
}

When I fire up the Memory Graph, it shows living instances of the CaseX_SheetViewModel class with the following reference pointing to it:

<AnyViewStorage<ModifiedContent<SheetContent<CaseX_SheetView>, _EnvironmentKeyWritingModifier<Binding<PresentationMode>>>>: 0x281f60640>

All my tests were done with the latest Xcode 15 RC on real devices running the latest beta of iOS 17 as well as the iOS 17 simulator.

Is there anything I’m missing, or is this a bug in SwiftUI? If the latter, has anyone found a suitable workaround?

Edit after a few days

Comment from Apple

Finally, a month later, I discovered an official statement from Apple regarding this bug on their forums. This post confirms that the memory leakage in SwiftUI’s sheet and fullScreenCover presentation modifiers is a known issue and suggests a workaround using UIKit. They’ve also provided a comprehensive code snippet for reference.

Resolved on iOS 17.2 🙌

As @JinwooKim pointed out in their answer, the issue seems to be resolved on iOS 17.2. I was able to confirm that with all the cases from above!

Upvotes: 12

Views: 2060

Answers (1)

Jinwoo Kim
Jinwoo Kim

Reputation: 621

Here is my solution without using UIKit Presentation style.

Caution: It's super unsafe. Don't use this in production code!!!

Sample

Usage:

.fullScreenCover(isPresented: $isPresenting) {
  SheetView_2()
    .fixMemoryLeak()
}
import UIKit
import SwiftUI

fileprivate let storageKey: UnsafeMutableRawPointer = .allocate(byteCount: 1, alignment: 1)
fileprivate let willDealloc: UnsafeMutableRawPointer = .allocate(byteCount: 1, alignment: 1)
fileprivate var didSwizzle: Bool = false

extension View {
  func fixMemoryLeak() -> some View {
    if #available(iOS 17.2, *) {
      return self
    } else if #available(iOS 17.0, *) {
      swizzle()
      return background {
        FixLeakView()
      }
    } else {
      return self
    }
  }
}

fileprivate struct FixLeakView: UIViewControllerRepresentable {
  func makeUIViewController(context: Context) -> ViewController {
    .init()
  }

  func updateUIViewController(_ uiViewController: ViewController, context: Context) {

  }

  @MainActor final class ViewController: UIViewController {
    override func viewDidLoad() {
      super.viewDidLoad()
      view.isUserInteractionEnabled = false
      view.backgroundColor = .clear
    }

    override func didMove(toParent parent: UIViewController?) {
      super.didMove(toParent: parent)

      guard
        let type: UIViewController.Type = NSClassFromString("_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_") as? UIViewController.Type,
        let hostingController: UIViewController = parentViewController(for: type) else {
        return
      }

      if 
        let delegate = Mirror(reflecting: hostingController).children.first(where: { $0.label == "delegate" })?.value,
        let some = Mirror(reflecting: delegate).children.first(where: { $0.label == "some" })?.value,
        let presentationState = Mirror(reflecting: some).children.first(where: { $0.label == "presentationState" })?.value,
        let base = Mirror(reflecting: presentationState).children.first(where: { $0.label == "base" })?.value,
        let requestedPresentation = Mirror(reflecting: base).children.first(where: { $0.label == "requestedPresentation" })?.value,
        let value = Mirror(reflecting: requestedPresentation).children.first(where: { $0.label == ".0" })?.value,
        let content = Mirror(reflecting: value).children.first(where: { $0.label == "content" })?.value,
        let storage = Mirror(reflecting: content).children.first(where: { $0.label == "storage" })?.value
      {
        objc_setAssociatedObject(hostingController, storageKey, storage, .OBJC_ASSOCIATION_ASSIGN)
      }
    }
  }
}

fileprivate func swizzle() {
  guard !didSwizzle else { return }
  defer { didSwizzle = true }

  let method: Method = class_getInstanceMethod(
    NSClassFromString("_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_")!,
    #selector(UIViewController.viewDidDisappear(_:))
  )!
  let original_imp: IMP = method_getImplementation(method)
  let original_func = unsafeBitCast(original_imp, to: (@convention(c) (UIViewController, Selector, Bool) -> Void).self)

  let new_func: @convention(block) (UIViewController, Bool) -> Void = { x0, x1 in
    if
      x0.isMovingFromParent || x0.isBeingDismissed,
      let storage: AnyObject = objc_getAssociatedObject(x0, storageKey) as? AnyObject,
      !(storage is NSNull),
      objc_getAssociatedObject(storage, willDealloc) as? Bool ?? true
    {
      Task { @MainActor [unowned storage] in
//        guard try? await Task.sleep(for: .seconds(0.3)) else {
//          return
//        }

        let retainCount: UInt = _getRetainCount(storage)
        let umanaged: Unmanaged<AnyObject> = .passUnretained(storage)

        for _ in 0..<retainCount - 1 {
          umanaged.release()
        }
      }

      objc_setAssociatedObject(storage, willDealloc, true, .OBJC_ASSOCIATION_COPY_NONATOMIC)
    }

    original_func(x0, #selector(UIViewController.viewDidDisappear(_:)), x1)
  }

  let new_imp: IMP = imp_implementationWithBlock(new_func)
  method_setImplementation(method, new_imp)
}

extension UIViewController {
  fileprivate func parentViewController(for type: UIViewController.Type) -> UIViewController? {
    var responder: UIViewController? = parent

    while let _responder: UIViewController = responder {
      if _responder.isKind(of: type) {
        return _responder
      }

      responder = _responder.parent
    }

    return nil
  }
}

Upvotes: 1

Related Questions