Android熱補丁動態修複實踐
前言
好幾個月之前關於Android App熱補丁修複火了一把,源於QQ空間團隊的一篇文章安卓App熱補丁動態修複技術介紹,然後各大廠的開源項目都出來了,本文的實踐基於HotFix,也就是QQ空間技術團隊那篇文章所應用的技術,筆者會把整個過程的細節和思路在文章中詳說,研究這個的出發點也是為了能緊急修複app的bug,而不需要重複發包,不需要使用者重新下載app就能把問題解決,個人覺得這個還是蠻有價值的,雖然老闆不知道….。
項目結構
這裡筆者建立一個新的項目”HotFixDemo”,帶大家一步一步來完成Hotfix這個架構實現,這個項目包含以下module:
- app :我們的Android應用程式Module。
- buildsrc :使用Groovy實現的項目,提供了一個類,用來實現修改class檔案的操作。
- hackdex :提供了一個類,後面會用來打包成hack.dex,也是buildsrc裡面實現在所有類的建構函式插入的一段代碼所引用到的類。
- hotfixlib :這個module最終會被app關聯,裡面提供實現熱補丁的核心方法
這個Demo裡面的代碼跟HotFix架構基本無異,主要是告訴大家它實現的過程,如果光看代碼,不實踐是無法把它應用到你自己的app上去的,因為有很多比較深入的知識需要你去理解。
先說原理
關於實現原理,QQ空間那篇文章已經說過了,這裡我再重新闡述一遍:
- Android使用的是PathClassLoader作為其類的載入器
- 一個ClassLoader可以包含多個dex檔案,每個dex檔案是一個Element,多個dex排列成一個有序的dexElements數組
- 當找類的時候會遍曆dexElements數組,從dex檔案中找類,找到則返回,否則繼續下一個dex檔案尋找
- 熱補丁的方案,其實就是將有問題的類單獨打包成一個dex檔案(如:patch.dex),然後將這個dex插入到dexElements數組的最前面去。
ok,這個就是HotFix對app進行熱補丁的原理,其實就是用ClassLoader載入機制,覆蓋掉有問題的方法,然後我們所謂的補丁就是將有問題的類打成一個包。
再說問題
當然要實現熱補丁動態修複不會很容易,我們首要解決的一個問題是:
當虛擬機器啟動時,當verify選項被開啟時,如果static方法、private方法、建構函式等,其中的直接引用(第一層關係)到的類都在同一個dex檔案中,那麼該類會被打上CLASS_ISPREERIFIED標記
如所示:
如果一個類被打上了CLASS_ISPREERIFIED這個標誌,如果該類引用的另外一個類在另一個dex檔案,就會報錯。簡單來說,就是你在打補丁之前,你所修複的類已經被打上標記,你通過補丁去修複bug的時候這個時候你就不能完成校正,就會報錯。
解決問題
要解決上一節所提到的問題就要在apk打包之前就阻止相關類打上CLASS_ISPREERIFIED標誌,解決方案如下:
在所有類的建構函式插入一段代碼,如:
public class BugClass { public BugClass() { System.out.println(AntilazyLoad.class); } public String bug() { return "bug class"; }}
其中引用到的AntilazyLoad這個類會單獨打包成hack.dex,這樣當安裝apk的時候,classes.dex內的類都會引用一個不相同的dex中的AntilazyLoad類,這樣就解決CLASS_ISPREERIFIED標記問題了。
實現細節
上面幾節講完原理、之後拋出了問題,再提出解決方案,相信大家對整個熱補丁修複架構有了一定的認識,至少我們知道它到底是怎麼一回事。下面來講實現細節:
建立兩個類
package com.devilwwj.hotfixdemo;/** * com.devilwwj.hotfixdemo * Created by devilwwj on 16/3/8. */public class BugClass { public String bug() { return "bug class"; }}
package com.devilwwj.hotfixdemo;/** * com.devilwwj.hotfixdemo * Created by devilwwj on 16/3/8. */public class LoadBugClass { public String getBugString() { BugClass bugClass = new BugClass(); return bugClass.bug(); }}
我們需要做的是在這兩個類的class檔案的構造方法中插入一段代碼:
System.out.println(AntilazyLoad.class);
建立hackdex模組並建立AntilazyLoad類
看圖就好了:
將AntilazyLoad單獨打成hack_dex.jar包
通過以下命令來實現:
jar cvf hack.jar com.devilwwj.hackdex/*
這個命令會將AntilazyLoad類打包成hack.jar檔案
dx --dex --output hack_dex.jar hack.jar
這個命令使用dx工具對hack.jar進行轉化,產生hack_dex.jar檔案
dx工具在我們的sdk/build-tools下
最終我們把hack_dex.jar檔案放到項目的assets目錄下:
使用javassist實現動態代碼注入
建立buildSrc模組,這個項目是使用Groovy開發的,需要配置Groovy SDK才可以編譯成功。
在這裡下載Groovy SDK:http://groovy-lang.org/download.html,下載之後,設定項目user Library即可。
它裡面提供了一個方法,用來向指定類的建構函式注入代碼:
/** * 植入代碼 * @param buildDir 是項目的build class目錄,就是我們需要注入的class所在地 * @param lib 這個是hackdex的目錄,就是AntilazyLoad類的class檔案所在地 */ public static void process(String buildDir, String lib) { println(lib); ClassPool classes = ClassPool.getDefault() classes.appendClassPath(buildDir) classes.appendClassPath(lib) // 將需要關聯的類的構造方法中插入引用代碼 CtClass c = classes.getCtClass("com.devilwwj.hotfixdemo.BugClass") if (c.isFrozen()) { c.defrost() } println("====添加構造方法====") def constructor = c.getConstructors()[0]; constructor.insertBefore("System.out.println(com.devilwwj.hackdex.AntilazyLoad.class);") c.writeFile(buildDir) CtClass c1 = classes.getCtClass("com.devilwwj.hotfixdemo.LoadBugClass") if (c1.isFrozen()) { c1.defrost() } println("====添加構造方法====") def constructor1 = c1.getConstructors()[0]; constructor1.insertBefore("System.out.println(com.devilwwj.hackdex.AntilazyLoad.class);") c1.writeFile(buildDir) }
配置app項目的build.gradle
上一小節建立的module提供相應的方法來讓我們對項目的類進行代碼注入,我們需要在build.gradle來配置讓它自動來做這件事:
apply plugin: 'com.android.application'task('processWithJavassist') << { String classPath = file('build/intermediates/classes/debug')// 項目編譯class所在目錄 com.devilwwj.patch.PatchClass.process(classPath, project(':hackdex').buildDir.absolutePath + "/intermediates/classes/debug") // 第二個參數是hackdex的class所在目錄}android { compileSdkVersion 23 buildToolsVersion "23.0.1" defaultConfig { applicationId "com.devilwwj.hotfixdemo" minSdkVersion 14 targetSdkVersion 23 versionCode 1 versionName "1.0" } buildTypes { debug { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } applicationVariants.all { variant -> variant.dex.dependsOn << processWithJavassist // 在執行dx命令之前將代碼打入到class中 }}dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) testCompile 'junit:junit:4.12' compile 'com.android.support:appcompat-v7:23.1.1' compile 'com.android.support:design:23.1.1' compile project(':hotfixlib')}
這時候我們run項目,反編譯build/output/apk下的app-debug.apk檔案,你就可以看到代碼已經成功植入了。
mac下的反編譯工具:
https://sourceforge.net/projects/jadx/?source=typ_redirect
反編譯的結果如:
其實你也可以直接在項目中看:
建立hotfixlib模組,並關聯到項目中
這差不多是最後一步了,也是最核心的一步,提供將heck_dex.jar動態插入到dexElements的方法。
核心代碼:
package com.devilwwj.hotfixlib;import android.annotation.TargetApi;import android.content.Context;import java.io.File;import java.lang.reflect.Array;import java.lang.reflect.Field;import java.lang.reflect.InvocationTargetException;import dalvik.system.DexClassLoader;import dalvik.system.PathClassLoader;/** * com.devilwwj.hotfixlib * Created by devilwwj on 16/3/9. */public final class HotFix { public static void patch(Context context, String patchDexFile, String patchClassName) { if (patchDexFile != null && new File(patchDexFile).exists()) { try { if (hasLexClassLoader()) { injectAliyunOs(context, patchDexFile, patchClassName); } else if (hasDexClassLoader()) { injectAboveEqualApiLevel14(context, patchDexFile, patchClassName); } else { injectBelowApiLevel14(context, patchDexFile, patchClassName); } } catch (Throwable th) { } } } private static boolean hasLexClassLoader() { try { Class.forName("dalvik.system.LexClassLoader"); return true; } catch (ClassNotFoundException e) { e.printStackTrace(); return false; } } private static boolean hasDexClassLoader() { try { Class.forName("dalvik.system.BaseDexClassLoader"); return true; } catch (ClassNotFoundException e) { e.printStackTrace(); return false; } } private static void injectAliyunOs(Context context, String patchDexFile, String patchClassName) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException { PathClassLoader obj = (PathClassLoader) context.getClassLoader(); String replaceAll = new File(patchDexFile).getName().replaceAll("\\.[a-zA-Z0-9]+", ".lex"); Class cls = Class.forName("dalvik.system.LexClassLoader"); Object newInstance = cls.getConstructor(new Class[]{String.class, String.class, String.class, ClassLoader.class}).newInstance( new Object[]{context.getDir("dex", 0).getAbsolutePath() + File.separator + replaceAll, context.getDir("dex", 0).getAbsolutePath(), patchDexFile, obj}); cls.getMethod("loadClass", new Class[]{String.class}).invoke(newInstance, new Object[]{patchClassName}); setField(obj, PathClassLoader.class, "mPaths", appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(newInstance, cls, "mRawDexPath"))); setField(obj, PathClassLoader.class, "mFiles", combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(newInstance, cls, "mFiles"))); setField(obj, PathClassLoader.class, "mZips", combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(newInstance, cls, "mZips"))); setField(obj, PathClassLoader.class, "mLexs", combineArray(getField(obj, PathClassLoader.class, "mLexs"), getField(newInstance, cls, "mDexs"))); } @TargetApi(14) private static void injectBelowApiLevel14(Context context, String str, String str2) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { PathClassLoader obj = (PathClassLoader) context.getClassLoader(); DexClassLoader dexClassLoader = new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()); dexClassLoader.loadClass(str2); setField(obj, PathClassLoader.class, "mPaths", appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(dexClassLoader, DexClassLoader.class, "mRawDexPath"))); setField(obj, PathClassLoader.class, "mFiles", combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(dexClassLoader, DexClassLoader.class, "mFiles"))); setField(obj, PathClassLoader.class, "mZips", combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(dexClassLoader, DexClassLoader.class, "mZips"))); setField(obj, PathClassLoader.class, "mDexs", combineArray(getField(obj, PathClassLoader.class, "mDexs"), getField(dexClassLoader, DexClassLoader.class, "mDexs"))); obj.loadClass(str2); } /** * 將dex注入dexElements數組中 * @param context * @param str * @param str2 * @throws ClassNotFoundException * @throws NoSuchFieldException * @throws IllegalAccessException */ 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); } /** * 通過PathClassLoader拿到pathList * @param obj * @return * @throws ClassNotFoundException * @throws NoSuchFieldException * @throws IllegalAccessException */ private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList"); } /** * 通過pathList取得dexElements對象 * @param obj * @return * @throws NoSuchFieldException * @throws IllegalAccessException */ private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException { return getField(obj, obj.getClass(), "dexElements"); } /** * 通過反射拿到指定對象 * @param obj * @param cls * @param str * @return * @throws NoSuchFieldException * @throws IllegalAccessException */ 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); } /** * 通過反射設定屬性 * @param obj * @param cls * @param str * @param obj2 * @throws NoSuchFieldException * @throws IllegalAccessException */ 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); } /** * 合并數組 * @param obj * @param obj2 * @return */ 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; } /** * 添加到數組 * @param obj * @param obj2 * @return */ private static Object appendArray(Object obj, Object obj2) { Class componentType = obj.getClass().getComponentType(); int length = Array.getLength(obj); Object newInstance = Array.newInstance(componentType, length + 1); Array.set(newInstance, 0, obj2); for (int i = 0; i < length + 1; i++) { Array.set(newInstance, i, Array.get(obj, i - 1)); } return newInstance; }}
準備補丁,最後測試結果
補丁是我們程式修複bug的包,如果我們已經上線的包出現了bug,你需要緊急修複,那你就找到有bug的那個類,將它修複,然後將這個修複的class檔案打包成jar包,讓服務端將這個補丁包放到指定位置,你的就程式就可以將這補丁包下載到sdcard,之後就是程式自動幫你打補丁把問題修複。
比如我們上面提到的BugClass:
未修複之前:
public class BugClass { public String bug() { return "bug class"; }}
修複之後:
public class BugClass { public String bug() { return "小巫將bug修複啦!!!"; }}
你要做的就是替換這個類,怎麼做?
先打包:
記住:一定要經過dx工具轉化,然後路徑一定要對<喎?http://www.bkjia.com/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxwPnBhdGNoX2RleC5qYXK+zcrHztLDx7XEsrm2obD8o6zV4sDvztLDx86qwcvR3cq+sNHL/LfFtb3P7sS/tcRhc3NldHPEv8K8z8KjujwvcD4NCjxwPjxpbWcgYWx0PQ=="補丁包" src="http://www.bkjia.com/uploads/allimg/160316/042002D31-8.png" title="\" />
之後,就是測試效果了,看動態圖:
好,到這裡就大公告成了,我們的bug被修複了啦。
總結
本次實踐過程是基於HotFix架構,在這裡感謝開源的作者,因為不滿足於拿作者的東西直接用,然後不知道為什麼,所以筆者把整個過程都跑了一遍,正所謂實踐出真知,原本以為很難的東西通過反覆實踐也會變得不那麼難,期間實踐自然不會那麼順利,筆者就遇到一個坑,比如Groovy編譯,hack_dex包中的類找不到等等,但最後都一一解決了,研究完這個熱更新架構,再去研究其他熱更新架構也是一樣的,因為原理都一樣,所以就不糾結研究哪個了,之後筆者也會把這個技術用到項目中去,不用每次發包也是心情愉悅的,最後感謝各位看官耐心看,有啥問題就直接留言吧。