自定义视图最重要的部分是外观。绘制自定义视图可能很简单,也可能很复杂,具体取决于应用的需求。
而绘制自定义视图最重要的一步是替换 onDraw()
方法:
#1. 替换 onDraw
onDraw() 的参数是一个 Canvas 对象,视图可以使用该对象绘制其自身。Canvas 类定义了绘制文本、线条、位图和许多其他图形基元的方法。您可以在 onDraw() 中使用这些方法创建自定义界面。
⚠️ 在调用任何绘制方法之前,必须先创建 Paint 对象。
#2. 创建绘制对象
android.graphics 框架将绘制分为两个方面:
需要绘制什么,由 Canvas 处理
如何绘制,由 Paint 处理。
例如,Canvas 提供绘制线条的方法,Paint 则提供定义线条颜色的方法。Canvas 具有绘制矩形的方法,Paint 则定义是在矩形中填充颜色还是留空。
简而言之,Canvas 定义您可以在屏幕上绘制的形状,Paint 则定义您绘制的每个形状的颜色、样式和字体等。
因此,在绘制任何内容之前,您需要创建一个或多个Paint对象。如下示例:
private val textPaint = Paint(ANTI_ALIAS_FLAG).apply {
color = textColor
if (textHeight == 0f) {
textHeight = textSize
} else {
textSize = textHeight
}
}
private val piePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
textSize = textHeight
}
private val shadowPaint = Paint(0).apply {
color = 0x101010
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}
⚠️ ⚠️ ⚠️ 提前创建对象是一项重要的优化措施。视图会非常频繁地重新绘制,并且许多绘制对象的初始化都需要占用很多资源。
所以:在 onDraw() 方法内创建绘制对象会显著降低性能并使界面显得卡顿。
#3. 处理布局事件
为了正确绘制自定义视图,您需要知道它的大小。复杂的自定义视图通常需要根据其在屏幕上所占区域的大小和形状执行多次布局计算。不要妄自假设视图在屏幕上的大小。即使只有一个应用使用您的视图,该应用也需要处理纵向和横向模式下的不同屏幕尺寸、多种屏幕密度和各种宽高比。
1. onSizeChanged()
尽管 View 提供多种测量处理方法,大部分方法都不需要被替换。如果您的视图不需要对其大小进行特殊控制,您只需替换一个方法,即 onSizeChanged()。如下示例:
// 视图不需要对其大小进行特殊控制,您只需替换一个方法,即 onSizeChanged()
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
val xMid = (viewWidth / 2)
val yMid = (viewHeight / 2)
val leftOffset = (xMid - innerWidth / 2).toInt().toFloat()
val topOffset = (yMid - innerHeight / 2).toInt().toFloat()
}
系统会在首次为您的视图分配大小时调用 onSizeChanged(),如果视图大小由于任何原因而改变,系统会再次调用该方法。
⚠️ ⚠️ ⚠️ 请在 onSizeChanged() 中计算位置、尺寸以及其他与视图大小相关的任何值,而不要在每次绘制(onDraw)时都重新计算。
为视图指定大小时,布局管理器会假定其大小包含视图的所有内边距。您必须在计算视图大小时处理内边距值。以下是PieChart.onSizeChanged()的代码段,其中说明了如何执行此操作:
// Account for padding
var xpad = (paddingLeft + paddingRight).toFloat()
val ypad = (paddingTop + paddingBottom).toFloat()
// Account for the label
if (showText) xpad += textWidth
val ww = w.toFloat() - xpad
val hh = h.toFloat() - ypad
// Figure out how big we can make the pie.
val diameter = Math.min(ww, hh)
2. onMeasure()
如果您需要更精细地控制视图的布局参数,请实现 onMeasure()。此方法的参数是 View.MeasureSpec 值,用于告诉您视图的父视图希望您的视图有多大,以及该大小是硬性最大值还是只是建议值。作为优化措施,这些值以打包整数形式存储,您可以使用 View.MeasureSpec 的静态方法解压缩每个整数中存储的信息。
以下是 onMeasure() 的一个实现示例:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Try for a width based on our minimum
val minw: Int = paddingLeft + paddingRight + suggestedMinimumWidth
val w: Int = View.resolveSizeAndState(minw, widthMeasureSpec, 1)
// Whatever the width ends up being, ask for a height that would let the pie
// get as big as it can
val minh: Int = View.MeasureSpec.getSize(w) - textWidth.toInt() + paddingBottom + paddingTop
val h: Int = View.resolveSizeAndState(
View.MeasureSpec.getSize(w) - textWidth.toInt(),
heightMeasureSpec,
0
)
setMeasuredDimension(w, h)
}
在此代码中,有三点需要注意:
- 计算时会考虑视图的内边距。如前文所述,这由视图负责计算。
- 辅助方法 resolveSizeAndState() 用于创建最终的宽度和高度值。该辅助程序通过将视图所需大小与传递到 onMeasure() 的规格进行比较,返回合适的 View.MeasureSpec 值。
- onMeasure() 没有返回值。而是由方法通过调用 setMeasuredDimension() 传达其结果。必须调用此方法。如果省略此调用,View 类将抛出运行时异常。
#4. 绘制!
创建好对象并定义了测量代码后,您可以实现 onDraw()。每个视图以不同方式实现 onDraw(),但大多数视图共享一些常见的操作:
- 使用 drawText() 绘制文本。通过调用 setTypeface() 指定字体,并通过调用 setColor() 指定文本颜色。
- 使用 drawRect()、drawOval() 和 drawArc() 绘制基元形状。通过调用setStyle()更改形状的填充和/或轮廓。
- 使用 Path 类绘制更复杂的形状。通过将线条和曲线添加到 Path 对象以定义形状,然后使用 drawPath() 绘制形状。与基元形状一样,路径可以只描绘轮廓或只进行填充,也可以两者兼具,具体取决于 setStyle()。
- 通过创建 LinearGradient 对象定义渐变填充。调用 setShader() 可在填充的形状上使用 LinearGradient。
- 使用 drawBitmap() 绘制位图。
例如,以下代码绘制了 PieChart。它组合使用了文本、线条和形状:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
// Draw the shadow
drawOval(shadowBounds, shadowPaint)
// Draw the label text
drawText(data[mCurrentItem].mLabel, textX, textY, textPaint)
// Draw the pie slices
data.forEach {
piePaint.shader = it.mShader
drawArc(bounds,
360 - it.endAngle,
it.endAngle - it.startAngle,
true, piePaint)
}
// Draw the pointer
drawLine(textX, pointerY, pointerX, pointerY, textPaint)
drawCircle(pointerX, pointerY, pointerSize, mTextPaint)
}
}
#5.优化自定义视图
拥有一个设计精良的视图,能够响应不同的手势和切换状态,接下来要确保这个视图快速运行。为避免播放过程中出现界面响应缓慢或卡顿,需确保动画始终以每秒 60 帧的速度运行。
精简代码,降低调用频率:
- ⚠️ 为了提高视图的运行速度,可从频繁调用的例程中剔除不必要的代码。首先处理
onDraw()
,这将为您带来最明显的成效。尤其是应剔除onDraw()
中的分配,因为分配可能会引起垃圾回收,从而造成卡顿。请在初始化期间或动画之间分配对象。切勿在动画运行期间进行分配。- 除了精简
onDraw()
之外,还要确保尽可能降低调用它的频率。对onDraw()
的大多数调用是由对invalidate()
的调用引起的,因此请避免对invalidate()
的不必要调用。- 另一种成本非常高昂的操作是遍历布局。每当视图调用
requestLayout()
时,Android 界面系统都需要遍历整个视图层次结构,以确定每个视图所需的尺寸。如果发现有冲突的尺寸,可能需要多次遍历该层次结构。界面设计人员有时会创建由嵌套式ViewGroup
对象组成的深层次结构,以便让界面正常运行。这些深层视图层次结构会造成性能问题。因此请尽可能保持较浅的视图层次结构。- 如果您的界面较为复杂,可考虑编写自定义
ViewGroup
以设计布局。与内置视图不同,自定义视图可以针对子视图的尺寸和形状做出特定于应用的推断,从而避免遍历子视图以计算尺寸。PieChart 示例展示了如何扩展ViewGroup
以作为自定义视图的一部分。PieChart 包含子视图,但从不测量它们的尺寸。而是根据自己的自定义布局算法直接设置尺寸。
写到这里,我想到对于之前的一篇自定义视图:
里面实现的代码就有悖于下面这点:
“提前创建对象是一项重要的优化措施。视图会非常频繁地重新绘制,并且许多绘制对象的初始化都需要占用很多资源。在 onDraw() 方法内创建绘制对象会显著降低性能并使界面显得卡顿。”
大致贴一下错误代码块:
public override fun onDraw(canvas: Canvas) {
val width = canvas.width.toFloat()
val height = canvas.height.toFloat()
val xMid = width / 2
val yMid = height / 2
val leftOffset = xMid - innerWidth / 2
val topOffset = yMid - innerHeight / 2
val frame = RectF(
leftOffset,
topOffset,
leftOffset + innerWidth,
topOffset + innerHeight
)
// Draw the exterior (i.e. outside the framing rect) darkened
paint.color = maskColor
canvas.drawRect(0f, 0f, width, frame.top, paint)
canvas.drawRect(
0f,
frame.top,
frame.left,
frame.bottom + 1,
paint
)
... // 省略了部分代码
}
以上有在 onDraw() 方法内创建对象是不可取的,这里可以优化:
// 视图不需要对其大小进行特殊控制,您只需替换一个方法,即 onSizeChanged()
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
viewWidth = width.toFloat()
viewHeight = height.toFloat()
val xMid = (viewWidth / 2)
val yMid = (viewHeight / 2)
val leftOffset = (xMid - innerWidth / 2).toInt().toFloat()
val topOffset = (yMid - innerHeight / 2).toInt().toFloat()
frame.set(
leftOffset,
topOffset,
leftOffset + innerWidth.toInt(),
topOffset + innerHeight.toInt()
)
}
// 在 onDraw() 方法内创建绘制对象会显著降低性能并使界面显得卡顿。
public override fun onDraw(canvas: Canvas) {
// Draw the exterior (i.e. outside the framing rect) darkened
// Draw the exterior (i.e. outside the framing rect) darkened
paint.color = maskColor
canvas.drawRect(0f, 0f, width, frame.top, paint)
canvas.drawRect(
0f,
frame.top,
frame.left,
frame.bottom + 1,
paint
)
... // 省略了部分代码
}
具体代码详见(已优化):【CustomView】扫码中的扫描框(ViewfinderView)-简单实现
这里总结下注意点:
⚠️ 提前创建对象是一项重要的优化措施。视图会非常频繁地重新绘制,并且许多绘制对象的初始化都需要占用很多资源。
⚠️ 请在 onSizeChanged()/onMeasure() 中计算位置、尺寸以及其他与视图大小相关的任何值,而不要在每次绘制(onDraw)时都重新计算。
⚠️ 在 onDraw() 方法内创建绘制对象会显著降低性能并使界面显得卡顿。