天天看點

Android H5容器整理

1.如何實作和設計一套JSBridge?

前端JS調用native的方式有很多種,或者說android有很多種方式可以攔截或者擷取到JS的行為。如下使用onConsoleMessage的方式,來設計一個簡單的JSBridge:

  • 前端代碼片段
(function () {
  var callbackArr = {};
  window.XJSBridge = {
    //JS調動native
    callNative: function (func, param, callback) {
      //生成調用的序列号Id
      var seqId = "__bridge_id_" + Math.random() + "" + new Date().getTime();
      //儲存回調
      callbackArr[seqId] = callback;
      //生成約定的JSBridge消息
      var msgObj = {
        func: func,
        param: param,
        msgType: "callNative",
        seqId: seqId,
      };
      //列印約定的日志
      console.log("____xbridge____:" + JSON.stringify(msgObj));
    },
    //native調用JS的方法
    nativeInvokeJS: function (res) {
      //解析native的消息
      res = JSON.parse(res);
      //判斷是否是callback消息
      if (res && res.msgType === "jsCallback") {
        //擷取之前保持的回調方法
        var func = callbackArr[res.seqId];
        //delete callbackArr[res.seqId];
        //調用JS的回調
        if ("function" === typeof func) {
          setTimeout(function () {
            //調用js的回調
            func(res.param);
          }, 1);
        }
      }
      return true;
    },
  };
  document.dispatchEvent(new Event("xbridge inject success..."), null);
  console.log("xbridge inject success...");
})();      

如上代碼就是在window對象上挂載一個XJSBridge對象,可以通過callNative函數來調用android的native方法,其原理就是JS調用console.log列印一行約定的日志(Bridge協定),android端通過WebChromeClient的onConsoleMessage方法,擷取到列印的日志,然後針對協定解析出對應的協定。

  • 其中WebChromeClient的代碼如下
public class XWebChromeClient extends WebChromeClient {

    private WebViewPage mWebViewPage;

    public XWebChromeClient setWebViewPage(WebViewPage mWebViewPage) {
        this.mWebViewPage = mWebViewPage;
        return this;
    }

    @Override
    public boolean onConsoleMessage(ConsoleMessage consoleMessage) {

        if (ConsoleMessage.MessageLevel.LOG.equals(consoleMessage.messageLevel())) {
            XLog.d("onConsoleMessage:" + consoleMessage.message()
                    + ",line = " + consoleMessage.lineNumber()
                    + ",sourceId = " + consoleMessage.sourceId()
                    + ",messageLevel = " + consoleMessage.messageLevel());
            //log級别的日志
            if (mWebViewPage.handleMsgFromJS(consoleMessage.message())) {
                return true;
            }
        } else if (ConsoleMessage.MessageLevel.ERROR.equals(consoleMessage.messageLevel())) {
            //ERROR級别的日志
            XLog.e("onConsoleMessage:" + consoleMessage.message()
                    + ",line = " + consoleMessage.lineNumber()
                    + ",sourceId = " + consoleMessage.sourceId()
                    + ",messageLevel = " + consoleMessage.messageLevel());
        } else {
            XLog.w("onConsoleMessage:" + consoleMessage.message()
                    + ",line = " + consoleMessage.lineNumber()
                    + ",sourceId = " + consoleMessage.sourceId()
                    + ",messageLevel = " + consoleMessage.messageLevel());
        }
        return super.onConsoleMessage(consoleMessage);
    }
}      

這裡的mWebViewPage會調用到XJSBridgeImpl的handleLogFromJS方法,在這裡針對JSBridge進行解析,比如:

public class XJSBridgeImpl {

    private WebView mWebView;

    public XJSBridgeImpl(WebView view) {
        mWebView = view;
    }

    private static String XJSBRIDGE_HEADER = "____xbridge____:";

    public boolean handleLogFromJS(String log) {
        if (log != null && log.startsWith(XJSBRIDGE_HEADER)) {
            String msg = log.substring(XJSBRIDGE_HEADER.length());
            XLog.d("msg:" + msg);
            return handleMsgFromJS(msg);
        }
        return false;
    }

    private boolean dispatch(String func, final String seqId, String param) {

        if (func.equals("nativeMethod")) {
            //模拟js bridge的native實作
            Toast.makeText(mWebView.getContext(), "Hello XJSBridge!I am in native.", Toast.LENGTH_SHORT).show();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    XLog.d("hello, I am native method...");
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    JSONObject mockRes = new JSONObject();
                    try {
                        mockRes.put("bridge", XJSBridgeImpl.class.getName());
                        mockRes.put("data", "I am from native");
                    } catch (JSONException e) {
                        e.printStackTrace();
                    }
                    invokeJS(seqId, mockRes);
                }
            }).start();
        }
        return false;
    }


    private void invokeJS(String seqId, JSONObject res) {
        final JSONObject jsonObject = new JSONObject();
        try {
            jsonObject.put("seqId", seqId);
            jsonObject.put("msgType", "jsCallback");
            jsonObject.put("param", res);
        } catch (JSONException e) {
            XLog.e(e);
        }
        mWebView.post(new Runnable() {
            @Override
            public void run() {
                //需要在主線程調用
                mWebView.evaluateJavascript(String.format("javascript: window.XJSBridge.nativeInvokeJS('%s')", jsonObject.toString()), new ValueCallback<String>() {
                    @Override
                    public void onReceiveValue(String s) {
                        XLog.d("onReceiveValue:s = " + s);
                    }
                });
            }
        });
    }


    private boolean handleMsgFromJS(String message) {
        try {
            JSONObject jsonObject = new JSONObject(message);
            String func = jsonObject.getString("func");
            String seqId = jsonObject.getString("seqId");
            String param = jsonObject.getString("param");
            dispatch(func, seqId, param);
            return true;
        } catch (JSONException e) {
            XLog.e(e);
        }
        return false;
    }

    public void changeH5Background() {
        mWebView.evaluateJavascript(String.format("javascript: changeColor('%s')", "#f00"), new ValueCallback<String>() {
            @Override
            public void onReceiveValue(String s) {
                XLog.d("changeH5Background:onReceiveValue:s = " + s);
            }
        });
    }

    public void addObjectForJS(){
        mWebView.addJavascriptInterface(new NativeLog(),"nativeLog");
    }
}      
  • native如何回調給前端?

android通過mWebView.evaluateJavascript或者mWebView.loadUrl的方式調用JS,在android 4.4以上推薦使用evaluateJavascript,loadUrl會導緻頁面重新整理,鍵盤收回等問題。

  • native如何向前端注入對象?

通過addJavascriptInterface添加對象,比如:

.addJavascriptInterface(new NativeLog(),"nativeLog");      

其中NativeLog的實作如下:

public class NativeLog {

    @JavascriptInterface
    public void print(String log) {
        XLog.d("nativeLog:" + log);
    }
}      

這裡面的方法print,需要添加注解@JavascriptInterface,JS才能通路到。

添加之後,前端可以通過如下方式調用:

.getElementById('a2').addEventListener('click', function () {
    console.log('clicked....');
    if (nativeLog) {
        nativeLog.print('content from js');
    }
});      

2.如果實作url的攔截和重定向?

WebView的WebViewClient中,可以通過重寫shouldOverrideUrlLoading實作,如果需要攔截,可以return true;比如:

public class XWebViewClient extends WebViewClient {

