SwiftUI:ObservableObject 在重绘时不会保持其状态

Posted

技术标签:

【中文标题】SwiftUI:ObservableObject 在重绘时不会保持其状态【英文标题】:SwiftUI: ObservableObject does not persist its State over being redrawn 【发布时间】:2020-05-08 10:15:13 【问题描述】:

问题

为了实现应用代码的简洁外观,我为每个包含逻辑的视图创建了 ViewModel。

一个普通的 ViewModel 看起来有点像这样:

class SomeViewModel: ObservableObject 

    @Published var state = 1

    // Logic and calls of Business Logic goes here

并像这样使用:

struct SomeView: View 

    @ObservedObject var viewModel = SomeViewModel()

    var body: some View 
        // Code to read and write the State goes here
    

当 Views Parent 没有更新时,这可以正常工作。如果父级的状态发生变化,这个视图会被重绘(在声明性框架中很正常)。 但是 ViewModel 也会被重新创建并且之后不会保持状态。与其他框架(例如:Flutter)相比,这是不寻常的。

在我看来,ViewModel 应该保留,或者 State 应该保留。

如果我将 ViewModel 替换为 @State 属性并直接使用 int(在此示例中),它将保持持久状态并且不会重新创建

struct SomeView: View 

    @State var state = 1

    var body: some View 
        // Code to read and write the State goes here
    

这显然不适用于更复杂的状态。而且,如果我为@State 设置一个类(如 ViewModel),就会有越来越多的事情无法按预期工作。

问题

有没有办法不每次都重新创建 ViewModel? 有没有办法为@ObservedObject 复制@State Propertywrapper? 为什么@State 在重绘时保留 State?

我知道通常在内部视图中创建 ViewModel 是不好的做法,但可以通过使用 NavigationLink 或 Sheet 来复制这种行为。 有时,当您想到一个非常复杂的 TableView 时,将 State 保留在 ParentsViewModel 中并使用绑定是没有用的,其中 Cell 本身包含很多逻辑。 对于个别情况,总有一种解决方法,但我认为如果不重新创建 ViewModel 会更容易。

重复问题

我知道有很多关于这个问题的问题,都在谈论非常具体的用例。在这里我想谈谈一般问题,而不是太深入地讨论自定义解决方案。

编辑(添加更详细的示例)

当拥有一个改变状态的 ParentView 时,比如来自数据库、API 或缓存的列表(想想一些简单的事情)。通过NavigationLink,您可能会到达一个详细信息页面,您可以在其中修改数据。通过更改数据,反应式/声明式模式会告诉我们也更新 ListView,然后“重绘”NavigationLink,这将导致重新创建 ViewModel。

我知道我可以将 ViewModel 存储在 ParentView / ParentView 的 ViewModel 中,但这是 IMO 错误的做法。而且由于订阅被销毁和/或重新创建 - 可能会有一些副作用。

【问题讨论】:

您能找到解决方案吗?我最终创建了一个 environmentObject,因此它只有一个视图模型实例。 【参考方案1】:

终于有苹果提供的解决方案:@StateObject

通过将@ObservedObject 替换为@StateObject,我最初的帖子中提到的一切都正常了。

很遗憾,这仅适用于 ios 14+。

这是我的 Xcode 12 Beta 代码(2020 年 6 月 23 日发布)

struct ContentView: View 

    @State var title = 0

    var body: some View 
        NavigationView 
            VStack 
                Button("Test") 
                    self.title = Int.random(in: 0...1000)
                

                TestView1()

                TestView2()
            
            .navigationTitle("\(self.title)")
        
    


struct TestView1: View 

    @ObservedObject var model = ViewModel()

    var body: some View 
        VStack 
            Button("Test1: \(self.model.title)") 
                self.model.title += 1
            
        
    


class ViewModel: ObservableObject 

    @Published var title = 0


struct TestView2: View 

    @StateObject var model = ViewModel()

    var body: some View 
        VStack 
            Button("StateObject: \(self.model.title)") 
                self.model.title += 1
            
        
    

