android线程间消息处理机制(Handler,Looper,MessageQueue,Message)

线程


线程间通信原理:
在这里插入图片描述
从上面的流程图可以看出,handler和looper构成了简单的生产者和消费者模式:hander是生产者,looper是消费者.

优点:保证数据产生,消费的顺序(通过MessageQueue,FIFO)无论是生产者还是消费者都只依赖缓存区(handler),生产者和消费者之间不会相互持有,也就不会构成耦合.

Looper

对线程中消息队列(MessageQueue)进行循环获取到Message,然后对Message进行分发处理,在Handler中调用Callback或handleMessage()来处理事件

Thread在默认情况下(主线程除外,主线程在程序启动时就已经创建了Looper对象)并没有与它关联的Looper,所以在handler对象生成之前需要调用Looper.prepare()来创建与线程关联的Looper对象,在线程中任务开始执行时,调用Looper.loop()来循环对应线程中的消息,

 class LooperThread extends Thread {
     public Handler mHandler;
     public void run() {
     	 // 创建looper
         Looper.prepare();
         mHandler = new Handler() {
             public void handleMessage(Message msg) {
                 // do something()
             }
         };
         // 循环队列
         Looper.loop();
     }

App开启动时就调用如下函数生成了sMainLooper对象,它处于主线程中,运行于app的整个生命中

public static void main(String[] args) {
	Looper.prepareMainLooper();
	Looper.loop(); //开始轮循操作
}


  • static final ThreadLocal sThreadLocal
    通过ThreadLocal持有Looper对象保证了线程之间的Looper对象互不影响,即线程并发安全性

  • final MessageQueue mQueue;
    需要执行循环的消息队列

  • final Thread mThread;
    持有该Looper的Thread对象

  • Looper.prepare(boolean quitAllowed)
    quitAllowed:表示是否可以使用quit()
    子线程中quitAllowed一般都是默认为true,我们可以控制;但是在主线程中为false.不可更改,它由系统控制

public static void prepareMainLooper() {
    prepare(false); // 主线程不可以退出
    synchronized (Looper.class) {
        if (sMainLooper != null) {
            throw new IllegalStateException("The main Looper has already been prepared.");
        }
        sMainLooper = myLooper();
    }
}

private static void prepare(boolean quitAllowed) {
        if (sThreadLocal.get() != null) {// 每个线程中都只有一个Looper,如果重复调用该函数就会抛出异常
            throw new RuntimeException("Only one Looper may be created per thread");
        }
        sThreadLocal.set(new Looper(quitAllowed));
    }
  • 构造方法 Looper()
    private Looper(boolean quitAllowed) {
        mQueue = new MessageQueue(quitAllowed);
        mThread = Thread.currentThread(); // 绑定当前的线程
    }

初始化Looper时,创建MessageQueue,同时绑定与该Looper对象对应的线程

  • loop()
    public static void loop() {
        final Looper me = myLooper();// 获取当前线程对应的Looper对象
        if (me == null) { // 该线程没有初始化Looper
            throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
        }
        final MessageQueue queue = me.mQueue; //获取指定线程中Looper的消息队列
        for (;;) { //这是一个死循环,从消息队列不断的取消息
            Message msg = queue.next(); // might block
            if (msg == null) {
                // 消息队列中没有消息队列已经调用了quit函数.
                return;
            }
            // 调用handler的dispatchMessage()对消息进行处理
            msg.target.dispatchMessage(msg);
            // 回收消息,将消息内部数据置为空
            msg.recycleUnchecked();
        }
    }

注意:

  1. loop()执行之前必须确定它所在的线程中已初始化Looper对象
  2. Looper中的MessageQueue调用quit()后,清空消息队列,Message回收处理
  • quit()
  • quitSafely()
    它们都会调用MessageQueue.quit();
    void quit(boolean safe) {
            if (safe) {
                removeAllFutureMessagesLocked();// 清空MessageQueue消息池中所有的延迟消息(通过postDelay()...发送的消息)并将消息池中所有的非延迟消息派发出去让Handler去处理
            } else {
                removeAllMessagesLocked();// 清空MessageQueue中的所有消息
            }
        }
    }

