其他
抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(一)
Editor's Note
低版本Andriod因CPU性能和系统版本原因,运行在系统上的程序通常需要做适配和降级处理,其中低版本安卓的冷启动耗时时间长十分突出,字节跳动技术团队深入研究起因,实现最优解决方案,实现APP首次启动时间减少80%。
The following article is from 字节跳动技术团队 Author 晓霖
起因
PathClassLoader
负责加载的。而类都是依附于 DEX 文件而存在的,只有加载了相应的 DEX,才能对其中的类进行使用。 8035972 00-00-1980 00:00 classes.dex
8476188 00-00-1980 00:00 classes2.dex
7882916 00-00-1980 00:00 classes3.dex
9041240 00-00-1980 00:00 classes4.dex
8646596 00-00-1980 00:00 classes5.dex
8644640 00-00-1980 00:00 classes6.dex
5888368 00-00-1980 00:00 classes7.dex
classes.dex
会自动做 ODEX 优化,并在启动的时候由系统默认直接加载到 APP 的PathClassLoader
里面,因此classes.dex
中的类肯定能直接访问,不需要我们操心。classes2.dex
、classes3.dex
、classes4.dex
等 DEX 文件(这里我们统称为 Secondary DEX 文件),这些文件都需要靠我们自己进行 ODEX 优化,并加载到 ClassLoader 里,才能正常使用其中的类。否则在访问这些类的时候,就会抛出ClassNotFound
异常从而引起崩溃。Application.attachBaseContext
里面直接调MultiDex.install
,它会解开 APK 包,对第二个以后的 DEX 文件做 ODEX 优化并加载。这样,带有多个 DEX 文件的 APK 就可以顺利执行下去了。原始实现
classes2.dex
、classes3.dex
、classes4.dex
等 DEX 文件都会被解压出来。com.bytedance.app.boost_multidex-1.apk.classes2.dex
com.bytedance.app.boost_multidex-1.apk.classes2.zip
com.bytedance.app.boost_multidex-1.apk.classes3.dex
com.bytedance.app.boost_multidex-1.apk.classes3.zip
com.bytedance.app.boost_multidex-1.apk.classes4.dex
com.bytedance.app.boost_multidex-1.apk.classes4.zip
DexFile.loadDex
方法实现的,只需要指定原始 ZIP 文件和 ODEX 文件的路径,就能够根据 ZIP 中的 DEX 生成相应的 ODEX 产物,这个方法会最终返回一个DexFile
对象。DexFile
对象都添加到PathClassLoader
的pathList
里面,就可以让 APP 在运行期间,通过ClassLoader
加载使用到这些 DEX 中的类。普遍采用的优化方式
异步化加载
MultiDex.install
,而在后续某个时间点需要用到 Secondary DEX 的时候,如果 MultiDex 还没执行完,就停下来同步等待它完成再继续执行后续的代码。模块懒加载
多线程加载
后台进程加载
一个更彻底的优化方案
MultiDex.install
操作本身。MultiDex.install
生成 ODEX 文件的过程,调用的方法是DexFile.loadDex
,它会启动一个 dexopt 进程对输入的 DEX 文件进行 ODEX 转化。那么,这个 ODEX 优化的时间是否可以避免呢?突破口
/*
* 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("<memory>"); // Needs to be free()able.
addToDexFileTable(pDexOrJar);
RETURN_PTR(pDexOrJar);
}
接受一个 byte[]
参数,也就是原始 DEX 文件的字节码。调用 dvmRawDexFileOpenArray
函数来处理byte[]
,生成RawDexFile
对象由 RawDexFile
对象生成一个DexOrJar
,通过addToDexFileTable
添加到虚拟机内部,这样后续就可以正常使用它了返回这个 DexOrJar
的地址给上层,让上层用它作为 cookie 来构造一个合法的DexFile
对象
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);
Dalvik_dalvik_system_DexFile_openDexFile_bytearray
这个方法是static
的,因此它并没有被导出。我们实际去解析libdvm.so
的时候,也确实没有找到Dalvik_dalvik_system_DexFile_openDexFile_bytearray
这个符号。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
这个数组需要被虚拟机在运行时动态地注册进去,因此,这个符号是一定会被导出的。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_openDexFileBytes>(func->fnPtr);
}
func++;
}
捋清步骤
从 APK 中解压获取原始 Secondary DEX 文件的字节码 通过 dlsym 获取 dvm_dalvik_system_DexFile
数组在数组中查询得到 Dalvik_dalvik_system_DexFile_openDexFile_bytearray
函数调用该函数,逐个传入之前从 APK 获取的 DEX 字节码,完成 DEX 加载,得到合法的 DexFile
对象把 DexFile
对象都添加到 APP 的PathClassLoader
的 pathList 里
getDex 问题
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.<init>(TypeToken.java:62)
at com.google.gson.Gson$1.<init>(Gson.java:112)
at com.google.gson.Gson.<clinit>(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;
}
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);
... ...
}
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, "<init>", "([B)V"),
bytes));
dexOrJar->pRawDexFile->pDvmDex->dex_object = dex_object;
小结
让我知道你在看