第11章 一个直播应用的构建(样章)

第11章 一个直播应用的构建

本章将会带着大家走进直播领域,而直播产品的实现也会像大多数产品的实现思路一样,首先进行场景的分析,然后在分析场景的过程中拆分出直播系统的各个模块,并且进行大致的技术选型,最终依次介绍这几个模块的具体实现手段,以及各个实现手段的优劣性,以供读者们根据自己的业务场景去选择具体的实现手段。

11.1 直播场景分析

在直播领域,大致上可以分为两种类型的直播,一种是非交互式直播,另外一种是交互式直播。非交互式直播典型的场景有:2015年9月的反法西斯阅兵直播,像某一些体育直播,这些直播因为都是非交互或者说交互性不太强,所以允许延迟(从视频中的主体发生实际的行为,到这个行为被用户观看到的时间)可以在10s或者10s以上,特点是:源(像阅兵直播、NBA直播、欧冠直播等)比较少,适合做多路转码(用户可以根据网络条件观看超清、高清,标清等多路视频);而交互式直播的典型场景有:秀场直播、游戏直播等,这些直播因为对主播和观众的互动性要求比较高,所以要求延迟要在5s以内,特点是:源(像美女主播、游戏主播)比较多,不太适合做多路转码,中间服务器只作为一个中转的角色。

直播中,传输的介质肯定是网络,网络中传播视频或者音频就需要使用对应的协议,而目前适合直播场景的常用协议有如下几种。

RTMP协议:长连接,低延时(3s左右),网络穿透性差;

HLS协议:http的流媒体协议,高延时(10s以上),跨平台性较好;

HDL协议:RTMP协议的升级版,低延时(2s左右),网络穿透性好。

RTP协议:低延时(1s以内),默认使用UDP作为传输协议。

所以我们应该按照自己的场景来选择协议,如果是非交互式场景下,选择HLS协议会更适合一些,但是交互式场景最好选择HDL协议或者RTMP协议,而RTP协议常用于视频会议中,或者直播场景的连麦中,不会直接应用于一对多的直播场景下。

接下来主要来看一下交互式直播场景下可以拆分为些模块,最基础最核心的应该是推流系统、拉流系统和流媒体服务器(Live Server),这三部分共同组成了整个直播系统的主播端和用户端之间在视频或者音频内容上的交互,整体流程是主播使用推流系统将采集的视频和音频进行编码,并最终发送到流媒体服务器上,而用户端使用拉流系统将流媒体服务器上的视频资源进行播放,整个过程是一种发布者/订阅者(Publisher/Subscriber)的模式,如图11-1所示。

图 11-1

但是作为一个完整的产品,仅仅有这三个模块又是远远不够的,最直观的就是缺少礼物系统(可让观众给喜欢的主播送礼物),礼物系统可以为App提供礼物动效的展示;既然观众能送礼物给用户,就需要有充值功能,所以就必须要有支付系统来提供用户充值、主播提现等服务;在直播过程中,主播想要和观众说话,观众在很短时间内就可以在视频中听到,但是观众想要和主播进行沟通交流,只能靠聊天系统来实现,聊天系统用来建立观众到主播的反馈通道;直播这种行为实际上是一种社交行为,而任何一个直播产品都应该是一种社交产品,所以还需要社交系统,为观众和主播提供长期有效的社交行为(比如,根据用户的关注关系给用户推送关注的主播开播了或者展示关注主播的开播列表以及关注主播的视频回放列表等);而图11-1中除了推流系统和拉流系统之外的四个系统,都需要与服务器进行交互,我们称之为Http Server,即服务器模块。经过上述分析,最终整体结构如图11-1所示。

在接下来的章节中,笔者会逐一介绍这几个系统,但是由于本书的范畴所限,支付系统以及Http server模块不再单独进行讲解,而社交系统又与产品所打造的社区有着密切关系,一般情况下社交系统包括但不限于以下的功能:

  • 第三方登录(包括微博、微信、QQ等);
  • 第三方分享(包括微博、微信、QQ等);
  • 手机号的登录与绑定;
  • 地理位置的使用;
  • 站内关系(关注与粉丝以及自己的Feed列表);
  • 推送策略以及用户端收到推送之后的跳转行为;
  • 后台系统,用于提供给客服、运营人员操作榜单以及推荐用户等功能。

由于其中的部分和本书的核心内容关系不大,所以也不会详细的分析。接下来看几个要重点分析的功能模块。

11.2拉流播放器的构建

本节将基于前面章节中所讲的视频播放器来构建用户端的拉流播放器,在第5章中已经详细地讲解了播放器的结构,它是使用FFmpeg的libavformat模块来处理协议层与解封装层的细节的,并且会使用FFmpeg的libavcodec模块来解码得到原始数据,最终使用OpenGL ES渲染视频,以及使用对应的API来渲染音频。

相比较于录播的实现,直播中的拉流播放器的使用时长(短则几十分钟,长则几个小时)会更长,所占用的CPU资源(观众界面还会有聊天的长连接、动画的展示等)也会更多,所以必须将第10章中讲解的硬件解码器集成进来。再者,由于网络直播中某一些主播由于环境光的因素,或者网络带宽的因素,导致视频不是很清楚,所以要在拉流播放器中加入一个后处理过程,以增加视频的清晰度。这里以增加对比度来作为这个后处理过程的实现,当然在实际的生产过程中可以再加上一些去块滤波器、锐化等效果器。硬件解码器的集成在第10章中已经有过讲解,所以本节将重点放在给播放器加入后处理过程这个工作上。

11.2.1 Android平台播放器增加后处理过程

首先,要想打开网络连接,必须知道媒体源的协议,也要在编译FFmpeg的过程中打开对应的网络协议,比如http、rtmp或者hls等协议。在第10章中讲解的硬件解码器中,解码器解码的目标是纹理ID,为此还建立了一个纹理对象的循环队列用于存储解码之后的纹理对象,整个解码器模块的运行流程如图11-2所示。

图 11-2

图11-2看起来可能稍微有点复杂,下面来逐一解释一下,首先是AVSync模块初始化解码器的过程,解码器会连接远程的流媒体服务器,如果打开连接失败则启用重试策略,重试几次,如果依然没能成功则提示给用户。如果连接成功,则开启Uploader线程,即最右侧橙色部分,Uploader线程是一个OpenGL ES线程,当解码器是软件解码器实例的时候,这个线程的职责就是将YUV数据上传到显卡上成为一个RGBA格式的纹理ID,而当解码器是硬件解码器实例的时候,这个线程的职责就是将硬件解码器解码出来的OES格式的纹理ID转换为RGBA格式的纹理ID。在实现Uploader这个模块的过程中肯定要设定一个父类,然后对应于两个子类(软件解码器与硬件解码器对应的Uploader)分别完成各自的职责。注意这里在为这个线程开辟OpenGL ES上下文的时候,需要和渲染线程共享上下文环境,这样OpenGL ES的对象在这两个线程中才可以共同使用。当Uploader线程开始运行以后,会进入一个循环,循环的一开始先阻塞(wait)住,等待解码线程发送Signal指令再去做上传纹理操作,之后进入下一次循环,也会先阻塞(wait)住,周而复始,完成整个纹理上传工作。

成功开启Uploader线程之后,初始化工作就结束了,等到AVSync模块中的解码线程开始工作后,解码器首先会调用FFmpeg的libavformat模块进行解析协议与解封装(Demuxer),然后调用具体实例(有可能是软件解码器,也有可能是硬件解码器)的解码方法。解码成功之后就给Uploader线程发送一个Signal指令,之后这个解码线程就Wait住,等待Uploader线程处理完这一帧视频帧之后解码线程再继续运行,如图11-2中间部分所示。

待Uploader线程接受到Signal指令之后,就会执行上传(转换)纹理的工作,而在转换成为一个RGBA格式的纹理对象之后,则调用回调函数(在初始化过程中AVSync传递进来的回调函数),来处理这一帧视频帧,并将这一帧视频帧拷贝到循环纹理队列中。虽然将纹理对象拷贝到循环纹理队列中的行为在Uploader线程中实现的,但是相关代码在AVSync中,这也是为了降低各个模块的耦合程度。所以这里就要在AVSync模块要加入视频特效处理器,每当解码器中解码出一帧纹理ID时,就交给视频特效处理器进行处理,待处理完毕之后再将这一帧纹理对象加入到循环纹理队列中。当然,在这里仅仅使用增加对比度作为特效处理器中的作用效果器,读者可以自己将去块滤波器以及锐化效果器也加入到特效处理器中,以增加后处理的效果。

要实现上述功能,需要新建一个LiveShowAVSync的类,继承自类AVSync,并重写父类中的三个回调方法,第一个就是当Uploader线程初始化成功之后的回调方法,代码如下:

void LiveShowAVSynchronizer::OnInitFromUploaderGLContext(EGLCore* eglCore,

int videoFrameWidth, int videoFrameHeight) {

videoEffectProcessor = new VideoEffectProcessor();

videoEffectProcessor->init();

int filterId = videoEffectProcessor->addFilter(0, 1000000 * 10 * 60 * 60,

PLAYER_CONTRAST_FILTER_NAME);

if(filterId >= 0){

videoEffectProcessor->invokeFilterOnReady(filterId);

}

glGenTextures(1, &mOutputTexId);

glBindTexture(GL_TEXTURE_2D, mOutputTexId);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, videoFrameWidth,

videoFrameHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);

glBindTexture(GL_TEXTURE_2D, 0);

AVSynchronizer::OnInitFromUploaderGLContext(eglCore,

videoFrameWidth, videoFrameHeight);

}

如上述代码所示,由于这个回调函数的调用发生在Uploader线程中,并且Uploader线程已经准备好了OpenGL ES上下文,并且也绑定好了OpenGL ES上下文,所以这里就是初始化特效处理器的好时机,同时,还要初始化一个输出纹理ID,因为经过特效处理器处理过后要有一个纹理ID作为输出,我们设定mOutputTexId来接受处理结束之后的纹理ID。

接着来看如何处理一帧视频帧,重写父类的处理视频帧的方法:

void LiveShowAVSynchronizer::processVideoFrame(GLuint inputTexId, int width,

int height, float position){

GLuint outputTexId = inputTexId;

if(videoEffectProcessor){

videoEffectProcessor->process(inputTexId, position, mOutputTexId);

outputTexId = mOutputTexId;

}

AVSynchronizer::processVideoFrame(outputTexId, width, height, position);

}

代码也很简单,如果视频特效处理器存在的话,就拿处理器处理过的纹理ID(mOutputTexId)交由父类拷贝到循环纹理队列中去。最后一个需要重写的方法自然是销毁资源的方法,代码如下:

void LiveShowAVSynchronizer::onDestroyFromUploaderGLContext() {

if (NULL != videoEffectProcessor) {

videoEffectProcessor->dealloc();

delete videoEffectProcessor;

videoEffectProcessor = NULL;

}

if (-1 != outputTexId) {

glDeleteTextures(1, &outputTexId);

}

AVSynchronizer::onDestroyFromUploaderGLContext();

}

因为这个方法的调用也是发生在Uploader线程中的,所以也符合视频特效处理器的销毁方法需求,同时,还要删除掉分配的这个输出纹理ID,最后调用父类的销毁资源方法。

至此,就将后处理过程集成到了播放器中,这样就使得在网络状态下对视频的清晰度会有一个增强的效果。可以看到,这里只是新增了类而没有修改旧的代码就达到了增加一个功能的效果,可见最初有一个合理的架构设计是多么的重要。

除了这个功能的增加,我们还有一个比较重要的适配工作要做,读者可还记得在AVSync模块里定义了三个宏用来控制解码线程的启动和暂停,以及音视频对齐策略?之前定义的宏如下:

#define LOCAL_MIN_BUFFERED_DURATION 0.5

#define LOCAL_MAX_BUFFERED_DURATION 0.8

#define LOCAL_AV_SYNC_MAX_TIME_DIFF 0.05

而这三个值放在网络环境中就需要做适配了,否则就会出现视频的卡顿,并且会因为对齐而影响到视频的整体播放,更改之后的三个宏定义如下:

#define NETWORK_MIN_BUFFERED_DURATION 2.0

#define NETWORK_MAX_BUFFERED_DURATION 4.0

#define NETWORK_AV_SYNC_MAX_TIME_DIFF 0.3

使用这三个宏的给全局变量赋值的地方需要重写父类的initMeta方法,代码如下:

void LiveShowAVSynchronizer::initMeta() {

this->maxBufferedDuration = NETWORK_MAX_BUFFERED_DURATION;

this->minBufferedDuration = NETWORK_MIN_BUFFERED_DURATION;

this->syncMaxTimeDiff = NETWORK_AV_SYNC_MAX_TIME_DIFF;

}

这样一来,本地视频播放器就做好了播放网络视频的适配了,这个拉流播放器也就可以使用了,读者可以参考代码仓库中的源码进行分析,以便加深理解。

11.2.2 iOS平台播放器增加后处理过程

在iOS平台增加了硬件解码器的支持之后,并没有像Andorid平台一样在解码器端进行非常大的改造,而是在渲染端进行了适配,改动之后的结构如图11-3所示。

图 11-3

如图11-3所示,VideoPlayerController这个调度器会带着是否使用硬件解码器的参数来初始化VideoOutput,而在VideoOutput中有一个属性为frameCopier,VideoOutput会根据是否使用硬件解码器而初始化不同类型的FrameCopier,如果使用硬件解码器就实例化FastFrameCopier,而如果没有使用硬件解码器就实例化YUVFrameCopier。当VideoPlayerController从视频队列中取出一帧视频帧交给VideoOutput来渲染的时候,VideoOutput就会调用前面实例化好的FrameCopier将VideoFrame类型的视频帧转化为一个纹理ID,然后VideoOutput就会绑定到displayFrameBuffer上,最后使用DirectPassRender将纹理ID渲染到RenderBuffer上面,也就是渲染到了Layer上,从而让用户可以看到。而在上面的整个渲染过程中,我们要引入视频后处理效果,最好的插入点就是在FrameCopier之后,将FrameCopier处理完的纹理ID交给我们新定义的一个VideoEffectFilter来做视频特效的处理,最终处理结束之后的outputTexId再由原来的DirectPassRender渲染到Layer上面去,将这个新的节点引入到VideoOutput之后,整体结构如图11-4所示。

图 11-4

接下来,就来看一下如何具体地构建这个VideoEffectFilter,首先提供一个prepareRender的方法,完成OpenGL ES相关资源的初始化,要求VideoOutput在OpenGL ES线程中来调用这个方法,代码如下:

– (BOOL) prepareRender:(NSInteger) frameWidth height:(NSInteger) frameHeight;

{

_frameWidth = frameWidth;

_frameHeight = frameHeight;

[self genOutputFrame];

_processor = new VideoEffectProcessor();

_processor->init();

int filterId = _processor->addFilter(0, 1000000 * 10 * 60 * 60,

PLAYER_CONTRAST_FILTER_NAME);

if(filterId >= 0){

_processor->invokeFilterOnReady(filterId);

}

return YES;

}

其中genOutputFrame方法是生成这个类的输出纹理ID与FBO的,至于如何生成一个纹理ID和FBO,并且把这个纹理ID与FBO绑定起来前面已经讲过很多遍,这里就不在展示具体的代码了,接下来就是真正的渲染过程了,代码如下:

– (void) renderWithWidth:(NSInteger) width height:(NSInteger) height

position:(float)position;

{

glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer);

self.processor->process(_inputTexId, position, _outputTextureID);

glBindFramebuffer(GL_FRAMEBUFFER, 0);

}

最后就是释放为做后处理而分配的资源,代码如下:

– (void) releaseRender;

{

if(_outputTextureID){

glDeleteTextures(1, &_outputTextureID);

_outputTextureID = 0;

}

if (_frameBuffer) {

glDeleteFramebuffers(1, &_frameBuffer);

_frameBuffer = 0;

}

if (_processor) {

_processor->dealloc();

delete _processor;

_processor = NULL;

}

}

至此这个VideoEffectFilter类就构建完毕了。接着需要在VideoOutput这个类中将FrameCopier的输出给VideoEffectFilter作为输入,而把VideoEffectFilter的输出给DirectPassRender作为输入。这样渲染过程就集成进了后处理过程,当然目前的后处理过程仅仅是增强对比度的一个效果器,读者也可以将去块滤波效果器,锐化效果器加入到后处理过程中,这样会使得整体播放效果有很大提升。

另外,针对于视频源是网络流的特殊处理如下,第一点是要在VideoDecoder这个模块下给FFmpeg加入超时回调的方法,因为在本地磁盘文件的读取过程中基本不会出现超时的场景,但是由于网络环境过于复杂,所以这里要加入超时方法。在VideoDecoder类中,调用libavformat模块的打开连接之前,可给AVFormatContext设置超时回调函数,代码如下:

AVFormatContext *formatCtx = avformat_alloc_context();

AVIOInterruptCB int_cb = {interrupt_callback, (__bridge void *)(self)};

formatCtx->interrupt_callback = int_cb;

而其中设置的interrupt_callback这个静态方法的回调函数实现如下:

static int interrupt_callback(void *ctx)

{

__unsafe_unretained VideoDecoder *p = (__bridge VideoDecoder *)ctx;

const BOOL isInterrupted = [p detectInterrupted];

return isInterrupted;

}

而在这个静态函数中调用的detectInterrupted方法的实现很简单,就是判断当前时间戳和上一次接收到数据或者开始连接的时间间隔,如果大于超时时间(比如说15s),则返回YES,代表已经超时,否则返回NO。返回YES,则代表FFmpeg中阻塞的调用(比如:read_frame、find_stream_info等)会立即返回。

第二点对于网络流的适配是更改缓冲区大小,由于在本地播放器中设置了一个minBuffer和maxBuffer来作为控制解码线程的暂停和继续的条件,之前定义的宏如下:

#define LOCAL_MIN_BUFFERED_DURATION 0.5

#define LOCAL_MAX_BUFFERED_DURATION 1.0

而在网络中为了避免频繁的卡顿,还需要将控制解码线程暂停和运行的Buffer长度进行网络适配,新增宏定义如下:

#define NETWORK_MIN_BUFFERED_DURATION 2.0

#define NETWORK_MAX_BUFFERED_DURATION 4.0

在拉流播放器中就是用上述的这两个宏定义来确定AVSync模块的缓冲区大小。这样我们就完成了由本地播放器到网络拉流播放器的适配,读者可以参考代码仓库中的源码进行分析,以便于可以深入理解。

11.3 推流器的构建

本节来构建主播端使用的推流工具,当然,也是根据本书前面章节中的视频录制应用进行改动适配。在第7章中,已经构建了一个视频录制器,同时在第8章和第9章为这个视频录制器增添了音频效果处理器和视频效果处理器,对于一个录播应用来讲,已经比较完整了,但是对于直播应用,我们还需要做一些适配工作。首先一块来看一下整体结构,如图11-5所示。

图 11-5

从整体结构来看,录播和直播的区别仅仅在于最终Muxer(图中的Publisher)模块的输出不同,录播是向本地磁盘中输出,而直播是向网络中输出。而网络不同于磁盘的是,有可能会出现网络波动甚至网络阻塞,所以要针对网络这种场景做对应的适配工作。

接下来分析一下复杂的网络环境,读者可以参考图11-6,主播端第一步请求Http Server的开播接口,然后会得到域名形式的推流地址;得到推流地址之后主播端将去做域名解析,将域名解析为实际的IP地址和端口号;使用这个IP地址经过公有网络的各级路由器和交换机,最终找到实际的CDN厂商节点。从拿到IP地址开始,影响整个连接通道的因素就有很多,其中包括主播自己的出口网络、中间的链路状态,以及CDN厂商的机房的节点链路情况等。既然影响连接通道的因素这么多,如果都由开发者自己来解决,显然是不合理的, CDN厂商可以帮开发者解决掉除主播自己出口网速以外的其他部分,当然这也是CDN厂商存在的意义,但是任何一家CDN厂商也没有100%的服务保证性,并且,主播自己的出口网络可能是小运营商,也有可能是教育网络,或者主播有其他设备占用着网络出口带宽,所以不可控性就太多了。读者可以对比图11-6来分析整个网络情况。

图 11-6

综上所述,必须要做两件事情,才可以将视频录制器转换为一个可用的主播推流工具:第一件事情就是如果网络超时,要可以通知给用户重新开播或者切换网络环境;第二件事情是当网络出现抖动的时候,我们要做丢帧来保证整个视频直播的延迟时间,以维持整场直播的交互性。

首先来看第一件事情,网络超时的设置。超时设置应该在Muxer模块来完成,由于Muxer模块使用的是FFmpeg中libavformat模块的封装层和协议层,所以这里设置的超时代码和我们在拉流端设置超时的代码类似,即在分配出AVFormatContext之后,并在打开连接之前,设置超时回调,代码如下:

AVFormatContext* oc;

AVIOInterruptCB int_cb = { interrupt_cb, this };

oc->interrupt_callback = int_cb;

而静态函数interrupt_cb的实现如下:

int RecordingPublisher::interrupt_cb(void *ctx) {

RecordingPublisher* publisher = (RecordingPublisher*) ctx;

return publisher->detectTimeout();

}

这个静态函数中调用了这个RecordingPublisher类中的detectTimeout方法,而在detectTimeout方法中会针对当前时间戳减去上一次发送数据包的时间戳的值进行判断:如果大于我们设定的值(一般设置为5~15s)就返回1,代表超时,停止发送数据包;否则返回0,代表没有超时,可以由FFmpeg的协议层继续发送数据包。而在我们的发送线程中,可以判断,如果发生了超时,则可以回调客户端代码,让客户端代码给用户弹出提示,让用户可以重新开播或者切换网络重新开播。

下面来看网络出现抖动,或者在弱网环境下的丢帧策略。读者可以先参考图11-5,图中有两种队列分别是编码之前的原始数据的队列,和编码之后的编码队列,弱网丢帧策略常见的实现有两种:其中一种是丢弃原始数据队列中未编码的数据帧,另外一种是丢弃掉编码队列中的数据帧。这两种实现各有优缺点,但是无论哪一种实现方式都以“不影响音视频的对齐”为第一准则。接下来分析一下两种丢帧策略的优缺点,丢弃原始数据帧的丢帧策略优点是节省了这部分丢弃帧所占用编码器的资源,并且由于是丢弃的原始数据帧,所以可以在任意时刻丢弃任意的音频视频帧,缺点是增大了直播的延迟时间,因为要保持中间队列有一个阈值;丢弃编码之后数据帧的策略优点是减少了直播的延迟时间,缺点是丢弃的帧白白消耗了编码器资源,并且对于视频帧只要丢帧就丢掉一个GOP或者整个GOP的后半部分,否则会造成观众端不能正常观看视频。

不同的丢帧策略应用在不同的直播场景中,读者可以依据自己产品的场景来选择丢帧策略,笔者在实际开发过程中使用的丢帧策略是:对于视频丢弃的是编码之后的视频帧,对于音频丢弃的是编码之前的原始格式的音频帧。下面来看一下具体的实现。

对于编码后的视频帧进行丢帧,要丢弃只能丢弃一个完整的GOP(或者这个GOP后半部分非参考视频帧),或者这个GOP中剩余的视频帧,因为对于P帧需要参考前面的I帧与P帧才能被解码出来,对于B帧需要双向参考(但是在直播中一般不使用B帧,仅用I帧和P帧)。某一些策略会保留一个GOP中的I帧,但是I帧是一个GOP中容量最大的视频帧;而某一些策略是丢弃GOP中的后半部分的P帧,直到这个GOP中仅剩余I帧的时候,再把I帧丢弃掉。第二种策略其实是一种可取的策略,但是本书为了简单考虑,最终的丢帧策略是:要丢弃就丢掉一整个GOP(如果这个GOP已经发送出去了部分I帧和P帧,则丢掉这个GOP中剩余的视频帧)。如果读者想去实践仅丢弃GOP中的后半部分P帧策略,在后续代码中进行更改也很简单。

丢弃了视频帧之后,为了不影响音画的对齐效果,也应该丢弃掉同等时间的音频数据,但是丢弃的那些视频帧总时长是多少呢?我们不可以仅仅通过fps计算这些视频帧所代表的时长,而应该计算出视频帧每一帧持续的时间到底是多少,必须要精确计算。因为fps对于Camera的影响是在一定时间段范围内,所以在一定时间内连续的一段视频帧数目是可以被限定在这个fps之内的,但是对于某一段小时间段却不能保证满足fps的限制。那如何计算每一帧视频帧的精确时长呢?只能将Camera采集出来的视频帧都打上一个相对时间戳,在编码的时候,要在编码成功第二帧时才把第一帧的duration信息赋值,并把第一帧放入到编码后的视频队列中,代码如下:

bool LivePacketPool::pushVideoPacketToQueue(LiveVideoPacket* videoPacket) {

if (NULL != videoPacketQueue) {

//为了计算当前帧的Duration, 所以延迟一帧放入Queue中

if(NULL != tempVideoPacket){

int packetDuration = videoPacket->timeMills –

tempVideoPacket->timeMills;

tempVideoPacket->duration = packetDuration;

videoPacketQueue->put(tempVideoPacket);

}

tempVideoPacket = videoPacket;

}

return dropFrame;

}

代码中的tempVideoPacket是一个全局变量,代表durtion属性还没有被赋值的视频帧,在被赋值之后,它会被加入到视频队列中。接下来看看,如何丢弃一整个GOP或者GOP中没有发送出去的视频帧,并计算出丢弃的视频帧所占用的总时长,在丢弃之前要给整个队列上锁,执行完操作之后解锁,代码主体如下:

int LiveVideoPacketQueue::discardGOP() {

int discardVideoFrameDuration = 0;

LiveVideoPacketList *pktList = 0;

pthread_mutex_lock(&mLock);

//执行丢帧操作

pthread_mutex_unlock(&mLock);

return discardVideoFrameDuration;

}

执行丢帧操作的逻辑也比较简单,会先判断当前第一个元素是否是关键帧,如果是关键帧的话,则将布尔型变量isFirstFrameIDR设置为true,代码如下:

bool isFirstFrameIDR = false;

if(mFirst){

LiveVideoPacket * pkt = mFirst->pkt;

if (pkt) {

int nalu_type = pkt->getNALUType();

if (nalu_type == H264_NALU_TYPE_IDR_PICTURE){

isFirstFrameIDR = true;

}

}

}

然后循环队列中所有的视频帧,会判断视频帧类型。如果帧类型不是关键帧,则丢弃这一帧,并且把这一帧的时间长度加到丢弃帧时间长度的变量上;如果是关键帧,就先判断是否变量isFirstFrameID为true,如果是true则先置为false,然后丢弃这一帧并将帧长度加到丢弃帧时间长度的变量上,而如果是false,则代表已经删除了一个GOP,就应该退出了。代码如下:

LiveVideoPacketList *pktList = 0;

for (;;) {

if (mAbortRequest) {

discardVideoFrameDuration = 0;

break;

}

pktList = mFirst;

if (pktList) {

LiveVideoPacket * pkt = pktList->pkt;

int nalu_type = pkt->getNALUType();

if (nalu_type == H264_NALU_TYPE_IDR_PICTURE){

if(isFirstFrameIDR){

isFirstFrameIDR = false;

discardVideoFrameDuration += pkt->duration;

relesse(pktList);

continue;

} else {

break;

}

} else if (nalu_type == H264_NALU_TYPE_NON_IDR_PICTURE) {

discardVideoFrameDuration += pkt->duration;

relesse(pktList);

}

}

}

上述方法的调用端,就是当一个编码之后的视频帧要放入队列中之前,当然要判断一下当前视频队列的大小和设置Threshold(阈值)的关系,如果超过了阈值,则说明当前网络发生抖动或者处于弱网环境下,则执行丢帧逻辑。待丢弃完一个GOP之后,以这个丢弃掉视频帧的时间长度参数再去丢弃音频数据,而丢弃的音频帧是原始数据帧,又因为对于PCM队列中每一个元素都是固定长度(暂时设置的是40ms)的一个buffer,所以代码如下:

bool LivePacketPool::discardAudioPacket() {

bool ret = false;

LiveAudioPacket *tempAudioPacket = NULL;

int resultCode = audioPacketQueue->get(&tempAudioPacket, true);

if (resultCode > 0) {

delete tempAudioPacket;

tempAudioPacket = NULL;

pthread_rwlock_wrlock(&mRwlock);

totalDiscardVideoPacketDuration -= (40.0 * 1000.0f);

pthread_rwlock_unlock(&mRwlock);

ret = true;

}

return ret;

}

上述函数的实现比较简单,调用的地方就在原来的音频编码适配器(AudioEncodeAdapter)的getAudioPacket方法中。至此,我们就完成了丢帧策略的实现,主播端的推流工具就由视频录制器改造而成。但是基于此还有很多其他的改进空间,下一章中会继续完善。

11.4 第三方云服务介绍

在开发音视频的App的过程中,不得不和第三方的云服务打交道,因为这一些CDN厂商对于视频的带宽可以提供更加廉价的价格(相比较于IDC的带宽),而对于视频的访问速度也可以提供更加快速的访问通道。无论在录播场景下还是直播场景下,使用CDN厂商都要优于自己在IDC搭建一套存储服务与流媒体服务。

这里首先解释一下CDN的概念,CDN的全称是Content Delivery Network,其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。一般实现手段都是在各处放置节点服务器,节点服务器呈树形结构,用户直接能访问到的是边缘(叶子)节点服务器,如果边缘节点服务器没有用户所访问的内容,就向它的上一级节点服务器去要数据,如果还没有的话(不同厂商提供的级数不同),就去开发者配置的文件实际地址(回源地址)去拉取;如果边缘节点有这个资源的话,那么就直接给到用户,并且在用户选择边缘节点的策略上,CDN厂商也会按照负载均衡、链路调度等服务安排用户就近获取内容,降低网络拥塞,提高用户访问的响应速度。而CDN去源站服务器获取源文件的这个过程称为回源,对于流媒体资源,回源率越小越好,否则源站服务器的I/O将不堪重负。而在日常工作中,不单单最终用户的视频作品会在CDN上存储,甚至我们的图片文件、音频文件,甚至前端工程师的js和css文件都可以存储在CDN上。当然在录播场景下,用户视频作品的存储以及访问,开发者肯定会用到某一些CDN厂商。而最终开发者或者公司和CDN一般会按照几部分服务进行计算资费,最主要的就是网络带宽的费用,这部分的费用要比使用我们自己机房的带宽费用便宜很多,至于存储、转码等服务,则依据开发者自己的使用情况而进行收费。

那么,在直播过程中,第三方的CDN厂商又可以给我们提供哪些服务呢?笔者根据自己接触的CDN厂商,总结了以下主要的几个服务。

直播转发服务:提供快速、稳定的直播转发服务,接受主播端推上来的视频流,并可以转发给所有的订阅者(观众端),一般CDN厂商对于视频会做一些关键帧缓存等优化,可以使得观众端最快速度的看到视频;

直播存档服务:在整个直播过程中的视频都可以被存储起来,以便于客户的产品可以沉淀下视频内容,在后续也可以进行观看这个视频;

直播转码服务:提供多协议、多分辨率、多码率的多路转码服务,一个产品的多个终端可能需求的拉流协议是不同的,比如网页上需要HLS协议的,而客户端需要HDL协议的,或者在一些大型直播以及一些专有的体育赛事直播中需要给用户提供超清、高清、标清等多路视频流;

视频抽帧服务:可以提供可配置时间内(1-10s)抽取一帧图像进行存储,可以提供给客户进行内容审核、实时预览等功能;

推流、拉流客户端SDK:可以提供给产品推流端和拉流端的视频基础服务,可以让客户花更多的时间在自己的产品和社交功能上,但是也有一些缺点,就是当和我们系统中的动画以及一些其他页面跳转等细节出现不兼容性问题的时候会比较麻烦,当然基于产品场景下,对于推流、拉流做相关优化也会很困难,所以有利也有弊,是否采用SDK,读者可以根据自己的业务场景以及产品阶段进行选择。

除了上述基础的服务之外,某一些CDN厂商也会提供其它可编程接口,离线处理接口等服务。而我们最常用的服务以及应用场景如下所示。

直播转发服务:在直播过程中作为中转服务器, 开发者自己的Http服务器会分配这个中间的地址,给推流端下发过去,推流端将视频流推送到这个中转服务器上,而观众端也会到中转服务器(和推流的服务器不一定相同,CDN厂商会提供最优的链路解决方案)上拉取直播流。另外在自己的Http服务器返回推流地址的时候,可以加入防盗链机制(推流地址会使用时间戳加密后进行验证)增加安全性;

直播存档服务:在直播过程中,我们的App会将主播端直播出来的视频实时转码为一个mp4文件存储到CDN上,以便后续可以产生视频回放服务;

直播转码服务:使用这个服务将视频实时转码为HLS的视频流,供网页播放服务使用,一般情况下不会使用到多分辨率以及多码率的服务,如果有一些特殊活动可以提前申请这样的服务;

视频抽帧服务:使用CDN厂商提供的这个服务,每隔5s拉取一帧图像进行展示,以供我们的内容审核人员针对一些违规视频进行处理。

合理地使用CDN厂商给我们提供的服务,可以提升开发效率,缩短开发周期,可以把我们有限的精力投入在产品的打磨上,让我们在自己的细分市场或者垂直领域快速地进行迭代。但是使用CDN厂商也有一定的弊端,比如CDN厂商提供了服务的稳定性,如果这家CDN厂商死掉了,那很有可能导致我们的产品处于不可用状态,所以在实际的开发中,要有多家CDN厂商备选,可以进行热切换,最彻底的方案就是我们自己再搭建一套系统,以便在所有第三方服务都挂掉的时候,也可以保持产品的可用性。具体读者的产品所提供的服务能达到哪一种层级,读者可以自己衡量与分析,笔者在这里只是给出可选的方案。

11.5 礼物系统的实现

对于一个直播App,礼物展示系统的实现是非常重要的,它实现的好坏直接会影响到整个产品的收入情况。我们在考虑礼物系统实现的时候,应该思考以下几个方面的问题:

  • 礼物系统性能怎么样;
  • 礼物系统将来的扩展性如何;
  • 当开发一个新动画的时候,开发成本多少,其中包括开发时间有多少,参与人员有哪几部分组成等。

下面来列举几种常用的实现手段:

第一种手段就是使用各个平台自身提供的API来实现动画,比如iOS平台使用CALayer动画来实现,Android平台使用自己的Canvas来实现。针对于这种实现手段,我们来分析一下以上几个问题,礼物系统的性能在iOS上没问题,在Android上可能要差一些;将来的扩展性并不会太好,将来可能会出现复杂的动画,比如类似碰撞检测的动画就很难实现;当开发一个新动画的时候,开发成本可以说是比较大的,因为需要两个客户端开发人员,还有设计人员,对于设计人员输出的图片尺寸可能也不一样,需要不断的和两端开发人员进行调试,以及适配各种机型。

第二种手段就是使用OpenGL ES来开发一套自己的动画引擎,前期开发成本巨大,需要兼容粒子系统(使用Particle Designer设计出来的配置文件可以直接运行到系统中),最好能兼容设计人员使用AE(全称是After Effect,是Adobe的一款专门设计视频特效的图形处理软件)产出的动画特效。礼物系统的性能没有问题,将来的扩展性主要看最开始自己的设计,但是遇到特殊情况,比如需要路径和碰撞检测的场景很难实现;对于开发成本,由于是跨平台的系统,需要的开发人员不多,但是需要精通OpenGL ES,也就是说,对开发人员的要求相对比较高,而和设计人员的沟通方面成本比较小。

第三种手段使用现有的一些游戏引擎来实现动画,比如Cocos2dX、libGDX等。这里就以最为流行的Cocos2dX为例来看上面提到的几个问题,Cocos2dX使用OpenGL ES来作为绘制引擎,所以效率方面没有问题;Cocos2dX是一款游戏引擎,所以在扩展性方面肯定是最强的,不论是碰撞检测,还是其他场景都比较容易实现;至于开发成本,由于开发语言是C++,所以比较简单,但是需要开发人员学习Cocos2dX的API,因为这是跨平台的一项技术,可见整体的开发成本并不算高,缺点就是引入Cocos2d引擎会增大App的体积。

其他手段,如Airbnb的工程师发布的Lottie项目,这个项目可更简单地为原生项目添加动画效果,它直接支持AE的动画特效,并且支持动画的热更新操作,可以有效地减小App的体积,且支持Andorid、iOS等平台,但是由于这一些技术业界使用的并不是太多,所以笔者也不在本书中详细介绍,等以后这一些技术更加成熟、稳定了之后,大家可以在一块交流。

下面将基于上述的分析为大家介绍如何使用Cocos2dX为应用增加动画特效。首先会介绍一下Cocos2dX的项目在Android和iOS设备上运行原理,然后会介绍Cocos2dX的关键API,最后利用这一些API实现一个动画。

11.5.1 Cocos2dX项目的运行原理

如何构建项目这里就不做过多的介绍了,大家可以根据官方文档来进行构建,本节重点介绍的是Cocos2dX项目在Android和iOS平台上如何运行,如果读者面前有开发环境的话,可以打开代码仓库中的Android工程或者iOS工程跟随笔者进行分析。

1. Android项目的运行

首先来看Android工程,这里要使用到Cocos2dX项目提供的jar包以及我们自己编写的so库。首先配置jar包,可在build.gradle中进行配置,至于so库,可暂且假设已经编译出来了,后续的关键API详解章节会详细介绍如何编译so库。前面笔者提到过Cocos2dX也是基于OpenGL ES引擎进行绘制的,所以在Android上需要使用GLSurfaceView作为Cocos2dX的绘制目标,而jar包里面提供的类Cocos2dxGLSurfaceView就是我们要使用的GLSurfaceView了。首先创建出这个View对象:

Cocos2dxGLSurfaceView glSurfaceView = new Cocos2dxGLSurfaceView(this);

然后给这个GLSurfaceView设置EGL的显示属性,并设置Renderer为jar包里面提供的专有类Cocos2dxRenderer:

mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer());

再来看这个Cocos2dxRenderer内部具体的关键生命周期方法onSurfaceCreated,这个方法里面的第一行代码就调用了nativeInit方法,而这个Renderer的nativeInit方法最终会调用的Native层,这个类对应的Native层的源码文件是javaactivity-android.cpp,虽然不同的版本实现不同,但不论是在JNI_OnLoad方法中,还是在nativeInit方法中,都可以调用到以下这个方法:

cocos_android_app_init

这个方法会和so库中的main.cpp文件的实现连接起来,代码如下:

void cocos_android_app_init (JNIEnv* env, jobject thiz){

LOGD(“cocos_android_app_init”);

AppDelegate *pAppDelegate = new AppDelegate();

}

这样就可以让类Application的单例引用指向我们自己写的APPDelegate(继承自Application类)了。而在接下来的nativeInit方法中,会给Director设置GLView,代码如下:

director->setOpenGLView(glview);

这个GLView是一个接口,Cocos2dX在Android和iOS平台有各自的实现,分别完成一些平台相关的操作,比如viewPort、getSize、SwapBuffer等操作,面向接口编程的好处显而易见,正是因为这种设计才可以让Cocos2dX可以跨平台运行。在nativeInit方法中有一个最关键的调用,它能让整个引擎运行起来,方法如下:

cocos2d::Application::getInstance()->run();

上述的流程会让整个引擎委托给我们书写的类来完成动作。而具体在APPDelegate中是如何实现的,会在11.5.2节继续讲解。而在这个Renderer的生命周期方法onDrawFrame中会调用到nativeRender方法,而nativeRenderer方法中也会调用到Native层去,在Native层中可以看到如下调用:

cocos2d::Director::getInstance()->mainLoop();

而mainLoop方法是Director这个类中要绘制内部所有场景(Scene)的地方,这样就可以使整个动画不断地绘制出来。

综上所述,可依靠GLSurfaceView内部的渲染线程调用Renderer的方法onDrawFrame将整个渲染过程跑起来。至于上面提到的Cocos2dX的入口类Director,以及关键API,在后续的章节会继续介绍。

2. iOS项目的运行

将iOS项目运行起来之后,可以先找到源码文件main.m,这个文件是整个App的入口类,可以看到,这里面将AppController这个类作为整个App的生命周期方法的代理类。在这个类中首先声明了一个变量如下:

static AppDelegate s_sharedApplication;

声明这个变量的目的是让Cocos2dX的Application入口交由APPDelegate这个类,由于Application是单例模式设计的,而APPDelegate又继承自Application这个类,从而达到了委托给APPDelegate这个类的目的。下面看到这个类中的启动方法:

– (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:

(NSDictionary *)launchOptions;

在这个启动的方法中,首先取出Application,然后设置OpenGL上下文的属性,接下来利用提供的CCEAGLView构造出一个UIView,并将这个View以subView的方式加到ViewController中,最终给Director设置这个构造出来GLView,以及调用Application的run方法,代码如下:

cocos2d::GLView *glview = cocos2d::GLViewImpl::createWithEAGLView(eaglView);

cocos2d::Director::getInstance()->setOpenGLView(glview);

cocos2d::Application::getInstance()->run();

在这个run方法中就会调用到Cocos2dX引擎定义在AppDelegate中的生命周期方法了,然后在源码中可以发现run方法中最终会调用Director的startMainLoop:

[[CCDirectorCaller sharedDirectorCaller] startMainLoop];

在这个方法中,会使用CADisplayLink来做一个定时器,按照设置的fps信息调用OpenGL ES的渲染操作,而关键的OpenGL ES的操作都在CCEAGLView中实现,在Director中的关键操作(比如SwapBuffer)则会委托给GLView来完成,这样一来,它们就又到达了CCEAGLView这个类中,从而让整个Cocos2dX引擎实现了在iOS平台的运行。相比较于Android平台,iOS平台的运行原理是比较简单的,大家可以理解一下Cocos2dX是如何通过面向接口编程达到实现跨平台特性的。

11.5.2 关键API详解

本节将介绍Cocos2dX里面的关键API,不过,这里不再区分平台,而是使用一套跨平台的代码。对于Cocos2dX引擎来讲,最重要的是Director类,就像它的名字一样,它是整个游戏或者动画的导演,控制着内部所有场景(Scene)的渲染,可以进行显示、切换等操作,对于场景,大家可以将其理解为一个界面,类似于Android里面的Activity,或者iOS里面的ViewController,在这个场景中可以有很多图层(Layer),而每一个图层就类似于Photoshop中图层的概念,所有上述的这些对象共同构成了Cocos2dX引擎。

接下来读者可以看到上一节提到的AppDelegate中,这个AppDelegate就是Cocos2dX引擎委托给开发者的程序入口,而这个AppDelegate也必须要继承自cocos2d:: Application,并重写这里面的如下生命周期方法。

当应用程序启动的时候会调用方法:applicationDidFinishLaunching;

当应用程序进入后台的时候会调用方法:applicationDidEnterBackground;

当应用程序回到前台的时候会调用方法:applicationWillEnterForeground。

由于iOS平台不允许App进入后台之后还使用OpenGL ES进行渲染,并且进入后台之后也没必要在为用户展示动画,所以当应用程序进入后台的时候,应该调用停止动画的方法:

Director::getInstance()->stopAnimation();

调用了上述方法之后,Director中的渲染行为就不会再被触发,内部实现会把一个invalid的变量设置为true,而在mainLoop方法中,待判断出这个变量是true之后,Director就不会去渲染内部的场景了。而当App又重新回到前台的时候,则应该继续启用动画:

Director::getInstance()->startAnimation();

上述方法的内部实现又会把invalid这个变量设置为false,而在Director内部的mainLoop就会继续渲染内部的场景,用户就可以继续看到动画了。接下来看一下最重要的生命周期方法applicationDidFinishLaunching,这个方法是Cocos2dX引擎留给开发者设置参数与绘制操作等程序入口的地方。先来看一下设置Director的代码部分:

auto director = Director::getInstance();

director->setDisplayStats(false);

director->setAnimationInterval(1.0 / 45);

director->setClearColor(Color4F(0, 0, 0, 0));

首先第一步得到Director的实例,然后将显示fps状态的开关关闭掉,接下来设置fps,这里设置的是一秒钟45帧的帧率,最后一行代码是设置背景颜色,其实这个颜色是每次绘制的最开始使用glClearColor时所用的颜色。由于设备分辨率具有多样性,而设计人员在调整动画效果或者游戏效果的时候也只会设计一个标准分辨率下的效果,因此在适配不同分辨率的机器在Cocos2dX中的实现如下:

static cocos2d::Size designResolutionSize = cocos2d::Size(480, 320);

static cocos2d::Size smallResolutionSize = cocos2d::Size(480, 320);

static cocos2d::Size mediumResolutionSize = cocos2d::Size(1024, 768);

auto glview = director->getOpenGLView();

if(!glview) {

glview = GLViewImpl::create(“changba-cocos”);

director->setOpenGLView(glview);

}

glview->setDesignResolutionSize(designResolutionSize.width,

designResolutionSize.height, ResolutionPolicy::NO_BORDER);

Size frameSize = glview->getFrameSize();

if (frameSize.height > smallResolutionSize.height){

director->setContentScaleFactor(MIN(mediumResolutionSize.height/

designResolutionSize.height, mediumResolutionSize.width/

designResolutionSize.width));

}else{

director->setContentScaleFactor(MIN(smallResolutionSize.height/

designResolutionSize.height, smallResolutionSize.width/

designResolutionSize.width));

}

可以看到,首先会取出Director的绘制目标GLView,然后会给这个GLView设置进入原始的分辨率,并对比GLView的大小,给Director设置一个Scale系数,而Cocos2dX在实际的绘制过程中,会使用这个Scale系数进行屏幕分辨率的适配。

完成了对Director的设置过程之后,接下来就来实例化一个场景,让Director来显示这个场景。

auto scene = AnimatinScene::create();

director->runWithScene(scene);

可以看到这里面所有的类型都是auto类型的,代表是自动回收对象,而最后一行代码就是告诉Director运行HellWord这个场景。

对AppDelegate的介绍就到这里了,接下看一下HelloWord这个场景是如何来构建的。要创建一个场景必须继承自cocos2d::Scene,然后重写方法init,因为Scene里面默认的create方法是调用了init方法,所以只有重写init方法才可以在场景的创建过程中完成我们的逻辑。

bool AnimationScene::init() {

if (!Scene::init()) {

return false;

}

auto keyBoardLayer = KeyBoardLayer::create();

addChild(keyBoardLayer, 1, 1);

return true;

}

第一行代码就调用父类的init方法,然后创建出KeyBoardLayer,并调用addChild方法将这个Layer加到我们的场景中,而这个KeyBoardLayer就是设置的一个菜单Layer,上面可以增加动画按钮与退出按钮,点击不同的按钮有不同的行为。

接下来看KeyBoardLayer的内部实现。首先Layer要继承自cocos2d::Layer,然后要重写init方法,在init方法中需要调用父类的初始化方法,代码如下:

bool KeyBoardLayer::init() {

//1:调用父类的初始化

if (!Layer::init()){

return false;

}

Size visibleSize = Director::getInstance()->getVisibleSize();

Vec2 origin = Director::getInstance()->getVisibleOrigin();

//2:增加关闭按钮

//3:增加动画按钮

}

可以看到代码分为三部分,第一部分是调用父类的初始化方法,然后取出屏幕的宽度与起始点位置,以便于后续添加按钮来及算位置,接下来第二部分是给这个Layer增加一个关闭按钮,代码如下:

auto closeItem = MenuItemImage::create(“CloseNormal.png”, “CloseSelected.png”,

CC_CALLBACK_1(KeyBoardLayer::menuCloseCallback, this));

closeItem->setPosition(Vec2(origin.x + visibleSize.width –

closeItem->getContentSize().width/2 ,

origin.y + closeItem->getContentSize().height/2));

auto menu = Menu::create(closeItem, NULL);

menu->setPosition(Vec2::ZERO);

this->addChild(menu, 1);

可以看到,这里选择了两张图片分别作为这个菜单项的普通状态和选中状态,点击的监听方法是本类的menuCloseCallback方法,然后设置位置,并且加入到创建的Menu里面去,最终将这个Menu加入到这个Layer中,而menuCloseCallback方法的实现如下:

void KeyboardLayer::menuCloseCallback(Ref* pSender) {

Director::getInstance()->end();

}

可以看到,直接调用Director的end方法可结束整个Cocos2dX的绘制。那接下来,看Layer的init方法中的第三部分,添加一个动画按钮,代码如下:

auto animationBtn = Label::createWithTTF(“show”, “fonts/Marker Felt.ttf”, 10);

animationBtn->setAnchorPoint(Point(0, 0));

auto listener = EventListenerTouchOneByOne::create();

listener->setSwallowTouches(true);

listener->onTouchBegan = [] (Touch *touch, Event *event) {

if (event->getCurrentTarget()->getBoundingBox().

containsPoint(touch->getLocation())) {

Scene* scene = Director::getInstance()->getRunningScene();

AnimationScene* animation = (AnimationScene*) scene;

animation->showAnimation();

return true;

}

return false;

};

Director::getInstance()->getEventDispatcher()->

addEventListenerWithSceneGraphPriority(listener, animationBtn);

animationBtn->setPosition(Vec2(origin.x + 0, origin.y));

this->addChild(animationBtn, 1);

虽然这里称之为增加了一个按钮,实际上使用的是Cocos2dX提供的一个Label控件,首先加载一个字体,然后按照文字(show)与字体大小(10)创建出一个label,并创建一个监听事件,这个监听事件被触发的时候会取出当前Director运行的场景,并调用showAnimation方法,接下来将这个label绑定这个事件,最后给这个label设置位置,并且加到Layer中去。

至此关键的API也已经介绍得差不多了,而具体与动画相关的API将会在下一节进行介绍,并在下一节会实现一款动画。

11.5.3实现一款动画

本节会带着大家实现一个亲吻的动画展示,首先来看一下有所有帧组成的一张大图片,如图11-7所示。

图 11-7

可以看到,这张图片是将序列帧图像合并在一起得到的,接着来看一下将整张图片裁剪成为动画帧序列的plist配置文件,如图11-8所示。

图 11-8

这个plist配置文件描述了每一帧图片应该在整张图片的位置以及旋转角度,Cocos2dX引擎可以解析这个plist配置文件并结合原始图片,最终形成序列帧。而设计人员如何生成plist配置文件以及合并整张图片呢?答案是使用TexturePacker这个工具,这个配置文件也是可以被大部分的游戏引擎所解析的,其中包括Cocos2dX、libGDX、Unity3D等。当设计人员利用AE开发完动画之后,然后导出为png序列图,之后在使用TexturePacker制作成为plist配置文件与大图的形式,然后提供给开发者进行使用。接下来就看一下如何利用整张图片与这个配置文件完成动画的展示。

在Cocos2dX中,每一个能运动的物体都可以理解为一个精灵,那我们先使用Cocos2dX提供的精灵缓存类来解析出所有的序列帧,代码如下:

SpriteFrameCache * cache = SpriteFrameCache::getInstance();

cache->addSpriteFramesWithFile(“el_kiss.plist”);

将plist的配置文件解析完成之后,所有的帧序列都存在与缓存中了,接下来就利用名字创建出一个精灵对象,代码如下:

auto kissSprite = Sprite::createWithSpriteFrameName(“el_kiss00.png”);

int randomY = random(170.f, screenHeight – 80.f);

kissSprite->setPosition(screenWidth + 30, randomY);

kissSprite->setOpacity(255);

this->addChild(kissSprite);

可以看到代码中先使用第一张图片创建出了一个精灵对象,然后设置了位置与透明属性,由于这个位置是画到屏幕的右侧还要再加30个像素的地方,所以暂时看不到,最后将这个精灵加入到这个场景中。接下来就来为这个精灵安排动画,动画分为三部分,第一部分是从屏幕右侧移动到屏幕中间,第二部分是重复3次整个序列帧动画,第三部分是重新移出到屏幕右侧。下面先来看第一部分从屏幕右侧移动到屏幕中的看得见的位置,代码如下:

auto kissFirstStepMoveAction = MoveTo::create(0.3,

Vec2(screenWidth – 200, randomY));

auto kissFirstStepEaseOutAction = EaseOut::create(kissFirstStepMoveAction, 2);

auto kissFirstMoveTargetAction = TargetedAction::create(kissSprite,

kissFirstStepEaseOutAction);

代码中的第一行定义了一个移动的动画,相当于从原来的位置历经0.3s的时间移动到屏幕宽度减去200的位置(纵坐标不变,依然是randomY),然后使用EaseOut封装一下,主要是为了将匀速的运动变成非匀速的运动,最终使用TargetedAction在这个精灵上创建出这个动作。接着来看第二部分的动画,代码如下:

//1:在缓存中拿出所有精灵帧

Vector<SpriteFrame *>animFrames(11);

char str[100] = {0};

for(int i = 0;i < 11 ;i++) {

sprintf(str, “el_kiss%02d.png”,i);

SpriteFrame *frame = cache->getSpriteFrameByName(str);

animFrames.pushBack(frame);

}

//2:创建Animation

auto animation = Animation::createWithSpriteFrames(animFrames, 1.0 / 11, 3);

//3:构建Action

auto kissAnimationAction = Animate::create(animation);

auto kissAnimationTargetAction = TargetedAction::create(kissSprite,

kissAnimationAction);

可以明显看到该段代码分为三部分,第一部分在精灵缓存池中拿出所有的精灵帧放入到一个数组中,第二部分利用这个数组中的精灵帧和延迟时间以及循环次数创建出Animation对象,第三部分就利用这个Animation对象构造出动作。接着来看最后一部分的动画,代码如下:

auto kissLastStepMoveAction = MoveTo::create(0.2,

Vec2(screenWidth + 30, randomY));

auto kissLastStepEaseInAction = EaseIn::create(kissLastStepMoveAction, 2);

auto kissLastMoveTargetAction = TargetedAction::create(kissSprite,

kissLastStepEaseInAction);

其实这个和第一部分的动画正好相反,是向屏幕的右侧移动出去,使用EaseIn将匀速运动封装为非匀速运动。最后将这三部分动画封装到一个序列动作中,并在结束的时候将这个精灵对象移除掉,之后将这个序列动作放入到场景中执行,代码如下:

auto sequence = Sequence::create(kissFirstMoveTargetAction,

kissAnimationTargetAction, kissLastMoveTargetAction,

CallFunc::create(CC_CALLBACK_0(Node::removeFromParent, kissSprite)),

NULL);

this->runAction(sequence);

至此,showAnimation方法就实现好了,这个方法完成了动画的展示,读者可以参考代码仓库中的源码进行分析,以便于深入理解。

本节对于礼物系统的实现就结束了,当然,整个礼物系统还需要指令控制系统才能构建完成,准确来讲本节只是介绍了礼物的动画展示部分,那么在接下来的章节中,会实现一个聊天系统,礼物系统中送礼物的命令也会在聊天系统中作为一个指令发送出去。

11.6 聊天系统的实现

聊天系统也有很多手段可以实现,在直播App中使用的聊天系统不单单用于聊天,还会作为这个直播房间的指令控制系统。那指令控制系统都包含了哪些指令呢?比如主播要踢出某一个观众,或者要禁言某一个观众,观众给主播赠送了一个礼物等,都属于一条条的控制指令,其实真正的聊天内容也可以看做一个指令,就是聊天指令。而实现手段也有很多种,其中最常用的就是WebSocket协议,本节将详细介绍如何利用WebSocket来实现指令控制(或者说是聊天系统)。

WebSocket API是下一代客户端-服务器的异步通信方法,是HTML5规范中替代AJAX的一种新技术。现在,很多网站为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式有很明显的缺点,即浏览器需要不断地向服务器发出请求,然而HTTP request的header是非常长的,里面包含的数据可能只是一个很小的值,这样会占用很多的带宽和服务器资源。而比较新的技术去做轮询的技术是Comet,使用了AJAX。这种技术虽然可达到双向通信,但依然需要发出请求,而且在Comet中,普遍采用了长链接,这也会大量消耗服务器带宽和资源。面对这种状况,HTML5定义了WebSocket协议,能更好的地节省服务器资源和带宽并且能实时通信。它实现了浏览器与服务器全双工通信(full-duplex)。在WebSocket API中,浏览器和服务器只需要要做一个握手的动作,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以进行数据互相传送。但是它不单单仅适用于Web端,在客户端使用起来也非常方便,一般使用在客户端会维护一个WebSocket对象,该对象可以调用发起连接、发送消息、关闭连接的方法,同时要为这个对象绑定以下四个事件。

onOpen:连接建立时触发;

onMessage:收到服务端消息时触发;

onError:连接出错时触发;

onClose:连接关闭时触发;

而在这四个事件中,开发者可以执行自己的操作,下面就分别来介绍如何在在Android和iOS客户端使用WebSocket技术实现简单的聊天系统。

11.6.1 Android客户端的WebSocket实现

Android客户端比较常用的WebSocketClient有:autobahn、AndroidAsync、Java-WebSocket,笔者使用的是autobahn,读者可以按照自己的使用场景来选择,其实原理都一样,现在就来看一下如何使用autobahn实现一个聊天系统。

首先来看实例化WebSocket对象与发起连接的实现,代码如下:

WebSocket mConnection = new WebSocketConnection();

String wsuri = “ws://echo.websocket.org”;

mConnection.connect(wsuri, new WebSocketConnectionHandler() {

@Override

public void onOpen() {

}

@Override

public void onClose(int code, String reason) {

}

@Override

public void onTextMessage(String payload) {

}

@Override

public void onBinaryMessage(byte[] payload) {

}

@Override

public void onRawTextMessage(byte[] payload) {

}

});

可以看到,在对地址”ws://echo.websocket.org”发起连接时,需要传递进去一个回调接口,当打开连接成功的时候会回调onOpen方法,用来提示用户已经连接成功,并且开发者也会开始发送Ping命令,以保持心跳连接;如果打开连接失败,回调onClose方法,开发者可以在这里尝试重试策略,或者提示用户连接失败;当收到消息的时候,如果是字符串类型的消息,则回调方法onTextMessage,如果是二进制类型的消息则回调onBinaryMessage方法。

接下来看一下发送Ping消息,直接调用WebSocket对象的sendPing方法,如果发送实际的消息就调用sendTextMessage方法,而在最终关闭连接的时候,调用disconnect方法就可以了。就这么简单,就可以利用autobahn库使用WebSocket完成聊天系统了,当然界面层还需要开发者自己去搭建。

11.6.2 iOS客户端的WebSocket实现

iOS客户端实现WebSocket使用得最多的就是SocketRocket这个第三方库,大家可以在github上下载到这个库,当然也可以直接在代码仓库中拿到源码。把这个库的目录拖到项目中去之后,还需要为项目添加一个libicucore.tbd的库,然后引入SRWebSocket的头文件,代码如下:

#import “SRWebSocket.h”

之后让自己的ViewController实现头文件中的协议SRWebSocketDelegate,这时需要重写以下方法:

– (void)webSocketDidOpen:(SRWebSocket *)webSocket {

}

– (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error {

}

– (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message {

}

– (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code

reason:(NSString *)reason wasClean:(BOOL)wasClean {

}

协议里面定义的这四个方法,分别对应前面介绍的四个事件,它们分别在连接成功的时候回调方法webSocketDidOpen,可以提示给用户连接成功以及取消掉Loading框,并同时启动一个定时器,定时给服务器发送ping的消息,以保证自己处理存活状态;连接失败的时候回调方法webSocket:didFaileWithError,开发者可以进行重连策略,如果不进行重连,应该提示给用户连接失败;接受到消息的时候回调方法webSocket:didReceiveMessage。由于可以传递二进制的消息,所以开发者可以判断参数中的message类型,再去做自己的处理;并且可在连接最终关闭的时候回调方法webSocket:didCloseWithCode:reason:wasClean,开发者可以根据关闭原因去尝试重连,并记录错误原因。

而真正的实例化WebSocket对象以及发起连接操作也很简单,代码如下:

NSString *url = “ws://echo.websocket.org”;

SRWebSocket *webSocket = [[SRWebSocket alloc] initWithURLRequest:

[NSURLRequest requestWithURL:[NSURL URLWithString:url]]];

webSocket.delegate = self;

[webSocket open];

如果要发送消息,则调用WebSocket的send方法,最终退出界面的时候可以调用close方法来关闭掉连接。

当然,要想真正运行一个聊天室,还需要有一个服务器的支持,可以使用WebSocket开源网站提供的服务器地址:ws://echo.websocket.org。若要想自己搭建一个WebSocket服务器,可使用Java-WebSocket库写一个JavaSE的程序,用于将发布者的消息转发给所有的订阅者。如果想要真正部署到WebSever上,可以使用Tomcat容器来跑一个JavaEE的Servlet,这个Servlet可以使用javax.websocket包中WebSocket相关的类来构建一个转发程序,从而将发布者的消息转发给所有订阅者。在此就不展示了,读者可以去代码仓库中看到所有的源码。

11.7 本章小结

在实现了上述所有的模块之后,最终构建的直播系统如图11-10所示。

图 11-10

如图11-10所示,首先来看主播端(Anchor),主播要想开播先去请求调度中心的开播接口,调度中心会返回两个地址,一个是流媒体中心的地址,一个是指令控制中心的地址,然后主播会先去拿着流媒体中心地址去连接Live服务器,连接成功之后就可以推流了;接着拿着指令控制中心的地址去连接WebSocket服务器,连接成功之后就可以接受到观众进入房间、聊天、礼物等指令,也可以发送出禁言、踢人等指令。再来看看观众端(Audience),为了加快用户的首屏时间(点击进入某个主播的的房间开始,到看到主播的视频的时间称为首屏时间),在系统中所有展示房间的位置都会冗余这个主播的流媒体地址字段,所以不用请求HttpServer就可以直接观看这个主播的直播,但是为了可以继续和主播发生聊天、送礼物等交互行为,并且验证自己的身份是否合法(可能被主播禁言或者踢掉了),还要请求一下HttpServer拿到指令控制中心地址,然后去连接WebSocket服务器,如果连接成功就可以接收到指令以及发送对应的指令。这样通过这些模块的共同交互就构建出了一个可用的直播系统,但是要想让这个直播系统表现得更好,还有一些细节需要处理,比如在弱网环境下主播端的处理,加快拉流端的首屏时间,两端利用统计数据来帮助我们完善整个系统的迭代更新等,所以在接下来的章节中会讨论一下直播应用中的关键处理。

发表评论

电子邮件地址不会被公开。 必填项已用*标注