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