作者:闲鱼技术——光酒
目前,闲鱼的主要业务场景都是基于流式场景构建的。在闲鱼的主要几个业务场景下存在两种类型的页面:一种是复杂交互的页面,如发布页面、商品详情页;另一种是轻交互、需要一定动态化能力满足千人千面的运营配置及快速A/B实验需求的页面,如首页、搜索页面、我的等页面。
在这些轻交互、动态化运营的页面场景下,有很多共通的处理逻辑:页面的布局、数据的管理、事件逻辑驱动的数据变化以及数据驱动的视图状态更新;这些工作往往大部分都是重复的工作,重复的代码逻辑。
在研发效能、交付效率方面,业务的变化往往依赖于版本发布,动辄两周的发版周期,对于需要快速投放和响应的业务来说,上线时间过长将难以接受。
能否设计一种流式页面搭建能力,实现页面的快速搭建,减少重复代码,提升研发效能;提供业务动态化的能力,减少对发版发布的依赖,提高上线的交付效率?
为了解决以上问题,在Flutter版本首页改版的契机下,闲鱼设计了一套流式场景下的页面搭建架构设计。
流式页面容器架构设计
在流式布局的架构设计过程中,面对实际的业务场景,通过以下几个方面解决端到端的流式页面容器设计:
- 在搭建平台侧,实现页面搭建、组件管理、协议编排等能力,与投放平台、A/B实验平台和监控平台打通;
- 在客户端侧,采用MVVM模型,设计通用的事件协议,抽象通用的页面布局、数据管理及事件处理的能力,减少重复的代码开发,提升研发效率。在页面布局管理方面,与列表容器PowerScrollView深度结合,实现高效的页面渲染、数据驱动的页面刷新能力;
- 使用阿里巴巴集团 DinamicX作为DSL实现动态模板渲染,满足投放以及运营需求;
- 在与服务端通信协议方面,闲鱼一直在实践Flutter+FaaS的云端一体化开发,借助FaaS的能力,定义一套云端一体化的事件协议,解决业务逻辑动态化的问题,减少发版依赖,进而提升交付效率。
在流式页面容器架构设计中,重点包括以下几个核心模块:协议层、事件中心和数据中心。下面介绍这几个模板的详细设计。
协议的设计
在页面容器协议的设计方面,在结合闲鱼业务以及阿里巴巴集团的一些技术方案后,闲鱼采用了三层协议的设计:Page、Section和Component。
- Page层协议主要包含整个页面Sections信息,以及下拉刷新、上拉加载更多等配置信息;
- Section层协议包含当前Section的布局信息、初始化Event、LoadMore Event及Components等信息;
- Component层协议与具体业务相关,对于容器来说是黑盒的,具体如何渲染会交给业务方处理;默认提供DX解析渲染Handler。
在通信协议的设计上,全部采用事件传递的方式,包括:客户端与服务端、组件与组件、页面与组件、页面与App之间。这也是云端一体化的设计,理论上开发者只需要考虑事件的发送与接收,具体事件的处理在客户端还是在服务端,由对应的Handler决定。在云端一体化的设计下,事件的处理更加灵活,可以更方便地将逻辑后移,当业务发生变更时,减少对发版的依赖。
接下来就让我们来具体看一看事件中心的设计。
事件中心的设计
一切皆是Event;
在PowerContainer的设计中,一切皆是事件:不论是数据的更新、消息的传递、网络请求、服务端返回的结果,还是自定义的本地处理逻辑。闲鱼抽象定义了八种通用的事件类型,整个页面容器通过事件的流动,完成页面UI的渲染和刷新,以及业务逻辑的表达和执行。
以一次网络请求为例,一次下拉刷新会获取每个Section的initEvent事件,并添加到事件中心;事件中心根据事件类型找到对应的Handler来处理。
如果initEvent配置的是remote请求,则交给remoteHandler发送网络请求,将事件传送给FaaS端;在FaaS端收到Event后,在FaaS端的事件中心分发,找到对应的hsf服务并获取数据,最后拼装成Event的方式,下发给客户端;客户端接收到之后继续让Event在事件中心流动起来。
在处理完远端下发的事件之后,EventCenter会发送事件结束的广播,便于业务处理相关自定义事件。
通用事件抽象
下面我们来具体看一看通用事件的抽象:
- Restart事件:指定整个Page或者某个Section的刷新事件,对于需要刷新的Section,会将其initEvent事件加入事件中心。initEvent常见的一般为一个Remote事件,也可以是任意其他事件。
- LoadMore事件:LoadMore事件主要处理分页加载更多数据的场景。
- Update事件:Update事件主要处理数据源的更新及UI的刷新。
- Context更新事件:每个Section都存在一个Context信息,代表了服务端与客户端请求的上下文信息;每个Section的Rmote事件请求,都会默认将Context信息发送给服务端,相应的服务端可以下发Context事件更新指定Section的Context信息;具体使用场景例如分页加载的page number等;
- Replace事件:Replace事件替换Section信息,在tab切换等场景使用会使用;
- Remote事件:远端请求事件;
- Native事件:本地通用事件,如页面跳转、toast提示、数据埋点等;
- Custom事件:版本预埋的业务自定义事件。
数据中心的设计
在MVVM架构中,数据中心承担着ViewModel的角色,处理Update事件,主要负责数据的更新及UI视图的刷新。对于数据的Update事件,闲鱼根据自身业务场景抽象了几种通用的数据更新类型:overload、patch、override和remove。在UI渲染方面,闲鱼将列表容器PowerScrollView与动态模板渲染DXFlutter相结合,实现页面渲染及数据更新后的页面刷新能力。
列表容器
PowerScrollView是闲鱼实现的一套功能完善、高性能的列表布局容器,满足了页面容器对于瀑布流、卡片曝光、锚点定位等能力的需求。在视图渲染刷新方面,PowerScrollView提供了列表的局部刷新能力,完美地解决了数据更新后视图的刷新问题。
在协议设计上,二级协议Section以及Footer、Header的设计与PowerScrollView的设计是一一对应的。二级协议Section定义了唯一标识Key,在UI渲染中,对应到PowerScrollView的SectionKey。在数据更新后,页面容器会根据Section Key实现视图的局部刷新能力。
关于PowerScrollView的详细设计和介绍可以参考闲鱼技术公众号的文章。
动态模板渲染
DXFlutter使用阿里巴巴集团DinamicX作为DSL,在Flutter端实现了高效的动态模板渲染的能力。闲鱼使用DXFlutter实现Component层协议的动态模板渲染。
在介绍协议设计时提到过,Component层协议对于页面容器来说是黑盒,那么DX卡片事件是如何与页面容器PowerContainer打通的呢?黑盒的数据又是如何更新的呢?
在DSL中,闲鱼自定义了页面容器PowerContainer的事件powerEvent,通过它可以生成页面容器的通用事件类型,将DinamicX卡片的事件与页面容器事件中心打通。以上面代码为例,点击“删除关注列表里面的推荐卡片”的场景,只需要在onTap的事件中定义一个update类型的事件,subType为remove,即可实现数据的删除及删除后UI的渲染。
然而这里没有定义任何标识,且列表中可以存在多个相同的卡片,又是怎么知道要操作的是哪一份数据呢?
这里为每个Component生成一个唯一的ComponentKey,根据SectionKey+ComponentKey生成卡片的唯一标识。在每一个powerEvent事件中,会将Key传入事件中心,这样就定位到任意一个Component的数据model,根据事件类型更新数据model。同时,PowerScrollView也可以通过这个Key,操作UI的局部刷新。
Section状态管理
页面加载的过程中,往往需要展示一些加载状态的处理,如加载中的Loading动画、加载失败状态的重试按钮、没有更多内容状态的提示信息等。
在协议的设计方面,每个Section定义了state,在事件中心处理Remote请求事件和应答事件时,更新Section 的state。通过注册render handler,针对Section的不同状态返回加载状态Widget。
void updateSectionState(String key, PowerSectionState state) {
final SectionData data = _dataCenter.findSectionByKey(key);
if (state == PowerSectionState.loading) {
// 从ViewCenter的config获取loadingWidget
final Widget loadingWidget = _viewCenter?.config?.loadingWidgetBuilder(this, key, data);
// ViewCenter调用replace section方法更新UI
_viewCenter.replaceSectionOfIndex(loadingWidget);
// 标记需要刷新Section
data.needRefreshWhenComplete = true;
} else if (state == PowerSectionState.error) {
...
} else if (state == PowerSectionState.noMore) {
...
} else if (state == PowerSectionState.complete) {
if (data.needRefreshWhenComplete ?? false) { // 判断是否需要更新Section
final int index = _dataCenter.fineSectionIndexByKey(key);
if (index != null) {
final SectionData sectionData = _dataCenter.containerModel.sections[index];
final PowerSection section = _viewCenter.initSection(sectionData);
_viewCenter.replaceSectionOfIndex(index, section);
}
data.needRefreshWhenComplete = false;
}
}
}
Section状态变化之后,通过PowerScrollView提供的replaceSection方法,刷新UI视图。
Tab容器支持
在闲鱼首页的场景,页面容器需要支持Tab容器的布局能力。PowerContainer又是如何支持Tab容器的支持呢?
闲鱼在Section的协议中引入了插槽(Slot)的概念,当搭建页面时,会指定Tab容器的Slot Section,默认不展示任何信息的空插槽。每一次切换Tab容器,通过Replace事件修改页面容器的Section信息。
void replaceSections(List<dynamic> sections) {
if (sections == null || sections.isEmpty || _dataCenter?.containerModel?.sections == null) {
return;
}
for (int i = 0; i < sections.length; i++) {
SectionData replaceData = sections[i];
assert(replaceData.slot != null);
// 寻找Section list中与Slot匹配的index
int slotIndex = _findSlot(replaceData);
// 更新dataCenter
_dataCenter.replaceSectionData(slotIndex, replaceData);
// SectionData 转换为PowerScrollView所需的PowerSection
final PowerSection sectionData = _viewCenter?.convertComponentDataToSection(replaceData);
// 更新viewCenter
_viewCenter?.replaceSectionOfIndex(slotIndex, sectionData);
//将替换Section的Restart事件发送到event center
sendEventRestart(replaceData.key);
}
}
PowerScrollView同样也提供了replaceSection方法,与上文提到的Section状态管理相结合,完美地解决了tab容器的切换和加载状态管理的问题。
总结和展望
本节主要介绍了在轻交互、动态化运营场景下,如何从页面搭建、协议设计、端侧容器的实现、动态模板渲染、云端一体的事件交互等方面,设计并实现一套流式页面搭建能力,实现页面的快速搭建,提升研发效能。同时,提供业务动态化的能力,减少发版发布的依赖,提高上线的交付效率。
目前,页面容器PowerContainer在闲鱼首页Flutter版本重构中设计并落地。使用PowerContainer后,极大地降低了首页三个tab页面的重复代码,代码逻辑统一管理,降低了一半的人日工作量。在性能方面,有了PowerScrollView的局部刷新、Element复用、分帧渲染、差值器等方面的优化,在流畅度方面要优于原生的体验。
没有银弹!这样一套页面容器的设计并不是为了适用所有的业务场景,更适合以展示为主、轻交互动态化运营的业务场景。云端一体的事件协议,在服务端需要事件协议的封装,这也使得与Serverless的场景更加适合。
闲鱼未来还会在搭建平台侧做更多的尝试,真正实现所见即所得的快速页面搭建能力。在业务逻辑动态化方面,目前更多的是通过与FaaS的结合、逻辑后移的方式解决。但目前仍然无法解决本地自定义事件依赖发版的问题,未来在这方面也会有更多的尝试,做到 less code甚至是no code的业务开发。