WKWebView 实战篇


前两篇文章简单学习了 WKWebView 的基础内容和几个协议,今天我们看看在使用中常见的问题。

一、Cookie 同步

以往通过 AFNetworking、NSURLSession、UIWebView 等方式得到的 cookie,统统放在 NSHTTPCookieStorage 中,一般情况下是不需要我们特别处理的。但对于 WKWebView 我们说过,改为放在 WKHTTPCookieStore 中,而且两者是不互通的。举个例子,对于现在很多 原生 + h5 混合开发的 App 来说,通常在登录成功以后,WebView 访问页面时会希望携带会话信息直接通过服务端的认证,而不是在 WebView 中再登录一次。这个时候我们可能就需要同步一下两个 Storage 中的 cookie。

WKWebView 基础篇 - WKProcessPool 中,我们留了一个疑问,给不同的 WKWebView 指定不同的 WKProcessPool,他们的 cookie 能否自动同步呢?跟上面的问题一起测试一下:

  1. 使用两个 UIWebView 和 UIWebView-1、两个 WKWebView 和 WKWebView -1,访问同样的页面。

例如谷歌账号的个人信息页面 https://myaccount.google.com/personal-info ,没有登录而直接访问这个页面的话,会重定向到登录页面。同时,两个 WKWebView 我们指定不同的 WKProcessPool 。

  1. viewWillAppear 中让 WebView 刷新
1
2
3
4
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.webView reload];
}
  1. 选择任意一个页面进行登录,例如第一个 UIWebView 。

当第一个 UIWebView 登录成功以后,我们切换页签刷新其他三个页面,会发现 UIWebView-1 可以成功访问 personal-info 页面,而两个 WKWebView 依旧是登录页面。同样,如果选择一个 WKWebView 进行登录结果也是一样的。所以,不同的 UIWebView 可以共享 NSHTTPCookieStorage 中的 cookie;不同的 WKWebView 、不同的 WKProcessPool 也可以共享 WKHTTPCookieStore 中的 cookie。当然它们二者是不互通的。

  1. 一个简单的 cookie 同步方案

Cookie 同步方案目前有缺陷,不建议这种方式.2022.11.25

  • NSHTTPCookieStorage 向 WKHTTPCookieStore 同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (@available(iOS 11.0, *)) {
NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
if (cookies.count == 0) {
return;
}
for (NSHTTPCookie *cookie in cookies) {
[cookieStroe setCookie:cookie completionHandler:^{
if ([[cookies lastObject] isEqual:cookie]) {
//Sync end
}
}];
}
} else {
// Fallback on earlier versions
}
  • WKHTTPCookieStore 向 NSHTTPCookieStorage 同步,使用 WKHTTPCookieStoreObserver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@interface WKWebViewController () <WKHTTPCookieStoreObserver>
@end

...

[configuration.websiteDataStore.httpCookieStore addObserver:self];

...

#pragma mark - WKHTTPCookieStoreObserver
- (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore {
[cookieStore getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
for (NSHTTPCookie *cookie in cookies) {
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
}
}];
}

这样实现的一个缺点是 cookie 污染,因为不管三七二十八都同步到一起了。可以做一个简单的筛选?只同步访问目标资源需要的 cookie ?欢迎讨论~

二、跨域

关于跨域我接触的也不是很多,这篇 什么是跨域请求以及实现跨域的方案 我觉得写的很清楚。iOS 开发中常遇到的跨域问题有两种:无法访问本地 HTML 资源;跨域存取 Cookie 问题。

  • 对于 无法访问本地 HTML 资源 的情况,修改下面的属性。
1
2
3
4
[configuration.preferences setValue:@YES forKey:@"allowFileAccessFromFileURLs"];
if (@available(iOS 10.0, *)) {
[configuration setValue:@YES forKey:@"allowUniversalAccessFromFileURLs"];
}
  • 对于 跨域存取 Cookie 问题

读了一下这篇博客,算是作为一个参考思路吧。WKWebView跨域的Cookie问题

三、Native 与 JS 的交互

JS 调用 Native

WKWebView 基础篇 - WKUserContentController 提到过了,通过消息处理器 addScriptMessageHandler 注册一个唯一的 name ,并且实现 WKScriptMessageHandler 协议。 示例:

1
2
//js 测
window.webkit.messageHandlers.YYWK.postMessage(['MPWebView', 'close', []]);
1
2
3
4
5
6
//native 测
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"YYWK"]) {
//Call MPWebView's selector - close.
}
}

Native 调用 JS

1
2
3
4
5
//js 已声明了一个方法
function helloWorld(message) {
console.log(message);
return 'YES';
}
1
2
3
4
//native 测
[self.webView evaluateJavaScript:@"helloWorld('Are you kidding me?')" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
NSLog(@"%@", result); //Will be 'YES'.
}];

当然,如果调用的 JS 方法不存在,result 会是 nil。

JS 调用 Native 并且得到执行结果

我们可以看到,Native 调用 JS 时,苹果提供了 completionHandler 来获得执行结果;但是 JS 通过 postMessage 调用 Native 时,我们是没有办法将 Native 的执行结果同步给 JS 的。苹果应该也注意到了这个问题,所以在 iOS14 中提供了一个新的解决方案,让我们一起康康:

1. iOS14 新增

WKScriptMessageHandlerWithReply

iOS14.0 新增的协议,同样是 iOS 与 JavaScript 做交互的协议。不过与 WKScriptMessageHandler 相比,多了一个可以向 JS 发送响应结果的处理器,而且还是异步的。是不是用起来很爽?🤓

示例:

1
2
3
4
5
6
7
8
9
10
11
12
//js 测使用promise异步回调获取结果。
function scriptMessageWithReply() {
let promise = window.webkit.messageHandlers.YYWK.postMessage("Fulfill me with 42");
promise.then(
function(result) {
alert('result' + result);
},
function(error) {
alert('error' + error);
}
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//native 测
{
WKUserContentController *userContentController = [[WKUserContentController alloc] init];
[userContentController addScriptMessageHandlerWithReply:self contentWorld:[WKContentWorld pageWorld] name:@"YYWK"];
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = userContentController;
...
}

...

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message replyHandler:(void (^)(id _Nullable reply, NSString *_Nullable errorMessage))replyHandler {
if ([message.body isEqual:@"Fulfill me with 42"])
replyHandler(@42, nil);
else
replyHandler(nil, @"Unexpected message received");
}

2. 基于 prompt 的实现

WKWebView 协议篇 - WKUIDelegate 中我们提到过关于 Native 实现 JS prompt 函数的操作。JS 会触发一个带输入框的 Alert,等用户输入了信息之后,Native 会将结果异步返回到 JS。所以我们是不是可以利用这个异步时机呢?这个时候 prompt 的参数就不是普通的字符串了,而是作为一个指令。示例:

1
2
3
4
5
function getUserMessage() {
var msg = prompt("GetUserMessage", "YYLittleCat");
//Use YYLittleCat's msg.
......
}

Native 的处理就改为:

1
2
3
4
5
6
7
//native 接收到的prompt: 指令 = 获取用户的信息; defaultText: uid = YYLittleCat
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler {
if ([prompt isEqualToString:@"GetUserMessage"]) {
//Did get user message.
completionHandler(@"A json object, Like dictionary to string.");
}
}

四、HTTPS 单、双向认证

以我们当前博客站点儿为例,SSL 证书是向”正经“机构申请的,Nginx 配置 HTTPS,并且 HTTP 请求自动跳转 HTTPS 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
listen 443 ssl;
server_name tommygirl.cn;
ssl_certificate certs/server.crt;#公钥
ssl_certificate_key certs/server.key;#私钥
...
}

server {
listen 80;
listen [::]:80;
server_name tommygirl.cn;
return 301 https://tommygirl.cn;
}
...

更多关于 Nginx 配置 HTTPS 单、双向认证的内容 Here

单向认证

显然通过浏览器访问 tommygirl.cn 是可以成功的,并且地址栏有一个小锁头🔒,所以 HTTPS 我们配置成功了。或者说单向认证已经没问题了。那有的同学可能会问,单向认证?谁?哪里认证的?我没有认证呀?我们对照着一个流程图看一下一个完整的 HTTPS 请求都经历了哪些过程:

img

  1. 客户端访问 https://tommygirl.cn
  2. 服务器端将本机的公钥证书 server.crt 发送给客户端;
  3. 客户端读取公钥证书 server.crt ,取出了服务端公钥;
  4. 客户端生成一个随机数(密钥 R),用刚才得到的服务器公钥去加密这个随机数形成密文,发送给服务端;
  5. 服务端用自己的私钥 server.key 去解密这个密文,得到了密钥 R;
  6. 服务端和客户端在后续通讯过程中就使用这个密钥 R 进行通信了。

所以单向认证是在哪一步完成的?第3步。那浏览器怎么知道应该信任我们的 SSL 证书呢?受信任的根证书,其任何下级证书都是受信任的。根证书在哪里呢?以 Mac 为例,打开钥匙串可以看到有一项是 系统根证书,也就是说系统会内置一部分根证书,浏览器在拿到我们的 SSL 证书后,它使用里面的公钥来验证签名并在证书链上向上移动一层;重复这个过程:对签名进行身份验证,并跟踪签名的证书链,直到最终到达浏览器信任存储中的一个根证书。如果它不能将证书链回到其受信任的根,它就不会信任该证书。(关于证书链的讨论,是一个比较大的话题,可以先参考 证书链 ,这里不再赘述。)

双向认证

客户端校验服务端的证书可靠性称为单向认证,那顾名思义,双向认证中服务端也需要校验客户端的合法性。为了不影响页面的正常访问,新起了一个 ssl.tommygirl.cn,Nginx 上的测试配置:

1
2
3
4
5
6
7
8
9
server {
listen 443 ssl;
server_name ssl.tommygirl.cn;
ssl_certificate certs/server.crt;
ssl_certificate_key certs/server.key;
ssl_client_certificate certs/client.crt;
ssl_verify_client on;
...
}

现在访问 https://ssl.tommygirl.cn 会收到 Nginx 的错误提示,因为我们没有发送客户端的证书:

继续说,一个基于双向认证的请求交互过程:

img

  1. 客户端访问 https://ssl.tommygirl.cn
  2. 服务端返回 server.crt;
  3. 客户端校验 crt 文件中的证书颁发机构、证书时效、公钥信息等等;
  4. 客户端将客户端公钥证书 client.crt 发送给服务器端;
  5. 服务器端解密客户端公钥证书,拿到客户端公钥;
  6. 客户端发送自己支持的加密方案给服务器端;
  7. 服务器端根据自己和客户端的能力,选择一个双方都能接受的加密方案,使用客户端的公钥加密后发送给客户端;
  8. 客户端使用自己的私钥解密加密方案,生成一个随机数 R,使用服务器公钥加密后传给服务器端;
  9. 服务端用自己的私钥去解密这个密文,得到了密钥 R;
  10. 服务端和客户端在后续通讯过程中就使用这个密钥 R 进行通信了。

手机端的处理

简单的单向认证,手机端也是不用特别处理的;以往在 UIWebView 中如果想实现双向认证,需要自己定义 NSURLProtocol 做网络拦截,并且实现 NSURLSessionDelegate 协议方法进行处理。但对于 WKWebView,Bingo~苹果提供了单独的方法供开发者实现。

相关协议: WKNavigationDelegate

供参考的实现如下,细节看项目需求优化吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
NSLog(@"%s",__FUNCTION__);
NSString *authenticationMethod = [[challenge protectionSpace] authenticationMethod];
if ([authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
//校验服务端证书
SecTrustRef secTrustRef = challenge.protectionSpace.serverTrust;
if (secTrustRef != NULL) {
SecTrustResultType result;
OSErr er = SecTrustEvaluate(secTrustRef, &result);
if (er != noErr){
NSLog(@"error");
}

switch (result) {
case kSecTrustResultProceed:
NSLog(@"kSecTrustResultProceed");
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
break;
case kSecTrustResultUnspecified:
NSLog(@"kSecTrustResultUnspecified");
completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:secTrustRef]);
break;
case kSecTrustResultRecoverableTrustFailure:
NSLog(@"kSecTrustResultRecoverableTrustFailure");
completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:secTrustRef]);
break;
default:
break;
}
}
}else if ([authenticationMethod isEqualToString:NSURLAuthenticationMethodClientCertificate]) {
//发送客户端证书
SecIdentityRef identity = NULL;
SecTrustRef trust = NULL;
NSString *p12 = [[NSBundle mainBundle] pathForResource:@"client"ofType:@"p12"];
NSFileManager *fileManager = [NSFileManager defaultManager];

if(![fileManager fileExistsAtPath:p12]) {
NSLog(@"client.p12: Not exist.");
} else {
NSData *PKCS12Data = [NSData dataWithContentsOfFile:p12];
if ([self _extractIdentity:&identity andTrust:&trust fromPKCS12Data:PKCS12Data]) {
SecCertificateRef certificate = NULL;
SecIdentityCopyCertificate(identity, &certificate);
const void*certs[] = {certificate};
CFArrayRef certArray = CFArrayCreate(kCFAllocatorDefault, certs,1,NULL);
NSURLCredential *credential = [NSURLCredential credentialWithIdentity:identity certificates:(__bridge NSArray*)certArray persistence:NSURLCredentialPersistencePermanent];
NSURLSessionAuthChallengeDisposition disposition =NSURLSessionAuthChallengeUseCredential;
completionHandler(disposition, credential);
}
}
}else {
NSLog(@"else");
}
}

- (BOOL)_extractIdentity:(SecIdentityRef*)outIdentity andTrust:(SecTrustRef *)outTrust fromPKCS12Data:(NSData *)inPKCS12Data {
OSStatus securityError = errSecSuccess;
//client certificate password
NSDictionary *optionsDictionary = [NSDictionary dictionaryWithObject:@"123456"
forKey:(__bridge id)kSecImportExportPassphrase];

CFArrayRef items = CFArrayCreate(NULL, 0, 0, NULL);
securityError = SecPKCS12Import((__bridge CFDataRef)inPKCS12Data,(__bridge CFDictionaryRef)optionsDictionary,&items);

if(securityError == 0) {
CFDictionaryRef myIdentityAndTrust =CFArrayGetValueAtIndex(items,0);
const void*tempIdentity =NULL;
tempIdentity= CFDictionaryGetValue (myIdentityAndTrust,kSecImportItemIdentity);
*outIdentity = (SecIdentityRef)tempIdentity;
const void*tempTrust =NULL;
tempTrust = CFDictionaryGetValue(myIdentityAndTrust,kSecImportItemTrust);
*outTrust = (SecTrustRef)tempTrust;
} else {
NSLog(@"Failedwith error code %d",(int)securityError);
return NO;
}
return YES;
}

PS:对于门户型的网站,同一套服务,想全部做到双向认证似乎不是很现实,客户端的证书一般也不会分发给每一个人。很多人为了网络安全考虑,常用的一个做法是防抓包:在单向认证中加入自己的校验规则-域名比对、CA信息比对、客户端内置 sever.crt 证书链校验等等。对于这种情况,只针对 NSURLAuthenticationMethodServerTrust 进行处理就行了。

五、window.open()

JS 与 Native 最常见的交互就是 window.open(); ,用于打开一个新窗口。更多细节 。常用的写法:

1
2
3
4
5
6
//在一个新窗口中打开链接
window.open('https://tommygirl.cn'); //默认就是_blank。
window.open('https://tommygirl.cn', '_blank');

//在当前窗口打开链接
window.open('https://tommygirl.cn', '_self');

有的前端人员基于 Cordova 或者 Ionic 这些框架开发久了,会习惯要求 native 支持 window.open('', '_system'); ,即用系统浏览器打开链接,但其实标准的 JS 中是没有 _system 参数的,只是 Cordova 框架内部提供了支持而已。所以在单纯的 WebView 使用中有没有问题呢?当然有问题……🙄

说回 WKWebView ,会发现对于 _blank 类型没有响应,但是 _self 可以打开。这是因为对于新窗口的弹出,苹果独立出了一个协议来让 native 自己实现:WKUIDelegate ,👈上一篇我们提到过了,不再赘述。

六、还没写完,累了,明天再说。


关于 WKWebView 的几篇文章:

WKWebView 基础篇
WKWebView 协议篇
WKWebView 实战篇
WKWebView Cookie 试错
WKWebView - WKScriptMessageHandler 循环引用

Demo

WebView 的 Demo