    public XWebViewClient() {
        super();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
        XLog.d("shouldOverrideUrlLoading2...");
        if (shouldOverrideUrlLoadingInternal(view, request.getUrl().toString())) {
            return true;
        }
        return super.shouldOverrideUrlLoading(view, request);
    }

    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        XLog.d("shouldOverrideUrlLoading1...");
        if (shouldOverrideUrlLoadingInternal(view, url)) {
            return true;
        }
        return super.shouldOverrideUrlLoading(view, url);
    }

    private boolean shouldOverrideUrlLoadingInternal(WebView view, String url) {
        if (url != null && url.startsWith("https://www.baidu.com")) {
            view.loadUrl("file:///android_asset/xbridge_demo.html");
            return true;
        }
        if (url != null && url.startsWith("xscheme://native_page")) {
            //根據前端的href進行scheme攔截,跳轉到native的頁面
            view.getContext().startActivity(new Intent(view.getContext(), TestActivity.class));
            return true;
        }
        if(url != null && url.startsWith("")){

        }
        return false;
    }
    ...
}      
  • 攔截url

    這裡可以實作很多功能,比如頁面跳轉到外部連結​​​https://www.baidu.com​​時,可以攔截掉,然後讓WebView加載本地的某個頁面,或者是錯誤頁面。

  • 擴充JSBridge

也可以通過shouldOverrideUrlLoading實作JSBridge,比如攔截到url是​

​xscheme://native_page​

​時,容器跳轉到某一個native頁面,達到啟動native頁面的目的,也可以擴充其他功能。前端可以通過如下方式:

<a href="xscheme://native_page?name=test_activity" class="btn read" id="a3">通過href的scheme調用native</a><br><br><br>      
  • 處理指定的某些url

比如這裡可以判斷url如果是以.apk結尾的時候,啟動系統的浏覽器進行下載下傳;攔截到某些scheme時,喚起對應的app等等。

3.如何實作資源的攔截?

可以對WebViewClient的shouldInterceptRequest方法進行Override,然後根據資源請求進行攔截。

public class XWebViewClient extends WebViewClient {

     ...

    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
        XLog.d("shouldInterceptRequest1:" + url + "");
        WebResourceResponse webResourceResponse = interceptRequestInternal(view, url);
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, url);
    }


    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        XLog.d("shouldInterceptRequest2:" + request.getUrl() + "");
        WebResourceResponse webResourceResponse = interceptRequestInternal(view, request.getUrl().toString());
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, request);
    }

    private WebResourceResponse interceptRequestInternal(WebView view, String url) {
        if (url != null &&
                (url.endsWith("png")
                        || url.endsWith("jpg")
                        || url.endsWith("gif")
                        || url.endsWith("JPEG"))) {
            WebResourceResponse webResourceResponse = null;
            try {
                webResourceResponse = new WebResourceResponse("image/jpeg", "UTF-8", getStream());
            } catch (FileNotFoundException e) {
                e.printStackTrace();
                XLog.e(e.getMessage());
            }
            return webResourceResponse;
        }
        return null;
    }

    private InputStream getStream() throws FileNotFoundException {

        FileInputStream fileInputStream = new FileInputStream(new File(Environment.getExternalStorageDirectory().getPath() + "/DCIM/xxx.jpg"));

        return fileInputStream;
    }

    ...
}      

比如上述代碼,是對H5頁面的圖檔資源進行攔截,把H5頁面的圖檔資源換成本地的圖檔。如上代碼并沒有實際意義,隻是舉例說明對資源的攔截。

4.如何實作資源的離線?

上述步驟中根據攔截shouldInterceptRequest方法,建構對應的WebResourceResponse,其實H5資源離線的原理正是如此。這裡需要注意的是CORS問題,攔截之後建構的資源URI可能和目前H5頁面的域名不同,是以需要添加Access-Control-Allow-Origin;

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private WebResourceResponse interceptRequestInternal(WebView view, String url) {
        if (url != null &&
                (url.endsWith("png")
                        || url.endsWith("jpg")
                        || url.endsWith("gif")
                        || url.endsWith("JPEG"))) {
            WebResourceResponse webResourceResponse = null;
            try {
                webResourceResponse = new WebResourceResponse("image/jpeg", "UTF-8", getStream());
                Map<String, String> header = new HashMap<>();
                header.put("Access-Control-Allow-Origin", "**");
                webResourceResponse.setResponseHeaders(header);
            } catch (Exception e) {
                e.printStackTrace();
                XLog.e(e.getMessage());
            }
            return webResourceResponse;
        }
        return null;
    }      

資源離線的原理就是在H5頁面需要通路的線上資源,在H5頁面打開之前,提前下載下傳好或者内置到apk中,等到H5頁面加載時,通過shouldInterceptRequest攔截資源的加載,然後将已經離線的資源封裝成WebResourceResponse對象,提高H5頁面的加載速度。

5.如何擷取JS列印的日志?

之前已經講過,可以通過WebChromeClient.onConsoleMessage方法擷取。但是,如果部分機型沒有回調,可以通過addJavascriptInterface的方式,給H5注入一個Console對象,并實作Console對象的log等方法即可。

6.如何實作接口的預取?

對H5頁面的網絡請求提前預取,可以提升H5頁面的性能。其原理是在啟動H5頁面的Activity時,就時開始進行網絡請求,把請求的結果進行緩存。當頁面加載時,攔截頁面的網絡請求,然後将緩存的結果傳回給H5,完成接口預期,加速頁面的加載。

7.WebView如何和native的Cookie同步?

很多app的登入頁面是native實作的,登入成功之後,希望H5頁面能夠在加載時,共用native的Cookie,此時如何做同步呢?Android中可以使用CookieManager

= CookieManager.getInstance();
    cookieManager.setAcceptCookie(true);

    List<Cookie> cookies = getCookiesFromLogin();//從業務中擷取

    cookieManager.removeAllCookie();

    if (cookies != null) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().contains("session")){
                String cookieString = cookie.getName() + "=" + cookie.getValue() + "; Domain=" + cookie.getDomain();
                cookieManager.setCookie(cookie.getDomain(), cookieString);
            }
        }
    }
    ...
    //加載H5頁面
    webView.loadUrl(url);      

當然,android也可以從CookieManager擷取WebView儲存的Cookie,比如H5頁面HTTP回應的頭資訊裡面,放置的Set-Cookie資訊,WebView會儲存在CookieManager中.

8.如何注入JSBridge?

為了友善的管控JSBridge的内容,友善後續統一更新,我們希望JSBridge的js通過攔截注入的方式加載,而不是直接寫到前端代碼中,那我們如何做呢?

  • 1.将JSBridge的内容抽離成一個xbridge.js檔案,存放到assets目錄中
  • 2.做一個虛拟域名,比如demo中的​

    ​https://www.baidu.com/xbridge.js​

  • 3.在H5的入口html中,添加script标簽,比如:
<script src="https://www.baidu.com/xbridge.js"></script>      
  • 4.在webview的WebViewClient中攔截虛拟url,然後将本地assets目錄中的jsbridge注入進去即可。
...

    private WebResourceResponse interceptRequestInternal(WebView view, String url) {
        if (url != null && url.equals("https://www.baidu.com/xbridge.js")) {
            XLog.d("start inject js bridge...");
            WebResourceResponse webResourceResponse = null;
            try {
                InputStream inputStream = view.getContext().getAssets().open("xbridge.js");
                webResourceResponse = new WebResourceResponse("text/html", "UTF-8", inputStream);
                Map<String, String> header = new HashMap<>();
                header.put("Access-Control-Allow-Origin", url);
                webResourceResponse.setResponseHeaders(header);
            } catch (Exception e) {
                e.printStackTrace();
                XLog.e(e.getMessage());
            }
            return webResourceResponse;
        }
        return null;
    }
...      

9.如何喚起其他APP?

大家都知道,每個app有一個scheme,比如口碑的scheme為​

​alipays://platformapi/startApp?appId=20000001​

​;具體每個app的scheme是什麼,我們可以反編譯對應點apk,檢視AndroidManifest檔案的如下代碼片段:

<activity ....>
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="demoscheme{這裡是scheme的值}" />
  </intent-filter>
</activity>      

其中​

​android:scheme​

​的值即為scheme

那我們如何喚起app呢?

  • 1.前端通過​

    ​window.location.href​

    ​傳入scheme,比如:
...
document.getElementById("a9").addEventListener("click", function () {
  const scheme = "koubei://platformapi/startApp?appId=20000001";
  console.log("scheme = " + scheme);
  window.location.href = scheme;
});
...      

以上是喚起口碑app的demo

  • 2.在webview的WebViewClient中通過shouldOverrideUrlLoading攔截scheme,然後喚起app
@Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        XLog.d("shouldOverrideUrlLoading1...");
        if (shouldOverrideUrlLoadingInternal(view, url)) {
            return true;
        }
        return super.shouldOverrideUrlLoading(view, url);
    }
    ...
    private boolean shouldOverrideUrlLoadingInternal(WebView view, String url) {
        //非http的scheme,喚起對應scheme的app
        if (url != null && !url.startsWith("http")) {
            Uri uri = Uri.parse(url);
            Intent intent = new Intent();
            intent.setAction(Intent.ACTION_VIEW);
            intent.setData(uri);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            view.getContext().startActivity(intent);
            return true;
        }
        ...
        return false;
    }      

10.前端如何下載下傳apk?

針對android的情況,可以有如下方式:

  • 1.通過JSBridge,由native實作一個檔案下載下傳和安裝的功能,前端隻需要調用即可
  • 2.通過window.location.href,用戶端對其進行攔截,同上;
  • 3.使用iframe.src中添加js标本的方式,可以避免打開新的頁面;
  • 4.使用form表單的action字段,通過submit也可以

11.前端根據ua判斷iOS/Android以及版本

  • 1.前端直接可以通過ua即可判斷,比如:
isAndroidOrIOS() {
    var u = navigator.userAgent;
    console.log("ua = " + u);
    var isAndroid = u.indexOf("Android") > -1 || u.indexOf("Adr") > -1; //android終端
    var isiOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios終端
    if (isAndroid) {
      return "android";
    }
    if (isiOS) {
      return "ios";
    }
    return false;
 }      
  • 2.自定義UA

如果端上想把自己的版本号等資訊,也想通過UA傳給前端,可以直接設定webview的settings,比如:

public class XWebView extends WebView {

