github
地址:JXBWKWebView,如果觉得项目不错可以点个star支持一下,谢谢~
前言
目前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
事件。
前端代码如下:
window.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
。
解决方案:获取当前window
上最终的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.在脚本中给全局对象(window
)绑定一个字典属性,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:@"window.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
页面完全展示给用户所需要的时间远比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
的这篇文章。
基于WKWebView封装的JXBWKWebView
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
实现。
12.iOS
和Android
为JS
提供统一的原生调用方式。
github
地址:JXBWKWebView,如果觉得项目不错可以点个star支持一下,谢谢~