6853 字
17 分钟
简体中文

弹幕该跟哪个时钟走:BiliKit 弹幕同步重写记录

上一篇写 BiliKit 的弹幕时,弹幕跟随播放器的时间快照推进,向前 seek 时清屏,再从新的位置继续,跨过去的弹幕不会补出来。

这套时间模型后来改掉了。起因是 seek 之后,屏上应该有的弹幕没有出现。比如跳到 1:00,从 0:55 开始飞、此刻本该在屏幕中间的那几条弹幕,一条都没有。

这个问题从 10 月 12 日一直改到 10 月 23 日,前后十几个 PR,底层模型换了几轮。大部分时间用在确认弹幕应该以哪个时间为准,写代码本身占的时间不多。

现在的做法是,每条弹幕此刻在哪、屏上此刻应该有哪些弹幕,都用同一个时间来算。这个时间直接取自播放器自己的时钟 AVPlayerItem.timebase,只多加了一条限制:正向播放时,不让它往回走。弹幕这一侧也不再用阈值去猜用户是不是在 seek。

文中的数值都来自我自己机器上的探针(macOS 27,Apple Silicon)。有线或内置输出和蓝牙 AirPods 的结果差别很大,涉及时会分开写。这些改动还没有进入正式版,目前最新的版本是 10 月 12 日发布的 1.3.2。

弹幕栈和几个概念#

BiliKit 是一个原生 macOS 的 B 站客户端,用 Swift 6、SwiftUI + AppKit 和 AVPlayer 写成。和上一篇相比,渲染方式也换了,每条弹幕现在是一个 CALayer,内容是用 Core Text 预先栅格化的纹理,不再使用 CATextLayer。滚动弹幕的运动是一个 CABasicAnimation,beginTime 等于这条弹幕的出现时刻。弹幕按 6 分钟一段从 B 站接口分段拉取。

管线分成三层。布局层给每条弹幕分配车道,也就是它在屏幕上走哪一行;呈现层决定此刻哪些弹幕挂在屏上,以及纹理什么时候准备;渲染层管理 CA layer 和纹理。

后文会反复用到下面几个概念。

  • timebase:AVPlayerItem.timebase 是播放器内部的媒体时钟。它可以看成一条直线,在某个宿主时刻(系统时钟上的一点),媒体时间是某个值,之后按播放速率往前走。暂停时速率为 0,直线变平。seek,或者系统内部重设时钟时,这条直线会被整体挪开,媒体时间就出现跳变。
  • 快照:主线程上有一个每 1/30 秒触发一次的周期观察器,每次读一下当前的播放位置,这就是快照。最初的弹幕调度就是按快照推进的。
  • 运动和在屏集合:弹幕要回答两个问题。一是每条弹幕此刻在屏幕上的位置,这由动画和驱动动画的时钟决定。二是此刻应该有哪些弹幕挂在屏上,后文把这组弹幕叫作在屏集合。

最初的实现是运行时调度,主线程按快照推进游标,弹幕到了出现时刻就挂上一条。

seek 后补位越补越多#

第一个改动是在 seek 落地后,按落点重放一小段时间窗,把此刻还在飞的弹幕挂上。

合并以后陆续出现了别的问题:闪现、落点附近微动、暂停时的残影,以及暂停中 seek 再开播时闪一下。我在 danmaku-seek-catchup 分支上逐个修,每修一个,调度器里就多一条启发式规则,包括 0.5 秒的回跳容差、2.5 秒的超限、落点是否稳定的判断,以及拖动停稳 150ms 才算落地。

这些规则都在根据位置的变化推测用户是不是 seek 了。比如回跳容差的意思是,位置往回跳了不到 0.5 秒,就不当作 seek。每个阈值背后都对应一个对用户意图的猜测。规则越加越多,这条分支最后没有合并。后来我判断一条规则是否属于补丁,主要看它是不是在修另一条规则的副作用。

之后我发现系统其实提供了明确的时钟事件。timebase 会发出 TimeJumped(时间跳变)和 EffectiveRateChanged(实际速率变化)两种通知。于是播放层订阅这两个通知,对外发布「正在 seek」和「已在某个位置落地」两种状态。弹幕调度器只处理落地这一件事,清屏后按落点补位一次。之前的阈值全部删掉了。

这个方案合并了,但暂停中 seek 后开播仍然会闪一下,原因当时没有定位清楚。另一个问题是,TimeJumped 是底层的时钟事件,系统内部重设时钟时也会发出,并不代表用户想要 seek,只靠速率也不能可靠地区分。当时我定下的原则是,下一步能解决的问题,就不在这个方案里继续打补丁。

让弹幕只依赖媒体时间#

下一轮换了思路。如果某一时刻屏上有哪些弹幕、各自在什么位置,完全由媒体时间 t 算出来,那么不管用户怎么 seek、拖动、变速,画面都只取决于当前的 t,也就不需要识别 seek 了。按这个思路做了三处改动。

  • 运动交给媒体时钟。弹幕层挂在 AVSynchronizedLayer 下面,这个 layer 会让子层的动画跟着播放器的时间走,暂停、变速和 seek 都由系统处理。
  • 车道改成确定性布局。把弹幕按 (time, id) 离线排好序,提前算出每条弹幕的车道。这样同一个时刻得到的画面永远是同一个。
  • 在屏集合只看一个时间窗。此刻还在屏上的弹幕,出现时刻一定不早于 t − 最长时长;再加一段前瞻,提前准备马上要出现的弹幕。所以每次只需要对 [t − 最长时长, t + 前瞻] 这个区间里的弹幕做增删(reconcile)。

在本机 loopback 上的 HLS 流里测试,seek、拖动、倍速、缓冲和播放结束都能对上,同时挂 1000 个 layer 大约需要 9ms。

开始这一轮之前,我看了一些开源实现的做法。表里的「运行时贪心」是指弹幕出现时才去找一条空车道,「预计算布局」是指提前把所有弹幕的位置排好。

类别代表seek 后满屏?resize 时
运行时贪心(原生)BarrageRenderer、DanmakuKit、DFM、IINA 插件否,seek 时清屏,不补位按新宽度重算 x,y 不变,可能重叠
运行时贪心(Web)weizhenye/Danmaku、DPlayer、ArtPlayer、danmu.js、CCL否不清屏,也不重排 y
预计算布局ASS/libass、uosc_danmaku、niconicomments是在虚拟 1920×1080 舞台上布局,渲染端缩放

seek 后能满屏的,只有预计算布局这一类。它们的位置本来就是时间的函数,跳到哪个时刻,就能直接算出那一刻的画面。开源弹幕项目里没有看到使用 AVSynchronizedLayer 的。我也测试了 B 站的网页和 App,两者在 seek 后都会立刻满屏,所以预先排布局的方向和官方的体验一致。

这一轮也有自己的问题。最初的布局按物理像素计算,滚动时长和车道数都随画面尺寸变化,窗口一变,整张布局就要重来。于是又派生出几种补偿:跨分段传递的布局种子、晚到分段的重排、全屏或缩放或修改设置时整表重排,还有大约 200ms 的交叉淡化。交叉淡化在合并前去掉了。

取景框#

尺寸问题借鉴了「虚拟舞台」的做法,但字号不随窗口缩放。布局表按当前屏幕的最大尺寸排一次,窗口相当于一个取景框,只决定能看到这张表的哪一部分:从右边缘往左露出多宽,露出前几条车道。滚动弹幕的容器锚定右边缘,顶部和底部弹幕的容器分别锚定居中和底边。resize 时不重新排版,字号也不变。

这样可行有两个前提。一是弹幕的速度恒定,和画面宽度无关,所以窗口宽一点窄一点,同一条弹幕在同一时刻离右边缘的距离都一样。二是车道从上往下依次分配,前 k 条车道的结果不受总车道数影响,窗口变矮只是少露出下面几条。

代价是窗口变大的一瞬间,取景框外原本看不到的那部分弹幕可能还没有「到」,这是已知的暂态。字号是否跟随缩放,我打算以后做成一个「跟随屏幕缩放」开关,叠加在取景框上。

拖动进度条时,弹幕完全跟随媒体时间。拖到远处会自然空屏,落地后整屏替换。触发重新计算在屏集合的条件也改了。以前看「不连续代号」,也就是每次时间发生不连续就加一的计数;现在只看当前位置是否还在已经排好的时间窗之内。

暂停时弹幕会往回缩#

改用 AVSynchronizedLayer 驱动以后,按暂停时,滚动弹幕会往回缩 4–10pt。

我用 Instruments(Time Profiler + System Trace)录下了按暂停的那一刻,找到两段阻塞。

第一段和 Live Text(系统在暂停画面上识别文字的功能)有关。pause() 内部会触发 rate 的 KVO 回调,AVKit 的画面分析在这个回调里从主线程读取 currentTime。而此时播放器正为音频暂停的握手持有一把锁,主线程因此多等了大约 20ms。关闭 allowsVideoFrameAnalysis 后,暂停时的阻塞从约 55ms 降到约 35ms。弹幕层覆盖在画面上,暂停帧上的文字选取原本就用不上。

第二段来自 AudioQueuePause 的 AwaitIOCycle,也就是等待当前这一轮音频 IO 结束,它同样持有锁。

AVSynchronizedLayer 什么时候冻结,取决于主线程。主线程被挡住的这段时间里,弹幕动画还在往前走;等 layer 真正冻结,弹幕又退回暂停那一刻的位置,看上去就是往回缩了一段。

自建时钟层#

既然问题出在主线程,我就写了一个时钟层,不经过主线程,在后台串行队列里跟着 timebase 走。

timebase 的通知由持有 MediaToolbox(AVFoundation 底层的媒体框架)锁的线程同步发出,所以回调里不能调用任何 AVPlayer 或 AVPlayerItem 的 API。比如在回调里读 currentTime,就会去申请同一把锁,把自己锁死。回调只负责把事件投递到私有的串行队列。

在队列里,时钟层调用 CMSyncGetRelativeRateAndAnchorTime,拿到 timebase 相对宿主时钟的速率,以及一对锚点:宿主时间为 Ha 时,媒体时间为 Ta。实测返回的 Ha 始终为 0,所以要先把锚点平移到当前时刻 now,再写进 layer。

Core Animation 里,一个 layer 的本地时间等于(父时间 − beginTime)× speed + timeOffset。按下面这样写,layer 的本地时间就正好等于此刻的媒体时间,之后按 rate 前进:

beginTime = now
timeOffset = Ta + (now − Ha) · rate
speed = rate

写入使用显式的 CATransaction,并关闭隐式动画。换视频时,时钟层会换绑到新 item 的 timebase,并用 generation(每次换绑加一的编号)丢弃旧 item 的回调,因为旧 timebase 在换 item 之后还会继续发通知,有线输出下最晚到 +33ms,蓝牙下到 +103ms。

在探针里,31 次暂停全部没有回退;同样的场景下,AVSynchronizedLayer 会回退 3.5–7.5pt。

时钟滤波没有保留#

换成时钟层以后,timebase 的小跳变都会直接反映在弹幕上。恢复播放时会前跳 20–40ms,画面同时跳 2 帧;seek 后起播时后退约 57ms;变速时先停约 45ms,然后前跳。

开源实现大多会刻意忽略这类小跳变,DFM 做平滑追赶,CCL 和 IINA 用阈值。我也写了一版时钟滤波,偏差达到 0.25–0.5 秒时直接对齐,偏差更小时把速率调快或调慢 5%,在几百毫秒内慢慢追平。

这一版很快又出现了特例。连续的滤波逻辑被塞进一个混合状态机;暂停期间追赶的速率被卡在上限,误差却一直在累积(控制里叫 windup);渲染和选择屏上弹幕各用一个时钟,两个模块之间还得共享容差。它和前面的启发式规则是同一类问题,这条分支也删掉了。

重新整理需求#

到这一步,我回头看了最初的要求。「弹幕和视频时间准确同步」并不是一条真正的需求,它是使用 AVSynchronizedLayer 时引入的手段。之前很多修补都围绕这个手段的副作用,前面列出的那些「需求」,大多也是从具体的错误倒推出来的。

于是我重新写了几条不变量,也就是不管怎么实现都必须成立的性质。它们只描述观众看到的结果。

不变量含义
锚定弹幕时间约等于观众时间。观众时间取画面时间,因为视觉先行
观众时间匀速流逝,只因为观众的操作而跳变
场景只由时刻决定同一时刻看到的弹幕集合是唯一的(前提是分段的到达历史、跨分段的布局种子和纹理预算都相同)
物体恒常屏上的弹幕不会无故消失、重现或换车道
可读不重叠,不闪
不干扰内容弹幕的问题不能拖累视频

