How to cache AVURLAsset data downloaded by AVPLayer
Hi, Habr. My name is Vlad. I work as an iOS developer at FunCorp. We make entertainment apps. You may have heard about our flagship iFunny and the popular CIS application AyDaPrikol. In this article I will talk about how to get the video data downloaded by the player for further work with them.
tl; dr
If you only need a solution, check out this library .
Problem
In our application, iFunny content feed consists mainly of pictures and videos. For caching images, we use SDWebImage . For the video, we previously downloaded the file completely and only after that started playback. This worked for short videos. On long ones, too much time passed from the moment the screen was opened (boot started) to the start of playback, even on wifi.
Solutions
The first idea was to store AVAsset objects at the model level. This approach works within a session (AVPlayer will not download the same file several times), but will not work between application launches.
After that, I tried translating AVAsset to NSData using AVAssetExportSession . The export session worked well for AVAsset created from local files, but for remote assets I always got an error:
Error Domain=AVFoundationErrorDomain Code=-11800 “The operation could not be completed” UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16974), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x60000025a940 {Error Domain=NSOSStatusErrorDomain Code=-16974 “(null)”}}
The third solution was to use the resourceLoader field of AVURLAsset. This approach worked, but I ran into some problems during its implementation.
Implementation
According to Apple documentation :
An AVAssetResourceLoader mediates requests to load resources required by an AVURLAsset by asking a delegate object that you provide for assistance. When a resource is required that cannot be loaded by the AVURLAsset itself, the resource loader makes a request of its delegate to load it and proceeds according to the delegate’s response.
First you need to make sure that AVURLAsset cannot load the data on its own and calls the delegate methods of resourceLoader for each request. To do this, just change the URL scheme of AVURLAsset from HTTP (S) to any other. Do not forget to keep the original, you still need it.
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:URL resolvingAgainstBaseURL:NO];
components.scheme = @“customscheme”;
AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options];
When the resource loader cannot load the resource on its own, it calls the delegate method:
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
By returning YES from this method, you tell the resource loader that you are now responsible for this request. A NO response will result in an error inside AVURLAsset, since neither the resource loader itself nor the delegate can fulfill this request.
From this moment, work begins with the AVAssetResourceLoadingRequest object, which was passed to the delegate method as an argument. You can load data synchronously or asynchronously (do not forget to save the loadingRequest object somewhere if you load asynchronously). After loading is complete, you need to call finishLoading or finishLoadingWithError: depending on the result.
There are two types of AVAssetResourceLoadingRequest download requests: data request and content information request. You can determine the type by checking the fields:
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest;
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingDataRequest *dataRequest;
In response to the download request, you need to return the content type (UTI), its length and the flag "whether range requests are supported". For this, I used HTTP HEAD. When you receive a response, you need to fill in the data in the fields of the contentInformationRequest object and call the finishLoading method .
In response to the data request, you must return the data by URL, indentation and length, lying in the AVAssetResourceLoadingDataRequest object. If you want to write your implementation of the request, carefully read the documentation of AVAssetResourceLoadingDataRequest , there are not quite obvious points there.
I wrote an implementation with HTTP GET requests and a range header. While writing a data request, I noticed strange behavior. Requests and responses in NSURLSessionDataTas k may be different. The developer.apple forum thread confirms that this is a bug inside NSURLCache. Range header is ignored and you may not receive the piece of data that you requested. I was able to reproduce this only on iOS <= 10.
You must put the loaded data into the dataRequest using the respondWithData: method . You can save the same data to your cache. The next time you open this file, you can take data directly from the cache.
Thus, we were able to launch the video immediately after the start of the download and cache it without making unnecessary requests.
You can find the implementation of all delegate methods here . The library for caching AVURLAsset is here ; readme describes how to work with it.
If you have any questions, welcome to comments or PM. vdugnist