Solving SwiftUI Performance Issues with the Instruments App

Chase
8 min readDec 29, 2023

--

Ever wanted to know how to find and fix performance issues in your app, or just how to make your app faster? In this article we go over how I made an app 19 times faster by replacing a single component, along with how to find and fix other performance related issues.

The text of performance above a line graph that is rising up and to the right

Before we get started, please take a couple seconds to follow me and 👏 clap for the article so that we can help more people learn about this useful content.

Adding the right tools to your engineering toolbox

Maybe you think finding a performance issue would be hard, or maybe you aren’t sure where to start. The easiest way to find and fix performance issues is to start with the right tools. If you have never used the Instruments app, or aren’t familiar with it, check out another article I wrote on Getting Started with Instruments here: https://medium.com/@jpmtech/getting-started-with-instruments-a35485574601

Finding our first performance issue

If you haven’t already, you can use the built-in tab view that we made in the following tutorial as the code to find our first bug in: https://medium.com/@jpmtech/making-a-performant-paged-tabview-45e360d55637

Once you have that code on your machine, and the for loop is set to 100_000 iterations, we are ready to press CMD+I to launch our code in the Instruments app. After we have set the recording options to the deferred mode and press record (if you aren’t sure how to set the recording mode, click here to learn), we are ready to start interacting with the app to get a few seconds of recorded measurements. Now our instruments panel should look similar to the following screenshot.

A screenshot of the instruments recording that shows micro hangs

Setting the inspection range to one of the micro hangs will show us all the code that was running during the hang. If we scroll through the Extended Detail View, you will notice that all of the text (except the main function of our app) is greyed out, meaning that the only code running is code from the system (or SwiftUI), not custom code from our app. This is how I found that the built in tab view is not lazy by default. By replacing it with the custom component from the article above, I was able to gain a speed boost of 19 times in the app I was working on.

Maybe you are already using lazy components in every scenario that you can, in the next section, we will trouble shooting a different kind of performance issue.

Solving a performance issue in concurrent code

For our second example, we will use the following code. When this code runs, it should automatically turn on the light. However, when we run the code for the first time, we can see that the light does NOT immediately turn on, and we can’t interact with our button to toggle the light. Let’s open this code in the Instruments app to see if we can find the problem.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State private var lightSwitch = false

var body: some View {
VStack {
Image(systemName: "lightbulb.fill")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.padding(.bottom)
.foregroundStyle(lightSwitch ? .yellow : .black)
Button(lightSwitch ? "Turn Off" : "Turn On") {
fetchLightState()
}.buttonStyle(.borderedProminent)
}
.padding()
.task {
fetchLightState()
}
}

func fetchLightState() {
let _ = poorlyImplementedFibonacciCalc(45)
lightSwitch.toggle()
}

func poorlyImplementedFibonacciCalc(_ n: Int) -> Int {
// this function will give us the Nth value in the Fibonacci sequence
// this is also not the best way to implement this kind of calculator
// it is implemented poorly on purpose to slow down our async code
if n <= 2 {return 1}

return poorlyImplementedFibonacciCalc(n - 1) + poorlyImplementedFibonacciCalc(n - 2)
}

}

#Preview {
ContentView()
}

Once we run the code, we should see something that looks similar to the following image (where I tried to turn the light off and on a couple of times after the light initially turned on).

A screenshot of the Instruments recording screen

