Android消息机制(Handler、Looper、MessageQueue)

一、ThreadLocal

1、什么是ThreadLocal

ThreadLocal 是一个线程内部的数据存储类,通过它可以在指定的线程中存储数据,数据存储以后,只有在指定线程中可以获取到存储的数据,对于其他线程来说则无法获取到数据。
一般来说,当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用 ThreadLocal。

2、ThreadLocal 使用

1)、创建ThreadLocal

ThreadLocal<Boolean> threadLocal = new ThreadLocal<>();

通过泛型指定ThreadLocal中存储的数据类型
2)、ThreadLocal保存参数

threadLocal.set(true);

threadLocal中保存参数true
3)、获取参数

threadLocal.get()

下面是一个使用ThreadLocal的例子:

final ThreadLocal<Boolean> threadLocal = new ThreadLocal<>();
threadLocal.set(true);
Log.d(TAG, "main thread:" + threadLocal.get());

new Thread() {
    @Override
    public void run() {
        super.run();
        // 这里打印的值不是false是null,因为ThreadLocal保存的值在不同线程是两个不同的对象
        Log.d(TAG, "child thread:" + threadLocal.get());
    }
}.start();

打印出日志结果如下:
D/ThreadLoalActivity: main thread:true
D/ThreadLoalActivity: child thread:null
在主线程中把ThreadLocal设置为true,但是在子线程获取的值是null。因为ThreadLocal的作用域是线程,在不同线程中相当于两个对象,所以子线程中没有赋值就是null。

3、ThreadLocal的工作原理

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

首先获取当前线程,通过getMap获取当前线程中保存的ThreadLocalMap ,getMap源码如下:

ThreadLocalMap getMap(Thread t) {
      return t.threadLocals;
}

实际上是获取Thread中的threadLocals对象,即每个线程中有一个ThreadLocalMap (我们可以简单理解为HashMap),ThreadLocalMap 用于保存数据。因为可能会定义多个ThreadLocal(注意在Android中Looper中的ThreadLocal是一个静态变量,因此Looper中只有唯一一个ThreadLocal),所以用Map保存。 Thread 中的threadLocals定义如下:

class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
 }

ThreadLocalMap是ThreadLocal的内部类,ThreadLocalMap源码如下:

static class ThreadLocalMap {

    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

    private Entry[] table;
}

在 ThreadLocalMap 内部有一个数组,private Entry[] table,ThrealLocal 的值就存在这个 table 数组中。Entry的key保存ThreadLocal对象,value保存ThrealLocal 的值。
2)、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();
}

取出当前线程的 ThreadLocalMap 对象,然后取出它的 table 数组,然后获取保存在ThreadLocal中的值。

二、消息队列MessageQueue的工作原理

MessageQueue主要包含两个操作:插入和读取。读取本身伴随着删除操作,对应着两个方法分别是enqueueMessage()和next()。enqueueMessage是插入一条消息到消息队列中,next是取出一条信息并将其从队列移除。看他的方法源码可以看到enqueueMessage主要是进行单链表的插入操作。next是一个无线循环,如果么有消息,next会一直阻塞在这里,如果有消息到来,next会返回这个消息并将其从队列移除。
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;
}

next源码如下:

Message next() {
	...
    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;
            }
    }
	...
}

第27行nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);计算距离下一个消息执行的时间。第10行nativePollOnce(ptr, nextPollTimeoutMillis);是本地方法,C语言实现。会阻塞当前线程,直到时间达到nextPollTimeoutMillis 。
上面使用synchronized 对插入和取出操作进行加锁,因为Handler sendMsg时可能在不同的线程中。

三、Looper的工作原理

Looper会不停的从MessageQueue中查看是否有新消息,有的话立刻处理,没有就会一直阻塞。Looper在Android消息机制中扮演消息循环的角色。

1、怎么创建一个Looper

1)、Looper.prepare()
Looper.prepare()源码如下:

public static void prepare() {
    prepare(true);
}

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

在prepare中新建了一个Looper对象,并且将这个对象保存在ThreadLocal中。
2)、Looper.prepareMainLooper()
这里是创建主线程的Looper,注意这个方法是系统调用的,不需要我们调用,系统默认就初始化了主线程的Looper(详见第下面五节)。所以我们在主线程可以直接new Handler()使用,如果在子线程还必须调用Looper.prepare()和Looper.loop()(详见下面第六节)
Looper.prepareMainLooper()源码如下;

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) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

先调用prepare(),在prepare中新建了一个Looper对象,并且将这个对象保存在ThreadLocal中。注意这里prepare传入的参数是false,表示消息队列不允许退出。然后将Looper保存在变量sMainLooper中。

2、开启循环

新建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();

    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 slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;

        final long traceTag = me.mTraceTag;
        if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
            Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
        }
        final long start = (slowDispatchThresholdMs == 0) ? 0 : SystemClock.uptimeMillis();
        final long end;
        try {
            msg.target.dispatchMessage(msg);
            end = (slowDispatchThresholdMs == 0) ? 0 : SystemClock.uptimeMillis();
        } finally {
            if (traceTag != 0) {
                Trace.traceEnd(traceTag);
            }
        }
        if (slowDispatchThresholdMs > 0) {
            final long time = end - start;
            if (time > slowDispatchThresholdMs) {
                Slog.w(TAG, "Dispatch took " + time + "ms on "
                        + Thread.currentThread().getName() + ", h=" +
                        msg.target + " cb=" + msg.callback + " msg=" + msg.what);
            }
        }

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

调用Message msg = queue.next()检测消息队列中是否有消息,有消息就继续向下执行,没有就阻塞。有消息时就会执行到msg.target.dispatchMessage(msg),target就是Handler。相当于调用的是Handler.dispatchMessage(msg)。Handler的sendMessage方法最终会调用enqueueMessage方法,enqueueMessage方法源码如下:

private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
    msg.target = this;
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

msg.target = this;就是把当前Handler的实例保存到 msg.target中。

什么时候会退出这个loop死循环呢?
Looper.loop()是一个死循环,唯一跳出的方式就是MessageQueue的next方法返回null。当Looper的quit方法被调用时,Looper会调用MessageQueue的quit或者quitSafely方法通知消息队列退出,当消息队列被标记为退出状态时,它的next方法返回null。也就是说Looper必须退出,否则loop方法就会无限循环下去。

四、Handler的工作原理

Handler的内部实现其实就是Looper和MessageQueue。创建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 that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}

mLooper = Looper.myLooper();获取了当前线程的Looper, mQueue = mLooper.mQueue;获取了Looper对应的消息队列。注意主线程系统自动初始化Looper,子线程需要自己初始化Looper。
Handler发送消息的方法最终都会执行如下方法:

public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
    MessageQueue queue = mQueue;
    if (queue == null) {
        RuntimeException e = new RuntimeException(
                this + " sendMessageAtTime() called with no mQueue");
        Log.w("Looper", e.getMessage(), e);
        return false;
    }
    return enqueueMessage(queue, msg, uptimeMillis);
}

private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
    msg.target = this;
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

实际就是向消息队列中添加消息,然后在Looper中会去处理。
Looper怎么处理,如何回调到Handler中的handleMessage见“3.2 开启循环”的分析。

五、主线程的消息循环

当我们启动一个程序(即点击Launch中的按钮)时,zygote会fork 一个进程,会给每个应用分配独立的JVM。Java程序都是从main函数开始启动,所以Android应用最先执行的也是main函数。而这个main函数在ActivityTread中。
Android的主线程就是ActivityTread,入口方法是main,在main方法中会通过Looper.prepareMainLooper()来创建主线程的Looper和MessageQueue,然后通过Looper.loop()开启消息循环,源码如下:

public static void main(String[] args) {
    ...
    Looper.prepareMainLooper();
    ActivityThread thread = new ActivityThread(); 
    //在attach方法中会完成Application对象的初始化,然后调用Application的onCreate()方法
    thread.attach(false);
    
    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();
    }
    ...
    Looper.loop();
    throw new RuntimeException("Main thread loop unexpectedly exited");
}

主线程消息循环开始以后还需要一个Handler来和消息队列进行交互,这个Handler就是ActivityThread.H,H定义的消息类型如下:

private class H extends Handler {
    public static final int LAUNCH_ACTIVITY         = 100;
    public static final int PAUSE_ACTIVITY          = 101;
    public static final int PAUSE_ACTIVITY_FINISHING= 102;
    public static final int STOP_ACTIVITY_SHOW      = 103;
    public static final int STOP_ACTIVITY_HIDE      = 104;
    public static final int SHOW_WINDOW             = 105;
    public static final int HIDE_WINDOW             = 106;
    public static final int RESUME_ACTIVITY         = 107;
    public static final int SEND_RESULT             = 108;
    public static final int DESTROY_ACTIVITY        = 109;
    public static final int BIND_APPLICATION        = 110;
    public static final int EXIT_APPLICATION        = 111;
    public static final int NEW_INTENT              = 112;
    public static final int RECEIVER                = 113;
    public static final int CREATE_SERVICE          = 114;
    public static final int SERVICE_ARGS            = 115;
    public static final int STOP_SERVICE            = 116;

    public static final int CONFIGURATION_CHANGED   = 118;
    public static final int CLEAN_UP_CONTEXT        = 119;
    public static final int GC_WHEN_IDLE            = 120;
    public static final int BIND_SERVICE            = 121;
    public static final int UNBIND_SERVICE          = 122;
    public static final int DUMP_SERVICE            = 123;
    public static final int LOW_MEMORY              = 124;
    public static final int ACTIVITY_CONFIGURATION_CHANGED = 125;
    public static final int RELAUNCH_ACTIVITY       = 126;
    public static final int PROFILER_CONTROL        = 127;
    public static final int CREATE_BACKUP_AGENT     = 128;
    public static final int DESTROY_BACKUP_AGENT    = 129;
    public static final int SUICIDE                 = 130;
    public static final int REMOVE_PROVIDER         = 131;
    public static final int ENABLE_JIT              = 132;
    public static final int DISPATCH_PACKAGE_BROADCAST = 133;
    public static final int SCHEDULE_CRASH          = 134;
    public static final int DUMP_HEAP               = 135;
    public static final int DUMP_ACTIVITY           = 136;
    public static final int SLEEPING                = 137;
    public static final int SET_CORE_SETTINGS       = 138;
    public static final int UPDATE_PACKAGE_COMPATIBILITY_INFO = 139;
    public static final int TRIM_MEMORY             = 140;
    public static final int DUMP_PROVIDER           = 141;
    public static final int UNSTABLE_PROVIDER_DIED  = 142;
    public static final int REQUEST_ASSIST_CONTEXT_EXTRAS = 143;
    public static final int TRANSLUCENT_CONVERSION_COMPLETE = 144;
    public static final int INSTALL_PROVIDER        = 145;
    public static final int ON_NEW_ACTIVITY_OPTIONS = 146;
    public static final int CANCEL_VISIBLE_BEHIND = 147;
    public static final int BACKGROUND_VISIBLE_BEHIND_CHANGED = 148;
    public static final int ENTER_ANIMATION_COMPLETE = 149;
}

H中处理消息的handMeassge

public void handleMessage(Message msg) {
if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
switch (msg.what) {
    case LAUNCH_ACTIVITY: {
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
        final ActivityClientRecord r = (ActivityClientRecord) msg.obj;

        r.packageInfo = getPackageInfoNoCheck(
                r.activityInfo.applicationInfo, r.compatInfo);
        handleLaunchActivity(r, null);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    } break;
    case RELAUNCH_ACTIVITY: {
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityRestart");
        ActivityClientRecord r = (ActivityClientRecord)msg.obj;
        handleRelaunchActivity(r);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    } break;
    case PAUSE_ACTIVITY:
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityPause");
        handlePauseActivity((IBinder)msg.obj, false, (msg.arg1&1) != 0, msg.arg2,
                (msg.arg1&2) != 0);
        maybeSnapshot();
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        break;
    case PAUSE_ACTIVITY_FINISHING:
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityPause");
        handlePauseActivity((IBinder)msg.obj, true, (msg.arg1&1) != 0, msg.arg2,
                (msg.arg1&1) != 0);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        break;
    case STOP_ACTIVITY_SHOW:
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStop");
        handleStopActivity((IBinder)msg.obj, true, msg.arg2);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        break;
    case STOP_ACTIVITY_HIDE:
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStop");
        handleStopActivity((IBinder)msg.obj, false, msg.arg2);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        break;
      ...
 }

可看到有启动Activity,暂停Activity,停止Activity。

ActivityThread的内部类H继承于Handler,通过handler消息机制,简单说Handler机制用于同一个进程的线程间通信。Activity的生命周期都是依靠主线程的Looper.loop,当收到不同Message时则采用相应措施:在H.handleMessage(msg)方法中,根据接收到不同的msg,执行相应的生命周期。比如收到msg=H.LAUNCH_ACTIVITY,则调用ActivityThread.handleLaunchActivity()方法,最终会通过反射机制,创建Activity实例,然后再执行Activity.onCreate()等方法;再比如收到msg=H.PAUSE_ACTIVITY,则调用ActivityThread.handlePauseActivity()方法,最终会执行Activity.onPause()等方法。

六、子线程如何创建消息循环

主线程消息循环的创建是系统自动完成的,子线程的消息循环的创建需要我们自己手动完成 。子线程创建消息循环需要3步,具体步骤见下面代码注释。
代码如下:

new Thread() {
    @Override
    public void run() {
        // 1、为当前线程新建Looper
        Looper.prepare();

        // 2、新建Handler
        // handler = new Handler(new Handler.Callback() { 这个等价于下面这行代码
        handler = new Handler(Looper.myLooper(), new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                switch (msg.what) {
                    case MSG_ONE:
                        Log.d(TAG, "handleMessage>>MSG_ONE");
                        break;
                    case MSG_TWO:
                        Log.d(TAG, "handleMessage>>MSG_TWO");
                        break;
                }
                return false;
            }
        });

        // 3、开启循环
        Looper.loop();

		// 4、使用完后调用quitSafely结束Looper的死循环,这样子线程才能结束,子线程才能回收
		Looper.myLooper().quitSafely();
    }
}.start();

注意上面Handler的工作线程是子线程。

总结

1、ThreadLocal会根据当前线程取出该线程对应的ThreadLocalMap(类似HashMap),ThreadLocal.set调用ThreadLocalMap.set(当前的ThreadLocal, value),即ThreadLocalMap使用ThreadLocal作为key。ThreadLocalMap中是把key、value封装成Entry放在一个数组中。
2、Android的消息机制主要就是Handler、Looper、MessageQueue三个类。
3、MessageQueue使用next读取数据。next是一个无线循环,如果没有消息,next会一直阻塞在这里,如果有消息到来,next会返回这个消息并将其从队列移除。
4、我们在new Handler()前必须先调用Looper.prepare(),否则会因为当前线程没有Looper报异常。线程最后还要调用Looper.loop()启动循环。在主线程中我们不用手动调用Looper.prepare()和loop(),因为应用启动时系统帮我们调用了Looper.prepare()和loop()。
5、Handler中包含Looper和MessageQueue,Handler发一个消息实际就是向MessageQueue中添加一个消息,Looper.loop()中循环调用MessageQueue.next()检测是否有新消息,有就处理,没有就一直检测。
6、Activity的生命周期都是在主线程的Looper.loop中收到不同Message时则采用相应措施:在H.handleMessage(msg)方法中,根据接收到不同的msg,执行相应的生命周期。
7、创建Message不使用new,使用Message.obtain()。Message中会使用单链表维护一个池,保存new出的Message对象。这样的好处时避免频繁创建对象,造成内存抖动。因为Message会系统很多地方都会使用,触摸屏幕然后响应都会使用Message,即系统每时每刻都在新建Message的对象。如果使用new,用完会释放,这样会造成很多内存碎片。其他对象分配时可能不够分配空间就会造成OOM。这中使用单链表复用就是享元模式。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值