首页 短视频 抖音短视频 文章详情

抖音BoostMultiDex优化实践AndroidAPP首次启动时间减少80%

2020-03-19 19:18:48

低版本Andriod因CPU性能和系统版本原因,运行在系统上的程序通常需要做适配和降级处理,其中低版本安卓的冷启动耗时时间长十分突出,字节跳动技术团队深入研究起因,实现最优解决方案,实现APP首次启动时间减少80%。

以下文章来源于字节跳动技术团队 ,作者晓霖

字节跳动技术团队
字节跳动技术团队

字节跳动的技术实践分享

前情回顾:抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(一)

抖音自研的 BoostMultiDex 方案,可以大幅改善 Android 低版本(4.4 及其以下)手机更新或安装后首次冷启动时间。并且,不同于目前业界所有优化方案,我们是从 Android Dalvik 虚拟机底层机制入手,从根本上解决了安装后首次执行 MultiDex 耗时过长问题。我们上一篇文章中已经介绍了 BoostMultiDex 的核心优化思路,即如何避免 ODEX,直接加载原始 DEX 完成启动。然而用这个方法加载 DEX 文件,相比于 ODEX 优化后的方式,其 Java 代码执行性能上还是有所损失的。我们也可以从前面方法的注释里面看出,虚拟机对于直接加载原始 DEX 的情况只是做了些基本优化:
The system will only perform "essential" optimizations on the given file.
所以,虽然第一次启动我们是加载了原始 DEX 来执行的,但从长远的角度考虑,后续的启动,还是应该尽量采用 ODEX 的方式来执行。因此,我们还需要在第一次启动完成后,在后台适当的时候做好 ODEX 优化。一开始我们是做法也比较简单,在顺利加载 DEX 字节数组,完成启动之后,在后台开辟单独的线程执行DexFile.loadDex就可以了。这样当后台做完 ODEX 后,APP 第二次启动时,就可以直接加载之前做好的 ODEX,得到较好的执行性能。这种做法在线下测试的时候也很正常,然而在上线之后,我们遇到了这样一个问题……

SIGSTKFLT 问题

