来看“Android编舞者”怎么编舞

2021-04-30 10:59:46 浏览数 (1)

前言

接上回,今天说说VSync机制的相关处理,也就是Choreographer类。

每次说到源码就很难表述,所以今天还是通过问题的方式,一步步解析这个“编舞者”

(本篇涉及到大量Handler知识点,如果忘记的朋友可以再翻翻我之前写的《Handler27问》- https://juejin.cn/post/6943048240291905549)

界面有绘制任务时候,会马上执行performTraversals吗?

当界面有绘制任务,一般是执行了两种方法:

  • View.invalidate
  • View.requestLayout

而这两个方法的的终点都是ViewRootImp类的scheduleTraversals方法,开始View树的绘制任务。

我们就从这里入手:

代码语言:javascript复制
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        }
    }

主要做了三件事:

  • 设置了mTraversalScheduled标识位为true。这个标示位的用法,很像我们防止按钮多次点击时候的逻辑,所以我们可以猜测下它就是为了防止某个时间段多次绘制任务的提交。
  • 给主线程的Handler加入了同步屏障。同步屏障我们之前说过了,它的存在是为了保证后续的异步消息优先执行。
  • 最后就是用到我们的Choreographer类,调用了postCallback方法。

我们先不看这个postCallback方法,而是看看传入的一个参数mTraversalRunnable

代码语言:javascript复制
 final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
            performTraversals();
        }
    }
    

可以发现,在这个Runnable中执行了doTraversal,同样是三件事:

  • 设置mTraversalScheduled标识位为false。
  • 移除同步屏障
  • 执行performTraversals方法,开始绘制。

这不巧了吗,刚好对应了刚才的三件事。

也就是在这里,执行了performTraversals方法开始绘制View树,即layout、measure、draw

所以这个标识位我们可以断定了,就是防止在开始绘制之前的某个时间段内多次提交绘制需求。而且在这里移除了同步屏障,因为绘制任务已经在执行了,不用怕别的Handler消息任务来插队了。

那这个过程到底发生了什么呢?是马上执行了doTraversal方法吗?悬念自然就在postCallback方法中了,继续下一个问题。

Choreographer单例是怎么实现的?保存在哪里?

刚才我们看到了编舞者Choreographer的身影,那么它又是从哪里来的呢?

代码语言:javascript复制
 public ViewRootImpl(Context context, Display display) {
        //...
        mChoreographer = Choreographer.getInstance();
    }


    //Choreographer.java
    private static final ThreadLocal<Choreographer> sThreadInstance =
            new ThreadLocal<Choreographer>() {
        @Override
        protected Choreographer initialValue() {
            Looper looper = Looper.myLooper();
            if (looper == null) {
                throw new IllegalStateException("The current thread must have a looper!");
            }
            Choreographer choreographer = new Choreographer(looper, VSync_SOURCE_APP);
            if (looper == Looper.getMainLooper()) {
                mMainInstance = choreographer;
            }
            return choreographer;
        }
    };

    public static Choreographer getInstance() {
        return sThreadInstance.get();
    }

ViewRootImpl构造方法中获取了Choreographer实例,至于怎么获取的。。没错,又是它:ThreadLocal

细节就不说了,ThreadLocal主要是保证一个线程对应一个实例,Choreographer一般只存在于主线程,所以就是用于单例。

Choreographer怎么处理绘制任务呢?

好了,接着上面的说说postCallback方法,也就是绘制任务被交给Choreographer之后是怎么处理的呢?

代码语言:javascript复制
    public void postCallback() {
        postCallbackDelayed(callbackType, action, token, 0);
    }
    public void postCallbackDelayed() {
        postCallbackDelayedInternal(callbackType, action, token, delayMillis);
    }

    private void postCallbackDelayedInternal() {

        synchronized (mLock) {
            final long now = SystemClock.uptimeMillis();
            final long dueTime = now   delayMillis;

            mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

            if (dueTime <= now) {
             //如果没有延时,马上预定下一个VSync信号
                scheduleFrameLocked(now);
            } else {
             //会在任务执行时间去预定VSync信号
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
                msg.arg1 = callbackType;
                msg.setAsynchronous(true);
                mHandler.sendMessageAtTime(msg, dueTime);
            }
        }
    }
  • 首先,看到delayMillis,就知道这个任务是可以延迟的,跟Handler很像,会把延迟后的时间记录下来,也就是任务的执行时间。
  • 然后,把这个任务带上执行时间加到了mCallbackQueues数组
  • 最后,判断了任务执行时间,如果没有延时就马上执行scheduleFrameLocked方法,否则就通过Handler延时执行任务。

继续看scheduleFrameLocked方法:

代码语言:javascript复制
 private void scheduleFrameLocked(long now) {
        if (!mFrameScheduled) {
            mFrameScheduled = true;
            if (USE_VSync) {
                //开启了VSync
                if (isRunningOnLooperThreadLocked()) {
                    scheduleVSyncLocked();
                } else {
                    Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSync);
                    msg.setAsynchronous(true);
                    mHandler.sendMessageAtFrontOfQueue(msg);
                }
            } else {
             //没有开启VSync
                final long nextFrameTime = Math.max(
                        mLastFrameTimeNanos / TimeUtils.NANOS_PER_MS   sFrameDelay, now);
                
                Message msg = mHandler.obtainMessage(MSG_DO_FRAME);
                msg.setAsynchronous(true);
                mHandler.sendMessageAtTime(msg, nextFrameTime);
            }
        }
    }
  • 如果开启了VSync,会判断当前是否运行在主线程,如果不是主线程就通过Handler切换线程,执行MSG_DO_SCHEDULE_VSync消息。
  • 如果在主线程,就执行scheduleVSyncLocked方法,所以我们猜测MSG_DO_SCHEDULE_VSync消息应该也是执行这个方法。
  • 如果没有开启VSync,就会通过Handler执行MSG_DO_FRAME消息。

到此,我们知道了Choreographer把任务加到数组之后,就开始根据VSync执行了scheduleVSyncLocked方法,所以这个方法自然跟VSync也就是垂直同步信号有关了,继续看看。

Choreographer与VSync信号的关系?每次VSync信号都能接收到吗?

代码语言:javascript复制
    private void scheduleVSyncLocked() {
        mDisplayEventReceiver.scheduleVSync();
    }

    public void scheduleVSync() {
        if (mReceiverPtr == 0) {
        } else {
            nativeScheduleVSync(mReceiverPtr);
        }
    }

    private final class FrameDisplayEventReceiver extends DisplayEventReceiver
            implements Runnable {
        
        @Override
        public void onVSync(long timestampNanos, long physicalDisplayId, int frame) {
         //...
        }

        @Override
        public void run() {
         //...
        }
    }

哎呀,这就没了?

所以绘制任务传到Choreographer之后,最终调用的是native方法nativeScheduleVSync

这个底层方法就不去细究了,它的目的就是注册VSync信号的监听。

nativeScheduleVSync() 会向 SurfaceFlinger 注册VSync信号的监听,VSync信号由 SurfaceFlinger 实 现并定时发送,当VSync信号来的时候就会回调 FrameDisplayEventReceiver#onVSync()

所以,当有绘制任务的时候,Choreographer会去注册VSync信号的监听,注册之后,下一个垂直同步信号到来的时候,才会传到Choreographer中的FrameDisplayEventReceiver#onVSync()方法。

也就是说,不需要绘制的时候,VSync信号是不会发到Choreographer这里的。虽然每隔16.6ms,就有一次VSync信号,但是只有需要绘制的时候,才会去订阅VSync信号,然后才开始真正的绘制工作。

好像剧透了?还没说到绘制工作呢。。

VSync信号到来的时候,Choreographer会怎么操作?

接下来就看看垂直同步信号来的时候,Choreographer做了啥。刚才说到会执行FrameDisplayEventReceiver#onVSync()方法。

