Android消息机制

一、介绍

(1)Message:

首先需要知道的是Android程序是由事件来驱动的,事件的具体形式,也就是可以代表一个事件的数据结构,就是Messager,它可以携带的信息包括了事件类型,事件发生的时间,事件描述,还可以携带额外的数据,可以设置处理事件的回调方法等,它的元素就这样代表了一件需要Android程序处理的具体事件。

(2)MessageQueue:

首先需要知道的是这是一个队列数据结构,先进先出,而且是一个链表,从哪里看出呢?Meseager中有一个指向Meseager 类型的next成员变量,这很明确了吧,MessageQueue就是一个节点为Message的单向链表。总的来说,就是当一个事件传递到Android程序中的时候,这个事件会先添加到MessageQueue这个事件队列中,之后程序再从MessageQueue中一个一个地取出Message来处理事件。当然,先添加的先处理。

(3)Looper:

上面说到,App程序会从MessageQueue中取出Message来处理事件,那么它是怎么取的呢?就是通过Looper来取的,总的来说,Looper就是这个MeseagerQueue的轮询机制,通过Loop不断的死循环地从meassageQueue中取出事件,说到这里,读者或许会有疑问了,死循环?我去,这家伙不就卡死线程了吗?别急,这个东西后面再展开说。

(4)Handler:

这个东西可以认为是事件的总指挥,我们可以通过它来发送事件给程序来处理,当程序需要处理Message事件的时候,也是通过它来处理事件的,具体的后面再展开讲解。

二、轮询

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

        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
            Printer logging = me.mLogging;
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }

            msg.target.dispatchMessage(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();
        }
    }

看一些关键的代码。首先

final Looper me = myLooper();

变量me指向了当前线程的loop,之后

final MessageQueue queue = me.mQueue;

通过当前线程的loop对象,拿到了当前线程中的事件队列,并用一个MessageQueue类型的变量指向了它。
接下来是一个for死循环,说明loop内部是在不停的执行取出事件的操作的。

Message msg = queue.next(); // might block

在循环里面,调用了queue.next()方法来取出消息队列分钟下一个需要处理的事件,之后有这么一句代码

msg.target.dispatchMessage(msg);

target变量是一个Handler类型的变量,它就是这个事件对应的句柄,然后调用了这个Handler对象的dispatchMessage方法来让事件被Handler处理掉

这里关键的点是queue.next(); // might block,它是轮询过程中第一个关键点,进一步看一下它

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

先看一下这个方法里很关键的一些点,首先看Message msg = mMessages;,这里出现了一个全局变量mMessages,这个变量十分关键,消息队列是一个链表结构的队列,而这个mMessages就是这个链表的头结点,简而言之就是说这个mMessages变量指向的Messager对象,就是这个线程下一个需要处理的事件。

nativePollOnce(ptr, nextPollTimeoutMillis);这句代码是调用了native层的方法,这个方法十分关键,但是我们后面再看它。先看synchronized同步代码块里做了什么事情。

先看这一段代码

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

跟着代码走,now是一个long类型,它的值是系统当前的时间,判断当前系统时间和下一个需要被处理的事件的触发时间,(1)如果当前事件还没有达到下一个事件被触发的事件,那么把当前时间和下一个需要被处理的事件的触发时间之间的时间差赋值给nextPollTimeoutMillis变量,然后再回到循环中,调用nativePollOnce(ptr,nextPollTimeoutMillis);方法。(2)否则,也就是当前时间已经达到下一个事件被触发的时间,先把mBlocked = false;,这个变量的含义是表明是否需要被阻塞,这个把它赋值为false,表明不再需要别阻塞。并且把msg返回,前面我们知道msg其实就是成员变量mMessage,也就是说这个轮询的过程把mMessage返回回去进行处理了,这里再次验证了mMessager是消息队列的头节点,也就是说mMessage变量指向的Message对象,就是消息队列中下一个等待被执行的事件。

到这里为止java层的轮询逻辑基本已经介绍完了,如果细心的话其实这里应该可以看出这里可能存在一个问题,就是这里是个for死循环,我们知道如果一个死循环里不停的做逻辑操作的话,那么会造成这个线程抢占了大量的计算机资源,会造成设备卡顿。显然,Android系统里是不可能存在这样的问题的,那么Android是怎么解决的呢,关键就在上面的那个nativePollOnce(ptr,nextPollTimeoutMillis);方法里。这个方法将会进入到JNI层里面,通过C++代码来解决这个问题。

后面不再展示代码了,展示代码调用链其实是没有什么意义的,我们只需要了解它的设计思想和关键的逻辑思想即可,当你了解了它的设计思想和关键逻辑之后,再去理解代码就是水到渠成的事情,和看图填字差不多了。

在JNI层中,创建了一个管道pipe,然后用两个文件描述符fd分别指向了读端和写端的句柄,但是,这个管道并不是用来传输关键数据的,它只是用来阻塞线程,节省系统资源用的,其实当写端往管道中写入数据的时候,只是写入了一个字节的数据,具体是怎么做的呢,接着往下看。

