怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

实习级这一档是干什么的 信息流的窗口复用、播放实例池化、弹幕动效降级这些性能台面,实习生大概率碰不到。这一档收的是客户端里实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块彼此独立——只做过一个就只写一个。
怎么讲才不显得 low 「我做了个点赞按钮」和「点赞要先本地变色再发请求(不然点了没反应),但失败要回滚,而且同一个视频在信息流、详情页、个人主页三处都有点赞按钮,一处点了另外两处也得变,所以状态不能存在组件里」——同一件事,后者面试官会顺着追问。这一档的破解办法是找到「同一份状态出现在多处」和「系统事件打断用户操作」这两类问题:它们都有明确的对错。
三条自检 一、能说出不这么做会怎样(不做乐观更新点了像没反应、状态存组件里三处显示不一致、不处理来电打断视频会在通话时继续放);二、能说出你踩过的具体坑;三、能说出量级(列表多少条、状态同步涉及几处、目标机型档位)。三条都有就能写。
项目背景设定 短视频 App 的用户端,uni-app + Vue 3,发布到微信小程序与 App 双端。核心的信息流性能和播放器池化由正式同学负责,我接的是围绕播放与互动的三块基础能力。
为什么这三块值得写 它们的技术含量来自客户端特有的两类问题:一是同一份状态出现在多个位置(点赞数在信息流、详情、主页都有),二是系统事件会打断用户操作(来电、切后台、拔耳机、锁屏)。这两类问题在服务端不存在,而它们决定了客户端功能是「能用」还是「好用」。

模块一:点赞收藏关注的乐观更新

  1. 点赞收藏关注的乐观更新(本地先变后请求 + 失败回滚 + 连点合并为最终态 + 状态提到全局单一来源)★★★
    简历这样写 互动状态的一致性与响应速度(uni-app + Pinia 全局状态 + 乐观更新 + 请求合并):点赞、收藏、关注改为乐观更新(本地立即变化再发请求,弱网下不再出现「点了没反应」),失败时回滚并提示而非静默;连续点击按最终态合并请求而非每次点击都发(原先快速连点会发出多个互相矛盾的请求,最终状态取决于返回顺序);同一内容的互动状态在信息流、详情页、个人主页多处出现,把状态提到全局单一来源并由各处订阅(原先各组件自持状态,一处操作另外两处不同步);计数与布尔态分开维护,避免「已点赞但数字没加」的中间态。改造后互动的响应从等待网络变为即时反馈,多入口状态不一致的问题不再出现。
    展开完整拆解
    为什么要这么设计

    点赞按钮看起来是最简单的功能:点一下、发请求、成功了变色。但它在真实的信息流产品里有四个问题,而且每一个用户都能直接感知。

    一是点了没反应。第一版是「发请求、等成功、再变色」。弱网下要等一两秒,用户以为没点上,又点了一次——于是第一次的点赞和第二次的取消都发出去了,最终状态随机。这是最直接的体验问题。

    二是快速连点产生互相矛盾的请求。用户连点五次,发出五个请求(赞、取消、赞、取消、赞)。它们并发返回,最终的本地状态取决于哪个响应最后到,可能和用户的实际操作次数不一致。而且服务端也收到了五次无意义的写操作。

    三是同一个内容在多处的状态不一致。同一个视频出现在信息流、详情页、作者主页。我最初把点赞状态存在各自的组件里,结果用户在详情页点了赞,返回信息流发现还是未点赞的样子——他会以为刚才没点成功,再点一次,变成取消。

    四是计数和状态不同步。点赞成功了按钮变红,但旁边的数字没加一。因为我只更新了布尔态忘了更新计数,或者反过来。这种中间态看起来就像 bug。

    所以四个改动:乐观更新(本地先变,请求在后台发)、连点按最终态合并(只发一次请求,发送前的状态才是要提交的)、状态提到全局单一来源(各处订阅同一份数据)、计数与布尔态一起变、一起回滚

    这个模块最想说的一句话是:只要同一份状态会出现在多个位置,它就不能存在组件里。这条判断听起来简单,但我当初是按「组件自治」的思路写的——每个卡片管自己的状态,看起来很干净。直到用户在两个入口之间来回时才暴露问题。判据很明确:这个状态会不会在别的地方也显示,会就必须提到全局。

    整体链路
    状态存放:全局单一来源(不在组件里) │ ├─ 按内容 ID 存一份互动状态 │ { liked: bool, likeCount: n, favored: bool, followed: bool } │ ├─ 信息流卡片 / 详情页 / 主页 都订阅这一份,不各自持有 │ 一处变化,所有订阅方自动同步 │ └─ 列表数据里的互动字段进入全局后,列表只存内容 ID 与静态字段 避免「列表里的副本」和「全局的主本」不一致 点击的处理(乐观更新) │ ├─ 1 立即改本地状态:布尔态取反 + 计数相应加减 │ 布尔与计数必须同时变,不能只变一个(否则出现中间态) │ ├─ 2 立即给交互反馈(动画、轻震动) │ 用户的感知是即时的,不等网络 │ ├─ 3 请求进入「待提交」并做防抖合并 │ 连点期间只更新目标态,不逐次发请求 │ 防抖窗口结束后,只发一次请求,提交的是当前的最终态 │ └─ 4 请求带幂等信息(内容 ID + 目标态) 重试时语义明确:不是「切换」而是「设为某态」 用「切换」语义的接口在重试时会翻转两次,结果错误 失败处理 │ ├─ 回滚本地状态(布尔与计数一起回滚) │ ├─ 明确提示(轻提示,不打断) │ 静默失败最糟:用户以为成功了,下次进来发现没有 │ └─ 网络错误可给一次自动重试;业务拒绝(如已被封禁)不重试 多端与多入口的一致性 │ ├─ 进入详情页时以服务端返回为准覆盖本地(可能在别的端操作过) │ 但要避免覆盖掉「用户刚点还没提交成功」的乐观状态 │ 做法:本地有未提交的变更时,跳过这次覆盖 │ └─ 列表下拉刷新时同样以服务端为准,但保留未提交的乐观状态 关注的额外考虑 ├─ 关注涉及双方关系,取消关注通常要二次确认(防误触) ├─ 关注成功后可能需要刷新信息流(推荐会变),但不要立刻抖动列表 └─ 关注按钮在多处出现(视频上、主页、粉丝列表),同样走全局状态
    分步拆解
    1. 状态提到全局单一来源,这是这个模块的地基。判据是「这个状态会不会在别的地方也显示」——会就必须提到全局。组件各自持有状态在单页面时很干净,一旦有多入口就必然不一致。
    2. 列表数据里的互动字段要合并进全局,不要在列表里留副本。否则会出现「全局改了但列表里的副本没改」,而列表渲染用的是副本。做法是列表只存内容 ID 和静态字段,互动字段从全局读。
    3. 乐观更新:本地先变,请求在后台发。用户的感知是即时的。弱网下这个差别极大——等网络的话点一下要一两秒才有反应,用户会以为没点上。
    4. 布尔态和计数必须同时变、同时回滚。只变一个会出现「已点赞但数字没加」这种看起来像 bug 的中间态。把它们放在同一个更新操作里,不要分两处写。
    5. 连点要合并为最终态,只发一次请求。做法是点击时只更新「目标态」,防抖窗口结束后发一次请求提交当前目标态。不合并的后果是发出多个互相矛盾的请求,最终状态取决于返回顺序。
    6. 接口语义要用「设为某态」而不是「切换」。这一条很关键:切换语义的接口在重试时会翻转两次,结果就错了(网络超时重试一次,服务端其实收到了两次切换)。用「设为已赞/未赞」的语义,重试是幂等的。
    7. 失败要回滚并提示,不能静默。静默失败最糟——用户以为成功了,下次进来发现没有,会认为平台把他的点赞弄丢了。提示要轻(不弹模态框打断)。
    8. 区分可重试与不可重试的失败。网络错误可以自动重试一次;业务拒绝(内容已删除、账号被限制)不要重试,直接回滚并说明原因。
    9. 进入详情页或刷新列表时以服务端为准,但要保护未提交的乐观状态。用户可能刚点了赞、请求还没回来,此时如果用服务端数据覆盖,按钮会闪回未点赞。做法是记录「有未提交的变更」标记,有标记时跳过覆盖。
    10. 取消关注要二次确认。关注涉及双方关系,误触取消的代价比误触点赞大。但点赞不要二次确认——那会毁掉这个高频操作的手感。判据是「误操作的代价」和「操作频率」
    11. 关注成功后不要立刻抖动信息流。推荐结果会变,但用户正在看当前这条视频,列表突然重排是很差的体验。做法是标记「下次刷新时应用新推荐」。
    12. 状态的持久化要谨慎。可以缓存到本地让下次打开秒显,但要有过期时间,且以服务端为准——用户可能在别的设备上取消了点赞。
    关键决策与取舍

    乐观更新的代价是「可能显示一个最终没成功的状态」。严格的做法是等服务端确认,绝不显示未确认的状态。但那意味着弱网下每次互动都要等一两秒,而点赞是最高频的操作,这个延迟会毁掉整个手感。取舍是:点赞收藏这类「错了代价很小」的操作用乐观更新,涉及资金或不可逆的操作绝不用。判据就是「乐观显示错了的代价」——点赞错了回滚一下没人在意,支付错了就是事故。

    接口用「设为某态」而不是「切换」,这个决定值得单独说。切换语义的接口更简洁(不用传目标态),但它不幂等:网络超时后重试,服务端可能已经收到了第一次,重试就翻转了两次,结果和用户意图相反。而客户端的重试是无法避免的(超时了你不知道服务端收到没有)。所以幂等性必须由接口语义提供。这一条和后端的幂等设计是同一个道理,只是这里的「幂等键」体现为「目标态而非操作」。

    连点合并选防抖而不是节流。节流会发出中间态的请求(第一次点击立刻发),而中间态可能和最终态相反。防抖只在停止点击后发一次,提交的一定是用户最后的选择。代价是最后一次点击后要等一个防抖窗口才真正提交,但因为本地已经乐观更新了,用户感知不到。

    踩过的坑一:状态存在组件里,用户在详情页点赞返回信息流发现没变。他以为没成功,又点了一次,变成取消。这个 bug 的用户表现是「我点的赞总是掉」,反馈上来时描述得很模糊,排查了一阵才定位。教训是:判断状态该放哪,标准不是「哪个组件用它」,而是「有几个地方会显示它」。我现在写任何互动状态前都会先数一遍它出现在几个位置。

    踩过的坑二:用切换语义的接口,超时重试导致点赞被翻转回去。用户点了赞,请求超时,客户端重试,服务端其实两次都收到了,切换两次等于没点。用户看到的是「点了赞,转了一下,又变回没赞」。修法是改接口语义。这件事让我理解了「客户端的重试是必然的,所以幂等必须由接口保证」——不能指望客户端不重试。

    踩过的坑三:刷新列表时用服务端数据覆盖,把用户刚点的赞冲掉了。用户点赞、乐观更新已生效、请求还在路上,此时下拉刷新,服务端返回的还是未点赞(因为写入还没完成),按钮闪回未点赞状态。修法是给有未提交变更的条目打标记,覆盖时跳过。这类「乐观状态和服务端状态竞争」的问题在任何乐观更新的场景都会遇到,需要显式处理而不是指望时序恰好正确。

    没做的部分:没做离线互动队列(没网时点赞先存本地、联网后补发)。因为点赞的时效性不高而实现复杂度不低(要处理队列去重、顺序、以及联网后内容可能已删除)。当前的处理是没网时直接提示并回滚,用户重新点一下的成本比维护一个队列低。如果是评论或者收藏这种用户投入更多的操作,离线队列就值得做。

    数字是怎么测的

    响应速度的改善不用测数字,说清机制即可:乐观更新之后本地状态变化是同步的,「零等待」不是一个测出来的数字而是设计的结果。要报的是对比场景——弱网下改造前需要等一次网络往返(用限速工具测出的往返时长),改造后无需等待。

    连点合并的验证:用工具监听网络请求,快速点击若干次,断言只发出一个请求且其目标态与最终点击结果一致。这个是可自动化的断言,而且它直接对应「发出矛盾请求」那个 bug。

    幂等性的验证:让接口第一次响应超时(但服务端实际处理成功),断言客户端重试后最终状态仍然正确必须构造这个场景——正常情况下不会超时,所以这个 bug 只在真实弱网下暴露。

    多入口一致性的验证:在详情页点赞、返回信息流,断言信息流的状态已同步;反向也测一遍。这类「跨页面状态」的用例容易被漏掉,因为单页面测试都是通过的。

    乐观状态保护的验证:点赞后立即下拉刷新(在请求返回前),断言按钮没有闪回未点赞。这一条需要构造慢请求才能测

    不要报什么:不要报「点赞成功率 99%」——失败主要取决于用户网络。该报的是「连点只发一个请求」「重试幂等」「多入口同步」「乐观状态不被覆盖」这四件可验证的行为,它们都是设计正确性的证据。

    面试追问
    Q:乐观更新如果最终失败了,用户已经看到成功的状态,这不是欺骗用户吗? A:这是个真实的权衡,我的判断依据是「乐观显示错了的代价有多大」。点赞收藏这类操作,错了的代价是「回滚一下加一个轻提示」,用户几乎不在意;而不用乐观更新的代价是每次互动都要等一次网络往返,弱网下一两秒,用户会以为没点上然后重复点击——这个体验损失是必然发生的,而失败回滚是偶发的。所以取舍很清楚。但边界必须守住:涉及资金、不可逆、或者用户会基于它做后续决策的操作,绝不能乐观更新。比如支付成功、订单提交、内容删除,这些必须等服务端确认——因为显示错了的代价是用户做出错误判断。另外乐观更新的前提是失败时必须明确提示且状态真的回滚,静默失败才是真的欺骗用户:他以为点赞了,下次进来发现没有,会认为平台把他的操作弄丢了。
    Q:为什么接口要设计成「设为某态」而不是「切换」?切换更简洁。 A:因为切换语义不幂等,而客户端的重试是无法避免的。具体场景:用户点赞,请求发出后网络超时——此时客户端不知道服务端有没有收到,唯一合理的行为是重试。如果接口是「切换」,服务端可能已经处理了第一次(点赞成功),重试就切换了第二次(变成取消),最终结果和用户意图完全相反。用户看到的是「点了赞,转一下,又变回没赞」。我们真的踩过这个坑。改成「设为已赞」之后,重试多少次结果都一样,幂等性由接口语义天然保证,不需要额外的幂等键。这件事让我理解了一个更一般的原则:「相对操作」不幂等,「绝对状态」幂等——加一、切换、追加都是相对操作;设为某值、覆盖为某状态都是绝对状态。凡是可能被重试的接口,都应该设计成绝对状态语义。这和后端做幂等的思路是一致的,只是表现形式不同。

