Android Hotfix 新方案——Amigo 原始碼解讀

yangxi_001發表於2016-09-13

現在 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,第二次開啟時就會自動生效。可以通過這個方法

  File hotfixApk = Amigo.getHotfixApk(context);

   獲取到新的 Apk。
   同時,你也可以使用 Amigo 提供的工具類將你的補丁包拷貝到指定的目錄當中。
   

 FileUtils.copyFile(yourApkFile, amigoApkFile);
  • 立即生效補丁包

   如果想要補丁包立即生效,呼叫以下兩個方法之一,App 會立即重啟,並且打入補丁包。

    Amigo.work(context);
    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 例項當做引數去呼叫NativeLibraryHelperfindSupportedAbi來獲得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 方案:


相關文章