视频加载优化

Posted by Self on May 16, 2024

在进行视频播放时如果直接使用URL开播,AVPlayer不会缓存,下次重播时会重新下载,十分浪费流量,并且很考验网速。所以对于播放一条视频较好的方式是边播边缓存,再次播放同一条视频时可以直接播缓存的数据。

IJKPlayer 的 ijkio:cache:ffio 协议

1
2
3
4
5
6
let newUrl = "ijkio:cache:ffio" + url

mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "cache_file_path",/storage/emulated/0/1.tmp");
mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "cache_map_path",“/storage/emulated/0/2.tmp"");
mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "parse_cache_map", 1);
mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "auto_save_map", 1);

缺点

  1. 预加载和缓存需要以创建播放器为前提
  2. 没看到有缓存管理的相关API,可能需要自行管理

AVPlayer Preroll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*!
	@method			prerollAtRate:completionHandler:
	@abstract		Begins loading media data to prime the render pipelines for playback from the current time with the given rate.
	@discussion		Once the completion handler is called with YES, the player's rate can be set with minimal latency.
				The completion handler will be called with NO if the preroll is interrupted by a time change or incompatible rate change, or if preroll is not possible for some other reason.
				Call this method only when the rate is currently zero and only after the AVPlayer's status has become AVPlayerStatusReadyToPlay.
				This method throws an exception if the status is not AVPlayerStatusReadyToPlay.
	@param rate		The intended rate for subsequent playback.
	@param completionHandler
					The block that will be called when the preroll is either completed or is interrupted.
*/
- (void)prerollAtRate:(float)rate completionHandler:(nullable void (^)(BOOL finished))completionHandler API_AVAILABLE(macos(10.8), ios(6.0), tvos(9.0), watchos(1.0));

/*!
	@method			cancelPendingPrerolls
	@abstract		Cancel any pending preroll requests and invoke the corresponding completion handlers if present.
	@discussion		Use this method to cancel and release the completion handlers for pending prerolls. The finished parameter of the completion handlers will be set to NO.
*/
- (void)cancelPendingPrerolls API_AVAILABLE(macos(10.8), ios(6.0), tvos(9.0), watchos(1.0));

/*!
	@property		sourceClock
	@abstract		Set to override the automatic choice of source clock for item timebases.
	@discussion		NULL by default. This is most useful for synchronizing video-only movies with audio played via other means. IMPORTANT NOTE: If you specify a source clock other than the appropriate audio device clock, audio may drift out of sync.
*/
  1. 创建 AVPlayer,设置 AVPlayerItem
  2. 设置 AVPlayer 的 rate = 0 (停止状态)
  3. 等 AVPlayer 的 status 变为 readyToPlay 之后调用 preroll 方法,否则会抛出异常
  4. 在 preroll 方法完成的回调或是业务需要的时机调用 play 或修改 rate 来开始播放

缺点

  1. 理论上可以通过 rate 参数控制预加载的数据量,但这个参数具体怎么决定的加载数据量不得而知,无法精确控制
  2. 需要先创建有 AVPlayer 对象和 AVPlayerItem,再调用 preroll 进行加载,不能独立开启预加载事务
  3. 调用时机和要求有较为严格的限制

AVAssetResourceLoader

用来处理 AVAsset 加载的工具,让我们可以外部接管整个视频加载的过程,自行控制 AVPlayer 数据的加载,以及可以决定传递多少数据给 AVPlayer。

1
2
3
4
let url = ".../test.mp4"
let asset = AVURLAsset(url: URL(string: url)!, options: nil)
let loader = asset.resourceLoader
asset.resourceLoader.setDelegate(self, queue: DispatchQueue.global())

原理

AVURLAsset 通过AVAssetResourceLoader来请求资源,AVAssetResourceLoader在请求的时候可以把相关的请求(AVAssetResourceLoadingRequest)代理出去,我们可以实现代理来自定义请求方法,最后把响应设置给AVAssetResourceLoadingRequest。

Untitled 0

1
2
3
4
5
6
7
//代理类是否可以处理该请求。我们通过在这个方法中捕获每个资源请求 loadingRequest,并创建对应的自定义网络请求对其所指定的数据进行读取或下载操作,完成后可以对 loadingRequest 进行完成操作
optional func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool

//主动放弃了某个资源请求。对此,我们需要将原始请求删除,并取消原始请求对应自定义请求的数据读取与下载
optional func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel authenticationChallenge: URLAuthenticationChallenge)
}

请求数据

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
open class AVAssetResourceLoadingRequest : NSObject {
    /** 
     @property 		request
     @abstract		An NSURLRequest for the requested resource.
    */
    open var request: URLRequest { get }

    /** 
     @property 		contentInformationRequest
     @abstract		An instance of AVAssetResourceLoadingContentInformationRequest that you must populate with information about the resource before responding to any AVAssetResourceLoadingDataRequests for the resource.  The value of this property will be nil if no such information is being requested.
    */
    @available(iOS 7.0, *)
    open var contentInformationRequest: AVAssetResourceLoadingContentInformationRequest? { get }

    /** 
     @property 		dataRequest
     @abstract		An instance of AVAssetResourceLoadingDataRequest that indicates the range of resource data that's being requested.  If an AVAssetResourceLoadingContentInformationRequest has been provided, you must set its properties appropriately before responding to any AVAssetResourceLoadingDataRequests.  The value of this property will be nil if no data is being requested.
    */
    @available(iOS 7.0, *)
    open var dataRequest: AVAssetResourceLoadingDataRequest? { get }
    
    /** 
     @method 		finishLoading   
     @abstract		Causes the receiver to treat the processing of the request as complete.
     @discussion	If a dataRequest is present and the resource does not contain the full extent of the data that has been requested according to the values of the requestedOffset and requestedLength properties of the dataRequest, or if requestsAllDataToEndOfResource has a value of YES, you may invoke -finishLoading after you have provided as much of the requested data as the resource contains.
    */
    @available(iOS 7.0, *)
    open func finishLoading()

    /** 
     @method 		finishLoadingWithError:   
     @abstract		Causes the receiver to treat the request as having failed.
     @param			error
     				An instance of NSError indicating the reason for failure.
    */
    open func finishLoading(with error: (any Error)?)
}

将数据设置到AVPlayer

根据 dataRequest 中的分片信息,创建并发起自定义网络请求。当远端的服务器响应该请求后,客户端会经历以下三个步骤,并调用相应的代理方法:

  1. 处理响应 当 AVPlayer 触发下载时,总是会先发起一个 Range 为 0-2 的数据请求,用来确认视频数据的信息,如文件类型、文件数据长度。当发起这个请求,收到服务端返回的 response 后,我们把这些视频的信息填充到 AVAssetResourceLoadingRequest 的 contentInformationRequest 属性中,告知下载的视频格式以及视频长度。

    从响应头部中获取资源相关信息,如:

    • ContentType 表示文件类型
    • Content-Range 包含文件长度信息
    • Accept-Ranges 包含是否支持分片请求
  2. 处理数据 获取完视频信息后,对于分页下载到的资源 data 数据, 可以塞给 AVAssetResourceLoadingRequest 里的 dataRequest 。 dataRequest 里面用 - (void)respondWithData:(NSData *)data; 专门用来接收下载的数据,这个方法可以调用多次,接收增量连续的 data 数据。 塞数据的同时可以在此时对下载到的数据进行缓存。
  3. 请求结束 当 AVAssetResourceLoadingRequest 要求的所有数据都下载完毕,调用 (void)finishLoading 完成下载,AVAssetResourceLoader 会继续发起之后的数据片段的请求。如果本次请求失败,也需要调用 (void)finishLoadingWithError:(nullable NSError *)error; 通知 AVAssetResourceLoader 结束下载。

重试

当一个分片的网络请求未完成时,拖动视频的进度条,AVAssetResourceLoader 会自动取消前一次的网络请求,从而发起一个新的网络请求。

在上述 didCancelLoadingRequest  的代理方法中,可以取消对应的自定义下载请求。

分片缓存下载

由于视频播放支持进度拖拽的功能,即 seek 功能。因此,seek到的节点发送的网络请求的分片与本地的分片数据可能存在如下关系:

  1. 本地包含完整分片数据,此时直接读取缓存数据进行播放即可
  2. 本地没有分片数据,需要取消正在下载的数据,然后从 seek 的点开始下载数据
  3. 本地包含部分分片数据,简单的做法是,当成上面的情况来处理,全部都重新下载,虽然逻辑简单,但会下载多次同样的数据。优化的解决方案是可以在收到 LoadingRequest 的请求范围后,会先获取已经下载的数据信息,把已下载的分片信息分别创建一个 action,再把需要远程下载的分片数据分别创建一个 action。最终组合就可能是 LocalAction(50-100 bytes) + RemoteAction(101-200 bytes) + LocalAction(201-300 bytes) + RemoteAction(300-400 bytes)。每一个 action 会按顺序获取数据再返回给 LoadingRequest。

Demo

https://github.com/vitoziv/VIMediaCache 待解读

缺点

  1. 分片下载 Downloader 逻辑复杂
  2. 需要使用 AVPlayer 作为播放器控件,不支持三方播放器

Local Server

客户端搭建本地服务器,作为视频播放的中间代理,代替客户端进行资源请求&进行缓存,再将视频数据返回给播放器。LS有几点好处:

  1. 不需要创建播放器对象就可以进行预加载缓存
  2. 控制预加载和缓存的比例大小
  3. 对播放器控件无要求

KTVHTTPCache

https://github.com/ChangbaDevs/KTVHTTPCache

Untitled

KTVHTTPCache 由 HTTP Server 和 Data Storage 两大模块组成。前者负责与客户端交互,后者负责资源加载及缓存处理。

其中 HTTP Server 主要负责与用户交互,也就是最顶层,最直接与用户交互(比如发起下载数据请求),而 Data Storage 则在后面为 HTTP Server 提供数据,数据主要从 DataSourcer 中获取,如果本地有数据,它会从 KTVHCDataFileSource 中获取,反之会从 KTVHCDataNetworkSource 中读取数据,这里会走下载逻辑(KTVHCDownload)。

HTTP Server

这层设计比较简单,主要是用了 CocoaHTTPServer 来作为本地的 HttpServer。说白了就是一个手机端的服务器,用来与用户交互,用户提出数据加载需求后,它会从不同的地方来获取数据源,如果本地没有会从网络中下载数据。

Untitled 1

  • KTVHCHTTPServer:是一个单例,用来管理 HttpServer 服务,负责开启或关闭服务;
  • KTVHCHTTPConnection:它继承于 HTTPConnection,表示一个连接,它主要为 HttpServer 提供 Response。
  • KTVHCHTTPRequest:一个请求,也就是一个数据模型;
  • KTVHCHTTPResponse:一个 Response;
  • KTVHCHTTPResponsePing:主要用来 ping 时的 Response;
  • KTVHCHTTPURL:主要用来处理 URL,比如把原 Url 生成 proxy url;

HttpServer 的关键点是在 KTVHCHTTPConnection 中下面这个方法,它是连接缓存模块的一个桥梁。使用 KTVHCDataRequest 和 KTVHCHTTPConnection 来生成 KTVHCHTTPResponse,作为 Local Server 的数据返回体:

1
2
3
4
5
6
7
8
9
10
11
12
13
@implementation KTVHCHTTPConnection
 
- (NSObject<HTTPResponse> *)httpResponseForMethod:(NSString *)method URI:(NSString *)path
{
    KTVHCLogHTTPConnection(@"%p, Receive request\nmethod : %@\npath : %@\nURL : %@", self, method, path, request.url);
    NSDictionary<NSString *,NSString *> *parameters = [[KTVHCURLTool tool] parseQuery:request.url.query];
    NSURL *URL = [NSURL URLWithString:[parameters objectForKey:@"url"]];
    KTVHCDataRequest *dataRequest = [[KTVHCDataRequest alloc] initWithURL:URL headers:request.allHeaderFields];
    KTVHCHTTPResponse *response = [[KTVHCHTTPResponse alloc] initWithConnection:self dataRequest:dataRequest];
    return response;
}
 
@end

Response 遵循 HTTPResponse 协议,实现协议方法,当本地发生请求时,就会获取 KTVHCHTTPResponse 内部方法返回的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
- (NSData *)readDataOfLength:(NSUInteger)length
{
    [self lock];
    if (self.isClosed) {
        [self unlock];
        return nil;
    }
    if (self.isFinished) {
        [self unlock];
        return nil;
    }
    if (self.error) {
        [self unlock];
        return nil;
    }
    NSData *data = [self.sourceManager readDataOfLength:length];
    if (data.length > 0) {
        self->_readedLength += data.length;
        if (self.response.contentLength > 0) {
            self->_progress = (double)self.readedLength / (double)self.response.contentLength;
        }
    }
    KTVHCLogDataReader(@"%p, Read data : %lld", self, (long long)data.length);
    if (self.sourceManager.isFinished) {
        KTVHCLogDataReader(@"%p, Read data did finished", self);
        self->_finished = YES;
        [self close];
    }
    [self unlock];
    return data;
}

KTVHCDataSourceManager通过KTVHCDataFileSource和KTVHCDataNetworkSource负责从本地文件获取数据和从网络获取数据,只要有可用的数据就通过代理回调给response。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)ktv_readerDidPrepare:(KTVHCDataReader *)reader
{
    KTVHCLogHTTPResponse(@"%p, Prepared", self);
    if (self.reader.isPrepared && self.waitingResponse == YES) {
        KTVHCLogHTTPResponse(@"%p, Call connection did prepared", self);
        [self.connection responseHasAvailableData:self];
    }
}
 
// 这个回调获取有可用的数据的通知。
- (void)ktv_readerHasAvailableData:(KTVHCDataReader *)reader
{
    KTVHCLogHTTPResponse(@"%p, Has available data", self);
    // 这个方法就会触发response的readDataOfLength
    [self.connection responseHasAvailableData:self];
}

分片与合并

缓存是分片处理的,缓存结构由请求的原 url,md5 后生成的字符串文件夹,它的子目录下会有多个文件,命名规则为:urlmd5_offset_数字,之后再合并为一个文件。

例如一次请求的 Range 为 0-999,本地缓存中已有 200-499 和 700-799 两段数据。那么会对应生成 5 个 Source,分别是:

  • 网络: 0-199
  • 本地: 200-499
  • 网络: 500-699
  • 本地: 700-799
  • 网络: 800-999

Untitled 2

Untitled 3

缺点

  1. 需要全局启动 HTTPServer,不需要缓存的 case 有一点浪费
  2. 只需要预加载,不需要播放时缓存的 case 可能需要想办法单独支持