Android 性能优化系列:UI 与卡顿

文章目录

  • 卡顿的原理、产生的原因和优化方向
  • UI 引起的卡顿
    • 布局层级嵌套过深
    • 过度绘制
  • 系统资源引起的卡顿
    • 内存不足频繁 GC
    • 线程阻塞/锁阻塞
  • 卡顿问题定位
    • Layout Inspector 排查布局嵌套
    • systrace 宏观层面找卡顿问题原因
      • systrace 环境配置
      • systrace 的操作步骤
      • systrace 分析卡顿
    • Choreographer 微观层面找卡顿问题原因(监控帧率)
    • 找到具体卡顿代码(Looper 机制)

该篇文章会涉及到 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 资源的情况导致的超过这个时间出现偶发性掉帧。帧率出现上下浮动是一个正常现象

UI 引起的卡顿

渲染和布局优化

布局层级嵌套过深

在目前传统的 Android 开发中,界面的编写都是放在 xml 文件,当程序运行时,从 xml 到 View 显示出来会经过以下步骤:

  • xml 文件经过 inflate 解析

  • 根据 xml 标签递归反射创建 View

  • View 经过测量布局绘制流程(绘制流程原理)

层级嵌套过深时,xml 的 inflate 属于 IO 解析、方法递归对内存栈(每个线程都会分配一个虚拟机栈,每个方法都是栈帧,具体查看 JVM 运行时数据区(栈和堆) )消耗占用了更多系统资源、反射创建 View、View 测量布局需要 CPU 提供算力计算出多边形和纹理、GPU 渲染,每个步骤都会因为 View 和层级嵌套的增多而耗时更长,众多耗时操作进行 IO 和占用系统资源,最终就会引发卡顿。

过度绘制

在 Android 的规范中要求我们要尽可能的减少过度绘制,即显示更多的蓝色区域减少红色区域,但如果你尝试去查看目前市场上一些流行的 app 会发现有很多红色区域,以 大众点评 app 举例:
Android 性能优化系列:UI 与卡顿_第1张图片
可以看到是很明显的过度绘制,从 Android 规范的角度看这是不合理的,但从布局角度看它又是合理能让人接受的

当然在我们日常开发中还是要遵循 Android 规范减少布局嵌套减少过度绘制,让控件能更快的渲染出来。

系统资源引起的卡顿

内存不足频繁 GC

内存不足有多个方面造成,但主要的有两个方面,一个是 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 排查布局嵌套

Layout Inspector 是 Android Studio 内置的布局检测工具,主要的作用就是检查界面嵌套层级情况。
Android 性能优化系列:UI 与卡顿_第2张图片
程序运行后通过上面的操作打开 Layout Inspector,可以查看当前运行程序处在界面的布局层级情况,工具比较简单,这里不再过多赘述。

systrace 宏观层面找卡顿问题原因

systrace 环境配置

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

  • 在安装完 pywin32 后可能还有提示如下问题:
ImportError: No module named six

需要安装 six 库:six

Android 性能优化系列:UI 与卡顿_第3张图片
然后在解压后的目录使用python安装:
在这里插入图片描述

systrace 的操作步骤

  • 在代码中加入 Trace.beginSection() 和 Trace.endSection() 开始和停止记录
// 在开始的地方调用
TraceCompat.beginSection("SystraceAppOnCreate");

// 在结束的地方调用
TraceCompat.endSection();
  • 命令行进入到 systrace 目录启动 systrace.py,程序运行启动后回车导出 trace.html:
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

Android 性能优化系列:UI 与卡顿_第4张图片

  • 在 Chrome 浏览器或 perfetto 打开 trace.html 分析

这里也列出 systrace 中常用的标签:

标签 描述
gfx 图形系统,包括 surfaceflinger、Vsync 等
input 分析滑动卡顿、按键或触摸的输入
view View 绘制系统的相关信息,比如 onMeasure、onLayout 等,分析绘制性能
am ActivityManager 调用相关信息,分析 Activity 的启动跳转
dalvik 虚拟机相关信息,分析虚拟机行为,如 GC 停顿
sched CPU 调度信息
disk IO 信息
wm WindowManager 相关信息
res 资源加载相关信息

systrace 分析卡顿

使用 systrace 不是用于定位具体卡顿点,而是它能够将所有的状态标出来,可以清晰的查看分析问题点。在 systrace 查找卡顿原因时我们主要看两栏:CPU 和 UI thread。
Android 性能优化系列:UI 与卡顿_第5张图片Android 性能优化系列:UI 与卡顿_第6张图片
首先看 UI thread 中不同颜色的帧率球,红色代表跳帧超过 1 帧,黄色是在 1 帧,绿色是正常。两个帧率球之间可以用鼠标滑动或按 m 高亮显示这个区域,例如上图可以看到两帧之间耗时 21.667ms,已经超过 16.66ms,说明跳帧了。

如果卡顿时将会连续出现红色的帧率球,所以我们要重点查看丢帧的帧率球之间的线程状态
Android 性能优化系列:UI 与卡顿_第7张图片
systrace 上面有一条线,它和 CPU 时间分片有关。线程状态在 systrace 中使用不同的颜色区分:
Android 性能优化系列:UI 与卡顿_第8张图片
如果这条线蓝色偏多,代表的是缺系统资源属于系统资源分配问题没法处理;白色偏多也是缺系统资源,涉及到线程锁、Binder、IO 等,多数时候出现跳帧都是蓝色和白色偏多;当然还有一种特殊情况,你的代码中有做了锁并发,如果出现了成片的白色就要考虑是否是自己代码问题了;还有紫色偏多就是触发了 GC(STW)导致的跳帧也会引起卡顿。

所以使用 systrace 最主要的就是看这条线颜色状态,看哪些线程状态成片的出现比较多,它代表了 UI 线程的状态,就能大致得出引发卡顿的是什么问题。主要是看是否丢帧,丢帧那一段的系统线程状态是怎样的

Choreographer 微观层面找卡顿问题原因(监控帧率)

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 帧的就会卡顿

找到具体卡顿代码(Looper 机制)

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());
        }
    };
}

你可能感兴趣的:(性能优化,android)