阅读 1477

Android高手笔记 - 卡顿优化

  • 如何定义发生了卡顿现象:
如果App的FPS平均值小于30,最小值小于24,即表明应用发生了卡顿。
复制代码
  • 线下很难复现,与发生场景强相关(所以需要我们去做卡顿监控,收集现场信息)

CPU相关知识

  • 现在最新的主流机型都使用了多级能效的CPU架构(即多核分层架构)
  • 从 CPU 到 GPU 再到 AI 芯片NPU,随着手机 CPU 整体性能的飞跃, 我们可以充分利用移动端的计算能力来降低高昂的服务器成本;
  • 评价一个 CPU 的性能,需要看主频、核心数、缓存等参数,具体表现出来的是计算能力和指令执行能力,也就是每秒执行的浮点计算数和每秒执行的指令数;
  • 造成卡顿的原因很多(涉及到代码、内存、绘制、IO、CPU等),最终都反映到 CPU 时间上,CPU时间 可以分为用户时间和系统时间;
    • 用户时间:执行用户态应用程序代码所消耗的时间;
    • 系统时间:执行内核态系统调用所消耗的时间,包括 I/|O、锁、中断以及其他系统调用的时间;
  • 常用命令:
adb shell
// 获取 CPU 核心数
cat /sys/devices/system/cpu/possible 
// 获取第一个 CPU 的最大频率
 cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq                         <
// 获取第二个CPU的最小频率
cat /sys/devices/system/cpu/cpu1/cpufreq/cpuinfo_min_freq                         <
//整个系统的 CPU 使用情况
cat /proc/[pid]/stat

top 命令可以帮助我们查看哪个进程是 CPU 的消耗大户;
vmstat 命令可以实时动态监视操作系统的虚拟内存和 CPU 活动;
strace 命令可以跟踪某个进程中所有的系统调用
vmstat命令或者/proc/[pid]/schedstat文件来查看 CPU 上下文切换次数

/proc/[pid]/stat             // 进程CPU使用情况
/proc/[pid]/task/[tid]/stat  // 进程下面各个线程的CPU使用情况
/proc/[pid]/sched            // 进程CPU调度相关
/proc/loadavg                // 系统平均负载,uptime命令对应文件
复制代码
  • 否存在高优先级的线程空等低优先级线程,例如主线程等待某个后台线程的锁
  • CPU相关的三类问题:
    1. CPU资源冗余使用:
      • 算法效率低
      • 没使用缓存
      • 计算时使用的基本类型不对(如int足够却用long,运算压力多出4倍)
    2. CPU资源争抢:
      • 抢主线程的CPU资源
      • 抢音视频的CPU资源,
        • 音视频编解码本身会消耗大量的CPU资源,并且其对于解码的速度是有硬性要求的,如果达不到就可能产生播放流畅度的问题;
        • 采取两种方式去优化:
          1. 尽量排除非核心业务的消耗。
          2. 优化自身的性能消耗,把CPU负载转化为GPU负载,如使用renderscript来处理视频中的影像信息。
      • 大家平等,互相抢(三个和尚没水喝)
    3. CPU资源利用率低:
      • 有磁盘和网络I/O,还有锁操作、sleep等等, 对于锁的优化,通常是尽可能地缩减锁的范围;

卡顿排查工具

  • Traceview 和 systrace 都是我们比较熟悉的排查卡顿的工具,从实现上这些工具分为两个流派
    • instrument: 获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点;
    • sample: 有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析;
  1. Traceview

    • 类型:instrument;
    • 原理:利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中;
    • 特点:
      • 用来查看整个过程有哪些函数调用,但工具本身带来的性能开销过大,有时无法反映真实的情况;
      • Android 5.0 之后,新增了startMethodTracingSampling方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。
      新增了 sample 类型后,就需要我们在开销和信息丰富度之间做好权衡。
      • 无论是哪种的 Traceview 对 release 包支持的都不太好,例如无法反混淆
  2. Nanoscope

    • 类型:instrument
    • 原理:直接修改 Android 虚拟机源码,在ArtMethod执行入口和执行结束位置增加埋点代码,将所有的信息先写到内存,等到 trace 结束后才统一生成结果文件;
    • 特点:性能损耗较小,适合做启动耗时的自动化分析,但是 trace 结束生成结果文件这一步需要的时间比较长。另一方面它可以支持分析任意一个应用,可用于做竞品分析。但是它也有不少限制:
      • 需要自己刷 ROM,并且当前只支持 Nexus 6P,或者采用其提供的 x86 架构的模拟器;
      • 默认只支持主线程采集,其他线程需要代码手动设置
      考虑到内存大小的限制,每个线程的内存数组只能支持大约 20 秒左右的时间段。
      • 我们可以每天定期去跑自动化启动测试,查看是否存在新增的耗时点
  3. systrace

    • 类型:sample
    • Android 4.1 新增的性能分析工具。我通常使用 systrace 跟踪系统的 I/O 操作、CPU 负载、Surface 渲染、GC 等事件。
    • 特点:只能监控特定系统调用的耗时情况,性能开销低,但不支持应用程序代码的耗时分析;但是系统预留了Trace.beginSection接口来监听应用程序的调用耗时,我们可以通过编译时给每个函数插桩的方式来实现在 systrace 基础上增加应用程序耗时的监控
  4. Simpleperf

    • 类型:sample
    • 如果我们想分析 Native 函数的调用,上面的三个工具都不能满足这个需求,Android 5.0 新增了Simpleperf性能分析工具
    • 利用 CPU 的性能监控单元(PMU)提供的硬件 perf 事件,可以看到所有的 Native 代码的耗时,同时封装了 systrace 的监控功能
    • Android Studio 3.2 也在 Profiler 中直接支持 Simpleperf
  • 汇总一下
    • 如果需要分析 Native 代码的耗时,可以选择 Simpleperf;
    • 如果想分析系统调用,可以选择 systrace;
    • 如果想分析整个程序执行流程的耗时,可以选择 Traceview 或者插桩版本的 systrace。
  1. 可视化方法
  • Android Studio 3.2 的 Profiler 中直接集成了几种性能分析工具:
    • Sample Java Methods 的功能类似于 Traceview 的 sample 类型
    • Trace Java Methods 的功能类似于 Traceview 的 instrument 类型
    • Trace System Calls 的功能类似于 systrace
    • SampleNative (API Level 26+) 的功能类似于 Simpleperf
  • 虽然不够全面和强大,但大大降低了开发者的使用门槛
  • 分析结果的展示方式:这些分析工具都支持了 Call Chart 和 Flame Chart 两种展示方式
    • Call Chart 是 Traceview 和 systrace 默认使用的展示方式,按照应用程序的函数执行顺序来展示,适合用于分析整个流程的调用
    • Flame Chart 也就是大名鼎鼎的火焰图,以一个全局的视野来看待一段时间的调用分布,时间和空间两个维度上的信息融合在一张图上
  1. StrictMode
    • 是Android 2.3引入的一个工具类,它被称为严苛模式,是Android提供的一种运行时检测机制,可以用来帮助开发人员用来检测代码中一些不规范的问题。
    • 主要用来检测两大问题:
      1. 线程策略: 检测内容是一些自定义的耗时调用、磁盘读取操作以及网络请求等;
      2. 虚拟机策略: 检测内容包括Activity泄漏,SqLite对象泄漏,检测实例数量;
    • 使用:在Application的onCreate方法中对StrictMode进行统一配置,在日志输出栏中注意使用“StrictMode”关键字过滤出对应的log即可
         private void initStrictMode() {
        // 1、设置Debug标志位,仅仅在线下环境才使用StrictMode
        if (BuildConfig.isDebug) {
            // 2、设置线程策略
            StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                    .detectCustomSlowCalls() //API等级11,使用StrictMode.noteSlowCode
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork() // or .detectAll() for all detectable problems
                    .penaltyLog() //在Logcat 中打印违规异常信息
                    //.penaltyDialog() //也可以直接跳出警报dialog
                    //.penaltyDeath() //或者直接崩溃
                    .build());
            // 3、设置虚拟机策略
            StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    // 给Person对象的实例数量限制为1
                    .setClassInstanceLimit(Person.class, 1)
                    .detectLeakedClosableObjects() //API等级11
                    .penaltyLog()
                    .build());
        }
    }
    复制代码

卡顿监控

  1. 消息队列
    • 方式1:通过替换 Looper 的 Printer 实现;
      1. 首先,我们需要使用Looper.getMainLooper().setMessageLogging()去设置我们自己的Printer 实现类去打印输出logging。这样,在每个message执行的之前和之后都会调用我们设置的这个Printer实现类。
      2. 如果我们匹配到">>>>> Dispatching to "之后,我们就可以执行一行代码:也就是在指定的时间阈值之后, 我们在子线程去执行一个任务,这个任务就是去获取当前主线程的堆栈信息以及当前的一些场景信息,比如:内存大小、电脑、网络状态等。
      3. 如果在指定的阈值之内匹配到了"<<<<< Finished to ",那么说明message就被执行完成了, 则表明此时没有产生我们认为的卡顿效果,那我们就可以将这个子线程任务取消掉
    • 方式2:通过一个监控线程,每隔 1 秒向主线程消息队列的头部插入一条空消息; 如果我们需要监控 3 秒卡顿,那在第 4 次轮询中头部消息依然没有被消费的话,就可以确定主线程出现了一次 3 秒以上的卡顿;
  2. 插装
    • 基于消息队列的卡顿监控并不准确,正在运行的函数有可能并不是真正耗时的函数;
    假设一个消息循环里面顺序执行了 A、B、C 三个函数,当整个消息执行超过 3 秒时,因为函数 A 和 B 已经执行完毕,
    我们只能得到的正在执行的函数 C 的堆栈,事实上它可能并不耗时,不过对于线上大数据来说,因为函数 A 和 B 相对
    比较耗时,所以抓取到它们的概率会更大一些,通过后台聚合后捕获到函数 A 和 B 的卡顿日志会更多一些;
    如果跟 Traceview 一样,可以拿到整个卡顿过程所有运行函数的耗时,就可以明确知道其实函数 A 和 B 才是造成卡顿的主要原因;
    那能否利用 Android Runtime 函数调用的回调事件,做一个自定义的 Traceview++ 呢?
    复制代码
    • 需要使用 Inline Hook 技术。我们可以实现类似 Nanoscope 先写内存的方案;需要注意两点:
      • 避免方法数暴增
      • 过滤简单的函数
    • 实现参考:微信的Matrix
    • 虽然插桩方案对性能的影响总体还可以接受,但只会在灰度包使用;
    • 短板:只能监控应用内自身的函数耗时,无法监控系统的函数调用,整个堆栈看起来好像“缺失了”一部分
  3. Profilo
    • 参考了JVM的 AsyncGetCallTrace 思路,然后适配 Android Runtime 的实现
    • Facebook 开源库,它收集了各大方案的优点
      1. 集成 atrace 功能
         ftrace 所有性能埋点数据都会通过 trace_marker 文件写入内核缓冲区,Profilo 通过
         PLT Hook 拦截了写入操作,选择部分关心的事件做分析。这样所有 systrace 的探针我们
         都可以拿到,例如四大组件生命周期、锁等待时间、类校验、GC 时间等。
        复制代码
      2. 快速获取 Java 堆栈
        • 获取堆栈的代价是巨大的,它要暂停主线程的运行,Profilo 的实现非常精妙,它实现类似
        Native 崩溃捕捉的方式快速获取 Java 堆栈,通过间隔发送 SIGPROF 信号;
  4. AndroidPerformanceMonitor
    • 一个非侵入式的性能监控组件,可以通过通知的形式弹出卡顿信息。
    • 优势:非侵入式,方便精准,能够定位到代码的某一行代码。
    • 使用:
    //1. build.gradle下配置它的依赖
    api 'com.github.markzhai:blockcanary-android:1.5.0'
    // 仅在debug包启用BlockCanary进行卡顿监控和提示的话,可以这么用
    debugApi 'com.github.markzhai:blockcanary-android:1.5.0'
    //2. Application的onCreate方法中开启卡顿监控
    BlockCanary.install(this, new AppBlockCanaryContext()).start();  
    //3.继承BlockCanaryContext类去实现自己的监控配置上下文类 
    /**
     * @Author: LiuJinYang
     * @CreateDate: 2020/12/9
     */
    public class AppBlockCanaryContext extends BlockCanaryContext {
        // 实现各种上下文,包括应用标识符,用户uid,网络类型,卡顿判断阙值,Log保存位置等等
    
        /**
         * 提供应用的标识符
         *
         * @return 标识符能够在安装的时候被指定,建议为 version + flavor.
         */
        @Override
        public String provideQualifier() {
            return "unknown";
        }
    
        /**
         * 提供用户uid,以便在上报时能够将对应的
         * 用户信息上报至服务器
         *
         * @return user id
         */
        @Override
        public String provideUid() {
            return "uid";
        }
    
        /**
         * 提供当前的网络类型
         *
         * @return {@link String} like 2G, 3G, 4G, wifi, etc.
         */
        @Override
        public String provideNetworkType() {
            return "unknown";
        }
    
        /**
         * 配置监控的时间区间,超过这个时间区间    ,BlockCanary将会停止, use
         * with {@code BlockCanary}'s isMonitorDurationEnd
         *
         * @return monitor last duration (in hour)
         */
        @Override
        public int provideMonitorDuration() {
            return -1;
        }
    
        /**
         * 指定判定为卡顿的阈值threshold (in millis),
         * 你可以根据不同设备的性能去指定不同的阈值
         *
         * @return threshold in mills
         */
        @Override
        public int provideBlockThreshold() {
            return 1000;
        }
    
        /**
         * 设置线程堆栈dump的间隔, 当阻塞发生的时候使用, BlockCanary 将会根据
         * 当前的循环周期在主线程去dump堆栈信息
         * <p>
         * 由于依赖于Looper的实现机制, 真实的dump周期
         * 将会比设定的dump间隔要长(尤其是当CPU很繁忙的时候).
         * </p>
         *
         * @return dump interval (in millis)
         */
        @Override
        public int provideDumpInterval() {
            return provideBlockThreshold();
        }
    
        /**
         * 保存log的路径, 比如 "/blockcanary/", 如果权限允许的话,
         * 会保存在本地sd卡中
         *
         * @return path of log files
         */
        @Override
        public String providePath() {
            return "/blockcanary/";
        }
    
        /**
         * 是否需要通知去通知用户发生阻塞
         *
         * @return true if need, else if not need.
         */
        @Override
        public boolean displayNotification() {
            return true;
        }
    
        /**
         * 用于将多个文件压缩为一个.zip文件
         *
         * @param src  files before compress
         * @param dest files compressed
         * @return true if compression is successful
         */
        @Override
        public boolean zip(File[] src, File dest) {
            return false;
        }
    
        /**
         * 用于将已经被压缩好的.zip log文件上传至
         * APM后台
         *
         * @param zippedFile zipped file
         */
        @Override
        public void upload(File zippedFile) {
            throw new UnsupportedOperationException();
        }
    
        /**
         * 用于设定包名, 默认使用进程名,
         *
         * @return null if simply concern only package with process name.
         */
        @Override
        public List<String> concernPackages() {
            return null;
        }
    
        /**
         * 使用 @{code concernPackages}方法指定过滤的堆栈信息
         *
         * @return true if filter, false it not.
         */
        @Override
        public boolean filterNonConcernStack() {
            return false;
        }
    
        /**
         * 指定一个白名单, 在白名单的条目将不会出现在展示阻塞信息的UI中
         *
         * @return return null if you don't need white-list filter.
         */
        @Override
        public List<String> provideWhiteList() {
            LinkedList<String> whiteList = new LinkedList<>();
            whiteList.add("org.chromium");
            return whiteList;
        }
    
        /**
         * 使用白名单的时候,是否去删除堆栈在白名单中的文件
         *
         * @return true if delete, false it not.
         */
        @Override
        public boolean deleteFilesInWhiteList() {
            return true;
        }
    
        /**
         * 阻塞拦截器, 我们可以指定发生阻塞时应该做的工作
         */
        @Override
        public void onBlock(Context context, BlockInfo blockInfo) {
    
        }
    } 
    复制代码
其他监控
  • 除了主线程的耗时过长之外,我们还有哪些卡顿问题需要关注呢?
  • Android Vitals 是 Google Play 官方的性能监控服务,涉及卡顿相关的监控有 ANR、启动、帧率三个
  1. 帧率
    • 业界都使用 Choreographer 来监控应用的帧率;
    • 需要排除掉页面没有操作的情况,应该只在界面存在绘制的时候才做统计;
      // 监听界面是否存在绘制行为
      getWindow().getDecorView().getViewTreeObserver().addOnDrawListener
      复制代码
    • 平均帧率: 衡量界面流畅度;
    • 冻帧率:计算发生冻帧时间在所有时间的占比;
      • 冻帧:Android Vitals 将连续丢帧超过 700 毫秒定义为冻帧,也就是连续丢帧 42 帧以上;
      • 出现丢帧的时候,我们可以获取当前的页面信息、View 信息和操作路径上报后台,降低二次排查的难度
  2. 生命周期监控
    • Activity、Service、Receiver 组件生命周期的耗时和调用次数也是我们重点关注的性能问题;
      • 如:Activity 的 onCreate() 不应该超过 1 秒,不然会影响用户看到页面的时间
      • 对于组件生命周期应采用更严格地监控,全量上报,在后台查看各个组件各个生命周期的启动时间和启动次数;
    • 除了四大组件的生命周期,我们还需要监控各个进程生命周期的启动次数和耗时;
    • 生命周期监控推荐使用编译时插桩的方式,如 Aspect、ASM 和 ReDex 三种插桩技术;
  3. 线程监控
    • Java 线程管理是很多应用非常头痛的事情,应用启动过程就已经创建了几十上百个线程。而且大部分的线程都没有经过线程池管理,都在自由自在地狂奔着;
    • 另外一方面某些线程优先级或者活跃度比较高,占用了过多的 CPU。这会降低主线程 UI 响应能力,我们需要特别针对这些线程做重点的优化。
    • 对于线程需要监控两点
      1. 线程数量,以及创建线程的方式:可以通过 got hook 线程的 nativeCreate() 函数,主要用于进行线程收敛,也就是减少线程数量。
      2. 监控线程的用户时间 utime、系统时间 stime 和优先级
  • 导致卡顿的原因会有很多,比如函数非常耗时、I/O 非常慢、线程间的竞争或者锁等。其实很多时候卡顿问题并不难解决,相较解决来说,更困难的是如何快速发现这些卡顿点,以及通过更多的辅助信息找到真正的卡顿原因。

卡顿现场

  • 以AssetManager.openNonAsset函数耗时为例进行分析
  1. 方案一: java实现:
    • 通过源码可以发现,AssetManager 内部有大量的 synchronized 锁;
    • 步骤1: 获得 Java 线程状态:通过Thread.getState获取 , 证实当时主线程是 BLOCKED 状态;
      //WAITING、TIME_WAITING 和 BLOCKED 都是需要特别注意的状态;
      //BLOCKED 是指线程正在等待获取锁,对应的是下面代码中的情况一;
      //WAITING 是指线程正在等待其他线程的“唤醒动作”,对应的是代码中的情况二;
      synchronized (object)  {     // 情况一:在这里卡住 --> BLOCKED
          doSomething();
          object.wait();           // 情况二:在这里卡住 --> WAITING
      }  
      //不过当一个线程进入 WAITING 状态时,它不仅会释放 CPU 资源,还会将持有的 object 锁也同时释放。
      复制代码
    • 步骤2:获得所有线程堆栈:通过Thread.getAllStackTraces()获得
      • 注意:在 Android 7.0,getAllStackTraces 是不会返回主线程的堆栈的
      • 通过分析收集上来的卡顿日志,发现跟 AssetManager 相关的线程是BackgroundHandler
        "BackgroundHandler"  RUNNABLE
        at  android.content.res.AssetManager.list
        at  com.sample.business.init.listZipFiles 
        //通过查看AssetManager.list的确发现是使用了同一个 synchronized 锁,而 list 函数需要遍历整个目录,耗时会比较久
        public String[] list(String path) throws IOException {
          synchronized (this) {
            ensureValidLocked();
            return nativeList(mObject, path);
          }
        }
        //另外一方面,“BackgroundHandler”线程属于低优先级后台线程,这也是我们前面文章提到的不良现象,也就是主线程等待低优先级的后台线程 
        复制代码
  2. 方案2:ANR日志实现(SIGQUIT信号)
    • 上面java实现方案还不错,不过貌似ANR 日志的信息更加丰富,如果直接用 ANR 日志呢?
     // 线程名称; 优先级; 线程id; 线程状态
     "main" prio=5 tid=1 Suspended
       // 线程组;  线程suspend计数; 线程debug suspend计数; 
       | group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400
       // 线程native id; 进程优先级; 调度者优先级;
       | sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec
       // native线程状态; 调度者状态; 用户时间utime; 系统时间stime; 调度的CPU
       | state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100
       // stack相关信息
       | stack=0xff717000-0xff719000 stackSize=8MB
    
    复制代码
    • Native 线程状态
         上面的 ANR 日志中“main”线程的状态是 Suspended,Java 线程中的 6 种状态中并不存在 Suspended 状态啊?
         事实上,Suspended 代表的是 Native 线程状态。怎么理解呢?在 Android 里面 Java 线程的运行都委托于一个
         Linux 标准线程 pthread 来运行,而 Android 里运行的线程可以分成两种,一种是 Attach 到虚拟机的,一种是
         没有 Attach 到虚拟机的,在虚拟机管理的线程都是托管的线程,所以本质上 Java 线程的状态其实是 Native 线程
         的一种映射。不同的 Android 版本 Native 线程的状态不太一样,例如 Android 9.0 就定义了 27 种线程状态,
         它能更加明确地区分线程当前所处的情况。
      复制代码
    • 如何拿到卡顿时的 ANR 日志?
      • 第一步:当监控到主线程卡顿时,主动向系统发送 SIGQUIT 信号。
      • 第二步:等待 /data/anr/traces.txt 文件生成。
      • 第三步:文件生成以后进行上报
      • 通过 ANR 日志,我们可以直接看到主线程的锁是由“BackgroundHandler”线程持有。相比之下通过 getAllStackTraces 方法,我们只能通过一个一个线程进行猜测。
           // 堆栈相关信息
           at android.content.res.AssetManager.open(AssetManager.java:311)
           - waiting to lock <0x41ddc798> (android.content.res.AssetManager) held by tid=66 (BackgroundHandler)
           at android.content.res.AssetManager.open(AssetManager.java:289)
        复制代码
    • 存在的问题:
      • 可行性:很多高版本系统已经没有权限读取 /data/anr/traces.txt 文件,需要刷ROM;
      • 性能:获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿;
  3. 方案3:Hook实现
    • 通过 Hook 方式我们实现了一套“无损”获取所有 Java 线程堆栈与详细信息的方法:
      1. 通过 fork 子进程方式实现,这样即使子进程崩溃了也不会影响我们主进程的运行,而且获取所有线程堆栈这个过程可以做到完全不卡我们主进程;
      2. 通过libart.so、dlsym调用ThreadList::ForEach方法,拿到所有的 Native 线程对象。
      3. 遍历线程对象列表,调用Thread::DumpState方法;
  4. 线上ANR监控方式:
    • ANR的几种常见的类型:
      1. KeyDispatchTimeout:按键事件在5s的时间内没有处理完成;
      2. BroadcastTimeout:广播接收器在前台10s,后台60s的时间内没有响应完成;
      3. ServiceTimeout:服务在前台20s,后台200s的时间内没有处理完成;
    • 之前的崩溃优化中说了“怎么去发现应用中的 ANR 异常”,那么,有没有更好的实现方式呢?
    • ANR-WatchDog:一种非侵入式的ANR监控组件,可以用于线上ANR的监控
      //1. build.gradle下配置它的依赖
      implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0'
      //2. Application的onCreate方法中初始化ANR-WatchDog
          new ANRWatchDog().start();
      //3.源码:ANRWatchDog实际上是继承了Thread类,也就是它是一个线程,对于线程来说,最重要的就是其run方法
      private static final int DEFAULT_ANR_TIMEOUT = 5000;
      private volatile long _tick = 0;
      private volatile boolean _reported = false;
      
      private final Runnable _ticker = new Runnable() {
          @Override public void run() {
              _tick = 0;
              _reported = false;
          }
      };
      
      @Override
      public void run() {
          // 1、首先,将线程命名为|ANR-WatchDog|。
          setName("|ANR-WatchDog|");
          // 2、接着,声明了一个默认的超时间隔时间,默认的值为5000ms。
          long interval = _timeoutInterval;
          // 3、然后,在while循环中通过_uiHandler去post一个_ticker Runnable。
          while (!isInterrupted()) {
              // 3.1 这里的_tick默认是0,所以needPost即为true。
              boolean needPost = _tick == 0;
              // 这里的_tick加上了默认的5000ms
              _tick += interval;
              if (needPost) {
                  _uiHandler.post(_ticker);
              }
              // 接下来,线程会sleep一段时间,默认值为5000ms。
              try {
                  Thread.sleep(interval);
              } catch (InterruptedException e) {
                  _interruptionListener.onInterrupted(e);
                  return ;
              }
              // 4、如果主线程没有处理Runnable,即_tick的值没有被赋值为0,则说明发生了ANR,第二个_reported标志位是为了避免重复报道已经处理过的ANR。
              if (_tick != 0 && !_reported) {
                  //noinspection ConstantConditions
                  if (!_ignoreDebugger && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) {
                      Log.w("ANRWatchdog", "An ANR was detected but ignored because the debugger is connected (you can prevent this with setIgnoreDebugger(true))");
                      _reported = true;
                      continue ;
                  }
                  interval = _anrInterceptor.intercept(_tick);
                  if (interval > 0) {
                      continue;
                  }
                  final ANRError error;
                  if (_namePrefix != null) {
                      error = ANRError.New(_tick, _namePrefix, _logThreadsWithoutStackTrace);
                  } else {
                      // 5、如果没有主动给ANR_Watchdog设置线程名,则会默认会使用ANRError的NewMainOnly方法去处理ANR。
                      error = ANRError.NewMainOnly(_tick);
                  }
                 // 6、最后会通过ANRListener调用它的onAppNotResponding方法,其默认的处理会直接抛出当前的ANRError,导致程序崩溃。 _anrListener.onAppNotResponding(error);
                  interval = _timeoutInterval;
                  _reported = true;
              }
          }
      }
      //但是在Java层去获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。
      如果是对性能要求比较高的应用,可以通过Hook Native层的方式去获得所有线程的堆栈信息,参考上面“方案3:Hook实现”
      复制代码
  • 现场信息:
    • 能不能进一步让卡顿的“现场信息”的比系统 ANR 日志更加丰富?我们可以进一步增加这些信息:
      • CPU 使用率和调度信息:参考下面的课后作业1;
      • 内存相关信息:可以添加系统总内存、可用内存以及应用各个进程的内存等信息。如果开启了 Debug.startAllocCounting 或者 atrace,还可以增加 GC 相关的信息。
      • I/O 和网络相关: 还可以把卡顿期间所有的 I/O 和网络操作的详细信息也一并收集
  • Android 8.0 后,Android 虚拟机终于支持了 JVM 的JVMTI机制。Profiler 中内存采集等很多模块也切换到这个机制中实现,

卡顿单点问题检测方案

  • 常见的单点问题有主线程IPC(进程间通信)、DB操作等等
  • IPC单点问题检测方案:
    1. 在IPC的前后加上埋点。但是,这种方式不够优雅
    2. 线下可以通过adb命令监测
         // 1、对IPC操作开始监控
         adb shell am trace-ipc start
         // 2、结束IPC操作的监控,同时,将监控到的信息存放到指定的文件
         adb shell am trace-ipc stop -dump-file /data/local/tmp/ipc-trace.txt
         // 3、将监控到的ipc-trace导出到电脑查看
         adb pull /data/local/tmp/ipc-trace.txt
      复制代码
    3. ARTHook
      • AspectJ只能针对于那些非系统方法,也就是我们App自己的源码,或者是我们所引用到的一些jar、aar包;
      • ARTHook可以用来Hook系统的一些方法,因为对于系统代码来说,我们无法对它进行更改,但是我们可以Hook住它的一个方法,在它的方法体里面去加上自己的一些代码;
      //通过PackageManager去拿到我们应用的一些信息,或者去拿到设备的DeviceId这样的信息以及AMS相关的信息等,最终会调用到android.os.BinderProxy
      //在项目中的Application的onCreate方法中使用ARTHook对android.os.BinderProxy类的transact方法进行Hook
         try {
             DexposedBridge.findAndHookMethod(Class.forName("android.os.BinderProxy"), "transact",
                 int.class, Parcel.class, Parcel.class, int.class, new XC_MethodHook() {
                     @Override
                     protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                         LogHelper.i( "BinderProxy beforeHookedMethod " + param.thisObject.getClass().getSimpleName()
                                 + "\n" + Log.getStackTraceString(new Throwable()));
                         super.beforeHookedMethod(param);
                     }
                 });
         } catch (ClassNotFoundException e) {
             e.printStackTrace();
         }
      复制代码
      • 除了IPC调用的问题之外,还有IO、DB、View绘制等一系列单点问题需要去建立与之对应的检测方案
      • 对于卡顿问题检测方案的建设,主要是利用ARTHook去完善线下的检测工具,尽可能地去Hook相对应的操作,以暴露、分析问题。

使用Lancet统计界面耗时

  • Lancet 是一个轻量级Android AOP框架,编译速度快, 并且支持增量编译.
  • 使用Demo如下
//1. 在根目录的 build.gradle 添加:
dependencies {
    classpath 'me.ele:lancet-plugin:1.0.6'
}
//2. 在 app 目录的'build.gradle' 添加
apply plugin: 'me.ele.lancet'
dependencies {
    provided 'me.ele:lancet-base:1.0.6'
}
//3. 基础API使用
/**
 * @Author: LiuJinYang
 * @CreateDate: 2020/12/10
 */
public class LancetUtil {
    //@Proxy 指定了将要被织入代码目标方法i, 织入方式为Proxy(将使用新的方法替换代码里存在的原有的目标方法)
    @Proxy("i")
    //TargetClass指定了将要被织入代码目标类 android.util.Log
    @TargetClass("android.util.Log")
    public static int anyName(String tag, String msg){
        msg = "LJY_LOG: "+msg ;
        //Origin.call() 代表了 Log.i() 这个目标方法
        return (int) Origin.call();
    }
}
//4. 统计界面耗时
/**
 * @Author: LiuJinYang
 * @CreateDate: 2020/12/10
 */
public class LancetUtil {
    public static ActivityRecord sActivityRecord;

