Performance Optimization

Jun 20 2024 · Swift 5.9, iOS 17.4, Xcode 15.3

Lesson 01: Identifying Performance Bottlenecks

Demo

Episode complete

Play next episode

Next
Transcript

Optimization Opportunities Detection

Welcome to Cinematica, where you’ll explore the intricacies of optimizing app performance! Cinematica is a sleek one-screen view app that brings you the latest upcoming movies from The Movie Database (TMDb) API. In this lesson, you’ll use Cinematica to delve into the world of performance optimization, focusing primarily on SwiftUI views.

Open the starter project for this lesson. Build and run the project. As you navigate through Cinematica, you’ll notice its minimalist design, allowing you to seamlessly scroll through a list of upcoming movies. But behind its elegant interface lie opportunities for performance enhancement. You’ll take a closer look at the app’s SwiftUI views to identify potential bottlenecks and learn how to address them effectively.

First, check Xcode’s debug metrics for possible performance opportunities. While you’re still running the app, tap the Debug View Hierarchy button in Xcode to see the current view’s View Hierarchy.

Next, open the Debug Navigator. You’ll see some important metrics like CPU and memory usage. You’ll focus on this part on the purple alert shown in the hierarchy, indicating the presence of an optimization opportunity. By default, Xcode enables you to see these opportunities. But if you want to ensure they’re visible, open the Editor menu, then check the Show Optimization Opportunities option.

Unfold the hierarchy to see the exact location of this issue. You’ll see that it’s related to the ListCollectionViewCell. But what’s the issue exactly? To answer this question, open the Issue Navigator. This navigator holds the details of all the performance opportunities. By reading the issue, you can spot that it’s related to triggering the offscreen rendering through shadowing. Now you know that this issue is related to the cell view and shadow. It’s time to fix it.

Open MovieCellView, then scroll down to the bottom of the view until you spot the line where you set the shadow for this view. SwiftUI draws the shadows around your view objects dynamically at Runtime based on their current positions and bounds. That rendering follows the view throughout its lifecycle.

It’s a math-intensive process that involves many draw-calls to the GPU. Replace this code with a background modifier that has a shadow in it. You could also remove the shadow if it isn’t important to your UI.

.background(
  Rectangle()
    .fill(Color.white)
    .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2)
    .padding(1)
)

Build and run the app. Then tap the Debug View Hierarchy button in Xcode. Next, open the Issue Navigator. Notice how all the performance opportunities have disappeared.

Congratulations, you’re on track to improve your app’s performance!

MovieCellView Performance Optimizations

Open MovieCellView again. You’ll make a few other performance improvements in this view.

First, remove the GeometryReader from the placeholder of the AsyncImage since you don’t need it. Replace this code with a similar one as the image inside the AsyncImage. GeometryReader was making unnecessary calculations, and you replaced it with a fixed frame and aspect ratio for the placeholder.

Image(.imagePlaceholder)
  .resizable()
  .aspectRatio(0.67, contentMode: .fit)
  .frame(height: 100)

Finally, replace the Labels used in the VStack with the lightweight Text since you don’t have an image to show beside this text.

Text(movie.originalTitle ?? "")
  .font(.title)
Text(movie.overview ?? "")
  .font(.subheadline)

Wow! You’ve become an expert in performance optimization, and you’ve done a great job so far. Notice that there is another optimization opportunity here. The movie property costs a lot of rendering because its located inside the body. You’ll fix this with another major change but first you’ll move to another performance measurement tool to detect a new optimization opportunity.

Using Printing Checks

Open MovieCellView, then add this printing line inside the view’s body. This line is typically used for debugging or logging purposes, where the _printChanges() method might print out or log changes or updates within the current view.

let _ = Self._printChanges()

Build and run the app. Monitor the console output and observe the print statements generated. Each cell appearing on the screen initially triggers a print statement, which is expected behavior. Now, scroll down precisely one cell. You might anticipate only one additional print statement corresponding to the newly displayed MovieCellView. However, multiple print statements are generated, indicating changes in other cells that shouldn’t occur.

This issue arises from how MovieListViewModel is passed to MovieCellView and used to retrieve movie data. This approach results in any modification to MovieListViewModel triggering a rerender of MovieCellView, leading to unnecessary updates when scrolling.

Open MovieCellView and replace all the properties with only one Movie property. Then, use this property throughout your view. Finally, make sure to fix the initializer for your preview.

@State var movie: Movie

Next, open MovieListView and replace the ForEach with a new one that passes only the movie property to the MovieCellView. This change ensures that the MovieCellView has only the needed data, which will prevent unnecessary updates.

ForEach(movieListViewModel.movies) { movie in
  MovieCellView(movie: movie)
    .frame(height: 100)
}

While you’r in this view, lets do another performance improvement. Replace the List with a LazyVGrid inside a ScrollView. You know the benefits of lazy loading to reduce memory usage and improve rendering performance.

ScrollView {
  LazyVGrid(columns: columns, spacing: 10) {
    ForEach(movieListViewModel.movies) { movie in
      MovieCellView(movie: movie)
        .frame(height: 100)
    }
    .padding(.horizontal)
  }

Also add the columns property above the body:

var columns: [GridItem] = [
    GridItem(.flexible(), spacing: 0)
]

Finally, add the needed columns for this LazyVGrid to draw the grid smoothly as previously.

var columns: [GridItem] = [
  GridItem(.flexible(), spacing: 0)
]

Build and run the app. Scroll down precisely one cell and notice how it prints only one statement as expected. You’ve made many improvements, but there is only one to go.

Enhancing SwiftUI Performance with @Observable Macro

You already know the importance of using the @Observable Macro over ObservableObject. Now, you’ll implement this change.

Open MovieListViewModel and import the Observation library. Next, add the @Observable Macro above the MovieListViewModel class. After that, remove the ObservableObject protocol and @Published property wrapper from all properties.

import Observation

@Observable
class MovieListViewModel

Next, open MovieListView, replace @ObservedObject with @State. Build and run the app. Notice that the app has the same behavior, but unnecessary redraws are now minimized, enhancing the overall efficiency of your app.

@State var movieListViewModel: MovieListViewModel

Congratulations! Your app has made great improvements in SwiftUI views. In the next lessons, you’ll address other optimization opportunities.

See forum comments
Cinema mode Download course materials from Github
Previous: Instruction Next: Conclusion