iOS自定义相机实战:AVFoundation曝光补偿与实时亮度分析
2026/8/29 18:59:36 网站建设 项目流程

之前做 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)控制。
  • 在强光或者逆光环境下,帮助用户先看清画面,再按下快门。
  • 用自定义相机方案替代原生相机在某些场景下的不足。

放到开发层面,它背后的技术点其实就是几个:

  1. 用 AVFoundation 自定义相机采集流程。
  2. 实时获取每一帧画面的亮度数据。
  3. 根据亮度分析结果调整曝光参数。
  4. 在取景界面上叠加辅助控件,比如亮度条、点测光框、EV 滑块。

本文的实战项目会围绕这四点展开,做一个能用模拟器跑通、可以在真机上体验的 Mini 版本。

2. 环境准备与项目结构

2.1 开发环境说明

本文示例以常见环境为例,版本需要根据你的项目实际情况调整:

项目说明
操作系统macOS 12 及以上
IDEXcode 14 及以上
语言Swift 5.7+
UI 框架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 是什么

曝光补偿用 EV(Exposure Value)表示,单位为 eV,简单理解就是“在你测光结果的基础上,人为地让画面更亮或更暗一档”。

iPhone 相机的 AVFoundation 提供了非常直接的控制接口:

device.setExposureTargetBias(completionHandler:)

AVCaptureDevice上的exposureTargetBias属性可以设置当前曝光补偿值,范围通常在minExposureTargetBiasmaxExposureTargetBias之间,iPhone 一般支持-8.08.0,实际会根据设备能力有所差异。

3.3 实时亮度分析思路

在自定义相机中,要判断当前画面是不是“太黑”或“太亮”,最直接的方法是读取每一帧画面的平均亮度。

常用的实现路径是:

  1. AVCaptureVideoDataOutput输出CMSampleBuffer
  2. CMSampleBuffer转成CVPixelBuffer
  3. 使用 CoreImage 的CIAreaAverage滤镜计算平均颜色。
  4. 将平均颜色的亮度值映射到 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.swift

import 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.swift

import 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.swift

import 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.swift

import SwiftUI struct ExposureControlView: View { @Binding var exposureValue: Double let range: ClosedRange<Double> 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.swift

import 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: ClosedRange<Double> = -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中加入相机权限描述:

<key>NSCameraUsageDescription</key> <string>Camera Sun 需要使用相机来分析和调整拍摄画面</string>

不加这一条,应用启动相机采集时会直接崩溃。

4.7 运行验证

将上述代码编译运行到 iPhone 真机上,预期效果如下:

  1. 启动 App 后,进入自定义相机预览页面。
  2. 预览画面显示实时取景。
  3. 顶部亮度指示条根据画面亮度实时变化。
  4. 手动拖动 EV 滑块后,画面亮度发生明显变化。
  5. 手指点击预览画面任意位置,相机自动对焦并重新测光。

在模拟器上运行时,预览画面可能是黑屏或者显示模拟器桌面,这是因为模拟器没有真实相机采集能力。建议尽早使用真机调试。

5. 常见问题与排查思路

5.1 相机权限弹窗没有弹出

问题现象常见原因解决思路
启动后直接崩溃Info.plist 缺少相机权限描述检查NSCameraUsageDescription
权限弹窗弹出后点击允许,仍然黑屏用户之前选择了“不允许”,需要在系统设置中开启提示用户前往系统设置开启权限
模拟器上没有弹窗模拟器不支持真实相机使用真机调试

5.2 预览画面黑屏

问题现象常见原因解决思路
预览全黑,但相机权限已开启AVCaptureVideoPreviewLayer没有正确添加到视图层级检查previewLayer.frame是否更新
预览区域黑屏,但亮度数据有回调previewLayer被释放使用强引用保存 previewLayer
从后台返回后黑屏未在viewWillAppear重新启动 sessionscenePhase变化时重新startRunning()

5.3 亮度数据不刷新

如果cameraService(_:didOutput:)一直不回调,优先检查AVCaptureVideoDataOutput是否成功添加到 session:

if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } else { print("无法添加 videoDataOutput") }

另外,AVCaptureVideoDataOutput需要强引用,不要声明为局部变量。

5.4 EV 滑块拖动后画面没有变化

可能原因:

  1. 当前设备isExposureTargetBiasSupportedfalse
  2. 设备没有成功lockForConfiguration()
  3. EV 值没有落在minExposureTargetBias...maxExposureTargetBias范围内。

排查代码:

print("曝光补偿范围: \(device.minExposureTargetBias) ~ \(device.maxExposureTargetBias)") print("是否支持曝光补偿: \(device.isExposureTargetBiasSupported)")

5.5 性能问题:掉帧和发热

每帧都执行CIAreaAverage在真机上通常不会有太大压力,但如果你的项目比较复杂,可以考虑以下优化方式:

  • 降低采样帧率:比如每 3 帧计算一次亮度。
  • 缩小计算区域:使用CIImagecropped(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
  • 不同设备的minExposureTargetBiasmaxExposureTargetBias范围不同。
  • 某些设备支持autoFocusRangeRestriction,某些不支持。

所以在做设备配置时,一定要先查询能力,再设置属性:

if device.isExposurePointOfInterestSupported { device.exposurePointOfInterest = point }

6.4 线程模型的必要性

CameraService中,我使用了三个队列:

  • sessionQueue:配置会话、启停相机。
  • videoQueue:接收视频帧回调。
  • 主队列:更新 UI。

原因是:

  • 相机配置操作耗时且阻塞,不能放在主线程。
  • 视频帧回调频率高,如果直接在里面更新 UI,会导致卡顿。
  • AVFoundation 的 delegate 回调不一定在主线程,需要手动切回主队列更新 UI。

6.5 内存管理与生命周期

在视频帧回调中,CMSampleBufferCVPixelBuffer是高频创建的对象,如果持有引用时间过长,内存会快速增长。建议:

  • 亮度计算完成后,不要保留CIImageCVPixelBuffer的强引用。
  • 使用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 #endif

7. 总结与下一步方向

Camera Sun 这个项目最核心的价值,不是把原生相机的所有功能重写一遍,而是提供一种思路——在系统相机覆盖不到的场景里,用 AVFoundation 构建更符合特定需求的拍摄工具。本文的示例已经覆盖了自定义相机采集、实时亮度分析、曝光补偿调节、点测光对焦这些关键能力。你在自己的项目里可以直接复用CameraServiceCameraViewModel的代码结构,再根据需要扩展。

如果想在这个基础上继续深入,以下几件事值得优先研究:

第一,更精细的测光策略。目前的CIAreaAverage是全画面平均亮度,在复杂光线场景下不够准确。可以学习一下中心重点测光和点测光的算法思路,并结合人脸检测、主体识别来动态选择测光区域。

第二,手动对焦与变焦控制。AVFoundation 提供了videoZoomFactorfocusModelensPosition等接口,可以进一步做成专业相机应用那样的手动模式。

第三,RAW 格式与照片处理。如果要走专业方向,需要学习AVCapturePhotoOutput的 RAW 输出能力,以及如何在拍摄后做 TONE MAPPING。

第四,视频拍摄中的实时曝光调整。如果项目需要支持视频录制,还要考虑曝光调整过程中画面的平滑过渡,避免录制过程中亮度突变。

相机类 App 的开发门槛不在“能不能调起相机”,而在“能不能把画面效果调到用户满意”。希望这篇教程能帮你迈过第一道坎,后续的优化空间还很大。如果你在真机调试中遇到其他问题,欢迎在评论区交流讨论。

把代码跑起来,然后试着在夕阳场景下拍一组对比照片,你会发现——原来 iPhone 的自定义相机,比系统相机的可玩性高得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询