AVFoundation广角/超广角镜头切换手电筒卡死? 解决方案
2025-05-04 01:35:49
解决 AVFoundation 在广角/超广角镜头间切换时手电筒导致卡死的问题
不少开发者在使用 AVFoundation 处理实时视频捕捉,特别是在结合物体检测等功能时,可能会踩到一个坑:当手电筒(闪光灯)开启时,在广角(Wide)和超广角(Ultra Wide)镜头之间切换,画面会直接卡死。更具体地说:
- 如果手电筒是开着的,从广角切到超广角,取景画面就冻结了。
- 如果当前是超广角镜头,尝试打开手电筒,画面同样会卡死。
奇怪的是,iPhone 自带的相机 App 明明可以在录制视频时,顺畅地在超广角镜头下使用手电筒。这说明硬件上是支持的,那问题很可能出在 AVFoundation 的使用方式上。
这到底是 AVFoundation 的限制,还是有别的办法绕过去?苹果自家的相机 App 是怎么做到不卡死的?正确的操作姿势应该是怎样的?以及,如何在切换相机和控制手电筒之间做好同步?
刨根问底:为什么会卡死?
这个现象,十有八九和 AVFoundation
管理设备配置的方式有关,特别是涉及到会话配置 (AVCaptureSession beginConfiguration
/commitConfiguration
) 和设备锁定 (lockForConfiguration
/unlockForConfiguration
)。
咱们来捋一捋:
- 手电筒和摄像头的“绑定” : 手电筒通常是附属于某个具体的摄像头硬件模块的(主要是广角摄像头模块)。当你通过
AVCaptureDevice
控制手电筒时,实际上是在操作那个关联的硬件。 - 切换镜头的过程 : 在
AVFoundation
中切换摄像头,通常需要:beginConfiguration()
: 告诉AVCaptureSession
你要开始修改配置了。- 移除当前的
AVCaptureDeviceInput
。 - 创建并添加新的
AVCaptureDeviceInput
(对应新的摄像头,比如超广角)。 commitConfiguration()
: 应用这些更改。
- 冲突点 : 想象一下,当手电筒(由广角摄像头硬件控制)是亮着的时候,你突然告诉
AVCaptureSession
:“嘿,现在把输入设备换成超广角!”。在这个切换过程中,系统需要解除对广角镜头的占用,并启用超广角镜头。但此时,广角镜头关联的手电筒硬件可能还处于被AVFoundation
“锁定”或“占用”的状态。当你尝试在这个转换的瞬间继续维持或改变手电筒状态时,很可能就引发了底层的资源冲突或者状态不同步,导致整个捕捉会话卡死。
为什么原生相机 App 就没事?
这很难百分百确定,但有几种可能:
- 更底层的 API : 苹果可能使用了不对外开放的、更底层的 API 来控制摄像头和闪光灯,这些 API 能更精细、原子化地处理这种切换,避免了
AVFoundation
这种配置变更带来的冲突。 - 不同的处理逻辑 : 原生 App 可能在切换前有一个短暂的“预备”阶段,或者采用了一种更平滑的过渡机制。它可能并不完全依赖我们常用的
addInput
/removeInput
流程,或者在内部做了更完善的状态同步。 - 对特定硬件的优化 : 苹果自家应用自然最了解自家硬件的特性和时序要求,可能做了针对性的优化。
看回你提供的代码,forceEnableTorchThroughWide
函数尝试在切换前强制通过 广角 设备对象打开手电筒。但问题在于,当你即将或正在把 AVCaptureSession
的输入切换到 超广角 时,再去操作一个即将被移除或已经被移除的广角设备对象的 torchMode
,这本身就有点“拧巴”,很可能在 commitConfiguration
时触发内部错误。而且,手电筒的控制权应该始终与当前 Session 中 活跃 的那个输入设备相关联。
动手解决:让超广角和手电筒“和平共处”
搞清楚原因,解决起来就思路清晰了。核心思想就是:避免在配置变更的关键窗口期(beginConfiguration
和 commitConfiguration
之间)让手电筒状态和设备切换产生冲突。
下面提供几种可行的方案:
方案一:先关灯,再切换,最后(按需)开灯(推荐)
这是最稳妥、兼容性最好的方法。逻辑简单粗暴但有效:在切换摄像头之前,先把手电筒关掉;切换完成之后,如果之前是开着的,再把它重新打开。
原理:
通过在修改 AVCaptureSession
输入设备前,确保手电筒处于关闭状态,彻底解除了手电筒状态和设备切换的潜在冲突。切换完成后,再根据需要恢复手电筒状态。这给了系统足够的时间和清晰的状态来完成镜头的切换。
步骤与代码示例:
修改你的 ultrawideCameraTapped
方法,大致流程如下:
- 记录当前的手电筒状态。
- 如果手电筒是开着的,先把它关掉(作用于当前的设备)。
- 开始会话配置 (
beginConfiguration
)。 - 移除旧输入,添加新输入(切换到目标摄像头)。
- 提交会话配置 (
commitConfiguration
)。 - 切换成功后,如果第 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
)。 - 错误处理 :
lockForConfiguration
和addInput
等操作都可能抛出异常,务必使用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 在使用广角和超广角镜头,并需要手电筒辅助时,表现得更加稳定可靠。