JSBridge(Android和IOS平臺)的設計和實現

前言

對于商務類的app,隨著app注冊使用人數遞增,app的運營者們就會逐漸考慮在應用中開展一些推廣活動。大多數活動具備時效性強、運營時間短的特征,一般產品們和運營者們都是通過wap頁面快速投放到產品的活動模塊。Wap頁面可以聲文并茂地介紹活動,但活動的最終目標是通過獲取特權、跳轉進入本地功能模塊,最后達成交易。如何建立wap頁面和本地Native頁面的深度交互,這就需要用到本文介紹的JSBridge。

此外一些平臺類的產品,如大家每天都在使用的微信、支付寶、手機qq等,無一例外都在使用集成JSBridge的webContainer完成眾多業務組件功能,大大減少了客戶端Native開發的工作量,不僅節約了大量人力開發成本,還能避開產品上線更新的版本審核周期限制(特別是IOS平臺)。當然這些超級APP有強大的技術力量支撐,通過JSBridge有計劃的進行API規范接口,不斷向前端Wap開發人員開放,并在版本上向下兼容。但對于我們剛起步運營的中小級app來說暫時還沒有必要如此大張旗鼓,相反前面提到的wap活動推廣則是我們的主要需求。

為了滿足這個需求,本文通過提煉JSBridge的核心部分改造成JSService方式供各個不同的產品零修改方式使用。各個不同的產品只需要按照插件的方式提供Native擴展接口,并在各自封裝的webContainer中調用JSService對Wap調用進行攔截處理。

具體產品應用

目前該框架同時覆蓋了Android和IOS平臺,在我司的幾個電商類產品中都得到了很好的使用,并趨于穩定。
本文的Demo工程運行效果如下:

image
image

關于JSAPI的接口封裝

JSAPI的封裝包括核心JS和對外開放接口JS兩個部分。 核心JS部分通過攔截某Q的wap請求頁面獲取,獲取的JS進行編碼混淆處理,已經通過調試進行了注釋,其主要過程就是對參數和回調進行封裝,并構建一個url鏈接通過創建一個隱藏的iframe進行發送。核心JS代碼閱讀

對參數和回調進行封裝部分的代碼如下:

//invoke
    //mapp.invoke("device", "getDeviceInfo", e);
    //@param e 類 必須
    //@param n 類方法 必須
    //@param i 同步回調的js方法
    //@param s 
    function k(e, n, i, s) {
        if (!e || !n) return null;
        var o, u;
        i = r.call(arguments, 2), //相當于調用Array.prototype.slice(arguments) == arguments.slice(2),獲取argument數組2以后的元素

        //令s等于回調函數
        s = i.length && i[i.length - 1],
        s && typeof s == "function" ? i.pop() : typeof s == "undefined" ? i.pop() : s = null,

        //u為當前存儲回調函數的index;
        u = b(s);

        //如果當前版本支持Bridge
        if (C(e, n)) {
            //將傳進來的所有參數生成一個url字符串;
            o = "ldjsbridge:" + "/" + "/" + encodeURIComponent(e) + "/" + encodeURIComponent(n),
            i.forEach(function(e, t) {
                typeof e == "object" && (e = JSON.stringify(e)),
                t === 0 ? o += "?p=": o += "&p" + t + "=",
                o += encodeURIComponent(String(e))
            }),
            (o += "#" + u); //帶上存儲回調的數組index;

            //執行生成的url, 有些函數是同步執行完畢,直接調用回調函數;而有些函數的調用要通過異步調用執行,需要通過
            //全局調用去完成;
            var f = N(o);
            if (t.iOS) {
                f = f ? f.result: null;
                if (!s) return f; //如果無回調函數,直接返回結果;
            }
        }else {
            console.log("mappapi: the version don't support mapp." + e + "." + n);
        }
    }

創建iframe發送JSBridge調用請求:

    //創建一個iframe,執行src,供攔截
    function N(n, r) {
        console.log("logOpenURL:>>" + n);
        var i = document.createElement("iframe");
        i.style.cssText = "display:none;width:0px;height:0px;";
        var s = function() {
            //通過全局執行函數執行回調函數;監聽iframe是否加載完畢
            E(r, {
                r: -201,
                result: "error"
            })
        };

        //ios平臺,令iframe的src為url,onload函數為全局回調函數
        //并將iframe插入到body或者html的子節點中;
        t.iOS && (i.onload = s, i.src = n);
        var o = document.body || document.documentElement; 
        o.appendChild(i),
        t.android && (i.onload = s, i.src = n);

        //
        var u = t.__RETURN_VALUE;
        //當iframe執行完成之后,最后執行settimeout 0語句
        return t.__RETURN_VALUE = e,
        setTimeout(function() {
            i.parentNode.removeChild(i)
        },
        0),
        u
    }

對外開放接口的封裝:(使用者只需要對該部分進行接口擴展即可)

