The article compares the performance of AnyView and Group (also known as ConditionalView) in SwiftUI, debunking the myth that AnyView has inherently negative performance implications.
Abstract
The SwiftUI performance battle between AnyView and Group is scrutinized in a series of tests conducted by an iOS engineer, who initially received concerns about the use of AnyView in a SwiftUI open-source project. Despite rumors that AnyView could significantly slow down the SwiftUI render engine due to type erasure, the tests reveal that AnyView does not adversely affect performance in scenarios involving constant redrawing of static content, tweaking a single element within a massive view, and toggling between two static massive views. The results show that AnyView can even outperform Group in certain cases, challenging the assumption that type erasure leads to the destruction and rebuilding of the entire view hierarchy. The article emphasizes that the real performance bottleneck in SwiftUI is the layout system, and it provides insights into how developers can optimize view performance by using EquatableView and binding the state as low as possible in the view hierarchy.
Opinions
The author initially doubts the performance impact of AnyView but finds through testing that it does not significantly hinder SwiftUI's rendering performance.
The article suggests that the SwiftUI community may have overestimated the negative effects of AnyView on performance.
The use of EquatableView is recommended for improving performance by allowing SwiftUI to skip calling the body property if the view has not changed.
The author advises against premature optimization and encourages the use of profiling tools to identify actual performance bottlenecks rather than relying on assumptions.
The tests indicate that both AnyView and Group can cache only one contained view at a time, which contradicts the belief that Group caches views for both "true" and "false" branches.
The article concludes that the re-creation of the view hierarchy in SwiftUI is not as costly as previously thought, and developers should focus on optimizing the layout system for better performance.
I’ve recently received a question from an iOS engineer regarding my open source project written with SwiftUI. He shared his concerns regarding the use of AnyView in the project.
To quickly bring you on the same page, there are rumors (2, 3, 4, 5, 6) that AnyView brings in serious performance implications to the SwiftUI render engine, due to the fact that it erases the type of the underlying view hierarchy thus slowing down the diffing.
An AnyView allows changing the type of view used in a given view hierarchy. Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type.
This sounds scary. Destroying and rebuilding the entire hierarchy? Oh boy!
But how often does the type of view change inside the AnyView? Probably not too often, unless you're building an app for developing epilepsy.
If the type of view stays the same, but instead a subgraph of the hierarchy contained in AnyView receives an update, does it behave the same way? Spoiler: it doesn't.
So what about the performance of diffing the type-erased hierarchy inside AnyView? Well, there were no benchmarking conducted by anyone from the community.
So in this MythBusters episode, we’ll find out:
How AnyView performance compares to ConditionalView (aka Group { if ... else ... })
What are the costs of re-creating the view hierarchy
What are the real performance bottlenecks in SwiftUI
How to organize the hierarchy to assure the best performance
Let's get started!
Preparing for the test
The most important part of the experiment is to pick the right toolkit and the test samples.
Obviously, we'd need a heavy-weight view carrying many elements to make the diffing harder.
The first variant I came up with was a simple grid of elements like so:
But profiler quickly showed that SwiftUI is spending most of the time on… calculating the layout of this thing.
So after a few other attempts, I ended up with this template of the test view, that can contain an arbitrary number of elements with minimal layout required:
Now, this StackView could be re-used in various tests, where <Element View> could be either static view, such as Text("@"), or a dynamic view that alters the content based on state parameters.
For measuring the performance I’ve used SwiftUI Profiler from Instruments, which shows lots of useful information, including functions time profiling, the number of times each body was called, and much more.
In order to see the performance when launching from Xcode, I’ve used a simple FPSView that was counting how many render cycles SwiftUI can carry under high load.
All the tests were running on iPhone 7 iOS 13.2.3 for a duration of 1 minute. The phone was lying on a refrigerant bag (was heating up like crazy without it).
Test #1: Constantly redrawn static content
It is obvious that SwiftUI does not constantly recalculate the view hierarchy when there are no changes to the state.
So in all the tests, I had to trigger the hierarchy update by Timer that was toggling a local @State var updateTrigger:
The <testView>, in this case, was a static view containing 1600 Text views.
The test mechanics
SwiftUI hierarchy with ContentView in the root receives constant refresh requests at a rate of 60 times a second. This triggers the recalculation of the body, which contains a never-changing statically typed View with 1600 Text elements inside.
The test is running for 1 minute under SwiftUI Profiler. For determining the FPS, the FPSView is added to the ContentView's body and the app is launched directly from Xcode.
Here are the results for the static content:
As a bonus, here are the results for the same setup, but when every Text was wrapped in AnyView
This is quite surprising, but wrapping element views in AnyView actually improved the performance of constructing the body by 50%, but had no visible effect on the diffing performance.
This is not a deal-breaker yet, because construction of the views was taking less than 0,1% of the time (total of 70ms for a 1-minute test).
In addition to the data from the profiler, I run a simple benchmarking in the code and found that creation of the test view with or without AnyView is taking between 3-4 nanoseconds:
This means that the creation of the hierarchy is incredibly fast, SwiftUI is able to produce ~285k View structs a second! And don't think that Text is a lightweight struct: reflection shows it has a quite complex inner structure for handling the string localization and formatting, and font modifiers, etc.
Test #2: Tweaking one element inside a massive view
The purpose of this test was to see how SwiftUI can perform diffing of the hierarchy when 99.9% of content remains unchanged.
The <elementView> is a dynamic view capable of toggling the content between static Text and static Image. Underlying UIImage has a small size (6x10 pixels) and is cached in a static variable to assure the best performance.
Three variations of <elementView> were tested:
1. ConditionalView
2. AnyView
3. EquatableView
The test mechanics
SwiftUI hierarchy receives constant refresh requests at a rate of 60 times a second. The hierarchy contains 1600 ElementViews able to toggle between Text and Image based on a Binding parameter they receive upon initialization.
The test is constructed in a way that only one ElementView has to constantly toggle between Text and Image, while others stay Text all the time. Since all ElementViews are hooked on the update from Binding, it's the job of the SwiftUI engine to properly run diffing of all 1600 views for optimal rendering.
The average time for calling .init() + .body for the test view is again ~4 nanoseconds.
The results for ConditionalView:
The results for AnyView:
The results for EquatableView:
Pretty interesting results. Here are the observations we need to note:
The performance of diffing the content for ConditionalView and AnyView is practically the same, based on the FPS.
EquatableView almost doubled the FPS compared to the other two. Because of this SwiftUI was able to run more render cycles in the allocated minute, resulting in a higher number of body calls and calculation time.
From the syntax of ConditionalView there may be an illusion that views for "true" and "false" branches are both cached. This is not true - the reflection shows that only one view gets cached at a time. The results of the experiment correspond with this fact: the cumulative body calculation time for AnyViewand ConditionalView is about the same. Both AnyView and ConditionalView are re-creating the inner hierarchy when the type of view changes.
In order to compare two hierarchies of views, the other one has to be created. Yes, we thought the worst thing that may happen is the re-creation of the hierarchy, but SwiftUI is doing this all the time for calculation of the diff. We can help it by making the view Equatable and wrapping it inside EquatableView. This way SwiftUI does not have to call the computed variable body for identifying if the content has changed or not.
This is the last challenge in the battle of AnyView and ConditionalView.
We have two static massive views of different inner structures and types.
The job of the AnyView and ConditionalView is to toggle between these two views at the highest possible rate.
This way we can find out if content switching inside ConditionalView is more performant than "destroying and re-building the view hierarchy" by AnyView.
The test mechanics
In the setup, we have two never-changing massive views, both containing 225 elements of types Text and Image respectively.
The code for testing ConditionalView and AnyView:
The <condition> is toggled by Timer a rate of 60 times a second.
The results for ConditionalView
The results for AnyView
Who placed the bet on ConditionalView? I guess, most of us did...
But it turns out that ConditionalView not only cannot beat AnyView, it is even a bit slower: in these two tests, SwiftUI was able to perform by 5% more render cycles for AnyView than for Group!
I cannot say there is a clear leader among AnyView and ConditionalView, but the myth about negative AnyView performance implication is BUSTED.
This was tested on micro views, as well as on massive views: AnyView does not slow down the diffing for tiny updates, but actually performs better with major content changes.
Based on the condition in the if, the ConditionalView does cache only one contained view. So practically it destroys and recreates the content just like AnyView does.
There is no reason to fear the words “old hierarchy is destroyed and a new hierarchy is created”. The re-creation of the SwiftUI view hierarchy is practically free. In fact, the SwiftUI engine does create a new sub-hierarchy every time for comparing the content. You cannot compare A to B without creating B, right?
The real performance bottleneck turns out to be the inner layout system of SwiftUI. I’m sure this will be optimized later, but for now, we just need to make sure to bind the state as low as possible in the hierarchy, so that SwiftUI had to recalculate only the small subgraphs of the hierarchy.
When you access a Binding, @State, @ObservedObject or @EnvironmentObject from the view's body, SwiftUI associates this view with any updates of that state. Even if the body returns the same result as before - SwiftUI recalculates the layout regardless.
One of the ways to improve the performance is to use EquatableView, which allows SwiftUI to take a shortcut and not call the body if your view says it did not change.
And finally: Premature optimization is the root of all evil. Use SwiftUI Profiler in the Instruments for locating the real performance bottleneck instead of guessing, and don’t trade the code clarity for a couple of nanoseconds of performance advantage.
The source code for the project can be found on GitHub.
This article (and all the others) are available in my tech blog: