2024年最全写给Rikka自己的Handler源码说明书(1),2024春招面试时间

最后

一线互联网Android面试题含详解(初级到高级专题)

这些题目是今年群友去腾讯、百度、小米、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。并且大多数都整理了答案,熟悉这些知识点会大大增加通过前两轮技术面试的几率

如果设置门槛,很多开发者朋友会因此错过这套高级架构资料,错过提升成为架构师的可能。这就失去了我们的初衷;让更多人都能通过高效高质量的学习,提升自己的技术和格局,升职加薪。

最后送给大家一句话,望共勉,永远不要放弃自己的梦想和追求;

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

Thread t = Thread.currentThread();

ThreadLocalMap map = getMap(t);

if (map != null)

map.set(this, value);

else

createMap(t, value);

}

这里出现了一个叫做 ThreadLocalMap的东西,而set方法就是把 (threadLocal, UiLooper)作为键值对存进去的,也就是说 ThreadLocalMap是一个存放 ThreadLocal - Looper的集合,我们可以看看 ThreadLocalMap这个类,它是ThreadLocal的内部类:

// ThreadLocal.java

static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {

Object value;

// 1

Entry(ThreadLocal<?> k, Object v) {

super(k);

value = v;

}

}

// 2

private Entry[] table;

private void set(ThreadLocal<?> key, Object value) {

Entry[] tab = table;

int len = tab.length;

// 3

int i = key.threadLocalHashCode & (len-1);

// 4

for (Entry e = tab[i];

e != null;

e = tab[i = nextIndex(i, len)]) {

ThreadLocal<?> k = e.get();

if (k == key) { // 5

e.value = value;

return;

}

if (k == null) {

replaceStaleEntry(key, value, i);

return;

}

}

tab[i] = new Entry(key, value); // 6

int sz = ++size;

if (!cleanSomeSlots(i, sz) && sz >= threshold)

rehash();

}

来解析一个 ThreadLocalMap:

注释1:一个 Entry包含一个弱引用的 ThreadLocal作为key,一个Object作为value,这个Object我们前面带进来的就是Looper。

注释2:表明 ThreadLocalMap的数据结构是一个 Entry数组

注释3:ThreadLocalMap的set方法借鉴了HashMap得做法,元素的存储位置是 key的hash值mod哈希桶的长度

注释4、注释6:通过注释3,拿到存储位置 i,如果i位置存在有效元素,也就是说发生了哈希冲突,这个时候 i取 nextIndex(i, len),即i 的下一个下标,然后拿新的i去判断这个位置有没有元素,直到找到为空的元素,然后放入这个Looper。

注释5:如果i下标存在元素,但是 它的key和拿来存放数据的 key是一样的,则覆盖value。

到这里我们就知道ThreadLocalMap是做什么的了:

ThreadLocalMap总结:

TheradLocalMap存放 ThreadLocal - Looper的键值对,其中ThreadLocal是弱引用,它采用hash的方式存取,效率比较高,如果出现了hash冲突,则采用线性探测的方法解决。它维护了当前ThreadLocal所持有的Looper。

回到 ThreadLocal.set()中,UI线程的Looper和Looper的成员变量 sThreadLocal被作为 k-v已经被存放到这个 ThreadLocalMap中了。然后又回到 prepareMainLooper()的注释2中: sMainLooper = myLooper(),这里的重点是 myLooper()方法,我们来看看这个方法做了什么:

// Looper.java

public static @Nullable Looper myLooper() {

return sThreadLocal.get();

}

myLooper() 是返回 ThreadLocal.get()

// ThreadLocal.java

public T get() {

Thread t = Thread.currentThread(); // 1

ThreadLocalMap map = getMap(t); // 2

if (map != null) {

ThreadLocalMap.Entry e = map.getEntry(this); // 3

if (e != null) {

@SuppressWarnings(“unchecked”)

T result = (T)e.value;

return result;

}

}

return setInitialValue();

}

注释1、2:获取到当前线程的 ThreadLocalMap

注释3:得到 ThreadLocalMap中当前 ThreadLocal所绑定的Looper。

所以 ThreadLocal.get()获取的是当前线程的Looper

而在当前场景中,我们拿到的是主线程的Looper。

这里也就得出了Handler构造函数中Looper从哪里来的结论:

每个线程只有一个Looper,如果不在Handler构造函数中带入Looper,那么Looper就是:Handler所在线程的Looper。

我们知道Looper是怎么出生的,但是我们还不知道它是做什么的,在这节的开头,我们通过 prepareMainLooper()创建了UI线程的Looper后,我们又通过 Looper.loop()开启了Looper的轮询,这个方法非常重要,我们需要深入它。

2.3 Loop.loop()


来看下 loop()代码:

// Looper.java

public static void loop() {

final Looper me = myLooper(); // 1

final MessageQueue queue = me.mQueue; // 2

for (;😉 { // 3

Message msg = queue.next(); // 4

if (msg == null) { // 5

// No message indicates that the message queue is quitting.

return;

}

final long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;

try {

msg.target.dispatchMessage(msg); // 6

} finally {

}

msg.recycleUnchecked();

}

}

上面滤去了不重要的代码。

注释1:拿到当前线程的Looper(即ui线程的Looper)

注释2:从Looper中拿出 MessageQueue

注释3:开启死循环

注释4:从 MessageQueue.next()中获取一个 Message出来

注释5:如果取出的Message为null,则跳出死循环

注释6:根据 Message的target来通过 dispatchMessage(msg)来发送这个消息。

也就是说 Looper.loop()开启了一个死循环,来处理其 MessageQueue里面的每一个Message。

直到 Message为null了才跳出循环,而英文注释表示 当只有在 MessageQueue退出的时候 Message才为空,而MessageQueue的生命和线程一样,也就是说:线程终止了,这个循环才会结束

所以也从而验证了,这个类为什么叫Looper(循环者,不是某冠军上单)。

这里有两个问题,也是面试的时候面试官喜欢问的:

(1)为什么开了死循环,App却感受不了卡顿,也没有ANR?

逆推回去,既然App没有卡顿,也就是说 MessageQueue有源源不断的message供主线程处理,这是因为像 AMS、WMS这些SystemServer进程在程序运行时会一直提供功能的支持,通过Binder机制,向主进程的主线程中发送消息,所以 Looper的死循环一直是工作状态,所以并不会导致卡顿。

(2)那这样一直开着死循环,不会很占CPU资源吗?

这里就要继续看代码了,我们看看上述循环中 MessageQueue.next()做了什么.

2.4 MessageQueue.next()


因为 next()代码有点长,所以把其中死循环的部分分成三个部分进行讲解:

Part1 无消息处理时挂起

// MessageQueue.java

private native void nativePollOnce(long ptr, int timeoutMillis);

Message next() {

int pendingIdleHandlerCount = -1;

int nextPollTimeoutMillis = 0;

for (;😉 { // 1

if (nextPollTimeoutMillis != 0) {

Binder.flushPendingCommands();

}

nativePollOnce(ptr, nextPollTimeoutMillis); // 2

}

}

注释1中:可以看到的, next()也开了个死循环,WTF?什么鬼,loop()已经是死循环,这里还来套一个死循环了???

注释2:调用 nativePollOnce(ptr, nextPollTimeoutMillis),这个方法非常滴重要,它是一个 native方法,我这里就不再深入到 JNI层去看C的代码了,我这边用大白话来解释这个代码的作用:

在主线程打开Looper循环前,会打开Android系统底层的一个I/O管道(Linux epoll),如果你身边有个后台人员,你可以问他关于 epoll的知识,epoll做的事情就是监视文件描述符的I/O事件,它是 事件驱动模型,换言之,它就是一个NIO。在没有事件时主线程挂起,有事件时主线程唤醒处理。

关于C++层的源码可以参考下这一篇:Android 中 MessageQueue 的 nativePollOnce

函数中传入了 nextPollTimeoutMillis,有点像 Thread.sleep(mills)。这个nextPollTimeoutMillis会在延时任务中起到作用。

这里也就回答了上节末尾的问题,为什么开死循环不会特别占用CPU的资源,是因为在没有消息的时候主线程已经挂起,有消息时才会唤醒。

Part2 返回一个消息

// MessageQueue.java

synchronized (this) {

final long now = SystemClock.uptimeMillis();

Message prevMsg = null;

Message msg = mMessages; // 1

if (msg != null) {

if (now < msg.when) { // 2

nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);

} else {

mBlocked = false;

if (prevMsg != null) {

prevMsg.next = msg.next;

} else {

mMessages = msg.next; // 3

}

msg.next = null;

msg.markInUse();

return msg;

}

} else {

nextPollTimeoutMillis = -1;

}

这段代码比较好理解,不过我们要先知道 MessageQueue里面维护的是一个 Message链表

注释1:拿到当前的MessagemMessage。

注释2:判断 msg是否是延时任务,如果是的话则不处理,并更新 nextPollTimeoutMillis 的时间

注释3:如果 msg不是延时任务,则把 mMessage指向当前 msg在链表中的下一个。然后return当前的msg。

Part3 IdleHandler

synchronized (this) {

if (pendingIdleHandlerCount < 0

&& (mMessages == null || now < mMessages.when)) {

pendingIdleHandlerCount = mIdleHandlers.size(); // 1

}

if (pendingIdleHandlerCount <= 0) { // 2

mBlocked = true;

continue;

}

if (mPendingIdleHandlers == null) {

mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)]; // 3

}

mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);

}

// 4

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

final IdleHandler idler = mPendingIdleHandlers[i];

mPendingIdleHandlers[i] = null;

boolean keep = false;

try {

keep = idler.queueIdle(); // 5

} catch (Throwable t) {

Log.wtf(TAG, “IdleHandler threw exception”, t);

}

if (!keep) {

synchronized (this) {

mIdleHandlers.remove(idler);

}

}

}

pendingIdleHandlerCount = 0; // 6

nextPollTimeoutMillis = 0; // 7

}

}

这段代码涉及到了 IdleHandler,它是MessagQueue的内部类,我们先来看看他是做啥的:

// MessagQueue.java

private final ArrayList mIdleHandlers = new ArrayList();

public static interface IdleHandler {

boolean queueIdle();

}

public void addIdleHandler(@NonNull IdleHandler handler) {

if (handler == null) {

throw new NullPointerException(“Can’t add a null IdleHandler”);

}

synchronized (this) {

mIdleHandlers.add(handler);

}

}

可以发现它是一个接口,通过 addIdleHandler()可以添加实现了 queueIdle()方法的对象进来,保存在 mIdleHandlers的List中。

我们回到part3的代码中来解析一下:

注释1:如果pendingIdleHandlerCount < 0 并且当前没有Message或者是延时任务,则把 mIdleHandlers的大小赋值给pendingIdleHandlerCount 。

注释2:如果赋值过后的pendingIdleHandlerCount <= 0,则说明当前线程没有可以执行的任务,那么continue,在下一个循环中将当前线程挂起。

注释3:如果mPendingIdleHandlers为null,则new一个IdleHandler出来赋值给它,并将其转换成一个 数组。

注释4:遍历 mPendingIdleHandlers

注释5:取出其中的每个 IdleHandler并执行它的 queueIdle()方法。

注释6、注释7:清空 pendingIdleHandlerCount 和 nextPollTimeoutMillis 。因为如果是延时任务,早就已经continue了。另外一个事情就是,如果第一次打开死循环,Message链表是空的,这时候主线程可以做些别的事情(就是 IdleHandler),做完之后,之后的循环就不会再去做这样的操作了(除非我们自己加IdleHandler进来)。

到这里 MessageQueue.next()就解析完了,这里做一个总结,它大概做了三件事情:

  1. 开启死循环

  2. 如果当前没有Message可以处理,则调用 nativePollOnce()挂起主线程,不占用CPU资源。 等到有消息可以处理时唤醒主线程。

  3. 如果存在非延时的消息,则把该消息返回到Looper中。

  4. 如果是循环的第一次,可能会没有消息,这个时候可以 处理IdleHandler的消息,做一些别的事情,这相当于提升了性能,不让主线程一上来就因为没有Message而挂起,然后下个循环又马上被唤醒。

2.5 Message是如何被加入到MessageQueue中的?


在上一节中,我把MessaqeQueue和Messaqe的关系笼统的概括为: MessageQueue中维护了一个Message链表。

仅仅这样理解是不够的,我们需要搞清楚,一个Message是怎么放到 MessageQueue中的。入口方法就是我们常用的 Handler.sendMessage()或者 Handler.sendMessageDelayed()还是 Handler.postDelay(),他们最终都会调用 sendMessageAtTime()

// Handler.java

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

}

这个方法会调用 enqueueMessage(),然后传入当前 Handler的 MessageQueue() mQueue,我们在开篇讲过,mQueue是构造函数中就被创建,它是传入的 Looper的MessageQueue。

// Handler.java

private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {

msg.target = this; // 1

if (mAsynchronous) {

msg.setAsynchronous(true);

}

return queue.enqueueMessage(msg, uptimeMillis); // 2

}

注释1:把msg.target = this就是说这个消息发送给当前Handler的

注释2:调用 MessageQueue.enqueueMessage(),传入消息体和延时时间。这个动作就是把消息加入到 MQ中了。

来看一下加入过程:

// MessageQueue.java

boolean enqueueMessage(Message msg, long when) {

synchronized (this) {

msg.markInUse();

msg.when = when;

Message p = mMessages; :

boolean needWake;

if (p == null || when == 0 || when < p.when) { //1

msg.next = p;

mMessages = msg;

needWake = mBlocked;

} else {

needWake = mBlocked && p.target == null && msg.isAsynchronous();

Message prev;

for (;😉 { // 2

prev = p;

p = p.next;

if (p == null || when < p.when) {

break;

}

if (needWake && p.isAsynchronous()) {

needWake = false;

}

}

msg.next = p;

prev.next = msg;

}

if (needWake) {

nativeWake(mPtr);

}

}

return true;

}

注释1:如果当前的Message是非延时任务,或者说比列表最后一个Message的执行时间要快,那么它就插队,插到前面去。

注释2:否则的话,就往列表后边找,找到 末尾或者第一个Message执行时间比当前的要晚的,然后插进去。

这段代码很简单,要么当前任务 插到前面,要么插到后面最早执行的位置。

到这里,一个Message的入队就讲完了。

2.6 关于消息分发 msg.target.dispatchMessage(msg)


回到Looper中,在死循环里,它会调用 msg.target.dispatchMessage(msg), 我们知道,msg.target是发送消息的Handler,那么这里调用了 目标Handler的dispatchMessage()把Message发送出去,来看看这个方法:

// Handler.java

public void dispatchMessage(Message msg) {

if (msg.callback != null) {

handleCallback(msg); // 1

} else {

if (mCallback != null) { // 2

if (mCallback.handleMessage(msg)) { // 3

return;

}

}

handleMessage(msg); // 4

}

}

private static void handleCallback(Message message) {

message.callback.run();

}

注释1:如果msg.callback不为空,则直接调用这个Callback的 run(),这个情况是我们在Message中传入了一个Callback的方法,它在处理时,会直接调用这个Callback方法。

注释2、注释3:如果在创建 Handler(构造函数中)时带入了 一个 Callback,则直接调用这个 Callback,这个构造函数我们在开篇看过。

注释4:如果都不是上述情况,则调用 handleMessage()

// Handler.java

/**

  • Subclasses must implement this to receive messages.

*/

public void handleMessage(Message msg) {

}

handleMessage() 是一个空方法,英文文档的解释为: 子类必须实现这个方法

这就是为什么我们在自己使用的时候,一定要重写 handleMessage(),然后对发送过来的消息做处理。

当这个消息被调用时,也说明了 Message被分发成功了。

到这里,handler的源码也讲解的差不多了。

3. 在子线程中更新UI

==============================================================================

这里画一个图便于理解:

在这里插入图片描述

这里手写一个简单例子,在子线程通过Handler去更新UI线程:

public class MainActivity extends AppCompatActivity {

private TextView textView;

private static final int COUNT = 0x00001;

private Handler handler = new Handler() { // 1

@Override

public void handleMessage(Message msg) {

if (msg.what == COUNT) {

textView.setText(String.valueOf(msg.obj));

}

super.handleMessage(msg);

}

};

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

textView = findViewById(R.id.btn);

new Thread() { // 2

@Override

public void run() {

try {

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

Thread.sleep(500);

Message msg = new Message();

msg.what = COUNT;

msg.obj = “计数:” + i;

handler.sendMessage(msg); // 3

}

} catch (Exception e) {

e.printStackTrace();

}

}

}.start();

}

}

注释1中:我们在主线程创建了一个Handler,那么其Looper自然就是UI线程的Looper

注释2:开启一个子线程

注释3:在子线程中,持有一个主线程的Handler,然后给这个Handler发送消息。

上面代码非常容易理解,但是可能会出现内存泄漏,所以这里只是做一个子线程更新UI的认知。

4. 主线程更新子线程、子线程更新其他子线程

========================================================================================

开篇我说过,Handler的出现是子线程不能更新UI,所以Handler起到了这个作用。

这一章是来自于我实习面试的时候,面试官在Handler这点上问我:那主线程怎么更新子线程?或者子线程怎么更新其他的子线程?

我当时由于没有细读Handler的源码,所以我粗略的回答:只要持有对方线程的Handler,就可以更新了。

其实我回答也没错,但是这谈不上及格的答案,面试官也不会喜欢,下面我将用代码来演示一遍:

4.1 主线程更新子线程


public class MainActivity extends AppCompatActivity {

private static final String TAG = “MainActivity”;

private MyThread thread;

class MyThread extends Thread {

private Looper looper;

@Override

public void run() {

Looper.prepare(); // 1

Log.d(TAG, “子线程为->” + Thread.currentThread() + “”);

looper = Looper.myLooper(); // 2

Looper.loop(); // 3

}

}

private Handler mHandler;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

Log.d(TAG, “主线程为->” + Thread.currentThread() + “”);

thread = new MyThread();

thread.start();

try {

sleep(2000); // 4

} catch (InterruptedException e) {

e.printStackTrace();

}

mHandler = new Handler(thread.looper) { // 5

@Override

public void handleMessage(Message msg) {

Log.d(TAG, “当前线程为->” + Thread.currentThread() + “”);

}

};

mHandler.sendEmptyMessage(0);

}

}

主线程要更新子线程,所以主线程要持有子线程的Handler,所以需要用子线程来构造一个Handlder

注释1:为子线程创建一个Looper

注释2:获取子线程Looper

注释3:打开子线程Looper的轮询

注释4:主线程睡一下,避免 子线程还没有完全起来的时候就在主线程获取子线程的Looper了

注释5:通过子线程的Looper创建属于子线程的Handler,然后在主线程中使用Handler发送消息。

打印如下:

在这里插入图片描述

说明消息已经从主线程传递到子线程当中去了。

4.2 子线程更新其他子线程


方法同上。下面写两个线程 MyThread1MyThread2,然后让2线程给1线程发消息:

public class MainActivity extends AppCompatActivity {

private static final String TAG = “MainActivity”;

private MyThread1 thread1;

private MyThread2 thread2;

private Handler thread1Handler;

class MyThread1 extends Thread {

@Override

public void run() {

Log.d(TAG, “子线程1为->” + Thread.currentThread() + “”);

Looper.prepare();

thread1Handler = new Handler() {

@Override

public void handleMessage(Message msg) {

super.handleMessage(msg);

Log.d(TAG, “这个消息是从->” + msg.obj + “线程发过来的,在” + Thread.currentThread() + “线程中获取的”);

}

};

Looper.loop();

}

}

class MyThread2 extends Thread {

@Override

public void run() {

Log.d(TAG, “子线程2为->” + Thread.currentThread() + “”);

总结

Android架构学习进阶是一条漫长而艰苦的道路,不能靠一时激情,更不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!

上面分享的字节跳动公司2020年的面试真题解析大全,笔者还把一线互联网企业主流面试技术要点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

就先写到这,码字不易,写的很片面不好之处敬请指出,如果觉得有参考价值的朋友也可以关注一下我

①「Android面试真题解析大全」PDF完整高清版+②「Android面试知识体系」学习思维导图压缩包阅读下载,最后觉得有帮助、有需要的朋友可以点个赞

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

class MyThread1 extends Thread {

@Override

public void run() {

Log.d(TAG, “子线程1为->” + Thread.currentThread() + “”);

Looper.prepare();

thread1Handler = new Handler() {

@Override

public void handleMessage(Message msg) {

super.handleMessage(msg);

Log.d(TAG, “这个消息是从->” + msg.obj + “线程发过来的,在” + Thread.currentThread() + “线程中获取的”);

}

};

Looper.loop();

}

}

class MyThread2 extends Thread {

@Override

public void run() {

Log.d(TAG, “子线程2为->” + Thread.currentThread() + “”);

总结

Android架构学习进阶是一条漫长而艰苦的道路,不能靠一时激情,更不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!

上面分享的字节跳动公司2020年的面试真题解析大全,笔者还把一线互联网企业主流面试技术要点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

就先写到这,码字不易,写的很片面不好之处敬请指出,如果觉得有参考价值的朋友也可以关注一下我

①「Android面试真题解析大全」PDF完整高清版+②「Android面试知识体系」学习思维导图压缩包阅读下载,最后觉得有帮助、有需要的朋友可以点个赞

[外链图片转存中…(img-SrkTFeoD-1715125242790)]

[外链图片转存中…(img-zSpwQSTm-1715125242790)]

[外链图片转存中…(img-lbK0nNZj-1715125242791)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值