Hello World!
Всем привет, меня зовут Эмиль. Я младший iOS разработчик в «3Д Платформа» (джуниор пишет статью, дада я) и несколько месяцев назад я столкнулся с камерой на AVFoundation: мне нужно было добавить камере опциональный зум с 1.0х до 0.5х, если камера поддерживает такой зум. Материалов я нашел очень много, попрактиковался на тестовом проекте, выверил лучшую формулу для зума и интегрировал решение в прод. После выполнения задачи я заметил, что не прочитал ни одного материала на русском, кроме одной статьи на Хабре, датированной 2013 годом. Собственно, это наблюдение и навело меня на мысль о написании своей собственной статьи об AVFoundation — камере. Я решил начать с проекта, на базе которого мы сегодня с вами изучим возможности AVFoundation на лоне работы с камерой (а вообще там можно еще и со звуком поработать). Это моя первая статья, но я не буду просить вас отнестись к ней снисходительно. Думаю, этот материал будет полезен новичкам и джунам, которые никогда не сталкивались с AVFoundation и не знают, какая информация по этой теме реально годна.
Ну что ж, поехали.
Полный проект: https://github.com/Wtclrsnd/RealTrapCamera
Навигация
-
Что такое AVFoundation
-
Пишем UI
-
AVCaptureSession, inputs, outputs, AVCaptureDevice
-
PreviewLayer
-
Переключение между фронтальной и задней камерами
-
Просим у Тимоши разрешение на съемку
-
AVCapturePhotoCaptureDelegate — получение и сохранение фото
-
PinchToZoom
-
Бонус: Haptics по нажатию кнопки
-
Заключение и источники
Что такое AVFoundation?
Процитирую Apple: AVFoundation — это фреймворк для работы с аудиовизуальными медиафайлами на iOS, macOS, watchOS и tvOS. Благодаря AVFoundation можно воспроизводить, создавать и редактировать файлы QuickTime и файлы MPEG-4, воспроизводить потоки HLS и встраивать мощную мультимедийную функциональность в свои приложения.
Как стало понятно из определения, AVFoundation это широкопрофильный фреймворк с большим количеством возможностей, в которые входит и создание фото и видео, на чем мы сегодня с вами и сконцентрируемся. Фото и видео камеры — самый частый кейс использования AVFoundation.

Под AVFoundation «лежат» CoreAudio, CoreMedia — названия говорят сами за себя. А также CoreAnimation для представления иерархии видео и презентационного слоя.
Углубимся в тему съемок фото. Представляю вам основные классы AVFoundation, использующиеся для написания камер:
-
AVCaptureDevice — класс, являющийся прямым API к камере устройства
-
AVCaptureDeviceInput — проводит данные от камеры
-
AVCaptureOutput — абстрактный класс, отвечающий за вывод картинки на экран. В этой статье мы будем использовать его сабкласс — AVCapturePhotoOutput
-
AVCaptureSession — обеспечивает связь между инпутом и аутпутом камеры и отвечает за работу камеры в целом.
-
AVCaptureVideoPreviewLayer — сабкласс CALayer, выводящий на экран видео изображение с нашего девайса
Также сегодня мы будем использовать следующие вспомогательные классы:
-
AVCaptureDevice.DiscoverySession — позволяет подключить специфический CaptureDevice для данного устройства — двойные и тройные камеры, или только одну из них
-
AVCapturePhotoSettings — настройки камеры для конкретного снимка
Пишем UI
Не буду долго останавливаться тут. Я сделал довольно простой пользовательский интерфейс, немного похожий на камеру от Apple, но со своим любимым фиолетовым оттенком.
Верхний и нижний бары и все UI элементы выделены в отдельные классы — BottomBarView, LastPhotoView, CaptureImageButton и TopBarView. А также вынес свой основной цвет для элементов в отдельный файл (Lavanda.swift). Для кнопок поворота камеры и вспышки я использовал SFSymbols и столкнулся с особенностью в их использовании — размер изображений, импортированных из SF, нужно настраивать точно как шрифты.
Обратите внимание на аргумент withConfiguration
button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath", withConfiguration: UIImage.SymbolConfiguration.init(pointSize: 25)), for: .normal)
После написания и сетапа баров на контроллере, нам требуется провести делегат от кнопок съемки и смены фронтальной и тыльной камер:
protocol BottomBarDelegate: AnyObject { func switchCamera() func takePhoto() }
Подпишем CamViewController под делегата, а также создадим CameraService, который будет выступать в роли носителя всей логики. Прокинем CameraService в инит нашего контроллера:
init(cameraService: CameraService) { self.cameraService = cameraService super.init(nibName: nil, bundle: nil) }
Вызовем контроллер с сервисом в ините через метод SceneDelegate, запустим приложение и увидим, что все готово! Таки перейдем к самому интересному — логике сервиса.
AVCaptureSession, inputs, outputs, AVCaptureDevice
Зайдем в CameraService. Первое что нам нужно будет сделать — настроить вводы (инпуты) выводы (аутпуты) и саму сессию. Добавим нужные проперти в начало класса:
private var captureDevice: AVCaptureDevice? private var backCamera: AVCaptureDevice? private var frontCamera: AVCaptureDevice? private var backInput: AVCaptureInput! private var frontInput: AVCaptureInput! private let cameraQueue = DispatchQueue(label: "com.shpeklord.CapturingModelQueue") private var startZoom: CGFloat = 2.0 private let zoomLimit: CGFloat = 10.0 private var backCameraOn = true weak var delegate: CameraServiceDelegate? let captureSession = AVCaptureSession() let photoOutput = AVCapturePhotoOutput()
captureDevice — текущая камера, с которой мы получаем изображение в данный момент. Может быть задней или фронтальной.
backCamera — наша задняя камера, которую мы будем сетапить через DiscoverySession чуть позже
frontCamera — фронтальная камера
backInput — инпут для задней камеры
frontInput — инпут для фронтальной камеры
captureSession — сессия, которую мы сейчас будем конфигурировать
photoOutput — аутпут, с которого мы будем получать изображение

Также я создал булеву переменную, границы зума и отдельную очередь для конфигурации сессии, потребность в которых объясню чуть позже...
Далее займемся подключением девайса. Тут в игру вступает DiscoverySession! Наша задача — подключить к приложению именно тот девайс, который поддерживается нашим устройством. Если вы используете приложение на iPhone Pro, то вы хотите получить тройную камеру. На не-pro телефоне понадобится камера с ультрашириком, а на более старых моделях с одним шириком или шириком/телевиком, вы получите соответствующий AVCaptureDevice. Благодаря DiscoverySession мы можем быстро определить тип камеры на устройстве и непосредственно подключить его к аутпуту. Виды камер:

Функция поиска нужной камеры выглядит так:
private func currentDevice() -> AVCaptureDevice? { let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInTripleCamera, .builtInDualWideCamera, .builtInDualCamera, .builtInWideAngleCamera], mediaType: .video, position: .back) guard let device = discoverySession.devices.first else { return nil } if device.deviceType == .builtInDualCamera || device.deviceType == .builtInWideAngleCamera { startZoom = 1.0 // об этом чуть позже } return device }
Обратите внимание на строку номер 3: мы расположили возможные девайсы в определенном порядке. Если используется iPhone Pro, первой в массиве окажется тройная камера. iPhone 11-14 — камера с ультрашириком. iPhone X/XS — получим камеру с телевиком. А на последнем месте расположилась простая широкоугольная камера, доступная в каждом девайсе. Также хочу отметить, что 2 и 3 камеры доступны для iPhone Pro.
Теперь, когда мы получили CaptureDevice, можно начать настройку инпутов:
private func setupInputs() { backCamera = currentDevice() // получаем актуальный девайс задней камеры frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) // подключаем фронталку guard let backCamera = backCamera, let frontCamera = frontCamera else { return } do { backInput = try AVCaptureDeviceInput(device: backCamera) guard captureSession.canAddInput(backInput) else { return } frontInput = try AVCaptureDeviceInput(device: frontCamera) guard captureSession.canAddInput(frontInput) else { return } } catch { fatalError("could not connect camera") } captureDevice = backCamera // сетапим заднюю камеру captureSession.addInput(backInput) // добавляем инпут к сессии if backCamera.deviceType == .builtInDualWideCamera || backCamera.deviceType == .builtInTripleCamera { updateZoom(scale: startZoom) // об этом чуть позже } }
Сетапим аутпут:
private func setupOutput() { guard captureSession.canAddOutput(photoOutput) else { return } photoOutput.isHighResolutionCaptureEnabled = true photoOutput.maxPhotoQualityPrioritization = .balanced captureSession.addOutput(photoOutput) }
Теперь, когда мы подготовили сетап инпутов и аутпутов, мы можем начать конфигурацию нашей AVCaptureSession. Делаем мы это на фоновом потоке, потому что captureSession.startRunning() является блокирующим вызовом и вызывать его на главной очереди не следует по причине остановки работы всего приложения до того момента как сессия запустится.
Функция сетапа сессии:
private func setupAndStartCaptureSession() { cameraQueue.async { [weak self] in self?.captureSession.beginConfiguration() // открываем сессию для конфигурации if let canSetSessionPreset = self?.captureSession.canSetSessionPreset(.photo), canSetSessionPreset { self?.captureSession.sessionPreset = .photo } // делаем пресет для фотографий self?.captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true // ставим возможность использования цветового пространства RGB нашей камерой self?.setupInputs() // опаньки, что-то знакомое ;) self?.setupOutput() self?.captureSession.commitConfiguration() self?.captureSession.startRunning() // тот самый блокирующий вызов } }
Теперь вызовем setupAndStartCaptureSession() в ините нашего CameraManager. До получения превью осталось всего лишь несколько шагов.
PreviewLayer
Пожалуй, самая простая часть сетапа камеры — вывод превью. Зайдем в CamViewController и добавим новый приватный метод.
private func setupPreviewLayer() { let previewLayer = AVCaptureVideoPreviewLayer(session: cameraService.captureSession) as AVCaptureVideoPreviewLayer previewLayer.frame = view.bounds previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) }
Из незнакомого и интересного здесь можно заметить проперти videoGravity, отвечающая за отображение видео в нашем леере. Настройки аналогичны contentMode в UIVew. Мы выбрали настройку .resizeAspectFill для того чтобы леер занимал всю площадь экрана.
Переключение между фронтальной и задней камерами
Помните булеву переменную проперти CameraService? Настало ее время. Как вы уже могли понять по названию, она отвечает за актуальную на данный момент камеру. Переключение между основной и фронтальной камерой осуществляется переключением инпутов. Внимание на код:
func switchCameraInput() { captureSession.beginConfiguration() if backCameraOn { captureSession.removeInput(backInput) captureSession.addInput(frontInput) captureDevice = frontCamera backCameraOn = false } else { captureSession.removeInput(frontInput) captureSession.addInput(backInput) captureDevice = backCamera backCameraOn = true updateZoom(scale: startZoom) // об этом чуть позже } photoOutput.connections.first?.videoOrientation = .portrait photoOutput.connections.first?.isVideoMirrored = !backCameraOn captureSession.commitConfiguration() }
Думаю, понятно что происходит в ветках оператора If. Из незнакомого тут только две предпоследние строчки, которые отвечают за ориентацию наших фото (мы выставляем портретную, то есть вертикальную) и isVideoMirrored отвечает за отзеркаливание изображения относительно вертикальной оси. Нужно это для корректного изображения при использовании фронтальной камеры. Функция не приватна, поскольку будет вызываться из CamViewController.
extension CamViewController: BottomBarDelegate { func switchCamera() { cameraService.switchCameraInput() } }
Данная функция вызывается из делегата нижнего бара по нажатию кнопки смены камер.
Просим у Тимоши разрешение на съемку
Как мы будем делать и сохранять фото, если наше приложение не имеет прав на использование камеры и галереи? Нужно запросить разрешение на съемку у iOS. Делается это довольно просто — через info.plist
Нам нужны два разрешения. Внимание на скриншот.

Далее, нам нужно прописать запрос разрешения на съемку у пользователя:
private func checkPermissions() { let cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: AVMediaType.video) switch cameraAuthStatus { case .authorized: return case .denied: abort() case .notDetermined: AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler: { (authorized) in if(!authorized){ abort() } }) case .restricted: abort() @unknown default: fatalError() } }
AVCapturePhotoCaptureDelegate — получение и сохранение фото
Мы плавно приближаемся к финалу. На очереди подключение делегата фотокамеры. Добавим следующий код в конец CameraService:
extension CameraService: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { guard error == nil else { print("Fail to capture photo: \(String(describing: error))") return } guard let imageData = photo.fileDataRepresentation() else { return } guard let image = UIImage(data: imageData) else { return } DispatchQueue.main.async { self.delegate?.setPhoto(image: image) // сетим фото на превью нижнего бара UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) // сохраняем сделанное фото в галерею } } }
Данная функция будет срабатывать каждый раз, когда мы нажмем кнопку затвора. Собственно, код самой главной кнопки:
func takePhoto() { let photoSettings = AVCapturePhotoSettings() photoSettings.isHighResolutionPhotoEnabled = true photoSettings.flashMode = topBar.isTorchOn ? .on : .off cameraService.photoOutput.capturePhoto(with: photoSettings, delegate: cameraService) }
Поговорим об AVCapturePhotoSettings. Этот класс отвечает за настройки нашей камеры в момент нажатия на кнопку. На каждый вызов камеры требуется новый экземпляр AVCapturePhotoSettings. Переиспользовать настройки нельзя. В данном случае мы сетим высокое разрешение картинки и вкл/выкл вспышки, за состояние которой отвечает булева переменная в топ баре. Далее мы вызываем метод capturePhoto и прокидываем туда наши настройки и камера сервис в качестве ответственного объекта. Вызов capturePhoto триггерит метод photoOutput в сервисе, откуда мы и получаем фото.
PinchToZoom
Настало время поставить нашей камере возможность приближать и отдалять. Для этого нам понадобится UIPinchGestureRecognizer. Поставим его в CamViewController:
private func setupZoomRecognizer() { let zoomRecognizer = UIPinchGestureRecognizer() zoomRecognizer.addTarget(self, action: #selector(didPinch(_:))) view.addGestureRecognizer(zoomRecognizer) } @objc private func didPinch(_ recognizer: UIPinchGestureRecognizer) { if recognizer.state == .changed { cameraService.setZoom(scale: recognizer.scale) } }
didPinch обращается к сервису. Посмотрим имплементацию setZoom и приватный updateZoom в сервисе:
func setZoom(scale: CGFloat) { guard let zoomFactor = captureDevice?.videoZoomFactor else { return } var newScaleFactor: CGFloat = 0 newScaleFactor = (scale < 1.0 ? (zoomFactor - pow(zoomLimit, 1.0 - scale)) : (zoomFactor + pow(zoomLimit, (scale - 1.0) / 2.0))) newScaleFactor = minMaxZoom(zoomFactor * scale) updateZoom(scale: newScaleFactor) } private func minMaxZoom(_ factor: CGFloat) -> CGFloat { min(max(factor, 1.0), zoomLimit) } private func updateZoom(scale: CGFloat) { do { defer { captureDevice?.unlockForConfiguration() } try captureDevice?.lockForConfiguration() captureDevice?.videoZoomFactor = scale } catch { print(error.localizedDescription) } }
Здесь и вступают в игру startZoom и zoomLimit.При сете камеры мы ставили startZoom на 2.0, если камера обладала ультрашириком и 1.0 во всех остальных случаях. Дело в том что камеры с ультрашириком ставят зум 0.5 (в коде 1.0) по умолчанию, а нам бы хотелось открывать камеру на зуме 1.0.
Код выставления зума весьма прост: мы получаем скейл рекогнайзера, определяем в какую сторону был сделан жест (меньше 1.0 — жест на уменьшение) и в зависимости от характера жеста, производим рассчет нового фактора. Дальше мы ограничиваем новый фактор функцией minMaxZoom и вызываем более низкоурвневую функцию updateZoom, которая принимает в себя новый фактор и безопасно сетит его девайсу. Собственно, на этом все.
Бонус: Haptics по нажатию кнопки
Как же пользователь поймет что он сделал фото? Можно сделать анимацию, но мы сегодня собрались не за этим. Я решил оповещать пользователя о факте съемки тактильным откликом устройства. В коде кнопки затвора и смены камер добавим следующие команды:
let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred()
.medium для кнопки затвора и .light для смены камер. Подробнее о Haptics можно почитать здесь.
Заключение и источники
Сегодня мы с вами создали простое приложение с возможностью снимать на фронталку и заднюю камеры, а также со вспышкой и зумом. Этого материала должно хватить на то чтобы ввести в курс дела тех, кто только начал работу с AVFoundation. Буду благодарен любому фидбэку, спасибо за внимание.
Источники:
ссылка на оригинал статьи https://habr.com/ru/post/709558/
Добавить комментарий