ApkTool项目解析resources.arsc详解

前言

上回说道ApkTool项目的概览,关于ApkTool如何编译,如何运行,还有各个参数的介绍。
今天想主要说明一下关于ApkTool如何分析resources.arsc文件的,以及resources.arsc文件的格式

总体流程

我们首先执行命令apktool d xxx.apk,然后看输出如下

I: Using Apktool 2.3.1 on douyin.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: C:\Users\hch\AppData\Local\apktool\framework\1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Baksmaling classes2.dex...
I: Baksmaling classes3.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...

其实这个时候apktool总体做了如下几个步骤

  • 加载resource table
  • 解码AndroidManifest.xml
  • 解码一些资源文件
  • 解码dex文件
  • copy剩余文件

今天想和大家讨论的只有第一步,关于ApkTool是如何解析resources.arsc的。

如何初始ApkDecoder的成员变量mResTable的,剩下的我们会下次继续探讨。

ps:想看大概结果的,直接跳到最后看图。

resources.arsc的格式

resources.arsc是一个二进制文件,想要解析他就必须先弄懂这个文件格式到底是什么样子的。
先上一张来源于网络的图片。(图片来源与网络,侵,删)

其实整体的就是这个意思了,首先全部的话就是一个resource table,然后依次读取String Pool,Package Header等。

这些格式,具体的都在Android源码里面,具体的文件是ResourceTypes.h,
比如:

struct ResChunk_header
{
    uint16_t type;
    uint16_t headerSize;
    uint32_t size;
};

enum {
    RES_NULL_TYPE               = 0x0000,
    RES_STRING_POOL_TYPE        = 0x0001,
    RES_TABLE_TYPE              = 0x0002,
    RES_XML_TYPE                = 0x0003,

    // Chunk types in RES_XML_TYPE
    RES_XML_FIRST_CHUNK_TYPE    = 0x0100,
    RES_XML_START_NAMESPACE_TYPE= 0x0100,
    RES_XML_END_NAMESPACE_TYPE  = 0x0101,
    RES_XML_START_ELEMENT_TYPE  = 0x0102,
    RES_XML_END_ELEMENT_TYPE    = 0x0103,
    RES_XML_CDATA_TYPE          = 0x0104,
    RES_XML_LAST_CHUNK_TYPE     = 0x017f,
    // This contains a uint32_t array mapping strings in the string
    // pool back to resource identifiers.  It is optional.
    RES_XML_RESOURCE_MAP_TYPE   = 0x0180,

    // Chunk types in RES_TABLE_TYPE
    RES_TABLE_PACKAGE_TYPE      = 0x0200,
    RES_TABLE_TYPE_TYPE         = 0x0201,
    RES_TABLE_TYPE_SPEC_TYPE    = 0x0202
};

struct ResStringPool_header
{
    struct ResChunk_header header;
    uint32_t stringCount;
    uint32_t styleCount;
    enum {
        SORTED_FLAG = 1<<0,
        UTF8_FLAG = 1<<8
    };
    uint32_t flags;
    uint32_t stringsStart;
    uint32_t stylesStart;
};

因为篇幅原因,所以把注释部分删除掉了,具体的大家可以查阅源码,也有一个不错的源码阅读网站分享给大家,想看的话可以不用下载啦,直接在线看就好了。

源码网站地址,

解析流程

我们首先看Main.java

