第五章 实现一款视频播放器

一 架构设计
  • 输入模块

待视频流和音频流都解码为裸数据之后,需要为音视频各自建立一个队列将裸数据存储起来,不过,如果是在需要播放一帧的时候再去做解码,那么这一帧的视频就有可能产生卡顿或者延迟,所以这里引出了第一个线程,即为播放器的后台解码分配一个线程,该线程用于解析协议,处理解封装以及解码,并最终将裸数据放到音频和视频的队列中,这个模块称为输入模块

  • 输出模块

输出部分其实是由两部分组成的,一部分是音频的输出,另一部分是视频的输出。

  • 音视频同步模块

所以需要再建立一个模块来负责音视频同步的工作,这个模块称为音视频同步模块。

  • 调度器

先把输入模块音频队列视频队列都封装到音视频同步模块中,然后为外界提供获取音频数据、视频数据的接口,这两个接口必须保证音视频的同步,内部将负责解码线程的运行与暂停的维护。

然后把音视频同步模块音频输出模块视频输出模块都封装到调度器中,调度器模块会分别向音频输出模块和视频输出模块注册回调函数,回调函数允许两个输出模块获取音频数据和视频数据。

1.1 详细介绍
image.png
image.png
  • VideoPlayerController 调度器,内部维护音视频同步模块、音频输出模块、视频输出模块,为客户端代码提供开始播放、暂停、继续播 放、停止播放接口;为音频输出模块和视频输出模块提供两个获取数据的接口。

  • AudioOutput 音频输出模块,由于在不同平台上有不同的实现, 所以这里真正的声音渲染API为Void类型,但是音频的渲染要放在一个单独线程中进行。

  • VideoOutput 视频输出模块,虽然这里统一使用OpenGL ES来渲染视频,必须 由我们主动开启一个线程来作为OpenGL ES的渲染线程。

  • AVSynchronizer 音视频同步模块,会组合输入模 块、音频队列和视频队列,其主要为它的客户端代码VideoPlayerController调度器提供接口,包括:开始结束,以及最重要的获取音频数据和获取对应时间戳的视频帧。此外,它还会维护一个解码线程,并且根据音视频队列里面的元素数目来继续或者暂停该解码线程的运行。

  • AudioFrame 音频帧,其中记录了音频的数据格式以及这一帧的具体数据、时间戳等信息。

  • AudioFrameQueue 音频队列,主要用于存储音频帧,为它的客户 端代码音视频同步模块提供压入和弹出操作。由于解码线程和声音播放线程会作为生产者消费者同时访问该队列中的元素,所以该队列要保证线程安全性

  • VideoFrame 视频帧,记录了视频的格式以及这一帧的具体的数据、宽、高以及时间戳等信息。

  • VideoFrameQueue 视频队列,主要用于存储视频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和视频播放线程会作为生产者消费者同时访问该队列中的元素,所以该队列要保证线程安全性

  • VideoDecoder 输入模块,个是协议层解析器,一个是格式解封装器,一个是解码器,并且它主要向AVSynchronizer提供接口,打开文件资源(网络或者本 地)、关闭文件资源、解码出一定时间长度的音视频帧。

1.2 具体实现
  • 输入模块
  1. 选择FFmpeg开源库的libavformat模块来处理各种不同的协议以及不同的封装格式。
  2. 使用FFmpeglibavcodec模块作为解码器模块的技术选型。
  • 音频输出模块
  1. 对于iOS平台,其实也有很多种方式,比较常见的就是AudioQueueAudioUnit
  • 视频输出模块

技术选型肯定是选择OpenGL ES,在iOS平台上使用EAGL来为OpenGL ES提供上下文环境,自己定义一个View继承自UIView,使用EAGLLayer作为渲染对象,并最终渲染到这个自定义的View上。

  • 音视频同步模块
  1. 使用pthread维护解码线程
  2. 对于音视频队列,我们可以自行编写一个保证线程安全的链表来实现。
  3. 采用视频向音频对齐的策略,即只需要把同步这块逻辑放到获取视频帧的方法里面就好了。
  • 控制器

需要将上述的三个模块合理地组装起来。

二 解码模块的实现

直接使用FFmpeg开源库来负责输入模块的协议解析、封装格式拆分、 解码操作等行为,整体流程如图所示。

image.png

整个运行流程分为以下几个阶段:

  1. 建立连接、准备资源阶段。
  2. 不断读取数据进行解封装、解码、处理数据阶段。
  3. 释放资源阶段。

注意点:

  1. 对于每个流都要分配一个AVFrame作为解码之后数据存放的结构体。
  2. 对于音频流,需要额外分配一个重采样的上下文,对解码之后的音频格式进行重采样, 使其成为我们需要的PCM格式。
  3. decodeFrames接口的实现,该接口主要负责解码音视频压缩数据成为原始格式,并且封装成为自定义的结构体,最终全部放到一个数组中,然后返回给调用端。
  4. 对应于FFmpeg里面的AVPacket结构体,对于视频帧,一个AVPacket就是一帧视频帧;对于音频帧,一个AVPacket有可能包含多个音频帧
  5. 解码之后,需要封装成自定义的结构体的AudioFrameVideoFrame
  6. 对于音频的格式转换,FFmpeg提供了一个libswresample库。
  7. 对于视频帧的格式转换,FFmpeg提供了一个libswscale的库,用于 转换视频的裸数据的表示格式。
三 音频播放模块的实现

在iOS平台,可使用AudioUnit(AUGraph封装的实际上就是 AudioUnit)来渲染音频。

构造AUGraph,用来实现音频播放,应配置一个ConvertNode将客户端代码填充的SInt16格式的音频数据转换为RemoteIONode可以播放的Float32格式的音频数据(采样率、声道数以及表示格式应对应上)。

image.png
四 画面播放模块的实现

无论是在哪一个平台上使用OpenGL ES渲染视频的画面,都需要单独开辟一个线程,并且为该线程绑定一个OpenGL ES的上下文。

  1. 首先会书写一个VideoOutput类继承自UIView,然后重写父类的layerClass方法,并且返回CAEAGLLayer类型,重写该方法的目的是 该UIView可以被OpenGL ES进行渲染;然后在初始化方法中,将 OpenGL ES绑定到Layer上。

  2. iOS平台上的线程模型,采用NSOperationQueue来实现。

  3. iOS平台有一个比较特殊的地方就是如果App进入后台之后,就不能再进行OpenGL ES的渲染操作。

4.1 接下来看一下初始化方法的实现,首先为layer设置属性,然后初始化NSOperation-Queue,并且将OpenGL ES的上下文构建以及OpenGL ES的渲染Program的构建作为一个Block(可以理解为一个代码块)直接加入到该Queue中。

4.2 该Block中的具体行为如下:先分配一个EAGLContext,然后为该NSOperationQueue线程绑定OpenGL ES上下文,接着再创建FrameBufferRenderBuffer

4.3 将RenderBufferstorage设置为UIViewlayer(就是前面提到的CAEAGLLayer),然后再将FrameBufferRenderBuffer绑定起来,这样绘制在FrameBuffer上的内容就相当于绘制到了RenderBuffer上,最后使用前面提到的VertexShaderFragmentShader构造出实际的渲染Program,至此,初始化就完成了。

5.1 然后是关键的渲染方法,这里先判断当前OperationQueueoperationCount的值,如果其数目大于我们规定的阈值(一般设置为2或者3),则说明每一次绘制所花费的时间都比较多,这将导致很多绘制的延迟,所以可以删除掉最久的绘制操作,仅仅保留等于阈值个数的绘制操作。

5.2 首先判定布尔型变量enableOpenGLRendererFlag的值,如果是YES,就绑定FrameBuffer,然 后使用Program进行绘制,最后绑定RenderBuffer并且调用EAGLContextPresentRenderBuffer将刚刚绘制的内容显示到layer上去,因为layer就是UIViewlayer,所以能够在UIView中看到我们刚刚绘制的内容了。

  1. 至于销毁方法,也要保证这步操作是放在OperationQueue中执行 的,因为涉及OpenGL ES的所有操作都要放到绑定了上下文环境的线程中去操作。

  2. 对于UIViewdealloc方法,其功能主要是负责回收所有的资源,首先移除所有的监听事件,然后清空OperationQueue中未执行的操作,最后释放掉所有的资源。

五 AVSync模块的实现

AVSynchronizer类的实现,第一部分是维护解码线程,第二部分就是音视频同步

5.1 维护解码线程

AVSync模块开辟的解码线程扮演了生产者的角色,其生产出来的 数据所存放的位置就是音频队列视频队列,而AVSync模块对外提供的填充音频数据和获取视频的方法则扮演了消费者的角色,从音视频队列中获取数据,其实这就是标准的生产者消费者模型

在最后销毁该模块的时候,需要先将isOnDecoding变量设置为false,然后还需要额外发送一次signal指令,让解码线程有机会结束,如果不发送该signal指令,那么解码线程就有可能一直wait在这里,成为一个僵尸线程。

5.2 音视频同步
  • 音频向视频同步

音频向视频同步,顾名思义,就是视频会维持一定的刷新频率,或者根据渲染视频帧的时长来决定当前视频帧的渲染时长,或者说视频的每一帧肯定可以全都渲染出来。

AudioOutput模块填充音频数据的时候,会与当前渲染的视频帧的时间戳进行比较。

  1. 在阈值范围内,直接填充数据播放
  2. 音频帧比视频帧小,跳帧(加快音频播放速度,或丢弃音频帧)
  3. 音频帧比视频帧大,等待(放慢音频播放速度或填充空数据静音帧)

优点 画面看上去是最流畅的
缺点 音频有可能会加速 (或者跳变)也有可能会有静音数据(或者慢速播放),发生丢帧或者插入空数据的时候,用户的耳朵 是可以明显感觉到的。

  • 视频向音频同步

不论是哪一个平台播放音频的引擎,都可以保证播放音频的时间长度与实际这段音频所代表的时间长度是一致的。

  1. 视频帧比音频帧小,跳帧
  2. 视频帧比音频帧大,等待(重复渲染上一帧或者不进行渲染)

优点 音频可以连续播放
缺点 视频画面有可能会有跳帧的操作,但是对于视频画面的丢帧和跳帧,用户的眼睛是不太容易分辨得出来的。

  • 统一向外部时钟同步

在外部单独维护一轨外部时钟,当我们获取音频数据视频帧的时候,都需要与这个外部时钟进行对齐,如果没有超过阈值,那么就直接返回本帧音频帧或者视频帧,如果超过了阈值就要进行对齐操作。

得出了一个理论,那就是人的耳朵比人的眼睛要敏感得多,我们所实现的播放器将采用音视频对齐策略的第二种方式,即视频音频对齐的方式。

六 中控系统串联各个模块
6.1 初始化阶段

调用AVSync模块放在一个异步线程中来打开连接会更加合理,所以这里使用GCD线程模型,将初始化的操作放在一个DispatchQueue中。首先也是调用AVSync模块的openFile方法,如果可以打开媒体资源连接,则继续初始化VideoOutput对象。

6.2 运行阶段

就是为AudioOutput模块填充数据,并且通知VideoOutput模块来更新画面。

6.3 销毁阶段
  1. 由于音视频对齐策略的影响,整个播放过程其实是由音频来驱动的,所以在销毁阶段肯定需要首先停止音频
  2. 然后停止AVSync模块。
  3. 最后一步应该是停止VideoOutput模块。
  4. 最终再将VideoOutput这个自定义的viewViewController中移除,至此销毁阶段就实现完毕了。
七 总结
  • 输入模块(或者称为解码模块),输出音频帧是AudioFrame,其中的主要数据是PCM裸数据;输出视频帧是VideoFrame,其中的主要数据是YUV420P的裸数据。

  • 音频播放模块,输入是解码出来的AudioFrame,直接就是SInt16表示的sample格式的数据,输出则是输出到Speaker用户能够直接听到声音。

  • 视频播放模块,输入是解码出来的VideoFrame,其中存放的是YUV420P格式的数据,在渲染过程中可使用OpenGL ESProgramYUV格式的数据转换为RGBA格式的数据,并最终显示到物理屏幕上。

  • 音视频同步模块,它的工作主要由两部分组成;第一部分是负责维护解码线程,即负责输入模块的管理;另外一部分是音视频同步,可向外部提供填充音频数据的接口和获取视频帧的接口,以保证所提供的数据是同步的。

  • 中控系统,负责将AVSync模块AudioOutput模块VideoOutput模块组织起来,最重要的就是维护这几个模块的生命周 期,由于其中存在多线程的问题,所以需要重点注意的是,应在初始 化、运行、销毁各个阶段保证这几个模块可以协同有序地运行,同时中 控系统应对外提供用户可以操作的接口,比如开始播放、暂停、继续、 停止等接口。


本文参考音视频开发进阶指南


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,230评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,261评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,089评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,542评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,542评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,544评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,922评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,578评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,816评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,576评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,658评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,359评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,937评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,920评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,859评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,381评论 2 342

推荐阅读更多精彩内容