IOS音视频(四十五)HTTPS 自签名证书 实现边下边播

@[TOC](IOS音视频(四十五)HTTPS 自签名证书 实现边下边播)

1. 边下边播概述

由于JimuPro相册里面获取视频,需要将视频全部下载到本地后才能播放,如果视频文件很大,则用户需要等待很长时间才能看到视频,这种体验效果不太友好,针对这个问题,需要IOS app端实现边下边播功能,使用一份数据流,完成观看视频的同事将视频保存到本地,等视频播放完成后,视频也就下载到了本地。下载完成后的视频格式是.mp4格式,导出来可以直接播放。当用户第二次观看次视频时,将不从机器人端获取视频,直接读取本地缓存的视频,也就是离线也可以观看。

实现边下边播的方式,可以节省数据流量,实时观看到机器人端录制的视频,可以拖拽的方式观看。

这个功能满足以下需求:

  • 支持正常播放器的一切功能,包括暂停、播放和拖拽。可以播放本地缓存的视频,也可以实时播放机器人端录制的视频。
  • 如果视频加载完成且完整,将视频文件保存到本地cache,下一次播放本地cache中的视频,不再请求网络数据。
  • 如果视频没有加载完(半路关闭或者拖拽),下次播放时,先从缓存中播放已经缓存的视频,并同时开启下载功能,从上次的视频末尾继续下载剩下的部分。
  • 由于机器人端采用HTTPS + 自签名证书的方式,实时播放视频需要解决证书信任问题。

2. 边下边播实现方案

  • IOS客户端实现边下边播的方案有很多,目前我研究的找到3种解决方案。下面将详细介绍3种方案的实现原理。由于JimuPro里面已经用到了开源的播放器:VGPlayer。这个播放器里面基本上实现了方案三的细节问题。只是没有实现HTTPS 自签名证书认证的问题。

  • IOS项目中我推荐使用第三种方案实现边下边播功能。

2.1 方案一

  • 通过解析mp4的格式,将mp4的数据直接下载并写入文件,然后让播放器直接播放的是本地的视频文件;

此方案是先下载视频到本地文件,然后把本地视频文件地址传给播放器,播放器实际播放的是本地文件。当播放器的播放进度大于当前的可播放的下载缓存进度,则暂停播放,等缓存到足够播放时间之后,再让播放器开始播放。这种方案的下载方式是与播放器完全没有关系的,只是顺序的将服务器下发的视频数据写入本地文件,然后让播放器来读取数据。

先下载然后直接播放本地文件

以mp4文件为例,通过解析mp4的格式,将mp4的数据直接下载并写入文件,然后让播放器直接播放的是本地的视频文件;如下图:


将mp4的数据直接下载并写入文件

这种方式虽然能够满足缓存播放这个需求,但是会产生很多问题,例如视频下载到本地,下载多少才可以把本地文件作为视频源传给播放器即视频开启播放速度;播放的速度大于下载速度的话,该怎么办?如果播放器seek到文件没有缓存的位置,应该怎么处理?对于视频关闭之后,第二次进入如何知道已经下载了多少?等等问题。

目前的已有解决方案是,当缓存到500kb才把缓存的地址传给播放器,视频文件小于500kb则下载完之后再播放,起播慢(需要改进)。当下载进度比播放进度多5秒的数据量才让播放器播放,不然的话就暂停。如果seek到没有缓存的地方就切换到网络上停止当前的下载,浪费一些流量。每次下载都会保存一份配置文件,来保存是否下载完成,没下载完成则第二次根据当前缓存文件大小,重新开始顺序下载。

总的来说第一种方案有如下缺点:

  1. 用户播放视频的时候可能等待的时间较长(起播
  2. 流量浪费(seek之后会播网络流,停止下载)
  3. 需要太多控制视频播放的逻辑来进行辅助,与播放器代码耦合严重。
  4. seek之后切源会耗时,每次seek比较慢

2.2 方案二

  • 使用的本地代理服务器的方式:
    在服务器端(机器人端)支持分片下载的方式下,APP内置一个HTTPServer代理服务器,代理服务器实现将数据缓存到本地,同时App的播放器之间重代理服务器获取播放数据。这种实现方式比较复杂一点,如果处理不好,容易导致crash的问题。

这个代理服务器也可以做在机器人端,一个接口用于播放,一个接口用于下载。

代理服务器方式实现边下边播

使用 HTTPServer,在本地开启一个 http 服务器,把需要缓存的请求地址指向本地服务器,并带上真正的 url 地址。HTTPServer 不管我们有没有使用缓存功能,都要在应用打开的时候默默开启,对APP性能是一大损耗。并且我们引入 HTTPServer 库也会增加一些包体积。

2.2.1 技术要点

此方案的特点如下:

  1. 通过代理服务器,从socket截取播放器请求数据;
  2. 根据截取的range信息,从网络服务器请求视频数据;
  3. 视频数据写入本地文件,seek后可以从seek位置继续写入并播放;
  4. 边下边播,加快播放速度;
  5. 与播放器逻辑完全解耦,对于播放器只是一个地址

本方案是在播放器与视频源服务器之间加一层代理服务器,截取视频播放器发送的请求,根据截取的请求,向网络服务器请求数据,然后写到本地。本地代理服务器从文件中读取数据并发送给播放器进行播放. 如下图所示:


边下边播-代理服务器模式流程

如上图,具体流程细节如下:

  1. 启动本地代理服务器。
  2. 视频源地址传给本地代理服务器。
  3. 将视频源地址转换成本地代理服务器的地址作为播放器的视频源地址。
  4. 播放器向本地代理服务器发送请求。
  5. 本地代理服务器截取这个请求,再根据解析出来请求的信息向真正的服务器发起请求。
  6. 本地代理服务器开始接受数据,写入文件并将文件数据再返回到播放器。
  7. 播放器接收到这些数据之后播放。
  8. seek之后重新进行以上步骤。

上面流程主要描述了代理服务器实现的实时播放流程,下面重点探讨一下代理服务器的下载流程。

  • 下载流程实现

考虑到播放视频的时候,用户会拖动进度条进行seek,而此时需要从用户拖动的位置进行下载,这样会让视频文件产生许多的空洞,如下图所示:


图1-seek文件

为了节省流量,只会下载文件中没有数据的部分,也就是上图1蓝色的部分。因此需要存储下载的片段信息。目前采用的数据结构如下所示:

fragment = [start,end];
array = [fragment 0,fragment 1,fragment 2,fragment 3];
  1. 其中fragment指的是下载的片段,start指的是片段开始的位置,end为片段的结束位置。
  2. array指的是存储fragment的数组,数组中的fragment是依靠start从小到大来来插入到数组中的,保证了数组的有序性。
  3. 下载的片段是记录在一个数组中:array = [fragment0 ,fragment 1,fragment 2,fragment 3];

下载共分为两个阶段:seek阶段补洞阶段

  • seek阶段:即为在播放的时候,根据用户seek的位置来进行下载。

根据seek到的位置分为两种情况:

  • 情况一:如果seek到的位置是在已有的片段中(例如图中的seek1的位置,该处有数据),就从该片段(fragment1)的末尾请求数据(end1),直到下个片段的开始位置处(fragment2start),也就是向服务器请求的range为:rang1 = (end1 ) —— start2;
    这个片段下载完成后,假如把下载的片段记为fragment1.1,则会把fragment1fragment1.1fragment2合为一个片段为fragment1-2,则array = [fragment 0,fragement1-2,frament3];这次下载后的状态图2所示:

    图2--情况一

    接下来一直下载直到array = [fragment 0,fragement1-3];之后会判断fragement1-3有没有到文件末尾,如果到了就下载结束,如果没到就从从fragement3的(end3)开始下载直到文件末尾。

  • 情况二:如果seek到的位置没有在已有的片段中,(例如说是在图1中的seek2的位置),就从seek到的位置开始下载数据直到下一个片段的startfragment2start2),假如这个片段记为fragment1.1,则会把fragment1.1fragment2合并即数组为:array= [fragment 0,fragment1,fagment1.1-2,fragment3]; 合并后的情况如下图3所示:接下来的操作就是继续下载,直到下载到文件末尾;

    图3--情况二

    如果片段太小保存起来就会让播放器下次播放的时候多发送一次请求,这样是很耗费资源。例如:如上图3所示,如果fragment1的大小只有1kb,想要补充fragment0fragment1.1-2之间的数据,就需要发送两次请求,这样频繁的发送请求,比较浪费资源。因此当fragment太小,就不存在配置数组中。这样会少发一次请求,也不会浪费很大的流量。当下载片段太小(例如说下载的长度<20KB),就不保存在片段数组中(为了控制片段的粒度)。这样会产生一个问题,当视频文件中间有一个空洞小于20KB,这个片段永远补不上。这个时候就需要用到第二阶段-补洞阶段

  • 补洞阶段
    第二阶段补洞阶段,就是第二次播放的时候,如果文件中有空洞,这个时候不论片段再小,也会存到片段中。
    最后当配置数组中存的数据只剩下最后的{0,length}length为视频总长度的时候,表示文件已全部下载完成。

2.3 方案三

对于IOS平台来说,还有一种更好的方案:使用IOS原生API ,使用 AVAssetResourceLoader,在不改变 AVPlayer API 的情况下,对播放的音视频进行缓存。

方案三跟方案二原理差不多,只不过是借助IOS原始API来实现的。

  • 使用IOS系统自动API 实现视频边下边播功能:

这里的边下边播不是单独开一个子线程去下载,而是把视频播放的数据给保存到本地。简而言之,就是使用一遍的流量,既播放了视频,也保存了视频。

具体实现方案如下:

  1. 需要在视频播放器和服务器之间添加一层类似代理的机制,视频播放器不再直接访问服务器,而是访问代理对象,代理对象去访问服务器获得数据,之后返回给视频播放器,同时代理对象根据一定的策略缓存数据。
  2. AVURLAsset中的resourceLoader可以实现这个机制,resourceLoader的delegate就是上述的代理对象。
  3. 视频播放器在开始播放之前首先检测是本地cache中是否有此视频,如果没有才通过代理获得数据,如果有,则直接播放本地cache中的视频即可。
  4. 如果是用HTTP的方式,上述3步可以实现边下边播功能,如果是HTTPS,服务器证书使用的是证书颁发机构签名的证书,则也可以直接跟HTTP方式一样处理。但是,如果是HTTPS+自签名证书的方式,则需要在resourceLoader每次方式请求前,先校验证书,也就是下面的第5步

2.3.1 AVPlayer实现边下边播流程

我们先来参考网上播放QQ音乐边下边播流程图如下:


整个边下边播流程图如下

QQ 音乐实现的缓存策略大致如下:

先观察并猜测企鹅音乐的缓存策略(当然它不是用AVPlayer播放):
  1、开始播放,同时开始下载完整的文件,当文件下载完成时,保存到缓存文件夹中;
  2、当seek时
   (1)如果seek到已下载到的部分,直接seek成功;(如下载进度60%,seek进度50%)
   (2)如果seek到未下载到的部分,则开始新的下载(如下载进度60%,seek进度70%)
      PS1:此时文件下载的范围是70%-100%
      PS2:之前已下载的部分就被删除了
      PS3:如果有别的seek操作则重复步骤2,如果此时再seek到进度40%,则会开始新的下载(范围40%-100%)
  3、当开始新的下载之后,由于文件不完整,下载完成之后不会保存到缓存文件夹中;
  4、下次再播放同一歌曲时,如果在缓存文件夹中存在,则直接播放缓存文件;

我们使用AVPlayer 来实现边下边播的大致流程跟上面QQ音乐的缓存机制差不多,就是依赖于AVAssetResourceLoader. 大致流程如下:

AVPlayer播放流程

如上图所示,我们简单描述一下AVPlayer实现边下边播的流程:

  1. 当开始播放视频时,通过视频url判断本地cache中是否已经缓存当前视频,如果有,则直接播放本地cache中视频
  2. 如果本地cache中没有视频,则视频播放器向代理请求数据
  3. 加载视频时展示正在加载的提示(菊花转)
  4. 如果可以正常播放视频,则去掉加载提示,播放视频,如果加载失败,去掉加载提示并显示失败提示
  5. 在播放过程中如果由于网络过慢或拖拽原因导致没有播放数据时,要展示加载提示,跳转到第4步

缓存代理策略:

  1. 当视频播放器向代理请求dataRequest时,判断代理是否已经向服务器发起了请求,如果没有,则发起下载整个视频文件的请求
    2.如果代理已经和服务器建立链接,则判断当前的dataRequest请求的offset是否大于当前已经缓存的文件的offset,如果大于则取消当前与服务器的请求,并从offset开始到文件尾向服务器发起请求(此时应该是由于播放器向后拖拽,并且超过了已缓存的数据时才会出现)
  2. 如果当前的dataRequest请求的offset小于已经缓存的文件的offset,同时大于代理向服务器请求的range的offset,说明有一部分已经缓存的数据可以传给播放器,则将这部分数据返回给播放器(此时应该是由于播放器向前拖拽,请求的数据已经缓存过才会出现)
  3. 如果当前的dataRequest请求的offset小于代理向服务器请求的range的offset,则取消当前与服务器的请求,并从offset开始到文件尾向服务器发起请求(此时应该是由于播放器向前拖拽,并且超过了已缓存的数据时才会出现)
  4. 只要代理重新向服务器发起请求,就会导致缓存的数据不连续,则加载结束后不用将缓存的数据放入本地cache
  5. 如果代理和服务器的链接超时,重试一次,如果还是错误则通知播放器网络错误
  6. 如果服务器返回其他错误,则代理通知播放器网络错误

2.3.2 AVPlayer相关API简介

IOS 播放网络视频我们一般使用AVFoundation框架里面的AVPlayer去实现自定义播放器,但是AVPlayer的相关API都是高度封装的,这样我们播放网络视频时,往往不能控制其内部播放逻辑,比如我们会发现播放时seek会失败,数据加载完毕后不能获取到数据文件进行其他操作,因此我们需要寻找弥补其不足之处的方法,这里我们选择了AVAssetResourceLoader。我们这里实现边下边播功能也是依赖于它。

先来了解一下AVAssetResourceLoader的作用:让我们自行掌握AVPlayer数据的加载,包括获取AVPlayer需要的数据的信息,以及可以决定传递多少数据给AVPlayer。

我们大致了解一下AVPlayer的组件图:


AVPlayer组件图

AVAssetResourceLoader:一个 iOS 6 就被开放出来,专门用来处理 AVAsset 加载的工具。这个完全满足JimuPro运行在IOS10以上的要求。

AVAssetResourceLoader 有一个AVAssetResourceLoaderDelegate代理,这个代理有两个重要的接口:

  • 要求加载资源的代理方法,这时我们需要保存loadingRequest并对其所指定的数据进行读取或下载操作,当数据读取或下载完成,我们可以对loadingRequest进行完成操作。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
  • 取消加载资源的代理方法,这时我们需要取消loadingRequest所指定的数据的读取或下载操作。
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;

我们只要找一个对象实现了 AVAssetResourceLoaderDelegate 这个协议的方法,丢给 asset,再把 asset 丢给 AVPlayer,AVPlayer 在执行播放的时候就会去问这个 delegate:喂,你能不能播放这个 url 啊?然后会触发下面这个方法:- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest

我们在这个方法中看看 request 里面的 url 是不是我们支持的,如果能支持就返回 YES!然后就可以开心的一边下视频数据,一边塞数据给 AVPlayer 让它显示视频画面。

AVUrlAsset在请求自定义的URLScheme资源的时候会通过AVAssetResourceLoader实例来进行资源请求。它是AVUrlAsset的属性,声明如下:var resourceLoader: AVAssetResourceLoader { get }

AVAssetResourceLoader请求的时候会把相关请求(AVAssetResourceLoadingRequest)传递给AVAssetResourceLoaderDelegate(如果有实现的话),我们可以保存这些请求,然后构造自己的NSUrlRequset来发送请求,当收到响应的时候,把响应的数据设置给AVAssetResourceLoadingRequest,并且对数据进行缓存,就完成了边下边播,整个流程大体如下图:

AVAssetResourceLoaderDelegate实现边下边播流程

其中最为复杂的部分是数据偏移处理,因为数据是分块下载和分块填充的,我们的需要填充的对象是AVAssetResourceLoadingDataRequest,需要控制好currentOffset

下面我们将来详细的介绍使用AVPlayer和AVAssetResourceLoaderDelegate来实现边下边播的具体实现。

3 HTTP边下边播 mp4文件 实现细节

目前网上有好多关于IOS边下边播的代码,其实原理都是一样的,只是实现方式,细节不一样,这里推荐两个比较好的开源代码:

  • OC版本:VIMediaCache 目前git上面有642颗星星,相当不错。
  • Swift版本:VGPlayer 目前git上面有363颗星星,功能也比较完善,这是我比较推荐的。

3.1 边下边播原理

边下边播的原理已经在上面的3种方案介绍中详细描述了,这里主要是基于第三种方案用AVPlayer 来实现边下边播。这里先抛开HTTPS字签证书的签名认证问题,先讲解基于HTTP方式的边下边播,主流程图如下:


AVPlayer边下边播主流程

整个过程就是分为两大块,一块是实时播放视频,一块就是缓存策略下载视频。

3.1.1 实时播放原理

我们先来看第一块,实时播放视频(先不管下载和缓存),实现上,我们可以分为两步:

  1. 需要知道如何请求数据,url 是什么,下载多少数据。
  2. 下载好的数据怎么塞给 AVPlayer

3.1.1.1 请求数据

在上面的回调方法中,会得到一个 AVAssetResourceLoadingRequest 对象,它里面的属性和方法不多,为了减少干扰,我精简了一下这个类的头文件,只留下我们会用到以及需要解释的属性和方法:

@interface AVAssetResourceLoadingRequest : NSObject 

 @property (nonatomic, readonly) NSURLRequest *request;

 @property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest NS_AVAILABLE(10_9, 7_0);

 @property (nonatomic, readonly, nullable) AVAssetResourceLoadingDataRequest *dataRequest NS_AVAILABLE(10_9, 7_0);

 - (void)finishLoading NS_AVAILABLE(10_9, 7_0);

 - (void)finishLoadingWithError:(nullable NSError *)error;

 @end 

AVAssetResourceLoadingRequest 里面,request 代表原始的请求,由于 AVPlayer 是会触发分片下载的策略,还需要从dataRequest 中得到请求范围的信息。有了请求地址和请求范围,我们就可以重新创建一个设置了请求 Range 头的 NSURLRequest 对象,让下载器去下载这个文件的 Range 范围内的数据。

3.1.1.2 赛数据给AVPlayer

AVPlayer 触发下载时,总是会先发起一个 Range0-2 的数据请求,这个请求的作用其实是用来确认视频数据的信息,如文件类型、文件数据长度。当下载器发起这个请求,收到服务端返回的 response 后,我们要把视频的信息填充到 AVAssetResourceLoadingRequestcontentInformationRequest 属性中,告知下载的视频格式以及视频长度。

AVAssetResourceLoadingRequest- (void)finishLoading 的时候,会根据 contentInformationRequest 中的信息,去判断接下去要怎么处理。例如:下载 AVURLAsset 中 URL 指向的文件,获取到的文件的 contentType 是系统不支持的类型,这个 AVURLAsset 将无法正常播放。

获取完视频信息后,会收到刚才指定的 2 Bytedata 数据,下载到的数据怎么办? 可以塞给 AVAssetResourceLoadingRequest 里的 dataRequestdataRequest 里面用 - (void)respondWithData:(NSData *)data; 专门用来接收下载的数据,这个方法可以调用多次,接收增量连续的 data 数据。

AVAssetResourceLoadingRequest 要求的所有数据都下载完毕,调用 - (void)finishLoading 完成下载,AVAssetResourceLoader 会继续发起之后的数据片段的请求。如果本次请求失败,可以直接调用 - (void)finishLoadingWithError:(nullable NSError *)error; 结束下载。

3.1.1.3 重试机制

在实际的测试中,发现AVAssetResourceLoader 在执行加载的时候,会时不时的触发取消下载调用 - (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest,然后重新发起加载请求的策略。如果下载了部分,那么重新发起的下载请求会从还没有下载的部分开始。

AVAssetResourceLoaderDelegate 中还有 3 个方法可以针对特殊场景做处理,不过在目前的环境中都用不到所以可以选择不实现这些方法。

3.1.2 下载缓存原理

通过上面实时播放原理的介绍,我们已经知道 AVAssetResourceLoaderDelegate 的实现机制,当 AVAsset 需要加载数据时会通过 delegate 告诉外部,外部接管整个视频下载过程。

当我们接管了视频下载,便可以对视频数据做任何事情。比如:缓存、记录下载速度、获得下载进度等等。

实现一个下载器,就是用 URLSession 开启一个 DataTask 请求数据,把接收到的数据塞给 DataRequest 并写入本地磁盘。在实现下载器时主要有三个注意的点:1. Range 请求 2. 可取消下载 3. 分片缓存

3.1.2.1 Range 请求

  • 能够通过Range分片请求,是实现实时播放,边下边播的关键。

每次得到的 LoadingRequest 带有请求数据范围的信息,比如期望请求第 100 字节到 500 字节,在创建 URLRequest 时需要设置 HTTPHeaderRange 值。

NSString *range = [NSString stringWithFormat:@"bytes=%lld-%lld", fromOffset, endOffset];
[request setValue:range forHTTPHeaderField:@"Range"];

引入分块下载最大的复杂点在于对响应数据的contentOffset的处理上,好在AVAssetResourceLoader帮我们处理了大量工作,我们只需要用好AVAssetResourceLoadingRequest就可以了。

例如,下面是代码部分,首先是获取原始请求和发送新的请求

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    if self.session == nil {
        //构造Session
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
        configuration.networkServiceType = .video
        configuration.allowsCellularAccess = true
        self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    }
    //构造 保存请求
    var urlRequst = URLRequest.init(url: self.initalUrl!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) // 20s超时
    urlRequst.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
    urlRequst.httpMethod = "GET"
    //设置请求头
    guard let wrappedDataRequest = loadingRequest.dataRequest else{
        //本次请求没有数据请求
        return true
    }
    let range:NSRange = NSMakeRange(Int.init(truncatingBitPattern: wrappedDataRequest.requestedOffset), wrappedDataRequest.requestedLength)
    let rangeHeaderStr = "byes=\(range.location)-\(range.location+range.length)"
    urlRequst.setValue(rangeHeaderStr, forHTTPHeaderField: "Range")
    urlRequst.setValue(self.initalUrl?.host, forHTTPHeaderField: "Referer")
    guard let task = session?.dataTask(with: urlRequst) else{
        fatalError("cant create task for url")
    }
    task.resume()
    self.tasks[task] = loadingRequest
    return true
}

收到响应请求后,抓包查看响应的请求头,下图是2个响应的请求头:

range请求响应

Content-Length表示本次请求的数据长度
Content-Range表示本次请求的数据在总媒体文件中的位置,格式是start-end/total,因此就有Content-Length = end - start + 1

3.1.2.2 可取消下载

AVAsset 在加载视频时,经常会在某次数据请求还没有完成时触发取消下载,然后发起一个新的 LoadingReqeust。这个机制是 AVAsset 里的黑盒,具体逻辑无法得知,比较像是 AVAsset 的一种重试机制。 作为下载器,在收到取消通知时,需要立刻停止下载。由于 DataRequestcancel 操作是异步的,就有可能在 cancel 还未完成时,下一个 LoadingRequest 就已经到来,所以还需要需要保证同一个 URL 只能同时存在一个下载器在下载,否则会出现数据混乱的问题。

3.1.2.3 分片缓存

如果只是单纯的下载视频,数据单调递增,缓存处理还是比较容易。然而现实是用户对 player 的 seek 操作给视频的缓存管理带来了巨大的挑战,一旦涉及到用户操作,可能性就越多,复杂度也会越高。

没有 seek 的情况:网速正常时缓存数据比播放时间走得开,正常播放;网速慢时,播放器 loading,直到有足够的数据量进行播放,如果网速一直很慢就会播几秒卡一下。

当加入 seek 后会有三种可能:

  • 第一种情况,视频完全下载好,这时 seek 只需读取相应缓存即可,这种情况最简单,就直接从缓存读数据即可。


    图3.1.2.3.1 - seek时视频完成下载了
  • 第二种情况,视频下载一半,用户 seek 到未下载部分,LoadingRequest 请求的部分全部都是未下载的数据。这时需要取消正在下载的数据,然后从 seek 的点开始下载数据。为了支持 seek 操作,下载器就需要支持分片缓存。目前使用的解决方案是下载的视频数据会根据请求的 Range 值,把数据存储到文件中对应的偏移值位置,并且每个视频文件都会另外再保存一个与之对应的下载信息文件。这个信息文件会记录当前下载了多少数据,总共有多少数据,下载了哪些片段的数据等信息,之后的缓存管理会非常依赖这个配置文件。
    图3.1.2.3.2 - seek时都是未下载的部分
  • 第三种情况,视频被 seek 了多次,用户 seek 到一个时间点,LoadingRequest 请求的部分包含了已下载和未下载的部分。这种情况是最复杂的!简单的做法是,当成上面的情况来处理,全部都重新下载,虽然逻辑简单,但这个方案会下载多次同样的数据,不是最最优解。我的目标当然是做最优的解决方案,但也是复杂高很多的解决方案。
    图3.1.2.3.3 - seek时既有下载完的部分,又有未下载的部分

    在收到 LoadingRequest 的请求范围后,下载器会先获取已经下载的数据信息,把已下载的分片信息分别创建一个 action,再把需要远程下载的分片数据分别创建一个 action。最终组合就可能是 LocalAction(50-100 bytes) + RemoteAction(101-200 bytes) + LocalAction(201-300 bytes) + RemoteAction(300-400 bytes)。每一个 action 会按顺序获取数据再返回给 LoadingRequest。如下图:
    图3.1.2.3.4 - seek时既有下载完的部分,又有未下载的部分,创建action

3.2 边下边播实现细节

  • 在下载视频时,出现错误无法正常下载是比较容易出现的。我们自己实现了 AVAssetResourceLoaderDelegate 在第一次请求就抛出错误的话,播放器会马上提示错误状态,而如果是已经响应了部分数据,再抛错误,AVAssetResourceLoader 会忽略错误而一直处于 loading,直到超时。这种情况就比较尴尬,在上面给出的VIMediaCache 实现中, VIResourceLoaderManager 提供了 delegate,如果内部出现错误,就会抛出错误,再又外部业务决定是如何处理。

  • 同一时间同一个 url 不能有多次下载: 由于缓存内部实现是对每一个 url 都共用同一个下载配置文件,如果同时有多次对同一个 url 进行下载,这个文件下载信息会被同时修改,下载信息会变得混乱。VIMediaCache 里的 MediaCache 内部做了简单的处理,如果正在下载某 url,这时再想尝试下载同样的 url 会直接抛出错误,提示无法开始下载。

  • 实际上VGPlayer只是参考VIMediaCache 方式的Swift版本实现,VIMediaCache 是真的大牛编写的OC版本,值得好好研究。

  • 鉴于我们JimuPro工程师纯swift项目,里面处了第三方库没有使用OC代码,所以我优先选择VGPlayer来实现机器人端到IOS app端的边下边播功能。

  • 由于VGPlayer没有实现HTTPS的证书验证,这里我只需要简单实现证书验证代码即可。我们将在下面讲解HTTPS的证书认证实现。这里我简单说一下我的实现,
    在VGPlayerDownloadURLSessionManager.swift文件的VGPlayerDownloadURLSessionManager类里面增加一个URLSession的一个代理实现:

    增加一个URLSession的一个代理实现

  • 即使你参考上面的源码实现了边下载边播放,还是有些细节地方需要注意的:
    例如要实现mp4文件的边下边播功能,不仅依赖于上面讲解的边下边播实现方案,还依赖于mp4的文件格式。如果遇到这种mp4文件的元数据放在文件末尾的,我们需要在服务器端将mp4文件做一下转换才可以实现边下边播功能。

接下来详细讲解一下mp4格式处理问题。

3.3 边下边播mp4文件格式需要注意

我们要明确一点就是即使你用上面的缓存方式实现了边下边播的功能,并不是所有mp4都支持的,这个需要你理解边下边播的原理。

mp4视频文件头中,包含一些元数据。元数据包含:视频的宽度高度、视频时长、编码格式等。mp4元数据通常在视频文件的头部,这样播放器在读取文件时会最先读取视频的元数据,然后开始播放视频。

当然也存在这样一种情况:mp4视频的元数据处于视频文件最后,这样播放器在加载视频文件时,一直读取到最后,才读取到视频信息,然后开始播放。如果缺少元数据,也是这样的情况。这就出现了mp4视频不支持边加载、边播放的问题。

  • 为啥会出现上面说的这种情况呢,下面我们简单分析一下原理:

在请求头里有一个Range:byte字段来告诉媒体服务器需要请求的是哪一段特定长度的文件内容,对于MP4文件来说,所有数据都封装在一个个的box或者atom中,其中有两个atom尤为重要,分别是moov atom和mdat atom。

  • moov atom:包含媒体的元数据的数据结构,包括媒体的块(box)信息,格式说明等等。
  • mdat atom: 包含媒体的媒体信息,对于视屏来说就是视频画面了。

在IOS中发送一个请求,利用NSUrlSession直接请求视频资源,针对元信息在视频文件头部的视频可以实现边下边播,而元信息在视频尾部的视频则会下载完才播放,为啥会这样呢?

答案就是:虽然moov和mdat都只有一个,但是由于MP4文件是由若干个这样的box或者atom组成的,因此这两个atom在不同媒体文件中出现的顺序可能会不一样,为了加快流媒体的播放,我们可以做的优化之一就是手动把moov提到mdat之前。 对于AVPlayer来说,只有到AVPlayerItemStatusReadyToPlay状态时,才可以开始播放视频,而进入AVPlayerItemStatusReadyToPlay状态的必要条件就是播放器读到了媒体的moov块。

如果mdat位于moov之后,那么这样的mp4视频文件是无法实现边下边播放的。要支持边下边播的mp4视频需要满足moov和mdat都位于文件头部,且moov位于mdat之前。如下图所示:

moov位于mdat之前

当moov和mdat都位于文件头部,且moov位于mdat之前。我们理论上一个请求就可以播放所有的moov位于mdat之前的视频的。但是,当我们seek拖拽播放的话,情况就变很复杂了,需要借助分块下载。

那么,如果遇到这种mp4文件的元数据放在文件末尾的,我们需要在服务器端将mp4文件做一下转换才可以实现边下边播功能。

可行的方法是使用的是qt-faststart工具。
qt-faststart能够将处于MP4文件末尾的moov atom元数据转移到最前面,不过由于qt-faststart工具只能处理moov atom元数据位于MP4末尾的文件。
如果我们想要将所有文件统一处理:整体思路是将MP4文件通过ffmpeg处理,将moov atom元数据转移至末尾,然后使用qt-faststart工具转移至最前面。

3.3.1 mp4 元数据特殊处理

  1. 先将下载的FFmpeg包解压:tar -jxvf ffmpeg-3.3.3.tar.bz2
  2. 配置:./configure --enable-shared --prefix=/usr/local/ffmpeg prefix就是设置安装位置,一般都默认usr/local下。
  3. 安装:
make
make install

编译安装时间会很长,10分钟左右吧,装完以后可以去安装目录下查看。
这时还没有结束,现在使用的话一般会报如下错误:

ffmpeg: error while loading shared libraries: libavfilter.so.1: cannot open shared object file: No such file or directory
  1. 需要编辑/etc/ld.so.conf文件加入如下内容:/usr/local/lib,保存退出后执行ldconfig命令。
echo "/usr/local/ffmpeg/lib" >> /etc/ld.so.conf
#注意这里是你前面安装ffmpeg的路径
ldconfig
  • qt-faststart 安装
    上面讲到的qt-faststart工具其实就在ffmpeg的源码中有,因为在ffmpeg解压完的文件中存在qt-faststart的源码,所以直接使用,位置在解压路径/tools/qt-faststart.c

如果你想单独下载点击这里: qt-faststart下载

  1. 进入ffmpeg解压路径执行命令:make tools/qt-faststart,会看到在tools中会出现一个qt-faststart文件(还有一个.c文件)
  2. ffmpeg将元数据转移至文件末尾:
cd ffmpeg安装路径/bin;./ffmpeg -i /opt/mp4test.mp4 -acodec copy -vcodec copy /opt/1.mp4
# /opt/mp4test.mp4为原始MP4文件路径,/opt/1.mp4为生成文件的存放路径

  1. qt-faststart 将元数据转移到文件开头:
cd ffmpeg压缩包解压路径/tools;
./qt-faststart /opt/1.mp4 /opt/2.mp4

4 HTTPS 边下边播 自签名证书认证

  • HTTPS SSL加密建立连接过程

如下图:


HTTPS SSL加密建立连接过程

过程详解:

  1. ①客户端的浏览器向服务器发送请求,并传送客户端SSL 协议的版本号,加密算法的种类,产生的随机数,以及其他服务器和客户端之间通讯所需要的各种信息。
  2. ②服务器向客户端传送SSL 协议的版本号,加密算法的种类,随机数以及其他相关信息,同时服务器还将向客户端传送自己的证书。
  3. ③客户端利用服务器传过来的信息验证服务器的合法性,服务器的合法性包括:证书是否过期,发行服务器证书的CA 是否可靠,发行者证书的公钥能否正确解开服务器证书的“发行者的数字签名”,服务器证书上的域名是否和服务器的实际域名相匹配。如果合法性验证没有通过,通讯将断开;如果合法性验证通过,将继续进行第四步。
  4. ④用户端随机产生一个用于通讯的“对称密码”,然后用服务器的公钥(服务器的公钥从步骤②中的服务器的证书中获得)对其加密,然后将加密后的“预主密码”传给服务器。
  5. ⑤如果服务器要求客户的身份认证(在握手过程中为可选),用户可以建立一个随机数然后对其进行数据签名,将这个含有签名的随机数和客户自己的证书以及加密过的“预主密码”一起传给服务器。
  6. ⑥如果服务器要求客户的身份认证,服务器必须检验客户证书和签名随机数的合法性,具体的合法性验证过程包括:客户的证书使用日期是否有效,为客户提供证书的CA 是否可靠,发行CA 的公钥能否正确解开客户证书的发行CA 的数字签名,检查客户的证书是否在证书废止列表(CRL)中。检验如果没有通过,通讯立刻中断;如果验证通过,服务器将用自己的私钥解开加密的“预主密码”,然后执行一系列步骤来产生主通讯密码(客户端也将通过同样的方法产生相同的主通讯密码)。
  7. ⑦服务器和客户端用相同的主密码即“通话密码”,一个对称密钥用于SSL 协议的安全数据通讯的加解密通讯。同时在SSL 通讯过程中还要完成数据通讯的完整性,防止数据通讯中的任何变化。
  8. ⑧客户端向服务器端发出信息,指明后面的数据通讯将使用的步骤. ⑦中的主密码为对称密钥,同时通知服务器客户端的握手过程结束。
  9. ⑨服务器向客户端发出信息,指明后面的数据通讯将使用的步骤⑦中的主密码为对称密钥,同时通知客户端服务器端的握手过程结束。
  10. ⑩SSL 的握手部分结束,SSL 安全通道的数据通讯开始,客户和服务器开始使用相同的对称密钥进行数据通讯,同时进行通讯完整性的检验。
  • 我这里只给出我项目里面使用VGPlayer播放器里的HTTPS证书认证方式实现代码,只需要简单的两部即可实现:
  1. 先将服务器给你自签名证书添加到工程里面:


    导入自签名证书
  2. 在VGPlayerDownloadURLSessionManager.swift文件的VGPlayerDownloadURLSessionManager类里面增加一个URLSession的一个代理实现:


    增加一个URLSession的一个代理实现
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        let method = challenge.protectionSpace.authenticationMethod
        if method == NSURLAuthenticationMethodServerTrust {
            //验证服务器,直接信任或者验证证书二选一,推荐验证证书,更安全
            completionHandler( HTTPSManager.trustServerWithCer(challenge: challenge).0, HTTPSManager.trustServerWithCer(challenge: challenge).1)
            
        } else if method == NSURLAuthenticationMethodClientCertificate {
            //认证客户端证书
             
            completionHandler( HTTPSManager.sendClientCer().0, HTTPSManager.sendClientCer().1)
            
        } else {
            //其他情况,不通过验证
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
  1. 认证类HTTPSManager的实现如下:
//
//  HTTPSManager.swift
//  JimuPro
//
//  Created by yulu kong on 2019/10/28.
//  Copyright © 2019 UBTech. All rights reserved.
//

import UIKit


class HTTPSManager: NSObject {
    
//    // MARK: - sll证书处理
//   static func setKingfisherHTTPS() {
//        //取出downloader单例
//        let downloader = KingfisherManager.shared.downloader
//        //信任Server的ip
//        downloader.trustedHosts = Set([ServerTrustHost.fileTransportIP])
//    }
//    
//   static func setAlamofireHttps() {
//        
//        SessionManager.default.delegate.sessionDidReceiveChallenge = { (session: URLSession, challenge: URLAuthenticationChallenge) in
//            
//            let method = challenge.protectionSpace.authenticationMethod
//            if method == NSURLAuthenticationMethodServerTrust {
//                //验证服务器,直接信任或者验证证书二选一,推荐验证证书,更安全
//                return HTTPSManager.trustServerWithCer(challenge: challenge)
////                return HTTPSManager.trustServer(challenge: challenge)
//                
//            } else if method == NSURLAuthenticationMethodClientCertificate {
//                //认证客户端证书
//                return HTTPSManager.sendClientCer()
//                
//            } else {
//                //其他情况,不通过验证
//                return (.cancelAuthenticationChallenge, nil)
//            }
//        }
//    }
    
    //不做任何验证,直接信任服务器
    static private func trustServer(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        let disposition = URLSession.AuthChallengeDisposition.useCredential
        let credential = URLCredential.init(trust: challenge.protectionSpace.serverTrust!)
        return (disposition, credential)
        
    }
    
    //验证服务器证书
    static  func trustServerWithCer(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling
        var credential: URLCredential?
        
        //获取服务器发送过来的证书
        let serverTrust:SecTrust = challenge.protectionSpace.serverTrust!
        let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0)!
        let remoteCertificateData = CFBridgingRetain(SecCertificateCopyData(certificate))!
        
        //加载本地CA证书
//        let cerPath = Bundle.main.path(forResource: "oooo", ofType: "cer")!
//        let cerUrl = URL(fileURLWithPath:cerPath)
        
        let cerUrl = Bundle.main.url(forResource: "server", withExtension: "cer")!
        let localCertificateData = try! Data(contentsOf: cerUrl)
        
        if (remoteCertificateData.isEqual(localCertificateData) == true) {
            //服务器证书验证通过
            disposition = URLSession.AuthChallengeDisposition.useCredential
            credential = URLCredential(trust: serverTrust)
            
        } else {
            //服务器证书验证失败
            //disposition = URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge
            disposition = URLSession.AuthChallengeDisposition.useCredential
            credential = URLCredential(trust: serverTrust)
        }
        
        return (disposition, credential)
        
    }
    
    //发送客户端证书交由服务器验证
    static  func sendClientCer() -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        let disposition = URLSession.AuthChallengeDisposition.useCredential
        var credential: URLCredential?
        
        //获取项目中P12证书文件的路径
        let path: String = Bundle.main.path(forResource: "clientp12", ofType: "p12")!
        let PKCS12Data = NSData(contentsOfFile:path)!
        let key : NSString = kSecImportExportPassphrase as NSString
        let options : NSDictionary = [key : "123456"] //客户端证书密码
        
        var items: CFArray?
        let error = SecPKCS12Import(PKCS12Data, options, &items)
        
        if error == errSecSuccess {
            
            let itemArr = items! as Array
            let item = itemArr.first!
            
            let identityPointer = item["identity"];
            let secIdentityRef = identityPointer as! SecIdentity
            
            let chainPointer = item["chain"]
            let chainRef = chainPointer as? [Any]
            
            credential = URLCredential.init(identity: secIdentityRef, certificates: chainRef, persistence: URLCredential.Persistence.forSession)
        }
        
        return (disposition, credential)
    }
}


