美文网首页Android进阶之路Android开发Android开发经验谈
读源码长知识 | Android卡顿真的是因为”掉帧“?

读源码长知识 | Android卡顿真的是因为”掉帧“?

作者: 木木玩Android | 来源:发表于2020-08-24 15:59 被阅读0次

Andriod 界面卡顿是因为掉帧,而掉帧是因为生产帧的速度跟不上消费帧的速度。

消费帧的速度与屏幕刷新率挂钩,屏幕就像连环画,若一秒播放 60 帧,消费一帧的速度为 1000/60 = 16.6 ms,即每 16.6 ms 屏幕就会去取下一帧的显示内容,若没取到,只能继续显示上一帧,画面就停滞了,这就称为“掉帧”,听上去好像丢失了无法找回的东西一样,其实它是形容“显示内容错过了一次显示机会”,描述的是屏幕硬件的一种行为。

屏幕为啥会没有取到显示内容?得从软件层找原因。带着这个问题,读一下 Framework 源码。

Choreographer

ViewRootImpl是一个 Activity 中所有视图的根视图,View 树的遍历就由它发起:

public final class ViewRootImpl implements ViewParent, View.AttachInfo.Callbacks, ThreadedRenderer.DrawCallbacks {
    // 编舞者
    Choreographer mChoreographer;
    // 触发遍历 View 树
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            // 向 Choreographer 抛 View树遍历任务
            mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

    // 遍历 View 树任务
    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }
    // 构建遍历 View 树任务实例
    final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
}
复制代码

ViewRootImpl通过抛任务到Choreographer来触发 View 树遍历。

Choreographerandroid.view包下的一个类,字面意思是“编舞者”,隐含着“需要将两者同步”的意思,编舞即是将动作和节奏同步。而Choreographer是将"绘制内容"和"垂直同步信号"同步。

存放绘制任务

public final class Choreographer {
    // 输入任务
    public static final int CALLBACK_INPUT = 0;
    // 动画任务
    public static final int CALLBACK_ANIMATION = 1;
    // view树遍历任务
    public static final int CALLBACK_TRAVERSAL = 2;
    // COMMIT任务
    public static final int CALLBACK_COMMIT = 3;
    // 暂存任务的链式数组
    private final CallbackQueue[] mCallbackQueues;
    // 主线程消息处理器
    private final FrameHandler mHandler;

    // 抛绘制任务
    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) {
        ...
        postCallbackDelayedInternal(callbackType, action, token, delayMillis);
    }

    // 抛绘制任务的具体实现
    private void postCallbackDelayedInternal(int callbackType, Object action, Object token, long delayMillis) {
        synchronized (mLock) {
            final long now = SystemClock.uptimeMillis();
            final long dueTime = now + delayMillis;
            // 1\. 将绘制任务根据类型暂存在链式结构中
            mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

            // 2\. 订阅下一个垂直同步信号
            if (dueTime <= now) {
                // 立刻订阅下一个垂直同步信号
                scheduleFrameLocked(now);
            } else {
                // 在未来的某个时间点订阅垂直同步信号
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
                msg.arg1 = callbackType;
                msg.setAsynchronous(true);
                mHandler.sendMessageAtTime(msg, dueTime);
            }
        }
    }

    // 主线程消息处理器
    private final class FrameHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                ...
                case MSG_DO_SCHEDULE_CALLBACK:
                    // 在未来时间点订阅垂直同步信号
                    doScheduleCallback(msg.arg1);
                    break;
            }
        }
    }

    void doScheduleCallback(int callbackType) {
        synchronized (mLock) {
            if (!mFrameScheduled) {
                final long now = SystemClock.uptimeMillis();
                if (mCallbackQueues[callbackType].hasDueCallbacksLocked(now)) {
                    // 订阅下一个垂直同步信号
                    scheduleFrameLocked(now);
                }
            }
        }
    }
}
复制代码

Choreographer接收到新的绘制任务后,会执行两个动作:

  1. 绘制任务入链:
public final class Choreographer {
    // 绘制任务链
    private final class CallbackQueue {
        // 任务链头结点
        private CallbackRecord mHead;

        // 绘制任务入链(按时间升序)
        public void addCallbackLocked(long dueTime, Object action, Object token) {
            CallbackRecord callback = obtainCallbackLocked(dueTime, action, token);
            CallbackRecord entry = mHead;
            if (entry == null) {
                mHead = callback;
                return;
            }
            // 头插入
            if (dueTime < entry.dueTime) {
                callback.next = entry;
                mHead = callback;
                return;
            }

            //中间插入或尾插入
            while (entry.next != null) {
                if (dueTime < entry.next.dueTime) {
                    callback.next = entry.next;
                    break;
                }
                entry = entry.next;
            }
            entry.next = callback;
        }
    }

    // 绘制任务结点
    private static final class CallbackRecord {
        // 下一个绘制任务
        public CallbackRecord next;
        // 绘制任务应该在这个时刻被执行
        public long dueTime;
        // 描述绘制任务的代码段
        public Object action;   
        ...
        // 执行绘制任务
        public void run(long frameTimeNanos) {
            if (token == FRAME_CALLBACK_TOKEN) {
                ((FrameCallback)action).doFrame(frameTimeNanos);
            } else {
                ((Runnable)action).run();
            }
        }
    }
}
复制代码

Choreographer接收有四种任务类型,分别是输入、动画、View树遍历、COMMIT。每个任务被抽象成CallbackRecord,同类任务按时间先后顺序组成一条任务链CallbackQueue。四条任务链存放在mCallbackQueues[]数组结构中。

  1. 订阅下一个垂直同步信号
public final class Choreographer {
    private void scheduleFrameLocked(long now) {
        // 若已经订阅过下个垂直同步信号,则什么也不做
        if (!mFrameScheduled) {
            // 当下一个垂直同步信号到来时,需要执行绘制任务
            mFrameScheduled = true;
            if (USE_VSYNC) {
                // 不管走哪个分支,最终都会调用scheduleVsyncLocked()来注册接收垂直同步信号
                if (isRunningOnLooperThreadLocked()) {
                    scheduleVsyncLocked();
                } else {
                    Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
                    msg.setAsynchronous(true);
                    mHandler.sendMessageAtFrontOfQueue(msg);
                }
            } else {
                ...
            }
        }
    }

    // 委托 DisplayEventReceiver 来注册垂直同步信号
    private void scheduleVsyncLocked() {
        mDisplayEventReceiver.scheduleVsync();
    }

    // 绘制一帧
    void doFrame(long frameTimeNanos, int frame) {
        synchronized (mLock) {
            // 若不需要响应这个垂直同步信号,则直接返回,该帧不绘制任何东西
            if (!mFrameScheduled) {
                return; 
            }
            ...
        }
        ...
    }

    private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
        // 垂直同步信号回调
        @Override
        public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
            ...
            // 向主线程抛任务,绘制一帧
            Message msg = Message.obtain(mHandler, this);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

        @Override
        public void run() {
            mHavePendingVsync = false;
            // 绘制当前帧
            doFrame(mTimestampNanos, mFrame);
        }
    }
}

// 垂直同步信号接收器
public abstract class DisplayEventReceiver {
    // 注册接收下一个垂直同步信号
    public void scheduleVsync() {
        if (mReceiverPtr == 0) {
            Log.w(TAG, "...");
        } else {
            // 向SurfaceFlinger订阅下一个垂直同步信号
            nativeScheduleVsync(mReceiverPtr);
        }
    }
    ...
}
复制代码

并不是每个垂直同步信号都会被上层订阅并处理,只有当Choreographer订阅了下一个垂直同步信号,SurfaceFlinger才会把信号通过onVsync()回调给上层。

图中第一个垂直信号到来之前,上层调用了postCallback()Choreographer抛绘制任务,同时订阅了下一个信号并把mFrameScheduled置为 true,表示需要绘制下一帧。当第一个信号产生时,onVsync()被回调,同时将doFrame()抛到主线程执行,执行完毕后将mFrameScheduled置为 false,因没有后续订阅动作,所以上层不会收到后续的onVsync()回调,也不会绘制任何新东西。

执行绘制任务

ViewRootImplChoreographer抛绘制任务后,任务并没有立马执行,而是被暂存在绘制任务链中,并注册接收下一个垂直同步信号。只有当下一个信号通过onVsync()回调后,它才会被执行:

public final class Choreographer {
    // 垂直同步信号接收器
    private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
        private boolean mHavePendingVsync;
        private long mTimestampNanos;
        private int mFrame;

        @Override
        public void onVsync(long timestampNanos, int builtInDisplayId, int frame) { 
            ...
            // 发送异步消息到主线程,执行当前的Runnable,即doFrame()
            Message msg = Message.obtain(mHandler, this);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

        @Override
        public void run() {
            mHavePendingVsync = false;
            // 绘制一帧的内容
            doFrame(mTimestampNanos, mFrame);
        }
    }
}
复制代码

每当垂直同步信号回调时,都会向主线程推送一个待执行的绘制帧任务doFrame()

public final class Choreographer {
    void doFrame(long frameTimeNanos, int frame) {
        final long startNanos;
        synchronized (mLock) {
            // 如果没有订阅这一帧的垂直同步信号,则直接退出不绘制
            if (!mFrameScheduled) {
                return; // no work to do
            }
        ...
        try {
            // 处理这一帧的输入事件
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
            // 处理这一帧的动画
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            // 处理这一帧的 View 树遍历
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
            // 所有绘制任务结束后执行 COMMIT 任务
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {
            ...
        }
        ...
    }
}
复制代码

绘制每一帧时,会按照“输入事件”、“动画”、“View 树遍历”、“COMMIT”这样的顺序处理任务。

处理函数doCallback()定义如下:

public final class Choreographer {
    // 暂存绘制任务的链式数组
    private final CallbackQueue[] mCallbackQueues;

    void doCallbacks(int callbackType, long frameTimeNanos) {
        CallbackRecord callbacks;
        synchronized (mLock) {
            final long now = System.nanoTime();
            // 从指定类型的任务链中根据当前时间点取出要执行的任务
            callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(now / TimeUtils.NANOS_PER_MS);
            if (callbacks == null) {
                return;
            }
            ...
        }
        try {
            // 执行任务链上被取出的所有任务
            for (CallbackRecord c = callbacks; c != null; c = c.next) {
                c.run(frameTimeNanos);
            }
        } finally {
            ...
        }
    }

    // 任务实体类
    private static final class CallbackRecord {
        public CallbackRecord next;
        public Object action;
        ...
        public void run(long frameTimeNanos) {
            if (token == FRAME_CALLBACK_TOKEN) {
                ((FrameCallback)action).doFrame(frameTimeNanos);
            } else {
                // 执行任务
                ((Runnable)action).run();
            }
        }
    }
}
复制代码

追踪到这,ViewRootImpl推送过来的 "View 树遍历" 任务总算被执行了。

其中extractDueCallbacksLocked()是任务链CallbackQueue的方法,用于获取待当前时间点需要被执行的所有任务:

private final class CallbackQueue {
    // 任务链头结点
    private CallbackRecord mHead;

    // 获取当前时间节点之前的所有任务,这些任务都需要在当前帧被执行
    public CallbackRecord extractDueCallbacksLocked(long now) {
        CallbackRecord callbacks = mHead;
        if (callbacks == null || callbacks.dueTime > now) {
            return null;
        }

        CallbackRecord last = callbacks;
        CallbackRecord next = last.next;
        // 遍历任务链,以现在时间点为分割线,从链上摘取过去的任务
        while (next != null) {
            if (next.dueTime > now) {
                last.next = null;
                break;
            }
            last = next;
            next = next.next;
        }
        mHead = next;
        // 以链的形式返回所有过去任务
        return callbacks;
    }
}
复制代码

绘制当前帧时,会以当前时刻为分割线,从任务链上摘取所有以前的任务,并按时间先后顺序逐个执行。

在纸上演算一遍上面的代码:

上图表示在第一个垂直同步信号还未到来时,上层已经向任务链中已添加了 3 个任务,其中前两个的执行时刻在第一个信号到来之前,而第三个任务在第一个信号后执行。

在第一个任务入链时就完成了对第一个信号的订阅,而第三个任务在第一个信号之后执行,所以它的订阅行为doScheduleCallback()先被存放在主线程消息队列中,待第一个信号到来之后再订阅第二个信号。

当第一个垂直同步信号到来后,doFrame()被抛到主线程,它从任务链中摘取当前时间节点之前的任务1和2并执行它们。当任务被执行完,就从主线程消息队列中取出下一条消息“为任务3订阅下一个垂直同步信号”并执行。所有这些都完成后,主线程只能发呆,等待下一个垂直同步信号。

当第二个垂直同步信号到来之后,任务链中剩下的任务3都被取出并抛到主线程执行。任务链空了,当任务3执行完毕后,主线程彻底没事做,只能等到上层再向Choreographer抛任务。

卡顿的原因是掉帧?

推迟

上述情况,绘制任务都能在一个帧间隔内完成,若任务很耗时,执行时间超过了帧间隔会怎么样?

第一个垂直信号到来后,任务1和2被抛到主线程执行,这次它们执行时间略长,超过了一个帧间隔,导致订阅下一个信号的函数迟迟未被执行。对于上层来说,错过了第二个onVsync()回调,这意味着,任务1和2错过了一次显示时机,任务3错过了一次渲染时机。对于底层来说,显示器在发出垂直同步信号时会向图形缓冲取显示内容,这次没拿到内容,只能继续显示上一帧图像。

任务1和2执行完,主线程才“订阅下一个信号”,当第三个信号到来时,显示器从图形缓冲中取到了任务1和2的渲染结果,而任务3也被抛到主线程执行。

这个 case 中,任务链中所有的绘制任务都被推迟显示了。

合并

若主线程中除了绘制任务外,还有别的耗时任务,情况会怎么样?

假设第一个垂直同步信号到来前,上层已经抛了3个绘制任务到任务链中,它们执行的时刻分别被设定于第一、二、三个垂直同步信号之间。

当第一个垂直同步信号到来时,doFrame()被抛到主线程消息队列,但主线程被一个 I/O 任务占用着,遂doFrame()一直没有得到执行机会。而且订阅后续信号的函数也无法被执行,所以第二、三个onVsync()不会被调用,直到 I/O 操作执行完毕。

推迟执行的doFrame()会有什么不一样?

public final class Choreographer {
    private Choreographer(Looper looper, int vsyncSource) {
        ...
        // 帧间隔 = 1 秒 / 刷新率
        mFrameIntervalNanos = (long)(1000000000 / getRefreshRate());
    }

    private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
        private long mTimestampNanos;

        @Override
        public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
            ...
            // 记录垂直信号到达的时刻
            mTimestampNanos = timestampNanos;
            ...
            Message msg = Message.obtain(mHandler, this);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

        @Override
        public void run() {
            mHavePendingVsync = false;
            // 垂直信号到达时刻会和doFrame()一起被抛到主线程
            doFrame(mTimestampNanos, mFrame);
        }
    }

    void doFrame(long frameTimeNanos, int frame) {
        synchronized (mLock) {
            ...
            // 当前帧实际被绘制的时刻
            startNanos = System.nanoTime();
            // 计算当前帧绘制延迟 = 当前时刻 - 本应绘制的时刻
            final long jitterNanos = startNanos - frameTimeNanos;
            // 如果延迟大于帧间隔
            if (jitterNanos >= mFrameIntervalNanos) {
                // 计算跳过的帧数,如果帧数超过阈值则log警告
                final long skippedFrames = jitterNanos / mFrameIntervalNanos;
                if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
                    Log.i(TAG, "Skipped " + skippedFrames + " frames!  "
                            + "The application may be doing too much work on its main thread.");
                }
                // 计算帧延迟相对于帧间隔的偏移量
                final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
                // 修正帧绘制时刻,将其对齐到最新到达的垂直同步信号
                frameTimeNanos = startNanos - lastFrameOffset;
            }
            ...
            // 将上一帧绘制时刻更新为最新垂直同步信号时刻
            mLastFrameTimeNanos = frameTimeNanos;
        }

        try {
            // 渲染当前帧(传入的frameTimeNanos是最新的垂直同步信号时刻)
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {
            ...
        }
        ...
    }
}
复制代码

doFrame()在绘制帧之前,会计算帧延迟,即当前时刻和帧本该被绘制时刻的差值。

如果帧延迟大于帧间隔,则会修正绘制时刻frameTimeNanos,将其对齐到当前时刻前的一个垂直同步信号时刻。然后该时刻被传入doCallbacks(),作为从任务链上摘取任务的依据,所有在此之前的任务都会被摘下。

此时,任务1、2、3都会被摘下,因为相对于对其后的frameTimeNanos,它们都是过去的任务,所以这次doFrame()的执行会将原本三次doFrame()的绘制任务一并执行,如果绘制任务执行的足够快,就有机会在下一个垂直同步信号到来之前订阅它,这样任务1、2、3的绘制的内容就会在下一帧被展示出来,如下图所示:

当主线程被耗时操作占用时,会发生掉帧,即本该被渲染及展示的帧会错过展示机会。但对于上层来说,什么东西都没有掉,只是本该在不同帧间隔执行的绘制任务被合并在一起执行展示。

如果三个绘制任务很耗时,会发生什么?

这时候,doCallbacks()中的某些条件就会被触发:

public final class Choreographer {
    void doFrame(long frameTimeNanos, int frame) {
        synchronized (mLock) {
            ...
            startNanos = System.nanoTime();
            final long jitterNanos = startNanos - frameTimeNanos;
            // 如果延迟大于帧间隔
            if (jitterNanos >= mFrameIntervalNanos) {
                ...
                // 计算帧延迟相对于帧间隔的偏移量
                final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
                // 修正帧绘制时刻,将其对齐到最新的垂直同步信号
                frameTimeNanos = startNanos - lastFrameOffset;
            }
            ...
            // 将上一帧绘制时刻更新为最新垂直同步信号时刻
            mLastFrameTimeNanos = frameTimeNanos;

            // 如果该帧相对于上一帧来说是旧帧,  跳过当前帧并订阅下一个垂直同步信号
            if (frameTimeNanos < mLastFrameTimeNanos) {
                scheduleVsyncLocked();
                return;
            }
        }

        try {
            // 渲染当前帧
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
            // 在输入,动画,View树遍历任务都执行完毕之后,执行 COMMIT 任务
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {
            ...
        }
        ...
    }

    void doCallbacks(int callbackType, long frameTimeNanos) {
        CallbackRecord callbacks;
        synchronized (mLock) {
            ...
            // 如果是 COMMIT 任务
            if (callbackType == Choreographer.CALLBACK_COMMIT) {
                // 计算帧绘制延迟
                final long jitterNanos = now - frameTimeNanos;
                // 如果帧绘制延迟 >= 2倍帧间隔
                if (jitterNanos >= 2 * mFrameIntervalNanos) {
                    // 计算帧偏移量并追加一个帧间隔的时长
                    final long lastFrameOffset = jitterNanos % mFrameIntervalNanos + mFrameIntervalNanos;
                    // 将mLastFrameTimeNanos对其到当前时刻前的第二个垂直同步信号时刻
                    frameTimeNanos = now - lastFrameOffset;
                    mLastFrameTimeNanos = frameTimeNanos;
                }
            }
        }
        ...
    }
}
复制代码

因为doFrame()执行时间超过了2个帧间隔,当输入、动画、View树遍历任务执行完毕,COMMIT任务执行时,触发了帧时间对其,将mLastFrameTimeNanos往前偏移了一段时间,对其到绘制任务结束时刻前的第二个垂直同步信号时刻。这样做的目的是防止因这次超时绘制而导致更多的掉帧。因为doFrame()在绘制当前帧之前会校验当前帧和上一帧的绘制时刻:

// 如果该帧相对于上一帧来说是旧帧, 跳过当前帧并订阅下一个垂直同步信号
if (frameTimeNanos < mLastFrameTimeNanos) {
    scheduleVsyncLocked();
    return;
}
复制代码

如果在上一个doFrame()快执行完时,上层又向Choreographer抛了若干绘制任务,这些绘制任务的绘制时刻frameTimeNanos必然小于上一帧绘制结束时刻mLastFrameTimeNanos,若不执行COMMIT任务将mLastFrameTimeNanos向前对其,则新的绘制任务将会错过一次执行时机。

写在最后


我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在IT学习中的很多困惑,所以在工作繁忙的情况下还是坚持各种整理和分享。但苦于知识传播途径有限,很多程序员朋友无法获得正确的资料得到学习提升,故此将并将重要的Android进阶资料包括自定义view、性能优化、MVC与MVP与MVVM三大框架的区别、NDK技术、阿里面试题精编汇总、常见源码分析等学习资料免费分享出来。

知识不体系?这里还有整理出来的Android进阶学习的思维脑图,给大家参考一个方向。包含知识脉络 + 诸多细节,由于篇幅有限,下面只是以图片的形式给大家展示一部分。

Android学习PDF+学习视频+面试文档+知识点笔记

【Android高级架构视频学习资源】

Android部分精讲视频领取学习后更加是如虎添翼!进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水,赶快领取吧!

【Android进阶学习视频】、【全套Android面试秘籍】可以简信我【学习】查看免费领取方式!

原文链接:https://juejin.im/post/6864365886837686285

相关文章

网友评论

    本文标题:读源码长知识 | Android卡顿真的是因为”掉帧“?

    本文链接:https://www.haomeiwen.com/subject/rygvjktx.html