一、WKWebView
1、WebKit
WebKit
是 Safari 浏览器的内核,WebKit
由多个重要模块组成:
WebKit Embedding API(WebKit 嵌入 API)
WebKit 提供给浏览器 UI 调用的接口,例如 iOS 中WebKit
框架提供的WKWebView
。WebCore
HTML 排版引擎,包括 HTML 解析器、CSS 解析器等。JavaScriptCore
JavaScriptCore
是WebKit
默认内嵌的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
开源(之前仅有 WebCore
及 JavaScriptCore
开源)。其中 WebCore 基于 KDE(一个国际性的自由软件社区) 发布的 Konqueror 浏览器项目中的 HTML 排版引擎 KHTML
,JavaScriptCore
基于 Konqueror 中的 JavaScript
引擎 KJS
。
Apple 将 KHTML
发扬光大,起初的时候 Apple 和 KDE 关系很和谐,但是随着时间的推进,WebKit
和 KHTML
之间交换代码变得越来越困难,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 宣布它创建了 WebKit
中 WebCore
组件的分支——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
进程。
(2) WKWebView 的 cookie 问题
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
,如果其属性中sessionOnly
为false
,且设置的过期时间未到达,那我们判断该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 问题UIWebView
对 cookie
是通过 NSHTTPCookieStorage
来统一处理的,服务端响应时写入,UIWebView
发起请求会自动带上 NSHTTPCookieStorage
中的 cookie
,WKWebView
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
,这就是常说的 WKWebView
的 cookie
问题。
iOS 11+ 可以利用 WKHTTPCookieStore
解决这个问题,因为只要是存在 WKHTTPCookieStore
里的 cookie,WKWebView
每次请求都会携带。
1 | @interface WKHTTPCookieStore : NSObject |
iOS 11 以下系统,可以在 WKWebView
loadRequest
前,在 request header 中设置 Cookie
以解决该问题:
1 | WKWebView * webView = [WKWebView new]; |
对于(同域)Ajax、iframe 请求不携带 cookie
问题,可以使用如下方式解决:
1 | WKUserContentController* userContentController = [WKUserContentController new]; |
这种方案无法解决 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) scheme
后 WKWebView
将可以使用 NSURLProtocol
拦截 http(s)
请求:
1 | Class cls = NSClassFromString(@"WKBrowsingContextController”); |
iOS 11 上, WebKit
团队开放了 WKWebView
加载自定义资源的 API WKURLSchemeHandler
:
1 | @implementation ViewController |
注意:
setURLSchemeHandler
注册时机只能在WKWebView
创建WKWebViewConfiguration
时注册。WKWebView
只允许开发者拦截自定义Scheme
的请求,不允许拦截 “http”、“https”、“ftp”、“file” 等的请求,否则会 crash。- 【补充】
WKWebView
加载网页前,要在user-agent
添加个标志,H5 遇到这个标识就使用customScheme
,否则就是用原来的http
或https
一般可以通过这种方式实现 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 的时候HTTPBody
和HTTPBodyStream
这两个字段被丢弃掉了。
因此,如果通过 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 | // 通过系统废弃函数获取 context |
在 WKWebView
中提供了系统级的 Web 和 Native 通讯机制,通过 Message Handler 的封装使开发效率有了很大的提升。同时系统封装了 JavaScript
对象和 Objective-C 对象的转换逻辑,也进降低了使用的门槛。
1 | // js 端发送消息,postMessage 有且只有一个参数,可传 json(字典)、字符串、数组。如不传参数写 postMessage(null)。{NAME} 为 Message Handler 的 name |
拦截自定义 Scheme 请求 - WebViewJavascriptBridge
由于私有方法的稳定性与审核风险,开发者不愿意使用上文提到的 UIWebView
获取 JSContext
的方式进行通信,所以通常都采用基于 iframe 和自定义 Scheme 的 JavascriptBridge
进行通信。虽然在之后的 WKWebView
提供了系统函数,但是大部分 APP 都需要兼容 UIWebView
与 WKWebView
,所以目前的使用范围仍然十分广泛。
在 Github 中类似的开源框架有很多,但是无外乎都是 Web 侧根据固定的格式创建包含通信信息的 Request,之后创建隐式 iFrame 节点请求;Native 侧在相应的 WebView 回调中解析 Request 的 Scheme,之后按照格式解析数据并处理。
而对于数据传递和回调处理的问题,在兼容两种 WebView、持续的更新的 WebViewJavascriptBridge
中,iFrame request 没有直接传递数据,而是 Web 和 Native 侧维护共同的参数或回调 Queue,Native 通过 Request 中 Scheme 的解析触发对 Queue 里数据的读取。
备注:WKWebView
的缓存模式:
1 | typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy) |
二、JavaScriptCore
想用使用 JavaScriptCore
需要先导入头文件 #import <JavaScriptCore/JavaScriptCore.h>
。我们先看下 JavaScriptCore.h 中的内容:
1 |
|
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 是由 GCRoot
(Context
)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:JavaScriptCore
API 都是线程安全的。你可以在任意线程创建 JSValue
或者执行 JS 代码。如果一个线程正在使用 JSVM,所有其他想要使用该 JSVM 的线程都要等待。
2、JSContext
一个 JSContext
表示了一次 JS 的执行环境。我们可以通过创建一个 JSContext
去调用 JS 脚本,访问一些 JS 定义的值和函数,同时也提供了让 JS 访问 Native 对象,方法的接口。
例如:
1 | JSContext *context = [[JSContext alloc] init]; |
打印结果 sum = 3
JSContext
中有一个 globalObject
,它返回当前执行 JSContext
的全局对象,在 WebKit
中的 JSContext
实例获取 globalObject
将返回对应WindowProxy
对象,实际上 JS 代码都是在这个 globalObject
上执行的,JSContext
只是 globalObject
的一层壳,为了理解方便,一般可以直接将 JSContext
等价地理解为 globalObject
。
1 | @interface JSContext : NSObject |
在 JS 中,对象就是一个引用类型的实例。与我们熟悉的 OC、Java 不一样,对象并不是一个类的实例,因为在 JS 中并不存在类的概念。ECMA 把对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。从这个定义我们可以发现,JS 中的对象就是无序的键值对,这和 OC 中的 NSDictionary
何其相似。所以,我们可以把 globalObject
转成 NSDictionary
:
1 | JSContext *context = [[JSContext alloc] init]; |
打印结果:
1 | { |
可以看到这个 globalObject
保存了所有的变量(函数也会保存在 globalObject
里)。
另外,我们可以通过 exceptionHandler
回调来监听来自 JS 的异常:
1 | context.exceptionHandler = ^(JSContext *context, JSValue *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 | JSValue *value = [JSValue valueWithObject:@"test" inContext:context]; |
通常我们使用 weak
来修饰 block
内需要使用的外部引用以避免循环引用,由于 JSValue
对应的 JS 对象内存由虚拟机进行管理并负责回收,这种方法不能准确地控制 block
内的引用 JSValue
的生命周期,可能在 block
内需要使用 JSValue
的时候,其已经被虚拟机回收。
因为 JSValue
的引用计数为 0,所以早早就被释放了,不能达到我们的预期。
Apple 引入了有条件的强引用:conditional retain,而对应的类就叫 JSManagedValue
。
一个 JSManagedValue
对象包含了一个 JSValue
对象,“有条件地持有(conditional retain)”的特性使其可以自动管理内存。
最基本的用法就是用来在导入到 JavaScript
的 native 对象中存储 JSValue
。
1 | JSValue *value = [JSValue valueWithObject:@"test" inContext:context]; |
所谓“有条件地持有(conditional retain)”,是指在以下两种情况任何一个满足的情况下保证其管理的 JSValue
被持有:
- 可以通过
JavaScript
的对象图找到该JSValue
。 - 可以通过 native 对象图找到该
JSManagedValue
。
使用 addManagedReference:withOwner:
方法可向虚拟机记录该关系。反之如果以上条件都不满足,JSManagedValue
对象就会将其 value
置为 nil
并释放该 JSValue
。
5、JSExport
实现 JSExport
协议可以开放 OC 类和它们的实例方法、类方法,以及属性给 JS 调用。
而通常情况下,我们如果想在 JS 环境中使用 OC 中的类和对象,需要它们实现 JSExport
协议,来确定暴露给 JS 环境中的属性和方法。比如我们需要向 JS 环境中暴露一个 Person
的类与获取名字的方法:
1 | @protocol PersonProtocol <JSExport> |
然后,我们可以把一个 JSExportPerson
的一个实例传入 JSContext
,并且可以直接执行 fullName
方法:
1 | JSExportPerson *person = [[JSExportPerson alloc] init]; |
这就是一个很简单的使用 JSExport
的例子,但请注意,我们只能调用在该对象在 JSExport
中开放出去的方法,如果并未开放出去,如上例中的 sayFullName
方法,直接调用则会报 TypeError
错误,因为该方法在 JS 环境中并未被定义。
- 本文章采用 知识共享署名 4.0 国际许可协议 进行许可,完整转载、部分转载、图片转载时均请注明原文链接。