Android侧webview与Js通信的方式(1)

Android侧webview与Js通信的方式(1)

JsBridge原理介绍

Android侧JsBridge一般指 JsBridge,该框架对应ios侧的WebViewJavascriptBridge,两者的实现细节各有不同,但是总体原理一致。我们主要看一下其Js与Native通信原理的实现,对于具体的代码细节不做深究。

JsBridge集成

  • Js端

    集成源码中的js文件,WebViewJavascriptBridge.js,注意此处不可以通过注入的方式实现,不要被各种讲解博客误导。

  • Android侧

 dependencies {
   compile 'com.github.lzyzsd:jsbridge:1.0.4'
   }

Js调用Native

步骤

    1. js侧
function _doSend(message, responseCallback) {
          if (responseCallback) {
              //生成唯一callbackid用于标识该次jsbridge通信过程
              var callbackId = 'cb_' + (uniqueId++) + '_' + new  Date().getTime();
              responseCallbacks[callbackId] = responseCallback;
              message.callbackId = callbackId;
           }
           sendMessageQueue.push(message);
           //src:"yy://__QUEUE_MESSAGE__/"
           messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
           }
  • 2.native侧
      @Override
      public boolean shouldOverrideUrlLoading(WebView view, String url) {
      try {
          url = URLDecoder.decode(url, "UTF-8");
      } catch (UnsupportedEncodingException e) {
          e.printStackTrace();
      }

      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);
      }
      }

这里会走第二个if, 调用BridgeWebView的flushMessageQueue()方法

   void flushMessageQueue() {
        if (Thread.currentThread() == Looper.getMainLooper().getThread()) {
            loadUrl(BridgeUtil.JS_FETCH_QUEUE_FROM_JAVA, new  CallBackFunction() {

                @Override
                public void onCallBack(String data) {
                    ...
                }
            });
        }
    }

在这个flushMessageQueue方法里, 如果当前是主线程就调用一个loadUrl方法

    public void loadUrl(String jsUrl, CallBackFunction returnCallback) {
    
        // jsUrl = "javascript:WebViewJavascriptBridge._fetchQueue();"
        this.loadUrl(jsUrl);
        // 添加至 Map<String, CallBackFunction>
        String functionName = BridgeUtil.parseFunctionName(jsUrl);
        // functionName = "_fetchQueue"
        responseCallbacks.put(functionName, returnCallback);
    }

在这个方法里, 首先会调用WebViewJavascriptBridge的_fetchQueue()方法, 然后解析方 法名字, 因为这里的方法名字是写死的, 其实就是_fetchQueue, 请记住这个名字, 因为后面会用到.然后将以这个_fetchQueue为key, 回调方法为value, 放到一个map里面.然后我们再去看js那端的方法.

  • 3.js侧
       // 提供给native调用,该函数作用:获取sendMessageQueue返回给native,由于android不能直接获取返回的内容,所以使用url shouldOverrideUrlLoading 的方式返回内容
  function _fetchQueue() {
      var messageQueueString = JSON.stringify(sendMessageQueue);
      console.log('messageQueueString = ' + messageQueueString);
      sendMessageQueue = [];
      // android can't read directly the return data, so we can reload iframe src to communicate with java
      var src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString);
      messagingIframe.src = src;
  }
  • 4.native侧

    触发shouldOverrideUrlLoading方法,并走第一个if,触发handlerReturnData方法

        void handlerReturnData(String url) {
        // _fetchQueue
        String functionName = BridgeUtil.getFunctionFromReturnUrl(url);
        //取出flushMessageQueue方法中放入responseCallbacks队列中的callback 
        CallBackFunction f = responseCallbacks.get(functionName);
        //取出js侧传来的数据
        String data = BridgeUtil.getDataFromReturnUrl(url);
        if (f != null) {
            //执行callback
            f.onCallBack(data);
            responseCallbacks.remove(functionName);
            return;
         }
        }

