Lifecycle of SwiftUI View - @Observable vs ObservableObject

@Observable / onChanged()
2024-02-06 07.52.10

@Published in ObservableObject /
.onReceive()
2024-02-06 07.52.34

Both perform the same on the surface, but this is causing a performance issue in my app.
I have a lot of confusion here.

  1. Does onChange(of:perform:) destroy and create new value=view?

    • init() of child view is called when:

      • A property of observableModel is changed
      • @State isHovered is changed
    • Looks like reusing? the view to redraw with new values when:

      • A property of publishedModel is changed (received)
        • This doesn’t call init() of child view.

      How to use onReceive() like this if @Observable macro can replace ObservableObject protocol?

  2. Difference between model declaration using @Observable vs ObservableObject

    Migrating from the Observable Object protocol to the Observable macro | Apple Developer Documentation

    If you have a list of caveats where this migration can cause problems, please share.

I was not aware that calling onChange() initializes a view every time (even if it performs nothing!), which was/is causing performance issues in my app. Also, the biggest problem for me is: if a view has .onChange() modifier as a part of view, the all sibling views are going to be regenerated with init().

To avoid this, using onReceive() with @Published in ObservableObject works, but I’d love to know how to do that with a model with @Observable,
Or if there is a way to avoid recreation of view while using onChange() that would be great.

Main View

struct ContentView: View {
    @State private var isHovered: Bool = false

    var publishedModel = PublishedModel()
    var observableModel = ObservableModel()

    var body: some View {
        let _ = Self._printChanges()
        
        HStack {
            LeftView($isHovered)
            RightView()
//                .onReceive(publisher.$array) { value in
//                    print("publisher.array:", publisher.array)
//                }
                .onChange(of: observableModel.array) {} // This demon...
        }
        .padding()
        .background(.white)
        .onHover {
            isHovered = $0
            publishedModel.value = "\(isHovered)"
            publishedModel.array.append(publishedModel.array.count)
            observableModel.value = "\(isHovered)"
            observableModel.array.append(observableModel.array.count)
        }
    }
}

Models

import Observation
import SwiftUI

class PublishedModel: ObservableObject {
    @Published var value: String
    @Published var array: [Int]

    init() {
        value = "Hi!"
        array = [0, 1, 2, 3, 4]
    }
}

@Observable
class ObservableModel {
    var value: String
    var array: [Int]

    init() {
        value = "Hi!"
        array = [0, 1, 2, 3, 4]
    }
}

Children View


struct LeftView: View {
    @Binding var isHovered: Bool
    init(_ isHovered: Binding<Bool>) {
        _isHovered = isHovered
        print(Date.now, "LeftView.init()")
    }

    var body: some View {
        let _ = Self._printChanges()

        Text("LeftView\n\(Date.now)")
            .padding()
            .background(.red)
            .opacity(isHovered ? 1 : 0)
    }
}

struct RightView: View {
    init() {
        print(Date.now, "RightView.init()")
    }

    var body: some View {
        let _ = Self._printChanges()

        Text("RightView\n\(Date.now)")
            .padding()
            .background(.blue)
    }
}

Okay, I guess this is how you use @Published in @Observable model.
At least in this demo code, seems same behavior as ObservableObject. So I go with this.

@Observable
class ObservableModel {
    @ObservationIgnored @Published var value: String
    @ObservationIgnored @Published var array: [Int]

    init() {
        value = "Hi!"
        array = [0, 1, 2, 3, 4]
    }
}

However, the main question still remains.
Does onChange(of:perform:) destroy and create new value=view?

I simply can not find a description of this behavior. I use onChange() where I want it to perform something that depends on the value that requires the view contains.

You can use onChange to trigger a side effect as the result of a value changing, such as an Environment key or a Binding .

I'm expecting "side effect", not "main effect" like initializing the view itself.

It's not shown in the code so I wonder why do you use onChange / onReceive to begin with. Is it not enough to check it at the model level?, e.g:

    @Published var array: [Int] {
        didSet {
            print("publisher.array:", array)
        }
    }

I also hate to say that but SwiftUI questions are out of scope on this forum which focuses on Swift itself, and people are advised to go to AppleDevForums or stackoverflow.

3 Likes

Thanks for your reply!

Are you suggesting me to delete this post?
Models are in scope, but not View?

I think I figured out the answer.

Which is NO.

onChange() does not recreate the view. However, onChange() makes/requires dependency to the value. And changes of the value evoke the refresh of the view, therefore the recreate of the children view. onChange() is tied to a dependency.

onReceive() responds to an event emitted by the publisher, which doesn't create a state dependency on the view. Therefore, changes in the value of the @Published variable do not affect the view and its recreation, and the children where the onReceive() is used.

In other words, the recreation of the view happens before the onChange() closure is executed. This is why even an empty onChange() seems to have an effect on the view.

So technically onChange() itself doesn’t recreates the view, but practically using onChange() does, since it requires mutable value in a struct, to make it practical, has to be anything observable.

1 Like

In this case, I decided to allow this post because it's sort of more about Observable than SwiftUI.

1 Like