前言
背景什么的就不说了,大家都懂!不懂的请百度!既然看到了这篇文章,说明你还是对动态化有自己的诉求哒,那么希望文章中的内容可以帮到你。
技术选型
技术选型永远是项目确定之后遇到的第一个难题,市面上可以解决项目问题的选型有很多,到底是时髦驱动开发还是热闹驱动开发嘞?其实大家在选型过程中最应该关心的不是技术,而是项目。因为技术应该为项目服务,而不是项目为技术服务,分清楚权重之后,就很清晰了。接下来就是从项目入手,从项目入手需要从三个因素考虑:
项目因素
团队因素
技术因素
项目因素
项目在不同的阶段需要考虑的情况是完全不一样的。
比如项目刚启动或者在基础功能铺设的阶段,那么关心就应该是快速试错、需求快速更新迭代、需求变更紧急、运营活动频繁、其他的非功能性需求。在项目的扩张期也就是中期,可能会经历一次重构,将前期各种临时的解决方案统一进行升级和整改,以此增加系统的稳定性并且可以足够应对项目对内的产品类目、功能需求以及对外的市场和产品扩张。在项目的稳定期大多数的项目架构都已经定型,但是并不是那么的 ” 尽善尽美 ”,会有很多历史遗留问题让人头疼,所以这个时候更需要有一个技术视野和能力很强的人来带领团队把项目的技术深度和高度达到一个更高的高度,当然,这个过程中是十分考验开发人员的能力的。
棋局里有一句善弈者通盘无妙手,好的架构都是润物细无声的,任何需求和功能的变化都可以让开发人员十分便捷的完成,拥有足够的灵活性和反脆弱性,当然这里就不做过多讨论了。
团队因素
选型是针对团队的考虑比重也是十分重要的,因为项目不是一个人在做,而是一群人。当你选定了某一项技术之后团队里肯定存在对这个技术不熟悉的人。所以你需要考虑到团队成员的学习成本,另外在团队招纳新人的时候也会把该技术的要求添加到新人的技能列表里,如果你选的技术大部分人都不会甚至不知道那就很尴尬了,项目就会越做越死。
技术因素
经过前两个因素的综合考虑之后,就该考虑技术因素了。选定的技术方案或者解决方案技术程度度怎么样,是不是已经达到了 stable 的状态。
文档和示例是否齐全?
遇到问题之后是否可以得到技术维护人员的第一时间解答?
遇到 bug 如何修复?
技术方案的稳定性怎么样,需要多少人力支持所谓的稳定性这也是需要考虑的地方。另外就是扩展性了,当然这个是相对于需求和功能的扩展来说的。
最后,把备选的多个技术方案进行三个方面的多维度对比,就会得到一个比较满意的方案了。
动态化的选型
我们在评审前,找了三端(FE、IOS、安卓)的高工一起讨论了选型的问题,经过综合考虑,我们选择了 Weex 和 Hybrid 两种方案。具体细节包括但不局限于技术选型、适用场景、功能边界、切入点、交互协议等方面,在这里不做赘述。
选定方案之后,我们从上述的三种因素基于团队当前的项目阶段进行综合对比,具体如下图。
至于为什么没有选 Weex,原因是我们 FE 团队里的人都不太了解 Weex(大多是新人),而且深入学习的成本太大(不要告诉我确定项目完全基于某个技术方案开发之后不需要深入学习和掌握,那你不太适合这篇文章)。遇到阻塞性问题怎么解决我们也不是很有把握,毕竟我们不是阿里系的。而使用 Hybrid 的话,这些问题就不需要考虑了。
大家都知道,javascript 是单线程的,即便 js 引擎底层引入了非阻塞(non-blocking)的机制,也改变不了运行逻辑较多时页面卡顿的问题(webWorker 不在讨论范围内)。所以高级点的 Hybrid 方案使用了多线程以此拆分前端的逻辑和视图。
关于异步和非阻塞的区别请参考 asynchronous-vs-non-blocking
使用 RAIL 模型评估性能
RAIL 是一种以用户为中心的性能模型。每个网络应用均具有与其生命周期有关的四个不同方面,且这些方面以不同的方式影响着性能:
TL;DR
以用户为中心;最终目标不是让您的网站在任何特定设备上都能运行很快,而是使用户满意。
立即响应用户;在 100 毫秒以内确认用户输入。
设置动画或滚动时,在 10 毫秒以内生成帧。
最大程度增加主线程的空闲时间。
持续吸引用户;在 1000 毫秒以内呈现交互内容。
最后基于上述的 RAIL 模型,我们得到了结论:Hybrid 并没有比 Weex 的体验差很多,在可控范围内。比如小程序的体验效果。
行业契合度
选择 Hybrid 之后,我们有对比了行业的契合度。因为我们的项目是一个类似与电商的项目,就是买东西的。所以还是蛮符合的。
适用场景
接下来介绍下 Hybrid 方案在项目功能内的使用场景,目前我们的项目由于是处于初期阶段,所以功能较少,主要有以下四类:
图中依次是:首页、二级页、详情页、单品详情页。依据于我们的场景,除了个人中心、订单列表、收银台之外,Hybrid 何以适用于项目的其他任何场景。
切入点
因为项目刚开始到目前为止,我们三个端都是各自实现业务逻辑,所以在实现动态化方案的过程中,不能一刀切。时间、人力、项目各种因素也不允许我们这么做。因此我们选择了一个切入点,循循渐进得完成我们需求,就像是给一辆高速驾驶的汽车更换地盘一样。
项目确定之后,我们优先考虑了单品详情页作为我们的技术切入点。具体原因如下:
展示内容居多,没有复杂交互
频繁变化,每个单品都有不同的详情内容
交互简单,适合循循渐进定制 Hybrid 的各种协议和逻辑
后面我们依次的迁移顺序为:单品详情页 -> 详情页 -> 二级页 -> 首页。
整体架构
上面聊了那么多,没多少技术的干货,现在开始介绍下整体架构的设计。在需求实现的过程中我们经常会发生统一套页面需求在 WAP 和 Native 端上都需要实现,为了解决这种需求带来的重复工作量的问题,我们在设计时加入对宿主环境兼容的考虑。
主要分为三层:
视图层
容器
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 里怎么做?请看下图:
前文我们说过了,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 的关注点:
位于 js 执行环境 和 宿主环境 之间,负责链接双方
兼容宿主环境(wap、app)差异性
适配不同业务线提供的桥连(注入 API、schema 协议)能力
根据业务线单独配置桥连能力
在 编译阶段 解决宿主环境兼容能力
js 执行环境可能是 wap 浏览器,也可能是 Native 中的 WebView。宿主环境也可能是浏览器和 Native App。对接的业务方提供的桥连能力各不相同,统一套方案需要对接至少三种不同的功能需求平台。而这些问题就是需要在 Bridge 中解决的。
上一节提到过『除了静态资源请求之外的异步化接口请求我们会通过 Native 进行代理,让 Native 帮我们发送请求』。至于为什么要这样做主要原因为:
接口鉴权问题
对数据进行更新粒度的控制
先说第一个鉴权问题,常规的做法是 App 用户登录后,将用户的认证标识存在在 webView 的 cookies 中,然后 WebView 里的业务代码发送 AJAX 请求时就会将 cookies 携带到服务器完成用户鉴权。这种情况下如果服务器端校验用户 token 失败的话是无法第一时间让 APP 跳转到登录窗口的。另外在 WebView 中发送了一个退出登录的异步接口请求,这时 APP 也需要同步退出登录。很显然,最好的办法就是让 APP 帮我们代为发送异步化接口请求。这样我们还可以利用上 APP 的持久化缓存能力来存储接口数据。
整体架构流程
宿主环境差异性
在浏览器和 Native APP 的差异性方面,我们总结了以下 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 的架构图,具体的你们自己看吧。
注意:这张图是我这个 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
『部分』原生组件的加载时机
那些总是需要在视图里第一时间展示(Header、TabBar 等)的原生组件必须区别对待。不能在 WebView 加载完之后再去渲染那些原生控件,因为这样会出现因需渲染原生控件而对 WebView 重新计算大小导致 Service 中数据错误以及页面闪烁的问题,从而影响用户体验。
最好的方法就是把这类原生组件的视图元数据单独放在一个控制版本管理的 json 文件 (下文有写到) 中,而不是放在包含 bundle 内容的 zip 包中。这样 Native 就可以根据 json 文件中的视图元信息提前渲染好原生控件,然后加载 WebView 并执行 javascript 代码。
路由系统
在设计整个路由系统之前我们有个前提条件,那就是每个视图页面都是独立的一个 WebView(其实包含两个,一个存放 View 逻辑,一个存放 Service 逻辑),而不是在同一个 WebView 中加载渲染多个页面。因为只有这样才可以完美的模拟原生应用的页面跳转的各种操作。这个一定要注意,如果你不注意你就不会理解下文到底在说什么!
我们遇到的场景有以下几种:
跳转场景:
Native to Native
Native to WebView
WebView to Native
WebView to WebView
加载场景:
同页面加载(重定向)
跨页面加载
存在的问题:
同时存在的 WebView 最大数量
视图之间的参数传递
视图历史栈管理
最后我们商定的 WebView 可以同时存在的数量为 9 个,和微信小程序一样。当页面栈已经达到 9 个的时再打开新页面就会无法打开新页面。页面之间的参数传递统一使用 querystring 格式。历史栈的管理由 Native 统一实现。
历史栈的管理
我们维护一个历史栈的目的就是让 Native 中的视图可以像浏览器的历史一样,进行前进和后退。唯一的不同是,浏览器的历史存的是 URL 字符串,而我们的历史栈存的是视图对象。每次 Native APP 打开都会重新从头记录,只会记录 APP 运行期间的历史,APP 关闭后历史栈清空。
逐级访问
正常的操作路径访问,会将每一级的视图存放在历史栈中。最多存入 9 级,超过 9 级则无法加载新页面。
重复打开页面
当最新的单品页新打开一个二级页时,即便这个二级页已经打开过,历史管理器也会在栈的顶部新打开一个二级页。注意,两个二级页是完全独立的。不存在视图提升。
重定向
在最新的单品页重定向为二级页时,和上面的重复打开页面情况类似,都是将当前页重定向为二级页并渲染。注意,这两个二级页是完全独立的。不存在视图提升。
后退
当点击 Header 左侧的后退按钮(一级一级后退)或者通过 Hybrid Router API(可以多级后退)进行后退操作时,就是消费当前的历史管理栈。
资源包的缓存与更新
当所有的步骤都已经就绪之后,就该到这一步了,bundle 资源的缓存和更新。这里我们引入的分包加载的机制,而且分包的级别是以页面为纬度的,而不是功能。其实就是小程序的那套分包加载机制。
为了实现这套机制,我们抛弃了 WebView 的缓存,和 Native 同学一起开发并建立起了这套缓存机制。并且只缓存 bundle 资源(一个一个的 zip 包)。我们规定每个业务只有一个入口 zip 包,所有的子包 zip 都必须依赖入口 zip 包中的 subConf.json 进行更新和加载。
只有在 Native 每次更新入口包是才会显示 loading,除此之外都会显示入口包中携带的骨架图 html。
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 的整体架构的三层和功能设计的三个点,基本涵盖了端动态化方向的全部要点。希望本文可以帮到你。