前言
XSearch 做为搜索客户端的基础框架,已经在线上运行多年,它同时支撑了主搜、拍立淘、云主题、店铺内等业务。XSearch深刻践行了 Native + 动态化的理念, 端上框架内提供动态化坑位,定义好交互行为,而服务端数据定义有哪些坑位,以及坑位内渲染哪些数据。这一套运作机制,保证了搜索的业务能够快速迭代。然而随着业务的不断发展,框架在不断壮大的同时,也存在了隐含的问题。例如代码复用率低、有些体验问题无法解决等。本文就基于业务容器和业务架构两方面进行总结阐述。
容器升级
▐ 1.0时代
XSearch列表容器按照功能划分,主要分为 SRP 和 XSearchList,前者是给主搜索使用的,面相 Native 开发,支持端上开发进行各种特殊交互定制。而后者目前支撑了拍立淘结果页、云主体二跳页、分类等业务,面相前端开发,框架内定制了标准的数据协议以及交互行为,前端可以根据自己的需求进行使用。
- SRP
动图中展示了一个带场景层的搜索结果页案例,整个页面的联动分为两个部分
- 头部场景层区域
- 列表内排序条区域
场景层结构
通用头部容器
SearchAppBarLayout 是一个通用头部容器,容器对每个子 View 定义了 layout_level 和 layout_position
level 越小的子 View,越先消耗 offset
position 越小的子 View,在y 轴上越靠上
offset 指的是列表的累积滚动距离
以下面这个 demo 为例,看一下完整效果
<com.taobao.scrolltosample.demo.SearchAppBarLayout android:id="@+id/slide_layout" android:layout_width="match_parent" android:layout_height="wrap_content"> <LinearLayout android:id="@+id/libsf_srp_header_bottom_fold_part_container" app:layout_level="0" app:layout_position="6"> /> <LinearLayout android:id="@+id/libsf_srp_header_folder_part_container" app:layout_level="0" app:layout_position="3"> <LinearLayout android:id="@+id/libsf_srp_header_fold_container" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> /> </LinearLayout> <FrameLayout android:id="@+id/libsf_srp_header_tab_container" app:layout_level="1" app:layout_position="2"/> <LinearLayout android:id="@+id/libsf_srp_header_half_sticky_container" app:layout_level="1" app:layout_position="4"/> <LinearLayout android:id="@+id/libsf_srp_header_sticky_container" app:layout_level="2" app:layout_position="5"/> <FrameLayout android:id="@+id/libsf_srp_header_searchbar_container" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_level="1" app:layout_position="0"/> </com.taobao.scrolltosample.demo.SearchAppBarLayout>
容器内部对子 View 做了两个排序,一个队列是根据子 View 的 layout_position 属性,以从小到大的顺序排列(队列 mSortedViews)。另一个对了根据子 View 的 layout_level 属性,按从小到大的顺序排列,若有level 相同的子 View,则按照 layout_position 从小到大排列(队列 mLevelSortedViews)。完成子 View 的排列之后,进入布局逻辑,按照 position 升序,从上往下顺序放置 View
protected void layoutChildViews(int left, int top, int right, int bottom) { final int parentLeft = 0; final int parentRight = right - left; final int parentTop = 0; int nextTop = parentTop; int size = mSortedViews.size(); for (int i = 0; i < size; i++) { ViewTuple viewTuple = mSortedViews.get(i); View child = viewTuple.view; final LayoutParams lp = (LayoutParams) child.getLayoutParams(); child.layout( parentLeft + lp.leftMargin, nextTop + lp.topMargin, parentRight - lp.rightMargin, nextTop + child.getMeasuredHeight() ); viewTuple.start = nextTop; viewTuple.height = child.getMeasuredHeight(); viewTuple.level = lp.level; viewTuple.offset = 0; nextTop += child.getMeasuredHeight() + lp.bottomMargin; }
然后是处理各子 View 移动的逻辑
protected void calChildrenOffset(int toConsume) { int size = mLevelSortedViews.size(); // 将level最低的view之上的view向下移动尽量多的距离.直到消耗完toConsume. for (int i = 0; i < size; i++) { ViewTuple tuple = mLevelSortedViews.get(i); if (tuple.view.getVisibility() == GONE) { continue; } //移动不能超过本身的高度. int move = Math.min(tuple.height - tuple.pinBottom, toConsume); calViewNewOffsetBefore(tuple, move); toConsume -= move; //消耗完成 if (toConsume <= 0) { break; } } } /** * 计算位置在tuple之前的view的newOffset,会相应的向下移动move距离. * * @return 是否移动了任何viewTuple */ protected boolean calViewNewOffsetBefore(ViewTuple tuple, int move) { boolean moved = false; int size = mSortedViews.size(); for (int i = 0; i < size; i++) { ViewTuple viewTuple = mSortedViews.get(i); if (viewTuple.view.getVisibility() == GONE) { continue; } if (viewTuple == tuple) { break; } viewTuple.newOffset = viewTuple.tempOffset + move; viewTuple.tempOffset = viewTuple.newOffset; moved = true; } return moved; }
根据 level 升序进行遍历,在移动一个 View 之前,需要先计算出position 在它之上的 View 的移动距离,例如在 demo 中,按照 position 排列是tab- fold - half_sticky - sticky - bottom_fold,按照 level 排列是fold - bottom_fold - tab - half_sticky - sticky。
因此,假如一开始移动50px,则先遍历到 fold,然后移动 position 在fold 之上的 View,那么此时 tab 先触发移动。当 offset 超过 tab 的高度时,开始处理bottom_fold,此时移动 position 在 bottom_fold 之上的 View,因此会将 tab + fold + half_sticky + sticky 都进行移动,如下动图所示。
在处理完子 View 的移动后,再将容器本身进行反方向移动,达到子 View 看着是向上移动的效果。
/** * @param offset 绝对偏移 */ public void setOffset(int offset) { ... //先清空之前的newOffset for (int i = 0; i < size; i++) { ViewTuple tuple = mLevelSortedViews.get(i); tuple.newOffset = 0; tuple.tempOffset = 0; } calChildrenOffset(toConsume); mOffsetTop = offset; //根据计算结果移动子view moveChildren(); //移动自身 ViewCompat.offsetTopAndBottom(this, mOffsetTop - (getTop() - mLayoutTop)); } /** * 按viewTuple中的newOffset移动view并重设offset. */ private void moveChildren() { int size = mSortedViews.size(); for (int i = 0; i < size; i++) { //遍历子 View,根据 newOffset 和 offset 的差值,进行 View 的偏移 if (viewTuple.newOffset != viewTuple.offset) { ViewCompat.offsetTopAndBottom(viewTuple.view, viewTuple.newOffset - viewTuple.offset); ... } } }
以上代码实现了容器内子 View 根据列表滚动距离的 offset 值进行同步移动的效果。然后说一下 sticky 效果的实现,容器中维护了一个最小 level 值,子 View 中小于这个 level 的,经过移动,最终都会隐藏,而大于等于这个 level 的子 View,则会呈现 sticky 的效果。容器在布局完成后,会计算出每个 level 的子 View 最大允许的滚动距离,然后在输入 offset 值时,进行 offset 范围限制,保证 sticky 对应的子 View 不会移动出屏幕范围。当然这个方案显而易见的问题是, 只有在容器最下方的 View 才能实现 sticky 效果。
场景层实现
淘宝的 TRecyclerView 组件支持添加Header和 Footer,在场景层 case 中,添加了一个透明的 HeaderView,headerView 的高度和场景层的高度相等,从而扩大列表滚动区域,同时给列表设置一个负偏移,保证 blankView 被列表父容器裁切掉。而在头部容器中,同时在 fold 区域添加了一个占位的 View,高度也和场景层相同,用来将头部容器撑开。当列表滚动时,通过 onScroll 回调同步通知给头部容器,使其内容一起向上移动,在头部容器移动的同时,给列表父容器同时设置一个反方向偏移,保证抵消列表的滚动,保证内容不变。
筛选条吸顶实现
筛选条的吸顶比较简单,就是在 列表负容器内同时放置一个 View,在场景层收起时,RecyclerView 和筛选条的父容器会同时做平移,保证列表和筛选条整体一起向上移动,并且能一直遮住列表。
- XSearchList
如上图所示,XSL分上下两个区域,上半区域包含 topHeader 和 tab;下半区域是一个多 tab 结构,每个子 tab 都支持 fold、sticky 和 listHeader。
上半区域实现
topHeader 和 tab 的联动通过 NestedScroll api 实现,当下方列表开始滚动时,外部容器进行拦截,然后将上半区域的内容进行展开或者收起,待上半区域无法再被滚动时,才继续滚动列表内容。
为了让手指滑动上半区域时能联动下半区域,topHeader和 tab 的容器全部使用特殊的 NestedScrollView(一个支持根据内容高度展开的的 NestedScrollView),当手指滑动上半区域时,外部容器同样能进行拦截。
下半区域实现
fold和 listHeader 的实现较为简单,前文说过,手淘的 RecyclerView 组件支持 HeaderView(就是个特殊的 ViewHolder),XSL 将 fold、stickyMask 和listHeader全部放到了 HeaderView,因此可以跟随列表正常滚动。比较特殊的 stickyHeader 的实现,为了让stickyHeader 能吸顶盖住列表,在列表的上方放置了一个 StickyLayout,用来存放 stickyHeader,然后监听 stickyMask 的位置,动态调整 stickyLayout 的位置,从而实现吸顶的效果。
▐ 2.0时代
随着搜索业务的不断迭代,业务场景越发复杂,2021年我们开始跨入消费搜索的领域,引来了更大的挑战。在消费搜索场景中,我们为 XSL 容器引入了 section 能力。在有了 section 的加持之后,我们可以在一个长列表中构建多楼层电梯结构。
- 新容器
在了解了 SRP 和 XSearchList 的实现之后,我们不难发现以下问题
- 框架对于 Header 的处理非常定制化,不利于拓展
- 现有支持的 header 的顺序较为固定,不够灵活
- 缺乏一套统一的机制,SRP 和 XSearchList 对于 header 的实现完全不同
- 用了较多的 hack 逻辑,后续维护成本较高
- SRP 的头部联动有时候会不跟手
新的列表容器完全基于 NestedScroll API 实现,具有以下特点
- header 行为完全自定义
- header 顺序可以任意排列
- 头部区域与列表区域完全联动
- 多少 Header 多少 View,不会存在多余的 View,且头部区域完全平铺,不存在布局嵌套
- 容器实现
列表容器整体基于 RecyclerView 的嵌套滚动机制实现,主要基于以下两个方法
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { //列表没消耗掉的滚动 } public void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, int type) { //列表发生滚动时,先触发这个方法,处理完成后,剩余的 scrollY 才会给 RecyclerView 消耗 }
列表起始位置
列表容器存在一个基线位置,即非沉浸式的子内容(包含 header 和列表)的 top 值不允许小于这个数值
Header 优先级
header 存在两种优先级:
- 滑动优先级 listHeader = stickyHeader = foldHeader < list < halfSticky
滑动优先级越高的 header,在触发滚动时,优先处理滑动事件 - 绘制优先级 listHeader = list = fold < halfSticky < stickHeader
绘制优先级越高,在渲染时的 Z 层级就越高,因此高优先级的header会盖住低优先级的 header,touch 事件的响应顺序也是。
当添加 header 时,根据这两个优先级,维护两个数组。优先级支持自定义,在创建 header 时可以指定自己的优先级,比 halfSticky 优先级更高也是可以的
内置Behavior
框架内置了 List、Sticky、HalfSticky 这三种滚动类型的Behavior
Header 布局
根据 header 顺序,从上往下遍历 header。
header 具备三个属性:
- 高度
- 偏移
- 底部 padding
当要确定一个 header 的位置时,需要关注它上一个 header 的起始位置,以及它的高度,偏移量,以及底部 padding。因此一个 header 的最终位置计算如下
header.y = lastHeader.y + lastHeader.高度 + lastHeader.偏移 - lastHeader.底部 padding
滑动处理
当列表发生滚动时,按照滑动处理的优先级以及各优先级内的 header 添加顺序进行遍历。
- 拓展 header
例如我们要实现拍立淘的结果页布局
我们需要实现以下几个 header:
- 图片背景
- 图片操作栏
- 商品卡片
- 类目筛选
实现效果如下
图片背景
@Override public int consumeScroll(int dy, int listStart) { int temp = accumulatedY; int max = rootView.getMeasuredHeight() - listStart; int min = DIVIDER; temp += dy; temp = Math.min(max, temp); temp = Math.max(min, temp); int delta = temp - accumulatedY; accumulatedY = temp; return delta; } @Override public int getParallexHeight() { return accumulatedY; } @Override public int getTranslation() { return 0; }
多主体操作栏
private final Priority priority = new Priority(HALF_STICKY, Priority.Draw.LIST_HEADER); @Override public int consumeScroll(int dy, int listStart) { if ((int)getView().getTranslationY() > listStart) { return 0; } int temp = accumulatedY; temp += dy; temp = Math.min(getHeight(), temp); temp = Math.max(0, temp); int delta = temp - accumulatedY; accumulatedY = temp; return delta; } @Override public int getTranslation() { return 0; } @Override public int getParallexHeight() { return accumulatedY; }
商品卡片
private ListBehavior behavior; private View contentView; @Override public int consumeScroll(int dy, int listStart,int type) { int result = behavior.consumeScroll(dy, listStart,type); contentView.setTranslationY(behavior.getTranslation()); return result; } @Override public int getTranslation() { return 0; } @Override public int getParallexHeight() { return -behavior.getTranslation(); }
通过以上的处理,我们就在新容器里简单实现了下拍立淘的嵌套布局。
业务架构升级
当前的 XSearch 框架下,存在比较多的冗余逻辑,SRP 、XSL、新搜索都有一套各自的渲染和数据逻辑。
▐ 数据结构统一
在进行框架统一前,我们需要先基于各种业务场景进行分析和归纳。
- SRP
搜索框不属于业务数据,是srp 场景内固有区块,但是在 UI 结构上属于外层 header。
- XSearchList
这里以饿了么的 xsearchlist 场景为例
- 新搜索
新搜索的 UI 结构和 srp 类似,这里就不再赘述。
从以上分析我们可以看到,SRP 和 XSearchList基本数据结构可以归纳为以下结构。
▐ 楼层结构抽象
原先的 XSearch 框架内,卡片内容 items 就是单纯的一个商品卡数组,页面每个 tab 对应一个 DataSource,而每个 DataSource 都会对应一个 Result。
- DataSource 负责管理请求的发送与处理、请求参数存储
- Result 负责保存页面 UI 数据
在新搜索业务内,我们首次引入了多楼层结构,每个楼层的卡片数据与页码管理都是隔离的,为此,我们将楼层 数据抽象成一个 Combo,那么新的 Result 如下
▐ 渲染层统一
原先的 XSearch 框架内,SRP、XSL和新搜索都有一套各自的UI渲染框架,究其原因有以下几点
- 旧容器无法支持所有业务场景
- 数据结构不统一,导致无法复用渲染逻辑
- 渲染逻辑与业务逻辑强耦合,导致拓展性差第一点我们基于 MetaLayout 构建渲染框架,就可以解决。而第二点上文已经处理了,那么我们还需要解决第三点,需要将渲染和业务逻辑进行分离。
我们只需要基于抽象的数据结构进行各个部分的渲染即可。
▐ 逻辑串联与拓展
在完成了渲染组件之后,我们还需要处理业务逻辑。SRP 和 XSL 的业务逻辑较为简单,可以归纳为以下几点
- 列表数据支持刷新,例如触发排序、筛选等
- 列表支持翻页
- 各tab 数据隔离,tab 打开时请求对应 tab 的数据
其中,XSL 分端上发请求和前端发请求,需要特殊处理。
而新搜索与老搜索的业务逻辑完全不同,最明显的区别是
- 老搜索各个 tab 之间完全独立,每个 tab 的数据都是在tab 打开时才去请求的
- 新搜索的tab 分为主 tab 和子 tab,页面打开时获取主 tab 数据,子 tab 数据由主 tab 内的各个对应楼层决定
基于以上业务特点以及拓展性的考量,我们抽象出以下接口
在初始化请求完成后,根据服务端返回的业务字段,决定后续执行新搜索的业务逻辑还是老搜索的业务逻辑。
对于新老搜索的不同的 tab 数据管理,我们可以在 【onCreateWidgetModel】回调中进行处理,老搜索只会创建初始数据源,并且在后续tab 打开时发起初始化请求。而新搜索在创建数据源的同时,会将对应楼层的数据进行一次深拷贝,作为首屏数据,而后续打开 tab 则不会触发初始化请求。
架构统一的好处有以下几点
- 提高代码复用率,降低包大小
- 渲染层特性统一,当新增功能时,srp 和 xsl 可以同时使用,不需要重复开发
- 架构分层更加清晰,业务逻辑与渲染层解耦,降低维护成本,提高拓展性
- View 层统一,新 header 特性在 srp 和 xsl 可以同时使用,例如在新框架下,xsearchlist 也能支持长颈鹿
目前我们已经完成了主搜索的架构升级,优化了部分功能的体验,以下是展示环节。