Android進(jìn)階從字節(jié)碼插樁技術(shù)了解美團(tuán)熱修復(fù)實(shí)例詳解
引言
熱修復(fù)技術(shù)如今已經(jīng)不是一個(gè)新穎的技術(shù),很多公司都在用,而且像阿里、騰訊等互聯(lián)網(wǎng)巨頭都有自己的熱修復(fù)框架,像阿里的AndFix采用的是hook native底層修改代碼指令集的方式;騰訊的Tinker采用類加載的方式修改dexElement;而美團(tuán)則是采用字節(jié)碼插樁的方式,也就是本文將介紹的一種技術(shù)手段。
我們知道,如果上線出現(xiàn)bug,通常是發(fā)生在方法的調(diào)用階段,某個(gè)方法異常導(dǎo)致崩潰;字節(jié)碼插樁,就是在編譯階段將一段代碼插入該方法中,如果線上崩潰,需要發(fā)布補(bǔ)丁包,同時(shí)在執(zhí)行該方法時(shí),如果檢測(cè)到補(bǔ)丁包的存在,將會(huì)走插樁插入的邏輯,而不是原邏輯。
如果想要知道美團(tuán)實(shí)現(xiàn)的熱修復(fù)框架原理,那么首先需要知道,robust該怎么用
對(duì)于每個(gè)模塊,如果想要插樁需要引入robust插件,所以如果自己實(shí)現(xiàn)一個(gè)簡(jiǎn)單的robust的功能,就需要?jiǎng)?chuàng)建一個(gè)插件,然后在插件中處理邏輯,我個(gè)人喜歡在buildSrc里寫插件然后發(fā)布,當(dāng)然也可以自己創(chuàng)建一個(gè)java工程改造成groovy工程
plugins { id 'groovy' id 'maven-publish' } dependencies { implementation gradleApi() implementation localGroovy() implementation 'com.android.tools.build:gradle:3.1.2' }
如果創(chuàng)建一個(gè)java模塊,如果要【改裝】成一個(gè)groovy工程,就需要做上述的配置??
1 插件發(fā)布
初始化之后,我一般會(huì)先建2個(gè)文件夾
plugin用于自定義插件,定義輸入輸出; task用于任務(wù)執(zhí)行。
class MyRobustPlugin implements Plugin<Project>{ @Override void apply(Project project) { //項(xiàng)目配置階段執(zhí)行,配置完成之后, project.afterEvaluate { println '插件開(kāi)始執(zhí)行了' } } }
如果需要發(fā)布插件到maven倉(cāng)庫(kù),或者放在本地,可以通過(guò)maven-publish(gradle 7.0+)插件來(lái)實(shí)現(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倉(cāng)庫(kù)的地址,這里就是寫在本地一個(gè)文件夾
重新編譯之后,在publish文件夾下會(huì)生成很多任務(wù),執(zhí)行發(fā)布到maven倉(cāng)庫(kù)的任務(wù),就會(huì)在本地的repo文件夾下生成對(duì)應(yīng)的jar包
接下來(lái)我們嘗試用下這個(gè)插件
buildscript { repositories { google() mavenCentral() jcenter() //這里配置了我們的插件依賴的本地倉(cāng)庫(kù)地址 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'
這里會(huì)報(bào)錯(cuò),com.demo這個(gè)插件id找不到,原因就是,其實(shí)插件是一個(gè)jar包,然后我們只是創(chuàng)建了這個(gè)插件,并沒(méi)有聲明入口,在編譯jar包時(shí)找不到清單文件,因此需要在資源文件夾下聲明清單文件
implementation-class=com.tal.robust.plugin.MyRobustPlugin
創(chuàng)建插件名字的屬性文件,聲明插件的入口,就是我們自己定義的插件,再次編譯運(yùn)行
這也意味著,我們的插件執(zhí)行成功了,所以準(zhǔn)備工作已完成,如果需要插樁的模塊,那么就需要依賴這個(gè)插件
2 Javassist
Javassist號(hào)稱字節(jié)碼手術(shù)刀,能夠在class文件生成之后,打包成dex文件之前就將我們自定義的代碼插入某個(gè)位置,例如在getClassId方法第62行代碼的位置,插入邏輯判斷代碼
2.1 準(zhǔn)備工作
引入Javassist,插件工程引入Javassist
implementation 'org.javassist:javassist:3.20.0-GA'
2.2 Transform
Javassist作用于class文件生成之后,在dex文件生成之前,所以如果想要對(duì)字節(jié)碼做處理,就需要在這個(gè)階段執(zhí)行代碼插入,這里就涉及到了一個(gè)概念 --- transform;
Android官方對(duì)于transform做出的定義就是:Transform用于在class打包成dex這個(gè)中間過(guò)程,對(duì)字節(jié)碼做修改
在build文件夾中,我們可以看到這些文件夾,像merged_assets、merged_java_res等,這是Gradle的Transform,用于打包資源文件到apk文件中,執(zhí)行的順序?yàn)榇袌?zhí)行,一個(gè)任務(wù)的輸出為下一個(gè)任務(wù)的輸入,而在transforms文件夾下就是我們自己定義的transform
implementation 'com.android.tools.build:transform-api:1.5.0'
導(dǎo)入Transform依賴????
class MyRobustTransform extends Transform{ /** * 在transforms文件夾下的文件夾名字 * @return */ @Override String getName() { return "MyRobust" } /** * Transform要處理的輸入文件類型 : 字節(jié)碼 * @return */ @Override Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS } /** * 作用域:整個(gè)項(xiàng)目 * @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生效,需要在插件中注冊(cè)這個(gè)Transform
@Override void apply(Project project) { println '插件開(kāi)始執(zhí)行了' //注冊(cè)Transform def ext = project.extensions.getByType(AppExtension) if(ext != null){ ext.registerTransform(new MyRobustTransform(project)); } }
對(duì)于每個(gè)模塊,Gradle編譯時(shí)都是創(chuàng)建一個(gè)Project對(duì)象,這里就是拿到了當(dāng)前模塊gradle中的android擴(kuò)展,然后調(diào)用了registerTransform函數(shù)注冊(cè)Transform,MyRobustTransform中的transform函數(shù)會(huì)被調(diào)用,將class、jar、resource等文件做處理
把一開(kāi)始的流程圖細(xì)分一下,其實(shí)class字節(jié)碼在處理的時(shí)候是經(jīng)歷了多個(gè)transform,這里可以把transform看做是任務(wù),每個(gè)任務(wù)執(zhí)行完成之后,都將輸出交由下一個(gè)task作為輸入,我們自定義的transform是被放在transform鏈的頭部
Task :app:transformClassesWithMyRobustForDebug
2.3 transform函數(shù)注入代碼
OK,我們注冊(cè)完成之后,這個(gè)Transform任務(wù)就能夠執(zhí)行了,執(zhí)行的時(shí)候,會(huì)執(zhí)行transform函數(shù)中的代碼,我們注入代碼也是在這個(gè)函數(shù)中進(jìn)行
@Override void transform(TransformInvocation transformInvocation) { super.transform(transformInvocation) println "transform start" transformInvocation.inputs.each { input -> //對(duì)于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) } //對(duì)jar包不處理,直接扔給下一個(gè)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ù)中有一個(gè)參數(shù)TransformInvocation,能夠獲取輸入,因?yàn)樽远xtransform是放在頭部,所以能夠獲取到的就是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包處理
對(duì)于jar包,我們不需要處理,直接作為輸出扔給下一級(jí)的transform處理,那么如何獲取到輸出,就是通過(guò)TransformInvocation獲取TransformOutputProvider,獲取輸出文件的位置,將jar包拷貝進(jìn)去即可
//對(duì)jar包不處理,直接扔給下一個(gè)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é)碼處理
對(duì)于字節(jié)碼處理,transform拿到的就是javac文件夾下的全部class文件
通過(guò)日志打印就能得知,只從這個(gè)位置取class文件
//對(duì)于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文件夾根目錄之后,只需要遞歸遍歷這個(gè)文件夾,然后拿到全部的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é)開(kāi)始的那個(gè)圖,如果拿到了BuildConfig.class文件,那么就需要獲取當(dāng)前字節(jié)碼文件的全類名,然后從字節(jié)碼池子中獲取這個(gè)字節(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 } //獲取當(dāng)前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")) { //如果是在當(dāng)前這個(gè)包名下的類,才會(huì)執(zhí)行插樁操作 insertCode(ctClass, fileName) } }
怎么獲取字節(jié)碼文件的全類名,其實(shí)這里是用了一個(gè)取巧的方式,因此我們能拿到字節(jié)碼文件所在的絕對(duì)路徑,然后把classes文件夾路徑去掉,將 / 替換為 . ,然后再把.class后綴去掉,就拿到了全類名。
2.4 Javassist織入代碼
前面我們已經(jīng)拿到了字節(jié)碼的全類名,那么就可以從Javassist提供的ClassPool字節(jié)碼池中,通過(guò)全類名獲取CtClass,CtClass包含了當(dāng)前字節(jié)碼的全部信息,可以通過(guò)類似反射的方式,來(lái)獲取方法、參數(shù)等屬性,加以構(gòu)造
2.4.1 ClassPool
ClassPool可以看做是一個(gè)字節(jié)碼池,在ClassPool中維護(hù)了一個(gè)Hashtable,key為類的名字也就是全類名,通過(guò)全類名能夠獲取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(); }
在遍歷輸入文件的時(shí)候,我們把字節(jié)碼的路徑添加到ClassPool中,那么在查找的時(shí)候(調(diào)用get方法),其實(shí)就是從這個(gè)路徑下查找字節(jié)碼文件,如果查找到了就返回CtClass
classPool.appendClassPath(jar.file.absolutePath)
2.4.2 CtClass
通過(guò)CtClass能夠像使用反射的方式那樣獲取方法CtMethod
private void insertCode(CtClass ctClass, String fileName) { //拿到了這個(gè)類,需要反射獲取方法,在某些方法下面加 try { def method = ctClass.getDeclaredMethod("getClassId") if(method != null){ //在這個(gè)方法之前插入 method.insertBefore("if(a > 0){\n" + " \n" + " return \"\";\n" + " }") ctClass.writeFile(fileName) } }catch(Exception e){ }finally{ ctClass.detach() } }
通過(guò)CtMethod可以設(shè)置,在方法之前、方法之后、或者方法中某個(gè)行號(hào)中插入代碼,最終通過(guò)CtClass的writeFile方法,將字節(jié)碼重新規(guī)整,最終像處理Jar文件一樣,將處理的文件交給下一級(jí)的transform處理。
最終可以看一下效果,在MainActivity中一個(gè)getClassId方法,一開(kāi)始只是返回了id_0009989799,我們將一部分代碼織入后,字節(jié)碼變成下面的樣子。
public String getClassId() { return this.a > 0 ? "" : "id_0009989799"; }
所以,美團(tuán)Robust在熱修復(fù)時(shí),是以同樣的方式(美團(tuán)采用的是ASM字節(jié)碼插樁,本文使用的是Javassist),在每個(gè)方法中織入了一段判斷邏輯代碼,當(dāng)線上出現(xiàn)問(wèn)題之后,通過(guò)某種方式使得代碼執(zhí)行這個(gè)判斷邏輯,實(shí)現(xiàn)了即時(shí)修復(fù)
以上就是Android進(jìn)階從字節(jié)碼插樁技術(shù)了解美團(tuán)熱修復(fù)實(shí)例詳解的詳細(xì)內(nèi)容,更多關(guān)于Android 美團(tuán)熱修復(fù)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
學(xué)習(xí)使用Android Chronometer計(jì)時(shí)器
Chronometer是一個(gè)簡(jiǎn)單的計(jì)時(shí)器,你可以給它一個(gè)開(kāi)始時(shí)間,并以計(jì)時(shí),或者如果你不給它一個(gè)開(kāi)始時(shí)間,它將會(huì)使用你的時(shí)間通話開(kāi)始,這篇文章主要幫助大家學(xué)習(xí)掌握使用Android Chronometer計(jì)時(shí)器,感興趣的小伙伴們可以參考一下2016-04-04Android textview 實(shí)現(xiàn)長(zhǎng)按自由選擇復(fù)制功能的方法
下面小編就為大家?guī)?lái)一篇Android textview 實(shí)現(xiàn)長(zhǎng)按自由選擇復(fù)制功能的方法。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-04-04rxjava+retrofit實(shí)現(xiàn)多圖上傳實(shí)例代碼
本篇文章主要介紹了rxjava+retrofit實(shí)現(xiàn)多圖上傳實(shí)例代碼,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-06-06Android自動(dòng)獲取輸入短信驗(yàn)證碼庫(kù)AutoVerifyCode詳解
這篇文章主要為大家詳細(xì)介紹了Android自動(dòng)獲取輸入短信驗(yàn)證碼庫(kù),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-07-07Android?Flutter實(shí)現(xiàn)創(chuàng)意時(shí)鐘的示例代碼
時(shí)鐘這個(gè)東西很奇妙,總能當(dāng)做創(chuàng)意實(shí)現(xiàn)的入口。這篇文章主要介紹了如何通過(guò)Android?Flutter實(shí)現(xiàn)一個(gè)創(chuàng)意時(shí)鐘,感興趣的小伙伴可以了解一下2023-03-03RecyclerView 源碼淺析測(cè)量 布局 繪制 預(yù)布局
這篇文章主要介紹了RecyclerView 源碼淺析測(cè)量 布局 繪制 預(yù)布局,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12