此文主要参考慕课网视频,视频名如标题。同时也加了一些视频中没有的操作,比如javapoet框架的使用。
第一章、构建的基石Gradle
1.Gradle工程结构
定义
Gradle是一个基于Apache和Apache Maven概念的项目自动化构建工具。它使用一种基于Groovy的特定领域语言来声明项目设置,而不是传统的XML。
APK构建流程:
Gradle的安装
有两种安装方式:
①在系统全局安装Gradle
就是常规的安装Gradle,网上教程一大堆,没有什么好提的
②生成Gradle Wrapper【划重点】
有些时候从网上down下来带Gradle项目,自己项目上又没有安装对应版本的Gradle,再去安装就很麻烦(深有同感)。这时候就体现出这种方式的便捷性了。
android项目创建的时候,会帮我们自动生成Gradle Wrapper,也就是.gradlew文件。
Tips:如果没有wrapper,那么可以cd到项目根目录,执行命令gradle wrapper
,就会生成Gradle Wrapper.
Gradle的执行
学习Gradle的入门操作,肯定就是执行Gradle自带的一些命令了:
Gradle命令格式:
./gradlew [task-name...] [-option-name]
下面就介绍几个Gradle自带常用的命令:
清理Build缓存:./gradlew clean
查看所有子工程:./gradlew projects
查看所有任务: ./gradlew tasks
其中,我们用clean命令测试demo的时候,可以用加个option:
./gradlew clean -q
这是就只会输出我们的println信息了,不会有系统信息干扰视线
Gradle升级的两种方式
..
Gradle脚本基础
有三种gradle文件需要了解:
①setting.gradle: 项目包含哪些子工程
②build.gradle:一个是根目录下,应用于所有子项目可以共用的配置;另一块是每个android library里的,是每个子项目的配置信息。
③gradle.properties: 在工程根目录下,配置一些开关型参数。
Gradle生命周期
1.初始化阶段
gradle支持单个工程或多个工程的编译。
两件事:
** ①判断需要参与编译的子工程,为这些子工程创建一个Project对象。
②创建Settings对象,并在其上执行settings.gradle脚本,建立工程之间的层次结构 **
2.配置阶段
两件事:
①会在每个Project对象上执行对应的build.gradle文件,完成对Project的配置。
⑥并且根据项目的配置,构建出一个任务关系依赖图,为执行阶段做准备。
3.执行阶段
就一件事:
** 判断哪些task需要执行,并执行对应的task **
此外,声明周期的监听实现如下:
//## gradle生命周期回调
gradle.addBuildListener(new BuildAdapter() {
@Override
void settingsEvaluated(Settings settings) {
super.settingsEvaluated(settings)
println("[life-cycle] 初始化阶段完成")
}
@Override
void projectsEvaluated(Gradle gradle) {
super.projectsEvaluated(gradle)
println("[life-cycle] 配置阶段完成")
}
@Override
void buildFinished(BuildResult result) {
super.buildFinished(result)
println("[life-cycle] 构建结束")
}
})
gradle中几个主要角色
主要有三个角色:
初始化阶段 - root project
配置阶段 - project
执行阶段 - task
Task
Gradle 的构建工作都是由一系列 Task 组合完成的。一个 Project 里面可以包含很多个 Task 。Task 可以理解为一个执行体,在 Project 的视角下,也可以看作是一个原子性的操作。
gradle中核心的工作单元就是一个又一个的task.创建Task有多种方式:
//创建方式一: 任务名字 方式创建Task
def customTask0 = task('customTask0')
customTask0.doLast {
println("通过任务名字方式创建Task")
}
//创建方式二: 任务名字 + 一个配置Map 创建Task
def customTask1 = task(group: 'RyeDemoTasks', 'customTask1')
customTask1.doLast {
println("通过 任务名字 + 一个配置Map 创建Task")
}
//创建方式三: 任务名 + 闭包
task customTask2(group: 'RyeDemoTasks', description: '任务名+闭包方式创建任务') {
println('带闭包的创建task')
}
//创建方式四: 通过TaskContainer创建任务
tasks.create('customTask3') {
group 'RyeDemoTask'
description '通过TaskContainer创建任务'
doLast {
println('hello~')
}
}
第三章 页面路由开发实战Gradle插件【路由框架】
Gradle插件
Gradle插件主要分为两种类型:
1.二进制插件
二进制插件就是实现了 org.gradle.api.Plugin 接口的插件,每个 Java Gradle 插件都有一个 plugin id,可以通过如下方式使用一个 Java 插件:
apply plugin : 'java'
其中 java 是 Java 插件的 plugin id,对于 Gradle 自带的核心插件都有唯一的 plugin id.对外会搞成一个jar包。
2.脚本插件
脚本插件的使用实际上就是某个脚本文件的使用,使用脚本插件时将脚本加载进来就可以了,使用脚本插件要使用到关键字 from,后面的脚本文件可以是本地的也可以是网络上的脚本文件。脚本插件更轻量。
两种插件使用的简单介绍:
自定义二进制插件
常规流程,三步走:
① 声明插件ID与版本号
② 在具体的子工程中应用插件 : apply plugin : 'pluginName'
③ 配置插件
自定义脚本插件
①创建gradle文件
②在子工程中(或者根工程中)引用此gradle文件:
apply from: 'xxxx.gradle'
ex:
顺带说一下这个apply()方法:
此方法在
PluginAware
中声明:可以接收三种不同类型的参数:
//闭包作为参数
void apply(Closure closure)
//配置一个ObjectConfigurationAction
void apply(Action<? super ObjectConfigurationAction> action);
//Map作为参数
void apply(Map<String, ?> options);
实例:
//Map作为参数
apply plugin:'java'
//闭包作为一个参数
apply {
plugin 'java'
}
//配置一个ObjectConfigurationAction
apply(new Action<ObjectConfigurationAction>() {
@Override
void execute(ObjectConfigurationAction objectConfigurationAction) {
objectConfigurationAction.plugin('java')
}
})
Gradle插件开发流程
1.建立插件工程
2.实现插件内部逻辑
3.发布与使用插件
页面路由
功能梳理
1.标记页面(标记出URL与页面的对应关系)
2.收集页面(收集映射关系,统一记录进映射关系表,这样才可以根据映射关系表打开对应页面)
3.生成文档(页面多,生成统一文档,记录URL与页面的对应关系;方便查询)
4.注册映射(将所有的路由表映射关系注册到路由框架中)
5.打开页面(根据URL打开对应的页面)
前四个在编译期间完成。
实战一 插件工程建立
二进制插件的实现方式有两种:
①一个独立工程:如果需要调试,需要手动发布成一个二进制插件的jar包。
这样其他工程才能引用,才可以测试,测试起来会比价麻烦
②建立buildSrc子工程:在构建的时候,gradle就会自动的将其打包成一个二进制jar包,更加方便。
发布插件有两种方向:
①发布到本地仓库
②发布到远程仓库
所以我们这里使用第二种方式开启我们的插件之旅:
步骤如下:
1.建立buildSrc子工程
①在根目录下创建buildSrc包(名字必须为此,gradle规定);
②创建build.gradle文件
③在上面文件中添加基础配置
2.建立插件运行入口
①在buildSrc工程下创建源码存放目录:
src/main/groovy
在这个文件夹中存放源码。
②上面那个目录是规定的,还需要创建我们自己的包名,ex:
com/imooc/router/gradle
如图所示:
3.新建插件文件
创建groovy插件文件,需要实现Plugin接口
package com.dream.gradle
import org.gradle.api.Plugin
import org.gradle.api.Project
class RouterPlugin implements Plugin<Project>{
//实现apply方法,注入插件的逻辑
@Override
void apply(Project project) {
println("I\'m from RouterPlugin,apply from ${project.name}")
}
}
4.创建属性文件(指定plugin名称)
【这里properties文件的名字:com.rye.router就是我们插件的id!!】apply plugin的时候用的就是这个。
目前只指定插件实现文件路径:
implementation-class=com.dream.gradle.RouterPlugin
5.实现参数配置
有以下几个步骤
①定义Extension
实际上就是定义一个实体类,指定扩展的各种属性:
package com.dream.gradle
class RouterExtension {
String wikiDir
}
定义的话,就这么简单
②注册Extension
注册Extension需要在我们的Plugin文件中注册:
//注册Extension
project.getExtensions().create("router", RouterExtension)
传入两个参数,一个是我们定义的扩展名字,这个随便起。另一个就是我们刚才创建的扩展实体类
③使用Extenstion
在我们引入插件的子工程中就可以运用我们刚才定义的插件的扩展属性了:
这样我们就在我们子工程里的gradle中配置好了我们自定义的扩展属性,配置好了,那我们就需要拿到这个配置的属性执行我们插件的逻辑了,也就到了下一步
④获取Extension
在我们插件中,需要等配置完成了,才能拿到我们上面设置的属性
执行gradlew clean -q 就可以看到我们配置之后属性获取到了:
发布与使用插件
毕竟插件是要给别人一起用的,所以我们需要将插件打成jar包,发布出去,和团队其他人一起使用。发布两种方式:发布到远程仓库或发布到本地仓库。
这里我们通过发布到本地仓库来了解这个流程:
发布到本地仓库
在工程中应用插件
发布插件到本地仓库
①在buildSrc的build.gradle文件夹下编写我们的上传任务:
uploadArchives(这个名字是固定的,这个任务已经被删除了,以后再写就用maven-publish或者ivy-publish任务)
②创建插件文件夹
【警告警告!!】有一点我们必须明确:在buildSrc中不能直接发布插件!!!!。所以我们需要将buildSrc拷贝一份到当前项目的根目录下,作为插件发布的源工程。
在mac下的命令是
cp -rf buildSrc router-gradle-plugin
在windows下的命令是
xcopy buildSrc router-gradle-plugin /E
拷贝完成后,项目识别不到这是一个module,所以还需要在settings.gradle中include进来:
③生成本地maven仓库
执行我们在插件build.gradle中定义的uploadArchives命令:
gradlew :router-gradle-plugin:uploadArchives
之后就会生成我们本地的mave仓库:
发布后:
这里的包名对应的就是groupId,文件夹名对应的是artifactId;版本好在这个文件夹下:
可以看到我们的插件工程正确的上传到了本地仓库中!!
【插件上传仓库成功!!!】
✿✿ヽ(°▽°)ノ✿撒花✿✿ヽ(°▽°)ノ✿
这个repo文件夹就是我们刚才在build.gradle中指定的../repo路径。
可以看到其中的jar包就是我们发布出后的插件。
至此,我们的插件已经在本地仓库上传成功。其他本地项目就可以使用此插件了。可以新建一个项目测试,这里之前有测试项目,我就用之前的项目做演示。
引用插件
其他项目引用插件可分为四步:
①在项目的build.gradle文件中配置Maven仓库地址;
需要分别在buildscript闭包下的repositories和allprojects闭包下的repositories中引入仓库地址:
/**
* 1.配置Maven仓库地址;这里可以是相对路径
*/
maven {
url uri("/Users/zhaozhenguo/Desktop/projects/AndroidZex/repo")
}
②声明依赖的插件
还是在此文件中,buildscript闭包下的dependencies中声明:
/**
* 2.声明依赖的插件
* 格式--> groupId : artifactId : version
*/
classpath 'com.rye.router:router-gradle-plugin:1.0.0'
③应用路由插件
在需要插件的子模块中引入插件:
/**
* 3.应用路由插件
*/
apply plugin: 'com.rye.router'
④向插件中传递参数
/**
* 4.向路由插件传递参数
*/
router {
wikiDir getRootDir().absolutePath
}
就这四步走战略~
引入并配置完成后,在此项目中运行
./gradlew clean -q
检验是否导入成功:
可以看出,本地仓库导入成功。
至于如何发布到远程仓库,等我们此插件编写完毕后,再进行尝试~
第四章 页面路由开发实战- APT采集页面路由信息
本章介绍:
APT
APT工作原理--面试必备!
页面路由开发-功能梳理
页面路由开发所需功能主要有以下几点:
1.标记页面(标记出URL与页面的对应关系)
2.收集页面(收集映射关系,统一记录进映射关系表,这样才可以根据映射关系表打开对应页面)
3.生成文档(页面多,生成统一文档,记录URL与页面的对应关系;方便查询)
4.注册映射(将所有的路由表映射关系注册到路由框架中)
5.打开页面(根据URL打开对应的页面)
采集页面信息:
定义注解:@Route 【用来标记页面】
采集注解:实现 RouteProcessor
发布与使用 :新建META-INF文件夹,注册Processor【可以手动注册,也可以采用谷歌官方框架auto,自动帮我们注册Processor】
新建的注解处理器工程有两个重要的组成部分:
①META-INF:此目录下会配置一个配置文件,此配置文件会有一个入口,入口指向我们的注解处理器
②注解处理器:将会接受javac帮我们找到的所有注解,然后在其中进行一些自定义操作,比如生成源码文件等。
【注解工程建立】
1.建立注解工程
新建Directory,名称router_annotation:
2.添加build.gradle文件(并引入java插件)
3.include 注解工程
在settings.gradle中将注解工程添加到编译中:
4.定义注解
这里的文件目录也需要我们自己新建。
这里注解的生命周期只需要保存在编译期就行。注解的类型在介绍APT的参考资料中已经做过总结,这里暂不赘述。
注解内容就两个:
value代表当前的页面的URL,description是对当前页面的中文描述;
5.使用注解
①在子工程中依赖router_annotation工程。
②在子工程中任意一个类中使用此注解:
【注解处理器工程建立】
注解处理器前期处理流程如下:
1.新建Directory,名称router_processor
2.新建build.gradle
①引入java插件(这里也引入了kotlin)
②依赖注解工程
3.include 注解处理器工程
4.采集注解
①创建源码目录及包目录
②新建Processor【注解处理器里的操作可以说是路由框架的核心所在了】
新建一个类RouteProcessor继承自AbstractProcessor。
然后可以通过重写getSupportAnnotationTypes方法告诉编译器当前处理器支持的注解类型。这里采用了@SupportAnnotationTypes注解来指定我们要处理的注解。这个必须指定,否则返回空集合。**
getSupportedAnnotationTypes和getSupportedSourceVersion这两个方法,也可以使用注解标识,如下所示,如果两个地方都写,以该方法的结果为主。
所以我们这里还加了一个指定源码版本的注解,我这边用的是JAVA1.8,课程中的是JAVA1.7.不同版本的会有坑。这里需要留意一下。
还有一个抽象方法必须重写:process(~)方法
编译器找到要处理的注解后,会回调此方法。【十分十分重要的方法!】
【开始实现注解处理器的process方法】
process方法有两个参数:
①第一个参数是一个TypeElement 的Set集合,将返回所有的由该Processor处理,并待处理的 Annotations的类的集合。
②第二个参数RoundEnvironment.表示当前或是之前的【运行环境】,可以通过该对象查找找到的注解。(getElementsAnnotatedWith)
我们process方法处理的第一步就是拿到所有的@Route注解的信息,后面就可以通过这些信息建立映射关系。
这里简单分析一下,上面是【如何获取注解的信息】的。
1.通过roundEnvironment的getElementsAnnotatedWith方法传入@Route的泛型,拿到所有@Route注解的类的Set<Element>集合
2.遍历Set<Element>集合,将Element强转成typeElement,并通过typeElement的getAnnotation(Class<A> var1)方法,获取到当前Element的注解。
3.拿到@Route注解的value、descrption信息。通过typeElement.getQualifiedName().toString()方法拿到当前类的真实路径信息。
至于拿到这些信息后存储到本地是我们之后要说的事了。
这里我们加了sout来输出我们拿到的注解的信息,当时代码执行不到这个地方!因为还没有【注册注解处理器!!】
注册注解处理器
1.可以使用AutoService库来注册注解处理器.AutoService这里主要是用来生成
META-INF/services/javax.annotation.processing.Processor文件的。
在router_processor的build.gradle中引入autoService的依赖:
2.在RouteProcessor上声明@AutoService(Processor.class)
3.在我们的子工程中使用processor(课程里用的是app module,也就是主module,我这边用的是自己建的子module)
4.验证:
①先执行clean清楚build残留;
②执行
./gradlew :app:assembleDebug -q
因为我是放在zgstep子模块中来验证注解处理器的使用的,所以执行的命令是:
./graldew :zgstep:assembleDebug -q
之后可以看到日志信息:
说明我们的【注解处理器注册成功】
看看我们AutoService生成的META-INF目录在哪:
这里需要注意,如果我们子module中用的是annotationProcessor引入的注解处理器,那么META-INF目录是在router-processor->build->classes->java->META-INF.
但我们用的是kotlin的kapt引入的注解处理器,META-INF目录文件路径是:
tmp->kapt3->classes->META-INF。
至于META-INF文件的作用,上面已经说了,必须搞懂。那个参考文章必须要看!面试要扯到APT,必问!
这里补充一下,在编译过程中,注解处理器的process方法会调用多次,为了避免逻辑重复执行,我们需要新增一个判断:
生成类-类信息拼接(路由表)
我们需要使用注解处理器生成一个路由信息表的类!
确定路由表的结构:
实际上就是一个类中提供一个静态的方法,用于获取路由表。
这个方法中持有了一个Map<String,String>,key是页面的URL,value是类的真实路径。
这了动态的部分就是类名、put操作,其他部分的代码都是固定
生成类信息可以用字符串拼接,也可以使用javapoet来生成。
我们需要生成路由表信息,跳转的时候,路由会在路由表中查找对应的跳转页面进行跳转。
这个路由表最简单的形式,起码得有一个HashMap,key是路由路径,value是要跳转页面的的真实路径。ex:
public class RouterMapping_1613657433732 {
public static Map<String, String> get() {
Map<String, String> mapping = new HashMap();
mapping.put("route://page_content","com.rye.catcher.activity.ContentProviderActivity");
return mapping;
}
}
如果通过StringBuilder拼接生成,就直接生成得了。这里看一下用javapoet如何生成此类:
private ArrayList<RouterValue> routerValues;
private void buildMapping() {
ClassName mapClassName = ClassName.get(Map.class);
MethodSpec get = MethodSpec.methodBuilder("get")
.returns(ParameterizedTypeName.get(mapClassName, ClassName.get(String.class), ClassName.get(String.class)))
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.addCode(getCodeBlock(mapClassName))
.build();
TypeSpec routerMappingClass = TypeSpec.classBuilder("RouterMapping_"+System.currentTimeMillis())
.addMethod(get)
.addModifiers(Modifier.PUBLIC)
.build();
JavaFile file = JavaFile.builder("com.dawn.zgstep.mapping", routerMappingClass)
.build();
try {
file.writeTo(processingEnv.getFiler());
hasCreatedFile = true;
} catch (IOException e) {
e.printStackTrace();
} finally {
}
System.out.println(file);
}
private CodeBlock getCodeBlock(ClassName mapClassName) {
CodeBlock.Builder contentBlock = CodeBlock.builder().addStatement("$T mapping = new $T()",
ParameterizedTypeName.get(mapClassName, ClassName.get(String.class),
ClassName.get(String.class)), ClassName.get("java.util", "HashMap"));
for (RouterValue value : routerValues) {
contentBlock.addStatement("mapping.put($S,$S)", value.url, value.realPath);
}
return contentBlock.addStatement("return mapping").build();
}
//这里的routerValues存储了对应页面的路由url和真实路径:
写完之后,重新调用一下:
./gradlew :app:assembleDebug
即可看到
在build文件夹下已经生成了我们所需的路由表信息。
上传插件并使用
-----------------------------这一段可都是满满的干货:---------------------------
在本地调试正常后,我们就需要将插件上传到仓库中,供其他项目使用了。
上传可分为以下几步:
1.指定插件基本信息,包括但不仅限于插件的ID、URL、 NAME
2.新建插件脚本文件
3.执行脚本上传任务
4.在其他项目中引用插件
我们一步一步来看:
新建插件脚本文件
新建一个maven-publish.gradle文件:
//使用maven插件中的发布功能
apply plugin: 'maven'
Properties gradleProperties = new Properties()
gradleProperties.load(project.rootProject.file('gradle.properties').newDataInputStream())
def VERSION_NAME = gradleProperties.getProperty("VERSION_NAME")
def POM_URL = gradleProperties.getProperty("POM_URL")
def GROUP_ID = gradleProperties.getProperty("GROUP_ID")
println("currentPlugin:versionName:$VERSION_NAME,pomUrl:$POM_URL,groupId:$GROUP_ID")
Properties projectGradleProperties = new Properties()
projectGradleProperties.load(
project.file('gradle.properties').newDataInputStream()
)
def POM_ARTIFACT_ID = projectGradleProperties.getProperty("POM_ARTIFACT_ID")
uploadArchives {
repositories {
mavenDeployer {
//填入发布信息
repository(url: uri(POM_URL)) {
pom.groupId = GROUP_ID
pom.artifactId = POM_ARTIFACT_ID
pom.version = VERSION_NAME
}
pom.whenConfigured { pom ->
pom.dependencies.forEach { dep ->
if (dep.getVersion() == "unspecified") {
dep.setGroupId(GROUP_ID)
dep.setVersion(VERSION_NAME)
}
}
}
}
}
}
这里有几点要着重提一下:
①属性配置
可以在根目录或者子module的gradle.properties中设置属性
这里我们要上传仓库的是两个module:router_annotation和router_processor工程。
这两个module都要指定对应的GROUP_ID、VERSION_ NAME、POM_URL(仓库地址)以及POM_ARTIFACT_ID (插件的id).
然鹅,这两个插件除了ID不同,前面三者都是可以一样的,所以我们声明在根目录下的gradle.properties中。而POM_ARTIFACT_ID可以声明在两个module各自的gradle.properties中。因为这两个项目创建的时候是java library.没有gradle.properties,所以需要我们手动创建一下这个文件。然后指定插件ID即可。ex:
②Properties
此类是java.util里的工具类,继承自Hashtable。移键值对形式存储属性值。主要方法如下:
这里我们主要使用此类的load方法,用来读取根项目以及子项目中gradle.properties文件里设置的属性。
这里读取的属性都是我们在gradle.properties里设置的。
最重要的还是这个uploadArchives任务。
实际上发布脚本除了这个任务还有publishing
实际上Gradle 1.3之后就推出了publishing任务用户上传插件到maven仓库。但是这都6.8.2了,uploadArchives依然在使用,而且 被作废的那一天目前还看不到,加上课程中老师采用这种方式,所以目前就用uploadArchives来将我们的插件上传到maven仓库。
因为插件用的是maven的上传功能,所以最上面需要apply maven插件。如果采用publishing任务上传,需要使用的是maven-publish插件。
格式比较固定,暂不多说。【后续需要对上面中的闭包都做个基本了解,以及pom】
3.上传插件到本地仓库中
创建好插件后,就可以在需要上传的module中的build.gradle中引用我们的发布插件了,ex:
因为我是放在根目录下的config文件夹中,所以路径可能有出入,按照自己的插件文件路径来就行。
router_processor和router_annotation都需要上传到maven仓库,所以两个module的build.gradle中都需要各自引入。
引入后,sync后,分别执行命令:
:router_processor:uploadArchives
:router_annotation:uploadArchives
即可正确上传我们的插件到仓库中,可以在我们的本地仓库../repo中看到我们上传成功的插件:
4.其他项目引用插件
分为简单的两步:
①在根工程的build.gradle中指定mven仓库地址
需要在buildScript和allprojects两个闭包的repositories中均指定maven地址:
②在子工程的build.gradle中implementation和kapt
implementation 'com.rye.router:router-annotation:1.0.0'
annotationProcessor "com.rye.router:router-processor:1.0.0"
引入我们的插件。
sync后build一下,就可以发现我们可以在别的仓库中引导我们发布到仓库中的插件,明显的现象就是:
①可以使用@Route注解
②在build-generated-ap_generated_sources文件夹下找到我们路由表文件:
那么到目前为止,我们简单的插件开发,与上传仓库,以及在其他项目中使用就可以告一段落,下面既可以完善我们插件的工程了。
第五章 完善Gradle插件
前面我们已经将页面路由中的标记页面与收集页面功能开发完毕了。这一章主要就是进行第三步:生成文档。
逻辑梳理
生成文档主要分为三步:
- 传递路径参数 (把文档存储到哪个地方)
- 生成JSON文件(注解处理器在查到注解的时候,就可以把注解信息存储到json文件中;每一个json文件包含的是当前子工程的映射信息)
- 汇总生成文档 (在某个统一路径下,将所有子工程的json文件汇总,生成一个统一的文档)
1.传递路径参数
生成json文档的位置我们希望是可以动态指定的,一种比较好的方式是在build.gradle中给用户提供一个入口,指定生成json文档的文件夹根目录,ex:
这种方式其实不错,不过我们还可以配置在plugin中:
这种就相当于在插件中动态添加参数,更加方便,起码用户不用sync了...
2.生成json文件
生成json文件的逻辑实际上和我们生成路由表的逻辑是在一块的。毕竟都是获取@Route的信息,存储到文件中:
没什么好说的,而且存储的方式是jsonArray,格式上不怎么好,后续需要修改。
3.生成汇总文档
在生成汇总文档前,我们还有个工作要做,就是自动清理构建产物。每次build的时候,用户的路由信息可能有所改变,可以新增了一个@Route,也可能修改了一个@Route等。所以这就要求我们每次build的时候,清理一下上次生成的每个模块的json文件。
这块的代码我们在buildSrc中的RouterPlugin中进行处理,跟上面的自动传递参数处理的地方一致:
接下来就是重头戏了,生成汇总文档:
首先明确生成时机,肯定要在配置结束后,开始执行任务的时候。在javac任务结束后,注解处理器的任务执行完毕,生成了每个模块的json文件,这时候我们就可以获取到所有json文件里的路由信息。那么就可以生成我们的汇总文档了:
project.afterEvaluate { //配置结束,可拿到用户配置的参数
RouterExtension extension = project["router"]
println("用户设置的wiki路径为:${extension.wikiDir}")
buildMarkDown(project,extension.wikiDir)
}
//3.在javac任务[compileDebugJavaWithJavac]后汇总生成文档(需要在配置结束后)
private void buildMarkDown(Project project,String wikiDir) {
project.tasks.findAll { task ->
task.name.startsWith('compile') && task.name.endsWith('JavaWithJavac')
}.each { task ->
task.doLast {
File routerMappingDir = new File(project.rootProject.projectDir, "router_mapping")
if (!routerMappingDir.exists()) {
return
}
File[] allChildFiles = routerMappingDir.listFiles()
if (allChildFiles.length < 1) {
return
}
StringBuilder markDownBuilder = new StringBuilder()
markDownBuilder.append("# 页面文档\n\n")
allChildFiles.each { child ->
if (child.name.endsWith(".json")) {
JsonSlurper jsonSlurper = new JsonSlurper()
def content = jsonSlurper.parse(child)
content.each { innerContent ->
def url = innerContent['url']
def description = innerContent['description']
def realPath = innerContent['realPath']
markDownBuilder.append("## $description \n")
markDownBuilder.append("-url: $url \n")
markDownBuilder.append("- realPath: $realPath \n\n")
}
}
}
File wikiFileDir = new File(wikiDir)
if (!wikiFileDir.exists()) {
wikiFileDir.mkdir()
}
File wikiFile = new File(wikiFileDir, "页面文档.md")
if (wikiFile.exists()) {
wikiFile.delete()
}
wikiFile.write(markDownBuilder.toString())
}
}
}
其他问题
1.JsonSlurper (Groovy里的类)
①JsonSlurper JsonSlurper是一个将JSON文本或阅读器内容解析为Groovy数据的类结构,例如map,列表和原始类型,如整数,双精度,布尔和字符串。
②JsonOutput 此方法负责将Groovy对象序列化为JSON字符串
ex:
def jsonSlurper = new JsonSlurper()
def object = jsonSlurper.parseText('{ "name": "John", "ID" : "1"}')
println(object.name)
println(object.ID)
结果输出如下:
John
1
上面我们解析每一个json文件,就是使用JsonSlurper来解析成Groovy的类结构。
- project.extensions.findByName('kapt')
如上图,我点击arguments的时候,发现根本点不了!
点开findByName方法,可以看到ExtenstionContainer返回的是一个Object对象:
后面通过打log发现返回的类的实例是:
也就是KaptExtension。
可以看一下这个类的源码:
KaptExtension源码
可以看到:
确实有个arguments方法,接收一个闭包参数。且 闭包里的内容:
这也就解释通了为什么可以如最上面那样设置参数了。
第六章 字节码插桩
插桩
用通俗的话来讲,插桩就是将一段代码通过某种策略插入到另一段代码,或替换另一段代码。这里的代码可以分为源码和字节码,而我们所说的插桩一般指字节码插桩。
图1是Android开发者常见的一张图,我们编写的源码(.java)通过javac编译成字节码(.class),然后通过dx/d8编译成dex文件。我们下面要讲的插桩,就是在.class转为.dex之前,修改.class文件从而达到修改或替换代码的目的。
使用场景
①代码插入
假如我们需要监控项目中所有的方法耗时。如果手动添加,一方面工作十分耗时,除了浪费了自己的时间,简直没有任何价值。另一方面做这么重复无意义的工作,根本就体现不出自己的价值。而通过字节码插桩,我们扫描每一个生成的.class文件,并针对特定规则进行字节码修改从而达到监控每个方法耗时的目的。
②代码替换
加入我们需要将 项目中的Dialog.show()方法替换成自己包装的方法MyDialog.show(),如果用快捷键,可能会有问题【本人遇到过这种问题】:
- 如果其他类定义了show方法,并被调用了,使用快捷键可能会错误替换掉
这里我们也可以通过插桩来解决。很多业务场景都可以使用插桩技术,比如无痕埋点、性能监控。【性能监控划重点,性能优化方面又多了一个有力的手段!】
实现原理
android编译原理就是将我们写的java或者kotlin文件经过javac或者kotlinc编译期编译完成后,统一变成.class字节码文件,class字节码随后会被编译成dex文件,并且和资源等文件打包性能最后的apk文件。
字节码插桩就是在.class转成.dex之前去修改class文件,从而达到修改或者替换代码的功能。关键在于如何获取到.class转成.dex的时间点以及相关的.class文件!
android提供了一个Transform的接口,我们只需要实现一个gradle插件,并且在插件里面提供一个自定义的Transform,注册到构建过程中。就可以在.class转成.dex之前收到对应的回调,在方法的回调里就可以拿到已经编译好的,全部的.class文件集合,接下来就可以对其进行修改。class文件是字节码文件,各种二进制,手动解析会很麻烦,我们可以借助ASM工具解析,修改,甚至是生成新的class文件。通过这个工具就可以不用关心复杂的字节码序列,将工作重心放在我们的字节码插桩上。
功能梳理
为什么路由框架中要用字节码插桩技术呢?
之前我们通过注解处理器生成了路由表,这个路由表是一个module中所有路由映射的表单。但是一个稍微有点规模的项目都会有好多module,对于日活百万、千万、亿级的app来说,其module更可能多达上百个。在运行时,为了能打开所有的页面,必不可少的要找到所有页面的注册信息,也就是这众多module中的路由表。人工查找必不可少会导致遗漏。
换种思路,无论是我们子工程中的路由表信息,还是打包成arr/jar包中的路由信息,都会被编译成.class文件,并达成.dex文件。所以我们可以拿到.class转成.dex的时间点,获取所有的.class文件,解析字节码文件。找到我们每一个路由表类,把这些类汇总起来,形成一个固定名称的映射表。这样在后续运行中,我们只需要注册这一个汇总表就不会产生遗漏的问题了。
实战:创建类结构
我们要做的第一步就是【收集固定包名、且前缀是RouterMapping】的所有路由表信息,通过ASM遍历拿到所有的信息。然后在Transform中生成新的类:汇总表。要实现这些,我们先定一个三步走战略:
①建立Transform
②收集目标类
③生成汇总映射表
1.建立Transform
Transform详解
Transform+ASM
Transform API
transform任务在app/build/intermediates/transform/
下可以看到。
①引入依赖,实现Transform
Transform是gradle里的类,在使用此类之前,我们需要在我们的buildSrc下的build.gradle文件中引入gradle构建工具:
接着在buildSr目录下新建一个文件:RouterMappingTransform.groovy;
实现Transform抽象类;覆写其中的抽象方法:
package com.freedom.gradle
import com.android.build.api.transform.Format
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry
class RouterMappingTransform extends Transform {
/**
* 当前 Transform 的名称
* @return
*/
@Override
String getName() {
return "RouterMappingTransform"
}
/**
* 返回告知编译器,当前Transform需要消费的输入类型
* 在这里是CLASS类型
* @return
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* 告知编译器,当前Transform需要收集的范围
* @return
*/
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
/**
* 是否支持增量
* 通常返回False
* @return
*/
@Override
boolean isIncremental() {
return false
}
/**
* 所有的class收集好以后,会被打包传入此方法
* @param transformInvocation
* @throws TransformException
* @throws InterruptedException
* @throws IOException
*/
@Override
void transform(TransformInvocation transformInvocation)
throws TransformException, InterruptedException, IOException {
// 1. 遍历所有的Input
// 2. 对Input进行二次处理
// 3. 将Input拷贝到目标目录
RouterMappingCollector collector = new RouterMappingCollector()
// 遍历所有的输入
transformInvocation.inputs.each {
// 把 文件夹 类型的输入,拷贝到目标目录
it.directoryInputs.each { directoryInput ->
def destDir = transformInvocation.outputProvider
.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY)
collector.collect(directoryInput.file)
FileUtils.copyDirectory(directoryInput.file, destDir)
}
// 把 JAR 类型的输入,拷贝到目标目录
it.jarInputs.each { jarInput ->
def dest = transformInvocation.outputProvider
.getContentLocation(
jarInput.name,
jarInput.contentTypes,
jarInput.scopes, Format.JAR)
collector.collectFromJarFile(jarInput.file)
FileUtils.copyFile(jarInput.file, dest)
}
}
println("${getName()} all mapping class name = "
+ collector.mappingClassName)
File mappingJarFile = transformInvocation.outputProvider.
getContentLocation(
"router_mapping",
getOutputTypes(),
getScopes(),
Format.JAR)
println("${getName()} mappingJarFile = $mappingJarFile")
if (mappingJarFile.getParentFile().exists()) {
mappingJarFile.getParentFile().mkdirs()
}
if (mappingJarFile.exists()) {
mappingJarFile.delete()
}
// 将生成的字节码,写入本地文件
FileOutputStream fos = new FileOutputStream(mappingJarFile)
JarOutputStream jarOutputStream = new JarOutputStream(fos)
ZipEntry zipEntry =
new ZipEntry(RouterMappingByteCodeBuilder.CLASS_NAME + ".class")
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(
RouterMappingByteCodeBuilder.get(collector.mappingClassName))
jarOutputStream.closeEntry()
jarOutputStream.close()
fos.close()
}
}
上面实现的方法,链接文章以及注释已经标的很明确了。主要是transform方法:
其中transform方法中的内容,我们来一步一步分析。
当项目中所有的.class文件打包后之后,就会调用此方法。也就是在这个方法中,我们可以拿到项目中所有的.class文件!!
除了transform方法,我们后需需要一步一步完善,在实现了上面的几个方法后,我们就可以将transform注册到项目中了。
②Transform注册
/**
* 注册Transform
* @param project
*/
private void registerTransform(Project project) {
//有则说明是com.android.application的子工程,一般就是主工程;其他模块的是apply plugin: 'com.android.library'
if (project.plugins.hasPlugin(AppPlugin)) { //目前发现只能在主工程注册
AppExtension baseExtension = project.extensions.getByType(AppExtension)
Transform transform = new RouterMappingTransform()
baseExtension.registerTransform(transform)
}
}
在我们上面的RouterPlugin中的apply方法中调用此方法,即可完成Transform的注册。这里有个坑点,就是Transform只能在主工程中注册。我原本是在一个library module中注册的,但是会报错。所以上面getByType中只能传入AppExtension或者BaseExtension,传入LibraryExtension就会报错。
那么对应的,我们就需要在app模块下的build.gradle文件中调用:
apply plugin:'com.rye.router'
来完成transform的注册。【当然其他模块下依然可以引用此插件,只是一定要主工程中注册!】
这里的 com.rye.router
是我buildSrc插件指定的plugin id,要替换成自己的buildSrc的plugin id;
【验证是否注册成功】:
执行./gradlew clean
后;
执行./gradlew :app:assembleDebug
;
接着在app->build->intermediates
文件夹下,如果看到:
就说明已经注册成功了。
③遍历所有的class文件
transform(~)方法是Transform的核心方法,用来处理输入。处理流程有一定格式:
就是从TransformInvocation 获取到总的输入后,分别按照 class目录 和 jar文件
集合的方式进行遍历处理。
整个Transform难点就就在于这个方法中的处理:
1.正确、高效的进行文件目录、jar 文件的解压、class 文件 IO 流的处理,保证在这个过程中不丢失文件和错误的写入
2.高效的找到要插桩的结点,过滤掉无效的 class
3.支持增量编译
那么接下来就可以开始我们实现transform的第一步,遍历所有的class文件【包括文件夹下的文件、jar包下的文件】:
// 1. 遍历所有的Input
// 2. 对Input进行二次处理
// 3. 将Input拷贝到目标目录
RouterMappingCollector collector = new RouterMappingCollector()
// 遍历所有的输入
transformInvocation.inputs.each {
// 把 文件夹 类型的输入,拷贝到目标目录
it.directoryInputs.each { directoryInput ->
def destDir = transformInvocation.outputProvider
.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY)
collector.collect(directoryInput.file)
FileUtils.copyDirectory(directoryInput.file, destDir)
}
// 把 JAR 类型的输入,拷贝到目标目录
it.jarInputs.each { jarInput ->
def dest = transformInvocation.outputProvider
.getContentLocation(
jarInput.name,
jarInput.contentTypes,
jarInput.scopes, Format.JAR)
collector.collectFromJarFile(jarInput.file)
FileUtils.copyFile(jarInput.file, dest)
}
}
对于RouterMappingCollector里的内容,我们下面会有分析。先简单分析这两个遍历。
inputs是传过来的输入流,有两种格式:jar和目录格式
outputProvider 获取输出目录,将修改的文件复制到输出目录,必须执行。所以这里面的两个遍历和copy固定是很固定的。不一样的地方就在于我们collector中。下面就介绍一下这个RouterMappingCollector.
2.收集目标类
①完成映射表类名的收集
我们要生成总的汇总表,那么在此之前,必须得拿到项目中所有module下的每一个路由表信息。
所以在这里,我们在buildSrc文件夹下新建一个groovy文件,用于过滤出所有的路由表对应的.class文件:RouterMappingCollector.groovy
这个工具类要做的事情就以一件:过滤出所有的路由表文件。
首先我们要知道,我们要过滤的除了文件夹下的类还有就是jar包下所有的类。
因为有可能有其他插件引用了我们的路由插件,并被打成了jar包供外界使用。
还有就要我们要怎么过滤出这些路由表信息?
那我们就要回头看一下我们的路由表都有哪些特征了。主要有三个特征:
(1)包名:在我们的注解处理器RouterProcessor中指定了路由表生成的目标文件夹,我个人用的文件夹路径是:
private static final String PACKAGE_NAME = "com/dawn/come/mapping"
(2)文件前缀:RouterMapping_
(3)文件后缀:因为在transform方法中过滤所有的路由表文件,所以能拿到的肯定都是.class文件。后缀也就是确定的.class了。
那么我们接下里要做的就是过滤所有的File,包括文件目录和jar包中的文件。
找出符合这三个条件的文件,就是我们每一个module中的路由文件了~,那么这个实现类里的内容也就应运而生了:
package com.freedom.gradle
import java.util.jar.JarEntry
import java.util.jar.JarFile
class RouterMappingCollector {
private static final String PACKAGE_NAME = "com/dawn/come/mapping"
private static final String FILE_NAME_PREFIX = "RouterMapping_"
private static final String FILE_NAME_SUFFIX = ".class"
private final Set<String> mappingClassNames = new HashSet<>()
Set<String> getMappingClassName() {
return mappingClassNames
}
/**
* 收集class文件或者class文件目录中的映射表类
* @param classFile
*/
void collect(File classFile) {
if (classFile == null || !classFile.exists()) {
return
}
if (classFile.isFile()) {
if (classFile.absolutePath.contains(PACKAGE_NAME)
&& classFile.name.startsWith(FILE_NAME_PREFIX)
&& classFile.name.endsWith(FILE_NAME_SUFFIX)) {
String className = classFile.name.replace(FILE_NAME_SUFFIX, "")
mappingClassNames.add(className)
}
} else {
classFile.listFiles().each { file ->
collect(file)
}
}
}
/**
* 收集jar包中的映射表类
* @param jarFile
*/
void collectFromJarFile(File jarFile) {
Enumeration<JarEntry> enumeration = new JarFile(jarFile).entries()
if (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.name
if (entryName.contains(PACKAGE_NAME)
&& entryName.contains(FILE_NAME_PREFIX)
&& entryName.contains(FILE_NAME_SUFFIX)) {
String className = entryName.replace(PACKAGE_NAME, "")
.replace("/", "")
.replace(FILE_NAME_SUFFIX, "")
mappingClassNames.add(className)
}
}
}
}
接下来就可以回到我们上边提到的transform方法了,在两个遍历中分别收集到路由表信息。可以通过println方法打印出所有的路由表文件名。
④模拟生成的汇总表
当我们收集好所有子module下的路由表信息后。接下来我们要做的一件事,就是模拟我们要生成的汇总表!
为什么要模拟这个这个类?主要是为了看生成后的汇总表用asm如何生成!
因为我们的汇总表生成时机是.class文件打包成.dex之前,不可能插入源文件.java,.kt这种,只能插入字节码文件!那么字节码文件如何创建呢?如果手动创创建,其成本还是十分高的,因为字节码文件的样式是如下样子的:
如果这样一点点的根据我们要生成的汇总表去拆成各种汇编命令,再编写生成字节码文件,就很麻烦!所以我们可以借助一些字节码操作工具,常用的有:
ASM、javassit等。我们使用的是ASM框架。
ASM官网
ASM是一个通用的Java字节码操作和分析框架。它可以直接以二进制形式用于修改现有类或动态生成类
在生成我们汇总表之前,我们最好的方式就是先写出要生成后的汇总表的java文件,通过一个插件来查看其asm格式的代码文件,然后根据这个文件内容,抽离出我们需要的asm命令。
模拟创建好后的文件:
这里的RouterMapping_XX就代表着我们各个module下的路由表信息;
RouterMapping就是我们汇总表生成后的样式。
下面我们会说明为什么要先模拟创建好后的汇总表。
3.生成汇总映射表
创建RouterMappingByteCodeBuilder.groovy文件,调用ASM命令创建汇总表的.class文件。
首先指定要生成汇总表的类名
public static final String CLASS_NAME = "com/dawn/come/mapping/generated/RouterMapping"
创建class文件的流程大致如下:
// 1. 创建一个类
// 2. 创建构造方法
// 3. 创建get方法
// (1)创建一个Map
// (2)塞入所有映射表的内容
// (3)返回map
问题就来了,我们需要用ASM来创建我们的汇总表。ASM框架学习成本还是有的,但是我们可以利用现有的插件偷一下懒。下面就介绍两款骚骚的插件:
ASM Bytecode Outline 、ASM Bytecode Viewer
前者是Intellij Idea里的插件、后者是Android Studio中的插件。
而且前者并不兼容Android Studio.但后者有点小问题,有时候可能看不到字节码文件...
下载好插件后restart一下。就可以看到看到as的右侧多了一栏:
接下来执行
./gradlew :app:assembleDebug
这样项目build之后,就可以右键我们的类文件:
点击ASM ByteCode Viewer选项,就可以看到该源文件对应的字节码文件及ASM类型的文件了:
上面我们模拟创建了汇总表生成后的文件,就是为了通过此插件查看ASMified里的内容。RouterMappingByteCodeBuilder.groovy文件中的逻辑,主要就是参照这个目录下的内容进行编写,编写好后该文件内容如下:
package com.freedom.gradle
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
class RouterMappingByteCodeBuilder implements Opcodes {
public static final String CLASS_NAME = "com/dawn/come/mapping/generated/RouterMapping"
//需要补习asm知识
static byte[] get(Set<String> allMappingNames) {
// 1. 创建一个类
// 2. 创建构造方法
// 3. 创建get方法
// (1)创建一个Map
// (2)塞入所有映射表的内容
// (3)返回map
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
//---------------创建类
cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER,
CLASS_NAME,
null,
"java/lang/Object",
null)
//--------------创建构造方法
// 生成或者编辑方法
MethodVisitor mv
// 创建构造方法
mv = cw.visitMethod(Opcodes.ACC_PUBLIC,
"<init>",
"()V",
null,
null)
//开启字节码的访问或编辑
mv.visitCode()
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitMethodInsn(Opcodes.INVOKESPECIAL,
"java/lang/Object", "<init>", "()V", false)
mv.visitInsn(Opcodes.RETURN)
mv.visitMaxs(1, 1)
mv.visitEnd()
//---------------创建get方法
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC,
"get",
"()Ljava/util/Map;",
"()Ljava/util/Map<Ljava/lang/String;Ljava/lang/String;>;",
null)
mv.visitCode()
mv.visitTypeInsn(NEW, "java/util/HashMap")
mv.visitInsn(DUP)
//得到HashMap的实例
mv.visitMethodInsn(INVOKESPECIAL,
"java/util/HashMap",
"<init>",
"()V",
false)
//保存实例
mv.visitVarInsn(ASTORE, 0)
// 向Map中,逐个塞入所有映射表的内容
allMappingNames.each {
mv.visitVarInsn(ALOAD, 0)
mv.visitMethodInsn(INVOKESTATIC,//!!!!!!!!!!!!!!!!!!!!采坑点,不要搞成SPECIAL
"com/dawn/come/mapping/$it",
"get", "()Ljava/util/Map;", false)
mv.visitMethodInsn(INVOKEINTERFACE,
"java/util/Map",
"putAll",
"(Ljava/util/Map;)V", true)//map是一个接口,要传入true
}
// 返回map
mv.visitVarInsn(ALOAD, 0)
mv.visitInsn(ARETURN)
mv.visitMaxs(2, 2)
mv.visitEnd()
return cw.toByteArray()
}
}
到这一步,生成汇总表字节码的逻辑就已经完成了,接下来就是将这个字节码写到本地创建我们的汇总表文件。
将汇总表字节码文件写入到本地jar包中去
回到RouterMappingTransform.groovy文件的transform方法中去:
之前遍历了所有的输入文件并进行了拷贝。在此之后,就是将汇总表写入本地jar包中去:
File mappingJarFile = transformInvocation.outputProvider.
getContentLocation(
"router_mapping",
getOutputTypes(),
getScopes(),
Format.JAR)
println("${getName()} mappingJarFile = $mappingJarFile")
if (mappingJarFile.getParentFile().exists()) {
mappingJarFile.getParentFile().mkdirs()
}
if (mappingJarFile.exists()) {
mappingJarFile.delete()
}
// 将生成的字节码,写入本地文件
FileOutputStream fos = new FileOutputStream(mappingJarFile)
JarOutputStream jarOutputStream = new JarOutputStream(fos)
ZipEntry zipEntry =
new ZipEntry(RouterMappingByteCodeBuilder.CLASS_NAME + ".class")
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(
RouterMappingByteCodeBuilder.get(collector.mappingClassName))
jarOutputStream.closeEntry()
jarOutputStream.close()
fos.close()
其中println("${getName()} mappingJarFile = $mappingJarFile")
输出了我们汇总表的位置。
接着就是继续
./gradlew clean
./gradlew :app:assembleDebug
看一下输出:
可以看到我们的汇总表生成在90.jar包中,进入到这个路径下:
调用unzip命令,解压当前文件,看一下解压好后的文件:
可以看到我们的汇总表已经正确生成了!
第七章 运行时处理
本章梳理:
建立runtime工程
新建一个android library工程,名为router_runtime;app模块引入此router_runtime依赖