iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧪

Improving App Performance with Instruments: A Tutorial Guide

に公開

I have written this article by expanding on my talk at Nagoya.swift.
Please also check out my slides and the LT video!
https://speakerdeck.com/hinakko/instrumentswoshi-yong-sita-apurinopahuomansuxiang-shang-fang-fa

https://youtu.be/Asnda8_tPB0?si=mPP-ez-pHEa5HR0f

What is Instruments?

Instruments is a tool for analyzing app performance, resource usage, and behavior. By utilizing Instruments, you can improve responsiveness, reduce memory usage, and analyze complex behaviors over time. It can be used to resolve hangs.

What is a Hang?

A state of unresponsiveness is called a hang when there is a significant delay in processing user interactions. Specifically, for example, if the screen does not change even after a user presses a button, the user might feel unsure whether the button press was successful. This state is what we call a hang.
Ideally, work on the main thread should execute without interruption for no more than 100 milliseconds.
Instruments detects hangs that exceed 250 milliseconds as warnings in the Time Profiler.

Instruments Tutorials

This article is based on the sample app from Instruments Tutorials and WWDC videos. Reading through the Instruments Tutorials in parallel with the WWDC videos helped deepen my understanding of how to use Instruments.

https://developer.apple.com/tutorials/instruments

Types of Hangs

Hangs can be broadly divided into two types: main thread is busy and main thread is blocked.

When the main thread is busy, it means the app is constantly processing data and the CPU usage is high.

When the main thread is blocked, the work on the main thread has stopped, and it has yielded CPU access to other threads instead.
By using Instruments and analyzing the main thread CPU usage during the hang, you can determine whether the main thread is busy or blocked.

Hang Example

Let's take a look at a hang example using the Instruments Tutorials sample code!
We press the Collection TabBar to switch the List-state TabBar. However, as shown in the video below, a hang occurs where it takes about 5 seconds for the screen to switch to the Collection screen after pressing the button.

How to Analyze a Hang

First, let's use the Time Profiler in Instruments!
Go to Xcode's Product > Profile, or use the shortcut: command + I
This will open the template selection window where you can select Time Profiler.

Identifying a Hang

Press the red record button in the upper left of the window below, reproduce the hang in the simulator, and press the stop button.
The image below shows a 5.63s hang, and from the high CPU usage, we can see that the main thread is busy.

Analyzing the Main Thread

Call Tree > ✅Hide System Libraries
Hide system libraries to make it easier to analyze the relevant sections of your Xcode code. This displays only methods and code within Xcode.


You can see that 100% of the CPU time is being spent on calls to the ThumbnailView body getter.
This is the cause of the hang.

Fixing the Hang

Consider improvement methods by measuring execution frequency and duration.
There are two ways to resolve a hang:
1. Reduce the amount of processing
2. Offload processing to a background thread

Since execution frequency cannot be determined with the Time Profiler used earlier, click the + Instrument button in the image below and add View Body Instruments. Check the execution frequency of the View getters.

After adding View Body, press the record button and reproduce the hang in the simulator again.


Each ThumbnailView body execution takes 43ms per image, and with a total of 129 image calls, it takes 5.6s.
Even though the current layout fits about 30 to 40 thumbnails on an iPhone screen, 129 body executions are too many.

Fixing the Hang: 1. Reduce the amount of processing

struct ImageCollectionsView: View {
    var images: ImageCollection