在看一下这个callback

         void flushMessageQueue() {
         if (Thread.currentThread() == Looper.getMainLooper().getThread()) {
            loadUrl(BridgeUtil.JS_FETCH_QUEUE_FROM_JAVA, new CallBackFunction() {

                @Override
                public void onCallBack(String data) {
                    // deserializeMessage 反序列化消息
                    List<Message> list = null;
                    try {
                        list = Message.toArrayList(data);
                    } catch (Exception e) {
                        e.printStackTrace();
                        return;
                    }
                    if (list == null || list.size() == 0) {
                        return;
                    }
                    for (int i = 0; i < list.size(); i++) {
                        Message m = list.get(i);
                        String responseId = m.getResponseId();
                        // 是否是response  CallBackFunction
                        if (!TextUtils.isEmpty(responseId)) {
                            CallBackFunction function = responseCallbacks.get(responseId);
                            String responseData = m.getResponseData();
                            function.onCallBack(responseData);
                            responseCallbacks.remove(responseId);
                        } else {
                            CallBackFunction responseFunction = null;
                            // if had callbackId 如果有回调Id
                            final String callbackId = m.getCallbackId();
                            <br>
                            if (!TextUtils.isEmpty(callbackId)) {
                                responseFunction = new CallBackFunction() {
                                    @Override
                                    public void onCallBack(String data) {
                                        Message responseMsg = new Message();
                                        responseMsg.setResponseId(callbackId);
                                        responseMsg.setResponseData(data);
                                        queueMessage(responseMsg);
                                    }
                                };
                             <br/>
                            } else {
                                responseFunction = new CallBackFunction() {
                                    @Override
                                    public void onCallBack(String data) {
                                        // do nothing
                                    }
                                };
                            }
                            // BridgeHandler执行
                            BridgeHandler handler;
                            if (!TextUtils.isEmpty(m.getHandlerName())) {
                                handler = messageHandlers.get(m.getHandlerName());
                            } else {
                                handler = defaultHandler;
                            }
                            if (handler != null){
                                handler.handler(m.getData(), responseFunction);
                            }
                        }
                    }
                }
            });
          }
        }
        

首先将数据解析成一个Message的list, 这个Message是自定义的类, 里面包含两端协商好格式的信息,最后会执行到queueMessage(responseMsg)中

private void queueMessage(Message m) {
        if (startupMessage != null) {
            startupMessage.add(m);
        } else {
            dispatchMessage(m);
        }
    }

走dispatch方法

/**
     * 分发message 必须在主线程才分发成功
     * @param m Message
     */
    void dispatchMessage(Message m) {
        String messageJson = m.toJson();
        //escape special characters for json string  为json字符串转义特殊字符
        messageJson = messageJson.replaceAll("(\\\\)([^utrn])", "\\\\\\\\$1$2");
        messageJson = messageJson.replaceAll("(?<=[^\\\\])(\")", "\\\\\"");
        messageJson = messageJson.replaceAll("(?<=[^\\\\])(\')", "\\\\\'");
        // javascript:WebViewJavascriptBridge._handleMessageFromNative('{\"responseData\":\"http:\\\/\\\/ww3.sinaimg.cn\\\/mw690\\\/96a29af5jw8fdfu43tnvlj20ro0rotab.jpg\",\"responseId\":\"cb_4_1532856634427\"}');
        String javascriptCommand = String.format(BridgeUtil.JS_HANDLE_MESSAGE_FROM_JAVA, messageJson);
        // 必须要找主线程才会将数据传递出去 --- 划重点
        if (Thread.currentThread() == Looper.getMainLooper().getThread()) {
            this.loadUrl(javascriptCommand);
        }
    }

首先将这个Message转化成json格式的字符串, 去掉一些特殊字符, 然后再主线程调用js方法, 方法是WebViewJavascriptBridge._handleMessageFromNative方法

  • 5.js侧
// 提供给native调用,receiveMessageQueue 在会在页面加载完后赋值为null,所以
    function _handleMessageFromNative(messageJSON) {
        console.log(messageJSON);
        if (receiveMessageQueue && receiveMessageQueue.length > 0) {
            receiveMessageQueue.push(messageJSON);
        } else {
            _dispatchMessageFromNative(messageJSON);
        }
    }

_handleMessageFromNative方法会处理native传来数据,本次交互结束

  • 流程图

    avatar
  • 问题

看完JsBridge代码,可能大家都会有疑问,这一次流程中,为什么js侧与native侧为什么要交互两次,第一次其实并没有传任何有效数据过来,是否多余。下面我们着重看下这个问题。

Cordova方案参考

由于业界hybrid方案并不多,一般大厂的方案又较为复杂,而且网上资料基本没有任何对该问题的解释,因此本文参考了云闪付正在使用的hybrid方案cordova的通信逻辑。

Cordova方案相较Jsbridge方案更为重量级,十分复杂,因此本文并不做深入研究,仅针对其实现的Native、JS端通信逻辑进行研究。

ios侧

ios侧一般有两种方式,核心代码如下

if (bridgeMode === jsToNativeModes.WK_WEBVIEW_BINDING) {
        window.webkit.messageHandlers.cordova.postMessage(command);
    } else {
        // If we're in the context of a stringByEvaluatingJavaScriptFromString call,
        // then the queue will be flushed when it returns; no need for a poke.
        // Also, if there is already a command in the queue, then we've already
        // poked the native side, so there is no reason to do so again.
        if (!isInContextOfEvalJs && commandQueue.length == 1) {
            switch (bridgeMode) {
            case jsToNativeModes.XHR_NO_PAYLOAD:
            case jsToNativeModes.XHR_WITH_PAYLOAD:
            case jsToNativeModes.XHR_OPTIONAL_PAYLOAD:
                pokeNativeViaXhr();    // 新建一个XMLHttpRequest,并发送一个HEAD请求,并将commondQueue以json串的形式放在请求头cmds上。
                break;
            default: // iframe-based.
                pokeNativeViaIframe(); // 创建iframe,通过hash值来传递commondQueue 或 execIframe.src = "gap://ready"
            }
        }
    }

可以看出有两种方式,一是新建一个XMLHttpRequest,并发送一个HEAD请求,并将commondQueue以json串的形式放在请求头cmds上。native侧进行拦截;二是创建iframe,通过hash值来传递commondQueue 或 execIframe.src = "gap://ready",与jsbridge一个原理。
ios端通过UIWebViewDelegate(iframe方式)或 NSURLProtocol拦截(xhr方式)方式接收到commondQueue后,执行插件的实际功能。
ios侧处理完后回消息给js侧也有两种方式一是通过UIWebView的stringByEvaluatingJavaScriptFromString方法,二是通过注入方式调用js侧iOSExec.nativeCallback方法。

Android侧

var messages = nativeApiProvider.get().exec(bridgeSecret, service, action, callbackId, argsJson); // 
// If argsJson was received by Java as null, try again with the PROMPT bridge mode.
// This happens in rare circumstances, such as when certain Unicode characters are passed over the bridge on a Galaxy S2.  See CB-2666.
if (jsToNativeBridgeMode == jsToNativeModes.JS_OBJECT && messages === "@Null arguments.") {
  androidExec.setJsToNativeBridgeMode(jsToNativeModes.PROMPT);
  androidExec(success, fail, service, action, args);
  androidExec.setJsToNativeBridgeMode(jsToNativeModes.JS_OBJECT);
  return;
} else {
  androidExec.processMessages(messages, true);
}
  1. 如果是JS_OBJECT方式,那么nativeApiProvider.get().exec= 安卓端源码中注解了@JavascriptInterface的 exec方法
  2. 如果是PROMPT方式,那么nativeApiProvider.get().exec 为如下方法:
exec: function(bridgeSecret, service, action, callbackId, argsJson) {
    return prompt(argsJson, 'gap:'+JSON.stringify([bridgeSecret, service, action, callbackId]));
  },

即通过promt()与native侧的onJsPromt()方法通信。

android侧回调回js侧也有两种方式,一是通过evaluateJavascript,二是通过loadurl。两种方式都是通过注入直接调用js侧androidExec.processMessages(messages, true)方法。

cordova总结

从以上分析可以看出,cordova不管在ios侧还是android侧,都是只通信一次。其中android侧js与native之间的通信使用了webview提供的多种api,接下来我们看一下这些api的特点及优劣。

交互方式总结

Android 端webview与Js通信的方式很多,要了解jsbridge两次通信是否合理,首先要了解下Android通过WebView与JS交互的方式。

  • 总体目录
avatar

Js主动调用Native

Js主动调Native主要有三种方式

  • 通过 WebView的addJavascriptInterface()(@JavascriptInterface)

    该方法通过addJavascriptInterface()将java对象映射到Js对象,js端直接调用即可,十分方便。但是该方法在Android4.2(17)之前有较大的安全漏洞,在Android <=4.1.2 (API 16),WebView使用WebKit浏览器引擎,并未正确限制addJavascriptInterface的使用方法,在应用权限范围内,攻击者可以通过Java反射机制实现任意命令执行。在Android >=4.2 (API 17),WebView使用Chromium浏览器引擎,并且限制了Javascript对Java对象方法的调用权限,只有声明了@JavascriptInterace注解的方法才能被Web页面调用。

    优点:使用简单
    缺点:API17之前有严重的安全漏洞

  • 通过 WebViewClient 的shouldOverrideUrlLoading ()拦截url

    1.js端通过修改iframe属性触发Android侧WebViewClient的回调方法shouldOverrideUrlLoading ()

    2.拦截、解析该 url 的协议

    3.如果检测到是预先约定好的协议,就调用相应方法

    优点:对Api无要求,不存在安全漏洞,较为通用

    缺点:需要js与native侧协商格式,JS获取Android方法的返回值复杂

  • 通过 WebChromeClient 的onJsAlert()、onJsConfirm()、onJsPrompt()拦截JS对话框alert()、confirm()、prompt()消息

    Android通过 WebChromeClient 的onJsAlert()、onJsConfirm()、onJsPrompt()方法回调分别拦截JS对话框 (即上述三个方法),得到他们的消息内容,然后解析即可。对比三个方法我们可以发现只有prompt()可以返回任意类型的值,操作最全面方便、更加灵活;而alert()对话框没有返回值;confirm()对话框只能返回两种状态(确定 / 取消)两个值,因此promt()方法较为合适

    avatar
  • 总结
    对比三种方式如下图

    image

    可以发现,利用WebChromeClient的onJsPrompt()方法拦截js侧的promt(),这种方式最合理

Native主动调用Js

  • 通过WebView的loadUrl(),及我们熟知的js注入

    通过webview的loadUrl()方法, mWebView.loadUrl("javascript:callJS()"),注意javascript为必加的前缀,callJS()为js对应方法名
    特别注意:
    1. JS代码调用一定要在 onPageFinished() 回调之后才能调用,否则不会调用。
    2. loadurl方法在url过长(2000个字符)时会失败,所以不要尝试将一些js文件通过注入的方式直接使用,What is the maximum length of a URL in different browsers?

    优点:对Api无要求,不存在安全漏洞,较为通用

    缺点:对注入代码长度有限制,且该方法执行会使页面刷新,并且无返回值

  • 通过WebView的evaluateJavascript()

    mWebView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
        @Override
        public void onReceiveValue(String value) {
            //此处为 js 返回的结果
          }
        });
      }
    

    优点:1. 该方法的执行不会使页面刷新。
    2. 有返回值,效率更高、使用更简洁。

    缺点:1. 要求Android4.4以上
    2. onReceiveValue(String value),value会多一对引号,需要特殊处理

  • 总结

    avatar
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,451评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,172评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,782评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,709评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,733评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,578评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,320评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,241评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,686评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,878评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,992评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,715评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,336评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,912评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,040评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,173评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,947评论 2 355