19 апреля в 11:38

Paparazzo. Мощный, стильный, свой. Часть I



Не так давно перед нами встала задача кардинальной переработки процесса подачи объявления через мобильное приложение Avito под iOS. Результатом должен был стать инструмент, который сделал бы этот процесс быстрым и необременительным для пользователя. Очевидно, что покупатель предпочитает видеть то, за что он собирается заплатить. Поэтому дать продавцу возможность удобного добавления и редактирования фотографий было одним из наших главных приоритетов. О том, как мы добились желаемого, читайте под катом.

Забегая немного вперёд, наш медиапикер называется Paparazzo и мы уже выложили его на Github. В этой части мы раскроем технические подробности того, как мы захватываем изображение с камеры и выводим его в несколько UIView одновременно.

UIImagePickerController и open source


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



  • Он может работать либо в режиме камеры, либо в режиме галереи, а нам нужен был гибрид этих двух режимов.

  • Его нужно показывать модально, потому что он представляет собой UINavigationController, который не может быть запущен в другой UINavigationController, а наша камера должна быть одним из шагов в линейной последовательности экранов.

  • В качестве результата UIImagePickerController отдает UIImage. UIImage — это несжатое представление изображения, которое хранится в памяти и занимает там много места. Работая с UIImagePickerController в своих предыдущих проектах, я сталкивался с тем, что на слабых устройствах вроде iPhone 4, и даже иногда на iPhone 5, приложение крэшилось из-за нехватки памяти еще до того, как был вызван метод делегата, непосредственно возвращающий изображение клиентскому коду. Нам же нужно было избежать нерационального использования оперативной памяти с целью исключения подобных проблем.

Мы также рассматривали некоторые из готовых решений, доступных в open source, но все они не удовлетворяли нашим требованиям. В каких-то отсутствовали нужные фичи, вроде кропа или поворота фотографии, в каких-то был предусмотрен выбор только одной фотографии, не было ленты с выбранными фото. В целом, user flow, который был реализован в этих компонентах, нам не подходил. Поэтому мы решили написать камеру с нуля, что гарантировало бы нам возможность ее быстрой доработки по мере необходимости.

AVFoundation


Ещё один способ реализации камеры — использование низкоуровнего фрэймворка AVFoundation. Он позволяет выжать максимум из возможностей записи и воспроизведения фото, видео и аудио, которые предоставляет iOS.



Центральным объектом в AVFoundation является AVCaptureSession, который координирует поток данных от устройств захвата к потребителям. Но прежде чем использовать его, нужно определиться, откуда запись будет производиться.

Источники записи представлены объектами AVCaptureDeviceInput, и берутся они из AVCaptureDevice, представляющих физические устройства, такие как фронтальная камера, задняя камера и микрофон. Аналогично тому, как мы говорим, откуда вести запись, мы также должны сказать, куда ее затем направить.

Для этого по другую сторону AVCaptureSession находится один или несколько AVCaptureOutput. Примерами output’ов могут служить AVCaptureStillImageOutput (для фотографий) и AVCaptureMovieFileOutput (для видео). Каждый output может получать информацию из одного или нескольких источников (например AVCaptureMovieFileOutput может получать как видео с камеры, так и аудио с микрофона).

Связи между input’ами и output’ами задаются с помощью одного или нескольких объектов AVCaptureConnection, и если нам, скажем, нужно записать видео без звука, можно не устанавливать связь между AVCaptureMovieFileOutput и микрофонным input’ом.

Настройка AVCaptureSession довольна проста. Для начала нужно получить список устройств, поддерживающих захват видео. Далее, ищем заднюю камеру, проверяя значение параметра position у каждого из найденных устройств. Наконец, инициализируем AVCaptureDeviceInput, передавая объект камеры в качестве параметра.

let videoDevices = AVCaptureDevice.devices(withMediaType: AVMediaTypeVideo)
	
let backCamera = videoDevices?.first { $0.position == .back }
let input = try AVCaptureDeviceInput(device: backCamera)

