从零做播放器,先立线程骨架和注册表

空工程立起来,第一件往里放的东西不是解码器,也不是渲染,是骨架。音视频是一条流,读流、解码、出画面、出声音,每一段的快慢都不一样,挤在一条线程里就互相堵;而这条流上的每一段——取数据的腿、解码器、渲染器——将来都得能换。所以骨架是两半:线程怎么起,零件从哪来。这篇讲线程类为什么都继承同一个基类、可替换的实现挂在哪张表上,以及为什么日志必须第一天就有。

从空工程开始做一个播放器,第一件要立起来的不是解码器,是骨架:线程怎么切、可替换的实现挂哪张表、日志怎么留。

建立基本框架

骨架与基本流程:读线程解出包,包队列交给解码线程,帧队列交给渲染线程;命令线程旁路控制,日志线程兜底,注册表收着所有能换的实现

播放器要干三件事:读流、解码、出画面和声音。这三件事的快慢都不由自己定——读多快看盘和网,解多快看编码和机器,出图多快看屏幕刷新。串在一条线程里,全场就只能按最慢的那个走;而且本地文件上看不出问题,一换成网络地址就露馅:读一段要看服务器脸色,那段时间里没人解码,画面就空着。

所以第一步是把流切开:读归读,解归解,画归画,声音归声音,每段一条线程,中间用队列交接。

切开之后还有第二个问题:每一段里干活的,都是将来要换的东西。今天取数据用 ffmpeg,明天要接流协议;今天用软解,明天要上硬解;今天出一路画面,明天要出到别的窗口里。如果这些实现是直接写死在调用点上的,那每加一样,主体就多一处分支。

所以空工程里真正要先立的,是骨架的两半:线程怎么起、零件从哪来。

播放器里哪些类是一条线程

现在播放器里,凡是开了线程的类都继承同一个基类,叫 RunTask。基类里就三样东西:一个停止标志、一条 std::thread、一个线程名字符串;子类只填三个钩子——起线程前做什么、循环体做什么、停的时候做什么。线程怎么起、怎么停统一收在基类这一处,子类只管自己的循环。

播放器本体也是一个类,而且是最特殊的一个。MediaPlayer 自己就是命令线程:轨道、渲染对象的创建与释放都由它做,别的线程不去碰它们。它既是线程,也是所有播放对象的家。

哪些是一条线程:播放器、轨、任务三层,浅蓝底的都是线程

图上浅蓝底的都是线程,灰底的是容器。包队列和帧队列不是线程,是轨的成员——轨本身也是个容器。

可替换的实现都挂在注册表上

注册表里可替换的播放组件:一个位置一个实现五张表,一个位置一串实现四张表,采集设备管理器两张

线程这一半解决的是「怎么同时跑」,另一半是「零件从哪来」。

播放器要取数据、要解码、要出画面,每一件事都有一个接口,接口下都可能有好几个实现。要是调用点直接写死实现,换一条取数据的腿、加一个硬解,就得改播放器主体。骨架里给出的答案是一层注册表:能力按接口登记,调用方只要接口,不要实现。

表里存的不是对象,是「怎么造这个对象」的函数——真正造出来,是用到它的那一刻。这一点看着小,决定了加东西的代价:注册一个实现,不用碰任何已有的调用点。

表有两种形态,对应两种问题。第一种是「一个位置一个实现」:取数据的 IO 源、视频渲染、音频渲染、封装器这一类,一个位置上就站一个;查表时先问一句「这个位置有没有人注册」,有就造,没有就当场报出来、不再往下走。第二种是「一个位置一串实现」:解码器和编码器这一类,同一个编码可以挂好几个实现,注册的先后就是候选的先后——先试谁、退到谁,由这张表的次序定,这一条到出图那一章会展开。

这张图是注册表的存量:五张一个位置站一个的表(取数据的腿、源对象、封装器、视频渲染、音频渲染)、四张一个位置挂一串的表(音视频的解码器与编码器),再加两张按 SDK 分的采集设备管理器。没标「插件」的几条随引擎一起编译进去;标了的,要等模块加载才出现。

注册的时机同样定调。每个实现自己在被加载的时候,把注册函数交上来,引擎初始化时统一跑一遍;反过来,清理时按相反的顺序再跑一遍,让模块在被卸载之前自己收尾。插件走的是这条路:插件在编译期根本不存在于播放器里,装上去就注册,注册表里就多出几条。而插件扫描是懒的——第一次去查插件那张表时才扫目录。一个只做播放的 App,没装插件也不为插件付启动成本。

还有一条不显眼、但第一天就得想清楚的:注册表的顺序是 ABI。注册表在管理器里的位置,一旦有插件是照着它编译的,就只能往末尾追加新表;插到中间,老插件还按旧位置去访问,访问到的就是别的东西,直接崩。所以「加一张表」在这里不是随手加个成员的事。

命名也是骨架的事:注册用的名字不是内部变量名,它要一路出现在选项、日志和报错里。名字就是这道接口对外的样子——取数据的腿叫什么、解码器叫什么,调用方全靠这个名字认它。名字一改,配置和日志跟着错位,所以定名要慎重,改名前先想清楚谁在用它。

这一层立住,后面换 IO 方案、加硬解、加录制、接插件,动的都是注册表那一侧,播放器主体不用挪。

日志框架

骨架立起来,接着进去的就是日志。这不是洁癖:播放器的病,九成是时序病——某一帧比它该出现的时间晚了一截、某次 seek 顺序反了——这种问题几乎没法当场复现,只有日志能回放。没有日志查时序问题,等于闭着眼修表。

所以日志要带三样东西:毫秒级时间戳、实例前缀、线程归属。时间戳不说了,时序问题全靠它;实例前缀解决两个播放器同时播——日志开头那个 [MP0] 就是它;线程归属让一条日志能查出它出自哪条线程、哪条轨。

设计上倒是很克制。级别只有四个:info、warn、error、debug,没有级别开关——过滤这件事交给宿主的观察者自己做。拼消息不用格式化字符串,用变参一路拼接,数字、时间、帧对象都重载了自己的拼法。输出走观察者回调,宿主愿意写文件就写文件,引擎自己不落地;没挂观察者就退到控制台。文件这条腿是命令行工具自己挂的观察者,播一次写一份日志到运行目录,排查的时候特别顺手。

同步还是异步,按编译档位切:debug 同步输出——调试时宁慢勿丢;release 走异步——独立日志线程配一条有上限的环形队列,队列满了调用线程就地帮着排空,进程退出前还有一道强排,防最后几条被丢。

日志链路全程不许抛异常。退出阶段全局对象可能已经析构,这时候打日志,就算失败也只能吞掉——丢几条日志,保一个进程。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,这套线程骨架、注册表和日志框架都在里面。

系列《从零做一个全平台播放器》按施工顺序发,本篇是 1.1。

posted @ 2026-10-19 08:52  天天不在  阅读(2)  评论(0)    收藏  举报