將作用中的拖曳預覽與已提交的畫布形狀分開,讓快速繪製矩形保持流暢。在 Mac 繪圖 App 中,讓單一手勢表面負責接收輸入,將暫時幾何資料保留在本地,並且只在拖曳成功結束時將形狀加入集合。

本指南適用於在自己的 macOS App 中建置簡易點擊並拖曳矩形工具的開發者。所有範例形狀都儲存在記憶體中。目標是避免指標移動反覆變更整個繪圖模型,接著再透過效能分析找出仍然存在的任何繪製成本。

為什麼已提交的形狀越多,繪圖就越慢?

首先區分三種操作:接收輸入、更新狀態,以及繪製幾何圖形。拖曳可能會頻繁變更暫時幾何資料。如果這些變更也會取代已提交的集合、重建每條路徑,或使廣泛的檢視階層失效,那麼每次移動所涉及的工作量就可能隨著繪圖內容增加。

Apple 說明,SwiftUI 會追蹤相依性,並在相依性變更時更新檢視。個別檢視更新緩慢,以及大量不必要的更新,都可能導致錯過影格期限。將頻繁變更的預覽狀態與已提交內容分開,是依據此文件所述模型採用的架構設計。使用 Instruments 最佳化 SwiftUI 效能

Canvas 透過圖形內容提供即時模式繪圖。Apple 指出,對於複雜的動態繪圖,這可能改善效能;但它不會為每個繪製元素提供互動功能或輔助使用支援。將輸入處理附加至明確指定的繪圖表面;如果 App 日後需要個別操作各形狀,請另外實作選取與輔助使用支援。Canvas

因此,應明確劃分責任:輸入表面負責手勢,暫時狀態負責預覽,父檢視負責已提交的矩形。拆分這些責任可減少不必要的相依性,但無法保證系統永遠不會重新繪製已提交的畫布。

將預覽與已提交的繪圖分開

在選擇繪製最佳化方式前,先採用以下所有權規劃:

責任 所有者 變更時機
目前拖曳中的矩形 手勢表面 拖曳進行期間
已提交的矩形集合 父繪圖檢視 每次成功拖曳一次
繪製已提交的矩形 背景畫布 輸入內容需要更新時
指標辨識 手勢表面 整個手勢序列期間

對於暫時幾何資料,@GestureState 很實用,因為手勢變為非作用中時,SwiftUI 會重設它。透過 onEnded 提交,其回呼會收到最終手勢值。Apple 說明,只有手勢成功結束時才會執行此回呼;無論手勢如何結束,手勢狀態都會負責清理。GestureState、onEnded(_:)

對矩形而言,最終幾何形狀只需要起始位置和目前位置,無須保留每個中間指標位置。Apple 將這些位置與預測端點分開說明;提交矩形時,請使用實際的最終位置。DragGesture.Value

建置完整的記憶體內矩形工具

以下兩段程式碼組成一個完整的 Swift 原始碼檔案。在目標為 macOS 12 或更新版本的 macOS SwiftUI App 專案中,使用這兩段程式碼取代產生的 App 進入點檔案,並移除其他 App 進入點宣告。Canvas 從 macOS 12 起提供。此範例已完成檢視,但此處的環境沒有 Swift 工具鏈,因此無法編譯。Canvas

第一段程式碼定義 App、已提交的集合及其繪製器。父檢視只會收到已完成的矩形,不會讀取作用中的拖曳狀態。

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
                )
            }
        }
    }
}

將以下程式碼附加至同一個檔案。透明表面會覆蓋繪圖區域,為命中測試指定明確的矩形形狀,並負責唯一的繪圖手勢。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

矩形輔助函式會將四種拖曳方向正規化。舉例來說,從 (90, 80) 拖曳至 (30, 20),會產生原點 (30, 20) 和尺寸 (60, 60)。預覽與提交使用相同的計算方式,因此放開指標不會改變幾何公式。

這個精簡工具也會提交寬度或高度為零的矩形。因此,點擊可能會增加計數,但不會產生可見矩形。如果產品需要最小尺寸,請在提交時明確加入該規則,並向使用者提供適當回饋。此範例會保留每個成功完成的手勢,不會默默篩除較小的結果。

可見區域會受到裁切,但儲存的幾何資料保持不變。因此,延伸至畫板外的矩形可能包含畫板外的座標。在加入座標限制之前,請先決定日後調整大小時是否應顯示這些幾何資料。

新增快取前,先診斷剩餘成本

此範例將已提交模型的變更從拖曳更新路徑中移除。只要已提交內容的繪製器執行,仍會逐一遍歷集合。請區分減少狀態傳播與減少繪製工作:獨立的檢視並非文件所述的持續性影像快取。

透過受控比較找出剩餘成本。可選擇 0、500 和 5,000 個矩形等記憶體內範例數量。保持矩形尺寸、視窗大小、顯示器縮放比例和建置設定一致。這些數量是測試輸入,不代表容量限制或實測結果。

分別記錄三個時段:集合保持不變時進行拖曳、放開以提交,以及調整視窗大小。持續拖曳時變慢,與只在提交時發生停頓所代表的工作不同。調整大小時的行為,可能揭露一般繪圖時不會出現的幾何資料或快取重建成本。

Apple 的 SwiftUI 效能分析課程介紹了 Long View Body Updates、Long Representable Updates 和 Cause & Effect Graph 工具,以及 Time Profiler。使用相依性圖檢查為什麼已提交的檢視會在拖曳期間更新;使用 CPU 樣本找出耗時的幾何建構或繪製工作。Apple 在 Instruments 26 中推出了這套效能分析工具流程。使用 Instruments 最佳化 SwiftUI 效能

若有更廣泛的卡頓,Apple 的 WWDC26 反應速度課程會從 Time Profiler 著手,並區分忙碌執行與等待時間。將工作移至其他執行器,或更換繪製堆疊之前,先運用這項區分。分析、修正並驗證:使用 Instruments 改善 App 反應速度

以下決策輔助表是根據上述效能分析工具和所有權設計所做的工程推論:

觀察到的行為 優先調查項目 可考慮的變更
指標移動時,父繪圖狀態會變更 手勢更新回呼和共享狀態 將預覽狀態保留在手勢表面內
移動期間的樣本主要來自已提交的繪圖 重複遍歷和建立路徑 預先計算幾何資料,或引入已提交內容快取
集合增加時,放開後的停頓也變長 陣列變更和完整繪圖重建 減少保留的快照,僅重建受影響的衍生內容
矩形繪製流暢,但長筆跡較慢 作用中路徑大小和重建 單獨分析持續增長的作用中筆跡
停頓主要發生在調整大小時 依尺寸變動的繪製和快取失效 明確定義衍生內容的調整大小行為

快取會增加另一個所有權邊界:矩形幾何資料仍是權威來源,快取則是可替換的衍生內容。在失效設計中納入繪圖尺寸、顯示器縮放比例和相關樣式。對於只能新增內容的繪圖,增量快取更新是可考慮的最佳化方式;編輯與移除則需要明確的重建策略或區域失效策略。在採用這些複雜度之前,請使用相同的輸入數量驗證結果。

檢查提交邊界的陣列複製

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 會控制所呈現檢視的版面配置;不要直接覆寫由它管理的 frame 或 bounds。NSViewRepresentable

為繪圖輸入指定單一負責者。如果由 AppKit 處理繪圖流程,就不要再透過重疊的 SwiftUI 拖曳手勢提交同一流程。對於直接建立的 NSView 子類別,Apple 指示,實作若處理列出的滑鼠事件,就不應針對已處理的事件呼叫超類別實作。NSView

先採取小幅且可觀察的改進:本地預覽狀態、單一提交邊界,以及移動期間輸入保持不變的已提交內容繪製器。接著,使用 Instruments 比較逐漸增多的合成繪圖。產生的追蹤記錄會指出下一項有效變更應著重於相依性管理、集合所有權、幾何資料準備,或繪製。

來源