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

1. 前言

Android應用層的開發有幾大模塊,其中WebView是最重要的模塊之一。網上能夠搜索到的WebView資料可謂寥寥,Github上的開源項目也不是很多,更別提有一個現成封裝好的WebView容器直接用於生產環境了。本文僅當記錄在使用WebView實現業務需求時所踩下的一些坑,並提供一些解決思路,避免遇到相同問題的朋友再次踩坑。在踩坑的基礎上,本文着重介紹WebView在開發過程中所需要注意的問題,這些問題大部分在網上找不到標準答案,但卻是WebView開發過程中幾乎都會遇到的。此外還會淺談WebView優化,旨在給用戶帶來更好的WebView體驗。需要說明的是,本文僅提供解決思路,不提供源碼。

2. WebView現狀

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

2.1 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

2.2 爲什麼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);
}

除此之外,使用方還需要根據業務需求實現WebViewClient和WebChromeClient,這兩個類所需要覆寫的方法更多,用來實現標題定製、加載進度條控制、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以後,服務器做了哪些響應,又下發了哪些資源,這些資源的作用是怎麼樣的。

2.3 爲什麼Github上的WebView項目不適用?

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

3. WebView踩坑歷程

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

3.1 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();
    }
}

3.2 301/302重定向問題

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

  • https://stackoverflow.com/questions/4066438/android-webview-how-to-handle-redirects-in-app-instead-of-opening-a-browser
  • http://blog.csdn.net/jdsjlzx/article/details/51698250
  • http://www.cnblogs.com/pedro-neer/p/5318354.html
  • http://www.jianshu.com/p/c01769ababfa

3.2.1 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再重新加載即可。

3.2.2 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
  3. 通過設置標記位,在onPageStartedonPageFinished分別標記變量避免重定向^9

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

3.2.3 301/302較優解決方案

3.2.3.1 解決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;
                }
            }
        });
    }
}

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

3.2.3.2 解決業務白屏問題

爲了解決白屏問題,考拉目前的解決思路和上面的回退棧問題思路有些類似,通過監聽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);
    }
}

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

3.3 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傳遞。非同域的話,還是需要客戶端拼接參數帶過去。

3.4 部分機型沒有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

3.5 WebView中的POST請求

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

3.6 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 filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) works on Android 5.0 (API level 21) and above

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

4. WebView實戰操作

WebView在使用過程中會遇到各種各樣的問題,下面針對幾個在生產環境中使用的WebView可能出現的問題進行探討。

4.1 WebView初始化

也許大部分的開發者針對要打開一個網頁這一個Action,會停留在下面這段代碼:

WebView webview = new WebView(context);
webview.loadUrl(url);

這應該是打開一個正常網頁最簡短的代碼了。但大多數情況下,我們需要做一些額外的配置,例如縮放支持、Cookie管理、密碼存儲、DOM存儲等,這些配置大部分在WebSettings裏,具體配置的內容在上文中已有提及,本文不再具體講解。

接下來,試想如果訪問的網頁返回的請求是30X,如使用http訪問百度的鏈接(http://www.baidu.com),那麼這時候頁面就是空白一片,GG了。爲什麼呢?因爲WebView只加載了第一個網頁,接下來的事情就不管了。爲了解決這個問題,我們需要一個WebViewClient讓系統幫我們處理重定向問題。

webview.setWebViewClient(new WebViewClient());

除了處理重定向,我們還可以覆寫WebViewClient中的方法,方法有:

public boolean shouldOverrideUrlLoading(WebView view, String url) 
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request)
public void onPageStarted(WebView view, String url, Bitmap favicon) 
public void onPageFinished(WebView view, String url) 
public void onLoadResource(WebView view, String url) 
public void onPageCommitVisible(WebView view, String url) 
public WebResourceResponse shouldInterceptRequest(WebView view, String url) 
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) 
public void onTooManyRedirects(WebView view, Message cancelMsg, Message continueMsg) 
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) 
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) 
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) 
public void onFormResubmission(WebView view, Message dontResend, Message resend) 
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) 
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) 
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) 
public void onReceivedHttpAuthRequest(WebView view, HttpAuthHandler handler, String host, String realm) 
public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) 
public void onUnhandledKeyEvent(WebView view, KeyEvent event) 
public void onScaleChanged(WebView view, float oldScale, float newScale) 
public void onReceivedLoginRequest(WebView view, String realm, String account, String args) 
public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail)

這些方法具體介紹可以參考文章《WebView使用詳解(二)——WebViewClient與常用事件監聽》。有幾個方法是有必要覆寫來處理一些客戶端邏輯的,後面遇到會詳細介紹。

另外,WebView的標題不是一成不變的,加載的網頁不一樣,標題也不一樣。在WebView中,加載的網頁的標題會回調WebChromeClient.onReceivedTitle()方法,給開發者設置標題。因此,設置一個WebChromeClient也是有必要的。

webview.setWebChromeClient(new WebChromeClient());

同樣,我們還可以覆寫WebChromeClient中的方法,方法有:

