基于Transform實現(xiàn)更高效的組件化路由框架

前言

之前通過APT實現(xiàn)了一個簡易版ARouter框架虐杯,碰到的問題是APT在每個module的上下文是不同的帅戒,導致需要通過不同的文件來保存映射關(guān)系表灯帮。因為類文件的不確定,就需要初始化時在dex文件中掃描到指定目錄下的class逻住,然后通過反射初始化加載路由關(guān)系映射钟哥。阿里的做法是直接開啟一個異步線程,創(chuàng)建DexFile對象加載dex瞎访。這多少會帶來一些性能損耗腻贰,為了避免這些,我們通過Transform api實現(xiàn)另一種更加高效的路由框架扒秸。

思路

gradle transform api可以用于android在構(gòu)建過程的class文件轉(zhuǎn)成dex文件之前播演,通過自定義插件,進行class字節(jié)碼處理伴奥。有了這個api写烤,我們就可以在apk構(gòu)建過程找到所有注解標記的class類,然后操作字節(jié)碼將這些映射關(guān)系寫到同一個class中拾徙。

自定義插件

首先我們需要自定義一個gradle插件洲炊,在application的模塊中使用它。為了能夠方便調(diào)試尼啡,我們?nèi)∠蟼鞑寮h(huán)節(jié)暂衡,直接新建一個名稱為buildSrc的library。
刪除src/main下的所有文件崖瞭,build.gradle配置中引入transform api和javassist(比asm更簡便的字節(jié)碼操作庫)

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下創(chuàng)建groovy文件夾古徒,在此文件夾下創(chuàng)建自己的包,然后新建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下創(chuàng)建resources/META-INF/gradle-plugins目錄读恃,在此目錄新建一個xxx.properties文件隧膘,文件名xxx就表示使用插件時的名稱(apply plugin 'xxx'),里面是具體插件的實現(xiàn)類

implementation-class=io.github.iamyours.RouterPlugin

整個buildSrc目錄如下圖


buildSrc目錄

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

apply plugin: 'RouterPlugin'

然后make app,得到如下結(jié)果表明配置成功代态。


image.png

router-api

在使用Transform api之前,創(chuàng)建一個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(取名這個是為了方便重構(gòu))

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創(chuàng)建導入映射萨驶,后面只需要修改字節(jié)碼歉摧,替換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包
  • 其他第三發(fā)依賴在EXTERNAL_LIBRARIES下,也是通過jar形式仗岖,name和implementation依賴的名稱相同逃延。
    知道這些信息,遍歷查找Route注解生命的activity以及修改RouteMap范圍就確定了箩帚。我們在directoryInputs中目錄中遍歷查找app模塊的activity真友,在jarInputs下scopes為SUB_PROJECTS中查找其他模塊的activity,然后在name為router-api的jar上修改RouteMap的字節(jié)碼紧帕。

ASM字節(jié)碼讀取

有了class目錄盔然,就可以動手操作字節(jié)碼了。主要有兩種方式是嗜,ASM愈案、javassist。兩個都可以實現(xiàn)讀寫操作鹅搪。ASM是基于指令級別的站绪,性能更好更快,但是寫入時你需要知道java虛擬機的一些指令丽柿,門檻較高恢准。而javassist操作更佳簡便魂挂,可以通過字符串寫代碼,然后轉(zhuǎn)換成對應(yīng)的字節(jié)碼馁筐⊥空伲考慮到性能,讀取時用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的相關(guān)信息盟迟,包括類信息秋泳,注解信息。以下是我通過idea debug得到的ASM相關(guān)信息


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

所有的路由信息我們已經(jīng)通過ASM讀取保存了,接下來只要操作RouteMap的字節(jié)碼概龄,將這些信息保存到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已經(jīng)修改锣光。


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


打印信息

而具體的路由跳轉(zhuǎn)就不細說了频丘,具體可以看github的項目源碼。

項目地址

https://github.com/iamyours/SimpleRouter

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末泡态,一起剝皮案震驚了整個濱河市搂漠,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌某弦,老刑警劉巖桐汤,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件而克,死亡現(xiàn)場離奇詭異,居然都是意外死亡怔毛,警方通過查閱死者的電腦和手機拍摇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來馆截,“玉大人充活,你說我怎么就攤上這事±ⅲ” “怎么了混卵?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長窖张。 經(jīng)常有香客問我幕随,道長,這世上最難降的妖魔是什么宿接? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任赘淮,我火速辦了婚禮,結(jié)果婚禮上睦霎,老公的妹妹穿的比我還像新娘梢卸。我一直安慰自己,他們只是感情好副女,可當我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布蛤高。 她就那樣靜靜地躺著,像睡著了一般碑幅。 火紅的嫁衣襯著肌膚如雪戴陡。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天沟涨,我揣著相機與錄音恤批,去河邊找鬼。 笑死裹赴,一個胖子當著我的面吹牛喜庞,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播篮昧,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼赋荆,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了懊昨?” 一聲冷哼從身側(cè)響起窄潭,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后嫉你,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體月帝,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年幽污,在試婚紗的時候發(fā)現(xiàn)自己被綠了嚷辅。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡距误,死狀恐怖簸搞,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情准潭,我是刑警寧澤趁俊,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布,位于F島的核電站刑然,受9級特大地震影響寺擂,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜泼掠,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一怔软、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧择镇,春花似錦挡逼、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽叹谁。三九已至饲梭,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間焰檩,已是汗流浹背憔涉。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留析苫,地道東北人兜叨。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像衩侥,于是被迫代替她去往敵國和親国旷。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容