《深入理解Android:卷三》深入理解控件系统读书笔记(中)

431 阅读15分钟
原文链接: www.jianshu.com

本篇文章承接上一篇《深入理解Android:卷三》深入理解控件系统读书笔记(上),继续深入了解Android的控件系统。

6.4 深入理解控件树的绘制

6.4.1 理解Canvas

Canvas的绘图指令可以分为两部分:

  • 绘制指令。这些最常用的指令由一系列名为drawXXX()的方法提供。用来实现实际的绘制行为,例如绘制点、线、圆以及方块等
  • 辅助指令。用于提供辅助功能,会影响后续绘制指令的效果,如设置变换、裁剪区等。同时还提供了save()restore()用于撤销一部分辅助指令

1. Canvas的绘制目标
  对软件Canvas来说,其绘制目标是一个建立在Surface之上的位图Bitmap

软件Canvas的绘制目标
  当通过Surface.lockCanvas()方法获取一个Canvas时会以Surface的内存创建一个Bitmap,通过Canvas所绘制的内容都会直接反映到Surface
  硬件Canvas的绘制目标有两种。一种是HardwareLayer,可以将其理解为一个纹理(GL Texture),或者更简单地认为它是一个硬件加速下的位图(Bitmap)。另一种被称为DisplayList,它并不是一块Buffer,而是一个指令序列。DisplayList会将Canvas的绘制指令编译并优化为硬件绘制指令,并且可以在需要时将这些指令回放到一个HardwareLayer上,而不需要重新使用Canvas进行绘制。
  BitmapHardwareLayer以及DisplayList都可以称为Canvas的画布

从使用角度来说,BitmapHardwareLayer十分相似。开发者可以将一个Bitmap通过Canvas绘制到另一个Bitmap上,也可以将一个HardwareLayer绘制到另一个HardwareLayer上。二者的区别仅在于使用时采用了硬件加速还是软件加速。
另外,将DisplayList回放到HardwareLayer上,与绘制一个BitmapHardwareLayer的结果并没有什么不同。只不过DisplayList并不像Bitmap那样存储了绘制的结果,而是存储了绘制的过程。


2. 坐标变换
  Canvas提供了配套使用的save()/restore()方法用以撤销不需要的变换。它们可以嵌套调用,在这种情况下restore()将会把坐标系状态返回到与其配对的save所创建的保存点。另外,也可以通过保存某个save()的返回值,并将这个返回值传递给restoreToCount()方法的方式来显示地指定一个保存点
  坐标系的变换,使得控件在onDraw()方法中使用Canvas时,使用的是控件自身的坐标系。而这个控件自身的坐标系就是通过Canvas的变换指令从窗口坐标系沿着控件树一步一步变化出来的

6.4.2 View.invalidate()与脏区域

当一个控件的内容发生变化而需要重绘的时候,它会通过invalidate()方法将其需要重绘的区域沿着控件树提交给ViewRootImpl,并保存到ViewRootImplmDirty成员中,最后通过scheduleTraversals()引发一次遍历,进而进行重绘。在回溯过程中,会将沿途的控件标记为脏,即设置PFLAG_DIRTYPFLAGE_DIRTY_OPAQUE(不透明)两者之一添加到View.mPrivateFlags成员中。如果控件时“实心”的,则将标记设为PFLAGE_DIRTY_OPAQUE,否则为PFLAGE_DIRTY。控件系统在重绘过程中区分这两种标记以决定是否为此控件绘制背景,如果是实心的就会跳过背景的绘制工作从而提高效率。
  在一个方法可以连续调用多个控件的invalidate()方法,而不用担心会由于多次重绘而产生的效率问题。另外,多次调用invalidate()方法会使得ViewRootImpl多次接收到设置脏区域的请求,ViewRootImpl会将这些脏区域累加到mDirty中,进而在随后的“遍历”中一次性地完成所有脏区域的重绘。

6.4.3 开始绘制

绘制控件树的入口就在performDraw(),其工作也很简单,一是调用draw()执行实际的绘制工作,二是在必要时,向WMS通知绘制已经完成。draw()方法中产生了硬件加速绘制和软件绘制两个分支,分支的条件为mAttachInfo.mHardwareRender()是否存在并且有效。在ViewRootImpl.setView()中会调用enableHardwareAcceleration()方法,倘若窗口的LayoutParams.flags中包含FLAG_HARDWARE_ACCELERATED标记,这个方法会通过HardwareRenderer.createGlRenderer()创建一个HardwareRender并保存在mAttachInfo中。因此mAttachInfo所保存的HardwareRenderer是否存在便成为区分使用硬件加速绘制还是软件绘制的依据。

6.4.4 软件绘制原理

软件绘制由ViewRootImpl.drawSoftware()完成,主要分为以下4步工作:

  • 通过Surface.lockCanvas()获取一个用于绘制的Canvas
  • Canvas进行变换以实现滚动效果
  • 通过mView.draw()将根控件绘制在Canvas
  • 通过Surface.unlockCanvasAndPost()显示绘制后的内容
      其中,第二步和第三步是控件绘制过程中的两个基本阶段,即首先通过Canvas的变换指令将Canvas的坐标系变换到控件自身的坐标系之下,然后再通过控件的View.draw(Canvas)方法将控件的内容绘制在这个变换后的坐标系中。
      注意,在View中还有draw(Canvas)的另一个重载,即View.draw(ViewGroup,Canvas,long)。后者是在父控件的绘制过程中所调用的(参数ViewGroup是其父控件),并且参数Canvas所在的坐标系为其父控件的坐标系。View.draw(ViewGroup,Canvas,long)会根据控件的位置、旋转、缩放以及动画对Canvas进行坐标系的变换,是的Canvas的坐标系从父控件的坐标系变化到本控件的坐标系,并且会在变化完成后调用draw(Canvas)来在变换后的坐标系中进行绘制。当然,该重载方法除了坐标系变换,还包括了硬件加速、绘图缓存以及动画计算等工作。

1. 纯粹的绘制:View.draw(Canvas)
  纯粹的绘制主要涉及以下4步:

  • 绘制背景,注意背景不会受到滚动的影响
  • 调用onDraw()方法绘制控件自身的内容
  • 通过调用dispatchDraw()绘制子控件
  • 绘制特殊的装饰,即滚动条

2. 确定子控件的绘制顺序:dispatchDraw()
  绘制顺序依次执行以下4步:

  • 设置裁剪区域。默认情况下,ViewGroup通过Canvas.clipRect()方法将子控件的绘制限制在自身的区域内。超出区域将会被裁剪。是否需要进行越界内容的裁剪取决于ViewGroup.mGroupFlags中是否包含CLIP_TO_PADDING_MASK标记。开发者可通过ViewGroup.setClipToPadding()方法修改这一行为,使得子控件超出的内容仍得以显示
  • 遍历绘制所有的子控件,根据mGroupFlags中是否存在FLAG_USE_CHILD_DRAWING_ORDER标记存在两种不同的情况:
    1. 默认情况下,dispatchDraw()会按照mChildren列表的索引顺序进行绘制。
    2. 倘若存在FLAG_USE_CHILD_DRAWING_ORDER标记,则表示此ViewGroup希望按照其自定义的绘制顺序进行绘制。自定义的绘制顺序由getChildDrawingOrder()方法实现
  • 在每次遍历中,调用drawChild()方法绘制一个子控件。该方法仅仅是调用子控件的View.draw(ViewGroup,Canvas,long)
  • 通过Canvas.restoreToCount()撤销之前所做的裁剪设置
      有关裁剪的使用,可参照TabWidget的实现来加深理解。

3. 变化坐标系:View.draw(ViewGroup,Canvas,long)
  该方法的工作流程可参见以下几步:

  • 进行动画的计算,并将结果存储在transformToApply中,这是进行坐标系变换的第一个因素
  • 计算控件内容的滚动量。向Scroller设置一个目标的滚动量,以及滚动动画的持续时间,scroller会自动计算在动画国成中本次绘制所需的滚动量。这是进行坐标系变换的第二个因素
  • 使用Canvas.save()保存Canvas的当前状态。此时Canvas的坐标系为父控件的坐标系。在随后将Canvas变换到此空间的坐标系并完成绘制后,会通过Canvas.restoreTo()Canvas重置到此时的状态,以便Canvas可以继续用来绘制父控件的下一个子控件
  • 第一次变换,对应控件位置与滚动量。最先处理的是子控件位置mLeft/mTop,以及滚动量。子控件的位置mLeft/mTop是进行坐标变换的第三个因素
  • 将动画产生的变换矩阵应用到Canvas中。canvas.concat(transformToApply.getMatrix()),主要是各种Animation,如SacleAnimation
  • 控件自身的变换矩阵应用到Canvas中,canvas.concat(getMatrix())。如setScaleX/Y(),setTranslationXY()等产生的变换。控件自身的变换矩阵是进行坐标系变换的第四个因素
  • 设置剪裁。当父控件的mGroupFlags包含FLAG_CLIP_CHILDREN时,子控件在绘制之前必须通过canvas.clipRect()设置裁剪区域。注意要和dispatchDraw()中的裁剪工作区分:dispatchDraw()中的裁剪是为了保证所有的子控件绘制的内容不得越过ViewGroup的边界。其设置由setClipToPadding()方法完成。而FLAG_CLIP_CHILDREN则表示所有子控件的绘制内容不得超出子控件自身的边界,由setClipChildren()方法启用或禁用
  • 使用变换过的Canvas进行最终绘制,调用dispatchDraw()draw(Canvas)两个方法
  • 恢复Canvas的状态到一切开始之前,canvas.restoreToCount(restoreTo)。这样Canvas又回到了父控件的坐标系,使得父控件的dispatchDraw()便可以将这个Canvas交给下一个子控件的draw(ViewGroup, Canvas, long)方法

4. 以软件方式绘制控件树的完成流程

控件树绘制的完整流程
软件绘制的流程特点

6.4.5 硬件加速绘制的原理

1. 硬件加速绘制简介
  倘若窗口使用硬件加速,则ViewRootImpl会创建一个HardwareRenderer并保存在mAttachInfo中。HardwareRenderer是用于硬件加速的渲染器,它封装了硬件加速的图形库,并以Android与硬件加速图形库的中间层的身份存在。它负责从AndroidSurface生成一个HardwareLayer,供硬件加速图形库作为绘制的输出目标,并提供一系列工厂方法用于创建硬件加速绘制过程中所需的DisplayListHardwareLayerHardwareCanvas等工具。

2. 硬件加速绘制的入口HardwareRenderer.draw()
drawSoftware()的4个主要工作作为对比来分析该实现:

  • 获取Canvas。不同于软件绘制时用Surface.lockCanvas()新建一个CanvasHardwareRendererHardwareRenderer创建之初便已被创建并绑定在由Surface创建的EGLSurface
  • Canvas进行变换以实现滚动效果。由于硬件绘制的过程位于HardwareRenderer内部,因此ViewRootImpl需要在onHardwarePreDraw()回调中完成这个操作
  • 绘制控件内容。这是硬件绘制和软件绘制的根本区别。软件绘制时通过View.draw()以递归的方式将整个控件树用给定的Canvas直接绘制Surface上。而硬件加速绘制则先通过View.getDisplayList()获取根控件的DisplayList中包含了已编译过的用于绘制整个控件树的绘图指令。如果说软件绘制是直接绘制,那么硬件绘制则是通过DisplayList间接绘制
  • 将绘制结果显示出来。硬件加速绘制通过sEgl.swapBuffers()将绘制内容显示出来。本质和Surface.unlockCanvasAndPost()方法一致,都是通过 ANativeWindow::queueBuffer将绘制内容发布给SurfaceFlinger

3. DisplayList的创建与渲染
  总体来看,硬件加速绘制过程中的View.getDisplayList()HardwareCanvas.drawDisplayList()的组合相当于软件绘制过程中的View.draw().
  ·View.getDisplayList()·的实现体现了DisplayList的使用方法。DisplayList渲染与Surface的绘制十分相似,分为如下三个步骤:

  • 通过DisplayList.start()创建一个HardwareCanvas并准备好开始录制绘图指令
  • 使用HardwareCanvas进行与Canvas一样的变换与绘制操作
  • 通过DisplayList.end()完成录制并回收HardwareCanvas
      可见DisplayList的渲染也是使用我们熟悉的View.draw()方法完成的,而且View.draw()方法的实现在硬件加速和软件绘制下完全一样。但仍体现了另一个重要区别:软件绘制的整个过程都是用了来自Surface.lockCanvas()的同一个Canvas;而硬件加速时,控件使用由自己的DisplayList所产生的Canvas进行绘制,此时每个控件onDraw()Canvas参数各不相同。另外,getDisplayList()中进行了滚动量的变换,因此在硬件加速绘制的情况下,View.draw(ViewGroup, Canvas,long)方法不需要进行滚动量变换

4. 硬件加速绘制下的子控件绘制
  软件绘制时的流程:View.draw(Canvas)(自身)->dispatchDraw()->View.draw(ViewGroup, Canvas, long)-> View.draw(Canvas)(子控件)
  硬件加速绘制与软件绘制在前两步完全相同,区别在于第三步,即在View.draw(ViewGroup, Canvas, long),两者几乎完全不同。其根本原因在于硬件加速绘制希望在Canvas上绘制子控件的DisplayList,而不是使用View.onDraw()直接绘制,总结两者不同之处如下:

  • 变化因素的应用方法不同。软件绘制时通过Canvas的变换操作将坐标系变换到子控件自身坐标系,而硬件加速绘制时Canvas的坐标系仍保持在父控件的坐标系下,然后通过DisplayList的相关方法将变换因素设置给DisplayListHardwareCanvas.drawDisplayList()会按照这些变换因素再以这些变换绘制(准确地说是回放)DisplayList
  • 绘制方法不同。软件绘制时可以说是直接绘制。硬件加速绘制时使用的是View.getDisplayList()HardwareCanvas.drawDisplayList()的组合进行间接绘制

5. 硬件加速绘制总结

软件绘制与硬件加速绘制的递归方式的差异
硬件加速绘制的流程特点

6.4.6 使用绘图缓存

绘图缓存是指一个Bitmap或一个HardwareLayer,它保存了控件及其子控件的一个快照。绘图缓存有两种类型,即软件缓存(Bitmap)和硬件缓存(HardwareLayer),开发者可以通过View.setLayerType()LAYER_TYPE_SOFTWARELAYER_TYPE_HARDWARE决定此控件使用哪种类型的缓存。默认情况下,控件的缓存类型为LAYER_TYPE_NONE。但值得注意的是,由于硬件缓存依赖于HardwareCanvas,所以在软件绘制的情况下,缓存类型被设置为LAYER_TYPE_HARDWARE的控件仍然会选择使用软件缓存。而在硬件加速绘制的情况下,可以在硬件缓存和软件缓存中任选其一。另外,View.setLayerType()可以通过传入Paint类型的参数用于实现一些显示效果,如透明度、Xfermode以及ColorFilter

1.软件绘制下的软件缓存
  使用软件缓存进行绘制时使用View.buildDrawingCache()/getDrawingCache()canvas.drawBitmap()的组合替代无缓存模式下的View.draw(Canvas)。这种模式和硬件加速绘制时的处理如出一辙,View.buildDrawingCache()的实现方式与View.getDisplayList()方法几乎完全一致,只不过它的目标是一个Bitmap而不是DisplayList。而View.getDrawingCache()则返回mDrawingCachemUnscaleDrawingCache,前者会根据兼容模式进行放大或缩小,用于做绘制时的软件缓存,因为绘制到窗口时需要根据兼容模式进行缩放。而后者反映了控件的真实尺寸,往往被用作控件截图等用途

