面试官:说说卡顿问题

上一篇文章面试官:说说多线程并发问题
阅读量和点赞数超出我的想象,这周带来这个系列第二篇。

故事开始

面试官:平时开发中有遇到卡顿问题吗?你一般是如何处理的?
来面试的小伙:额...没有遇到过卡顿问题,我平时写的代码质量比较高,不会出现卡顿。
面试官:...


上面对话像是开玩笑,但是前段时间真的遇到一个来面试的小伙这样答,问他有没有遇到过卡顿问题,一般怎么处理的?他说没遇到过,说他写的代码不会出现卡顿。这回答似乎没啥问题,但是我会认为你在卡顿优化这一块是0经验。

卡顿这个话题,相信大部分两年或以上工作经验的同学都应该能说出个大概。
一般的回答可能类似这样:

卡顿是由于主线程有耗时操作,导致View绘制掉帧,屏幕每16毫秒会刷新一次,每秒会刷新60次,人眼能感觉到卡顿的帧率是每秒24帧。所以解决卡顿的办法就是:耗时操作放到子线程、View的层级不能太多、要合理使用include、ViewStub标签等等这些,来保证每秒画24帧以上。

如果问稍微深一点,
卡顿的底层原理是什么?如何理解16毫秒刷新一次?假如界面没有更新操作,View会每16毫秒draw一次吗?

这个问题相信会难倒一片人,包括大部分3年以上经验的同学,如果没有去阅读源码,未必能答好这个问题。当然,我希望你刚好是小部分人~

接下来将从源码角度分析屏幕刷新机制,深入理解卡顿原理,以及介绍卡顿监控的几种方式,希望对你有帮助。


一、屏幕刷新机制

View#requestLayout 开始分析,因为这个方法是主动请求UI更新,从这里分析完全没问题。

1. View#requestLayout

    protected ViewParent mParent;
    ...
    public void requestLayout() {
    ...

    if (mParent != null && !mParent.isLayoutRequested()) {
        mParent.requestLayout(); //1
    }
   }

主要看注释1,这里的 mParent.requestLayout(),最终会调用 ViewRootImplrequestLayout 方法。你可能会问,为什么是ViewRootImpl?因为根View是DecorView,而DecorView的parent就是ViewRootImpl,具体看ViewRootImplsetView方法里调用view.assignParent(this);,可以暂且先认为就是这样的,之后整理View的绘制流程的时候会详细分析。

2. ViewRootImpl#requestLayout

    public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        //1 检测线程
        checkThread();
        mLayoutRequested = true;
        //2 
        scheduleTraversals();
    }
}

注释1 是检测当前是不是在主线程

2.1 ViewRootImpl#checkThread

    void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}

这个异常很熟悉吧,我们平时说的子线程不能更新UI,会抛异常,就是在这里判断的,ViewRootImpl#checkThread

接着看注释2

2.2 ViewRootImpl#scheduleTraversals

    void scheduleTraversals() {
    //1、注意这个标志位,多次调用 requestLayout,要这个标志位false才有效
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        // 2. 同步屏障
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        // 3. 向 Choreographer 提交一个任务
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        if (!mUnbufferedInputDispatch) {
            scheduleConsumeBatchedInput();
        }
        //绘制前发一个通知
        notifyRendererOfFramePending();
        //这个是释放锁,先不管
        pokeDrawLockIfNeeded();
    }
}

主要看注释的3点:

注释1:防止短时间多次调用 requestLayout 重复绘制多次,假如调用requestLayout 之后还没有到这一帧绘制完成,再次调用是没什么意义的。

注释2: 涉及到Handler的一个知识点,同步屏障
往消息队列插入一个同步屏障消息,这时候消息队列中的同步消息不会被处理,而是优先处理异步消息。这里很好理解,UI相关的操作优先级最高,比如消息队列有很多没处理完的任务,这时候启动一个Activity,当然要优先处理Activity启动,然后再去处理其他的消息,同步屏障的设计堪称一绝吧。 同步屏障的处理代码在MessageQueuenext方法:

Message next() {
...
        for (;;) {
           ...
          synchronized (this) {
                // Try to retrieve the next message.  Return if found.
                final long now = SystemClock.uptimeMillis();
                Message prevMsg = null;
                Message msg = mMessages;
                if (msg != null && msg.target == null) { //如果msg不为空并且target为空
                    // Stalled by a barrier.  Find the next asynchronous message in the queue.
                    do {
                        prevMsg = msg;
                        msg = msg.next;
                    } while (msg != null && !msg.isAsynchronous());
                }
      ...
}

逻辑就是:如果msg不为空并且target为空,说明是一个同步屏障消息,进入do while循环,遍历链表,直到找到异步消息msg.isAsynchronous()才跳出循环交给Handler去处理这个异步消息。

回到上面的注释3:mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);,往Choreographer 提交一个任务 mTraversalRunnable,这个任务不会马上就执行,接着看~

3. Choreographer

看下 mChoreographer.postCallback

3.1 Choreographer#postCallback

public void postCallback(int callbackType, Runnable action, Object token) {
    postCallbackDelayed(callbackType, action, token, 0);
}

public void postCallbackDelayed(int callbackType,
        Runnable action, Object token, long delayMillis) {
    if (action == null) {
        throw new IllegalArgumentException("action must not be null");
    }
    if (callbackType < 0 || callbackType > CALLBACK_LAST) {
        throw new IllegalArgumentException("callbackType is invalid");
    }

    postCallbackDelayedInternal(callbackType, action, token, delayMillis);
}

private void postCallbackDelayedInternal(int callbackType,
        Object action, Object token, long delayMillis) {
    if (DEBUG_FRAMES) {
        Log.d(TAG, "PostCallback: type=" + callbackType
                + ", action=" + action + ", token=" + token
                + ", delayMillis=" + delayMillis);
    }

    synchronized (mLock) {
        final long now = SystemClock.uptimeMillis();
        final long dueTime = now + delayMillis;
        //1.将任务添加到队列
        mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

        //2. 正常延时是0,走这里
        if (dueTime <= now) {
            scheduleFrameLocked(now);
        } else {
            //3. 什么时候会有延时,绘制超时,等下一个vsync?
            Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
            msg.arg1 = callbackType;
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, dueTime);
        }
    }
}

入参 callbackType 这里传的是 Choreographer.CALLBACK_TRAVERSAL,后面会说到,最终调用了 postCallbackDelayedInternal 方法。

注释1:将任务添加到队列,不会马上执行,后面会用到。
注释2: scheduleFrameLocked,正常的情况下delayMillis是0,走这里,看下面分析。
注释3:什么情况下会有延时,TextView中有调用到,暂时不管。

3.2. Choreographer#scheduleFrameLocked

// Enable/disable vsync for animations and drawing. 系统属性参数,默认true
private static final boolean USE_VSYNC = SystemProperties.getBoolean(
        "debug.choreographer.vsync", true);
...
private void scheduleFrameLocked(long now) {
    //标志位,避免不必要的多次调用
    if (!mFrameScheduled) {
        mFrameScheduled = true;
        if (USE_VSYNC) {
            if (DEBUG_FRAMES) {
                Log.d(TAG, "Scheduling next frame on vsync.");
            }

            // If running on the Looper thread, then schedule the vsync immediately,
            // otherwise post a message to schedule the vsync from the UI thread
            // as soon as possible.
            //1 如果当前线程是UI线程,直接执行scheduleFrameLocked,否则通过Handler处理
            if (isRunningOnLooperThreadLocked()) {
                scheduleVsyncLocked();
            } else {
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
                msg.setAsynchronous(true);
                mHandler.sendMessageAtFrontOfQueue(msg);
            }
        } else {
            final long nextFrameTime = Math.max(
                    mLastFrameTimeNanos / TimeUtils.NANOS_PER_MS + sFrameDelay, now);
            if (DEBUG_FRAMES) {
                Log.d(TAG, "Scheduling next frame in " + (nextFrameTime - now) + " ms.");
            }
            Message msg = mHandler.obtainMessage(MSG_DO_FRAME);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, nextFrameTime);
        }
    }
}

