WKWebView使用指南

github地址:JXBWKWebView,如果觉得项目不错可以点个star支持一下,谢谢~

前言


目前iOS系统已经更新到iOS11,大多数项目向下兼容最多兼容到iOS8,因此,在项目中对WebView组件进行重构再封装时,打算直接舍弃UIWebView转用WKWebView

如果你目前正在网上浏览关于WKWebView的一些文章,相信你已经清楚了WKWebView的优点,也目睹了大家在使用WKWebView的过程中遇到的坑,而这篇文章,会对到目前为止大家遇到的关于WKWebView的问题给出详细的解决方案,文章的最后,也会讲述关于对WKWebView进行性能优化的方案。

解决的问题


  • goback返回页面不刷新
  • Cookie
  • POST请求失效
  • crash
  • navigationBackItem
  • 进度条
  • NativeJS的交互
  • 优化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。然后在根据truefalse在执行相应的页面刷新动作或者直接ajax请求接口更新数据。

关于onloadonpageshow事件在safarichrome上的区别如下:

. 事件 Chrome Safari
第一次加载页面 onload 触发 触发
第一次加载页面 onpageshow 触发 触发
从其他页面返回 onload 触发 不触发
从其他页面返回 onpageshow 触发 触发

关于cookie

WKWebView属于webkit框架,其将浏览器内核渲染进程提取出 App主进程,由另外一个进程进行管理,减少了相当一部分的性能损失,这也是性能上比UIWebView优越的原因之一。

既然WKWebView的工作进程独立于App Process之外,我们暂且称为WK Process(随便起的)。

在使用AFN进行网络请求时,如果server使用set-cookiecookie写入headerAFN接受到响应后会将cookie保存到NSHTTPCookieStorage,下次如果是同域的request urlAFN会将cookieNSHTTPCookieStorage中取出然后作为request headercookie发送给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 headerset-cookie

NSString *cookieString = [[response allHeaderFields] valueForKey:@"Set-Cookie"];

那么,WKWebViewcookie存入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请求(可以理解域名发生变化,也就是说不同域),上面的解决方案就用不了了,这时就需要你在WKWebViewdecidePolicyForNavigationAction代理方法中拦截URL,判断当前URL与初次请求的URL是否同域,如果不同域,在该代理方法中获取到当前请求的request对象并copy出一个新的对象,通过addValue:forHeaderField:方法将cookie手动添加到header中,然后让WKWebView使用loadRequest重新加载这个copy出来的新的request对象。

问题就没了吗?NO,上面的解决方法同样有局限,即只能解决后续的同域ajax请求不加cookie的问题。如果发生iframe跨域请求,我们拦截不到请求,所以也没法给请求的header手动添加cookieWKWebView只适合加载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,只不过Applehttphttps这两个scheme给过滤掉了,导致我们拦截不到WKWebView发送的网路请求。

因此,在我们自定义NSURLProtocol时,要通过使用私有api来注册一些scheme,注册scheme的类名叫WKBrowsingContextControllerWKWebView中有一个属性叫browsingContextController,就是这个类的对象。注册的方法叫registerSchemeForCustomProtocol:,知道这个私有api,我们就可以通过target-action的方式,注册WKWebView发起网络请求时需要拦截的URL scheme,此时注册的scheme至少要包括3种,分别是httphttpspost

问题还没玩,解决一个问题的同时往往伴随另一个问题的产生。

使用这种方案拦截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不会在进程间通信发送httpbody

因为WKWebView属于webkit框架,因此WKWebView的网络请求、内容加载/渲染都是在WK Process中进行,但NSURLProtocol拦截请求还在App Process,一旦注册http(s) scheme后,网络请求将从独立进程中发送到App Process,这样自定义的NSURLProtocol才能拦截到网络请求,为了提升进程间通信效率,出于性能上的考虑,Apple会将requestbody数据丢弃,因为body数据(二进制类型)大小没有限制,size偏大的话就会对数据传输效率有严重影响进而影响到拦截请求时的操作及延时后续的网络请求,因此,Apple在进行进程间通信时会把post请求的body丢弃。

如何解决?
终极思路就是虽然httpbody会在进程间通信时被丢弃,但header不会。

因此,解决问题步骤如下:

  • WKWebViewloadRequest前对request对象进行一些处理,这个request对象我们记为old request
    1.记下old requestschemeNSData类型的http body
    2.获取当前old requestURL,替换URLschemepost(这也是我们为什么要在前面使用NSURLProtocol注册post scheme的原因),并根据这个替换好的URL重新生成一个新的NSMutableURLRequest对象,这个对象记为new request
    3.给new requestheader赋值,把步骤1中获取的schemehttp body手动添加到这个new requestheader中,如果这个post请求需要附带cookie的话,你也要把cookieold request中拿出来放到new requestheader中。
    4.让WKWebView加载这个new request
  • WKWebView发送新的request时(这个request urlschemepost),我们可以在自定义NSURLProtocol中拦截到这个请求,执行如下步骤:
    1.替换scheme,此时的schemepost,你需要把post scheme替换成old requestscheme,这个字段我们之前已经保存下来了。
    2.替换scheme后会生成一个新的URL,根据这个新的URL生成一个NSURLMutableRequest对象,将之前保存的http bodycookie放到这个新的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所在的UIViewControllerpushpresent的动画尚未结束,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系统,可以通过在completionHandlerretain WKWebView防止completionHandler被过早释放。

解决方案是使用method swizzling hook了这个系统方法,在回调中对self进行了强引用来保证在执行completionHandler的时候self还在。

navigationBackItem

实现导航栏back item的方式有两种。

  • 自定义导航栏

这个比较简单,根据WebView是否可以goback决定navigationBarButtonItems的个数和功能。

  • 使用系统默认的导航返回按钮,类似于微信

难点在于我们要获取到点击系统导航返回按钮时的事件,然后进行一些处理。

点击返回按钮时,实际上调用了UINavigationControllernavigationBar:shouldPopItem方法,我们可以使用method swizzling hook住这个方法,在这个方法中通过调用代理方法的方式告诉WKWebView所在的UIViewController进行相应的处理。

UIProgressView

这个简单,也不多说了。

Native与JS的交互

  • 拦截URL

WKWebViewdecidePolicyForNavigationAction代理方法中可对URL进行拦截,一般使用拦截URL的方式URL的格式如下:

scheme://host?paramKey=paramValue

一般情况下scheme对应业务,host是业务对应的服务(method),?后面就是参数。

使用拦截URL的交互方式时,业务逻辑不复杂情况下,JS调用Native没什么问题,但当业务逻辑复杂时,JS需要拿到Native处理好的回调数据的话,处理起来将十分麻烦。

并且使用拦截URL的交互方式,不利于今后JSNative的业务拓展。

  • 使用Bridge

WKWebViewJSNative通过Bridge交互提供了非常好的支持,我们可以通过ScriptMessageHandler来达成各种交互的目的。使用ScriptMessageHandler添加脚本的具体代码在此不多赘述,大家可自行研究。重点说一下Bridge的脚本代码。

现在关于Bridge的开源解决方案有很多,但基本都遵循一个模式,在注入的Bridge脚本代码中,定义好供JS调用的方法名称,该方法通常包括如下几个参数:
1.要调用的native业务模块名称(有些有,有些没有,如果项目中实施模块化建议加上)。
2.要调用的native服务名称(通常是方法名)。
3.传递给native的参数(也就是方法需要的参数)。
4.callbackJS调用native的方法后脚本需要调用的回调。

详细来描述一下使用Bridge整个交互过程,从创建Bridge脚本到Bridge脚本执行callback
Bridge脚本下称脚本。
1.脚本为JS提供JavaScript语言的方法,该方法用来调用native方法,方法的4个参数如前所述。
2.在该方法中,会根据前述的部分参数生成一个唯一标识符,记为identifier
3.在脚本中给全局对象(window)绑定一个字典属性,key是步骤2中的identifiervaluecallback
4.调用messagehandlerpostMessage函数,将前述的参数和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封装了WKWebViewevaluateJavaScript操作,这个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)传递过去,目标对象将任务处理完成后,调用paramsuccess block, failure block, progress block,将任务处理的结果回传给JS

  • 交互总结

无论是拦截URL还是使用Bridge,最后调用native方法的机制都是利用target-action,使用target-action机制的原因之一就是可减少类与类之间的耦合程度,减少硬编码的同时有利于今后的业务扩展。

当然,如果你不喜欢target-action的方案,也可以自行扩展。

拦截WKWebView的网络请求

通过观看WebKit的源码可以了解到WKWebView是支持拦截网络请求的,但是WebKit没有注册需要拦截的scheme,所以我们只能进行手动注册了。

手动注册需要调用WKWebView的私有api,注册scheme的私有apiregisterSchemeForCustomProtocol:,注销的私有apiunregisterSchemeForCustomProtocol:,有些同学会考虑到在项目中使用私有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就可以理解为是在线打开的。

把一个H5HTML/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.提供nativeH5的交互解决方案,支持自定义MessageHandler操作。
11.提供H5秒开解决方案,server使用Go实现。
12.iOSAndroidJS提供统一的原生调用方式。

github地址:JXBWKWebView,如果觉得项目不错可以点个star支持一下,谢谢~