如何設計一個優雅健壯的Android WebView?(上)
前言
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 voidsetDefaultWebSettings(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以后,服務器做了哪些響應,又下發了哪些資源,這些資源的作用是怎么樣的。
為什么Github上的WebView項目不適用?
從上面的鏈接可以看到,Github上面star過千的WebView項目主要是FinestWebView-Android和Android-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.lxweimin.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(newWebViewClient() {? ? @Override? ? public boolean shouldOverrideUrlLoading(WebView view, String url) {? ? view.loadUrl(url);returntrue;? ? }? ? ...)復制代碼
這種方法最致命的弱點就是如果不經過特殊處理,那么按返回鍵是沒有效果的,還會停留在302之前的頁面。現有的解決方案無非就幾種:
手動管理回退棧,遇到重定向時回退兩次^6。
通過HitTestResult判斷是否是重定向,從而決定是否自己加載url^7?^8。
通過設置標記位,在onPageStarted和onPageFinished分別標記變量避免重定向^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 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,? ? ? ? ? ? StringhistoryUrl) {? ? ? ? super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding,historyUrl);? ? ? ? resetAllStateInternal(getUrl());? ? }? ? @Override? ? public voidreload() {? ? ? ? super.reload();? ? ? ? resetAllStateInternal(getUrl());? ? }? ? public booleanisTouchByUser() {returnmTouchByUser;? ? }? ? private void resetAllStateInternal(String url) {if(!TextUtils.isEmpty(url) && url.startsWith("javascript:")) {return;? ? ? ? }? ? ? ? resetAllState();? ? }// 加載url時重置touch狀態? ? protected voidresetAllState() {? ? ? ? mTouchByUser =false;? ? }? ? @Override? ? public boolean onTouchEvent(MotionEvent event) {? ? ? ? switch (event.getAction()) {caseMotionEvent.ACTION_DOWN:? ? ? ? ? ? //用戶按下到下一個鏈接加載之前,置為truemTouchByUser =true;break;? ? ? ? }returnsuper.onTouchEvent(event);? ? }? ? @Override? ? public voidsetWebViewClient(final WebViewClient client) {? ? ? ? super.setWebViewClient(newWebViewClient() {? ? ? ? ? ? @Override? ? ? ? ? ? public boolean shouldOverrideUrlLoading(WebView view, String url) {? ? ? ? ? ? ? ? boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, url);if(handleByChild) {? ? ? ? ? ? // 開放client接口給上層業務調用,如果返回true,表示業務已處理。returntrue;? ? ? ? ? ? ? }elseif(!isTouchByUser()) {? ? ? ? ? ? // 如果業務沒有處理,并且在加載過程中用戶沒有再次觸摸屏幕,認為是301/302事件,直接交由系統處理。returnsuper.shouldOverrideUrlLoading(view, url);? ? ? ? ? ? ? ? }else{? ? ? ? ? ? ? ? //否則,屬于二次加載某個鏈接的情況,為了解決拼接參數丟失問題,重新調用loadUrl方法添加固有參數。? ? ? ? ? ? ? ? ? ? loadUrl(url);returntrue;? ? ? ? ? ? ? ? }? ? ? ? ? ? }? ? ? ? ? ? @RequiresApi(api = Build.VERSION_CODES.N)? ? ? ? ? ? @Override? ? ? ? ? ? public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {? ? ? ? ? ? ? ? boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, request);if(handleByChild) {returntrue;? ? ? ? ? ? ? ? }elseif(!isTouchByUser()) {returnsuper.shouldOverrideUrlLoading(view, request);? ? ? ? ? ? ? ? }else{? ? ? ? ? ? ? ? ? ? loadUrl(request.getUrl().toString());returntrue;? ? ? ? ? ? ? ? }? ? ? ? ? ? }? ? ? ? });? ? }}復制代碼
上述代碼解決了正常情況下的回退棧問題。
解決業務白屏問題
為了解決白屏問題,考拉目前的解決思路和上面的回退棧問題思路有些類似,通過監聽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 voidback() {if(mBackStep < 1) {? ? ? ? ? ? mJsApi.trigger2("kaolaGoback");? ? ? ? }else{? ? ? ? ? ? realBack();? ? ? ? }? ? }? ? @Override? ? public boolean dispatchTouchEvent(MotionEvent ev) {if(ev.getAction() == MotionEvent.ACTION_UP) {? ? ? ? ? ? mIsBlankPageRedirect =true;? ? ? ? }returnsuper.dispatchTouchEvent(ev);? ? }? ? private WebViewClient mWebViewClient = newWebViewClient() {? ? ? ? @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);? ? ? ? ? ? ? ? }returnhandleByCaller || 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);? ? ? ? ? ? ? ? }returntrue;? ? ? ? ? ? }? ? ? ? }? ? ? ? @RequiresApi(Build.VERSION_CODES.LOLLIPOP)? ? ? ? @Override? ? ? ? public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {returnshouldOverrideUrlLoading(view, request.getUrl().toString());? ? ? ? }? ? ? ? ? ? ? ? private boolean handleOverrideUrl(final String url) {? ? ? ? ? RouterResult result =? WebActivityRouter.startFromWeb(? ? ? ? ? ? ? ? ? ? new IntentBuilder(mContext, url).setRouterActivityResult(newRouterActivityResult() {? ? ? ? ? ? ? ? ? ? ? ? @Override? ? ? ? ? ? ? ? ? ? ? ? public voidonActivityFound() {if(!mIsBlankPageRedirect) {? ? ? ? ? ? ? ? ? ? // 路由已經攔截到跳轉到native頁面,但此時可能發生了? ? ? ? ? ? ? ? ? ? // 301/302跳轉,那么執行后退動作,防止白屏。? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? back();? ? ? ? ? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? ? ? ? ? @Override? ? ? ? ? ? ? ? ? ? ? ? public voidonActivityNotFound() {if(mIWebViewClient != null) {? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? mIWebViewClient.onActivityNotFound();? ? ? ? ? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? ? ? }));returnresult.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 installedat 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 WebViewFactoryProvidergetProvider() {? ? synchronized (sProviderLock) {? ? ? ? // For now the main purpose of thisfunction(and the factory abstraction) is to keep? ? ? ? // us honest and minimize usage of WebView internals when binding the proxy.if(sProviderInstance != null)returnsProviderInstance;? ? ? ? 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 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);returnsProviderInstance;? ? ? ? ? ? } 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 ClassgetProviderClass() {? ? Context webViewContext = null;? ? Application initialApplication = AppGlobals.getInitialApplication();? ? try {? ? //獲取WebView上下文并設置provider? ? ? ? webViewContext = getWebViewContextAndSetProvider();? ? } finally {? ? ? ? Trace.traceEnd(Trace.TRACE_TAG_WEBVIEW);? ? } 代碼省略...? ? }}private static ContextgetWebViewContextAndSetProvider() {? ? 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優化策略,敬請期待。
參考鏈接
developer.chrome.com/multidevice…
developer.android.com/about/versi…
developer.android.com/about/versi…
stackoverflow.com/questions/3…
issuetracker.google.com/issues/3691…
原文鏈接