6 播放器底层原理

6.1 视频格式简介

  • mp4 也叫做MPEG-4 官方介绍如下:
  1. MP4是一套用于音频、视频信息的压缩编码标准,由国际标准化组织(ISO)和国际电工委员会(IEC)下属的“动态图像专家组”(Moving Picture Experts Group,即MPEG)制定,第一版在1998年10月通过,第二版在1999年12月通过。MPEG-4格式的主要用途在于网上流、光盘、语音发送(视频电话),以及电视广播。
  2. MPEG-4包含了MPEG-1及MPEG-2的绝大部份功能及其他格式的长处,并加入及扩充对虚拟现实模型语言(VRML , VirtualReality Modeling Language)的支持,面向对象的合成档案(包括音效,视讯及VRML对象),以及数字版权管理(DRM)及其他互动功能。而MPEG-4比MPEG-2更先进的其中一个特点,就是不再使用宏区块做影像分析,而是以影像上个体为变化记录,因此尽管影像变化速度很快、码率不足时,也不会出现方块画面。
  • MP4标准
    MPEG-4码流主要包括基本码流和系统流,基本码流包括音视频和场景描述的编码流表示,每个基本码流只包含一种数据类型,并通过各自的解码器解码。系统流则指定了根据编码视听信息和相关场景描述信息产生交互方式的方法,并描述其交互通信系统。

  • MP4也可以理解成一种视频的封装格式
    视频封装格式,简称视频格式,相当于一种储存视频信息的容器,它里面包含了封装视频文件所需要的视频信息、音频信息和相关的配置信息(比如:视频和音频的关联信息、如何解码等等)。一种视频封装格式的直接反映就是对应着相应的视频文件格式。

常见的封装格式有如下:


常见的封装格式有如下

封装格式:就是将已经编码压缩好的视频数据 和音频数据按照一定的格式放到一个文件中.这个文件可以称为容器. 当然可以理解为这只是一个外壳.

通常我们不仅仅只存放音频数据和视频数据,还会存放 一下视频同步的元数据.例如字幕.这多种数据会不同的程序来处理,但是它们在传输和存储的时候,这多种数据都是被绑定在一起的.

  • 常见的视频容器格式:
  1. AVI: 是当时为对抗quicktime格式(mov)而推出的,只能支持固定CBR恒定定比特率编码的声音文件
  2. MOV:是Quicktime封装
  3. WMV:微软推出的,作为市场竞争
  4. mkv:万能封装器,有良好的兼容和跨平台性、纠错性,可带外挂字幕
  5. flv: 这种封装方式可以很好的保护原始地址,不容易被下载到,目前一些视频分享网站都采用这种封装方式
  6. MP4:主要应用于mpeg4的封装,主要在手机上使用。
  • 视频编解码方式

视频编解码的过程是指对数字视频进行压缩或解压缩的一个过程.
在做视频编解码时,需要考虑以下这些因素的平衡:视频的质量、用来表示视频所需要的数据量(通常称之为码率)、编码算法和解码算法的复杂度、针对数据丢失和错误的鲁棒性(Robustness)、编辑的方便性、随机访问、编码算法设计的完美性、端到端的延时以及其它一些因素。

  • 常见视频编码方式:
  • H.26X 系列,由国际电传视讯联盟远程通信标准化组织(ITU-T)主导,包括 H.261、H.262、H.263、H.264、H.265
  1. H.261,主要用于老的视频会议和视频电话系统。是第一个使用的数字视频压缩标准。实质上说,之后的所有的标准视频编解码器都是基于它设计的。
  2. H.262,等同于 MPEG-2 第二部分,使用在 DVD、SVCD 和大多数数字视频广播系统和有线分布系统中。
  3. H.263,主要用于视频会议、视频电话和网络视频相关产品。在对逐行扫描的视频源进行压缩的方面,H.263 比它之前的视频编码标准在性能上有了较大的提升。尤其是在低码率端,它可以在保证一定质量的前提下大大的节约码率。
  4. H.264,等同于 MPEG-4 第十部分,也被称为高级视频编码(Advanced Video Coding,简称 AVC),是一种视频压缩标准,一种被广泛使用的高精度视频的录制、压缩和发布格式。该标准引入了一系列新的能够大大提高压缩性能的技术,并能够同时在高码率端和低码率端大大超越以前的诸标准。
  5. H.265,被称为高效率视频编码(High Efficiency Video Coding,简称 HEVC)是一种视频压缩标准,是 H.264 的继任者。HEVC 被认为不仅提升图像质量,同时也能达到 H.264 两倍的压缩率(等同于同样画面质量下比特率减少了 50%),可支持 4K 分辨率甚至到超高画质电视,最高分辨率可达到 8192×4320(8K 分辨率),这是目前发展的趋势。
  • MPEG 系列,由国际标准组织机构(ISO)下属的运动图象专家组(MPEG)开发。
  1. MPEG-1 第二部分,主要使用在 VCD 上,有些在线视频也使用这种格式。该编解码器的质量大致上和原有的 VHS 录像带相当。
  2. MPEG-2 第二部分,等同于 H.262,使用在 DVD、SVCD 和大多数数字视频广播系统和有线分布系统中。
  3. MPEG-4 第二部分,可以使用在网络传输、广播和媒体存储上。比起 MPEG-2 第二部分和第一版的 H.263,它的压缩性能有所提高。
  4. MPEG-4 第十部分,等同于 H.264,是这两个编码组织合作诞生的标准。

可以把「视频封装格式」看做是一个装着视频、音频、「视频编解码方式」等信息的容器。一种「视频封装格式」可以支持多种「视频编解码方式」,比如:QuickTime File Format(.MOV) 支持几乎所有的「视频编解码方式」,MPEG(.MP4) 也支持相当广的「视频编解码方式」。当我们看到一个视频文件名为 test.mov 时,我们可以知道它的「视频文件格式」是 .mov,也可以知道它的视频封装格式是 QuickTime File Format,

但是无法知道它的「视频编解码方式」。那比较专业的说法可能是以 A/B 这种方式,A 是「视频编解码方式」,B 是「视频封装格式」。比如:一个 H.264/MOV 的视频文件,它的封装方式就是 QuickTime File Format,编码方式是 H.264

在这里机器人里面录制视频时采用H.264/mp4,所以这里我这边实现的边下边播方案里面也是针对的这种H.264视频编解码方式的mp4容器格式的视频文件。

H264最大的优势,具有很高的数据压缩比率,在同等图像质量下,H264的压缩比是MPEG-2的2倍以上,MPEG-4的1.5~2倍.

原始文件的大小如果为88GB,采用MPEG-2压缩标准压缩后变成3.5GB,压缩比为25∶1,而采用H.264压缩标准压缩后变为879MB,从88GB到879MB,H.264的压缩比达到惊人的102∶1

  • 正常我们机器人采集到视频流数据后,经过H264硬编码,或者FFmpeg处理的H264软编码方式,将YUV4:2:0的数据进行H264编码后得到编码后的H264流数据。
  • 我们在IOS播放时,其实也是拿到这种一帧一帧的H264流数据,然后进行硬解码或FFmpeg软解码。(硬解码在IOS里面是有VideoToolBox框架里面的API可以实现,软解码需要使用FFmpeg里的H264解码器)。解码后我们得到原始裸数据YUV数据,然后我们将YUV数据转换为RGB数据,借助OpenGL ES或Metal 以纹理渲染的方式,将图像显示在View的 Layer上。
  • 其实这些解码,播放相关底层代码都被我们的AVFoundation框架里面的AVPlayer 封装了,没有暴露这些细节给我,我们只需要传递一个URL 就可以实现视频播放功能。

为了更好的理解播放视频的原理,我这里还简单介绍一下H264编解码的相关知识

6.2 H264简介

  • H264的码流结构:H264视频压缩后会成为一个序列帧.帧里包含图像,图像分为很多片.每个片可以分为宏块.每个宏块由许多子块组成,如下图:


    H264的码流结构

    H264结构中,一个视频图像编码后的数据叫做一帧,一帧由一个片(slice)或多个片组成,一个片由一个或多个宏块(MB)组成,一个宏块由16x16的yuv数据组成。宏块作为H264编码的基本单位。

  • 场和帧:视频的一场或一帧可用来产生一个编码图像。在电视中,为减少大面积闪烁现象,把一帧分成两个隔行的场。

  • :每个图象中,若干宏块被排列成片的形式。片分为I片、B片、P片和其他一些片。

  1. I片只包含I宏块,P片可包含P和I宏块,而B片可包含B和I宏块。
  2. I宏块利用从当前片中已解码的像素作为参考进行帧内预测。
  3. P宏块利用前面已编码图象作为参考图象进行帧内预测。
  4. B宏块则利用双向的参考图象(前一帧和后一帧)进行帧内预测。
  5. 的目的是为了限制误码的扩散和传输,使编码片相互间是独立的。
  • H264码流分层结构图:


    H264码流分层结构图

A Annex格式数据,就是起始码+Nal Unit 数据
NAL Unit: NALU 头+NALU数据
NALU 主体,是由切片组成.切片包括切片头+切片数据
Slice数据: 宏块组成
PCM类: 宏块类型+pcm数据,或者宏块类型+宏块模式+残差数据
Residual: 残差块.

  • NAL 单元是由一个NALU头部+一个切片.切片又可以细分成"切片头+切片数据".我们之间了解过一个H254的帧是由多个切片构成的.因为一帧数据一次有可能传不完. 如下图:


    NAL 单元
  • 切片与宏块的关系(Slice & MacroBlock)
    每个切片都包括切片头+切片数据. 那每个切片数据包括了很多宏块.每个宏块包括了宏块的类型,宏块的预测,残差数据. 如下图:


    切片与宏块的关系

而我们在一副压缩的H264的帧里,可以包含多个切片.至少有一个切片,如下图:

H264切片

了解了上面关于H264码流的一些基本概念后,我们就能更好的理解H264编码解码的原理,以及图像渲染,视频播放器的实现原理。

在H264解码的过程中会涉及到一帧帧的数据,这里有I帧,P帧,B帧,三个概念。

  • I帧: 关键帧,采用帧内压缩技术.

举个例子,如果摄像头对着你拍摄,1秒之内,实际你发生的变化是非常少的.1秒钟之内实际少很少有大幅度的变化.摄像机一般一秒钟会抓取几十帧的数据.比如像动画,就是25帧/s,一般视频文件都是在30帧/s左右.对于一些要求比较高的,对动作的精细度有要求,想要捕捉到完整的动作的,高级的摄像机一般是60帧/s.那些对于一组帧的它的变化很小.为了便于压缩数据,那怎么办了?将第一帧完整的保存下来.如果没有这个关键帧后面解码数据,是完成不了的.所以I帧特别关键.

  • P帧: 向前参考帧.压缩时只参考前一个帧.属于帧间压缩技术.

视频的第一帧会被作为关键帧完整保存下来.而后面的帧会向前依赖.也就是第二帧依赖于第一个帧.后面所有的帧只存储于前一帧的差异.这样就能将数据大大的减少.从而达到一个高压缩率的效果.

  • B帧: 双向参考帧,压缩时即参考前一帧也参考后一帧.帧间压缩技术.
  1. B帧,即参考前一帧,也参考后一帧.这样就使得它的压缩率更高.存储的数据量更小.如果B帧的数量越多,你的压缩率就越高.这是B帧的优点,但是B帧最大的缺点是,如果是实时互动的直播,那时与B帧就要参考后面的帧才能解码,那在网络中就要等待后面的帧传输过来.这就与网络有关了.如果网络状态很好的话,解码会比较快,如果网络不好时解码会稍微慢一些.丢包时还需要重传.对实时互动的直播,一般不会使用B帧.

我们实时播放视频时,每次从服务器请求一个Range范围的视频帧,实际上服务器是返回一组组的H264帧数据,一组帧数据又称为GOF(Group of Frame),GOF 表示:一个I帧到下一个I帧.这一组的数据.包括B帧/P帧. 如下图所示:

Group of Frame

  • 在H264码流中,我们使用SPS/PPS来存储GOP的参数。

  • SPS 序列参数集 :全称是Sequence Parameter Set,序列参数集存放帧数,参考帧数目,解码图像尺寸,帧场编码模式选择标识等.

  • PPS 图像参数集:全称是Picture Parameter Set,图像参数集.存放编码模式选择标识,片组数目,初始量化参数和去方块滤波系数调整标识等.(与图像相关的信息)

在一组帧之前我们首先收到的是SPS/PPS数据.如果没有这组参数的话,我们是无法解码. 之前WebRTC视频的时候遇到的一个问题就是:IOS端有时候图传的时候黑屏,这个原因就是因为I帧缺少SPS/PPS信息,导致解码失败,导致的黑屏。

  • 如果我们在解码时发生错误,首先要检查是否有SPS/PPS.如果没有,是因为对端没有发送过来还是因为对端在发送过程中丢失了.
    SPS/PPS数据,我们也把其归类到I帧.这2组数据是绝对不能丢的.

  • 视频花屏,卡顿的原因分析
    我们在观看视频时,会遇到花屏或者卡顿现象.那这个与我们刚刚所讲的GOF就息息相关了

  1. 如果GOP分组中的P帧丢失就会造成解码端的图像发生错误.解码错误时,我们把解码失败的图片用来展示了,就导致我们看到的花屏现象
  2. 为了避免花屏问题的发生,一般如果发现P帧或者I帧丢失.就不显示本GOP内的所有帧.只到下一个I帧来后重新刷新图像.
  3. 当这时因为没有刷新屏幕.丢包的这一组帧全部扔掉了.图像就会卡在哪里不动.这就是卡顿的原因.
  4. 所以总结起来,花屏是因为你丢了P帧或者I帧.导致解码错误. 而卡顿是因为为了怕花屏,将整组错误的GOP数据扔掉了.直达下一组正确的GOP再重新刷屏.而这中间的时间差,就是我们所感受的卡顿.
  • 软编码与硬编码
  • 硬编码: 使用非CPU进行编码,例如使用GPU芯片处理
  1. 性能高,低码率下通常质量低于硬编码器,但部分产品在GPU硬件平台移植了优秀的软编码算法(如X264)的,质量基本等同于软编码。
  2. 硬编码,就是使用GPU计算,获取数据结果,优点速度快,效率高.
  3. 在IOS平台针对视频硬编码使用VideoToolBox框架,针对音频硬编码使用AudioToolBox 框架
  • 软编码: 使用CPU来进行编码计算.
  1. 实现直接、简单,参数调整方便,升级易,但CPU负载重,性能较硬编码低,低码率下质量通常比硬编码要好一点。
  2. 软编码,就是通过CPU来计算,获取数据结果.
  3. 在IOS平台针对视频软编码一般使用FFmpeg,X264算法把视频原数据YUV/RGB编码成H264。针对音频使用fdk_aac 将音频数据PCM转换成AAC。

如果想更加深入的探索播放器的底层原理,可以参考这两款开源的播放器:
ijkplayer,kxmovie 他们都是基于FFmpeg框架封装的

  • ijkplayer是bilibili出品的一款基于FFmpeg的视频播放器,在git上面已经有25.7k的星星了,非常强大,值得深入研究,这个包含ios,和android端的。
    ijkplayer
  • kxmovie 在git上面也有2.7k的星星,这是实力的认证,值得学习,研究。
    kxmovie

6.3 MP4 格式

MP4(MPEG-4 Part 14)是一种常见的多媒体容器格式,它是在“ISO/IEC 14496-14”标准文件中定义的,属于MPEG-4的一部分,是“ISO/IEC 14496-12(MPEG-4 Part 12 ISO base media file format)”标准中所定义的媒体格式的一种实现,后者定义了一种通用的媒体文件结构标准。MP4是一种描述较为全面的容器格式,被认为可以在其中嵌入任何形式的数据,各种编码的视频、音频等都不在话下,不过我们常见的大部分的MP4文件存放的AVC(H.264)或MPEG-4(Part 2)编码的视频和AAC编码的音频。MP4格式的官方文件后缀名是“.mp4”,还有其他的以mp4为基础进行的扩展或者是缩水版本的格式,包括:M4V,3GP,F4V等。

首先看一下软件对于mp4文件的解析如下图所示:

图6.3.1 mp4文件格式

从上图图6.3.1 中可以看出这个视频文件第一层有4部分,每一部分都是一个box,分别为:ftype,moov,free,mdat。其实mp4文件是有许多的box组成的。如下图6.3.2 所示:
图6.3.2 - mp4文件格式

box的基本结构如下图6.3.3所示,其中,size指明了整个box所占用的大小,包括header部分,type指明了box的类型。如果box很大(例如存放具体视频数据的mdat box),超过了uint32的最大数值,size就被设置为1,并用接下来的8位uint64来存放大小。

图6.3.3 - Box结构

一个mp4文件有可能包含非常多的box,在很大程度上增加了解析的复杂性,这个网页上http://mp4ra.org/atoms.html记录了一些当前注册过的box类型。看到这么多box,如果要全部支持,一个个解析,怕是头都要爆了。还好,大部分mp4文件没有那么多的box类型,下图就是一个简化了的,常见的mp4文件结构如下图6.3.4所示

图6.3.4 - mp4包含的box结构

一般来说,解析媒体文件,最关心的部分是视频文件的宽高、时长、码率、编码格式、帧列表、关键帧列表,以及所对应的时戳和在文件中的位置,这些信息,在mp4中,是以特定的算法分开存放在stbl box下属的几个box中的,需要解析stbl下面所有的box,来还原媒体信息。下表是对于以上几个重要的box存放信息的说明:
图6.3.5 - mp4 box类型说明

6.4 IOS 原始API实现 将mp4文件的 moov的box移到前面

上面已经讲解过使用FFmpeg里面的 qt-faststart下载工具可以实现将mp4文件的 moov的box移到前面,从而让mp4文件支持边下边播功能。下面将介绍一种通过IOS原始代码的方式实现将mp4文件的moov的box从文件最后面移到前面。

不过这种方式一般用不到,一是因为效率问题,而是一般实现边下边播,都是由服务器端去完成这种事情。

具体代码如下:

- (NSData*)exchangestco:(NSMutableData*) moovdata{

int i, atom_size, offset_count, current_offset;

NSString*atom_type;

longlongmoov_atom_size = moovdata.length;

Byte*buffer = (Byte*)malloc(5);

buffer[4] =0;

Byte*buffer01 = (Byte*)malloc(moov_atom_size);

[moovdatagetBytes:buffer01 length:moov_atom_size];

for(i =4; i < moov_atom_size -4; i++) {

NSRangerange;

range.location= I;

range.length=4;

[moovdatagetBytes:buffer range:range];

atom_type = [selftosType:buffer];

if([atom_typeisEqualToString:@"stco"]) {

range.location= i-4;

range.length =4;

[moovdatagetBytes:bufferrange:range];

atom_size = [selftoSize:buffer];

if(i + atom_size -4> moov_atom_size) {

WBLog(LOG_ERROR,@"error i + atom_size - 4 > moov_atom_size");

returnnil;

}

range.location= I+8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

offset_count = [selftoSize:buffer];

for(intj =0; j < offset_count; j++) {

range.location= i +12+ j *4;

range.length=4;

[moovdatagetBytes:bufferrange:range];

current_offset= [selftoSize:buffer];

current_offset += moov_atom_size;

buffer01[i +12+ j *4+0] = (Byte) ((current_offset >>24) &0xFF);

buffer01[i +12+ j *4+1] = (Byte) ((current_offset >>16) &0xFF);

buffer01[i +12+ j *4+2] = (Byte) ((current_offset >>8) &0xFF);

buffer01[i +12+ j *4+3] = (Byte) ((current_offset >>0) &0xFF);

}

i += atom_size -4;

}

elseif([atom_typeisEqualToString:@"co64"]) {

range.location= i-4;

range.length=4;

[moovdatagetBytes:bufferrange:range];

atom_size = [selftoSize:buffer];

if(i + atom_size -4> moov_atom_size) {

WBLog(LOG_ERROR,@"error i + atom_size - 4 > moov_atom_size");

returnnil;

}

range.location= I+8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

offset_count = [selftoSize:buffer];

for(intj =0; j < offset_count; j++) {

range.location= i +12+ j *8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

current_offset = [selftoSize:buffer];

current_offset += moov_atom_size;

buffer01[i +12+ j *8+0] = (Byte)((current_offset >>56) &0xFF);

buffer01[i +12+ j *8+1] = (Byte)((current_offset >>48) &0xFF);

buffer01[i +12+ j *8+2] = (Byte)((current_offset >>40) &0xFF);

buffer01[i +12+ j *8+3] = (Byte)((current_offset >>32) &0xFF);

buffer01[i +12+ j *8+4] = (Byte)((current_offset >>24) &0xFF);

buffer01[i +12+ j *8+5] = (Byte)((current_offset >>16) &0xFF);

buffer01[i +12+ j *8+6] = (Byte)((current_offset >>8) &0xFF);

buffer01[i +12+ j *8+7] = (Byte)((current_offset >>0) &0xFF);

}

i += atom_size -4;

}

}

NSData*moov = [NSDatadataWithBytes:buffer01length:moov_atom_size];

free(buffer);

free(buffer01);

returnmoov;

}

参考:https://www.jianshu.com/p/0188ab0381ba
https://www.jianshu.com/p/bb925a4a9180
https://www.cnblogs.com/ios4app/p/6928806.html
https://www.jianshu.com/p/990ee3db0563

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

推荐阅读更多精彩内容