一、框架
1.1、技术选型及思考
首先需要明确定位以及边界,我们需要一个怎么样的框架去解决存在的痛点。
需要的:
• 由于在首页场景使用,高性能和稳定性是最基本的要求;
• 为了不跟随版本发布,所以动态性也是要考虑的;
• 为了解决研发成本,多端渲染也是需要解决的问题;
不考虑的场景:
• 不需要处理复杂的业务逻辑;
• 不支持动画精细的交互场景;
• 不考虑多个组件的联动性;
通过梳理场景和边界使得目标清晰。我们需要一个跨平台支持动态性并且高性能 UI 渲染框架。
站在自身需求的角度,调研业界成熟的方案得出的结论如下表。
React Native:动态性高,但是学习成本和性能(加载性能、页面性能)不理想;
Flutter:谷歌的跨平台框架,性能高,但是无动态性;
通过以上的调研,我们打算用 Native 解析 JSON + Flexbox 的方式来作为最终方案。
这么做有什么优势?
1)学习成本低:Flexbox 布局方式被开发广泛接受(内部跨平台技术栈用的多的是 RN);
2)开发成本低:JSON 和 Flexbox(Yoga)都有成熟的高性能可靠的第三库直接使用,加快框架开发速度(在一个月内将框架完成并且上线);
3)兼容性强:Flexbox 完美兼容 Web 端布局的方式,FoxPage 同时支持 Web 端的 DSL 的输出;
4)自定义&扩展强:由于自研,没有包袱,可以在设计上以最符合我们的场景来设计框架;
1.2、架构设计
如何做好架构设计,可以先了解下 Chrome 是如何完成一个 HTML 到 UI 的输出。
那么 Flutter 渲染流程是如何呢?
通过调研沉淀下我们的渲染流程:
各个模块的职责清晰且独立:
Downloader:主要负责 DSL 更新与下载。
CacheManager:顾名思义,负责 DSL 的缓存管理。
Parse:这层主要是做 DSL 解析,负责将 JSON 数据组织成节点,供下层使用。
Layout:此层职责为将 Parse 模块解析之后的数据计算布局,生成布局元素。
Draw:此层职责为将 Parse 生成节点设置 Layout 层的布局信息输出 Native 视图树并提交系统完成渲染。
遵守职责化最小原则,每个模块被赋予的职责最小化并且彼此独立,使后续的维护,可以将影响范围限制模块内部,而不影响其他模块的稳定性,增强系统维护性。也使得对模块定制优化提供基础。
下文会继续针对框架内部原理做深入介绍。
1.3、DSL 的定义
数据绑定
想象一下,在我们日常开发中,往往是数据对应一个 UI 元素的显示,需要有一定的绑定数据机制。
{
"parentId":"cfb87570-82d8-11e9-811f-0906d1cca8d4",
"name": "image",
"props": {
"url":"{{product.imageUrl}}",
},
"extendId": "",
"conditions": [],
"type": "trip-app.image",
}
变量的格式为{{变量名}},如以上示例:我们的 image 组件中 url 的属性被设置为 product 对象属性中的 imageUrl 的值。
{
"parentId":"cfb87570-82d8-11e9-811f-0906d1cca8d4",
"name": "image",
"props": {
"url":"{{products[0].imageUrl}}",
},
"extendId": "",
"conditions": [],
"type":"trip-app.image",
}
数组取值格式为{{数组[Index]}},如上,我们可以通过此方法获取 products 数组中的第一个元素的图片。
事件
在组件触发事件的时侯,我们希望能做一些自定义的事情,如跳转页面,怎么定义呢?
{
"onClick": {
"name":"router_call",
"props": {
"value": {
"plugin":"router",
"method":"openURL",
"args": {
"url":"{{products.deeplink}}"
}
}
},
"type":"function.call",
}
}
以上示例表示在点击事件中通过 router 中的 openURL 打开了一个新的页面。
条件判断
在某些条件成立才渲染的场景下,我们也提供了条件判断,示例:
{
"props": {
"hidden": {
"name":"render-activity",
"type": 1,
"props": {
"items": [
{
"key":"{{data.showActivities}}",
"operation":"eq",
"value":"1"
}
]
}
}
},
"type":"trip-app.image",
}
如上示例: image 组件是否隐藏通过{{data.showActivities}} =="1"来控制。
埋点机制
我们还定义了动态埋点的一些规范,如示例:
{
"name": "image",
"props": {
"$traceData": {
"onClick": {
"eventName":"home.click.deals.item",
"data": {
"url":"{{data.operatingActivities.0.deeplink}}",
"type":"operating_activities"
}
}
}
},
"type":"trip-app.image"
}
在 image 组件中声明了点击事件,并且把需要的参数,通过 data 字段一并上传服务端。
1.4、布局
我们的目标是为了解决动态性和多端一致性,那么具备一个完备的布局能力是一个基础要求。通过调研,有以下3种方案可选。
1)自定义:完全自定义一套规则,实现成本高,布局效率取决于实现程度,所以这边是“中”,因为是自定义,所有通用性是三者最差的,几乎独家专属。
2)Web CSS:实现一套 Web CSS 样式集,可想而知,如果实现这样的一套系统代价是极高的,为了兼容众多的 CSS 样式,布局效率必然会下降,但是此方案通用性也是最佳,多端共享。
3)Flexbox:弹性盒子布局,从 Web CSS 子集发展而来,在 RN 已得到充分证明它的适用性,由于 Yoga 的存在,让我们在实现成本上得到下降。
Yoga 是 Facebook 基于 Flexbox 的跨平台布局引擎开源库,被用于 RN,Weex 等项目中,也证明了其高性能和可靠性。
在实际使用过程中,Yoga 在 Android 中发现了一些问题,不过我们通过定制源码完美解决,并且在实际体验下来 Yoga 完美的胜任了布局的任务。
一些布局示例:
"props": {
"$layoutStyle": {
"position":"absolute",
"bottom": "12",
"flexDirection":"column",
"marginLeft": "8",
"width": "100%",
"paddingRight":"16"
}
}
单位定义
width、height 等实际数字单位定义中,我们定义了如下的数字单位。
1.5、DSL 解析
解析层,要做的事情比较简单,为了提高性能,并且对于相同的 DSL 模板,只会做一次解析,之后便会把结果做一个缓存,以下的流程图代表着解析流程。
1.6、视图构建
视图构建相对简单,通过解析层解析之后,每个视图组件都会ViewNode节点一一对应视图在虚拟视图树中的状态,包括了视图布局属性,视图属性等元素信息。
以下为 iOS 代码示例:
/**
视图节点,映射 FoxPage 中的组件
*/
@interfaceFPViewNode : NSObject
/**
视图属性
*/
@property(nonatomic, strong) FPViewAttribute *attribute;
/**
子视图
*/
@property(nonatomic, copy) NSArray<FPViewNode *> *children;
/**
绑定关系的值
*/
@property(nonatomic, copy, readonly) NSArray <NSDictionary *> *bindValues;
/**
绑定关系的值
*/
@property(nonatomic, strong) NSMutableArray<NSString *> *conditions;
/**
真正的视图引用
*/
@property(nonatomic, weak) UIView<FPViewComponentProtocol> *view;
/**
添加的父视图
*/
@property(nonatomic, weak) UIView<FPViewComponentProtocol> *superView;
@end
在ViewNode树准备好之后,我们会将树传递到渲染层中进行渲染操作,在渲染层中,根据ViewNode节点的类型,通过代理的方式,从注册的组件之中创建出视图实例,配合 Yoga 布局属性,转换到 Native 视图的映射,由系统完成最终的渲染。
1.7、数据更新
在解析渲染完成之后,关于数据流是怎么处理的呢?以下是处理流程图:
为了优化性能,我们针对 UI 元素有变化的部分做 dirty 处理,会触发 Layout 和 Draw 模块重计算和重绘。
1.8、动态更新
动态更新能力是重要的一环,在云端更新了页面布局或者样式之后,App 需要即时拉取到最新的 DSL 模板。以下是流程中的时序图:
需要注意几点:
1)App 打包需要把线上目前可用的 DSL 模板打包进 App 中,避免第一次打开 App DSL 模板未下载的时候的空窗口现象;
2)版本升级需要做好数据隔离和清除;
3)DSL 最新版本下发,需要做好 backup 与异常校验;
通过动态更新机制,改变了我们发布需要跟随版本的痛点,有问题,修复之后可以直接下发到用户的 App。
1.9、可视化页面搭建平台
看到这里的看官,心中肯定会有疑问?你们提供变量、布局、事件、埋点追踪,条件,手动来写这很模板复杂度肯定是很高的,不是普通人可以胜任的吧。
是的,我们也意识到了此问题,所以配套了一套可视化的编辑界面。如下面示例图:
左边是可视化编辑页面,右侧为实际在 App 场景的使用效果,可以看出还原度还是很高的。
属性编辑界面:
二、页面工程化的转变
通过动态化的转变之后,首页的业务需求开发的工程模式与研发流程也由此发生变化。
在旧模式下,研发人员更加关注业务需求如何实现,首页的业务需求如何在已有的框架体系之内跑起来。新模式下,研发人员更注重的是,业务组件如何设计,如何完成的一个高质量的业务组件。
将研发人员关注的复杂度从面降为点,使得首页的各个业务模块之间的独立性更高,以及更加稳定。模块之间的复用性提升,如其他业务部门也想用广告组件,他们只需要在其页面做些简单的配置。
在组件生态不断补充的未来,各个业务线之间共享彼此的组件模块,想完成一个新的业务或者产品,只需要像乐高积木一样堆砌即可。
三、构建业务运营闭环
在提供技术基础的条件下,我们继续思考技术和业务之间的关系,如何将业务价值最大化,UI 搭建可以通过平台搭建,是不是可以把产品运营同学也一起参与进来,构建一个业务运营闭环。
1)产品运营同学提出需求;
2)研发人员介入需求开发,开发组件;
3)组件搭建业务上线之后,一站式追踪线上业务价值;
4)根据平台的数据来实时进行运营策略,如修改页面模块,下线模块,添加模块等等;
5)然后反推产品同学提出更合理的产品需求;
如何优化链路,科学的运营体系构建运营业务闭环也是重中之中,并且未来会持续不断在此方向上探索。
四、总结
FoxPage Native 平台上线短短 2 个月,承载了 30+ 次的业务调整,5+ 次临时调整埋点的需求,这是之前做不到的。并且合理系统的设计也增加了框架稳定性,上线至今无任何异常发生。在保证的高质量的交付的同时,也大大减少我们研发成本,如一个复杂的展示模块开发,从原来的双端 4 人日降低到双端 1 人日。
在首页动态化的探索中,遇到了很多的挑战,也有很多收获,后续有很多的功能和需求还需要继续优化和完善,并且需要考虑更多的场景支持。我们相信这是一个好的开头。