第一版的实现是最直观的写法:把接口返回的数据全部渲染成列表,用 swiper 上下滑,非当前项用 v-show 隐藏。功能上没问题,但在测试机(一台千元机)上滑到三四十条就开始卡,滑到七八十条应用直接被系统杀掉。
定位之后发现三个问题叠加在一起。一是 v-show 只是隐藏,节点和视频实例都还在——滑过 50 条就有 50 个 video 组件活着,而原生视频组件本身有数量上限,超了之后新的视频压根播不出来。二是每次滚动都在算当前是第几项,滚动事件在小程序里是跨线程回调,高频触发时通信开销把主线程占满。三是预加载太激进,一次预载了后面三条的完整视频,带宽和解码资源被抢光。
所以三个方向分别改:渲染层做窗口复用,可见性判断挪到渲染层,预加载改成分级。
要点是数据和视图都要裁剪。只做视图层的虚拟化不够——用户刷了两百条,数据数组里就有两百条对象,每条带封面 URL、作者信息、统计数据,这部分内存同样可观。
IntersectionObserver。它在渲染层判断元素是否进入视口,不需要把滚动位置回传到逻辑层再计算。小程序的双线程架构下,这个改动省掉了高频的跨线程通信,是帧率提升的主要来源。为什么不用现成的虚拟列表组件。试过社区的虚拟列表方案,但短视频场景有个特殊性——一屏只有一项、且必须整屏对齐,通用虚拟列表要处理不定高和任意滚动位置,复杂度和开销都是浪费。用 swiper 加固定 3 项的窗口反而更简单可控,代码量少一半。通用方案不一定适合特化场景,这个判断要主动说出来。
裁剪数据的代价。裁掉之后用户往回滑超过 20 条就要重新请求,会有短暂加载。权衡的结果是接受这个代价——因为回滑很远的行为占比极低,而内存问题是必须解决的。如果面试官追问,我会说更好的做法是把裁掉的数据缓存到本地存储而不是直接丢弃,这是当时没做但应该做的。
踩过的坑:小程序端和 App 端表现不一致。同一套代码在 App 端(WebView 渲染)流畅,小程序端卡顿明显,因为小程序的原生视频组件层级和 setData 机制都不同。最后是用条件编译在两端走了略微不同的实例数和预加载策略。这也印证了跨端「大部分代码复用,剩下的差异必须正面处理」。
内存:Android 上用 adb shell dumpsys meminfo <包名> 取 TOTAL PSS,固定操作路径——连续滑动 100 条(用脚本或手动匀速滑),每滑 20 条记一次。改造前是持续上涨到 610MB 左右然后被系统回收;改造后稳定在 350MB 上下不再增长。关键是「稳定不增长」而不是绝对值,因为绝对值和机型、系统版本都有关。
帧率:用小程序开发者工具的性能面板,或者 App 端用 adb shell dumpsys gfxinfo 看掉帧。取滑动过程的平均帧率。为什么是 54 不是 60:真实设备上视频解码加渲染很难跑满帧,说 60 反而不可信。
测试机型要说清。「低端机」要能报出具体型号或芯片档次,因为同样的代码在旗舰机上根本测不出问题——这也是为什么性能优化必须用低端机验证。
v-show 只是把 CSS 的 display 设成 none,节点还在树上、组件实例还活着、播放器上下文也没释放。真正要回收得让组件从树上移除(v-if 或者从数据源里删掉),并且显式调用播放器的销毁方法。另外视频的解码缓冲区占的是原生内存,不在 JS 堆里,所以看 JS 内存是发现不了的,必须看进程的总内存。
IntersectionObserver 的判断在渲染层完成,只在「进入/离开视口」这个状态变化时才回调一次逻辑层,通信次数从每帧几十次降到状态变化时的一两次。
最早是最简单的实现:选完视频整个文件一次性传给自己的服务器,服务器再转存到对象存储。三个问题。
一是成功率极低。手机拍的视频动辄几十兆,用户在地铁上、在国外的弱网环境下,一次传完的概率很低,而且失败了要整个重来。二是服务器成了瓶颈,文件流量全走业务服务器,带宽和内存都吃不消。三是没有进度和恢复能力,用户传到一半切个后台回来就得重传。
所以改造的三个方向:不让文件经过自己的服务器(直传对象存储)、把大文件拆小(分片加断点续传)、从源头减少体积(本地压缩)。其中本地压缩是投入产出比最高的一步——它直接把问题规模降了一个数量级,比任何传输层的优化都有效。
压缩参数怎么定。压太狠画质肉眼可见地糊,用户会不满;压太轻起不到作用。做法是先用几个典型视频(室内、室外、快速运动)试不同参数,在可接受画质的下限处取值。这里的取舍是明确的:宁可画质略降也要保证传得上去——传不上去的视频画质再好也没有意义。
为什么不在服务端压缩。服务端压缩画质控制更好,但那意味着原始大文件还是要传上来,压根没解决核心问题。所以本地压缩是必须的,服务端转码是在此基础上再出多档清晰度。
小程序端的硬限制要认。小程序对单个文件大小有上限,超过的压根传不了。这种情况只能引导用户用 App 或者提示降低视频时长,技术上绕不过去。承认平台限制、给出产品层面的降级路径,比硬找技术方案更专业。
上传体积:取一批真实用户视频(或自己拍的典型样本,覆盖不同时长和场景),统计压缩前后的平均文件大小。样本量要说得出来(比如「30 个样本」),不然「平均」没有意义。
弱网成功率:用抓包工具或系统的网络限速功能把带宽限到 256KB/s,同一批视频各传 20 次,统计成功次数。为什么说「约五成」和「九成左右」而不是精确百分比:样本量只有几十次,算出 52.5% 这种精度是假的,用模糊量级更诚实。
被追问时要能说清失败的原因分布——改造前的失败主要是整体超时和中途断网,改造后残留的失败主要是压根传不动的极端弱网。能报出原因分布,说明你真的分析过而不是只看了个成功率。
第一版是「纯推模式加每次进会话全量拉取」。问题很快出来:
一是推送丢了消息就没了。WebSocket 在移动端极不稳定——切后台被断开、网络切换、弱网丢包,任何一次断连期间的消息,如果只依赖推送就永久丢失,用户要杀掉应用重进才能看到。二是多端不一致,手机上读过的消息在小程序端还是未读。三是进会话慢,每次都从服务端拉最近 50 条,弱网下要等一两秒白屏。
根本原因是把推送当成了数据来源。改造的核心思路是:推送只是信号,数据靠拉;本地是主要数据源,服务端是增量补齐。这个转变之后上面三个问题同时被解决。
为什么不用「推模式 + 本地直接插入」。那样首屏更快(推送来了直接显示,不用请求),但可靠性完全依赖推送不丢,而移动端的长连接不可能不丢。用「拉」做数据一致性、用「推」做实时性提醒,是唯一能同时保证快和准的组合。多付出的代价是每次推送要多一次请求,但增量请求很轻。
本地存储的容量取舍。小程序 storage 有容量上限,不可能存全部历史。做法是只存最近 N 条,更早的按需从服务端拉。N 的取值要平衡首屏效果和容量占用——存太少首屏还是要等网络,存太多挤占其他缓存。
没做的部分要诚实说。没做端到端加密(业务上没这个要求);没做消息撤回和已读回执(产品优先级不高,而且已读状态在部分海外市场用户接受度不好)。面试时主动说清边界,比让面试官问出来好。
首屏渲染耗时:在进入会话的入口和列表首次渲染完成处各打一个时间戳,取差值。要说清测试条件——「本地已有历史时」这个前提必须讲,因为首次进入某个会话本地没数据,还是要等网络,那种情况没有优化空间。这类限定条件不主动说,被问出来就显得心虚。
消息重复:写脚本模拟弱网下的重复发送和推送重放,检查列表里有没有相同客户端 ID 的两条消息。旧实现(没有客户端 ID 去重)每轮都能复现,改造后没再出现。这和上一个项目里验证并发 bug 的思路一样——可靠性问题用可复现的测试来量化,比用线上统计更实在。
INCR(key 是会话 ID)。不能用全局递增的 ID(比如雪花算法)当序列号,因为那样会话内的号是跳跃的,客户端没法判断「中间是不是缺了」。这个区分是设计的关键——序列号的作用是连续性检测,不是唯一性。
scrollHeight 和 scrollTop,插入后新的 scrollHeight 变大了,把 scrollTop 加上高度差,视觉上就没动。小程序里因为拿节点信息是异步的,这个操作要在 nextTick 之后做,而且可能有一帧的跳动。更稳的做法是用倒序布局(列表本身是反向的),插入到「视觉上方」实际是数组末尾,压根不需要校正——但那样其他逻辑要跟着反转,改造成本高。
没有匹配的内容,换个关键词试试。
项目拆解 · 短视频社区 App(移动端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据