You need to enable JavaScript to run this app. SwiftUI NavigationLink推送视图时Published ViewModel泄漏问题
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI NavigationLink推送视图时Published ViewModel泄漏问题

问题背景

此前围绕「异步回调获取数据后推送对应视图」的实现方案落地后,出现了不符合预期的内存泄漏问题。
项目采用SwiftUI + MVVM架构,设计逻辑为父ViewModel发布子ViewModel实例,供给对应弹出视图展示;预期弹出视图销毁时,对应ViewModel会被自动释放,但现有实现完全不符合该预期。
具体异常表现:UserView 被销毁后,其对应的 UserViewModel 实例仍驻留内存,UserViewModel 的 deinit 从未打印“Deinit UserViewModel”日志,仅当下次触发 pushUser 推送视图时,旧实例才会执行释放逻辑。

现有实现代码如下:

struct ParentView: View {
    @ObservedObject var vm: ParentViewModel
    
    var presentationBinding: Binding<Bool> {
        .init(get: { vm.pushUser != nil },
              set: { isPresented in
                if !isPresented {
                    vm.pushUser = nil
                }
              }
        )
    }
    
    var body: some View {
        VStack {
            Button("Get user") {
                vm.getUser()
            }
            Button("Read user") {
                print(vm.pushUser ?? "No userVm")
            }
            if let userVm = vm.pushUser {
                NavigationLink(
                    destination: UserView(vm: userVm),
                    isActive: presentationBinding,
                    label: EmptyView.init
                )
            }
        }
    }
}

class ParentViewModel: ObservableObject {
    @Published var pushUser: UserViewModel? = nil
    
    var cancellable: AnyCancellable?
    
    private func fetchUser() -> AnyPublisher<User, Never> {
        Just(User.init(id: "1", name: "wiingaard"))
            .delay(for: .seconds(1), scheduler: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
    
    func getUser() {
        cancellable = api.getUser().sink { [weak self] user in
            self?.pushUser = UserViewModel(user: user)
        }
    }
}

struct User: Identifiable {
    let id: String
    let name: String
}

class UserViewModel: ObservableObject, Identifiable {
    deinit { print("Deinit UserViewModel") }

    @Published var user: User
    init(user: User) { self.user = user }
}

struct UserView: View {
    @ObservedObject var vm: UserViewModel    
    var body: some View {
        Text(vm.user.name)
    }
}

销毁UserView后,通过Xcode内存图调试工具可观察到UserViewModel实例仍处于分配状态,其顶层引用为SwiftUI内部的AnyViewStorage类型对象,引用链路来自于包装了UserView的导航修饰符视图节点。

内存泄漏触发原因
  • 现有实现采用的是带isActive绑定的老式NavigationLink写法,且将该NavigationLink放在if let userVm = vm.pushUser的条件渲染分支内,以EmptyView作为标签。这种实现方式下,只要NavigationLink被渲染到视图树中,SwiftUI导航系统就会对其关联的destination视图(即UserView)产生强引用。
  • 当用户触发返回逻辑时,绑定的presentationBinding会将vm.pushUser置为nil,触发条件分支的视图移除逻辑,但SwiftUI导航层内部并不会同步清除之前缓存的目标视图引用,这部分缓存的强引用会一直持有UserView及其持有的UserViewModel,导致实例无法释放。
  • 只有当pushUser被再次赋值为新的UserViewModel时,新的条件分支会生成新的NavigationLink节点,替换掉导航层内部旧的缓存引用,旧的UserViewModel才会失去强引用、完成释放,这也是观察到“下次push时旧实例才执行deinit”现象的核心原因。
对应解决方案
  • 适配iOS 16及以上版本时,替换基于isActive绑定的老式NavigationLink逻辑,采用值驱动的navigationDestination路由写法:将pushUser作为路由的关联数据源,由SwiftUI导航系统统一管理目标视图的生命周期。当绑定的数据源被置为nil时,对应的目标视图与关联的ViewModel会被同步从导航栈移除、自动释放。
  • 需要兼容iOS 15及更早版本时,调整NavigationLink的创建方式,不要在条件分支内直接内嵌创建目标视图,改用NavigationLink(tag:selection:)的初始化形式,将可选的pushUser值作为选择绑定源。这种方式下SwiftUI不会提前强持有未触发跳转的目标视图,视图销毁时关联的ViewModel会随视图树的移除同步释放。
  • 不需要额外在ViewModel层添加弱引用修饰:泄漏的根源是SwiftUI导航层的非预期强引用,而非ViewModel之间的引用循环,调整导航实现写法后,UserView使用@ObservedObject持有UserViewModel的逻辑即可正常实现自动释放,不需要额外的手动内存管理操作。

内容的提问来源于stack exchange,提问作者Wiingaard

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.31 07:15:09