Mar
28
现代电商类应用很常见一种“双向联动列表”的设计,比如京东分类页类目列表的顶部tab联动锚定:
并且行业内各种移动端组件库也有类似的输出:https://taro-ext.jd.com/plugin/view/5edf005bbe897edf72b35ea8。不过看起来真正用这样的组件的人并不多——就算用肯定也是自己开发的。究其原因,我们在讨论某个场景是否适合使用这个交互方式的时候,注意到几个痛点:
1. 如果列表是完整的并且能快速锚定到某个滚动位置,这意味着这个列表在初始化的时候应该是加载完毕的,也就是说很难做分页。因此当数据量极大,长度不可控时,这个加载无论是对于前端还是后端都是不可控的。
2. 同时即使可以加载这么大的数据,对用户流量上也是一种挑战。
因此实际在不可控的场景,对这个交互方案的场景进行接口设计的时候,我们讨论了多种假设:
a. 数据需要分页加载的主要原因是数据查询量大,每一个item都包含多项子数据的查询,成百上千条查询后端确实受不了。因此我们是否可以优先全量的下载出来一个信息简略的数据(比如只包含title和imgurl)来构成基本列表,在随着用户浏览滑动,类似于精准曝光的逻辑,在慢速滑动的时候去加载对应的详细数据。
这个方案还是基于一口气加载完整,不过也有明显的缺点:需要一边加载一边补充数据,可能带来页面抖动与看到异常数据,用户体验不佳。另外实际上到上千个品的时候,就算简略数据服务端也很难一口气返回回来,
b. 另一个方案,即手动进行全部加载,在进入界面的时候,渐进式的往下逐步完成分页加载。当然,如果用户有快速切锚点进行跳转的动作,只能让用户等待直到实际加载到用户的目标跳转位置为止。
这个做法可以很好的兼容绝大部分即顺序滑的场景,也能大概率兼容锚点切换的场景,对应的代价是——会大概率请求超级多多余的数据(相对于有正常的分页),流量当然也有无效的浪费,因此整体看下来肯定是一个愚蠢的方案。
c. 在调研过程中,发现美团外卖商超,比如家乐福店铺页左边一级分类与顶部都有锚定:
并且支持的数据量奇大,家乐福的完整列表品数甚至高达3-4k的数量。因此对外卖app进行了黑盒断网测试研究起加载逻辑,并结合我们的场景,得出了目前判断最优的双向联动列表分页加载方案。
方案描述
方案的设计原则遵循我们的理想态:
a. 最小请求加载,访问啥加载啥。
b. 完整列表,流程滚动,最终还是会成为一个长列表。
c. 快速锚点,通过锚点定位到列表的某个位置准确并迅速。
基于以上设定,我们有以下初步方案设计结论:
a. 基于请求最小化,肯定有分页,并且基于快速跳转锚点,那分页的第一纬度肯定是锚点分页,每个锚点内部可以再分页,类似于假设我有abcde五个锚点,但是c的数据量确实很大,可能需要分三页,所以分页会得到a->b->c->c2->c3->d->e。
b. 基于可以快速跳转到某一点的设定,列表肯定是支持从中间加载的,比如我初始状态处于a,快速点击到d,我会优先加载d,往前滚到c3,往后滚到e。同时基于滚动流畅性的特点,肯定会有提前加载的逻辑,比如落地到d的开始位置时,肯定需要同时加载c3,稍微往下滑动即需要加载e。
c. 整个分页的前后关系必须是明确的,即我必须知道d的前面是c3,可以是从d的接口返回的,但是更好的情况是直接通过锚点接口下发,这样加载控制只需要query上mark=c&pagenumber=3即可获取到对应数据了。
因此接口设计清晰了,分页由前端控制,那么锚点接口必须稳定的返回锚点于分页情况:
主列表的核心分页请求参数也即为:
本地为锚点准备数据数组,储存每个mark的分页与其加载完成的数据。比如上面描述快速跳转到d,那么完成加载后我们这个数组储存的数据为[A(List), B(null), C(null), C2(null), C3(List), D(List), E(null)],随着用户前后滑动,逐步将数据填充完整即可。
- 这个的填充逻辑即我们场景的提前分页加载,即距离列表顶部还有多少个item,即开始加载上一页,距离列表底部还有多少个item,即开始加载列表底部,同时配上滚动预判更佳。
- 列表可以适当加入更新逻辑,但是需要处理抖动问题,具体见下。
渲染逻辑
那么到头ListView怎么渲染这么一份不连续的分section列表?说起来也简单,只渲染当前加载的连续部分。比如刚才这个操作流程:
a. 初始态填充了a数据,因此列表渲染的,A(List)。
b. 快速点击到d,先转圈圈加载了c3和d,那么列表改为渲染C3(List), D(List),并且offset调整到d的开始位置。
c. 如果用户继续往下滑,继续加载e,当前的渲染窗口即C3(List), D(List), E(List)了。
d. 如果用户继续往上滑,加载c2,append到前面,即渲染C2(List), C3(List), D(List)。
e. 当然如果持续滑动到a的时候,a由于已经完成加载,所以直接继续append到渲染数据上,形成完整的渲染结果。
这个流程里注意到会出现往渲染数据前append数据,并reload的过程。正常理解一个ListView在头部添加数据并刷新,当然展示数据窗口offset肯定不会变化,因此会发现用户看到的数据发生跳变。因此reload的时候需要手动调整offset。同时以来以下动作:
a. 调整列表offset需要和reload一起完成才能实现流程操作,这意味着列表的高度是一定需要预先知道的,不能根据渲染结果取计算,否则肯定会闪跳的。不过这种复杂列表的高度理应都是数据可计算的,所以还好问题不大。
b. 同时如果担心数据加载过慢,真的滑动出数据窗口外了,有两种做法:一种是列表上线直接是结束位置,停下来展示上下拉下载那个圈圈,让用户有明显加载感;或者列表上下留足够空白区域,直接滑进空白,数据加载进来后再做布局替换,这样滚动流畅性不会收到影响,就是用户偶尔会看到白屏。
用图来描述目标过程和可选方案,即如下:
当然,实际的分页加载逻辑不用那么死板,听起来都合理的逻辑包括:
a. 保证列表向前有n个多余的卡片加载,如果每加载一个分页填充的卡片数量不足n,则继续往前加载,向后同理。
b. 总是尝试填充满用户最近连续加载的数据,链接到一起。
c. 如果有多级分类,优先加载同一个大分类下的数据。
根据业务场景与业务预期的用户场景,比如同品类比价场景,都可以针对性优化。
适用与适配
实际看这个方案的可用性,注意到明显存在的需要同步解决的问题是:
- reload的同时调整offset以避免跳变问题。
- 快速滚动提前加载。
涉及到的即要求,渲染操作同步性与事件响应同步性。因此客户端原生开发及Android与iOS一定可以做到。
- H5事件响应与渲染均有高延迟,所以做不到。
- RN和小程序类似,有跨jscore通信,因此问题也很大。
- Flutter事件类似于native,可以同步处理,因此可以做到。
对于明显做不到的端,对这个方案可以进行降级处理。因为主要问题出在保证向上加载的用户视野/操作的连续性。那么只要用户停下来明显感受到加载过程问题就不到了。——因此将方案中上滑预加载改成触顶,甚至下拉加载,让用户有明显的停止动作,并且强调出加载过程,比如弹出一个加载圈圈,即可解决该问题了。
当然偏web容器上这样加载引起的长列表性能问题,就是另一个话题了。
并且行业内各种移动端组件库也有类似的输出:https://taro-ext.jd.com/plugin/view/5edf005bbe897edf72b35ea8。不过看起来真正用这样的组件的人并不多——就算用肯定也是自己开发的。究其原因,我们在讨论某个场景是否适合使用这个交互方式的时候,注意到几个痛点:
1. 如果列表是完整的并且能快速锚定到某个滚动位置,这意味着这个列表在初始化的时候应该是加载完毕的,也就是说很难做分页。因此当数据量极大,长度不可控时,这个加载无论是对于前端还是后端都是不可控的。
2. 同时即使可以加载这么大的数据,对用户流量上也是一种挑战。
因此实际在不可控的场景,对这个交互方案的场景进行接口设计的时候,我们讨论了多种假设:
a. 数据需要分页加载的主要原因是数据查询量大,每一个item都包含多项子数据的查询,成百上千条查询后端确实受不了。因此我们是否可以优先全量的下载出来一个信息简略的数据(比如只包含title和imgurl)来构成基本列表,在随着用户浏览滑动,类似于精准曝光的逻辑,在慢速滑动的时候去加载对应的详细数据。
这个方案还是基于一口气加载完整,不过也有明显的缺点:需要一边加载一边补充数据,可能带来页面抖动与看到异常数据,用户体验不佳。另外实际上到上千个品的时候,就算简略数据服务端也很难一口气返回回来,
b. 另一个方案,即手动进行全部加载,在进入界面的时候,渐进式的往下逐步完成分页加载。当然,如果用户有快速切锚点进行跳转的动作,只能让用户等待直到实际加载到用户的目标跳转位置为止。
这个做法可以很好的兼容绝大部分即顺序滑的场景,也能大概率兼容锚点切换的场景,对应的代价是——会大概率请求超级多多余的数据(相对于有正常的分页),流量当然也有无效的浪费,因此整体看下来肯定是一个愚蠢的方案。
c. 在调研过程中,发现美团外卖商超,比如家乐福店铺页左边一级分类与顶部都有锚定:
并且支持的数据量奇大,家乐福的完整列表品数甚至高达3-4k的数量。因此对外卖app进行了黑盒断网测试研究起加载逻辑,并结合我们的场景,得出了目前判断最优的双向联动列表分页加载方案。
方案描述
方案的设计原则遵循我们的理想态:
a. 最小请求加载,访问啥加载啥。
b. 完整列表,流程滚动,最终还是会成为一个长列表。
c. 快速锚点,通过锚点定位到列表的某个位置准确并迅速。
基于以上设定,我们有以下初步方案设计结论:
a. 基于请求最小化,肯定有分页,并且基于快速跳转锚点,那分页的第一纬度肯定是锚点分页,每个锚点内部可以再分页,类似于假设我有abcde五个锚点,但是c的数据量确实很大,可能需要分三页,所以分页会得到a->b->c->c2->c3->d->e。
b. 基于可以快速跳转到某一点的设定,列表肯定是支持从中间加载的,比如我初始状态处于a,快速点击到d,我会优先加载d,往前滚到c3,往后滚到e。同时基于滚动流畅性的特点,肯定会有提前加载的逻辑,比如落地到d的开始位置时,肯定需要同时加载c3,稍微往下滑动即需要加载e。
c. 整个分页的前后关系必须是明确的,即我必须知道d的前面是c3,可以是从d的接口返回的,但是更好的情况是直接通过锚点接口下发,这样加载控制只需要query上mark=c&pagenumber=3即可获取到对应数据了。
因此接口设计清晰了,分页由前端控制,那么锚点接口必须稳定的返回锚点于分页情况:
[{
"markTitle":"a",
"markId":1001,
"markPageCount":1
},{
"markTitle":"b",
"markId":1002,
"markPageCount":1
},{
"markTitle":"c",
"markId":1003,
"markPageCount":3
},{
"markTitle":"d",
"markId":1004,
"markPageCount":1
},{
"markTitle":"e",
"markId":1005,
"markPageCount":1
}]
主列表的核心分页请求参数也即为:
{
"pageMarkId":1004,
"pageNumber":1,
//再配合上其他请求参数
}
本地为锚点准备数据数组,储存每个mark的分页与其加载完成的数据。比如上面描述快速跳转到d,那么完成加载后我们这个数组储存的数据为[A(List), B(null), C(null), C2(null), C3(List), D(List), E(null)],随着用户前后滑动,逐步将数据填充完整即可。
- 这个的填充逻辑即我们场景的提前分页加载,即距离列表顶部还有多少个item,即开始加载上一页,距离列表底部还有多少个item,即开始加载列表底部,同时配上滚动预判更佳。
- 列表可以适当加入更新逻辑,但是需要处理抖动问题,具体见下。
渲染逻辑
那么到头ListView怎么渲染这么一份不连续的分section列表?说起来也简单,只渲染当前加载的连续部分。比如刚才这个操作流程:
a. 初始态填充了a数据,因此列表渲染的,A(List)。
b. 快速点击到d,先转圈圈加载了c3和d,那么列表改为渲染C3(List), D(List),并且offset调整到d的开始位置。
c. 如果用户继续往下滑,继续加载e,当前的渲染窗口即C3(List), D(List), E(List)了。
d. 如果用户继续往上滑,加载c2,append到前面,即渲染C2(List), C3(List), D(List)。
e. 当然如果持续滑动到a的时候,a由于已经完成加载,所以直接继续append到渲染数据上,形成完整的渲染结果。
这个流程里注意到会出现往渲染数据前append数据,并reload的过程。正常理解一个ListView在头部添加数据并刷新,当然展示数据窗口offset肯定不会变化,因此会发现用户看到的数据发生跳变。因此reload的时候需要手动调整offset。同时以来以下动作:
a. 调整列表offset需要和reload一起完成才能实现流程操作,这意味着列表的高度是一定需要预先知道的,不能根据渲染结果取计算,否则肯定会闪跳的。不过这种复杂列表的高度理应都是数据可计算的,所以还好问题不大。
b. 同时如果担心数据加载过慢,真的滑动出数据窗口外了,有两种做法:一种是列表上线直接是结束位置,停下来展示上下拉下载那个圈圈,让用户有明显加载感;或者列表上下留足够空白区域,直接滑进空白,数据加载进来后再做布局替换,这样滚动流畅性不会收到影响,就是用户偶尔会看到白屏。
用图来描述目标过程和可选方案,即如下:
当然,实际的分页加载逻辑不用那么死板,听起来都合理的逻辑包括:
a. 保证列表向前有n个多余的卡片加载,如果每加载一个分页填充的卡片数量不足n,则继续往前加载,向后同理。
b. 总是尝试填充满用户最近连续加载的数据,链接到一起。
c. 如果有多级分类,优先加载同一个大分类下的数据。
根据业务场景与业务预期的用户场景,比如同品类比价场景,都可以针对性优化。
适用与适配
实际看这个方案的可用性,注意到明显存在的需要同步解决的问题是:
- reload的同时调整offset以避免跳变问题。
- 快速滚动提前加载。
涉及到的即要求,渲染操作同步性与事件响应同步性。因此客户端原生开发及Android与iOS一定可以做到。
- H5事件响应与渲染均有高延迟,所以做不到。
- RN和小程序类似,有跨jscore通信,因此问题也很大。
- Flutter事件类似于native,可以同步处理,因此可以做到。
对于明显做不到的端,对这个方案可以进行降级处理。因为主要问题出在保证向上加载的用户视野/操作的连续性。那么只要用户停下来明显感受到加载过程问题就不到了。——因此将方案中上滑预加载改成触顶,甚至下拉加载,让用户有明显的停止动作,并且强调出加载过程,比如弹出一个加载圈圈,即可解决该问题了。
当然偏web容器上这样加载引起的长列表性能问题,就是另一个话题了。