public void onProgressChanged(WebView view, int newProgress)
public void onReceivedTitle(WebView view, String title)
public void onReceivedIcon(WebView view, Bitmap icon)
public void onReceivedTouchIconUrl(WebView view, String url, boolean precomposed)
public void onShowCustomView(View view, int requestedOrientation, CustomViewCallback callback)
public void onHideCustomView()
public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg)
public void onRequestFocus(WebView view)
public void onCloseWindow(WebView window)
public boolean onJsAlert(WebView view, String url, String message, JsResult result)
public boolean onJsConfirm(WebView view, String url, String message, JsResult result)
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result)
public void onExceededDatabaseQuota(String url, String databaseIdentifier, long quota, long estimatedDatabaseSize, long totalQuota, WebStorage.QuotaUpdater quotaUpdater)
public void onReachedMaxAppCacheSize(long requiredStorage, long quota, WebStorage.QuotaUpdater quotaUpdater)
public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback)
public void onGeolocationPermissionsHidePrompt()
public void onPermissionRequest(PermissionRequest request)
public void onPermissionRequestCanceled(PermissionRequest request)
public boolean onJsTimeout()
public void onConsoleMessage(String message, int lineNumber, String sourceID)
public boolean onConsoleMessage(ConsoleMessage consoleMessage)
public Bitmap getDefaultVideoPoster()
public void getVisitedHistory(ValueCallback<String[]> callback)
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams)
public void openFileChooser(ValueCallback<Uri> uploadFile, String acceptType, String capture)
public void setupAutoFill(Message msg)

這些方法具體介紹可以參考文章《WebView使用詳解(三)——WebChromeClient與LoadData補充》。除了接收標題以外,進度條的改變,WebView請求本地文件、請求地理位置權限等,都是通過WebChromeClient的回調實現的。

在初始化階段,如果啓用了Javascript,那麼需要移除相關的安全漏洞,這在上一篇文章中也有所提及。最後,在考拉KaolaWebView.init()方法中,執行了如下操作:

protected void init() {
    mContext = getContext();
    mWebJsManager = new WebJsManager();	// 初始化Js管理器
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    	// 根據本地調試開關打開Chrome調試
       WebView.setWebContentsDebuggingEnabled(WebSwitchManager.isDebugEnable());
    }
    // WebSettings配置
    WebViewSettings.setDefaultWebSettings(this);
    // 獲取deviceId列表,安全相關
    WebViewHelper.requestNeedDeviceIdUrlList(null);
    // 設置下載的監聽器
    setDownloadListener(this);
    // 前端控制回退棧,默認回退1。
    mBackStep = 1;
    // 重定向保護,防止空白頁
    mRedirectProtected = true;
    // 截圖使用
    setDrawingCacheEnabled(true);
    // 初始化具體的Jsbridge類
    enableJsApiInternal();
    // 初始化WebCache,用於加載靜態資源
    initWebCache();
    // 初始化WebChromeClient,覆寫其中一部分方法
    super.setWebChromeClient(mChromeClient);
    // 初始化WebViewClient,覆寫其中一部分方法
    super.setWebViewClient(mWebViewClient);
}

4.2 WebView加載一個網頁的過程中該做些什麼?

如果說加載一個網頁只需要調用WebView.loadUrl(url)這麼簡單,那肯定沒程序員啥事兒了。往往事情沒有這麼簡單。加載網頁是一個複雜的過程,在這個過程中,我們可能需要執行一些操作,包括:

  1. 加載網頁前,重置WebView狀態以及與業務綁定的變量狀態。WebView狀態包括重定向狀態(mTouchByUser)、前端控制的回退棧(mBackStep)等,業務狀態包括進度條、當前頁的分享內容、分享按鈕的顯示隱藏等。
  2. 加載網頁前,根據不同的域拼接本地客戶端的參數,包括基本的機型信息、版本信息、登錄信息以及埋點使用的Refer信息等,有時候涉及交易、財產等還需要做額外的配置。
  3. 開始執行頁面加載操作時,會回調WebViewClient.onPageStarted(webview, url, favicon)。在此方法中,可以重置重定向保護的變量(mRedirectProtected),當然也可以在頁面加載前重置,由於歷史遺留代碼問題,此處尚未省去優化。
  4. 加載頁面的過程中,WebView會回調幾個方法。
    • WebChromeClient.onReceivedTitle(webview, title),用來設置標題。需要注意的是,在部分Android系統版本中可能會回調多次這個方法,而且有時候回調的title是一個url,客戶端可以針對這種情況進行特殊處理,避免在標題欄顯示不必要的鏈接。
    • WebChromeClient.onProgressChanged(webview, progress),根據這個回調,可以控制進度條的進度(包括顯示與隱藏)。一般情況下,想要達到100%的進度需要的時間較長(特別是首次加載),用戶長時間等待進度條不消失必定會感到焦慮,影響體驗。其實當progress達到80的時候,加載出來的頁面已經基本可用了。事實上,國內廠商大部分都會提前隱藏進度條,讓用戶以爲網頁加載很快。
    • WebViewClient.shouldInterceptRequest(webview, request),無論是普通的頁面請求(使用GET/POST),還是頁面中的異步請求,或者頁面中的資源請求,都會回調這個方法,給開發一次攔截請求的機會。在這個方法中,我們可以進行靜態資源的攔截並使用緩存數據代替,也可以攔截頁面,使用自己的網絡框架來請求數據。包括後面介紹的WebView免流方案,也和此方法有關。
    • WebViewClient.shouldOverrideUrlLoading(webview, request),如果遇到了重定向,或者點擊了頁面中的a標籤實現頁面跳轉,那麼會回調這個方法。可以說這個是WebView裏面最重要的回調之一,後面WebView與Native頁面交互一節將會詳細介紹這個方法。
    • WebViewClient.onReceived**Error(webview, handler, error),加載頁面的過程中發生了錯誤,會回調這個方法。主要是http錯誤以及ssl錯誤。在這兩個回調中,我們可以進行異常上報,監控異常頁面、過期頁面,及時反饋給運營或前端修改。在處理ssl錯誤時,遇到不信任的證書可以進行特殊處理,例如對域名進行判斷,針對自己公司的域名“放行”,防止進入醜陋的錯誤證書頁面。也可以與Chrome一樣,彈出ssl證書疑問彈窗,給用戶選擇的餘地。
  5. 頁面加載結束後,會回調WebViewClient.onPageFinished(webview, url)。這時候可以根據回退棧的情況判斷是否顯示關閉WebView按鈕。通過mActivityWeb.canGoBackOrForward(-1)判斷是否可以回退。

