BlockCanary 卡顿监测

492 阅读5分钟

81D4477D-5B3A-49be-8786-527946E93412.png

前言

最近在处理项目中的拍摄视频后上传界面卡顿的问题,找到 BlockCanary 这个工具来定位,由于不支持高版本 Android,当时在定位卡顿时先将项目的 targetSdk 版本降下来,当然这不是个长久的办法,打算花一点时间适配下高版本,先过一遍源码流程

网上很多博客只提到适配分区存储和通知栏,好像忽略了一个细节,CPU 的采样"proc" 在高版本 Android 被禁用,原因是系统防止旁路攻击,只允许系统应用访问 ,所以这个也需要寻找替换方案,可以看这篇博客 Android 高版本采集系统CPU使用率的方式 ,单独开了一篇来看 CPU 采样部分 分析 BlockCanary CPU 采样

具体的原理,库作者有些一篇博客来介绍,放到的相关链接部分

整体流程图,来自 BlockCanary 作者

初始化

BlockCanary 跟随 App 启动,内部的 BlockCanaryInternal 有添加拦截器的操作,这里应该是控制的核心逻辑

install () 大概做了这些:

  1. BlockCanaryContext 实现了 BlockInterceptor 接口 进行基础设置,比如文件夹名,判断时间等,用于给开发者自定义的
  2. 启动组件,显示图标到桌面

BlockCanary 构造方法中做了:

  1. 创建 BlockCanaryInternal
  2. 添加拦截器到 BlockCanaryInternal

这里主要是为子线程监测做初始化

启动

    public void start() {
        // 往主线程的 looper 里设置 printer
        Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
    }

这里就是 BlockCanary 检测的位置,原理是 Looper.loop() 中会在 Looper.dispatchMessage() 执行前后做打印,刚好可以利用这个做执行时长的处理,通过判断是否超过时间,来判断是否发生了卡顿

检测执行时长

检测时长的逻辑位于 LooperMonitor,它实现了 Printer 接口

    @Override
    public void println(String x) {

        // 执行前
        if (!mPrintingStarted) {
            // 获取当前时间
            mStartTimestamp = System.currentTimeMillis();
            mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
            mPrintingStarted = true;
            startDump();
        } else {
            // 执行后
            // 获取当前时间
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            // 计算是否卡顿,如果发生了则通知
            if (isBlock(endTime)) {
                notifyBlockEvent(endTime);
            }
            stopDump();
        }
    }


    // 根据时间差来判断是否卡顿
    private boolean isBlock(long endTime) {
        return endTime - mStartTimestamp > mBlockThresholdMillis;
    }

先来思考下,如果卡顿已经发生了,我们想要获取哪些信息来定位问题:

  1. 哪个位置发生了卡顿,我觉得这是最最重要的
  2. 发生卡顿的原因,是内存不够导致的,其他地方导致的,这对定位问题比较重要

那这些信息应该是在卡顿后,再去获取吗,还能拿到现场信息嘛? 带着这些问题,来看看 BlockCanary 是怎么做的

来看看 startDump() 中做了什么

    private void startDump() {
       // 分别调用了 StackSampler/CpuSampler 的 start()   
       BlockCanaryInternals.getInstance().stackSampler.start()
       BlockCanaryInternals.getInstance().cpuSampler.start();
    }

这两个 start 都是在子线程中执行的,原因是基类内部有个 HandlerThread ,在这个子线程执行方法 doSample()

获取当前执行的内存堆栈的逻辑就在这里,也是定位卡顿位置的关键

    // StackSampler
    @Override
    protected void doSample() {
        
        StringBuilder stringBuilder = new StringBuilder();
        // 遍历当前线程(主线程)的所有堆栈,放到 String 里
        for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) {
            stringBuilder
                    .append(stackTraceElement.toString())
                    .append(BlockInfo.SEPARATOR);
        }

        synchronized (sStackMap) {
            if (sStackMap.size() == mMaxEntryCount && mMaxEntryCount > 0) {
                sStackMap.remove(sStackMap.keySet().iterator().next());
            }
            // 保存到 Map 中,最多保存 100 个
            sStackMap.put(System.currentTimeMillis(), stringBuilder.toString());
        }
    }


   // CpuSampler
   
    protected void doSample() {
        BufferedReader cpuReader = null;
        BufferedReader pidReader = null;
            // 通过 /proc/stat 读取 cpu 参数,这个 Android 高版本中已经被禁用了
            cpuReader = new BufferedReader(new InputStreamReader(
                    new FileInputStream("/proc/stat")), BUFFER_SIZE);
            String cpuRate = cpuReader.readLine();
           
            if (mPid == 0) {
                mPid = android.os.Process.myPid();
            }
            pidReader = new BufferedReader(new InputStreamReader(
                    new FileInputStream("/proc/" + mPid + "/stat")), BUFFER_SIZE);
            String pidCpuRate = pidReader.readLine();
            if (pidCpuRate == null) {
                pidCpuRate = "";
            }

            parse(cpuRate, pidCpuRate);
        
    }