只有管道还不够,因为如果你又使用一个死循环来不停的读取读端数据,那么显然的又造成了一个死循环,这样和节省系统资源的目的相违背了,这时候,就需要用到一个关键的东西——epoll,它可以同时监听多个文件描述符的变化,当被监听的文件描述符都没有发生变化时,也就是epoll正在执行监听的时候,它会把当前线程给阻塞掉。当被监听的文件描述符发生变化时,也就是说当写端往管道中写出数据之后,epoll会唤醒当前线程,以便线程可以对数据进行处理,当然,这里其实主要利用的是epoll的可以阻塞和唤醒线程的特性。同时需要注意的是,当写端往管道中写入数据以唤醒线程的时候,它其实只写入了一个字节的数据,然后当线程唤醒之后,读端会把这一个字节的数据从管道中读取出来,并且没有对这个字节的数据做任何处理,不要忘了上面说的,真正需要被处理的事件是Loop中mMessage这个成员变量指向的事件对象。

这里可以引入另外一个问题,系统利用了epoll阻塞了线程,那么阻塞是什么一个概念呢?要解释这个问题,首先需要知道的是linux对系统里的多个线程是采用了调度算法来排序执行的,也就是说,linux内部一般都会同时存在着多个线程,其实这些所有的线程并不是同时被运行的,linux内核会给需要被执行的线程轮流分配被执行的时间片,举个简单的例子,比如现在有ABCDE这5个线程需要被执行,假设CPU只有一个核心,那么5个线程就是先执行A,执行一个时间片的时间,然后再执行线程B一个时间片,接下来再执行线程C一个时间片,一直到D,E,然后再回来执行A,一直如此循环,这是这些多个任务间的切换非常迅速,所以看起来系统内的多个任务是同时被执行的。当然,时间片的分配远不止那么简单,内核会考虑线程的优先级(权重数值越小,优先级越高)等因素来计算出时间片,当然一般来说,优先级越高的获得的时间片就越多,然后根据时间片来分配CPU资源,比如,有ABC三个线程,A线程分配了100个时间片,B线程分配了110个时间片,C线程分配了50个时间片,那么他们的执行顺序是,B线程先执行10个时间片,这时候B和A都只剩下100个时间片,然后B和A轮流执行一个时间片,当B和A的时间片被执行到只剩下50个的时候,ABC都是只剩下50个时间片,这时候ABC这三个线程才开始轮流被执行时间片。
介绍了内核中任务调度和时间片的概念之后,当线程被阻塞的时候,线程会告诉系统内核,我暂时不再需要CPU资源了,这样内核就可以把它原来占用的CPU资源用来执行其他任务,其中这又涉及到了用户态和内核态的切换。一般来说,系统中运行的进程和线程是应用所有的,所以为了系统安全,不可能随便给以这些进程线程操作系统内部的权限的,这种由用户创建的进程线程被称为用户态,而当线程需要被阻塞和唤醒的时候,这跟系统内部的任务调度有关,所以这个过程中,会需要从用户态切换到内核态中,只有内核态才有权利去修改系统内部,其实如果频繁的在用户态和内核态中切换的话,这也是一笔很大的系统资源开销,所以,除了监听读端这种由外部唤醒的方式外,当消息队列内部的待处理事件达到设定的被处理时间时,需要有内部唤醒的机制,显然的内部唤醒也不可能采用死循环的方式,即使可以采用间隙地阻塞唤醒的方式来一定程度减少系统资源开销,但是这种死循环的方式也只是治标不治本罢了,而且别忘了,频繁的从用户态切换到内核态再切换到用户态也是一笔不小的开销。还记得上面提到的nativePollOnce(ptr,nextPollTimeoutMillis);这个方法吗,里面有一个参数nextPollTimeoutMillis,这个参数就是为了解决这个问题的,在使用epoll监听的时候,会传入一个long型参数,这个参数的值就是nextPollTimeoutMillis,当nextPollTimeoutMillis的时间过去后,即使被监听的读端文件描述符没有发生变化,线程也会从阻塞状态中唤醒,上面说过,nextPollTimeoutMillis的值就是当前时间距离下一个需要被处理的事件的执行时间的时间差,这样,总结一下,消息队列机制使用了管道和epoll来阻塞线程,以便线程不需要空转来占用CPU资源,而唤醒的时候会分两种情况,一是当外部唤醒时,可以向管道的读端随便写入数据(不一定只能是一个字节,读端会一次性把管道内的数据全部读取出来,当然,写端写数据的时候就只写了一个字节),这时候epoll监听到读端变化,就会唤醒线程。二是当内部唤醒时,也是就没有外部因素通过写端向管道写入数据时,因为epoll在设置监听wait的时候,传入了一个nextPollTimeoutMillis的long型参数,所以epoll会在nextPollTimeoutMillis毫秒后自动唤醒线程,即使这时候读端并没有监听到数据。

说了那么多,总结一下轮询过程。首先,Looper中有一个死循环不断的从消息队列中取Messager,消息队列是一个链表,Looper持有这个这个链表的头节点,由成员变量mMessager指向它,每次循环完成后会处理掉头节点的Messager,头节点再指向下一个节点,其次,死循环为什么不会造成线程大量占用CPU,因为死循环里会调用到一个系统机制让其阻塞住,阻塞是什么意思?为什么可以节省CPU资源?看上文。。。阻塞对应着唤醒机制,什么时候唤醒呢?有几种情况可以唤醒,(1)当阻塞的预设时间达到后,自动唤醒线程。(2)当往管道中(结合epoll)写入数据时,会唤醒线程。线程唤醒后,则会去处理mMessager指向的事件对象,也就是消息队列中的头节点。那么这里有一个问题点上面我没提到的,消息队列是一个链表,那么它是怎么入队的呢?队列里的顺序是怎么确定的呢?请看下一节。

三、事件入队

常用的两个事件入队接口(1)Handler.post(Runnable r);(2)Handler.post(Message msg);
这两个接口最终会调用到Handler.enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis);
区别在于post中参入的是一个Runnable,Handler会用一个Message对象把将其包装起来,代码如下:

    private static Message getPostMessage(Runnable r) {
        Message m = Message.obtain();
        m.callback = r;
        return m;
    }
    private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
        msg.target = this;
        if (mAsynchronous) {
            msg.setAsynchronous(true);
        }
        return queue.enqueueMessage(msg, uptimeMillis);
    }

queue是从Looper中获取到的消息队列,从这里把消息传入消息队列中,然后来到MessageQueue中看它的boolean enqueueMessage(Message msg, long when)方法

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

以下是关键代码,一句一句来看一下都做了什么事情,直接写在代码注释里比较直观。

            //事件的触发时间,用来说明这个事件希望多久以后才会处理的,还记得post中可以选择传入一个long型参///数吗,一般可以用来做延时定时操作,
            //这个时间代表的是真实流逝时间,不是手机时间,从MessageQueue的next方法中也可以看出,从now变量和
            //msg.when来比较的,再看now变量,是通过final long now = SystemClock.uptimeMillis();这句话获取的
            //这就说明了是用真实流逝时间来做时间的参照系的
            msg.when = when;

            //还记得mMessages吗?消息队列的头节点,这里创建一个引用指向它
            Message p = mMessages;

            //这个标志是记录是否需要马上唤醒线程的
            boolean needWake;

            //如果还没有头节点,就以这个参入的事件作为头节点
            //在这里我们看到,if里的这三种情况下,从needWake=mBlocked;这句话可以看出如果Looper绑定的线程当///前是阻塞的话,都需要唤醒它,为什么呢?下面分别解释一下这三种情况
            //1.p == null 
            //这种情况下,说明之前的消息队列是没有头节点的,这个时候如果线程是在阻塞中的话,肯定是无限期阻///塞的,还记得epoll时候参入的时间参数吗?如果连一个待处理的事件都没有,那么怎么可能确定下一次自//动唤醒线程的时间呢?所以,这时候肯定是无限期阻塞直到有人主动唤醒线程的。所以这时候需要唤醒它
            //这里应该有个疑问,唤醒了线程之后,不是会直接处理mMessage头节点的事件吗?还是看MessageQueue的n//ext方法中的代码:
            //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 {
            //msg是和mMessage一样指向的是队列的头节点,nextPollTimeoutMillis是预设的阻塞时长,也就是说如果///发现头节点事件的触发时间还没有达到,就会让线程预设阻塞头节点事件的时间距离现在的时间的时间差
            //2.when == 0
            //同样的,当事件的触发时间为0的时候,表示这个时间要马上触发,所以这个时间插入到队列的头中,接下//来发生的事情和第一点是一样的。
            //3.when < p.when
            //这个时候队列不为空,但是新加入的事件比队列中头节点的事件需要更早的被处理,那么这个时候新加入///的事件就需要插队到当前的头节点之前,也就是说新加入的事件作为消息队列中新的头节点。接下来的
            //处理和第一点是一样的。
            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);
            }

四、事件的处理

从nativePollOnce往回看,MessageQueue.next方法返回一个Message对象,这个Message对象就是需要被处理的事件,再看是谁调用了MessageQueue.next,终于回到了起点Looper.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();

        for (;;) {
            //线程先阻塞,被唤醒后返回消息队列的头节点Message
            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
            Printer logging = me.mLogging;
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }

            //target是一个Handler对象,也就是说事件的处理通过Handler.dispatchMessage方法来分发
            msg.target.dispatchMessage(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();
        }
    }

代码注释说到事件的处理通过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是一个Runable接口,还记得Handler.post方法吗,参入的是一个Runnable对象,callback就是这个Runnable对象。如果第一步没有被执行,到了第二步可以看到,除了可以给Message事件设置callback之外,也可以对Handler对象设置回调mCallback,如果给Handler对象设置了回调,就会让这个mCallback来处理事件。如果第二步也没有被执行,那么第三步,最后一步,就会让这个Handler对象的handleMessage方法去处理事件,我们可以通过重写handleMessage方法来自定义处理事件的流程。

Over。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值