4.3 WebView與JavaScript交互——JsBridge

Android WebView與JavaScript的通信方案,目前業界已經有比較成熟的方案了。常見的有lzyzsd/JsBridgepengwei1024/JsBridge等,詳見此鏈接

通常,Java調用js方法有兩種:

  • WebView.loadUrl(“javascript:” + javascript);
  • WebView.evaluateJavascript(javascript, callbacck);

第一種方式已經不推薦使用了,第二種方式不僅更方便,也提供了結果的回調,但僅支持API 19以後的系統。

js調用Java的方法有四種,分別是:

  • JavascriptInterface
  • WebViewClient.shouldOverrideUrlLoading()
  • WebChromeClient.onConsoleMessage()
  • WebChromeClient.onJsPrompt()

這四種方式不再一一介紹,掘金上的這篇文章已經講得很詳細。

下面來介紹一下考拉使用的JsBridge方案。Java調用js方法不必多說,根據Android系統版本不同分別調用第一個方法和第二個方法。在js調用Java方法上,考拉使用的是第四種方案,即侵入WebChromeClient.onJsPrompt(webview, url, message, defaultValue, result)實現通信。

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue,
        JsPromptResult result) {
    if (!ActivityUtils.activityIsAlive(mContext)) {//頁面關閉後,直接返回
        try {
            result.cancel();
        } catch (Exception ignored) {
        }
        return true;
    }
    if (mJsApi != null && mJsApi.hijackJsPrompt(message)) {
        result.confirm();
        return true;
    }
    return super.onJsPrompt(view, url, message, defaultValue, result);
}

onJsPrompt方法最終是在主線程中回調,判斷一下WebView所在容器的生命週期是有必要的。js與Java的方法調用主要在mJsApi.hijackJsPrompt(message)中。

public boolean hijackJsPrompt(String message) {
    if (TextUtils.isEmpty(message)) {
        return false;
    }
    boolean handle = message.startsWith(YIXIN_JSBRIDGE);
    if (handle) {
        call(message);
    }
    return handle;
}

首先判斷該信息是否應該攔截,如果允許攔截的話,則取出js傳過來的方法和參數,通過Handler把消息拋給業務層處理。

private void call(String message) {
    // PREFIX
    message = message.substring(KaolaJsApi.YIXIN_JSBRIDGE.length());
    // BASE64
    message = new String(Base64.decode(message));
    JSONObject json = JSONObject.parseObject(message);
    String method = json.getString("method");
    String params = json.getString("params");
    String version = json.getString("jsonrpc");
    if ("2.0".equals(version)) {
        int id = json.containsKey("id") ? json.getIntValue("id") : -1;
        call(id, method, params);
    }
    callJS("window.jsonRPC.invokeFinish()");
}
private void call(int id, String method, String params) {
	Message msg = Message.obtain();
	msg.what = MsgWhat.JSCall;
	msg.obj = new KaolaJSMessage(id, method, params);
	// 通過handler把消息發出去,待接收方處理。
	if (handler != null) {
	    handler.sendMessage(msg);
	}
}

jsbridge中,實現了一個存儲jsbridge指令的隊列CommandQueue,每次需要調用jsbridge時,只需要入隊即可。

function CommandQueue() {
    this.backQueue = [];
    this.queue = [];
};
CommandQueue.prototype.dequeue = function() {
    if(this.queue.length <=0 && this.backQueue.length > 0) {
        this.queue = this.backQueue.reverse();
        this.backQueue = [];
    }
    return this.queue.pop();
};
CommandQueue.prototype.enqueue = function(item) {
    this.backQueue.push(item);
};
Object.defineProperty(CommandQueue.prototype, 'length',
{get: function() {return this.queue.length + this.backQueue.length; }});
var commandQueue = new CommandQueue();
function _nativeExec(){
    var command = commandQueue.dequeue();
    if(command) {
        nativeReady = false;
        var jsoncommand = JSON.stringify(command);
        var _temp = prompt(jsoncommand,'');
        return true;
    } else {
        return false;
    }
}

上面的代碼有所刪減,若需要執行完整的jsbridge功能,還需要做一些額外的配置。例如告知前端這段js代碼已經注入成功的標記。

4.4 什麼時候注入js合適?

如果做過WebView開發,並且需要和js交互的同學,大部分都會認爲js在WebViewClient.onPageFinished()方法中注入最合適,此時dom樹已經構建完成,頁面已經完全展現出來13。但如果做過頁面加載速度的測試,會發現WebViewClient.onPageFinished()方法通常需要等待很久纔會回調(首次加載通常超過3s),這是因爲WebView需要加載完一個網頁裏主文檔和所有的資源纔會回調這個方法。能不能在WebViewClient.onPageStarted()中注入呢?答案是不確定。經過測試,有些機型可以,有些機型不行。在WebViewClient.onPageStarted()中注入還有一個致命的問題——這個方法可能會回調多次,會造成js代碼的多次注入。

另一方面,從7.0開始,WebView加載js方式發生了一些小改變,官方建議把js注入的時機放在頁面開始加載之後。援引官方的文檔^4

Javascript run before page load
Starting with apps targeting Android 7.0, the Javascript context will be reset when a new page is loaded. Currently, the context is carried over for the first page loaded in a new WebView instance.
Developers looking to inject Javascript into the WebView should execute the script after the page has started to load.

這篇文章中也提及了js注入的時機可以在多個回調裏實現,包括:

  • onLoadResource
  • doUpdateVisitedHistory
  • onPageStarted
  • onPageFinished
  • onReceivedTitle
  • onProgressChanged

儘管文章作者已經做了測試證明以上時機注入是可行的,但他不能完全保證沒有問題。事實也是,這些回調裏有多個是會回調多次的,不能保證一次注入成功。

WebViewClient.onPageStarted()太早,WebViewClient.onPageFinished()又太遲,究竟有沒有比較合適的注入時機呢?試試WebViewClient.onProgressChanged()?這個方法在dom樹渲染的過程中會回調多次,每次都會告訴我們當前加載的進度。這不正是告訴我們頁面已經開始加載了嗎?考拉正是使用了WebViewClient.onProgressChanged()方法來注入js代碼。

@Override
public void onProgressChanged(WebView view, int newProgress) {
    super.onProgressChanged(view, newProgress);
    if (null != mIWebViewClient) {
        mIWebViewClient.onProgressChanged(view, newProgress);
    }
    if (mCallProgressCallback && newProgress >= mProgressFinishThreshold) {
        DebugLog.d("WebView", "onProgressChanged: " + newProgress);
        mCallProgressCallback = false;
        // mJsApi不爲null且允許注入js的情況下,開始注入js代碼。
        if (mJsApi != null && WebJsManager.enableJs(view.getUrl())) {
            mJsApi.loadLocalJsCode();
        }
        if (mIWebViewClient != null) {
            mIWebViewClient.onPageFinished(view, newProgress);
        }
    }
}

可以看到,我們使用了mProgressFinishThreshold這個變量控制注入時機,這與前面提及的當progress達到80的時候,加載出來的頁面已經基本可用了是相呼應的。

達到80%很容易,達到100%卻很難。

正是因爲這個原因,頁面的進度加載到80%的時候,實際上dom樹已經渲染得差不多了,表明WebView已經解析了\標籤,這時候注入一定是成功的。在WebViewClient.onProgressChanged()實現js注入有幾個需要注意的地方:

  1. 上文提到的多次注入控制,我們使用了mCallProgressCallback變量控制
  2. 重新加載一個URL之前,需要重置mCallProgressCallback,讓重新加載後的頁面再次注入js
  3. 注入的進度閾值可以自由定製,理論上10%-100%都是合理的,我們使用了80%。

4.5 H5頁面、Weex頁面與Native頁面交互——KaolaRouter

H5頁面、Weex頁面與Native頁面的交互是通過URL攔截實現的。在WebView中,WebViewClient.shouldOverrideUrlLoading()方法能夠獲取到當前加載的URL,然後把URL傳遞給考拉路由框架,便可以判斷URL是否能夠跳轉到其他非H5頁面,考拉路由框架在《考拉Android客戶端路由總線設計》一文中有詳細介紹,但當時未引入Weex頁面,關於如何整合三者的通信,後續文章會有詳細介紹。

WebViewClient.shouldOverrideUrlLoading()中,根據URL類型做了判斷:

public boolean shouldOverrideUrlLoading(WebView view, String url) {
    if (StringUtils.isNotBlank(url) && url.equals("about:blank")) {   //js調用reload刷新頁面時候,個別機型跳到空頁面問題修復
        url = getUrl();
    }
    url = WebViewUtils.removeBlank(url);
    mCallProgressCallback = true;
    //允許啓動第三方應用客戶端
    if (WebViewUtils.canHandleUrl(url)) {
        boolean handleByCaller = false;
        // 如果不是用戶觸發的操作,就沒有必要交給上層處理了,直接走url攔截規則。
        if (null != mIWebViewClient && isTouchByUser()) {
        	// 先交給業務層攔截處理
            handleByCaller = mIWebViewClient.shouldOverrideUrlLoading(view, url);
        }
        if (!handleByCaller) {
        	// 業務層不攔截,走通用路由總線規則
            handleByCaller = handleOverrideUrl(url);
        }
        mRedirectProtected = true;
        return handleByCaller || super.shouldOverrideUrlLoading(view, url);
    } else {
        try {
            notifyBeforeLoadUrl(url);
            // https://sumile.cn/archives/1223.html
            Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
            intent.addCategory(Intent.CATEGORY_BROWSABLE);
            intent.setComponent(null);
            intent.setSelector(null);
            mContext.startActivity(intent);
            if (!mIsBlankPageRedirect) {
                back();
            }
        } catch (Exception e) {
            ExceptionUtils.printExceptionTrace(e);
        }
        return true;
    }
}
private boolean handleOverrideUrl(final String url) {
   RouterResult result =  WebActivityRouter.startFromWeb(
            new IntentBuilder(mContext, url).setRouterActivityResult(new RouterActivityResult() {
                @Override
                public void onActivityFound() {
                    if (!mIsBlankPageRedirect) {
                    	// 路由攔截成功以後,爲防止首次進入WebView產生白屏,因此加了保護機制
                        back();
                    }
                }
                @Override
                public void onActivityNotFound() {
                    
                }
            }));
    return result.isSuccess();
}

