4、视频采集与预览:使用AVCaptureSession进行视频采集、配置摄像头与分辨率、实时预览与画面调整

视频采集,说白了就是让摄像头把光信号变成数字信号。iOS 上做这件事,绕不开 AVCaptureSession。我最早接触它的时候,觉得这玩意儿配置起来挺啰嗦的,但用熟了就会发现,它的设计其实很优雅——把采集、预览、输出拆得清清楚楚。

这一章,我们就来聊聊怎么用 AVCaptureSession 搞定视频采集。我会把我在项目中踩过的坑、积累的经验都抖出来。

4.1 AVCaptureSession 的核心架构

先看一张逻辑图,帮你快速建立认知:

AVCaptureSession
├── AVCaptureDeviceInput(输入:摄像头/麦克风)
├── AVCaptureVideoDataOutput(输出:原始视频帧)
├── AVCaptureAudioDataOutput(输出:原始音频数据)
└── AVCaptureVideoPreviewLayer(预览:实时画面)

嗯,结构就这么简单。一个 session 管理输入和输出,中间由系统帮你做数据流转。你只需要告诉它:

  • 从哪个设备采集(前置/后置摄像头)
  • 输出什么格式(视频帧、音频数据)
  • 要不要实时预览(预览层)

我个人习惯把 session 的配置封装成一个方法,这样在多个页面复用时特别方便。你想想看,如果每个页面都写一遍配置代码,那维护起来得多痛苦。

4.2 配置摄像头与分辨率

配置摄像头,第一步是找到可用的设备。iOS 设备上的摄像头种类不少:

设备类型 说明 常见场景
.builtInWideAngleCamera 广角摄像头 日常拍摄、直播
.builtInTelephotoCamera 长焦摄像头 远距离拍摄
.builtInUltraWideCamera 超广角摄像头 大场景拍摄
.builtInDualCamera 双摄(广角+长焦) 人像模式

获取设备的代码很简单:

guard let device = AVCaptureDevice.default(.builtInWideAngleCamera,
                                           for: .video,
                                           position: .back) else {
    print("后置摄像头不可用")
    return
}

这里有个坑——position 参数。你如果指定了 .back,但设备只有前置摄像头(比如某些老款 iPad),那就会返回 nil。我在项目中遇到过这种情况,后来加了个降级逻辑:如果指定位置不可用,就取第一个可用的摄像头。

注意: 在模拟器上,摄像头设备是拿不到的。真机调试时,记得在 Info.plist 中添加 NSCameraUsageDescription 权限描述,否则会 crash。

4.3 分辨率的选择

分辨率这东西,不是越高越好。你想想看,4K 分辨率虽然清晰,但每帧数据量巨大,处理起来 CPU 和 GPU 都吃不消。我建议根据场景来选:

分辨率 宽高比 推荐场景
AVCaptureSessionPreset640x480 4:3 老设备、低功耗场景
AVCaptureSessionPreset1280x720 16:9 直播推流(性价比最高)
AVCaptureSessionPreset1920x1080 16:9 高清录制、短视频
AVCaptureSessionPreset3840x2160 16:9 专业级录制(慎用)

设置分辨率时,记得先检查设备是否支持:

if device.supportsSessionPreset(.hd1920x1080) {
    session.sessionPreset = .hd1920x1080
} else {
    session.sessionPreset = .hd1280x720
}

我曾经在一个项目里直接写死了 1080p,结果在 iPhone 6 上跑不起来——那台设备最高只支持 720p。嗯,从那以后我就养成了检查支持列表的习惯。

4.4 实时预览的实现

预览层用的是 AVCaptureVideoPreviewLayer,它本质上是一个 CALayer 子类。你把它加到 view 的 layer 上,就能看到摄像头画面了。

let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)

这里有个细节:videoGravity 属性。它决定了画面如何适配预览层的大小:

  • .resizeAspect:保持宽高比,可能会有黑边
  • .resizeAspectFill:保持宽高比,填满整个区域,可能会裁剪
  • .resize:拉伸画面,不保持比例(一般不推荐)

