WWDC Quick Look 💓 By SwiftGGTeam
Understand and eliminate hangs from your app

Understand and eliminate hangs from your app

元の動画を見る

ハイライト

Apple は、ハングアップを、アプリケーションのメインスレッドがユーザー イベントをタイムリーに処理できないときに発生する応答しない期間として定義し、そのようなハングアップを特定して排除するための Instruments、MetricKit、Xcode Organizer、および Dispatch プラクティスを提供しています。

主要内容

ユーザーがマンゴー タンゴ 1 杯のレシピをクリックすると、画面が応答しなくなります。数秒後にページが表示されます。ユーザーは、アプリが動かなくなり、遅くなり、クリックできないと言うでしょう。

Apple では、このエクスペリエンスを「このセッションでハングする」と呼んでいます。その本質は非常に特殊です。メインスレッドは前のものを処理しており、新しいタッチ イベントはキューに入れて待つことしかできません。

メインスレッドの Main Run Loop は、毎回イベントを受信して​​イベント処理を実行し、UI を更新します。イベント処理に時間がかかりすぎると、UI の更新が遅れます。ユーザーがクリックし続けると、イベントが蓄積され続けます。

開発者は、この種の問題を見つけるためにローカルでの再現に頼っていました。このアプローチでは、実際のユーザー デバイス、ネットワーク、ストレージの負荷、古いハードウェアをカバーすることはできません。 Apple が指定したパスは次のとおりです。Instruments を使用して開発中にコール スタックを記録し、MetricKit を使用してオンラインになった後の実際のハングを収集し、次に Xcode Organizer を使用してバージョン間のハング率を観察します。

解決の方向性も明確です。メインスレッドは、UI の更新に必要な重要な作業のみを実行します。画像のデコード、連絡先のクエリ、ネットワーク リクエスト、ファイル I/O、キャッシュの更新、およびメンテナンスのタスクは、削減するか、延期するか、バックグラウンド キューに移動する必要があります。

詳細

メイン実行ループのイベント処理でハングが発生する

(01:45) メイン実行ループは、メインスレッドでのユーザー イベントの処理を担当します。ループ内で、アプリケーションはイベントを受信し、処理ロジックを実行し、必要に応じて UI を更新します。処理ロジックは長く、ユーザー入力と UI 更新の間に遅延が生じます。

import UIKit

final class RecipeViewController: UIViewController {
    private let statusLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(statusLabel)
    }

    @objc func didTapRecipe() {
        statusLabel.text = "Loading"
        renderVisibleRecipeContent()
        statusLabel.text = "Ready"
    }

    private func renderVisibleRecipeContent() {
        // Only do the work needed for the first visible screen.
    }
}

キーポイント:

  • RecipeViewControllerこれは通常の UIKit ビュー コントローラーであり、すべての UI オブジェクトはメイン スレッドで作成および変更されます。
  • didTapRecipe()ユーザーのクリック イベントのハンドラー関数を表します。
  • statusLabel.text = "Loading"そしてstatusLabel.text = "Ready"これらはすべて UI の更新であり、メインスレッド上に存在する必要があります。
  • renderVisibleRecipeContent()スクロールせずに見える範囲に必要なコンテンツのみを生成する必要があります。セッション内の Desserted の例では、材料の 4 つの写真のみが表示され、メインスレッドがすべての写真を一度にロードする必要はありません。

メインスレッドがビジー状態です: ユーザーが事前に確認できないリソースをロードしています

(04:02) Desserted のレシピ ページには、材料の写真が 4 枚しか掲載されていません。メインスレッドがすべての材料画像を一度に読み取り、準備し、合成する場合、作業のほとんどは現在の画面を変更しませんが、実行ループを拡張します。

import UIKit

final class ImageTileStore {
    private let cache = NSCache<NSString, UIImage>()
    private let renderQueue = DispatchQueue(label: "com.example.recipe.render", qos: .userInitiated)

    func image(for identifier: String, makeImage: @escaping () -> UIImage, completion: @escaping (UIImage) -> Void) {
        let key = identifier as NSString

        if let image = cache.object(forKey: key) {
            completion(image)
            return
        }

        renderQueue.async { [cache] in
            let image = makeImage()
            cache.setObject(image, forKey: key)

            DispatchQueue.main.async {
                completion(image)
            }
        }
    }
}

キーポイント:

  • NSCache<NSString, UIImage>()繰り返し生成されるのを避けるために、生成された画像タイルを保存します。
  • renderQueueこれは、イメージの準備作業を実行するために使用されるバックグラウンドのディスパッチ キューです。
  • cache.object(forKey:)キャッシュがヒットした場合は直接戻り、メインスレッドはメモリ読み取りを 1 回だけ行います。
  • renderQueue.async時間がかかるmakeImage()メインスレッドから外されました。
  • DispatchQueue.main.async実行のためにメインスレッドに戻りますcompletion、呼び出し元が UI を安全に更新できるようにします。

メインスレッドのブロック: 同期 API、I/O、およびすべてのキュー イベントをロック