线上报上来一个 Native Crash,它的堆栈如下所示:
Signal 16(SIGSTKFLT), Code -6(SI_TKILL)
#00 pc 00016db4 /system/lib/libc.so (write+12) [armeabi-v7a]
#01 pc 000884a5 /system/lib/libdvm.so (sysWriteFully(int, void const*, unsigned int, char const*)+28) [armeabi-v7a]
#02 pc 00088587 /system/lib/libdvm.so (sysCopyFileToFile(int, int, unsigned int)+114) [armeabi-v7a]
#03 pc 00050d41 /system/lib/libdvm.so (dvmRawDexFileOpen(char const*, char const*, RawDexFile**, bool)+392) [armeabi-v7a]
#04 pc 00064a41 /system/lib/libdvm.so [armeabi-v7a]
#05 pc 000276e0 /system/lib/libdvm.so [armeabi-v7a]
#06 pc 0002b5c4 /system/lib/libdvm.so (dvmInterpret(Thread*, Method const*, JValue*)+184) [armeabi-v7a]
#07 pc 0005fc79 /system/lib/libdvm.so (dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list)+272) [armeabi-v7a]
#08 pc 0005fca3 /system/lib/libdvm.so (dvmCallMethod(Thread*, Method const*, Object*, JValue*, ...)+20) [armeabi-v7a]
#09 pc 0005481f /system/lib/libdvm.so [armeabi-v7a]
#10 pc 0000e3e8 /system/lib/libc.so (__thread_entry+72) [armeabi-v7a]
#11 pc 0000dad4 /system/lib/libc.so (pthread_create+160) [armeabi-v7a]
APP 收到 SIGSTKFLT 信号崩溃了,同时还输出了这样的日志:
06-25 15:10:53.821 7449 7450 E dalvikvm: threadid=2: stuck on threadid=135, giving up
06-25 15:10:53.821 7449 7450 D dalvikvm: threadid=2: sending two SIGSTKFLTs to threadid=135 (tid=8021) to cause debuggerd dump
SIGSTKFLT 是 Dalvik 虚拟机特有的一个信号。当虚拟机发生了 ANR 或者需要做 GC 的时候,就需要挂起所有 RUNNING 状态的线程,如果此时 Dalvik 虚拟机等待了足够长时间,线程仍旧无法被挂起,就会调用dvmNukeThread函数发送 SIGSTKFLT 信号给相应线程,从而杀死 APP。具体代码如下:
static void waitForThreadSuspend(Thread* self, Thread* thread) { constint kMaxRetries = 10;
... ... while (thread->status == THREAD_RUNNING) {
... ... if (retryCount++ == kMaxRetries) {
                ALOGE("Fatal spin-on-suspend, dumping threads");
                dvmDumpAllThreads(false); /* log this after -- long traces will scroll off log */ =>              ALOGE("threadid=%d: stuck on threadid=%d, giving up",
                    self->threadId, thread->threadId); /* try to get a debuggerd dump from the spinning thread */ =>              dvmNukeThread(thread); /* abort the VM */ dvmAbort();
... ...
}
而从堆栈我们看出,杀死进程的时候,我们正调用DexFile.loadDex,这个方法最后会调用到dvmRawDexFileOpen里面,执行 write 操作。而这个 write 涉及 I/O 操作,是比较耗时的。所以,当线程在做 dexopt,长时间无法响应虚拟机的挂起请求时,就会触发这个问题。一般来说,虚拟机在执行 Java 代码的时候,都会是 RUNNING 状态。而只要调用了 JNI 方法,在执行到 C/C++代码的时候,就会切换为 NATIVE 状态。而虚拟机只会在 RUNNING 状态下会挂起线程,如果是在 NATIVE 状态下,虚拟机是不会要求线程必须挂起的。不过,这里有一个特殊之处。虽然DexFile.loadDex方法最终也走到了 JNI 里面调用dvmRawDexFileOpen函数,但由于DexFile类是虚拟机的内部类,Dalvik 虚拟机不会在内部类执行 JNI 方法的时候将线程切换为 NATIVE 状态,仍然会保持原来的 RUNNING 状态。于是,在 RUNNING 状态下,做 OPT 的线程就会被要求挂起。而此时由于正在执行耗时的 write 操作,无法响应挂起请求,便出现了如上的崩溃。当然,可能有人会想到在 Native 代码中,用CallStaticObjectMethod来触发DexFile.loadDex,不过这种方式是不可行的。因为CallStaticObjectMethod调用 Java 方法DexFile.loadDex时,会使得状态再次切换为 RUNNING。具体来看下 CallStatciXXXMethod 方法的定义处:
static _ctype CallStatic##_jname##Method(JNIEnv* env, jclass jclazz,    
    jmethodID methodID, ...)                                            
{                                                                       
    UNUSED_PARAMETER(jclazz);                                            ScopedJniThreadState ts(env);                                       
    JValue result;                                                      
    va_list args;                                                       
    va_start(args, methodID);                                           
    dvmCallMethodV(ts.self(), (Method*)methodID, NULL, true, &result, args);
    va_end(args);                                                        if (_isref && !dvmCheckException(ts.self()))                        
        result.l = (Object*)addLocalReference(ts.self(), result.l);            return _retok;                                                      
}
关键在于 ScopedJniThreadState:
explicit ScopedJniThreadState(JNIEnv* env) {
    mSelf = ((JNIEnvExt*) env)->self;
... ...
    CHECK_STACK_SUM(mSelf);
    dvmChangeStatus(mSelf, THREAD_RUNNING);
}

~ScopedJniThreadState() {
    dvmChangeStatus(mSelf, THREAD_NATIVE);
    COMPUTE_STACK_SUM(mSelf);
}
在使用dvmCallMethodV调用 Java 方法前,会先切换状态为THREAD_RUNNING,执行完毕后,ScopedJniThreadState析构,再切换回THREAD_NATIVE。这样,JNI 执行DexFile.loadDex就和直接执行 Java 代码一样,状态会有问题。不只是CallStaticXXXMethod,所有使用CallXXXMethod函数在 Native 下调用 Java 方法的情况都是如此。好在,我们想到了另一个办法:既然 Dalvik 不会对内部类的 JNI 调用做切换,我们就自己写一个 JNI 调用,使其走到 Native 代码中,这样线程就会变为 Native 状态,然后直接调用虚拟机内部函数做 dexopt 即可。这样在做 dexopt 的时候,始终会处于 NATIVE 的状态,不会切为 RUNNING,也不会被要求挂起,也就能避免这个问题。这个虚拟机内部函数就是dvmRawDexFileOpen,我们先来看下它的代码说明:
/*
 * Open a raw ".dex" file, optimize it, and load it.
 *
 * On success, returns 0 and sets "*ppDexFile" to a newly-allocated DexFile.
 * On failure, returns a meaningful error code [currently just -1].
 */ int dvmRawDexFileOpen(const char* fileName, const char* odexOutputName,
    RawDexFile** ppDexFile, bool isBootstrap);
这个函数可以用来打开原始 DEX 文件,并且对它做优化和加载。对应到 libdvm.so 中的符号是_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb,我们只需要用 dlsym 在 libdvm.so 里面找到它,就可以直接调用了,完整代码如下:
using func = int (*)(constchar* fileName, constchar* odexOutputName, void* ppRawDexFile, bool isBootstrap); void* handler = dlopen("libdvm.so", RTLD_NOW);
dvmRawDexFileOpen = (func) dlsym(handler, "_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb");
dvmRawDexFileOpen(file_path, opt_file_path, &arg, false);
这样,我们自己写一个 JNI 调用,在 Native 状态下执行上述代码,就能达到完成 ODEX 的目的,从而根本上杜绝这个异常了。另外,我们把 dexopt 操作放到了单独进程执行,由此可以避免 ODEX 操作对主进程造成其他性能影响。此外,由于设备情况多种多样,运行环境十分复杂,还可能会有一些厂商魔改,导致的 dlsym 找不到_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb符号,虽然这种情况极为罕见,但理论上仍有可能发生。单独进程里面由于环境比较纯粹,基本很少发生 ANR 和 GC 事件,挂起的情况就很少,也能最大程度规避这个问题。

多级加载

我们发现,相比于官方 MultiDex 加载 ZIP 形态的 DEX 文件,非 ZIP 方式的 DEX(也就是直接对 DEX 文件做 ODEX,而不用先把 DEX 压缩进 ZIP 里面)对于整体时间也有一定程度的优化,因为这种非 ZIP 方式避免了原先的两个耗时:
  1. 把原始 DEX 压缩为 ZIP 格式的时间;
  2. ODEX 优化的时候从 ZIP 中解压出原始 DEX 的时间。
非 ZIP 的方式相比于 ZIP 方式,整体耗时会减少 40%左右,但是 DEX 文件磁盘占用空间比原先 ZIP 文件的方式增加一倍多。因此我们可以只在磁盘空间充裕的时候,优先使用非 ZIP 方式加载。而我们openDexFile_bytearray加载 DEX 的方式,需要的只是原始 DEX 文件的字节数组(byte[])。这个字节数组我们在首次冷启动的时候是直接从 APK 里面解压提取得到的。我们可以在这次启动提取完成后,先把这些字节数组落地为 DEX 文件。这样如果再次启动 APP 的时候,ODEX 没做完,就可以直接使用前面保存的 DEX 文件来得到字节数组了,从而避免了从 APK 解压的时间。总体来看,我们整套方案中一共存在四种形态的 DEX:
  1. 从 APK 文件里面解压得到的 DEX 字节数组;
  2. 从落地的 DEX 文件里面得到的 DEX 字节数组;
  3. 从 DEX 文件优化得到的 ODEX 文件;
  4. 从 ZIP 文件优化得到的 ODEX 文件。
生成各个产物的时序图如下所示:

我们依次说明每一步:

  • A. 从 APK 里面直接解压得到 DEX 字节数组;
  • B. 将 DEX 数组保存为文件;
  • C. 用 DEX 文件生成 ODEX 文件;
  • D. 用 DEX 数组生成 ZIP 文件以及它对应的 ODEX 文件。
正常情况下,我们会依次按 A -> B -> C 的时序依次产生各个文件,如果中间有中断的情况,我们下次启动后会继续按照当前已有产物做对应操作。我们仅在磁盘空间不够,且所在系统不支持直接加载字节数组的情况下才会走 ZIP&ODEX 方式的 D 路径。这里不支持的情况主要是一些特殊机型,比如 4.4 却采用了 ART 虚拟机的机型、阿里 Yun OS 机型等。接下来我们继续看下加载流程图:
  • 当 APP 首次启动的时候,如果会从 APK 里面解压 DEX 数组,因此会按照 a -> b 的路径执行;

  • 当 APP 发现只有 DEX 文件,没有 ODEX 文件时,会把从 DEX 文件中取得 DEX 数组,按照 c -> b 路径执行;

  • 当 APP 发现 DEX 文件和 ODEX 文件都存在的时候,会按照 ODEX 方式加载,按照 d 路径执行;

  • 当 APP 发现有 ZIP 文件以及它所对应的 ODEX 的时候,会按照 e 路径执行。

这么一来,APP 就可以根据当前情况,选择最合适的方式执行加载 DEX 了。从而保证了任意时刻的最优性能。

进程锁优化

前面提到,OPT 优化是在单独的进程里面执行的。单独进程除了可以减少前面的 SIGSTKFLT 问题,还能在做完 OPT 后及时终止后台进程,避免过多的资源占用。然而,在单独进程处理 OPT 和其他进程执行 install 的时候,都涉及到 DEX 和 ODEX 文件的访问和生成,因此在这些进程之间涉及到文件访问和 OPT 时,都是加文件锁互斥执行的。这样可以避免加载的同时,另一个进程在操作 DEX 和 ODEX 文件导致的文件损坏。在官方的 MultiDex 中也是采用这种文件锁的方式来进行互斥访问的。但这带来了另一个问题,如果 OPT 进程在长时间做 dexopt,而此时主进程(或者其他后台进程)需要再次启动,便会因为 OPT 进程持有互斥文件锁,而导致这些进程被阻塞住无法继续启动。可以看流程图来理解这一过程:正如图中描绘的场景,用户第一次打开了 APP,然后运行一会之后因为一些情况杀死了 APP,这时,后台进程已经启动并正在做 OPT。如果此时用户想要再次打开,就会由于 OPT 进程互斥锁导致阻塞而黑屏。这显然是不可接受的。因此,我们就需要采取更好的策略,使得在主进程能够正常地继续往下执行,而不至于被阻塞住。这个问题的关键在于,主进程需要依赖 OPT 进程的产物,才能继续往下执行,而 OPT 进程此时正在操作 DEX 文件,这个过程中的产物必定无法被主进程直接使用。所以,如果想要主进程不再因 OPT 操作阻塞,我们很容易想到可以无视 OPT 进程,不使用 DEX 文件,只从 APK 里面获取内存形式的 DEX 字节码就可以了。不过这种方式的主要问题在于,如果 OPT 时间非常长,在这段时间内就不得不一直使用内存方式的 DEX 启动 APP,这样性能就会处于比较差的水平。因此我们采用的是另一种方案。在主进程退出而再次启动的时候,先中止 OPT 进程,直接取得现有 DEX 产物进行加载,然后再唤起 OPT 进程。如下图所示:这里关键点在于如何中止进程。当然,我们可以直接在主进程发信号杀死 OPT 进程,不过这种方式过于粗暴,很可能导致 DEX 文件损坏。而且 kill 信号的方式没有回调,我们无法得知是否进程确实地退出了。因此,我们采取的方式是用两个文件锁来做同步,保证进程启动和退出的信息可以在多个进程之间传达。第一个文件锁就是单纯用来作为互斥锁,保证处理 DEX 和加载 DEX 的过程是互斥发生的。第二个文件锁用来表示进程即将获取互斥锁,我们称之为准备锁,它可以用来通知 OPT 进程:此时有其他进程正需要加载 DEX 产物。对于 OPT 进程而言,获取文件锁的步骤如下:
  1. 获取互斥锁;
  2. 执行 OPT;
  3. 非阻塞地尝试获取准备锁;
  4. 如果没有获取到准备锁,表示此时有其他进程已经持有准备锁,则释放互斥锁,并退出 OPT 进程;
  5. 如果获取到了准备锁,表示此时没有其他进程正常持有准备锁,则再次执行第 2 步,做下个文件的 OPT;
  6. 完成所有 DEX 文件的 OPT 操作,释放互斥锁,退出。
对于主进程(或其他非 OPT 进程)而言,获取文件锁的步骤如下:
  1. 阻塞等待获取准备锁;
  2. 阻塞等待获取互斥锁;
  3. 释放准备锁;
  4. 完成 DEX 加载;
  5. 释放互斥锁;
  6. 继续往下执行业务代码。

具体情形见下图:

首先,OPT 进程开始执行,会获取到互斥锁,然后做 DEX 处理。OPT 进程在处理完第一个 DEX 文件后,由于没有其他进程持有准备锁,因此 OPT 进程获取准备锁成功,然后释放准备锁,继续做下一个 DEX 优化。这时候,主进程(或其他非 OPT 进程)启动,先成功地获取准备锁。然后继续阻塞地获取互斥锁,此时由于 OPT 进程已经在前一步获取到了互斥锁,因此只能等待其释放。OPT 进程在处理完第二个 DEX 后,检测到准备锁已经被其他进程持有了,因此获取失败,从而停止继续做 OPT,释放互斥锁并退出。此时主进程就可以成功地获取到互斥锁,并且立即释放准备锁,以便其他进程可以获取。接着,在完成 DEX 加载后,释放互斥锁,继续执行后续业务流程。最后再唤起 OPT 进程接着做完原先的 DEX 处理。总体看来,在这种模式下,OPT 进程可以主动发现有其他进程需要加载 DEX,从而中断 DEX 处理,并释放互斥锁。主进程便不需要等待整个 DEX 处理完成,只需要等 OPT 进程完成最近一个 DEX 文件的处理就可以继续执行了。

实测数据

我们本地选取了几台 4.4 及以下的设备,对它们首次启动的 DEX 加载时间进行了对比:

以上是在抖音上测得的实际数据,APK 中共有 6 个 Secondary DEX,显而易见,BoostMultiDex 方案相比官方 MultiDex 方案,其耗时有着本质上的优化,基本都只到原先的 11%~17%之间。也就是说 BoostMultiDex 减少了原先过程 80%以上的耗时。 另外我们看到,其中有一个机型,在官方 MultiDex 下是直接崩溃,无法启动的。使用 BoostMultiDex 也将使得这些机型可以焕发新生。另外,我们在线上采取了对半分的方式,也就是 BoostMultiDex 和原始 MultiDex 随机各自选取一半线上设备,对比二者的耗时。我们先以设备维度来看,这里随机选取了 15 分钟的线上数据,图中横轴为每个 Android 版本 4.4 及以下的设备,纵轴为首次启动加载 DEX 的耗时,按耗时升序排列,单位为纳秒。BoostMultiDex 下的设备耗时:MultiDex 下的设备耗时:两张图最大的区别在于纵轴的时间刻度。可以看到,绝大多数设备的 BoostMultiDex 耗时在 5s 左右,最多耗时也不会超过 35s。而反观 MultiDex,大多数都需要耗时 30 多 s,最长的耗时甚至达到了将近 200s。上面的图可能差别不够明显,我们选取一段时间,每半小时取所有设备耗时的中位数,可以得到下面的对比曲线:其中,下方橙色线为 BoostMultiDex,上方蓝色线为原始 MultiDex,可以明显看出,耗时下降的幅度非常巨大。耗时的大幅减少会带来怎样的效果呢?我们统计了 4.4 及以下机型中,两者进入到抖音播放页的设备数占比,时间范围为一周,其中右边橙色为 BoostMultiDex,左边蓝色为原始 MultiDex。由于我们所有设备对于两种方案的选取是对半开的,所以理论上二者的设备数应该接近于 1 比 1,不过从图中我们可以看到,BoostMultiDex 的设备数已经大幅超过 MultiDex 的设备数,两者比例接近于 2 比 1。从中可以看出,MultiDex 耗时的减少对于设备活跃数的提升,效果十分显著!

总结

最后,我们再梳理一下整个方案的实现要点:
  1. 采用openDexFile_bytearray函数,可以直接加载原始 DEX 字节码;
  2. 提前注入dex_object对象,以解决 4.4 机型上加载原始 DEX 字节码时,getDex的崩溃问题;
  3. 采用dvmRawDexFileOpen函数做 ODEX,以解决 SIGSTKFLT 问题;
  4. 多级加载,在 DEX 字节码、DEX 文件、ODEX 文件中选取最合适的产物启动 APP;
  5. 单独进程做 OPT,并实现合理的中断及恢复机制。

对于国内偏远地区,尤其对于海外许多发展中国家,Android 低版本机型仍然占比较高。目前 BoostMultiDex 方案在抖音和 TikTok 已经全量上线,这会使得这部分低版本 Android 用户直接受益,极大优化升级和安装启动体验。我们后续将开源 BoostMultiDex 方案,以协助其他 APP 在低版本 Android 手机上改进性能体验。今后,各家对下沉市场有需要的 APP,都能直接使用 BoostMultiDex 方案,立即获得飞一般的升级安装体验!这也是我们为改善 Android 生态贡献的一小份力,后续很快就会发布开源地址,敬请期待!

【此文章转载于微信公众号  作者金科优源汇 

【如有意向认证本平台签约作者 可扫描二维码或添加微信联系:baozhilin76】

【如有侵权,请联系删除】



2020-03-19 19:18:48

已有人点赞

已有人收藏
评论

登录

后参与讨论
暂无评论
关注作者
CopyRight © 2020 baozhinin.com inc. Powered By 宝知林 / 闽ICP备17009271号-1