代碼裏寫了註釋,就不一一解釋了。

4.6 WebView下拉刷新實現

由於考拉使用的下拉刷新跟Material Design所使用的下拉刷新樣式不一致,因此不能直接套用SwipeRefreshLayout。考拉使用的是一套改造過的Android-PullToRefresh,WebView的下拉刷新,正是繼承自PullToRefreshBase來實現的。

/**
 * 創建者:Square Xu
 * 日期:2017/2/23
 * 功能模塊:webview下拉刷新組件
 */
public class PullToRefreshWebView extends PullToRefreshBase<KaolaWebview> {
    public PullToRefreshWebView(Context context) {
        super(context);
    }
    public PullToRefreshWebView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    public PullToRefreshWebView(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs);
    }
    public PullToRefreshWebView(Context context, Mode mode) {
        super(context, mode);
    }
    public PullToRefreshWebView(Context context, Mode mode, AnimationStyle animStyle) {
        super(context, mode, animStyle);
    }
    @Override
    public Orientation getPullToRefreshScrollDirection() {
        return Orientation.VERTICAL;
    }
    @Override
    protected KaolaWebview createRefreshableView(Context context, AttributeSet attrs) {
        KaolaWebview kaolaWebview = new KaolaWebview(context, attrs);
        //解決鍵盤彈起時候閃動的問題
        setGravity(AXIS_PULL_BEFORE);
        return kaolaWebview;
    }
    @Override
    protected boolean isReadyForPullEnd() {
        return false;
    }
    @Override
    protected boolean isReadyForPullStart() {
        return getRefreshableView().getScrollY() == 0;
    }
}

考拉使用了全屏模式實現沉浸式狀態欄及滑動返回,全屏模式和WebView下拉刷新相結合對鍵盤的彈起產生了閃動效果,經過組內大神的研究與多次調試(感謝@俊俊),發現setGravity(AXIS_PULL_BEFORE)能夠解決閃動的問題。

4.7 如何處理加載錯誤(Http、SSL、Resource)?

對於WebView加載一個網頁過程中所產生的錯誤回調,大致有三種:

  • WebViewClient.onReceivedHttpError(webView, webResourceRequest, webResourceResponse)

任何HTTP請求產生的錯誤都會回調這個方法,包括主頁面的html文檔請求,iframe、圖片等資源請求。在這個回調中,由於混雜了很多請求,不適合用來展示加載錯誤的頁面,而適合做監控報警。當某個URL,或者某個資源收到大量報警時,說明頁面或資源可能存在問題,這時候可以讓相關運營及時響應修改。

  • WebViewClient.onReceivedSslError(webview, sslErrorHandler, sslError)

任何HTTPS請求,遇到SSL錯誤時都會回調這個方法。比較正確的做法是讓用戶選擇是否信任這個網站,這時候可以彈出信任選擇框供用戶選擇(大部分正規瀏覽器是這麼做的)。但人都是有私心的,何況是遇到自家的網站時。我們可以讓一些特定的網站,不管其證書是否存在問題,都讓用戶信任它。在這一點上,分享一個小坑。考拉的SSL證書使用的是GeoTrust的GeoTrust SSL CA - G3,但是在某些機型上,打開考拉的頁面都會提示證書錯誤。這時候就不得不使用“絕招”——讓考拉的所有二級域都是可信任的。

@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
    if (UrlUtils.isKaolaHost(getUrl())) {
        handler.proceed();
    } else {
        super.onReceivedSslError(view, handler, error);
    }
}
  • WebViewClient.onReceivedError(webView, webResourceRequest, webResourceError)

只有在主頁面加載出現錯誤時,纔會回調這個方法。這正是展示加載錯誤頁面最合適的方法。然鵝,如果不管三七二十一直接展示錯誤頁面的話,那很有可能會誤判,給用戶造成經常加載頁面失敗的錯覺。由於不同的WebView實現可能不一樣,所以我們首先需要排除幾種誤判的例子:

  1. 加載失敗的url跟WebView裏的url不是同一個url,排除;
  2. errorCode=-1,表明是ERROR_UNKNOWN的錯誤,爲了保證不誤判,排除
  3. failingUrl=null&errorCode=-12,由於錯誤的url是空而不是ERROR_BAD_URL,排除
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
    super.onReceivedError(view, errorCode, description, failingUrl);
    // -12 == EventHandle.ERROR_BAD_URL, a hide return code inside android.net.http package
    if ((failingUrl != null && !failingUrl.equals(view.getUrl()) && !failingUrl.equals(view.getOriginalUrl())) /* not subresource error*/
            || (failingUrl == null && errorCode != -12) /*not bad url*/
            || errorCode == -1) { //當 errorCode = -1 且錯誤信息爲 net::ERR_CACHE_MISS
        return;
    }
    if (!TextUtils.isEmpty(failingUrl)) {
        if (failingUrl.equals(view.getUrl())) {
            if (null != mIWebViewClient) {
                mIWebViewClient.onReceivedError(view);
            }
        }
    }
}

