iOS - WebView 杂七杂八

2018-10-06

记录了我在使用 WKWebView 和 UIWebView 过程中遇到的问题以及一些总结

一、WebView 首屏预加载

对于一个 WebView 打开 H5 页面的过程,粗略的将其分为2个过程,对 WebView 首屏加载的优化主要可以从以下 2 个过程考虑

T1:初始化 Webview + RunWebThread

初始化一个 WebView ,开启 Web 线程

T2:Load Main Frame

这个过程包括资源下载,渲染

下面是两种方案

(1)方案一:初始化 WebView 提前加载 URL

初始化一个 WebView 加载相应 URL 进行预加载,原理是通过 HTTP 缓存机制对可以做缓存的页面(可以做缓存指html、静态资源都符合 HTTP 缓存机制)提前加载实现缓存。

HTTP 缓存机制

HTTP 缓存机制是指通过 HTTP 协议头里的 Cache-Control(或 Expires)和 Last-Modified(或 Etag)等字段来控制文件缓存的机制。这应该是 WEB 中最早的缓存机制了,是在 HTTP 协议中实现的。

  • Cache-Control: 用于控制文件在本地缓存有效时长。最常见的,比如服务器回包:Cache-Control:max-age=600 表示文件在本地应该缓存,且有效时长是600秒(从发出请求算起)。在接下来600秒内,如果有请求这个资源,浏览器不会发出 HTTP 请求,而是直接使用本地缓存的文件。

  • Last-Modified: 是标识文件在服务器上的最新更新时间。下次请求时,如果文件缓存过期,浏览器通过 If-Modified-Since 字段带上这个时间,发送给服务器,由服务器比较时间戳来判断文件是否有修改。如果没有修改,服务器返回 304 告诉浏览器继续使用缓存;如果有修改,则返回 200,同时返回最新的文件。

  • Expires: 是 HTTP1.0 标准中的字段,Cache-Control 是 HTTP1.1 标准中新加的字段,功能一样,都是控制缓存的有效时间。当这两个字段同时出现时,Cache-Control 是高优化级的。

  • Etag: 也是和 Last-Modified 一样,对文件进行标识的字段。不同的是,Etag 的取值是一个对文件进行标识的特征字串。在向服务器查询文件是否有更新时,浏览器通过 If-None-Match 字段把特征字串发送给服务器,由服务器和文件最新特征字串进行匹配,来判断文件是否有更新。没有更新回包304,有更新回包200。Etag 和 Last-Modified 可根据需求使用一个或两个同时使用。两个同时使用时,只要满足基中一个条件,就认为文件没有更新。

Cache-Control 通常与 Last-Modified 一起使用。一个用于控制缓存有效时间,一个在缓存失效后,向服务查询是否有更新。

Cache-Control 还有一个同功能的字段:Expires。Expires 的值一个绝对的时间点,如:Expires: Thu, 10 Nov 2015 08:45:11 GMT,表示在这个时间点之前,缓存都是有效的。

HTTP 缓存详解

  • 优化的阶段:T1 + T2

  • 优点:
    • 对于打开一个 URL, 可以将页面上所有的静态资源(包括 link 的 js、css 以及页面上的图片)进行缓存,缓存过程不用我们控制,WebView 会自动对其做好缓存。
  • 缺点:
    • 初始化 WebView 加载 URL 会占据一定内存,WKWebView 还好,UIWebView 内存开销挺大
    • 只针对 页面、资源具有缓存功能时(需要服务端配置,通过HTTP 协议头里的 Cache-Control、 Last-Modified 或 Expires、Etag字段来控制文件缓存的机制)有效。若页面、静态资源本身没有做缓存处理,该方案是无效。
  • 补充说明:

    • WebView 缓存的资源地址:

      WKWebView: 沙盒 -> Caches/项目 Bundle Identifier/WebKit
      UIWebView: 不清楚
      
    • 代码清缓存:

      URLCache.shared.removeAllCachedResponses()
      // iOS 9.0 后才能使用
      if #available(iOS 9.0, *) {
          let websiteDataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
          let dateFrom = Date(timeIntervalSince1970: 0)
          WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes, modifiedSince: dateFrom) {
              print("WebView 缓存清理完毕")
          }
      }
      
    • UIWebView 清缓存只能清除磁盘缓存,内存缓存没有清,测试 UIWebView 清缓存时每次需要重启 App,否则会读取内存中的缓存。
    • UIWebView 即使有 HTTP 缓存,重启应用后还是会重新请求,WKWebView 则不会

(2)方案二:通过 http 请求下载资源,拦截 WebView 请求返回本地缓存数据

获取所有需要缓存的 html、js、css 和 图片等资源的 url

这里的 url 获取方式需要和服务端或者前端协商好,实现的方式很多种,我们实现的方式是服务端下发首页的 url,内部资源的 url 由前端配置在指定的 json 文件中

通过 http get 请求获取到 data 缓存至本地。WebView 加载 URL 时通过 URLProtocol 拦截所有请求,读取本地数据返回给 WebView。

  • 优化的阶段:T2

  • 优点:
    • 基本不占内存,除了首次下载资源时的网络请求会占一定内存,因为缓存的文件都在本地,只要匹配文件就行
    • 对于没有 http 缓存的页面同样适用
    • 可以自定义缓存策略,比如版本更新策略等等
  • 缺点:
    • 对于 WebView 初始化、Web 线程初始化没有提升,对于 UIWebView 尤其明显,提升的是资源请求部分,但是这一点可以通过提前初始化一个空的 WebView 进行提速
    • 对于 WKWebView 极其不友好,需要做很多的适配才可以满足。其中主要的就是 NSURLProtocol 不支持 WKWebView 的拦截,必须通过调用私有 API 才行。以及通过 NSURLPortocol 拦截到的 POST 请求的 body 会被清空,这个问题看另一篇的解决方案。
    • 实现较为复杂,网络请求,缓存管理都要手动实现

####

二、WebView JS 交互相关

WebView 和 JS 通信一般有两种方式

(1)方法一

第一种就是 WebViewJavascriptBridge 实现的方式,是通过在 js 新建一个 iframe,iframe 指定一个自定义 scheme 的 url,将该 frame 添加到当前 dom 中。那么就可以分别通过 WKWebView 的

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)

以及 UIWebView 的

func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebView.NavigationType) -> Bool

拦截到该 iframe 的 url,根据 url 的 scheme 再做具体操作,这样就可以进行通信了,具体实现可以看 WebViewJavascriptBridge 的源码。这种方式的好处就是同时适用 UIWebView、WKWebView,项目中若是想要同时兼容这两个 WebView,适用这种方式比较方便。

WebViewJavascriptBridge 在使用的时候需要向前端代码中注入一段 JS 以开启整个通信过程,这里有一点需要注意的就是要掌握好 JS 注入时机,最好在 H5 DOM 加载完后注入,即 document.ready 时。太早注入 JS ,JS 会无效;太迟注入,在注入前的 JS、OC 交互就无法执行。详情可以看 UIWebView代码注入时机与姿势

  • WKWebView:
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
configuration.userContentController = [WKUserContentController new];
NSString *js = JS();
//WKUserScriptInjectionTimeAtDocumentStart为 时注入
WKUserScript *script = [[WKUserScript alloc] initWithSource:js injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
[configuration.userContentController addUserScript:script];
  • UIWebView:

用到 TS_JavaScriptContext,通过代理方法在 didCreateJavaScriptContext 的时机注入 JS,TS_JavaScriptContext 用法可以自行查找文档,这里不细说。

(2)方法二

第二种是直接通过原生 API 进行通信,UIWebView 通过 JSContext,WKWebView 通过 WKScriptMessageHandler。

  • WKWebView

WKWebView 调用 js 主要通过以下方法,因为之前写 JSBridge 是用 swift 写的,这里就放 swift 的代码了

/* @abstract Evaluates the given JavaScript string.
 @param javaScriptString The JavaScript string to evaluate.
 @param completionHandler A block to invoke when script evaluation completes or fails.
 @discussion The completionHandler is passed the result of the script evaluation or an error.
*/
open func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil)

如以下的调用就是将 Object 以及 String 当做参数传递给 js 中的 window.bridge.receiveMessage() 方法

wkWebview.evaluateJavaScript("window.bridge.receiveMessage({'key': 'value'});", completionHandler: nil)

wkWebview.evaluateJavaScript("window.bridge.receiveMessage('message');", completionHandler: nil)

JS 调用 WKWebView 主要通过 JS 使用 window.webkit.messageHandlers.name.postMessage(messageBody) 方法

上面的 name 是指什么呢,name 表示在 WKWebView 中添加过的 scriptMessageHandler 的 name,scriptMessageHandler 类似一个 delegate,处理 WKWebView 接收到的 JS 的 message (即上述方法中的 messageBody),每个 scriptMessageHandler 对应一个 name

比如以下代码

// swift
wkWebview.configuration.userContentController.add(self, name: "bridge")

通过上述代码添加了一个 name 为 bridge 的 scriptMessageHandler 后,我们可以通过以下方式像 WKWebView 发送 message

// js
window.webkit.messageHandlers.bridge.postMessage({
    "key": "value"
});

WKWebView 怎么接受这些 message 呢

通过实现协议 WKScriptMessageHandler 的方法接受 message

extension JustBridge: WKScriptMessageHandler {
    
    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // body: ["key": "value"]
        guard let body = message.body as? [String: Any] else { return }
   
}
  • UIWebView
- (void)webViewDidStartLoad:(JSBridgeWebView *)webView {
    JSContext* context =[self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
	JSValue *value = [context evaluateScript:javaScriptString];
	id obj = [value toObject];
}

三、WKWebView 相关功能

(1)WKWebView 使用 NSURLProtocol 拦截请求

WKWebView 要使用 NSURLProtocol 拦截请求需要通过调用私有 API,或者在 iOS 11 中有新增 API WKURLSchemeHandler 也可以实现拦截自定义 Scheme

  • ObjC:
FOUNDATION_STATIC_INLINE Class ContextControllerClass() {
    static Class cls;
    if (!cls) {
        if (@available(iOS 8.0, *)) {
            cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
        }
    }
    return cls;
}
FOUNDATION_STATIC_INLINE SEL RegisterSchemeSelector() {
    return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}
FOUNDATION_STATIC_INLINE SEL UnregisterSchemeSelector() {
    return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}

/// 需要注册指定的 Scheme 才能够拦截相应请求
+ (void)registerScheme:(NSString *)scheme {
    Class cls = ContextControllerClass();
    SEL sel = RegisterSchemeSelector();
    if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
    }
}
+ (void)unregisterScheme:(NSString *)scheme {
    Class cls = ContextControllerClass();
    SEL sel = UnregisterSchemeSelector();
    if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
    }
}


//========================== 使用 ==============================
[NSURLProtocol registerClass:yourClass];
// 拦截 http 请求
[NSURLProtocol registerScheme:@"http"];
  • Swift:

通过头文件连接上面 OC 代码使用

(2)禁用页面长按选项

  • OC:
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    // 禁用页面长按功能(长按图片保存等)
    [webView evaluateJavaScript:@"document.documentElement.style.webkitTouchCallout='none';" completionHandler:nil];
    [webView evaluateJavaScript:@"document.documentElement.style.webkitUserSelect='none';"completionHandler:nil];
}

(3)WKWebView 开启 keyboardDisplayRequiresUserAction

WKWebView 是没有 keyboardDisplayRequiresUserAction 这个属性的,但是 UIWebView 有,用于控制 H5 页面是否可以自己 focus 一个输入框。实现方法在 StackOverflow 上都可以找到。

注意事项: iOS 11.3 以后私有 API 有改动,换了一个方法,需要做判断。

  • OC:

    + (void)wkWebViewShowKeybord {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            Class cls = NSClassFromString(@"WKContentView");
            SEL originalSelector;
            if (@available(iOS 11.3, *)) {
                originalSelector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:changingActivityState:userObject:");
            } else {
                originalSelector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:userObject:");
            }
            Method originalMethod = class_getInstanceMethod(cls, originalSelector);
            IMP originalImp = method_getImplementation(originalMethod);
            IMP overrideImp;
              
            if (@available(iOS 11.3, *)) {
                overrideImp = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, BOOL arg3, id arg4) {
                    ((void (*)(id, SEL, void*, BOOL, BOOL, BOOL, id))originalImp)(me, originalSelector, arg0, TRUE, arg2, arg3, arg4);
                });
            } else {
                overrideImp = imp_implementationWithBlock(^void(id me, void* arg0, BOOL arg1, BOOL arg2, id arg3) {
                    ((void (*)(id, SEL, void*, BOOL, BOOL, id))originalImp)(me, originalSelector, arg0, TRUE, arg2, arg3);
                });
            }
              
            method_setImplementation(originalMethod, overrideImp);
        });
    }
    
  • Swift:

    typealias OlderClosureType =  @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Any?) -> Void
    typealias NewerClosureType =  @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void
      
    extension WKWebView{
      
        var keyboardDisplayRequiresUserAction: Bool? {
            get {
                return self.keyboardDisplayRequiresUserAction
            }
            set {
                self.setKeyboardRequiresUserInteraction(newValue ?? true)
            }
        }
      
        func setKeyboardRequiresUserInteraction( _ value: Bool) {
      
            guard let WKContentViewClass: AnyClass = NSClassFromString("WKContentView") else {
                    print("Cannot find the WKContentView class")
                    return
            }
      
            let olderSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:userObject:")
            let newerSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:changingActivityState:userObject:")
      
            if let method = class_getInstanceMethod(WKContentViewClass, olderSelector) {
                let originalImp: IMP = method_getImplementation(method)
                let original: OlderClosureType = unsafeBitCast(originalImp, to: OlderClosureType.self)
                let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3) in
                    original(me, olderSelector, arg0, !value, arg2, arg3)
                }
                let imp: IMP = imp_implementationWithBlock(block)
                method_setImplementation(method, imp)
            }
      
            if let method = class_getInstanceMethod(WKContentViewClass, newerSelector) {
                let originalImp: IMP = method_getImplementation(method)
                let original: NewerClosureType = unsafeBitCast(originalImp, to: NewerClosureType.self)
                let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3, arg4) in
                    original(me, newerSelector, arg0, !value, arg2, arg3, arg4)
                }
                let imp: IMP = imp_implementationWithBlock(block)
                method_setImplementation(method, imp)
            }
        }
    }
    

四、WebView 预加载中遇到的问题及解决

这些问题都是开发过程中一些需要思考的点,但是解决方案是否是最有效的我也不知道,只是将我的一些抉择做记录以供参考

(1)使用 WebView 加载 URL 的方式预加载缓存的注意事项

  • 对于需要预加载多个 URL 的时候,可以有两种方式做预加载,对于 WKWebView 最好使用第一种方式,对于 UIWebView 如果考虑内存情况可以使用后面的方式

    • 初始化多个 WebView 每个单独加载一个 URL,多个 URL 同时加载减少时间,但是对于 UIWebView 会提高内存

    • 只初始化一个 WebView 轮流加载多个 URL,内存占用较少,但是耗时较久。

      注意:前端使用 Vue.js 框架的话要注意加载的 URL 可能和 webViewDidFinishLoad 代理方法中 webView 中获取的 URL 可能不相同,因此不能用代理方法中的获取的 URL 作为判断一个 URL 是否已经加载完。

      例如:假设加载的 URL 为 www.baidu.com,但是 webViewDidFinishLoad 中 webView 获取的 URL 经过路由可能变成 www.google.com,就无法判断 www.baidu.com 是否加载完。

  • 预加载时 WebView 若没有被 addSubview,前端是用 Vue.js 框架的话,那么 Vue.js 的路由可能会不执行,一些路由后才能加载的资源就无法请求到。测试后具体原因不清楚,但是和内存相关,开多个 WKWebView 预加载基本不走路由,少量小概率不走路由。

(2)同一个 WebView 加载同一个 URL 不会使用 HTTP 缓存

同一个 WebView 加载同一个 URL 不会使用 HTTP 缓存。比如 WebView 当前在页面 A,后来又加载页面 A,那么 html 是不会使用 HTTP 缓存的,但是内部的静态资源会使用缓存。但是若是 WebView 先跳转到页面 B 后又跳回 A,那 html 是会使用 HTTP 缓存的

HTTP 缓存有效性:

用户操作 Cache-Control/Expires Last-Modified/Etag
地址栏回车 有效 有效
页面链接跳转 有效 有效
新开窗口 有效 有效
前进后退 有效 有效
F5 刷新 无效 有效
Ctrl + F5 无效 无效

因此猜测上面那种情况应该类似 F5 刷新,所以缓存无法使用

(3)H5 以及静态资源有使用代理服务器、CDN 时注意事项

HTTP 缓存有一个消息头 Age ,解释如下

Age消息头的值通常接近于0。表示此消息对象刚刚从原始服务器获取不久;其他的值则是表示代理服务器当前的系统时间与此应答消息中的通用消息头 Date 的值之差。

经常和max-age一起来验证缓存是否过期,即如果这个字段的值比max-age的值还大,那这份缓存就已经过期了。

当使用 CDN,CDN上没有做相应配置(具体什么配置不清楚,服务端的事情了),那么CDN 上的资源超过 Cache-Control 规定的缓存时间,那么此时 WebView 获取上面的资源,返回的消息头会包含一个大于 max-age 的 Age 信息,缓存会无效。

(4)URLSession 相关

  • NSURLProtocol 拦截 URLSession 请求

OC:

// 可以被 NSURLProtocol 拦截
NSURLSession.sharedSession
// 通过 NSURLSessionConfiguration 初始化的无法 被 NSURLProtocol 拦截
[NSURLSession sessionWithConfiguration: delegate: delegateQueue:]

Swift:

// 可以被 NSURLProtocol 拦截
URLSession.shared
// 通过 NSURLSessionConfiguration 初始化的无法 被 NSURLProtocol 拦截
URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil)

如上注释所述,只有默认的 URLSession 的请求才可以被 NSURLProtocol 拦截,NSURLSession.sharedSession API 的官方描述如下

The shared session uses the shared NSURLCache, NSHTTPCookieStorage, and NSURLCredentialStorage objects, uses a shared custom networking protocol list (configured with registerClass: and unregisterClass:), and is based on a default configuration.

When working with a shared session, you should generally avoid customizing the cache, cookie storage, or credential storage (unless you are already doing so with NSURLConnection), because there’s a very good chance that you’ll eventually outgrow the capabilities of the default session, at which point you’ll have to rewrite all of those customizations in a manner that works with your custom URL sessions.

  • 通过 URLSession 请求的数据也是会有 HTTP 缓存的。即使 Request 缓存策略设置为忽略本地缓存,数据也是会缓存在本地,只是读取缓存时会忽略本地缓存。

(5)NSURLProtocol 相关

拦截到的请求每一次请求会初始化一个 NSURLProtocol 执行,NSURLProtocol 持有一个 client。多个请求可以并发执行。

(6)H5 的 LocalStorage

应用杀端重启后,前端页面通过 localStorage 保存的数据会丢失,初步判断是 localStorage 数据缓存在内存中,随着应用关闭会释放。

(7)WKWebView 的 POST 请求会清空 body

通过 URLProtocol 拦截的 WKWebView 的 POST 请求 body 会被清空,由于 WKWebView 对 JS 处理比 UIWebView 有优势,所有 Apple 官方员工推荐的做法是前端通过 JS 将请求传给客户端通过客户端请求后返回给前端。详情可看 WKWebView 那些坑

(8)WKPreferences 的 minimumFontSize

初始化 WKWebView 时若有设置 WKPreferences 的 minimumFontSize 属性可能会造成页面布局偏移原来位置

例如: 设置 minimumFontSize 为 30,那么打开 www.baidu.com 会有明显的 “百度一下” 按钮向下偏移

(9)禁止键盘弹起时 WebView 整体向上滚动

WKWebView 和 UIWebView 键盘弹起时默认整个 WebView 会向上滚动,要禁止该滚动无法用 scrollEnable 属性禁止,可以在以下代理中通过改变 contentOffset 实现不滚动的效果

-(void)scrollViewDidScroll:(UIScrollView *)scrollView

(10)打开自定义 sheme 的 URL 时 H5 报错 Failed to load resource: unsupported URL

WKWebView 通过 URLProtocol 拦截自定义 sheme 的 url 时需要 registerScheme(见第 三 点的第(1)点)。若未 registerScheme 或者提前 unregisterScheme 时,那么打开自定义 sheme 的 URL 时 H5 会报错 Failed to load resource: unsupported URL。发生该错误时需要查看是否 registerScheme 获取是否哪里提前 unregisterScheme 了。


文档:

HTTP 缓存详解

UIWebView代码注入时机与姿势

WKWebView 那些坑

iframe介绍

第三方库:

WebViewJavascriptBridge

TS_JavaScriptContext