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:
- eagerly load the Sheet View, will only initialize the Sheet ViewModel when the Sheet View is presented.
- deinit Sheet ViewModel when the Sheet View is dismissed.
- init Sheet List ViewModel when the Sheet List View is presented.
- 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!
}
2
Answers
It seems that this is a legitimate bug on apple’s end. You passing the viewModel to the View’s constructor doesn’t really “cause” the memory leak but it allows it to happen. Otherwise what you are doing should be fine:
See the related issue on Apple dev forums:
https://developer.apple.com/forums/thread/737967?answerId=767599022#767599022
Also, there is a very similar question here:
SwiftUI Sheet never releases object from memory
@James is right.
You should end up with something like this:
In this case, the view model is created inside the sheet block, and its lifecycle is tied to the sheet view. When dismissed,
sheetViewModel
should be deallocated, assuming there are no other strong references to it.