我个人习惯用 .resizeAspectFill,因为直播场景下,画面铺满屏幕看起来更舒服。但要注意,如果你要做画面上的标注(比如人脸框),坐标需要做转换——预览层的坐标系和实际画面坐标系是不一样的。

小技巧: 如果你发现预览画面是倒的,别慌。检查一下设备的连接方向。用 previewLayer.connection?.videoOrientation = .portrait 可以修正。

4.5 画面调整:对焦、曝光与白平衡

画面调整是直播场景里的刚需。用户可能从暗处走到亮处,或者想拍清楚某个物体。这些操作都对应着摄像头硬件的调节。

4.5.1 对焦

对焦有两种模式:自动对焦和手动对焦。自动对焦是默认开启的,但如果你想实现「点击屏幕对焦」的效果,就需要手动设置:

func focus(at point: CGPoint) {
    guard let device = AVCaptureDevice.default(for: .video) else { return }
    do {
        try device.lockForConfiguration()
        if device.isFocusPointOfInterestSupported {
            device.focusPointOfInterest = point
            device.focusMode = .autoFocus
        }
        device.unlockForConfiguration()
    } catch {
        print("对焦失败: \(error)")
    }
}

注意,point 是归一化坐标,范围是 (0,0) 到 (1,1)。你需要把用户点击的屏幕坐标转换一下。

4.5.2 曝光

曝光调整和对焦类似,也是通过 lockForConfiguration 来设置。我建议在直播场景里使用连续自动曝光(.continuousAutoExposure),这样用户从暗处走到亮处时,画面会自动调整亮度。

if device.isExposureModeSupported(.continuousAutoExposure) {
    device.exposureMode = .continuousAutoExposure
}

但如果你要做专业相机 App,可能需要手动曝光补偿。我记得有一次做户外直播,阳光太强,自动曝光把画面压得太暗。后来我加了个滑块,让用户可以手动调节曝光补偿值(-2 到 +2),效果好了很多。

4.5.3 白平衡

白平衡决定了画面的色温。自动白平衡在大多数场景下够用,但如果你在混合光源下(比如日光灯+自然光),画面可能会偏色。这时候可以锁定白平衡:

if device.isWhiteBalanceModeSupported(.locked) {
    device.whiteBalanceMode = .locked
}

嗯,这里要提醒一下:锁定白平衡后,如果环境光变化,画面颜色不会自动调整。所以一般直播场景还是用 .continuousAutoWhiteBalance 更稳妥。

4.6 完整的采集流程

把上面这些串起来,一个完整的视频采集流程大概是这样的:

// 1. 创建 session
let session = AVCaptureSession()

// 2. 配置输入
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera,
                                           for: .video,
                                           position: .back) else { return }
guard let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) {
    session.addInput(input)
}

// 3. 配置输出
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
if session.canAddOutput(videoOutput) {
    session.addOutput(videoOutput)
}

// 4. 配置预览
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)

// 5. 启动采集
session.startRunning()

这段代码看起来简单,但实际项目中要注意的点不少。比如 startRunning 是一个同步方法,会阻塞当前线程。我建议把它放到后台线程执行,避免卡 UI。

核心要点: 视频采集的配置顺序很重要——先加输入,再加输出,最后配置预览。如果你先加了预览层再添加输入,预览层可能不会自动刷新。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 权限问题: 没有权限时,AVCaptureDeviceInput 初始化会抛异常。记得在调用前检查 AVCaptureDevice.authorizationStatus(for: .video)
  • Session 中断: 电话进来、App 切后台时,session 会自动中断。监听 AVCaptureSessionWasInterruptedNotificationAVCaptureSessionInterruptionEndedNotification 来做恢复处理。
  • 内存泄漏: 如果你在 AVCaptureVideoDataOutput 的代理方法里做了耗时操作,记得用 autoreleasepool 包裹,否则内存会暴涨。

我曾经在一个直播项目里,因为没处理 session 中断,导致用户接完电话后画面卡死。嗯,从那以后,我每次都会加上中断监听和恢复逻辑。

好了,这一章的内容就到这里。视频采集是音视频开发的基石,把 AVCaptureSession 玩熟了,后面的编码、推流才能走得顺。下一章,我们来聊聊音频采集——你会发现,很多思路是相通的。