尝试自己实现Android View布局流程

457 阅读8分钟

相关阅读:尝试自己实现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

总结

  1. 整个View和ViewGroup关于布局(包含measure,layout)的框架代码是十分简单的,具体的布局算法需要各子类自行实现。

  2. ViewGroup关于子View的遍历,因为需要重写,均发生在on开头的方法内。而父View的测量宽高的确定本身需要子View的测量宽高,因此,setMeasuredDimension的调用在onMeasure中的遍历之后;而父View坐标的确定就不需要另外关注子View了,因此和View一样在layout方法中设置,发生在onLayout对子View的遍历之前。

  3. measure过程即限制的传递过程以及View的期望大小(代码中的width,height)匹配限制得到测量大小(measuredWidth,measuredHeight)的过程。

  4. 整个布局流程的根本目的在于确定View中的4个坐标值,而这个值是在layout方法中设置的,因此对layout方法的调用决定了布局流程的结果,measure可以说是对这个流程的辅助。