Привет, Хабр! Меня зовут Илья, и последние несколько недель я провёл в дебрях документации Apple, исходников AVFoundation и видео с WWDC, разбираясь с новой фичей iPhone 16 — Camera Control. В этой статье расскажу, как устроена архитектура этой системы, почему интеграция сложнее, чем кажется, и дам рабочий код для интеграции в ваше приложение.

Что такое Camera Control и зачем он нужен

Camera Control — это физическая кнопка на боковой грани iPhone 16 (всех моделей линейки), которая:

  • Запускает камерное приложение одним нажатием

  • Работает как кнопка затвора для съёмки

  • Позволяет регулировать параметры (зум, экспозиция) свайпом по поверхности кнопки

Под капотом это комбинация Force Touch сенсора, ёмкостного датчика и Taptic Engine, которая обеспечивает распознавание силы нажатия, свайпов и тактильную обратную связь.

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

Архитектура: почему без Extension ничего не работает

Первое, что я сделал — добавил AVCaptureSystemZoomSlider в свою сессию захвата. Код скомпилировался, запустился на iPhone 16, но в настройках Camera Control (Settings → Camera → Camera Control) моего приложения не было.

После изучения документации и WWDC-сессий стало понятно: Camera Control работает только с приложениями, у которых есть Lock Screen Camera Capture Extension.

Логика Apple следующая:

┌─────────────────────────────────────────────────────────────┐
│                    Camera Control Button                     │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│              SpringBoard (системный процесс)                 │
│  Проверяет: есть ли у приложения                            │
│  LockedCameraCaptureExtension?                              │
└─────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
┌──────────────────────┐        ┌──────────────────────┐
│      Extension       │        │    Нет Extension     │
│      существует      │        │                      │
└──────────────────────┘        └──────────────────────┘
              │                               │
              ▼                               ▼
┌──────────────────────┐        ┌──────────────────────┐
│ Приложение доступно  │        │ Приложение невидимо  │
│ в настройках Camera  │        │ для Camera Control   │
│ Control              │        │                      │
└──────────────────────┘        └──────────────────────┘

Extension нужен даже если вы не планируете использовать камеру с заблокированного экрана. Это входной билет в экосистему Camera Control.

Стек технологий

Для полной интеграции потребуются:

Компонент

Фреймворк

Минимальная версия

Сессия захвата

AVFoundation

iOS 4.0

Системные контролы

AVFoundation

iOS 18.0

Обработка событий кнопки

AVKit

iOS 17.2

Lock Screen Extension

LockedCameraCapture

iOS 18.0

Ключевые классы:

  • AVCaptureSession — управление потоком данных от камеры

  • AVCaptureControl — базовый класс для контролов Camera Control

  • AVCaptureSystemZoomSlider — системный слайдер зума

  • AVCaptureSystemExposureBiasSlider — системный слайдер экспозиции

  • AVCaptureEventInteraction — обработка событий физических кнопок

  • AVCaptureSessionControlsDelegate — делегат для отслеживания состояния контролов

Шаг 1: Создание Lock Screen Camera Extension

В Xcode: File → New → Target → Locked Camera Capture Extension.

Минимальная реализация:

import LockedCameraCapture
import AVFoundation
import Photos

@main
class LockScreenCameraExtension: NSObject, LockedCameraCaptureExtension {
    
    private var captureSession: LockedCameraCaptureSession?
    private var photoOutput: LockedCameraCapturePhotoOutput?
    
    func beginCapture(
        with device: LockedCameraCaptureDevice,
        completion: @escaping (Error?) -> Void
    ) {
        captureSession = LockedCameraCaptureSession()
        photoOutput = LockedCameraCapturePhotoOutput()
        
        guard let session = captureSession,
              let output = photoOutput else {
            completion(ExtensionError.initializationFailed)
            return
        }
        
        do {
            try session.addInput(device)
            try session.addOutput(output)
            session.startRunning()
            completion(nil)
        } catch {
            completion(error)
        }
    }
    
    func endCapture(completion: @escaping (Error?) -> Void) {
        captureSession?.stopRunning()
        captureSession = nil
        photoOutput = nil
        completion(nil)
    }
    
    func captureOutput(
        _ output: LockedCameraCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: LockedCameraCaptureConnection
    ) {
        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        
        let ciImage = CIImage(cvImageBuffer: imageBuffer)
        let context = CIContext()
        
        guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent),
              let jpegData = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else {
            return
        }
        
        PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCreationRequest.forAsset()
            request.addResource(with: .photo, data: jpegData, options: nil)
        }
    }
}

enum ExtensionError: Error {
    case initializationFailed
}

Info.plist для Extension:

<key>NSExtension</key>
<dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.camera.lock-screen</string>
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).LockScreenCameraExtension</string>
</dict>

<key>NSCameraUsageDescription</key>
<string>Камера для съёмки с заблокированного экрана</string>

<key>NSPhotoLibraryAddUsageDescription</key>
<string>Сохранение снимков в галерею</string>

Важный момент: Extension будет убит системой, если не запросит доступ к камере или не настроит AVCaptureEventInteraction. Пустой Extension не пройдёт.

Шаг 2: Базовый CameraController

Создаём класс, который инкапсулирует всю работу с камерой:

import AVFoundation
import AVKit
import Combine

final class CameraController: NSObject, ObservableObject {
    
    // MARK: - Published Properties
    
    @Published private(set) var zoomFactor: CGFloat = 1.0
    @Published private(set) var exposureBias: Float = 0.0
    @Published private(set) var isCameraControlActive = false
    @Published private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined
    
    // MARK: - Internal Properties
    
    let session = AVCaptureSession()
    
    // MARK: - Private Properties
    
    private let sessionQueue = DispatchQueue(
        label: "com.app.camera.session",
        qos: .userInitiated
    )
    private let photoOutput = AVCapturePhotoOutput()
    private var deviceInput: AVCaptureDeviceInput?
    private var keyValueObservations: [NSKeyValueObservation] = []
    
    // MARK: - Initialization
    
    override init() {
        super.init()
        checkAuthorization()
    }
    
    // MARK: - Authorization
    
    private func checkAuthorization() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            authorizationStatus = .authorized
            sessionQueue.async { self.configureSession() }
            
        case .notDetermined:
            sessionQueue.suspend()
            AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                guard let self else { return }
                DispatchQueue.main.async {
                    self.authorizationStatus = granted ? .authorized : .denied
                }
                if granted {
                    self.configureSession()
                }
                self.sessionQueue.resume()
            }
            
        case .denied, .restricted:
            authorizationStatus = .denied
            
        @unknown default:
            break
        }
    }
    
    // MARK: - Session Configuration
    
    private func configureSession() {
        session.beginConfiguration()
        defer { session.commitConfiguration() }
        
        session.sessionPreset = .photo
        
        // Input
        guard let camera = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: .back
        ) else {
            print("[CameraController] Camera device not available")
            return
        }
        
        do {
            let input = try AVCaptureDeviceInput(device: camera)
            guard session.canAddInput(input) else {
                print("[CameraController] Cannot add input to session")
                return
            }
            session.addInput(input)
            deviceInput = input
        } catch {
            print("[CameraController] Failed to create input: \(error)")
            return
        }
        
        // Output
        guard session.canAddOutput(photoOutput) else {
            print("[CameraController] Cannot add output to session")
            return
        }
        session.addOutput(photoOutput)
        photoOutput.isHighResolutionCaptureEnabled = true
        photoOutput.maxPhotoQualityPrioritization = .quality
        
        // Camera Control (iOS 18+)
        if #available(iOS 18.0, *) {
            configureCameraControls()
        }
        
        // KVO
        setupObservers(for: camera)
        
        // Start
        session.startRunning()
    }
    
    private func setupObservers(for device: AVCaptureDevice) {
        let zoomObservation = device.observe(\.videoZoomFactor, options: .new) { [weak self] _, change in
            guard let value = change.newValue else { return }
            DispatchQueue.main.async {
                self?.zoomFactor = value
            }
        }
        
        let exposureObservation = device.observe(\.exposureTargetBias, options: .new) { [weak self] _, change in
            guard let value = change.newValue else { return }
            DispatchQueue.main.async {
                self?.exposureBias = value
            }
        }
        
        keyValueObservations = [zoomObservation, exposureObservation]
    }
    
    // MARK: - Lifecycle
    
    func start() {
        sessionQueue.async { [weak self] in
            guard let self, !self.session.isRunning else { return }
            self.session.startRunning()
        }
    }
    
    func stop() {
        sessionQueue.async { [weak self] in
            guard let self, self.session.isRunning else { return }
            self.session.stopRunning()
        }
    }
    
    deinit {
        keyValueObservations.removeAll()
    }
}

