Android降低UI渲染速度的检测、诊断及修复
一. Slow rendering - jank
- 为了保证UI交互的流畅,必须保证每帧的渲染时间不超过16毫秒,保证60的FPS。
- 一旦界面有较慢的渲染,系统将强制跳帧,用户就会感觉到卡顿。
- We call this jank.
二. 定位jank
1. 三种定位方法
想要准确定位发生jank的代码并不容易,以下三个办法可以帮助开发者:
- 视觉检查:可以快速直观的发现jank界面
- Systrace:能提供更多的细节信息
- FrameMetricsAggregator:在Firebase Performance Monitoring中可以查看分析数据
2. 方法一:使用视觉检查
打开app,手动切换不同的界面并查看哪些疑似jank。下面是一些检查经验:
1. 应当启动release版本的app,或不可调式版本的app。因为ART运行时为了支持调式功能,禁用了一些重要的优化。
2. 在开发者选项中,打开Profile GPU Rendering开关(GPU呈现模式)。Profile GPU Rendering可以直观地展示绘制耗时。不同的颜色代表了不同的绘制操作。
3. 有一些组件常常会导致jank,比如RecyclerView
。可以重点关注包含这些组件的界面。
4. 有些jank只会发生在冷启动过程中。
5. 尽量在性能更低的测试机上做检查,原因你懂的—–让卡的变的更卡、更明显。
3. 方法二:使用systrace
Systrace能有效地发现jank,而且系统开销极小。有两种启动方法:
- 通过device monitor来启动systrace
- 现在AS默认不集成Monitor,可以使用python来启动(需要python环境):
`python systrace.py --time=10 -o mynewtrace.html sched gfx view wm`
4. 方法三:使用FrameMetricsAggregator
使用FrameMetricsAggregator
来收集app帧渲染的时间,使用Firebase Performance Monitoring来记录和分析数据。
三. 修复jank
- 你需要坚持哪些帧没有在16.7毫秒内渲染完成,然后发现问题。
- 一般来说,将耗时任务放在异步工作线程可以有效避免jank。
- 有个有效的办法:经常注意代码执行在哪个线程中,并且在耗时代码中检查当前线程,如果是主线程则发出警告。
- 如果有非常重要且复杂的UI,比如Scrolling List,考虑用Automate UI performance tests
- 下面将列举一些常见的jank原因
四. 常见jank原因
1. Scrollable lists
-
ListView
,尤其是RecyclerView
,你应该使用Systrace来查看它们是否导致jank。
2. RecyclerView: notifyDataSetChanged
- 如果RecyclerView每个item正在重新绑定(将会导致重新布局和绘制),请不要使用
notifyDataSetChanged()
,setAdapter(Adapter)
, 或者swapAdapter(Adapter, boolean)
来更新很小部分的数据。它们会标示整个列表都发生改变,在Systrace中会显示为RV FullInvalidate。 - 应当使用
SortedList
或DiffUtil
来进行少量更新或增加。 -
示例代码,考虑从服务端获取一个新的信息list,使用
notifyDataSetChanged
:void onNewDataArrived(List<News> news) { myAdapter.setNews(news); myAdapter.notifyDataSetChanged(); }
但这有个很严重的潜在问题:如果list变动很小,比如仅仅是新增了一个数据,
RecyclerView
将会清除所有item缓存,重新绑定所有的item views。 -
示例代码,使用
DiffUtil
:void onNewDataArrived(List<News> news) { List<News> oldNews = myAdapter.getItems(); DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news)); myAdapter.setNews(news); result.dispatchUpdatesTo(myAdapter); }
而你只需要实现接口
DiffUtil.Callback
来告诉DiffUtil
该怎样检查list对比结果。
3.RecyclerView: Nested RecyclerViews
- 嵌套RecyclerView,特别是水平滑动list里面嵌套一个竖直RecyclerViews。
- 当你首次滑动页面,而如果有太多的内部item,可以在内部
RecyclerView
s中考虑使用RecyclerView.RecycledViewPool
s。 -
如果你有一打或更多
RecyclerView
需要显示,而且他们的itemViews
类似,就应该将itemViews
在各个RecyclerView
*享: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个项目。
4. RecyclerView: Too much inflation / Create taking too long
- UI线程有空时,使用预取功能使inflation Layout工作更有效率。
- 如果您在一帧中看到 inflation Layout (而不是标记为 RV Prefetch 的部分),请确保您正在测试最近的设备( Prefetch 目前仅在 Android 5.0 API Level 21 及更高版本上支持),并使用最近版本的Support Library.
- 当新的item显示在屏幕时,如果发现inflation 导致jank,
RecyclerView
中的view可能就太多了点,需要删除多余的view。 - 如果在各个view类型中只有一个图标,颜色,或一条文本信息的差别,就有理由合并view类型,在绑定时改变这些信息。这样可以避免inflate,同时减少内存占用。
5. RecyclerView: Bind taking too long
- 尽量减少
onBindViewHolder(VH, int)
的调用时间,不要在其中做多余的事情。 - 如果只是一些简单的pojo数据,尽量不要使用
Data Binding library
(数据绑定库)。
6. RecyclerView or ListView: layout / draw taking too long
- 尽可能减少布局层次复杂度。
7. ListView: Inflation
- 确保ListView的缓存机制运作正常。缓存的View不应当再次inflate。如果每当屏幕显示item都会inflate(即使它已经显示过了),说明缓存复用机制失效了。
-
示例代码:
view getView(int position, View convertView, ViewGroup parent) { if (convertView == null) { // 仅当第一次显示时flate,此处应添加到缓存 convertView = mLayoutInflater.inflate(R.layout.my_layout, parent, false) } // 这里绑定相应的数据到convertView return convertView; }
8. layout性能
如果Systrace 显示Layout的Choreographer#doFrame做了太多事情,或者太过频繁,那就表示layout性能存在问题。如果View 层次结构改变layout参数或输入,就会导致layout性能问题。
-
Layout performance: Cost
如果这部分超过了几毫秒,很可能的原因就是
RelativeLayouts
或weighted-LinearLayouts
的嵌套布局碰到了最坏的情况。每个layout都会触发其子View的多次measure
/layout
,所以这些layout的嵌套会导致layout时间开销为基于嵌套层次的O(n^2)。有一些方法可以可供参考:
- 重新充足View层次结构
- 自定义Layout,修改其layout部分
- 使用ConstraintLayout
。这个布局可以满足类似的需求,同时可以避免性能缺陷。 -
Layout performance: Frequency
当新内容出现时,将会发生新的Layout。例如,当一个新item在RecyclerView
中滚动到屏幕中。如果每帧都有重要的layout,而此时又有可能正在变动layout,这种情况下极有可能会掉帧。修改layout参数会导致重新layout。
想要减少开销,请使用View属性动画(比如
setTranslationX/Y/Z()
,setRotation()
,setAlpha()
等等),它比改变layout属性(比如padding或margin)的性能开销小的多。通过触发
invalidate()
(接下来会在下一帧draw)改变View的属性,其性能也比改变layout属性要更好。这将重新记录无效的视图的绘制,并且同样也比布局性能好的多。
9.Rendering performance: UI Thread
Android UI会在两个阶段开始工作:
- 在UI线程中,Record View#draw:
在每个无效的View上绘制(Canvas),并可能调用自定义视图或代码。
- 在RenderThread中,DrawFrame
在本地RenderThread上运行,但是将根据`Record View#draw`阶段生成的工作进行操作。
-
Rendering performance: UI Thread
如果
Record View#draw
花费了大量时间,经常可能的原因就是Bitmap
正在UI线程中被绘制。绘制Bitmap会用到CPU渲染,所以一般要尽量避免。可以使用Android CPU Profiler
来检测这个问题。-
绘制位图通常是在应用程序想要在显示位图之前修饰位图的时候完成的。比如绘制圆角图片:
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线程中的工作,你可以将这些工作放在后台启动的解码线程中,甚至可以放在draw的时候。
-
示例代码,性能差的的代码:
void setBitmap(Bitmap bitmap) { mBitmap = bitmap; invalidate(); } void onDraw(Canvas canvas) { canvas.drawBitmap(mBitmap, null); }
-
示例代码,提升性能的改变:
void setBitmap(Bitmap bitmap) { mShaderPaint.setShader( new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP)); invalidate(); } void onDraw(Canvas canvas) { canvas.drawRoundRect(0, 0, mWidth, mHeight, 20, 20, mShaderPaint); }
这通常也可以用于后台保护(在位图顶部绘制渐变)和图像过滤(使用
ColorMatrixColorFilter
)。如果由于其他原因(可能将其用作缓存)绘制到位图,则尝试绘制直接传递到View或Drawable的硬件加速硬件,如有必要,可考虑使用
LAYER_TYPE_HARDWARE
调用setLayerType()
来缓存复杂的渲染 输出,并仍然利用GPU渲染。
-
-
Rendering performance: RenderThread
一些
Canvas
操作记录开销很小,但会在RenderThread
中触发昂贵的计算。Systrace会对此作出警告的:Canvas.saveLayer()
: 尽力避免。
它将触发每帧昂贵的、无缓存的离屏渲染,应当尽量避免,或者至少确保传递CLIP_TO_LAYER_SAVE_FLAG
(或者调用一个不带flag的变量).-
Animating large Paths:
当硬件加速Canvas传递给Views时,
Canvas.drawPath()
被调用,Android首先在CPU上绘制这些路径,然后将它们上传到GPU。 如果路径较大,请避免逐帧编辑,以便高速缓存和绘制。drawPoints()
,drawLines()
,drawRect/Circle/Oval/RoundRect()
效率更高,即使最终调用了更多的draw方法。 -
Canvas.clipPath
:
会触发昂贵的裁剪操行为,应尽量避免。如果可能,应选择绘制形状,而不是裁剪到非矩形形状。绘制的性能更好,而且抗锯齿。
-
示例代码,性能差的:
canvas.save(); canvas.clipPath(mCirclePath); canvas.drawBitmap(mBitmap); canvas.restore();
-
示例代码,优化过的:
// one time init: mPaint.setShader(new BitmapShader(mBitmap, TileMode.CLAMP, TileMode.CLAMP)); // at draw time: canvas.drawPath(mCirclePath, mPaint);
-
-
Bitmap uploads
Android将位图显示为OpenGL纹理,并且首次在一帧中显示位图时,将其上传到GPU。可以在Systrace中将此视为上传宽度x高度纹理。这可能需要几个毫秒(见下图),但是有必要用GPU显示图像。
如果这些花费很长时间,请首先检查trace中的宽度和高度。如果正在显示的位图比屏幕大很多,就是浪费时间和空间。一般bitmap加载库会提供简单的方法来请求大小合适的位图。
在Android 7.0中,一些图片库可能会在需要图片之前调用预加载方法
prepareToDraw()
来触发更早的GPU上传,此时RenderThread
是空闲状态。这个操作可以在解码后做,也可以在将图片绑定到View时来做。
一般来说,图片库会帮你做这个。除此之外,如果想自己管理图片,或想确定不会在更新的设备上传,也可以在合适的地方手动调用
prepareToDraw()
。
10.线程调度延迟(Thread scheduling delays)
-
Systrace 会用不同的颜色来指示线程状态:
- 灰色:Sleeping ,睡眠
- 蓝色:Runnable,可以运行,但调度器还没有选择它运行
- 绿色:Actively running ,正在运行
- 红色或橙色:Uninterruptible sleep
这在调试因线程调度延迟导致的jank问题时,非常有用。
图中可以看到,UI线程在RenderThread的syncFrameState 正在运行时和bitmap上传时会被阻塞, -
另一种情况:RenderThread在使用IPC时会被阻塞:在帧的开始处获取缓冲区,从中查询信息,或者通过
eglSwapBuffers
将缓冲区传回给合成器。在最近版本的Android中,导致UI线程停止的原因常常就是IPC。
而修复措施如下:- 尽量避免远程调用
- 如果必须要用远程调用,请缓存数据以便返回,或在后台线程中调用远程方法。
-
可以通过adb命令来捕获binder transactions的方法调用栈:
$ 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 transactions,并在频繁调用时导致严重的问题。定期跟踪可以快速找到并解决这些问题:上图显示,在RV fling中的binder transactions导致UI线程睡眠。请保持简洁的bind逻辑,使用
trace-ipc
来追踪并删除远程调用。 如果你没有看到绑定Activity,但仍然没有看到你的UI线程运行,确保没有等待另一个线程的锁或其他操作。通常,UI线程不应该等待来自其他线程的结果。
11. 对象分配和GC
Systrace会告诉你GC是否频繁运行,Android Memory Profiler可以显示对象分配发生在哪儿。
HeapTaskDaemon thread中,花费94ms的GC。
- 请尽量避免在密集的循环中分配对象。