SwiftUI: ObservableObject не сохраняет свое состояние при перерисовке

Проблема

Чтобы получить чистый внешний вид кода приложения, я создаю модели представления для каждого представления, содержащего логику.

Обычная 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 не обновляется. Если состояние родителя изменяется, это представление перерисовывается (это нормально для декларативной платформы). Но также 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 каждый раз?
  • Есть ли способ воспроизвести @State Propertywrapper для @ObservedObject?
  • Почему @State сохраняет состояние над перерисовкой?

Я знаю, что обычно создавать ViewModel во внутреннем представлении - плохая практика, но такое поведение можно воспроизвести с помощью NavigationLink или Sheet.
Иногда тогда просто нецелесообразно сохранять State в ParentsViewModel и работать с привязками, когда вы думаете об очень сложном TableView, где сами ячейки содержат много логики.
Для отдельных случаев всегда есть обходной путь, но я думаю, что было бы намного проще, если бы ViewModel не воссоздавался.

Повторяющийся вопрос

Я знаю, что есть много вопросов, касающихся этой проблемы, и все они говорят об очень конкретных вариантах использования. Здесь я хочу поговорить об общей проблеме, не углубляясь в индивидуальные решения.

Редактировать (добавление более подробного примера)

При наличии ParentView, изменяющего состояние, например списка из базы данных, API или кеша (подумайте о чем-нибудь простом). ЧерезNavigationLinkвы можете перейти на страницу сведений, где вы можете изменить данные. Изменяя данные, реактивный / декларативный шаблон подсказывал нам также обновить ListView, который затем "перерисовал"NavigationLink, что затем привело бы к воссозданию ViewModel.

Я знаю, что могу сохранить ViewModel в ViewModel ParentView / ParentView, но это неправильный способ сделать это, IMO. А поскольку подписки уничтожаются и / или создаются заново - могут быть некоторые побочные эффекты.

5 ответов

Решение

Наконец, есть решение, предоставленное Apple: @StateObject.

Заменив @ObservedObject с участием @StateObject все, что упомянуто в моем первоначальном сообщении, работает.

К сожалению, это доступно только в iOS 14+.

Это мой код из бета-версии Xcode 12 (опубликовано 23 июня 2020 г.)

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 сбрасывается.

Я согласен с вами, я думаю, что это одна из многих основных проблем 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 во время реконструкции.

Есть ли способ не воссоздавать ViewModel каждый раз?

Да, держать ViewModel экземпляр снаружи отSomeView и ввести через конструктор

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

Есть ли способ репликации @State Propertywrapper для @ObservedObject?

Нет нужды. @ObservedObject уже DynamicProperty аналогично @State

Почему @State сохраняет состояние над перерисовкой?

Потому что хранит свое хранилище, т.е. обернутое значение вне поля зрения. (так что снова см. первое выше)

Вам необходимо предоставить индивидуальный PassThroughSubject в твоем ObservableObjectкласс. Взгляните на этот код:

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

Сначала я использовал TextChanger передать новое значение .text к .onReceive(...) в CustomStateПосмотреть. Обратите внимание, чтоonReceive в этом случае получает PassthroughSubject, не ObservableObjectPublisher. В последнем случае у вас будет толькоPublisher.Output в perform: closure, а не NewValue. state.text в этом случае будет иметь старое значение.

Во-вторых, посмотрите на ComplexStateкласс. Я сделалobjectWillChangeсвойство вносить изменения текста, отправлять уведомления подписчикам вручную. Это почти так же, как@Publishedобертка делаю. Но при изменении текста он отправит как, так иobjectWillChange.send() а также textChanged.send(newValue). Это дает вам возможность выбирать в точномView, как реагировать на изменение состояния. Если вам нужно обычное поведение, просто поместите состояние в@ObservedObject обертка в CustomStateContainerПосмотреть. Затем у вас будут воссозданы все представления, и этот раздел также получит обновленные значения:

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

Если вы не хотите, чтобы все они воссоздавались, просто удалите @ObservedObject. Обычный текстовый вид перестанет обновляться, а CustomState -. Без воссоздания.

update: если вам нужен больший контроль, вы можете решить при изменении значения, кого вы хотите сообщить об этом изменении. Проверьте более сложный код:

//
//
//  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.

Это то, что вам нужно?

Мое решение - использовать EnvironmentObject и не использовать ObservedObject при просмотре, его viewModel будет сброшен, вы проходите через иерархию

      .environmentObject(viewModel)

Просто инициализируйте viewModel где-нибудь, он не будет сброшен (пример root view).

Другие вопросы по тегам