接下来要确认的是,播放器里有没有一个完美的时间,能让第一条「弹幕时间约等于观众时间」直接成立。如果有,弹幕只要跟着它走就行。

播放器时钟的实测行为#

为此我写了一个探针程序。它的 AVPlayerView 配置和 BiliKit 完全一致;测试视频是 60fps,每一帧都编了条码,用来读出当前上屏的是哪一帧;HLS 有两档码率,音频单独作为 EXT-X-MEDIA;本地服务器可以限速,也可以暂停。程序原样复用仓库里的时间线适配器,在各种场景下同时记录 timebase、主线程快照、上屏帧号和主线程阻塞。

AVFoundation 的文档很少涉及这些行为,比如 timebase 什么时候跳、跳多少,AVKit 在哪个线程调用 seek,KVO 什么时候同步触发,都需要自己测。这次的每个设计决定,都先有一个独立的探针程序给出数据。

按输出设备区分的数值如下。

现象有线 / 内置输出蓝牙 AirPods
首播、seek 后起播、卡顿恢复、换 item 后起播时,timebase 往回跳,跳的量约等于音频输出延迟约 −37ms−97…−215ms
暂停后恢复时,timebase 往前跳+24…+38ms+11…+127ms
播放中 seek 后,快照先到目标,然后领先 timebase约 37ms93–184ms
播放中 seek 后,主线程被挡56–72ms260–280ms

第一行的跳变量还会随输出设备变化。也就是说,每次开始播放,timebase 都会先往回退一小段;弹幕如果严格跟着 timebase 走,也会跟着往回缩。

其他观察结果如下:

  • 蓝牙下 timebase 会在没有通知的情况下静默重锚,出现 +2…+31ms 的离散跳变,误差最大 ±49ms。因为没有通知,弹幕侧没法及时知道。
  • 码率切换前后是同一个 timebase,直线不变,也没有通知。
  • 4 倍速播放 HLS 时,会在短时间内收到大量 TimeJumped 通知,每 4 秒 6–66 次。
  • 在未缓冲区拖动时,快照大约 4ms 就跳到目标,timebase 却原地不动 0.2–4 秒甚至更久,两者相差几十秒。这时如果一部分逻辑看快照、另一部分看 timebase,它们看到的时间会差出几十秒。
  • 主线程卡住时,快照停止产出,既不排队,也不合并。
  • 有线输出下,上屏的帧严格等于 floor(timebase × fps),画面和 timebase 对得上。seek 后,目标帧会先于 timebase 重新开始上屏,并停住约 93ms。
  • 暂停后主线程被挡约 31ms,变速后被挡 24–42ms。

音频也会跳#

timebase 的跳变大多来自音频。timebase 估计的是音频实际播出的位置,而音频的停止和起播都有延迟。我想过,如果能拿到音频真正停止的时刻,也许能消掉恢复播放时的前跳,于是又测了音频。

结果只能部分成立。暂停和变速时,音频比 timebase 多播 17–21ms 才停,这部分很稳定;但恢复播放时,音频本身会跳过 61–76ms 已经渲染的内容。系统也没有可用的公开「音频停止」信号,kAudioProcessPropertyIsRunningOutput 要大约 7.4 秒才变化,监听块从不触发。MTAudioProcessingTap 会把这些量放大约 10 倍(音频按 4096 帧一块渲染),而且无法挂到 HLS 上。

恢复播放时,timebase、画面和音频都会跳,播放器里没有一个连续的时间可以作为锚。

另外确认了一点,AVPlayer 变速时音频会停顿,这是系统本身的行为,Safari 也一样,只有自己渲染音频(AVAudioEngine + TimePitch)才能避免。

锚定起播的尝试#

我还试过另一条路,就是让 timebase 不跳。具体做法是继承 AVPlayer,重写 rate 的 setter,起播时用 setRate(_:time:atHostTime:) 指定「在哪个宿主时刻、从哪个媒体时间开始播」,让 timebase 的直线正好接上暂停时停住的那一点。这个 spike 在 mp4 上效果很好,但有几个问题。

  • 判断条件有 11 条,其中几条在修它自己的副作用;
  • 变速时画面冻结从约 45ms 变成约 150ms,违反了「观众时间匀速流逝」;
  • 失败时比现状更糟,HLS 下 seek 在途时会卡死,mp4 会撤销刚做的 seek,AVPlayer 未就绪时会抛出 NSException;
  • AVKit 的进度条在后台线程调用 seek,按主线程登记会漏掉。

