AndFix熱修復 —— 實戰與源碼解析

當你的應用發佈後次日卻發現一個重要的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.** { *; }

 

 

 

 

轉載請註明:http://www.cnblogs.com/soaringEveryday/p/5338214.html

相關文章
相關標籤/搜索