Add rich graphics to your SwiftUI app
Graphics & Games 进阶 26m

为 SwiftUI app 添加丰富的图形

Add rich graphics to your SwiftUI app

2021年6月10日

在 Apple 官方观看视频

一句话判断

SwiftUI 在 iOS 15 终于拿到了 Canvas 和 TimelineView 这两张牌——实时图形渲染和数据驱动的动画再也不用桥接 UIKit 了,但 Metal 级别的性能需求还是绕不开。

这场 Session 讲了什么

SwiftUI 的图形能力在 iOS 15 得到了显著增强。Session 介绍了两个全新的组件:Canvas 用于高性能的自定义绘制,TimelineView 用于按时间表更新视图。这两个组件的组合使得在 SwiftUI 中创建复杂的数据可视化和动态图形成为可能。

Canvas 是一个 immediate-mode 的绘制 API,类似于 UIKit 中的 drawRect,但它天然支持 SwiftUI 的动画系统。你可以在 Canvas 中使用 GraphicsContext 来绘制路径、图片、文字,所有操作都发生在 GPU 上。

TimelineView 则是一个按时间表刷新的容器视图。传统的 SwiftUI 动画只能做预设路径的插值,而 TimelineView 可以让你根据时间精确控制每一帧的绘制内容。配合 Canvas,这就等于在 SwiftUI 中拥有了一个轻量级的渲染引擎。

值得深挖的点

Canvas 的性能边界

Canvas 的绘制操作发生在 GPU 上,但它不是 Metal。如果你的绘制逻辑涉及每帧几万个独立路径或者复杂的 shader,Canvas 的性能可能不够。Session 明确指出 Canvas 适合的是仪表盘、图表、自定义控件这类场景。对于游戏级别的渲染,仍然需要 MTKView(MetalKit View)或者 SpriteKit。

另一个需要注意的点是 CanvasisOpaque 属性。如果你的绘制内容有透明区域,确保设置为 false,否则透明区域会渲染成黑色。这个小细节很容易遗漏。

TimelineView 的刷新策略

TimelineView 接受一个 Schedule 参数来控制刷新频率。EveryMinuteTimelineSchedule 每分钟刷新一次,适合时钟类 app。PeriodicTimelineSchedule 可以设置任意间隔。但过度频繁的刷新(比如 60fps)会让 CPU 和 GPU 都很忙,电池消耗会显著增加。Session 建议只在用户看得见的时候才用高频率刷新。

代码片段

// 使用 Canvas 绘制自定义图表
struct BarChartView: View {
    let values: [Double]
    let maxValue: Double
    
    var body: some View {
        Canvas { context, size in
            let barWidth = size.width / CGFloat(values.count) * 0.7
            let spacing = size.width / CGFloat(values.count) * 0.3
            
            for (index, value) in values.enumerated() {
                let barHeight = CGFloat(value / maxValue) * size.height
                let x = CGFloat(index) * (barWidth + spacing) + spacing / 2
                let rect = CGRect(
                    x: x,
                    y: size.height - barHeight,
                    width: barWidth,
                    height: barHeight
                )
                
                // 使用渐变填充
                let gradient = Gradient(colors: [.blue, .cyan])
                context.fill(
                    Path(roundedRect: rect, cornerRadius: 4),
                    with: .linearGradient(
                        gradient,
                        startPoint: rect.bottomLeading,
                        endPoint: rect.topLeading
                    )
                )
            }
        }
        .frame(height: 200)
    }
}
// TimelineView + Canvas 实现实时动画
struct AnimatedGaugeView: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 0.05)) { timeline in
            Canvas { context, size in
                let time = timeline.date.timeIntervalSinceReferenceDate
                let angle = Angle.degrees(time.truncatingRemainder(dividingBy: 360) * 60)
                
                let center = CGPoint(x: size.width / 2, y: size.height / 2)
                let radius = min(size.width, size.height) / 2 - 20
                
                // 绘制外圈
                let circlePath = Path(ellipseIn: CGRect(
                    x: center.x - radius,
                    y: center.y - radius,
                    width: radius * 2,
                    height: radius * 2
                ))
                context.stroke(circlePath, with: .color(.gray.opacity(0.3)), lineWidth: 4)
                
                // 绘制指针
                let pointerEnd = CGPoint(
                    x: center.x + cos(angle.radians) * radius,
                    y: center.y + sin(angle.radians) * radius
                )
                context.stroke(
                    Path { path in
                        path.move(to: center)
                        path.addLine(to: pointerEnd)
                    },
                    with: .color(.orange),
                    lineWidth: 3
                )
            }
        }
        .frame(width: 200, height: 200)
    }
}
// Canvas 绘制带有滤镜效果的图形
struct BlurEffectView: View {
    var body: some View {
        Canvas { context, size in
            // 添加模糊滤镜
            context.addFilter(.blur(radius: 5))
            
            let rect = CGRect(origin: .zero, size: size)
            context.fill(
                Path(ellipseIn: rect.insetBy(dx: 40, dy: 40)),
                with: .color(.blue.opacity(0.5))
            )
            
            // 移除滤镜绘制清晰文字
            context.addFilter(.blur(radius: 0))
            context.draw(
                Text("模糊背景")
                    .font(.title)
                    .foregroundColor(.white),
                at: CGPoint(x: size.width / 2, y: size.height / 2)
            )
        }
    }
}

最佳实践

Canvas 的绘制闭包中不要创建复杂对象。每次重绘时闭包都会被调用,如果你在里面创建大量的 Path 或者 CGAffineTransform,GC 压力会很大。建议把不变的计算结果缓存到视图的属性中,闭包只做简单的绘制调用。

TimelineView 配合 .animation() 修饰符时要注意:TimelineView 本身的刷新不是动画驱动的,它是基于时间表的硬刷新。如果你想要平滑的过渡效果,需要在 Canvas 内部用插值来实现,而不是依赖 SwiftUI 的 .animation()

还有什么值得关注

  • GraphicsContext 支持 drawLayer 方法,可以在独立图层中绘制然后合成,适合分层渲染场景。
  • Canvas 支持 symbols,可以预渲染 SwiftUI 视图为图片,然后在 Canvas 中高效复用。
  • SwiftUI 的 ImageRenderer(iOS 16+)在 iOS 15 还不可用,如果需要把 Canvas 内容导出为图片,目前还需要用 UIKit 的截图方法。
WWDC 2021