找回密码
 会员注册
查看: 25|回复: 0

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

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
73539
发表于 2024-10-1 12:04:41 | 显示全部楼层 |阅读模式
我们知道,Android 低版本(4.X 及以下,SDK 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(""); // Needs to be free()able. addToDexFileTable(pDexOrJar); RETURN_PTR(pDexOrJar);}这个方法可以做到对原始 DEX 文件做加载,而不依赖 ODEX 文件,它其实就做了这么几件事:接受一个byte[]参数,也就是原始 DEX 文件的字节码。调用dvmRawDexFileOpenArray函数来处理byte[],生成RawDexFile对象由RawDexFile对象生成一个DexOrJar,通过addToDexFileTable添加到虚拟机内部,这样后续就可以正常使用它了返回这个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(func->fnPtr); } func++; }捋清步骤小结一下,绕过 ODEX 直接加载 DEX 的方案,主要有以下步骤:从 APK 中解压获取原始 Secondary DEX 文件的字节码通过 dlsym 获取dvm_dalvik_system_DexFile数组在数组中查询得到Dalvik_dalvik_system_DexFile_openDexFile_bytearray函数调用该函数,逐个传入之前从 APK 获取的 DEX 字节码,完成 DEX 加载,得到合法的DexFile对象把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.(TypeToken.java:62) at com.google.gson.Gson$1.(Gson.java:112) at com.google.gson.Gson.(Gson.java:112)... ...可以看到,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, "", "([B)V"), bytes));dexOrJar->pRawDexFile->pDvmDex->dex_object = dex_object;这样设置进去之后,果然不再出现 getDex 异常了。小结至此,无需等待 ODEX 优化的直接 DEX 加载方案已经完全打通,APP 的首次启动时间由此可以大幅减少。我们距离最终的极致完整解决方案还有一小段路,然而,正是这一小段路,才最为艰险严峻。更大的挑战还在后面,我们将在下一篇文章为大家细细分解,同时也会详细展示最终方案带来的收益情况。大家也可以先思考一下这里还有哪些问题没有考虑到。抖音/TikTok Android 基础技术团队是一个追求极致的深度技术团队,目前上海、北京、深圳、杭州都有大量人才需要,欢迎各位同学前来与我们共同建设亿级用户全球化 APP!可以点击阅读原文,进入字节跳动招聘官网查询抖音 Android 相关职位,也可以联系xiaolin.gan@bytedance.com 咨询相关信息或者直接发送简历内推!敬请期待,抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(二)。欢迎关注「字节跳动技术团队」 点击阅读原文,快来加入我们吧!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-13 07:28 , Processed in 0.801092 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表