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


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

前言

在上文《如何設計一個優雅健壯的Android WebView?(上)》中,筆者分析了國內WebView的現狀,以及在WebView開發過程中所遇到的一些坑。在踩坑的基礎上,本文着重介紹WebView在開發過程中所需要注意的問題,這些問題大部分在網上找不到標准答案,但卻是WebView開發過程中幾乎都會遇到的。此外還會淺談WebView優化,旨在給用戶帶來更好的WebView體驗。

WebView實戰操作

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

WebView初始化

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

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

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

接下來,試想如果訪問的網頁返回的請求是30X,如使用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); } 

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的時候,加載出來的頁面已經基本可用了。因此,可以投機取巧,達到80%以后便可以認為進度條到100%了,事實上,國內廠商大部分都會提前隱藏進度條,讓用戶以為網頁加載很快。
    • 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)判斷是否可以回退。

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方法不確定是在什么時候回調,官方文檔也沒有說明這個方法是在主線程調用還是異步線程,因此判斷一下Activity的生命周期是有必要的。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 filterObj(obj){ for(var i in obj){ if (obj.hasOwnProperty(i)) { if(typeof obj[i] == 'string'){ obj[i] = obj[i].replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, ''); } } } return obj; } function _nativeExec(){ var command = commandQueue.dequeue(); if(command) { nativeReady = false; var jsoncommand = JSON.stringify(command); // 做了base64轉換。 var _temp = prompt(YIXIN_JSBRIDGE + base64encode(UTF8.encode(jsoncommand)),''); return true; } else { return false; } } 

前端真正需要調用Java方法時,執行window.WeiXinJSBridge.call方法。

function doCall(request, success_cb, error_cb) { if (jsonRPCIdTag in request && typeof success_cb !== 'undefined') { _callbacks[request.id] = { success_cb: success_cb, error_cb: error_cb }; } commandQueue.enqueue(request); if(nativeReady) { _nativeExec(); } } jsonRPC.call = function(method, params, success_cb, error_cb) { var request = { jsonrpc : jsonRPCVer, method : method, params : params, id : _current_id++ }; doCall(request, success_cb, error_cb); }; jsonRPC.notify = function(method, params) { var request = { jsonrpc : jsonRPCVer, method : method, params : params, }; doCall(request, null, null); }; jsonRPC.ready = function() { jsonRPC.nativeEvent.on('NativeReady', function(e) { nativeReady = false; if(!_nativeExec()) { nativeReady = true; } }); jsonRPC.nativeEvent.Trigger('WeixinJSBridgeReady'); }; jsonRPC.invokeFinish = function() { nativeReady = true; _nativeExec(); }; jsonRPC.nativeEvent = {}; jsonRPC.nativeEvent.Trigger = function(type, detail) { var ev = YixinEvent(type,detail); document.dispatchEvent(ev); }; var nativeEvent = {}; var doc = document; window.WeixinJSBridge = {}; window.jsonRPC = jsonRPC; window.WeixinJSBridge.call = jsonRPC.notify; })(); 

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

什么時候注入js合適?

如果做過WebView開發,並且需要和js交互的同學,大部分都會認為js在WebViewClient.onPageFinished()方法中注入最合適,此時dom樹已經構建完成,頁面已經完全展現出來^1^3。但如果做過頁面加載速度的測試,會發現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已經解析了<html>標簽,這時候注入一定是成功的。在WebViewClient.onProgressChanged()實現js注入有幾個需要注意的地方:

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

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

代碼里寫了注釋,就不一一解釋了。

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)能夠解決閃動的問題。

如何處理加載錯誤(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); } } } } 

如何操作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)

下面是一個給考拉M站設置Cookie的例子:

public static void setBoundCookies() { CookieSyncManager.createInstance(HTApplication.getInstance()); long expiredTime = System.currentTimeMillis() + 10 * 60 * 1000; CookieManager cookieManager = CookieManager.getInstance(); cookieManager.setAcceptCookie(true); cookieManager.setCookie(NetConfig.KAOLA_M_HOST, String.format("Expires=%s; domain=.kaola.com; path=/", expiredTime)); cookieManager.setCookie(NetConfig.KAOLA_M_HOST, "KAOLA_CLEAR_RELATION=1; domain=.kaola.com; path=/"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { CookieManager.getInstance().flush(); } else { CookieSyncManager.getInstance().sync(); } } 

如何調試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加載的內容。

WebView優化

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

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的頁面訪問體驗,但最好由前后端一起配合改造。這套整體的解決方案有很多借鑒意義,考拉也在學習中。

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上。

WebView獨立進程

WebView實例在Android7.0系統以后,已經可以選擇運行在一個獨立進程上^7;8.0以后默認就是運行在獨立的沙盒進程中^8,未來Google也在朝這個方向發展,具體的WebView歷史可以參考上一篇文章《如何設計一個優雅健壯的Android WebView?(上)》第一小節。

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; } 

WebView免流實現(待實現)

  1. 全局代理
  2. WebViewClient.shouldInterceptRequest(),IP替換

作者:網易考拉移動端團隊
鏈接:https://juejin.im/post/5a94fb046fb9a0635865a2d6
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

作者:網易考拉移動端團隊
鏈接:https://juejin.im/post/5a94fb046fb9a0635865a2d6
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM