骨架立好、注册表也上了,播放器还是个不会出声的空壳——没有水进来。这一步接第一条进水管:一个地址进去,一个一个的包出来。整条路要回答四件事:这一层对外露几个动作、实现是怎么分类的、谁去取数据为什么能换、解封装把什么消化掉了。再往下是一个包上到底带了什么:哪些字段一次带齐、配置帧为什么必须最先入队、关键帧标志为什么只打一处标却够五处用。
从空工程开始做一个播放器,骨架和控制都立好之后,还是不会出声——没有数据进来。这篇接第一条进水管:一个地址进去,一个一个的包出来;六条取数据的腿、解封装吃掉的容器与协议、包上带的字段,以及直播为什么不能当成换一种打开方式。
编码数据源对上层的接口小得出奇:打开、关闭、设置速度。设速度是给直播用的变速语义,点播用不上;日常真正转的就两个——打开,关闭。剩下的都是查询:能不能 seek、总共多长、放到哪儿了,这些不改源的状态。
打开拿到的是一个地址,可能是本地路径,也可能是网络地址,怎么取由实现自己判断。源上还有一个模式位,区分本地播放、边下边播和直播三种来路——后面所有策略都跟着它走:缓存留多厚、缓冲水位多高、能不能 seek。同样厚的一份缓冲,点播可以随便回退,直播根本没有「回退」这回事。
接口收得这么紧是故意的。上层要的是「给我包」,至于包是从磁盘读的还是从网上拉的,它不该知道。

这一层只有一个基类:轨道描述放在它身上——视频的长宽帧率码率、音频的采样率声道数、字幕轨、总时长。这些东西在打开之后、出包之前就交出来了,上层读轨道描述就够,不用先问「你是文件还是摄像头」。
基类下面直接干活的那一层叫 AVSource,是编码数据源:吐出来的是压缩码流包,H.264、H.265、AAC 这些。共性的部分都实现在它里面——PTS 对齐、包的拆分合并、配置帧管理;具体怎么取数据,交给子类。眼下的子类有六条:ffmpeg 与 zlmediakit 编译在引擎里,网络共享、磁力与 BT、光盘镜像这几条由插件带进来——插件装上,注册表里就多一条腿;没装,地址就落到默认那条。播放器不认插件,只认注册表。
向上则是一组接口和观察者。动作那一面是打开、关闭、设速度;信息那一面是轨道数和各轨描述、能不能 seek;数据从观察者回来——就绪、同步基准、包、出错、结束,各是一个回调。上层实现一个观察者,这一层就把地址那头的差异全挡在外面了。

打开地址之后,容器被消化掉:mp4、mkv、ts 这些「装法」在这一层终结。解封装的产出物很明确——干净的、单帧的码流包。
「单帧」两个字要做不少事。网络来的数据常常不是按帧切好的:单个 P 帧通常自成一包,配置帧喜欢组合在一起发;I 帧体积大,常被切成好几包。有的流是三四字节起始码开头,有的是长度前缀,要先认出来再还原。这些都要还原成解码器能直接吃的形状。
时间戳也在这层理顺。容器里的时间基五花八门,出来的包统一带上渲染时间和解码时间,下游再也不用问「这个数是什么单位」。基准也要定:视频以第一个 I 帧为基准,音频在有视频时跟着视频的 I 帧走,两边差得太多就各走各的,不硬凑;中途时间戳跳变,基准重设;极端异常的数值直接拦掉,免得把解码器带崩。
边界两侧互不相知。往上,播放器不知道包是从文件来的还是从网络来的;往下,取数据的人不知道这些包最后是拿去渲染还是录下来。这条边界守住了,后面换 IO 方案、加录制功能,都不用动这层契约——反方向的路几乎是白捡的:把包交给封装器写出去,就是录制。
选「包」当交接单位,是因为链上的交接都按它来:进队列、跨线程传递、按帧丢弃、按帧回调,粒度都是包。字段就得一次带齐,下游不用回头再问。
包里装的是:类型——视频、音频、字幕三种轨,每种分数据包与配置包两档;渲染时间戳和解码时间戳,有 B 帧的时候这两个不一样,渲染按前者、解码按后者;包时长,音频没有时间戳的时候靠它往前推进;关键帧标志;码流格式前缀长度——h264/h265 前三或四字节,aac 前七可能是 ADTS 头,解码器按这个认格式。关键帧标志除了 0(普通)和 1(关键帧),2 往上是配置变化的提示帧。
缓冲区是复用增长的:一块分配下来的内存比这次要装的数据大,就留着后面接着用,每个包尽量不重新分配。代价是包会越到后面越大,换来的是分配次数变少。
码流里有几种包不含画面:H.264 的 SPS、PPS,H.265 还多一个 VPS,AAC 的采样率声道信息。它们是解码器的说明书,没有说明书解码器起不来——H.264 的配置至少要凑齐两个 NALU,H.265 要三个,少一个,解析直接失败。
所以两条规矩:配置帧最先入队,解码线程起循环之前先把缓存的配置喂进解码器;而且每个关键帧之前都要重新发一遍。第二条是后来补的:参数集进了解码器就一直在,补投是我给自己定的一条不变式,它一次解决了三个场景:中途起播的观众拿到的第一包就是关键帧,前面正好带着配置;切轨回来,旧轨的队列清空了,靠缓存补投;换解码器降级,新解码器同样靠重放配置站起来。AAC 还有个小坑:有的流根本没有配置数据,那就把第一个 ADTS 头升格成配置帧,凑齐说明书。
每个包上有一个关键帧标志,打标的地方在 IO 层:容器的标志先带进来,h264/h265 再按 NALU 语义复核一遍——有些容器会把整段全标成关键帧,不复核的话,后面的逻辑全会被误导。
这一个字段,下游五处消费:I 帧模式的检测(连着来两个不同时间的纯 I 帧流,自动切 I 帧模式);seek 之后的丢弃(丢弃非关键帧,直到撞上关键帧为止,带一个防呆上限);解码器重置(重置要等到关键帧包前才真动手);硬解降级换道时,丢包吸到关键帧才起解;四倍速以上,非关键帧的包在读线程侧直接丢。一个字段贯穿整条链,起了五个不同的作用——这也是把「包」定成交接单位的好处:所有下游都读同一份事实。
引擎那头只给一个默认值:开出来就走 ffmpeg 那条腿。
真正定规矩的是上层。播放器留了切换的入口,宿主想按自己的地址规则选腿——哪些协议走哪条、哪些后缀走哪条——在这一层写就行。命令行工具就是这么干的:它自己维护一张表,rtsp、rtmp、srt 这些真流协议,以及 m3u8、flv、ts 这类流式后缀走 zlmediakit,其余的走 ffmpeg。这套映射出在工具的代码里;换个宿主,规则可以是另一套。
有一条不需要上层操心:开门那一刻按地址分道。rtsp、rtmp 直连流交给 zlmediakit 那条腿;磁力链接或 .torrent、以 smb:// 或 dav:// 开头的网络共享地址、.iso 蓝光镜像,交给对应的插件腿——插件已经注册进来才命中,没注册就还用上层选的那条。这一下只影响这次打开,不会改掉上层设过的选择。
六条腿实现的是同一个接口,对上层交出的是同样的包、同样的事件,播放器完全分不出自己脚下踩的是哪条腿。
自己拆 RTSP、RTMP 的协议栈是一件很重的事——控制信息和媒体数据混在同一条连接里,块格式、序列化、排序、重传,每一样都有细节。这一层我没有自己啃,而是把 zlmediakit 当成另一个数据源实现接进来。
读数据占一条独立线程,但线程名不是一个角色名:「ffmpeg io parse」是这条腿给自己起的名字。腿一换,线程名和线程本身都换——zlmediakit 那条腿不另起读线程,包是在它的 socket 线程上回调进来的;插件接进来的那几条腿,名字又跟着换成各自的。规律在接口这一侧:谁实现数据源,谁负责把自己的线程管好,并且起个名字让日志能认出来。
三点。第一,直播没有位置概念:点播 seek 是「跳到上次看的地方附近」,直播里这个动词不存在,断了只能从现在的直播点接着看。第二,延迟的账压在缓冲上:缓冲留得越厚越抗抖动,观众看到的画面就越落后;留得薄,一抖就卡。第三,出错的样子不同:点播出错多半是「读不到」,直播出错是「断了」,断流要能自己回来,回不来也要有个说法——重连是完整重来,重新拿描述、重新等配置帧,而且次数和时间都得设上限,真断了的无主直播流,无限制重试只会一直失败一直试。
这三条决定了直播不能只是「换一个打开方式」,它要一套自己的节奏。协议里最容易认错的有三处:分拣(控制包和媒体包混在一条连接里,看开头字节分)、拼帧(一个网络包不等于一帧,配置帧合并发、大帧切成几十包,收齐才能拼)、排序(网络不保证按序到达,解码器要求有序,进来先过排序器)。时间戳也是个陷阱:头里那个字段是计数器不是时间,视频每秒走九万个数,得换算成毫秒才能进时钟。
起播也不能来一帧播一帧——网络一抖,刚起播就卡住,做法是先攒够一个门槛再出画,这个门槛是低延迟和不卡之间的折中。连接建起来时,服务器先发的不是画面而是描述:几路媒体、什么编码、什么采样率;拿到描述才建通道,等配置帧齐了才算真的就绪,而等就绪是有预算的——有些设备首帧就是慢,等不起会误杀,等太久观众早跑了。
引擎 avox 在 GitHub 开源,仓库 xxxzhou/avox,数据源这一层、六条 IO 实现与直播这一路的参数都在里面。
系列《从零做一个全平台播放器》按施工顺序发,本篇是 1.3;已发各篇收在这里:https://cloud.tencent.com/developer/column/109457
上一篇:1.2 从零做播放器,命令怎么排队、状态怎么走 https://cloud.tencent.com/developer/article/2764582
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。