Gradle:multiDexEnabled之DEX 方法超過64K限制和gradle編譯OOM問題解決DEX
方法超過64K限制
UNEXPECTED TOP-LEVEL EXCEPTION: com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536 at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:502) at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:277) at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:491) at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:168) at com.android.dx.merge.DexMerger.merge(DexMerger.java:189) at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:454) at com.android.dx.command.dexer.Main.runMonoDex(Main.java:302) at com.android.dx.command.dexer.Main.run(Main.java:245) at com.android.dx.command.dexer.Main.main(Main.java:214) at com.android.dx.command.Main.main(Main.java:106)
如果你是一個Android開發者,你至少聽說過的Dalvik的蛋疼的64K方法限制。概括地說,在一個DEX文件,你可以調用很多的方法,但你只能調用它們最前面的65,536個 ,因為這是在方法調用集合中的所有的空間了。如果你的源代碼和狂拽炫酷叼炸天的三方庫中方法超過了這個限制
為了解決這個問題,Android開發社區有人想出了一些解決方案,比如dmarcato的這個,還有casidiablo的這個。他們都是可行的,但是需要一些比較嚴格的條件。
最終,Google決定提供一套官方的解決方案,在10月14日的時候發布了MultiDex 支持庫,隨后幾周gradle在 v0.14.0版本中也支持了
使用MultiDex支持庫
如果你在使用 Android Studio,這個用起來很簡單。如果不是,強烈建議你遷移過來。因為Google很快就會不知處Eclipse插件和舊的基於Ant的系統構建方式。
第1步 添加依賴於你的build.gradle支持MultiDex庫
dependencies { ... compile 'com.android.support:multidex:' ... }
第2步 在buildType或productFlavor中開啟multiDexEnabled
defaultConfig { ... multiDexEnabled true ... }
第三步 Application注冊
現在,根據你的項目情況,你有3種選擇:
1、 如果你沒有創建自己的Application 類,在你的清單文件
AndroidManifest.xml中配置android.support.multidex.MultiDexApplication就可以了。
android:name="android.support.multidex.MultiDexApplication"
2、如果你有自己的Application類了
讓它繼承android.support.multidex.MultiDexApplication而不是android.app.Application
3、如果你的Application繼承了其他的類,並且你不想改變或者沒辦法改變 按照下面的方法重寫attachBaseContext()
public class MyApplication extends FooApplication { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); MultiDex.install(this); } }
不論你選擇上面哪種,都會創建多個大小差不多的dex文件代替單個龐大的dex文件。運行的時候回同事加載所有的這些dex文件。
當年編譯app的時候,Gradle會生成很多個dex文件和一個apk文件讓你可以在設備或者模擬器上運行。
Out of memory 問題
對於有很多依賴的項目,編譯可能因為下面的錯誤中斷
Error:Execution failed for task ':app:dexDebug'. ... Error Code: 3 Output: UNEXPECTED TOP-LEVEL ERROR: java.lang.OutOfMemoryError: GC overhead limit exceeded at com.android.dx.cf.cst.ConstantPoolParser.parse0(ConstantPoolParser.java:326)
在build.gralde android標簽下面添加下面代碼可以解決
dexOptions { incremental true javaMaxHeapSize "4g" }
應用啟動緩慢
根據我們的經驗,添加了這個支持庫以后,大多數情況下都正常了。這對某些設備,比如Kindle Fire上面,應用啟動會比之前慢很多。加載所有的類在應用一啟動的時候會花費大量的時間。這就會導致黑屏一段時間,甚至導致ANR
這個雖然在大多數時候可以解決DEX 64K的問題,但是應該是保留使用。當你嘗試使用它以前,請先嘗試刪除不需要的依賴並且使用ProGuard混淆,如果你必須要使用這個方案。請確保在舊設備上做了測試
multidex帶來的性能問題-減慢app啟動速度
背景
先為外行做一下科普。安卓app是由被轉換成一個.class文件的java寫成的。然后這個class文件(以及任何jar依賴)被編譯成單個classes.dex文件。然后這個dex文件和一個apk文件(即最終從app商店所下載的東西)所需要的任意資源相組合。
更多參見 這里 。
這種編譯過程的一個缺陷是一個dex文件系統只允許最多有65k個方法。在安卓的早期,達到65k方法上限的應用解決這個問題的辦法就是使用Proguard來減少無用的代碼。但是,這個方法有局限,並且只是為生產app拖延了接近65k限制的時間。
為了解決這個問題,谷歌在最近的兼容庫中放出了一個65k方法限制的解決方案:multidexing。這個方法非常方便並且允許你65k方法限制,但是(就如我之前說的),對性能有一個非常嚴重的影響,可能會減慢app的啟動速度。
設置multidex
multidex是一個文檔齊全的成熟的解決方案。我強烈推薦遵循 安卓開發者網站 上的指示來啟用multidex。你也可以參考github上的 項目樣例
NoClassDefFoundError?!
在為項目配置multidexing 的時候,你可能會在運行的時候看到java.lang.NoClassDefFoundError。這意味着app啟動的class不在main dex文件中。Android SDK Build Tools 21.1或者更高版本中的Gradle Android 插件有對multidex 的支持。這個插件使用Proguard 來分析你的項目並在 [buildDir]/intermediates/multi-dex/[buildType]/maindexlist.txt文件中生成一個app啟動classes 的列表。但是這個列表並不是100%准確,可能會丟失一些app啟動所需的classes 。
YesClassDefFound
為了解決這個問題,你應該在multidex.keep 文件中羅列出那些class,以便讓編譯器知道在main dex文件中要保持哪些class。.
- 在工程目錄中創建一個multidex.keep文件。
- 把java.lang.NoClassDefFoundError中報告的class列舉到multidex.keep文件。(注意:
不要直接修改build目錄里的maindexlist.txt ,這個文件每次在編譯的時候都會生成)。 - 添加如下腳本到build.gradle。這個腳本將在編譯項目的時候把multidex.keep 和
由Gradle生成的maindexlist.txt 結合在一起。
android.applicationVariants.all { variant -> task "fix${variant.name.capitalize()}MainDexClassList" << { logger.info "Fixing main dex keep file for $variant.name" File keepFile = new File("$buildDir/intermediates/multi-dex/$variant.buildType.name/maindexlist.txt") keepFile.withWriterAppend { w -> // Get a reader for the input file w.append('\n') new File("${projectDir}/multidex.keep").withReader { r -> // And write data from the input into the output w << r << '\n' } logger.info "Updated main dex keep file for ${keepFile.getAbsolutePath()}\n$keepFile.text" } } } tasks.whenTaskAdded { task -> android.applicationVariants.all { variant -> if (task.name == "create${variant.name.capitalize()}MainDexClassList") { task.finalizedBy "fix${variant.name.capitalize()}MainDexClassList" } } }
multidex app啟動性能問題
如果你使用multidex,你需要意識到它對app啟動性能有影響。我們通過跟蹤app的啟動時間發現了這個問題-用戶點擊app圖標到所有圖片都下載完並顯示給用戶的這段時間。一旦multidex 啟用,在所有運行Kitkat (4.4) 及以下的設備上我們的app啟動時間就會大約增加15%。更多信息參考 Carlos Sessa的Lazy Loading Dex files 。
這是因為Android 5.0 以及更高版本使用了一個叫做ART的運行時,它天生就支持從應用的apk文件中加載multiple dex文件。
解決multidex app啟動性能問題
在app啟動到所有圖片顯示的間隙,存在着許多沒有被Proguard 檢測到的class,因此它們也就沒有被存進main dex文件中。現在的問題是,我們如何才能知道在app啟動期間什么樣的calss被加載了呢?
幸運的是,在 ClassLoader中我們有 findLoadedClass 方法。我們的辦法就是在app啟動結束的時候做一次運行時檢查。如果第二個dex 文件中存有任何在app啟動期間加載的class,那么就通過添加calss name 到multidex.keep文件中的方式來把它們移到main dex文件中。我的 項目案例 中有實現的細節,但是你也可以這樣做:
- 在你認為app啟動結束的地方運行下面util類中的getLoadedExternalDexClasses
- 把上面這個方法返回的列表添加到你的 multidex.keep 文件然后重新編譯。
public class MultiDexUtils { private static final String EXTRACTED_NAME_EXT = ".classes"; private static final String EXTRACTED_SUFFIX = ".zip"; private static final String SECONDARY_FOLDER_NAME = "code_cache" + File.separator + "secondary-dexes"; private static final String PREFS_FILE = "multidex.version"; private static final String KEY_DEX_NUMBER = "dex.number"; private SharedPreferences getMultiDexPreferences(Context context) { return context.getSharedPreferences(PREFS_FILE, Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB ? Context.MODE_PRIVATE : Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS); } /** * get all the dex path * * @param context the application context * @return all the dex path * @throws PackageManager.NameNotFoundException * @throws IOException */ public List<String> getSourcePaths(Context context) throws PackageManager.NameNotFoundException, IOException { final ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); final File sourceApk = new File(applicationInfo.sourceDir); final File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME); final List<String> sourcePaths = new ArrayList<>(); sourcePaths.add(applicationInfo.sourceDir); //add the default apk path //the prefix of extracted file, ie: test.classes final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT; //the total dex numbers final int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1); for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) { //for each dex file, ie: test.classes2.zip, test.classes3.zip... final String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX; final File extractedFile = new File(dexDir, fileName); if (extractedFile.isFile()) { sourcePaths.add(extractedFile.getAbsolutePath()); //we ignore the verify zip part } else { throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'"); } } return sourcePaths; } /** * get all the external classes name in "classes2.dex", "classes3.dex" .... * * @param context the application context * @return all the classes name in the external dex * @throws PackageManager.NameNotFoundException * @throws IOException */ public List<String> getExternalDexClasses(Context context) throws PackageManager.NameNotFoundException, IOException { final List<String> paths = getSourcePaths(context); if(paths.size() <= 1) { // no external dex return null; } // the first element is the main dex, remove it. paths.remove(0); final List<String> classNames = new ArrayList<>(); for (String path : paths) { try { DexFile dexfile = null; if (path.endsWith(EXTRACTED_SUFFIX)) { //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache" dexfile = DexFile.loadDex(path, path + ".tmp", 0); } else { dexfile = new DexFile(path); } final Enumeration<String> dexEntries = dexfile.entries(); while (dexEntries.hasMoreElements()) { classNames.add(dexEntries.nextElement()); } } catch (IOException e) { throw new IOException("Error at loading dex file '" + path + "'"); } } return classNames; } /** * Get all loaded external classes name in "classes2.dex", "classes3.dex" .... * @param context * @return get all loaded external classes */ public List<String> getLoadedExternalDexClasses(Context context) { try { final List<String> externalDexClasses = getExternalDexClasses(context); if (externalDexClasses != null && !externalDexClasses.isEmpty()) { final ArrayList<String> classList = new ArrayList<>(); final java.lang.reflect.Method m = ClassLoader.class.getDeclaredMethod("findLoadedClass", new Class[]{String.class}); m.setAccessible(true); final ClassLoader cl = context.getClassLoader(); for (String clazz : externalDexClasses) { if (m.invoke(cl, clazz) != null) { classList.add(clazz.replaceAll("\\.", "/").replaceAll("$", ".class")); } } return classList; } } catch (Exception e) { e.printStackTrace(); } return null; } }
結論
這里是我們在多個設備上觀察到的啟動性能的提升效果。第一列(藍色)是沒有multidexing的基准app啟動時間。你可以在第二列(紅色)看到明顯的增加,
這是啟用了multidex但沒有其它任何額外工作的app啟動時間。第三列(綠色)是開啟了multidex 並且使用了我們提升方法的app啟動時間。就如圖中所看到的,
app啟動時間降到了multidex開啟之前的水平,甚至更低。自己試試吧,你應該也能觀察到性能的提升。
后記
僅僅因為你能並不意味着你應該。你應該把multidex看成最后的辦法因為它對app啟動時間存在很大影響而且要解決這個問題你需要維護額外的代碼並解決奇怪的錯誤
(比如: java.lang.NoClassDefFoundError)。一旦達到了65k方法數的限制,我們應該先避免去使用multidex以防止性能問題。我們不斷的檢查使用的sdk找出許
多可以移除或者重構的無用代碼。只有此時仍然沒有辦法的時候我們才考慮multidex。那時我們的代碼質量也會有個質的飛躍。不要直接使用multidex,要先保持代碼的干凈,
復用現有組建,或者重構代碼來避免65k方法數限制。