模块二:播放器基础封装

  1. 播放器基础封装(系统事件打断的统一处理 + 横竖屏与手势 + 静音与音频焦点 + 状态机化的播放控制)★★★
    简历这样写 播放器交互封装(uni-app + 能力抽象层 + 播放状态机 + 系统事件监听):把播放控制收敛为显式状态机(空闲 / 加载 / 播放 / 暂停 / 结束 / 错误),并区分「用户主动暂停」与「系统打断暂停」(原先混为一种,来电结束后不知道该不该恢复);统一处理来电、切后台、锁屏、拔耳机、其他应用抢占音频五类打断,各自的恢复策略不同(拔耳机应暂停而非静音继续);横竖屏切换保持播放进度与状态,手势控制(进度拖动、音量亮度)做方向锁定避免误触;小程序与 App 的播放能力差异收敛到抽象层,业务侧不出现条件编译。改造后「通话结束后视频还在放」「拔耳机后声音外放」两类反馈不再出现。
    展开完整拆解
    为什么要这么设计

    播放器组件库本身能用,接进来就能播。但「能播」和「好用」之间差的全是系统事件的处理,而这些在开发时几乎不会遇到,全靠用户反馈才发现。

    一是来电结束后视频还在放(或者该恢复却没恢复)。来电时系统会暂停音频,我的代码只是记录了「暂停」这个状态。通话结束后我不知道该不该恢复——因为「用户主动点的暂停」和「系统打断的暂停」在我的状态里是同一个值。如果一律恢复,那用户主动暂停后接了个电话,挂了之后视频自己开始放了;如果一律不恢复,那被打断的用户要手动点继续。两种都有人反馈。

    二是拔耳机之后声音从扬声器外放了。用户在公共场合看视频,拔掉耳机,声音直接从手机喇叭放出来。这个体验问题很尴尬而且是真实的隐私问题。系统层面有拔耳机的事件,但我没监听。

    三是横竖屏切换后播放状态丢失。切到横屏时组件被重建,播放进度回到开头、播放状态变成暂停。用户要重新找到刚才看的位置。

    四是手势误触。信息流是上下滑动切视频,而播放器上又有左右滑动拖进度。斜着滑的时候两个手势都触发了——视频切了,进度也被拖了。

    所以四个改动:播放控制做成显式状态机并区分暂停的原因统一处理五类系统打断并为每类定义恢复策略横竖屏切换保持进度与状态手势做方向锁定

    这个模块最想说的一句话是:客户端的复杂度不在业务逻辑,在「系统会打断你」。来电、切后台、锁屏、拔耳机、其他应用抢音频——这五类事件在开发环境几乎不会遇到,但用户每天都会遇到。把它们当成一等公民显式处理,而不是等反馈来了再补,是这个模块最主要的经验。

    整体链路
    播放状态机(显式建模,不用零散的布尔值) │ ├─ 状态:空闲 · 加载中 · 播放中 · 暂停 · 已结束 · 错误 │ └─ 暂停必须带原因(这是关键) pausedBy: 'user' | 'system' | 'invisible' | 'audio-focus' 来电结束后是否自动恢复,取决于是谁暂停的 混为一种的后果:要么该恢复不恢复,要么不该恢复却恢复了 五类系统打断与各自的恢复策略 │ ├─ 来电 / 系统中断音频 │ 暂停(pausedBy = system)→ 中断结束后自动恢复 │ 因为用户并没有表达「不想看了」 │ ├─ 切后台 / 锁屏 │ 暂停(pausedBy = invisible)→ 回前台自动恢复 │ 注意:某些场景(如音频类内容)产品可能要求后台继续播 │ 这是产品决策,代码里要留开关 │ ├─ 拔耳机 │ 必须暂停,不能静音继续也不能外放继续 │ 理由:外放是隐私问题,静音继续则用户以为还在放 │ 重新插入耳机后不自动恢复(用户可能已经不在看了) │ ├─ 其他应用抢占音频焦点(如导航播报、别的视频) │ 暂停(pausedBy = audio-focus)→ 焦点归还后恢复 │ └─ 用户主动暂停 pausedBy = user → 任何系统事件结束后都不自动恢复 这条是上面所有恢复策略的前提 横竖屏 │ ├─ 切换前记录:当前进度 + 播放状态 + 音量 ├─ 切换后恢复:seek 到原进度 + 恢复原状态 └─ 尽量复用同一个播放实例而不是销毁重建 重建的代价是重新起播(黑屏一下)且要重新缓冲 手势(方向锁定,防误触) │ ├─ 触摸开始 → 记录起点,方向未定 │ ├─ 移动超过阈值后判定主方向,并锁定 │ 纵向为主 → 交给列表滑动,播放器不处理 │ 横向为主 → 播放器处理进度拖动,阻止列表滑动 │ 锁定后不再改变,直到触摸结束 │ ├─ 不锁定的后果:斜滑时两个手势同时生效 │ 视频被切走了,同时进度也被拖动了 │ └─ 拖动进度时显示预览时间,松手才真正 seek 拖动过程中频繁 seek 会卡顿且耗流量 静音策略 ├─ 信息流自动播放默认静音(平台规则与用户体验双重要求) ├─ 用户主动开启声音后,在本次会话内记住这个选择 └─ 详情页进入默认带声音(用户是主动点进来的) 平台差异收敛 ├─ 小程序与 App 的播放组件 API、全屏方式、事件名都不同 ├─ 抽一层统一接口:play / pause / seek / setMuted / onEvent └─ 业务代码里不出现条件编译,差异关在抽象层
    分步拆解
    1. 暂停必须带原因,这是整个模块最关键的一条。「用户主动暂停」和「系统打断暂停」在状态上必须可区分,否则打断结束后你无法决定该不该恢复——一律恢复会违背用户意图,一律不恢复会让被打断的用户手动点继续。
    2. 用显式状态机而不是零散的布尔值。isPlayingisPausedisLoading 三个布尔值可以组合出不合法的状态(同时 playing 和 paused),而状态机保证任何时刻只有一个状态,转换路径也是明确的。
    3. 五类打断要逐一处理,不能只处理切后台。来电、切后台、锁屏、拔耳机、音频焦点被抢。开发环境几乎遇不到这些,所以必须主动去查平台文档有哪些事件、逐个接上。
    4. 拔耳机必须暂停,这一条不要犹豫。外放是隐私问题(公共场合尴尬),静音继续则用户以为还在放(实际上错过了内容)。暂停是唯一正确的行为。重新插上不自动恢复——用户可能已经不在看了。
    5. 后台是否继续播放要做成开关。视频类通常应该暂停,但如果内容偏音频(播客、音乐类)产品可能要求后台继续。这是产品决策,代码里留开关而不是写死。
    6. 横竖屏切换要尽量复用播放实例。销毁重建的代价是重新起播(黑屏、重新缓冲)。如果平台限制必须重建,那至少要记录进度并 seek 回去,并保持播放状态。
    7. 手势必须做方向锁定。触摸移动超过阈值后判定主方向并锁定,锁定后不再改变。不锁定的后果是斜滑时视频被切走、同时进度也被拖动了。这是信息流加播放器场景的必现问题。
    8. 拖动进度时只显示预览,松手才真正 seek。拖动过程中频繁 seek 会导致卡顿和重复缓冲(每次 seek 都要重新请求数据)。
    9. 信息流自动播放默认静音。这既是平台规则(很多平台不允许带声音自动播放)也是用户体验要求(突然出声很惊吓)。用户主动开声后在本次会话内记住,不要每条视频都要重新点。
    10. 详情页进入默认带声音。因为用户是主动点进来的,意图明确。信息流和详情页的默认策略不同,这个区分要有。
    11. 平台差异收敛到抽象层。播放组件的 API、全屏方式、事件名各端不同。业务代码里出现条件编译是维护灾难——每加一个功能都要写两遍。
    12. 错误态要有明确呈现和重试入口。视频加载失败、格式不支持、网络中断,各自的提示和可采取的行动不同。只显示一个黑屏或者转圈是最差的处理。
    关键决策与取舍

    把播放控制做成状态机,代价是代码量比几个布尔值多。但布尔值的组合会产生不合法状态——我最初就遇到过 isPlayingisPaused 同时为真的情况(两处代码各自改了一个),界面上表现为按钮图标和实际播放状态不一致。状态机保证任何时刻只有一个状态,且转换路径是受控的。判据是「这个东西有几种状态、状态间的转换是否有约束」——超过三四种状态且有约束,就该上状态机。

    拔耳机选择暂停而不是静音继续。静音继续的好处是用户重新插上耳机可以接着听(不用找进度)。但代价是用户不知道视频还在播,等他插上耳机时已经过去一段内容了。而且如果他没插耳机就锁屏离开,视频还在静默播放,浪费流量。暂停是更符合直觉的行为,而「重新插上不自动恢复」也是有意的——用户可能只是整理耳机线,不代表要继续看。

    手势方向锁定选「移动超过阈值后锁定」而不是「按起始方向判定」。起始方向判定更简单,但手指刚接触屏幕时的移动方向很不稳定(几像素的抖动就可能判错)。等移动超过一个阈值再判定,准确率高得多。阈值的取值要测:太小会误判,太大会让手势响应变迟钝。

    踩过的坑一:暂停不区分原因,来电结束后的行为怎么都不对。先是一律恢复,用户反馈「我主动暂停了,接完电话它自己放起来了」;改成一律不恢复,又有人反馈「被电话打断后要手动点继续,很烦」。两次修改都是在错误的层面调整——真正的问题是状态里丢失了「是谁暂停的」这个信息。这件事让我明白「状态不足导致的问题,不可能通过调整逻辑解决」,只能补充状态。

    踩过的坑二:没监听拔耳机事件,用户在公共场合被外放。这个问题是用户投诉才知道的,而且投诉的措辞很不客气。教训是「客户端要主动去查平台有哪些系统事件」,而不是等遇到问题再补——因为这些事件在开发环境几乎不会触发,你不会自然想到。我后来的做法是接任何涉及媒体、权限、后台的能力时,先把平台文档里的事件列表通读一遍。

    踩过的坑三:斜着滑动时视频切了、进度也被拖了。两个手势各自监听触摸事件,互不知道对方存在。修法是方向锁定加事件拦截(横向锁定时阻止列表的滑动处理)。这类「多个手势竞争同一个触摸序列」的问题在移动端很常见,通用解法就是显式的方向判定与锁定。

    没做的部分:没做画中画和后台音频播放。这两个需要原生能力配合(App 端要改原生代码),而且产品定位是视频而不是音频,后台播放的需求不强。留了状态机上的扩展位——「后台是否继续播放」已经是一个开关,具备能力时打开即可。

    数字是怎么测的

    五类系统打断必须逐一手工验证,这是这个模块唯一可靠的测法:真机上打进一个电话、切后台再回来、锁屏再解锁、播放中拔耳机、播放中启动导航应用触发音频抢占。每一类断言暂停发生、以及打断结束后的恢复行为符合定义。报法是「五类打断场景逐一验证通过」并列出各自的预期行为。模拟器测不出来这些,必须真机。

    「主动暂停 vs 系统暂停」的区分验证:主动暂停后接电话、挂断,断言不自动恢复;播放中接电话、挂断,断言自动恢复。这两条对比着测才能证明区分是有效的。

    横竖屏的进度保持:记录切换前的进度,切换后断言进度差在可接受范围内(seek 有精度)。要说清可接受范围是多少,因为不可能完全精确。

    手势方向锁定:用自动化难测,靠手工——刻意斜着滑动,断言只有一个手势生效。要测多个角度(接近水平、接近垂直、45 度),45 度是最容易出问题的。

    状态机的合法性:这个可以自动化——遍历所有状态转换组合,断言非法转换被拒绝(比如从「错误」直接到「播放中」而不经过「加载」)。这类测试能防止后续改动引入不合法路径。

    不要报什么:不要报「起播速度」「卡顿率」——那是播放器内核和网络决定的,这个模块管的是交互与状态。该报的是「五类打断的处理符合定义」「主动与系统暂停可区分」「手势不误触」这三件可验证的事。

    面试追问
    Q:暂停就是暂停,为什么还要记录是谁暂停的? A:因为打断结束后是否自动恢复,完全取决于是谁暂停的。如果是用户主动点的暂停,那他表达了「不想看了」,接完电话后自动恢复就违背了他的意图;如果是来电打断的,他并没有表达任何意图,挂断后自动恢复才是他期望的。这两种情况在界面上看起来一样(都是暂停态),但正确行为完全相反。我最初没记录原因,于是先试了「一律恢复」——被投诉「我暂停了它自己放起来」;又改成「一律不恢复」——被投诉「接完电话要手动点」。两次都是在逻辑层面调整,但问题的根源是状态里缺少信息。这件事让我总结出一条:如果同一个状态下需要做出不同的行为,说明状态的粒度不够,应该补充状态而不是增加条件判断。这条判断后来在很多地方都用上了——比如订单的「已取消」要区分是用户取消还是超时取消,因为后续处理不同。
    Q:这些系统事件的处理,你是怎么知道要处理哪些的? A:坦白说最初不知道,是被用户投诉出来的——拔耳机外放那次投诉措辞很不客气。这件事之后我改了方法:接任何涉及媒体、权限、后台的平台能力时,先把官方文档里的事件列表通读一遍,而不是只看自己需要的那几个 API。因为这类事件有个共同特点:在开发环境几乎不会触发——你在电脑上调试小程序不会来电话、不会拔耳机、不会被导航抢音频,所以你不会自然想到它们存在。通读事件列表是成本最低的补救。另外我还养成了一个习惯:把「系统可能怎么打断我」列成一个清单,每个媒体类功能都过一遍——来电、切后台、锁屏、耳机、音频焦点、低电量模式、系统弹窗。这个清单是可复用的资产,做直播、做语音、做录制时都能用上。我认为这比记住某个具体 API 更有价值。

模块三:评论列表与发布

  1. 评论列表与发布(软键盘遮挡适配 + 两级回复的展开交互 + 发布后乐观插入 + 游标分页配合)★★
    简历这样写 评论列表与发布交互(uni-app + 键盘事件适配 + 游标分页 + 乐观插入):软键盘弹起时输入框被遮挡,改为监听键盘高度变化并抬升输入区,并处理各端差异(部分平台不上报高度需用视口变化推算);两级回复采用楼中楼折叠加「展开更多回复」而非全部平铺,回复的分页与主评论分页各自独立;发布成功后乐观插入到列表顶部并高亮(原先重新拉取整个列表,用户找不到自己刚发的那条);分页配合服务端游标而非页码,避免新评论插入导致的重复与遗漏;发布走客户端幂等标识防重复提交,失败保留输入内容。改造后「发完看不到自己的评论」与「翻页看到重复评论」两类反馈不再出现。
    展开完整拆解
    为什么要这么设计

    评论功能的坑集中在两处:软键盘列表的动态性。四个具体问题。

    一是软键盘把输入框挡住了。用户点输入框,键盘弹起,输入框被键盘完全遮住,他看不到自己在打什么字。这个问题在不同平台表现还不一样:有的平台会自动把页面推上去,有的不会;有的上报键盘高度,有的不上报。我最初只在自己的测试机上试过,那台机器恰好会自动推上去,所以完全没发现问题。

    二是发完评论找不到自己那条。发布成功后我重新拉取了列表。但列表是按热度排序的,用户刚发的评论排在很后面,他翻不到——于是他以为发布失败,又发了一次。

    三是翻页看到重复的评论。用页码分页,第一页拉完之后有人发了新评论,新评论插到了最前面,导致原来第一页的最后一条挤到了第二页——用户翻到第二页看到了重复的那条。反过来如果有评论被删,就会漏掉一条。

    四是回复全部平铺,长评论区完全没法看。一条热评有几百条回复,全平铺出来把主评论流冲散了,用户滑了很久都还在同一条评论的回复里。

    所以四个改动:监听键盘高度抬升输入区并处理各端差异发布后乐观插入到顶部并高亮分页用游标而非页码回复折叠为楼中楼并独立分页

    这个模块最想说的一句话是:列表是动态的,所以「位置」不可靠,只有「游标」可靠。页码的隐含假设是「列表不变」,而评论区每秒都在变。这条认知不只适用于评论——任何会持续新增内容的列表(消息、动态、日志)都不能用页码分页。

    整体链路
    软键盘适配(各端行为不同,必须逐端验证) │ ├─ 监听键盘高度变化事件 → 把输入区上移相应高度 │ 优先用平台提供的键盘高度事件 │ ├─ 部分平台不上报高度 → 用视口高度变化推算 │ keyboardHeight ≈ 原视口高度 - 当前视口高度 │ 要排除地址栏收起等干扰(设一个最小阈值) │ ├─ 抬升时同步让列表底部留出等高的占位 │ 否则最后几条评论被输入区盖住 │ ├─ 键盘收起 → 恢复布局,并保持列表滚动位置 │ └─ iOS 与 Android、小程序与 App 的行为各不相同 这一块必须每端真机验证,不能只测一端 分页:用游标不用页码 │ ├─ 请求带上「最后一条的游标」而非「第几页」 │ 游标通常是「排序键 + ID」的组合,保证唯一且有序 │ ├─ 页码的问题:它假设列表不变 │ 新评论插到前面 → 原第一页的末条挤到第二页 → 用户看到重复 │ 评论被删 → 后面的补上来 → 用户漏看一条 │ └─ 热度排序的列表尤其需要游标 因为热度会变,位置比时间更不稳定 两级回复的展示 │ ├─ 主评论流只展示前若干条回复 + 「展开更多回复」 │ 不平铺全部:一条热评几百条回复会把主流冲散 │ ├─ 回复的分页与主评论分页各自独立 │ 展开某条评论的回复,不影响主评论的分页游标 │ ├─ 更深的层级不再嵌套,一律归到二级(回复某人用「@某人」表达) │ 无限嵌套在窄屏上没有可读性 │ └─ 展开状态在列表刷新后保持(用户展开了就别自动收起) 发布 │ ├─ 客户端生成幂等标识(会话内唯一)随请求提交 │ 超时重试用同一标识,服务端去重 │ ├─ 提交中禁用按钮 + 显示进行态(但真正防重靠幂等标识) │ ├─ 成功 → 乐观插入到列表顶部并短暂高亮 │ 不重新拉整个列表:热度排序下用户刚发的会排到很后面找不到 │ 插入的是服务端返回的真实数据(含 ID、时间、头像) │ ├─ 失败 → 保留输入内容 + 明确提示 │ 内容绝不清空(用户可能写了很长) │ └─ 敏感词与字数在客户端前置提示,服务端仍要校验 其他交互细节 ├─ 回复某人时输入框预填「回复 @某人:」并聚焦 ├─ 长评论折叠为「展开全文」,避免单条占满屏幕 ├─ 自己的评论要有删除入口,删除后从列表移除(不刷新整列表) └─ 表情面板与键盘互斥切换,不要两个同时占屏
    分步拆解
    1. 软键盘适配必须逐端真机验证。各平台的行为差异很大:有的自动推页面、有的不推;有的上报键盘高度、有的不上报。我最初只在一台恰好会自动推的机器上测过,完全没发现问题。
    2. 不上报键盘高度的平台用视口变化推算。键盘高度约等于视口高度的减少量。要设一个最小阈值排除干扰(地址栏收起、状态栏变化也会改变视口高度,但幅度小得多)。
    3. 抬升输入区的同时要给列表底部留占位。只抬输入框的话,列表最后几条评论会被输入区盖住,用户看不到最新的评论。
    4. 键盘收起后要保持列表滚动位置。布局变化容易导致滚动位置跳动,用户刚看到的那条评论跑掉了。
    5. 分页必须用游标,不能用页码。页码的隐含假设是「列表不变」,而评论区持续新增。新评论插到前面会导致翻页时看到重复,删除会导致漏看。游标(排序键加 ID)不受插入删除影响。
    6. 热度排序的列表尤其需要游标。因为热度会变,同一条评论的位置随时在动,位置比时间更不稳定。
    7. 发布成功后乐观插入到顶部并高亮,不要重新拉列表。重新拉的问题是热度排序下用户刚发的评论排在很后面,他找不到,会以为发布失败又发一次。插入的数据要用服务端返回的真实数据(有 ID、时间、头像),不要用本地拼的。
    8. 发布要用客户端幂等标识。提交中禁用按钮只是视觉防护(切后台回来状态可能重置),真正的防重靠幂等标识 + 服务端去重。重试用同一个标识。
    9. 失败绝不清空输入内容。用户可能写了很长一段。这和评价发布那边是同一条原则:用户输入过的内容一个字都不能丢。
    10. 回复折叠为楼中楼,不要平铺。一条热评几百条回复平铺出来会把主评论流冲散,用户滑很久都还在同一条评论里。做法是主流只显示前几条加「展开更多」。
    11. 回复的分页与主评论分页各自独立。展开某条评论的回复,不应该影响主评论的分页游标。混在一起会导致展开回复后主列表的翻页出错。
    12. 层级不超过两级。回复的回复也归到二级,用「@某人」表达指向。无限嵌套在窄屏上完全没有可读性(缩进几层之后一行只能放几个字)。
    13. 展开状态在列表刷新后要保持。用户展开了某条评论的回复,下拉刷新之后不该自动收起。
    14. 删除自己的评论后局部移除,不要刷新整列表。刷新会丢失滚动位置和展开状态。
    关键决策与取舍

    发布后乐观插入到顶部,代价是「这条评论的位置和真实排序不一致」。严格按热度排序的话它应该在很后面。但用户刚发完评论,最关心的是「我发出去了吗」——把它放在顶部并高亮,是对这个需求的直接回应。做法上要给它一个视觉标记(比如「我的评论」标签或短暂高亮),让用户知道这是特殊呈现;下次刷新后它回到真实位置,用户此时已经确认过发布成功了,不会困惑。判据是「用户此刻最需要确认什么」——排序的严格性可以暂时让位于反馈的确定性。

    层级限制为两级,接受「回复关系不够精确」。无限嵌套能精确表达谁回复谁,但在手机窄屏上缩进几层后一行只能放几个字,完全没有可读性。用「@某人」表达指向是所有主流产品的做法。这个取舍是可读性优先于结构精确性。

    软键盘适配没有统一方案,只能逐端处理。我尝试过写一个「通用」的适配层,但各端的差异不只是数值不同,行为模式也不同(有的平台会自己推页面,你再推一次就推多了)。最后的做法是抽象出统一接口,内部按端分别实现,而不是试图用一套逻辑覆盖所有端。判据是:当差异在「行为模式」层面而不只是「参数」层面时,抽象应该在接口而不在实现。

    踩过的坑一:软键盘遮挡问题,我的测试机恰好不复现。那台机器会自动把页面推上去,所以我完全没意识到有问题。用户反馈「打字看不到自己打的什么」时我一头懵。教训是「涉及系统 UI 的功能必须多端多机型验证」——键盘、状态栏、安全区、返回手势都属于这一类,它们的行为高度依赖平台和机型。

    踩过的坑二:用页码分页,用户翻页看到重复评论。而且这个问题在测试时不复现——测试环境没有其他人在同时发评论。只有真实流量下(评论区一直有新评论)才会出现。教训是:涉及并发写入的列表,测试时要模拟「拉取过程中有新数据插入」这个场景,而不是在静态数据上测。

    踩过的坑三:发布后重新拉列表,用户找不到自己的评论又发了一次。产生了重复评论。这件事有两个层面的教训:一是要乐观插入让用户看到;二是要有幂等防重——即使用户重复提交,也不该产生两条。两个措施缺一不可:只做幂等的话用户还是困惑,只做插入的话仍可能因为别的原因重复提交。

    没做的部分:没做评论的实时推送(别人发的新评论实时出现)。需要长连接,而且实时插入会让正在阅读的用户列表跳动。折中方案是显示「有 N 条新评论,点击查看」的提示条,让用户自己决定何时刷新——这比自动插入体验好,实现也简单得多。

    数字是怎么测的

    软键盘适配必须逐端真机验证,列出验证矩阵:平台(小程序 / App)× 系统(iOS / Android)× 至少两个机型。每一格断言输入框可见、列表底部不被遮挡、收起后滚动位置保持。报法是「N 个组合逐一验证通过」并列出组合。这一条没有捷径,模拟器和单机型测试都不可靠。

    分页正确性要构造「拉取过程中有新数据」的场景:拉完第一页后,手动插入几条新评论,再拉第二页,断言没有重复也没有遗漏这个场景必须构造——静态数据下页码和游标表现完全一样,测不出差别。

    发布幂等的验证:让提交接口第一次超时(服务端实际成功),断言重试后只产生一条评论。要构造延迟才能测。

    乐观插入的验证:发布后断言新评论出现在列表顶部且有视觉标记;下拉刷新后断言它回到真实排序位置。两条都要断言,后一条证明乐观插入是临时呈现而不是破坏了排序。

    展开状态保持:展开某条评论的回复、下拉刷新,断言仍是展开的。

    不要报什么:不要报「评论加载速度」——那取决于接口和网络。该报的是「N 个端机型组合的键盘适配通过」「有新数据插入时分页无重复无遗漏」「超时重试只产生一条」这三件可验证的事。

    面试追问
    Q:分页用页码有什么问题?大部分列表不都是页码吗? A:页码的隐含假设是「列表在你翻页期间不变」。这个假设对静态数据成立(比如后台的历史订单查询),但对持续新增的列表完全不成立——评论区、消息列表、动态流每秒都在变。具体的坏结果是:拉完第一页后有人发了新评论,新评论插到最前面,原来第一页的最后一条被挤到了第二页,用户翻页时看到重复的那条;反过来如果有评论被删,后面的补上来,用户就漏看一条。用游标(排序键加 ID 的组合)就没有这个问题,因为它锚定的是「上次看到的那条具体是哪条」而不是「第几个位置」。而且热度排序的列表尤其需要游标,因为热度会变,同一条评论的位置随时在动,位置比时间更不稳定。我总结的判据是:这个列表会不会在用户翻页期间新增或删除内容,会就必须用游标。这条对消息、动态、日志、审计流水都适用。另外这个 bug 在测试环境不复现——因为没有其他人同时在发评论,所以测试时必须主动构造「拉取过程中插入新数据」的场景。
    Q:软键盘遮挡这种问题,有没有通用的解决方案? A:我尝试过写一个「通用适配层」,但失败了,因为各端的差异不只是数值不同,行为模式也不同:有的平台会自己把页面推上去(你再推一次就推多了)、有的完全不动、有的上报键盘高度、有的不上报只能用视口变化推算。试图用一套逻辑覆盖所有端的结果是每加一个端就要加一堆条件判断,最后没人能维护。最终的做法是抽象出统一的接口(比如「获取当前键盘高度」「订阅键盘变化」),内部按端分别实现判据是:当差异在「行为模式」层面而不只是「参数」层面时,抽象应该做在接口而不是实现——接口统一让业务代码干净,实现分端让每端的逻辑各自清晰。另外这类问题还有个共性:必须多端多机型真机验证,模拟器和单机型都不可靠。我踩的坑就是测试机恰好会自动推页面,所以完全没发现问题。键盘、状态栏、安全区、返回手势都属于这一类「高度依赖平台和机型」的功能,我现在做这些都会先列一个验证矩阵。

没有匹配的内容,换个关键词试试。

项目拆解 · 播放与互动基础(uni-app · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据