🧪

Instrumentsを使用したアプリのパフォーマンス向上方法〜Instruments Tutorials〜

に公開

Nagoya.swiftで登壇した内容をさらに詳しくして記事にしました。
スライドとLT動画もよかったらみてください!
https://speakerdeck.com/hinakko/instrumentswoshi-yong-sita-apurinopahuomansuxiang-shang-fang-fa

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

Instrumentsとは

Instrumentsとはアプリのパフォーマンス、リソース使用量、動作を分析するツールです。Instrumentsを活用することで、応答性を向上させ、メモリ使用量を削減し、複雑な動作を時間の経過とともに分析することができます。ハングアップの解消に使用できます。

ハングアップとは

ユーザーインタラクションの処理に顕著な遅延が発生する場合、応答のない状態をハングアップと呼ぶ。
もっと具体的にいうと、ユーザーがボタンを押したのに画面が切り替わらない。そうするとユーザーはボタンが押せているのか不安に思ってしまう。このような状態をハングアップと言います。
理想的にはメインスレッドでの作業は100ミリ秒以上中断されずに実行する必要がある。
InstrumentsはTime Profilerでは250ミリ秒を超えるハングが警告として検出される。

Instruments Tutorials

この記事はInstruments TutorialsのサンプルアプリやWWDCの動画をもとに書いています。Instruments TutorialsとWWDCの動画を並行して読み進めることでInstrumentsの使用方法の理解が深まりました。

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

ハングの種類

ハングの種類はメインスレッドがビジー状態メインスレッドがブロック状態大きく2つに分けることができます。

メインスレッドがビジー状態であるということは、アプリが常にデータを処理しており、CPU 使用率が高いことを意味します。

メインスレッドがブロック状態は、メインスレッドでの作業が停止し、代わりに他のスレッドにCPUアクセスを与えている状態
Instrumentsを使用し、ハング中のメインスレッドのCPU使用率を解析することで、メインスレッドがビジー状態かブロック状態かを判断することができます。

ハングの例

Instruments Tutorialsのサンプルコードでハングの例をみてみましょう!
Listの状態のTabBarを切り替えるためにCollectionのTabBarを押します。しかし、下記の動画のようにボタンを押してから約5秒しないとCollectionの画面に切り替わらないというハングが起こっています。

ハングの解析方法

まず、InstrumentsのTime Profilerを使用しましょう!
XcodeのProduct > Profile または、ショートカット:command + I
でテンプレート選択ウィンドウを表示し、Time Profilerを選択する。

ハングの特定

下記の画像の左上の赤色の録画ボタンを押し、シミレーターでハングを再現し、録画停止ボタンを押す。
下記の画像では5.63sのハングが起こっていること・CPU使用率が高いことからメインスレッドがビジー状態であることがわかります。

メインスレッドの分析

Call Tree > ✅Hide System Libraries
システムライブラリを非表示にし、Xcodeのコードの該当箇所を分析しやすくする。メソッドやXcode上のコードのみを表示する。


ThumbnailViewのボディゲッターの呼び出しにCPU時間の100%費やされていることがわかる。
これがハングの原因である。

ハングを修正

実行頻度と継続時間を測定することで改善方法を考える。
ハングの解消方法として下記の2つがある。
①処理量を減らす
②バックグラウンドスレッドに処理を移す

実行頻度は先ほど使用したTime Profilerでわからないので下記の画像の+ Instrumentボタンを押し、View Body Instrumentsを追加。Viewのゲッターの実行頻度を調べる。

View Bodyを追加した後、録画ボタンを押し、シミレーターで再度ハングを再現する。


ThumbnailViewのbodyの実行に1つの画像あたり43ms、合計129回画像が呼ばれており、5.6sかかっている。
現在のレイアウトでiPhoneの画面に収まるサムネイルは30~40個程度であるにもかかわらず、129回のbody実行は多すぎる。

ハングを修正: ①処理量を減らす

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) {
                        // ✅HStack → LazyHStackに変更
                        LazyHStack {
                            ForEach(images) { imageFile in
                                ThumbnailView(imageFile: imageFile)
                            }
                        }
                    }
                }
            }

HStackからLazyHStackに書き換えます。
HStackを使用しているときはHang 5.69s

LazyHStackを使用しているときはHang 2.03s

LazyHStack画面に見えている画像だけ読み込むのでThumbnailViewの呼び出しが129回から38回になっている。

ハングを修正: ②バックグラウンドスレッドに処理を移す

makeThumbnail(displayScale:)を同期関数からmakeThumbnail(displayScale:) async非同期関数にする。

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)
    }
    // ✅makeThumbnail(displayScale:)を同期関数から非同期関数にする
    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) {
          ✅非同期関数にしたのでawaitをつける
                guard let thumbnail = await imageFile.makeThumbnail(displayScale: displayScale) else {
                    loadedThumbnail = Image(systemName: "x.square")
                    return
                }
                loadedThumbnail = Image(uiImage: thumbnail)
            }
    }

.task修飾子のクロージャでラップされているにも関わらず、makeThumbnail(displayScale:)のような同期関数の場合はメインアクターで実行されるのはなぜか?
コードのこの部分がメインアクターで実行される理由は2つあります。1つ目はSwiftUIのビューのbodyプロパティは、Viewプロトコルによって暗黙的にメインアクターにバインドされています。そして第2つ目は.task修飾子のクロージャは、init(priority:operation:)と同じで、周囲のコンテキストからアクターコンテキストを継承します。bodyプロパティがメインアクターにバインドされているため、.task()に渡されるクロージャもメインアクターにバインドされる。

※Task.init(priority:operation:)によって作成されたタスクは、呼び出し元の優先順位とアクターコンテキストを継承します。
https://developer.apple.com/documentation/swift/task/init(priority:operation:)-7f0zv

メインアクターから切り離して実行できるようにするため、makeThumbnail(displayScale:) async非同期関数にする。非同期関数自体はどのアクターにも束縛されません。task修飾子によって生成された非同期タスクの中で呼び出されるため、バックグラウンドのスレッドプールで実行される。
逆に、同期関数は呼び出された場所で実行され、.task修飾子のクロージャはメインアクターになってしまっていた。
対照的に、非同期関数は、メインスレッドではなく、Swiftの並行実行ランタイム (並行スレッドプール) によって管理されるいくつかのワーカースレッドの 1 つで実行されます。
メインスレッドではなく、Swift concurrencyランタイム (concurrentスレッドプール) によって管理されるいくつかのワーカースレッドの1つで実行されます。このセクションでは、makeThumbnail(displayScale:) 関数を非同期にし、メインアクタではなく、主にバックグラウンドのconcurrentスレッドプールで実行するようにします。

makeThumbnail(displayScale:)を同期関数: 実行時間 1.69s

makeThumbnail(displayScale:) async非同期関数: 実行時間 412ms

非同期関数にすることで1.69sから412msに実行時間を短縮でき、Instruments上のハングも解消される。

参考文献

Instruments Tutorial https://developer.apple.com/tutorials/instruments
Analyze hangs with Instruments https://developer.apple.com/videos/play/wwdc2023/10248/
SwiftUIのご紹介 https://developer.apple.com/jp/videos/play/wwdc2020/10119/
SwiftUIの基本 https://developer.apple.com/jp/videos/play/wwdc2024/10150/
Swift UIにおけるデータの重要事項 https://developer.apple.com/jp/videos/play/wwdc2020/10040/
SwiftUIの徹底解説 https://developer.apple.com/jp/videos/play/wwdc2021/10022/
SwiftUIのパフォーマンスを解明 https://developer.apple.com/jp/videos/play/wwdc2023/10160

Discussion