    @Override
    public WebSettings getSettings() {

        WebSettings webSettings = super.getSettings();

        String ua = webSettings.getUserAgentString();

        try {
            PackageInfo pInfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
            String version = pInfo.versionName;
            String appName = getContext().getString(pInfo.applicationInfo.labelRes);
            String newUaWithVersion = ua + " AndroidApp_" + appName + "/" + version;
            webSettings.setUserAgentString(newUaWithVersion);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        return webSettings;
    }
}      

當然,也可以直接調用webview的setUserAgentString進行重置;比如設定後的UA為:

Mozilla/5.0 (Linux; Android 6.0.1; MuMu Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.100 Mobile Safari/537.36 AndroidApp_XWebViewDemo/1.0      

12.離線包設計

  • 離線壓縮包設計

以一個​

​create-react-app​

​​的H5項目為例,執行​

​npm build​

​​進行編譯,我們将生成的​

​build​

​​目錄下的檔案進行簡單的壓縮,壓縮成zip包。為了友善後續離線包的管理,我們在離線包中添加一個我們約定的包資訊描述檔案,比如命名為​

​pkg-desc.json​

​​,當然,我們也可以複用​

​manifest.json​

​這個檔案,在json中,添加對離線包的描述,比如:

{
  "launchParams": {
    "indexUrl": "/index.html",
    "transparentTitle": "true"
  },
  "vHost": "https://www.taobao.com"
}      

這裡簡單列幾個參數,比如vHost,即是我們設計的​

​虛拟域名​

​,我們設計的目的,是在webview加載vHost+indexUrl時,自動加載目前離線包目錄裡的index.html;壓縮成離線包之前的目錄為:

.
├── asset-manifest.json
├── favicon.ico
├── index.html
├── manifest.json
├── robots.txt
└── static
    ├── css
    │   ├── main.5b343dc0.chunk.css
    │   └── main.5b343dc0.chunk.css.map
    └── js
        ├── 2.94eb2e42.chunk.js
        ├── 2.94eb2e42.chunk.js.LICENSE.txt
        ├── 2.94eb2e42.chunk.js.map
        ├── 3.df4689f7.chunk.js
        ├── 3.df4689f7.chunk.js.map
        ├── main.83926041.chunk.js
        ├── main.83926041.chunk.js.map
        ├── runtime-main.ebe92296.js
        └── runtime-main.ebe92296.js.map
      

這些檔案均是​

​create-react-app​

​​編譯(npm run build)之後生成的檔案,其中​

​manifest.json​

​儲存了我們自定義的資訊。

  • 離線包的解壓和加載

我們将上述設計的壓縮包,壓縮成​

​zip​

​檔案,在app啟動的時候,将離線包的内容加載到記憶體中,并以Map的形式儲存。我們以存放到assets目錄下的離線包為例,對其進行加載的部分核心代碼:

public class OfflinePkgManager {

    private static final String PKG_DESC_JSON = "manifest.json";
    private static final String DEFAULT_INDEX_URL = "/index.html";

    private static OfflinePkgManager mOfflinePkgManager = null;
    //儲存離線包的内容:key為url的路徑,byte為對應的緩存内容
    private Map<String, byte[]> offlinePkg = new ConcurrentHashMap<>();

    //儲存離線包的位址以及啟動參數等資訊
    private Map<String, PkgDescModel> vHostUrlInfoMap = new ConcurrentHashMap<>();

    private OfflinePkgManager() {

    }

    public synchronized static OfflinePkgManager getInstance() {
        if (mOfflinePkgManager == null) {
            mOfflinePkgManager = new OfflinePkgManager();
        }
        return mOfflinePkgManager;
    }

    /**
     * 加載assets中的離線包
     *
     * @param context
     */
    public void loadAssetsPkg(Context context) {
        try {
            InputStream inputStream = context.getAssets().open("react_zhihu_demo_offline_pkg.zip");
            Map<String, byte[]> relativePathByteMap = new HashMap<>();
            XFileUtils.loadZipFile(inputStream, relativePathByteMap);
            addPackageInfo(relativePathByteMap);
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

    /**
     * 添加離線包資訊
     *
     * @param relativePathByteMap
     */
    private void addPackageInfo(Map<String, byte[]> relativePathByteMap) {
        //擷取離線包的描述資訊
        byte[] descByte = relativePathByteMap.get(PKG_DESC_JSON);
        if (descByte != null) {
            String jsonStr = new String(descByte);
            PkgDescModel pkgDesc = JSON.parseObject(jsonStr, PkgDescModel.class);
            String vHost = pkgDesc.getvHost();
            String indexUrl = getIndexUrl(pkgDesc);
            String vHostUrl = vHost + indexUrl;
            //儲存離線包資訊
            vHostUrlInfoMap.put(vHostUrl, pkgDesc);
            for (Map.Entry<String, byte[]> entry : relativePathByteMap.entrySet()) {
                String fullUrl = vHost + "/" + entry.getKey();
                //儲存離線包内容
                offlinePkg.put(fullUrl, entry.getValue());
            }
            XLog.d("add packageInfo success:" + vHostUrl);
        }
    }

    /**
     * 擷取入口html的url
     *
     * @param pkgDesc
     * @return
     */
    private String getIndexUrl(PkgDescModel pkgDesc) {
        if (pkgDesc != null
                && pkgDesc.getLaunchParams() != null
                && pkgDesc.getLaunchParams().getIndexUrl() != null) {
            return pkgDesc.getLaunchParams().getIndexUrl();
        }
        //預設為/index.html
        return DEFAULT_INDEX_URL;
    }

    public byte[] getOfflineContent(String url) {
        byte[] offlineContent = offlinePkg.get(url);
        if (offlineContent != null) {
            XLog.d(String.format("url:%s  load from cache", url));
        }
        return offlineContent;
    }

}      

加載zip檔案的方法實作:

//将ZIP檔案加載記憶體中,以路徑和byte[]的key/value形式存儲
public class XFileUtils {
    /**
     * 将壓縮包解壓到記憶體中
     *
     * @param is
     * @param relativePathByteMap
     * @return
     */
    public static boolean loadZipFile(InputStream is, Map<String, byte[]> relativePathByteMap) {
        ZipInputStream zis;
        try {
            String filename;
            zis = new ZipInputStream(new BufferedInputStream(is));
            ZipEntry zipEntry;
            int count;
            byte[] buffer = new byte[1024];
            while ((zipEntry = zis.getNextEntry()) != null) {
                filename = zipEntry.getName();
                if (zipEntry.isDirectory() || TextUtils.isEmpty(filename)) {
                    continue;
                }
                ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
                while ((count = zis.read(buffer)) != -1) {
                    byteArrayOutputStream.write(buffer, 0, count);
                }
                byte[] data = byteArrayOutputStream.toByteArray();
                String pureFilename = filename.substring(filename.indexOf('/') + 1);
                //保持相對路徑的檔案名稱以及對應的資料
                relativePathByteMap.put(pureFilename, data);
                byteArrayOutputStream.close();
                XLog.d("unzip filename = " + pureFilename);
                zis.closeEntry();
            }
            zis.close();
        } catch (Throwable e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

}      
  • 虛拟域名與離線包的比對

使用webview通過loadUrl加載虛拟域名的時候,webview通過shouldInterceptRequest攔截url,查找對應的``

public class XWebViewClient extends WebViewClient {

    ...
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
        WebResourceResponse webResourceResponse = interceptRequestInternal(view, url);
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, url);
    }


    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        WebResourceResponse webResourceResponse = interceptRequestInternal(view, request.getUrl().toString());
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, request);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private WebResourceResponse interceptRequestInternal(WebView view, String url) {
        if (url != null) {
            WebResourceResponse webResourceResponse = null;
            try {
                //嘗試擷取離線内容
                byte[] contentByte = OfflinePkgManager.getInstance().getOfflineContent(url);
                if (contentByte != null && contentByte.length > 0) {
                    InputStream inputStream = new ByteArrayInputStream(contentByte);
                    //構造WebResourceResponse
                    webResourceResponse = new WebResourceResponse(getMimeType(url), "UTF-8", inputStream);
                    Map<String, String> header = new HashMap<>();
                    header.put("Access-Control-Allow-Origin", url);
                    webResourceResponse.setResponseHeaders(header);
                }
            } catch (Exception e) {
                e.printStackTrace();
                XLog.e(e.getMessage());
            }
            return webResourceResponse;
        }
        return null;
    }

    /**
     * 擷取mimeType:
     *
     * @param url
     * @return
     */
    private String getMimeType(String url) {
        try {
            String mimeType = null;
            String ext = MimeTypeMap.getFileExtensionFromUrl(url);
            if ("js".equalsIgnoreCase(ext)) {
                mimeType = "application/javascript";
            } else {
                mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
            }
            XLog.d("mimeType = " + mimeType);
            return mimeType;
        } catch (Throwable e) {
            e.printStackTrace();
        }
        return "text/html";
    }

}      

這一部分就是根據url查找離線包的内容,然後構造WebResourceResponse以及對應的MimeType;

  • url參數(魔術參數)

​manifest.json​

​裡面還可以添加一下預設的參數,比如設定titlebar的參數,這些參數可以在webview加載url之前生效。比如titlebar的背景,title等資訊,實作原理比較簡單,這裡不再贅述。

13.自定義錯誤頁面

native可以根據WebView的報錯資訊,自定義錯誤頁面,可通過WebViewClient監聽報錯回調:

onReceivedError
onReceivedHttpError
onReceivedSslError      

自定義的錯誤頁面有如下幾種實作方式:

  • 1.H5實作一個預設的錯誤頁,打包到apk中,直接使用webview加載。
  • 2.使用native實作一個layout,然後覆寫在webview布局上方,同時可以自定義一些功能。

14.頁面加載逾時

自定義webview的頁面加載逾時時間,可以通過​

​onPageStarted​

​​和​

​onPageFinished​

​進行配合

public class XWebViewClient extends WebViewClient {

     private boolean loadTimeout;
    ...
    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
        super.onPageStarted(view, url, favicon);
        startLoadTime = System.currentTimeMillis();
        XLog.d("onPageStarted:" + url);
        new Thread(new Runnable() {
            @Override
            public void run() {
                loadTimeout = true;
                try {
                    Thread.sleep(10000);
                } catch (Throwable t) {
                    t.printStackTrace();
                }
                if (loadTimeout) {
                    webViewPage.showErrorPage("TIMEOUT");
                }
            }
        }).start();
    }

    @Override
    public void onPageFinished(WebView view, String url) {
        loadTimeout = false;
        super.onPageFinished(view, url);
    }      

15.對前端頁面的性能統計

為了更加準确和詳細的擷取H5頁面的性能,可以通過前端的​

​performance​

​實作:

  • 前端代碼:
;(function (win) {
  if (!win.performance || !win.performance.timing) return {};
  var time = win.performance.timing;
  var timingResult = {};
  timingResult["重定向時間"] = (time.redirectEnd - time.redirectStart) / 1000;
  timingResult["DNS解析時間"] =
    (time.domainLookupEnd - time.domainLookupStart) / 1000;
  timingResult["TCP完成握手時間"] =
    (time.connectEnd - time.connectStart) / 1000;
  timingResult["HTTP請求響應完成時間"] =
    (time.responseEnd - time.requestStart) / 1000;
  timingResult["DOM開始加載前所花費時間"] =
    (time.responseEnd - time.navigationStart) / 1000;
  timingResult["DOM加載完成時間"] = (time.domComplete - time.domLoading) / 1000;
  timingResult["DOM結構解析完成時間"] =
    (time.domInteractive - time.domLoading) / 1000;
  timingResult["腳本加載時間"] =
    (time.domContentLoadedEventEnd - time.domContentLoadedEventStart) / 1000;
  timingResult["onload事件時間"] =
    (time.loadEventEnd - time.loadEventStart) / 1000;
  timingResult["頁面完全加載時間"] =
    timingResult["重定向時間"] +
    timingResult["DNS解析時間"] +
    timingResult["TCP完成握手時間"] +
    timingResult["HTTP請求響應完成時間"] +
    timingResult["DOM結構解析完成時間"] +
    timingResult["DOM加載完成時間"];
  return { result: timingResult };
})(this);      
  • webview在onPageFinised加載完成時執行統計代碼
public class XWebViewClient extends WebViewClient {

    ...
    @Override
    public void onPageFinished(WebView view, String url) {
        super.onPageFinished(view, url);
        String jsCode = "(function(win){if(!win.performance||!win.performance.timing){return{}}var time=win.performance.timing;var timingResult={};timingResult[\"重定向時間\"]=(time.redirectEnd-time.redirectStart)/1000;timingResult[\"DNS解析時間\"]=(time.domainLookupEnd-time.domainLookupStart)/1000;timingResult[\"TCP完成握手時間\"]=(time.connectEnd-time.connectStart)/1000;timingResult[\"HTTP請求響應完成時間\"]=(time.responseEnd-time.requestStart)/1000;timingResult[\"DOM開始加載前所花費時間\"]=(time.responseEnd-time.navigationStart)/1000;timingResult[\"DOM加載完成時間\"]=(time.domComplete-time.domLoading)/1000;timingResult[\"DOM結構解析完成時間\"]=(time.domInteractive-time.domLoading)/1000;timingResult[\"腳本加載時間\"]=(time.domContentLoadedEventEnd-time.domContentLoadedEventStart)/1000;timingResult[\"onload事件時間\"]=(time.loadEventEnd-time.loadEventStart)/1000;timingResult[\"頁面完全加載時間\"]=timingResult[\"重定向時間\"]+timingResult[\"DNS解析時間\"]+timingResult[\"TCP完成握手時間\"]+timingResult[\"HTTP請求響應完成時間\"]+timingResult[\"DOM結構解析完成時間\"]+timingResult[\"DOM加載完成時間\"];return{result:timingResult}})(this);";
        view.evaluateJavascript(jsCode, new ValueCallback<String>() {
            @Override
            public void onReceiveValue(String s) {
                XLog.d("onPageFinished:JSResult = " + s);
                //TODO 對性能資料進行上報
            }
        });
    }
    ...
}      
  • 統計結果示例

以https://www.baidu.com為例:

{
    "result":{
        "DNS解析時間":0.013,
        "DOM加載完成時間":1.586,
        "DOM開始加載前所花費時間":0.312,
        "DOM結構解析完成時間":0.142,
        "HTTP請求響應完成時間":0.118,
        "TCP完成握手時間":0.123,
        "onload事件時間":0.001,
        "腳本加載時間":0.001,
        "重定向時間":0,
        "頁面完全加載時間":1.982
    }
}      

16.H5頁面的任務棧多開

類似微信小程式或者支付寶小程式,我們可以将H5頁面在一個獨立的程序中加載.

  • 多程序的優勢:
  • 隔離:與主程序是程序級别的隔離,不影響主程序的Crash
  • 增加app的可用記憶體
  • 多程序的劣勢:
  • 資料共享問題
  • 不必要的初始化

對于一個H5頁面,我們需要将WebView所在的Activity設定為獨立程序。同時,為了支援多個H5頁面,我們預注冊5個Activity,比如:

public class XWebViewActivity extends Activity {

    private WebViewPage mWebViewPage = null;

    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Process.setThreadPriority(-20);
        setContentView(R.layout.activity_webview);
        ...
        setTaskDesc();
    }

    private void setTaskDesc() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            setTaskDescription(new ActivityManager.TaskDescription("H5程序", R.drawable.h5_icon));
        } else {
            Bitmap iconBmp = BitmapFactory.decodeResource(getResources(), R.drawable.h5_icon); // 這裡應該是小程式圖示的bitmap
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                setTaskDescription(new ActivityManager.TaskDescription("H5程序", iconBmp));
            }
        }
    }

    public static class H5Activity1 extends XWebViewActivity {

    }

    public static class H5Activity2 extends XWebViewActivity {

    }

    public static class H5Activity3 extends XWebViewActivity {

    }

    public static class H5Activity4 extends XWebViewActivity {

    }

    public static class H5Activity5 extends XWebViewActivity {

    }

}      

