【问题标题】:SwiftUI memory leakSwiftUI 内存泄漏
【发布时间】:2021-08-29 21:48:41
【问题描述】:

在使用Listid: \.self 时,我在SwiftUI 中遇到了奇怪的内存泄漏,其中只有一些项目被破坏。我正在使用 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&lt;TestObj&gt;,而不是Set&lt;UUID&gt;

有没有办法解决这个问题?

【问题讨论】:

  • 为什么不将id 用作UUID()?另外我不太明白"I want to support multiple selection, and I want to get the actual reference to the object in the selection" 是什么意思-List 仍然在obj 中为您提供参考。 ID 只是一个唯一的常量值,用于标识列表中的每一行。
  • @George 我的意思是选择将是 UUID 类型,所以每当我想获得关联的 TestObj 时,我必须扫描数组(这对数千个项目)
  • ID只是为了唯一标识它。显示选择位可能很有用 - 因为我看不出 ID 与它有什么关系。
  • @George 如果我更改列表中的id: 部分,那么选择的类型也必须更改。 (这取决于id: 是什么)
  • List 中进行选择同时使用不同的id 的一种方法是将tag(_:) 修饰符(如.tag(obj))添加到列表行。然而,一些行仍然没有被释放(在测试大约 42 个 deinit,而之前为 0 个)。我认为解决方案可能类似于 Swift Collection 的OrderedDictionary。这样您就可以保存对象,但每个对象的关键是id。然后,您可以在 O(1) 时间内访问它们,并且不再通过引用类型来识别或标记项目。

标签: swift macos swiftui memory-leaks


【解决方案1】:

如果您不必在 List 中使用选择,则可以使用任何唯一且常量 id,例如:

class TestObj: Hashable, Identifiable {
    let id = UUID()

    /* ... */
}

然后你的List 带有隐含的id: \.id

List(model.objs) { obj in
    Text(obj.text)
}

这很好用。它之所以有效,是因为现在您不再通过 SwiftUI 保留的引用类型来识别列表中的行。相反,您使用的是值类型,因此没有任何强引用导致 TestObjs 无法释放。

但是您需要在List 中进行选择,因此请参阅下文了解如何实现。


为了让这个与选择一起工作,我将使用来自Swift CollectionsOrderedDictionary。这样列表行仍然可以像上面一样用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.values 而不是model.objs,仅此而已!

查看下面的完整演示代码以测试选择:

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()
                }
            }
        }
    }
}

结果:

【讨论】:

  • 该死!谢谢你这么详细的回答!
  • 我想知道,内存泄漏是 SwiftUI 中的一个错误吗?
  • @recaptcha 我认为这不一定是 SwiftUI 中的错误,我认为问题更多在于您将 TestObjs 存储在 model 中,然后 SwiftUI 存储那些 TestObj 引用唯一标识列表中的行,创建一个强引用循环。我不能确切地说到底发生了什么,但这很可能与 SwiftUI 如何将具有引用类型的视图存储为与它一起存储的标识符有关。它可能是一个错误 - 不幸的是,我不能 100% 确定。通常最好将您的所有数据都保存为struct,这样就不会发生此类问题。
  • 哦,好的。谢谢你的建议!
  • 等等,但是如果是强引用循环,那么不会没有对象被释放吗? (对我来说,有 85 个对象被释放)