With more and more articles and books about SwiftUI in recent years, developers should be familiar with the basic concept of SwiftUI that “views are functions of state”. Each view has a corresponding state, and when the state changes, SwiftUI will recalculate the body value of the corresponding view.
If a view responds to a state that it shouldn’t respond to, or if the view’s state contains members that shouldn’t be included, it may cause unnecessary updates (redundant computation) of the view by SwiftUI. When such situations occur frequently, it will directly affect the application’s interaction response and cause lagging.
Usually, we call this kind of redundant computation behavior over-computation or redundant computation. This article will introduce how to reduce (or even avoid) similar situations from happening, thereby improving the overall performance of SwiftUI applications.
Composition of View State
Sources that can drive view updates are called Source of Truth, and they include:
- Variables declared using property wrappers such as @State and @StateObject
- Construction parameters of view types (conforming to the View protocol)
- Event sources such as onReceive
A view can contain multiple types of Source of Truth, which together constitute the view state (the view state is a composite).
Based on the differences between the implementation principles and driving mechanisms of Source of Truth of different types, we will classify and introduce their corresponding optimization techniques in the following sections.
Property wrappers that conform to the DynamicProperty protocol
Almost every SwiftUI user will come across property wrappers that trigger view updates, such as @State and @Binding, on their first day of learning SwiftUI.
As SwiftUI continues to evolve, more and more of these property wrappers are being introduced. As of SwiftUI 4.0, the known ones include: @AccessibilityFocusState, @AppStorage, @Binding, @Environment, @EnvironmentObject, @FetchRequest, @FocusState, @FocusedBinding, @FocusedObject, @FocusedValue, @GestureState, @NSApplicationDelegateAdaptor, @Namespace, @ObservadObject, @ScaledMetric, @SceneStorage, @SectionedFetchRequest, @State, @StateObject, @UIApplicationDelegateAdaptor, @WKApplicationDelegateAdaptor, and @WKExtentsionDelegateAdaptor. All property wrappers that allow a variable to become a source of truth have one characteristic in common - they conform to the DynamicProperty protocol.
Therefore, understanding how the DynamicProperty protocol works is particularly important for optimizing the repetitive calculations caused by these kinds of sources of truth.
How DynamicProperty works
Apple does not provide much information about the DynamicProperty protocol. The only publicly available protocol method is “update”, and its complete protocol requirements are as follows:
public protocol DynamicProperty {
static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
static var _propertyBehaviors: UInt32 { get }
mutating func update()
}
The _makeProperty
method is the soul of the entire protocol. Through the _makeProperty
method, SwiftUI is able to save the required data (values, methods, references, etc.) in the managed data pool of SwiftUI when the view is loaded into the view tree, and associate the view with the Source of Truth in the AttributeGraph, so that the view can respond to its changes (when the data in the SwiftUI data pool signals a change, update the view).
Using @State as an example:
@propertyWrapper public struct State<Value> : DynamicProperty {
internal var _value: Value
internal var _location: SwiftUI.AnyLocation<Value>? // Reference to data in SwiftUI's managed data pool
public init(wrappedValue value: Value)
public init(initialValue value: Value) {
_value = value // When creating an instance, only the initial value is temporarily stored
}
public var wrappedValue: Value {
get // guard let _location else { return _value} ...
nonmutating set // Can only modify the data pointed to by _location
}
public var projectedValue: SwiftUI.Binding<Value> {
get
}
// This method is called when the view is loaded into the view tree to complete the association
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
- When initializing State, the initialValue is only saved in the internal property
_value
of the State instance. At this time, the value wrapped by State is not saved in SwiftUI’s managed data pool, and SwiftUI has not yet associated it with the view as the Source of Truth in the property graph. - When SwiftUI loads a view into the view tree, it completes the operation of saving data to the managed data pool and creating an associated operation in the property graph by calling
_makeProperty
, and saves a reference to the data in the managed data pool in_location
(AnyLocation is a subclass of AnyLocationBase and is a reference type). The get and set methods of wrappedValue and projectedValue are both operations on_location
. When SwiftUI removes a view from the view tree, it also cleans up the related SwiftUI data pool. As a result, the lifespan of a variable wrapped with State will be exactly the same as that of the view, and SwiftUI will automatically update (recalculate) the corresponding view when it changes. There is a problem that has troubled many people on SwiftUI: why can’t we change the value of a variable wrapped with State in the constructor of the view? Understanding the above process provides an answer to this problem.
struct TestView: View {
@State private var number: Int = 10
init(number: Int) {
self.number = 11 // The change is ineffective
}
var body: some View {
Text("\(number)") // On the first run, it displays 10
}
}
When assigning a value with self.number = 11
in the constructor, the view has not yet been loaded and _location
is nil, so the assignment does not take effect for the wrappedValue set operation.
For property wrappers like @StateObject, which are designed for reference types, SwiftUI associates the view with the object instance (conforming to the ObservableObject protocol) that is wrapped by the property. When the objectWillChange(ObjectWillChangePublisher) publisher sends data, the view is updated. Any operations performed through objectWillChange.send will cause the view to be refreshed, regardless of whether the contents of the properties in the instance have been modified.
@propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
internal enum Storage { // Use an internally defined enumeration to indicate whether the view has been loaded and whether the data has been managed by the data pool.
case initially(() -> ObjectType)
case object(ObservedObject<ObjectType>)
}
internal var storage: StateObject<ObjectType>.Storage
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
storage = .initially(thunk) // Initialization, view not yet loaded.
}
@_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
get
}
@_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
get
}
// In the methods required by DynamicProperty, implement saving the instance in the managed data pool and associating the view with the objectWillChange of the managed instance.
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
The biggest difference between @ObservedObject and @StateObject is that ObservedObject does not save a reference to the object instance in the SwiftUI managed data pool (StateObject saves the instance in the managed data pool), but only associates the view with the objectWillChange of the reference object in the view type instance.
@ObservedObject var store = Store() // A new Store instance is created every time the view type instance is created.
Since SwiftUI creates view type instances at unpredictable times (not when the view is loaded), every time a new reference object is created, it is easy to encounter some strange phenomena if @ObservedObject points to an unstable reference instance using the above code (creating an instance with @ObservedObject).
Avoid unnecessary declarations
Any Source of Truth (property wrapper that conforms to the DynamicProperty protocol) that can be modified outside the current view, when declared in a view type, will cause the current view to be refreshed whenever it provides a refresh signal, regardless of whether it is used in the view body.
For example, in the following code:
struct EnvObjectDemoView:View{
@EnvironmentObject var store:Store
var body: some View{
Text("abc")
}
}
Although the properties or methods of the store instance are not called in the current view, whenever the objectWillChange.send method of the instance is called (for example, when a property wrapped with @Published is modified), all views associated with it (including the current view) will be refreshed (by re-evaluating the body).
Similar situations can also occur with @ObservedObject and @Environment:
struct MyEnvKey: EnvironmentKey {
static var defaultValue = 10
}
extension EnvironmentValues {
var myValue: Int {
get { self[MyEnvKey.self] }
set { self[MyEnvKey.self] = newValue }
}
}
struct EnvDemo: View {
@State var i = 100
var body: some View {
VStack {
VStack {
EnvSubView()
}
.environment(\.myValue, i)
Button("change") {
i = Int.random(in: 0...100)
}
}
}
}
struct EnvSubView: View {
@Environment(\.myValue) var myValue // declared but not used in the body
var body: some View {
let _ = print("sub view update")
Text("Sub View")
}
}
Even if myValue is not used in the body of EnvSubView, it will still be refreshed because its ancestor view modifies myValue in EnvironmentValues.
By checking the code and removing unused declarations, you can avoid duplicate calculations caused by this approach.
Other Suggestions
- When jumping between view hierarchies, consider using Environment or EnvironmentObject
- For loosely-coupled State relationships, consider using multiple EnvironmentObjects injected into the same view hierarchy to separate the states
- In appropriate scenarios, objectWillChange.send can be used instead of @Published
- Consider using third-party libraries to split the state and reduce the chance of view refresh
- Do not pursue complete avoidance of redundant calculation; balance between dependency injection convenience, application performance, and testing difficulty
- There is no perfect solution, even for popular projects like TCA, there will be obvious performance bottlenecks when facing high granularity and multi-level State cutting
View Construction Parameters
When attempting to improve the repetitive calculation behavior of SwiftUI views, developers often focus on property wrappers that conform to the DynamicProperty protocol. However, optimizing the view type construction parameters can sometimes yield more significant gains.
SwiftUI treats the construction parameters of view types as the Source of Truth. Unlike the mechanism where property wrappers that conform to the DynamicProperty protocol actively drive view updates, when updating views, SwiftUI determines whether to update child views by checking whether the instances of child views have changed (most of which are caused by changes in construction parameter values).
For example: When SwiftUI updates ContentView, if the content of the construction parameters (name, age) of SubView changes, SwiftUI will re-evaluate the body of SubView (update the view).
struct SubView{
let name:String
let age:Int
var body: some View{
VStack{
Text(name)
Text("\(age)")
}
}
}
struct ContentView {
var body: some View{
SubView(name: "fat" , age: 99)
}
}
Simple, Rough, and Efficient Comparison Strategy
We know that during the existence of a view, SwiftUI usually creates instances of the view type multiple times. In these operations that create instances, the vast majority of the purposes are to check whether the instance of the view type has changed (in the vast majority of cases, the change is caused by a change in the value of the construction parameter).
- Create a new instance
- Compare the new instance with the instance currently used by SwiftUI
- If the instance has changed, replace the current instance with the new instance, evaluate the instance’s body, and replace the old view value with the new view value
- The existence of the view will not change due to the replacement of the entity
Since SwiftUI does not require view types to comply with the Equatable protocol, it adopts a simple, rough, but highly efficient block-based comparison operation (not based on parameters or properties).
The comparison result can only prove whether the two instances are different, but SwiftUI cannot determine whether this difference will cause the value of the body to change. Therefore, it will blindly evaluate the body.
To avoid duplicate calculations, instances are changed only when they really need to be updated by optimizing the design of the construction parameters.
Since the operation of creating instances of view types is extremely frequent, do not perform any operations that will burden the system in the constructor of the view type. Also, do not set unstable values (such as random values) for properties in the constructor of the view without using a wrapper that conforms to the DynamicProperty protocol. Unstable values will cause each created instance to be different, resulting in unnecessary refreshes.
Breaking down into pieces
The above comparison operation is performed within an instance of the view type, which means that splitting the view into multiple small views (view structures) can obtain more refined comparison results and reduce the computation of some parts of the body.
struct Student {
var name: String
var age: Int
}
struct RootView:View{
@State var student = Student(name: "fat", age: 88)
var body: some View{
VStack{
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age"){
student.age = Int.random(in: 0...99)
}
}
}
}
// 分成小视图
struct StudentNameView:View{
let student:Student
var body: some View{
let _ = Self._printChanges()
Text(student.name)
}
}
struct StudentAgeView:View{
let student:Student
var body: some View{
let _ = Self._printChanges()
Text(student.age,format: .number)
}
}
Although the above code implements the visualization of the Student’s display subview, due to the design issue of the construction parameters, it did not reduce the duplicate calculation.
After clicking the random age button to modify the age attribute, even though the age attribute is not used in StudentNameView, SwiftUI still updates both StudentNameView and StudentAgeView.
This is because we pass the Student type as a parameter to the subview. When SwiftUI compares instances, it does not care about which attribute of student is used in the subview. As long as student changes, it will recalculate. To solve this problem, we should adjust the type and content of the parameters passed to the subview and only pass the data needed by the subview.
struct RootView:View{
@State var student = Student(name: "fat", age: 88)
var body: some View{
VStack{
StudentNameView(name: student.name) // Only pass the required data
StudentAgeView(age:student.age)
Button("random age"){
student.age = Int.random(in: 0...99)
}
}
}
}
struct StudentNameView:View{
let name:String // Required data
var body: some View{
let _ = Self._printChanges()
Text(name)
}
}
struct StudentAgeView:View{
let age:Int
var body: some View{
let _ = Self._printChanges()
Text(age,format: .number)
}
}
After the above modifications, only when the name attribute changes, StudentNameView will be updated. Similarly, StudentAgeView will only be updated when age changes.
Making Views Conform to the Equatable Protocol to Customize Comparison Rules
Perhaps for some reason, you cannot optimize construction parameters using the above method. SwiftUI also provides another way to achieve the same result by adjusting the comparison rules.
- Make the view conform to the Equatable protocol.
- Customize the comparison rules for the view.
In earlier versions of SwiftUI, we needed to use EquatableView to wrap views that conform to the Equatable protocol to enable custom comparison rules. Recent versions no longer require this.
Using the code example from above:
struct RootView: View {
@State var student = Student(name: "fat", age: 88)
var body: some View {
VStack {
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age") {
student.age = Int.random(in: 0...99)
}
}
}
}
struct StudentNameView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.name)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.student.name == rhs.student.name
}
}
struct StudentAgeView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.age, format: .number)
}
static func== (lhs: Self, rhs: Self) -> Bool {
lhs.student.age == rhs.student.age
}
}
This method only affects the comparison of instances of view types and does not affect the refresh generated by property wrappers that conform to the DynamicProperty protocol.
Closure - An easily overlooked breakthrough
When the type of the construction parameter is a function, a little carelessness can lead to duplicate calculations.
For example, in the following code:
struct ClosureDemo: View {
@StateObject var store = MyStore()
var body: some View {
VStack {
if let currentID = store.selection {
Text("Current ID: \(currentID)")
}
List {
ForEach(0..<100) { i in
CellView(id: i){ store.sendID(i) } // Set the button action for the subview using a trailing closure
}
}
.listStyle(.plain)
}
}
}
struct CellView: View {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
}
class MyStore: ObservableObject {
@Published var selection:Int?
func sendID(_ id: Int) {
self.selection = id
}
}
When you click a button in a CellView view, all CellView views (current List display area) will be recalculated.
This is because at first glance, we didn’t introduce a Source of Truth that would cause updates in CellView. However, because we placed the store in a closure, clicking the button causes the store to change, which leads SwiftUI to recognize a change when comparing instances of CellView.
CellView(id: i){ store.sendID(i) }
There are two ways to solve this problem:
- Make CellView conform to the Equatable protocol and not compare the action parameter.
struct CellView: View, Equatable {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
static func == (lhs: Self, rhs: Self) -> Bool { // 将 action 排除在比较之外
lhs.id == rhs.id
}
}
ForEach(0..<100) { i in
CellView(id: i){ store.sendID(i) }
}
- Modify the function definition in the constructor parameters to exclude store from CellView.
struct CellView: View {
let id: Int
var action: (Int) -> Void // modify function definition
init(id: Int, action: @escaping (Int) -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \\(id)")
Button("ID: \\(id)") {
action(id)
}
}
}
}
ForEach(0..<100) { i in
CellView(id: i, action: store.sendID) // directly pass the sendID method from store, excluding the store
}
Event Source
In order to fully transition to the SwiftUI life cycle, Apple has provided a series of view modifiers that can directly handle events within views, such as: onReceive, onChange, onOpenURL, onContinueUserActivity, etc. These triggers are called event sources and are also considered the Source of Truth, a component of the view’s state.
These triggers exist in the form of view modifiers, so their lifecycles are completely consistent with the survival period of the associated views. When the trigger receives an event, the current view will be updated regardless of whether it changes other states of the current view. Therefore, in order to reduce duplicate calculations caused by event sources, we can consider the following optimization ideas:
-
Control the lifecycle
Load the associated view only when the event needs to be processed, and use the survival period of the associated view to control the lifecycle of the trigger.
-
Reduce the scope of impact
Create a separate view for the trigger to minimize its impact on the view updates.
Swiftstruct EventSourceTest: View { @State private var enable = false var body: some View { VStack { let _ = Self._printChanges() Button(enable ? "Stop" : "Start") { enable.toggle() } TimeView(enable: enable) // A separate view, onReceive can only cause TimeView to update } } } struct TimeView:View{ let enable:Bool @State private var timestamp = Date.now var body: some View{ let _ = Self._printChanges() Text(timestamp, format: .dateTime.hour(.twoDigits(amPM: .abbreviated)).minute(.twoDigits).second(.twoDigits)) .background( Group { if enable { // Load the trigger only when necessary Color.clear .task { while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1000000000) NotificationCenter.default.post(name: .test, object: Date()) } } .onReceive(NotificationCenter.default.publisher(for: .test)) { notification in if let date = notification.object as? Date { timestamp = date } } } } ) } } extension Notification.Name { static let test = Notification.Name("test") }
Please note that SwiftUI triggers closures on the main thread. If the operations within the closure are expensive, consider sending the closure to a background queue.
Summary
This article describes some techniques for avoiding view recalculations in SwiftUI. In addition to finding methods that can solve your current problem, I hope you will focus on the principles behind these techniques.