Reputation: 1150
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?
@StateObject
in the view’s initializer (see amended Case G). Unfortunately, it didn’t resolve the issue.onDismiss
or onDisappear
) to make it stop long-running tasks.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.
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
Reputation: 621
Here is my solution without using UIKit Presentation style.
Caution: It's super unsafe. Don't use this in production code!!!
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