Android自定义view的理解与思考

Android自定义view的理解与思考

什么是自定义view?

view是Android sdk的原生类,打开源码能看到,view有四个构造方法。查看注释能知道,它们的使用场景不一样。

  1. 第一个是在用代码创建时使用的,只需要传一个context参数。
  2. 第二个是在xml布局创建view时使用的,它需要两个参数,分别是context和属性集对象,当我们自定义view时使用了自定义属性那么这个对象就能用上了。
  3. 第三个和第二个相比多了一个int类型的参数,这个参数就是主题样式。
  4. 第四个构造方法比第三个多了一个参数,这个第四参数是一个默认值,当第三个参数主题传的值为0或找不到这个主题时生效。

自定义view可以大致分为三类,

  • 把系统内置的控件组合起来生成一个新的控件
  • 继承现有的控件,然后加入新的功能和逻辑
  • 直接继承view或viewGroup,并自己实现代码绘制等。

Android自定义view中有三个方法很重要,onMeasure();onLayout();onDraw(),他们是view绘制的三个流程。

onMeasure

这个中文意思是测量。查看view中这个方法的注释可以了解到,他是用于测量视图及其内容以确定视图的宽度和高度。也就是说这个方法用于确定视图的大小。

onMeasure方法的两个参数widthMeasureSpec,heightMeasureSpec这两个值是由父视图经过计算后传递给子视图的。

  1. 父View(即ViewGroup)的onMeasure()中会通过getChildCount()拿到子View数量,然后遍历子View并执行measureChildWithMargins()
  2. measureChildWithMargins()中会计算并得到两个MeasureSpec
  3. 执行child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
  4. measure()方法中将调用onMeasure()

MeasureSpec值由specMode和specSize共同组成,onMeasure两个参数的作用根据specMode的不同,有所区别。MeasureSpec是一个int类型的变量,int有32位,MeasureSpec让32位中的最高2位代表specMode,后30位代表specSize。
我们不需要自己去进行位运算,Android已经提供了API:

//获取specMode
MeasureSpec.getMode(widthMeasureSpec)
MeasureSpec.getMode(heightMeasureSpec)
//获取specSize
MeasureSpec.getSize(widthMeasureSpec)
MeasureSpec.getSize(heightMeasureSpec)

其中specMode有三种模式:

  1. MeasureSpec.UNSPECIFIED(00)=0
    1. 父容器对该view没有限制
    2. 如:该view的父view是ScrollView
  2. MeasureSpec.EXACTLY(01)=1
    1. 父view对该view指定了大小
    2. 当该View在xml中设置了具体的dp或match_parent
      1. 子视图的大小应根据specSize的大小来设置
  3. MeasureSpec.AT_MOST(02)=2
    1. 父容器给该View一个可以使用的最大值值
    2. 当该View在xml中设置了wrap_content

onLayout

view中注释给的定义是为这个视图每个子view分配大小和位置,也就是确认子view的位置。这就是为什么view这个类中,onLayout是一个空的方法了,因为他没有子view。
在ViewGroup中的onLayout()中我们可以计算子View的坐标。
FrameLayout布局为例:

  1. 获取子view数量并开始遍历子View
  2. 判断子View是否隐藏
  3. 通过getMeasuredWidth();getMeasuredHeight()两个方法获取子view测量的宽高
  4. 计算childLeft;childTop,即子View左上角的坐标
  5. childLeft + width; childTop + height得到子View右下角的坐标。两个坐标确认子View位置
  6. 调用child.layout(childLeft, childTop, childLeft + width, childTop + height)将坐标传递给子View

onDraw

绘制,在经过measure和layout后,view的尺寸和位置已经确定,接下来就是绘制到屏幕。onDraw传了一个参数canvas,最终就是把图像绘制到这个canvas。

而这个canvas是怎么来的呢?
一开始我是看View; ViewGroup; ViewRootImpl;的源码,想看一下怎么实现的,但是东西太多了看得脑子疼。
然后我就想到了一个便捷的方法,我写了一个自定义View,然后Debug看这个View#onDraw()上层是怎么调用的。跟着这个流程走就轻松许多了。

  1. 可以看到Canvas是在的ViewRootImpl#drawSoftware()方法中通过lockCanvas(rect)拿到的。
  2. 接着经过几个流程后通过ThreadedRenderer#updateViewTreeDisplayList()方法调用View#updateDisplayListIfDirty()
  3. 开始遍历执行View#draw()

在view中有两个同名的draw()方法:

  1. View#draw(canvas)
    1. View#updateDisplayListIfDirty()中递归执行的就是这个方法,这个方法内Android定义了7个绘制步骤
  2. View#draw(canvas, parent, drawingTime)
    1. 这个是给ViewGroup提供的,用于drawChild(),他属于draw(canvas)的第4个绘制步骤

View#draw(canvas)内定义了7个绘制步骤:

/*  
 * Draw traversal performs several drawing steps which must be executed  
 * in the appropriate order: 
 * 
 *      1. Draw the background       
		2. If necessary, save the canvas' layers to prepare for fading       
		3. Draw view's content       
		4. Draw children       
		5. If necessary, draw the fading edges and restore layers       
		6. Draw decorations (scrollbars for instance)       
		7. If necessary, draw the default focus highlight 
*/

