iframe 與 webview ,記錄一次使用 jsBridge 遇到的 bug 解決過程

前提-出現場景

  1. 使用機型爲 Android 9,API 28
  2. 使用的 jsBridge 爲 link

bug 描述

在頁面加載先後若是連續屢次調用原生的方法,會遇到回調參數未被調用的狀況。javascript

// 屢次調用以下函數, 部分 callback 將不會被調用
window.WebViewJavascriptBridge.callHandler(api, parameter, callback);

複製代碼

bug 的穩定復現方式

在頁面加載時經過jsBridge和原生進行10次以上的數據交換。html

出現的緣由

查詢所得

在多篇文章(1,2)中看到是由於 jsBridge 使用 iframe 的 src 變化 和 shouldOverrideUrlLoading 來實現原生與js的溝通致使的問題,而刷新 iframe 並不能保證 shouldOverrideUrlLoading 會被調用java

因而咱們以此爲假設進行驗證android

  • 驗證1: jsBridge 是否使用 iframe.src 的變化來進行js與原生的通信git

    咱們能夠直接看看進行一次完整的通信的調用過程。github

//依據調用鏈 
 window.WebViewJavascriptBridge.callHandler(api, parameter, callback);
 
 function callHandler(handlerName, data, responseCallback) {
   _doSend(
     {
       handlerName: handlerName,
       data: data
     },
     responseCallback
   );
 }
 
 function _doSend(message, responseCallback) {
   if (responseCallback) {
     var callbackId = "cb_" + uniqueId++ + "_" + new Date().getTime();
     responseCallbacks[callbackId] = responseCallback;
     message.callbackId = callbackId;
   }
 
   sendMessageQueue.push(message);
   //改變html內的iframe的src
   messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + "://" + QUEUE_HAS_MESSAGE;
 }
 
  // 此時步驟轉到原生層面
複製代碼
// shouldOverrideUrlLoading 將在 iframe.src 改變時被調用
public boolean shouldOverrideUrlLoading(WebView view, String urlString) {
    super.shouldOverrideUrlLoading(view, urlString);
    if (PhoneUtil.INSTANCE.startTelActivity(getActivity(), urlString)) return true;
    if (mWebViewHelper.shouldOverrideUrlLoading(view, urlString)) return true;
    return false;
}

//父類的 shouldOverrideUrlLoading 
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    try {
        url = URLDecoder.decode(url, "UTF-8");
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
    }

  	// 根據 url 的內容,區分是哪一種類型的操做
  	// 事實上 只有 YY_RETURN_DATA 和 YY_OVERRIDE_SCHEMA 兩種
  	// YY_RETURN_DATA 根據 url 的 參數,返回數據,即原生備好數據後調用 js 原生方法(js 的回調函數)
  	// YY_OVERRIDE_SCHEMA 則注入腳本到 webview 調用 js 原生方法 _fetchQueue
    if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) { 
        webView.handlerReturnData(url);
        return true;
    } else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) { //
        webView.flushMessageQueue();
        return true;
    } else {
        return super.shouldOverrideUrlLoading(view, url);
    }
}

//通信結束 

複製代碼
// YY_OVERRIDE_SCHEMA 類型通信所調用的原生方法
function _fetchQueue() {
  var messageQueueString = JSON.stringify(sendMessageQueue);
  console.warn(++count, "-", messageQueueString);
  sendMessageQueue = [];
  //android can't read directly the return data,
  //so we can reload iframe src to communicate with java
  messagingIframe.src =
    CUSTOM_PROTOCOL_SCHEME +
    "://return/_fetchQueue/" +
    encodeURIComponent(messageQueueString);
}
複製代碼

從源碼能夠看出,一個完整的通信過程,將改變兩次 src,也就是說 shouldOverrideUrlLoading 會被調用兩次(預計)。@Q說來 jsBridge 設計也奇怪,爲何不設計成一次 src,完成一次通信web

驗證1證明完畢。api

  • 驗證2:iframe 改變 src 是否與 shouldOverrideUrlLoading 調用次數一致。數組

    我在 WebViewJavascriptBridge.js 中對 ifram.src 的變化 和 BasewebviewFragment.java 的 shouldOverrideUrlLoading 調用進行計數,發現兩邊的次數確實不一致。網絡

    通信狀態 iframe 的 src 改變次數 shouldOverrideUrlLoading 被調用次數
    預計 18 18
    T 13 9
    T 17 14
    T 13 6
    F 17 18
    F 6 3
    T 11 8

    驗證2 證明完畢。

    同時咱們也得知,就算兩者調用次數不一致,也不影響 js 與 native 的通信,幾回通信成功的狀況兩者的次數都不一致,甚至咱們能夠初步預測,兩者的次數根本不須要一致就能實現通信。

    @Q 那麼通信成功的充分必要條件是什麼呢?

通信失敗的緣由

回顧咱們以前所作的驗證1,一個完整的通信過程,其調用時序圖以下:

jsBridge時序圖

回顧咱們最初遇到的問題,屢次調用 callHandler 後,部分 callback 沒有被調用,致使通信失敗

根據流程圖逆行推理, callback 未被調用 => 表示攜帶該callback 的 respMessage 未被傳遞過來,也就是說 yy://return/ ${resp} 缺失了 => _fetchQueue 傳遞的數據有缺失

function _fetchQueue() {
  var messageQueueString = JSON.stringify(sendMessageQueue);  
  
  // ATENTION 這裏在將 string 化後當即清空了當前的 messageQueue 
  sendMessageQueue = [];
  
  messagingIframe.src =
    CUSTOM_PROTOCOL_SCHEME +
    "://return/_fetchQueue/" +
    encodeURIComponent(messageQueueString);
}
複製代碼

從 _fetchQueue 的源碼中,發如今將 message 傳遞後就立馬清空了,實際上這並不許確,由於連續N次改變 iframe 的 src ,shouldOverrideUrlLoading 的實際調用次數爲 M(M<N),且將之後一次調用時的參數爲準。

webview的輸出

原生的輸

上述圖示是一次失敗通信的日誌,能夠看到,前6次調用爲 _doSend 的調用,即改變了 6次 iframe 的 src,但實際上只有兩次生效了,第一次生效的通信調用了 _fetchQueue ,傳遞前 6 次的 message 給 native,可是因爲清空了 message 隊列,緊跟的第二次 _fetchQueue 執行時傳遞空數組給 native ,又由於兩次 _fetchQueue 的調用間隔過短,實際上只有第二次 _fetchQueue 的調用傳遞給了 native ,此時 native 只收到一個 空數組的 通信,天然沒有了後續的操做。

因此咱們最初 callHandler 裏的 callback,都沒人再調用了...

解決方法

緣由已經明瞭,當前的問題是如何解決。切入點有如下幾個,

  1. 查清爲何屢次 iframe.src 變化只調用更少次數的 shouldOverrideUrlLoading,並解決...
  2. 修改 _fetchQueue 函數
  3. js 在調用時只能線性調用

鑑於1的實施難度對我這個切圖仔來講有點大,優先考慮後續兩個解決方法。

修改 _fetchQueue 函數

  1. 線性調用 _fetchQueue ,主要代碼以下。
function _fetchQueue() {
    if (sendMessageQueue.length === 0 || fetchingQueueLength > 0) {
        return;
    }

    // 記錄當前等待 native 響應的個數
    fetchingQueueLength += sendMessageQueue.length;
    
    var messageQueueString = JSON.stringify(sendMessageQueue);
    sendMessageQueue = [];
    //android can't read directly the return data, so we can reload iframe src to communicate with java
    bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString);
}

/* ... */

