Android 打包流程之aapt打包资源文件

上一篇:Android打包流程之资源管理
Android应用最终是以apk的形式放在手机上安装并运行的,而负责将资源文件和代码进行打包的工具就叫appt,全称Android Asset Packaging Tool,翻译过来就是Android资源打包工具,是Android打包流程中不可或缺的一环。虽然build-tools中都会有一个aapt.exe负责打包apk,但底层还是通过执行aapt命令的方式来进行操作,所以这里需要了解一下aapt的相关命令,有助于更好的理解打包的流程。

Android打包流程简述

先来看张官方的打包流程图:


Android打包流程图

  1. 首先将资源文件(res目录下)通过aapt工具打包成R.java类(资源索引)和.arsc资源文件。
  2. 倘若工程中又aidl,则通过aidl工具将aidl打包成java接口类。
  3. R.java、aidl生成的java文件和项目中的源代码混合编译成.class文件
  4. class文件和第三方jar或者library通过dx工具打包成dex文件。dx工具的主要作用是将java字节码转换成Dalvik字节码,在此过程中会压缩常量池,消除一些冗余信息等。
  5. 通过apkbuilder工具将所有没有编译的资源,.arsc资源,.dex文件打包到一个完成apk文件中。
  6. 生成的apk通过Jarsinger工具,利用relese或debug下配置的keystore文件进行签名,得到签名后的apk文件。
  7. 通过zipAlign工具对签名后的apk进行对齐处理。即将APK包中所有的资源文件距离文件起始偏移为4字节整数倍,这样通过内存映射访问apk文件时的速度会更快。减少运行时内存的使用。

这就是Android打包成apk的流程,而资源的打包则涉及到许多aapt命令。

AAPT命令详解

1、aapt l[ist] [-v] [-a] file.{zip,jar,apk}
列出(zip、jar、apk)等类型压缩包中的所有文件列表,示例:

aapt list  app-debug.apk

#list可以简写为l,如下:
aapt l  app-debug.apk

列出app-debug.apk中所有的文件列表:


在这里插入图片描述

打印出来的内容很长,在命令行中不方便观察,我们可以将输出的内容存放到.txt文件中便于查看。示例:

aapt list  app-debug.apk>a.txt

把输出的内容存入a.txt文件中。
aapt list后面还可以加-v和-a。例如加-v参数:

aapt list -v app-debug.apk>a.txt

也会列出apk中所有的文件列表,只不过更加详细:
在这里插入图片描述

其中各字段代表的含义如下:
Length:文件的长度。
Method:数据压缩算法,有Deflate和Stored两种。知道是用来压缩文件的就行。
Ratio:压缩率。
Size:文件压缩后节省的大小。跟压缩率有关。Size=(1-压缩率)*Length。
Date:日期。
Time:时间。
CRC-32:循环冗余校验,是一种加密算法。
Name:文件名称。

aapt list后面加-a参数:

aapt list -a app-debug.apk>a.txt

列出apk中所有的文件列表、资源id、各限定符下资源文件对应的id、AndroidManifest文件内容。


在这里插入图片描述

看下右边的滚动条,发现这个输出的内容不要太多,不要太详细。最底下列出了Android Manifest文件的内容。

2、aapt d[ump] [–values] [–include-meta-data] WHAT file.{apk} [asset [asset …]]
通过参数配置列出apk中各种详细信息,例如apk的权限、字符、资源等等。
主要有以下几种参数:

  1. strings
    列出apk中的所有字符资源(包括不同语言限定符):示例:
aapt dump strings app-debug.apk>a.txt

结果:


在这里插入图片描述

这里有乱码,有其它国家语言符号。大家可以自行试一试。

  1. badging
    Print the label and icon for the app declared in APK。实际内容不止label和icon。还有包名、versionCode、versionName、compileSdkVersion、targetSdkVersion等等信息,这里截取一小部分:


    在这里插入图片描述

  2. permissions
    列出apk所用到的所有权限。示例:

aapt dump permissions app-debug.apk

结果会列出apk所需要的所有权限,这里不贴图了。

  1. resources。
    输出apk中所有的资源信息,包括用户添加的资源、不同限定符下的系统资源等等,示例:
aapt dump resources app-debug.apk>a.txt

结果:


在这里插入图片描述

  1. configurations
    列出apk中所有的资源目录,注意,只是目录,不包含任何文件内容,资源目录对应不同的限定符。
aapt dump configurations app-debug.apk

关于限定符这篇文章有写到:限定符,结果如下:


在这里插入图片描述

5.xmltree
打印出指定xml文件的文档树结构。例如打印出项目中res/layout/activity_main.xml的树形结构,则可以这样写:

aapt d xmltree app-debug.apk res/layout/activity_main.xml

打印结果如下:


在这里插入图片描述

从上到下列出了activity_main.xml的文档树结构。

6.xmlstrings
列出给出的xml文件中所包含的控件名、控件的属性名和属性值等等。示例:

aapt d xmlstrings app-debug.apk res/layout/activity_main.xml

结果:


在这里插入图片描述

3、aapt p[ackage]
负责打包资源文件的命令,这个命令是aapt中最核心、最复杂的命令:

aapt p[ackage] [-d][-f][-m][-u][-v][-x][-z][-M AndroidManifest.xml] \
        [-0 extension [-0 extension ...]] [-g tolerance] [-j jarfile] \
        [--debug-mode] [--min-sdk-version VAL] [--target-sdk-version VAL] \
        [--app-version VAL] [--app-version-name TEXT] [--custom-package VAL] \
        [--rename-manifest-package PACKAGE] \
        [--rename-instrumentation-target-package PACKAGE] \
        [--utf16] [--auto-add-overlay] \
        [--max-res-version VAL] \
        [-I base-package [-I base-package ...]] \
        [-A asset-source-dir]  [-G class-list-file] [-P public-definitions-file] \
        [-D main-dex-class-list-file] \
        [-S resource-sources [-S resource-sources ...]] \
        [-F apk-file] [-J R-file-dir] \
        [--product product1,product2,...] \
        [-c CONFIGS] [--preferred-density DENSITY] \
        [--split CONFIGS [--split CONFIGS]] \
        [--feature-of package [--feature-after package]] \
        [raw-files-dir [raw-files-dir] ...] \
        [--output-text-symbols DIR]

好吧,来看下每个参数啥意义:

-d:包含一个或多个设备资源,由逗号分割。
-f:强制覆盖现有文件。
-m:生成包的目录在-j参数指定的目录。
-u:更新现有的包。
-v:详细输出,加上此命令会在控制台输出每一个资源文件信息,R.java生成后还有注释。
-x:创建拓展资源id。
-z:需要本地化的资源属性标记定位。
-M:指定AndroidManifest文件的路径。
-0:指定一个附加扩展名,对于该扩展名,此类文件将不会压缩存储在.apk中。空字符串表示不压缩所有文件。
-g:强制图片灰度,灰度值默认为0-jar:指定要包含的类的jar或zip文件。
--debug-mode:设置调试模式,即在AndroidManifest中加入  android:debuggable="true"
--min-sdk-version VAL:指定最小SDK版本 如是7以上 则默认编译资源的格式是 utf-8
--target-sdk-version VAL:AndroidManifest中指定目标编译版本sdk--app-version VAL:指定app的版本号。
--app-version-name TEXT:指定app的版本名。
--custom-package VAL:生成R.java到不同的包。
--rename-manifest-package PACKAGE:修改manifest中的应用包名。
--rename-instrumentation-target-package PACKAGE:重写指定包名的选项
--utf16:将资源的默认编码更改为UTF-16。在api在7或者更高时有用,资源的默认编码是utf-8--auto-add-overlay:自动添加资源覆盖。
--max-res-version VAL:最大资源版本,忽略高于给定值的版本化资源目录。
-I base-package:指定的SDK版本中android.jar的路径。
-A asset-source-dir:指定Assets文件夹路径。
-G class-list-file:指定用于输出混淆选项的文件。
-P public-definitions-file:指定的输出公共资源,可以指定一个文件 让资源ID输出到上面。
-D main-dex-class-list-file:指定主DEX的混淆选项输出的文件。
-S resource-sources:指定资源目录,一般是res。
-F apk-file:指定把资源输出到 apk文件中。
-J R-file-dir指定R.java的输出路径。
-c CONFIGS:指定资源有哪些限定符,中间以逗号分割,如en、port、land、en_US。
--preferred-density DENSITY:指定设备的屏幕密度,不符合此密度的资源将会被删除。
--split CONFIGS:分包构建apk,可以和主apk一起加载。
--output-text-symbols DIR:在指定的文件夹下生成一个包含R.java中的资源标识符的文本文件。

这命令复杂的分分钟让人抓狂-。-
但其实只要知道这个命令是做什么的就好,它就是用来将项目中的所有资源文件打包,经过我的实践,其最小执行单元如下:

aapt package -S [res文件夹路径] -M [AndroidManifest.xml文件路径] -I [sdk中android.jar的路径] -F [xxx.apk]

意思就是最起码要指定这四个值,才能执行aapt package命令。于是我这里执行了一个命令。

aapt package -S res -M E:\AndroidProjects\Dagger2\app\src\main\AndroidManifest.xml -I D:\Android\android-sdk-windows\platforms\android-28\android.jar -F out.apk

结果:


在这里插入图片描述

然后就报了这个错误,说是资源找不到,可用as打开工程,这些资源明明都能找到。一度陷入绝望,后来实在没办法,把这些资源删了,AndroidManifest.xml文件中删除这一行代码:android:theme="@style/TheTheme",于是才可以执行成功。至于加上这三个资源、指定主题为什么会报错,我在网上找了许多资料都没有找到这个问题的症结所在,若是有哪位大神知道这个问题的解决办法,还请在底下留言告诉我,不胜感激~我真的很想知道他喵的到底是为什么-。-

好了,至于打包后就是一个out.apk压缩文件,我们解压后,目录如下:
在这里插入图片描述

res文件夹里面是我们的资源目录:


在这里插入图片描述

至于resources.arsc就是资源打成的包了,将资源文件打包成.arsc的文件,就是我们上面流程图的第一步。以后打包apk的时候会将这个文件也一同打包进去。还有一个AndroidManifest.xml,打开后一堆看不懂的:
在这里插入图片描述

这里的文件内容做了处理,防止别人窥探到其中的代码。至于其它可以接的参数,这里只演示一个:

--rename-manifest-package PACKAGE:修改manifest中的应用包名。

好吧,然后我们在原来的命令上接入该参数:

aapt package -S res -M E:\AndroidProjects\Dagger2\app\src\main\AndroidManifest.xml -I D:\Android\android-sdk-windows\platforms\android-28\android.jar  -F out.apk --rename-manifest-package com.aapt.demo

在刚才命令的最后面加入–rename-manifest-package com.aapt.demo,把AndroidManifest.xml中的包名改成com.aapt.demo,打包完成后解压并查看AndroidManifest文件:


在这里插入图片描述

虽然大部分是乱码,可是我们还是能看到包名已经改成了com.aapt.demo。至于接其它的参数,笔者这里没有一个个试,其实常用的就那些,其他的都是辅助参数。

4、aapt r[emove] [-v] file.{zip,jar,apk} file1 [file2 …]
用于移除打包好的apk中的文件。例如移除打包好的apk中的AndroidManifest.xml文件:

aapt r out.apk AndroidManifest.xml

这里移除了out.apk中的AndroidManifest.xml文件,然后执行如下命令:

aapt d xmmtree out.apk AndroidManifest.xml

然后会出现如下错误:


在这里插入图片描述

提示该文件找不到,可见确实文件是被删除了。可是这里奇怪的是,解压apk,发现AndroidManifest.xml文件依旧在,但执行命令的时候就提示找不到。什么原因,暂时还不知道,有知道的可以告诉我。

5、 aapt a[dd] [-v] file.{zip,jar,apk} file1 [file2 …]
添加文件到打包好的apk中。示例:将a.txt、b.txt添加到打包好的apk中:

aapt a out.apk a.txt b.txt

多个文件中间以空格分割就好,然后看下解压apk,看其中的目录:


在这里插入图片描述

将a.txt和b.txt成功加入到apk中了。

6、 aapt c[runch] [-v] -S resource-sources … -C output-folder …
做PNG文件的预处理,并将结果存储到一个文件夹中。示例,把res目录下的图片预处理,并存储到任意路径pictures中:

aapt c -S res -C E:\AndroidProjects\Dagger2\app\src\main\pictures

执行后结果如下:


在这里插入图片描述

