在充电桩、光伏监控、智能电表这类App里,仪表盘几乎是绕不开的界面元素。UIKit自带的控件库里并没有现成的仪表盘,第三方库又常常样式固定、难以贴合设计稿,因此掌握用Core Graphics自绘仪表盘的思路就显得很实用。本文以一个功率表为例,完整走一遍刻度绘制、功率计算、指针动画和功率因数显示的流程,代码以Swift为主,思路同样适用于Objective-C。

一、绘制刻度盘:路径计算是核心
仪表盘的灵魂在于弧线和刻度的几何计算。通常功率表的量程是0到最大值,对应表盘上从135度到405度(也就是起始角在左下、终止角在右下,中间留出底部缺口),这个角度区间可以根据设计稿调整。Core Graphics中的角度以弧度为单位,且屏幕坐标系的Y轴向下,所以真实绘制的角度方向需要做一次换算,否则画出来的弧会跑到表盘上方。
刻度分为主刻度和次刻度。主刻度带数字标签,数量一般是量程分段数加一;次刻度在每个主刻度之间再均分若干份。绘制时先算出每根刻度线两端的坐标,外端落在刻度圆上,内端向圆心方向收缩固定长度,主刻度收缩得多一些、线宽更粗,视觉上就能形成层次。坐标计算用最基础的三角函数即可。
func point(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
// 表盘起始角与结束角,底部留缺口
let startAngle = CGFloat.pi * 0.75
let endAngle = CGFloat.pi * 2.25
let totalSweep = endAngle - startAngle
func drawTicks(in ctx: CGContext, rect: CGRect) {
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) * 0.4
let mainTickCount = 11 // 0~10共11个主刻度
for i in 0...10 {
let angle = startAngle + totalSweep * CGFloat(i) / CGFloat(mainTickCount - 1)
let outer = point(center: center, radius: radius, angle: angle)
let inner = point(center: center, radius: radius - 14, angle: angle)
ctx.setLineWidth(i % 2 == 0 ? 2.5 : 1.5)
ctx.strokeLineSegments(between: [outer, inner])
}
}颜色分区也是常见需求,比如0到30%用绿色、30%到70%用黄色、70%以上用红色。实现方式是按百分比换算出三段弧各自的角度区间,分别调用addArc后设置不同颜色描边即可。注意弧度制的角度是逆时针为正还是顺时针为正,取决于clockwise参数与坐标系方向,绘制前用小demo验证一下方向可以省去不少调试时间。
二、功率计算与数值到角度的映射
功率表显示的数值通常来自电表或传感器的原始数据,比如电压U、电流I、相位差或者直接的有功功率P。这里需要理清三个概念:有功功率P等于电压与电流的有效值乘以功率因数,单位是瓦;视在功率S等于电压有效值乘以电流有效值,单位是伏安;功率因数cosφ等于P除以S,反映电能的有效利用程度。如果设备只上报P和S,功率因数直接做除法就能得到,注意S为零时要做保护,避免除零。
struct PowerSample {
let voltage: Double // 电压有效值 V
let current: Double // 电流有效值 A
let activePower: Double // 有功功率 W
}
extension PowerSample {
var apparentPower: Double { voltage * current } // 视在功率 VA
var powerFactor: Double {
let s = apparentPower
guard s > 0.001 else { return 0 }
return min(1, max(0, activePower / s)) // 限制在0~1
}
}拿到功率值后,要把它映射成指针角度。公式很直接:先算当前值占满量程的比例ratio,再用起始角加上比例乘以总扫过角度,就得到目标角度。满量程的选择建议比实际最大值高出一档,比如设备峰值7.5千瓦,量程设为10千瓦,指针平时工作在表盘中段,既好看又留有超限提示的余量。
let maxPower: Double = 10000.0 // 满量程 10kW
func angle(for power: Double) -> CGFloat {
let clamped = min(maxPower, max(0, power))
let ratio = clamped / maxPower
return startAngle + totalSweep * ratio
}三、指针动画:CADisplayLink比UIView动画更细腻
指针从当前角度转到目标角度,如果直接用UIView的animate方法做隐式动画,视觉上会显得生硬,因为真实的机械表针有惯性,快速变化时会过冲再回摆。推荐的做法是在视图内部维护当前角度和目标角度两个状态,用CADisplayLink每帧做插值更新,并叠加一个简单的弹簧效果。
具体思路:每帧计算目标角度与当前角度的差值,让当前角度以一定比例向目标靠拢,同时记录速度,速度参与下一帧计算,就形成了类似弹簧阻尼的动态。数值更新后调用setNeedsDisplay触发重绘,Core Graphics会重新画指针。这种方式CPU占用很低,一秒六十帧重绘一个中等复杂度的表盘完全没有压力。
class GaugeView: UIView {
var targetAngle: CGFloat = 0
private var currentAngle: CGFloat = 0
private var velocity: CGFloat = 0
private var displayLink: CADisplayLink?
func startAnimating() {
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func tick() {
// 弹簧阻尼模型:加速度正比于角度差,速度按阻尼系数衰减
let stiffness: CGFloat = 120
let damping: CGFloat = 14
velocity += (targetAngle - currentAngle) * stiffness * (1.0 / 60.0)
velocity *= (1 - damping / 60)
currentAngle += velocity * (1.0 / 60.0)
setNeedsDisplay()
if abs(targetAngle - currentAngle) < 0.002 && abs(velocity) < 0.002 {
currentAngle = targetAngle
velocity = 0
setNeedsDisplay()
displayLink?.invalidate()
displayLink = nil
}
}
}有一个容易踩的坑:CADisplayLink默认模式在列表滚动时会暂停回调,如果仪表盘嵌在ScrollView里,务必加到common`模式,否则滚动列表时指针会卡住。另外数值稳定后记得invalidate掉displayLink,常驻空转既费电又没有意义。
四、功率因数显示与整体刷新策略
功率因数一般不占用指针,而是在表盘中央或下方用数字直接显示,格式化为两位小数,并按区间着色:0.9以上绿色,0.8到0.9黄色,低于0.8红色提示电能利用效率低。文字绘制不要用Core Text硬写,直接在draw(_ rect:)里调用NSAttributedString的draw(at:)方法即可,字号和颜色都方便调整。
func drawPowerFactor(_ pf: Double, in rect: CGRect) {
let text = String(format: "PF %.2f", pf)
let color: UIColor = pf >= 0.9 ? .systemGreen : (pf >= 0.8 ? .systemYellow : .systemRed)
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .semibold),
.foregroundColor: color
]
let size = text.size(withAttributes: attrs)
let point = CGPoint(x: rect.midX - size.width / 2, y: rect.midY + 40)
(text as NSString).draw(at: point, withAttributes: attrs)
}数值刷新方面,传感器上报频率可能达到每秒十次以上,如果每次都直接更新目标角度,指针会不停抖动。实践中有两种处理方式:一是对上报数据做滑动平均滤波,取最近几个采样值的平均;二是对目标角度本身做节流,比如每200毫秒才更新一次目标值,中间由弹簧动画自然过渡,视觉效果反而更接近真实仪表。
最后补充绘制细节:指针建议画成带阴影的细长三角形,圆心处叠加两层不同半径的圆做立体感;刻度数字用等宽数字字体,避免数值跳动时文字宽度变化;整个表盘的静态部分(弧线、刻度、数字)可以提前渲染成一张位图缓存,draw方法里只重绘指针,性能还能再提升一截。这套方案在真机上实测帧率稳定在60fps,即使叠加多个仪表盘同屏展示也毫无压力。
Core GraphicsiOS仪表盘功率计算修改时间:2026-09-12 08:32:42