Android Oreo 的 WebView 多程序模式|得物技術

語言: CN / TW / HK

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 的 loaderUrl reload 等一系列方法時,都會丟擲這個異常 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()

該方法會通過 WebViewFactory createWebView() 方法去建立 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() 就不多少說了,上文已經介紹過了,裡面關鍵函式只會執行一次,這裡我們可以看到,其實最終呼叫的是 AwContents loadUrl() 方法。而 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 物件 。

@CalledByNative
private 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() 方法。

@Override
public 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 呢?在上文中我們也提到了,我們可以通過 WebViewClient shouldInterceptRequest() 來攔截資源請求,然後載入本地離線資源,從而減少網路資源請求耗時,以達到 H5 頁面秒開的目的。而本地離線資源是從何而來呢?在 1.0 的方案中,是通過離線包的方式去實現的;而在 預載入 2.0 方案中,則是通過 WebView 在後臺對 H 5 頁面進行 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 &跨平臺 工程實踐與效能優化

報名方式: