ドラッグ中のプレビューと確定済みのキャンバス図形を分離して、矩形を素早く描いても滑らかに動作するようにします。Macの描画アプリでは、入力を1つのジェスチャー面で受け取り、一時的なジオメトリをローカルに保持し、ドラッグが正常に終了したときだけ図形コレクションに追加します。

このガイドは、自分のmacOSアプリでシンプルなクリック&ドラッグ式の矩形ツールを作る開発者を対象としています。例の図形はすべてメモリ上に保持されます。ポインターの移動によって描画モデル全体が何度も変更されるのを防ぎ、その後、プロファイリングで残るレンダリングコストを特定することが目的です。

確定済みの図形が増えると、なぜ描画が遅くなるのでしょうか?

まず、入力の受け取り、状態の更新、ジオメトリのレンダリングという3つの処理を区別します。ドラッグ中は一時的なジオメトリが頻繁に変わることがあります。その変更によって確定済みのコレクションまで置き換えたり、すべてのパスを再構築したり、広範囲のビュー階層を無効化したりすると、移動ごとに発生する処理量が描画の進行とともに増える可能性があります。

Appleによると、SwiftUIは依存関係を追跡し、それらの依存関係が変わるとビューを更新します。個々のビュー更新が遅い場合も、不要な更新が大量に発生する場合も、フレームの期限を逃す原因になり得ます。頻繁に変化するプレビュー状態を確定済みの内容から分離するのは、文書化されたこのモデルに基づく設計上の適用例です。InstrumentsでSwiftUIのパフォーマンスを最適化する

Canvasはグラフィックスコンテキストを使った即時モード描画を提供します。Appleは、複雑で動的な描画のパフォーマンス改善に役立つ可能性がある一方、描画要素ごとの操作やアクセシビリティは提供しないと説明しています。入力処理は意図を明確にした描画面に割り当てます。後で個々の図形を操作できるようにする必要が生じた場合は、選択機能とアクセシビリティ対応を別途実装してください。Canvas

したがって、責務の境界を明確にします。入力面がジェスチャーを受け持ち、一時状態がプレビューを保持し、親ビューが確定済みの矩形を管理します。これらの責務を分けることで、避けられる依存関係を減らせます。ただし、確定済みのキャンバスがシステムによって再描画されなくなる保証はありません。

プレビューと確定済みの描画を分離する

レンダリングの最適化を選ぶ前に、次の所有関係を決めます。

責務 所有者 変更されるタイミング
現在ドラッグ中の矩形 ジェスチャー面 ドラッグ中
確定済みの矩形コレクション 親の描画ビュー ドラッグが正常に終了したときに1回
確定済みの矩形の描画 背景のキャンバス 入力に応じて更新が必要なとき
ポインターの認識 ジェスチャー面 ジェスチャーの一連の動作を通じて

一時的なジオメトリには@GestureStateが便利です。ジェスチャーが非アクティブになるとSwiftUIがリセットするためです。onEndedを通じて確定します。このコールバックには最終的なジェスチャー値が渡されます。Appleの仕様では、このコールバックはジェスチャーが正常に終了した場合にのみ実行されます。ジェスチャーの終了方法にかかわらず、ジェスチャー状態はクリーンアップを担います。GestureState、onEnded(_:)

矩形の最終的なジオメトリに必要なのは、開始位置と現在位置だけです。ポインターの中間位置をすべて保持する必要はありません。Appleはこれらの位置と予測終点を区別して記載しています。矩形を確定するときは、実際の最終位置を使ってください。DragGesture.Value

メモリ内で完結する矩形ツールを作る

次の2つのコードブロックを合わせると、Swiftソースファイル1つ分の完成例になります。macOS 12以降を対象にしたmacOS SwiftUI Appプロジェクトで、生成されたアプリのエントリーファイルを両方のブロックで置き換え、ほかにアプリのエントリー宣言があれば削除してください。CanvasはmacOS 12以降で利用できます。この例は確認しましたが、環境にSwiftツールチェーンがないため、ここではコンパイルできませんでした。Canvas

最初のブロックでは、アプリ、確定済みの矩形コレクション、そのレンダラーを定義します。親が受け取るのは完成した矩形だけで、ドラッグ中の状態は参照しません。

import SwiftUI

@main
struct RectangleDrawingApp: App {
    var body: some Scene {
        WindowGroup {
            DrawingBoard()
        }
    }
}

struct DrawingBoard: View {
    @State private var rectangles: [CGRect] = []

    var body: some View {
        VStack(spacing: 8) {
            Text("Drag to draw a rectangle")
            Text("Committed rectangles: \(rectangles.count)")

            ZStack {
                Color.white

                CommittedRectangles(rectangles: rectangles)
                    .allowsHitTesting(false)

                RectangleGestureSurface { rectangle in
                    rectangles.append(rectangle)
                }
            }
            .clipped()
            .frame(minWidth: 480, minHeight: 320)
        }
        .padding()
    }
}

struct CommittedRectangles: View {
    let rectangles: [CGRect]

