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

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

Поделиться с друзьями
-->

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


  1. IvovDry
    19.04.2017 18:59

    а CAReplicatorLayer пробовали?


    1. HiveHicks
      19.04.2017 19:02

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