英文原文: Android’s multidex slows down app startup
在安卓社区中,65k方法数的限制是一个被多次提及的问题。目前解决这个问题的办法就是用multidex。虽然multidex是谷歌给出的一个非常棒的办法,但是我发现了它对app启动性能存在严重的影响,这点还没有在社区引起重视。我这篇文章的就是为那些还没有听说过这个问题(但是想使用multidexing)的开发者以及那些使用了multidexing,但是想观察我的解决办法能赢得的性能的伙伴而写的。
先为外行做一下科普。安卓app是由被转换成一个.class文件的java写成的。然后这个class文件(以及任何jar依赖)被编译成单个classes.dex文件。然后这个dex文件和一个apk文件(即最终从app商店所下载的东西)所需要的任意资源相组合。
更多参见 这里 。
这种编译过程的一个缺陷是一个dex文件系统只允许最多有65k个方法。在安卓的早期,达到65k方法上限的应用解决这个问题的办法就是使用Proguard来减少无用的代码。但是,这个方法有局限,并且只是为生产app拖延了接近65k限制的时间。
为了解决这个问题,谷歌在最近的兼容库中放出了一个65k方法限制的解决方案:multidexing。这个方法非常方便并且允许你65k方法限制,但是(就如我之前说的),对性能有一个非常严重的影响,可能会减慢app的启动速度。
multidex是一个文档齐全的成熟的解决方案。我强烈推荐遵循 安卓开发者网站 上的指示来启用multidex。你也可以参考我的github上的 项目样例 。
在为项目配置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 。
为了解决这个问题,你应该在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启动性能有影响。我们通过跟踪app的启动时间发现了这个问题-用户点击app图标到所有图片都下载完并显示给用户的这段时间。一旦multidex 启用,在所有运行Kitkat (4.4) 及以下的设备上我们的app启动时间就会大约增加15%。更多信息参考 Carlos Sessa 的 Lazy Loading Dex files 。
这是因为Android 5.0 以及更高版本使用了一个叫做ART的运行时,它天生就支持从应用到apk文件中加载multiple dex文件。
在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方法数限制。