想要准确定位发生jank的代码并不容易,以下三个办法可以帮助开发者:
- 视觉检查:可以快速直观的发现jank界面
- Systrace:能提供更多的细节信息
- FrameMetricsAggregator:在Firebase Performance Monitoring中可以查看分析数据
打开app,手动切换不同的界面并查看哪些疑似jank。下面是一些检查经验:
1. 应当启动release版本的app,或不可调式版本的app。因为ART运行时为了支持调式功能,禁用了一些重要的优化。
2. 在开发者选项中,打开Profile GPU Rendering开关(GPU呈现模式)。Profile GPU Rendering可以直观地展示绘制耗时。不同的颜色代表了不同的绘制操作。
3. 有一些组件常常会导致jank,比如RecyclerView
。可以重点关注包含这些组件的界面。
4. 有些jank只会发生在冷启动过程中。
5. 尽量在性能更低的测试机上做检查,原因你懂的—–让卡的变的更卡、更明显。
Systrace能有效地发现jank,而且系统开销极小。有两种启动方法:
- 通过device monitor来启动systrace
- 现在AS默认不集成Monitor,可以使用python来启动(需要python环境):
`python systrace.py --time=10 -o mynewtrace.html sched gfx view wm`
使用FrameMetricsAggregator
来收集app帧渲染的时间,使用Firebase Performance Monitoring来记录和分析数据。
ListView
,尤其是RecyclerView
,你应该使用Systrace来查看它们是否导致jank。notifyDataSetChanged()
, setAdapter(Adapter)
, 或者 swapAdapter(Adapter, boolean)
来更新很小部分的数据。它们会标示整个列表都发生改变,在Systrace中会显示为RV FullInvalidate。SortedList
或 DiffUtil
来进行少量更新或增加。示例代码,考虑从服务端获取一个新的信息list,使用notifyDataSetChanged
:
void onNewDataArrived(List news) { myAdapter.setNews(news); myAdapter.notifyDataSetChanged(); }
但这有个很严重的潜在问题:如果list变动很小,比如仅仅是新增了一个数据,RecyclerView
将会清除所有item缓存,重新绑定所有的item views。
示例代码,使用DiffUtil
:
void onNewDataArrived(List news) {
List oldNews = myAdapter.getItems();
DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
myAdapter.setNews(news);
result.dispatchUpdatesTo(myAdapter);
}
而你只需要实现接口DiffUtil.Callback
来告诉DiffUtil
该怎样检查list对比结果。
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个项目。
RecyclerView
中的view可能就太多了点,需要删除多余的view。onBindViewHolder(VH, int)
的调用时间,不要在其中做多余的事情。Data Binding library
(数据绑定库)。示例代码:
view getView(int position, View convertView, ViewGroup parent) {
if (cOnvertView== null) {
// 仅当第一次显示时flate,此处应添加到缓存
cOnvertView= mLayoutInflater.inflate(R.layout.my_layout, parent, false)
}
// 这里绑定相应的数据到convertView
return convertView;
}
如果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属性要更好。这将重新记录无效的视图的绘制,并且同样也比布局性能好的多。
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()
。
Systrace 会用不同的颜色来指示线程状态:
图中可以看到,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线程不应该等待来自其他线程的结果。
Systrace会告诉你GC是否频繁运行,Android Memory Profiler可以显示对象分配发生在哪儿。
HeapTaskDaemon thread中,花费94ms的GC。