天天看点

用两张图告诉你,为什么你的App会卡顿?

用两张图告诉你,为什么你的App会卡顿?

从这篇文章中你能获得这些料:

知道setcontentview()之后发生了什么?

知道android究竟是如何在屏幕上显示我们期望的画面的?

对android的视图架构有整体把握。

学会从根源处分析画面卡顿的原因。

掌握如何编写一个流畅的app的技巧。

从源码中学习android的细想。

收获两张自制图,帮助你理解android的视图架构。

用两张图告诉你,为什么你的App会卡顿?

上面这段代码想必androider们大都已经不能再熟悉的更多了。但是你知道这样写了之后发生什么了吗?这个布局到底被添加到哪了?我的天,知识点来了!

可能很多同学也知道这个布局是被放到了一个叫做decorview的父布局里,但是我还是要再说一遍。且看下图️

用两张图告诉你,为什么你的App会卡顿?

这个图可能和伙伴们在书上或者网上常见的不太一样,为什么不太一样呢?因为是我自己画的,哈哈哈...

下面就来看着图捋一捋android最基本的视图框架。

估计很多同学都知道,每一个activity都拥有一个window对象的实例。这个实例实际是phonewindow类型的。那么phonewindow从名字很容易看出,它应该是window的儿子(即子类)!

那么,phonewindow有什么用呢?它在activity充当什么角色呢?下面我就姑且把phonewindow等同于window来称呼吧。

用两张图告诉你,为什么你的App会卡顿?

window从字面看它是一个窗口,意思和pc上的窗口概念有点像。但也不是那么准确。看图说。可以看到,我们要显示的布局是被放到它的属性mdecor中的,这个mdecor就是decorview的一个实例。下面会专门撸decorview,现在先把关注点放到window上。window还有一个比较重要的属性mwindowmanager,它是windowmanager(这是个接口)的一个实现类的一个实例。我们平时通过getwindowmanager()方法获得的东西就是这个mwindowmanager。顾名思义,它是window的管理者,负责管理着窗口及其中显示的内容。它的实际实现类是windowmanagerimpl。可能童鞋们现在正在phonewindow中寻找着这个mwindowmanager是在哪里实例化的,是不是上下来回滚动着这个类都找不见?stop!mwindowmanager是在它爹那里就实例化好的。下面代码是在window.java中的。

通过上面的介绍,我们已经知道了window中有负责承载布局的decorview,有负责管理的windowmanager(事实上它只是个代理,后面会讲它代理的是谁)。

前面提到过,在activity的oncreate()中通过setcontentview()设置的布局实际是被放到decorview中的。我们在图中找到decorview。

从图中可以看到,decorview继承了framelayout,并且一般情况下,它会在先添加一个预设的布局。比如decorcaptionview,它是从上到下放置自己的子布局的,相当于一个linearlayout。通常它会有一个标题栏,然后有一个容纳内容的mcontentroot,这个布局的类型视情况而定。我们希望显示的布局就是放到了mcontentroot中。

前面已经提到过,windowmanager在window中具有很重要的作用。我们先在图中找到它。这里需要先说明一点,在phonewindow中的mwindowmanager实际是windowmanagerimpl类型的。windowmanagerimpl自然就是接口windowmanager的一个实现类喽。这一点是我没有在图中反映的。

继续看图。windowmanagerimpl持有了phonewindow的引用,因此它可以对phonewindow进行管理。同时它还持有一个非常重要的引用mglobal。这个mglobal指向一个windowmanagerglobal类型的单例对象,这个单例每个应用程序只有唯一的一个。在图中,我说明了windowmanagerglobal维护了本应用程序内所有window的decorview,以及与每一个decorview对应关联的viewrootimpl。这也就是为什么我前面提到过,windowmanager只是一个代理,实际的管理功能是通过windowmanagerglobal实现的。我们来看个源码的例子就比较清晰了。开始啦!

用两张图告诉你,为什么你的App会卡顿?

从上面可以看到,当activity执行onresume()的时候就会添加视图,或者刷新视图。需要解释一点:windowmanager实现了viewmanager接口。

如图中所说,windowmanagerglobal调用addview()的时候会把decorview添加到它维护的数组中去,并且会创建另一个关键且极其重要的viewrootimpl(这个必须要专门讲一下)类型的对象,并且也会把它存到一个数组中维护。

可以看出viewrootimpl是在activity执行onresume()的时候才被创建的,并且此时才把decorview传进去让它管理。

viewrootimpl能够和系统的windowmanagerservice进行交互,并且管理着decorview的绘制和窗口状态。非常的重要。赶紧在图中找到对应位置吧!

viewrootimpl并不是一个view,而是负责管理视图的。它配合系统来完成对一个window内的视图树的管理。从图中也可以看到,它持有了decorview的引用,并且视图树它是视图树绘制的起点。因此,viewrootimpl会稍微复杂一点,需要我们更深入的去了解,在图中我标出了它比较重要的组成surface和choreographer等都会在后面提到。

到此,我们已经一起把第一张图撸了一遍了,现在童鞋们因该对android视图框架有了大致的了解。下面将更进一步的去了解android的绘制机制。

下面将会详细的讲解为什么我们设置的视图能够被绘制到屏幕上?这中间究竟隐藏着怎样的离奇?看完之后,你自然就能够从根源知道为什么你的app会那么卡,以及开始有思路着手解决这些卡顿。

用两张图告诉你,为什么你的App会卡顿?

同样用一张图来展示这个过程。由于android绘制机制确实有点复杂,所以第一眼看到的时候你的内心中可能蹦腾了一万只草泥马。不要怕!我们从源头开始,一点一点的梳理这个看似复杂的绘制机制。为什么说看似复杂呢?因为这个过程只需要几分钟。just do it!

整天听到cpu、gpu的,你知道他们是干什么的吗?这里简单的提一下,帮助理解后面的内容。

在android的绘制架构中,cpu主要负责了视图的测量、布局、记录、把内容计算成polygons多边形或者texture纹理,而gpu主要负责把polygons或者textture进行rasterization栅格化,这样才能在屏幕上成像。在使用硬件加速后,gpu会分担cpu的计算任务,而cpu会专注处理逻辑,这样减轻cpu的负担,使得整个系统效率更高。

用两张图告诉你,为什么你的App会卡顿?

refreshrate刷新率是屏幕每秒刷新的次数,是一个与硬件有关的固定值。在android平台上,这个值一般为60hz,即屏幕每秒刷新60次。

framerate帧率是每秒绘制的帧数。通常只要帧数和刷新率保持一致,就能够看到流畅的画面。在android平台,我们应该尽量维持60fps的帧率。但有时候由于视图的复杂,它们可能就会出现不一致的情况。

用两张图告诉你,为什么你的App会卡顿?

如图,当帧率小于刷新率时,比如图中的30fps < 60hz,就会出现相邻两帧看到的是同一个画面,这就造成了卡顿。这就是为什么我们总会说,要尽量保证一帧画面能够在16ms内绘制完成,就是为了和屏幕的刷新率保持同步。

下面将会介绍android是如何来确保刷新率和帧率保持同步的。

你可能在游戏的设置中见过vsync,开启它通常能够提高游戏性能。在android中,同样使用vsync垂直同步来提高显示性能。它能够使帧率framerate和硬件的refreshrate刷新强制保持一致。

hwcomposer与vsync不得不说的事

看图啦看图啦。首先在最左边我们看到有个叫hwcomposer的类,这是一个c++编写的类。它android系统初始化时就被创建,然后开始配合硬件产生vsync信号,也就是图中的hw_vsync信号。当然它不是一直不停的在产生,这样会导致vsync信号的接收者不停的接收到绘制、渲染命令,即使它们并不需要,这样会带来严重的性能损耗,因为进行了很多无用的绘制。所以它被设计设计成能够唤醒和睡眠的。这使得hwcomposer在需要时才产生vsync信号(比如当屏幕上的内容需要改变时),不需要时进入睡眠状态(比如当屏幕上的内容保持不变时,此时屏幕每次刷新都是显示缓冲区里没发生变化的内容)。

