Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iframe 与 webview ,记录一次使用 jsBridge 遇到的 bug 解决过程 #6

Open
Anshiii opened this issue May 22, 2019 · 0 comments
Labels
jsBridge 原生与 js 的通讯

Comments

@Anshiii
Copy link
Owner

Anshiii commented May 22, 2019

前提-出现场景

  1. 使用机型为 Android 9,API 28
  2. 使用的 jsBridge 为 link

bug 描述

在页面加载前后如果连续多次调用原生的方法,会遇到回调参数未被调用的情况。

// 多次调用如下函数, 部分 callback 将不会被调用
window.WebViewJavascriptBridge.callHandler(api, parameter, callback);

bug 的稳定复现方式

在页面加载时通过jsBridge和原生进行10次以上的数据交换。

出现的原因

查询所得

在多篇文章(1,2)中看到是因为 jsBridge 使用 iframe 的 src 变化 和 shouldOverrideUrlLoading 来实现原生与js的沟通导致的问题,而刷新 iframe 并不能保证 shouldOverrideUrlLoading 会被调用

于是我们以此为假设进行验证

  • 验证1: jsBridge 是否使用 iframe.src 的变化来进行js与原生的通讯

    我们可以直接看看进行一次完整的通讯的调用过程。

 //依据调用链 
 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,完成一次通讯

验证1证实完毕。

  • 验证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 上….当然我的方案不是最好的,如果你有更好的方案,欢迎留言。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
jsBridge 原生与 js 的通讯
Projects
None yet
Development

No branches or pull requests

1 participant