☰
Kotlin自定义View实现码表控件:从绘制到动画的完整指南
2026/10/14 17:22:47 网站建设 项目流程

骑行App、跑步记录、车机仪表盘,这些场景里最常见的圆形UI就是这种:一个码表,半圈刻度,一根指针跟着数值左右摆。Android 里这类组件没有现成的,基本都是自定义 View 一点点画出来的。今天讲的这个码表,就是用 Kotlin 从零写的一个可复用控件,支持自定义量程、刻度密度、单位文字,带指针动画,也支持触摸滑动改值。整个过程涉及的测量、绘制、动画、状态保存我都会摊开讲,新手可以直接照着抄,老手也可以看看我在性能优化上的处理方式。

1. 码表控件设计拆解:先想清楚画什么再动手

1.1 一张码表由哪些图层组成

码表不是一个单纯的圆,我习惯把它拆成 5 层,先明确图层顺序:

  1. 底盘背景层:圆形或带纹理的背景,决定整体风格
  2. 刻度层:主刻度、细分刻度、刻度文字
  3. 高亮弧线层:表示当前进度或速度区间的弧线
  4. 指针层:指针本体和中心轴帽
  5. 单位与状态层:底部单位文本、当前值文本

从下到上画,顺序不能乱。比如指针要盖在刻度上方,中心轴帽再盖在指针根部。如果把顺序写反,表盘会被指针根部挡住,数字也会被遮得模糊。这个顺序看起来简单,但我在实际项目里见过把数字画到指针下面,导致过渡动画不完整的例子。

为什么分这么多层?一是方便后期换主题,只要换背景实现;二是为性能优化留空间,后面会把静态层缓存成 Bitmap,只让指针等动态层参与重绘。代码角度上说,每一层对应一组 Paint,避免在 onDraw 里频繁改 Paint 属性。

1.2 Kotlin 写自定义 View 的优势和取舍

用 Java 同样能写这个控件,但 Kotlin 有几个地方让代码舒服很多。

构造函数:@JvmOverloads自动生成多构造函数重载,自定义 View 的典型三参构造可以这样写:

class OdometerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr)

如果不用它,你得手动写两个重载,模板代码明显变长。

属性声明:Kotlin 属性天然带 setter,所以 value 字段被外部赋值时可以直接触发动画。Java 里通常要写一个setValue()方法,Kotlin 里是这么写的:

var value: Float = 0f set(v) { val newValue = v.coerceIn(minValue, maxValue) if (field != newValue) { field = newValue animateToValue(newValue) } }

外部代码直接odometerView.value = 80f就触发了动画,调用方不需要知道内部有动画机制。

当然也有取舍:如果团队对 Java 更熟悉,Kotlin 风格过强可能增加维护成本。但单从自定义 View 这个领域来讲,我强烈建议直接用 Kotlin——Android 官方如今的现代示例基本都是 Kotlin,代码示例找起来也方便。

1.3 参数定义:需要哪些可配置项

码表不能写死,至少要支持:

  • minValue / maxValue:量程,比如最大 240km/h
  • value:当前值
  • unit:单位文本
  • majorTickCount:主刻度数量
  • minorTickCount:每两个主刻度之间的细分刻度数
  • majorColor / minorColor:刻度颜色
  • bgColor:背景色
  • pointerColor:指针颜色

这些参数放 attrs.xml 里,既能 XML 配置,也能在代码里通过 setter 改,使用方式很灵活。

2. 初始化与尺寸测量:基础不牢后面全是坑

2.1 读取自定义属性

attrs.xml 的声明:

<resources> <declare-styleable name="OdometerView"> <attr name="minValue" format="float" /> <attr name="maxValue" format="float" /> <attr name="value" format="float" /> <attr name="unit" format="string" /> <attr name="majorTickCount" format="integer" /> <attr name="minorTickCount" format="integer" /> </declare-styleable> </resources>

初始化时:

init { val ta = context.obtainStyledAttributes(attrs, R.styleable.OdometerView) try { minValue = ta.getFloat(R.styleable.OdometerView_minValue, 0f) maxValue = ta.getFloat(R.styleable.OdometerView_maxValue, 240f) value = ta.getFloat(R.styleable.OdometerView_value, 0f) unit = ta.getString(R.styleable.OdometerView_unit) ?: "km/h" majorTickCount = ta.getInt(R.styleable.OdometerView_majorTickCount, 12) minorTickCount = ta.getInt(R.styleable.OdometerView_minorTickCount, 2) } finally { ta.recycle() } }

obtainStyledAttributes拿到的 TypedArray 必须在 finally 中recycle(),否则多主题切换时可能内存泄漏。这一条很多资料不提,但 lint 也会报警。

Paint 的初始化我会在 init 里一次性创建,不在 onDraw 里 new。onDraw 高频执行,不断创建对象会触发 GC,掉帧就是这样来的。所有画笔都通过Paint(Paint.ANTI_ALIAS_FLAG)创建,抗锯齿是必开的。

2.2 onMeasure:把码表锁成正方形

圆形的码表,如果宽高不一致,刻度就会被压扁。踩过坑的人都懂:布局里写 wrap_content,但自定义 View 没处理测量,结果表盘变成一个椭圆,主刻度看起来忽长忽短。

解决办法简单粗暴:onMeasure 里取最小边长,强制设置成正方形。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val availableWidth = (widthMeasureSpec.resolveSize(0)) - paddingLeft - paddingRight val availableHeight = (heightMeasureSpec.resolveSize(0)) - paddingTop - paddingBottom val side = min(availableWidth, availableHeight) setMeasuredDimension( side + paddingLeft + paddingRight, side + paddingTop + paddingBottom ) }

注意要处理 padding,把 padding 扣除后再算边长,否则设置 padding 后表盘会被裁掉。还要给一个默认尺寸,比如 120dp 转成 px,作为 resolveSize 的第一个参数。如果父容器是 match_parent,会按照父给的 MeasureSpec 来;如果是 wrap_content,就用默认值。

2.3 绘制坐标的基准

有了宽高,接下来需要知道圆心和半径。通常在 onSizeChanged 里计算一次:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) centerX = width / 2f centerY = height / 2f radius = min( width - paddingLeft - paddingRight, height - paddingTop - paddingBottom ) / 2f rebuildBackgroundBitmap() }

onSizeChanged 在尺寸变化时调用,比在 onDraw 里反复计算划算。不要把 centerX、radius 这些放到 onDraw 里重新计算,每帧调用累积起来对性能不友好,还会打乱预渲染的逻辑。

关于数据解耦:View 内部不持有业务 model,外部传入基本类型。如果项目里用 ViewModel,可以直接观察 LiveData 或 Flow,把值转给 View 的 value 属性。不要在 onDraw 里做任何数据访问和解析,这是自定义 View 的铁律。

3. 绘制核心:刻度、文字与指针的数学细节

3.1 角度与数值的换算

先说角度约定。Android 的 Canvas 坐标系里,0 度在 3 点钟方向,角度顺时针增加。传统仪表盘多数留下方缺口,所以我让码表从 135 度开始,扫过 270 度,到 405 度结束。405 度等价于 45 度,但角度全程保持连续,动画来回切换才不会有指针“回卷”一圈的效果。

换算公式:

companion object { private const val START_ANGLE = 135f private const val SWEEP_ANGLE = 270f } private fun valueToAngle(value: Float): Float = START_ANGLE + (value - minValue) / (maxValue - minValue) * SWEEP_ANGLE

举个例子:min=0,max=240,value=60,那么角度是 135 + (60/240)*270 = 202.5 度。指针就落在表盘右下方偏上的位置。

这里有一个我反复踩过的坑:动画过程中不要对角度做取模运算。指针当前在 405 度,目标值对应的角度是 180 度,如果先%360再计算,系统会以为指针从 45 度转到 180 度,结果顺时针绕一大圈。正确做法是保持角度连续,直接从 405 平滑走到 180,中间角度也连续递减。

3.2 刻度线的循环绘制

按角度计算端点坐标,用三角函数画线。每个主刻度之间有 minorTickCount 个细分刻度,循环是这样的:

private fun drawTicks(canvas: Canvas) { val totalTicks = majorTickCount * minorTickCount val stepValue = (maxValue - minValue) / totalTicks for (i in 0..totalTicks) { val valueAtTick = minValue + i * stepValue val angle = Math.toRadians(valueToAngle(valueAtTick).toDouble()) val cos = cos(angle).toFloat() val sin = sin(angle).toFloat() val isMajor = i % minorTickCount == 0 val outerR = radius - dp2px(12f) val innerR = if (isMajor) radius - dp2px(30f) else radius - dp2px(22f) canvas.drawLine( centerX + cos * innerR, centerY + sin * innerR, centerX + cos * outerR, centerY + sin * outerR, if (isMajor) majorTickPaint else minorTickPaint ) } }

几个细节:

  • totalTicks = 主刻度数 * 每个主刻度区间内的细分数量。如果主刻度 12 个,minorTickCount 为 2,那么总刻度数等于 24 个区间,循环 0 到 24 共 25 条线。
  • outerR 决定了刻度线外端统一落在同一个圆上,视觉整齐;不同长度只需要改变 innerR。
  • isMajor 通过i % minorTickCount == 0判断,第一根(i=0)也会被判定为主刻度,不用特殊处理。

为什么用三角函数而不是canvas.rotate()?因为后续把背景缓存成 Bitmap 时,旋转状态处理起来会很绕;用坐标计算没有状态依赖,缓存层和动态层都能用同样的方式绘制。如果你只是临时画一次,用 rotate 也没问题,但可维护性差一些。

3.3 刻度文字的垂直居中

文字的高度不能按“圆心上方 xx 像素”简单处理。drawText的 y 坐标是文字基线(baseline),不是文字中心。所以要把基线抬高:

val textRadius = radius - dp2px(48f) val baselineOffset = (tickTextPaint.fontMetrics.ascent + tickTextPaint.fontMetrics.descent) / 2f val textY = centerY + sin * textRadius - baselineOffset canvas.drawText( valueText, centerX + cos * textRadius, textY, tickTextPaint )

(ascent + descent) / 2是一个很常用的基线补偿技巧。ascent 是负数,descent 是正数,二者之和的一半就是基线到文字中线的偏差。如果不这么处理,数字会偏上或偏下,尤其字号大时特别明显。我之前在 0 到 240 的表盘上踩过一次,80 这个数字明显比 160 高一截,补上基线补偿才整齐。

文字内容建议做去除小数处理:当值整除 1 时显示整数,否则显示小数,避免出现 66.66667 这种尴尬文案。

3.4 指针与中心轴帽

指针形状我用 Path 定义:

private val pointerPath = Path().apply { moveTo(0f, -radius * 0.72f) lineTo(-radius * 0.045f, 0f) lineTo(radius * 0.045f, 0f) close() } private fun drawPointer(canvas: Canvas) { canvas.save() canvas.rotate(displayAngle, centerX, centerY) canvas.drawPath(pointerPath, pointerPaint) canvas.restore() canvas.drawCircle(centerX, centerY, radius * 0.045f, centerCapPaint) }

把指针做成“以中心为原点、指向上方”的三角形 Path,再旋转到 displayAngle,这是最直观的方案。注意 Path 里的长度以 radius 为单位,尺寸变化时不会比例失调。每次绘制都 save/restore,避免影响后面画中心帽的位置。

中心帽盖在指针根部:一个小圆,颜色比指针浅或者用渐变色,可以提升质感。想更好看的话,再画一个更亮的半径一半的小圆模拟高光。

3.5 进度弧线:表盘上的一条高亮圆弧

加上进度弧线会让码表更有科技感。只需要在当前值对应的角度画一段圆弧:

private fun drawProgressArc(canvas: Canvas) { val arcRect = RectF( centerX - radius + dp2px(8f), centerY - radius + dp2px(8f), centerX + radius - dp2px(8f), centerY + radius - dp2px(8f) ) canvas.drawArc(arcRect, START_ANGLE, displayAngle - START_ANGLE, false, progressPaint) }

progressPaint 用 Stroke 样式,宽度 3dp,设置StrokeCap.ROUND让两端圆头。这里有个小技巧:用displayAngle - START_ANGLE作为扫过角度,而不是直接传 displayAngle,这样圆弧永远从 135 度开始,跟着指针走。如果码表可配置起始角度不同,这个写法更通用。

4. 指针动画:让码表活起来

4.1 ValueAnimator 平滑移动

动画的本质:把 displayAngle 从一个值渐变到另一个值,每帧 invalidate。

private fun animateToValue(target: Float) { val targetValue = target.coerceIn(minValue, maxValue) val endAngle = valueToAngle(targetValue) if (abs(endAngle - displayAngle) < 0.5f) return animator?.cancel() animator = ValueAnimator.ofFloat(displayAngle, endAngle).apply { duration = (abs(endAngle - displayAngle) / SWEEP_ANGLE * 900).toLong() interpolator = DecelerateInterpolator() addUpdateListener { animation -> displayAngle = animation.animatedValue as Float invalidate() } start() } }

几个细节:

  • duration 根据角度差动态调整。角度差小动画就短,快速摆动时不会显得拖沓;角度差大动画就长,不至于快到看不清。
  • 动画启动前 cancel 旧动画,这是必须的:连续赋值时旧动画可能还在运行,不 cancel 会出现两个动画同时改 displayAngle,互相覆盖。
  • 用 DecelerateInterpolator 而不是 Linear,指针接近目标时减速,感官上接近真实机械表的阻尼。

这也是为什么 value 的 setter 不让用户直接赋值,而走 animateToValue——外部只关心数值,动画是内部实现。

4.2 对齐 VSync 的刷新方式

普通invalidate()写在动画回调里就够了,系统会在下个 VSync 信号到来时重新绘制。这里真正值得注意的经验是:如果动画每帧变化很大,或者同时开了好几个 ValueAnimator,推荐用postInvalidateOnAnimation()而不是invalidate()。它会把重绘请求打进下一帧的消息队列,避免不必要的前置竞争。这个习惯在放大缩放的动画场景里尤其明显。

4.3 阻尼回弹效果(手感的关键)

真实码表的指针不会瞬间跳到目标值,而是先快速摆动再慢慢回落。ValueAnimator 加 DecelerateInterpolator 已经能做到。如果想要机械表那种阻尼感,可以每帧手动计算:

private val dampFactor = 0.18f private fun updateDisplayAngle() { val target = valueToAngle(value) displayAngle += (target - displayAngle) * dampFactor if (abs(target - displayAngle) < 0.1f) { displayAngle = target animationRunning = false } invalidate() }

这样配合 Choreographer 回调,能做出指针先快后慢又带一点微小追踪的质感。代价是代码更复杂,所以如果追求效率就用 ValueAnimator,追求手感再上阻尼。

4.4 生命周期清理

动画是长期运行任务,自定义 View 又经常在页面切换时被销毁。必须在 onDetachedFromWindow 里 cancel:

override fun onDetachedFromWindow() { super.onDetachedFromWindow() animator?.cancel() }

否则页面销毁时动画还在跑,轻则卡顿,重则空指针。

5. 触摸交互与状态恢复

5.1 触摸滑动拨指针

触摸交互的骨架:

override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { touching = isInsideDial(event.x, event.y) if (touching) { parent?.requestDisallowInterceptTouchEvent(true) } return true } MotionEvent.ACTION_MOVE -> { if (touching && isDragging) { val angle = computeAngle(event.x, event.y) setValueFromUser(angleToValue(angle)) } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { touching = false isDragging = false parent?.requestDisallowInterceptTouchEvent(false) return true } } return super.onTouchEvent(event) }

需要两个辅助函数:根据触点坐标算角度,再映射到值区间。

private fun computeAngle(x: Float, y: Float): Float { val dx = x - centerX val dy = y - centerY var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() angle = (angle + 360f) % 360f return if (angle < START_ANGLE) angle + 360f else angle } private fun angleToValue(angle: Float): Float { val clamped = angle.coerceIn(START_ANGLE, START_ANGLE + SWEEP_ANGLE) return minValue + (clamped - START_ANGLE) / SWEEP_ANGLE * (maxValue - minValue) }

computeAngle 中要注意,起始 135 度和 405 度是同一个物理位置。如果手指在表盘下方缺口(角度不到 135 或超过 405),映射时需要做钳制。另外,调用了requestDisallowInterceptTouchEvent(true)是为了避免在横向滚动容器里,手指滑动触发父容器滚动,打断码表的操作。

5.2 touch slop 的必要性

如果手指只是点击了下去,并没有要拖动,却因为向下偏移几个像素就触发大旋转,体验会很糟。所以在 ACTION_DOWN 记下起始位置,ACTION_MOVE 里检查距离超过ViewConfiguration.get(context).scaledTouchSlop才进入拖动模式。拖动模式下才更新 value。这个阈值系统已经算好,直接用即可。

5.3 触摸拖动时绕过动画

触摸拖动时,手指直接设定 value,会触发指定的 animateToValue;而拖动过程中每帧都设置新 value,会不断 cancel/重启动画,指针看起来卡顿。所以拖动过程中的更新应该直接改 displayAngle 并 invalidate:

private fun setValueFromUser(newValue: Float) { this.value = newValue.coerceIn(minValue, maxValue) displayAngle = valueToAngle(this.value) invalidate() }

这就是“触摸时不做动画”的做法,和真实码表交互一致:拨到哪里就跟到哪里,松开后才做平滑。

5.4 保存 View 状态

Activity 旋转时 View 会被重建,码表数值如果丢了,用户调了半天表盘突然回零,体验很糟。覆写状态保存:

override fun onSaveInstanceState(): Parcelable? { val state = SavedState(super.onSaveInstanceState()) state.value = value return state } override fun onRestoreInstanceState(state: Parcelable?) { (state as SavedState).let { super.onRestoreInstanceState(it.superState) value = it.value displayAngle = valueToAngle(value) } }

SavedState 内部类需要实现 Parcelable,模板比较固定:保存 value,writeToParcel 里 writeFloat,createFromParcel 里 readFloat。如果业务上数值来源是 ViewModel,未必需要这种保存,但作为控件自带状态更严谨。

6. 性能优化与常见问题排查

6.1 静态层缓存成 Bitmap

码表重绘大多集中在指针变化上,底盘、刻度、数字这些不会变。把它们缓存成一个背景 Bitmap,能大幅提升绘制效率,尤其在 RecyclerView 里使用多个码表时区别非常明显。

private var backgroundBitmap: Bitmap? = null private fun rebuildBackgroundBitmap() { if (measuredWidth == 0 || measuredHeight == 0) return backgroundBitmap?.recycle() backgroundBitmap = Bitmap.createBitmap( measuredWidth, measuredHeight, Bitmap.Config.ARGB_8888 ).apply { val canvas = Canvas(this) drawBackground(canvas) drawTicks(canvas) drawTickTexts(canvas) } }

onDraw 里只画三样东西:

override fun onDraw(canvas: Canvas) { backgroundBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } drawProgressArc(canvas) drawPointer(canvas) }

这里有一个坑:Bitmap 尺寸必须用像素,不能直接拿 dp 值去创建,否则在 xxhdpi 下会偏小。另外 View 尺寸变化时要在 onSizeChanged 里调用 rebuildBackgroundBitmap(),否则缓存的旧背景会沿用,出现拉伸或留白。

还有一个细节:创建 Bitmap 后最好设置 density:

bitmap.density = resources.displayMetrics.densityDpi

不设的话,某些机型上 drawBitmap 会被 Canvas 按默认密度缩放,莫名变大变小。

6.2 高清屏适配细节

  • 所有偏移量如 dp 值必须转 px,我习惯封装一个dp2px(dp: Float)方法,内部用resources.displayMetrics.density计算。
  • 刻度文字建议用 dp 而不是 sp。它是视觉比例而非可读文本,用户系统字号调大后,刻度数字挤在一起很难看。
  • 字体大小和指针长度都要基于 radius 比例,不要写死像素值。同一个控件在手机和平板上尺寸差异很大,只有按比例计算才能保持比例协调。
private fun dp2px(value: Float): Float = resources.displayMetrics.density * value

6.3 常见问题速查表

现象可能原因解决方法
表盘被压扁成椭圆onMeasure 未处理宽高不一致取最小边长,扣 padding 后强制正方形
刻度文字整体偏高直接用了圆心坐标用 fontMetrics.ascent/descent 做基线补偿
指针绕一大圈回卷角度取模导致不连续全程保持连续角度,不做 %360
拖动时指针卡顿抖动每次 move 都启动/取消动画触摸回调中直接改 displayAngle
放在 RecyclerView 里滑动卡静态层每帧重复绘制背景缓存 Bitmap,onDraw 只画变化层
旋转屏幕后数值清零未保存状态实现 onSaveInstanceState

6.4 健壮性建议

自定义 View 要在各种场景下存活,这几条保命:

  • 数据变化频繁时不要每次 setValue 都做动画,提供一个 enableAnimation 开关。
  • 对状态栏高度变化、分屏模式导致的尺寸变化,及时重建缓存,不要在 onDraw 里判断。
  • 指针 Path 在尺寸变化后需要重建。把 pointerPath 的构建也放进一个updateGeometry()方法里,与 onSizeChanged 联动。
  • 不要过度设置硬件层setLayerType(LAYER_TYPE_HARDWARE, null),它带来性能收益的同时也增加内存开销,通常不需要。
  • init 里所有 Paint 一次性创建,onDraw 里不再 new 对象,这是自定义 View 性能的底线。可以用 Profile GPU Rendering 工具观察,绿色柱状图明显下降就是优化生效了。

最后说一点我自己的感受:码表这种自定义 View,技术难点真不在 API,而在于把角度、数值、动画状态拧成一根线。只要把 valueToAngle 和 angleToValue 写对,后面所有的绘制、动画、触摸都是一本账。写完这个控件之后还可以继续扩展:把最小刻度投影出来、做成环形进度、叠加渐变色表盘,或者让两个码表实现联动。希望这篇能帮到你,尤其是那个“指针回卷”的坑,调试的时候真的容易被它气得怀疑人生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询