继续来看是怎样通知的,都通知了谁

    private void notifyBlockEvent(final long endTime) {
        // 触发 onBlockEvent,在子线程中执行
        HandlerThreadFactory.getWriteLogThreadHandler().post(new Runnable() {
            @Override
            public void run() {
                mBlockListener.onBlockEvent(startTime, endTime, startThreadTime, endThreadTime);
            }
        });
    }
        @Override
        public void onBlockEvent(long realTimeStart, long realTimeEnd,long threadTimeStart, long threadTimeEnd) {
            // Get recent thread-stack entries and cpu usage
            // 获取之前在内存中保存的堆栈
            ArrayList<String> threadStackEntries = stackSampler
            .getThreadStackEntries(realTimeStart, realTimeEnd);
              if (!threadStackEntries.isEmpty()) {
                // 组合一个阻塞信息的对象
                    BlockInfo blockInfo = BlockInfo.newInstance()
                        .setMainThreadTimeCost(realTimeStart, realTimeEnd, threadTimeStart, threadTimeEnd)
                        .setCpuBusyFlag(cpuSampler.isCpuBusy(realTimeStart, realTimeEnd))
                        .setRecentCpuRate(cpuSampler.getCpuRateInfo())
                        .setThreadStackEntries(threadStackEntries)
                        .flushString();
                    // 写到硬盘中去  
                    LogWriter.save(blockInfo.toString());
                    // 如果拦截器里还有,就依次去执行,就是责任揽模式的一种实现
                    if (mInterceptorChain.size() != 0) {
                        for (BlockInterceptor interceptor : mInterceptorChain) {
                       //在 BlockCanary 的构造函数里,添加了拦截器到队列中,主要是来打开 DisplayActivity,像开发者展示卡顿信息 interceptor.onBlock(getContext().provideContext(), blockInfo);
                        }
                    }
                }
            }

小结

BlockCanary 核心是通过 Looper 中分发 Message 前后会执行的打印,在这个判断执行时长是否过长,如果判断为阻塞,就马上将执行前就开始收集的程序堆栈/CPU 内存信息在一个页面中展示出来,这里的收集都是在子线程中进行的

既然在高版本上 “/proc/stat” 已经不能用了,我们能不能做个版本判断,在高版本上不去其获取 CPU 信息了呢?

这样还是不太好,如果没有 CPU 使用频率这些信息,我们判断卡顿时就没法排查是 CPU 跑满了,分不到足够的时间片

高版本获取 CPU 使用率

不过“/proc/stat”由于在 API 26 以上,只有系统应用才能使用,这也让 BlockCanary 的 CPU 监测部分在高版本上不可使用了

原因是会被利用来对系统旁路攻击,Android 禁止了非系统应用的访问

思考

  1. 除了使用 Printer , Android 10 以上支持 Looper Observer,不过由于是 Hidden API,需要绕过限制
  2. Printer 是 Looper 中的成员变量,会不会存在被替换的风险,如果有其他库也使用了 Printer 会导致无法开始采样吧,个人疑问,有不同看法欢迎讨论

相关知识

  • StackTrace 当前线程的堆栈信息,当时一个方法执行时,会对应创建一个栈帧,通过 StackTraceElement ,可以获取如下数据:
  1. 声明的类名
  2. 方法名
  3. 行号
  4. 文件名(不知道是什么)
  • 组件开关 通过 PackageManager.setComponentEnabledSetting,能够对组件的开关进行控制,BlockCanary 在 Launcher 的显示和隐藏就是通过这个设置的,关闭之后 DisplayActivity 的桌面图片也不再显示了

相关链接部分