[面试官,你坐好],今天我给你吹下卡顿监控

3,975 阅读8分钟

image.png

这是一篇面试总结稿,根据之前的面试过程以一种模拟面试的风格进行阐述。

面试官: 自我介绍下

诶?这面试官头发还比较多,应该不牛逼,心里踏实了不少。

我: 面试官你好,我叫酱爆大头菜,5年工作经验。曾经跟OPPO产品PK,跟VIVO技术Battle,练就了一身不要脸的乙方心态,平时有写博客的习惯,20来篇博客单篇阅读量3000+,我今天来面试高级Android开发工程师岗位,精通八门兵器,善于救死扶伤....(一顿吹...)

这是我博客地址不信可以点开看下:博客主页

面试官: 嗯,不错啊,我今天主要问下性能方面,你之前有没有做过性能优化?

 上来就直奔主题? 不循循渐进么?我准备的那一堆废话还没说呢。
 

image.png

.

我: 嗯....我做过功耗优化,比如耗电优化,流量优化。性能方面我做过卡顿优化,主要针对卡顿监控方面。

面试官: 那着重描述下卡顿方面吧,说下你是怎么做的。

废话先给怼上,怎么可能白准备。


image.png

我: 嗯... 卡顿其实分为直观的和微观两个方向的,举个例子

  • 比如用户点击了登录,预期是得到登录成功/失败的反馈,可现在没有页面刷新,实际的刷新耗时超出了预期,这就是直观的卡顿。

  • 比如用户在看股票,正常60秒刷新一次,可到了60秒,触发了刷新但是刷新处理逻辑耗时较长,导致新的数据在下一个60秒之前的第59秒才回来,用户基本无感知,主要说的是内部耗时的问题,这种情况可以粗略的理解为微观卡顿。

面试官: 你不用说概念,直接说为什么会产生卡顿就行了 这小子,居然.... 不不不,是这位面试官,居然发现了我在划水...

好的面试官,我整体吹一...,啊不,我整体说一下,说的不对您及时补充。手动呲牙

  • 首先,我认为卡顿产生的原因主要是某些逻辑处理时间超出了我们的预期阈值,这些耗时操作影响了页面的刷新,当刷新的频率和速度(60帧/s)低于我们的预期时,就会产生我们理解的卡顿。

  • 其次,如果主线程耗时过长还可能产生ANR,当然ANR在不同场景下的耗时阈值不同。

  • 最后,当然也有很多现实因素会影响用户体验,比如手机的配置,所处环境的网络质量等...

面试官: 嗯,那你怎么做到让App不卡顿? 我靠,这个问题是个多元化问题啊,我一想,这一时半会也说不完啊。 干脆把问题聚焦到"卡顿监控上",于是整理下发型,说了一句"你刚问的啥? 手动呲牙..." . . . 当然咱也不是那不靠谱的人,怎么会干那不靠谱的事呢?对吧.

我认为做到App不卡顿主要分为以下几种情况

  • 1.如果App整体都是自己开发,那自己写逻辑尽量保持简洁合规,主线程尽可能不做耗时操作,UI绘制层级尽量保持扁平,复杂数据处理尽量前置,或缓存等等.

  • 2.如果项目多人合作开发,这个问题会演进为怎么治理这些卡顿。

  • 3.如果需要治理卡顿,首先需要发现这些卡顿,也就变成了你怎么发现它卡了?

面试官在我之前的项目中我主要负责卡顿监控操作,这为我们多模块的卡顿治理提供了大量的卡顿素材,使得我们能及时发现问题,解决问题。(引导面试官,往自己擅长的领域跟进)

面试官: 行,同学说的不错啊,你主要负责卡顿监控是吧,那说说你怎么监控的吧?

我一猜这小子就会这么问,怎么可能会有面试官不接收挑战的。

他不给你问到你答不上来的那他肯定是第一次面试。

这个时候就要 分阶段,分成果 的去阐述这个卡顿监控的方案选型,实现落地以及优化过程了。

这个过程描述的越透彻,一般面试官对你的能力就越认可。

所以这个时候,我一看面试官来劲了。

反手给他一句,面试官我上个厕所,你坐好,别着急...(说啥到厕所也得把红内裤换上,再看看发型...)

我:

起因: 我们做这个卡顿监控最初是因为收到了部分客诉,反馈某几个功能块操作不流畅,掉帧卡顿,尤其体现在中低端机上,客诉70%都是中低端机。

方案:

最开始,并没有特别好的方案,因此我们先后调研了两个方案

方案一:参考了ANR-WatchDog机制

ANR-WatchDog机制原理不复杂,它内部启动了一个子线程,定时通过主线程Handler发送Message,然后定时去检查Message的处理结果。

通俗来说就是利用了Android系统MessageQueue队列的排队处理特性。

通过主线程Handler发送消息到MessageQueue队列,5秒去看下这个Message有没有被消费,如果消费了则代表没有卡顿,如果没有,则代表有卡顿,当然这个5秒是可调节的。

面试官,你要不懂Handler机制,可以去看下我这篇博客

面试官还问Handler?那我要给你讲个故事

这种监控非常简单,接入成本较低,但是有弊端,比如

  • 1.它会漏报情况,举个例子,比如我们以5秒未响应作为卡顿阈值,如果我们发送监听Message的时间在上一个消息处理的第2-5秒之间,那这种就会产生漏报。

  • 2.监听间隔越小,系统开销越大。

  • 3.即便监听到了,不好区分卡顿链路,无法提供准确的卡顿堆栈。

方案二:参考BlockCanary开源库,利用Looper.loop() Printer打印机制

每次Message处理(也就是dispatchMessage(msg))都会在处理前,和处理后通过Looper.mLogging打印日志。

我们只需知道打印日志的时间差即可知道Message的处理耗时。当耗时超过我们的阈值时我们即可收集调用堆栈,然后根据堆栈进行针对性优化即可。

这种方案也有弊端,比如

  • 1.View的TouchEvent事件是在queue.next()中处理的,只统计dispatchMessage(msg)前后耗时,不会覆盖到View的TouchEvent耗时。

  • 2.IdleHandler.queueIdle()也在queue.next()中,当主线程空闲会调用IdleHandler,此时IdleHandler也是在主线程执行,当过于耗时时也可能出现卡顿,甚至到只ANR,这种场景也无法监控。

  • 3.SyncBarrier(同步屏障)的泄漏同样无法被监控到,这种情况比较少见,参考了微信的监控方案。

比如

/**
     * Run the message queue in this thread. Be sure to call
     * {@link #quit()} to end the loop.
     */
    public static void loop() {
        final Looper me = myLooper();
        if (me == null) {
            throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
        }
        final MessageQueue queue = me.mQueue;

        // Make sure the identity of this thread is that of the local process,
        // and keep track of what that identity token actually is.
        Binder.clearCallingIdentity();
        final long ident = Binder.clearCallingIdentity();

        // Allow overriding a threshold with a system prop. e.g.
        // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
        final int thresholdOverride =
                SystemProperties.getInt("log.looper."
                        + Process.myUid() + "."
                        + Thread.currentThread().getName()
                        + ".slow", 0);

        boolean slowDeliveryDetected = false;

        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }

            // This must be in a local variable, in case a UI event sets the logger
            final Printer logging = me.mLogging;
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }
            
            ...

            try {
                msg.target.dispatchMessage(msg);
                if (observer != null) {
                    observer.messageDispatched(token, msg);
                }
                dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
            }

            ...

            if (logging != null) {
                logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
            }
        }
     }

每次Message处理(也就是dispatchMessage(msg))都会在处理前,和处理后通过Looper.mLogging打印日志.

我们只需知道打印日志的时间差即可知道Message的处理耗时。当耗时超过我们的阈值的时候就是发生卡顿的时机,我们触发收集调用堆栈,然后根据堆栈进行针对性优化即可。

替换Printer可以通过Looper提供的方法,然后自定义Printer进行计算统计即可。

	/**
     * Control logging of messages as they are processed by this Looper.  If
     * enabled, a log message will be written to <var>printer</var>
     * at the beginning and ending of each message dispatch, identifying the
     * target Handler and message contents.
     *
     * @param printer A Printer object that will receive log messages, or
     * null to disable message logging.
     */
    public void setMessageLogging(@Nullable Printer printer) {
        mLogging = printer;
    }

@Override
public void println(String x) {
        if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
            return;
        }
        if (!mPrintingStarted) {
            //1、记录第一次执行时间,mStartTimestamp
            mStartTimestamp = System.currentTimeMillis();
            mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
            mPrintingStarted = true;
            startDump(); //2、开始dump堆栈信息
        } else {
            //3、第二次就进来这里了,调用isBlock 判断是否卡顿
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            if (isBlock(endTime)) {
                notifyBlockEvent(endTime);
            }
            stopDump(); //4、结束dump堆栈信息
        }
    }
    
    //判断是否卡顿的代码很简单,跟上次处理消息时间比较,比如大于3秒,就认为卡顿了
     private boolean isBlock(long endTime) {
        return endTime - mStartTimestamp > mBlockThresholdMillis;
    }

//获取堆栈数据

protected void doSample() {
        StringBuilder stringBuilder = new StringBuilder();
        // 获取堆栈信息
        for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) {
            stringBuilder
                    .append(stackTraceElement.toString())
                    .append(BlockInfo.SEPARATOR);
        }

        synchronized (sStackMap) {
            // LinkedHashMap中数据超过100个就remove掉链表最前面的
            if (sStackMap.size() == mMaxEntryCount && mMaxEntryCount > 0) {
                sStackMap.remove(sStackMap.keySet().iterator().next());
            }
            //放入LinkedHashMap,时间作为key,value是堆栈信息
            sStackMap.put(System.currentTimeMillis(), stringBuilder.toString());
        }
    }

以上两种方案,已经能解决大部分问题,面试官,我吹完了

面试官: 嗯,看来真做过卡顿监控,那你说的这两种方案都有弊端,你怎么监控TouchEvent和IdelHander的卡顿呢?

我: 啊.... 天不早了,我得回家吃饭了,咱们下次再细聊...

image.png