背景
之前一段时间在对项目里的 JSBridge 进行整理和优化,突然想到想整理一下 JSBridge 在 iOS 系统版本衍化过程中所出现过的主要技术手段。
绝大多数 APP 都逃不开 H5 开发或加载网页的需求。JSBridge 应用于 Web 和 Native 两端的交互,所以也是 Hybrid APP (混合移动端应用)运作的核心层级。
可以说 JSBridge 是 iOS 开发者的必修课。
JSBridge
JSBridge 即利用 JavaScript 语言,令 Web 和 Native 两端可以进行交互的桥接层。一个完整的 JSBridge 方案需要对所有两端交互的技术手段进行选型、优化和整合。这里我们只讨论 iOS 和 Web 端的交互手段。
JavaScriptCore
任何一个移动端系统的 WebKit 都会默认内嵌各自的 JS 引擎,它们的工作就是对 JS 脚本进行编译与运行( JS 虚拟机,用来分析词汇与语法生成 ByteCode (指令字节码)并运行,和负责运行时的内存空间开辟、管理等等)。
JS 引擎是实现 JSBridge 核心,而 iOS/OS 端对应的 JS 引擎是 JavaScriptCore 。
在 iOS 7 以后,苹果对 WebKit 中的 JavaScriptCore 框架进行 Objective-C 的封装并提供给开发者。所以在 iOS 端中, JSBridge 使用的技术手段也以 iOS 7 为临界点分为两个阶段。
Before iOS 7
iOS call Web
UIWebView 提供了 stringByEvaluatingJavaScriptFromString:
// NOTE: Returns the result of running a JavaScript script
- (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
用来在当前网页执行一段 JS 脚本并以字符串的形式返回调用结果:
// NOTE: 获取当前url
NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];
// NOTE: 获取当前网页标题
NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
// NOTE: 注入自定义方法jsFunction()
NSString *jsFunction = @";(function() {function jsFunction(){return 'Hello World';})();";
[webView stringByEvaluatingJavaScriptFromString:js];//注入js方法
// NOTE: 调用自定义方法jsFunction并获取回调结果
NSString *resultString = [webView stringByEvaluatingJavaScriptFromString:@"jsFunction()"];
Web call iOS
拦截 URL
iOS 7 之前,基本上都是使用拦截 URL 的方案来实现 Web call iOS。
1 . iframe.src (重定向)
HTML内联框架元素 <iframe>,将另一个HTML页面嵌入到当前页面中。
function doSend(message, responseCallback) {
messagingIframe.src = 'mizhua://';
}
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"]) {
//do something here
return NO;
}
return YES;
}
核心思路就是在 WebView 拦截 Web 端发起的请求。双方提前约定好协议(例如:mizhua://host?dyaction=home&tab=0,约定了 Scheme、URL 和入参),iOS 端在对应的 WebView delegate 方法过滤对应协议的请求,拿到url参数后做对应的处理。
缺陷
- 发起 url request 有命中 url 长度限制的隐患(如 browser 端的IE限定url长度为2083字节,opera 是4050,或者服务端的限制),特别是带参多的情况;
- WebView对调用 url request 有频率限制,快速调用多次 url request 有令前一部分丢失拦截的风险;
- 比起用 JavaScriptCore 注入 api 并直接调用的方式,url request 创建请求有一定的耗时;
- 比起用 JavaScriptCore 注入 api 的方式,url request 无法直接 callback 处理结果。
优化
- 针对 url 长度限制的问题,可以转 push 为 pull ,让 iOS 端主动拉取要 web 端所要调用方法的信息,步骤如下:
① Web 端把要调用的方法和参数先转换为 json 字符串(例如:{"funcName":"login","prama":{"username":"xiaoming"}}
)保存起来;
② 并预写好一个用来获取该 json 字符串的方法(例如:function fetchMessage()
);
③ 然后发起一个简单的 url request (例如: mizhua://fetch_message );
④ WebView 在拦截到 mizhua://fetch_message 请求后,主动调用 Web 端的fetchMessage()
方法,拉取到 json 字符串并反序列化,得到要调用的方法的信息。
相应的伪代码如下:
// ---------------------------- Web ----------------------------
var sendMessage;
callOC({ funcName:'login', prama:{'username': 'xiaoming'} })
function callOC(message) {
sendMessage = message;
messagingIframe.src = 'mizhua://fetch_message';
// messagingIframe.src = 'mizhua://fetch_message?funcName=login&username=xiaoming...';
}
function fetchMessage() {
var messageString = JSON.stringify(sendMessage);
sendMessage = null;
return messageString;
}
// ---------------------------- iOS ----------------------------
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"] && [url.host containsString:@"fetch_message"]) {
NSString *messageString = [self stringByEvaluatingJavaScriptFromString:@"fetchMessage();"];
Message *obj = [self deserializationMessage:messageString];
!obj.handleBlock?:obj.handleBlock();
return NO;
}
return YES;
}
- 针对丢失拦截的问题,我们在前面方案的基础上,把要调用的函数信息保存到一个消息队列
sendMessageQueue
中,Native 调用fetchMessage()
的时候,Web 端把sendMessageQueue
里的所有方法取出来返回给客户端,并清空队列。
这样做的好处是当发生丢失拦截时,消息还会缓存在消息队列中,等到下一次 web 端发起 mizhua://fetch_message 要调用 iOS 端的时候,未执行的 message 就会跟着其他消息一起被 native 获取到。 - 针对 url request 无法直接 callback 的问题,解决方案也是让 iOS 端主动调用 web 端预先注册好的监听回调函数,不过需要让这个回调函数和调用函数建立起联系。
我们可以在 web 端维护一个回调函数字典responseCallbacks
,用来存储所有注册的回调函数,并以 web 端发起请求的时间戳为 key (callbackId
),并将它写入到 message 中({"funcName":"login","prama":{"username":"xiaoming"}, "callbackId":"1555064744266"}
),iOS 端检测到 message 中带有callbackId
时,再将callbackId
和返回值拼接起来,调用 web 端预写的处理函数handleCallback()
,由 web 端进行回调分发。
结合以上所有的优化手段,我们可以得到一个针对 url 重定向的 JSBridge 整体技术方案:
// ---------------------------- Web ----------------------------
// call native 方法队列
var sendMessageQueue = [];
// 回调函数字典,key 为时间戳生成的 callbackId
var responseCallbacks = {};
// 调用原生方法
// message 为消息内容,一般包括funcName(原生方法名)、prama(入参)、callbackId(回调标记)等字段
// responseCallback 为回调函数,
function callOC(message, responseCallback) {
// step 1:设置回调函数的情况下,根据时间戳生成 callbackId ,并将它们缓存到内存
if (responseCallback) {
var callbackId = new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
// step 2:消息入队
sendMessageQueue.push(message);
// step 3:发起让 native 拉取消息列表的 url 请求
messagingIframe.src = 'mizhua://fetch_message';
}
callOC({ funcName:'login', prama:{'username': 'xiaoming'} }, function(response) {
log(response)
})
// 序列化消息队列并返回给 native
function fetchMessageQueue() {
// step 5:序列化消息队列 `sendMessageQueue`,并清空队列
var messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
// step 6:返回消息队列的json string
return messageQueueString;
}
// 监听 native 的回调
function handleCallback(messageJSON) {
var message = JSON.parse(messageJSON);
var responseCallback;
// step 9:处理回调并将对应 callbackId 的回调函数移出缓存
if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
}
}
// ---------------------------- iOS ----------------------------
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"] && [url.host containsString:@"fetch_message"]) {
// step 4:native 识别到 fetch_message 请求后,从 web 端拉取消息列表
NSString *messageQueueString = [self stringByEvaluatingJavaScriptFromString:@"fetchMessageQueue();"];
NSArray <Message *>*messageList = [self deserializationMessageQueue:messageQueueString];
// step 7:反序列化出所有的 message 并依次执行
[messageList enumerateObjectsUsingBlock:^(Message * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
!obj.handleBlock?:obj.handleBlock();
// step 8:message 带 callbackId 的情况下,需要调用回调方法
if (obj.callbackId) {
[self stringByEvaluatingJavaScriptFromString:@"handleCallback('%@')",@{@"callbackId":obj.callbackId,@"responseData":responseData}];
}
}];
return NO;
}
return YES;
}
https://upload-images.jianshu.io/upload_images/1835011-6c02d5c848b2629b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240
2 . Ajax & NSURLProtocol
AJAX = Asynchronous JavaScript and XML
AJAX 可以在不重定向当前页面的情况下,与服务器交换数据并更新部分网页内容。
官方文档对于 NSURLProtocol 的描述如下:
An abstract class that handles the loading of protocol-specific URL data.
它是一个描述 URL 加载过程的抽象类,你可以通过子类化它来重新定义新的或已经存在的URL加载行为。
我们可以利用这个方案来拦截到 APP 的 URL请求,面向切面编程地应用到网络缓存、网络请求监控、防止DNS劫持、重定向网络请求等场景。不过这里我们只讨论拦截 URL 来实现 JSBridge 的部分。
前端使用XMLHttpRequest
发起请求,原生注册自定义NSURLProtocol
进行拦截:
// 1.新建类继承自`NSURLProtocol`,并注册
[NSURLProtocol registerClass:[DYURLProtocol class]];
// 2.前端调用原生
function callNative(action, data) {
var xhr = new window.XMLHttpRequest(),
url = 'mizhua://fetch_message';
xhr.open('POST', url, false);
xhr.send(JSON.stringify({
action: action,
data: data
}));
return xhr.responseText;
}
// 3.在`startLoading`代理方法拦截请求
@implementation DYURLProtocol
- (void)startLoading {
NSURL *url = [[self request] URL];
if (![url.host isEqualToString:@"__jsbridge__"]) return;
// 4.处理JS调用Native
NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:self.request.HTTPBody options:NSJSONReadingAllowFragments error:nil];
NSString *action = dic[@"action"];
NSString *data = dic[@"data"];
// 5. 处理完成,将结果返回给js
NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[[self request] URL] statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : mimeType}];
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
if (data != nil) {
[[self client] URLProtocol:self didLoadData:data];
}
[[self client] URLProtocolDidFinishLoading:self];
}
缺陷
WKWebView 维护着自己的 NSURLProtocol ,并不在以上方案的 hook 范围中。因此,在 WKWebView 上无法直接使用注册 NSURLProtocol 的方式拦截请求。
优化
苹果开源的 WebKit2 源码暴露了以下的私有API:
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]
通过注册 http(s) scheme 可以拦截到对应的http或https请求
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}
After iOS 7
iOS 7 以后,苹果对 WebKit 中的 JavaScriptCore 框架进行 Objective-C 的封装并提供给开发者。
简单说一下 JavaScriptCore 对 Objective-C 的封装相关的几个概念
JSValue
JSValue 是一个指向 JS 变量(var)的引用指针。使用 JSValue,可以让数据类型在 OC 和 JS 之间相互转换。
Objective-C type | JavaScript type
--------------------+---------------------
nil | undefined
NSNull | null
NSString | string
NSNumber | number, boolean
NSDictionary | Object object
NSArray | Array object
NSDate | Date object
NSBlock | Function object
id | Wrapper object
Class | Constructor object
JSContext
“Context” 一般理解为上下文。 JSContext 是 JS 语言的执行环境。我们可以通过 KVC 的方式获取当前 WebView 的 JSContext:
JSContext *context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
JSContext 中有一个 JSValue 类型的属性,名字是 GlobalObject。它是当前所执行的 JSContext 的全局对象,所有的 JS 变量(var)与函数(function)都在全局对象里。例如在 WebKit 中, GlobalObject 就相当于 web 端的 Window 对象。我们获取到浏览器的 JSContext ,对它注入代码,其实就是在操作 Window。
一个 JSContext 可以拥有多个 JSValue ,同时 JSValue 和它对应的 var 以及它其所属的 JSContext 对象都是强引用的关系:
GC 机制
JS 不需要我们去手动管理内存。JS 的内存管理使用的是 GC 机制(Tracing Garbage Collection)。不同于 OC 的引用计数,Tracing Garbage Collection 是由 GCRoot(Context)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:
单线程
JS 引擎是单线程,以消息队列(TaskQueue)与事件循环(EventLoop)机制进行 task 的分发,原理上可联想到 OperationQueue 和 RunLoop。
iOS call Web
// NOTE: UIWebView 中,我们可以通过KVC的方式获取当前 WebView 的 JSContext
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
JSValue *value = [context evaluateScript:@"document.title"];
NSString *title = value.toString;
[context evaluateScript:@"fetchMessages();"];
//or
JSValue *fetchMessagesFunction = context[@"fetchMessages"];
[fetchMessagesFunction callWithArguments:nil];
// NOTE: 异步调用
[context[@"setTimeout"] callWithArguments:@[fetchMessagesFunction, @0]];
// NOTE: WKWebView 中,直接提供了evaluateJavaScript函数
[wkWebView evaluateJavaScript:@"document.title" completionHandler:^(NSString* title, NSError *error) {
}];
Web call iOS
UIWebView
向 JSContext 中注入 Block:
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
context[@"fetchMessages"] = ^(NSArray<NSArray *> *calls) {
// Native 逻辑
};
//Web 端直接调用 fetchMessages()
JavaScriptCore 会在 Window 中生成对应的 fetchMessages()
。
踩坑
- 不要在 Block 中直接使用相应的 JSValue 或 JSContext 。
前面说到 JSValue 会强引用它所属的 JSContext ,而 Block 会强引用它使用到的外部变量。
针对 JSValue ,可以把 JSValue 当做参数传到 Block 中,来规避外部引用。
针对 JSContext,可以在 Block 中使用[JSContext currentContext]
方法来获取当前的 JSContext 。 - 需要在 Block 中 call Web (
callWithArguments:
、evaluateScript:
) 的情况下,不要切换线程。
Block 中的运行线程就是 JS 引擎所使用的单线程,切换它有几率会引发其他问题。
缺陷
- 在
UIWebViewDelegate
提供的有限的代理方法中,唯一能有效获取页面 javaScriptContext 加载完成时机的方法是webViewDidFinishLoad:
。意味着在整个页面加载完成前,前端调用原生的方法不会生效。
优化
在WebKit中,苹果提供了 WebFrameLoadDelegate
的 didCreateJavaScriptContext: 代理方法来定位javaScriptContext
加载完成的时机,但只曝露给 OS 系统。我们可以通过给NSObject
加分类实现该代理方法,不过这个方案也触及了私有api
WKWebView (iOS 8)
@interface WKWebVIewVC ()<WKScriptMessageHandler>
@implementation WKWebVIewVC
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = [[WKUserContentController alloc] init];
WKUserContentController *userCC = configuration.userContentController;
[userCC addScriptMessageHandler:self name:@"fetchMessages"];
WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"fetchMessages"]) {
NSLog(@"前端传递的数据 %@: ",message.body);
}
}
项目中的 JSBridge 方案
目前项目里和前端配合,使用了 Kerkee 跨平台 hybrid 框架,仅使用到里面的 JSBridge 模块。
框架内判断 iOS 8 以上使用 WKWebView,并使用向 JavaScriptContext 注入 API 的方案来实现;iOS 8 以前使用拦截 URL 重定向的方案来实现,并有进行 message queue 和 callback function handler 的优化。
参考与拓展
WKWebView 弹窗拦截
NSURLProtocol
JavaScriptCore
JavaScriptCore 踩坑(内存管理、线程安全等)
WebViewJavascriptBridge
UIWebView-TS_JavaScriptContext
WKWebView 踩坑(Cookie、NSURLProtocol 等)
JavaScriptCore 美团
JavaScriptCore nshipster
JavaScriptCore 加载时机
JavaScriptCore 线程安全