如您所见,StateObject 在重绘父视图时保持其值,而 ObservedObject 正在重置。

【讨论】:

您好!我想知道您是否找到适用于 iOS 13 的任何解决方案?我真的需要解决这个问题,但我尝试的任何方法都没有奏效。 @AbdulelahHajjar,我认为唯一的方法是在视图层次结构中创建视图模型。在某些时候可能会很棘手。或者更新到14+,这将是最简单的解决方案 感谢@KonDeichmann 的回复,您能否详细说明“在视图层次结构中向下创建 ViewModel”的含义?非常感谢您的宝贵时间。 @AbdulelahHajjar,已经在父视图(或更改状态的视图)中创建了 ViewModel。当您必须将数据传递到该 ViewModel 时,这有时不起作用。我可能会建议更新您的部署目标。 如果StateObject 在其init 中使用参数,例如:MyViewModel(status: 2),假设我们必须从视图外部创建此视图模型,我怎样才能使视图保留其在这种情况下状态?与 ObservableObjects 相比,Apple 不鼓励从视图外部传递 StateObject,但它们不会保留视图重绘时的状态。【参考方案2】:

我同意你的观点,我认为这是 SwiftUI 的许多主要问题之一。这就是我发现自己在做的事情,虽然很恶心。

struct MyView: View 
  @State var viewModel = MyViewModel()

  var body : some View 
    MyViewImpl(viewModel: viewModel)
  


fileprivate MyViewImpl : View 
  @ObservedObject var viewModel : MyViewModel

  var body : some View 
    ...
  

您可以就地构建视图模型或将其传入,它会为您提供一个视图,该视图将在重建过程中维护您的 ObservableObject。

【讨论】:

嘿,你完全正确。我想我在我的问题中也提到了这个解决方案。问题是MyView 的父视图可能会被重绘,我们最终会遇到同样的问题。但实际上我目前以这种方式实施解决方案。但作为一种非常通用的方法,也存在问题。 @KonDeichmann,我认为这不应该是真的。如果修改了父结构,则必须复制/重新创建所有子结构,但其中的任何@State 都应保存在单独的存储中,并在子视图结构 init() 之后恢复。这个“包装器”模式相当于写@State @ObservedObject var viewModel : MyViewModel。它可以防止您的视图模型在重绘时丢失。父级仅引用MyView,这是一个维护MyViewImpl 状态的包装器。【参考方案3】:

有没有办法不每次都重新创建 ViewModel?

是的,保持 ViewModel 实例 SomeView 之外 并通过构造函数注入

struct SomeView: View 
    @ObservedObject var viewModel: SomeViewModel  // << only declaration

有没有办法为@ObservedObject 复制@State Propertywrapper?

不需要。 @ObservedObject 已经是DynamicProperty 类似于@State

为什么@State 在重绘时保持状态?

因为它保留了它的存储空间,即。包装值,视图之外。 (所以,再次参见上面的第一个)

【讨论】:

有时您不需要在每次发生变化时重绘(初始化)整个视图。在我的例子中,init 中有大量的形状计算,我不想在每次发生变化时都执行它。特别是当你有“状态”单例时。您更改了一个属性 - 所有视图都取决于重绘的状态。即使它们依赖于另一个属性。我认为主题启动器处理类似问题。 将状态保留在视图之外确实是一种解决方案,但在某些情况下,它是不切实际的!考虑一个复杂的详细信息页面,您可以在其中更改对象,然后更新父视图上的项目列表(状态更改)。此外,在这种情况下,'@State' 属性保持状态,'@ObservedObject' 不会。 @KonDeichmann,它确实 - 被广泛使用,并且在这里......此外,两者的组合解决方案也是可能的。 但是组合解决方案(@State 和 @ObservedObject 的组合)无法在重绘时保留订阅。当然我可以在视图中“存储”某个状态(这不是一个好习惯,当状态应该隐藏在视图模型中时),但绝对不是订阅。 您好!遗憾的是,我尝试了这个解决方案,但没有成功。我想知道我是否做错了什么。我在视图中有声明,并且我正在从父视图的 View 调用中传递 ViewModel。【参考方案4】:

您需要在 ObservableObject 类中提供自定义 PassThroughSubject。看这段代码:

//
//  Created by Франчук Андрей on 08.05.2020.
//  Copyright © 2020 Франчук Андрей. All rights reserved.
//

import SwiftUI
import Combine


struct TextChanger
    var textChanged = PassthroughSubject<String,Never>()
    public func changeText(newValue: String)
        textChanged.send(newValue)
    


class ComplexState: ObservableObject
    var objectWillChange = ObservableObjectPublisher()
    let textChangeListener = TextChanger()
    var text: String = ""
    
        willSet
            objectWillChange.send()
            self.textChangeListener.changeText(newValue: newValue)
        
    


struct CustomState: View 
    @State private var text: String = ""
    let textChangeListener: TextChanger
    init(textChangeListener: TextChanger)
        self.textChangeListener = textChangeListener
        print("did init")
    
    var body: some View 
        Text(text)
            .onReceive(textChangeListener.textChanged)newValue in
                self.text = newValue
            
    

struct CustomStateContainer: View 
    //@ObservedObject var state = ComplexState()
    var state = ComplexState()
    var body: some View 
        VStack
            HStack
                Text("custom state View: ")
                CustomState(textChangeListener: state.textChangeListener)
            
            HStack
                Text("ordinary Text View: ")
                Text(state.text)
            
            HStack
                Text("text input: ")
                TextInput().environmentObject(state)
            
        
    


struct TextInput: View 
    @EnvironmentObject var state: ComplexState
    var body: some View 
        TextField("input", text: $state.text)
    


struct CustomState_Previews: PreviewProvider 
    static var previews: some View 
        return CustomStateContainer()
    

首先,我使用TextChangerCustomState 视图中将.text 的新值传递给.onReceive(...)。请注意,在这种情况下,onReceive 得到PassthroughSubject,而不是ObservableObjectPublisher。在最后一种情况下,perform: closure 中只有 Publisher.Output,而不是 NewValue。 state.text 在这种情况下将具有旧值。

其次,查看ComplexState 类。我创建了一个objectWillChange 属性,以使文本更改手动向订阅者发送通知。它几乎和@Published wrapper 一样。但是,当文本更改时,它将同时发送objectWillChange.send()textChanged.send(newValue)。这使您能够准确地选择View,如何对状态变化做出反应。如果您想要普通行为,只需将状态放入 @ObservedObject 包装器中的 CustomStateContainer 视图中。然后,您将重新创建所有视图,并且此部分也将获得更新的值:

HStack
     Text("ordinary Text View: ")
     Text(state.text)

如果您不想重新创建所有这些,只需删除 @ObservedObject。普通文本 View 会停止更新,但 CustomState 会。无需重新创建。

更新: 如果您想要更多控制权,您可以在更改值时决定您希望通知谁来了解该更改。 查看更复杂的代码:

//
//
//  Created by Франчук Андрей on 08.05.2020.
//  Copyright © 2020 Франчук Андрей. All rights reserved.
//

import SwiftUI
import Combine


struct TextChanger
//    var objectWillChange: ObservableObjectPublisher
   // @Published
    var textChanged = PassthroughSubject<String,Never>()
    public func changeText(newValue: String)
        textChanged.send(newValue)
    


class ComplexState: ObservableObject
    var onlyPassthroughSend = false
    var objectWillChange = ObservableObjectPublisher()
    let textChangeListener = TextChanger()
    var text: String = ""
    
        willSet
            if !onlyPassthroughSend
                objectWillChange.send()
            
            self.textChangeListener.changeText(newValue: newValue)
        
    


struct CustomState: View 
    @State private var text: String = ""
    let textChangeListener: TextChanger
    init(textChangeListener: TextChanger)
        self.textChangeListener = textChangeListener
        print("did init")
    
    var body: some View 
        Text(text)
            .onReceive(textChangeListener.textChanged)newValue in
                self.text = newValue
            
    