如图,vsync的两个接收者,一个是surfaceflinger(负责合成各个surface),一个是choreographer(负责控制视图的绘制)。我们稍后再介绍,现在先知道它们是干什么的就行了。

vsync offset机制

为了提高效率,尽量减少卡顿,在android 4.1时引入了vsync机制,并在随后的4.4版本中加入vsync offset偏移机制。

用两张图告诉你,为什么你的App会卡顿?

图1. 为4.1时期的vsync机制。可以看到,当一个vsync信号到来时,surfaceflinger和ui绘制进程会同时启动,导致它们竞争cpu资源,而cpu分配资源会耗费时间,着降低系统性能。同时当收到一个vsync信号时,第n帧开始绘制。等再收到一个vsync信号时,第n帧才被surfaceflinger合成。而需要显示到屏幕上,需要等都第三个vsync信号。这是比较低效率。于是才有了图2. 4.4版本加入的vsync offset机制。

图2. google加入vsync offset机制后,原本的hw_vsync信号会经过dispsync会分成vsync和sf_vsync两个虚拟化的vsync信号。其中vsync信号会发送到choreographer中,而sf_vsync会发送到surfaceflinger中。理论上只要phase_app和phase_sf这两个偏移参数设置合理,在绘制阶段消耗的时间控制好,那么画面就会像图2中的前几帧那样有序流畅的进行。理想总是美好的。实际上很难一直维持这种有序和流畅,比如frame_3是比较复杂的一帧,它的绘制完成的时间超过了surfaceflinger开始合成的时间,所以它必须要等到下一个vsync信号到来时才能被合成。这样便造成了一帧的丢失。但即使是这样,如你所见,加入了vsync offset机制后,绘制效率还是提高了很多。

从图中可以看到,vsync和sf_vsync的偏移量分别由phase_app和phase_sf控制,这两个值是可以调节的,默认为0,可为负值。你只需要找到boardconfig.mk文件,就可以对这两个值进行调节。

前面介绍了几个关键的概念,现在我们回到viewrootimpl中去,在图中找到viewrootimpl的对应位置。

前面说过,viewrootimpl控制着一个window中的整个视图树的绘制。那它是如何进行控制的呢?一次绘制究竟是如何开始的呢?

用两张图告诉你,为什么你的App会卡顿?

在viewrootimpl创建的时候,会获取到前面提到过过的一个关键对象choreographer。choreographer在一个线程中仅存在一个实例,因此在ui线程只有一个choreographer存在。也就说,通常情况下,它相当于一个应用中的单例。

在viewrootimpl初始化时,会实现一个choreographer.framecallback(这是一个choreographer中的内部类),并向choreographer中post。顾名思义,framecallback会在每次接收到vsync信号时被回调。

framecallback一旦被注册,那么每次收到vsync信号时它都会被回调。利用它,我们可以实现会帧率的监听。

上面代码出现了一个重要方法scheduletraversals()。下面我们看看它究竟为何重要。 viewrootimpl.java

可以看出scheduletraversals()每次调用时会向choreographer中post一个traversalrunnable,它会促使choreographer去请求一个vsync信号。所以这个方法的作用就是用来请求一次vsync信号刷新界面的。事实上,你可以看到,在invalidate()、requestlayout()等操作中,都能够看到它被调用。原因就是这些操作需要刷新界面,所以需要请求一个vsync信号来出发新界面的绘制。

从图中可以看到,每当dotraversal()被调用时,一系列的测量、布局和绘制操作就开始了。在绘制时,会通过surface来获取一个canvas内存块交给decorview,用于视图的绘制。整个view视图的内容都是被绘制到这个canvas中。

前面反复提到向choreographer中post回调,那么post过去发生了些什么呢?从图中可以看到,所有的post操作最终都进入到postcallbackdelayedinternal()中。

用两张图告诉你,为什么你的App会卡顿?

上面这段代码会把post到choreographer中的callback添加到callback[]中,并且当它因该被回调时,请求一个vsync信号,在接收到下一个vsync信号时回调这个callback。如果没有到回调的时间,则向framehandler中发送一个msg_do_schedule_callback消息,但最终还是会请求一个vsync信号,然后回调这个callback。

现在来看看前面代码中调用的scheduleframelocked()是如何请求一个vsync信号的。

上面我们提到过,choreographer在一个线程中只有一个。所以,如果在其它线程,需要通过handler来切换到ui线程,然后再请求vsync信号。

下面看看刚刚出场的mdisplayeventreceiver是个什么鬼?

用两张图告诉你,为什么你的App会卡顿?

这给类功能比较明确,而且很重要!

用两张图告诉你,为什么你的App会卡顿?

上面一直在说向framehandler中发消息,搞得神神秘秘的。接下来就来看看framehandler本尊。请在图中找到对应位置哦。

framehandler主要在ui线程处理3种类型的消息。

msg_do_frame:值为0。当接收到一个vsync信号时会发送该种类型的消息,然后开始回调callbackqueue[]中的callback。比如上面说过,在viewrootimpl有两个重要的callback,framecallback(请求vsync并再次注册回调)和traversalrunnable(执行dotraversal()开始绘制界面)频繁被注册。

msg_do_schedule_vsync:值为1。当需要请求一个vsync消息(即屏幕上的内容需要更新时)会发送这个消息。接收到vsync后,同上一步。

msg_do_schedule_callback:值为2。请求回调一个callback。实际上会先请求一个vsync信号,然后再发送msg_do_frame消息,然后再回调。

framehandler并不复杂,但在ui的绘制过程中具有重要的作用,所以一定要结合图梳理下这个流程。

在介绍vsync的时候,我们可能已经看到了,现在android系统会将hw_vsync虚拟化为两个vsync信号。一个是vsync,被发送给上面一直在讲的choreographer,用于触发视图树的绘制渲染。另一个是sf_vsync,被发送给我接下来要讲的surfaceflinger,用于触发surface的合成,即各个window窗口画面的合成。接下来我们就简单的看下surfaceflinger和surface。由于这部分基本是c++编写的,我着重讲原理。

隐藏在背后的surface

平时同学们都知道,我们的视图需要被绘制。那么它们被绘制到那了呢?也许很多童鞋脑海里立即浮现出一个词:canvas。但是,~没错!就是绘制到了canvas上。那么canvas又是怎么来的呢?是的,它可以new出来的。但是前面提到过,我们window中的视图树都是被绘制到一个由surface提供的canvas上。忘了的童鞋面壁思过。

用两张图告诉你,为什么你的App会卡顿?

canvas实际代表了一块内存,用于储存绘制出来的数据。在canvas的构造器中你可以看到:

可以看到,canvas实际主要就是持有了一块用于绘制的内存块的索引long mnativecanvaswrapper。每次绘制时就通过这个索引找到对应的内存块,然后将数据绘制到内存中。比如:

简单的说一下。android绘制图形是通过图形库skia(主要针对2d)或opengl(主要针对3d)进行。图形库是个什么概念?就好比你在pc上用画板画图,此时画板就相当于android中的图形库,它提供了一系列标准化的工具供我们画图使用。比如我们drawrect()实际就是操作图形库在内存上写入了一个矩形的数据。

扯多了,我们继续回到surface上。当viewrootimpl执行到draw()方法(即开始绘制图形数据了),会根据是否开启了硬件(从android 4.0开始默认是开启的)加速来决定是使用cpu软绘制还是使用gpu硬绘制。如果使用软绘制,图形数据会绘制在surface默认的compatiblecanvas上(和普通canvas的唯一区别就是对matrix进行了处理,提高在不同设备上的兼容性)。如果使用了硬绘制,图形数据会被绘制在displaylistcanvas上。displaylistcanvas会通过gpu使用opengl图形库进行绘制,因此具有更高的效率。

前面也简单说了一下,每一个window都会有一个自己的surface,也就是说一个应用程序中会存在多个surface。通过上面的讲解,童鞋们也都知道了surface的作用就是管理用于绘制视图树的canvas的。这个surface是和surfaceflinger共享,从它实现了parcelable接口也可以才想到它会被序列化传递。事实上,surface中的绘制数据是通过匿名共享内存的方式和surfaceflinger共享的,这样surfaceflinger可以根据不同的surface,找到它所对应的内存区域中的绘制数据,然后进行合成。

合成师surfaceflinger

surfaceflinger是系统的一个服务。前面也一直在提到它专门负责把每个surface中的内容合成缓存,以待显示到屏幕上。surfaceflinger在合成surface时是根据surface的z-order顺序一层一层进行。比如一个dialog的surface就会在activity的surface上面。然后这个东西不多提了。

通过对android绘制机制的了解,我们知道造成应用卡顿的根源就在于16ms内不能完成绘制渲染合成过程,因为android平台的硬件刷新率为60hz,大概就是16ms刷新一次。如果没能在16ms内完成这个过程,就会使屏幕重复显示上一帧的内容,即造成了卡顿。在这16ms内,需要完成视图树的所有测量、布局、绘制渲染及合成。而我们的优化工作主要就是针对这个过程的。

复杂的视图树

频繁的requestlayout()

如果频繁的触发requestlayout(),就可能会导致在一帧的周期内,频繁的发生布局计算,这也会导致整个traversal过程变长。有的viewgroup类型的控件,比如relativelayout,在一帧的周期内会通过两次layout()操作来计算确认子view的位置,这种少量的操作并不会引起能够被注意到的性能问题。但是如果在一帧的周期内频繁的发生layout()计算,就会导致严重的性能,每次计算都是要消耗时间的!而requestlayout()操作,会向viewrootimpl中一个名为mlayoutrequesters的list集合里添加需要重新layout的view,这些view将在下一帧中全部重新layout()一遍。通常在一个控件加载之后,如果没什么变化的话,它不会在每次的刷新中都重新layout()一次,因为这是一个费时的计算过程。所以,如果每一帧都有许多view需要进行layout()操作,可想而知你的界面将会卡到爆!卡到爆!需要注意,setlayoutparams()最终也会调用requestlayout(),所以也不能烂用!同学们在写代码的过程中一定要谨慎注意那些可能引起requestlayout()的地方啊!

ui线程被阻塞

如果ui线程受到阻塞,显而易见的是,我们的traversal过程也将受阻塞!画面卡顿是妥妥的发生啊。这就是为什么大家一直在强调不要在ui线程做耗时操作的原因。通常ui线程的阻塞和以下原因脱不了关系。

在ui线程中进行io读写数据的操作。这是一个很费时的过程好吗?千万别这么干。如果不想获得一个卡到爆的app的话,把io操作统统放到子线程中去。

在ui线程中进行复杂的运算操作。运算本身是一个耗时的操作,当然简单的运算几乎瞬间完成,所以不会让你感受到它在耗时。但是对于十分复杂的运算,对时间的消耗是十分辣眼睛的!如果不想获得一个卡到爆的app的话,把复杂的运算操作放到子线程中去。

在ui线程中进行复杂的数据处理。我说的是比如数据的加密、解密、编码等等。这些操作都需要进行复杂运算,特别是在数据比较复杂的时候。如果不想获得一个卡到爆的app的话,把复杂数据的处理工作放到子线程中去。

故意阻塞ui线程。好吧,相信没人会这么干吧。比如sleep()一下?

用两张图告诉你,为什么你的App会卡顿?

整篇下来,相信童鞋对android的绘制机制也有了一个比较全面的了解。现在回过头来再写代码时是不是有种知根知底的自信呢?

看到这里的童鞋快奖励自己一口辣条吧!

继续阅读