将活动拖动预览与已提交的画布形状分开,让快速绘制矩形始终流畅响应。在 Mac 绘图应用中,让一个手势表面负责接收输入,将临时几何数据保存在局部,并且只在拖动成功结束时将形状添加到集合中。

本指南面向正在自己的 macOS 应用中构建简单点击并拖动矩形工具的开发者。所有示例形状都保存在内存中。目标是避免指针移动反复修改整个绘图模型,然后通过性能分析找出仍然存在的渲染开销。

为什么已提交的形状不断累积时绘图会变慢?

首先区分三种操作:接收输入、更新状态和渲染几何图形。拖动可能会频繁改变临时几何数据。如果这些变化还会替换已提交的集合、重建每条路径,或使宽泛的视图层级失效,那么每次移动所需的工作量可能会随着绘图内容增加。

Apple 说明,SwiftUI 会跟踪依赖关系,并在依赖项发生变化时更新视图。单次视图更新缓慢,以及大量不必要的更新,都可能导致错过帧截止时间。将频繁变化的预览状态与已提交内容分开,是基于这一已记录模型采用的架构设计。使用 Instruments 优化 SwiftUI 性能

Canvas 通过图形上下文提供即时模式绘图。Apple 指出,对于复杂的动态绘图,它可能有助于提升性能,但不会为每个已绘制元素提供交互或辅助功能。将输入处理附加到明确的绘图表面;如果应用之后需要单独操作各个形状,再实现独立的选择和辅助功能支持。Canvas

因此,职责边界应当明确:输入表面负责手势,临时状态负责预览,父视图负责已提交的矩形。拆分这些职责可以减少可避免的依赖关系,但不能保证系统永远不会重绘已提交的画布。

将预览与已提交的绘图分开

选择渲染优化方案之前,先确定以下所有权安排:

职责 所有者 变化时机
当前拖动的矩形 手势表面 拖动期间
已提交的矩形集合 父绘图视图 每次成功拖动一次
绘制已提交的矩形 背景画布 其输入需要更新时
识别指针 手势表面 整个手势序列期间

对于临时几何数据,@GestureState 很有用,因为手势变为非活动状态时,SwiftUI 会重置它。通过 onEnded 提交数据,其回调会接收最终手势值。Apple 指明,只有手势成功结束时才会运行此回调;无论手势如何结束,手势状态都会负责清理。GestureState、onEnded(_:)

矩形的最终几何形状只需要起点和当前位置,无需保留每个中间指针位置。Apple 将这些位置与预测终点分别记录;提交矩形时应使用实际的最终位置。DragGesture.Value

构建完整的内存矩形工具

以下两个代码块组成一个完整的 Swift 源文件。在目标平台为 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
                )
            }
        }
    }
}

将以下代码块追加到同一文件中。透明表面覆盖绘图区域,为命中测试提供明确的矩形形状,并独自负责绘图手势。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 提升应用响应能力

以下决策参考是根据上述性能分析工具和所有权设计得出的工程推论:

观察到的表现 优先调查 可考虑的改动
指针移动期间父绘图状态发生变化 手势更新回调和共享状态 将预览状态保留在手势表面内
移动期间的采样主要耗费在已提交的绘图上 反复遍历和创建路径 预先计算几何数据或引入已提交内容缓存
随集合增大,松开时的卡顿越来越明显 数组修改和完整绘图重建 减少保留的快照,只重建受影响的派生内容
矩形绘制流畅,长自由手绘线条缓慢 活动路径的大小和重建 单独分析不断增长的活动笔画
卡顿主要发生在调整大小时 依赖尺寸的渲染和缓存失效 明确派生内容在调整大小时的处理方式

缓存会增加一个所有权边界:矩形几何数据仍是权威数据,缓存则是可替换的派生内容。将绘图尺寸、显示缩放比例和相关样式纳入失效设计。对于只追加的绘图,增量式缓存更新可以作为优化方案;对于编辑和删除,则需要明确的重建或局部失效策略。使用相同的测试数据量验证效果,再决定是否保留增加的复杂性。

检查提交边界上的数组复制

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 控制被包装视图的布局;不要直接覆盖由 SwiftUI 管理的视图 frame 或 bounds。NSViewRepresentable

为绘图输入指定唯一的所有者。如果由 AppKit 处理绘图序列,就不要再通过重叠的 SwiftUI 拖动手势提交同一序列。对于直接继承 NSView 的子类,Apple 指示:实现如果处理了所列鼠标事件,就不要对这些已处理的事件调用父类实现。NSView

先实施范围小且效果可观察的改进:局部预览状态、单一提交边界,以及在移动期间输入保持不变的已提交绘图渲染器。然后在 Instruments 中对比不断增大的合成绘图。生成的跟踪记录会告诉你,下一步改动应该针对依赖关系管理、集合所有权、几何数据准备,还是渲染。

来源