前言

大多数用户感知到的卡顿等性能问题的主要根源都是因为渲染性能。Android系统每隔大概16.6ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能达到流畅的画面所需要的60fps,为了能够实现fps(每秒传输帧数),这意味着程序的大多数操作都必须在16ms完成。

我们通常都会提到60fps和16ms,可是知道为何会以程序是否达到60fps来作为APP性能的衡量标准吗?这是因为人眼与大脑之间的协作无法感知超过60fps的画面更新。

12fps大概类似手动快速翻动书籍的频率,这明显是可以感知到是不够顺滑的。24fps使得人眼感知的是连续线性的运动,这其实是归功于运动模糊的效果。24fps是电影胶圈通常使用的帧率,因为这个帧率已经足够支撑大部分电影画面需要表达的内容,同时能够最大的减少费用的支出。但是低于30fps是无法顺畅表现绚丽的画面的内容的,此时就需要用到60fps来表达想要的效果,当然超过60fps是没有必要的。开发app的性能目标就是保持60fps,这意味着每一帧你只有16ms=1000/60的时间来处理事务。

img

如果某个操作花费时间是24ms,系统在得到VSYNC信号的时候就无法进行正常渲染,这样就发生了丢帧现象。那么用户在32ms内看到的会是同一帧画面。

img

有很多原因可以导致丢帧,一般主线程过多的UI绘制、大量的IO操作或是大量的计算操作占用CPU,都会导致App界面卡顿。

IdleHandler

IdleHandler会在Handler空闲时执行

适用场景

  • 如果启动的Activity、Fragment、Dialog内含有大量的数据和视图的加载,导致首次打开时切换卡顿或者一瞬间白屏,可将部分加载逻辑放到queueIdle()内处理。例如引导图的加载和弹窗提示等;
  • 一些第三方框架Glide和LeakCanary等也使用到IdleHandler。

实例:

image-20221030172939455

image-20221030172952411

卡顿分析

Systrace

Systrace是Android平台提供的一款工具,用于记录短期内的设备活动。该工具会生成一份报告,其中汇总了Android内核中的数据,例如CPU调度程序、磁盘活动和应用线程。Systrace主要用来分析绘制性能方面的问题。在发生卡顿时,通过这份报告可以知道当前整个系统所处的状态,从而帮助开发者更直观的分析系统瓶颈,改进性能。

  • CPU Profiler可以看出代码在运行时的一些具体信息,方法调用时长、次数、时间比率、了解代码运行过程的效率问题,从而针对性改善代码。所以对于可能导致卡顿的耗时方法也可以通过CPU Profiler检测。

要使用Systrace,需要先安装Python2.7.(目前不支持高版本)。

Systrace具体使用可以参考这篇博客:https://www.jianshu.com/p/e73768e66b8d

执行systrace可以选择配置自己感兴趣的category,常用的有:

标签 描述
gfx Graphics图形系统,包括SerfaceFlinger,VSYNC消息,Texture,RenderThread等
input Input输入系统,按键或者触摸屏输入;分析滑动卡顿等
view View绘制系统的相关信息,比如onMeasure,onLayout等;分析View绘制性能
am ActivityManager调用的相关信息;分析Activity的启动、跳转
dalvik 虚拟机相关信息;分析虚拟机行为;如GC停顿
sched CPU调度的信息,能看到CPU在每个时间段在运行什么线程,线程调度情况,锁信息。
disk IO信息
wm WindowManager的相关信息
res 资源加载的相关信息

其实Systrace对于应用开发者来说,能看的并不多。主要用于看是否丢帧,与丢帧时系统以及我们应用大致的一个状态。

image-20221030173502923

image-20221030173517795

注意:我们在抓取systrace文件的时候,切记不要抓取太长的时间,也不要太多不同操作。

打开抓取的html文件,可以看到我们应用存在非常严重的掉帧,不借助工具直接用肉眼看UI是看不出来的。如果只是单独存在一个红色或者黄色都是没有关系的。关键是连续的红/黄或者两帧间隔非常大那就需要我们去仔细观察,按“W”放大视图,在UIThread(主线程)上面有一条很细的线,表示线程的状态。

image-20221030173601186

Systrace会用不同的颜色来标识不同的线程状态,在每个方法上都会有对应的线程状态来标识目前线程所处的状态。通过查看线程状态我们可以知道目前的瓶颈是什么,是CPU执行慢还是因为Binder调用,又或是进行IO操作,又或是拿不到CPU时间片。

Systrace会用不同的颜色来标识不同的线程状态,在每个方法上都会有对应的线程状态来标识目前线程所处的状态。通过查看线程状态我们可以知道目前的瓶颈是什么,是CPU执行慢还是因为Binder调用,又或是进行IO操作,又或是拿不到CPU时间片。

线程状态主要有以下几种:

  • 绿色:表示正在运行:

    • 是否频率不够?(CPU处理速度)
    • 是否跑在小核上?(不可控,但实际上很多手机都会有游戏模式,如果我们应用是手游,那系统会优先把手游中的任务放到大核上跑。)
  • 蓝色:表示可以运行,但是CPU在执行其他线程:

    • 是否后台有太多的任务在跑?Runnable状态的线程状态持续时间越长,则表示CPU的调度越忙,没有及时处理到这个任务
    • 没有及时处理是否是因为频率太低?
  • 紫色:表示休眠,一般表示IO:

    • img

    • 官方介绍为:

      橙色:不可中断的休眠

      线程在遇到I/O操作时被阻止或正在等待磁盘操作完成。

    • 紫色:可中断的休眠线程在遇到另一项内核操作(通常是内存管理)时被阻止。

    但是实际从Android 9模拟器中拉取数据,遇到IO显示紫色,没有橙色状态显示。

  • **白色:表示休眠,**可能是因为线程在互斥锁上被阻塞,如Binder阻塞/Wait/Sleep等

点击线程的某段能获取当前的线程信息与状态

img

实例:

如下图,对比两个“红点”之间与正常“绿点”发现卡顿的发生原因可能是大量的文件操作导致

img

还可以再结合CPU Profiler->System Trace分析

找出原因:排查到出现非法文件操作的位置

img

点击线程的某段能获取当前的线程信息与状态

image-20221030174107243

实例:

如下图,对比两个“红点”之间与正常“绿点”发现卡顿的发生原因可能是大量的文件操作导致

image-20221030174119469

还可以再结合CPU Profiler->System Trace分析

找出原因:排查到出现非法文件操作的位置

image-20221030174131882

Profiler-> System Trace

Systrace只是能为我们提供一种猜想,猜想发生卡顿的大概位置、原因,要想定位到具体代码块就需要System Trace。

其实目前版本的Android Studio已经封装的很好了,System Trace就是模拟Systrace然后封装到AS中。并且还能看到某个时间区间方法的调用。

image-20221030174230495

:不要Record太多操作!

image-20221030174241101

:以下内容针对Android 10以及更低版本(高版本操作雷同,亦可参看官网界面卡顿检测

image-20221030174305823

相关的操作系统图形管道信息会显示在 CPU 性能分析器系统轨迹中的单个部分,称为 ****Display****。

  • Frames:此部分显示应用中的界面线程和RenderThread轨迹事件。时长超过16ms的事件会以红色表示,以突出显示潜在的卡顿帧,因为它们超出了60帧/秒(fps)的速度进行呈现的截止时间。

  • SurfaceFlinger:此部分显示SurfaceFlinger处理帧缓冲区的时间。SurfaceFlinger是负责将缓冲区内容发送到显示屏的系统进程。

  • VSYNC:此部分显示VSYNC,这是一个表示与显示流水线保持同步的信号。此轨迹会显示VSYNC-app信号,这个信号会在应用启动时间过晚时显示。通常情况下,发生这种情况是因为界面线程处于忙碌状态。在动画播放期间,它会导致屏幕上出现可见的闪烁,并且在动画或滚动完成之前,会持续带来额外的输入延迟。对于刷新率较高的显示屏,尤其要注意查看该轨迹,因为与刷新率为60次/秒或刷新率可变的显示屏相比,这种显示屏容易出现此类问题。

  • BufferQueue:此部分显示有多少帧缓冲区在排队等待SurfaceFlinger使用。对于部署到搭载Android 9(API级别28)或更高版本的设备的应用,此轨迹显示应用 surface BufferQueue的缓冲区计数(0、1或2)。BufferQueue可帮助我们了解图像缓冲区在Android图形组件之间切换时的状态。例如,值2表示应用当前处于三重缓冲状态,这会导致额外的输入延迟。

Display部分会提供有助于检测潜在卡顿的实用信号,例如何时界面线程或RenderThread的用时超过16毫秒。若要调查导致卡顿的确切细节,可以查看Threads部分,其中会显示与界面呈现有关的线程。

image-20221030174425605

在上图中,Threads部分显示了界面线程(java.com.google.samples.apps.iosched)、RenderThread和GPU completion。这些线程与界面呈现有关,可能是导致卡顿的原因。

Android 10或更低版本检测卡顿操作:

  1. 查看Display中的Frames轨迹。红色帧是要调查的候选对象。

    image-20221030174517716

  2. 发现可能存在卡顿的帧后,按“W”,或在按住Control键的同时滚动鼠标,以便进行放大。继续放大,直到看到界面线程和RenderThread中的轨迹事件。

image-20221030174541910

在上图中,Choreographer#Frame显示了界面线程何时调用Choreographer来协调动画、视图布局、图像绘制和相关进程。DrawFrames显示了RenderThread何时形成并向GPU发出实际绘制命令。

  1. 如果发现轨迹事件特别长,可以进一步放大,以便找出可能导致呈现速度缓慢的原因。上图显示了界面线程中的inflate,这意味着应用正在花时间膨胀布局。当放大其中一个inflate事件时,可以确切了解每个界面组件花费的时间,如下所示:

    img

  2. 根据Frames缩小排查区域,根据下图右方的Top Down进一步排查出可能导致卡顿的操作。

img

APP层面监控卡顿

Systrace可以让我们了解应用所处的状态,了解应用卡顿因为什么导致的。若需要准确分析卡顿发生在什么函数,资源占用情况如何,目前业界两种主流有效的app监控方式如下:

  1. 利用UI线程的Looper打印的日志匹配;

  2. 使用Choregrapher.FrameCallback

Looper日志监控卡顿(BlockCanary)

其实后面的内容就是BlockCanary的原理,也可以直接使用BlockCanary:

https://github.com/markzhai/AndroidPerformanceMonitor

其提示方式和LeakCanary一样,会生成如下弹窗

img

Android主线程更新UI。如果界面1秒钟少了60次,用户就会产生卡顿感觉。简单来说,Android使用消息机制进行UI更新,UI线程有个Looper,在其Looper方法中会不断去除message,调用其绑定的Handler在UI线程执行。如果在Handler的dispatchMessage方法里有耗时操作,就会发生卡顿。

public static void loop(){
//......
for (;;){
//......
Printer logging = me.mLogging;
if (logging != null){
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ":" + msg.what);
}

msg.target.dispatchMessage(msg);
if(logging != null){
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
//......
}
}

只要检测msg.target.dispatchMessage(msg)的执行时间,就能检测到部分UI线程是否有耗时操作。注意到这行执行代码前后,有两个logging.println函数,如果设置了logging,会分别打印出>>>>>Dispatching to和<<<<<Finished to这样的日志,这样我们就可以通过两次log的时间差值,来计算dispatchMessage的执行时间,从而设置阈值判断是否发生了卡顿。

image-20221030175520451

Looper提供了setMessageLogging(@Nullable Printer printer)方法,所以我们可以自己实现一个Printer,再通过setMessageLogging()方法传入即可:

public class BlockCanary {
public static void install(){
LogMonitor logMonitor = new LogMonitor();
Looper.getMainLooper().setMessageLogging(logMonitor);
}
}
public class LogMonitor implements Printer {
private StackSampler mStackSampler;
private boolean mPrintingStarted = false;
private long mStartTimestamp;
//卡顿阈值
private long mBlockThresholdMills = 3000;
//采样频率
private long mSampleInterval = 1000;

private Handler mLogHandler;

public LogMonitor(){
mStackSampler = new StackSampler(mSampleInterval);
HandlerThread handlerThread = new HandlerThread("block-canary-io");
handlerThread.start();
mLogHandler = new Handler(handlerThread.getLooper());
}
@Override
public void println(String s) {
//从if到else会执行 dispatchMessage,如果执行耗时超过阈值,输出卡顿信息
if (!mPrintingStarted){
//记录开始时间
mStartTimestamp = System.currentTimeMillis();
mPrintingStarted = true;
mStackSampler.startDump();
}else{
final long endTime = System.currentTimeMillis();
mPrintingStarted = false;
//出现卡顿
if (isBlock(endTime)){
notifyBlockEvent(endTime);
}
mStackSampler.stopDump();
}
}
private void notifyBlockEvent(final long endTime){
mLogHandler.post(new Runnable() {
@Override
public void run() {
//获取卡顿时主线程堆栈
List<String> stacks = mStackSampler.getStacks(mStartTimestamp,endTime);
for (String stack : stacks){
Log.e("block-canary",stack);
}
}
});
}

private boolean isBlock(long endTime){
return endTime - mStartTimestamp > mBlockThresholdMills;
}
}
public class StackSampler {
public static final String SEPARATOR = "\r\n";
@SuppressLint("SimpleDateFormat")
public static final SimpleDateFormat TIME_FORMATTER =
new SimpleDateFormat("MM-dd HH:mm:ss.SSS");

private Handler mHandler;
private Map<Long,String> mStackMap = new LinkedHashMap<>();
private int mMaxCount = 100;
private long mSampleInterval;
//是否需要采样
protected AtomicBoolean mShouldSample = new AtomicBoolean(false);

public StackSampler(long sampleInterval){
mSampleInterval = sampleInterval;
HandlerThread handlerThread = new HandlerThread("block-canary-sampler");
handlerThread.start();
mHandler = new Handler(handlerThread.getLooper());
}

/**
* 开始采样 执行堆栈
*/
public void startDump(){
//避免重复开始
if (mShouldSample.get()){
return;
}
mShouldSample.set(true);

mHandler.removeCallbacks(mRunnable);
mHandler.postDelayed(mRunnable,mSampleInterval);
}

public void stopDump(){
if (!mShouldSample.get()){
return;
}
mShouldSample.set(false);

mHandler.removeCallbacks(mRunnable);
}

public List<String> getStacks(long startTime,long endTime){
ArrayList<String> result = new ArrayList<>();
synchronized (mStackMap){
for (Long entryTime : mStackMap.keySet()){
if (startTime < entryTime && entryTime < endTime){
result.add(TIME_FORMATTER.format(entryTime)
+ SEPARATOR
+ SEPARATOR
+ mStackMap.get(entryTime));
}
}
}
return result;
}
private 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");
}
synchronized (mStackMap){
//最多保存100条堆栈信息
if (mStackMap.size() == mMaxCount){
mStackMap.remove(mStackMap.keySet().iterator().next());
}
mStackMap.put(System.currentTimeMillis(),sb.toString());
}

if (mShouldSample.get()){
mHandler.postDelayed(mRunnable,mSampleInterval);
}
}
};
}

使用时在Application的onCreate()调用BlockCanary.install()即可。

其实这种方法也就是BlockCanary原理。

例如:

image-20221030175924056

Choregrapher.FrameCallback

Android系统每隔16ms发出VSYNC信号,来通知界面进行重绘、渲染,每一次同步的周期约为16.6ms,代表一帧的刷新频率。通过Choreographer类设置它的FrameCallback,当每一帧被渲染时会触发回调FrameCallback.doFrame(long frameTimeNanos)函数。frameTimeNanos是底层VSYNC信号到达的时间戳。

public class ChoreographerHelper {
public static void start(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN){
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
long lastFrameTimeNanos = 0;
@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);
}
lastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
}
}
}

通过ChoreographerHelper可以实时计算帧率和掉帧率,实时检测App页面的帧率数据,发生帧率过低,还可以自动保存现场堆栈信息。

Looper比较适合在发布前进行测试或者小范围灰度测试然后定位问题,ChoreographerHelper适合监控线上环境的app的掉帧情况来计算app在某些场景的流畅度然后有针对性的做性能优化。

布局优化

层级优化

measure、layout、draw这三个过程都包含自顶向下的View Tree遍历耗时,如果视图层级太深自然需要更多的时间来完成整个绘测过程,从而造成启动速度慢、卡顿等问题。而onDraw在频繁刷新时可能多次出发,因此onDraw更不能做耗时操作,同时需要注意内存抖动。对于布局性能的检测,依然可以使用systrace与CPU Profiler按照绘制流程检查绘制耗时函数。

