求助:Swift实现Apple Pencil PDF绘图功能及CAShapeLayer渲染卡顿问题
Hey MartinWo, 我正好在几款PDF标注类APP里踩过类似的坑,对你用CAShapeLayer实现Apple Pencil绘图时遇到的卡顿问题太有共鸣了!下面是我实际项目中验证过的几个解决方案,应该能帮你解决这个困扰:
用CAShapeLayer逐笔添加路径时,随着绘图次数增加,图层数量或单图层路径复杂度会指数级上升,CoreAnimation的渲染压力越来越大,自然就会变慢。我们的优化方向就是减少渲染负载和优化路径数据。
1. 合并路径/图层,避免图层爆炸
不要每画一笔就新建一个CAShapeLayer,也不要把所有路径都堆在同一个图层里。推荐的做法是:
- 用一个临时
CAShapeLayer处理当前正在画的笔画 - 每完成N笔(比如10笔),就把临时图层的内容渲染成一张位图,合并到背景的
UIImageView中,然后清空临时图层
示例代码:
// 将CAShapeLayer内容渲染为UIImage func renderShapeLayerToImage(layer: CAShapeLayer, bounds: CGRect) -> UIImage? { UIGraphicsBeginImageContextWithOptions(bounds.size, false, UIScreen.main.scale) guard let context = UIGraphicsGetCurrentContext() else { return nil } layer.render(in: context) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image } // 合并临时图层到背景图 func mergeTemporaryLayer() { guard let tempImage = renderShapeLayerToImage(layer: tempDrawLayer, bounds: self.bounds), let bgImage = backgroundImageView.image else { return } // 合并两张图片 UIGraphicsBeginImageContextWithOptions(bgImage.size, false, UIScreen.main.scale) bgImage.draw(at: .zero) tempImage.draw(at: .zero) let combinedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() backgroundImageView.image = combinedImage tempDrawLayer.path = nil // 清空临时图层路径 }
2. 简化Apple Pencil的采样路径
Apple Pencil的采样率高达240Hz,直接用原始触摸点生成路径会有大量冗余点,导致路径过于复杂。用Ramer-Douglas-Peucker算法简化路径,能大幅减少点的数量,同时保留笔画的核心形状。
示例简化函数:
func simplifyPath(_ points: [CGPoint], epsilon: CGFloat = 0.6) -> [CGPoint] { guard points.count > 2 else { return points } var maxDistance: CGFloat = 0 var splitIndex = 0 for i in 1..<points.count-1 { let distance = pointToLineDistance(points[i], lineStart: points[0], lineEnd: points.last!) if distance > maxDistance { maxDistance = distance splitIndex = i } } if maxDistance > epsilon { let leftPath = simplifyPath(Array(points[0...splitIndex]), epsilon: epsilon) let rightPath = simplifyPath(Array(points[splitIndex...]), epsilon: epsilon) return leftPath + rightPath.dropFirst() } else { return [points.first!, points.last!] } } private func pointToLineDistance(_ point: CGPoint, lineStart: CGPoint, lineEnd: CGPoint) -> CGFloat { let lineLength = hypot(lineEnd.x - lineStart.x, lineEnd.y - lineStart.y) guard lineLength > 0 else { return hypot(point.x - lineStart.x, point.y - lineStart.y) } let t = ((point.x - lineStart.x) * (lineEnd.x - lineStart.x) + (point.y - lineStart.y) * (lineEnd.y - lineStart.y)) / (lineLength * lineLength) let clampedT = max(0, min(1, t)) let projection = CGPoint(x: lineStart.x + clampedT * (lineEnd.x - lineStart.x), y: lineStart.y + clampedT * (lineEnd.y - lineStart.y)) return hypot(point.x - projection.x, point.y - projection.y) }
3. 直接在PDF上下文绘制(适合需要保存笔记到PDF的场景)
如果你的APP需要把笔记嵌入PDF文件保存,直接跳过CAShapeLayer,在PDF的CGContext上绘制会更高效。每次显示页面时,先渲染原PDF内容,再叠加笔记路径,这样既避免了图层问题,又能直接导出带笔记的PDF。
示例代码:
func renderPDFWithNotes(originalPDFData: Data, notes: [DrawingNote]) -> UIImage? { guard let pdfDoc = CGPDFDocument(originalPDFData as CFData), let page = pdfDoc.page(at: 1) else { return nil } let pageRect = page.getBoxRect(.mediaBox) UIGraphicsBeginImageContextWithOptions(pageRect.size, false, UIScreen.main.scale) guard let context = UIGraphicsGetCurrentContext() else { return nil } // 绘制原PDF页面(翻转坐标系适配CoreGraphics) context.saveGState() context.translateBy(x: 0, y: pageRect.size.height) context.scaleBy(x: 1.0, y: -1.0) context.drawPDFPage(page) context.restoreGState() // 绘制笔记 for note in notes { context.setStrokeColor(note.color.cgColor) context.setLineWidth(note.lineWidth) context.setLineCap(.round) context.setLineJoin(.round) let path = UIBezierPath() path.move(to: note.points[0]) for point in note.points.dropFirst() { path.addLine(to: point) } path.stroke() } let renderedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return renderedImage }
4. 进阶:用Metal加速渲染
如果你的APP需要支持超大量的绘图(比如连续画几百笔),可以考虑用Metal直接管理GPU渲染,它比CoreAnimation的底层控制能力更强,能更高效地处理复杂路径的渲染。不过这个学习成本稍高,适合对性能有极致要求的场景。
这些方法我在实际项目中都用过,卡顿问题基本都能解决。如果还有细节问题,比如撤销功能的适配,可以再补充说明~
内容的提问来源于stack exchange,提问作者MartinWo

