卡顿分析
造成卡顿的原因可能是多种多样的,但是最终都会反映在CPU时间上。Android系统是基于Linux的,可以CPU时间分为两种:
- 用户时间——执行用户态应用程序代码消耗的时间。
- 系统时间——执行内核态系统调用的时间。包括I/O、锁、中断以及其他系统调用的执行时间。
CPU的相关问题可以分为三类:
- CPU资源冗余使用
算法效率太低——主要出现在数据的查找、排序、删除等环节
没有使用缓存——比如bitmap的复用,图片的缓存等,图片的读取会涉及文件I/O或者网络I/O,Bitmap的创建涉及解码。这些操作对于CPU来说都是耗时操作,应该尽量避免。
计算时使用的数据结构不对——可以用int类型处理的数据运算,却使用long或者double,这会导致CPU的运算负载多出4倍。 - CPU资源抢占
主线程的CPU资源被抢占——这是最常见的问题,在Android6.0之前没有RenderThread的时候,页面绘制渲染的工作都是在主线程中完成,主线程处理这些工作需要的CPU资源被子线程抢占,就会导致页面绘制渲染工作无法及时完成。
音视频播放的资源被抢占——音视频编解码本身会消耗大量的CPU资源,并且流畅的视频播放会解码速度是有硬性要求的,如果达不到就可能导致音视频播放效果不流畅。
线程过多——如果同时竞争CPU资源的线程过多,就会导致同一段时间内,每个线程分配到的CPU资源偏少,从而导致线程执行任务的耗时增加。所以在使用线程池时,要结合运行设备的CPU的核心数,来合理设置线程数。 - CPU使用率低
当系统处理磁盘或者网络IO、同步锁的竞争和线程切换、线程的休眠等操作时,会降低CPU的使用率。
通过shell命令,了解CPU的性能
查看CPU的相关信息
//在Android studio的Terminal窗口
//先输入adb shell进入当前已连接手机的shell环境
adb shell
//获取手机CPU的核心数,如下图所示,当前连接的手机CPU为8核
cat /sys/devices/system/cpu/possible
//获取第一个CPU的最大频率
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq
//获取第二个CPU的最小频率
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq
执行结果如下图所示:通过/proc/stat命令,查看CPU耗时
//查看整个系统的CPU使用情况
cat /proc/stat
//查看当前正在调试的app所在进程的CPU使用情况
cat /proc/self/stat
//查看指定的某个进程的CPU使用情况
cat /proc/[pid]/stat
/proc/[pid]/stat // 进程CPU使用情况
/proc/[pid]/task/[tid]/stat // 进程下面各个线程的CPU使用情况
/proc/[pid]/sched // 进程CPU调度相关
/proc/loadavg // 系统平均负载,uptime命令对应文件
执行结果如下:使用top命令查看各进程的CPU使用情况
// 直接使用top命令会定时不断地输出进程的相关信息
top
// 排除0%的进程信息
top | grep -v '0% S'
// 获取指定进程的CPU、内存消耗,并设置刷新间隔
top -d 1 | grep pers.jay.wanandroid
使用ps命令查看进程消耗CPU的时间占比
// 查看指定进程的状态信息
ps -p 6440
上述指令中的6440是进程ID,执行结果如下上图中各输出参数的含义如下
- USER:用户名
- PID:进程ID
- PPID:父进程ID
- VSZ:虚拟内存大小,以K为单位
- RSS:常驻内存大小(正在使用的页)
- WCHAN:进程在内核态的运行时间
- nstruction pointer:指令指针
- NAME:进程名字
- S:当前进程的状态,总共有10种可能的状态:R (running) S (sleeping) D (device I/O) T (stopped) t (traced) Z (zombie) X (deader) x (dead) K (wakekill) W (waking)。
查看指定进程已经消耗的CPU时间占系统总时间的百分比
// 查看指定进程已经消耗的CPU时间占系统总时间的百分比
ps -o PCPU -p 6440
使用dumpsys cpuinfo命令查看CPU使用情况
使用dumpsys cpuinfo命令获得的信息比起top命令得到的信息要更加精炼,可以看到一段时间内,系统内正在运行的所有进程,以及各进程的CPU使用情况。卡顿优化的工具
StrictMode
StrictMode,是Android提供一种运行时检测机制,可以帮助开发人员检测代码中一些不规范的问题。对于规模较大的项目,代码量也很大,如果只是肉眼去review代码,不仅效率非常低,而且也比较容易出问题。使用StrictMode之后,系统会自动检测出主线程中的一些异常情况。并且按照自定义的配置给出相应的反应。
StrictMode主要用来检测两方面的问题:
- 线程策略
线程策略的检测内容,是一些自定义的耗时操作,磁盘读取以及网络请求等。主要用于检测主线程中的耗时操作。 - 虚拟机策略
虚拟机策略的检测内容主要是Activity泄漏、Sqlite对象泄漏和检测实例数量。
StrictMode使用如下:
//设置线程策略
StrictMode.ThreadPolicy threadPolicy = new StrictMode.ThreadPolicy.Builder()
// .detectCustomSlowCalls() //API等级11,使用StrictMode.noteSlowCode
// .detectDiskReads()
// .detectDiskWrites()
// .detectNetwork()
.detectAll() //detectAll() for all detectable problems
// .penaltyDialog() //可以直接弹出警报Dialog
// .penaltyDeath() //或者直接崩溃
.penaltyLog() //在Logcat 中打印违规异常信息
.build();
StrictMode.setThreadPolicy(threadPolicy);
//虚拟机策略
StrictMode.VmPolicy vmPolicy = new StrictMode.VmPolicy.Builder()
.detectActivityLeaks() //检测Activity对象的泄漏
.detectLeakedSqlLiteObjects() //检测Sqlite对象的泄漏
.setClassInstanceLimit(Danmakus.class,1)
.detectAll()
.penaltyLog()
.build();
StrictMode.setVmPolicy(vmPolicy);
在上述代码中,我通过设置setClassInstanceLimit(Danmakus.class,1)来限制在程序运行时Danmakus类的对象只能有一个,当StrictMode检测到有多个Danmakus类的对象时,就会报出如下提示:
2020-07-24 14:16:06.105 19773-19773/com.zacky.bulletchattest D/StrictMode: StrictMode policy violation: android.os.strictmode.InstanceCountViolation: class master.flame.danmaku.danmaku.model.android.Danmakus; instances=11; limit=1
at android.os.StrictMode.setClassInstanceLimit(StrictMode.java:1)
2020-07-24 14:16:29.579 19773-19773/com.zacky.bulletchattest D/StrictMode: StrictMode policy violation; ~duration=56 ms: android.os.strictmode.DiskWriteViolation
at android.os.StrictMode$AndroidBlockGuardPolicy.onWriteToDisk(StrictMode.java:1460)
at java.io.FileOutputStream.<init>(FileOutputStream.java:236)
at java.io.FileOutputStream.<init>(FileOutputStream.java:119)
at java.io.FileWriter.<init>(FileWriter.java:63)
at com.android.server.am.ActivityManagerServiceInjector.writeToNode(ActivityManagerServiceInjector.java:1726)
at com.android.server.am.ActivityManagerServiceInjector.setTopAppUIThread(ActivityManagerServiceInjector.java:1716)
at com.android.server.am.ActivityManagerService.applyOomAdjLocked(ActivityManagerService.java:25240)
at com.android.server.am.ActivityManagerService.updateOomAdjLocked(ActivityManagerService.java:25943)
at com.android.server.am.ActivityStack.resumeTopActivityInnerLocked(ActivityStack.java:2871)
at com.android.server.am.ActivityStack.resumeTopActivityUncheckedLocked(ActivityStack.java:2474)
at com.android.server.am.ActivityStackSupervisor.resumeFocusedStackTopActivityLocked(ActivityStackSupervisor.java:2352)
at com.android.server.am.ActivityStack.completePauseLocked(ActivityStack.java:1726)
at com.android.server.am.ActivityStack.activityPausedLocked(ActivityStack.java:1646)
at com.android.server.am.ActivityManagerService.activityPaused(ActivityManagerService.java:8523)
at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:225)
at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:3374)
at android.os.Binder.execTransact(Binder.java:726)
BlockCanary
BlockCanary对主线程操作进行了完全透明的监控,并能输出有效的信息,帮助开发分析、定位到问题所在,迅速优化应用。其特点有:
- 非侵入式,简单的两行就打开监控,不需要到处打点,破坏代码优雅性。
- 精准,输出的信息可以帮助定位到问题所在(精确到行),不需要像Logcat一样,慢慢去找。
BlockCanary的原理:基于Android的消息处理机制。熟悉消息处理机制的同学都知道,一个线程最多只有一个Looper对象与之关联。应用程序的主线程也是如此,在ActivityThread的main方法中,会调用Looper.prepareMainLooper()方法,来为主线程创建关联的Looper对象。
private static Looper sMainLooper; // guarded by Looper.class
...
/**
* Initialize the current thread as a looper, marking it as an
* application's main looper. The main looper for your application
* is created by the Android environment, so you should never need
* to call this function yourself. See also: {@link #prepare()}
*/
public static void prepareMainLooper() {
prepare(false);
synchronized (Looper.class) {
if (sMainLooper != null) {
throw new IllegalStateException("The main Looper has already been prepared.");
}
sMainLooper = myLooper();
}
}
/** Returns the application's main looper, which lives in the main thread of the application.
*/
public static Looper getMainLooper() {
synchronized (Looper.class) {
return sMainLooper;
}
}
从上面的代码也可以看出,prepareMainLooper()方法保证了主线程的Looper对象只会创建一次。这样不管在主线程中,创建了多少个Handler对象来发送和处理各种消息,最终都会通过这个Looper对象来进行消息的分发,即调用Handler的dispatchMessage方法
在Looper.loop方法中有这样一段代码:
public static void loop() {
...
for (;;) {
...
// This must be in a local variable, in case a UI event sets the logger
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);
}
...
}
}
可以看到在dispatchMessage方法的前后,都有一个Printer类型的对象logging在打印信息。也就是说,只需要比较开始和结束信息的打印时间,就可以得到dispatchMessage方法的耗时。如果主线程中存在耗时操作,那肯定会体现在dispatchMessage的执行时间上。那么我们可以给主线程的Looper对象设置一个自定义的Printer,来实现对每一次dispatchMessage方法执行耗时的监控。
Looper.getMainLooper().setMessageLogging(mainLooperPrinter);
并在mainLooperPrinter中判断start和end,来获取主线程dispatch该message的开始和结束时间,并判定该时间超过阈值(如2000毫秒)为主线程卡慢发生,此时就通过子线程dump出卡慢发生时的各种信息,提供开发者分析性能瓶颈。
...
@Override
public void println(String x) {
if (!mStartedPrinting) {
mStartTimeMillis = System.currentTimeMillis();
mStartThreadTimeMillis = SystemClock.currentThreadTimeMillis();
mStartedPrinting = true;
} else {
final long endTime = System.currentTimeMillis();
mStartedPrinting = false;
if (isBlock(endTime)) {
notifyBlockEvent(endTime);
}
}
}
private boolean isBlock(long endTime) {
return endTime - mStartTimeMillis > mBlockThresholdMillis;
}
...
核心流程图如下:
具体的监控流程可以归纳为如下步骤:
- 首先通过Looper.getMainLooper().setMessageLogging()为主线程的Looper对象设置自定义的Printer实现类来打印输出logging。这样在每次执行dispatchMessage方法前后都会调用我们自定义的Printer类。
- 在自定义的Printer类的println方法中,通过匹配字符串,如果匹配到">>>>> Dispatching to ",则开始在子线程去执行获取当前主线程堆栈信息的任务,这个任务同时会获取当前的一些场景信息,比如内存、CPU和网络状态等信息。
- 如果在指定的时间阈值内,再次调用println方法匹配到了“<<<<< Finished to ”,就说明dispatchMessage方法的耗时正常,没有发生卡顿,那我们就可以将子线程的任务取消掉。
应用发生卡顿时,BlockCanary除了能在开发调试时提供信息界面让开发和测试人员直接看到卡顿原因之外,其最大的作用就是在App发布到线上后,也可以进行大范围的Log采集和分析,主要是从两个维度进行分析:一是卡顿时间,二是根据相同堆栈出现的次数来对卡顿原因进行排序和归类。下图演示了实际开发调试时,BlockCanary的卡顿信息提示页面
BlockCanary的优点:
非侵入式
方便精准,能定位到具体的某一行代码
BlockCanary的缺陷:
还有一个问题是,调用Printer的println方法时,会涉及到字符串拼接,所以在短时间内处理大量任务,类似快速滑动recyclerView导致页面刷新等操作时,会增加内存消耗,甚至触发GC等。
AspectJ
AspectJ是一个实现AOP的框架。在Android平台上,常用的是Hujiang团队开源的AspectJ插件。它的工作原理是:通过Gradle Transform,在编译期间class文件生成后至dex文件生成前,遍历并匹配所有符合AspectJ定义的切点处,插入定义好的代码,从而处理相应的逻辑。AspectJ常用于打印方法的耗时,权限检查,统计按钮事件的点击次数等。在Android上的应用主要是做性能监控,基于注解的数据埋点。具体的使用教程可以参考Android AspectJ详解和AspectJ AOP教程:实现Android基于注解无侵入埋点、性能监控。
另外,常见的基于AspectJ实现的有大神JakeWharton的Hugo框架。
AspectJ的弊端:由于其基于规则,所以切入点相对固定,对于字节码文件的操作自由度以及开发的掌握度都要打一定的折扣。,并且它会额外生成一些包装代码,对性能以及包大小都有一定的负担。
虽然AspectJ非常强大,但是它也只能实现50%的字节码操作场景,如果要实现100%的字节码操作场景,就需要使用ASM。
ASM
ASM基本上可以实现任何对字节码的操作,也就是自由度和开发的掌握度很高。它提供了访问者模式来访问字节码文件,并且只注入我们想要注入的代码。ASM是诸多JVM语言钦定的字节码生成库,它在效率和性能方面的优势要远超其他的字节码操作库如AspectJ。
ASM的优点:
- 适宜处理简单类的修改
- 学习成本较低
- 代码量较少
ASM的缺点: - 处理大量信息会使代码变得复杂
- 代码难以复用
Lancet
Lancet是一个轻量级的Android AOP框架。由eleme团队开源分享。它的特点是:
- 编译速度快,并且支持增量编译。
- 简洁的API,几行Java代码就能完成注入需求。
- 没有任何多余代码插入APK.
- 支持用于SDK,可以在SDK编写注入代码来修改依赖SDK的App。
DroidAssist
DroidAssist由滴滴团队开源,是一个轻量级的Android字节码编辑插件,基于Javassist对字节码操作,根据xml配置class文件,以达到对class文件进行动态修改的效果。与其他AOP方案不同。DroidAssist提供了一种更加轻量,简单易用,无侵入,可配置化的字节码操作方式。你不需要Java字节码的相关知识,只需要在xml插件配置中添加简单的Java代码即可实现类似AOP的功能,同时不需要引入其他额外的依赖。
DroidAssist的特点
- 灵活的配置化方式,使得一个配置就可以处理项目中所有的 class 文件。
- 丰富的字节码处理功能,针对 Android 移动端的特点提供了例如代码替换,添加try catch,方法耗时等功能。
- 简单易用,只需要依赖一个插件,处理过程以及处理后的代码中也不需要添加额外的依赖。
- 处理速度较快,只占用较少的编译时间。
最后,卡顿优化还会涉及到布局和绘制方面的优化,慢慢把坑补上吧。
本文参考:
Android开发高手课:06 | 卡顿优化(下):如何监控应用卡顿?
BlockCanary — 轻松找出Android App界面卡顿元凶
深入探索编译插桩技术(二、AspectJ)
深入探索编译插桩技术(四、ASM 探秘)
编译插桩操纵字节码,实现不可能完成的任务
DroidAssist
Android AspectJ详解
AOP在Android中最佳用法
AspectJ AOP教程:实现Android基于注解无侵入埋点、性能监控