public static void main(String[] args) throws IOException, InterruptedException, BrutException {

        //......略......
        boolean cmdFound = false;
        for (String opt : commandLine.getArgs()) {
            if (opt.equalsIgnoreCase("d") || opt.equalsIgnoreCase("decode")) {
                //主要是这里,执行了cmdDecode方法来解码
                cmdDecode(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("b") || opt.equalsIgnoreCase("build")) {
                cmdBuild(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("if") || opt.equalsIgnoreCase("install-framework")) {
                cmdInstallFramework(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("empty-framework-dir")) {
                cmdEmptyFrameworkDirectory(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("publicize-resources")) {
                cmdPublicizeResources(commandLine);
                cmdFound = true;
            }
        }
    //......略......
}

主要是调用了cmdDecode方法来解码,我们跟进去看看

private static void cmdDecode(CommandLine cli) throws AndrolibException {
        //先new了一个APkDecoder类,主要是利用这个类进行解码
        ApkDecoder decoder = new ApkDecoder();

        int paraCount = cli.getArgList().size();
        String apkName = cli.getArgList().get(paraCount - 1);
        File outDir;

        //这里主要是根据我们设置的一些参数,然后来对应的设置decoder类的成员变量,、
        //最主要的主要是设置好输出目录,一些模式,以及版本等
        if(//......略......) {
            //......略......
        } else {
            // make out folder manually using name of apk
            String outName = apkName;
            outName = outName.endsWith(".apk") ? outName.substring(0,
                    outName.length() - 4).trim() : outName + ".out";

            //设置输出目录
            outName = new File(outName).getName();
            outDir = new File(outName);
            decoder.setOutDir(outDir);
        }
        //......略......
        decoder.setApkFile(new File(apkName));

        try {
            //开始解码
            decoder.decode();
        } catch (OutDirExistsException ex) {
           //......略......
        } finally {
            //......略......
        }
    }

我们跟进decoder.decode()方法来看看

public void decode() throws AndrolibException, IOException, DirectoryException {
        try {
            //获取输出目录
            File outDir = getOutDir();
            //这里其实是和我们输入的一个keep-broken-res参数有关
            AndrolibResources.sKeepBroken = mKeepBrokenResources;
            //判断是否需要覆盖
            if (!mForceDelete && outDir.exists()) {
                throw new OutDirExistsException();
            }
            //判断apk文件是否合法
            if (!mApkFile.isFile() || !mApkFile.canRead()) {
                throw new InFileNotFoundException();
            }

            //清理干净需要输出的目录,准备写入
            try {
                OS.rmdir(outDir);
            } catch (BrutException ex) {
                throw new AndrolibException(ex);
            }
            outDir.mkdirs();
            //打印log信息,这个时候就对应我们执行apktool d xxx.apk时候的第一句了
            LOGGER.info("Using Apktool " + Androlib.getVersion() + " on " + mApkFile.getName());
            //判断apk内是否有resources.arsc文件,
            if (hasResources()) {
                //判断解码Resources
                switch (mDecodeResources) {
                    case DECODE_RESOURCES_NONE:
                        mAndrolib.decodeResourcesRaw(mApkFile, outDir);
                        if (mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
                            setTargetSdkVersion();
                            setAnalysisMode(mAnalysisMode, true);

                            // done after raw decoding of resources because copyToDir overwrites dest files
                            if (hasManifest()) {
                                mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
                            }
                        }
                        break;
                    case DECODE_RESOURCES_FULL:

                        setTargetSdkVersion();
                        setAnalysisMode(mAnalysisMode, true);

                        if (hasManifest()) {
                            mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
                        }
                        mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());
                        break;
                }
            } else {
                // if there's no resources.arsc, decode the manifest without looking
                // up attribute references
                if (hasManifest()) {
                    if (mDecodeResources == DECODE_RESOURCES_FULL
                            || mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
                        mAndrolib.decodeManifestFull(mApkFile, outDir, getResTable());
                    }
                    else {
                        mAndrolib.decodeManifestRaw(mApkFile, outDir);
                    }
                }
            }
            //......略......

    }

一般来说的话,我们会执行到DECODE_RESOURCES_FULL分支里面的,这里面的第一步是setTargetSdkVersion。

我们主要再看看setTargetSdkVersion方法的内部实现

public void setTargetSdkVersion() throws AndrolibException, IOException {
    if (mResTable == null) {
        mResTable = mAndrolib.getResTable(mApkFile);
    }

    Map<String, String> sdkInfo = mResTable.getSdkInfo();
    if (sdkInfo.get("targetSdkVersion") != null) {
        mApi = Integer.parseInt(sdkInfo.get("targetSdkVersion"));
    }
}

其实ApkDecoder内部是维护了一个mResTable的,我们的任何的信息都是根据mResTable来取的,那可能会问了,那ApkDecoder内部的ResTable到底是个什么东西呢,其实他就是我们上面的部分说的那张经典的图。

当ApkDecoder发现mResTable变量是空的的时候,会对此进行初始化,接下来我们就主要看看Androlib的getResTable方法,这个方法就是主要从apkFile里面读出mResTable,分析他的格式,

//Androidlib.java文件内容
public ResTable getResTable(ExtFile apkFile)
        throws AndrolibException {
    return mAndRes.getResTable(apkFile, true);
}

//AndrolibResources.java的getResTable方法
public ResTable getResTable(ExtFile apkFile, boolean loadMainPkg)
            throws AndrolibException {
    ResTable resTable = new ResTable(this);
    if (loadMainPkg) {
        loadMainPkg(resTable, apkFile);
    }
    return resTable;
}

上面的代码掉有了mAndRes的getResTable方法,然后内部再调用loadMainPkg方法,我们继续跟进内部实现


public ResPackage loadMainPkg(ResTable resTable, ExtFile apkFile)
            throws AndrolibException {
    //打印log信息,这个时候就对应到了我们上面说的第二句了
    LOGGER.info("Loading resource table...");
    ResPackage[] pkgs = getResPackagesFromApk(apkFile, resTable, sKeepBroken);
    ResPackage pkg = null;

    switch (pkgs.length) {
        case 1:
            pkg = pkgs[0];
            break;
        case 2:
            if (pkgs[0].getName().equals("android")) {
                LOGGER.warning("Skipping \"android\" package group");
                pkg = pkgs[1];
                break;
            } else if (pkgs[0].getName().equals("com.htc")) {
                LOGGER.warning("Skipping \"htc\" package group");
                pkg = pkgs[1];
                break;
            }

        default:
            pkg = selectPkgWithMostResSpecs(pkgs);
            break;
    }

    if (pkg == null) {
        throw new AndrolibException("arsc files with zero packages or no arsc file found.");
    }

    resTable.addPackage(pkg, true);
    return pkg;
}

这个时候首先是执行getResPackagesFromApk方法,获取ResPackage信息,

private ResPackage[] getResPackagesFromApk(ExtFile apkFile,ResTable resTable, boolean keepBroken)
            throws AndrolibException {
    try {
        Directory dir = apkFile.getDirectory();
        BufferedInputStream bfi = new BufferedInputStream(dir.getFileInput("resources.arsc"));
        try {
            //主要是这个方法来对resources.arsc文件进行解析
            return ARSCDecoder.decode(bfi, false, keepBroken, resTable).getPackages();
        } finally {
            try {
                bfi.close();
            } catch (IOException ignored) {}
        }
    } catch (DirectoryException ex) {
        throw new AndrolibException("Could not load resources.arsc from file: " + apkFile, ex);
    }
}

我们跟进ARSCDecoder的decode方法


public static ARSCData decode(InputStream arscStream, boolean findFlagsOffsets, boolean keepBroken,
                                  ResTable resTable)
            throws AndrolibException {
    try {
        //首先根据输入流,resTable等参数new一个ARSCDecoder
        ARSCDecoder decoder = new ARSCDecoder(arscStream, resTable, findFlagsOffsets, keepBroken);
        //
        ResPackage[] pkgs = decoder.readTableHeader();
        return new ARSCData(pkgs, decoder.mFlagsOffsets == null
                ? null
                : decoder.mFlagsOffsets.toArray(new FlagsOffset[0]), resTable);
    } catch (IOException ex) {
        throw new AndrolibException("Could not decode arsc file", ex);
    }
}
private ResPackage[] readTableHeader() throws IOException, AndrolibException {

    nextChunkCheckType(Header.TYPE_TABLE);

    int packageCount = mIn.readInt();

    mTableStrings = StringBlock.read(mIn);
    ResPackage[] packages = new ResPackage[packageCount];

    nextChunk();
    for (int i = 0; i < packageCount; i++) {
        mTypeIdOffset = 0;
        packages[i] = readTablePackage();
    }
    return packages;
}

那么这里的时候,关键的点总算来了,首先是读取了ChunkCheckType,Header.TYPE_TABLE的值是0x0002, 这里的type正好对应上了我们在ResourceTypes.h里面对应的RES_TABLE_TYPE = 0x0002,其实就是图中最外层的那个ResourceTable

我们跟进nextChunkCheckType方法,

//ARSCDecoder类内
private void nextChunkCheckType(int expectedType) throws IOException, AndrolibException {
    nextChunk();
    //这时候这里的参数expectedType的值是2,也就是RES_TABLE_TYPE的,
    checkChunkType(expectedType);
}

//ARSCDecoder类内
private Header nextChunk() throws IOException {
    return mHeader = Header.read(mIn, mCountIn);
}

//Header类内
public static Header read(ExtDataInput in, CountingInputStream countIn) throws IOException {
        short type;
        int start = countIn.getCount();
        try {
            //首先读出type,
            type = in.readShort();
        } catch (EOFException ex) {
            return new Header(TYPE_NONE, 0, 0, countIn.getCount());
        }
        //这里分别解释下4个参数,
        //第一个参数type 对应的类型 2个字节
        //第二个参数     头大小 2个字节
        //第三个参数     文件大小 4个字节
        //第四个参数     暂时我们start位置为0
        //然后返回new出来的Header
        return new Header(type, in.readShort(), in.readInt(), start);
    }

    private void checkChunkType(int expectedType) throws AndrolibException {
        //这里主要校验的就是我们刚刚header的type和我们传入的是否相同,不同就抛异常了
        if (mHeader.type != expectedType) {
            throw new AndrolibException(String.format("Invalid chunk type: expected=0x%08x, got=0x%08x",
                    expectedType, mHeader.type));
        }
    }

读取一个Chunk,如上方所示调用关系,关键的地方已经加上了注释。

nextChunkCheckType(Header.TYPE_TABLE)主要是读取了下面红圈的部分。

我们继续分析readTableHeader方法。

private ResPackage[] readTableHeader() throws IOException, AndrolibException {
    //主要是读取红圈部分的值
    nextChunkCheckType(Header.TYPE_TABLE);
    //读取上图红圈后面的packageCount变量,4字节
    int packageCount = mIn.readInt();
    //接下来就是主要分析这里了,读取Global String Pool 
    mTableStrings = StringBlock.read(mIn);
    ResPackage[] packages = new ResPackage[packageCount];

    nextChunk();
    for (int i = 0; i < packageCount; i++) {
        mTypeIdOffset = 0;
        packages[i] = readTablePackage();
    }
    return packages;
}

接下来主要分析StringBlock的read方法


public static StringBlock read(ExtDataInput reader) throws IOException {
    //这里主要是跳过了RES_STRING_POOL_TYPE,和头大小两个,并且还校验了一下,
    //校验的方法就是和CHUNK_STRINGPOOL_TYPE比对一下,CHUNK_STRINGPOOL_TYPE的值是0x001C0001
    //这是因为RES_STRING_POOL_TYPE的值是0x0001,头大小是0x001C,所以这个CHUNK_STRINGPOOL_TYPE就是0x001C0001了
    reader.skipCheckInt(CHUNK_STRINGPOOL_TYPE);
    //读取块大小,Global String Pool内
    int chunkSize = reader.readInt();

    // ResStringPool_header
    //字符串数
    int stringCount = reader.readInt();
    //style数
    int styleCount = reader.readInt();
    //flags标记,1是SORTED_FLAG,256是UTF8_FLAG
    int flags = reader.readInt();
    //字符串起始位置
    int stringsOffset = reader.readInt();
    //style起始位置
    int stylesOffset = reader.readInt();
    //new一个StringBlock
    StringBlock block = new StringBlock();
    //根据读取出的flags信息,来设置block
    block.m_isUTF8 = (flags & UTF8_FLAG) != 0;
    //初始化block变量
    block.m_stringOffsets = reader.readIntArray(stringCount);
    block.m_stringOwns = new int[stringCount];
    Arrays.fill(block.m_stringOwns, -1);
    //初始化block内部style
    if (styleCount != 0) {
        block.m_styleOffsets = reader.readIntArray(styleCount);
    }

    int size = ((stylesOffset == 0) ? chunkSize : stylesOffset) - stringsOffset;
    block.m_strings = new byte[size];
    reader.readFully(block.m_strings);

    if (stylesOffset != 0) {
        size = (chunkSize - stylesOffset);
        block.m_styles = reader.readIntArray(size / 4);

        // read remaining bytes
        int remaining = size % 4;
        if (remaining >= 1) {
            while (remaining-- > 0) {
                reader.readByte();
            }
        }
    }
    //返回最终的结果
    return block;
}

reader.skipCheckInt(CHUNK_STRINGPOOL_TYPE)跳过的部分如下:

private ResPackage[] readTableHeader() throws IOException, AndrolibException {
    //主要是读取红圈部分的值
    nextChunkCheckType(Header.TYPE_TABLE);
    //读取上图红圈后面的packageCount变量,4字节
    int packageCount = mIn.readInt();
    //接下来就是主要分析这里了,读取Global String Pool 
    mTableStrings = StringBlock.read(mIn);
    //此时此刻执行到了这里,要开始分析ResPackage了
    ResPackage[] packages = new ResPackage[packageCount];

    nextChunk();
    for (int i = 0; i < packageCount; i++) {
        mTypeIdOffset = 0;
        //使用readTablePackage方法来分析
        packages[i] = readTablePackage();
    }
    return packages;
}

重复性任务

emmmmm。。。。
博主分析到了这里,如果你能读到这里我自己也感受到很高兴啊,希望能给你带来了帮助。其实后序的分析readTablePackage方法和之前的一样啦,博主详细如果你读懂了前面的分析,那么这个肯定也不在话下

所以呢,我就不一一的带大家理解,主要的还是看懂那张图,然后看懂ApkTool是如何来分析就可以啦。

这样做的好处就是,如果有apk在这个resource.arsc文件内做文章,我们可以debug反查,看看到底是怎么回事,可以有一些自己对付的思路。

readTablePackage之后

读取完了之后,程序就会一步一步的返回回去,这个时候我们的mResTable变量就初始化好了,就可以继续进行setTargetSdkVersion方法的执行了,

我们这篇博客主要就是进行ApkDeocder成员变量mResTable的初始化分析,
我画了个图,希望能帮助大家虑说清楚上面的一系列调用

Created with Raphaël 2.1.2 Main Main ApkDecoder ApkDecoder Androidlib Androidlib AndrolibResources AndrolibResources ARSCDecoder ARSCDecoder cmdDecode decode setTargetSdkVersion getResTable getResTable loadMainPkg getResPackagesFromApk decode readTableHeader nextChunkCheckType nextChunk readTablePackage

写在最后

分析源码并不难,希望大家都能耐下心来一点一点看,一点一点调试分析。
文章一层一层的调用很深,所以可能会给读者困惑,有困惑的,可以联系我,我也喜欢和读者一起探讨啦,有写的不对的地方多多指教。

正因为调用比较深,所以最后画出了UML图,希望能让大家看得更简单明了

关于我

个人博客:MartinHan的小站

博客网站:hanhan12312的专栏

知乎:MartinHan01

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: apktool是一个用于反编译和重新编译Android应用程序的工具。它帮助开发人员了解和修改应用程序的内部结构。apktool_2.6.1.jar是apktool的一个特定版本。 使用apktool_2.6.1.jar的一般步骤如下: 1. 下载apktool_2.6.1.jar文件和与之兼容的Java运行环境(JRE)。 2. 打开终端或命令行窗口,并导航到包含apktool_2.6.1.jar文件的目录。 3. 接下来,你可以使用以下命令行选项之一来使用apktool_2.6.1.jar: - "apktool d <apk路径>":这个命令用于反编译指定的APK文件。APK文件将被解压缩到当前目录的子目录中。你可以在这个子目录中找到应用程序的资源、XML文件、源代码等。 - "apktool b <反编译目录>":这个命令用于重新编译先前反编译的应用程序。指定的反编译目录将被重新打包成一个新的APK文件。 4. 根据你的需求,你可以在反编译期间使用其他选项,如"--no-src"(不反编译源代码)和"--force"(强制覆盖目标文件)。 总而言之,apktool_2.6.1.jar是一个功能强大的工具,它允许你反编译和重新编译Android应用程序。它提供了命令行界面,使你能够轻松地修改应用程序的资源、XML文件和源代码。通过使用适当的命令行选项,你可以定制反编译和重新编译过程,以满足你的具体需求。 ### 回答2: apktool_2.6.1.jar 是一个用于反编译和编译Android APK文件的工具。以下是它的一些用法: 1. 反编译APK文件:通过运行以下命令,可以将APK文件解析为其源代码和资源文件: java -jar apktool_2.6.1.jar d filename.apk 这将在当前目录下创建一个新的文件夹,其中包含解析出的源代码和资源文件。 2. 编辑应用程序代码:在反编译APK文件后,可以编辑解析出的源代码。你可以使用任何文本编辑器进行修改,以实现对应用程序的必要更改。 3. 重新编译APK文件:在完成对源代码的修改后,可以重新编译APK文件,使用以下命令: java -jar apktool_2.6.1.jar b foldername "foldername" 是解析出的源代码文件夹的名称。这将重新打包和编译应用程序,并在当前目录中生成一个新的APK文件。 4. 安装APK文件:你可以使用adb工具或将生成的APK文件传输到你的Android设备上,并通过点击进行安装。 需要注意的是,使用apktool_2.6.1.jar 时需要先安装Java运行环境。此外,apktool还具有其他选项和功能,可以通过运行"java -jar apktool_2.6.1.jar"命令查看帮助文档以获取更多信息和用法示例。 总之,apktool_2.6.1.jar 是一个方便的工具,可以帮助开发人员进行Android APK文件的反编译和编译,以便进行应用程序的修改和定制。 ### 回答3: apktool_2.6.1.jar 是一种用于反编译和编译APK文件的工具。下面是一些关于使用 apktool_2.6.1.jar 的常见用法说明: 1. 反编译:使用以下命令将 APK 文件反编译为其包含的资源文件和 Smali 代码: java -jar apktool_2.6.1.jar d <apk文件路径> -o <输出目录> 例如:java -jar apktool_2.6.1.jar d app.apk -o output/ 反编译后的资源文件将会存储在 output/ 目录中,可以在这里查看和修改应用程序的资源。 2. 编译:使用以下命令将修改后的资源文件和 Smali 代码编译回 APK 文件: java -jar apktool_2.6.1.jar b <输入目录> -o <输出APK文件路径> 例如:java -jar apktool_2.6.1.jar b output/ -o modified_app.apk 编译过程会将修改后的资源文件和 Smali 代码重新打包为一个 APK 文件,保存到指定的输出路径中。 3. 重新签名:由于反编译和编译过程会破坏应用程序的签名,所以需要重新对 APK 文件进行签名。可以使用 Android SDK 中的工具进行签名,例如 jarsigner。 例如:jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore <你的密钥库文件路径> -storepass <密钥库密码> modified_app.apk <密钥库别名> 这样,重新签名后的 APK 文件就可以安装在 Android 设备上了。 这些是 apktool_2.6.1.jar 常见的用法。通过反编译和编译 APK 文件,可以查看和修改应用程序的源码、资源和配置文件等内容,帮助开发人员进行应用程序的逆向工程或定制化开发。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值