基於Transform實現更高效的組件化路由框架

前言

以前經過APT實現了一個簡易版ARouter框架,碰到的問題是APT在每一個module的上下文是不一樣的,致使須要經過不一樣的文件來保存映射關係表。由於類文件的不肯定,就須要初始化時在dex文件中掃描到指定目錄下的class,而後經過反射初始化加載路由關係映射。阿里的作法是直接開啓一個異步線程,建立DexFile對象加載dex。這多少會帶來一些性能損耗,爲了不這些,咱們經過Transform api實現另外一種更加高效的路由框架。java

思路

gradle transform api能夠用於android在構建過程的class文件轉成dex文件以前,經過自定義插件,進行class字節碼處理。有了這個api,咱們就能夠在apk構建過程找到全部註解標記的class類,而後操做字節碼將這些映射關係寫到同一個class中。node

自定義插件

首先咱們須要自定義一個gradle插件,在application的模塊中使用它。爲了可以方便調試,咱們取消上傳插件環節,直接新建一個名稱爲buildSrc的library。 刪除src/main下的全部文件,build.gradle配置中引入transform api和javassist(比asm更簡便的字節碼操做庫)android

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的文件git

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'),裏面是具體插件的實現類github

implementation-class=io.github.iamyours.RouterPlugin
複製代碼

整個buildSrc目錄以下圖 api

buildSrc目錄
而後咱們在app下的build.gradle引入插件

apply plugin: 'RouterPlugin'
複製代碼

而後make app,獲得以下結果代表配置成功。 bash

image.png

router-api

在使用Transform api以前,建立一個router-api的java module處理路由邏輯。app

## 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的項目源碼。

項目地址

github.com/iamyours/Si…

相關文章
相關標籤/搜索