它同样属于补丁,所以没有采用。

两个时间源#

做 spike 的同时,我请了一个子代理做敌对审查,评估代码库里历代的弹幕实现,包括最初的运行时调度、catchup 分支、系统事件方案、媒体时钟方案、取景框到时钟层的几轮改动,以及时钟滤波。

审查的结论是,每一版都有两个时间源。前面说过,弹幕要回答两个问题:每条弹幕在哪,以及屏上该有哪些弹幕。负责前一个问题的时钟换过几次,先后是 root layer、同步 layer 和时钟层;后一个问题却一直按主线程快照来判断,而快照每 1/30 秒一次,会合并,也会回跳。这两个时间从来没有统一过,只要两者不一致,弹幕的位置和该不该出现就会对不上。

例如 seek 起播时,timebase 回跳 57ms,快照因此落到了已经排好的时间窗之外,触发重新计算,结果已经播完的弹幕又被挂了上去,原本扣住准备整屏显示的画面也被拆开。

之前那次「第一性原理」的研究只检查了时钟这一层,没看在屏集合是怎么决定的。研究的范围应该覆盖整个系统,不能事先假定哪一部分不需要改。

审查时有两种做法比较有用。几份审查互不知情;设计稿由作者先列出自己认为最该被攻击的三点,审查按这份清单展开。

审查最后给出一份 14 条的评判清单,其中包括:

  1. 单一时间源:弹幕的位置和在屏集合,按同一个时钟的同一时刻计算;
  2. 不识别意图:不用阈值、计数或越界去猜 seek;
  3. 规则有预算:不允许出现专门修上一条规则副作用的规则;
  4. 测试要断言弹幕确实可见,而不只是断言它已经被添加(installed)。

另一份审查确认,只进不退和单一时间源必须一起上线,否则在 4 倍速那种大量 TimeJumped 的情况下,可见弹幕会少 30%。

现在的设计#

现在的方案仍以 timebase 为准,没有再去改变播放器的行为,只是在它之上加了一条从观众角度出发的规则:正向播放时,时间只进不退。

只进不退#

时钟层仍然严格跟着 timebase。区别在于,正向播放时如果 timebase 往回跳了一小段,时钟层不跟着往回退,而是停在原地,等 timebase 追上来以后再一起往前走。屏幕上看到的效果是弹幕停顿一下,不会往回缩。

这只需要一次写入。利用前面那个本地时间的公式,把 beginTime 设到未来,并设置 fillMode = .backwards,这样在 beginTime 到来之前,layer 的本地时间保持在 timeOffset:

beginTime = now + (D − T) / r
timeOffset = D
fillMode = .backwards

其中 D 是当前显示的时间,T 是 timebase 此刻的时间,r 是速率。timebase 比 D 落后 D − T,按速率 r 需要 (D − T) / r 秒才能追上,这正好是 beginTime 往后推的距离。追上之前,本地时间停在 D;追上之后,按 r 继续前进。整个过程不用定时器,也没有阈值。速率为 0 时的跳变照常跟随。

在我的机器上,seek 起播和首播时 timebase 会后退约 125ms。改之前弹幕会往回缩约 16pt;改之后诊断日志记录了 39 次等待,没有一次后退。

单一时间源#

时钟层每次写入 layer 后,把这次用的那条直线的参数,连同纪元(时钟层给每一段连续时间编的号)一起发布给主线程。主线程用同一个函数 DanmakuClockWrite.mediaTime 从这条直线算出显示时间 D,这是代码里 D 唯一的计算公式。这样主线程和 layer 用的就是同一个时间。

在屏集合、重新计算、迟到的纹理和分段淘汰,都改成按显示时间判断。主线程快照只剩三个用途:识别当前的播放对象(identity)、决定多久检查一次,以及请求分段。

代码审查中发现过一个阻断问题。切换视频时,解绑之后仍在发布冻结的显示时间,加载期间的黑屏上会出现定格的弹幕。修复方法是解绑之后不再发布。

删掉暂存#

