天天看点

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

摘要

App应用的功能代码,通常在用户访问之前,就已经以安装包的形式,通过应用市场下载安装好了。而网页应用的功能代码(静态资源),则是在用户实际点击访问时,才实时下载运行。

这一『用时下载』的特点是一把双刃剑,既带来了实时更新的灵活性,也造成了应用启动的延迟,导致网页应用启动速度远远落后于App应用,造成交互体验和用户转化短板。

本文提出一种基于静态资源预加载技术,提升App内网页启动速度的新方案。根据线上项目实践数据,此方案可显著提升网页启动速度(缩短页面加载时间30%-50%以上)、提高网页加载成功率。相比于传统的离线化技术方案,本方案具有差异性优势,且能规避iOS WKWebview中无法拦截请求的问题。

1 背景和问题

在App应用开发中,网页应用(又名H5/WebApp/Hybrid等)以其跨平台/跨App、开发成本低、迭代试错速度快的特点,始终占有一席之地。

然而,在上述优势之外,网页应用启动速度慢(尤其是用户首次访问时)、点击转化率低等问题,使其难以进入核心业务技术选型。

下面我们以微信内的一个页面为例,从一个普通用户视角,感受一下网页应用的启动过程。

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

然后从技术视角,分析一下网页启动的几个关键耗时阶段。

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

(注:上述数据根据线下测试数据及部分线上项目TP95用户数据得出,仅用于说明占比趋势,不同项目会有一定差异)

从图中可知,静态资源下载是最大的耗时环节,那么,如何解决这一耗时瓶颈?

调研发现,针对静态资源下载慢的问题,业界常规的解决方案是链接预取(link prefetching)。

链接预取是一种浏览器机制,其利用浏览器空闲时间来提前下载用户在不久的将来可能访问的文档。其主要实现思路是:

  • 网页向浏览器提供一组预取提示,在完成当前页面加载后,浏览器按照预取提示,开始静默地下载指定的文档,并将其存储在浏览器缓存中
  • 当用户访问预取文档时,便可以快速从浏览器缓存中读取
  • 链接预取属于预加载技术,已成为Web事实标准的一部分

链接预取已在前端项目中广泛应用,在各类构建工具中均有默认实现。

在用户进入网页应用首页之后,链接预取能加速后续页面的访问速度,而对于网页应用首页本身的访问加速,链接预取则显得无能为力。

因为链接预取需要一个前置页面来设置预取提示,而网页应用首页通常是用户访问的起点,不存在这样的前置页面。

2 App内网页静态资源预加载

2.1 App内网页特点和预加载问题

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

回到App场景内,App内的网页应用,入口一般投放在导航类/频道类Native页面,用户点击入口图标后启动WebView组件展示。

Native页面是网页应用首页的前置页面,这就给了我们一个很好的预加载首页时机:可以在Native页面中去预加载网页应用首页,从而提高网页应用首页的启动速度。

要实现这一机制,需要解决如下问题:

  • 如何在Native页面下载Web网页静态资源,并放入缓存区?
  • Web网页打开时,如何命中上述缓存?

2.2 技术方案

针对上述两个关键问题,提出基于『静态资源预加载 + 浏览器缓存』的解决方案,核心思路如下图:

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

主要包括三个核心模块:隐藏WebView启动模块、预加载器模块和静态资源URL列表配置模块

1)隐藏WebView启动模块

此模块由客户端实现,主要功能:

  • 在App启动或进入导航类Native页面时,初始化一个隐藏不可见的WebView组件,打开预加载器模块H5页面
  • 一般仅在网络空闲、使用WIFI情况下执行,以避免占用用户正常访问带宽,节省用户流量成本

2)预加载器模块

此模块由Web前端实现,主要功能:

  • 请求服务端接口,获取需要预加载的静态资源URL列表
  • 调用浏览器Fetch方法,下载列表中的静态资源,存储到WebView HTTP缓存区
  • 静态资源下载完毕后,通知Native销毁隐藏WebView
// 预加载器核心代码示例function prefetch(url) {  return self.fetch == null  ? xhrPrefetchStrategy(url)    : fetch(url, { credentials: `omit` });}function xhrPrefetchStrategy(url) {  return new Promise((resolve: () => {}, reject: () => {}) => {  const req = new XMLHttpRequest();  req.open(`GET`, url, (req.withCredentials = false));  req.onload = () => {  req.status === 200 ? resolve() : reject();    };  req.send();  });}
           

3)静态资源URL列表配置模块

此模块由服务端实现,通常以管理配置平台的形式,开放给企业内所有业务线接入,主要功能:

  • 配置和管理需要被预加载的各网页应用的URL列表
  • 组合所有接入方的URL列表,形成统一列表,提供给预加载器模块调用
// URL列表配置接口示意// 资源列表接口URL:https://www.demo.com/prefetch-platform/config.json// 资源列表接口响应体示例// 实际返回给预加载器页面的结果,需要对此配置中的assetsURL进行请求后,返回实际要加载的URL地址{  "prefetch": true, // 全局预加载开关  "assets": [       // 资源URL列表    {      "name": "projectA", // 接入项目名称        "assetsURL": "https://www.companyA.com/projectA/prefetch-assets.json", // 接入项目资源列表接口地址        "prefetch": true // demo项目预加载开关    },    {      "name": "projectA",      "assetsURL": "https://www.companyA.com/projectB/prefetch-assets.json",      "prefetch": true    }  ]}
           

接入方要接入App预加载功能,需要:

  • 项目上线时,构建生成自己项目的静态资源URL列表
  • 设置静态资源响应头,允许预加载器跨域下载列表中的资源
// 接入方式示例// 1、构建URL列表// 资源列表接口URL:https://www.companyA.com/projectA/prefetch-assets.json// 资源列表接口响应体:{  "prefetch": true, // 是否开启预加载  "assets": [    // 资源URL列表    "https://www.companyA.com/projectA/js/index.abcd1234.js",    "https://www.companyA.com/projectA/css/index.abcd1234.css",    "https://www.companyA.com/projectA/img/index.abcd1234.png"  ]}// 2、资源跨域头设置location ~* \.(html|js|css|png)$ {  add_header Access-Control-Allow-Origin *;            }
           

2.3 针对HTML主文档的预加载

上文我们已经对网页中的JavaScript/CSS等资源进行了预加载,那如何对入口HTML文档进行预加载呢?

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

众所周知,入口HTML通常设置为不缓存,每次请求都会从服务端获取最新内容。

这就导致HTML无法进行预加载,进而导致整个网页应用无法实现离线化(断网可用)。

要解决这一问题,我们需要给HTML文档增加版本号,并应用新的缓存策略。主要实现思路如下:

1)在项目上线构建时,对HTML主文档增加版本号,并将带版本号的入口地址URL,传给服务端入口配置系统更新

// 构建发布时bash脚本示例// 编译构建完毕后,复制生成带版本号的HTML主文档- htmlVersion=$(date +%Y%m%d%H%M%S)- cp ./dist/index.html ./dist/index.$htmlVersion.html // index.20190501124536.html// 上线成功后,发消息通知服务端入口配置系统,更新为最新版本的入口URL- push a message: [url = 'https://www.companyA.com/projectA/index.20190501124536.html'] - to backend config server
           

2)针对带版本号的主文档,设置长期缓存

# *.[version-number].html file: cache 1 yearlocation ~* \.(\d+)\.html$ {  add_header Access-Control-Allow-Origin *;  add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";}# *.html file: no-cachelocation ~* \.html$ {  add_header Access-Control-Allow-Origin *;  add_header Cache-Control "no-cache, no-store, must-revalidate";}
           

3)通过服务端接口,下发带版本号的入口URL

// 配置URL示例        服务端下发带版本号的URL示例:https://www.companyA.com/projectA/index.20190501124536.html    客户端Native页面兜底降级使用的不带版本号的URL示例:https://www.companyA.com/projectA/index.html
           

4)预加载所有静态资源(包括HTML)项目URL配置列表示例

// 配置URL示例{  "prefetch": true,  "assets": [    "https://www.companyA.com/projectA/index.20190501124536.html",    "https://www.companyA.com/projectA/index.20190501124536.html?from=test",    "https://www.companyA.com/projectA/js/index.abcd1234.js",    "https://www.companyA.com/projectA/css/index.abcd1234.css"  ]}
           

对这些资源进行预加载后,便可以实现在用户首次访问页面时,所有的静态资源都从本地缓存读取。主要收益:

  • 消除静态资源下载带来的网络连接建立、数据传输等时间消耗,提高网页应用启动速度和点击转化率
  • 实现网站首页完全离线化,很大程度上解除应用对静态资源服务器的核心依赖,提高系统可用性

5)带动态可变参数的HTML地址,如何解决?

在生产实践中,存在URL中有动态可变参数的案例,以支付收银台为例,业务打开收银台时,其入口地址为:

// 配置URL示例    // 打开收银台地址,其中pay_order_number为动态变化的参数,是启动收银台时动态生成的参数    支付订单1 => https://www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0001    支付订单2 => https://www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0002    支付订单3 => https://www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0003
           

由于订单号是用户下单动态生成的,而缓存是以URL为key进行查询的,动态参数将导致无法在App启动或其他时机对HTML进行预加载。

这类问题如何解决呢?当然也是有办法的,这里就不直接抛出解决方案了,有兴趣、遇到同样问题的朋友可以思考下。

此外,还有部分业务的HTML入口URL是固定不变的,无法通过服务端动态下发。对于这种情况,可以给入口URL设置一个短时间的缓存时间(如10-30分钟),这样既能提前预加载,又不会导致长时间无法更新。

6)服务端API接口数据如何离线?

对于页面使用前端渲染的项目,除HTML/JS/CSS等静态数据外,应用首次启动一般还会有服务端API数据请求,此请求的离线化思路是:

  • 先尝试网络请求,失败后走下一步
  • 从本地LocalStorge读取(数据为上一次正常网络请求时存储),如果读取成功,则用上次的API数据渲染,并在页面上展示网络异常通知文案;如果读取失败,则走下一步;
  • 展示JavaScript代码中内置的默认兜底数据,以及网络异常通知文案

从线上项目实践来看,仅对html增加版本号这一项(未进行任何资源预加载),即可提升完全加载时间10%-20%左右,对于老客多的项目,这是一个简单有效的优化手段。

2.4 成本分析

由于用户浏览行为的难以预测性,静态资源预加载会带来一定的流量浪费,需要对这部分成本进行核算。

1)企业下行带宽成本

以预加载一个H5项目(资源大小50KB)1000万次为例,增加流量 = 1000万 * 50KB ≈ 500GB。

按照当前某知名云CDN按流量计费价格,对应的下行带宽价格为95元。

2)用户手机流量成本

以预加载50个网页项目为例,增加的手机流量 = 50 * 50KB ≈ 2.5MB,4G流量约5分钱,仅在WIFI下载则用户成本更低。

这里需要指出的是,并非每次App启动都会下载2.5MB。在一个项目上线周期内,缓存资源失效前,仅会下载1次,后续资源预加载请求也会命中缓存。

预加载并非适合所有的项目场景,不同项目的投入产出比是不同的,需要具体项目具体分析,以上给出的是成本分析的计算方法。

下面给出一些适用的项目场景:

  • 高点击率、大流量网页项目。高点击率、大流量意味着高缓存命中、低流量浪费,如:点击App首页banner打开的热点活动网页
  • 对启动速度和转化率有极致要求的网页项目。如:支付收银台、登录、频道首页等核心路径项目
  • 不适合的项目。流量小、转化收益低的项目

2.5 预加载策略

如果上述成本还无法接受,我们可以通过静态化和动态化策略,进一步降成本。

1)静态化预加载策略

  • 仅下载大流量/重点项目
  • 仅WIFI环境和浏览器网络空闲时下载
  • 在网页启动前,最近的上一步或上两步Native页面预加载,而不是都放到App启动时下载
  • 仅预加载网站首页需要的静态资源,首页之外后续其他页面的静态资源,由网站首页进行预加载
  • 按照项目配置的优先级顺序分批下载,控制每次并发的下载连接数,响应缓慢时及时终止下载

上述部分策略,需通过服务端管理平台落地,实现在合适的时机下载合适的项目。

2)动态化智能预加载策略

  • 基于用户画像(包括基础画像、长期画像、用户行为轨迹、实时数据、历史数据等),预测一个用户未来会点击哪些页面,计算出未来访问概率
  • 仅仅下载访问概率高于特定阈值项目的静态资源

动态策略举例:仅针对未登录用户,预加载登陆网页的静态资源。

这里需要指出的是,部分动态化策略的实现,需要大量研发资源和计算资源的投入,这部分投入可能已经远远超过了流量成本,因此在实施动态化策略时,需要综合评估考虑。

2.6 缓存命中率统计

通过浏览器的PerformanceAPI来得知请求的静态资源是fromLocalCache还是fromRemoteServer。

具体思路:performance.getEntries() ,可以获取每一个静态资源的请求信息,其返回如下图:

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角
  • transferSize等于0 说明从缓存读取,transferSize不等于0 说明从网络读取
  • 如果transferSize不可用,则使用duration。duration等于0,说明从缓存读取;duration不等于0,说明从网络读取

缓存命中率 = fromLocalCache / (fromLocalCache + fromRemoteServer)

这里需要指出的是,缓存命中率只是一个过程指标,在项目初期,建议将更多精力关注到完全加载时间和页面加载成功率率等结果指标上。

2.7 App启动时WebView内置公共基础库

在组件化、服务化盛行的今天,各个前端项目之间,共用了大量的基础库、组件库、业务框架,对于这些共用的部分,可以独立成一组公共静态资源,在App启动时预加载,直接内置到WebView缓存中。

// 公共基础库内置示例{  "prefetch": true,  "assets": [    "https://www.companyA.com/library/vue/2.5.0/vue.runtime.min.js",    "https://www.companyA.com/library/vue/2.6.0/vue.runtime.min.js",    "https://www.companyA.com/library/react/16.8.6/react.runtime.min.js",    "https://www.companyA.com/library/UIKit/2.1.6/UIKit.min.js",    "https://www.companyA.com/library/UIKit/2.1.6/UIKit.min.css",    "https://www.companyA.com/library/analytics/1.0.1/analytics.js",  ]}
           

业务项目只需要引用这些地址,便可以直接从WebView缓存中读取公共库。这是对公共CDN服务的一种改进,不会再有一种为别人做嫁衣的感觉。

在Webview内置这一类公共基础资源,可避免各项目之间重复下载公共基础代码,节省公司流量成本,提高网页启动速度。

2.8 与离线化/离线包方案的对比

在解决静态资源下载慢这一问题上,业界还有一种广为应用的技术方案,既:离线化/离线包方案。其主要思路是:

  • 将包括HTML/JS/CSS等静态资源打包到一个压缩包内,在用户访问项目前,预先下载该离线包到本地并解压
  • 当用户访问页面发出资源请求时,WebView容器会对请求进行拦截,如果已经在离线包内,会使用离线包中的本地资源响应给用户

自iOS 8.0起,Apple使用WKWebView来替换UIWebView,由于WKWebView在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,使得无法直接使用 NSURLProtocol 拦截请求,导致离线化方案在iOS端陷入困境,各大厂不得不采用颇具风险的私有API来解决这一问题。而本方案则摒弃了拦截的思路,有效规避了这一问题。

此外,与离线化/离线包相比,本方案还具备如下差异化优势:

webview加载不出网页_App内网页启动加速实践:静态资源预加载视角

当然,离线化方案也有其优势,如:包的形式可以减少请求数、对缓存全生命周期都可以做到细粒度控制等,需要大家根据实际应用场景进行选择。

特别提示:如果在App启动时进行预加载且App DAU很高,容易导致静态资源服务器QPS激增,形成流量突刺造成宕机,需要做好流量预估,逐步放量。

3 收益

我们针对线上部分项目,进行了资源预加载实践,结果数据如下:

  • 网页应用启动完全加载时间,缩短30%-50%以上(PerformanceAPI统计的页面onload指标,TP95),显著提高App内网页应用启动速度
  • 网页加载成功率,提高约1%(网页加载成功率 = 网页加载成功数 / 入口图标点击次数)

4 总结规划与畅想

4.1 总结

App应用的功能代码,通常在用户访问之前,就已经以安装包的形式,通过应用市场下载安装好了。而网页应用的功能代码(静态资源),则是在用户实际点击访问时,才实时下载运行。

这一『用时下载』的特点是一把双刃剑,既带来了实时更新的灵活性,也造成了应用启动的延迟,导致网页应用启动速度远远落后于App应用,造成交互体验和用户转化短板。

本文提出一种基于静态资源预加载技术,提升App内网页启动速度的新方案。根据线上项目实践数据,此方案可显著提升网页启动速度(缩短页面加载时间30%-50%以上)、提高网页加载成功率。

相比于传统的离线化技术方案,本方案具有差异性优势。

4.2 未来畅想

从事技术开发多年,一直在思考一个问题:面对铺天盖地、日新月异的技术,十年之后哪些技术还会存在?哪些技术终将消亡?活在当下我们又应当如何自处?

思索寻找之后,看到一句广为流传的话语:『一流公司做标准,二流公司做技术,三流公司做产品』。我们常常执迷于技术和产品,而忽视了其背后标准的力量。

iOS/Andriod/ReactNative/Flutter/小程序/快应用等当下火热的封闭性/私有化技术,终会随着巨头的兴衰而沉浮,Flash/塞班等技术便是先例。

而Web将会历久弥新,因为我们相信开放终将战胜封闭,分裂终将趋于标准和统一。

敬畏标准、拥抱标准、融入标准,围绕Web标准开展技术实践创新,『 Leading the web to its full potential 』,这或许是我们未来值得投入长期耐心和努力的方向!

5 参考资料

  1. fouber.大公司里怎样开发和部署前端代码?[EB/OL].链接,2019-02-02
  2. Ilya Grigorik.HTTP Caching[EB/OL].链接,2019-04-21
  3. 育新,徐宏,嘉洁.WebView性能、体验分析与优化[EB/OL].链接,2019-05-01
  4. Brian Jackson.Resource Hints - What is Preload, Prefetch, and Preconnect?[EB/OL].链接,2019-05-01
  5. MDN.HTTP Caching[EB/OL].链接,2019-04-21
  6. 阿里云.阿里云产品定价[EB/OL].链接,2019-05-01
  7. GoogleChromeLabs,quicklink[EB/OL].链接,2019-05-01
  8. 蚂蚁金服,离线包简介[EB/OL].链接,2019-04-21
  9. W3C,W3C官方主页[EB/OL].链接,2019-05-01