返回

AVFoundation广角/超广角镜头切换手电筒卡死? 解决方案

IOS

解决 AVFoundation 在广角/超广角镜头间切换时手电筒导致卡死的问题

不少开发者在使用 AVFoundation 处理实时视频捕捉,特别是在结合物体检测等功能时,可能会踩到一个坑:当手电筒(闪光灯)开启时,在广角(Wide)和超广角(Ultra Wide)镜头之间切换,画面会直接卡死。更具体地说:

  • 如果手电筒是开着的,从广角切到超广角,取景画面就冻结了。
  • 如果当前是超广角镜头,尝试打开手电筒,画面同样会卡死。

奇怪的是,iPhone 自带的相机 App 明明可以在录制视频时,顺畅地在超广角镜头下使用手电筒。这说明硬件上是支持的,那问题很可能出在 AVFoundation 的使用方式上。

这到底是 AVFoundation 的限制,还是有别的办法绕过去?苹果自家的相机 App 是怎么做到不卡死的?正确的操作姿势应该是怎样的?以及,如何在切换相机和控制手电筒之间做好同步?

刨根问底:为什么会卡死?

这个现象,十有八九和 AVFoundation 管理设备配置的方式有关,特别是涉及到会话配置 (AVCaptureSession beginConfiguration/commitConfiguration) 和设备锁定 (lockForConfiguration/unlockForConfiguration)。

咱们来捋一捋:

  1. 手电筒和摄像头的“绑定” : 手电筒通常是附属于某个具体的摄像头硬件模块的(主要是广角摄像头模块)。当你通过 AVCaptureDevice 控制手电筒时,实际上是在操作那个关联的硬件。
  2. 切换镜头的过程 : 在 AVFoundation 中切换摄像头,通常需要:
    • beginConfiguration(): 告诉 AVCaptureSession 你要开始修改配置了。
    • 移除当前的 AVCaptureDeviceInput
    • 创建并添加新的 AVCaptureDeviceInput (对应新的摄像头,比如超广角)。
    • commitConfiguration(): 应用这些更改。
  3. 冲突点 : 想象一下,当手电筒(由广角摄像头硬件控制)是亮着的时候,你突然告诉 AVCaptureSession:“嘿,现在把输入设备换成超广角!”。在这个切换过程中,系统需要解除对广角镜头的占用,并启用超广角镜头。但此时,广角镜头关联的手电筒硬件可能还处于被 AVFoundation “锁定”或“占用”的状态。当你尝试在这个转换的瞬间继续维持或改变手电筒状态时,很可能就引发了底层的资源冲突或者状态不同步,导致整个捕捉会话卡死。

为什么原生相机 App 就没事?

这很难百分百确定,但有几种可能:

  • 更底层的 API : 苹果可能使用了不对外开放的、更底层的 API 来控制摄像头和闪光灯,这些 API 能更精细、原子化地处理这种切换,避免了 AVFoundation 这种配置变更带来的冲突。
  • 不同的处理逻辑 : 原生 App 可能在切换前有一个短暂的“预备”阶段,或者采用了一种更平滑的过渡机制。它可能并不完全依赖我们常用的 addInput/removeInput 流程,或者在内部做了更完善的状态同步。
  • 对特定硬件的优化 : 苹果自家应用自然最了解自家硬件的特性和时序要求,可能做了针对性的优化。

看回你提供的代码,forceEnableTorchThroughWide 函数尝试在切换前强制通过 广角 设备对象打开手电筒。但问题在于,当你即将或正在把 AVCaptureSession 的输入切换到 超广角 时,再去操作一个即将被移除或已经被移除的广角设备对象的 torchMode,这本身就有点“拧巴”,很可能在 commitConfiguration 时触发内部错误。而且,手电筒的控制权应该始终与当前 Session 中 活跃 的那个输入设备相关联。

动手解决:让超广角和手电筒“和平共处”

搞清楚原因,解决起来就思路清晰了。核心思想就是:避免在配置变更的关键窗口期(beginConfigurationcommitConfiguration 之间)让手电筒状态和设备切换产生冲突。

下面提供几种可行的方案:

方案一:先关灯,再切换,最后(按需)开灯(推荐)

这是最稳妥、兼容性最好的方法。逻辑简单粗暴但有效:在切换摄像头之前,先把手电筒关掉;切换完成之后,如果之前是开着的,再把它重新打开。

原理:

通过在修改 AVCaptureSession 输入设备前,确保手电筒处于关闭状态,彻底解除了手电筒状态和设备切换的潜在冲突。切换完成后,再根据需要恢复手电筒状态。这给了系统足够的时间和清晰的状态来完成镜头的切换。

步骤与代码示例:

修改你的 ultrawideCameraTapped 方法,大致流程如下:

  1. 记录当前的手电筒状态。
  2. 如果手电筒是开着的,先把它关掉(作用于当前的设备)。
  3. 开始会话配置 (beginConfiguration)。
  4. 移除旧输入,添加新输入(切换到目标摄像头)。
  5. 提交会话配置 (commitConfiguration)。
  6. 切换成功后,如果第 1 步记录的状态是“开”,那么在新设备上重新打开手电筒。
@IBAction func ultrawideCameraTapped(_ sender: Any?) {
    // 先获取当前的手电筒状态 (假设你有一个变量 isFlashlightOn 追踪)
    let wasTorchOn = self.isFlashlightOn

    // 目标:切换到哪个摄像头?
    let isSwitchingToUltraWide = !self.isUsingFisheyeCamera
    let targetCameraType: AVCaptureDevice.DeviceType = isSwitchingToUltraWide ? .builtInUltraWideCamera : .builtInWideAngleCamera
    let targetCameraName = isSwitchingToUltraWide ? "Ultra Wide" : "Wide"

    // 使用后台队列处理耗时操作
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        guard let self = self else { return }

        // **关键步骤 1: 切换前,如果手电筒开着,先关掉** 
        var turnTorchBackOn = false // 标记是否需要在切换后重新开灯
        if wasTorchOn {
            // 获取当前活动的摄像头设备
            if let currentInput = self.videoCapture.captureSession.inputs.first as? AVCaptureDeviceInput,
               let currentDevice = currentInput.device, currentDevice.hasTorch {
                do {
                    try currentDevice.lockForConfiguration()
                    if currentDevice.torchMode == .on {
                        currentDevice.torchMode = .off
                        print("Torch turned OFF temporarily before switching.")
                        // 标记需要在新设备上重新开启
                        turnTorchBackOn = true
                        // 更新你的手电筒状态变量
                        self.isFlashlightOn = false // 立刻更新状态,虽然只是暂时的
                         // 如果有UI反馈,记得在主线程更新
                         DispatchQueue.main.async {
                             // Update torch button UI state if needed
                         }
                    }
                    currentDevice.unlockForConfiguration()
                } catch {
                    print("Error turning off torch before switch: \(error.localizedDescription)")
                    // 这里可以决定是中止切换还是继续(可能仍会卡死)
                    // return // 如果出错,建议中止
                }
            }
        }

        // 查找目标摄像头
        guard let selectedCamera = AVCaptureDevice.default(targetCameraType, for: .video, position: .back) else {
            DispatchQueue.main.async {
                self.showAlert(title: "Camera Error", message: "\(targetCameraName) camera is not available on this device.")
            }
            return
        }

        do {
            // 开始配置 Session
            self.videoCapture.captureSession.beginConfiguration()

            // 移除当前输入
            if let currentInput = self.videoCapture.captureSession.inputs.first as? AVCaptureDeviceInput {
                self.videoCapture.captureSession.removeInput(currentInput)
                 print("Removed old input.")
            }

            // 添加新输入
            let videoInput = try AVCaptureDeviceInput(device: selectedCamera)
            if self.videoCapture.captureSession.canAddInput(videoInput) {
                self.videoCapture.captureSession.addInput(videoInput)
                 print("Added new input for \(targetCameraName).")
            } else {
                print("Could not add \(targetCameraName) input.")
                 // 如果无法添加,可能需要回滚或报错
                 self.videoCapture.captureSession.commitConfiguration() // 仍然需要提交,即使是失败的状态
                DispatchQueue.main.async {
                     self.showAlert(title: "Camera Error", message: "Failed to add \(targetCameraName) camera input.")
                 }
                 // 别忘了可能需要恢复之前关闭的手电筒(如果已关闭)
                 // 或者简单地中止操作
                 return
            }


            // 提交配置
            self.videoCapture.captureSession.commitConfiguration()
             print("Committed configuration.")


            // 更新视频方向(如果需要)
            self.videoCapture.updateVideoOrientation()


            // **关键步骤 2: 切换后,如果需要,在新设备上重新打开手电筒** 
            if turnTorchBackOn {
                // 现在获取 *新* 的输入设备
                 // 加一点延迟也许有帮助,确保配置完全生效(虽然理论上commit后应该就可以了)
                // DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.1) { // 可以尝试加个短暂延时
                 guard let newInput = self.videoCapture.captureSession.inputs.first as? AVCaptureDeviceInput,
                       let newDevice = newInput.device, newDevice.hasTorch, newDevice.isTorchAvailable else {
                       print("New device doesn't support torch or torch unavailable after switch.")
                       self.isFlashlightOn = false // 确保状态是关
                       DispatchQueue.main.async { /* Update UI */ }
                       return // 如果新设备不支持,就不开了
                 }

                do {
                    try newDevice.lockForConfiguration()
                     // 设置合理的手电筒亮度,这里用最大亮度示例
                    // try newDevice.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
                    newDevice.torchMode = .on // 或者简单的 .on
                    newDevice.unlockForConfiguration()
                    self.isFlashlightOn = true // 更新状态
                     print("Torch turned back ON on \(targetCameraName).")
                     DispatchQueue.main.async {
                         // Update torch button UI state if needed
                     }
                } catch {
                    print("Error turning torch back on \(targetCameraName): \(error.localizedDescription)")
                     self.isFlashlightOn = false // 开启失败,更新状态
                     DispatchQueue.main.async { /* Update UI */ }
                }
             // } // 对应 asyncAfter 的闭包
            }


            // 更新 UI (相机切换按钮)
            DispatchQueue.main.async {
                if let barButton = sender as? UIBarButtonItem {
                    barButton.title = isSwitchingToUltraWide ? "Wide" : "Ultra Wide"
                    barButton.tintColor = isSwitchingToUltraWide ? UIColor.systemGreen : UIColor.white
                }
                print("Successfully switched to \(targetCameraName) camera.")
            }

            // 更新内部状态标记
            self.isUsingFisheyeCamera.toggle()

        } catch {
            // 捕获添加输入或其他配置过程中的错误
            print("Failed to switch camera configuration: \(error.localizedDescription)")
             // 如果出错,可能也需要恢复之前关闭的手电筒?取决于业务逻辑
            DispatchQueue.main.async {
                self.showAlert(title: "Camera Error", message: "Failed to switch to \(targetCameraName) camera: \(error.localizedDescription)")
            }
             // 尝试恢复手电筒(如果之前关了且标记了要恢复) - 这部分逻辑可能复杂,视需求而定
             // ... 恢复逻辑 ...
        }
    }
}

小贴士:

  • 线程 : 确保所有 AVFoundation 的配置操作(包括开关手电筒、添加移除输入、begin/commitConfiguration)都在一个串行的后台队列执行,避免阻塞主线程和潜在的并发问题。UI 更新则必须回到主线程 (DispatchQueue.main.async)。
  • 错误处理 : lockForConfigurationaddInput 等操作都可能抛出异常,务必使用 do-catch 妥善处理。
  • 状态同步 : 维护一个可靠的 isFlashlightOn 状态变量,并在实际开关操作成功后才更新它。UI 显示也应基于这个变量。
  • 检查能力 : 在尝试开关手电筒前,务必检查设备是否 hasTorch 以及 isTorchAvailable。特别是切换到超广角后,虽然新 iPhone 支持,但旧设备或某些情况下可能 isTorchAvailable 会是 false

方案二:切换后再尝试开启手电筒

如果你的需求不是“必须在切换过程中保持手电筒常亮”,而是“切换到超广角后,用户 可以 选择打开手电筒”,那么逻辑可以简化:

原理:

完全不在切换摄像头的过程中处理手电筒。切换就只是切换。完成之后,如果用户点击手电筒按钮,再检查当前的(新的)摄像头设备是否支持并允许开启手电筒。

步骤与代码示例:

你的 ultrawideCameraTapped 函数可以简化,专注于摄像头切换逻辑(移除旧输入、添加新输入),不需要关心手电筒。

// Camera switching function - Simplified (No torch logic during switch)
@IBAction func ultrawideCameraTapped(_ sender: Any?) {
    let isSwitchingToUltraWide = !self.isUsingFisheyeCamera
    let targetCameraType: AVCaptureDevice.DeviceType = isSwitchingToUltraWide ? .builtInUltraWideCamera : .builtInWideAngleCamera
    let targetCameraName = isSwitchingToUltraWide ? "Ultra Wide" : "Wide"

    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        guard let self = self else { return }

        guard let selectedCamera = AVCaptureDevice.default(targetCameraType, for: .video, position: .back) else {
             // ... error handling ...
            return
        }

        do {
            self.videoCapture.captureSession.beginConfiguration()

            if let currentInput = self.videoCapture.captureSession.inputs.first as? AVCaptureDeviceInput {
                self.videoCapture.captureSession.removeInput(currentInput)
            }

            let videoInput = try AVCaptureDeviceInput(device: selectedCamera)
            if self.videoCapture.captureSession.canAddInput(videoInput) {
                self.videoCapture.captureSession.addInput(videoInput)
            } else {
                 // ... error handling ...
                self.videoCapture.captureSession.commitConfiguration()
                return
            }

            self.videoCapture.captureSession.commitConfiguration()

            self.videoCapture.updateVideoOrientation() // If needed

             // **注意** : 这里不处理手电筒。手电筒的状态会自然变为 "关" 或保持上一个设备的状态(直到下次手动操作)

            DispatchQueue.main.async {
                 // ... Update UI for camera switch ...
            }
             self.isUsingFisheyeCamera.toggle()

        } catch {
             // ... error handling ...
        }
    }
}


