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

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

(這篇文章寫得有點晚,請諒解~)

前言

在上文《如何設計一個優雅健壯的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的時候,載入出來的頁面已經基本可用了。事實上,國內廠商大部分都會提前隱藏進度條,讓使用者以為網頁載入很快。
    • 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方法最終是在主執行緒中回撥,判斷一下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程式碼已經注入成功的標記。

什麼時候注入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)

如何除錯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免流

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

免流原理

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

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

WebView免流方案

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

全域性代理

由於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退出時,需要重置代理。

攔截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網路請求使用的框架統一起來,方便管理。

總結

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


參考連結

  1. medium.com/@filipe.bat…
  2. stackoverflow.com/questions/2…
  3. stackoverflow.com/questions/2…
  4. developer.android.com/about/versi…
  5. developers.google.com/web/tools/c…
  6. www.jianshu.com/p/5e7075f48…
  7. developer.android.com/about/versi…
  8. developer.android.com/about/versi…
  9. stackoverflow.com/questions/2…
  10. stackoverflow.com/questions/4…

相關文章