卡顿优化②原因分析和监控工具

卡顿分析

造成卡顿的原因可能是多种多样的,但是最终都会反映在CPU时间上。Android系统是基于Linux的,可以CPU时间分为两种:

  • 用户时间——执行用户态应用程序代码消耗的时间。
  • 系统时间——执行内核态系统调用的时间。包括I/O、锁、中断以及其他系统调用的执行时间。

CPU的相关问题可以分为三类:

  1. CPU资源冗余使用
    算法效率太低——主要出现在数据的查找、排序、删除等环节
    没有使用缓存——比如bitmap的复用,图片的缓存等,图片的读取会涉及文件I/O或者网络I/O,Bitmap的创建涉及解码。这些操作对于CPU来说都是耗时操作,应该尽量避免。
    计算时使用的数据结构不对——可以用int类型处理的数据运算,却使用long或者double,这会导致CPU的运算负载多出4倍。
  2. CPU资源抢占
    主线程的CPU资源被抢占——这是最常见的问题,在Android6.0之前没有RenderThread的时候,页面绘制渲染的工作都是在主线程中完成,主线程处理这些工作需要的CPU资源被子线程抢占,就会导致页面绘制渲染工作无法及时完成。
    音视频播放的资源被抢占——音视频编解码本身会消耗大量的CPU资源,并且流畅的视频播放会解码速度是有硬性要求的,如果达不到就可能导致音视频播放效果不流畅。
    线程过多——如果同时竞争CPU资源的线程过多,就会导致同一段时间内,每个线程分配到的CPU资源偏少,从而导致线程执行任务的耗时增加。所以在使用线程池时,要结合运行设备的CPU的核心数,来合理设置线程数。
  3. 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

执行结果如下图所示:
image.png
通过/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命令对应文件

执行结果如下:
image.png
image.png
使用top命令查看各进程的CPU使用情况
// 直接使用top命令会定时不断地输出进程的相关信息
top

// 排除0%的进程信息 
top | grep -v '0% S'
image.png
// 获取指定进程的CPU、内存消耗,并设置刷新间隔 
top -d 1 | grep pers.jay.wanandroid
image.png
使用ps命令查看进程消耗CPU的时间占比
// 查看指定进程的状态信息 
ps -p 6440

上述指令中的6440是进程ID,执行结果如下
image.png

上图中各输出参数的含义如下

  • 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
image.png
使用dumpsys cpuinfo命令查看CPU使用情况

使用dumpsys cpuinfo命令获得的信息比起top命令得到的信息要更加精炼,可以看到一段时间内,系统内正在运行的所有进程,以及各进程的CPU使用情况。
image.png

卡顿优化的工具

StrictMode

StrictMode,是Android提供一种运行时检测机制,可以帮助开发人员检测代码中一些不规范的问题。对于规模较大的项目,代码量也很大,如果只是肉眼去review代码,不仅效率非常低,而且也比较容易出问题。使用StrictMode之后,系统会自动检测出主线程中的一些异常情况。并且按照自定义的配置给出相应的反应。
StrictMode主要用来检测两方面的问题:

  1. 线程策略
    线程策略的检测内容,是一些自定义的耗时操作,磁盘读取以及网络请求等。主要用于检测主线程中的耗时操作。
  2. 虚拟机策略
    虚拟机策略的检测内容主要是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;
}
...

核心流程图如下:


image.png

具体的监控流程可以归纳为如下步骤:

  1. 首先通过Looper.getMainLooper().setMessageLogging()为主线程的Looper对象设置自定义的Printer实现类来打印输出logging。这样在每次执行dispatchMessage方法前后都会调用我们自定义的Printer类。
  2. 在自定义的Printer类的println方法中,通过匹配字符串,如果匹配到">>>>> Dispatching to ",则开始在子线程去执行获取当前主线程堆栈信息的任务,这个任务同时会获取当前的一些场景信息,比如内存、CPU和网络状态等信息。
  3. 如果在指定的时间阈值内,再次调用println方法匹配到了“<<<<< Finished to ”,就说明dispatchMessage方法的耗时正常,没有发生卡顿,那我们就可以将子线程的任务取消掉。

应用发生卡顿时,BlockCanary除了能在开发调试时提供信息界面让开发和测试人员直接看到卡顿原因之外,其最大的作用就是在App发布到线上后,也可以进行大范围的Log采集和分析,主要是从两个维度进行分析:一是卡顿时间,二是根据相同堆栈出现的次数来对卡顿原因进行排序和归类。下图演示了实际开发调试时,BlockCanary的卡顿信息提示页面


image.png

BlockCanary的优点:
非侵入式
方便精准,能定位到具体的某一行代码

BlockCanary的缺陷:

BlockCanary监控的是dispatchMessage方法的耗时,而dispatchMessage方法执行的是一个相对完成的流程,里面可能涉及到多个方法的串行执行。这样会导致在发生卡顿的周期内,应用确实发生了卡顿,但是获取到的卡顿信息可能会不准确。也就是说,最后获取到的堆栈信息只是一个表象,并不是真正导致卡顿的原因。先看看如下示意图:
image.png
假设在dispatchMessage方法处理过程中,顺序执行了A、B、C三个方法,当整个执行过程的耗时超过指定的阈值(假设是3秒)时,可以看出,实际上A、B、C三个方法的耗时分别是2300ms、500ms和200ms。但是因为方法A和B已经执行完毕,我们只能得到正在执行的方法C的堆栈信息。但事实上它并不是导致卡顿的真正原因。

还有一个问题是,调用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基于注解无侵入埋点、性能监控

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349