文章背景
在做互聯網app項目的時候,當我們發佈迭代了一個新版本,把apk發佈到各個Android應用市場上時,由於程序猿或是程序媛在編碼上的疏忽,突然出現了一個緊急Bug時,通常的做法是重新打包,重新發布到各個應用市場,這不僅給公司相關部門增加大量工作量外,好比古時候皇帝下放一道緊急命令時,從州到縣到鎮到村,整條線都提着腦袋忙得不可交,搞的人心惶惶,而且更嚴重的是最終給用戶帶來的是重新下載覆蓋安裝,在一定程度上會流失用戶,嚴重影響了公司的用戶流量。在這種場景我們應該採用熱補丁動態修復技術來解決以上這些問題。可以選擇現成的第三方熱修復SDK,我在這裏不選擇的原因,主要出於兩點:1、使用第三方SDK有可能增大我們的項目包,而且總感覺受制於人;2、追逐技術進階
文章目標
Android類加載機制介紹
javassist動態修改字節碼
實現熱補丁動態修復
Android類加載機制
1.ClassLoader體系結構
2、如何加載一個類
我們先來看一下BaseDexClassLoader源碼中比較重要的code
根據截圖可以看到裏面有一個findClass方法,沒錯它就是根據類名來查找指定的某一個類。然後在該方法中調用了 DexPathList 實例的pathList.findClass(name, suppressedExceptions)的方法,我們進到這個方法看看
可以看出最終在此處找到了某一個類
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
到這裏我們可以直觀的看出該過程是基於android dex分包方案的。其實最終我們打包apk時可能有一個或是多個dex文件,默認是一個叫classes.dex的文件。不管是一個還是多個,都會一一對應一個Element,按順序排成一個有序的數組dexElements,當找類的時候,會按順序遍歷dex文件,然後從當前遍歷的dex文件中找類,如果找類則返回,如果找不到從下一個dex文件繼續查找。
按照這個原理,我們可以把有問題的類打包到一個dex(patch.dex)中去,然後把這個dex插入到Elements的最前面,當遍歷findClass的時候,我們修復的類就會被查找到,從而替代有bug的類即可,那麼下面來進行這一個過程的操作吧。
patch.dex補丁製作
新建一個Hotfix的工程,然後新建一個BugClass類
/*
* Copyright (C) 2015 Baidu, Inc. All Rights Reserved.
*/
package ydc.hotfix;
/**
* Created by sunpengfei on 15/11/3.
*/
public class BugClass {
public String bug() {
return "fix bug class";
}
}
在新建一個LoadBugClass類
/*
* Copyright (C) 2015 Baidu, Inc. All Rights Reserved.
*/
package ydc.hotfix;
/**
* Created by sunpengfei on 15/11/4.
*/
public class LoadBugClass {
public String getBugString() {
BugClass bugClass = new BugClass();
return bugClass.bug();
}
}
注意LoadBugClass應用了BugClass類。
然後在界面層是這樣調用的:
ok,假設我們把該apk發佈出去了,那麼用戶看到效果應該是“ 測試調用方法:fix bug class”。這個時候公司領導認爲這樣的提示對於用戶是致命的。那麼我們要把BugClass 類中的bug()方法中字符串替換一下,僅僅是修復一句話而已,實在沒有必要走打包發佈下放市場等複雜的流程。
public String bug() {
return "fix bug class";
}
ok,把這個有問題的地方修正爲:
public String bug() {
return "楊德成正在修復提示語fix bug class";
}
ok,我們把BugClass類使用dex工具單獨打包成path_dex.jar補丁包
Step
1、配置dex環境變量,最好是對應版本。
2、驗證dex
3、先把BugClass.class文件做成成jar,注意路徑,一定要定位到該位置執行以下命令:
jar cvf path.jar ydc/hotfix/BugClass.class
4、再把path.jar做成補丁包path_dex.jar,只有通過dex工具打包而成的文件才能被Android虛擬機(dexopt)執行。
依然在該路徑下執行以下命令:
dx --dex --output=path_dex.jar path.jar
5、我們把path_dex文件拷貝到assets目錄下
ok,這個時候我們可以開始來打補丁
Step
1、將我們的補丁包path_dex插入到上面提到的裝有dex的有序數組dexElements的最前面
首先我們看一下hotfix的源碼:
根據截圖所示,做了兩個動作
a、創建一個私有目錄,並把補丁包文件寫入到該目錄下
a1、 創建私有目錄
File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "path_dex.jar");
a2、文件讀寫方式把補丁包文件寫入到剛創建的私有目錄下
public class Utils {
private static final int BUF_SIZE = 2048;
public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) {
BufferedInputStream bis = null;
OutputStream dexWriter = null;
try {
bis = new BufferedInputStream(context.getAssets().open(dex_file));
dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));
byte[] buf = new byte[BUF_SIZE];
int len;
while ((len = bis.read(buf, 0, BUF_SIZE)) > 0) {
dexWriter.write(buf, 0, len);
}
dexWriter.close();
bis.close();
return true;
} catch (IOException e) {
if (dexWriter != null) {
try {
dexWriter.close();
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
if (bis != null) {
try {
bis.close();
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
return false;
}
}
}
b、path_dex插入到上面提到的裝有dex的有序數組dexElements的最前面
patch方法中的代碼如下:
public static void patch(Context context, String patchDexFile, String patchClassName) {
if (patchDexFile != null && new File(patchDexFile).exists()) {
try {
if (hasLexClassLoader()) {
injectInAliyunOs(context, patchDexFile, patchClassName);
} else if (hasDexClassLoader()) {
injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
} else {
injectBelowApiLevel14(context, patchDexFile, patchClassName);
}
} catch (Throwable th) {
}
}
}
根據代碼所示,這根據傳入的文件類型類類加載器ClassLoader的類型做了下判斷,根據上文提到過的ClassLoader 體系原理,我們的補丁包應該走的是hasDexClassLoader()分支,該方法代碼如下:
private static boolean hasDexClassLoader() {
try {
Class.forName("dalvik.system.BaseDexClassLoader");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
系統中肯定會存在”dalvik.system.BaseDexClassLoader”類,那麼接下來應該進入injectAboveEqualApiLevel14(context, patchDexFile, patchClassName)方法,代碼如下:
private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
getDexElements(getPathList(
new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));
Object a2 = getPathList(pathClassLoader);
setField(a2, a2.getClass(), "dexElements", a);
pathClassLoader.loadClass(str2);
}
根據Android系統源碼解讀源以上代碼
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
根據context拿到PathClassLoader,還記得這個類是用來幹嘛的嗎,上面已經提到過,再次提醒一下它是用來加載安裝到Android系統中的apk文件。既然這樣我們可以用它來得到沒有打補丁之前的dexElements有序數組對象
Step
a、getPathList(pathClassLoader)方法解讀:
private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
IllegalAccessException {
return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
根據以上代碼片段,可以看出這裏根據引用類名稱”BaseDexClassLoader”查找有個叫”pathList”屬性名的被引用類型。
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
上面這個片段通過反射找到對應的被引用類”DexPathList”,上個”BaseDexClassLoader”系統源碼:
b、getDexElements(getPathList(pathClassLoader))方法解讀:
private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
return getField(obj, obj.getClass(), "dexElements");
}
上面的這個代碼片段根據a步驟得到的DexPathList對象獲取到了沒有打補丁之前的dexElements有序數組對象
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
根據代碼可知依然使用反射原理獲取DexPathList對象中的有序數組dexElements。
DexPathList類系統源碼如下:
將我們的補丁包path_dex.jar轉化爲dexElements對象
Step
a、根據我們在上面所創建的私有目錄及私有文件,創建一個DexClassLoader,還記得這個來是用來幹嘛的嗎,上面已經提到到,再次提醒一下,用來加載從.jar文件內部加載classes.dex文件,沒錯我們要用它來加載我們的補丁包文件。
new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))
根據該類的系統源碼看出其實該類的構造函數並沒有做具體的事情
真正做之情的是它的直接父類BaseDexClassLoader的構造函數,如圖所示
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
看到沒,根據傳入參數初始化了我們補丁包對應的 DexPathList對象,注意這一步僅僅是初始化哦
b、getPathList(
new DexClassLoader(str, context.getDir(“dex”, 0).getAbsolutePath(), str, context.getClassLoader()))方法解讀:
private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
IllegalAccessException {
return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
上面這兩段代碼根據引用名”dalvik.system.BaseDexClassLoader”和被引用類屬性名”pathList”得到DexPathList對象
c、然後調用getDexElements方法
private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
return getField(obj, obj.getClass(), "dexElements");
}
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
上面的這兩端片段根據 DexPathList類及屬性名dexElements獲取到我們補丁包對應的有序數組dexElements
上面已經得到了兩個有序數組dexElements,一個存放的的是沒有打補丁之前的dex有序數組dexElements,另外一個是我們的補丁包對應的dex有序數組dexElements,那麼是不是到了該合併兩個數組的時候了呢,沒錯
Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
getDexElements(getPathList(
new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));
到這裏終於知道這整句代碼到底幹了什麼事情了,Object a 就是我們合併後的有序dex數組dexElements
合併過程如下:
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
int length = Array.getLength(obj2);
int length2 = Array.getLength(obj) + length;
Object newInstance = Array.newInstance(componentType, length2);
for (int i = 0; i < length2; i++) {
if (i < length) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
Array.set(newInstance, i, Array.get(obj, i - length));
}
}
return newInstance;
}
其實就是把補丁包對應的dex插入到原來有序數組dexElements的最前面了。
d、得到最新的”PathList”對象
Object a2 = getPathList(pathClassLoader);
e、重新設置DexPathList 的有序數組對象dexElements值
setField(a2, a2.getClass(), “dexElements”, a);
private static void setField(Object obj, Class cls, String str, Object obj2)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
declaredField.set(obj, obj2);
}
依然是使用反射機制設置新值。
f、加載我們有bug的類
pathClassLoader.loadClass(str2);
str參數是通過以下代碼傳入,即(ydc.hotfix.BugClass)
HotFix.patch(this, dexPath.getAbsolutePath(), "ydc.hotfix.BugClass");
這時候loadClass到的就是我們補丁包中的BugClass類了,這是因爲我們把補丁包對應的dex文件插入到dexElements最前面。所以找到就BugClass直接返回了,代碼如下:
public Class findClass(String name, List<Throwable> suppressed) {
317 for (Element element : dexElements) {
318 DexFile dex = element.dexFile;
319
320 if (dex != null) {
321 Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
322 if (clazz != null) {
323 return clazz;
324 }
325 }
326 }
327 if (dexElementsSuppressedExceptions != null) {
328 suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
329 }
330 return null;
331 }
按照我們之前的推論,到這裏應該就完成了補丁動態修復了,那麼真的是這樣的嗎,我們不防運行下項目看看。
很不幸,運行時報錯:
這是由於LoadBugClass引用了BugClass,但是發現這這兩個類所在的dex不在一起,其中:
1. LoadBugClass在classes.dex中
2. BugClass在path_dex.jar中
結果發生了錯誤。
究其原因是 pathClassLoader.loadClass(str2)的時候,會去校驗LoadBugClass所在的dex和BugClass所在的dex是否是同一個,不是則會報錯。那麼校驗的前提是有一個叫CLASS_ISPREVERIFIED的類標誌,如果引用者被打上這個標識,就會去校驗,就會導致報錯,那麼我們可以想象如果引用者LoadBugClass 沒被打上這個標識,是否就會運行通過了呢,沒錯,就是這個原理。
阻止LoadBugClass打上CLASS_ISPREVERIFIED標誌
我們應該知道LoadBugClass引用了BugClass,類加載器是先加載引用者,所以我在LoadBugClass的構造方法中來做這件事情,其實我們要做的就是動態的在構造方法中,引用一個別的類,然後把這個被引用類打包成一個單獨的dex文件。這樣就可以防止了LoadBugClass類被打上CLASS_ISPREVERIFIED的標誌了,那我們現在來開始做這件事情。
Step
1、動態被注入類的製作
a、新建一個hackdex的Module,我這裏來自HotFix的源碼,你也可以自己新建
b、在該Module之下,新建一個AntilazyLoad空類。
package dodola.hackdex;
/**
* Created by sunpengfei on 15/11/3.
*/
public class AntilazyLoad {
}
c、打包成單獨的dex文件,打包步驟完全等同於補丁包的製作,所以我這裏就不在走這個過程了,然後把它放置在assets下
d、依然要把這個dex文件插入到dexElements有序數組的中,插入原理和補丁包插入原理完全一致,而且這個dex文件需要在程序的入口進行插入,保證它是在有序數組的最前面,因爲我們要把該dex文件中的AntilazyLoad要動態注入到其它包裏面的某一個類的構造方法中。切記,dexElements裏面可以塞入無數個dex文件。
/**
* Created by sunpengfei on 15/11/4.
*/
public class HotfixApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");
HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
try {
this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
ok,下面就是如何注入的問題了,這個時候應該到了我們的AOP三劍客之一”javassist”閃亮登場了。
javassist實現動態代碼注入
javassist這貨是個好東西啊,它可以以無侵入的方式重構你的原代碼。我之前編寫過另外一個三劍客之一的文章,原理基本一樣。參考地址:http://blog.csdn.net/xinanheishao/article/details/74082605
Step
a、創建buildSrc模塊,這個項目是使用Groovy開發的,據說這貨具備Java, Javascript, Phython, Ruby等等語言的優點,而且Groovy依賴於Java的,和Java無縫掛接的,你可以到這裏下載SDK:http://groovy-lang.org/download.html;然後,配置path環境變量,Groovy的安裝挺簡單的,基本上和JDK的安裝差不多, 當然,這是Groovy自帶的最基本的開發工具,你可以查看它如何支持as的,如果是eclipse的話選擇菜單項“Help->Install New Software”之後重啓eclipse工具即可利用eclipse開發Groovy應用程序了,但是工程名一定要叫”buildSrc”,這裏我就直接使用了HotFix,你也可以自己構建,若你覺得閒麻煩,也可以下載我的demo裏面獲取。
b、導入javassist
apply plugin: 'groovy'
repositories {
mavenCentral()
}
dependencies {
compile gradleApi()
compile 'org.codehaus.groovy:groovy-all:2.3.6'
compile 'org.javassist:javassist:3.20.0-GA'
}
c、PatchClass 代碼截圖如下
其實很簡單的,這幾句的意思就是通過反射相關類,然後在相關類的構造方法中插入一句輸出語句。
CtClass c = classes.getCtClass("ydc.hotfix.BugClass")
if (c.isFrozen()) {
c.defrost()
}
println("====添加構造方法====")
def constructor = c.getConstructors()[0];
constructor.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
//constructor.insertBefore("System.out.println(888);")
c.writeFile(buildDir)
執行完這段代碼之後,也無形中應用了AntilazyLoad這個類。
d、這個工程不需要引用到主app(Module)中,只需要在 app->build.gradle中配置一個任務:
在配置一下,侵入時期
ok,總算把整個過程寫完了,準備開始運行了,不管你激不激動,反正本人是挺激動的了。
在運行之前,先看一下我們的引用者類
沒錯,可以確認這是我們的源代碼,化成灰我也可以認出它來。
在看一下運行之後的引用者類
沒錯,就是這個效果,我們的源碼被javassist 赤裸裸的侵犯了,是不是瞬間覺的自己的“東西”很不安全,這就是AOP編程的強大之處啊。
項目講解到這裏,我想估計沒有幾個人能有耐心的看到這裏來了,因爲覺得文章實在太長,需要有多大耐心才能扛到這裏,連我自己也懷疑自己如何寫出來的,不過我認爲,這麼強大而且實用的技術點,不是能夠三五兩語就能說清的,我們要有足夠的耐心來探索我們所不知的,有耐心,我們就有希望,有希望就不會失望!
ok,我們見證一下奇蹟。
看到這效果,我手已累,鍵盤已壞。。。。
Demo下載地址:
http://download.csdn.net/download/xinanheishao/9902530
演示環境:
demo導入不能正常運行,建議先調整環境,跑起來,再進階。
classpath 'com.android.tools.build:gradle:1.3.0'
#Thu Jul 13 16:40:06 CST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-2.2-all.zip
如果默認的jdk環境找不到,手動指向一下
如果你已經準備好足夠信心的話,可以按照文章,自己嘗試一方
最後感謝騰訊空間給出的解決方法思路和HotFix開源作者。
如果對你有所幫助的話,賞我1元奶粉錢吧,多謝!
微信:
支付寶: