基于Transform实现更高效的组件化路由框架

系统管理员 2022-01-19 00:15 314阅读 0赞

前言

之前通过APT实现了一个简易版ARouter框架,碰到的问题是APT在每个module的上下文是不同的,导致需要通过不同的文件来保存映射关系表。因为类文件的不确定,就需要初始化时在dex文件中扫描到指定目录下的class,然后通过反射初始化加载路由关系映射。阿里的做法是直接开启一个异步线程,创建DexFile对象加载dex。这多少会带来一些性能损耗,为了避免这些,我们通过Transform api实现另一种更加高效的路由框架。

思路

gradle transform api可以用于android在构建过程的class文件转成dex文件之前,通过自定义插件,进行class字节码处理。有了这个api,我们就可以在apk构建过程找到所有注解标记的class类,然后操作字节码将这些映射关系写到同一个class中。

自定义插件

首先我们需要自定义一个gradle插件,在application的模块中使用它。为了能够方便调试,我们取消上传插件环节,直接新建一个名称为buildSrc的library。 删除src/main下的所有文件,build.gradle配置中引入transform api和javassist(比asm更简便的字节码操作库)

  1. apply plugin: 'groovy'
  2. dependencies {
  3. implementation 'com.android.tools.build:gradle:3.1.2'
  4. compile 'com.android.tools.build:transform-api:1.5.0'
  5. compile 'org.javassist:javassist:3.20.0-GA'
  6. compile gradleApi()
  7. compile localGroovy()
  8. }
  9. 复制代码

然后在src/main下创建groovy文件夹,在此文件夹下创建自己的包,然后新建RouterPlugin.groovy的文件

  1. package io.github.iamyours
  2. import org.gradle.api.Plugin
  3. import org.gradle.api.Project
  4. class RouterPlugin implements Plugin<Project> {
  5. @Override
  6. void apply(Project project) {
  7. println "=========自定义路由插件========="
  8. }
  9. }
  10. 复制代码

然后src下创建resources/META-INF/gradle-plugins目录,在此目录新建一个xxx.properties文件,文件名xxx就表示使用插件时的名称(apply plugin ‘xxx’),里面是具体插件的实现类

  1. implementation-class=io.github.iamyours.RouterPlugin
  2. 复制代码

整个buildSrc目录如下图

然后我们在app下的build.gradle引入插件

  1. apply plugin: 'RouterPlugin'
  2. 复制代码

然后make app,得到如下结果表明配置成功。

router-api

在使用Transform api之前,创建一个router-api的java module处理路由逻辑。

  1. ## build.gradle
  2. apply plugin: 'java-library'
  3. dependencies {
  4. implementation fileTree(dir: 'libs', include: ['*.jar'])
  5. compileOnly 'com.google.android:android:4.1.1.4'
  6. }
  7. sourceCompatibility = "1.7"
  8. targetCompatibility = "1.7"
  9. 复制代码

注解类@Route

  1. @Target({ElementType.TYPE})
  2. @Retention(RetentionPolicy.CLASS)
  3. public @interface Route {
  4. String path();
  5. }
  6. 复制代码

映射类(后面通过插件修改这个class)

  1. public class RouteMap {
  2. void loadInto(Map<String,String> map){
  3. throw new RuntimeException("加载Router映射错误!");
  4. }
  5. }
  6. 复制代码

ARouter(取名这个是为了方便重构)

  1. public class ARouter {
  2. private static final ARouter instance = new ARouter();
  3. private Map<String, String> routeMap = new HashMap<>();
  4. private ARouter() {
  5. }
  6. public static ARouter getInstance() {
  7. return instance;
  8. }
  9. public void init() {
  10. new RouteMap().loadInto(routeMap);
  11. }
  12. 复制代码

因为RouteMap是确定的,直接new创建导入映射,后面只需要修改字节码,替换loadInto方法体即可,如:

  1. public class RouteMap {
  2. void loadInto(Map<String,String> map){
  3. map.put("/test/test","com.xxx.TestActivity");
  4. map.put("/test/test2","com.xxx.Test2Activity");
  5. }
  6. }
  7. 复制代码

RouteTransform

新建一个RouteTransform继承自Transform处理class文件,在自定义插件中注册它。

  1. class RouterPlugin implements Plugin<Project> {
  2. @Override
  3. void apply(Project project) {
  4. project.android.registerTransform(new RouterTransform(project))
  5. }
  6. }
  7. 复制代码

在RouteTransform的transform方法中我们遍历一下jar和class,为了测试模块化路由,新建一个news模块,引入library,并且把它加入到app模块。在news模块中,新建一个activity如:

  1. @Route(path = "/news/news_list")
  2. class NewsListActivity : AppCompatActivity() {}
  3. 复制代码

然后在通过transform方法中遍历一下jar和class

  1. @Override
  2. void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
  3. def inputs = transformInvocation.inputs
  4. for (TransformInput input : inputs) {
  5. for (DirectoryInput dirInput : input.directoryInputs) {
  6. println("dir:"+dirInput)
  7. }
  8. for (JarInput jarInput : input.jarInputs) {
  9. println("jarInput:"+jarInput)
  10. }
  11. }
  12. }
  13. 复制代码

可以得到如下信息

通过日志,我们可以得到以下信息:

  • app生成的class在directoryInputs下,有两个目录一个是java,一个是kotlin的。
  • news和router-api模块的class在jarInputs下,且scopes=SUB_PROJECTS下,是一个jar包
  • 其他第三发依赖在EXTERNAL_LIBRARIES下,也是通过jar形式,name和implementation依赖的名称相同。 知道这些信息,遍历查找Route注解生命的activity以及修改RouteMap范围就确定了。我们在directoryInputs中目录中遍历查找app模块的activity,在jarInputs下scopes为SUB_PROJECTS中查找其他模块的activity,然后在name为router-api的jar上修改RouteMap的字节码。

ASM字节码读取

有了class目录,就可以动手操作字节码了。主要有两种方式,ASM、javassist。两个都可以实现读写操作。ASM是基于指令级别的,性能更好更快,但是写入时你需要知道java虚拟机的一些指令,门槛较高。而javassist操作更佳简便,可以通过字符串写代码,然后转换成对应的字节码。考虑到性能,读取时用ASM,修改RouteMap时用javassist。

读取目录中的class
  1. //从目录中读取class
  2. void readClassWithPath(File dir) {
  3. def root = dir.absolutePath
  4. dir.eachFileRecurse { File file ->
  5. def filePath = file.absolutePath
  6. if (!filePath.endsWith(".class")) return
  7. def className = getClassName(root, filePath)
  8. addRouteMap(filePath, className)
  9. }
  10. }
  11. /**
  12. * 从class中获取Route注解信息
  13. * @param filePath
  14. */
  15. void addRouteMap(String filePath, String className) {
  16. addRouteMap(new FileInputStream(new File(filePath)), className)
  17. }
  18. static final ANNOTATION_DESC = "Lio/github/iamyours/router/annotation/Route;"
  19. void addRouteMap(InputStream is, String className) {
  20. ClassReader reader = new ClassReader(is)
  21. ClassNode node = new ClassNode()
  22. reader.accept(node, 1)
  23. def list = node.invisibleAnnotations
  24. for (AnnotationNode an : list) {
  25. if (ANNOTATION_DESC == an.desc) {
  26. def path = an.values[1]
  27. routeMap[path] = className
  28. break
  29. }
  30. }
  31. }
  32. //获取类名
  33. String getClassName(String root, String classPath) {
  34. return classPath.substring(root.length() + 1, classPath.length() - 6)
  35. .replaceAll("/", ".")
  36. }
  37. 复制代码

通过ASM的ClassReader对象,可以读取一个class的相关信息,包括类信息,注解信息。以下是我通过idea debug得到的ASM相关信息

从jar包中读取class

读取jar中的class,就需要通过java.util中的JarFile解压读取jar文件,遍历每个JarEntry。

  1. //从jar中读取class
  2. void readClassWithJar(JarInput jarInput) {
  3. JarFile jarFile = new JarFile(jarInput.file)
  4. Enumeration<JarEntry> enumeration = jarFile.entries()
  5. while (enumeration.hasMoreElements()) {
  6. JarEntry entry = enumeration.nextElement()
  7. String entryName = entry.getName()
  8. if (!entryName.endsWith(".class")) continue
  9. String className = entryName.substring(0, entryName.length() - 6).replaceAll("/", ".")
  10. InputStream is = jarFile.getInputStream(entry)
  11. addRouteMap(is, className)
  12. }
  13. }
  14. 复制代码

至此,我们遍历读取,保存Route注解标记的所有class,在transform最后我们打印routemap,重新make app。

Javassist修改RouteMap

所有的路由信息我们已经通过ASM读取保存了,接下来只要操作RouteMap的字节码,将这些信息保存到loadInto方法中就行了。RouteMap的class文件在route-api下的jar包中,我们通过遍历找到它

  1. static final ROUTE_NAME = "router-api:"
  2. @Override
  3. void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
  4. def inputs = transformInvocation.inputs
  5. def routeJarInput
  6. for (TransformInput input : inputs) {
  7. ...
  8. for (JarInput jarInput : input.jarInputs) {
  9. if (jarInput.name.startsWith(ROUTE_NAME)) {
  10. routeJarInput = jarInput
  11. }
  12. }
  13. }
  14. insertCodeIntoJar(routeJarInput, transformInvocation.outputProvider)
  15. ...
  16. }
  17. 复制代码

这里我们新建一个临时文件,拷贝每一项,修改RouteMap,最后覆盖原先的jar。

  1. /**
  2. * 插入代码
  3. * @param jarFile
  4. */
  5. void insertCodeIntoJar(JarInput jarInput, TransformOutputProvider out) {
  6. File jarFile = jarInput.file
  7. def tmp = new File(jarFile.getParent(), jarFile.name + ".tmp")
  8. if (tmp.exists()) tmp.delete()
  9. def file = new JarFile(jarFile)
  10. def dest = getDestFile(jarInput, out)
  11. Enumeration enumeration = file.entries()
  12. JarOutputStream jos = new JarOutputStream(new FileOutputStream(tmp))
  13. while (enumeration.hasMoreElements()) {
  14. JarEntry jarEntry = enumeration.nextElement()
  15. String entryName = jarEntry.name
  16. ZipEntry zipEntry = new ZipEntry(entryName)
  17. InputStream is = file.getInputStream(jarEntry)
  18. jos.putNextEntry(zipEntry)
  19. if (isRouteMapClass(entryName)) {
  20. jos.write(hackRouteMap(jarFile))
  21. } else {
  22. jos.write(IOUtils.toByteArray(is))
  23. }
  24. is.close()
  25. jos.closeEntry()
  26. }
  27. jos.close()
  28. file.close()
  29. if (jarFile.exists()) jarFile.delete()
  30. tmp.renameTo(jarFile)
  31. }
  32. 复制代码

具体修改RouteMap的逻辑如下

  1. private static final String ROUTE_MAP_CLASS_NAME = "io.github.iamyours.router.RouteMap"
  2. private static final String ROUTE_MAP_CLASS_FILE_NAME = ROUTE_MAP_CLASS_NAME.replaceAll("\\.", "/") + ".class"
  3. private byte[] hackRouteMap(File jarFile) {
  4. ClassPool pool = ClassPool.getDefault()
  5. pool.insertClassPath(jarFile.absolutePath)
  6. CtClass ctClass = pool.get(ROUTE_MAP_CLASS_NAME)
  7. CtMethod method = ctClass.getDeclaredMethod("loadInto")
  8. StringBuffer code = new StringBuffer("{")
  9. for (String key : routeMap.keySet()) {
  10. String value = routeMap[key]
  11. code.append("\$1.put(\"" + key + "\",\"" + value + "\");")
  12. }
  13. code.append("}")
  14. method.setBody(code.toString())
  15. byte[] bytes = ctClass.toBytecode()
  16. ctClass.stopPruning(true)
  17. ctClass.defrost()
  18. return bytes
  19. }
  20. 复制代码

重新make app,然后使用JD-GUI打开jar包,可以看到RouteMap已经修改。

拷贝class和jar到输出目录

使用Tranform一个重要的步骤就是要把所有的class和jar拷贝至输出目录。

  1. @Override
  2. void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
  3. def sTime = System.currentTimeMillis()
  4. def inputs = transformInvocation.inputs
  5. def routeJarInput
  6. def outputProvider = transformInvocation.outputProvider
  7. outputProvider.deleteAll() //删除原有输出目录的文件
  8. for (TransformInput input : inputs) {
  9. for (DirectoryInput dirInput : input.directoryInputs) {
  10. readClassWithPath(dirInput.file)
  11. File dest = outputProvider.getContentLocation(dirInput.name,
  12. dirInput.contentTypes,
  13. dirInput.scopes,
  14. Format.DIRECTORY)
  15. FileUtils.copyDirectory(dirInput.file, dest)
  16. }
  17. for (JarInput jarInput : input.jarInputs) {
  18. ...
  19. copyFile(jarInput, outputProvider)
  20. }
  21. }
  22. def eTime = System.currentTimeMillis()
  23. println("route map:" + routeMap)
  24. insertCodeIntoJar(routeJarInput, transformInvocation.outputProvider)
  25. println("===========route transform finished:" + (eTime - sTime))
  26. }
  27. void copyFile(JarInput jarInput, TransformOutputProvider outputProvider) {
  28. def dest = getDestFile(jarInput, outputProvider)
  29. FileUtils.copyFile(jarInput.file, dest)
  30. }
  31. static File getDestFile(JarInput jarInput, TransformOutputProvider outputProvider) {
  32. def destName = jarInput.name
  33. // 重名名输出文件,因为可能同名,会覆盖
  34. def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath)
  35. if (destName.endsWith(".jar")) {
  36. destName = destName.substring(0, destName.length() - 4)
  37. }
  38. // 获得输出文件
  39. File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
  40. return dest
  41. }
  42. 复制代码

注意insertCodeIntoJar方法中也要copy。 插件模块至此完成。可以运行一下app,打印一下routeMap

而具体的路由跳转就不细说了,具体可以看github的项目源码。

项目地址

github.com/iamyours/Si…

转载于:https://juejin.im/post/5cf35bde6fb9a07ed440e99a

发表评论

表情:
评论列表 (有 0 条评论,314人围观)

还没有评论,来说两句吧...

相关阅读