【Android 修炼手册】Gradle 篇 -- Android Gradle Plugin 主要 Task 分析(2)

Task对应实现类作用
preBuild空 task,只做锚点使用
preDebugBuild空 task,只做锚点使用,与 preBuild 区别是这个 task 是 variant 的锚点
compileDebugAidlAidlCompile处理 aidl
compileDebugRenderscriptRenderscriptCompile处理 renderscript
checkDebugManifestCheckManifest检测 manifest 是否存在
generateDebugBuildConfigGenerateBuildConfig生成 BuildConfig.java
prepareLintJarPrepareLintJar拷贝 lint jar 包到指定位置
generateDebugResValuesGenerateResValues生成 resvalues,generated.xml
generateDebugResources空 task,锚点
mergeDebugResourcesMergeResources合并资源文件
createDebugCompatibleScreenManifestsCompatibleScreensManifestmanifest 文件中生成 compatible-screens,指定屏幕适配
processDebugManifestMergeManifests合并 manifest 文件
splitsDiscoveryTaskDebugSplitsDiscovery生成 split-list.json,用于 apk 分包
processDebugResourcesProcessAndroidResourcesaapt 打包资源
generateDebugSources空 task,锚点
javaPreCompileDebugJavaPreCompileTask生成 annotationProcessors.json 文件
compileDebugJavaWithJavacAndroidJavaCompile编译 java 文件
compileDebugNdkNdkCompile编译 ndk
compileDebugSources空 task,锚点使用
mergeDebugShadersMergeSourceSetFolders合并 shader 文件
compileDebugShadersShaderCompile编译 shaders
generateDebugAssets空 task,锚点
mergeDebugAssetsMergeSourceSetFolders合并 assets 文件
transformClassesWithDexBuilderForDebugDexArchiveBuilderTransformclass 打包 dex
transformDexArchiveWithExternalLibsDexMergerForDebugExternalLibsMergerTransform打包三方库的 dex,在 dex 增量的时候就不需要再 merge 了,节省时间
transformDexArchiveWithDexMergerForDebugDexMergerTransform打包最终的 dex
mergeDebugJniLibFoldersMergeSouceSetFolders合并 jni lib 文件
transformNativeLibsWithMergeJniLibsForDebugMergeJavaResourcesTransform合并 jnilibs
transformNativeLibsWithStripDebugSymbolForDebugStripDebugSymbolTransform去掉 native lib 里的 debug 符号
processDebugJavaResProcessJavaResConfigAction处理 java res
transformResourcesWithMergeJavaResForDebugMergeJavaResourcesTransform合并 java res
validateSigningDebugValidateSigningTask验证签名
packageDebugPackageApplication打包 apk
assembleDebug空 task,锚点

三、如何去读 Task 的代码

在 gradle plugin 中的 Task 主要有三种,一种是普通的 task,一种是增量 task,一种是 transform,下面分别看下这三种 task 怎么去读。

如何读 Task 的代码
  1. 看 Task 继承的父类,一般来说,会继承 DefaultTask,IncrementalTask
  2. 看 @TaskAction 注解的方法,此方法就是这个 Task 做的事情
如何读 IncrementalTask

我们先看看下这个类,这个类表示的是增量 Task,什么是增量呢?是相对于 全量来说的,全量我们可以理解为调用 clean 以后第一次编译的过程,这个就是全量编译,之后修改了代码或者资源文件,再次编译,就是增量编译。
其中比较重要的几个方法如下:

public abstract class IncrementalTask extends BaseTask {
// …
@Internal
protected boolean isIncremental() {
// 是否需要增量,默认是 false
return false;
}

// 需要子类实现,全量的时候执行的任务
protected abstract void doFullTaskAction() throws Exception;

// 增量的时候执行的任务,默认是什么都不执行,参数是增量的时候修改过的文件
protected void doIncrementalTaskAction(Map<File, FileStatus> changedInputs) throws Exception {
}

@TaskAction
void taskAction(IncrementalTaskInputs inputs) throws Exception {
// 判断是否是增量
if(this.isIncremental() && inputs.isIncremental()) {
this.doIncrementalTaskAction(this.getChangedInputs(inputs));
} else {
this.getProject().getLogger().info(“Unable do incremental execution: full task run”);
this.doFullTaskAction();
}
}

// 获取修改的文件
private Map<File, FileStatus> getChangedInputs(IncrementalTaskInputs inputs) {
Map<File, FileStatus> changedInputs = Maps.newHashMap();
inputs.outOfDate((change) -> {
FileStatus status = change.isAdded()?FileStatus.NEW:FileStatus.CHANGED;
changedInputs.put(change.getFile(), status);
});
inputs.removed((change) -> {
FileStatus var10000 = (FileStatus)changedInputs.put(change.getFile(), FileStatus.REMOVED);
});
return changedInputs;
}
}

简单介绍了 IncrementalTask 之后,我们这里强调一下,如何去读一个 增量 Task 的代码,主要有四步:

  1. 首先这个 Task 要继承 IncrementalTask,
  2. 其次看 isIncremental 方法,如果返回 true,说明支持增量,返回 false 则不支持
  3. 然后看 doFullTaskAction 方法,是全量的时候执行的操作
  4. 最后看 doIncrementalTaskAction 方法,这里是增量的时候执行的操作
如何读 Transform
  1. 继承自 Transform
  2. 看其 transform 方法的实现

四、重点 Task 实现分析

上面每个 task 已经简单说明了具体做什么以及对应的实现类,下面选了几个比较重要的来分析一下其实现
为什么分析这几个呢?这几个代表了 gradle 自动生成代码,资源的处理,以及 dex 的处理,算是 apk 打包过程中比较重要的几环。
generateDebugBuildConfig
processDebugManifest
mergeDebugResources
processDebugResources
transformClassesWithDexBuilderForDebug
transformDexArchiveWithExternalLibsDexMergerForDebug
transformDexArchiveWithDexMergerForDebug

分析过程主要下面几个步骤,实现类,整体实现图,调用链路(方便以后回看代码),以及重要代码分析

4.1 generateDebugBuildConfig
4.1.1 实现类

GenerateBuildConfig

4.1.2 整体实现图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.1.3 代码调用链路

GenerateBuildConfig.generate -> BuildConfigGenerator.generate -> JavaWriter

4.1.4 主要代码分析

在 GenerateBuildConfig 中,主要生成代码的步骤如下:

  1. 生成 BuildConfigGenerator
  2. 添加默认的属性,包括 DEBUG,APPLICATION_ID,FLAVOR,VERSION_CODE,VERSION_NAME
  3. 添加自定义属性
  4. 调用 JavaWriter 生成 BuildConfig.java 文件

// GenerateBuildConfig.generate()
@TaskAction
void generate() throws IOException {
// …
BuildConfigGenerator generator = new BuildConfigGenerator(
getSourceOutputDir(),
getBuildConfigPackageName());
// 添加默认的属性,包括 DEBUG,APPLICATION_ID,FLAVOR,VERSION_CODE,VERSION_NAME
generator
.addField(
“boolean”,
“DEBUG”,
isDebuggable() ? “Boolean.parseBoolean(“true”)” : “false”)
.addField(“String”, “APPLICATION_ID”, ‘"’ + appPackageName.get() + ‘"’)
.addField(“String”, “BUILD_TYPE”, ‘"’ + getBuildTypeName() + ‘"’)
.addField(“String”, “FLAVOR”, ‘"’ + getFlavorName() + ‘"’)
.addField(“int”, “VERSION_CODE”, Integer.toString(getVersionCode()))
.addField(
“String”, “VERSION_NAME”, ‘"’ + Strings.nullToEmpty(getVersionName()) + ‘"’)
.addItems(getItems()); // 添加自定义属性

List flavors = getFlavorNamesWithDimensionNames();
int count = flavors.size();
if (count > 1) {
for (int i = 0; i < count; i += 2) {
generator.addField(
“String”, “FLAVOR_” + flavors.get(i + 1), ‘"’ + flavors.get(i) + ‘"’);
}
}

// 内部调用 JavaWriter 生成 java 文件
generator.generate();
}

4.2 mergeDebugResources
4.2.1 实现类

MergeResources

4.2.2 整体实现图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.2.3 调用链路

MergeResources.doFullTaskAction -> ResourceMerger.mergeData -> MergedResourceWriter.end -> QueueableAapt2.compile -> Aapt2QueuedResourceProcessor.compile -> AaptProcess.compile -> AaptV2CommandBuilder.makeCompile

4.2.4 主要代码分析

MergeResources 这个类,继承自 IncrementalTask,按照前面说的阅读增量 Task 代码的步骤,依次看三个方法的实现:isIncremental,doFullTaskAction,doIncrementalTaskAction

  • isIncremental

// 说明 Task 支持增量
protected boolean isIncremental() {
return true;
}

  • doFullTaskAction
  1. 通过 getConfiguredResourceSets() 获取 resourceSets,包括了自己的 res/ 和 依赖库的 res/ 以及 build/generated/res/rs

// MergeResources.doFullTaskAction()
List resourceSets = getConfiguredResourceSets(preprocessor);

  1. 创建 ResourceMerger

// MergeResources.doFullTaskAction()
ResourceMerger merger = new ResourceMerger(minSdk);

  1. 创建 QueueableResourceCompiler,因为 gradle3.x 以后支持了 aapt2,所以这里有两种选择 aapt 和 aapt2。其中 aapt2 有三种模式,OutOfProcessAaptV2,AaptV2Jni,QueueableAapt2,这里默认创建了 QueueableAapt2,resourceCompiler = QueueableAapt2

// MergeResources.doFullTaskAction()
// makeAapt 中会判断使用 aapt 还是 aapt2,这里以 aapt2 为例,返回的是 QueueableAapt2 对象
QueueableResourceCompiler resourceCompiler =
makeAapt(
aaptGeneration,
getBuilder(),
fileCache,
crunchPng,
variantScope,
getAaptTempDir(),
mergingLog)

  1. 将第一步获取的 resourceSet 加入 ResourceMerger 中

for (ResourceSet resourceSet : resourceSets) {
resourceSet.loadFromFiles(getILogger());
merger.addDataSet(resourceSet);
}

  1. 创建 MergedResourceWriter
  2. 调用 ResourceMerger.mergeData 合并资源

// MergeResources.doFullTaskAction()
merger.mergeData(writer, false /doCleanUp/);

  1. 调用 MergedResourceWriter 的 start(),addItem(),end() 方法,伪代码如下:

// DataMerger.mergeData
consumer.start()
for item in sourceSets:
// item 包括了需要处理的资源,包括 xml 和 图片资源,每一个 item 对应的文件,会创建一个 CompileResourceRequest 对象,加入到 mCompileResourceRequests 里
consumer.addItem(item)
consumer.end()

  1. 调用 QueueableAapt2 -> Aapt2QueuedResourceProcessor -> AaptProcess 处理资源

// MergedResourceWriter.end()
Future result = this.mResourceCompiler.compile(new CompileResourceRequest(fileToCompile, request.getOutput(), request.getFolderName(), this.pseudoLocalesEnabled, this.crunchPng));
// AaptProcess.compile
public void compile(
@NonNull CompileResourceRequest request,
@NonNull Job job,
@Nullable ProcessOutputHandler processOutputHandler)
throws IOException {
// …
// 使用 AaptV2CommandBuilder 生成 aapt2 命令
mWriter.write(joiner.join(AaptV2CommandBuilder.makeCompile(request)));
mWriter.flush(); // 输出命令
}

这一步调用 aapt2 命令去处理资源,处理完以后 xxx.xml.flat 格式

  • doIncrementalTaskAction
    增量任务过程和全量其实差异不大,只不过是在获取 resourceSets 的时候,使用的是修改后的文件
4.3 processDebugResources
4.3.1 实现类

ProcessAndroidResources

4.3.2 整体实现图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.3.3 调用链路

ProcessAndroidResources.doFullTaskAction -> ProcessAndroidResources.invokeAaptForSplit -> AndroidBuilder.processResources -> QueueAapt2.link -> Aapt2QueuedResourceProcessor.link -> AaptProcess.link -> AaptV2CommandBuilder.makeLink

4.3.4 主要代码分析

ProcessAndroidResources 也是继承自 IncrementalTask,但是没有重写 isIncremental,所以不是增量的 Task,直接看 doFullTaskAction 即可

  • doFullTaskAction
    这个里面代码虽然多,但是主要的逻辑比较简单,就是调用 aapt2 link 去生成资源包。
    这里会处理 splits apk 相关的内容,关于 splits apk 具体可以查看 splits apk,简单来说,就是可以按照屏幕分辨率,abis 来生成不同的 apk,从而让特定用户的安装包变小。
    分下面几个步骤:
  1. 获取 split 数据

List splitsToGenerate =
getApksToGenerate(outputScope, supportedAbis, buildTargetAbi, buildTargetDensity);

返回的是一个 ApkData 列表,ApkData 有三个子类,分别是 Main,Universal,FullSplit
我们配置 如下:

android {
splits {
// Configures multiple APKs based on screen density.
density {
// Configures multiple APKs based on screen density.
enable true
// Specifies a list of screen densities Gradle should not create multiple APKs for.
exclude “ldpi”, “xxhdpi”, “xxxhdpi”
// Specifies a list of compatible screen size settings for the manifest.
compatibleScreens ‘small’, ‘normal’, ‘large’, ‘xlarge’
}
}
}

这里的 ApkData 会返回一个 Universal 和多个 FullSplit,Universal 代表的是主 apk,FullSplit 就是根据屏幕密度拆分的 apk。
如果我们没有配置 splits apk,那么这里只会返回一个 Main 的实例,标识完整的 apk。
2. 先处理 main 和 不依赖 density 的 ApkData 资源

// ProcessAndroidResources.doFullTaskAction
List apkDataList = new ArrayList<>(splitsToGenerate);
for (ApkData apkData : splitsToGenerate) {
if (apkData.requiresAapt()) {
// 这里只处理 main 和不依赖 density 的资源
boolean codeGen =
(apkData.getType() == OutputFile.OutputType.MAIN
|| apkData.getFilter(OutputFile.FilterType.DENSITY) == null);
if (codeGen) {
apkDataList.remove(apkData);
invokeAaptForSplit(
manifestsOutputs,
libraryInfoList,
packageIdFileSet,
splitList,
featureResourcePackages,
apkData,
codeGen,
aapt);
break;
}
}
}

  1. 调用 invokeAaptForSplit 处理资源

// ProcessAndroidResources.invokeAaptForSplit
void invokeAaptForSplit(…) {
// …
String packageForR = null;
File srcOut = null;
File symbolOutputDir = null;
File proguardOutputFile = null;
File mainDexListProguardOutputFile = null;
// 如果传了 generateCode 参数,会生成 R.java
if (generateCode) {
packageForR = originalApplicationId;

// we have to clean the source folder output in case the package name changed.
srcOut = getSourceOutputDir();
if (srcOut != null) {
FileUtils.cleanOutputDir(srcOut);
}

symbolOutputDir = textSymbolOutputDir.get();
proguardOutputFile = getProguardOutputFile();
mainDexListProguardOutputFile = getMainDexListProguardOutputFile();
}
// …
getBuilder().processResources(aapt, config);
}

  1. 调用 AndroidBuilder.processResources -> QueueAapt2.link -> Aapt2QueuedResourceProcessor.link -> AaptProcess.link -> AaptV2CommandBuilder.makeLink 处理资源,生成资源包以及 R.java 文件
  2. 处理其他 ApkData 资源,这里只会生成资源包而不会生成 R.java 文件

关于 aapt2 的 compile 和 link 参数,可以在 developer.android.com/studio/comm… 这里看

4.4 processDebugManifest
4.4.1 实现类

MergeManifests

4.4.2 整体实现图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.4.3 调用链路

MergeManifests.dofFullTaskAction -> AndroidBuilder.mergeManifestsForApplication -> Invoker.merge -> ManifestMerge2.merge

4.4.4 主要代码分析

MergeManifests 也是继承了 IncrementalTask,但是没有实现 isIncremental,所以只看其 doFullTaskAction 即可。
这个 task 功能主要是合并 mainfest,包括 module 和 flavor 里的,整个过程通过 MergingReport,ManifestMerger2 和 XmlDocument 进行。
这里直接看 ManifestMerger2.merge() 的 merge 过程 。 主要有几个步骤:

  1. 获取依赖库的 manifest 信息,用 LoadedManifestInfo 标识
  2. 获取主 module 的 manifest 信息
  3. 替换主 module 的 Manifest 中定义的某些属性,替换成 gradle 中定义的属性 例如: package, version_code, version_name, min_sdk_versin 等等

performSystemPropertiesInjection(mergingReportBuilder, xmlDocumentOptional.get());
// ManifestMerger2.performSystemPropertiesInjection
protected void performSystemPropertiesInjection(
@NonNull MergingReport.Builder mergingReport,
@NonNull XmlDocument xmlDocument) {
for (ManifestSystemProperty manifestSystemProperty : ManifestSystemProperty.values()) {
String propertyOverride = mSystemPropertyResolver.getValue(manifestSystemProperty);
if (propertyOverride != null) {
manifestSystemProperty.addTo(
mergingReport.getActionRecorder(), xmlDocument, propertyOverride);
}
}
}

  1. 合并 flavor,buildType 中的 manifest

