从零开始理解Choreographer

(源码版本:android-29)

为了更好地理解Choreographer,建议先读看下这篇文章:Android的16ms和垂直同步以及三重缓存

说道View的绘制流程,平时用的最多的就是View的onMeasure、onLayout、onDraw三组相关的方法,都2021年了,行业这么卷,相信大家都用的滚瓜烂熟了。让我们深入一点。

debug的时候,在这些方法中打断点可以看到在调用栈中,是ViewRootImpl触发了这些方法。

ViewRootImpl中,持有了View的成员mView,ViewRootImpl对View的直接调用是通过以下方法:

measure方法在performMeasure中:

    private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
        if (mView == null) {
            return;
        }
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
        try {
            mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
    }

而performMeasure方法被measureHierarchy、performTraversal两个方法调用。

layout方法在performLayout方法中被调用:

    private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
            int desiredWindowHeight) {
        mLayoutRequested = false;
        mScrollMayChange = true;
        mInLayout = true;

        final View host = mView;
        if (host == null) {
            return;
        }
        if (DEBUG_ORIENTATION || DEBUG_LAYOUT) {
            Log.v(mTag, "Laying out " + host + " to (" +
                    host.getMeasuredWidth() + ", " + host.getMeasuredHeight() + ")");
        }

        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "layout");
        try {
            host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

            mInLayout = false;
            int numViewsRequestingLayout = mLayoutRequesters.size();
            if (numViewsRequestingLayout > 0) {
                // requestLayout() was called during layout.
                // If no layout-request flags are set on the requesting views, there is no problem.
                // If some requests are still pending, then we need to clear those flags and do
                // a full request/measure/layout pass to handle this situation.
                ArrayList<View> validLayoutRequesters = getValidLayoutRequesters(mLayoutRequesters,
                        false);
                if (validLayoutRequesters != null) {
                    // 省略一些代码
                    host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());
                    // 省略一些代码
                }

            }
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
        mInLayout = false;
    }

可以看到View的layout方法在两个地方被调用,而performLayout方法被performTraversal方法调用。

draw方法在drawSoftware方法中:

            if (DEBUG_DRAW) {
                Context cxt = mView.getContext();
                Log.i(mTag, "Drawing: package:" + cxt.getPackageName() +
                        ", metrics=" + cxt.getResources().getDisplayMetrics() +
                        ", compatibilityInfo=" + cxt.getResources().getCompatibilityInfo());
            }
            canvas.translate(-xoff, -yoff);
            if (mTranslator != null) {
                mTranslator.translateCanvas(canvas);
            }
            canvas.setScreenDensity(scalingRequired ? mNoncompatDensity : 0);

            mView.draw(canvas);

            drawAccessibilityFocusedDrawableIfNeeded(canvas);

查找drawSoftware方法的引用,发现调用顺序为:performDraw->draw->drawSoftware。

 

小结:

三个方法都被performTraversal方法调用,可以看出,performTraversal是View绘制的入口方法

measure除了被入口方法调用外,还被measureHierarchy方法调用,查看measureHierarchy方法的源码,发现有三处逻辑调用,所以,视图的测量是会进行多次的

 

继续,performTraversal被doTraversal调用:

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

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }

            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }

再然后,doTraversal封装成了一个Runnable:

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

VIewRootImpl有一个TraversalRunnable成员:

    final TraversalRunnable mTraversalRunnable = new TraversalRunnable();

 接下来是绘制和取消绘制的方法:

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

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

 我们搜下开始绘制流程方法的引用,有18个,看来很多参数的改变都会是ViewRootImpl开始进行绘制:

我们先歇一会,不往scheduleTraversals的调用者方向撸了,换一个方向,我们注意到如下图:

诶?我们的mTraversalRunnable是我们主要的要执行的任务,上图红框里的是什么东西,为什么要交给它,它是干什么的?

引入今天的主角Choreographer,也是挺有名的,大伙应该经常能听到他的名字,如果说精通View的绘制流程,但如果你不认识他,那真是说不过去。

