Los usuarios de VKontakte intercambian 10 mil millones de mensajes diarios. Se envían fotos, cómics, memes y otros archivos adjuntos. Le diremos cómo se nos ocurrió una aplicación iOS para cargar imágenes usando
URLProtocol
, y paso a paso descubriremos cómo implementar la nuestra.
Hace aproximadamente un año y medio, el desarrollo de una nueva sección de mensajes en la aplicación VK para iOS estaba en pleno apogeo. Esta es la primera sección escrita completamente en Swift. Se encuentra en un módulo separado
vkm
(VK Messages), que no sabe nada sobre el dispositivo de la aplicación principal. Incluso se puede ejecutar en un proyecto separado: la funcionalidad básica de leer y enviar mensajes continuará funcionando. En la aplicación principal, los controladores de mensajes se agregan a través del controlador de vista de contenedor correspondiente para mostrar, por ejemplo, una lista de conversaciones o mensajes en una conversación.
Messages es una de las secciones más populares de la aplicación móvil VKontakte, por lo que es importante que funcione como un reloj. En el proyecto de
messages
, luchamos por cada línea de código. Siempre nos gustó lo bien que se integran los mensajes en la aplicación, y nos esforzamos por garantizar que todo siga igual.
Llenando gradualmente la sección con nuevas funciones, abordamos la siguiente tarea: era necesario hacer que la foto que se adjunta al mensaje se muestre primero en un borrador, y después de enviarla a la lista general de mensajes. Podríamos agregar un módulo para trabajar con
PHImageManager
, pero las condiciones adicionales dificultaron la tarea.
Al elegir una imagen, el usuario puede procesarla: aplicar un filtro, rotar, recortar, etc. En la aplicación VK, esta funcionalidad se implementa en un componente
AssetService
separado. Ahora era necesario aprender a trabajar con él desde el proyecto del mensaje.
Bueno, la tarea es bastante simple, lo haremos. Esta es aproximadamente la solución promedio, porque hay muchas variaciones. Tomamos el protocolo, lo volcamos en mensajes y comenzamos a llenarlo con métodos. ¡Agregamos al AssetService, adaptamos el protocolo y agregamos nuestra implementación de caché! para viscosidad Luego colocamos la implementación en mensajes, la agregamos a algún servicio o administrador que funcionará con todo esto y comenzamos a usarla. Al mismo tiempo, todavía llega un nuevo desarrollador y, mientras trata de resolverlo todo, lo condena en un susurro ... (bueno, ya entiendes). Al mismo tiempo, el sudor aparece en su frente.
Esta decisión
no fue
de nuestro agrado . Aparecen nuevas entidades que los componentes del mensaje deben conocer al trabajar con imágenes de
AssetService
. El desarrollador también necesita hacer un trabajo extra para descubrir cómo funciona este sistema. Finalmente, había un enlace implícito adicional a los componentes del proyecto principal, que tratamos de evitar para que la sección de mensajes continúe funcionando como un módulo independiente.
Quería resolver el problema para que el proyecto no supiera nada sobre qué tipo de imagen se eligió, cómo almacenarla, si necesitaba una carga y representación especial. Además, ya tenemos la capacidad de descargar imágenes convencionales de Internet, solo que no se descargan a través de un servicio adicional, sino simplemente por
URL
. Y, de hecho, no hay diferencia entre los dos tipos de imágenes. Solo algunos se almacenan localmente, mientras que otros se almacenan en el servidor.
Entonces se nos ocurrió una idea muy simple: ¿qué pasa si los activos locales también se pueden aprender a cargar a través de
URL
? Parece que con un solo clic de
los dedos de
Thanos resolvería todos nuestros problemas: no necesita saber nada sobre
AssetService
, agregar nuevos tipos de datos y aumentar la entropía en vano, aprender a cargar un nuevo tipo de imagen, cuidar el almacenamiento en caché de datos. Suena como un plan.
Todo lo que necesitamos es una URL
Consideramos esta idea y decidimos definir el formato de
URL
que usaremos para cargar activos locales:
asset://?id=123&width=1920&height=1280
Usaremos el valor de la propiedad
localIdentifier
de
localIdentifier
como
PHObject
, y pasaremos los parámetros
width
y
height
para cargar las imágenes del tamaño deseado. También agregamos algunos parámetros más como
crop
,
filter
,
rotate
, que le permitirán trabajar con la información de la imagen procesada.
Para manejar estas
URL
crearemos un
AssetURLProtocol
:
class AssetURLProtocol: URLProtocol { }
Su tarea es cargar la imagen a través de
AssetService
y devolver los datos que ya están listos para su uso.
Todo esto nos permitirá delegar casi por completo el trabajo del protocolo
URL Loading System
y el
URL Loading System
.
Dentro de los mensajes será posible operar con las
URL
más comunes, solo en un formato diferente. También será posible reutilizar el mecanismo existente para cargar imágenes, es muy sencillo serializar en la base de datos e implementar el almacenamiento en caché de datos a través de
URLCache
estándar.
¿Funcionó? Si, al leer este artículo, puede adjuntar una foto de la galería al mensaje en la aplicación VKontakte, entonces sí :)
Para dejar en claro cómo implementar su
URLProtocol
, propongo considerar esto con un ejemplo.
Nos propusimos la tarea: implementar una aplicación simple con una lista en la que necesita mostrar una lista de instantáneas de mapa en las coordenadas dadas. Para descargar instantáneas, utilizaremos el
MKMapSnapshotter
estándar de
MapKit
y
MapKit
datos a través del
URLProtocol
personalizado
URLProtocol
. El resultado podría verse más o menos así:
Primero implementamos el mecanismo para cargar datos por
URL
. Para mostrar la instantánea del mapa, necesitamos conocer las coordenadas del punto: su latitud y longitud (
latitude
,
longitude
). Defina el formato de
URL
personalizado mediante el cual queremos cargar información:
map://?latitude=59.935634&longitude=30.325935
Ahora implementamos
URLProtocol
, que procesará dichos enlaces y generará el resultado deseado.
MapURLProtocol
clase
MapURLProtocol
, que
MapURLProtocol
de la clase base
URLProtocol
. Sin
URLProtocol
, a pesar de su nombre,
URLProtocol
es una clase abstracta. No se avergüence, aquí usamos otros conceptos:
URLProtocol
representa exactamente el protocolo
URL
y no tiene relación con los términos de OOP. Entonces
MapURLProtocol
:
class MapURLProtocol: URLProtocol { }
Ahora redefinimos algunos métodos requeridos sin los cuales el protocolo
URL
no funcionaría:
1. canInit(with:)
override class func canInit(with request: URLRequest) -> Bool { return request.url?.scheme == "map" }
El
canInit(with:)
es necesario para indicar qué tipos de solicitudes puede manejar nuestro protocolo
URL
. Para este ejemplo, suponga que el protocolo solo procesará solicitudes con un esquema de
map
en la
URL
. Antes de comenzar cualquier solicitud, la
URL Loading System
pasa por todos los protocolos registrados para la sesión y llama a este método. El primer protocolo registrado, que en este método devolverá
true
, se utilizará para procesar la solicitud.
canonicalRequest(for:)
override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request }
El método
canonicalRequest(for:)
está destinado a reducir la solicitud a la forma canónica. La documentación dice que la implementación del protocolo en sí mismo decide qué considerar como la definición de este concepto. Aquí puede normalizar el esquema, agregar encabezados a la solicitud, si es necesario, etc. El único requisito para que este método funcione es que para cada solicitud entrante siempre debe haber el mismo resultado, incluso porque este método también se utiliza para buscar respuestas almacenadas en caché solicitudes en
URLCache
.
3. startLoading()
El método
startLoading()
describe toda la lógica para cargar los datos necesarios. En este ejemplo, debe analizar la
URL
solicitud y, en función de los valores de sus parámetros de
latitude
y
longitude
, recurrir a
MKMapSnapshotter
y cargar la instantánea de mapa deseada.
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) } }
Después de recibir los datos, es necesario apagar correctamente el protocolo:
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) }
En primer lugar, cree un objeto de tipo
URLResponse
. Este objeto contiene metadatos importantes para responder a una solicitud. Luego ejecutamos tres métodos importantes para un objeto de tipo
URLProtocolClient
. La propiedad del
client
de este tipo contiene cada entidad del protocolo
URL
. Actúa como un proxy entre el protocolo
URL
y la
URL Loading System
completa
URL Loading System
, que, cuando se invocan estos métodos, saca conclusiones sobre lo que se debe hacer con los datos: caché, pasar solicitudes a
completionHandler
, de alguna manera procesar el cierre del protocolo, etc. y el número de llamadas a estos métodos puede variar según la implementación del protocolo. Por ejemplo, podemos descargar datos de la red con lotes y notificar periódicamente a
URLProtocolClient
sobre esto para mostrar el progreso de la carga de datos en la interfaz.
Si se produce un error en el funcionamiento del protocolo, también es necesario procesar y notificar correctamente a
URLProtocolClient
sobre esto:
func fail(with error: Error) { client?.urlProtocol(self, didFailWithError: error) }
Es este error el que luego se enviará al
completionHandler
la solicitud, donde se puede procesar y mostrar un hermoso mensaje al usuario.
4. stopLoading()
Se llama al método
stopLoading()
cuando se completa la operación del protocolo por algún motivo. Esto puede ser una finalización exitosa, o una finalización de error o una cancelación de solicitud. Este es un buen lugar para liberar recursos ocupados o eliminar datos temporales.
override func stopLoading() { }
Esto completa la implementación del protocolo
URL
; se puede usar en cualquier lugar de la aplicación. Para saber dónde aplicar nuestro protocolo, agregue un par de cosas más.
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 } }
Esta es una clase simple, descendiente de
UIImageView
, una implementación similar de la que probablemente tenga en cualquier aplicación. Aquí simplemente cargamos la imagen por la
URL
en el método
render(url:)
y la escribimos en la propiedad de la
image
. La conveniencia es que puede cargar absolutamente cualquier imagen, ya sea por
http
/
https
URL
o por nuestra
URL
personalizada.
Para ejecutar solicitudes para cargar imágenes, también necesitará un objeto de tipo
URLSession
:
let config: URLSessionConfiguration = { let c = URLSessionConfiguration.ephemeral c.protocolClasses = [ MapURLProtocol.self ] return c }() let session = URLSession( configuration: config, delegate: nil, delegateQueue: nil )
La configuración de la sesión es especialmente importante aquí. En
URLSessionConfiguration
hay una propiedad importante para nosotros:
protocolClasses
. Esta es una lista de los tipos de protocolos
URL
que puede manejar una sesión con esta configuración. De manera predeterminada, la sesión admite el procesamiento de protocolos
http
/
https
, y si se requiere soporte personalizado, deben especificarse. Para nuestro ejemplo, especifique
MapURLProtocol
.
Todo lo que queda por hacer es implementar el controlador de vista, que mostrará instantáneas del mapa. Su código fuente se puede encontrar
aquí .
Aquí está el resultado:
¿Qué pasa con el almacenamiento en caché?
Todo parece funcionar bien, excepto un punto importante: cuando desplazamos la lista de un lado a otro, aparecen puntos blancos en la pantalla. Parece que las instantáneas no se almacenan en caché de ninguna manera y para cada llamada al método
render(url:)
,
MKMapSnapshotter
datos a través de
MKMapSnapshotter
. Esto lleva tiempo y, por lo tanto, tales brechas en la carga. Vale la pena implementar un mecanismo de almacenamiento en caché de datos para que las instantáneas ya creadas no se vuelvan a descargar. Aquí usamos el poder del
URL Loading System
, que ya cuenta con un mecanismo de almacenamiento en caché para
URLCache
.
Considere este proceso con más detalle y divida el trabajo con el caché en dos etapas importantes: lectura y escritura.
Lectura
Para leer correctamente los datos en caché, la
URL Loading System
necesita ayuda para obtener respuestas a varias preguntas importantes:
1. ¿Qué URLCache usar?Por supuesto, ya ha finalizado
URLCache.shared
, pero la
URL Loading System
no siempre puede usarlo; después de todo, el desarrollador puede querer crear y usar su propia entidad
URLCache
. Para responder a esta pregunta, la
URLSessionConfiguration
sesión
URLSessionConfiguration
tiene una propiedad
urlCache
. Se utiliza para leer y registrar respuestas a solicitudes.
URLCache
algunos
URLCache
para estos fines en nuestra configuración existente.
let config: URLSessionConfiguration = { let c = URLSessionConfiguration.ephemeral c.urlCache = ImageURLCache.current c.protocolClasses = [ MapURLProtocol.self ] return c }()
2. ¿Necesito usar datos en caché o descargar nuevamente?La respuesta a esta pregunta depende de la solicitud de
URLRequest
que estamos a punto de ejecutar. Al crear una solicitud, tenemos la oportunidad de especificar una política de caché en el argumento
cachePolicy
además de la
URL
.
let request = URLRequest( url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 30 )
El valor predeterminado es
.useProtocolCachePolicy
, que también está escrito en la documentación. Esto significa que en esta versión, la tarea de encontrar una respuesta en caché a una solicitud y determinar su relevancia recae completamente en la implementación del protocolo
URL
. Pero hay una manera más fácil. Si establece el valor
.returnCacheDataElseLoad
, al crear la siguiente entidad
URLProtocol
URL Loading System
asumirá parte del trabajo: solicitará a
urlCache
respuesta en caché a la solicitud actual utilizando el
cachedResponse(for:)
. Si hay datos en caché, entonces un objeto de tipo
CachedURLResponse
se transferirá inmediatamente cuando el
URLProtocol
inicialice y se almacene en la propiedad
cachedResponse
:
override init( request: URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?) { super.init( request: request, cachedResponse: cachedResponse, client: client ) }
CachedURLResponse
es una clase simple que contiene datos (
Data
) y metainformación para ellos (
URLResponse
).
Solo podemos cambiar un
startLoading
método
startLoading
y verificar el valor de esta propiedad dentro de él, e inmediatamente finalizar el protocolo con estos datos:
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) } }
Registro
Para encontrar datos en la memoria caché, debe colocarlos allí. El
URL Loading System
también se encarga de este trabajo. Todo lo que se requiere de nosotros es decirle que queremos almacenar en caché los datos cuando el protocolo se
cacheStoragePolicy
usando la
cacheStoragePolicy
política de caché
cacheStoragePolicy
. Esta es una enumeración simple con los siguientes valores:
enum StoragePolicy { case allowed case allowedInMemoryOnly case notAllowed }
Significan que el almacenamiento en caché está permitido en la memoria y en el disco, solo en la memoria o está prohibido. En nuestro ejemplo, indicamos que el almacenamiento en caché está permitido en la memoria y en el disco, porque ¿por qué no?
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
Entonces, siguiendo algunos pasos simples, admitimos la capacidad de almacenar en caché las instantáneas del mapa. Y ahora la aplicación funciona así:
Como puede ver, no hay más puntos blancos: las tarjetas se cargan una vez y luego simplemente se reutilizan del caché.
No siempre es facil
Al implementar el protocolo
URL
, encontramos una serie de bloqueos.
El primero estaba relacionado con la implementación interna de la interacción del
URL Loading System
con
URLCache
al almacenar en caché las respuestas a las solicitudes. La documentación
indica : a pesar de la seguridad del
URLCache
de
URLCache
, el funcionamiento de los
cachedResponse(for:)
y
storeCachedResponse(_:for:)
para leer / escribir respuestas a las solicitudes puede conducir a una raza de estados, por lo que este punto debe tenerse en cuenta en
URLCache
subclases de
URLCache
. Esperábamos que usando
URLCache.shared
este problema se resolviera, pero resultó ser incorrecto. Para solucionar esto, utilizamos un caché de
ImageURLCache
separado, un descendiente de
URLCache
, en el que ejecutamos los métodos especificados sincrónicamente en una cola separada. Como beneficio adicional, podemos configurar por separado la capacidad de caché en la memoria y en el disco por separado de otras entidades de
URLCache
.
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) } }
Otro problema se reprodujo solo en dispositivos con iOS 9. Los métodos para iniciar y finalizar la carga del protocolo
URL
se pueden realizar en diferentes subprocesos, lo que puede provocar bloqueos raros pero desagradables. Para resolver el problema, guardamos el hilo actual en el método
startLoading
y luego ejecutamos el código de finalización de descarga directamente en este hilo.
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) } } }
¿Cuándo puede ser útil un protocolo URL?
Como resultado, casi todos los usuarios de nuestra aplicación iOS de una forma u otra encuentran elementos que funcionan a través del protocolo
URL
. Además de descargar medios de la galería, varias implementaciones de protocolos
URL
nos ayudan a mostrar mapas y encuestas, así como a mostrar avatares de chat compuestos por fotos de sus participantes.
Como cualquier solución,
URLProtocol
tiene sus ventajas y desventajas.
Desventajas de URLProtocol
- Falta de escritura estricta : al crear una
URL
esquema y los parámetros de enlace se especifican manualmente a través de cadenas. Si realiza un error tipográfico, el parámetro deseado no se procesará. Esto puede complicar la depuración de la aplicación y la búsqueda de errores en su funcionamiento. En la aplicación VKontakte, utilizamos URLBuilder
especiales que forman la URL
final en función de los parámetros pasados. Esta decisión no es muy hermosa y de alguna manera contradice el objetivo de no producir entidades adicionales, pero aún no hay una mejor idea. Pero sabemos que si necesita crear algún tipo de URL
personalizada, entonces seguramente hay un URLBuilder
especial que lo ayudará a no cometer un error. URLProtocol
no obvios : ya describí un par de escenarios que podrían hacer que una aplicación que usa URLProtocol
bloquee. Quizás hay otros. , , , stack trace' .
URLProtocol
- — , , , : , .
URL
— . - —
URL
- . . - — , ,
URL
-. URL
, URLSession
, URLSessionDataTask
. - —
URL
- URL
-, URL Loading System
. - * API — . , API, - ,
URL
-. , API , . URL
- http
/ https
.
URL
- — . . - , - , , , — , . , , —
URL
.
GitHub