什么是卡顿,很多人能马上联系到的是帧率 FPS (每秒显示帧数)。那么多低的 FPS 才是卡顿呢?又或者低 FPS 真的就是卡顿吗?(以下 FPS 默认指平均帧率)

其实并非如此,举个例子,游戏玩家通常追求更流畅的游戏画面体验一般要达到 60FPS 以上,但我们平时看到的大部分电影或视频 FPS 其实不高,一般只有 25FPS ~ 30FPS,而实际上我们也没有觉得卡顿。 在人眼结构上看,当一组动作在 1 秒内有 12 次变化(即 12FPS),我们会认为这组动作是连贯的;而当大于 60FPS 时,人眼很难区分出来明显的变化,所以 60FPS 也一直作为业界衡量一个界面流畅程度的重要指标。一个稳定在 30FPS 的动画,我们不会认为是卡顿的,但一旦 FPS 很不稳定,人眼往往容易感知到。

FPS 低并不意味着卡顿发生,而卡顿发生 FPS 一定不高。 FPS 可以衡量一个界面的流程性,但往往不能很直观的衡量卡顿的发生,这里有另一个指标(掉帧程度)可以更直观地衡量卡顿。

什么是掉帧(跳帧)? 按照理想帧率 60FPS 这个指标,计算出平均每一帧的准备时间有 1000ms/60 = 16.6667ms,如果一帧的准备时间超出这个值,则认为发生掉帧,超出的时间越长,掉帧程度越严重。假设每帧准备时间约 32ms,每次只掉一帧,那么 1 秒内实际只刷新 30 帧,即平均帧率只有 30FPS,但这时往往不会觉得是卡顿。反而如果出现某次严重掉帧(>300ms),那么这一次的变化,通常很容易感知到。所以界面的掉帧程度,往往可以更直观的反映出卡顿。

造成 掉帧 的直接原因通常是 doFrame 过于繁重执行超时,或者其他任务挤压了 doFrame 的执行时间,这些都会造成主线程不能在 帧间隔时间 内完成 ui 绘制

public static void Looper.loop() {
    // ...
    for (;;) {
        // ...
        final 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);
        }
        // ...
    }
}

计算 Message 耗时

我们知道主线程是「生产者 - 消费者」模型,任务(Message)都在消息队列(MessageQueue)里排队等待执行,如果能够度量出每个 Message 的耗时,然后与某个阈值进行比较,我们就能找出耗时任务做进一步的优化

从下面的代码可以看到,Message 执行前后都会有特定格式的日志输出,只要捕获这些日志,就能计算出每个 Message 的耗时

LooperMonitor 通过替换 Looper.mLogging,从而捕获到 >>>>> Dispatching to<<<<< Finished to 的日志输出进而算出 Message 耗时,并提供 dispatchStart 和 dispatchEnd 两个钩子

public class LooperMonitor {
    // 通过反射替换 Looper.mLogging
    private synchronized void resetPrinter() {
        Printer originPrinter = null;
        try {
            if (!isReflectLoggingError) {
                originPrinter = ReflectUtils.get(looper.getClass(), "mLogging", looper);
                if (originPrinter == printer && null != printer) {
                    return;
                }
            }
        } catch (Exception e) {
            isReflectLoggingError = true;
            Log.e(TAG, "[resetPrinter] %s", e);
        }
        if (null != printer) {
            MatrixLog.w(TAG, "maybe thread:%s printer[%s] was replace other[%s]!",
                    looper.getThread().getName(), printer, originPrinter);
        }
        looper.setMessageLogging(printer = new LooperPrinter(originPrinter));
        if (null != originPrinter) {
            MatrixLog.i(TAG, "reset printer, originPrinter[%s] in %s", originPrinter, looper.getThread().getName());
        }
    }

    // 捕获根据特定格式的日志输出
    class LooperPrinter implements Printer {
        public void println(String x) {
            if (null != origin) {
                origin.println(x);
                if (origin == this) {
                    throw new RuntimeException(TAG + " origin == this");
                }
            }
            if (!isHasChecked) {
                isValid = x.charAt(0) == '>' || x.charAt(0) == '<';
                isHasChecked = true;
                if (!isValid) {
                    MatrixLog.e(TAG, "[println] Printer is inValid! x:%s", x);
                }
            }
            if (isValid) {
                dispatch(x.charAt(0) == '>', x);
            }
        }
    }

    // 分发 Dispatching to 和 Finished to 事件
    private void dispatch(boolean isBegin, String log) {
        for (LooperDispatchListener listener : listeners) {
            if (listener.isValid()) {
                if (isBegin) {
                    if (!listener.isHasDispatchStart) {
                        listener.onDispatchStart(log);
                    }
                } else {
                    if (listener.isHasDispatchStart) {
                        listener.onDispatchEnd(log);
                    }
                }
            } else if (!isBegin && listener.isHasDispatchStart) {
                listener.dispatchEnd();
            }
        }
    }
}

public boolean LooperMonitor.queueIdle() {
    if (SystemClock.uptimeMillis() - lastCheckPrinterTime >= CHECK_TIME) {
        resetPrinter();
        lastCheckPrinterTime = SystemClock.uptimeMillis();
    }
    return true;
}

为了防止其他代码也替换 Looper.mLogging,LooperMonitor 还在主线程空闲的时候进行检查

public class UIThreadMonitor {
    // 最开始先在 INPUT 头部插入钩子
    public synchronized void UIThreadMonitor.onStart() {
        // ...
        queueStatus = new int[CALLBACK_LAST + 1];
        queueCost = new long[CALLBACK_LAST + 1];
        addFrameCallback(CALLBACK_INPUT, this, true);
    }

    // 依次往 ANIMATION/TRAVERSAL 插入钩子
    public void run() {
        final long start = System.nanoTime();
        try {
            doFrameBegin(token);
            doQueueBegin(CALLBACK_INPUT);
            addFrameCallback(CALLBACK_ANIMATION, new Runnable() {
                @Override
                public void run() {
                    doQueueEnd(CALLBACK_INPUT);
                    doQueueBegin(CALLBACK_ANIMATION);
                }
            }, true);
            addFrameCallback(CALLBACK_TRAVERSAL, new Runnable() {
                @Override
                public void run() {
                    doQueueEnd(CALLBACK_ANIMATION);
                    doQueueBegin(CALLBACK_TRAVERSAL);
                }
            }, true);
        } finally {
            if (config.isDevEnv()) {
                MatrixLog.d(TAG, "[UIThreadMonitor#run] inner cost:%sns", System.nanoTime() - start);
            }
        }
    }

    // 主线程消息队列的任务结束钩子,也作为 TRAVERSAL 的结束标志
    private void dispatchEnd() {
        // ...
        long startNs = token;
        long intendedFrameTimeNs = startNs;
        if (isVsyncFrame) {
            doFrameEnd(token);
            intendedFrameTimeNs = getIntendedFrameTimeNs(startNs);  // FrameDisplayEventReceiver.mTimestampNanos
        }
        // ...
    }

    private void doFrameEnd(long token) {
        doQueueEnd(CALLBACK_TRAVERSAL);
        // ...
        addFrameCallback(CALLBACK_INPUT, this, true);   // 继续放入下一轮 doFrame 的钩子
    }
}

计算 doFrame 耗时

上面的方法可以统计主线程里每个 Message 的耗时,但并不是每个 Message 都在做 ui 绘制(doFrame),比如违规放入耗时的 IO 操作;为了更精确地捕获耗时的 doFrame,以及进行 FPS 统计,还需做更多的工作

参考 Vsync 这篇文章 我们知道,当 APP_VSYNC 到达时会走 FrameDisplayEventReceiver.onVsync -> Choreographer.doFrame -> Choreographer.doCallbacks,最后按 INPUT - ANIMATION - TRAVERSAL 的顺序执行 Choreographer.mCallbackQueues 里的 Runnable

动态地往这三个队列的头部插入钩子,从而计算出每个阶段开始时间和结束时间,加上捕获 Message 的方法(Choreographer.doFrame 是由 FrameDisplayEventReceiver.onVsync 放入主线程任务队列的 Message),最终形成完整的闭环:

dispatchBegin -> doFrameBegin -> doQueueBegin(INPUT) -> doQueueEnd(INPUT) -> doQueueBegin(ANIMATION) -> doQueueEnd(ANIMATION) -> doQueueBegin(TRAVERSAL) -> doQueueEnd(TRAVERSAL) -> doFrameEnd -> dispatchEnd

LooperObserver,结合上面捕获到的信息,提供一个「观察者」

public abstract class LooperObserver {

    private boolean isDispatchBegin = false;

    /**
     * Message 开始执行
     * @param beginNs       开始时间(System.nanoTime)
     * @param cpuBeginNs    线程的开始时间(SystemClock.currentThreadTimeMillis)
     * @param token         等于 beginNs
     */
    @CallSuper
    public void dispatchBegin(long beginNs, long cpuBeginNs, long token) {
        isDispatchBegin = true;
    }

    /**
     * Message 耗时统计
     * @param focusedActivity       当前页面(Activity)
     * @param startNs               Message 的开始时间(等于 beginNs)
     * @param endNs                 Message 的结束时间(等于 endNs)
     * @param isVsyncFrame          是否 ui 绘制任务(doFrame)
     * @param intendedFrameTimeNs   收到 APP_VSYNC 信号的时间(FrameDisplayEventReceiver.onVsync)
     * @param inputCostNs           doFrame INPUT 阶段耗时
     * @param animationCostNs       doFrame ANIMATION 阶段耗时
     * @param traversalCostNs       doFrame TRAVERSAL 阶段耗时
     */
    public void doFrame(String focusedActivity, long startNs, long endNs, boolean isVsyncFrame, long intendedFrameTimeNs, long inputCostNs, long animationCostNs, long traversalCostNs) {

    }

    /**
     * Message 结束执行
     * @param beginNs       开始时间
     * @param cpuBeginMs    线程的开始时间
     * @param endNs         结束时间
     * @param cpuEndMs      结束的线程时间
     * @param token         等于 beginNs
     * @param isVsyncFrame  此任务是否 ui 绘制任务(doFrame)
     */
    @CallSuper
    public void dispatchEnd(long beginNs, long cpuBeginMs, long endNs, long cpuEndMs, long token, boolean isVsyncFrame) {
        isDispatchBegin = false;
    }

    public boolean isDispatchBegin() {
        return isDispatchBegin;
    }
}

fps_board.jpg

计算 FPS

我们将掉帧数划分出几个区间进行定级,掉帧数小于 3 帧的情况属于最佳,依次类推,见下表:

Best Normal Middle High Frozen
[0:3) [3:9) [9:24) [24:42) [42:∞)

相比单看平均帧率,掉帧程度的分布可以明显的看出,界面卡顿(平均帧率低)的原因是因为连续轻微的掉帧,还是某次严重掉帧造成的。再通过 Activity 区分不同场景,计算每个界面在有效绘制的时间片内,掉帧程度的分布情况及平均帧率,从而来评估出一个界面的整体流畅程度。

也就是说,一次掉帧可能掉了一帧、两帧或者更多帧(掉帧数),这里根据上表把一次掉帧划分为 Best/Normal/Middle/High/Frozen 五个级别,后续评价掉帧时就不再关注掉帧数而是 掉帧次数 及其严重级别

下图绿色的 62.00 FPS 指的是过去 200ms 内的平均帧率

灰色的 sum: 3.0 是总掉帧次数,下面的彩虹从左到右分别代表 Normal/Middle/High/Frozen 这四个级别的掉帧占总掉帧的比例,往下是当前页面的掉帧数和掉帧比例

最底下的图表是过去 10s 内平均帧率(200ms 时间段)的横向柱状图,每 5s 就会有 25 条记录,50 FPS 差不多是 Normal 的帧率下限,30 FPS 差不多是 Middle 的帧率下限

// 每帧的时间间隔,默认取 16ms(60 FPS)
public class UIThreadMonitor {
    private long frameIntervalNanos = 16666666;

    public void init(TraceConfig config) {
        frameIntervalNanos = ReflectUtils.reflectObject(choreographer, "mFrameIntervalNanos", Constants.DEFAULT_FRAME_DURATION);
        // ...
    }
}

public class FrameTracer {
    private final long frameIntervalNs;     // UIThreadMonitor.getMonitor().getFrameIntervalNanos()

    private void notifyListener(final String focusedActivity, final long startNs, final long endNs, final boolean isVsyncFrame,
                                final long intendedFrameTimeNs, final long inputCostNs, final long animationCostNs, final long traversalCostNs) {
        long traceBegin = System.currentTimeMillis();
        try {
            final long jiter = endNs - intendedFrameTimeNs;         // 从收到 vsync 到完成 doFrame 的时间,也就是实际渲染一帧的耗时
            final int dropFrame = (int) (jiter / frameIntervalNs);  // 计算出渲染这一帧对比理论 FPS 有没掉帧,掉了多少帧
            // ...
        }
    }
}

public class FrameDecorator {
    public void doFrameAsync(String focusedActivity, long startNs, long endNs, int dropFrame, boolean isVsyncFrame, long intendedFrameTimeNs, long inputCostNs, long animationCostNs, long traversalCostNs) {
        super.doFrameAsync(focusedActivity, startNs, endNs, dropFrame, isVsyncFrame, intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);

        if (!Objects.equals(focusedActivity, lastVisibleScene)) {        // 切换页面时,重置页面的统计数据
            dropLevel = new int[FrameTracer.DropStatus.values().length];
            lastVisibleScene = focusedActivity;
            lastCost[0] = 0;
            lastFrames[0] = 0;
        }

        // 为什么不是用 endNs ?
        // 1,因为 doFrame 执行完还要等 surfacefling 在下一帧的时间点进行合成和显示,而不是 doFrame 后立即显示,所以要用 frameIntervalMs 的倍数
        // 2,其次考虑用户无操作/页面静止的情况,主线程的任务队列为空,没有刷新页面,如果用设备时间 endNs 会导致计算出极低的错误 FPS
        sumFrameCost += (dropFrame + 1) * frameIntervalMs;
        sumFrames += 1;
        float duration = sumFrameCost - lastCost[0];            // 距离上一次刷新 FPS 的时间间隔

        if (dropFrame >= Constants.DEFAULT_DROPPED_FROZEN) {    // 根据掉帧数,给发生的掉帧事故分级别统计
            dropLevel[FrameTracer.DropStatus.DROPPED_FROZEN.index]++;
            sumDropLevel[FrameTracer.DropStatus.DROPPED_FROZEN.index]++;
            belongColor = frozenColor;
        } else if (dropFrame >= Constants.DEFAULT_DROPPED_HIGH) {
            dropLevel[FrameTracer.DropStatus.DROPPED_HIGH.index]++;
            sumDropLevel[FrameTracer.DropStatus.DROPPED_HIGH.index]++;
            if (belongColor != frozenColor) {
                belongColor = highColor;
            }
        } // ...

        long collectFrame = sumFrames - lastFrames[0];                              // 200ms 内刷新了几帧
        if (duration >= 200) {                                                      // 每隔 200ms 刷新一次 FPS
            final float fps = Math.min(maxFps, 1000.f * collectFrame / duration);   // 统计过去 200ms 的 FPS
            updateView(view, fps, belongColor, dropLevel[FrameTracer.DropStatus.DROPPED_NORMAL.index], dropLevel[FrameTracer.DropStatus.DROPPED_MIDDLE.index] ...);
            lastCost[0] = sumFrameCost;
            lastFrames[0] = sumFrames;
            // ...
        }
    }
}

public class EvilMethodTracer {
    public void dispatchEnd(long beginNs, long cpuBeginMs, long endNs, long cpuEndMs, long token, boolean isVsyncFrame) {
        long dispatchCost = (endNs - beginNs) / Constants.TIME_MILLIS_TO_NANO;
        try {
            if (dispatchCost >= evilThresholdMs) {
                // ...
            }
        }
    }
}

统计耗时 Message

TraceCanary 把耗时超过 700ms 的 Message 作为 Evil Method 上报

无埋点插桩收集函数耗时

为了在捕捉到卡顿堆栈后,获取各个函数的执行耗时,需要对所有函数进行无埋点插桩,在函数执行前调用 MethodBeat.i,在函数执行后调用 MethodBeat.o