Создание output’а еще проще: просто создаем AVCaptureStillImageOutput и задаем кодек, который будет использоваться при захвате.

let output = AVCaptureStillImageOutput()
output.outputSettings = [AVVideoCodecKey: AVVideoCodecJPEG]

Теперь, когда у нас есть input и output, мы готовы создать AVCaptureSession.

let captureSession = AVCaptureSession()
captureSession.sessionPreset = AVCaptureSessionPresetPhoto

if captureSession.canAddInput(input) {
    captureSession.addInput(input)
}
	
if captureSession.canAddOutput(output) {
    captureSession.addOutput(output)
}

captureSession.startRunning()


Свойство sessionPreset позволяет задать качество, битрейт и другие параметров output’а. Существует 14 готовых пресетов, которых, как правило, достаточно для решения типовых задач. В случае, если они не дают требуемого результата, существуют также специфические свойства, которые выставляются на экземпляре AVCaptureDevice, представляющем само физическое устройство захвата.

Перед добавлением input’ов и output’ов к сессии нужно обязательно проверять возможность такой операции методами canAddInput и canAddOutput, соответственно, иначе можно напороться на крэш.

После этого можно сказать сессии startRunning(), чтобы запустить передачу данных от input’ов к output’ам. И тут есть важный нюанс — startRunning() является блокирующим вызовом, и его выполнение может занять некоторое время, поэтому рекомендуется выполнять настройку сессии в фоне, чтобы не блокировать главный поток.

В состав AVFoundation также включен класс AVCaptureVideoPreviewLayer. Это наследник CALayer, который позволяет без лишних усилий отобразить превью записи, инициализировав его экземпляром AVCaptureSession. Мы просто кладем этот layer в нужное место, и все работает автоматически. Казалось бы, что может пойти не так? Однако все не так безоблачно, как кажется на первый взгляд.

Вывод превью камеры


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



Тут же возникла другая мысль: создадим две сессии, и каждая из них будет осуществлять вывод в свой AVCaptureVideoPreviewLayer. Но это тоже не работает. Как только запускается вторая сессия, первая автоматически останавливается.

В ходе дальнейшего поиска выяснилось, что решение все-таки есть. Помимо CaptureStillImageOutput, который присутствовал к нашей CaptureSession изначально, нам нужно добавить новый output типа AVCaptureVideoDataOutput.



У этого output’а есть делегат, а у делегата есть метод, который позволяет нам получать каждый кадр с камеры, давая возможность делать с ним все, что угодно, в том числе — отрисовывать его самостоятельно.

Результат отдается в виде CMSampleBuffer. Данные, представленные этим объектом, могут быть эффективно отрисованы графическим процессором с помощью OpenGL, а также низкоуровнего фреймворка от самой Apple — Metal, который был представлен сравнительно недавно. По заявлениям Apple, Metal может быть в 10 раз быстрее, чем OpenGL ES, однако работает он только начиная с iPhone 5s и выше. Поэтому мы остановились на OpenGL.



Как я уже сказал, для самостоятельной отрисовки кадров, полученных с камеры, нужно реализовать протокол AVCaptureVideoDataOutputSampleBufferDelegate, а именно — его метод captureOutput(_:didOutputSampleBuffer:from:).

func captureOutput(
    _: AVCaptureOutput?,
    didOutputSampleBuffer sampleBuffer: CMSampleBuffer?, from _: AVCaptureConnection?)
{
    let imageBuffer: CVImageBuffer? = sampleBuffer.flatMap { CMSampleBufferGetImageBuffer($0) }
    if let imageBuffer = imageBuffer, !isInBackground {
        views.forEach { $0.imageBuffer = imageBuffer }
    }
}

Дальше вы увидите, что всю тяжелую работу по отрисовке кадров возьмет на себя Core Image, но Core Image не умеет работать напрямую с CMSampleBuffer, зато работает с CVImageBuffer, и здесь мы выполняем преобразование одного объекта в другой.

