引言
在之前的博客中,我写过一篇关于拍照的文章。在最近的项目开发过程中,我遇到了一个视频认证的功能需求。这个功能需要一个定制的界面,实现视频的录制、重拍和上传功能。
如果只是选择或拍摄一段视频来上传,我们可以使用 UIImagePickerController(选择和录制)或 PHPickerViewController 来实现。这些系统提供的方案虽然方便,但不可避免地缺乏灵活性。因此,对于这种需要定制 UI 的视频录制页面,我们不得不自己实现视频录制功能。
功能实现
在前面的博客中,我们已经介绍过关于 AVCaptureSession、AVCaptureDevice、AVCaptureDeviceInput 以及 AVCaptureOutput。在拍照功能中,我们主要使用了 AVCaptureOutput 的子类 AVCapturePhotoOutput。
而对于视频录制,我们需要使用另一个子类 AVCaptureMovieFileOutput,它主要负责视频数据的捕获和保存。
创建相机管理类
我们首先来创建一个继承自NSObject的相机管理类CSCameraController。
基本属性
它的作用主要用来启动捕捉会话,视频录制和输出,并不掺杂任何的业务相关的代码,先来看一下它的几个私有属性:
/// 会话
private var captureSession:AVCaptureSession!
/// 视频输出
private var videoOutput:AVCaptureMovieFileOutput!
/// 自定义队列
private var sessionQueue = DispatchQueue(label: "com.camera.session.queue")
/// 摄像头
private var videoDevice:AVCaptureDevice?
包含了捕捉会话,视频输出已经当前正在使用的摄像头设备,另外我们还创建了一个自定的队列,稍后我们会在该队列内启动会话,因为如果我们不明确指出它的队列的话,那么AVCaptureSession默认会在主队列来执行它的任务,这样有可能会导致UI的阻塞,特别是当处理视频捕捉这种高开销的任务时。
代理
我们还为它创建了一个代理,定义了两个代理方法,并提供了默认的实现:
/// 代理
weak var delegate:CSCameraControllerDelegate?
protocol CSCameraControllerDelegate: NSObjectProtocol {
/// 错误
func cameraController(_ cameraController:CSCameraController, didFailWithError error:Error)
/// 视频录制完成
func cameraController(_ cameraController:CSCameraController, didFinishRecordingTo outputFileURL:URL)
}
extension CSCameraControllerDelegate {
// 默认实现
func cameraController(_ cameraController:CSCameraController, didFailWithError error:Error) {
}
func cameraController(_ cameraController:CSCameraController, didFinishRecordingTo outputFileURL:URL) {
}
}
第一个代理方法用于将启动录制功能发生的错误信息返回到业务层面。
第二个方法用于将录制完成的视频地址传递到业务层。
只读属性
定义三个只读属性,为了从外面获取录制的一些信息比如是否正在录制,当前捕捉会话,以及录制时长:
/// 是否正在录制
var isRecording:Bool {
get {
return videoOutput.isRecording
}
}
/// 当前会话
var session:AVCaptureSession {
get {
return captureSession
}
}
/// 录制时长
var recordingDuration:CMTime {
get {
return videoOutput.recordedDuration
}
}
捕捉会话
我们创建了三个公共的方可以提供给外层调用,用来配置和操作捕捉会话。
配置会话
初始化捕捉会话,并且配置会话的视频输入,音频输入和影片输出:
/// 设置会话
func setupSession() {
captureSession = AVCaptureSession()
captureSession.sessionPreset = .high
// 添加视频输入
addVideoDeviceInput()
// 添加音频输入
addAudioDeviceInput()
// 添加视频输出
addVideoDeviceOutput()
}
视频输入
/// 添加视频输入
private func addVideoDeviceInput() {
// 获取前置摄像头
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
CSLog.debug(module: "CSCameraController", "获取前置摄像头失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1001, description: "获取前置摄像头失败"))
}
return
}
// 获取视频输入
guard let videoInput = try? AVCaptureDeviceInput(device: videoDevice) else {
CSLog.debug(module: "CSCameraController", "获取输入会话失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1002, description: "获取输入会话失败"))
}
return
}
// 添加
if self.captureSession.canAddInput(videoInput) {
self.captureSession.addInput(videoInput)
} else {
CSLog.debug(module: "CSCameraController", "添加视频输入失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1000, description: "添加视频输入失败"))
}
}
self.videoDevice = videoDevice
}
音频输入
/// 添加音频输入
private func addAudioDeviceInput() {
// 获取麦克风
guard let audioDevice = AVCaptureDevice.default(for: .audio) else {
CSLog.debug(module: "CSCameraController", "获取麦克风失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1003, description: "获取麦克风失败"))
}
return
}
// 获取音频输入
guard let audioInput = try? AVCaptureDeviceInput(device: audioDevice) else {
CSLog.debug(module: "CSCameraController", "获取音频输入失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1004, description: "获取音频输入失败"))
}
return
}
// 添加
if self.captureSession.canAddInput(audioInput) {
self.captureSession.addInput(audioInput)
} else {
CSLog.debug(module: "CSCameraController", "添加音频输入失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1005, description: "添加音频输入失败"))
}
}
}
视频输出
/// 添加视频输出
private func addVideoDeviceOutput() {
videoOutput = AVCaptureMovieFileOutput()
if self.captureSession.canAddOutput(videoOutput) {
self.captureSession.addOutput(videoOutput)
} else {
CSLog.debug(module: "CSCameraController", "添加视频输出失败")
if let delegate = delegate {
delegate.cameraController(self, didFailWithError: createError(code: 1006, description: "添加视频输出失败"))
}
}
}
启动会话
判断会话状态,在自定义队列中启动会话:
/// 开始会话
func startSession() {
if captureSession.isRunning {
return
}
sessionQueue.async {
self.captureSession.startRunning()
}
}
停止会话
判断会话状态,停止会话:
/// 停止会话
func stopSession() {
if !captureSession.isRunning {
return
}
sessionQueue.async {
self.captureSession.stopRunning()
}
}
开始录制
判断是否在录制状态,如果没有在录制,则直接开启录制。
/// 开始录制
func startRecording() {
if isRecording {
return
}
let videoConnection = videoOutput.connection(with: .video)
// 方向
// 防抖
// 对焦
let url = buildVideoUrl()
videoOutput.startRecording(to: url, recordingDelegate: self)
}
/// 构建视频路径url
private func buildVideoUrl() -> URL {
let path = NSTemporaryDirectory() + "1v1video.mp4"
// 如果已经存在该文件则删除
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
return URL(fileURLWithPath: path)
}
调用startRecording方法传入视频的输出地址,设置代理,就可以启动录制了。
在启动之前我们可以进行一些设置,比如视频录制的方向,防抖以及对焦等等,关于这些功能的设置需要涉及到一个新的类AVCaptureConnection本篇博客就不具体介绍了。
停止录制
调用同名方法直接停止视频录制:
/// 停止录制
func stopRecording() {
if !isRecording {
return
}
videoOutput.stopRecording()
}
实现代理
遵循并实现代理方法,将视频的文件地址传递出去。
class CSCameraController: NSObject, AVCaptureFileOutputRecordingDelegate {
...
}
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: (any Error)?) {
// 获取视频
let video = outputFileURL
if let delegate = delegate {
delegate.cameraController(self, didFinishRecordingTo: video)
}
}
创建预览视图
预览需要使用AVCaptureVideoPreviewLayer图层,通过设置捕捉会话的方式来实现预览,和拍照一样我们采用重写UIView的layerClass方法来重置UIView的图层,并在初始时设置它的内容填充模式为.resizeAspectFill,这就以为着画面将充满整个屏幕。
class CSPreviewView: UIView {
/// 重写layerClass
override class var layerClass: AnyClass {
return AVCaptureVideoPreviewLayer.self
}
var previewLayer:AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
/// 会话
var session: AVCaptureSession? {
didSet {
previewLayer.session = session
}
}
init() {
super.init(frame: .zero)
previewLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
创建录制页面
基本属性
我们为录制页面按照UI涉及创建了一些基本的属性,比如定时器,底部工具栏,最大时长,最小时长,小问号等等:
/// 预览视图
private let previewView = CSPreviewView()
/// 录制控制器
private let cameraController = CSCameraController()
/// 小问号
private let questionBtn = UIButton()
/// 底部按钮
private let bottomView = CS1V1VideoBottomView()
/// 定时器
private var timer: Timer?
/// 记录视频时长
private var videoDuration: TimeInterval = 0
/// 视频最小时长
private let minDuration: TimeInterval = 5
/// 视频最大时长
private let maxDuration: TimeInterval = 15
/// 视频url
private var videoUrl: URL?
/// 数据处理类
private let presenter = CSMakeFriendsModePresenter()
添加预览
有了相机管理类和预览页面,接下来我们只需要把它俩放到我们的录制页面的视图控制器中,一个简单的预览画面就会呈现出来了。
func addPreviewView() {
previewView.frame = self.view.bounds
self.view.addSubview(previewView)
}
/// 设置相机
private func setupCamera() {
cameraController.setupSession()
previewView.session = cameraController.session
cameraController.startSession()
cameraController.delegate = self
}
// MARK: - CSCameraControllerDelegate
/// 错误
func cameraController(_ cameraController:CSCameraController, didFailWithError error:Error) {
}
/// 视频录制完成
func cameraController(_ cameraController:CSCameraController, didFinishRecordingTo outputFileURL:URL) {
if videoDuration < minDuration {
CSToastManager.makeToast("please record at least 5 seconds")
return
}
self.videoUrl = outputFileURL
}
添加底部按钮
这是一个自定义的View而里面的内容为自定义Button在这里就不粘贴具体的代码了,源码会放到文章的最后。
func addBottomView() {
self.view.addSubview(bottomView)
bottomView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(cs_bottomInset - 100)
make.height.equalTo(100)
}
}
定义定时器方法
这里面的定时器并不是用来记录录制视频时长的,因为我们都知道定时器的记录并不准确,我们通过这个定时器从捕捉会话输出中读取视频的录制时长:
/// 创建定时器
private func addTimer() {
timer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(timerAction), userInfo: nil, repeats: true)
// 暂停
timer?.fireDate = Date.distantFuture
}
/// 开启定时器
private func startTimer() {
timer?.fireDate = Date()
}
/// 暂停定时器
private func stopTimer() {
timer?.fireDate = Date.distantFuture
}
/// 移除定时器
private func removeTimer() {
timer?.invalidate()
timer = nil
}
@objc func timerAction() {
// 读取录制时长
let duration = cameraController.recordingDuration
bottomView.setRecordTime(time: duration)
let seconds = CMTimeGetSeconds(duration)
self.videoDuration = seconds
if seconds >= maxDuration {
startOrStopRecording()
}
}
既然使用了定时器,而我们又没有任何特殊的处理来消除它的循环引用,那么在页面消失时一定需要显式的销毁定时器,打破循环:
override func viewDidDisappear(_ animated: Bool) {
CSToastManager.hiddenLoading()
removeTimer()
}
添加方法实现录制
实现底部工具栏的按钮回调,来调用我们定义好的录制及停止录制的方法:
func setEvent() {
// 录制点击回调
bottomView.recordAction = { [weak self] in
guard let self = self else { return }
self.startOrStopRecording()
}
// 重拍
bottomView.reRecordAction = { [weak self] in
guard let self = self else { return }
self.startReRecord()
}
// 完成
bottomView.finishAction = { [weak self] in
guard let self = self else { return }
self.finishAction()
}
}
开始或停止录制
/// 开始 或 暂停录制
private func startOrStopRecording() {
var isRecording = cameraController.isRecording
if cameraController.isRecording {
cameraController.stopRecording()
isRecording = false
stopTimer()
cameraController.stopSession()
bottomView.setRecordedState()
} else {
cameraController.startRecording()
isRecording = true
startTimer()
}
bottomView.setRecordButton(isRecording: isRecording)
}
重新录制
/// 重新录制
private func startReRecord() {
cameraController.startSession()
cameraController.startRecording()
bottomView.setRecordButton(isRecording: true)
bottomView.setRecordingState()
startTimer()
}
录制完成
/// 完成
private func finishAction() {
if videoDuration < minDuration {
CSToastManager.makeToast("please record at least 5 seconds")
return
}
startUploadVideo(url: videoUrl)
}
获取到了录制完成的视频地址,我们就可以执行上传及认证的操作了,接下来我们需要让视图控制器返回到指定页面即可
/// 返回到CSMakeFriendsModeViewController
private func backToMakeFriendsMode() {
for vc in self.navigationController?.viewControllers ?? [] {
if vc is CSMakeFriendsModeViewController {
self.navigationController?.popToViewController(vc, animated: true)
CSToastManager.makeToast(" upload success, please wait for the approval result")
return
}
}
}
结语
通过本文的介绍,我们了解了如何使用 AVFoundation 创建一个自定义的视频录制页面,并实现视频的录制、重拍和上传功能。相比于系统提供的 UIImagePickerController 和 PHPickerViewController,自定义实现能够提供更多的灵活性和定制化选项,以满足特定项目的需求。
在开发过程中,我们配置了 AVCaptureSession、AVCaptureDevice、AVCaptureDeviceInput 和 AVCaptureMovieFileOutput,并使用自定义的 UI 元素打造了一个用户友好的录制界面。通过这种方式,我们不仅掌握了 AVFoundation 的基础使用方法,还积累了宝贵的实践经验。
需要注意的是,我们在启动录制之前,一定要先申请相机以及麦克风的权限。
希望这篇博客能为你在 iOS 开发中的视频录制功能实现提供有用的参考。如果你有任何问题或建议,欢迎在评论区留言讨论。