可以通過setTaskDescription設定task的名稱,比如以小程式的業務名稱命名。在AndroidManifest.xml中配置多程序以及task的屬性,以如下xml為例:主要是設定​

​android:process​

​​,​

​launchMode​

​​,​

​taskAffinity​

​三個屬性。

  • xml的設定
<activity
    android:name=".activity.XWebViewActivity"
    android:label="@string/app_name"
    android:launchMode="singleTask"
    android:process=":h5container"
    android:taskAffinity=":lite1">
    <intent-filter>
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="xwebview" />
    </intent-filter>
</activity>

<activity
    android:name=".activity.XWebViewActivity$H5Activity1"
    android:launchMode="singleTask"
    android:process=":activity1"
    android:taskAffinity=":activity1" />

<activity
    android:name=".activity.XWebViewActivity$H5Activity2"
    android:launchMode="singleTask"
    android:process=":activity2"
    android:taskAffinity=":activity2" />

<activity
    android:name=".activity.XWebViewActivity$H5Activity3"
    android:launchMode="singleTask"
    android:process=":activity3"
    android:taskAffinity=":activity3" />

<activity
    android:name=".activity.XWebViewActivity$H5Activity4"
    android:launchMode="singleTask"
    android:process=":activity4"
    android:taskAffinity=":activity4" />

<activity
    android:name=".activity.XWebViewActivity$H5Activity5"
    android:launchMode="singleTask"
    android:process=":activity5"
    android:taskAffinity=":activity5" />      
  • 啟動H5Activity以及複用

最多打開5個獨立程序的H5頁面,當打開第6個時,會覆寫掉第1個打開的程序。如下是部分核心代碼:

public class RouterManager {

    private static Activity mMainActivity;
    private static int index = 0;

    private static List<String> availableActivityList = new ArrayList<>();

    private static final String[] H5_ACTIVITY_ARR = new String[]{
            XWebViewActivity.H5Activity1.class.getName(),
            XWebViewActivity.H5Activity2.class.getName(),
            XWebViewActivity.H5Activity3.class.getName(),
            XWebViewActivity.H5Activity4.class.getName(),
            XWebViewActivity.H5Activity5.class.getName(),
    };

    public static void init(Activity activity) {
        index = 0;
        mMainActivity = activity;
        availableActivityList.addAll(Arrays.asList(H5_ACTIVITY_ARR));
    }

    public static void openH5Activity(String url) {
        String activityClazz = availableActivityList.get(index % (availableActivityList.size() - 1));
        try {
            Class<?> c = Class.forName(activityClazz);
            Intent intent = new Intent(mMainActivity, c);
            intent.putExtra("url", url.trim());
            mMainActivity.startActivity(intent);
            index++;
        } catch (Exception ignored) {
        }
    }
}      
  • 多程序的“後遺症”

多程序下,Application會初始化多次,我們需要排除非必要的初始化,以及合理安排APP主程序和H5進行的分工,然後通過程序間通信(比如AIDL)的方式解決主程序和子程序之間的通信問題。

17.使用MessageChannel通信

  • ​​MessageChannel​​

MessageChannel,并不是一個新的概念,它是一個Web API。它允許我們建立一個新的MessageChannel(消息通道)然後通過這個消息通道的兩個端口(MessagePort)進行傳遞資料。比如,前端可以通過MessageChannel的

var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;
port1.onmessage = function (event) {
  console.log("recv msg from port2:" + event.data);
};
port2.onmessage = function (event) {
  console.log("recv msg from port1:" + event.data);
};

port1.postMessage("send to port2");
port2.postMessage("send to port1");      

運作之後,可以看到日志如下:

recv msg from port1:send to port2
recv msg from port2:send to port1      

以上隻是前端方面的一個簡單Demo

  • android webview與H5之間通過MessageChannel通信

我們知道,我們可以通過MessageChannel的兩個MessagePort進行通信。那native和H5的通信的思路是,native通過WebMessage把其中一個端口發送給H5,H5儲存端口的引用,然後通過端口postMessage另一個port;

android端代碼:

import android.annotation.TargetApi;
import android.net.Uri;
import android.os.Build;
import android.webkit.WebMessage;
import android.webkit.WebMessagePort;
import android.webkit.WebView;

import com.mochuan.github.log.XLog;

/**
 * @Author Zheng Haibo
 * @Blog github.com/nuptboyzhb
 * @Company Alibaba Group
 * @Description WebView的MessageChannel
 */
public class XMessageChannel {

    private static final String TAG = "XMessageChannel";

    private WebMessagePort nativePort = null;
    private WebMessagePort h5Port = null;

