現在 hotfix 框架有很多,原理大同小異,基本上是基于qq空間這篇文章 或者微信的方案。可惜的是微信的 Tinker 以及 QZone 都沒有將其具體實現開源出來,只是在文章中分析了現有各個 hotfix 框架的優缺點以及他們的實現方案。Amigo 原理與 Tinker 基本相同,但是在 Tinker 的基礎上,進一步實現了 so 文件、資源文件、Activity、BroadcastReceiver 的修復,幾乎可以號稱全面修復,不愧 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);
- 立即生效補丁包
如果想要補丁包立即生效,調用以下兩個方法之一,App 會立即重啟,并且打入補丁包。
```Java
Amigo.work(context);
```
```Java
Amigo.work(context, apkFile);
```
刪除補丁包
如果需要刪除掉已經下好的補丁包,可以通過這個方法
Amigo.clear(context);
提示:如果apk 發生了變化,Amigo 會自動清除之前的apk。
自定義界面
在熱修復的過程中會有一些耗時的操作,這些操作會在一個新的進程中的 Activity 中執行,所以你可以通過以下方式來自定義這個 Activity。
<meta-data
android:name="amigo_layout"
android:value="{your-layout-name}" />
<meta-data
android:name="amigo_theme"
android:value="{your-theme-name}" />
組件修復
Amigo 目前能夠支持增加 Activity 和 BroadcastReceiver。只需要將新的 Activity 和 BroadcastReceiver 加到新的 Apk 包中就可以了。Service 和 ContentProvider 將會在未來的版本中支持更新。
集成 Amigo 十分簡單,但是明白 Amigo 的實現更加重要。
源碼分析
----
在Amigo
這個類中實現了主要的修復工作。我們一起追追看,到底是怎樣的實現。
檢查補丁包
****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()
方法中會開啟一個線程去進行一系列的釋放操作,這些操作十分耗時,目前在不同的機子上測試,從幾秒到二十幾秒之間不等,如果就這樣黑屏在用戶前面未免太不優雅,所以 Amigo 開啟了一個新的進程,啟動這個 Activity。
在這個線程中,做了三件微小的事情:
釋放 Dex 到指定目錄
-
拷貝 so 文件到 Amigo 的指定目錄下
拷貝 so 文件是通過反射去調用NativeLibraryHelper
這個類的nativeCopyNativeBinaries()
方法,但這個方法在不同版本上有不同的實現。- 如果版本號在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); }
會去反射調用這個方法,其中系統會自動判斷出 primaryAbi 和 secondAbi。
如果版本號在21以上
copyNativeBinariesIfNeededLI(file, file)
這個方法已經被廢棄了,需要去反射調用這個方法
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 先對機器的位數做了判斷,如果是64位的機子,就只找64位的 abi,如果是32位的,就只找32位的 abi。然后將 Handle 實例當做參數去調用NativeLibraryHelper
的findSupportedAbi
來獲得primaryAbi。最后再去調用copyNativeBinaries
去拷貝 so 文件。
對于 so 文件加載的原理可以參考這篇文章
- 優化 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 沒有做什么事情,只是調用了父類構造器,他的父類是 BaseDexClassLoader。在 BaseDexClassLoader 的構造器中又去構造了一個DexPathList 對象。
在DexPathList
類中,有一個 Element 數組
****DexPathList****
/** list of dex/resource (class path) elements */
private final Element[] dexElements;
Element 就是對 Dex 的封裝。所以一個 Element 對應一個 Dex。這個 Element 在后文中會提到。
優化 dex 只需要在構造 DexClassLoader 對象的時候將 dex 的路徑傳進去,系統會在最后會通過DexFile
的
****DexFile.java****
native private static int openDexFile(String sourceName, String outputName,
int flags) throws IOException;
來這個方法來加載 dex,加載的同時會對其做優化處理。
這三項操作完成之后,通知優化完畢,之后就關閉這個進程,將補丁包的校驗和保存下來。這樣第一步釋放 Apk 就完成了。之后就是重頭戲替換修復。
替換修復
替換classLoader
Amigo 先行構造一個AmigoClassLoader
對象,這個AmigoClassLoader
是一個繼承于PathClassLoader
的類,把補丁包的 Apk 路徑作為參數來構造AmigoClassLoader
對象,之后通過反射替換掉 LoadedApk 的 ClassLoader。這一步是 Amigo 的關鍵所在。
替換Dex
之前提到,每個 dex 文件對應于一個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 對應的 Element 對象拿出來,之后組成新的 Element[],通過反射,將現有的 Element[] 數組替換掉。
在 QZone 的實現方案中,他們是通過將新的 dex 插到 Element[] 數組的第一個位置,這樣就會先加載新的 dex ,微信的方案是下發一個 DiffDex,然后在運行時與舊的 dex 合成一個新的 dex。但是 Amigo 是下發一個完整的 dex直接替換掉了原來的 dex。與其他的方案相比,Amigo 因為直接替換原來的 dex ,兼容性更好,能夠支持修復的方面也更多。但是這也導致了 Amigo 的補丁包會較大,當然,也可以發一個利用 BsDiff 生成的差分包,在本地合成新的 apk 之后再放到 Amigo 的指定目錄下。
替換動態鏈接庫
****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 加到數組前面,保證先加載的是新的 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 文件到數組時,會發現在不同的版本上封裝 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 通過反射去構造一個 Element 對象。之后就是將 so 文件插到數組的第一個位置就行了。
第二個小坑是nativeLibraryDir要設置成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 文件沒有設置成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 下,會對應不同的 Resource 對象,所以通過 ResourceManager 拿到所有的 configuration 對應的 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
的類中,該修復的都修復完了是時候將原來的 application 替換回來了。拿到原有 Application 名字之后先調用 application 的attach(context)
,然后將 application 設回到 loadedApk 中,最后調用oncreate()
,執行原有 Application 中的邏輯。
這之后,一個修復完的 app 就出現在用戶面前。優秀的庫~
Amigo 插件
前文提到 Amigo 在編譯期利用插件替換了 app 原有的 application,那這一個操作是怎么實現的呢?
****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 分包的規則中,一定會將 Activity 放到主 dex 中,Amigo Plugin 為了保證原來的 application 被替換后仍然在主 dex 中,就做了這個十分 hack 的一步。機智的少年。
接下來會再去判斷是否開啟了混淆,如果有混淆的話,查找 mapping 文件,將 applicationName 字段換成混淆后的名字。
下一步會去執行 GenerateCodeTask,在這個 task 中會生成一個 Java 文件,這個文件就是上文提到過得acd.java
,并且將模板中的 appName 替換成applicationName。
然后執行 javaCompile task,編譯 Java 代碼。
最后還要做一件事,就是修改 maindexlist.txt。被定義在這個文件中的類會被加到主 dex 中,所以 Amigo plugin 在collectMultiDexInfo
方法中掃描加到主 dex 的類,然后再在掃描的結果中加上 acd.class,把這些內容全部加到 maindexlist.txt。到此Amigo plugin 的任務就完成了。
Amigo plugin 的主要目的是在編譯期用 amigo 替換掉原來的 application,但是還得保存下來這個 application,因為之后還得在運行時將這個 application 替換回來。
總結
----
Amigo 幾乎實現了全方位的修復,通過替換 ClassLoader,直接全量替換 dex 的思路,保證了兼容性,成功率,但是可能下發的補丁包會比較大。還有一點 Amigo 的精彩之處就是利用 Amigo 替換了 app 原有的 application,這一點保證了 Amigo 連 application 都能修復。以后可能唯一不能修復的就是 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
性能損耗|無|較小|較大|較小|較小
補丁包大小|較大|較小|較大|一般|一般
開發透明|yes|yes|yes|no|no
復雜度|無|較低|較低|復雜|復雜
gradle支持|yes|yes|yes|no|no
接口文檔|豐富|豐富|一般|一般|較少
占Rom體積|較大|較大|較小|較小|較小
成功率|100%|較好|很高|一般|一般