rayx
rayx

Reputation: 1720

Why does SwiftUI recall an existing view's body which contains stale data?

I recently started writing my first SwiftUI app and ran into an design issue, which can be demonstrated by the simple code below. I know what the issue is, but I wonder why it occurred and what's the recommended way to address it.

In the demo, there are two views. The first is a list view, the second is a detail view. The detail view contains a button to delete the item being displayed. Clicking on the button crashes the app due to the forced unwrapping in the detail view. I didn't expect the crash, because from my understanding when the data model changes, SwiftUI should regenerate the entire view hierarchy. That is, it calls ContentView.body, which calls FooListView.body, which goes through items in the data model and creates NavigationLink for each item.

Since the data model has been changed, there is only one item left. So I don't think SwiftUI would create FooDetailView (or call its body) for the item deleted. If so, how come the FooDetailView code crashed? I tried to debug the code but didn't find much useful information. I believe the FooDetailView that crashed the app is the one that contained the deleted item. That I don't understand. Since SwiftUI regenerates view hierarchy, how could that old view left uncleaned up?

Can anyone explain a bit how you understand it? And how do you address the issue? I currently think out two ways. The first is to pass all the params needed by detail view to avoid accessing data model. But I don't think this approach scales. The second is to not use forced wrapping. That should work fine, but I doubt if this is the recommended way to do it.

BTW, another similar setup to generate the crash is to use three views: list view -> detail view -> delete view. When user clicks on button in delete view, the detail view will crash.

Thanks.

Update:

  1. SwiftUI may recall an existing view's body which contains stale data.

@jrturton I was aware that changes to @EnvironmentObject would cause view's body get called. What I didn't realize was that SwiftUI migtht recalled an existing view's body which contain stale data. I never read any discussion about this on the net. Do you know why SwiftUI do that?

I had always thought that when state changes, SwiftUI would regenerate the entire view hierarchy from top down by calling Content.body. If it was so, FooDetailView would always has the up-to-date data when it gets called and there wouldn't be the issue. I had the understanding because SwiftUI is advertised as a state driven architecture, and app developers are supposed to declare the UI based on the current state. By "current" I mean the new state, not the previous state. That's the reason why I thought it should be fine to use forced unwrapping.

  1. I doubt if passing all params to detail view is a general solution.

First, this doesn't scale well. For example, suppose Foo is associated with another struct Bar (that is, Foo has a property containing Bar's id) and we want to display Bar's name in detail view, then we will need to add bar name to the params. For a complicated Item, its detail view may contain a lot of things which are determined at runtime, it will be hard to prepare everything ahead by the caller.

More importantly, once we pass these params to detail view, they are effectively outside data model and can easily go stale. It would be issue if user performs delete action using these stale data.

  1. Passing binding doesn't solve the crash issue on its own.

@lorem-ipsum, thanks for your suggestion on using binding. I wasn't aware that ForEach can take binding. What's more, I have also beening think if it's good practice to pass binding, instead of regular params, in SwiftUI (I don't know that anwser yet).

That said, passing binding doesn't solve the crash issue on its own, because when the data model changes, SwiftUI still recalls the existing detail view's body which contain stale data.

  1. The solution?

I think the root cause is that, although SwiftUI is advertised as a state driven architecture, a view's body may get called with stale data. So the data model's api should deal with invalid params. Hornestly speaking, this isn't a design decison which I prefer to. I usually think the caller should only pass valid params to data model API. Otherwise it's an architecture issue that should be resolved on the caller side in the first place. Unfortunately it seems that's the case with SwiftUI.

(Note: Thank all for pointing out that deletion code should be in data model. I knew that. I didn't do it because I spent long time investigating the issue in my app and was exhausted when I prepared the example code.)

import SwiftUI

struct Foo: Identifiable {
    var id: Int
    var value: Int
}

class DataModel: ObservableObject {
    @Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
}

struct FooListView: View {
    @StateObject var dataModel = DataModel()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(dataModel.foos) { foo in
                    NavigationLink {
                        FooDetailView(fooID: foo.id)
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
        .environmentObject(dataModel)
    }
}

struct FooDetailView: View {
    @EnvironmentObject var dataModel: DataModel
    var fooID: Int
    
    var body: some View {
        // Issue: the forced unwrapping may crashe the app!
        let index = dataModel.foos.firstIndex(where: { $0.id == fooID })! 
        
        VStack {
            Text("\(dataModel.foos[index].value)")
            Button("Delete It") {
                dataModel.foos.remove(at: index)
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        FooListView()
    }
}

Upvotes: 2

Views: 1358

Answers (2)

rayx
rayx

Reputation: 1720

First, to answer my original question. The behavior is due to the way how ObservableObject (or EnvironmentObject) invalidates view. Both ListView and DetailView monitor data model changes by subscribing ObservableObject's objectWillChange publisher. Since it's impossible to control which subscriber receives change first, which view's body gets called is undefined. In practice, it's DetailView's body gets called first, that's the reason why the regular property in the detail view doesn't get updated.

Note that, while in this case we can give a concrete explanation and modify our code accordingly, in general it's unreliable to write code based on our understanding when a view's body gets called. SwiftUI may call a view's body at unexpected time. See this example. I believe when and how a view's body get called should be considered as SwiftUI implementation details. In other words, it's undefined.

Why is this an issue? Well, it's because when we call data model API in body's code (either view rendering code or intent code) we need to pass view specific data (e.g., regular properties of the view in the example code) as param to the API. Note while these view specific data are retrieved from data model, once they are saved in the view, they can go stale at any time. Since I use force unwrapping, it causes crash.

Why do I use force unwrapping? It's because I underestimated SwiftUI's complexity. I thought how views were invalidated are simple and predictable. I also thought when data model changed, the entire view hiearchy would be recreated first and then views with change would get their body called (and hence view specific data and data model were always consitent). It turned out both are wrong.

So, how to address the view specific data and data model inconsistency issue?

Approach 1: Using binding.

This is Apple's approach. See here. This approach looks great. But unfortunately it doesn't seem feasible in most practical applications, for at least two reasons:

  1. It only suits for simple data models. For complex data models (e.g. it contains bank accounts and transers which refers to each by id) and deep view hierarchies (e.g. account A detail view -> transfer view -> account B detail view -> ...), it's impossible to prepare all the data ahead, as a result it has to call data model API. BTW, I did experiment to pass both view specific data and data model as binding, it seemed to work but I ran into the second issue.

  2. Binding doesn't work well when being passed to deep view hiearchy. In my experiment it caused weird view identity issue. I'd like to cite @Asperi's comment on this (he has a lot of great answers on SwiftUI and he made the comment in a unrelated question).

Binding works bad being transferred into deep view hierarchy, use instead ObservableObject based view model to inject for each next view layer, with some communication between view model to update only needed properties.

Approach 2: Using the usual view model approach.

This requires validating params. There are different ways to do it:

  • Move the view specific data to data model object and encapsulte the validation in data model.

  • Do it in view rendering code in body.

Either way the data model api needs to check invalid params (that is, it shouldn't do force unwrapping).

Note the code in this approach doesn't look as simple as Apple's approach. Also it may require the use of if statement. This confused me a lot because it doesn't fell like the SwiftUI way Apple advertised. But based on my current understanding, it has to be this way to write practicial applications.


Since the question plagued me a lot, I want to emphasize it's an architecture requirement for data model API to be lenient, because a) a view's body can be called at unexpected time, b) hence there is no way to make sure view specific data and data model are consistent, c) hence data model API have to handle invalid params if we'd like to call it in body.

The situation in Apple's approach is a bit different. But as explained above, it only works in simple cases.

Upvotes: 1

jrturton
jrturton

Reputation: 119292

@EnvironmentObject values are all observed objects, and when you delete the item from the array, you are triggering observing views to re-render. In your example, the re-rendering of the detail view is performed before that of the list, so your detail view's body is recalculated even though it is about to be removed from the screen.

Your instinct to pass more parameters in is sensible - it would make sense to pass in the entire Foo, and add a dataModel.delete(foo) method so you're not exposing how your data model stores its data.

Upvotes: 3

Related Questions