Android高效view绘制方案-Kotlin自定义viewGroup

2,259 阅读6分钟

传统编写界面的方案

如图所示, 假设我们要完成一个如图类似的布局 image.png

大部分人的做法肯定是 线性布局 相对布局 一顿组合操作 写在xml里面

这样布局嵌套其实就有点深了,有没有更好一点的写法?

答案是有的,我们可以用约束布局

约束布局来完成这个界面 自然是不需要布局嵌套了,一层viewGroup 就解决了。

还有没有其他方案?或者说 约束布局的缺陷是啥?

  1. 既然使用了约束布局 那不可避免的 要读取一次xml 涉及到文件io
  2. xml文件的解析 本身比较耗时
  3. 约束布局的性能没有想象中那么好,因为这个layout要照顾的场景太多了,导致逻辑特别复杂

有没有比上述更好的解决方案?

答案是有的, 在android的上古时期, 有一个方案叫X2C

这是个啥?

其实就是 将xml 转为代码, 之前我们不是用xml 写布局吗? 以后我们用code 写布局不就行了?

这样就可以省略xml的读取和解析了啊。

那为啥这个方案没有流行起来?

因为code 来写布局 太麻烦了,以前只有java语言。 ROI 太低了。

但是现在我们有了Kotlin 一切 皆有可能。

最后 缺陷的第三点 指出,约束布局的性能没有那么好,难道我们自定义viewGroup的 性能能超越 谷歌的大神吗?

你的自定义viewGroup 百分之99的情况下 性能是无法超越谷歌大神的,水平在这呢,无法逾越, 但是我们的viewGroup 只要照顾我们ui设计稿上的情况就可以了,不需要 写那么多逻辑照顾所有情况 基于这种特定条件下,我们viewGroup的性能 肯定是可以超越约束布局的

动手写viewGroup的最大阻碍

很多人觉的自定义viewGroup 难,是不是就难在这?

image.png

一看这张图都懵了吧。

其实按照我的理解 大家大可不必 关心 unspecified 这个东西,放心好了 你的职业生涯不会碰到这种场景的 如果碰到了 那你应该去谷歌了。 然后剩下的 其实就是exactly 和at most 这2个也简单。无非翻译过来就是

**实际用多少,就用多少 和 最多可以用多少。 **

我们用kotlin代码来翻译一下:

0 就是 unspecified

 fun Int.toExactlyMeasureSpec(): Int {
        return MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
    }

    fun Int.toAtMostMeasureSpec(): Int {
        return MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)
    }

    fun View.defaultWidthMeasureSpec(parentView: ViewGroup): Int {
        return when (layoutParams.width) {
            MATCH_PARENT -> parentView.measuredWidth.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("need special treatment for $this")
            else -> layoutParams.width.toExactlyMeasureSpec()
        }
    }

    fun View.defaultHeightMeasureSpec(parentView: ViewGroup): Int {
        return when (layoutParams.height) {
            MATCH_PARENT -> parentView.measuredHeight.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("need special treatment for $this")
            else -> layoutParams.height.toExactlyMeasureSpec()
        }
    }

就这么简单。

基于此 我们可以自定义一系列的方便的扩展函数 作为一个基类 来方便我们后面具体的自定义view

abstract class CustomLayout(context: Context) : ViewGroup(context) {

    // 自动measure
    fun View.autoMeasure() {
        measure(
            this.defaultWidthMeasureSpec(this@CustomLayout),
            this.defaultHeightMeasureSpec(this@CustomLayout)
        )
    }

    // 自动layout,有些view是从右边开始摆放,这个时候看else里面的逻辑就可以了
    fun View.layout(x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
        if (!fromRight) {
            layout(x, y, x + measuredWidth, y + measuredHeight)
        } else {
            layout(this@CustomLayout.measuredWidth - x - measuredWidth, y)
        }

    }

    // dp转px
    val Int.dp: Int get() = (this * resources.displayMetrics.density + 0.5f).toInt()

    fun Int.toExactlyMeasureSpec(): Int {
        return MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
    }

    fun Int.toAtMostMeasureSpec(): Int {
        return MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)
    }

    fun View.defaultWidthMeasureSpec(parentView: ViewGroup): Int {
        return when (layoutParams.width) {
            MATCH_PARENT -> parentView.measuredWidth.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("need special treatment for $this")
            else -> layoutParams.width.toExactlyMeasureSpec()
        }
    }

    fun View.defaultHeightMeasureSpec(parentView: ViewGroup): Int {
        return when (layoutParams.height) {
            MATCH_PARENT -> parentView.measuredHeight.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("need special treatment for $this")
            else -> layoutParams.height.toExactlyMeasureSpec()
        }
    }

    // 把view 左右两遍的margin也一起带上
    val View.measuredWidthWithMargins get() = measuredWidth + marginLeft + marginRight
    val View.measuredHeightWithMargins get() = measuredHeight + marginTop + marginBottom
    // 用来方便的设置
    class LayoutParams(width: Int, height: Int) : MarginLayoutParams(width, height)


}

真正开始自定义viewGroup

我们首先回顾一下 之前的图,就是那个效果图, 我们先分析下 这个效果图 要实现起来 主要分几个步骤。

  1. 先把各个元素 定义出来 比如说 最大的底图,然后底图下面的按钮 然后是头像, 昵称,和 描述 总共是5个view

  2. 对这5个view 分别进行measure

  3. 对这5个view 分别进行layout

简单吧,就这三步

首先看第一步 这第一步是最简单的,无非就是给这些view 设置一些属性而已,比如宽高,图像来源

难的是第二步吧,measure的过程是最难的,解决了他 第三部 无非就是根据第二部的结果 来摆放view 而已

哪我们根据图里的信息 来看下measure的过程怎么写

  1. 底图:这个最简单 设置固定的宽高 就行了。宽是match 高度 自己随便写个px 就行了吧
  2. 底图右下方的按钮: 和1一样 只要设置好他的宽高 用我们的autoMeasure方法 很方便
  3. 头像 头像也很方便,autoMeasure 可以解决

问题来了 昵称和描述 就有点难了。难在哪? 因为昵称和描述的右边界要在 按钮的左边,你不能超过这个按钮的左边吧

昵称和描述的宽度是多少?仔细观察 如图所示:他们的宽度 是 父布局的宽度-头像的宽度-按钮的宽度-他们距离左 边头像的marginLeft这个就是他们2 可以用的宽度了。

有了宽度 我们再看高度。 实际上这个描述的高度 是能够决定整个viewgroup的高度的。为啥?

因为如果描述的这个textview 高度超过了头像 那整个viewgroup的高度 就应该是底图的高度 +昵称和描述的高度 +头像的marginTop

如果没超过头像,哪自然就是 头像的高度+底图的高度了

搞清楚这一点 那么measure的流程 就有思路了。 剩下的 layout的流程 就更简单了。无非就是根据你measure 出来的值 来按顺序摆放这些view 即可

最后上下代码:

class TheLayout2(context: Context, attributes: AttributeSet) : CustomLayout(context) {

    val header = AppCompatImageView(context).apply {
        scaleType = ImageView.ScaleType.CENTER_CROP
        setImageResource(R.mipmap.tlyhp)
        layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 200.dp)
        //addView 不会触发layout 只有view被attach到一个view树中 才会走measure-layout-draw这个流程
        addView(this)
    }

    val fab = FloatingActionButton(context).apply {
        setImageResource(R.mipmap.ic_launcher)
        layoutParams = LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        ).also {
            it.rightMargin = 12.dp
        }
        addView(this)
    }

    //头像
    val avatarIv = AppCompatImageView(context).apply {
        scaleType = ImageView.ScaleType.CENTER_CROP
        setImageResource(R.mipmap.abc)
        layoutParams = LayoutParams(50.dp, 50.dp).also {
            it.leftMargin = 12.dp
            it.topMargin = 12.dp
        }
        addView(this)
    }


    //昵称
    val nameTV = AppCompatTextView(context).apply {
        textSize = 3.dp.toFloat()
        text = "呵呵呵呵额呵11112312311211呵呵"
        isSingleLine = true
        layoutParams =
            LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            ).also {
                it.leftMargin = 12.dp
            }
        addView(this)
    }

    //描述
    val desTV = AppCompatTextView(context).apply {
        textSize = 5.dp.toFloat()
        text = "我是大帅是大帅哥我是大帅哥"
        //text = "我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥我是大帅哥"
        layoutParams =
            LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            ).also {
                it.leftMargin = 12.dp
                it.topMargin = 12.dp
            }
        addView(this)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        header.autoMeasure()
        fab.autoMeasure()
        avatarIv.autoMeasure()
       

        // 计算昵称的宽度
        val nameTvWidth =
            measuredWidth - avatarIv.measuredWidthWithMargins - fab.measuredWidthWithMargins - nameTV.marginLeft
        // 计算描述的宽度
        val desTvWidth =
            measuredWidth - avatarIv.measuredWidthWithMargins - fab.measuredWidthWithMargins - desTV.marginLeft

        nameTV.measure(nameTvWidth.toExactlyMeasureSpec(), nameTV.defaultHeightMeasureSpec(this))
        desTV.measure(desTvWidth.toExactlyMeasureSpec(), desTV.defaultHeightMeasureSpec(this))


        val contentHeight =
            (avatarIv.marginTop + desTV.measuredHeightWithMargins + nameTV.measuredHeightWithMargins).coerceAtLeast(
                avatarIv.measuredHeightWithMargins
            )


        setMeasuredDimension(measuredWidth, header.measuredHeight + contentHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        header.layout(0, 0)
        fab.let {
            it.layout(
                x = it.marginRight,
                y = header.bottom - (it.measuredHeight / 2),
                fromRight = true
            )
        }
        avatarIv.let {
            it.layout(x = it.marginLeft, y = header.bottom + it.marginTop)
        }

        nameTV.let {
            it.layout(x = avatarIv.right + it.marginLeft, y = avatarIv.top)
        }

        desTV.let {
            it.layout(x = avatarIv.right + it.marginLeft, y = nameTV.bottom + it.marginTop)
        }
    }

}

然后运行下,完美无缺~~~可以看到当我们更改des内容的时候 整个viewGroup的高度 也会随之发生改变。

image.png

实际上可以看出来 一点重复绘制的区域都没有,可以说很完美了。

除了前面提到的优点以外,他还有一个优点就是 你不需要findviewbyId了, 大家都知道 findViewById 在你布局复杂的时候 其实是有一定性能损耗的, 那么现在也没有了。

你可以直接通过viewGroup 来取里面的子view 很方便

image.png

总结

使用Kotlin语言 来自定义viewGroup 其实并不复杂,相比于xml的传统view方式,手写viewGroup 性能上要更高,成本上相比xml 有所提升,但是总体上其实也可以接受。

最后感谢drakeet大神的分享。