SwiftUI mutating func weirdness

this simple SwiftUI app loops forever calling the picture(of:) method... but only if it's marked mutating. what's going on?
(tested with Xcode 13, iOS 15)

import SwiftUI

struct MyState {
    mutating // try commenting out
    func picture(of id: Int) -> Image {
        print("id: \(id)")
        return .init(systemName: "person")
    }
}

class MyModel: ObservableObject {
    @Published var state = MyState()
}

struct ContentView: View {
    @StateObject private var model = MyModel()
    
    var body: some View {
        List {
            ForEach(1 ..< 20) { id in
                model.state.picture(of: id)
            }
        }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
1 Like

When you render your view, you mutate your @StateObject, causing the view to re-render.

2 Likes

i thought SwiftUI uses low-level "memcmp" approach to diffing, is it not (only) the case? am i changing a single bit of MyState/MyModel in picture(of:) ?

I would suggest not relying on SwiftUI's implementation details.

When you have @Published property on an ObservableObject type you are telling SwiftUI that every time this value changes all the views that read this model (sometime we use the terminology that a view depends on a value) should re-render. That's what the framework is doing in this case.

I would also note that despite the compiler allowing it to do it, SwiftUI really don't expect you creating side effect or doing mutation in your body. The framework can call body at any time and you should not rely on it being called to trigger specific mutation or effects.

With the current sample code unfortunately it's hard to provide any suggestion on how would be better to architect this. If you think there is some general functionality that SwiftUI should provide to help solve your use case I would encourage you to file a feedback.

2 Likes

"at any time" needs clarification, otherwise it's not obvious when it would be possible to mutate model at all:

var body: some View {
	Text(model.text) // i shall not mutate model here, now that i know it
	Button(...) { // button action
		(1) but can i mutate model here? (1)
		DispatchQueue.main.async {
			(2) or i mutate model here?
		}
	}
	Text(model.something that might depend on (1))
}

...
some async event (e.g. network or timer, etc) -- 
is it safe to mutate model there? or are there any rules 
when it is ok and when it is not? e.g. is timer dispatched 
on main loop guaranteed to run outside of any "body" call?

ideally SwiftUI + Swift shall prohibit me doing the wrong thing... if that's possible. with "mutating" method above i'm getting runtime error, but sometimes not even this. ideally that needs to be a compilation error:

var body: some View {
	Text(model.mutatingGetText) // compilation error: can't do that
	Button(...) { // button action
		model.mutatingMethod1() // (1) ok (right?)
		DispatchQueue.main.async {
			model.mutatingMethod1() // (2) ok (right?)
		}
		Text(model.something that might depend on (1))
	}
}

You should distinguish between SwiftUI calling into body and closures that are triggered for example by Button or even onAppear. In the latter case, of course, it is perfectly fine to do side effect. That's their purpose, the user taps on a Button and you execute something; body is called by the system to produce a description of your UI.

I do agree with you that SwiftUI and Swift should guide you to do the best thing and we always look into better way to improve the framework.

In this case I was trying to give you a mental model to understand what is happening in your code and the fact that SwiftUI is just doing what you've asked it to do.

3 Likes

I think the runtime error checker is testing for mutation during body evaluation, non? You should at least get a purple exclamation mark in the issue navigator, methinks.

EDIT: Sorry, I think I just repeated what you said. Disregard me.

I am debugging a rare case when a view body is called during me changing my model property from within the button action callback. This happen rarely but once I trigger it it happens every time I subsequently click the button. Is this supposed to happen?

struct TestView: View {
    @ObservedObject private var model = Model.singleton
    let val: Int
    let title: String
    
    var body: some View {
        print("Test body: \(val), cur: \(model.currentVal)")
        return HStack {
            Button("\(title) \(val) \(model.currentVal)") {
                print("--- Test b4 set", val)
                model.currentVal = val
                print("--- Test after set", val)
            }
            Spacer()
        }
    }
}

When the bug doesn't happen (normally) the console output is:

--- Test b4 set 0
--- Test after set 0
Test body: 1, cur: 0

when the bug is triggered it is:

--- Test b4 set 0
Test body: 1, cur: 0
--- Test after set 0

Here is the stack at that point:

In the full version of this app not just the view's body is called but a few other view's bodies. The particular problem to my code is that the body is called before the value is changed (IIUC on the "will set" of the property), so the body is called with the old value of the property - the value that is about to change - and the body is not called afterwards with the new value of the property, leaving UI and model out of sync. Any ideas?

ps. the same happens (rarely) if I do the actual model change in the DispatchQueue.main.async block. I am currently thinking of a workaround of using a non published property, and explicitly calling objectWillChange.send() after changing it.

Could this issue be triggered by the fact that I am observing the same model objects both in parent view and one or more of its subviews? Pseudo code:

struct MainView: View {
    @ObservedObject var model = Model.singleton
    func body() -> some View {
        ChildView(...)
    }
}

struct ChildView: View {
    func body() -> some View {
        ChildSubView(...)
    }
}

struct ChildSubView: View {
    @ObservedObject var model = Model.singleton
    func body() -> some View {
        ...
    }
}

Changing from @ObservedObject to @EnvironmentObject (along with setting the model as environment object in the root of the view hierarchy didn't help - same result.

Interestingly changing @ObservedObject to @Binding, along with passing the model value argument to pretty much every view did the trick... There were about 50 @ObservedObject to change and about 100 places where I had to add the state parameter passing from parent view to child view, like so: ChildView(state: $state, ...)

If this is indeed a bug (would appreciate a confirmation if it is) that view's body might get called when my app is mutating the model in an action (of Button, etc), then perhaps this check is worth to have internally in SwiftUI:

Button {
    bodyMustNotBeCalled + 1;  defer { bodyMustNotBeCalled -= 1 }
    assert(insideBody == 0)
    model.variable = ...
}

the body of any view :
    var body: some View {
        insideBody += 1;  defer { insideBody -= 1}
        assert(bodyMustNotBeCalled == 0)
        ...
    }

not this checks literally but something to that effect (the actual check should be on the "outside" of action and body callbacks.

Perhaps the fact this is button action is not important here... even if i mutate my model from within DispatchQueue.async { model.variable = ... } block I don't expect a situation of body being called synchronously... well, at least not the situation when a view's body is called with the old value of the model state and not called again with the new value, leaving the model and UI out of sync. @luca_bernardi

1 Like
Terms of Service

Privacy Policy

Cookie Policy