Обратите внимание, что на этом этапе мы выполняем проверку флага isInBackground. OpenGL-вызовы нельзя осуществлять, когда приложение находится в фоне, иначе система немедленно выгрузит его из памяти. О том, как этого избежать, я расскажу через минуту, а пока просто запомните, что этот флаг есть.

Далее в цикле проходимся по всем вьюшкам, в которых будет отображаться превью, и передаем им полученный imageBuffer. Вьюшки эти — экземпляры нашего собственного класса GLKViewSubclass.

Этот класс, как вы наверняка догадались по названию, является наследником GLKView. Как и любая другая GLKView, эта вью инициализируется контекстом OpenGL ES. Что отличает ее от других — это наличие также Core Image контекста, который и будет выполнять весь heavy lifting по отрисовке содержимого image buffer’а.

За исключением нескольких несущественных деталей полная реализация метода draw(_:) приведена здесь.

// instance vars:
let eaglContext = EAGLContext(api: .openGLES2)
let ciContext = CIContext(eaglContext: eaglContext)
var imageBuffer: CVImageBuffer?
	
// draw(_:) implementation:
if let imageBuffer = imageBuffer {
    let image = CIImage(cvPixelBuffer: imageBuffer)

    ciContext.draw(
        image,
        in: drawableBounds(for: rect),
	from: sourceRect(of: image, targeting: rect)
    )
}

Метод drawableBounds просто преобразует CGRect, заданный в пунктах, в пиксельный CGRect, поскольку Core Image имеет дело с пикселями и не знает про то, какой у нас экран — retina или нет.

Метод sourceRect возвращает прямоугольник, соответствующий фрагменту отображаемого кадра, который поместится в нашу вьюшку, учитывая соотношение ее сторон. То есть если кадр имеет формат 3:4, а наша вью квадратная, то это метод вернет frame, соответствующий центральной его части.



Как я уже говорил, OpenGL-вызовы нельзя осуществлять, когда приложение находится в фоне. И для того, чтобы это контролировать, нужно обрабатывать события ApplicationWillResignActive и ApplicationDidBecomeActive.

// UIApplicationWillResignActive
func handleAppWillResignActive(_: NSNotification) {
    captureOutputDelegateBackgroundQueue.sync {
        glFinish()
        self.isInBackground = true
    }
}
	
// UIApplicationDidBecomeActive
func handleAppDidBecomeActive(_: NSNotification) {
    captureOutputDelegateBackgroundQueue.async {
        self.isInBackground = false
    }
}

Оба этих уведомления отправляются в главном потоке, но сообщения делегата AVCaptureSession доставляются в фоновом потоке. Чтобы гарантировать, что после выхода из первого обработчика точно не будет происходить никакого рисования нужно синхронно переключиться на очередь делегата, вызвать glFinish() и выставить флаг isInBackground, который output delegate проверяет перед отрисовкой каждого кадра.

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

Итак, если обобщить все вышесказанное, общая схема реализации такова: CaptureSession передает кадр с камеры на VideoDataOutput, тот перенаправляет его своему делегату, а делегат, в свою очередь, передает данные нужным вьюшкам, если приложение не ушло в бэкграунд, после чего вьюшки сами выполняют отрисовку кадра.



На этом наши проблемы с выводом превью камеры закончились.

Итак, в первой части мы подробно рассмотрели использование AVFoundation для захвата изображения с камеры, а также для его вывода в несколько UIView одновременно. Во второй части мы расскажем об абстракции, которую сделали, чтобы одинаково работать с фотографиями из разных источников, с диска, из фотогалереи и из сети.

Полезные ссылки:

Автор: @HiveHicks
Avito
рейтинг 209,59
Avito – сайт объявлений №1 в России
Похожие публикации

Вакансии компании Avito

Комментарии (2)

  • 0
    а CAReplicatorLayer пробовали?
    • 0
      CAReplicatorLayer не позволяет разместить дубликат слоя на другом уровне иерархии вьюшек (оригинал и дубликаты будут лежать в одном слое). Нам это не подходило.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка