消息中心第一版就是「拉一个列表接口、渲染、进页面时调一次清未读」。看起来完全没问题,上线后用户反馈和线上问题集中在四个点。
一是红点消不掉。用户点进消息页,红点没了;退出来,红点又回来了。原因是我在进入页面时调了清未读接口,但没等它返回就本地把红点清了——请求在弱网下失败了,本地红点清了、服务端还是未读,下次进页面重新拉到未读数,红点又出现。用户的感受是「这个 App 的红点是坏的」,而且他会反复点进来试。
二是消息刷屏。一条内容火了之后,收到几百个赞,消息列表就是几百条「XX 赞了你的内容」。用户想找一条重要的评论,得在几百条赞里翻。而这几百条赞对他的信息价值等于一条。
三是一个总未读数没法用。用户看到「99+」,不知道是有人评论了还是只是一堆赞。而他关心的只有评论和关注,赞可以不看。为了消掉这个红点他必须把所有消息都过一遍。
四是刷新时阅读位置被顶走。他正在看第 20 条,下拉刷新拉到 5 条新消息直接插到顶部,列表整体下移,他刚才在看的那条跑到屏幕外了。
所以四个改动:分类展示各自维护未读数、同内容多互动聚合成一条、已读改成本地先清加失败回滚、且清除时机改为进入 tab 并实际曝光、新消息用顶部提示条而不是直接插入。
这个模块最想说的一句话是:客户端的坑绝大多数出在「异步结果回来时界面已经变了」。本地先清红点是为了响应快,但请求可能失败、用户可能已经退出页面、可能同时有新消息进来——这三种情况都会让本地状态和服务端状态分叉。解决办法不是不做本地先行(那样体验很差),而是明确定义失败时怎么回滚、并且让状态只有一个来源。
本地先清红点 vs 等请求成功再清。等请求成功的好处是状态永远一致,代价是用户点进消息页要等几百毫秒红点才消失,弱网下要等更久甚至一直不消失——这个体验很差,用户会以为没点到。本地先清的代价是要处理失败回滚。我选本地先清,因为「界面立刻响应」在客户端是硬要求,而失败回滚是可以做对的。关键是不能只做一半——我第一版就是只做了本地先清没做回滚,反而比不做本地先清更糟(红点会闪现回来,用户觉得是 bug)。
清除请求传「已读到某条」而不是「全部已读」,代价是要维护一个游标。但传「全部已读」有一个真实的时序问题:请求在网络上走的这段时间里到达的新消息会被一起清掉。用户会遇到「明明有人评论了但我没收到通知」。这类时序问题在手测时几乎不出现(手速不够快),但在真实的高互动账号上会稳定发生。
聚合的取舍在于「压缩信息」和「丢失感知」之间。聚合窗口长,列表干净但新互动感知不到;窗口短,感知及时但压缩效果差。而更关键的判断是「哪些类型不该聚合」——评论、私信这类每条内容都不同的绝不能聚合。我最初把评论也聚合了,用户反馈「看不到别人具体说了什么」,这是把优化用错了地方。
踩过的坑一:红点清了又回来,被用户反馈成「红点坏了」。根因是本地先清但请求失败无回滚。更麻烦的是这个问题在办公室网络下从不出现,我一直复现不了,直到用弱网工具限速才稳定重现。教训是:涉及网络的状态同步,必须在弱网和失败路径下测,成功路径正确不代表功能正确。
踩过的坑二:进入页面就清所有分类的未读。用户只想看评论,结果一进去赞的红点也没了。他会怀疑「是不是有消息被吞了」。修法是清除时机绑定到具体 tab 的实际曝光。这个坑的通用教训是:「已读」的语义应该对应用户真的看到了,而不是他打开了某个容器。
踩过的坑三:新消息直接插入列表把阅读位置顶走。用户正在看第 20 条,5 条新消息插到顶部,他看的那条跑出屏幕。而他不知道发生了什么,只觉得列表乱跳。修法是顶部提示条。这一条和搜索结果、个人主页的滚动位置问题是同一类——界面在用户没有预期的时候发生了变化。
没做的部分:没做消息的本地全量存储与离线可读(只缓存首屏)。全量存储要处理本地库、增量同步、多端一致,复杂度高出一个量级,而消息中心的使用场景基本是在线查看。取舍依据是「离线读历史消息」这个需求实际很少被提。
红点一致性用断言型用例,而且必须在失败路径下测:用弱网工具让清除请求失败,断言本地未读数回滚到原值、红点重新出现且数字正确;再断言重试成功后红点正确消失。这两条是我踩的坑的直接回归。
时序问题的验证:清除请求发出后、返回前,人为注入一条新消息,断言这条新消息的未读没有被清掉。这条要靠打桩制造时序,手测测不出来。
聚合效果报「消息条数压缩比」并说清样本:取一个高互动账号的真实互动数据,报聚合前后的条数对比。要说明是哪种账号(互动量级不同压缩比差很多),不能只报一个数。
未读数一致性:断言 tabBar 角标、消息页 tab、首页入口三处显示的数字始终相同,且总数等于各分类之和。这条防的是多来源问题。
列表性能报机型:加载到多少条时滚动帧率下降到多少,在哪个机型上测的。低端安卓机和 iOS 差别很大,只报一个数没有参考价值。
不要报什么:不要报「消息阅读率提升 N%」——这受消息内容和推送策略影响,归因不清。该报的是「弱网失败时未读数正确回滚」「请求在途期间的新消息不被误清」「三处红点数字一致且等于分类之和」「某量级账号的条数压缩比」这四件可核对的事。
搜索页第一版是最直接的写法:输入框绑定 input 事件,每次输入调建议接口;点搜索按钮调结果接口;结果渲染成一个列表。本地测试完全正常,真机上问题很快就来了,四个问题。
一是建议列表会显示错误的结果。用户输入「篮球」,建议列表里出现的是「篮」的建议。原因是我为每次输入都发了请求,而请求的返回顺序不保证和发出顺序一致——输入「篮」发了一个请求,输入「篮球」发了第二个,如果第一个请求慢、第二个快,那么第二个先返回被渲染,接着第一个返回又把它覆盖了。用户看到的是自己两个字符之前的建议。这个问题在本地测试时几乎不出现(响应太快),在真机弱网下必现。
二是请求量过大。每次输入都发请求,输入「篮球比赛」是四次请求。而其中前三次的结果用户根本没看到(输入太快)。这些请求既浪费服务端资源也占用了客户端的并发额度。
三是切换分类 tab 会丢失结果。用户在「内容」tab 翻了五页,切到「用户」tab 看了一下,切回来又回到第一页,之前翻的五页白翻了。因为我用了一个共享的列表状态。
四是安卓上点击结果会误触。软键盘还弹着,点击列表项时键盘先收起、页面高度变化、列表位置上移,手指落点已经不在原来那一项上了——他点开的是另一条内容。
所以四个改动:请求序号加过期响应丢弃、防抖加最小触发长度、各分类独立维护分页与滚动状态、点击结果先收键盘再跳转。
这个模块最想说的一句话是:并发请求的响应顺序不保证和发出顺序一致,所以「用最后返回的响应更新界面」是错的,必须是「用最新发出的那个请求的响应更新界面」。这两句话听起来差不多,但实现完全不同,而且前者在本地测试时看不出问题——这是我在这个页面上学到最重要的一件事。
abort 能省流量,但它不保证及时(请求可能已经在返回路上),所以序号丢弃这层必须有。两者是叠加关系。用请求序号丢弃而不是「串行请求」。串行(等上一个返回再发下一个)也能保证顺序,但它会让建议明显变慢——用户输入很快,串行意味着要排队。序号丢弃的代价是有些请求发出去了但结果被丢弃(浪费一点流量),换来的是永远显示最新关键词的建议。判据是「用户对建议延迟的敏感度」——建议是输入过程中的辅助,慢了就没意义,所以宁可浪费请求。
防抖时间的取舍:短了省不下请求,长了感觉迟钝。我们最后取的值是在真机上试出来的——关键是要在真机上边输入边看感受,而不是拍一个数字。另外要注意防抖不能用在「点击提交」上,只用在输入触发的建议上;提交必须立即响应。
各 tab 独立状态的代价是内存占用增加。三个分类各存几页数据。但相比「切回来重新翻五页」的体验损失,这点内存是值得的。缓解办法是只保留最近访问的两个 tab 的完整数据,更早的只保留首页——不过我们的量级下没有做到这一步,因为还没到需要的程度。这里我倾向于诚实说明「没做」而不是编一个优化。
搜索历史不上传服务端,代价是换设备就没有历史了。产品提过「多端同步历史」。我的意见是不做:搜索词属于隐私敏感信息,上传会带来存储、合规、删除权的一系列责任,而收益(换设备保留历史)很小。「能不收集就不收集」是我认为对的默认原则。
踩过的坑一:建议列表显示上一个关键词的结果,本地完全测不出来。本地响应几十毫秒,几乎不会乱序;真机弱网下稳定复现。这件事让我第一次真正理解「并发请求的响应顺序不保证」——以前知道这个概念,但没意识到它会以「界面显示错误内容」的形式出现。后来我把「有没有处理响应竞态」加进了自己的自检清单,凡是输入即请求的地方都要检查。
踩过的坑二:安卓上点搜索结果打开的是另一条内容。我一直以为是列表渲染的 bug,查了很久。真正的原因是软键盘收起导致布局上移,手指抬起时落点已经不在原来那一项。修法是先收键盘、等布局稳定再处理点击。教训是:客户端上「界面在用户操作过程中发生变化」会导致各种诡异问题,而这类问题往往不在你怀疑的那个模块里。
踩过的坑三:网络错误被显示成「没有搜索结果」。用户以为真的没有,就不会重试。我们从客诉里发现的——用户说「你们搜不到东西」,实际是他当时网络有问题。教训是空态必须区分原因,「没有数据」和「拿不到数据」对用户的行动指引完全不同。
没做的部分:没做搜索结果的本地缓存(同一个词二次搜索直接出结果)。内容型搜索结果时效性强,缓存容易展示过期内容,而且用户重复搜同一个词的比例不高。取舍依据是「缓存命中率低 + 过期风险高」,收益不划算。
响应竞态的验证必须靠打桩制造乱序,不能靠手测:让第一个请求延迟返回、第二个正常返回,断言界面最终显示的是第二个(最新)请求的结果,第一个的响应被丢弃。这条是我踩的坑的直接回归,也是这个模块最重要的用例。
防抖效果报请求次数:模拟输入一个四字词,断言只发出一次建议请求(原来是四次)。这个数字很具体也很好验证。
建议延迟要报「从停止输入到建议出现」的时间,包含防抖等待。只报接口耗时会漏掉防抖那部分,而用户感知的是总时间。
tab 状态保持:在内容 tab 加载到第五页,切到用户 tab 再切回,断言仍在第五页且滚动位置不变。
软键盘问题的验证要在真机上做,而且要报机型:断言点击列表项打开的是该项对应的内容。这条在模拟器上测不出来(模拟器的键盘行为和真机不同),要诚实说明测试环境。
空态区分:断言无结果时显示建议文案、请求失败时显示重试按钮,两者文案不同。
历史治理:断言重复搜索同一词不会新增条目而是提到最前、超出条数上限时淘汰最旧的、单条删除与清空都生效。
不要报什么:不要报「搜索转化率提升」——这受内容质量和排序算法影响,不是这个页面的功劳。该报的是「乱序响应被正确丢弃」「四字输入只发一次建议请求」「tab 切换后分页与滚动位置保持」「空态区分两种原因」这四件可核对的事。
abort 不保证及时,请求可能已经在返回的路上了。我总结出的一句话是:不能「用最后返回的响应更新界面」,必须「用最新发出的那个请求的响应更新界面」。这两句听起来差不多但实现完全不同。为什么不用串行请求(等上一个回来再发下一个)?因为那会让建议明显变慢——用户输入很快,串行意味着排队,而建议是输入过程中的辅助,慢了就失去意义。所以宁可浪费一些被丢弃的请求。这个坑之后我把「有没有处理响应竞态」加进了自检清单,凡是「输入即请求」的地方都要检查。
个人主页就是一个三列的内容网格,看起来是最简单的页面。第一版用了一个 v-for 直接渲染全部数据,问题在真实数据量下集中爆发,四个问题。
一是加载到几百条后滚动卡顿。头部作者的主页有上千条内容,用户一直往下滑,DOM 节点持续累积,滚动帧率明显下降,低端安卓机上会卡到几乎滑不动。而这个页面是社区里访问量很高的页面。
二是图片加载时布局跳动。封面图的尺寸不统一,我没给容器设固定高度,图片加载完成的瞬间容器高度按图片实际比例变化,整个网格重排、用户正在看的位置被顶走。而图片是陆续加载的,所以跳动会持续一段时间。
三是从详情页返回后回到列表顶部。用户滑了很久点进一条内容,返回时列表重新请求、重新渲染、滚动位置归零,他要重新滑几十屏才能找回原来的位置。这是被抱怨最多的一点。
四是切换 tab 丢失状态。作品、喜欢、收藏三个 tab 共享一份列表状态,切走再切回就要重新加载。
所以四个改动:分片渲染加视口外节点回收、固定宽高比容器加统一裁剪、缓存列表与滚动位置并在返回时恢复、三个 tab 各自维护独立状态。
这个模块最想说的一句话是:客户端页面的性能问题基本都来自「节点数量」和「布局变化次数」这两件事,而它们在数据量小的时候完全看不出来。我本地测试用的是自己的账号,只有十几条内容,三个问题一个都没暴露。所以后来我养成的习惯是用真实的极端数据(最多内容的账号、最差的机型、最慢的网络)来验收,而不是用自己的账号。
选固定宽高比裁剪而不是保留原始比例的瀑布流。瀑布流视觉上更好、图片不被裁掉,但代价有两个:一是高度不确定,视口外回收要先测量每一项的高度,实现复杂度高出一个量级;二是图片加载前无法确定占位高度,必然有布局抖动(除非服务端返回图片尺寸,那又要改接口和存量数据)。我们选固定宽高比,因为它同时解决了抖动和回收两个问题,代价只是图片被裁掉一部分(而封面图的主体一般在中间)。判据是「这个视觉收益值不值得那两个技术代价」——在个人主页这个场景,用户主要是浏览和定位自己的内容,裁剪的影响很小。
视口外回收 vs 只做分片渲染不回收。只分片能解决「一次插入造成掉帧」,但解决不了「节点累积导致越滑越卡」。两个是不同的问题,都要做。回收的代价是滚动时有渲染开销、快速滚动可能出现瞬时空白(靠缓冲区缓解)。判据是「用户会不会滑到那个量级」——头部作者上千条内容,用户确实会一直滑,所以必须回收。
返回时先用缓存渲染再静默刷新,而不是直接重新请求。直接重新请求的问题是位置无法恢复(数据还没回来)、而且刷新后条目可能变了导致位置对不上。用缓存的代价是可能显示稍旧的数据。我认为这个代价可以接受,因为用户刚从详情页返回,他的预期就是「回到我刚才看的地方」,而不是「看到最新数据」。
踩过的坑一:本地测试用自己的账号,只有十几条内容,三个性能问题一个都没暴露。是测试同学用一个头部作者的账号才发现卡顿。教训是:性能问题必须用真实的极端数据验收——最多内容的账号、最差的机型、最慢的网络。我后来把这三个「最」固定成了自己的验收清单。
踩过的坑二:恢复滚动位置时直接设置滚动值,结果一直是 0。查了半天才发现是列表还没渲染出高度,滚动值被钳制了。修法是等列表渲染完成后再设置。这个坑的通用教训是:在客户端设置滚动位置、测量元素尺寸这类操作,都必须等布局完成之后做,而「布局完成」的时机不等于「数据赋值完成」。
踩过的坑三:布局跳动最初被我当成了「图片加载慢」。我一直在优化图片加载速度,但真正的问题是加载完成瞬间的容器高度变化——加载再快也会跳,只是跳得更快而已。教训是:现象和根因不一定在同一件事上,「图片加载完才跳」不代表问题在图片加载。
没做的部分:没做图片的多尺寸适配(按设备像素密度请求不同尺寸)。它能省流量、提升加载速度,但需要服务端支持多规格裁剪,超出了我这次改动的范围。我把它作为后续建议提了出来,并给出了实测的流量数据作为依据。
滚动帧率必须报清三个条件:多少条数据、什么机型、什么网络。只报「帧率提升」没有意义。我的报法是「在某型低端安卓机上,加载到 500 条时滚动帧率从 X 提升到 Y」,并说明是用性能面板录制滚动过程取的平均值。
节点数是比帧率更本质的指标:报「加载到 500 条时页面节点数」,改造前随条数线性增长、改造后基本恒定。这个对比直接解释了为什么帧率改善了,比单报帧率有说服力。
布局抖动用「布局偏移」衡量:录制首屏加载过程,统计图片加载引起的位置偏移量。固定宽高比之后应该接近零。要说明测量方式。
返回恢复位置是断言型用例:滑到某个位置、进详情、返回,断言滚动位置与离开时一致(允许小误差)、列表数据未重新加载。这条要在真机上测。
图片懒加载效果报「打开主页时发起的图片请求数」:改造前是全部条目的图片、改造后只有视口附近的。这个数字很直观。
失败降级:把图片地址改成无效的,断言显示统一占位、网格结构不变、不出现破图。
不要报什么:不要报「页面性能提升 N%」——性能不是单一数字。该报的是「某机型某条数下的帧率对比」「节点数从线性增长变为恒定」「布局偏移接近零」「返回位置恢复准确」这四件带条件的、可核对的事。带条件恰恰说明你知道自己在测什么。
v-for 直接渲染全部数据,用户一直往下滑,DOM 节点持续增长,低端安卓机上滑到几百条就卡到几乎滑不动。这个只能靠视口外节点回收解决——不回收的话卡顿是必然的,只是迟早问题。第二是布局抖动,这个更隐蔽:封面图尺寸不统一,我没给容器设固定高度,每张图片加载完成的瞬间容器高度按实际比例变化,整个网格重排一次。而图片是陆续加载的,所以跳动会持续一段时间,用户正在看的位置反复被顶走。我最初把这个当成了「图片加载慢」,花时间去优化加载速度——但加载再快也会跳,只是跳得更快。修法是固定宽高比容器加统一裁剪。这个改动还有个额外收益:高度确定之后,视口外回收才好做(占位高度能精确算出来)。我从这件事学到两点:一是性能问题必须用真实极端数据验收(最多内容的账号、最差机型、最慢网络),二是现象和根因不一定在同一件事上。
没有匹配的内容,换个关键词试试。
项目拆解 · 社区客户端常用页(uni-app · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据