有了UIWebView,为什么还需要WKWebView?
UIWebVieW的缺点: 笨重难用、内存泄露、内存消耗大,性能差 —— WKWebView提高性能
WKWebView 拥有60fps滚动刷新率和safari相同的js引擎等优势。
原生和Web的交互
JS调用OC
方法一:
1、动态注入JS方法
//在OC中添加一个scriptMessageHandler,添加处理消息,
//self指代的对象需要遵守WKScripteMessageHandler协议,结束时候需要移除
[userContentController addScriptMessageHandler:self name:@"share"];
2、当JS中调用share方法时候
windnow.webkit.messageHandlers.share.postMessage("参数")
在OC中会收到WKScriptMessageHandler的回调
#pragma mark -- WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
if ([message.name isEqualToString:@"share"]) {
NSLog(@"message.body=%@", message.body);
}
}
方法二:
通过url的scheme来进行判断 ,然后再下面的代理方法中处理
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
// 通过定义的scheme来进行处理
decisionHandler(WKNavigationActionPolicyCancel);
}
方法三
/**
* JS 调用 OC ,关闭当前 H5 控制器
*/
[_bridge registerHandler:@"_app_closeWebView" handler:^(id data, WVJBResponseCallback responseCallback) {
[weakSelf closeTheView];
responseCallback(@"OK,已关闭当前 WebView ");
}];
/**
* OC 调用 JS ,获取 OC 的值
*/
[_bridge callHandler:@"_app_getToken" data:@"userToken"];
js写的桥接文件
/**
* 使用 WebViewJavascriptBridge 实现 OC 与 JS 交互
*/
- (void)setUpWebViewJavascriptBridge {
/**
JS 调用 OC ,设置导航条 title
@param data 后台 JS 页面传过来的参数
@param registerHandler 要注册的事件名称(这里我们为 locationAlertViewWithMessage)
@param handler 回调 block 函数 当后台触发这个事件的时候会执行 block 里面的代码
*/
[_bridge registerHandler:@"_app_setTitle" handler:^(id data, WVJBResponseCallback responseCallback) {
if (data) {
NSDictionary *dic = (NSDictionary *)data;
weakSelf.title = dic[@"title"];
}
// responseCallback 给后台 JS 的回复
responseCallback(@"OK,已收到标题信息!");
}];
/**
* JS 调用 OC ,关闭当前 H5 控制器
*/
[_bridge registerHandler:@"_app_closeWebView" handler:^(id data, WVJBResponseCallback responseCallback) {
[weakSelf closeTheView];
responseCallback(@"OK,已关闭当前 WebView ");
}];
/**
* OC 调用 JS ,获取 OC 的值
*/
[_bridge callHandler:@"_app_getToken" data:@"userToken"];
}
OC调用异步js方法,并获取js的返回值,在呢么实现?
主要的是阻塞主线程
- (void)nativeCallJS:(NSString *)func para:(NSString *)para block:(void (^)(id))block{
//在主线程调用
__block BOOL end = NO;
[self.bridge callHandler:func data:para responseCallback:^(id responseData) {
NSLog ( @"from js: %@" , responseData ) ;
block(responseData);
end = YES;
}];
while (!end) {
//阻塞主线程
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
}
官方提供的异步调用js的方法
- (void)callAsyncJavaScript:(NSString *)functionBody arguments:(nullable NSDictionary<NSString *, id> *)arguments inFrame:(nullable WKFrameInfo *)frame inContentWorld:(WKContentWorld *)contentWorld completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler NS_REFINED_FOR_SWIFT API_AVAILABLE(macos(11.0), ios(14.0));
js调用OC,并需要异步回调结果怎么处理的?
//别忘了,在configuration中的userContentController中添加scriptMessageHandler
//[controller addScriptMessageHandler:self name:@"share"]; //记得适当时候remove哦
//JS调用share方法时,则会调用下面的方法
#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"share"]) {
NSDictionary *shareData = message.body;
//模拟异步回调
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
//读取js function的字符串
NSString *jsFunctionString = shareData[@"result"];
//拼接调用该方法的js字符串
NSString *callbackJs = [NSString stringWithFormat:@"(%@)(%d);", jsFunctionString, NO]; //后面的参数NO为模拟分享失败
[self.webView evaluateJavaScript:callbackJs completionHandler:^(id _Nullable result, NSError * _Nullable error) {
if (!error) {
NSLog(@"模拟回调,分享失败");
}
}];
});
}
}
对应的JS代码
/**
* 分享方法,并且会异步回调分享结果
* @param {对象类型} shareData 一个分享数据的对象,包含title,imgUrl,link以及一个回调function
* @return {void} 无同步返回值
*/
function shareNew(shareData) {
//这是该方法的默认实现,上篇文章中有所提及
var title = shareData.title;
var imgUrl = shareData.imgUrl;
var link = shareData.link;
var result = shareData.result;
//do something
//这里模拟异步操作
setTimeout(function() {
//2s之后,回调true分享成功
result(true);
}, 2000);
//用于WKWebView,因为WKWebView并没有办法把js function传递过去,因此需要特殊处理一下
//把js function转换为字符串,oc端调用时 (<js function string>)(true); 即可
shareData.result = result.toString();
window.webkit.messageHandlers.shareNew.postMessage(shareData);
}
function test() {
//清空分享结果
shareResult.innerHTML = "";
//调用时,应该
share({
title: "title",
imgUrl: "http://img.dd.com/xxx.png",
link: location.href,
result: function(res) {
//这里shareResult 等同于 document.getElementById("shareResult")
shareResult.innerHTML = res ? "success" : "failure";
}
});
}
关键点:
- 可以采用异步回调的方式,将返回值返回给js
- 一般js的参数中包含function是为了异步回调,这里我们可以把js的function转换为字符串,再传递给OC。
1、WKWebView 白屏问题
WKWebView是一个多进程的组件,Network Loading以及UI Rendering在其他进程中执行。初次适配WKWebView的时候,我们也惊讶于打开WKWebView后, App进程内存消耗反而大幅度下降,但仔细观察会发现,Other Process的内存占用会增加。在一些用webGL渲染的复杂页面,使用WKWebView总体的内存占用【App process Memory + other Process Memory】,不见得比UIWebView少很多。
UIWebView上当内存占用太大的时候, App Process会crash;而在WKWebView上当总体的内存占用比较大的时候,WebContent Process会crash, 从而出现白屏现象
这个时候WKWebView.URL会变成nil,简单的reload刷新操作已经失效,对于一些长驻的H5页面影响比较大。
解决方案:
《1》借助WKNavigationDelegate
iOS9之后增加的回调函数
/*! @abstract Invoked when the web view's web content process is terminated.
@param webView The web view whose underlying web content process was terminated.
*/
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macos(10.11), ios(9.0));
在WKWebView总体内存占用过大的时候,页面即将出现白屏,在上面这个系统回调方法中执行[webview reload]
来解决白屏问题。
《2》检测webView.title是否为空
并不是所有的H5页面白屏的时候都会调用上面的回调函数;
场景:最近遇到的一个高内存消耗的H5页面上present系统相机,拍照完毕后返回原来页面的时候出现白屏现象(拍照过程消耗了大量内存,导致内存紧张, WebContent Process被系统挂起),但上面的回调函数并没有被调用。在WKWebView白屏的时候,另一种现象是webView.title会被置空,因此,可以在viewWillAppear的时候检测webView.title是否为空来reload页面。
2、WKWebView Cookie问题
2.1、WKWebView Cookie存储
业界普遍认为 WKWebView 拥有自己的私有存储,不会将 Cookie 存入到标准的 Cookie 容器 NSHTTPCookieStorage 中。
实践发现 WKWebView 实例其实也会将 Cookie 存储于 NSHTTPCookieStorage 中,但存储时机有延迟,在iOS 8上,当页面跳转的时候,当前页面的 Cookie 会写入 NSHTTPCookieStorage 中,而在 iOS 10 上,JS 执行 document.cookie 或服务器 set-cookie 注入的 Cookie 会很快同步到 NSHTTPCookieStorage 中,FireFox 工程师曾建议通过 reset WKProcessPool 来触发 Cookie 同步到 NSHTTPCookieStorage 中,实践发现不起作用,并可能会引发当前页面 session cookie 丢失等问题。
WKWebView Cookie 问题在于 WKWebView 发起的请求不会自动带上存储于 NSHTTPCookieStorage 容器中的 Cookie。
比如,NSHTTPCookieStorage 中存储了一个 Cookie:
name=Nicholas;value=test;domain=y.qq.com;expires=Sat, 02 May 2019 23:38:25 GMT;
通过 UIWebView 发起请求http://y.qq.com, 则请求头会自动带上 cookie: Nicholas=test;
而通过 WKWebView发起请求http://y.qq.com, 请求头不会自动带上 cookie: Nicholas=test。
2.2、WKProcessPool
*WKProcessPool定义:A WKProcessPool object represents a pool of Web Content process。
通过让所有 WKWebView 共享同一个 WKProcessPool 实例,可以实现多个 WKWebView 之间共享 Cookie(session Cookie and persistent Cookie)数据。 不过WKWebView WkProcessPool实例在app杀进程重启后会被重置,导致WKProcessPool 中的cookie/session Cookie数据丢失,目前也无法实现WKProcessPool实例本地化保存。
2.3 Workround
H5的业务都是依赖于Cookie作登陆态校验,而WKWebView上请求不会自动携带Cookie,目前的主要解决方案是:
《1》WKWebView loadRequest前,在request header中设置Cookie,解决首个请求Cookie带不上的问题
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];
《2》通过document.cookie设置Cookie解决后续页面(同域)Ajax, iframe 请求的cookie问题
注意:document.cookie() 无法跨域设置cookie
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而无法访问。当然,由于每一次页面跳转都会调用回调函数:
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
可以在该回调函数里拦截302请求,copy request, 在request header中带上cookie并重新loadRequest。不过这种方法依然解决不了页面的iframe跨域请求的cookie问题,毕竟-[WKWebView loadRequest]
只适合加载mainiFrame请求。
3、WKWebView NSURLProtocol 问题
WKWebView在独立于App进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用NSURLProcol无法拦截请求。
苹果开源的WebKit源码暴露了私有API
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]
通过注册http(s) scheme 后, WKWebView将可以使用NSURLProtocol拦截http(s)请求:
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"];
}
缺点:
《1》post请求body数据被清空
由于WKWebView在独立进程里网络请求。一旦注册http(s) scheme后,网络请求将从Network process发送到App Process,这样NSURLProtocol才能拦截网络请求。 在webkit2的设计里使用了messageQueue进行进程之间的通信, Network Process会将请求encode成一个Message,然后通过IPC发送给App Process。 出于性能的原因,encode的时候HTTPBody和HTTPBodyStream这两个字段丢弃掉了。
因此,如果通过registerSchemeForCustomProcol注册了http(s) scheme,那么由WKWebView发起的所有http(s)请求都会通过IPC传给进程NSURLProtocol处理,导致post请求body被清空;
《2》对ATS支持不足
打开ATS开关: Allow Arbitrary Loads选项设置为NO,同时通过registerSchemeForCustomProtocol注册了http(s) scheme;WKWebView发起的所有http网络请求将被阻塞(即便将Allow Arbitrary Loads in Web Content 选项设置为YES)
WKWebView可以注册customScheme,比如dynamic://,因此希望使用离线功能,又不使用post方式的请求可以通过customScheme发起请求,eg:dynamic://www.dynamicalbumlocalimage.com/,然后在 app 进程 NSURLProtocol 拦截这个请求并加载离线数据。不足: 使用post方式的请求该方案依然不适用,同时需要H5侧修改请求scheme以及CSP规则。
4、WKWebView loadRequest问题
在WKWebView上通过loadRequest发起的post请求body数据会丢失;
//同样是由于进程间通信性能问题,HTTPBody字段被丢弃
[request setHTTPMethod:@"POST"];
[request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]];
[wkwebview loadRequest: request];
workround:
假设想通过-[WKWebView loadRequest:]
加载post请求,request1: http://h5.qzone.qq.com/mqzone/index,可以通过以下步骤实现:
1、替换请求scheme,生成新的post请求request2:post://h5.qzone.qq.com/mqzone/index,同时将request1的body字段复制到request2的header中(WebKit不会丢弃header字段)
2、通过-[WKWebView loadRequest:]
加载新的post请求request2;
3、通过 +[WKbrowsingContextController registerSchemeForCustom Protocol:]
注册scheme:post://;
4、注册 NSURLProtocol 拦截请求post://h5.qzone.qq.com/mqzone/index ,替换请求 scheme, 生成新的请求 request3: http://h5.qzone.qq.com/mqzone/index,将 request2 header的body 字段复制到 request3 的 body 中,并使用 NSURLConnection 加载 request3,最后通过 NSURLProtocolClient 将加载结果返回 WKWebView;
5、WKWebView页面样式问题
适配过程中,发现h5页面元素位置向下偏移或被拉伸变形,追踪后发现主要是h5页面高度值异常导致:
1.问题: 空间H5页面有透明导航、透明导航下拉刷新、全屏等需求,因此之前 webView 整个是从(0, 0)开始布局,通过调整webView.scrollView.contentInset 来适配特殊导航栏需求。而在 WKWebView 上对 contentInset 的调整会反馈到webView.scrollView.contentSize.height的变化上,比如设置 webView.scrollView.contentInset.top = a,那么contentSize.height的值会增加a,导致H5页面长度增加,页面元素位置向下偏移;
解决方案是:调整WKWebView布局方式,避免调整webView.scrollView.contentInset。实际上,即便在 UIWebView 上也不建议直接调整webView.scrollView.contentInset的值,这确实会带来一些奇怪的问题。如果某些特殊情况下非得调整 contentInset 不可的话,可以通过下面方式让H5页面恢复正常显示:
/**设置contentInset值后通过调整webView.frame让页面恢复正常显示
*参考:http://km.oa.com/articles/show/277372
*/
webView.scrollView.contentInset = UIEdgeInsetsMake(a, 0, 0, 0);
webView.frame = CGRectMake(webView.frame.origin.x, webView.frame.origin.y, webView.frame.size.width, webView.frame.size.height - a);
2、接入now直播问题: 在接入 now 直播的时候,我们发现在 iOS 9 上 WKWebView 会出现页面被拉伸变形的情况,最后发现是window.innerHeight值不准确导致(在WKWebView上返回了一个非常大的值),而H5同学通过获取window.innerHeight来设置页面高度,导致页面整体被拉伸。通过查阅相关资料发现,这个bug只在 iOS 9 的几个系统版本上出现,苹果后来fix了这个bug。我们最后的解决方案是:延迟调用window.innerHeight。
setTimeout(function(){height = window.innerHeight},0);
或者
Use shrink-to-fit meta-tag
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, shrink-to-fit=no">
6、WKWebView截屏问题
空间玩吧H5小游戏有截屏分享的功能,WKWebView 下通过 -[CALayer renderInContext:]
实现截屏的方式失效,需要通过以下方式实现截屏功能:
@implementation UIView (ImageSnapshot)
- (UIImage*)imageSnapshot {
UIGraphicsBeginImageContextWithOptions(self.bounds.size,YES,self.contentScaleFactor);
[self drawViewHierarchyInRect:self.bounds afterScreenUpdates:YES];
UIImage* newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}
@end
然而这种方式依然解决不了 webGL 页面的截屏问题,笔者已经翻遍苹果文档,研究过 webKit2 源码里的截屏私有API,依然没有找到合适的解决方案,同时发现 Safari 以及 Chrome 这两个全量切换到 WKWebView 的浏览器也存在同样的问题:对webGL 页面的截屏结果不是空白就是纯黑图片。无奈之下,我们只能约定一个JS接口,让游戏开发商实现该接口,具体是通过 canvas getImageData()方法取得图片数据后返回 base64 格式的数据,客户端在需要截图的时候,调用这个JS接口获取 base64 String 并转换成 UIImage。
7、WKWebView crash问题
WKWebView 放量后,外网新增了一些 crash, 其中一类 crash 的主要堆栈如下:
...
28 UIKit 0x0000000190513360 UIApplicationMain + 208
29 Qzone 0x0000000101380570 main (main.m:181)
30 libdyld.dylib 0x00000001895205b8 _dyld_process_info_notify_release + 36
Completion handler passed to -[QZWebController webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:] was not called
主要是JS调用window.alert()函数引起的,从 crash 堆栈可以看出是 WKWebView 回调函数:
+ (void) presentAlertOnController:(nonnull UIViewController*)parentController title:(nullable NSString*)title message:(nullable NSString *)message handler:(nonnull void (^)())completionHandler;
completionHandler 没有被调用导致的。在适配 WKWebView 的时候,我们需要自己实现该回调函数,window.alert()才能调起 alert 框,我们最初的实现是这样的:
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
{
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"确认" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { completionHandler(); }]];
[self presentViewController:alertController animated:YES completion:^{}];
}
如果 WKWebView 退出的时候,JS刚好执行了window.alert(), alert 框可能弹不出来,completionHandler 最后没有被执行,导致 crash;另一种情况是在 WKWebView 一打开,JS就执行window.alert(),这个时候由于 WKWebView 所在的 UIViewController 出现(push或present)的动画尚未结束,alert 框可能弹不出来,completionHandler 最后没有被执行,导致 crash。我们最终的实现大致是这样的:
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
{
if (/*UIViewController of WKWebView has finish push or present animation*/) {
completionHandler();
return;
}
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"确认" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { completionHandler(); }]];
if (/*UIViewController of WKWebView is visible*/)
[self presentViewController:alertController animated:YES completion:^{}];
else
completionHandler();
}
确保上面两种情况下 completionHandler 都能被执行,消除了 WKWebView 下弹 alert 框的 crash,WKWebView 下弹 confirm 框的 crash 的原因与解决方式与 alert 类似。
另一个 crash 发生在 WKWebView 退出前调用:
-[WKWebView evaluateJavaScript: completionHandler:]
执行JS代码的情况下。WKWebView 退出并被释放后导致completionHandler变成野指针,而此时 javaScript Core 还在执行JS代码,待 javaScript Core 执行完毕后会调用completionHandler(),导致 crash。这个 crash 只发生在 iOS 8 系统上,参考Apple Open Source,在iOS9及以后系统苹果已经修复了这个bug,主要是对completionHandler block做了copy(refer: https://trac.webkit.org/changeset/179160);对于iOS 8系统,可以通过在 completionHandler 里 retain WKWebView 防止 completionHandler 被过早释放。我们最后用 methodSwizzle hook 了这个系统方法:
+ (void) load
{
[self jr_swizzleMethod:NSSelectorFromString(@"evaluateJavaScript:completionHandler:") withMethod:@selector(altEvaluateJavaScript:completionHandler:) error:nil];
}
/*
* fix: WKWebView crashes on deallocation if it has pending JavaScript evaluation
*/
- (void)altEvaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^)(id, NSError *))completionHandler
{
id strongSelf = self;
[self altEvaluateJavaScript:javaScriptString completionHandler:^(id r, NSError *e) {
[strongSelf title];
if (completionHandler) {
completionHandler(r, e);
}
}];
}
8、其它问题
8.1、视频自动播放
WKWebView 需要通过WKWebViewConfiguration.mediaPlaybackRequiresUserAction设置是否允许自动播放,但一定要在 WKWebView 初始化之前设置,在 WKWebView 初始化之后设置无效。
8.2、goBack API问题
WKWebView 上调用 -[WKWebView goBack], 回退到上一个页面后不会触发window.onload()函数、不会执行JS。
8.3、页面滚动速率
WKWebView 需要通过scrollView delegate调整滚动速率:
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
scrollView.decelerationRate = UIScrollViewDecelerationRateNormal;
}
9、App嵌入小段html代码 —— CoreText
1、富文本复杂的排版
2、图片
3、连接
优点:
1)比webView消耗少
2)后台渲染【非常适用于内容排版工作】快
3)精确
缺点:
1)不能够像webView那样支持复制
2)需要自己处理很多逻辑
小结:
坑多, 相对 UIWebView 在内存消耗、稳定性方面还是有很大的优势。