表示执行成功,然后我们看下pictures路径中的文件:


在这里插入图片描述

好吧,没什么可说的。

7、aapt s[ingleCrunch] [-v] -i input-file -o outputfile
对单个PNG文件进行预处理,并输出到指定文件:

aapt s -v -i[需要处理的图片文件路径] -o[处理完成后存储的图片文件路径]

示例:

aapt s -v -i E:\AndroidProjects\Dagger2\app\src\main\res\mipmap-hdpi\ic_launcher.png -o E:\AndroidProjects\Dagger2\app\src\main\pictures\a.png

这里预先在pictures文件夹中新建a.txt文件,然后把后缀名改成.png,这时候a.png是无法打开的,执行此命令后,a.png就可以打开了吗,显示的图像就是res中的hdpi目录下的ic_launcher.png。

8、 aapt v[ersion]
没什么好说的,显示aapt的版本


在这里插入图片描述

aapt的命令差不多就这么几种。在命令行中执行aapt命令,就可以查看aapt详细的用法。有兴趣的小伙伴可以自行试一试哈~

AAPT源码分析

关于源码,这里只是看一个脉络。需要下载Android系统源码并解压,下载地址:各版本系统源码下载。我这里下载的是6.0的源码。关于aapt部分的源码在frameworks\base\tools\aapt文件夹下。其入口在Main.cpp中main方法中。main方法共有近500行,这里只贴一部分来分析:

int main(int argc, char* const argv[])
{
    char *prog = argv[0];
	//Bundle对象用来存储输入的操作类型和相关的参数。
    Bundle bundle;
    bool wantUsage = false;
    int result = 1;    // pessimistically assume an error.
    int tolerance = 0;

    /* default to compression */
    bundle.setCompressionMethod(ZipEntry::kCompressDeflated);

    if (argc < 2) {
        wantUsage = true;
        goto bail;
    }

	....
	//argv[] 一行aapt命令被分割成字符串数组。
	//这边判断该数组的第二个元素的第1个字符,如aapt package,argv[1][0]获取到的就是p。
    else if (argv[1][0] == 'p')
		//设置执行类型为打包。
        bundle.setCommand(kCommandPackage);
	
	....
    
    argc -= 2;
    argv += 2;

    /*
     * Pull out flags.  We support "-fv" and "-f -v".
     */
    while (argc && argv[0][0] == '-') {
        /* flag(s) found */
        const char* cp = argv[0] +1;

        while (*cp != '\0') {
            switch (*cp) {
       
			......
		    //收集appt命令输入的参数,这些参数以"-"开头。
            case '-':
                if (strcmp(cp, "-debug-mode") == 0) {
					//例如这里就获取到了-debug-mode,然后设置Bundle的debugMode为true。
                    bundle.setDebugMode(true);
                } 
				.....
				
                cp += strlen(cp) - 1;
                break;
            default:
                fprintf(stderr, "ERROR: Unknown flag '-%c'\n", *cp);
                wantUsage = true;
                goto bail;
            }

            cp++;
        }
        argc--;
        argv++;
    }

    /*
     * We're past the flags.  The rest all goes straight in.
     */
    bundle.setFileSpec(argv, argc);

	//执行最终的命令,并得到结果
    result = handleCommand(&bundle);

bail:
    if (wantUsage) {
        usage();
        result = 2;
    }

    //printf("--> returning %d\n", result);
    return result;
}

源码中比较详细,这里举个例子。整行aapt命令会被分割成字符串数组。然后去字符串数组的第二个的第一个字符,如aapt package…,得到p,代表这是aapt打包命令。Bundle对象用以存储输入的操作类型和相关的参数,供后面执行命令详细操作时使用。当然,命令解析错误时,会通过goto跳转到bail代码块,比如:

 default:
     fprintf(stderr, "ERROR: Unknown flag '-%c'\n", *cp);
     wantUsage = true;
     goto bail;

bail代码块:

bail:
    if (wantUsage) {
        usage();
        result = 2;
    }
    //printf("--> returning %d\n", result);
    return result;
}

好吧,会调用usage()函数,这个函数会打印出aapt的用法文档。