    var body: some View {
        let groupNames = images.groupedImages.keys.sorted()
        NavigationStack {
            List(groupNames, id: \.self) { groupName in
                Section(groupName) {
                    let images = images.groupedImages[groupName]!
                    ScrollView(.horizontal) {
                        // ✅Changed from HStack to LazyHStack
                        LazyHStack {
                            ForEach(images) { imageFile in
                                ThumbnailView(imageFile: imageFile)
                            }
                        }
                    }
                }
            }

Rewrite from HStack to LazyHStack.
When using HStack, the hang is 5.69s

When using LazyHStack, the hang is 2.03s

Since LazyHStack only loads images visible on the screen, ThumbnailView calls have decreased from 129 to 38.

Fixing the Hang: 2. Offload processing to a background thread

Change makeThumbnail(displayScale:) from a synchronous function to an async asynchronous function.

struct ImageFile: Identifiable {
    let fileURL: URL
    /// Maximum thumbnail height in points.
    static let maxThumbnailHeight: CGFloat = 50
    
    var id: URL {
        fileURL
    }
    
    var name: String {
        fileURL.deletingPathExtension().lastPathComponent
    }
    
    var image: UIImage? {
        UIImage(contentsOfFile: fileURL.path)
    }
    // ✅Make makeThumbnail(displayScale:) an asynchronous function
    func makeThumbnail(displayScale: CGFloat) async -> UIImage? {
        guard let image else { return nil }
        let thumbnailSize = thumbnailSize(for: image.size, displayScale: displayScale)
        return image.preparingThumbnail(of: thumbnailSize)
    }.....
struct ThumbnailView: View {
    @Environment(\.displayScale) private var displayScale: CGFloat
    var imageFile: ImageFile
    @State private var loadedThumbnail: Image?

    var body: some View {
        content
            .task(id: displayScale) {
						// ✅Add await since it is now an asynchronous function
                guard let thumbnail = await imageFile.makeThumbnail(displayScale: displayScale) else {
                    loadedThumbnail = Image(systemName: "x.square")
                    return
                }
                loadedThumbnail = Image(uiImage: thumbnail)
            }
    }

Why is it executed on the Main Actor if it is wrapped in a .task modifier closure, even for synchronous functions like makeThumbnail(displayScale:)? There are two reasons why this part of the code runs on the Main Actor. First, the body property of a SwiftUI View is implicitly bound to the Main Actor by the View protocol. Second, the closure of the .task modifier, like init(priority:operation:), inherits the actor context from its surrounding scope. Since the body property is bound to the Main Actor, the closure passed to .task() is also bound to the Main Actor.

*Tasks created by Task.init(priority:operation:) inherit the caller's priority and actor context.
https://developer.apple.com/documentation/swift/task/init(priority:operation:)-7f0zv

To allow it to be executed detached from the Main Actor, make it an async asynchronous function, makeThumbnail(displayScale:). Asynchronous functions themselves are not bound to any actor. Because it is called within an asynchronous task generated by the task modifier, it executes on a background thread pool.
Conversely, synchronous functions execute where they are called, and the .task modifier's closure had become bound to the Main Actor.
In contrast, asynchronous functions execute on one of several worker threads managed by the Swift concurrent runtime (concurrent thread pool) rather than the main thread.
In this section, we make the makeThumbnail(displayScale:) function asynchronous so it runs primarily on a background concurrent thread pool, rather than on the Main Actor.

makeThumbnail(displayScale:) as a synchronous function: Execution time 1.69s

makeThumbnail(displayScale:) async as an asynchronous function: Execution time 412ms

By using an asynchronous function, we can reduce the execution time from 1.69s to 412ms, and the hang in Instruments is also resolved.

References

Instruments Tutorial https://developer.apple.com/tutorials/instruments
Analyze hangs with Instruments https://developer.apple.com/videos/play/wwdc2023/10248/
Introduction to SwiftUI https://developer.apple.com/jp/videos/play/wwdc2020/10119/
SwiftUI Basics https://developer.apple.com/jp/videos/play/wwdc2024/10150/
Data Essentials in SwiftUI https://developer.apple.com/jp/videos/play/wwdc2020/10040/
Demystify SwiftUI https://developer.apple.com/jp/videos/play/wwdc2021/10022/
Demystify SwiftUI performance https://developer.apple.com/jp/videos/play/wwdc2023/10160

Discussion