坑爹的MultiDex 一、問題 二、啓用MultiDex解決問題 四、存在的問題

一、問題

1、65535 問題

當 App 的功能越來越豐富、使用的庫越來越多時,其包含的 Java 方法總數也越來越多,這時候就會出現 65535 問題。
在構建 apk 的時候限制了一個 dex 文件能包含的方法數,其總數不能超過 65535(則 64K,1K = 2^10 = 1024 , 64 * 1024 = 65535)。MultiDex, 顧名思義,是指多 dex 實現,大多數 App,解壓其 apk 後,一般只有一個 classes.dex 文件,採用 MultiDex 的 App 解壓後可以看到有 classes.dex,classes2.dex,… classes(N).dex,這樣每個 dex 都可以最大承載 64k 個方法,很大限度地緩解了單 dex 方法數限制。

2、LinearAlloc問題

現在這個問題已經不常見了,它多發生在 2.x 版本的設備上,安裝時會提示 INSTALL_FAILED_DEXOPT。這個問題發生在安裝期間,在使用 Dalvik 虛擬機的設備上安裝 APK 時,會通過 DexOpt 工具將 Dex 文件優化爲 odex 文件,即 Optimized Dex,這樣可以提高執行效率 (不同的設備需要不同的 odex 格式,所以這個過程只能安裝 apk 後進行)。
LinearAlloc 是一個固定大小的緩衝區,dexopt 使用 LinearAlloc 來存儲應用的方法信息,在 Android 的不同版本中有 4M/5M/8M/16M 等不同大小,目前主流 4.x 系統上都已到 8MB 或 16MB,但是在 Gingerbread 或以下系統(2.2 和 2.3)LinearAlloc 分配空間只有 5M 大小的。當應用的方法信息過多導致超出緩衝區大小時,會造成 dexopt 崩潰,造成 INSTALL_FAILED_DEXOPT 錯誤。

二、啓用MultiDex解決問題

1、配置 build.gradle

android {
    compileSdkVersion 21
    buildToolsVersion "21.1.0"

    defaultConfig {
        ...
        minSdkVersion 14
        targetSdkVersion 21
        ...

        multiDexEnabled true // Enable MultiDex.
    }
    ...
}

dependencies {
  compile 'com.android.support:multidex:1.0.1'
}

2、在代碼裏啓動 MultiDex

在 Java 代碼裏啓動 MultiDex,有兩種方式可以搞定。
方式一,使用 MultiDexApplication

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="xxx">
    <application
        ...
        android:name="android.support.multidex.MultiDexApplication">
        ...
    </application>
</manifest>

方式二,在自己的 Application#attachBaseContext(Context) 方法裏添加以下代碼。

public class MyApplication extends Application {
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(this); // Enable MultiDex.
    }
}

三、實現原理


MultiDex的入口是MultiDex.install(Context),先從這裏入手

1、MultiDex.install

public static void install(Context context) {
    // 經過一系列檢查
    try {
        ApplicationInfo applicationInfo = getApplicationInfo(context);
        if (applicationInfo == null) {
            Log.i(TAG, "No ApplicationInfo available, i.e. running on a test Context:"
                  + " MultiDex support library is disabled.");
            return;
        }
        // 調用真正進行dex install的方法
        doInstallation(context,
                       new File(applicationInfo.sourceDir),
                       new File(applicationInfo.dataDir),
                       CODE_CACHE_SECONDARY_FOLDER_NAME,
                       NO_KEY_PREFIX,
                       true);

    } catch (Exception e) {
        Log.e(TAG, "MultiDex installation failure", e);
        throw new RuntimeException("MultiDex installation failed (" + e.getMessage() + ").");
    }
}

經過一系列檢查之後調用doInstallation發方法開始真正的dex install操作

private static void doInstallation(Context mainContext, File sourceApk, File dataDir,
                                   String secondaryFolderName, String prefsKeyPrefix,
                                   boolean reinstallOnPatchRecoverableException) throws IOException {
    //保證方法僅調用一次,如果這個方法已經調用過一次,就不能再調用了。
    synchronized (installedApk) {
        if (installedApk.contains(sourceApk)) {
            return;
        }
        installedApk.add(sourceApk);
        // 如果當前Android版本>20已經自身支持了MultiDex,依然可以執行MultiDex操作,但是會有警告。
        if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) {
            Log.w(TAG, "MultiDex is not guaranteed to work in SDK version "
                  + Build.VERSION.SDK_INT + ": SDK version higher than "
                  + MAX_SUPPORTED_SDK_VERSION + " should be backed by "
                  + "runtime with built-in multidex capabilty but it's not the "
                  + "case here: java.vm.version=\""
                  + System.getProperty("java.vm.version") + "\"");
        }
        // 獲取當前的ClassLoader實例,後面要做的工作,就是把其他dex文件加載後,
        // 把其DexFile對象添加到這個ClassLoader裏
        ClassLoader loader = getDexClassloader(mainContext);
        if (loader == null) {
            return;
        }

        try {
            // 清除舊的dex文件,這裏不是清除上次加載的dex文件緩存。
            // 獲取dex緩存目錄是,會優先獲取/data/data/${packageName}/code-cache作爲緩存目錄。
            // 如果獲取失敗,則使用/data/data/${packageName}/files/code-cache目錄。
            // 這裏清除的是第二個目錄。
            clearOldDexDir(mainContext);
        } catch (Throwable t) {
            Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, "
                  + "continuing without cleaning.", t);
        }
        //獲取一個存放dex的目錄,路徑是"/data/data/${packageName}/code_cache/secondary-dexes",用來存放優化後的dex文件
        File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
        // 使用MultiDexExtractor這個工具類把APK中的dex提取到dexDir目錄中
        MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
        IOException closeException = null;
        try {
            //返回的files集合有可能爲空,表示沒有secondaryDex
            //不強制重新加載,也就是說如果已經提取過了,可以直接從緩存目錄中拿來使用,這麼做速度比較快
            List<? extends File> files =
                extractor.load(mainContext, prefsKeyPrefix, false);
            try {
                // 如果提取的文件是有效的,就安裝secondaryDex
                installSecondaryDexes(loader, dexDir, files);
            } catch (IOException e) {
                if (!reinstallOnPatchRecoverableException) {
                    throw e;
                }
                //如果提取出的文件是無效的,那麼就強制重新加載,這麼做的話速度就慢了一點,有一些IO開銷
                files = extractor.load(mainContext, prefsKeyPrefix, true);
                installSecondaryDexes(loader, dexDir, files);
            }
        } finally {
            try {
                extractor.close();
            } catch (IOException e) {
                // Delay throw of close exception to ensure we don't override some exception
                // thrown during the try block.
                closeException = e;
            }
        }
        if (closeException != null) {
            throw closeException;
        }
    }
}

方法開始使用synchronized關鍵字保證方法僅調用一次,如果這個方法已經調用過一次,就不能再調用了。如果當前Android版本>20已經自身支持了MultiDex,依然可以執行MultiDex操作,但是會有警告。開始提取dex文件之前先調用 clearOldDexDir 清除舊的dex文件,這裏不是清除上次加載的dex文件緩存。這裏清除的文件目錄是/data/data/${packageName}/files/code-cache (getDexDir 獲取dex緩存目錄是,會優先獲取/data/data/${packageName}/code-cache作爲緩存目錄,如果獲取失敗,則使用/data/data/${packageName}/files/code-cache目錄)。
使用MultiDexExtractor這個工具類把APK中的dex提取到dexDir目錄中,MultiDexExtractor返回的files集合有可能爲空,表示沒有secondaryDex,
不強制重新加載,也就是說如果已經提取過了,可以直接從緩存目錄中拿來使用,這麼做速度比較快

2、提取Dex文件

再來看一下從APK文件中抽取出.dex文件的邏輯。下面是MultiDexExtractor的load()方法:

List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload)
    throws IOException {
    //加上文件鎖,防止多進程衝突。
    if (!cacheLock.isValid()) {
        throw new IllegalStateException("MultiDexExtractor was closed");
    }

    List<ExtractedDex> files;
    // sourceApk 路徑爲"/data/app/${packageName}-xxx/base.apk"
    // 先判斷是否強制重新解壓,這裏第一次會優先使用已解壓過的dex文件,如果加載失敗就強制重新解壓。
    // 此外,通過crc和文件修改時間,判斷如果Apk文件已經被修改(覆蓋安裝),就會跳過緩存重新解壓dex文件
    if (!forceReload && !isModified(context, sourceApk, sourceCrc, prefsKeyPrefix)) {
        try {
            // 加載緩存的dex文件
            files = loadExistingExtractions(context, prefsKeyPrefix);
        } catch (IOException ioe) {
            Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
                  + " falling back to fresh extraction", ioe);
            // 加載失敗的話重新解壓,並保存解壓出來的dex文件的信息。
            files = performExtractions();
            putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
                             files);
        }
    } else {
        if (forceReload) {
            Log.i(TAG, "Forced extraction must be performed.");
        } else {
            Log.i(TAG, "Detected that extraction must be performed.");
        }
        //重新解壓,並保存解壓出來的dex文件的信息。
        files = performExtractions();
        putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
                         files);
    }

    Log.i(TAG, "load found " + files.size() + " secondary dex files");
    return files;
}

這個過程主要是獲取可以安裝的dex文件列表,可以是上次解壓出來的緩存文件,也可以是重新從Apk包裏面提取出來的。需要注意的是,如果是重新解壓,這裏會有明顯的耗時,而且解壓出來的dex文件,會被壓縮成.zip壓縮包,壓縮的過程也會有明顯的耗時(這裏壓縮dex文件可能是爲了節省空間)。
如果dex文件是重新解壓出來的,則會保存dex文件的信息,包括解壓的apk文件的crc值、修改時間以及dex文件的數目,以便下一次啓動直接使用已經解壓過的dex緩存文件,而不是每一次都重新解壓。
根據前後順序的話,App第一次運行的時候需要從APK中提取取dex文件,先來看一下MultiDexExtractor的performExtractions()方法:

private List<ExtractedDex> performExtractions() throws IOException {
    // 抽取出的dex文件名前綴是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;

    clearDexDir();

    List<ExtractedDex> files = new ArrayList<ExtractedDex>();

    final ZipFile apk = new ZipFile(sourceApk);
    try {

        int secondaryNumber = 2;
        
        // 獲取"classes${secondaryNumber}.dex"格式的文件
        ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        // 如果dexFile不爲null就一直遍歷
        while (dexFile != null) {
            // 抽取後的文件名是"${apkName}.classes${secondaryNumber}.zip"
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            // 創建文件
            ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
            // 添加到集合中
            files.add(extractedFile);

            Log.i(TAG, "Extraction is needed for file " + extractedFile);
            // 抽取過程中存在失敗的可能,可以多次嘗試,使用isExtractionSuccessful作爲是否成功的標誌
            int numAttempts = 0;
            boolean isExtractionSuccessful = false;
            while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
                numAttempts++;

                // 抽出去apk中對應序號的dex文件,存放到extractedFile這個zip文件中,只包含它一個dex文件
                // extract方法就是一個IO操作
                extract(apk, dexFile, extractedFile, extractedFilePrefix);

                // 判斷是夠抽取成功
                try {
                    extractedFile.crc = getZipCrc(extractedFile);
                    isExtractionSuccessful = true;
                } catch (IOException e) {
                    isExtractionSuccessful = false;
                    Log.w(TAG, "Failed to read crc from " + extractedFile.getAbsolutePath(), e);
                }

                // Log size and crc of the extracted zip file
                Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "succeeded" : "failed")
                      + " '" + extractedFile.getAbsolutePath() + "': length "
                      + extractedFile.length() + " - crc: " + extractedFile.crc);
                if (!isExtractionSuccessful) {
                    // Delete the extracted file
                    extractedFile.delete();
                    if (extractedFile.exists()) {
                        Log.w(TAG, "Failed to delete corrupted secondary dex '" +
                              extractedFile.getPath() + "'");
                    }
                }
            }
            if (!isExtractionSuccessful) {
                throw new IOException("Could not create zip file " +
                                      extractedFile.getAbsolutePath() + " for secondary dex (" +
                                      secondaryNumber + ")");
            }
            secondaryNumber++;
            dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        }
    } finally {
        try {
            apk.close();
        } catch (IOException e) {
            Log.w(TAG, "Failed to close resource", e);
        }
    }

    return files;
}

當MultiDexExtractor的performExtractions()方法調用完畢的時候就把APK中所有的dex文件抽取出來,並以一定文件名格式的zip文件保存在緩存目錄中。然後再把一些關鍵的信息通過調用putStoredApkInfo(Context context, long timeStamp, long crc, int totalDexNumber)方法保存到SP中。
當APK之後再啓動的時候就會從緩存目錄中去加載已經抽取過的dex文件。接着來看一下MultiDexExtractor的loadExistingExtractions()方法:

