获得的APP是三年多的产品,最初是纯Native方式开发的。18年初,我们开始了Hybyid开发技术方案的探索与实践。目前获得的APP包括两种混合方案,即ReactNative和Webview。从时间维度来看,本文重点回顾了Webview Hybrid从0到1获取APP的过程,希望我们的经验可以给一些想要登陆Hybrid的团队。
1. 背景和动机
得到一个运营场景比较重的产品,APP中的大部分功能都会有共享功能。18世纪初,开发一个功能,基本上需要三个终端三个人。部分商家采用嵌入式Webview,类似浏览器的方案,虽然满足了跨端,但体验较差。所以最初的目的是有一个跨平台的方案,一套代码可以三个终端执行,有很好的体验。这是Hybrid当时的架构图:
除了Webview,当时流行的跨平台方案还有ReactNative和Weex。对比两种方案,Weex更接近我们团队的技术栈,而RN在当时的社区中更成熟,最后我们觉得社区更重要,所以选择了RN。
在RN的研究阶段,我们发现RN虽然支持三端和动态更新,但需要配套的基础设施来实现其动态更新能力,因此我们需要一个能够动态更新客户端RN文件的离线资源管理系统。当我们思考和设计这个线下资源管理系统的时候,我们发现同样的思路可以应用到Webview上,我们可以把前端代码做成线下的包。通过离线资源管理系统更新,而Weview只需要访问数据API,无需下载HTML/JS/CSS等。在启动过程中,这也可以被视为离线能力的变相增加。
因此,我们制定了最初的路线图:
一是开发线下资源管理系统;
之后接入线下Web包,因为线下Web包开发成本低,可以快速提升现有项目的体验,快速盈利。
最后,开发和访问RN;
2. 离线资源包管理系统-Seeder
做一个技术驱动的项目就像做一个产品。需要梳理需求、使用场景等,然后思考技术架构和实现细节。首先,我们把这个项目命名为播种机。(你为什么给它起名?其实说不通,主要是里面没有其他叫播种机的系统。)
2.1. 目标
通过梳理,我们认为播种机需要达到以下目标:
资源可以动态更新;
可以支持非最新版本客户端更新;
支持增量更新;
支持多渠道发布;
2.2. 技术选型和架构
目标明确后,我们需要进行技术选择和架构。在技术选择上,我们使用了团队熟悉的Nodejs Mongodb组合,架构图如下:
服务器包括两部分:播种机和CDN。CDN部分主要用于下载资源包。播种机分为更新器服务和管理器服务:
更新服务:主要承担处理客户端的更新请求;
Manager服务:主要管理资源包及相关配置,包括生成diff包等;
通过合理的拆分,Updater服务在我们后续的压力测试中,两台8C16G机器可以稳定的承载6000QPS;
2.3. 关键实现点 Package 定义
由于资源包是被管理的,我们需要定义资源包的格式和约束。
在格式方面,我们选择了tgz格式,即使用tar进行归档,gzip进行压缩,以减少传输量。
在文件结构方面,在原始资源目录结构的根目录中,添加了一个info.json文件来描述包信息。
!【包结构(https://piccdn . luojilab.com/Fe-OSS/default/image-20200114171319102 . png)
让我们看看下一个包的结构。
jpg">- appId:标示包的应用 ID;
- version:标示这个包的版本;
- depend.containerVersion: 标示这个包依赖的容器版本,目前的容器只有客户端;
- files: 一个数组,记录所有的文件和路径及其 MD5;
- meta: 扩展信息字段,这里使用了两个扩展字段,后面详细讲这两个字段
- type:包的类型
- routes:包需要注册的路由列表
2.4. 关键实现点 – 增量更新
增量更新指的是我们只需要下载一个 Patch 包,安装 Patch 包之后即可以完成应用的更新,像我们常用的 VSCode 之类的软件、大部分手机游戏,都支持增量更新。实现增量更新关键点是增量算法,通过调研,最终选择了支持二进制 diff 算法 bsdiff 。
确认算法之后就要开始思考增量包的实现方式,因为 bsdiff 是对单个二进制进行 diff,而我们是一个包。因此有两种方式:
- 基于归档压缩完的 tgz 包进行 diff 和 patch,这种方案的优势是实现成本低,带来的问题是客户端必须保留一份底包,并且由 于 Package 在客户端是下载完先解压才能执行,这种方案无法连续 patch 升级(不能增量从 v1.0->v1.1->v1.2,只能 v1.0->v1.1,v1.0->v1.2);
- 基于单文件 diff,即增量包其实包含多个 patch 文件,包含了描述 Package 变更信息的文件,这种方案虽然实现会复杂些,但是并没有方案 1 的各种问题,因此我们采用了是单文件 diff 的方案;
下面我们看下单文件 diff 的方案,先看一个增量包的结构:
相较于普通包,多了一个 update.json 文件,这个文件描述了整个包是如果变化的,基于这个文件和包内的其他文件,便可以 Patch 到最先的版本,看一下 update.json 的结构:
files 是描述变化的文件。关键字段 type, 标示了变更类型,add、delete、move、modify 等,分别表示新增的、需要删除的、发生目录和文件名变化的、内容变化的文件。add、delete、move 只涉及到了文件的新增、删除、变更路径等操作,而 modify 则是用到了 bsdiff,表示这个文件发生变化,需要增量更新。
通过这种精细化的操作,可以提高 patch 的效率,同时客户端无需保留底包,基于解压完的代码文件就可以完成增量更新。
梳理完了增量包结构,还有面临一个问题,就是增量包的生成时机。同样有两种方案:
- 请求来了生成增量包,好处就是一定会有增量包,问题是增量包的生成是一个 CPU 密集型操作,无法支持高并发;
- 提前生成增量包,但是只能提前生成指定版本数量的增量包,但可能存在较老版本没有增量包可用;
我们最终采用了提前生成增量包的方案,因为包内容差异越大,增量带来的收益越小,没有必要生成所有版本的增量包。我们在上传包时,会同时生成历史 10 个版本的增量包。基于我们目前的更新频率,10 个历史版本目前基本可以满足需求(后续不满足可以调整,就是一个配置项)。当然,用户长时间不打开 APP,可能再次打开,我们已经更新了十几个版本,这个时候只能通过全量包来进行更新。
2.5. 架构变化
看一下我们调整后的架构变化:
3. 应用框架 – Adam
完成了基础设施的建设之后,客户端的离线资源也具备的动态更新的能力,但普通的 Web 离线包还有以下的限制:
- 每个 Webview 只能有一个页面,无法实现复杂的功能(为了跟客户端保持一致的页面交互体验,每个 Webview 只有一个页面,这样前进后退、导航条的表现是一致的);
- 无法控制导航条,一些需要定制导航条的功能依赖客户端;
- 没有体系化的框架,无法统一处理异常、缓存等;
为了解决以上问题,我们决定开发一个应用层的框架。
3.1. 目标和分解
我们整个团队最熟悉的技术栈是 Vue,因此 Adam 肯定是基于 Vue 做封装,在设计 Adam 之前,需要我们先确认目标:
- 功能上:一个 Package 可以作为一个完整的 Application,能够完整地实现一个功能模块,包括多页面的功能等;
- 技术上:实现标准化的解决方案,由框架处理缓存、异常页面等通用逻辑;
对目标进一步做分解:
- 需要客户端将界面全部交给 Webview 处理;
- 需要 Router,并且像客户端一样,支持栈式管理页面的路由;
- 页面要实现客户端相同的前进和后退动效,要支持滑动返回上一个页面;
- 需要抽象缓存和异常页面等到框架层;
3.2. 架构图
我们先看下一下 Adam 的整体架构,以便于我们后续内容的表述:
每一个 Web Package 就是一个应用,每个 Application 实例对应一个 Global Store 和 vue-stack-router 的实例,对应多个 Page 实例。
每一个页面都由 Page Componets 和 Page Store 组成。其中 Page Store 的生命周期跟页面保持一致。
3.3. 关键实现点 – Router
最初的 Router 方案我们是选了我们常用的 vue-router,但在实现过程中,遇到了以下问题:
- 实现类似栈式的路由较为困难。客户端内的页面大部分都具有栈式的特点,页面实例的存活取决于是否在栈中。而 vue-router 中,组件实例的存活则是取决与是否使用了 kee-alive 组件;
- 实现两个页面间的、类似 Native 的滑动返回较为困难;
- 无法实现多例页面。Native 中,A 页面跳转 A 页面,会产生一个新的 A 页面的实例。vue-router 中,A 页面跳转 A 页面会重新渲染现有的 A 页面,也就是 A 页面始终是单例的;
为了解决这些问题,我们开发了 vue-stack-router (已开源,具体实现细节,感兴趣的可以直接看 github 代码,内容较多,这里不展开),相较于 vue-router,有以下新功能:
- 栈式的路由管理;
- 路由间数据传递;
- 支持更细粒度、可定制的路由过渡效果;
- 支持预渲染;
基于预渲染模式,我们实现了手势滑动返回的功能,即触发手势时,预渲染后一个页面,此时同时存在两个叠加在一起的页面,通过 JS 控制两个页面的动画,便可以实习类似 Native 的滑动返回的效果。
3.4. 关键实现点 – Store
提到状态管理工具,共识都是简单的项目无需使用 Store,复杂项目才能体现出 Store 的价值,其实无非是引入 Store 带来了成本。我们分析一下移动端页面的特点:
- 展示为主
- 页面间耦合性低
- 数据流简单
因此,在移动端页面,我们追踪状态变化的收益可能不会很高,如果去掉状态追踪,Store 可以变的很精简, 看一下我们自己精简的 Store,原理如下:
classMyStore{
public name:string='';
public updateName(name:string):void{
this.name = name;
}
}
const store =Vue.observable(newMyStore());
没有状态追踪,只是最精简的将状态抽离到一个类中进行管理。
聊完了 Store 实现,再看看关于 Store 的组织形态,我们常用 Vuex 和 Redux 都是单一组件树,连 MobX 也有 mobx-state-tree 这种单一组件树的社区方案。但是结合移动端业务的特点,单一组件树会有些问题,对多页面实例的支持,实现比较复杂。再一个,优秀的单一组件树的组织通常是跟页面分离的,经过单独设计的,因此会带来了额外的心智负担。
基于以上死牢,最后我们没有采用单一组件树,而是实现了多状态的 Store 方案:一个页面对应一个 Store,Store 和页面的生命周期保持一致的方案。逻辑跟展现分离,页面间又不耦合,最重要的是简单;
3.5. 缓存
数据缓存是体验优化的一大利器,通过先渲染缓存数据,在更新正式数据的方式,我们可以立刻展现出一个页面而无需等待。Adam 实现了三级缓存:
依次从路由数据、内存、LocalStorage 中取。路由数据是什么呢,通常在客户端内,页面跳转很多都是摘要信息跳往详情信息的页面(如列表的 item 跳详情页),其实前一个页面已经包含一部分后续页面的信息,这个时候可以将前一个页面的数据带到后一个页面中,后一个页面便可以渲染出主要信息,提升用户体验。
那么缓存的数据是哪里来的呢,并不需要开发者手动写入。我们知道 View=fn(State),在 Store 方案中我们已经将页面的状态都放到 store 中了,只需要缓存 Store 就可以了。至于缓存和还原的时机,就是在页面销毁时,我们序列化 Store,等页面在打开,还原 Store 。
4. 标准化容器
在开发 Adam 的同时,也不断有同学反馈,现在接入一个新的 Web Hybrid 业务比较麻烦,需要客户端配置 webview,而且新业务依赖发版,是不是可以我们完全不依赖客户端呢?
答案是可以的。
4.1. 路由协议
我们在 Package 中增加了包的类型和包的全局路由信息,这样客户端在更新到包的信息时,可以动态注册路由,也就是所有的 Package 中的路由,都绑定到一个标准化的 webview,webview 启动后,根据跳转过来的路由加载对应 Package,已实现动态加载和注册功能。
4.2. 最终架构
完成了 Adam 和 标准化容器后,我们看一下最终接架构:
至此我们可以将每个 Package 当做一个独立的 Application 来更新和迭代。
5. 总结和思考
5.1. 成果
功能方面,我们接入了讲座、电子书、评测、训练营、得到大学、活动系统、帮助中心等模块,接入了 90+的页面(其中 ReactNative 占 30+,Web 占 60+);
效率方面,我们在一年半内支撑了 49 个功能模块动态更新了 1900 次。测试环境中,动态更新了 1.3 万次;
性能方面,我们从性能监控系统中找到两个未使用和使用 Seeder 的功能进行对比(这个对比不太严谨,因为没有同一个功能先后采用两种方案的数据,我们找了两个功能相近,代码量相近的两个项目进行对比)。
普通 Webview 方案
Adam + Seeder 方案:
基本可以看到,稳定性和效率都有较好的改善。
5.2. 思考
Hybrid 落地过程中,我们踩了很多坑,也有很多收货,简单谈两点。
第一个,如何评价一个技术方案的好坏?我们有太多的标准:站在业务角度,是不是能满足需求及低成本的满足潜在的后续需求;站在运维角度,是不是带来了新的部署运维成本。站在技术角度,我们甚至可以掏出一本设计模式大谈一番。但是我们很少有注意到技术方案的用户体验,这里的用户指的是使用你框架、库的开发同学。站在业务开发同学的角度会发现,提供的方案确实解决了问题,但是使用这个方案过程中,可能有 30% 工作是不属于方案部分,但是属于方案部分必须的,比如方案的入参是 A,开发者需要花大力气才能得到 A,才能使用这个方案。所以作为框架、库的开发者,要考虑清楚整个方案的使用场景,技术部分是不是可以覆盖整个场景,覆盖不了要怎么解决,是否需要提供自动化工具等等。
第二个,Hybrid 不是一个端的事情,而是三端一起的事情,而作为推动方,要尽可能的了解三端,不了解可以多跟各端同学沟通交流,不要做成一方推动两方配合,要让大家感觉是在一起干一件事情,这样才能做好。
5.3 后续的规划
后续的规划主要是有两大方面:
- Adam 的多环境多端的支持,覆盖得到业务“端”的场景;
- Seeder 更加灵活的更新场景,比如支持 Lazy 加载等;