先说清直播和点播在端上的根本差别,因为它决定了所有策略:点播的文件是固定的,可以提前下载、可以缓存、缓冲多大都不影响体验;直播的流是实时产生的,缓冲了多少就等于延迟了多少。
于是直播端上有三个目标,而它们互相冲突:
首帧要快——用户点进直播间希望立刻看到画面,而缓冲越小首帧越快。延迟要低——直播的互动性依赖低延迟(主播说「点赞过万我就唱」,弹幕延迟十秒就没意义了),缓冲越小延迟越低。播放要不卡——而这个恰恰要求缓冲越大越好,用缓冲吸收网络抖动。
三个目标里前两个和第三个直接对立,没有一个固定的缓冲值能同时满足。第一版用了一个固定缓冲策略,结果弱网用户频繁卡顿(缓冲不够吸收抖动),而强网用户延迟偏大(缓冲白留了)——两头都不舒服。
所以第一个设计是分阶段策略:起播阶段用小缓冲,优先把首帧出来(用户对「点进去多久看到画面」最敏感);进入稳态后按实测的网络抖动动态调整缓冲水位——网络稳的用户缓冲小、延迟低;网络抖的用户缓冲大、不卡。把「一个固定值」换成「按实际情况调」。
第二个设计是追帧。缓冲会累积——网络恢复后,之前积压的数据会让播放位置越来越落后于直播现场,延迟从两秒变成十几秒。必须追回来。
追帧的方式很关键。第一版是直接丢帧跳到最新位置,快但画面和声音会有明显的突跳,用户的感受是「卡了一下」——而实际上是我们主动造成的。改成轻微倍速播放(比如 1.1 倍),在几秒内平滑地把延迟追回来,用户基本感觉不到。只有延迟大到倍速追不回来时才丢帧。
第三个是码率自适应:连续卡顿说明当前码率超出了用户的带宽,要降档;网络稳定一段时间后再回升。关键是回升要保守——刚稳定就升档,很可能立刻又卡,来回切换比一直低码率更难受。
第四个容易被忽略但很影响体验:要区分「主播断流」和「观众网络断开」。这两种在技术上都表现为「拉不到数据」,但对用户来说完全不同:主播断流应该显示「主播网络不稳定,正在恢复」并保持在直播间等待;观众自己断网应该显示「你的网络已断开」并在恢复后重连。第一版统一显示「网络异常」,主播断流时一批用户以为自己网络有问题而退出了直播间。
最后是起播失败的分层兜底:拉流地址不可用就切备用地址、码率太高起不来就降码率重试、主播确实没开播就明确提示而不是一直转圈。
追帧用倍速而不用丢帧,这是这个模块最值得讲的取舍。丢帧的实现最简单(直接 seek 到最新位置),而且追赶速度最快。但它的代价是用户可感知的突跳——画面跳一下、声音断一下,而用户会把这个归因为「卡了」,也就是说我们为了解决延迟主动制造了一次卡顿的观感。倍速追帧慢一些(要几秒),但整个过程用户基本感觉不到。取舍依据是:用户感知不到的慢,优于用户感知到的快。这条判断在很多平滑处理上都成立(地图点位插值也是同一个思路:宁可显示推算位置,也不要让点位跳变)。但要保留丢帧作为兜底——延迟大到几十秒时倍速追不回来,此时突跳一次是必要的。
码率回升比降档保守,这是不对称的。降档要果断(连续卡顿就降,因为继续硬撑只会一直卡);回升要保守(要求网络稳定持续一段时间)。原因是「频繁切换」本身就是一种糟糕的体验——每次切换都可能伴随短暂的画面变化,来回切换比稳定在低码率更让人难受。这个不对称的依据是「切换本身有成本」,所以要减少切换次数而不只是追求「每一刻都用最高可用码率」。
区分断流原因,这不是技术优化而是归因的准确性。技术上两种情况都是「拉不到数据」,要区分需要额外的信号(服务端下发主播状态、或者根据自身网络探测判断)。做这个区分的价值是把「用户误判」消掉:主播断流时说「你的网络异常」,用户会去检查自己的网络甚至退出直播间;而如果告诉他「主播网络不稳定,正在恢复」,他会等。代价是要多拿一个信号并处理两种状态的文案与行为,收益是留住了这批本来会流失的观众。
踩过的坑:固定缓冲策略导致两头不满意。弱网用户频繁卡顿(缓冲不够吸收抖动),强网用户延迟偏大(缓冲白留了,互动体验受损)。修法是分阶段加动态水位。教训是:当多个目标互相冲突时,用一个固定参数一定会同时得罪两端——正确的方向是让参数随实际情况变化,而这要求先有上报数据能知道「实际情况」是什么。
踩过的坑二:丢帧追赶被用户当成卡顿投诉。我们为了压延迟做了丢帧追赶,结果卡顿类的反馈反而变多了——用户看到画面和声音突跳,理解成「卡了」。我们做的优化被算成了 bug。修法是改用倍速追帧。教训是:优化的效果要按「用户的感知」来评估而不是按「指标的数值」——延迟指标确实降了,但用户的体验评分变差了,这种情况下指标是在骗自己。
踩过的坑三:主播断流时显示「网络异常」,观众误以为是自己的问题而退出。主播那边网络抖动断流几十秒,我们的观众端统一显示「网络异常,请检查网络」,一批观众去重启路由器、切换网络,最后干脆退出了直播间;等主播恢复时人已经走了一批。修法是区分断流原因并给出对应文案。教训是:错误提示的准确性直接影响用户的行为选择——一个归因错误的提示会引导用户做无用甚至有害的动作。
踩过的坑四:码率回升太激进,形成来回切换。网络刚稳定就升档,升上去立刻又卡、又降下来,几分钟内切换十几次,用户的反馈是「画质一直在变,很难受」。修法是要求稳定持续一段时间才升,并且限制单位时间内的切换次数。教训是:自适应策略必须考虑「切换本身的成本」,只优化「每一刻的最优」会导致震荡——这和自动降级要有恢复条件、但恢复条件要比降级条件严格,是同一个道理。
没做的部分:没做低延迟直播协议(把延迟压到一秒内)。它需要服务端与端上都换协议栈,而且在 uni-app 的小程序端支持有限,当时判断投入产出不合适。也没做多主播连麦的多路播放,属于产品没做的功能。
首帧耗时:报从点击进入到首帧渲染的耗时,按网络类型与机型分组报 P50 与 P90。必须分组——整体均值会把弱网用户的问题平掉,而弱网用户恰恰是要优化的对象。
卡顿:报单位时长内的卡顿次数与累计卡顿时长,同样分网络类型看。两个指标都要报:次数多但每次很短,和次数少但每次很长,体验完全不同。
延迟:报播放位置与直播现场的时间差分布。并且要报追帧的效果——延迟超阈值后多久追回到正常区间。
追帧方式的体验对比:这一项要报「卡顿类反馈的数量」而不是延迟数值——因为丢帧追赶时延迟指标是好的,但卡顿反馈反而变多了。这个对比本身就说明「指标好不等于体验好」,是这一页最值得讲的数据故事。
码率切换次数:报单次观看内的码率切换次数分布。这个数字要控制住——切换太频繁本身就是糟糕体验,回升保守之后它应该明显下降。
断流归因的效果:报主播断流期间观众的退出率,改造前后对比。这是「区分断流原因」这个改动的直接业务收益,而且能明确归因。
起播失败率与失败原因分布:按原因分开报(地址不可用、码率过高、主播未开播),以及各层兜底的成功率。
不要报「首帧 200ms 以内」这类单一数字。首帧高度依赖网络与机型,脱离条件的单一数值没有意义。也不要报「零卡顿」——弱网下卡顿是物理存在的。正确表述是「按网络与机型分组的首帧与卡顿分布、延迟追赶从丢帧改为倍速后卡顿类反馈的变化、码率切换次数的下降、主播断流期间退出率的变化」。
热门直播间的渲染压力和普通页面完全不是一个量级:弹幕每秒几十条持续不断,礼物动效可能连击几十次,而这些是叠加在一个正在播放视频的页面上的。视频解码本身就占着资源,动画只能用剩下的。
第一版的实现很直接:每条弹幕创建一个节点、每份礼物播放一次动效。在测试的中高端机上没问题,上线后低端机的反馈很集中:卡、内存涨、点不动。
第一个问题是弹幕逐条处理。每秒几十条,就是每秒几十次节点创建、样式计算、动画启动。而且这些操作分散在一秒内的各个时刻,每一次都可能打断当前帧。
解法有三层。按帧合并:把同一帧内到达的多条弹幕攒起来一次性处理,让处理次数与帧率同阶而不是与消息条数同阶(这和地图点位的帧内合并重绘是同一个手段)。节点池复用:维护一个定长的节点池,弹幕飘出屏幕后节点归还池中重用,而不是销毁再新建。上屏上限与丢弃:屏幕上同时显示的弹幕有数量上限,超出的直接丢弃。
「丢弃」这个决定很重要,它基于一个业务判断:弹幕是可丢的。用户看直播时不会逐条读弹幕,他感受的是「氛围热闹」;丢掉一部分不影响这个感受,而卡顿会直接毁掉它。所以保证流畅优先于保证全展示。(对比:私信消息不能丢,因为用户会逐条读。同样是消息,可丢性完全不同,处理策略也就不同。)
第二个问题是礼物动效。第一版每份礼物独立播放,用户连击三十次就有三十个动效同时在播,画面糊成一片而且帧率崩掉。
解法是串行队列加连击合并:动效进队列依次播放而不是同时播;同一用户的连续送礼合并成一次带计数的动效(显示「x30」而不是播三十遍)。合并不只是性能优化,它的展示效果反而更好——一个「x30」比三十个重叠的动效更能表达「这人送了很多」。
第三个问题是低端机压根撑不住任何复杂动效。所以要按机型能力分档:高端机播完整动效、中端机播简化版、低端机只显示一条文字提示。分档的依据要用实测的设备能力而不是价格或年份。
第四个问题最容易被忽略但用户最恼火:动效播放期间点不动东西。全屏礼物动效盖住了整个屏幕,用户想点评论、想送礼,全被动效层拦住了——而这正是他最想互动的时刻。解法是动效层与互动层严格分离,动效层不拦截点击事件。
最后是页面不可见时暂停全部动画。用户切到后台或者最小化,动画还在跑就是纯耗电。
弹幕可以丢,这是这个模块最重要的业务判断。技术上可以做到「一条不丢」(排队慢慢上屏),但那样弹幕会严重滞后于直播现场,用户看到的是十几秒前的评论,互动感就没了。丢弃则是牺牲完整性换取实时性与流畅度。依据是「用户如何消费这些信息」:弹幕是氛围性的、被扫视的、不需要完整;而私信是逐条阅读的、必须完整。同样是「高频消息 + 长列表」,可丢性不同,策略就完全不同——我在私信那边做的是「seq 空洞检测与补齐」,这里做的是「超限丢弃」,方向完全相反而依据是同一条:看用户怎么消费它。
连击合并是少见的「性能与体验同向」的优化。大多数性能优化都要牺牲一点体验(降码率画质变差、降级动效变简单)。而连击合并反而让表达更清楚——三十个重叠动效是视觉噪音,一个「x30」是清晰的信息。这提示我一个判断方法:遇到性能问题时先问「现在的展示方式本身是不是有问题」,有时性能问题的根源是展示设计不合理,改对了展示,性能问题自然消失,而不需要做取舍。
按机型分档而不是统一降级或统一不降。统一用低配动效对高端机用户是浪费(他们的设备能承受更好的效果);统一用高配则低端机崩。分档的代价是要维护多套动效资源与一套设备能力判定,而判定本身不可能完全准确。缓解手段是运行时监控实际帧率,如果分档判错了(判成高端但实际卡),运行时自动往下降——静态分档加运行时动态调整,比只靠其中一种可靠。
踩过的坑:动效层拦截点击,用户在最想互动的时刻点不动东西。全屏礼物动效播放期间盖住了整个屏幕,用户想点评论、想跟着送礼,全被动效层拦住了。这个坑的代价被严重低估——礼物动效播放的时刻恰恰是直播间气氛最热、用户互动意愿最强的时刻,而我们在那一刻把交互堵死了,直接损失的是送礼收入。修法是动效层与互动层严格分离,动效层不拦截点击。教训是:全屏视觉元素必须明确它对交互的影响,「好看」不能以「不能用」为代价——尤其当它出现的时机正好是用户最想操作的时候。
踩过的坑二:逐条创建弹幕节点,低端机上内存持续增长。每秒几十条弹幕各创建一个节点,飘出屏幕后销毁,看起来该释放了;但频繁的创建销毁导致内存抖动严重,而且部分节点因为动画未结束就被移除而残留引用,观看十几分钟后明显卡顿。修法是定长节点池复用。教训是:高频创建销毁的对象要用池化,而不是指望垃圾回收跟上——这和播放器实例池化、和「多媒体资源必须显式回收」是同一类判断。
踩过的坑三:连击礼物同时播放,画面糊成一片而且帧率崩掉。用户连击三十次,三十个动效同时在播,什么都看不清,低端机直接掉到个位数帧率。修法是串行队列加连击合并。教训是:同类事件的高频发生要先考虑「合并」而不是「并发处理」——合并往往同时解决性能和表达两个问题。
踩过的坑四:按机型价格分档,判断结果和实际能力不符。我们最初用机型价格区间粗略分档,结果一批低价但芯片不错的机型被判成低端、只能看文字提示,用户抱怨「为什么我看不到动效」;反过来也有高价老机型被判成高端然后卡。修法是用实测的设备能力分档,并且加运行时帧率监控做动态调整。教训是:设备分档不能用价格或年份这类间接指标,要用能力实测;而且静态分档一定会有判错的,必须配运行时的动态修正。
没做的部分:没做弹幕的智能优先级(把关注的人、大额送礼的弹幕优先保留)。丢弃策略目前是简单的超限丢新,按优先级丢会更好但需要弹幕携带更多信息。也没做动效资源的动态下发(新礼物动效不用发版),需要资源包管理与安全校验,工作量不小。
帧率:必须按机型分组报,并且要报低端机的数据。报在指定弹幕量与动效强度下的帧率,改造前后对比。整体均值毫无意义——问题只出在低端机上,均值会把它平掉。
内存:报连续观看一段时间后的内存曲线,改造前是持续增长(逐条创建销毁导致抖动与残留),改造后平稳。用曲线形态而不是峰值数字。
弹幕丢弃率:要主动报,而且要说明「弹幕可丢」这个判断的依据。报在不同弹幕量下的丢弃比例——它是一个被有意设计的行为,不是缺陷,主动交代比等面试官问出来好。
动效降级分布:报各档位的设备占比,以及运行时动态下降的触发次数。后者说明静态分档判错的比例,诚实报出来比声称分档很准可信。
动效期间的点击响应:确定性验证——动效播放期间点击评论、送礼、点赞,检查全部正常响应且控件可见。这是踩坑后必须固化的用例,因为它在功能测试里很容易被忽略(没人会在动效播放时去点)。
互动收入的影响:如果能拿到数据,报动效期间的送礼行为变化。这是「动效不拦截交互」这个改动最直接的业务收益,而且能明确归因。
不要报「支持无限弹幕」或「零掉帧」。低端机的渲染能力是硬约束,而且我们本来就设计了丢弃。正确表述是「按机型分组的帧率变化、内存曲线由增长变平稳、弹幕丢弃率与其业务依据、动效降级的档位分布与运行时修正次数、动效期间交互可用由用例保证」。
直播间的交互形态是上下滑切换——和刷短视频一样。这就意味着用户可能在一分钟内滑过十几个直播间,而每个直播间都不是一个轻量页面:它有一个播放器在拉流、一个长连接订阅在收弹幕、一个动效队列在跑、还有若干定时器(倒计时、心跳、数据刷新)。
第一版的问题是进房时创建得很完整,退房时释放得不完整。表现有三类。
第一是内存持续增长。连续滑二三十个直播间后内存明显上涨,最后应用被系统回收或者标签页崩溃。查下来是好几处没释放:播放器实例、长连接的订阅回调、动效队列里未播完的任务、以及定时器。单独看每一处泄漏都不大,累积几十个房间就致命了。
第二是旧房间的弹幕串进了新房间。用户滑到新直播间,看到的弹幕里混着上一个直播间的内容。原因是退房时没有退订旧房间的消息订阅,而新房间又订阅了自己的,于是两个房间的消息都进来了。这个 bug 用户感知非常明显,会被理解成「串台」这种严重问题。
第三是快速滑动时对每个中间房间都发起了建连。用户快速连滑五个房间,我们对五个房间都创建了播放器、都建了连接——而他只在最后一个停留。中间四个的建连是纯浪费,而且它们的创建与销毁本身就在制造卡顿,让滑动更不流畅。
所以四个设计。
第一是播放器单例复用:全局只有一个播放器实例,切房间时只换流地址而不销毁重建。这和直播中控台的播放器实例池化是同一个思路,只是这里更简单(同时只需要一个)。
第二也是最重要的:把退房释放做成清单化流程。不要靠「在组件卸载钩子里写几行清理」——那种写法一定会漏,因为新增一个资源时没人记得回去补。清单化的意思是:有一个明确的资源清单(播放器、订阅、动效队列、定时器、节点池),退房时逐项执行,新增资源必须加进清单。
第三是滑动防抖加提前建连。快速滑动时只对「停下来的那个房间」建连;同时对即将进入的下一个房间提前建连(用户滑动的方向是可预测的),这样停下来时首帧更快。防抖和预建连看起来矛盾,实际是配合的:防抖避免对中间房间浪费,预建连只针对最可能进入的那一个。
第四是长连接按房间切换订阅而不是断开重建。长连接本身是应用级的(和 IM 那边一样),切房间只需要退订旧房间、订阅新房间,不需要断开连接再重连——重连的代价远大于换订阅。
最后加了一道自检:连续切换若干个房间后检查内存,超阈值就上报。因为泄漏这类问题在开发时切三五个房间测不出来,只在真实使用的连续切换下才暴露,必须有线上数据兜住。
把释放做成「清单化流程」而不是「在卸载钩子里写清理代码」,这是这个模块唯一真正重要的决策。写在钩子里更自然、更符合框架惯例;但它的问题是「没有一个地方能看出应该释放哪些东西」——新增一个定时器、新增一个订阅,开发者要自己记得回去补清理,而这是记不住的(我们就漏了四处)。清单化把「应该释放什么」变成一份显式的、可 review 的列表。代价是多一层间接(资源要注册到清单里),而且约定需要团队遵守。缓解手段是把注册和创建绑在一起(创建资源的封装函数自动注册到当前房间的清单里),这样和 IM 那边「注册即清理」的思路一致:让机制保证而不是让规范保证。
防抖与预建连同时做,看起来矛盾但依据不同。防抖针对的是「用户正在快速滑过」的房间——他不会停留,建连是纯浪费。预建连针对的是「用户最可能停下的下一个房间」——提前建连能缩短首帧。两者的判断依据是「用户会不会在这个房间停留」,而滑动速度和方向正好能提供这个信号。取舍是预建连会有一定的浪费(预测错了就白建),缓解手段是只预建一个、且在弱网或低端机上关闭预建连——那些场景下浪费的代价更高。
长连接按房间换订阅而不是断开重建,这是从 IM 那边直接搬过来的判断。长连接是应用级资源而不是页面级资源,切页面不该断连接。重连的代价包括:握手、鉴权、以及重连期间的消息丢失窗口,而换订阅只是发一条信令。这一条在两个完全不同的业务里(IM 和直播)结论一致,说明它是个通用判断。
踩过的坑:连续滑二三十个直播间后应用被系统回收。四处泄漏叠加:播放器实例、长连接订阅回调、动效队列未播完的任务、定时器。单独看每一处都不大,累积几十个房间就致命了;而且开发时切三五个房间压根测不出来。修法是清单化释放加内存自检上报。教训有两条:泄漏问题的严重性取决于「这个流程会被重复多少次」——同样的漏释放在一个只进一次的页面里毫无影响;还有测试必须按真实使用模式做,「切三五个」和「连续切三十个」不是量的差别。
踩过的坑二:旧房间的弹幕串进新房间。退房时没有退订旧房间的消息订阅,新房间订阅了自己的,于是两个房间的消息都进来了。这个 bug 的用户感知极其明显——会被理解成「串台」这种严重的内容安全问题,客服接到的反馈是「我在这个直播间看到了别的主播的弹幕」。修法是退订纳入释放清单。教训是:订阅类资源的泄漏不只是内存问题,它会造成可见的数据错乱——比纯内存泄漏更需要优先处理。
踩过的坑三:快速滑动时对每个中间房间都建连,滑动本身变卡。用户连滑五个房间,我们对五个都创建播放器都建连,而他只在最后一个停留;更糟的是这些创建与销毁本身在制造卡顿,让滑动更不流畅——用户想快速找到感兴趣的直播间,而我们让滑动变卡了。修法是防抖,只对停下来的房间建连。教训是:预加载类的优化要看「用户是否真的会用到」,对快速滑过的内容做预加载是负优化,它消耗的资源正好损害了当前正在进行的交互。
踩过的坑四:退房时把未完成的送礼请求一起取消了。用户送礼后立刻上滑切房间,我们在退房清理时取消了未完成的请求,而服务端其实已经扣款成功,用户的礼物没送出去但钱扣了。修法是区分「要清理的资源」和「要完成的在途请求」,后者不随退房取消,并且结果要能正确归属到原房间。教训是:清理资源时必须区分「纯本地资源」和「有服务端副作用的在途操作」——前者可以随手清掉,后者必须让它走完。
没做的部分:没做多房间的预加载(同时预拉两三个房间的流以做到「秒开」)。技术上可行但同时多路拉流的带宽与解码成本很高,在移动端得不偿失,只做了单个预建连。也没做进房前的封面预览过渡(用封面图垫首帧),需要设计配合,排期没排上。
连续切换的内存:最该报的数字。报连续切换指定数量的直播间后的内存曲线,改造前是持续增长最终被回收,改造后平稳。必须说明切换了多少个、什么机型——「切三五个」和「连续切三十个」结论完全不同。
弹幕串房:确定性验证——切换房间后检查弹幕列表中不含上一个房间的消息。这一项必须是常驻用例,因为它的用户感知极其明显(会被理解成串台)。
释放清单的完整性:报清单包含哪几类资源,并说明「新增资源必须加进清单」的流程约定与它是怎么被保证的(比如创建封装自动注册)。这个清单本身就是证明。
滑动流畅度:报快速连续滑动时的帧率,改造前后对比。关键是要说明改造前的卡顿来自「对中间房间的建连与销毁」——这个归因让数字有意义。
预建连的收益与浪费:两个都要报——预建连命中时的首帧提升,以及预建连未命中(预测错)的比例。主动报浪费比只报收益可信,而且说明了为什么只预建一个。
在途请求的正确性:确定性验证——送礼后立刻切房间,检查礼物正常送达且结果归属到原房间。这是踩坑后固化的用例。
内存自检的触发:报线上内存自检超阈值的上报次数。这个数字要有——如果一直是零,要么真的没问题,要么自检没生效,得说清是哪种。
不要报「零内存泄漏」。泄漏是持续对抗的,新增功能就可能引入新的泄漏——这也正是我们做内存自检上报的原因。正确表述是「连续切换 N 个房间的内存曲线变化、释放清单覆盖哪几类资源与流程约定、弹幕串房与在途请求由常驻用例保证、内存自检的线上上报」。
没有匹配的内容,换个关键词试试。
项目拆解 · 直播观众端(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据