天天看點

Android dex分包方案和熱更新檔原理

一、分包的原因:

 當一個app的功能越來越複雜,代碼量越來越多,也許有一天便會突然遇到下列現象:

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

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

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

出現這種問題的原因是:

1. Android2.3及以前版本用來執行dexopt(用于優化dex檔案)的記憶體隻配置設定了5M

2. 一個dex檔案最多隻支援65536個方法。

針對上述問題,也出現了諸多解決方案,使用的最多的是插件化,即将一些獨立的功能做成一個單獨的apk,當打開的時候使用DexClassLoader動态加載,然後使用反射機制來調用插件中的類和方法。這固然是一種解決問題的方案:但這種方案存在着以下兩個問題:

1. 插件化隻适合一些比較獨立的子產品;

2. 必須通過反射機制去調用插件的類和方法,是以,必須搭配一套插件架構來配合使用;

由于上述問題的存在,通過不斷研究,便有了dex分包的解決方案。簡單來說,其原理是将編譯好的class檔案拆分打包成兩個dex,繞過dex方法數量的限制以及安裝時的檢查,在運作時再動态加載第二個dex檔案中。faceBook曾經遇到相似的問題,具體可參考:

https://www.facebook.com/notes/facebook-engineering/under-the-hood-dalvik-patch-for-facebook-for-android/10151345597798920

文中有這麼一段話:

However, there was no way we could break our app up this way--too many of our classes are accessed directly by the Android framework. Instead, we needed to inject our secondary dex files directly into the system class loader。

文中說得比較簡單,我們來完善一下該方案:除了第一個dex檔案(即正常apk包唯一包含的Dex檔案),其它dex檔案都以資源的方式放在安裝包中,并在Application的onCreate回調中被注入到系統的ClassLoader。是以,對于那些在注入之前已經引用到的類(以及它們所在的jar),必須放入第一個Dex檔案中。

下面通過一個簡單的demo來講述dex分包方案,該方案分為兩步執行:

整個demo的目錄結構是這樣,我打算将SecondActivity,MyContainer以及DropDownView放入第二個dex包中,其它保留在第一個dex包。

二、1、編譯時分包

整個編譯流程如下:

除了框出來的兩Target,其它都是編譯的标準流程。而這兩個Target正是我們的分包操作。首先來看看spliteClasses target。

由于我們這裡僅僅是一個demo,是以放到第二個包中的檔案很少,就是上面提到的三個檔案。分好包之後就要開始生成dex檔案,首先打包第一個dex檔案: 

由這裡将${classes}(該檔案夾下都是要打包到第一個dex的檔案)打包生成第一個dex。接着生成第二個dex,并将其打包到資資源檔案中:

可以看到,此時是将${secclasses}中的檔案打包生成dex,并将其加入ap檔案(打包的資源檔案)中。到此,分包完畢,接下來,便來分析一下如何動态将第二個dex包注入系統的ClassLoader。

2、将dex分包注入ClassLoader

這裡談到注入,就要談到Android的ClassLoader體系。

由上圖可以看出,在葉子節點上,我們能使用到的是DexClassLoader和PathClassLoader,通過查閱開發文檔,我們發現他們有如下使用場景:

(1). 關于PathClassLoader,文檔中寫到: Android uses this class for its system class loader and for its application class loader(s),

由此可知,Android應用就是用它來加載;

(2) DexClass可以加載apk,jar,及dex檔案,但PathClassLoader隻能加載已安裝到系統中(即/data/app目錄下)的apk檔案。

知道了兩者的使用場景,下面來分析下具體的加載原理,由上圖可以看到,兩個葉子節點的類都繼承BaseDexClassLoader中,而具體的類加載邏輯也在此類中:

BaseDexClassLoader:  

[java] view plaincopy

  1. @Override  
  2. protected Class<?> findClass(String name) throws ClassNotFoundException {  
  3.     List<Throwable> suppressedExceptions = new ArrayList<Throwable>();  
  4.     Class c = pathList.findClass(name, suppressedExceptions);  
  5.     if (c == null) {  
  6.         ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);  
  7.         for (Throwable t : suppressedExceptions) {  
  8.             cnfe.addSuppressed(t);  
  9.        }  
  10.         throw cnfe;  
  11.     }  
  12.      return c;  
  13. }  

