自己实现Android View布局流程
相关阅读:尝试自己实现Android View Touch事件分发流程
Android View的布局以ViewRootImpl为起点,开启整个View树的布局过程,而布局过程本身分为测量(measure)和布局(layout)两个部分,以View树本身的层次结构递归布局,确定View在界面中的位置。
下面尝试通过最少的代码,自己实现这套机制,注意下面类均为自定义类,未使用Android 源码中的同名类。
MeasureSpec
首先定义MeasureSpec,它是描述父布局对子布局约束的类,在Android源码中它是一个int值,通过位运算获取mode和size,这里我们为了方便起见实现为一个类:
class MeasureSpec(var mode: Int = UNSPECIFIED, var size: Int = 0) { companion object { const val UNSPECIFIED = 0 const val EXACTLY = 1 const val AT_MOST = 2 } }
同样包含三种mode,分别表示父布局对子布局没有限制,父布局对子布局要求为固定值,父布局对子布局有最大值限制。
LayoutParam
LayoutParam在源码中定义在各种ViewGroup的内部,是静态内部类,用于在该ViewGroup布局中的子View中使用,这里我们定义为顶层类,并且只包含宽高两种属性,对应于xml文件中的layout_width和layout_height属性。同样定义MATCH_PARENT与WRAP_CONTENT。
class LayoutParam(var width: Int, var height: Int) { companion object { const val MATCH_PARENT = -1 const val WRAP_CONTENT = -2 } }
下面我们实现View与ViewGroup。
View
(1)处我们定义的View的坐标,和源码中一致,这里表示的是相对于父View的坐标,与上篇View相关文章尝试自己写Android View Touch事件分发中不同,那篇的View的坐标是绝对坐标。
(2)处定义了padding,(3)处表示measure过程的测量宽高,(4)为布局文件中指定的layoutParam
这些属性,总结下来就是(2)(4)由开发者在布局中指定,(3)通过测量过程由View自己测得,(1)通过布局过程最终确定,也就是我们的目的所在,包括(3)存在的意义也是为了确定(4)中的值。
下面开始编写测量过程,虽然这些代码都是重写的,进行了大量的简化,但整体流程依然和源码是一致的,能够更清晰的理解Android的View树的布局是如何实现的。
(5)处measure直接调用onMeasure开始测量过程,而onMeasure这里简单直接设置了MeasureSpec中父ViewGroup中的限制值作为测量值就结束了自己的测量过程(6),因为onMeasure是需要继承使用的,不同View的测量方式并不相同,所以这里简单处理。
(7)处开始布局过程,首先调用setFrame方法将坐标保存(8),并调用onLayout回调,这里为空实现(9)。
至此View的布局相关方法实现完毕。
open class View { open var tag = javaClass.simpleName var left = 0 var right = 0 var top = 0 var bottom = 0//1 var paddingLeft = 0 var paddingRight = 0 var paddingTop = 0 var paddingBottom = 0//2 var measuredWidth = 0 var measuredHeight = 0//3 var layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT )//4 fun measure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) { onMeasure(widthMeasureSpec, heightMeasureSpec) }//5 open fun onMeasure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) { setMeasuredDimension(widthMeasureSpec.size, heightMeasureSpec.size)//6 } fun setMeasuredDimension(measuredWidth: Int, measuredHeight: Int) { this.measuredWidth = measuredWidth this.measuredHeight = measuredHeight } fun layout(l: Int, t: Int, r: Int, b: Int) { val changed = setFrame(l, t, r, b)//8 onLayout(changed, l, t, r, b) }//7 private fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { var changed = false if (l != left || t != top || r != right || b != bottom) { left = l top = t right = r bottom = b changed = true } println("$tag = L: $l, T: $t, R: $r, B: $b") return changed } open fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}//9 fun resolveSize(size: Int, measureSpec: MeasureSpec): Int { return when (measureSpec.mode) { MeasureSpec.EXACTLY -> measureSpec.size MeasureSpec.AT_MOST -> minOf(size, measureSpec.size) else -> size } }//10 }
ViewGroup
下面我们实现ViewGroup,只有一个抽象方法,即将View中的onLayout空实现声明为抽象的,即要求子类自行实现布局算法,而ViewGroup本身不允许当做布局使用。
abstract class ViewGroup(vararg val children: View) : View() { abstract override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) }
如此,整个Android的View层次结构的骨架已经搭建完成了,在源码中,对于View的布局方面,主要也就干了这么点事情。其他各种各样的View与ViewGroup均是通过继承,实现各自的测量算法(即子View实现onMeasure),和布局算法(即子ViewGroup实现onMeasure与onLayout)。
下面我们依托这个框架各实现一个View与ViewGroup。
Text
下面我们实现一个TextView,这里因为我们只是为了说明View测量的原理,因此只支持两个属性text与textSize。
只需实现onMeasure即可,将左右padding相加,并加上字符串长度与字号的乘积作为宽(1),将上下padding相加,并加上字号作为高,当然这里我们只是简单这样计算示意,实际计算TextView长宽肯定不能这样来算。
如此算得的长宽就是Text自身理想的长宽,但是,还需要施加上父布局的限制才行,即MeasureSpec,这里即调用resolveSize,将限制与理想值传入即可(2)。
resolveSize定义在View节的(10)处,里面处理逻辑即,当限制为固定值时,测量值取限制值,当限制上限时,测量值为限制值与理想值取小,当限制为不限时,取理想值。
如此,整个TextView的测量过程完毕。对于布局过程,由于,layout方法内已经设置了自身的坐标,onLayout保持空实现即可,并不需要重写。
class Text(private val text: String, private val textSize: Int = 10) : View() { override var tag: String = "Text($text)" override fun onMeasure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) { val width = paddingLeft + paddingRight + text.length * textSize//1 val height = paddingTop + paddingBottom + textSize setMeasuredDimension( resolveSize(width, widthMeasureSpec),//2 resolveSize(height, heightMeasureSpec) ) } }
Column
下面定义一个类似于orientation为vertical的LinearLayout来说明ViewGroup的布局过程。
对于源码中的LinearLayout,子布局中使用的layout_开头的布局属性,对应的是LinearLayout内部类中的LayoutParams,而这里我们直接使用上面已经定义的LayoutParams,相当于LinearLayout中有部分功能并未实现,比如layout_margin,layout_weight,layout_gravity,这里我们简单处理。
在onMeasure中,要做两件事,第一件事是向父类View一样测量自己的长宽,即需要调用setMeasuredDimension;第二件事是对于每个子View,开始它们的测量,其实,第二件事本身就是第一件的前提,因为子View的测量没有结束的话,自己的长宽根本就无法确定。
(1)处在循环中调用子View的measure开启它们的测量过程,但需要传递给它们限制,即childWidthMeasureSpec和childHeightMeasureSpec,这里通过getChildMeasureSpec方法确定长与宽的限制(2),该方法在源码中是定义在ViewGroup中的。
(3)处该方法接收3个参数,spec为Column自身的受到的父View的限制,padding为测量到该View时,Column已经用完的大小(因为Column是要将View一个挨着一个排布的,肯定需要这个值),childDimension是开发者在布局文件中指定的layout_width或layout_height值。
因此spec有UNSPECIFIED,EXACTLY,AT_MOST三种类型,childDimension有MATCH_PARENT,WRAP_CONTENT和精确值3种类型,这些交织的情况都需要分别考虑。在源码中,将spec放在外层,childDimension放在内层,这里我们将childDimension放在放在外层(4),spec放在内层,实现更为简洁。
(5)当childDimension为MATCH_PARENT,只要忠实将限制mode传递下去即可,大小使用(6)处计算的剩余大小。
(6)当childDimension为WRAP_CONTENT,需限制mode设为AT_MOST,同样使用(6)处计算的剩余大小,但是需要考虑spec.mode为UNSPECIFIED的情况,需要将这种不限制给传递下去(7)。
(8)最后对应于childDimension为开发者指定精确值的情况,只要如实传递开发者指定值即可,不必考虑父布局限制。
如此就得到了(1)处传给各自View的限制,开始子View的测量,当前遍历到的子View测量完成后,需要获取测得的子View高度来更新已使用的高度值(9),因为Column是单行纵向排布的,usedWidth就不需要更新。但需要更新width值,作为Column本身的期望宽度。
(10)当遍历完成后,和上节Text一样,将resolveSize返回值传入setMeasuredDimension即可,如此就完成了Column的测量过程。
class Column(vararg children: View) : ViewGroup(*children) { override fun onMeasure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) { var usedHeight = paddingTop + paddingBottom val usedWidth = paddingLeft + paddingRight var width = 0 children.forEach { child -> val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, usedWidth, child.layoutParam.width) val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, usedHeight, child.layoutParam.height) child.measure(childWidthMeasureSpec, childHeightMeasureSpec)//1 usedHeight += child.measuredHeight//9 width = maxOf(width, child.measuredWidth) } setMeasuredDimension( resolveSize(width, widthMeasureSpec), resolveSize(usedHeight, heightMeasureSpec) )//10 } private fun getChildMeasureSpec( spec: MeasureSpec, padding: Int, childDimension: Int ): MeasureSpec {//3 val childWidthSpec = MeasureSpec() val size = spec.size - padding//6 when (childDimension) {//4 LayoutParam.MATCH_PARENT -> { childWidthSpec.mode = spec.mode childWidthSpec.size = size }//5 LayoutParam.WRAP_CONTENT -> { if (spec.mode == MeasureSpec.AT_MOST || spec.mode == MeasureSpec.EXACTLY) { childWidthSpec.mode = MeasureSpec.AT_MOST childWidthSpec.size = size } else if (spec.mode == MeasureSpec.UNSPECIFIED) { childWidthSpec.mode = MeasureSpec.UNSPECIFIED childWidthSpec.size = 0//7 } } else -> { childWidthSpec.mode = MeasureSpec.EXACTLY childWidthSpec.size = childDimension//8 } } return childWidthSpec }//2 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { var childTop = paddingTop children.forEach { child -> child.layout( paddingLeft, childTop, paddingLeft + child.measuredWidth, childTop + child.measuredHeight ) childTop += child.measuredHeight } } }
而对于onLayout方法,因为已经知道各子View的测量宽高,只需要在此遍历各子View,逐个设置坐标即可,Column本身的坐标设置已经在View中layout方法中实现。
如此整个类Android的布局重写完毕。
使用
下面验证我们代码:
fun main() { val page = Column( Text("Marshmallow").apply { layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT ) }, Text("Nougat").apply { layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT ) }, Text("Oreo").apply { layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT ) paddingTop = 10 paddingBottom = 10 }, Text("Pie").apply { layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT ) } ).apply { layoutParam = LayoutParam( LayoutParam.WRAP_CONTENT, LayoutParam.WRAP_CONTENT ) paddingLeft = 10 paddingRight = 10 paddingBottom = 10 }//1 val root = Column(page)//2 root.measure(MeasureSpec(MeasureSpec.AT_MOST, 1080), MeasureSpec(MeasureSpec.AT_MOST, 1920)) root.layout(0, 0, 1080, 1920)//3 }
(1)处定义一个布局page,就像在Android中写的布局文件那样,只不过这里更像是Flutter中声明式UI的书写方式。
在源码中布局流程可以简单的认为在ViewRootImpl中发起,内部有performMeasure,performLayout从DecorView开启整个布局流程,这里在(2)处的Column就类似于DecorView,下面两行就类似于ViewRootImpl中perform开头的方法发起的布局流程(这里因为无关,我们不考虑draw部分)。
运行查看打印,与预想一致。
Column = L: 0, T: 0, R: 1080, B: 1920 Column = L: 0, T: 0, R: 110, B: 70 Text(Marshmallow) = L: 10, T: 0, R: 120, B: 10 Text(Nougat) = L: 10, T: 10, R: 70, B: 20 Text(Oreo) = L: 10, T: 20, R: 50, B: 50 Text(Pie) = L: 10, T: 50, R: 40, B: 60
总结
- 整个View和ViewGroup关于布局(包含measure,layout)的框架代码是十分简单的,具体的布局算法需要各子类自行实现。
- ViewGroup关于子View的遍历,因为需要重写,均发生在on开头的方法内。而父View的测量宽高的确定本身需要子View的测量宽高,因此,setMeasuredDimension的调用在onMeasure中的遍历之后;而父View坐标的确定就不需要另外关注子View了,因此和View一样在layout方法中设置,发生在onLayout对子View的遍历之前。
- measure过程即限制的传递过程以及View的期望大小(代码中的width,height)匹配限制得到测量大小(measuredWidth,measuredHeight)的过程。
- 整个布局流程的根本目的在于确定View中的4个坐标值,而这个值是在layout方法中设置的,因此对layout方法的调用决定了布局流程的结果,measure可以说是对这个流程的辅助。
上一篇:Android RecyclerView实现拼团倒计时列表实例代码
栏 目:Android代码
本文标题:自己实现Android View布局流程
本文地址:http://www.codeinn.net/misctech/187950.html