Android 从Java线程到Handler机制源码分析

线程和进程

进程

进程是操作系统中程序执行时的一个实例,是一块独立运行区域,进程之间不会共享数据,都有各自的数据管理。

线程

线程是程序执行的最小单位,是CPU调度和分派的基本单位。

Java 线程

开启线程的几种方式
  1. 继承Thread类实现run方法
class MyThread extends Thread{
    @Override
    public void run() {
        doSomething();
    }
}

MyThread myThread = new MyThread();
myThread.start();
  1. 实现Runnable接口传入Thread
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        doSomething();
    }
};
new Thread(runnable).start();
  1. 实现Callable接口传入FutureTask,再将FutureTask传入Thread
FutureTask<String> futureTask = new FutureTask<String>(new Callable<String>() {
    @Override
    public String call() throws Exception {
        return "Hello World!";
    }
});
new Thread(futureTask).start();
String result = futureTask.get();
  1. 利用ExecutorService线程池执行Runnable
ExecutorService executors = Executors.newSingleThreadExecutor();
executors.execute(new Runnable() {
    @Override
    public void run() {
        doSomething();
    }
});

线程安全问题

线程安全的本质主要有原子性,可见性,有序性三点:

  1. 原子性:多线程操作时,只有一个操作执行或者多个操作全部执行,且不会被打断。
  2. 可见性:当多线程访问变量时,其中一个线程修改了变量,其他线程能立即看到修改的值。
  3. 有序性:程序执行的顺序按照代码的先后顺序执行。

volatile

保证了变量的可见性,但不保证原子性;
代码示例:

private static boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
    new TestThread().start();
    Thread.sleep(1000);
    isRunning = false;
}
static class TestThread extends Thread{
    @Override
    public void run() {
        while (isRunning){
        }
        System.out.println("TestThread stop");
    }
}

如果运行以上 main 方法,则会发现 “TestThread stop” 并不会被打印,isRunning 变量会被 TestThread 线程拷贝一份,在 main 方法中修改 isRunning 并没有影响到 TestThread 中拷贝的变量。
当对 isRunning 加上 volatile 关键字修饰后

private static volatile boolean isRunning = true;

即可看到运行一秒后打印 “TestThread stop”。但是 volatile 只是强制刷新了主内存的值,并没有实现原子性操作,看以下代码验证:

private static volatile int count = 0;
static void add() {
    count++;
}
public static void main(String[] args) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1_000_000; i++) {
                add();
            }
            System.out.println("线程1最终结果:" + count);
        }
    }).start();
    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1_000_000; i++) {
                add();
            }
            System.out.println("线程2最终结果:" + count);
        }
    }).start();
}

启动两个线程分别调用 add() 方法一百万次对 count 变量执行 ++ 操作,预期结果是其中一个线程输出 二百万,但实际运行结果并不理想:
在这里插入图片描述
count ++ 其实可以拆解成以两步:

int temp = count + 1;
count = temp;

线程执行到其中任何一行代码时都可能被操作系统调度到另一个线程,也就造成了 count 的复写。解决原子性问题就需要用到锁。

锁机制

synchronized

针对上述执行 count++ 操作的问题,通过 synchronized 关键字很容易就解决了,只需要给 add 方法增加上 synchronized 修饰即可:

static synchronized void add() {
	count++;
}

再次执行:
在这里插入图片描述
synchronized 提供了一种保证了方法、代码块内部的资源的互斥访问机制。实际上它会对修饰的方法、代码块提供一个监视器 Monitor,监视当前代码块是否被其他线程访问,当多个线程同时访问同一个 synchronized 修饰的方法时会先访问 Monitor, Monitor 检查是否有其他线程正在访问,如果有则其他线程进行等待。
看一下更具体的实例:

class DemoSynchronized {
    int x = 0;
    int y = 0;

    String z = "";

    synchronized void setValue(int value) {
        x = value;
        y = value;
    }

    synchronized void setStr(String str) {
        z = str;
    }
}

