Understanding and Improving SwiftUI Performance
[
New techniques we’re using at Airbnb to improve and maintain performance of SwiftUI features at scale
By Cal Stephens, Miguel Jimenez
Airbnb first adopted SwiftUI in 2022, starting with individual components and later expanding to entire screens and features. We’ve seen major improvements to engineers’ productivity thanks to its declarative, flexible, and composable architecture. However, adopting SwiftUI has brought new challenges related to performance. For example, there are many common code patterns in SwiftUI that can be inefficient, and many small papercuts can add up to a large cumulative performance hit. To begin addressing some of these issues at scale, we’ve created new tooling for proactively identifying these cases and statically validating correctness.
SwiftUI feature architecture at Airbnb
We’ve been leveraging declarative UI patterns at Airbnb for many years, using our UIKit-based Epoxy library and unidirectional data flow systems. When adopting SwiftUI in our screen layer, we decided to continue using our existing unidirectional data flow library. This simplified the process of incrementally adopting SwiftUI within our large codebase, and we find it improves the quality and maintainability of features.
However, we noticed that SwiftUI features using our unidirectional data flow library didn’t perform as well as we expected, and it wasn’t immediately obvious to us what the problem was. Understanding SwiftUI’s performance characteristics is an important requirement for building performant and outside of the “standard” SwiftUI toolbox.
Understanding SwiftUI view diffing
When working with declarative UI systems like SwiftUI, it’s important to ensure the framework knows which views need to be re-evaluated and re-rendered when the state of the screen changes. Changes are detected by diffing the view’s stored properties any time its parent is updated. Ideally the view’s body will only be re-evaluated when its properties actually change:
However, this behavior is not always the reality (more on why in a moment). Unnecessary view body evaluations hurt performance by performing unnecessary work.
How do you know how often a view’s body is re-evaluated in a real app? An easy way to visualize this is with a modifier that applies a random color to the view every time it’s rendered. When testing this on various views in our app’s most performance-sensitive screens, we quickly found that many views were re-evaluated and re-rendered more often than necessary:
The SwiftUI view diffing algorithm
SwiftUI’s built-in diffing algorithm is often overlooked and not officially documented, but it has a huge impact on performance. To determine if a view’s body needs to be re-evaluated, SwiftUI uses a reflection-based diffing algorithm to compare each of the view’s stored properties:
- If a type is Equatable, SwiftUI compares the old and new values using the type’s Equatable conformance. Otherwise:
- SwiftUI compares value types (e.g., structs) by recursively comparing each instance property.
- SwiftUI compares reference types (e.g., classes) using reference identity.
- SwiftUI attempts to compare closures by identity. However, most non-trivial closures cannot be compared reliably.
If all of the view’s properties compare as equal to the previous value, then the body isn’t re-evalulated and the content isn’t re-rendered. Values using SwiftUI property wrappers like @State and @Environment don’t participate in this diffing algorithm, and instead trigger view updates through different mechanisms.
When reviewing different views in our codebase, we found several common patterns that confounded SwiftUI’s diffing algorithm:
- Some types are inherently not supported, like closures.
- Simple data types stored on the view may be unexpectedly compared by reference instead of by value.
Here’s an example SwiftUI view with properties that interact poorly with the diffing algorithm:
struct MyView: View {
let dataModel: CopyOnWriteDataModel
let requestState: MyFeatureRequestState
let handler: Handler<MyViewAction>
var body: some View { ... }
}
If a view contains any value that isn’t diffable, the entire view becomes non-diffable. Preventing this in a scalable way is almost impossible with existing tools. This finding also reveals the performance issue caused by our unidirectional data flow library: action handling is closure-based, but SwiftUI can’t diff closures!
In some cases, like with the action handlers from our unidirectional data flow library, making the value diffable would require large, invasive, and potentially undesirable architecture changes. Even in simpler cases, this process is still time consuming, and there’s no easy way to prevent a regression from creeping in later on. This is a big obstacle when trying to improve and maintain performance at scale in large codebases with many different contributors.
Controlling SwiftUI view diffing
Fortunately, we have another option: If a view conforms to Equatable, SwiftUI will diff it using its Equatable conformance instead of using the default reflection-based diffing algorithm.
The advantage of this approach is that it lets us selectively decide which properties should be compared when diffing our view. In our case, we know that the handler object doesn’t affect the content or identity of our view. We only want our view to be re-evalulated and re-rendered when the dataModel and requestState values are updated. We can express that with a custom Equatable implementation:
extension MyView: Equatable {
static func ==(lhs: MyView, rhs: MyView) -> Bool {
lhs.dataModel == rhs.dataModel
&& lhs.requestState == rhs.requestState }
}
However:
- This is a lot of additional boilerplate for engineers to write, especially for views with lots of properties.
- Writing and maintaining a custom conformance is error-prone. You can easily forget to update the Equatable conformance when adding new properties later, which would cause bugs.
So, instead of manually writing and maintaining Equatable conformances, we created a new @Equatable macro that generates conformances for us.
struct MyView: View {
let dataModel: CopyOnWriteDataModel
let requestState: MyFeatureRequestState
let handler: Handler<MyViewAction>
var body: some View { ... }
}
The @Equatable macro generates an Equatable implementation that compares all of the view’s stored instance properties, excluding properties with SwiftUI property wrappers like_@State_ and @Environment that trigger view updates through other mechanisms. Properties that aren’t Equatable and don’t affect the output of the view body can be marked with @SkipEquatable to exclude them from the generated implementation. This allows us to continue using the closure-based action handlers from our unidirectional data flow library without impacting the SwiftUI diffing process!
After adopting the @Equatable macro on a view, that view is guaranteed to be diffable. If an engineer adds a non-Equatable property later, the build will fail, highlighting a potential regression in the diffing behavior. This effectively makes the @Equatable macro a sophisticated linter — which is really valuable for scaling these performance improvements in a codebase with many components and many contributors, since it makes it less likely for regressions to slip in later.
Managing the size of view bodies
Another essential aspect of SwiftUI diffing is understanding that SwiftUI can only diff proper View structs. Any other code, such as computed properties or helper functions that generate a SwiftUI view, cannot be diffed.
Consider the following example:
struct MyScreen: View {
var store: StateStore<MyState, MyAction>
var body: some View {
VStack { headerSection actionCardSection } }
private var headerSection: some View {
Text(store.state.titleString) .textStyle(.title) }
private var actionCardSection: some View {
VStack {
Image(store.state.cardSelected ? "enabled" : "disabled")
Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { store.handle(.cardTapped) }) }
}
This is a common way to organize complex view bodies, since it makes the code easier to read and maintain. However, at runtime, SwiftUI effectively inlines the views returned from the properties into the main view body, as if we instead wrote:
struct MyScreen: View {
var store: StateStore<MyState, MyAction>
var body: some View {
Text(store.state.titleString) .textStyle(.title)
VStack {
Image(store.state.cardSelected ? "enabled" : "disabled")
Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { store.handle(.cardTapped) }) }
}
Since all of this code is part of the same view body, all of it will be re-evaluated when any part of the screen’s state changes. While this specific example is simple, as the view grows larger and more complicated, re-evaluating it will become more expensive. Eventually there would be a large amount of unnecessary work happening on every screen update, hurting performance.
To improve performance, we can implement the layout code in separate SwiftUI views. This allows SwiftUI to properly diff each child view, only re-evaluating their bodies when necessary:
struct MyScreen: View {
var store: StateStore<MyState, MyAction>
var body: some View {
VStack {
HeaderSection(title: store.state.titleString)
CardSection( isCardSelected: store.state.isCardSelected, handler: store.handler) } }}
struct HeaderSection: View {
let title: String
var body: some View {
Text(title) .textStyle(.title) }}
struct CardSection: View {
let isCardSelected: Bool
let handler: Handler<MyAction>
var body: some View {
VStack {
Image(store.state.isCardSelected ? "enabled" : "disabled")
Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { handler.handle(.cardTapped) }) }
}
By breaking the view into smaller, diffable pieces, SwiftUI can efficiently update only the parts of the view that actually changed. This approach helps maintain performance as a feature grows more complex.
View body complexity lint rule
Large, complex views aren’t always obvious during development. Easily available metrics like total line count aren’t a good proxy for complexity. To help engineers know when it’s time to refactor a view into smaller, diffable pieces, we created a custom SwiftLint rule that parses the view body using SwiftSyntax and measures its complexity. We defined the view complexity metric as a value that increases every time you compose views using computed properties, functions, or closures. With this rule we automatically trigger an alert in Xcode when a view is getting too complex. (The complexity limit is configurable, and we currently allow a maximum complexity level of 10.)
The rule shows as a warning during local Xcode builds alerting engineers as early as possible. In this screenshot, the complexity limit is set to 3, and this specific view has a complexity of 5.
Conclusion
With an understanding of how SwiftUI view diffing works, we can use an @Equatable macro to ensure view bodies are only re-evaluated when the values inside views actually change, break views into smaller parts for faster re-evaluation, and encourage developers to refactor views before they get too large and complex.
Applying these three techniques to SwiftUI views in our app has led to a large reduction in unnecessary view re-evaluation and re-renders. Revisiting the examples from earlier, you see far fewer re-renders in the search bar and filter panel:
Using results from our page performance score system, we’ve found that adopting these techniques in our most complicated SwiftUI screens really does improve performance for our users. For example, we reduced scroll hitches by 15% on our main Search screen by adopting @Equatable on its most important views, and breaking apart large view bodies into smaller diffable pieces. These techniques also give us the flexibility to use a feature architecture that best suits our needs without compromising performance or imposing burdensome limitations (e.g., completely avoiding closures in SwiftUI views).
Of course, these techniques aren’t a silver bullet. It’s not necessary for all SwiftUI features to use them, and these techniques by themselves aren’t enough to guarantee great performance. However, understanding how and why they work serves as a valuable foundation for building performant SwiftUI features, and makes it easier to spot and avoid problematic patterns in your own code.
If you’re interested in joining us on our quest to make the best iOS app in the App Store, please see our careers page for open iOS roles.