前两篇文章简单学习了 WKWebView 的基础内容和几个协议,今天我们看看在使用中常见的问题。
一、Cookie 同步
以往通过 AFNetworking、NSURLSession、UIWebView 等方式得到的 cookie,统统放在 NSHTTPCookieStorage 中,一般情况下是不需要我们特别处理的。但对于 WKWebView 我们说过,改为放在 WKHTTPCookieStore 中,而且两者是不互通的。举个例子,对于现在很多 原生 + h5 混合开发的 App 来说,通常在登录成功以后,WebView 访问页面时会希望携带会话信息直接通过服务端的认证,而不是在 WebView 中再登录一次。这个时候我们可能就需要同步一下两个 Storage 中的 cookie。
在 WKWebView 基础篇 - WKProcessPool 中,我们留了一个疑问,给不同的 WKWebView 指定不同的 WKProcessPool,他们的 cookie 能否自动同步呢?跟上面的问题一起测试一下:
- 使用两个 UIWebView 和 UIWebView-1、两个 WKWebView 和 WKWebView -1,访问同样的页面。
例如谷歌账号的个人信息页面 https://myaccount.google.com/personal-info
,没有登录而直接访问这个页面的话,会重定向到登录页面。同时,两个 WKWebView 我们指定不同的 WKProcessPool 。
- 在
viewWillAppear
中让 WebView 刷新
1 2 3 4
| - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self.webView reload]; }
|
- 选择任意一个页面进行登录,例如第一个 UIWebView 。
当第一个 UIWebView 登录成功以后,我们切换页签刷新其他三个页面,会发现 UIWebView-1 可以成功访问 personal-info 页面,而两个 WKWebView 依旧是登录页面。同样,如果选择一个 WKWebView 进行登录结果也是一样的。所以,不同的 UIWebView 可以共享 NSHTTPCookieStorage 中的 cookie;不同的 WKWebView 、不同的 WKProcessPool 也可以共享 WKHTTPCookieStore 中的 cookie。当然它们二者是不互通的。
- 一个简单的 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]) { } }]; } } else { }
|
- 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"]; }
|
读了一下这篇博客,算是作为一个参考思路吧。WKWebView跨域的Cookie问题 。
三、Native 与 JS 的交互
JS 调用 Native
在 WKWebView 基础篇 - WKUserContentController
提到过了,通过消息处理器 addScriptMessageHandler 注册一个唯一的 name ,并且实现 WKScriptMessageHandler 协议。 示例:
1 2
| window.webkit.messageHandlers.YYWK.postMessage(['MPWebView', 'close', []]);
|
1 2 3 4 5 6
| - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name isEqualToString:@"YYWK"]) { } }
|
Native 调用 JS
1 2 3 4 5
| function helloWorld(message) { console.log(message); return 'YES'; }
|
1 2 3 4
| [self.webView evaluateJavaScript:@"helloWorld('Are you kidding me?')" completionHandler:^(id _Nullable result, NSError * _Nullable error) { NSLog(@"%@", result); }];
|
当然,如果调用的 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
| 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
| { 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"); ...... }
|
Native 的处理就改为:
1 2 3 4 5 6 7
| - (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler { if ([prompt isEqualToString:@"GetUserMessage"]) { 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 请求都经历了哪些过程:
- 客户端访问 https://tommygirl.cn ;
- 服务器端将本机的公钥证书 server.crt 发送给客户端;
- 客户端读取公钥证书 server.crt ,取出了服务端公钥;
- 客户端生成一个随机数(密钥 R),用刚才得到的服务器公钥去加密这个随机数形成密文,发送给服务端;
- 服务端用自己的私钥 server.key 去解密这个密文,得到了密钥 R;
- 服务端和客户端在后续通讯过程中就使用这个密钥 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 的错误提示,因为我们没有发送客户端的证书:
继续说,一个基于双向认证的请求交互过程:
- 客户端访问 https://ssl.tommygirl.cn ;
- 服务端返回 server.crt;
- 客户端校验 crt 文件中的证书颁发机构、证书时效、公钥信息等等;
- 客户端将客户端公钥证书 client.crt 发送给服务器端;
- 服务器端解密客户端公钥证书,拿到客户端公钥;
- 客户端发送自己支持的加密方案给服务器端;
- 服务器端根据自己和客户端的能力,选择一个双方都能接受的加密方案,使用客户端的公钥加密后发送给客户端;
- 客户端使用自己的私钥解密加密方案,生成一个随机数 R,使用服务器公钥加密后传给服务器端;
- 服务端用自己的私钥去解密这个密文,得到了密钥 R;
- 服务端和客户端在后续通讯过程中就使用这个密钥 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; 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'); 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