你可能并不需要拦截 WebView 的静态资源

一、引子

假设你在开发一个 Hybird App, 老板嫌 h5 呈现的速度太慢了。你除了希望 h5 本身能够足够快之外,你在想: App 端能做的什么?

按照你自己日常经验,和临时抱佛脚 Google 一遍,你通常会看到以下解决方案;

  1. 配合 h5、后端、管理后台,整一套解决方案,做离线包加载。典型的如 VasSonic。
  2. 关键页面,改成 weex、react 来实现。
  3. webcache,那些将线上资源映射为本地资源的技术的总称。

你仔细考虑了想,觉得饭要一口一口吃, webcache 不需要和第三方配合,而且和其他方案也不冲突,而且工作量也不大,先做 webcache 吧。

二、反思

因为大家都这么干了,而且你也可以这么做,所以你实现了 webcache。但是回到需求本身,这种网络交互场景在构建 http 服务的时候,已经考虑到了,参考 MDN HTTP 缓存

重用已获取的资源能够有效的提升网站与应用的性能。Web 缓存能够减少延迟与网络阻塞,进而减少显示某个资源所用的时间。借助 HTTP 缓存,Web 站点变得更具有响应性。

在 HTTP response status codes 里,特别的 200304200 from cache和缓存息息相关。
- 你自己实现的 webcache 的流程是

  1. 在 WebView 里请求发起时,你用 NSURLProtocol 里的方法拦截了请求,
  2. 检查 url 是否是静态资源?
  3. 检查自己本地是否存在对应的文件?
  4. 如果有,从 memory 或者 本地 file (此后会缓存到 memory)读取内容,封装 NSURLResponse 返回给 WebView。

- 被我简化后的浏览器缓存使用流程是

  1. 在 WebView 里请求发起时,浏览器自己先 hold 请求,不向网络请求,
  2. 检查浏览器的 cache 文件夹(如 iOS 里的 /Library/Caches/com.xx.xxx)有没有 url 对应的资源
  3. 如果有,是否过期?是否需要验证?
  4. 如果未过期,从 memory cache 或者 disk cache (此后会缓存到 memory cache) 里获取内容, 返回给 WebView。

对比发现,webcache 实现的只是浏览器缓存的一部分,还不如浏览器完善。
我们本地 webcache 和浏览器的缓存能够可用,都建立在一个前提上;

对于静态资源,相同的 url 必定对应相同的内容。

http-cache 还保留着 etag,last-modified机制处理例外情况。

猜想静态资源拦截方案的由来

拦截的技术方案应该很久之前有了,当时的出现一定是解决了很多问题,我试着去搜索这个技术方案的缘起,大概有以下几个原因:

  • 由 native 决定哪些文件是可缓存的,受控,可以决定什么时候启用或关闭;而使用 http-cache 不受控。
  • 使用浏览器缓存,在首次加载页面时,不可用;而 webcache 离线包可在首次就使用
  • 静态资源拦截,比起浏览器 304 要快
  • native 和 web 共享同一个套资源
  • Android 的浏览器缓存为 12M,在图片多的情况下,前面的资源会被清理,典型的如在网易严选 App 逛专题。详见以前 Android 组同事文章,如何对Android WebView 轻量缓存优化?
  • UIWebview 默认的资源缓存只有 4M memory, 20M disk,而且是和 NSURLCache 是共享的。听说超限了会被清理

三、对旧问题的新方案思考

  1. 受控。确实 native 可控些,但是如果你弄明白了 http-cache 的机制,完全可以放心的把缓存的控制权限交给 http-cache 的头,交给服务器。

即使你还是不能完全放心,你甚至还有两个杀手锏:

  1. 预留清理本地缓存的接口
  2. 预留使用NSURLRequestReloadIgnoringLocalCacheData 策略加载资源的接口
  1. 首次不可用。有解决方法:使用一个预加载的 WebView 去 load 自己关心的资源,让其被浏览器缓存,如

基于 Android WebView自身的这些缓存机制,有一种小技巧能更好地去减少H5页面的加载速度 & 提高性能:在应用启动时就初始化一个 WebView ,事先加载常用H5页面资源(加载后就有缓存了),后续需要打开这些H5页面时就直接从本地获取。via

你可能还需要考虑被新缓存资源把一开始就加载好的资源 override 的情况,这时候你需要使用 NSURLRequestNSURLRequestReturnCacheDataDontLoad 来探测是不是我自己用预加载方式 load 的资源被冲掉了,然后再 reload ...

  1. 静态资源拦截,比起浏览器 304 要快。这个是事实,但是如果考虑上 200 from cache,拦截并不比 200 from cache 更快(后面有性能对比)。
  2. 共享资源。大部分情况这个需求是伪需求,没什么意义。
  3. 浏览器缓存被 override 或者整体清理。Android 不太熟悉,但是我猜测也有设置缓存空间大小的接口。现在都快发布 iOS 13 了,我们不需要考虑 iOS 11 以下。 iOS 12 的缓存是Caches\com.xx.xxx\文件夹下的 Webkit 子文件,
    WKWebview 的缓存文件
    到目前为止我没有找到关于Webkit 文件夹的大小限制。而 /Library/Caches 的大小限制取决于你的 disk 容量;

In iOS 5.0 and later, the system may delete the Caches directory on rare occasions when the system is very low on disk space. This will never occur while an app is running. However, be aware that restoring from backup is not necessarily the only condition under which the Caches directory can be erased.
File System Programming Guide

所以我采取了黑盒测试法。在严选 App 里打开首页 banner 里的严选美食好评榜,查看缓存会不会被删除;

美食好评榜

逐个浏览专题,这里的图片有不同的尺寸,数量足够多。点进去看商品详情,整体大概浏览了 2 分钟。打开 WebKit文件显示 97M,点到“宅家夏换新”,大概点了点,显示 117M,看起来没限制(待我这几天再试试)。
而且 chrome for iOS 也是用 WKWebview 实现的,那说明 WKWebview 的基础能力应该可以放心使用。

使用 http-cache 额外的好处

  1. 有更多的 OS 资源可以使用。 200 from cache 的处理是由 browser process 来处理的,是个独立进程;而使用 wkbroswingcontextcontroller 拦截执行在 com.apple.CFNetwork.CustomProtocols 线程,而且 qos 是

NSQualityOfServiceUserInitiated:次高优先级,用于用户需要马上执行的事件

后面会提到的 WKURLSchemeTask则在主线程执行, qos 是

NSQualityOfServiceUserInteractive:最高优先级,用于用户交互事件。

  1. http-cache 会和 Service Worker 共享缓存
  2. http-cache,可以利用 Chrome and Safari have “the preload scanner” and Firefox – the speculative parser. 技术预下载资源,预下载资源的代码不占用 App 进程资源。

看起了旧时的很多问题,在如今不需要解决或者有更好的解决方案。

最重要的,资源拦截和 200 from cache 以及其他方式的性能对比,特别的我们加入了 userScript 方式(这个是下一部分的主角),是什么样的呢?
下面是我自己在模拟器上的测试结果;

耗时

说明:

  1. 上表里的耗时指执行时间区间,包含下载和解析执行耗时(看测试代码容易理解)
  2. 测试的 4 个 js 来自严选首页的 dll.js,内容都是一样的,共 264.32KB(详见测试 demo 源码)
  3. 304 请求直接引用了 https 的 cdn 资源;200 请求是没有设置 max-age 头的资源,是强缓存,原因见 #设置静态资源多久为好 部分。如果你对 304 改为 200 有疑虑不敢改,你就多虑了,放心大胆的去做。
  4. NSURLProtocolWKURLSchemeTask 拦截都使用了内存缓存,不是每次都去读文件的

四、是否拦截的结论

  • 在大部分场景下,你写的 webcache 并没有多少用,性能并不比单纯的设置静态资源强缓存有用(2.216 < 2.482 )
  • userScript 带来真正的加速

五、userScript 加速方案

大前提:

多个 WebView 之间可以共用一个 WKWebViewConfiguration

以打开一个页面的 WebView 为例,
第一步,css 缓存,向共享的 Configuration 实例添加 css,这里需要使用私有方法,风险同使用 WKBrowsingContextController实现 WKWebview 拦截请求。

Class WKUserStyleSheetClass = NSClassFromString(@"_WKUserStyleSheet");
        id userStyleSheet = [WKUserStyleSheetClass new];
        if ([userStyleSheet respondsToSelector:@selector(initWithSource:forMainFrameOnly:)]) {
            [userStyleSheet performSelector:@selector(initWithSource:forMainFrameOnly:) withObject:@"body{background-color:blue;}" withObject:@(YES)];
        }
        
        if ([userController respondsToSelector:@selector(_addUserStyleSheet:)]) {
            [userController performSelector:@selector(_addUserStyleSheet:) withObject:userStyleSheet];
        }

第二步,js 缓存,向共享的 Configuration 实例添加 js。

NSURL *dllURL = [[NSBundle mainBundle] URLForResource:@"dll" withExtension:@".js"];
        NSString *code = [NSString stringWithContentsOfURL:dllURL encoding:kCFStringEncodingUTF8 error:nil];
        WKUserScript *dlljs = [[WKUserScript alloc] initWithSource:code injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
        [userController addUserScript:dlljs];

第三步,使用本身图片缓存,使用WKURLSchemeTask拦截,这里不举例。至于为什么不能用WKURLSchemeTask 去拦截 js、css 请参阅我以前的文章,WKURLSchemeHandler 的能与不能

原理:利用了WKUserScript_WKUserStyleSheet在相同的 WebView 里发生 navigation 行为后都会执行的特性;而且将 js、css 字符初始化为 WKUserScript_WKUserStyleSheet 对象后,只会校验一次、初始化一次(我怀疑还会缓存 js、css 解析后的中间表达,在查阅源码后没有找到证据,衰),后面的 navigation 发生时都不需要再次校验初始化的特点。

疑存无据

这套方案,可以满足旧的 NSURLProtocol 拦截的主要资源。应用到网易严选的详情页很合适,因为详情页使用率高,且不容易发生二次 navigation 到其他 url,且现在详情页是的跳转逻辑是

native 页面 -> webview 页面 -> native 页面 -> webview。
需要频繁打开同一类 url 的 webview ,还不会跳转到其他 url。

相比之下,对应网易推手的 webview 就不怎么友好。因为 网易推手就是一个 webview 的壳子,所有的 url 跳转 navigation 都在同一个 webview 里,这时候每次 navigation 跳转都会执行同一份 Configuration 是不对的。因为还有会跳转到考拉和严选这种第三方页面,不需要共有的 js、css 资源。这时候需要额外的加 url 匹配执行的逻辑,css 的话需要加 namespace 之类的代码;

六、补记

设置静态资源多久为好

根据对京东、天猫、苏宁、考拉等同类电商平台的观察,最常见的参数

  • css\js\ttf 等资源为 30 天
  • 图片、视频、音频为 1 年

还有一种方式:不设置 cache-control 和 etag
这也是一种设置强缓存的方式,缓存的时长根据公式计算可得。根据 Firefox、chrome、Safari 源码可知,三者基本都支持,有效期算法也是基本一致。
大概的 freshness_lifetime 是这样算的。假设 有 a.js 文件, last-modified 的时间是我现在访问它时间的前 10 个小时,那么它的过期时间是 1 个小时之后。如果没有 last-modified 则视为🙅缓存

下面是有关 freshness_lifetime 算法公式的源码

Firefox(可能有误)
https://dxr.mozilla.org/mozilla-central/source/obj-x86_64-pc-linux-gnu/_virtualenvs/init/lib/python2.7/site-packages/pip/_vendor/cachecontrol/heuristics.py

now = time.time()
        current_age = max(0, now - date)
        delta = date - calendar.timegm(last_modified)
        freshness_lifetime = max(0, min(delta / 10, 24 * 3600))
        if freshness_lifetime <= current_age:
            return {}

        expires = date + freshness_lifetime
        return {'expires': time.strftime(TIME_FMT, time.gmtime(expires))}

Chromium
/chromium/net/http/http_response_headers.cc 1003

// [https://datatracker.ietf.org/doc/draft-reschke-http-status-308/](https://datatracker.ietf.org/doc/draft-reschke-http-status-308/) is an
  // experimental RFC that adds 308 permanent redirect as well, for which "any
  // future references ... SHOULD use one of the returned URIs."
  if ((response_code_ == 200 || response_code_ == 203 ||
       response_code_ == 206) &&
      !must_revalidate) {
    // TODO(darin): Implement a smarter heuristic.
    Time last_modified_value;
    if (GetLastModifiedValue(&last_modified_value)) {
      // The last-modified value can be a date in the future!
      if (last_modified_value <= date_value) {
        lifetimes.freshness = (date_value - last_modified_value) / 10;
       return lifetimes;
      }
    }

WebKit
CacheValidation.cpp

// Freshness Lifetime:
    // [http://tools.ietf.org/html/rfc7234#section-4.2.1](http://tools.ietf.org/html/rfc7234#section-4.2.1)
    case 410: // Gone
        // These are semantically permanent and so get long implicit lifetime.
        return 24_h * 365;
    default:
        // Heuristic Freshness:
        // [http://tools.ietf.org/html/rfc7234#section-4.2.2](http://tools.ietf.org/html/rfc7234#section-4.2.2)
        if (auto lastModified = response.lastModified())
            return (effectiveDate - *lastModified) * 0.1;
        return 0_us;
    }

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349