AnyView’s impact on SwiftUI performance

Introduction

AnyView is a type-erased view, that can be handy in SwiftUI containers consisting of heterogeneous views. In these cases, you don’t need to specify the concrete type of all the views that can be in the view hierarchy. With this approach, you can avoid using generics, thus simplifying your code.

However, that can come with a performance penalty. As mentioned in a previous post, SwiftUI relies on the type of the views to compute the diffing. If it’s AnyView (which is basically a wrapped type), SwiftUI will have hard time figuring out the view’s identity and its structure, and it will just redraw the whole view, which is not really efficient. You can find more details about SwiftUI’s diffing mechanism in this great WWDC talk.

Apple also mentioned several times that we should avoid using AnyView inside a ForEach, by saying it may cause performance issues. A possible case where this can be measured is an endless list of different views, presenting different types of data (e.g. chats, activity feeds, etc). In this post, I will do some measurements using Stream’s SwiftUI chat SDK, by using its default generics-based implementation, and comparing it with a modified implementation that uses AnyView.

Testing setup

Few notes about the testing setup:

  • All the tests and measurements are done on an iPhone 11 Pro Max.
  • The same dataset and user are used on all the tests for consistency.
  • The tests are performed multiple times.
  • The lists being tested have different types of data (e.g. images, videos, giphys, texts, etc).
  • The same actions are performed while testing the different implementations (e.g. scrolling three times over the content).
  • The data is fetched in pages of 25 items each.
  • We will use the animations hitches instruments profile, as well as this open-source FPS counter.
Animation hitches Instruments profile

Animation hitches

Apple recommends using animation hitches as a metric for measuring the performance of an app. A hitch is basically a frame that’s displayed on the screen later than it was supposed to be shown. The longer the hitch time, the more noticeable are the glitches and hangs that make a poor user experience. For example, if you have a hitch of 100 milliseconds, it means that this frame is shown 100 milliseconds later than expected, thus making the hang visible by the users. The hitches can appear in the commit phase or in the render phase.

Great starting point for learning more about animation hitches are the following Apple Talks:

Demystify and eliminate hitches in the render phase
Explore UI animation hitches and the render loop
Find and fix hitches in the commit phase

To improve our app’s performance, we need to reduce these animation hitches to a minimum (or even better, get rid of them altogether).

I will also show comparisons with FPS (frames per second), since it’s generally better known metric among developers. When using FPS as a metric, it’s important to specify the max frame rate (60 in this case), and also discard values when there’s no activity on the app.

Browsing data

First, let’s see how the different implementations would perform while scrolling through the content. In this test, we will scroll through the whole list of messages three times.

Without AnyView

Below is the animation hitches recording of the implementation with generics.

As you can see, there are few animation hitches, with 2 of them being orange, which means that the hitch duration is over the acceptable latency of 33 ms. Therefore, in these 2 cases a frame will be dropped. These 2 hitches happen when new messages are loaded and appended to the message list. Any subsequent scrolling through the channel while messages are loaded, doesn’t impact performance.

The average of the FPS values during this test, is around 59 frames per second. The scrolling is smooth and responsive.

With AnyView

Next, let’s do the same test, while using the AnyView wrapper. The results from the animation hitches instruments profile are below:

You can see a bit more orange in this example. There are more hitches that go over the acceptable latency of 33 ms. This results in some visible hangs, both in instruments, as well as visually while performing the tests.

Also, when you scroll through the list again, the performance is not improved (it even gets worse). This makes sense, since SwiftUI doesn’t know that it already displayed this view once (because it’s hidden under the AnyView umbrella). Therefore, it draws it one more time, while potentially also caching (but not using), the old version of that same view.

The average FPS in this test was around 55, and you could notice some visible glitches while scrolling, although it wasn’t that bad.

Modifying data while browsing

Another test we could do is a performance test – sending a lot of content to the list and forcing updates to the views (e.g. reacting to messages), while we also browse through the data. This will trigger several redraws of the views over a short time interval.

Without AnyView

Doing the tests without the AnyView wrapper produces similar results (58-59 FPS) as the regular scrolling test. This is also mostly expected, since SwiftUI knows about the identity of the views and their structure. When a view needs to be updated, only the changes to it are applied (e.g. adding another reaction to a view).

With AnyView

Things get interesting when we use AnyView in this context – frequent updates to the views on the screen in a short timeframe.

There are several visible hitches and hangs in this scenario, and the FPS is dropping below 50 when we react to messages frequently. The frame drops are more visible here, because we force redraw of the view many times in a matter of few seconds. Since SwiftUI doesn’t know what this view is, I assume it redraws it every time from scratch. Some of these views are quite expensive (e.g. gifs), so redrawing can be quite an expensive operation.

By using AnyView, the effect is similar as applying an id modifier with the value of UUID() – which will always update items in the view, when there’s a change.

Analyzing the results

Test/ImplementationWithout AnyView (FPS)With AnyView (FPS)Performance regression
Browsing through data595510%
Modifying data extensively595016.5%
Test numbers comparison

These numbers are pretty dependent on the setup, so they shouldn’t be taken for granted, but just as an indication.

Just browsing through data is around 10% slower if you wrap your views in AnyView. If you change the data while browsing, this difference increases to around 17%, and the glitches are more visible here.

To understand the results better, we need to deep dive into how SwiftUI works. In this WWDC session about SwiftUI performance, Raj from the SwiftUI team discusses how a list or a table needs to know all of its identifiers up front. It can only gather those efficiently without visiting all the content if the content resolves to a constant number of rows. If you use conditional checks or AnyView, the number of rows can’t be determined, and all views need to be created in advance, which affects performance.

Therefore, try to avoid code like this:

ForEach(someData) { someElement in
  if someCondition {
    SomeView(data: someElement)
  }
}

As well as code like this:

ForEach(someData) { someElement in
    AnyView(SomeView(data: someElement))
}

The last piece of code is similar to how we were performing the tests with AnyView. This means, we were actually recreating the whole list when there were changes to it. This also explains why the AnyView implementation got slower over time – more content needs to be created from scratch on each redraw.

Conclusion

As a conclusion, in these scenarios (scrollable lists of heterogeneous views), it’s better to use concrete types for the different views in your containers. It might sound more complicated to implement, but actually you can make it simpler, without dealing too much with generics.

However, that doesn’t mean that using AnyView always impacts performance this way. For example, if you have some menu as a list of few heterogenous elements, that on tap shows different navigation destinations, and you decide to wrap those views as AnyView, my measurements have shown no difference in performance over using other approaches.

A different type of test using AnyView vs Group with if-else statements was done in this post, showing no noticeable differences between the two. Using if-else causes the view identity to be lost like AnyView, so not having performance differences here is expected.

It also depends on how the implementation looks like – your data model, which state is passed where, which updates can cause view redraws, etc.

What are your experiences with AnyView? Have you used it extensively and did you notice any performance regressions? Looking forward to your thoughts in the comments or on Twitter.

Leave a comment