Android的消息机制概述

提到消息机制应该都不陌生,在日常开发过程中不可避免的会涉及到这方面的内容。从开发的角度来说,Handler是Android消息机制的上层接口,这使得只需要和Handler交互即可,通过Handler可以将一个任务切换到Handler所在线程中去执行。Handler最常用的场景就是更新UI。

Handler运行的需要底层的MessageQueue和Looper的支撑。MessageQueue是消息队列,其内部存储了一组消息,以队列的形式对外提供插入和删除的工作。虽然是消息队列,但是它的内存并不是真正的队列,而是用一个单链表来存储消息列表。Looper可以理解为是一个消息循环,由于MessageQueue只是一个消息的存储单元,它不能去处理消息,而Looper则会以无限循环的形式去查找是否有新消息,如果有的话则处理,否则则等待。Handler之所以能够用于线程切换,还涉及到一个特殊概念,那就是ThreadLocal,ThreadLocal并不是一个线程,它的作用是可以在每个线程中存储数据,Handler内部就是通过ThreadLocal来获取Looper的。ThreadLocal可以在不同的线程中互不干扰的存储并提供数据,通过它可以获取每个线程的Looper。线程默认是没有Looper的,如果需要使用Handler就必须为线程创建Looper。UI线程中的Looper就是在ActivityThread的main函数中创建的,这就是主线程默认可以使用Handler的原因。

实际上Handler、MessageQueue和Looper是一个整体,Handler的主要作用是将一个任务切换到某个指定的线程中去执行,之所以有这个功能,主要是因为Android规定访问UI只能在主线程中进行,如果在非主线程中,则会抛出异常。ViewRootImpl对UI操作做了验证,如下所示。

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

由于这点限制,导致必须在主线程中执行,但是Android又建议不要再主线程执行耗时操作,否则会出现ANR,所以这个时候耗时任务就必须在子线程中执行了,如果没有handler的话,这个时候如果想更新UI就无法进行了。除此之外,Android的UI控件不是线程安全的,如果在多线程中并发访问可能会导致UI控件处于不可预期的状态,那为什么不加锁呢?加锁的话会导致UI访问得逻辑变得复杂,还会降低UI的访问效率,因为锁机制会阻塞某些线程额执行。综合起来,最简单高效的方式就是采用单线程模型来处理UI操作,只需要用Handler切换一下UI访问得执行线程即可。

Handler创建时会采用当前线程的Looper来构建内部消息循环系统,如果没有Looper的话,则会抛出异常:

public Handler(Callback callback, boolean async) {
    if (FIND_POTENTIAL_LEAKS) {
        final Class<? extends Handler> klass = getClass();
        if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
                (klass.getModifiers() & Modifier.STATIC) == 0) {
            Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
                klass.getCanonicalName());
        }
    }

    mLooper = Looper.myLooper();
    if (mLooper == null) {
        throw new RuntimeException(
            "Can't create handler inside thread " + Thread.currentThread()
                    + " that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}
public static @Nullable Looper myLooper() {
    return sThreadLocal.get();
}
private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

可以看到,在线程中调用Looper.prepare方法的时候,会创建Looper实例,而且每个线程只能存在一个,然后存储到ThreadLocal中,Looper.myLooper就是从ThreadLocal中获取存储的Looper。

Handler创建完毕后,就可以和Looper、MessageQueue一起协同工作了,然后通过Handler的post方法或者send方法发送消息到MessageQueue中,在Looper循环中处理。其实post方法最终调用的也是send方法

public final boolean post(Runnable r)
{
   return  sendMessageDelayed(getPostMessage(r), 0);
}
private static Message getPostMessage(Runnable r) {
    Message m = Message.obtain();
    m.callback = r;
    return m;
}

当Handler的send方法被调用时,它会调用MessageQueue的enqueueMessage方法将这个消息放入到消息队列中,然后Looper发现有新消息,就会处理这个消息,最终消息中的Runnable或者Handler的handleMessage方法被调用。Looper是运行在创建Handler所在线程中的,这从前面的代码中可以看出来,这样一来,Handler中的业务逻辑就被切换到创建Handler的线程中。

上图展示的就是Handler消息机制的一个基本流程。

为了更好的理解Looper的工作原理,需要对ThreadLocal进行深入的理解。ThreadLocal是一个线程内部的数据存储类,数据存储以后,只有在指定线程中可以获取到存储的数据,对于其他线程来说则无法获取到。一般来说当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以采用ThreadLocal。比如说Handler,它需要获取当前线程的Looper,很显然Looper的作用是当前线程,且每个线程具有不同的Looper,这个时候通过ThreadLocal就可以轻松的实现Looper在线程中的存取。如果不采用TheadLocal,那么系统就必须提供一个全局的HashTable供handler查找指定线程的Looper。

另外一个使用常见是复杂逻辑下的对象传递,比如监听器的传递,有些时候一个线程中的任务过于复杂,这可能表现为函数调用栈比较深以及代码入口的多样性,在这种情况下,我们又需要监听器能贯穿怎个执行过程,这种情况下就可以采用ThreadLocal,它可以让监听器作为线程内的全局对象而存在,在线程内部只要通过get方法就可以获取到监听器。这么讲太过抽象,下面举例说明:

public class ThreadLocalActivity extends AppCompatActivity {
    private String TAG = getClass().getSimpleName();

    private ThreadLocal<Boolean> mBooleanThreadLocal = new ThreadLocal<>();

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBooleanThreadLocal.set(true);
        Log.e( TAG , Thread.currentThread().getName() + " mBooleanThreadLocal = " + mBooleanThreadLocal.get() );
        new Thread(new Runnable() {
            @Override
            public void run() {
                mBooleanThreadLocal.set(false);
                Log.e( TAG , Thread.currentThread().getName() + " mBooleanThreadLocal = " + mBooleanThreadLocal.get() );
            }
        }, "[#Thread1]").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                Log.e( TAG , Thread.currentThread().getName() + " mBooleanThreadLocal = " + mBooleanThreadLocal.get() );
            }
        }, "[#Thread2]").start();
    }
}

在上面的代码中,在主线程中设置mBooleanThreadLocal为true ,在Thread1中设置为false,在Thread2中没有设置,分别get它的值,这个时候分别获取到的值应该是true , false , null:

E/ThreadLocalActivity: main mBooleanThreadLocal = true
E/ThreadLocalActivity: [#Thread1] mBooleanThreadLocal = false
E/ThreadLocalActivity: [#Thread2] mBooleanThreadLocal = null

 从日志可以分析出,虽然在不同的线程中访问得是同一个ThreadLocal对象,但是获取到的值确不一样,这就是TreadLocal神奇的地方。之所以会这样,是因为不同的线程访问同一个get方法,ThreadLocal内部会从各自的线程中取出一个数组,然后从数组中根据当前ThreadLocal的索引去查找对应的值。很显然不同的线程的这个数组是不一样的,这就是为什么通过ThreadLocal可以在不同线程中维护一套数据的副本,且互不干扰。可以看下ThreadLocal的set方法:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

在set方法中,首先获取当前线程的专门存储ThreadLocal数据的threadLocals, 如果数据为空则会初始化,threadLocals的类型是ThreadLocalMap,它内部维护了个Entry[] table,ThreadLocal的数据就存储在这个table中,下面是ThreadLocalMap的set方法:

/**
 * Set the value associated with key.
 *
 * @param key the thread local object
 * @param value the value to be set
 */
private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

下面看下ThreadLcoal的get方法:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

 可以看出ThreadLocal的get方法与set方法一样,也是先取出当前线程的threadLocals,如果为空就返回初始值,默认为null

protected T initialValue() {
    return null;
}

如果threadLocals不为空,则取出table数组并获取对应的值 。

从ThreadLocal的set和get方法可以看出,他们所操作的对象都是当前线程的localThreads对象的table数组,因此在不同线程中访问一个ThreadLocal的set和get方法,它们对ThreadLocal所做的读写操作仅限于各自线程的内部,这就是为什么ThreadLocal可以在多个线程中互不干扰地存储和修改数据。

消息队列在Android中指的是MessageQueue,MessageQueue主要包含两个操作:插入和读取。读取操作本身会伴随着删除操作,插入和读取对应的方法分别为enqueueMessage和next,其中enqueueMessage的作用是往消息队列中插入一条消息,而next的作用是从消息队列中取出一条消息并将其从消息队列中删除。尽管MessageQueue是消息队列,但是它的内部实现并不是用的队列,实际上它是通过一个单链表的数据结构来维护消息列表,单链表在插入和删除上比较有优势。看下它们的源码:

boolean enqueueMessage(Message msg, long when) {
    if (msg.target == null) {
        throw new IllegalArgumentException("Message must have a target.");
    }
    if (msg.isInUse()) {
        throw new IllegalStateException(msg + " This message is already in use.");
    }

    synchronized (this) {
        if (mQuitting) {
            IllegalStateException e = new IllegalStateException(
                    msg.target + " sending message to a Handler on a dead thread");
            Log.w(TAG, e.getMessage(), e);
            msg.recycle();
            return false;
        }

        msg.markInUse();
        msg.when = when;
        Message p = mMessages;
        boolean needWake;
        if (p == null || when == 0 || when < p.when) {
            // New head, wake up the event queue if blocked.
            msg.next = p;
            mMessages = msg;
            needWake = mBlocked;
        } else {
            // Inserted within the middle of the queue.  Usually we don't have to wake
            // up the event queue unless there is a barrier at the head of the queue
            // and the message is the earliest asynchronous message in the queue.
            needWake = mBlocked && p.target == null && msg.isAsynchronous();
            Message prev;
            for (;;) {
                prev = p;
                p = p.next;
                if (p == null || when < p.when) {
                    break;
                }
                if (needWake && p.isAsynchronous()) {
                    needWake = false;
                }
            }
            msg.next = p; // invariant: p == prev.next
            prev.next = msg;
        }

        // We can assume mPtr != 0 because mQuitting is false.
        if (needWake) {
            nativeWake(mPtr);
        }
    }
    return true;
}
Message next() {
    // Return here if the message loop has already quit and been disposed.
    // This can happen if the application tries to restart a looper after quit
    // which is not supported.
    final long ptr = mPtr;
    if (ptr == 0) {
        return null;
    }

    int pendingIdleHandlerCount = -1; // -1 only during first iteration
    int nextPollTimeoutMillis = 0;
    for (;;) {
        if (nextPollTimeoutMillis != 0) {
            Binder.flushPendingCommands();
        }

        nativePollOnce(ptr, nextPollTimeoutMillis);

        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) {
                // Stalled by a barrier.  Find the next asynchronous message in the queue.
                do {
                    prevMsg = msg;
                    msg = msg.next;
                } while (msg != null && !msg.isAsynchronous());
            }
            if (msg != null) {
                if (now < msg.when) {
                    // Next message is not ready.  Set a timeout to wake up when it is ready.
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    // Got a message.
                    mBlocked = false;
                    if (prevMsg != null) {
                        prevMsg.next = msg.next;
                    } else {
                        mMessages = msg.next;
                    }
                    msg.next = null;
                    if (DEBUG) Log.v(TAG, "Returning message: " + msg);
                    msg.markInUse();
                    return msg;
                }
            } else {
                // No more messages.
                nextPollTimeoutMillis = -1;
            }

            // Process the quit message now that all pending messages have been handled.
            if (mQuitting) {
                dispose();
                return null;
            }

            // If first time idle, then get the number of idlers to run.
            // Idle handles only run if the queue is empty or if the first message
            // in the queue (possibly a barrier) is due to be handled in the future.
            if (pendingIdleHandlerCount < 0
                    && (mMessages == null || now < mMessages.when)) {
                pendingIdleHandlerCount = mIdleHandlers.size();
            }
            if (pendingIdleHandlerCount <= 0) {
                // No idle handlers to run.  Loop and wait some more.
                mBlocked = true;
                continue;
            }

            if (mPendingIdleHandlers == null) {
                mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
            }
            mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
        }

        // Run the idle handlers.
        // We only ever reach this code block during the first iteration.
        for (int i = 0; i < pendingIdleHandlerCount; i++) {
            final IdleHandler idler = mPendingIdleHandlers[i];
            mPendingIdleHandlers[i] = null; // release the reference to the handler

            boolean keep = false;
            try {
                keep = idler.queueIdle();
            } catch (Throwable t) {
                Log.wtf(TAG, "IdleHandler threw exception", t);
            }

            if (!keep) {
                synchronized (this) {
                    mIdleHandlers.remove(idler);
                }
            }
        }

        // Reset the idle handler count to 0 so we do not run them again.
        pendingIdleHandlerCount = 0;

        // While calling an idle handler, a new message could have been delivered
        // so go back and look again for a pending message without waiting.
        nextPollTimeoutMillis = 0;
    }
}

从enqueueMessage的实现来看,它的主要操作其实单链表的插入操作, 而next方法是一个无限循环的方法,如果消息队列中没有消息,那么next方法一直会阻塞。当next方法会返回这条消息并将其从单链表中移除。

Looper在Android消息机制中扮演者消息循环的角色,它会不停地从MessageQueue中查看是否有新消息,如果有消息就会立刻处理,否则立即阻塞。看下它的构造方法,在构造方法中会创建消息队列

private Looper(boolean quitAllowed) {
    mQueue = new MessageQueue(quitAllowed);
    mThread = Thread.currentThread();
}

 那么Looper是怎么跟Handler建立联系的呢,看下通常的用法:

  new Thread(new Runnable() {
                @Override
                public void run() {
                    Looper.prepare();
                    Handler handler = new Handler();
                    Looper.loop();
                }
            }).start();

private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

 Looper.prepare()中会创建Looper实例并保存在ThreadLocal中,属于当前线程。在看下Handler的构造方法:

public Handler(Callback callback, boolean async) {
    if (FIND_POTENTIAL_LEAKS) {
        final Class<? extends Handler> klass = getClass();
        if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
                (klass.getModifiers() & Modifier.STATIC) == 0) {
            Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
                klass.getCanonicalName());
        }
    }

    mLooper = Looper.myLooper();
    if (mLooper == null) {
        throw new RuntimeException(
            "Can't create handler inside thread " + Thread.currentThread()
                    + " that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}
public static @Nullable Looper myLooper() {
    return sThreadLocal.get();
}

 handler在构造的时候通过Looper.myLooper获取Looper,, 而就是从ThreadLocal获取的当前线程的looper实例。Looper最重要的方法就是loop(),只有调用了loop()方法后,消息循环才真正的起作用。

public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    final MessageQueue queue = me.mQueue;

    // Make sure the identity of this thread is that of the local process,
    // and keep track of what that identity token actually is.
    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();

    // Allow overriding a threshold with a system prop. e.g.
    // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
    final int thresholdOverride =
            SystemProperties.getInt("log.looper."
                    + Process.myUid() + "."
                    + Thread.currentThread().getName()
                    + ".slow", 0);

    boolean slowDeliveryDetected = false;

    for (;;) {
        Message msg = queue.next(); // might block
        if (msg == null) {
            // No message indicates that the message queue is quitting.
            return;
        }

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

        final long traceTag = me.mTraceTag;
        long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
        long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
        if (thresholdOverride > 0) {
            slowDispatchThresholdMs = thresholdOverride;
            slowDeliveryThresholdMs = thresholdOverride;
        }
        final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
        final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

        final boolean needStartTime = logSlowDelivery || logSlowDispatch;
        final boolean needEndTime = logSlowDispatch;

        if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
            Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
        }

        final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
        final long dispatchEnd;
        try {
            msg.target.dispatchMessage(msg);
            dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
        } finally {
            if (traceTag != 0) {
                Trace.traceEnd(traceTag);
            }
        }
        if (logSlowDelivery) {
            if (slowDeliveryDetected) {
                if ((dispatchStart - msg.when) <= 10) {
                    Slog.w(TAG, "Drained");
                    slowDeliveryDetected = false;
                }
            } else {
                if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
                        msg)) {
                    // Once we write a slow delivery log, suppress until the queue drains.
                    slowDeliveryDetected = true;
                }
            }
        }
        if (logSlowDispatch) {
            showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
        }

        if (logging != null) {
            logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
        }

        // Make sure that during the course of dispatching the
        // identity of the thread wasn't corrupted.
        final long newIdent = Binder.clearCallingIdentity();
        if (ident != newIdent) {
            Log.wtf(TAG, "Thread identity changed from 0x"
                    + Long.toHexString(ident) + " to 0x"
                    + Long.toHexString(newIdent) + " while dispatching to "
                    + msg.target.getClass().getName() + " "
                    + msg.callback + " what=" + msg.what);
        }

        msg.recycleUnchecked();
    }
} 

Looper也是可以退出的,它提供了quit和quitSafely方法来退出一个Looper,二者的区别是:quit会直接退出Looper,而quitSafely只是设定了一个退出标记,然后把消息队列中的已有消息处理完毕后才安全退出。Looper退出后,通过Handler发送消息会失败,send方法会返回false。在子线程中,如果手动创建了Looper,那么在所有的事情完成以后应该调用quit方法来终止消息循环,否则这个线程一直处于等待的状态,而退出Looper以后,这个线程就会立刻终止,因此建议不需要的时候终止Looper。loop方法是一个死循环,跳出循环的唯一方法就是MessageQueue的next方法返回的null,调用quit或quitSafely方法标记为退出状态后,MessageQueue的next方法就会返回null。如果next方法返回了消息,Looper就会处理这条消息,msg.target.dispatchMessage,这里的msg.target就是发送消息的Handler,这样消息最终还是交给了发送消息的Handler的dispatchMessage方法处理,而这个方法是在Looper中执行的,这样就成功做到了线程切换。

Handler通过post和send等一系列方法发送消息,其实发送消息仅是像MessageQueue队列中插入一条消息。MessageQueue的next方法,会返回这条消息给Looper处理,最终消息交由Handler处理,即Handler的dispatchMessage方法会被调用,此时进入了消息处理阶段:

/**
 * Handle system messages here.
 */
public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

 处理消息过程中,检查是否msg.callback为空,这个callback其实就是post方法中的Runnable参数,最终调用的还是它的run方法。

private static void handleCallback(Message message) {
    message.callback.run();
}

其次检查mCallback为空,不为空则调用mCallBack的handleMessage方法,提供这么个Callback,主要是创建Handler的时候可以不用派生Handler的子类,提供另外一种使用Handler的方式。最后调用Handler的handleMessage来处理消息。整个处理过程可以总结如下:

在使用构造Handler的时候,可能会提示Can't create handler inside thread that has not called Looper.prepare(),我们看下它的构造函数:

public Handler(Callback callback, boolean async) {
    if (FIND_POTENTIAL_LEAKS) {
        final Class<? extends Handler> klass = getClass();
        if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
                (klass.getModifiers() & Modifier.STATIC) == 0) {
            Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
                klass.getCanonicalName());
        }
    }

    mLooper = Looper.myLooper();
    if (mLooper == null) {
        throw new RuntimeException(
            "Can't create handler inside thread " + Thread.currentThread()
                    + " that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}

 很明显,当前线程没有Looper的时候会抛出上述异常。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

释汐宇辰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值