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.

Источник: https://medium.com/@divya.nayak/learning-avfoundation-part-1-c761aad183ad
Источник: https://medium.com/@divya.nayak/learning-avfoundation-part-1-c761aad183ad

Под 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 мы можем быстро определить тип камеры на устройстве и непосредственно подключить его к аутпуту. Виды камер:

Источник - https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype
Источник - https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype

Функция поиска нужной камеры выглядит так:

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

Источники:

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


  1. nazar228
    09.01.2023 13:12

    ????