View in English

  • Global Nav Open Menu Global Nav Close Menu
  • Apple Developer
Search
Cancel
  • Apple Developer
  • News
  • Discover
  • Design
  • Develop
  • Distribute
  • Support
  • Account
Only search within “”

Quick Links

5 Quick Links

Videos

Open Menu Close Menu
  • Collections
  • Topics
  • All Videos
  • About

More Videos

Streaming is available in most browsers,
and in the Developer app.

  • About
  • Summary
  • Transcript
  • Code
  • Optimize SwiftUI performance with Instruments

    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.

    Chapters

    • 0:00 - Introduction & Agenda
    • 2:19 - Discover the SwiftUI instrument
    • 4:20 - Diagnose and fix long view body updates
    • 19:54 - Understand causes and effects of SwiftUI updates
    • 35:01 - Next steps

    Resources

    • Analyzing the performance of your visionOS app
    • Improving app responsiveness
    • Measuring your app’s power use with Power Profiler
    • Performance and metrics
    • Understanding and improving SwiftUI performance
      • HD Video
      • SD Video

    Related Videos

    WWDC25

    • Optimize CPU performance with Instruments

    WWDC23

    • Analyze hangs with Instruments
    • Demystify SwiftUI performance
    • Explore SwiftUI animation

    WWDC22

    • Compose custom layouts with SwiftUI

    WWDC21

    • Demystify SwiftUI

    Tech Talks

    • Explore UI animation hitches and the render loop
  • Search this video…

    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! 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. -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. 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.

    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.

    • 8:47 - LandmarkListItemView

      import SwiftUI
      import CoreLocation
      
      /// A view that shows a single landmark in a list.
      struct LandmarkListItemView: View {
          @Environment(ModelData.self) private var modelData
      
          let landmark: Landmark
      
          var body: some View {
              Image(landmark.thumbnailImageName)
                  .resizable()
                  .aspectRatio(contentMode: .fill)
                  .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                  .overlay { ... }
                  .clipped()
                  .cornerRadius(Constants.cornerRadius)
                  .overlay(alignment: .bottom) {
                      VStack(spacing: 6) {
                          Text(landmark.name)
                              .font(.title3).fontWeight(.semibold)
                              .multilineTextAlignment(.center)
                              .foregroundColor(.white)
      
                          if let distance {
                              Text(distance)
                                  .font(.callout)
                                  .foregroundStyle(.white.opacity(0.9))
                                  .padding(.bottom)
                          }
                      }
                  }
                  .contextMenu { ... }
          }
      
          private var distance: String? {
              guard let currentLocation = modelData.locationFinder.currentLocation else { return nil }
              let distance = currentLocation.distance(from: landmark.clLocation)
      
              let numberFormatter = NumberFormatter()
              numberFormatter.numberStyle = .decimal
              numberFormatter.maximumFractionDigits = 0
      
              let formatter = MeasurementFormatter()
              formatter.locale = Locale.current
              formatter.unitStyle = .medium
              formatter.unitOptions = .naturalScale
              formatter.numberFormatter = numberFormatter
              return formatter.string(from: Measurement(value: distance, unit: UnitLength.meters))
          }
      }
    • 12:13 - LocationFinder Class with Cached Distance Strings

      import CoreLocation
      
      /// A class the app uses to find the current location.
      @Observable
      class LocationFinder: NSObject {
          var currentLocation: CLLocation?
          private let currentLocationManager: CLLocationManager = CLLocationManager()
      
          private let formatter: MeasurementFormatter
      
          override init() {
              // Format the numeric distance
              let numberFormatter = NumberFormatter()
              numberFormatter.numberStyle = .decimal
              numberFormatter.maximumFractionDigits = 0
      
              // Format the measurement based on the current locale
              let formatter = MeasurementFormatter()
              formatter.locale = Locale.current
              formatter.unitStyle = .medium
              formatter.unitOptions = .naturalScale
              formatter.numberFormatter = numberFormatter
              self.formatter = formatter
      
              super.init()
              
              currentLocationManager.desiredAccuracy = kCLLocationAccuracyKilometer
              currentLocationManager.delegate = self
          }
      
          // MARK: - Landmark Distance
      
          var landmarks: [Landmark] = [] {
              didSet {
                  updateDistances()
              }
          }
      
          private var distanceCache: [Landmark.ID: String] = [:]
      
          private func updateDistances() {
              guard let currentLocation else { return }
      
              // Populate the cache with each formatted distance string
              self.distanceCache = landmarks.reduce(into: [:]) { result, landmark in
                  let distance = self.formatter.string(
                      from: Measurement(
                          value: currentLocation.distance(from: landmark.clLocation),
                          unit: UnitLength.meters
                      )
                  )
                  result[landmark.id] = distance
              }
          }
      
          // Call this function from the view to access the cached value
          func distance(from landmark: Landmark) -> String? {
              distanceCache[landmark.id]
          }
      }
      
      extension LocationFinder: CLLocationManagerDelegate {
          func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
              switch currentLocationManager.authorizationStatus {
              case .authorizedWhenInUse, .authorizedAlways:
                  currentLocationManager.requestLocation()
              case .notDetermined:
                  currentLocationManager.requestWhenInUseAuthorization()
              default:
                  currentLocationManager.stopUpdatingLocation()
              }
          }
          
          func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
              print("Found a location.")
              currentLocation = locations.last
              // Update the distance strings when the location changes
              updateDistances() 
          }
          
          func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
              print("Received an error while trying to find a location: \(error.localizedDescription).")
              currentLocationManager.stopUpdatingLocation()
          }
      }
    • 16:51 - LandmarkListItemView with Favorite Button

      import SwiftUI
      import CoreLocation
      
      /// A view that shows a single landmark in a list.
      struct LandmarkListItemView: View {
          @Environment(ModelData.self) private var modelData
      
          let landmark: Landmark
      
          var body: some View {
              Image(landmark.thumbnailImageName)
                  .resizable()
                  .aspectRatio(contentMode: .fill)
                  .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                  .overlay { ... }
                  .clipped()
                  .cornerRadius(Constants.cornerRadius)
                  .overlay(alignment: .bottom) { ... }
                  .contextMenu { ... }
                  .overlay(alignment: .topTrailing) {
                      let isFavorite = modelData.isFavorite(landmark)
                      Button {
                          modelData.toggleFavorite(landmark)
                      } label: {
                          Label {
                              Text(isFavorite ? "Remove Favorite" : "Add Favorite")
                          } icon: {
                              Image(systemName: "heart")
                                  .symbolVariant(isFavorite ? .fill : .none)
                                  .contentTransition(.symbolEffect)
                                  .font(.title)
                                  .foregroundStyle(.background)
                                  .shadow(color: .primary.opacity(0.25), radius: 2, x: 0, y: 0)
                          }
                      }
                      .labelStyle(.iconOnly)
                      .padding()
                  }
          }
      }
    • 17:20 - ModelData Class

      /// A structure that defines a collection of landmarks.
      @Observable
      class LandmarkCollection: Identifiable {
          // ...
          var landmarks: [Landmark] = []
          // ...
      }
      
      /// A class the app uses to store and manage model data.
      @Observable @MainActor
      class ModelData {
          // ...
          var favoritesCollection: LandmarkCollection!
          // ...
      
          func isFavorite(_ landmark: Landmark) -> Bool {
              var isFavorite: Bool = false
              
              if favoritesCollection.landmarks.firstIndex(of: landmark) != nil {
                  isFavorite = true
              }
              
              return isFavorite
          }
      
          func toggleFavorite(_ landmark: Landmark) {
              if isFavorite(landmark) {
                  removeFavorite(landmark)
              } else {
                  addFavorite(landmark)
              }
          }
      
          func addFavorite(_ landmark: Landmark) {
              favoritesCollection.landmarks.append(landmark)
          }
      
          func removeFavorite(_ landmark: Landmark) {
              if let landmarkIndex = favoritesCollection.landmarks.firstIndex(of: landmark) {
                  favoritesCollection.landmarks.remove(at: landmarkIndex)
              }
          }
          // ...
      }
    • 20:50 - OnOffView

      struct OnOffView: View {
          @State private var isOn = true
          var body: some View {
              Text(isOn ? "On" : "Off")
          }
      }
    • 29:21 - Favorites View Model Class

      @Observable class ViewModel {
          var isFavorite: Bool
          
          init(isFavorite: Bool = false) {
              self.isFavorite = isFavorite
          }
      }
    • 29:21 - ModelData Class with New ViewModel

      @Observable @MainActor
      class ModelData {
          // ...
          var favoritesCollection: LandmarkCollection!
          // ...
      
          @Observable class ViewModel {
              var isFavorite: Bool
              init(isFavorite: Bool = false) {
                  self.isFavorite = isFavorite
              }
          }
      
          // Don't observe this property because we only need to react to changes
          // to each view model individually, rather than the whole dictionary
          @ObservationIgnored private var viewModels: [Landmark.ID: ViewModel] = [:]
      
          private func viewModel(for landmark: Landmark) -> ViewModel {
              // Create a new view model for a landmark on first access
              if viewModels[landmark.id] == nil {
                  viewModels[landmark.id] = ViewModel()
              }
              return viewModels[landmark.id]!
          }
      
          func isFavorite(_ landmark: Landmark) -> Bool {
              // When a SwiftUI view, such as LandmarkListItemView, calls
              // `isFavorite` from its body, accessing `isFavorite` on the 
              // view model here establishes a direct dependency between
              // the view and the view model
              viewModel(for: landmark).isFavorite
          }
      
          func toggleFavorite(_ landmark: Landmark) {
              if isFavorite(landmark) {
                  removeFavorite(landmark)
              } else {
                  addFavorite(landmark)
              }
          }
      
          func addFavorite(_ landmark: Landmark) {
              favoritesCollection.landmarks.append(landmark)
              viewModel(for: landmark).isFavorite = true
          }
      
          func removeFavorite(_ landmark: Landmark) {
              if let landmarkIndex = favoritesCollection.landmarks.firstIndex(of: landmark) {
                  favoritesCollection.landmarks.remove(at: landmarkIndex)
              }
              viewModel(for: landmark).isFavorite = false
          }
          // ...
      }
    • 31:34 - Cause and effect: EnvironmentValues

      struct View1: View {
          @Environment(\.colorScheme)
          private var colorScheme
      
          var body: some View {
              Text(colorScheme == .dark
                      ? "Dark Mode"
                      : "Light Mode")
          }
      }
      
      struct View2: View {
          @Environment(\.counter) private var counter
      
          var body: some View {
              Text("\(counter)")
          }
      }
    • 0:00 - Introduction & Agenda
    • Learn how to optimize SwiftUI app performance with the new SwiftUI instrument and template in Instruments 26. You can use Instruments to profile your apps and identify bottlenecks, such as long view body updates and unnecessary SwiftUI updates, which can cause hitches, hangs, paused animations and transitions, and delayed scrolling. The example app presented, called Landmarks, displays global landmarks and their distances from the user's location. See how to use the new SwiftUI instrument to improve the app's scrolling smoothness by diagnosing and resolving performance issues in the SwiftUI code.

    • 2:19 - Discover the SwiftUI instrument
    • Instruments 26 introduces a new SwiftUI instrument and template for profiling SwiftUI apps. Similar to both Time Profiler and Hangs and Hitches instruments, it helps identify performance issues. The Update Groups lane shows SwiftUI work. The remaining three lanes highlight long view body updates, long representable updates, and other updates — colored orange and red based on their likelihood of causing hitches or hangs. To use the new SwiftUI instrument, install Xcode 26 and update your device OS to the latest release.

    • 4:20 - Diagnose and fix long view body updates
    • The example uses Xcode 26 and Instruments 26 to profile the Landmarks app, which is written in SwiftUI. Begin by launching Instruments and selecting the SwiftUI template to record the app's performance. Then interact with the app on iPhone by scrolling through a list of landmarks, which loads additional views. After the recording stops, Instruments processes the data, and you can then analyze the SwiftUI track. Focus on the Long View Body Updates lane, where you can identify specific views, such as 'LandmarkListItemView', causing performance issues. By expanding the SwiftUI track and using the Time Profiler instrument, you can delve deeper into the CPU usage during view body updates. You can discover that certain computed properties, particularly formatters used to convert and display distance data, are consuming excessive time. Consider the importance of optimizing view body runtime in SwiftUI, specifically that view bodies run on the main thread, and any delays can cause the app to miss frame deadlines, resulting in hitches. Hitches make animations appear less fluid and can negatively impact the overall user experience. To address these performance problems in the example project, you can calculate and cache the distance string in advance, rather than performing these calculations during the view body update, ensuring smoother and more responsive app performance. In Xcode, there's an optimization process in the 'LocationFinder' class, which manages location updates. Previously, the system calculated formatted distance strings within the view body of 'LandmarkListItemView', leading to inefficient updates. To address this, the code moves this logic to the 'LocationFinder' class. Here, the system creates and stores formatters in the initializer to be reused, avoiding redundant creation. A dictionary caches the distance strings after calculation. The 'updateDistances' function is responsible for recalculating these strings whenever the location changes. This function utilizes the previously created formatters to generate the distance string and store it in the cache. The CoreLocation framework calls the 'locationManager(_:didUpdateLocations:)' method on its 'CLLocationManagerDelegate' object when the device's location changes. By calling 'updateDistances' within this method, the cache is kept up to date. The views then retrieve the cached distance strings, eliminating the need for recalculation during view body updates. Next, you can add a new feature: a heart button to favorite landmarks. When someone taps the button, the `toggleFavorite` function is called, updating the model data class to add or remove the landmark from the list of favorites. The view then reflects this change by displaying a filled or empty heart icon. When profiling the app's new favoriting feature in Instruments, you may find that the 'LandmarkListItemView' updated more frequently than expected. This unexpected behavior prompts an investigation into the view update logic, highlighting the challenges in debugging view updates in SwiftUI apps compared to UIKit apps, where traditional breakpoint-based inspection may not be as straightforward for declarative frameworks.

    • 19:54 - Understand causes and effects of SwiftUI updates
    • In Xcode, debugging imperative code, such as in UIKit apps, is straightforward using backtraces. However, this approach becomes less effective with SwiftUI due to its declarative nature. SwiftUI's data model, the 'AttributeGraph', manages dependencies between views, optimizing updates. When a SwiftUI view is declared, it conforms to the 'View' protocol and defines its appearance and behavior through the 'body' property. This 'body' property returns another 'View' value, and SwiftUI internally manages the view's state and updates using attributes. Changes to state variables trigger transactions, marking relevant attributes as outdated. SwiftUI then efficiently updates the view hierarchy during the next frame, traversing the dependency chain to refresh only the necessary parts. To understand why a SwiftUI view updated, you can utilize the new SwiftUI Instrument Cause & Effect graph. This graph visualizes the relationships between updates, showing the chain of causes from user interactions, such as gestures, to state changes and ultimately, view body updates. By examining this graph, you can identify inefficiencies, like unnecessary updates, and optimize their code accordingly. In the Landmarks app, the 'ModelData' class contains a 'favoritesCollection' property, which stores favorited landmarks in an array. Initially, each 'LandmarkListItemView' checked if a landmark was a favorite by accessing the entire 'favoritesCollection' array, creating a dependency between each item view and the whole array. This led to inefficient performance as whenever a favorite was added, every item view's body ran. To address this issue, the approach was rethought. An 'Observable' data model was created for each landmark, storing its favorite status directly. Each 'LandmarkListItemView' now has its own data model, eliminating the dependency on the full array of favorites. By implementing this change, the system only updates the necessary view body when someone toggles a favorite. This optimization significantly improves performance, as demonstrated by the reduced number of view body updates observed in the Cause & Effect graph. The graph also shows how updates to the environment, such as changes in color scheme, can affect views. Even if a view's body doesn't need to run due to an environment update, there's still a cost associated with checking for these updates, so it's important to avoid storing frequently changing values in the environment.

    • 35:01 - Next steps
    • For the new SwiftUI instrument in Instruments 26, additional features, videos, and related resources on app performance analysis and improvement are available in the developer documentation.

