現(xiàn)在 hotfix 框架有很多,原理大同小異败许,基本上是基于qq空間這篇文章 或者微信的方案王带。可惜的是微信的 Tinker 以及 QZone 都沒有將其具體實現(xiàn)開源出來市殷,只是在文章中分析了現(xiàn)有各個 hotfix 框架的優(yōu)缺點以及他們的實現(xiàn)方案愕撰。Amigo 原理與 Tinker 基本相同,但是在 Tinker 的基礎(chǔ)上醋寝,進一步實現(xiàn)了 so 文件搞挣、資源文件、Activity音羞、BroadcastReceiver 的修復(fù)柿究,幾乎可以號稱全面修復(fù),不愧 Amigo(朋友)這個稱號黄选,能在危急時刻送來全面的幫助。
庫地址:https://github.com/eleme/Amigo
Amigo 是來自餓了么團隊的 JackCho 所寫,他的 github 地址:https://github.com/JackCho办陷。本文是對 Amigo 源碼的解讀貌夕。
首先我們先來看看如何使用這個庫。
用法
----
在 project 的build.gradle
中
dependencies {
classpath 'me.ele:amigo:0.0.3'
}
在 module 的build.gradle
中
apply plugin: 'me.ele.amigo'
就這樣輕松的集成了 Amigo民镜。
生效補丁包
補丁包生效有兩種方式可以選擇:
- 稍后生效補丁包
如果不想立即生效而是用戶第二次打開 App 時才打入補丁包啡专,則可以將新的 Apk 放到 `/data/data/{your pkg}/files/amigo/demo.apk`,第二次打開時就會自動生效制圈∶峭可以通過這個方法
```Java
File hotfixApk = Amigo.getHotfixApk(context);
```
獲取到新的 Apk。
同時鲸鹦,你也可以使用 Amigo 提供的工具類將你的補丁包拷貝到指定的目錄當中慧库。
FileUtils.copyFile(yourApkFile, amigoApkFile);
- 立即生效補丁包
如果想要補丁包立即生效,調(diào)用以下兩個方法之一馋嗜,App 會立即重啟齐板,并且打入補丁包。
```Java
Amigo.work(context);
```
```Java
Amigo.work(context, apkFile);
```
刪除補丁包
如果需要刪除掉已經(jīng)下好的補丁包葛菇,可以通過這個方法
Amigo.clear(context);
提示:如果apk 發(fā)生了變化甘磨,Amigo 會自動清除之前的apk。
自定義界面
在熱修復(fù)的過程中會有一些耗時的操作眯停,這些操作會在一個新的進程中的 Activity 中執(zhí)行济舆,所以你可以通過以下方式來自定義這個 Activity。
<meta-data
android:name="amigo_layout"
android:value="{your-layout-name}" />
<meta-data
android:name="amigo_theme"
android:value="{your-theme-name}" />
組件修復(fù)
Amigo 目前能夠支持增加 Activity 和 BroadcastReceiver莺债。只需要將新的 Activity 和 BroadcastReceiver 加到新的 Apk 包中就可以了滋觉。Service 和 ContentProvider 將會在未來的版本中支持更新。
集成 Amigo 十分簡單九府,但是明白 Amigo 的實現(xiàn)更加重要椎瘟。
源碼分析
----
在Amigo
這個類中實現(xiàn)了主要的修復(fù)工作。我們一起追追看侄旬,到底是怎樣的實現(xiàn)肺蔚。
檢查補丁包
****Amigo.java****
...
if (demoAPk.exists() && isSignatureRight(this, demoAPk)) {
SharedPreferences sp = getSharedPreferences(SP_NAME, MODE_MULTI_PROCESS);
String demoApkChecksum = checksum(demoAPk);
boolean isFirstRun = !sp.getString(NEW_APK_SIG, "").equals(demoApkChecksum);
...
這段代碼中,首先檢查是否有補丁包儡羔,并且簽名正確宣羊,如果正確,則通過檢驗校驗和是否與之前的檢驗和相同汰蜘,不同則為檢測到新的補丁包仇冯。
釋放Apk
當這是新的補丁包時,首先第一件事就是釋放族操。ApkReleaser.work(this, layoutId, themeId)
在這個方法中最終會去開啟一個 ApkReleaseActivity苛坚,而這個 Activity 的layout 和 theme 就是之前從配置中解析出來比被,在 work 方法中傳進來的layoutId 和 themeId。
****ApkReleaseActivity.java****
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
new Thread() {
@Override
public void run() {
super.run();
DexReleaser.releaseDexes(demoAPk.getAbsolutePath(), dexDir.getAbsolutePath());
NativeLibraryHelperCompat.copyNativeBinaries(demoAPk, nativeLibraryDir);
dexOptimization();
handler.sendEmptyMessage(WHAT_DEX_OPT_DONE);
}
}.start();
}
在 ApkReleaseActivity 的 onCreate()
方法中會開啟一個線程去進行一系列的釋放操作泼舱,這些操作十分耗時等缀,目前在不同的機子上測試,從幾秒到二十幾秒之間不等娇昙,如果就這樣黑屏在用戶前面未免太不優(yōu)雅尺迂,所以 Amigo 開啟了一個新的進程,啟動這個 Activity冒掌。
在這個線程中噪裕,做了三件微小的事情:
釋放 Dex 到指定目錄
-
拷貝 so 文件到 Amigo 的指定目錄下
拷貝 so 文件是通過反射去調(diào)用NativeLibraryHelper
這個類的nativeCopyNativeBinaries()
方法,但這個方法在不同版本上有不同的實現(xiàn)股毫。- 如果版本號在21以下
****NativeLibraryHelper****
public static int copyNativeBinariesIfNeededLI(File apkFile, File sharedLibraryDir) { final String cpuAbi = Build.CPU_ABI; final String cpuAbi2 = Build.CPU_ABI2; return nativeCopyNativeBinaries(apkFile.getPath(), sharedLibraryDir.getPath(), cpuAbi, cpuAbi2); }
會去反射調(diào)用這個方法膳音,其中系統(tǒng)會自動判斷出 primaryAbi 和 secondAbi。
如果版本號在21以上
copyNativeBinariesIfNeededLI(file, file)
這個方法已經(jīng)被廢棄了皇拣,需要去反射調(diào)用這個方法
NativeLibraryHelper
public static int copyNativeBinaries(Handle handle, File sharedLibraryDir, String abi) {
for (long apkHandle : handle.apkHandles) {
int res = nativeCopyNativeBinaries(apkHandle, sharedLibraryDir.getPath(), abi,
handle.extractNativeLibs, HAS_NATIVE_BRIDGE);
if (res != INSTALL_SUCCEEDED) {
return res;
}
}
return INSTALL_SUCCEEDED;
}
所以首先得去獲得一個NativeLibraryHelper$Handle
類的實例严蓖。之后就是找 primaryAbi。Amigo 先對機器的位數(shù)做了判斷氧急,如果是64位的機子颗胡,就只找64位的 abi,如果是32位的吩坝,就只找32位的 abi毒姨。然后將 Handle 實例當做參數(shù)去調(diào)用NativeLibraryHelper
的findSupportedAbi
來獲得primaryAbi。最后再去調(diào)用copyNativeBinaries
去拷貝 so 文件钉寝。
對于 so 文件加載的原理可以參考這篇文章
- 優(yōu)化 dex 文件
****ApkReleaseActivity.java****
private void dexOptimization() {
...
for (File dex : validDexes) {
new DexClassLoader(dex.getAbsolutePath(), optimizedDir.getAbsolutePath(), null, DexUtils.getPathClassLoader());
Log.e(TAG, "dexOptimization finished-->" + dex);
}
}
DexClassLoader 沒有做什么事情弧呐,只是調(diào)用了父類構(gòu)造器,他的父類是 BaseDexClassLoader嵌纲。在 BaseDexClassLoader 的構(gòu)造器中又去構(gòu)造了一個DexPathList 對象俘枫。
在DexPathList
類中,有一個 Element 數(shù)組
****DexPathList****
/** list of dex/resource (class path) elements */
private final Element[] dexElements;
Element 就是對 Dex 的封裝逮走。所以一個 Element 對應(yīng)一個 Dex鸠蚪。這個 Element 在后文中會提到。
優(yōu)化 dex 只需要在構(gòu)造 DexClassLoader 對象的時候?qū)?dex 的路徑傳進去师溅,系統(tǒng)會在最后會通過DexFile
的
****DexFile.java****
native private static int openDexFile(String sourceName, String outputName,
int flags) throws IOException;
來這個方法來加載 dex茅信,加載的同時會對其做優(yōu)化處理。
這三項操作完成之后墓臭,通知優(yōu)化完畢蘸鲸,之后就關(guān)閉這個進程,將補丁包的校驗和保存下來窿锉。這樣第一步釋放 Apk 就完成了酌摇。之后就是重頭戲替換修復(fù)膝舅。
替換修復(fù)
替換classLoader
Amigo 先行構(gòu)造一個AmigoClassLoader
對象,這個AmigoClassLoader
是一個繼承于PathClassLoader
的類妙痹,把補丁包的 Apk 路徑作為參數(shù)來構(gòu)造AmigoClassLoader
對象铸史,之后通過反射替換掉 LoadedApk 的 ClassLoader。這一步是 Amigo 的關(guān)鍵所在怯伊。
替換Dex
之前提到,每個 dex 文件對應(yīng)于一個PathClassLoader
判沟,其中有一個 Element[]理茎,Element 是對于 dex 的封裝靶庙。
****Amigo.java****
private void setDexElements(ClassLoader classLoader) throws NoSuchFieldException, IllegalAccessException {
Object dexPathList = getPathList(classLoader);
File[] listFiles = dexDir.listFiles();
List<File> validDexes = new ArrayList<>();
for (File listFile : listFiles) {
if (listFile.getName().endsWith(".dex")) {
validDexes.add(listFile);
}
}
File[] dexes = validDexes.toArray(new File[validDexes.size()]);
Object originDexElements = readField(dexPathList, "dexElements");
Class<?> localClass = originDexElements.getClass().getComponentType();
int length = dexes.length;
Object dexElements = Array.newInstance(localClass, length);
for (int k = 0; k < length; k++) {
Array.set(dexElements, k, getElementWithDex(dexes[k], optimizedDir));
}
writeField(dexPathList, "dexElements", dexElements);
}
在替換dex時,Amigo 將補丁包中每個 dex 對應(yīng)的 Element 對象拿出來,之后組成新的 Element[]谱仪,通過反射,將現(xiàn)有的 Element[] 數(shù)組替換掉仔燕。
在 QZone 的實現(xiàn)方案中裂垦,他們是通過將新的 dex 插到 Element[] 數(shù)組的第一個位置,這樣就會先加載新的 dex 斯入,微信的方案是下發(fā)一個 DiffDex砂碉,然后在運行時與舊的 dex 合成一個新的 dex。但是 Amigo 是下發(fā)一個完整的 dex直接替換掉了原來的 dex刻两。與其他的方案相比增蹭,Amigo 因為直接替換原來的 dex ,兼容性更好,能夠支持修復(fù)的方面也更多磅摹。但是這也導(dǎo)致了 Amigo 的補丁包會較大滋迈,當然,也可以發(fā)一個利用 BsDiff 生成的差分包户誓,在本地合成新的 apk 之后再放到 Amigo 的指定目錄下饼灿。
替換動態(tài)鏈接庫
****Amigo.java****
private void setNativeLibraryDirectories(AmigoClassLoader hackClassLoader)
throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException, NoSuchFieldException {
injectSoAtFirst(hackClassLoader, nativeLibraryDir.getAbsolutePath());
nativeLibraryDir.setReadOnly();
File[] libs = nativeLibraryDir.listFiles();
if (libs != null && libs.length > 0) {
for (File lib : libs) {
lib.setReadOnly();
}
}
}
so 文件的替換跟 QZone 替換 dex 原理相差不多,也是利用 ClassLoader 加載 library 的時候帝美,將新的 library 加到數(shù)組前面碍彭,保證先加載的是新的 library。但是這里會有幾個小坑证舟。
****DexUtils.java****
public static void injectSoAtFirst(ClassLoader hackClassLoader, String soPath) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
Object[] baseDexElements = getNativeLibraryDirectories(hackClassLoader);
Object newElement;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Constructor constructor = baseDexElements[0].getClass().getConstructors()[0];
constructor.setAccessible(true);
Class<?>[] parameterTypes = constructor.getParameterTypes();
Object[] args = new Object[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++) {
if (parameterTypes[i] == File.class) {
args[i] = new File(soPath);
} else if (parameterTypes[i] == boolean.class) {
args[i] = true;
}
}
newElement = constructor.newInstance(args);
} else {
newElement = new File(soPath);
}
Object newDexElements = Array.newInstance(baseDexElements[0].getClass(), 1);
Array.set(newDexElements, 0, newElement);
Object allDexElements = combineArray(newDexElements, baseDexElements);
Object pathList = getPathList(hackClassLoader);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
writeField(pathList, "nativeLibraryPathElements", allDexElements);
} else {
writeField(pathList, "nativeLibraryDirectories", allDexElements);
}
}
注入 so 文件到數(shù)組時硕旗,會發(fā)現(xiàn)在不同的版本上封裝 so 文件的是不同的類,在版本23以下女责,是File
****DexPathList.java****
/** list of native library directory elements */
private final File[] nativeLibraryDirectories;
在23以上卻是改成了Element
****DexPathList.java****
/** List of native library path elements. */
private final Element[] nativeLibraryPathElements;
因此在23以上漆枚,Amigo 通過反射去構(gòu)造一個 Element 對象。之后就是將 so 文件插到數(shù)組的第一個位置就行了抵知。
第二個小坑是nativeLibraryDir要設(shè)置成readOnly墙基。
****DexPathList.java****
public String findNativeLibrary(String name) {
maybeInit();
if (isDirectory) {
String path = new File(dir, name).getPath();
if (IoUtils.canOpenReadOnly(path)) {
return path;
}
} else if (zipFile != null) {
String entryName = new File(dir, name).getPath();
if (isZipEntryExistsAndStored(zipFile, entryName)) {
return zip.getPath() + zipSeparator + entryName;
}
}
return null;
}
在ClassLoader 去尋找本地庫的時候软族,如果 so 文件沒有設(shè)置成ReadOnly的話是會不會返回路徑的,這樣就會報錯了残制。
替換資源文件
****Amigo.java****
...
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = getDeclaredMethod(AssetManager.class, "addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, demoAPk.getAbsolutePath());
setAPKResources(assetManager)
...
想要更新資源文件立砸,只需要更新Resource
中的 AssetManager 字段。AssetManager
提供了一個方法addAssetPath
初茶。將新的資源文件路徑加到AssetManager
中就可以了颗祝。在不同的 configuration 下,會對應(yīng)不同的 Resource 對象恼布,所以通過 ResourceManager 拿到所有的 configuration 對應(yīng)的 resource 然后替換其 assetManager螺戳。
替換原有 Application
****Amigo.java****
...
Class acd = classLoader.loadClass("me.ele.amigo.acd");
String applicationName = (String) readStaticField(acd, "n");
Application application = (Application) classLoader.loadClass(applicationName).newInstance();
Method attach = getDeclaredMethod(Application.class, "attach", Context.class);
attach.setAccessible(true);
attach.invoke(application, getBaseContext());
setAPKApplication(application);
application.onCreate();
...
在編譯過程中,Amigo 的插件將 app 的 application 替換成了 Amigo折汞,并且將原來的 application 的 name 保存在了一個名為acd
的類中倔幼,該修復(fù)的都修復(fù)完了是時候?qū)⒃瓉淼?application 替換回來了。拿到原有 Application 名字之后先調(diào)用 application 的attach(context)
爽待,然后將 application 設(shè)回到 loadedApk 中损同,最后調(diào)用oncreate()
,執(zhí)行原有 Application 中的邏輯鸟款。
這之后膏燃,一個修復(fù)完的 app 就出現(xiàn)在用戶面前。優(yōu)秀的庫~
Amigo 插件
前文提到 Amigo 在編譯期利用插件替換了 app 原有的 application欠雌,那這一個操作是怎么實現(xiàn)的呢蹄梢?
****AmigoPlugin.groovy****
File manifestFile = output.processManifest.manifestOutputFile
def manifest = new XmlParser().parse(manifestFile)
def androidTag = new Namespace("http://schemas.android.com/apk/res/android", 'android')
applicationName = manifest.application[0].attribute(androidTag.name)
manifestFile.text = manifestFile.text.replace(applicationName, "me.ele.amigo.Amigo")
首先,Amigo Plugin 將 AndroidManifest.xml 文件中的applicationName 替換成 Amigo富俄。
****AmigoPlugin.groovy****
Node node = (new XmlParser()).parse(manifestFile)
Node appNode = null
for (Node n : node.children()) {
if (n.name().equals("application")) {
appNode = n;
break
}
}
Node hackAppNode = new Node(appNode, "activity")
hackAppNode.attributes().put("android:name", applicationName)
manifestFile.text = XmlUtil.serialize(node)
之后禁炒,Amigo Plugin 做了很 hack 的一步,就是在 AndroidManifest.xml 中將原來的 application 做為一個 Activity 霍比。我們知道 MultiDex 分包的規(guī)則中幕袱,一定會將 Activity 放到主 dex 中,Amigo Plugin 為了保證原來的 application 被替換后仍然在主 dex 中悠瞬,就做了這個十分 hack 的一步们豌。機智的少年。
接下來會再去判斷是否開啟了混淆浅妆,如果有混淆的話望迎,查找 mapping 文件,將 applicationName 字段換成混淆后的名字凌外。
下一步會去執(zhí)行 GenerateCodeTask辩尊,在這個 task 中會生成一個 Java 文件,這個文件就是上文提到過得acd.java
康辑,并且將模板中的 appName 替換成applicationName摄欲。
然后執(zhí)行 javaCompile task轿亮,編譯 Java 代碼。
最后還要做一件事胸墙,就是修改 maindexlist.txt我注。被定義在這個文件中的類會被加到主 dex 中,所以 Amigo plugin 在collectMultiDexInfo
方法中掃描加到主 dex 的類迟隅,然后再在掃描的結(jié)果中加上 acd.class但骨,把這些內(nèi)容全部加到 maindexlist.txt。到此Amigo plugin 的任務(wù)就完成了玻淑。
Amigo plugin 的主要目的是在編譯期用 amigo 替換掉原來的 application嗽冒,但是還得保存下來這個 application,因為之后還得在運行時將這個 application 替換回來补履。
總結(jié)
----
Amigo 幾乎實現(xiàn)了全方位的修復(fù),通過替換 ClassLoader剿另,直接全量替換 dex 的思路箫锤,保證了兼容性,成功率雨女,但是可能下發(fā)的補丁包會比較大谚攒。還有一點 Amigo 的精彩之處就是利用 Amigo 替換了 app 原有的 application,這一點保證了 Amigo 連 application 都能修復(fù)氛堕。以后可能唯一不能修復(fù)的就是 Amigo 自身了馏臭。
最后我們比較下目前幾個 hotfix 方案:
| Amigo | Tinker | nuwa/QZone | AndFix | Dexposed
---|------|---|---|----|---|
類替換 | yes | yes| yes |no| no
lib替換|yes | yes| no | no | no
資源替換|yes|yes|yes|no|no
全平臺支持|yes|yes|yes|yes|no
即時生效|optional|no|no|yes|yes
性能損耗|無|較小|較大|較小|較小
補丁包大小|較大|較小|較大|一般|一般
開發(fā)透明|yes|yes|yes|no|no
復(fù)雜度|無|較低|較低|復(fù)雜|復(fù)雜
gradle支持|yes|yes|yes|no|no
接口文檔|豐富|豐富|一般|一般|較少
占Rom體積|較大|較大|較小|較小|較小
成功率|100%|較好|很高|一般|一般