“重新设计” Android View 体系?(建议收藏)

刘望舒

共 36572字,需浏览 74分钟

 · 2022-06-30

 安卓进阶涨薪训练营,让一部分人先进大厂


大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。


详情见文章:没错!皇叔开了个训练营


作者:leobert-lan
https://juejin.cn/post/6931630027553374221


关于 Android View 体系,大家从如何自定义 View 到相关源码的掌握,肯定都不陌生。


但是这个过程主要还是记,如果想要真正的理解,目前更喜欢看着现有的事物去进行反推


如何正确显示一个界面呢?需要做哪些事情?


一步步反推、拆解、组合,最后形成一套自己认为可行的理解,在与系统的源码去做对比,这样更有助于大家对系统设计的理解,也能让系统的精良设计不断的将自己的设计方案进行打磨与雕刻。

1.前言

我们知道,在GUI编程中,必然存在一套视图体系内容,Android中也有一套,抛开掉底层内容,和Compose中的内容, 我们这一篇,一同探究下 Framework中,View体系 如何做视觉呈现。


补充:2021-02-22


感谢读者 鲁班贼六的建议,补充内容导图。


这篇文章篇幅较长,在 View 的 measure 机制上花费了不少篇幅。本文尝试先抛开 Android已有知识体系,模拟 从现实情况思考,以建立认知体系的情况。


所以文章的内容编排和导图有一定出入。


注:本文中不涉及:


• Canvas绘制基础。


• 屏幕渲染底层机制。


我们会先思考,如何描述一个任意的界面,引出 View 继承体系,和 View-Tree 视图树。


再逆推一波:当界面被描述后,需要正确显示存在以下三步:


1. 将 正确内容 绘制在 正确位置。


        本文中,Widget的内容绘制略。


2. 依据布局规则,确定布局位置。 注:显示大小 也可以算作 布局规则 的范畴。


3. 测量显示大小。


我们会先从现实情况出发,思考并设计一种可行的 测量规则 ,并不断完善它,重点在于:


• 理解 这种设计是如何 演化 得来的。


• 明白 测量本身就和 布局规则 有关,布局规则 会影响到测量过程。


如果读者对 某些内容 已经打下 坚实的基础,建议 选择性泛读。

2.如何描述一个任意的界面

假如我们现在对Android的内容一无所知,如何描述 一个 任意的界面。


• 无论我们要达成什么效果,必然存在一个虚拟窗体,和物理屏幕相对应。


• 系统层面抽象的绘制呈现过程,一定需要通过 这个 虚拟窗体,而我们描述的界面内容,会被放在窗体中。


• 按照 面向对象思想 和 单一职责原则,描述 这个窗体 的类,假定被称为 Window,一定和描述视图的类 不是同一个。假定视图类被称为 View。


• Window可以获知内部View的信息。


在此基础上,


方案1:构建一个上帝类,它全知全能,能够 记录 和 表达 任意的"文字"、"图片"、"区块"等信息。


方案2:构建一个简单类 View,它有方式知道自己多大,并抽象了视图内容绘制,可以在内部放置子 View,并有方式确定如何放置。


显然,方案1不可取。我们细化方案2。


此时,我们做出了一个假设:View拥有3个能力。


1. 测算自身大小。


2. 可以放置子View;并知道其所在位置,即拥有 布局能力。


3. 准确的知道如何绘制自身所代表的内容。


在此基础上,我们就可以将 任意界面 拆分结构,这个结构可以用 树 来表达。


目前我们约定:


• 每个 View 只能有 一个 双亲。


• 作为双亲的 View,仅用来描述 布局信息。


• 实际 可视 、 可交互 的 View, 描述其代表的内容信息。


于是 描述任意界面 的问题,就可以用 描述一棵树 来解决。


注:目前这个约定还很粗糙,但是不影响我们进行问题认知。

树的存储方法有3种:


1. 双亲表示法。


2. 孩子表示法。


3. 孩子兄弟表示法。


以及基于以上方法的改进版本。


为了更加方便地向上和向下检索,我们使用 双亲孩子表示法 这一改进版本。


细化方案2,ViewGroup和Widget,各司其职。


按照我们上面对树的约定。


我们按职责细分:


• 一部分View 专注于对子View的布局能力,而不再表达 "文字"、"图片"等内容信息,我们将其抽象为子类 ViewGroup。因为没有具体表达 如何放置子View的 规则,所以它是抽象类。


• 将 非包含子View 的,表达"文字"、"图片"等特定信息的View,归纳为Widget。


小结:在上面的阶段性成果中,我们已经细化了方案,用树的形式,描述了界面的结构和内容。存在一个预设的ViewGroup,作为树的根节点。


下面我们先给出一些伪代码。



open class View {
    var parent: View? = null

    //绘制能力
    protected open fun onDraw(canvas: Canvas) {

    }

    //布局能力
    protected open fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

    }

    //测量能力
    protected open fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    }
}

abstract class ViewGroup : View() {
    protected val children = arrayListOf<View>()

    abstract override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int)

    fun addView(view: View) {
        if (view.parent != null) {
            throw IllegalArgumentException("view has a parent")
        }
        children.add(view)
        view.parent = this
    }

}

3.测量大小

接下来我们设计测量大小的能力。


假定有一个显示文字的View,他可以测算自身的大小,但这有3种可能:


• 恰好装下文字内容。


• 被指定了大小,但和第一种大小不一致,这又分两个情况:


        • 人为指定的明确值。


        • 被限定的区域,比如无法超过屏幕大小。


此时仔细思考一下,对于一个 View-tree 而言,测量每一个节点大小的意义是什么?


准确的完成布局 并 完成自身的绘制。


但是有很重要的一点: 屏幕的大小,屏幕的大小是 固定的 、 明确的,这意味着,界面能够单次展示的最大 区域已经固定。


同理,对于一个有 Parent 的 View,原则上来说,它的展示区域也被限定在 Parent的区域中。


但是仔细一想,这并 不合理 啊,有一种革命式的交互: 滑动 ,可以用有限的窗口,展示无限的内容。


所以,我们先记住 一个情况:


不同类型的ViewGroup,对应着不同的布局特性,他们对待 子View 的态度也是不同的,可以表现为:


• 子View 可以 要求 比自身大 的展示大小,最终满不满足以及如何满足是之后的事情。


• 子View 可以 要求 比自身大 的展示大小,但是要了也不给。


这时我们可以总结一个结论,展示和绘制一个内容时,有两组大小需要被考虑:


• 内容本身的大小。


• 用于展示的区域大小。


同样的,当一个 View 或者 ViewGroup,称之为A 被置于 ViewGroup B 中时。


A的大小就是内容本身的大小,B的大小就是用于展示的区域大小,递归思考之后,整个View-Tree都是这样。


显然,测量工作从树的 根节点开始,按照经验,可以使用 深度优先 完成整个测量工作。


我们希望得到的,是每个 View 所对应的 展示区域大小。按照刚才举的例子分析实际情况,我们可以用三种方式来指定View的展示大小:


• 一个明确值。


• 相对值:刚好能够放下它的内容 -- wrap_content。


• 相对值:撑满 Parent 的空间 -- match_parent。


并在测量时,得到准确的结果。


我们再思考这几个取值场景:


对于Child而言,


• 设置了 展示大小为 明确值,毋庸置疑,测量时一定可以得到这个明确值。


• 设置了 展示大小为 match_parent, 因为测量是从 Parent 到 Child, 所以,对于Child 而言,只要Parent的测量工作已经完成, 即 Parent 已经测算出自己的 精确大小, 那么Child使用 match_parent 是可以得到明确值的。但如果Parent没有完成测算,我们先不思考这个问题。


• 设置了 wrap_content,显然,要先测算出 其内容 的大小,才能得到 显示区域 的 明确值。


注 上面这一段内容,非常重要,值得仔细思考。另:上述的内容中,我们先忽略掉 可能存在 的 内边距。


刚才我们还有一些没有考虑的内容:


Parent 没有完成测算,Child 设置了 match_parent。


那么,至少我们可以确定 Parent 不可能 指定了 显示大小 的 明确值,至于其他的情况,需要用数学归纳法 讨论嵌套,我们换个角度思考。


根节点 的ViewGroup,我们可以得到 显示大小 的 明确值,按照刚才的讨论,其子View,使用 match_parent 或者 明确值 时,结合Parent 信息,可以得到 明确值。


只有当其为 wrap_content 时,需要继续测量其内容,再根据内容的大小,确定自身显示大小。


可以确定,当树中的一个节点为wrap_content 时,将该节点作为根节点,取出子树,当该子树的 所有分支 都能够找到满足 条件R 的节点时, 该根节点能够确定自身需要的显示大小。


条件R为:该View:


• 指定 显示大小为 match_parent 或者 明确值。


• 或者其 布局要求 能够让 parent 大小撑满至一个 明确值。


上面这一段内容有点长,适当消化一下。


此时,我们可以做出一点约定:


Parent 多承担一点责任,结合自身情况,和Child情况,先确定一下,Child是否可以得到明确的显示大小:


• 如果不可以,就将自身信息传递给Child,让它向下继续处理。


• 如果可以,那么 Parent 可以得出Child的显示大小, 注意 不同类型的Parent,应该有不同的计算方式。这在前面提到过。


确定测量规则


经过上面的思考,我们可以拟定测量规则了。


1. 测量必然从一个明确自身展示大小 的 ViewGroup 开始。


2. 对于一个子View -- A,当其 Parent -- P 判断出 A。

       

       • 可以得到 明确的 显示大小时,将 该信息: 可准确得到结果 + 结果值 传递给 子View A; 注意,结果值是 Parent 按照自身规则计算的,和子View要求的可能不一致。

        

       •  否则,将 P 的 自身大小 和 你还需要继续测量以得到结果 的信息传递给 子View A。


3. 对于一个Parent,如果它是 wrap_content,则需要在子View 的显示大小都确定时,再计算自身大小。


4. 只要View-Tree中还 存在 未确定 自身 显示大小 的节点。就需要从根节点开始,继续遍历处理测量。


让表达更加准确一些,可准确得到结果 用 EXACTLY 代替。 你还需要继续测量以得到结果 用 AT_MOST 代替。


不言自明,AT_MOST 意味着会给定一个最大值。意味着:家族中的直系长辈 已经帮它 限定了人身自由。


方便准确表达,将他们称为 测量模式,简称 mode :


• EXACTLY:Parent 已经为 Child 决定了显示大小,按照规则,Child 应当使用 Parent 给定的值。


• AT_MOST:Parent 已经为 Child 决定了最大显示大小,按照规则,Child 自行决定使用 最大不超过该值 的显示大小。

方便表达, 将 显示大小 简称为 size。


显示和屏幕像素数量有关,显然,该数量是自然数范畴。size 在绝大多数情况下,可以用 Int值 准确表达,极少数情况下,大到越界,但极不合理。


若使用对象封装 mode 和 size,会出现大量的对象创建,这一点都不优雅,可以将 Int 分为 高位区域 和 低位区域 分别表达 mode 和 size 这也是Android中采用的设计。


考虑到 测量模式 中,还可能存在 Parent 不约束 Child 的情况。


我们使用一个 32位Int 的 高2位 标识 mode,低30位 标识 size。


进一步优化以减少遍历


规则的第4点中,是通过 迭代 的方式,完成整个树中所有节点的测量,按照实际分析,我们可以用 递归 来简化。


我们约定, 对于一个 设置了 wrap_content 的尾端节点,如果它没有实质的内容物,我们也认为它 已经测量出了 需要的展示大小。


那么在一次递归中,我们就可以完成整个树的测量。


在 递 的过程中,仅有设置为 wrap_content 的 Parent角色 无法完成准确测量,而 尾端节点 必然完成了自身的测量。


开始 归 的过程,我们可以确定,每 归 到一个 Parent,


• 已经完成测量的继续 归。


• 没有完成测量的,它的 Children 都完成了测量,则按照 wrap_content 的定义,它必然可以完成测量,然后继续 归。


最终整棵树完成测量。


完善规则,再添加一种mode


前面我们提到了 滑动 这一交互形式,可以利用 有限的 展示空间,显示 无限的 内容。


即,我们会遇到一些场景,Child 并不会收到 Parent 的制约。更加准确的说,是 内容 不受到 呈现主体 在显示空间上的制约。


而这个场景,超越了 EXACTLY 和 AT_MOST 两种测量模式的功能,我们还需要一种配套的测量模式:


UNSPECIFIED,即 Parent 不约束 Child,Child按照自身情况,自行测算。


注:对于 UNSPECIFIED ,不要 强行结合场景,尤其是 不要 利用 warp_content或者 match_parent的概念去理解。他们虽然有一些关联, 但并不是一个范畴的内容,也不可以相互推导。


因此,我单独将其拎了出来。

编码以验证


参考Android中FrameLayout的布局规则,它对于Child要求的大小为:子View 可以 要求 比自身大 的展示大小,但是超过自身显示范围的不予显示。所以,不 按照自身情况 调整 子View的 size。


先给View添加一些必要的内容:


open class View {

    companion object {
        const val layout_width = "layout_width"
        const val layout_height = "layout_height"
        var debug = true
    }

    var tag: Any? = null

    var parent: View? = null

    val layoutParams: MutableMap<String, Int> = mutableMapOf()

    var measuredWidth: Int = WRAP_CONTENT

    var measuredHeight: Int = WRAP_CONTENT

    val heightMeasuredSize: Int
        get() = android.view.View.MeasureSpec.getSize(measuredHeight)

    val widthMeasuredSize: Int
        get() = android.view.View.MeasureSpec.getSize(measuredWidth)

    val heightMeasureMode: Int
        get() = android.view.View.MeasureSpec.getMode(measuredHeight)

    val widthMeasureMode: Int
        get() = android.view.View.MeasureSpec.getMode(measuredWidth)


    private var measured: Boolean = false

    fun isMeasured() = measured

    //绘制能力
    protected open fun onDraw(canvas: Canvas) {

    }

    //布局能力
    protected open fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

    }

    fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        if (!measured) {
            onMeasure(widthMeasureSpec, heightMeasureSpec)
        }
    }

    //测量能力
    protected open fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        setMeasuredDimensionRaw(widthMeasureSpec, heightMeasureSpec)
        debugMeasureInfo()
    }

    protected fun debugMeasureInfo() {
        if (debug) {
            Log.d(
                "view-debug",
                "$tag has measured: $measured, w mode:${getMode(widthMeasureMode)}, w size: $widthMeasuredSize " +
                        "h mode:${getMode(heightMeasureMode)}, h size: $heightMeasuredSize "
            )
        }
    }

    protected fun setMeasuredDimension(measuredWidth: Int, measuredHeight: Int) {
        setMeasuredDimensionRaw(measuredWidth, measuredHeight)
    }

    private fun setMeasuredDimensionRaw(measuredWidth: Int, measuredHeight: Int) {
        this.measuredWidth = measuredWidth
        this.measuredHeight = measuredHeight
        measured = true
        if (debug) {
            Log.d(
                "view-debug",
                "$tag mark has measured: $measured"
            )
        }
    }
}


添加一个FrameLayout:


class FrameLayout : ViewGroup() {
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //handle horizon
        val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = View.MeasureSpec.getSize(widthMeasureSpec)

        var wMeasured = false
        var hMeasured = false
        when (widthMode) {
            View.MeasureSpec.EXACTLY -> {
                // widthSize 即为Parent 为此决定的准确值,直接采用
                wMeasured = true
            }
            View.MeasureSpec.AT_MOST -> {
                // 需要再次测量,但可以保存该信息了
                measuredWidth = widthMeasureSpec
            }
            else -> {
                throw IllegalStateException("暂不支持测量模式:$widthMode")
            }
        }

        //同理处理 vertical方向

        val heightMode = View.MeasureSpec.getMode(heightMeasureSpec)
        var heightSize = View.MeasureSpec.getSize(heightMeasureSpec)

        when (heightMode) {
            View.MeasureSpec.EXACTLY -> {
                hMeasured = true
            }
            View.MeasureSpec.AT_MOST -> {
                measuredHeight = heightMeasureSpec
            }
            else -> {
                throw IllegalStateException("暂不支持测量模式:$widthMode")
            }
        }

        if (hMeasured && wMeasured) {
            setMeasuredDimension(widthMeasureSpec, heightMeasureSpec)
        }

        children.forEach {
            val childWidthMeasureSpec = makeMeasureSpec(widthMode, widthSize, it.layoutWidth)
            val childHeightMeasureSpec = makeMeasureSpec(heightMode, heightSize, it.layoutHeight)
            it.measure(childWidthMeasureSpec, childHeightMeasureSpec)
        }


        if (!hMeasured || !wMeasured) {
            var w = 0
            var h = 0
            children.forEach {
                if (!wMeasured)
                    w = maxOf(w, it.widthMeasuredSize)

                if (!hMeasured)
                    h = maxOf(h, it.heightMeasuredSize)
            }

            if (wMeasured)
                w = widthSize

            if (hMeasured)
                h = heightSize

            setMeasuredDimension(
                View.MeasureSpec.makeMeasureSpec(w, widthMode),
                View.MeasureSpec.makeMeasureSpec(h, heightMode),
            )
        }
        if (!allChildHasMeasured())
            throw IllegalStateException("child 未全部完成测量")

        debugMeasureInfo()
    }

    private fun makeMeasureSpec(mode: Int, size: Int, childSize: Int)Int {
        // 参考Android中FrameLayout的布局规则,它对于Child要求的大小为:
        // 子View 可以 要求 比自身大 的展示大小,但是超过自身显示范围的不予显示。
        // 所以,不 按照自身情况 调整 子View的 size
        val childMode = when (childSize) {
            WRAP_CONTENT -> View.MeasureSpec.AT_MOST
            else -> View.MeasureSpec.EXACTLY

        }

        val childSize2 = when (childSize) {
            WRAP_CONTENT -> size
            MATCH_PARENT -> size
            else -> childSize
        }
        return View.MeasureSpec.makeMeasureSpec(childSize2, childMode)
    }

    private fun allChildHasMeasured()Boolean {
        val i = children.iterator()
        while (i.hasNext()) {
            if (!i.next().isMeasured())
                return false
        }

        return true
    }

}


以上代码 结合前面的规则 理解下即可。


目前还没有到LayoutParam的阶段,我们将 必要的布局信息 声明在 map 中存储。


我们适当添加添加一些助手类,以建立View-tree。


enum class Mode(val v: Int) {

    /**
     * Measure specification mode: The parent has not imposed any constraint
     * on the child. It can be whatever size it wants.
     */

    UNSPECIFIED(0 shl 30),

    /**
     * Measure specification mode: The parent has determined an exact size
     * for the child. The child is going to be given those bounds regardless
     * of how big it wants to be.
     */

    EXACTLY(1 shl 30),

    /**
     * Measure specification mode: The child can be as large as it wants up
     * to the specified size.
     */

    AT_MOST(2 shl 30)
}

class A {
    companion object {
        fun getMode(v: Int): Mode {
            Mode.values().forEach {
                if (it.v == v)
                    return it
            }
            throw IllegalStateException()
        }
    }
}


以上代码不言自明。


typealias Decor<T> = (v: T) -> Unit

val MATCH_PARENT: Int = android.view.ViewGroup.LayoutParams.MATCH_PARENT
val WRAP_CONTENT = android.view.ViewGroup.LayoutParams.WRAP_CONTENT

var View.layoutWidth: Int
    get() {
        return layoutParams[View.layout_width] ?: WRAP_CONTENT
    }
    set(value) {
        layoutParams[View.layout_width] = value
    }

var View.layoutHeight: Int
    get() {
        return layoutParams[View.layout_height] ?: WRAP_CONTENT
    }
    set(value) {
        layoutParams[View.layout_height] = value
    }


fun root(): ViewGroup = FrameLayout().apply {
    this.layoutWidth = 1080
    this.layoutHeight = 1920
}

inline fun ViewGroup?.frameLayout(decor: Decor<FrameLayout>): ViewGroup {
    val child = FrameLayout()
    child.let(decor)
    return this?.apply { addView(child) } ?: child
}

inline fun ViewGroup.view(decor: Decor<View>): ViewGroup {
    val child = View()
    child.let(decor)
    return this.apply { addView(child) }
}


用以实现树结构描述的助手,不言自明。


偷个懒,不设计单元测试了,构建一个结构:


class ViewTest {

    @Test
    fun testMeasure() {

        val tree = root().frameLayout { v1 ->

            v1.tag = "v1"
            v1.layoutWidth = MATCH_PARENT
            v1.layoutHeight = WRAP_CONTENT

            v1.frameLayout { frameLayout ->
                frameLayout.tag = "v2"
                frameLayout.layoutWidth = MATCH_PARENT
                frameLayout.layoutHeight = WRAP_CONTENT

                frameLayout.view {
                    it.tag = "v3"
                    it.layoutWidth = 200
                    it.layoutHeight = 300
                }

                frameLayout.frameLayout {
                    it.tag = "v4"
                    it.layoutWidth = WRAP_CONTENT
                    it.layoutHeight = WRAP_CONTENT
                }
            }
        }

        tree.tag = "root"


        tree.measure(
            View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
            View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY)
        )
        assert(tree is FrameLayout)
        assertEquals(true, (tree as FrameLayout).allChildHasMeasured())
    }
}