(06:53) 同期 API は、呼び出しの開始から戻りまで現在のスレッドをブロックします。ネットワーク要求、ファイル I/O、データ ストレージの読み取り、および同期プリミティブはすべて、メイン スレッドを妨害する可能性があります。セッションは、セマフォを使用して非同期 API を同期呼び出しにラップすることは、メインスレッドに表示されるのを避ける必要があることに特に注意してください。

import Foundation

let recipeQueue = DispatchQueue(label: "com.example.recipe.worker", qos: .userInitiated)

func loadRecipeSummary(completion: @escaping (String) -> Void) {
    recipeQueue.async {
        let summary = "Mango Tango takes 5 minutes."

        DispatchQueue.main.async {
            completion(summary)
        }
    }
}

キーポイント:

  • recipeQueue.asyncレシピ概要の準備をバックグラウンドキューで実行させます。
  • qos: .userInitiatedこの作業がユーザーの現在の操作に関連していることを示します。
  • summaryバックグラウンドで生成されるメインスレッドは、同期が戻るのを待ちません。
  • DispatchQueue.main.asyncHand UI はメインスレッドに更新されます。
  • このモードは、セッション内の提案に対応します。非同期 API を使用できる場合はそれを使用します。非同期 API がない場合は、Grand Central Dispatch (GCD、中央ディスパッチ) を使用して独自のコードを移動します。

CPU ビットマップの再描画を回避するために、コア アニメーションを使用して丸い角を処理します。

(05:55) デザート 画像に丸い角を追加する場合、元の解決策は、ビットマップ グラフィックス コンテキストを作成し、画像をビットマップに変換し、UIBezierPath を適用して、それを画像に変換して戻すことでした。このプロセスは CPU とメモリを消費します。 CoreAnimation のレイヤー機能を使用してセッションを GPU に渡して処理することをお勧めします。

import UIKit

func applyRoundedCorners(to imageView: UIImageView) {
    imageView.layer.cornerRadius = 12
    imageView.layer.masksToBounds = true
}

キーポイント:

  • imageView.layerUIKit ビューの背後にあるコア アニメーション レイヤーにアクセスします。
  • cornerRadius = 12コーナーの半径を設定します。
  • masksToBounds = true画像コンテンツを丸い角に切り取ってみましょう。
  • このコードは、メインスレッドでの CPU 負荷の高い画像変換を回避するために、ビットマップ グラフィックス コンテキストを作成しません。

開発中に Instruments を使用して、メインスレッドが何を行っているかを確認します。

(11:23) ハングを診断する場合、最初に行うべきことは、ハング中にアプリケーションが何をしていたかを知ることです。 Time Profiler は、時間の経過に伴う呼び出しスタックの変化を表示します。システム トレースは、システム コール、仮想メモリ ページ フォールト、I/O、プロセス内およびプロセス間の対話を補足します。

Instruments
  Template: Time Profiler + System Trace
  Target: Desserted
  Steps:
    1. Record
    2. Reproduce the tap that hangs
    3. Select the hang interval
    4. Inspect the main-thread call tree

キーポイント:

  • Time Profiler + System Traceセッションで同時に使用される 2 つのインストゥルメント ツールに対応します。
  • Reproduce the tap that hangsユーザーがトレースをクリックすることによって生じる遅延を記録します。
  • Select the hang intervalハングが発生する時間帯に注目してください。
  • Inspect the main-thread call tree対応するセッションで見つかりましたloadAllMessages4.6 秒かかるステップ。

MetricKit を使用して、オンラインになった後に実際のユーザーからハングを収集する

(13:02) アプリケーションがリリースされた後は、ネイティブ トレースだけでは十分ではありません。 MetricKit は、現場で発生したハング コール ツリーを収集できます。ハング中にサンプリングされたコール スタックをコール ツリーに集約し、開発者が実際のユーザーが遭遇する頻度に応じてコール スタックを並べ替えて修正できるようにします。

import MetricKit

final class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
    func start() {
        MXMetricManager.shared.add(self)
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            print(payload)
        }
    }
}

キーポイント:

  • MXMetricManagerSubscriberは MetricKit の加入者契約です。
  • MXMetricManager.shared.add(self)サブスクライバーを登録して、アプリが MetricKit ペイロードを受信できるようにします。
  • didReceive(_ payloads: [MXDiagnosticPayload])受信診断ペイロード、ハング診断は、このタイプのフィールド問題データに属します。
  • print(payload)これは最小限の例です。実際のアプリケーションでは、診断コンテンツを独自の分析システムに保存またはアップロードする必要があります。

クリックごとに高価なクエリを通知とキャッシュに置き換えます

(16:31) Desserted のソーシャル アイコンは、連絡先が友達かどうかを知る必要があります。ページに入るたびにすべての連絡先をクエリすると、フレームワークが負荷の高い操作を実行するまでメインスレッドが待機することになります。セッションは変更通知を監視し、通知が到着したときにキャッシュを非同期で更新することをお勧めします。

import Foundation

extension Notification.Name {
    static let friendsDidChange = Notification.Name("friendsDidChange")
}

final class FriendStatusStore {
    private let updateQueue = DispatchQueue(label: "com.example.friends.update", qos: .utility)
    private var cachedFriendIDs = Set<String>()

    func startObserving() {
        NotificationCenter.default.addObserver(
            forName: .friendsDidChange,
            object: nil,
            queue: nil
        ) { [weak self] _ in
            self?.refreshCache()
        }
    }

    private func refreshCache() {
        updateQueue.async { [weak self] in
            let latestIDs = Set(["contact-1", "contact-2"])
            self?.cachedFriendIDs = latestIDs
        }
    }
}

キーポイント:

  • Notification.Name監視可能な状態変更名を定義します。
  • NotificationCenter.default.addObserverオブジェクトの状態が変化したときにコールバックを受信できるようにオブザーバーを登録します。
  • refreshCache()コールバックで直接重い作業を行うことを避け、代わりに更新をディスパッチします。updateQueue
  • cachedFriendIDsクエリ結果を保存すると、ページに入るときにキャッシュのステータスを読み取ることができます。
  • この構造は、セッションでの提案に対応しています。値が頻繁に変更されない場合は、ユーザーが操作するたびに負荷の高いクエリを再実行しないでください。

GCD を使用して計算をウォームアップしますが、タスクの順序に注意してください

(20:28) GCD は計算をウォームアップすることもできます。アプリケーションはタスクをディスパッチしますprefetchQueue、メインスレッドはユーザーに応答し続けます。実際に結果が必要な場合は、ウォームアップ キューが完了するまで待ちます。また、セッションでは、非同期によりコードの実行順序が変更されること、およびどのタスクに逐次的な依存関係があるかを確認する必要があることも思い出させます。

import Foundation

let prefetchQueue = DispatchQueue(label: "com.example.recipe.prefetch", qos: .utility)

func prefetchIngredients() {
    prefetchQueue.async {
        _ = ["mango", "yogurt", "ice"]
    }
}

func usePrefetchedIngredients(completion: @escaping ([String]) -> Void) {
    prefetchQueue.async {
        let ingredients = ["mango", "yogurt", "ice"]

        DispatchQueue.main.async {
            completion(ingredients)
        }
    }
}

キーポイント:

  • prefetchQueueウォームアップタスクの実行順序を維持するのに適したシリアルキューです。
  • prefetchIngredients()メインスレッドをブロックせずに、事前にバックグラウンド作業を開始します。
  • usePrefetchedIngredients予熱タスクの順序が狂わないように、同じキュー内の結果を読み取ります。
  • DispatchQueue.main.async最終的な UI 更新のみをメインスレッドに戻します。

重要ポイント

  1. やるべきこと: 最初の画面の優先レンダリングと背景のウォームアップを画像リストに追加します。 実行する価値がある理由: セッション レシピの例は、目に見えない画像を事前に処理するとハングが発生することを示しています。 開始方法: を使用しますNSCache生成された画像を保存するには、次を使用します。DispatchQueue.async次の画面の写真をバックグラウンドで準備する場合のみ、DispatchQueue.main.async中程度の設定UIImageView.image

  2. やるべきこと: 連絡先、権限、サブスクリプションステータスなどの、低頻度で変更されるデータ用のキャッシュを作成します。 実行する価値がある理由: クリックするたびにシステム リソースをクエリすると、メインスレッドが制御不能な遅延にさらされることになります。 開始方法: を使用しますNotificationCenter.default.addObserverステータスの変化を監視し、バックグラウンド キュー内のキャッシュを更新し、ページに入ったときにキャッシュを読み取ります。

  3. やるべきこと: ハング診断チャネルを既存のアプリに追加します。 実行する価値がある理由: MetricKit は、実際のユーザーが遭遇したハングをコール ツリーに集約し、最も大きな影響を与えるラグを最初に修正するのに役立ちます。 開始方法: 実装MXMetricManagerSubscriber、合格MXMetricManager.shared.add購読者として登録して、MXDiagnosticPayloadロギング システムに書き込みます。

  4. 対処方法: 同期ネットワーク、ファイルの読み取り、およびデータの解析をメイン スレッドの外に移動します。 実行する価値がある理由: セッションは、同期 API が呼び出しから復帰までをブロックし、ネットワークと I/O の遅延もデバイス環境の影響を受けることを指摘しています。 開始方法: まず、完了ハンドラーを備えた非同期 API を探します。独自のコードを使用するDispatchQueue.async実行し、最後にメインスレッドに戻って UI を更新します。

  5. やるべきこと: エントリ ページのパフォーマンス回帰チェックを作成します。 実行する価値がある理由: Xcode Organizer はバージョンごとにハング レートを表示できるため、新しいバージョンによってもたらされたパフォーマンスの低下を発見するのに適しています。 開始方法: 主要なページのインストゥルメント トレースを記録し、時間のかかるメイン スレッドのベースラインを記録します。リリース後にオーガナイザーで新しいバージョンのハング率を観察します。

関連セッション

コメント

GitHub Issues · utterances