function _dispatchMessageFromNative(messageJSON) {
    setTimeout(function() {
        var message = JSON.parse(messageJSON);

        fetchingQueueLength--;
        // 若是通信完畢,清理被阻塞的 message
        if (fetchingQueueLength === 0) {
            // 使用 sto,在當前的通信結束後再 _fetchQueue 
            setTimeout(function() {
                _fetchQueue();
            });
        }
      
      ...
複製代碼

以私有變量 fetchingQueueLength 記錄等待響應的 message 數量,可是存在隊首阻塞的問題,甚至由於沒保證因此沒采用。

  1. 既然是由於 _fetchQueue 調用間隔過短,因此就採用了切圖仔經常使用的節流方案。

    var lastCallTime = 0;
    var stoId = null;
    var FETCH_QUEUE = 20;
    
    function _fetchQueue() {
        // 空數組直接返回 
        if (sendMessageQueue.length === 0) {
          return;
        }
    
        if (new Date().getTime() - lastCallTime < FETCH_QUEUE) {
          if (!stoId) {
            stoId = setTimeout(_fetchQueue, FETCH_QUEUE);
          }
          return;
        }
    
        lastCallTime = new Date().getTime();
        stoId = null;
        var messageQueueString = JSON.stringify(sendMessageQueue);
        sendMessageQueue = [];
        //android can't read directly the return data, so we can reload iframe src to communicate with java
        bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString);
        
    }
    複製代碼

    這個 20 ms,其實我是有些隨意的定義的,從 200 開始向下試驗,20 是我以爲比較穩定一個數字… 。20 ms 內連續的調用 _fetchQueue 將只有一次生效,回顧以前通信流程的同窗應該知道 _fetchQueue 的觸發是依靠 native 的調用的,因此 _fetchQueue 的觸發對 _doSend 來講是異步的,因此並不須要一一對應,_doSend 只是往 sendMessageQueue 裏添加任務,而 _fetchQueue 只負責將 sendMessageQueue 裏的任務清空,只要保證至少有一個 _fetchQueue 晚於 _doSend 執行便可。

    可是這裏改動 WebViewJavascriptBridge.js 是須要從新發包的。

修改 js 調用時的函數

這個其實有點難處理,由於是在 js 層面,這裏解決的點仍然是 2. 中的 _fetchQueue 調用頻繁的問題,從這個角度切入有點隔山打牛的意味。可是由於改動只在頁面,不依賴原生髮包,因此在某些場景也適用。

這裏的思想相似,封裝 callHandler 函數,節流或者串行都可,固然串行就會有阻塞的可能,節流,這裏的節流是想讓 _fetchQueue 的調用節流,可是 _fetchQueue 的觸發畢竟是異步,並且掌控在原生代碼那邊,全部其實不太推薦適用這個方案。

隨便說說

縱觀整個通信過程,其實就是一個網絡協議的縮影。最開始考慮部分通信失敗的問題時,想的這是否是就是網絡裏的丟包,想一想 TCP 怎麼解決丟包的,好像是記錄字節序 + 定時器,可是這裏響應體只包含通信內容,光是標記請求就有點麻煩了,再加上定時器...若是要改就是大重構了…算了;後來開始針對 _fetchQueue ,要不就考慮學 HTTP 一來一回吧,可是這樣效率過低了,js 單線程也沒有併發,並且還有隊首阻塞的問題… 後來轉而一想,既然 fetchQueue 間隔短,那我控制間隔不就行了嗎…因而引入了節流的方案… 變更小代碼簡單易懂…雖然這個 20ms 不太具備事實依據性。

總的來講解決問題並不難,可貴是找到問題的核心,爲了這個我甚至找了原生開發小哥 copy 一份源碼…,好在以前有過 RN 調試經驗… 不至於卡在配置 android studio 上….固然個人方案不是最好的,若是你有更好的方案,歡迎留言。

相關文章
相關標籤/搜索