自定义 View
问题
如何自定义一个 Android View?onMeasure、onLayout、onDraw 分别做什么?
答案
1. 自定义 View 的三种方式
| 方式 | 说明 | 示例 |
|---|---|---|
| 继承 View | 完全自绘 | 圆形进度条、图表 |
| 继承已有控件 | 扩展功能 | 圆角 ImageView、自动换行 TextView |
| 继承 ViewGroup | 自定义布局 | 流式布局 FlowLayout |
2. 三大流程详解
onMeasure(测量)
class CircleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var radius = 100f
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredSize = (radius * 2 + paddingLeft + paddingRight).toInt()
val width = resolveSize(desiredSize, widthMeasureSpec)
val height = resolveSize(desiredSize, heightMeasureSpec)
setMeasuredDimension(width, height)
}
}
注意
自定义 View 必须处理 wrap_content 的情况。如果不重写 onMeasure,wrap_content 和 match_parent 效果一样(都是父容器大小)。
onLayout(布局 - ViewGroup 专用)
class SimpleFlowLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var currentX = paddingLeft
var currentY = paddingTop
var lineHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility == GONE) continue
// 超出宽度则换行
if (currentX + child.measuredWidth > width - paddingRight) {
currentX = paddingLeft
currentY += lineHeight
lineHeight = 0
}
// 确定子 View 的位置
child.layout(currentX, currentY,
currentX + child.measuredWidth,
currentY + child.measuredHeight)
currentX += child.measuredWidth
lineHeight = maxOf(lineHeight, child.measuredHeight)
}
}
}
onDraw(绘制)
class CircleProgressView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
// 背景画笔
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.LTGRAY
style = Paint.Style.STROKE
strokeWidth = 20f
}
// 进度画笔
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.STROKE
strokeWidth = 20f
strokeCap = Paint.Cap.ROUND // 圆角端点
}
// 文字画笔
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 48f
textAlign = Paint.Align.CENTER
}
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 100f)
invalidate() // 触发重绘
}
override fun onDraw(canvas: Canvas) {
val cx = width / 2f
val cy = height / 2f
val r = minOf(cx, cy) - 20f
val rect = RectF(cx - r, cy - r, cx + r, cy + r)
// 1. 绘制背景圆
canvas.drawArc(rect, 0f, 360f, false, bgPaint)
// 2. 绘制进度弧
val sweepAngle = 360f * progress / 100f
canvas.drawArc(rect, -90f, sweepAngle, false, progressPaint)
// 3. 绘制中间文字
val text = "${progress.toInt()}%"
val fontMetrics = textPaint.fontMetrics
val textY = cy - (fontMetrics.ascent + fontMetrics.descent) / 2
canvas.drawText(text, cx, textY, textPaint)
}
}
3. 自定义属性
<!-- res/values/attrs.xml -->
<declare-styleable name="CircleProgressView">
<attr name="progressColor" format="color" />
<attr name="progressWidth" format="dimension" />
<attr name="maxProgress" format="integer" />
</declare-styleable>
// 在构造函数中读取属性
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView)
val progressColor = ta.getColor(R.styleable.CircleProgressView_progressColor, Color.BLUE)
val progressWidth = ta.getDimension(R.styleable.CircleProgressView_progressWidth, 20f)
ta.recycle() // 必须回收
progressPaint.color = progressColor
progressPaint.strokeWidth = progressWidth
}
<!-- 使用 -->
<com.example.CircleProgressView
android:layout_width="200dp"
android:layout_height="200dp"
app:progressColor="@color/primary"
app:progressWidth="16dp" />
4. Canvas 常用 API
| 方法 | 说明 |
|---|---|
drawLine() | 画线 |
drawRect() | 画矩形 |
drawRoundRect() | 画圆角矩形 |
drawCircle() | 画圆 |
drawArc() | 画弧/扇形 |
drawPath() | 画路径 |
drawBitmap() | 画位图 |
drawText() | 画文字 |
save() / restore() | 保存/恢复画布状态 |
translate() / rotate() / scale() | 画布变换 |
常见面试问题
Q1: 自定义 View 的 onMeasure 中如何处理 wrap_content?
答案:
在 onMeasure 中使用 MeasureSpec 判断模式:
EXACTLY→ 使用 specSizeAT_MOST→ 使用min(desiredSize, specSize)UNSPECIFIED→ 使用 desiredSize
可以用 View.resolveSize(desiredSize, measureSpec) 简化处理,它内部就是按上述逻辑实现的。
Q2: invalidate() 和 postInvalidate() 的区别?
答案:
invalidate():在主线程调用,标记 View 需要重绘postInvalidate():在子线程调用,内部通过 Handler post 到主线程执行 invalidate
Q3: 如何实现圆形头像?
答案:
常见方案:
- BitmapShader:给 Paint 设置 BitmapShader,然后
canvas.drawCircle() - Xfermode:先画圆形遮罩,用
PorterDuff.Mode.SRC_IN混合原图 - ClipPath:
canvas.clipPath(circlePath)裁剪画布(有锯齿问题) - Glide / Coil 的圆形变换(推荐实际项目使用)
BitmapShader 方案性能最好,无锯齿。
Q4: View 的 draw 方法绘制顺序是什么?
答案:
View.draw() 的绘制顺序(6 步):
- 绘制背景
drawBackground - 保存 Canvas 图层(为 fading edge 准备)
- 绘制内容
onDraw() - 绘制子 View
dispatchDraw() - 绘制 fading edge
- 绘制前景/装饰(滚动条等)
onDrawForeground()
Q5: 什么是硬件加速?对自定义 View 有什么影响?
答案:
硬件加速使用 GPU 绘制 View,默认开启。优点是绘制更快,但部分 Canvas API 不支持硬件加速(如 canvas.drawPicture()、某些 Xfermode)。可以通过 setLayerType(LAYER_TYPE_SOFTWARE, null) 关闭单个 View 的硬件加速。