Шаг 3: Интеграция Camera Control

Добавляем extension к CameraController для работы с Camera Control:

// MARK: - Camera Control (iOS 18+)

@available(iOS 18.0, *)
extension CameraController: AVCaptureSessionControlsDelegate {
    
    func configureCameraControls() {
        guard session.supportsControls else {
            print("[CameraController] Camera Control not supported on this device")
            return
        }
        
        guard let device = deviceInput?.device else {
            print("[CameraController] No camera device for controls")
            return
        }
        
        // Удаляем старые контролы (если были)
        session.controls.forEach { session.removeControl($0) }
        
        // Zoom Slider
        let zoomSlider = AVCaptureSystemZoomSlider(device: device) { [weak self] factor in
            DispatchQueue.main.async {
                self?.zoomFactor = factor
            }
        }
        
        // Exposure Slider
        let exposureSlider = AVCaptureSystemExposureBiasSlider(device: device) { [weak self] bias in
            DispatchQueue.main.async {
                self?.exposureBias = bias
            }
        }
        
        // Добавляем контролы
        for control in [zoomSlider, exposureSlider] {
            if session.canAddControl(control) {
                session.addControl(control)
            } else {
                print("[CameraController] Cannot add control: \(type(of: control))")
            }
        }
        
        // Устанавливаем делегат
        session.setControlsDelegate(self, queue: sessionQueue)
        
        print("[CameraController] Camera Control configured with \(session.controls.count) controls")
    }
    
    // MARK: - AVCaptureSessionControlsDelegate
    
    func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
        DispatchQueue.main.async { [weak self] in
            self?.isCameraControlActive = true
        }
    }
    
    func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
        DispatchQueue.main.async { [weak self] in
            self?.isCameraControlActive = false
        }
    }
    
    func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {
        // Скрываем кастомные UI-элементы
        NotificationCenter.default.post(name: .cameraControlWillEnterFullscreen, object: nil)
    }
    
    func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {
        // Показываем кастомные UI-элементы
        NotificationCenter.default.post(name: .cameraControlWillExitFullscreen, object: nil)
    }
}

extension Notification.Name {
    static let cameraControlWillEnterFullscreen = Notification.Name("cameraControlWillEnterFullscreen")
    static let cameraControlWillExitFullscreen = Notification.Name("cameraControlWillExitFullscreen")
}

Шаг 4: Обработка событий кнопки затвора

AVCaptureEventInteraction доступен с iOS 17.2 и позволяет обрабатывать нажатия физических кнопок:

// MARK: - Capture Event Interaction

extension CameraController {
    
    @available(iOS 17.2, *)
    func setupCaptureEventInteraction(on view: UIView) -> AVCaptureEventInteraction {
        let interaction = AVCaptureEventInteraction(
            primary: { [weak self] event in
                self?.handleCaptureEvent(event)
            },
            secondary: { [weak self] event in
                self?.handleCaptureEvent(event)
            }
        )
        
        interaction.isEnabled = true
        view.addInteraction(interaction)
        
        return interaction
    }
    
    private func handleCaptureEvent(_ event: AVCaptureEvent) {
        switch event.phase {
        case .began:
            // Визуальная обратная связь (анимация кнопки и т.д.)
            NotificationCenter.default.post(name: .captureEventBegan, object: nil)
            
        case .ended:
            // Делаем снимок только на ended, не на began
            capturePhoto()
            NotificationCenter.default.post(name: .captureEventEnded, object: nil)
            
        case .cancelled:
            NotificationCenter.default.post(name: .captureEventCancelled, object: nil)
            
        @unknown default:
            break
        }
    }
}

extension Notification.Name {
    static let captureEventBegan = Notification.Name("captureEventBegan")
    static let captureEventEnded = Notification.Name("captureEventEnded")
    static let captureEventCancelled = Notification.Name("captureEventCancelled")
}

Шаг 5: Захват фото

// MARK: - Photo Capture

extension CameraController: AVCapturePhotoCaptureDelegate {
    
    func capturePhoto() {
        guard let connection = photoOutput.connection(with: .video),
              connection.isEnabled else {
            print("[CameraController] Photo connection not available")
            return
        }
        
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        settings.isHighResolutionPhotoEnabled = true
        
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    func photoOutput(
        _ output: AVCapturePhotoOutput,
        didFinishProcessingPhoto photo: AVCapturePhoto,
        error: Error?
    ) {
        if let error {
            print("[CameraController] Capture error: \(error)")
            return
        }
        
        guard let data = photo.fileDataRepresentation() else {
            print("[CameraController] No image data")
            return
        }
        
        // Сохраняем в галерею
        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
            guard status == .authorized else { return }
            
            PHPhotoLibrary.shared().performChanges {
                let request = PHAssetCreationRequest.forAsset()
                request.addResource(with: .photo, data: data, options: nil)
            }
        }
    }
}

Шаг 6: SwiftUI View

import SwiftUI

struct CameraView: View {
    @StateObject private var camera = CameraController()
    @State private var captureInteraction: AVCaptureEventInteraction?
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                CameraPreviewView(session: camera.session) { view in
                    if #available(iOS 17.2, *) {
                        captureInteraction = camera.setupCaptureEventInteraction(on: view)
                    }
                }
                .ignoresSafeArea()
                
                VStack {
                    controlsOverlay
                    Spacer()
                    captureButton
                }
                .padding()
            }
        }
        .onAppear { camera.start() }
        .onDisappear { camera.stop() }
    }
    
    private var controlsOverlay: some View {
        HStack {
            // Зум
            Text(String(format: "%.1fx", camera.zoomFactor))
                .font(.system(.caption, design: .monospaced))
                .padding(8)
                .background(.ultraThinMaterial)
                .clipShape(Capsule())
            
            Spacer()
            
            // Экспозиция
            Text(String(format: "%+.1f EV", camera.exposureBias))
                .font(.system(.caption, design: .monospaced))
                .padding(8)
                .background(.ultraThinMaterial)
                .clipShape(Capsule())
            
            Spacer()
            
            // Индикатор Camera Control
            if camera.isCameraControlActive {
                Image(systemName: "button.horizontal.top.press")
                    .padding(8)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .clipShape(Circle())
            }
        }
    }
    
    private var captureButton: some View {
        Button(action: { camera.capturePhoto() }) {
            Circle()
                .strokeBorder(.white, lineWidth: 4)
                .frame(width: 72, height: 72)
                .overlay(
                    Circle()
                        .fill(.white)
                        .padding(6)
                )
        }
        .opacity(camera.isCameraControlActive ? 0.3 : 1.0)
        .animation(.easeInOut(duration: 0.2), value: camera.isCameraControlActive)
    }
}

// MARK: - Camera Preview

struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession
    let onViewCreated: ((UIView) -> Void)?
    
    func makeUIView(context: Context) -> PreviewView {
        let view = PreviewView()
        view.videoPreviewLayer.session = session
        view.videoPreviewLayer.videoGravity = .resizeAspectFill
        onViewCreated?(view)
        return view
    }
    
    func updateUIView(_ uiView: PreviewView, context: Context) {}
}

final class PreviewView: UIView {
    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
    var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}

Кастомные контролы

Помимо системных AVCaptureSystemZoomSlider и AVCaptureSystemExposureBiasSlider, можно создавать свои контролы:

@available(iOS 18.0, *)
func createCustomControls(for device: AVCaptureDevice) -> [AVCaptureControl] {
    var controls: [AVCaptureControl] = []
    
    // Слайдер ISO (если поддерживается)
    if device.isExposureModeSupported(.custom) {
        let isoRange = device.activeFormat.minISO...device.activeFormat.maxISO
        
        let isoSlider = AVCaptureSlider(
            "ISO",
            symbolName: "camera.aperture",
            in: Float(isoRange.lowerBound)...Float(isoRange.upperBound)
        )
        
        isoSlider.setActionQueue(sessionQueue) { [weak self] value in
            guard let self else { return }
            do {
                try device.lockForConfiguration()
                device.setExposureModeCustom(
                    duration: device.exposureDuration,
                    iso: value
                )
                device.unlockForConfiguration()
            } catch {
                print("[CameraController] ISO error: \(error)")
            }
        }
        
        controls.append(isoSlider)
    }
    
    // Переключатель вспышки
    if device.hasFlash {
        let flashPicker = AVCaptureIndexPicker(
            "Flash",
            symbolName: "bolt.fill",
            localizedIndexTitles: ["Off", "On", "Auto"]
        )
        
        flashPicker.setActionQueue(sessionQueue) { [weak self] index in
            // Сохраняем выбор для следующего снимка
            DispatchQueue.main.async {
                self?.selectedFlashMode = AVCaptureDevice.FlashMode(rawValue: index) ?? .auto
            }
        }
        
        controls.append(flashPicker)
    }
    
    return controls
}

