VKontakte users exchange 10 billion messages daily. They send each other photos, comics, memes and other attachments. We’ll tell you how in the iOS application we came up with uploading pictures using
URLProtocol
, and step by step we will figure out how to implement our own.
About a year and a half ago, the development of a new message section in the VK application for iOS was in full swing. This is the first section written entirely in Swift. It is located in a separate module
vkm
(VK Messages), which does not know anything about the device of the main application. It can even be run in a separate project - the basic functionality of reading and sending messages will continue to work. In the main application, message controllers are added via the corresponding Container View Controller to display, for example, a list of conversations or messages in a conversation.
Messages is one of the most popular sections of the VKontakte mobile application, so it is important that it works like a clock. In the
messages
project, we fight for every line of code. We always really liked how neatly the messages are built into the application, and we strive to ensure that everything remains the same.
Gradually filling the section with new functions, we approached the following task: we had to make sure that the photo that is attached to the message was first displayed in a draft, and after sending it, in the general list of messages. We could just add a module to work with
PHImageManager
, but additional conditions made the task more difficult.
When choosing a snapshot, the user can process it: apply a filter, rotate, crop, etc. In the VK application, this functionality is implemented in a separate
AssetService
component. Now it was necessary to learn how to work with him from the message project.
Well, the task is quite simple, we will do it. This is approximately the average solution, because there are a lot of variations. We take the protocol, dump it in messages and start filling it with methods. We add to the AssetService, adapt the protocol and add our cache implementation! for viscosity. Then we put the implementation in messages, add it to some service or manager that will work with all this, and start using it. At the same time, a new developer still comes and, while trying to figure it all out, he condemns in a whisper ... (well, you understand). At the same time, sweat appears on his forehead.
This decision was
not to our liking . New entities appear that message components need to know about when working with images from
AssetService
. The developer also needs to do extra work to figure out how this system works. Finally, an additional implicit link appeared on the components of the main project, which we try to avoid so that the message section continues to work as an independent module.
I wanted to solve the problem so that the project didn’t know anything at all about what kind of picture was chosen, how to store it, whether it needed special loading and rendering. At the same time, we already have the ability to download conventional images from the Internet, only they are not downloaded through an additional service, but simply by
URL
. And, in fact, there is no difference between the two types of images. Just some are stored locally, while others are stored on the server.
So we came up with a very simple idea: what if local assets can also be learned to load via
URL
? It seems that with one click of
Thanos’s fingers, it would solve all our problems: you don’t need to know anything about
AssetService
, add new data types and increase entropy in vain, learn to load a new type of image, take care of data caching. Sounds like a plan.
All we need is a URL
We considered this idea and decided to define the
URL
format that we will use to load local assets:
asset://?id=123&width=1920&height=1280
We will use the value of the
localIdentifier
property of
localIdentifier
as the
PHObject
, and we will pass the
width
and
height
parameters to load the images of the desired size. We also add some more parameters like
crop
,
filter
,
rotate
, which will allow you to work with the information of the processed image.
To handle these
URL
we will create an
AssetURLProtocol
:
class AssetURLProtocol: URLProtocol { }
Its task is to load the image through
AssetService
and return back the data that is already ready for use.
All this will allow us to almost completely delegate the work of the
URL
protocol and
URL Loading System
.
Inside the messages it will be possible to operate with the most common
URL
, only in a different format. It will also be possible to reuse the existing mechanism for loading images, it is very simple to serialize in the database, and implement data caching through standard
URLCache
.
Did it work out? If, reading this article, you can attach a photo from the gallery to the message in the VKontakte application, then yes :)
To make it clear how to implement your
URLProtocol
, I propose to consider this with an example.
We set ourselves the task: to implement a simple application with a list in which you need to display a list of map snapshots at the given coordinates. To download snapshots, we will use the standard
MKMapSnapshotter
from
MapKit
, and we will load data through the custom
URLProtocol
. The result might look something like this:
First, we implement the mechanism for loading data by
URL
. To display the map snapshot, we need to know the coordinates of the point - its latitude and longitude (
latitude
,
longitude
). Define the custom
URL
format by which we want to load the information:
map://?latitude=59.935634&longitude=30.325935
Now we implement
URLProtocol
, which will process such links and generate the desired result. Let's create the
MapURLProtocol
class, which we will inherit from the base class
URLProtocol
. Despite its name,
URLProtocol
is, although abstract, but a class. Don’t be embarrassed, here we use other concepts -
URLProtocol
represents exactly the
URL
protocol and has no relation to OOP terms. So
MapURLProtocol
:
class MapURLProtocol: URLProtocol { }
Now we redefine some required methods without which the
URL
protocol will not work:
1. canInit(with:)
override class func canInit(with request: URLRequest) -> Bool { return request.url?.scheme == "map" }
The
canInit(with:)
method is needed to indicate what types of requests our
URL
protocol can handle. For this example, suppose that the protocol will only process requests with a
map
scheme in the
URL
. Before starting any request, the
URL Loading System
goes through all the protocols registered for the session and calls this method. The first registered protocol, which in this method will return
true
, will be used to process the request.
canonicalRequest(for:)
override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request }
The
canonicalRequest(for:)
method is intended to reduce the request to canonical form. The documentation says that the implementation of the protocol itself decides what to consider as the definition of this concept. Here you can normalize the scheme, add headers to the request, if necessary, etc. The only requirement for this method to work is that for every incoming request there should always be the same result, including because this method is also used to search for cached answers requests in
URLCache
.
3. startLoading()
The
startLoading()
method describes all the logic for loading the necessary data. In this example, you need to parse the request
URL
and, based on the values of its
latitude
and
longitude
parameters, turn to
MKMapSnapshotter
and load the desired map snapshot.
override func startLoading() { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { fail(with: .badURL) return } load(with: queryItems) } func load(with queryItems: [URLQueryItem]) { let snapshotter = MKMapSnapshotter(queryItems: queryItems) snapshotter.start( with: DispatchQueue.global(qos: .background), completionHandler: handle ) } func handle(snapshot: MKMapSnapshotter.Snapshot?, error: Error?) { if let snapshot = snapshot, let data = snapshot.image.jpegData(compressionQuality: 1) { complete(with: data) } else if let error = error { fail(with: error) } }
After receiving the data, it is necessary to correctly shut down the protocol:
func complete(with data: Data) { guard let url = request.url, let client = client else { return } let response = URLResponse( url: url, mimeType: "image/jpeg", expectedContentLength: data.count, textEncodingName: nil ) client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) client.urlProtocol(self, didLoad: data) client.urlProtocolDidFinishLoading(self) }
First of all, create an object of type
URLResponse
. This object contains important metadata for responding to a request. Then we execute three important methods for an object of type
URLProtocolClient
. The
client
property of this type contains each entity of the
URL
protocol. It acts as a proxy between the
URL
protocol and the entire
URL Loading System
, which, when these methods are called, draws conclusions about what needs to be done with the data: cache, send requests to
completionHandler
, somehow process the protocol shutdown, etc. and the number of calls to these methods may vary depending on the protocol implementation. For example, we can download data from the network with batches and periodically notify
URLProtocolClient
about this in order to show the progress of data loading in the interface.
If an error occurs in the protocol operation, it is also necessary to correctly process and notify
URLProtocolClient
about this:
func fail(with error: Error) { client?.urlProtocol(self, didFailWithError: error) }
It is this error that will then be sent to the
completionHandler
the request execution, where it can be processed and a beautiful message displayed to the user.
4. stopLoading()
The
stopLoading()
method is called when the protocol has been completed for some reason. This can be either a successful completion, or an error completion or a request cancellation. This is a good place to free up occupied resources or delete temporary data.
override func stopLoading() { }
This completes the implementation of the
URL
protocol; it can be used anywhere in the application. To be where to apply our protocol, add a couple more things.
URLImageView
class URLImageView: UIImageView { var task: URLSessionDataTask? var taskId: Int? func render(url: URL) { assert(task == nil || task?.taskIdentifier != taskId) let request = URLRequest(url: url) task = session.dataTask(with: request, completionHandler: complete) taskId = task?.taskIdentifier task?.resume() } private func complete(data: Data?, response: URLResponse?, error: Error?) { if self.taskId == task?.taskIdentifier, let data = data, let image = UIImage(data: data) { didLoadRemote(image: image) } } func didLoadRemote(image: UIImage) { DispatchQueue.main.async { self.image = image } } func prepareForReuse() { task?.cancel() taskId = nil image = nil } }
This is a simple class, the descendant of
UIImageView
, a similar implementation of which you probably have in any application. Here we simply load the image by the
URL
in the
render(url:)
method and write it to the
image
property. The convenience is that you can upload absolutely any image, either by
http
/
https
URL
, or by our custom
URL
.
To execute requests for loading images, you will also need an object of type
URLSession
:
let config: URLSessionConfiguration = { let c = URLSessionConfiguration.ephemeral c.protocolClasses = [ MapURLProtocol.self ] return c }() let session = URLSession( configuration: config, delegate: nil, delegateQueue: nil )
Session configuration is especially important here. In
URLSessionConfiguration
there is one important property for us -
protocolClasses
. This is a list of the types of
URL
protocols that a session with this configuration can handle. By default, the session supports processing of
http
/
https
protocols, and if custom support is required, they must be specified. For our example, specify
MapURLProtocol
.
All that remains to be done is to implement the View Controller, which will display map snapshots. Its source code can be found
here .
Here is the result:
What about caching?
Everything seems to work well - except for one important point: when we scroll the list back and forth, white spots appear on the screen. It seems that snapshots are not cached in any way and for each call to the
render(url:)
method, we
MKMapSnapshotter
data through
MKMapSnapshotter
. This takes time, and therefore such gaps in loading. It is worth implementing a data caching mechanism so that already created snapshots are not downloaded again. Here we will take advantage of the power of the
URL Loading System
, which already has a caching mechanism for
URLCache
provided for this.
Consider this process in more detail and divide the work with the cache into two important stages: reading and writing.
Reading
In order to correctly read cached data, the
URL Loading System
needs to be helped to get answers to several important questions:
1. What URLCache to use?Of course, there is already finished
URLCache.shared
, but the
URL Loading System
cannot always use it - after all, the developer may want to create and use his own
URLCache
entity. To answer this question, the
URLSessionConfiguration
session
URLSessionConfiguration
has a
urlCache
property. It is used for both reading and recording responses to requests. We will
URLCache
some
URLCache
for these purposes in our existing configuration.
let config: URLSessionConfiguration = { let c = URLSessionConfiguration.ephemeral c.urlCache = ImageURLCache.current c.protocolClasses = [ MapURLProtocol.self ] return c }()
2. Do I need to use cached data or download again?The answer to this question depends on the
URLRequest
request we are about to execute. When creating a request, we have the opportunity to specify a cache policy in the
cachePolicy
argument in addition to the
URL
.
let request = URLRequest( url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 30 )
The default value is
.useProtocolCachePolicy
; this is also written in the documentation. This means that in this option, the task of finding a cached response to a request and determining its relevance lies entirely with the implementation of the
URL
protocol. But there is an easier way. If you set the value
.returnCacheDataElseLoad
, then when creating the next entity
URLProtocol
URL Loading System
will take on some of the work: it will ask
urlCache
cached response to the current request using the
cachedResponse(for:)
method. If there is cached data, then an object of type
CachedURLResponse
will be transferred immediately upon initialization of the
URLProtocol
and stored in the
cachedResponse
property:
override init( request: URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?) { super.init( request: request, cachedResponse: cachedResponse, client: client ) }
CachedURLResponse
is a simple class that contains data (
Data
) and meta-information for them (
URLResponse
).
We can only change the
startLoading
method a
startLoading
and check the value of this property inside it - and immediately end the protocol with this data:
override func startLoading() { if let cachedResponse = cachedResponse { complete(with: cachedResponse.data) } else { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { fail(with: .badURL) return } load(with: queryItems) } }
Record
To find data in the cache, you need to put it there. The
URL Loading System
also takes care of this work. All that is required of us is to tell her that we want to cache the data when the protocol
cacheStoragePolicy
using the
cacheStoragePolicy
cache policy
cacheStoragePolicy
. This is a simple enumeration with the following values:
enum StoragePolicy { case allowed case allowedInMemoryOnly case notAllowed }
They mean that caching is allowed in memory and on disk, only in memory or is prohibited. In our example, we indicate that caching is allowed in memory and on disk, because why not.
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
So, by following a few simple steps, we supported the ability to cache map snapshots. And now the application’s work looks like this:
As you can see, there are no more white spots - the cards are loaded once and then simply reused from the cache.
Not always easy
When implementing the
URL
protocol, we encountered a series of crashes.
The first was related to the internal implementation of the interaction of the
URL Loading System
with
URLCache
when caching responses to requests. The documentation
states : despite the
URLCache
safety of
URLCache
, the operation of the
cachedResponse(for:)
and
storeCachedResponse(_:for:)
methods for reading / writing responses to requests can lead to a race of states, therefore, this point should be taken into account in
URLCache
subclasses. We expected that using
URLCache.shared
this problem would be solved, but it turned out to be wrong. To fix this, we use a separate
ImageURLCache
cache, a descendant of
URLCache
, in which we execute the specified methods synchronously on a separate queue. As a pleasant bonus, we can separately configure the cache capacity in memory and on the disk separately from other
URLCache
entities.
private static let accessQueue = DispatchQueue( label: "image-urlcache-access" ) override func cachedResponse(for request: URLRequest) -> CachedURLResponse? { return ImageURLCache.accessQueue.sync { return super.cachedResponse(for: request) } } override func storeCachedResponse(_ response: CachedURLResponse, for request: URLRequest) { ImageURLCache.accessQueue.sync { super.storeCachedResponse(response, for: request) } }
Another problem was reproduced only on devices with iOS 9. The methods for starting and ending the loading of the
URL
protocol can be performed on different threads, which can lead to rare but unpleasant crashes. To solve the problem, we save the current thread in the
startLoading
method and then execute the download completion code directly on this thread.
var thread: Thread! override func startLoading() { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { fail(with: .badURL) return } thread = Thread.current if let cachedResponse = cachedResponse { complete(with: cachedResponse) } else { load(request: request, url: url, queryItems: queryItems) } }
func handle(snapshot: MKMapSnapshotter.Snapshot?, error: Error?) { thread.execute { if let snapshot = snapshot, let data = snapshot.image.jpegData(compressionQuality: 0.7) { self.complete(with: data) } else if let error = error { self.fail(with: error) } } }
When can a URL protocol come in handy?
As a result, almost every user of our iOS application in one way or another encounters elements that work through the
URL
protocol. In addition to downloading media from the gallery, various implementations of
URL
protocols help us display maps and polls, as well as show chat avatars composed of photos of their participants.
Like any solution,
URLProtocol
has its advantages and disadvantages.
Disadvantages of URLProtocol
- Lack of strict typing - when creating a
URL
scheme and link parameters are specified manually through strings. If you make a typo, the desired parameter will not be processed. This can complicate the debugging of the application and the search for errors in its operation. In the VKontakte application, we use special URLBuilder
that form the final URL
based on the parameters passed. This decision is not very beautiful and somewhat contradicts the goal of not producing additional entities, but there is no better idea yet. But we know that if you need to create some kind of custom URL
, then for sure there is a special URLBuilder
for it that will help you not to make a mistake. - Non-obvious crashes - I already described a couple of scenarios that could cause an application using
URLProtocol
to crash. Perhaps there are others. , , , stack trace' .
URLProtocol
- — , , , : , .
URL
— . - —
URL
- . . - — , ,
URL
-. URL
, URLSession
, URLSessionDataTask
. - —
URL
- URL
-, URL Loading System
. - * API — . , API, - ,
URL
-. , API , . URL
- http
/ https
.
URL
- — . . - , - , , , — , . , , —
URL
.
GitHub