首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从零做播放器,命令怎么排队、状态怎么走

从零做播放器,命令怎么排队、状态怎么走

原创
作者头像
用户1290566
发布于 2026-10-19 22:43:53
发布于 2026-10-19 22:43:53
150
举报

线程骨架立好之后,马上遇到第二个问题:界面要暂停,要跳进度条,网络层在报错,好几路都想改播放器的状态。我的答案是它们都不直接改——所有控制动作打包成命令,排进一条队列,由命令线程逐条执行。前半讲这条队列的规矩:保证顺序与串行,不保证命令被执行完;后半讲九个状态——每一个各自怎么来,opening 和 ready 为什么分成两个,buffering 是谁报上来的,以及 pause 与 seek 在渲染和解码两侧各动了哪些手。

从空工程开始做一个播放器,骨架立完之后,接下来的问题是控制怎么走:界面、读线程、解码线程、宿主都想改状态,办法是把这些动作全部打包成命令,排进一条队列;状态一共九个,每个各自怎么来,以及 pause 与 seek 各自停在哪儿。

所有控制动作走同一条队列

想动播放器状态的有四路:界面线程点暂停、拖进度条;读线程报网络断开;解码线程报解不动要降级;宿主查进度、设倍速,来自随便哪条调用线程。四路分在四条线程上,都能直接写的话,「当前状态」出错时就说不清是谁改的。

所以规矩是:任何人都不直接改,想做什么就打包成一条命令,压进一条队列。队列只有消费者——命令线程,一条线程逐条执行。open、close、seek、play、pause、设倍速、切音轨、选字幕,全部走这条路。命令队列挂在播放器对象里,有上限;播放器对象一创建,命令线程就跟着起了。

这一步还定下一条更深的纪律:轨道对象、渲染对象的创建和销毁,也只发生在命令线程里。理由不复杂——销毁一条视频轨,要停解码线程、清两条队列、放 GPU 资源,这些动作和别的命令并发就没法补;串行化换来的确定性,比并发省下的那点时间值钱。

队列给的是顺序与串行:先来的先执行,不会有两条命令同时在改状态。但它只保证顺序,不保证执行完——入队等到队列有空位就返回,不等命令被执行;想知道结果,靠订阅状态通知或直接读当前状态,整条链上唯一带回执的只有字幕的加载与卸载。还有一条:入队成功不等于会被执行,执行体开头都有一道条件判断,不合时宜的命令——比如还没打开就发 pause——到了命令线程手里会被直接丢掉。顺序由队列保证;合不合时宜,由状态机在执行时判。

队列上只有一样东西能插队:关播。close 入队前先把整条队列清空,再把自己放进去——排着的 seek、切轨全部作废,它不排在任何人后面。

九个状态与它们之间的迁移

播放状态一共九个:none、opening、ready、playing、pause、seek、buffering、stopped、completed。它们之间会走的迁移,一条条数出来只有这些:

命令进队与状态的真实迁移:上半是命令进队(上限 100 的队列 + 单消费者命令线程),下半是状态之间真正的走向——有回头箭头、有旁路,不是一条直线
命令进队与状态的真实迁移:上半是命令进队(上限 100 的队列 + 单消费者命令线程),下半是状态之间真正的走向——有回头箭头、有旁路,不是一条直线
  • none → opening → ready → playing:起播这条顺路——open 受理后 IO 打开、报出轨道信息进 ready,第一帧渲染出来、渲染线程入队一条 Play,进 playing。打不开就直接跳到 stopped,不停在中间等。
  • playing ⇄ pause 与 playing ⇄ buffering:前者是用户按的;后者要渲染取不到帧、包队列也空了才进,缓存够了回 playing。
  • playing / pause / buffering → seek → playing:seek 是瞬时态,置入与置回在同一条命令里,所以不管从哪个状态进来,出去都是 playing。
  • ready → seek → playing:断点续播,还没起播就要跳;ready 是特批允许直接进 seek 的唯一非播放态。
  • playing / pause / buffering → stopped(close、缓冲看门狗超时、打开失败),playing / buffering → completed(尾包放尽)。

三条:第一,playing、pause、buffering 这三态是一个并集,只有落在里面,pause、seek、buffering 这类命令才有资格被执行;第二,seek 是给外面看的瞬时态,pause 与 buffering 是并列的中间态,两个都回 playing;第三,状态里没有 error 这一档——错误不走状态,走回调。

opening 与 ready:能取到数据与能出画面

这两个状态分的是两件事:能取到数据,和能出画面。

opening 是命令线程受理了 open、IO 正在打开的那一段:连协议、读容器头、找轨道。这一段会失败——IO 打开返回失败就直接进 stopped。打开期间命令线程也不肯久等:它的主循环末尾本来有一次短暂休眠(用来压住空转的 CPU);只要队列里有命令、或者状态是 opening,这一轮就跳过休眠、只让出时间片,保证这一段里随时接得住下一条命令。

ready 的由头在 IO 侧的回调上。IO 把轨道列表和时长交出来,播放器做三件事:把轨道描述挂到轨对象上——音频只挂当前选中的那条,超出的轨直接丢掉;记下总时长;入队一条 Ready 命令。命令线程执行到它,才置 ready、通知外部,然后启动各轨——注意顺序,先起解码线程,等解码信息返回,再起渲染线程。

所以 ready 的含义很具体:轨数、格式、时长都能查了,解码链挂上了,但一帧画面都没有——一个「准备就绪,还没有内容」的窗口。它有两个用处:一是断点续播,seek 就是在 ready 那一瞬间发出去的,ready 因此是唯一允许直接进 seek 的非播放态;二是它不判 buffering,首帧一到就直接进 playing,否则刚 ready 就报缓冲、干等数据。

buffering 是渲染线程报上来的

进 buffering 的唯一入口在渲染侧:渲染线程去取帧,没取到,这时候才回头看一眼两条包队列——音频和视频的包队列都空,才算「后面真没数据了」。只要包队列里还有存货,那是解码跟不上,不是断流,不进 buffering。而且只有 playing 态才上报。

IO 已经把「结束」报上来的情况要分岔:两条队列全空就该收尾,入队的是 Complete;IO 结束了、尾包还在管线里放(音频尾包能拖好几秒),那既不是卡顿也不是结束,什么都不做。

进了 buffering,命令线程做三件事。停渲染,让队列里的数据别再被消费,IO 与解码器照跑,继续把数据存够。时钟先锚到当前显示值再钉住——不锚,进度条会在卡顿这一下往回跳;不钉,它又会在卡顿期间按墙钟往前爬。最后置 buffering,看门狗归零开始计时。

恢复在命令线程的节拍里做,条件有三层。要求上次的状态是 playing——否则从暂停进来的 buffering 会自己恢复播放。要求视频或音频的队列缓存时长够(代码里叫 delayMs)。超过四倍速时只解 I 帧,判据换成「视频帧队列里有帧」。另外 IO 已报结束就无条件恢复,把尾包放完;不然 seek 落到离片尾不足两秒的地方,这个门永远不满足,只能等看门狗超时。

一直恢复不了,看门狗超时就直接关播放器,走 stopped。这个时长对两类源要放宽:torrent 是渐进下载源,seek 落进未下载区域要等片落地,放得更宽;http 直连有服务端的惩罚窗,也要放宽一档。

pause 与 seek 的停法:时钟、IO、渲染

pause 命令动三处:停时钟、停 IO、停渲染。停 IO 是两层——先在协议层叫一声,把「服务端别发了」这件事交给支持的协议(不支持就当没这回事),再把自己的读线程暂停。

解码器不在这三样里。渲染一停,帧队列很快填满,解码线程就卡在「入队」这一步,包队列跟着填满,读线程再被包队列顶住——IO 那头除了一声显式叫停,队列深度本身也是一道刹车。下一篇讲码流到帧这一路时会看到,视频那条帧队列浅、音频那条深,浅的那条刹车来得快,正好配这里。

seek 麻烦得多,因为它得动数据。入队那一步先跟队尾合并:已经排着一条 seek,只改它的目标位置,命令线程只执行最后那一下。真正的动作在命令线程里,顺序是这样:先给 IO 一个预告打断读,再把包队列置成关闭、撤掉背压——这两步不能反,反了快源会先狂奔几包。然后三道闸一起打上:包队列关闭、渲染暂停、解码线程暂停。等一小会儿,让在途的数据落进队列,再清空两条队列——不清,跳完还会播一段旧位置的数据。顺手作废「IO 已结束」的结论,清掉字幕侧的待播事件。接着通知 IO 跳到目标位;开了精确 seek,再给每条视频轨武装「丢弃到目标位」,第一个不早于目标的帧才恢复入队。最后时钟定位到目标位,三道闸按相反顺序放开。

整场 seek 里,状态停留在 seek 的时间,就是这条命令自己执行的那一小段——置入和置回在同一个函数体内,中间不等任何外部事件,跨不出命令边界。但这个状态并不等于「正在跳」:真正长命的是另一个标志,它从调用 seek 那一刻就置位,一直挂到真实位置追平目标(或超时兜底)才清;它挂着的这段时间里,查位置拿到的是跳转目标而不是真实位置,界面因此不会先跳到目标、再闪回落点。另有一处例外要照顾:buffering 态也允许 seek,那条路不走节拍里的恢复分支,时钟的闩没人解,位置会永久冻在目标位,所以 seek 末尾统一解一次闩。

错误不占状态,走两条回调

出错这件事没有专门的一档状态,因为错误不是一种状态。错误先到播放器手里,它自己消化一遍:能就地处理的就地处理,顺手把状态更新到该去的那一档;处理完再分两路回调上层——IO 错误走 onIoError,带错误码和消息;解码错误走 onDecodeError,带轨类型和结果码。停不停、要不要重试,由上层定。

源侧的错误码是分档的:负值是中断性错误(网络超时、网络关闭、URL 不支持、设备掉了、解码车道死),正值可以继续(文件结束、源无效、数据无效)。文件结束不算错误,播放器把它转进「完成」那条路;其余原样回调出去,状态不动。

解码侧的结果码区分超时、不支持、找不到、打开失败、配置缺失、数据错。有一种播放器不往上交:视频解码超时——硬解卡死这一类,它自己关播放器,收敛到 stopped。

「消化」还有一层意思:能判死就不重复报。解码车道从没出过一帧、连续喂包都失败(没有软解构建时遇到 AV1 就是这样),源侧就判这条车道死了,停喂、释放解码器,按源错误上报一次,不是每包报一次。

状态机自己也会收口:打开失败进 stopped、缓冲看门狗超时自己 close。

引擎 avox 在 GitHub 开源,仓库 xxxzhou/avox,这条命令队列和这套状态机都在里面。

系列《从零做一个全平台播放器》按施工顺序发,本篇是 1.2;已发各篇收在这里:https://cloud.tencent.com/developer/column/109457

上一篇:1.1 从零做播放器,先立线程骨架和注册表 https://cloud.tencent.com/developer/article/2763946

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 所有控制动作走同一条队列
  • 九个状态与它们之间的迁移
  • opening 与 ready:能取到数据与能出画面
  • buffering 是渲染线程报上来的
  • pause 与 seek 的停法:时钟、IO、渲染
  • 错误不占状态,走两条回调
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档