该篇文章会涉及到 UI 布局渲染、Vsync、Choreographer 和 View 绘制流程的原理,涉及的原理不会再这里赘述,建议详细查看以下文章了解后再阅读该篇文章,会对知识的学习很有帮助:
渲染和布局优化
VSync、Choreographer和Render Thread
绘制流程原理
JVM 运行时数据区(栈和堆)
Bitmap
对象分配过程
JMM内存并发模型及volatile原理
Java synchronized与CAS
卡顿原理简单理解就是 Android 在 CPU + GPU 每 16.66ms 处理一帧,如果数据处理时间耗时,即 CPU + GPU 处理时间大于 16.66ms 就会引发跳帧,多次跳帧从视觉上就是卡顿。
造成卡顿的原因多种多样,小到代码的编写和使用的数据结构不合理,大到系统资源不能及时分配,所以我们这里主要是介绍常见的会引起卡顿的原因,主要分为两个大方面:
UI 引起的卡顿:布局层级嵌套过深、过度绘制
系统资源引起的卡顿:内存不足引发频繁 GC、线程阻塞/锁阻塞(Binder、IO)
卡顿优化的唯一核心就是让 CPU 的数据处理和 GPU 的数据处理降低至 16.66ms 内全部处理完。当然这并不是绝对的,因为还会涉及到 CPU 时间分片的问题,可能会遇到等待 CPU 资源的情况导致的超过这个时间出现偶发性掉帧。帧率出现上下浮动是一个正常现象。
渲染和布局优化
在目前传统的 Android 开发中,界面的编写都是放在 xml 文件,当程序运行时,从 xml 到 View 显示出来会经过以下步骤:
xml 文件经过 inflate 解析
根据 xml 标签递归反射创建 View
View 经过测量布局绘制流程(绘制流程原理)
层级嵌套过深时,xml 的 inflate 属于 IO 解析、方法递归对内存栈(每个线程都会分配一个虚拟机栈,每个方法都是栈帧,具体查看 JVM 运行时数据区(栈和堆) )消耗占用了更多系统资源、反射创建 View、View 测量布局需要 CPU 提供算力计算出多边形和纹理、GPU 渲染,每个步骤都会因为 View 和层级嵌套的增多而耗时更长,众多耗时操作进行 IO 和占用系统资源,最终就会引发卡顿。
在 Android 的规范中要求我们要尽可能的减少过度绘制,即显示更多的蓝色区域减少红色区域,但如果你尝试去查看目前市场上一些流行的 app 会发现有很多红色区域,以 大众点评 app 举例:
可以看到是很明显的过度绘制,从 Android 规范的角度看这是不合理的,但从布局角度看它又是合理能让人接受的。
当然在我们日常开发中还是要遵循 Android 规范减少布局嵌套减少过度绘制,让控件能更快的渲染出来。
内存不足有多个方面造成,但主要的有两个方面,一个是 Bitmap 占用内存过大(Bitmap),另一个是内存泄漏。
Dalvik 在对象分配时如果无法分配出所需的内存,会通过 GC 尽可能地腾出可分配的内存(对象分配过程),而如果频繁的申请 Bitmap 大图片大内存,以及内存泄漏无法及时释放内存,内存不足最终的结果就是频繁 GC(GC 会触发 STW 即 Stop The World)引发卡顿。
所以在 Android 中如果出现明显的卡顿,通常会伴随着可能出现了内存泄漏。
JMM内存并发模型及volatile原理
Java synchronized与CAS
线程会占用 CPU 系统资源,如果线程处理不合理多个线程抢占 CPU 资源导致给 UI 线程的 CPU 时间片没有及时分配,也会一定程度上引起卡顿。但更多的情况是可能并发代码不合理导致了锁阻塞甚至死锁一直占用 CPU 资源,让 UI 线程处于较长时间休眠引发卡顿。
线程阻塞/锁阻塞问题需要使用到 systrace 工具定位,下面会讲到。
上面讲解了卡顿的原理、造成的原因以及优化的方向,那么在实际的项目中又要怎样才能检测应用是否卡顿、哪里出现了卡顿呢?我们需要借助一些工具定位卡顿问题。
检测布局层级工具:Layout Inspector
查看系统资源使用情况:systrace
帧率监控:Choreographer
卡顿监控步骤主要可以分为三个部分:发现问题 -> 找到发生点 -> 处理。
宏观层面找问题:systrace(不是用它看细节),找到问题原因:GC、线程阻塞/锁阻塞、系统资源问题
微观层面找问题:Choreographer 监控帧率
找到具体卡顿代码:Looper 机制
Layout Inspector 是 Android Studio 内置的布局检测工具,主要的作用就是检查界面嵌套层级情况。
程序运行后通过上面的操作打开 Layout Inspector,可以查看当前运行程序处在界面的布局层级情况,工具比较简单,这里不再过多赘述。
systrace 工具存放在 sdk 目录 platform-tools/systrace/systracce.py,在使用前需要安装相关环境:
安装 python 2.7
电脑系统 Win10 和较高版本 Android Studio 在运行 systrace 准备导出 trace.html 文件时可能会出现如下问题:
ImportError: No module named win32con
需要安装 pywin32(选择 python 2.7 版本,根据 32 位或 64 位系统区分下载):pywin32
ImportError: No module named six
需要安装 six 库:six
// 在开始的地方调用
TraceCompat.beginSection("SystraceAppOnCreate");
// 在结束的地方调用
TraceCompat.endSection();
cd sdk\platform-tools\systrace
// systrace支持的命令参考Android文档:
// https://developer.android.com/topic/performance/tracing/command-line#command_options
python systrace.py -a packageName sched gfx view wm am app
这里也列出 systrace 中常用的标签:
标签 | 描述 |
---|---|
gfx | 图形系统,包括 surfaceflinger、Vsync 等 |
input | 分析滑动卡顿、按键或触摸的输入 |
view | View 绘制系统的相关信息,比如 onMeasure、onLayout 等,分析绘制性能 |
am | ActivityManager 调用相关信息,分析 Activity 的启动跳转 |
dalvik | 虚拟机相关信息,分析虚拟机行为,如 GC 停顿 |
sched | CPU 调度信息 |
disk | IO 信息 |
wm | WindowManager 相关信息 |
res | 资源加载相关信息 |
使用 systrace 不是用于定位具体卡顿点,而是它能够将所有的状态标出来,可以清晰的查看分析问题点。在 systrace 查找卡顿原因时我们主要看两栏:CPU 和 UI thread。
首先看 UI thread 中不同颜色的帧率球,红色代表跳帧超过 1 帧,黄色是在 1 帧,绿色是正常。两个帧率球之间可以用鼠标滑动或按 m 高亮显示这个区域,例如上图可以看到两帧之间耗时 21.667ms,已经超过 16.66ms,说明跳帧了。
如果卡顿时将会连续出现红色的帧率球,所以我们要重点查看丢帧的帧率球之间的线程状态。
systrace 上面有一条线,它和 CPU 时间分片有关。线程状态在 systrace 中使用不同的颜色区分:
如果这条线蓝色偏多,代表的是缺系统资源属于系统资源分配问题没法处理;白色偏多也是缺系统资源,涉及到线程锁、Binder、IO 等,多数时候出现跳帧都是蓝色和白色偏多;当然还有一种特殊情况,你的代码中有做了锁并发,如果出现了成片的白色就要考虑是否是自己代码问题了;还有紫色偏多就是触发了 GC(STW)导致的跳帧也会引起卡顿。
所以使用 systrace 最主要的就是看这条线颜色状态,看哪些线程状态成片的出现比较多,它代表了 UI 线程的状态,就能大致得出引发卡顿的是什么问题。主要是看是否丢帧,丢帧那一段的系统线程状态是怎样的。
Choreographer 是配合 Vsync 同步告知 CPU 开始测量布局 和 GPU 渲染绘制的工具类(VSync、Choreographer和Render Thread),通过 Choreographer 可以很方便的计算每帧的耗时,也就能计算出是否存在跳帧。
我们可以设置一个丢帧的阀值,如果超过这个阀值就说明存在卡顿问题,这里主要是定位到哪个界面出现丢帧,需要后续具体细看定位代码。工具类代码可以参考如下:
public class ChoreographerHelper {
private static final String TAG = "ChoreographerHelper";
private static long lastFrameTimeNanos = 0;
public static void start() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTimeNanos == 0) {
lastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
return;
}
long diff = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000;
if (diff > 16.6f) {
// 丢帧数
int droppedCount = (int) (diff / 16.6);
// 自己设定一个丢帧的阈值,程序运行后就看 logcat 哪里输出这段日志定位到卡顿的界面
if (droppedCount > 2) {
Log.w(TAG, "UI线程超时(超过16ms)当前:" + diff + "ms, 丢帧:" + droppedCount);
}
}
lastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
}
}
}
在非生产环境可以使用 Choreographer 监控每一帧的状态和计算丢帧数量,我们可以将它开启后在 app 上运行,如果出现了丢帧数多的情况就能定位到某个界面会出现卡顿了。
丢 1 帧不算丢,丢 2 帧和底层运行时间也有关系,如果出现丢 3 帧但频率不高的情况一般不会卡,如果有超过 5 帧的就会卡顿。
Android 是一个事件驱动的系统,主线程运行在 Looper 机制上,每一个事件触发都会走 Handler。在 Looper 源码中处理事件时有对应的日志打印:
Looper.java
public static void loop() {
...
for (;;) {
...
// 事件处理前,获取 Printer 打印
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
// 事件处理后,打印已处理
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
...
}
}
在 Looper 源码中设置 Printer 的代码是公开的:
Looper.java
public void setMessageLogging(@Nullable Printer printer) {
mLogging = printer;
}
所以如果我们要定位具体代码,可以利用 Looper 公开的日志打印方法自定义我们的 Printer 将日志打印出来,Looper 调用 printer.println() 首次表示记录,第二次调用时表示事件处理结束,如果和首次记录的时间对比超过我们设置的阀值,就认为有卡顿出现输出堆栈。工具类参考代码如下:
public class BlockCanary {
public static void install() {
LogMonitor logMonitor = new LogMonitor();
Looper.getMainLooper().setMessageLogging(logMonitor);
}
}
public class LogMonitor implements Printer {
// 卡顿阈值,超过5帧输出堆栈信息
private static final long sBlockThresholdMillis = (long) (5 * 16.66);
// 采样频率
private static final long sSampleInterval = 300;
private final StackSampler mStackSampler;
private boolean mPrintingStarted;
private long mStartTimestamp;
public LogMonitor() {
mStackSampler = new StackSampler(sSampleInterval);
}
@Override
public void println(String x) {
// 从 if 到 else 会执行 dispatchMessage,如果执行耗时超过阈值,输出卡顿信息
if (!mPrintingStarted) {
mPrintingStarted = true;
mStartTimestamp = System.currentTimeMillis();
mStackSampler.startDump();
} else {
mPrintingStarted = false;
final long endTime = System.currentTimeMillis();
if (isBlock(endTime)) {
Log.e("block-canary", "block, time = " + (endTime - mStartTimestamp));
}
mStackSampler.stopDump();
}
}
private boolean isBlock(long endTime) {
return endTime - mStartTimestamp > sBlockThresholdMillis;
}
}
public class StackSampler {
private final Handler mHandler;
private final long mSampleInterval;
public StackSampler(long sampleInterval) {
mSampleInterval = sampleInterval;
HandlerThread handlerThread = new HandlerThread("block-canary-sampler");
handlerThread.start();
mHandler = new Handler(handlerThread.getLooper());
}
public void startDump() {
mHandler.postDelayed(mRunnable, mSampleInterval);
}
public void stopDump() {
mHandler.removeCallbacks(mRunnable);
}
private final Runnable mRunnable = new Runnable() {
@Override
public void run() {
StringBuilder sb = new StringBuilder();
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
for (StackTraceElement s : stackTrace) {
sb.append(s.toString()).append("\n");
}
Log.e("block-canary", sb.toString());
}
};
}