为什么 SwiftUI 会召回包含陈旧数据的现有视图主体?

Why does SwiftUI recall an existing view's body which contains stale data?

我最近开始编写我的第一个 SwiftUI 应用程序并且 运行 陷入了一个设计问题,可以通过下面的简单代码来演示。我知道问题出在哪里,但我想知道它为什么会发生以及建议的解决方法是什么。

在演示中,有两个视图。第一个是列表视图,第二个是详细视图。详细视图包含一个按钮,用于删除正在显示的项目。由于详细视图中的强制展开,单击按钮会使应用程序崩溃。我没想到会崩溃,因为根据我的理解,当数据模型发生变化时,SwiftUI 应该重新生成整个视图层次结构。也就是说,它调用 ContentView.body,后者调用 FooListView.body,后者遍历数据模型中的项目并为每个项目创建 NavigationLink

由于更改了数据模型,因此只剩下一项。所以我认为 SwiftUI 不会为删除的项目创建 FooDetailView (或称其主体)。如果是这样,为什么 FooDetailView 代码会崩溃?我试图调试代码,但没有找到太多有用的信息。我相信导致应用程序崩溃的 FooDetailView 就是包含已删除项目的那个。我不明白。由于 SwiftUI 重新生成视图层次结构,旧视图怎么可能没有清理干净?

谁能解释一下你是怎么理解的?你如何解决这个问题?目前想出两种办法。第一种是传递详细视图所需的所有参数,以避免访问数据模型。但我不认为这种方法可以扩展。二是不使用强制包装。这应该可以正常工作,但我怀疑这是否是推荐的方法。

顺便说一句,另一个类似的生成崩溃的设置是使用三个视图:列表视图 -> 详细视图 -> 删除视图。当用户在删除视图中单击按钮时,详细视图将崩溃。

谢谢。

更新:

  1. SwiftUI 可能会调用包含陈旧数据的现有视图主体

@jrturton 我知道对 @EnvironmentObject 的更改会导致调用视图的主体。我没有意识到的是 SwiftUI 可能会调用包含陈旧数据的现有视图主体。我从来没有在网上读过任何关于这个的讨论。你知道为什么 SwiftUI 会那样做吗?

我一直认为当状态发生变化时,SwiftUI 会通过调用Content.body 从上到下重新生成整个视图层次结构。如果是这样,FooDetailView 在被调用时将始终拥有最新数据,并且不会有问题。我之所以理解,是因为 SwiftUI 被宣传为状态驱动架构,应用程序开发人员应该根据当前状态声明 UI。 “当前”是指新状态,而不是以前的状态。这就是为什么我认为使用强制展开应该没问题的原因。

  1. 我怀疑将所有参数传递给详细视图是否是一个通用的解决方案

首先,这不能很好地扩展。例如,假设 Foo 与另一个结构 Bar 相关联(即 Foo 有一个 属性 包含 Bar 的 id)并且我们想要显示详细视图中的酒吧名称,然后我们需要将酒吧名称添加到参数中。对于一个复杂的Item,它的detail view可能包含很多在运行时确定的东西,调用者很难提前准备好一切。

更重要的是,一旦我们将这些参数传递给详细视图,它们实际上就处于数据模型之外,很容易过时。如果用户使用这些陈旧数据执行删除操作,将会出现问题。

  1. 传递绑定本身并不能解决崩溃问题

@lorem-ipsum,感谢您对使用绑定的建议。我不知道 ForEach 可以绑定。更重要的是,我也一直在思考在 SwiftUI 中传递绑定而不是常规参数是否是一个好习惯(我还不知道那个答案)。

也就是说,传递绑定本身并不能解决崩溃问题,因为当数据模型发生变化时,SwiftUI 仍然会调用包含陈旧数据的现有详细视图的主体。

  1. 解决方法?

我认为根本原因是,虽然 SwiftUI 被宣传为状态驱动架构,但视图的主体可能会被陈旧数据调用。所以数据模型的 api 应该处理无效的参数。老实说,这不是我喜欢的设计决策。我通常认为调用者应该只将有效参数传递给数据模型 API。否则,这是一个架构问题,应该首先在调用方解决。不幸的是,SwiftUI.

似乎就是这种情况

(注意:感谢大家指出删除代码应该在数据模型中。我知道这一点。我没有这样做是因为我花了很长时间在我的应用程序中调查这个问题,并且在准备示例代码。)

import SwiftUI

struct Foo: Identifiable {
    var id: Int
    var value: Int
}

class DataModel: ObservableObject {
    @Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
}

struct FooListView: View {
    @StateObject var dataModel = DataModel()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(dataModel.foos) { foo in
                    NavigationLink {
                        FooDetailView(fooID: foo.id)
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
        .environmentObject(dataModel)
    }
}

struct FooDetailView: View {
    @EnvironmentObject var dataModel: DataModel
    var fooID: Int
    
    var body: some View {
        // Issue: the forced unwrapping may crashe the app!
        let index = dataModel.foos.firstIndex(where: { [=10=].id == fooID })! 
        
        VStack {
            Text("\(dataModel.foos[index].value)")
            Button("Delete It") {
                dataModel.foos.remove(at: index)
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        FooListView()
    }
}

@EnvironmentObject 值都是观察对象,当你从数组中删除项目时,你会触发对 re-render 的观察视图。在您的示例中,详细视图的 re-rendering 是在列表之前执行的,因此即使您的详细视图的主体即将从屏幕上删除,也会重新计算它。

您传递更多参数的本能是明智的 - 传递整个 Foo 并添加一个 dataModel.delete(foo) 方法是有意义的,这样您就不会暴露数据模型的存储方式它的数据。

首先,回答我原来的问题。该行为是由于 ObservableObject(或 EnvironmentObject)使视图无效的方式。 ListViewDetailView 都通过订阅 ObservableObjectobjectWillChange 发布者来监控数据模型的变化。由于无法控制哪个订阅者先收到更改,因此调用哪个视图的主体是未定义的。实际上,首先调用的是 DetailView 的正文,这就是详细信息视图中的常规 属性 未更新的原因。

请注意,虽然在这种情况下我们可以给出具体的解释并相应地修改我们的代码,但通常在调用视图的主体时根据我们的理解编写代码是不可靠的。 SwiftUI 可能会在意外的时间调用视图的主体。参见 。我相信何时以及如何调用视图的主体应该被视为 SwiftUI 实现细节。换句话说,它是未定义的。

为什么这是个问题?嗯,这是因为当我们在主体代码(视图渲染代码或意图代码)中调用数据模型 API 时,我们需要将视图特定数据(例如,示例代码中视图的常规属性)作为参数传递给API。请注意,虽然这些特定于视图的数据是从数据模型中检索的,但一旦将它们保存在视图中,它们随时可能会过时。由于我使用强制展开,它导致崩溃。

为什么要使用强制展开?这是因为我低估了 SwiftUI 的复杂性。我认为如何使视图无效是简单且可预测的。我还认为当数据模型发生变化时,将首先重新创建整个视图层次结构,然后更改的视图将调用它们的主体(因此视图特定数据和数据模型始终是一致的)。结果都错了

那么,如何解决视图特定数据和数据模型不一致的问题?

方法 1:使用绑定。

这是苹果的做法。参见 here。这种方法看起来不错。但不幸的是,它在大多数实际应用中似乎并不可行,至少有两个原因:

  1. 只适合简单的数据模型。对于复杂的数据模型(例如,它包含银行账户和 t运行sers,它们通过 id 引用每个)和深层视图层次结构(例如,账户 A 详细视图 -> t运行sfer 视图 -> 账户 B 详细信息view -> ...), 不可能提前准备好所有的数据,因此不得不调用数据模型API。顺便说一句,我做了实验,将视图特定数据和数据模型作为绑定传递,它似乎有效,但我 运行 进入第二期。

  2. 传递给深层视图层次结构时,绑定无法正常工作。在我的实验中,它导致了奇怪的视图身份问题。我想引用@Asperi 对此的评论(他在 SwiftUI 上有很多很好的答案,他在一个不相关的问题中发表了评论)。

Binding works bad being transferred into deep view hierarchy, use instead ObservableObject based view model to inject for each next view layer, with some communication between view model to update only needed properties.

方法 2:使用通常的视图模型方法。

这需要验证参数。有不同的方法:

  • 将特定于视图的数据移动到数据模型对象并将验证封装在数据模型中。

  • 在正文中的视图渲染代码中执行。

无论哪种方式,数据模型 api 都需要检查无效参数(也就是说,它不应该强制解包)。

请注意,此方法中的代码看起来不像 Apple 的方法那么简单。它还可能需要使用 if 语句。这让我很困惑,因为它并不像 Apple 宣传的那样 SwiftUI。但是以我目前的理解,写出实际的应用就必须这样了。


这个问题一直困扰着我,我想强调一下数据模型API的架构要求要宽松,因为 a) 视图的主体可以在意想不到的时间调用,b) 因此无法确保视图特定数据和数据模型一致,c) 因此数据模型 API 必须处理无效参数,如果我们想在正文中调用它.

Apple 的情况有点不同。但是如上所述,它只适用于简单的情况。