SwiftUI内存泄漏问题

3
我在使用 SwiftUI 的 Listid: \.self 时出现了奇怪的内存泄漏问题,只有一些项目被销毁。我正在使用 macOS Monterey Beta 5。
以下是重现步骤:
1. 创建一个新的空白 SwiftUI macOS 项目。 2. 粘贴以下代码:
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. 运行该应用,并点击“删除一半”按钮,保持点击直到所有项都被删除。然而,如果你查看控制台,你会发现只有85个项目被销毁了,而实际上有99个项目。Xcode内存图也证明了这一点。

这似乎是由id: \.self这行代码引起的。将其删除并替换为id: \.text即可解决问题。

但我使用id: \.self的原因是我想支持多重选取,并且我希望选择的类型是Set<TestObj>,而不是Set<UUID>

有没有什么办法可以解决这个问题?


为什么不直接将id设为UUID()呢?另外,我并不太明白你所说的“我想支持多选,并且我想获取选择中对象的实际引用” - List仍然可以通过obj提供对对象的引用。ID只是一个唯一且恒定的值,用于标识列表中的每一行。 - George
@George 我的意思是选择将会是UUID类型,所以每当我想要获取相关的TestObj时,我必须扫描数组(这在有数千个项目的情况下效率不高)。 - recaptcha
ID只是用来唯一标识它的。显示选择位可能会有帮助,因为我看不出ID与之相关性何在。 - George
@George 如果我在列表中更改id:部分,那么选择的类型也必须更改。(它取决于id:是什么) - recaptcha
一种在使用不同的 id 的同时使选择功能在 List 中工作的方法是向列表行添加 .tag(obj) 修饰符,但是一些行仍然没有被释放(测试中约有42个 deinit,而之前为0)。我认为解决方案可能类似于 Swift Collection 的 OrderedDictionary。这样可以保存对象,但每个对象的键是 id。然后,您可以在 O(1) 时间内访问它们,并且将不再通过引用类型来标识或标记项目。 - George
1个回答

2
如果您不必在列表中使用选择,您可以使用任何唯一且恒定的id,例如:
class TestObj: Hashable, Identifiable {
    let id = UUID()

    /* ... */
}

然后你的带有隐式id的List:`id: \.id`。
List(model.objs) { obj in
    Text(obj.text)
}

这很好用。它之所以有效,是因为您不再使用 SwiftUI 保留的引用类型来标识列表中的行。相反,您正在使用值类型,因此没有强引用会导致 TestObj 无法释放。
但是您需要在 List 中进行选择,请参阅下面有关如何实现此目的的更多信息。
为了使其与选择一起使用,我将使用Swift Collections中的OrderedDictionary。这样,列表行仍然可以像上面那样通过id进行标识,但我们可以快速访问它们。它部分是字典,部分是数组,因此按键访问元素的时间复杂度为O(1)。
首先,这里有一个扩展程序,用于从数组创建此字典,以便我们可以通过其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
        }
    }
}

将您的Model对象更改为以下内容:
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)
    }
}

而不是使用 model.objs,你将使用 model.objs.values,但仅此而已!请参见下面的完整演示代码以测试选择:
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()
                }
            }
        }
    }
}

结果:


2
该死!非常感谢您提供如此详细的答案! - recaptcha
我在想,内存泄漏是不是SwiftUI中的一个bug呢? - recaptcha
等一下,但如果这是一个强引用循环,那么难道不是所有的对象都不会被释放吗?(对我来说有85个对象被释放) - recaptcha
@recaptcha 进行了更多的测试:即使您将引用类型作为“id”(我基本上只是创建了一个类,然后保存了“UUID()”),您仍然会遇到选择工作的问题。唯一修复它的方法是手动提供.tag(_:)(因为List现在由不是TestObj的东西标识)。这意味着要使用.tag(obj),而由于obj是一个引用,它会导致内存泄漏。 - George
@recaptcha 这将涉及到 SwiftUI 如何缓存视图和内部工作的问题,这就是为什么我无法确定它的全部工作原理。 - George
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接