微信Android客户端的卡顿监控方案

https://mp.weixin.qq.com/s/3dubi2GVW_rVFZZztCpsKg

卡顿       UI线程不能够及时的进行渲染,导致UI的反馈不能按照用户的预期,连续、一致的呈现。

ANR       ANR是Google人为规定的概念,产生ANR的原因最多也只有四个。

二、Looper Printer

而大部分的主线程的操作最终都会执行到这个dispatchMessage方法中。

为什么说是大部分?因为有些情况的卡顿,这种方案从原理上就无法监控到。

下图是next方法简化过后的源码,frameworks/base/core/java/android/os/MessageQueue.java:


for (;;) {
    if (nextPollTimeoutMillis != 0) {
        Binder.flushPendingCommands();
    }

    nativePollOnce(ptr, nextPollTimeoutMillis);

    //......

    // Run the idle handlers.
    // We only ever reach this code block during the first iteration.
    for (int i = 0; i < pendingIdleHandlerCount; i++) {
        final IdleHandler idler = mPendingIdleHandlers[i];
        mPendingIdleHandlers[i] = null; // release the reference to the handler

        boolean keep = false;
        try {
            keep = idler.queueIdle();
        } catch (Throwable t) {
            Log.wtf(TAG, "IdleHandler threw exception", t);
        }

        if (!keep) {
            synchronized (this) {
                mIdleHandlers.remove(idler);
            }
        }
    }
    //......
}

如果排除主线程空闲的情况,究竟会是什么原因会卡在MessageQueuenext方法中呢?

1.除了主线程空闲时就是阻塞在nativePollOnce之外,非常重要的是,应用的Touch事件也是在这里被处理的。这就意味着,View的TouchEvent中的卡顿这种方案是无法监控的。然而,对于我们来说,微信中有大量的自定义View,这些View中充满了各种各样很多的onTouch回调,卡在这里面的情况非常普遍,这种情况的卡顿监控不到是很难接受的。

2.另外一种常见的情况是IdleHandlerqueueIdle()回调方法也是无法被监控的,这个方法会在主线程空闲的时候被调用。然而实际上,很多开发同学都先入为主的认为这个时候反正主线程空闲,做一些耗时操作也没所谓。其实主线程MessageQueue的queueIdle默认当然也是执行在主线程中,所以这里的耗时操作其实是很容易引起卡顿和ANR的。例如微信之前就使用IdleHandler在进入微信的主界面后,做一些读写文件的IO操作,就造成了一些卡顿和ANR问题。'

3.还有一类相对少见的问题是SyncBarrier(同步屏障)的泄漏同样无法被监控到,当我们每次通过invalidate来刷新UI时,最终都会调用到ViewRootImpl中的scheduleTraversals方法,会向主线程的Looper中post一个SyncBarrier,其目的是为了在刷新UI时,主线程的同步消息都被跳过,此时渲染UI的异步消息就可以得到优先处理。但是我们注意到这个方法是线程不安全的,如果在非主线程中调用到了这里,就有可能会同时post多个SyncBarrier,但只能remove掉最后一个,从而有一个SyncBarrier就永远无法被remove,就导致了主线程Looper无法处理同步消息(Message默认就是同步消息),导致卡死,参考源码frameworks/base/core/java/android/view/ViewRootImpl.java:


void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
}

void unscheduleTraversals() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
        mChoreographer.removeCallbacks(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
    }
}

3.1.  监控IdleHandler卡顿

我们惊喜的发现MessageQueue中的mIdleHandlers是可以被反射的,这个变量保存了所有将要执行的IdleHandler,我们只需要把ArrayList类型的mIdleHandlers,通过反射,替换为MyArrayList,在我们自定义的MyArrayList中重写add方法,再将我们自定义的MyIdleHandler添加到MyArrayList中,就完成了“偷天换日”。从此之后MessageQueue每次执行queueIdle回调方法,都会执行到我们的MyIdleHandler中的的queueIdle方法,就可以在这里监控queueIdle的执行时间了。

3.2. 监控TouchEvent卡顿

熟悉input系统的同学应该知道,Touch事件最终是通过server端的InputDispatcher线程传递给Client端的UI线程的,并且使用的是一对Socket进行通讯的。我们可以通过PLT Hook,去Hook这对Socket的send和recv方法来监控Touch事件啊!我们先捋一下一次Touch事件的处理过程:

微信Android客户端的卡顿监控方案

我们通过PLT Hook,成功hook到libinput.so中的recvfromsendto方法,使用我们自己的方法进行替换。当调用到了recvfrom时,说明我们的应用接收到了Touch事件,当调用到了sendto时,说明这个Touch事件已经被成功消费掉了,当两者的时间相差过大时即说明产生了一次Touch事件的卡顿。这种方案经过验证是可行的!

3.3.  监控SyncBarrier泄漏

最后,SyncBarrier泄漏的问题,有什么好办法能监控到吗?目前我们的方案是不断轮询主线程LooperMessageQueuemMessage(也就是主线程当前正在处理的Message)。而SyncBarrier本身也是一种特殊的Message,其特殊在它的target是null。如果我们通过反射mMessage,发现当前的Message的target为null,并且通过这个Message的when发现其已经存在很久了,这个时候我们合理怀疑产生了SyncBarrier的泄漏(但还不能完全确定,因为如果当时因为其他原因导致主线程卡死,也可能会导致这种现象),然后再发送一个同步消息和一个异步消息,如果异步消息被处理了,但是同步消息一直无法被处理,这时候就说明产生了SyncBarrier的泄漏。如果激进一些,这个时候我们甚至可以反射调用MessageQueueremoveSyncBarrier方法,手动把这个SyncBarrier移除掉,从而从错误状态中恢复。下面代码展示了大概的原理:

MessageQueue mainQueue = Looper.getMainLooper().getQueue();
Field field = mainQueue.getClass().getDeclaredField("mMessages");
field.setAccessible(true);
Message mMessage = (Message) field.get(mainQueue);  //通过反射得到当前正在等待执行的Message
if (mMessage != null) {
    currentMessageToString = mMessage.toString();
    long when = mMessage.getWhen() - SystemClock.uptimeMillis();
    if (when < -3000 && mMessage.getTarget() == null) { //target == null则为sync barrier
        int token = mMessage.arg1;
        startCheckLeaking(token);
    }
}

private static void startCheckLeaking(int token) {
    int checkCount = 0;
    barrierCount = 0;
    while (checkCount < CHECK_STRICTLY_MAX_COUNT) {
        checkCount++;
        int latestToken = getSyncBarrierToken();
        if (token != latestToken) {     //token变了,不是同一个barrier,return
            break;
        }
        if (DetectSyncBarrierOnce()) {
            //发生了sync barrier泄漏
            removeSyncBarrier(token);   //手动remove泄漏的sync barrier
            break;
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

private static void removeSyncBarrier(int token) {
    MessageQueue mainQueue = Looper.getMainLooper().getQueue();
    Method method = mainQueue.getClass().getDeclaredMethod("removeSyncBarrier", int.class);
    method.setAccessible(true);
    method.invoke(mainQueue, token);
}

private static boolean DetectSyncBarrierOnce() {
    Handler mainHandler = new Handler(Looper.getMainLooper()) {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg.arg1 == 0) {
                barrierCount ++;    //收到了异步消息,count++
            } else if (msg.arg1 == 1) {
                barrierCount = 0;   //收到了同步消息,说明同步屏障不在, count设置为0
            }
        }
    };

    Message asyncMessage = Message.obtain();
    asyncMessage.setAsynchronous(true);
    asyncMessage.setTarget(mainHandler);
    asyncMessage.arg1 = 0;

    Message syncNormalMessage = Message.obtain(); 
    syncNormalMessage.arg1 = 1;

    mainHandler.sendMessage(asyncMessage);      //发送一个异步消息
    mainHandler.sendMessage(syncNormalMessage); //发送一个同步消息

    if(barrierCount > 3){
        return true;
    }
    return false;
}

坏消息是,这种方案只能监控到问题的产生,也可以直接解决问题,但是无法溯源问题究竟是哪个View导致的。其实我们也尝试过,通过插桩或者Java hook的方法,监控invalidate方法是否在非主线程中进行,但是考虑到风险以及对性能影响都比较大,没有在线上使用。所幸,通过监控发现,这个问题对我们来说,发生的概率并不高。如果发现某个场景下该问题确实较为严重,可以考虑使用插桩或者Java hook在测试环境下debug该问题。

上一篇:2021Android面试总结!安卓面试基础技能罗列


下一篇:2021年Android网络编程总结篇,附赠课程 题库