Диагностика и отладка

При возникновении проблем полезно иметь диагностическую функцию:

struct CameraControlDiagnostics {
    
    static func run() -> String {
        var report = """
        ═══════════════════════════════════════
        Camera Control Diagnostics
        ═══════════════════════════════════════
        
        """
        
        // Device Info
        report += "Device: \(UIDevice.current.name)\n"
        report += "Model: \(UIDevice.current.model)\n"
        report += "iOS: \(UIDevice.current.systemVersion)\n\n"
        
        // iOS 18 Check
        if #available(iOS 18.0, *) {
            report += "✓ iOS 18.0+ available\n"
            
            let session = AVCaptureSession()
            if session.supportsControls {
                report += "✓ Camera Control hardware supported\n"
            } else {
                report += "✗ Camera Control hardware NOT supported (iPhone 16+ required)\n"
            }
        } else {
            report += "✗ iOS 18.0+ NOT available\n"
        }
        
        // Camera Check
        if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
            report += "✓ Camera available\n"
            report += "  - Zoom range: \(camera.minAvailableVideoZoomFactor)x - \(camera.maxAvailableVideoZoomFactor)x\n"
            report += "  - Exposure range: \(camera.minExposureTargetBias) - \(camera.maxExposureTargetBias) EV\n"
        } else {
            report += "✗ Camera NOT available\n"
        }
        
        // Authorization
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .authorized: report += "✓ Camera authorized\n"
        case .denied: report += "✗ Camera denied\n"
        case .restricted: report += "✗ Camera restricted\n"
        case .notDetermined: report += "? Camera not determined\n"
        @unknown default: report += "? Camera unknown status\n"
        }
        
        report += "\n═══════════════════════════════════════\n"
        
        return report
    }
}

Частые ошибки

1. Обновление UI не на main thread

// ❌ Краш или некорректное поведение
let slider = AVCaptureSystemZoomSlider(device: device) { zoom in
    self.zoomLabel.text = "\(zoom)x"
}

// ✓ Корректно
let slider = AVCaptureSystemZoomSlider(device: device) { zoom in
    DispatchQueue.main.async {
        self.zoomLabel.text = "\(zoom)x"
    }
}

2. Конфигурация сессии на main thread

// ❌ Блокирует UI
session.beginConfiguration()
// ...
session.commitConfiguration()

// ✓ Корректно
sessionQueue.async {
    self.session.beginConfiguration()
    // ...
    self.session.commitConfiguration()
}

3. Отсутствие проверки supportsControls

// ❌ Падение на старых устройствах
@available(iOS 18.0, *)
func setup() {
    let slider = AVCaptureSystemZoomSlider(device: device)
    session.addControl(slider) // Может не поддерживаться!
}

// ✓ Корректно
@available(iOS 18.0, *)
func setup() {
    guard session.supportsControls else { return }
    
    let slider = AVCaptureSystemZoomSlider(device: device)
    if session.canAddControl(slider) {
        session.addControl(slider)
    }
}

4. Дублирование контролов

// ❌ Ошибка при повторном вызове
func setupControls() {
    let slider = AVCaptureSystemZoomSlider(device: device)
    session.addControl(slider) // Второй вызов упадёт
}

// ✓ Корректно
func setupControls() {
    // Удаляем существующие
    session.controls.forEach { session.removeControl($0) }
    
    let slider = AVCaptureSystemZoomSlider(device: device)
    if session.canAddControl(slider) {
        session.addControl(slider)
    }
}

Заключение

Camera Control — интересная технология, но её интеграция требует понимания архитектуры. Ключевые моменты:

  1. Lock Screen Extension обязателен — без него приложение невидимо для Camera Control

  2. Проверяйте supportsControls — работает только на iPhone 16+

  3. Используйте фоновую очередь для конфигурации сессии

  4. Обновляйте UI на main thread — колбэки контролов приходят на фоновой очереди

  5. Реализуйте graceful degradation — приложение должно работать и без Camera Control

Полный проект с примерами для SwiftUI и UIKit, Lock Screen Extension и диагностическими утилитами я выложил на сайте:

dodecaidr.pro/ru/articles/camera-control

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


Если есть вопросы по реализации — пишите в комментариях, постараюсь помочь.

Теги: iOS, Swift, AVFoundation, Camera Control, iPhone 16, iOS 18, Разработка под iOS

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