4.8 如何操作cookie?

Cookie默認情況下是不需要做處理的,如果有特殊需求,如針對某個頁面設置額外的Cookie字段,可以通過代碼來控制。下面列出幾個有用的接口:

  • 獲取某個url下的所有Cookie:CookieManager.getInstance().getCookie(url)
  • 判斷WebView是否接受Cookie:CookieManager.getInstance().acceptCookie()
  • 清除Session Cookie:CookieManager.getInstance().removeSessionCookies(ValueCallback<Boolean> callback)
  • 清除所有Cookie:CookieManager.getInstance().removeAllCookies(ValueCallback<Boolean> callback)
  • Cookie持久化:CookieManager.getInstance().flush()
  • 針對某個主機設置Cookie:CookieManager.getInstance().setCookie(String url, String value)

4.9 如何調試WebView加載的頁面?

在Android 4.4版本以後,可以使用Chrome開發者工具調試WebView內容^5。調試需要在代碼裏設置打開調試開關。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    WebView.setWebContentsDebuggingEnabled(true);
}

開啓後,使用USB連接電腦,加載URL時,打開Chrome開發者工具,在瀏覽器輸入

chrome://inspect

可以看到當前正在瀏覽的頁面,點擊inspect即可看到WebView加載的內容。

5. WebView優化

除了上面提到的基本操作用來實現一個完整的瀏覽器功能外,WebView的加載速度、穩定性和安全性是可以進一步加強和提高的。下面從幾個方面介紹一下WebView的優化方案,這些方案可能並不是都適用於所有場景,但思路是可以借鑑的。

5.1 CandyWebCache

我們知道,在加載頁面的過程中,js、css和圖片資源佔用了大量的流量,如果這些資源一開始就放在本地,或者只需要下載一次,後面重複利用,豈不美哉。儘管WebView也有幾套緩存方案^6,但是總體而言效果不理想。基於自建緩存系統的思路,由網易杭研研發的CandyWebCache項目應運而生。CandyWebCache是一套支持離線緩存WebView資源並實時更新遠程資源的解決方案,支持打母包時下載當前最新的資源文件集成到apk中,也支持在線實時更新資源。在WebView中,我們需要攔截WebViewClient.shouldInterceptRequest()方法,檢測緩存是否存在,存在則直接取本地緩存數據,減少網絡請求產生的流量。

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
    if (WebSwitchManager.isWebCacheEnabled()) {
        try {
            WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, request);
            return WebViewUtils.handleResponseHeader(resourceResponse);
        } catch (Throwable e) {
            ExceptionUtils.uploadCatchedException(e);
        }
    }
    return super.shouldInterceptRequest(view, request);
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
    if (WebSwitchManager.isWebCacheEnabled()) {
        try {
            WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, url);
            return WebViewUtils.handleResponseHeader(resourceResponse);
        } catch (Throwable e) {
            ExceptionUtils.uploadCatchedException(e);
        }
    }
    return super.shouldInterceptRequest(view, url);
}

除了上述緩存方案外,騰訊的QQ會員團隊也推出了開源的解決方案VasSonic,旨在提升H5的頁面訪問體驗,但最好由前後端一起配合改造。這套整體的解決方案有很多借鑑意義,考拉也在學習中。

5.2 Https、HttpDns、CDN

將http請求切換爲https請求,可以降低運營商網絡劫持(js劫持、圖片劫持等)的概率,特別是使用了http2後,能夠大幅提升web性能,減少網絡延遲,減少請求的流量。

HttpDns,使用http協議向特定的DNS服務器進行域名解析請求,代替基於DNS協議向運營商的Local DNS發起解析請求,可以降低運營商DNS劫持帶來的訪問失敗。目前在WebView上使用HttpDns尚存在一定問題,網上也沒有較好的解決方案(阿里雲Android WebView+HttpDns最佳實踐騰訊雲HttpDns SDK接入webview接入HttpDNS實踐),因此還在調研中。

另一方面,可以把靜態資源部署到多路CDN,直接通過CDN地址訪問,減少網絡延遲,多路CDN保障單個CDN大面積節點訪問失敗時可切換到備用的CDN上。

5.3 WebView獨立進程

WebView實例在Android7.0系統以後,已經可以選擇運行在一個獨立進程上^7;8.0以後默認就是運行在獨立的沙盒進程中^8,未來Google也在朝這個方向發展。

Android7.0系統以後,WebView相對來說是比較穩定的,無論承載WebView的容器是否在主進程,都不需要擔心WebView崩潰導致應用也跟着崩潰。然後7.0以下的系統就沒有這麼幸運了,特別是低版本的WebView。考慮應用的穩定性,我們可以把7.0以下系統的WebView使用一個獨立進程的Activity來包裝,這樣即使WebView崩潰了,也只是WebView所在的進程發生了崩潰,主進程還是不受影響的。

public static Intent getWebViewIntent(Context context) {
    Intent intent;
    if (isWebInMainProcess()) {
        intent = new Intent(context, MainWebviewActivity.class);
    } else {
        intent = new Intent(context, WebviewActivity.class);
    }
    return intent;
}
public static boolean isWebInMainProcess() {
    return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N;
}

5.4 WebView免流

從去年開始,市場上出現了一批互聯網套餐卡,如騰訊王卡、螞蟻寶卡、京東強卡、阿里魚卡、網易白金卡等,這些互聯網套餐相比傳統的運營商套餐來說,資費便宜,流量多,甚至某些卡還擁有特殊權限——對某些應用免流。如網易白金卡,對於網易系與百度系的部分應用實現免流。

5.4.1 免流原理

市面上常見的免流應用,原理無非就是走“特殊通道”,讓這一部分的流量不計入運營商的流量統計平臺中。Android中要實現這種“特殊通道”,有幾種方案。

  • 微屁恩。目前運營商貌似沒有采用這種方案,但確實是可行的。由於國情,不多介紹,懂的自然懂。
  • 全局代理。把所有的流量中轉到代理服務器中,代理服務器再根據流量判斷是否屬於免流流量。
  • IP直連。走這個IP的所有流量,服務器判斷是否免流。

5.4.2 WebView免流方案

對於上面提到的幾種方案,native頁面所有的請求都是應用層發起的,實際上都比較好實現,但WebView的頁面和資源請求是通過JNI發起的,想要攔截請求的話,需要一些功夫。網羅網上的所有方案,目前覺得可行的有兩種,分別是全局代理和攔截WebViewClient.shouldInterceptRequest()

5.4.2.1 全局代理

由於WebView並沒有提供接口針對具體的WebView實例設置代理,所以我們只能進行全局代理。設置全局代理時,需要通知系統代理環境發生了改變,不幸地是,Android並沒有提供公開的接口,這就導致了我們只能hook系統接口,根據不同的系統版本來實現通知的目的9、10。6.0以後的系統,尚未嘗試是否可行,根據公司同事的反饋,和5.0系統的方案是一致的。

/**
 * Set Proxy for Android 4.1 - 4.3.
 */
@SuppressWarnings("all")
private static boolean setProxyJB(WebView webview, String host, int port) {
    Log.d(LOG_TAG, "Setting proxy with 4.1 - 4.3 API.");
    try {
        Class wvcClass = Class.forName("android.webkit.WebViewClassic");
        Class wvParams[] = new Class[1];
        wvParams[0] = Class.forName("android.webkit.WebView");
        Method fromWebView = wvcClass.getDeclaredMethod("fromWebView", wvParams);
        Object webViewClassic = fromWebView.invoke(null, webview);
        Class wv = Class.forName("android.webkit.WebViewClassic");
        Field mWebViewCoreField = wv.getDeclaredField("mWebViewCore");
        Object mWebViewCoreFieldInstance = getFieldValueSafely(mWebViewCoreField, webViewClassic);
        Class wvc = Class.forName("android.webkit.WebViewCore");
        Field mBrowserFrameField = wvc.getDeclaredField("mBrowserFrame");
        Object mBrowserFrame = getFieldValueSafely(mBrowserFrameField, mWebViewCoreFieldInstance);
        Class bf = Class.forName("android.webkit.BrowserFrame");
        Field sJavaBridgeField = bf.getDeclaredField("sJavaBridge");
        Object sJavaBridge = getFieldValueSafely(sJavaBridgeField, mBrowserFrame);
        Class ppclass = Class.forName("android.net.ProxyProperties");
        Class pparams[] = new Class[3];
        pparams[0] = String.class;
        pparams[1] = int.class;
        pparams[2] = String.class;
        Constructor ppcont = ppclass.getConstructor(pparams);
        Class jwcjb = Class.forName("android.webkit.JWebCoreJavaBridge");
        Class params[] = new Class[1];
        params[0] = Class.forName("android.net.ProxyProperties");
        Method updateProxyInstance = jwcjb.getDeclaredMethod("updateProxy", params);
        updateProxyInstance.invoke(sJavaBridge, ppcont.newInstance(host, port, null));
    } catch (Exception ex) {
        Log.e(LOG_TAG, "Setting proxy with >= 4.1 API failed with error: " + ex.getMessage());
        return false;
    }
    Log.d(LOG_TAG, "Setting proxy with 4.1 - 4.3 API successful!");
    return true;
}
/**
 * Set Proxy for Android 5.0.
 */