private List<ExtractedDex> loadExistingExtractions(
    Context context,
    String prefsKeyPrefix)
    throws IOException {
    Log.i(TAG, "loading existing secondary dex files");
    // 抽取出的dex文件名前綴是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
    SharedPreferences multiDexPreferences = getMultiDexPreferences(context);
    // 從SharedPreferences中獲取.dex文件的總數量,調用這個方法的前提是已經抽取過dex文件,所以SP中是有值的
    int totalDexNumber = multiDexPreferences.getInt(prefsKeyPrefix + KEY_DEX_NUMBER, 1);
    final List<ExtractedDex> files = new ArrayList<ExtractedDex>(totalDexNumber - 1);
    // 從第2個dex開始遍歷,這是因爲主dex由Android系統自動加載的,從第2個開始即可
    for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
        // 文件名,格式是"${apkName}.classes${secondaryNumber}.zip"
        String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
        // 根據緩存目錄和文件名得到抽取後的文件
        ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
        // 如果是一個文件就保存到抽取出的文件列表中
        if (extractedFile.isFile()) {
            extractedFile.crc = getZipCrc(extractedFile);
            long expectedCrc = multiDexPreferences.getLong(
                prefsKeyPrefix + KEY_DEX_CRC + secondaryNumber, NO_VALUE);
            long expectedModTime = multiDexPreferences.getLong(
                prefsKeyPrefix + KEY_DEX_TIME + secondaryNumber, NO_VALUE);
            long lastModified = extractedFile.lastModified();
            if ((expectedModTime != lastModified)
                || (expectedCrc != extractedFile.crc)) {
                throw new IOException("Invalid extracted dex: " + extractedFile +
                                      " (key \"" + prefsKeyPrefix + "\"), expected modification time: "
                                      + expectedModTime + ", modification time: "
                                      + lastModified + ", expected crc: "
                                      + expectedCrc + ", file crc: " + extractedFile.crc);
            }
            files.add(extractedFile);
        } else {
            throw new IOException("Missing extracted secondary dex file '" +
                                  extractedFile.getPath() + "'");
        }
    }

    return files;
}

3、安裝Dex文件

提取完dex後,接下來就是安裝過程

private static void installSecondaryDexes(ClassLoader loader, File dexDir,
                                          List<? extends File> files){
    if (!files.isEmpty()) {
        if (Build.VERSION.SDK_INT >= 19) {
            V19.install(loader, files, dexDir);
        } else if (Build.VERSION.SDK_INT >= 14) {
            V14.install(loader, files);
        } else {
            V4.install(loader, files);
        }
    }
}

因爲在不同的SDK版本上,DexClassLoader加載dex文件的方式有所不同,所以這裏做了V4/V14/V19的兼容
下面主要分析SDK19以上安裝過程:

private static final class V19 {

    static void install(ClassLoader loader,
                        List<? extends File> additionalClassPathEntries,
                        File optimizedDirectory){
        // 反射獲取到DexClassLoader的pathList字段;
        Field pathListField = findField(loader, "pathList");
        Object dexPathList = pathListField.get(loader);
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // 將剛剛提取出來的zip文件包裝成Element對象,並擴展DexPathList中的dexElements數組字段;
        expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                                                                     new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                                                                     suppressedExceptions));
        if (suppressedExceptions.size() > 0) {
            for (IOException e : suppressedExceptions) {
                Log.w(TAG, "Exception in makeDexElement", e);
            }
            Field suppressedExceptionsField =
                findField(dexPathList, "dexElementsSuppressedExceptions");
            IOException[] dexElementsSuppressedExceptions =
                (IOException[]) suppressedExceptionsField.get(dexPathList);

            if (dexElementsSuppressedExceptions == null) {
                dexElementsSuppressedExceptions =
                    suppressedExceptions.toArray(
                    new IOException[suppressedExceptions.size()]);
            } else {
                IOException[] combined =
                    new IOException[suppressedExceptions.size() +
                                    dexElementsSuppressedExceptions.length];
                suppressedExceptions.toArray(combined);
                System.arraycopy(dexElementsSuppressedExceptions, 0, combined,
                                 suppressedExceptions.size(), dexElementsSuppressedExceptions.length);
                dexElementsSuppressedExceptions = combined;
            }

            suppressedExceptionsField.set(dexPathList, dexElementsSuppressedExceptions);

            IOException exception = new IOException("I/O exception during makeDexElement");
            exception.initCause(suppressedExceptions.get(0));
            throw exception;
        }
    }

    private static Object[] makeDexElements(
        Object dexPathList, ArrayList<File> files, File optimizedDirectory,
        ArrayList<IOException> suppressedExceptions)
        throws IllegalAccessException, InvocationTargetException,
    NoSuchMethodException {
        // 反射調用DexPathList對象中的makeDexElements方法,將剛剛提取出來的zip文件包裝成Element對象
        Method makeDexElements =
            findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
                       ArrayList.class);

        return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
                                                 suppressedExceptions);
    }
}