首先,Choreographer是作为一个成员变量被ViewRootImpl持有:

    Choreographer mChoreographer;

我们再往前撸Choreographer的调用:

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

 然后:

    @TestApi
    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;
            mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

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

上图先把这个action(就是我们之前的绘制Runnable)放到了队列mCallbackQueues里,然后出现了两个分支。

分支a1:如果预期执行的实现早于现在,则执行scheduleFrameLocked方法

分支a2:否则发送一个消息给mHandler。

我们先不管分支a1,先看下mHandler是什么呢:

    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:
                    doScheduleVsync();
                    break;
                case MSG_DO_SCHEDULE_CALLBACK:
                    doScheduleCallback(msg.arg1);
                    break;
            }
        }
    }

mHandler是FrameHandler对象,FrameHandler是Choreographer的内部类,处理了3个消息。

又有分支了,有点懵,没关系,看名字:

  • MSG_DO_SCHEDULE_VSYNC应该是和垂直同步有关,直接调用了doScheduleVsync方法,我们先不管,记作分支b1。
  • MSG_DO_FRAME,一看就是我们要的真正去处理逻辑的方法,也先不管,记作分支b2。
  • MSG_DO_SCHEDULE_CALLBACK,上面传的就是这个,记作分支b3,先看这个:
    void doScheduleCallback(int callbackType) {
        synchronized (mLock) {
            if (!mFrameScheduled) {
                final long now = SystemClock.uptimeMillis();
                if (mCallbackQueues[callbackType].hasDueCallbacksLocked(now)) {
                    scheduleFrameLocked(now);
                }
            }
        }
    }

上图也是调用了scheduleFrameLocked方法(前面的分支a1也是调用这个方法)。

scheduleFrameLocked是干嘛的呢:

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

看上图的分支:

如果没有启动垂直同步,直接发送MSG_DO_FRAME消息到FrameHandler,然后会调用doFrame方法。

对于开启了垂直同步的情况,如果是在主线程,直接调用scheduleVsyncLocked方法,会向一个FrameDisplayEventReceiver对象发送垂直同步消息;

对于开启了垂直同步的情况,如果不在主线程,向FrameHandler发送MSG_DO_SCHEDULE_VSYNC消息,在Handler中会调用doScheduleVsync方法,这就到了前文的b1分支,直接调用doScheduleVsync方法,看doScheduleVsync方法,还是调用的scheduleVsyncLocked方法,只不过加了个锁判断了个状态:

    void doScheduleVsync() {
        synchronized (mLock) {
            if (mFrameScheduled) {
                scheduleVsyncLocked();
            }
        }
    }

scheduleVsyncLocked方法通过FrameDisplayEventReceiver最终还是调用了doFrame方法:

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

        public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
            super(looper, vsyncSource);
        }

        // TODO(b/116025192): physicalDisplayId is ignored because SF only emits VSYNC events for
        // the internal display and DisplayEventReceiver#scheduleVsync only allows requesting VSYNC
        // for the internal display implicitly.
        @Override
        public void onVsync(long timestampNanos, long physicalDisplayId, 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);
            msg.setAsynchronous(true);
            mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

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

最后我们看下doFrame方法:

    @UnsupportedAppUsage
    void doFrame(long frameTimeNanos, int frame) {
        // 省略成吨的代码...

        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);
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);

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

            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {
            AnimationUtils.unlockAnimationClock();
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

        // 省略一些代码
    }

这就是最终boss了。

回顾之前ViewRootImpl,mTraversalRunnable是所有绘制的入口,这个Runnable刚刚被postCallbackDelayedInternal方法放在了队列数组里面。

我们再看看数组:

    private final CallbackQueue[] mCallbackQueues;
    private final class CallbackQueue {
        private CallbackRecord mHead;
        // ...
    }

这个数组包含了一个CallbackRecord成员:

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

这个类有dueTime(预期的执行时间),有action(Runnable),还有一个next,是一个单项链表。

再看mCallbackQueues是用什么作为数组索引呢?

    /**
     * Must be kept in sync with CALLBACK_* ints below, used to index into this array.
     * @hide
     */
    private static final String[] CALLBACK_TRACE_TITLES = {
            "input", "animation", "insets_animation", "traversal", "commit"
    };

    /**
     * Callback type: Input callback.  Runs first.
     * @hide
     */
    public static final int CALLBACK_INPUT = 0;

    /**
     * Callback type: Animation callback.  Runs before {@link #CALLBACK_INSETS_ANIMATION}.
     * @hide
     */
    @TestApi
    public static final int CALLBACK_ANIMATION = 1;

    /**
     * Callback type: Animation callback to handle inset updates. This is separate from
     * {@link #CALLBACK_ANIMATION} as we need to "gather" all inset animation updates via
     * {@link WindowInsetsAnimationController#changeInsets} for multiple ongoing animations but then
     * update the whole view system with a single callback to {@link View#dispatchWindowInsetsAnimationProgress}
     * that contains all the combined updated insets.
     * <p>
     * Both input and animation may change insets, so we need to run this after these callbacks, but
     * before traversals.
     * <p>
     * Runs before traversals.
     * @hide
     */
    public static final int CALLBACK_INSETS_ANIMATION = 2;

    /**
     * Callback type: Traversal callback.  Handles layout and draw.  Runs
     * after all other asynchronous messages have been handled.
     * @hide
     */
    public static final int CALLBACK_TRAVERSAL = 3;

    /**
     * Callback type: Commit callback.  Handles post-draw operations for the frame.
     * Runs after traversal completes.  The {@link #getFrameTime() frame time} reported
     * during this callback may be updated to reflect delays that occurred while
     * traversals were in progress in case heavy layout operations caused some frames
     * to be skipped.  The frame time reported during this callback provides a better
     * estimate of the start time of the frame in which animations (and other updates
     * to the view hierarchy state) actually took effect.
     * @hide
     */
    public static final int CALLBACK_COMMIT = 4;

    private static final int CALLBACK_LAST = CALLBACK_COMMIT;

上图可以看出有4种类型:输入、动画、插画动画(insets_animation这么翻译应该是对的吧?),提交(commit?应该是要在绘制结束后进行一些日志输出、告警什么的)。

回到最终boss--doFrame方法:

可以看到,这些不同类型的队列组成数组mCallbackQueues,最后执行也是按照一定顺序去执行的,input最先执行,动画其次,然后是traversal(就是我们scheduleTraversals方法入队时的类型),最后是commit。

doCallbacks的源码如下,其实就是把指定类型的队列从mCallbackQueues中弹出,然后按照一定条件依次执行这些Runnable:

    void doCallbacks(int callbackType, long frameTimeNanos) {
        CallbackRecord callbacks;
        synchronized (mLock) {
            // We use "now" to determine when callbacks become due because it's possible
            // for earlier processing phases in a frame to post callbacks that should run
            // in a following phase, such as an input event that causes an animation to start.
            final long now = System.nanoTime();
            callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(
                    now / TimeUtils.NANOS_PER_MS);
            if (callbacks == null) {
                return;
            }
            mCallbacksRunning = true;

            // Update the frame time if necessary when committing the frame.
            // We only update the frame time if we are more than 2 frames late reaching
            // the commit phase.  This ensures that the frame time which is observed by the
            // callbacks will always increase from one frame to the next and never repeat.
            // We never want the next frame's starting frame time to end up being less than
            // or equal to the previous frame's commit frame time.  Keep in mind that the
            // next frame has most likely already been scheduled by now so we play it
            // safe by ensuring the commit time is always at least one frame behind.
            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));
                }
                c.run(frameTimeNanos);
            }
        } finally {
            synchronized (mLock) {
                mCallbacksRunning = false;
                do {
                    final CallbackRecord next = callbacks.next;
                    recycleCallbackLocked(callbacks);
                    callbacks = next;
                } while (callbacks != null);
            }
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
    }

