优化云课堂Android端直播间性能的一些思考与总结

达芬奇密码2018-06-19 11:09

优化云课堂直播间性能的一些思考与总结

一、本文背景

云课堂Android端的Native直播间模块,聊天面板滑动有些卡顿,在弹起、收起输入键盘的时候页面有明显的闪动,另外在横竖屏切换的时候也不流畅。同时在播放视频的时候,在对比其他APP产品,发现在CPU及电量使用上有稍许劣势。

二、问题分析

流畅的样子应该是,系统每秒60帧的渲染频率的话,也就是16ms一帧的速度。而出现卡顿,闪动,就是说应用在16ms内没有完成相应的数据更新操作,导致这几帧的画面没法及时更新或者直接被丢弃了。另外,也可能存在内存抖动,因为在虚拟机GC的时候,所有其他线程都会暂停,这也会导致画面丢帧。所以,我们需要找寻系统在进行每帧渲染前的耗时操作以及查看内存情况。

三、Android性能优化方向

在考虑优化方案时,先让我们回顾下Android的渲染机制,内存与GC。

1、关于渲染

Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都可以绘制成功的话,这样就能达到流畅的画面所需要的60fps,这就意味着,程序在更新画面的时候,操作必须在16ms内完成。如果你的某个操作时间超过了16ms,系统在得到VSYNC信号的时候就无法进行正常渲染,这样就发生了丢帧现象,用户在32ms内看到的会是同一帧画面。

在面对这样的问题,我们通常可以通过一些工具进行定位问题,比如可以使用HierarchyViewer来查找Activity中的布局是否过于复杂,也可以打开手机的Show GPU Overdraw选项,查看是否存在过度绘制。另外也可以使用TraceView、Systrace来观察CPU的执行情况。

2、关于过度绘制

对于在多层次的UI结构里面,如果不可见的UI也在做绘制的操作,这样就会导致某些像素区域被绘制了多次。这样会浪费大量的CPU以及GPU资源。

比如Activity有一个白色的背景,然后Layout又有自己的背景,同时子View又分别有自己的背景。这样就绘制了三次!!!

3、关于GPU绘制图形信息。

在开发这工具中有一个选项用于打开Profile GPU Rendering。选择了以后,我们可以在手机画面上看到丰富的GPU绘制图形信息,分别关于StatusBar,NavBar,激活的程序Activity区域的GPU Rending信息。 随着滚动界面,界面上会滚动显示垂直的柱状图来表示每帧画面所需要渲染的时间,柱状图越高表示花费的时间越长。中间会有一根绿色的横线,代表16ms,我们需要确保每一帧花费的总时间都低于这条横线,这样才能避免出现卡顿的问题。

每一条柱状线包含三部分,蓝色代表测量绘制Display List的时间红色代表OpenGL渲染Display List所需要的时间黄色代表CPU等待GPU处理的时间

4、理解GPU工作

activity的画面是如何绘制到屏幕上的?复杂的xml布局文件是如何能够被识别并绘制出来的?

Resterization栅格化,它把那些组件拆分到不同的像素上进行显示。 CPU负责把UI组件计算成Polygons,Texture纹理,然后交给GPU进行栅格化渲染。 然而每次从CPU转移到GPU是一件很麻烦的事情,所幸的是OpenGL ES可以把那些需要渲染的纹理保存在GPU Memory里面,在下次需要渲染的时候直接进行操作。所以如果更新了GPU所保存住的纹理内容,那么之前保存的状态就丢失了。

在Android里面那些由主题所提供的资源,例如Bitmaps,Drawable都是一起打包到统一的Textture纹理当中,然后在传递到GPU里面,这就意味每次需要使用这些资源的时候,都是直接从纹理里面进行获取渲染的。当随着UI组件的越来越丰富,有了更多演变的形态。例如显示图片的时候,需要先经过CPU的计算加载到内存中,然后再传递给GPU进行渲染。

5、Invalidations,Layouts, and Performance(系统如何处理UI组件的更新操作)

