Ежедневно пользователи ВКонтакте обмениваются 10 млрд сообщений. Они отправляют друг другу фотографии, комиксы, мемы и другие вложения. Расскажем, как в iOS-приложении мы придумали загружать картинки с помощью
URLProtocol
, и пошагово разберём, как реализовать свой.Примерно полтора года назад в самом разгаре была разработка нового раздела сообщений в приложении VK для iOS. Это первый раздел, полностью написанный на Swift. Он разместился в отдельном модуле
vkm
(VK Messages), который ничего не знает про устройство основного приложения. Его даже можно запустить в отдельном проекте — базовая функциональность чтения и отправки сообщений при этом продолжит работать. В основное приложение контроллеры сообщений добавляются через соответствующие Container View Controller для отображения, например, списка бесед или сообщений в беседе.Сообщения — один из самых популярных разделов мобильного приложения ВКонтакте, поэтому важно, чтобы он работал как часы. В проекте
messages
мы бьёмся за каждую строчку кода. Нам всегда очень нравилось, как аккуратно сообщения встроены в приложение, и мы стремимся к тому, чтобы всё так и оставалось.Постепенно наполняя раздел новыми функциями, мы подошли к следующей задаче: нужно было сделать так, чтобы фотография, которая прикрепляется к сообщению, сначала отображалась в черновике, а после отправки — в общем списке сообщений. Мы могли бы просто добавить модуль для работы с
PHImageManager
, но дополнительные условия делали задачу сложнее.При выборе снимка пользователь может его обработать: наложить фильтр, повернуть, обрезать и т. д. В приложении VK такая функциональность реализована в отдельном компоненте
AssetService
. Теперь нужно было научиться работать с ним из проекта сообщений.Что ж, задача довольно простая, будем делать. Такое вот примерно решение усреднённое, потому что вариаций масса. Берём протокол, вываливаем его в messages и начинаем наполнять методами. Добавляем в AssetService, адаптируем протокол и добавляем свою реализацию КЕША! для вязкости. Потом заносим реализацию в messages, добавляем в какой-нибудь сервис или менеджер, который будет работать со всем этим, и начинаем использовать. При этом ещё приходит новый разработчик и, пока пытается разобраться во всём этом, приговаривает полушёпотом… (ну вы поняли). При этом у него на лбу аж пот выступает.
Такое решение нам было не по вкусу. Появляются новые сущности, о которых нужно знать компонентам сообщений при работе с изображениями из
AssetService
. Разработчику также нужно провести дополнительную работу, чтобы разобраться, как устроена эта система. Наконец, появлялась дополнительная неявная завязка на компоненты основного проекта, чего мы стараемся избегать, чтобы раздел сообщений и дальше продолжал работать как независимый модуль.Хотелось решить задачу так, чтобы проект вообще ничего не знал о том, что за картинка выбрана, как её хранить, нужно ли её по-особенному загружать и рендерить. При этом у нас уже есть возможность загрузки обычных изображений из интернета, только они загружаются не через дополнительный сервис, а просто по
URL
. И, по сути, разницы между этими двумя типами изображений нет. Просто одни хранятся локально, а другие — на сервере.Так мы пришли к очень простой идее: а что, если локальные ассеты тоже можно научиться загружать через
URL
? Кажется, что это одним щелчком AssetService
, добавлять новые типы данных и зря увеличивать энтропию, учиться загружать новый тип изображений, заботиться о кешировании данных. Звучит как план.Всё, что нам нужно, — URL
Мы обдумали эту идею и решили определить формат
URL
, который будем использовать для загрузки локальных ассетов:asset://?id=123&width=1920&height=1280
В качестве
id
будем использовать значение свойства localIdentifier
у PHObject
, а для загрузки изображений нужного размера передадим параметрами width
и height
. Также добавим ещё несколько параметров вроде crop
, filter
, rotate
, которые позволят работать с информацией обработанного изображения.Для обработки таких
URL
мы создадим AssetURLProtocol
:class AssetURLProtocol: URLProtocol {
}
Его задача — загружать изображение через
AssetService
и возвращать обратно уже готовые к использованию данные.Всё это позволит нам почти полностью делегировать работу
URL
-протоколу и URL Loading System
.Внутри сообщений можно будет оперировать самыми обычными
URL
, только другого формата. Также появится возможность переиспользовать уже существующий механизм по загрузке изображений, очень просто сериализовать в БД, а кеширование данных реализовать через стандартный URLCache
.Получилось ли? Если, читая эту статью, вы можете в приложении ВКонтакте прикрепить к сообщению фотографию из галереи, то да :)
Чтобы было понятно, как реализовать свой
URLProtocol
, предлагаю рассмотреть это на примере.Поставим себе задачу: реализовать простое приложение со списком, в котором нужно по заданным координатам отображать список снапшотов карт. Для загрузки снапшотов будем использовать стандартный
MKMapSnapshotter
из MapKit
, а загрузку данных реализуем через кастомный URLProtocol
. Результат может выглядеть примерно так:Сначала реализуем механизм загрузки данных по
URL
. Для отображения снапшота карты нам необходимо знать координаты точки — её широту и долготу (latitude
, longitude
). Определим формат кастомного URL
, по которому хотим загружать информацию:map://?latitude=59.935634&longitude=30.325935
Теперь реализуем
URLProtocol
, который будет обрабатывать такие ссылки и формировать нужный результат. Создадим класс MapURLProtocol
, который унаследуем от базового класса URLProtocol
. Несмотря на своё название, URLProtocol
является хоть и абстрактным, но классом. Не смущайтесь, здесь мы оперируем другими понятиями — URLProtocol
представляет именно URL
-протокол и к терминам ООП отношения не имеет. Итак, MapURLProtocol
:class MapURLProtocol: URLProtocol {
}
Теперь переопределим несколько обязательных методов, без которых
URL
-протокол работать не будет:1. canInit(with:)
override class func canInit(with request: URLRequest) -> Bool {
return request.url?.scheme == "map"
}
Метод
canInit(with:)
необходим, чтобы указать, какие типы запросов наш URL
-протокол может обрабатывать. Для этого примера предположим, что протокол будет обрабатывать только те запросы, в URL
которых указана схема map
. Перед началом выполнения любого запроса URL Loading System
проходит по всем зарегистрированным для сессии протоколам и вызывает этот метод. Первый зарегистрированный протокол, который в этом методе вернёт true
, и будет использован для обработки запроса.2. canonicalRequest(for:)
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
Метод
canonicalRequest(for:)
предназначен для приведения запроса к каноническому виду. Документация гласит, что реализация протокола сама решает, что считать определением этого понятия. Здесь можно нормализовать схему, добавить заголовки к запросу, если это нужно, и т. д. Единственное требование к работе этого метода — на каждый входящий запрос всегда должен быть одинаковый результат, в том числе потому что этот метод используется ещё и для поиска закешированных ответов на запросы в URLCache
.3. startLoading()
В методе
startLoading()
описывается вся логика по загрузке необходимых данных. В этом примере нужно разобрать URL
запроса и, исходя из значений его параметров latitude
и longitude
, обратиться к MKMapSnapshotter
и загрузить нужный снапшот карты.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)
}
}
После получения данных необходимо корректно завершить работу протокола:
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)
}
Прежде всего создаём объект типа
URLResponse
. Этот объект содержит важные метаданные для ответа на запрос. Затем выполняем три важных метода у объекта типа URLProtocolClient
. Свойство client
этого типа содержит каждая сущность URL
-протокола. Оно выполняет роль прокси между URL
-протоколом и всей URL Loading System
, которая при вызове этих методов делает выводы о том, что нужно сделать с данными: закешировать, передать в completionHandler
запроса, как-то обработать завершение работы протокола и т. д. Порядок и количество вызовов этих методов может отличаться в зависимости от реализации протокола. Например, мы можем загружать данные из сети батчами и периодически оповещать об этом URLProtocolClient
, чтобы в интерфейсе показать прогресс загрузки данных.При возникновении ошибки в работе протокола её также необходимо корректно обработать и оповестить об этом
URLProtocolClient
:func fail(with error: Error) {
client?.urlProtocol(self, didFailWithError: error)
}
Именно эта ошибка затем будет отправлена в
completionHandler
выполнения запроса, где её можно обработать и показать красивое сообщение пользователю.4. stopLoading()
Метод
stopLoading()
вызывается, когда работа протокола по какой-то причине была завершена. Это может быть как успешное завершение, так и завершение с ошибкой или отмена запроса. Это хорошее место для того, чтобы освободить занятые ресурсы или удалить временные данные.override func stopLoading() { }
На этом реализация
URL
-протокола завершена, его можно использовать в любом месте приложения. Чтобы было где применять наш протокол, добавим ещё пару вещей.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
}
}
Это простой класс, наследник
UIImageView
, похожая реализация которого наверняка есть в любом приложении у каждого из вас. Здесь мы просто по URL
в методе render(url:)
загружаем картинку и записываем в свойство image
. Удобство в том, что можно загружать абсолютно любые картинки, как по http
/https
URL
, так и по нашим кастомным URL
.Для выполнения запросов на загрузку изображений также понадобится объект типа
URLSession
:let config: URLSessionConfiguration = {
let c = URLSessionConfiguration.ephemeral
c.protocolClasses = [
MapURLProtocol.self
]
return c
}()
let session = URLSession(
configuration: config,
delegate: nil,
delegateQueue: nil
)
Здесь особенно важна конфигурация сессии. В
URLSessionConfiguration
есть одно важное для нас свойство — protocolClasses
. Здесь указывается список типов URL
-протоколов, которые сессия с данной конфигурацией умеет обрабатывать. По умолчанию сессия поддерживает обработку http
/https
-протоколов, а если требуется поддержка кастомных, их необходимо указать. Для нашего примера указываем MapURLProtocol
.Всё, что осталось сделать, — реализовать View Controller, который будет отображать снапшоты карт. Его исходный код можно посмотреть здесь.
Вот такой получается результат:
А что с кешированием?
Вроде всё работает хорошо — за исключением одного важного момента: когда мы скроллим список туда-сюда, на экране появляются белые пятна. Похоже, снапшоты никак не кешируются и на каждый вызов метода
render(url:)
мы заново загружаем данные через MKMapSnapshotter
. На это нужно время, оттого и такие пробелы при загрузке. Стоит реализовать механизм кеширования данных, чтобы уже созданные снапшоты не загружать снова. Здесь мы воспользуемся силой URL Loading System
, в которой уже есть предусмотренный для этого механизм кеширования через URLCache
.Рассмотрим этот процесс подробнее и разделим работу с кешем на два важных этапа: чтение и запись.
Чтение
Чтобы корректно считывать закешированные данные,
URL Loading System
нужно помочь получить ответы на несколько важных вопросов:1. Какой URLCache использовать?
Конечно, есть уже готовый
URLCache.shared
, но URL Loading System
не может всегда использовать его — ведь разработчик может захотеть создать и использовать свою сущность URLCache
. Для ответа на этот вопрос в конфигурации сессии URLSessionConfiguration
есть свойство urlCache
. Именно оно используется как для чтения, так и для записи ответов на запросы. Укажем какой-нибудь URLCache
для этих целей в нашей существующей конфигурации.let config: URLSessionConfiguration = {
let c = URLSessionConfiguration.ephemeral
c.urlCache = ImageURLCache.current
c.protocolClasses = [
MapURLProtocol.self
]
return c
}()
2. Нужно ли использовать кешированные данные или выполнять загрузку заново?
Ответ на этот вопрос зависит от запроса
URLRequest
, который мы собираемся выполнить. При создании запроса у нас есть возможность помимо URL
указать политику кеширования в аргументе cachePolicy
. let request = URLRequest(
url: url,
cachePolicy: .returnCacheDataElseLoad,
timeoutInterval: 30
)
По умолчанию используется значение
.useProtocolCachePolicy
, об этом также написано в документации. Это значит, что в таком варианте задача по поиску кешированного ответа на запрос и определению его актуальности полностью ложится на реализацию URL
-протокола. Но есть и более простой способ. Если задать значение .returnCacheDataElseLoad
, то при создании очередной сущности URLProtocol
URL Loading System
возьмёт часть работы на себя: запросит у urlCache
закешированный ответ на текущий запрос с помощью метода cachedResponse(for:)
. Если закешированные данные есть, то объект типа CachedURLResponse
будет передан сразу при инициализации URLProtocol
и сохранён в свойство cachedResponse
:override init(
request: URLRequest,
cachedResponse: CachedURLResponse?,
client: URLProtocolClient?) {
super.init(
request: request,
cachedResponse: cachedResponse,
client: client
)
}
CachedURLResponse
— это простой класс, который содержит данные (Data
) и метаинформацию для них (URLResponse
).Нам остаётся только немного изменить метод
startLoading
и проверить внутри него значение этого свойства — и сразу завершить работу протокола с этими данными: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)
}
}
Запись
Чтобы найти в кеше данные, их туда надо положить. Эту работу также полностью берёт на себя
URL Loading System
. Всё, что требуется от нас, — сообщить ей, что мы хотим закешировать данные при завершении работы протокола с помощью параметра политики кеширования cacheStoragePolicy
. Это простое перечисление с такими значениями:enum StoragePolicy {
case allowed
case allowedInMemoryOnly
case notAllowed
}
Они означают, что кеширование разрешено в память и на диск, только в память или запрещено. В нашем примере указываем, что кеширование разрешено в память и на диск, потому что почему бы и нет.
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
Вот так, выполнив несколько простых шагов, мы поддержали возможность кеширования снапшотов карт. И теперь работа приложения выглядит так:
Как видим, больше никаких белых пятен — карты загружаются один раз и затем просто переиспользуются из кеша.
Не всегда всё просто
При реализации
URL
-протокола мы столкнулись с рядом падений.Первое было связано с внутренней реализацией взаимодействия
URL Loading System
с URLCache
при кешировании ответов на запросы. В документации указано: несмотря на потокобезопасность URLCache
, работа методов cachedResponse(for:)
и storeCachedResponse(_:for:)
для чтения?/?записи ответов на запросы может приводить к гонке состояний, поэтому в подклассах URLCache
необходимо этот момент учитывать. Мы рассчитывали, что при использовании URLCache.shared
эта проблема будет решена, но оказалось не так. Чтобы исправить это, мы используем отдельный кеш ImageURLCache
, наследник URLCache
, в котором выполняем указанные методы синхронно на отдельной очереди. В качестве приятного бонуса можем отдельно от других сущностей 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)
}
}
Другая проблема воспроизводилась только на устройствах с iOS 9. Методы начала и окончания загрузки
URL
-протокола могут выполняться на разных потоках, что может привести к редким, но неприятным падениям. Чтобы решить проблему, мы сохраняем текущий поток в методе startLoading
и затем код завершения загрузки выполняем непосредственно на этом потоке.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)
}
}
}
Когда может пригодиться URL-протокол?
В итоге практически каждый пользователь нашего приложения для iOS так или иначе сталкивается с элементами, работающими через
URL
-протокол. Помимо загрузки медиа из галереи, различные реализации URL
-протоколов помогают нам отображать карты и опросы, а также показывать аватарки бесед, составленные из фотографий их участников.Как и любое решение,
URLProtocol
имеет свои преимущества и недостатки.Недостатки URLProtocol
- Отсутствие строгой типизации — при создании
URL
схема и параметры ссылки указываются вручную через строки. Если допустить опечатку, нужный параметр не будет обрабатываться. Это может усложнить отладку приложения и поиск ошибки в его работе. В приложении ВКонтакте мы используем специальныеURLBuilder
’ы, которые формируют конечныйURL
исходя из переданных параметров. Это решение не очень красивое и несколько противоречит цели не плодить дополнительные сущности, но лучшей идеи пока нет. Зато мы знаем, что если нужно создать какой-то кастомныйURL
, то наверняка для него есть специальныйURLBuilder
, который поможет не ошибиться. - Неочевидные падения — я уже описал пару сценариев, из-за которых приложение, использующее
URLProtocol
, может упасть. Возможно, есть и другие. Но такие проблемы, как обычно, решаются либо более вдумчивым чтением документации, либо глубоким изучением stack trace’а и нахождением корня проблемы.
Преимущества URLProtocol
- Слабая связанность компонентов — часть приложения, которая инициирует нужную ей загрузку данных, может вообще не знать о том, как она организована: какие компоненты для этого используются, как устроено кеширование. Мы знаем только про определённый формат
URL
— и только с ним взаимодействуем. - Простота реализации — для корректной работы
URL
-протокола достаточно реализовать несколько простых методов и зарегистрировать протокол. После этого его можно использовать в любом месте приложения. - Удобство в использовании — приложению не нужно иметь дополнительные типы данных, которые участвуют в процессе загрузки данных, кроме самого
URL
-протокола. Для работы используются уже известные типыURL
,URLSession
,URLSessionDataTask
. - Поддержка кеширования — при правильной реализации
URL
-протокола и конфигурацииURL
-сессии, а также корректном формировании запроса работа по кешированию данных ложится полностью наURL Loading System
. - *Можно замокать API — это такой дополнительный пункт со звёздочкой. При желании можно сделать так, что запросы будут выполняться не к реальному API, а к какой-то собственной заглушке, реализованной через
URL
-протокол. В нём можно отдавать любые тестовые данные, чтобы даже без доступа к настоящему API проверить работу приложения и состояния в зависимости от ответа, написать тесты. В определённый момент нужно будет только заменить использованиеURL
-протокола с кастомной схемой на стандартныйhttp
/https
.
URL
-протокол — не панацея и подходит далеко не для всех задач. У него есть преимущества и недостатки. Но всё-таки в следующий раз, когда потребуется что-то загрузить по заданным параметрам, асинхронно выполнить операцию, а конечные данные закешировать, — просто проверьте, вдруг такой подход поможет устранить проблему. Иногда всё, что нужно для решения задачи, — это URL
.Полный исходный код проекта можно посмотреть на нашем GitHub
andreyverbin
Есть альтернатива — а что если удаленные ассеты загружать так же как локальные, через имеющийся уже сервис? У подхода есть несколько мощнейших преимуществ — а) не нужно разбираться с nsurlprotocol б) отлично ложится на уже имеющийся код в) ненужно ограничивать себя только URL в качестве идентификатора объекта.
itdevelop
Возможно, я не очень понятно рассказал в статье, попробую ещё раз.
Во-первых, AssetService работает только с PHImageManager и умеет загружать только локальные изображения. Не очень понятно, как сюда можно прикрутить работу с удалёнными изображениями, учитывая, что для них у нас в наличии есть только URL.
Во-вторых, как я уже сказал, в проекте messages нет никакого знания о наличии сервиса AssetService, т.к. он расположен в другом проекте, на который ссылки у messages нет. В цитате про жареный суп я описал, как можно было бы сделать, но затем так же описал почему нам это решение не понравилось.
В-третьих, мы лишаемся важных преимуществ работы с URL — поддержки кеширования изображения по URL «из коробки» и возможности удобной сериализации ключа (URL) с уже учтёнными параметрами вроде ширины, высоты, обрезки для сохранения этой информации в БД.
andreyverbin
Например, как-то так
Сравним с подходом на NsUrlProtocol
Утверждение — первый вариант решает аналогичную задачу и его проще понять и выяснить как он работает с помощью функции IDE «Go To Definition». Мне интересно услышать контраргументы.
В разделе про жареный суп вы предложили плохое решение и от него отказались. Вы не рассмотрели всех решений или хотя бы ряд достаточно очевидных и достаточно хороших решений, которые не требуют NsUrlProtocol. Связь с AssetService никуда не делась, просто вы теперь его используете через прослойку в виде NsUrlProtocol.
Кеширование не теряется, загрузка удаленных картинок все также происходит через NSUrlConnection, кеш все таке участвует. Но вместо цепочки URL -> Protocol -> Connection -> Cache, есть цепочка Service -> Connection -> Cache. Поясняющий пример
Мое ИМХО — NsUrlProtocol нужен **только** если вы не контролируете потребителя данных и он может использовать только URL.
itdevelop
Кажется, что всё, что вы сделали — перевернули всё в обратную сторону. Чтобы сервис по загрузке и обработке изображений из галереи умел так же и ходить на сервер за удалённой картинкой. Не очень понимаю, чем это решение лучше предложенного. К тому же, вся остальная функциональность по обработке изображений (наложению фильтра, обрезке и т.д.) для удалённых картинок не нужна. Получается, что нужно делать ещё один отдельный сервис, который в зависимости от того, нужно ли загружать удалённую картинку или локальную, будет выполнять разную логику.
Ещё раз повторюсь, в проекте messages ничего не известно про наличие AssetService. И знания о нём в этом проекте не прибавилось. Мы используем URL определённого формата, а какой URL-протокол его обработает и обработает ли вообще — это отдельная задача.
Контраргументов против «Go To Definition» в IDE не приведу. Действительно удобная функция.
Кеширование теряется, поскольку загрузка через URLSession (NSURLConnection уже deprecated) осуществляется только для удалённых картинок. Для локальных, при использовании вашего решения, нужно придумывать что-то своё.
Плюс, как я уже упомянул в статье, у нас уже есть механизм загрузки удалённых изображений через URLImageView. Вы же предлагаете ради загрузки разного рода изображений его переделывать на работу с каким-то ранее неизвестным AssetService, чтобы вся загрузка шла через него. Кажется, что это знание добавлять вовсе необязательно.