李峰峰博客

WKWebView 与 JavaScriptCore

2020-09-12

一、WKWebView

1、WebKit

WebKit 是 Safari 浏览器的内核,WebKit 由多个重要模块组成:

  • WebKit Embedding API(WebKit 嵌入 API)
    WebKit 提供给浏览器 UI 调用的接口,例如 iOS 中 WebKit 框架提供的 WKWebView

  • WebCore
    HTML 排版引擎,包括 HTML 解析器、CSS 解析器等。

  • JavaScriptCore
    JavaScriptCoreWebKit 默认内嵌的 JavaScript 脚本引擎,是 JavaScript 的虚拟机,为 JavaScript 的执行提供底层资源。

2008 年,WebKit 项目宣布对 JavaScriptCore 重写,项目演变成 SquirrelFish Extreme(简称为 SFX,市场称之为 Nitro),JavaScript 的运行效率有了很大的提升。Safari 和 WKWebView 都使用了 Nitro JavaScript 引擎。

  • WebKit Ports
    WebKit 中的非共享部分,WebKit Ports 提供了调用 Native Library 的接口,由于各平台差异、第三方库和需求不同等原因,往往需要按照自己的方式来设计与实现,例如网络方面 Safari 使用 CFNetwork,而 Chromium 中使用 Chromium stack 等等。

WebKit 的历史
WebKit 项目是 Apple 于 2001 年启动的项目,2005 年将 WebKit 开源(之前仅有 WebCoreJavaScriptCore 开源)。其中 WebCore 基于 KDE(一个国际性的自由软件社区) 发布的 Konqueror 浏览器项目中的 HTML 排版引擎 KHTMLJavaScriptCore 基于 Konqueror 中的 JavaScript 引擎 KJS

Apple 将 KHTML 发扬光大,起初的时候 Apple 和 KDE 关系很和谐,但是随着时间的推进,WebKitKHTML 之间交换代码变得越来越困难,Apple 经常间隔很长时间后提交一大批代码修改,并且没有足够的代码注释及文档,而且存在未开发完整的功能,对于 KDE 而言合并代码非常困难。此外,Apple 要求 KDE 开发者阅览苹果代码之前必须签署保密条款,KDE 也很难接受这一点。在 2005 年,KDE 开发者开始公开攻击 Apple 的做法,并称两方的合作关系已经彻底瓦解了。

事情被媒体报道之后,苹果做出了一系列的让步。在 2005 年,苹果宣布将 WebKit 完全开源。KDE 和 Apple 的关系也得到了一些改善,有一些 KDE 的开发者们开始为 WebKit 提交更改。

之后,Google 参与 WebKit 的开发,并与 2008 年推出 Chrome 浏览器,Chrome 浏览器是不开源的,其开源项目是 Chromium,Chromium 为 Chrome 提供了绝大多数的源代码。虽然 Chrome 浏览器是基于 WebKit 开发的,但是 Chrome 主要使用了 WebKit 中的 WebCore,Google 自研了 JavaScript 引擎 V8。

后由于 Google 与 Apple 之间竞争关系,两者互相指责对方在源码共享上不够开放,2013 年 Google 宣布它创建了 WebKitWebCore 组件的分支——Blink。

2、WKWebView

(1) WKWebView 多进程结构

在 iOS 系统中,通常一个应用对应一个进程,但是在 WebKit 的发展过程中,基于稳定性与安全性考虑,引入了多进程的概念,避免单一页面的异常影响整体 app 运行,进程之间通过 CoreIPC 进行通信,一个 WKWebView 往往对应如下三个进程:

  • UIProcess 进程为 app 所在进程,WKWebView 在该进程中提供了大量 API 供开发者与内核交互,也是开发者最熟悉的一部分。

  • WebContent 进程对应的是每一个新开的网页,该进程视内存情况可进行复用,某一 WebContent 进程的异常并不会影响到主 app 进程及其他 WebContent 进程,WebContent 进程被杀死的现象为白屏。

  • NetWorking 进程,无论多 WKWebView 还是单 WKWebView 场景,都只有唯一的 NetWorking 进程,这种设计主要便于网络请求管理以及保证网络缓存、cookie 等管理的一致性。

苹果官方文档中描述:配置同一 WKProcessPool 的多个 WKWebView 共享同一 WebContent 进程,即可以配置 WebContent 进程唯一(原文)。
但源码头文件中的注释与官方文档不一致,源码头文件描述配置同一 WKProcessPool 的多个 WKWebView 共享的是同一 WebContent 进程池,该配置未限制 WebContent 进程数量,而是共享进程池。
从 Demo 实际测试看,官方文档描述并不准确,我们以源码注释为准。
(摘自 WebKit 源码调试与分析

也就是说,即使多个 WKWebView 配置了同一个 WKProcessPool,打开多个 WKWebView 仍然会创建多个 WebContent 进程。

WKWebView cookie 的存储
先看下 WKWebView 的三个进程与 cookie 的关联
UIProcess:
UIProcess 进程为 app 进程(APP 进程中其实有 NSHTTPCookieStorage 仓储进行 cookie 管理,但这不是本文的重点,因此不展开来讲),苹果系统为开发者提供了 WKHTTPCookieStorage API 进行 WebKit 内核的 cookie 管理,WKHTTPCookieStorage 其实并不提供实际的存储能力,而是封装了一系列基于进程间通信的方法,将 UIProcess 进程中发生的 cookie 操作,发送到 NetWorking 进程中进行处理,并将执行结果通过回调函数返回。

WebContent:
WebContent 进程是前端操作 cookie 的进程,原则上,每一个网页页面都只能操作当前页面域名下的 cookie。因此基于性能考虑,每一个 WebContent 进程中会有一个 cookieCache 实例,它是 NetWorking 进程中存储的 cookie 的子集,仅存储当前页面域名下的 cookie,因此 cookieCache 采取了内存缓存的方式,其特征是存储量小,查找速度快。

NetWorking:
NSHTTPCookieStorage setCookie 流程图:

NetWorking 进程是 cookie 存储的最核心进程,它管理来自网络中服务端 response 中配置的 cookie,同时也接受来自前端和客户端的 cookie 操作,是最全的 cookie 存储中心。通过源码分析,我们发现其内部还是通过 NSHTTPCookieStorage 进行管理的, NSHTTPCookieStorage 有如下存储规则:

  • allCookies
    所有 cookie 都会存入字典 allCookies 中,方便快速查询。当我们杀死 app 后,位于内存中的 allCookies 字典也会一同清理掉。

  • sessionOnly false cookie
    对于某个 cookie,如果其属性中 sessionOnlyfalse,且设置的过期时间未到达,那我们判断该 cookie 是否具备持久性。

  • 持久性 cookie
    具备持久性的 cookie 需要存储到磁盘文件中。

三种不同场景的 cookie 操作是如何协同工作的?
cookie 管理协同图所示,不同场景下的 cookie 协同操作其本质就是三大进程间的通信:

  • UIProcess 进程并没有直接管理 cookie,而是通过进程间通信的方式,在 NetWorking 进程中管理 cookie

  • 冷启动时,NetWorking 进程会初始化内部 NSHTTPCookieStorage ,并会将磁盘中的 cookie 读取出来,设置到内存字典 allCookies 中,同时将 allCookies 中的 cookie 变更通过广播的方式告知 WebContent 进程,发生了 cookie 变更,需要进行 cookie 同步。

    • 来自客户端的 cookie 操作或者来自服务端的 cookie 设置,导致了 NetWorking 进程中的 cookie 变更,都会通过广播的方式告知所有 WebContent 进程同时进行变更操作。
  • 所有 WebContent 进程都会注册监听 NetWorking 进程中的 cookie 变更,及时进行相关变更的同步。

    • 前端 setCookie 操作会将 cookie 字符串解析为 NSHTTPCookie 实例,然后将该 cookie 存入 cookieCache 中,并同步到 NetWorking 进程中进行存储。

    • 前端执行 getCookie 操作会读取当前页面域名下的所有 cookie,若判断 cookieCache 中没有当前页面域名下的 cookie,考虑到异常情况,会兜底向 NetWorking 进程发送请求进行 cookie 查找。

WKWebView 的 cookie 问题
UIWebViewcookie 是通过 NSHTTPCookieStorage 来统一处理的,服务端响应时写入,UIWebView 发起请求会自动带上 NSHTTPCookieStorage 中的 cookieWKWebView Cookie 问题在于 WKWebView 发起的请求不会自动带上存储于 NSHTTPCookieStorage 容器中的 cookie。因为 WKWebView 请求已不在 APP 进程中发起和响应处理,而是在单独进程中处理,所以 WKWebView 发起的请求无法直接从 NSHTTPCookieStorage 取到 cookie

但是 WKWebView 其实会将 Cookie 存储于 NSHTTPCookieStorage 中的,但存储时机有延迟,在 iOS 8 上,当页面跳转的时候,当前页面的 cookie 会写入 NSHTTPCookieStorage 中,而在 iOS 10 上,JS 执行 document.cookie 或服务器 set-cookie 注入的 cookie 会很快同步到 NSHTTPCookieStorage 中。

所以当我们使用 Native 去做登录,登录完成后将 cookie 写入 NSHTTPCookieStorage,在 UIWebView 上不会有问题,在 WKWebView 上就不行了,原因就在于 WKWebView 不会从 NSHTTPCookieStorage 中获取 cookie,这就是常说的 WKWebViewcookie 问题。

iOS 11+ 可以利用 WKHTTPCookieStore 解决这个问题,因为只要是存在 WKHTTPCookieStore 里的 cookie,WKWebView 每次请求都会携带。

1
2
3
4
5
6
7
8
9
10
11
@interface WKHTTPCookieStore : NSObject
/*! @abstract 获取所有 cookie @param completionHandler 获取所有 cookie 后回调 */
- (void)getAllCookies:(void (^)(NSArray *))completionHandler;

/*! @abstract 设置一个 cookie @param cookie 需要设置的 cookie @param completionHandler cookie 设置成功的回调 */
- (void)setCookie:(NSHTTPCookie *)cookie completionHandler:(nullable void (^)(void))completionHandler;

/*! @abstract 删除指定的 cookie @param completionHandler cookie 成功删除的回调 */
- (void)deleteCookie:(NSHTTPCookie *)cookie completionHandler:(nullable void (^)(void))completionHandler;

@end

iOS 11 以下系统,可以在 WKWebView loadRequest 前,在 request header 中设置 Cookie 以解决该问题:

1
2
3
4
5
WKWebView * webView = [WKWebView new]; 
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://h5.qzone.qq.com/mqzone/index"]];

[request addValue:@"skey=skeyValue" forHTTPHeaderField:@"Cookie"];
[webView loadRequest:request];

对于(同域)Ajax、iframe 请求不携带 cookie 问题,可以使用如下方式解决:

1
2
3
4
WKUserContentController* userContentController = [WKUserContentController new]; 
WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource: @"document.cookie = 'skey=skeyValue';" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];

[userContentController addUserScript:cookieScript];

这种方案无法解决 302 请求的 Cookie 问题,比如,第一个请求是 www.a.com, 我们通过在 request header 里带上 Cookie 解决该请求的 Cookie 问题,接着页面 302 跳转到 www.b.com, 这个时候 www.b.com 这个请求就可能因为没有携带 cookie 而无法访问。当然,由于每一次页面跳转前都会调用回调函数:

1
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

可以在该回调函数里拦截 302 请求,copy request,在 request header 中带上 cookie 并重新 loadRequest。不过这种方法依然解决不了页面 iframe 跨域请求的 Cookie 问题,毕竟-[WKWebView loadRequest:] 只适合加载 mainFrame 请求。

(3) WKWebView NSURLProtocol 拦截问题

WKWebView 在独立于 APP 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol 无法拦截请求。苹果开源的 webKit2 源码暴露了私有 API:

1
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]

通过注册 http(s) schemeWKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求:

1
2
3
4
5
6
7
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"];
}

iOS 11 上, WebKit 团队开放了 WKWebView 加载自定义资源的 API WKURLSchemeHandler

1
2
3
4
5
6
7
8
9
10
11
12
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
//设置URLSchemeHandler来处理特定URLScheme的请求,URLSchemeHandler需要实现WKURLSchemeHandler协议
//本例中WKWebView将把URLScheme为customScheme的请求交由CustomURLSchemeHandler类的实例处理
[configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
self.view = webView;
[webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"customScheme://www.test.com"]]];
}
@end

注意:

  • setURLSchemeHandler 注册时机只能在 WKWebView 创建WKWebViewConfiguration 时注册。
  • WKWebView 只允许开发者拦截自定义 Scheme 的请求,不允许拦截 “http”、“https”、“ftp”、“file” 等的请求,否则会 crash。
  • 【补充】WKWebView 加载网页前,要在 user-agent 添加个标志,H5 遇到这个标识就使用 customScheme,否则就是用原来的 httphttps

一般可以通过这种方式实现 H5 加载秒开,基本流程是提前预加载 html、js、css、图片类型文件,注册自定义 WKURLSchemeHandler 拦截资源请求,收到拦截请求后,先获取本地资源包对应的资源,转换成 data 回传给 webView 进行渲染处理;若本地没有,则 customScheme 替换成 https 的 url 重发请求通知 webview。注意由于只能拦截自定义的 Scheme 请求,所以必须和相关 H5 研发统一 Scheme。

但是这种方案目前存在两个严重缺陷:

  • a、post 请求 body 数据被清空
    由于 WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。在 webkit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC 发送给 App Process。出于性能的原因,encode 的时候 HTTPBodyHTTPBodyStream 这两个字段被丢弃掉了。

因此,如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发起的所有 http(s) 请求都会通过 IPC 传给主进程 NSURLProtocol 处理,导致 post 请求 body 被清空;

  • b、对 ATS 支持不足
    测试发现一旦打开 ATS 开关:Allow Arbitrary Loads 选项设置为 NO,同时通过 registerSchemeForCustomProtocol 注册了 http(s) scheme,WKWebView 发起的所有 http 网络请求将被阻塞(即便将Allow Arbitrary Loads in Web Content 选项设置为YES);

WKWebView 可以注册 customScheme, 比如 dynamic:// ,因此希望使用离线功能又不使用 post 方式的请求可以通过 customScheme 发起请求,比如 dynamic://www.dynamicalbumlocalimage.com/,然后在 app 进程 NSURLProtocol 拦截这个请求并加载离线数据。不足:使用 post 方式的请求该方案依然不适用,同时需要 H5 侧修改请求 scheme 以及 CSP 规则;

3、WKWebView Native 与 JS 互相调用

UIWebView 时代没有提供系统级的函数进行 Web 与 Native 的交互,绝大部分 APP 都是通过 WebViewJavascriptBridge(下节介绍)来进行的通信。但是由于 JavascriptCore 的存在,对于 UIWebView 来说只要有效的获取到内部的 JSContext,也可以达到目的。目前已知有效的几个私有方法获取 Context 的方法如下:

1
2
3
4
5
// 通过系统废弃函数获取 context
- (void)webView:(WebView *)webView didCreateJavaScriptContext:(JSContext *)context forFrame:(WebFrame *)frame;

// 通过 valueForKeyPath 获取 context
self.jsContext = [_webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

WKWebView 中提供了系统级的 Web 和 Native 通讯机制,通过 Message Handler 的封装使开发效率有了很大的提升。同时系统封装了 JavaScript 对象和 Objective-C 对象的转换逻辑,也进降低了使用的门槛。

1
2
3
4
5
6
7
8
9
10
11
12
13
// js 端发送消息,postMessage 有且只有一个参数,可传 json(字典)、字符串、数组。如不传参数写 postMessage(null)。{NAME} 为 Message Handler 的 name
window.webkit.messageHandlers.{NAME}.postMessage("xxxx")

// 注册 Message Handler 的 name
[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"{NAME}"];

// Native 在回调中接收
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;

Objective-C 执行 js 代码也很简单:
[webView evaluateJavaScript:@"js 代码" completionHandler:^(id _Nullable result, NSError * _Nullable error) {

}];

拦截自定义 Scheme 请求 - WebViewJavascriptBridge
由于私有方法的稳定性与审核风险,开发者不愿意使用上文提到的 UIWebView 获取 JSContext 的方式进行通信,所以通常都采用基于 iframe 和自定义 Scheme 的 JavascriptBridge 进行通信。虽然在之后的 WKWebView 提供了系统函数,但是大部分 APP 都需要兼容 UIWebViewWKWebView,所以目前的使用范围仍然十分广泛。

在 Github 中类似的开源框架有很多,但是无外乎都是 Web 侧根据固定的格式创建包含通信信息的 Request,之后创建隐式 iFrame 节点请求;Native 侧在相应的 WebView 回调中解析 Request 的 Scheme,之后按照格式解析数据并处理。

而对于数据传递和回调处理的问题,在兼容两种 WebView、持续的更新的 WebViewJavascriptBridge 中,iFrame request 没有直接传递数据,而是 Web 和 Native 侧维护共同的参数或回调 Queue,Native 通过 Request 中 Scheme 的解析触发对 Queue 里数据的读取。

备注:
WKWebView 的缓存模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
NSURLRequestUseProtocolCachePolicy = 0,//默认遵守http缓存策略

NSURLRequestReloadIgnoringLocalCacheData = 1, //忽略本地缓存
NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4, // Unimplemented //忽略本地和远程缓存
NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,

NSURLRequestReturnCacheDataElseLoad = 2,//只有当本地缓存不存在的时候才会请求,否则加载本地缓存
NSURLRequestReturnCacheDataDontLoad = 3,//只加载本地缓存,没有缓存也不会请求

NSURLRequestReloadRevalidatingCacheData = 5, // Unimplemented //判断缓存是否过期
};

二、JavaScriptCore

想用使用 JavaScriptCore 需要先导入头文件 #import <JavaScriptCore/JavaScriptCore.h>。我们先看下 JavaScriptCore.h 中的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import <JavaScriptCore/JSContext.h>
#import <JavaScriptCore/JSValue.h>
#import <JavaScriptCore/JSManagedValue.h>
#import <JavaScriptCore/JSVirtualMachine.h>
#import <JavaScriptCore/JSExport.h>

#endif

#endif /* JavaScriptCore_h */

接下来分别介绍一下这几个文件:

1、JSVirtualMachine

一个 JSVirtualMachine(以下简称 JSVM)实例代表了一个自包含的 JS 运行环境,或者是一系列 JS 运行所需的资源。该类有两个主要的使用用途:一是支持并发的 JS 调用,二是管理 JS 和 Native 之间桥对象的内存。

既然 JavaScriptCore 被认为是一个虚拟机,那 JSVM 又是什么?实际上,JSVM 就是一个抽象的 JS 虚拟机,让开发者可以直接操作。在 APP 中,我们可以运行多个 JSVM 来执行不同的任务。而且每一个 JSContext(下节介绍)都从属于一个 JSVM,一个 JSVM 中可以有多个 JSContext。每个 JSVM 都有自己独立的堆空间,GC 也只能处理 JSVM 内部的对象。所以说,不同的 JSVM 之间是无法传递值(JSValue)的。

在一个 JSVM 中,只有一条线程可以跑 JS 代码,所以我们无法使用 JSVM 进行多线程处理 JS 任务。如果我们需要多线程处理 JS 任务的场景,就需要同时生成多个 JSVM,从而达到多线程处理的目的。

JS 的 GC 机制
JS 同样也不需要我们去手动管理内存。JS 的内存管理使用的是 GC 机制(Tracing Garbage Collection)。不同于 OC 的引用计数,Tracing Garbage Collection 是由 GCRootContext)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:

JavaScriptCore API 都是线程安全的。你可以在任意线程创建 JSValue 或者执行 JS 代码。如果一个线程正在使用 JSVM,所有其他想要使用该 JSVM 的线程都要等待。

2、JSContext

一个 JSContext 表示了一次 JS 的执行环境。我们可以通过创建一个 JSContext 去调用 JS 脚本,访问一些 JS 定义的值和函数,同时也提供了让 JS 访问 Native 对象,方法的接口。

例如:

1
2
3
4
5
JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var a = 1;var b = 2;"];
// evaluateScript: 返回值是 JavaScript 代码中最后一个生成的值
NSInteger sum = [[context evaluateScript:@"a + b"] toInt32];
NSLog(@"sum = %ld",(long)sum);

打印结果 sum = 3

JSContext 中有一个 globalObject,它返回当前执行 JSContext 的全局对象,在 WebKit 中的 JSContext 实例获取 globalObject 将返回对应
WindowProxy 对象,实际上 JS 代码都是在这个 globalObject 上执行的,JSContext 只是 globalObject 的一层壳,为了理解方便,一般可以直接将 JSContext 等价地理解为 globalObject

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface JSContext : NSObject

/*!
@property
@abstract Get the global object of the context.
@discussion This method retrieves the global object of the JavaScript execution context.
Instances of JSContext originating from WebKit will return a reference to the
WindowProxy object.
@result The global object.
*/
@property (readonly, strong) JSValue *globalObject;

@end

在 JS 中,对象就是一个引用类型的实例。与我们熟悉的 OC、Java 不一样,对象并不是一个类的实例,因为在 JS 中并不存在类的概念。ECMA 把对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。从这个定义我们可以发现,JS 中的对象就是无序的键值对,这和 OC 中的 NSDictionary 何其相似。所以,我们可以把 globalObject 转成 NSDictionary

1
2
3
JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var a = 1;var b = 2;"];
NSLog(@"%@",[context.globalObject toDictionary]);

打印结果:

1
2
3
4
{
a = 1;
b = 2;
}

可以看到这个 globalObject 保存了所有的变量(函数也会保存在 globalObject 里)。

另外,我们可以通过 exceptionHandler 回调来监听来自 JS 的异常:

1
2
3
context.exceptionHandler = ^(JSContext *context, JSValue *exception) {
NSLog(@"%@", exception);
};

3、JSValue

JSValue 实例是一个指向 JS 值的引用指针。我们可以使用 JSValue 类,在 OC 和 JS 的基础数据类型之间相互转换。同时我们也可以使用这个类,去创建包装了 Native 自定义类的 JS 对象,或者是那些由 Native 方法或者 Block 提供实现 JS 方法的 JS 对象。

每个 JSValue 实例都来源于一个代表 JavaScript 执行环境的 JSContext 对象,这个执行环境就包含了这个 JSValue 对应的值。每个 JSValue 对象都持有其 JSContext 对象的强引用,只要有任何一个与特定 JSContext 关联的 JSValue 被持有(retain),这个 JSContext 就会一直存活。通过调用 JSValue 的实例方法返回的其他的 JSValue 对象都属于与最始的 JSValue 相同的 JSContext

每个 JSValue 都通过其 JSContext 间接关联了一个特定的代表执行资源基础的 JSVirtualMachine 对象。只能将一个 JSValue 对象传给由相同虚拟机管理的 JSValue 或者 JSContext 的实例方法。如果尝试把一个虚拟机的 JSValue 传给另一个虚拟机,将会触发一个 Objective-C 异常。

4、JSManagedValue

Objective-C 用的是 ARC,不能自动解决循环引用问题,需要开发者手动处理,而 JavaScript 用的是 GC,所有的引用都是强引用,但是垃圾回收器会解决循环引用问题,JavaScriptCore 也一样,一般来说,大多数时候不需要我们去手动管理内存,但是有些情况需要注意:
不要在在一个导出到 JavaScript 的 native 对象中持有 JSValue 对象。因为每个 JSValue 对象都包含了一个 JSContext 对象,这种关系将会导致循环引用,因而可能造成内存泄漏。

1
2
3
4
JSValue *value = [JSValue valueWithObject:@"test" inContext:context];
context[@"block"] = ^(){
NSLog(@"%@", value);
};

通常我们使用 weak 来修饰 block 内需要使用的外部引用以避免循环引用,由于 JSValue 对应的 JS 对象内存由虚拟机进行管理并负责回收,这种方法不能准确地控制 block 内的引用 JSValue 的生命周期,可能在 block 内需要使用 JSValue 的时候,其已经被虚拟机回收。

因为 JSValue 的引用计数为 0,所以早早就被释放了,不能达到我们的预期。

Apple 引入了有条件的强引用:conditional retain,而对应的类就叫 JSManagedValue
一个 JSManagedValue 对象包含了一个 JSValue 对象,“有条件地持有(conditional retain)”的特性使其可以自动管理内存。

最基本的用法就是用来在导入到 JavaScript 的 native 对象中存储 JSValue

1
2
3
4
5
JSValue *value = [JSValue valueWithObject:@"test" inContext:context];
JSManagedValue *managedValue = [JSManagedValue managedValueWithValue:value andOwner:self];
context[@"block"] = ^(){
NSLog(@"%@", [managedValue value]);
};

所谓“有条件地持有(conditional retain)”,是指在以下两种情况任何一个满足的情况下保证其管理的 JSValue 被持有:

  • 可以通过 JavaScript 的对象图找到该 JSValue
  • 可以通过 native 对象图找到该 JSManagedValue

使用 addManagedReference:withOwner: 方法可向虚拟机记录该关系。反之如果以上条件都不满足,JSManagedValue 对象就会将其 value 置为 nil 并释放该 JSValue
upload successful

5、JSExport

实现 JSExport 协议可以开放 OC 类和它们的实例方法、类方法,以及属性给 JS 调用。
而通常情况下,我们如果想在 JS 环境中使用 OC 中的类和对象,需要它们实现 JSExport 协议,来确定暴露给 JS 环境中的属性和方法。比如我们需要向 JS 环境中暴露一个 Person 的类与获取名字的方法:

1
2
3
4
5
6
7
8
9
10
11
12
@protocol PersonProtocol <JSExport>
- (NSString *)fullName; // fullName 用来拼接 firstName 和 lastName,并返回全名
@end

@interface JSExportPerson : NSObject <PersonProtocol>

- (NSString *)sayFullName ;// sayFullName 方法

@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;

@end

然后,我们可以把一个 JSExportPerson 的一个实例传入 JSContext,并且可以直接执行 fullName 方法:

1
2
3
4
5
6
 JSExportPerson *person = [[JSExportPerson alloc] init];
context[@"person"] = person;
person.firstName = @"li";
person.lastName =@"fengfeng";
[context evaluateScript:@"log(person.fullName())"]; //调 Native方 法,打印出 person 实例的全名
[context evaluateScript:@"person.sayFullName())"]; // 提示 TypeError,'person.sayFullName' is undefined

这就是一个很简单的使用 JSExport 的例子,但请注意,我们只能调用在该对象在 JSExport 中开放出去的方法,如果并未开放出去,如上例中的 sayFullName 方法,直接调用则会报 TypeError 错误,因为该方法在 JS 环境中并未被定义。

Tags: 底层