当更新可视化物品的时候,Android在绘制图案前,都会将高级的XML文件转化为GPU可接受的文件,然后进行屏幕渲染。这里要借助DisplayList(显示列表),它基本包含了所有GPU渲染所需信息,GPU用到的资产,执行的命令。

  • 在某个View第一次需要被渲染时,DisplayList会因此而被创建,当这个View要显示到屏幕上时,我们会执行GPU的绘制指令进行渲染。
  • 如果在后续有执行类似移动这个View的位置等操作而需要再次渲染这个View时,仅仅需要额外一次渲染指令就够了。
  • 如果修改了View中的某些可见组件,那么之前的DisplayList就无法继续使用了,需要重新创建一个DisplayList并且重新执行渲染指令并更新到屏幕上。
  • 可怕的是,以上的流程的表现性能取决于你的View的复杂程度,View的状态变化以及渲染管道的执行性能。比如某个Button的大小需要增大到目前的两倍,在增大Button大小之前,需要通过父View重新计算并摆放其他子View的位置。修改View的大小会触发整个HierarchView的重新计算大小的操作。如果布局复杂,这很容易导致严重的性能问题

这里可以通过打开Show GPU view Updates 来查看你的应用出现了什么类型的失效。

6、Overdraw,Cliprect,QuickReject

对于非可见的高度自定义UI组件,有几个APIs方法可以显著提升绘制操作的性能。

我们可以通过canvas.clipRect()来帮助系统识别那些可见的区域。这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视。这个API可以很好的帮助那些有多组重叠组件的自定义View来控制显示的区域。同时clipRect方法还可以帮助节约CPU与GPU资源,在clipRect区域之外的绘制指令都不会被执行,那些部分内容在矩形区域内的组件,仍然会得到绘制。 除了clipRect方法之外,我们还可以使用canvas.quickreject()来判断是否没和某个矩形相交,从而跳过那些非矩形区域内的绘制操作。

7、关于内存模型,Memory Churn(内存抖动)

在同一帧里面创建过多的对象是件需要特别引起注意的事情。

Android系统里面有一个Generational Heap Memory的模型,系统会根据内存中不同的内存数据类型分别执行不同的GC操作。例如,最近刚分配的对象会放在Young Generation区域,这个区域的对象通常都是会快速被创建并且很快被销毁回收的,同时这个区域的GC操作速度也是比Old Generation区域的GC操作速度更快的。

Android内存模型

原始JVM中的GC机制在Android中得到了很大程度上的优化。Android里面是一个三级Generation的内存模型,最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后到Permanent Generation区域。

除了速度差异之外,执行GC操作的时候,任何线程的任何操作都会需要暂停,等待GC操作完成之后,其他操作才能够继续运行。

通常来说,单个的GC并不会占用太多时间,但是大量不停的GC操作则会显著占用帧间隔时间(16ms)。如果在帧间隔时间里面做了过多的GC操作,那么自然其他类似计算,渲染等操作的可用时间就变得少了。

导致GC频繁执行有两个原因:

  • Memory Churn内存抖动,内存抖动是因为大量的对象被创建又在短时间内马上被释放。
  • 瞬间产生大量的对象会严重占用Young Generation的内存区域,当达到阀值,剩余空间不够的时候,也会触发GC。即使每次分配的对象占用了很少的内存,但是他们叠加在一起会增加Heap的压力,从而触发更多其他类型的GC。这个操作有可能会影响到帧率,并使得用户感知到性能问题。

当你大致定位问题之后,接下去的问题修复也就显得相对直接简单了。例如,你需要避免在for循环里面分配对象占用内存,需要尝试把对象的创建移到循环体之外,自定义View中的onDraw方法也需要引起注意,每次屏幕发生绘制以及动画执行过程中,onDraw方法都会被调用到,避免在onDraw方法里面执行复杂的操作,避免创建对象。对于那些无法避免需要创建对象的情况,我们可以考虑对象池模型,通过对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。

8、总结

最坏情况下16ms内需要做的事情:

  • View进行Measure,进行Layout,进行Draw
  • 为View创建DisplayList
  • 将数据从CPU传递到GPU
  • GPU执行DisplayList
  • CPU等待GPU执行完毕的回调
  • 其中如果进行了GC,还会暂停所有线程。
  • ListView,绑定数据还会有各种业务逻辑。

四、优化方案

然后参照上面的几个方面,我们对直播间进行优化。

1、布局及绘制优化

要做到让布局平铺,减少嵌套。

如图,这些都是可以优化的地方。

因为ActivitLive本身就是一个壳,只需要一层FrameLayout用于添加Fragment,因此可以减少ActivityLive的一层嵌套。

移除聊天室xml背景,因为聊天室的背景色和主题色是一样的,因此没必要再绘制一次。

某些自定义的ViewBox,自身就是RelativeLayout的子类了,因此对应的xml应该用merge标签。

以上都是很直观的从xml里面修改,是最先能够想到的优化地方。

接着我们借助Android提供的工具,使用Systrace。打开直播间,点击弹起输入框。得到如下一些问题

这里提示了过度的耗时的测量和布局

首先,我们探究一下View.GONE对布局性能的影响。 因为在云课堂,直播间是嵌套在一个课时模块页面里面。课时页面有2个头图封面CoverBox。我们将这个头图设置为GONE属性,因为直播间用不到这个控件。发现

弹起键盘和收起键盘都会有一次的重新测量和布局。 当我们改成INVISIBLE。发现不止一次的测量。

结论是,虽然,VIEW.GONE会触发View树的重新测量及布局,但对于页面的VIEW比较稳定在不显示的状态下,应该设置成GONE,减少测量和布局。

然后课时页有一个ViewPager,它会保存左右两边的View,所以当键盘弹起或收起的时候,会触发整个根View的测量和布局,从而ViewPager保存的所有的View也同样进行。

发现左右两边的View,在OnMeasure和OnLayout阶段并不耗时,主要耗时在当前的View。 接着,将未嵌入课时页的直播间和嵌入课时页的直播间进行了测量耗时的对比。

发现每一次测量rootView布局,会多次测量直播间view。原来是RelativeLayout导致的,这里嵌套了两层RelativeLayout。所以足足多了4倍的测量。

课时页的布局,在里面的Relative里面塞着直播间模块。

尽可能少用Relative嵌套太多复杂的布局。

接着发现ListView在滚动更新View的时候,报出的警告。这说明getView()方法返回的太慢。然后通过查看方法调用时间工具,得到如下图。

就拿聊天面板展示文本消息的这个ChatRoomViewHolderText,在绑定数据所进行的操作来分析。如图,红色的都是耗时的。

因为bingContentView方法实在每次getView()里绑定数据的时候调用的。这个方法会比较频繁调用,因此一些平常看起来不那么耗时的操作,在这里都会被放大几十倍。如findViewById、setText、NTLog.d、getDrawableByName、ScreenUtils.dip2px。另外还有些逻辑重复,在绑定数据的最开始setText,替换Emoji表情。然后在setLinkClickIntercept方法中,为了寻找超链接的文本,又进行了一次setText和替换Emoji。这里其实可以归并在一起。而对于ScreenUtils.dip2px每次都是一个固定的值,可以用静态变量代替。getDrawableByName可以用成员变量代替。

五、优化总结

关于布局方面:

  • 尽量减少布局的嵌套,尤其对于RelativeLayout,应该使用ConstrainLayout来缓解嵌套布局。
  • 当一个页面聚合着很多模块内容控件,但却依赖业务需求而只需要展示对应的部分控件。应该竟可能避免在xml里面写死,而应该通过动态加入或移除的方式来管理View。好处是减少View树的复杂性,避免生成一些不必要的View,当页面改动,如setVisible(GONE),触发View树重测时候,View树越简单,测量的耗时就越少。
  • 如果这个页面某个逻辑分支里,这个模块是永不可见的,应该尽可能设置成GONE,让它不要参与测量和布局。
  • 如果这个页面控件位置都是稳定的,应该尽可能使用INVISIBLE,因为setVisible(GONE)会触发重测。
  • 一些自定义的View,如XXXView extends FrameLayout,它的xml里应该使用merge标签,不要增加不必要的View层级
  • 尽量在页面减少View的大小更变,这会触发Invalidations,导致View树重测,重新生成DisplayList。

绘制方面:

  • 可以定一个这个页面的主题色,然后删去所有不必要的Background颜色绘制。
  • android对于原生的View控件有一套优化机制,就是在view不可见的情况下,不会去绘制。而我们自定义的view应该通过canvas.Cliprect,canvas.QuickReject等APIs进行优化
  • 避免在onDraw的操作里进行大量的耗时或者创建对象的操作

对于一些耗时方法的:

  • 一些看起来不耗时的方法,放在for循环里,onDraw,listView.getView()里,当被平反调用时,耗时都是被放大的。
  • 方法内调用的逻辑应该清晰,合并一些方法,减少重复调用。
  • 在方法内,调用了其他方法,并且频繁需要使用它的返回对象的时候,应该用一个临时变量保存起来,减少该方法的调用。如if(list.get(index)!=null){list.get(index).getName()}可以优化。
  • 一些调用一次,并且结果就固定的,应该用静态变量或成员变量。

参考文章:

LinearLayout和RelativeLayout测量分析

关于隐藏软键盘出现黑屏问题记录

Android Performance Patterns: Invalidations, Layouts, and Performance


本文来自网易实践者社区,经作者陈柏宁授权发布。