近年来,伴随着大前端概念的提出和兴起,移动端和前端的边界变得越来越模糊,涌现了一大批移动跨平台开发框架和模式。从早期的PhoneGap、inoc等Hybird技术,到现在耳熟能详的React Native、Weex和Flutter等技术,无不体现着移动端开发的前端化。而提供一套三端统一的开发框架,一直是前端奋斗的目标,而React Native就是这么一个不错的三端统一的跨平台开发框架,这方面的知识可以参考我之前出的《React Native移动开发实战》关于跨平台相关的内容分析。
注:本文原文地址开源的 React Native 组件库
beeshell简介
一个 React Native 应用的基础组件库,基于 0.53.3 版本,提供一整套开箱即用的高质量组件,包含 JS 组件和复合组件(包含 Native 代码),涉及 FE、iOS、Android 三端技术,兼顾通用性和定制化,支持自定义主题,用于开发和服务企业级移动应用。开源地址:github.com/meituan/bee…
特性
据称,beeshell已经被广泛使用在美团外卖的多条业务线,通过了各种业务场景、操作系统、机型的实战考验,具备很好的稳定性、安全性和易用性等特点,基于此,美团将此开源出来以供大家使用和借鉴。
- UI 样式的一致性和定制化。
- 通用性:主要使用 JS 来实现,保证跨平台通用性。
- 定制化:我们在比较细的粒度上对组件进行拆分,通过继承的方式层层依赖,功能渐进式增强,为在任意层级上的继承扩展、个性化定制提供了可能。
- 原生功能支持:组件库中的复合组件包含 Native 代码,支持图片选择、定位等原生功能。
- 功能丰富:不仅仅提供组件,还提供了基础工具、动画以及 UI 规范。 完善的文档和使用示例。
对比
在beeshell开源之前,React Native社区已经出现了很多流行且著名的脚手架框架。此处选取 Github Star 数 5000 以上的组件库,并从组件数量、通用性、定制化、是否包含原生功能、文档完善程度五个维度来进行对比分析。
组件库 | 组件数量 | 通用性 | 定制化 | 是否包含原生功能 | 文档完善程度 |
---|---|---|---|---|---|
react-native-elements | 16 | 强,提供一套风格一致的 UI 控件 | 弱,若要定制化可能需要重写 | 否 | 高 |
NativeBase | 28 | 强,提供一套风格一致的 UI 控件 | 中,支持主题变量 | 是 | 高 |
ant-design-mobile | 41 | 强,提供一套风格一致的 UI 控件 | 中,部分可以支持定制化需求 | 是 | 低 |
beeshell | 25 | 强,提供一套风格一致的 UI 控件 | 强,不仅支持主题变量,还支持使用继承的方式进行定制化扩展 | 是 | 高 |
通过对比可以看出,beeshell 只在组件数量上稍有劣势,在其他方面都一致或者优于其他项目。因为 beeshell 具备了良好的系统架构,所以丰富组件数量只时间问题,而且我们团队也已经有了详细的规划来完善数量上的不足。
系统设计
系统设计是将一个实际问题转换成相应解决方案的主动过程,是解决办法的描述。在通用的软件工程模型中,需求分析完成后的第一步就是系统设计。一个项目最终的稳定性、易用性在很大程度上也取决于系统设计这一步。
beeshell 组件库是为了更加快速的搭建移动端应用,为业务开发提供基础技术支持,大幅提升开发人效。然而,面对不同的业务方、不同的功能需求、不同的 UI 规范与交互方式,如何有效的兼顾所有的需求?这对系统设计提出了更高的要求,下面以抽象层次逐层降低的方式来详细介绍 beeshell 的系统设计。
框架设计
beeshell 组件库基于 React Native,向下通过 React Native 与 iOS、Android 平台进行系统层面的交互,向上提供开发者友好的统一接口,抹平平台差异,为用户开发业务功能提供服务支持。beeshell 扮演了一个中间者的角色,从而保证了移动端应用基础功能的稳定性、易用性。其框架的设计原理如下图。
方案设计
为了更好的介绍beeshell,我们来看一下beeshell设计上的一些细节。整体上使用 JS 作为统一入口,多层封装隐藏实现细节,抹平 JS 与 Native、iOS 平台与 Android 平台的差异,开箱即用,降低了用户的学习和使用成本。局部上基于 React Native 的技术特点,分成 JS 组件部分和复合组件部分,两部分推行“松耦合”的开发模式,使得 Native 部分拥有替换变更的能力,提升组件库的灵活性。
复合组件部分可以直接暴露 JS 接口,如果有需要,也可以在 JS 组件部分进行定制化封装。我们尽量保证 Native 部分功能的原子性、简洁性,有任何定制化需求都使用 JS 来统一实现,遵循 JS 实现优先的设计原则,保证跨平台通用的特性。为了达到上面的要求,下面从JS 组件部分和复合组件两个部分来介绍。
JS 组件部分设计
一个软件的设计分为三个设计层次:体系结构、代码设计和可执行设计。我们使用自上而下的方法,从体系结构开始进行 JS 组件部分的设计。
软件的体系结构的风格通常有 7 种:管道和过滤器,面向对象,隐式请求,层次化,知识库,解释程序和过程控制。
JS 组件部分使用了层次化的体系结构风格,整体分成三层:基础工具、通用组件、扩展组件,从上到下通用性逐渐减弱、定制化逐渐增强,功能渐进式增强,通过分层设计,各层各司其职,兼顾通用性和定制化。
- 基础工具(common):最基础的、通用的部分,包含 JS Utils、动画定义、UI 规范等。
- 通用组件(components):把功能相似的组件进行归类,整理成一个个系列,每个系列内部使用继承的方式实现,层层依赖,功能渐进式增强,该部分专注通用性,不考虑定制化需求,保证代码的简洁性。同时,在比较细的粒度对组件进行拆分,提供了良好的可扩展性。
- 扩展组件(modules):是对通用组件的继承扩展、组合应用,该部分专注定制化,在最大程度上满足业务上的需求,通用性较低。
我们扩展组件部分会提供大量的定制化组件,如果仍然不能满足需求,用户就可以借鉴扩展组件的实现,根据自己业务需求,在某一继承层级上继承通用组件,自行进行定制化扩展,这点充分体现了 beeshell 定制化的能力。
复合组件设计
既然是 React Native 组件库当然少不了 Native 部分,复合组件包含 Native 的功能。beeshell 组件库已经完成了 Native 部分的集成方案与规范,有良好的开发与使用体验,可以不断的集成原生功能。
复合组件部分通过 JS 封装接口,保证了跨平台。Native 部分主要分成 Native Bridge 和纯 Native 两大部分,Bridge 是针对 React Native 的封装,必须在组件库中实现;而纯 Native 部分则可以通过 Pods/Gradle 依赖三方实现,有效的吸收利用原生开发的技术积累。
组件库实现
跨平台组件
React Native 提供了一些内置组件,我们能使用 JS 来实现功能都是基于这些内置组件,这些内置的组件一些是跨平台通用的组件,如:View、Text、TextInput;而另一些是两个平台分别实现的,如 DatePickerIOS 和 DatePickerAndroid、AlertIOS 和 ToastAndroid。例如:
iOS 平台的 DatePickerIOS 组件:
Android 平台的 DatePickerAndroid 组件:
不仅功能交互完全不同,而且类名、调用方式各异,这不仅满足不了业务需求,而且也有很高的学习和使用成本。这样类似的组件还有很多,如何抹平平台的差异,实现跨平台?我们提出的方案是优先使用 JS 来实现功能,这也是我们组件库的设计原则。针对上面的问题我们开发了基于 ScrollView 的 Datepicker 组件,统一类名与调用方式,保证了跨平台通用性。下面是Android和iOS实现的Datepicker组件。
Datepicker 是使用 JS 完全实现了一个完整功能,但是有的情况不需要实现完整的功能,我们可以通过 React Native 提供的 Platform 来进行局部的跨平台处理。例如 TextInput 组件,默认在Android平台下是没有清空按钮的,但是我们可以通过自定义来实现清空功能。
定制化支持
随着移动互联网的快速发展,各类移动端产品涌现并且不断发展,这也让软件知识不断被普及,业务方对产品功能的定位逐渐从厂商主导转变为用户主导。产品功能更加精准,个性化、细化、深化是必然趋势,通过定制化服务来满足产品发展的要求也应运而生。不同行业、不同类型的产品,功能、特点各不相同,用某一种既定的软件产品来满足不同类型的需求,其适用性可想而知。定制化有良好的技术架构和技术优势,可定制、可扩展、可集成、跨平台,在个性化需求的处理方面,有着很好的优势,所以我们需要定制化。
样式定制化
在组件库设计之初,就已经统一好了 UI 规范。我们根据 UI 规范,统一定义样式变量并放置在基础工具层中,即 beeshell/common/styles/varibles.js 文件中,在 React Native 应用中,样式变量其实就是普通的 JS 变量,可以很方便的进行复用与重写操作。React Native 提供了 StyleSheet 通过创建一个样式表,使用 ID 来引用样式,减少频繁创建新的样式对象,在组件库的样式变量应用中灵活使用 StyleSheet.create 和 StyleSheet.flatten 来获取样式 ID 和样式对象。
在每个组的实现中,会事先引入基础工具层中的样式变量,使用统一的变量对象而不是在组件中自行定义,这样就保证了 UI 样式的一致性。同时,beeshell 提供了重置样式变量的 API,可以实现一键换肤。我们推荐 beeshell 的用户在开发移动应用时,事先定义好样式变量。一方面使用自己的样式变量重置 beeshell 的样式变量;另一方面在业务功能开发时,使用自己定义好的样式变量,从而保证整体 UI 的一致性。
功能定制化
样式定制化可以从宏观和整体的角度来实现,而功能的定制化则需要具体问题具体分析,从微观和局部的角度来分析和实现。下文将以 Modal 系列的实现为例,来详细介绍功能定制化。
在移动端的弹窗交互,与 PC 端相比一般会比较简单,我们把模态框、下拉菜单、信息提示等交互类似的组件统一归类为 Modal 系列,使用继承的方式实现。有人可能会问为什么使用继承而不用使用组合?前文已经讲过,组合的主要目的是代码复用,而继承的主要目的是扩展。考虑到弹窗交互有很多定制化的可能性,为了满足更好的扩展性,我们选择了继承的方式来实现。下面来看一下实现效果:
提供了遮罩、弹出容器以及淡入淡出(Fade)动画效果,弹出内容部分完全由用户自定义。这个组件通用性极强,没有任何定制化的功能。这里需要说明下,动画部分独立实现,提供了 FadeAnimated 和 SlideAnimated 两个子类,使用了策略模式与 Modal 系列集成,Modal 组件默认集成 FadeAnimated。
继承 Modal 组件,对弹出内容做了一定程度的定制化扩展,支持标题、确认按钮、取消按钮以及自定义 body 部分的功能,通用性减弱,定制化增强。
PageModal继承 SlideModal 组件,对弹出内容做了定制化扩展,支持标题、确认按钮、取消按钮以及自定义 body 功能,通用性减弱,定制化增强。 CheckboxModal 组件由 PageModal 和 Checkbox 两个组件使用组合的方式实现,基于通用型组件组合出了更加强大功能,遵循继承与组合灵活运用的设计原则。通过以上部分,我们已经对 Modal 系列已经有了直观的认识,然后我们来看下 Modal 系列的类图以及分层。
动画部分在基础工具(common)中实现;在通用组件(components)中 Modal 组件聚合 FadeAnimated 动画,同时因为 SlideModal、ConfirmModal 比较通用,也在该部分实现;CheckboxModal 则定制化比较强,归类到扩展组件(modules)中。通过这种方式的分层,三层各司其职,使得组件库的层次结构更加清晰,不仅实现了定制化,还保证了通用部分的简洁性和可维护性。复杂情况
递归处理异步渲染
React Native 应用的 JS 线程和 UI 线程是两个线程,与浏览器中共用一个线程的实现不同,所以我们可以看到 React Native 提供的操作 UI 元素的 API,都是通过回调函数的方式进行调用。
受益于 React,我们一般不需要直接操作 UI 元素,但是有的组件确实需要复杂的 UI 操作,例如完全由 JS 实现的 Scrollerpicker 组件。
在此种Case中,我们需要精确的计算容器以及每一项元素的高度,才能正确得到当前选中的项在数据模型(数组)中的索引。现在面临的问题是:在组件渲染完成后的生命周期 componentDidMount 并不能拿到正确容器的高度为,而使用 setTimeout 也会有延迟时长设置为多少的问题。我们选择使用递归来解决,一次 setTimeout 不行就执行多次。UI 尺寸容错机制
React Native 为用户提供了 style 属性来控制元素的样式,我们可以手动设置相关 UI 元素的尺寸。但是,在一些 Android 机器上,我们设置的元素尺寸与 measure 方法获取的尺寸信息不一致,经过大量 Android 机器的实际的测试,我们得到的结论是:有零点几像素的误差。
可以通过 measure 方法得到尺寸信息进行向上与向下取整,得到一个阈值范围,手动设置的尺寸信息只要在这个阈值范围内,就认为是有效尺寸,这种容错机制有效的兼容了极端情况,提高了组件的稳定性。精细化布局控制
在使用 Form 组件时,最常见的需求就是校验功能,通常组件库的 Form 组件都会内置校验功能。然而,因为校验方式有同步与异步两种,校验结果展示的样式、位置五花八门,这就导致了校验功能的复杂度变得很高。
为此,beeshell提供了以下几种布局方式: 绝对定位
Static 定位 自定义位置 如何有效的兼顾不同的需求?我们提出了校验独立实现的方式,在使用 Form 组件的父组件中,使用 CVD 来定义、配置校验规则,校验结果输出到统一的数据结构(单一数据源),基于这个数据结构,我们就能在任意时机、任意位置、使用任意样式来展示校验信息。何为CVD,下面看一个模型。
CVD 是一个针对复杂表单录入场景的分层解决方案,轻量级、跨平台、易扩展,内置在 beeshell 组件库中,可以直接使用。CVD 把表单某个控件的录入的流程分成三层:- Connector 连接器,把用户输入的信息转化成所需的数据格式。
- Validator 校验器,对格式化的数据进行校验。
- Dependency 依赖处理器,处理当前控件与其他控件的依赖关系。
每一层都对单一数据源 Store 进行不可变数据更新,符合交互内聚和顺序内聚,内聚程度高。 每一层使用函数式组合的方式,定义 key(表单控件的唯一标志)与 key 对应的回调函数,避免了批量 if else,可以有效降低程序的圆环复杂度。
下面以 Input 组件录入姓名为例,来具体说明CVD的运作原理。
人挪活在 onChange 中获取用户输入,调用 cvd.flow 然后就可以通过 cvd.getStore 获取到结果。 通过校验功能独立实现,把校验信息输出到 Store 中,在需要的时候从 Store 中获取校验信息,可以更加精细化的控制元素的样式、位置与布局,兼容各种定制化需求。测试
代码的终极目标有两个,第一个是实现需求,第二个是提高代码质量和可维护性,测试是为了提高代码质量和可维护性,检测代码的质量。
单元测试
单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证。在结构化编程的时代,单元测试中单元指的就是函数。beeshell 组件库全面使用单元测试,由组件的开发者完成。研究成果表明,无论什么时候作出修改都需要进行完整的回归测试,对于提供基础功能的组件来说更是如此,在生命周期中尽早地对软件产品进行测试将使效率和质量都得到最好的保证。Bug 发现的越晚,修改它所需的成本就越高,单元测试是一个在早期抓住 Bug 的机会。beeshell 组件库使用 Jest 做为单元测试的工具,自带断言、测试覆盖率工具,实现开箱即用。
测试用例设计
测试用例的核心是输入数据,我们会选择具有代表性的数据作为输入数据,主要有三种:正常输入,边界输入,非法输入。下面以组件库中提供的 isLeapYear 工具函数来举例说明。
Jest 使用 test 函数来描述一个测试用例,其中的 toBe 边是一句断言。函数使用了外部数据,正常输入肯定会有,这里的 2000 和 '2000' 都是正常输入;边界输入和非法输入并不是所有的函数都有,这里为了说明使用了有这两种输入的例子,边界输入是有效输入的极限值,这里 0 和 Infinity 是边界输入;非法输入是正常取值范围以外的数据, 'xx' 和 false 则是非法输入。一般情况下,考虑以上三种输入可以找出函数的基本功能点,单元测试与代码编写是“一体两面”的关系,编码时对上述三种输入都是应该考虑的,否则代码的健壮性就会出现问题。
上文所说的测试是针对程序的功能来设计的,就是所谓的“黑盒测试”。单元测试还需要从另一个角度来设计测试数据,即针对程序的逻辑结构来设计测试用例,就是所谓的“白盒测试”。
这里有一个 if else 语句,如果我们只提供一个 2000 的输入,只会测试到 if 语句,而不会测试 else 语句。虽然,在黑盒测试足够充分的情况下,白盒测试没有必要,可惜“足够充分”只是一种理想状态,难于衡量测试的完整性是黑盒测试的主要缺陷。而白盒测试恰恰具有易于衡量测试完整性的优点,两者之间具有极好的互补性。白盒测试也是比较常见的需求,Jest 内置了测试覆盖率工具,可以直接在命令中添加 --coverage 参数便可以输出单元测试覆盖率的报告。
UI 自动化测试
想要确保组件库的 UI 不会意外被更改,快照测试(Snapshot Testing)是非常有用的工具。一个典型的移动 App 快照测试案例过程是,先渲染 UI 组件,然后截图,最后和独立于测试存储的参考图像进行比较。
使用 Jest 进行在快照测试,在 beeshell 中第一次对某个组件进行测试时,会在测试目录下创建一个 snapshots 文件夹,并将快照结果存放在该文件夹中。快照结果文件以 <组件名>.js.snap 命名,其内容为某个状态下的 UI 组件树。下面以 Button 组件为例来说明如何使用快照测试。
运行命令后得到快照结果如下:
静态分析
经常与单元测试联系起来的开发活动还有静态分析(Static analysis)。静态分析就是对软件的源代码进行研读,查找错误或收集一些度量数据,并不需要对代码进行编译和执行。
静态分析效果较好而且快速,可以发现 30%~70% 的代码问题,可以在几分钟内检查一遍,成本低、收益高。beeshell 使用 SonarQube 进行静态代码检查。
SonarQube 是一个开源的代码质量管理系统,支持 25+ 种语言,可以通过使用插件机制与 Eclipse、VSCode 等工具集成,实现对代码的质量的全面自动化分析和管理。
SonarQube 通过对 Reliability(可靠性)、Security(安全性)、Maintainability(可维护性)、Coverage(测试覆盖率)、Duplications(重复)几个维度,对代码进行全方位的分析,通过设置 Quality Gates 保证代码质量。详细的使用情况可以访问SonarQube官网文档
beeshell开发与使用
beeshell 组件库使用 npm 包的形式下载使用,下载成功后会放置在项目根目录的 node_modules 目录,然后在项目中通过引入模块的方式,引入 beeshell 的组件来使用。
那我们如何开发组件库?如何保证组件库的开发与使用的体验一致性?
首先,我们需要一个 demo 项目,这个项目是 beeshell 组件库的开发环境,是一个 React Native 应用。然后,我们把 beeshell 做为 demo 项目的依赖,在 demo 项目中下载安装。现在,问题就变成了 node_modules 目录中的 beeshell 如何和本地的 beeshell 源码进行同步。
npm link
我们知道,可以使用 npm link 来开发 npm 包,其工作原理如下图:
本质是就是使用 Symbol link,但是我们建立好软链接后,运行打包命令却报错了,错误信息为 Expected path '/xxx/xxx/index.js' to be relative to one of project roots。
前端开发通常会用 Webpack 做为打包工具,而 React Native 应用使用的是 Metro,所以此处我们需要分析 Metro 来定位问题。
Metro
经过 Metro 的源码分析,我们发现 Metro 的打包方案与 Webpack 有较大差异,Webpack 是根据入口文件,即配置中的 entry 属性,递归解析依赖,构建依赖关系图而 Metro 是爬取特定路径下的所有文件来构建依赖关系图。分析发现 Metro 的特定路径默认是运行打包命里的路径,以及 node_modules 下第一层目录。
Metro 在爬取文件的时候,通过软链接找到了全局的 beeshell 但是并没有继续判断全局的 beeshell 是否有软链接,所以无法爬取 beeshell 源码部分。
软链接
通过 ln -s 命令,直接建立 demo 项目 node_modules 下 beeshell 包 与 beeshell 源码的软链接。
这种方式同时支持 Native 部分 iOS、Android 的源码开发,注意 Android 部分的需要在 setting.gradle 中调用 getCanonicalPath 方法获取建立软链接后的路径。 通过试验、发现问题、分析源码、定位问题、解决问题、方案完善这几个步骤,完整的实现了 beeshell 组件库的开发与使用的体验一致性,同时提升了组件库的开发效率。本文为转载文章,原文地址:tech.meituan.com/waimai-bees…
附: beeshell开源地址