mapp.build("mapp.device.getDeviceInfo", {
    iOS: function(e) {
        return mapp.invoke("device", "getDeviceInfo", e);
    },
    android: function(e) {
        var t = e;
        e = function(e) {
            try {
                e = JSON.parse(e)
            } catch(n) {}
            t && t(e)
        },
        mapp.invoke("device", "getDeviceInfo", e)
    },
    support: {
        iOS: "1.0",
        android: "1.0"
    }
}),

核心JS代碼調用說明


mapp.version: mappAPI自身版本號

mapp.iOS: 如果在ios app中,值為true

mapp.android: 如果在android app中,值為true

mapp.support: 檢查當前app環境是否支持該接口,支持返回true

    mapp.support("mqq.device.getClientInfo")

mapp.callback: 用于生成回調名字,跟著invoke參數傳給客戶端,供客戶端回調

    var callbackName = mapp.callback(function(type, index){
        console.log("type: " + type + ", index: " + index);
    });

mapp.invoke 方法:

mapp核心方法,用于調用客戶端接口。

        @param {String} namespace 命名空間
        @param {String} method 接口名字
        @param {Object/String} params 可選,API調用的參數
        @param {Function} callback 可選,API調用的回調

* 調用普通的無參數接口:

        mapp.invoke("ns", "method");

* 調用有異步回調函數的接口:

        mapp.invoke("ns", "method", function(data){
            console.log(data);
        });

        或

        mapp.invoke("ns", "method", {
            "params" : params   //參數通過json封裝
            "callback" : mapp.callback(handler), //生成回調名字
        });

* 如果有多個參數調用:

        mapp.invoke("ns", "method", param1, param2 /*,...*/,callback);

JSService的具體實現-插件運行機制

JSService部分是基于Phonegap的Cordova引擎的基礎上簡化而來,其基本原理參照Cordova的引擎原理如圖所示:

image

一般app中都有自己定制的Webcontainer,為了更好的跟已有項目相融合,在Cordova的基礎上我們進行了簡化,通過JSAPIService服務的方式進行插件擴展開發如圖所示:

image

本JSBridge是基于Phonegap的Cordova引擎的基礎上簡化而來, Android平臺Webview和JS的交互方式共有三種:

  1. ExposedJsApi:js直接調用java對象的方法;(同步)
  2. 重載chromeClient的prompt 截獲方案;(異步)
  3. url截獲+webview.loadUrl回調的方案;(異步)

為了和IOS保持一致的JSAPI,只能選用第三套方案;

基于JSService的插件開發、配置和使用

IOS平臺

git地址:https://github.com/Lede-Inc/LDJSBridge_IOS.git

在Native部分,定義一個模塊插件對應于創建一個插件類, 模塊中的每個插件接口對應插件類中某個方法。

集成LDJSBridge_IOS框架之后,只需要繼承框架中的插件基類LDJSPlugin,如下所示:

  • 插件接口定義
    #import "LDJSPlugin.h"
    @interface LDPDevice : LDJSPlugin
    {}

    //@func 獲取設備信息
    - (void)getDeviceInfo:(LDJSInvokedUrlCommand*)command;

    @end

  • 自定義插件接口實現
@implementation LDPDevice

/**
 *@func 獲取設備信息
 */
- (void)getDeviceInfo:(LDJSInvokedUrlCommand*)command{
    //讀取設備信息
    NSMutableDictionary* deviceProperties = [NSMutableDictionary dictionaryWithCapacity:4];

    UIDevice* device = [UIDevice currentDevice];
    [deviceProperties setObject:[device systemName] forKey:@"systemName"];
    [deviceProperties setObject:[device systemVersion] forKey:@"systemVersion"];
    [deviceProperties setObject:[device model] forKey:@"model"];
    [deviceProperties setObject:[device modelVersion] forKey:@"modelVersion"];
    [deviceProperties setObject:[self uniqueAppInstanceIdentifier] forKey:@"identifier"];

    LDJSPluginResult* pluginResult = [LDJSPluginResult resultWithStatus:LDJSCommandStatus_OK messageAsDictionary:[NSDictionary dictionaryWithDictionary:deviceProperties]];

    [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}

@end

  • 在plugin.json文件中對plugin插件的統一配置
{
    "update": "",
    "module": "mapp",
    "plugins": [
        {
            "pluginname": "device",
            "pluginclass": "LDPDevice",
            "exports": [
                {
                    "showmethod": "getDeviceInfo",
                    "realmethod": "getDeviceInfo"
                }
            ]
        }
    ]
}

  • 在webContainer中對JSService初始化, 當初始化完成之后,向前端頁面發送一個ReadyEvent,前端即可開始調用JSAPI接口;
//注冊插件Service
    if(_bridgeService == nil){
        _bridgeService = [[LDJSService alloc] initBridgeServiceWithConfig:@"PluginConfig.json"];
    }
    [_bridgeService connect:_webview Controller:self];

/**
 Called when the webview finishes loading.  This stops the activity view.
 */
- (void)webViewDidFinishLoad:(UIWebView*)theWebView{
    NSLog(@"Finished load of: %@", theWebView.request.URL);
    //當webview finish load之后,發event事件通知前端JSBridgeService已經就緒
    //監聽事件由各個產品自行決定
    [_bridgeService readyWithEvent:@"LDJSBridgeServiceReady"];
}

Android平臺

git地址:https://github.com/Lede-Inc/LDJSBridge_Android.git

  • 插件接口定義
    public class LDPDevice extends LDJSPlugin {
        public static final String TAG = "Device";

        /**
         * Constructor.
         */
        public LDPDevice() {
        }
    }

  • LDJSPlugin 屬性方法說明
    /**
    * Plugins must extend this class and override one of the execute methods.
    */
    public class LDJSPlugin {
        public String id;

        //在插件初始化的時候,會初始化當前插件所屬的webview和controller
        //供插件方法接口 返回處理結果
        public WebView webView; 
        public LDJSActivityInterface activityInterface;

        //所有自定義插件需要重載此方法
        public boolean execute(String action, LDJSParams args, LDJSCallbackContext callbackContext) throws JSONException {
            return false;
        }

    }   

  • 自定義插件接口實現
@Override
    public boolean execute(String action, LDJSParams args, LDJSCallbackContext callbackContext) throws JSONException {
        if (action.equals("getDeviceInfo")) {
            JSONObject r = new JSONObject();
            r.put("uuid", LDPDevice.uuid);
            r.put("version", this.getOSVersion());
            r.put("platform", this.getPlatform());
            r.put("model", this.getModel());
            callbackContext.success(r);
        }
        else {
            return false;
        }
        return true;
    }

  • 在封裝的webContainer中注冊服務并調用:
  /**
     * 初始化Activity,打開網頁,注冊插件服務
     */
    public void initActivity() {
        //創建webview和顯示view
        createGapView();
        createViews();

        //注冊插件服務
        if(jsBridgeService == null){
            jsBridgeService = new LDJSService(_webview, this, "PluginConfig.json");
        }

        //加載請求
        if(this.url != null && !this.url.equalsIgnoreCase("")){
            _webview.loadUrl(this.url);
        }
    }

 /**
     * 初始化webview,如果需要調用JSAPI,必須為Webview注冊WebViewClient和WebChromeClient
     */
    @SuppressLint("SetJavaScriptEnabled")
    public void createGapView(){
        if(_webview == null){
            _webview = new WebView(LDPBaseWebViewActivity.this, null);
            //設置允許webview和javascript交互
            _webview.getSettings().setJavaScriptEnabled(true);
            _webview.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);

            //綁定webviewclient
            _webviewClient = new WebViewClient(){
                public void onPageStarted(WebView view, String url, Bitmap favicon){
                    super.onPageStarted(view, url, favicon);
                    isWebviewStarted = true;
                }

                public void onPageFinished(WebView view, String url) {
                    super.onPageFinished(view, url);
                        //發送事件通知前端
                    if(isWebviewStarted){
                        //在page加載之后,加載核心JS,前端頁面可以在document.ready函數中直接調用了;
                        jsBridgeService.onWebPageFinished();
                            jsBridgeService.readyWithEventName("LDJSBridgeServiceReady");
                    }
                    isWebviewStarted = false;
                }

                  @Override
                  public boolean shouldOverrideUrlLoading(WebView view, String url) {
                        if(url.startsWith("about:")){
                            return true;
                        }
                        if(url.startsWith(LDJSService.LDJSBridgeScheme)){
                            //處理JSBridge特定的Scheme
                            jsBridgeService.handleURLFromWebview(url);
                            return true;
                        }

                        return false;
                  }
            };

            _webview.setWebViewClient(_webviewClient);
            //綁定chromeClient
            _webviewChromeClient = new WebChromeClient(){
                @Override
                public boolean onJsAlert(WebView view, String url, String message,
                        JsResult result) {
                    return super.onJsAlert(view, url, message, result);
                }
            };
            _webview.setWebChromeClient(_webviewChromeClient);
        }
    }

結束

第一次寫博客,寫得糙和不好的地方望見諒,本人將會不斷改善和提高自身能力;所以本博客主要提供大概的解決方案,望能夠和有需要的人士交流溝通具體實現方式的差異。

作者:philon
鏈接:http://www.lxweimin.com/p/90d1bee2f3c9
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權并注明出處。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,412評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,514評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,373評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,975評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,743評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,199評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,262評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,414評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,951評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,780評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,527評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,218評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,649評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,889評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,673評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容

  • 前言 對于商務類的app,隨著app注冊使用人數遞增,app的運營者們就會逐漸考慮在應用中開展一些推廣活動。大多數...
    淡淡如水舟閱讀 12,598評論 3 24
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,151評論 4 61
  • 最近有好多事情煩躁著我 不能靜下心來看書 學習 果然 我還是那個比較在意友情的人 所以傷心 亂想的時候比較多 你越...
    大鵬Renr閱讀 308評論 0 0
  • 燈紅酒綠,煙霧繚繞,震耳欲聾的音樂,而我只是這里的一粒塵埃。 生活本...
    帶夢的理想閱讀 232評論 0 0
  • 2017年11月13日 初三的日子 第85天 晴 中度污染 早上全家一起出發,天色完全黑了。 張同學上周五把喝水杯...
    天空有云閱讀 206評論 0 0