实际场景
场景:现在有一个H5活动页面,上面有一个登陆按钮,要求点击登陆按钮以后,唤出App内部的登录界面,当登录成功以后将用户的手机号返回给H5页面,显示出来。
这个场景应该算是比较完整的一次H5中的JavaScript与App原生代码进行交互了,这个过程,我们制定的方案满足以下几点:
- 满足基本的交互流程的功能
- Android与iOS都能适用
- H5的前端开发者,在书写JavaScript的业务代码的时候不需要为了迁就移动端语言的特性而写特殊的磨合代码
- 方便调试
交互流程
当H5页面上的JavaScript代码要调用原生的页面或者组件的时候,调用最好是双向的,一来一回,这样比较容易满足一些比较复杂的业务场景,就像上面的场景一样,有调用,有回调告知H5调用的结果。前端开发写的JavaScript代码基本上都是异步风格的,就拿上面的场景,如果登录是H5前端的,那么这个流程就会是:
function loginClick() { loginComponent.login(function (error,result) { //处理登录完成以后的逻辑 }); } var loginComponent = { callBack:null, "login":function (callBack) { this.show(); this.callBack = callBack; }, show:function (loginComponent) { //登录组件显示的逻辑 }, confirm:function (userName,password) { ajax.post('https://xxxx.com/login',function (error,result) { if(this.callBack !== null){ this.callBack(error,result); } }); } }
如果要改成调用原生登录,那么这个流程就应该是这样:
确定了流程,接下来就可以详细设计和实现
原生与JavaScript的桥梁
为了实现上述流程,并且能让H5的前端开发尽可能少的语法损失,我们需要构建一个JavaScript与原生App进行交互的桥梁,这个桥梁来处理与App的协议交互,兼容iOS与Android的交互实现。
Android与iOS都支持在打开H5页面的时候,向H5页面的window对象上注入一个JavaScript可以访问到的对象,Android端使用的是
webView.addJavascriptInterface(myJavaScriptInterface, “bridge”);
iOS则可以使用JavaScriptCore来完成:
#import <Foundation/Foundation.h> #import <JavaScriptCore/JavaScriptCore.h> @protocol PICBridgeExport <JSExport> @end @interface PICBridge : NSObject<PICBridgeExport> @end self.jsContext = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; self.bridge =[[PICBridge alloc] init];
这里面Android的myJavaScriptInterface与PICBridge都是作为与JavaScript进行通信的桥梁。
我们使用设计这个桥梁的时候,需要使用一个具体的语法约定和数据约定,比方说,当前端开发调用App登录的时候,他一定是希望就像调用其他JavaScript的组件一样,而登录的结果通过传入callBack的函数来完成,对于callBack函数,我们希望借助NodeJS的规范:
function(error,res) { //回调函数第一个参数是错误,第二个参数是结果 }
以上我们可以看到,bridge必须有能力将前端开发写的JavaScript回调函数传入到App内部,然后App处理完逻辑以后通过回调函数来告知前端处理,并且这个需要通过约定好的数据格式来传递入参和返回值。
为了完成双向通信,我们就需要在JavaScript设置一个bridge,原生再注入一个bridge,这两个bridge按照一定的数据约定来进行双向通信和分发逻辑。
原生端注入到JS当中的“桥”(iOS端)
通过使用JavaScriptCore这个库,我们能很容易的将JavaScript传入的回调函数在objective-c或者是swift端持有,并回去回调这个回调函数。
#import <Foundation/Foundation.h> #import <JavaScriptCore/JavaScriptCore.h> @protocol PICBridgeExport <JSExport> JSExportAs(callRouter, -(void)callRouter:(JSValue *)requestObject callBack:(JSValue *)callBack); @end @interface PICBridge : NSObject<PICBridgeExport> -(void)addActionHandler:(NSString *)actionHandlerName forCallBack:(void(^)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)))callBack; @end
需要说明的是,JavaScript没有函数参数标签的概念,JSExportAs是用来将objective-c的方法映射为JavaScript的函数。
-(void)callRouter:(JSValue )requestObject callBack:(JSValue )callBack);
这个方法是暴露给JavaScript端调用的。
第一个参数requestObject是一个JavaScript对象,传入到objective-c中以后就可以转换为key-value结构的字典,那么这个字典的数据约定是:
{ 'Method':'Login', 'Data':null }
其中Method是App内部对外提供的API,而这个Data则是该API需要的入参。
第二个参数是一个callBack函数,该类型的JSValue可以调用callWithArguments:方法来invoke这个回调函数。
前面已经说明,回调函数的第一个参数是error,第二个参数是一个结果,而回调的结果我们也进行一下约定,那就是:
{ 'result':{} }
这样的好处是,业务逻辑可以讲返回的结果放入result中,跟result同级别的我们还可以加入统一的签名认证的东西,在此暂时不延伸。
原生端的bridge的来实现一下callRouter:
-(void)callRouter:(JSValue *)requestObject callBack:(JSValue *)callBack{ NSDictionary * dict = [requestObject toDictionary]; NSString * methodName = [dict objectForKey:@"Method"]; if (methodName != nil && methodName.length>0) { NSDictionary * params = [dict objectForKey:@"Data"]; __weak PICBridge * weakSelf = self; //因为JavaScript是单线程的,需要尽快完成调用逻辑,耗时操作需要异步提交到主线程中执行 dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf callAction:methodName params:params success:^(NSDictionary *responseDict) { if (responseDict != nil) { NSString * result = [weakSelf responseStringWith:responseDict]; if (result) { [callBack callWithArguments:@[@"null",result]]; } else{ [callBack callWithArguments:@[@"null",@"null"]]; } } else{ [callBack callWithArguments:@[@"null",@"null"]]; } } failure:^(NSError *error) { if (error) { [callBack callWithArguments:@[[error description],@"null"]]; } else{ [callBack callWithArguments:@[@"App Inner Error",@"null"]]; } }]; }); } else{ [callBack callWithArguments:@[@NO,[PICError ErrorWithCode:PICUnkonwError].description]]; } return; } //将返回的结果字典转换为字符串通过回调函数传回给JavaScript -(NSString *)responseStringWith:(NSDictionary *)responseDict{ if (responseDict) { NSDictionary * dict = @{@"result":responseDict}; NSData * data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil]; NSString * result = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding]; return result; } else{ return nil; } }
callAction函数实际上就是分发业务逻辑用的
-(void)callAction:(NSString *)actionName params:(NSDictionary *)params success:(void(^)(NSDictionary * responseDict))success failure:(void(^)(NSError * error))failure{ void(^callBack)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)) = [self.handlers objectForKey:actionName]; if (callBack != nil) { callBack(params,failure,success); } }
这个callBack Block是在self.handlers的字典中存储,比较复杂,block第一个参数是传入的入参,后面两个参数是成功以后的回调和失败以后的回调,以便业务逻辑完成后进行回调给JavaScript。
同时会有注册业务逻辑的方法:
-(void)addActionHandler:(NSString *)actionHandlerName forCallBack:(void(^)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)))callBack{ if (actionHandlerName.length>0 && callBack != nil) { [self.handlers setObject:callBack forKey:actionHandlerName]; } }
至此,原生端路由实现完毕。
JavaScript端路由
(function(win) { var ua = navigator.userAgent; function getQueryString(name) { var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i'); var r = window.location.search.substr(1).match(reg); if (r !== null) return unescape(r[2]); return null; } function isAndroid() { return ua.indexOf('Android') > 0; } function isIOS() { return /(iPhone|iPad|iPod)/i.test(ua); } var mobile = { /** *通过bridge调用app端的方法 * @param method * @param params * @param callback */ callAppRouter: function(method, params, callback) { var req = { 'Method': method, 'Data': params }; if (isIOS()) { win.bridge.callRouter(req, function(err, result) { var resultObj = null; var errorMsg = null; if (typeof(result) !== 'undefined' && result !== 'null' && result !== null) { resultObj = JSON.parse(result); if (resultObj) { resultObj = resultObj['result']; } } if (err !== 'null' && typeof(err) !== 'undefined' && err !== null) { errorMsg = err; } callback(err, resultObj); }); } else if (isAndroid()) { //生成回调函数方法名称 var cbName = 'CB_' + Date.now() + '_' + Math.ceil(Math.random() * 10); //挂载一个临时函数到window变量上,方便app回调 win[cbName] = function(err, result) { var resultObj; if (typeof(result) !== 'undefined' && result !== null) { resultObj = JSON.parse(result)['result']; } callback(err, resultObj); //回调成功之后删除挂载到window上的临时函数 delete win[cbName]; }; win.bridge.callRouter(JSON.stringify(req), cbName); } }, login: function() { // body... this.callAppRouter('Login', null, function(errMsg, res) { // body... if (errMsg !== null && errMsg !== 'undefined' && errMsg !== 'null') { } else { var name = res['phone']; if (name !== 'undefined' && name !== 'null') { var button = document.getElementById('loginButton'); button.innerHTML = name; } } }); } }; //将mobile对象挂载到window全局 win.webBridge = mobile; })(window);
在window上挂在一个叫webBridge的对象,其他业务JavaScript可以通过webBridge.login来进行调用原生端开放的API。
callAppRouter方法的实现我们来分析一下:
如果判断是iOS设备,则使用iOS注册的bridge对象进行调用callRouter方法:
if (isIOS()) { win.bridge.callRouter(req, function(err, result) { var resultObj = null; var errorMsg = null; if (typeof(result) !== 'undefined' && result !== 'null' && result !== null) { resultObj = JSON.parse(result); if (resultObj) { resultObj = resultObj['result']; } } if (err !== 'null' && typeof(err) !== 'undefined' && err !== null) { errorMsg = err; } callback(err, resultObj); }); }
req是标准的包含Method和Data的对象,紧接着传入回调函数,回调函数有err与result,里面做好各种类型检查。
着重说一下Android端的实现,因为Android端的JavaScript方法注册,参数类型只能字符串,java语言本身没有匿名函数的概念,所以只能给Java端传入回调函数的名字,而回调函数的实现则在JavaScript端持有。
else if (isAndroid()) { //生成回调函数方法名称 var cbName = 'CB_' + Date.now() + '_' + Math.ceil(Math.random() * 10); //挂载一个临时函数到window变量上,方便app回调 win[cbName] = function(err, result) { var resultObj; if (typeof(result) !== 'undefined' && result !== null) { resultObj = JSON.parse(result)['result']; } callback(err, resultObj); //回调成功之后删除挂载到window上的临时函数 delete win[cbName]; }; win.bridge.callRouter(JSON.stringify(req), cbName); }
本质上就是将其他业务JavaScript代码传入的callBack函数通过随机生成函数名,挂在到window变量上,回调以后将其删除:delete win[cbName]。
当调用Java端的bridge.callRouter(JSON.stringify(req), cbName),Java端拿到cbName,在完成业务逻辑后,按照标准数据格式,在JavaScript执行的上下文中,回调这个名字的方法。
至此,前端的webBridge完成。
最后附上Demo地址:
https://github.com/Neojoke/Picidae.git