因为这块流程还没有理解透彻,所以就不详细扩展。

在开发符合自己要求的view时,有时会需要使用自定义属性。那么这个属性要怎么定义呢?

在项目的资源目录res/values下新建一个资源文件,然后在文件的顶层定义一个<resources>并使用<declare-styleable>创建一个属性集,然后用attr声明属性。当然也可以不适用属性集,而是声明一个独立的属性。然后所有在资源文件下声明的属性都会在R文件中生成相对应的变量。到这里属性定义就完成了。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MyViewPager">
        <attr name="idName" format="string"/>
        <attr name="showName" format="boolean"/>
    </declare-styleable>
</resources>

接下来是使用,在自定义view的构造方法中,通过context中的obtainStyledAttributes,传入属性集对象和资源文件。获取TypedArray,然后同个这个对象获取具体的属性。

TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.ToolBar); 
buttonNum = a.getInt(R.styleable.ToolBar_buttonNum, 5); 
itemBg = a.getResourceId(R.styleable.ToolBar_itemBackground, -1);
a.recycle();

在自定义view的时候我们可能会在onCreate()获取view的宽高,这时拿到的值为0,这是为什么?

因为这时view还没完成绘制,没有测量完宽高,所以拿到的值为0,添加一个viewTreeObserver监听即可,但是需要注意的是,该操作会额外占用资源,所以使用完后应该移除监听。

代码如下:

mBinding.onClickTest.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
    override fun onGlobalLayout() {
        mBinding.onClickTest.viewTreeObserver.removeOnGlobalLayoutListener(this)
        mBinding.onClickTest.measuredWidth
        mBinding.onClickTest.measuredHeight
    }
})

自定义View性能优化

过度绘制优化

应用可能会在单个帧内多次绘制同一个像素,这种情况称为过度绘制。它会浪费系统资源来多次绘制,或浪费时间来渲染与用户所见内容无关的像素,进而导致性能问题。

比如我们有若干界面卡片堆叠在一起,每张卡片都会遮盖其下面一张卡片的部分内容。但是,系统仍然需要绘制堆叠中的卡片被遮盖的部分。这是因为堆叠的卡片是根据画家算法(也就是按从后到前的顺序)来渲染的。

找出问题

Google提供了工具用于检查APP是否过度绘制,是否影响了应用性能。

GPU过度绘制调试工具

该工具使用半透明颜色来显示应用在屏幕上绘制每个像素的次数。此数值越高,过度绘制影响应用性能的可能性越大。

上图中,左图是应用程序正常情况,右图是工具标识了过度绘制区域的样子。

如何直观呈现 GPU 过度绘制

GPU渲染模式分析工具

GPU 渲染模式分析工具以滚动直方图的形式显示渲染流水线的每个阶段显示一帧所用的时间。每个竖条的“处理”部分都以橙色表示,它显示系统何时交换缓冲区;该指标可提供有关过度绘制的重要线索。
如下:

如何分析 GPU 渲染速度

解决问题

我们可以从以下思路解决过度绘制问题:

  1. 移除布局中不需要的背景。
  2. 使视图层次结构扁平化。
  3. 降低透明度。

接下来介绍这几种方法。

移除布局中不需要的背景

默认情况下,布局没有背景,这表示布局本身不会直接渲染任何内容。但是,当布局具有背景时,有可能会导致过度绘制。

移除不必要的背景可以快速提高渲染性能。不必要的背景可能永远不可见,因为它会被应用在该视图上绘制的任何其他内容完全覆盖。例如,当系统在父视图上绘制子视图时,可能会完全覆盖父视图的背景。

使视图层次结构扁平化

借助先进的布局设计方法,您可以轻松对视图进行堆叠和分层,从而打造出精美的设计。但是,这样做会导致过度绘制,从而降低性能,特别是在每个堆叠视图对象都是不透明的情况下,这需要将可见和不可见的像素都绘制到屏幕上。

比如:开服时可能会过度使用嵌套布局。可能会在 RelativeLayout 容器包含一个同样也是 RelativeLayout 容器的子级。这种嵌套实际是多余的,并且会给视图层次结构造成不必要的开销。

降低透明度

在屏幕上渲染透明像素,即所谓的透明度渲染,是导致过度绘制的重要因素。在普通的过度绘制中,系统会在已绘制的现有像素上绘制不透明的像素,从而将其完全遮盖,与此不同的是,透明对象需要先绘制现有的像素,以便达到正确的混合效果。
诸如透明动画、淡出和阴影之类的视觉效果都会涉及某种透明度,因此有可能导致严重的过度绘制。您可以减少要渲染的透明对象的数量,以此来改善这些情况下的过度绘制。
例如,如需获得灰色文本,您可以在 TextView 中绘制黑色文本,再为其设置一个半透明的透明度值。但是,您可以简单地通过用灰色绘制文本来获得同样的效果,而且能够大幅提升性能。

扩展知识

  1. 图片加载性能优化

参考文章

  1. 减少过度绘制
posted @ 2023-03-10 15:40  Ysun_top  阅读(197)  评论(0编辑  收藏  举报