弹幕该跟哪个时钟走: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 = nowtimeOffset = Ta + (now − Ha) · ratespeed = 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 | 约 37ms | 93–184ms |
| 播放中 seek 后,主线程被挡 | 56–72ms | 260–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 条的评判清单,其中包括:
- 单一时间源:弹幕的位置和在屏集合,按同一个时钟的同一时刻计算;
- 不识别意图:不用阈值、计数或越界去猜 seek;
- 规则有预算:不允许出现专门修上一条规则副作用的规则;
- 测试要断言弹幕确实可见,而不只是断言它已经被添加(installed)。
另一份审查确认,只进不退和单一时间源必须一起上线,否则在 4 倍速那种大量 TimeJumped 的情况下,可见弹幕会少 30%。
现在的设计
现在的方案仍以 timebase 为准,没有再去改变播放器的行为,只是在它之上加了一条从观众角度出发的规则:正向播放时,时间只进不退。
只进不退
时钟层仍然严格跟着 timebase。区别在于,正向播放时如果 timebase 往回跳了一小段,时钟层不跟着往回退,而是停在原地,等 timebase 追上来以后再一起往前走。屏幕上看到的效果是弹幕停顿一下,不会往回缩。
这只需要一次写入。利用前面那个本地时间的公式,把 beginTime 设到未来,并设置 fillMode = .backwards,这样在 beginTime 到来之前,layer 的本地时间保持在 timeOffset:
beginTime = now + (D − T) / rtimeOffset = DfillMode = .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–40ms | 2–3pt | 这是 timebase 的真值,画面也在跳 |
| 蓝牙静默重锚 | ±49ms | 没有通知,只能靠轮询或滤波,都是补丁 |
| 纹理预算吃紧时,缺哪几条取决于完成顺序 | 只在 2x 高密度视频中 | 「场景只由时刻决定」里已写明的例外 |
| 缓存未命中时逐条出现 | 数帧 | 有意为之,不扣留 |