面试官:今日头条启动很快,你觉得可能是做了哪些优化?
网上关于启动优化的文章多不胜数,内容千篇一律,大都是列举一些耗时操作,采用异步加载、懒加载等。
而在面试过程中,关于启动优化的问题,如果只是很表面地回答耗时操作应该放在子线程,显然太过于普通,无法跟竞争者拉开差距。如何让面试官知道你的“内功深厚”,那肯定是要往原理层面去回答。
本文重点还是关注原理,冷启动优化这个问题能延伸到很多原理层面的知识点,本文比较有意思的地方是通过反编译今日头条 App,研究大厂的启动优化方案。
讲启动优化之前,先看下应用的启动流程
应用进程不存在的情况下,从点击桌面应用图标,到应用启动(冷启动),大概会经历以下流程:
Launcher startActivity
AMS startActivity
Zygote fork 进程
ActivityThread main()
4.1. ActivityThread attach
4.2. handleBindApplication
4.3 attachBaseContext
4.4. installContentProviders
4.5. Application onCreate
ActivityThread 进入 loop 循环
Activity 生命周期回调,onCreate、onStart、onResume…
整个启动流程我们能干预的主要是 4.3、4.5 和 6,应用启动优化主要从这三个地方入手。理想状况下,这三个地方如果不做任何耗时操作,那么应用启动速度就是最快的,但是现实很骨感,很多开源库接入第一步一般都是在 Application onCreate 方法初始化,有的甚至直接内置 ContentProvider,直接在 ContentProvider 中初始化框架,不给你优化的机会。
直奔主题,常见的启动优化方式大概有这些:
闪屏页优化
MultipDex 优化(本文重点)
第三方库懒加载
WebView 优化
线程优化
系统调用优化
2.1 闪屏页优化
消除启动时的白屏/黑屏,市面上大部分 App 都采用了这种方法,非常简单,是一个障眼法,不会缩短实际冷启动时间,简单贴下实现方式吧。
<application
android:name=".MainApplication"
...
android:theme="@style/AppThemeWelcome>
复制代码
styles.xml 增加一个主题叫 AppThemeWelcome
<style name="AppThemeWelcome" parent="Theme.AppCompat.NoActionBar">
...
</style>
复制代码
闪屏页设置这个主题,或者全局给 Application 设置
<activity android:name=".ui.activity.DemoSplashActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/AppThemeWelcome"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
复制代码
这样的话启动 Activity 之后背景会一直在,所以在 Activity 的 onCreate 方法中切换成正常主题
protected void onCreate(@Nullable Bundle savedInstanceState) {
setTheme(R.style.AppTheme); //切换正常主题
super.onCreate(savedInstanceState);
复制代码
这样打开桌面图标会马上显示 logo,不会出现黑/白屏,直到 Activity 启动完成,替换主题,logo 消失,但是总的启动时间并没有改变。
2.2 MultiDex 优化(本文重点)
说MultiDex
之前,先梳理下 apk 编译流程
2.2.1 apk 编译流程
Android Studio 按下编译按钮后发生了什么?
打包资源文件,生成 R.java 文件(使用工具 AAPT)
处理 AIDL 文件,生成 java 代码(没有 AIDL 则忽略)
编译 java 文件,生成对应.class 文件(java compiler)
.class 文件转换成 dex 文件(dex)
打包成没有签名的 apk(使用工具 apkbuilder)
使用签名工具给 apk 签名(使用工具 Jarsigner)
对签名后的.apk 文件进行对齐处理,不进行对齐处理不能发布到 Google Market(使用工具 zipalign)
在第 4 步,将 class 文件转换成 dex 文件,默认只会生成一个 dex 文件,单个 dex 文件中的方法数不能超过 65536,不然编译会报错:
Unable to execute dex: method ID not in [0, 0xffff]: 65536
App 集成一堆库之后,方法数一般都是超过 65536 的,解决办法就是:一个 dex 装不下,用多个 dex 来装,gradle 增加一行配置即可。
multiDexEnabled true
这样解决了编译问题,在 5.0 以上手机运行正常,但是 5.0 以下手机运行直接 crash,报错 Class NotFound xxx。
Android 5.0 以下,ClassLoader 加载类的时候只会从 class.dex(主 dex)里加载,ClassLoader 不认识其它的 class2.dex、class3.dex、…,当访问到不在主 dex 中的类的时候,就会报错:Class NotFound xxx,因此谷歌给出兼容方案,MultiDex
。
2.2.2 MultiDex 原来这么耗时
在 Android 4.4 的机器打印MultiDex.install(context)
耗时如下:
MultiDex.install 耗时:1320
复制代码
平均耗时 1 秒以上,目前大部分应用应该还是会兼容 5.0 以下手机,那么 MultiDex 优化是冷启动优化的大头。
为什么MultiDex
会这么耗时?老规矩,分析一下 MultiDex 原理~
2.2.3 MultiDex 原理
下面看下MultiDex
的 install 方法做了什么事
public static void install(Context context) {
Log.i("MultiDex", "Installing application");
if (IS_VM_MULTIDEX_CAPABLE) { //5.0 以上 VM 基本支持多 dex,啥事都不用干
Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
} else if (VERSION.SDK_INT < 4) { //
throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
} else {
...
doInstallation(context, new File(applicationInfo.sourceDir), new File(applicationInfo.dataDir), "secondary-dexes", "", true);
...
Log.i("MultiDex", "install done");
}
}
复制代码
从入口的判断来看,如果虚拟机本身就支持加载多个 dex 文件,那就啥都不用做;如果是不支持加载多个 dex(5.0 以下是不支持的),则走到 doInstallation
方法。
private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix, boolean reinstallOnPatchRecoverableException) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException, InstantiationException {
...
//获取非主 dex 文件
File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
IOException closeException = null;
try {
// 1. 这个 load 方法,第一次没有缓存,会非常耗时
List files = extractor.load(mainContext, prefsKeyPrefix, false);
try {
//2. 安装 dex
installSecondaryDexes(loader, dexDir, files);
}
...
}
}
}
}
复制代码
先看注释 1,MultiDexExtractor#load
List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload) throws IOException {
if (!this.cacheLock.isValid()) {
throw new IllegalStateException("MultiDexExtractor was closed");
} else {
List files;
if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
try {
//读缓存的 dex
files = this.loadExistingExtractions(context, prefsKeyPrefix);
} catch (IOException var6) {
Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var6);
//读取缓存的 dex 失败,可能是损坏了,那就重新去解压 apk 读取,跟 else 代码块一样
files = this.performExtractions();
//保存标志位到 sp,下次进来就走 if 了,不走 else
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
}
} else {
//没有缓存,解压 apk 读取
files = this.performExtractions();
//保存 dex 信息到 sp,下次进来就走 if 了,不走 else
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
}
Log.i("MultiDex", "load found " + files.size() + " secondary dex files");
return files;
}
}
复制代码
查找 dex 文件,有两个逻辑,有缓存就调用loadExistingExtractions
方法,没有缓存或者缓存读取失败就调用performExtractions
方法,然后再缓存起来。使用到缓存,那么performExtractions
方法想必应该是很耗时的,分析一下代码:
private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {
//先确定命名格式
String extractedFilePrefix = this.sourceApk.getName() + ".classes";
this.clearDexDir();
List<MultiDexExtractor.ExtractedDex> files = new ArrayList();
ZipFile apk = new ZipFile(this.sourceApk); // apk 转为 zip 格式
try {
int secondaryNumber = 2;
//apk 已经是改为 zip 格式了,解压遍历 zip 文件,里面是 dex 文件,
//名字有规律,如 classes1.dex,class2.dex
for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
//文件名:xxx.classes1.zip
String fileName = extractedFilePrefix + secondaryNumber + ".zip";
//创建这个 classes1.zip 文件
MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);
//classes1.zip 文件添加到 list
files.add(extractedFile);
Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
int numAttempts = 0;
boolean isExtractionSuccessful = false;
while(numAttempts < 3 && !isExtractionSuccessful) {
++numAttempts;
//这个方法是将 classes1.dex 文件写到压缩文件 classes1.zip 里去,最多重试三次
extract(apk, dexFile, extractedFile, extractedFilePrefix);
...
}
//返回 dex 的压缩文件列表
return files;
}
复制代码
这里的逻辑就是解压 apk,遍历出里面的 dex 文件,例如 class1.dex,class2.dex,然后又压缩成 class1.zip,class2.zip…,然后返回 zip 文件列表。
思考为什么这里要压缩呢? 后面涉及到 ClassLoader 加载类原理的时候会分析 ClassLoader 支持的文件格式。
第一次加载才会执行解压和压缩过程,第二次进来读取 sp 中保存的 dex 信息,直接返回 file list,所以第一次启动的时候比较耗时。
dex 文件列表找到了,回到上面MultiDex#doInstallation
方法的注释 2,找到的 dex 文件列表,然后调用installSecondaryDexes
方法进行安装,怎么安装呢?方法点进去看 SDK 19 以上的实现
private static final class V19 {
private V19() {
}
static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
Field pathListField = MultiDex.findField(loader, "pathList");//1 反射 ClassLoader 的 pathList 字段
Object dexPathList = pathListField.get(loader);
ArrayList<IOException> suppressedExceptions = new ArrayList();
// 2 扩展数组
MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
...
}
private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class);
return (Object[])((Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions));
}
}
复制代码
反射 ClassLoader 的 pathList 字段
找到 pathList 字段对应的类的
makeDexElements
方法通过
MultiDex.expandFieldArray
这个方法扩展dexElements
数组,怎么扩展?看下代码:
private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
Field jlrField = findField(instance, fieldName);
Object[] original = (Object[])((Object[])jlrField.get(instance)); //取出原来的 dexElements 数组
Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length)); //新的数组
System.arraycopy(original, 0, combined, 0, original.length); //原来数组内容拷贝到新的数组
System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); //dex2、dex3...拷贝到新的数组
jlrField.set(instance, combined); //将 dexElements 重新赋值为新的数组
}
复制代码
就是创建一个新的数组,把原来数组内容(主 dex)和要增加的内容(dex2、dex3…)拷贝进去,反射替换原来的dexElements
为新的数组,如下图
看起来有点眼熟,Tinker 热修复的原理也是通过反射将修复后的 dex 添加到这个 dex 数组去,不同的是热修复是添加到数组最前面,而 MultiDex 是添加到数组后面。这样讲可能还不是很好理解?来看看 ClassLoader 怎么加载一个类的就明白了~
2.2.4 ClassLoader 加载类原理
不管是 PathClassLoader
还是DexClassLoader
,都继承自BaseDexClassLoader
,加载类的代码在 BaseDexClassLoader
中
4.4 源码
/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
构造方法通过传入
dex 路径,创建了DexPathList
。
ClassLoader 的 findClass 方法最终是调用 DexPathList 的 findClass 方法
接着看DexPathList
源码 /dalvik/src/main/java/dalvik/system/DexPathList.java
DexPathList
里面定义了一个dexElements
数组,findClass
方法中用到,看下
findClass 方法逻辑很简单,就是遍历 dexElements 数组,拿到里面的 DexFile 对象,通过 DexFile 的 loadClassBinaryName 方法加载一个类。
最终创建 Class 是通过 native 方法,就不追下去了,大家有兴趣可以看下 native 层是怎么创建 Class 对象的。DexFile.cpp
那么问题来了,5.0 以下这个 dexElements 里面只有主 dex(可以认为是一个 bug),没有 dex2、dex3…,MultiDex 是怎么把 dex2 添加进去呢? 答案就是反射DexPathList
的dexElements
字段,然后把我们的 dex2 添加进去,当然,dexElements 里面放的是 Element 对象,我们只有 dex2 的路径,必须转换成 Element 格式才行,所以反射 DexPathList 里面的 makeDexElements 方法,将 dex 文件转换成 Element 对象即可。
dex2、dex3…通过makeDexElements
方法转换成要新增的 Element 数组,最后一步就是反射 DexPathList 的 dexElements 字段,将原来的 Element 数组和新增的 Element 数组合并,然后反射赋值给 dexElements 变量,最后 DexPathList 的 dexElements 变量就包含我们新加的 dex 在里面了。
makeDexElements
方法会判断 file 类型,上面讲 dex 提取的时候解压 apk 得到 dex,然后又将 dex 压缩成 zip,压缩成 zip,就会走到第二个判断里去。仔细想想,其实 dex 不压缩成 zip,走第一个判断也没啥问题吧,那谷歌的 MultiDex 为什么要将 dex 压缩成 zip 呢?在 Android 开发高手课中看到张绍文也提到这一点
然后我在反编译头条 App 的时候,发现头条参考谷歌的 MultiDex,自己写了一套,猜想可能是优化这个多余的压缩过程,头条的方案下面会介绍。
2.2.5 原理小结
ClassLoader 加载类原理:
ClassLoader.loadClass -> DexPathList.loadClass -> 遍历 dexElements 数组 ->DexFile.loadClassBinaryName
通俗点说就是:ClassLoader 加载类的时候是通过遍历 dex 数组,从 dex 文件里面去加载一个类,加载成功就返回,加载失败则抛出 Class Not Found 异常。
MultiDex 原理:
在明白 ClassLoader 加载类原理之后,我们可以通过反射 dexElements 数组,将新增的 dex 添加到数组后面,这样就保证 ClassLoader 加载类的时候可以从新增的 dex 中加载到目标类,经过分析后最终 MultipDex 原理图如下:
2.2.6 MultiDex 优化(两种方案)
知道了 MultiDex 原理之后,可以理解 install 过程为什么耗时,因为涉及到解压 apk 取出 dex、压缩 dex、将 dex 文件通过反射转换成 DexFile 对象、反射替换数组。
那么 MultiDex 到底应该怎么优化呢,放子线程可行吗?
方案 1:子线程 install(不推荐)
这个方法大家很容易就能想到,在闪屏页开一个子线程去执行MultiDex.install
,然后加载完才跳转到主页。需要注意的是闪屏页的 Activity,包括闪屏页中引用到的其它类必须在主 dex 中,不然在MultiDex.install
之前加载这些不在主 dex 中的类会报错 Class Not Found。这个可以通过 gradle 配置,如下:
defaultConfig {
//分包,指定某个类在 main dex
multiDexEnabled true
multiDexKeepProguard file('multiDexKeep.pro') // 打包到 main dex 的这些类的混淆规制,没特殊需求就给个空文件
multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到 main dex
}
复制代码
maindexlist.txt 文件指定哪些类要打包到主 dex 中,内容格式如下
com/lanshifu/launchtest/SplashActivity.class
复制代码
在已有项目中用这种方式,一顿操作猛如虎之后,编译运行在 4.4 的机器上,启动闪屏页,加载完准备进入主页直接崩掉了。
报错NoClassDefFoundError
,一般都是该类没有在主 dex 中,要在 maindexlist.txt 将配置指定在主 dex。 **第三方库中的 ContentProvider 必须指定在主 dex 中,否则也会找不到,为什么?**文章开头说过应用的启动流程,ContentProvider 初始化时机如下图:
ContentProvider 初始化太早了,如果不在主 dex 中,还没启动闪屏页就已经 crash 了。
所以这种方案的缺点很明显:
MultiDex 加载逻辑放在闪屏页的话,闪屏页中引用到的类都要配置在主 dex。
ContentProvider 必须在主 dex,一些第三方库自带 ContentProvider,维护比较麻烦,要一个一个配置。
这时候就思考一下,有没有其它更好的方案呢?大厂是怎么做的?今日头条肯定要对 MultiDex 进行优化吧,反编译瞧瞧?
点了一根烟之后,开始偷代码…
MultiDex 优化方案 2:今日头条方案
今日头条没有加固,反编译后很容易通过关键字搜索找到MultidexApplication
这个类,
看注释 1 的d.a(this);
这个方法,代码虽然混淆了,但是方法内部的代码还是可以看出是干嘛的,继续跟这个方法,为了不影响阅读,我对混淆做了一些处理,改成正常的方法名。
每个方法开头都有PatchProxy.isSupport
这个 if 判断,这个是美团 Robust 热修复生成的代码,今日头条没有自己的热修复框架,没有用 Tinker,而是用美团的,想了解关于 Robust 细节可以参考文末链接。Robust 直接跳过,看 else 代码块即可。
继续看loadMultiDex
方法
评论