ios – Why is passing ViewModel in a Sheet View’s constructor, it cause memory leak?


My goal is to conform to the Dependency Inversion principle. Meaning that SheetView should depend on Sheet ViewModel’s protocol.

The problem is when I pass ViewModel to a Sheet View’s constructor, when I dismiss the Sheet View, it will not deinit.

        .sheet(isPresented: $viewModel.isSheetPresented) {
            let params = SheetViewModelParams(
                initialCount: 0,
                initialViewType: .List
            )
            
            /**
             - Bug: ViewModel will not deinit. Use .init(params:)
             */
//            let viewModel = SheetViewModel(params: params)
//            SheetView(viewModel: viewModel)
            
            SheetView(params: params)
        }

Full code: swift-bloc-example

The code below test:

  1. eagerly load the Sheet View, will only initialize the Sheet ViewModel when the Sheet View is presented.
  2. deinit Sheet ViewModel when the Sheet View is dismissed.
  3. init Sheet List ViewModel when the Sheet List View is presented.
  4. deinit Sheet List ViewModel when the Sheet View or when it present Sheet New View.

=== Code that is necessary for this test.

ContentWithoutStateView.swift

import SwiftUI

struct ContentWithoutStateView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: ContentWithoutStateViewModel
    
    init(params: ContentWithoutStateViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: ContentWithoutStateViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Content Without State")
            
            switch viewModel.onSubmitStatus {
            case .initial:
                Button {
                    Task {
                        await viewModel.onSubmit()
                    }
                } label: {
                    Text("Submit")
                }
                .onAppear {
                    print("\(type(of: self)) initial")
                }
            case .loading:
                ProgressView()
                    .onAppear {
                        print("\(type(of: self)) loading")
                    }
            case .success:
                Text("Success")
                    .onAppear {
                        print("\(type(of: self)) Success")
                    }
            case .failure:
                Text("Failure")
                    .onAppear {
                        print("\(type(of: self)) Failure")
                    }
            }
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
            
            Button {
                viewModel.isSheetPresented = true
            } label: {
                Text("show the sheet")
            }
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("\(type(of: self)) viewModel will change. count: \(renderCount)")
        })
        .sheet(isPresented: $viewModel.isSheetPresented) {
            let params = SheetViewModelParams(
                initialCount: 0,
                initialViewType: .List
            )
            
            /**
             - Bug: ViewModel will not deinit. Use .init(params:)
             */
//            let viewModel = SheetViewModel(params: params)
//            SheetView(viewModel: viewModel)
            
            SheetView(params: params)
        }
    }
}

#Preview {
    let params = ContentWithoutStateViewModelParams()
    return ContentWithoutStateView(params: params)
}

OnSubmitStatus.swift

enum OnSubmitStatus {
    case initial
    case loading
    case success
    case failure
}

ContentWithoutStateViewModelParams

struct ContentWithoutStateViewModelParams {
    let initialCount: Int
    let initialOnSubmitStatus: OnSubmitStatus
    let initialIsSheetPresented: Bool
    
    init(
        initialCount: Int = 0,
        initialOnSubmitStatus: OnSubmitStatus = .initial,
        initialIsSheetPresented: Bool = false
    ) {
        self.initialCount = initialCount
        self.initialOnSubmitStatus = initialOnSubmitStatus
        self.initialIsSheetPresented = initialIsSheetPresented
    }
}

ContentWithoutStateViewModel.swift

final class ContentWithoutStateViewModel: ObservableObject {
    @Published var count: Int
    @Published var onSubmitStatus: OnSubmitStatus
    
    @Published var isSheetPresented: Bool
    
    init(params: ContentWithoutStateViewModelParams) {
        self.count = params.initialCount
        self.onSubmitStatus = params.initialOnSubmitStatus
        self.isSheetPresented = params.initialIsSheetPresented
        print("\(type(of: self)) \(#function)")
    }
    
    deinit {
        print("\(type(of: self)) \(#function)")
    }
    
    func fetchContent() async -> Result<Bool, Error> {
        sleep(1)
        return .success(true)
    }
    
    @MainActor
    func onSubmit() async {
        onSubmitStatus = .loading
        
        let result = await fetchContent()
        
        result.fold { success in
            count += 1
            onSubmitStatus = .success
        } errorTransform: { failure in
            count -= 1
            onSubmitStatus = .failure
        }

    }
}

ContentWithoutStateViewModel+Shared.swift

extension ContentWithoutStateViewModel {
    static func shared(params: ContentWithoutStateViewModelParams) -> ContentWithoutStateViewModel {
        var temp: ContentWithoutStateViewModel
        
        if _shared == nil {
            temp = ContentWithoutStateViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    static weak var _shared: ContentWithoutStateViewModel?
}

==== Sheet

SheetView.swift

struct SheetView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: SheetViewModel
    
    init(params: SheetViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetViewModel.shared(params: params)
        )
    }
    
    @available(
        *,
         deprecated,
         message: "Bug: ViewModel will not deinit when Sheet is dismissed. use .init(params:)")
    init(viewModel: SheetViewModel) {
        self._viewModel = StateObject(wrappedValue: viewModel)
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
            
            Button {
                viewModel.selectedViewType = .List
            } label: {
                Text("show the sheet list")
            }
            
            Button {
                viewModel.selectedViewType = .New
            } label: {
                Text("show the sheet new")
            }
            
            switch viewModel.selectedViewType {
            case .List:
                let params = SheetListViewModelParams(initialCount: 0)
                SheetListView(params: params)
            case .New:
                let params = SheetNewViewModelParams(initialCount: 0)
                SheetNewView(params: params)
            }
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("\(type(of: self)) viewModel will change. count: \(renderCount)")
        })
    }
}

#Preview {
    let params = SheetViewModelParams(
        initialCount: 0, 
        initialViewType: .List
    )
    return SheetView(params: params)
}

SheetViewType.swift

enum SheetViewType {
    case List
    case New
}

SheetViewModelParams.swift

struct SheetViewModelParams {
    let initialCount: Int
    let initialViewType: SheetViewType
}

SheetViewModel.swift

import Foundation

final class SheetViewModel: ObservableObject {
    let id: UUID = UUID()
    
    @Published var count: Int
    @Published var selectedViewType: SheetViewType
    
    init(
        params: SheetViewModelParams
    ) {
        self.count = params.initialCount
        self.selectedViewType = params.initialViewType
        print("\(type(of: self)) \(#function) \(id)")
    }
    
    deinit {
        print("\(type(of: self)) \(#function) \(id)")
    }
}

SheetViewModel.swift

extension SheetViewModel {
    static func shared(params: SheetViewModelParams) -> SheetViewModel {
        var temp: SheetViewModel
        
        if _shared == nil {
            temp = SheetViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    private static weak var _shared: SheetViewModel?
}

=== Sheet List

struct SheetListView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: SheetListViewModel
    
    init(params: SheetListViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetListViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet List")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("\(type(of: self)) viewModel will change. count: \(renderCount)")
        })
    }
}

#Preview {
    let params = SheetListViewModelParams(initialCount: 0)
    return SheetListView(params: params)
}

SheetListViewModelParams.swift

import Foundation

struct SheetListViewModelParams {
    let initialCount: Int
}

SheetListViewModel.swift

import Foundation

final class SheetListViewModel: ObservableObject {
    let id = UUID()
    
    @Published var count: Int
    
    init(params: SheetListViewModelParams) {
        self.count = params.initialCount
        
        print("\(type(of: self)) \(#function) \(id)")
    }
    
    deinit {
        print("\(type(of: self)) \(#function) \(id)")
    }
}

SheetListViewModel.swift

extension SheetListViewModel {
    static func shared(params: SheetListViewModelParams) -> SheetListViewModel {
        var temp: SheetListViewModel
        
        if _shared == nil {
            temp = SheetListViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    private static weak var _shared: SheetListViewModel?
}

=== Sheet New

SheetNew.swift

import SwiftUI

struct SheetNewView: View {
    @State var renderCount = 0
    
    @StateObject var viewModel: SheetNewViewModel
    
    init(params: SheetNewViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetNewViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet New")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("\(type(of: self)) viewModel will change. count: \(renderCount)")
        })
    }
}

#Preview {
    let params = SheetNewViewModelParams(initialCount: 0)
    return SheetNewView(params: params)
}

SheetNewViewModelParams.swift

struct SheetNewViewModelParams {
    let initialCount: Int
}

SheetNewViewModel.swift

import Foundation

final class SheetNewViewModel: ObservableObject {
    let id: UUID = UUID()
    
    @Published var count: Int
    
    init(params: SheetNewViewModelParams) {
        self.count = params.initialCount
        print("\(type(of: self)) \(#function) \(id)")
    }
    
    deinit {
        print("\(type(of: self)) \(#function) \(id)")
    }
}

SheetNewViewModel.swift

extension SheetNewViewModel {
    static func shared(params: SheetNewViewModelParams) -> SheetNewViewModel {
        var temp: SheetNewViewModel
    
        if _shared == nil {
            temp = SheetNewViewModel(params: params)
            _shared = temp
        }
    
        return _shared!
    }
    
    private static weak var _shared: SheetNewViewModel!
}

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img