当前位置: 首页 > news >正文

iOS视频封装步骤解析

视频的采集和编码全部采用的是之前的内容,不再过多进行赘述

//
//  KFMP4Muxer.swift
//  VideoDemo
//
//  Created by ricard.li on 2025/5/15.
//import AVFoundation/// 封装器错误码
enum KFMP4MuxerError: Int {case addOutputError = 1000
}/// 最大队列数量
let KFMP4MuxerMaxQueueCount: Int32 = 10000/// 封装器状态
enum KFMP4MuxerStatus: Int {case unknown = 0    // 未知状态case running = 1    // 运行中case failed = 2     // 失败case completed = 3  // 完成case cancelled = 4  // 取消
}/// MP4封装器
class KFMP4Muxer {// MARK: - 属性/// 配置private(set) var config: KFMuxerConfig/// 封装器实例private var muxWriter: AVAssetWriter?/// 视频输入private var writerVideoInput: AVAssetWriterInput?/// 音频输入private var writerAudioInput: AVAssetWriterInput?/// 音频队列private var audioQueue: CMSimpleQueue?/// 视频队列private var videoQueue: CMSimpleQueue?/// 封装队列private let muxerQueue: DispatchQueue/// 信号量private let semaphore: DispatchSemaphore/// 封装状态private var muxerStatus: KFMP4MuxerStatus = .unknown/// 错误回调var errorCallBack: ((Error) -> Void)?// MARK: - 生命周期/// 初始化封装器/// - Parameter config: 封装配置init(config: KFMuxerConfig) {self.config = configself.muxerQueue = DispatchQueue(label: "com.KeyFrameKit.muxerQueue", qos: .default)self.semaphore = DispatchSemaphore(value: 1)// 创建音频队列var audioQueueRef: CMSimpleQueue?CMSimpleQueueCreate(allocator: kCFAllocatorDefault, capacity: KFMP4MuxerMaxQueueCount, queueOut: &audioQueueRef)self.audioQueue = audioQueueRef// 创建视频队列var videoQueueRef: CMSimpleQueue?CMSimpleQueueCreate(allocator: kCFAllocatorDefault, capacity: KFMP4MuxerMaxQueueCount, queueOut: &videoQueueRef)self.videoQueue = videoQueueRef}deinit {semaphore.wait()reset() // 清理semaphore.signal()}// MARK: - 公共方法/// 开始写入func startWriting() {weak var weakSelf = selfmuxerQueue.async {guard let self = weakSelf else { return }self.semaphore.wait()self.reset() // 清理self.muxerStatus = .running // 标记状态self.semaphore.signal()}}/// 取消写入func cancelWriting() {weak var weakSelf = selfmuxerQueue.async {guard let self = weakSelf else { return }self.semaphore.wait()if let muxWriter = self.muxWriter, muxWriter.status == .writing {muxWriter.cancelWriting()}self.muxerStatus = .cancelled // 标记状态self.semaphore.signal()}}/// 添加采样缓冲区/// - Parameter sampleBuffer: 采样缓冲区func appendSampleBuffer(_ sampleBuffer: CMSampleBuffer) {// 数据校验 - 如果sample buffer没有数据缓冲区或者状态不是running,则不处理if !CMSampleBufferDataIsReady(sampleBuffer) || muxerStatus != .running {return}// 异步添加数据weak var weakSelf = selfmuxerQueue.async {guard let self = weakSelf else { return }self.semaphore.wait()// 1、添加数据到队列self.enqueueSampleBuffer(sampleBuffer)// 2、第一次添加数据时,创建Muxer实例并触发写数据操作if self.muxWriter == nil {// 检查数据是否正常if !self.checkFormatDescriptionLoadSuccess() {self.semaphore.signal()return}// 创建Muxer实例do {try self.setupMuxWriter()} catch {self.muxerStatus = .failedself.semaphore.signal()self.callBackError(error)return}// 开始写入guard let muxWriter = self.muxWriter, muxWriter.startWriting() else {self.muxerStatus = .failedself.semaphore.signal()if let error = self.muxWriter?.error {self.callBackError(error)}return}// 启动会话,设置源时间muxWriter.startSession(atSourceTime: self.sessionSourceTime())}// 3、检查Muxer状态if self.muxWriter == nil || self.muxWriter?.status != .writing {self.muxerStatus = .failedself.semaphore.signal()if let error = self.muxWriter?.error {self.callBackError(error)}return}// 4、音视频数据交织self.avInterLeavedSample()self.semaphore.signal()}}/// 停止写入/// - Parameter completeHandler: 完成回调func stopWriting(completeHandler: @escaping (Bool, Error?) -> Void) {weak var weakSelf = selfmuxerQueue.async {guard let self = weakSelf else { return }self.semaphore.wait()self.stopWritingInternal { success, error inself.muxerStatus = success ? .completed : .failedself.semaphore.signal()completeHandler(success, error)}}}// MARK: - 私有方法/// 设置封装器private func setupMuxWriter() throws {// 直接使用outputURL,没有必要进行可选绑定,因为它不是可选类型let outputURL = config.outputURL// 1、清理写入路径的文件if FileManager.default.fileExists(atPath: outputURL.path) {try? FileManager.default.removeItem(atPath: outputURL.path)}// 2、创建封装器实例if muxWriter != nil {return}// 使用AVAssetWriter作为封装器,类型使用AVFileTypeMPEG4muxWriter = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)muxWriter?.movieTimeScale = CMTimeScale(1_000_000_000)muxWriter?.shouldOptimizeForNetworkUse = true // 这个选项会将MP4的moov box前置// 3、当封装内容包含视频时,创建视频输入if config.muxerType.contains(.video) && writerVideoInput == nil, let videoQueue = videoQueue,let videoHead = CMSimpleQueueGetHead(videoQueue) {// 直接转换为可选类型并检查let sampleBuffer: CMSampleBuffer = unsafeBitCast(videoHead, to: CMSampleBuffer.self)if let videoDescription = CMSampleBufferGetFormatDescription(sampleBuffer) {writerVideoInput = AVAssetWriterInput(mediaType: .video, outputSettings: nil, sourceFormatHint: videoDescription)writerVideoInput?.expectsMediaDataInRealTime = true // 输入是否为实时数据源writerVideoInput?.transform = config.preferredTransform // 视频变换if let videoInput = writerVideoInput, let writer = muxWriter, writer.canAdd(videoInput) {writer.add(videoInput)} else {throw NSError(domain: String(describing: KFMP4Muxer.self), code: KFMP4MuxerError.addOutputError.rawValue, userInfo: nil)}}}// 4、当封装内容包含音频时,创建音频输入if config.muxerType.contains(.audio) && writerAudioInput == nil,let audioQueue = audioQueue,let audioHead = CMSimpleQueueGetHead(audioQueue) {// 直接转换为可选类型并检查let sampleBuffer: CMSampleBuffer = unsafeBitCast(audioHead, to: CMSampleBuffer.self)if let audioDescription = CMSampleBufferGetFormatDescription(sampleBuffer) {writerAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: audioDescription)writerAudioInput?.expectsMediaDataInRealTime = true // 输入是否为实时数据源if let audioInput = writerAudioInput, let writer = muxWriter, writer.canAdd(audioInput) {writer.add(audioInput)} else {throw NSError(domain: String(describing: KFMP4Muxer.self), code: KFMP4MuxerError.addOutputError.rawValue, userInfo: nil)}}}}/// 将采样缓冲区加入队列private func enqueueSampleBuffer(_ sampleBuffer: CMSampleBuffer) {// 根据媒体类型将数据放入不同队列guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else {return}// Swift中我们需要手动管理Core Foundation对象在队列中的引用计数let mediaType = CMFormatDescriptionGetMediaType(formatDescription)// 我们需要增加引用计数,因为CMSimpleQueue不会自动增加引用计数// 在Swift中,使用Unmanaged<T>来处理Core Foundation对象的引用计数let sampleBufferRef = Unmanaged.passRetained(sampleBuffer)let pointer = sampleBufferRef.toOpaque()if mediaType == kCMMediaType_Audio, let audioQueue = audioQueue {CMSimpleQueueEnqueue(audioQueue, element: pointer)} else if mediaType == kCMMediaType_Video, let videoQueue = videoQueue {CMSimpleQueueEnqueue(videoQueue, element: pointer)} else {// 不需要保留在队列中,释放引用sampleBufferRef.release()}}/// 清空封装器数据private func flushMuxer() {appendAudioSample()appendVideoSample()}/// 添加音频采样private func appendAudioSample() {guard let audioQueue = audioQueue else { return }// 音频写入封装while let audioInput = writerAudioInput, audioInput.isReadyForMoreMediaData && CMSimpleQueueGetCount(audioQueue) > 0 {guard let dequeuedItem = CMSimpleQueueDequeue(audioQueue) else { break }// 从队列中取出后需要处理引用计数:获取引用并在使用后释放let audioSampleRef = Unmanaged<CMSampleBuffer>.fromOpaque(dequeuedItem)let audioSample = audioSampleRef.takeRetainedValue() // 这会减少引用计数audioInput.append(audioSample)}}/// 添加视频采样private func appendVideoSample() {guard let videoQueue = videoQueue else { return }// 视频写入封装while let videoInput = writerVideoInput, videoInput.isReadyForMoreMediaData && CMSimpleQueueGetCount(videoQueue) > 0 {guard let dequeuedItem = CMSimpleQueueDequeue(videoQueue) else { break }// 从队列中取出后需要处理引用计数:获取引用并在使用后释放let videoSampleRef = Unmanaged<CMSampleBuffer>.fromOpaque(dequeuedItem)let videoSample = videoSampleRef.takeRetainedValue() // 这会减少引用计数videoInput.append(videoSample)}}/// 音视频交织采样private func avInterLeavedSample() {let hasAudio = config.muxerType.contains(.audio)let hasVideo = config.muxerType.contains(.video)guard let audioQueue = audioQueue, let videoQueue = videoQueue else { return }// 当同时封装音频和视频时,需要做好交织if hasAudio && hasVideo {while CMSimpleQueueGetCount(audioQueue) > 0 && CMSimpleQueueGetCount(videoQueue) > 0 {if let audioInput = writerAudioInput, let videoInput = writerVideoInput,audioInput.isReadyForMoreMediaData && videoInput.isReadyForMoreMediaData {// 取队列头部数据比较时间戳guard let audioHeaderItem = CMSimpleQueueGetHead(audioQueue),let videoHeaderItem = CMSimpleQueueGetHead(videoQueue) else { break }let audioHeader = unsafeBitCast(audioHeaderItem, to: CMSampleBuffer.self)let videoHeader = unsafeBitCast(videoHeaderItem, to: CMSampleBuffer.self)let audioDtsTime = CMSampleBufferGetPresentationTimeStamp(audioHeader)// 获取视频的解码时间戳,如果无效则使用显示时间戳var videoDtsTime = CMSampleBufferGetDecodeTimeStamp(videoHeader)if CMTIME_IS_INVALID(videoDtsTime) {videoDtsTime = CMSampleBufferGetPresentationTimeStamp(videoHeader)}// 比较时间戳,将较小的先写入if CMTimeGetSeconds(audioDtsTime) >= CMTimeGetSeconds(videoDtsTime) {guard let dequeuedItem = CMSimpleQueueDequeue(videoQueue) else { break }// 从队列中取出后需要处理引用计数let videoSampleRef = Unmanaged<CMSampleBuffer>.fromOpaque(dequeuedItem)let videoSample = videoSampleRef.takeRetainedValue() // 这会减少引用计数videoInput.append(videoSample)} else {guard let dequeuedItem = CMSimpleQueueDequeue(audioQueue) else { break }// 从队列中取出后需要处理引用计数let audioSampleRef = Unmanaged<CMSampleBuffer>.fromOpaque(dequeuedItem)let audioSample = audioSampleRef.takeRetainedValue() // 这会减少引用计数audioInput.append(audioSample)}} else {break}}} else if hasAudio {// 只封装音频appendAudioSample()} else if hasVideo {// 只封装视频appendVideoSample()}}/// 检查格式描述是否加载成功private func checkFormatDescriptionLoadSuccess() -> Bool {guard let audioQueue = audioQueue, let videoQueue = videoQueue else { return false }// 检查数据是否正常if muxWriter == nil {let hasAudio = config.muxerType.contains(.audio)let hasVideo = config.muxerType.contains(.video)if hasAudio && hasVideo {return CMSimpleQueueGetCount(videoQueue) > 0 && CMSimpleQueueGetCount(audioQueue) > 0} else if hasAudio {return CMSimpleQueueGetCount(audioQueue) > 0} else if hasVideo {return CMSimpleQueueGetCount(videoQueue) > 0}}return false}/// 获取会话源时间private func sessionSourceTime() -> CMTime {guard let audioQueue = audioQueue, let videoQueue = videoQueue else { return CMTime.invalid }// 数据起始时间:音视频pts的最小值if CMSimpleQueueGetCount(audioQueue) > 0, CMSimpleQueueGetCount(videoQueue) > 0,let audioHeaderItem = CMSimpleQueueGetHead(audioQueue),let videoHeaderItem = CMSimpleQueueGetHead(videoQueue) {let audioFirstBuffer = unsafeBitCast(audioHeaderItem, to: CMSampleBuffer.self)let videoFirstBuffer = unsafeBitCast(videoHeaderItem, to: CMSampleBuffer.self)let audioPtsTime = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(audioFirstBuffer))let videoPtsTime = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(videoFirstBuffer))return audioPtsTime >= videoPtsTime ? CMSampleBufferGetPresentationTimeStamp(videoFirstBuffer) :CMSampleBufferGetPresentationTimeStamp(audioFirstBuffer)} else if CMSimpleQueueGetCount(audioQueue) > 0, let audioHeaderItem = CMSimpleQueueGetHead(audioQueue) {let audioFirstBuffer = unsafeBitCast(audioHeaderItem, to: CMSampleBuffer.self)return CMSampleBufferGetPresentationTimeStamp(audioFirstBuffer)} else if CMSimpleQueueGetCount(videoQueue) > 0, let videoHeaderItem = CMSimpleQueueGetHead(videoQueue) {let videoFirstBuffer = unsafeBitCast(videoHeaderItem, to: CMSampleBuffer.self)return CMSampleBufferGetPresentationTimeStamp(videoFirstBuffer)}return CMTime.invalid}/// 停止写入private func stopWritingInternal(completion: @escaping (Bool, Error?) -> Void) {// 1、状态不对,回调错误guard let muxWriter = muxWriter, muxWriter.status == .writing else {let error = muxWriter?.error ?? NSError(domain: String(describing: type(of: self)),code: muxWriter?.status.rawValue ?? AVAssetWriter.Status.unknown.rawValue,userInfo: nil)completion(false, error)return}// 2、消费队列中剩余数据avInterLeavedSample() // 先做交织flushMuxer() // 消费剩余数据// 3、标记输入源为结束状态markVideoAsFinished()markAudioAsFinished()// 4、结束写入weak var weakSelf = selfmuxWriter.finishWriting {guard let self = weakSelf else { return }let complete = self.muxWriter?.status == .completedcompletion(complete, complete ? nil : self.muxWriter?.error)}}/// 标记视频输入结束private func markVideoAsFinished() {if let muxWriter = muxWriter, muxWriter.status == .writing, let videoInput = writerVideoInput {videoInput.markAsFinished()}}/// 标记音频输入结束private func markAudioAsFinished() {if let muxWriter = muxWriter, muxWriter.status == .writing, let audioInput = writerAudioInput {audioInput.markAsFinished()}}/// 重置private func reset() {// 取消写入操作if let muxWriter = muxWriter, muxWriter.status == .writing {muxWriter.cancelWriting()}// 清理实例self.muxWriter = nilself.writerVideoInput = nilself.writerAudioInput = nil// 清理音频队列if let audioQueue = audioQueue {while CMSimpleQueueGetCount(audioQueue) > 0 {if let item = CMSimpleQueueDequeue(audioQueue) {// 释放队列中的对象Unmanaged<CMSampleBuffer>.fromOpaque(item).release()}}}// 清理视频队列if let videoQueue = videoQueue {while CMSimpleQueueGetCount(videoQueue) > 0 {if let item = CMSimpleQueueDequeue(videoQueue) {// 释放队列中的对象Unmanaged<CMSampleBuffer>.fromOpaque(item).release()}}}}/// 回调错误private func callBackError(_ error: Error) {if let errorCallBack = errorCallBack {DispatchQueue.main.async {errorCallBack(error)}}}
} 

从代码上可以看到主要有这几个部分:

  • 1)创建封装器实例及对应的音频和视频数据输入源。第一次调用 -appendSampleBuffer: 添加待封装数据时才会创建封装器实例。

  • -_setupMuxWriter: 方法中实现。音频和视频的输入源分别是 writerAudioInputwriterVideoInput

  • 2)用两个队列作为缓冲区,分别管理音频和视频待封装数据。

  • 这两个队列分别是 _audioQueue_videoQueue

  • 每次当外部调用 -appendSampleBuffer: 方法送入待封装数据时,其实都是先调用 -_enqueueSampleBuffer: 把数据放入两个队列中的一个,以便根据情况进行后续的音视频数据交织。

  • 3)同时封装音频和视频数据时,进行音视频数据交织。

  • -_avInterLeavedSample 方法中实现音视频数据交织。当带封装的数据既有音频又有视频,就需要根据他们的时间戳信息进行交织,这样便于在播放该音视频时提升体验。

  • 4)音视频数据写入封装。

  • 同时封装音频和视频数据时,在做完音视频交织后,即分别将交织后的音视频数据写入对应的 writerAudioInputwriterVideoInput。在 -_avInterLeavedSample 中实现。

  • 单独封装音频或视频数据时,则直接将数据写入对应的 writerAudioInputwriterVideoInput。分别在 -_appendAudioSample-_appendVideoSample 方法中实现。

  • 5)停止写入。

  • -stopWriting:-_stopWriting: 方法中实现。

  • 在停止前,还需要消费掉 _audioQueue_videoQueue 的剩余数据,要调用 -_avInterLeavedSample-_flushMuxer

  • 并将视频输入源和音频输入源标记位结束,分别在 -_markVideoAsFinished-_markAudioAsFinished 方法中实现。

  • 6)贯穿整个封装过程的状态机管理。

  • 在枚举 KFMP4MuxerStatus 中定义了封装器的各种状态,对于封装器的状态机管理贯穿在封装的整个过程中。

  • 7)错误回调。

  • -callBackError: 方法向外回调错误。

  • 8)清理封装器实例及数据缓冲区。

  • -reset 方法中实现。需要调用 -_reset 方法清理封装器实例、音频和视频输入源、音频和视频缓冲区。

接下来来说一下具体的实现步骤,在对应配置的controller中,会初始化封装器,然后点击开始按钮就会调用start 方法
在这里插入图片描述
后续都是KFMP4Muxer 中的具体逻辑了,我们接着看KFMP4Muxer 的后续实现步骤

controller中会调用muxer中的startwriting方法,然后在采集过程中,每一帧的采集成功都会有一个回调,然后我们回调回处理初始的采集数据,把它进行编码,然后会调用muxer中的appendSampleBuffer 对编码后的内容进行封装。

关于appendSampleBuffer 方法:

  • 校验样本数据是否准备好,状态是否允许写入。

  • 异步串行队列 muxerQueue 执行,使用信号量保证线程安全。

  • 调用setupMuxWriter 方法设置封装器

  • 调用 enqueueSampleBuffer(_:) 根据样本类型(音频/视频)分别入对应的 CMSimpleQueue 队列,注意用 Unmanaged.passRetained 增加引用计数。

  • 调用 avInterLeavedSample() 方法处理音视频数据交织

关于avInterLeavedSample() 方法:

  • 在音视频队列都不为空时,取队列头样本进行时间戳比较。

  • 根据时间戳,选择较小时间戳的样本出队(CMSimpleQueueDequeue)。

  • 使用 Unmanaged.fromOpaque(dequeuedItem).takeRetainedValue() 恰当管理引用计数,获取 CMSampleBuffer

  • 将样本写入对应轨道:writerVideoInput.append(videoSample)writerAudioInput.append(audioSample)

  • 循环交错写入,保证音视频同步。

大体的执行顺序就是这样,其他具体细节可以看代码注释。

以下是controller相关代码

//
//  KFVideoMuxerViewController.swift
//  VideoDemo
//
//  Created by ricard.li on 2025/5/15.
//import UIKit
import AVFoundationclass KFVideoMuxerViewController: UIViewController {private lazy var videoCaptureConfig: VideoCaptureConfig = {return VideoCaptureConfig()}()private lazy var videoCapture: VideoCapture = {let capture = VideoCapture(config: videoCaptureConfig)capture.sessionInitSuccessCallBack = { [weak self] inguard let self = self else { return }DispatchQueue.main.async {if let previewLayer = self.videoCapture.previewLayer {previewLayer.backgroundColor = UIColor.black.cgColorpreviewLayer.frame = self.view.boundsself.view.layer.insertSublayer(previewLayer, at: 0)}}}capture.sampleBufferOutputCallBack = { [weak self] sampleBuffer inguard let self = self, self.isWriting else { return }if let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) {self.videoEncoder.encode(pixelBuffer: imageBuffer, ptsTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer))}}// 错误回调capture.sessionErrorCallBack = { error inif let nsError = error as NSError? {print("KFVideoCapture Error: \(nsError.code) \(error.localizedDescription)")} else {print("KFVideoCapture Error: \(error.localizedDescription)")}}return capture}()private lazy var videoEncoderConfig: KFVideoEncoderConfig = {return KFVideoEncoderConfig()}()private lazy var videoEncoder: KFVideoEncoder = {let encoder = KFVideoEncoder(config: videoEncoderConfig)encoder.sampleBufferOutputCallBack = { [weak self] sampleBuffer inguard let self = self, self.isWriting else { return }self.muxer.appendSampleBuffer(sampleBuffer)}return encoder}()private lazy var muxerConfig: KFMuxerConfig = {let videoPath = (NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last! as NSString).appendingPathComponent("test.mp4")print("MP4 file path: \(videoPath)")try? FileManager.default.removeItem(atPath: videoPath)let config = KFMuxerConfig(outputURL: URL(fileURLWithPath: videoPath))config.muxerType = .videoreturn config}()private lazy var muxer: KFMP4Muxer = {return KFMP4Muxer(config: muxerConfig)}()private var isWriting = false// MARK: - Lifecycleoverride func viewDidLoad() {super.viewDidLoad()requestAccessForVideo()setupUI()}override func viewWillLayoutSubviews() {super.viewWillLayoutSubviews()videoCapture.previewLayer?.frame = view.bounds}// MARK: - Actions@objc private func start() {if !isWriting {muxer.startWriting()isWriting = true}}@objc private func stop() {if isWriting {videoEncoder.flush { [weak self] inguard let self = self else { return }self.isWriting = falseself.muxer.stopWriting { success, error inprint("muxer stop \(success ? "success" : "failed")")}}}}@objc private func changeCamera() {let newPosition: AVCaptureDevice.Position = videoCapture.config.position == .back ? .front : .backvideoCapture.changeDevicePosition(to: newPosition)}@objc private func singleTap(_ sender: UIGestureRecognizer) {// 可自定义处理}@objc private func handleDoubleTap(_ sender: UIGestureRecognizer) {let newPosition: AVCaptureDevice.Position = videoCapture.config.position == .back ? .front : .backvideoCapture.changeDevicePosition(to: newPosition)}// MARK: - Private Methodsprivate func requestAccessForVideo() {let status = AVCaptureDevice.authorizationStatus(for: .video)switch status {case .notDetermined:AVCaptureDevice.requestAccess(for: .video) { [weak self] granted inif granted {self?.videoCapture.startRunning()} else {// 用户拒绝}}case .authorized:videoCapture.startRunning()default:break}}private func setupUI() {edgesForExtendedLayout = .allextendedLayoutIncludesOpaqueBars = truetitle = "Video Muxer"view.backgroundColor = .whitelet singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(singleTap(_:)))singleTapGesture.numberOfTapsRequired = 1singleTapGesture.numberOfTouchesRequired = 1view.addGestureRecognizer(singleTapGesture)let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))doubleTapGesture.numberOfTapsRequired = 2doubleTapGesture.numberOfTouchesRequired = 1view.addGestureRecognizer(doubleTapGesture)singleTapGesture.require(toFail: doubleTapGesture)let startBarButton = UIBarButtonItem(title: "Start", style: .plain, target: self, action: #selector(start))let stopBarButton = UIBarButtonItem(title: "Stop", style: .plain, target: self, action: #selector(stop))let cameraBarButton = UIBarButtonItem(title: "Camera", style: .plain, target: self, action: #selector(changeCamera))navigationItem.rightBarButtonItems = [stopBarButton, startBarButton, cameraBarButton]}
}

相关文章:

  • 【skywalking】index“:“skywalking_metrics-all“},“status“:404}
  • 题目:两个线程交替输出1-100的数字,例如:t1--》1,t2--》2,....
  • 本地文件操作 MCP (多通道处理) 使用案例
  • DeepSearch:字节新一代 DeerFlow 框架
  • Swagger使用
  • 全栈项目中是否可以实现统一错误处理链?如果可以,这条链路该如何设计?需要哪些技术支撑?是否能同时满足性能、安全性和用户体验需求?
  • 机器学习 --- 模型选择与调优
  • 山东大学计算机图形学期末复习8——CG11下
  • ElfBoard技术实战|ELF 2开发板本地部署DeepSeek大模型的完整指南
  • C#发送文件到蓝牙设备
  • 【实战篇】低代码报表开发——平台运营日报表的开发实录
  • Spring 框架 JDBC 模板技术详解
  • SQL实战:06交叉日期打折问题求解
  • 解密企业级大模型智能体Agentic AI 关键技术:MCP、A2A、Reasoning LLMs- MCP内幕解析
  • 观QFramework框架底层逻辑有感
  • 经典卷积神经网络
  • Secs/Gem第四讲(基于secs4net项目的ChatGpt介绍)
  • 开源免费iOS或macOS安装虚拟机运行window/Linux系统
  • Qt中控件的Viewport作用
  • 服务器连接多客户端
  • 埃尔多安:愿在土耳其促成俄乌领导人会晤
  • 董军在第六届联合国维和部长级会议上作大会发言
  • 车载抬头显示爆发在即?业内:凭借市场和产业链优势,国内供应商实现反超
  • 济南市委副秘书长吕英伟已任历下区领导
  • 从采购到销售!市场监管总局指导行业协会防控肉品风险
  • 减重人生|走过节食弯路,她如何半年减60斤找回自信?