cover_image

Android Oreo 的 WebView 多进程模式|得物技术

庞振林 得物技术
2022年06月17日 10:30

1. 前言


Android 应用层的开发有很多模块,其中 WebView 就是最重要的模块之一。对于一个百万级用户的应用,WebView 必然是不可或缺的,而对于 得物App 这样的电商应用,更是不言而喻。而对于大部分开发者而言,并没有深入的去了解过 WebView 的一些实现细节,本文就从 WebView 启动的视角分析一下 Android Oreo 下的 WebView 多进程模式,帮助大家更深入的了解 WebView,并提供一些优化上的思路。


2. WebView 现状


WebView 的发展历程可谓是一波三折,在前期的 Android 系统中,Android 系统的每次版本升级,基本上都伴随着 WebView 的重大变更。Google 也是费尽心思才换来了今天的结果,应用内的 WebView 与 Chrome 的性能平分秋色,表现一致。而对于大部分 Android 开发者来说,对 WebView 的这些变更却知之甚少。而对于 WebView 的优化,更是举步维艰,不知道从何处下手,需要开发人员去翻阅大量资料去深入了解 WebView 的一些关键流程。


2.1 WebView 的变更记录


在 Android 4.4(API level 19)系统以前,Android 使用了原生自带的 Android Webkit 内核,这个内核对HTML5的支持不是很好,现在使用 4.4 以下机子的也不多了,就不对这个内核做过多介绍了。


从 Android 4.4 系统开始,Chromium 内核取代了 Webkit 内核,正式地接管了 WebView 的渲染工作。Chromium 是一个开源的浏览器内核项目,基于 Chromium 开源项目修改实现的浏览器非常多,包括最著名的Chrome浏览器,以及一众国内浏览器(360浏览器、QQ浏览器等)。其中Chromium 在 Android 上面的实现是 Android System WebView。


从 Android 5.0 系统开始,WebView 移植成了一个独立的 apk,可以不依赖系统而独立存在和更新,我们可以在应用列表中看到 Android System WebView 应用并查看当前的版本。


从 Android 7.0 系统开始,如果系统安装了 Chrome (version>51),那么 Chrome 将会直接为应用的WebView提供渲染,WebView 版本会随着 Chrome 的更新而更新,用户也可以选择 WebView 的服务提供方(在开发者选项->WebView Implementation里),WebView 可以脱离应用,在一个独立的沙盒进程中渲染页面(需要在开发者选项里打开)


从 Android 8.0 系统开始,默认开启 WebView 多进程模式,即 WebView 运行在独立的沙盒进程中。


2.2 WebView 的初始化


做过 WebView 相关业务的开发同学,应该都有注意到 WebView 第一次初始化相当耗时,而且线上 一部分 ANR 日志也和它息息相关。这里,我们站在源码的角度,去分析一下 WebView 第一次初始化为何如此耗时,能不能从中找出一些蛛丝马迹,并从这些蛛丝马迹中提出一些优化思路。


首先,看一下 WebView 构造函数实现。

protected WebView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,        int defStyleRes, @Nullable Map<String, Object> javaScriptInterfaces,        boolean privateBrowsing) {    super(context, attrs, defStyleAttr, defStyleRes);
// 代码省略
if (mWebViewThread == null) { throw new RuntimeException( "WebView cannot be initialized on a thread that has no Looper."); } sEnforceThreadChecking = context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR2; checkThread();
ensureProviderCreated(); mProvider.init(javaScriptInterfaces, privateBrowsing); // Post condition of creating a webview is the CookieSyncManager.getInstance() is allowed. CookieSyncManager.setGetInstanceIsAllowed();}


这里有三个重要的点,分别是 线程检查、WebViewProvider 的创建 以及 WebViewProvider 的初始化。接下来,我们对这三个流程进行一一分析。


(1)mWebViewThread 与 checkThread()


通过 mWebViewThread 的判空逻辑,我们可以发现,WebView 的初始化必须在 Looper 线程中,而 checkThread() 就是检查当前线程是不是 mWebViewThread 对应的 Looper 线程,而 WebView 中的所有方法,基本上在执行前都调用了 checkThread() 检查当前线程,想必是为了线程安全。而 mWebViewThread 是属性赋值,且赋值的对象是 Looper.myLooper(),所以 mWebViewThread 是在 WebView 对象构造时所在的线程所确定。


通常情况下,WebView 要展示在页面布局中,所以 WebView 对象的构造一般伴随页面布局发生在主线程中,所以 WebView 的 mWebViewThread 指向的是主线程的 Looper,所以当我们在子线程中调用 WebView 的 loaderUrlreload等一系列方法时,都会抛出这个异常A WebView method was called on thread 'xxx'. All WebView methods must be called on the same thread. (Expected Looper Looper (main, tid 2) {7424b6e} called on null, FYI main Looper is Looper (main, tid 2) {7424b6e}) 。让我们误以为 WebView 只能在主线程使用,其实不然,只要我们能保证 WebView 对象创建时的线程 和 对应方法的调用都发生在同一个 Looper 线程中,WebView 完全可以运行在子线程中,和主线程脱离关系,不占用主线程资源。


测试代码

val thread = HandlerThread("WebView").apply { start() }Handler(thread.looper).post {    Log.d(TAG, Thread.currentThread().name)    val wv = WebView(this)    wv.webViewClient = WebViewClient()    wv.settings.javaScriptEnabled = true    wv.loadUrl("https://baidu.com")    val params = WindowManager.LayoutParams()    windowManager.addView(wv, params)}


测试代码中为什么要使用 windowManager 的 addView 添加到 Activity 窗口中,而不是使用布局对象直接 addView。相比大家都应该了解,addView 会触发 requestLayout,而 requestLayout 方法中也有线程检查,当前线程必须 与 ViewRootImpl 的线程保证一致。因为布局是在主线程加载的,对应的 ViewRootImpl 也是主线程创建的,所以不能直接使用主线程加载的页面布局进行添加。所以,这里通过 windowManager 的 addView 创建新的子窗口,保证新创建的 ViewRootImpl 的线程与 WebView 所在的子线程保证一致。


小结:WebView 不是必须在主线程使用,子线程中也可以使用

接下来,我们来看 WebView 首次初始化耗时的关键点。


(2)ensureProviderCreated()

该方法会通过WebViewFactorycreateWebView() 方法去创建 WebViewProvider 对象并赋值给 mProvider 属性,看过 WebView 源码的同学都应该知道,WebView 其实是一个空壳,除了 checkThread,方法实现都委托给了 mProvider 对象。接下来,我们着重看一下 WebViewProvider 的创建流程。


首先会通过WebViewFactory.getProvider() 获取 WebViewFactoryProvider 对象,去创建 WebViewFactory 对象。而这个 WebViewFactoryProvider 对象是一个单例对象,只会创建一次,如果存在,直接返回该单例,如果不存在,则通过 getProviderClass() 去加载 WebViewFactoryProvider 的实现类,然后 CHROMIUM_WEBVIEW_FACTORY_METHOD 方法去创建 WebViewFactoryProvider 对象。


Class<WebViewFactoryProvider> providerClass = getProviderClass();Method staticFactory = providerClass.getMethod(        CHROMIUM_WEBVIEW_FACTORY_METHOD, WebViewDelegate.class);


private static Class<WebViewFactoryProvider> getProviderClass() {    try {        Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW,                "WebViewFactory.getWebViewContextAndSetProvider()");        try {            webViewContext = getWebViewContextAndSetProvider();        } finally {            Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);        }
Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactory.getChromiumProviderClass()"); try { sTimestamps.mAddAssetsStart = SystemClock.uptimeMillis(); for (String newAssetPath : webViewContext.getApplicationInfo().getAllApkPaths()) { initialApplication.getAssets().addAssetPathAsSharedLibrary(newAssetPath); } sTimestamps.mAddAssetsEnd = sTimestamps.mGetClassLoaderStart = SystemClock.uptimeMillis(); ClassLoader clazzLoader = webViewContext.getClassLoader(); Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactory.loadNativeLibrary()"); sTimestamps.mGetClassLoaderEnd = sTimestamps.mNativeLoadStart = SystemClock.uptimeMillis(); WebViewLibraryLoader.loadNativeLibrary(clazzLoader, getWebViewLibrary(sPackageInfo.applicationInfo)); Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW); Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "Class.forName()"); sTimestamps.mNativeLoadEnd = sTimestamps.mProviderClassForNameStart = SystemClock.uptimeMillis(); try { return getWebViewProviderClass(clazzLoader); } finally { sTimestamps.mProviderClassForNameEnd = SystemClock.uptimeMillis(); Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW); } } catch (ClassNotFoundException e) { Log.e(LOGTAG, "error loading provider", e); throw new AndroidRuntimeException(e); } finally { Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW); } } catch (MissingWebViewPackageException e) { Log.e(LOGTAG, "Chromium WebView package does not exist", e); throw new AndroidRuntimeException(e); }}