for (File inputFile : mFlavorsAndBuildTypeFiles) {
LoadedManifestInfo overlayDocument = load(
new ManifestInfo(null, inputFile, XmlDocument.Type.OVERLAY,
Optional.of(mainPackageAttribute.get().getValue())),
selectors,
mergingReportBuilder);

// 检查 package 定义
Optional packageAttribute =
overlayDocument.getXmlDocument().getPackage();
if (loadedMainManifestInfo.getOriginalPackageName().isPresent() &&
packageAttribute.isPresent()
&& !loadedMainManifestInfo.getOriginalPackageName().get().equals(
packageAttribute.get().getValue())) {
// 如果 package 定义重复的话,会输出下面信息,我们平时应该或多或少见过类似的错误
String message = mMergeType == MergeType.APPLICATION
? String.format(
“Overlay manifest:package attribute declared at %1 s v a l u e = ( s value=(%2 svalue=(s)\n”

  • "\thas a different value=(%3$s) "
  • “declared in main manifest at %4$s\n”
  • "\tSuggestion: remove the overlay declaration at %5$s "
  • “\tand place it in the build.gradle:\n”
  • “\t\tflavorName {\n”
  • “\t\t\tapplicationId = “%2$s”\n”
  • “\t\t}”,
    packageAttribute.get().printPosition(),
    packageAttribute.get().getValue(),
    mainPackageAttribute.get().getValue(),
    mainPackageAttribute.get().printPosition(),
    packageAttribute.get().getSourceFile().print(true))
    : String.format(
    “Overlay manifest:package attribute declared at %1 s v a l u e = ( s value=(%2 svalue=(s)\n”
  • "\thas a different value=(%3$s) "
  • “declared in main manifest at %4$s”,
    packageAttribute.get().printPosition(),
    packageAttribute.get().getValue(),
    mainPackageAttribute.get().getValue(),
    mainPackageAttribute.get().printPosition());
    // …
    return mergingReportBuilder.build();
    }
    }
  1. 合并依赖库的 manifest

for (LoadedManifestInfo libraryDocument : loadedLibraryDocuments) {
mLogger.verbose("Merging library manifest " + libraryDocument.getLocation());
xmlDocumentOptional = merge(
xmlDocumentOptional, libraryDocument, mergingReportBuilder);
if (!xmlDocumentOptional.isPresent()) {
return mergingReportBuilder.build();
}
}

  1. 处理 manifest 的 placeholders

performPlaceHolderSubstitution(loadedMainManifestInfo, xmlDocumentOptional.get(), mergingReportBuilder, severity);

  1. 之后对最终合并后的 manifest 中的一些属性重新进行一次替换,类似步骤 4
  2. 保存 manifest 到 build/intermediates/manifest/fullxxx/AndroidManifest.xml 这就生成了最终的 Manifest 文件

Android核心知识点

面试成功其实是必然的,因为我做足了充分的准备工作,包括刷题啊,看一些Android核心的知识点,看一些面试的博客吸取大家面试的一些经验。

下面这份PDF是我翻阅了差不多3个月左右一些Android大博主的博客从他们那里取其精华去其糟泊所整理出来的一些Android的核心知识点,全部都是精华中的精华,我能面试到现在2-2资深开发人员跟我整理的这本Android核心知识点有密不可分的关系,在这里本着共赢的心态分享给各位朋友。

不管是Android基础还是Java基础以及常见的数据结构,这些是无原则地必须要熟练掌握的,尤其是非计算机专业的同学,面试官一上来肯定是问你基础,要是基础表现不好很容易被扣上基础不扎实的帽子,常见的就那些,只要你平时认真思考过基本上面试是没太大问题的。

最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上我搜集整理的2019-2021BAT 面试真题解析,我把大厂面试中常被问到的技术点整理成了PDF,包知识脉络 + 诸多细节。

节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
华去其糟泊所整理出来的一些Android的核心知识点,全部都是精华中的精华,我能面试到现在2-2资深开发人员跟我整理的这本Android核心知识点有密不可分的关系,在这里本着共赢的心态分享给各位朋友。

[外链图片转存中…(img-8QVpH7Dc-1715021391892)]

不管是Android基础还是Java基础以及常见的数据结构,这些是无原则地必须要熟练掌握的,尤其是非计算机专业的同学,面试官一上来肯定是问你基础,要是基础表现不好很容易被扣上基础不扎实的帽子,常见的就那些,只要你平时认真思考过基本上面试是没太大问题的。

最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上我搜集整理的2019-2021BAT 面试真题解析,我把大厂面试中常被问到的技术点整理成了PDF,包知识脉络 + 诸多细节。

节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

  • 23
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要修改Android项目中的Gradle配置,可以按照以下步骤进行操作: 1. 打开Android Studio,并打开你的项目。 2. 在项目结构中,选择File -> Project Structure。 3. 在Project选项卡下,可以指定Gradle的版本。你可以选择已经安装的Gradle版本,或者通过指定Gradle的分布URL来下载指定版本的Gradle。\[2\] 4. 如果你选择使用Gradle wrapper,你可以在项目的gradle/wrapper/gradle-wrapper.properties文件中编辑distributionUrl属性来指定Gradle的分布URL。\[2\] 5. 确保你的电脑上已经安装了Java环境,并设置了GRADLE_HOME环境变量,指向Gradle的安装目录。同时,将GRADLE_HOME/bin加入到PATH环境变量中,这样你就可以在任意位置使用gradle命令了。\[3\] 6. 如果你想查看当前项目使用的Gradle版本,可以在命令行中运行gradle -v命令。\[3\] 通过以上步骤,你就可以修改Android项目中的Gradle配置了。 #### 引用[.reference_title] - *1* *3* [android构建工具gradle基础知识](https://blog.csdn.net/honeysx/article/details/123565622)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [Android GradleAndroid Plugin for Gradle、SDK Build Tools](https://blog.csdn.net/qq_38056514/article/details/127255403)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值