外部模型对象的 SwiftUI 数据流和 UI 更新,其值发生变化但 id 相同

Posted

技术标签:

【中文标题】外部模型对象的 SwiftUI 数据流和 UI 更新,其值发生变化但 id 相同【英文标题】:SwiftUI data flow and UI update for foreign model object with changing values but same id 【发布时间】:2020-07-10 12:59:22 【问题描述】:

我有一些第三方库的对象(“事物”)数组,我想在 SwiftUI 视图中显示它们。 这些“事物”对象可以通过 id 识别和散列,但是当重新加载一组新事物时,它们的内容可能已经改变(比如说那个事物的“状态”或“文本”,尽管它又是同一个事物)。所以 id 保持不变,但 Thing 的内容可以改变。 问题是,当我得到一个新的事物数组时,SwiftUI 不会更新 UI。我认为这是因为事物被它们的 id 再次“识别”为相同的事物。 我无法更改 Thing,因为它来自第三方库。

现在我只是简单地将 Thing 包装到另一个类中,突然它就可以工作了!但我想了解为什么这行得通,以及它是否是已定义的行为,而不仅仅是巧合或“运气”。

谁能解释一下幕后发生了什么?特别是 DirectThingView 和 WrappedThingView 之间导致 SwiftUI 为后者而不是前者更新 UI 的主要区别是什么?

或者有什么建议可以更好地解决这个问题?

这是显示所有内容的示例代码: 它在两列中显示事物;第一列使用 DirectThingView,第二列使用 WrappedThingView。如果您点击“重新加载”按钮,事物数组将被更改的事物填充,但只有右列的 UI 正确更新值;左列始终保持其初始状态。

//
//  TestView.swift
//
//  Created by Manfred Schwind on 10.07.20.
//  Copyright © 2020 mani.de. All rights reserved.
//

import SwiftUI

// The main model contains an array of "Things",
// every Thing has an id and contains a text.
// For testing purposes, every other time a Thing gets instantiated, its text contains either "A" or "B".
// Problem here: the "current" text of a Thing with the same id can change, when Things are reloaded.
class TestViewModel: ObservableObject 
    @Published var things = [Thing(id: 1), Thing(id: 2), Thing(id: 3)]


struct TestView: View 
    @ObservedObject var viewModel = TestViewModel()
    var body: some View 
        VStack (spacing: 30) 
            HStack (spacing: 40) 
                // We try to display the current Thing array in the UI

                // The views in the first column directly store the Thing:
                // Problem here: the UI does not update for changed Things ...
                VStack 
                    Text("Direct")
                    ForEach(self.viewModel.things, id: \.self)  thing in
                        DirectThingView(viewModel: thing)
                    
                
                // The views in the second column store the Thin wrapped into another class:
                // In this case, the problem magically went away!
                VStack 
                    Text("Wrapped")
                    ForEach(self.viewModel.things, id: \.self)  thing in
                        WrappedThingView(viewModel: thing)
                    
                
            
            Button(action: 
                // change the Thing array in the TestViewModel, this causes the UI to update:
                self.viewModel.things = [Thing(id: 1), Thing(id: 2), Thing(id: 3)]
            ) 
                Text("Reload")
            
        
    


struct DirectThingView: View 
    // first approach just stores the passed Thing directly internally:
    private let viewModel: Thing

    init(viewModel: Thing) 
        self.viewModel = viewModel
    

    var body: some View 
        Text(self.viewModel.text)
    


struct WrappedThingView: View 
    // second approach stores the passed Thing wrapped into another Object internally:
    private let viewModel: WrappedThing

    init(viewModel: Thing) 
        // take the Thing like in the first approach, but internally store it wrapped:
        self.viewModel = WrappedThing(childModel: viewModel)
    

    var body: some View 
        Text(self.viewModel.childModel.text)
    

    // If type of WrappedThing is changed from class to struct, then the problem returns!
    private class WrappedThing 
        let childModel: Thing
        init(childModel: Thing) 
            self.childModel = childModel
        
    



// Thing has do be Identifiable and Hashable for ForEach to work properly:
class Thing: Identifiable, Hashable 

    // Identifiable:
    let id: Int

    // The text contains either "A" or "B", in alternating order on every new Thing instantiation
    var text: String

    init(id: Int) 
        self.id = id
        struct Holder 
            static var flip: Bool = false
        
        self.text = Holder.flip ? "B" : "A"
        Holder.flip = !Holder.flip
    

    // Hashable:
    public func hash(into hasher: inout Hasher) 
        hasher.combine(self.id)
    

    // Equatable (part of Hashable):
    public static func == (lhs: Thing, rhs: Thing) -> Bool 
        return lhs.id == rhs.id
    


#if DEBUG
struct TestView_Previews: PreviewProvider 
    static var previews: some View 
        TestView()
    

#endif

非常感谢您!

【问题讨论】:

【参考方案1】:

我自己找到了答案。问题是这里的 Equatable of Thing 的实现。如上所述,同一事物(但内容不同)的旧版本和新版本被认为是相同的。但是 SwiftUI 区分了“身份”和“平等”,这必须正确实现。

在上面的代码中, Identifiable 和 Hashable 都可以,但 Equatable 必须更改为更精确。所以例如这解决了问题:

// Thing has do be Identifiable and Hashable for ForEach to work properly:
class Thing: Identifiable, Hashable 

    // Identifiable:
    let id: Int

    // The text contains either "A" or "B", in alternating order on every new Thing instantiation
    var text: String

    init(id: Int) 
        self.id = id
        struct Holder 
            static var flip: Bool = false
        
        self.text = Holder.flip ? "B" : "A"
        Holder.flip = !Holder.flip
    

    // Hashable:
    public func hash(into hasher: inout Hasher) 
        // we are lazy and just use the id here, "collisions" are then separated by func ==
        hasher.combine(self.id)
    

    // Equatable (part of Hashable):
    public static func == (lhs: Thing, rhs: Thing) -> Bool 
        // We are lazy again (in reality Thing has many properties) and we consider
        // two Things to be the equal ONLY when they point to the same address.
        // So we get the "same but different" semantic that we want, when we are
        // getting a new version of the Thing.
        // (Same in the sense of identity, different in the sense of equality)
        return lhs === rhs
    

【讨论】:

以上是关于外部模型对象的 SwiftUI 数据流和 UI 更新,其值发生变化但 id 相同的主要内容,如果未能解决你的问题,请参考以下文章

如何为不同环境的 SwiftUI App 生命周期应用程序运行 UI 测试?

SwiftUI 中的领域:UI 中仅偶尔保存对象的实时文本

SwiftUI之深入解析如何处理特定的数据和如何在视图中适配数据模型对象

Swift UI - 如何从外部访问 @State 变量?

SwiftUI - 环境对象未更新 UI

将 MVC 之类的设计模式与 SwiftUI 结合使用