文章内容根据《性能优化入门与实战》总结而来
为什么要做流畅度优化
流畅度是与用户体验直接关联的指标,当App的流畅度不够高时,会直接导致用户放弃使用该App,从而降低产品的用户留存率。根据第三方平台公布的数据,诺德斯特龙网站上,用户操作的响应时间每增加0.5s,产品的转化率下降11%;沃尔玛网站的响应时间每减少0.1s,其公司的收入增加1%。因此我们可以说:App的流畅度与业务的成功有直接关系。
判断 APP 是否流畅的指标主要是App对用户交互的响应是否及时,比如
- 打开页面速度是否够快
- 滑动界面是否连贯
- 播放动画是否连续
如何线上监控流畅度
流畅度监控一般需要监控如下指标:
- FPS,即帧率,代表页面的刷新流畅情况。
- 掉帧数,代表页面的不连贯情况。
- 卡顿率,代表主线程消息执行的超时情况。
Android绘制原理
VSync 是什么
VSync(Vertical Synchronization)即垂直同步,用于CPU、GPU和显示器的同步,主要有以下作用:
- 通知切换buffer,显示前一个buffer的数据到屏幕上。
- 通知CPU进行下一帧数据的计算。
我们在App中发起的布局绘制操作,会由CPU在收到VSync信号后进行处理,以生成GPU可以执行的绘制命令。处理完成后同步给GPU进行绘制。等下一个VSync信号来临时,显示器从缓冲池中取一个buffer数据进行显示,同时CPU开始进行下一帧绘制内容的计算。
Android 图形系统为什么使用三缓冲,而不是双缓冲
Android 缓冲的使用详情可以见Android VSYNC与图形系统中的撕裂、双缓冲、三缓冲浅析 - 简书。
三缓冲和双缓冲的不同之处在于,此时CPU不需要等待GPU绘制完成,可以在收到第一个VSync信号时就开始处理下一帧的数据。这样的结果是:只会卡顿一次。GPU在绘制上一帧的数据时,CPU就可以开始下一帧数据的计算,这样可以让界面显示的流畅度大大提升。
FPS和掉帧数
评估App的流畅度时最常使用的指标之一是FPS,FPS的上限值根据手机的屏幕刷新率的不同有所不同。目前Android手机的主流屏幕刷新率为60Hz、90Hz和120 Hz这3档。
获取帧数和每帧耗时,一般有如下3种常见的方案。
- 通过Choreographer注册FrameCallback,在其中计算两帧之间的间隔时长。
- 在Looper任务执行结束后判断当前任务是否为绘制帧,是的话计算帧耗时。
- 通过FrameMetrics获取每帧耗时。
使用 Choreographerd 的方案
Choreographer是App界面渲染机制的核心组成,它帮我们封装了VSync信号的请求和分发操作,在我们发起界面绘制、播放动画、事件处理请求时,会先向Choreographer注册VSync信号的“监听”,同时请求VSync信号,这样在收到渲染子系统发出的VSync信号后,App的输入事件处理、动画播放和布局测量和渲染等就得以执行。
Choreographer使用比较简单,因此用的比较多。代码示例如下:
Choreographer.getInstance().postFrameCallback(new Choreographer.
FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
//这里做帧数和耗时统计
}
});
Choreographer中5种类型的队列,分别是:
callback添加到队列后,接收到 VSync 信号的时候会执行。执行逻辑如下所示:
//frameworks/base/core/java/android/view/Choreographer.java
void doFrame(long frameTimeNanos, int frame,
DisplayEventReceiver.VsyncEventData vsyncEventData) {
//执行 CALLBACK_INPUT
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos,
frameIntervalNanos);
//先后执行 CALLBACK_ANIMATION 和 CALLBACK_INSETS_ANIMATION
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos,
frameIntervalNanos);
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos,
frameIntervalNanos);
//执行 CALLBACK_TRAVERSAL
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos,
frameIntervalNanos);
//执行 CALLBACK_COMMIT
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos,
frameIntervalNanos);
}
通过Choreographer计算FPS和掉帧数的示例如下所示:
private final Choreographer.FrameCallback myCallback = new Choreographer.
FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTimeNanos != 0) {
//上一帧的耗时
long frameCost = frameTimeNanos - lastFrameTimeNanos;
//转换为ms
final long jitter = frameCost/ 1000000;
//计算出掉帧数
final int dropFrameCount = (int) (jitter / frameIntervalMillis);
//帧数加一
frameCount ++;
//掉帧数加一
dropFrameCount += dropFrameCount;
//异常数据处理
float frameDuration = Math.max(jitter, frameIntervalMillis);
//累加总时长
frameTotalDurationMillis += frameDuration;
long now = System.currentTimeMillis();
if (now - lastFPSTime > 1000) {
float fps = Math.min(refreshRate, 1000.f * frameCount /
frameTotalDurationMillis);
lastFPSTime = now;
}
}
lastFrameTimeNanos = frameTimeNanos;
//持续注册
Choreographer.getInstance().postFrameCallback(frameCallback);
}
};
上面的代码中做了如下几件事:
- 通过这一帧的开始时间减去上一帧的开始时间得到上一帧的耗时frameCost。
- 用上一帧的耗时除以理论上每帧的耗时,得到上一帧的掉帧数。
- 累加帧数和掉帧数。
- 每帧耗时的异常数据处理,每帧耗时应不低于理论帧间隔时间。
- 累加总时长。
- 超出1s后,计算FPS。
其中 frameIntervalMillis 是理论帧间隔时间即VSync信号的发送间隔,它与屏幕刷新率相关,不能固定为16.666ms。我们可以通过 android.view.Display的getRefreshRate获取当前设备的屏幕刷新率。代码示例如下:
WindowManager windowManager = (WindowManager) context.getSystemService(Context.
WINDOW_SERVICE);
Display display = windowManager.getDefaultDisplay();
float refreshRating = display.getRefreshRate();
需要注意的是,Choreographer是ThreadLocal(线程间不共享)的,不同的线程调用Choreographer.getInstance,其返回的值不同
使用Choreographer和Looper监控结合使用的方案
使用Choreographer#FrameCallback可以获取上一帧的耗时和FPS,但这样做的缺点是只能获取指标,在得知卡顿时上一帧已经过去,无法定位到卡顿原因。要获取掉帧的原因,我们可以将Choreographer和Looper的卡顿监控机制结合起来.
实现原理:在收到Choreographer的doFrame回调后,则认为当前正在执行绘制任务,如果此时Looper执行的消息耗时超过卡顿阈值,即认为出现了卡顿和掉帧。这时去抓栈的话就可以获取引起这一帧掉帧的原因。Matrix的卡顿监控里就有这个思路的实现(Matrix是微信开源的App性能管理框架,可以帮助监控和定位Android/iOS/macOS App的性能问题)
使用 FrameMetrics 的方案
FrameMetrics(android.view.FrameMetrics)是从Android 7.0开始提供的新API,它提供了每帧的详细耗时数据,其中也包括当前帧的总耗时数据。代码示例如下:
private void getRenderPerformance() {
//新建一个监听器
Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener = new
Window.OnFrameMetricsAvailableListener() {
@Override
public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics,
int dropCountSinceLastInvocation) {
//获取这帧的总耗时,单位为ns
long totalDurationInNanos = frameMetrics.getMetric(FrameMetrics.
TOTAL_DURATION);
}
};
//将监听器添加到窗口上
getWindow().addOnFrameMetricsAvailableListener(frameMetricsAvailableListener,
new Handler());
}
除了总耗时,FrameMetrics提供的耗时信息如下表所示:
滑动帧率
使用Choreographer Framecallback的方式计算FPS,会出现当没有绘制界面时FPS数值偏低的情况,无法真实反映用户使用体验。在实际监控中,我们更关注App在用户产生交互后的流畅性,比如滑动时是否卡顿。因此,我们需要在帧率的基础上,增加一个更加细致的指标:滑动帧率
布局滑动本质上是一种事件,要获取整个布局所有层级的事件信息,可以通过ViewTreeObserver实现。代码示例如下:
View decorView = activity.getWindow().getDecorView();
if (decorView != null) {
decorView.getViewTreeObserver().addOnScrollChangedListener(new
ViewTreeObserver.OnScrollChangedListener() {
@Override
public void onScrollChanged() {
//开始滑动后向Handler中提交一个延迟执行的任务
//下次滑动后移除
isScrolling = true;
mHandler.removeCallbacks(onScrollFinishRunnable);
mHandler.postDelayed(onScrollFinishRunnable, 100);
}
});
}
由于OnScrollChangedListener的onScrollChanged没有状态,因此我们需要在onScrollChanged每次调用时向Handler中提交一个延时任务,再次滑动时移除。当延时任务执行时,说明滑动已经停止。这样我们可以获取是否滑动的状态,在获取每帧的时长时,可以根据当前是否滑动,判断这帧是否是滑动帧,累加后计算即可得到滑动帧率。
主线程卡顿监控
监控卡顿,等同于监控主线程是否存在长耗时消息,有如下几种常见的思路:
思路1:定时向主线程的消息队列中提交任务,并在子线程中延迟一定时间后检查这个任务是否被执行。该思路的优点是实现简单,缺点是获取的数据准确度有限,同时对性能也有不小的影响
思路2:计算主线程Looper执行每个任务的时长,若有任务耗时超过自定义的阈值即可认为发生了卡顿。该思路的优点是可以在任务执行完就立刻检测到其是否耗时过久,可以极大提升问题捕获成功率,在很多公司的App中都使用了这种思路。
监视Looper执行每个任务的耗时,有三种方式:
- Printer#println。
- Trace的traceBegin和traceEnd。
- Observer的messageDispatchStarting和messageDispatched。
Printer#println
通过 Looper.getMainLooper().setMessageLogging(myPrinter) 即可设置监听,从而获取消息执行的开始时间和结束时间。该方式实现简单,业内很多卡顿监控库都使用这种方法实现,比如Android Performance Monitor和Matrix。缺点是调用Printer的println时会增加额外的字符串拼接逻辑,带来一定的性能损耗。
Trace的traceBegin和traceEnd
Trace的traceBegin和traceEnd也可以用来统计消息耗时。可以在线下使用Systrace以图形化的方式查看某个任务的耗时情况,搜索的关键字是msg.target.getTraceName(msg) 的返回值,也就是Handler的getTraceName方法返回的内容。
不过这种方式的实现成本比Printer#println的要高不少,如果要在线上使用需要开启atrace,会带来一些性能损耗,所以使用得不多。
Observer的messageDispatchStarting和messageDispatched
通过设置Observer获取消息耗时的方式,性能损耗最少,可以获取明确的消息执行的开始时间和结束时间,也可以获取执行的消息信息。
但很可惜,这个接口在target API目标版本为31上被列入了hidden API,App无法直接使用,通过反射访问也会报错,因此对上层App来说基本不可用。
综合来看,实现主线程长耗时任务监控目前比较好的方式还是使用Looper.getMainLooper().setMessageLogging(myPrinter)
线程运行情况监控
可以通过以下数据判断主线程的调度情况。
- CPU time和Wall time。
- 主线程的优先级。
- 主线程被抢占的次数。
Wall time指客观过去的时间,CPU time指进程真正在CPU上执行的时间。一个进程的CPU time包括两部分:用户态时间(utime)和内核态时间(stime)。用户态时间指执行App代码的时间,而内核态时间指执行App调用的系统API花费的时间
要获取当前进程的CPU time,我们可以通过/proc/${pid}/stat实现
获取主线程的优先级和调度情况,可以通过/proc/{pid}/sched实现
emulator64_arm64: # cat /proc/26993/task/26993/sched
ndroid.settings (26993, #threads: 31)
-------------------------------------------------------------------
se.exec_start : 36594087.581945
se.vruntime : 626925.913763
//总运行时间
se.sum_exec_runtime : 1892.188025
//总休眠时间
se.statistics.sum_sleep_runtime : 25930068.572341
//累计等待运行的时间,即可运行状态的时间
se.statistics.wait_sum : 7306.111963
se.statistics.wait_count : 8547
//累计等待 I/O 操作的时间
se.statistics.iowait_sum : 86.472741
se.statistics.iowait_count : 272
//总切换次数
nr_switches : 8542
//主动切换次数,比如等待 I/O
nr_voluntary_switches : 4551
//被动切换次数,比如被其他线程抢占 CPU
nr_involuntary_switches : 3991
//…
policy : 0
//优先级
prio : 110
通过这些指标,我们就能判断出卡顿发生,究竟是否是因为App的主线程没有被CPU及时调度、发生过多I/O操作、被其他线程频繁抢占。如果是,就需要从CPU调度层面考虑优化方案。
如何线下分析流畅度
线下分析页面是否卡顿一般有如下几种方式。
- 使用开发者选项。
- 使用Android Studio Profiler。
- 使用Systrace。
使用开发者选项分析卡顿问题
GPU呈现模式分析
GPU呈现模式分析可以用来查看界面刷新频率和绘制过程各阶段的耗时。如下图所示:
GPU呈现模式条形图由不同颜色的分段组成,每个分段表示绘制过程中的一个阶段。绿色表示输入处理、动画和测量/布局,深蓝色表示绘制,浅蓝色表示同步和上传,红色表示发起绘制命令。GPU呈现模式条形图的颜色含义如下图所示:
查看呈现模式条形图时,主要关注下面几点:
- 是否存在整体过高问题、条形是否刷新频繁。当条形图的高度超过底部的红色横线时,我们就可以认为出现掉帧了。
- 掉帧时,如果绿色分段占比较大,可以从列表滚动事件处理、动画绘制、布局层级角度进行分析;
- 掉帧时,如果蓝色分段占比较大,可以对自定义View的onDraw、dispatchDraw等方法进行分析;
- 掉帧时,如果红色分段占比较大,可以从绘制命令是否过多、绘制内容是否过于复杂(比如大量的图片)的角度进行分析。
GPU过度绘制
GPU过度绘制可以用来查看布局层级是否合理。打开过度绘制检测开关后,会在App界面上增加绿色、蓝色、红色等的遮罩,我们可以根据遮罩的颜色判断布局层级是否过深
不同遮罩颜色的含义与是否需要优化见下图:
使用Android Studio Profiler分析卡顿问题
Profiler集成了Systrace和采样抓栈的功能,可以提供采样期间的如下信息:
- 正在交互的页面(Activity/Fragment)名称。
- CPU频率和繁忙程度。
- 物理内存情况。
- 不同线程的堆栈执行耗时情况。
使用Systrace分析卡顿
除了Android Studio Profiler,另外一个常用的卡顿分析工具是Systrace。相较于Profiler,Systrace的功能更为强大,可以提供如下信息:
- CPU的繁忙程度及执行的任务的信息。
- 用户进程和系统进程的执行信息。
- 线程状态、线程的详细执行耗时。
- 内存分配情况、帧率是否正常等。
Systrace 的详细介绍可以看Android Systrace -- 系列文章目录。不过由于随着 Google 宣布 Systrace 工具停更,推出 Perfetto 工具,因此更加推荐使用 Perfetto。Perfetto 的教程可以见 Android Perfetto 系列
如何优化流畅度
常见的卡顿原因如下:
- 绘制相关线程获得的CPU时间过少。
- 主线程消息队列里绘制无关任务(如I/O、Binder、锁)的耗时太多。
- 绘制任务耗时过久。
因此,我们可以从如下3个方面进行优化:
- 增加绘制相关线程的运行时间。
- 减少主线程非绘制任务耗时。
- 减少绘制任务耗时。
增加绘制相关线程的运行时间
Linux中目前使用的主流进程/线程调度方式是CFS(Completely Fair Scheduler,完全公平调度器),CFS在分配时间片时,会根据当前可运行的线程数及其优先级计算线程的权重,优先级越高权重越高。可以简单理解为:单个时间片等于当前线程的权重除以所有线程的权重之和,再乘调度周期。可以看到如果要增加绘制相关线程的运行时间,有两种方法:
- 提升核心线程优先级。为主线程和RenderThread这两个绘制相关的线程设置高优先级,同时监控、拦截到业务代码或者系统代码中将这两个线程优先级调低的操作
- 减少线程抢占。方法有,减少线程数,复用线程池;及时停止子线程。
减少主线程非绘制任务耗时
一般来说,主线程执行的非绘制任务的功能和耗时点如下图所示:
减少布局加载和解析耗时
常用的优化方法有:
- LayoutInflater.Factory 和 Factory2
- AsyncLayoutInflater异步加载布局,不能设置Factory,并且View中不能有依赖于主线程的操作
- Java代码写布局,不足:不便于开发,可维护性差
- x2c,在编译区间编译成Java代码;不足:部分属性Java不支持,失去了系统的兼容(AppCompat)
- 减少view树层级;尽量宽而浅
- merge标签,只能用于根view
- PrecomputedText
- 优化layout的开销。尽量不使用RelativeLayout或者基于weighted LinearLayout,它们layout的开销非常巨大。这里我推荐使用ConstraintLayout替代RelativeLayout或者weighted LinearLayout
- Litho:异步布局
- RenderThread与RenderScript
减少主线程文件的读写耗时
常见的文件的读写操作有,读取Assets文件、调用ContentResolver query或者insert、数据库读写等。
StrictMode(严格模式)是Android官方提供的不合理代码检测工具,可以帮助我们发现主线程执行的磁盘操作、网络请求等。使用起来也很简单,只需使用如下代码即可开启相关检测。
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectCustomSlowCalls()
.detectDiskReads() //检测主线程的磁盘读
.detectDiskWrites()
.detectNetwork() //检测主线程的网络请求
.penaltyDialog() //检测到后怎么处理
.penaltyLog()
.penaltyFlashScreen()
.build());
需要注意,StrictMode对性能损耗较大,所以只能在线下使用,并且StrictMode无法检测到C/C++ 代码中的文件操作。我们使用的Android File等API的文件操作,最终都会执行到Linux的read、write等文件操作API。因此要想在线上检测到所有文件读写操作,可以通过native hook拦截这些API。开源库Matrix和btrace中都实现了文件I/O相关API的拦截。
减少主线程阻塞等锁耗时
减少主线程的阻塞等锁耗时,主要可以通过如下两种方式实现。
- 合理使用锁。
- 掌握分析锁问题的工具
在编写加锁或者访问锁的代码时,需要注意如下3点:
- 减少主线程取锁情况。
- 减小锁范围。
- 使用合理的数据结构。
减少主线程Binder调用耗时
除了文件I/O和等锁,另一种典型的耗时任务就是Binder调用。Binder虽然高效,但毕竟采用的是跨进程通信的方式,每次Binder调用需要经过Client端的Java到Native、Native到驱动,然后在Service端从驱动到Native、Native到Java,整个流程的耗时不容小觑。
由于Android Framework中大量使用Binder实现功能,稍有不慎主线程中就会出现大量不必要的Binder调用。如下图所示,最终会产生Binder调用的常见功能:
要检测App有哪些代码频繁触发Binder调用,有如下两种工具可以选择:
- adb shell am trace-ipc。就是用于对App的Binder调用进行追踪记录
- Systrace/Perfetto。
减少主线程中其他不必要的操作
- Service、BroadcastReceiver、ContentProvider生命周期方法里的耗时操作。
- 调试日志引入的额外开销。
- 多层嵌套重复抛出异常。
- 监听文字输入、播放进度、滑动距离等频繁执行的函数。
减少绘制任务耗时
常见的绘制任务耗时点和改进方法,如下图所示: