iOS应用AVCaptureSession间歇性失效问题排查咨询
相机预览黑屏、会话停止运行的随机复现问题
问题现象
- 部分用户相机预览黑屏,
captureSession.isRunning返回 false - 拍照时抛出AVFoundation相关错误,错误码包括-11800(底层-12780/-16800)、-11819(底层-16405)
- 问题随机出现,持续数天后自动恢复,卸载重装无法解决
- 已确认相机权限正常授予
捕获的错误日志
Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-12780), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x30012adc0 {Error Domain=NSOSStatusErrorDomain Code=-12780 "(null)"}} Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16800), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x301666b50 {Error Domain=NSOSStatusErrorDomain Code=-16800 "(null)"}} Error Domain=AVFoundationErrorDomain Code=-11819 "Cannot Complete Action" UserInfo={AVErrorRecordingSuccessfullyFinishedKey=false, NSLocalizedDescription=Cannot Complete Action, NSLocalizedRecoverySuggestion=Try again later., AVErrorRecordingFailureDomainKey=2, NSUnderlyingError=0x10cb61a70 {Error Domain=NSOSStatusErrorDomain Code=-16405 "(null)"}}
当前错误监听代码
NotificationCenter.default.addObserver(self, selector: #selector(sessionRuntimeError), name: .AVCaptureSessionRuntimeError, object: captureSession)
会话初始化核心代码
func initSession(finished: @escaping ((CameraSessionPermissionState) -> Void)) { checkPermissions { permissionState in self.configure { config in config.position = SharedPrefs.Camera.cameraPosition config.photo = .enabled(config: CameraConfiguration.PhotoConfig()) config.video = .enabled(config: CameraConfiguration.VideoConfig()) config.audio = .enabled(config: CameraConfiguration.AudioConfig()) config.orientation = .portrait config.isActive = true } finished: { finished(permissionState) } } } func configure(update: @escaping (_ configuration: CameraConfiguration) throws -> Void, finished: (() -> Void)? = nil) { isConfiguring = true CameraQueues.cameraQueue.async { [weak self] in guard let self = self else { return } self.executeConfiguration(update: update, finished: finished) } } private func executeConfiguration(update: @escaping (_ configuration: CameraConfiguration) throws -> Void, finished: (() -> Void)? = nil) { // Let caller configure a new configuration for the Camera. let config = CameraConfiguration(copyOf: self.configuration) do { try update(config) } catch CameraConfiguration.AbortThrow.abort { // call has been aborted and changes shall be discarded return } catch { // another error occured, possibly while trying to parse enums self.onConfigureError(error) return } if config.cameraId == nil { switch config.position { case .back: config.cameraId = self.backCameraId case .front: config.cameraId = self.frontCameraId default: config.cameraId = self.backCameraId } } let difference = CameraConfiguration.Difference(between: self.configuration, and: config) do { // If needed, configure the AVCaptureSession (inputs, outputs) if difference.isSessionConfigurationDirty { self.captureSession.beginConfiguration() self.captureSession.sessionPreset = .hd1920x1080 // 1. Update input device if difference.inputChanged { try self.configureDevice(configuration: config) } // 2. Update outputs if difference.outputsChanged { try self.configureOutputs(configuration: config) } // 3. Update Video Stabilization if difference.videoStabilizationChanged { self.configureVideoStabilization(configuration: config) } // 4. Update output orientation if difference.orientationChanged { self.configureOrientation(configuration: config) } } guard let device = self.videoDeviceInput?.device else { self.captureSession.commitConfiguration() throw CameraError.device(.noDevice) } // If needed, configure the AVCaptureDevice (format, zoom, low-light-boost, ..) if difference.isDeviceConfigurationDirty { try device.lockForConfiguration() defer { device.unlockForConfiguration() } // 4. Configure format if difference.formatChanged { try self.configureFormat(configuration: config, device: device) } // 5. After step 2. and 4., we also need to configure some output properties that depend on format. // This needs to be done AFTER we updated the `format`, as this controls the supported properties. if difference.outputsChanged || difference.formatChanged { self.configureVideoOutputFormat(configuration: config) self.configurePhotoOutputFormat(configuration: config) } // 6. Configure side-props (fps, lowLightBoost) if difference.sidePropsChanged { try self.configureSideProps(configuration: config, device: device) } // 7. Configure zoom if difference.zoomChanged { self.configureZoom(configuration: config, device: device) } // 8. Configure exposure bias if difference.exposureChanged { self.configureExposure(configuration: config, device: device) } } if difference.isSessionConfigurationDirty { // We commit the session config updates AFTER the device config, // that way we can also batch those changes into one update instead of doing two updates. self.captureSession.commitConfiguration() } // 9. Start or stop the session if needed self.checkIsActive(configuration: config) // 10. Enable or disable the Torch if needed (requires session to be running) if difference.torchChanged { try device.lockForConfiguration() defer { device.unlockForConfiguration() } try self.configureTorch(configuration: config, device: device) } // Notify about Camera initialization if difference.inputChanged { self.delegate?.onSessionInitialized() } // After configuring, set this to the new configuration. self.configuration = config } catch { self.onConfigureError(error) } // Set up Audio Capture Session (on audio queue) if difference.audioSessionChanged { CameraQueues.audioQueue.async { do { // Lock Capture Session for configuration self.audioCaptureSession.beginConfiguration() try self.configureAudioSession(configuration: config) // Unlock Capture Session again and submit configuration to Hardware self.audioCaptureSession.commitConfiguration() } catch { self.onConfigureError(error) } } } // Check if Hardware Cost is okay if #available(iOS 16.0, *) { if self.captureSession.hardwareCost > 1 { // Throw this error to the user, but don't abort the configuration. Maybe it still works. self.onConfigureError(CameraError.session(.hardwareCostTooHigh(cost: self.captureSession.hardwareCost))) } } DispatchQueue.main.async { finished?() self.isConfiguring = false } }
可能的原因分析
1. 系统级相机资源冲突
- 其他应用(如系统相机、第三方相机类APP)长时间占用相机硬件资源,导致系统拒绝当前APP的相机请求。这类冲突通常会在系统资源回收后自动解除,对应问题数天后自行恢复的现象。
- 部分iOS版本存在相机资源调度的偶发Bug,系统重启或资源自动释放后问题消失。
2. AVFoundation会话配置的隐性问题
- 会话配置过程中,
beginConfiguration()和commitConfiguration()的调用时机或线程处理存在潜在问题,导致会话处于异常状态。虽然代码中使用了专用队列,但极端情况下可能因系统调度问题导致配置未正确提交。 - 相机设备锁定(
lockForConfiguration())未正确释放,导致后续会话启动失败。不过代码中使用了defer解锁,这种概率较低,但仍需考虑极端场景下的系统异常。
3. 底层硬件临时故障
- 设备相机模块出现临时硬件异常(如过热、供电波动),系统进入保护状态禁止相机访问。待硬件恢复正常后,相机功能自动恢复。此类问题与APP无关,卸载重装无法解决。
4. 错误监听的遗漏
.AVCaptureSessionRuntimeError仅捕获会话运行时的严重错误,部分配置阶段的错误可能未触发该通知,导致Crashlytics未收到日志。建议补充监听会话中断相关通知,排查是否存在会话被中断的情况。
建议的排查/修复措施
- 增加会话中断监听,记录中断原因和恢复事件:
NotificationCenter.default.addObserver(self, selector: #selector(sessionInterrupted), name: .AVCaptureSessionWasInterrupted, object: captureSession) NotificationCenter.default.addObserver(self, selector: #selector(sessionInterruptionEnded), name: .AVCaptureSessionInterruptionEnded, object: captureSession) - 在会话启动失败时,增加重试逻辑(如间隔1-2秒后尝试重新启动会话),避免一次性失败导致永久黑屏。
- 检查
checkIsActive方法的实现,确保会话启动逻辑正确处理了异常状态(如启动失败时的错误捕获和重试)。 - 针对iOS 16+的
hardwareCost检查,考虑在成本过高时降级会话预设(如从.hd1920x1080改为.hd1280x720),减少硬件资源占用。
内容的提问来源于stack exchange,提问作者BenCLT
相关产品推荐
相关产品推荐

