前言
之前通過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目錄如下圖
然后我們在app下的build.gradle引入插件
apply plugin: 'RouterPlugin'
然后make app,得到如下結果表明配置成功。
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)
}
}
}
可以得到如下信息
通過日志,我們可以得到以下信息:
- 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相關信息
從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。
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已經修改。
拷貝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的項目源碼。