Android進階從字節(jié)碼插樁技術了解美團熱修復實例詳解
引言
熱修復技術如今已經(jīng)不是一個新穎的技術,很多公司都在用,而且像阿里、騰訊等互聯(lián)網(wǎng)巨頭都有自己的熱修復框架,像阿里的AndFix采用的是hook native底層修改代碼指令集的方式;騰訊的Tinker采用類加載的方式修改dexElement;而美團則是采用字節(jié)碼插樁的方式,也就是本文將介紹的一種技術手段。
我們知道,如果上線出現(xiàn)bug,通常是發(fā)生在方法的調用階段,某個方法異常導致崩潰;字節(jié)碼插樁,就是在編譯階段將一段代碼插入該方法中,如果線上崩潰,需要發(fā)布補丁包,同時在執(zhí)行該方法時,如果檢測到補丁包的存在,將會走插樁插入的邏輯,而不是原邏輯。
如果想要知道美團實現(xiàn)的熱修復框架原理,那么首先需要知道,robust該怎么用

對于每個模塊,如果想要插樁需要引入robust插件,所以如果自己實現(xiàn)一個簡單的robust的功能,就需要創(chuàng)建一個插件,然后在插件中處理邏輯,我個人喜歡在buildSrc里寫插件然后發(fā)布,當然也可以自己創(chuàng)建一個java工程改造成groovy工程
plugins {
id 'groovy'
id 'maven-publish'
}
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'com.android.tools.build:gradle:3.1.2'
}
如果創(chuàng)建一個java模塊,如果要【改裝】成一個groovy工程,就需要做上述的配置??
1 插件發(fā)布
初始化之后,我一般會先建2個文件夾

plugin用于自定義插件,定義輸入輸出; task用于任務執(zhí)行。
class MyRobustPlugin implements Plugin<Project>{
@Override
void apply(Project project) {
//項目配置階段執(zhí)行,配置完成之后,
project.afterEvaluate {
println '插件開始執(zhí)行了'
}
}
}
如果需要發(fā)布插件到maven倉庫,或者放在本地,可以通過maven-publish(gradle 7.0+)插件來實現(xiàn)
afterEvaluate {
publishing {
publications{
releaseType(MavenPublication){
from components.java
groupId 'com.demo'
artifactId 'robust'
version '0.0.1'
}
}
repositories {
maven {
url uri('../repo')
}
}
}
}
publications:這里可以添加你要發(fā)布的maven版本配置 repositories:maven倉庫的地址,這里就是寫在本地一個文件夾

重新編譯之后,在publish文件夾下會生成很多任務,執(zhí)行發(fā)布到maven倉庫的任務,就會在本地的repo文件夾下生成對應的jar包

