Post

Optimizing SwiftUI List performance and hangs

This week I watched the Analyze hangs with Instruments session from WWDC23 and it inspired me to take a look at my own app.

Setup

So I hooked up Instruments (Time Profiler and added View Body to trace SwiftUI) and started the app with 2000 beer instances. I pressed record and the following output appeared:

main thread hang

Main thread hang

It seems there is a pretty bad hang of 1.71 seconds. Let’s investigate what happens here by zooming in:

main thread hang zoom

It seems like the main thread is very busy (which is bad for responsiveness) and somehow the ListViewItem is being instantiated a lot more than I expected with 2000 instances. So let’s add the following code to ListViewItem and see how many times it is actually constructed:

1
2
3
4
5
6
7
8
9
10
11
struct ListItemView: View {
    static var count = 0

    init() {
        Self.count += 1
        print(Self.count)

    }

    ...
}

The result is 5040 times! This is a bit strange but the biggest problem is the main thread being blocked. So let’s figure out why this is.

The ListView (simplified version) is constructed like this in my app from a CoreData SectionedFetchResults:

1
2
3
4
5
6
7
8
9
10
List {
    ForEach(beers) { section in
        Section(header: headerContent(section.id)) {
            ForEach(section, id: \.wrappedBeerId) { beer in
                ListItemView()
                    .id(beer.wrappedBeerId)
                    .environmentObject(ListItemViewModel(beer: beer, viewContext: viewContext))
            }
    }
}

and ListItemView looks like this (again simplified):

1
2
3
4
5
6
7
8
9
10
11
NavigationLink {
    DetailView()
} label: {
    HStack {
        ImageView(image: viewModel.beer.wrappedThumbnail)
        VStack(spacing: Constant.verticalSpacing) {
            Text(viewModel.beer.wrappedName)
            Text(viewModel.beer.wrappedComment)
        }
    }
}

Why does List seem to instantiate all ListItemView objects, even though we’re only showing about 10 instances on screen? Wasn’t it supposed to be a bit smarter than that? 🤓

Insight - Careful with .id

After some searching I found this excellent article on List performance with large datasets. The whole article is worth a read but in there is a section called “id Modifier and Explicit Identity of Views” and it explains the difference between the two options you have for setting view .id:

  1. Use constructor of ForEach
  2. Specify with the .id modifier

The key quote is:

The id modifier is used to track, manage, and cache explicitly identified views through IDViewList. It is completely different from the identification processing mechanism of ForEach. Using the id modifier is equivalent to splitting these views out of ForEach, thus losing the optimization conditions. In summary, when dealing with large amounts of data, it is recommended to avoid using the id modifier on child views in ForEach within List.

If you look closely, I had a bug in my code. I specified the id both in the ForEach and I manually specified it as well. So let’s only use the first method:

1
2
3
4
5
6
7
8
9
10
List {
    ForEach(beers) { section in
        Section(header: headerContent(section.id)) {
            ForEach(section, id: \.wrappedBeerId) { beer in
                ListItemView()
                    // .id(beer.wrappedBeerId) -- NOT NECESSARY!
                    .environmentObject(ListItemViewModel(beer: beer, viewContext: viewContext))
            }
    }
}

This is the result:

main thread without id

The hang has been reduced to about 800ms. Which is not great but it’s half the original time and also the main thread is now not completely overloaded. In console, I see the ListItemView is now only instantiated 65 times. This is a big improvement!

⚠️ Conclusion: always use the ForEach constructor to specify .id otherwise List will instantiate all views it contains.

One thing that I remembered at this point is that NavigationLink has the very surprising behaviour that any destination view will be created at init, even though you haven’t navigated to this view yet. This is discussed by Chris Eidhof from objc.io in this tweet:

objc.io tweet

Linked to that is a solution called LazyView:

1
2
3
4
5
6
7
8
9
struct LazyView<Content: View>: View {
    let build: () -> Content
    init(_ build: @autoclosure @escaping () -> Content) {
        self.build = build
    }
    var body: Content {
        build()
    }
}

So let’s introduce this LazyView and see if it makes a difference. The code now becomes:

1
2
3
4
5
6
7
8
9
10
11
NavigationLink {
    LazyView(DetailView())
} label: {
    HStack {
        ImageView(image: viewModel.beer.wrappedThumbnail)
        VStack(spacing: Constant.verticalSpacing) {
            Text(viewModel.beer.wrappedName)
            Text(viewModel.beer.wrappedComment)
        }
    }
}

In console, I see the ListItemView is still only instantiated 65 times. So it seems it is not necessary? Let’s profile things:

main thread lazyview

It looks pretty similar to the previous profile and the hang time is similar.

⚠️ Conclusion: it seems that LazyView is no longer necessary here

Just for fun, I reverted back to the old buggy code in the List (but still keeping LazyView) and ran the numbers again. It instantiated 5040 instances and the hang is again 1.71 seconds, so no improvement.

Conclusion

While I still have plenty of things to figure out, it seems that if you use the ForEach constructor to specify .id in a List the performance greatly improves. And it seems that in this specific scenario, the pretty well known LazyView solution is actually not necessary. Which is great because it means it was probably fixed on Apple side at some point.

The time watching and applying the WWDC video was definitely not wasted and I learned a lot. To be continued!

This post is licensed under CC BY 4.0 by the author.