Mastering SwiftUI Performance

Performance optimization is a critical aspect of software development, especially when it comes to building scalable and responsive SwiftUI applications.

Mazvydas Katinas
5 min readOct 4, 2023

Understanding Diffing in SwiftUI

SwiftUI employs a diffing algorithm to update the UI efficiently. When the source of truth for your views, such as @State or @ObservableObject, changes, SwiftUI re-renders only the affected views. However, this process can be resource-intensive for complex view hierarchies.

Using EquatableView for Custom Diffing

There are instances where you might not want to rely on SwiftUI’s default diffing mechanism. Perhaps you want to ignore certain changes in your data, or maybe you have a more efficient way to detect changes. This is where EquatableView comes into play.

EquatableView is a struct that wraps around a SwiftUI View and also conforms to the View protocol. To use it, you need to make your view conform to the Equatable protocol. Here's a hypothetical example:

struct TaskListView: View, Equatable {
let tasks: [Task]
let categories: [Category]

var body: some View {
List {
ForEach(categories, id: \.id) { category in
Section(header: Text(category.name)) {
ForEach(self.tasks.filter { $0.categoryID == category.id }, id: \.id) { task in
TaskRow(task: task)
}
}
}
}.listStyle(GroupedListStyle())
}
}

In this example, we have a list of tasks organized by categories. By making TaskListView conform to Equatable, we can implement custom diffing logic. This is done by overriding the == operator:

static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.tasks.count == rhs.tasks.count
}

To apply your custom diffing, wrap your view with EquatableView:

struct TaskListContainer: View {
@EnvironmentObject var taskStore: TaskStore

var body: some View {
EquatableView(
TaskListView(
tasks: taskStore.tasks,
categories: taskStore.categories
)
).onAppear(perform: taskStore.load)
}
}

An alternative to using EquatableView is the .equatable() modifier. This provides the same functionality but in a more concise manner:

struct TaskListContainer: View {
@EnvironmentObject var taskStore: TaskStore

var body: some View {
TaskListView(tasks: taskStore.tasks, categories: taskStore.categories)
.equatable()
.onAppear(perform: taskStore.load)
}
}

Efficient Data Models and Dependencies

Efficient Data Models with Structs

When working with SwiftUI, it’s advisable to use structs instead of classes for your data models. SwiftUI is designed to work seamlessly with Swift’s value types, particularly structs. Structs have several advantages over classes when it comes to performance:

1. Stack Allocation: Structs are usually allocated on the stack, which is faster than heap allocation used for classes.
2. Immutability: Structs are immutable by default, which makes it easier to reason about your code and leads to safer multithreading.
3. No Reference Counting: Unlike classes, structs don’t involve reference counting, which can add overhead.

Efficient Data Models with Enums

Enums can also be an efficient choice for data models, especially when you have a limited set of states that an object can be in. Enums are also value types and can be used to create highly expressive and efficient data models.

enum TaskState {
case pending, completed, failed
}

struct Task: Identifiable {
let id: UUID
let name: String
let state: TaskState
}

Using Property Wrappers for Efficient Storage

SwiftUI provides property wrappers like @State, @Binding, @ObservedObject, and @EnvironmentObject for state management. When designing your data models, consider which property wrapper is most appropriate for each piece of data.

For instance, use @State for local state that only affects the current view:

struct ToggleView: View {
@State private var isOn: Bool = false

var body: some View {
Toggle("Is On", isOn: $isOn)
}
}

And use @ObservedObject or @EnvironmentObject for more complex data models that are shared across multiple views:

class TaskListViewModel: ObservableObject {
@Published var tasks: [Task] = []

// … other logic
}

struct TaskListView: View {
@ObservedObject var viewModel: TaskListViewModel

var body: some View {
List(viewModel.tasks) { task in
Text(task.name)
}
}
}

By carefully choosing the right data structures and property wrappers, you can create data models that are not only efficient but also make your code easier to understand and maintain.

Reduce Unnecessary Dependencies

Every view in SwiftUI has its dependencies, which trigger the view to re-render when they change. Use tools like Self._printChanges() to understand why a view is updating. Reduce unnecessary dependencies by scoping down the data that a view relies on.

struct UserStatusView: View {
let isUserOnline: Bool

var body: some View {
Text(isUserOnline ? "Online" : "Offline")
}
}

Faster Updates and Efficient Lists

Minimize Conditional Logic

Try to reduce the amount of conditional logic inside the body computation. Each conditional statement adds a layer of complexity and can slow down the rendering process. If possible, move the logic to helper methods or computed properties.

    // Avoid
var body: some View {
if someCondition {
// Complex View 1
} else {
// Complex View 2
}
}

// Prefer
var body: some View {
contentView
}

var contentView: some View {
if someCondition {
// Complex View 1
} else {
// Complex View 2
}
}

Offload Expensive Computations

If your view relies on data that requires expensive computations, consider performing these calculations in the background or caching the results. SwiftUI’s .task modifier can be used to run asynchronous tasks.

struct ExpensiveView: View {
@State private var computedData: Data

var body: some View {
Text(computedData.description)
.task {
computedData = await performExpensiveCalculation()
}
}

func performExpensiveCalculation() async -> Data {
// Perform your expensive calculation here and return the result
}
}

Use Lazy Loading for Lists

For lists with many items, use LazyVStack or LazyHStack instead of their eager counterparts. Lazy stacks only instantiate the views that fit on the screen, thus reducing memory usage and improving performance.

LazyVStack {
ForEach(items) { item in
Text(item.name)
}
}

Limit the Number of Views

Each additional view in the hierarchy adds to the computational cost. Try to limit the number of nested views and use SwiftUI’s built-in views whenever possible, as they are optimized for performance.

Avoid High-Cost Operations

Operations like sorting an array, string manipulation, or image processing can be expensive. If such operations are necessary, try to perform them outside the body computation or cache the results for reuse.

struct SortedListView: View {
let items: [Item]
let sortedItems: [Item]

init(items: [Item]) {
self.items = items
self.sortedItems = items.sorted() // Sort once and store the result
}

var body: some View {
List(sortedItems) { item in
Text(item.name)
}
}
}

Use @Environment for Dynamic Properties

The @Environment property wrapper is useful for reading dynamic properties that can change during the lifetime of a view.

struct WeatherView: View {
@Environment(\.temperatureUnit) private var unit
var weather: Weather

var body: some View {
Text("Temperature: \(weather.temperature) \(unit)")
}
}

Use @State for Local Mutable State

The @State property wrapper is ideal for managing local mutable state within a view.

struct ZoomableImageView: View {
@State private var zoomedIn = false
var image: Image

var body: some View {
image
.resizable()
.aspectRatio(contentMode: zoomedIn ? .fill : .fit)
.frame(maxHeight: zoomedIn ? 400 : nil)
.onTapGesture {
withAnimation { zoomedIn.toggle() }
}
}
}

Debugging and Profiling Tools

Xcode provides a variety of tools to help you identify performance bottlenecks. The SwiftUI Profiler and the View Debugger are particularly useful for this purpose.

Conclusion

Performance optimization in Swift is not just about writing faster code; it’s about understanding the intricacies of the language, the runtime, and the tools at your disposal. Always be prepared to measure, identify, and optimize. Use the feedback loop to continuously improve your code. Take advantage of Swift’s modern features like async/await and property wrappers like @Environment and @State to write efficient, maintainable code.

Happy coding!

--

--