前言
今年真是熱補丁框架的洪荒之力爆發的一年,短短幾個月內,已經出現了好幾個熱修復的框架了,基本上都是大同小異,這裏我就不過多的去評論這些框架。只有自己真正的去經歷過,你纔會發現其中的
大寫的坑
事實上,現在出現的大多數熱修復的框架,穩定性和兼容性都還達不到要求,包括阿里的Andfix,據同事說,我們自己的app原本沒有多少crash,接入了andfix倒引起了一部分的crash,這不是一個熱修復框架所應該具有的“變態功能”。雖然阿里百川現在在大力推廣這套框架,我依舊不看好,只是其思路還是有學習價值的。
Dex的熱修復總結
Dex的熱修復目前來看基本上有四種方案:
- 阿里系的從native層入手,見AndFix
- QQ空間的方案,插樁,見安卓App熱補丁動態修復技術介紹
- 微信的方案,見微信Android熱補丁實踐演進之路,dexDiff和dexPatch,方法很牛逼,需要全量插入,但是這個全量插入的dex中需要刪除一些過早加載的類,不然同樣會報class is pre verified異常,還有一個缺點就是合成佔內存和內置存儲空間。微信讀書的方式和微信類似,見Android Patch 方案與持續交付,不過微信讀書是miniloader方式,啓動時容易ANR,在我錘子手機上變現出來特別明顯,長時間的卡圖標現象。
- 美團的方案,也就是instant run的方案,見Android熱更新方案Robust
此外,微信的方案是多classloader,這種方式可以解決用multidex方式在部分機型上不生效patch的問題,同時還帶來一個好處,這種多classloader的方式使用的是instant run的代碼,如果存在native library的修復,也會帶來極大的方便。
Native Library熱修復總結
而native libraray的修復,目前來說,基本上有兩種方案。。
- 類似multidex的dex方式,插入目錄到數組最前面,具體文章見Android熱更新之so庫的熱更新,需要處理系統的兼容性問題,系統分隔線是Android 6.0
- 第二種方式需要依賴多classloader,在構造BaseDexClassLoader的時候,獲取原classloader的native library,通過環境變量分隔符(冒號),將patch的native library與原目錄進行連接,patch目錄在前,這樣同樣可以達到修復的目的,缺點是需要依賴dex的熱修復,優點是應用native library時不需要處理兼容性問題,當然從patch中釋放出來的時候也需要處理兼容性問題。
第二種方式的實現可以看看BaseDexClassLoader的構造函數
BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent)
只需要在修復dex的同時,如果有native library,則獲取原來的路徑與patch的路徑進行連接,僞代碼如下:
nativeLibraryPath = 獲取與原始路徑;
nativeLibraryPath = patchNativeLibraryPath + File.pathSeparator + nativeLibraryPath;
IncrementalClassLoader inject = IncrementalClassLoader.inject(
classLoader,
nativeLibraryPath,
optDir.getAbsolutePath(),
dexList);
而這種方式需要強依賴dex的修復,如果沒有dex,就無能爲例了,實際情況基本上是兩種方式交叉使用,在沒有dex的情況下,使用另外一種方式。
而native library還有一個坑,就是從patch中釋放so的過程,這個過程需要處理兼容性,在android 21以下,通過下面這個函數去釋放
com.android.internal.content.NativeLibraryHelper.copyNativeBinariesIfNeededLI
而在andrdod 21及以上,則通過下面的這幾個函數去釋放
com.android.internal.content.NativeLibraryHelper$Handle.create()
com.android.internal.content.NativeLibraryHelper.findSupportedAbi()
com.android.internal.content.NativeLibraryHelper.copyNativeBinaries()
資源的熱修復
而對於資源的熱修復,其實主要還是和插件化的思路是一樣的,具體實現可以參考兩個
- Atlas或者攜程的插件化框架
- Instant run的資源處理方式,甚至可以做到運行期立即生效。
本篇文章就來說說資源的熱修復的實現思路,在這之前,需要貼兩個鏈接,以下文章的內容基於這兩個鏈接去實現,所以務必先看看,不然會一臉懵逼。一個是instant run的源碼,自備梯子,另一個是馮老師寫的一個類,這個類在Atlas中出現過,後來被馮老師重寫了,同樣自備梯子。
重要的事情說三遍
自備梯子
自備梯子
自備梯子
資源的熱修復實現,主要由一下幾個步驟組成:
- 提前感知系統兼容性,不兼容則不進行後續操作
- 服務器端生成patch的資源,客戶端應用patch的資源
- 替換系統AssetManger,加入patch的資源
對於第一步,我們需要先看看instant run對於資源部分的實現,其僞代碼如下
AssetManager newAssetManager = new AssetManager();
newAssetManager.addAssetPath(externalResourceFile)
// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
newAssetManager.ensureStringBlocks();
// Find the singleton instance of ResourcesManager
ResourcesManager resourcesManager = ResourcesManager.getInstance();
// Iterate over all known Resources objects
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
for (WeakReference<Resources> wr : resourcesManager.mActiveResources.values()) {
Resources resources = wr.get();
// Set the AssetManager of the Resources instance to our brand new one
resources.mAssets = newAssetManager;
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
代碼很簡單,通過調用addAssetPath將patch的資源加到新建的AssetManager對象中,然後將內存中所有Resources對象中的AssetManager對象替換爲新建的AssetManager對象。當然還需要處理兼容性問題,對於兼容性問題,則需要用到馮老師的Hack類(這裏我爲了與原來馮老師沒有重寫前的Hack類做區分,將其重命名了HackPlus,意思你懂的),具體Hack過程請參考Atlas或者攜程的插件化框架的實現,然後基於instant run進行實現,當然這種方式有一部分資源是修復不了了,比如notification。
坑麼,你沒遇到,總是說沒有,遇到了,坑無數。
- 主要的分界線是Android 19 和 Android N
首先需要拿到App運行後內存中的Resources對象
- Android N,通過ResourcesManager中的mResourceReferences去獲取Resources對象,是個ArrayList對象
- Android 19到Android N(不含N),通過ResourcesManager中的mActiveResources去獲取Resources對象,是個ArrayMap對象
- Android 19以下,通過ActivityThread的mActiveResources去獲取Resources對象,是個HashMap對象。
- 接着就是替換Resources中的AssetManager對象
- Android N,替換的是Resources對象中的mResourcesImpl成員變量中的mAssets成員變量。
- Android N以前,替換的是Resources對象中的mAssets成員變量。
- 對於Android 19以下,ActivityThread是通過ActivityThread中的靜態函數currentActivityThread獲取的的,這裏有個坑,如果在主線程獲取還好,但是萬一在子線程獲取,在低版本的Android上可能就是Null,因爲在低版本,這個變量是通過ThreadLocal進行存儲的,對於這種情況,只要檢測當前線程是不是主線程,如果是主線程,則直接獲取,如果不是主線程,則阻塞當前線程,然後切換到主線程獲取,獲取完成後通知阻塞線程。
這裏我已經基本實現了反射檢測系統支持性相關的代碼,主要就是對以上分析的內容做反射檢測,一旦發生異常,則不再進行資源的修復,代碼如下(HackPlus的源碼見上面的Hack.java的源碼):
//這個類用於保存hack過程中發生的異常,一旦mAssertionErr不爲空,則表示當前系統不支持資源的熱修復,直接return,不進行修復
public class AssertionArrayException extends Exception {
private static final long serialVersionUID = 1;
private List<AssertionException> mAssertionErr;
public AssertionArrayException(String str) {
super(str);
this.mAssertionErr = new ArrayList();
}
public void addException(AssertionException hackAssertionException) {
this.mAssertionErr.add(hackAssertionException);
}
public void addException(List<AssertionException> list) {
this.mAssertionErr.addAll(list);
}
public List<AssertionException> getExceptions() {
return this.mAssertionErr;
}
public static AssertionArrayException mergeException(AssertionArrayException assertionArrayException, AssertionArrayException assertionArrayException2) {
if (assertionArrayException == null) {
return assertionArrayException2;
}
if (assertionArrayException2 == null) {
return assertionArrayException;
}
AssertionArrayException assertionArrayException3 = new AssertionArrayException(assertionArrayException.getMessage() + ";" + assertionArrayException2.getMessage());
assertionArrayException3.addException(assertionArrayException.getExceptions());
assertionArrayException3.addException(assertionArrayException2.getExceptions());
return assertionArrayException3;
}
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
for (AssertionException hackAssertionException : this.mAssertionErr) {
stringBuilder.append(hackAssertionException.toString()).append(";");
try {
if (hackAssertionException.getCause() instanceof NoSuchFieldException) {
Field[] declaredFields = hackAssertionException.getHackedClass().getDeclaredFields();
stringBuilder.append(hackAssertionException.getHackedClass().getName()).append(".").append(hackAssertionException.getHackedFieldName()).append(";");
for (Field field : declaredFields) {
stringBuilder.append(field.getName()).append(File.separator);
}
} else if (hackAssertionException.getCause() instanceof NoSuchMethodException) {
Method[] declaredMethods = hackAssertionException.getHackedClass().getDeclaredMethods();
stringBuilder.append(hackAssertionException.getHackedClass().getName()).append("->").append(hackAssertionException.getHackedMethodName()).append(";");
for (int i = 0; i < declaredMethods.length; i++) {
if (hackAssertionException.getHackedMethodName().equals(declaredMethods[i].getName())) {
stringBuilder.append(declaredMethods[i].toGenericString()).append(File.separator);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
stringBuilder.append("@@@@");
}
return stringBuilder.toString();
}
}
//具體Hack類,主要Hack AssetManager相關類,
public class AndroidHack {
private static final String TAG = "AndroidHack";
//exception
public static AssertionArrayException exceptionArray;
//resources
public static HackPlus.HackedClass<android.content.res.AssetManager> AssetManager;
public static HackedMethod0<android.content.res.AssetManager, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> AssetManager_construct;
public static HackPlus.HackedMethod1<Integer, android.content.res.AssetManager, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked, String> AssetManager_addAssetPath;
public static HackedMethod0<Void, android.content.res.AssetManager, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> AssetManager_ensureStringBlocks;
//>=19
public static HackedClass<Object> ResourcesManager;
public static HackedMethod0<Object, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> ResourcesManager_getInstance;
public static HackedField<Object, ArrayMap> ResourcesManager_mActiveResources;
//>=24
public static HackedField<Object, ArrayList> ResourcesManager_mResourceReferences;
//<19
public static HackedClass<Object> ActivityThread;
public static HackedMethod0<Void, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> ActivityThread_currentActivityThread;
public static HackedField<Object, HashMap> ActivityThread_mActiveResources;
//>=24
public static HackedField<Resources, Object> Resources_ResourcesImpl;
public static HackedField<Object, Object> ResourcesImpl_mAssets;
//<24
public static HackedField<Resources, Object> Resources_mAssets;
public static boolean sIsIgnoreFailure;
public static boolean sIsReflectAvailable;
public static boolean sIsReflectChecked;
public static boolean defineAndVerify() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return false;
}
if (sIsReflectChecked) {
return sIsReflectAvailable;
}
long startHack = System.currentTimeMillis();
try {
initAssertion();
hackResources();
if (exceptionArray != null) {
Logger.e(TAG, "Hack error:" + AndroidHack.exceptionArray);
sIsReflectAvailable = false;
return sIsReflectAvailable;
}
sIsReflectAvailable = true;
return sIsReflectAvailable;
} catch (Throwable e) {
sIsReflectAvailable = false;
Logger.d(TAG, e);
} finally {
sIsReflectChecked = true;
long stopHack = System.currentTimeMillis();
Logger.e(TAG, "Hack spend time: " + (stopHack - startHack) + " ms");
}
return sIsReflectAvailable;
}
private static void initAssertion() {
HackPlus.setAssertionFailureHandler(new AssertionFailureHandler() {
@Override
public void onAssertionFailure(final AssertionException failure) {
if (!sIsIgnoreFailure) {
if (exceptionArray == null) {
exceptionArray = new AssertionArrayException("Hack assert failed");
}
exceptionArray.addException(failure);
}
}
});
}
private static void hackResources() {
//Hack AssetManager
AssetManager = HackPlus.into(AssetManager.class);
AssetManager_construct = AssetManager.constructor().withoutParams();
AssetManager_addAssetPath = AssetManager.method("addAssetPath").returning(int.class).withParam(String.class);
AssetManager_ensureStringBlocks = AssetManager.method("ensureStringBlocks").withoutParams();
//大於19時,開始有ResourcesManager這個類,通過這個類去替換內存中的AssetManager對象
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
ResourcesManager = HackPlus.into("android.app.ResourcesManager");
ResourcesManager_getInstance = ResourcesManager.staticMethod("getInstance").returning(ResourcesManager.getClazz()).withoutParams();
//Android N的時候,將Resources對象移到了mResourceReferences中
if (Build.VERSION.SDK_INT >= 24) {
// N moved the resources to mResourceReferences
ResourcesManager_mResourceReferences = ResourcesManager.field("mResourceReferences").ofType(ArrayList.class);
} else {
//Android N之前的版本,Resources隨心則在mActiveResources對象中
// Pre-N
ResourcesManager_mActiveResources = ResourcesManager.field("mActiveResources").ofType(ArrayMap.class);
}
} else {
//在Andorid 19之前,沒有ResourcesManager對象,通過ActivityThread去操作,但是通過ActivityThread操作有個坑,在早期的版本中,ActivityThread是保存在ThreadLocal對象中的,如果你要在子線程中去拿,就會出問題,所以這裏也需要Hack一下。
ActivityThread = HackPlus.into("android.app.ActivityThread");
ActivityThread_currentActivityThread = ActivityThread.staticMethod("currentActivityThread").withoutParams();
ActivityThread_mActiveResources = ActivityThread.field("mActiveResources").ofType(HashMap.class);
}
//在Android N中,AssetManager對象從Resources對象中的mAssets成員變量轉移到了mResourcesImpl成員變量中mAssets成員 變量
if (Build.VERSION.SDK_INT >= 24) {
// N moved the mAssets inside an mResourcesImpl field
Resources_ResourcesImpl = HackPlus.into(Resources.class).field("mResourcesImpl");
ResourcesImpl_mAssets = HackPlus.into(Resources_ResourcesImpl.getType()).field("mAssets");
} else {
// Pre-N
Resources_mAssets = HackPlus.into(Resources.class).field("mAssets");
}
}
private static Object _sActivityThread;
static class ActivityThreadGetter implements Runnable {
ActivityThreadGetter() {
}
public void run() {
try {
_sActivityThread = AndroidHack.ActivityThread_currentActivityThread.invoke().statically();
} catch (Exception e) {
e.printStackTrace();
}
synchronized (AndroidHack.ActivityThread_currentActivityThread) {
AndroidHack.ActivityThread_currentActivityThread.notify();
}
}
}
//獲取ActivityThread的Hack方式,通過判斷是否是主線程,如果不是主線程,在阻塞當前線程,切換到主線程去拿
public static Object getActivityThread() throws Exception {
if (_sActivityThread == null) {
if (Thread.currentThread().getId() == Looper.getMainLooper().getThread().getId()) {
_sActivityThread = AndroidHack.ActivityThread_currentActivityThread.invoke().statically();
} else {
// In older versions of Android (prior to frameworks/base 66a017b63461a22842)
// the currentActivityThread was built on thread locals, so we'll need to try
// even harder
Handler handler = new Handler(Looper.getMainLooper());
synchronized (AndroidHack.ActivityThread_currentActivityThread) {
handler.post(new ActivityThreadGetter());
AndroidHack.ActivityThread_currentActivityThread.wait();
}
}
}
return _sActivityThread;
}
}
使用的時候,只要在加載patch資源前,調用如下方法進行檢測
if(!AndroidHack.defineAndVerify()){
//不加載patch資源
return;
}
//加載patch資源邏輯
patch資源的生成比較麻煩,我們放在最後面說明,現在假設我們有一個包含整個apk的資源的文件,需要運行時替換,現在來實現上面的加載patch資源的邏輯,具體邏輯上面反射的時候已經說明了,這時候只需要調用上面反射獲取的包裝類,進行替換即可,直接看代碼中的註釋:
public class ResourceLoader {
private static final String TAG = "ResourceLoader";
public static boolean patchResources(Context context, File patchResource) {
try {
if (context == null || patchResource == null){
return false;
}
if (!patchResource.exists()) {
return false;
}
//通過構造函數new一個AssetManager對象
AssetManager newAssetManager = AndroidHack.AssetManager_construct.invoke().statically();
//調用AssetManager對象的addAssetPath方法添加patch資源
int cookie = AndroidHack.AssetManager_addAssetPath.invokeWithParam(patchResource.getAbsolutePath()).on(newAssetManager);
//添加成功時cookie必然大於0
if (cookie == 0) {
Logger.e(TAG, "Could not create new AssetManager");
return false;
}
// 在Android 19以前需要調用這個方法,但是Android L後不需要,實際情況Andorid L上調用也不會有問題,因此這裏不區分版本
// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
AndroidHack.AssetManager_ensureStringBlocks.invoke().on(newAssetManager);
//獲取內存中的Resource對象的弱引用
Collection<WeakReference<Resources>> references;
if (Build.VERSION.SDK_INT >= 24) {
// Android N,獲取的是一個ArrayList,直接賦值給references對象
// Find the singleton instance of ResourcesManager
Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically();
//noinspection unchecked
references = (Collection<WeakReference<Resources>>) AndroidHack.ResourcesManager_mResourceReferences.on(resourcesManager).get();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
//Android 19以上 獲得的是一個ArrayMap,調用其values方法後賦值給references
// Find the singleton instance of ResourcesManager
Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically();
@SuppressWarnings("unchecked")
ArrayMap<?, WeakReference<Resources>> arrayMap = AndroidHack.ResourcesManager_mActiveResources.on(resourcesManager).get();
references = arrayMap.values();
} else {
//Android 19以下,通過ActivityThread獲取得到的是一個HashMap對象,通過其values方法獲得對象賦值給references
Object activityThread = AndroidHack.getActivityThread();
@SuppressWarnings("unchecked")
HashMap<?, WeakReference<Resources>> map = (HashMap<?, WeakReference<Resources>>) AndroidHack.ActivityThread_mActiveResources.on(activityThread).get();
references = map.values();
}
//遍歷獲取到的Ressources對象的弱引用,將其AssetManager對象替換爲我們的patch的AssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = wr.get();
// Set the AssetManager of the Resources instance to our brand new one
if (resources != null) {
if (Build.VERSION.SDK_INT >= 24) {
Object resourceImpl = AndroidHack.Resources_ResourcesImpl.get(resources);
AndroidHack.ResourcesImpl_mAssets.set(resourceImpl, newAssetManager);
} else {
AndroidHack.Resources_mAssets.set(resources, newAssetManager);
}
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
return true;
} catch (Throwable throwable) {
Logger.e(TAG, throwable);
throwable.printStackTrace();
}
return false;
}
}
這樣一來,就在Appliction啓動的時候完成了資源的熱修復,當然我們也可以像instant run那樣,把activity也處理,不過我們簡單起見,讓其重啓生效,所以activity就不處理了。
於是,我們Appliction的onCreate()中的代碼就變成了下面這個樣子
if (hasResourcePatch){
if (!AndroidHack.defineAndVerify()) {
//不加載patch資源
return;
}
//加載patch資源邏輯
File file = new File("/path/to/patchResource.apk");
ResourceLoader.patchResources(this, file);
}
這裏有一個坑。
patch應用成功後,如果要刪除patch,patch文件的刪除一定要謹慎,最好先通過配置文件標記patch不可用,下次啓動時檢測該標記,然後再刪除,運行期刪除正在使用的patch文件會導致所有進程的重啓,Application中的所有邏輯會被初始化一次。
還差最後一步,patch的資源從哪裏來,這裏主要講兩種方式。
- 直接下發整個apk文件,全量的資源,想怎麼用就怎麼用,當然缺點很明顯,文件太大了,下載容易出錯,不過也最簡單。
- 下發patch部分的資源,在客戶端和沒改變的資源合成新的apk,這種方式的優點是文件小,缺點是合成時佔內存,需要開啓多進程去合成,比較複雜,沒有辦法校驗合成文件的md5值。
無論哪一種方式,都需要public.xml去固定資源id。
這裏討論的是第二種方式,所以給出精簡版的實現思路:
首先需要生成public.xml,public.xml的生成通過aapt編譯時添加-P參數生成。相關代碼通過gradle插件去hook Task無縫加入該參數,有一點需要注意,通過appt生成的public.xml並不是可以直接用的,該文件中存在id類型的資源,生成patch時應用進去編譯的時候會報resource is not defined,解決方法是將id類型的資源單獨記錄到ids.xml文件中,相當於一個聲明過程,編譯的時候和public.xml一樣,將ids.xml也參與編譯即可。
/**
* 添加aapt addition -P選項
*/
String processResourcesTaskName = variant.variantData.getScope().getGenerateRClassTask().name
ProcessAndroidResources processResourcesTask = (ProcessAndroidResources) project.tasks.getByName(processResourcesTaskName)
Closure generatePubicXmlClosure = {
if (processResourcesTask) {
//添加-P 參數,生成public.xml
AaptOptions aaptOptions = processResourcesTask.aaptOptions
File outPublicXml = new File(outputDir, PUBLIC_XML)
aaptOptions.additionalParameters("-P", outPublicXml.getAbsolutePath())
processResourcesTask.setAaptOptions(aaptOptions)
}
}
/**
* public.xml中對一些選項進行剔除,目前處理id類型資源,不然應用的時候編譯不過,會報resource is not defined,主要是生成一個ids.xml,相當於對這部分資源進行聲明
*/
Closure handlePubicXmlClosure = {
if (processResourcesTask) {
File outPublicXml = new File(outputDir, PUBLIC_XML)
if (outPublicXml.exists()) {
SAXReader reader = new SAXReader();
Document document = reader.read(outPublicXml);
Element root = document.getRootElement();
List<Element> childElements = root.elements();
File idsFile = new File(outPublicXml.getParentFile(), IDS_XML)
if (idsFile.exists()) {
idsFile.delete()
}
if (!idsFile.exists()) {
idsFile.getParentFile().mkdirs()
idsFile.createNewFile()
}
idsFile.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
idsFile.append("\n")
idsFile.append("<resources>")
idsFile.append("\n")
for (Element child : childElements) {
String attrName = child.attribute("name").value
String attrType = child.attribute("type").value
if ("id".equalsIgnoreCase(attrType)) {
String value = child.asXML()
idsFile.append(" <item type=\"id\" name=\"${attrName}\" />\n")
project.logger.error "write id item ${attrName}"
}
}
idsFile.append("</resources>")
}
}
}
if (processResourcesTask) {
processResourcesTask.doFirst(generatePubicXmlClosure);
processResourcesTask.doLast(handlePubicXmlClosure)
}
在編譯資源之前,將public.xml和ids.xml文件拷貝到資源目錄values下,並檢測values.xml文件中是否有已經定義的id類型的資源,如果有,則從ids.xml文件中將其刪除,否則會報resource is already defined的異常,也會編譯不過去。
/**
* 應用public.xml
*/
String mergeResourcesTaskName = variant.variantData.getScope().getMergeResourcesTask().name
MergeResources mergeResourcesTask = (MergeResources) project.tasks.getByName(mergeResourcesTaskName)
Closure applyPubicXmlClosure = {
if (mergeResourcesTask != null) {
if (oldTinkerDir != null && needApplyPublicXml) {
File publicXmlFile = new File(oldTinkerDir, "${dirName}/${PUBLIC_XML}")
if (publicXmlFile.exists()) {
File toDir = new File(mergeResourcesTask.outputDir, "values")
project.copy {
project.logger.error "\n$variant.name:copy a ${PUBLIC_XML} from ${publicXmlFile.getAbsolutePath()} to ${toDir}/${PUBLIC_XML}"
from(publicXmlFile.getParentFile()) {
include PUBLIC_XML
rename PUBLIC_XML, "${PUBLIC_XML}"
}
into(toDir)
}
} else {
logger.error("${publicXmlFile.absolutePath} does not exist")
}
File valuesFile = new File(mergeResourcesTask.outputDir, "values/values.xml")
File oldIdsFile = new File(oldTinkerDir, "${dirName}/${IDS_XML}")
if (valuesFile.exists() && oldIdsFile.exists()) {
SAXReader valuesReader = new SAXReader();
Document valuesDocument = valuesReader.read(valuesFile);
Element valuesRoot = valuesDocument.getRootElement()
List<Element> publicIds = valuesRoot.selectNodes("item[@type='id']")
if (publicIds != null && publicIds.size() != 0) {
Set<String> existIdItems = new HashSet<String>();
for (Element element : publicIds) {
existIdItems.add(element.attribute("name").value)
}
logger.error "existIdItems:${existIdItems}"
SAXReader oldIdsReader = new SAXReader();
Document oldIdsDocument = oldIdsReader.read(oldIdsFile);
Element oldIdsRoot = oldIdsDocument.getRootElement();
List<Element> oldElements = oldIdsRoot.elements();
if (oldElements != null && oldElements.size() != 0) {
File newIdsFile = new File(mergeResourcesTask.outputDir, "values/${IDS_XML}")
newIdsFile.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
newIdsFile.append("\n")
newIdsFile.append("<resources>")
newIdsFile.append("\n")
for (Element element : oldElements) {
String itemName = element.attribute("name").value
if (!existIdItems.contains(itemName)) {
newIdsFile.append(" ${element.asXML()}\n")
} else {
logger.error "already exist id item ${itemName}"
}
}
newIdsFile.append("</resources>")
}
}
} else {
logger.error("${valuesFile.absolutePath} does not exist")
}
} else {
logger.error "res not changed.not to apply public.xml"
}
}
}
if (mergeResourcesTask) {
mergeResourcesTask.doLast(applyPubicXmlClosure);
}
這樣一來,按照正常流程去編譯,生成的apk安裝包就可以獲得了,然後將這個new.apk和有問題的old.apk進行差量算法,這裏只考慮資源相關文件,即assets目錄,res目錄,arsc文件,AndroidManifest.xml文件,相關算法如下:
- 對比new.apk和old.apk中的所有資源相關的文件。
- 對於新增資源文件,則直接壓入patch.apk中。
- 對於刪除的資源文件,則不處理到patch.apk中。
- 對於改變的資源文件,如果是assets或者res目錄中的資源,則直接壓縮到patch.apk中,如果是arsc文件,則使用bsdiff算法計算其差量文件,壓入patch.apk,文件名不變。
- 對於改變和新增的文件,通過一個meta文件去記錄其原始文件的adler32和合成後預期文件的adler32值,以及文件名,這是個文本文件,直接壓縮到patch.apk中去。
- 對patch.apk進行簽名。
這樣做的好處是能將資源patch文件儘可能的減小到最低,實際情況嚴重下來,res目錄下的資源文件大小都非常小,沒有必要去進行diff,所以直接使用原文件,而arsc文件則相對比較大,在考慮文件大小和內存的兩個因素下,犧牲內存換大小還是ok的,所以在下發前,我們對其進行diff,生成diff文件,在客戶端進行合成最終的arsc文件。
客戶端下載到patch.apk後需要進行還原,還原的步驟如下:
- 考慮到客戶端jni的兼容性問題,bspatch算法全部使用java實現
- 首先校驗patch.apk的簽名
- 讀取壓縮包中meta文件,判斷哪些文件是新增文件,哪些文件是改變的文件。
- 遍歷patch.apk中的文件,如果是新增文件,則壓縮到new.apk文件中去
- 如果是改變的文件,如果是assets和res文件夾下的資源,則直接壓縮到new.apk文件中,如果是arsc文件,則應用bspatch算法合成最終的arsc文件,壓縮到new.apk中
- 如果文件沒有改變,則直接複製old.apk中的原始文件到new.apk中
- 以上任何一個步驟都會去校驗合成時舊文件的adler32和合成後的adler32值和meta文件中記錄的是否符合
- 由於無法驗證合成後的文件的md5值(沒有記錄哪些文件被刪除了,加上壓縮算法等原因),需要使用一種方式在加載前進行驗證,這裏使用crc32值。
- 合成成功後計算new.apk文件的crc32值,計算方式進行改進,不計算所有文件內容的crc32,爲了快速計算,只計算文件的某一個特定段的crc32值,比如文件從200字節開始到2000字節部分的crc32值,並保存在sharePrefrences中,加載patch前進行校驗crc32,校驗不通過,則直接刪除patch文件,當然這種計算方式有一定概率會把錯誤的文件當成正確的,畢竟計算的不是完整的文件,當然正確的文件是一定不會當成錯誤的,這種低概率事件可以接受。
這種方式的兼容性如何?簡單自測了下,4.0-7.0的模擬器運行全部通過,當然不排除國產奇葩ROM的兼容性,所以這裏我不宣稱100%兼容。
無圖言屌,沒圖你說個jb,先上一張沒有進行熱修復的圖:
熱修復之後的效果圖
最後送上一句話: