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


當你的應用發布后第二天卻發現一個重要的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.** { *; }

 

 

 

 

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM