1. 为什么需要双路径视频录制方案
最近在做一个社交类App的项目时,遇到了一个很有意思的技术选型问题:如何实现小视频录制功能?一开始我天真地以为直接用系统提供的UIImagePickerController就完事了,结果产品经理甩过来一长串需求清单:实时滤镜、精准裁剪、自定义分辨率...得,看来得搬出AVFoundation这个大家伙了。
AVFoundation提供了两种截然不同的视频录制路径:基于AVCaptureMovieFileOutput的"快速封装"方案,和基于AVAssetWriter的"深度定制"方案。这就像做菜一样,前者像是用现成的调料包,简单快捷但味道固定;后者则是自己调配调料,过程复杂但可以做出米其林级别的定制口味。
我在实际项目中两种方案都用过,AVCaptureMovieFileOutput方案从零开始到出Demo只用了半天,而AVAssetWriter方案光是调试视频参数就折腾了两天。但后者带来的灵活性让产品经理眼前一亮——我们可以在录制过程中实时添加水印、调整画质,甚至实现动态码率控制。
2. AVCaptureMovieFileOutput快速方案实战
2.1 基础搭建五部曲
先来看看这个"快餐式"方案的具体做法。记得第一次用这个方案时,我按照官方文档一步步操作,结果在真机上跑起来发现画面是倒置的——这就是没仔细看设备方向的典型坑。
// 1. 创建会话 let session = AVCaptureSession() session.sessionPreset = .hd1280x720 // 这个参数直接影响画质和性能 // 2. 配置设备输入 guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let audioDevice = AVCaptureDevice.default(for: .audio) else { return } let videoInput = try AVCaptureDeviceInput(device: videoDevice) let audioInput = try AVCaptureDeviceInput(device: audioDevice) // 3. 添加文件输出 let movieOutput = AVCaptureMovieFileOutput() if session.canAddOutput(movieOutput) { session.addOutput(movieOutput) } // 4. 设置预览层 let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) // 5. 启动会话 DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }这里有个性能优化的小技巧:sessionPreset不要盲目选择最高分辨率,4K视频在旧设备上会导致明显卡顿。我通常先检测设备型号,中低端设备用720p,高端设备才考虑1080p。
2.2 录制控制的那些坑
开始录制看似简单,但有几个魔鬼细节需要注意:
func startRecording() { let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("tempMovie.mp4") // 这个参数影响视频方向 if let connection = movieOutput.connection(with: .video) { connection.videoOrientation = .portrait } movieOutput.startRecording(to: outputURL, recordingDelegate: self) } func stopRecording() { movieOutput.stopRecording() }录制过程中最容易被忽视的是存储空间检查。有次用户反馈录制突然中断,排查后发现是手机存储空间不足——现在我会在开始录制前检查可用空间,小于100MB就提前提示用户。
2.3 代理回调的精准把控
实现AVCaptureFileOutputRecordingDelegate时,要注意这三个关键回调:
extension ViewController: AVCaptureFileOutputRecordingDelegate { // 实际开始写入文件时触发 func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) { print("真正开始写入时间戳:\(CACurrentMediaTime())") } // 录制过程中可能出现的错误 func fileOutput(_ output: AVCaptureFileOutput, wasInterruptedAt timestamp: CMTime, reason: AVCaptureSession.InterruptionReason) { showAlert("录制被中断:\(reason.rawValue)") } // 录制完成回调 func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { print("录制失败:\(error.localizedDescription)") return } processVideo(at: outputFileURL) } }特别提醒:didFinishRecordingTo回调完成不代表文件已经写入完毕!我遇到过文件尚未完全写入就被后续处理代码读取的情况,现在都会用FileCoordinator确保文件完整性。
3. AVAssetWriter深度定制方案
3.1 数据流处理架构
当产品提出"要在录制时实时添加滤镜"的需求时,我知道必须上AVAssetWriter了。这个方案的核心在于处理CMSampleBuffer数据流,下面是它的基本架构:
// 配置视频输出 let videoOutput = AVCaptureVideoDataOutput() videoOutput.setSampleBufferDelegate(self, queue: processingQueue) videoOutput.alwaysDiscardsLateVideoFrames = true // 丢帧保性能 // 配置音频输出 let audioOutput = AVCaptureAudioDataOutput() audioOutput.setSampleBufferDelegate(self, queue: processingQueue) // 创建AssetWriter var assetWriter: AVAssetWriter? var videoWriterInput: AVAssetWriterInput? var audioWriterInput: AVAssetWriterInput? func setupWriter(outputURL: URL) throws { assetWriter = try AVAssetWriter(url: outputURL, fileType: .mp4) // 视频输入配置 let videoSettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 720, AVVideoHeightKey: 1280, AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill ] videoWriterInput = AVAssetWriterInput( mediaType: .video, outputSettings: videoSettings ) videoWriterInput?.expectsMediaDataInRealTime = true // 音频输入配置 let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128000 ] audioWriterInput = AVAssetWriterInput( mediaType: .audio, outputSettings: audioSettings ) audioWriterInput?.expectsMediaDataInRealTime = true guard let writer = assetWriter, writer.canAdd(videoWriterInput!), writer.canAdd(audioWriterInput!) else { throw NSError() } writer.add(videoWriterInput!) writer.add(audioWriterInput!) }这里有个性能关键点:expectsMediaDataInRealTime必须设为true,否则在录制实时视频时会出现写入延迟。
3.2 实时处理的艺术
处理数据流时最考验技术功底,既要保证实时性又要处理各种异常情况:
extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard isRecording else { return } // 确保Writer就绪 if assetWriter?.status == .unknown { let startTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) assetWriter?.startWriting() assetWriter?.startSession(atSourceTime: startTime) } // 处理视频帧 if output is AVCaptureVideoDataOutput { processVideoBuffer(sampleBuffer) } // 处理音频帧 else if output is AVCaptureAudioDataOutput { processAudioBuffer(sampleBuffer) } } private func processVideoBuffer(_ buffer: CMSampleBuffer) { guard let writerInput = videoWriterInput, writerInput.isReadyForMoreMediaData else { return } // 这里可以插入滤镜处理 let filteredBuffer = applyFilters(to: buffer) if !writerInput.append(filteredBuffer) { print("视频帧写入失败:\(assetWriter?.error?.localizedDescription ?? "")") } } }实测发现,在iPhone X上处理1080p视频时,单个滤镜会使帧率从30fps降到22fps左右。我的优化方案是:在低端设备上先降低分辨率再应用滤镜。
3.3 优雅的录制收尾
停止录制时的处理直接影响最终视频的完整性:
func stopRecording() { processingQueue.async { [weak self] in guard let self = self else { return } self.videoWriterInput?.markAsFinished() self.audioWriterInput?.markAsFinished() self.assetWriter?.finishWriting { [weak self] in guard let self = self, self.assetWriter?.status == .completed else { print("视频合成失败") return } DispatchQueue.main.async { self.didFinishRecording(url: self.assetWriter?.outputURL) } } } }这里有个血泪教训:一定要在同一个队列(processingQueue)中完成markAsFinished操作,否则可能导致最后一帧丢失。我曾经因为这个问题被测试提了十几个视频残缺的bug。
4. 双方案深度对比与选型指南
4.1 性能参数实测对比
为了给团队提供客观的选型依据,我在iPhone 12上做了组对比测试:
| 指标 | AVCaptureMovieFileOutput | AVAssetWriter |
|---|---|---|
| CPU占用率(720p) | 12%-18% | 22%-35% |
| 内存占用 | 80MB | 120MB |
| 启动延迟 | 200ms | 500ms |
| 视频处理延迟 | 无实时处理能力 | 3-5帧延迟 |
| 1080p录制续航 | 4小时 | 2.5小时 |
有趣的是,当开启实时滤镜后,AVAssetWriter方案的CPU占用会飙升到45%以上,这时候必须考虑降低分辨率或者优化滤镜算法。
4.2 六大选型决策因素
根据项目经验,我总结出这些决策要点:
- 开发周期:如果赶时间上线,MovieFileOutput方案能节省至少3天开发量
- 定制需求:需要实时处理?选AVAssetWriter没商量
- 设备兼容性:针对旧设备优化时,MovieFileOutput的稳定性更好
- 后期处理:如果要对视频二次编辑,AVAssetWriter的元数据更完整
- 团队技能:AVAssetWriter需要更深的视频编码知识
- 功耗敏感:直播类应用要谨慎使用AVAssetWriter
最近一个电商项目里,我们最终采用了混合方案:普通商品展示用MovieFileOutput,需要AR试妆的特殊场景用AVAssetWriter实时添加妆容效果。
4.3 常见场景适配建议
- 社交APP短视频:先用MovieFileOutput快速上线,迭代时逐步替换为AVAssetWriter
- 教育类录屏:AVAssetWriter可以实时添加标注和画中画
- 安防监控:MovieFileOutput更稳定,且支持分段存储
- AR应用:必须用AVAssetWriter处理实时3D渲染结果
记得有个运动类APP的需求是在录制时实时标注心率数据,用AVAssetWriter配合Core Graphics在每帧上绘制数据曲线,最终效果让客户直呼"黑科技"。
5. 进阶技巧与避坑指南
5.1 方向处理的正确姿势
视频方向问题坑过无数开发者,这是我的终极解决方案:
func adjustVideoOrientation( _ buffer: CMSampleBuffer, connection: AVCaptureConnection ) -> CGAffineTransform { var transform = CGAffineTransform.identity // 设备方向 let deviceOrientation = UIDevice.current.orientation // 界面方向 let interfaceOrientation = UIApplication.shared.statusBarOrientation // 计算正确的变换矩阵 if connection.isVideoOrientationSupported { switch interfaceOrientation { case .portraitUpsideDown: connection.videoOrientation = .portraitUpsideDown transform = CGAffineTransform(rotationAngle: .pi) case .landscapeLeft: connection.videoOrientation = .landscapeLeft transform = CGAffineTransform(rotationAngle: .pi/2) case .landscapeRight: connection.videoOrientation = .landscapeRight transform = CGAffineTransform(rotationAngle: -.pi/2) default: connection.videoOrientation = .portrait } } // 前置摄像头需要镜像翻转 if cameraPosition == .front { transform = transform.scaledBy(x: -1, y: 1) } return transform }把这个变换矩阵设置到AVAssetWriterInput的transform属性上,能解决99%的视频方向问题。
5.2 内存优化实战
处理高分辨率视频时,内存管理尤为关键:
- 使用CMSampleBuffer池:避免频繁创建释放缓冲区
- 及时释放CVImageBuffer:
buffer.withImageBuffer { imageBuffer in // 处理图像... CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) }- 设置合理的像素格式:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange比ARGB节省40%内存
- 监控内存警告:
NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main ) { [weak self] _ in self?.handleMemoryWarning() }在最近的项目中,通过这些优化将4K视频处理时的内存峰值从1.2GB降到了600MB左右。
5.3 录制质量调优
视频质量的黄金三角法则:分辨率、帧率、码率需要平衡:
// 高质量配置 let highQualitySettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.hevc, AVVideoWidthKey: 1920, AVVideoHeightKey: 1080, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: 8_000_000, AVVideoExpectedSourceFrameRateKey: 30, AVVideoMaxKeyFrameIntervalKey: 30 ] ] // 均衡配置 let balancedSettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 1280, AVVideoHeightKey: 720, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: 3_000_000, AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel ] ]实测发现,HEVC编码比H.264节省约40%存储空间,但对设备要求更高。我的经验法则是:面向年轻用户群用HEVC,大众应用还是保守选择H.264。
6. 扩展功能实现思路
6.1 实时滤镜的三种实现方式
- Core Image方案:
func applyFilter(to buffer: CMSampleBuffer) -> CMSampleBuffer? { guard let imageBuffer = CMSampleBufferGetImageBuffer(buffer) else { return nil } let ciImage = CIImage(cvImageBuffer: imageBuffer) let filter = CIFilter(name: "CISepiaTone")! filter.setValue(ciImage, forKey: kCIInputImageKey) filter.setValue(0.8, forKey: kCIInputIntensityKey) let context = CIContext(options: nil) context.render(filter.outputImage!, to: imageBuffer) return buffer }优点:简单易用,内置滤镜丰富 缺点:性能消耗大,不适合复杂滤镜链
- Metal方案: 使用MTKView配合自定义着色器,性能最好但开发成本高
- 第三方库: 像GPUImage这样的成熟框架平衡了性能和易用性
在最近的美颜相机项目中,我们最终选择Metal方案,通过预编译着色器和管线优化,将滤镜处理时间控制在5ms以内。
6.2 动态码率控制技巧
根据场景动态调整码率可以显著改善用户体验:
func adjustBitRate( for motionLevel: CGFloat, currentBitRate: Int ) -> Int { let baseBitRate = 3_000_000 let maxBitRate = 6_000_000 // 运动剧烈时提高码率 let adjusted = baseBitRate + Int(motionLevel * 1_000_000) return min(max(adjusted, baseBitRate), maxBitRate) } // 在视频处理回调中 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { let motion = calculateMotionLevel(from: sampleBuffer) let newBitRate = adjustBitRate(for: motion, currentBitRate: currentBitRate) if newBitRate != currentBitRate { videoWriterInput?.setBitRate(newBitRate) currentBitRate = newBitRate } }这个技巧在运动类APP中特别有用,静态场景节省存储空间,快速运动时保证画质。
6.3 音频处理的隐藏关卡
很多人只关注视频处理,其实音频也有不少坑:
// 解决音频不同步问题 let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128000, AVEncoderBitRateStrategyKey: AVAudioBitRateStrategy_Constant, AVEncoderBitDepthHintKey: 16 ] // 处理音频采样率转换 func adjustAudioBuffer(_ buffer: CMSampleBuffer) -> CMSampleBuffer? { var timingInfo = CMSampleTimingInfo() CMSampleBufferGetSampleTimingInfo(buffer, at: 0, timingInfoOut: &timingInfo) timingInfo.presentationTimeStamp = adjustTimeStamp( timingInfo.presentationTimeStamp ) var newBuffer: CMSampleBuffer? CMSampleBufferCreateCopyWithNewTiming( allocator: kCFAllocatorDefault, sampleBuffer: buffer, sampleTimingEntryCount: 1, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer ) return newBuffer }曾经有个项目因为没处理好音频时间戳,导致视频越长音画不同步越明显,最后不得不重新处理所有用户上传的视频。