原生App與javascript互動之JSBridge介面原理、設計與實現

伊吾魚發表於2017-01-04

前期調研

調研物件:
支付寶,微信,雲之家

調研文件:
Android中JS與Java的極簡互動庫 SimpleJavaJsBridge

設計需求

  1. 閱讀型別的業務功能頁面需要由前端H5實現,需要做到服務端可控;

  2. 頁面介面更改減少重新發布新版本的頻率;

  3. 功能頁面部分原型需求無法實現,需要原生功能支援;

  4. 對未來業務功能的擴充,方便迭代;

作用和意義

  1. 定製化JSBridge實際上是擴充NativeApp的hybrid程度, 參照微信和支付寶,可打造APP強力的生態圈;

  2. jsBridge在支付,錢包,媒體擴充,圖片處理,活動頁面,使用者地理位置網路狀態都能得到原生強有力支援;

  3. 對於閱讀性頁面有更多擴充;

優秀的通訊設計方案

  1. 前端和Native對對方的細節知道的越少越好,減少耦合度,暴露的介面儘量控制在5個以內;

  2. js與Native之間的通訊,最好定義一套通訊協議或者規則,減少js程式碼為相容不同系統而過多if;

  3. 主動傳送訊息給對方時,對方儘量對該訊息進行反饋,即使無需求對某些功能做反饋,減少if判斷的相容程式碼;

實現方式(互動形式)

Native 呼叫 JS

使用前端暴露在window下的一個方法或者一個物件的方法;
_handlerFromApp(message)
JSBridge._handlerFromApp(message)

方法名: handlerFromApp
引數:

message: {
  cbId  : "cb_(:id)_(:timeStamp)",      //回撥函式的id
  status: 0,                            //狀態資料 (0:失敗, 1:成功)
  msg   : "ok",                         //反饋的訊息
  data  : {
    //...                               //一些處理後的資料
  } 
}

以下提供的部分參考方法
未對其進行真實測試,因為我使用的是iframe的方法,但原理幾乎相同
建議封裝後提供給Native開發工程師放入對應的APP包中,在webView讀取頁面的時候用對應的Native語言注入頁面,避免頁面在前端匯入被抓取;

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(`不存在該回撥方法`);
    }
  }
}

JS呼叫Native

以下只介紹前兩個方法,第三個和第二個比較類似

  • A. Native暴露一個含有通訊方法的類給web呼叫

  • B. Native攔截iframe請求

  • C. Native攔截prompt彈出框

A 一個包含呼叫方法的類

iOS : 可使用javascriptCore
Android: 直接使用WebView的addJavascriptInterface方法

將一個js物件繫結到一個Native類,在類中實現相應的函式,當js需要呼叫Native的方法時,只需要直接在js中通過繫結的物件呼叫相應的函式

確定物件名稱: (:AppName)JSBridge

Native提供的物件含有的方法:

  • invoke(funcName, data)

  • listen(funcName, data)

invoke:用於web頁面呼叫Native私有方法的通用方法
引數: funcNamedata
funcName:對應為Native內部私有方法的方法名或對映
data :web傳遞給Native的必要資料
data資料結構如下:

{
  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`,
      ]
    }
  });
*/

那麼當呼叫之後,Native執行完成對應的私有方法後,執行一次我們提供的回撥介面,以下是javascript的語法,請Native開發工程師對應修改

JSBridge.handlerFromApp({
  cbId  : "cb_(:id)_(:timeStamp)", //web傳給Native的cbId
  status: 1,                       //狀態資料 (0:失敗, 1:成功)
  msg   : "預覽成功", 
  data  : {} 
});

listen是一個用於web頁面監聽Native方法實現的通用方法
使用環境: 不屬於web頁面上的操作。當使用者直接操作Native上的功能來影響或傳送資料給web,或者操作的功能需要用到web頁面上的資料,我們需要告知Native我們希望能收到回撥;
例子:
微信監聽分享操作

  1. 分享的內容是web上的內容(標題,描述,圖片);

  2. 獲取分享操作是否完成和分享操作的資料收集;

  3. 分享按鈕是原生APP提供;

資料結構和操作與invoke相似,對應Native開發哥們接收到listen操作後需要儲存一個對映,在被監聽的操作實現上判斷是不是需要執行web端提供的回撥介面;

注意:有關java addJavascriptInterface的使用有漏洞,詳情見參考第二條連結,未驗證,僅供讀者自行權衡;

B iframe的魔法

由於Native App可以監聽webview的請求,所以web端通過建立一個隱藏的iframe,請求商定後的統一協議來傳送資料給Native App;

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

url格式:
(:scheme)://register_type?func=(:funcName)&cbId=(:cbId)&data={…}&verifyTimeStamp=(:new Date().getTime())

scheme:協議,可用appName,兩端商定,例如weixin,alipayjsbridge

register_type: 註冊形式,即invoke還是listen

funcName: Native內的方法名或對映

cbId:見上文

data:詳細資料

verifyTimeStamp:驗證的時間引數,不必須

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

協作

  • 需要Native開發兄弟在webview開啟時候為頁面注入jsbridge.js程式碼並執行(防止被前端瀏覽器直接檢視原始碼瞭解app的程式碼邏輯)

  • 獲取引數執行對應的功能後,執行回撥

頁面前期準備

1.app開啟webview
2.loadUrl(頁面url)
3.監聽webview開始,並執行一段js程式碼將包內的jsbridge.js檔案引入頁面中;

功能業務邏輯

  1. web頁面呼叫請求介面
    jsbridge.invoke(funcName, data);(A方法:Native提供,B&C方法: 前端實現);

  2. 介面呼叫原生功能

  3. 原生功能完成後執行回撥

比較

A:android曝安全漏洞,但相對來說實現簡單,呼叫方式容易,且傳遞引數,無需前端搭建jsbridge,只需要封裝易用的sdk,App不需要讀取本地靜態js檔案;

B: iframe規定協議,規範統一,需要前端實現jsbridge和封裝sdk, iframe通過url的方式,資料統一為字串格式,資料量受限制,兩端要轉義字元;

C: prompt在一些安卓裝置受系統劫持,監聽prompt相容性需要測試,也是字串形式,資料量不受限,需要轉義字元;

還有很多參考頁面未註明,以及文中有問題的地方歡迎提出。

相關參考
iOS中Objective-C與JavaScript之間相互呼叫的實現(實現了與Android相同的機制)
Android WebView的Js物件注入漏洞解決方案(JSBridge存在的意義)

相關文章