void usage(void)
{
    fprintf(stderr, "Android Asset Packaging Tool\n\n");
    fprintf(stderr, "Usage:\n");
    fprintf(stderr,
        " %s l[ist] [-v] [-a] file.{zip,jar,apk}\n"
        "   List contents of Zip-compatible archive.\n\n", gProgName);
    fprintf(stderr,
        " %s d[ump] [--values] [--include-meta-data] WHAT file.{apk} [asset [asset ...]]\n"
        "   strings          Print the contents of the resource table string pool in the APK.\n"
        "   badging          Print the label and icon for the app declared in APK.\n"
        "   permissions      Print the permissions from the APK.\n"
        "   resources        Print the resource table from the APK.\n"
        "   configurations   Print the configurations in the APK.\n"
        "   xmltree          Print the compiled xmls in the given assets.\n"
        "   xmlstrings       Print the strings of the given compiled xml assets.\n\n", gProgName);
    fprintf(stderr,
        " %s p[ackage] [-d][-f][-m][-u][-v][-x][-z][-M AndroidManifest.xml] \\\n"
        "        [-0 extension [-0 extension ...]] [-g tolerance] [-j jarfile] \\\n"
        "        [--debug-mode] [--min-sdk-version VAL] [--target-sdk-version VAL] \\\n"
        "        [--app-version VAL] [--app-version-name TEXT] [--custom-package VAL] \\\n"
        "        [--rename-manifest-package PACKAGE] \\\n"
        "        [--rename-instrumentation-target-package PACKAGE] \\\n"
        "        [--utf16] [--auto-add-overlay] \\\n"
        "        [--max-res-version VAL] \\\n"
        "        [-I base-package [-I base-package ...]] \\\n"
        "        [-A asset-source-dir]  [-G class-list-file] [-P public-definitions-file] \\\n"
        "        [-S resource-sources [-S resource-sources ...]] \\\n"
        "        [-F apk-file] [-J R-file-dir] \\\n"
        "        [--product product1,product2,...] \\\n"
        "        [-c CONFIGS] [--preferred-density DENSITY] \\\n"
        "        [--split CONFIGS [--split CONFIGS]] \\\n"
        "        [--feature-of package [--feature-after package]] \\\n"
        "        [raw-files-dir [raw-files-dir] ...] \\\n"
        "        [--output-text-symbols DIR]\n"

这是代码,看下我们执行aapt时的输出:


在这里插入图片描述

是不是就是这个?好吧,当命令校验成功,会执行handleCommand方法,并传入一个Bundle对象。看下handleCommand方法的代码:

/*
 * Dispatch the command.
 */
int handleCommand(Bundle* bundle)
{
    //printf("--- command %d (verbose=%d force=%d):\n",
    //    bundle->getCommand(), bundle->getVerbose(), bundle->getForce());
    //for (int i = 0; i < bundle->getFileSpecCount(); i++)
    //    printf("  %d: '%s'\n", i, bundle->getFileSpecEntry(i));

    switch (bundle->getCommand()) {
    case kCommandVersion:      return doVersion(bundle);
    case kCommandList:         return doList(bundle);
    case kCommandDump:         return doDump(bundle);
    case kCommandAdd:          return doAdd(bundle);
    case kCommandRemove:       return doRemove(bundle);
    case kCommandPackage:      return doPackage(bundle);
    case kCommandCrunch:       return doCrunch(bundle);
    case kCommandSingleCrunch: return doSingleCrunch(bundle);
    case kCommandDaemon:       return runInDaemonMode(bundle);
    default:
        fprintf(stderr, "%s: requested command not yet supported\n", gProgName);
        return 1;
    }
}

也没什么,就判断是哪种命令,比如package、dump、add、remove,然后再去做具体的操作,如doPackage、doDump、doRemove等等。不过这些方法是通过外部引用的,其真正的代码在同目录下的Command.cpp文件中,这个c文件实现了所有的aapt命令的具体代码。这里举一个稍微简单的例子doRemove看一下:

/*
 * Delete files from an existing archive.
 */
int doRemove(Bundle* bundle)
{
	//命令行举例:aapt r out.apk AndroidManifest.xml
    ZipFile* zip = NULL;
    status_t result = UNKNOWN_ERROR;
    const char* zipFileName;

    if (bundle->getFileSpecCount() < 1) {
		//如果没有指定压缩包名称,提示必须指定压缩包名称
        fprintf(stderr, "ERROR: must specify zip file name\n");
        goto bail;
    }
	//获取到压缩文件名称。[out.apk,AndroidManifest.xml]数组中的第一个。
    zipFileName = bundle->getFileSpecEntry(0);

	//[out.apk,AndroidManifest.xml]数组大小小于2,说明没有要移除的文件。
    if (bundle->getFileSpecCount() < 2) {
        fprintf(stderr, "NOTE: nothing to do\n");
        goto bail;
    }

	//类似于java中的打开输入输出流。
    zip = openReadWrite(zipFileName, false);
    if (zip == NULL) {
		//打开文件失败。
        fprintf(stderr, "ERROR: failed opening Zip archive '%s'\n",
            zipFileName);
        goto bail;
    }

	//索引从1开始,可以有多个要删除的文件。
    for (int i = 1; i < bundle->getFileSpecCount(); i++) {
		//获取要删除的文件名。
        const char* fileName = bundle->getFileSpecEntry(i);
        ZipEntry* entry;

		//获取要删除的文件。
        entry = zip->getEntryByName(fileName);
        if (entry == NULL) {
			//文件找不到
            printf(" '%s' NOT FOUND\n", fileName);
            continue;
        }

		//找到该文件则删除该文件。
        result = zip->remove(entry);

        if (result != NO_ERROR) {
            fprintf(stderr, "Unable to delete '%s' from '%s'\n",
                bundle->getFileSpecEntry(i), zipFileName);
            goto bail;
        }
    }

	//相当于java中输入输出流的刷新。
    zip->flush();

bail:
    delete zip;
    return (result != NO_ERROR);
}

首先会判断命令中有没有指定压缩包,如果没有,会报错。有指定压缩包才会去获取命令中压缩包的名称。如果没有指定要删除的文件,会提示没有指定要删除的文件。否则打开文件,在打开文件成功的情况下,循环遍历要删除的文件,依次把该文件从压缩包中删除,全部操作完成后刷新压缩包中的内容。

以上就是执行aapt remove命令底层所执行的详细代码了,说到底aapt所有的命令最底层都是在操作文件。只是google把这些复杂的文件操作封装成命令工具供我们使用。这里的源码分析也只是看一个大体的结构,更具体的实现,各位小伙伴有兴趣可以去翻一翻源码啊,不一定要精通c++,有一点基础或者其它语言基础也能看得懂大概的流程,除非涉及到修改然后重新编译成定制化的aapt命令,那又是另外一回事了。

好了,关于aapt命令就写到这里了,知道怎么用,底层大概是怎么实现的就可以了,有深厚c++功底的同学可以自己尝试改一改。文章中若是有不足或错误,看到的小伙伴可以在底下留言告诉我,小菜鸟不胜感激~

  • 7
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
Android Gradle是一种用于构建和管理Android应用程序项目的工具。它的一个重要功能就是打包和编译资源。 在Android Gradle中,资源是指应用程序使用的各种非代码文件,如图像、布局文件、字符串、样式等。这些资源文件需要经过打包和编译的过程才能被应用程序使用。 打包资源的过程是指将应用程序使用的所有资源文件收集起来,打包成一个或多个二进制资源文件(.arsc),以方便应用程序在运行时访问。这个过程由Gradle的打包任务完成。打包后的资源文件会被放置在应用程序的res目录下的各个res-qualified目录中。 编译资源的过程是指将资源文件从其源文件形式编译成二进制格式,以便应用程序可以在运行时使用它们。这个过程由Android资源编译器(AAPT)完成。编译资源的过程包括对资源文件进行验证、解析和优化,最终生成资源文件的二进制表示形式。 要在Android Gradle项目中进行资源打包和编译,我们需要在项目的build.gradle文件中配置相应的构建规则和参数。其中,可以通过设置资源文件的名称、路径、扩展名等属性来指定要打包和编译的资源文件。此外,还可以通过配置资源的qualifier(如屏幕密度、语言等)来实现对不同设备和语言环境的资源适配。 通过Android Gradle的打包和编译资源功能,我们可以方便地管理和使用应用程序的各种资源文件,使应用程序在不同设备和语言环境下都能正确地加载和显示相应的资源。这对于开发多语言和多平台的应用程序来说,是非常重要的。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值