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工程运行效果如下:

jsapidemo_ios.png

jsapidemo_android.jpg

关于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的引擎原理如图所示:

JSBridgeIOS_1.png

一般app中都有自己定制的Webcontainer,为了更好的跟已有项目相融合,在Cordova的基础上我们进行了简化,通过JSAPIService服务的方式进行插件扩展开发如图所示:

JSBridgeIOS_2.png

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


结束

第一次写博客,写得糙和不好的地方望见谅,本人将会不断改善和提高自身能力;所以本博客主要提供大概的解决方案,望能够和有需要的人士交流沟通具体实现方式的差异。

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

推荐阅读更多精彩内容