深度探索 Gradle 自动化构建技术(四、自定义 Gradle 插件)

可以看到,name 与 baseName 的区别:baiduDebug 与 baidu-debug 。

2)、使用 applicationVariants.all 在配置阶段之后去修改输出的 APK 名称

this.afterEvaluate {
this.android.applicationVariants.all { variant ->
variant.outputs.each {
// 由于我们当前的变体是 application 类型的,所以
// 这个 output 就是我们 APK 文件的输出路径,我们
// 可以通过重命名这个文件来修改我们最终输出的 APK 文件
outputFileName = “app- v a r i a n t . b a s e N a m e − {variant.baseName}- variant.baseName{variant.versionName}.apk”
println outputFileName
}
}
}

执行 gradle clean task,其输出信息如下所示:

Configure project :app
app-debug-1.0.apk
app-release-1.0.apk

3)、对 applicationVariants 中的 Task 进行 Hook

我们可以在 android.applicationVariants.all 的闭包中通过 variant.task 来获取相应的 Task。代码如下所示:

this.afterEvaluate {
this.android.applicationVariants.all { variant ->
def task = variant.checkManifest
println task.name
}
}

然后,执行 gradle clean task,其输出信息如下所示:

checkDebugManifest
checkReleaseManifest

既然可以获取到变体中的 Task,我们就可以根据不同的 Task 类型来做特殊处理。例如,我们可以利用 variants 去解决插件化开发中的痛点:编写一个对插件化项目中的各个插件自动更新的脚本,其核心代码如下所示:

this.afterEvaluate {
this.android.applicationVariants.all { variant ->
// checkManifest 这个 Task 在 Task 容器中
// 靠前的位置,我们可以在这里预先更新插件。
def checkTask = variant.checkManifest
checkTask.doFirst {
def bt = variant.buildType.name
if (bt == ‘qa’ || bt == ‘preview’
|| bt == ‘release’) {
update_plugin(bt)
}
}
}
}

至于 update_plugin 的实现,主要就是一些插件安全校验与下载的逻辑,这部分其实跟 Gradle 没有什么联系,如果有需要,可以在 Awesome-WanAndroid 项目下查看。

五、Transform

众所周知,Google 官方在 Android Gradle V1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包成 .dex 文件之前的编译过程中操作 .class 文件,我们需要做的就是实现 Transform 来对 .class 文件遍历以拿到所有方法,修改完成后再对原文件进行替换即可。

总的来说,Gradle Transform 的功能就是把输入的 .class 文件转换为目标字节码文件。 下面,我们来了解一下 Transform 的两个基础概念。

1、TransformInput

TransformInput 可认为是所有输入文件的一个抽象,它主要包括两个部分,如下所示:

  • 1)、DirectoryInput 集合:表示以源码方式参与项目编译的所有目录结构与其目录下的源码文件。
  • 2)、JarInput 集合:表示以 jar 包方式参与项目编译的所有本地 jar 包和远程 jar 包。需要注意的是,这个 jar 所指也包括 aar。

2、TransformOutputProvider

表示 Transform 的输出,利用它我们可以获取输出路径等信息。

3、实现 Transform

1、首先,配置 Android DSL 相关的依赖:

// 由于 buildSrc 的执行时机要早于任何一个 project,因此需要⾃⼰添加仓库
repositories {
google()
jcenter() 
}

dependencies {
// Android DSL
implementation ‘com.android.tools.build:gradle:3.6.2’
}

2、然后,继承 com.android.build.api.transform.Transform ,创建⼀个 Transform 的子类

其创建步骤可以细分为五步,如下所示:

  • 1)、重写 getName 方法:返回对应的 Task 名称。
  • 2)、重写 getInputTypes 方法:确定对那些类型的结果进行转换。
  • 3)、重写 getScopes 方法:指定插件的适用范围。
  • 4)、重写 isIncremental 方法:表示是否支持增量更新。
  • 5)、重写 transform 方法:进行具体的转换过程。

下面👇,我们来分别来进行详细讲解。

1)、重写 getName 方法:返回对应的 Task 名称

每一个 Transform 都有一个与之对应的 Transform task,这里便是返回的 task name。它会出现在 app/build/intermediates/transforms 目录下。其代码如下所示:

/**
* 每一个 Transform 都有一个与之对应的 Transform task,
* 这里便是返回的 task name。它会出现在
* app/build/intermediates/transforms 目录下
*
* @return Transform Name
*/
@Override
String getName() {
return “MyCustomTransform”
}

2)、重写 getInputTypes 方法:确定对那些类型的结果进行转换

getInputTypes 方法用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。目前 ContentType 有六种枚举类型,通常我们使用比较频繁的有前两种,如下所示:

  • 1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
  • 2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
  • 3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
  • 4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
  • 5、CONTENT_DEX:表示需要处理 DEX 文件。
  • 6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。

因为我们需要修改的是字节码,所以直接返回 TransformManager.CONTENT_CLASS 即可,代码如下所示:

/**
* 需要处理的数据类型,目前 ContentType
* 有六种枚举类型,通常我们使用比较频繁的有前两种:
*      1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
*      2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
*      3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
*      4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
*      5、CONTENT_DEX:表示需要处理 DEX 文件。
*      6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。 
*
* @return
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
// 用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。
// return TransformManager.RESOURCES
return TransformManager.CONTENT_CLASS
}

3)、重写 getScopes 方法:指定插件的适用范围

getScopes 方法则是用于确定插件的适用范围:目前 Scope 有 五种基本类型,如下所示:

  • 1、PROJECT:只有项目内容。
  • 2、SUB_PROJECTS:只有子项目。
  • 3、EXTERNAL_LIBRARIES:只有外部库,
  • 4、TESTED_CODE:由当前变体(包括依赖项)所测试的代码。
  • 5、PROVIDED_ONLY:只提供本地或远程依赖项。

此外,还有一些复合类型,它们是都是由这五种基本类型组成,以实现灵活确定自定义插件的范围,这里通常是指定整个 project,也可以指定其它范围,其代码如下所示:

/**
* 表示 Transform 要操作的内容范围,目前 Scope 有五种基本类型:
*      1、PROJECT                   只有项目内容
*      2、SUB_PROJECTS              只有子项目
*      3、EXTERNAL_LIBRARIES        只有外部库
*      4、TESTED_CODE               由当前变体(包括依赖项)所测试的代码
*      5、PROVIDED_ONLY             只提供本地或远程依赖项
*      SCOPE_FULL_PROJECT 是一个 Scope 集合,包含 Scope.PROJECT,
Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES 这三项,即当前 Transform
的作用域包括当前项目、子项目以及外部的依赖库
*
* @return
*/
@Override
Set<? super QualifiedContent.Scope> getScopes() {
// 适用范围:通常是指定整个 project,也可以指定其它范围
return TransformManager.SCOPE_FULL_PROJECT
}

4)、重写 isIncremental 方法:表示是否支持增量更新

isIncremental 方法用于确定是否支持增量更新,如果返回 true,TransformInput 会包含一份修改的文件列表,如果返回 false,则会进行全量编译,并且会删除上一次的输出内容。

@Override
boolean isIncremental() {
// 是否支持增量更新
// 如果返回 true,TransformInput 会包含一份修改的文件列表
// 如果返回 false,会进行全量编译,删除上一次的输出内容
return false
}

5)、重写 transform 方法:进行具体的转换过程

在 transform 方法中,就是用来给我们进行具体的转换过程的。其实现代码如下所示:

/**
* 进行具体的转换过程
*
* @param transformInvocation
*/
@Override
void transform(TransformInvocation transformInvocation) throws
TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
println '--------------- MyTransform visit start --------------- ’
def startTime = System.currentTimeMillis()
def inputs = transformInvocation.inputs
def outputProvider = transformInvocation.outputProvider
// 1、删除之前的输出
if (outputProvider != null)
outputProvider.deleteAll()
// Transform 的 inputs 有两种类型,一种是目录,一种是 jar
包,要分开遍历
inputs.each { TransformInput input ->
// 2、遍历 directoryInputs(本地 project 编译成的多个 class
⽂件存放的目录)
input.directoryInputs.each { DirectoryInput directoryInput ->
handleDirectory(directoryInput, outputProvider)
}
// 3、遍历 jarInputs(各个依赖所编译成的 jar 文件)
input.jarInputs.each { JarInput jarInput ->
handleJar(jarInput, outputProvider)
}
}
def cost = (System.currentTimeMillis() - startTime) / 1000
println '--------------- MyTransform visit end --------------- ’
println “MyTransform cost : $cost s”
}

这里我们主要是做了三步处理,如下所示:

  • 1)、删除之前的输出。
  • 2)、遍历 directoryInputs(本地 project 编译成的多个 class ⽂件存放的目录)。
  • 3)、遍历 jarInputs(各个依赖所编译成的 jar 文件)。

在 handleDirectory 与 handleJar 方法中则是进行了相应的 文件处理 && ASM 字节码修改。这里我直接放出 Transform 的通用模板代码,代码如下所示:

class MyTransform extends Transform {

/**
* 每一个 Transform 都有一个与之对应的 Transform task,
* 这里便是返回的 task name。它会出现在 app/build/intermediates/transforms 目录下
*
* @return Transform Name
*/
@Override
String getName() {
return “MyCustomTransform”
}

/**
* 需要处理的数据类型,目前 ContentType 有六种枚举类型,通常我们使用比较频繁的有前两种:
*      1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
*      2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
*      3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
*      4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
*      5、CONTENT_DEX:表示需要处理 DEX 文件。
*      6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。
*
* @return
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
// 用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。
// return TransformManager.RESOURCES
return TransformManager.CONTENT_CLASS
}

/**
* 表示 Transform 要操作的内容范围,目前 Scope 有五种基本类型:
*      1、PROJECT                   只有项目内容
*      2、SUB_PROJECTS              只有子项目
*      3、EXTERNAL_LIBRARIES        只有外部库
*      4、TESTED_CODE               由当前变体(包括依赖项)所测试的代码
*      5、PROVIDED_ONLY             只提供本地或远程依赖项
*      SCOPE_FULL_PROJECT 是一个 Scope 集合,包含 Scope.PROJECT, Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES 这三项,即当前 Transform 的作用域包括当前项目、子项目以及外部的依赖库
*
* @return
*/
@Override
Set<? super QualifiedContent.Scope> getScopes() {
// 适用范围:通常是指定整个 project,也可以指定其它范围
return TransformManager.SCOPE_FULL_PROJECT
}

@Override
boolean isIncremental() {
// 是否支持增量更新
// 如果返回 true,TransformInput 会包含一份修改的文件列表
// 如果返回 false,会进行全量编译,删除上一次的输出内容
return false
}

/**
* 进行具体的转换过程
*
* @param transformInvocation
*/
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
println '--------------- MyTransform visit start --------------- ’
def startTime = System.currentTimeMillis()
def inputs = transformInvocation.inputs
def outputProvider = transformInvocation.outputProvider
// 删除之前的输出
if (outputProvider != null)
outputProvider.deleteAll()

// Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
inputs.each { TransformInput input ->
// 遍历 directoryInputs(本地 project 编译成的多个 class ⽂件存放的目录)
input.directoryInputs.each { DirectoryInput directoryInput ->
handleDirectory(directoryInput, outputProvider)
}

// 遍历 jarInputs(各个依赖所编译成的 jar 文件)
input.jarInputs.each { JarInput jarInput ->
handleJar(jarInput, outputProvider)
}
}

def cost = (System.currentTimeMillis() - startTime) / 1000
println '--------------- MyTransform visit end --------------- ’
println “MyTransform cost : $cost s”
}

static void handleJar(JarInput jarInput, TransformOutputProvider outputProvider) {
if (jarInput.file.getAbsolutePath().endsWith(“.jar”)) {
// 截取文件路径的 md5 值重命名输出文件,避免同名导致覆盖的情况出现
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(“.jar”)) {
jarName = jarName.substring(0, jarName.length() - 4)
}
JarFile jarFile = new JarFile(jarInput.file)
Enumeration enumeration = jarFile.entries()
File tmpFile = new File(jarInput.file.getParent() + File.separator + “classes_temp.jar”)
// 避免上次的缓存被重复插入
if (tmpFile.exists()) {
tmpFile.delete()
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.getName()
ZipEntry zipEntry = new ZipEntry(entryName)
InputStream inputStream = jarFile.getInputStream(jarEntry)
if (checkClassFile(entryName)) {
// 使用 ASM 对 class 文件进行操控
println ‘----------- deal with “jar” class file <’ + entryName + ‘> -----------’
jarOutputStream.putNextEntry(zipEntry)
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, org.objectweb.asm.ClassWriter.COMPUTE_MAXS)
ClassVisitor cv = new MyCustomClassVisitor(classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
jarOutputStream.write(code)
} else {
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
jarOutputStream.closeEntry()
}
jarOutputStream.close()
jarFile.close()

// 生成输出路径 dest:./app/build/intermediates/transforms/xxxTransform/…
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
// 将 input 的目录复制到 output 指定目录
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}

static void handleDirectory(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
// 在增量模式下可以通过 directoryInput.changedFiles 方法获取修改的文件
//        directoryInput.changedFiles
if (directoryInput.file.size() == 0)
return
if (directoryInput.file.isDirectory()) {
/*遍历以某一扩展名结尾的文件/
directoryInput.file.traverse(type: FileType.FILES, nameFilter: ~/.*.class/) {
File classFile ->
def name = classFile.name
if (checkClassFile(name)) {
println ‘----------- deal with “class” file <’ + name + ‘> -----------’
def classReader = new ClassReader(classFile.bytes)
def classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
def classVisitor = new MyCustomClassVisitor(classWriter)
classReader.accept(classVisitor, EXPAND_FRAMES)
byte[] codeBytes = classWriter.toByteArray()
FileOutputStream fileOutputStream = new FileOutputStream(
classFile.parentFile.absolutePath + File.separator + name
)
fileOutputStream.write(codeBytes)
fileOutputStream.close()
}
}
}
/// 获取 output 目录 dest:./app/build/intermediates/transforms/hencoderTransform/
def destFile = outputProvider.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY
)
// 将 input 的目录复制到 output 指定目录
FileUtils.copyDirectory(directoryInput.file, destFile)
}

/**
* 检查 class 文件是否需要处理
*
* @param fileName
* @return class 文件是否需要处理
*/
static boolean checkClassFile(String name) {
// 只处理需要的 class 文件
return (name.endsWith(“.class”) && !name.startsWith(“R$”)
&& “R.class” != name && “BuildConfig.class” != name
&& “android/support/v4/app/FragmentActivity.class” == name)
}

编写完 Transform 的代码之后,我们就可以 在 CustomGradlePlugin 的 apply 方法中加入下面代码去注册 MyTransform 实例,代码如下所示:

// 注册我们自定义的 Transform
def appExtension = project.extensions.findByType(AppExtension.class)
appExtension.registerTransform(new MyTransform());

上面的自定义 Transform 的代码就是一个标准的 Transorm + ASM 修改字节码的模板代码,在使用时,我们只需要编写我们自己的 MyClassVisitor 类去修改相应的字节码文件即可,关于 ASM 的使用可以参考我前面写的 深入探索编译插桩技术(四、ASM 探秘) 一文。

4、Transform 使用小结

我们可以自定义一个 Gradle Plugin,然后注册一个 Transform 对象,在 tranform 方法里,可以分别遍历目录和 jar 包,然后我们就可以遍历当前应用程序的所有 .class 文件,然后再利用 ASM 框架的 Core API 去加载相应的 .class 文件,并解析,就可以找到满足特定条件的 .class 文件和相关方法,最后去修改相应的方法以实现动态插入相应的字节码。

六、发布 Gradle 插件

发布插件可以分为 两种形式,如下所示:

  • 1)、发布插件到本地仓库。
  • 2)、发布插件到远程仓库。

下面,我们就来使用 mavenDeployer 插件来将插件分别发布在本地仓库和远程仓库。

1、发布插件到本地仓库

引入 maven 插件之后,我们在 uploadArchives 加入想要上传的仓库地址与相关配置即可,这样 Gradle 在执行 uploadArchives 时将生成和上传 pom.xml 文件,将插件上传至本地仓库的示例代码如下所示:

apply plugin: ‘maven’

uploadArchives {
repositories {
mavenDeployer {
// 上传到当前项目根目录下的本地 repo 目录中
repository(url: uri(‘…/repo’))

pom.groupId = ‘com.json.chao.study’
pom.artifactId = ‘custom-gradle-plugin’
pom.version = ‘1.0.0’
}
}
}

可以看到,这里我们将本地仓库路径指定为了根目录下的 repo 文件夹。此外,我们需要配置插件中的一些属性信息,通常包含如下三种:

  • 1)、groupId:组织/公司名称。
  • 2)、artifactId:项目/模块名称。
  • 3)、version:项目/模块的当前版本号。

2、发布插件到远程仓库

apply plugin: ‘maven’

uploadArchives {
configuration = configurations.archives
repositories {
mavenDeployer {
repository(url: MAVEN_REPO_RELEASE_URL) {
authentication(userName: “JsonChao”, password: “123456”)
}

pom.groupId = ‘com.json.chao.study’
pom.artifactId = ‘custom-gradle-plugin’
pom.version = ‘1.0.0’
}
}
}

不同于发布插件到本地仓库的方式,发布插件到远程仓库仅仅是将 repository 中的 url 替换为 远程 maven 仓库的 url,并将需要认证的 userName 与 password 进行配置即可。将插件配置好了之后,我们就可以通过 ./gradlew uploadArchivers 来执行这个 task,实现将插件发布到本地/远程仓库。

七、调试 Gradle 插件

文末

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。

最后想要拿高薪实现技术提升薪水得到质的飞跃。最快捷的方式,就是有人可以带着你一起分析,这样学习起来最为高效,所以为了大家能够顺利进阶中高级、架构师,我特地为大家准备了一套高手学习的源码和框架视频等精品Android架构师教程,保证你学了以后保证薪资上升一个台阶。

当你有了学习线路,学习哪些内容,也知道以后的路怎么走了,理论看多了总要实践的。

进阶学习视频

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

师教程,保证你学了以后保证薪资上升一个台阶。

当你有了学习线路,学习哪些内容,也知道以后的路怎么走了,理论看多了总要实践的。

进阶学习视频

[外链图片转存中…(img-F9xjaQfk-1714664442246)]

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

[外链图片转存中…(img-t4XQthWl-1714664442247)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值