    @TargetApi(Build.VERSION_CODES.M)
    public void init(WebView webView) {
        final WebMessagePort[] channel = webView.createWebMessageChannel();

        //供native使用的port
        nativePort = channel[0];
        //供h5使用的port
        h5Port = channel[1];
        //監聽從h5Port中發送過來的消息
        nativePort.setWebMessageCallback(new WebMessagePort.WebMessageCallback() {
            @Override
            public void onMessage(WebMessagePort port, WebMessage message) {
                XLog.d(TAG, ":onMessage:" + message.getData());
                postMessageToH5("hello from native:" + this.getClass().getName());
            }
        });
        //發送webmessage,把h5Port發送給H5頁面
        XLog.d(TAG, "start postWebMessage to transfer port");
        WebMessage webMessage = new WebMessage("__init_port__", new WebMessagePort[]{h5Port});
        webView.postWebMessage(webMessage, Uri.EMPTY);
    }

    /**
     * 通過port,向H5發送webMessage
     *
     * @param msg
     */
    @TargetApi(Build.VERSION_CODES.M)
    private void postMessageToH5(String msg) {
        nativePort.postMessage(new WebMessage(msg));
    }

}      

不過,MessageChannel需要在WebView的onPageFinished以後才能建立,時機相對較晚。也即是在WebViewClient的onPageFinished回調中,調用init方法。首先通過webView.createWebMessageChannel建立端口,将其中一個供native使用,并設定消息監聽回調,另外一個則通過webView.postWebMessage,将内容為​

​__init_port__​

​的WebMessage發送給前端,消息中攜帶了WebMessagePort端口資訊。其中``init_port`是我們自定義的協定,供前端判斷。

接下來,看前端部分的代碼實作,前端通過window.addEventListener,監聽所有的message消息。然後判斷messageEvent的data部分是否是​

​__init_port__​

​​,如果是的話,就從messageEvent中擷取到native傳過來的port對象,将其挂載到window上,比如​

​window.__my_port__​

​​,并設定onmessage消息監聽,用于監聽android發送過來的消息。另外,我們也可以通過​

​window.__my_port__​

​向native發送消息。

document.getElementById("a10").addEventListener("click", function () {
    sendMessageToNative();
  });

  function sendMessageToNative() {
    if (window.__my_port__) {
      window.__my_port__.postMessage("h5 test message");
    }
  }

  window.addEventListener("message", receiverMessage, false);

  function receiverMessage(messageEvent) {
    console.log("onmessage...", JSON.stringify(messageEvent.data));
    if (messageEvent.data === "__init_port__") {
      //在window上挂載port對象,将native發過來的h5Port引用儲存起來
      window.__my_port__ = messageEvent.ports[0];
      //設定消息
      window.__my_port__.onmessage = function (f) {
        console.log("recv msg from native...");
        onChannelMessage(f.data);
      };
    }
  }

  function onChannelMessage(msg) {
    const content = "msg from native:" + msg;
    document.getElementById("text").innerHTML = "<span>" + content + "</span>";
  }      

以上即完成native和H5通過MessageChannel進行通信的過程。基于這個消息通道,我們可以自定義自己的通信協定。MessageChannel的優勢就是可以減少序列化和反序列化,提升消息通信的性能。​​stackoverflow​​也有關于這個問題的讨論和解答。

18.webview的安全問題

  • addJavscriptInterface

addJavscriptInterface是Android 4.2之前的安全漏洞,4.2以上,可通過@JavascriptInterface注解來聲明JS可以通路的native方法,這裡不再贅述。

  • file協定的跨域

手動配置setAllowFileAccessFromFileURLs或setAllowUniversalAccessFromFileURLs兩個API為false當然省事,但是很多場景需要使用到通路本地資源。此時則需要對uri進行校驗或權限控制。其他情況,參見國家資訊安全漏洞平台的公告​​https://www.cnvd.org.cn/webinfo/show/4365?from=timeline​​

  • 其他安全漏洞:TODO

19.V8引擎(J2V8)

  • V8的引入以及初始化
implementation 'com.eclipsesource.j2v8:j2v8:6.0.0@aar'      
  • 初始化
v8runtime = V8.createV8Runtime();      
  • 橋接console.log
/**
     * 橋接v8中的console.log
     */
    private void registerLogMethod() {
        AndroidConsole androidConsole = new AndroidConsole();
        V8Object v8Object = new V8Object(v8runtime);
        v8runtime.add("console", v8Object);
        //params1:對象
        //params2:java方法名
        //params3:js裡面寫的方法名
        //params4:方法的參數類型 個數
        v8Object.registerJavaMethod(androidConsole, "log", "log", new Class<?>[]{String.class});
        v8Object.registerJavaMethod(androidConsole, "logObj", "logObj", new Class<?>[]{V8Object.class});
        v8Object.registerJavaMethod(androidConsole, "error", "error", new Class<?>[]{String.class});
        //在js中調用 `console.log('test')`
        v8runtime.executeScript("console.log('test');");
        v8Object.close();
    }      

其中,AndroidConsole的源碼為:

public class AndroidConsole {

    private static final String TAG = ">>>AndroidConsole<<<";

    /**
     * 通過反射注冊Java方法
     *
     * @param msg
     */
    public void log(String msg) {
        XLog.d(TAG, msg);
    }

    public void logObj(V8Object msg) {
        try {
            JSONObject jsonObject = XV8Utils.toJSONObject(msg);
            XLog.d(TAG, jsonObject.toJSONString());
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }

    /**
     * 通過反射注冊Java方法
     *
     * @param msg
     */
    public void error(String msg) {
        XLog.e(TAG, msg);
    }
}      

這樣,在js中調用​

​console.log​

​,将會回調到AndroidConsole的log方法,然後就可以看到v8中的日志資訊了。

  • JSBridge的整體設計

整體思路是,注冊一個Java回調與對應的JS方法做綁定,比如

//向v8中注入對象Java方法
v8runtime.registerJavaMethod(new XV8JsBridgeCallback(this), "__xBridge_js_func__");      

當JS中調用​

​__xBridge_js_func__​

​的時候,會回調到XV8JsBridgeCallback的invoke方法。

public class XV8JsBridgeCallback implements JavaVoidCallback {

    XV8Manager mXV8Manager;

    public XV8JsBridgeCallback(XV8Manager manager) {
        this.mXV8Manager = manager;
    }

    @Override
    public void invoke(V8Object receiver, V8Array params) {
        //擷取JS中傳遞過來的參數
        JSONArray jsonArray = XV8Utils.toJSONArray(params);
        if (jsonArray != null) {
            XLog.d("V8ArrayCallBack:" + jsonArray.toJSONString());
        }
        String string = (String)jsonArray.get(0);
        //擷取消息,然後處理消息
        new V8BridgeImpl(mXV8Manager).handleMsgFromJS(string);
        params.close();
        receiver.close();
    }
}      

然後就是JS層的設計,參考第一部分關于WebView的設計,進行如下改造,比如JSBridge的檔案名稱為​

​xbridge4v8.js​

​,其内容如下:

var XJSBridge;
(function () {
  var callbackArr = {};
  XJSBridge = {
    callNative: function (func, param, callback) {
      var seqId = "__bridge_id_" + Math.random() + "" + new Date().getTime();
      callbackArr[seqId] = callback;
      var msgObj = {
        func: func,
        param: param,
        msgType: "callNative",
        seqId: seqId,
      };
      if (typeof __xBridge_js_func__ === "undefined") {
        console.log("__xBridge_js_func__ not register before...");
      } else {
        __xBridge_js_func__(JSON.stringify(msgObj));
      }
    },
    nativeInvokeJS: function (res) {
      console.log("nativeInvokeJS start...");
      try {
        var resObj = JSON.parse(res);
        if (resObj && resObj.msgType === "jsCallback") {
          var func = callbackArr[resObj.seqId];
          if ("function" === typeof func) {
            func(resObj.param);
          }
        } else {
          console.log("error...");
        }
        return true;
      } catch (error) {
        console.error(error);
        return false;
      }
    },
  };
})();      

與WebView的JSBridge的協定内容一緻,但是最終是通過​

​__xBridge_js_func__​

​方法調用到native。

//執行自定義的JSBridge代碼
v8runtime.executeVoidScript(bridge4v8);
//擷取JSBridge對象
mXJSBridge = v8runtime.getObject("XJSBridge");
//擷取調用JS的方法
mNativeInvokeJS = (V8Function) mXJSBridge.getObject("nativeInvokeJS");      

與webview不同,webview是通過​

​loadUrl​

​​或者​

​evaluateJavascript​

​​來調用JS,在v8中,我們是通過v8擷取對應的JSFunction的對象,來進行調用的,比如以上代碼裡,我們儲存了​

​mNativeInvokeJS​

​​對象。當我們JSBridge中處理完之後,就可以通過​

​mNativeInvokeJS​

​回調給JS了。

private void sendToV8RuntimeInUiThread(String msg) {
  V8Array args = new V8Array(v8runtime);
  args.push(msg);
  mNativeInvokeJS.call(mXJSBridge, args);
  args.close();
}      
  • V8與WebView的通信(以點選dom觸發V8調用http請求為例,大緻的流程)
  • 1.使用者點選webview中的dom,通過webview的JSBridge,将事件傳遞給native
  • 2.native對事件進行分發,找到對應的JSBridge,對事件進行進行解析
  • 3.然後通過v8的JSBridge,這裡的​

    ​mNativeInvokeJS​

    ​調用V8中的JS方法
  • 4.V8中的JS觸發對應的JS方法(需要通過反射調用)
  • 5.JS方法中,調用v8的JSBridge,比如http的JSBridge,調用到native
  • 6.回調到XV8JsBridgeCallback的invoke方法
  • 7.通過V8BridgeImpl對消息進行分發,然後找到對應的HttpBridege
  • 8.HttpBridege通過Okhttp發送請求
  • 9.将http的結果回調給v8中的JS
  • 10.v8中的JS再調用JSBridge,将http的結果,發送給native
  • 11.native再對消息進行分發,調用PostMsgToWebViewBridge這個JSBridge
  • 12.JSBridge通過loadUrl或者evaluateJavascript,或者MessageChannel,發給WebView
  • 13.WebView接收到native發過來的消息,然後展示到dom中。

以上流程,第4步還未完成,是以,通過v8直接運作的​

​v8demo.js​

​​,其中​

​v8demo.js​

​的代碼如下:

(function () {
  console.log("start execute...");
  if (XJSBridge) {
    XJSBridge.callNative(
      "http",
      { url: "https://news-at.zhihu.com/api/4/news/latest", data: {} },
      (resp) => {
        if (resp) {
          console.logObj(resp);
          XJSBridge.callNative("postMsgToWebView", resp, () => {
            console.log("post end.");
          });
        }
      }
    );
  } else {
    console.log("XJSBridge is invalid.");
  }
})();      

如果通過MessageChannel進行通信,​

​v8_demo.html​

​的示範代碼如下:

<h1>H5與V8引擎測試頁面</h1>

<a href="javascript:void(0)" class="btn read" id="a1">v8測試</a
><br /><br /><br />

<body>
  <div id="text"></div>
  <br /><br /><br />
  <div id="container"></div>
</body>
<!-- 在這裡注入JSBridge,由webview攔截這個連結,然後替換成assets目錄的xbridge.js檔案-->
<script src="https://www.baidu.com/xbridge.js"></script>
<script>.getElementById("a1").addEventListener("click", function () {
    window.XJSBridge.callNative("nativeMethod", { name: "test" }, (res) => {
       //TODO
    });
  });

  window.addEventListener("message", receiverMessage, false);

  function receiverMessage(messageEvent) {
    console.log("onmessage...", JSON.stringify(messageEvent.data));
    if (messageEvent.data === "__init_port__") {
      //在window上挂載port對象,将native發過來的h5Port引用儲存起來
      window.__my_port__ = messageEvent.ports[0];
      //設定消息
      window.__my_port__.onmessage = function (f) {
        console.log("recv msg from v8...");
        onChannelMessage(f.data);
      };
    }
  }

  function onChannelMessage(msg) {
    const content = "msg from native:" + msg;
    document.getElementById("text").innerHTML = "<span>" + content + "</span>";
  }</script>      
  • V8中的setTimeout,setInterval,clearTimeout,clearInterval問題

類似console.log的方式,通過native實作

20.借助Glide圖檔加載架構對webview的圖檔進行緩存

第12部分講了基于離線包(packageApp)級别的緩存,将前端的html、js、css等進行了統一的緩存。在APP啟動的時,将離線包加載到記憶體中。當Webview加載H5頁面時,根據虛拟域名比對,加載本地的資源。但是,離線包在進行網絡請求之後,會開始渲染服務端json資料,這裡面有一些圖檔資源。我們如何對圖檔進行緩存呢?native通過圖檔加載架構(比如Glide)實作圖檔的多級緩存及複用,WebView則沒有這麼能力,本小節就是通過Glide實作圖檔資源的攔截和緩存。

  • 接入Glide
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'      
  • 對圖檔url進行判斷,使用Glide加載圖檔

完整代碼如下,主要是通過Glide将圖檔加載為bitmap,然後建構WebResourceResponse即可。

public class GlideImgCacheManager {

    private static final String TAG = "GlideCache";

    private static GlideImgCacheManager sGlideImgCacheManager = null;

    //隻緩存白名單中的圖檔資源
    private static final HashSet CACHE_IMG_TYPE = new HashSet() {
        {
            add("png");
            add("jpg");
            add("jpeg");
            add("bmp");
            add("webp");
        }
    };

    public synchronized static GlideImgCacheManager getInstance() {
        if (sGlideImgCacheManager == null) {
            sGlideImgCacheManager = new GlideImgCacheManager();
        }
        return sGlideImgCacheManager;
    }

    /**
     * 攔截資源
     *
     * @param url
     * @return
     */
    public WebResourceResponse interceptRequest(WebView webView, String url) {
        try {
            String extension = MimeTypeMapUtils.getFileExtensionFromUrl(url);
            if (TextUtils.isEmpty(extension) || !CACHE_IMG_TYPE.contains(extension.toLowerCase())) {
                //不在支援的緩存範圍内
                return null;
            }
            XLog.d(TAG, String.format("start glide cache img (%s),url:%s", extension, url));
            long startTime = System.currentTimeMillis();
            //String mimeType = MimeTypeMapUtils.getMimeTypeFromUrl(url);
            InputStream inputStream = null;
            Bitmap bitmap = Glide.with(webView).asBitmap().diskCacheStrategy(DiskCacheStrategy.ALL).load(url).submit().get();
            inputStream = getBitmapInputStream(bitmap, Bitmap.CompressFormat.JPEG);
            long costTime = System.currentTimeMillis() - startTime;
            if (inputStream != null) {
                XLog.d(TAG, String.format("glide cache img(%s ms): %s", costTime, url));
                WebResourceResponse webResourceResponse = new WebResourceResponse("image/jpg", "UTF-8", inputStream);
                return webResourceResponse;
            } else {
                XLog.e(TAG, String.format("glide cache error.(%s ms): %s", costTime, url));
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
        return null;
    }

    /**
     * 将bitmap進行壓縮轉換成InputStream
     *
     * @param bitmap
     * @param compressFormat
     * @return
     */
    private InputStream getBitmapInputStream(Bitmap bitmap, Bitmap.CompressFormat compressFormat) {
        try {
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            bitmap.compress(compressFormat, 80, byteArrayOutputStream);
            byte[] data = byteArrayOutputStream.toByteArray();
            return new ByteArrayInputStream(data);
        } catch (Throwable t) {
            t.printStackTrace();
        }
        return null;
    }
}      
  • 在webView的WebViewClient中攔截
...
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        WebResourceResponse webResourceResponse = GlideImgCacheManager.getInstance().interceptRequest(view, request.getUrl().toString());
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, request);
    }      

21.借助OkHttp的緩存政策做url級别的緩存&預加載

第12部分講了基于離線包(packageApp)級别的緩存,20講了基于Glide對圖檔資源的緩存。那麼,如果不是離線包,是線上頁面,如何對線上頁面的文檔、js、css以及其他檔案進行緩存呢?我們可以通過OkHttp替代WebView的網絡請求,然後使用OkHttp的緩存政策,來緩存WebView中需要加載的url資源。

  • 建立OkHttp的攔截器Interceptor
public class MyOkHttpCacheInterceptor implements Interceptor {

    private int maxAga = 365;//default
    private TimeUnit timeUnit = TimeUnit.DAYS;

    public void setMaxAge(int maxAga, TimeUnit timeUnit) {
        this.maxAga = maxAga;
        this.timeUnit = timeUnit;
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        Response response = chain.proceed(chain.request());

        CacheControl cacheControl = new CacheControl.Builder()
                .maxAge(maxAga, timeUnit)
                .build();

        return response.newBuilder()
                .removeHeader("Pragma")
                .removeHeader("Cache-Control")
                .header("Cache-Control", cacheControl.toString())
                .build();
    }
}      
  • 對url資源進行攔截,然後使用OkHttp進行加載和緩存
public class OkHttpCacheManager {

    private static final String TAG = "CACHE";

    private static OkHttpCacheManager sOkHttpCacheManager;

    private int allCount = 0;
    private int cacheCount = 0;

    //隻緩存白名單中的資源
    private static final HashSet CACHE_MIME_TYPE = new HashSet() {
        {
            add("html");
            add("htm");
            add("js");
            add("ico");
            add("css");
            add("png");
            add("jpg");
            add("jpeg");
            add("gif");
            add("bmp");
            add("ttf");
            add("woff");
            add("woff2");
            add("otf");
            add("eot");
            add("svg");
            add("xml");
            add("swf");
            add("txt");
            add("text");
            add("conf");
            add("webp");
        }
    };

    public synchronized static OkHttpCacheManager getIntance() {
        if (sOkHttpCacheManager == null) {
            sOkHttpCacheManager = new OkHttpCacheManager();
        }
        return sOkHttpCacheManager;
    }

    private OkHttpClient mHttpClient;

    private OkHttpCacheManager() {
        //設定緩存的目錄檔案
        File httpCacheDirectory = new File(XApplication.getApplication().getExternalCacheDir(), "x-webview-http-cache");
        //僅作為日志使用
        if (httpCacheDirectory.exists()) {
            List<File> result = XFileUtils.listFiles(httpCacheDirectory);
            for (File file : result) {
                XLog.d(TAG, "file = " + file.getAbsolutePath());
            }
        }
        //緩存的大小,OkHttp會使用DiskLruCache緩存
        int cacheSize = 20 * 1024 * 1024; // 20 MiB
        Cache cache = new Cache(httpCacheDirectory, cacheSize);
        //設定緩存
        mHttpClient = new OkHttpClient.Builder()
                .addNetworkInterceptor(new MyOkHttpCacheInterceptor())
                .cache(cache)
                .build();
    }

    /**
     * 針對url級别的緩存,包括主文檔,圖檔,js,css等
     *
     * @param url
     * @param headers
     * @return
     */
    public WebResourceResponse interceptRequest(String url, Map<String, String> headers) {
        try {
            String extension = MimeTypeMapUtils.getFileExtensionFromUrl(url);
            if (TextUtils.isEmpty(extension) || !CACHE_MIME_TYPE.contains(extension.toLowerCase())) {
                //不在支援的緩存範圍内
                XLog.w(TAG + "+" + url + " 's extension is " + extension + "!!not support...");
                return null;
            }
            long startTime = System.currentTimeMillis();
            Request.Builder reqBuilder = new Request.Builder()
                    .url(url);
            if (headers != null) {
                for (Map.Entry<String, String> entry : headers.entrySet()) {
                    XLog.d(TAG, String.format("header:(%s=%s)", entry.getKey(), entry.getValue()));
                    reqBuilder.addHeader(entry.getKey(), entry.getValue());
                }
            }

            Request request = reqBuilder.get().build();
            Response response = mHttpClient.newCall(request).execute();
            if (response.code() != 200) {
                XLog.e(TAG, "response code = " + response.code() + ",extension = " + extension);
                return null;
            }
            String mimeType = MimeTypeMapUtils.getMimeTypeFromUrl(url);
            XLog.d(TAG, "mimeType = " + mimeType + ",extension = " + extension + ",url = " + url);
            WebResourceResponse okHttpWebResourceResponse = new WebResourceResponse(mimeType, "", response.body().byteStream());
            Response cacheRes = response.cacheResponse();
            long endTime = System.currentTimeMillis();
            long costTime = endTime - startTime;
            allCount++;
            if (cacheRes != null) {
                cacheCount++;
                XLog.e(TAG, String.format("count rate = (%s),costTime = (%s);from cache: %s", (1.0f * cacheCount / allCount), costTime, url));
            } else {
                XLog.e(TAG, String.format("costTime = (%s);from server: %s", costTime, url));
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                String message = response.message();
                if (TextUtils.isEmpty(message)) {
                    message = "OK";
                }
                try {
                    okHttpWebResourceResponse.setStatusCodeAndReasonPhrase(response.code(), message);
                } catch (Exception e) {
                    return null;
                }
                Map<String, String> header = MimeTypeMapUtils.multimapToSingle(response.headers().toMultimap());
                okHttpWebResourceResponse.setResponseHeaders(header);
            }
            return okHttpWebResourceResponse;
        } catch (Throwable t) {
            t.printStackTrace();
        }
        return null;
    }

}      
  • 在webView的WebViewClient中設定
...
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        WebResourceResponse webResourceResponse = OkHttpCacheManager.getIntance().interceptRequest(request.getUrl().toString(), request.getRequestHeaders());
        if (webResourceResponse != null) {
            return webResourceResponse;
        }
        return super.shouldInterceptRequest(view, request);
    }      
  • 預加載緩存
...
     //記憶體級别的預加載緩存
    private static LruCache<String, byte[]> preLoadCache = new LruCache<>(100);
    ...
    /**
     * 預加載資源
     *
     * @param urls
     */
    public void preLoadResource(final List<String> urls) {
        XApplication.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                for (String url : urls) {
                    try {
                        XLog.e(TAG, "start load res:" + url);
                        Request.Builder reqBuilder = new Request.Builder()
                                .url(url);
                        Request request = reqBuilder.get().build();
                        Response response = mHttpClient.newCall(request).execute();
                        if (response.code() == 200) {
                            XLog.e(TAG, "res preload success..." + url);
                            //儲存下載下傳的資源
                            preLoadCache.put(url, response.body().bytes());
                        }
                    } catch (Throwable t) {
                        t.printStackTrace();
                    }
                }
            }
        });
    }
    ...

    public WebResourceResponse interceptRequest(String url, Map<String, String> headers) {
        try {
            ...
            //預加載
            if (preLoadCache.get(url) != null) {
                byte[] contentByte = preLoadCache.get(url);
                InputStream inputStream = new ByteArrayInputStream(contentByte);
                WebResourceResponse webResourceResponse = new WebResourceResponse(MimeTypeMapUtils.getMimeType(url), "UTF-8", inputStream);
                XLog.e(TAG, "hit preload cache.url = " + url);
                return webResourceResponse;
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
        return null;
      }      
List<String> urls = new ArrayList<>();
urls.add("https://as.alipayobjects.com/g/component/fastclick/1.0.6/fastclick.js");
urls.add("https://as.alipayobjects.com/g/component/es6-promise/3.2.2/es6-promise.min.js");
urls.add("https://www.baidu.com/index.html");
OkHttpCacheManager.getIntance().preLoadResource(urls);