字节Android高级岗:今日头条启动速度很快,你觉得可能是做了哪些优化?


网上关于启动优化的文章多不胜数,内容千篇一律,大都是列举一些耗时操作,采用异步加载、懒加载等。

而在面试过程中,关于启动优化的问题,如果只是很表面地回答耗时操作应该放在子线程,显然太过于普通,无法跟竞争者拉开差距。如何让面试官知道你的“内功深厚”,那肯定是要往原理层面去回答。

本文重点还是关注原理,冷启动优化这个问题能延伸到很多原理层面的知识点,本文比较有意思的地方是通过反编译今日头条App,研究大厂的启动优化方案。

讲启动优化之前,先看下应用的启动流程

一、应用启动流程


应用进程不存在的情况下,从点击桌面应用图标,到应用启动(冷启动),大概会经历以下流程:

  1. Launcher startActivity

  2. AMS startActivity

  3. Zygote fork 进程

  4. ActivityThread main()

4.1. ActivityThread attach

4.2. handleBindApplication

4.3 attachBaseContext

4.4. installContentProviders

4.5. Application onCreate

  1. ActivityThread 进入loop循环

  2. 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

闪屏页设置这个主题,或者全局给Application设置

<activity android:name=“.ui.activity.DemoSplashActivity”

android:configChanges=“orientation|screenSize|keyboardHidden”

android:theme=“@style/AppThemeWelcome”

android:screenOrientation=“portrait”>

这样的话启动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 按下编译按钮后发生了什么?

  1. 打包资源文件,生成R.java文件(使用工具AAPT)

  2. 处理AIDL文件,生成java代码(没有AIDL则忽略)

  3. 编译 java 文件,生成对应.class文件(java compiler)

  4. .class 文件转换成dex文件(dex)

  5. 打包成没有签名的apk(使用工具apkbuilder)

  6. 使用签名工具给apk签名(使用工具Jarsigner)

  7. 对签名后的.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 suppressedExceptions = new ArrayList();

// 2 扩展数组

MultiDex.expandFieldArray(dexPathList, “dexElements”, makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));

}

private static Object[] makeDexElements(Object dexPathList, ArrayList files, File optimizedDirectory, ArrayList 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));

}

}

  1. 反射ClassLoader 的 pathList 字段

  2. 找到pathList 字段对应的类的makeDexElements 方法

  3. 通过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

  1. 构造方法通过传入dex路径,创建了DexPathList

  2. 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添加进去呢? 答案就是反射DexPathListdexElements字段,然后把我们的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了。

所以这种方案的缺点很明显:

  1. MultiDex加载逻辑放在闪屏页的话,闪屏页中引用到的类都要配置在主dex。
    自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

总结

写到这里也结束了,在文章最后放上一个小小的福利,以下为小编自己在学习过程中整理出的一个关于Flutter的学习思路及方向,从事互联网开发,最主要的是要学好技术,而学习技术是一条慢长而艰苦的道路,不能靠一时激情,也不是熬几天几夜就能学好的,必须养成平时努力学习的习惯,更加需要准确的学习方向达到有效的学习效果。
由于内容较多就只放上一个大概的大纲,需要更及详细的学习思维导图的
还有高级UI、性能优化、架构师课程、NDK、混合式开发(ReactNative+Weex)微信小程序、Flutter全方面的Android进阶实践技术资料,并且还有技术大牛一起讨论交流解决问题。

跨平台开发:Flutter.png

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

j-1713581169218)]

[外链图片转存中…(img-a0UeOFj2-1713581169219)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

总结

写到这里也结束了,在文章最后放上一个小小的福利,以下为小编自己在学习过程中整理出的一个关于Flutter的学习思路及方向,从事互联网开发,最主要的是要学好技术,而学习技术是一条慢长而艰苦的道路,不能靠一时激情,也不是熬几天几夜就能学好的,必须养成平时努力学习的习惯,更加需要准确的学习方向达到有效的学习效果。
由于内容较多就只放上一个大概的大纲,需要更及详细的学习思维导图的
还有高级UI、性能优化、架构师课程、NDK、混合式开发(ReactNative+Weex)微信小程序、Flutter全方面的Android进阶实践技术资料,并且还有技术大牛一起讨论交流解决问题。

[外链图片转存中…(img-uLSOdLzk-1713581169220)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 13
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值