Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI memory leak

I'm getting a weird memory leak in SwiftUI when using List and id: \.self, where only some of the items are destroyed. I'm using macOS Monterey Beta 5.

Here is how to reproduce:

  1. Create a new blank SwiftUI macOS project
  2. Paste the following code:
class Model: ObservableObject {
    @Published var objs = (1..<100).map { TestObj(text: "\($0)")}
}
class TestObj: Hashable {
    let text: String
    static var numDestroyed = 0
    
    init(text: String) {
        self.text = text
    }
    static func == (lhs: TestObj, rhs: TestObj) -> Bool {
        return lhs.text == rhs.text
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(text)
    }
    
    deinit {
        TestObj.numDestroyed += 1
        print("Deinit: \(TestObj.numDestroyed)")
    }
}
struct ContentView: View {
    @StateObject var model = Model()
    
    var body: some View {
        NavigationView {
            List(model.objs, id: \.self) { obj in
                Text(obj.text)
            }
            Button(action: {
                var i = 1
                model.objs.removeAll(where: { _ in
                    i += 1
                    return i % 2 == 0
                })
            }) {
                Text("Remove half")
            }
        }
    }
}
  1. Run the app, and press the "Remove half" button. Keep pressing it until all the items are gone. However, if you look at the console, you'll see that only 85 items have been destroyed, while there were 99 items. The Xcode memory graph also supports this.

This seems to be caused by the id: \.self line. Removing it and switching it out for id: \.text fixes the problem.

However the reason I use id: \.self is because I want to support multiple selection, and I want the selection to be of type Set<TestObj>, instead of Set<UUID>.

Is there any way to solve this issue?

like image 891
recaptcha Avatar asked Dec 17 '25 15:12

recaptcha


1 Answers

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 TestObjs 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:

like image 154
George Avatar answered Dec 20 '25 08:12

George



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!