java进入native源码分析_Android源码探究:Android Native层消息机制完全解析

前言

前文详细分析了Java层的消息循环机制的工作原理,在分析MessageQueue的过程中,我们遇到了nativePollOnce()和nativeWake()方法的调用,下面我们就深入到Native层的消息机制来看看它背后的运作原理。

Native层的消息机制

一、NativeMessageQueue的相关逻辑

1、NativeMessageQueue的构建

首先,我们来看看Java层的MessageQueue的构造函数:

MessageQueue(boolean quitAllowed) {

mQuitAllowed = quitAllowed;

mPtr = nativeInit();

}

这里调用了一个native方法,实际上这里初始化了native层的消息队列,并返回了该消息队列的头部指针地址。由于这个是native方法,这里利用了JNI机制调用本地代码,JNI的相关知识笔者在前面的文章也有说到,这里就不再赘述。我们直接来看本地代码:(frameworks/base/core/jni/android_os_MessageQueue.cpp):

static jlong android_os_MessageQueue_nativeInit(JNIEnv* env, jclass clazz) {

NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue(); //创建了一个本地的消息队列

if (!nativeMessageQueue) {

jniThrowRuntimeException(env, "Unable to allocate native queue");

return 0;

}

nativeMessageQueue->incStrong(env); //增加强引用指针计数,这里与RefBase,类似于智能指针的概念

return reinterpret_cast(nativeMessageQueue); //强制类型转换,返回jlong类型,实际上是指针的值

}

这里引入了NativeMessageQueue,顾名思义,这应该是native层的消息队列,我们用UML类图来看看它的类结构:

959418884977

NativeMessageQueue类结构

其中,RefBase是基类,它的作用比较特殊,在Android的native代码中,大部分的类都是继承自RefBase,作用有点类似于Java中的Object。实际上,它的作用与实现智能指针有关,便于native层的垃圾回收的实现(因为C++没有GC机制,我们需要手动实现对象的创建和回收操作)。这里不对RefBase做过多的深究,我们把关注点放回NativeMessageQueue。

NativeMessageQueue是native层的消息队列,虽然它称作消息队列,但实际上它是一个空壳,它内部并没有维护消息的队列或者链表,它把涉及消息的相关操作都交给了Looper去处理。与Java层一样的是,一条线程只会有一个Looper,这是因为native层也有着一套线程独立变量的机制,当前线程的变量只与当前线程有关。我们来看看NativeMessageQueue的构造方法:

NativeMessageQueue::NativeMessageQueue() :

mPollEnv(NULL), mPollObj(NULL), mExceptionObj(NULL) {

mLooper = Looper::getForThread(); //获取当前线程的Looper对象

if (mLooper == NULL) { //如果没有,那么初始化Looper对象

mLooper = new Looper(false);

Looper::setForThread(mLooper); //Looper与当前线程绑定

}

}

通过NativeMessageQueue的构造方法可以看出,这里实例化了一个Looper,而这个Looper则是与线程相关的,因此我们可以推测Looper实际上承担了消息队列的实际功能,NativeMessageQueue对外表现为消息队列,也有相关的方法,但实际上它把核心逻辑都移交给了Looper去处理。

小结:Java层的MessageQueue被创建的时候,同时会创建一个Native层的NativeMessageQueue,并初始化一个native的Looper。经过JNI的调用返回,NativeMessageQueue对象的指针地址会返回给Java层的MessageQueue,保存在mPtr这个成员变量内。

2、NativeMessageQueue#pollOnce

经过上面的NativeMessageQueue的初始化后,我们就能正常使用它了。还记得我们在Java层的MessageQueue#next()方法内曾经看到过这个调用吗?nativePollOnce(ptr, nextPollTimeoutMillis),这里调用了native的方法,我们来看看它的nativec层的源码:

static void android_os_MessageQueue_nativePollOnce(JNIEnv* env, jobject obj,

jlong ptr, jint timeoutMillis) {

NativeMessageQueue* nativeMessageQueue = reinterpret_cast(ptr);

nativeMessageQueue->pollOnce(env, obj, timeoutMillis);

}

方法很简单,这里传进来的ptr参数实际上就是MessageQueue.mPtr变量,经过强制类型转换后,把ptr转换成了NativeMessageQueue指针,然后调用这个对象的pollOnce(args)方法。这个过程可以理解为:在某一线程内,Java层持有Native层的NativeMessageQueue的指针地址值,经过JNI调用把该地址传递过来,native层根据这个地址找到了这个对象,强制类型转换成了NativeMessageQueue对象。简单地说,Java层的MessageQueue对应了Native层的NativeMessageQueue。

下面,我们来看nativeMessageQueue->pollOnce(env, obj, timeoutMillis):

void NativeMessageQueue::pollOnce(JNIEnv* env, jobject pollObj, int timeoutMillis) {

mPollEnv = env;

mPollObj = pollObj;

mLooper->pollOnce(timeoutMillis); //把逻辑交给Looper去处理

mPollObj = NULL;

mPollEnv = NULL;

if (mExceptionObj) {

env->Throw(mExceptionObj); //env是JNI环境,这里的异常会抛给Java层

env->DeleteLocalRef(mExceptionObj);

mExceptionObj = NULL;

}

}

逻辑很简单,这里调用了mLooper->pollOnce(timeoutMillis)方法,并把超时时间传递了进去,结合函数名字,我们可以合理推测:该函数内部在超时时间内进行native的消息处理,达到了超时时间就会JNI调用返回,以便处理Java层的消息。我们先记住这个推测,待会分析Looper的时候再来看这个推测是否正确。

3、NativeMessageQueue#wake

同样地,我们之前在讨论MessageQueue#enqueueMessage()的时候,会发现进行了nativeWake()调用,那么我们直接看源码:

static void android_os_MessageQueue_nativeWake(JNIEnv* env, jclass clazz, jlong ptr) {

NativeMessageQueue* nativeMessageQueue = reinterpret_cast(ptr);

nativeMessageQueue->wake();

}

void NativeMessageQueue::wake() {

mLooper->wake();

}

显然,根据ptr找到对应的NativeMessageQueue,然后调用了mLooper->wake(),把逻辑交给了Looper去处理。那么到目前为止,一切问题的关键都指向了Looper,我们开始探究native的Looper吧。

二、native Looper的工作原理

Looper是整个Native层消息机制的核心所在,大部分功能都是Looper完成的。要准确理解native Looper,我们首先就要对它的类结构有个整体的认识,下面笔者给出Looper的UML类图方便读者的理解。

1、native Looper的整体认识

Looper的类结构被定义在/system/core/libutils/include/utils/Looper.h,源码地址为Looper.h。结合类结构,我们可以清晰地画出如下的UML类图:

959418884977

Looper UML类图

结合Looper.h的源码和上面的UML类图,下面先列举几个与Looper有关的类或成员变量,以便后面的源码阅读。

①mWakeEventFd:用于事件通知的文件描述符,在这里表示唤醒Looper的文件描述符。实际上它指向一个eventfd对象,该对象可以实现事件的等待和通知机制。

②mEpollFd:epoll的句柄,指向一个epoll对象,有关epoll的使用都要经过该句柄。epoll简单来说,是Linux下的一种I/O事件通知机制。epoll监听了eventfd,当该文件描述符的缓冲区为空时,epoll发出可写信号,当文件描述符的缓冲区不空时,发出可读信号。利用这样的机制,能实现线程间的通信。

③MessageEnvelop:内部封装了Message、MessageHandler和uptime。其中Message是native层发送的消息,而MessageHandler内部有一个回调函数,当消息接收方接收并处理消息后就会回调该函数。uptime是消息的触发时间。

④Request:封装了fd、events、callback等参数,实际上这是Looper监听的一种特殊消息,可以称之为事件,它是以文件描述符形式存在的。通过addFd(args)把fd添加到epoll的监听队列中,然后封装成Request对象,加入到Looper.mRequest内,然后等待事件的发生。

⑤Response:当epoll侦测到某一fd的缓冲池发生了改变后,Looper会找到该fd对应的Request,把它进一步封装成Response,然后加入mResponse等待Looper处理该消息。

2、Looper的创建与初始化

上面详细讲述了与Looper有关的一些知识,在此基础上我们继续探索Looper的原理,首先,我们从Looper的构造方法看起。(源码位置在:/system/core/libutils/Looper.cpp)

Looper::Looper(bool allowNonCallbacks) :

mAllowNonCallbacks(allowNonCallbacks), mSendingMessage(false),

mPolling(false), mEpollFd(-1), mEpollRebuildRequired(false),

mNextRequestSeq(0), mResponseIndex(0), mNextMessageUptime(LLONG_MAX) {

mWakeEventFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); //创建一个用于唤醒Looper的文件描述符

LOG_ALWAYS_FATAL_IF(mWakeEventFd < 0, "Could not make wake event fd: %s",

strerror(errno));

AutoMutex _l(mLock);

rebuildEpollLocked(); //重建一个epoll

}

void Looper::rebuildEpollLocked() {

// 如果已经有一个epoll了,那么关闭它

if (mEpollFd >= 0) {

#if DEBUG_CALLBACKS

ALOGD("%p ~ rebuildEpollLocked - rebuilding epoll set", this);

#endif

close(mEpollFd);

}

// 通过Linux系统调用 创建一个epoll实例

mEpollFd = epoll_create(EPOLL_SIZE_HINT);

LOG_ALWAYS_FATAL_IF(mEpollFd < 0, "Could not create epoll instance: %s", strerror(errno));

struct epoll_event eventItem; //epoll_event 封装了fd、event等

memset(& eventItem, 0, sizeof(epoll_event)); // 初始化为0

eventItem.events = EPOLLIN; //event:EPOLLIN 表示读事件,即对应的连接可读(缓冲区有值)

eventItem.data.fd = mWakeEventFd;

// epoll_ctl 系统调用,这里epoll添加了对mWakeEventFd的监听,

// 当epoll监听到mWakeEventFd的eventItem出现时,就会通知Looper

int result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeEventFd, & eventItem);

LOG_ALWAYS_FATAL_IF(result != 0, "Could not add wake event fd to epoll instance: %s",

strerror(errno));

// 对mRequest内的所有fd重新添加epoll监听

for (size_t i = 0; i < mRequests.size(); i++) {

const Request& request = mRequests.valueAt(i);

struct epoll_event eventItem;

request.initEventItem(&eventItem);

int epollResult = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, request.fd, & eventItem);

if (epollResult < 0) {

ALOGE("Error adding epoll events for fd %d while rebuilding epoll set: %s",

request.fd, strerror(errno));

}

}

}

Looper的创建和初始化过程的代码逻辑很清晰,主要是做了以下几件事情:

(1)调用eventfd(args)方法创建一个用于唤醒Looper的文件描述符mWakeEventFd。

(2)进行epoll_create(int)系统调用,创建一个epoll,用于轮询IO以及通知Looper有关的fd是否发生了改变,并把epoll句柄保存在mEpollFd内。

(3)进行epoll_ctl(args)系统调用,把mWakeEventFd添加到epoll的监听列表中。

(4)遍历mRequests,把所有通过addFd(args)添加进来的文件描述符fd再次加到这个新的epoll的监听列表内。(旧的epoll已经被close了)

3、发送消息/添加事件监听

我们知道,native层的Looper主要处理两种消息:一个是Message,另一个是eventItem(epoll监听文件描述符fd的变化)。下面我们来了解以下消息的发送过程或事件的添加过程。

(1)Looper#sendMessage

void Looper::sendMessage(const sp& handler, const Message& message) {

nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);

sendMessageAtTime(now, handler, message);

}

void Looper::sendMessageDelayed(nsecs_t uptimeDelay, const sp& handler,

const Message& message) {

nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);

sendMessageAtTime(now + uptimeDelay, handler, message);

}

void Looper::sendMessageAtTime(nsecs_t uptime, const sp& handler,

const Message& message) {

#if DEBUG_CALLBACKS

ALOGD("%p ~ sendMessageAtTime - uptime=%" PRId64 ", handler=%p, what=%d",

this, uptime, handler.get(), message.what);

#endif

size_t i = 0;

{ // acquire lock

AutoMutex _l(mLock);

size_t messageCount = mMessageEnvelopes.size();

// 在mMessageEnvelopes内寻找消息要插入的位置,条件是msg的uptime要比后者的小,但比前者大

// 这说明在这个消息列表内,下标越小,消息的触发时间越早

while (i < messageCount && uptime >= mMessageEnvelopes.itemAt(i).uptime) {

i += 1;

}

MessageEnvelope messageEnvelope(uptime, handler, message);

mMessageEnvelopes.insertAt(messageEnvelope, i, 1);

// 如果Looper正在发送消息,那么直接返回。因为Looper在发送完消息后,

// 会计算下一条消息的触发时间进而处理该消息

if (mSendingMessage) {

return;

}

} // release lock

// 如果消息被插入在消息列表头部,立刻唤醒looper

if (i == 0) {

wake();

}

}

发送一个消息时,不但要有Message实例,同时也要有MessageHandler,这是消息被处理的时候进行回调的方法。类似于Java层的Messaeg.callback。紧接着,在把消息添加到消息列表的时候,会进行加锁,防止出现并发错误。如果当前Looper没有在处理消息并且插入的消息放在了列表头部,则需要去唤醒Looper。

(2)Looper#addFd

上面是发送一个消息然后等待Looper的处理,而这个方法则是添加一个文件描述符fd,然后让epoll去监听它的变化,如果产生了变化则会得到Looper的处理。

int Looper::addFd(int fd, int ident, int events, const sp& callback, void* data) {

#if DEBUG_CALLBACKS

ALOGD("%p ~ addFd - fd=%d, ident=%d, events=0x%x, callback=%p, data=%p", this, fd, ident,

events, callback.get(), data);

#endif

//省略...

{ // acquire lock

AutoMutex _l(mLock);

// 封装成Request对象

Request request;

request.fd = fd;

request.ident = ident;

request.events = events;

request.seq = mNextRequestSeq++;

request.callback = callback;

request.data = data;

if (mNextRequestSeq == -1) mNextRequestSeq = 0; // reserve sequence number -1

struct epoll_event eventItem;

request.initEventItem(&eventItem);

ssize_t requestIndex = mRequests.indexOfKey(fd);

if (requestIndex < 0) {

// 如果mRequest内没有该Request,那么添加到epoll监听列表内

int epollResult = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, fd, & eventItem);

if (epollResult < 0) {

ALOGE("Error adding epoll events for fd %d: %s", fd, strerror(errno));

return -1;

}

mRequests.add(fd, request);

} else {

// 如果mRequest已经有该Request,那么修改epoll监听列表对应的fd的内容

int epollResult = epoll_ctl(mEpollFd, EPOLL_CTL_MOD, fd, & eventItem);

if (epollResult < 0) {

// 异常状态处理

// 省略...

}

mRequests.replaceValueAt(requestIndex, request); //更新mRequest

}

} // release lock

return 1;

}

首先将fd、LooperCallback、*data等封装成了Request对象,然后经过epoll_ctl系统调用,添加到epoll监听列表内,此时epoll就会对fd进行轮询,如果发生了变化就能得到通知。所以可以通过文件描述符的形式添加到Looper,然后我们在别的线程改变它,那么Looper所在的线程就能就能知道它的改变,然后调用回调方法,这样就实现了线程的切换。

4、Looper的唤醒操作

Looper的唤醒操作指的是,往mWakeEventFd的缓冲区写入一个数字,然后Looper的pollOnce(args)就会获取到该事件的发生,然后可以开始处理消息,否则该方法将会阻塞直到超时。

我们回忆下上一篇文章,在调用Message#enqueueMessage插入一条消息的时候,如果Java的消息队列处于阻塞状态、队列头部是消息屏障以及马上有一个异步消息要处理,那么就会进行nativeWake()的本地调用,然后进一步调用Looper#wake()以唤醒Looper。当native的消息处理完成后,会导致Java层的nativePollOnce()的调用返回,从而让Java层处理消息。因此,Looper#wake和Looper#pollOnce起着重要的桥梁作用,它让Java层和native层的消息机制得以联系和连续运作。

下面,我们来看Looper#wake:

void Looper::wake() {

#if DEBUG_POLL_AND_WAKE

ALOGD("%p ~ wake", this);

#endif

uint64_t inc = 1;

ssize_t nWrite = TEMP_FAILURE_RETRY(write(mWakeEventFd, &inc, sizeof(uint64_t)));

if (nWrite != sizeof(uint64_t)) {

if (errno != EAGAIN) {

LOG_ALWAYS_FATAL("Could not write wake signal to fd %d: %s",

mWakeEventFd, strerror(errno));

}

}

}

首先定义了一个64位的整型变量为1,然后通过write()方法,往mWakeEventFd写入1。此时epoll会侦测到这个写入事件,然后就会通知Looper了。

5、Looper处理消息的过程

如果Looper想要运转起来,还需要调用一个方法,那就是pollOnce。该方法驱动了native Looper的消息循环。我们先来看源码:

int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {

int result = 0;

for (;;) {

// 先处理被添加到mResponses列表的事件,这些事件都没有callback,

// 因此只能返回给调用者根据ident去处理

while (mResponseIndex < mResponses.size()) {

const Response& response = mResponses.itemAt(mResponseIndex++);

int ident = response.request.ident;

if (ident >= 0) {

int fd = response.request.fd;

int events = response.events;

void* data = response.request.data;

if (outFd != NULL) *outFd = fd;

if (outEvents != NULL) *outEvents = events;

if (outData != NULL) *outData = data;

return ident;

}

}

if (result != 0) {

if (outFd != NULL) *outFd = 0;

if (outEvents != NULL) *outEvents = 0;

if (outData != NULL) *outData = NULL;

return result;

}

result = pollInner(timeoutMillis); //进一步处理,并传入超时时间

}

}

int Looper::pollInner(int timeoutMillis) {

// 调整超时时间,根据当前超时时间和mNextMessageUptime来判断

// 如果传递进来的超时时间大于mNextMessageUptime,就要把超时时间改成这个触发时间

// 否则,下一条Message将不能在准确时间内得到处理

if (timeoutMillis != 0 && mNextMessageUptime != LLONG_MAX) {

nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);

int messageTimeoutMillis = toMillisecondTimeoutDelay(now, mNextMessageUptime);

if (messageTimeoutMillis >= 0

&& (timeoutMillis < 0 || messageTimeoutMillis < timeoutMillis)) {

timeoutMillis = messageTimeoutMillis;

}

}

// Poll.

int result = POLL_WAKE;

mResponses.clear();

mResponseIndex = 0;

// 表示正在轮询

mPolling = true;

// 创建epoll_event数组,以保存epoll结果

struct epoll_event eventItems[EPOLL_MAX_EVENTS];

// 进行系统调用,阻塞式等待,收集在epoll监控的事件中已经发生的事件,超过timeoutMillis则返回

// 实际上这里epoll监听所有已添加的fd的改变,比如写入了“1”,那么就能收集到该事件

int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

// 取消轮询状态

mPolling = false;

// Acquire lock.

mLock.lock();

// Rebuild epoll set if needed.

if (mEpollRebuildRequired) {

mEpollRebuildRequired = false;

rebuildEpollLocked();

goto Done;

}

// Check for poll error.

if (eventCount < 0) {

if (errno == EINTR) {

goto Done;

}

ALOGW("Poll failed with an unexpected error: %s", strerror(errno));

result = POLL_ERROR;

goto Done;

}

// Check for poll timeout.

if (eventCount == 0) {

#if DEBUG_POLL_AND_WAKE

ALOGD("%p ~ pollOnce - timeout", this);

#endif

result = POLL_TIMEOUT;

goto Done;

}

// 处理所有在epoll收集到的事件

for (int i = 0; i < eventCount; i++) {

int fd = eventItems[i].data.fd;

uint32_t epollEvents = eventItems[i].events;

if (fd == mWakeEventFd) {

if (epollEvents & EPOLLIN) {

awoken(); //从mWakeEventFd中读取“1”,以便下一次的wake()操作,即清空缓冲区

} else {

ALOGW("Ignoring unexpected epoll events 0x%x on wake event fd.", epollEvents);

}

} else {

// 根据已触发的fd事件,找到相应的Request,然后添加到mResponses内

// 表示该事件已经被监听到,等待处理

ssize_t requestIndex = mRequests.indexOfKey(fd);

if (requestIndex >= 0) {

int events = 0;

if (epollEvents & EPOLLIN) events |= EVENT_INPUT;

if (epollEvents & EPOLLOUT) events |= EVENT_OUTPUT;

if (epollEvents & EPOLLERR) events |= EVENT_ERROR;

if (epollEvents & EPOLLHUP) events |= EVENT_HANGUP;

pushResponse(events, mRequests.valueAt(requestIndex));

} else {

ALOGW("Ignoring unexpected epoll events 0x%x on fd %d that is "

"no longer registered.", epollEvents, fd);

}

}

}

Done: ;

// 处理所有的Message 这里只处理触发时间小于当前时间的,

// 如果触发时间还没到的消息,等待下一次pollOnce()再处理

mNextMessageUptime = LLONG_MAX;

while (mMessageEnvelopes.size() != 0) {

nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);

const MessageEnvelope& messageEnvelope = mMessageEnvelopes.itemAt(0);

if (messageEnvelope.uptime <= now) {

{ // obtain handler

sp handler = messageEnvelope.handler;

Message message = messageEnvelope.message;

mMessageEnvelopes.removeAt(0);

mSendingMessage = true;

mLock.unlock();

handler->handleMessage(message); //利用MessageHandler来处理消息,即回调函数

} // release handler

mLock.lock();

mSendingMessage = false;

result = POLL_CALLBACK;

} else {

// The last message left at the head of the queue determines the next wakeup time.

mNextMessageUptime = messageEnvelope.uptime;

break;

}

}

// Release lock.

mLock.unlock();

// 处理所有的Response,即已触发的fd事件

for (size_t i = 0; i < mResponses.size(); i++) {

Response& response = mResponses.editItemAt(i);

if (response.request.ident == POLL_CALLBACK) {

int fd = response.request.fd;

int events = response.events;

void* data = response.request.data;

// 回调callback

int callbackResult = response.request.callback->handleEvent(fd, events, data);

if (callbackResult == 0) {

removeFd(fd, response.request.seq);

}

response.request.callback.clear();

result = POLL_CALLBACK;

}

}

return result;

}

一眼看去,整个pollOnce和pollInner的代码非常长,但实际上它的逻辑非常地清晰明了,详细的分析已经写在了注释里面,这里简单地总结以下它所作的工作:

(1)在pollOnce方法中,首先会对所有Response中没有callback的事件进行处理,这会返回ident给调用者,让调用者自己去处理这个消息。

(2)在pollInner方法内,首先超时时间的调整操作,这要兼顾到一个常规Message的触发,不能让epoll阻塞过久。

(3)紧接着,进行了epoll_wait系统调用,阻塞式等待epoll返回事件,epoll中监听到的事件会保存在eventItems[EPOLL_MAX_EVENTS]内。如果超时时间到了,就返回。

(4)对所有监听到的事件进行处理,根据事件找到相应的Request,然后封装成Response,等待Looper的处理。

(5)处理所有到达触发事件的常规Message,进行handler的回调。

(6)处理所有Response,进行callback回调。

总结

上面完整地分析了Looper的工作原理和运行机制,现在整理一下所有相关的知识点。

①native Looper在初始化的时候,会创建epoll用以监听文件描述符fd的改变,同时实例化了一个mWakeEventFd,这是用来唤醒Looper的fd,然后把它添加到epoll监听队列内。

②Java层的Looper在loop()的过程中,会调用MessageQueue#next()方法,进而调用nativePollOnce()方法,而该方法又交给Looper#pollOnce处理。这表示Java层的消息在处理之前,会先处理native层的消息,直到JNI调用返回。

③在native Looper的循环过程中,会使用epoll_wait系统调用,阻塞式等待相关的fd事件的发生。然后再处理Message和fd event。

④Looper在阻塞的时候,可以通过wake()方法来唤醒它,通过往mWakeEventFd写入"1",会导致epoll_wait返回,从而取消轮询状态。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值