iOS JSBridge技术手段衍化整理

背景

之前一段时间在对项目里的 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中,苹果提供了 WebFrameLoadDelegatedidCreateJavaScriptContext: 代理方法来定位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 线程安全

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

推荐阅读更多精彩内容