直接看一下日志输出的信息:


I/TestRunner: started: testMeasure(osp.leobert.blog.code.ViewTest)
D/view-debug: root mark has measured: true
D/view-debug: v3 mark has measured: true
D/view-debug: v3 has measured: true, w mode:EXACTLY, w size: 200 h mode:EXACTLY, h size: 300 
D/view-debug: v4 mark has measured: true
D/view-debug: v4 has measured: true, w mode:AT_MOST, w size: 0 h mode:AT_MOST, h size: 0 
D/view-debug: v2 mark has measured: true
D/view-debug: v2 has measured: true, w mode:EXACTLY, w size: 1080 h mode:AT_MOST, h size: 300 
D/view-debug: v1 mark has measured: true
D/view-debug: v1 has measured: true, w mode:EXACTLY, w size: 1080 h mode:AT_MOST, h size: 300 
D/view-debug: root has measured: true, w mode:EXACTLY, w size: 1080 h mode:EXACTLY, h size: 1920 
I/TestRunner: finished: testMeasure(osp.leobert.blog.code.ViewTest)



考虑到 一组 Parent 和 Child 有9种组合,我们全部验证一下。限于篇幅就不放代码和结果了。


小结:上面通过很长一段篇幅,让我们在 抛开Android的知识 的前提下:


  1. 思考了如何设计一套系统,用以描述任意的界面。


根据经验确定了使用 视图树 的方式,进行界面的描述,并意识到,应用 不同的类 来封装不同的功能,相互配合,完成界面描述工作。


  1. 思考了描述尺寸的 两种方式 、三种取值类型,并延伸出 测量 视图树 每个节点的 显示大小 问题。


从现实角度出发,得出一种测量方式,并进行了优化,得出结论:


1. 测量过程从 Parent 到 Child。Parent 结合自身情况和 Child的情况,为 Child 决定测量的模式 即 mode, 以及 EXACTLY 模式下的精准值 和 AT_MOST 模式下的 最大值 参考值。


        • 从 Parent 到 Child 表现为:测量的入口为 measure(),其中封装了调用自身onMeasure() 的逻辑, 具体ViewGroup 类覆写 onMeasure() 并调用 Child的 measure() 方法, 传递测量过程。


        • 显示大小的 测量 和 布局规则 有关。


2. 通过一次递归即可测量出视图树每个节点的显示大小。


至此,我们对这套测量机制已经有了足够的认知,但是请注意,它还没有被完善。

4.确定布局位置

在前面,我们思考了一套可行的测量方案,其中我们提到:一个情况。


并且,提出了条件R, 我们在其中提到了一个概念: 布局规则。


结合我们的经验,不同的GUI中,都会有布局规则体系。为了解决可能出现的布局需求,均抽象了不同的布局类,以实现不同的规则。


前面我们也提到了,不同的规则下,ViewGroup 对 子View 的测量是不同的。


这很合理,测量的目的 是为了 正确布局,不同的布局规则,具有特定的测量规则。


使用 LayoutParams 描述布局规则和信息


在前面,我们参考Android 建立了 FrameLayout 类,实现了 帧布局 的规则, 当然,这一种规则还不足以处理各种界面布局需求,还有更多的ViewGroup子类 等着我们实现。


换个说法:当 一个View 被 添加到 一个ViewGroup 中时,需要按照该ViewGroup的布局规则,阐述自身的布局信息. 必要信息不可缺省。


显然,


• 按照面向对象思想,布局规则簇 应该被封装为类,称之为 LayoutParams。


• 按照单一职责原则,不同的布局规则,对应不同的ViewGroup子类,也对应不同的 LayoutParams类,显然这是一一对应的。


• 按照依赖倒置原则,View 的 layoutParam 依赖于 抽象,而不是某个规则的具体类。


• 按照里氏代换原则,LayoutParams的继承关系,和ViewGroup的继承关系应当是对应的。


按照经验,我们会写出如下代码,一个 必须指定宽高规则 的 ViewGroup.LayoutParams 基类。


而视图 可以 存在 内、外边距,这可以被认为是 基本规则。


继续为FrameLayout 加上 重力 规则。


我们很快写出如下代码:



abstract class ViewGroup : View() {
    open class LayoutParams(var width: Intvar height: Int) {

    }

    open class MarginLayoutParams(width: Int, height: Int) : LayoutParams(width, height) {
        var leftMargin = 0

        var topMargin = 0

        var rightMargin = 0

        var bottomMargin = 0
    }
}

class FrameLayout : ViewGroup() {
    class LayoutParams(width: Int, height: Int) : ViewGroup.MarginLayoutParams(width, height) {
        val UNSPECIFIED_GRAVITY = -1

        var gravity = UNSPECIFIED_GRAVITY
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    }

    override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams)Boolean {
        return layoutParams is LayoutParams
    }

    override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams {
        return LayoutParams(MATCH_PARENT, MATCH_PARENT)
    }
}



并对原先的Demo工程进行重构, 限于篇幅,略去相关代码。


注:按照里氏代换原则,我们定义的 LayoutParams 体系在使用中时,可能会遇到 输入不符合期望 的问题。此时我们需要了解一下: 契约式设计:


使用契约式设计,类中的方法需要声明前置条件和后置条件。前置条件为真,则方法才能被执行。而在方法调用完成之前,方法本身将确保后置条件也成立。


于是,在ViewGroup 体系中,设计了:


• checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean


• generateDefaultLayoutParams(): ViewGroup.LayoutParams


我们可以采用两种契约:


• 输入的LayoutParams 必须满足约束,否则抛出异常。


• 输入的LayoutParams 需要满足约束,否则使用默认规则。

获得布局规则信息、按照ViewGroup 的布局规则进行布局。


至此,我们已经理解了:


• 使用视图树描述一个任意视图。


• 用不同的 ViewGroup 子类描述不同的布局,他们具有特定的布局规则;用不同的 Widget 展现不同的内容。


• 一种 测量视图树各个节点 的 显示大小 的测量方式。


• 不同的规则,决定了显示大小测算的细节有所不同。


• 使用LayoutParams 描述布局规则信息。


在此基础上,我们需要接受设定:


存在一个机制,可以正确地解析 视图树各个节点 中申明的 布局规则信息,这些信息,会存储在正确的 LayoutParams 对象中,被对应的节点所持有,以待使用。


这个机制,我们先忽略。


按照刚才获得的经验,布局和测量的过程类似。


我们定义 layout()  onLayout() 方法。



open class View {
    open fun layout(l: Int, t: Int, r: Int, b: Int) {
        //todo
    }

    //布局能力
    protected open fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

    }
}



对于参数,约定为:


• l Left position, relative to parent


• t Top position, relative to parent


• r Right position, relative to parent


• b Bottom position, relative to parent


在完成了 大小测试 和 布局规则解析 的前提下,这些相对值的计算并不复杂。


我们约定,实际的布局逻辑,在onLayout中完成,而layout方法,用于实现 前置条件,onLayout调用 和 状态维护。


对于 ViewGroup 而言,需要遍历Children,为每个 Child,使用其显示大小信息&布局规则信息,确定其布局位置,即 l,t,r,b 四个参数值。调用 Child 的 layout() 方法。


对于 Widget 而言,则是需要决定Content的展示区域,因为 Content 不再是 View,不再需要继续向下调用 layout 方法。


至此,所有的准备工作均已完成,接下来,就是绘制工作。

5.最后一步,绘制在正确位置

在此之前,我们已经得到了视图树每个节点的正确位置,此时,只需要将内容绘制在对应位置,即可通过屏幕呈现在用户眼前。


按照之前的经验,我们定义:


• draw(canvas:Canvas) 方法,封装整个绘制流程。


• onDraw(canvas:Canvas) 方法,实现内容的绘制。


• 如果在ViewGroup中覆写onDraw(canvas:Canvas) 同时 实现 自身内容的绘制,例如背景 ,和 分发 Child 的绘制,这并不符合开闭原则。


故而添加 dispatchDraw(canvas: Canvas) 用以实现 分发 Child 的绘制。


其实到此为止,我们已经对 正确展示内容 有了比较完善的认知,绘制的内容,理解不复杂,但内容很庞杂,本篇就不再展开了。






为了防止失联,欢迎关注我防备的小号



 

                                         微信改了推送机制,真爱请星标本公号👇


浏览 12
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报