通过代理编译期间的任务 transformClassesWithDexTask,将全局 class 文件作为输入,利用 ASM 工具,高效地对所有 class 文件进行扫描及插桩

插桩过程有几个关键点:

  1. 选择在该编译任务执行时插桩,是因为 proguard 操作是在该任务之前就完成的,意味着插桩时的 class 文件已经被混淆过的。而选择 proguard 之后去插桩,是因为如果提前插桩会造成部分方法不符合内联规则,没法在 proguard 时进行优化,最终导致程序方法数无法减少,从而引发方法数过大问题
  2. 为了减少插桩量及性能损耗,通过遍历 class 方法指令集,判断扫描的函数是否只含有 PUT/READ FIELD 等简单的指令,来过滤一些默认或匿名构造函数,以及 get/set 等简单不耗时函数
  3. 针对界面启动耗时,因为要统计从 Activity.onCreateActivity.onWindowFocusChange 间的耗时,所以在插桩过程中需要收集应用内所有 Activity 的实现类,并覆盖 onWindowFocusChange 函数进行打点
  4. 为了方便及高效记录函数执行过程,我们为每个插桩的函数分配一个独立 ID,在插桩过程中,记录插桩的函数签名及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。

归纳起来,编译期所做的工作如下图:

build.png

Gradle Transform

ignoreMethodMapFilePath 上面说过为了减少插桩量及性能损耗会忽略一些函数,这些被忽略的函数记录在此文件里(默认放在 /app/build/outputs/mapping/{var}/ignoreMethodMapping.txt),大概长这样:

ignore methods:
android.arch.core.executor.ArchTaskExecutor <clinit> ()V
android.arch.core.executor.ArchTaskExecutor <init> ()V
android.arch.core.executor.ArchTaskExecutor$1 execute (Ljava.lang.Runnable;)V
android.arch.core.executor.DefaultTaskExecutor executeOnDiskIO (Ljava.lang.Runnable;)V
android.arch.core.internal.FastSafeIterableMap <init> ()V
android.arch.core.internal.FastSafeIterableMap ceil (Ljava.lang.Object;)Ljava.util.Map$Entry;
android.arch.core.internal.SafeIterableMap size ()I
android.arch.core.internal.SafeIterableMap equals (Ljava.lang.Object;)Z
android.arch.core.internal.SafeIterableMap$AscendingIterator backward (Landroid.arch.core.internal.SafeIterableMap$Entry;)Landroid.arch.core.internal.SafeIterableMap$Entry;
android.arch.core.internal.SafeIterableMap$AscendingIterator forward (Landroid.arch.core.internal.SafeIterableMap$Entry;)Landroid.arch.core.internal.SafeIterableMap$Entry;
android.arch.core.internal.SafeIterableMap$AscendingIterator <init> (Landroid.arch.core.internal.SafeIterableMap$Entry;Landroid.arch.core.internal.SafeIterableMap$Entry;)V

methodMapFilePath 函数签名和函数 ID 的映射(默认放在 /app/build/outputs/mapping/{var}/methodMapping.txt),大概长这样:

1,1,sample.tencent.matrix.MainActivity$6 run ()V
2,10,sample.tencent.matrix.MatrixApplication initSQLiteLintConfig ()Lcom.tencent.sqlitelint.config.SQLiteLintConfig;
3,1,sample.tencent.matrix.MainActivity$5 onClick (Landroid.view.View;)V
4,1,sample.tencent.matrix.MainActivity$4 onClick (Landroid.view.View;)V
5,4,sample.tencent.matrix.MainActivity onResume ()V
6,1,sample.tencent.matrix.MatrixApplication onCreate ()V
7,1,sample.tencent.matrix.MainActivity$3 onClick (Landroid.view.View;)V
8,4,sample.tencent.matrix.MainActivity onCreate (Landroid.os.Bundle;)V
9,1,sample.tencent.matrix.MainActivity$2 onClick (Landroid.view.View;)V
10,1,org.apache.commons.io.comparator.DirectoryFileComparator compare (Ljava.io.File;Ljava.io.File;)I