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

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

前言


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 也能支持长颈鹿


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


 新框架主搜






相关文章
|
14天前
|
机器学习/深度学习 编解码 人工智能
超越Transformer,全面升级!MIT等华人团队发布通用时序TimeMixer++架构,8项任务全面领先
一支由麻省理工学院、香港科技大学(广州)、浙江大学和格里菲斯大学的华人研究团队,开发了名为TimeMixer++的时间序列分析模型。该模型在8项任务中超越现有技术,通过多尺度时间图像转换、双轴注意力机制和多尺度多分辨率混合等技术,实现了性能的显著提升。论文已发布于arXiv。
132 83
|
3月前
|
存储 SQL 缓存
快手:从 Clickhouse 到 Apache Doris,实现湖仓分离向湖仓一体架构升级
快手 OLAP 系统为内外多个场景提供数据服务,每天承载近 10 亿的查询请求。原有湖仓分离架构,由离线数据湖和实时数仓组成,面临存储冗余、资源抢占、治理复杂、查询调优难等问题。通过引入 Apache Doris 湖仓一体能力,替换了 Clickhouse ,升级为湖仓一体架构,并结合 Doris 的物化视图改写能力和自动物化服务,实现高性能的数据查询以及灵活的数据治理。
快手:从 Clickhouse 到 Apache Doris,实现湖仓分离向湖仓一体架构升级
|
4天前
|
搜索推荐 API 定位技术
一文看懂Elasticsearch的技术架构:高效、精准的搜索神器
Elasticsearch 是一个基于 Lucene 的开源搜索引擎,以其强大的全文本搜索功能和快速的倒排索引技术著称。它不仅支持数字、文本、地理位置等多类型数据,还提供了可调相关度分数、高级查询 DSL 等功能。Elasticsearch 的核心技术流程包括数据导入、解析、索引化、查询处理、得分计算及结果返回,确保高效处理大规模数据并提供准确的搜索结果。通过 RESTful API、Logstash 和 Filebeat 等工具,Elasticsearch 可以从多种数据源中导入和解析数据,支持复杂的查询需求。
20 0
|
1月前
|
人工智能 Cloud Native 算法
|
2月前
|
存储 消息中间件 人工智能
ApsaraMQ Serverless 能力再升级,事件驱动架构赋能 AI 应用
本文整理自2024年云栖大会阿里云智能集团高级技术专家金吉祥的演讲《ApsaraMQ Serverless 能力再升级,事件驱动架构赋能 AI 应用》。
147 11
|
2月前
|
分布式计算 大数据 Serverless
云栖实录 | 开源大数据全面升级:Native 核心引擎、Serverless 化、湖仓架构引领云上大数据发展
在2024云栖大会开源大数据专场上,阿里云宣布推出实时计算Flink产品的新一代向量化流计算引擎Flash,该引擎100%兼容Apache Flink标准,性能提升5-10倍,助力企业降本增效。此外,EMR Serverless Spark产品启动商业化,提供全托管Serverless服务,性能提升300%,并支持弹性伸缩与按量付费。七猫免费小说也分享了其在云上数据仓库治理的成功实践。其次 Flink Forward Asia 2024 将于11月在上海举行,欢迎报名参加。
241 6
云栖实录 | 开源大数据全面升级:Native 核心引擎、Serverless 化、湖仓架构引领云上大数据发展
|
29天前
|
机器学习/深度学习 存储 人工智能
政务部门人工智能OCR智能化升级:3大技术架构与4项核心功能解析
本项目针对政务服务数字化需求,建设智能文档处理平台,利用OCR、信息抽取和深度学习技术,实现文件自动解析、分类、比对与审核,提升效率与准确性。平台强调本地部署,确保数据安全,解决低质量扫描件、复杂表格等痛点,降低人工成本与错误率,助力智慧政务发展。
|
2月前
|
存储 消息中间件 运维
架构升级的救星!流量回放自动化测试的必备指南
大家好,我是小米,一名29岁的技术宅。今天分享一个物联网领域的实用技能——流量回放自动化测试。系统重构后,测试工作量巨大,本文介绍如何通过日志收集和数据回放进行自动化测试,包括离线、实时和并行回放模式,帮助快速定位Bug,提升测试效率和系统稳定性。欢迎关注我的微信公众号“软件求生”,获取更多技术干货!
61 3
|
2月前
|
存储 SQL 缓存
Apache Doris 3.0 里程碑版本|存算分离架构升级、湖仓一体再进化
从 3.0 系列版本开始,Apache Doris 开始支持存算分离模式,用户可以在集群部署时选择采用存算一体模式或存算分离模式。基于云原生存算分离的架构,用户可以通过多计算集群实现查询负载间的物理隔离以及读写负载隔离,并借助对象存储或 HDFS 等低成本的共享存储系统来大幅降低存储成本。
Apache Doris 3.0 里程碑版本|存算分离架构升级、湖仓一体再进化
|
2月前
|
安全 Android开发 iOS开发
深入解析:安卓与iOS的系统架构及其对应用开发的影响
本文旨在探讨安卓与iOS两大主流操作系统的架构差异,并分析这些差异如何影响应用开发的策略和实践。通过对比两者的设计哲学、安全机制、开发环境及性能优化等方面,本文揭示了各自的特点和优势,为开发者在选择平台和制定开发计划时提供参考依据。
67 4