懂球帝Android客戶端WebView優化之路

H5頁面承載了懂球帝文章、活動、廣告等核心業務場景,因此通過了長期的迭代以後,懂球帝客戶端H5相關的業務也很是複雜,這裏麪包含了分享、支付、用戶評論、點贊等交互,各類業務交織雜糅在一塊兒,致使這一塊的代碼難以維護。筆者對業務進行了全面的梳理,在重構這塊業務的過程當中也收穫了不少,同時考慮到不少產品都有類似的應用場景,分享出來但願對你們有幫助。 介紹下全文的結構和思路,首先筆者對現有業務進行抽象,提取出其中的調用關係和信息流,第二部分簡單地介紹了項目中以前採用的方案和缺點。第三部分着重介紹了新的設計架構。第四部分則是基於新的架構,實現了一些優化的功能,好比緩存和HttpDns的功能。javascript

1、業務場景

首先咱們分析一下通常App中,H5相關的業務場景和這些業務場景中的信息流。H5業務場景裏面包含了幾個角色:WebView是顯示H5的控件;Activity/Fragment等嵌入有WebView的界面H5頁面JsBridge是H5和客戶端互相調用的橋樑,這裏面用戶直接打交道的是界面和H5頁面。基本上,大部分業務場景能夠概括爲如下幾種css

  1. 用戶與H5交互,須要調用客戶端某項功能,好比用戶點擊了H5的按鈕參加某個活動,這時候H5須要獲取客戶端用戶的登陸信息。

  1. 用戶與界面交互,須要調用H5的某項功能,好比用戶下拉刷新,這時候須要通知H5去對數據作更新

  1. 界面的某些非用戶行爲觸發H5的相關操做,好比在Acitivty的生命週期中調用WebView或者H5頁面進行一些操做

  1. Webview自身的狀態改變須要調用界面,好比在WebView中WebviewClient和WebChromeClient的幾個回調onPageStarted、onPageFinished等方法,須要分別通知界面和H5進行處理

業務邏輯多是以上四種業務場景之一,複雜的業務場景也多是這幾種業務場景的組合,好比用戶點擊H5中的點贊按鈕,這時候調用界面的登陸功能,登陸完成後,界面調用H5的功能執行刷新界面等操做。html

2、原有實現邏輯

原有的實現邏輯也比較簡單粗暴,就是在界面和Webview中間增長一個WebviewManager,負責這二者之間的通訊。前端

在WebView和H5之間增長一個BrigeHelper負責管理JsBridge的交互。一開始可能沒什麼大問題,可是隨着業務代碼的積累,就會發現幾個問題:java

  1. WebviewManager和BrigeHelper的功能愈來愈多,愈來愈多的處理邏輯和代碼往這裏面堆
  2. 因爲通訊是雙向的,也就意味着WebviewManager和Bridgehelper必須提供雙向通訊的接口,可是隨着業務的增加,接口也臃腫不堪,接口粒度過大致使有些頁面只須要接口中的部分信息,也被迫去實現整個接口。並且,一有新的功能就必需要改接口,這也不符合設計規範
  3. 全部業務都糅合在WebviewManager和BrigeHelper中,致使各個業務很差拆分甚至會互相影響。好比登陸和支付以後返回當前頁面均可能會有刷新頁面的需求,這兩個業務刷新頁面的部分就雜糅在一塊兒,天長日久後,就沒人知道這一塊業務究竟是作什麼的
  4. WebviewManager和BridgeHelper沒有任何約束,中間各個對象互相調用,有着千絲萬縷的聯繫
  5. 各個頁面對Webview的使用方式也不太一致

3、重構的方案

經過前面的業務介紹能夠發現,H5和native之間的交互多且雜。筆者在項目中也嘗試去使用Cordova的方案,可是Cordova過於龐大複雜,客戶端和前端要徹底遷移到這上面,特別是對於懂球帝這樣迭代了幾年的項目,成本太高。出於實際的考慮,筆者準備探尋其餘的解決方案。不過看了Cordova的源碼也給了我必定的思路。 經過進一步對業務場景的抽象,筆者初步傾向於把不少業務以及JsBridge都抽離出來互相獨立,好比分享的業務、支付的業務、用戶相關的業務等,這種情境就很是適合用策略模式,使用策略模式有幾個優勢:android

  • 把各個業務封裝在獨立的策略中就能夠不互相影響;
  • 策略也能夠自由組合,給界面和H5提供不一樣的功能;
  • 策略模式能夠方便擴展,擴展策略接口就行了

固然策略模式也有缺點,就是使用者必需要了解各個業務,並且策略模式會多出來不少類,目前看,應該是優勢大於缺點git

定下來使用策略模式後,就面臨一個問題,如何定義策略接口。簡單的策略模式接口只有一個接口,可是在咱們的場景中,策略的觸發會比較複雜,在第一節中咱們就分析發現,策略多是用戶觸發的、界面的生命週期觸發、H5經過JsBridge的調用以及WebViewClient等相關回調觸發,因此咱們要對策略模式作一些變形,將一個接口拆分紅多個接口,而後將策略的接口改成抽象類實現:

  • Activity/Fragment的生命週期能夠從界面的LifeCycle中得到,實現LifeCycleObserver就能夠了github

  • Webview的觸發事件定義爲IWebviewCallback,這裏麪包含策略裏面須要用到的主要方法web

public interface IWebviewCallback {
    void onPageFinished();
    void onPageStart();
    boolean shouldOverrideUrlLoading(WebView webView, String url);
    WebResourceResponse shouldInterceptRequest(WebView view, final WebResourceRequest webResourceRequest);
    boolean onShowFileChooser(ValueCallback<Uri[]> filePathCallback,WebChromeClient.FileChooserParams fileChooserParams);
    void onLoadResource(WebView webView, String url);
    ......
}
複製代碼
  • JsBridge觸發的定義爲IBridgeHandler
public interface IBridgeHandler {
    /** * bridge名稱 * @return */
    public String[] getName();

    /** * Birdge觸發時調用調用 * @param jsonObject * @param callBackFunction */
    public void onHandle(String functionName, JSONObject jsonObject, CallBackFunction callBackFunction);
}

複製代碼
  • 策略自身被加載和卸載時的生命週期的監聽
public interface IPluginLifeCycle {
    /** * 插件被添加的時候調用 */
    void onPluginAdded();
    /** * 插件被移除的時候調用 */
    void onPluginRemoved();
}

複製代碼

接下來定義策略的接口IWebviewplugin繼承者四個接口apache

public abstract class IWebviewPlugin implements GenericLifecycleObserver, IPluginLifeCycle, IWebviewCallback, IBridgeHandler {
}
複製代碼

這樣策略的接口就定義好了,而且具有了處理來自WebView、界面生命週期、JsBridge以及自身生命週期的能力。經過這四個接口,咱們就能實現第一部分中介紹的業務場景的功能 接口定義好以後,整個框架就呼之欲出了:

除了以上介紹的幾個接口,咱們還須要幾個接口和類:

  • WebHostCallback:由界面實現,能夠獲取WebView的狀態

  • PageInterface: 因爲使用WebView的多是Activity,也有多是Fragment甚至是一個ViewGroup,因此須要對界面的功能進行抽象,提供一個可供WebView使用的一些功能,好比打開一個Activity等

  • PluginManager:策略的管理類,能夠動態組合管理策略集合

  • WebviewWrapper:WebView的包裝類,這裏採用抽象WebView的一些Api而後包裝的形式而不是繼承WebView來擴展功能,主要是考慮到使用者可能繼承WebView實現本身的一些功能,甚至是將來可能更換系統的WebView採用第三方的內核

  • PluginFactory:生產策略的工廠

能夠看到,使用接口抽象後,WebView並不知道有多少個策略被實現了,它要作的只是調用IWebviewCallback接口,這樣就把WebView從業務中抽離出來,WebviewWrapper並不涉及到業務的代碼,只須要配置和管理好WebView就能夠了 經過PageInterface和WebviewHostCallback的接口,Webview也不須要關心本身是在Activity仍是Fragment裏面。 從使用者的角度來看,繼承IWebviewPlugin實現本身的策略就能夠了。 經過接口抽象後,解耦了原來各個部分強耦合的關係。

4、實現WebView的HttpDns和本地圖片、文件的緩存

前面介紹了整個客戶端H5服務的架構,咱們就基於這個架構實現一個技術優化需求,那就是提高H5頁面的加載速度。咱們先來看下,H5頁面的網絡請求可能包括如下幾部分

  1. Html和js、css等文件的加載
  2. 圖片等素材的下載
  3. 異步網絡請求,好比一些Ajax請求

在這些網絡請求中,咱們面臨一些問題:

  1. 圖片在Native中下載過,點進去H5時還得再下一遍,圖片沒法複用
  2. Ajax異步網絡請求不能添加Header或者其餘的一些處理邏輯(Webview只能在loadUrl的時候添加header)
  3. Html和js、css反覆下載,不能進行可靠的管理
  4. 在一些網絡環境中DNS服務並不可靠,DNS結果可能拿不到或者被劫持(筆者在項目中就跟過幾例在浙江移動網絡中文件被劫持成其餘文件的狀況)。

要解決這些問題,咱們就須要實現由客戶端代理WebView的網絡請求,還須要實現WebView加載客戶端本地緩存的能力。 WebViewClient類中提供了shouldInterceptRequest的方法。

1.shouldInterceptRequest接口
public WebResourceResponse shouldInterceptRequest(WebView view, final WebResourceRequest webResourceRequest) 複製代碼

經過複寫這個方法咱們能夠攔截瀏覽器的資源請求,返回指定的內容。這個接口在文檔中是這麼描述的

Notify the host application of a resource request and allow the application to return the data. If the return value is null, the WebView will continue to load the resource as usual. Otherwise, the return response and data will be used. This callback is invoked for a variety of URL schemes (e.g., http(s):, data:, file:, etc.), not only those schemes which send requests over the network. This is not called for javascript: URLs, blob: URLs, or for assets accessed via file:///android_asset/ or file:///android_res/ URLs. In the case of redirects, this is only called for the initial resource URL, not any subsequent redirect URLs.

這裏面有幾點須要注意的

1. 這個接口不僅是http或者https的請求才出發的,也有多是data或者file協議,可是不包括javascript、file:///android_asset或者file:///android_res。因此攔截的時候咱們要注意只攔截網絡請求。 2. 返回值爲空時瀏覽器會按原有的邏輯執行請求,不然就使用指定的數據。 3. 重定向的請求只有第一個連接會調用這個接口,後續跳轉連接不會。

那麼這個接口怎麼使用呢,看看這個接口的參數和返回值就知道了。這個接口的參數WebResourceRequest的定義:

public interface WebResourceRequest {

    /** * 請求的url */
    Uri getUrl();

    /** * 返回該請求是否是主Frame發出的 */
    boolean isForMainFrame();

    /** * 該請求是不是服務端重定向的結果 */
    boolean isRedirect();

    /** * 該請求是否與用戶的手勢有關,好比點擊等 */
    boolean hasGesture();

    /** * 網絡的請求Method,好比GET或者POST */
    String getMethod();

    /** * 網絡請求的header */
    Map<String, String> getRequestHeaders();
}
複製代碼

這裏麪包含了請求的基本信息,再來看看返回值WebResourceResponse的相關屬性

public class WebResourceResponse {
    private String mMimeType;//資源的MIME類型,好比text/html
    private String mEncoding;//response的編碼格式,好比utf-8
    private int mStatusCode;//http狀態碼
    private String mReasonPhrase;//狀態碼描述與,好比200對應的是「OK」,這個值不能爲空
    private Map<String, String> mResponseHeaders;//response的header
    private InputStream mInputStream;//輸入流
}
複製代碼
2.實現代理資源請求和HttpDns

因爲國內Dns並非十分可靠,不少公司都會考慮接入HttpDns服務,咱們在網絡層中已經實現了HttpDns相關功能,是經過OkHttp的dns接口實現的,有興趣的能夠看一下這兩篇文章 OkHttp接入HttpDNS,最佳實踐 okhttp源碼解析(五):代理和DNS 相關原理在這裏就再也不贅述,咱們重點關心一下如何攔截WebView的請求,並使用OkHttp作網絡請求

Request.Builder builder = new Request.Builder();
    //構造請求
    builder.url(url).method(webResourceRequest.getMethod(), null);
    Map<String, String> requestHeaders = webResourceRequest.getRequestHeaders();
    if (Lang.isNotEmpty(requestHeaders)) {
        for (Map.Entry<String, String> entry : requestHeaders.entrySet()) {
            builder.addHeader(entry.getKey(), entry.getValue());
        }
    }
    Call synCall = mClient.newCall(builder.build());
    okhttp3.Response response = synCall.execute();
    if (response.body() != null) {
        ResponseBody body = response.body();
        Map<String, String> map = new HashMap<>();
        for (int i = 0; i < response.headers().size(); i++) {
        //相應體的header
            map.put(response.headers().name(i), response.headers().value(i));
        }
        //MIME類型
        String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(url));
        String contentType = response.headers().get("Content-Type");
        String encoding = "utf-8";
        //獲取ContentType和編碼格式
        if (contentType != null && !"".equals(contentType)) {
            if (contentType.contains(";")) {
                String[] args = contentType.split(";");
                mimeType = args[0];
                String[] args2 = args[1].trim().split("=");
                if (args.length == 2 && args2[0].trim().toLowerCase().equals("charset")) {
                    encoding = args2[1].trim();
                }
            } else {
                mimeType = contentType;
            }
        }
        WebResourceResponse webResourceResponse = new WebResourceResponse(mimeType, encoding, body.byteStream());
        String message = response.message();
        int code = response.code();
        if (TextUtils.isEmpty(message) && code == 200) {
            //message不能爲空
            message = "OK";
        }
        webResourceResponse.setStatusCodeAndReasonPhrase(code, message);
        webResourceResponse.setResponseHeaders(map);
複製代碼

這裏面須要注意的是對重定向的處理,筆者在實際項目中使用時,若是有重定向的code返回,OkHttp中是能夠直接處理重定向的結果,而且返回重定向後網絡請求的結果。可是把這個結果返回給給WebView後,WebView並不知道通過302跳轉了,因此會將當前的響應與重定向前的url關聯,致使頁面加載時可能出現錯誤,因此須要把302的狀況交回給WebView進行處理。在OkHttp中把重定向的follow設置爲false:okClientBuilder.followRedirects(false); 這樣,就能夠代理網絡請求。結合咱們基於OkHttp作的HttpDns,就能夠解決WebView的請求Dns的問題。 此外,若有應用對Cookie有需求,能夠自行設置Cookie的請求,相關設置能夠參考有贊技術團隊的相關文章

3.加載本地緩存

前面介紹過咱們把H5頁面的資源分紅三個部分:

  1. Html和js、css等文件的加載
  2. 圖片等素材的下載
  3. 異步網絡請求,好比一些Ajax請求

第3點咱們採用的是由客戶端對一些數據進行預緩存和預請求,在用戶打開頁面的時候,再將緩存數據經過jsBridge傳遞到H5中,提升頁面的加載速度。 對於1和2能夠由WebView自帶的緩存,可是缺點比較明顯,首先,客戶端很難操做WebView的緩存,沒辦經過WebView的緩存進行一些預下載和預更新的邏輯,形成首次進入時網絡請求的時間過長。其次,圖片在Native下載過,在WebView可能又得下載一遍,好比文章的封面圖,在列表中加載過一次,在H5中又須要下載一次。因此咱們考慮在Native中作代碼包管理和共享Native的圖片緩存。這些資源都以文件的形式存放在用戶的手機上。一樣經過shouldInterceptRequest接口實現:

fileInputStream = new FileInputStream(file);
String type = "text/html";
if (path.contains(".css")) {
    type = "text/css";
} else if (path.contains(".js")) {
    type = "application/javascript";
}
WebResourceResponse response = new WebResourceResponse(type, "utf-8", fileInputStream);
Map<String, String> map = new HashMap<>();
map.put("Content-Type", type);
//跨域響應頭,不然可能會有跨域沒法訪問的問題
map.put("Access-Control-Allow-Origin", "*");
response.setResponseHeaders(map);
return response;
複製代碼

把本地文件流做爲WebResourceResponse的輸入流,這樣就把網絡請求替代問本地文件。基於此,咱們也實現了本地包的管理和圖片的緩存。而這一切對於H5頁面來講甚至是毫無感知的。 優化完成後,懂球帝客戶端打開文章頁和加載封面圖,幾乎是秒開的速度

4、總結與展望

一、筆者首先對H5相關的業務進行了抽象,梳理了這其中的信息流和調用關係,採用策略模式重構了客戶端的WebView頁面,依賴於接口而不是原來的依賴於具體實現。
二、基於策略模式的接口,筆者實現了對於H5的網絡資源請求的代理,爲H5頁面的網絡請求實現HttpDns的功能,解決線上的DNS問題。
三、基於策略模式的接口,筆者實現H5包和圖片的本地緩存。
經過以上幾個優化,懂球帝的Android客戶端中與WebView相關的業務都獲得了極大的優化,十幾個頁面中重複的數千行代碼和邏輯獲得了從新的梳理,有些頁面甚至簡化了上千行代碼。基於策略模式,WebView的功能擴展也獲得了良好的擴展性和約束。至此咱們對於WebView頁面的優化也告一段落,固然後面咱們還會持續作一些優化,有幾個方向
一、目前包緩存只在部分頁面應用了,將來能夠擴展到更多的頁面和更細粒度的文件上
二、提升WebView的一致性,更換第三方內核
三、對WebView中的音視頻進行優化
......

參考文獻

【1】如何設計一個優雅健壯的Android WebView?(下)
【2】有贊webview加速平臺探索與建設(三)——html加速
【3】《研磨設計模式》,清華大學出版社,陳臣,王斌
【4】Android Developers WebResourceResponse
【5】Android:手把手教你構建 全面的WebView 緩存機制 & 資源加載方案


以上文獻對本文幫助甚多,十分感謝文獻做者的分享精神! 因爲本文準備比較倉促,文中有些地方可能有錯誤或者待提升的地方,敬請指出糾正,若有更多交流,能夠聯繫個人郵箱txlbupt@gmail.com

【本文做者】塗曉龍,曾就任於網易和美圖,現擔任懂球帝安卓研發工程師,致力於爲足球迷們打造一款更好用的App

相關文章
相關標籤/搜索