# 影响网页缓慢的要素
网络条件弱
CPU处理器小
内存空间有限
# 加载耗时的痛点
在讨论如何提升网页加载速度前,需要先以数字的形式给出网页加载缓慢的定义,明确一个基准点——如何定义用户所感受到的网页加载耗时。这里有一个计算公式:
由此可见,用户在打开网页的整个过程中先后会经历 无反馈、白屏、loading 这几个阶段,而在 WebView 控件 loadFinish 后,页面基本上还停留在 loading 界面。所以上面公式里提到的网页加载完成一般可以理解为业务数据渲染完成的时候,因为只有在这之后用户才能够真正看见想要的内容。
换言之,网页加载缓慢体现在数值上来说就是指用户点击开启网页到业务数据渲染完成这段时间差过大,那么如何降低这个时间差就是我们亟待解决的问题。
# 优化方案
# 预置离线包
# SSR/NSR/骨架屏
# Webview 优化
WebView 是一个基于 WebKit 引擎、展现 Web 页面的控件, App 打开 WebView 的第一步不是请求连接,而是启动浏览器内核。这意味着,在浏览器端,我们输入地址就开始请求加载页面,但在 App 内,我们还需要先初始化 WebView 然后才能请求和加载。
这会造成什么结果呢?同一个页面,在 App 端外反而比端内打开速度更快。因为在 App 内,WebView 还需要先进行初始化,这需要时间,且这个初始化时间还和 WebView 类型有关。其中 Android 下只有一个 WebView,而 iOS 下却分 UIWebView 和 WKWebView。以我们 iOS 端使用的 UIWebView 为例,需要 400ms 左右,如果是 WKWebView,时间会更短,但基本也会占首屏时间的 30%左右。
怎么解决这个问题呢?这就需要进行 WebView 优化了, 一般它的优化包括资源缓存、并行初始化、资源预加载和数据接口请求优化,以及更换 WebView 内核等。
其中缓存选用方面比较简单,直接选用的浏览器默认缓存。而更换 WebView 内核,往往会因为需要进行灰度处理,必须一段时间内(通常几个月)并行两套 WebView 方案,很容易出现系统性风险,比如修改一个严重 Bug 后,前端工程师不知道用户端什么时候生效。所以,在这里,我着重介绍下 WebView 优化里面的并行初始化、资源预加载、数据接口请求优化三个方案。
- 并行初始化(池化)
所谓并行初始化,是指用户在进入 App 时,系统就创建 WebView 和加载模板,这样 WebView 初始化和 App 启动就可以并行进行了,这大大减少了用户等待时间。
如果是使用 native 开发的应用,根据用户在首页的访问路径,选择初始化策略,操作体验会更好。以携程 App 为例,假设用户进入首页后,停留在西双版纳自由行区域,直接加载 WebView 和模板,两者同时运行,此时首屏主要工作就变成加载接口请求数据和渲染模板部分的工作了。
为了减少 WebView 再次初始化的时间,我们可以在使用完成后不进行注销,将里面数据清空,放进 WebView 池子里面,下次使用时,直接拿过来注入数据使用即可。注意,使用时,要对 WebView 池子进行容量限制,避免出现内存问题。
另外还需注意一点,由于初始化过程本身就需要时间,我们如果直接把它放到 UI 线程,会导致打开页面卡死甚至 ANR(Application Not Responding,应用无响应),所以,我建议将初始化过程放到子线程中,初始化结束后才添加到 View 树中。
- 资源预加载
资源预加载,是指提前在初始化的 WebView 里面放置一个静态资源列表,后续加载东西时,由于这部分资源已经被强缓存了,页面显示速度会更快。那么,要预加载的静态资源一般可以放哪些呢?
一定时间内(如 1 周)不变的外链;
一些基础框架,多端适配的 JS(如 adapter.js),性能统计的 JS(如 perf.js)或者第三方库(如 vue.js);
基础布局的 CSS 如 base.css。
一般在 App 启动时,系统就加载一个带有通用资源模版的 HTML 页面,虽然这些静态资源不经常变化,但如果变化呢?怎么避免因变化导致 App 频繁发布版本的麻烦呢?
一个办法是通过静态资源预加载后台进行管理。具体的话,我们不需要从 0 到 1 搭建,只需要在离线包后台添加一个栏目即可。
在业务接入预加载功能时,前端工程师通过静态资源预加载后台发布出一个静态资源列表页,然后把它的 URL 提供给 App,App 启动时会对这个 URL 下页面中的静态资源进行预加载。之后,前端工程师就可以查看静态资源的编号 ID、URL 和类型,进行删除、添加等管理操作。
不要小看这一点,通过这种做法,我们手机列表页 13 个文件缓存后,首屏时间从 1050ms 降低到了 900ms。
- 数据接口请求优化
数据接口请求优化,主要是通过同域名策略和客户端代理数据请求来实现。
其中,同域名策略是指前端页面和资源加载,尽量和 App 使用的数据接口在同一个域名下,这样域名对应的 DNS 解析出来的 IP,由于已经在系统级别上被缓存过了,大大降低了加载时间。
比如,58 App 客户端请求域名主要集中在 api.58.com,请求完这个地址后,DNS 将会被系统缓存,而前端资源的请求地址在 i.58.com,打开 WebView 后,由于请求了不同的地址,还需要重新去 DNS 服务器去查询 i.58.com 对应的 IP,而如果前端也改到 api.58.com 后,DNS 查询的时间可以从原来的将近 80ms 降低到几 ms。
客户端代理数据请求,则是指把前端的数据请求拦截起来,通过客户端去发送数据请求。因为正常的页面加载顺序是,前端在 HTML,CSS,JS 拉取下来之后才开始由 JS 发起前端的 ajax 请求,获取到数据后程序才开始进行填充。而我们通过客户端代理数据请求,可以把前端的 ajax 请求提前到与页面加载同时进行,由客户端请求数据,等 H5 加载完毕,直接向客户端索要即可。如此一来,便缩短了总体的页面加载时间。
注意,这里的数据拦截环节,Android 端可以重写 WebViewClient 的 shouldInterceptRequest 方法,iOS 端没有类似的方法,只能通过私有 API 方案、自定义协议方案和 LocalWebServer 来实现。
容器升级
iOS 升级 WKWebView
Android 升级 X5 内核
小结
好了,以上就是 WebView 性能优化和代码架构层的优化,这里面有一些注意事项。WebView 会占用一定的内存,如果使用 WebView 缓存池进行优化,会出现内存占用多的问题,我们可以将 WebView 放到独立进程中,避免内存泄漏。当然,WebView 独立进程的话,就需要解决进程间调用问题,一般可以直接使用 Aidl 来解决。
# 预请求、预加载、预渲染
- 预请求
想要通过拉取后端接口来降低首屏时间,我们需要先实现接口的预加载。而实现它要先解决预请求的逻辑,也就是统一拼装请求参数的逻辑。
具体怎么进行统一拼参呢?这就涉及前端正常的数据请求过程了。
以机票业务为例,我们进入列表页后,输入出发地和目的地后,比如从北京到深圳,选择日期为 2021-06-06,前端应用通过解析页面 URL 路径,拿到所需的一些参数(如 from=shanghai&to=beijing&date=20210606),然后调用 Native 的 schema 进入参数解析环节,找到 Native 对应的协议和参数(如://search?terminal=app),然后再通过参数初始化,拼装成对应的参数(如://search?terminal=app&from=shanghai&to=beijing&date=20210606)。
如果预请求走上述流程的话,面临的一个问题是,没有预请求的页面 URL 参数,也没法通过 Native 获取到。这需要自己根据逻辑拼装,所以往往会单独做出一套流程,结果就是不但容易出错,还会因为需要用类似两份代码去实现这个功能,反过来拉长页面的首屏时间。所以,我们使用了同样的流程,将预请求封装成 preReq 功能,把所有的功能都包括起来,用同一份代码实现。
在做完这个统一拼参逻辑后,预请求实现起来就容易了。具体来说,如果你已经使用了 Native 统一请求,直接走客户端逻辑发送即可。如果还没有走 Native 统一请求,
我们可以借助 Axios 库函数来完成。
第一步,我们需要封装一下 Axios 库函数,在 post 和 get 之前,通过添加一个钩子函数 BeforeFetch,对 URL 参数进行解析和 Native 参数补全。
第二步,业务侧使用与请求时,因为 Axios 库是整体打包引入的,所以使用时,可以直接使用 Axios.fetch 方法来实现预请求功能。
- 预加载
在完成预请求参数拼装之后,紧接着就是预加载逻辑了。首先是要把握预加载的时机。以机票列表页为例,我们需要判断用户操作的特定路径。如果用户操作命中了这个特定路径,就会做预加载,去请求列表页的接口。
这个路径是我们和后端的一个约定,有具体的编号,比如用户“进入首页”编号是 0,“输入出发地和目的地”操作路径是 1,“输入日期”操作路径是 2,“切换关键词”是 3,点击“我的位置”是 4。
后端在用户进入列表页时,以接口的方式返回一个操作路径的数组,当用户的操作路径命中这个数组后,比如 [1,2,3],意思是用户从首页进入,选择了出发地和目的地,并且输入了日期,接下来开始进行预加载。
当用户点击“开始搜索”后,前端应用就会去判断有没有预加载下级页面(搜索页面)的接口,是否有搜索页的预加载数据,而且这个数据又没有过期,就直接跳转下级页;如果没有可用的预加载数据,此时我们进行一次搜索页的预加载,减少从列表页到搜索页的跳转时间和搜索页的初始化时间。
预加载是怎么实现的呢?如果 Native 已经提供这个功能,我们直接使用 Native 的预加载接口即可。反之,我们还是需要扩展 Axios 库函数来实现。
具体来说,在 Axios 进行数据请求后,封装一个 afterFetch 的钩子方法,负责将加载完成的数据存储到本地,供下一个路由使用。这就完成了预加载。当业务侧使用时,先在 aftereFetch 钩子里面定义好取到数据后做什么,然后直接使用 fetch 方法即可。
比如手机列表页,有一个场景是提前获取下一页的数据做排版,数据预加载完成后,在 afterFetch 里面就会将这些数据存储到内存中。
需要注意的是,即便是预加载,也要做好缓存处理。 我们要先在内存里面 check 一下是否存在之前预加载的数据。有的话,直接用预加载数据,做后续操作,如果没有,就继续走预加载逻辑,然后设置缓存数据。
- 预渲染
预渲染是指在用户访问这个页面之前,完成页面渲染的准备。还是以机票列表页为例,比如说用户命中特定路径的时候,前端进行判断并会把搜索结果页先渲染出来,只不过在列表页可视区域下方,用户是不可见的。
当用户点击开始搜索时,前端会去 check,如果已经有了预渲染的页面,只需要把页面显示出来的操作, push 到顶层即可。这样就省去了初始化页面、请求数据和渲染的时间。
具体怎么实现呢?这就需要用到 “客户端”渲染技术了。你看我在这里加了个引号,其实就是说,它有别于 CSR,而是 NSR(Native side rendering,客户端渲染),即通过客户端(Native 侧)进行页面结构拼接,进而实现页面渲染的处理技术。具体见下图所示。
页面按需预先渲染是为了一次性解决网页加载过程中各个环节问题所制定的优化方案,它基于客户端渲染( NSR,Native Side Rending )的思想实现,而 NSR 又是由服务端渲染( SSR,Server Side Rendering )引申而来的,NSR 的本质是分布式的 SSR。
SSR 是指在服务端完成网页的渲染,在服务端完成页面模板、数据填充、页面排版等工作,然后将完整的 HTML 内容返回给浏览器。由于所有的渲染工作都在服务端完成,因此网页加载耗时会有所降低。但是这种优化方案导致前端页面的渲染需要在服务端完成,并不能很好进行前后端职责分离,而且页面加载过程中不可避免仍会有一段白屏时间,同时对于服务端的负载要求也会比较高。
所以这里我们采用了 NSR 的方式,在用户登录成功后,借助 WebView 控件启用一个 JS-Runtime ,在用户手动跳转目标网页之前提前在后台加载本地离线组件包中的资源并发送网络请求获取业务数据,再进行排版和渲染,动态直出,最后将网页设置到内存级别的 MemoryCache 中,从而达到点开即看的效果。退一步说,即便用户在点开页面时以上流程并未全部执行完毕,也会因为提前执行了其中部分流程,较传统模式降低一些用户感知时间。
但是另一方面,预先渲染也是一柄双刃剑,它本质上是利用空间换取时间,会占用大量额外的内存空间。但内存在一些较低端的移动设备上是十分宝贵的,过高的内存占用会引发一系列的体验和稳定性的问题。所以如何在尽可能低的内存占用情况下完成预先渲染,是需要仔细权衡的。最终我们决定按需只对 App 内入口级的几个重要页面开放了此功能,尽量避免占用过高的内存空间。
页面按需预先渲染的收益是十分显著的,经数据统计,目标页面的平均网页加载耗时 iOS 从 2500ms 降低到了 231ms ,Android 从 2803ms 降低到了 628ms。
# 总结
- 预置离线包
注意离线包命中率和兜底策略
- 预加载(Webview预初始化)
注意内存占用的情况
请求前置/并行请求
预渲染/骨架屏/NSR
WebView池化技术
# 参考
← JsBridge原理 Hybrid问题集锦 →