为 SwiftUI app 添加丰富的图形
Add rich graphics to your SwiftUI app
2021年6月10日
一句话判断
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。
另一个需要注意的点是 Canvas 的 isOpaque 属性。如果你的绘制内容有透明区域,确保设置为 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 的截图方法。