    var body: some View {
        Canvas { context, _ in
            for rectangle in rectangles {
                context.stroke(
                    Path(rectangle),
                    with: .color(.blue),
                    lineWidth: 2
                )
            }
        }
    }
}

次のブロックを同じファイルに追加します。透明な面が描画領域を覆い、ヒットテストの形状を明示的な矩形にし、描画ジェスチャーを1つだけ受け持ちます。Appleの仕様では、contentShapeはヒットテストの形状を定義し、allowsHitTesting(false)はビューをヒットテストの対象から除外します。contentShape(_:eoFill:)、allowsHitTesting(_:)

struct RectangleGestureSurface: View {
    let commit: (CGRect) -> Void
    @GestureState private var preview: CGRect? = nil

    var body: some View {
        ZStack {
            Color.clear

            Canvas { context, _ in
                if let rectangle = preview {
                    context.stroke(
                        Path(rectangle),
                        with: .color(.orange),
                        lineWidth: 2
                    )
                }
            }
            .allowsHitTesting(false)
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0, coordinateSpace: .local)
                .updating($preview) { value, state, _ in
                    state = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                }
                .onEnded { value in
                    let rectangle = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                    commit(rectangle)
                }
        )
    }

    private static func rectangle(
        from start: CGPoint,
        to end: CGPoint
    ) -> CGRect {
        CGRect(
            x: min(start.x, end.x),
            y: min(start.y, end.y),
            width: abs(end.x - start.x),
            height: abs(end.y - start.y)
        )
    }
}

minimumDistance: 0にすると、認識に必要な最小移動距離がなくなります。ローカル座標空間を使うことで、ジェスチャーの位置と、同じ大きさのプレビューキャンバスの位置が一致します。これらは文書化されたジェスチャー設定の選択肢であり、イベントの発生頻度や表示遅延を保証するものではありません。DragGesture

矩形ヘルパーは、ドラッグの4方向すべてを正規化します。例として、(90, 80)から(30, 20)までドラッグすると、原点は(30, 20)、サイズは(60, 60)になります。プレビューと確定処理で同じ計算を使うため、ポインターを離してもジオメトリの計算式は変わりません。

この最小ツールでは、幅または高さが0の矩形も確定します。そのため、クリックだけでも矩形が見えないままカウントが増えることがあります。製品として最小サイズが必要なら、確定時にそのルールを明示的に追加し、適切なフィードバックをユーザーに返してください。この例では、小さすぎる結果を黙って除外せず、正常に完了したすべてのジェスチャーを保持します。

表示領域はクリップされますが、保存されたジオメトリは変わりません。そのため、ボードからはみ出した矩形にはボード外の座標が含まれる場合があります。座標を制限する前に、将来ボードのサイズを変更したときにそのジオメトリを表示すべきかどうかを決めてください。

キャッシュを追加する前に、残っているコストを調べる

この例では、ドラッグ中の更新処理から確定済みモデルの変更を取り除いています。ただし、確定済みの図形を描画するビューは、実行されるたびにコレクションを走査します。状態伝播の削減と描画処理の削減は区別してください。ビューを分けても、永続的な画像キャッシュができるわけではありません。

残っているコストを特定するには、条件をそろえて比較します。例として、メモリ内の矩形数を0、500、5,000とします。矩形のサイズ、ウィンドウサイズ、ディスプレイのスケール、ビルド設定は一定に保ってください。これらの数値はテスト入力であり、容量の上限や測定結果を示すものではありません。

3つの区間を分けて記録します。コレクションを変更せずにドラッグしている間、確定のためにポインターを離すとき、ウィンドウのサイズ変更時です。ドラッグ中に遅くなる場合と、確定時にだけ一時停止する場合では、原因となる処理が異なる可能性があります。サイズ変更時の挙動からは、通常の描画では表面化しないジオメトリやキャッシュの再構築が見つかることがあります。

AppleのSwiftUIプロファイリングセッションでは、Time ProfilerとともにLong View Body Updates、Long Representable Updates、Cause & Effect Graphの各ツールが紹介されています。依存関係グラフを使って、ドラッグ中に確定済みビューが更新される理由を調べます。CPUサンプルを使って、ジオメトリの構築やレンダリングの負荷を特定します。AppleはInstruments 26でこの計測ワークフローを導入しました。InstrumentsでSwiftUIのパフォーマンスを最適化する

より広範な停止については、AppleのWWDC26の応答性に関するセッションで、まずTime Profilerを使い、CPUが処理中の時間と待機中の時間を区別しています。処理を別の実行器に移したり、レンダリング基盤を置き換えたりする前に、この違いを見極めてください。プロファイル、修正、検証:Instrumentsでアプリの応答性を改善する

次の判断ガイドは、これらのプロファイリングツールと上記の所有関係の設計から導いた、エンジニアリング上の推論です。

観察された挙動 まず調べること 変更候補
ポインターの移動中に親の描画状態が変わる ジェスチャーの更新コールバックと共有状態 プレビュー状態をジェスチャー面の内部に置く
移動中のサンプルで確定済みの描画が大半を占める 繰り返しの走査とパスの作成 ジオメトリを事前計算するか、確定済みコンテンツ用のキャッシュを導入する
コレクションが増えるほど、ポインターを離したときの一時停止が長くなる 配列の変更と描画全体の再構築 保持するスナップショットを減らし、影響を受ける派生コンテンツだけを再構築する
矩形の描画は滑らかだが、長いフリーハンドの線は遅い アクティブなパスのサイズと再構築 増え続けるアクティブな線を個別にプロファイリングする
主にサイズ変更中に一時停止する サイズ依存の描画とキャッシュの無効化 派生コンテンツのサイズ変更時の挙動を明確に定める

キャッシュを導入すると、所有関係がもう1つ加わります。矩形のジオメトリを正とし、キャッシュは置き換え可能な派生コンテンツとして扱います。無効化の設計には、描画サイズ、ディスプレイのスケール、関連するスタイルを含めてください。描画が追加のみなら、キャッシュを差分更新する方法が最適化候補になります。編集や削除を扱う場合は、全体再構築か領域単位の無効化かを明確に決める必要があります。複雑さを増やす前に、同じ入力数で結果を検証してください。

確定時に配列がコピーされるかを確認する

Swiftの配列は値セマンティクスを持ち、コピーオンライト方式のストレージを使います。変更によって独立したストレージが必要になるまでは、複製した配列間でストレージを共有できます。クラスのインスタンスを含む配列では、それらのインスタンスへの参照が引き続き共有されます。ビューに配列を渡すことも配列に要素を追加することも、要素が毎回すべてコピーされると説明すべきではありません。Array

このツールでは、ポインターの更新ごとに新しい描画全体のスナップショットを保存しないようにします。プロファイリングで確定時のコピーが見つかった場合は、どのスナップショットが生存しているか、描画モデルがどのように共有されているかを調べてください。コピーを避けるためだけに値型を共有可変オブジェクトに置き換えると、所有関係の意味が変わるため、別途設計上の判断が必要です。

非同期表示は測定したうえで選択する

Appleの仕様では、rendersAsynchronouslyを指定すると、キャンバスは内容を親に非同期で表示できます。この説明は、ジェスチャーの入力処理が速くなること、ジェスチャーハンドラーがバックグラウンド実行器に移ること、コレクションの走査がなくなることを保証しません。入力と状態の境界を明確にした後で、個別に評価してください。rendersAsynchronously

いつAppKitに描画面を任せるべきでしょうか?

レスポンダーメソッドを直接処理する必要がある場合や、描画領域を明示的に無効化する必要がある場合は、カスタムAppKit面を使います。Appleによると、NSViewは描画とイベント処理の基盤であり、カスタム動作のためにサブクラス化できます。これはサポートされている選択肢ですが、描画に関する責務は独自に担うことになります。NSView

AppKitで実装する場合も、同じ順序を保ちます。mouseDown(with:)で一時的なジオメトリを初期化し、mouseDragged(with:)で更新し、mouseUp(with:)で確定します。ジオメトリを計算する前に、event.locationInWindowをイベントを受け取るビューの座標に変換してください。Appleのアーカイブ版マウスイベントガイドには、この順序と座標変換が記載されています。現在のNSViewリファレンスには、サポート対象のレスポンダーメソッドが記載されています。マウスイベントの処理、NSView

needsDisplay = trueを設定して再描画を要求します。Appleの仕様では、そのビューは次のイベントループの反復で再描画されます。領域単位で無効化する設計では、プレビューの古い境界と新しい境界を結合し、ストローク幅の分だけ拡張した領域を無効化します。描画の実装が影響を受けない内容の処理も避けない限り、領域単位の無効化によって走査量が減るわけではありません。needsDisplay、マウスイベントの処理

NSViewRepresentableでこの面を埋め込みます。makeNSViewで作成し、updateNSViewで外部状態と整合させ、コーディネーターを使って変更をSwiftUIに伝えます。更新時にコールバックのルーティングを更新し、SwiftUIから別のrepresentable値が渡されたというだけで、進行中の下書きをリセットしないようにします。またAppleは、表現対象のビューのレイアウトをSwiftUIが制御すると述べています。管理対象のフレームや境界を直接上書きしないでください。NSViewRepresentable

描画入力を受け持つのは1つの側だけにします。AppKitが描画の一連の処理を扱う場合は、重複するSwiftUIのドラッグジェスチャーからも同じ処理を確定しないようにしてください。直接NSViewをサブクラス化する場合、Appleは、指定されたマウスイベントを処理する実装では、処理対象のイベントについてスーパークラスの実装を呼び出さないよう指示しています。NSView

最初の改善は小さく、効果を観察できるものにします。プレビュー状態をローカルに保持し、確定の境界を1つにし、移動中は入力が変わらない確定済みレンダラーを使います。その後、増加する合成描画をInstrumentsで比較します。計測結果から、次の改善を依存関係の管理、コレクションの所有関係、ジオメトリの事前準備、レンダリングのどこに加えるべきかを判断できます。

参考資料