端动态化方案详细设计

4,257 阅读21分钟

前言

背景什么的就不说了,大家都懂!不懂的请百度!既然看到了这篇文章,说明你还是对动态化有自己的诉求哒,那么希望文章中的内容可以帮到你。


技术选型

技术选型永远是项目确定之后遇到的第一个难题,市面上可以解决项目问题的选型有很多,到底是时髦驱动开发还是热闹驱动开发嘞?其实大家在选型过程中最应该关心的不是技术,而是项目。因为技术应该为项目服务,而不是项目为技术服务,分清楚权重之后,就很清晰了。接下来就是从项目入手,从项目入手需要从三个因素考虑:

  1. 项目因素
  2. 团队因素
  3. 技术因素

项目因素

项目在不同的阶段需要考虑的情况是完全不一样的。

Alt text

比如项目刚启动或者在基础功能铺设的阶段,那么关心就应该是快速试错需求快速更新迭代需求变更紧急运营活动频繁、其他的非功能性需求。在项目的扩张期也就是中期,可能会经历一次重构,将前期各种临时的解决方案统一进行升级和整改,以此增加系统的稳定性并且可以足够应对项目对内的产品类目、功能需求以及对外的市场和产品扩张。在项目的稳定期大多数的项目架构都已经定型,但是并不是那么的"尽善尽美",会有很多历史遗留问题让人头疼,所以这个时候更需要有一个技术视野和能力很强的人来带领团队把项目的技术深度和高度达到一个更高的高度,当然,这个过程中是十分考验开发人员的能力的。

棋局里有一句善弈者通盘无妙手,好的架构都是润物细无声的,任何需求和功能的变化都可以让开发人员十分便捷的完成,拥有足够的灵活性和反脆弱性,当然这里就不做过多讨论了。

团队因素

选型是针对团队的考虑比重也是十分重要的,因为项目不是一个人在做,而是一群人。当你选定了某一项技术之后团队里肯定存在对这个技术不熟悉的人。所以你需要考虑到团队成员的学习成本,另外在团队招纳新人的时候也会把该技术的要求添加到新人的技能列表里,如果你选的技术大部分人都不会甚至不知道那就很尴尬了,项目就会越做越死。

技术因素

经过前两个因素的综合考虑之后,就该考虑技术因素了。选定的技术方案或者解决方案技术程度度怎么样,是不是已经达到了stable的状态。

  1. 文档和示例是否齐全?
  2. 遇到问题之后是否可以得到技术维护人员的第一时间解答?
  3. 遇到bug如何修复?

技术方案的稳定性怎么样,需要多少人力支持所谓的稳定性这也是需要考虑的地方。另外就是扩展性了,当然这个是相对于需求和功能的扩展来说的。

最后,把备选的多个技术方案进行三个方面的多维度对比,就会得到一个比较满意的方案了。


动态化的选型

我们在评审前,找了三端(FE、IOS、安卓)的高工一起讨论了选型的问题,经过综合考虑,我们选择了Weex和Hybrid两种方案。具体细节包括但不局限于技术选型适用场景功能边界切入点交互协议等方面,在这里不做赘述。

选定方案之后,我们从上述的三种因素基于团队当前的项目阶段进行综合对比,具体如下图。

Alt text

至于为什么没有选Weex,原因是我们FE团队里的人都不太了解Weex(大多是新人),而且深入学习的成本太大(不要告诉我确定项目完全基于某个技术方案开发之后不需要深入学习和掌握,那你不太适合这篇文章)。遇到阻塞性问题怎么解决我们也不是很有把握,毕竟我们不是阿里系的。而使用Hybrid的话,这些问题就不需要考虑了。

大家都知道,javascript是单线程的,即便js引擎底层引入了非阻塞(non-blocking)的机制,也改变不了运行逻辑较多时页面卡顿的问题(webWorker不在讨论范围内)。所以高级点的Hybrid方案使用了多线程以此拆分前端的逻辑和视图。

关于异步非阻塞的区别请参考 asynchronous-vs-non-blocking

使用RAIL模型评估性能

RAIL 是一种以用户为中心的性能模型。每个网络应用均具有与其生命周期有关的四个不同方面,且这些方面以不同的方式影响着性能:

Alt text

TL;DR

  • 以用户为中心;最终目标不是让您的网站在任何特定设备上都能运行很快,而是使用户满意。
  • 立即响应用户;在 100 毫秒以内确认用户输入。
  • 设置动画或滚动时,在 10 毫秒以内生成帧。
  • 最大程度增加主线程的空闲时间。
  • 持续吸引用户;在 1000 毫秒以内呈现交互内容。

最后基于上述的RAIL模型,我们得到了结论:Hybrid并没有比Weex的体验差很多,在可控范围内。比如小程序的体验效果

行业契合度

选择Hybrid之后,我们有对比了行业的契合度。因为我们的项目是一个类似与电商的项目,就是买东西的。所以还是蛮符合的。

Alt text


适用场景

接下来介绍下Hybrid方案在项目功能内的使用场景,目前我们的项目由于是处于初期阶段,所以功能较少,主要有以下四类:

Alt text

图中依次是:首页、二级页、详情页、单品详情页。依据于我们的场景,除了个人中心、订单列表、收银台之外,Hybrid何以适用于项目的其他任何场景。

切入点

因为项目刚开始到目前为止,我们三个端都是各自实现业务逻辑,所以在实现动态化方案的过程中,不能一刀切。时间、人力、项目各种因素也不允许我们这么做。因此我们选择了一个切入点,循循渐进得完成我们需求,就像是给一辆高速驾驶的汽车更换地盘一样。

项目确定之后,我们优先考虑了单品详情页作为我们的技术切入点。具体原因如下:

  1. 展示内容居多,没有复杂交互
  2. 频繁变化,每个单品都有不同的详情内容
  3. 交互简单,适合循循渐进定制Hybrid的各种协议和逻辑

后面我们依次的迁移顺序为:单品详情页 -> 详情页 -> 二级页 -> 首页。


整体架构

上面聊了那么多,没多少技术的干货,现在开始介绍下整体架构的设计。在需求实现的过程中我们经常会发生统一套页面需求在WAP和Native端上都需要实现,为了解决这种需求带来的重复工作量的问题,我们在设计时加入对宿主环境兼容的考虑。

Alt text

主要分为三层:

  1. 视图层
  2. 容器
  3. native / OS

视图层主要负责视图的展现,包括H5的页面和模板、业务的框架实现还有内嵌在视图层的bridge,如果视图是在APP的webView中,那么也会包含Native Activities控件。至于原生的Native Activities如何设计,后面会讲到。

容器就是视图层的执行环境,可能是移动端浏览器,也可能是App的webView。浏览器的话这里暂且不提,webView的话会提供一个Bridge Provider用来将端封装好的能力输出给视图层,一般使用API注入和Schema的方式实现。里面封装的都是Native级别的业务API和硬件设备的API。

最下面的就是Native的OS层,主要提供一切必要的基础能力,由于我对Native了解的并不深入,所以这里暂不讨论。

由于视图层和容器的层隔离,让视图层不需要关心容器的实现,但是它们之间的bridge却必须得关心这个。以至于bridge如何兼容不同的容器(wap浏览器、Native App),这是个值得深入考虑的问题。

这是一个简单的分层架构。其中每一层都有着特定的角色和职能。架构里的层次是具体工作的高度抽象,它们都是为了实现某种特定的业务请求而存在的。还有一个突出特性是关注点分离,每层都只会处理本层的逻辑。从另一方面说,分层隔离使得层与层之间都是相互独立的。架构中的每一层都必须符合最少知识原则,正因为这种高度独立,才使得我们可以很好的兼容WAP浏览器和Native APP。


视图层设计

UI的本质是什么?是将从服务器获取数据状态(state),经过一定的操作使之展示出来。我们可以用一个数学表达式来表现它们的关系:UI = f(state)。state是通过bridge或异步化接口获取的数据,UI就是用户看到的界面,对于Hybrid模式的来说,真正关心的就是f这个函数到底如何实现。

我们可以简单的把f往大了想,把它理解成为一个web容器,也就是Web Container。至于Container里怎么做?请看下图:

Alt text

前文我们说过了,Hybrid中可以将视图层中的视图(View)与逻辑(Service)分开达到体验提升的目的。在Native App中一般是将一个页面拆成这两部分放在两个不同WebView中,一个WebView放View部分,一个WebView放Service部分*(也就是说,每个页面都需要2个WebView)*。

他们之间经过各自的Bridge对即将发送或刚接收到的数据进行包装,然后再经过封装在bridge中的tunnel进行数据交互,完成后续操作。不过,在wap浏览器中完全不需要考虑这些,该怎么做就这么做。但是也会出现一个兼容问题,视图层和容器之间通过bridge交互,也就是说在wap浏览器(wap浏览器也是容器)中也需要存在bridge,不过这个bridge提供的是浏览器的能力。

bridge中存在一个叫做tunnel的东西,主要负责传递Service和View之间的数据和事件。在不同的宿主环境中,tunnel的组成也不同,在Native App中,tunnel是一个IPC的实现。在浏览器中,tunnel是一个发布订阅事件机制的实现。

在Service中会遇到数据本地存储的问题。数据的存储和获取统一通过bridge将操作内容发送给Native,然后Native根据不同的操作内容进行处理,完毕之后再通过处理完毕之后的数据发送给Bridge,进而bridge再行通知Service。

data的传递

Service包含视图层中除了视图渲染之外的其他任何逻辑。它把获取到的state经过framework的API处理之后会生成一个视图元信息(View Metadata),视图元信息是对将要渲染视图的简单描述,通过它我们可以预想到视图长什么样子。之后framework会把视图元信息通过bridge中的tunnel发送给View。

注意: tunnel发送数据的过程是异步的。比如小程序中的setData()方法。

View只包含视图层中的页面渲染。渲染对象主要包括两个:html以及需要展示的Native Activities控件。当Service发送过来的视图元信息中包含Native级别控件时,bridge会把该部分的视图元数据发送给Native。Native收到之后,就会根据元数据在视图层的WebView上展示原生控件*(注意:原生组件是Cover在WebView上的)*。当Service发送过来的数据为html的视图元数据时,会先根据视图元数据进行DOM Diff,然后根据生成的Patch对象来进行页面的渲染。

event的传递

View渲染完成之后,就会等待用户操作。View会将用户操作的事件区别对待:html的事件和Native控件事件。先说Native的事件,Native的事件WebView把控不了,需要Native在封装业务原生控件时多做注意,对控件可能遇到的事件做统一梳理。原生控件会通过Native框架把事件源和事件参数进行序列化,然后Native框架再将序列化后的事件数据通过bridge发送给Service。

如果是html的事件,View这边中bridge会通过js获取事件源和事件参数,然后统一进行序列化。然后在通过tunnel将序列化后的事件数据发送给Service。

数据的请求

Web Container中请求的数据主要分为两类:

  • 静态资源请求
  • 业务数据请求(异步化接口)

静态资源请求会直接通过webView对外发起请求,这里不做赘述。

除了静态资源请求之外的异步化接口请求我们会通过Native进行代理,让Native帮我们发送请求,而不是使用XMLHttpRequest对象进行请求。


Bridge设计

bridge层位于视图层和Native之间,负责链接双方,一个好的bridge设计,可以让我们在开发的过程中事半功倍。

我们对Bridge的关注点:

  1. 位于 js执行环境宿主环境之间,负责链接双方
  2. 兼容宿主环境*(wap、app)*差异性
  3. 适配不同业务线提供的桥连*(注入API、schema协议)*能力
  4. 根据业务线单独配置桥连能力
  5. 编译阶段 解决宿主环境兼容能力

js执行环境可能是wap浏览器,也可能是Native中的WebView。宿主环境也可能是浏览器和Native App。对接的业务方提供的桥连能力各不相同,统一套方案需要对接至少三种不同的功能需求平台。而这些问题就是需要在Bridge中解决的。

上一节提到过『除了静态资源请求之外的异步化接口请求我们会通过Native进行代理,让Native帮我们发送请求』。至于为什么要这样做主要原因为:

  1. 接口鉴权问题
  2. 对数据进行更新粒度的控制

先说第一个鉴权问题,常规的做法是App用户登录后,将用户的认证标识存在在webView的cookies中,然后WebView里的业务代码发送AJAX请求时就会将cookies携带到服务器完成用户鉴权。这种情况下如果服务器端校验用户token失败的话是无法第一时间让APP跳转到登录窗口的。另外在WebView中发送了一个退出登录的异步接口请求,这时APP也需要同步退出登录。很显然,最好的办法就是让APP帮我们代为发送异步化接口请求。这样我们还可以利用上APP的持久化缓存能力来存储接口数据。

整体架构流程

Alt text

宿主环境差异性

在浏览器和Native APP的差异性方面,我们总结了以下5点:

  1. 视图控件
  2. 数据存储
  3. 异步化接口请求
  4. 页面路由
  5. 页面历史管理

我们会在有差异的功能上封装统一的API,以此减少FE开发人员在开发过程中的兼容问题。

这里仅以异步化接口请求举例,我们封装一个统一request方法。开发人员不需要关心自己写的代码将要在哪个平台上运行。借助WebPack和Rollup等工具的tree shaking功能,我们可以很好的完成差异化编译。

// tools.js
import Axios from 'Axios';
import bridgeRequest from '@/bridge/request.js';

export default {
	request: process.env.TARGET === 'app' ? bridgeRequest : Axios
}

// main.js
import {request} from 'tools';

request.get('http://www.test.com/test', {a: 1}).then(data => {
	console.log('this is test data -> ', data);
});

在编译时我们只需要指定target就可以做差异化编译了:

# 编译为app版本
$ npm run build --TARGET=app
# 编译为wap浏览器版本
$ npm run build --TARGET=browser

桥连能力注入

我们定制一个Bridge的标准接口,用来规范各种操作,比如Native的调起弹出层控件。业务方根据自己往WebView注入的API或schema协议,填写一个配置Json文件,然后注入到bridge中,该文件中声明了alert操作要访问的协议或方法以及参数名称。这样bridge在调用alert方法的时候就会根据json完成指定操作。

业务方只需要根据Bridge定义好的标准接口,注入自己的schema协议即可。

// system.schema.json
export default {
	alert: {
		schema: 'xxxx',
		params: {}
	},
	request: {
		schema: 'xxxx',
		params: {}
	}
}

// interactive,js
import schema from '@/schemas/system.schema.json';
// 注入业务方自己的alert schema
interactive.injectSchema(schema);
export default {
	alert(options) {
		return interactive.api.alert(options)
	}
}

// main.js
import {bridge} from '@/bridge/index.js';
import {alert} from '@/bridge/interactive.js';

// view层准备完毕
bridge.on('ready', () => {
	alert('这是一个alert!').then(data => {
		console.log(data.state ? '确定' : '取消');
	}).catch(e => {
		console.log('调起alert失败');
	});
})

Native层设计

由于我本身不是Native的开发人员所以这里就列一张Native的架构图,具体的你们自己看吧。

Alt text

注意: 这张图是我这个FE画的,被安卓的大佬吐槽说画的结构不清晰。你们将就着看吧!

到这里,我们就把架构里最主要的三层:视图层、Bridge和Native层介绍完了。下面开始介绍功能设计,主要包括三个方面:原生组件交互、路由系统(统跳协议)、资源包的缓存与更新。


原生组件交互

原生组件与webView中用javascript实现的组件是不一样的。它们是由Native直接在WebView之上渲染的原生控件,无法受到javascript影响,只会受到Native的控制和影响。对于WebView中的javascirpt代码来说就是:超乎三界之外,不在五行之中

为什么不可以全部使用WebView中的js组件哪?那就是WebView中前端组件的影响面过小,就跟唐朝末年的朝廷一样,政令不出长安城。比如Alert提示在显示状态下,不可以做其他交互操作,只能点击Alert的确定和取消按钮。还有Header上左侧按钮的后退以及点击右侧Icon返回APP首页的操作等,这样的例子还可以往下举很多。所以遇到这种情况,就必须请原生的Native控件出马控场了。

我们这里梳理了一下可以用到的Native级别控件:

  • Header
  • Footer TabBar
  • Alert Tip Confirm
  • Dialog
  • SelectBar

Alt text

『部分』原生组件的加载时机

那些总是需要在视图里第一时间展示(Header、TabBar等)的原生组件必须区别对待。不能在WebView加载完之后再去渲染那些原生控件,因为这样会出现因需渲染原生控件而对WebView重新计算大小导致Service中数据错误以及页面闪烁的问题,从而影响用户体验。

最好的方法就是把这类原生组件的视图元数据单独放在一个控制版本管理的json文件(下文有写到)中,而不是放在包含bundle内容的zip包中。这样Native就可以根据json文件中的视图元信息提前渲染好原生控件,然后加载WebView并执行javascript代码。


路由系统

在设计整个路由系统之前我们有个前提条件,那就是每个视图页面都是独立的一个WebView(其实包含两个,一个存放View逻辑,一个存放Service逻辑),而不是在同一个WebView中加载渲染多个页面。因为只有这样才可以完美的模拟原生应用的页面跳转的各种操作。这个一定要注意,如果你不注意你就不会理解下文到底在说什么!

我们遇到的场景有以下几种:

跳转场景:

  1. Native to Native
  2. Native to WebView
  3. WebView to Native
  4. WebView to WebView

加载场景:

  1. 同页面加载(重定向)
  2. 跨页面加载

存在的问题:

  • 同时存在的WebView最大数量
  • 视图之间的参数传递
  • 视图历史栈管理

最后我们商定的WebView可以同时存在的数量为9个,和微信小程序一样。当页面栈已经达到9个的时再打开新页面就会无法打开新页面。页面之间的参数传递统一使用querystring格式。历史栈的管理由Native统一实现。

历史栈的管理

我们维护一个历史栈的目的就是让Native中的视图可以像浏览器的历史一样,进行前进和后退。唯一的不同是,浏览器的历史存的是URL字符串,而我们的历史栈存的是视图对象。每次Native APP打开都会重新从头记录,只会记录APP运行期间的历史,APP关闭后历史栈清空。

Alt text

逐级访问

正常的操作路径访问,会将每一级的视图存放在历史栈中。最多存入9级,超过9级则无法加载新页面。

重复打开页面

当最新的单品页新打开一个二级页时,即便这个二级页已经打开过,历史管理器也会在栈的顶部新打开一个二级页。注意,两个二级页是完全独立的。不存在视图提升。

重定向

在最新的单品页重定向为二级页时,和上面的重复打开页面情况类似,都是将当前页重定向为二级页并渲染。注意,这两个二级页是完全独立的。不存在视图提升。

后退

当点击Header左侧的后退按钮(一级一级后退)或者通过Hybrid Router API(可以多级后退)进行后退操作时,就是消费当前的历史管理栈。


资源包的缓存与更新

当所有的步骤都已经就绪之后,就该到这一步了,bundle资源的缓存和更新。这里我们引入的分包加载的机制,而且分包的级别是以页面为纬度的,而不是功能。其实就是小程序的那套分包加载机制。

为了实现这套机制,我们抛弃了WebView的缓存,和Native同学一起开发并建立起了这套缓存机制。并且只缓存bundle资源(一个一个的zip包)。我们规定每个业务只有一个入口zip包,所有的子包zip都必须依赖入口zip包中的subConf.json进行更新和加载。

Alt text

只有在Native每次更新入口包是才会显示loading,除此之外都会显示入口包中携带的骨架图html。

Alt text

APP每次打开的时候都是先去服务器获取conf.json,conf.json中内容如下:

{
	"version": "v1.0.1",
	// 此内容仅为示例
	"skeletonURL": "https://pan.baidu.com/nt-static/hybrid/app.skeleton_v1.0.1.d3a938346f1ab825.html",
	// 需要下载的入口zip包 命名方式 {version}.{md5}.zip
	"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.d3a938346f1ab825.zip",
	// zip包的md5,根据此md5判断该zip包是否需要更新
	"md5": "d3a938346f1ab825",
	// 签名校验
	"signature": "342876ba19d34aba92f7536e42992a45",
	// 需要提前渲染的原生控件
	"header": {
		"title": "this is a title"
	},
	"tabBar": {
		{
			"text": "log",
			"icon": ""
		},
		{
			"text": "home",
			"icon": ""
		}
	}
}

入口zip包解压完毕之后的目录结构为:

$ tree ./v1.0.1.d3a938346f1ab825

./v1.0.1.d3a938346f1ab825
├── app.bundle.css //样式文件
├── app.bundle.js // js逻辑文件
├── app.index.html // 入口html
├── app.skeleton_v1.0.1.d3a938346f1ab825.html // 骨架图,和conf.json中的skeletonURL一致
└── subConf.json // 子包的加载及校验配置

subConf.json中的内容:

{
	// 和conf.json一致
	"signature": "342876ba19d34aba92f7536e42992a45",
	// 子包入口
	"subRoutes": [
		{
			// 入口的路由
			"routes": ["/go/to/path/1", "/go/to/path/2"],
			// 骨架图URL
			"skeletonURL": {
				"/go/to/path/1": "https://pan.baidu.com/nt-static/hybrid/app.skeleton_v1.0.1.bfa31a2ae5f55a7f.html"
			},
			"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.bfa31a2ae5f55a7f.zip"
			"md5": "bfa31a2ae5f55a7f"
		},
		{
			"routes": ["/go/to/path/3"],
			"skeletonURL": {}
			"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.7af5f492a74499e7.zip",
			"md5": "7af5f492a74499e7"
		}
	]
}

最后

本文主要介绍了Hybrid的整体架构的三层和功能设计的三个点,基本涵盖了端动态化方向的全部要点。希望本文可以帮到你。