public static void setWebViewProxyL(Context context, String host, int port) {
    System.setProperty("http.proxyHost", host);
    System.setProperty("http.proxyPort", port + "");
    try {
        Context appContext = context.getApplicationContext();
        Class applictionClass = Class.forName("android.app.Application");
        Field mLoadedApkField = applictionClass.getDeclaredField("mLoadedApk");
        mLoadedApkField.setAccessible(true);
        Object mloadedApk = mLoadedApkField.get(appContext);
        Class loadedApkClass = Class.forName("android.app.LoadedApk");
        Field mReceiversField = loadedApkClass.getDeclaredField("mReceivers");
        mReceiversField.setAccessible(true);
        ArrayMap receivers = (ArrayMap) mReceiversField.get(mloadedApk);
        for (Object receiverMap : receivers.values()) {
            for (Object receiver : ((ArrayMap) receiverMap).keySet()) {
                Class clazz = receiver.getClass();
                if (clazz.getName().contains("ProxyChangeListener")) {
                    Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class, Intent.class);
                    Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
                    onReceiveMethod.invoke(receiver, appContext, intent);
                }
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

需要注意的是,在WebView退出時,需要重置代理。

5.4.2.1 攔截WebViewClient.shouldInterceptRequest()

攔截WebViewClient.shouldInterceptRequest()的目的是使用免流的第三種方案——IP替換。直接看代碼。

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
    WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, request);
    if (request.getUrl() != null && request.getMethod().equalsIgnoreCase("get")) {
        Uri uri = request.getUrl();
        String url = uri.toString();
        String scheme = uri.getScheme().trim();
        String host = uri.getHost();
        String path = uri.getPath();
        if (TextUtils.isEmpty(path) || TextUtils.isEmpty(host)) {
            return null;
        }
        // HttpDns解析css文件的網絡請求及圖片請求
        if ((scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) && (path.endsWith(".css")
                || path.endsWith(".png")
                || path.endsWith(".jpg")
                || path.endsWith(".gif")
                || path.endsWith(".js"))) {
            try {
                URL oldUrl = new URL(uri.toString());
                URLConnection connection;
                // 獲取HttpDns域名解析結果
                List<String> ips = HttpDnsManager.getInstance().getIPListByHostAsync(host);
                if (!ListUtils.isEmpty(ips)) {
                    String ip = ips.get(0);
                    String newUrl = url.replaceFirst(oldUrl.getHost(), ip);
                    connection = new URL(newUrl).openConnection(); // 設置HTTP請求頭Host域
                    connection.setRequestProperty("Host", oldUrl.getHost());
                } else {
                    connection = new URL(url).openConnection(); // 設置HTTP請求頭Host域
                }
                String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
                String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension);
                return new WebResourceResponse(mimeType, "UTF-8", connection.getInputStream());
            } catch (MalformedURLException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    return super.shouldInterceptRequest(view, request);
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
    if (!TextUtils.isEmpty(url) && Uri.parse(url).getScheme() != null) {
        Uri uri = Uri.parse(url);
        String scheme = uri.getScheme().trim();
        String host = uri.getHost();
        String path = uri.getPath();
        if (TextUtils.isEmpty(path) || TextUtils.isEmpty(host)) {
            return null;
        }
        // HttpDns解析css文件的網絡請求及圖片請求
        if ((scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) && (path.endsWith(".css")
                || path.endsWith(".png")
                || path.endsWith(".jpg")
                || path.endsWith(".gif")
                || path.endsWith(".js"))) {
            try {
                URL oldUrl = new URL(uri.toString());
                URLConnection connection;
                // 獲取HttpDns域名解析結果
                List<String> ips = HttpDnsManager.getInstance().getIPListByHostAsync(host);
                if (!ListUtils.isEmpty(ips)) {
                    String ip = ips.get(0);
                    String newUrl = url.replaceFirst(oldUrl.getHost(), ip);
                    connection = new URL(newUrl).openConnection(); // 設置HTTP請求頭Host域
                    connection.setRequestProperty("Host", oldUrl.getHost());
                } else {
                    connection = new URL(url).openConnection(); // 設置HTTP請求頭Host域
                }
                String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
                String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension);
                return new WebResourceResponse(mimeType, "UTF-8", connection.getInputStream());
            } catch (MalformedURLException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    return super.shouldInterceptRequest(view, url);
}

使用此種方案,還可以把WebView網絡請求與native網絡請求使用的框架統一起來,方便管理。

6. 總結

本文前半部分介紹了目前Android裏的WebView現狀,以及由於現狀的不可改變導致遺留下的一些坑。所幸,世界上沒有什麼代碼問題是一個程序員不能解決的,如果有,那就用兩個程序員解決。

本文後半部分介紹了WebView在開發中的一些實踐經驗和優化流程。爲了滿足業務需求,WebView着實提供了非常豐富的接口供應用層處理業務邏輯。針對WebView的二次開發,本文介紹了一些常用的回調處理邏輯以及開發過程中總結下的經驗,以及爲了用戶更好的體驗,提出的一些WebView優化策略。由於是經驗,不一定是準確的,若有錯誤的地方,敬請指出糾正,不勝感激!

參考鏈接(一)

  • https://developer.chrome.com/multidevice/webview/overview
  • https://developer.android.com/about/versions/nougat/android-7.0.html#webview
  • https://developer.android.com/about/versions/oreo/android-8.0-changes.html#o-sec
  • https://stackoverflow.com/questions/30078217/why-openfilechooser-in-webchromeclient-is-hidden-from-the-docs-is-it-safe-to-us
  • http://blog.csdn.net/self_study/article/details/55046348
  • http://qbeenslee.com/article/android-webview-302-redirect/
  • https://juejin.im/entry/5977598d51882548c0045bde
  • http://www.cnblogs.com/zimengfang/p/6183869.html
  • http://blog.csdn.net/dg_summer/article/details/78105582
  • https://issuetracker.google.com/issues/36918490

參考鏈接(二)

  • https://medium.com/@filipe.batista/inject-javascript-into-webview-2b702a2a029f
  • https://stackoverflow.com/questions/21552912/android-web-view-inject-local-javascript-file-to-remote-webpage
  • https://stackoverflow.com/questions/23172247/android-webview-javascript-injection
  • https://developer.android.com/about/versions/nougat/android-7.0.html#webview
  • https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews
  • https://www.jianshu.com/p/5e7075f4875f
  • https://developer.android.com/about/versions/nougat/android-7.0.html#webview
  • https://developer.android.com/about/versions/oreo/android-8.0-changes.html#security-all
  • https://stackoverflow.com/questions/25272393/android-webview-set-proxy-programmatically-on-android-l/25485747#25485747
  • https://stackoverflow.com/questions/4488338/webview-android-proxy

搬運於兩個博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章