涡轮流量计的仪表盘动画,难点不在于画出一个涡轮,而在于让涡轮的转速和流量数值严格挂钩。流量大时叶片飞速旋转,流量趋近于零时缓缓停转,这种同步感是整个仪表盘的灵魂。用Core Graphics直接绘制配合CADisplayLink逐帧驱动,是目前最灵活也最精确的做法,比预制的GIF或Lottie动画更能贴合实时数据的变化。

一、涡轮动画的整体绘制思路
涡轮的本质是一组围绕中心点均匀分布的叶片,每个叶片相对于前一个叶片旋转固定的角度。假设叶片数量为N,则相邻两个叶片之间的角度间隔为360/N度。绘制时先画第一个叶片,再通过CGContext的旋转变换依次画出其余叶片,就能得到完整的涡轮轮廓。
这里有个容易被忽略的细节:叶片数量会影响视觉平滑度。叶片太少,旋转时会有明显的跳跃感;叶片太多,叶片之间会挤在一起难以分辨。实际经验中,6到10片是比较理想的区间。流量计类仪表通常选8片,既有机械感又不显得杂乱。
叶片本身建议用贝塞尔曲线勾勒出流线型轮廓,而不是简单的矩形。矩形叶片旋转起来像风车,缺乏仪表盘应有的精密感。用UIBezierPath构造从叶根到叶尖的弧线,配合轻微的曲率变化,可以让叶片看起来像真实涡轮上冲压成型的金属叶片。
二、转速与流量速度的同步算法
动画同步的核心是角度累积。每帧绘制时,涡轮的当前角度需要加上一个增量角度,这个增量由当前流量速度决定。设流量的归一化值为v(范围0到1),则每帧增量可以表示为baseSpeed乘以v再乘以当前帧间隔时间。baseSpeed是流量满量程时涡轮每秒旋转的圈数换算出的角速度,通常设为每秒2到4圈比较符合视觉习惯。
关键点在于帧间隔时间必须参与计算。CADisplayLink的回调频率并不总是60fps,在ProMotion屏幕上是120fps。如果只按固定每帧角度递增,在不同刷新率的设备上转速会不一致。正确做法是在selector中读取targetTimestamp与timestamp的差值,或者使用displayLink.duration估算帧时长,将时间因子纳入角度增量。
// 每帧更新涡轮角度
private func updateAngle(with displayLink: CADisplayLink) {
let dt = displayLink.targetTimestamp - displayLink.timestamp
// flowRate: 0.0 ~ 1.0 的归一化流量
// maxAngularVelocity: 满量程角速度,弧度/秒
let delta = maxAngularVelocity * flowRate * dt
currentAngle += delta
setNeedsDisplay()
}另一个细节是流量趋近于零时的表现。真实流量计在流量极小时涡轮会缓慢蠕动直至停止,如果代码里直接用flowRate乘以角速度,当flowRate为0.05时叶片几乎不动,视觉上像卡住了。解决办法是引入一个最小可视速度,让小流量下仍保留缓慢旋转,同时用缓动函数(比如EaseInOut)平滑过渡流量值的变化,避免涡轮转速突变。
三、叶片的绘制与旋转变换实现
绘制阶段先在draw(_ rect: CGRect)中获取CGContext,将坐标系原点平移到视图中心,然后按当前角度旋转坐标系,画出第一个叶片。之后每画一片叶片,就将坐标系再旋转360/N度。这种做法比手动为每个叶片计算坐标点简洁得多,也让叶片形状的定义集中在一处,改形状时只改一份路径代码。
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: rect.midX, y: rect.midY)
let bladeCount = 8
let bladeAngle = CGFloat.pi * 2 / CGFloat(bladeCount)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: currentAngle)
for _ in 0..<bladeCount {
let path = CGMutablePath()
// 从叶根到叶尖的流线型轮廓
path.move(to: CGPoint(x: -4, y: -8))
path.addCurve(to: CGPoint(x: 10, y: -60),
control1: CGPoint(x: 12, y: -20),
control2: CGPoint(x: 16, y: -45))
path.addLine(to: CGPoint(x: 2, y: -62))
path.addCurve(to: CGPoint(x: -6, y: -10),
control1: CGPoint(x: -2, y: -40),
control2: CGPoint(x: -8, y: -25))
path.closeSubpath()
ctx.addPath(path)
ctx.setFillColor(bladeColor.cgColor)
ctx.setStrokePathWithColor()
ctx.strokePath()
ctx.fillPath()
// 旋转坐标系,绘制下一片叶片
ctx.rotate(by: bladeAngle)
}
}注意上面代码中叶片路径是围绕Y轴负方向定义的,因为Core Graphics的Y轴向下为正,叶片朝上绘制时用负值。同时叶片根部要留出中心轮毂的空间,轮毂可以在最后单独画一个圆形盖住叶根,让整体结构更清晰。渐变色也值得投入:用CGGradient给叶片做从叶根到叶尖的亮度渐变,配合旋转,会产生类似金属反光掠过的效果,视觉档次立刻不同。
四、性能优化与常见问题处理
CADisplayLink会强引用target,如果直接add(target: self, ...)会造成循环引用,视图控制器释放后回调仍在执行,导致CPU空转。标准解法是用weak proxy包装target,或者在deinit中调用invalidate()。这是无数线上卡顿事故的根源,务必处理好。
绘制性能方面,叶片路径每帧都在重复构建,但形状本身是固定的,只有旋转角度在变。优化方案是把叶片路径预先构建一次存为成员变量,绘制时直接ctx.addPath(cachedPath),减少每帧的对象分配。对于8片叶片来说这个优化看似微小,但在低端机上配合其他动画同时运行时收益明显。
还有一个常见问题是涡轮旋转时出现闪烁或撕裂,多半是setNeedsDisplay调用过于频繁,或者视图的isOpaque设置不当导致透明度合成开销过大。如果视图背景完全不透明,将isOpaque设为true可以让系统跳过透明合成。此外,若仪表盘上还有指针、刻度等其他元素,建议把它们拆到独立的layer或子视图中,只让涡轮区域重绘,避免整块区域每帧全部刷新。
最后,当流量数据来自网络或传感器时,更新频率往往和屏幕刷新率不一致。不要在数据回调里直接赋值flowRate并重绘,而是把新数据存入缓冲区,由CADisplayLink的回调统一消费并做插值平滑。这样既能消除数据抖动带来的转速突跳,也让数据源与渲染节奏完全解耦,架构上更干净。
Core GraphicsiOS动画仪表盘绘制修改时间:2026-09-15 01:12:36