Android性能优化-App卡顿
目录
1.卡顿简介
2.检测Jank:介绍监测卡顿的方法
3.修复卡顿问题:介绍如何修复卡顿问题;
4.卡顿通常的原因:介绍导致卡顿问题的常见原因
1.卡顿介绍:
为了保证应用的平滑性,每一帧渲染时间不能超过16ms,达到60帧每秒;如果UI渲染慢的话,就会发生丢帧,这样用户就会感觉到不连贯性,我们称之为Jank;本文提供一些关于检测和修复丢帧问题的一些指导;可以通过adb shell dumpsys gfxinfo <package name>来确定应用渲染时间指标;
2.监测Jank
在您的应用程序中定位引起jank的代码可能很困难。 本部分介绍了三种识别jank的方法:Visual inspection:通过视觉检查,您可以在几分钟内快速浏览应用程序中的所有用例use-cases,但不能提供与Systrace相同的详细信息。Systrace:Systrace提供了更多的细节,但是如果你运行Systrace来处理应用程序中的所有用例,那么就会被大量的数据淹没,难以分析;Custom performance monitoring:Visual inspection和Systrace都会在你的本地设备上检测到。如果不能在本地设备上重现,则可以构建自定义性能监视器Custom performance monitoring,以测量在现场运行的设备上应用的特定部分。
2.1检测Jank一:Visual inspection
即打开开发者选项中的GPU渲染模式分析,目视检查可以帮助您识别正在生产结果的使用案例。 要执行视觉检查,请打开您的应用程序并手动执行应用程序的不同部分,然后查看是否有丢帧情况。以下是GPU渲染模式分析检测的一些注意事项:
(1)运行release 版本:运行您release应用程序的版本(或至少不可调试)的版本。ART运行时为了支持调试功能而禁用了一些重要的优化,所以确保你正在寻找类似于用户将看到的东西。
(2)开启GPU渲染:设置-开发者选项-GPU渲染模式分析:开启配置文件GPU渲染,会在屏幕上显示条形图,可以快速直观地显示相对于每帧16毫秒基准测试渲染UI窗口帧所花费的时间。每个条都有不同颜色的组件映射到渲染管道中的一个操作,所以你可以看到哪个部分花费的时间最长,例如,如果框架花费大量时间处理输入,则应该查看处理用户输入的应用程序代码。如下图所示:
开启GPU渲染效果图.png
(3)留意特殊组件:有一些组件,如RecyclerView,是Jank普遍的来源。 如果您的应用程序使用这些组件,那么运行应用程序的这些部分是一个很好的想法;
(4)App 冷启动导致:有时候,只有当应用程序从冷启动启动(Clod start)时,才能复现Jank;
(5)将应用运行在比较慢的机器上更容易复现这些问题;
如果你找到了导致丢帧的场景,那么你应用大致知道是什么原因导致丢帧的,可以使用systrace进一步分析该问题;
2.2检测Jank二:systrace
Systrace可以显示整体设备正在做什么,并且可以识别应用程序中的Jank,SysTrace系统开销很小,所以你可以检测到真正的卡顿问题;
用SysTrace记录执行导致卡顿的用例,可以参考systrace如何使用SysTrace,systrace被进程和线程分解,我们来看一下下图中SysTrace中的应用进程:
Figure 1: systrace
上图中的systrace包含以下信息可以用来检测Jank;
(1)SysTrace显示每一帧的绘制,并会高亮显示渲染比较慢的帧,使用绿色、黄色、红色来显示,每一帧的渲染时间超过16ms会用黄色或者红色来显示;这个比GPU渲染模式分析更准确的显示每一帧,更过的信息可以参考Inspecting Frames。
(2)SysTrace检测出来的应用问题显示在每一帧和警告面板中,丢帧问题的修改可以参考警告面板中的提示;
(3)部分Android框架和jar包代码中包含了Trace标记,例如RecyclerView中,这样在SysTrace的时间线中会显示主线程中那个方法被执行以及执行时间;
(4)在分析SysTrace结果之后,我们会找到可能导致丢帧的相关方法,然后在相关代码中添加Trace编辑(如何添加Trace标记可以参考systrace),然后继续抓取新的SysTrace信息。这样在新的SysTrace中可以看到方法何时被调用以及方法执行时间;
(5)如果SysTrace看不出来主线耗时比较严重的详细信息,可以使用CPU Profiler记录和检查函数跟踪,由于记录和检测函数跟踪系统开销比较大,所以函数跟踪并不是很好检测卡顿的很好手段,但是函数跟踪可以帮助我们找到那些比较耗时的方法,在检测出这些耗时方法之后,可以添加trace标记,然后抓取新的trace来查看是否这些耗时的方法导致丢帧;
(6)Systrace每对标记开始和结束需要增加10纳秒的系统开销,为了避免错误的丢帧判断,不要将trace添加在每一帧中需要频繁调用的方法中,也不要为小于200纳秒的方法添加trace标记;
更多信息可以参考Understanding Systrace
2.3检测Jank三:Custom performance monitoring
如果您无法在本地设备上再复现丢帧情况,则可以在您的应用中构建自定义性能监控,以帮助识别设备上的真实丢帧情况。为此,请使用FrameMetricsAggregator从应用程序的特定部分收集帧渲染时间,并使用Firebase性能监控记录和分析数据。要了解更多信息,请参阅[Use Firebase Performance Monitoring with Android Vitals.
]
3.修复卡顿问题
1.为了修改丢帧问题,我们需要先检测出来那些帧的执行时间超过16.7ms,再查看那些地方运行错误,是否是Record View#draw在一些帧中执行时间过程,还是Layout问题导致丢帧?我们查看常见丢帧问题的原因;
2.为了避免丢帧,比较耗时操作需要不能放在主线程中执行,同事需要知道代码运行在那个线程中,同时需要注意一些无意义的任务放到主线程中去执行;
3.如果应用有一些复杂并且重要的UI组件,可以考虑写一些测试用例来检测耗时操作,通过运行这些测试用例来避免耗时操作,更多的信息可以参考 Automated Performance Testing Codelab.
4.卡顿通常的原因
下面介绍应用中丢帧问题的常见原因,并介绍如何修改这类问题;
4.1滑动列表
(1)滑动列表:ListView和RecyclerView经常会使用到复杂的滑动列表,这些复杂的列表经常会导致丢着,在ListView和RecyclerView中已经添加Systrace标记,我们可以使用SysTrace来发现应用中导致丢帧问题的原因,我们可以根据警告面板中建议来修改丢帧问题,同时可以查看每一帧中RecyclerView中主要在做那些工作;
(2)RecyclerView:notifyDataSetChanged;主要是解决RecyclerView部分内容更新导致卡顿问题;如果我们发现在一帧中列表中所有的内容都重新绑定(重新layout和draw),为了比较微小的更新请确保不要调用notifyDataSetChanged、setAdapter(Adapter)、swapAdapter(Adapter, boolean),这些方法会导致列表所有内容更新,在SysTrace中会发现RecyclerView列表全部更新,当列表内容更新或者添加时,我们可以使用SortedList和DiffUtil来完成一个比较小的更新。例如:应用收到服务器新闻内容更新时,我们将信息告诉Adapter时,可能会使用如下notifyDataSetChanged()方法;
void onNewDataArrived(List<News> news) {
myAdapter.setNews(news);
myAdapter.notifyDataSetChanged();
}
但是这是一个比较大的问题,如何仅仅是一个比较小的变化,例如仅仅更新一个item,将所有的item都进行更新,这样显然是不合理的,我们可以使用DiffUtil来计算和分配最小更新,如下所示:通过定义MyCallback来实现DiffUtil.Callback,以此告知DiffUtil来监控RecyclerView列表;
void onNewDataArrived(List<News> news) {
List<News> oldNews = myAdapter.getItems();
DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
myAdapter.setNews(news);
result.dispatchUpdatesTo(myAdapter);
}
(3)RecyclerView: Nested RecyclerViews:主要是解决嵌套RecyclerView卡顿问题;RecyclerView嵌套是一种很常见的情况,例如水平滚动的垂直列表,这个可以很好的工作,还是有itemView;如果有很多内部item在第一个滑动方向被加载,则可能需要检查是否在内部(水平)RecyclerViews之间共享RecyclerView.RecycledViewPools。每个RecyclerView都有自己的item pool,这样在屏幕一次会有很多的itemViews,如果每一行都有类似的view,并且itemView不能被不同方向上列表所共享的话,就会带来问题,我们可以使用如下方法来修改该问题:
class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> {
RecyclerView.RecycledViewPool mSharedPool = new RecyclerView.RecycledViewPool();
...
@Override
public void onCreateViewHolder(ViewGroup parent, int viewType) {
// inflate inner item, find innerRecyclerView by ID…
LinearLayoutManager innerLLM = new LinearLayoutManager(parent.getContext(),
LinearLayoutManager.HORIZONTAL);
innerRv.setLayoutManager(innerLLM);
innerRv.setRecycledViewPool(mSharedPool);
return new OuterAdapter.ViewHolder(innerRv);
}
...
如果想更进一步优化,我们可以在LinearLayoutManager中使用setInitialPrefetchItemCount(int)方法,例如每一行有3.5个item需要显示,调用innerLLM.setInitialItemPrefetchCount(4);这将告诉RecyclerView,当一个水平行即将出现在屏幕上时,如果UI线程上有空闲时间,它应该尝试预取内部的项目
(4)RecyclerView: Too much inflation / Create taking too long:主要解决RecyclerView inflate item时导致的卡顿问题;UI线程则处于闲置状态下,RecyclerView中的预取功能应该有助于在大多数情况下通过提前完成工作来解决inflation Layout的成本问题。如果经常看到inflation Layout导致屏幕上出现新的Jank,请确保Inflate时没有多余的view类型;View越少,这样在inflate时需要做的事就越少,如果可以的话,尽量合并不同的view类型,加入一个图标、颜色、text有不同的表现形式,我们可以在bind item的时候进行改变,从而避免在inflate时多做工作,这样也可以减少内存;我的理解是item类型尽量一直,inflate的时候性能比较好,如果有很多不同的item类型,会影响到inflate效率,从而导致卡顿;如果View多种类型已经没有问题,我们还可以减少view容器以及view的层级,构建itemViews时可以考虑使用ConstrainLayout,该布局可以减少View的层级。view的层级简单、不使用复杂的主题风格这样可以提升应用的性能。
(5)RecyclerView: Bind taking too long:主要是绑定操作尽量简单,RecyclerView的onBindViewHolder操作也需要很简单,最复杂的item的绑定操作也不能大于一毫秒,Adapter中item可以采用简单的Java对象,如果RecyclerView的onbindView操作比较耗时,那么需要确保在绑定操作时做最少的工作;如果您使用简单的POJO对象来保存适配器中的数据,则可以完全避免使用Data Binding库来将绑定代码写入onBindViewHolder。
(6)RecyclerView or ListView: layout / draw taking too long:有关布局和绘制问题,可以参考下边Layout性能和渲染性能部分。
4.2布局性能
如果Systrace显示Choreographer#doFrame的布局部分工作太多,或者工作频繁,这意味着您遇到了布局性能问题。您的应用的布局性能取决于View层次结构的哪个部分具有更改布局参数或输入。
(1)Layout performance: Cost:如果布局操作完成的时间超过几毫秒,这很可能是使用性能比较差的RelativeLayout和Linearlayout,这两种布局可能
导致子view做两次测量和布局操作,这样嵌套的时间复杂度就是O(n^2);所以我们应该尽量避免使用这两种布局。主要有以下几点建议:
建议一:优化View结构;
建议二:使用自定义View可以参考optimize your layout;
建议三:尝试使用ConstraintLayout:它提供了类似的功能,并且没有性能上的缺陷。
(2)Layout performance: Frequency:当新的内容显示在屏幕上时,就会发生layout操作,例如新的item在RecyclerView中显示出来,如果在每个帧上都发生重要的布局,则可能是在布局上进行动画处理,这很可能导致丢帧。通常,动画应该在View的绘图属性(例如setTranslationX / Y / Z(),setRotation(),setAlpha()等)上运行。这些都可以比Layout属性(如设置padding或margin)更好地更改。 通常通过调用触发invalidate()操作,然后在下一帧中绘制(Canvas),来更改视图的绘制属性。 这将重新记录无效的视图的绘图操作,并且通常也比布局好得多。
4.3.Rendering performance
Android UI两个阶段工作:在UI线程上Record View.draw,在RenderThread上绘制DrawFrame。第一阶段工作在每次更新View的时候调用View.draw,这可能会调用自定义视图或代码,第二阶段工作运行本地RenderThread中,将根据View.draw阶段生成的工作进行操作。
(1)Rendering performance: UI Thread:如果View.draw需要很长的时间,则通常是在UI线程进行绘制Bitmap操作,绘制bitmap需要使用CPU渲染,可以通过CPU Profiler来确定在主线程绘制bitmap是否有问题;有时候bitmap在显示之前需要进行一些操作,例如如下添加圆角:
Canvas bitmapCanvas = new Canvas(roundedOutputBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
// draw a round rect to define shape:
bitmapCanvas.drawRoundRect(0, 0,
roundedOutputBitmap.getWidth(), roundedOutputBitmap.getHeight(), 20, 20, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY));
// multiply content on top, to make it rounded
bitmapCanvas.drawBitmap(sourceBitmap, 0, 0, paint);
bitmapCanvas.setBitmap(null);
// now roundedOutputBitmap has sourceBitmap inside, but as a circle
如果这些操作是在UI线程中知心个,可以考虑将这些工作放在异步线程中,如果Drawable和View得代码如下,可以直接在主线程中操作:
void setBitmap(Bitmap bitmap) {
mBitmap = bitmap;
invalidate();
}
void onDraw(Canvas canvas) {
canvas.drawBitmap(mBitmap, null, paint);
}
如果想使用bitmap的缓存,可以考虑使用硬件加速来实现,这样可以使用GPU来进行渲染操作;
(2)Rendering performance: RenderThread:有时候View.draw操作很简单,但是会触发渲染线程执行大量的计算,我们可以通过SysTrace提示发现该问题;主要有以下建议:
第一,尽量少使用Canvas.saveLayer()方法,因为该方法消耗较大,并且没有缓存数据,每次离屏都会从新渲染每一帧,尽管Android6.0版本进行了优化,但是还是不建议使用该方法;
第二,Animating large Paths:当使用硬件加速绘制路径到Views上时,Android首先在CPU上绘制这些路径,然后将它们上传到GPU。如果路径较大,请避免逐帧编辑,以便高速缓存和绘制。 drawPoints(),drawLines()和drawRect / Circle / Oval / RoundRect()更有效率,即使最终使用更多的绘制调用,最好使用它们。
第三:clipPath(Path)触发了昂贵的裁剪行为:通常应该避免。 如果可能,选择绘制形状,而不是剪裁到非矩形。它性能更好,支持抗锯齿。 例如,下面的clipPath调用:
canvas.save();
canvas.clipPath(mCirclePath);
canvas.drawBitmap(mBitmap, 0f, 0f, mPaint);
canvas.restore();
可以使用下边代码进行替换;
// one time init:
mPaint.setShader(new BitmapShader(mBitmap, TileMode.CLAMP, TileMode.CLAMP));
// at draw time:
canvas.drawPath(mCirclePath, mPaint);
(3)Bitmap uploads:Android使用OpenGL纹理来显示bitmap,在某一帧中Bitmap第一次进行显示,需要长传到GPU中,我们可以在SysTrace中看到该操作
Upload width x height Texture,该过程需要几毫秒,但是这些image笔仙使用GPU显示。如果该操作比较耗时,我们首先需要检测SysTrace中bitmap
的宽高是否比屏幕的实际显示区域大,如果是的话,这会浪费上传时间以及内存,通常Bimap加载库提供了简单的方法来请求适当大小的位图。
在Android 7.0中,位图加载代码(通常由库完成)可以在需要之前调用prepareToDraw()来及早触发上传。这样上传发生的早,而RenderThread空闲。
这可以在解码之后完成,也可以在将位图绑定到View时进行,只要知道位图即可。理想情况下,你的位图加载库会为你做这个,
但是如果你正在管理你自己的,或者想确保你没有在新设备上点击上传,你可以在你自己的代码中调用prepareToDraw()。
下图显示应用加载一个1356*1356pixel像素大小的图片花了10ms,我么可以调整该图片的大小或者使用prepareToDraw;
4.4.Thread scheduling delays
线程调度程序是Android操作系统的一部分,负责决定系统中哪些线程应该运行,何时运行以及运行多长时间。 有时候,因为你的应用程序的UI线程被阻塞或者没有运行,就会发生Jank。 Systrace使用不同的颜色来指示线程正在Sleep(灰色),Runnable(蓝色:可以运行,但调度程序还没有选择它运行),正在运行(绿色)或中断(红色或橙色),如下图所示。 这对于调试线程调度延迟导致的Jank`问题非常有用。
image
Figure 3: highlights a period when the UI Thread is sleeping.
提示:旧版本Android更频繁地遇到不是应用程序故障的调度问题。 在这方面Android进行了不断的改进,所以考虑在最近的操作系统版本上更多的调试线程调度问题,在这些版本中,被调度的线程更可能是应用程序的错误。UI线程或RenderThread预计不会运行时,有框架的一部分, 例如,UI线程在RenderThread的syncFrameState正在运行并且上传位图时被阻塞,这是因为RenderThread可以安全地复制UI线程所使用的数据。 另一个例子是,RenderThread在使用IPC时可以被阻塞:在帧的开始处获取缓冲区,从中查询信息,或者通过eglSwapBuffers将缓冲区传回给合成器。
Binder调用经常会导致主线程卡住,这些是Android上进程间通信机制,在最近Android版本中个,导致UI线程卡住的主要原因是因为Binder调用,如果不可避免的需要Binder调用,我们可以缓存数据,绘制将binder调用放在异步线程中,当代码量比较大的时候,binder调用经常会导致该问题,但是这些问题通过Trace很容易被发现和修改;如果您有绑定事务,则可以使用以下adb命令来捕获其调用堆栈:如下代码所示:
$ adb shell am trace-ipc start
… use the app - scroll/animate ...
$ adb shell am trace-ipc stop --dump-file /data/local/tmp/ipc-trace.txt
$ adb pull /data/local/tmp/ipc-trace.txt
有时像getRefreshRate()这样的无害的表面调用可能会触发绑定事务,并在频繁调用时导致严重的问题。 定期跟踪可以帮助您快速找到并解决这些问题。如果发现没有binder调用,并且UI线程还在阻塞,请确保主线代码等待异步线程中的锁,通常主线程不应该等待异步线程中的消息,
这样可能导致主线程阻塞;
4.5.Object allocation and garbage collection
对象分配和垃圾回收(GC)已经成为一个问题,因为ART是Android 5.0中默认运行时引入的,但是仍然有可能通过这些额外的工作来减轻你的线程负担。 对于每秒钟不会发生多次的罕见事件(如用户单击按钮)进行分配是很好的做法,但要记住,每次分配都需要付出一定的代价。 如果它处于一个频繁调用的紧密循环中,请考虑避免分配来减轻GC上的负载。Systrace会告诉你GC是否频繁运行,Android Memory Profiler可以显示你的分配来自哪里。 如果你可以避免分配,特别是在紧密的循环中,那应该就没有问题;在最新版本的Android上,GC通常在名为HeapTaskDaemon的后台线程上运行。 请注意,大量的分配可能意味着更多的CPU资源花费在GC上.如下图所示;
image
Figure 5: shows a 94ms GC on the HeapTaskDaemon thread
参开资料:
https://www.jianshu.com/p/12ce50137605
https://developer.android.google.cn/topic/performance/vitals/render#java
http://hukai.me/android-performance-patterns/