本篇文章承接上一篇《深入理解Android:卷三》深入理解控件系统读书笔记(上),从绘制原理,以及动画原理方面,继续深入了解Android的控件系统。
6.4 深入理解控件树的绘制
6.4.1 理解Canvas
Canvas
的绘图指令可以分为两部分:
- 绘制指令。这些最常用的指令由一系列名为
drawXXX()
的方法提供。用来实现实际的绘制行为,例如绘制点、线、圆以及方块等 - 辅助指令。用于提供辅助功能,会影响后续绘制指令的效果,如设置变换、裁剪区等。同时还提供了
save()
和restore()
用于撤销一部分辅助指令
1. Canvas的绘制目标
对软件Canvas
来说,其绘制目标是一个建立在Surface
之上的位图Bitmap
当通过
Surface.lockCanvas()
方法获取一个Canvas
时会以Surface
的内存创建一个Bitmap
,通过Canvas
所绘制的内容都会直接反映到Surface
中硬件
Canvas
的绘制目标有两种。一种是HardwareLayer
,可以将其理解为一个纹理(GL Texture
),或者更简单地认为它是一个硬件加速下的位图(Bitmap
)。另一种被称为DisplayList
,它并不是一块Buffer
,而是一个指令序列。DisplayList
会将Canvas
的绘制指令编译并优化为硬件绘制指令,并且可以在需要时将这些指令回放到一个HardwareLayer
上,而不需要重新使用Canvas
进行绘制。Bitmap
、HardwareLayer
以及DisplayList
都可以称为Canvas
的画布
从使用角度来说,Bitmap
与HardwareLayer
十分相似。开发者可以将一个Bitmap
通过Canvas
绘制到另一个Bitmap
上,也可以将一个HardwareLayer
绘制到另一个HardwareLayer
上。二者的区别仅在于使用时采用了硬件加速还是软件加速。
另外,将DisplayList
回放到HardwareLayer
上,与绘制一个Bitmap
或HardwareLayer
的结果并没有什么不同。只不过DisplayList
并不像Bitmap
那样存储了绘制的结果,而是存储了绘制的过程。
2. 坐标变换
Canvas
提供了配套使用的save()/restore()
方法用以撤销不需要的变换。它们可以嵌套调用,在这种情况下restore()
将会把坐标系状态返回到与其配对的save
所创建的保存点。另外,也可以通过保存某个save()
的返回值,并将这个返回值传递给restoreToCount()
方法的方式来显示地指定一个保存点
坐标系的变换,使得控件在onDraw()
方法中使用Canvas
时,使用的是控件自身的坐标系。而这个控件自身的坐标系就是通过Canvas
的变换指令从窗口坐标系沿着控件树一步一步变化出来的
6.4.2 View.invalidate()与脏区域
当一个控件的内容发生变化而需要重绘的时候,它会通过invalidate()
方法将其需要重绘的区域沿着控件树提交给ViewRootImpl
,并保存到ViewRootImpl
的mDirty
成员中,最后通过scheduleTraversals()
引发一次遍历,进而进行重绘。在回溯过程中,会将沿途的控件标记为脏,即设置PFLAG_DIRTY
或PFLAGE_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
标记存在两种不同的情况:- 默认情况下,
dispatchDraw()
会按照mChildren
列表的索引顺序进行绘制。 - 倘若存在
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
与硬件加速图形库的中间层的身份存在。它负责从Android
的Surface
生成一个HardwareLayer
,供硬件加速图形库作为绘制的输出目标,并提供一系列工厂方法用于创建硬件加速绘制过程中所需的DisplayList
、HardwareLayer
、HardwareCanvas
等工具。
2. 硬件加速绘制的入口HardwareRenderer.draw()
从drawSoftware()
的4个主要工作作为对比来分析该实现:
- 获取
Canvas
。不同于软件绘制时用Surface.lockCanvas()
新建一个Canvas
,HardwareRenderer
在HardwareRenderer
创建之初便已被创建并绑定在由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
的相关方法将变换因素设置给DisplayList
,HardwareCanvas.drawDisplayList()
会按照这些变换因素再以这些变换绘制(准确地说是回放)DisplayList
- 绘制方法不同。软件绘制时可以说是直接绘制。硬件加速绘制时使用的是
View.getDisplayList()
与HardwareCanvas.drawDisplayList()
的组合进行间接绘制
5. 硬件加速绘制总结
6.4.6 使用绘图缓存
绘图缓存是指一个Bitmap
或一个HardwareLayer
,它保存了控件及其子控件的一个快照。绘图缓存有两种类型,即软件缓存(Bitmap
)和硬件缓存(HardwareLayer
),开发者可以通过View.setLayerType()
为LAYER_TYPE_SOFTWARE
和LAYER_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()
则返回mDrawingCache
或mUnscaleDrawingCache
,前者会根据兼容模式进行放大或缩小,用于做绘制时的软件缓存,因为绘制到窗口时需要根据兼容模式进行缩放。而后者反映了控件的真实尺寸,往往被用作控件截图等用途
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
也依赖于这一机制,使得其移出动画被用户看到