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的方式实现的,本次JimuPro IOS里面用的方案就是使用VGPlayer来实现的。

接下来详细讲解实现原理

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下载
6. 进入ffmpeg解压路径执行命令:make tools/qt-faststart,会看到在tools中会出现一个qt-faststart文件(还有一个.c文件)
7. 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)
    }
}


发布了250 篇原创文章 · 获赞 93 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/kyl282889543/article/details/104354112