公司动态

iOS自定义相机实战:AVFoundation曝光补偿与实时亮度分析

📅 2026/8/29 19:00:06
iOS自定义相机实战:AVFoundation曝光补偿与实时亮度分析
之前做 iOS 拍摄类功能时我一直在吐槽 iPhone 原生相机在逆光场景下的体验明明阳光很好取景器里却黑成一片点一下亮处对焦整体画面又过曝得厉害。后来在 Hacker News 上看到一个很有意思的项目叫 “Camera Sun”定位就是针对 iPhone 相机 App 的取景与曝光问题进行改进。这让我意识到单纯吐槽原生相机不够用不如自己动手在 AVFoundation 层面做一套自定义相机方案把取景亮度分析、曝光补偿、手动辅助控件这些能力补全。本文将围绕这个思路完整拆解一个名为Camera Sun的 iOS 自定义相机项目。先从原生相机的问题场景入手再讲清楚 AVFoundation 的采集架构然后给出完整可运行的 SwiftUI AVFoundation 案例代码最后补充常见报错排查和工程化建议。无论你是 iOS 新手还是在做自定义相机、扫码、AR 采集的开发者都可以从中找到可以复用的内容。1. 背景iPhone 原生相机在逆光场景下为什么“不好用”1.1 原生相机的“自动”与“不够自动”iPhone 自带相机 App 的拍摄体验在大多数日常场景下是非常优秀的白平衡、自动曝光、智能 HDR 都做得足够好。但一旦进入逆光、大光比、强太阳直射这类环境问题就很容易暴露出来取景器画面偏暗主体轮廓不清晰。点击暗部对焦亮部直接过曝。点击亮部对焦暗部又死黑一片。自动曝光系统始终在“你想拍什么”和“天空很亮”之间摇摆。这本质上不是相机硬件不行而是原生相机的自动曝光策略更偏向“整体画面均衡”在生产级拍摄中我们经常需要的是“对某个区域精准曝光”甚至需要手动干预 EV 补偿值。1.2 “Camera Sun”项目想解决的问题“Camera Sun”这个名字很直白——它面向的是“太阳”这种极端光源场景。项目的核心目标是把取景器中的画面亮度信息用更直观的方式呈现出来。提供手动曝光补偿EV控制。在强光或者逆光环境下帮助用户先看清画面再按下快门。用自定义相机方案替代原生相机在某些场景下的不足。放到开发层面它背后的技术点其实就是几个用 AVFoundation 自定义相机采集流程。实时获取每一帧画面的亮度数据。根据亮度分析结果调整曝光参数。在取景界面上叠加辅助控件比如亮度条、点测光框、EV 滑块。本文的实战项目会围绕这四点展开做一个能用模拟器跑通、可以在真机上体验的 Mini 版本。2. 环境准备与项目结构2.1 开发环境说明本文示例以常见环境为例版本需要根据你的项目实际情况调整项目说明操作系统macOS 12 及以上IDEXcode 14 及以上语言Swift 5.7UI 框架SwiftUI系统框架AVFoundation、CoreImage、UIKit最低部署版本iOS 15.0调试设备建议使用 iPhone 真机需要特别说明的是相机类功能在模拟器中会有很大限制尤其是采集会话、曝光参数调整、亮度回调这些能力模拟器只能模拟部分行为。所以本文的代码在模拟器上可以运行界面但完整功能建议在真机上验证。2.2 项目基础结构我们先在 Xcode 中创建一个 SwiftUI App 项目工程名称可以叫CameraSunDemo最终文件结构如下CameraSunDemo/ ├── CameraSunDemoApp.swift ├── ContentView.swift ├── Models/ │ └── ExposureState.swift ├── Services/ │ └── CameraService.swift ├── Views/ │ ├── CameraPreviewView.swift │ ├── BrightnessIndicatorView.swift │ └── ExposureControlView.swift └── Info.plist后面所有代码都会按照这个结构给出文件路径方便你对照创建。3. 核心原理AVFoundation 相机采集链路3.1 从 AVCaptureSession 说起在 iOS 中实现自定义相机核心类是AVCaptureSession。它就像一条“数据管道”把相机硬件采集到的数据经过输入、输出两个端口连接起来。相机硬件 ↓ AVCaptureDeviceInput输入 ↓ AVCaptureSession会话 ↓ AVCaptureVideoDataOutput输出回调 ↓ CMSampleBuffer单帧数据与直接调用UIImagePickerController相比使用 AVFoundation 自定义采集可以获得对每一帧画面做实时处理的能力。手动控制曝光、对焦、白平衡的权限。更自由的界面定制。更低的延迟。3.2 曝光补偿 EV 是什么曝光补偿用 EVExposure Value表示单位为 eV简单理解就是“在你测光结果的基础上人为地让画面更亮或更暗一档”。iPhone 相机的 AVFoundation 提供了非常直接的控制接口device.setExposureTargetBias(completionHandler:)AVCaptureDevice上的exposureTargetBias属性可以设置当前曝光补偿值范围通常在minExposureTargetBias到maxExposureTargetBias之间iPhone 一般支持-8.0到8.0实际会根据设备能力有所差异。3.3 实时亮度分析思路在自定义相机中要判断当前画面是不是“太黑”或“太亮”最直接的方法是读取每一帧画面的平均亮度。常用的实现路径是AVCaptureVideoDataOutput输出CMSampleBuffer。将CMSampleBuffer转成CVPixelBuffer。使用 CoreImage 的CIAreaAverage滤镜计算平均颜色。将平均颜色的亮度值映射到 UI 上。具体代码如下func brightness(from pixelBuffer: CVPixelBuffer) - Float? { let ciImage CIImage(cvPixelBuffer: pixelBuffer) let extent ciImage.extent let filter CIFilter(name: CIAreaAverage)! filter.setValue(ciImage, forKey: kCIInputImageKey) filter.setValue(CIVector(cgRect: extent), forKey: kCIInputExtentKey) guard let outputImage filter.outputImage else { return nil } var bitmap [UInt8](repeating: 0, count: 4) let context CIContext(options: [.workingColorSpace: NSNull()]) context.render(outputImage, toBitmap: bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) let r Float(bitmap[0]) / 255.0 let g Float(bitmap[1]) / 255.0 let b Float(bitmap[2]) / 255.0 // 使用 Rec.709 亮度权重 let brightness 0.2126 * r 0.7152 * g 0.0722 * b return brightness }这里使用CIAreaAverage的原因是它由 CoreImage 底层优化性能比自己遍历像素要好很多。需要注意的点是CIContext建议复用不要每帧创建。CIAreaAverage的运行耗时取决于图像大小实际项目建议先缩小图像再计算。3.4 AVCaptureVideoDataOutput 的像素格式设置要让上面的亮度计算代码正常工作需要确保输出像素格式是 BGRA 或者 RGBA。在创建AVCaptureVideoDataOutput时建议显式设置videoOutput.videoSettings [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ]如果你不设置系统默认可能输出 420f也就是kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange这时候直接转CIImage再走CIAreaAverage也可以但输出颜色通道需要额外处理不够直观。所以我们统一改成 BGRA方便调试。4. 完整实战实现 Camera Sun 的取景亮度分析与曝光控制下面我们进入核心实战环节。这一节会逐步完成一个 Mini 版 Camera Sun功能包括启动自定义相机并显示实时预览。分析每一帧画面的平均亮度。用一个可视化的亮度条展示当前亮度状态。通过滑块手动调节曝光补偿 EV 值。点击预览画面进行点测光对焦。4.1 创建相机服务类先创建相机采集服务类CameraService它负责整个 AVCaptureSession 的生命周期。文件路径Services/CameraService.swiftimport AVFoundation import CoreImage import UIKit protocol CameraServiceDelegate: AnyObject { func cameraService(_ service: CameraService, didOutput brightness: Float) func cameraService(_ service: CameraService, didFail error: Error) } final class CameraService: NSObject { private let session AVCaptureSession() private let sessionQueue DispatchQueue(label: com.camerasun.sessionQueue) private let videoOutput AVCaptureVideoDataOutput() private let ciContext CIContext(options: [.workingColorSpace: NSNull()]) private var currentDevice: AVCaptureDevice? weak var delegate: CameraServiceDelegate? var previewLayer: AVCaptureVideoPreviewLayer { let layer AVCaptureVideoPreviewLayer(session: session) layer.videoGravity .resizeAspectFill return layer } override init() { super.init() videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: com.camerasun.videoQueue)) videoOutput.alwaysDiscardsLateVideoFrames true videoOutput.videoSettings [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] } func start() { sessionQueue.async { [weak self] in self?.configureSession() self?.session.startRunning() } } func stop() { sessionQueue.async { [weak self] in self?.session.stopRunning() } } private func configureSession() { session.beginConfiguration() session.sessionPreset .high guard let device AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { delegate?.cameraService(self, didFail: CameraError.deviceNotFound) return } currentDevice device do { let input try AVCaptureDeviceInput(device: device) if session.canAddInput(input) { session.addInput(input) } } catch { delegate?.cameraService(self, didFail: error) return } if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } session.commitConfiguration() } func setExposureBias(_ value: Float) { guard let device currentDevice else { return } do { try device.lockForConfiguration() let clampedValue max(device.minExposureTargetBias, min(device.maxExposureTargetBias, value)) device.setExposureTargetBias(clampedValue) { _ in } device.unlockForConfiguration() } catch { print(锁定设备配置失败: \(error)) } } func focusAtPoint(_ point: CGPoint, in previewLayer: AVCaptureVideoPreviewLayer) { guard let device currentDevice else { return } let pointOfInterest previewLayer.captureDevicePointConverted(fromLayerPoint: point) do { try device.lockForConfiguration() if device.isFocusPointOfInterestSupported { device.focusPointOfInterest pointOfInterest device.focusMode .autoFocus } if device.isExposurePointOfInterestSupported { device.exposurePointOfInterest pointOfInterest device.exposureMode .autoExpose } device.unlockForConfiguration() } catch { print(设置对焦失败: \(error)) } } } extension CameraService: AVCaptureVideoDataOutputSampleBufferDelegate { func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer CMSampleBufferGetImageBuffer(sampleBuffer) else { return } if let brightness brightness(from: pixelBuffer) { DispatchQueue.main.async { [weak self] in self?.delegate?.cameraService(self!, didOutput: brightness) } } } private func brightness(from pixelBuffer: CVPixelBuffer) - Float? { let ciImage CIImage(cvPixelBuffer: pixelBuffer) let extent ciImage.extent let filter CIFilter(name: CIAreaAverage)! filter.setValue(ciImage, forKey: kCIInputImageKey) filter.setValue(CIVector(cgRect: extent), forKey: kCIInputExtentKey) guard let outputImage filter.outputImage else { return nil } var bitmap [UInt8](repeating: 0, count: 4) ciContext.render(outputImage, toBitmap: bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) let r Float(bitmap[0]) / 255.0 let g Float(bitmap[1]) / 255.0 let b Float(bitmap[2]) / 255.0 return 0.2126 * r 0.7152 * g 0.0722 * b } } enum CameraError: LocalizedError { case deviceNotFound var errorDescription: String? { switch self { case .deviceNotFound: return 未找到可用的相机设备 } } }代码说明使用sessionQueue串行队列执行会话配置和启停操作避免主线程阻塞。videoOutput.alwaysDiscardsLateVideoFrames true表示帧数据堆积时丢弃旧帧保证亮度回调的实时性。亮度计算使用 Rec.709 权重更接近人类对亮度的感知。setExposureBias方法会先做min/max范围约束防止传入非法值导致崩溃。4.2 创建预览视图在 SwiftUI 中接入AVCaptureVideoPreviewLayer最通用的方式是使用UIViewRepresentable包装一个UIView。文件路径Views/CameraPreviewView.swiftimport SwiftUI import AVFoundation struct CameraPreviewView: UIViewRepresentable { let previewLayer: AVCaptureVideoPreviewLayer func makeUIView(context: Context) - UIView { let view UIView() view.backgroundColor .black previewLayer.frame view.bounds view.layer.addSublayer(previewLayer) return view } func updateUIView(_ uiView: UIView, context: Context) { DispatchQueue.main.async { previewLayer.frame uiView.bounds } } }这里有一个容易忽略的细节makeUIView执行时UIView的 bounds 可能还不是最终尺寸所以需要在updateUIView中持续更新previewLayer.frame否则预览画面可能显示不全。4.3 创建亮度指示器亮度指示器是 Camera Sun 项目最有辨识度的 UI 之一。它用一条横向的渐变条展示当前画面的平均亮度用户一眼就能看出“现在画面是偏暗还是偏亮”。文件路径Views/BrightnessIndicatorView.swiftimport SwiftUI struct BrightnessIndicatorView: View { let brightness: Float let targetBrightness: Float private var normalizedValue: CGFloat { CGFloat(max(0, min(1, brightness))) } var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { Capsule() .fill(Color.black.opacity(0.6)) .overlay( Capsule() .stroke(Color.white.opacity(0.3), lineWidth: 1) ) // 亮度渐变条 Capsule() .fill( LinearGradient( colors: [.black, .gray, .white], startPoint: .leading, endPoint: .trailing ) ) .frame(width: geometry.size.width * normalizedValue) // 目标亮度指示线 Rectangle() .fill(Color.yellow) .frame(width: 2) .offset(x: geometry.size.width * CGFloat(targetBrightness) - 1) } } .frame(height: 8) } } struct BrightnessIndicatorView_Previews: PreviewProvider { static var previews: some View { BrightnessIndicatorView(brightness: 0.5, targetBrightness: 0.6) .padding() } }这里的targetBrightness可以理解为我们“期望的画面亮度”比如 0.5 代表中等亮度。当指示条超过目标线时画面偏亮低于目标线时画面偏暗。4.4 创建曝光控制滑块曝光补偿是 Camera Sun 的核心交互这里用 SwiftUI 的Slider绑定 EV 值。文件路径Views/ExposureControlView.swiftimport SwiftUI struct ExposureControlView: View { Binding var exposureValue: Double let range: ClosedRangeDouble var body: some View { VStack(spacing: 8) { HStack { Text(EV) .font(.caption) .foregroundColor(.white) Slider(value: $exposureValue, in: range, step: 0.1) .tint(.yellow) Text(String(format: %.1f, exposureValue)) .font(.caption.monospacedDigit()) .foregroundColor(.white) .frame(width: 50, alignment: .trailing) } HStack { Text(暗) .font(.caption2) .foregroundColor(.white.opacity(0.6)) Spacer() Text(亮) .font(.caption2) .foregroundColor(.white.opacity(0.6)) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.black.opacity(0.5)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 20) } }4.5 组装主界面现在把相机预览、亮度指示器、曝光控制整合到一个界面中。文件路径Views/ContentView.swiftimport SwiftUI import AVFoundation struct ContentView: View { StateObject private var viewModel CameraViewModel() var body: some View { ZStack { Color.black.ignoresSafeArea() if let previewLayer viewModel.previewLayer { CameraPreviewView(previewLayer: previewLayer) .ignoresSafeArea() } else { ProgressView(相机初始化中...) .foregroundColor(.white) } VStack { Spacer() // 亮度状态卡片 VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: sun.max.fill) .foregroundColor(.yellow) Text(亮度分析) .font(.headline) .foregroundColor(.white) Spacer() Text(String(format: %.2f, viewModel.currentBrightness)) .font(.system(.body, design: .monospaced)) .foregroundColor(.white) } BrightnessIndicatorView( brightness: viewModel.currentBrightness, targetBrightness: viewModel.targetBrightness ) Text(viewModel.brightnessDescription) .font(.caption) .foregroundColor(.white.opacity(0.8)) } .padding(16) .background(Color.black.opacity(0.5)) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal, 20) ExposureControlView( exposureValue: $viewModel.exposureValue, range: viewModel.exposureRange ) } } .onAppear { viewModel.start() } .onDisappear { viewModel.stop() } .gesture( DragGesture(minimumDistance: 0) .onEnded { value in viewModel.focusAtPoint(value.location) } ) } } final class CameraViewModel: ObservableObject { Published var previewLayer: AVCaptureVideoPreviewLayer? Published var currentBrightness: Float 0 Published var exposureValue: Double 0 Published var targetBrightness: Float 0.55 Published var exposureRange: ClosedRangeDouble -8.0...8.0 Published var brightnessDescription: String 等待数据... private let cameraService CameraService() private var isUpdatingExposure false init() { cameraService.delegate self preparePreviewLayer() } private func preparePreviewLayer() { // 这里延迟获取 previewLayer确保 session 已经初始化 DispatchQueue.main.async { [weak self] in guard let self self else { return } self.previewLayer self.cameraService.previewLayer } } func start() { cameraService.start() } func stop() { cameraService.stop() } func focusAtPoint(_ point: CGPoint) { guard let previewLayer previewLayer else { return } cameraService.focusAtPoint(point, in: previewLayer) } } extension CameraViewModel: CameraServiceDelegate { func cameraService(_ service: CameraService, didOutput brightness: Float) { DispatchQueue.main.async { self.currentBrightness brightness switch brightness { case ..0.2: self.brightnessDescription 画面过暗建议增加曝光补偿 case 0.2..0.4: self.brightnessDescription 画面偏暗可适当增加曝光 case 0.4..0.7: self.brightnessDescription 亮度正常无需调整 case 0.7..0.9: self.brightnessDescription 画面偏亮可适当降低曝光 default: self.brightnessDescription 画面过亮建议降低曝光补偿 } // 如果有手动曝光调整不自动干预 if abs(self.exposureValue) 0.05 { self.autoAdjustExposureIfNeeded(brightness: brightness) } } } func cameraService(_ service: CameraService, didFail error: Error) { DispatchQueue.main.async { self.brightnessDescription 相机错误: \(error.localizedDescription) } } private func autoAdjustExposureIfNeeded(brightness: Float) { guard !isUpdatingExposure else { return } isUpdatingExposure true defer { isUpdatingExposure false } let diff targetBrightness - brightness // 只有偏差超过阈值才调整避免频繁抖动 if abs(diff) 0.1 { let adjustment Double(diff * 2) let newValue max(-8, min(8, exposureValue adjustment)) cameraService.setExposureBias(Float(newValue)) DispatchQueue.main.async { self.exposureValue newValue } } } }代码说明CameraViewModel作为中间层负责把CameraService的代理回调转成 SwiftUI 可观察的Published属性。exposureValue被用户手动拖动后自动曝光调整会暂停避免“用户想调系统又拉回来”的冲突。亮度描述文本使用区间判断提示用户当前画面状态。4.6 配置权限在Info.plist中加入相机权限描述keyNSCameraUsageDescription/key stringCamera Sun 需要使用相机来分析和调整拍摄画面/string不加这一条应用启动相机采集时会直接崩溃。4.7 运行验证将上述代码编译运行到 iPhone 真机上预期效果如下启动 App 后进入自定义相机预览页面。预览画面显示实时取景。顶部亮度指示条根据画面亮度实时变化。手动拖动 EV 滑块后画面亮度发生明显变化。手指点击预览画面任意位置相机自动对焦并重新测光。在模拟器上运行时预览画面可能是黑屏或者显示模拟器桌面这是因为模拟器没有真实相机采集能力。建议尽早使用真机调试。5. 常见问题与排查思路5.1 相机权限弹窗没有弹出问题现象常见原因解决思路启动后直接崩溃Info.plist 缺少相机权限描述检查NSCameraUsageDescription权限弹窗弹出后点击允许仍然黑屏用户之前选择了“不允许”需要在系统设置中开启提示用户前往系统设置开启权限模拟器上没有弹窗模拟器不支持真实相机使用真机调试5.2 预览画面黑屏问题现象常见原因解决思路预览全黑但相机权限已开启AVCaptureVideoPreviewLayer没有正确添加到视图层级检查previewLayer.frame是否更新预览区域黑屏但亮度数据有回调previewLayer被释放使用强引用保存 previewLayer从后台返回后黑屏未在viewWillAppear重新启动 session在scenePhase变化时重新startRunning()5.3 亮度数据不刷新如果cameraService(_:didOutput:)一直不回调优先检查AVCaptureVideoDataOutput是否成功添加到 sessionif session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } else { print(无法添加 videoDataOutput) }另外AVCaptureVideoDataOutput需要强引用不要声明为局部变量。5.4 EV 滑块拖动后画面没有变化可能原因当前设备isExposureTargetBiasSupported为false。设备没有成功lockForConfiguration()。EV 值没有落在minExposureTargetBias...maxExposureTargetBias范围内。排查代码print(曝光补偿范围: \(device.minExposureTargetBias) ~ \(device.maxExposureTargetBias)) print(是否支持曝光补偿: \(device.isExposureTargetBiasSupported))5.5 性能问题掉帧和发热每帧都执行CIAreaAverage在真机上通常不会有太大压力但如果你的项目比较复杂可以考虑以下优化方式降低采样帧率比如每 3 帧计算一次亮度。缩小计算区域使用CIImage的cropped(to:)只分析画面中心区域。降低视频输出分辨率使用session.sessionPreset .medium。避免在主线程做任何 CoreImage 操作。降低采样帧率的示例private var frameCount 0 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { frameCount 1 guard frameCount % 3 0 else { return } // 亮度计算 }6. 最佳实践与工程建议6.1 曝光控制的策略选择Camera Sun 项目里我把曝光控制做成了两种模式混合用户不干预时系统根据亮度信息自动调整 EV。用户手动调整后以用户设置为准。这种设计的核心思路是不跟用户抢控制权。原生相机之所以在一些场景下给人“不听话”的感觉恰恰是因为自动策略太强势。在自定义相机中我们应该把自动策略看成“默认值”而不是“唯一值”。在实际业务中还可以增加一个“自动/手动”切换开关让用户明确知道当前处于哪种模式。6.2 亮度分析的通用性CIAreaAverage计算的是全画面平均亮度但在很多场景下它并不准确。例如画面左边是太阳右边是阴影平均亮度可能正好落在 0.5。但用户真正想拍的主体在阴影里需要的是“阴影区域”的亮度数据。更合理的方式是引入区域权重// 只分析画面中心 60% 区域的亮度 let centerRect CGRect(x: extent.width * 0.2, y: extent.height * 0.2, width: extent.width * 0.6, height: extent.height * 0.6) let centerImage ciImage.cropped(to: centerRect)这样在逆光场景下自动曝光会更偏向画面中心的主体。6.3 设备配置的边界检查AVFoundation 的 API 在不同 iOS 版本、不同设备上有很大差异。比如某些设备支持builtInDualCamera某些只有builtInWideAngleCamera。不同设备的minExposureTargetBias和maxExposureTargetBias范围不同。某些设备支持autoFocusRangeRestriction某些不支持。所以在做设备配置时一定要先查询能力再设置属性if device.isExposurePointOfInterestSupported { device.exposurePointOfInterest point }6.4 线程模型的必要性在CameraService中我使用了三个队列sessionQueue配置会话、启停相机。videoQueue接收视频帧回调。主队列更新 UI。原因是相机配置操作耗时且阻塞不能放在主线程。视频帧回调频率高如果直接在里面更新 UI会导致卡顿。AVFoundation 的 delegate 回调不一定在主线程需要手动切回主队列更新 UI。6.5 内存管理与生命周期在视频帧回调中CMSampleBuffer和CVPixelBuffer是高频创建的对象如果持有引用时间过长内存会快速增长。建议亮度计算完成后不要保留CIImage或CVPixelBuffer的强引用。使用autoreleasepool包裹每帧处理逻辑。在 App 进入后台时调用stop()回到前台再start()。示例func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { autoreleasepool { // 帧处理逻辑 } }6.6 权限处理的工程化真实项目中相机权限不应该只在启动时请求一次。更合理的方式是启动时检查授权状态。如果被拒绝在 UI 上展示引导页面。引导用户去系统设置中开启。从后台返回时重新检查授权状态。switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: // 已有权限直接启动 case .notDetermined: // 请求权限 AVCaptureDevice.requestAccess(for: .video) { granted in // 处理结果 } case .denied: // 提示用户去设置开启 case .restricted: // 家长控制等情况 break }6.7 日志与调试建议相机模块涉及硬件交互真机问题往往只能在特定设备上复现。建议在关键路径输出日志// 设备信息 print(设备: \(device.localizedName)) print(曝光范围: \(device.minExposureTargetBias) ~ \(device.maxExposureTargetBias)) print(当前曝光: \(device.exposureTargetBias)) // 会话状态 print(session.isRunning: \(session.isRunning))在调试阶段打开 AVFoundation 的日志会有大量输出如果不好定位问题可以先加一个全局日志开关#if DEBUG let isDebugLogEnabled true #else let isDebugLogEnabled false #endif7. 总结与下一步方向Camera Sun 这个项目最核心的价值不是把原生相机的所有功能重写一遍而是提供一种思路——在系统相机覆盖不到的场景里用 AVFoundation 构建更符合特定需求的拍摄工具。本文的示例已经覆盖了自定义相机采集、实时亮度分析、曝光补偿调节、点测光对焦这些关键能力。你在自己的项目里可以直接复用CameraService和CameraViewModel的代码结构再根据需要扩展。如果想在这个基础上继续深入以下几件事值得优先研究第一更精细的测光策略。目前的CIAreaAverage是全画面平均亮度在复杂光线场景下不够准确。可以学习一下中心重点测光和点测光的算法思路并结合人脸检测、主体识别来动态选择测光区域。第二手动对焦与变焦控制。AVFoundation 提供了videoZoomFactor、focusMode、lensPosition等接口可以进一步做成专业相机应用那样的手动模式。第三RAW 格式与照片处理。如果要走专业方向需要学习AVCapturePhotoOutput的 RAW 输出能力以及如何在拍摄后做 TONE MAPPING。第四视频拍摄中的实时曝光调整。如果项目需要支持视频录制还要考虑曝光调整过程中画面的平滑过渡避免录制过程中亮度突变。相机类 App 的开发门槛不在“能不能调起相机”而在“能不能把画面效果调到用户满意”。希望这篇教程能帮你迈过第一道坎后续的优化空间还很大。如果你在真机调试中遇到其他问题欢迎在评论区交流讨论。把代码跑起来然后试着在夕阳场景下拍一组对比照片你会发现——原来 iPhone 的自定义相机比系统相机的可玩性高得多。