接下來我們嘗試用下這個插件
buildscript {
repositories {
google()
mavenCentral()
jcenter()
//這里配置了我們的插件依賴的本地倉庫地址
maven {
url uri('repo')
}
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.3"
classpath "com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10"
classpath "com.demo:robust:0.0.1"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
配置完成后,在app模塊添加插件依賴
apply plugin:'com.demo'
這里會報錯,com.demo這個插件id找不到,原因就是,其實插件是一個jar包,然后我們只是創(chuàng)建了這個插件,并沒有聲明入口,在編譯jar包時找不到清單文件,因此需要在資源文件夾下聲明清單文件

implementation-class=com.tal.robust.plugin.MyRobustPlugin
創(chuàng)建插件名字的屬性文件,聲明插件的入口,就是我們自己定義的插件,再次編譯運行

這也意味著,我們的插件執(zhí)行成功了,所以準備工作已完成,如果需要插樁的模塊,那么就需要依賴這個插件
2 Javassist
Javassist號稱字節(jié)碼手術刀,能夠在class文件生成之后,打包成dex文件之前就將我們自定義的代碼插入某個位置,例如在getClassId方法第62行代碼的位置,插入邏輯判斷代碼

2.1 準備工作
引入Javassist,插件工程引入Javassist
implementation 'org.javassist:javassist:3.20.0-GA'
2.2 Transform

Javassist作用于class文件生成之后,在dex文件生成之前,所以如果想要對字節(jié)碼做處理,就需要在這個階段執(zhí)行代碼插入,這里就涉及到了一個概念 --- transform;
Android官方對于transform做出的定義就是:Transform用于在class打包成dex這個中間過程,對字節(jié)碼做修改

在build文件夾中,我們可以看到這些文件夾,像merged_assets、merged_java_res等,這是Gradle的Transform,用于打包資源文件到apk文件中,執(zhí)行的順序為串行執(zhí)行,一個任務的輸出為下一個任務的輸入,而在transforms文件夾下就是我們自己定義的transform
implementation 'com.android.tools.build:transform-api:1.5.0'
導入Transform依賴????
class MyRobustTransform extends Transform{
/**
* 在transforms文件夾下的文件夾名字
* @return
*/
@Override
String getName() {
return "MyRobust"
}
/**
* Transform要處理的輸入文件類型 : 字節(jié)碼
* @return
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* 作用域:整個項目
* @return
*/
@Override
Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
/**
* 是否為增量編譯
* @return
*/
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws IOException, TransformException, InterruptedException {
}
}
如何讓自定義的Transform生效,需要在插件中注冊這個Transform
@Override
void apply(Project project) {
println '插件開始執(zhí)行了'
//注冊Transform
def ext = project.extensions.getByType(AppExtension)
if(ext != null){
ext.registerTransform(new MyRobustTransform(project));
}
}
對于每個模塊,Gradle編譯時都是創(chuàng)建一個Project對象,這里就是拿到了當前模塊gradle中的android擴展,然后調用了registerTransform函數(shù)注冊Transform,MyRobustTransform中的transform函數(shù)會被調用,將class、jar、resource等文件做處理

把一開始的流程圖細分一下,其實class字節(jié)碼在處理的時候是經(jīng)歷了多個transform,這里可以把transform看做是任務,每個任務執(zhí)行完成之后,都將輸出交由下一個task作為輸入,我們自定義的transform是被放在transform鏈的頭部
Task :app:transformClassesWithMyRobustForDebug

2.3 transform函數(shù)注入代碼
OK,我們注冊完成之后,這個Transform任務就能夠執(zhí)行了,執(zhí)行的時候,會執(zhí)行transform函數(shù)中的代碼,我們注入代碼也是在這個函數(shù)中進行
@Override
void transform(TransformInvocation transformInvocation) {
super.transform(transformInvocation)
println "transform start"
transformInvocation.inputs.each { input ->
//對于class字節(jié)碼,需要處理
input.directoryInputs.each { dic ->
println "dic路徑 $dic.file.absolutePath"
classPool.appendClassPath(dic.file.absolutePath)
//插入代碼 -- javassist
//找到class在哪,需要遍歷class
findTargetClass(dic.file, dic.file.absolutePath)
def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(dic.file, nextTransform)
}
//對jar包不處理,直接扔給下一個Transform
input.jarInputs.each { jar ->
println "jar包路徑 $jar.file.absolutePath"
classPool.appendClassPath(jar.file.absolutePath)
def nextTransform = transformInvocation.outputProvider.getContentLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR)
FileUtils.copyFile(jar.file, nextTransform)
}
}
println "transform end"
}
在transform函數(shù)中有一個參數(shù)TransformInvocation,能夠獲取輸入,因為自定義transform是放在頭部,所以能夠獲取到的就是jar包、class字節(jié)碼等資源,如下:
public interface TransformInput {
/**
* Returns a collection of {@link JarInput}.
*/
@NonNull
Collection<JarInput> getJarInputs();
/**
* Returns a collection of {@link DirectoryInput}.
*/
@NonNull
Collection<DirectoryInput> getDirectoryInputs();
}
2.3.1 Jar包處理
對于jar包,我們不需要處理,直接作為輸出扔給下一級的transform處理,那么如何獲取到輸出,就是通過TransformInvocation獲取TransformOutputProvider,獲取輸出文件的位置,將jar包拷貝進去即可
//對jar包不處理,直接扔給下一個Transform
input.jarInputs.each { jar ->
println "jar包路徑 $jar.file.absolutePath"
classPool.appendClassPath(jar.file.absolutePath)
def nextTransform = transformInvocation.outputProvider.getContentLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR)
FileUtils.copyFile(jar.file, nextTransform)
}
2.3.2 字節(jié)碼處理
對于字節(jié)碼處理,transform拿到的就是javac文件夾下的全部class文件

通過日志打印就能得知,只從這個位置取class文件

//對于class字節(jié)碼,需要處理
input.directoryInputs.each { dic ->
println "dic路徑 $dic.file.absolutePath"
classPool.appendClassPath(dic.file.absolutePath)
//插入代碼 -- javassist
//找到class在哪,需要遍歷class
findTargetClass(dic.file, dic.file.absolutePath)
def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(dic.file, nextTransform)
}
在拿到classes文件夾根目錄之后,只需要遞歸遍歷這個文件夾,然后拿到全部的class文件,執(zhí)行代碼插入
/**
* 遞歸查找class文件
* @param file classes文件夾
* @param fileName ../build/javac/debug/classes 路徑名
*/
private void findTargetClass(File file, String fileName) {
//遞歸查找
if (file.isDirectory()) {
file.listFiles().each {
findTargetClass(it, fileName)
}
} else {
//如果是文件
modify(file, fileName)
}
}
遞歸查找,我們拿本小節(jié)開始的那個圖,如果拿到了BuildConfig.class文件,那么就需要獲取當前字節(jié)碼文件的全類名,然后從字節(jié)碼池子中獲取這個字節(jié)碼信息
/**
* 獲取字節(jié)碼文件全類名
* @param file BuildConfig.class
* @param fileName ../build/javac/debug/classes 路徑名
*/
private void modify(File file, String fileName) {
def fullName = file.absolutePath
if (!fullName.endsWith(SdkConstants.DOT_CLASS)) {
return
}
if (fileName.contains("BuildConfig.class") || fileName.contains("R")) {
return
}
//獲取當前class的全類名 com.tal.demo02.MainActivity.class
def temp = fullName.replace(fileName, "").replace("/", ".")
def className = temp.replace(SdkConstants.DOT_CLASS, "").substring(1)
println "className $className"
//從字節(jié)碼池中找到ctClass
def ctClass = classPool.get(className)
if (className.contains("com.tal.demo02")) {
//如果是在當前這個包名下的類,才會執(zhí)行插樁操作
insertCode(ctClass, fileName)
}
}
怎么獲取字節(jié)碼文件的全類名,其實這里是用了一個取巧的方式,因此我們能拿到字節(jié)碼文件所在的絕對路徑,然后把classes文件夾路徑去掉,將 / 替換為 . ,然后再把.class后綴去掉,就拿到了全類名。
2.4 Javassist織入代碼
前面我們已經(jīng)拿到了字節(jié)碼的全類名,那么就可以從Javassist提供的ClassPool字節(jié)碼池中,通過全類名獲取CtClass,CtClass包含了當前字節(jié)碼的全部信息,可以通過類似反射的方式,來獲取方法、參數(shù)等屬性,加以構造
2.4.1 ClassPool
ClassPool可以看做是一個字節(jié)碼池,在ClassPool中維護了一個Hashtable,key為類的名字也就是全類名,通過全類名能夠獲取CtClass
public ClassPool(ClassPool parent) {
this.classes = new Hashtable(INIT_HASH_SIZE);
this.source = new ClassPoolTail();
this.parent = parent;
if (parent == null) {
CtClass[] pt = CtClass.primitiveTypes;
for (int i = 0; i < pt.length; ++i)
classes.put(pt[i].getName(), pt[i]);
}
this.cflow = null;
this.compressCount = 0;
clearImportedPackages();
}
在遍歷輸入文件的時候,我們把字節(jié)碼的路徑添加到ClassPool中,那么在查找的時候(調用get方法),其實就是從這個路徑下查找字節(jié)碼文件,如果查找到了就返回CtClass
classPool.appendClassPath(jar.file.absolutePath)
2.4.2 CtClass
通過CtClass能夠像使用反射的方式那樣獲取方法CtMethod
private void insertCode(CtClass ctClass, String fileName) {
//拿到了這個類,需要反射獲取方法,在某些方法下面加
try {
def method = ctClass.getDeclaredMethod("getClassId")
if(method != null){
//在這個方法之前插入
method.insertBefore("if(a > 0){\n" +
" \n" +
" return \"\";\n" +
" }")
ctClass.writeFile(fileName)
}
}catch(Exception e){
}finally{
ctClass.detach()
}
}
通過CtMethod可以設置,在方法之前、方法之后、或者方法中某個行號中插入代碼,最終通過CtClass的writeFile方法,將字節(jié)碼重新規(guī)整,最終像處理Jar文件一樣,將處理的文件交給下一級的transform處理。
最終可以看一下效果,在MainActivity中一個getClassId方法,一開始只是返回了id_0009989799,我們將一部分代碼織入后,字節(jié)碼變成下面的樣子。
public String getClassId() {
return this.a > 0 ? "" : "id_0009989799";
}
所以,美團Robust在熱修復時,是以同樣的方式(美團采用的是ASM字節(jié)碼插樁,本文使用的是Javassist),在每個方法中織入了一段判斷邏輯代碼,當線上出現(xiàn)問題之后,通過某種方式使得代碼執(zhí)行這個判斷邏輯,實現(xiàn)了即時修復
以上就是Android進階從字節(jié)碼插樁技術了解美團熱修復實例詳解的詳細內容,更多關于Android 美團熱修復的資料請關注腳本之家其它相關文章!
相關文章
Android編程實現(xiàn)通過反射獲取資源Id的方法
這篇文章主要介紹了Android編程實現(xiàn)通過反射獲取資源Id的方法,結合實例形式分析了Android反射機制操作資源的相關實現(xiàn)技巧,需要的朋友可以參考下2018-01-01
Android編程將Activity背景設置為墻紙的簡單實現(xiàn)方法
這篇文章主要介紹了Android編程將Activity背景設置為墻紙的簡單實現(xiàn)方法,涉及Android簡單的屬性設置及XML配置修改等相關技巧,具有一定參考借鑒價值,需要的朋友可以參考下2015-10-10
Android SharedPreference存儲文件三步走
SharedPreferences是安卓平臺上一個輕量級的存儲類,用來保存應用的一些常用配置,比如Activity狀態(tài),Activity暫停時,將此activity的狀態(tài)保存到SharedPereferences中;當Activity重載,系統(tǒng)回調方法onSaveInstanceState時,再從SharedPreferences中將值取出2023-01-01
月下載量上千次Android實現(xiàn)二維碼生成器app源碼分享
既然是二維碼生成器那么我們如何制作二維碼呢?這篇文章為大家分享了月下載量上千次Android實現(xiàn)二維碼生成器app源碼,希望大家喜歡2015-12-12

