Android Gradle 中的字节码插桩之ASM(八)

前言

         逐步整理的一系列的总结:

        Android Gradle插件开发初次交手(一)

        Android Gradle的基本概念梳理(二)

       Android 自定义Gradle插件的完整流程(三) 

       Android 自定义Task添加到任务队列(四)

       Android 自定义Gradle插件的Extension类(五)

       Android Gradle 中的Transform(六)

      Android Gradle之Java字节码(七)

      Android Gradle 中的字节码插桩之ASM(八)

      Android Gradle 中的使用ASMified插件生成.class的技巧(九)

      Android Gradle 中的实例之动态修改AndroidManifest文件(十)


         在 Android Gradle 中的Transform(六)中只是简单的了解了怎么能够怎么在apk打包流程中进行对.class文件进行修改处理,那么具体实现一个像在 Android Gradle 中的Transform(六)前言说的那些功能实现,还需要用到一些字节码框架。

        在JVM平台上,常见的处理字节码的框架最常见的就三个:ASM,Javasist,AspectJ(这个不就是在小白新手web开发简单总结(十二)-数据库连接的相关优化(事务管理)中的三 声明式事务管理里面的一个概念吗?有点开心)。

一 ASM

 1.AOP                

        先认识一个概念AOP。

        AOP(Aspect Oriented Programming):面向切面编程。相比较于OOP的将问题化为单个模块,AOP就是将这些模块的某一类问题进行统一管理。把横跨并嵌入到众多模块的功能集中起来,放到一个统一的地方来管理和控制。

        通常有三种实现方式:

  • (1)生成子类的字节码
  • (2)生成代理类的字节码
  • (3)直接修改源类的字节码

        在Android中通常在.class文件转换成.dex文件的时候,通过一些字节码框架实现对应的功能,即在  Android Gradle 中的Transform(六) 的三 Android Transform高级应用中对提到.对jar文件和.class文件的增加修改逻辑地方中通过字节码框架实现对应的功能。

2.ASM

        ASM 是一个字节码操作框架,可以动态生成类或者为增加既有类的功能。该框架可以直接产生二进制的class文件,也可以在类被加载Java虚拟机之前改变类的行为。ASM相比较于Javasist,AspectJ或者反射,可以更底层的处理字节码的每条命令、处理速度更快,占用内存更小。

        有两种API类型:Tree API和Visitor API。

  • (1)Tree API:也称为对象模型。将class的内容都读取到内存,构建成一个树形结构,在处理Method、Field等元素的时候,定位到树形结构中的元素,最后写入新的class文件;
  • (2)Visitor API:也称为事件模型。每次扫描到类文件的相应内容的时候,就会回调API中对应的方法,然后处理完之后可覆盖原来的.class文件实现代码注入。其中几个核心类如下:
    • 1)ClassReader:解析编译过的.class文件,可以获取该文件中的类名、接口、成员名、方法参数等;
    • 2)ClassWriter:重构编译之后的类,用来修改类名、属性、方法等以及生成新的.class文件;
    • 3)ClassVisitor:访问类信息。包括标记在类上的注解、类构造方法、类字段、类方法、静态代码块;
    • 4)AdviceAdapter:实现了MethodVisitor接口,主要访问方法的信息。用来对具体方法进行字节码操作;
    • 5)FieldVisitor:访问具体的类成员;
    • 6)AnnotationVisitor:访问具体的注解信息

        在使用ASM框架的时候,需要在引入其对应的依赖,如下:

dependencies {
    implementation 'org.ow2.asm:asm:9.2'
}

        对应maven库地址为https://mvnrepository.com/artifact/org.ow2.asm/asm,可在里面选择合适的版本引入到项目的dependencies {}中。 

二 ASM中几个重要的类  

