第一次在项目里接到“做一个能拍短视频的功能”这个需求时,我第一反应是直接拖一个UIImagePickerController上去。说实话,那会儿20分钟就能拍照、录视频,看起来效率很高。但产品需求一旦变成“录到12秒自动停、拍摄界面要自定义UI、直播要实时预览并推流、录制过程中要叠加时间水印”,UIKit那套封装就完全撑不住了。也就是从那时起,我才真正沉下心把AVFoundation啃了一遍。
这篇文章想和你聊的,就是AVFoundation在照片/视频捕捉上的完整落地思路。它是什么、能做什么、解决什么问题,以及在小视频、直播这类实战场景里,怎么选参数、怎么组织代码、又会在哪些地方踩坑。无论你是刚开始接触iOS相机开发,还是已经在用UIImagePickerController但准备转向更底层的自定义相机,这篇内容都能给你一条比较清晰的路线。
1. AVFoundation在iOS媒体能力中的位置:不是所有相机需求都得硬写底层
很多人一听说“用AVFoundation做相机”,第一反应是“这是不是有点重了”。这里面其实有个认知误区:不是所有相机需求都得直接操作底层硬件,但如果你要的是“可控”,那AVFoundation基本是绕不开的中间层。
1.1 先分清三套API:UIKit、AVFoundation、CoreMedia/VideoToolbox
iOS的媒体能力大概分三个层级,每层的控制粒度完全不同。
第一层是UIKit封装的相机能力,典型代表就是UIImagePickerController和PHPickerViewController。这一层适合“赶快出活”的场景:UI固定、流程固定、不需要在录制过程中做任何干预。拍照、选图、录一段简单的视频,直接调就完了。但它的边界也很明显——你不能改拍照按钮的交互样式,不能实时处理帧数据,更不可能在录制的同时做推流。
第二层就是AVFoundation,它提供的是对采集设备的会话级控制。你可以理解成UIKit帮你把“相机”封装成了一个整体,而AVFoundation把“相机”拆成了几张拼图:设备(设备型号、位置、对焦、曝光)、输入(设备怎么接入会话)、输出(采集到的数据以什么形式出去,是照片、视频文件还是裸数据帧)、会话(它们之间的协同关系)。这一层已经能覆盖绝大部分自定义相机需求,也是本文的核心。
第三层是CoreMedia和VideoToolbox,它们处理的是编码、解码、帧级别的数据操作。直播推流时你得把CMSampleBuffer喂给VideoToolbox做H.264硬编码,这已经是流媒体链路里偏后面的环节了。绝大多数App开发者不会长期泡在这一层,但得知道存在这条链路。
1.2 什么场景必须用AVFoundation
根据我自己的经验,只要踩中下面任意一条,就可以直接考虑AVFoundation:
- 自定义拍摄界面:比如竖排按钮、全屏预览、手势滑动变焦,这些用
UIImagePickerController根本做不到。 - 录制过程中实时叠加画面:水印、贴纸、美颜效果,必须拿到实时帧数据才行,
AVCaptureMovieFileOutput这类输出拿的是编码后的文件,改不了画面。 - 直播推流:采集端和编码/推流端是两回事,采集端需要把视频帧和音频帧回调给业务层,而不是简单落成一个MP4文件。
- 同时采集多路数据:比如前后双摄同时采,或者视频+深度数据一起采。
- 手动控制相机参数:对焦位置、曝光补偿、ISO、快门速度、白平衡,这些在系统相机里能调的,AVFoundation都给了接口。
换句话说,你的需求越接近“做一个像系统相机一样的东西”,就越应该从AVFoundation入手。反过来说,如果只是上传一张图片,PHPicker就够用了,没必要自己再造轮子。
2. 理解AVCaptureSession的三角关系:Input、Output、Connection
说完层级,直接进入核心。AVFoundation的采集体系可以用“三角关系”来概括:AVCaptureSession是中枢,AVCaptureDeviceInput代表硬件设备的输入,AVCaptureOutput代表数据的输出,而输入和输出之间通过AVCaptureConnection建立连接。先把这个结构理解透,后面写代码就是搭积木的事。
2.1 会话是中枢:beginConfiguration和commitConfiguration
AVCaptureSession的作用是协调输入和输出之间的数据流。你往会话里添加一个输入、一个输出,它就开始管理整个采集流程。但这里有个关键习惯:所有配置改动要包在beginConfiguration()和commitConfiguration()之间。
private let session = AVCaptureSession() func configureSession() { session.beginConfiguration() // 在这里做添加/移除输入输出、调整preset等操作 session.sessionPreset = .high session.commitConfiguration() }为什么要这样?因为beginConfiguration()之后,系统会暂时挂起配置变更通知,等你把一系列操作都改完再统一提交。如果你不做这个包装,每次增删输入输出都可能触发重新配置,容易造成卡顿甚至崩溃。这是一个新手很容易忽略、但影响很大的细节。
sessionPreset用来设定采集的整体质量档位,比如AVCaptureSession.Preset.high、.hd1280x720、.hd1920x1080、.hd4K3840x2160。需要注意的是,preset只是告诉系统“我大概要什么质量”,最终能不能达到还取决于硬件支持情况。所以严谨的做法是用session.canSetSessionPreset()做一次判断。
2.2 Connection:输入和输出之间的连接线
AVCaptureConnection是最容易被忽视、却又极其重要的概念。它描述了从一个输入设备(精确到一个指定的物理端口)到一个输出通道的连接关系。
比如后置摄像头采集到的画面,要输出给AVCaptureMovieFileOutput用于录像,同时也想输出给AVCaptureVideoPreviewLayer做预览。这两个输出各自与同一路输入建立Connection,互不干扰。而在直播场景里,你可以让视频数据输出给AVCaptureVideoDataOutput拿到帧数据做美颜处理,音频数据输出给AVCaptureAudioDataOutput单独采集,互相同步后再合流推出去。这就是Connection的灵活性所在。
另外,Connection还携带了一些关键属性,比如:videoOrientation(画面方向)、preferredVideoStabilizationMode(视频防抖模式)、isVideoMirrored(镜像,前置摄像头默认要开)。照片方向不对、视频拍出来上下颠倒,多数情况下都是这里没设好。
2.3 输出端的多样性:Photo、MovieFile、VideoData、AudioData
AVFoundation输出端的类型直接决定了数据流出去之后的形态。常用的有四种:
AVCapturePhotoOutput:用于静态照片采集,支持RAW、Live Photo、闪光灯控制、预览捕获等。拍一张照片时,它会通过delegate回调给你AVCapturePhoto对象,里面有完整的图片数据和元数据。AVCaptureMovieFileOutput:用于直接录制视频文件,底层帮你封装好了MP4/QuickTime格式。适合做“录完就能拿文件”的普通小视频功能。AVCaptureVideoDataOutput:逐帧输出原始视频数据(CMSampleBuffer),适合做实时滤镜、美颜、直播编码。这是进阶方向的核心。AVCaptureAudioDataOutput:同理,逐帧输出音频数据,常用于直播或录音需要单独处理音轨的场景。
我一般会先问自己:这个功能是需要文件,还是需要帧数据?需要文件就省事用MovieFileOutput,需要帧就得上VideoDataOutput,这决定了后续的代码架构完全不同。
3. 照片捕捉的完整落地:从配置到写相册
现在开始真正动手。先做一个最小可用的“拍照功能”,把整个链路打通:配置会话 -> 预览画面 -> 按下快门 -> 拿到照片 -> 保存到相册。这一节的内容适合当作模板直接抄。
3.1 配置一个基本可用的照片采集会话
一个最基础的照片采集会话,需要三个部分:会话、输入(后置摄像头)、输出(AVCapturePhotoOutput),外加一个预览Layer。
import AVFoundation import UIKit class CameraManager: NSObject { let session = AVCaptureSession() private let photoOutput = AVCapturePhotoOutput() var previewLayer: AVCaptureVideoPreviewLayer? func configureSession() -> Bool { session.beginConfiguration() session.sessionPreset = .high // 1. 获取后置广角摄像头 guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return false } // 2. 创建设备输入 guard let input = try? AVCaptureDeviceInput(device: device) else { return false } // 3. 添加输入并校验 guard session.canAddInput(input) else { return false } session.addInput(input) // 4. 添加照片输出并校验 guard session.canAddOutput(photoOutput) else { return false } session.addOutput(photoOutput) session.commitConfiguration() return true } func startSession() { if !session.isRunning { // 注意:startRunning是异步的,后面会细说 session.startRunning() } } func stopSession() { if session.isRunning { session.stopRunning() } } }这段代码有两点值得注意。第一,AVCaptureDevice.default(_:for:position:)是iOS 10之后推荐的获取设备方式,你指定设备类型(广角、长焦、超广角)、媒体类型(视频/音频)和位置(前置/后置/不指定),就能拿到对应的硬件对象。第二,canAddInput和canAddOutput这个判断一定不能省,不同机型的支持能力差异很大,直接硬加会导致会话配置失败。
配置完成后,把previewLayer挂到界面上:
func setupPreview(in view: UIView) { let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.bounds view.layer.addSublayer(layer) previewLayer = layer }3.2 按下快门:AVCapturePhotoOutput的完整回调链路
照片输出最有用的点是它能生成一张附带完整元数据的静态图片,而不是简单截取预览Layer的内容。按下快门时,调用capturePhoto(with:delegate:)方法,传入一个AVCapturePhotoSettings对象和一个delegate。
func takePhoto() { let settings = AVCapturePhotoSettings() // 如果设备支持闪光灯,可以在这里控制 if photoOutput.supportedFlashModes.contains(.auto) { settings.flashMode = .auto } photoOutput.capturePhoto(with: settings, delegate: self) }然后实现AVCapturePhotoCaptureDelegate的回调方法:
extension CameraManager: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { guard error == nil, let data = photo.fileDataRepresentation() else { print("照片数据处理失败: \(String(describing: error))") return } // 拿到data后,可以做上传、做滤镜、或保存相册 saveToPhotoLibrary(data) } }fileDataRepresentation()返回的是完整可用的图片数据,内部已经帮你处理好了HEIC/JPEG的包装逻辑。如果你直接拿photo.pixelBuffer自己去做渲染,那就是另一个更进阶的话题了。
3.3 保存到相册:PHPhotoLibrary的权限与写入
照片拿到手,接下来是保存。这里强烈建议用PHPhotoLibrary而不是直接写文件到tmp目录,因为相册才是用户能直接看到的媒介。
func saveToPhotoLibrary(_ data: Data) { PHPhotoLibrary.requestAuthorization { status in guard status == .authorized || status == .limited else { print("没有相册写入权限") return } PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() request.addResource(with: .photo, data: data, options: nil) } completionHandler: { success, error in if success { print("保存成功") } else { print("保存失败: \(String(describing: error))") } } } }iOS 14之后新增了.limited授权状态,意味着用户只允许你访问部分照片。对于“拍照后保存到相册”这个场景,.limited其实够用,但需要在界面上给用户一个明确的能力说明。
我实际做的时候还发现一个问题:performChanges的completionHandler不一定在主线程回调,如果你要在里面刷新UI,记得先DispatchQueue.main.async包一层。
4. 视频录制与小视频场景:帧率、码率、分辨率怎么定
拍照只是第一步。小视频场景里,录制一段可预览、可分享的视频才是重点。这一节我会聊两套方案:直接用AVCaptureMovieFileOutput录文件,以及用AVCaptureVideoDataOutput获取帧数据后再做处理。两者适用的需求完全不同。
4.1 AVCaptureMovieFileOutput还是AVCaptureVideoDataOutput
AVCaptureMovieFileOutput最大的优点是省心:你只需要配置好输入输出,调用startRecording(to:recordingDelegate:),系统就开始往指定路径写文件,录制结束后delegate回调给你一个可播放的本地视频文件。
let movieOutput = AVCaptureMovieFileOutput() // 配置会话时 if session.canAddOutput(movieOutput) { session.addOutput(movieOutput) } // 开始录制 let path = NSTemporaryDirectory() + UUID().uuidString + ".mp4" movieOutput.startRecording(to: URL(fileURLWithPath: path), recordingDelegate: self)如果只是做一个简单的“按住录制、松手出片”的功能,MovieFileOutput完全够用。它的缺点是:你拿不到原始帧数据,也就没法在录制过程中加水印、美颜、替背景。
反过来,AVCaptureVideoDataOutput会在每一帧采集完成后,把CMSampleBuffer回调给你。你可以把这一帧交给CoreImage做滤镜,或者交给VideoToolbox做硬编码,再封装成推流格式。这也是直播采集端的标准姿势。
4.2 小视频场景下的参数选择:不是越高越好
刚接触视频采集时,大家的常见问题是“能上4K就上4K,能60帧就60帧”。但实际做小视频功能时,这个思路会带来两个麻烦:一是文件体积大得离谱,15秒的4K视频动不动上百MB;二是弱光环境下的高帧率高分辨率画质反而更差。
我的建议是:
| 场景 | 推荐分辨率 | 推荐帧率 | 备注 |
|---|---|---|---|
| 15秒短视频分享 | 720p或1080p | 30fps | 体积和画质平衡 |
| 慢动作特效 | 720p | 120fps或240fps | 依赖设备能力和光线 |
| 直播推流 | 720p或1080p | 30fps | 码率受网络限制 |
| 高画质短视频 | 1080p | 60fps | 适合运动画面 |
分辨率通过sessionPreset控制,帧率则需要单独在设备上配置。举个例子,要让后置摄像头跑60fps:
func configureHighFrameRate() { guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return } var selectedFormat: AVCaptureDevice.Format? var selectedFPS: Int = 30 for format in device.formats { for range in format.videoSupportedFrameRateRanges { if range.maxFrameRate >= 60 && range.minFrameRate <= 30 { selectedFormat = format selectedFPS = Int(min(range.maxFrameRate, 60)) } } } guard let format = selectedFormat else { return } do { try device.lockForConfiguration() device.activeFormat = format device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: CMTimeScale(selectedFPS)) device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: CMTimeScale(selectedFPS)) device.unlockForConfiguration() } catch { print("配置高帧率失败: \(error)") } }这里的逻辑是:遍历设备支持的所有格式,找到能跑60fps的格式,然后通过activeFormat切换过去。为什么要遍历?因为不是所有分辨率都支持高帧率,直接写死格式编号是一个非常脆弱的做法。
另外,高帧率模式会显著增加功耗和发热,录制超过几分钟后,系统可能会因为thermal压力自动降频甚至中断会话。所以给小视频功能定帧率时,千万别盲目追求120fps。
4.3 录制中做特效:用VideoDataOutput接CoreImage
如果你需要在录制过程中实时叠加滤镜或水印,方案就不是MovieFileOutput,而是AVCaptureVideoDataOutput+ CoreImage的组合。
大致流程是这样:videoDataOutput的代理方法captureOutput(_:didOutput:from:)里拿到每个样本缓冲,转换成CIImage后做滤镜处理,再交给后续编码器。这就意味着采集链路变成了“采集 -> 处理 -> 编码 -> 封装”,每一步都需要你自己掌控。
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let ciImage = CIImage(cvPixelBuffer: pixelBuffer) // 此时可以用CIFilter做美颜、调色等处理 // 处理完之后交给编码器或者继续传给预览 // 注意控制处理耗时,避免掉帧 }这里最大的坑是性能。实时处理一帧的时间不能超过帧间隔,否则采集管线会追不上现实时间,表现出来就是画面卡顿、录出来的视频一卡一卡的。我的个人经验是:滤镜不要堆太多,能用硬件加速的就用硬件加速(比如Metal),CPU端的CIFilter操作要尽量精简。
5. 直播场景的进阶:数据回调、对焦、缩放、性能
小视频和直播在采集层面最大的区别,是直播要的是“源源不断的数据流”而不是“一个文件”。所以直播场景里,AVCaptureVideoDataOutput和AVCaptureAudioDataOutput是绝对的主力。这一节我重点讲讲直播采集的几个核心操作。
5.1 直播采集链路:CMSampleBuffer交给VideoToolbox硬编
直播的完整链路是:摄像头/麦克风采集原始数据 -> 对视频帧做美颜/滤镜 -> 编码器(一般用VideoToolbox的H.264硬编) -> 封装成FLV等格式 -> 推流到CDN。
在AVFoundation这一层,你要做的事就是配置一个同时包含视频和音频输出的会话,然后在回调里把样本数据按时间戳交给编码器。
session.beginConfiguration() // 添加视频输入 // 添加音频输入 let videoOutput = AVCaptureVideoDataOutput() videoOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] session.addOutput(videoOutput) let audioOutput = AVCaptureAudioDataOutput() session.addOutput(audioOutput) session.commitConfiguration()kCVPixelBufferPixelFormatTypeKey这里为什么要设成32BGRA?因为VideoToolbox的H.264编码器通常接收BGRA格式的像素缓存,CoreImage也能直接处理这个格式。如果你不指定,系统给的默认格式可能需要在编码前多一次转换,白白浪费性能。
直播场景另一个麻烦是音视频同步。两个独立的数据输出回调在不同的时间到达,一定要靠时间戳对齐。CMSampleBuffer里自带presertationTimeStamp,编码时按这个时间戳打上PTS,就不会错位。
5.2 点按对焦与曝光:手动控制的核心方法
做直播一个非常高频的交互是“点按画面中对焦”。AVFoundation里对焦和曝光都是用设备对象来控制的。
func focusAndExpose(at point: CGPoint) { guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return } do { try device.lockForConfiguration() if device.isFocusPointOfInterestSupported { device.focusPointOfInterest = point device.focusMode = .autoFocus } if device.isExposurePointOfInterestSupported { device.exposurePointOfInterest = point device.exposureMode = .autoExpose } device.unlockForConfiguration() } catch { print("设置对焦/曝光失败: \(error)") } }注意,在调用lockForConfiguration()之后一定要在合适的时机unlockForConfiguration()。如果忘记解锁,后续其他操作会一直失败,而且系统日志里不会直接报错,排查起来很难受。
对焦模式有.locked、.autoFocus、.continuousAutoFocus几种。直播场景里通常用.autoFocus让用户点一下后自动对焦到这个点,如果镜头前的东西移动了再重新点击。连续自动对焦适合拍摄移动物体,但会带来画面轻微呼吸感,直播时反而容易显得不稳定。
曝光也一样,有.locked、.autoExpose、.continuousAutoExposure。如果需要手动调亮度,可以进一步设置device.exposureTargetBias。
5.3 变焦与视频稳定
直播画面里“双指缩放”也是高频操作。AVFoundation的变焦通过videoZoomFactor控制,取值范围在1.0和activeFormat.videoMaxZoomFactor之间。
func setZoom(factor: CGFloat) { guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return } do { try device.lockForConfiguration() device.videoZoomFactor = max(1.0, min(factor, device.activeFormat.videoMaxZoomFactor)) device.unlockForConfiguration() } catch { print("设置变焦失败: \(error)") } }videoMaxZoomFactor在不同机型、不同格式下差异很大,有些长焦镜头的最大值甚至超过10倍。你直接写死8.0在低端设备上可能就崩溃或越界,所以一定要做min/max夹取。
视频防抖方面,AVCaptureConnection上有一个preferredVideoStabilizationMode属性,可选.off、.standard、.cinematic、.auto。直播场景通常建议用.auto,让系统根据当前光线和运动幅度决定是否开启防抖。防抖会带来画面裁切(因为需要给移动留出余量),如果你发现直播画面怎么比别的App少了一块,多半是防抖模式导致的。
5.4 性能监控与掉帧排查
直播掉帧是最让人头疼的问题,因为采集端的掉帧会被直接放大成观众端的卡顿。排查时我一般分三步:先看CPU占用率,再看GPU占用率(通过Instruments里的Core Animation调试),最后看是否有thermal压力。
在代码里可以挂一个简单的帧间隔统计:
var lastTimestamp: CMTime? var frameCountSinceLog: Int = 0 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { if let last = lastTimestamp { let interval = CMTimeSubtract(sampleBuffer.presentationTimeStamp, last) if CMTimeCompare(interval, CMTime(value: 1, timescale: 30)) > 0 { // 当前帧间隔大于33ms,说明可能掉帧了 } } lastTimestamp = sampleBuffer.presentationTimeStamp }如果掉帧,优先检查三件事:一是处理帧数据的回调里有没有做耗时操作,比如直接在主线程跑CoreImage;二是videoDataOutput有没有设置automaticallyDropsLateVideoFrames,这个属性在实时处理场景默认值为true,如果处理跟不上,系统会主动丢帧,这是保护机制,一般不用关;三是推流端的发送缓冲区是否堆积,发送不过去时整个采集会话会受影响。
6. 真实项目里的坑和排查思路:session为什么会黑屏
最后用一节的篇幅,把我在实际项目里踩得最深的几个坑整理出来。这些问题在网上讨论很多,但每次自己遇到时还是会绕好几个弯。如果你在看这篇内容之前就已经动过手,大概率也踩过其中一两个。
6.1 黑屏问题:session.startRunning()其实是异步的
刚接触AVFoundation时,我遇到的第一类问题就是“明明配置好了会话,界面上一片黑”。原因是startRunning()是一个异步操作,它不会马上点亮摄像头,而是要等系统真正启动采集链路后,画面才会出现。如果在startRunning()返回后就立刻把预览Layer显示出来,很可能看到的是黑屏。
处理方式有两种:一是通过KVO监听session.isRunning,等它变成true后再显示预览;二是注册AVCaptureSession.runtimeErrorNotification,捕获采集过程中的运行时错误。最稳妥的做法是两者结合,直播App里尤其要监听runtimeError,因为直播时摄像头被其他App抢占、或者设备发热导致采集被中断,都会通过这个通知暴露出来。
我也见过“黑屏但session还是running”的情况。这时通常是因为预览Layer没有正确关联到session,或者videoGravity设得不对导致画面渲染区域异常。检查时先确认previewLayer是否还在视图层级里,再检查frame是否有效。
6.2 前后台切换与权限变更后的重启
App退到后台再接回来,经常发现相机黑屏或者摄像头打不开。原因是退后台时系统会暂停相机硬件,但你的session可能还处于running状态;回到前台时,硬件恢复需要重新配置。
我的习惯是在App进入后台时调用session.stopRunning(),进入前台后再session.startRunning()。但这又引出另一个坑:startRunning()是阻塞方法,如果在主线程调用而设备忙于配置,可能会导致UI卡顿,严重时还会被看门狗杀掉。所以更规范的做法是把startRunning()放到一个串行队列里,而不是直接在applicationDidBecomeActive里调用。
权限方面,如果用户进App时没授权相机,后面在设置里打开权限回到App,系统不会自动把session重新跑起来,需要在回调里重新配置并startRunning()。
6.3 高帧率不可用:前置摄像头的格式限制
做美颜直播时,有一个高频现象:后置摄像头跑60fps没问题,切到前置摄像头就提示“不支持”。这不是代码写错了,而是设备本身对前置摄像头的格式支持范围更窄。
解决方式也是遍历device.formats检查videoSupportedFrameRateRanges,在切换镜头前先判断目标设备是否支持当前设置的fps和分辨率。如果支持就正常切换,不支持就回退到30fps并给用户一个提示。这个过程需要在切镜头时同步更新activeFormat,否则即便session里有新input,帧率和分辨率还是老一套。
6.4 保存视频/照片失败:PHPhotoLibrary权限与存储空间
最后说一个“项目快要上线了才暴露”的问题:用户拒绝相册权限时,很多代码直接崩溃或者静默失败。iOS 14之后的授权状态更细,requestAuthorization回调里除了.authorized和.denied,还有.limited。如果你的App只是把用户拍摄的内容保存到相册,.limited其实够用,但要给用户一个“只允许部分照片”的引导说明。
另一个容易被忽略的是存储空间判断。视频录制时如果存储满了,AVCaptureFileOutputRecordingDelegate的回调会带一个error,代码里如果只判断“文件是否存在”而不判断error,就会以为录制成功了,其实文件没写完整。所以我通常会这样写:
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { // 处理录制中断,清理临时文件 return } // 正常处理 outputFileURL }有时录制出问题的是用户还在返回上一页,录制会话已经停止了,回调里依然会走didFinishRecordingTo,这个时候error可能是nil,但文件时长很短。判断时最好再检查一下文件的实际时长和大小,避免保存一个0字节的“成功文件”。
最后一点实操体会
AVFoundation这套框架,刚入手时觉得类多、调用繁琐,但当你把Input、Output、Connection这三角关系理顺之后,后面写照片、录像、直播就都是在同一个框架里换不同的搭法。
我在项目里还有一个习惯:把相机整个封装成一个单独的模块,对外只暴露startCamera、stopCamera、takePhoto、startRecording这类语义明确的接口,不让业务层直接碰AVCaptureSession。这样一来,切换镜头、调整参数、替换滤镜都只在模块内部改,业务层完全不用关心底层是用的MovieFileOutput还是VideoDataOutput。
如果你刚开始做这块,不用急着把直播那套链路一步到位。先从拍照链路抄一遍,跑通后再接视频录制,最后再往数据帧方向走,每一步都验证扎实了再往后推进。AVFoundation虽然上手门槛比UIKit高一些,但一旦跨过这道坎,后面你会突然发现:原来系统相机的很多交互细节,自己也能一点一点搭出来了。