反射獲取ClassLoader中的pathList字段;
反射調用DexPathList對象中的makeDexElements方法,將剛剛提取出來的zip文件包裝成Element對象;
將包裝成的Element對象擴展到DexPathList中的dexElements數組字段裏;
makeDexElements中有dexopt的操作,是一個耗時的過程,產物是一個優化過的odex文件。
至此:提取出來的dex文件也被加到了ClassLoader裏,而那些Class也就可以被ClassLoader所找到並使用。

四、存在的問題

MultiDex 並不是萬全的方案,Google 貌似不太熱衷於舊版本的兼容工作,通過閱讀 MultiDex Support 庫的源碼,我們也能發現其代碼寫得貌似沒有那麼嚴謹。
目前來說,使用 MultiDex 可能存在以下問題。

1、NoClassDefFoundError

如果你在調用 MultiDex#install(Context) 做了別的工作,而這些工作需要用到的類卻存在於別的 dex 文件裏面(Secondary Dexes),就會出現類找不到的運行時異常。
正確的做法是把這些需要用到的類標記在 multidex.keep 清單文件裏面,再在 build.gradle 裏面啓用該清單文件。

android {

  defaultConfig {
    multiDexEnabled true
    multiDexKeepProguard file('multidex.pro')
    multiDexKeepFile file('main_dex.txt')
   }
}

dependencies {
  compile 'com.android.support:multidex:1.0.3'
}

multiDexKeepProguard使用的是類似於混淆文件的過濾規則,除了這個配置項之外還有multiDexKeepFile,這個要求你在清單文件裏把所有的類都羅列出來。

2、卡頓/ANR問題

目前 Android 5.0 以上的設備已經自身支持了 MultiDex 功能,也就是說在安裝 apk 的時候,系統已經會幫我們把 apk 裏面的所有 dex 文件都做好 Optimize 處理,所以不需要我們在代碼裏啓用 MultiDex 了。但是對於 Android 5.0 以下的設備,依然要求我們啓用 MultiDex。而這些系統的設備在第一次運行 App 的時候,需要對所有的 Secondary Dexes 文件都進行一次解壓以及 Optimize 處理(生成 odex 文件),這段時間會有明顯的耗時,所有會產生明顯的卡頓現象


1、在Application的attachBaseContext啓動新進程執行dexOpt

protected void attachBaseContext(Context base) {
    // 只有5.0以下需要執行 MultiDex.install
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
        MULTI_DEX = MULTI_DEX + "_" + getVersionCode(base);
        if (SystemUtil.isInMainProcess(base)) {
            // 判斷有沒有執行過dexOpt
            if (!dexOptDone(base)) {
                preLoadDex(base);
            }
        }
        if (!KwaiApp.isMultiDeXProcess(base)) {
            MultiDex.install(base);
        }
    }
    super.attachBaseContext(base);
}

/**
   * 是否進行過DexOpt操作。
   * 
   * @param context
   * @return
   */
private boolean dexOptDone(Context context) {
    SharedPreferences sp = context.getSharedPreferences(MULTI_DEX, MODE_MULTI_PROCESS);
    return sp.getBoolean(MULTI_DEX, false);
}

/**
   * 在單獨進程中提前進行DexOpt的優化操作;主進程進入等待狀態。
   *
   * @param base
   */
public void preLoadDex(Context base) {
    // 在新進程中啓動PreLoadDexActivity
    Intent intent = new Intent(base, PreLoadDexActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    base.startActivity(intent);
    while (!dexOptDone(base)) {
        try {
            // 主線程開始等待;直到優化進程完成了DexOpt操作。
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

2、 子進程中執行dexOpt

public class PreLoadDexActivity extends Activity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    super.onCreate(savedInstanceState);
    // 取消掉系統默認的動畫。
    overridePendingTransition(0, 0);
    setContentView(R.layout.tv_splash_layout);
    new Thread() {
      @Override
      public void run() {
        try {
          // 在子線程中調用
          MultiDex.install(getApplication());
          SharedPreferences sp = getSharedPreferences(App.MULTI_DEX, MODE_MULTI_PROCESS);
          sp.edit().putBoolean(App.MULTI_DEX, true).commit();
          finish();
        } catch (Exception e) {
          finish();
        }
      }
    }.start();
  }

  @Override
  protected void onDestroy() {
    super.onDestroy();
    System.exit(0);
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章