Первая часть истории о медиапикере 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)
Поделиться с друзьями