    static {
        sActivityRecord = new ActivityRecord();
    }

    @Insert(value = "onCreate",mayCreateSuper = true)
    @TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
    protected void onCreate(Bundle savedInstanceState) {
        sActivityRecord.mOnCreateTime = System.currentTimeMillis();
        // 调用当前Hook类方法中原先的逻辑
        Origin.callVoid();
    }

    @Insert(value = "onWindowFocusChanged",mayCreateSuper = true)
    @TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
    public void onWindowFocusChanged(boolean hasFocus) {
        sActivityRecord.mOnWindowsFocusChangedTime = System.currentTimeMillis();
        LjyLogUtil.i(getClass().getCanonicalName() + " onWindowFocusChanged cost "+(sActivityRecord.mOnWindowsFocusChangedTime - sActivityRecord.mOnCreateTime));
        Origin.callVoid();
    }

    public static class ActivityRecord {

        /**
         * 避免没有仅执行onResume就去统计界面打开速度的情况,如息屏、亮屏等等
         */
        public boolean isNewCreate;

        public long mOnCreateTime;
        public long mOnWindowsFocusChangedTime;
    }
}
复制代码

卡顿分析

  • 在客户端捕获卡顿之后,最后数据需要上传到后台统一分析
  • 卡顿率
    • 评估卡顿的影响面:UV 卡顿率 = 发生过卡顿 UV / 开启卡顿采集 UV,一个用户如果命中采集,那么在一天内都会持续的采集数据
    • 评估卡顿的严重度:PV 卡顿率 = 发生过卡顿 PV / 启动采集 PV,对于命中采集 PV 卡顿率的用户,每次启动都需要上报作为分母

课后作业

  1. 模仿ProcessCpuTracker.java拿到一段时间内各个线程的耗时占比
  2. 使用 PLTHook 技术来获取 Atrace 的日志
  3. 使用 PLTHook 技术来获取线程创建的堆栈

参考

我是今阳,如果想要进阶和了解更多的干货,欢迎关注公众号”今阳说“接收我的最新文章

文章分类
Android
文章标签