天天看點

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和第三方類庫的代碼)。

解決方案:

  • facebook曾經遇到過這樣的問題,詳情檢視:https://www.facebook.com/notes/facebook-engineering/under-the-hood-dalvik-patch-for-facebook-for-android/10151345597798920
  • 網上還有插件式解決方案,DynamicLod:https://github.com/singwhatiwanna/dynamic-load-apk
  • google官方目前在已經在API 21中提供了通用的解決方案,那就是android-support-multidex.jar. 這個jar包最低可以支援到API 4的版本(Android L及以上版本會預設支援mutidex).

說了這麼多,到底跟我們的熱修複有什麼關系呢? 以上三種解決方案都是基于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檔案的類,此處盜一張圖:

Android打更新檔 熱修複(HotFix)小結

也就是說,如果patch.jar的dex在app包dex的前面,修複過 Qzone.class會被加載,原來包裡的Qzone.class被忽略。

說到這兒,相信看懂的同學已經笑了,原來更新檔的原理這麼簡單~~~

OK~ 那我們怎麼去實作這個Android的更新檔方案呢,網上有幾種解決方案:

  • https://github.com/dodola/HotFix
  • https://github.com/jasonross/Nuwa
  • https://github.com/bunnyblue/DroidFix
  • https://github.com/alibaba/dexposed
  • https://github.com/alibaba/AndFix

其中後兩個解決方案是基于C的實作, 前三個解決方案是java的解決方案,思路和實作方式基本一緻。因為Nuwa的内部實作了很多自動化的處理,本文以Nuwa為例。

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。

       關于注入Hello方式,具體參見:http://blog.csdn.net/sbsujjbcy/article/details/50812674

其實這個問題Nuwa架構内部已經解決了,我們要做的就是給app打更新檔包,下面我們來看看怎麼給app打更新檔。

OK~

一開始我們的app運作的界面是這樣的:

Android打更新檔 熱修複(HotFix)小結

接下來我們把Hello.java代碼修改掉:

public class Hello {
    public String say() {
        return "hello world~~~ After Fix";
    }
}
           

然後需要把我們的Hello.java打成patch_dex.jar包:

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

class打成jar包(可指定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的界面變成這樣了:

Android打更新檔 熱修複(HotFix)小結

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