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

前言

之前通过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更简便的字节码操作库)

apply plugin: 'groovy'
dependencies {
    implementation 'com.android.tools.build:gradle:3.1.2'
    compile 'com.android.tools.build:transform-api:1.5.0'
    compile 'org.javassist:javassist:3.20.0-GA'
    compile gradleApi()
    compile localGroovy()
}

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

package io.github.iamyours

import org.gradle.api.Plugin
import org.gradle.api.Project

class RouterPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println "=========自定义路由插件========="
    }
}

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

implementation-class=io.github.iamyours.RouterPlugin

整个buildSrc目录如下图


buildSrc目录

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

apply plugin: 'RouterPlugin'

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


image.png

router-api

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

## build.gradle
apply plugin: 'java-library'
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    compileOnly 'com.google.android:android:4.1.1.4'
}
sourceCompatibility = "1.7"
targetCompatibility = "1.7"

注解类@Route

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Route {
    String path();
}

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

public class RouteMap {
    void loadInto(Map<String,String> map){
        throw new RuntimeException("加载Router映射错误!");
    }
}

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

public class ARouter {
    private static final ARouter instance = new ARouter();
    private Map<String, String> routeMap = new HashMap<>();

    private ARouter() {
    }

    public static ARouter getInstance() {
        return instance;
    }

    public void init() {
        new RouteMap().loadInto(routeMap);
    }

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

public class RouteMap {
    void loadInto(Map<String,String> map){
        map.put("/test/test","com.xxx.TestActivity");
        map.put("/test/test2","com.xxx.Test2Activity");
    }
}

RouteTransform

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

class RouterPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.android.registerTransform(new RouterTransform(project))
    }
}

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

@Route(path = "/news/news_list")
class NewsListActivity : AppCompatActivity() {}

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

@Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        def inputs = transformInvocation.inputs
        for (TransformInput input : inputs) {
            for (DirectoryInput dirInput : input.directoryInputs) {
                println("dir:"+dirInput)
            }
            for (JarInput jarInput : input.jarInputs) {
                println("jarInput:"+jarInput)
            }
        }
    }

可以得到如下信息


image.png

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

  • 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
 //从目录中读取class
    void readClassWithPath(File dir) {
        def root = dir.absolutePath
        dir.eachFileRecurse { File file ->
            def filePath = file.absolutePath
            if (!filePath.endsWith(".class")) return
            def className = getClassName(root, filePath)
            addRouteMap(filePath, className)
        }
    }
 /**
     * 从class中获取Route注解信息
     * @param filePath
     */
    void addRouteMap(String filePath, String className) {
        addRouteMap(new FileInputStream(new File(filePath)), className)
    }
 static final ANNOTATION_DESC = "Lio/github/iamyours/router/annotation/Route;"
    void addRouteMap(InputStream is, String className) {
        ClassReader reader = new ClassReader(is)
        ClassNode node = new ClassNode()
        reader.accept(node, 1)
        def list = node.invisibleAnnotations
        for (AnnotationNode an : list) {
            if (ANNOTATION_DESC == an.desc) {
                def path = an.values[1]
                routeMap[path] = className
                break
            }
        }
    }
 //获取类名
    String getClassName(String root, String classPath) {
        return classPath.substring(root.length() + 1, classPath.length() - 6)
                .replaceAll("/", ".")
    }

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


ASM读取注解
从jar包中读取class

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

//从jar中读取class
    void readClassWithJar(JarInput jarInput) {
        JarFile jarFile = new JarFile(jarInput.file)
        Enumeration<JarEntry> enumeration = jarFile.entries()
        while (enumeration.hasMoreElements()) {
            JarEntry entry = enumeration.nextElement()
            String entryName = entry.getName()
            if (!entryName.endsWith(".class")) continue
            String className = entryName.substring(0, entryName.length() - 6).replaceAll("/", ".")
            InputStream is = jarFile.getInputStream(entry)
            addRouteMap(is, className)
        }
    }

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


routeMap信息

Javassist修改RouteMap

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

