SwiftUI 表未正确取消初始化关联实例

Luk*_*nek 10 swiftui observedobject swiftui-sheet

我\xe2\x80\x99在 SwiftUI 中遇到了与工作表演示相关的令人费解的行为。关闭工作表时,我注意到关联的实例(工作表\xe2\x80\x99s 视图持有的视图模型)don\xe2\x80\x99t 似乎已正确取消初始化。

\n

根据我的测试,唯一deinit按预期被调用的场景是使用@StateObject. 相反,@ObservedObject@Observable宏 don\xe2\x80\x99t 似乎都会触发调用deinit

\n

下面,我\xe2\x80\x99ve提供了一组展示各种场景的示例。每个尝试以不同的方式提供视图模型。要测试解雇,您只需在显示的工作表上向下滑动即可:

\n
import SwiftUI\n\n// ============================================================================ //\n// MARK: - App\n// ============================================================================ //\n\n@main\nstruct SwiftUISheetDeinitIssueApp: App {\n    var body: some Scene {\n        WindowGroup {\n            CaseA_ContentView()\n        }\n    }\n}\n\n// ============================================================================ //\n// MARK: - Case A: @StateObject (Works!)\n// ============================================================================ //\n\nstruct CaseA_ContentView: View {\n    @State var isPresented = false\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.isPresented = true\n        }\n        .sheet(isPresented: $isPresented) {\n            CaseA_SheetView()\n        }\n    }\n}\n\nstruct CaseA_SheetView: View {\n    @StateObject var model = CaseA_SheetViewModel()\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\nfinal class CaseA_SheetViewModel: ObservableObject {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n\n// ============================================================================ //\n// MARK: - Case B: @ObservedObject & Inline Model Creation (Doesn\'t Work!)\n// ============================================================================ //\n\nstruct CaseB_ContentView: View {\n    @State var isPresented = false\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.isPresented = true\n        }\n        .sheet(isPresented: $isPresented) {\n            CaseB_SheetView(model: CaseB_SheetViewModel())\n        }\n    }\n}\n\nstruct CaseB_SheetView: View {\n    @ObservedObject var model: CaseB_SheetViewModel\n    \n    init(model: CaseB_SheetViewModel) {\n        self.model = model\n    }\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\nfinal class CaseB_SheetViewModel: ObservableObject {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n\n// ============================================================================ //\n// MARK: - Case C: @ObservedObject + @State in Parent (Doesn\'t Work!)\n// ============================================================================ //\n\nstruct CaseC_ContentView: View {\n    @State var sheetViewModel: CaseC_SheetViewModel?\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.sheetViewModel = CaseC_SheetViewModel()\n        }\n        .sheet(item: self.$sheetViewModel) { model in\n            CaseC_SheetView(model: model)\n        }\n    }\n}\n\nstruct CaseC_SheetView: View {\n    @ObservedObject var model: CaseC_SheetViewModel\n    \n    init(model: CaseC_SheetViewModel) {\n        self.model = model\n    }\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\nfinal class CaseC_SheetViewModel: ObservableObject, Identifiable {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n\n// ============================================================================ //\n// MARK: - Case D: Content @StateObject + Sheet @ObservedObject (Doesn\'t Work!)\n// ============================================================================ //\n\nstruct CaseD_ContentView: View {\n    @StateObject var model = CaseD_ContentViewModel()\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.model.sheetViewModel = CaseD_SheetViewModel()\n        }\n        .sheet(item: self.$model.sheetViewModel) { model in\n            CaseD_SheetView(model: model)\n        }\n    }\n}\n\nfinal class CaseD_ContentViewModel: ObservableObject, Identifiable {\n    @Published\n    var sheetViewModel: CaseD_SheetViewModel?\n}\n\nstruct CaseD_SheetView: View {\n    @ObservedObject var model: CaseD_SheetViewModel\n    \n    init(model: CaseD_SheetViewModel) {\n        self.model = model\n    }\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\nfinal class CaseD_SheetViewModel: ObservableObject, Identifiable {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n\n// ============================================================================ //\n// MARK: - Case E: @Observable\n// ============================================================================ //\n\nstruct CaseE_ContentView: View {\n    @State\n    var sheetViewModel: CaseE_SheetViewModel?\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.sheetViewModel = CaseE_SheetViewModel()\n        }\n        .sheet(item: self.$sheetViewModel) { model in\n            CaseE_SheetView(model: model)\n        }\n    }\n}\n\nstruct CaseE_SheetView: View {\n    let model: CaseE_SheetViewModel\n    \n    init(model: CaseE_SheetViewModel) {\n        self.model = model\n    }\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\n@Observable\nfinal class CaseE_SheetViewModel: Identifiable {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n\n// ============================================================================ //\n// MARK: - Case G\n// ============================================================================ //\n\nstruct CaseG_ContentView: View {\n    @State var isPresented = false\n    \n    var body: some View {\n        Button("Show Sheet") {\n            self.isPresented = true\n        }\n        .sheet(isPresented: $isPresented) {\n            CaseG_SheetView(model: CaseG_SheetViewModel())\n        }\n    }\n}\n\nstruct CaseG_SheetView: View {\n    @StateObject var model: CaseG_SheetViewModel\n    \n    init(model: CaseG_SheetViewModel) {\n        self._model = StateObject(wrappedValue: model)\n    }\n    \n    var body: some View {\n        Text("Sheet")\n    }\n}\n\nfinal class CaseG_SheetViewModel: ObservableObject {\n    init() { print("\\(Self.self).\\(#function)") }\n    deinit { print("\\(Self.self).\\(#function)") }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