这些改完以后,呈现层还保留着一套暂存机制。时间跳变后,它会先把新画面扣住,等需要的纹理都准备好了再整屏显示。具体有好几种:缺失的纹理超过 8 条才整屏等待,少于 8 条就逐条显示;可以被后来的暂存取代、也可以部分提交的暂存;整屏保持;以及车道重排时的整屏暂存。

从不变量出发,呈现层只需要一条规则:

纹理就绪、而且此刻还应该在屏的弹幕,就立刻挂上,不为等待其他弹幕而扣住它。

扣住新画面,就是让屏上暂时保持旧画面,这只有在旧画面本身仍然是一个有效场景时才有意义。在单一时间源下,时间一跳,旧 layer 几乎都已经落在自己的动画时间之外,旧画面其实已经空了,保留它等于保留一个空屏。所以上面这几种暂存,连同重新计算时的三路分支,都可以删掉,也不需要替代规则。

删掉之后,缓存命中的纹理会在 prepare 里同步回调,所以远跳到看过的位置时,弹幕仍然整屏一次出现;没有命中的逐条出现,估算 300 条大约需要 4 帧。修改设置、换屏或换缩放比例时,车道原位更换。纹理的 key 与车道无关,backend.update 会在同一次调用里把屏上的弹幕全部换到新车道。

规则数从 20 条降到 12 条,修补类规则从 4 条降到 1 条。弹幕侧不再有分类阈值,只剩一条批量参数之间的不等式(步长 + 检查间隔 < 前瞻),由测试固定。

另外有一项改动没有采用,就是把快照时刻也并入在屏集合,用来解决「未缓冲拖动时,时钟跳到落点那一帧就满屏」。纹理预算有上限,满了以后要挤掉一部分,而挤掉谁只看「是否已经进场」。审查发现,这样一来,快照一侧早已走完的弹幕会挤掉显示时间一侧马上要用的弹幕,探针也证实了这一点。密集视频里,64MiB 只能放下 290–844 条纹理,预算饱和是常态,而这项改动的收益只有大约 1 帧,所以暂时搁置。

单一时间源的实现细节#

下面几点是实现上的细节,只关心思路的话可以跳过。

  • 时钟层的三个时间属性需要原子写入,否则可能有人读到只更新了一部分的中间状态(撕裂)。我只在三次赋值外层包 CATransaction.lock(),commit 放在锁外。持着全局锁 commit 会死锁,因为 commit 内部的锁顺序是先 context 锁、再全局锁。探针中这样写入没有出现撕裂(0/20000)。
  • 后台的显式提交只会提交它自己改过的 layer,但同一个 layer 上主线程还没提交的 frame 会被一起带上屏,这一点也在探针里确认过。
  • 只进不退按纪元计算下限,纪元变化后直接严格跟随 timebase。同时检测一个平台前提,即祖先 layer 的时间属性是否恒等,这样 layer 的父时间才等于宿主时间,前面的公式才成立。普通窗口、原生全屏、detached 全屏和画中画下实测都是恒等的。
  • 分段淘汰要保护「快照时刻 ∪ 显示时间」两侧需要的分段,这样修掉了淘汰和请求之间的死循环。
  • currentItem 的 KVO 会在 replaceCurrentItem 调用内部同步触发,所以在这里同步清屏并换绑时钟,旧时钟就不会多走一个主线程回合。

已知的限制#

限制量级原因
播放中 seek 后,主线程被挡期间落点空屏有线 ≤ 约 70ms,蓝牙 ≤ 约 280ms(推测)根治需要把对齐挪到时钟队列,或者把快照时刻并入在屏集合(已搁置)
恢复和变速时前跳 20–40ms2–3pt这是 timebase 的真值,画面也在跳
蓝牙静默重锚±49ms没有通知,只能靠轮询或滤波,都是补丁
纹理预算吃紧时,缺哪几条取决于完成顺序只在 2x 高密度视频中「场景只由时刻决定」里已写明的例外
缓存未命中时逐条出现数帧有意为之,不扣留
弹幕该跟哪个时钟走:BiliKit 弹幕同步重写记录
https://www.shiinayane.com/zh/posts/danmaku-sync-rewrite/
作者
YANKAI WANG
发布于
2026-10-23
许可协议
CC BY-NC-SA 4.0