【https://juejin.im/entry/5975916e518825594d23d777】
作者:谢波
本文为原创文章,转载请注明作者及出处
WKWebView 是 iOS 8 之后提供的一款浏览器组件,其载入速度和内存占用对比老的 UIWebView
来说简直是一次飞跃。下面对比 UIWebView
介绍该组件如何去使用,以及使用过程中会存在的问题。
介绍:为什么需要 WKWebView
使用:对比 UIWebView
介绍
深入:JS
和 Native
交互
重点:已知问题和解决方案
总结 & 结束
WKWebView
用 Web
方式承载业务引入 app 的混合开发已成为一个硬性需求,并且随着业务的扩展和审核的限制,该比例会愈加增大,这样对承载的平台就有严格要求,而老旧的 UIWebView
存在严重的性能和内存消耗问题,限制了业务方的自由度。而自 iOS 8 之后,苹果提供的 WebKit 库包含了 WKWebView,WKWebView 采用跨进程方案,Nitro JS 解析器,高达 60fps 的刷新率,理论上性能和 Safari 比肩,而且对 H5 的高度支持,还提供了一个准确的加载进度值属性,我们没有理由拒绝这样的一个新事物。
UIWebView
介绍WKWebView
和 UIWebView
二者在使用上差不多(如下代码所示),如果仅做页面呈现和简单交互,不需要考虑太多的投入成本。 对比代码,二者在创建和使用上基本一致,通过一个 URL 字符串构建 Request 对象,然后使用WebView 内置接口载入 Request 对象即可。
UIWebView *webView = [[UIWebView alloc] init];
NSURL *url = [NSURL URLWithString:@"https://www.hujiang.com"];
[webView loadRequest:[NSURLRequest requestWithURL:url]];
WKWebView *webView = [[WKWebView alloc] init];
NSURL *url = [NSURL URLWithString:@"https://www.hujiang.com"];
[webView loadRequest:[NSURLRequest requestWithURL:url]];
流程上二者有一定的区别,如下图,左边是 UIWebView,右边是WKWebView,在节点上,WKWebView 比 UIWebView 多了一个询问过程,在服务器响应请求之后会询问是否载入内容到当前 Frame,在控制上会比UIWebView 粒度更细一些。
WKWebView 和 UIWebView 差异
以上流程的控制主要是用过 Protocol 去实现,WKWebView
的代理协议为 WKNavigationDelegate
,对比 UIWebDelegate
首先跳转询问,就是载入 URL之前的一次调用,询问开发者是否下载并载入当前 URL,UIWebView 只有一次询问,就是请求之前的询问,而 WKWebView 在 URL 下载完毕之后还会发一次询问,让开发者根据服务器返回的 Web 内容再次做一次确定。
#pragma mark - UIWebViewDelegate
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {return YES;}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { }
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { }
同意载入之后,组件就开始下载指定 URL 的内容,在下载之前会调用一次 开始下载 回调,通知开发者 Web 已经开始下载。
#pragma mark - UIWebViewDelegate
- (void)webViewDidStartLoad:(UIWebView *)webView {}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation { }
- (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation { }
页面下载完毕之后,UIWebView 会直接载入视图并调用载入成功回调,而WKWebView 会发询问,确定下载的内容被允许之后再载入视图。
#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation { }
成功则调用成功回调,整个流程有错误发生都会发出错误回调。
#pragma mark - UIWebViewDelegate
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error { }
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error {}
而除此之外,WKWebView
对比 UIWebView
有较大差异的地方有几点。
1、 WKWebView 多了一个重定向通知,在收到服务器重定向消息并且跳转询问允许之后,会回调重定向方法,这点是 UIWebView 没有的,在 UIWebView之上需要验证是否重定向,得在询问方法验证 head 信息。
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView
didReceiveServerRedirectForProvisionalNavigation:
(null_unspecified WKNavigation *)navigation { }
2、 因为 WKWebView 是跨进程的方案,当 WKWebView 进程退出时,会对主进程做一次方法回调。注:该方法是在 iOS 9 之后才出现,而我们最低支持版本是 iOS 8,所以还得考虑 iOS 8下WKWebView
进程崩溃问题,另外该方法也不是很靠谱,不一定所有崩溃情况都会触发回调,具体的在后面的部分细说。
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macosx(10.11), ios(9.0)) {}
3、 HTTPS 证书自定义处理
- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler { }
注意:证书认证代理不一定百分百会回调,可能是 iOS 系统的 Bug,目前无解,只能不要太过依赖该回调逻辑。
4、 新增 WKUIDelegate
协议,该协议包含一些UI相关的内容。
在 UIWebView
中,Alert、Confirm、Prompt 等视图是直接可执行的,但在WKWebView
上,需要通过 WKUIDelegate
协议接收通知,然后通过 iOS 原生执行。
其次,WKWebView
关闭时的回调通知也在 WKUIDelegate
协议中。注:该方法也是 iOS 9 才有。
另外,对于类似 ‘A’ 标签 ‘target=_blank’ 这种情况,会要求创建一个新的WKWebView 视图,这个消息的通知回调也在该协议中,不过针对 iOS 设备在当前一个视图中显示,该标签点击会没反应,所以在视图载入之后会清除掉所有的 _blank 标记
还有在 iOS 10 之后,新增了链接预览的支持,相关方法也在该协议中
协议相关的就是这些,相对来说还是比较简单的,用来做呈现控制是足够了,更进一步,就需要涉及到和 JS 端的交互问题了,交互就是 Native 通知 JS,JS 通知 Native,基于这个能力拟订具体的交互协议。
JS
和 Native
交互先来看 Native 通知 JS,这个完全依靠 WebView 提供的接口实现,WKWebView 提供的接口和 UIWebView 命名上较为类似,区别是 WKWebView 的这个接口是异步的,而 UIWebView 是同步接口
#pragma mark - UIWebView
NSString *title = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];
#pragma mark - WKWebView
[wkWebView evaluateJavaScript:@"document.title"
completionHandler:^(id _Nullable ret, NSError * _Nullable error) {
NSString *title = ret;
}];
对比 Native 通知 JS,JS 通知 Native 就要复杂许多
JS 调用 Native
在 iOS 6 之前,UIWebView 是不支持共享对象的,Web 端需要通知 Native,需要通过修改 location.url,利用跳转询问协议来间接实现,通过定义 URL 元素组成来规范协议
在 iOS 7 之后新增了 JavaScriptCore
库,内部有一个 JSContext
对象,可实现共享
而 WKWebView
上,Web 的 window 对象提供 WebKit 对象实现共享
而 WKWebView 绑定共享对象,是通过特定的构造方法实现,参考代码,通过指定 UserContentController 对象的 ScriptMessageHandler 经过 Configuration 参数构造时传入
WKUserContentController *userContent = [[WKUserContentController alloc] init];
[userContent addScriptMessageHandler:id name:@"MyNative"];
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
config.userContentController = userContent;
WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];
而 handler 对象需要实现指定协议,实现指定的协议方法,当 JS 端通过 window.webkit.messageHandlers 发送 Native 消息时,handler 对象的协议方法被调用,通过协议方法的相关参数传值
#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {}
// Javascript
function callNative() {
window.webkit.messageHandlers.MyNative.postMessage('body');
}
至此,WKWebView 的呈现和交互就已经说完了,如果没有太高的要求,这些完全可以做简单的呈现交互操作,实现一个轻度页面。
但,我们的目标肯定不仅于此,于是,重点话题来了。
WKWebView 是一个新组件,并且是采用跨进程方案,实现了比较好的性能和体验,那同样的,肯定会带来一些问题
在说问题之前,我们先看一下接入 WKWebView
之后的内存结构
接入内存结构
为什么 app 接入 WKWebView 之后,相对比 UIWebView 内存占用小那么多,是因为网页的载入和渲染这些耗内存和性能的过程都是由 WKWebView
进程去实现的,相对来说 app 仅占很小一部分内存,甚至因为 Web 进程内存的膨胀,触发 app 的内存警告,导致 app 内存占用还会下跌。
但 Web 进程也是会崩溃的,能致 UIWebView
内存爆掉的页面在 WKWebView
上也可能导致 Web 进程崩溃,只是这个上限值相对比起来会高很多,而且崩溃体现到 app 也没那么严重,具体结果可能就是白屏、载入失败之类等。
根据问题的特性,我们将之大体分为三类。首先是最关键的 Cookie
问题;其次是使用上的一些功能性问题,需要针对适配的问题;还有就是前端适配的页面问题。针对这三类问题我们一一说。
对于 Cookie 问题,如下图,这张图是正常的登录 Cookie 认证流程,用户发起登录请求,后台登录之后,对浏览器写入 Cookie,该 Cookie 会写入磁盘,在后续请求发起时会带上该 Cookie,后台通过验证该 Cookie 来认证身份,确定用户已登录
Web 流程
在 WKWebView
上,最大的问题就在于 Cookie 写入这里。 UIWebView
的Cookie是通过 NSHTTPCookieStorage
统一管理,服务器返回时写入,发起请求时读取,Web 和 Native 通过该对象能共享 Cookie
而在 WKWebView
上,NSHTTPCookieStorage
不再是一个必经的流程节点,虽然 WKWebView
同样会对该对象写入Cookie,但并不是实时的,而且针对不同的系统版本延迟还不一样,另外发起请求时也不会实时去读取 Cookie。 这就导致每次 app 每次重启 Cookie 都会丢失,无法做到会话和Native同步
针对这个问题,我们做了一些尝试。
既然 WKWebView 无法实时写入磁盘,那我们手动干涉这个过程,主动读取Cookie 写入磁盘,然后载入时再绑定 Cookie 是否可行?整个操作的流程如图所示,在登录之后,获取服务器返回的 Cookie 值,持久化到本地,在下次 WebView 初始化时,读取本地的缓存值手动设置 Cookie
读取 Cookie 值写入 NSHttpCookieStorage
Web 的请求分三类:Location 跳转、AJAX 请求、资源载入
对于 Locaiton 跳转,会走 Web 组件的 Navigation 代理流程,我们可以通过拦截相应回调方法
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
{
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)navigationResponse.response;
id cookies = [httpResponse.allHeaderFields objectForKey:@“Set-Cookie”];
if(cookies) {
// Cookie 写入 NSHttpCookieStorage
}
decisionHandler(WKNavigationResponsePolicyAllow);
}
而 AJAX 和资源载入请求不会走协议代理方法。前者因为 W3C 标准影响,无法通过 JS 去读取 Set-Cookie 的值,直接读取 SESSIONID 也因为HTTPOnly 问题无法读取;而后者更是直接的无法拦截。总的来说,只能获取 Location 跳转请求的 Cookie 信息,如果登录操作由 AJAX 实现,那无法实现。
读取 NSHttpCookieStorage
写入 Request
而对 Request 的 Cookie 写入也分两部分,一个是当前 Request,二个是子元素的 Request
当前 Request 可以通过设置 Request 对象的 Header 去绑定 Cookie,但这个值只在当前请求中有效
NSURL *url = [NSURL URLWithString:@"https://www.hujiang.com"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
[request addValue:@"key0=value0" forHTTPHeaderField:@"Cookie"];
[wkWebView loadRequest:request];
注意:Request 信息是通过 IPC 进程通信传递给 WKWebView,因为HTTPBody 和 HTTPBodyStream 信息可能比较大,在传递过程中被舍弃了,也就说发起请求的 Request 不能设置 body 值
request
如果页面包含多 Frame ,出现 302 重定向时,Cookie 无法跨域设置,针对 MainFrame 还可以通过 Delegate 的请求询问方法中手动载入, 但非MainFrame 就没办法了
而子资源请求的 Cookie 问题,同样的因为 W3C 限制,JS 无法对 Cookie和标记为 HTTPOnly 的值进行写入,子资源无法设置 Cookie
方案一总结:通过 WKWebView 提供的标准接口来实现 Cookie 同步,无论是 Request 还是 Response 都有很大的限制,无法实际去运用。
我们知道可以通过注册 NSURLProtocol
来代理全局的 HTTP 和 HTTPS 请求,在 UIWebView 中,通过该方案能拦截到所有的请求,包含 Location、AJAX、资源载入等,而 WKWebView
的载入是在单独进程中实现,默认的请求是不走该协议的,那么有没有办法让它遵循该协议呢? 答案是可以,通过私有 API
WKWebView
包含一个 browsingContextController
属性对象,该对象提供了 registerSchemeForCustomProtocol
和 unregisterSchemeForCustomProtocol
两个方法,能通过注册 scheme
来代理同类请求,符合注册 scheme
类型的请求会走 NSURLProtocol
协议
但毕竟是私有 API,存在被拒的风险,就算通过一定手段避开了苹果的审查,私有 API 在后续 iOS 的升级中也随时可能被废弃,存在一定的风险
总结来说,在 WKWebView Cookie 无法完美共享的前提下,登录会话无法通过依赖 Cookie 来实现
抛弃对 Cookie 的依赖,可选的方案就比较有限了,比如 OAuth2,OAuth2 授权认证系统能很好的解决身份认证问题,并且支持多点,而且成熟
OAuth2
如图,Web 第一次发起请求肯定是未授权状态,服务端验证失败,返回重定向登录,WKWebView 通过监听指定 URL (如这里的重定向地址),调用 app 内置的 OAuth2 认证服务,提示用户手动授权,app 得到授权后向第三方 HJ 认证服务器获取认证,得到认证 token,序列化到本地,同时写入 WKWebView Cookie,信息部署完毕之后,WKWebView 带上认证 token 重新发起请求,服务器通过认证服务器验证通过,返回正常服务
针对现有的问题和背景,在不改变现有结构的情况下接入 WKWebView 是不可能的,而以 OAuth2 方案来执行的话,需要各客户端实现认证授权系统,服务端新增认证服务中心,web 端服务得支持授权验证
针对 Cookie 的问题就到这里,Cookie 问题确实是 WKWebView 最头疼的问题,对全局架构的影响也是大家选择它时重点考评的点
除去 Cookie 问题之外,其它零零碎碎也比较多,但都还算好,遵循相关的规范都能处理掉这里我列出了收集的一些其它比较容易遇到的问题:
1、WKWebView 进程崩溃引发的问题
WKWebView 进程崩溃,在 app 内的效果就是白屏,我们要做的就是在得知白屏时重新载入 Request,iOS 9 下有 Delegate 方法能收到崩溃的回调,但在打开相册或拍照比较耗内存的情况下,WKWebView 崩溃 Delegate 方法却不会被调用,同时我们支持的最低版本是 iOS 8,而在 iOS 8 下,可以通过校验webView.title 属性是否为空来确定,title 属性是 WKWebView 内置属性,自动读取 document.title 值,而在进程崩溃的情况下,该值为空
2、WKWebView 视图尺寸变化对页面的影响
WKWebView 也是通过 ScrollView 实现,设置 contentInset 等相关偏移值会映射到 Web 页面,导致页面的长度增加
其次 WKWebView 的页面渲染与 JS 执行同步进行的,可能你 JS 执行时布局渲染并未完成,所以不管是 JS 还是 Native,在页面载入完成之后就获取innerHeight 或者 contentSize 都是不准确的,要么通过延迟获取,要么监听属性值变化,实时修正获取的值
3、默认的跳转行为,打开 iTuns、tel、mail、open 等
在 UIWebView 上,如果超链接设置未 tel://00-0000 之类的值,点击会直接拨打电话,但在 WKWebView 上,该点击没有反应,类似的都被屏蔽了,通过打开浏览器跳转 AppStore 已然无法实现
这类情况只能在跳转询问中处理,校验 scheme 值通过 UIApplication 外部打开
4、 下载链接无法处理
下载链接在 UIWebView 上其实也是需要特殊处理,在服务器响应询问中校验流类型即可
5、跨域问题
HTTPS 对 HTTPS、HTTP 对 HTTP 跨域默认是能载入的,但如果是 HTTP 想载入 HTTPS 跨域链接,因为安全考虑,WKWebView 会被拦截,这问题在引入跨域 HTTPS 的页面也做 HTTPS。我们 HJ 已经切换了 HTTPS,所以不存在该问题
6、NSURLProtocol 问题
UIWebView 是通过 NSURLConneciton 处理的 HTTP 请求,而通过Conneciton 发出的请求都会遵循 NSURLProtocol 协议,通过这个特性,我们可以代理 Web 资源的下载,做统一的缓存管理或资源管理
但在 WKWebView 上这个不可行了,因为 WKWebView 的载入在单独进程中进行,数据载入 app 无法干涉
7、缓存问题
WKWebView 内部默认使用一套缓存机制,开发能控制的权限很有限,特别是在 iOS 8 下,根本没方式去操作,对于静态资源的更新,客户端经常出现读取缓存不更新的情况。
针对这个问题,如果仅仅是单个资源如此,并且其它缓存比较有用,那对该资源地址加时间戳避开缓存
如果全局都是如此,这需要手动的去清理缓存,iOS 9 之后,系统提供了缓存管理接口 WKWebsiteDataStore
// RemoveCache
NSSet *websiteTypes = [NSSet setWithArray:@[
WKWebsiteDataTypeDiskCache,
WKWebsiteDataTypeMemoryCache]];
NSDate *date = [NSDate dateWithTimeIntervalSince1970:0];
[[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteTypes
modifiedSince:date
completionHandler:^{
}];
而 iOS 9 之前,就只能通过删除文件来解决了,WKWebView 的缓存数据会存储在 ~/Library/Caches/BundleID/WebKit/ 目录下,可通过删除该目录来实现清理缓存
8、其它问题
还有一些零碎的小问题,比如通过写入 NSUserDefaults 来统一修改UserAgent;第三方库可能修改 Delegate 引起问题等等就不一一例举了,通过上述的问题,主要想表明出现问题的解决思路,只要不断去尝试,这些都不是阻碍。
功能性的问题比较典型的大多在 iOS 更新中都会完善,相信随着最低支持版本的提高,问题会越来越少
最后,还有一些可能会对前端同学造成影响的因素,我这里列举了几条
1、页面退回上一页不会重新执行 Script 脚本,也不会触发 reload 事件 这个是因为 WKWebView 的页面管理和缓存机制导致的
2、页面键盘弹出会触发 resize 事件
3、window.unload 只有刷新页面才会触发,退出或跳转到其它页都无法触发
根据这几条问题来看,新组件的适配主要集中在流程周期上,前端同学适配新组件过程中,可以针对周期的依赖做特殊测试
WKWebView 虽然很新,有许多问题需要去解决,但它有着新技术带来的种种诱惑,随着 iOS 系统版本的不断迭代升级,问题会越来越少,功能也会越来越多,而且有较多的一线团队已经做了切换,证明业务承载完全没问题。
我相信,它就像 HTTPS 一样,以后完全有可能成为 iOS 平台下主流浏览器。 谢谢。
End