绘制流程
View 的绘制流程是从 ViewRootImpl 的 performTraversals 方法开始的,从上到下遍历整个视图树,每个 View 控件负责绘制自己,而 ViewGroup 还需要负责通知子 View 进行绘制操作。performTraversals 会依次调用 performMeasure,performLayout 和 performDraw 三个方法,分别完成 DecorView 的测量,布局和绘制三大流程。
DecorView 的加载
ViewRoot 对应于 ViewRootImpl 类,它是连接 WindowManager 和 DecorView 的纽带,View 的三大流程均是通过 ViewRoot 来完成的。在 ActivityThread 中,当 Activity 对象创建后,会将 DecorView 添加到 Window 中,同时会创建 ViewRootImpl 对象,并将 ViewRootImpl 对象和 DecorView 建立关联。
测量过程
我们首先要明白 MeasureSpec,它表示的是一个32位的整形值,它的高2位表示测量模式 SpecMode,低30位表示某种测量模式下的规格大小 SpecSize 。
测量模式有三类,分别是:
- EXACTLY:精确测量模式,视图宽高指定为 match_parent 或具体数值时生效。
- AT_MOST :最大值测量模式,当视图的宽高指定为 wrap_content 时生效,子视图的尺寸可以是不超过父视图允许的最大尺寸的任何尺寸。
- UNSPECIFIED:不指定测量模式,父视图没有限制子视图的大小,子视图可以是想要的任何尺寸,通常用于系统内部,应用开发中很少用到。
直接继承 View 的控件需要重写 onMeasure 方法并设置 wrap_content 时的自身大小,否则在布局中使用 wrap_content 就相当于使用 match_parent 。
测量过程是从 performMeasure 开始的:
- 遍历测量 ViewGroup 中所有的 View,在此过程中,当 View 的可见性处于 GONE 时,则不对其进行测量。
- 测量某个指定的 View,根据父容器的 MeasureSpec 和子 View 的 LayoutParams 等信息计算子 View 的 MeasureSpec。
- 不同的 ViewGroup 子类有不同的布局特性,这导致它们的测量细节各不相同,如果需要自定义测量过程,则子类可以重写 onMeasure 方法,通过 setMeasureDimension 方法来设置 View 的测量宽高。
- 如果 View 没有重写 onMeasure 方法,则会默认调用 getDefaultSize 来获得 View 的宽高。这里会进行判断,如果 View 没有设置背景,那么返回 minWidth 这个属性所指定的值,如果 View 设置了背景,则返回 minWidth 和背景的最小宽度这两者中的最大值。
由于 View 的测量过程和 Activity 的生命周期方法不是同步执行的,如果 View 还没有测量完毕,那么获得的宽高就是 0,所以在 onCreate,onStart 和 onResume 中均无法正确得到某个 View 的宽高信息,而在 post 方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成时放在一个等待队列里,等视图绘制完成后再去执行队列里面的线程,所以在 post 里面可以获取 View 的宽高。
view.post {
val width = view.measuredWidth
val height = view.measuredHeight
}
布局过程
布局过程是从 performLayout 开始的:
- 执行 layout 方法,通过 setFrame 方法来设定 View 的四个顶点的位置,即 View 在父容器中的位置。
- 执行 onLayout 方法,它是一个空方法,子类如果是 ViewGroup 类型,则重写这个方法,实现 ViewGroup 中所有 View 控件布局流程。
- 当 ViewGroup 的位置确定后,会在 onLayout 方法中遍历所有子 View 并调用子 View 的 layout 方法,确定子 View 自己的位置。
绘制过程
绘制过程是从 performDraw 开始的,流程是:
- 绘制 View 的背景
- 绘制 View 的内容
- 绘制 View 的子 View
- 绘制装饰,如滚动条等
具体实现方式
- 继承系统 View :继承 TextView 等系统控件,在此基础上进行扩展。
- 继承系统 ViewGroup :继承 LinearLayout 等系统控件,在此基础上进行扩展。
- 继承 View :不复用系统控件逻辑,继承 View 进行功能定义。
- 继承 ViewGroup :不复用系统控件逻辑,继承 ViewGroup 进行功能定义。
- 自定义组合控件 :多个控件组合成为一个新的控件,方便复用。
坐标系
以屏幕左上角作为原点,原点向右是 X 轴的正轴,向下是 Y 轴正轴。
所以,宽高计算规则为
val width = right - left
val height = bottom - top
构造函数
class MyView : View {
//创建对象的时候用到
constructor(context: Context?) : super(context) {}
//在 xml 布局文件中使用时自动调用
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {}
//不会自动调用,如果有默认 style 时,在第二个构造函数中调用
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
}
//API>21 时才会用到,不会自动调用,如果有默认 style 时,在第二个构造函数中调用
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes) {
}
}
自定义属性
<declare-styleable name="MyView">
<attr name="viewAttr1" format="string" />
<attr name="viewAttr2" format="string" />
</declare-styleable>
class MyView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
init {
val typeArray = context?.obtainStyledAttributes(attrs, R.styleable.MyView)
val viewAttr1 = typeArray?.getString(R.styleable.MyView_viewAttr1)
val viewAttr2 = typeArray?.getString(R.styleable.MyView_viewAttr2)
typeArray?.recycle()
}
}
<com.example.openglapp.MyView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:viewAttr1="hello"
app:viewAttr2="world" />
继承系统 View
这种方式会复用系统的逻辑,大多数情况下希望复用系统的 onMeasure 和 onLayout 流程,所以我们通常只需重写 onDraw 方法。
举个例子,我们继承系统控件 AppCompatTextView ,为其绘制背景。
class MyView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) {
private val paint: Paint = Paint()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
paint.color = context.getColor(R.color.blue)
val rect = Rect(0, 0, width, height)
//绘制背景
canvas?.drawRect(rect, paint)
}
}
继承系统 ViewGroup
这里举个简单的例子,继承 LinearLayout,然后往里面加入 Button 和 TextView 。
class CusLinearLayout(context: Context) : LinearLayout(context) {
private val button: Button = Button(context)
private val textView = TextView(context)
init {
orientation = VERTICAL
val layoutParam =
LayoutParams(DensityUtils.dp2px(context, 300f), DensityUtils.dp2px(context, 300f))
button.layoutParams = layoutParam
textView.layoutParams = layoutParam
textView.text = "welcome to pay attention to me"
button.text = "button"
button.setOnClickListener {
Toast.makeText(context, "click", Toast.LENGTH_SHORT).show()
}
addView(button)
addView(textView)
}
}
继承 View
这种方式因为不复用系统控件的逻辑,所以 onDraw 和 onMeasure 方法都要重写。
class MyView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint: Paint = Paint()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
paint.color = Color.RED
val viewWidth = width - paddingStart - paddingEnd
val viewHeight = height - paddingTop - paddingBottom
canvas?.drawRect(
paddingStart.toFloat(),
paddingTop.toFloat(), (viewWidth - paddingStart).toFloat(),
(viewHeight + paddingTop).toFloat(), paint
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
//该方法用来设置 View 的宽高
setMeasuredDimension(
DensityUtils.dp2px(context, 300f),
DensityUtils.dp2px(context, 300f)
)
} else if (widthMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(DensityUtils.dp2px(context, 300f), heightSize)
} else if (heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSize, DensityUtils.dp2px(context, 300f))
}
}
}
顺便也贴上 dp 和 px 的转换工具类
class DensityUtils {
companion object {
fun px2dp(context: Context, pxValue: Float): Int {
val scale = context.resources.displayMetrics.density
return (pxValue / scale + 0.5f).toInt()
}
fun dp2px(context: Context, dpValue: Float): Int {
val scale = context.resources.displayMetrics.density
return (dpValue * scale + 0.5f).toInt()
}
}
}
继承 ViewGroup
对于自定义 ViewGroup,最典型的例子就是流式布局了,这里简单实现一下吧 ~
class FlowLayout : ViewGroup {
// 记录每个 View 的位置
private val childList = ArrayList<ChildPosition>()
constructor(context: Context) : super(context)
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
constructor(context: Context, attributeSet: AttributeSet, defStyle: Int) : super(
context,
attributeSet,
defStyle
)
// 子 View 的位置
data class ChildPosition(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int
)
/**
* 让 ViewGroup 支持 margin 属性
*/
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 获取宽高
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 流式布局的最终宽高
var layoutWidth = 0
var layoutHeight = 0
// 记录每一行的宽度和高度
var lineWidth = 0
var lineHeight = 0
childList.clear()
// 循环遍历每个子 View
for (i in 0 until childCount) {
val child = getChildAt(i)
// 测量子 View 的宽高
measureChild(child, widthMeasureSpec, heightMeasureSpec)
val lp = child.layoutParams as MarginLayoutParams
// 子 View 所占的宽度和高度
val childWidth =
child.measuredWidth + lp.leftMargin + lp.rightMargin
val childHeight =
child.measuredHeight + lp.topMargin + lp.bottomMargin
if (lineWidth + childWidth > widthSize - paddingLeft - paddingRight) {
// 如果当前所占的宽度大于总宽度,则换行
layoutWidth = Math.max(layoutWidth, lineWidth)
// 高度叠加
layoutHeight += lineHeight
// 重置行宽高为第一个 View 的宽高
lineWidth = childWidth
lineHeight = childHeight
childList.add(
ChildPosition(
paddingLeft + lp.leftMargin,
paddingTop + layoutHeight + lp.topMargin,
paddingLeft + childWidth + lp.rightMargin,
paddingTop + layoutHeight + childHeight - lp.bottomMargin
)
)
} else { //不换行
childList.add(
ChildPosition(
paddingLeft + lineWidth + lp.leftMargin,
paddingTop + layoutHeight + lp.topMargin,
paddingLeft + lineWidth + childWidth - lp.rightMargin,
paddingTop + layoutHeight + childHeight - lp.bottomMargin
)
)
// 叠加子 View 宽度得到新宽度
lineWidth += childWidth
lineHeight = Math.max(lineHeight, childHeight)
}
if (i == childCount - 1) { // 最后一个子 View
layoutWidth = Math.max(layoutWidth, lineWidth)
layoutHeight += lineHeight
}
}
setMeasuredDimension(
if (widthMode == MeasureSpec.EXACTLY) widthSize else width + paddingLeft + paddingRight,
if (heightMode == MeasureSpec.EXACTLY) heightSize else height + paddingTop + paddingBottom
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
childList.forEachIndexed { index, it ->
getChildAt(index).layout(it.left, it.top, it.right, it.bottom)
}
}
}
自定义组合控件
最常见应用场景就是自定义标题栏了,那就以此为例。
自定义属性
<declare-styleable name="MyToolbar">
<attr name="left_button_visible" format="boolean" />
<attr name="right_button_visible" format="boolean" />
<attr name="title_text" format="string" />
<attr name="right_button_text" format="string" />
<attr name="left_button_text" format="string" />
</declare-styleable>
标题栏的布局如下
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<Button
android:id="@+id/toolbar_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginStart="7dp"
android:minWidth="45dp"
android:minHeight="45dp"
android:textSize="14sp" />
<TextView
android:id="@+id/toolbar_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:singleLine="true"
android:textSize="17sp" />
<Button
android:id="@+id/toolbar_right"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginEnd="7dp"
android:minWidth="45dp"
android:minHeight="45dp"
android:textSize="14sp" />
</merge>
自定义标题栏
class MyToolbar(context: Context?, attrs: AttributeSet?) : RelativeLayout(context, attrs) {
private var leftBtn: Button? = null
private var rightBtn: Button? = null
private var title: TextView? = null
init {
inflate(context, R.layout.toolbar, this)
leftBtn = findViewById(R.id.toolbar_left)
rightBtn = findViewById(R.id.toolbar_right)
title = findViewById(R.id.toolbar_title)
val attributes = context?.obtainStyledAttributes(attrs, R.styleable.MyToolbar)
attributes?.let {
//设置左边按钮是否显示
val leftVisible = it.getBoolean(R.styleable.MyToolbar_left_button_visible, true)
leftBtn?.visibility = if (leftVisible) View.VISIBLE else View.GONE
//设置左边按钮的文字
val leftText = it.getString(R.styleable.MyToolbar_left_button_text)
if (!TextUtils.isEmpty(leftText)) {
leftBtn?.text = leftText
}
//设置标题
val titleText = it.getString(R.styleable.MyToolbar_title_text)
if (!TextUtils.isEmpty(titleText)) {
title?.text = titleText
}
//设置右边按钮是否显示
val rightVisible = it.getBoolean(R.styleable.MyToolbar_right_button_visible, true)
rightBtn?.visibility = if (rightVisible) View.VISIBLE else View.GONE
//设置右边按钮文字
val rightText = it.getString(R.styleable.MyToolbar_right_button_text)
if (!TextUtils.isEmpty(rightText)) {
rightBtn?.text = rightText
}
}
}
}
使用
<com.xzj.normalapp.MyToolbar
android:layout_width="match_parent"
android:layout_height="45dp"
app:left_button_text="left"
app:left_button_visible="true"
app:right_button_text="right"
app:right_button_visible="true"
app:title_text="标题" />