Первая часть истории о медиапикере Paparazzo
В первой части мы рассказали о том, как пришли к своему медиапикеру и сколько вариантов перебрали до него, а теперь пора продолжить историю.
Разные источники фотографий
Следующая задача, с которой мы столкнулись, была связана с тем, что фотографии в MediaPicker могли попадать из трех разных источников:
- Фотографии, сделанные с помощью камеры, сохранялись на диске в папке приложения.
- Мы также могли выбрать фотографии из фотогалереи пользователя.
- Наконец, при открытии уже размещенного объявления для редактирования, они подтягивались из сети.
Конечно, мы хотели иметь одну сущность, а не три, чтобы в коде, который работает с фотографиями, не приходилось делать уродливые ветвления, и чтобы обезопасить его от изменений, если вдруг появится какой-то новый источник данных.
Мы выделили 4 действия, которые требуется совершать при работе с изображением:
- Наиболее частое действие — это, конечно, отображение в интерфейсе. Причем было бы неплохо, если бы нам не приходилось для каждой маленькой превьюшки фотографии, коих на экране может поместиться достаточно много, держать в памяти полноразмерные фотки размером 3 на 4 тысячи пикселей.
- Далее идет получение оригинала изображения — например, для отправки на сервер или сохранения на диск. Опять же, тут мы хотим загружать память как можно меньше, и нам достаточно сжатого представления в виде NSData — не нужно тратить системные ресурсы на то, чтобы декодировать фотку из, скажем, JPEG в bitmap.
- Также иногда бывает необходимо узнать размер изображения, причем делать это также лучше наиболее оптимальным образом — то есть, если такое возможно, не скачивать его полностью и не забивать им память только для этого. Зачастую размер можно получить из метаданных файла, либо нам может его присылать сервер отдельным свойством JSON-структуры рядом с URL’ом.
- Наконец, если изображение загружается из сети, но в какой-то момент мы понимаем, что оно нам точно уже не понадобится (например, мы закрыли экран, на котором оно должно было показаться), неплохо было бы иметь возможность отменить загрузку.
Ну и разумеется, в силу того, что изображение может быть не доступно локально в тот момент, когда оно нам понадобилось, API для первых трех пунктов должно быть асинхронным. Для того, чтобы отобразить изображение в UI, не загружая память избыточными данными, нужно выяснить, какой его размер нам нужен.
- Для этого надо знать размер области, в которой будет происходить отображение и то, как мы хотим её использовать: хотим ли мы полностью вписать в нее изображение или же можем пожертвовать какими-то его частями, чтобы внутри не оставалось свободного места (аналогично content mode aspectFit и aspectFill у UIView).
- Так как API должно быть асинхронным, нам понадобится обработчик, в котором мы передадим полученную картинку в UIImageView.
- Еще может случиться так, что нам нужно загрузить фото из сети, но при этом у нас локально есть закэшированная версия этого же изображения, но меньшего размера. И оказывается, что если на время загрузки мы подставим эту уменьшенную версию во вьюшку, у пользователя создастся впечатление, что загрузка происходит быстрее.
- Поэтому не помешает еще и параметр deliveryMode, проставляя которому значение progressive, мы как бы говорим, что не против плохих версий запрошенной картинки, и handler может быть вызван несколько раз по мере возрастания качества. Best будет означать, что мы хотим, чтобы handler вызвался лишь один раз с самой лучшей версией картинки.
Соответственно, метод запроса картинки с перечисленными параметрами может выглядеть как-то так.
func requestImage(
viewSize: CGSize,
contentMode: ContentMode,
deliveryMode: DeliveryMode,
handler: @escaping (UIImage?) -> ())
Сократим его, объединив первые три параметра в структуру. Это даст нам возможность добавлять по мере необходимости другие параметры, не меняя сигнатуру метода.
func requestImage(
options: ImageRequestOptions,
handler: @escaping (UIImage?) -> ())
struct ImageRequestOptions {
let viewSize: CGSize
let contentMode: ContentMode
let deliveryMode: DeliveryMode
}
Получившийся вариант все еще нуждается в доработке. Во-первых, в параметре клоужура handler явно указан тип UIImage, а нам хотелось отвязаться от UIKit, чтобы этим методом можно было пользоваться не только на iOS.
Поэтому UIImage нужно заменить на что-то, что может быть впоследствие превращено в UIImage. Существует тип, который соответствует этому критерию и присутствует как на iOS, так и на macOS — это CGImage.
Поэтому мы создаем протокол InitializableWithCGImage.
protocol InitializableWithCGImage {
init(cgImage: CGImage)
}
По счастливому стечению обстоятельств, у UIImage и NSImage уже есть такие инициализаторы, поэтому все, что нам остается сделать — добавить пустые экстеншены для этих классов, формально описав их соответствие данному протоколу.
extension UIImage: InitializableWithCGImage {}
extension NSImage: InitializableWithCGImage {}
Заменив UIImage этим протоколом, получим такую сигнатуру метода.
func requestImage<T: InitializableWithCGImage>(
options: ImageRequestOptions,
handler: @escaping (T?) -> ())
Наконец, следует позаботиться об обеспечение возможности отмены запроса. Для этого добавим в метод requestImage возвращаемое значение ImageRequestId, которое позволит нам в дальнейшем идентифицировать запрос.
func requestImage<T: InitializableWithCGImage>(
options: ImageRequestOptions,
handler: @escaping (T?) -> ())
-> ImageRequestId
Остается еще одно небольшое изменение.
Ранее я говорил о том, что если установить для deliveryMode значение progressive, handler может вызываться несколько раз. Было бы неплохо внутри этого handler’а понимать, вызвался ли он с окончательной или промежуточной версией изображения. Поэтому будем передавать ему структуру ImageRequestResult, в которой, помимо самого изображения, будет содержаться другая полезная информация о результате запроса.
func requestImage<T: InitializableWithCGImage>(
options: ImageRequestOptions,
handler: @escaping (ImageRequestResult<T>) -> ())
-> ImageRequestId
struct ImageRequestResult<T> {
let image: T?
let degraded: Bool
let requestId: ImageRequestId
}
Таким образом, мы пришли к финальной версии метода запроса картинки для отображения его в интерфейсе.
Три других метода просты, два из них представляют собой по сути просто асинхронные геттеры.
protocol ImageSource {
func requestImage<T: InitializableWithCGImage>(
options: ImageRequestOptions,
resultHandler: @escaping (ImageRequestResult<T>) -> ())
-> ImageRequestId
func fullResolutionImageData(completion: @escaping (Data?) -> ())
func imageSize(completion: @escaping (CGSize?) -> ())
func cancelRequest(_: ImageRequestId) }
Таким образом мы получили протокол ImageSource, которая прекрасно подходит для использования в качестве модели нашего пикера, и остается только реализовать его для трех возможных случаев: фотографий с диска, из сети и из фотогалереи пользователя.
Фотогалерея
Начиная с iOS 8, доступ к фотогалерее осуществляется через Photos.framework. Непосредственно сама галерея представлена в нем объектом PHPhotoLibrary, а фотографии — объектами PHAsset.
Чтобы получить представление фотографии, которое можно отобразить в интерфейсе, нужно использовать PHImageManager, дающий на выходе UIImage.
Метод, который осуществляет данное преобразование, выглядит так:
func requestImage(
for: PHAsset,
targetSize: CGSize,
contentMode: PHImageContentMode,
options: PHImageRequestOptions?,
resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> ())
-> PHImageRequestID
Как вы можете заметить, он очень похож на метод получения изображения в нашем собственном протоколе ImageSource: тот же target size, content mode, какие-то параметры, асинхронный result handler.
Это неудивительно, поскольку первой реализацией ImageSource была именно обертка над PHAsset, поэтому мы во многом отталкивались именно от этой сигнатуры.
К сожалению, в процессе изучения работы PHImageManager мы столкнулись с некоторыми скользкими моментами, поэтому тело нашего собственного метода requestImage не состояло из одного-единственного вызова этого стандартного метода, как могло бы показаться.
Первый из них проявился при решении классической задачи отображения фотографий в collection view.
- PHImageManager не дает вообще никаких гарантий относительно того, как будет вызываться resultHandler после отмены запроса. Он может не вызваться, а может и вызваться, но при этом в каких-то случаях мы получим какую-то UIImage, а в каких-то — nil вместо нее. Мы хотели упростить клиентский код, чтобы ему не приходилось самому разбираться в том, что же именно произошло.
- Поэтому появился строгий набор правил вызова resultHandler для ImageSource, одно из которых гласило, что resultHandler после отмены запроса вызываться не должен.
Решение данной задачи было довольно простым. resultHandler’у PHImageManager’а дается на вход два параметра: первый — UIImage, а второй — словарь info, в котором содержится всякая полезная информация.
// внутри resultHandler PHImageManager’а
let cancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue ?? false || cancelledRequestIds.contains(requestId)
if !cancelled {
// вызываем "внешний" resultHandler
}
Среди этой информации есть флажок, по которому можно определить, был ли отменен запрос. Но этот флажок может и не прийти, если запрос отменили уже после того, как данный вызов resultHandler попал в очередь. Поэтому нам пришлось держать внутри ImageSource массив отмененных requestId, и проверять наличие нашего запроса в нем.
Вторая проблема появилась, когда мы столкнулись с фото из iCloud, и нам нужно было показать activity indicator на время загрузки.
Единственная возможность отследить такую загрузку — задать progress handler в объекте PHImageRequestOptions, который затем передается PHImageManager’у при запросе изображения.
class PHImageRequestOptions {
// для PHImageManager var progressHandler: PHAssetImageProgressHandler? // ...
}
Нам нужно было отслеживать только факт начала и окончания загрузки, поэтому в собственную структуру с параметрами запроса мы добавили два таких closure. И если onDownloadStart мы просто дергали при первом вызове progressHandler, то с onDownloadFinish было не все так просто.
struct ImageRequestOptions { // для ImageSource
var onDownloadStart: ((ImageRequestId) -> ())?
var onDownloadFinish: ((ImageRequestId) -> ())?
}
Если нам повезло, и progressHandler зарепортил нам, что картинка загружена на 100%, что соответствует значению progress == 1, мы вызывали onDownloadFinish в этом месте.
phImageRequestOptions.progressHandler = { progress, _, _, _ in
if progress == 1 {
callOnDownloadFinish()
}
}
Однако хитрость в том, что этого может не произойти, и последний вызов progressHandler’а произойдет на прогрессе менее 100%. В этом случае мы вынуждены уже внутри resultHandler’а пытаться угадать, завершилась загрузка или нет.
// внутри resultHandler:
let degraded: Bool = info?[PHImageResultIsDegradedKey]
let looksLikeLastCallback = cancelled || (image != nil && !degraded)
if looksLikeLastCallback {
callOnDownloadFinish()
}
В словарике info, который нам приходит в этой callback’е, есть флаг IsDegraded, который показывает, получили ли мы окончательную или промежуточную версию изображения. Так вот на данном этапе логично предположить, что загрузка завершена либо если мы отменили запрос, либо если пришла окончательная версия картинки.
Реализацию ImageSource для фотографий с диска и из сети вы можете самостоятельно изучить в репозитории Paparazzo.
Наш медаипикер уже привлек внимание iOS-разработчиков, в том числе зарубежных ресурсов. Отмечают, что он отлично выполняет возложенные на него функциии и довольно элегантно и просто реализован. Теперь и вы можете свободно его пробовать, тестировать, обсуждать. Команда Avito всегда рада ответить на ваши вопросы.
Полезные ссылки:
- Ссылка на Часть первую
- Paparazzo на Github
- Запись доклада Media Picker — to infinity and beyond (CocoaHeads Russia 01.03.2017)
Автор: HiveHicks