1.ASM之ClassVisitor

        抽象类,主要用来访问类信息。其中几个重要的方法如下:

  • (1)构造函数

        当继承ClassVisitor来实现一个具体的访问类的类时,必须增加一个构造函数来通过super()调用到该抽象类的构造函数,例如

    public AutoLogClassVisitor(ClassVisitor visitor) {
        super(Opcodes.ASM9, visitor);
    }

        其中 ClassVisitor的构造函数的api这个参数,如下:

  /**
   * Constructs a new {@link ClassVisitor}.
   */
  public ClassVisitor(final int api, final ClassVisitor classVisitor) {
    if (api != Opcodes.ASM9
        && api != Opcodes.ASM8
        && api != Opcodes.ASM7
        && api != Opcodes.ASM6
        && api != Opcodes.ASM5
        && api != Opcodes.ASM4
        && api != Opcodes.ASM10_EXPERIMENTAL) {
      throw new IllegalArgumentException("Unsupported api " + api);
    }
    // ...........
}

        从源码中可以看到目前只支持AMS4、AMS5、AMS6、AMS7、AMS8、AMS9、ASM10_EXPERIMENTAL。这个参数指的是 ASM API 版本号,差别在于高版本中有些方法在低版本中没有。

  • (2)visit(version, access, name, signature, superName, interfaces)

        该方法是扫描类的时候回调的第一个方法。其中里面的具体实现采用的访问者设计模式(遗留问题:后面去学习下这个设计模式),具体的实现通过传入的ClassVisitor来实现。里面的参数含义如下:

    /**
     * 当开始扫描类的时候回调的第一个方法
     *
     * @param version    jdk版本.如52则为jdk1.8,对应{@link Opcodes#V18}; 51则为jdk1.7,对应Opcodes的V17.具体参数对应值在 {@link Opcodes}中查看.
     * @param access     类修饰符.在ASM中以“ACC_”开头的常量:如 {@link Opcodes#ACC_PUBLIC}对应的public.具体参数对应值在 {@link Opcodes}中查看.
     * @param name       类名:包名+类名
     * @param signature  泛型信息,若未定义任何类型,则为空
     * @param superName  父类
     * @param interfaces 实现的接口的数组
     */
  • (3)MethodVisitor visitMethod(access, name, descriptor, signature, exceptions)

        该方法是扫描类的方法的时候进行回调的方法。返回的是一个MethodVisitor,在实现该方法的时候,通常返回的是一个自定义的MethodVisitor,用于对方法的进行处理。里面的参数含义如下:

  /**
     * 扫描到类的方法回调该方法
     *
     * @param access     方法修饰符,同visit()中的access
     * @param name       方法名
     * @param descriptor 方法签名:(参数列表)返回类型,如void onCreate(Bundle savedInstanceState),返回的为(Landroid/os/Bundle;)V
     *                   I代表int;B代表byte;C代表char;D代表double;F代表float;J代表long;S代表short;Z代表boolean;V代表void;
     *                   [...;代表一维数组;[[...;代表二维数组;[[[...;代表三维数组
     *                   例如输入参数为:
     *                   1.String[]则返回[Ljava/lang/String;
     *                   2.int,String,int[]则返回(ILjava/lang/String;[I)
     * @param signature  泛型相关信息
     * @param exceptions 会抛出异常
     * @return MethodVisitor
     */
  • (4)FieldVisitor visitField(access, name, descriptor, signature, value)

        该方法是扫描类的成员变量的时候回调该方法,返回的是一个FieldVisitor,在实现该方法的时候,通常返回一个自定义的FieldVisitor,用于对成员变量进行逻辑处理。里面的参数含义同visitMethod()

  • (5)AnnotationVisitor visitAnnotation(descriptor, visible)

        该方法是扫描类的注解的时候调用该方法,返回的是一个AnnotationVisitor,在实现该方法的时候,通常返回一个自定义的AnnotationVisitor,用于对注解进行逻辑处理。里面的参数含义如下:

    /**
     * 扫描到类注解回调该方法
     *
     * @param descriptor 注解类型
     * @param visible    在JVM是是否可见
     * @return AnnotationVisitor
     */

         还有像visitSource()访问的源码文件、visitOuterClass()等,后面如果有用到的时候再去详细总结。

 2.ASM之AdviceAdapter

        在前面介绍visitMethod()的时候,会返回一个MethodVisitor ,用来对类中的方法进行处理。该  MethodVisitor为一个抽象类,通常使用其子类AdviceAdapter,可以更方便的修改方法的字节码。其中几个比较重要的方法如下:

  • (1)构造函数

        当继承该抽象类的时候,必须要增加构造函数来通过super()调用到父类的构造函数,例如:

/**
     * Constructs a new {@link AdviceAdapter}.
     *
     * @param api           the ASM API version implemented by this visitor. Must be one of {@link
     *                      Opcodes#ASM4}, {@link Opcodes#ASM5}, {@link Opcodes#ASM6} or {@link Opcodes#ASM7}.
     * @param methodVisitor the method visitor to which this adapter delegates calls.
     * @param access        the method's access flags (see {@link Opcodes}).
     * @param name          the method's name.
     * @param descriptor    the method's descriptor (see {@link Type Type}).
     */
    protected AutoLogAdviceAdapter(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
        super(api, methodVisitor, access, name, descriptor);
    }

        从注释中可以看到改处的api参数支持 AMS4、AMS5、AMS6、AMS7,并且从源码中也可以看到如果不设置这几个值,会抛出异常,代码如下:

  public MethodVisitor(final int api, final MethodVisitor methodVisitor) {
    if (api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4 && api != Opcodes.ASM7) {
      throw new IllegalArgumentException();
    }
    //.......
}

        遗留问题:在实际使用过程中,将ASM9赋值api,并没有该异常抛出。有时候会出现"com.android.tools.r8.errors.b: Absent Code attribute in method that is not native or abstract"异常,改成ASM7即没有,暂时还没有找到原因

        在    Android Gradle之Java字节码(七)中的(2)方法引用中,已经对比了一个Java源码经过javac编译之后生成的.class字节码对应的结构对比关系,那么ASM字节码框架中同样提供了对应的回调方法来对应这些.class字节码。部分回调方法与字节码文件的对应关系图如下:

        

         简单的汇总下这些方法:

  • (2)方法调用生命周期相关的方法
    • onMethodEnter():进入到该方法的时候回调该方法。
    • visitCode():开始执行字节码的Code部分的时候回调该方法,会在onMethodEnter()之后调用。
    • onMethodExit():即将退出该方法的时候回调该方法,该方法调用结束之后,才会执行返回的字节码指令,如ireturn。
    • visitEnd():该方法调用结束之后该回调方法。
  • (3)方法的执行的指令集

        根据不同作用的指令集会回调到不同的方法,具体指令集对应的代码位于org.objectweb.asm.Opcodes下定义的相关变量:

  •         1)visitVarInsn(opcode, var):加载和存储局部变量表中的变量的指令。

        第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:

  int ILOAD = 21; // visitVarInsn
  int LLOAD = 22; // -
  int FLOAD = 23; // -
  int DLOAD = 24; // -
  int ALOAD = 25; // -
  int ISTORE = 54; // visitVarInsn
  int LSTORE = 55; // -
  int FSTORE = 56; // -
  int DSTORE = 57; // -
  int ASTORE = 58; // -
  int RET = 169; // visitVarInsn

        第二个参数var:就是字节码指令的操作数

         当执行的指令集中有如上指令的时候,就会回调到该方法,如上例中的iload_1,那么在回调该方法的时候,两个opcode, var对应的值为

visitVarInsn opcode = 21 , var = 1
  •         2)visitIntInsn(opcode, operand):加载常量的bipush和sipush以及创建数组的newarray指令

        第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:  

  int BIPUSH = 16; // visitIntInsn
  int SIPUSH = 17; // -
  int NEWARRAY = 188; // visitIntInsn

        第二个参数var:就是字节码指令的操作数

  •         3)visitInsn(opcode):算术指令操作指令、数组相关的操作指令等

                 第一个参数opcode对应的字节码指令位于Opcodes类中,因为内容比较多,不单独罗列,用的时候参照Opcodes类

  •         4)visitFieldInsn(opcode, owner, name, descriptor):访问类的成员变量的操作指令

         第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:

  int GETSTATIC = 178; // visitFieldInsn
  int PUTSTATIC = 179; // -
  int GETFIELD = 180; // -
  int PUTFIELD = 181; // -

        第二个参数name:方法的所有者类的内部名称     

        后面的name和descripor不在多于罗列,同其他出现过的作用一样。

  •         5)visitMethodInsn(opcode, owner, name, descriptor, isInterface):方法调用相关的指令

         第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:

  int INVOKEVIRTUAL = 182; // visitMethodInsn
  int INVOKESPECIAL = 183; // -
  int INVOKESTATIC = 184; // -
  int INVOKEINTERFACE = 185; // -

        后面的三个参数同上。

        最后一个参数 isInterface:就是该方法是否为接口方法的一个标示

        当然跟指令集相关的回调方法还有像visitTypeInsn()等,不在一一罗列,等用到的时候在去查看Opcodes类。

  • (4)局部变量表中的所有变量输出

        visitLocalVariable(name, descriptor, signature, start, end, index)

  • (5)方法传入参数的输出

        visitParameter(name, access)

  • (6)最大操作数栈和局部变量个数的输出

        visitMaxs(maxStack, maxLocals)        

        那么还是刚才的这个sum()方法,最后在访问该方法的时候,该AdviceAdapter返回的内容如下:

//ClassVisitor#visitMethod回调
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*  visitMethod access = 2 , name = sum , descriptor = (II)I
//进入到AdviceAdapter#visitParameter回调
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitParameter name = aa
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitParameter name = bb
//进入到该方法
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = onMethodEnter  
//进入到Code部分
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitCode
//指令集合
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitVarInsn opcode = 21 , var = 1
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitVarInsn opcode = 21 , var = 2
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitInsn opcode = 96
//退出方法
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = onMethodExit
//执行ireturn
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitInsn opcode = 172
//输出visitLocalVariable内容
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitLocalVariable name = this
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitLocalVariable name = aa
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitLocalVariable name = bb
//输出 stack=2, locals=3的内容
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitMaxs maxStack = 2 , maxLocals = 3
//该方法执行完毕
 ~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~*   = visitEnd

        遗留问题:visitLabel()和visitLineNumber()还没有看明白是怎么回事

         ASM之FieldVisitor和AnnotationVisitor等有需要的时候再去总结,用法应该和MethodVisitor类似

5.ASM之ClassReader和ClassWriter

        ClassReader就是将.class文件以InputSteam、byte[]等形式读到ClassReader中,然后通过accept(classVisitor)方法,将对应的需要处理具体逻辑ClassVisitor传入到ClassReader中,按照顺序调用ClassVisitor中的方法。其中里面几个比较重要的方法如下:

  • (1)构造函数

        支持.class文件对应的InputStream、byte[]、className(即具体的类.class.getName())

  • (2) accept( classVisitor, parsingOptions)

       用来接收ClassVisitor解析和处理.class的信息。

        第一个参数classVisitor:给定具体处理逻辑的ClassVisitor,通常需要自定义类来继承抽象类ClassVisitor.

        第二个参数parsingOptions:解析.class文件的选项.其中有几个取值:

  •          1)ClassReader.SKIP_CODE:跳过方法体的code属性,即Code属性下的内容不会被转换或访问;
  •          2)ClassReader.SKIP_DEBUG:跳过文件中的调试信息,即源文件、源码调试扩展、局部变量表、行号表属性、局部变量表类型表这些属性,即下面的这些方法不会被调用到 {@link ClassVisitor#visitSource}, {@link MethodVisitor#visitLocalVariable}, {@link MethodVisitor#visitLineNumber} and {@link MethodVisitor#visitParameter};
  •           3)ClassReader.SKIP_FRAMES:跳过文件StackMapTable和StackMap属性,即{@link MethodVisitor#visitFrame}不会被方法;该属性只有在ClassWriter设置 {@link ClassWriter#COMPUTE_FRAMES}才会起作用;
  •           4) ClassReader.EXPAND_FRAMES:跳过文件的StackMapTable属性。默认的栈图是以原始格式被访问,设置此标识栈图始终以扩展格式进行访问,大幅度降低性能。

        ClassWriter将新生成的字节码写入到文件中,通过toByteArray()形式返回byte[]。

  • (1)构造函数

        里面的参数含义如下:

        第一个参数reader :就是要处理的.class文件的ClassReader;
        第二个参数 flags:就是标记符。有三种值:

        1)0:不自动计算操作数栈和局部变量表的大小,需要手动指定

        2)COMPUTE_MAXS和COMPUTE_FRAMES:下图是官方API文档:

        

        从描述中可以看出:

  •         COMPUTE_MAXS:会自动计算操作栈数(maximum stack size)和局部变量表中的个数(maximum number of local variables)
  •         COMPUTE_FRAMES:不仅会自动计算操作数栈和局部变量表个数,并且还会自动计算StackMapTable

         但是这些标识也会让性能损失:COMPUTE_MAXS慢10% COMPUTE_FRAMES慢2倍。

        对于字节码中的代码注入,不仅注入相关的执行指令,比如方法注入,还需要堆栈帧图(StackMapTable)进行计算、栈帧中的局部变量表和操作数栈的大小。当然ClassWriter也提供了对应的flag,可以自行计算这些内容,见上面的两个flag的解释。

        前面在Android Gradle之Java字节码(七)4.运行时数据区域Runtime data area中也介绍过,Java的源码是运行在线程中,每个线程都有一个JVM栈,而栈又有多个栈帧(stack frame)组成,每个栈帧中包含着执行该方法的局部变量表、操作数栈、返回方法、动态链接。每运行一个方法都会创建一个栈帧压入到JVM栈中,当方法执行完返回的时候,就会将该栈帧出栈。

        栈帧中的局部变量表和操作数栈的大小取决于方法代码。

        从Java1.6以后引入的栈帧图(StackMapTable)概念:在Code属性中用来存储局部变量和操作数的类型验证以及字节码的偏移量。一个方法仅对应一个栈帧图。

        遗留问题:栈帧图的概念需要在理解一下

  • (2)toByteArray()

        最终通过该方法将新的字节码文件转换成byte[],供写入文件。

