Привет, Хабр! Меня зовут Илья, и последние несколько недель я провёл в дебрях документации 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 ControlAVCaptureSystemZoomSlider— системный слайдер зума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 — интересная технология, но её интеграция требует понимания архитектуры. Ключевые моменты:
Lock Screen Extension обязателен — без него приложение невидимо для Camera Control
Проверяйте
supportsControls— работает только на iPhone 16+Используйте фоновую очередь для конфигурации сессии
Обновляйте UI на main thread — колбэки контролов приходят на фоновой очереди
Реализуйте 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
