Android熱更新修復核心原理
前言
做程式開發,基礎很重要。同樣是擰螺絲人家擰出來的可以經久不壞,你擰出來的遇到點風浪就開始顫抖,可見基本功的重要性。再複雜的技術,也是由一個一個簡單的邏輯構成。先了解核心基礎,才能更好理解前沿高新技術。
正文大綱
熱更新技術,不是新話題。目前最熱門的熱更新由兩種,一種是騰訊tinker為代表的 需重啟app的熱更新,一種是美團app為代表的instant Run,無需重啟app.
今天先探究 前者的核心原理。
先看效果( github Demo地址 )
假如說這是我們的app介面,這個介面有個bug,我們直接用一個 TextView
來表示。

image.png
然而,我們的開發人員發現了這個bug,但是產品已經上線。這時候,由於引起bug的 程式碼,只有一行,

image.png

image.png
這個時候,機智的程式設計師用最快的方式修復了這個bug,也只是改了一行程式碼:

image.png
那麼,產品已經在線上,怎麼辦?
我們通過後臺,向app推送了一個 fix.dex檔案, 等這個檔案下載完成,app提示使用者,發現新的更新,需要重啟app. 待使用者重啟,程式碼修復 即會生效。無需釋出新版本!

image.png
Demo使用方法
下載Demo程式碼之後,會在assets下看到一個fix.dex檔案

image.png
按照正常的邏輯,我們做bug修復一定是把fix.dex放到伺服器上, app去伺服器下載它,然後存放在app私有目錄,重啟app之後,fix.dex生效, 當載入到這個類的時候,就會去讀fix.dex中當時打包的已修復bug的類.
但是,我這裡為了演示方便,直接放在assets,然後使用 專案中的 AssetsFileUtil類
用io流將它讀寫到 app私有目錄下.
演示方法:
1. 刪掉 fix.dex ,執行app,你看到 手機螢幕中心 出現:"臥槽,有bug!"
2. 還原 fix.dex ,執行app,你看到 手機螢幕中心 出現:"嘿嘿,bug已修復"
起作用的是誰?就是這個fix.dex檔案.
Demo原始碼概覽

image.png
如上圖所示:
核心類其實就只有一個: ClassLoaderHookHelper
,它 就是 讓 fix.dex
這個補丁發揮作用的 " 幕後大佬
".
這個核心類:有3個方法,分別是在不同的系統版本上,來對原始碼程式邏輯進行 hook
,提高hook的相容性.

image.png
下面是完整 ClassLoaderHookHelper
程式碼 以及 使用它的 MyApp
完整程式碼 :
import java.io.File; import java.io.IOException; import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; public class ClassLoaderHookHelper { //23和19的差別,就是 makeXXXElements 方法名和引數要求不同 //後者是 makeDexElements(ArrayList<File> files, File optimizedDirectory,ArrayList<IOException> suppressedExceptions) //前者是 makePathElements(List<File> files, File optimizedDirectory,List<IOException> suppressedExceptions) public static void hookV23(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException { Field pathList = ReflectionUtil.getField(classLoader, "pathList");//1、獲得DexPathList pathList 屬性 Object dexPathListObj = pathList.get(classLoader); //2、獲得DexPathList pathList物件 Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements");//3、獲得DexPathList的dexElements屬性 Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj);//4、獲得pathList物件中 dexElements 的屬性值 List<File> files = new ArrayList<>();//開始構建makeDexElements的實參 files.add(outDexFilePath); List<IOException> ioExceptions = new ArrayList<>(); Method makePathElementsMethod = ReflectionUtil.getMethod(//獲得 DexPathList 的 makePathElements 方法 dexPathListObj, "makePathElements", List.class, File.class, List.class); assert makePathElementsMethod != null; Object[] newElements = (Object[]) makePathElementsMethod.invoke(//這個方法是靜態方法,所以不需要傳例項,直接invoke;這裡取得的返回值就是 我們外部的dex檔案構建成的 Element陣列 null, files, optimizedDirectory, ioExceptions);//構建出一個新的Element陣列 //下面把新陣列和舊數組合並,注意新陣列放前面 Object[] dexElements = null; if (newElements != null && newElements.length > 0) { dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length);//先建一個新容器 //這5個引數解釋一下, 如果是將A,B 你找AB的順序組合成陣列C,那麼引數的含義,依次是 A物件,A陣列開始複製的位置,C物件,C物件的開始存放的位置,陣列A中要複製的元素個數 System.arraycopy( newElements, 0, dexElements, 0, newElements.length);//新來的陣列放前面 System.arraycopy( oldElements, 0, dexElements, newElements.length, oldElements.length); } //最後把合併之後的陣列設定到 dexElements裡面 dexElementsField.set(dexPathListObj, dexElements); } public static void hookV19(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException { Field pathList = ReflectionUtil.getField(classLoader, "pathList");//1、獲得DexPathList pathList 屬性 Object dexPathListObj = pathList.get(classLoader); //2、獲得DexPathList pathList物件 Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements");//3、獲得DexPathList的dexElements屬性 Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj);//4、獲得pathList物件中 dexElements 的屬性值 List<File> files = new ArrayList<>();//開始構建makeDexElements的實參 files.add(outDexFilePath); List<IOException> ioExceptions = new ArrayList<>(); Method makePathElementsMethod = ReflectionUtil.getMethod(//獲得 DexPathList 的 makeDexElements 方法 dexPathListObj, "makeDexElements", ArrayList.class, File.class, ArrayList.class);//別忘了後面的引數列表 Object[] newElements = (Object[]) makePathElementsMethod.invoke( null, files, optimizedDirectory, ioExceptions);//這個方法是靜態方法,所以不需要傳例項,直接invoke;這裡取得的返回值就是 我們外部的dex檔案構建成的 Element陣列 //下面把新陣列和舊數組合並,注意新陣列放前面 Object[] dexElements = null; if (newElements != null && newElements.length > 0) { dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length);//先建一個新容器 //這5個引數解釋一下, 如果是將A,B 你找AB的順序組合成陣列C,那麼引數的含義,依次是 A物件,A陣列開始複製的位置,C物件,C物件的開始存放的位置,陣列A中要複製的元素個數 System.arraycopy( newElements, 0, dexElements, 0, newElements.length);//新來的陣列放前面 System.arraycopy( oldElements, 0, dexElements, newElements.length, oldElements.length); } //最後把合併之後的陣列設定到 dexElements裡面 dexElementsField.set(dexPathListObj, dexElements); } //14和19的區別,是這個方法 makeDexElements(ArrayList<File> files,File optimizedDirectory)···它又少了一個引數 public static void hookV14(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException { Field pathList = ReflectionUtil.getField(classLoader, "pathList");//1、獲得DexPathList pathList 屬性 Object dexPathListObj = pathList.get(classLoader); //2、獲得DexPathList pathList物件 Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements");//3、獲得DexPathList的dexElements屬性 Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj);//4、獲得pathList物件中 dexElements 的屬性值 List<File> files = new ArrayList<>();//開始構建makeDexElements的實參 files.add(outDexFilePath); List<IOException> ioExceptions = new ArrayList<>(); Method makePathElementsMethod = ReflectionUtil.getMethod(//獲得 DexPathList 的 makeDexElements 方法 dexPathListObj, "makeDexElements", ArrayList.class, File.class);//別忘了後面的引數列表 Object[] newElements = (Object[]) makePathElementsMethod.invoke( null, files, optimizedDirectory, ioExceptions);//這個方法是靜態方法,所以不需要傳例項,直接invoke;這裡取得的返回值就是 我們外部的dex檔案構建成的 Element陣列 //下面把新陣列和舊數組合並,注意新陣列放前面 Object[] dexElements = null; if (newElements != null && newElements.length > 0) { dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length);//先建一個新容器 //這5個引數解釋一下, 如果是將A,B 你找AB的順序組合成陣列C,那麼引數的含義,依次是 A物件,A陣列開始複製的位置,C物件,C物件的開始存放的位置,陣列A中要複製的元素個數 System.arraycopy( newElements, 0, dexElements, 0, newElements.length);//新來的陣列放前面 System.arraycopy( oldElements, 0, dexElements, newElements.length, oldElements.length); } //最後把合併之後的陣列設定到 dexElements裡面 dexElementsField.set(dexPathListObj, dexElements); } }
import android.app.Application; import android.content.Context; import android.os.Build; import android.util.Log; import com.example.administrator.myapplication.utils.AssetsFileUtil; import com.example.administrator.myapplication.utils.ClassLoaderHookHelper; import java.io.File; public class MyApp extends Application { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); Log.d("BugTag2", "" + getClassLoader());//PathClassLoader Log.d("BugTag2", "" + getClassLoader().getParent());//BootClassLoader String fixPath = "fix.dex"; try { String path = AssetsFileUtil.copyAssetToCache(this, fixPath); File fixFile = new File(path); if (Build.VERSION.SDK_INT >= 23) { ClassLoaderHookHelper.hookV23(getClassLoader(), fixFile, getCacheDir()); } else if (Build.VERSION.SDK_INT >= 19) { ClassLoaderHookHelper.hookV19(getClassLoader(), fixFile, getCacheDir()); } else if (Build.VERSION.SDK_INT >= 14) { ClassLoaderHookHelper.hookV14(getClassLoader(), fixFile, getCacheDir()); } } catch (Exception e) { e.printStackTrace(); } } }
熱修復核心技術
其實 熱修復的核心技術,就一句話, Hook ClassLoader
,但是要深入瞭解它,需要相當多的基礎知識,下面列舉出必須要知道的一些東西。
基礎知識預備
1.Dex檔案是什麼?
我們寫安卓,目前還是用 java
比較多,就算是用 kotlin
,它最終也是要轉換成 java
來執行。 java
檔案,被編譯成 class
之後,多個 class
檔案,會被打包成 classes.dex
,被放到 apk
中,安卓裝置拿到 apk
,去安裝解析( 預編譯balabala...
),當我們執行 app
時, app
的程式邏輯全都是在 classes.dex
中。
所以, dex
檔案是什麼?
一句話
,dex
檔案是 android app
的原始碼的最終打包
2.Dex檔案如何生成?
androidStudio 打包 apk
的時候會生成 Dex
,其實它使用的是 SDK
的 dx
命令,我們可以用 dx
命令自己去打包想要打包的 class
.
命令格式為: dx --dex --output=output.dex xxxx.class
將上面的output 和 xxxx換成你想要的檔名即可。
注: dx.bat
在 安卓 SDK
的目錄下:比如我的 C:\XXXXX\AndroidStudioAbout\sdk1\build-tools\28.0.3\dx.bat
3.ClassLoader是什麼?
ClassLoader來自 jdk
,翻譯為 : 類載入器,用於將 class
檔案中的類,載入到記憶體中,生成 class
物件。只有存在了 Class
物件,我們才可以建立我們想要的物件。
android SDK
繼承了JDK的 classLoader
,創造出了新的 ClassLoader
子類。下圖表示了 android9.0-28
所有的ClassLoader直接或者間接子類.

android28所有的ClassLoader子類.png
BaseDexClassLoader
,
DexClassLoader
,
PathClassLoader
, 其他這些,應該是谷歌大佬 創造出來新的 類載入器子類吧,還沒研究過。
注:關於 DexClassLoader
和 PathClassLoader
,網上資料有個誤區,應該不少人都認為, PathClassLoader
用於載入 app
內部的 dex
檔案, DexClassLoader
用於載入外部的 dex
檔案,但是其實只要看一眼 這兩個類的關係,就會發現,它們都是繼承自 BaseDexClassLoader
,他們的建構函式內部都會去執行父類的建構函式。 他們只有一個差別,那就是 PathClssLoader
不用傳 optimizedDirectory
這個引數,但是 DexClassLoader
必須傳。這個引數的作用是,傳入一個 dex優化之後的存放目錄
。而事實上,雖然 PathClassLoader
不要求傳這個 optimizedDirectory
,但是它實際上是給了一個預設值。 emmmm............所以不要再認為 PathClassLoader
不能載入外部的 dex
了,它只是沒有讓你傳 optimizedDirectory
而已。
另外:
BootClassLoader
用於載入 Android Framework
層class檔案( SDK中沒有這個BootClassLoader,也是很奇怪
)
PathClassLoader
是用於Android應用程式類的載入器,可以載入指定的 dex
,以及 jar
、 zip
、 apk
中的 classes.dex
。
DexClassLoader
可以載入指定的 dex
,以及 jar
、 zip
、 apk
中的 classes.dex
。
4.ClassLoader的雙親委託機制是什麼?
android裡面 ClassLoader
的作用,是將 dex
檔案中的類,載入到記憶體中,生成 Class
物件,供我們使用
(舉個例子:我寫了一個 A
類,app執行起來之後,當我需要new 一個 A
, ClassLoader
首先會幫我查詢 A
的 Class
物件是否存在,如果存在,就直接給我 Class
物件,讓我拿去 new
A
,如果不存在,就會出建立這個 A
的 Class
物件。)
這個查詢的過程,就遵循 雙親委託機制
。
一句話解釋 雙親委託機制
:某個 類載入器
在載入某個 類
的時候,首先會將 這件事委託給 parent類載入器
,依次遞迴,如果 parent類載入器
可以完成載入,就會直接返回 Class物件
。如果 parent
找不到或者沒有父了,就會 自己
載入。
下圖是 安卓原始碼 ClassLoader.java
:

image.png
紅字註解
,很容易讀懂
ClassLoader
去
load
一個
class
的過程.
hook思路
OK,現在可以來解讀我是如何去hook ClassLoader的了.
解讀之前,先弄清楚,我為何 要 hook ClassLoader
,為什麼 hook
了它之後,我的 fix.dex
就能發揮作用?
一張圖解決你的疑問:

類的載入過程.png
按照上面圖,去追蹤原始碼,會發現, ClassLoader
最終會從 DexFile
物件中去獲得一個 Class
物件。並且在 DexPathList
類中 findClass
的時候,存在一個 Element
陣列的遍歷。
這就意味著,如果存在多個 dex
檔案,多個 dex
檔案中都存在同樣一個 class
,那麼它會從第一個開始找,如果找到了,就會立即返回。如果沒找到,就往下一個 dex
去找。
也就是說,如果我們可以在 這個陣列中插入我們自己的修復bug的 fix.dex
,那我們就可以讓我們 已經修復bug的補丁類
發揮作用,讓類載入器優先讀取我們的 補丁類
.
OK,理解了原始碼的邏輯,那我們可以動手了。來解析SDK 23的 hook ClassLoader
過程吧!
確定思路,我們要改變app啟動之後,自帶的ClassLoader物件(具體實現類是PathClassLoader )中 DexPathList 中 Element[] element 的實際值。
那麼,步驟:
1.取得PathClassLoader的pathList的屬性
2.取得PathClassLoader的pathList的屬性真實值(得到一個DexPathList物件)
3.獲得DexPathList中的dexElements 屬性
做完這4個步驟,我們得到下面的程式碼

image.png
5.用外部傳入的Dex檔案路徑,構建一個我們自己的Element陣列

image.png
6.將從外部傳入的ClassLoader中得到的Element陣列和 我們自己的Element數組合並起來, 注意,我們自己的陣列元素要放前面
!

image.png
7.將剛才合併的新Element陣列,設定到 外部傳入的ClassLoader裡面去。

image.png
OK,收官!
TIPS:
上面的內容,讀起來可能會有一些疑問,我預估到了一些,將答案寫在下面
- 當我們需要反射獲得一個類的某個方法或者成員變數時,我們只想拿getDeclareXX,因為我們只想拿本類中的成員,但是僅僅getDeclareXX不能跨越繼承關係 拿到 父類中的非私有成員,所以我寫了ReflectionUtil.java,支援跨越繼承關係 拿到父類的非私有成員。
-
這種熱修復,是不是下載的包會很大,和原先的apk差不多大?
答案是,NO,我們只需要將我們修復bug之後的補丁dex下載到裝置,讓app重啟,去讀取這個dex即可。補丁包很小,甚至只有1K.
- 這種修復方式必須重啟麼?
是的,必須重啟,當然,存在不需要重啟就可以修復bug的方法,那種方法叫做instant run方案,本文不涉及。 而,當前這種方案叫做:MultipleDex 即,多dex方案。 -
為什麼要對SDK 23 ,19,14 寫不同的hook程式碼?
因為SDK版本的變遷,導致 一些類的關係,變數名,方法名,方法引數(個數和型別)都會發生變化,所以,要針對各個變遷的版本進行相容。
鳴謝
感謝享學課堂Lance老師的授課和課後指點
閱讀本文有任何疑問歡迎留言討論!另,歡迎轉載,註明出處即可。