Developer Tools • iOS, macOS, tvOS, visionOS, watchOS • 35:36
Discover the new SwiftUI instrument. We’ll cover how SwiftUI updates views, how changes in your app’s data affect those updates, and how the new instrument helps you visualize those causes and effects. To get the most out of this session, we recommend being familiar with writing apps in SwiftUI.
Speakers: Jed Fox, Steven Peterson
Downloads from Apple
Transcript
Introduction & Agenda
Hi! I’m Jed from the Instruments team. And I’m Steven, from the Apple Music team. Great apps have great performance. Any piece of code running in your app potentially slow it down. It’s important to analyze your app to figure out which areas in your code may be bottlenecks, and then resolve those issues to keep your app running as smoothly as possible. In today’s session, we’ll focus on ways you can identify when your SwiftUI code is the bottleneck, and show you how to help SwiftUI work more efficiently.
How do you know you have a performance issue in the first place? One symptom you might notice is that your app is less responsive due to hitches or hangs. Animations may pause or jump, or scrolling may be delayed. The best way to identify performance problems is to profile your app using Instruments. Today, we’re going to focus on diagnosing performance issues in code that uses SwiftUI. First, we’ll start with an introduction to the new SwiftUI instrument included with Instruments 26.
Next, we’ll take a look at an app that has long view body updates, talk about why they are a common performance problem, and use the instrument to find and fix them. Finally, we’ll dive into the causes and effects of SwiftUI updates. We’ll use the instrument to identify unnecessary updates and show you how to remove them. There can be many different underlying causes of performance issues, but today we’ll be focusing on ones caused by your use of SwiftUI.
If your app’s issue isn’t related to your SwiftUI code, we recommend you check out “Analyze hangs with Instruments” and “Optimize CPU performance with Instruments” as a starting point for identifying what’s happening. Steven and I have been working on an app together. Steven, can you show off what we’ve built so far? Thanks, Jed!
The app is called Landmarks, and it features some of the most amazing places from around the world. Each landmark shows how far it is from my current location, so I can I dream about where to go next, whether it’s somewhere on the other side of a long flight, or just a quick road trip away! The app looks pretty good so far, but as I've been testing it, I've noticed that it's not always scrolling as smoothly as I'd like. I’d love to get to the bottom of that. Jed, you mentioned the new SwiftUI instrument. How about a tour? Sure!
Discover the SwiftUI instrument
In Instruments 26, we’re excited to introduce a new way to identify performance issues in your SwiftUI apps: the next-generation SwiftUI instrument. The updated SwiftUI template includes a few different instruments to help assess your app's performance. First, we have the new SwiftUI instrument, which I’ll talk more about in a moment. Next, we’ve included Time Profiler, which shows samples of the work your app is performing on the CPU over time. And finally, we have the Hangs and Hitches instruments, which keep track of your app's responsiveness.
The first step when investigating potential performance issues in your app, is to look at the top-level information provided by the SwiftUI instrument. The first lane of the SwiftUI instrument track is called “Update Groups”, and it shows when SwiftUI is doing work. If CPU usage is spiking during a time when this lane is empty, you’ll know that your problem likely lies somewhere outside of SwiftUI.
The other lanes of the SwiftUI instrument track allow you to easily identify long SwiftUI updates and when they're occurring. Long View Body Updates highlights when the 'body' property of your view is taking too long to run. Long Representable Updates identifies view and view controller representable updates that may be taking too long.
Lastly, Other Long Updates shows all other types of long SwiftUI work. These 3 lanes give you a high level view of all long updates that may cause your app to perform poorly. Updates are shown in orange and red based on how likely they are to contribute to a hitch or hang.
Whether these updates actually result in any hangs or hitches in your app can depend on device conditions, but investigating these long updates, starting with ones in red, is typically a great starting point. To get started with the SwiftUI instrument, install Xcode 26. Then, on the device you’d like to run and profile your app on, update to the latest OS releases which include support for recording SwiftUI traces. I think we’re ready to profile the Landmarks app for the first time.
Diagnose and fix long view body updates
-Steven, take it away! -Thanks, Jed! The project is already open in Xcode. To begin profiling, I’ll press Command-I and Xcode compiles the app in Release mode and then automatically launches Instruments. From the template chooser, I’ll choose the SwiftUI template and click the record button to start recording. I'll start by scrolling through the list of landmarks. There’s a horizontal shelf for each continent. I’ll scroll horizontally to the end of the North America shelf, to load a few more views.
And then I'll click stop recording. After the recording is stopped, Instruments copies the profiling data from my device and processes it for analysis. When the processing is complete, I’ll be able to use the SwiftUI Instrument to determine if I have any potential performance problems that need my attention. I’ll maximize the window to make it easier to see everything.
I’ll start by inspecting the top-level long update lanes in the SwiftUI instrument track. View bodies that take too long to run are a common cause of performance problems in SwiftUI, so I'll inspect the Long View Body Updates lane first. There are some long updates in orange and red in this lane, so I want to investigate those.
I’ll click to expand the SwiftUI track. And this reveals 3 subtracks: View Body Updates, Representable Updates, and Other Updates. Each subtrack has long updates highlighted in orange and red just like the top level lanes. The rest of the updates are shown in gray. I’ll select the View Body Updates track.
In the detail pane below, there’s a hierarchical summary of all the view bodies that ran during my profiling session. When I expand my app’s process in the hierarchy I get a list of the modules for all the view body updates that ran. I can filter these down to just the long updates by clicking the dropdown and choosing the Long View Body Updates summary.
I can tell from the counts that I have several long updates to investigate. I’ll click to expand my app’s module. and LandmarkListItemView has several long updates, so I’ll start with that view. Hovering over the view name reveals an arrow. And clicking on the arrow reveals a context menu. I’ll choose “Show Updates” which reveals a sequential list of all the long updates for this view’s body.
I’ll right click on one of the long updates, and click “Set Inspection Range and Zoom”. This sets the selection in my trace to the interval for this view body update. Then I’ll click on the Time Profiler instrument track. This is where I can see what’s happening on the CPU while my view body is running.
Time Profiler gathers data by sampling what's running on the CPU at regular intervals. For each sample, it checks which function is currently running, and saves that info to the profiling session. The Profile detail pane below shows call stacks for the samples recorded during the trace. In this case, these are the samples recorded while my view body was running. I’ll hold the Option key and click to expand the main thread call stack. SwiftUI work is represented by a very deep call stack. What I'm most interested in here is LandmarkListItemView. I’ll press Command-F to search the call stack, and I'll type the name in the search field.
There’s my view body. In the leftmost column, Time Profiler shows the amount of time spent in each frame in the call stack. This column shows that most of the time spent in the view body was in a computed property called distance. Within distance, the two heaviest frames are calls to two different formatters. This measurement formatter, and this number formatter. Let’s switch back to Xcode to check out what’s happening in the code.
This is LandmarkListItemView, which is the view for each landmark in the list. And this is the distance property I noticed in Time Profiler. This property converts my distance from the landmark, into a formatted string to display in the view. Here’s the number formatter, which Time Profiler showed me was expensive to create. And here’s where the measurement formatter creates the string, which was also a big contributor to the time spent in the view body.
In the view body, I’m reading the distance property in order to build the text for the label. This happens every time the view body runs, and because view bodies run on the main thread, my app has to wait for the distance text to be formatted before it can continue updating its UI.
But why does this matter? A millisecond to run a view body may not seem like a long time, but the total time spent can really add up, especially when SwiftUI has a lot of views on screen to update. Hey Jed, how should I be thinking about the time it takes SwiftUI to run view bodies? That’s a great question. I’ll start by describing how the render loop works on Apple platforms. Every frame, the app wakes up to handle events, like touches or key presses. Then, it updates the UI. This includes running the body property of any SwiftUI views that have changed.
All of this work has to complete before each frame deadline. The app then hands the work off to the system, which renders your views before the next frame deadline. The rendered output finally becomes visible on screen just after that deadline. Everything here is working just as it should. Updates complete before their corresponding frame deadlines, giving the system enough time to render each frame and make it visible on screen. Let’s compare this with an app with a hitch caused by a view body that took too long.
Just like before, we handle events first. Then we run the UI updates. But on the first frame here, one of the UI updates took too long. This caused the UI update portion to run past the frame deadline. This means the next update can’t begin until a frame later. And this frame isn’t ready to hand anything off to the renderer at the deadline.
As a result, the previous frame remains visible on screen until the system finishes rendering the next frame. We call a frame that stays visible on screen for too long, delaying future frames, a hitch. Hitches make animations appear less fluid. For more information about hitches, check out the article “Understanding hitches in your app” and this Tech Talk, that go more into depth on the render loop and how you can fix different kinds of hitches.
Steven, does this help explain why view body runtime matters? Yeah, that was really helpful! So the risk of having view body updates run for any longer than they need to is that this can cause my app to miss the frame deadline, which causes hitches. So I need a way to calculate the distance string for each landmark and cache it in advance of displaying the view, instead of doing it while the body is running. Let’s go back to the code. Okay, so here’s the distance property that’s running every time the view body updates. Instead of doing this work while the view body is running, I’m going to move it somewhere more centralized; the class where I manage location updates.
The LocationFinder class is responsible for receiving updates whenever my location changes. Instead of calculating the formatted distance string in the view body, I can create these strings in advance and cache them here so they’ve already been calculated whenever one of my views needs to show one. I'll start by updating the initializer to create the formatters I was previously creating in the view body.
I’ve added this property called formatter to store my measurement formatter. And at the top of the initializer, I’m creating the number formatter I was previously creating in my view. And the measurement formatter, which I’m storing in the new property I added. Because the format will never change, I can reuse the formatters any time the distance strings need to update, and avoid incurring the cost of recreating a new formatter each time the view body runs. Next, I’ll need a way to keep the strings cached, so my views can use them when needed. I’ll add some code to manage those updates.
I have an array to store the landmarks, which I’ll use to calculate the distances. I also have a dictionary to cache the distance strings after they're calculated. This function called updateDistances will recalculate the strings whenever my location changes. I’m using the formatter here, to create the distance text.
And storing the text in my cache here. In just a moment, I’ll call this last function from my view to get the cached text. There’s one last thing I need to do here. When my location updates, I need to update the cache of strings. I’ll click the jump bar dropdown, and jump to the didUpdateLocations function, which CoreLocation calls when my location changes. This is where I’ll call the updateDistances function I created. Now, I’ll switch back to my view. And I’ll update the view to use the cached value.
These changes should fix the slow view body updates. Now, let’s look at an Instruments trace taken with these fixes implemented, to verify that things have improved. With the View Body Updates track selected, the Long View Body Updates summary in the detail pane shows that the long updates to LandmarkListItemView are gone.
There are still two long view body updates listed in the summary, but it’s important to note that these updates happen at the very beginning of the trace, as the app is preparing to render its first frame. It’s not uncommon for updates right after app launch to take longer, while the system builds the app’s initial view hierarchy. But this won’t result in a hitch.
The important thing here is that the long LandmarkListItemView updates that could have caused hitches while scrolling, have now been fixed, and are gone from the list. This means I can be confident that I’m not slowing SwiftUI down, as it works to get all of my views onto the screen.
Fixing long view body updates, is a great way to enhance an app’s performance. However, there’s something else to consider; too many unnecessary view body updates can also cause performance issues. Let’s explore why. Here’s the diagram Jed showed before. But this time, there isn’t a single update that was longer than the rest. Instead, there are a large number of relatively fast updates that all have to happen during this frame.
All this extra work, results in the app missing the deadline to submit its frame. And again, the next update is delayed by a frame. And because there’s nothing hand off to the renderer, once again there’s a hitch, because the previous frame stays visible for two whole frames. The reason I mention the potential performance impact of unnecessary view updates is because I’ve been working on a new feature for our app where I think this will matter a lot.
Scrolling through all the landmarks has me super excited about exploring new places, but it’s really hard to prioritize where to go. So I came up with an idea to make it easier. Let me show you. I've added a new heart button to each landmark, which I can tap to add and remove favorites.
Let me show you the code. In LandmarkListItemView, I’ve added this overlay that displays my new heart button. The Button’s action calls the toggleFavorite function on my model data class to favorite or un-favourite the landmark. The label icon shows a filled heart if the landmark is favorited, or an empty one if it isn't. I’ll Command-Click on toggleFavorite to jump to that function.
And this is how I’m adding a favorite. The model stores an array of favorite landmarks. and I’m appending the landmark to the array when a favorite is added. To remove a favorite, I'm doing the opposite. And that's what I've got so far. I'm sure my feature needs some more work, but it’s a good idea to profile in Instruments early and often during development. So let’s find out how my new feature is performing. I’ll press Command-I to build the app and switch back to Instruments, and click record again.
I think I’ll scroll down to the North America list like before and over to the right and I’ll tap the heart to favorite Muir Woods. Because it’s not that far from where I live, yet somehow I still haven’t been there! Okay, now I’ll scroll back up. And favorite somewhere far away How about Mount Fuji? Now that would be a fun adventure. Now I’ll stop the recording.
I want to make sure that tapping on my new favorite button isn’t causing any extra unnecessary updates. Analyzing a trace with an idea in mind of what you expect, and looking for anything that seems out of place, can be a great way to identify potential problems. I'll click to expand the SwiftUI instrument track, and select the View Body Updates subtrack.
Since I tapped on two favorite buttons, Muir Woods and Mount Fuji, I’m expecting those two views to have updated. I tapped the buttons in the second half of the trace, after I scrolled down to the bottom. So I’ll highlight that part of the trace, to focus on just the part I'm interested in. Now I’ll check the detail pane below. I'll expand the hierarchy to find the list of updates for my views.
I’m surprised to see that LandmarkListItemView actually updated quite a few times. But why? When debugging a view update in a UIKit app, I’d usually put a breakpoint in my code, and inspect the backtrace to try to figure out why the view updated. But in my SwiftUI apps, such as Landmarks, this hasn’t worked well for me. SwiftUI call stacks seem harder to understand. Jed, why doesn’t this approach work with SwiftUI apps? Let me explain.
Understand causes and effects of SwiftUI updates
Xcode helps you understand cause and effect for imperative code, like in UIKit apps, by showing you backtraces when you hit a breakpoint. UIKit is an imperative framework, so backtraces are often useful for debugging cause and effect. Here, I can tell that my label is being updated because I set an isOn property in my viewDidLoad. And guessing at the names of some of the system frames in the backtrace, it seems like this happened while my app was launching its first scene.
When I compare with a similar SwiftUI app that does the same thing, I find several recursive updates to stuff inside SwiftUI, separated by frames inside something called AttributeGraph. None of this tells me why my view specifically needs to update. Because SwiftUI is declarative, you can’t use the backtrace to understand why your view is updating. So how do I make sense of what’s causing my SwiftUI views to update? First, you’ll need to understand how SwiftUI works.
I’ll walk you through a small example view to show how SwiftUI’s data model, the AttributeGraph defines dependencies between views, and avoids re-running your view unless necessary. I won’t cover all of the details today, but this section should give you a foundation for understanding how updates flow around your app. Views declare conformance to the View protocol. Then, they implement a body property to define their appearance and behavior by returning another View value.
The OnOffView here returns a Text view from its body and passes in a label that changes depending on the value of its isOn state variable. When this view is first added to the view hierarchy, SwiftUI receives an object called an attribute from its parent view that stores the view struct.
View structs are recreated frequently, but attributes keep their identity and maintain state for the entire lifetime of the view. So as the parent view updates, the value of this attribute will change but its identity won’t. The view is asked to create its own attributes to store its state and define its behavior.
It first creates storage for the isOn state variable, and an attribute that tracks when that state variable changes. Then, the view creates a new attribute to run its body, which depends on both of these. Whenever the view body attribute is asked to produce a new value, it reads the current value of your view passed from the parent view.
Next, the attribute updates a copy of that view struct with the current value of your state variable. Then, it accesses the 'body' computed property on that temporary copy of your view, and saves the value it returns as the updated value of the attribute. Then, since your view’s body returned a Text view, SwiftUI sets up the attributes it needs to display text.
The text view creates an attribute that depends on the environment to access the current default styles like the foreground color and font to determine what any rendered text should look like. This attribute adds a dependency on your view body to access the string it will render from the Text struct you returned.
Finally, Text creates another attribute that builds a description of what to renderbased on the styled text. Now, let’s talk about what happens when you change a state variable. When you do this, SwiftUI doesn’t immediately update your views. Instead, it creates a new transaction. A transaction represents a change to the SwiftUI view hierarchy that needs to be made before the next frame.
This transaction will mark the signal attribute for your state variable as outdated. Then, when SwiftUI gets ready to update for its next frame, it runs the transaction and applies the update that was scheduled. Now that an attribute has been marked as outdated, SwiftUI walks down the chain of attributes that depend on the now-outdated attribute, marking each one as outdated by setting a flag.
Setting the flag happens really quickly, and no additional work happens just yet. After running any other transactions, SwiftUI now needs to figure out what to draw to the screen for this frame. But it can’t access that information because it’s marked as outdated. So SwiftUI must update all the dependencies of this information to decide what to draw.
starting with the ones that have no outdated dependencies, like the State signal. Now your view body attribute is ready to update. It runs again, producing a brand new Text struct value with an updated string. This is passed to the existing Apply styling attribute and the updates continue until all the attributes needed to figure out what needs to be drawn have been updated. Now SwiftUI is able to answer the question it came for; what should it draw on the screen?
When I ask "why did my view body run?" the real question is "what marked my view body as outdated?". You can often control when dependencies, such as other views, mark your view body as outdated, especially when those views are your own. But SwiftUI also performs additional work in order to display your view. While this work is necessary and usually unavoidable, understanding when it's happening can be valuable.
Making information about both the causes and effects of your view updates available to you is a big feature of the new SwiftUI instrument. The Cause & Effect Graph records all of these cause and effect relationships and displays them to you in a graph that looks like this. We start with the view body update that we’re investigating. The update shows up as a node with an icon identifying it as a view body update, and a title telling you which view type it corresponds to.
There’s an arrow pointing to it from a node representing the State change. The arrow is labeled "update” because the state change caused the view to update. You will also notice edges labeled “Creation” that tell you what made your view first appear in the view hierarchy. The state change node has a title that tells you what the name of the state variable is, and the type of view it’s attached to. When you select the state change, you’ll be shown a backtrace of where the value was updated.
Continuing towards the left of the cause and effect graph, you can tell the state change happened due to a gesture, like a tap on a button. Steven, what does the cause graph show for the Landmarks app? Let’s check out the Cause & Effect graph to make sense of why all those extra view body updates happened. This is the Cause & Effect Graph view. The node for LandmarkListItemView.body is selected. The blue nodes in the graph represent parts of my own code, or actions I performed while interacting with the app. The graph shows the chain of causes and effects from left to right.
The “Gesture” node represents my taps of the favorite button. This caused the array of favorite landmarks to be updated, which caused LandmarkListItemView’s body to update quite a few times. That’s a lot more than I expected. It seems like tapping on a single favorite button may be causing lots of item views on the screen to update, instead of just the one I tapped. So let’s figure out what’s happening here by going back to the code.
I’ll switch back to LandmarkListItemView The way I’m checking to see if a landmark is marked as a favorite is by calling modelData.isFavorite and passing the landmark. ModelData is my top-level model object, which uses the @Observable macro to allow SwiftUI to update my view as its properties change. I’ll Command-Click on isFavorite to jump to that function.
Here, I’m accessing the favoritesCollection.landmarks array to check if this landmark is a favorite. This causes @Observable to establish a dependency between each item view and the whole array of favorites. So, whenever I add a favorite to the array, every item view’s body runs, because the array has changed. Let me show you how this works.
Here are some of my LandmarkListItemViews And here’s my ModelData class with the favoritesCollection, which keeps track of my favorite landmarks. Currently, my only favorite is landmark number two. � The ModelData class has an isFavorite function And each LandmarkListItemView calls this function to determine whether the icon should be highlighted or not. The isFavorite function checks the collection to see if it contains the landmark, and each view renders its own button. Because each view accessed the favorites array, even though it was indirectly, the @Observable macro has created a dependency for each view on the whole array of favorites.
So what happens when I want to add a new favorite by tapping the favorite button on one of my other views? The view calls toggleFavorite, which adds a new landmark to my favorites. Because all of my LandmarkListItemViews have a dependency on the favoritesCollection, all of the views are marked as outdated, and their bodies run again.
But that’s not ideal, because the only view I actually changed was view number three. What I really need is for my view’s data dependencies to be more granular, so when my app’s data changes, only the necessary view bodies are updated. So let’s rethink this a bit. I know that each one of my views has a landmark that has its own favorite status; favorited, or not. So to keep track of that status I’ll create an Observable view model for my view. The model has an isFavorite property to track the favorite status, and each view will have its own view model.
Now I can store my view models in the ModelData class. Each view can retrieve its own model and toggle the favorite on and off as needed. So instead of each view being dependent on the full array of favorites, each view only depends directly on its own landmark’s view model.
So let’s add one more favorite! Tapping the button calls toggleFavorite Which updates the view model for view number one. And because view number one is only dependent on its own view model, It’s the only view whose body runs again. Let’s find out how making these changes turned out in Landmarks. Here’s a trace I recorded after implementing the new view model improvements. I'll click the View Body Updates subtrack again. And I’ll select the same portion of the timeline from before.
In the detail pane, I’ll expand the process, and the Landmarks module. Now, there are only two updates. Since I changed two favorites, that seems right, but let’s double check the graph. I’ll hover over the view name, and click the arrow and choose “Show Cause & Effect Graph”. And here’s the graph again.
Now, the arrow from the @Observable node to my view body only shows two updates, one for each button. By replacing each item view’s dependency on the entire array of favorites, with a tightly coupled view model, I’ve eliminated a substantial number of unnecessary view body updates, which will help keep my app running smoothly In this example, the graph was relatively small, because the causes of my view body’s updates were very limited.
However, the graph can grow much larger when there are more distinct causes. One way this can happen is when a view reads from the Environment. Jed, can you show us an example? Sure! I’ll start by talking about how the environment works Values in the environment are stored in the EnvironmentValues struct, which is a value type, similar to a dictionary.
Each of these views has a dependency on the whole EnvironmentValues struct, because each view accesses the environment using the environment property wrapper. When any value in the environment is updated, Each view with a dependency on the environment is notified that its body may need to run. Then each of these views checks to see if the value it’s reading has changed. If the value changed, the view body needs to run again. If it didn’t change, SwiftUI can skip running the view body because the view is already up to date. Let’s explore how these updates look in the Cause & Effect graph.
There are two main types of nodes in the graph representing updates to the environment. External Environment updates include app-level things like color scheme that are updated from outside of SwiftUI. EnvironmentWriter updates represent changes to a value in the environment that happen inside of SwiftUI. Updates you make in your app using the dot-environment modifier fall into this category.
So let’s say the color scheme environment value is updated because the device switched to dark mode. What would that look like in the Cause & Effect Graph for these views? The graph will show a node for “External Environment” for View1, since the color scheme is a system-level environment update. And the graph will also show a node indicating that View1’s body ran. Because View2 also reads the environment, it has an External Environment update in the graph as its cause too. But View2 doesn’t read the color scheme value, so its body doesn’t run.
In the graph, a view update where the bodydidn’t run is represented by a dimmed icon In this case, these two external environment nodes represent the same update. If you hover or click on either node for the same update, they will both highlight at the same time to make this easier to identify Both of these view updates are shown in the graph, because even in cases where a view’s body doesn’t need to run as a result of an environment update, there is still a cost associated with checking for updates to the value of interest to the view.
The time spent can add up quickly if your app has a lot of views reading from the environment. That’s why it’s important to avoid storing values that update really often, such as geometry values or timers, in the environment. And that’s the Cause & Effect Graph. It’s a great way to visualize how data flows through your app, to help you ensure that your views aren’t updating more than they need to. In this session, we’ve covered some best practices for achieving great performance in your SwiftUI app.
It’s important to keep your view bodies fast, so that SwiftUI has enough time to get your UI onto the screen without delay. Unnecessary view body updates can really add up. Design your data flow to update your views only when necessary, and be extra careful with dependencies that change very frequently.
And finally, remember to use Instruments early and often to analyze your app’s performance during development. I know that we’ve covered a lot today. However, the most important takeaway is this; Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance. Use the SwiftUI instrument to verify your app’s performance along the way.
Next steps
In today’s session we showed you how to profile your apps with the SwiftUI instrument, but there’s more to explore. Check out the documentation linked in the video description to learn about some of the other features of the instrument. We’ve also added links to more videos and reference material about analyzing and improving the performance of your app. Thank you for joining us! We’re excited to see you get the best performance out of your apps, using the new SwiftUI Instrument.