原生App与javascript交互之JSBridge接口原理、设计与实现

CatalinaMGY 5年前
   <h2>前期调研</h2>    <p>调研对象:</p>    <p>支付宝,微信,云之家</p>    <p>调研文档:</p>    <p><a href="/misc/goto?guid=4959733549143449947" rel="nofollow,noindex">Android中JS与Java的极简交互库 SimpleJavaJsBridge</a></p>    <h2>设计需求</h2>    <ol>     <li> <p>阅读类型的业务功能页面需要由前端H5实现,需要做到服务端可控;</p> </li>     <li> <p>页面界面更改减少重新发布新版本的频率;</p> </li>     <li> <p>功能页面部分原型需求无法实现,需要原生功能支持;</p> </li>     <li> <p>对未来业务功能的拓展,方便迭代;</p> </li>    </ol>    <h2>作用和意义</h2>    <ol>     <li> <p>定制化JSBridge实际上是拓展NativeApp的hybrid程度, 参照微信和支付宝,可打造APP强力的生态圈;</p> </li>     <li> <p>jsBridge在支付,钱包,媒体拓展,图片处理,活动页面,用户地理位置网络状态都能得到原生强有力支持;</p> </li>     <li> <p>对于阅读性页面有更多拓展;</p> </li>    </ol>    <h2>优秀的通信设计方案</h2>    <ol>     <li> <p>前端和Native对对方的细节知道的越少越好,减少耦合度,暴露的接口尽量控制在5个以内;</p> </li>     <li> <p>js与Native之间的通信,最好定义一套通信协议或者规则,减少js代码为兼容不同系统而过多if;</p> </li>     <li> <p>主动发送消息给对方时,对方尽量对该消息进行反馈,即使无需求对某些功能做反馈,减少if判断的兼容代码;</p> </li>    </ol>    <h2>实现方式(交互形式)</h2>    <h3>Native 调用 JS</h3>    <p>使用前端暴露在window下的一个方法或者一个对象的方法;</p>    <p>_handlerFromApp(message)<br> JSBridge._handlerFromApp(message)</p>    <p>方法名: handlerFromApp<br> 参数:</p>    <pre>  <code class="language-java">message: {    cbId  : "cb_(:id)_(:timeStamp)",      //回调函数的id    status: 0,                            //状态数据 (0:失败, 1:成功)    msg   : "ok",                         //反馈的消息    data  : {      //...                               //一些处理后的数据    }   }</code></pre>    <p>以下提供的部分参考方法</p>    <p>未对其进行真实测试,因为我使用的是iframe的方法,但原理几乎相同</p>    <p>建议封装后提供给Native开发工程师放入对应的APP包中,在webView读取页面的时候用对应的Native语言注入页面,避免页面在前端导入被抓取;</p>    <pre>  <code class="language-java">var doc = JSBridge || window;  var uniqueId = 1;  var invokeCBMap = {};  var listenCBMap = {};    //  function _send(type, funcName, data, cb) {    var _id = 'cb_' + (uniqueId++) + '_' + new Date().getTime();    data.cbId = _id;    if (type == 'invoke')       invokeCBMap[_id] = cb;    else if (type == 'listen')      listenCBMap[_id] = cb;    doc[type](funcName, data);  }  doc._handlerFromApp = function(msg) {    var _id = msg.cbId,        callback;    if (_id) {      callback = invokeCBMap[_id] || listenCBMap[_id];      if (callback) {        delete msg.cbId;        callback(msg.data);        delete invokeCBMap[_id];      } else {        console.error('不存在该回调方法');      }    }  }</code></pre>    <h3>JS调用Native</h3>    <p>以下只介绍前两个方法,第三个和第二个比较类似</p>    <ul>     <li> <p>A. Native暴露一个含有通信方法的类给web调用</p> </li>     <li> <p>B. Native拦截iframe请求</p> </li>     <li> <p>C. Native拦截prompt弹出框</p> </li>    </ul>    <p>A 一个包含调用方法的类</p>    <p>iOS : 可使用javascriptCore</p>    <p>Android: 直接使用WebView的addJavascriptInterface方法</p>    <p>将一个js对象绑定到一个Native类,在类中实现相应的函数,当js需要调用Native的方法时,只需要直接在js中通过绑定的对象调用相应的函数</p>    <p>确定对象名称: (:AppName)JSBridge</p>    <p>Native提供的对象含有的方法:</p>    <ul>     <li> <p>invoke(funcName, data)</p> </li>     <li> <p>listen(funcName, data)</p> </li>    </ul>    <p>invoke :用于web页面调用Native私有方法的通用方法</p>    <p><strong>参数</strong> : funcName , data</p>    <p>funcName :对应为Native内部私有方法的方法名或映射</p>    <p>data :web传递给Native的必要数据</p>    <p>data 数据结构如下:</p>    <pre>  <code class="language-java">{    cbId : "cb_(:id)_(:timeStamp)",  //回调函数的id    msg  : {}                        //提供给使用方法执行的一些参数  }  /**     //1.拿wx参考为例    wx.previewImg({      current: 'http://xxx_1.png',      urls   : [        'http: //xxx_0.png',        'http: //xxx_1.png',        'http: //xxx_2.png',        'http: //xxx_3.png',      ]    });    //2.因为wx对jsbridge进行了一次封装,jssdk, 而我们在未封装时应该如下使用    JSBridge.invoke('imagePreview', {      cbId : "cb_(:id)_(:timeStamp)",      msg : {        current: 'http://xxx_1.png',        urls   : [          'http: //xxx_0.png',          'http: //xxx_1.png',          'http: //xxx_2.png',          'http: //xxx_3.png',        ]      }    });  */</code></pre>    <p>那么当调用之后,Native执行完成对应的私有方法后,执行一次我们提供的回调接口,以下是javascript的语法,请Native开发工程师对应修改</p>    <pre>  <code class="language-java">JSBridge.handlerFromApp({    cbId  : "cb_(:id)_(:timeStamp)", //web传给Native的cbId    status: 1,                       //状态数据 (0:失败, 1:成功)    msg   : "预览成功",     data  : {}   });</code></pre>    <p>listen 是一个用于web页面监听Native方法实现的通用方法</p>    <p>使用环境: 不属于web页面上的操作。当用户直接操作Native上的功能来影响或发送数据给web,或者操作的功能需要用到web页面上的数据,我们需要告知Native我们希望能收到回调;</p>    <p>例子:</p>    <p>微信监听分享操作</p>    <ol>     <li> <p>分享的内容是web上的内容(标题,描述,图片);</p> </li>     <li> <p>获取分享操作是否完成和分享操作的数据收集;</p> </li>     <li> <p>分享按钮是原生APP提供;</p> </li>    </ol>    <p>数据结构和操作与 invoke 相似,对应Native开发哥们接收到listen操作后需要存储一个映射,在被监听的操作实现上判断是不是需要执行web端提供的回调接口;</p>    <p>注意: 有关 java addJavascriptInterface 的使用有漏洞,详情见参考第二条链接,未验证,仅供读者自行权衡;</p>    <p>B iframe的魔法</p>    <p>由于Native App可以监听webview的请求,所以web端通过创建一个隐藏的iframe,请求商定后的统一协议来发送数据给Native App;</p>    <pre>  <code class="language-java">function createIframeCall(url) {    setTimeout(function() {      var iframe = document.createElement('iframe');      iframe.style.width = '1px';      iframe.style.height = '1px';      iframe.style.display = 'none';      iframe.src = url;      document.body.appendChild(iframe);      setTimeout(function() {          document.body.removeChild(iframe);      }, 100);    }, 0);  }</code></pre>    <p>url 格式:</p>    <p>(:scheme)://register_type?func=(:funcName)&cbId=(:cbId)&data={...}&verifyTimeStamp=(:new Date().getTime())</p>    <p>scheme :协议,可用appName,两端商定,例如weixin,alipayjsbridge</p>    <p>register_type : 注册形式,即 invoke 还是 listen</p>    <p>funcName : Native内的方法名或映射</p>    <p>cbId :见上文</p>    <p>data :详细数据</p>    <p>verifyTimeStamp :验证的时间参数,不必须</p>    <pre>  <code class="language-java">;(function() {      if (window.ZaihuJSBridge) return;      var CUSTOM_PROTOCOL_SCHEME = 'zaihu';      var REGISTER_INVOKE = 'invoke';      var REGISTER_LISTEN = 'listen';      var uniqueId = 1;      var invokeCbMap = {};      var listenCbMap = {};      function dataHandler(type, funcName, data, cb) {        var register_type = '';        switch (type) {          case 'invoke':             register_type = REGISTER_INVOKE;break;          case 'listen':             register_type = REGISTER_LISTEN;break;          default: break;        }        var cbId = '';        if (cb) {          cbId = 'cb_' + (uniqueId++) + '_' + new Date().getTime();          invokeCBMap[cbId] = cb;        }        var dataStr = '';        if (data) dataStr = encodeURIComponent(JSON.stringify(data));        var paramStr = CUSTOM_PROTOCOL_SCHEME + '://' + register_type + '?func=' + funcName + (cbId ? ('&cbId=' + cbId): '') + (data ? ('&data=' + dataStr): '');        createIframeCall(paramStr);          }      function _invoke(nativeFuncName, data, cb) {        dataHandler('invoke', nativeFuncName, data, cb);      }            function _listen(h5FuncName, data, cb) {        dataHandler('listen', h5FuncName, data, cb);      }      function _handlerFromZaihu(msg) {        var data = JSON.parse(msg);        var cbId = data.cbId;        var cb = invokeCBMap[cbId] || listenCBMap[cbId];        if (cb) {          delete data.cbId && cb(data) && delete invokeCBMap[cbId];        }      }        var app;              app = {          version: '0.1',          invoke: _invoke,          on: _listen,          log: _log,          author: '伊吾鱼O(∩_V)O',          // private          _handlerFromApp: _handlerFromApp        };        window.JSBridge = app;  })()</code></pre>    <p>协作</p>    <ul>     <li> <p>需要Native开发兄弟在webview开启时候为页面注入jsbridge.js代码并执行(防止被前端浏览器直接查看源代码了解app的代码逻辑)</p> </li>     <li> <p>获取参数执行对应的功能后,执行回调</p> </li>    </ul>    <h2>页面前期准备</h2>    <p>1.app打开webview</p>    <p>2.loadUrl(页面url)</p>    <p>3.监听webview开始,并执行一段js代码将包内的jsbridge.js文件引入页面中;</p>    <h2>功能业务逻辑</h2>    <ol>     <li> <p>web页面调用请求接口</p> <p>jsbridge.invoke(funcName, data);(A方法:Native提供,B&C方法: 前端实现);</p> </li>     <li> <p>接口调用原生功能</p> </li>     <li> <p>原生功能完成后执行回调</p> </li>    </ol>    <h2>比较</h2>    <p>A:android曝 安全漏洞 ,但相对来说实现简单,调用方式容易,且传递参数,无需前端搭建jsbridge,只需要封装易用的sdk,App不需要读取本地静态js文件;</p>    <p>B: iframe规定协议,规范统一,需要前端实现jsbridge和封装sdk, iframe通过url的方式,数据统一为字符串格式,数据量受限制,两端要转义字符;</p>    <p>C: prompt在一些安卓设备受系统劫持,监听prompt兼容性需要测试,也是字符串形式,数据量不受限,需要转义字符;</p>    <p>还有很多参考页面未注明,以及文中有问题的地方欢迎提出。</p>    <p> </p>    <p>来自:https://segmentfault.com/a/1190000008012111</p>    <p> </p>