struct CustomStateContainer: View 
    //var state = ComplexState()
    @ObservedObject var state = ComplexState()
    var body: some View 
        VStack
            HStack
                Text("custom state View: ")
                CustomState(textChangeListener: state.textChangeListener)
            
            HStack
                Text("ordinary Text View: ")
                Text(state.text)
            
            HStack
                Text("text input with full state update: ")
                TextInput().environmentObject(state)
            
            HStack
                Text("text input with no full state update: ")
                TextInputNoUpdate().environmentObject(state)
            
        
    


struct TextInputNoUpdate: View 
    @EnvironmentObject var state: ComplexState
    var body: some View 
        TextField("input", text: Binding(   get: self.state.text,
                                            set: newValue in
                                                self.state.onlyPassthroughSend.toggle()
                                                self.state.text = newValue
                                                self.state.onlyPassthroughSend.toggle()
        
        ))
    


struct TextInput: View 
    @State private var text: String = ""
    @EnvironmentObject var state: ComplexState
    var body: some View 

        TextField("input", text: Binding(
            get: self.text,
            set: newValue in
                self.state.text = newValue
               // self.text = newValue
            
        ))
            .onAppear()
                self.text = self.state.text
            .onReceive(state.textChangeListener.textChanged)newValue in
                self.text = newValue
            
    


struct CustomState_Previews: PreviewProvider 
    static var previews: some View 
        return CustomStateContainer()
    

我做了一个手动绑定来停止广播 objectWillChange。但是您仍然需要在更改此值以保持同步的所有地方获取新值。这也是我修改 TextInput 的原因。

这是你需要的吗?

【讨论】:

一般来说,我认为@State是供Views内部使用的。 @ObservedObject 用于在视图之间更改数据。因此ObservableObject 仅适用于类——它们是可以在其他视图中使用的链接。就像您将它们放在init() 中一样作为输入参数。你需要做什么,你不确切知道什么,发生了什么变化?重绘批发View 我认为我们谈论的不是同一件事。我知道@State@ObservedObject 的用途。当我正确理解您时,您只是尝试使用 ObservableObjects 重建@State。我看到的问题是保持订阅和状态超过父视图的重绘 inObservableObject!您可能会明白我的意思,如果您使用“父视图”构建一个非常简单的应用程序,它会在按下按钮时更改其状态,并且子视图做同样的事情,将两个状态保持在ObservableObjects (使用@Published)并将prints 放入initdeinit 您的问题是关于使用@State 变量的默认值。当您在此视图之外更改某些内容(无论是@ObservedObject 中的另一个属性,还是@EnvironmentObject 或通过init() 传递的参数),它将被完全销毁并再次创建。因此,您的 @State 变量设置为其默认值。我向您展示了一种如何通过使用PassthroughSubject 发送更改来避免破坏视图的方法。 —— 请看这个要点:gist.github.com/konDeichmann/9b4e2e7947068ffd906966edddf1d093 尝试替换新创建的 SwiftUI 项目的 ContentView 并使用 3 个按钮。如您所见,@State 将保持其值,而 @ObservedObject 将始终在 ParentView 状态更改时重置。 看起来@State 在重新创建View 后能够恢复其值,而@ObservedObject 则不行。如果你把print() 放入CounterStateView init() 你也会看到它被重绘,但是在初始化完成后,它会回复旧值。尝试创建状态视图的初始化: init() self._counter = State(initialValue: 1) print("init state (with value of (counter))")

以上是关于SwiftUI:ObservableObject 在重绘时不会保持其状态的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI/组合监听 ObservableObject 中的多个发布者

SwiftUI = ObservableObject 作为类的选择

SwiftUI 致命错误:未找到“”类型的 ObservableObject

在 SwiftUI 中将 ObservableObject 链接到 DatePicker

SwiftUI - 在 ObservableObject 类/依赖注入中使用 EnvironmentObject

SwiftUI:ObservableObject 在重绘时不会保持其状态