Pull to refresh
VK
Building the Internet

All you need is URL

Reading time 13 min
Views 8.6K
image

Ежедневно пользователи ВКонтакте обмениваются 10 млрд сообщений. Они отправляют друг другу фотографии, комиксы, мемы и другие вложения. Расскажем, как в iOS-приложении мы придумали загружать картинки с помощью URLProtocol, и пошагово разберём, как реализовать свой.

Примерно полтора года назад в самом разгаре была разработка нового раздела сообщений в приложении VK для iOS. Это первый раздел, полностью написанный на Swift. Он разместился в отдельном модуле vkm (VK Messages), который ничего не знает про устройство основного приложения. Его даже можно запустить в отдельном проекте — базовая функциональность чтения и отправки сообщений при этом продолжит работать. В основное приложение контроллеры сообщений добавляются через соответствующие Container View Controller для отображения, например, списка бесед или сообщений в беседе.

Сообщения — один из самых популярных разделов мобильного приложения ВКонтакте, поэтому важно, чтобы он работал как часы. В проекте messages мы бьёмся за каждую строчку кода. Нам всегда очень нравилось, как аккуратно сообщения встроены в приложение, и мы стремимся к тому, чтобы всё так и оставалось.

Постепенно наполняя раздел новыми функциями, мы подошли к следующей задаче: нужно было сделать так, чтобы фотография, которая прикрепляется к сообщению, сначала отображалась в черновике, а после отправки — в общем списке сообщений. Мы могли бы просто добавить модуль для работы с PHImageManager, но дополнительные условия делали задачу сложнее.

image


При выборе снимка пользователь может его обработать: наложить фильтр, повернуть, обрезать и т. д. В приложении VK такая функциональность реализована в отдельном компоненте AssetService. Теперь нужно было научиться работать с ним из проекта сообщений.

Что ж, задача довольно простая, будем делать. Такое вот примерно решение усреднённое, потому что вариаций масса. Берём протокол, вываливаем его в messages и начинаем наполнять методами. Добавляем в AssetService, адаптируем протокол и добавляем свою реализацию КЕША! для вязкости. Потом заносим реализацию в messages, добавляем в какой-нибудь сервис или менеджер, который будет работать со всем этим, и начинаем использовать. При этом ещё приходит новый разработчик и, пока пытается разобраться во всём этом, приговаривает полушёпотом… (ну вы поняли). При этом у него на лбу аж пот выступает.

image


Такое решение нам было не по вкусу. Появляются новые сущности, о которых нужно знать компонентам сообщений при работе с изображениями из 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.

Получилось ли? Если, читая эту статью, вы можете в приложении ВКонтакте прикрепить к сообщению фотографию из галереи, то да :)

image

Чтобы было понятно, как реализовать свой URLProtocol, предлагаю рассмотреть это на примере.

Поставим себе задачу: реализовать простое приложение со списком, в котором нужно по заданным координатам отображать список снапшотов карт. Для загрузки снапшотов будем использовать стандартный MKMapSnapshotter из MapKit, а загрузку данных реализуем через кастомный URLProtocol. Результат может выглядеть примерно так:

image

Сначала реализуем механизм загрузки данных по 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, который будет отображать снапшоты карт. Его исходный код можно посмотреть здесь.

Вот такой получается результат:

image

А что с кешированием?


Вроде всё работает хорошо — за исключением одного важного момента: когда мы скроллим список туда-сюда, на экране появляются белые пятна. Похоже, снапшоты никак не кешируются и на каждый вызов метода 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)

Вот так, выполнив несколько простых шагов, мы поддержали возможность кеширования снапшотов карт. И теперь работа приложения выглядит так:

image

Как видим, больше никаких белых пятен — карты загружаются один раз и затем просто переиспользуются из кеша.

Не всегда всё просто


При реализации 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-протоколов помогают нам отображать карты и опросы, а также показывать аватарки бесед, составленные из фотографий их участников.

image

image

image

image

Как и любое решение, 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
Tags:
Hubs:
+14
Comments 5
Comments Comments 5

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен