SwiftUI memory leak
If you didn't have to use selection in your List
, you could use any unique & constant id
, for example:
class TestObj: Hashable, Identifiable {
let id = UUID()
/* ... */
}
And then your List
with the implicit id: \.id
:
List(model.objs) { obj in
Text(obj.text)
}
This works great. It works because now you are no longer identifying the rows in the list by a reference type, which is kept by SwiftUI. Instead you are using a value type, so there aren't any strong references causing TestObj
s to not deallocate.
But you need selection in List
, so see more below about how to achieve that.
To get this working with selection, I will be using OrderedDictionary
from Swift Collections. This is so the list rows can still be identified with id
like above, but we can quickly access them. It's partially a dictionary, and partially an array, so it's O(1) time to access an element by a key.
Firstly, here is an extension to create this dictionary from the array, so we can identify it by its id
:
extension OrderedDictionary {
/// Create an ordered dictionary from the given sequence, with the key of each pair specified by the key-path.
/// - Parameters:
/// - values: Every element to create the dictionary with.
/// - keyPath: Key-path for key.
init<Values: Sequence>(_ values: Values, key keyPath: KeyPath<Value, Key>) where Values.Element == Value {
self.init()
for value in values {
self[value[keyPath: keyPath]] = value
}
}
}
Change your Model
object to this:
class Model: ObservableObject {
@Published var objs: OrderedDictionary<UUID, TestObj>
init() {
let values = (1..<100).map { TestObj(text: "\($0)")}
objs = OrderedDictionary<UUID, TestObj>(values, key: \.id)
}
}
And rather than model.objs
you'll use model.objs.values
, but that's it!
See full demo code below to test the selection:
struct ContentView: View {
@StateObject private var model = Model()
@State private var selection: Set<UUID> = []
var body: some View {
NavigationView {
VStack {
List(model.objs.values, selection: $selection) { obj in
Text(obj.text)
}
Button(action: {
var i = 1
model.objs.removeAll(where: { _ in
i += 1
return i % 2 == 0
})
}) {
Text("Remove half")
}
}
.onChange(of: selection) { newSelection in
let texts = newSelection.compactMap { selection in
model.objs[selection]?.text
}
print(texts)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
EditButton()
}
}
}
}
}
Result:
SwiftUI - Possible Memory Leak
Your code appears to be perfectly acceptable SwiftUI, and there does appear to be a memory leak somewhere, as switching back and forth (even with a manual Toggle()
instead of the asyncAfter()
call) leads to increasing memory.
I believe this is a bug with List, because if you change the List to another type of view, the issue disappears, and I haven't noticed it when using this same pattern with all other kinds of views.
I'd recommend you file feedback with Apple, and post the feedback number here so others affected can file their own and reference it.
SwiftUI - memory leak in NavigationView
You don't need to split the close button out in its own view. You can solve this memory leak by adding a capture list to the NavigationView's closure: this will break the reference cycle that retains your viewModel
.
You can copy/paste this sample code in a playground to see that it solves the issue (Xcode 11.4.1, iOS playground).
import SwiftUI
import PlaygroundSupport
struct ModalView: View {
@Environment(\.presentationMode) private var presentation
@ObservedObject var viewModel: ViewModel
var body: some View {
// Capturing only the `presentation` property to avoid retaining `self`, since `self` would also retain `viewModel`.
// Without this capture list (`->` means `retains`):
// self -> body -> NavigationView -> Button -> action -> self
// this is a retain cycle, and since `self` also retains `viewModel`, it's never deallocated.
NavigationView { [presentation] in
Text("Modal is presented")
.navigationBarItems(leading: Button(
action: {
// Using `presentation` without `self`
presentation.wrappedValue.dismiss()
},
label: { Text("close") }))
}
}
}
class ViewModel: ObservableObject { // << tested view model
init() {
print(">> inited")
}
deinit {
print("[x] destroyed")
}
}
struct TestNavigationMemoryLeak: View {
@State private var showModal = false
var body: some View {
Button("Show") { self.showModal.toggle() }
.sheet(isPresented: $showModal) { ModalView(viewModel: ViewModel()) }
}
}
PlaygroundPage.current.needsIndefiniteExecution = true
PlaygroundPage.current.setLiveView(TestNavigationMemoryLeak())
SwiftUI memory leak when referencing property from closure inside Form/NavigationView and swapping views
I found a solution: Make a weak viewModel
in your action. It seems that Apple changed the behavior of closures. This means that the NavigationView
is storing a strong reference to viewModel. After a couple days of debugging, it finally worked for me.
Button(action: {
[weak viewModel] in viewModel?.dismissButtonPressed.send(())
}) {
Image("crossmark")
.padding()
.foregroundColor(Color.white)
}
}
In your problem, this will be solved like this:
NavigationView {
[weak viewModel] in Button(action: { viewModel?.logOut() }) {
Text("X").frame(width: 40, height: 40)
}
}
Tested on the latest Xcode 11.5, with iOS 13.5. Now, after dismissing the view, the viewModel
is correctly deallocated.
How do you fix a SwiftUI memory leak caused by compound modifiers + animation?
This appears to be related to embedding mutation inside a ForEach, which looking at the memory graphs may involve List (which also seems to have a memory leak when its elements are mutated). I recommend opening a Feedback on this.
It can be eliminated by removing the ForEach:
func square(at offset: Int) -> some View {
let mySquare = mySquares[offset]
return MySquareView()
.offset(x: mySquare.offsetX,
y: mySquare.offsetY)
}
var body: some View {
VStack {
ZStack {
square(at: 0)
square(at: 1)
square(at: 2)
square(at: 3)
}
.scaleEffect(0.8)
}
}
You can also hack your way around this by injecting a recursive AnyView rather than using ForEach. There may be other clever solutions like this; it may be worth exploring further, since losing both ForEach and List is quite obnoxious.
func loopOver<C: Collection, V: View>(_ list: C, content: (C.Element) -> V) -> AnyView
{
guard let element = list.first else { return AnyView(EmptyView()) }
return AnyView(Group {
content(element)
loopOver(list.dropFirst(), content: content)
})
}
var body: some View {
VStack {
ZStack {
loopOver(mySquares) { MySquareView().offset(x: $0.offsetX, y: $0.offsetY )}
}
.scaleEffect(0.8)
}
}
(Using AnyView like this will get in the way of various SwiftUI optimizations, so it's a last resort, but in this case it is likely necessary.)
Related Topics
Change Uibarbuttonitem from Uisearchbar
Distinction Between Private and Fileprivate Top-Level Classes
Multiple Bottom Sheets - the Content Doesn't Load Swiftui
Create Codable Struct with Generic Type
How to Validate Dynamically Added Textfields on a Button Click in Swiftui
How to Use Keywords as Parameter Names in Swift
Coreplot with Swift: There Is No Yaxis.Majorintervallength
How to Retrieve a Random Object from Firebase Using a Sequential Id
Swift Parsing Attribute Name for Given Elementname
Getting Country Name from Country Code
Swiftui: Prevent Image() from Expanding View Rect Outside of Screen Bounds
Swift Error: Binary Operator '&&' Cannot Be Applied to Two 'Bool' Operands
Swift: Corelocation Handling Nserror in Didfailwitherror