对于安卓开发猿来说,每天都会跟布局打交道,那么从我们写的一个布局文件,到运行后可视化的视图页面,这么长的时间内到底 发生了啥呢?今天我们就一起来探询这一旅程。
View tree的创建过程
布局文件的生成过程
一般情况下,一个布局写好了,如果不是特别复杂的布局,那么当把布局文件塞给Activity#setContentView或者一个Dialog或者一个Fragment,之后这个View tree就创建好了。那么setContentView,其实是通过LayoutInflater这个对象来具体的把一个布局文件转化为一个内存中的View tree的。这个对象不算太复杂,主要的逻辑就是解析XML文件,把每个TAG,用反射的方式来生成一个View对象,当XML文件解析完成后,一颗View tree就生成完了。
但是需要注意,inflate之后虽然View tree是创建好了,但是这仅仅是以单纯对象数据的形式存在,这时去获取View的一些GUI的相关属性,如大小,位置和渲染状态,是不存在的,或者是不对的。
手动创建
除了用布局文件来生成布局,当然也可以直接用代码来撸,这个就比较直观了,view tree就是你创建的,然后再把根节点塞给某个窗口,如Activity或者Dialog,那么view tree就创建完事了。
渲染前的准备工作
View tree生成的最后一步就是把根结点送到ViewRootImpl#setView里面,这里会把view添加到wms之中,并着手开始渲染,接下来就主要看ViewRootImpl这个类了,主要入口方法就是ViewRootImpl#requestLayout,然后是scheduleTraversals(),这里会把请求放入到队列之中,最终执行渲染的是doTraversal,它里面调用的是performTraversals(),所以,我们需要重点查看ViewRootImpl#performTraversals这个方法,view tree渲染的流程全在这里面。这个方法相当之长,接近1000行,主要就是三个方法performMeasure,performLayout和performDraw,就是常说的三大步:measure,layout和draw。
渲染之measure
就看performMeasure方法,这个方法很简单,就是调用了根view的measure方法,然后传入widthSpec和heightSpec。measure的目的就是测量view tree的大小,就是说view tree在用户可视化角度所占屏幕大小。要想理解透彻measure,需要理解三个事情,MeasureSpec,View#measure方法和View#onMeasure方法:
理解MeasureSpec
从文档中可以了解到,MeasureSpec是从父布局传给子布局,用以代表父布局对子布局在宽度和高度上的约束,它有两部分一个是mode,一个是对应的size,打包成一个integer。
UNSPECIFIED
父布局对子布局没有要求,子布局可以设置任意大小,这个 基本上 不常见。
EXACTLY
父布局已经计算好了一个精确的大小,子布局要严格按照 这个来。
AT_MOST
子布局最大可以达到传过来的这个尺寸。
光看这几个mode,还是不太好理解。因为我们平日里写布局,在大小(或者说宽和高)这块就三种写法:一个是MATCH_PARENT,也就是要跟父布局一样大;要么是WRAP_CONTENT,也就是说子布局想要刚好合适够显示自己就行了;再者就是写死的如100dp等。需要把measure时的mode与LayoutParams结合联系起来,才能更好的理解measure的过程。
还是得从performMeasure这时入手,这个MeasureSpec是由父节点传给子节点,追根溯源,最原始的肯定是传给整个view tree根节点的,也就是调用performMeasure时传入的参数值。
根节点的MeasureSpec
根节点的MeasureSpec是由getRootMeasureSpec得来的,这个方法传入的是窗口的大小,这是由窗口来给出的,当前的窗口肯定 是知道自己的大小的,以及根节点布局中写的大小。从这个方法就能看出前面说的布局中的三种写法对MeasureSpec的影响了:
- 如果 根节点布局是MATCH_PARENT的,那么 mode就是EXACTLY,大小就是父布局的尺寸,因为根节点的父亲就是窗口,所以就是窗口的大小
- 如果 根节点布局是WRAP_CONTENT的,那么 mode是AT_MOST,大小依然会是父布局的尺寸。这个要这样理解,WRAP_CONTENT是想让子布局自己决定自己多大,但是,你的极限 就是父布局的大小了。
- 其他,其实就是根节点写死了大小的(写布局时是必须 要指定layout_width和layout_height的,即使某些view可以省略一个,也是因为缺省值,而并非不用指定),那么mode会是EXACTLY,大小用根节点指定的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
子View的MeasureSpec
MeasureSpec这个东西是自上而下的,从根节点向子View传递。前面看过了根节点的spec生成方式,还有必要再看一下子View在measure过程中是如何生成spec的,以更好的理解整体过程。主要看ViewGroup#getChildMeasureSpec方法就可以了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
|
单纯从spec角度来理解,与上面的是一样的,基本上WRAP_CONTENT会是AT_MOST,而其他都是EXACTLY。
后面会再详细讨论一下,父布局与子View的相互影响。
View#measure和View#onMeasure
performMeasure比较简单,只是调用根节点的measure方法,然后把计算出来的根节点的MeasureSpec传进去,就完事了,所以 重点要View#measure方法。这里需要注意的是整个View的设计体系里面一些主要的逻辑流程是不允许子类override的,可定制的部分作被动式的方法嵌入在主要逻辑流程中,如measure是不能被override的,它会调用可以被子类override的onMeasure。onMeasure是每个View必须实现的方法,用传入的父布局的约束来计算出自已的大小。
为了优化measure流程,还有一个cache机制,用从父布局传入的MeasureSpec作为key,从onMeasure得出的结果 作为value,保存在cache中,当后面再次调用measure时,如果MeasureSpec未发生变化,那么就直接从cache中取出结果,如果 有变化 那么再调用onMeasure去计算一次。光看View#measure和onMeasure这两个方法也没啥啊,或者说常见的view或者我们自己定义的view的onMeasure方法也没啥啊,都不算太复杂,有同学就会问,这里为啥这么费劲 非要搞出一个cache呢?这个也好理解,要明白任何一个view不光是你自己,还涉及到所有你的子view啊,如果你只是一个未端的view(叶子),那当然 无所谓了,但如果是一个ViewGroup,下面有很多个子view,那么 如果能少调用一次onMeasure,还是能节省不少CPU资源的。
ViewGroup的onMeasure
每个View的本身的onMeasure并不复杂,只需要关注好本身的尺寸就好了。
复杂的在于ViewGroup的onMeasure,简单来理解也并不复杂,它除了需要测量自己的宽与高之外,还需要逐个遍历子view以measure子view。如果ViewGroup自身是EACTLY的,那么onMeasure过程就会简单不少,因为它自身的宽与高是确定的,只需要挨个measure子View就可了,而且子View并不影响它本身。当然,要把padding和margin考虑进来。
最为复杂的就是AT_MOST,ViewGroup自身的宽与高是由其所有子View决定的,这才是最复杂的,也是各个ViewGroup子类布局器需要重点解决的,而且过程各不相同,因为每个布局器的特点不一样,所以过程并不相同,下面来各自讨论一下。
几种常见的ViewGroup的measure逻辑
下来来看一下一些非常常见的ViewGroup是如何measure的:
LinearLayout
它的方向只有两个,可以只分析一个方向,另外一个方向是差不多的,我们就看看measureVertical。
第1种情况,也就是height mode是EXACTLY的时候,这个时候LinearLayout布局本身的高度是已知的,挨个遍历子view然后measure一下就可以。
第2种情况,比较复杂的情况,是AT_MOST时,这其实也还好,理论上高度就是所有子view的高度之和。
对于LinearLayout,最为复杂的情况是处理weight,这需要很多复杂处理,要把剩余所有的空间按weight来分配,具体比较复杂,有兴趣的可以具体去看源码。这也说明了,为何在线性布局中使用weight会影响性能,代码中就可以看出当有weight要处理的时候,至少多遍历一遍子view以进行相关的计算。
虽然方向是VERTICAL时,重点只处理垂直方向,但是width也是需要计算的,但width的处理就要简单得多,如果其是EXACTLY的,那么就已知了;如果是AT_MOST的,就要找子view中width的最大值。
FrameLayout
FrameLayout其实是最简单的一个布局管理器,因为它对子view是没有约束的,无论水平方向还是垂直方向,对子view都是没有约束,所以它的measure过程最简单。
如果是EXACTLY的,它本身的高度与宽度是确定的,那么就遍历子view,measure一下就可以了,最后再把margin和padding加一下就完事了。
如果是AT_MOST的,那么也不难,遍历子View并measure,然后取子view中最大宽为它的宽度,取最大的高为其高度,再加上margin和padding,基本上就做完了。
因为,FrameLayout的measure过程最为简单,因此系统里很多地方默认用的就是FrameLayout,比如窗口里的root view。
RelativeLayout
这个是最为复杂的,从设计的目的来看,RelativeLayout要解决的问题也是提供了长与宽两个维度来约束子view。
总体过的过程就是要分别从vertical方向和horizontal方向,来进行两遍的measure,同时还要计算具体的坐标,实际上RelativeLayout的measure过程是把measure和layout一起做了。
自定义View如何实现onMeasure
如果是一个具体的View,那就相当简单了,默认的实现就可以了。
如果是ViewGroup会相对复杂一些,取决于如何从水平和垂直方向上约束子view,然后进行遍历,并把约束考虑进去。可以参考LinearLayout和RelativeLayout的onMeasure实现。
渲染之layout
measure是确定控件的尺寸,下一步就是layout,也就是对控件进行排列。
首先,需要理解现代GUI窗口的坐标系统,假设屏幕高为height,宽为width,那么屏幕左上角为坐标原点(0,0),右下角为(width, height),屏幕从上向下为Y轴方向,从左向右则是X轴方向。安卓当中,也是如此。每一个控件都是一个矩形区域,为了能知道如何渲染每一块矩形(每 一个控件)就需要知道它的坐标,在前一步measure中,能知道它的宽与高,如果再能确定它的起始坐标左上角,那么它在整个屏幕中的位置就可以确定了。
对于Android来说,view的渲染的第二步骤就是layout,其目的就是要确定好它的坐标,每一个View都有四个变量mLeft, mTop,mRight和mBottom,(mLeft, mTop)是它的左上角,(mRight, mBottom)是它的右下角,很明显width=mRight-mLeft,而height=mBottom-mTop。这些数值是相对于父布局来说的,每个View都是存在于view tree之中,知道相对于父布局的数值就足够在渲染时使用了,没必要用相对屏幕的绝对数值,而且用相对父布局的坐标数值再加上父布局的坐标,就可以得到在屏幕上的绝对数值,如果需要这样做的话。
layout过程依然是从根节点开始的,所以仍要从ViewRootImpl#performLayout作为起点来理顺layout的逻辑。performLayout的参数是一个LayoutParam,以及一个windowWidth和desiredWindowHeight,调用performLayout是在performTraversal当中,在做完performMeasure时,传入的参数其实就是窗口window的宽与高(因为毕竟是根节点嘛)。performLayout中会从根节点mView开开对整个view tree进行layout,其实就是调用mView.layout,传入的是0, 0和view的经过measure后宽与高。
单个View的layout方法实现较简单,把传入的参数保存到mLeft,mTop,mRight和mBottom变量,再调用onLayout就完事了,这个很好理解,因为子view是由父布局确定好的位置,只要在measure过程把自己需要的大小告诉父布局后,父布局会根据LayoutParam做安排,传给子view的就是计算过后的结果,每个子view记录一下结果就可以了,不需要做啥额外的事情。
ViewGroup稍复杂,因为它要处理其子view,并且要根据其设计的特点对子view进行约束排列。还是可以看看常见的三个ViewGroup是如何做layout的。
LinearLayout
依然是两个方向,因为LinearLayout的目的就是在某一个方向上对子view进行约束。看layoutVertical就可以了,水平方向上逻辑是一样的。
遍历一次子View即可,从父布局的left, top起始,考虑子view的height 以及上下的padding和margin,依次排列就可以了。需要注意的是,对于left的处理,理论上子view的left就应该等于父布局,因为这毕竟是vertical的,水平上是没有约束的,但是也要考虑Gravity,当然也要把padding和margin考虑进来。最后通过setChildFrame把排列好的坐标设置给子view。
总体来看,线性布局的layout过程比其measure过程要简单不少。
FrameLayout
FrameLayout对子view的排列其实是没有约束的,所以layout过程也不复杂,遍历子view,子view的left和top初始均为父布局,依据其Gravity来做一下排布即可,比如如果Gravity是right,那么子view就要从父布局的右侧开始计算,childRight=parentRight-margin-padding,childLeft=childRight-childWidth,以次类推,还是比较好理解的。
RelativeLayout
前面提到过RelativeLayout是在measure的时候就把坐标都计算好了,它的layout就是把坐标设置给子view,其余啥也没有。
自定义View如何实现onLayout
如果是自定义View的话,不需要做什么。
如果是自定义的ViewGroup的话,要看设计的目的,是如何排列子view的。
总之,layout过程相较measure过程还是比较好理解的,约束规则越复杂的view,其measure过程越复杂,但layout过程却不复杂。
渲染之draw
draw是整个渲染过程的核心也是最复杂的一步,前面的measure和layout只能算作准备,draw才会真正进行绘制。
draw的整个逻辑流程
与measure和layout的过程非常不一样,虽然在performTraversals中也会调用performDraw,也就是说看似draw流程的起点仍是ViewRootImpl#performDraw,但查看一下这个方法的实现就可以发现,这里面其实并没有调用到View#draw,就是说它其实也是做一些准备工作,整个View tree的draw触发,并不在这里。
从performDraw中并没有做直接与draw相关的事情,它会调用另外一个方法draw()来做此事情,在draw方法中,它会先计算需要渲染的区域(dirty区域),然后再针对 此区域做渲染,正常情况下会走硬件加速方式去渲染,这部分比较复杂,它直接与一个叫做ThreadedRenderer打交道,稍后再作分析。
由于各种原因,假如硬件加速未没有成功,那么会走到软件渲染,这部分逻辑相对清晰一些,可以先从这里看起,会直接调用到drawSoftware(),这个方法有助于我们看清楚渲染的流程。这个方法里面会创建一个Canvas对象,是由ViewRootImpl持有的一个Surface对象中创建出来的,并调用view tree根节点的mView.draw(canvas),由此便把流程转移到了view tree上面。
view tree的draw的过程
ViewRootImpl是直接调用根节点的draw方法,那么这里便是整个view tree的入口。可先从View#draw(canvas)方法看起。主要分为四步:1)画背景drawBackground;2)画自己的内容通过onDraw来委派,具体的内容是在onDraw里面做的;3)画子view,通过dispatchDraw方法;4)画其他的东西,如scroll bar或者focus highlight等。可以重点关注一下这些操作的顺序,先画背景,然后画自己,然后画子view,最后画scroll bar和focus之类的东西。
重点来看看dispatchDraw方法,因为其他几个都相对非常好理解,这个方法主要要靠ViewGroup来实现,因为在View里面它是空的,节点自己只需要管自己就可以了,只有父节点才需要关注如何画子View。ViewGroup#dispatchDraw这个方法做一些准备工作,如把padding考虑进来并进行clip,后会遍历子View,针对 每个子view调用drawChild方法,这实际上就 是调用回了View#draw(canvas,parent,drawingTime)方法,注意这个方法是package scope的,也就是说只能供view框架内部调用。这个方法并没有做具体的渲染工作(因为每个View的具体渲染都是在onDraw里面做的),这个方法里面做了大量与动画相关的各种变换。
Canvas对象是从哪里来的
View的渲染过程其实大都是GUI框架内部的逻辑流程控制,真正涉及graphics方面的具体的图形如何画出来,其实都是由Canvas对象来做的,比如如何画点,如何画线,如何画文字,如何画图片等等。一个Canvas对象从ViewRootImpl传给View tree,就在view tree中一层一层的传递,每个view都把其想要展示的内容渲染到Canvas对象中去。
那么,这个Canvas对象又是从何而来的呢?从view tree的一些方法中可以看到,都是从外面传进来的,view tree的各个方法(draw, dipsatchDraw和drawChild)都只接收Canvas对象,但并不创建它。
从上面的逻辑可以看到Canvas对象有二个来源:一是在ViewRootImpl中创建的,当走软件渲染时,会用Surface创建出一个Canvas对象,然后传给view tree。从ViewRootImpl的代码来看,它本身就会持有一个Surface对象,大概的逻辑就是每一个Window对象内,都会有一个用来渲染的Surface;
另外一个来源就是走硬件加速时,会由hwui创建出Canvas对象。
draw过程的触发逻辑
从上面的讨论中可以看出draw的触发逻辑有两条路:
一是,没有启用硬件加速时,走的软件draw流程,也是一条比较好理解的简单流程:performTraversal->performDraw->draw->drawSoftware->View#draw。
二是,启用了硬件加速时,走的是performTraversal->performDraw->draw->ThreadedRenderer#draw,到这里就走进了硬件加速相关的逻辑了。
硬件加速
硬件加速是从Android 4.0开始支持的,在此之前都是走的软件渲染,也就是从ViewRoot(4.0版本以前是叫ViewRoot,后来才是ViewRootImpl)中持有的Surface直接创建Canvas,然后传给view tree去做具体的渲染,与前面提到的drawSoftware过程类似。
硬件加速则要复杂得多,多了好多东西,它又搞出了一套渲染架构,但这套东西是直接与GPU联系,有点类似于OpenGL,把view tree的渲染转换成为一系列命令,直接传给GPU,软件渲染则是需要CPU把所有的运算都做了,最终生成graphic buffer送给屏幕(当然也是GPU)。
这一坨东西中最为核心就是RenderNode和RecordingCanvas。其中RenderNode是纯新的东西,它是为了构建 一个render tree(类似于view tree),用以构建复杂的渲染逻辑关系。RecordingCanvas是Canvas的一个子类,它是专门用于硬件加速渲染的,但又为了兼容老的Canvas(软件渲染),为啥叫recording呢?因为硬件加速方式渲染,对于view tree的draw过程来说就是记录一系列的操作,这其实就是给GPU的指令,渲染的最后一步就是把整个render tree丢给GPU,就完了。
前面说的两个是数据结构,还不够,还有HardwareRenderer和ThreadedRenderer,这两个用来建立和管理render tree的,也就是说它们内部管理着一组由RenderNode组成的render tree,并且做一些上下文环境的初始化与清理资源的工作。类似于OpenGL中GLSurfaceView的RenderThread做的事情。
硬件加速与原框架的切入点都是RenderNode和RecordingCanvas,View类中多了一个RenderNode成员,当draw的时候,从RenderNode中得到RecordingCanvas,其余操作都与原来一致,都是调用Canvas的方法进行graphics的绘制,这样整体渲染流程就走入到了硬件加速里面。
Choreographer与vsync
虽然在Android 4.0版本加入了硬件加速的支持,但这还是不够,因为它只是相当于具体的渲染时间可能快了一些,举例来说,可能是普通火车与高铁之间的差异,虽然确实行程所花时间变短了,但是对于整体的效率来说提升并不大。对于整体GUI的流畅度,响应度,特别是动画这一块的流程程度与其他平台(如水果)差距仍是巨大的。一个最重要的原因就在于,GUI整体的渲染流程是缺少协同的,仍是按需式渲染:应用层布局加载完了要渲染了,或者ViewRootImpl发现dirty了,需要重绘了,或者有用户事件了需要响应了,触发整体渲染流程,更新graphic buffer,屏幕刷新了。
这一过程其实也没有啥大问题,对于常规的UI显示,没有问题,我没有更新,没有变化 ,当然 不需要重绘了,如果有更新有变化时再按需重新渲染,这显然 没有什么问题。最大的问题在于动画,动画是要求连续不停的重绘,如果仅靠客户这一端(相较于graphic buffer和屏幕这一端来说)来触发,显然FPS(帧率)是不够的,由此造成流畅度肯定不够好。
于是在Android 4.1 (Jelly Bean)中就引入了Choreographer以及vsync机制,来解决此问题,它们两个并不全完是一回事,Choreographer是纯软件的,vsync则是更为复杂的更底层的机制,有没有vsync,Choreographer都能很好的工作,只不过有了vsync会更好,就好比硬件加速之于View的渲染,没有硬件加速也可以渲染啊,有了硬件加速渲染会更加的快一些。
Choreographer
它的英文本意是歌舞的编舞者,有点类似于导演,但歌舞一般时间更短,所以对编舞者要求更高,需要在短时间内把精华全部展现出来。它的目的就是要协调整个View的渲染过程,对输入事件响应,动画和渲染进行时间上的把控。文档原文是说:Coordinates the timing of animations, input and drawing.,精华就在于timing这个词上。
但其实,这个类本身并不是很复杂,相较于其他frameworks层的东西来说它算简单的了,它就是负责定时回调,按照一定的FPS来给你回调,简单来说它就是做了这么一件事情。它公开的接口也特别少,就是postFrameCallback和removeFrameCallback,而FrameCallback也是一个非常简单的接口doFrame(long frameTimeNanos),里面的参数是当前帧开始渲染的时间序列。
所以,它的工作就是在计时,或者叫把控时间,到了每一帧该渲染的时候了,它会告诉你。有了它,那么GUI的渲染将不再是按需重绘了,而是有节奏的,可以以固定FPS定时刷新。ViewRootImpl那头也需要做调整,每当有主动重绘时(view tree有变化,用户有输入事件等),也并不是说立马就去做draw,而是往Choreographer里post一个FrameCallback,在里面做具体的draw。
vsync(Vertical Synchronization)
垂直同步,是另外一套更为底层的机制,简单来理解就是由屏幕显示系统直接向软件层派发定时的脉冲信号,用以提高整体的渲染流畅程度,屏幕刷新,graphic buffer和window GUI(view tree)三者在这个脉冲信号下,做到同步。
vsync是通过对Choreographer来发挥作用的。Choreographer有两套timing机制,一是靠它自己实现的一套,另外就是直接传导vsync的信号。通过DisplayEventReceiver(这个类对于App层是完全不可见的被hide了)就可以接收到vsync的信号了,调用其sheduleVsync来告诉vsync说我想接收下一次同步的信号,然后在重载onVsync方法以接收信号,就能够与vsync系统连接起来了。
渲染性能优化
这是一个很大的话题
保持简单
最最重要的原则就是要保持简单,比如,UI页面尽可能的简洁,view tree的层级要尽可能的少,能用颜色就别用背景图片,能merge就merge。
动画也要尽可能的简单,并且使用标准的ValueAnimator接口,而不要简单粗暴的去修改LayoutParams(如height和width)。
减少重绘
这个要多用系统中开发者模式里面的重绘调试工具来做优化,尽可能的减少重绘。
专项定制
有些时候,对于一些特殊需求的view要进行定制优化。举个例子,比如一个巨复杂的页面(如某宝的首页),中有一个用于显示倒计时的view,实现起来并不复杂,一个TextView就搞定了,一个Timer来倒计时,不断的刷新数字 就可以了。但是,这通常会导致整个页面都跟着在重绘。因为数字在变化,会导致TextView的大小在变化,进而导致整个View tree都在不断的跟着重绘。
像这种case,如果遇到了,就需要自定义一个专门用于此的View,并针对数字不断刷新做专门的优化,以不让其影响整个view tree。
不要在意这个例子的真实性,要知道,当某个View演变成了整个页面的瓶颈的时候,就需要专门针对 其进行特殊定制以优化整体页面的渲染性能。
更多的技巧可以参考这篇文章和后面的参考资料。
参考资料
列举一下关于此话题的比较好的其他资源