众所周知,安卓版本较低的设备(4。x及以下版本,SDK 21)使用Java运行环境Dalvik。
虚拟机。与更高版本相比,最大的问题是安装或升级更新后第一次冷启动需要很长时间。往往需要几十秒甚至几分钟,用户要面对黑屏才能熬过这段时间才能正常使用。
APP .
这将极大地影响用户的体验。我们也可以从网上的数据发现,安卓4。X
而后面的车型,它的新用户也占了一定的比例,但是留存用户的数量却比新用户少很多。特别是海外,比如东南亚、拉美,还是有大量低端机的。4.X
尽管以下低版本的用户相对较少,即使像抖音和抖音这样拥有数十亿用户的应用比例
10%,这个数字是几千万。所以要想打通下沉市场,这部分用户的使用和升级体验永远不能忽视。
这个问题的根本原因是MultiDex第一次安装或升级的时间太长。为了解决这个问题,我们探索了Dalvik虚拟机的底层系统机制。
对DEX相关处理逻辑进行了重新设计,最终引入了BoostMultiDex方案,可以将黑屏等待时间减少80%以上,节省了安卓的低版本。
用户的升级安装体验。
首先,我们简单看一下安装后第一次冷启动时DEX加载时间的对比数据:
可以看到,原来的MultiDEX解决方案实际上花了半分多钟才加载完DEX,而BoostMultiDex解决方案只用了5分钟。
几秒钟之内。优化效果极其显著!
接下来,我们将详细讲解整个BoostMultiDex解决方案的研发过程和解决方案。
#因为
我们先来看看这个问题的根源。这有几个原因。
首先,应该明确的是,如果要访问Java中的类,必须通过ClassLoader加载它们才能访问。在安卓上,APP
里面的类都是由PathClassLoader加载的。类被附加到DEX文件并存在。只有加载了相应的DEX,才能使用其中的类。
安卓早期针对DEX的指令格式设计并不完善,单个DEX文件中引用的Java方法总数不能超过65536个。
对于目前的APP来说,只要有更多的功能逻辑,就很容易达到这个极限。
这样,如果一个APP的Java代码中的方法数量超过65,536,那么这个APP的代码就不能被一个DEX访问。
文件已完全加载,因此我们必须在编译过程中生成多个DEX文件。从解锁Tik Tok的APK可以看出,它确实包含许多DEX文件:
8035972 00-00-1980 00:00类. dex 8476188 00-00-1980 00:00类. dex 7882916 00-00-1980 00:00类. dex 3 . dex 9041240 00-00-1980 00: 000类. dex 4 . 50003: 000类. 0000333:00
安卓4.4及以下版本使用Dalvik虚拟机。一般情况下,Dalvik虚拟机只能执行OPT优化的DEX文件,这也是我们常说的。
ODEX档案。
当安装了APK时,它的classes.dex会自动进行ODEX优化,默认情况下会在启动时直接加载到APP中。
PathClassLoader,所以classes.dex中的类肯定是可以直接访问的,我们不用担心。
其他DEX文件,即类2。dex、class 3 . DEX、class 4 . DEX和其他DEX文件(这里我们统称为
二级DEX文件),这些文件需要我们自己进行优化,并加载到ClassLoader中。
为了正常使用这些类。否则,当访问这些类时,将引发一个ClassNotFound异常,导致崩溃。
于是,安卓正式推出了MultiDex解决方案。只需要在应用程序中
程序执行的最早入口,即Application.attachBaseContext,被直接转移到MultiDex.install,它将解锁APK。
打包,进行oDEX优化并加载第二个和以后的DEX文件。这样,APK用多个DEX文件就可以顺利进行了
利执行下去了。这个操作会在 APP 安装或者更新后首次冷启动的时候发生,正是由于这个过程耗时漫长,才导致了我们最开始提到的耗时黑屏问题。
# 原始实现
了解了这个背景之后,我们再来看 MultiDex 的实现,逻辑就比较清晰了。
首先,APK 里面的所有classes2.dex、classes3.dex、classes4.dex等 DEX 文件都会被解压出来。
然后,对每个 dex 进行 ZIP 压缩。生成 classesN.zip 文件。
接着,对每个 ZIP 文件做 ODEX 优化,生成 classesN.zip.odex 文件。
具体而言,我们可以看到 APP 的 code_cache 目录下有这些文件:
com.bytedance.app.boost_multidex-1.apk.classes2.dexcom.bytedance.app.boost_multidex-1.apk.classes2.zipcom.bytedance.app.boost_multidex-1.apk.classes3.dexcom.bytedance.app.boost_multidex-1.apk.classes3.zipcom.bytedance.app.boost_multidex-1.apk.classes4.dexcom.bytedance.app.boost_multidex-1.apk.classes4.zip
这一步是通过DexFile.loadDex方法实现的,只需要指定原始 ZIP 文件和 ODEX 文件的路径,就能够根据 ZIP 中的 DEX 生成相应的
ODEX 产物,这个方法会最终返回一个DexFile对象。
最后,APP 把这些DexFile对象都添加到PathClassLoader的pathList里面,就可以让 APP
在运行期间,通过ClassLoader加载使用到这些 DEX 中的类。
在这整个过程中,生成 ZIP 和 ODEX 文件的过程都是比较耗时的,如果一个 APP 中有很多个 Secondary DEX
文件,就会加剧这一问题。尤其是生成 ODEX 的过程,Dalvik 虚拟机会把 DEX 格式的文件进行遍历扫描和优化重写处理,从而转换为 ODEX
文件,这就是其中最大的耗时瓶颈。
# 普遍采用的优化方式
目前业界已经有了一些对 MultiDex 进行优化的方法,我们先来看下大家通常是怎么优化这一过程的。
# 异步化加载
把启动阶段要使用的类尽可能多地打包到主 Dex 里面,尽量多地不依赖 Secondary DEX
来跑业务代码。然后异步调用MultiDex.install,而在后续某个时间点需要用到 Secondary DEX 的时候,如果 MultiDex
还没执行完,就停下来同步等待它完成再继续执行后续的代码。
这样确实可以在 install
的同时往下执行部分代码,而不至于被完全堵住。然而要做到这点,必须首先梳理好启动逻辑的代码,明确知道哪些是可以并行执行的。另外,由于主 Dex
能放的代码本身就比较有限,业务在启动阶段如果有太多依赖,就不能完全放入主 Dex 里面,因此就需要合理地剥离依赖。
因此现实情况下这个方案效果比较有限,如果启动阶段牵扯了太多业务逻辑,很可能并行执行不了太多代码,就很快又被 install 堵住了。
# 模块懒加载
这个方案最早见于美团的文章,可以说是前一个方案的升级版。
它也是做异步 DEX 加载,不过不同之处在于,在编译期间就需要对 DEX 按模块进行拆分。
一般是把一级界面的 Activity、Service、Receiver、Provider 涉及到的代码都放到第一个 DEX 中,而把二级、三级页面的
Activity 以及非高频界面的代码放到了 Secondary DEX 中。
当后面需要执行某个模块的时候,先判断这个模块的 Class 是否已经加载完成,如果没有完成,就等待 install 完成后再继续执行。
可见,这个方案对业务的改造程度相当巨大,而且已经有了一些插件化框架的雏形。另外,想要做到能对模块的 Class 的加载情况进行判断,还得通过反射
ActivityThread 注入自己的 Instrumentation,在执行 Activity 之前插入自己的判断逻辑。这也会相应地引入机型兼容性问题。
# 多线程加载
原生的 MultiDex 是顺序依次对每个 DEX 文件做 ODEX 优化的。而多线程的思路是,把每个 DEX 分别用各自线程做 OPT。
这么乍看起来,似乎是能够并行地做 ODEX 来起到优化效果。然而我们项目中一共有 6 个 Secondary DEX
文件,实测发现,这种方式几乎没有优化效果。原因可能是 ODEX 本身其实是重度 I/O 类型的操作,对于并发而言,多个线程同时进行 I/O
操作并不能带来明显收益,并且多线程切换本身也会带来一定损耗。
# 后台进程加载
这个方案主要是防止主进程做 ODEX 太久导致 ANR。当点击 APP 的时候,先单独启动了一个非主进程来先做 ODEX,等非主进程做完 ODEX
后再叫起主进程,这样主进程起来直接取得做好的 ODEX 就可以直接执行。不过,这只是规避了主进程 ANR 的问题,第一次启动的整体等待时间并没有减少。
# 一个更彻底的优化方案
上述几个方案,在各个层面都尝试做了优化,然而仔细分析便会发现,它们都没有触及这个问题中根本,也就是就MultiDex.install操作本身。
MultiDex.install生成 ODEX 文件的过程,调用的方法是DexFile.loadDex,它会启动一个 dexopt 进程对输入的 DEX
文件进行 ODEX 转化。那么,这个 ODEX 优化的时间是否可以避免呢?
我们的 BoostMultiDex 方案,正是从这一点入手,从本质上优化 install 的耗时。
我们的做法是,在第一次启动的时候,直接加载没有经过 OPT 优化的原始 DEX,先使得 APP 能够正常启动。然后在后台启动一个单独进程,慢慢地做完 DEX
的 OPT 工作,尽可能避免影响到前台 APP 的正常使用。
# 突破口
这里的难点,自然是――如何做到可以直接加载原始 DEX,避免 ODEX 优化带来的耗时阻塞。
如果要避免 ODEX 优化,又想要 APP 能够正常运行,就意味着 Dalvik 虚拟机需要直接执行没有做过 OPT 的、原始的 DEX
文件。虚拟机是否支持直接执行 DEX 文件呢?毕竟 Dalvik 虚拟机是可以直接执行原始 DEX 字节码的,ODEX 相比 DEX
只是做了一些额外的分析优化。因此即使 DEX 不通过优化,理论上应该是可以正常执行的。
功夫不负有心人,经过我们的一番挖掘,在系统的 dalvik 源码里面果然找到了这一隐藏入口:
/* * private static int openDexFile(byte[] fileContents) throws IOException * * Open a DEX file represented in a byte[], returning a pointer to our * internal data structure. * * The system will only perform "essential" optimizations on the given file. * */static void Dalvik_dalvik_system_DexFile_openDexFile_bytearray(const u4* args, JValue* pResult){ ArrayObject* fileContentsObj = (ArrayObject*) args[0]; u4 length; u1* pBytes; RawDexFile* pRawDexFile; DexOrJar* pDexOrJar = NULL; if (fileContentsObj == NULL) { dvmThrowNullPointerException("fileContents == null"); RETURN_VOID(); } /* TODO: Avoid making a copy of the array. (note array *is* modified) */ length = fileContentsObj->length; pBytes = (u1*) malloc(length); if (pBytes == NULL) { dvmThrowRuntimeException("unable to allocate DEX memory"); RETURN_VOID(); } memcpy(pBytes, fileContentsObj->contents, length); if (dvmRawDexFileOpenArray(pBytes, length, &pRawDexFile;) != 0) { ALOGV("Unable to open in-memory DEX file"); free(pBytes); dvmThrowRuntimeException("unable to open in-memory DEX file"); RETURN_VOID(); } ALOGV("Opening in-memory DEX"); pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar)); pDexOrJar->isDex = true; pDexOrJar->pRawDexFile = pRawDexFile; pDexOrJar->pDexMemory = pBytes; pDexOrJar->fileName = strdup("
这个方法可以做到对原始 DEX 文件做加载,而不依赖 ODEX 文件,它其实就做了这么几件事:
1. 接受一个byte[]参数,也就是原始 DEX 文件的字节码。
2. 调用dvmRawDexFileOpenArray函数来处理byte[],生成RawDexFile对象
3. 由RawDexFile对象生成一个DexOrJar,通过addToDexFileTable添加到虚拟机内部,这样后续就可以正常使用它了
4. 返回这个DexOrJar的地址给上层,让上层用它作为 cookie 来构造一个合法的DexFile对象
这样,上层在取得所有 Seconary DEX 的DexFile对象后,调用 makeDexElements 插入到 ClassLoader 里面,就完成
install 操作了。如此一来,我们就能完美地避过 ODEX 优化,让 APP 正常执行下去了。
# 寻找入口
看起来似乎很顺利,然而在我们却遇到了一个意外状况。
我们从Dalvik_dalvik_system_DexFile_openDexFile_bytearray这个函数的名字可以明显看出,这是一个 JNI
方法,从 4.0 到 4.3 版本都能找到它的 Java 原型:
/* * Open a DEX file based on a {@code byte[]}. The value returned * is a magic VM cookie. On failure, a RuntimeException is thrown. */native private static int openDexFile(byte[] fileContents);
然而我们在 4.4 版本上,Java 层它并没有对应的 native 方法。这样我们便无法直接在上层调用了。
当然,我们很容易想到,可以用 dlsym
来直接搜寻这个函数的符号来调用。但是可惜的是,Dalvik_dalvik_system_DexFile_openDexFile_bytearray这个方法是static的,因此它并没有被导出。我们实际去解析libdvm.so的时候,也确实没有找到Dalvik_dalvik_system_DexFile_openDexFile_bytearray这个符号。
不过,由于它是 JNI 函数,也是通过正常方式注册到虚拟机里面的。因此,我们可以找到它对应的函数注册表:
const DalvikNativeMethod dvm_dalvik_system_DexFile[] = { { "openDexFileNative", "(Ljava/lang/String;Ljava/lang/String;I)I", Dalvik_dalvik_system_DexFile_openDexFileNative }, { "openDexFile", "([B)I", Dalvik_dalvik_system_DexFile_openDexFile_bytearray }, { "closeDexFile", "(I)V", Dalvik_dalvik_system_DexFile_closeDexFile }, { "defineClassNative", "(Ljava/lang/String;Ljava/lang/ClassLoader;I)Ljava/lang/Class;", Dalvik_dalvik_system_DexFile_defineClassNative }, { "getClassNameList", "(I)[Ljava/lang/String;", Dalvik_dalvik_system_DexFile_getClassNameList }, { "isDexOptNeeded", "(Ljava/lang/String;)Z", Dalvik_dalvik_system_DexFile_isDexOptNeeded }, { NULL, NULL, NULL },};
dvm_dalvik_system_DexFile这个数组需要被虚拟机在运行时动态地注册进去,因此,这个符号是一定会被导出的。
这么一来,我们也就可以通过 dlsym
取得这个数组,按照逐个元素字符串匹配的方式来搜寻openDexFile对应的Dalvik_dalvik_system_DexFile_openDexFile_bytearray方法了。
具体代码实现如下:
const char *name = "openDexFile"; JNINativeMethod* func = (JNINativeMethod*) dlsym(handler, "dvm_dalvik_system_DexFile");; size_t len_name = strlen(name); while (func->name != nullptr) { if ((strncmp(name, func->name, len_name) == 0) && (strncmp("([B)I", func->signature, len_name) == 0)) { return reinterpret_cast
# 捋清步骤
小结一下,绕过 ODEX 直接加载 DEX 的方案,主要有以下步骤:
1. 从 APK 中解压获取原始 Secondary DEX 文件的字节码
2. 通过 dlsym 获取dvm_dalvik_system_DexFile数组
3. 在数组中查询得到Dalvik_dalvik_system_DexFile_openDexFile_bytearray函数
4. 调用该函数,逐个传入之前从 APK 获取的 DEX 字节码,完成 DEX 加载,得到合法的DexFile对象
5. 把DexFile对象都添加到 APP 的PathClassLoader的 pathList 里
完成了上述几步操作,我们就可以正常访问到 Secondary DEX 里面的类了
# getDex 问题
然而,正当我们顺利注入原始 DEX 往下执行的时候,却在 4.4 的机型上马上遇到了一个必现的崩溃:
JNI WARNING: JNI function NewGlobalRef called with exception pending in Ljava/lang/Class;.getDex:()Lcom/android/dex/Dex; (NewGlobalRef)Pending exception is:java.lang.IndexOutOfBoundsException: index=0, limit=0 at java.nio.Buffer.checkIndex(Buffer.java:156) at java.nio.DirectByteBuffer.get(DirectByteBuffer.java:157) at com.android.dex.Dex.create(Dex.java:129) at java.lang.Class.getDex(Native Method) at libcore.reflect.AnnotationAccess.getSignature(AnnotationAccess.java:447) at java.lang.Class.getGenericSuperclass(Class.java:824) at com.google.gson.reflect.TypeToken.getSuperclassTypeParameter(TypeToken.java:82) at com.google.gson.reflect.TypeToken.
可以看到,Gson 里面使用到了Class.getGenericSuperclass方法,而它最终调用了Class.getDex,它是一个 native
方法,对应实现如下:
JNIEXPORT jobject JNICALL Java_java_lang_Class_getDex(JNIEnv* env, jclass javaClass) { Thread* self = dvmThreadSelf(); ClassObject* c = (ClassObject*) dvmDecodeIndirectRef(self, javaClass); DvmDex* dvm_dex = c->pDvmDex; if (dvm_dex == NULL) { return NULL; } // Already cached? if (dvm_dex->dex_object != NULL) { return dvm_dex->dex_object; } jobject byte_buffer = env->NewDirectByteBuffer(dvm_dex->memMap.addr, dvm_dex->memMap.length); if (byte_buffer == NULL) { return NULL; } jclass com_android_dex_Dex = env->FindClass("com/android/dex/Dex"); if (com_android_dex_Dex == NULL) { return NULL; } jmethodID com_android_dex_Dex_create = env->GetStaticMethodID(com_android_dex_Dex, "create", "(Ljava/nio/ByteBuffer;)Lcom/android/dex/Dex;"); if (com_android_dex_Dex_create == NULL) { return NULL; } jvalue args[1]; args[0].l = byte_buffer; jobject local_ref = env->CallStaticObjectMethodA(com_android_dex_Dex, com_android_dex_Dex_create, args); if (local_ref == NULL) { return NULL; } // Check another thread didn't cache an object, if we've won install the object. ScopedPthreadMutexLock lock(&dvm;_dex->modLock); if (dvm_dex->dex_object == NULL) { dvm_dex->dex_object = env->NewGlobalRef(local_ref); } return dvm_dex->dex_object;}
结合堆栈和代码来看,崩溃的点是在 JNI 里面执行com.android.dex.Dex.create的时候:
jobject local_ref = env->CallStaticObjectMethodA(com_android_dex_Dex, com_android_dex_Dex_create, args);
由于是 JNI 方法,这个调用发生异常后如果没有 check,在后续执行到env->NewGlobalRef调用的时候会检查到前面发生了异常,从而抛出。
而com.android.dex.Dex.create之所以会执行失败,主要原因是入参有问题,这里的参数是dvm_dex->memMap取到的一块 map
内存。dvm_dex 是从这个 Class 里面取得的。虚拟机代码里面,每个 Class 对应是结构是ClassObject中,其中有这个字段:
struct ClassObject : Object {... ... /* DexFile from which we came; needed to resolve constant pool entries */ /* (will be NULL for VM-generated, e.g. arrays and primitive classes) */ DvmDex* pDvmDex;... ...
这里的pDvmDex是在这里加载类的过程中赋值的:
static void Dalvik_dalvik_system_DexFile_defineClassNative(const u4* args, JValue* pResult){... ... if (pDexOrJar->isDex) pDvmDex = dvmGetRawDexFileDex(pDexOrJar->pRawDexFile); else pDvmDex = dvmGetJarFileDex(pDexOrJar->pJarFile);... ...
pDvmDex是从dvmGetRawDexFileDex方法里面取得的,而这里的参数pDexOrJar->pRawDexFile正是我们前面openDexFile_bytearray里面创建的,pDexOrJar是之前返回给上层的
cookie。
再根据dvmGetRawDexFileDex:
INLINE DvmDex* dvmGetRawDexFileDex(RawDexFile* pRawDexFile) { return pRawDexFile->pDvmDex;}
可以最终推得,dvm_dex->memMap对应的正是openDexFile_bytearray时拿到的pDexOrJar->pRawDexFile->pDvmDex->memMap。我们在当初加载
DEX 字节数组的时候,是否遗漏了对memMap进行赋值呢?
我们通过分析代码,发现的确如此,memMap这个字段只在 ODEX 的情况下才会赋值:
/* * Given an open optimized DEX file, map it into read-only shared memory and * parse the contents. * * Returns nonzero on error. */int dvmDexFileOpenFromFd(int fd, DvmDex ppDvmDex){... ... // 构造memMap if (sysMapFileInShmemWritableReadOnly(fd, &memMap;) != 0) { ALOGE("Unable to map file"); goto bail; }... ... // 赋值memMap /* tuck this into the DexFile so it gets released later */ sysCopyMap(&pDvmDex-;>memMap, &memMap;);... ...}
而只加载 DEX 字节数组的情况下并不会走这个方法,因此也就没法对 memMap 进行赋值了。看来,Android
官方从一开始对openDexFile_bytearray就没支持好,系统代码里面也没有任何使用的地方,所以当我们强制使用这个方法的时候就会暴露出这个问题。
虽然这个是官方的坑,但我们既然需要使用,就得想办法填上。
再次分析Java_java_lang_Class_getDex方法,我们注意到了这段:
if (dvm_dex->dex_object != NULL) { return dvm_dex->dex_object; }
dvm_dex->dex_object如果非空,就会直接返回,不会再往下执行到取 memMap
的地方,因此就不会引发异常。这样,解决思路就很清晰了,我们在加载完 DEX 数组之后,立即自己生成一个dex_object对象,并注入pDvmDex里面。
详细代码如下:
jclass clazz = env->FindClass("com/android/dex/Dex");jobject dex_object = env->NewGlobalRef( env->NewObject(clazz), env->GetMethodID(clazz, "
这样设置进去之后,果然不再出现 getDex 异常了。
# 小结
至此,无需等待 ODEX 优化的直接 DEX 加载方案已经完全打通,APP 的首次启动时间由此可以大幅减少。
我们距离最终的极致完整解决方案还有一小段路,然而,正是这一小段路,才最为艰险严峻。更大的挑战还在后面,
我们将在下一篇文章为大家细细分解,同时也会详细展示最终方案带来的收益情况 。大家也可以先思考一下这里还有哪些问题没有考虑到。
抖音/TikTok Android
基础技术团队是一个追求极致的深度技术团队,目前上海、北京、深圳、杭州都有大量人才需要,欢迎各位同学前来与我们共同建设亿级用户全球化 APP!
可以点击阅读原文,进入字节跳动招聘官网查询抖音 Android 相关职位「链接」,也可以联系 xiaolin.gan@bytedance.com
咨询相关信息或者直接发送简历内推!
> 敬请期待,抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(二)。
欢迎关注字节跳动技术团队