当我启动内存图时,它显示该类的活动实例CaseX_SheetViewModel,并带有指向它的以下引用:

\n
<AnyViewStorage<ModifiedContent<SheetContent<CaseX_SheetView>, _EnvironmentKeyWritingModifier<Binding<PresentationMode>>>>: 0x281f60640>\n
Run Code Online (Sandbox Code Playgroud)\n

我的所有测试都是在运行最新 iOS 17 测试版的真实设备以及 iOS 17 模拟器上使用最新的 Xcode 15 RC 完成的。

\n

是否缺少 I\xe2\x80\x99m 的内容,或者这是 SwiftUI 中的错误?如果是后者,有人找到合适的解决方法吗?

\n

几天后编辑

\n
    \n
  • 该问题已得到swiftui-navigation(和可组合架构)的开发者Point-Free团队的确认。
  • \n
  • 我向 Apple 提交了反馈 ( FB13194873 )。随意欺骗它!
  • \n
  • 在寻找解决方法时,我尝试了一种旧的、不再推荐的填充@StateObjectin view\xe2\x80\x99s 初始值设定项的模式(请参阅修改后的案例 G)。不幸的是,它没有解决\xe2\x80\x99 问题。
  • \n
  • 唯一可行的解​​决方法似乎是接受这样一个事实:视图模型对象没有被取消初始化,而是通知它有关解雇(onDismissonDisappear)的信息,以使其停止长时间运行的任务。
  • \n
\n

苹果的评论

\n

最后,一个月后,我在他们的论坛上发现了苹果关于这个错误的官方声明。这篇文章确认了 SwiftUI\xe2\x80\x99ssheetfullScreenCover演示修改器中的内存泄漏是一个已知问题,并建议使用 UIKit 的解决方法。他们\xe2\x80\x99ve还提供了完整的代码片段以供参考。

\n

iOS 17.2 已解决

\n

正如 @JinwooKim在他们的回答中指出的那样,这个问题似乎在 iOS 17.2 上得到了解决。我可以通过上面的所有案例来确认这一点!

\n

Jin*_*Kim 1

这是我的解决方案,不使用 UIKit 演示风格。

注意:这是非常不安全的。不要在生产代码中使用它!

样本

用法:

.fullScreenCover(isPresented: $isPresenting) {
  SheetView_2()
    .fixMemoryLeak()
}
Run Code Online (Sandbox Code Playgroud)
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
  }
}
Run Code Online (Sandbox Code Playgroud)