Layout Inspector

视图层次结构的检测工具

img

然后选择需要查看的进程与Activity后:

image-20221030180644284

在左侧id为content之下的就是我们写在XML中的布局。可以明显看出,我们的布局中是一个LinearLayout,其中又包含两个LinearLayout。我们应该尽量减少其层级,可以使用ConstraintLayout约束布局使得布局尽量扁平化,移除非必需的UI组件。

image-20221030180704070

使用merge标签

当我们有一些布局元素需要被多处使用时,这时候我们会考虑将其抽取成一个单独的布局文件。在需要使用的地方通过include加载。同时还能巧妙地减少布局层级。

image-20221030181233671

image-20221030181217841

这时候我们的主布局文件是垂直的LinearLayout,include的”layout_merge”也是垂直的LinearLayout,这时候include的布局中使用的LinearLayout就没意义了,使用的话反而减慢你的UI表现。这时可以使用merge标签优化。同时还达到了减少布局层级的目的。

image-20221030181327339

修改为merge后,通过Layout Inspector能够发现,include的布局中TextView直接被加入到父布局中。

image-20221030182934087

merge标签使用注意点

  1. 根布局是FrameLayout且不需要设置background或padding等属性,可以用merge代替,因为Activity的ContentView父元素就是FrameLayout,所以可以用merge消除只剩一个。

  2. 因为merge标签并不是View,所以在通过LayoutInflate.inflate()方法渲染的时候,第二个参数必须指定一个父容器,且第三个参数必须为true,也就是merge下的视图指定一个父亲节点,由于merge不是View,所以对merge标签设置的所有属性都是无效的。

使用ViewStub标签

当我们布局中存在一个View/ViewGroup,在某个特定时刻才需要他的展示时,你可能会把这个元素在xml中定义为invisible或者gone,在需要显示时再设置为visible可见。比如在登陆时,如果密码错误在密码输入框上显示提示。

invisible

view设置为invisible,view在layout布局文件中会占用位置,但是view为不可见,该view还是会创建对象,会被初始化,会占用资源。

gone

view设置gone时,view在layout布局文件中不占用位置,但是该view还是会创建对象,会被初始化,会占用资源。

如果view不一定会显示,此时可以使用ViewStub来包裹此View以避免不需要显示view但是又需要加载view消耗资源。

ViewStub是一个轻量级的view,它不可见,不用占用资源,只有设置viewstub为visible或者调用其inflate()方法时,其对应的布局文件才会被初始化。

  • 优点:实现View的延迟加载,避免资源的浪费,减少渲染的时间,在需要的时候才加载View

  • 缺点:

    • ViewStub所替代的layout文件不能有<merge>标签

    • ViewStub在加载完后会被移除,或者说是被加载进来的layout替换

      image-20221030183918391

      用ViewStub加载layout文件时,可以调用 setVisibility()或者 inflate()

      image-20221030183940211

      注意

      • 一旦ViewStub visible/inflated,则ViewStub将从视图框架中移除,其id stub_import也会失效
      • ViewStub被绘制完成的layout文件取代,并且该layout文件的root view的id是android:inflatedId指定的id panel_import,root view的布局和ViewStub视图的布局保持一致

过度渲染

过度绘制是指系统在渲染单个帧的过程中多次在屏幕上绘制某一个像素。例如,如果我们有若干界面卡片堆叠在一起,每张卡片都会遮盖其下面一张卡片的部分内容。但是,系统仍然需要绘制堆叠中的卡片被遮盖的部分。

GPU过度绘制检查

手机开发者选项中能够显示过度渲染检查功能,通过对界面进行彩色编码来帮我们识别过度绘制。开启步骤如下:

  1. 进入开发者选项(Developer Options)

  2. 找到调试GPU过度绘制(Debug GPU overdraw)

  3. 在弹出的对话框中,选择显示过度绘制区域(Show overdraw areas)。

    Android将按如下方式为界面元素着色,以确定过度绘制的次数:

    • 真彩色:没有过度绘制
    • 蓝色:过度绘制1次
    • 绿色:过度绘制2次
    • 粉色:过度绘制3次
    • 红色:过度绘制4次或更多次

在优化应用的界面时,应尝试达到大部分显示真彩色或仅有1次过度绘制(蓝色)的视觉效果。

解决过度绘制问题

可以采取以下几种策略来减少甚至消除过度绘制

  • 移除布局中不需要的背景
    • 默认情况下,布局没有背景,这表示布局本身不会直接渲染任何内容。但是,当布局具有背景时,其有可能会导致过度绘制。
    • 移除不必要的背景可以快速提高渲染的性能。不必要的背景可能永远不可见,因为它会被应用在该视图上绘制的其他内容完全覆盖。例如,当系统在父视图上绘制子视图,可能会完全覆盖父视图的背景。
  • 使视图层次结构扁平化
    • 可以通过优化视图层次结构来减少重叠界面对象的数量,从而提高性能。
  • 降低透明度
    • 对于不透明的View,只需要渲染一次即可把它显示出来。但是如果这个View设置了alpha值,则至少需要渲染两次。这是因为使用了alpha的View需要先知道混合View的下一层元素是什么,然后再结合上层的View进行Blend混色处理。透明动画、淡入淡出和阴影等效果都涉及到某种透明度,这就会造成了过度绘制。可以通过减少要渲染的透明对象的数量,来改善这些情况下的过度绘制。例如,如需获得灰色文本,可以在TextView中绘制黑色文本,再为其设置半透明的透明度值->但是,简单地通过绘制灰色文本也能获得同样的效果,而且能够大幅提升性能。

布局加载优化

异步加载

LayoutInflater加载xml布局的过程会在主线程使用IO读取XML布局文件进行XML解析,再根据解析结果利用反射创建布局中的View/ViewGroup对象。这个过程随着布局的复杂度上升,耗时自然也会随之增大。Android为我们提供了Asynclayoutinflater把耗时的加载操作在异步线程中完成,最后把加载结果再回调给主线程。

dependencies{
implementation"androidx.asynclayoutinflater:asynclayoutinflater:1.0.0"
}

image-20221030210116028

  1. 使用异步inflate,那么需要这个layout的parent的generateLayoutParams函数是线程安全的;

  2. 所有构建的View中必须不能创建Handler或者是调用Looper.myLooper;(因为是在异步线程中加载的,异步线程默认没有调用Looper.prepare);

  3. AsyncLayoutInflater不完全支持设置LayoutInflater.Factory或者LayoutInflater.Factory2;

  4. 不支持加载包含Fragment的layout;

  5. 如果AsyncLayoutInflater失败,那么会自动回退到UI线程来加载布局.