前言
目前 iOS 系统已经更新到 iOS11, 大多数项目向下兼容最多兼容到 iOS8, 因此, 在项目中对 webView 组件进行重构再封装时, 打算直接舍弃 UIWebView 转用 WKWebView.
如果你目前正在网上浏览关于 WKWebView 的一些文章, 相信你已经清楚了 WKWebView 的优点, 也目睹了大家在使用 WKWebView 的过程中遇到的坑, 而这篇文章, 会对到目前为止大家遇到的关于 WKWebView 的问题给出详细的解决方案, 文章的最后, 也会讲述关于对 WKWebView 进行性能优化的方案.
解决的问题
goback 返回页面不刷新
Cookie
POST 请求失效
- crash
- navigationBackItem
进度条
Native 与 JS 的交互
优化 H5 页面启动速度
入坑
goback API 返回不刷新
在之前使用 UIWebView 时, 调用 goback 后, 页面会刷新. 使用 WKWebView 后, 调用 goback, 即便调用 reload 方法, H5 依然不会刷新.
原因是调用 goback 时, UIWebView 会触发 onload 事件, WKWebView 不会触发 onload 事件, 如果前端依旧在 onload 事件中处理 iOS 的页面返回事件, 是处理不了的, 解决方案是让前端使用 onpageshow 事件监听 WKWebView 的页面 goback 事件.
前端代码如下:
- Windows.addEventListener("pageshow", function(event){
- if(event.persisted){
- location.reload();
- }
- });
为了查看页面是直接从服务器上载入还是从缓存中读取, 可以使用 PageTransitionEvent 对象的 persisted 属性来判断.
如果页面从浏览器的缓存中读取该属性返回 ture, 否则返回 false. 然后在根据 true 或 false 在执行相应的页面刷新动作或者直接 Ajax 请求接口更新数据.
关于 onload 和 onpageshow 事件在 Safari 和 Chrome 上的区别如下:
. | 事件 | Chrome | Safari |
---|---|---|---|
第一次加载页面 | onload | 触发 | 触发 |
第一次加载页面 | onpageshow | 触发 | 触发 |
从其他页面返回 | onload | 触发 | 不触发 |
从其他页面返回 | onpageshow | 触发 | 触发 |
关于 cookie
WKWebView 属于 webkit 框架, 其将浏览器内核渲染进程提取出 App 主进程, 由另外一个进程进行管理, 减少了相当一部分的性能损失, 这也是性能上比 UIWebView 优越的原因之一.
既然 WKWebView 的工作进程独立于 App Process 之外, 我们暂且称为 WK Process(随便起的).
在使用 AFN 进行网络请求时, 如果 server 使用 set-cookie 将 cookie 写入 header,AFN 接受到响应后会将 cookie 保存到 NSHTTPCookieStorage, 下次如果是同域的 request url,AFN 会将 cookie 从 NSHTTPCookieStorage 中取出然后作为 request header 的 cookie 发送给 server 端, 而这一切发生在 App Process.
那么在 WK Process 工作的 WKWebView 在发送网络请求及收到响应后对 cookie 的处理是否也会使用 NSHTTPCookieStorage 呢, 经过测试后, 答案是 yes, 但在存取的过程中会有一些问题需要注意.
先说存:
测试进行: iPhone 6p iOS:10
测试过程:
1.client 使用 AFN 发送一个网络请求
2.server 接收到请求后, 使用 set-cookie 写入 cookie
3.client 接收到 success response 后, 使用如下方式输出 log:
- NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:fields forURL:url];
- for (NSHTTPCookie *cookie in cookies) {
- NSLog(@"cookie,name:= %@,valuie = %@",cookie.name,cookie.value);
- }
4. 进入 WKWebView 所在页面, 使用 loadRequest 随便发送一个同域的网络请求, 在 decidePolicyForNavigationResponse 代理方法中, 使用如下代码输出 log:
- NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
- NSArray *cookies =[NSHTTPCookie cookiesWithResponseHeaderFields:[response allHeaderFields] forURL:response.URL];
- for (NSHTTPCookie *cookie in cookies) {
- NSLog(@"wkwebview 中的 cookie:%@", cookie);
- }
也可以使用如下代码输出该请求的 server response header 的 set-cookie:
NSString *cookieString = [[response allHeaderFields] valueForKey:@"Set-Cookie"];
那么, WKWebView 将 cookie 存入 NSHTTPCookieStorage 的时机是什么时候?
1.JS 执行 document.cookie 或服务器 set-cookie 注入的 Cookie 会很快同步到 NSHTTPCookieStorage 中.
2.H5 页面进行跳转时会将 Cookie 同步到 NSHTTPCookieStorage 中.
3. 控制器页面跳转时会将 Cookie 同步到 NSHTTPCookieStorage 中.
再说取:
WKWebView 使用 loadRequest 发送网络时不会主动将 cookie 存入到 NSHTTPCookieStorage 中, 即使是同域的请求.
所以, 如果你有一个请求需要附带 cookie, 就不能直接加载 URL, 需要你根据 URL 创建一个 URLMutableRequest 对象, 将需要附加的 cookie 使用 addValue:forHTTPHeaderField: 方法手动将 cookie 添加到 request header 中, 但这仅能解决首次请求不带 cookie 的问题, 如果页面发送 Ajax 请求, cookie 同样带不上, 解决方案是通过 document.cookie 设置 cookie, 也就是说在你实例化 WKWebView 时就应该注入相关 script.
上面我们说的都是在同域的情况下, 如果发生 302 请求(可以理解域名发生变化, 也就是说不同域), 上面的解决方案就用不了了, 这时就需要你在 WKWebView 的 decidePolicyForNavigationAction 代理方法中拦截 URL, 判断当前 URL 与初次请求的 URL 是否同域, 如果不同域, 在该代理方法中获取到当前请求的 request 对象并 copy 出一个新的对象, 通过 addValue:forHeaderField: 方法将 cookie 手动添加到 header 中, 然后让 WKWebView 使用 loadRequest 重新加载这个 copy 出来的新的 request 对象.
问题就没了吗? NO, 上面的解决方法同样有局限, 即只能解决后续的同域 Ajax 请求不加 cookie 的问题. 如果发生 iframe 跨域请求, 我们拦截不到请求, 所以也没法给请求的 header 手动添加 cookie,WKWebView 只适合加载 mainFrame 请求.
所以, 要和前端同学提前打好招呼, 尽量避免使用 iframe, 能使用 Ajax 的地方尽量使用 Ajax, 另一方面, iframe 现在已经不怎么提倡使用了, 除非是解决一些特殊的问题.
POST 请求
使用 WKWebView 无法正常发送 POST 请求.
所以, 这个时候我们需要通过自定义 NSURLProtocol 拦截 WKWebView 的网络请求, 并且, 使用 NSURLProtocol 拦截 WKWebView 网络请求的好处还有就是:
1. 如果产品需求要求 client 需要日志采集, 包括所有的网络请求记录, 通过这种方式你是可以获取到的.
2. 如果公司对用户体验的要求较高, 可以在这里实现 WKWebView 初始化和相关网络请求的并发执行, 以缩短用户在 client 打开 H5 的速度, 甚至可以秒开, 达到和 native 相同的体验.
但问题是正常情况下 NSURLProtocol 是拦截不到 WKWebView 的网络请求的.
通过观看 webkit 的源码 (GitHub 直接搜 webkit) 可以得到的结果是, 通过 WKWebView 发送一个网络请求其实也会走 NSURLProtocol, 只不过 Apple 把 http 和 https 这两个 scheme 给过滤掉了, 导致我们拦截不到 WKWebView 发送的网路请求.
因此, 在我们自定义 NSURLProtocol 时, 要通过使用私有 API 来注册一些 scheme, 注册 scheme 的类名叫 WKBrowsingContextController,WKWebView 中有一个属性叫 browsingContextController, 就是这个类的对象. 注册的方法叫 registerSchemeForCustomProtocol:, 知道这个私有 API, 我们就可以通过 target-action 的方式, 注册 WKWebView 发起网络请求时需要拦截的 URL scheme, 此时注册的 scheme 至少要包括 3 种, 分别是 http,https,post.
问题还没玩, 解决一个问题的同时往往伴随另一个问题的产生.
使用这种方案拦截 WKWebView 的网络请求造成的问题就是 post 请求 body 数据被清空, 还是 Apple 所为, 看 webkit 源码:
- void ArgumentCoder<ResourceRequest>::encodePlatformData(Encoder& encoder, const ResourceRequest& resourceRequest)
- {
- RetainPtr<CFURLRequestRef> requestToSerialize = resourceRequest.cfURLRequest(DoNotUpdateHTTPBody);
- bool requestIsPresent = requestToSerialize;
- encoder <<requestIsPresent;
- if (!requestIsPresent)
- return;
- // We don't send HTTP body over IPC for better performance.
- // Also, it's not always possible to do, as streams can only be created in process that does networking.
- RetainPtr<CFDataRef> requestHTTPBody = adoptCF(CFURLRequestCopyHTTPRequestBody(requestToSerialize.get()));
- RetainPtr<CFReadStreamRef> requestHTTPBodyStream = adoptCF(CFURLRequestCopyHTTPRequestBodyStream(requestToSerialize.get()));
- if (requestHTTPBody || requestHTTPBodyStream) {
- CFMutableURLRequestRef mutableRequest = CFURLRequestCreateMutableCopy(0, requestToSerialize.get());
- requestToSerialize = adoptCF(mutableRequest);
- CFURLRequestSetHTTPRequestBody(mutableRequest, nil);
- CFURLRequestSetHTTPRequestBodyStream(mutableRequest, nil);
- }
- RetainPtr<CFDictionaryRef> dictionary = adoptCF(WKCFURLRequestCreateSerializableRepresentation(requestToSerialize.get(), IPC::tokenNullTypeRef()));
- IPC::encode(encoder, dictionary.get());
- // The fallback array is part of CFURLRequest, but it is not encoded by WKCFURLRequestCreateSerializableRepresentation.
- encoder << resourceRequest.responseContentDispositionEncodingFallbackArray();
- encoder.encodeEnum(resourceRequest.requester());
- }
主要看代码中间那两句注释, 大致的意思就是 Apple 不会在进程间通信发送 http 的 body.
因为 WKWebView 属于 webkit 框架, 因此 WKWebView 的网络请求, 内容加载 / 渲染都是在 WK Process 中进行, 但 NSURLProtocol 拦截请求还在 App Process, 一旦注册 http(s) scheme 后, 网络请求将从独立进程中发送到 App Process, 这样自定义的 NSURLProtocol 才能拦截到网络请求, 为了提升进程间通信效率, 出于性能上的考虑, Apple 会将 request 的 body 数据丢弃, 因为 body 数据 (二进制类型) 大小没有限制, size 偏大的话就会对数据传输效率有严重影响进而影响到拦截请求时的操作及延时后续的网络请求, 因此, Apple 在进行进程间通信时会把 post 请求的 body 丢弃.
如何解决?
终极思路就是虽然 http 的 body 会在进程间通信时被丢弃, 但 header 不会.
因此, 解决问题步骤如下:
WKWebView 在 loadRequest 前对 request 对象进行一些处理, 这个 request 对象我们记为 old request.
1. 记下 old request 的 scheme 和 NSData 类型的 http body.
2. 获取当前 old request 的 URL, 替换 URL 的 scheme 为 post(这也是我们为什么要在前面使用 NSURLProtocol 注册 post scheme 的原因), 并根据这个替换好的 URL 重新生成一个新的
NSMutableURLRequest
对象, 这个对象记为 new request.
3. 给 new request 的 header 赋值, 把步骤 1 中获取的 scheme 和 http body 手动添加到这个 new request 的 header 中, 如果这个 post 请求需要附带 cookie 的话, 你也要把 cookie 从 old request 中拿出来放到 new request 的 header 中.
4. 让 WKWebView 加载这个 new request.
WKWebView 发送新的 request 时(这个 request url 的 scheme 是 post), 我们可以在自定义 NSURLProtocol 中拦截到这个请求, 执行如下步骤:
1. 替换 scheme, 此时的 scheme 是 post, 你需要把 post scheme 替换成 old request 的 scheme, 这个字段我们之前已经保存下来了.
2. 替换 scheme 后会生成一个新的 URL, 根据这个新的 URL 生成一个
NSURLMutableRequest
对象, 将之前保存的 http body,cookie 放到这个新的 request 对象的 header 中.
3. 使用 NSURLSession, 根据新的 request 对象发送网络请求, 然后通过
NSURLProtocol Client
将加载结果返回给 WKWebView.
注意: 在这几个步骤中一共产生了 3 个 request 对象.
crash
1.alert 弹窗
引起 crash 的原因是 JS 调用 alert()引起的, 也就是说, 当 WKWebView 销毁的时候, JS 刚好执行了 alert(), 原生的 alert 弹窗可能弹不出来, completionHandler 回调最后没有被执行, 导致 crash; 另一种情况是在 WKWebView 刚打开, JS 就执行 alert(), 这个时候由于 WKWebView 所在的 UIViewController 的 push 或 present 的动画尚未结束, alert 框可能弹不出来, completionHandler 最后没有被执行, 导致 crash.
解决方案: 获取当前 Windows 上最终的 UIViewController, 判断 UIViewController 是否未被销毁, UIViewController 是否已经加载完成, 动画是否执行完毕.
2. 另一个 crash 发生在 WKWebView 退出前调用:
执行 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 被过早释放.
解决方案是使用 method swizzling hook 了这个系统方法, 在回调中对 self 进行了强引用来保证在执行 completionHandler 的时候 self 还在.
navigationBackItem
实现导航栏 back item 的方式有两种.
自定义导航栏
这个比较简单, 根据 WebView 是否可以 goback 决定 navigationBarButtonItems 的个数和功能.
使用系统默认的导航返回按钮, 类似于微信
难点在于我们要获取到点击系统导航返回按钮时的事件, 然后进行一些处理.
点击返回按钮时, 实际上调用了 UINavigationController 的 navigationBar:shouldPopItem 方法, 我们可以使用 method swizzling hook 住这个方法, 在这个方法中通过调用代理方法的方式告诉 WKWebView 所在的 UIViewController 进行相应的处理.
UIProgressView
这个简单, 也不多说了.
Native 与 JS 的交互
拦截 URL
在 WKWebView 的 decidePolicyForNavigationAction 代理方法中可对 URL 进行拦截, 一般使用拦截 URL 的方式 URL 的格式如下:
scheme://host?paramKey=paramValue
一般情况下 scheme 对应业务, host 是业务对应的服务(method),? 后面就是参数.
使用拦截 URL 的交互方式时, 业务逻辑不复杂情况下, JS 调用 Native 没什么问题, 但当业务逻辑复杂时, JS 需要拿到 Native 处理好的回调数据的话, 处理起来将十分麻烦.
并且使用拦截 URL 的交互方式, 不利于今后 JS 与 Native 的业务拓展.
使用 Bridge
WKWebView 对 JS 与 Native 通过 Bridge 交互提供了非常好的支持, 我们可以通过 ScriptMessageHandler 来达成各种交互的目的. 使用 ScriptMessageHandler 添加脚本的具体代码在此不多赘述, 大家可自行研究. 重点说一下 Bridge 的脚本代码.
现在关于 Bridge 的开源解决方案有很多, 但基本都遵循一个模式, 在注入的 Bridge 脚本代码中, 定义好供 JS 调用的方法名称, 该方法通常包括如下几个参数:
1. 要调用的 native 业务模块名称(有些有, 有些没有, 如果项目中实施模块化建议加上).
2. 要调用的 native 服务名称(通常是方法名).
3. 传递给 native 的参数(也就是方法需要的参数).
4.callback,JS 调用 native 的方法后脚本需要调用的回调.
详细来描述一下使用 Bridge 整个交互过程, 从创建 Bridge 脚本到 Bridge 脚本执行 callback:
Bridge 脚本下称脚本.
1. 脚本为 JS 提供 JavaScript 语言的方法, 该方法用来调用 native 方法, 方法的 4 个参数如前所述.
2. 在该方法中, 会根据前述的部分参数生成一个唯一标识符, 记为 identifier.
3. 在脚本中给全局对象 (Windows) 绑定一个字典属性, key 是步骤 2 中的 identifier,value 是 callback.
4. 调用 messagehandler 的 postMessage 函数, 将前述的参数和 identifier 都发送给 native(没发 callback,callback 的作用主要就是步骤 3).
5. 前端调用你的脚本中的代码调用 native 的方法, 具体代码可参见 Apple 官方文档.
5.native 在自定义的 MessageHandler 对象的 userContentController:didReceiveScriptMessage: 代理方法中接收到 JS 传过来的参数(记为 param). 获取到了模块名称, 服务名称, 参数, identifier 等, 额外的, 需要创建几个 block, 对应 JS 那边的 callback, 比如 JS 那边有个 success callback, 那么在 native 就要有一个 success block, 而创建的这些 block, 我们会赋值给前面说的那个 param 里面, 那么现在, 这个 param 有如下几个值:
- targetName(模块名称)
- actionName(服务名称)
- identifier(通过该属性最后我们可以找到 JS 的 callback)
- success block
- failure block
- progress block
上面这些参数基本上已经够了, 如果需要扩展就自己加吧
那么这些 block 里面的操作主要是什么呢? block 封装了 WKWebView 的 evaluateJavaScript 操作, 这个 block 最后可以拿到 native 处理任务后的结果和 identifier, 然后把结果转换为 JSON 数据, 通过 identifier 找到 JS 那边的 callback, 然后把结果的 JSON 数据作为 callback 的参数回传给 JS 那边. 代码如下:
- NSString *resultDataString = [self jsonStringWithData:resultDictionary];
- NSString *callbackString = [NSString stringWithFormat:@"Windows.Callback('%@','%@','%@')", identifier, result, resultDataString];
- [message.webView evaluateJavaScript:callbackString completionHandler:nil];
6. 利用 target-action 机制, 根据 targetName 实例化对象, 根据 actionName 调用方法, 并把参数 (param) 传递过去, 目标对象将任务处理完成后, 调用 param 的 success block, failure block, progress block, 将任务处理的结果回传给 JS.
交互总结
无论是拦截 URL 还是使用 Bridge, 最后调用 native 方法的机制都是利用 target-action, 使用 target-action 机制的原因之一就是可减少类与类之间的耦合程度, 减少硬编码的同时有利于今后的业务扩展.
当然, 如果你不喜欢 target-action 的方案, 也可以自行扩展.
拦截 WKWebView 的网络请求
通过观看 WebKit 的源码可以了解到 WKWebView 是支持拦截网络请求的, 但是 WebKit 没有注册需要拦截的 scheme, 所以我们只能进行手动注册了.
手动注册需要调用 WKWebView 的私有 API, 注册 scheme 的私有 API 是 registerSchemeForCustomProtocol:, 注销的私有 API 是 unregisterSchemeForCustomProtocol:, 有些同学会考虑到在项目中使用私有 API 在审核时会被苹果爸爸打回, 我这里测试不会, 如果你遇到了被打回的情况, 可以把私有 API 拆分成多个字符串, 然后把多个字符串拼接在一起.
所以拦截 WKWebView 网络请求的步骤是:
(1)自定义 NSURLProtocol, 用来处理拦截到的网络请求.
(2)利用系统提供的 NSURLProtocol 注册 (1) 中自定义的 NSURLProtocol.
(3)通过私有 API 注册需要拦截的网络请求的 scheme.
(4)在合适的时机注销 (3) 中注册的 scheme.
H5 启动性能优化
H5 最让人诟病的一点就是它的用户体验没有 native 好, 其实 H5 的交互效果 (不包括复杂的动效) 已经非常接近于 native 了, 所以剩下的缺点总体来说就是关于 WebView 的渲染问题, 我们在写 native 界面的时候, 页面一打开就能看到我们创建的 UI 元素, 但是远程的 H5 不能, 因为远程 H5 的页面元素都需要去服务器获取, 随后经过渲染才能展示, 过程大致如下:
H5 启动流程
所以, 一个 H5 页面完全展示给用户所需要的时间远比 native 页面长的多.
所以针对于移动端来说, 优化 H5 启动性能的点主要有两个:
(1)优化 WebView 的启动速度
(2)让 html/CSS/JavaScript 文件下载的更快一些, 也就是离线包方案.
(1)优化 WebView 的启动速度
App 打开的时候并不会初始化浏览器内核, 当我们创建一个 WKWebView 的时候, 系统才会初始化浏览器内核, 也就是说, 当我们第一次用 WebView 打开 H5 的时候, H5 的显示时间需要加上浏览器内核启动时间, 所以优化点就在于优化浏览器内核启动时间.
很多解决方案是初始化一个单例 WebView, 让这一个 WebView 全局可用, 这样打开每个 H5 的时候用的都是同一个 WebView 对象, 工作原理有点接近 PC 端浏览器, 这样做的缺点就是如果这个 WebView 因为某些原因导致异常终止之后, 再用这个 WebView 打开 H5 可能会产生一些意料之外的问题, 所以, 这里推荐使用另外一种解决方案.
另外一种解决方案就是维护一个全局的 WebView 复用池, 复用原理同 UITableViewCell 一样, 这里不细讲. 如果一个 WebView 一直是正常工作的就放入复用池中, 如果一个 WebView 因为某些原因异常终止, 那么就把这个 WebView 从复用池中移除.
无论是哪种复用方案, 都会产生一个新问题, 当我们利用复用 WebView 打开一个新 H5 的时候, 浏览器的浏览历史记录里还保留着上一次打开的 H5 的痕迹, 所以, 我们需要在复用时清除这个痕迹并让页面打开一个空白页.
(2)使用离线包打包 H5 的静态资源.
我们通过一个远程 URL 打开 H5 就可以理解为是在线打开的.
把一个 H5 的 HTML/CSS/JavaScript 文件分别打包成静态资源文件保存在服务器, 这些保存在服务器的静态资源文件就可以理解为是离线包, 移动端可以选择一个合适的时机下载离线包, 然后在本地解压缩, 当我们打开一个 H5 的时候其实打开的是已经下载到本地的 HTML 文件, 免去了在线拉取资源的过程, 从而节省了时间.
当 H5 页面需要更新的时候, 直接对离线包做增量更新可以了.
更多细节可参考 bang 的这篇文章 http://blog.cnbang.NET/tech/3477/ .
基于 WKWebView 封装的 JXBWebKit
1. 内核决定了 goback 返回不刷新问题需要前端支持
2. 支持 natigationBackItem & navigationLeftItems
3. 支持自定义 rightBarButtonItem
4. 支持进度条
5. 提供 cookie 解决方案, 首次自己加, 后续的 Ajax 请求自动加, 302 请求自动加
6. 支持拦截 WKWebView 拦截网络请求
7. 支持 POST 请求
8. 支持子类继承
9. 支持拦截 URL 的交互方式, 支持自定义拦截 URL 操作.
10. 提供 native 与 H5 的交互解决方案, 支持自定义 MessageHandler 操作.
11. 提供 H5 秒开解决方案, server 使用 Go 实现.
GitHub 地址: https://GitHub.com/xiubojin/JXBWKWebView
来源: https://juejin.im/entry/5ba224a65188256bab2955e0