static final ROUTE_NAME = "router-api:"
 @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        def inputs = transformInvocation.inputs
        def routeJarInput
        for (TransformInput input : inputs) {
          ...
            for (JarInput jarInput : input.jarInputs) {
                if (jarInput.name.startsWith(ROUTE_NAME)) {
                    routeJarInput = jarInput
                }
            }
        }
        insertCodeIntoJar(routeJarInput, transformInvocation.outputProvider)
...

    }

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

 /**
     * 插入代码
     * @param jarFile
     */
    void insertCodeIntoJar(JarInput jarInput, TransformOutputProvider out) {
        File jarFile = jarInput.file
        def tmp = new File(jarFile.getParent(), jarFile.name + ".tmp")
        if (tmp.exists()) tmp.delete()
        def file = new JarFile(jarFile)
        def dest = getDestFile(jarInput, out)
        Enumeration enumeration = file.entries()
        JarOutputStream jos = new JarOutputStream(new FileOutputStream(tmp))
        while (enumeration.hasMoreElements()) {
            JarEntry jarEntry = enumeration.nextElement()
            String entryName = jarEntry.name
            ZipEntry zipEntry = new ZipEntry(entryName)
            InputStream is = file.getInputStream(jarEntry)
            jos.putNextEntry(zipEntry)
            if (isRouteMapClass(entryName)) {
                jos.write(hackRouteMap(jarFile))
            } else {
                jos.write(IOUtils.toByteArray(is))
            }
            is.close()
            jos.closeEntry()
        }
        jos.close()
        file.close()
        if (jarFile.exists()) jarFile.delete()
        tmp.renameTo(jarFile)
    }

具体修改RouteMap的逻辑如下

private static final String ROUTE_MAP_CLASS_NAME = "io.github.iamyours.router.RouteMap"
private static final String ROUTE_MAP_CLASS_FILE_NAME = ROUTE_MAP_CLASS_NAME.replaceAll("\\.", "/") + ".class"
private byte[] hackRouteMap(File jarFile) {
        ClassPool pool = ClassPool.getDefault()
        pool.insertClassPath(jarFile.absolutePath)
        CtClass ctClass = pool.get(ROUTE_MAP_CLASS_NAME)
        CtMethod method = ctClass.getDeclaredMethod("loadInto")
        StringBuffer code = new StringBuffer("{")
        for (String key : routeMap.keySet()) {
            String value = routeMap[key]
            code.append("\$1.put(\"" + key + "\",\"" + value + "\");")
        }
        code.append("}")
        method.setBody(code.toString())
        byte[] bytes = ctClass.toBytecode()
        ctClass.stopPruning(true)
        ctClass.defrost()
        return bytes
    }

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


RouteMap反编译信息

拷贝class和jar到输出目录

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

 @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        def sTime = System.currentTimeMillis()
        def inputs = transformInvocation.inputs
        def routeJarInput
        def outputProvider = transformInvocation.outputProvider
        outputProvider.deleteAll() //删除原有输出目录的文件
        for (TransformInput input : inputs) {
            for (DirectoryInput dirInput : input.directoryInputs) {
                readClassWithPath(dirInput.file)
                File dest = outputProvider.getContentLocation(dirInput.name,
                        dirInput.contentTypes,
                        dirInput.scopes,
                        Format.DIRECTORY)
                FileUtils.copyDirectory(dirInput.file, dest)
            }
            for (JarInput jarInput : input.jarInputs) {
                ...
                copyFile(jarInput, outputProvider)
            }
        }
        def eTime = System.currentTimeMillis()
        println("route map:" + routeMap)
        insertCodeIntoJar(routeJarInput, transformInvocation.outputProvider)

        println("===========route transform finished:" + (eTime - sTime))
    }
 void copyFile(JarInput jarInput, TransformOutputProvider outputProvider) {
        def dest = getDestFile(jarInput, outputProvider)
        FileUtils.copyFile(jarInput.file, dest)
    }

    static File getDestFile(JarInput jarInput, TransformOutputProvider outputProvider) {
        def destName = jarInput.name
        // 重名名输出文件,因为可能同名,会覆盖
        def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath)
        if (destName.endsWith(".jar")) {
            destName = destName.substring(0, destName.length() - 4)
        }
        // 获得输出文件
        File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
        return dest
    }

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


打印信息

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

项目地址

https://github.com/iamyours/SimpleRouter

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容