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

Time:01-30

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

CodePudding user response:

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

CodePudding user response:

I find a few approaches to solve the issue. One is like the following, in which I pass data model as a plain old object, instead of environment object, to detail view (note that I still use forced unwrapping).

Managing states in SwiftUI is like programming in Perl - there are a thousand ways to do it.

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(dataModel: dataModel, fooID: foo.id)
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
        .environmentObject(dataModel)
    }
}

struct FooDetailView: View {
    var dataModel: DataModel
    var fooID: Int
    
    var body: some View {
        // It works fine to do forced unwrapping!
        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()
    }
}

I'll keep the question open for other answers.

  •  Tags:  
  • Related