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