Android性能优化:安装包优化

本文探讨了Android应用的安装包优化方法,包括图片压缩(SVG、WebP)、资源动态加载、Lint建议、极限压缩和Proguard混淆。详细介绍了如何通过资源打包原理进行更深层次的优化,如更改资源名称缩短大小,并提到了zipalign和资源二进制文件的处理。文章提供了优化工具和配置文件的使用示例。
摘要由CSDN通过智能技术生成

1.常规apk瘦身

apk压缩包的大小经过优化之后变小

1.1 apk中的图片的压缩

对apk中的图片进行压缩

【1】svg图片:文件是对图片的描述,牺牲CPU的计算能力的,节省空间。

【2】webp图片:在图片压缩的时候可以指定压缩的格式为webp。在android中是支持webp图片显示的。

使用的原则:简单的图标。

图片格式的大小的比较:png>jpeg>webp

webp是由VP8派生而来的。webp的无损压缩比PNG文件小45%左右,即使PNG进过其他的压缩工具压缩后,可以减小到PNG的28%。

使用的厂商:Facebook在用、腾讯、淘宝

缺点:加载相比于PNG要慢很多,随着现在手机配置变高,可以使用。

转换的工具:http://isparta.github.io/

image

image

1.2 资源的动态加载

常见:emoji表情、皮肤、动态下载资源、模块的插件化动态加载,例如small等工具。

1.3 Lint提供的建议

Lint工具会提供一写优化建议的点,可以采用,但是并非所有的建议都是正确的。

目的也是一样的,就是为了减小apk的大小

1)检测没有用的布局 删除

2)未使用到的资源 比如 图片 —删除

3)建议String.xml有一些没有用到的字符。

1.4 极限压缩

7zZip工具的使用

image

1.5 proguard混淆

1)可以删除注释和不用的代码。
2)将java文件名改成短名a.java,b.java
3)方法名变为无意义的字符

2.优化

2.1 优化的原理

资源打包的过程:

image

【参考】https://www.jianshu.com/p/3cc131db2002,下面的内容摘自链接文章

开发app时,需要代码和资源。最终生成的apk中代码转换为了dex文件,那么apk文件中的资源是否还是app开发时那些资源文件呢?或者说这些资源文件是否发生了什么变化?

引用老罗一张关于资源打包过程以及查找的图:

image

从上图可以看出:

  1. 除了assets和res/raw资源被原装不动地打包进APK之外,其它的资源都会被编译或者处理.xml文件会被编译为二进制的xml,所以解压apk后,无法直接打开xml文件。

  2. 除了assets资源之外,其它的资源都会被赋予一个资源ID。

  3. 打包工具负责编译和打包资源,编译完成之后,会生成一个resources.arsc文件和一个R.java,前者保存的是一个资源索引表,后者定义了各个资源ID常量,供在代码中索引资源。

  4. 应用程序配置文件AndroidManifest.xml同样会被编译成二进制的XML文件,然后再打包到APK里面去。

  5. 应用程序在运行时最终是通过AssetManager来访问资源,或通过资源ID来访问,或通过文件名来访问。

在生成的apk中,只有assets和res/raw资源被原装不动地打包进apk。其它的资源都会被编译或者处理。可以使用如下命令查看apk中的文件列表:

aapt l -v apkfile

将apk直接解压后,会发现xml都打不开,提示格式不对,因为其已经变为二进制xml了。另外PNG等图片也会进行相应的优化。还有就是多了一个resources.arsc文件。

在android中存在与对应R文件描述的一个二进制的资源文件,名称为:resources.arsc

Android 中具有18个资源维度,

image

【原理】再次做混淆:将长名称改为短名字命名;

** 实际的替换内容:将资源文件的名称改为名字,然后将映射文件resources.arsc的中的字符串修改为对应的短名字**

再做“混淆”:要实现将res/drawable/ic_launcher.png图片改成a.png
drawable文件的名字
String文件的名字
layout的名字
比如:R.string.description—>R.string.a
res/drawable/ic_launcher.png图片改成a.png

还可以更加夸张
res/drawable—>r/d
res/value–>r/v
res/drawable/ic_launcher.png图片改成r/d/a.png

