Android SDK全域性熱更新方案(全網唯一)
一、背景
App熱更新
目前市面上成熟的商業熱更新方案不少,有騰訊Bugly的Tinker封裝,有阿里雲的Sophix,也有遊戲垂直行業的卓盟樂變。這些成熟方案,都有一個適用範圍,即對App、對遊戲整包進行熱更新。前兩者是和包名繫結在一起的,所以只適用於App熱更新;而卓盟樂變則專注於遊戲行業,可支援多渠道包熱更新。其實最好的還是Sophix,可惜沒有開源,雖有公開原理,但是公開資料裡也透露了探索與開發週期長達9個月。
在社群,比較流行的熱更新有Tinker
、QZone
、AndFix(HotFix)
、Sophix
、Robust
、Dexposed
、Nuwa
、Amigo
,同商業熱更新方案一樣,也是適用於App整包熱更新。在這些方案裡,影響力最大的是微信的Tinker
方案,13048個Star,擁有完善的文件,整個框架注重高可用性
,最重要的是官方持續維護
,在2018年12月,merge
7次。相比之下,其他有在Github上開源的框架,star
數都是7000以下,上次更新時間都在1年前,甚至2年前。
SDK熱更新
SDK熱更新,這是一個極少被關注的問題,Google、百度上相關的文章一篇都沒有。我們首先進行思考,SDK熱更新
同App熱更新
有什麼不同?,SDK熱更新
要做什麼?
SDK熱更新同App熱更新有什麼不同?
- App熱更新,輸入的是一個基準包和一個新版包,輸出的是差分包(或補丁),將這個差分包(或補丁)下載到客戶端,客戶端載入後生效。
- SDK熱更新,輸入的是一個基準SDK和一個新版SDK,輸出的也是差分包(或補丁),不同的是,SDK會被整合到不同的遊戲包中,這個遊戲包也會被分成各式各樣的渠道包,我們要將這個差分包(或補丁)下載到所有遊戲、所有渠道包,並載入生效 。
SDK熱更新要做什麼?
1. 對SDK的程式碼、資源進行標識,我們要進行熱更新的物件,就是這些程式碼、資源。
比如,我們可以進行這樣標識:所有在com.divin.
包名之下的java類,所有assets/divin/
資料夾之下的Assets檔案,所有以divin_
開頭的Res檔案,所有/res/values/
檔案,所有以divin_
開頭的so檔案。
2. 在熱更新的整個流程,對上述程式碼、資源進行特別操作。
包括build(計算差分)、patch(合併差分)、load(載入差分)。
十分感謝微信Tinker
的開源,對外開放了完整的熱更新過程,站在偉人肩上,下面的SDK熱更新,都是基於Tinker
開源庫進行的修改。
熱更新重點
1. dex熱更新,即Java程式碼熱更新。
阿里系(AndFix,Hotfix)走的底層替換方案 ,好處在於實時生效,騰訊系(Tinker)走的是類載入方案 ,好處在於高相容性。阿里百川系(Sophix)就有點機智了,兩種方案都有使用,還進行了一定的升級,優先走底層替換方案,底層替換方案走不下去了就走類載入方案。
AndFix(HotFix)的底層替換方案已過時,Sophix的無視底層具體結構的底層替換方案較新。感興趣的同學可以深入瞭解下,追尋極致的程式碼熱替換 。
Tinker的類載入方案,需要重啟應用後讓Classloader去載入新類。因為Android上無法對一個類進行解除安裝,不重啟,則無法載入新類。
2. 資原始檔熱更新。
這裡也是有兩個流派,一個流派是參考Instant Run通過addAssetPath載入新的資源包到AssetsManager,然後再替換Resource中的AssetsManager;一個流派是構造新的R檔案資源地址以0x66開頭的資源包,再通過addAssetPath載入新的資源包到AssetsManager,因為新的R檔案資源地址以0x66開頭,新的Java程式碼裡,也引用0x66開頭的資源,這樣就可以新舊資源不干擾且都能生效。
Tinker屬於第一個流派 ,Sophix屬於第二個流派 。
非常遺憾的是,在我們基於Tinker實現SDK資源更新(即指定資源更新)時,只知道第一個流派,並不知道第二個流派(那篇文章沒細讀,印象不深)。所以後文中所提到的SDK資源更新(指定資源更新),其實是自己摸索出來的,可以理解成流派二的拼多多版,實現了資源新增、更改,但暫未支援R檔案直接引用。
3. so檔案熱更新。
說到這裡,是真感謝這世界上有陣列這玩意。so檔案的熱更新,也是把補丁so庫的路徑插入到nativeLibraryDirectories陣列的最前面。
二、Tinker
開源
Tinker已開源,Tencent/tinker ,同時有詳細的使用Wiki,Tinker使用Wiki 。
熱更新過程
Tinker的整個熱更新過程,可以理解成四個步驟。
1. Tinker整合
整合Tinker分兩大塊,一塊是Application改造,一塊是定製化功能。第一塊較為簡單,使用Annotation Processor在編譯時生成新Application;第二塊非常複雜。
2. build(計算差分)
build有兩種模式,一種是供Android Studio開發使用的Gradle模式,一種是使用Java實現的命令列模式。二者最底層,其實都是使用的tinker-patch-lib,一個用Java實現的核心庫。
3. patch(合併差分)
4. load(載入差分)
原始碼結構
Tinker的原始碼分為這麼幾大塊:
1. tinker-sample-android
顧名思義,這是一個demo,龐大!龐大!龐大!從未見過一個第三方SDK,暴露了如此多的api,可以定製如此多的功能!難怪Sophix在其官方文件中對熱更新方案做橫向對比時,把自己描述為“傻瓜式接入”,把Amigo描述為“一般”,卻把Tinker描述為“複雜”。其實微信官方也有描述,Tinker為了實現“高可用”的目標,在接入成本上做了妥協。熱補丁並不簡單,在使用之前請務必先仔細閱讀XXXX 。總的來說,感謝騰訊baba。
demo裡,示例了:
①如何控制熱更新的請求過濾、合併過程、載入過程、合併後的後續處理、升級熱更新模組本身的程式碼。
②如何改造Application。
③Gradle整合模式的42個參考配置。 42個參考配置!42個參考配置!42個參考配置!
這裡讓大家放心的是,複雜的是Tinker的定製化開發,而不是給到cp的SDK。我們可以對外隱藏這些定製化開發的細節。
2. tinker-build
這是熱更新過程中build步驟的原始碼,有三個子模組,tinker-patch-lilb是核心程式碼,tinker-patch-cli是命令列模式的原始碼,tinker-patch-gradle-plugin是Gradle模式的原始碼。
3. tinker-android
這是熱更新過程中patch和load步驟的原始碼,隨Apk、遊戲執行在客戶端。也有Application改造時用到的Annotation Processor庫的原始碼。
4. tinker-commons
tinker-build所用到的基礎庫。
5. third-party
tinker-build所用到的第三方庫。
三、SDK熱更新實現
1. 指定程式碼熱更新。
我們回顧熱更新的4個步驟,第二個步驟是build(計算差分),輸入的是一個基準包和一個新版包,輸出的是差分包(或補丁)。如果在這個核心演算法的裡,增加一項功能,只比對SDK的程式碼,不比對遊戲的程式碼 ,是不是就可行了呢?
這種思路,有一點點站在業務層反推實現方案的嫌疑。但最後實踐檢驗,還真可以這樣。
我們回顧demo中的一項功能,升級熱更新模組本身的程式碼 ,那Tinker如何去實現這一個功能的呢?Tinker通過一個配置表來配置熱更新模組本身的程式碼。
<issue id="dex"> <loader value="com.tencent.tinker.loader.*"/> <loader value="tinker.sample.android.SampleApplication"/> <loader value="tinker.sample.android.app.GameClass"/> </issue>
這裡的配置是支援Pattern的。
把遊戲的程式碼也當熱更新模組本身的程式碼配置,是否OK?
結果是不OK。能夠build,但是不能patch、load。
網上所有的部落格,其實都有提到Tinker自研了一套dex diff、patch的演算法,可以高效地比對出差分包,並在客戶端patch出目標dex包。難道是Tinker這一套演算法不支援這樣地新增非熱更新模組程式碼?
這時候我們回過頭理解這一套dex diff、patch演算法,也許你都還用不上深入理解,看到上面的幾行字,說不定就能發現玄妙。有興趣可以把視野停在此處思考一下。
- 佔
- 位
- 符
Tinker的dex diff、patch演算法,說到底,就是一個可逆的過程,先計算兩個包的區別特徵,再通過一個包以及區別特徵,來推出另一個包。這套演算法是從dex的方法和指令維度進行全量合成。
用簡單的公式來表示:
服務端diff:New.dex - base.dex = patch.file
客戶端patch: base.dex + patch.file = New.dex
在上面的嘗試中,客戶端patch所用到的base.dex,已經不是服務端diff所用到的base.dex了。前者是遊戲包的dex,後者是SDK的dex。
擺在我們面前的選擇只有兩個,一個是理解並修改這套演算法,另一個是,另闢蹊徑。但前者,顯然不是3、5天調研時間能完成的。
柳暗花明又一村~
除錯原始碼時,發現了這玩意:
@Override public void onAllPatchesEnd() throws Exception { if (!hasDexChanged) { Logger.d("No dexes were changed, nothing needs to be done next."); return; } if (config.mIsProtectedApp) { generateChangedClassesDexFile(); } else { generatePatchInfoFile(); } addTestDex(); }
超想用抖音的BGM描述一下內心的心情,“這是什麼造型,挺別緻哦~”
在開發者配置isProtectedApp
的true或false時,其實Tinker走了兩套不一樣的差分演算法。false時,走Tinker自研的差分演算法;true時,走常規的差分演算法。
這套差分演算法是基於Class類的,可以被客戶端patch、load的。
接著,就是對配置表loader配置的復刻了,這裡思路比較清晰,增加一個isSDKMode配置,如果為true則走SDK模式,不去讀loader配置,而去讀loader配置的復刻欄位sdkPackage,用來填寫需要更新的SDK程式碼。我們SDK是com.divin.*。
<issue id="dex"> <loader value="com.tencent.tinker.loader.*"/> <loader value="tinker.sample.android.SampleApplication"/> <loader value="tinker.sample.android.app.GameClass"/> <isSDKMode value="true"/> <sdkPackage value="com.divin.*"/> </issue>
搞定!
2. 指定資原始檔熱更新。
我們先說一下不同資源,在Apk包中的目錄結構。
解壓縮Apk包後,根目錄下有assets和res資料夾。如果你用這個Apk包的目錄結構 和Android工程原始碼的目錄結構 做對比,assets中的內容是一一對應的,Apk包的res資料夾也能Android工程原始碼的res中資源一一對應起來,但是會少了Android工程原始碼的res/values資料夾下的檔案。
這些res/values檔案去哪兒了呢?
resources.arsc
所以,指定資原始檔熱更新要分兩大塊,一塊是不能一一對應上的res/values檔案,一塊是能一一對應上的assets檔案和res檔案。
不能一一對應上的res/values檔案
重述一下,Android工程原始碼中,不能一一對應上的res/values檔案,到Apk檔案目錄的resources.arsc檔案中去了。
我們回顧Tinker更新步驟,第2步build,通過diff演算法生成差分包,第3步patch,通過patch演算法生成新的res資源包,第4步load,載入新的res資源包。
用SDK的resources.arsc生成差分包,再用遊戲的舊resources.arsc計算新的resources.arsc?
這樣,又面臨我們做指定程式碼熱更新時面臨的問題。擺在我們面前的選擇只有兩個,一個是理解並修改這套演算法,另一個是,另闢蹊徑。
What?? 逼我們上梁山??
這裡面臨兩個問題:
- 我們無法計算出新的resources.arsc檔案。
- 就算計算出來了也沒用,因為resources.arsc不僅有SDK的資源,還有遊戲的資源。使用SDK的resources.arsc檔案,必然會讓遊戲因找不到資源而崩潰!
車到山前必有路,逐個擊破!
第一個問題。其實Res資源也是有兩種演算法,一種是Tinker自研的diff、patch演算法,一種是不計算差分,完整下載,完整載入。具體到每一個資源,到底走哪種演算法,其實是根據資源的大小做的判斷,預設是100kb以下的完整下載、完整載入,100kb以上的走自研的diff、patch演算法。
那我們就強行走第二種演算法,這裡要做的事情有二件:
- 控制差分的判斷邏輯,強行走第二種演算法。
- 修改patch時的CSC、md5完整性判斷邏輯。(TODO:預研時,我是直接去掉了,實際業務中,需要增加新的完整性判斷邏輯)
第二個問題。我們細讀Tinker的資源load流程,它生效的原理是Instant Run那一套流派一 。
流派一原理簡述如下:
-
先獲取預設的AssetManager,通過反射獲取其構造方法
-
通過AssertManager的addAssetPath函式,加入外部的資源路徑
-
將Resources的mAssets的欄位設為前面的AssertManager
這一套,所實現的效果,就是用addAssetPath用新的Res資源包替換原來的Res資源包。慢著,add ,Asset ,Path ,新增資源目錄,能不能新增多個呢?
看Android原始碼找找希望吧。
/** * @deprecated Use {@link #setApkAssets(ApkAssets[], boolean)} * @hide */ @Deprecated @UnsupportedAppUsage public int addAssetPath(String path) { return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/); } private int addAssetPathInternal(String path, boolean overlay, boolean appAsLib) { Preconditions.checkNotNull(path, "path"); synchronized (this) { ensureOpenLocked(); final int count = mApkAssets.length; // See if we already have it loaded. for (int i = 0; i < count; i++) { if (mApkAssets[i].getAssetPath().equals(path)) { return i + 1; } } final ApkAssets assets; try { if (overlay) { // TODO(b/70343104): This hardcoded path will be removed once // addAssetPathInternal is deleted. final String idmapPath = "/data/resource-cache/" + path.substring(1).replace('/', '@') + "@idmap"; assets = ApkAssets.loadOverlayFromPath(idmapPath, false /*system*/); } else { assets = ApkAssets.loadFromPath(path, false /*system*/, appAsLib); } } catch (IOException e) { return 0; } mApkAssets = Arrays.copyOf(mApkAssets, count + 1); mApkAssets[count] = assets; nativeSetApkAssets(mObject, mApkAssets, true); invalidateCachesLocked(-1); return count + 1; } }
BGM再來一次,“這是什麼造型,挺別緻哦~”
mApkAssets,偉大的陣列!
獲取新的AssetsManager,先新增熱更新的新Res資源,再新增遊戲原本的舊Res資源。這樣,會先去第一個Res中找資源,第一個Res中找不到再去第二個Res中找。
所以,這裡是能實現對SDK資源的新增、修改,但是不能刪去資源,同時也不支援R檔案直接引用,因為R檔案的地址是常量,在Apk編譯時,這些常量會跟著引用R檔案的業務Class走。如果想保持R檔案的地址不變,可以修改APT編譯器,也能通過Apktool來做,當然還有上面提到的資源熱更新流派二 。
能一一對應上的assets檔案和res檔案。
這裡實現起來,其實和程式碼熱更新有些相似。Tinker預設有這樣的配置表:
<issue id="resource"> <!--what resource in apk are expected to deal with tinkerPatch--> <!--it support * or ? pattern.--> <!--you must include all your resources in apk here--> <!--otherwise, they won't repack in the new apk resources--> <pattern value="res/*"/> <pattern value="assets/*"/> <pattern value="resources.arsc"/> <pattern value="AndroidManifest.xml"/> <!--ignore add, delete or modify resource change--> <!--Warning, we can only use for files no relative with resources.arsc, such as assets files--> <!--it support * or ? pattern.--> <!--Such as I want assets/meta.txt use the base.apk version whatever it is change ir not.--> <ignoreChange value="assets/sample_meta.txt"/> <!--ignore any warning caused by add, delete or modify changes on resources specified by this pattern.--> <ignoreChangeWarning value="" /> <!--default 100kb--> <!--for modify resource, if it is larger than 'largeModSize'--> <!--we would like to use bsdiff algorithm to reduce patch file size--> <largeModSize value="10000000"/> </issue>
增加一個isSDKMode配置,如果為true則走SDK模式,不去讀ignoreChange配置,而去讀ignoreChange配置的復刻欄位sdkResPath,
<issue id="resource"> <!--what resource in apk are expected to deal with tinkerPatch--> <!--it support * or ? pattern.--> <!--you must include all your resources in apk here--> <!--otherwise, they won't repack in the new apk resources--> <pattern value="res/*"/> <pattern value="assets/*"/> <pattern value="resources.arsc"/> <pattern value="AndroidManifest.xml"/> <!--ignore add, delete or modify resource change--> <!--Warning, we can only use for files no relative with resources.arsc, such as assets files--> <!--it support * or ? pattern.--> <!--Such as I want assets/meta.txt use the base.apk version whatever it is change ir not.--> <isSDKMode value="true"> <sdkResPath value="assets/only_use_to_test_tinker_resource.txt"/> <sdkResPath value="assets/divin/*"/> <sdkResPath value="res/*/divin_*"/> <sdkResPath value="resources.arsc"/> <sdkResPath value="AndroidManifest.xml"/> <ignoreChange value="assets/sample_meta.txt"/> <!--ignore any warning caused by add, delete or modify changes on resources specified by this pattern.--> <ignoreChangeWarning value="" /> <!--default 100kb--> <!--for modify resource, if it is larger than 'largeModSize'--> <!--we would like to use bsdiff algorithm to reduce patch file size--> <largeModSize value="10000000"/> </issue>
至於差分演算法,倒是沒有什麼問題。不論是Tinker自研的diff、patch演算法,還是完整下載、完整載入,都可行,畢竟要更新的檔案都是SDK獨有的,遊戲並沒有共用。當然啦,使用Tinker自研的diff、patch演算法肯定是最好的,畢竟可以減小差分包大小。
3. 指定so檔案熱更新。
略。
四、效果
SDK熱更新範圍
com.divin. assets/divin/ divin_ divin_
SDK熱更新限制
- 無法更新AndroidManifest
- 在部分三星android-21的機型上無法生效
- 資源替換不支援遠端View, 如應用icon.
- 不支援SDK直接R檔案引用資源
整合配置
1. app.gradle
dependencies { // tinker-android-lib(本地module) 為必須依賴 // anno為可選依賴,用於使用AnnotationProcessor生成Application //implementation("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true } implementation project(':tinker-android::tinker-android-lib') annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true } compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true } }
2. 修改Application
參考SampleApplicationLike.java
改造Application.
3. 更新
TinkerLogic.patch(Context context)
↓來來來,點一點小愛心。愛心是動力~