ios 底层

iOS JSBridge技术手段衍化整理

2021-05-25  本文已影响0人  Eve郑思越

背景

之前一段时间在对项目里的 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参数后做对应的处理。

缺陷

优化

// ---------------------------- 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;
}

结合以上所有的优化手段,我们可以得到一个针对 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://img.haomeiwen.com/i1835011/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()

踩坑

缺陷

优化

在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 线程安全

上一篇下一篇

猜你喜欢

热点阅读