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
It seems there is a pretty bad hang of 1.71 seconds. Let’s investigate what happens here by zooming in:
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
:
- Use constructor of
ForEach
- 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:
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.
What about with NavigationLink
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:
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:
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!