目录
虽然使用原生的代码书写功能可以使应用的功能更加稳定,用户体验更佳完美,但是由于原生代码书写功能会受到审核时间相对较长,灵活性不足,页面不精彩的因素的限制,近些年来,很多的开发者都在致力于开发出来易于改写,方便发布,完成及时性页面修改等功能的语言脚本,方法和框架,比如React,React-native以及cordova框架等。使用UIWebView如何实现与原生方法之间的通信,下边做一个简单的演示.
使用UIWebView加载本地HTML文件
为了完成展示,保存一个简单的本地HTML界面:近有一个按钮,点击之后触发clickAction事件, 在该事件中触发调用原生的方法来展示原生的UIAlertController弹窗,当用户点击选择按钮之后使用func_callback负责接收来自原生端的回调结果,将HTML文件命名为index.html:
<html>
<meta name="viewport" content="initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<head>
<meta charset="utf-8">
<style type="text/css">
</style>
<script>
//接收来自原生的处理结果
function func_callback(params){
document.getElementById('jsParamFuncSpan').innerHTML = JSON.stringify(params);
}
//web端触发的点击事件
function clickAction() {
//在以下展示中,web端只有这部分代码不相同,展示中只给出这部分不同的代码
}
</script>
</head>
<body>
<div style="margin-top: 100px">
<h1>Test how to use objective-c call js</h1>
<input type="button" value="Call ObjC and get result" onclick="clickAction()">
</div>
<div>
<span id="jsParamFuncSpan" style="color: red; font-size: 20px;"></span>
</div>
</body>
</html>
定义展示控制器:
- 定义UIWebView对象为控制器属性,使用懒加载创建UIWebView对象;
- 使用UIWebView进行加载本地html文件.
- (void)viewDidLoad {
[super viewDidLoad];
[self.view addSubview:self.webview];
NSString *str = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
NSData *data = [NSData dataWithContentsOfFile:str];
[self.webview loadHTMLString:[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] baseURL:nil];
// Do any additional setup after loading the view.
}
- (UIWebView *)webview {
if (!_webview) {
_webview = [[UIWebView alloc] initWithFrame:[UIScreen mainScreen].bounds];
_webview.delegate = self;
}
return _webview;
}
封装辅助功能
主队列中执行block
UI展示以及用户交互等事件必须放在主线程中完成,为了便于判断是否当前是否是主线程并确保事件在主线程中正确执行,封装必须在主线程中执行block宏executeBlockInSafeMainQueue
#ifndef executeBlockInSafeMainQueue
#define executeBlockInSafeMainQueue(block) \
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) { \
block(); \
} else { \
dispatch_async(dispatch_get_main_queue(), block); \
}
展示UIAlertController弹窗
为了方便各处调用,使用C的语法封装弹唱展示功能并在参数中传入回调函数:
typedef void(^showAlertControllerCallback)(id);
void showAlertController(NSString *title, NSString *msg,NSArray<NSString *> *buttons, showAlertControllerCallback callback) {
dispatch_block_t block = ^(){
UIWindow *window = nil;
//获取最高级别的window
for (UIWindow *win in [UIApplication sharedApplication].windows) {
if (!win.hidden && window.windowLevel == UIWindowLevelNormal) {
window = win;
break;
}
}
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:(UIAlertControllerStyleAlert)];
for (NSString *actionTitle in buttons) {
[alertController addAction:({
UIAlertAction *action = [UIAlertAction actionWithTitle:actionTitle style:(UIAlertActionStyleDefault) handler:^(UIAlertAction * _Nonnull action) {
callback(@{
@"selectedTitle" : actionTitle,
});
}];
action;
})];
}
[window.rootViewController presentViewController:alertController animated:true completion:^{
}];
};
executeBlockInSafeMainQueue(block);
};
转化JSON
演示中只涉及到简单的NSDictionary转化为JSON字符串,为NSDictionary添加Category:
#import "NSDictionary+JSON.h"
@implementation NSDictionary (JSON)
-(NSString *)json {
NSError *error;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:self
options:0 error:&error];
if (!jsonData) {
NSLog(@"json转换失败: error: %@", error.localizedDescription);
return @"{}";
} else {
NSString *result = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
return result;
}
}
@end
交互方式
UIWebview和原生的交互大概有两种方式:
- 自定义scheme实现交互:加载自定义的scheme然后进行拦截,根据url中的参数信息判断是何种操作,从而转化为原生对应的方法进行调用,在必要时返回结果;
- 使用JavaScriptCore实现交互:利用系统JavaScriptCore框架进行方法定义实现交互.
相比起来,自定义scheme的方式工作量会比较大,但方便实行精细化功能;使用系统JavaScriptCore框架就相对简单,把更多的工作交给系统来完成.
自定义scheme实现交互
虽然名字叫自定义scheme,实际上是自定义了一个相对"完整"的资源定位符(URL).这种方式注视通过向当前界面中注入自定的js方法,然后通过注入的方法作为交互的桥梁实现通信.
scheme
在自定义scheme的实现中,需要注意以下问题:
- scheme的定义需要确保唯一,且不与已知通用scheme冲突.
短信 sms
app store itms-apps
电话 tel
备忘录 mobilenotes
E-Mail MESSAGE
支付宝 alipays
QQ相关 mqqapi, qqmusic, qqnews:, weiyun, sosomap,tencentrm等;
微信相关 wechat, weixin
百度相关 baidumap, com.baidu.tieba, baiduyun, baidumusic, bainuo, BaiduIMShop等
网易相关 newsapp, orpheuswidget, ntesopen等
美团相关 imeituan, dianping等
...
...
-
不要以"_"开头.在测试过程中发现以"_"开头时会被默认拼接上baseURL,可能会导致判断条件失效;
例如:发起自定义url="_bridgescheme://bridge_message/bridge_api"
1. 加载方式一:
NSString *str = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
NSURL *url = [NSURL fileURLWithPath:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
在界面上发起url加载请求时收到的请求的链接为:
file:///private/var/containers/Bundle/Application/C2F3B0E3-C937-49F4-AF3C-11AAC034CB34/JavaScriptCore.app/_bridgescheme://bridge_message/bridge_api
2. 加载方式二:
NSString *str = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
NSData *data = [NSData dataWithContentsOfFile:str];
[self.webview loadHTMLString:[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] baseURL:nil];
在界面上发起url加载请求时收到的请求的链接为:
applewebdata://0A419058-5421-4168-99C7-4C6D8819804D/_bridgescheme://bridge_message/bridge_api
在定义完自己应用"专属"scheme之后,还可以根据需求定义其他的信息来区分事件,也可将其他信息都放在消息体中进行区分,不过为了使自定义的链接看起来更加像一个真实的资源定位符,一般都会填充host,relativePath等信息.
参数传递
通信过程中必不可少的需要传递参数,来交互信息.在自定义URL的实现方式中,传递参数大致有两种方式:
- web将参数使用query拼接在URL后边,原生通过拦截URL截取参数.
- 优点:拦截到URL之后即可解析出参数不必要再次调用JS来获取参数,操作更加简单容易实现.
- 缺点:
- 参数含有特殊字符时需要进行特殊编码(例如中文空格等);
- URL中字符长度有限制,当参数比较长时可能会丢失.
- 将参数存放在全局的变量中,当拦截到URL时,原生调用JS的方法获取参数.
- 优点:参数长度理论上可以不受限制;
- 缺点:需要额外一次JS调用,增加系统开销.
所以在参数传递的选择上可以根据需求进行取舍.例如本身不需要太复杂类型的交互时,可以使用充分利用URL中host等信息传递需求.例如将交互分为不同的种类,使用host作为类型标识,参数作为辅助
bridgescheme://show.alert?msg=helloworld //alert类型
bridgescheme://show.toast?msg=helloworld //toast
bridgescheme://show.sheet?msg=helloworld&msg1=Helloworld
但是当需求比较复杂时,就需要定义大量的时间标识来做区分,非常麻烦.这时候使用单独获取参数的方式就非常方便
例如当原生拦截到URL=bridgescheme://bridge_message/bridge_api时,重新调用JS的getAPIData()方法获取参数:
{
api = "show.alert";
data = {
buttons = (
confirm,
cancel
);
functionName = "func_callback";
msg = "Hello world!";
title = "\U6765\U81eaweb\U7684\U5f39\U7a97\U8c03\U7528";
};
这样就可以根据api中的参数判断类型,然后从data中获取需要显示的信息.
代码实现
选用单独调用js获取参数的方式做演示,以下实现只是提供实现思路描述消息传递过程,并不是最终实现.
实现场景:加载本地html文件展示界面,界面上有一个按钮,点击之后调用原生弹窗,并传递参数信息.用户点击关闭弹窗之后原生端将用户点击的按钮title返回给web.
- 首先需要向界面中注入自定的JS代码,包含:
- JS调用原生方法:加载自定义URL;
- 原生获取参数信息;
- 原生反馈信息给JS.
;(function(w, doc){
//防止重复添加
if(w.Bridge && w.uuid){
return;
}
var JSBRIDGE_URL_SCHEME = 'bridgescheme'; //自定义scheme
var JSBRIDGE_URL_HOST = 'bridge_message';//自定义host,可以作为消息类型的标识
var JSBRIDGE_URL_API = 'bridge_api';//定义不同的事件类型:例如事件,消息等
var responseCallbacks = {}; //回调方法map
var jsBridgeApiData = null; //将参数保存在全局的变量中,当原生端拦截到指定的scheme时,获取其中的参数
//获取完整的自定义url
function getIFrameSrc (type) {
return JSBRIDGE_URL_SCHEME + '://' + JSBRIDGE_URL_HOST + '/' + type;
}
//产生函数唯一标识
w.uuid=(function(){
return function(){
var timestamp = new Date().getTime()
return timestamp;
}
})();
//JS调用原生方法: data参数传递,responseCallback回调方法
function callNative(data, responseCallback) {
data = data || {}
try{
var cid = 'cid' + uuid();
if(responseCallback) {
responseCallbacks[cid] = responseCallback;//保存回调
data.callbackID = cid; //回调时使用callbackID取出回调函数
}
jsBridgeApiData = data;
var iframe = doc.createElement('iframe');
var url = getIFrameSrc(JSBRIDGE_URL_API);
console.log(url);
iframe.setAttribute('src', url);
doc.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
}catch(e){
if(typeof console !== 'undefined') {
console.error('[JSBridge] EXCEPTION: ', e);
}
}
}
//原生获取参数信息
function getAPIData () {
return JSON.stringify(jsBridgeApiData);
}
//原生调用JS方法返回消息给JS
function invokeJSCallback (cid, removeAfterExecute, config) {
if (!cid) {
return;
}
var cb = responseCallbacks[cid];
if (!cb) {
return;
}
if (removeAfterExecute) {
delete (responseCallbacks[cid]);
}
var data = config;
if (data.callbackID) {
delete data.callbackID;
}
cb.call(null, data);
}
//将对象绑定在window上
w.Bridge = {
callNative:callNative.bind(this),
getAPIData:getAPIData.bind(this),
invokeJSCallback: invokeJSCallback.bind(this),
};
})(window, document);
- 原生端
- 在界面加载完成之后注入自定义的JS代码;
-
- (void)webViewDidFinishLoad:(UIWebView *)webView { NSString *hasInjected = [webView stringByEvaluatingJavaScriptFromString:@";(function(){return window.Bridge ? 'true' : 'false';})()"]; if (![hasInjected isEqualToString:@"true"]) { //注入js NSError *error = nil; NSString *jspath = [[NSBundle mainBundle] pathForResource:@"bridge" ofType:@"js"]; NSString *jsToInject = [[NSString alloc] initWithContentsOfFile:jspath encoding:NSUTF8StringEncoding error:&error]; [webView stringByEvaluatingJavaScriptFromString:jsToInject]; } }
- 查看加载的URL是否需是需要拦截自定义链接:如果是需要拦截的URL,调用对应的方法实现并返回false,否则返回true;
- 调用getAPIData获取参数数据;
- 解析参数获取执行操作需要的数据;
- 定义返回数据并转化为json数据;
- 调用invokeJSCallback将操作结果数据返回给web.
-
static NSString * const bridge_scheme = @"bridgescheme"; static NSString * const bridge_host = @"bridge_message"; static NSString * const bridge_relativePath = @"/bridge_api"; - (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType { NSURL *url = request.URL; if ([url.scheme isEqualToString:bridge_scheme]) { //拦截到自定义请求 if ([url.host isEqualToString:bridge_host]) { if ([url.relativePath isEqualToString:bridge_relativePath]) { //获取参数 NSString *apiData = [webView stringByEvaluatingJavaScriptFromString:@"Bridge.getAPIData()"]; NSData *data = [apiData dataUsingEncoding:NSUTF8StringEncoding]; NSError *error = nil; NSDictionary *params = [NSJSONSerialization JSONObjectWithData:data options:(0) error:&error]; if (!error) { NSLog(@"params == %@", params); if ([params[@"api"] isEqualToString:@"show.alert"]) { NSString *callbackID = params[@"callbackID"]; NSString *title = params[@"data"][@"title"]; NSString *msg = params[@"data"][@"msg"]; NSArray <NSString *> *buttons = params[@"data"][@"buttons"]; showAlertController(title, msg, buttons, ^(NSDictionary *p) { NSString *jsonForResult = p.json; NSString *js = [NSString stringWithFormat:@"Bridge.invokeJSCallback(\"%@\", %@, %@)", callbackID, @"true", jsonForResult]; [webView stringByEvaluatingJavaScriptFromString:js]; }); } } } } return false; } return true; }
- web端:主要负责发起对原生的调用请求并接收回调,将以下代码保存为index.html.
<script>
function func_callback(params){
document.getElementById('jsParamFuncSpan').innerHTML = JSON.stringify(params);
}
function clickAction() {
Bridge.callNative({api:'show.alert', data:{title: '来自web的弹窗调用',msg: 'Hello world!', buttons:["confirm" , "cancel"]}}, func_callback);
}
</script>
使用JavaScriptCore实现交互
自iOS7.0之后系统提供了JavaScriptCore特殊的框架专门用于原生和js的交互.其中主要的类JSContext用于提供JS运行环境上下文,JSValue用于处理一切JS中的存在.
例如:
//创建js执行环境 JSContext *jsContext = [[JSContext alloc] init]; //定义js函数 [jsContext evaluateScript:@"function add(a, b){return a + b;}"]; //可以使用如下两种方法调用js环境中的方法 //方式一:直接执行js方法 JSValue *result = [jsContext evaluateScript:@"add(3.4, 4.5)"]; NSLog(@"result == %@", result); //方式二:首先获取js方法,然后传递参数执行js方法 //1. 获取js方法 JSValue *add = [jsContext evaluateScript:@"add"]; //2. 执行js方法使用数组传递参数 JSValue *result = [add callWithArguments:@[@3.4, @4.5]]; NSLog(@"result == %@", result);
如果使用JavaScriptCore来实现JS和原生的交互处理起来就会容易很多.JavaScriptCore提供了两种方式来实现JS调用原生的方法:
- 注册原生block方法:在JSContext环境中注册block回调,在JS进行调用注册方法的时候可以调用到原生的block实现;
- 定义JSExport子协议:将JS环境中需要调用的原生方法定义在JSExport子协议中,定义遵守服从子协议的类并实现协议方法.
注册原生block方法
对于这种方式,只需要将JS中需要调用到的方法名称在JSContext中进行注册,JS即可通过方法名调用到原生的实现.
在原生端:
- (void)webViewDidFinishLoad:(UIWebView *)webView {
JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
//注册原生方法供JS调用
jsContext[@"show"] = ^(NSDictionary *params) {
NSString *api = params[@"api"];
if ([api isEqualToString:@"show.alert"]) {
NSDictionary *data = params[@"data"];
NSArray<NSString *> *buttons = data[@"buttons"];
NSString *functionName = data[@"functionName"];
//显示alert
NSString *title = data[@"title"];
NSString *msg = data[@"msg"];
JSContext *_jsContext = [JSContext currentContext];
//显示弹窗并注册用户回调
showAlertController(title, msg, buttons, ^(NSDictionary *params) {
NSString *js = [NSString stringWithFormat:@"%@(%@)", functionName, params.json];
[_jsContext evaluateScript:js];
});
}
};
}
在web端:
<script>
//接收原生回调结果
function func_callback(params){
document.getElementById('jsParamFuncSpan').innerHTML = JSON.stringify(params);
}
//发起JS调用原生方法
function clickAction() {
show({api:'show.alert', data:{title: '来自web的弹窗调用',msg: 'Hello world!', buttons:["confirm" , "cancel"], functionName: 'func_callback'}});
}
</script>
这种方式实现起来相对简单,对于小的需求来说开发难度小.缺点就是不符合单一性原则,会导致大量的交互处理代码集中在一起耦合在一起,当交互功能比较复杂时维护起来难度较大.
定义JSExport子协议
- 定义JSExport的子协议
-
@protocol BridgeProtocal <JSExport> - (void)showAlert:(NSDictionary *)command; @end
- 定义遵从协议并实现协议方法类:在应用加载时会将遵从协议的方法加载到UIWebview的JSContext执行环境中去.
-
@interface Bridge : NSObject<BridgeProtocal> @property (nonatomic, weak) JSContext *jsContext; @end @implementation Bridge - (void)showAlert:(NSDictionary *)params { //获取参数 if ([params[@"api"] isEqualToString:@"show.alert"]) { NSString *functionName = params[@"data"][@"functionName"]; NSString *title = params[@"data"][@"title"]; NSString *msg = params[@"data"][@"msg"]; NSArray <NSString *> *buttons = params[@"data"][@"buttons"]; dispatch_block_t block = ^(){ UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:(UIAlertControllerStyleAlert)]; for (NSString *title in buttons) { [alert addAction:({ UIAlertAction *confirm = [UIAlertAction actionWithTitle:title style:(UIAlertActionStyleDefault) handler:^(UIAlertAction * _Nonnull action) { NSString *jsonForResult = @{@"selectedTitle" : title}.json; NSString *js = [NSString stringWithFormat:@"%@(%@)", functionName, jsonForResult]; [self.jsContext evaluateScript:js]; }]; confirm; })]; } [[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alert animated:true completion:^{ }]; }; executeBlockInSafeMainQueue(block); } } @end
- 在UIWebview代理方法中获取执行JS的环境变量:
-
- (void)webViewDidFinishLoad:(UIWebView *)webView { self.jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; Bridge *bridge = [[Bridge alloc] init]; //保存属性执行回调 bridge.jsContext = self.jsContext; self.jsContext[@"Bridge"] = bridge; }
- web端:
-
<script> function func_callback(params){ document.getElementById('jsParamFuncSpan').innerHTML = JSON.stringify(params); } function clickAction() { Bridge.showAlert({api:'show.alert', data:{title: '来自web的弹窗调用',msg: 'Hello world!', buttons:["confirm" , "cancel"], functionName : 'func_callback'}}); } </script>