三 实例

        一般在使用ASM字节码框架往.class文件注入字节码的时候,一般需要将注入的Java代码先写出来,然后编译成.class文件,在通过前面的Android Gradle之Java字节码(七)中提到的ASMByte插件中的一些ASMified反编译.class文件,得到所需要的ASM注入代码。

1.为所有的方法添加调用日志实例

       接   Android Gradle 中的Transform(六)三 Android Transform高级应用的实例,循环input.getDirectoryInputs()中的所有.class文件,然后得到的.class文件添加ASM来处理逻辑,主要逻辑代码如下:

 private void addLogForClass(String input, String output) {
        if (input == null || output == null) {
            return;
        }
        try {
            //1.创建ClassReader
            FileInputStream is = new FileInputStream(input);
            ClassReader reader = new ClassReader(is);
            //2.创建ClassWriter
            ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
            //3.增加自定义的ClassVisitor实现为文件进行增加log
            AutoLogClassVisitor classVisitor = new AutoLogClassVisitor(writer);
            //4.调用reader.accept
            reader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
            //5.最后将修改后的.class文件重新写入该文件中
            FileOutputStream fos = new FileOutputStream(output);
            fos.write(writer.toByteArray());
            fos.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

        其实主要解析逻辑在 AutoLogClassVisitor中,然后进入到AutoLogClassVisitor中看下里面的内容:

2.AutoLogClassVisitor

        访问的是所有.class文件。因为本次打印所有的方法的执行时间,那么就要循环到.class文件的所有方法,在方法上添加相应的逻辑,那么对于AutoLogClassVisitor这个类中主要就是在visitMethod()的时候,将承载着方法添加调用时间逻辑的MethodVisitor返回即可。

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        SystemOutPrintln.println("visitMethod access = " + access + " , name = " + name + " , descriptor = " + descriptor);
        MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        //TODO 这里传入ASM9为什么没有报错?
        AdviceAdapter logAdviceAdapter = new AutoLogAdviceAdapter(this.api, methodVisitor, access, name, descriptor);
        return logAdviceAdapter;
        // return super.visitMethod(access, name, descriptor, signature, exceptions);
    }

        那么现在所有的逻辑都在 AutoLogAdviceAdapter这个类中。

3.   AutoLogAdviceAdapter

            该类就是当执行到.class文件的每个方法的时候,都会回调到该AutoLogAdviceAdapter中的每个回调周期方法中。具体怎么将Java源代码转换成对应的ASM框架所需要的代码可参见Android Gradle 中的使用ASMified插件生成.class的技巧(九)总结,这里仅提一点自己的逻辑处理,因为有些方法可能在执行return的时候,有一些计算公式还在占用方法执行时间,所以将方法的调用完的执行时间放到visitInsn()中执行return相关的字节码指令的时候,添加方法执行完的时间计算,而不是onMethodExit()。

        相关代码已经上传到具体的代码已经上传到至https://github.com/wenjing-bonnie/AndroidPlugin.git的firstplugin目录的相关内容。因为逐渐在这基础上进行迭代,可回退到Gradle_8.0该tag下可以查看相关内容。

         运行代码之后,经过编译之后的.class文件中已经添加了计算方法执行的相关代码,其中一个方法如下:

    private int sum(int aa, int bb) {
        long beginTime = System.currentTimeMillis();
        System.out.println("Other running code");
        long callTime = System.currentTimeMillis() - beginTime;
        Log.d("AUTO", String.format("cost time is [%d]ms", callTime));
        return aa + bb;
    }

4.Transform输出的路径 

        在Android Gradle 中的Transform(六)中提到了,通过Transform添加的字节码相关代码,该Transform会自动添加到build的任务队列中,那么前面在AutoLogTransform的时候,获取Transform的输出路径的时候,通过下面的代码获取:

File dest = outputProvider.getContentLocation(directory.getName(), directory.getContentTypes(), directory.getScopes(), Format.DIRECTORY);

        经过编译执行该Transform对应的Task之后, 

> Task :app:transformClassesWithAutoLogTransformTaskForHuaweiDebug

        会在项目的app/build/intermediates/transforms按照"Transform名字/productFlavor/buildType/根据传入的是directory对应的Format.DIRECTORY或jar对应Format.JAR)"创建出下面的一系列文件或文件夹。

        

        另外从app/build下面的文件夹几乎都是不同的Task在执行阶段创建的文件夹来生成对应的task的输出。

四 总结

        稍后在总结。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值