由上述函數可知,當我們需要加載一個class時,實際是從pathList中去需要的,查閱源碼,發現pathList是DexPathList類的一個執行個體。ok,接着去分析DexPathList類中的findClass函數,

DexPathList:

  1. public Class findClass(String name, List<Throwable> suppressed) {  
  2.     for (Element element : dexElements) {  
  3.         DexFile dex = element.dexFile;  
  4.         if (dex != null) {  
  5.             Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);  
  6.             if (clazz != null) {  
  7.                 return clazz;  
  8.             }  
  9.         }  
  10.    }  
  11.     if (dexElementsSuppressedExceptions != null) {  
  12.         suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));  
  13.     return null;  

上述函數的大緻邏輯為:周遊一個裝在dex檔案(每個dex檔案實際上是一個DexFile對象)的數組(Element數組,Element是一個内部類),然後依次去加載所需要的class檔案,直到找到為止。

看到這裡,注入的解決方案也就浮出水面,假如我們将第二個dex檔案放入Element數組中,那麼在加載第二個dex包中的類時,應該可以直接找到。

帶着這個假設,來完善demo。

在我們自定義的BaseApplication的onCreate中,我們執行注入操作:

  1. public String inject(String libPath) {  
  2.     boolean hasBaseDexClassLoader = true;  
  3.     try {  
  4.         Class.forName("dalvik.system.BaseDexClassLoader");  
  5.     } catch (ClassNotFoundException e) {  
  6.         hasBaseDexClassLoader = false;  
  7.     if (hasBaseDexClassLoader) {  
  8.         PathClassLoader pathClassLoader = (PathClassLoader)sApplication.getClassLoader();  
  9.         DexClassLoader dexClassLoader = new DexClassLoader(libPath, sApplication.getDir("dex", 0).getAbsolutePath(), libPath, sApplication.getClassLoader());  
  10.         try {  
  11.             Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));  
  12.             Object pathList = getPathList(pathClassLoader);  
  13.             setField(pathList, pathList.getClass(), "dexElements", dexElements);  
  14.             return "SUCCESS";  
  15.         } catch (Throwable e) {  
  16.             e.printStackTrace();  
  17.             return android.util.Log.getStackTraceString(e);  
  18.     return "SUCCESS";  
  19. }   

這是注入的關鍵函數,分析一下這個函數:

參數libPath是第二個dex包的檔案資訊(包含完整路徑,我們當初将其打包到了assets目錄下),然後将其使用DexClassLoader來加載(這裡為什麼必須使用DexClassLoader加載,回顧以上的使用場景),然後通過反射擷取PathClassLoader中的DexPathList中的Element數組(已加載了第一個dex包,由系統加載),以及DexClassLoader中的DexPathList中的Element數組(剛将第二個dex包加載進去),将兩個Element數組合并之後,再将其指派給PathClassLoader的Element數組,到此,注入完畢。

現在試着啟動app,并在TestUrlActivity(在第一個dex包中)中去啟動SecondActivity(在第二個dex包中),啟動成功。這種方案是可行。

但是使用dex分包方案仍然有幾個注意點:

1. 由于第二個dex包是在Application的onCreate中動态注入的,如果dex包過大,會使app的啟動速度變慢,是以,在dex分包過程中一定要注意,第二個dex包不宜過大。

2. 由于上述第一點的限制,假如我們的app越來越臃腫和龐大,往往會采取dex分包方案和插件化方案配合使用,将一些非核心獨立功能做成插件加載,核心功能再分包加載。

Android開發者應該都遇到了64K最大方法數限制的問題,針對這個問題,google也推出了multidex分包機制,在生成apk的時候,把整個應用拆成n個dex包(classes.dex、classes2.dex、classes3.dex),每個dex不超過64k個方法。使用multidex,在5.0以前的系統,應用安裝時隻安裝main dex(包含了應用啟動需要的必要class),在應用啟動之後,需在Application的

attachBaseContext

中調用

MultiDex.install(base)

方法,在這時候才加載第二、第三…個dex檔案,進而規避了64k問題。 

當然,在

attachBaseContext

方法中直接install啟動second dex會有一些問題,比如install方法是一個同步方法,當在主線程中加載的dex太大的時候,耗時會比較長,可能會觸發ANR。不過這是另外一個問題了,解決方法可以參考:Android最大方法數和解決方案 http://blog.csdn.net/shensky711/article/details/52329035。

本文主要分析的是

MultiDex.install()

到底做了什麼,如何把secondary dexes中的類動态加載進來。

MultiDex使用到的路徑解析

  • ApplicationInfo.sourceDir:apk的安裝路徑,如/data/app/com.hanschen.multidex-1.apk
  • Context.getFilesDir():傳回

    /data/data/<packagename>/files

    目錄,一般通過openFileOutput方法輸出檔案到該目錄
  • ApplicationInfo.dataDir: 傳回

    /data/data/<packagename>

    目錄

源碼分析

代碼入口

代碼入口很簡單,簡單粗暴,就調用了一個靜态方法

MultiDex.install(base);

,傳入一個Context對象

@Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(base);
    }           

MultiDex.install分析

下面是主要的代碼

public static void install(Context context) {
        Log.i("MultiDex", "install");
        if (IS_VM_MULTIDEX_CAPABLE) {
            //VM版本大于2.1時,IS_VM_MULTIDEX_CAPABLE為true,這時候MultiDex.install什麼也不用做,直接傳回。因為大于2.1的VM會在安裝應用的時候,就把多個dex合并到一塊
        } else if (VERSION.SDK_INT < 4) {
            //Multi dex最小支援的SDK版本為4
            throw new RuntimeException("Multi dex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
        } else {
            try {
                ApplicationInfo e = getApplicationInfo(context);
                if (e == null) {
                    return;
                }

                Set var2 = installedApk;
                synchronized (installedApk) {
                    String apkPath = e.sourceDir;
                    //檢測應用是否已經執行過install()了,防止重複install
                    if (installedApk.contains(apkPath)) {
                        return;
                    }

                    installedApk.add(apkPath);

                    //擷取ClassLoader,後面會用它來加載second dex
                    DexClassLoader classLoader;
                    ClassLoader loader;
                    try {
                        loader = context.getClassLoader();
                    } catch (RuntimeException var9) {
                        return;
                    }

                    if (loader == null) {
                        return;
                    }

                    //清空目錄:/data/data/<packagename>/files/secondary-dexes/,其實我沒搞明白這個的作用,因為從後面的代碼來看,這個目錄是沒有使用到的
                    try {
                        clearOldDexDir(context);
                    } catch (Throwable var8) {
                    }

                    File dexDir = new File(e.dataDir, "code_cache/secondary-dexes");
                    //把dex檔案緩存到/data/data/<packagename>/code_cache/secondary-dexes/目錄,[後有詳細分析]
                    List files = MultiDexExtractor.load(context, e, dexDir, false);
                    if (checkValidZipFiles(files)) {
                        //進行安裝,[後有詳細分析]
                        installSecondaryDexes(loader, dexDir, files);
                    } else {
                        //檔案無效,從apk檔案中再次解壓secondary dex檔案後進行安裝
                        files = MultiDexExtractor.load(context, e, dexDir, true);
                        if (!checkValidZipFiles(files)) {
                            throw new RuntimeException("Zip files were not valid.");
                        }

                        installSecondaryDexes(loader, dexDir, files);
                    }
                }
            } catch (Exception var11) {
                throw new RuntimeException("Multi dex installation failed (" + var11.getMessage() + ").");
            }
        }
    }           

這段代碼的主要邏輯整理如下:

  1. VM版本檢測,如果大于2.1就什麼都不做(系統在安裝應用的時候已經幫我們把dex合并了),如果系統SDK版本小于4就抛出運作時異常
  2. 把apk中的secondary dexes解壓到緩存目錄,并把這些緩存讀取出來。應用第二次啟動的時候,會嘗試從緩存目錄中讀取,除非讀取出的檔案校驗失敗,否則不再從apk中解壓dexes
  3. 根據目前的SDK版本,執行不同的安裝方法

先來看看

MultiDexExtractor.load(context, e, dexDir, false)

/**
     * 解壓apk檔案中的classes2.dex、classes3.dex等檔案解壓到dexDir目錄中
     *
     * @param dexDir      解壓目錄
     * @param forceReload 是否需要強制從apk檔案中解壓,否的話會直接讀取舊檔案
     * @return 解壓後的檔案清單
     * @throws IOException
     */
    static List<File> load(Context context,
                           ApplicationInfo applicationInfo,
                           File dexDir,
                           boolean forceReload) throws IOException {
        File sourceApk = new File(applicationInfo.sourceDir);
        long currentCrc = getZipCrc(sourceApk);
        List files;
        if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
            try {
                //從緩存目錄中直接查找緩存檔案,跳過解壓
                files = loadExistingExtractions(context, sourceApk, dexDir);
            } catch (IOException var9) {
                files = performExtractions(sourceApk, dexDir);
                putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
            }
        } else {
            //把apk中的secondary dex檔案解壓到緩存目錄,并把解壓後的檔案傳回
            files = performExtractions(sourceApk, dexDir);
            //把解壓資訊儲存到sharedPreferences中
            putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
        }

        return files;
    }           

首先判斷以下是否需要強制從apk檔案中解壓,再進行下CRC校驗,如果不需要從apk重新解壓,就直接從緩存目錄中讀取已解壓的檔案傳回,否則解壓apk中的classes檔案到緩存目錄,再把相應的檔案傳回。這個方法再往下的分析就不貼出來了,不複雜,大家可以自己去看看。讀取後會把解壓資訊儲存到sharedPreferences中,裡面會儲存時間戳、CRC校驗和dex數量。

得到dex檔案清單後,要做的就是把dex檔案關聯到應用,這樣應用findclass的時候才能成功。這個主要是通過

installSecondaryDexes

方法來完成的

/**
     * 安裝dex檔案
     *
     * @param loader 類加載器
     * @param dexDir 緩存目錄,用以存放opt之後的dex檔案
     * @param files  需要安裝的dex
     * @throws IllegalArgumentException
     * @throws IllegalAccessException
     * @throws NoSuchFieldException
     * @throws InvocationTargetException
     * @throws NoSuchMethodException
     * @throws IOException
     */
    private static void installSecondaryDexes(ClassLoader loader,
                                              File dexDir,
                                              List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {

        if (!files.isEmpty()) {
            //對不同版本的SDK做不同處理
            if (VERSION.SDK_INT >= 19) {
                MultiDex.V19.install(loader, files, dexDir);
            } else if (VERSION.SDK_INT >= 14) {
                MultiDex.V14.install(loader, files, dexDir);
            } else {
                MultiDex.V4.install(loader, files);
            }
        }

    }           

可以看到,對于不同的SDK版本,分别采用了不同的處理方法,我們主要分析SDK>=19的情況,其他情況大同小異,讀者可以自己去分析。

private static final class V19 {
        private V19() {
        }

        /**
         * 安裝dex檔案
         *
         * @param loader                     類加載器
         * @param additionalClassPathEntries 需要安裝的dex
         * @param optimizedDirectory         緩存目錄,用以存放opt之後的dex檔案
         * @throws IllegalArgumentException
         * @throws IllegalAccessException
         * @throws NoSuchFieldException
         * @throws InvocationTargetException
         * @throws NoSuchMethodException
         */
        private static void install(ClassLoader loader,
                                    List<File> additionalClassPathEntries,
                                    File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {

            //通過反射擷取ClassLoader對象中的pathList屬性,其實是ClassLoader的父類BaseDexClassLoader中的成員
            Field pathListField = MultiDex.findField(loader, "pathList");
            //通過屬性擷取該屬性的值,該屬性的類型是DexPathList
            Object dexPathList = pathListField.get(loader);

            ArrayList suppressedExceptions = new ArrayList();
            //通過反射調用dexPathList的makeDexElements傳回Element對象數組。方法裡面會讀取每一個輸入檔案,生成DexFile對象,并将其封裝進Element對象
            Object[] elements = makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions);

            //将elements數組跟dexPathList對象的dexElements數組合并,并把合并後的數組作為dexPathList新的值
            MultiDex.expandFieldArray(dexPathList, "dexElements", elements);

            //處理異常
            if (suppressedExceptions.size() > 0) {
                Iterator suppressedExceptionsField = suppressedExceptions.iterator();

                while (suppressedExceptionsField.hasNext()) {
                    IOException dexElementsSuppressedExceptions = (IOException) suppressedExceptionsField.next();
                    Log.w("MultiDex", "Exception in makeDexElement", dexElementsSuppressedExceptions);
                }

                Field suppressedExceptionsField1 = MultiDex.findField(loader, "dexElementsSuppressedExceptions");
                IOException[] dexElementsSuppressedExceptions1 = (IOException[]) ((IOException[]) suppressedExceptionsField1.get(loader));
                if (dexElementsSuppressedExceptions1 == null) {
                    dexElementsSuppressedExceptions1 = (IOException[]) suppressedExceptions.toArray(new IOException[suppressedExceptions
                            .size()]);
                } else {
                    IOException[] combined = new IOException[suppressedExceptions.size() + dexElementsSuppressedExceptions1.length];
                    suppressedExceptions.toArray(combined);
                    System.arraycopy(dexElementsSuppressedExceptions1, 0, combined, suppressedExceptions.size(), dexElementsSuppressedExceptions1.length);
                    dexElementsSuppressedExceptions1 = combined;
                }

                suppressedExceptionsField1.set(loader, dexElementsSuppressedExceptions1);
            }

        }

        private static Object[] makeDexElements(Object dexPathList,
                                                ArrayList<File> files,
                                                File optimizedDirectory,
                                                ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
            Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", new Class[]{ArrayList.class, File.class, ArrayList.class});
            return (Object[]) ((Object[]) makeDexElements.invoke(dexPathList, new Object[]{files, optimizedDirectory, suppressedExceptions}));
        }
    }           

在Android中,有兩個ClassLoader,分别是

DexClassLoader

PathClassLoader

,它們的父類都是

BaseDexClassLoader

,DexClassLoader和PathClassLoader的實作都是在BaseDexClassLoader之中,而BaseDexClassLoader的實作又基本是通過調用DexPathList的方法完成的。DexPathList裡面封裝了加載dex檔案為DexFile對象(調用了native方法,有興趣的童鞋可以繼續跟蹤下去)的方法。 

上述代碼中的邏輯如下:

  1. 通過反射擷取pathList對象
  2. 通過pathList把輸入的dex檔案輸出為elements數組,elements數組中的元素封裝了DexFile對象
  3. 把新輸出的elements數組合并到原pathList的dexElements數組中
  4. 異常處理

當把dex檔案加載到pathList的dexElements數組之後,整個multidex.install基本上就完成了。 

但可能還有些童鞋還會有些疑問,僅僅隻是把Element數組合并到ClassLoader就可以了嗎?還是沒有找到加載類的地方啊?那我們再繼續看看,當用到一個類的時候,會用ClassLoader去加載一個類,加載類會調用類加載器的findClass方法

@Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        //調用pathList的findClass方法
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }           

于是繼續跟蹤:

public Class findClass(String name, List<Throwable> suppressed) {
        //周遊dexElements數組
        for (Element element : dexElements) {

            DexFile dex = element.dexFile;
            if (dex != null) {
                //繼續跟蹤會發現調用的是一個native方法
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }           

到現在就清晰了,當加載一個類的時候,會周遊dexElements數組,通過native方法從Element元素中加載類名相應的類

總結下整個multidex.install流程,其實很簡單,就做了一件事情,把apk中的secondary dex檔案通過ClassLoader轉換成Element數組,并把輸出的數組合與ClassLoader的Element數組合并。

通常情況下,dexElements數組中隻會有一個元素,就是apk安裝包中的classes.dex 

而我們則可以通過反射,強行的将一個外部的dex檔案添加到此dexElements中,這就是dex的分包原理了。 

這也是熱更新檔修複技術的原理。

三、熱更新檔修複技術的原理

上面的源碼,我們注意到一點,如果兩個dex中存在相同的class檔案會怎樣? 

先從第一個dex中找,找到了直接傳回,周遊結束。而第二個dex中的class永遠不會被加載進來。 

簡而言之,兩個dex中存在相同class的情況下,dex1的class會覆寫dex2的class。 

盜一下QQ空間的圖,如圖:classes1.dex中的Qzone.class并不會被加載 

而熱更新檔技術則利用了這一特性,當一個app出現bug的時候,我們就可以将出現那個bug的類修複後,重新編譯打包成dex,插入到dexElements的前面,那麼出現bug的類就會被覆寫,app正常運作,這就是熱修複的原理了。