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

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

前言


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天前
|
前端开发 测试技术 数据处理
安卓开发中的MVP架构模式深度解析
【4月更文挑战第30天】在移动应用开发领域,模型-视图-呈现器(Model-View-Presenter, MVP)是一种广泛采用的架构模式。它旨在通过解耦组件间的直接交互来提高代码的可维护性和可测试性。本文将深入探讨MVP在安卓开发中的应用,揭示其如何促进代码的模块化,提升用户界面的响应性,并简化单元测试过程。我们将从理论概念出发,逐步过渡到实践案例,为读者提供一套行之有效的MVP实施策略。
|
10天前
|
传感器 Java Android开发
Android HAL深入探索(1): 架构概述
Android HAL深入探索(1): 架构概述
24 1
|
10天前
|
存储 安全 文件存储
Android OTA升级后输入法异常和应用丢失的分析
Android OTA升级后输入法异常和应用丢失的分析
18 1
|
12天前
|
设计模式 前端开发 数据库
构建高效Android应用:使用Jetpack架构组件实现MVVM模式
【4月更文挑战第21天】 在移动开发领域,构建一个既健壮又易于维护的Android应用是每个开发者的目标。随着项目复杂度的增加,传统的MVP或MVC架构往往难以应对快速变化的市场需求和复杂的业务逻辑。本文将探讨如何利用Android Jetpack中的架构组件来实施MVVM(Model-View-ViewModel)设计模式,旨在提供一个更加模块化、可测试且易于管理的代码结构。通过具体案例分析,我们将展示如何使用LiveData, ViewModel, 和Repository来实现界面与业务逻辑的分离,以及如何利用Room数据库进行持久化存储。最终,你将获得一个响应迅速、可扩展且符合现代软件工
15 0
|
13天前
|
存储 设计模式 数据库
构建高效的安卓应用:探究Android Jetpack架构组件
【4月更文挑战第20天】 在移动开发的世界中,构建一个既高效又可维护的安卓应用是每个开发者追求的目标。随着Android Jetpack的推出,Google为开发者提供了一套高质量的库、工具和指南,以简化应用程序开发流程。本文将深入探讨Jetpack的核心组件之一——架构组件,并展示如何将其应用于实际项目中,以提升应用的响应性和稳定性。我们将通过分析这些组件的设计原则,以及它们如何协同工作,来揭示它们对于构建现代化安卓应用的重要性。
|
20天前
|
XML 前端开发 测试技术
安卓架构模式:MVC、MVP、MVVM及更多
【4月更文挑战第13天】本文探讨了安卓应用开发中的常见架构模式,包括MVC、MVP和MVVM,以及VIPER和Clean Architecture。MVC分离关注点,易于理解,但安卓不直接支持。MVP通过呈现器实现更清晰的分层和便于单元测试。MVVM利用数据绑定简化UI逻辑,适合声明式编程。开发者应根据项目需求、团队技能和维护周期选择合适架构,随着工具和框架的进步,未来将提供更多模块化、可测试性和敏捷性的解决方案。
|
21天前
|
存储 数据库 Android开发
构建高效安卓应用:采用Jetpack架构组件优化用户体验
【4月更文挑战第12天】 在当今快速发展的数字时代,Android 应用程序的流畅性与响应速度对用户满意度至关重要。为提高应用性能并降低维护成本,开发者需寻求先进的技术解决方案。本文将探讨如何利用 Android Jetpack 中的架构组件 — 如 LiveData、ViewModel 和 Room — 来构建高质量的安卓应用。通过具体实施案例分析,我们将展示这些组件如何协同工作以实现数据持久化、界面与逻辑分离,以及确保数据的即时更新,从而优化用户体验并提升应用的可维护性和可测试性。
|
24天前
|
存储 设计模式 前端开发
构建高效安卓应用:Jetpack MVVM 架构的实践之路
【4月更文挑战第9天】 在移动开发的迅猛浪潮中,Android 平台以其开放性和灵活性受到开发者青睐。然而,随着应用复杂度的不断增加,传统的开发模式已难以满足快速迭代和高质量代码的双重要求。本文将深入探讨 Jetpack MVVM 架构模式在 Android 开发中的应用实践,揭示如何通过组件化和架构设计原则提升应用性能,实现数据驱动和UI分离,进而提高代码可维护性与测试性。我们将从理论出发,结合具体案例,逐步展开对 Jetpack MVVM 架构的全面剖析,为开发者提供一条清晰、高效的技术实施路径。
|
26天前
|
存储 SQL 数据库
构建高效Android应用:采用Jetpack架构组件的实践之路
【4月更文挑战第7天】 在快速迭代的移动开发领域,构建一个既健壮又易于维护的Android应用至关重要。本文将深入探讨如何利用Google推出的Jetpack架构组件,实现Android应用的模块化和组件化,从而提升开发效率和应用性能。我们将通过具体实例分析生命周期管理、UI控制器、数据存储等核心组件,展示其在真实应用中的运用,以及如何借助这些组件简化日常开发任务,确保代码的可扩展性和可测试性。
|
1月前
|
移动开发 前端开发 数据管理
构建高效Android应用:采用MVVM架构与LiveData的全面指南
在移动开发领域,构建一个既快速又可靠的应用对于开发者来说至关重要。随着Android Jetpack组件的推出,MVVM(Model-View-ViewModel)架构和LiveData已成为实现响应式、可测试且易于维护应用的首选解决方案。本文将深入探讨如何在Android应用中实施MVVM模式,以及如何利用LiveData来优化UI组件的数据更新流程,确保用户界面与业务逻辑之间的高度解耦和流畅交互。
18 4