Android消息机制详细解析

在初学Android的时候我们都碰过这个问题,要从服务端获取数据,这时候,我们知道在主线程不能做耗时操作,否则会引起ANR,更不能在主线程中联网,Android3.0以后会报一异常,或者在子线程中更新UI报出了一个经典异常,我们都知道解决方法是在子线程用Handler发消息给主线程进行更新就可以解决上述两个问题,但是我们会不会疑惑,Handler干了什么事情就可以自由切换线程执行,由于Android帮我们封装好了我们日常开发的时候只要跟Handler打交道就可以办成这些事,但是了解整个Android消息机制的原理有助于提升自己对整个Android的了解,所以我就写了这个博客,把我自己对Android消息传递机制的理解记录下来。


Android消息机制简叙

Android的消息机制主要指Handler、MessengeQueue和Looper的工作机制,有时我们会发现当我们主线程中进行联网操作时,会发现可能在2.3运行时是正常,但是在3.0以后的版本之后运行就会报一个android.os.NewWorkOnMainTHreadException,这是因为Android在API Level9之后就对这个进行了处理,如下所示。

<span style="white-space:pre">	</span>/**
         * For apps targetting SDK Honeycomb or later, we don't allow
         * network usage on the main event loop / UI thread.
         *
         * Note to those grepping:  this is what ultimately throws
         * NetworkOnMainThreadException ...
         */
        if (data.appInfo.targetSdkVersion > 9) {
            StrictMode.enableDeathOnNetwork();
        }
Android的设计者并不希望也不允许我们在主线程进行进行联网操作,接着就是在子线程中进行更新UI的操作,我举个栗子,如下所示:

public class MainActivity extends AppCompatActivity {

    private TextView tv;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv = (TextView) findViewById(R.id.tv_hello);
        new Thread(new Runnable() {
            @Override
            public void run() {
//                SystemClock.sleep(3000);
                    tv.setText("aa" + i);
            }
        }).start();
    }
    
}
我们发现这时候竟然是可以运行,不是说不能在子线程中更新UI吗,其实是这样的这个线程是在onCreate里创建的,当线程运行结束的时候界面还没有出来,所以就可以更新UI了,要证明这个只要在子线程添加一个sleep方法睡一下,之后就会报出这个经典异常:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

这是由于在ViewRootImpl的checkThread方法进行了验证,如下:

void checkThread() {
        if (mThread != Thread.currentThread()) {
            throw new CalledFromWrongThreadException(
                    "Only the original thread that created a view hierarchy can touch its views.");
        }
    }
这个异常基本上是每个Android开发者都曾碰过的,其实想想Android这样设计也是很靠谱的,我们知道Android的UI控件并不是线程安全的,如果多线程并发访问可能会产生不可预估的问题,举个栗子,比如一款游戏,打怪掉血跟吃血瓶操作都放在了子线程中进行,他们也都要更新血条这个UI,这时候如果子线程可以操作血条,那血一直掉,用户就吃了血瓶,但是我们知道线程是有不确定性的,有可能我是先吃的血瓶但是线程执行却没有掉血的快,等到血瓶执行更新UI的时候角色已经死了等等...,由于上面的两个矛盾,如果交由我们自己处理怕是处理不来,所以Android提供了一套基于Handler的运行机制去解决在子线程中无法访问UI的矛盾。还是上面这个例子,当角色被怪打了,这时掉血线程发个信息告诉UI线程让UI线程去更新血条,喝血瓶这个线程要更新血条也发信息让UI线程去更新,这样就解决了子线程并发修改UI控件的矛盾了。


虽然使用Handler进行UI更新操作并不复杂,但是还是用个示例来说明:

public class MainActivity extends AppCompatActivity {
    private ProgressBar pb;
    private TextView tvPb;
    private static final int FLAGS = 1;

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case FLAGS:
                    pb.setProgress(msg.arg1);
                    tvPb.setText(String.valueOf(msg.arg1));
                    break;
                default:
                    break;
            }

        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        pb = (ProgressBar) findViewById(R.id.pb);
        tvPb = (TextView) findViewById(R.id.tv_pb);

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i <= 100; i++) {
                    Message msg = mHandler.obtainMessage();
                    SystemClock.sleep(200);
                    msg.what = FLAGS;
                    msg.arg1 = i;
                    mHandler.sendMessage(msg);
                }
            }
        }).start();
    }
}

示例很简单,就是通过Handler去sendMessage把要更新progressBar和TextView的操作发送给handleMessage来执行,当然有一点就是Message对象可以自己new出来也可以使用handler.obtainMessage去获取这个对象,因为使用.obtainMessage去获取一个对象的性能比new出来的要好,是因为.obtainMessage是从消息池中取出(如果有),而不是每次都直接new,省去了创建对象的内存消耗,所以就用了这个方法效果如图


Android消息机制分析

前面说了Android消息机制主要指Handler及其附带的MessageQueue、Looper以及Message的工作流程,下面一个个分析


(1).Message

消息,里面可以包含消息处理对象和处理数据等等,由MessageQueue进行统一的排列,然后交由Handler进行处理.


(2).MessageQueue

MessageQueue主要有插入和读取两个操作,

插入:在MessageQueue中插入操作由enqueueMessage这个方法来执行,enqueueMessage方法源码如下:

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

从源码可以看出,这个方法主要就是对单链表进行插入操作,当然我们平常开发可能会碰到一个异常就是从这里报的,比如第一个例子

[java]  view plain  copy
  1. new Thread(new Runnable() {  
  2.          @Override  
  3.          public void run() {  
  4.              for (int i = 0; i <= 100; i++) {  
  5.                  SystemClock.sleep(200);  
  6.                  Message msg = mHandler.obtainMessage();  
  7.                  msg.what = FLAGS;  
  8.                  msg.arg1 = i;  
  9.                  mHandler.sendMessage(msg);  
  10.              }  
  11.          }  
  12.      }).start();  

当我们把

[java]  view plain  copy
  1. Message msg = mHandler.obtainMessage();  


这段代码放到for循环外面时,就会报

Java.lang.IllegalStateException: { when=-205ms what=1 arg1=1 target=com.sjr.handlerdemo.MainActivity$1 } This message is already in use.这个异常,这是因为当把代码放到循环体外面的时候sendMessage可能会和handleMessage并发同时操作一个对象,所以Android直接抛了这个异常出来,当然这是题外话。

读取:读取操作的执行方法是next方法,next的源码如下:


[java]  view plain  copy
  1. Message next() {  
  2.         // Return here if the message loop has already quit and been disposed.  
  3.         // This can happen if the application tries to restart a looper after quit  
  4.         // which is not supported.  
  5.         final long ptr = mPtr;  
  6.         if (ptr == 0) {  
  7.             return null;  
  8.         }  
  9.   
  10.         int pendingIdleHandlerCount = -1// -1 only during first iteration  
  11.         int nextPollTimeoutMillis = 0;  
  12.         for (;;) {//无限循环  
  13.             if (nextPollTimeoutMillis != 0) {  
  14.                 Binder.flushPendingCommands();  
  15.             }  
  16.   
  17.             nativePollOnce(ptr, nextPollTimeoutMillis);  
  18.   
  19.             synchronized (this) {  
  20.                 // Try to retrieve the next message.  Return if found.  
  21.                 final long now = SystemClock.uptimeMillis();  
  22.                 Message prevMsg = null;  
  23.                 Message msg = mMessages;  
  24.                 if (msg != null && msg.target == null) {  
  25.                     // Stalled by a barrier.  Find the next asynchronous message in the queue.  
  26.                     do {  
  27.                         prevMsg = msg;  
  28.                         msg = msg.next;  
  29.                     } while (msg != null && !msg.isAsynchronous());  
  30.                 }  
  31.                 if (msg != null) {  
  32.                     if (now < msg.when) {  
  33.                         // Next message is not ready.  Set a timeout to wake up when it is ready.  
  34.                         nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);  
  35.                     } else {  
  36.                         // Got a message.  
  37.                         mBlocked = false;  
  38.                         if (prevMsg != null) {  
  39.                             prevMsg.next = msg.next;  
  40.                         } else {  
  41.                             mMessages = msg.next;  
  42.                         }  
  43.                         msg.next = null;  
  44.                         if (DEBUG) Log.v(TAG, "Returning message: " + msg);  
  45.                         msg.markInUse();  
  46.                         return msg;  
  47.                     }  
  48.                 } else {  
  49.                     // No more messages.  
  50.                     nextPollTimeoutMillis = -1;  
  51.                 }  
  52.   
  53.                 // Process the quit message now that all pending messages have been handled.  
  54.                 if (mQuitting) {  
  55.                     dispose();  
  56.                     return null;  
  57.                 }  
  58.   
  59.                 // If first time idle, then get the number of idlers to run.  
  60.                 // Idle handles only run if the queue is empty or if the first message  
  61.                 // in the queue (possibly a barrier) is due to be handled in the future.  
  62.                 if (pendingIdleHandlerCount < 0  
  63.                         && (mMessages == null || now < mMessages.when)) {  
  64.                     pendingIdleHandlerCount = mIdleHandlers.size();  
  65.                 }  
  66.                 if (pendingIdleHandlerCount <= 0) {  
  67.                     // No idle handlers to run.  Loop and wait some more.  
  68.                     mBlocked = true;  
  69.                     continue;  
  70.                 }  
  71.   
  72.                 if (mPendingIdleHandlers == null) {  
  73.                     mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];  
  74.                 }  
  75.                 mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);  
  76.             }  
  77.   
  78.             // Run the idle handlers.  
  79.             // We only ever reach this code block during the first iteration.  
  80.             for (int i = 0; i < pendingIdleHandlerCount; i++) {  
  81.                 final IdleHandler idler = mPendingIdleHandlers[i];  
  82.                 mPendingIdleHandlers[i] = null// release the reference to the handler  
  83.   
  84.                 boolean keep = false;  
  85.                 try {  
  86.                     keep = idler.queueIdle();  
  87.                 } catch (Throwable t) {  
  88.                     Log.wtf(TAG, "IdleHandler threw exception", t);  
  89.                 }  
  90.   
  91.                 if (!keep) {  
  92.                     synchronized (this) {  
  93.                         mIdleHandlers.remove(idler);  
  94.                     }  
  95.                 }  
  96.             }  
  97.   
  98.             // Reset the idle handler count to 0 so we do not run them again.  
  99.             pendingIdleHandlerCount = 0;  
  100.   
  101.             // While calling an idle handler, a new message could have been delivered  
  102.             // so go back and look again for a pending message without waiting.  
  103.             nextPollTimeoutMillis = 0;  
  104.         }  
  105.     }  


从源码可以看到,next方法里面有个死循环方法,当消息队列里没有消息时next方法就会一直阻塞,当有消息时就返回这条消息然后将消息从消息队列中移除。

(3).Looper

Looper在这套消息机制里面可以称为一个轮询器,它会不断的从MessageQueue中轮询查看是否有新消息,如果有新消息这个轮询器就会处理,创建一个Looper的方法为Looper.prepare();源码中Looper创建的方法为:


[java]  view plain  copy
  1. /** Initialize the current thread as a looper. 
  2.       * This gives you a chance to create handlers that then reference 
  3.       * this looper, before actually starting the loop. Be sure to call 
  4.       * {@link #loop()} after calling this method, and end it by calling 
  5.       * {@link #quit()}. 
  6.       */  
  7.     public static void prepare() {  
  8.         prepare(true);  
  9.     }  


然后它进行了一系列的逻辑操作来创建一个Looper,介于篇幅有限就不再详细点进去研究了,当然主线程有一个专门的创建方法prepareMainLooper,它最终也是通过prepare来实现,由于主线程Looper比较特殊,所以Android帮我们封装了一个方法,通过getMainLooper可以获取到主线程的Looper。如果不需要使用Looper时应该终止它,通过quit()或quitSafely()方法可以终止Looper,两个方法的区别是一个前者一旦调用就直接退出,后者是把消息队列中存在的消息处理完之后才会退出。

我们前面说了Looper会一直轮询MessageQueue,接下来的方法就是起轮询作用的方法,有了这个方法Looper才会去轮询,这个方法是loop();源码为:


[java]  view plain  copy
  1. /** 
  2.      * Run the message queue in this thread. Be sure to call 
  3.      * {@link #quit()} to end the loop. 
  4.      */  
  5.     public static void loop() {  
  6.         final Looper me = myLooper();  
  7.         if (me == null) {  
  8.             throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");  
  9.         }  
  10.         final MessageQueue queue = me.mQueue;  
  11.   
  12.         // Make sure the identity of this thread is that of the local process,  
  13.         // and keep track of what that identity token actually is.  
  14.         Binder.clearCallingIdentity();  
  15.         final long ident = Binder.clearCallingIdentity();  
  16.   
  17.         for (;;) {  
  18.             Message msg = queue.next(); // might block  
  19.             if (msg == null) {  
  20.                 // No message indicates that the message queue is quitting.  
  21.                 return;  
  22.             }  
  23.   
  24.             // This must be in a local variable, in case a UI event sets the logger  
  25.             Printer logging = me.mLogging;  
  26.             if (logging != null) {  
  27.                 logging.println(">>>>> Dispatching to " + msg.target + " " +  
  28.                         msg.callback + ": " + msg.what);  
  29.             }  
  30.   
  31.             msg.target.dispatchMessage(msg);  
  32.   
  33.             if (logging != null) {  
  34.                 logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);  
  35.             }  
  36.   
  37.             // Make sure that during the course of dispatching the  
  38.             // identity of the thread wasn't corrupted.  
  39.             final long newIdent = Binder.clearCallingIdentity();  
  40.             if (ident != newIdent) {  
  41.                 Log.wtf(TAG, "Thread identity changed from 0x"  
  42.                         + Long.toHexString(ident) + " to 0x"  
  43.                         + Long.toHexString(newIdent) + " while dispatching to "  
  44.                         + msg.target.getClass().getName() + " "  
  45.                         + msg.callback + " what=" + msg.what);  
  46.             }  
  47.   
  48.             msg.recycleUnchecked();  
  49.         }  
  50.     }  


下面是源码的大略解析首先是


[java]  view plain  copy
  1. final Looper me = myLooper();  

myLooper()方法源码为

[java]  view plain  copy
  1. /** 
  2.     * Return the Looper object associated with the current thread.  Returns 
  3.     * null if the calling thread is not associated with a Looper. 
  4.     */  
  5.    public static @Nullable Looper myLooper() {  
  6.        return sThreadLocal.get();  
  7.    }  


我们发现他调用的是ThreadLocal的get方法,ThreadLocal是一个线程内部数据存储类,通过它可以在指定线程中存储数据然后只能在只能的线程中才能获取存储的数据。它通过get和set方法去获取和设置当前线程的localValues对象的table数组,由于它们对ThreadLocal的读/写操作仅限于各自线程内部,所以ThreadLocal可以在多个线程中互不干扰地存储和修改数据。这个类在我们平常开发中用得比较少,这里只是因为涉及到其中的get方法就提一下,get方法源码为:

[java]  view plain  copy
  1. /** 
  2.     * Returns the value of this variable for the current thread. If an entry 
  3.     * doesn't yet exist for this variable on this thread, this method will 
  4.     * create an entry, populating the value with the result of 
  5.     * {@link #initialValue()}. 
  6.     * 
  7.     * @return the current value of the variable for the calling thread. 
  8.     */  
  9.    @SuppressWarnings("unchecked")  
  10.    public T get() {  
  11.        // Optimized for the fast path.  
  12.        Thread currentThread = Thread.currentThread();  
  13.        Values values = values(currentThread);  
  14.        if (values != null) {//如果不为空就取出它的table数组  
  15.            Object[] table = values.table;  
  16.            int index = hash & values.mask;  
  17.            if (this.reference == table[index]) {//找出ThreadLocal的reference对象在table数组中的位置  
  18.                return (T) table[index + 1];  
  19.            }  
  20.        } else {  
  21.            values = initializeValues(currentThread);//如果对象为空就返回初始值  
  22.        }  
  23.   
  24.        return (T) values.getAfterMiss(this);  
  25.    }  


从源码可以看出,这是一个取出当前线程的localValues对象,如果这个对象不为空就取出它的table数组并找出ThreadLocal的reference对象在数组中的额位置,table数组中的下一个位置所存储的数据就是ThreadLocal的值。

回到loop()的源码,可以看到如果
ThreadLocal对象为空Android直接抛出一个异常,然后是一个死循环,跳出循环的方法是只能是MessageQueue.next()返回null,当Looper的quit或quitSafely方法被调用时,这个方法会调用MessageQueue的quit方法来让消息队列退出,当消息队列为退出状态时,它的next方法返回null,然后loop中的死循环就会终止,如果MessageQueue的next方法有新消息Looper就会通过

[java]  view plain  copy
  1. msg.target.dispatchMessage(msg);  


去处理这条消息,这里的msg.target是发送这条消息的Handler对象,所以通过这段代码我们就可以解开我们前面的疑惑,为什么同一个Handler对象可以在子线程中发送消息然后在主线程中通过自己的handleMessage去处理这条消息,然后这里不同的是,Handler的dispatchMessage方法是在创建Handler时所使用的Looper执行的,这样就可以将代码逻辑切换到指定的线程中执行了。

(4).Handler

Handler是消息的处理者,它的工作主要是消息的发送和接收以及处理,下面是Handler对消息插入的处理,就是一系列senMessage方法的源码:


[java]  view plain  copy
  1. public final boolean sendMessage(Message msg)  
  2.     {  
  3.         return sendMessageDelayed(msg, 0);  
  4.     }  
  5.   
  6.     public final boolean sendEmptyMessage(int what)  
  7.     {  
  8.         return sendEmptyMessageDelayed(what, 0);  
  9.     }  
  10.   
  11.   
  12.     public final boolean sendEmptyMessageDelayed(int what, long delayMillis) {  
  13.         Message msg = Message.obtain();  
  14.         msg.what = what;  
  15.         return sendMessageDelayed(msg, delayMillis);  
  16.     }  
  17.   
  18.   
  19.     public final boolean sendEmptyMessageAtTime(int what, long uptimeMillis) {  
  20.         Message msg = Message.obtain();  
  21.         msg.what = what;  
  22.         return sendMessageAtTime(msg, uptimeMillis);  
  23.     }  
  24.   
  25.      
  26.     public final boolean sendMessageDelayed(Message msg, long delayMillis)  
  27.     {  
  28.         if (delayMillis < 0) {  
  29.             delayMillis = 0;  
  30.         }  
  31.         return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);  
  32.     }  
  33.   
  34.   
  35.     public boolean sendMessageAtTime(Message msg, long uptimeMillis) {  
  36.         MessageQueue queue = mQueue;  
  37.         if (queue == null) {  
  38.             RuntimeException e = new RuntimeException(  
  39.                     this + " sendMessageAtTime() called with no mQueue");  
  40.             Log.w("Looper", e.getMessage(), e);  
  41.             return false;  
  42.         }  
  43.         return enqueueMessage(queue, msg, uptimeMillis);  
  44.     }  
  45.   
  46.     
  47.     public final boolean sendMessageAtFrontOfQueue(Message msg) {  
  48.         MessageQueue queue = mQueue;  
  49.         if (queue == null) {  
  50.             RuntimeException e = new RuntimeException(  
  51.                 this + " sendMessageAtTime() called with no mQueue");  
  52.             Log.w("Looper", e.getMessage(), e);  
  53.             return false;  
  54.         }  
  55.         return enqueueMessage(queue, msg, 0);  
  56.     }  


从源码可以看到,send方法最后返回的都是enqueueMessage这个方法,这个方法的源码为:


[java]  view plain  copy
  1. private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {  
  2.        msg.target = this;  
  3.        if (mAsynchronous) {  
  4.            msg.setAsynchronous(true);  
  5.        }  
  6.        return queue.enqueueMessage(msg, uptimeMillis);  
  7.    }  


最后返回的是MessageQueued的enqueueMessage方法去往消息队列中插入一条消息,这就是Handler的sendMessage能够插入消息的原理了,然后MessageQueue的next方法发现有消息了就停止阻塞返回这条消息给Looper,Looper收到消息之后就开始进行处理,最后交由Handler的dispatchMessage方法去处理。dispatchMessage的源码为:


[java]  view plain  copy
  1. /** 
  2.    * Handle system messages here. 
  3.    */  
  4.   public void dispatchMessage(Message msg) {  
  5.       if (msg.callback != null) {  
  6.           handleCallback(msg);  
  7.       } else {  
  8.           if (mCallback != null) {  
  9.               if (mCallback.handleMessage(msg)) {  
  10.                   return;  
  11.               }  
  12.           }  
  13.           handleMessage(msg);  
  14.       }  
  15.   }  


通过这个方法就可以对消息进行处理了,这个方法的逻辑是先检查Message的callback方法是否为null,不为null就调用handleCallback这个方法来处理,Callback是一个接口:

[java]  view plain  copy
  1. /** 
  2.     * Callback interface you can use when instantiating a Handler to avoid 
  3.     * having to implement your own subclass of Handler. 
  4.     * 
  5.     * @param msg A {@link android.os.Message Message} object 
  6.     * @return True if no further handling is desired 
  7.     */  
  8.    public interface Callback {  
  9.        public boolean handleMessage(Message msg);  
  10.    }  


通过这个接口我们可以初始化一个Handler而且不需要实现Handler的子类,比如前面第一个例子,我们是new了一个Handler类然后重写了handleMessage方法,而Callback这个接口是另一种使用Handler的方式.

回到上面的dispatchMessage方法然后会判断当前Handler的Callback是不是为null,如果不为null就接着判断它的handleMessage方法是否为true,如果为true就直接return,最后调用handleMessage方法,这是空的方法,我们调用时一般是重写这个犯法实现具体的业务逻辑。


总结


Android消息机制在日常开发中很常见,熟悉它的运行机制有助于我们开发出更高效的应用程序,最后上述的内容可以用一张图来简略总结(图画得有些难看,接受吐槽..)




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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值