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

咱们知道,Android 低版本(4.X 及如下,SDK < 21)的设备,采用的 Java 运行环境是 Dalvik 虚拟机。它相比于高版本,最大的问题就是在安装或者升级更新以后,首次冷启动的耗时漫长。这经常须要花费几十秒甚至几分钟,用户不得不面对一片黑屏,熬过这段时间才能正常使用 APP。java

这是很是影响用户的使用体验的。咱们从线上数据也能够发现,Android 4.X 及如下机型,其新增用户也占了必定的比例,但留存用户数相比新增则要少很是多。尤为在海外,像东南亚以及拉美等地区,还存有着很大量的低端机。4.X 如下低版本用户虽然比较少,但对于抖音及 TikTok 这样有着亿级规模的用户的 APP,即便占比 10%,数目也有上千万。所以若是想要打通下沉市场,这部分用户的使用和升级体验是绝对没法忽视的。android

这个问题的根本缘由就在于,安装或者升级后首次 MultiDex 花费的时间过于漫长。为了解决这个问题,咱们挖掘了 Dalvik 虚拟机的底层系统机制,对 DEX 相关处理逻辑进行了从新设计,最终推出了 BoostMultiDex 方案,它可以减小 80%以上的黑屏等待时间,挽救低版本 Android 用户的升级安装体验。c++

咱们先来简单看一个安装后首次冷启动加载 DEX 时间的对比数据:es6

Android 版本 厂商 机型 原始 MultiDex 耗时(s) BoostMultiDex 耗时(s)
4.4.2 LG LGMS323 33.545 5.014
4.3.0 Samsung SGH-T999 30.331 3.791
4.2.1 HUAWEI G610-U00 36.465 4.981
4.1.2 Samsung I9100 30.962 5.345

能够看到原始 MultiDex 方案居然花了半分钟以上才能完成 DEX 加载,而 BoostMultiDex 方案的时间仅须要 5 秒之内。优化效果极为显著!数组

接下来,咱们就来详细讲解整个 BoostMultiDex 方案的研发过程与解决思路。bash

原由

咱们先来看下致使这个问题的根本缘由。这里面是有多个缘由共同引发的。cookie

首先须要清楚的是,在 Java 里面想要访问一个类,必然是须要经过 ClassLoader 来加载它们才能访问到。在 Android 上,APP 里面的类都是由PathClassLoader负责加载的。而类都是依附于 DEX 文件而存在的,只有加载了相应的 DEX,才能对其中的类进行使用。多线程

Android 早期对于 DEX 的指令格式设计并不完善,单个 DEX 文件中引用的 Java 方法总数不能超过 65536 个。并发

对于如今的 APP 而言,只要功能逻辑多一些,很容易就会触达这个界限。app

这样,若是一个 APP 的 Java 代码的方法数超过了 65536 个,这个 APP 的代码就没法被一个 DEX 文件彻底装下,那么,咱们在编译期间就不得不生成多个 DEX 文件。咱们解开抖音的 APK 就能够看到,里面确实包含了不少个 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
复制代码

Android 4.4 及如下采用的是 Dalvik 虚拟机,在一般状况下,Dalvik 虚拟机只能执行作过 OPT 优化的 DEX 文件,也就是咱们常说的 ODEX 文件。

一个 APK 在安装的时候,其中的classes.dex会自动作 ODEX 优化,并在启动的时候由系统默认直接加载到 APP 的PathClassLoader里面,所以classes.dex中的类确定能直接访问,不须要咱们操心。

除它以外的 DEX 文件,也就是classes2.dexclasses3.dexclasses4.dex等 DEX 文件(这里咱们统称为 Secondary DEX 文件),这些文件都须要靠咱们本身进行 ODEX 优化,并加载到 ClassLoader 里,才能正常使用其中的类。不然在访问这些类的时候,就会抛出ClassNotFound异常从而引发崩溃。

所以,Android 官方推出了 MultiDex 方案。只须要在 APP 程序执行最先的入口,也就是Application.attachBaseContext里面直接调MultiDex.install,它会解开 APK 包,对第二个之后的 DEX 文件作 ODEX 优化并加载。这样,带有多个 DEX 文件的 APK 就能够顺利执行下去了。

这个操做会在 APP 安装或者更新后首次冷启动的时候发生,正是因为这个过程耗时漫长,才致使了咱们最开始提到的耗时黑屏问题。

原始实现

了解了这个背景以后,咱们再来看 MultiDex 的实现,逻辑就比较清晰了。

首先,APK 里面的全部classes2.dexclasses3.dexclasses4.dex等 DEX 文件都会被解压出来。

而后,对每一个 dex 进行 ZIP 压缩。生成 classesN.zip 文件。

接着,对每一个 ZIP 文件作 ODEX 优化,生成 classesN.zip.odex 文件。

具体而言,咱们能够看到 APP 的 code_cache 目录下有这些文件:

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对象。

最后,APP 把这些DexFile对象都添加到PathClassLoaderpathList里面,就可让 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("<memory>"); // Needs to be free()able.
    addToDexFileTable(pDexOrJar);

    RETURN_PTR(pDexOrJar);
}
复制代码

这个方法能够作到对原始 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<func_openDexFileBytes>(func->fnPtr);
        }
        func++;
    }
复制代码

捋清步骤

小结一下,绕过 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.<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;
}

复制代码

结合堆栈和代码来看,崩溃的点是在 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, "<init>", "([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%(二)。

欢迎关注字节跳动技术团队

相关文章
相关标签/搜索