** 依据:根据二进制文件中提供的信息,重新再造一张表(一个二进制文件),区别就是将旧表的中的字符串信息替换为简单的名称,减小占用的空间**

image

【LEDataInputStream.java】工具类

public final class LEDataInputStream
        implements DataInput {
    private static final String EMBEDDED_COPYRIGHT = "copyright (c) 1999-2010 Roedy Green, " +
            "Canadian Mind Products, http://mindprod.com";
    protected final DataInputStream dis;
    protected final InputStream is;
    protected final byte[] work;

    public static String readUTF(DataInput in)
            throws IOException {
        return DataInputStream.readUTF(in);
    }

    public LEDataInputStream(InputStream in) {
        this.is = in;
        this.dis = new DataInputStream(in);
        this.work = new byte[8];
    }

    public final void close()
            throws IOException {
        this.dis.close();
    }

    public final int read(byte[] ba, int off, int len)
            throws IOException {
        return this.is.read(ba, off, len);
    }

    public final boolean readBoolean()
            throws IOException {
        return this.dis.readBoolean();
    }

    public final byte readByte()
            throws IOException {
        return this.dis.readByte();
    }

    public final char readChar()  //读取一个字节
            throws IOException {
        this.dis.readFully(this.work, 0, 2);
        return (char) ((this.work[1] & 0xFF) << 8 | this.work[0] & 0xFF);
    }

    public final double readDouble()
            throws IOException {
        return Double.longBitsToDouble(readLong());
    }

    public final float readFloat()
            throws IOException {
        return Float.intBitsToFloat(readInt());
    }

    public final void readFully(byte[] ba)
            throws IOException {
        this.dis.readFully(ba, 0, ba.length);
    }

    public final void readFully(byte[] ba, int off, int len)
            throws IOException {
        this.dis.readFully(ba, off, len);
    }

    public final int readInt()  //读4个字节
            throws IOException {
        this.dis.readFully(this.work, 0, 4);
        return (this.work[3] << 24 | (this.work[2] & 0xFF) << 16 | (this.work[1] & 0xFF) << 8 |
                this.work[0] & 0xFF);
    }

    @Deprecated
    public final String readLine()
            throws IOException {
        return this.dis.readLine();
    }

    public final long readLong()
            throws IOException {
        this.dis.readFully(this.work, 0, 8);
        return (this.work[7] << 56 |
                (this.work[6] & 0xFF) << 48 |
                (this.work[5] & 0xFF) << 40 |
                (this.work[4] & 0xFF) << 32 |
                (this.work[3] & 0xFF) << 24 |
                (this.work[2] & 0xFF) << 16 |
                (this.work[1] & 0xFF) << 8 |
                this.work[0] & 0xFF);
    }

    public final short readShort()  //读取2个字节
            throws IOException {
        this.dis.readFully(this.work, 0, 2);
        return (short) ((this.work[1] & 0xFF) << 8 | this.work[0] & 0xFF);
    }

    public final String readUTF()
            throws IOException {
        return this.dis.readUTF();
    }

    public final int readUnsignedByte()
            throws IOException {
        return this.dis.readUnsignedByte();
    }

    public final int readUnsignedShort()
            throws IOException {
        this.dis.readFully(this.work, 0, 2);
        return ((this.work[1] & 0xFF) << 8 | this.work[0] & 0xFF);
    }

    public final int skipBytes(int n)
            throws IOException {
        return this.dis.skipBytes(n);
    }
}

【demo】读取二进制表resources_arsc的简单示例

/**
 * @function: 示例解析了resources_arsc二进制表的Resource Table、StringPool、packageHeader 三部分内容,没有完全读取结束
 * 简单的演示
 */