这个方法有个系统参数判断,默认true,我们分析true的情况。
注释1: 判断当前线程如果是UI线程,直接执行scheduleVsyncLocked方法,否则,通过Handler发一个异步消息到消息队列,最终也是到主线程处理,所以直接看scheduleVsyncLocked方法。

3.3 Choreographer#scheduleVsyncLocked

private final FrameDisplayEventReceiver mDisplayEventReceiver;

private void scheduleVsyncLocked() {
    mDisplayEventReceiver.scheduleVsync();
}

调用 DisplayEventReceiverscheduleVsync 方法

4. DisplayEventReceiver

4.1 DisplayEventReceiver#scheduleVsync

/**
 * Schedules a single vertical sync pulse to be delivered when the next
 * display frame begins.
 */
public void scheduleVsync() {
    if (mReceiverPtr == 0) {
        Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event "
                + "receiver has already been disposed.");
    } else {
        nativeScheduleVsync(mReceiverPtr);  //1、请求vsync
    }
}

// Called from native code. //2、vsync来的时候底层会通过JNI回调这个方法
@SuppressWarnings("unused")
private void dispatchVsync(long timestampNanos, int builtInDisplayId, int frame) {
    onVsync(timestampNanos, builtInDisplayId, frame);
}

这里的逻辑就是:通过JNI,跟底层说,下一个vsync脉冲信号来的时候请通知我。
然后在下一个vsync信号来的时候,就会收到底层的JNI回调,也就是dispatchVsync这个方法会被调用,然后会调用onVsync这个空方法,由实现类去自己做一些处理。

/**
     * Called when a vertical sync pulse is received.
     * The recipient should render a frame and then call {@link #scheduleVsync}
     * to schedule the next vertical sync pulse.
     *
     * @param timestampNanos The timestamp of the pulse, in the {@link System#nanoTime()}
     * timebase.
     * @param builtInDisplayId The surface flinger built-in display id such as
     * {@link SurfaceControl#BUILT_IN_DISPLAY_ID_MAIN}.
     * @param frame The frame number.  Increases by one for each vertical sync interval.
     */
    public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
    }

这里是屏幕刷新机制的重点,应用必须向底层请求vsync信号,然后下一次vsync信号来的时候会通过JNI通知到应用,然后接下来才到应用绘制逻辑。

往回看,DisplayEventReceiver的实现类是 Choreographer 的内部类 FrameDisplayEventReceiver,代码不多,直接贴上来

5. Choreographer

5.1 Choreographer$FrameDisplayEventReceiver

private final class FrameDisplayEventReceiver extends DisplayEventReceiver
        implements Runnable {
    private boolean mHavePendingVsync;
    private long mTimestampNanos;
    private int mFrame;

    public FrameDisplayEventReceiver(Looper looper) {
        super(looper);
    }

    @Override
    public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
       
        // Post the vsync event to the Handler.
        // The idea is to prevent incoming vsync events from completely starving
        // the message queue.  If there are no messages in the queue with timestamps
        // earlier than the frame time, then the vsync event will be processed immediately.
        // Otherwise, messages that predate the vsync event will be handled first.
        long now = System.nanoTime();
        // 更正时间戳,当前纳秒
        if (timestampNanos > now) {
            Log.w(TAG, "Frame time is " + ((timestampNanos - now) * 0.000001f)
                    + " ms in the future!  Check that graphics HAL is generating vsync "
                    + "timestamps using the correct timebase.");
            timestampNanos = now;
        }

        if (mHavePendingVsync) {
            Log.w(TAG, "Already have a pending vsync event.  There should only be "
                    + "one at a time.");
        } else {
            mHavePendingVsync = true;
        }

        mTimestampNanos = timestampNanos;
        mFrame = frame;
        Message msg = Message.obtain(mHandler, this); //1 callback是this,会回调run方法
        msg.setAsynchronous(true);
        mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
    }

    @Override
    public void run() {
        mHavePendingVsync = false;
        doFrame(mTimestampNanos, mFrame); //2
    }
}

根据上面4.1分析,收到vsync信号后,onVsync方法就会被调用,里面主要做了什么呢?通过Handler,往消息队列插入一个异步消息,指定执行的时间,然后看注释1,callback传this,所以最终会回调run方法,run里面调用doFrame(mTimestampNanos, mFrame);重点来了,如果Handler此时存在耗时操作,那么需要等耗时操作执行完,Looper才会轮循到下一条消息,run方法才会调用,然后才会调用到doFrame(mTimestampNanos, mFrame);,doFrame干了什么?调用慢了会怎么样?继续看

5.2 Choreographer#doFrame

void doFrame(long frameTimeNanos, int frame) {
    final long startNanos;
    synchronized (mLock) {
        ...

        long intendedFrameTimeNanos = frameTimeNanos;
        startNanos = System.nanoTime();
        // 1 当前时间戳减去vsync来的时间,也就是主线程的耗时时间
        final long jitterNanos = startNanos - frameTimeNanos;
        if (jitterNanos >= mFrameIntervalNanos) {
            //1帧是16毫秒,计算当前跳过了多少帧,比如超时162毫秒,那么就是跳过了10帧
            final long skippedFrames = jitterNanos / mFrameIntervalNanos;
            // SKIPPED_FRAME_WARNING_LIMIT 默认是30,超时了30帧以上,那么就log提示
            if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
                Log.i(TAG, "Skipped " + skippedFrames + " frames!  "
                        + "The application may be doing too much work on its main thread.");
            }
            // 取余,计算离上一帧多久了,一帧是16毫秒,所以lastFrameOffset 在0-15毫秒之间,这里单位是纳秒
            final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
            if (DEBUG_JANK) {
                Log.d(TAG, "Missed vsync by " + (jitterNanos * 0.000001f) + " ms "
                        + "which is more than the 8frame interval of "
                        + (mFrameIntervalNanos * 0.000001f) + " ms!  "
                        + "Skipping " + skippedFrames + " frames and setting frame "
                        + "time to " + (lastFrameOffset * 0.000001f) + " ms in the past.");
            }
            // 出现掉帧,把时间修正一下,对比的是上一帧时间
            frameTimeNanos = startNanos - lastFrameOffset;
        }
        //2、时间倒退了,可能是由于改了系统时间,此时就重新申请vsync信号(一般不会走这里)
        if (frameTimeNanos < mLastFrameTimeNanos) {
            if (DEBUG_JANK) {
                Log.d(TAG, "Frame time appears to be going backwards.  May be due to a "
                        + "previously skipped frame.  Waiting for next vsync.");
            }
            //这里申请下一次vsync信号,流程跟上面分析一样了。
            scheduleVsyncLocked();
            return;
        }

        mFrameInfo.setVsync(intendedFrameTimeNanos, frameTimeNanos);
        mFrameScheduled = false;
        mLastFrameTimeNanos = frameTimeNanos;
    }

    //3 能绘制的话,就走到下面
    try {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");
        AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);

        mFrameInfo.markInputHandlingStart();
        doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

        mFrameInfo.markAnimationsStart();
        doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);

        mFrameInfo.markPerformTraversalsStart();
        doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

        doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
    } 
}

分析:

1. 计算收到vsync信号到doFrame被调用的时间差,vsync信号间隔是16毫秒一次,大于16毫秒就是掉帧了,如果超过30帧(默认30),就打印log提示开发者检查主线程是否有耗时操作。

  1. 如果时间发生倒退,可能是修改了系统时间,就不绘制,而是重新注册下一次vsync信号
  2. 正常情况下会走到 doCallbacks 里去,callbackType 按顺序是Choreographer.CALLBACK_INPUT、Choreographer.CALLBACK_ANIMATION、Choreographer.CALLBACK_TRAVERSAL、Choreographer.CALLBACK_COMMIT

doCallbacks 里的逻辑

5.3 Choreographer#doCallbacks

void doCallbacks(int callbackType, long frameTimeNanos) {
    CallbackRecord callbacks;
    synchronized (mLock) {
        final long now = System.nanoTime();
        //1. 从队列取出任务,任务什么时候添加到队列的,上面有说过哈
        callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(
                now / TimeUtils.NANOS_PER_MS);
        if (callbacks == null) {
            return;
        }
        mCallbacksRunning = true;
        ...
        //2.更新这一帧的时间,确保提交这一帧的时间总是在最后一帧之后
        if (callbackType == Choreographer.CALLBACK_COMMIT) {
            final long jitterNanos = now - frameTimeNanos;
            Trace.traceCounter(Trace.TRACE_TAG_VIEW, "jitterNanos", (int) jitterNanos);
            if (jitterNanos >= 2 * mFrameIntervalNanos) {
                final long lastFrameOffset = jitterNanos % mFrameIntervalNanos
                        + mFrameIntervalNanos;
                if (DEBUG_JANK) {
                    Log.d(TAG, "Commit callback delayed by " + (jitterNanos * 0.000001f)
                            + " ms which is more than twice the frame interval of "
                            + (mFrameIntervalNanos * 0.000001f) + " ms!  "
                            + "Setting frame time to " + (lastFrameOffset * 0.000001f)
                            + " ms in the past.");
                    mDebugPrintNextFrameTimeDelta = true;
                }
                frameTimeNanos = now - lastFrameOffset;
                mLastFrameTimeNanos = frameTimeNanos;
            }
        }
    }
    try {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, CALLBACK_TRACE_TITLES[callbackType]);
        for (CallbackRecord c = callbacks; c != null; c = c.next) {
            if (DEBUG_FRAMES) {
                Log.d(TAG, "RunCallback: type=" + callbackType
                        + ", action=" + c.action + ", token=" + c.token
                        + ", latencyMillis=" + (SystemClock.uptimeMillis() - c.dueTime));
            }
            // 3. 执行任务,
            c.run(frameTimeNanos);
        }
    } ...
}

这里主要就是取出对应类型的任务,然后执行任务。
注释2:if (callbackType == Choreographer.CALLBACK_COMMIT)是流程的最后一步,数据已经绘制完准备提交的时候,会更正一下时间戳,确保提交时间总是在最后一次vsync时间之后。这里文字可能不太好理解,引用一张图

图中 doCallbacks 从 frameTimeNanos2 开始执行,执行到进入 CALLBACK_COMMIT 时,经过了2.2帧,判断
now - frameTimeNanos >= 2 * mFrameIntervalNanos,lastFrameOffset = jitterNanos % mFrameIntervalNanos取余就是0.2了,于是修正的时间戳 frameTimeNanos = now - lastFrameOffset 刚好就是3的位置。

注释3,还没到最后一步的时候,取出其它任务出来run,这个任务肯定就是跟View的绘制相关了,记得开始requestLayout传过来的类型吗,Choreographer.CALLBACK_TRAVERSAL,从队列get出来的任务类对应是mTraversalRunnable,类型是TraversalRunnable,定义在ViewRootImpl里面,饶了一圈,回到ViewRootImpl继续看~

6. ViewRootImpl

刚开始看的是ViewRootImpl#scheduleTraversals,继续往下分析

6.1 ViewRootImpl#scheduleTraversals

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        ...
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
    }
}

这个mTraversalRunnable 任务绕了一圈,通过请求vsync信号,到收到信号,然后终于被调用了。

6.2 ViewRootImpl$TraversalRunnable

final class TraversalRunnable implements Runnable {
    @Override
    public void run() {
        doTraversal();
    }
}

6.3 ViewRootImpl#doTraversal

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            //移除同步屏障
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            performTraversals();
        }
    }

先移除同步屏障消息,然后调用performTraversals 方法,
performTraversals 这个方法代码有点多,挑重点看

6.4 ViewRootImpl#performTraversals

    private void performTraversals() {

        // mAttachInfo 赋值给View
        host.dispatchAttachedToWindow(mAttachInfo, 0);

        // Execute enqueued actions on every traversal in case a detached view enqueued an action
        getRunQueue().executeActions(mAttachInfo.mHandler);

        ... 
      //1 测量
        if (!mStopped || mReportNextDraw) {

            // Ask host how big it wants to be
            //1.1测量一次
             performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);

            / Implementation of weights from WindowManager.LayoutParams
            // We just grow the dimensions as needed and re-measure if
            // needs be

            if (lp.horizontalWeight &gt; 0.0f) {
                width += (int) ((mWidth - width) * lp.horizontalWeight);
                childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width,
                        MeasureSpec.EXACTLY);
                measureAgain = true;
            }
            if (lp.verticalWeight &gt; 0.0f) {
                height += (int) ((mHeight - height) * lp.verticalWeight);
                childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height,
                        MeasureSpec.EXACTLY);
                measureAgain = true;
            }
            //1.2、如果有设置权重,比如LinearLayout设置了weight,需要测量两次
            if (measureAgain) {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            }

        }

        ... 
        //2.布局
        if (didLayout) {
            // 会回调View的layout方法,然后会调用View的onLayout方法
            performLayout(lp, mWidth, mHeight);

        }

        ... 
        //3.画
        if (!cancelDraw &amp;&amp; !newSurface) {
            performDraw();
        }
  
    }
    

可以看到,View的三个方法回调measure、layout、draw是在performTraversals 里面,需要注意的点是LinearLayout设置权重的情况下会measure两次

到这里,屏幕刷新机制就分析完了,整个流程总结一下:

7. 小结

View 的 requestLayout 会调到ViewRootImpl 的 requestLayout方法,然后通过 scheduleTraversals 方法向Choreographer 提交一个绘制任务,然后再通过DisplayEventReceiver向底层请求vsync信号,当vsync信号来的时候,会通过JNI回调回来,通过Handler往主线程消息队列post一个异步任务,最终是ViewRootImpl去执行那个绘制任务,调用performTraversals方法,里面是View的三个方法的回调。

网上的流程图虽然很漂亮,但是不如自己画一张印象深刻

屏幕刷新机制

认真看完,想必大家对屏幕刷新机制应该清楚了:

应用需要主动请求vsync,vsync来的时候才会通过JNI通知到应用,然后才调用View的三个绘制方法。如果没有发起绘制请求,例如没有requestLayout,View的绘制方法是不会被调用的。ViewRootImpl里面的这个View其实是DecorView。

那么有两个地方会造成掉帧,一个是主线程有其它耗时操作,导致doFrame没有机会在vsync信号发出之后16毫秒内调用,对应下图的3;还有一个就是当前doFrame方法耗时,绘制太久,下一个vsync信号来的时候这一帧还没画完,造成掉帧,对应下图的2。1是正常的

vsync

这一张图很形象,大家可以参考这张图自己研究研究。
关于Choreographer如果还有不了解的地方,我看这篇文章写的还不错Choreographer 解析

二、如何监控应用卡顿?

上面从源码角度分析了屏幕刷新机制,为什么主线程有耗时操作会导致卡顿?原理想必大家已经心中有数,那么平时开发中如何去发现那些会造成卡顿的代码呢?

接下来总结几种比较流行、有效的卡顿监控方式:

2.1 基于消息队列

2.1.1 替换 Looper 的 Printer

Looper 暴露了一个方法

    public void setMessageLogging(@Nullable Printer printer) {
        mLogging = printer;
    }

在Looper 的loop方法有这样一段代码

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

Looper轮循的时候,每次从消息队列取出一条消息,如果logging不为空,就会调用 logging.println,我们可以通过设置Printer,计算Looper两次获取消息的时间差,如果时间太长就说明Handler处理时间过长,直接把堆栈信息打印出来,就可以定位到耗时代码。不过println 方法参数涉及到字符串拼接,所以这种方式只推荐在Debug模式下使用。基于此原理的开源库代表是:BlockCanary,看下BlockCanary核心代码:

类:LooperMonitor

    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;
    }
    

原理是这样,比较Looper两次处理消息的时间差,比如大于3秒,就认为卡顿了。细节的话大家可以自己去研究源码,比如消息队列只有一条消息,隔了很久才有消息入队,这种情况应该是要处理的,BlockCanary是怎么处理的呢?

在Android开发高手课中张绍文说过微信内部的基于消息队列的监控方案有缺陷:


这个我在BlockCanary 中测试,并没有出现此问题,所以BlockCanary 是怎么处理的,简单分析一下源码:
上面这段代码,注释1和注释2,记录第一次处理的时间,同时调用startDump()方法,startDump()最终会通过Handler 去执行一个AbstractSampler 类的mRunnable,代码如下:

abstract class AbstractSampler {

    private static final int DEFAULT_SAMPLE_INTERVAL = 300;

    protected AtomicBoolean mShouldSample = new AtomicBoolean(false);
    protected long mSampleInterval;

    private Runnable mRunnable = new Runnable() {
        @Override
        public void run() {
            doSample();
            //调用startDump 的时候设置true了,stop时设置false
            if (mShouldSample.get()) {  
                HandlerThreadFactory.getTimerThreadHandler()
                        .postDelayed(mRunnable, mSampleInterval);
            }
        }
    };

可以看到,调用doSample之后又通过Handler执行mRunnable,等于是循环调用doSample,直到stopDump被调用。

doSample方法有两个类实现,StackSampler和CpuSampler,分析堆栈就看StackSamplerdoSample方法

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());
        }
    }

所以,BlockCanary 能做到连续调用几个方法也能准确揪出耗时是哪个方法,是因为开启循环去获取堆栈信息并保存到LinkedHashMap,因此不会出现误判或者漏判。核心代码就先分析到这里,其它细节大家可以自己去看源码。

2.1.2 插入空消息到消息队列

这种方式可以了解一下。

通过一个监控线程,每隔1秒向主线程消息队列的头部插入一条空消息。假设1秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在0~1秒之间。换句话说,如果我们需要监控3秒卡顿,那在第4次轮询中,头部消息依然没有被消费的话,就可以确定主线程出现了一次3秒以上的卡顿。


2.2 插桩

编译过程插桩(例如使用AspectJ),在方法入口和出口加入耗时监控的代码。
原来的方法:

public void test(){
    doSomething();
}

通过编译插桩之后的方法类似这样

public void test(){
    long startTime = System.currentTimeMillis();
    doSomething();
    long methodTime = System.currentTimeMillis() - startTime;//计算方法耗时
}

当然,原理是这样,实际上可能需要封装一下,类似这样

public void test(){
    methodStart();
    doSomething();
    methodEnd();
}

在每个要监控的方法的入口和出口分别加上methodStartmethodEnd两个方法,类似插桩埋点。

当然,这种插桩的方法缺点比较明显:

  • 无法监控系统方法
  • apk体积会增大(每个方法都多了代码)

需要注意:

  • 过滤简单的方法
  • 只需要监控主线程执行的方法

2.3 其它

作为扩展:
Facebook 开源的Profilo

三、总结

这篇文章围绕卡顿这个话题

  1. 从源码角度分析了屏幕刷新机制,底层每间隔16毫秒会发出vsyn信号,应用界面要更新,必须先向底层请求vsync信号,这样下一个16毫秒vsync信号来的时候,底层会通过JNI通知到应用,然后通过主线程Handler执行View的绘制任务。所以两个地方会造成卡顿,一个是主线程在执行耗时操作导致View的绘制任务没有及时执行,还有一个是View绘制太久,可能是层级太多,或者里面绘制算法太复杂,导致没能在下一个vsync信号来临之前准备完数据,导致掉帧卡顿。

  2. 介绍目前比较流行的几种卡顿监控方式,基于消息队列的代表BlockCanary原理,以及通过编译插桩的方式在每个方法入口和出口加入计算方法耗时的代码。

面试中应对卡顿问题,可以围绕卡顿原理、屏幕刷新机制、卡顿监控这几个方面来回答,当然,卡顿监控这一块,还可以通过TraceView、SysTrace等工具来找出卡顿代码。在BlockCanary出现之前,TraceView、Systrace是开发者必备的卡顿分析工具,而如今,能把BlockCanary原理讲清楚我认为就很不错了,而对于厂商做系统App开发维护的,不会轻易接入开源库,所以就有必要去了解TraceView、Systrace工具的使用。

本文主要介绍卡顿原理和卡顿监控,至于View具体是怎么绘制的,软件绘制和硬件绘制的区别,绘制流程走完之后,如何更新到屏幕,这个涉及到的内容很多,以后有时间会整理一下。

有问题直接在评论区留言,就这样~


本文参考:
Choreographer 解析
Android开发高手课-06如何监控应用卡顿
BlockCanary

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容