如何设计一个前端 + 移动端离线包方案?
我在《导读:前端技术发展回顾和架构升级之路》中提到了多种渲染方式,而以离线包方案为代表的方案,属于 NSR(Native Side Rendering),这是大前端配合的典型案例。这一讲,我们就从 0 到 1 分析一个前端+移动端离线包方案。
当然,设计离线包方案并不是我们的终极目的,通过离线包方案的源起和落地,我们也会梳理整个 hybrid 页面的优化相关方案。
从流程图分析 hybrid 性能痛点
简单来说,离线包是解决性能问题、提升 hybrid 页面可用性的重要方案。hybrid 页面性能具有一定特殊性,它是客户端和前端的衔接之处,因此针对 hybrid 页面的性能一直较为复杂。我们从加载一个 hybrid 页面的流程图来分析,如下图:
hybrid 页面加载流程图
我们看上图,从一个原生页面点击按钮,打开一个 hybrid 页面,首先经过原生页面路由,识别到“这是在访问一个 hybrid 页面”,此时原生会启动一个 WebView 容器,接着就是一个正常的前端加载并渲染页面的流程了。
图中以前端 CSR 方式为例,首先请求并加载 HTML,接着以 HTML 为起点,请求 JavaScript、CSS 等静态资源,并由 JavaScript 发送数据请求,最终完成页面内容的加载和渲染。
整个路径分成了两大路径:客户端阶段、前端阶段,单一一个阶段我们都有多种优化方法,比如对于 WebView 容器的启动,客户端可以提前启动 WebView 容器池,这样在真正访问 hybrid 页面时,可以复用已储备好的 WebView 容器。再比如,前端渲染架构我们可以从 CSR 切换到 SSR,这样在一定程度上能保证首屏页面的直出,达到更好的 FMP、FCP 等时间。
相应优化策略
我们结合下图,简单总结一下各阶段、各个方向能够做的优化:
在前端业务层面上,我们可以做到以下几个方向的优化。
静态资源瘦身:将 JavaScript 和 CSS 等静态资源进行充分压缩,或实施合理的分割策略,能够有效地减少对于静态资源的网络请求时间、响应脚本的解析时间等。
静态数据占位:是一种使用静态数据预先填充页面,使得页面能够更迅速地呈现内容,并在数据请求成功后再加载真实数据的做法。静态数据往往来自缓存内容,甚至极端一点,可以静态内置到资源包中。
静态资源缓存:是一种常用的工程手段,静态资源通过合理的缓存策略,减少网络 IO,以此提升性能。
服务端渲染:即 SSR 渲染,前面提到过,服务端渲染可以直出带有数据的 HTML 内容,能够有效优化 FMP/FCP 等指标。
骨架屏:广义的骨架屏甚至可以包括 Loading Icon 在内,这其实是一种提升用户体验的关键手段。在内容渲染完成之前,我们可以加载一段表意内容的 Icon 或者占位区块 placeholder,帮助用户缓解焦虑的心理,营造一种“页面加载渲染足够快”的感觉。
首屏分屏或按需渲染:这种手段和静态资源瘦身有一定关系。我们将非关键的内容延迟按需渲染,而不是在首次加载渲染时就一并完成,这样可以优先保证视口内的内容展现。
关键路径优化:关键路径或关键渲染路径,是指页面在渲染内容完成前,必须先要完成的步骤。对于关键渲染路径的优化,其实已经被前面几点有所囊括了。
下面我们再从浏览器的关键渲染步骤来了解,展现从 HTML、JavaScript、CSS 字节到渲染内容到屏幕上的流程,如下图:
浏览器的关键渲染步骤
图中主要步骤:
解析 HTML 并构建 DOM tree;
并行解析 CSS 并构建 CSSOM;
将 DOM 与 CSSOM 合成为 Render tree;
根据 Render tree 合成 Layout,并完成绘制。
由上述流程我们可以总结出,优化关键常规方式为:减少关键资源的数量(消除阻塞或延迟解析的 JavaScript,避免使用 CSS import);减少关键资源的 size;优化关键资源的加载顺序,充分并行化。
上面优化相关内容,相信你并不陌生。接下来我们再看看客户端层面容器层的优化方案:
容器预热
数据预取
跨栈数据传递
小程序化
其中小程序化能够充分利用客户端开发的性能优势,但与主题不相关,我们暂且不赘述;容器预热和数据预取也是常规通用优化手段,其本质都是“先抢跑”。
而今天的主题,离线包优化策略主要属于通用层优化方案,接下来我们进入离线包的设计环节。
离线包方案
自从 GMTC2019 全球大前端技术大会上 UC 团队提到了 0.3 秒的“闪开方案”以来,很多团队已经将离线包方案落地并成熟发展了。事实上,该方案的提出可以追溯到更早的时间。总之,不论你是否了解过离线包方案,现在该技术已经并不新鲜了。其核心思路是:客户端提前下载好 HTML 模版,在用户交互时,由客户端完成数据请求并渲染 HTML,最终交给 WebView 容器加载。
换句话说,离线包方案为代表的 NSR,就是客户端版本的 SSR。各个团队可能在实现思路的细节上有所不同,但主要流程基本如下图:
根据上图,我们总结基本流程如下。
用户打开 hybrid 页面。
在原生客户端路由阶段,判断离线包是否可用:
如果内置的离线包版本不可用或已经落后线上版本,则走在线逻辑,即正常的 WebView 加载前端页面,由前端页面加载渲染页面的流程;
如果内置的离线包版本可用,则走离线包流程。
客户端启动 WebVeiw;
客户端并行请求业务数据接口;
客户端并行加载本地模版;
接下来,客户端将执行权和必要数据交给前端,由 WebView 完成页面的渲染。
整个流程简单清晰,但有几个主要环节需要我们思考:
如何检测离线包版本,如何维护离线包
如何生产离线包模版
客户端如何“知道”该页面需要请求哪些业务数据
接下来我们就一一分析。
离线包服务平台
第一个问题:如何检测离线包版本,如何维护离线包?这是一个可大可小的话题。简单来说,我们可以由开发者手动打出离线包,并内置在应用包中,随着客户端发版进行更新。但是这样做的问题非常明显:
更新周期太慢,需要和客户端发版绑定;
手动流程过多,不够自动化、工程化。
一个更合理的方式是实现“离线包平台”,该平台需要完成以下任务。
获取离线包
获取离线包我们可以考虑主动和被动模式,被动模式需要开发者构建出离线包后,手动上传到离线包平台;被动模式则更为智能,可以绑定前端 CI/CD 流程,在前端每次发版上线时,自动完成离线包构建,在构建成功后,由 CI/CD 环节主动请求离线包接口,将离线包推送到离线平台。
提供离线包查询服务
提供一个 HTTP 服务,该服务用于提供离线包状态的查询。比如,在每次启动应用时,客户端查询该服务,获取各个业务离线包的最新/稳定版本,客户端以此判断是否可以应用本地离线包资源。
离线包获取服务
提供一个 HTTP 服务,该服务用于提供离线包资源。离线包的下发方式可以按照各个离线包版本下发,也可以将离线包内静态资源完全扁平化,进行增量下发。需要提出的是,扁平化增量下发,可以较大限度地使用离线包资源。比如某次离线包版本构建过程中,v1 和 v2 版本可能会存在较多没有变化的静态资源,此时就可以复用已有静态资源,减少带宽和存储压力。
整体离线包服务平台我们可以抽象为下图:
离线服务平台,按照离线版本整体下发资源如下图:
离线服务平台,扁平化增量下发离线资源如下图:
离线包构建能力
了解了离线包服务平台,我们再思考一个问题:离线包和传统的静态资源会有区别,那么我们如何构建出一个离线包呢?
我们以“客户端发送数据请求”的离线包模式为例,既然数据请求需要客户端发出,那么离线包资源就需要声明“该页面需要哪些数据请求”。因此离线包就需要有一个 json 文件进行配置声明:
// 一个描述这个离线包的 json 文件 appConfig.json
{
"appid": XXX,
"name":"template1",
"version": "2020.1204.162513",
"author": "xxxx",
"description": "XXX页面",
"check_integrity": true,
"home": "https://www.XXX.com/XXX",
"host": {"online":"XXX.com"},
"scheme": {"android":{"online":"https"},
"iOS":{"online":"resource"}},
"expectedFiles":["1.js","2.js","1.css","2.css","index.html"],
"created_time":1607070313,
"sdk_min":"1.0.0",
"sdk_max":"2.0.0",
"dataApi": ["xxxx"]
}
上面这个 appConfig.json 描述了该离线包的关键信息,比如dataApi
表明业务所需要的数据接口,一般这里可以放置首屏关键请求,由客户端发出这些请求并由 template 渲染。appid
表明了该业务 ID,expectedFiles
字段表明了该业务所需的离线包资源,这些资源一并内置于离线包当中。
对于expectedFiles
字段声明的资源,前端依然可以由 Webpack 等构建工具打包完成。我们借助 Webpack 能力,可以通过编写一个 Webpack 插件,来获取dataApi
字段内容,当然初期实现,也可以由开发者手动维护该字段。
方案持续优化
上述设计基本已经囊括了一个离线包方案的流程了,但是一个工程方案还需要考虑更多的细节内容。下面我们来对更多优化点进行分析。
离线包可用性和使用命中率
试想,如果我们的业务迭代频繁,相对应的,离线包也就迭代频繁,那么可用离线包的命中率就会降低,效果会打上折扣。同时离线包的下载以及解压过程也可能会出现错误,导致离线包不可用。
为此,一般的做法可以考虑设计重试机制和定时轮询。在网络条件允许的情况下,为了减少网络因素导致的失败,我们可以设置最大重试次数,并设定 15s 或一定时间的间隔,进行离线包的下载重试。
同时,为了防止移动运营商的劫持,我们还需要保证离线包的完整性,即检查离线包文件是否被篡改过。一般在下发离线包时,同时下发文件签名,在离线包下载完成后,由客户端进行签名校验。
另外定时轮询机制能够定时去离线包服务平台拉取最新版本的离线包,这样能够防止离线包下载不及时,是对仅在“App 启动时加载离线包”策略的很好补充。当然你也可以做到服务端主动推送离线包,但是该方案成本较高。
离线包安全性考量
离线包策略从本质上改变了传统 hybrid 页面加载和渲染流程技术较为激进的弊端,我们需要从各方面考量离线包的安全性。一般可以设计灰度发布状态,即在全量铺开某离线包前,先小流量测试,观察一部分用户的使用情况。
另外,还要建立健全的 fallback 机制,在发现当前最新版本离线包不可用时,可以迅速切到稳定可用的版本,或者回退到线上传统机制。
实际情况中,我们总结出需要使用 fallback 机制的 case 包括但不限于:
离线包解压缩失败;
离线包服务平台接口超时;
使用增量 diff 时,资源合并失败。
用户流量考量
为了减少每次下载或更新离线包时对流量的消耗,我们前文也提到了增量更新的机制。一种思路是可以在客户端内根据 hash 值进行增量更新,另一个思路是利用 git-diff 时,根据更改的文件进行文件变更的增量包设计。
另外我们也可以在具体文件内容层面进行 diff,具体策略可以使用 Node.js 的 bsdiff/bspatch 二进制差量算法工具包 bsdp,但影响 bsdiff 生成 patch 包体积因素往往也会受到:
压缩包压缩登记
压缩包修改内容
的影响,且 patch 包的生成具有一定的风险,可以按照业务和团队实际情况进行选型。
另外,还有些优化打磨手段值得一提,比如
离线包资源的核心静态文件可以和图片等富媒体资源文件缓存分离
这样可以更方便地管理缓存,且离线包核心静态资源也可以整体提前加载进内存,减少磁盘 IO 耗时。
使用离线包之后是否会对现有的 AB Testing 策略、数据打点策略有冲突
离线包渲染后,在用户真实访问之前,是不能够将预创建页面的 UV、PV、数据曝光等埋点上报的,否则会干扰正常的数据统计。
HTML 文件是否应该作为离线包资源的一部分
目前主流方案中,很多方案也将 HTML 文件作为离线包资源的一部分。另一种方案是只缓存 JavaScript、CSS 文件,而 HTML 还需要使用在线策略。
总结
这一讲,我们分析了加载一个 hybrid 页面的流程中前端业务层、容器层、通用层的优化策略,并着重分析了离线包方案,并加以优化。本讲内容总结如下:
性能优化是一个宏大的话题,我们不仅需要在前端领域做到性能最优,还要有更高的视角,在业务全链路上,做到性能最优。而离线包方案就是一个典型的例子,它突破了传统狭隘前端,需要各个业务团队协调配合。比如客户端业务团队、客户端基础(容器)团队、前端团队、数据分析团队、测试团队等。
架构一定需要跨栈,一定需要全链路交付。本小节只是一个例子,希望你能够统筹更多技术领域和方案,做到精益求精。最后给大家留一个思考题,你平时是如何做性能优化的呢?欢迎在留言区和我分享你的见解。
脚手架是工程化中不可缺少的一环,对于前端来说,从零开始建立一个项目是复杂的,因此也就存在了较多类型的脚手架,下一讲,我们就深入这些脚手架的原理,设计一个“万能”项目脚手架。
# 精选评论
# *静:
这篇文章相见恨晚,去年断断续续花了半年的时间打磨离线包,踩了各种坑,这篇文章基本上都概括全了
# **4057:
请问,文中所述的离线包方案与最近流行的PWA方案实现思路上有什么联系或者区别
# 讲师回复:
离线包是自己动手就能做的,PWA 是巨头推动,需要终端配合的方案,没有太多可比性
# *聪:
老师,离线包和传统的静态资源的区别还是没太大明白,能否再详细讲讲?
# 讲师回复:
根本区别是是否走网络请求
# **金:
离线包只缓存js,css文件,也就是html文件中的js,css路径还是http地址,那是需要webview进行请求拦截或者代理吗?
# 讲师回复:
取决于你。一般设计都需要