public class ArscTest {
    public static void main(String[] args){
        ArscTest arscTest = new ArscTest();
        try {
//            arscTest.readInputStream("C:/Users/luoding/workspace_test/arscTest/input.apk");
//            arscTest.readInputStream("C:/Users/luoding/workspace_test/arscTest/Lsn10SearchView.apk");
            arscTest.readInputStream("C:/Users/luoding/workspace_test/arscTest/dn_jobschduler.apk");
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
    
    @SuppressWarnings("resource")
    public void readInputStream(String path) throws IOException{
        //读取压缩文件:resources_arsc
        ZipFile zipFile = new ZipFile(path);
        //以流形式读进来
        InputStream inputStream = zipFile.getInputStream(new ZipEntry(""+"resources.arsc"));
        LEDataInputStream leDataInputStream = new LEDataInputStream(inputStream);
        //Resource Table
        // 读整个包的RES_TABLE_TYPE,2个字节?
        short type = leDataInputStream.readShort();
        //跳过了--整个包的头大小:2个字节
        leDataInputStream.skipBytes(2);
        //整个包的文件大小
        leDataInputStream.readInt();
        //整个包的package个数,一般的个数是1个
        int packageNum = leDataInputStream.readInt();
        System.out.println("num of package:"+packageNum);
        
        //StringPool对应的内容
        // 直接了4个字节:包含RES_STRING_POOL_TYPE+头的大小,没有使用??
        int got =leDataInputStream.readInt();
        //块大小
        int chunkSize = leDataInputStream.readInt();
        //字符串数量
        int stringCount = leDataInputStream.readInt();
        //style数量
        int styleCount = leDataInputStream.readInt();
        //标记
        int flags = leDataInputStream.readInt();
        //字符串起始位置
        int stringsOffset = leDataInputStream.readInt();
        //style起始位置
        int stylesOffset = leDataInputStream.readInt();
        //下面的循环读的是字符串的偏移数组,每个元素存的是字符串的起始位置
        int[] array = new int[stringCount];
        for (int i = 0; i < stringCount; ++i){
            array[i] = leDataInputStream.readInt();
        }
        //下面读取的style的内容
        if (styleCount != 0) {
            for (int i = 0; i < styleCount; ++i)
                array[i] = leDataInputStream.readInt();
        }

        //字符串长度
        int size = ((stylesOffset == 0) ? chunkSize : stylesOffset) - stringsOffset;
        byte[] m_strings = new byte[size];
        StringBuffer ss = new StringBuffer();
        leDataInputStream.readFully(m_strings);
        //读取了所有的字符串的内容
        for(int i = 0;i<m_strings.length;i++){
            //(通过打开resources.arsc看到一些乱码 猜得出字符都是ASCII码)
            char c = (char) m_strings[i];
            ss.append(c);
        }
        System.out.println(ss.toString());
        if (stylesOffset != 0) {
            size = chunkSize - stylesOffset;
            if (size % 4 != 0)
                throw new IOException("Style data size is not multiple of 4 (" + size + ").");

            for (int i = 0; i < size / 4; ++i)
                leDataInputStream.readInt();
        }

        //nextChunk--packageHeader
        leDataInputStream.readShort();
        leDataInputStream.skipBytes(2);
        leDataInputStream.readInt();

        int id = (byte) leDataInputStream.readInt();
        StringBuilder sb = new StringBuilder(16);
        int length = 256;
        //拿包名
        while (length-- != 0) {
            short ch = leDataInputStream.readShort();
            if (ch == 0)
                break;
            sb.append((char)ch);
        }
        System.out.println("pacakgeName:"+sb.toString());
    }
}

2.2 完整工具

image

/**
     * 程序的总入口:
     * @param args
     */
    public static void main(String[] args) {
        mBeginTime = System.currentTimeMillis();
        Main m = new Main();
        //获取保存的优化后文件的路径
        getRunningLocation(m);
        //运行程序:需要传入args参数
        m.run(args);
    }

【args参数的来源】

image

image

【解析args源码】是在run方法中:下面截取了部分代码

private void run(String[] args) {
        if (args.length < 1) {
            goToError();
        }

        File configFile = null;// 配置文件
        File outputFile = null;// 输出文件
        String apkFileName = null;// apk输入文件

        File signatureFile = null;// 签名秘钥
        File mappingFile = null;// 修改后映射文件
        String keypass = null;// 秘钥主密码
        String storealias = null;// 别名
        String storepass = null;// 别名密码

        String signedFile = null;// 签名apk
        // 检查参数
        for (int index = 0; index < args.length; ++index) {
            String arg = args[index];
            if ((arg.equals("--help")) || (arg.equals("-h"))) {
                goToError();
            } else if (arg.equals("-config")) {// 配置文件不能最后读,后面的执行需要依赖配置文件,或者不是.xml则报错
                if ((index == args.length - 1) || (!(args[(index + 1)].endsWith(".xml")))) {
                    System.err.println("Missing XML configuration file argument");
                    goToError();
                }
                // 参数后面的参数才是config配置文件的路径
                configFile = new File(args[(++index)]);
                if (!(configFile.exists())) {
                    System.err.println(configFile.getAbsolutePath() + " does not exist");
                    goToError();
                }
                System.out.printf("special configFile file path: %s\n", new Object[] { configFile.getAbsolutePath() });

【config.xml】其中解析的配置文件:

image

<?xml version="1.0" encoding="UTF-8"?>
<resproguard>
    <!--defaut property to set  -->
    <issue id="property">
        <!--是否使用极限压缩:7zip  -->
        <!--whether use 7zip to repackage the signed apk, you must install the 7z command line version in window -->
        <!--sudo apt-get install p7zip-full in linux -->
        <!--and you must write the sign data fist, and i found that if we use linux, we can get a better result -->
        <seventzip value="true"/>
        <!--密钥-->
        <!--the sign data file name in your apk, default must be META-INF-->
        <!--generally, you do not need to change it if you dont change the meta file name in your apk-->
        <metaname value="META-INF"/>
        <!--名称的极端的压缩-->
        <!--if keep root, res/drawable will be kept, it won't be changed to such as r/s-->
        <keeproot value="false"/>
    </issue>

微信中的r目录下的文件 命名:

image

继续看config.xml的内容

<!--whitelist, some resource id you can not proguard, such as getIdentifier-->
    <!--isactive, whether to use whitelist, you can set false to close it simply-->
    <!--白名单:可以指定第三方的资源不被修改名称  -->
    <issue id="whitelist" isactive="true">
        <!--you must write the full package name, such as com.tencent.mm.R -->
        <!--for some reason, we should keep our icon better-->
        <!--and it support *, ?, such as com.tencent.mm.R.drawable.emoji_*, com.tencent.mm.R.drawable.emoji_?-->
        <path value="com.codeboy.autoresguarddemo.R.mipmap.ic_launcher"/>
    </issue>

    <!--是否生成映射文件  -->
    <!--keepmapping, sometimes if we need to support incremental upgrade, we should keep the old mapping-->
    <!--isactive, whether to use keepmapping, you can set false to close it simply-->
    <!--if you use -mapping to set sign property in cammand line, these setting will be overlayed-->
    <issue id="keepmapping" isactive="false">
        <!--the old mapping path, in window use \, in linux use /, and the default path is the running location-->
        <path value="resource_mapping.txt"/>
    </issue>

映射文件的内容部分截取如下:

image

config.xml 还包括是否使用签名,其中指定签名的路径和密码等

<!--sign, if you want to sign the apk, and if you want to use 7zip, you must fill in the following data-->
    <!--isactive, whether to use sign, you can set false to close it simply-->
    <!--if you use -signature to set sign property in cammand line, these setting will be overlayed-->
    <issue id="sign" isactive="true">
<!--     <issue id="sign" isactive="false"> -->
        <!--the signature file path, in window use \, in linux use /, and the default path is the running location-->
<!--         <path value="签名路径"/> -->
<!--         storepass -->
<!--         <storepass value="签名密码"/> -->
<!--         keypass -->
<!--         <keypass value="别名密码"/> -->
<!--         alias -->
<!--         <alias value="别名"/> -->
        <path value="C:\Users\luoding\workspace_test\dn_android_resproguard\mykeystore.keystore"/>
        <!--storepass-->
        <storepass value="123456"/>
        <!--keypass-->
        <keypass value="123456"/>
        <!--alias-->
        <alias value="123456"/>
    </issue>

可以通过out参数指定输出的文件的目录

image

image

在生成的打包文件具有多种类型:

image

其中的zipaligne是对齐,相当于磁盘的碎片整理,具有专门的zipalign工具

image

参考文章:http://bbs.ihei5.com/thread-171596-1-1.html

关于Android平台的Zipalign或Zipaligned:


什么是Zipalign? 
     Zipalign是一个档案整理工具,它首次被介绍是在Android 1.6版本的SDK(Software Development Kit)软件开发工具包中。它优化Android应用程序包(APK)到整合包, 以使Android操作系统与应用程序之间的交互作用更有效率,然后应用程序和整体系统的运行速度更快,发挥更大的潜能。它使Zipaligned的应用程序执行时间达到最低限度,其最终结果导致当设备运行APK应用程序时占更少的RAM(Random Access Memory)随机访问内存


Zipalign如何准备的执行(工作)呢? 
     在Android的操作环境中,存储在每个应用程序包的数据文件通过多个进程访问,例如,程序安装工具将读取数据列表确定相关的权限;因为包括显示通知等在内的多种原因,系统服务器可以读取这些资源;主界面应用程序要读取资源以便获取应用程序的名称与图标等。因为Android是基于一个真正的多任务操作基础架构,这些文件是不断地读取。最后但也是最重要的,应用程序本身读取体现的数据 

     因为Android操作系统基于Linux架构,存储单元布置(Memory Mapping)在有效的处理过程中起着一个关键的作用。从本质上而讲,为Android操作系统资源的处理代码最佳的整理是4字节界层。这个意思是说,如果APK应用程序包是存储单元布置到4字节界层,依据相应的整理,操作系统将不需要通读整个应用程序包以获取所需要的数据表,而每一个系统处理都将提前知道到哪里去寻找它所需要的资源,因此执行效率更快(运行更平滑,速度更快) 

     总结而讲,Zipalign一个APK应用程序的结果即是让所有的未压缩的数据以整合包的形式被整理到4字节界层,让所有的数据可以直接从存储器映象读取。而因为正访问的代码没有通读到整个应用程序包使得执行消耗的程序运行内存(RAM)降低。最终,操作系统整体上的速度会更快


UnZipalign(未整理)的APK应用程序包有什么劣势呢? 
     这是很容易理解的,对于未整理的应用程序包的情况,资源读取将会缓慢,程序运行内存(RAM)的使用将会处在一个较高的范围。它也取决于当前有多少未整理的应用程序。例如,如果有着较少的应用程序,然后有一个未整理的主界面程序,在启动时间上,你能观察到更慢的应用程序,这是最理想的情况。 对于一个糟糕的情况,如有着许多的未整理的应用程序,将会导致系统反复的启动和结束进程,系统运行将会滞后,电池的使用时间将会大幅度降低
我们应该怎么做呢? 

     当前,Zipalign档案整理工具已变成Android SDK(Software Development Kit)软件开发工具包的一部分。它可以在电脑上已安装好的Android SDK “tools” 或“platform-tools” 目录或文件夹中被查看 

     对于使用Zipalign档案整理工具,可以简单地输入并执行Command命令—— 
          zipalign [-f] [-v] <alignment> infile.apk outfile.apk 
          其中,infile.apk是源文件,outfile.apk是输出文件 

     更多的,我们也可以确认APK应用程序的整理,输入并执行Command命令—— 
          zipalign -c -v <alignment> existing.apk 
          其中,existing.apk能被任何你需要的应用程序包获得确认。

      另外<alignment>命令需要是一个整数值,不然,命令会返回无效的错误提示。而这个值,虽然可以是任何的整数,但必须始终是4,因为这将提供32位元的整理层,任何其他的值,它们都是有效的,但不起任何作用


      对于这些Command命令的标识—— 
          -f—重写存在的outfile.zip 
          -v—提供详细的输出 
          -c—确认一个给定的文件的整理

有什么要注意呢? 

     Zipalign操作必须且仅在标记APK文件附有个人加密钥之后。如果在标记之前进行Zipalign操作,标记过程将会干扰整理

其中还存在压缩选项:主要是对图片的压缩

<!--compress, if you want to compress the file, the name is relative path, such as resources.arsc, res/drawable-hdpi/welcome.png-->
    <!--what can you compress? generally, if your resources.arsc less than 1m, you can compress it. and i think compress .png, .jpg is ok-->
    <!--isactive, whether to use compress, you can set false to close it simply-->
    <issue id="compress" isactive="true">
        <!--you must use / separation, and it support *, ?, such as *.png, *.jpg, res/drawable-hdpi/welcome_?.png-->
        <path value="*.png"/>
        <path value="*.jpg"/>
        <path value="*.jpeg"/>
        <path value="*.gif"/>
        <path value="resources.arsc"/>
    </issue>

【完整的配置文件】config.xml

<?xml version="1.0" encoding="UTF-8"?>
<resproguard>
    <!--defaut property to set  -->
    <issue id="property">
        <!--是否使用极限压缩:7zip  -->
        <!--whether use 7zip to repackage the signed apk, you must install the 7z command line version in window -->
        <!--sudo apt-get install p7zip-full in linux -->
        <!--and you must write the sign data fist, and i found that if we use linux, we can get a better result -->
        <seventzip value="true"/>
        <!--密钥-->
        <!--the sign data file name in your apk, default must be META-INF-->
        <!--generally, you do not need to change it if you dont change the meta file name in your apk-->
        <metaname value="META-INF"/>
        <!--名称的极端的压缩-->
        <!--if keep root, res/drawable will be kept, it won't be changed to such as r/s-->
        <keeproot value="false"/>
    </issue>

    <!--whitelist, some resource id you can not proguard, such as getIdentifier-->
    <!--isactive, whether to use whitelist, you can set false to close it simply-->
    <!--白名单:可以指定第三方的资源不被修改名称  -->
    <issue id="whitelist" isactive="true">
        <!--you must write the full package name, such as com.tencent.mm.R -->
        <!--for some reason, we should keep our icon better-->
        <!--and it support *, ?, such as com.tencent.mm.R.drawable.emoji_*, com.tencent.mm.R.drawable.emoji_?-->
        <path value="com.codeboy.autoresguarddemo.R.mipmap.ic_launcher"/>
    </issue>

    <!--是否生成映射文件  -->
    <!--keepmapping, sometimes if we need to support incremental upgrade, we should keep the old mapping-->
    <!--isactive, whether to use keepmapping, you can set false to close it simply-->
    <!--if you use -mapping to set sign property in cammand line, these setting will be overlayed-->
    <issue id="keepmapping" isactive="false">
        <!--the old mapping path, in window use \, in linux use /, and the default path is the running location-->
        <path value="resource_mapping.txt"/>
    </issue>

    <!--compress, if you want to compress the file, the name is relative path, such as resources.arsc, res/drawable-hdpi/welcome.png-->
    <!--what can you compress? generally, if your resources.arsc less than 1m, you can compress it. and i think compress .png, .jpg is ok-->
    <!--isactive, whether to use compress, you can set false to close it simply-->
    <issue id="compress" isactive="true">
        <!--you must use / separation, and it support *, ?, such as *.png, *.jpg, res/drawable-hdpi/welcome_?.png-->
        <path value="*.png"/>
        <path value="*.jpg"/>
        <path value="*.jpeg"/>
        <path value="*.gif"/>
        <path value="resources.arsc"/>
    </issue>

    <!--sign, if you want to sign the apk, and if you want to use 7zip, you must fill in the following data-->
    <!--isactive, whether to use sign, you can set false to close it simply-->
    <!--if you use -signature to set sign property in cammand line, these setting will be overlayed-->
    <issue id="sign" isactive="true">
<!--     <issue id="sign" isactive="false"> -->
        <!--the signature file path, in window use \, in linux use /, and the default path is the running location-->
<!--         <path value="签名路径"/> -->
<!--         storepass -->
<!--         <storepass value="签名密码"/> -->
<!--         keypass -->
<!--         <keypass value="别名密码"/> -->
<!--         alias -->
<!--         <alias value="别名"/> -->
        <path value="C:\Users\luoding\workspace_test\dn_android_resproguard\mykeystore.keystore"/>
        <!--storepass-->
        <storepass value="123456"/>
        <!--keypass-->
        <keypass value="123456"/>
        <!--alias-->
        <alias value="123456"/>
    </issue>

</resproguard>

最后

如果你看到了这里,觉得文章写得不错就给个呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

希望读到这的您能转发分享关注一下我,以后还会更新技术干货,谢谢您的支持!

转发+点赞+关注,第一时间获取最新知识点

Android架构师之路很漫长,一起共勉吧!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值