点赞按钮看起来是最简单的功能:点一下、发请求、成功了变色。但它在真实的信息流产品里有四个问题,而且每一个用户都能直接感知。
一是点了没反应。第一版是「发请求、等成功、再变色」。弱网下要等一两秒,用户以为没点上,又点了一次——于是第一次的点赞和第二次的取消都发出去了,最终状态随机。这是最直接的体验问题。
二是快速连点产生互相矛盾的请求。用户连点五次,发出五个请求(赞、取消、赞、取消、赞)。它们并发返回,最终的本地状态取决于哪个响应最后到,可能和用户的实际操作次数不一致。而且服务端也收到了五次无意义的写操作。
三是同一个内容在多处的状态不一致。同一个视频出现在信息流、详情页、作者主页。我最初把点赞状态存在各自的组件里,结果用户在详情页点了赞,返回信息流发现还是未点赞的样子——他会以为刚才没点成功,再点一次,变成取消。
四是计数和状态不同步。点赞成功了按钮变红,但旁边的数字没加一。因为我只更新了布尔态忘了更新计数,或者反过来。这种中间态看起来就像 bug。
所以四个改动:乐观更新(本地先变,请求在后台发)、连点按最终态合并(只发一次请求,发送前的状态才是要提交的)、状态提到全局单一来源(各处订阅同一份数据)、计数与布尔态一起变、一起回滚。
这个模块最想说的一句话是:只要同一份状态会出现在多个位置,它就不能存在组件里。这条判断听起来简单,但我当初是按「组件自治」的思路写的——每个卡片管自己的状态,看起来很干净。直到用户在两个入口之间来回时才暴露问题。判据很明确:这个状态会不会在别的地方也显示,会就必须提到全局。
乐观更新的代价是「可能显示一个最终没成功的状态」。严格的做法是等服务端确认,绝不显示未确认的状态。但那意味着弱网下每次互动都要等一两秒,而点赞是最高频的操作,这个延迟会毁掉整个手感。取舍是:点赞收藏这类「错了代价很小」的操作用乐观更新,涉及资金或不可逆的操作绝不用。判据就是「乐观显示错了的代价」——点赞错了回滚一下没人在意,支付错了就是事故。
接口用「设为某态」而不是「切换」,这个决定值得单独说。切换语义的接口更简洁(不用传目标态),但它不幂等:网络超时后重试,服务端可能已经收到了第一次,重试就翻转了两次,结果和用户意图相反。而客户端的重试是无法避免的(超时了你不知道服务端收到没有)。所以幂等性必须由接口语义提供。这一条和后端的幂等设计是同一个道理,只是这里的「幂等键」体现为「目标态而非操作」。
连点合并选防抖而不是节流。节流会发出中间态的请求(第一次点击立刻发),而中间态可能和最终态相反。防抖只在停止点击后发一次,提交的一定是用户最后的选择。代价是最后一次点击后要等一个防抖窗口才真正提交,但因为本地已经乐观更新了,用户感知不到。
踩过的坑一:状态存在组件里,用户在详情页点赞返回信息流发现没变。他以为没成功,又点了一次,变成取消。这个 bug 的用户表现是「我点的赞总是掉」,反馈上来时描述得很模糊,排查了一阵才定位。教训是:判断状态该放哪,标准不是「哪个组件用它」,而是「有几个地方会显示它」。我现在写任何互动状态前都会先数一遍它出现在几个位置。
踩过的坑二:用切换语义的接口,超时重试导致点赞被翻转回去。用户点了赞,请求超时,客户端重试,服务端其实两次都收到了,切换两次等于没点。用户看到的是「点了赞,转了一下,又变回没赞」。修法是改接口语义。这件事让我理解了「客户端的重试是必然的,所以幂等必须由接口保证」——不能指望客户端不重试。
踩过的坑三:刷新列表时用服务端数据覆盖,把用户刚点的赞冲掉了。用户点赞、乐观更新已生效、请求还在路上,此时下拉刷新,服务端返回的还是未点赞(因为写入还没完成),按钮闪回未点赞状态。修法是给有未提交变更的条目打标记,覆盖时跳过。这类「乐观状态和服务端状态竞争」的问题在任何乐观更新的场景都会遇到,需要显式处理而不是指望时序恰好正确。
没做的部分:没做离线互动队列(没网时点赞先存本地、联网后补发)。因为点赞的时效性不高而实现复杂度不低(要处理队列去重、顺序、以及联网后内容可能已删除)。当前的处理是没网时直接提示并回滚,用户重新点一下的成本比维护一个队列低。如果是评论或者收藏这种用户投入更多的操作,离线队列就值得做。
响应速度的改善不用测数字,说清机制即可:乐观更新之后本地状态变化是同步的,「零等待」不是一个测出来的数字而是设计的结果。要报的是对比场景——弱网下改造前需要等一次网络往返(用限速工具测出的往返时长),改造后无需等待。
连点合并的验证:用工具监听网络请求,快速点击若干次,断言只发出一个请求且其目标态与最终点击结果一致。这个是可自动化的断言,而且它直接对应「发出矛盾请求」那个 bug。
幂等性的验证:让接口第一次响应超时(但服务端实际处理成功),断言客户端重试后最终状态仍然正确。必须构造这个场景——正常情况下不会超时,所以这个 bug 只在真实弱网下暴露。
多入口一致性的验证:在详情页点赞、返回信息流,断言信息流的状态已同步;反向也测一遍。这类「跨页面状态」的用例容易被漏掉,因为单页面测试都是通过的。
乐观状态保护的验证:点赞后立即下拉刷新(在请求返回前),断言按钮没有闪回未点赞。这一条需要构造慢请求才能测。
不要报什么:不要报「点赞成功率 99%」——失败主要取决于用户网络。该报的是「连点只发一个请求」「重试幂等」「多入口同步」「乐观状态不被覆盖」这四件可验证的行为,它们都是设计正确性的证据。
播放器组件库本身能用,接进来就能播。但「能播」和「好用」之间差的全是系统事件的处理,而这些在开发时几乎不会遇到,全靠用户反馈才发现。
一是来电结束后视频还在放(或者该恢复却没恢复)。来电时系统会暂停音频,我的代码只是记录了「暂停」这个状态。通话结束后我不知道该不该恢复——因为「用户主动点的暂停」和「系统打断的暂停」在我的状态里是同一个值。如果一律恢复,那用户主动暂停后接了个电话,挂了之后视频自己开始放了;如果一律不恢复,那被打断的用户要手动点继续。两种都有人反馈。
二是拔耳机之后声音从扬声器外放了。用户在公共场合看视频,拔掉耳机,声音直接从手机喇叭放出来。这个体验问题很尴尬而且是真实的隐私问题。系统层面有拔耳机的事件,但我没监听。
三是横竖屏切换后播放状态丢失。切到横屏时组件被重建,播放进度回到开头、播放状态变成暂停。用户要重新找到刚才看的位置。
四是手势误触。信息流是上下滑动切视频,而播放器上又有左右滑动拖进度。斜着滑的时候两个手势都触发了——视频切了,进度也被拖了。
所以四个改动:播放控制做成显式状态机并区分暂停的原因、统一处理五类系统打断并为每类定义恢复策略、横竖屏切换保持进度与状态、手势做方向锁定。
这个模块最想说的一句话是:客户端的复杂度不在业务逻辑,在「系统会打断你」。来电、切后台、锁屏、拔耳机、其他应用抢音频——这五类事件在开发环境几乎不会遇到,但用户每天都会遇到。把它们当成一等公民显式处理,而不是等反馈来了再补,是这个模块最主要的经验。
isPlaying、isPaused、isLoading 三个布尔值可以组合出不合法的状态(同时 playing 和 paused),而状态机保证任何时刻只有一个状态,转换路径也是明确的。把播放控制做成状态机,代价是代码量比几个布尔值多。但布尔值的组合会产生不合法状态——我最初就遇到过 isPlaying 和 isPaused 同时为真的情况(两处代码各自改了一个),界面上表现为按钮图标和实际播放状态不一致。状态机保证任何时刻只有一个状态,且转换路径是受控的。判据是「这个东西有几种状态、状态间的转换是否有约束」——超过三四种状态且有约束,就该上状态机。
拔耳机选择暂停而不是静音继续。静音继续的好处是用户重新插上耳机可以接着听(不用找进度)。但代价是用户不知道视频还在播,等他插上耳机时已经过去一段内容了。而且如果他没插耳机就锁屏离开,视频还在静默播放,浪费流量。暂停是更符合直觉的行为,而「重新插上不自动恢复」也是有意的——用户可能只是整理耳机线,不代表要继续看。
手势方向锁定选「移动超过阈值后锁定」而不是「按起始方向判定」。起始方向判定更简单,但手指刚接触屏幕时的移动方向很不稳定(几像素的抖动就可能判错)。等移动超过一个阈值再判定,准确率高得多。阈值的取值要测:太小会误判,太大会让手势响应变迟钝。
踩过的坑一:暂停不区分原因,来电结束后的行为怎么都不对。先是一律恢复,用户反馈「我主动暂停了,接完电话它自己放起来了」;改成一律不恢复,又有人反馈「被电话打断后要手动点继续,很烦」。两次修改都是在错误的层面调整——真正的问题是状态里丢失了「是谁暂停的」这个信息。这件事让我明白「状态不足导致的问题,不可能通过调整逻辑解决」,只能补充状态。
踩过的坑二:没监听拔耳机事件,用户在公共场合被外放。这个问题是用户投诉才知道的,而且投诉的措辞很不客气。教训是「客户端要主动去查平台有哪些系统事件」,而不是等遇到问题再补——因为这些事件在开发环境几乎不会触发,你不会自然想到。我后来的做法是接任何涉及媒体、权限、后台的能力时,先把平台文档里的事件列表通读一遍。
踩过的坑三:斜着滑动时视频切了、进度也被拖了。两个手势各自监听触摸事件,互不知道对方存在。修法是方向锁定加事件拦截(横向锁定时阻止列表的滑动处理)。这类「多个手势竞争同一个触摸序列」的问题在移动端很常见,通用解法就是显式的方向判定与锁定。
没做的部分:没做画中画和后台音频播放。这两个需要原生能力配合(App 端要改原生代码),而且产品定位是视频而不是音频,后台播放的需求不强。留了状态机上的扩展位——「后台是否继续播放」已经是一个开关,具备能力时打开即可。
五类系统打断必须逐一手工验证,这是这个模块唯一可靠的测法:真机上打进一个电话、切后台再回来、锁屏再解锁、播放中拔耳机、播放中启动导航应用触发音频抢占。每一类断言暂停发生、以及打断结束后的恢复行为符合定义。报法是「五类打断场景逐一验证通过」并列出各自的预期行为。模拟器测不出来这些,必须真机。
「主动暂停 vs 系统暂停」的区分验证:主动暂停后接电话、挂断,断言不自动恢复;播放中接电话、挂断,断言自动恢复。这两条对比着测才能证明区分是有效的。
横竖屏的进度保持:记录切换前的进度,切换后断言进度差在可接受范围内(seek 有精度)。要说清可接受范围是多少,因为不可能完全精确。
手势方向锁定:用自动化难测,靠手工——刻意斜着滑动,断言只有一个手势生效。要测多个角度(接近水平、接近垂直、45 度),45 度是最容易出问题的。
状态机的合法性:这个可以自动化——遍历所有状态转换组合,断言非法转换被拒绝(比如从「错误」直接到「播放中」而不经过「加载」)。这类测试能防止后续改动引入不合法路径。
不要报什么:不要报「起播速度」「卡顿率」——那是播放器内核和网络决定的,这个模块管的是交互与状态。该报的是「五类打断的处理符合定义」「主动与系统暂停可区分」「手势不误触」这三件可验证的事。
评论功能的坑集中在两处:软键盘和列表的动态性。四个具体问题。
一是软键盘把输入框挡住了。用户点输入框,键盘弹起,输入框被键盘完全遮住,他看不到自己在打什么字。这个问题在不同平台表现还不一样:有的平台会自动把页面推上去,有的不会;有的上报键盘高度,有的不上报。我最初只在自己的测试机上试过,那台机器恰好会自动推上去,所以完全没发现问题。
二是发完评论找不到自己那条。发布成功后我重新拉取了列表。但列表是按热度排序的,用户刚发的评论排在很后面,他翻不到——于是他以为发布失败,又发了一次。
三是翻页看到重复的评论。用页码分页,第一页拉完之后有人发了新评论,新评论插到了最前面,导致原来第一页的最后一条挤到了第二页——用户翻到第二页看到了重复的那条。反过来如果有评论被删,就会漏掉一条。
四是回复全部平铺,长评论区完全没法看。一条热评有几百条回复,全平铺出来把主评论流冲散了,用户滑了很久都还在同一条评论的回复里。
所以四个改动:监听键盘高度抬升输入区并处理各端差异、发布后乐观插入到顶部并高亮、分页用游标而非页码、回复折叠为楼中楼并独立分页。
这个模块最想说的一句话是:列表是动态的,所以「位置」不可靠,只有「游标」可靠。页码的隐含假设是「列表不变」,而评论区每秒都在变。这条认知不只适用于评论——任何会持续新增内容的列表(消息、动态、日志)都不能用页码分页。
发布后乐观插入到顶部,代价是「这条评论的位置和真实排序不一致」。严格按热度排序的话它应该在很后面。但用户刚发完评论,最关心的是「我发出去了吗」——把它放在顶部并高亮,是对这个需求的直接回应。做法上要给它一个视觉标记(比如「我的评论」标签或短暂高亮),让用户知道这是特殊呈现;下次刷新后它回到真实位置,用户此时已经确认过发布成功了,不会困惑。判据是「用户此刻最需要确认什么」——排序的严格性可以暂时让位于反馈的确定性。
层级限制为两级,接受「回复关系不够精确」。无限嵌套能精确表达谁回复谁,但在手机窄屏上缩进几层后一行只能放几个字,完全没有可读性。用「@某人」表达指向是所有主流产品的做法。这个取舍是可读性优先于结构精确性。
软键盘适配没有统一方案,只能逐端处理。我尝试过写一个「通用」的适配层,但各端的差异不只是数值不同,行为模式也不同(有的平台会自己推页面,你再推一次就推多了)。最后的做法是抽象出统一接口,内部按端分别实现,而不是试图用一套逻辑覆盖所有端。判据是:当差异在「行为模式」层面而不只是「参数」层面时,抽象应该在接口而不在实现。
踩过的坑一:软键盘遮挡问题,我的测试机恰好不复现。那台机器会自动把页面推上去,所以我完全没意识到有问题。用户反馈「打字看不到自己打的什么」时我一头懵。教训是「涉及系统 UI 的功能必须多端多机型验证」——键盘、状态栏、安全区、返回手势都属于这一类,它们的行为高度依赖平台和机型。
踩过的坑二:用页码分页,用户翻页看到重复评论。而且这个问题在测试时不复现——测试环境没有其他人在同时发评论。只有真实流量下(评论区一直有新评论)才会出现。教训是:涉及并发写入的列表,测试时要模拟「拉取过程中有新数据插入」这个场景,而不是在静态数据上测。
踩过的坑三:发布后重新拉列表,用户找不到自己的评论又发了一次。产生了重复评论。这件事有两个层面的教训:一是要乐观插入让用户看到;二是要有幂等防重——即使用户重复提交,也不该产生两条。两个措施缺一不可:只做幂等的话用户还是困惑,只做插入的话仍可能因为别的原因重复提交。
没做的部分:没做评论的实时推送(别人发的新评论实时出现)。需要长连接,而且实时插入会让正在阅读的用户列表跳动。折中方案是显示「有 N 条新评论,点击查看」的提示条,让用户自己决定何时刷新——这比自动插入体验好,实现也简单得多。
软键盘适配必须逐端真机验证,列出验证矩阵:平台(小程序 / App)× 系统(iOS / Android)× 至少两个机型。每一格断言输入框可见、列表底部不被遮挡、收起后滚动位置保持。报法是「N 个组合逐一验证通过」并列出组合。这一条没有捷径,模拟器和单机型测试都不可靠。
分页正确性要构造「拉取过程中有新数据」的场景:拉完第一页后,手动插入几条新评论,再拉第二页,断言没有重复也没有遗漏。这个场景必须构造——静态数据下页码和游标表现完全一样,测不出差别。
发布幂等的验证:让提交接口第一次超时(服务端实际成功),断言重试后只产生一条评论。要构造延迟才能测。
乐观插入的验证:发布后断言新评论出现在列表顶部且有视觉标记;下拉刷新后断言它回到真实排序位置。两条都要断言,后一条证明乐观插入是临时呈现而不是破坏了排序。
展开状态保持:展开某条评论的回复、下拉刷新,断言仍是展开的。
不要报什么:不要报「评论加载速度」——那取决于接口和网络。该报的是「N 个端机型组合的键盘适配通过」「有新数据插入时分页无重复无遗漏」「超时重试只产生一条」这三件可验证的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 播放与互动基础(uni-app · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据