上述两个方法 setValue,setStr 同时被 synchronized 修饰,当多线程访问两个方法时,所对应的 Monitor 其实都是一个,并不能同时访问 setValue setStr,上述方法的写法等价于:

void setValue(int value) {
    synchronized (this){
        x = value;
        y = value; 
    }
}
synchronized void setStr(String str) {
    synchronized (this){
        z = str;
    }
}

可以看出,都是用的 this 锁,如果想要两个方法分别纸牌一个 Monitor,可以改为如下写法:

final Object lock1 = new Object();
final Object lock2 = new Object();
void setValue(int value) {
    synchronized (lock1){
        x = value;
        y = value;
    }
}
synchronized void setStr(String str) {
    synchronized (lock2){
        z = str;
    }
}

改写后,实现了不同的 Monitor 管理不同的方法,当 setValue 方法被线程访问时,setStr 方法并不会受影响。
这里多说一点:synchronized 的 Demo 是新写了一个类来解释,并没有在最初的 Demo 上做修改,最初的 Demo 大多都是 static 修饰的静态方法,synchronized 修饰静态方法时,上述的 lock1, lock2 也就失效, 那么如何解决?将 lock1 lock2 也用 static 修饰即可,这个原因也很简单,静态方法怎么能访问本地变量作为锁呢?

死锁

线程安全的本质,就是锁,保证方法执行的原子性,当业务复杂度上去后,锁也会出现多层的情况,就有可能引发死锁问题,修改上述 DemoSynchronized 代码,新增两个方法:

// 先修改 x y 在修改 z
void setXYZ(int value){
    synchronized (lock1){
        x = value;
        y = value;
        synchronized (lock2){
            z = value + "";
        }
    }
}

// 先修改 z 再修改 x y
void resetXYZ(){
    synchronized (lock2){
        z = null;
        synchronized (lock1){
            x = 0;
            y = 0;
        }
    }
}

当多线程同时调用 setXYZ 、resetXYZ 方法时,可能会出现一种情况 A 线程访问 setXYZ 得到了 lock1
的锁,B 线程访问 resetXYZ 拿到了 lock2 锁,然后都进入了等待状态永远拿不到另一个锁,无法执行下面的代码,造成死锁问题。在 Android 开发中出现此情况比较少,这里就简单了解下。

悲观锁 乐观锁

悲观锁 乐观锁其实是一种编程思想或者说是人们的一种定义,是对数据冲突的解决方式。想象一个场景:从数据库读出最新的一条数据,进行大量计算得出结果,再写回去数据库,这是一种很常见的需求,在这个需求的基础上增加高并发,当 A 用户取出数据库中的数据进行计算的过程时,B 用户可能已经完成了计算写回数据库,这时 A 用户发现数据库最新的数据已经和当时取出来的数据不一样了,这种情况怎么办?
当遇到这种情况,发现最新数据和刚刚取到的数据不同,那么我再取一次最新的数据,重新计算,然后再写回数据库(写回去的过程是一定要加锁的),这就叫乐观锁。
当然,我也可以这么设计:当 A 用户读数据的时候就对数据上锁,在计算和写回去的过程中不允许其他任何人进行读写操作,直到 A 用户完成了再允许其他用户进行操作,这就叫悲观锁。
其实悲观锁 乐观锁就是对并发业务场景的控制方式,这两概念主要是运用在数据库方面的思想,在 Android 上的场景也并不是特别多,也是了解一下就可以了。

读写锁

synchroized 关键字实现了原子性,那么也会带来其他问题,多线程在对变量进行写操作时,不允许其他线程读写,这没问题,但是读操作呢?如果多线程读取变量,需要这个限制吗?当然是不需要的,但是
synchroized 将读写都变成了原子性,如果希望变量的写操作是原子性,而读操作不受影响,也就是说在写变量时,不允许其他线程读写,当读变量时只仅仅不允许其他线程写,但允许读。那么就需要通过 ReentrantReadWriteLock 来进行更细致的管理。在 DemoSynchronized 中增加以下代码:

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
void write(int value){
    writeLock.lock();
    try {
        x = value;
        y = value;
        z = value + "";
    }finally {
        writeLock.unlock();
    }
}
void read(){
    readLock.lock();
    try {
        System.out.println(x);
        System.out.println(y);
        System.out.println(z);
    }finally {
        readLock.unlock();
    }
}

这样就实现了多线程访问情况下,进行 write 调用时只允许一个线程访问,并且 write 时不允许其他线程调用 read;进行 read 调用时其他线程可以同时调用 read 方法,但不允许同时调用 write 方法。

单例模式中的锁应用

来通过一段逐步完善的单例类的getInstance方法设计来实践一下 volatile、synchronized 的使用,下面的 getInstance 1-4 的注释中分别解释了当前写法的问题,getInstance4 结合 volatile 关键字的使用才实现了一个线程安全的且效率较高的单例模式:

public class SingleUtil {

    // private static SingleUtil instance = null;

    // getInstance4 方法存在小瑕疵 需要用 volatile 关键字开启同步
    private static volatile SingleUtil instance = null;

    private SingleUtil() {
    }

    /**
     * 当多线程访问时 会出现多次初始化的问题
     */
    public static SingleUtil getInstance1() {
        if (instance == null) {
            instance = new SingleUtil();
        }
        return instance;
    }

    /**
     * 解决了多线程多次初始化问题 但是每次访问的效率比较低
     */
    public static synchronized SingleUtil getInstance2() {
        if (instance == null) {
            instance = new SingleUtil();
        }
        return instance;
    }

    /**
     * 和 getInstance1 问题一样 会出现多次初始化的问题
     */
    public static SingleUtil getInstance3() {
        if (instance == null) { // 多线程访问 可能都为 null 进入此 if
            synchronized (SingleUtil.class) { // 锁的是 instance 初始化操作 多线程进入 if 会排队多次初始化
                instance = new SingleUtil();
            }
        }
        return instance;
    }

    /**
     * 解决 getInstance3 多次初始化问题
     * 但仍然有瑕疵,多线程访问(A、B线程) 
     * A 线程进入后 开始执行初始化操作 初始化操作还没结束 但是 instance 已经不为 null
     * B 进入后 获取的 instance 已经不为 null 但是 初始化操作并没有完成
     * 解决这个问题 需要在 instance 对象添加 volatile 关键字
     */
    public static SingleUtil getInstance4() {
        if (instance == null) {
            synchronized (SingleUtil.class) {
                if(instance == null){
                    instance = new SingleUtil();
                }
            }
        }
        return instance;
    }
}

线程间通信

线程间通信也就说线程间的交互,下面会讲述一些非常常见的场景

一个线程启动另一个线程

在主线程中 new Thread().start() 就算是主线程启动一个自线程,这个场景过于简单就不再多说了。

一个线程停止另一个线程

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(){
        @Override
        public void run() {
            while (true){
                System.out.println(System.currentTimeMillis());
            }
        }
    });
    t.start();
    Thread.sleep(1000);
    t.stop();
}

Thread 提供了 stop 方法终止线程,运行上述代码后,程序执行大约一秒左右后就会停止。stop 方法已经被标记为 @Deprecated ,因为这种终止是比较暴力的,直接杀死线程,当线程中存在状态控制时,这种暴力终止就会很危险,状态无法恢复至预期的结果,所以这种停止方法不推荐。

即然 stop 被标记为 Deprecated 那么就有更好的方法 interrupt(),修改代码为:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(){
        @Override
        public void run() {
            while (!isInterrupted()){
                System.out.println(System.currentTimeMillis());
            }
        }
    };
    t.start();
    Thread.sleep(1000);
    t.interrupt();
}

interrupt() 在我理解这算是一种配合式的终止,当调用 t.interrupt(); 后 t 线程就被标记为需要被终止,通过内部的 isInterrupted() 方法可以获取是否需要被终止,然后线程内自己写终止逻辑,可以在终止前做一些收尾工作,当然也可以不终止继续执行,完全靠线程自己去控制。除了通过 isInterrupted() 方法获取是否需要被终止,还可以用 Thread.interrupted() 静态方法获取,不过 Thread.interrupted() 不仅仅会返回终止状态并且在返回结果后会将终止状态置为 false。

线程的交替执行

等待和唤醒 (wait、notify、notifyAll)

在开发中经常遇到一种情况,一个操作需要等待另一个操作完成之后再执行,模拟以下场景当进行sdk调用时,往往需要先初始化sdk这个过程是耗时的,模拟代码:

class Sdk {

    public String status = null;

    //模拟sdk初始化
    public synchronized void initSdk(){
        status = "SUCCESS";
        notify();
    }

    //模拟sdk调用
    public synchronized void doWork() throws InterruptedException {
        if (status == null){
            wait();
        }
        System.out.println(status);
    }

    public void run(){
        new Thread(){
            @Override
            public void run() {
                // sdk 调用
                try {
                    doWork();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                try {
                    //模拟初始化操作
                    sleep(3000);
                    initSdk();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();
    }
}

在 main 方法中调用:

public static void main(String[] args) throws InterruptedException {
    Sdk sdk = new Sdk();
    sdk.run();
}

执行结果:
在这里插入图片描述

doWork() 在 initSdk() 执行完成后成功调用了,核心就在于 wait() 和 notify(),doWork 虽然先执行了,当进入 if 语句时 sdk 未初始化,然后调用 wait() 方法 进入等待状态,但是线程并没有被杀死。当 initSkd() 执行时,调用了 notify() 会唤醒进入等待状态的线程(notify 只会唤醒一个等待的线程,如果多个线程等待是无法预知会唤醒哪个!),因为此时只有一个线程在等待所以成功被唤醒,执行了 doWork,多线程情况下用 notifyAll 最好。
值得注意的是,wait、notify、notifyAll 方法需要在 synchronized 修饰的代码块内调用,否则会运行时会报错,这里想一下也很容易理解,只有共享的资源需要互斥访问的资源才会需要等,如果都不需要互斥访问还需要等这个操作吗?那么也就不难理解,其实 wait、notify、notifyAll 是由 Monitor 调用的,那么同理,当代码中 synchronized 指派了指定的 Monitor 那么调用 wait 时就需要 Monitor.wait() 通过指定的 Monitor 来调用,notify、notifyAll 也是一样的道理。

特殊的等待和唤醒 —— join()

为什么说 join 是特殊的等待和唤醒呢?因为 join() 本质上就是帮我调用了 wait() 和 notifyAll(),通过一段代码比较来了解下 join():

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread() {
            @Override
            public void run() {
                try {
                    sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("子线程执行");
            }
        };
        thread.start();
        System.out.println("主线程执行");
    }

执行结果:
在这里插入图片描述
修改最后几行代码为:

thread.start();
thread.join();
System.out.println("主线程执行");

执行结果:
在这里插入图片描述
join() 方法调用后会在内部调用 wait() 方法让当前线程(示例中也就是主线程)进入等待状态,当 thread 执行结束被杀死之前内部会调用 notifyAll() 方法恢复当前线程执行。

yield()

这个方法调用后,当前线程的代码就不会继续执行,会稍微让出一下当前线程的执行权,让出给同优先级的线程去执行,让出并不等于等待。这个目前还没想到合适的模拟代码以及使用场景,后续再补充吧。

Android 线程机制

HandlerThread

HandlerThread 是一个包含 Handler 机制的线程,本质上是一个死循环在不断读取一个消息队列,每当收到消息就开始执行消息的具体任务,没有消息时就休眠,先简单看一下使用方法:

HandlerThread handlerThread = new HandlerThread("thread-1");
handlerThread.start();

Handler handler = new Handler(handlerThread.getLooper());
handler.post(new Runnable() {
    @Override
    public void run() {
        Log.e("当前线程", Thread.currentThread().getName());
        handlerThread.quit();
    }
});

输出结果:
在这里插入图片描述
在了解 Handler 机制之前先看一下 HandlerThread 源码:
在这里插入图片描述
HandlerThread 继承自 Thread,那么顺着这个点直接看一下 run 方法的实现:
在这里插入图片描述
由 run 方法可知当我们调用 start() 后,HandlerThread 内部的 Handler机制就启动了,每当我们 post 一个 Runnable,都会加入到 Handler 消息队列中依次执行。下面来着重介绍下 Handler 机制。

Handler消息机制

先来简单说一下 Handler 中包含的几个重要角色:

  1. Handler 负责发送消息到消息队列,接受消息并处理消息;
  2. Message 消息,内部可以存放 int、object 类型变量,并且内部的 target 变量是一个 Handler 实例(可为空);
  3. MessageQueue 消息队列,通过 Handler 的 post 或 sendMessage 方法将 Message 入队,内部包含 next() 方法从队列中取出下一个要执行的消息;
  4. Looper 其中包含一个 MessageQueue ,Looper 不难理解算是一个启动开关,内部利用一个 for 的死循环不断从其内部的 MessageQueue.next 方法取消息,取出 Message 后调用其 target (也就Handler 对象)的 dispatchMessage 方法交由 Handler 去处理消息;

大致介绍了下几个重点对象,结合下面一张图来深入理解一下这套机制的运转逻辑:
在这里插入图片描述
如上图所示,当 HandlerThread 的 start() 方法调用后,会执行 Looper.loop(),Handler 机制启动,也就是 Looper 内部的死循环开始取消息,当然 Looper 内部的 MessageQueue 也是在这时候启动,当调用 Handler sendXXX 时,消息队列中添加 Message(Message内部也会持有发送它的 Handler 对象),Looper 的死循环这时就会取出 Message,再回调到 Handler 的 dispatchMessage。
顺着这个思路来看一下源码:
在这里插入图片描述
还是从上面的示例代码开始,首先调用了 handlerThread.start(),会执行 HandlerThread 内部的 run 方法:
在这里插入图片描述
可以看出并没有 new Looper,而是直接调用了静态方法,那么需要看一下 Looper 是如何初始化的,看一下第一次调用 Looper 的代码,也就是 Looper.prepare():
在这里插入图片描述
可以看出 Looper 是在内部帮我们自动 new 了一个实例,并且存储到了 sThreadLocal 中:
在这里插入图片描述
sThreadLocal 是一个 static final 修饰的对象,那么 sThreadLocal 只会初始化一次,那么也就说明 一个线程中只能存在一个 Looper 对象,接着看一下 Looper 的构造方法都干了什么:
在这里插入图片描述
初始化了 MessageQueue 消息队列,因为一个线程中只存在一个 Looper 对象,那么一个线程中也就只会有一个 MessageQueue,看完了 Looper 初始化代码,继续回过头看一下 Looper.loop() 的启动源码:
在这里插入图片描述
本质就是一个死循环,继续看一下 loopOnce 方法, loopOnce 方法比较长,我就截图一些重点代码了:
在这里插入图片描述
在这里插入图片描述
这就是 Looper 的核心代码了,大概了解了 Looper 后,再回过头看一下 MessageQueue 是如何取出消息的, MessageQueue.next() 方法(方法也比较长,需要多个截图,分析写在截图上):
在这里插入图片描述在这里插入图片描述
看完取消息的逻辑,再接着看一下消息入队的逻辑,首先是 Handler 发送消息,不管是postXXX 还是 sendMessage 最终都调用到了sendMessageAtTime:
在这里插入图片描述
接着看最后一行的 enqueueMessage :
在这里插入图片描述
最终又调到了 MessageQueue 的 enqueueMessage 方法:
在这里插入图片描述
在这里插入图片描述

Handler 同步屏障机制

根据上面的代码分析可以得知,当我们用Handler发送消息入队时,会判断 Message.target 如果为null 则抛出异常,但是在 MessageQueue.next 方法中取消息时又判断了 message.target 为 null 的情况,这是为什么?
我们知道 android 的屏幕也就是 UI 部分,每隔 16ms 就会刷新,这样符合人的视觉,不会觉得卡顿,Handler 的消息机制据上面的分析又是依次一个个执行 Message,刷新UI也是其中一个Message,那么是如何做到 16ms 就刷新呢?这就用到了同步屏障机制,在 MessageQueue 中有这样一个方法 postSyncBarrier:
在这里插入图片描述
可以看出,这里的大体逻辑就是在消息队列中插入了一个 target 为 null 的 Message,回想一下 MessageQueue.next 方法中的一个 if 判断:
在这里插入图片描述
上面的 isAsynchronous 是判断这个消息是否是异步消息,再回看一下我们正常用 Handler 发送消息时的源码:
在这里插入图片描述
这个 mAsynchronous 默认是 false 的,不会进入这个 if 语句,那么通常我们使用的 Handler 中发送消息默认是同步消息,综合以上代码思考一下,如果系统需要一个Message立马执行,需要先通过 MessageQueue 的 postSyncBarrier 设置一个同步屏障,接着再发送一个异步消息的Message,当消息队列发现同步屏障后,会遍历队列找出异步消息,优先执行,这样就保证了一些优先级高的消息(如 16ms 刷新)优先执行。

Handler 如何实现线程切换

首先先思考一下利用 Handler 进行线程切换的场景。实际开发场景下,通常是在 activity 中 new 一个 Handler 重写 handlerMessage 方法 , 在子线程中用这个 handler sendMessage 接着会触发重写的 handlerMessage 方法,达到线程切换的目的,代码示例:

Handler handler = new Handler(){
    @Override
    public void dispatchMessage(@NonNull Message msg) {
        super.dispatchMessage(msg);
        switch (msg.what){
            //线程处理消息...
        }
    }
};

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    new Thread(new Runnable() {
        @Override
        public void run() {
            Message msg = Message.obtain();
            msg.obj = obj;
            msg.what = 0x1;
            //子线程发送消息
            handler.sendMessage(msg);
            //除了sendMessage 用post方法也是一样的效果
			//post 方法最终也是构造了一个Message
			handler.post(new Runnable() {
    			@Override
    			public void run() {
        			//要切换到主线程做的事..
    			}
			});
        }
    }).start();
}

通过上面的代码,就实现了子线程切换到主线程。回想一下Handler机制是如何运转的:一个线程只有一个Looper,Looper循环取自身MessageQueue中的Message,一个线程也就只有一个MessageQueue,Handler初始化的时候可以传入一个Looper,默认不传是获取当前线程的Looper,那么上述代码,Handler 在主线程初始化,其内部的Looper就是主线程的Looper,那么在任何线程用这个Handler发送Message都不影响它的执行线程,所以,不论在主线程、子线程发送消息,它的执行线程跟本身没有任何关系,Handler初始化的Looper属于哪个线程,那么消息的处理就会在哪个线程,子线程切换到另一个子线程,只需要给 Handler 初始化的时候传入子线程的 Looper 即可:

HandlerThread handlerThread = new HandlerThread("thread-1");
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper());

上述代码就初始化了一个子线程的 Handler,用这个 handler 发送的消息,最后的处理都在 thread-1 线程。

Android 主线程的 Handler 是如何启动的
Handler handler = new Handler();

一般来说我们在 activity 中 new Handler() 可以不传参数,默认是主线程,那么主线程的的 Looper 是在何时启动的?答案在 android 系统源码的 ActivityThread 类中:
在这里插入图片描述
在这里插入图片描述

延伸:ANR问题

在上面的分析中,会有一个疑问,Android 中的 ANR,如果当 Message 处理的逻辑特别耗时,会不会ANR?
其实这个问题本身就有问题,ANR 是当一些特殊的消息处理超过特定的时间没有执行完成,Handler 机制会将其取出并且触发 ANR,而 Handler 机制本身只是一个循环不断去取消息,并不会造成 ANR。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值