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. Буду благодарен любому фидбэку, спасибо за внимание.
Источники: