分页设计的难点问题
在网站开发、移动 APP 开发的时候,遇到数据量多的时候,都会有人性化的分页功能。但是,看似简单的分页功能,其实存在很多的设计技巧以及不少的坑。
分页有三种样式:普通分页、首末分页、跳转分页
Google的翻页功能的设计
当超过10项的搜索结果,Google会自动分页,你是否曾注意到,这个分页的链接,只出现在网页的底部,而网页顶部却没有分页的链接?分页的链接如下图:
从用户体验的角度,如果顶部也出现分页链接,实际上可有可无。因为按照逻辑的操作,用户起码应该是快速浏览完每页的10项搜索结果,才会浏览下一页,或者干脆按键盘的“End”键,跳到网页底部,按“下一页”。
所以顶部的分页链接作用不大。除非是网页一屏就显示所有的内容,顶部的分页链接才会起到作用,但这时候仍然是可有可无的。
分页设计的两大难点问题
1.数据重复
2.一次性加载大量信息,加载缓慢
数据重复
传统分页的话,一般只考虑传页数和每页数据条数这两个参数给后端,为了方便后面描述,我们给这个传参方式起个名字叫传统分页。
这种传参方式对于静态数据(数据不会变动)的分页是没问题的,因为每条数据的顺序、数据的总量,都是不变的。
如果出现数据顺序变动或者数据总量变动的分页需求时,单纯的传page和limit已经不能解决了。不同的需求需要显示的列表也不一样。
关于列表分页主要关系到两个方面:
Ø总量(列表头插入了新数据)
Ø排列顺序
传统分页在总量不变,排列顺序不变的列表下是没有任何问题的,但只要这两个要素其中一个是变化的。
例如:
总量不变,排列顺序改变:排行榜
总量改变,排列顺序不变:文章留言列表
总量改变,排列顺序改变:评论列表(点赞数倒叙)
排行榜
现在有一个积分排行榜
假定每页显示3条数据,在某一时刻拿第一页数据时,得到 A、B、C三条数据。就在此时,用户D突然增加了100积分,最新的排行榜情况变成了
传统分页情况下,获取第二页数据时,从当前排行榜第四条数据开始获取,得到 C、E,用户看到的数据就变成 A、B、C、C、E。
C出现了2次,而且D消失了。这就是传统分页用在数据排列顺序会改变的列表时会出现的问题,因为列表顺序改变导致出现重复数据和丢失数据。
这种总量不变,排列顺序改变的分页问题暂时有两种方案解决:
一次性取出、
排行榜快照、通过变动记录表拿数据。
一次性取出(针对特殊需求)
这里说的一次性取出是针对类似“top100”这种取有限条数的需求。
在比较简单的列表数据结构下一次性取出100条数据对服务器性能来说问题不大,但是在复杂数据结构下(涉及关联多个表、数据格式化、数据处理等)一次性处理100或更多的数据是糟糕的做法。
排行榜主要的分页问题是影响排名的字段的值在不断变化导致列表顺序不断改变,我们现在可以一次性取出整个列表但是又担心复杂的数据结构导致服务器性能问题。
如果把整个功能拆分一下,用异步的思想来做这个功能设计如何呢?
分两个接口来做这个功能:获取排行榜列表和获取用户排行榜数据。
获取排行榜列表接口一次性取整个排名列表的用户ID和排名相关的字段数据,这样就保证了整个列表的排序是不变的同时,又不增大服务器性能。
获取用户排行榜数据接口 负责取排行榜要显示的用户的其他数据,这个接口接受多个用户ID的作为参数。
这个接口做了类似分页的功能,前端每次从排行榜中按分页的方式按顺序取部分用户ID,然后通过这个接口获取具体数据显示给用户。
下面以例子的方式来做具体说明:
这是一个积分排行 top100
这里的排行条件是 积分,那我们的 获取排行榜列表接口 只需要取“用户ID”和“积分”即可,剩下的 “昵称”、“胜率”等数据通过 获取用户排行榜数据接口获取。
前端先请求列表接口,获取到一下数据:
然后根据这个列表数据,先取前10条的用户ID:5、12、60、2、77… 请求获取用户排行榜数据接口,把获得的用户数据填充到排行榜中。
当用户下滑加载更多数据时再去列表取在11-20的用户ID重复上面的操作。
如果是 top100 的需求,这个方案是比较推荐的,因为没有性能和储存空间上的额外消耗。
排行榜快照(推荐)
因为考虑到主要问题出在排列顺序是变化的,而且通过其他APP也有看到过按时刷新的排行榜,所以想到了用快照的方式来解决。
可以通过写一个定时脚本,每5分钟生成一次排行榜的快照信息并存下来。接口请求时直接从快照中取数据,这一定程度上解决了列表排序一直在变化问题。
这里之所以说只解决了一定程度,是因为在每次刷新快照数据的时候,可能有用户刚好卡在这个时间点之间去请求(刷新快照前用户请求了第一页数据,刷新快照后用户请求第二页,这就出现传统分页同样的问题了)。
可以通过在快照中加上版本号来解决问题。
例如在生成快照的时候以当前时间戳作为版本号跟快照数据一起保存,同时需要系统保存多份快照数据以便用户获取旧快照数据。请求接口时默认拿最新版本的快照,如果接口传入了版本号就拿对应版本号的快照数据。
优点:
通俗易懂,传参方式跟传统分页类似。
请求处理效率高,生成快照时可以把数据进行处理再保存(例如日期格式转换、类型key值转类型名字等),使得请求到来时获取的数据可以直接返回给用户,无需再做处理。
易于测试和排查,在生成快照那一刻已经决定了整个列表的数据展示,测试和错误排查很方便。
缺点:
实时性比较差,用户拿到的数据不是最新的。
需要额外存储空间,需要额外的地方存储多个版本的快照数据。
需要定时器,对于本来存在定时器的系统架构,这一点不算缺点。
通过变动记录表拿数据
每个完备的系统都会有数据变动的记录表,用于追踪数据变动和操作明细。记录变记录着数据每次变动前后的变化和变动时间,这一特性为使得数据的每次变动都有迹可循,我们就是利用这一点来做排行榜的分页。
我们分页出问题的地方就是因为数据在不断变化导致排序不停改变。
上面说到每次数据变动都会有记录,那我们只需要根据某一时刻之前用户的数据来做排名,是不是就解决数据不断变动这个问题。
文字表达可能不太直观,看下面的数据演示应该能比较好理解。假定用户 A、B、C 初始默认都是100积分
表:score_log
表格中为了方便查看,用了varchar类型表示时间,在实际应用中应该使用int型来存储,因为需要加索引。
假定在03分的时候请求了数据,通过SQL语句就可以拿到03分之前的数据排行。
得到第一页数据:
第二页数据:
关于这种方式的请求,前端需要记录发起第一次请求时的时间,以后每页的请求都带着这个时间。
优点:
Ø无需额外存储数据,利用系统原有数据结构来解决数据变动问题,也无需做多版本控制。
Ø数据相对实时,每次拿到的排行榜数据都是请求第一页那一刻最新的数据。
缺点:
Ø效率相对较差,由于数据需要实时排序和获取,效率相比排行榜要低。而且上面例子只取了记录表中最基础的数据,实际需求中一般需要关联更多的表去取信息,所以效率将随着需求负责度增大而降低。
Ø只适用于用户量不大的情况,由于数据变动记录表的数据量随着用户量的递增是呈倍数递增的,所以用户量达到一定程度的情况下,这个方式效率会变得相当低。
文章评论列表
评论列表一般按照倒叙排列,而且顺序不变。因为是倒叙排列,所以最新的用户评论会放在最顶部,这就会导致问题了。我们还是用实际例子来说。
假定每页拿3条数据,此时请求第一页,得到ID分别5、4、3的评论。在请求第二页之前,突然又来了一条留言,此时列表变成:
用传统分页方式,此时获取第二页会得到ID 3、2、1,这里ID 3 就重复取出来了。
这个问题的解决方案相比排行榜列表分页问题简单而且易懂。评论ID是一个自增的字段,新的评论ID总是比旧评论ID要大,利用这一点我们可以很好的解决问题。
接口传参:
说一下lastid。当获取第一页数据时,因为没有上一页所以 lastid 传空或者不传,此时服务器取最新的数据即可。
获取第二页数据时,lastid 传第一页最后一条数据的ID,此时服务器取 ID <lastid 的数据,这就保证最新的评论不会影响到当前用户的分页。
这里做一个扩展,我们有时候看到某些页面在刷新的时候,会提示有多少条新的未查看评论(即列表头新的数据),这个功能的实现原理跟我们上面分页的原理差不多。
在获取第一页数据时,把第一页的第一条数据ID保存下来,后面请求每一页时都把第一条ID(firstid)带上,服务器每次查 ID >firstid 的数据条数,如果大于0即表示有新的评论。
评论列表(点赞数倒叙)
微博的评论排序也存在上面说到的分页bug,要完美解决这个需求的分页问题花费的代价(实现时间、服务器性能、存储空间等)大于功能本身,所以建议选择比较折中的方式来处理(与产品或上级沟通实现的难度)。
这个需求相比评论列表,多了点赞的功能,列表按点赞数量倒叙排列。
先说一下不严谨情况下这个分页的实现方式:
## 优先对点赞数量倒叙,再对评论ID倒叙 ##
这种方式会有两个问题:
1.评论点赞数的变化导致列表排序不断改变
2.新写的评论会影响列表的总量
可以沿用上面讲到的两个需求的解决方案。在解决列表排序问题上,沿用排行榜的通过变动记录表拿数据方式,增加一个表去记录评论的点赞变动记录(用空间换效率)。
优化:
1.分表:(固定某个表存多少数量的数据:例如:一张表存100w的数据量)
2.优化sql和建立适合的索引(复合索引)
3.使用redis缓存。(redis存一份ID.然后mysql存一份ID每次插入删除的时候同步即可。查询的时候只需要从redis里面找出适合的10个ID,然后到mysql里面查询出10条记录即可)
4.总数要单独处理:涉及到总数操作,专门维护一个总数。(例如:新注册一个会员,总数值加1,需要总数的时候直接拿这个总数,也可以在这个表上添加了触发器并创建一个专门用来统计总行数的表添加更新删除该表就会触发,分析条件后直接把统计表的相应字段累加,查询的时候直接读取统计表中的相应字段就可以了准确度没问题,如果有条件查询分页,那么分页表的数据就发挥不了左右)。
5.可通过定时任务去批量查询总数,例如:开启10个线程去批量计算总数,然后再各自相加即可,不过这样会导致内存(CPU)过高,而造成内存溢出。
6.修改原有界面内容,单独去查询总数,需要即去查询。也可以用ID建立一定的区间,比如查询最新的记录,每次只是查询2w条的记录。每次只要查最新的一条记录,id是自增字段,取当前的这个id值就可以大约知道总条数了(注意:项目里并不会删除参与记录),但是这种不适合带条件的查询。
瀑布流和分页设计是展示信息的两种不同方式。瀑布流就是向下滚动页面时内容会不断刷新以加载更多,分页设计就是将信息分成一页一页,然后通过点击进入。
下面是两者的优缺点
先说优点
优点1:通过流畅的显示以及海量的信息吸引用户
使用瀑布流显示信息时,因为信息的显示是实时的,特别是在移动设备上,随着手指的下滑信息不断出现,该过程的流畅性会让用户更好地沉浸其中,同时,不断出现的新的信息也会吸引住用户。
当然,这个优点成立的前提是用户没有很明确的目的,反之会成为缺点。
优点2:更好的操作体验
滚动比点击使用起来更加轻松,包括鼠标滚动和手指滚动。根据菲茨定律,点击所花费的时间较长,除非点击目标够大。而滚动则几乎没有负担。
优点3:更好地适用于移动设备
移动设备屏幕较小,一屏显示的东西不多,因此翻页要比较频繁。在这种情况下,瀑布流的显示是具有更大的优势的。
下面是缺点
缺点1:性能要求比较高
有两点,一个是网速的要求,一个是设备的要求,如果达不到要求,瀑布流的显示会有延迟,以上的优点就都不存在了。而且即使性能达到要求了,随着东西展开越来越多,缓存越来越多,性能要求会更高。
缺点2:搜索和定位的不便
一旦离开页面,下次打开时要重新找到自己上次的内容比较麻烦,不能快速定位。现在有一些应用可以恢复上次关闭时的位置,但是往往会造成打开速度的变慢,因为加载的东西更多了。
缺点3:有点鸡肋的滚动条
滚动条是用来显示位置的,但是在瀑布流中,你看着滚动条到达最低段了,然而随着新内容的加载,又马上上升,这时候滚动条的定位就没什么实际意义了。不过我觉得还是有作用的,可以用来快速返回前面。或者在两个位置之间快速变换。
缺点4:没有页尾
一个无限的页面,自然就没有尾了,页尾有时候会有一些有用的信息,但是用户一滚动到最下面,又重新加载出新的东西,这会让用户感到沮丧。
最好在顶部或者侧边加上页尾的信息。
另外,有一个瀑布流的变形,就是不自动加载,而是增加一个点击加载更多的按键,这算是一个折中吧。
优点1:对信息良好的控制
原文章分为3点去讲,我觉得其实可以直接归为一点。
分页显示时,你可以快速地了解信息量的多少,比如搜索结果,是10页,100页,还是1000页,然后你就可以有自己的预期,是要换个关键字,还是往下翻翻看,而且你可以估计所需要的大致时间。
另外一点就是快速地定位,你只要记得上次是在第几页,就可以快速达到。
优点2:性能
相比于瀑布流对性能的要求,分页因为其显示内容相对较少,要求较低。但是在由于每次点击换页都要重新加载,所以总体还是不如瀑布流流畅。
缺点1:额外的切换动作
显而易见,就像前面说的,点击需要花费一定的时间以及精力。而且,结合上一点,如果要求加载速度较快,那么内容则比较少,用户点击的次数增加,如果要显示更多的内容以减少点击次数,那加载速度要受到影响。
瀑布流还是要慎用,除了少数一些场景,像用户生产内容的Twitter,Facebook和视觉内容的Pinterest等。分页相对来说是一个比较保险的方案。
就像谷歌,图片搜索是用瀑布流显示,文字搜索用分页显示。
一般来说,瀑布流用于用户没有很明确的目的,只是喜欢获取尽量多的信息的情况,分页用于用户有较明确的目的,对定位有一定的要求的情况。
传统分页的话,一般只考虑传页数和每页数据条数这两个参数给后端,为了方便后面描述,我们给这个传参方式起个名字叫 传统分页 。这种传参方式对于静态数据(数据不会变动)的分页是没问题的,因为每条数据的顺序、数据的总量,都是不变的。
如果出现数据顺序变动或者数据总量变动的分页需求时,单纯的传page和limit已经不能解决了。
不同的需求需要显示的列表也不一样。关于列表分页我认为主要关系到两个方面, 总量 (列表头插入了新数据) 和 排列顺序 。 传统分页 在 总量不变,排列顺序不变 的列表下是没有任何问题的,但只要这两个要素其中一个是变化的, 传统分页 方式就会出现BUG(具体案例后面会讲到)。关于上面提到两个要素对应的需求举例:
现在有一个积分排行榜
假定每页显示3条数据,在某一时刻拿第一页数据时,得到 A、B、C三条数据。就在此时,用户D突然增加了100积分,最新的排行榜情况变成了
传统分页 的情况下,获取第二页数据时,即从当前排行榜第四条数据开始获取,得到 C、E,用户看到的数据就变成 A、B、C、C、E。这里C出现了2次,而且D消失了。这就是传统分页用在 数据排列顺序会改变的列表 时会出现的问题,因为列表顺序改变导致出现重复数据和丢失数据。
这种 总量不变,排列顺序改变 的分页问题我能想到的暂时有两种方案解决:一次性取出、排行榜快照、通过变动记录表拿数据。
这里说的一次性取出是针对类似“top100”这种取有限条数的需求。在比较简单的列表数据结构下一次性取出100条数据对服务器性能来说问题不大,但是在复杂数据结构下(涉及关联多个表、数据格式化、数据处理等)一次性处理100或更多的数据肯定是糟糕的做法。
排行榜主要的分页问题是 影响排名的字段的值在不断变化导致列表顺序不断改变 ,我们现在可以一次性取出整个列表但是又担心复杂的数据结构导致服务器性能问题。那如果我们把整个功能拆分一下,用异步的思想来做这个功能设计如何呢。
我们分两个接口来做这个功能:获取排行榜列表和获取用户排行榜数据。
获取排行榜列表接口 一次性取整个排名列表的用户ID和排名相关的字段数据,这样就保证了整个列表的排序是不变的同时,又不增大服务器性能。
获取用户排行榜数据接口 负责取排行榜要显示的用户的其他数据,这个接口接受多个用户ID的作为参数。这个接口做了类似分页的功能,前端每次从排行榜中按分页的方式按顺序取部分用户ID,然后通过这个接口获取具体数据显示给用户。
下面以例子的方式来做具体说明:
这是一个 积分排行 top100
这里的排行条件是 积分 ,那我们的 获取排行榜列表接口 只需要取“用户ID”和“积分”即可,剩下的 “昵称”、“胜率”等数据通过 获取用户排行榜数据接口 获取。
前端先请求 列表接口,获取到一下数据:
然后根据这个列表数据,先取前10条的用户ID:5、12、60、2、77… 去请求 获取用户排行榜数据接口,把获得的用户数据填充到排行榜中。当用户下滑加载更多数据时再去列表取在11-20的用户ID重复上面的操作。
如果是 top100 的需求,这个方案是比较 推荐 的,因为没有性能和储存空间上的额外消耗。
因为考虑到主要问题出在排列顺序是变化的,而且通过其他APP也有看到过按时刷新的排行榜,所以想到了用快照的方式来解决。
可以通过写一个定时脚本,每5分钟生成一次排行榜的快照信息并存下来。接口请求时直接从快照中取数据,这一定程度上解决了列表排序一直在变化问题。这里之所以说只解决了一定程度,是因为在每次刷新快照数据的时候,可能有用户刚好卡在这个时间点之间去请求(刷新快照前用户请求了第一页数据,刷新快照后用户请求第二页,这就出现 传统分页 同样的问题了)。
可以通过在快照中加上 版本号 来解决问题。例如在生成快照的时候以当前时间戳作为版本号跟快照数据一起保存,同时需要系统保存多份快照数据以便用户获取旧快照数据。请求接口时默认拿最新版本的快照,如果接口传入了版本号就拿对应版本号的快照数据。
每个完备的系统都会有数据变动的记录表,用于追踪数据变动和操作明细。记录变记录着数据每次变动前后的变化和变动时间,这一特性为使得数据的每次变动都有迹可循,我们就是利用这一点来做排行榜的分页。
我们分页出问题的地方就是因为数据在不断变化导致排序不停改变。上面说到每次数据变动都会有记录,那我们只需要根据某一时刻之前用户的数据来做排名,是不是就解决数据不断变动这个问题。文字表达可能不太直观,看下面的数据演示应该能比较好理解。
假定用户 A、B、C 初始默认都是100积分
表: score_log
假定在03分的时候请求了数据,通过下面的SQL语句就可以拿到03分之前的数据排行。
得到第一页数据:
第二页数据:
关于这种方式的请求,前端需要记录发起第一次请求时的时间,以后每页的请求都带着这个时间。
评论列表一般按照倒叙排列,而且顺序不变。因为是倒叙排列,所以最新的用户评论会放在最顶部,这就会导致问题了。我们还是用实际例子来说。
假定每页拿3条数据,此时请求第一页,得到ID分别5、4、3的评论。在请求第二页之前,突然又来了一条留言,此时列表变成:
用 传统分页 方式,此时获取第二页会得到ID 3、2、1,这里ID 3 就重复取出来了。
这个问题的解决方案相比排行榜列表分页问题简单而且易懂。评论ID是一个自增的int字段,新的评论ID总是比旧评论ID要大,利用这一点我们可以很好的解决问题。
接口传参:
limit 就不用作解释,说一下lastid。当获取第一页数据时,因为没有上一页所以 lastid 传空或者不传,此时服务器取最新的数据即可。获取第二页数据时,lastid 传第一页最后一条数据的ID,此时服务器取 ID < lastid 的数据,这就保证最新的评论不会影响到当前用户的分页。
这里做一个扩展,我们有时候看到有的页面在刷新的时候,会提示有多少条新的未查看评论(即列表头新的数据),这个功能的实现原理跟我们上面分页的原理差不多。在获取第一页数据时,把第一页的第一条数据ID保存下来,后面请求每一页时都把第一条ID( firstid )带上,服务器每次查 ID > firstid 的数据条数,如果大于0即表示有新的评论。
首先说一下,下面提供的方法我自己也不满意(如果有什么想法欢迎大家留言交流)。参考了微博的评论排序也存在上面说到的分页bug,感觉要完美解决这个需求的分页问题花费的代价(实现时间、服务器性能、存储空间等)大于功能本身,所以建议读者选择比较折中的方式来处理(与产品或上级沟通实现的难度)。
这个需求相比评论列表,多了点赞的功能,列表按点赞数量倒叙排列。先说一下不严谨情况下这个分页的实现方式:
这种方式会有两个问题:
我们可以沿用上面讲到的两个需求的解决方案。在解决列表排序问题上,我们可以沿用排行榜的 通过变动记录表拿数据 方式,增加一个表去记录评论的点赞变动记录(用空间换效率)。
表结构:
分页用到的查询语句:
API处理分页看似简单,实际上暗藏危机。最常见的分页方式,大概是下面这样的
/users/?page=1&limit=5
//服务端返回
{
"code": 0,
"pagination": {
"page": 1,
"limit": 5,
"total": 10
}
"data": {}
}
最理想的情况下,客户端请求第一页的5条数据,服务端如常返回,比如下图:
拿Twitter的图用一下,假设我们的数据库有10条数据,按照5条一页,正好有2页。
在理想情况下,客户端拉取数据时不会出现任何异常。但,这仅仅是正常情况,如果此时刚好有2条新数据插入。
数据库记录变为13。原来第二页的数据是[5, 4, 3, 2, 1],现在变为[7, 6, 5, 4, 3],我们再一次拿到了第一页的数据。同理,如果用户在拉取数据时正好有数据被删除,一样会出现类似的问题。
根据item_id分页
要解决此类问题,就不能使用常规的分页方式。现在,我们换一个思路,客户端拉取数据时不再传page,改为item_id,我们就把它称为max_id
/users/?max_id=5&limit=5
此时服务端就知道我们上次拉取到了item_id为5的数据,继续在它后面拉取5条, 如下图:
数据可以正常取回,不会再出现上一页中的[6,7]。好了,让我们再一次假设,此时又有8条数据插入了数据库。再一次获取数据
可以看出,再一次出现问题,我们又拿到了上一页的[10,9]。所以,我们得告诉服务端,上一次拿到哪一条数据了。所以继续增加一个since_id字段。
恩,再一次取出了正确的数据。可能你觉得一切都正常了,但还是隐藏了一个致命的缺陷。
上面的数据能正常获取,是因为数据都是一个有序的集合,如果数据无序,且从数据库取出时需要按照某个字段排序,那么一切再次打回原点:所有的分页都乱了。
如何设计分页API
可以看出,两种分页方式都存在问题。所以这两种需求都是必要的,我们需要根据不同的业务场景使用不同的分页方式。
为了不造成客户端的麻烦,我们对api的分页做了一些更改。
{
"code": 0,
"pagination": {
"page": 1,
"next": {
"sinceId": 11,
"maxId": 11
//page: 1
},
"limit": 5,
"total": 10
},
"data": {}
}
我们由服务端来决定如何分页,前端需要做的,只是把next字段直接拼接到url中,这样就可以应付各种分页情况。
1、点击“设置”——>“图页”,弹出图页对话框,如下图所示:
2、双击图页名称可以进行修改;
3、参考编号起始值可以输入图页内元器件的起始值,默认是从1开始的,比如我们在POWER图页内放置一个电阻,如果不设置参考编号起始值,则默认为R1,如果如上图所示设置参考编号起始值为200,则电阻的编号我R200,下一个电阻编号为R201。
4、右侧的添加按钮可以添加新图页,最多可以添加1024个图页。
5、选中一个图页电机查看按钮,则直接跳到选中的原理图。
6、上、下按钮(上按钮不可点,是因为我们选择的是第1个图页,所以不可点),可以调整图页的顺序,上图是POWER第1个,MCU第2个,CONNECT第3个。如果想让MCU排第1个,可以选中MCU图页,然后点击上,就可以移动。移动之后的效果如下图所示:
7、选中一个图页之后,点击删除按钮,可以删除该图页。