1. 程式人生 > >Android打補丁 熱修復(HotFix)小結

Android打補丁 熱修復(HotFix)小結

需求場景:

   當我們的app釋出以後,發現有bug,比如維護資料錯誤,應用邏輯錯誤,嚴重的可能引發應用崩潰。這時修改應用可能只需要修改幾行程式碼,或者某個方法就可以搞定。以前為了解決這樣的問題發只能釋出新版本。而緊急釋出新版本會造成很惡劣的影響,使使用者使用的成本升高,並且影響產品在使用者心中的形象(不靠譜啊~~~)。

技術背景:

 在不斷迭代我們的應用的時候,功能越多,不可避免的方法量也不斷增加,當方法量不斷增加,最終可能會遇到這樣的問題:

1.生成的apk在2.3以前的機器無法安裝,提示INSTALL_FAILED_DEXOPT

    原因:

        首先我們要知道打包過程中我們開發的java類的變化,首先java類被編譯成class檔案,接著class檔案會被編譯生成dex檔案,我們打包完成後,一個App的所有程式碼都在一個dex檔案中(class.dex,解壓apk就可以看到)。當Android系統啟動一個應用的時候,會使用DexOpt工具

對Dex進行優化,DexOpt的執行過程是在第一次載入Dex檔案的時候執行的。這個過程會生成一個ODEX檔案。執行ODex的效率會比直接執行Dex檔案的效率要高很多。但是在早期的Android系統中,DexOpt的LinearAlloc存在著限制: Android 2.2和2.3的緩衝區只有5MB,Android 4.x提高到了8MB或16MB。當方法數量過多導致超出緩衝區大小時,會造成dexopt崩潰,導致無法安裝. 

2. 方法數量過多,編譯時出錯,提示:

  Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536  

    原因:

        這是由於dex的檔案限制,dex檔案中method的的索引的id型別被定義為short型別(0~65535),field和class的個數也有此限制。導致dex文的方法總數被限制為65536(包括自己開發以及所引用的Android Framework和第三方類庫的程式碼)。

解決方案:

說了這麼多,到底跟我們的熱修復有什麼關係呢? 以上三種解決方案都是基於dex分包:    dex分包的解決方案。簡單來說,其原理是將編譯好的class檔案拆分打包成兩個dex,繞過dex方法數量的限制以及安裝時的檢查,在執行時再動態載入第二個dex檔案中。 這裡需要注意的是在具體的實施過程中,可以都不同的形式,比如DynamicLod使用的是apk檔案,可以包含資原始檔。不涉及資源時,可以使用簡單的編譯過的jar檔案。簡單來說,只要能讓ClassLoader載入到dex檔案的歸檔檔案都是可以實現的(甚至可以是zip)。
    OK~     這就是補丁的基礎,讓app載入多個dex檔案,假如我的釋出包裡有一個Qzone.class,釋出之後發現這個類有bug,然後修改了一行程式碼(一定是大意導致的~~~),然後把這個類打成dex包(具體操作後文詳述)。客戶端通過某種途徑下載到客戶端(一般通過是下載),啟動應用時讓app載入這個patch.jar包。注意原本我們的專案裡有一個Qzone.class,此處我們的patch.jar裡面也有一個Qzone.class,那麼ClassLoader在載入dex的時候是怎麼載入的呢?我們看看BaseClassLoader的原始碼:
public Class findClass(String name, List<Throwable> suppressed) {  
    for (Element element : dexElements) {  
        DexFile dex = element.dexFile;  
        if (dex != null) {  
          Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);  
            if (clazz != null) {  
                return clazz;  
            }  
        }  
   }  
    if (dexElementsSuppressedExceptions != null) {  
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));  
    }  
    return null;  
}  
一個ClassLoader可以包含多個dex檔案,每個dex檔案是一個Element,多個dex檔案排列成一個有序的陣列dexElements,當找類的時候,會按順序遍歷dex檔案,然後從當前遍歷的dex檔案中找類,如果找類則返回,如果找不到從下一個dex檔案繼續查詢。
理論上,如果在不同的dex中有相同的類存在,那麼會優先選擇排在前面的dex檔案的類,此處盜一張圖:


也就是說,如果patch.jar的dex在app包dex的前面,修復過Qzone.class會被載入,原來包裡的Qzone.class被忽略。 說到這兒,相信看懂的同學已經笑了,原來補丁的原理這麼簡單~~~ OK~ 那我們怎麼去實現這個Android的補丁方案呢,網上有幾種解決方案: Nuwa框架實現 使用Nuwa的第一步是初始化,原始碼如下:
public static void init(Context context) {
    
    File dexDir = new File(context.getFilesDir(), DEX_DIR);
    dexDir.mkdir();

    String dexPath = null;
    try {
        dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
    } catch (IOException e) {
        Log.e(TAG, "copy " + HACK_DEX + " failed");
        e.printStackTrace();
    }

    loadPatch(context, dexPath);
}

init()函式做了兩件事:1,把asset目錄下的hack,apk拷貝到應用的私有目錄下;2,載入hack.apk到ClassLoader中dexElement的最前面。

loadPatch方法也是之後進行熱修復的關鍵方法,你的所有補丁檔案都是通過這個方法動態載入進來

public static void loadPatch(Context context, String dexPath) {

        if (context == null) {
            Log.e(TAG, "context is null");
            return;
        }
        if (!new File(dexPath).exists()) {
            Log.e(TAG, dexPath + " is null");
            return;
        }
        File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
        dexOptDir.mkdir();
        try {
            DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "inject " + dexPath + " failed");
            e.printStackTrace();
        }
    }

其中呼叫injectDexAtFirst將dex放到ClassLoader中dexElements的最前面的方法:
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
    Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
    Object newDexElements = getDexElements(getPathList(dexClassLoader));
    Object allDexElements = combineArray(newDexElements, baseDexElements);
    Object pathList = getPathList(getPathClassLoader());
    ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}

內部使用combineArray()方法將這兩個物件進行結合,將我們傳進來的dex插到該物件的最前面,之後呼叫ReflectionUtils.setField()方法,將dexElements進行替換。combineArray方法中做的就是擴充套件陣列,將第二個陣列插入到第一個陣列的最前面

private static Object combineArray(Object firstArray, Object secondArray) {
        Class<?> localClass = firstArray.getClass().getComponentType();
        int firstArrayLength = Array.getLength(firstArray);
        int allLength = firstArrayLength + Array.getLength(secondArray);
        Object result = Array.newInstance(localClass, allLength);
        for (int k = 0; k < allLength; ++k) {
            if (k < firstArrayLength) {
                Array.set(result, k, Array.get(firstArray, k));
            } else {
                Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
            }
        }
        return result;
    }

這個hack.apk裡面只有一個類,下面看一下這個Hack.java的原始碼:

public class Hack {
}

原來這個類什麼都沒幹~~~那我們費這麼大勁載入這個包乾嘛?

因為這裡面還存在一個CLASS_ISPREVERIFIED的問題,對於這個問題呢,詳見:安卓App熱補丁動態修復技術介紹

關於這個CLASS_ISPREVERIFIED,簡單來說就是:

        在虛擬機器啟動的時候,當verify選項被開啟的時候,如果static方法、private方法、建構函式等,其中的直接引用(第一層關係)到的類都在同一個dex檔案中,那麼該類就會被打上CLASS_ISPREVERIFIED標誌。

        注意,是阻止引用者的類,在Nuwa的示例裡面,MainActivity內部引用了Hello。釋出過程中發現Hello有編寫錯誤,那麼想要釋出一個新的Hello類,那麼你就要阻止MainActivity這個類打上CLASS_ISPREVERIFIED的標誌。也就是說,在生成apk之前,就需要阻止相關類打上CLASS_ISPREVERIFIED的標誌了。對於如何阻止,上面的文章說的很清楚,讓MainActivity在構造方法中,去引用別的dex檔案,在本例中,就是hack.apk。

其實這個問題Nuwa框架內部已經解決了,我們要做的就是給app打補丁包,下面我們來看看怎麼給app打補丁。

OK~
一開始我們的app執行的介面是這樣的:

接下來我們把Hello.java程式碼修改掉:
public class Hello {
    public String say() {
        return "hello world~~~ After Fix";
    }
}
然後需要把我們的Hello.java打成patch_dex.jar包:

下一步就是把我們的patch.jar載入進來,一行程式碼搞定:

classjar(class)jar cvf patch.jar cn/jiajixin/nuwasample/Hello/Hello.java jar打成dex包(dx工具在sdk的build-tools目錄下)dx --dex --output patch_dex.jar patch.jar 最後要在Application裡面載入我們的補丁包:
Nuwa.loadPatch(this, Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar"));
同樣是呼叫loadPatch()和injectDexAtFirst()方法將dex插入到dexElements最前面。
關閉app.重新開啟,MainActivity的介面變成這樣了:

到此為止,Nuwa框架的實現和流程就分析完了,希望對大家有一些幫助~~~