一、分包的原因:
當一個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曾經遇到相似的問題,具體可參考:
文中有這么一段話:
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:
- @Override
- protected Class<?> findClass(String name) throws ClassNotFoundException {
- List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
- 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;
- }
由上述函數可知,當我們需要加載一個class時,實際是從pathList中去需要的,查閱源碼,發現pathList是DexPathList類的一個實例。ok,接着去分析DexPathList類中的findClass函數,
DexPathList:
- 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;
- }
上述函數的大致邏輯為:遍歷一個裝在dex文件(每個dex文件實際上是一個DexFile對象)的數組(Element數組,Element是一個內部類),然后依次去加載所需要的class文件,直到找到為止。
看到這里,注入的解決方案也就浮出水面,假如我們將第二個dex文件放入Element數組中,那么在加載第二個dex包中的類時,應該可以直接找到。
帶着這個假設,來完善demo。
在我們自定義的BaseApplication的onCreate中,我們執行注入操作:
- public String inject(String libPath) {
- boolean hasBaseDexClassLoader = true;
- try {
- Class.forName("dalvik.system.BaseDexClassLoader");
- } catch (ClassNotFoundException e) {
- hasBaseDexClassLoader = false;
- }
- if (hasBaseDexClassLoader) {
- PathClassLoader pathClassLoader = (PathClassLoader)sApplication.getClassLoader();
- DexClassLoader dexClassLoader = new DexClassLoader(libPath, sApplication.getDir("dex", 0).getAbsolutePath(), libPath, sApplication.getClassLoader());
- try {
- Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));
- Object pathList = getPathList(pathClassLoader);
- setField(pathList, pathList.getClass(), "dexElements", dexElements);
- return "SUCCESS";
- } catch (Throwable e) {
- e.printStackTrace();
- return android.util.Log.getStackTraceString(e);
- }
- }
- return "SUCCESS";
- }
這是注入的關鍵函數,分析一下這個函數:
參數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() + ")."); } } }
這段代碼的主要邏輯整理如下:
- VM版本檢測,如果大於2.1就什么都不做(系統在安裝應用的時候已經幫我們把dex合並了),如果系統SDK版本小於4就拋出運行時異常
- 把apk中的secondary dexes解壓到緩存目錄,並把這些緩存讀取出來。應用第二次啟動的時候,會嘗試從緩存目錄中讀取,除非讀取出的文件校驗失敗,否則不再從apk中解壓dexes
- 根據當前的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方法,有興趣的童鞋可以繼續跟蹤下去)的方法。
上述代碼中的邏輯如下:
- 通過反射獲取pathList對象
- 通過pathList把輸入的dex文件輸出為elements數組,elements數組中的元素封裝了DexFile對象
- 把新輸出的elements數組合並到原pathList的dexElements數組中
- 異常處理
當把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正常運行,這就是熱修復的原理了。