在最后,我们的收获,先从总体上概括:

  • View的绘制流程由ViewRootImpl发起很多,ViewRootImpl持有了一个Choreographer,ViewRootImpl很多public方法都会触发performTraveral方法,performTraveral是绘制的入口方法
  • Choreographer英文是编排者的意思,这里负责收集ViewRootImpl传过来的行为,而具体的执行时间则由Choreographer来控制
  • Choreographer持有一个数组,数组的每个元素是一个行为单向链表队列。链表节点包含目标执行时间,Choreographer可以由此判断行为需要在哪一帧去执行并按链表顺序执行。
  • Choreographer要处理垂直同步信号,也可以处理animation、inset-animation。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Choreographer::postCallbackDelayedInternal 是 Android 系统中一个非常重要的方法,用于将一个 Runnable 对象投递给 Choreographer ,以便在下一次 VSync 信号到来时执行该 Runnable 对象。该方法源码如下: ```java private void postCallbackDelayedInternal(int callbackType, Runnable action, Object token, long delayMillis) { synchronized (mLock) { // 如果 Choreographer 已经停止工作,则直接返回 if (mCallbacks == null) { return; } final long now = SystemClock.uptimeMillis(); final long when = now + delayMillis; // 将任务封装成 ChoreographerCallback 对象 final CallbackRecord callbacks = obtainCallbackLocked(callbackType, action, token, when); // 将 ChoreographerCallback 对象添加到任务队列中 addCallbackLocked(when, callbacks); // 如果任务队列中的任务数量超过了阈值,则向 Choreographer 发送消息 if (mFrameScheduled || (mFrameScheduled = mLooper.getQueue().isPolling())) { mTraversalScheduled = false; mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); } else if (!mTraversalScheduled) { mTraversalScheduled = true; // 如果任务队列中的任务数量未超过阈值,则将提交遍历任务的操作延迟一段时间 mChoreographer.postCallbackDelayed(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null, FRAME_TIMEOUT_MS - delayMillis); } } } ``` 该方法主要有以下几个步骤: 1. 判断 Choreographer 是否已经停止工作,如果已经停止,则直接返回。 2. 将任务封装成 ChoreographerCallback 对象。 3. 将 ChoreographerCallback 对象添加到任务队列中。 4. 判断任务队列中的任务数量是否超过阈值,如果超过则向 Choreographer 发送消息,否则将提交遍历任务的操作延迟一段时间。 其中,步骤 2 和步骤 3 的实现比较简单,这里不再赘述,主要介绍一下步骤 4。 在 Android 系统中,Choreographer 是一个用于协调应用程序 UI 绘制和动画的系统组件,它的主要作用是通过 VSync 信号来同步应用程序的 UI 绘制和动画,保证帧率的稳定性和流畅性。Choreographer 在初始化时会创建一个 Handler 对象,并且在 Handler 中注册了一个消息回调函数,当 Handler 接收到消息时,就会执行该消息回调函数。 在 Choreographer 中,有两个回调函数,一个是 CALLBACK_TRAVERSAL,另一个是 CALLBACK_COMMIT。其中 CALLBACK_TRAVERSAL 用于执行应用程序的 UI 绘制操作,CALLBACK_COMMIT 用于执行应用程序的动画操作。Choreographer 会根据任务队列中的任务类型来决定将任务添加到哪个回调函数中。 在步骤 4 中,如果任务队列中的任务数量超过了阈值,Choreographer 就会向 Handler 发送 CALLBACK_TRAVERSAL 消息,并执行 CALLBACK_TRAVERSAL 回调函数中的任务。如果任务队列中的任务数量未超过阈值,Choreographer 就会将提交遍历任务的操作延迟一段时间,并延迟执行 CALLBACK_TRAVERSAL 回调函数中的任务。这样做的目的是为了尽量保证应用程序的帧率稳定性和流畅性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值