如何設計一個優雅健壯的Android WebView?(上)

網易考拉移動端團隊發表於2018-02-27

前言

Android應用層的開發有幾大模組,其中WebView是最重要的模組之一。網上能夠搜尋到的WebView資料可謂寥寥,Github上的開源專案也不是很多,更別提有一個現成封裝好的WebView容器直接用於生產環境了。本文僅當記錄在使用WebView實現業務需求時所踩下的一些坑,並提供一些解決思路,避免遇到相同問題的朋友再次踩坑。

WebView現狀

Android系統的WebView發展歷史可謂一波三折,系統WebView開發者肯定費勁心思才換取了今天的局面——應用裡的WebView和Chrome表現一致。對於Android初學者,或者剛要開始接觸WebView的開發來說,WebView是有點難以適應,甚至是有一些懼怕的。開源社群對於WebView的改造和包裝非常少,需要開發者查詢大量資料去理解WebView。

WebView Changelog

在Android4.4(API level 19)系統以前,Android使用了原生自帶的Android Webkit核心,這個核心對HTML5的支援不是很好,現在使用4.4以下機子的也不多了,就不對這個核心做過多介紹了,有興趣可以看下這篇文章

從Android4.4系統開始,Chromium核心取代了Webkit核心,正式地接管了WebView的渲染工作。Chromium是一個開源的瀏覽器核心專案,基於Chromium開源專案修改實現的瀏覽器非常多,包括最著名的Chrome瀏覽器,以及一眾國內瀏覽器(360瀏覽器、QQ瀏覽器等)。其中Chromium在Android上面的實現是Android System WebView^1

從Android5.0系統開始,WebView移植成了一個獨立的apk,可以不依賴系統而獨立存在和更新,我們可以在系統->設定->Android System WebView看到WebView的當前版本。

從Android7.0系統開始,如果系統安裝了Chrome (version>51),那麼Chrome將會直接為應用的WebView提供渲染,WebView版本會隨著Chrome的更新而更新,使用者也可以選擇WebView的服務提供方(在開發者選項->WebView Implementation裡),WebView可以脫離應用,在一個獨立的沙盒程式中渲染頁面(需要在開發者選項裡開啟)^2

從Android8.0系統開始,預設開啟WebView多程式模式,即WebView執行在獨立的沙盒程式中^3

為什麼WebView那麼難搞?

儘管應用開發者使用WebView和使用普通的View一樣簡單,只需要在xml裡定義或者直接例項化出來即可使用,但WebView是相當難搞的。為什麼呢?以下有幾個可能的因素。

  • 繁雜的WebView配置

WebView在初始化的時候就提供了預設配置WebSettings,但是很多預設配置是不能夠滿足業務需求的,還需要進行二次配置,例如考拉App在預設配置基礎做了如下修改:

public static void setDefaultWebSettings(WebView webView) {
    WebSettings webSettings = webView.getSettings();
    //5.0以上開啟混合模式載入
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
    }
    webSettings.setLoadWithOverviewMode(true);
    webSettings.setUseWideViewPort(true);
    //允許js程式碼
    webSettings.setJavaScriptEnabled(true);
    //允許SessionStorage/LocalStorage儲存
    webSettings.setDomStorageEnabled(true);
    //禁用放縮
    webSettings.setDisplayZoomControls(false);
    webSettings.setBuiltInZoomControls(false);
    //禁用文字縮放
    webSettings.setTextZoom(100);
    //10M快取,api 18後,系統自動管理。
    webSettings.setAppCacheMaxSize(10 * 1024 * 1024);
    //允許快取,設定快取位置
    webSettings.setAppCacheEnabled(true);
    webSettings.setAppCachePath(context.getDir("appcache", 0).getPath());
    //允許WebView使用File協議
    webSettings.setAllowFileAccess(true);
    //不儲存密碼
    webSettings.setSavePassword(false);
	//設定UA
    webSettings.setUserAgentString(webSettings.getUserAgentString() + " kaolaApp/" + AppUtils.getVersionName());
    //移除部分系統JavaScript介面
    KaolaWebViewSecurity.removeJavascriptInterfaces(webView);
    //自動載入圖片
    webSettings.setLoadsImagesAutomatically(true);
}
複製程式碼

