淘宝安卓端搜索架构升级总结

简介: 推荐语:这篇文章图文并茂地介绍了淘宝搜索滚动容器的技术演进过程,结合代码讲解页面结构划分、数据处理、交互效果,还包含了对逻辑抽象、功能拓展的思考,最后总结了可复用的架构。非常具有实践意义,推荐阅读学习!——大淘宝技术终端开发工程师 门柳

前言


XSearch 做为搜索客户端的基础框架,已经在线上运行多年,它同时支撑了主搜、拍立淘、云主题、店铺内等业务。XSearch深刻践行了 Native + 动态化的理念, 端上框架内提供动态化坑位,定义好交互行为,而服务端数据定义有哪些坑位,以及坑位内渲染哪些数据。这一套运作机制,保证了搜索的业务能够快速迭代。然而随着业务的不断发展,框架在不断壮大的同时,也存在了隐含的问题。例如代码复用率低、有些体验问题无法解决等。本文就基于业务容器和业务架构两方面进行总结阐述。

容器升级


 1.0时代


XSearch列表容器按照功能划分,主要分为 SRP 和 XSearchList,前者是给主搜索使用的,面相 Native 开发,支持端上开发进行各种特殊交互定制。而后者目前支撑了拍立淘结果页、云主体二跳页、分类等业务,面相前端开发,框架内定制了标准的数据协议以及交互行为,前端可以根据自己的需求进行使用。


  • SRP

image.gif

动图中展示了一个带场景层的搜索结果页案例,整个页面的联动分为两个部分

  1. 头部场景层区域
  2. 列表内排序条区域
场景层结构

image.png

通用头部容器


SearchAppBarLayout 是一个通用头部容器,容器对每个子 View 定义了 layout_levellayout_position


level 越小的子 View,越先消耗 offset

position 越小的子 View,在y 轴上越靠上

offset 指的是列表的累积滚动距离


以下面这个 demo 为例,看一下完整效果

image.png

 <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 都进行移动,如下动图所示。

image.png

在处理完子 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 效果。


场景层实现

image.png

淘宝的  TRecyclerView 组件支持添加Header和 Footer,在场景层 case 中,添加了一个透明的 HeaderView,headerView 的高度和场景层的高度相等,从而扩大列表滚动区域,同时给列表设置一个负偏移,保证 blankView 被列表父容器裁切掉。而在头部容器中,同时在 fold 区域添加了一个占位的 View,高度也和场景层相同,用来将头部容器撑开。当列表滚动时,通过 onScroll 回调同步通知给头部容器,使其内容一起向上移动,在头部容器移动的同时,给列表父容器同时设置一个反方向偏移,保证抵消列表的滚动,保证内容不变。


筛选条吸顶实现

筛选条的吸顶比较简单,就是在 列表负容器内同时放置一个 View,在场景层收起时,RecyclerView 和筛选条的父容器会同时做平移,保证列表和筛选条整体一起向上移动,并且能一直遮住列表。


  • XSearchList


网络异常,图片无法展示
|


image.png

如上图所示,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 的实现之后,我们不难发现以下问题

  1. 框架对于 Header 的处理非常定制化,不利于拓展
  2. 现有支持的 header 的顺序较为固定,不够灵活
  3. 缺乏一套统一的机制,SRP 和 XSearchList 对于 header 的实现完全不同
  4. 用了较多的 hack 逻辑,后续维护成本较高
  5. SRP 的头部联动有时候会不跟手

新的列表容器完全基于 NestedScroll API 实现,具有以下特点

  1. header 行为完全自定义
  2. header 顺序可以任意排列
  3. 头部区域与列表区域完全联动
  4. 多少 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 存在两种优先级:

  1. 滑动优先级 listHeader = stickyHeader = foldHeader < list < halfSticky
    滑动优先级越高的 header,在触发滚动时,优先处理滑动事件
  2. 绘制优先级 listHeader = list = fold < halfSticky < stickHeader
    绘制优先级越高,在渲染时的 Z 层级就越高,因此高优先级的header会盖住低优先级的 header,touch 事件的响应顺序也是。


当添加 header 时,根据这两个优先级,维护两个数组。优先级支持自定义,在创建 header 时可以指定自己的优先级,比 halfSticky 优先级更高也是可以的

内置Behavior

框架内置了 List、Sticky、HalfSticky 这三种滚动类型的Behavior


Header 布局

根据 header 顺序,从上往下遍历 header。

header 具备三个属性:

  1. 高度
  2. 偏移
  3. 底部 padding

当要确定一个 header 的位置时,需要关注它上一个 header 的起始位置,以及它的高度,偏移量,以及底部 padding。因此一个 header 的最终位置计算如下


header.y = lastHeader.y  + lastHeader.高度 + lastHeader.偏移 - lastHeader.底部 padding
滑动处理

当列表发生滚动时,按照滑动处理的优先级以及各优先级内的 header 添加顺序进行遍历。

  • 拓展 header

例如我们要实现拍立淘的结果页布局

网络异常,图片无法展示
|

我们需要实现以下几个 header:

  1. 图片背景
  2. 图片操作栏
  3. 商品卡片
  4. 类目筛选

实现效果如下

image.gif

图片背景


@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


image.png

搜索框不属于业务数据,是srp 场景内固有区块,但是在 UI 结构上属于外层 header。

  • XSearchList


这里以饿了么的 xsearchlist 场景为例


image.png

  • 新搜索


新搜索的 UI 结构和 srp 类似,这里就不再赘述。
从以上分析我们可以看到,SRP 和 XSearchList基本数据结构可以归纳为以下结构。

image.png

 楼层结构抽象


原先的 XSearch 框架内,卡片内容 items 就是单纯的一个商品卡数组,页面每个 tab 对应一个 DataSource,而每个 DataSource 都会对应一个 Result。

  1. DataSource 负责管理请求的发送与处理、请求参数存储
  2. Result 负责保存页面 UI 数据


image.png

在新搜索业务内,我们首次引入了多楼层结构,每个楼层的卡片数据与页码管理都是隔离的,为此,我们将楼层 数据抽象成一个 Combo,那么新的 Result 如下

image.png

 渲染层统一


原先的 XSearch 框架内,SRP、XSL和新搜索都有一套各自的UI渲染框架,究其原因有以下几点

  1. 旧容器无法支持所有业务场景
  2. 数据结构不统一,导致无法复用渲染逻辑
  3. 渲染逻辑与业务逻辑强耦合,导致拓展性差第一点我们基于 MetaLayout 构建渲染框架,就可以解决。而第二点上文已经处理了,那么我们还需要解决第三点,需要将渲染和业务逻辑进行分离。


image.png我们只需要基于抽象的数据结构进行各个部分的渲染即可。


 逻辑串联与拓展


在完成了渲染组件之后,我们还需要处理业务逻辑。SRP 和 XSL 的业务逻辑较为简单,可以归纳为以下几点

  1. 列表数据支持刷新,例如触发排序、筛选等
  2. 列表支持翻页
  3. 各tab 数据隔离,tab 打开时请求对应 tab 的数据


其中,XSL 分端上发请求和前端发请求,需要特殊处理。


而新搜索与老搜索的业务逻辑完全不同,最明显的区别是

  1. 老搜索各个 tab 之间完全独立,每个 tab 的数据都是在tab 打开时才去请求的
  2. 新搜索的tab 分为主 tab 和子 tab,页面打开时获取主 tab 数据,子 tab 数据由主 tab 内的各个对应楼层决定

基于以上业务特点以及拓展性的考量,我们抽象出以下接口


网络异常,图片无法展示
|
根据不同的业务场景,我们实现对应的 Controller。以主搜场景为例,在新的框架下,新老搜索合为一体,共用一个 MainSearchController。

在初始化请求完成后,根据服务端返回的业务字段,决定后续执行新搜索的业务逻辑还是老搜索的业务逻辑。

对于新老搜索的不同的 tab 数据管理,我们可以在 【onCreateWidgetModel】回调中进行处理,老搜索只会创建初始数据源,并且在后续tab 打开时发起初始化请求。而新搜索在创建数据源的同时,会将对应楼层的数据进行一次深拷贝,作为首屏数据,而后续打开 tab 则不会触发初始化请求。

网络异常,图片无法展示
|

架构统一的好处有以下几点

  1. 提高代码复用率,降低包大小
  2. 渲染层特性统一,当新增功能时,srp 和 xsl 可以同时使用,不需要重复开发
  3. 架构分层更加清晰,业务逻辑与渲染层解耦,降低维护成本,提高拓展性
  4. View 层统一,新 header 特性在 srp 和 xsl 可以同时使用,例如在新框架下,xsearchlist 也能支持长颈鹿


目前我们已经完成了主搜索的架构升级,优化了部分功能的体验,以下是展示环节。


 新框架主搜






相关文章
|
3天前
|
存储 监控 安全
360 企业安全浏览器基于阿里云数据库 SelectDB 版内核 Apache Doris 的数据架构升级实践
为了提供更好的日志数据服务,360 企业安全浏览器设计了统一运维管理平台,并引入 [Apache Doris](https://doris.apache.org/) 替代了 Elasticsearch,实现日志检索与报表分析架构的统一,同时依赖 Doris 优异性能,聚合分析效率呈数量级提升、存储成本下降 60%....为日志数据的可视化和价值发挥提供了坚实的基础。
360 企业安全浏览器基于阿里云数据库 SelectDB 版内核 Apache Doris 的数据架构升级实践
|
2月前
|
存储 缓存 关系型数据库
鱼和熊掌如何兼得?一文解析RDS数据库存储架构升级
阿里云RDS率先推出新型存储类型通用云盘,提供低延迟、低成本、高持久性的用户体验。
鱼和熊掌如何兼得?一文解析RDS数据库存储架构升级
|
2月前
|
Cloud Native 关系型数据库 分布式数据库
阿里云瑶池助力九州通B2B电商平台,完成100%云原生架构升级
九州通数字化转型,通过引入阿里云云原生数据库PolarDB,云原生内存数据库Tair等产品,完美支撑了医药电商平台数据库100%云原生化,实现了统一、高效、标准化和可跟踪的B2B医药平台。
366 4
|
2月前
|
缓存 Ubuntu 数据库
百度搜索:蓝易云【Ubuntu系统目录架构详解】
这是Ubuntu系统目录架构的主要部分,每个目录都有其特定的用途,合理使用目录结构可以使系统更加有序和易于管理。
40 2
|
2月前
|
算法 搜索推荐 Android开发
android的A/B到底是什么?OTA升级又是什么?
android的A/B到底是什么?OTA升级又是什么?
37 0
|
3月前
|
Web App开发 移动开发 小程序
"项目中mpaas升级到10.2.3 适配Android 14之后 app中的H5以及小程序都访问不了,
"项目中mpaas升级到10.2.3 适配Android 14之后 app中的H5以及小程序都访问不了,显示“网络不给力,请稍后再试”,预发内网版本不能使用,线上版本可以正常使用,这个是什么原因啊,是某些参数没有配置吗,还是说是一些参数改错了?
39 2
|
22小时前
|
XML JSON API
百度搜索:蓝易云【Android网络编程之Http通信】
以上是Android网络编程之Http通信的基本步骤,可以根据具体需求添加异常处理、线程管理等功能,以提高代码的健壮性和性能。 买CN2云服务器,免备案服务器,高防服务器,就选蓝易云。百度搜索:蓝易云
21 7
|
18天前
|
监控 NoSQL Redis
百度搜索:蓝易云【Redis高可用部署架构】
通过这种高可用部署架构,即使出现主节点的故障,系统也能自动切换到备用节点,保障Redis服务的高可用性。
20 6
百度搜索:蓝易云【Redis高可用部署架构】
|
2月前
|
自然语言处理 Cloud Native 开发者
【2023年度技术盘点】「年终盘点后端系列」探索服务架构体系的技术风向,构建微服务核心能力(升级版)
回顾过去的几年,我们目睹了科技界的快速发展,其势头如同一列驶向前方的高速列车。作为后端开发者,我们见证了每一次技术革新所带来的广阔前景。这些创新不仅深刻影响着我们的工作方式,而且不断引领我们走向未来。
45 1
|
2月前
|
存储 前端开发 测试技术
Android 官方架构中的 UseCase 该怎么写?
Android 官方架构中的 UseCase 该怎么写?
48 0