Android 自定义 View 的测量与绘制
Android 自定义 View 的测量与绘制
自定义 View 适合实现进度环、行情曲线、刻度盘等标准控件难以表达的视觉效果。它的难点不在于画出图形,而在于遵守测量协议、处理不同尺寸和密度、减少绘制阶段分配,并让状态变化以正确方式触发重绘。
一、明确 View 的职责
自定义 View 应接收已经整理好的展示数据,只负责测量、绘制和局部交互。数据请求、业务计算和长期状态不应放在 View 内部。
data class ProgressViewState(
val progress: Float,
val label: String
)
fun setState(state: ProgressViewState) {
currentState = state.copy(
progress = state.progress.coerceIn(0f, 1f)
)
invalidate()
}
这里限制进度范围,是控件本身的输入契约。对于业务字段,不应在 View 中猜测错误值代表什么。
二、理解测量规格
父容器通过 MeasureSpec 把尺寸约束传给子视图:
EXACTLY:父容器已经确定尺寸。AT_MOST:子视图可以自行决定,但不能超过上限。UNSPECIFIED:父容器不提供明确限制。
自定义 View 如果不重写 onMeasure,在 wrap_content 下可能无法获得期望尺寸。可以提供基于内容的建议大小:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = paddingLeft + contentDiameter + paddingRight
val desiredHeight = paddingTop + contentDiameter + paddingBottom
val measuredWidth = resolveSize(desiredWidth, widthMeasureSpec)
val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec)
setMeasuredDimension(measuredWidth, measuredHeight)
}
resolveSize 能遵守父容器传入的约束,比直接设置固定像素更安全。即使父容器给出非正方形区域,内部圆形也可以按较短边计算,而不能通过测量结果违反父容器的精确尺寸要求。
三、统一处理密度
代码中的视觉尺寸不能直接使用裸像素。可以把默认描边、间距等 dp 值按屏幕密度转换:
private fun Float.toPx(): Float {
return this * resources.displayMetrics.density
}
private val strokeWidthPx = 6f.toPx()
private val defaultDiameterPx = 120f.toPx().roundToInt()
文字绘制也应遵循项目统一字号适配规则。若项目规定字号使用 dp,就使用相同密度转换,不混入另一套缩放体系。
四、在尺寸变化时计算几何信息
绘制区域通常只在 View 尺寸改变时需要重新计算,可以放在 onSizeChanged 中:
private val arcBounds = RectF()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val availableWidth = w - paddingLeft - paddingRight
val availableHeight = h - paddingTop - paddingBottom
val diameter = minOf(availableWidth, availableHeight).toFloat()
val contentLeft = paddingLeft + (availableWidth - diameter) / 2f
val contentTop = paddingTop + (availableHeight - diameter) / 2f
val halfStroke = strokeWidthPx / 2f
arcBounds.set(
contentLeft + halfStroke,
contentTop + halfStroke,
contentLeft + diameter - halfStroke,
contentTop + diameter - halfStroke
)
}
避免在每次 onDraw 中重新创建矩形、画笔和路径,可以减少滚动或动画过程中的垃圾回收压力。
五、保持 onDraw 轻量
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawArc(arcBounds, -90f, 360f, false, backgroundPaint)
canvas.drawArc(
arcBounds,
-90f,
360f * currentState.progress,
false,
progressPaint
)
val centerX = width / 2f
val baseline = height / 2f -
(textPaint.ascent() + textPaint.descent()) / 2f
canvas.drawText(currentState.label, centerX, baseline, textPaint)
}
绘制阶段不要进行数据库查询、图片解码或复杂集合处理。曲线点位等计算结果应在数据变化时提前准备。
六、区分 invalidate 与 requestLayout
invalidate 表示视觉内容改变,需要重新绘制,但尺寸不变。requestLayout 表示尺寸或布局关系可能改变,需要重新测量和布局。
进度值、颜色变化通常只需 invalidate;文字长度导致控件期望尺寸改变时,才需要 requestLayout。无条件同时调用两者会增加不必要的布局工作。
七、实现平滑动画
动画每帧更新展示值并触发重绘,不应每帧重建对象。
fun animateTo(target: Float) {
progressAnimator?.cancel()
progressAnimator = ValueAnimator.ofFloat(
currentState.progress,
target.coerceIn(0f, 1f)
).apply {
addUpdateListener { animator ->
currentState = currentState.copy(
progress = animator.animatedValue as Float
)
invalidate()
}
start()
}
}
override fun onDetachedFromWindow() {
progressAnimator?.cancel()
progressAnimator = null
super.onDetachedFromWindow()
}
控件离开窗口时停止动画,避免无效刷新。动画时长和插值器应复用项目现有动效规范。
八、支持状态与可访问性
如果控件本身可点击,要实现 performClick。如果图形表达了进度或状态,还应提供可访问性文本,让读屏用户获得等价信息。
配置变化后的业务状态通常由页面模型重新传入。只有控件包含无法从外部恢复的临时交互状态时,才需要实现实例状态保存。
九、兼容性与测试
绘制 API、混合模式和硬件加速行为在不同系统版本可能存在差异。使用新能力前必须确认项目最低兼容版本,优先沿用已有兼容实现。
测试应覆盖:
wrap_content、精确尺寸和受限尺寸。- 不同密度与横竖屏。
- 零进度、满进度和边界输入。
- 长文本、空文本和多语言。
- 列表快速复用及动画中途移除。
- 深色主题与可访问性读取。
总结
稳定的自定义 View 需要正确遵守测量约束,在尺寸变化时缓存几何信息,并让 onDraw 只承担绘制。区分重绘与重新布局、及时停止动画,再补齐密度和可访问性处理,才能让控件既流畅又易于复用。
- 点赞
- 收藏
- 关注作者
评论(0)