跳到主要内容

自定义 View

问题

如何自定义一个 Android View?onMeasureonLayoutonDraw 分别做什么?

答案

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 的情况。如果不重写 onMeasurewrap_contentmatch_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 → 使用 specSize
  • AT_MOST → 使用 min(desiredSize, specSize)
  • UNSPECIFIED → 使用 desiredSize

可以用 View.resolveSize(desiredSize, measureSpec) 简化处理,它内部就是按上述逻辑实现的。

Q2: invalidate()postInvalidate() 的区别?

答案

  • invalidate():在主线程调用,标记 View 需要重绘
  • postInvalidate():在子线程调用,内部通过 Handler post 到主线程执行 invalidate

Q3: 如何实现圆形头像?

答案

常见方案:

  1. BitmapShader:给 Paint 设置 BitmapShader,然后 canvas.drawCircle()
  2. Xfermode:先画圆形遮罩,用 PorterDuff.Mode.SRC_IN 混合原图
  3. ClipPathcanvas.clipPath(circlePath) 裁剪画布(有锯齿问题)
  4. Glide / Coil 的圆形变换(推荐实际项目使用)

BitmapShader 方案性能最好,无锯齿。

Q4: View 的 draw 方法绘制顺序是什么?

答案

View.draw() 的绘制顺序(6 步):

  1. 绘制背景 drawBackground
  2. 保存 Canvas 图层(为 fading edge 准备)
  3. 绘制内容 onDraw()
  4. 绘制子 View dispatchDraw()
  5. 绘制 fading edge
  6. 绘制前景/装饰(滚动条等)onDrawForeground()

Q5: 什么是硬件加速?对自定义 View 有什么影响?

答案

硬件加速使用 GPU 绘制 View,默认开启。优点是绘制更快,但部分 Canvas API 不支持硬件加速(如 canvas.drawPicture()、某些 Xfermode)。可以通过 setLayerType(LAYER_TYPE_SOFTWARE, null) 关闭单个 View 的硬件加速。

相关链接