當你的應用發布后第二天卻發現一個重要的bug要修復,頭疼的同時你可能想着趕緊修復重新打個包發布出去,讓用戶收到自動更新重新下載。但是萬事皆有可能,萬一隔一天又發現一個急需修復的bug呢?難道再次發布打擾用戶一次?
這個時候就是熱修復技術該登場的時候了,它可以讓你在無需發布新版本的前提下修復小范圍的問題。最近研究了下幾個熱修復的開源框架,其中Nuwa等框架的原理是修改了gradle的編譯task流程,替換dex的方式來實現。但是可惜的是gradle plugin在1.5以后取消了predexdebug這個task,而Nuwa恰恰是依賴這個task的,所以導致Nuwa在gradle plugin1.5版本后無法使用。
所以我們這里將探討另一個熱修復框架AndFix,它的原理簡單而純粹。本文將從實戰項目應用和原理兩個角度來闡述,同時將闡述項目中引用該框架后帶來的影響(微乎其微)。
引入
首先AndFix的主要實現是CPP實現,而且只有幾個很小的文件。同時提供了dalvik和ART兩個版本的so通過JNI供上層Java層調用。所以顯然AndFix的一個最大優點是支持Dalvik和ART兩種運行時環境,同時它支持Android2.3 - 6.0版本,支持arm和x86架構CPU的設備。改框架的作者團隊是支付寶,相傳已經應用到了阿里巴巴的一些應用上(真實性不詳)
首先在你的項目中添加以下gradle依賴:
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進行熱修復工作。
詳細的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.** { *; }