getProviderClass()的代码我们可以看出,这里会去加载另外一个 apk,然后通过另外一个 apk 去加载 WebViewFactoryProvider 的实现类。而getWebViewContextAndSetProvider()方法就是创建另外一个 apk Context 的过程,代码略长,就不在这里展示了,感兴趣的同学可以查看WebViewFactory 源码

getWebViewContextAndSetProvider
()方法是通过系统服务 UpdateService 来获取 WebView apk 的 packageInfo,在上文中的 WebView 变更记录中,我们提到过我们可以在开发者选项中自主选择 WebView 的实现,它依靠的就是这里提到的UpdateService,通过 UpdateService 查询 WebView 的 packageInfo,然后加载对应 apk,从而加载 WebViewFactory 的 providerClass。


从上文中,我们可以发现,在首次加载 WebView,会去加载 WebViewFactory 的 providerClass,而这个过程,需要通过 UpdateService 去查询Android System WebView 的 packageInfo,然后根据 packageInfo 去加载对应的 apk,这就是 WebView 首次加载耗时的原因之一。


拿到 WebViewFactoryProvider 的实现类之后,紧接着通过反射调用 CHROMIUM_WEBVIEW_FACTORY_METHOD方法来创建 WebViewFactoryProvider 对象。而默认的 Android System WebView 中 WebViewFactoryProvider 对象则是WebViewChromiumFactoryProvider。最终通过 WebViewFactoryProvider 对象来创建 WebViewProvider 对象,也就是 WebView 最终委托的对象。在默认的 Android System WebView 中 WebViewFactoryProvider 对象则是 WebViewChromium


关于这两个对象,这里就不过多介绍了,感兴趣的同学可以通过链接自行查看源码。

接下来,我们来看影响 WebView 首次加载耗时的最后一个关键点。


(3)WebViewProvider.init()

这里的 WebViewProvider 就是上文中提到的 Android System WebView 中WebViewChromium。我们来看一下它的 init() 方法。


public void init(final Map<String, Object> javaScriptInterfaces,            final boolean privateBrowsing) {    long startTime = SystemClock.uptimeMillis();    boolean isFirstWebViewInit = !mFactory.hasStarted();    try (ScopedSysTraceEvent e1 = ScopedSysTraceEvent.scoped("WebViewChromium.init")) {
if (mAppTargetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR2) { mFactory.startYourEngines(false); checkThread(); } else { mFactory.startYourEngines(true); }
// 省略n行代码
mSharedWebViewChromium.init(mContentsClientAdapter);
mFactory.addTask(new Runnable() { @Override public void run() { initForReal(); if (privateBrowsing) { // Intentionally irreversibly disable the webview instance, so that private // user data cannot leak through misuse of a non-private-browsing WebView // instance. Can't just null out mAwContents as we never null-check it // before use. destroy(); } } }); }}


这里我可以看到一个关键方法 startYourEngines(),它就是一切罪恶的根源,也是本文要介绍的 WebView 多进程模式的起源。


startYourEngines() 通过 WebViewChromiumFactoryProvider 辗转反侧调用到 WebViewChromiumAwInit 的 startYourEngines()startYourEngines()中又调用了自身的  ensureChromiumStartedLocked()


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. mIsInitializedFromUIThread = true; 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. } }}


首先是 mInitState 状态判断,如果已经初始化,则直接返回。在上文中,我们也提到过 WebView 中的 WebViewChromiumFactoryProvider 是个单例对象,由此可以推导出它的属性变量  mAwInit 也是个单例对象,所以 mInitState 状态切换为 INIT_FINISHED 之后,下面代码就不会再次执行了。也就是说,我们后面再多次创建 WebView 实例,不会再次执行了。所以下方的startChromiumLocked()只会执行一次,也就是 WebView 首次初始化时。


接着向下看,它并不是直接调用了 startChromiumLocked(),而是通过 ThreadUtils 判断一下当前线程是不是 UiThread,如果不是,则 post 到 UiThread 执行。看到 UiThread 这个字段,让我不禁回想起文章开头所提到的 mWebViewThread,这个 UiThread 指向的是不是 WebView 构造时所在的 Looper 线程。看完 ThreadUtils 的代码之后,我大失所望,这里的 UiThread 指向的就是应用的主线程。可能是为了线程安全,保证 startChromiumLocked()只被执行一次,所以只能在主线程执行。最后,在函数末尾,判断了 mInitState 状态,判断是否完成了初始化,如果没有完成初始化,则通过同步锁阻塞当前线程,直到 startChromiumLocked() 执行完成。


看到这里,我思绪万千,这就意味着 startChromiumLocked() 没有什么优化空间了,只能在主线中运行了。如果在应用启动时,对 WebView 进行提前初始化,startChromiumLocked() 这段主线程耗时,是无法避免了,而在 得物App 的应用启动性能监控中,可以发现 WebView 初始化,就占用了 800ms,严重拖累了启动耗时。


图片


从火焰图可以看出,在应用启动过程中,WebViewChromiumFactoryProvider 的 startYourEngines() 在 WebView 初始化过程中占用了 250ms,而该函数的主要作用就是调用了 startChromiumLocked(),通过上述分析,我们可以得知startChromiumLocked() 最终只会在主线程中执行,所以这 250ms 的主线程耗时只能通过其他策略进行优化了(如:在主线程空闲时对 WebView 进行初始化)


接着,我们就来分析一下 startChromiumLocked(),看一下该函数为什么这么耗时。


startChromiumLocked()

我们先看一下代码

public class WebViewChromiumAwInit {
protected void startChromiumLocked() { try (ScopedSysTraceEvent event = ScopedSysTraceEvent.scoped("WebViewChromiumAwInit.startChromiumLocked")) { TraceEvent.setATraceEnabled(mFactory.getWebViewDelegate().isTraceTagEnabled()); assert Thread.holdsLock(mLock) && ThreadUtils.runningOnUiThread(); // The post-condition of this method is everything is ready, so notify now to cover all // return paths. (Other threads will not wake-up until we release |mLock|, whatever). mLock.notifyAll(); if (mStarted) { return; } final Context context = ContextUtils.getApplicationContext(); BuildInfo.setFirebaseAppId(AwFirebaseConfig.getFirebaseAppId()); JNIUtils.setClassLoader(WebViewChromiumAwInit.class.getClassLoader()); ResourceBundle.setAvailablePakLocales( new String[] {}, AwLocaleConfig.getWebViewSupportedPakLocales()); BundleUtils.setIsBundle(ProductConfig.IS_BUNDLE); // We are rewriting Java resources in the background. // NOTE: Any reference to Java resources will cause a crash. try (ScopedSysTraceEvent e = ScopedSysTraceEvent.scoped("WebViewChromiumAwInit.LibraryLoader")) { LibraryLoader.getInstance().ensureInitialized(); } PathService.override(PathService.DIR_MODULE, "/system/lib/"); PathService.override(DIR_RESOURCE_PAKS_ANDROID, "/system/framework/webview/paks"); initPlatSupportLibrary(); doNetworkInitializations(context); waitUntilSetUpResources(); // NOTE: Finished writing Java resources. From this point on, it's safe to use them. AwBrowserProcess.configureChildProcessLauncher(); // finishVariationsInitLocked() must precede native initialization so the seed is // available when AwFeatureListCreator::SetUpFieldTrials() runs. finishVariationsInitLocked(); AwBrowserProcess.start(); AwBrowserProcess.handleMinidumpsAndSetMetricsConsent(true /* updateMetricsConsent */); mSharedStatics = new SharedStatics(); if (BuildInfo.isDebugAndroid()) { mSharedStatics.setWebContentsDebuggingEnabledUnconditionally(true); } mFactory.getWebViewDelegate().setOnTraceEnabledChangeListener( new WebViewDelegate.OnTraceEnabledChangeListener() { @Override public void onTraceEnabledChange(boolean enabled) { TraceEvent.setATraceEnabled(enabled); } }); mStarted = true; RecordHistogram.recordSparseHistogram("Android.WebView.TargetSdkVersion", context.getApplicationInfo().targetSdkVersion); try (ScopedSysTraceEvent e = ScopedSysTraceEvent.scoped( "WebViewChromiumAwInit.initThreadUnsafeSingletons")) { // Initialize thread-unsafe singletons. AwBrowserContext awBrowserContext = getBrowserContextOnUiThread(); mGeolocationPermissions = new GeolocationPermissionsAdapter( mFactory, awBrowserContext.getGeolocationPermissions()); mWebStorage = new WebStorageAdapter(mFactory, mBrowserContext.getQuotaManagerBridge()); mAwTracingController = getTracingController(); mServiceWorkerController = awBrowserContext.getServiceWorkerController(); mAwProxyController = new AwProxyController(); } mFactory.getRunQueue().drainQueue(); maybeLogActiveTrials(context); } }
}


一套代码看下去,里面的操作还不少,support库的加载与初始化、网络的初始化、BrowserProcess 进程的启动 等等,其中 AwBrowserProcess.start() 就是 BrowserProcess 进程启动的关键,它虽然可能不是里面最耗时的,但却是本文关心的重点,接下来就要进入正题了。


3. BrowserProcess


在上文 WebView 变更记录 部分,我们知道,从 Android 8.0 系统开始,默认开启 WebView 多进程模式,即 WebView 运行在独立的沙盒进程中。上文中提到的 AwBrowserProcess.start()正是用来启动该沙盒进程的。Google 为什么要引出这个沙盒进程呢?根源来自于 WebView 频频爆出的漏洞,正是这些漏洞,导致了应用进程变得不那么安全,严重一些,可能已经影响到泄漏用户隐私了。下面介绍一些 WebView 的历史漏洞。


3.1 WebView 常见漏洞


3.1.1 WebView 任意代码执行漏洞

Android 系统为了方便应用中 Java 代码和网页中的 Javascript 脚本交互,于是在 WebView 中实现了 addJavascriptInterface 接口。在 JELLY_BEAN(android 4.1)和 JELLY_BEAN 之前的版本中,使用这个方法是不安全的,网页中的JS脚本可以利用注入的对象调用应用中的 Java 代码,而 Java 对象继承关系会导致很多 Public 的函数及 getClass 函数都可以在JS中被访问,结合 Java 的反射机制,攻击者还可以获得系统类的函数,进而可以进行任意代码执行。JS 中可以遍历 window 对象,找到存在 getClass 方法的对象,再通过反射的机制,得到 Runtime 对象,然后就可以调用静态方法来执行一些命令,比如访问文件的命令。


核心 JS 代码

function execute(cmdArgs) {      for (var obj in window) {          if ("getClass" in window[obj]) {              return window[obj].getClass().forName("java.lang.Runtime")                   .getMethod("getRuntime", null).invoke(null, null).exec(cmdArgs);          }      }  }


3.1.2 WebView 密码明文存储漏洞


WebView 默认开启密码保存功能 mWebView.setSavePassword(true),如果该功能未关闭,在用户输入密码时,会弹出提示框,询问用户是否保存密码,如果选择”是”,密码会被明文保到 /data/data/com.xxx.xxx/databases/webview.db 中,这样就有被盗取密码的危险,所以需要通过 WebSettings.setSavePassword(false) 关闭密码保存提醒功能。


3.1.3 WebView 域控制不严格漏洞

通过 setAllowFileAccess 这个 API 可以设置是否允许 WebView 使用 File 协议,Android 中默认 setAllowFileAccess(true),所以默认值是允许,在 File 域下,能够执行任意的 JavaScript 代码, 同源策略跨域访问则能够对私有目录文件进行访问,应用内嵌入的 WebView 未对 file:/// 形式的 URL 做限制,所以使用 file 域加载的 js 能够使用同源策略跨域访问导致隐私信息泄露,针对 IM 类软件会导致聊天信息、联系人等等重要信息泄露,针对浏览器类软件,则更多的是 cookie 信息泄露。如果不允许使用 file 协议,则不会存在各种跨源的安全威胁,但同时也限制了 WebView 的功能,使其不能加载本地的 html 文件。


其实,对于 WebView 的漏洞还有很多,这里只是简单列举了几个,感兴趣的同学可以到 Google 的 Android 安全公告进行查看。


沙盒进程的优缺点

优点:

  • 更加安全,就算 WebView 再爆出严重漏洞,也不会影响到应用进程。


  • 减轻主进程的负担,因为 dom 解析、渲染、js 执行都发生在沙盒进程中,减轻了主进程的内存占用和 CPU 调度。


  • 减小内存泄漏的可能性,众所周知,在 WebView 一些特定版本上,由于 WebView 自身的一些原因会导致内存泄漏,WebView 在版本迭代过程中如果再次出现内存泄漏,大概率也是发生在 沙盒进程这一侧,避免影响到了应用进程。


  • 减少应用进程 crash 的风险,提升了应用的稳定性。如果 Chromium 内核在加载、渲染过程中发生了异常,影响的是沙盒进程,最终导致的是沙盒进程 crash,不会影响到 应用进程。不过,沙盒进程在 crash 之后 也会通过 onRenderProcessGone() 通知给应用进程的 WebViewClient 。(Tips:onRenderProcessGone() 中一定要返回 true,表明自己已经处理了这种情况,不然还是会导致应用进程 crash)


缺点:

  • 对 WebView 的大部分操作以及 沙盒进程对 WebClient 方法的回调都要经过跨进程调用。


其实,WebView 引出沙盒进程最主要的原因还是 Security。做过 Android framework 的同学应该都知道系统应用是不允许使用 WebView,在 WebView 初始化的时候,其实是有检查当前应用的 uid 是否是 拥有系统特权的 uid,如果是,则直接抛出异常。其实原因很简单,一旦 WebView 被发现出致命漏洞,攻击人就可以通过这些系统应用来获取到系统特权,这对于系统来说是致命的。所以,google 在系统应用上设置了一道门槛,不允许系统应用使用 WebView。可见,google 对于自家的 WebView 的安全性也是相当不自信。接下来,就介绍一下应用进程是如何与 BrowserProcess 进行交互的。


4. 与 BrowserProcess 的交互


这里主要介绍两个方法,loadUrl() 与 WebViewClient 的 shouldInterceptRequest(),因为这两个方法是开发过程中经常用到的。


4.1 loadUrl()

在上文中,我们也介绍过,WebView 所有方法都是委托给了 WebViewProvider,而这个 WebViewProvider 也是上文提到过的WebViewChromium。接下来,我们看一下 WebViewChromium 的 loadUrl() 实现。


public void loadUrl(final String url) {    mFactory.startYourEngines(true);    if (checkNeedsPost()) {        // Disallowed in WebView API for apps targeting a new SDK        assert mAppTargetSdkVersion < Build.VERSION_CODES.JELLY_BEAN_MR2;        mFactory.addTask(new Runnable() {            @Override            public void run() {                mAwContents.loadUrl(url);            }        });        return;    }    mAwContents.loadUrl(url);}


startYourEngines()就不多少说了,上文已经介绍过了,里面关键函数只会执行一次,这里我们可以看到,其实最终调用的是 AwContentsloadUrl()方法。而 AwContents 的 loadUrl() 也并非最终调用。整个调用链路过长,这里就不一一粘贴代码了。


在 AwContents 的 loadUrl() 中会调用 NavigationController 的 loadUrl(),而 NavigationController 又是一个接口类,它的具体实现是NavigationControllerImpl类。


--->NavigationControllerImpl.loadUrl(params)
通过JNI进入到native层的navigation_controller_impl.cc,
NavigationControllerImpl::LoadURL()--->LoadURLWithParams()
--->NavigationControllerImpl::LoadEntry(NavigationEntryImpl* entry)
--->NavigationControllerImpl::NavigateToPendingEntry()
--->NavigationControllerDelegate::NavigateToPendingEntry()
NavigationControllerDelegate 是一个虚基类,WebContentsImpl 实现了它。
--->WebContentsImpl::NavigateToPendingEntry()
--->NavigatorImpl.NavigateToPendingEntry()
--->NavigatorImpl::NavigateToEntry()
--->RenderFrameHostImpl::Navigate(navigate_params)


RenderFrameHostImpl 通过 IPC 向 BrowserProcess 的 RenderFrameImpl发送异步 IPC 消息:Send(new FrameMsg_Navigate(routing_id_, params)),RenderFrameImpl 接收到 IPC 消息后,通过 RenderFrameImpl::OnNavigate() 将 url以及其他所有信息打包到一个WebURLRequest中,调用 Blink 中的 WebFrame。至此,LoadUrl 已经走到了 Blink,WebView loadUrl() 的流程可以告一段落了。


4.2 shouldInterceptRequest()


做过 H5 秒开的同学应该对这个方法相当熟悉,WebView 页面每一个资源请求都会回调该方法,允许客户端拦截资源请求,加载本地资源。


我们知道,在 H5 页面的加载过程中,耗时的环节主要有两点,一是WebView初始化,可以通过提前初始化WebView优化此问题;二是资源(html、js、css、图片等)的请求连接和加载,可以用H5离线包方案解决此问题,通过资源的预加载,解决 html、js、css 和资源图片的加载问题,从而大大降低资源的加载时间,提升页面加载性能,从而达到秒开的效果。


而 shouldInterceptRequest() 正是作用于第二点,也是落地的资源预加载方案关键,通过提前下载 H5 页面资源到本地,在 WebViewClient shouldInterceptRequest() 回调时,进行资源请求拦截,加载本地提前下载好的离线资源,避免了网络耗时。


接下来,我们看一下响应 BrowserProcess 资源拦截请求的流程是怎么样的。


在上面 loadUrl() 分析中,我们有介绍到 AwContents,而在 AwContents 构造过程中,会创建 mBackgroundThreadClient 对象与 mIoThreadClient 对象,而 mBackgroundThreadClient 对象又是属于 mIoThreadClient 对象的,通过 AwContentsJni 的 setJavaPeers() 将 mIoThreadClient 对象与native 对象建立映射。


Native 层的 AwContentsIoThreadClient 对象在接收到 BrowserProcess 进程发起的资源拦截请求时,会调用到 java 层的 mIoThreadClient 对象的 getBackgroundThreadClient() 获取到 mBackgroundThreadClient 对象,最终通过 base::PostTaskAndReplyWithResult() 在指定的 task_runner 中调用 mBackgroundThreadClient 对象的 shouldInterceptRequestFromNative() 方法去触发 java 层资源拦截,并将结果返回给 BrowserProcess 进程。


代码的链路较长,这里就不一一粘贴了,不过,从 AwContentsBackgroundThreadClient 的类注释可以看出,这是一个后台线程的回调(这里的后台线程是指非 UI 线程和 IO 线程),这里之所以进行区分是为了更清楚的表达 chromium 的线程架构,即不同线程的回调通过不同的中间层来转接。


接下来看一下,mBackgroundThreadClient 对象 。

@CalledByNativeprivate AwWebResourceInterceptResponse shouldInterceptRequestFromNative(String url,        boolean isMainFrame, boolean hasUserGesture, String method, String[] requestHeaderNames,        String[] requestHeaderValues) {    try {        return new AwWebResourceInterceptResponse(                shouldInterceptRequest(new AwContentsClient.AwWebResourceRequest(url,                        isMainFrame, hasUserGesture, method, requestHeaderNames,                        requestHeaderValues)),                /*raisedException=*/false);    } catch (Throwable e) {        Log.e(TAG,                "Client raised exception in shouldInterceptRequest. Re-throwing on UI thread.");
AwThreadUtils.postToUiThreadLooper(() -> { Log.e(TAG, "The following exception was raised by shouldInterceptRequest:"); throw e; });
return new AwWebResourceInterceptResponse(null, /*raisedException=*/true); }}



可以看到,里面其实主要是调用了子类的 shouldInterceptRequest() 方法。


@Overridepublic WebResourceResponseInfo shouldInterceptRequest(        AwContentsClient.AwWebResourceRequest request) {    String url = request.url;    WebResourceResponseInfo webResourceResponseInfo;    // Return the response directly if the url is default video poster url.    webResourceResponseInfo = mDefaultVideoPosterRequestHandler.shouldInterceptRequest(url);    if (webResourceResponseInfo != null) return webResourceResponseInfo;
webResourceResponseInfo = mContentsClient.shouldInterceptRequest(request);
if (webResourceResponseInfo == null) { mContentsClient.getCallbackHelper().postOnLoadResource(url); }
if (webResourceResponseInfo != null && webResourceResponseInfo.getData() == null) { // In this case the intercepted URLRequest job will simulate an empty response // which doesn't trigger the onReceivedError callback. For WebViewClassic // compatibility we synthesize that callback. http://crbug.com/180950 mContentsClient.getCallbackHelper().postOnReceivedError( request, /* error description filled in by the glue layer */ new AwContentsClient.AwWebResourceError()); } return webResourceResponseInfo;}


而子类的 shouldInterceptRequest() 其实是调用的 AwContentsClient 的 shouldInterceptRequest() 方法,而 AwContentsClient 是一个抽象类,它的真正实现是 WebViewContentsClientAdapter,这个类通过 适配器模式 对 WebViewClient 对象进行了一层包装。最终,通过 WebViewContentsClientAdapter 对象回调到了 WebViewCient 的 shouldInterceptRequest() 方法。至此,shouldInterceptRequest() 的流程结束了。


5.调整&优化


在前不久参与 H5秒开 项目 预加载 2.0 需求的开发,什么是 预加载 2.0 呢?在上文中我们也提到了,我们可以通过 WebViewClientshouldInterceptRequest() 来拦截资源请求,然后加载本地离线资源,从而减少网络资源请求耗时,以达到 H5 页面秒开的目的。而本地离线资源是从何而来呢?在 1.0 的方案中,是通过离线包的方式去实现的;而在 预加载 2.0 方案中,则是通过 WebView 在后台对 H5 页面进行 loadUrl()


在上文中,我们也提到过 WebView 是可以在子线程中使用的。既然已经验证了 WebView 可以在子线程中加载,那在开发预加载 2.0 需求时,肯定是放在子线程中执行。在预加载 2.0 开发中,并没有采用子线程加载 WebView 方案,而是选用了更为传统的主线程 WebView 加载。为了避免造成主线程卡顿,通过 MessageQueue.IdleHandler,在主线程空闲时,执行预加载 2.0 任务。实际测试下来,体验上并没有差异。子线程 WebView 加载方案没有落地,因为 WebView 的 loadUrl() 也是一个非常耗时的函数,虽然放在了主线程空闲时刻执行,但是也存在极小概率阻塞后续的 主线程 UI 任务。


6. WebView 预加载优化


通过上述文章分析,我们知道 WebView 首次加载比较耗时的,如果我们等到打开 h5页面时才去触发 WebView 首次加载,肯定会加长页面的打开时间,从而增加用户的等待时长。所以为了减少这个等待时长,我们可以通过MessageQueue.IdleHandler,在主线程空闲时,对 WebView 进行提前初始化。下方直接给出代码。


public static void preloadWebView(final Application app) {    app.getMainLooper().getQueue().addIdleHandler(new MessageQueue.IdleHandler() {        @Override        public boolean queueIdle() {            startChromiumEngine();            return false;        }    });}
private static void startChromiumEngine() { try { final long t0 = SystemClock.uptimeMillis(); final Object provider = invokeStaticMethod(Class.forName("android.webkit.WebViewFactory"), "getProvider"); invokeMethod(provider, "startYourEngines", new Class[]{boolean.class}, new Object[]{true}); Log.i(TAG, "Start chromium engine complete: " + (SystemClock.uptimeMillis() - t0) + " ms"); } catch (final Throwable t) { Log.e(TAG, "Start chromium engine error", t); }}


而上方代码并不适用于所有场景,对于启动页面是 H5 页面的场景,上方代码显然是不合适的。对于 得物App,目前并没有采用上述方案,因为得物App 面临的复杂场景繁多,目前采用的是比较折中的方案,启动时 WebView 提前初始化,存入对象池中以便后续直接使用。在上文也提到了,这严重影响了 得物App 的启动耗时。所以,在使用上方代码时,需要一定策略,这样既可以达到 WebView 提前初始化的效果,也不影响应用的启动速度。


7. 总结


本文从源码的角度分析了 WebView 的初始化流程,从中也窥见了 WebView 沙盒进程的启动。沙盒进程使得应用进程变得更加安全的同时,也带来许多其他好处,例如提升了应用的稳定性,然而 7.0 以下的系统就没有这么幸运了,尤其是低版本的 WebView。


文中也介绍到了 WebView 预初始化、资源预加载等等,其实这些都是为了提升 H5 页面的秒开,从而提升用户体验,避免用户流失。但对于 WebView 的优化远不止这些,需要更深入的挖掘,才能找到更多可能。


扩展阅读:

【1】WebView 源码

https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/webkit/WebView.java

【2】chromium 源码

https://github.com/chromium/chromium



*文/庞振林

 关注得物技术,每周一三五晚18:30更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~



限时活动:
即日起至6月30日,公开转发得物技术任意一篇文章到朋友圈,就可以在「得物技术」公众号后台回复「得物」,参与得物文化衫抽奖。


图片



得物技术 - 沙龙推荐


时间:2022年6月25日 13:50~18:00

主题:Android &跨平台 工程实践与性能优化

报名方式:


继续滑动看下一个
得物技术
向上滑动看下一个