除此之外,使用方還需要根據業務需求實現WebViewClientWebChromeClient,這兩個類所需要覆寫的方法更多,用來實現標題定製、載入進度條控制、jsbridge互動、url攔截、錯誤處理(包括http、資源、網路)等很多與業務相關的功能。

  • 複雜的前端環境

如今,全球資訊網的核心語言,超文字標記語言已經發展到了HTML5,隨之而來的是html、css、js相應的升級與更新。高版本的語法無法在低版本的核心上識別和渲染,業務上需要使用到新的特性時,開發不得不面對後向相容的問題。網際網路的連結千千萬萬,使用哪些語言特性不是WebView能決定的,要求WebView適配所有頁面幾乎是不可能的事情。

  • 版本間差異

WebView不同的版本方法的實現是有可能不一樣的,而前端一般情況下只會呼叫系統的api來實現功能,這就會導致Android不同的系統、不同的WebView版本表現不一致的情況。一個典型的例子是下面即將描述的WebView中的檔案上傳功能,當我們在Web頁面上點選選擇檔案的控制元件(<input type="file">)時,會產生不同的回撥方法。除了檔案上傳功能,版本間的差異還有很多很多,比如快取機制的版本差異,js安全漏洞的遮蔽,cookie管理等。Google也在想辦法解決這些差異給開發者帶來的適配壓力,例如Webkit核心到Chromium核心的切換對開發者是透明的,底層的API完全沒有改變,這也是好的設計模式帶來的益處。

  • 國內ROM、瀏覽器對WebView核心的魔改

國產手機的廠商基本在出廠時都自帶了瀏覽器,檢視系統應用時,發現並沒有內建com.android.webview或者com.google.android.webview包,這些瀏覽器並不是簡單地套了一層WebView的殼,而是直接使用了Chromium核心,至於有沒有魔改過核心原始碼,不得而知。國產出品的瀏覽器,如360瀏覽器、QQ瀏覽器、UC瀏覽器,幾乎都魔改了核心。值得一提的是,騰訊出品的X5核心,號稱頁面渲染流暢度高於原生核心,客戶端減少了WebView帶來坑的同時,增加了前端適配的難度,功能實現上需要有更多地考慮。

  • 需要一定的Web知識

如果僅僅會使用WebView.loadUrl()來載入一個網頁而不瞭解底層到底發生了什麼,那麼url發生錯誤、url中的某些內容載入不出來、url裡的內容點選無效、支付寶支付浮層彈不起來、與前端無法溝通等等問題就會接踵而至。要開發好一個功能完整的WebView,需要對Web知識(html、js、css)有一定了解,知道loadUrl,WebView在後臺請求這個url以後,伺服器做了哪些響應,又下發了哪些資源,這些資源的作用是怎麼樣的。

為什麼Github上的WebView專案不適用?

上面的連結可以看到,Github上面star過千的WebView專案主要是FinestWebView-AndroidAndroid-AdvancedWebView。看過原始碼的話應該知道,第一個專案偏向於實現一個瀏覽器,第二個專案提供的介面太少,並且一些坑並未填完。陸續看過幾個別的開源實現,發現並不理想。後來想想,很難不依賴於業務而單獨實現一個WebView,特別是與前端約定了jsbridge介面,需要處理頁面關閉、全屏、url攔截、登入、分享等一系列功能,即便是接入了開源平臺的WebView,也需要做大量的擴充套件才有可能完全滿足需求。與其如此,每個電商平臺都有自己一套規則,基於電商的業務需求來自己擴充套件WebView是比較合理的。

WebView踩坑歷程

可以說,如果是初次接觸WebView,不踩坑幾乎是不可能的。筆者在接觸到前人留下來的WebView程式碼時,有些地方寫的很trickey,如果不仔細閱讀,或者翻閱資料,很有可能就會掉進坑裡。下面介紹幾個曾經遇到過的坑。

WebSettings.setJavaScriptEnabled

我相信99%的應用都會呼叫下面這句

WebSettings.setJavaScriptEnabled(true);
複製程式碼

在Android 4.3版本呼叫WebSettings.setJavaScriptEnabled()方法時會呼叫一下reload方法,同時會回撥多次WebChromeClient.onJsPrompt()。如果有業務邏輯依賴於這兩個方法,就需要注意判斷回撥多次是否會帶來影響了。

同時,如果啟用了JavaScript,務必做好安全措施,防止遠端執行漏洞^5

@TargetApi(11)
private static final void removeJavascriptInterfaces(WebView webView) {
    try {
        if (Build.VERSION.SDK_INT >= 11 && Build.VERSION.SDK_INT < 17) {
	        webView.removeJavascriptInterface("searchBoxJavaBridge_");
	        webView.removeJavascriptInterface("accessibility");
	        webView.removeJavascriptInterface("accessibilityTraversal");
        }
    } catch (Throwable tr) {
        tr.printStackTrace();
    }
}
複製程式碼

301/302重定向問題

WebView的301/302重定向問題,絕對在踩坑排行榜里名列前茅。。。隨便搜了幾個解決方案,要麼不能滿足業務需求,要麼清一色沒有徹底解決問題。

stackoverflow.com/questions/4… blog.csdn.net/jdsjlzx/art… www.cnblogs.com/pedro-neer/… www.jianshu.com/p/c01769aba…

301/302業務場景及白屏問題

先來分析一下業務場景。對於需要對url進行攔截以及在url中需要拼接特定引數的WebView來說,301和302發生的情景主要有以下幾種:

  • 首次進入,有重定向,然後直接載入H5頁面,如http跳轉https
  • 首次進入,有重定向,然後跳轉到native頁面,如掃一掃短鏈,然後跳轉到native
  • 二次載入,有重定向,跳轉到native頁面
  • 對於考拉業務來說,還有類似登入後跳轉到某個頁面的需求。如我的拼團,未登入狀態下點選我的拼團跳轉到登入頁面,登入完成後再載入我的拼團頁。

第一種情況屬於正常情況,暫時沒遇到什麼坑。

第二種情況,會遇到WebView空白頁問題,屬於原始url不能攔截到native頁面,但301/302後的url攔截到native頁面的情況,當遇到這種情況時,需要把WebView對應的Activity結束,否則當使用者從攔截後的頁面返回上一個頁面時,是一個WebView空白頁。

第三種情況,也會遇到WebView空白頁問題,原因在於載入的第一個頁面發生了重定向到了第二個頁面,第二個頁面被客戶端攔截跳轉到native頁面,那麼WebView就停留在第一個頁面的狀態了,第一個頁面顯然是空白頁。

第四種情況,會遇到無限載入登入頁面的問題。考拉的登入連結是類似下面這種格式:

https://m.kaola.com/login.html?target=登入後跳轉的url
複製程式碼

如果登入成功後還重新載入這個url,那麼就會迴圈跳轉到登入頁面。第四點解決起來比較簡單,登入成功以後拿到target後的跳轉url再重新載入即可。

301/302回退棧問題

無論是哪種重定向場景,都不可避免地會遇到回退棧的處理問題,如果處理不當,使用者按返回鍵的時候不一定能回到重定向之前的那個頁面。很多開發者在覆寫WebViewClient.shouldOverrideUrlLoading()方法時,會簡單地使用以下方式粗暴處理:

WebView.setWebViewClient(new WebViewClient() {
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
    	view.loadUrl(url);
    	return true;
    }
    ...
)
複製程式碼

這種方法最致命的弱點就是如果不經過特殊處理,那麼按返回鍵是沒有效果的,還會停留在302之前的頁面。現有的解決方案無非就幾種:

  1. 手動管理回退棧,遇到重定向時回退兩次^6
  2. 通過HitTestResult判斷是否是重定向,從而決定是否自己載入url^7 ^8
  3. 通過設定標記位,在onPageStartedonPageFinished分別標記變數避免重定向^9

可以說,這幾種解決方案都不是完美的,都有缺陷。

301/302較優解決方案

解決301/302回退棧問題

能否結合上面的幾種方案,來更加準確地判斷301/302的情況呢?下面說一下本文的解決思路。在提供解決方案之前,我們需要了解一下shouldOverrideUrlLoading方法的返回值代表什麼意思。

Give the host application a chance to take over the control when a new url is about to be loaded in the current WebView. If WebViewClient is not provided, by default WebView will ask Activity Manager to choose the proper handler for the url. If WebViewClient is provided, return true means the host application handles the url, while return false means the current WebView handles the url.

簡單地說,就是返回true,那麼url就已經由客戶端處理了,WebView就不管了,如果返回false,那麼當前的WebView實現就會去處理這個url。

WebView能否知道某個url是不是301/302呢?當然知道,WebView能夠拿到url的請求資訊和響應資訊,根據header裡的code很輕鬆就可以實現,事實正是如此,交給WebView來處理重定向(return false),這時候按返回鍵,是可以正常地回到重定向之前的那個頁面的。(PS:從上面的章節可知,WebView在5.0以後是一個獨立的apk,可以單獨升級,新版本的WebView實現肯定處理了重定向問題)

但是,業務對url攔截有需求,肯定不能把所有的情況都交給系統WebView處理。為了解決url攔截問題,本文引入了另一種思想——通過使用者的touch事件來判斷重定向。下面通過程式碼來說明。

/**
 * WebView基礎類,處理一些基礎的公有操作
 *
 * @author xingli
 * @time 2017-12-06
 */
public class BaseWebView extends WebView {

    private boolean mTouchByUser;

    public BaseWebView(Context context) {
        super(context);
    }

    public BaseWebView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public BaseWebView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public final void loadUrl(String url, Map<String, String> additionalHttpHeaders) {
        super.loadUrl(url, additionalHttpHeaders);
        resetAllStateInternal(url);
    }

    @Override
    public void loadUrl(String url) {
        super.loadUrl(url);
        resetAllStateInternal(url);
    }

    @Override
    public final void postUrl(String url, byte[] postData) {
        super.postUrl(url, postData);
        resetAllStateInternal(url);
    }

    @Override
    public final void loadData(String data, String mimeType, String encoding) {
        super.loadData(data, mimeType, encoding);
        resetAllStateInternal(getUrl());
    }

    @Override
    public final void loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding,
            String historyUrl) {
        super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl);
        resetAllStateInternal(getUrl());
    }

    @Override
    public void reload() {
        super.reload();
        resetAllStateInternal(getUrl());
    }

    public boolean isTouchByUser() {
        return mTouchByUser;
    }

    private void resetAllStateInternal(String url) {
        if (!TextUtils.isEmpty(url) && url.startsWith("javascript:")) {
            return;
        }
        resetAllState();
    }

	// 載入url時重置touch狀態
    protected void resetAllState() {
        mTouchByUser = false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            	//使用者按下到下一個連結載入之前,置為true
                mTouchByUser = true;
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public void setWebViewClient(final WebViewClient client) {
        super.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, String url) {
                boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, url);
            	   if (handleByChild) {
             		// 開放client介面給上層業務呼叫,如果返回true,表示業務已處理。
                    return true;
            	   } else if (!isTouchByUser()) {
             		// 如果業務沒有處理,並且在載入過程中使用者沒有再次觸控螢幕,認為是301/302事件,直接交由系統處理。
                    return super.shouldOverrideUrlLoading(view, url);
                } else {
                	//否則,屬於二次載入某個連結的情況,為了解決拼接引數丟失問題,重新呼叫loadUrl方法新增固有引數。
                    loadUrl(url);
                    return true;
                }
            }

            @RequiresApi(api = Build.VERSION_CODES.N)
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, request);

                if (handleByChild) {
                    return true;
                } else if (!isTouchByUser()) {
                    return super.shouldOverrideUrlLoading(view, request);
                } else {
                    loadUrl(request.getUrl().toString());
                    return true;
                }
            }
        });
    }
}
複製程式碼

上述程式碼解決了正常情況下的回退棧問題。

解決業務白屏問題

為了解決白屏問題,考拉目前的解決思路和上面的回退棧問題思路有些類似,通過監聽touch事件分發以及onPageFinished事件來判斷是否產生白屏,程式碼如下:

public class KaolaWebview extends BaseWebView implements DownloadListener, Lifeful, OnActivityResultListener {

    private boolean mIsBlankPageRedirect;  //是否因重定向導致的空白頁面。

    public KaolaWebview(Context context) {
        super(context);
        init();
    }

    public KaolaWebview(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public KaolaWebview(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    protected void back() {
        if (mBackStep < 1) {
            mJsApi.trigger2("kaolaGoback");
        } else {
            realBack();
        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_UP) {
            mIsBlankPageRedirect = true;
        }
        return super.dispatchTouchEvent(ev);
    }

    private WebViewClient mWebViewClient = new WebViewClient() {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            url = WebViewUtils.removeBlank(url);
            //允許啟動第三方應用客戶端
            if (WebViewUtils.canHandleUrl(url)) {
                boolean handleByCaller = false;
                // 如果不是使用者觸發的操作,就沒有必要交給上層處理了,直接走url攔截規則。
                if (null != mIWebViewClient && isTouchByUser()) {
                    handleByCaller = mIWebViewClient.shouldOverrideUrlLoading(view, url);
                }
                if (!handleByCaller) {
                    handleByCaller = handleOverrideUrl(url);
                }
                return handleByCaller || super.shouldOverrideUrlLoading(view, url);
            } else {
                try {
                    notifyBeforeLoadUrl(url);
                    Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
                    intent.addCategory(Intent.CATEGORY_BROWSABLE);
                    mContext.startActivity(intent);
                    if (!mIsBlankPageRedirect) {
                    	// 如果遇到白屏問題,手動後退
                        back();
                    }
                } catch (Exception e) {
                    ExceptionUtils.printExceptionTrace(e);
                }
                return true;
            }
        }

        @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
            return shouldOverrideUrlLoading(view, request.getUrl().toString());
        }
        
        private boolean handleOverrideUrl(final String url) {
           RouterResult result =  WebActivityRouter.startFromWeb(
                    new IntentBuilder(mContext, url).setRouterActivityResult(new RouterActivityResult() {
                        @Override
                        public void onActivityFound() {
                            if (!mIsBlankPageRedirect) {
                    			// 路由已經攔截到跳轉到native頁面,但此時可能發生了
                    			// 301/302跳轉,那麼執行後退動作,防止白屏。
                                back();
                            }
                        }

                        @Override
                        public void onActivityNotFound() {
                            if (mIWebViewClient != null) {
                                mIWebViewClient.onActivityNotFound();
                            }
                        }
                    }));
            return result.isSuccess();
        }
    };

    @Override
    public void onPageFinished(WebView view, String url) {
        mIsBlankPageRedirect = true;
        if (null != mIWebViewClient) {
            mIWebViewClient.onPageReallyFinish(view, url);
        }
        super.onPageFinished(view, url);
    }
}
複製程式碼

本來上面的兩個問題可以用同一個變數控制解決的,但由於歷史程式碼遺留問題,目前還沒有時間優化測試,這也是程式碼暫不公佈的原因之一(程式碼太醜陋:()。

url引數拼接問題

一般情況下,WebView會拼接一些本地引數作為識別碼傳給前端,如app版本號,網路狀態等,例如需要載入的url是

http://m.kaola.com?platform=android
複製程式碼

假設我們拼接appVersion和network,則拼接後url變成:

http://m.kaola.com?platform=android&appVersion=3.10.0&network=4g
複製程式碼

使用WebView.loadUrl()載入上面拼接好的url,隨意點選這個頁面上的某個連結跳轉到別的頁面,本地拼接的引數是不會自動帶過去的。如果需要前端處理引數問題,那麼如果是同域,可以通過cookie傳遞。非同域的話,還是需要客戶端拼接引數帶過去。

部分機型沒有WebView,應用直接崩潰

在Crash平臺上面發現有部分機型會存在下面這個崩潰,這些機型都是7.0系統及以上的。

android.util.AndroidRuntimeException: android.webkit.WebViewFactory$MissingWebViewPackageException: Failed to load WebView provider: No WebView installed
at android.webkit.WebViewFactory.getProviderClass(WebViewFactory.java:371)
at android.webkit.WebViewFactory.getProvider(WebViewFactory.java:194)
at android.webkit.WebView.getFactory(WebView.java:2325)
at android.webkit.WebView.ensureProviderCreated(WebView.java:2320)
at android.webkit.WebView.setOverScrollMode(WebView.java:2379)
at android.view.View.(View.java:4015)
at android.view.View.(View.java:4132)
at android.view.ViewGroup.(ViewGroup.java:578)
at android.widget.AbsoluteLayout.(AbsoluteLayout.java:55)
at android.webkit.WebView.(WebView.java:627)
at android.webkit.WebView.(WebView.java:572)
at android.webkit.WebView.(WebView.java:555)
at android.webkit.WebView.(WebView.java:542)
at com.kaola.modules.webview.BaseWebView.void (android.content.Context)(Unknown Source)
複製程式碼

經過測試發現,普通使用者是沒有辦法解除安裝WebView的(即使能解除安裝,也只是把更新解除安裝了,原始版本的WebView還是存在的),所以理論上不會存在異常……但既然發生並且上傳上來了,那麼就需要細細分析一下原因了。跟著程式碼WebViewFactory.getProvider()走,

static WebViewFactoryProvider getProvider() {
    synchronized (sProviderLock) {
        // For now the main purpose of this function (and the factory abstraction) is to keep
        // us honest and minimize usage of WebView internals when binding the proxy.
        if (sProviderInstance != null) return sProviderInstance;

        final int uid = android.os.Process.myUid();
        if (uid == android.os.Process.ROOT_UID || uid == android.os.Process.SYSTEM_UID
                || uid == android.os.Process.PHONE_UID || uid == android.os.Process.NFC_UID
                || uid == android.os.Process.BLUETOOTH_UID) {
            throw new UnsupportedOperationException(
                    "For security reasons, WebView is not allowed in privileged processes");
        }

        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
        Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactory.getProvider()");
        try {
            Class<WebViewFactoryProvider> providerClass = getProviderClass();
            Method staticFactory = null;
            try {
                staticFactory = providerClass.getMethod(
                    CHROMIUM_WEBVIEW_FACTORY_METHOD, WebViewDelegate.class);
            } catch (Exception e) {
                if (DEBUG) {
                    Log.w(LOGTAG, "error instantiating provider with static factory method", e);
                }
            }

            Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactoryProvider invocation");
            try {
                sProviderInstance = (WebViewFactoryProvider)
                        staticFactory.invoke(null, new WebViewDelegate());
                if (DEBUG) Log.v(LOGTAG, "Loaded provider: " + sProviderInstance);
                return sProviderInstance;
            } catch (Exception e) {
                Log.e(LOGTAG, "error instantiating provider", e);
                throw new AndroidRuntimeException(e);
            } finally {
                Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);
            }
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);
            StrictMode.setThreadPolicy(oldPolicy);
        }
    }
}
複製程式碼

可以看到,獲取WebView的例項,就是先拿到WebViewFactoryProvider這個工廠類,通過WebViewFactoryProvider工廠類裡的靜態方法CHROMIUM_WEBVIEW_FACTORY_METHOD建立一個WebViewFactoryProvider,接著,呼叫WebViewFactoryProvider.createWebView()建立一個WebViewProvider(相當於WebView的代理類),後面WebView的方法都是通過代理類來實現的。

在第一步獲取WebVIewFactoryProvider類的過程中,

private static Class<WebViewFactoryProvider> getProviderClass() {
    Context webViewContext = null;
    Application initialApplication = AppGlobals.getInitialApplication();

    try {
    	//獲取WebView上下文並設定provider
        webViewContext = getWebViewContextAndSetProvider();
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);
    }
	 程式碼省略...
    }
}

private static Context getWebViewContextAndSetProvider() {
    Application initialApplication = AppGlobals.getInitialApplication();
    WebViewProviderResponse response = null;
    Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW,
            "WebViewUpdateService.waitForAndGetProvider()");
    try {
        response = getUpdateService().waitForAndGetProvider();
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);
    }
    if (response.status != LIBLOAD_SUCCESS
            && response.status != LIBLOAD_FAILED_WAITING_FOR_RELRO) {
        // 崩潰就發生在這裡。
        throw new MissingWebViewPackageException("Failed to load WebView provider: "
                + getWebViewPreparationErrorReason(response.status));
    }
}

複製程式碼

可以發現,在與WebView包通訊的過程中,so庫並沒有載入成功,最後程式碼到了native層,沒有繼續跟下去了。

對於這種問題,解決方案有兩種,一種是判斷包名,如果檢測到系統包名裡不包含com.google.android.webview或者com.android.webview,則認為使用者手機裡的WebView不可用;另外一種是通過try/catch判斷WebView例項化是否成功,如果丟擲了WebViewFactory$MissingWebViewPackageException異常,則認為使用者的WebView不可用。

需要說明的是,第一種解決方案是不可靠的,因為國內的廠商基於Chromium的WebView實現有很多種,很有可能包名就被換了,比如MiWebView,包名是com.mi.webkit.core

WebView中的POST請求

在WebView中,如果前端使用POST方式向後端發起一個請求,那麼這個請求是不會走到WebViewClient.shouldOverrideUrlLoading()方法裡的^10。網上有一些解決方案,例如android-post-webview,通過js判斷是否是post請求,如果是的話,在WebViewClient.shouldInterceptRequest()方法裡自己建立連線,並拿到對應的頁面資訊,返回給WebResourceResponse。總之,儘量避免Web頁面使用POST請求,否則會帶來很大不必要的麻煩。

WebView檔案上傳功能

WebView中的檔案上傳功能,當我們在Web頁面上點選選擇檔案的控制元件(<input type="file">)時,會產生不同的回撥方法:^4

void openFileChooser(ValueCallback uploadMsg) works on Android 2.2 (API level 8) up to Android 2.3 (API level 10)

openFileChooser(ValueCallback uploadMsg, String acceptType) works on Android 3.0 (API level 11) up to Android 4.0 (API level 15)

openFileChooser(ValueCallback uploadMsg, String acceptType, String capture) works on Android 4.1 (API level 16) up to Android 4.3 (API level 18)

onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) works on Android 5.0 (API level 21) and above

最坑的點是在Android4.4系統上沒有回撥,這將導致功能的不完整,需要前端去做相容。解決方案就是和前端另外約定一個jsbridge來解決此類問題。

總結

限於篇幅,《如何設計一個優雅健壯的Android WebView?(上)》先介紹到這裡。本文介紹了目前Android裡的WebView現狀,以及由於現狀的不可改變導致遺留下的一些坑。所幸,世界上沒有什麼程式碼問題是一個程式設計師不能解決的,如果有,那就用兩個程式設計師解決。既然我們已經把前人留下的一些坑填了,那麼是時候構造一個可以用於生產環境的WebView了!《如何設計一個優雅健壯的Android WebView?(下)》將會介紹如何打造WebView的實戰操作,以及為了使用者更好的體驗,提出的一些WebView優化策略,敬請期待。

參考連結

  1. developer.chrome.com/multidevice…
  2. developer.android.com/about/versi…
  3. developer.android.com/about/versi…
  4. stackoverflow.com/questions/3…
  5. blog.csdn.net/self_study/…
  6. qbeenslee.com/article/and…
  7. juejin.im/entry/59775…
  8. www.cnblogs.com/zimengfang/…
  9. blog.csdn.net/dg_summer/a…
  10. issuetracker.google.com/issues/3691…

原文連結

kaolamobile.github.io/2017/12/10/…

相關文章