當你的應用發佈後次日卻發現一個重要的bug要修復,頭疼的同時你可能想着趕忙修復從新打個包發佈出去,讓用戶收到自動更新從新下載。可是萬事皆有可能,萬一隔一天又發現一個急需修復的bug呢?難道再次發佈打擾用戶一次?html
這個時候就是熱修復技術該登場的時候了,它可讓你在無需發佈新版本的前提下修復小範圍的問題。最近研究了下幾個熱修復的開源框架,其中Nuwa等框架的原理是修改了gradle的編譯task流程,替換dex的方式來實現。可是惋惜的是gradle plugin在1.5之後取消了predexdebug這個task,而Nuwa偏偏是依賴這個task的,因此致使Nuwa在gradle plugin1.5版本後沒法使用。
java
因此咱們這裏將探討另外一個熱修復框架AndFix,它的原理簡單而純粹。本文將從實戰項目應用和原理兩個角度來闡述,同時將闡述項目中引用該框架後帶來的影響(微乎其微)。android
引入git
首先AndFix的主要實現是CPP實現,並且只有幾個很小的文件。同時提供了dalvik和ART兩個版本的so經過JNI供上層Java層調用。因此顯然AndFix的一個最大優勢是支持Dalvik和ART兩種運行時環境,同時它支持Android2.3 - 6.0版本,支持arm和x86架構CPU的設備。改框架的做者團隊是支付寶,相傳已經應用到了阿里巴巴的一些應用上(真實性不詳)github
首先在你的項目中添加如下gradle依賴:
api
compile 'com.alipay.euler:andfix:0.3.1@aar'
隨後在你的自定義Application中加入一個屬性,同時添加getter方法,這裏後面要用到:安全
private PatchManager patchManager;
public PatchManager getPatchManager() { return patchManager; }
而後在Application的onCreate中初始化AndFix:服務器
// init AndFix patchManager = new PatchManager(this); patchManager.init(AppUtils.getVersionName(this)); patchManager.loadPatch();
同時繼續寫上這麼一段代碼:架構
// get patch under new thread Intent patchDownloadIntent = new Intent(this, PatchDownloadIntentService.class); patchDownloadIntent.putExtra("url", "http://xxx/patch/app-release-fix-shine.apatch"); startService(patchDownloadIntent);
這段代碼的含義後面講具體闡述,這裏你只須要知道咱們新建了一個IntentService在另起的線程中下載http://xxx/patch/app-release-fix-shine.apatch這個patch文件,而後下載完畢後調用patchManager進行熱修復工做。app
詳細的PatchDownloadIntentService代碼:
/** * 用於下載Patch熱修復文件的service */ public class PatchDownloadIntentService extends IntentService { private int fileLength, downloadLength; public PatchDownloadIntentService() { super("PatchDownloadIntentService"); } @Override protected void onHandleIntent(Intent intent) { if (intent != null) { String downloadUrl = intent.getStringExtra("url"); if (StrUtils.isNotNull(downloadUrl)) { downloadPatch(downloadUrl); } } } private void downloadPatch(String downloadUrl) { File dir = new File(Environment.getExternalStorageDirectory() + "/shine/patch"); if (!dir.exists()) { dir.mkdir(); } File patchFile = new File(dir, String.valueOf(System.currentTimeMillis()) + ".apatch"); downloadFile(downloadUrl, patchFile); if (patchFile.exists() && patchFile.length() > 0 && fileLength > 0) { try { CustomApplication.getInstance().getPatchManager().addPatch(patchFile.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } } } private void downloadFile(String downloadUrl, File file){ FileOutputStream fos = null; try { fos = new FileOutputStream(file); } catch (FileNotFoundException e) { L.e("can not find saving dir"); e.printStackTrace(); } InputStream ips = null; try { URL url = new URL(downloadUrl); HttpURLConnection huc = (HttpURLConnection) url.openConnection(); huc.setRequestMethod("GET"); huc.setReadTimeout(10000); huc.setConnectTimeout(3000); fileLength = Integer.valueOf(huc.getHeaderField("Content-Length")); ips = huc.getInputStream(); int hand = huc.getResponseCode(); if (hand == 200) { byte[] buffer = new byte[8192]; int len = 0; while ((len = ips.read(buffer)) != -1) { if (fos != null) { fos.write(buffer, 0, len); } downloadLength = downloadLength + len; } } else { L.e("response code: " + hand); } } catch (IOException e) { e.printStackTrace(); } finally { try { if (fos != null) { fos.close(); } if (ips != null) { ips.close(); } } catch (IOException e) { e.printStackTrace(); } } } }
到此,一個關鍵問題來了,就是那個.apatch文件究竟是什麼?它是怎麼來的?
熱修復開發流程和patch文件製做
首先放出大體的推薦開發流程:
簡單來講,假如咱們把目前已經上線的apk的名字叫作app-release-online.apk(即文件名),在這個發佈後咱們及時打上Tag,作一個歷史快照。當後面發現bug須要發起熱修復時,就在該Tag上新建branch進行修改,修改完畢後的apk的文件名是app-release-fix.apk,隨後咱們經過AndFix提供過的apkpatch工具來製做.apatch文件(即對比兩個apk的差別,後面將介紹),驗證無誤後,將.apatch文件發佈。這樣子已經發布的版本會實時收到patch文件並進行熱修復工做,用戶正在使用的軟件便可在不知不覺的中修復了bug。隨後咱們將修復後的代碼merge會主分支。
這裏針對咱們實際的項目進行一步步操做講解。
咱們的上線apk名字假設也爲app-release-online.apk,它其中的關於界面要顯示當前的版本號:
版本已經發布,用戶已經在使用中,隨後咱們想將前面的那個"v1.5.1"中的"v"改爲「hello world」,同時用戶是無感知的收到更新。這個時候在已發佈版本的代碼Tag上咱們修改代碼,其實就是修改一個Activity即一個java文件中的某一行。而後打包生成了一個新的apk叫作app-release-fix.apk。
而後將兩個apk文件放到項目代碼的app目錄下(這裏隨你而定,放在這裏主要是由於簽名文件也在這個文件夾下,方面使用apkpatch命令而已)。將apkpatch這個工具下載後,加入環境變量。隨後輸入命令:
apkpatch -f app-release-fix.apk -t app-release-online.apk -o D:\Work\patchresult -k debug.keystore -p xxx -a xxx -e xxx
這個時候你會發如今D:\work\patchresult文件夾中生成了:
這個.apatch就是補丁文件,而後咱們把它更名爲app-release-fix-shine.apatch,而後用FTP工具上傳到上述IntentService中指定的那個目錄。
到這裏,當用戶再次啓動app後,發現關於界面已經變成了這樣:
大功告成!熱修復成功!
固然實際開發中,若是能對patch文件進行更加精細的管理控制那就更好了,這裏經過上傳到ftp服務器,Android客戶端下載該文件進行修復也是個不錯的辦法。
同時,友盟提供了在線參數的功能,咱們能夠設置一個參數,實時的讓客戶端檢查是否須要打補丁,而後再下載patch文件進行打補丁操做。
原理淺析
.apatch實際是一個壓縮文件,解壓後以下:
meta-inf文件夾爲:
打開patch.mf文件能夠發現兩個apk的差別信息:
Manifest-Version: 1.0 Patch-Name: app-release-fix To-File: app-release-online.apk Created-Time: 30 Mar 2016 06:26:27 GMT Created-By: 1.0 (ApkPatch) Patch-Classes: com.qianmi.shine.activity.me.AboutActivity_CF From-File: app-release-fix.apk
這個Patch-CLasses標誌了哪些類有修改,這裏會顯示徹底的類名同時加上一個_CF後綴。AndFix首先會讀取這個文件裏面的東西,保存在Patch類的一個對象裏,備用。
而後咱們反編譯classes.dex來查看裏面的類,用jd-gui來查看:
能夠看到這個dex裏面只有一個class,並且在咱們所修改的方法上有一個"@MethodReplace"註解,在代碼中能夠明顯的看到了咱們加入的「hello world」這段代碼!
patchManager.init(AppUtils.getVersionName(this));
上一節咱們再Application所調用的patchManager.init方法,首先判斷傳入的版本號「1.0」是不是已有補丁對應的版本號。不是,說明APP版本已經升級,須要把老版本的clean掉。而後初始化補丁包:遍歷APP 的私有目錄(/data/data/xxx.xxx.xxx/file/apatch)下全部文件,找到以「apatch」爲後綴的文件。解析文件 ->讀取文件必要信息(主要是PATCH.MF中)->存放在mPatchs(類型:SortedSet<Patch>)中。
patchManager.loadPatch();
遍歷mPatchs,針對每一個補丁文件:安全校驗->解析dex->加載類->找到含有MethodReplace註解的方法->hook替換.
須要注意的時上述所說的是已經下載的patch文件,那麼小心下載一個patch文件時(例如上述例子中在PatchDownloadIntentService中),須要調用addpatch方法來載入新的patch文件:
CustomApplication.getInstance().getPatchManager().addPatch(patchFile.getAbsolutePath());
這個時候虛擬機就會自動的加載準備替換的class,替換被標註的方法。那麼這裏是怎麼作到的呢?這裏開始查看AndFix的相關源碼。
源碼淺析
首先Java層的入口爲AndFixManager.java,找到fixClass這個方法:
private void fixClass(Class<?> clazz, ClassLoader classLoader) { Method[] methods = clazz.getDeclaredMethods(); MethodReplace methodReplace; String clz; String meth; for (Method method : methods) { methodReplace = method.getAnnotation(MethodReplace.class); if (methodReplace == null) continue; clz = methodReplace.clazz(); meth = methodReplace.method(); if (!isEmpty(clz) && !isEmpty(meth)) { replaceMethod(classLoader, clz, meth, method); } } } /** * replace method * * @param classLoader classloader * @param clz class * @param meth name of target method * @param method source method */ private void replaceMethod(ClassLoader classLoader, String clz, String meth, Method method) { try { String key = clz + "@" + classLoader.toString(); Class<?> clazz = mFixedClass.get(key); if (clazz == null) {// class not load Class<?> clzz = classLoader.loadClass(clz); // initialize target class clazz = AndFix.initTargetClass(clzz); } if (clazz != null) {// initialize class OK mFixedClass.put(key, clazz); Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes()); AndFix.addReplaceMethod(src, method); } } catch (Exception e) { Log.e(TAG, "replaceMethod", e); } }
它以方法的粒度進行了替換,走到最後其實就是AndFix.addReplace這個方法,這個方法在AndFix.java中:
public class AndFix { static { try { Runtime.getRuntime().loadLibrary("andfix"); } catch (Throwable e) { Log.e(TAG, "loadLibrary", e); } } private static native boolean setup(boolean isArt, int apilevel); private static native void replaceMethod(Method dest, Method src); private static native void setFieldFlag(Field field); /** * replace method's body * * @param src * source method * @param dest * target method * */ public static void addReplaceMethod(Method src, Method dest) { try { replaceMethod(src, dest); initFields(dest.getDeclaringClass()); } catch (Throwable e) { Log.e(TAG, "addReplaceMethod", e); } } 。。。 }
這個Java文件載入了libandfix.so,最後實際上是調用了cpp實現的replaceMethod方法,在這個以前調用了setup方法進行了設置。走到了這裏我以爲他其實是調用了dalvik的函數來進行底層的替換,因此我以爲setup方法必定獲取了dalvik的句柄。對了這裏提一下,AndFix對於libandfix.so提供了兩個實現,一個是Dalvik的一個是ART的,因此AndFix是順利的支持兩種模式,這裏僅僅對Dalvik進行分析。
下面咱們來看libandfix.so的dalvik實現,即dalvik_method_replace.cpp
首先是native的setup函數:
extern jboolean __attribute__ ((visibility ("hidden"))) dalvik_setup( JNIEnv* env, int apilevel) { void* dvm_hand = dlopen("libdvm.so", RTLD_NOW); if (dvm_hand) { dvmDecodeIndirectRef_fnPtr = dvm_dlsym(dvm_hand, apilevel > 10 ? "_Z20dvmDecodeIndirectRefP6ThreadP8_jobject" : "dvmDecodeIndirectRef"); if (!dvmDecodeIndirectRef_fnPtr) { return JNI_FALSE; } dvmThreadSelf_fnPtr = dvm_dlsym(dvm_hand, apilevel > 10 ? "_Z13dvmThreadSelfv" : "dvmThreadSelf"); if (!dvmThreadSelf_fnPtr) { return JNI_FALSE; } jclass clazz = env->FindClass("java/lang/reflect/Method"); jClassMethod = env->GetMethodID(clazz, "getDeclaringClass", "()Ljava/lang/Class;"); return JNI_TRUE; } else { return JNI_FALSE; } }
這個dvm_hand就是dalvik的句柄,經過dlsym系統調用得到了dalvik的_Z20dvmDecodeIndirectRefP6ThreadP8_jobject和_Z13dvmThreadSelfv函數指針,這裏還針對apilevel是否大於10進行判斷。
這兩個函數在後面的替換Method中是直接用到的,換句話而已,AndFix實際上最終是調用了dalvik的上述兩個方法來獲取源方法和目標方法的句柄,從而進行「方法粒度」的無感知替換,當虛擬機誤覺得方法仍是以前的「方法」。
在native的replaceMethod中:
extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod( JNIEnv* env, jobject src, jobject dest) { jobject clazz = env->CallObjectMethod(dest, jClassMethod); ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr( dvmThreadSelf_fnPtr(), clazz); clz->status = CLASS_INITIALIZED; Method* meth = (Method*) env->FromReflectedMethod(src); Method* target = (Method*) env->FromReflectedMethod(dest); LOGD("dalvikMethod: %s", meth->name); meth->clazz = target->clazz; meth->accessFlags |= ACC_PUBLIC; meth->methodIndex = target->methodIndex; meth->jniArgInfo = target->jniArgInfo; meth->registersSize = target->registersSize; meth->outsSize = target->outsSize; meth->insSize = target->insSize; meth->prototype = target->prototype; meth->insns = target->insns; meth->nativeFunc = target->nativeFunc; }
咱們看到源方法(meth)的各個屬性被替換成了新的方法(target)的各個屬性,這樣子就完成了方法的替換,完成了熱修復操做。
看到這裏咱們其實也瞭解了AndFix的缺陷,它既然是方法的替換,那麼若是新的apk增長了新的類,或者是增長修改了xml資源,那麼AndFix則無從下手了。因此,AndFix僅僅支持android 方法的替換,不支持資源文件、xml的修復!
影響
因爲AndFix的實現很是簡單,僅有一些很普通的源代碼,因此項目引入後對於apk的大小的影響是微乎其微的,這裏進行了一個引入先後的對比:
發現僅僅是增長了22KB左右,基本上能夠忽略不計
其次,本文中每次Application在onCreate中都進行了下載patch補丁的操做,實際開發中應該注意下不要重複下載。這裏能夠作一些操做,不要重複打一樣的補丁。
混淆
請加入下列混淆語句
# AndFix -keep class * extends java.lang.annotation.Annotation -keepclasseswithmembernames class * { native <methods>; } -keep class com.alipay.euler.andfix.** { *; }