百度爱番番移动端网页秒开实践
全文约5800字,预计阅读时间12分钟
导读
目前是移动互联网全面发展的时代,随着产品迭代速度的不断提升,网页在 App 开发中占据的比例也与日俱增。网页开发不仅可以较低成本的实现 iOS、Android 和 Web 等多端复用节省人力,还能够有效减少程序安装包的体积,更重要的是可以冠冕堂皇的规避 Apple 对 iOS 端热更新的封锁。
但另一方面,移动端网页相较于原生页面而言在加载速度方面仍有比较明显的差距。如何最大程度的减小这种差距,为用户提供一个良好的交互体验就成了每一个移动开发者都需要掌握的能力。本文将结合百度爱番番前端团队在过去一段时间里的实际研发经历,为大家从体验、性能、安全等方面系统分析并优化解决移动端网页开发所面临的一些问题,让用户在 App 中打开网页时能够做到秒开,如原生页面般流畅。
1. 明确问题:网页缓慢
现阶段移动端设备相较于传统的桌面级电脑还有很多不足之处,“ 带宽低 ”、“ 速度慢 ”、“ 内存小 ”是三个最明显的瓶颈,而这些却恰恰是网页所依赖的重点。
其中首当其冲的就是网络条件,尽管近年来伴随着 4G、5G 的普及用户手机的网速不断提升,但是移动端的网络延迟永远是不确定的,它会受到各种条件的限制,现实生活中仍然会有很多情况下会导致用户的网速不佳。而这种制约对网页而言是十分严重的,它会使得网页加载过程变得更加漫长,甚至是失败。
另一个方面是处理器的速度,现今的网页承载的信息越来越多,界面交互和业务逻辑也越来越复杂,过多的计算量会让网页的处理时间增加。而用户设备的硬件配置又是多种多样的,这个问题在其中占大多数的中低端机型上会更加明显。
对于网页而言,设备的内存大小也很重要的,更大的内存代表可以支持更多的网页内容。反之内存紧张会让 APP 在处理网页时变得效率低下,频繁出现卡顿问题,最致命的是会更容易引发 OOM( Out Of Memory )现象导致程序崩溃。
随着技术的发展,移动端交互体验的不断提升,人们对网页加载缓慢的忍耐度也越来越低。有调查表明,超过 2/3 的用户认为对于网页来说加载速度是影响浏览体验最大的一个因素。当移动端的网页加载时间超过3秒,过半的用户会选择直接离开。所以一个快速的加载过程,是我们提高 APP 网页质量的重要一环。
2. 分析痛点:加载耗时
在讨论如何提升网页加载速度前,需要先以数字的形式给出网页加载缓慢的定义,明确一个基准点——如何定义用户所感受到的网页加载耗时。这里有一个计算公式:
网页加载耗时 = 网页加载完成的时间 – 页面开始加载的时间
其中页面开始加载的时间比较容易判断,从用户的角度来看,当他在上级页面点击某处跳转网页的时候就可以理解为页面开始加载了。
关键是如何界定网页加载完成的时间,从客户端开发的方向来说,不论是 iOS 还是 Android,作为承载网页的 WebView 控件,都有一个 loadFinish 回调表示网页加载完成,但是实际上它并不能真实反应用户的实际感官体验。
这里我们先来梳理一下移动端加载一个普通网页大致需要经过哪些步骤:
由此可见,用户在打开网页的整个过程中先后会经历 无反馈、白屏、loading 这几个阶段,而在 WebView 控件 loadFinish 后,页面基本上还停留在 loading 界面。所以上面公式里提到的网页加载完成一般可以理解为业务数据渲染完成的时候,因为只有在这之后用户才能够真正看见想要的内容。
换言之网页加载缓慢体现在数值上来说就是指用户点击开启网页到业务数据渲染完成这段时间差过大,那么如何降低这个时间差就是我们亟待解决的问题。
3. 提供方案:优化实践
针对移动端网页加载时无反馈、白屏、loading 这三个阶段,爱番番前端团队从前面提到的网络条件、处理速度、内存占用这几个点进行切入,针对缓存系统、网页渲染机制、浏览器内核、网络请求效率等方向,制定了一系列的优化方案。
首先是 “ 独立组件打包分发 ” ,其后在此基础之上先后进行了 “ 页面按需预先渲染 ”、“ 网页容器预初始化 ” 和 “ 业务请求前置执行 ” 等处理。
我们的目标是使爱番番内各主要网页的加载耗时降低到 1s 以内。
3.1 独立组件打包分发
通常来说,加载网页时静态资源的下载是非常耗时的,而且这个过程也是最容易受到网络环境影响的。为了解决这个问题,我们将一组独立网页的 HTML、JavaScript、CSS 等静态资源压缩打包,形成一个离线组件包,在 App 启动后预先下载并解压到手机本地,当用户打开目标网页时,直接从本地加载这些资源。
而且一个 App 中根据业务模块可以分为多个离线组件包,每个离线包都拥有唯一的版本号,通过后端搭建的离线包平台进行管理和下发,客户端会在指定的时机和平台同步离线包的版本信息,当有版本更新的时候,会在后台批量静默下载并更新本地文件,用户在正常操作 App 时基本处在一个无感知的状态。
通过独立组件打包分发的方案可以绕过耗时的静态资源下载环节,网页加载过程中的白屏时间也能够得到大幅降低。
3.2 页面按需预先渲染
页面按需预先渲染是为了一次性解决网页加载过程中各个环节问题所制定的优化方案,它基于客户端渲染( 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。
3.3 网页容器预初始化
移动端和 Web 端网页的加载过程并不完全一致,当App启动时默认是不会自动初始化内嵌浏览器内核的,只有当作为网页容器的 WebView 初始化时才会执行。所以针对这一点我们设计了网页容器预初始化的优化方案。
3.3.1 容器预加载
容器预加载是网页容器预初始化方案的核心,即在用户开启网页前预先进行 WebView 控件的初始化以及相关资源和框架的加载以降低网页加载耗时。
爱番番在下载和更新离线组件包后,会在后台初始化 WebView 控件,并加载组件包内的一个中间态网页,提前加载相关资源和框架,中间页加载成功后 WebView 会被放置在容器池中,开始监听一个自定的 JS 方法并进行等待。
当用户点击开启目标网页时,会先根据所在离线组件包内的配置文件,判断该该页面是否开启了容器预加载的功能,如果开启了会向容器池请求获取初始化好的 WebView,获取成功后调用自定的 JS 方法通知 H5 端,最后 H5 端通过 Vue Router 跳转目标页面。
并且在容器池向外交付 WebView 时,会自动重新初始化一个新的 WebView 开始加载中间页,为下一次用户操作做准备。
因为在容器池中获取的 WebView 已经提前进行了初始化,并且完成了组件包内一些公共资源和框架的加载,所以在当用户开启网页时所见到的白屏阶段就会大幅缩短,网页加载耗时也会显著降低。具体体现在数据方面,使用该方案优化的网页在 iOS 和 Android 双端加载速度均提升了 200~300ms 。
3.3.2 微前端架构
在前面的 “ 容器预加载 ” 方案中,因为各业务离线组件包内的页面间是相互独立的,无法通过Vue Router 跳转至其他组件包内的页面,所以需要在容器池中为每个业务组件包都提供一个WebView 控件,用于加载中间态网页。随着业务组件包数量的不断增多,容器池中的 WebView 也会同步增多,如此会大幅提高内存占用,而如前文所说,较高的内存占用可能会引发程序运行卡顿,甚至崩溃等问题,这在较低端的设备上是尤其致命的。
而微前端方案则很好的解决了这个难题,所谓的微前端主要是将原先的多个业务离线组件包聚合成为了一个系统,实现系统内的整体调度,完成组件包间的交互。爱番番采用的是 Master-Slave 架构,即主-从式设计:
Master:公共组件包,负责加载其他组件包,并且提供公共资源;
Slave:各业务组件包,负责不同模块的具体业务代码。
其中 Master 和 Slave 之间的数据交互在本地主要依赖 Symbolic Link 实现,Native 端会为包括公共组件包在内的每个离线组件包提供一个对应所在本地路径的 Symbolic Link。容器池中仅为公共组件包提供一个 WebView 控件,而公共组件包可以通过 Symbolic Link 进行本地寻址,找到对应业务组件包内页面的路径,再使用 Vue Router 就可以完成 “ 容器预加载 ” 中的跳转逻辑。这样一来就在原有 n个 组件包的条件下,将组件容器池中的 WebView 控件从 n个缩减为了 1个,内存占用也缩减到了原来的 1/n,有效降低了程序的卡顿率和崩溃率。
另一方面,公共组件包也将各业务组件包内的一些公共框架资源提取了出来,如 Vue Router等,各业务组件包在使用它们时,同样可以通过 Symbolic Link 定位到公共组件包中的对应框架资源。这样做的好处在于可以对公共资源进行统一管理,并在一定程度上降低了离线组件包整体的体积。
通过微前端架构的优化,使得我们的 App 在展示网页时明显降低了内存占用,避免了很多高内存带来的问题,而且各业务离线组件包的体积也都有所缩小。
3.3.3 预置离线包
因前面提到的 “ 微前端架构 ” 中采用了主-从式设计,作为 Master 的公共离线组件包内包含了业务离线组件包( Slave )所需要的一些公共框架资源。当用户打开业务组件包的某个页面时,公共组件包的存在就成为了这个页面能正常运行的前提条件,而当用户初次安装启动 APP时,必定有一个从离线包平台下载公共组件包的过程,如果下载过慢会导致期间其他所有业务组件包都无法正常使用。
为了避免此类问题的出现,我们采用了将公共离线组件包预置进 APP 安装包内的方式来确保其优先性,并且它会随着 App 发版进行更新。APP 在初次安装启动后,一般会跳过预置包的下载流程,将其直接从 APP 复制到本地沙盒中。
而且另一方面,预置离线组件包的方案不仅适用于公共组件包,也适用于业务组件包,尤其对于其中一些体积较大下载耗时较长的包,可以在 APP 初次安装启动时为用户提供更加良好的交互体验。
3.4 业务请求前置执行
网页大多需要依赖服务器提供业务数据驱动页面展示内容。在前面分析网页加载耗时的过程中可以得知,在传统模式下业务网络请求要在 WebView 容器 loadFinish 后才会执行,针对这一点我们设计了业务请求前置执行的优化方案。
3.4.1 客户端请求
为了支持业务请求前置执行的方案,首先需要对网页中的网络请求进行客户端化改造,即由Native 端来处理网页中的业务数据网络请求。并且用客户端请求和服务器进行交互,还可以解决原先使用 XHR 请求的一系列相关问题,比如跨域限制、测试联调时无法直连后端,网络层配置逻辑不统一等。
具体步骤方面,在网页进行网络请求时,首先由 H5 端配置业务请求信息,如请求地址、接口入参、自定义请求头等,并通过 JS-Bridge 将这些内容发送到 Native 端,Native 端再执行一些网络层的统一配置和优化后发送请求,比如添加 Cookie 和一些必要的请求头数据等。最后在收到 response 后再次通过 JS-Bridge 将内容返回给 H5 端。
这样一方面使得前端开发人员在调试时不用做任何配置和代理即可直连服务器,避免了传统模式下耗时的发包流程,大幅提升了迭代开发的效率。另一方面,iOS 端的 WKWebView 加强了安全性限制,在访问本地网页时禁止跨域请求,使用客户端请求可以完美规避这个限制。最后它还可以将网页中所有的请求都在Native端进行集中管理和统一优化,这也为之后的“网络预加载”的实现提供了前提条件。
3.4.2 网络预加载
优化前(图1):
优化后(图2):
网络预加载是业务请求前置执行方案的核心。
一般来说,网页中的业务网络请求最早可以在在页面构建 DOM 完成后执行,即 图1 中的 B点以后,而在爱番番中,大部分网页的业务请求相关参数都依赖于一些页面级的入参,但是页面入参需要等待本地 JS 脚本( 爱番番 App 内置的 JS 文件,其中包含 H5 端和 Native 端间交互的众多逻辑,是网页正常加载的前提条件 )注入完成之后才可以获取,又因为本地JS脚本需要在 loadFinish( 即 图1 中的 B点 )之后才执行注入,所以对我们的大多数网页来说,发送业务请求的最早时机在 图1 中的 C点 以后。并且按前文 “ 客户端请求 ” 的技术方案,网页发送网络请求需要先由 H5端 配置请求信息,再通过 JS-Bridge 申请 Native 端来真正执行。所以在进行网络预加载优化以前,业务网络请求的发送时机为 图1 中的 D点。
这里总结一下,传统方式下网页加载耗时可以简单分为两个部分:
解析静态资源并构建 DOM 结构和本地 JS 脚本注入( 即 A-D ):这里我们将此部分称为 Part1,它的耗时主要取决于前端;
业务网络请求的具体执行过程( 即 D-F ):这里我们将此部分称为 Part2,它的耗时主要取决于用户的网络环境和后端;
Part1 和 Part2 二者之间是前后串行的关系。那么我们为了尽可能的降低用户感知时间,可以将 Part2 尽量前置,将其与 Part1 做并行处理。
网络预加载正是基于这种思想实现的,首先前端开发人员会先将要执行预加载的网络请求的相关信息写入对应离线组件包的配置文件当中,当用户打开该离线包的指定网页时,会在 Native端直接从组件包的配置信息中读取信息并立即开始发送网络请求。这个动作会在子线程中执行,几乎和 WebView 控件的初始化同时开始。
而之后获取 response 的时机(即图2中的I点)根据 Part2 耗时的不同,大体可分为2种情况:
在Part1 完成之前(即I点在J点之前、G点之后,如图2所示):这时 Native 端会将网络预加载获取到的 response 进行缓存并开始等待,当 Part1 完成后 Native 端会收到H5端以正常方式发来的同一请求的申请,此时立即通过 JS-Bridge 将缓存的 response 交付给H5端使用,并销毁缓存。另一方面,若等待时长超过一定时间限制,Native端也会销毁缓存,视作此次网络预加载行为失败。如果成功,这种情况下的收益为整个 Part2 的耗时(D-F);
在 Part1 完成之后(即I点在J点之后):这种情况下,当网络预加载的请求仍在途中时,Native 端就收到了 H5 端正常方式发来的请求申请,此时 Native 端会拦截本次的请求申请,继续等待前面网络预加载的请求返回,待返回后再通过 JS-Bridge 将 response 交付给 H5端使用。这种情况下的收益为 Part1 的耗时(A-D)。
以上就是网络预加载的主要实现原理,通过这种优化方案,在用户打开移动端网页时,可以很大程度的降低用户在开启网页时的可感知时长,体现在数据方面,爱番番内使用此方案的网页加载耗时降低了 300 ~ 600ms 。
4. 总结收益:持续探索
除了以上提到的这些,百度爱番番前端团队为了降低网页加载耗时还做了许多工作,比如 iOS升级 WKWebView、Android 升级 X5 内核等等,限于篇幅原因,在此就不做展开了。
通过这些优化手段,爱番番移动端各一级核心页面的加载耗时从平均 2-3秒 收敛到了 1秒 以内,基本上达到了秒开的既定目标。
当然,我们未来需要做的还有很多,后续也会持续推进移动端网页的性能优化工作,让用户在使用爱番番时能够享受到接近原生页面的体验。
5. 作者介绍
时恩宝贝,百度爱番番前端高级工程师,拥有多年研发经历。擅长iOS、Android、Web多端开发。