2. 硬件加速绘制下的绘图缓存
  绘图缓存的实现位于View.getDisplayList(),如果将DisplayList理解为一种缓存,那么硬件加速绘制下的绘图缓存则是在DisplayList上建立的另一级缓存,即二级绘图缓存。
  硬件加速时,使用软件缓存的方式与软件绘制的流程一样,只是绘制到DisplayList上。而使用硬件缓存时,HardwareLayer就是我们所说的硬件缓存,其处理在View.getHardwareLayer()

硬件加速绘制启用绘图缓存后的特点

3. 绘图缓存的利弊
  使用绘图缓存的原则:

  • 不要为十分轻量级的控件启用绘图缓存。因为缓存绘制的开销可能大于控件重绘开销
  • 为很少发生内容改变的控件启用绘图缓存。因为启用绘图缓存的控件在invalidate()时会产生额外的缓存绘制绘制操作
  • 当父控件要频繁改变子控件的位置或变换时对其子控件启用绘图缓存。这会避免频繁地重绘子控件

6.4.7 控件动画

控件系统存在三种方式实现控件的动画。
1.ValueAnimator,ObjectAnimator,ViewPropertyAimator。当动画运行时,ValueAnimator会将AnimationHandler不断地抛给Choreographer,并在VSYNC事件到来时修改指定的控件属性,控件属性的变化引发invalidate()操作进而进行重绘。
2.LayoutTransition,用于ViewGroup中。
3.View.startAnimation(),与控件绘制内部过程联系紧密,因此针对此方法展开分析动画的实现原理

1. 启动动画
  从startAnimation()方法启动的动画依托于Animation类的子类。启动动画时首先将给定的Animaiton通过setAnimaiton()保存到mCurrentAnimaiton成员中,再通过invalidate()方法触发一次重绘

2. 计算动画变换
  既然动画是以坐标系变换的方式产生效果的,因此动画计算的代码位于View.draw(ViewGroup,Canvas,long)中:

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
      
       .........

        Transformation transformToApply = null;
        //获取startAnimation()所给予的Animation对象
        final Animation a = getAnimation();
        if (a != null) {
            //通过drawAnimation()方法计算当前时间点的变换(Transformation)。结果保存在parent.mChildTransformation中
            more = drawAnimation(parent, drawingTime, a, scalingRequired);
            concatMatrix = a.willChangeTransformationMatrix();
            if (concatMatrix) {
                mPrivateFlags3 |= PFLAG3_VIEW_IS_ANIMATING_TRANSFORM;
            }
            transformToApply = parent.getChildTransformation();
        } else {
        //在介绍绘制原理时提到,把transformToApply应用到坐标系变换中
        .............
        }
}

drawAnimation()中,通过Animation.getTransformation()计算当前时间点的变换,并将其保存到父控件的mChildTransformation成员中,然后在View.draw(ViewGroup, Canvas, long)方法中将这个变换以坐标系变换的方式应用到Canvas或者DisplayList中,从而对最终的绘制结果产生影响。倘若动画还将继续,则调用invalidate()以便在下次VSYNC时间到来时进行下一帧的计算与绘制

3. 动画的结束
  动画的结束借由parent.finishAnimatingView()实现,也就是由父控件完成。交给父控件来完成,是因为在执行动画将这个控件从父控件中移除时,ViewGroup会将其从mChildren中移除,但会同时将其放置到mDisappearingChildren数组中,并等待动画结束。由于mDisappearingChildren中的控件依然会得到绘制,因此在执行了ViewGroup.removeView()之后,用户仍然可以看到动画中的控件,直到动画结束后才会消失。另外,LayoutTransition也依赖于这一机制,使得其移出动画被用户看到