void ensureChromiumStartedLocked(boolean onMainThread) {
//省略其他逻辑
// We must post to the UI thread to cover the case that the user has invoked Chromium
// startup by using the (thread-safe) CookieManager rather than creating a WebView.
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
synchronized (mLock) {
startChromiumLocked();
}
}
});
while (!mStarted) {
try {
// Important: wait() releases |mLock| the UI thread can take it :-)
mLock.wait();
} catch (InterruptedException e) {
}
}
}
问题定位
从业务角度优化我们首先需要找到业务的使用点,虽然我们通过分析代码定位到耗时消息是 Webview 相关的,但是我们仍然无法定位到最终的调用点。要定位最终的调用点,我们需要对WebView 相关调用流程有所了解。系统的 WebView 是一个独立的 App,其他应用对于 Webview 的使用都需要经过一个叫 WebViewFactory 的 framework 类,在这个类中首先会通过 Webview 的包名获取到 Webview 应用的 Context,然后通过获取到的 Context 获得 Webview 应用的 Classloader,最后通过 ClassLoader 去加载 Webview 的相关 so,反射加载 Webview 中的 WebViewFactoryProvider 的实现类并进行实例化,后续对于 WebiView 的相关调用都是通过 WebViewFactoryProvider 接口进行的。
通过后续分析发现对于 WebViewFactoryProvider 接口的 getStatics、 getGeolocationPermission、createWebView 等多个方法的首次调用都会触发 WebViewChromiumAwInit 的 ensureChromiumStartedLocked 往主线程 post 一个耗时消息,因此我们的问题就变成了对于WebViewFactoryProvider 相关方法的调用定位。
一种定位办法就是通过插桩的方式实现,由于 WebViewFactoryProvider 并不是应用能够直接访问到的类,因此我们对于 WebViewFactoryProvider 的调用必然是通过调用 framework 其他代码实现的,这种情况下我们需要去分析 framework 中所有对于 WebViewFactoryProvider 的调用点,然后把应用中所有对于这些调用点的调用都进行插桩,进行日志输出以进行定位。很显然这种方式成本是比较高的,比较容易出现漏掉的情况。
事实上对于 WebViewFactoryProvider 的情况我们可以采用一个更便捷的方式。在前面的分析中我们知道 WebViewFactoryProvider 是一个接口,我们是通过反射的方式获得其在 Webview 应用中实现的方式获得的,因此我们完全可以通过动态代理方式生成一个 WebViewFactoryProvider 对象,去替换 WebViewFactory 中的 WebViewFactoryProvider,在生成的 WebViewFactoryProvider 类的 invoke 方法中通过方法名过滤,对于我们的白名单方法输出其调用栈。通过这样的方式我们最终定位到触发主线程耗时逻辑的是我们的 WebView UA 的获取。

解决方案
确认到我们的耗时是由获取 WebView UA 引起的,我们可以采用本地缓存的方式解决:考虑到 WebView UA 记录的是 Webview 的版本等信息,其在绝大部分情况下是不会发生变化的,因此我们完全可以把 Webview UA 缓存在本地,后续直接从本地进行读取,并且在每次应用切到后台时,去获取一次 WebView UA 更新到本地缓存,以避免造成使用过程中的卡顿。
缓存的方案在 Webview 升级等造成 Webview UA 发生变化的情况下可能会出现更新不及时的情况,如果对 WebView 的实时性要求非常高,我们也可以通过调用子进程 ContentProvider 的方式在子进程去获取 WebView UA,这样虽然会影响到子进程的的主线程但是不会影响到我们的前台进程。当然这种方式由于需要启动一个子进程同时需要走完整的 Webview UA 读取,相对本地缓存的方式在读取速度方面是有明显的劣势的,对于一些对读取速度有要求的场景是不太适合的,我们可以根据实际需要采用相应的方案。
2. 后台任务优化前面的案例基本都是主线程相关耗时的优化,事实上除了主线程直接的耗时,后台任务的耗时也是会影响到我们的启动速度的,因为它们会抢占我们前台任务的 cpu、io 等资源,导致前台任务的执行时间变长,因此我们在优化前台耗时的同时也需要优化我们的后台任务。一般来说后台任务的优化与具体的业务有很强的关联性,不过我们也可以整理出来一些共性的优化原则:
- 减少后台线程不必要的任务的执行,特别是一些重 CPU、IO 的任务;
- 对启动阶段线程数进行收敛,防止过多的并发任务抢占主线程资源,同时也可以避免频繁的线程间调度降低并发效率。
除了这些通用的原则,这里也介绍两个抖音中比较典型的后台任务优化的案例。
2.1 进程启动优化我们优化过程中除了需要关注当前进程后台线程的运行情况,也需要关注后台进程的运行情况。目前绝大部分应用都会有 push 功能,为了减少后台耗电、避免因为占用过多内存导致进程被*,一般情况下会把 push 相关功能放在独立的进程。如果在启动阶段去启动 push 进程,其也会对我们的启动速度造成比较大的影响,我们尽量对 push 进程的启动去进行适当延迟,避免在启动阶段启动。
在线下情况下我们可以通过对 logcat 中“Start proc”等关键字进行过滤,去发现是否存在启动阶段启动子进程的情况,以及获得触发子进程启动的组件信息。对于一些复杂的工程或者是三方 sdk,我们即使知道了启动进程的组件,也比较难定位到具体的启动逻辑,我们可以通过对 startService、bindService 等启动Service、Recevier、ContentProvider组件调用进行插桩,输入调用堆栈的方式,结合“Start proc”中组件的去精准定位我们的触发点。除了在 manifest 中生命的进程可能还存在一些 fork 出 native 进程的情况,这种进程我们可以通过adb shell ps的方式去进行发现。

后台任务影响启动速度中还有还有另一个比较典型的 case 就是 GC,触发 GC 后可能会抢占我们的 cpu 资源甚至导致我们的线程被挂起,如果启动过程中存在大量的 GC,那么我们的启动速度将会受到比较大的影响。
解决这个问题的一个方法就是减少我们启动阶段代码的执行,减少内存资源的申请与占用,这个方案需要我们去改造我们的代码实现,是解决 gc 影响启动速度的最根本办法。同时我们也可以通过 GC 抑制的通用办法去减少 GC 对启动速度的影响,具体来说就是在启动阶段去抑制部分类型的 GC,以达到减少 GC 的目的。
近期公司的 Client Infrastructure-App Health 团队调研出了 ART 虚拟机上的 GC 抑制方案,在公司的部分产品上尝试对应用的启动速度有不错的优化效果,详细的技术细节在后续打磨完成后将会在“字节跳动终端技术”公众号分享出来。
3. 全局优化前面介绍的案例基本都是针对某个阶段一些比较耗时点的优化,实际上我们还存在一些单次耗时不那么明显,但是频率很高可能会影响到全局的点,比如我们业务中的高频函数、比如我们的类加载、方法执行效率等,这里我们将对抖音在这些方面的优化尝试做一些介绍。
3.1 类加载优化3.1.1 ClassLoader 优化首先我们来看一下抖音在类加载方面的一个优化案例。谈到类加载我们就离不开类加载的双亲委派机制,我们简单回顾一下这种机制下的类加载过程:
- 首先从已加载类中查找,如果能够找到则直接返回,找不到则调用 parent classloader 的 loadClass 进行查找;
- 如果 parent clasloader 能找到相关类则直接返回,否则调用 findClass 去进行类加载;
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
c = findClass(name);
}
}
return c;
}
Android 中的 ClassLoader
双亲委派机制中很重要的一个点就是 ClassLoader 的父子关系,我们再来看一下 Android 中 ClassLoader 情况。一般情况下 Android 中有两个 ClassLoader,分别是 BootClassLoader 和 PathClassLoader,BootClassLoaderart 负责加载 android sdk 的类,像我们的 Activity、TextView 等都由 BootClassLoader 加载。PathClassLoader 则负责加载 App 中的类,比如我们的自定义的 Activity、support 包中的 FragmentActivity 这些会被打进 app 中的类则由 PathClassLoader 进行加载。BootClassLoader 是 PathClassLoader 的 parent。
ART 虚拟机对类加载的优化
ART 虚拟机在类加载方面仍然遵循双亲委派的原则,不过在实现上做了一定的优化。一般情况下它的大致流程如下:
- 首先调用 PathClassLoader 的 findLoadedClass 方法去查找已加载的类中查找,这个方法将会通过 jni 调用到 ClassLinker 的 LookupClass 方法,如果能够找到则直接返回;
- 在已加载类中找不到的情况下,不会立刻返回到 java 层,其会在 native 层去调用 ClassLinker 的 FindClassInBaseDexClasLoader 进行类查找;
- 在 FindClassInBaseDexClasLoader 中,首先会去判断当前 ClassLoader 是否为 BootClassLoader,如果为 BootClasLoader 则尝试从当前 ClassLoader 的已加载类中查找,如果能够找到则直接返回,如果找不到则尝试使用当前 ClassLodaer 进行加载,无论能否加载到都返回;
- 如果当前 ClassLoader 不是 BootClassLoader,则会判断是否为 PathClasLoader,如果不是 PathClassLoader 则直接返回;
- 如果当前 ClassLoader 为 PathClassLoader,则会去判断当前 PathClassLoader 是否存在 parent,如果存在 parent 则将 parent 传入递归调用 FindClassInBaseDexClasLoader 方法,如果能够找到则直接返回;如果找不到或者当前 PathClassLoader 没有 parent 则直接在 native 层通过 DexFile 直接进行类加载。

可以看到当 PathClassLoader 到 BootClassLoader 的 ClassLoadeer 链路上只有 PathClassLoader 时,java 层的 findLoadedClass 方法调用后,并不止如其字面含义的去已加载的类中查找,其还会在 native 层直接通过 DexFile 去加载类,这种方式相对于回到 java 层调用 findClass 再调回 native 层通过 DexFile 加载可以减少一次不必要的 jni 调用,在运行效率上是更高的,这是 art 虚拟机对类加载效率的一个优化。
抖音中 ClassLoader 模型
在前面我们介绍了 Android 中的类加载相关机制,那么我们究竟在类加载方面做了哪些优化,要解答这个问题我们需要了解一下抖音中的ClassLoader 模型。在抖音中为了减少包体积,一些非核心功能我们通过插件化的方式进行了动态下发。在接入插件化框架后抖音中的 ClassLoader 模型如下:
- 除了原有的 BootClassLoader 和 PathClassLoader 另外引入了 DelegateClassLoader 和 PluginClasLoader;
- DelegateClassloader 全局 1 个,它是 PathClassLoader 的 parent,它的 parent 为 BootClassLoader;
- PluginClassLoader 每个插件一个,它的 parent 为 BootClassLoader;
- DelegateClassLoader 会持有 PluginClassLoader 的引用,PluginClassLoader 则会持有 PathClasloader 的引用;
