Android 热修复方案Tinker(七) 插桩实现

基于Tinker V1.7.5

Tinker V1.7.5是最后一个还支持回退Qzone方案插桩实现补丁修复的版本.由于Tinker的全量合成需要拿到原dex,而第三方的加固通常会将原dex隐藏起来做保护所以使用了第三方加固就只能用V1.7.5的Qzone模式.除非自己做dex保护或者不使用加固,但是有时候用不用第三方加固不是开发者能决定的并且自己实现加固的门槛和成本都是很高的.基于这些因素还是很有必要分析Tinker的Qzone方案实现,用了这套方案的话,有什么问题就只能fork出来自己维护了.

Tinker的插桩实现可以分为两个步骤,首先是基于Transform api侵入到编译流程中, 再通过ASM对编译中间步骤的Java字节码文件进行修改.

Transform

从上面的Transform官方简介中可以知道Transform API是从Gradle 1.5.0版本之后提供的,该API允许第三方在打包Dex文件之前的编译的过程中操作.class文件.目前Android Gradle内部牵扯到代码处理的动作像是jarMerge,jacoco, proguard, multi-dex,InstantRun等也都已经转为Transform的实现了.所以在Tinker插桩的时候也是使用Transform API来控制插桩的时机.

使用Transform大致分两步,先继承Transform类实现子类,再将子类注册进android编译流程中.注册很简单,有API可以直接使用.android.registerTransform(theTransform)android.registerTransform(theTransform, dependencies).在Transform类中有几个抽象方法用来配置自定义Transfrom的各种特性.

  1. getName()

    Transform中的抽象方法,实现之后返回当前Transform子类的名字.该方法return出去的名字并不是最终编译时的名字.例如返回AuxiliaryInject作为名字.

    @Override
    String getName() {
        return 'AuxiliaryInject'
    }
    

    在grdle core的代码中可以找到Transform的管理类
    com.android.build.gradle.internal.pipeline.TransformManager.在Manager中会根据Transform返回的name以transform${InputType}And${InputType}And${name}For${flavor}${BuildType}的格式构建出完整的名字.

    private static String getTaskNamePrefix(@NonNull Transform transform) {
        StringBuilder sb = new StringBuilder(100);
        sb.append("transform");
    
        Iterator<ContentType> iterator = transform.getInputTypes().iterator();
        // there's always at least one
        sb.append(capitalize(iterator.next().name().toLowerCase(Locale.getDefault())));
        while (iterator.hasNext()) {
            sb.append("And").append(capitalize(
                    iterator.next().name().toLowerCase(Locale.getDefault())));
        }
    
        sb.append("With").append(capitalize(transform.getName())).append("For");
    
        return sb.toString();
    }
    

    经过拼装之后,在运行gradle的assemble时就可以看到最终的transform记录.

    :app:transformClassesWithAuxiliaryInjectForPushRelease
    
  2. getInputTypes()

    指明当前Trasfrom要处理的数据类型,类型枚举中只有两个属性,CLASSES代表要处理的数据是编译过的Java代码,而这些数据的容器可以是jar包也可以是文件夹.RESOURCES表示要处理的是标准的Java资源.在Tinker中是使用Transform来做插桩,所以使用CLASSES类型.

    /**
     * Returns the type(s) of data that is consumed by the Transform. This may be more than
     * one type.
     *
     * <strong>This must be of type {@link QualifiedContent.DefaultContentType}</strong>
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return ImmutableSet.of(QualifiedContent.DefaultContentType.CLASSES)
    }
    
  3. getScopes()

    配置当前Transform的影响范围,相当于作用域.其中Scope的属性解释如下图所示.

    TypeDes
    PROJECT只处理当前项目
    SUB_PROJECTS只处理子项目
    PROJECT_LOCAL_DEPS只处理当前项目的本地依赖,例如jar, aar
    SUB_PROJECTS_LOCAL_DEPS只处理子项目的本地依赖,例如jar, aar
    EXTERNAL_LIBRARIES只处理外部的依赖库
    PROVIDED_ONLY只处理本地或远程以provided形式引入的依赖库
    TESTED_CODE测试代码

    跟getInputTypes方法一样, getScopes也是返回一个集合,那么就可以根据自己的需求配置多种Scope.像Tinker的作用域覆盖了除了测试代码和provided引入库之外所有的范围.

    /**
     * Returns the scope(s) of the Transform. This indicates which scopes the transform consumes.
     */
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return ImmutableSet.of(
                QualifiedContent.Scope.PROJECT,
                QualifiedContent.Scope.SUB_PROJECTS,
                QualifiedContent.Scope.PROJECT_LOCAL_DEPS,
                QualifiedContent.Scope.SUB_PROJECTS_LOCAL_DEPS,
                QualifiedContent.Scope.EXTERNAL_LIBRARIES
        )
    }
    
  4. isIncremental()

    返回当前Transfrom是否支持增量编译,如果支持的话再Input的数据中根据场景可能会包含changed/removed/added的文件.所以在处理插桩的时候也要考虑是否开启了增量编译.

    /**
     * Returns whether the Transform can perform incremental work.
     *
     * <p/>
     * If it does, then the TransformInput may contain a list of changed/removed/added files, unless
     * something else triggers a non incremental run.
     */
    @Override
    boolean isIncremental() {
        return true
    }
    
  5. transform方法

    复写父类Transform的transform方法,从名字也能看出来这个方法是处理中间文件的核心方法.如果在方法中没有实现任何操作,注册了Transform之后编译会出错抛出一个NoSuchElementException异常.了解一下Transform的工作原理就能发现为什么会这样.在正常的编译过程中Transform处理的过程如下图所示.

    可以看出我们在Transform中要将处理过的输入数据再输出出去,否则下一个Transform或者其他Task在使用数据的时候就会找不到数据抛出NoSuchElementException异常.具体要怎么做?我们可以先实现出来一个空的Transform注册进编译过程.首先收集起来Transform的输入数据.

    transformInvocation.inputs.each { input ->
        input.directoryInputs.each { dirInput ->
            dirInputs.add(dirInput)
        }
        input.jarInputs.each { jarInput ->
            jarInputs.add(jarInput)
        }
    }
    

    使用outputprovider创建出Transform输出class文件的路径即下个Transform的输入路径.当Format为DIRECTORY时是返回存放class文件的路径. Format为JAR,返回并创建出Jar文件.

    File dirOutput = transformInvocation.outputProvider.getContentLocation(
            "classes", getOutputTypes(), getScopes(), Format.DIRECTORY)
    if (!dirOutput.exists()) {
        dirOutput.mkdirs()
    }
    

    遍历路径类型的输入数据,通常为class文件,在遍历的过程中直接使用traverse方法获取到路径下的文件即class文件.获取到之后我们先什么也不做,只是log一下然后copy到上面创建出来的dir输出路径下,作为下一个Task的输入数据使用.

    if (!dirInputs.isEmpty()) {
        dirInputs.each { dirInput ->
            dirInput.file.traverse(type: FileType.FILES) { fileInput ->
                File fileOutput = new File(fileInput.getAbsolutePath().replace(dirInput.file.getAbsolutePath(), dirOutput.getAbsolutePath()))
                if (!fileOutput.exists()) {
                    fileOutput.getParentFile().mkdirs()
                }
                printMsgLog('Copying class %s to output', fileInput.absolutePath)
                Files.copy(fileInput, fileOutput)
            }
        }
    }
    

    遍历jar文件, 由于创建jarInputs对应的输出文件要使用Format为JAR,所以这里就不像处理dirInputs一样建立一个输出的路径就行了,这里需要对每一个jar文件建立一个output, log一下然后将输入文件copy覆盖到对应的输出jar文件.这里需要注意创建output时第一个参数同DirOutput不同, DIRECTORY格式下是建立一个name路径, 而JAR格式是建立一个name.jar,所以Tinker是使用原文件名_路径MD5的形式作为output jar的唯一文件名.

    if (!jarInputs.isEmpty()) {
        jarInputs.each { jarInput ->
            File jarInputFile = jarInput.file
            File jarOutputFile = transformInvocation.outputProvider.getContentLocation(
                    getUniqueHashName(jarInputFile), getOutputTypes(), getScopes(), Format.JAR)
            if (!jarOutputFile.exists()) {
                jarOutputFile.getParentFile().mkdirs()
            }
            printMsgLog('Copying Jar %s', jarInputFile.absolutePath)
            Files.copy(jarInputFile, jarOutputFile)
        }
    }
    

    上面的工作做完之后就可以将这个Transform注册到编译流程中,只不过数据流过该Transform时只是把输入数据转化为输出数据,没有做任何处理.编译工程可以在Gradle的终端处看到文件copy前log的打印.从下图可以看到在执行完AuxiliaryInjectTransform后紧跟着就是JarMergingTransform.

    点击查看大图

上面介绍了下如何自己扩展一个空的Transfrom并将其注册到编译流程中, 接下来就是结合Tinker的插桩需求分析一下Tinker的Transform具体实现.

  1. 获取Application的class路径

    由于Tinker是修改dexElements数组来修复java的, 所以Application的入口是不能修复的, 那么插桩的话也不需要对Application进行操作.既然这样我们就需要知道哪个.class是Application才能做过滤.Tinker根据编译variant获取到AndroidManifest文件,解析xml得到Application字段中Application类的名字,拼装出Application的class文件的路径.

    首先根据上面TransformManager.getTaskNamePrefix方法的描述,自己拼装出部分transform的名字例如transformClassesWithAuxiliaryInjectFor.再通过transformInvocation对象获取到当前Transform的完整名字例如sample:transformClassesWithAuxiliaryInjectForRelease根据连个字符串切割出最后的BuildType,也就是当前的variant并转化成小写release.

    获取到variant就可以使用闭包条件遍历所有的variant, 匹配出当前运行的variant从而获取到AndroidManifest文件.

    this.applicationVariants.any { variant ->
        if (variant.name.equals(variantName)) {
            def variantOutput = variant.outputs.first()
            manifestFile = variantOutput.processManifest.manifestOutputFile
            return true  // break out.
        }
    }
    

    解析上面拿到的manifest文件,再根据android的namespace获取到application block中的android.name数据,编译过之后的文件中android.name是Application的绝对路径.有了绝对路径就可以把它改造成.class文件的路径,供后面使用.

    def parsedManifest = new XmlParser().parse(
            new InputStreamReader(new FileInputStream(manifestFile), "utf-8"))
    def androidTag = new Namespace(
            'http://schemas.android.com/apk/res/android', 'android')
    appClassName = parsedManifest.application[0].attribute(androidTag.name)
    
    if (appClassName != null && appClassName.length() > 0) {
        this.appClassPathName = appClassName.replace('.', '/') + '.class'
    }
    
  2. directoryInputs处理

    dexOptions 中的incremental在Gradle 2.1.0的版本之后是默认开启的.开启了增量编译的话只会对发生变化的部分进行编译,保留没有发生变化的部分从而达到提高编译速度的目的.有变化的部分肯定包含了三个状态:增删改.所以插桩的时候要考虑这几种不同的状态.

    • 开启了incremental

      如果开启了incremental,在遍历directoryInputs之后, 再遍历当前目录下改变过的文件.这个changedFiles其实是一个Map<File, Status>对象, key为改变的文件, value是这个改变过的文件的状态,是添加,删除还是修改.再根据上面的dirOutput路径创建出跟当前key文件对应的output文件.并取得该key文件的changed状态.

      dirInput.changedFiles.each { entry ->
          File fileInput = entry.getKey()
          File fileOutput = new File(fileInput.getAbsolutePath().replace(
                  dirInput.file.getAbsolutePath(), dirOutput.getAbsolutePath()))
          if (!fileOutput.exists()) {
              fileOutput.getParentFile().mkdirs()
          }
          final String relativeInputClassPath =
                  dirInput.file.toPath().relativize(fileInput.toPath())
                          .toString().replace('\\', '/')
          Status fileStatus = entry.getValue()
          ...
      }
      

      当前遍历的changedFile状态是added或changed(这两个状态会做相同的处理)时, 如果没有开启插桩模式或文件不是以.class结尾或文件是第一步中解析出来的Application的字节码文件, 就只是copy当前文件到fileOutput.否则就使用ASM做插桩处理, 插桩的结果会体现在fileOutput文件中.

      if (!this.isEnabled || !fileInput.getName().endsWith('.class')) {
          Files.copy(fileInput, fileOutput)
      } else {
          // Skip application class.
          if (relativeInputClassPath.equals(this.appClassPathName)) {
              printWarnLog('Skipping Application class: %s',
                      relativeInputClassPath)
              Files.copy(fileInput, fileOutput)
          } else {
              printMsgLog('Processing %s file %s',
                      fileStatus,
                      relativeInputClassPath)
              AuxiliaryClassInjector.processClass(fileInput, fileOutput)
          }
      }
      

      如果文件状态是removed, 则将前面创建出来的fileOutput删除掉, 保持该文件该有的状态.

      if (fileOutput.exists()) {
          if (fileOutput.isDirectory()) {
              fileOutput.deleteDir()
          } else {
              fileOutput.delete()
          }
      }
      
    • 没有开启incremental

      如果没有开启incremental, 相当于每次编译文件都是重新产生的,那么就没有added, changed, removed三种状态了.所以直接遍历路径下所有的文件,按着上面added或changed状态时一样的逻辑处理就行了.

      dirInput.file.traverse(type: FileType.FILES) { fileInput ->
          ...
      }
      
  3. jarInputs处理

    如上面注册一个空Transform时对jarInputs的处理,遍历jarInputs之后根据jarInput创建出对应的outputFile.再根据每个jarInput的状态做处理.

    • jarInput文件状态为removed

      同处理dirInput的逻辑, 删除该状态下对应的输出文件.确保在下面的流程中没有该文件输出.

      if (jarOutputFile.exists()) {
          jarOutputFile.delete()
      }
      
    • jarInput文件状态为 notchanged, added, changed.

      这三个状态下处理的逻辑类似, 因为文件肯定是存在的,所以都需要根据场景做插桩处理.其中有一点需要注意的是, 当开启了incremental之后, 文件状态为notchanged时代表当前文件已经处理过了,直接忽略该文件.接下来跟dirInput的逻辑类似, 根据是否开启插桩模式,是否是.class的文件结尾,是否是Application的.class文件等条件做过滤,然后使用ASM进行插桩.

ASM

前面有提到Tinker插桩的动作是通过ASM实现的, 那么什么是ASM? ASM如何做插桩? Tinker插桩的思路是怎样的? 这里就一步一步分析下去.

根据ASM官网的介绍可以了解到,ASM是一个多功能的java字节码操控和分析框架, ASM可以用来修改已经存在的class文件也可以动态创建class文件, 同时也可以利用ASM提供常用转换和分析算法实现定制一些复杂的需求. 因为ASM直接通过指令操作字节码文件,所以它的效率会比javasist高. 但是由于需要了解java字节码指令, 所以他的学习成本比较高, 适合在一些追求性能的动态系统中使用. 如果对性能没有要求的场景也可以使用javasist.

介绍几个使用ASM时会用到的核心类

  1. ClassReader

    ClassReader用来加载class文件,它会根据java字节码的格式读出一个byte数组, 并且提供一个可以让ClassVisitor按具体的特性访问class文件的方法.

  2. ClassVisitor

    一个可以访问到class所有属性的类, 在访问class文件时ClassVisitor的方法会按照如下顺序被调用.下方所有的方法都调用完成之后就代表当前class已经访问结束.

    方法描述
    visit访问class的头部信息.例如版本号,类名,父类
    信息或者接口信息等.可以在该方法处做一些
    初始化的工作
    visitSource访问当前class的源码
    visitOuterClass如果有enclosing类则返回enclosing类信息
    visitAnnotation
    visitTypeAnnotation
    访问该类的注解相关信息
    visitAttribute访问类中非标准的Attribute信息
    visitInnerClass访问内部类
    visitField访问类的属性
    visitMethod访问类的方法
    visitEnd类的所有信息都访问过之后回调该方法,可以
    在这里做一些收尾工作
  3. ClassWriter

    ClassWriter其实是ClassVisitor的子类, 可以通过ClassReader读取出来的class文件经过解析再写入的文件中.

  4. MethodVisitor

    MethodVisitor是可以访问一个方法所有信息的类, 跟ClassVisitor类似,该类也有一个链式回调,用来分拆不同的属性.

  5. MethodWriter

    MethodWriter是MethodVisitor的子类, 可以通过读取到的方法bytecode转化为method.

简单介绍过ASM之后, 我们看一下Tinker是怎么实现在类初始化时插入一个无关类引用.

  1. class 文件插桩

    AuxiliaryClassInjector类对外暴露一个直接处理插桩class文件的方法, 在Transfrom中直接将input class和output class文件直接传入该方法, 获取两个文件的文件流.

    public static void processClass(File classIn, File classOut) throws IOException {
        InputStream is = null;
        OutputStream os = null;
        try {
            is = new BufferedInputStream(new FileInputStream(classIn));
            os = new BufferedOutputStream(new FileOutputStream(classOut));
            processClass(is, os);
        } finally {
            closeQuietly(os);
            closeQuietly(is);
        }
    }
    

    拿到input和output class文件的文件流之后, 先用input的文件流初始化ASM的ClassReader, 用来格式化并解析class文件.并初始化出一个ClassWriter,用这个ClassWriter再初始化出一个ClassVisitor的子类.最终在ClassReader解析整个Class文件时回调到ClassVisitor中对<init><clinit>方法进行插桩, 最后将ClassWriter写入到output class文件完成对input class文件的插桩.

    private static void processClass(InputStream classIn, OutputStream classOut) throws IOException {
        ClassReader cr = new ClassReader(classIn);
        ClassWriter cw = new ClassWriter(0);
        AuxiliaryClassInjectAdapter aia = new AuxiliaryClassInjectAdapter(NOT_EXISTS_CLASSNAME, cw);
        cr.accept(aia, 0);
        classOut.write(cw.toByteArray());
        classOut.flush();
    }
    

    在ClassVisitor回调第一个方法visit时,对插桩所用到的状态进行初始化,例如当前class是否存在<init>方法,是否存在<clinit>方法,是否是目标class文件,是否已经完成插桩的动作.

    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.isClInitExists = false;
        this.isInitExists = false;
        this.isTargetClass = ((access & Opcodes.ACC_INTERFACE) == 0);
        this.isInjected = false;
    }
    

    在ClassVisitor回调visitMethod方法时,如果当前class是满足条件的目标class文件,并且还没有做过插桩(插桩只需要确保一个class插一次,就可以避免class在调用时被系统打上pre-verify的标记).如果method的名字是<init><clinit>则更新插桩状态并对该方法插桩.

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
        if (mv != null && this.isTargetClass && !this.isInjected) {
            if ("<clinit>".equals(name)) {
                this.isClInitExists = true;
                this.isInjected = true;
                mv = new InjectImplMethodVisitor(mv);
            } else
            if ("<init>".equals(name)) {
                this.isInitExists = true;
                this.isInjected = true;
                mv = new InjectImplMethodVisitor(mv);
            }
        }
        return mv;
    }
    

    在MethodVisitor中回调visitInsn方法时, 在return指令之前将无关的引用通过指令插入进来.

    public void visitInsn(int opcode) {
        if (opcode == Opcodes.RETURN) {
            super.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "lineSeparator", "()Ljava/lang/String;", false);
            Label lblSkipInvalidInsn = new Label();
            super.visitJumpInsn(Opcodes.IFNONNULL, lblSkipInvalidInsn);
            super.visitLdcInsn(Type.getType(AuxiliaryClassInjectAdapter.this.auxiliaryClassDesc));
            super.visitVarInsn(Opcodes.ASTORE, 0);
            super.visitLabel(lblSkipInvalidInsn);
        }
        super.visitInsn(opcode);
    }
    

    在ClassVisitor在回调visitEnd方法时, 如果<init>方法和<clinit>方法都不存在的话, 说明整个class文件解析完了也还没有做插桩, 在这种情况下Tinker就创建一个<clinit>方法, 并将一个无用的引用插入到该方法中.

    @Override
    public void visitEnd() {
        // If method <clinit> and <init> are not found, we should generate a <clinit>.
        if (!this.isClInitExists && !this.isInitExists) {
            MethodVisitor mv = super.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
            mv.visitCode();
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "lineSeparator", "()Ljava/lang/String;", false);
            Label lblSkipInvalidInsn = new Label();
            mv.visitJumpInsn(Opcodes.IFNONNULL, lblSkipInvalidInsn);
            mv.visitLdcInsn(Type.getType(this.auxiliaryClassDesc));
            mv.visitVarInsn(Opcodes.ASTORE, 0);
            mv.visitLabel(lblSkipInvalidInsn);
            mv.visitInsn(Opcodes.RETURN);
            mv.visitMaxs(1, 1);
            mv.visitEnd();
        }
        super.visitEnd();
    }
    
  2. jar文件插桩

    jar文件的插桩其实是依赖于class文件的插桩行为, 这里是遍历jar包中的元素,将是.class结尾的文件名回调到插件中,根据回调产生的结果来决定是否对该.class文件进行插桩(同上), 如果不需要插桩则通过文件流的形式将文件从input拷贝到output处.

    while ((entryIn = zis.getNextEntry()) != null) {
        final String entryName = entryIn.getName();
        if (!processedEntryNamesMap.containsKey(entryName)) {
            ZipEntry entryOut = new ZipEntry(entryIn);
            entryOut.setCompressedSize(-1);
            zos.putNextEntry(entryOut);
            if (!entryIn.isDirectory()) {
                if (entryName.endsWith(".class")) {
                    if (cb == null || cb.onProcessClassEntry(entryName)) {
                        processClass(zis, zos);
                    } else {
                        Streams.copy(zis, zos);
                    }
                } else {
                    Streams.copy(zis, zos);
                }
            }
            zos.closeEntry();
            processedEntryNamesMap.put(entryName, 1);
        }
    }
    

    在ProcessJarCallback回调到Transfrom的实现中时,Transform已经有维护了一个已经插桩过class的Map, 如果出现重复回调的entryName, 会将这个class信息warn到日志中.再根据jarEntry是否是.class文件,是否开启插桩,是否是Application子类的class文件这三个条件控制是否允许processJar方法对该jarEntry做插桩.

    @Override
    boolean onProcessClassEntry(String entryName) {
        final String lastContainsJarPath = entryNameToJarPathMap.get(entryName)
        if (lastContainsJarPath != null) {
            printWarnLog("Duplicate zip entry ${entryName} found in ${lastContainsJarPath} and ${jarInputFile.absolutePath}")
        } else {
            entryNameToJarPathMap.put(entryName, jarInputFile.absolutePath)
        }
    
        // If disabled or not a class file, skip transforming them.
        if (!this.isEnabled || !entryName.endsWith('.class')) {
            return false
        } else {
            // Skip application class.
            if (entryName.equals(AuxiliaryInjectTransform.this.appClassPathName)) {
                return false
            } else {
                return true;
            }
        }
    }
    

在插桩之后反编译出java文件可以看到,在构造方法的末尾处插入了两行之前描述过的代码.

public final class BuildConfig {

    public BuildConfig() {
        if(System.lineSeparator() == null)
            this = tInKEr/pReVEnT/PrEVErIfIEd/STuBCLaSS;
    }

    ...
}

至此在Tinker 1.7.5版本之前,开启usePreGeneratedPatchDex之后, 编译期间结合Transfrom和ASM的插桩流程已经分析完了.而ASM和Java指令部分没有系统性得详细展开,后面有机会会单独分析Java指令和ASM库.


转载请注明出处:http://blog.csdn.net/l2show/article/details/54846682

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值