代码语言:javascript复制
private final class FrameDisplayEventReceiver extends DisplayEventReceiver
            implements Runnable {

        @Override
        public void onVSync(long timestampNanos, long physicalDisplayId, int frame) {

            Message msg = Message.obtain(mHandler, this);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

        @Override
        public void run() {
            doFrame(mTimestampNanos, mFrame);
        }
    }

这里要注意的是,这里的Runnable是包装成为Handler中的异步消息加到了消息队列中,而这个Handler也是主线程的Handler,待会会细说。

Runnable中的run方法,会执行到doFrame方法,这个doFrame我们就可以理解为这一帧要做的事情。

到这里,VSync信号机制其实就显现出来了,正常情况下,每隔16.6ms系统就会发送一次VSync信号。

  • 如果有绘制任务,就会通过Choreographer类进行下一次信号的监听
  • 当垂直同步信号来了,就会切换到主线程执行doFrame方法开始下一帧数据的绘制,也就是之前所说的CPU工作的开始。
  • 没有任务的情况下,VSync信号就不会发到Choreographer这里。

来个图:

最后看看doFrame方法:

代码语言:javascript复制
void doFrame(long frameTimeNanos, int frame) {
        final long startNanos;
        synchronized (mLock) {
            if (!mFrameScheduled) {
                return; // no work to do
            }
            //VSync信号时间
            long intendedFrameTimeNanos = frameTimeNanos;
            //当前时间
            startNanos = System.nanoTime();
            //时间差
            final long jitterNanos = startNanos - frameTimeNanos;
            //如果当前时间大于Vsync信号时间
            if (jitterNanos >= mFrameIntervalNanos) {
             //就设置这一帧的时间为上一个Vsync信号时间
                final long skippedFrames = jitterNanos / mFrameIntervalNanos;
                final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
                frameTimeNanos = startNanos - lastFrameOffset;
            }

            //如果这一帧时间小于上次帧绘制时间,就重新申请新的VSync信号
            if (frameTimeNanos < mLastFrameTimeNanos) {
                scheduleVsyncLocked();
                return;
            }
        }

        try {
         //依次执行任务
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } 
        
    }

中间的一系列时间判断其实是对时间做校正,暂且按下不表,看看最后执行任务的时候干了啥,也就是doCallbacks方法。

代码语言:javascript复制
 void doCallbacks(int callbackType, long frameTimeNanos) {
        CallbackRecord callbacks;

        callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(now / TimeUtils.NANOS_PER_MS);
        try {
            for (CallbackRecord c = callbacks; c != null; c = c.next) {
                c.run(frameTimeNanos);
            }
        } 
    }

    public void run(long frameTimeNanos) {
            if (token == FRAME_CALLBACK_TOKEN) {
                ((FrameCallback)action).doFrame(frameTimeNanos);
            } else {
                ((Runnable)action).run();
            }
    }

粗略的看下,可以得知其实是执行到任务中action的run方法,这个action很明显就是当初传进来的Runnable,所以最后就是执行到doTraversal方法,开始三大绘制流程。

终于,一个环闭合了,当然中间还有很多细节,我们慢慢看。

Choreographer内部的Handler是干什么的?

刚才说到很多次Choreographer内部的Handler,来看看它是何方神圣:

代码语言:javascript复制
 private Choreographer(Looper looper, int vsyncSource) {
        mHandler = new FrameHandler(looper);
    }


 private final class FrameHandler extends Handler {
        public FrameHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_DO_FRAME:
                 //绘制新的一帧数据
                    doFrame(System.nanoTime(), 0);
                    break;
                case MSG_DO_SCHEDULE_VSYNC:
                 //申请VSYNC信号
                    doScheduleVsync();
                    break;
                case MSG_DO_SCHEDULE_CALLBACK:
                 //延迟预定VSync信号
                    doScheduleCallback(msg.arg1);
                    break;
            }
        }
    }

Choreographer的单例存在于主线程,所以这里的FrameHandler也是主线程的Handler。

所以这里的Handler有两个作用:

  • 1、作为切换到主线程,比如MSG_DO_FRAME消息。
  • 2、延时消息,比如之前说到的延迟预定VSync信号,发的就是MSG_DO_SCHEDULE_CALLBACK消息。

Choreographer中保存任务的数组结构是如何?

还记得刚才添加绘制任务的时候吗?调用了addCallbackLocked方法:

代码语言:javascript复制
mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

进去看看里面的任务处理:

代码语言:javascript复制
    mCallbackQueues = new CallbackQueue[CALLBACK_LAST   1];
    for (int i = 0; i <= CALLBACK_LAST; i  ) {
        mCallbackQueues[i] = new CallbackQueue();
    }

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

也?又是链表嗦。

没错,数组里面存储的是CallbackQueue对象,而CallbackQueue对象里面存储的就是链表。

那数组的序号callbackType又是什么呢?

代码语言:javascript复制
 //输入任务
    public static final int CALLBACK_INPUT = 0;
    //动画任务
    public static final int CALLBACK_ANIMATION = 1;
    //插入更新动画任务
    public static final int CALLBACK_INSETS_ANIMATION = 2;
    //绘制任务
    public static final int CALLBACK_TRAVERSAL = 3;
    //提交任务
    public static final int CALLBACK_COMMIT = 4;

而我们的view树绘制任务传入的就是CALLBACK_TRAVERSAL

到这里,Choreographer中的数组结构我们算是搞清楚了,数组一共有五个对象,分别对应着每个任务类别,而每个对象都存储着一个任务链表,记录了任务的回调和时间。

而最后处理的时候,也是按照链表上元素的时间进行处理:

代码语言:javascript复制
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;
        }

通过遍历,把任务时间大于当前时间的任务都摘除掉,只剩下当前时间之前的所有任务,然后去遍历执行run方法。

其实这里我好奇的一点是,比如绘制任务CALLBACK_TRAVERSAL,明明同一帧时间内,scheduleTraversals只会执行一次。原因刚才说过了,通过mTraversalScheduled字段控制的。

代码语言:javascript复制
void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
        }
    }

那么,为什么这里要用链表结构呢?还遍历链表进行处理。

于是我去全局搜索了CALLBACK_TRAVERSAL任务,结果还真发现了另外的地方也调用了:

代码语言:javascript复制
//DisplayPowerState

    private void scheduleColorFadeDraw() {
        if (!mColorFadeDrawPending) {
            mColorFadeDrawPending = true;
            mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL,
                    mColorFadeDrawRunnable, null);
        }
    }

DisplayPowerState应该是控制电量相关View的。

虽然没有细看,但是说明界面的绘制应该不只是scheduleTraversals一种情况的,有了解的朋友可以来讨论下。

哪些情况会导致掉帧呢(开发角度考虑)?Choreographer又是怎么处理的?

接下来就看看实际会发生掉帧的情况。

1、发起了一次绘制任务,然后会申请VSync信号,当信号来的时候,将doFrame消息加到主线程的消息队列中,这时候主线程正在被其他任务占用,并且迟迟不能结束。**

我们画个图表示下这种情况:

可以看到,在VSync1之前我们就提交了绘制任务,按道理应该是VSync1到来的时候执行doFrame方法,但是被耗时任务耽误了,等到耗时任务执行完已经是VSync3之后的时间了,所以本该VSync2的时间点屏幕就可以显示第二张图片,结果被拖到了VSync4的时间点才显示第二张图片,发生了掉帧情况。

这时候执行onFrame会怎么处理呢?这就涉及到刚才没说到的时间校正了:

代码语言:javascript复制
void doFrame(long frameTimeNanos, int frame) {
        final long startNanos;
        synchronized (mLock) {
            if (!mFrameScheduled) {
                return; // no work to do
            }
            //VSync信号时间
            long intendedFrameTimeNanos = frameTimeNanos;
            //当前时间
            startNanos = System.nanoTime();
            //时间差
            final long jitterNanos = startNanos - frameTimeNanos;
            //如果当前时间大于Vsync信号时间
            if (jitterNanos >= mFrameIntervalNanos) {
             //就设置这一帧的时间为上一个Vsync信号时间
                final long skippedFrames = jitterNanos / mFrameIntervalNanos;
                final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
                frameTimeNanos = startNanos - lastFrameOffset;
            }

            //如果这一帧时间小于上次帧绘制时间,就重新申请新的VSync信号
            if (frameTimeNanos < mLastFrameTimeNanos) {
                scheduleVsyncLocked();
                return;
            }

            mLastFrameTimeNanos = frameTimeNanos;
        }
    }

进入doFrame方法,frameTimeNanos应该为VSync1的时间,而当前时间是在VSync3之后,假设为VSync3 5ms

所以时间差 jitterNanos = 两帧时间 5ms ,上一帧时间偏差 lastFrameOffset = 5ms

而这里做的校正就是把要绘制的这一帧时间frameTimeNanos设置为最近一帧的时间,也就是VSync3的时间,然后赋值给mLastFrameTimeNanos

校正之后,就能保证帧的处理时间与VSync信号的时间是在一个节奏上的,保证帧处理的连贯性。

2、View树绘制的时间过长。

也就是doTraversal方法的执行时间过长,也就是doFrame本身的执行时间过长,再来张图片说明:

正常情况下,doFrame方法应该在一帧时间内,也就是16.6ms内完成绘制工作。但是如果绘制时间过长,跨过了几帧时间,那么新一帧的显示也就延后了,导致了掉帧情况。

这时候Choreographer又是怎么处理的呢?

代码语言:javascript复制
void doFrame(long frameTimeNanos, int frame) {
        //...
        try {
         //依次执行任务
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } 
        
    }

可以看到,doFrame方法中,执行了一系列任务之后,还执行了一个CALLBACK_COMMIT任务,这个任务有点特殊,它包含了对doFrame方法执行完毕的一个处理:

代码语言:javascript复制
void doCallbacks(int callbackType, long frameTimeNanos) {
        CallbackRecord callbacks;
        synchronized (mLock) {
            if (callbackType == Choreographer.CALLBACK_COMMIT) {
                final long jitterNanos = now - frameTimeNanos;
                if (jitterNanos >= 2 * mFrameIntervalNanos) {
                    final long lastFrameOffset = jitterNanos % mFrameIntervalNanos
                              mFrameIntervalNanos;
                    frameTimeNanos = now - lastFrameOffset;
                    mLastFrameTimeNanos = frameTimeNanos;
                }
            }
        }

当任务为CALLBACK_COMMIT的时候,会再次判断当前时间和 绘制帧时间 的时间差,如果大于两个帧时间,就将绘制帧时间移动到 最近一个VSync时间点之前的一个VSync时间点,也就是倒数第二个VSync时间点。

从图中分析下:

从刚才的代码分析我们得知,frameTimeNanos每次会被设置为倒数第一个垂直信号的时间,也就是VSync1时间点。

现在在每次doFrame执行完毕后,如果发现doFrame耗时超过两帧时间,也就是图中黄色耗时,那么就把frameTimeNanos时间设置为倒数第二个垂直信号时间点,也就是VSync2时间点。

这又是为何呢?

还是为了保证帧处理时间的连贯性,同时又保证了帧时间的不重复。

例如在执行doFrame耗时方法的时候(叫他doFrame1方法),VSync3之前又申请了一个绘制任务,那么当VSync3来的时候,就向Handler中加了新的一个doFrame消息,我们叫他doFrame2消息。

  • 如果doFrame1方法执行完,不校正时间,那么帧时间frameTimeNanos还是为VSync1。而doFrame2执行的时候,帧时间为VSync3,就不连贯了。
  • 如果doFrame1方法执行完,校正时间为倒数第一个帧时间,也就是为VSync3。结果doFrame2执行的帧时间也是VSync3,就出现了重复的情况。

所以,考虑连贯性和不重复性,就设置为倒数第二个垂直信号的时间点为最后的帧时间。

可以通过代码统计掉帧情况吗?

可以,用到的是Choreographer的postFrameCallback方法。

代码语言:javascript复制
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
    @Override
    public void doFrame(long frameTimeNanos) {
       long starTime=System.nanoTime();
       Log.e(TAG,"starTime=" starTime ", frameTimeNanos=" frameTimeNanos ", frameDueTime=" (frameTimeNanos-starTime)/1000000);
    }
});

通过Choreographer的回调方法,我们可以获取每一帧的获取时间。我们可以进去看看原理:

代码语言:javascript复制
public void postFrameCallbackDelayed(FrameCallback callback, long delayMillis) {
        postCallbackDelayedInternal(CALLBACK_ANIMATION,
                callback, FRAME_CALLBACK_TOKEN, delayMillis);
    }

    private static final class CallbackRecord {
        public CallbackRecord next;
        public long dueTime;
        public Object action; // Runnable or FrameCallback
        public Object token;

        @UnsupportedAppUsage
        public void run(long frameTimeNanos) {
            if (token == FRAME_CALLBACK_TOKEN) {
                ((FrameCallback)action).doFrame(frameTimeNanos);
            } else {
                ((Runnable)action).run();
            }
        }
    }

同样是发送了一个任务,不同的是token=FRAME_CALLBACK_TOKEN

所以当VSync信号来的时候,会执行回调方法doFrame,我们就可以在这里自己进行帧时间处理了。

总结:Choreographer是什么?

Choreographer到底是什么呢?

  • 是View绘制、动画等界面变动任务的接收和执行者。
  • 是可以对VSync信号进行预约和响应的监听者
  • 是同步VSync信号和绘制工作的编舞者。

参考

https://www.jianshu.com/p/0a54aa33ba7d https://juejin.cn/post/6864365886837686285

感谢大家的阅读,有一起学习的小伙伴可以关注下公众号—码上积木❤️ 每日一个知识点,建立完整体系架构。

0 人点赞