quitSafely相比于quit方法安全之处在于清空消息之前会派发所有的非延迟消息,调用quit之后,Looper就不再接收新的消息,因为消息队列已经退出了.
ActivityThread.java中,在退出程序时会调用主线程中Looper.quit()

case EXIT_APPLICATION:
        if (mInitialApplication != null) {
           mInitialApplication.onTerminate();
         }
         Looper.myLooper().quit();
 break;

通过上述介绍,我们可以知道:

  1. 每个线程Thread都有与之对应的一个Looper对象
  2. 该Looper对象中含有一个MessageQueue,Looper通过loop()不断循环得到位置的Message,Message通过他的属性target(Hanlder对象)调用dispatchMessage将消息传入到handleMessage()中
  3. 在创建Handler之前应该先创建对应线程的Looper对象,(Looper中通过ThreadLocal存储looper对象),ThreadLocal可以使不同线程持有不同的Looper对象,即实现了多线程之间数据安全性

ThreadLocal
为每一个使用该变量的线程提供一个变量值的副本,是java中一种较为特殊的线程绑定机制,即:每一个线程都可以独立的改变自己的副本,而不会和其他线程的副本发生冲突.在ThreadLocal中有一个Map,用于存储每一个线程的变量副本.其中,key为当前线程,value为数据,不同的线程的key是不同的,所以它们之间的数据不会发生错误.

MessageQueue

保存线程通信时用到的消息,对消息进行插入,获取,移除回收操作;
我们一般说他是消息队列包含所有的消息,但是实际上它只包含一个Message,而在每Message中有一个Message对象next,就这样每个Message都有一个next指向下一个Message对象,从而形成了一条数据链.
MessageQueue数据结构
该队列根据任务运行时间排序

  • Long mPtr; // MessageQueue对象地址
  • Message mMessages:当前消息队列头部消息
    …其他的在下面的函数介绍中会介绍到这里就不说了

MessageQueue是通过enqueueMessage()和next()来执行Message的插入和取出

  • 构造函数
    MessageQueue数据实际上是在native底层初始化的,从代码中就可以看出来
    MessageQueue(boolean quitAllowed) {
        mQuitAllowed = quitAllowed;
        mPtr = nativeInit();
    }
	// mPtr是从android_os_MessageQueue_nativeInit返回的MessageQueue的地址 
	static jlong android_os_MessageQueue_nativeInit(JNIEnv* env, jclass clazz) {
	    NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue();
	    if (!nativeMessageQueue) { 
	        return 0;// MessageQueue初始化失败
	    }
	    nativeMessageQueue->incStrong(env);
	    return reinterpret_cast<jlong>(nativeMessageQueue);
	}
  • enqueueMessage(Message msg, long when)
    将数据插入队列中
    在这里插入图片描述
boolean enqueueMessage(Message msg, long when) {
		// 1.判断消息是否可用
		// 2.开始循环MessageQueue中的消息,将Message插入到对应的位置
        synchronized (this) {
            if (mQuitting) {// 该消息队列已结束循环,Message插入失败,回收message
                msg.recycle();
                return false;
            }

            msg.markInUse();
            msg.when = when;
            Message p = mMessages;
            boolean needWake;
            if (p == null || when == 0 || when < p.when) {
                //将Message插入到MessageQueue队列的头部
                msg.next = p;
                mMessages = msg;
                needWake = mBlocked;// 如果阻塞,则需要唤醒.
            } else {
                //通过循环找出msg在队列中的位置(按照时间顺序(从小到大)),然后将Message插入到队列中对应的位置
                // 判断是否需要唤醒队列:如果队列阻塞,头部消息是Barrier并且插入的消息是异步消息则有可能需要唤
                needWake = mBlocked && p.target == null && msg.isAsynchronous();
                Message prev;
                for (;;) {
                    prev = p;
                    p = p.next;
                    if (p == null || when < p.when) {
                        break;
                    }
                    // p.next = msg,判断是否需要唤醒:消息队列中有异步消息并且执行时间在新消息之前,所以不需要唤醒。
                    if (needWake && p.isAsynchronous()) {
                        needWake = false;
                    }
                }// 将数据插入到数据链中
                msg.next = p;
                prev.next = msg;
            }
            // 如果需要唤醒Looper,调用nativeWake取消阻塞
            if (needWake) {
                nativeWake(mPtr);
            }
        }
        return true;
    }

从上面我们可以看出

  1. MessageQueue中的数据结构为单链表,顺序是Message被执行的时间大小,(从小到大)
  2. 如果队列头部消息为空或者插入的消息可以立即执行的话,将数据插入到队列头部时
  3. 否则队列会进行循环,将指定的Message插入到合适位置.
  4. 消息插入到队列时,可能需要调用nativeWake来唤醒Looper
  • next()

在这里插入图片描述

```java
Message next() {
		//消息队列已不存在 
        final long ptr = mPtr;
        if (ptr == 0) {
            return null;
        }
        int nextPollTimeoutMillis = 0; // 阻塞时长
        for (;;) {
 			//阻塞操作,等待唤醒或者等待nextPollTimeoutMillis时长
            nativePollOnce(ptr, nextPollTimeoutMillis);// 阻塞操作
            synchronized (this) {// 同步操作为了数据安全和准确性
                // uptimeMillis用来判断时间(不受系统时间影响).
                final long now = SystemClock.uptimeMillis();
                Message prevMsg = null;
                Message msg = mMessages;
                if (msg != null && msg.target == null) {
                    // 这种情况是队列中当前消息是barrier类型的,寻找队列中下一条的异步消息
                    // Barrier同步障碍消息(用来阻塞队列的),异步消息不受该消息的影响,
                    // postSyncBarrier():向MessageQueue中添加同步障碍消息
                    // removeSyncBarrier():移除消息队列中对应的障碍消息;
                    do {
                        // 获取异步或null Message
                    } while (msg != null && !msg.isAsynchronous());
                }
                if (msg != null) {
                    if (now < msg.when) { // 该消息处于阻塞状态
                        // 设置消息阻塞时间
                        nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                    } else { // 处于唤醒状态
                        // 将得到的next Message
                        return msg;
                    }
                } else {
                    // 队列中没有消息
                    nextPollTimeoutMillis = -1;
                }

                // 结束消息循环,清空消息队列,释放内存
                if (mQuitting) {
                    dispose();
                    return null;
                }
        }
    }

  • public int postSyncBarrier()
  • private int postSyncBarrier(long when)
  • public void removeSyncBarrier(int token)

Barrier: 它也是一种Message,在next()函数中就有简单介绍
消息的target为null就表示它是个Barrier,在MessageQueue中,有两种向消息队列中添加消息的函数,一种是enqueueMessage,另一种是enqueueBarrier,而enqueueMessage中如果mst.target为null是直接抛异常的,这个在源码中可以看到.
通过enqueueBarrier往消息队列中插入一个BarrierMessage,队列中消息执行时,在这个Barrier以后的同步消息都会被这个Barrier拦截阻塞住无法执行,直到我们调用removeBarrier移除了这个Barrier,或者异步消息. 因为异步消息不受它的影响,可以继续执行.

  • removeCallbacksAndMessages(Handler h, Object object)

  • removeMessages(Handler h, Runnable r, Object object)
    移除消息队列的Message,我们通过调用handler.remove()来调用它们.

  • boolean hasMessages()
    判断消息队列是否含有指定消息

IdleHandler

原文:Callback interface for discovering when a thread is going to block waiting for more messages
MessageQueue中的message处理完了或者是需要阻塞等待一段时间,这个时候会回调这个接口
作用:就是在线程中的消息处理完或消息处于阻塞时调用该函数.这时,我们就可以做一些我们自己需要的操作了.

queueIdle()

调用该接口时,如果该函数返回false,那么就会把它从消息队列的mIdleHandlers中移除,返回true就会在下次继续回调

  • public void addIdleHandler(@NonNull IdleHandler handler)
    向消息队列中添加IdleHandler对象

  • public void removeIdleHandler(@NonNull IdleHandler handler)
    从消息队列中移除IdleHandler对象

  • 该函数在源码中的调用

    Message next() {
        int pendingIdleHandlerCount = -1; // -1 only during first iteration
        for (;;) {
            synchronized (this) {
                if (pendingIdleHandlerCount <= 0) {
                    // 没有IdleHandler,继续循环
                    continue;
                }
            }
			// 循环调用MessageQueue中的mIdleHandlers,
            for (int i = 0; i < pendingIdleHandlerCount; i++) {
                try {
                    keep = idler.queueIdle();
                } catch (Throwable t) {
                }
                if (!keep) {
                    synchronized (this) {
                        mIdleHandlers.remove(idler);
                    }
                }
            }
            // Reset the idle handler count to 0 so we do not run them again.
            pendingIdleHandlerCount = 0;
        }
    }

我们可以看到:在调用next()函数之后,我们先循环获取消息,如果队列中的消息为null或者是处于阻塞时,才会调用上面这段代码来循环mIdleHandlers,如果mIdleHandlers中含有数据,就会执行IdleHandler中的queueIdle()函数,最后判断是否需要从mIdleHandlers中移除IdleHandler对象

在源码ActivityThread中就使用到了IdleHandler ,通过下面代码我们可以更加直观的了解IdleHandler.

    final class GcIdler implements MessageQueue.IdleHandler {
        @Override
        public final boolean queueIdle() {
            doGcIfNeeded();
            return false;
        }
    }
    
    void scheduleGcIdler() {
        if (!mGcIdlerScheduled) {
            mGcIdlerScheduled = true;
            Looper.myQueue().addIdleHandler(mGcIdler);
        }
        mH.removeMessages(H.GC_WHEN_IDLE);
    }

    void unscheduleGcIdler() {
        if (mGcIdlerScheduled) {
            mGcIdlerScheduled = false;
            Looper.myQueue().removeIdleHandler(mGcIdler);
        }
        mH.removeMessages(H.GC_WHEN_IDLE);
    }

Message

  • what: 用来分辨消息内容的标识符
  • arg1,arg2:用来存储整形数据,表示消息内容
  • obj :用来保存我们需要的对象
  • when:发送消息的时间,以毫秒为单位
  • data(Bundle):用来存储数据
  • callback(Runnable):用来存储需要执行Runnable对象:具体可以查看handler.post(Runnable)和handler.getPostMessage(Runnable r),它不会生成新的线程
  • Message sPool:改线程对象池中的头部Message对象

该类中最重要的只有两个函数,就生成(obtain),回收(recycler),

生成

  • obtain()
    public static Message obtain() {
        synchronized (sPoolSync) {
            if (sPool != null) {
                Message m = sPool;
                sPool = m.next;
                m.next = null;
                m.flags = 0; // clear in-use flag
                sPoolSize--;
                return m;
            }
        }
        return new Message();
    }

它主要是从Message对象池中取出一个Message对象
其他obtain()都是基于该函数,只是指定了Message的一些属性而已
在这里插入图片描述

回收

  • recycleUnchecked()
    void recycleUnchecked() {
        // Mark the message as in use while it remains in the recycled object pool.
        // Clear out all other details.
        flags = FLAG_IN_USE;
        what = 0;
        arg1 = 0;
        arg2 = 0;
        obj = null;
        replyTo = null;
        sendingUid = -1;
        when = 0;
        target = null;
        callback = null;
        data = null;
        synchronized (sPoolSync) {
            if (sPoolSize < MAX_POOL_SIZE) {
                next = sPool;
                sPool = this;
                sPoolSize++;
            }
        }
    }

该函数把Message内的数据置为初始状态,然后存储对象池中

Handler

发送消息

  • sendMessage()
    在这里插入图片描述
  • post()
    在这里插入图片描述
    这些函数实际上最后调用的是:handler.enqueueMessage()
    private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
        msg.target = this;
        if (mAsynchronous) {
            msg.setAsynchronous(true);
        }// 通过looper获取到messagequeue,然后调用messagequeue.enqueueMessage()将Message插入到队列中
        return queue.enqueueMessage(msg, uptimeMillis);
    }

接受消息

  • Looper.loop()
    public static void loop() {
        final Looper me = myLooper();
        final MessageQueue queue = me.mQueue;
        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                return;
            }
            try {
                msg.target.dispatchMessage(msg);
            }
            msg.recycleUnchecked();
        }
    }

通过Looper.loop不断循环调用MessageQueue.next()获取队列头部的消息Message,因为message包含Handler对象,所以通过message.target.dispatchMessage(msg)来分发消息.

  • dispatchMessage()
   public void dispatchMessage(Message msg) {
        if (msg.callback != null) {
            handleCallback(msg);
        } else {
            if (mCallback != null) {
                if (mCallback.handleMessage(msg)) {
                    return;
                }
            }
            handleMessage(msg);
        }
    }

该函数判断消息中是否含有Runnable,如果有则执行handleCallback(msg);否则,如果handler中含有Callback 接口对象的话,就调用Callback.handleMessage(),handler没有callback对象的话则调用handler.handleMessage()来处理Message

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

他实际上是回调Runnable的run()函数.

  • Callback.handleMessage()
    public interface Callback {
        /**
         * @param msg A {@link android.os.Message Message} object
         * @return True if no further handling is desired
         */
        public boolean handleMessage(Message msg);
    }

这个是我们在创建handler时,需要传入的接口对象,回调时使用

  • Handler.handleMessage()

我们在多线程消息接收处理中使用最多的就是这种模式了

消息移除

  • removeMessage()
    在这里插入图片描述
    它们最终调用的都是MessageQueue中的removeMessage()和removeCallbacksAndMessages()
    void removeMessages(Handler h, Runnable r, Object object) {
        if (h == null || r == null) {
            return;
        }

        synchronized (this) {
            Message p = mMessages;
            // Remove all messages at front.
            while (p != null && p.target == h && p.callback == r
                   && (object == null || p.obj == object)) {
                Message n = p.next;
                mMessages = n;
                p.recycleUnchecked();
                p = n;
            }
            // Remove all messages after front.
            while (p != null) {
                Message n = p.next;
                if (n != null) {
                    if (n.target == h && n.callback == r
                        && (object == null || n.obj == object)) {
                        Message nn = n.next;
                        n.recycleUnchecked();
                        p.next = nn;
                        continue;
                    }
                }
                p = n;
            }
        }
    }

在MessageQueue中,循环把队列中的全部消息都通过recycleUnchecked()回收到Message的对象回收池中.

  • Handler():创建handler对象
    在这里插入图片描述

他们的具体不同之处可以看源码了解

  • createAsync(@NonNull Looper looper)
  • createAsync(@NonNull Looper looper, @NonNull Callback callback)

创建一个Handler对象,它和正常Handler对象的区别就是它发布的Message或Runnable不会受到同步栅栏消息的阻塞
同步栅栏消息只阻塞同步消息.

总结

  • 一个线程有几个handler,一个线程有几个 Looper?如何保证?
    每个线程有多个handler,一个looper,每个looper中都有一个MessageQueue, 通过使用ThreadLocal保证每个线程中只有唯一的一个Looper
    变量时共享的 MessageQueue不能说是属于哪个线程,函数是有线程区别的
  • Handler内存泄漏原因? 为什么其他的内部类没有说过有这个问题?
    内部类持有外部类的对象的引用,如果message在外部类对象销毁时,还没有到执行时间,即MessageQueue还存在任务需要执行,这时根据GC可达性算法,外部类对象无法销毁.
    如果是静态内部类,这时使用static修饰,他属于类的属性,不属于对象的,在对象销毁时,不会持有外部对象的引用,就不会造成内存泄漏
Handler handler = new Handler() {
       public void handleMessage(Message msg) {
           MainActivity.this.test();// 对象调用
       }
   };
   
Handler handler = new Handler() {
       public void handleMessage(Message msg) {
           MainActivity.test();// 类调用
       }
   };
  • 为何主线程可以new Handler?如果想要在子线程中new Handler 要做些什么准备?
    主线程在程序启动时就生成了一个MainLooper()
    子线程在new handler时:
 class LooperThread extends Thread {
     public Handler mHandler;
     public void run() {
     	 // 创建looper
         Looper.prepare();
         mHandler = new Handler() {
             public void handleMessage(Message msg) {
                 // do something()
             }
         };
         // 循环队列
         Looper.loop();
     }
  • 子线程中维护的Looper,消息队列无消息的时候的处理方案是什么?有什么用?
    子线程中维护的looper在无消息时调用quit,可以结束循环.
    loop()是一个死循环,想要退出,必须msg == null,
public static void loop() {
        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }
            msg.recycleUnchecked();
        }
    }

只有在调用quit退出的时候才会返回null

Message next() {
        for (;;) {
            synchronized (this) {
                if (mQuitting) {
                    dispose();
                    return null;
                }
            }
        }
    }

void quit(boolean safe) {
        synchronized (this) {
            if (mQuitting) {
                return;
            }
            mQuitting = true;
        }
    }

所以使用quit()唤醒队列,执行loop()退出循环,子线程的looper不再执行了.

消息入队:根据时间排序,当队列满的时候,阻塞,直到用户通过next取出消息,当next被调用的时候通知MQ可以进行消息的入队

消息出队:由Looer.loop进行循环,对queue进行轮询操作,当消息达到执行时间就取出,但MQ为空的时候,队列阻塞,等待消息队列调用enqueue的时候,通知队列可以取出消息,停止阻塞.

handler没有使用多线程中阻塞队列 BlockQueue,因为主线程(系统)也在使用,如果使用阻塞队列BlockQueue设置上限的话,系统可能会发生卡顿

looper循环阻塞:

  1. 执行时间阻塞(没有到执行时间), nativePollOnce(long ptr, int timeoutMillis)执行阻塞操作,timeoutMillis为-1表示无限等待,直到有事件发生为止,如果为0,无需等待立即返回;
    nativePollOnce(long ptr, int timeoutMillis) timeoutMillis不为-1,时间一到,自动唤醒
 Message next() {
        final long ptr = mPtr;
        if (ptr == 0) {
            return null;
        }
        int nextPollTimeoutMillis = 0;
        for (;;) {
            nativePollOnce(ptr, nextPollTimeoutMillis);// 循环进入阻塞状态,等待执行时间到达后唤醒
            synchronized (this) {
                if (msg != null) {
                    if (now < msg.when) {
                        // 消息不为空,并且没有到执行时间,nextPollTimeoutMillis 不为-1
                        nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                    }
                } 
                // Process the quit message now that all pending messages have been handled.
                if (mQuitting) {
                    dispose();
                    return null;
                }
            }
        }
    }

2.MQ为空,执行阻塞,等待唤醒;
插入消息时,手动唤醒

Message next() {
        if (ptr == 0) { // mPtr==0,表示中断循环,
            return null;
        }

        int pendingIdleHandlerCount = -1;
        int nextPollTimeoutMillis = 0;
        for (;;) {
            nativePollOnce(ptr, nextPollTimeoutMillis);
            synchronized (this) {
                if (msg != null) {
                } else {
                    // 无消息,timeoutMillis为-1表示无限等待,直到有事件发生为止
                    nextPollTimeoutMillis = -1;
                }
            }
        }
    }
// mPtr==0
private void dispose() {
        if (mPtr != 0) {
            nativeDestroy(mPtr);
            mPtr = 0;
        }
    }
// 唤醒
boolean enqueueMessage(Message msg, long when) {
        synchronized (this) {
            boolean needWake;
            Message p = mMessages;
            if (p == null || when == 0 || when < p.when) {
                msg.next = p;
                mMessages = msg;
                needWake = mBlocked;
            } 
            // mPtr != 0 循环没有中断,进行唤醒操作.
            if (needWake) {
                nativeWake(mPtr);
            }
        }
        return true;
    }
  • 既然可以存在多个 Handler 往 MessageQueue 中添加数据(发消息时各个 Handler 可能处于不同线程),那它内部是如何确保线程安全的?取消息呢?
    锁synchronized: synchronized内置锁,它是由jvm自动完成的,插入和取都需要锁,因为取的时候,可能正在插入.它是锁的对象,因为MessageQueue 每个线程中只有一个Looper,每个Looper又只有一个MQ.
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值