// Separate function to toggle torch based on the *current* active camera
func toggleTorch() {
     // 在合适的时机(比如用户点击按钮时)调用
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        guard let self = self,
              let currentInput = self.videoCapture.captureSession.inputs.first as? AVCaptureDeviceInput,
              let currentDevice = currentInput.device,
              currentDevice.hasTorch else {
            print("Current device doesn't have a torch.")
            DispatchQueue.main.async { /* Update UI to show torch unavailable */ }
            return
        }

        guard currentDevice.isTorchAvailable else {
            print("Torch is currently unavailable (perhaps in use by another app or overheating).")
             DispatchQueue.main.async { self.showAlert(title: "Torch Unavailable", message: "...") }
            return
        }

        do {
            try currentDevice.lockForConfiguration()
            let newTorchMode: AVCaptureDevice.TorchMode = self.isFlashlightOn ? .off : .on
            if newTorchMode == .on {
                 // Consider setting brightness level if needed/supported
                // try currentDevice.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
                 currentDevice.torchMode = .on
            } else {
                currentDevice.torchMode = .off
            }
            currentDevice.unlockForConfiguration()

             // Update state and UI
             self.isFlashlightOn.toggle()
             let status = self.isFlashlightOn ? "ON" : "OFF"
             let deviceName = currentDevice.localizedName
             print("Torch on \(deviceName) toggled \(status).")
             DispatchQueue.main.async {
                 // Update torch button UI state
             }

        } catch {
             print("Error toggling torch on \(currentDevice.localizedName): \(error.localizedDescription)")
            DispatchQueue.main.async { /* Show error alert */ }
        }
    }
}

适用场景:

当你不需要在切换的瞬间无缝保持手电筒状态时,这个方法更简单。缺点是如果用户希望在切换时灯一直亮着,体验会稍差(会灭一下)。

方案三:探索 AVCaptureMultiCamSession(更复杂,可能不适用)

AVCaptureMultiCamSession 允许你同时从多个摄像头(例如,前置和后置,或者后置的广角和长焦)接收数据流。虽然它的主要目的不是解决这个 切换 卡死的问题,但了解它有助于理解苹果可能如何管理复杂摄像头场景。

原理:

它管理多个摄像头和输入/输出,可能内部有更复杂的同步机制。原生相机在执行某些操作(如人像模式融合数据,或某些变焦过渡)时,可能利用了类似多摄像头协同工作的能力。

适用性:

对于我们遇到的“切换广角/超广角时手电筒卡死”问题,AVCaptureMultiCamSession 通常不是直接的解决方案 。它的配置和使用比 AVCaptureSession 复杂得多,并且主要是为了并行处理,而非串行切换。强行用它来模拟切换可能会引入更多复杂性。

何时考虑:

如果你的应用需要同时显示来自不同摄像头的画面(画中画),或者需要在不同摄像头数据流之间做融合处理,那可以研究一下。但对于单纯的镜头切换 + 手电筒问题,方案一通常是最佳选择。

总结与关键点

处理 AVFoundation 中广角/超广角切换与手电筒的冲突,核心在于避开配置变更期间的状态冲突

  • 最推荐的方法 :切换摄像头前关掉手电筒,切换完成后再根据需要打开。这是最稳妥的跨设备兼容方案。
  • 操作要点
    • 所有 AVFoundation 的配置变更(开关灯、加减输入、begin/commit)放到串行后台队列。
    • 每次操作设备(如开关手电筒),务必先 lockForConfiguration(),操作完后 unlockForConfiguration()
    • 确保操作的是当前 AVCaptureSession 中有效 的那个 AVCaptureDevice 实例。
    • 做好错误处理和设备能力检查(hasTorch, isTorchAvailable)。
    • UI 更新放到主线程。

遵循这些原则,应该就能解决这个恼人的卡死问题,让你的 App 在使用广角和超广角镜头,并需要手电筒辅助时,表现得更加稳定可靠。