Developer Footer

  • Videos
  • WWDC25
  • Optimize SwiftUI performance with Instruments
  • Open Menu Close Menu
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    Open Menu Close Menu
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • SF Symbols
    Open Menu Close Menu
    • Accessibility
    • Accessories
    • App Extensions
    • App Store
    • Audio & Video
    • Augmented Reality
    • Design
    • Distribution
    • Education
    • Fonts
    • Games
    • Health & Fitness
    • In-App Purchase
    • Localization
    • Maps & Location
    • Machine Learning
    • Open Source
    • Security
    • Safari & Web
    Open Menu Close Menu
    • Documentation
    • Tutorials
    • Downloads
    • Forums
    • Videos
    Open Menu Close Menu
    • Support Articles
    • Contact Us
    • Bug Reporting
    • System Status
    Open Menu Close Menu
    • Apple Developer
    • App Store Connect
    • Certificates, IDs, & Profiles
    • Feedback Assistant
    Open Menu Close Menu
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program
    • News Partner Program
    • Video Partner Program
    • Security Bounty Program
    • Security Research Device Program
    Open Menu Close Menu
    • Meet with Apple
    • Apple Developer Centers
    • App Store Awards
    • Apple Design Awards
    • Apple Developer Academies
    • WWDC
    Get the Apple Developer app.
    Copyright © 2025 Apple Inc. All rights reserved.
    Terms of Use Privacy Policy Agreements and Guidelines