All you need is URL

image

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.

image


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.

image


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 :)

image

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:

image

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:

image

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:

image

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.

image

image

image

image

Like any solution, URLProtocol has its advantages and disadvantages.

Disadvantages of URLProtocol



URLProtocol



URL - — . . - , - , , , — , . , , — URL .

GitHub

Source: https://habr.com/ru/post/467605/


All Articles