Android 启动优化案例-WebView非预期初始化排查

去年年底做启动优化时,有个比较好玩的 case 给大家分享下,希望大家能从我的分享里 get 到我在做一些问题排查修复时是怎么看上去又low又土又高效的。

1. 现象

在我们使用 Perfetto 进行app 启动过程性能观测时,在 UI 线程发现了一段 几十毫秒接近百毫秒 的非预期Webview初始化的耗时(机器环境:小米10 pro),在线上用户机器上这段代码执行时间可能会更长。

为什么说非预期呢:

  • 首页没有WebView的使用、预加载

  • X5内核的初始化也在启动流程之后

2. 顺藤摸瓜

一般当我们发现了这种问题后,我们应该如何应对呢?

  • 搞懂流程,如果在排查启动性能时,发现了不符合预期的主(子)线程耗时,第一步就是摸清楚这段耗时代码是怎么被调用的。

  • 见招拆招,当我们知道代码如何被调用的之后,就可以想办法进行修复工作,如果是因为项目代码在错误的时机被调用,那就延后或者去除相关调用

WebViewChromiumAwInit.java

那我们开始第一步,搞懂流程,我们能看到图中耗时代码块被调用的系统方法是:

WebViewChromiumAwInit.startChromiumLocked,由于 Perfetto 并看不到 App 相关的堆栈信息,所以我们无法直接知道到底是哪行代码引起的。

那我们就去跟跟 webview 源码,看看具体情况,点进 WebViewChromiumAwInit.java

页面看相关代码,发现 startChromiumLocked 是被 ensureChromiumStartedLocked 方法调用的:

scss 复制代码
// This method is not private only because the downstream subclass needs to access it,
// it shouldn't be accessed from anywhere else.
/* package */ 
void ensureChromiumStartedLocked(boolean fromThreadSafeFunction) {
        assert Thread.holdsLock(mLock);
        if (mInitState == INIT_FINISHED) { // Early-out for the common case.
            return;
        }
        if (mInitState == INIT_NOT_STARTED) {
            // If we're the first thread to enter ensureChromiumStartedLocked, we need to determine
            // which thread will be the UI thread; declare init has started so that no other thread
            // will try to do this.
            mInitState = INIT_STARTED;
            setChromiumUiThreadLocked(fromThreadSafeFunction);
        }
        if (ThreadUtils.runningOnUiThread()) {
            // If we are currently running on the UI thread then we must do init now. If there was
            // already a task posted to the UI thread from another thread to do it, it will just
            // no-op when it runs.
            startChromiumLocked();
            return;
        }
        mIsPostedFromBackgroundThread = true;
        // If we're not running on the UI thread (because init was triggered by a thread-safe
        // function), post init to the UI thread, since init is *not* thread-safe.
        AwThreadUtils.postToUiThreadLooper(new Runnable() {
            @Override
            public void run() {
                synchronized (mLock) {
                    startChromiumLocked();
                }
            }
        });
        // Wait for the UI thread to finish init.
        while (mInitState != INIT_FINISHED) {
            try {
                mLock.wait();
            } catch (InterruptedException e) {
                // Keep trying; we can't abort init as WebView APIs do not declare that they throw
                // InterruptedException.
            }
        }
    }

那么 ensureChromiumStartedLocked 方法又是被谁调用的呢?我们在WebViewChromiumAwInit.java 文件里大致找一下就能找到以下嫌疑人,第一反应是"这也太多了吧,这咋排查啊"

scss 复制代码
-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

public class WebViewChromiumAwInit {
    public AwTracingController getAwTracingController() {
        synchronized (mLock) {
            if (mAwTracingController == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mAwTracingController;
    }
    public AwProxyController getAwProxyController() {
        synchronized (mLock) {
            if (mAwProxyController == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mAwProxyController;
    }
    void startYourEngines(boolean fromThreadSafeFunction) {
        synchronized (mLock) {
            ensureChromiumStartedLocked(fromThreadSafeFunction);
        }
    }
    
    public SharedStatics getStatics() {
        synchronized (mLock) {
            if (mSharedStatics == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mSharedStatics;
    }

    public GeolocationPermissions getDefaultGeolocationPermissions() {
        synchronized (mLock) {
            if (mDefaultGeolocationPermissions == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mDefaultGeolocationPermissions;
    }

    public AwServiceWorkerController getDefaultServiceWorkerController() {
        synchronized (mLock) {
            if (mDefaultServiceWorkerController == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mDefaultServiceWorkerController;
    }
    public android.webkit.WebIconDatabase getWebIconDatabase() {
        synchronized (mLock) {
            ensureChromiumStartedLocked(true);
            if (mWebIconDatabase == null) {
                mWebIconDatabase = new WebIconDatabaseAdapter();
            }
        }
        return mWebIconDatabase;
    }

    public WebStorage getDefaultWebStorage() {
        synchronized (mLock) {
            if (mDefaultWebStorage == null) {
                ensureChromiumStartedLocked(true);
            }
        }
        return mDefaultWebStorage;
    }

    public WebViewDatabase getDefaultWebViewDatabase(final Context context) {
        synchronized (mLock) {
            ensureChromiumStartedLocked(true);
            if (mDefaultWebViewDatabase == null) {
                mDefaultWebViewDatabase = new WebViewDatabaseAdapter(mFactory,
                        HttpAuthDatabase.newInstance(context, HTTP_AUTH_DATABASE_FILE),
                        mDefaultBrowserContext);
            }
        }
        return mDefaultWebViewDatabase;
    }
}

WebViewChromiumFactoryProvider.java

经过上面对的简单分析,我们大概知道了WebViewChromiumAwInit.startChromiumLocked是被 ensureChromiumStartedLocked 方法调用的,而ensureChromiumStartedLocked 方法会被以下几个方法调用,那我们接下来的工作就需要找到下面这几个方法到底被谁调用了。

diff 复制代码
-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

到这里,分享我的一个土方法,我们要找到底哪个地方会调用这些方法,那就找一个不认识的、看上去不会被别人提起的方法,进行 google,我们一眼就选中了getDefaultServiceWorkerController 方法,没办法,谁叫我不认识你呢。虽然方法笨,但是架不住效率啊。于是乎,我们把它揪出来了 - WebViewChromiumFactoryProvider.java

我们大概了解一下 WebViewChromiumFactoryProvider 大概是个什么角色,WebViewChromiumFactoryProvider 实现了 WebViewFactoryProvider 接口,简单理解就是 WebView 的工厂,App 如果要创建 WebView,就会通过 WebViewFactoryProvider 接口的实现类进行 createWebView,所以其实就是个工厂模式。通过抽象规范 api,保证兼容性和可移植性可扩展性。

我们在这个文件中也如愿以偿的看到了上面列出来的几个方法的调用。WebViewChromiumFactoryProvider 在接口方法的实现中,调用了WebViewChromiumAwInit 里的一系列方法,如下:

typescript 复制代码
//WebViewChromiumFactoryProvider.java
@Override
public WebViewProvider createWebView(WebView webView, WebView.PrivateAccess privateAccess) {
    return new WebViewChromium(this, webView, privateAccess, mShouldDisableThreadChecking);
}

//我们截取一段
    @Override
    public GeolocationPermissions getGeolocationPermissions() {
        return mAwInit.getDefaultGeolocationPermissions();
    }
    @Override
    public CookieManager getCookieManager() {
        return mAwInit.getDefaultCookieManager();
    }
    @Override
    public ServiceWorkerController getServiceWorkerController() {
        synchronized (mAwInit.getLock()) {
            if (mServiceWorkerController == null) {
                mServiceWorkerController = new ServiceWorkerControllerAdapter(
                        mAwInit.getDefaultServiceWorkerController());
            }
        }
        return mServiceWorkerController;
    }
    @Override
    public TokenBindingService getTokenBindingService() {
        return null;
    }
    @Override
    public android.webkit.WebIconDatabase getWebIconDatabase() {
        return mAwInit.getWebIconDatabase();
    }
    @Override
    public WebStorage getWebStorage() {
        return mAwInit.getDefaultWebStorage();
    }
    @Override
    public WebViewDatabase getWebViewDatabase(final Context context) {
        return mAwInit.getDefaultWebViewDatabase(context);
    }
    WebViewDelegate getWebViewDelegate() {
        return mWebViewDelegate;
    }
    WebViewContentsClientAdapter createWebViewContentsClientAdapter(WebView webView,
            Context context) {
        try (ScopedSysTraceEvent e = ScopedSysTraceEvent.scoped(
                     "WebViewChromiumFactoryProvider.insideCreateWebViewContentsClientAdapter")) {
            return new WebViewContentsClientAdapter(webView, context, mWebViewDelegate);
        }
    }
    void startYourEngines(boolean onMainThread) {
        try (ScopedSysTraceEvent e1 = ScopedSysTraceEvent.scoped(
                     "WebViewChromiumFactoryProvider.startYourEngines")) {
            mAwInit.startYourEngines(onMainThread);
        }
    }
    boolean hasStarted() {
        return mAwInit.hasStarted();
    }

3. 确定问题

我们上面通过阅读 WebViewChromiumFactoryProvider.javaWebViewChromiumAwInit.java 这两个文件具体代码实现,有了一个比较清晰的思路。

App 在初始化过程中,调用到了 WebViewFactoryProvider 接口实现类的某个方法,这个方法调用了 WebViewChromiumAwInit 的下面方法中的其中一个或者多个。那其实问题就清晰了,我们只需要找到,我们 app 启动阶段到底哪行代码,会调用到 WebViewFactoryProvider 接口某个接口方法就行。

diff 复制代码
-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

由于 WebView 的代码并不会打包进 app 里,App 用的 WebView 内核都是用的 Android 系统负责内置、升级的 WebView 内核代码,所以通过 transform 的方式进行 hook 调用是不行的,这里我们采用动态代理的方式,对 WebViewFactoryProvider 接口方法进行 hook,我们通过动态代理生成一个 proxy 对象,通过反射的方式,替换掉 android.webkit.WebViewFactorysProviderInstance 对象。

java 复制代码
    ##WebViewFactory
    @SystemApi
    public final class WebViewFactory{
        //...
        @UnsupportedAppUsage
        private static WebViewFactoryProvider sProviderInstance;
        //...
    }
    
    
    ##动态代理
    try {
        Class clas = Class.forName("android.webkit.WebViewFactory");
        Method method = clas.getDeclaredMethod("getProvider");
        method.setAccessible(true);
        Object obj = method.invoke(null);

        Object hookService = Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getSuperclass().getInterfaces(),
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        Log.d("zttt", "hookService method: " + method.getName());
                        new RuntimeException(method.getName()).printStackTrace();
                        return method.invoke(obj, args);
                    }
                });

        Field field = clas.getDeclaredField("sProviderInstance");
        field.setAccessible(true);
        field.set(null, hookService);
    } catch (Exception e) {
        e.printStackTrace();
    }

替换掉 sProviderInstance 之后,我们就可以在我们的代理逻辑中,加上断点来进行调试,最终找到了造成 WebView非预期初始化的始作俑者:WebSettings.getDefaultUserAgent

4. 解决问题

事情到这里就好解决了,只需要对 WebSettings.getDefaultUserAgent 进行编译期的Hook,重定向到带缓存defaultUserAgent 的相关方法就行了,本地有缓存则直接读取,本地没有则立即读取,得益于之前我在项目中实现的使用方便的 配置化 Hook 框架,这种小打小闹的 Hook 工作不到一分钟就能完成。

当然,这里还需要考虑一个问题,那就是当用户机器的 defaultUserAgent 发生变化之后,怎么才能及时的更新本地缓存以及网络请求中用上新的defaultUserAgent。我们的做法是:

  • 当本地没有缓存时,立刻调用 WebSettings.getDefaultUserAgent 拿值并更新缓存;

  • 每次App启动阶段结束之后,会在子线程中去调用WebSettings.getDefaultUserAgent 拿值并更新缓存。

这样处理之后,将 defaultUserAgent 发生变化之后的影响最小化,系统 WebView 升级本身就是极度不频繁的事情,在这种 case 下我们舍弃了下一次 App 打开前几个网络请求的 defaultUserAgent 正确性也是合理的,这也是我们考量 「风险收益比」的一个经典case。

5. 确认问题被解决

通过上述 hook,我们重新打包 run 一遍 app,在启动阶段已经观察不到相关耗时了。

搞定,收工,不仅解决问题效率高,写博客也效率高,一会就整完了,简直就像是季度绩效考核前的产品,出方案和上线的效率就一个字,嗖。

相关推荐
黄林晴35 分钟前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我42 分钟前
flutter 之真手势冲突处理
android·flutter
法的空间1 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止1 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭1 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
道可到1 小时前
Java 反射现代实践速查表(JDK 11+/17+)
java
jctech2 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户2018792831672 小时前
为何Handler的postDelayed不适合精准定时任务?
android
道可到2 小时前
Java 反射现代实践指南(JDK 11+ / 17+ 适用)
java
玉衡子2 小时前
九、MySQL配置参数优化总结
java·mysql