技术栈

主页 > 其他 >

[iOS]JPVideoPlayer 3.0 源码解析

技术栈 - 中国领先的IT技术门户

大家好,我是 NewPan,这次我们来讲解 JPVideoPlayer 3.0 实现上的细节。

如果你没有了解实现原理的需求,请直接看另外一篇介绍如何使用的文章:[iOS]JPVideoPlayer 3.0 使用介绍

01. 漫长的选择

从去年发了 2.0 版本以后,越来越多的同学使用这个框架, issue 也越来越多,一度有 90 多个,但是绝大多数是说使用这个框架并不能实现变下边播,而是要下载完才能播。当时我也是头大,我看了整个 AVFoundation关于视频播放的文档,苹果除了留出了一个拦截 AVPlayer的请求的接口,另外没有任何关于对请求的处理的介绍。

文档没有结果,就去 Google 上搜,网上的结果大致有三类,第一类就是说不可能基 AVPlayer实现变下边播;第二类是就是 2.0 版本时候的样子,只能支持某些视频的边下边播;第三类是说使用本地代理实现对端口的请求的拦截。我自己考虑还可以使用 ijkPlayer 实现边下边播。

我最先研究的是 ijkPlayer ,因为 FFmpeg是开源的,只要从底层开始将播放器拿到请求数据回调到上层,就能实现视频数据的缓存。当时看了差不多一周的 ijkPlayer源码,整个 ijkPlayer大概有四层封装,最后才能看到 OC 的接口,我从最上层往下看,依次是 OC 层,iOS 平台层,iOS和安卓共用层,最后才是 FFmepg,我看到第二层,到后面越来越难,而且随着调试的深入,发现在平台特性上,内存、启动时间、优化、性能真的不如 AVPlayer,而且还有一点,现在很多 APP 都有直播功能,直播 SDK 都是使用 FFmpeg,如果直接基于ijkPlayer,会出现标识符重复,很多人都没办法使用。于是选择研究其他方案。

接下来开始研究使用本地代理实现对端口的请求的拦截,要实现对端口的拦截,GitHub 上有一个很有名的基于 GCD 的框架可以实现 GCDWebServer。这个框架的作者当时是为了做局网内实现 iPad 本地数据投屏到电视还是什么鬼的做了这样一个框架。这个框架的原理是对指定的端口进行拦截,然后让 AVPlayer往这个端口请求,然后就能拦截到 AVPlayer的所有请求,然后把这个请求转发给用户,用户可以响应本地的视频数据,然后 AVPlayer就可以开始播放了。这个很典型的使用场景就是,在局网内把 iPhone 或是 iPad 的本地数据分享给别的终端。这个想法挺棒的,我看到安卓有一个很棒的开源项目就是基于这个思路给安卓官方的播放器做的本地缓存。所以去年过年那几天都在研究这个框架。

但是这个GCDWebserver的作者只做了本地数据的响应,也就是说,我本地有一个数据,其他地方来请求,我把这个本地数据一片一片的读出来写到 socket 里,然后请求者就能拿到数据了,等这个数据写完以后,这个 socket 就断开了。但是我们现在要做的事情,我们的视频数据不在本地,我们拿到请求以后还要去网络上请求数据才能响应数据给 socket,这个框架不符合我们的使用场景。所以我要做的就是,自己基于这个框架写一个我们要用的功能。然后吭哧吭哧写了好几天,发现这些底层的写起来真的很费劲,而且调试也不容易。写高级语言习惯了,已经不会写底层了。

一次偶然逛 GitHub,看到了 AVPlayerCacheSupport 这个框架,发现这位作者实现了支持 seek 的缓存,赶紧下载了源码下来看了一下,发现原来 JPVideoPlayer 2.0 有些视频播不了是因为我对请求队列的管理出了问题,所以我后来联系了这个框架的作者,请他授权我在我的框架中使用他部分源码,他慷慨答应,但是要加他微信,他就没回我了。

到了这里,我把之前基于端口拦截的方案给停了,因为从底层开始写,真的效率太低了。而且既然 AVPlayer提供了请求拦截的入口,我就没必要自己再基于端口进行拦截了。

于是方案终于敲定,也就到了年后开工的时候了,现在想想,这个方案真的花了差不多半年的时间,也是挺不容易的。

02. 大致结构

接下来我们就把下面这张结构图讲清楚就可以了。

现在框架支持下面三种类型的视频路径的播放:

    1. 本地视频,就是上图绿色的 local file, play video。这个是最简单的,检查一下 URL,如果是本地 URL,初始化一个JPVideoPlayer,把路径塞给它,立马就开始播放了,没什么好讲的。
    1. 一个全新的没有任何缓存的 URL,也就是紫色部分 network result, play video。这个就复杂点,初始化一个播放器以后,拦截它的请求,然后把这个请求封装成为自己内部的请求,然后去网络上下载视频,下载下来响应播放器,同时也缓存到本地。
    1. 一个已经播过一部分,有部分缓存的 URL,上图 disk result, play video,和上面一样要初始化播放器,然后拦截播放器请求,然后要先去缓存中查一下这个请求,有哪些数据已经缓存到本地了,那些已经缓存到本地的数据就不用再去下载了,直接从磁盘中读出来就可以了,那些没缓存的就按照第二点的思路去下载。然后整个过程就串起来了。

接下来熟悉一下整个类目结构:

我现在按照我当时写的循序,从最低层开始,一点一点往上封装,直到最后用户看到的只有一个简单的接口。

  • 01.AVPlayer请求的截获
  • 02.AVPlayer请求队列的管理
  • 03.基于JPResourceLoadingRequestTask封装本地和网络请求
  • 04.JPVideoPlayerCacheFile如何管理断点续传
  • 05.JPResourceLoadingRequestTaskJPVideoPlayerCacheFile
  • 06.前后台状态管理
  • 07.UIView+WebVideoCache接口如何封装
  • 08.JPVideoPlayerControlViews怎么和播放业务完全解耦
  • 09.假横屏布局有何问题
  • 10.等高和不等高 cell 两种情况两种策略

03. AVPlayer请求的截获

// 获取到新的请求
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;

// 取消请求
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;

一切都从这里开始,我们从这里拿到 AVPlayer想要获取的数据的请求,然后将这个请求保存到数组中,然后发起网络请求,拿回这个请求的数据,然后将这个数据传回给播放器,视频播放就开始了。

同时,播放器也可能会调用取消请求的回调,告诉我们某个请求已经取消掉了,不用在请求数据了,此时我们就应该将这个请求从请求数组中移除掉。

04. AVPlayer请求队列的管理

上面有说过,2.0 版本时对这个请求队列的管理有问题导致有些视频播不了。之前的处理方式是,一旦AVPlayer有新的请求过来,就立马将之前内部请求停止掉,然后发起新的请求。这样导致的问题是,如果这个视频的metadata在视频数据的最前面,那么立马可以拿到这个元数据,就可以一个请求从头播到尾。但是并不是所有的视频在编码的时候都把metadata放在最前面,metadata可能编码在视频数据的任何位置,就像下面这张图一样。这就是 2.0 为什么有些视频能播,有些视频却要下载完再播的原因。

在生产环境,大多都不是metadata在视频数据的最前面,所以AVPlayer会不断地调整请求的 range 来获得视频的metadata。因为要播放一个视频,必须要有metadatametadata就是这个视频的身份证信息。下面我列出了使用青花瓷拦截一次视频播放的请求。

Range: bytes=0-1 // 获取请求的 contenttype,只响应视频或音频
Range: bytes=0-34611998 // 尝试从头到尾请求
Range: bytes=34537472-34611998 // 上次请求没有拿到 metadata, 调整请求range
Range: bytes=34548960-34611998 // 上次请求没有拿到 metadata, 调整请求range
Range: bytes=34603008-34611998 // 上次请求没有拿到 metadata, 调整请求range
Range: bytes=34601692-34603007 // 上次请求没有拿到 metadata, 调整请求range
Range: bytes=1388-34537471 // 获取 metadata 成功,视频开始播放
...

可以看到,AVPlayer的请求有一定的套路。第一次请求是拿到服务器的响应,看这个 URL 是不是一个视频或者音频,如果不是视频或者音频,播放器就直接抛出播放失败的错误。如果是一个可播放的 URL,那么接下来第一步,会假设这个视频的metadata在头部,如果不在头部,再一次,会假设在尾部,尾部还没有就可能编码在任何位置了,只能通过不断地尝试来获取到这个metadata。所以如果你要在手机端录制视频,最能快速播放这个视频的方式就是把这个metadata编码在视频头部,这样,别人播放时的时候就能一个请求百步穿杨,大大减少了这个视频看到首帧的时间,进而提高用户体验。而这个视频的数据编码在Range: bytes=1388-34537471的范围内,所以要多请求很多次。

知道了这些以后我们的请求队列就应该更改成为,不要进来一个请求就将之前的请求取消掉,而是应该将这些请求编队,让它们遵行 FIFO(先进先出),如果播放器明确要求将某个请求取消的时候,在将对应的请求 cancel 掉,然后移出队列。

上面两点都是JPVideoPlayerResourceLoader所做的事情的一部分,简单来说就是拦截请求,然后管理这些拦截到的请求。

05. 基于JPResourceLoadingRequestTask封装本地和网络请求

由于我们要做断点续传,所以不可能直接截获AVPlayer就拿这个请求的 range 进行请求。因为有些数据可能已经缓存在磁盘里了,不需要再次从网络上重复下载,而有些确实需要通过网络请求获取,就像下面这样。

所以当我们拿到一个AVPlayer请求的时候,先要和本地已有的缓存进行比对,然后按照规则:没有的从网络上下载,有的直接取本地。这样以后,每个AVPlayer请求就会拆分为多个内部的本地和网络请求,而JPResourceLoadingRequestTask就是内部请求。

JPResourceLoadingRequestTask是一个抽象模板类,不能直接被使用,需要继承并且实现它定义的方法,才可以使用。因为我们的使用场景就是本地和网络请求,所以框架中实现了网络和本地请求两个子类,分别对应的负责对应的请求,并在获得数据以后回调给它的代理。

到此为止,我们拦截到的请求就已经全部封装成为框架内部的请求了。

06. JPVideoPlayerCacheFile如何管理断点续传

对视频数据文件的增删改查绝对是这个框架的核心,这个文件是 AVPlayerCacheSupport的作者写的,我只是在他文件的基础上改了 bug,让这个类能正常工作。

这个文件持有两个文件句柄NSFileHandle,一个负责写文件,一个负责读文件。每当一个视频第一次播放的时候,播放器肯定会先请求前两个字节的数据,其实就是为了拿到这个 URL 的contentType,当拿到这个响应信息的时候,当前这个类也会把contentType信息缓存到本地。然后每次视频数据一片一片回来的时候,这个类拿到数据,就会使用文件句柄写到本地,然后每次写完数据也会将当前这一片数据的 range 保存起来。同时也会将这个 range 和已有的 range 进行比对,当这个 range 和已有的 range 有交集,或者前后衔接的时候,就将这两个 range 合成一个 range。

想象一下,按照这个规则一直循环下去,最后当这个文件缓存完全的时候,这些 range 最后会合并成一个 range,而这个 range 就是文件的长度。这样,我们就实现了文件的断点续传。

而读文件就相对简单了。但是读文件有一点需要注意,我们不应该将视频文件一次性全部读出来,假如一个视频有 1 GB,那内存会突然爆掉。所以我们应该采取的策略是一点一点读,比方说,每次读出 32 Kb 写给播放器,写完以后再读 32 Kb,这样循环,直到数据读完。

07. 前后台状态管理

对前后台的管理可能在不同的产品中有不同的形式,比方说用户将 APP 推入后台和用户滑出通知中心可能有不同的处理。而在 iPhone 设备上前后台总共分为“通知中心,控制中心,全局警告,双击 home 键,跳去其他 APP 分享,进入后台,锁屏”。而这些,都不用你操心,框架中有一个JPApplicationStateMonitor类,专门负责监听 APP 状态。你只需要成为代理就能轻松应对这些状态。

- (BOOL)shouldPausePlaybackWhenApplicationWillResignActiveForURL:(NSURL *)videoURL;

- (BOOL)shouldPausePlaybackWhenApplicationDidEnterBackgroundForURL:(NSURL *)videoURL;

- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromResignActiveForURL:(NSURL *)videoURL;

- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromBackgroundForURL:(NSURL *)videoURL;

08.UIView+WebVideoCache接口如何封装

考虑到列表播放视频的场景,一个是在列表中播放视频,还有就是从视频列表页跳转视频详情页面,另外一个就是详情页悬停的界面。框架为这三个场景封装了专门的 API,如果在使用中还有其他的场景,可以基于最基础的视频播放 API 进行封装。

08.1 列表中播放视频

列表中播放视频,像新浪微博、Facebook、Twitter 这样的 APP,都只有一个缓冲动画和播放&缓冲进度指示器,框架也使用了一样的思路进行了类似的封装。

08.2 视频详情播放视频

在详情页播放视频,上面的缓冲动画和播放&缓冲进度指示器都得有,而且还需要一套和用户交互控制视频播放的界面。

08.1 悬停播放视频

悬停的时候比较简单,就是单独一个视频窗口。

基于以上三个场景,封装了以下三个方法:

- (void)jp_playVideoMuteWithURL:url
             bufferingIndicator:nil
                   progressView:nil
        configurationCompletion:nil;

- (void)jp_playVideoWithURL:url
         bufferingIndicator:nil
                controlView:nil
               progressView:nil
    configurationCompletion:nil;

- (void)jp_playVideoWithURL:url
                    options:kNilOption
    configurationCompletion:nil;

当然还有一种必不可少的场景就是,比方说用户从列表页跳转到详情页,这个时候如果使用以上的接口,就会出现到了详情页以后视频重新播放,这样很影响用户体验。所以框架里对这种情况也进行了封装,就是使用包含resume的接口,这样就能实现连贯的播放了。就像下面这样:

09. JPVideoPlayerControlViews怎么和播放业务完全解耦

考虑到用户后期需要定制自己的界面,所以业务层和界面层必须完全解耦。框架里使用了面向协议的方式进行解耦。抽取了三个不同的协议,要定制不同的界面只需要实现指定的协议方法就可以根据播放状态更新 UI。

  • 缓冲动画指示器:<JPVideoPlayerBufferingProtocol>
  • 播放和缓冲进度指示器:<JPVideoPlayerProtocol>
  • 控制界面:<JPVideoPlayerProtocol>

同时框架还根据对应的协议实现了对应的模板类,如果没有定制 UI 的需求,可以直接使用模板类,就能快速实现对应的界面。同时也可以继承模板类替换 UI 素材快速定制 UI。

10. 假横屏布局有何问题

包括腾讯视频、优酷视频、哔哩哔哩等 APP 都是采用假横屏来实现视频横屏,那究竟什么是假横屏?下面这张图演示了什么是假横屏。将视频添加到 window 上,然后将视频顺时针旋转 90°,这样就是假横屏。

横屏代码如下:

- (void)executeLandscape {
    UIView *videoPlayerView = ...;
    CGRect screenBounds = [[UIScreen mainScreen] bounds];
    CGRect bounds = CGRectMake(0, 0, CGRectGetHeight(screenBounds), CGRectGetWidth(screenBounds));
    CGPoint center = CGPointMake(CGRectGetMidX(screenBounds), CGRectGetMidY(screenBounds));
    videoPlayerView.bounds = bounds;
    videoPlayerView.center = center;
    videoPlayerView.transform = CGAffineTransformMakeRotation(M_PI_2);
}

这样横是横过来了,但是这个videoPlayerView的子view 都没有横过来,而且就算是这些子view是使用 autolayout 布局的,也没有对应的更改约束。

下面是 frame 的文档说明:

The frame rectangle is position and size of the layer specified in the superlayer’s coordinate space. For layers, the frame rectangle is a computed property that is derived from the values in thebounds, anchorPoint and position properties. When you assign a new value to this property, the layer changes its position and bounds properties to match the rectangle you specified. The values of each coordinate in the rectangle are measured in points.

意思就是子view是相对父view进行布局的,现在我们直接更改videoPlayerViewboundscenter属性,而没有更改frame属性,这样就会导致子view布局出现问题,所以我们在更改完boundscenter以后,也要讲对应的frame属性也进行更正。这样更正以后,使用 autolayout 布局的子view布局就正常了。但是直接使用frame布局的子view还是会有横竖屏兼容的问题,所以框架里专门抽取了一个布局的方法给子类复写。

- (void)layoutThatFits:(CGRect)constrainedRect
nearestViewControllerInViewTree:(UIViewController *_Nullable)nearestViewController
        interfaceOrientation:(JPVideoPlayViewInterfaceOrientation)interfaceOrientation;

这个方法把父视图的大小传了过来,同时也把当前视图对应的控制器也传了过来,同时还把当前的视频的方向也传了过来,这样,就可以根据不同的屏幕方向进行不同的布局了。

11. 等高和不等高 cell 两种情况两种策略

不同的产品中可能同时存在等高和不等高的 cell 来播放视频,上个版本就只支持等高 cell 的滑动播放,其实滑动播放的策略是不分等高和不等高的,只要稍加修改就可以了。

这次不仅支持不等高 cell 的滑动播放,还支持在计算离 tableView 可见区域中心最近时,可以使用 cell 进行计算,也可以使用播放视频的 view 来进行计算。为此我专门画了一幅图来说明这个区别:

我的文章集合

下面这个链接是我所有文章的一个集合目录。这些文章凡是涉及实现的,每篇文章中都有 Github 地址,Github 上都有源码。

我的文章集合索引

你还可以关注我自己维护的简书专题 iOS开发心得。这个专题的文章都是实打实的干货。如果你有问题,除了在文章最后留言,还可以在微博 @盼盼_HKbuy上给我留言,以及访问我的 Github
责任编辑:admin  二维码分享:
本文标签: 请求视频播放AVPlayer这个metadata