This image shows three severe hangs (the number of times that it tried to fetch the light state. Zooming in and clicking into any one of the hangs should display the details for what was going on in during that instant. As we can see from the image below, the heaviest stack trace shows that the second closure in the body is high on our problem list, followed by several calls to our poorlyImplementedFibonacciCalc function.

A screenshot of the problem code highlighted in the Instruments app

If we double click on the problem we are taken to the spot in our code where those methods are called. We can see in the image below, that the fetchLightState function takes 3.81 seconds to run on my machine.

A screenshot of the problem code showing how long it took to run the code on my machine

Inspecting the problem a little further, we can see that we are running this code in a task, however, none of the code that we are running is asynchronous. You may be surprised to learn that the task modifier here implicitly inherits the “@MainActor” attribute from the View. Since our code is synchronous (not asynchronous), our task closure is now running synchronously on the main thread. Running synchronous code on the main thread means that user interaction will not be available while that code runs, which is the reason we can’t click the button while it is trying to update the light state.

Let’s get this function off the main thread by making it asynchronous and see how our performance looks after that. Updating our code to use async/await should move our code off of the main thread.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State private var lightSwitch = false

var body: some View {
VStack {
Image(systemName: "lightbulb.fill")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.padding(.bottom)
.foregroundStyle(lightSwitch ? .yellow : .black)
Button(lightSwitch ? "Turn Off" : "Turn On") {
Task {
await fetchLightState()
}
}.buttonStyle(.borderedProminent)
}
.padding()
.task {
await fetchLightState()
}
}

func fetchLightState() async {
let _ = await poorlyImplementedFibonacciCalc(45)
lightSwitch.toggle()
}

func poorlyImplementedFibonacciCalc(_ n: Int) async -> Int {
// this function will give us the Nth value in the Fibonacci sequence
// this is also not the best way to implement this kind of calculator
// it is implemented poorly on purpose to slow down our async code
if n <= 2 {return 1}

return await poorlyImplementedFibonacciCalc(n - 1) + poorlyImplementedFibonacciCalc(n - 2)
}

}

#Preview {
ContentView()
}

After we have checked the performance of the updated code in the Instruments app, we should see a graph similar to the following. Note that we no longer have any severe hangs, our main thread remains mostly free, and we can see that the heavy computations are now being run on different threads, meaning that our code is now running concurrently as we expected. This is a big improvement over the previous performance check, even if our poorlyImplementedFibonacciCalc still takes a while to run.

A screenshot of the instruments app after we have moved work off of the main thread.

If we look at our stack trace, we can see that the first closure inside the first closure is still one of our longest running tasks, and double clicking on that line in our extended detail view shows us that the new slowest running code is the call to the poorlyImplementedFibonacciCalc function. As we can see from the image below, our poorlyImplementedFibonacciCalc function now takes about 37 seconds to run which is still not great, but once again, the Instruments app has shown us exactly where the problem is which saves us time trouble shooting and keeps us from having to add print statements all over our code to find a problem.

A screenshot showing that our poorlyImplementedFibonacciCalc function now takes 37 seconds to run

Wanting to continue improving our performance, we should try to make our poorlyImplementedFibonacciCalc function run faster. We can use memoization to help our function run faster. Memoization is a concept that is used all the time in other languages such as Ruby. At its most basic level, memoization means that your code will check to see if a value is already available before trying to calculate it again, and since our code will do less computing, it should be faster, sounds easy enough, right? Let’s update our Fibonacci function with the code below to see what improvements adding memoization can make.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State private var lightSwitch = false

// Using a dictionary here, we have made a basic example
// of how to use memoization in Swift
@State private var fibonacciMemo = [1: 1, 2: 1]

var body: some View {
VStack {
Image(systemName: "lightbulb.fill")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.padding(.bottom)
.foregroundStyle(lightSwitch ? .yellow : .black)
Button(lightSwitch ? "Turn Off" : "Turn On") {
Task {
await fetchLightState()
}
}.buttonStyle(.borderedProminent)
}
.padding()
.task {
await fetchLightState()
}
}

func fetchLightState() async {
let _ = await betterFibonacci(45)
lightSwitch.toggle()
}

func betterFibonacci(_ n: Int) async -> Int {
// check if the key is in the dictionary
if fibonacciMemo[n] != nil { return fibonacciMemo[n]! }

fibonacciMemo[n] = await betterFibonacci(n - 1) + betterFibonacci(n - 2)

return fibonacciMemo[n]!
}
}

#Preview {
ContentView()
}

After measuring the performance of our fix, we can see (in the image below) our performance has been greatly improved!

A screenshot of the performance graph after we have replaced our code with a more performant function

In fact, the performance has improved so much, we almost can’t see the calls in the graph. Don’t worry though, we cover a way to measure the performance of a specific function in the next article in the series: https://medium.com/@jpmtech/using-swift-signpost-to-measure-performance-of-a-specific-function-6779c920d0f4

If you got value from this article, please consider following me, 👏 clapping for this article, or sharing it to help others more easily find it.

If you have any questions on the topic, or know of another way to accomplish the same task, feel free to respond to the post or share it with a friend and get their opinion on it. If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@jpmtech. If you want to see apps that have been built with native mobile development, you can check out my apps here: https://jpmtech.io/apps. Thank you for taking the time to check out my work!

--

--