Amigo 源碼解讀

如今 hotfix 框架有不少,原理大同小異,基本上是基於qq空間這篇文章 或者微信的方案。惋惜的是微信的 Tinker 以及 QZone 都沒有將其具體實現開源出來,只是在文章中分析了現有各個 hotfix 框架的優缺點以及他們的實現方案。Amigo 原理與 Tinker 基本相同,可是在 Tinker 的基礎上,進一步實現了 so 文件、資源文件、Activity、BroadcastReceiver 的修復,幾乎能夠號稱全面修復,不愧 Amigo(朋友)這個稱號,能在危急時刻送來全面的幫助。java

首先咱們先來看看如何使用這個庫。 庫地址:https://github.com/eleme/Amigonode

用法

在project 的build.gradle 中android

dependencies {
     classpath 'me.ele:amigo:0.0.3'
   }

在module 的build.gradle 中git

apply plugin: 'me.ele.amigo'

就這樣輕鬆的集成了Amigo。github

生效補丁包

補丁包生效有兩種方式能夠選擇:數組

  • 稍後生效補丁包微信

    若是不想當即生效而是用戶第二次打開App 時纔打入補丁包,則能夠將新的Apk 放到 /data/data/{your pkg}/files/amigo/demo.apk,第二次打開時就會自動生效。能夠經過這個方法app

    File hotfixApk = Amigo.getHotfixApk(context);

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

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

    若是想要補丁包當即生效,調用如下兩個方法之一,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 實例當作參數去調用`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
lib替換 yes yes no no
資源替換 yes yes yes no
全平臺支持 yes yes yes yes
即時生效 optional no no yes
性能損耗 較小 較大 較小
補丁包大小 較大 較小 較大 通常
開發透明 yes yes yes no
複雜度 較低 較低 複雜
gradle支持 yes yes yes no
接口文檔 豐富 豐富 通常 通常
佔Rom體積 較大 較大 較小 較小
成功率 100% 較好 很高 通常
相關文章
相關標籤/搜索