☰
iOS虚拟摄像头插件:基于屏幕广播与CMSampleBuffer的落地实现
2026/10/14 4:54:03 网站建设 项目流程

简介:这套iOS虚拟摄像头插件源码包,面向具备系统级开发经验的iOS开发者,演示了如何通过MobileSubstrate注入系统相机进程,并Hook AVFoundation框架中AVCaptureSession相关方法,从而替换原始视频流数据。核心代码基于AVCaptureVideoDataOutputSampleBufferDelegate协议实现视频帧的逐帧替换,覆盖从视频采集到帧数据回调的完整链路。同时,资源还包含一套基于SwiftUI设计的摄像头应用界面,支持摄像头切换、滤镜选择和视频源配置,并利用PHPickerViewController从相册加载本地视频,配合UISlider实现播放时长控制与裁剪导出功能。整个包共5个文件,以2个HTML页面、1个JavaScript脚本和1个InsCode工程配置为主,附带.gitignore,压缩包仅15KB,结构精简、便于快速拆解与二次开发。目前已有274人学习下载,适合希望深入理解iOS虚拟摄像头实现原理、底层Hook技巧以及SwiftUI视频处理流程的开发者参考。

1. iOS虚拟摄像头插件:这条路到底能不能走通

做直播辅助工具或 iOS 自动化测试的开发者,大概率都动过这个念头:把一张图、一段视频、一个游戏画面,伪装成摄像头输入喂给直播 App 或视频 SDK。Android 上有成熟的虚拟摄像头方案,可 iOS 这边一搜,资料零零散散,一说改系统驱动,一说注入 CoreMediaIO,看着都像玄学。先说结论:iOS 上不存在真正意义上的「虚拟摄像头设备」,但存在一条可靠且可落地的链路——拿屏幕广播的帧做素材,构造出符合 AVCaptureSession 规范的 CMSampleBuffer,再注入到自定义采集通道里。这套「iOS虚拟摄像头插件」方案,适合做直播伴侣、远程调试工具、自动化脚本的人;它能解决「应用拿不到摄像头权限,却需要自定义画面源」的实际问题。下面按我的落地路径把它拆透。

2. 先拆清楚 iOS 的摄像头调用链:为什么「虚拟摄像头」在 iOS 上是伪需求

很多人在 iOS 上找虚拟摄像头,默认套用 macOS 的思路。macOS 上可以通过 CoreMediaIO 的 DAL 插件向系统注册一个虚拟摄像头设备,其他应用打开摄像头时能看到你注册的虚拟设备。但这个机制在 iOS 上是不开放的。iOS 不允许第三方 App 注册系统设备,连用私有 API 硬闯,也过不了沙箱和权限校验这一关。所以「iOS虚拟摄像头插件」这个标题真正动手的方向,不是造一个假的设备节点,而是造一条假的采集链路。

2.1 iOS 摄像头采集链路的四个环节:从硬件到业务帧

要理解注入点在哪,先得看清 iOS 摄像头画面从产生到被业务层消费要过哪几关。设备端,AVCaptureDevice 提供物理摄像头,系统通过 AVCaptureDeviceInput 将它接入 AVCaptureSession。在会话里,AVCaptureVideoDataOutput 把实时画面以 CMSampleBuffer 的形式抛给业务层,业务层再把帧转成 BGRA 或 YCbCr 像素格式送去编码或预览。

这四个环节里,开发者的自定义代码能干活的只有最后一个环节:拿到 CMSampleBuffer 之后。苹果没有开放 AVCaptureDeviceInput 的自定义实现,也没有开放 AVCaptureSession 的虚拟输入源,但业务层拿到的 CMSampleBuffer 是从哪来的,系统并不真正关心。只要你的帧格式、时间戳、像素格式符合约定,下游的编码器、预览层、直播 SDK 都会把它当成普通摄像头帧处理。

这就是整条方案的基石:对下游业务而言,帧本身比帧的来源更重要。

2.2 真正能插手的两个注入点:屏幕广播扩展与自定义采集输出

既然系统输入源动不了,只能从自己能控制的环节里找突破口。第一个突破口是 Broadcast Upload Extension。它本身是系统给屏幕录制开的合法扩展点,启动后系统会把每一帧屏幕画面(含音频)以 RPBroadcastSampleHandler 回调的形式交给你。你拿到的是 CMSampleBuffer,可以做裁剪、缩放、水印叠加,再通过 Socket 或内存映射转给宿主 App。

第二个突破口是宿主 App 里自定义的采集输出。宿主 App 负责接收扩展送来的帧,构造新的 CMSampleBuffer,然后调用自定义采集接口。很多直播 SDK 和视频通话 SDK 都暴露了 setCustomVideoSource 或类似的自定义视频源接口,你的帧从这里注入,SDK 会走和摄像头完全一样的前处理、编码、推流管线。

这里需要分清两者的边界。扩展负责采集和预处理,宿主负责注入和业务消费。中间的视频传输通道是另一门学问,丢帧、延时、时钟漂移都发生在这段路上,后面专门讲。

提示:不要尝试用 Runtime 黑科技篡改 AVCaptureDeviceInput 的返回对象。iOS 15 之后对摄像头权限和硬件调用的校验越来越严,黑匣子方案在真机上大概率直接崩。

3. 最小可跑通方案:把一张图变成摄像头画面的三条命令

理论立住之后,我们直接动手搭最小链路。目标明确:让一个直播 SDK 的自定义视频源接口,收到你送进去的一张图片或一段视频帧。整条链路分三段:扩展端采集帧、传输帧、宿主端注入帧。这里给出一个能跑通的最小骨架,我用的是 Broadcast Upload Extension 加 Socket 传输加 SDK 自定义接口,工程结构如下:

iOSVirtualCamera/ ├── BroadcastExtension // Broadcast Upload Extension │ ├── SampleHandler.swift │ └── Info.plist ├── HostApp // 宿主 App │ ├── CameraSource.swift │ ├── VirtualCameraManager.swift │ └── ViewController.swift └── Shared/ ├── FramePacket.swift └── PacketStream.swift

3.1 先搭骨架:Broadcast Upload Extension 的创建与帧接收

在 Xcode 里新建一个 Target,选 Broadcast Upload Extension,系统会自动生成 SampleHandler.swift。它是整个方案的采集入口。extension 启动后,系统按你设定的帧率回调processSampleBuffer,这里拿到的就是摄像头的替代素材。

// SampleHandler.swift import ReplayKit import CoreMedia class SampleHandler: RPBroadcastSampleHandler { private let stream = PacketStream() // 帮助你编码帧字节流的工具类 override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { switch sampleBufferType { case .video: stream.send(sampleBuffer) case .audioApp, .audioMic: // 视频虚拟摄像头场景通常不需要处理音频 break @unknown default: break } } override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { // 这里可以做分辨率、帧率的日志记录,方便对齐宿主端 } override func broadcastFinished() { stream.close() } }

逻辑说明:processSampleBuffer是系统每帧回调,RPSampleBufferType.video表示视频帧。扩展端不需要做复杂的编解码,直接把 CMSampleBuffer 序列化成字节流发走。音频帧在这里先忽略,因为虚拟摄像头场景下音频通常走原始麦克风,避免两条音频链路打架。

参数说明:系统回调你扩展的帧率上限是 60fps,但建议从 30fps 开始跑,减少传输压力和 CPU 占用。如果你把扩展里的帧直接转发,不做任何压缩,一帧 1080p BGRA 大约是 8MB,带宽根本扛不住,所以下面我加一个简单的 JPEG 压缩步骤,压缩质量选 0.7 到 0.8 之间,画质损失不明显,单帧体积能压到 100KB 以内。

3.2 把屏幕帧挪进宿主 App:内存共享通道与帧序号

扩展进程和宿主进程是两个不同的进程,不能直接用单例传数据。最快的通道是 Socket:扩展端作为本地服务端监听,宿主 App 主动连接。我用的是 TCP,因为要做拥塞控制和帧有序性,UDP 在丢帧时会导致画面花屏。

// PacketStream.swift import Foundation import CoreMedia import VideoToolbox class PacketStream { private var socket: Socket? private let queue = DispatchQueue(label: "packet.stream.queue") func send(_ sampleBuffer: CMSampleBuffer) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // 用 JPEG 压缩大幅降低传输体积 var ciImage = CIImage(cvPixelBuffer: pixelBuffer) // 如果要叠加水印或裁剪,用 CoreImage 在这里处理 let context = CIContext() guard let jpegData = context.jpegRepresentation(of: ciImage, colorSpace: CGColorSpaceCreateDeviceRGB(), options: [:]) else { return } // 封包格式:4 字节帧序号 + 4 字节数据长度 + JPEG 数据 let frameIndex = getNextFrameIndex() var packet = Data() packet.append(withUnsafeBytes(of: frameIndex) { Data($0) }) packet.append(withUnsafeBytes(of: jpegData.count) { Data($0) }) packet.append(jpegData) queue.async { self.socket?.write(packet) } } }

逻辑说明:JPEG 压缩放在扩展端做,能让宿主端解码负担小很多。帧序号很关键,宿主端靠它判断丢帧和乱序。如果你的画面里有复杂的 UI 动画,JPEG 在低质量参数下会出现马赛克,质量参数建议不低于 0.7。

参数说明:jpegRepresentation的压缩质量可调,适配不同场景时我会这样设——静态画面或文字界面,质量 0.7 足够;动态游戏画面或视频播放画面,质量调到 0.85 以上才不糊。帧序号用 UInt32 累加,溢出问题在 32 位无符号下有约 49 天才会遇到,测试环境完全够用。

3.3 宿主端接收与解码:从字节流回到摄像头帧

宿主端把 Socket 收到的数据解包,JPEG 解码成 CVPixelBuffer,再交给注入逻辑。这一段的重点是线程模型,解包、解码、注入必须放在同一个串行队列,否则帧序会乱,画面会跳。

// VirtualCameraManager.swift import Foundation import CoreVideo import UIKit class VirtualCameraManager { private var stream: PacketStream? private let decodeQueue = DispatchQueue(label: "virtual.camera.decode") func startServer() { stream = PacketStream() stream?.startListening(port: 9321) { packet in self.decodeQueue.async { self.decode(packet) } } } private func decode(_ packet: Data) { guard packet.count > 8 else { return } let frameIndex = packet.withUnsafeBytes { $0.load(as: UInt32.self) } let dataLength = packet.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } let jpegData = packet.subdata(in: 8..<(8 + Int(dataLength))) guard let image = UIImage(data: jpegData), let cgImage = image.cgImage else { return } // 构造 CVPixelBuffer,后面注入用 var pixelBuffer: CVPixelBuffer? let status = CVPixelBufferCreate(kCFAllocatorDefault, cgImage.width, cgImage.height, kCVPixelFormatType_32BGRA, nil, &pixelBuffer) guard status == kCVReturnSuccess, let buffer = pixelBuffer else { return } // 把 CGImage 绘制到 PixelBuffer 上(需要 CIContext 或 vImage) CVPixelBufferLockBaseAddress(buffer, []) // ... draw cgImage into buffer CVPixelBufferUnlockBaseAddress(buffer, []) onFrameCaptured?(buffer) } }

逻辑说明:宿主端用一个固定端口监听,广播扩展主动连接它。onFrameCaptured是给注入层用的回调,拿到 CVPixelBuffer 后直接交给 SDK 的自定义视频源。这里我没有贴完整的 CGImage 绘制代码,因为不同工程用的绘制方式差异较大,CoreGraphics 和 vImage 的性能表现不同,之后单开一节讲。

参数说明:端口号要选 1024 以上的高位端口,同时要在工程的 entitlements 里配置com.apple.security.network.server权限。真机上跑,扩展端和宿主端都用 localhost 连接,但扩展的本地 Socket 会被系统限制,如果连不上,优先检查 entitilements。

提示:首次跑通时建议在宿主端打印收到的帧序号和实际渲染帧率。如果序号跳变,说明传输丢帧;如果渲染帧率低于输入帧率,说明解码或注入环节有瓶颈。

4. 把帧伪装成摄像头输出:CMSampleBuffer 构造与注入的边界条件

帧到达宿主端后,还差最后一步:把 CVPixelBuffer 包装成符合摄像头输出的 CMSampleBuffer,再注入 SDK。多数自定义视频源接口要求的是 CMSampleBuffer 或它内部的 CVPixelBuffer,格式不对会在黑屏和崩溃之间反复横跳。

4.1 CMSampleBuffer 构造:从 CVPixelBuffer 到可注入的媒体样本

CMSampleBuffer 不是简单的数据容器,它里面有格式描述(CMVideoFormatDescription)、时间信息(CMSampleTimingInfo)、像素缓冲(CVPixelBuffer)。漏掉任何一部分,SDK 下层可能拿不到帧率或分辨率信息导致黑屏。

// CameraSource.swift import CoreMedia import CoreVideo func makeCMSampleBuffer(from pixelBuffer: CVPixelBuffer, timestamp: CMTime) -> CMSampleBuffer? { var formatDescription: CMVideoFormatDescription? let status = CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription ) guard status == noErr, let formatDesc = formatDescription else { return nil } var timingInfo = CMSampleTimingInfo( duration: CMTime(value: 1, timescale: 30), presentationTimeStamp: timestamp, decodeTimeStamp: .invalid ) var sampleBuffer: CMSampleBuffer? CMSampleBufferCreateReadyWithImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: formatDesc, sampleTiming: &timingInfo, sampleBufferOut: &sampleBuffer ) return sampleBuffer }

逻辑说明:CMVideoFormatDescriptionCreateForImageBuffer会自动从 pixelBuffer 里读出宽高、像素格式、色彩空间,这一步省不了。CMTime的 timescale 取 30 表示每帧 1/30 秒;如果你想让帧率看起来是 60fps,把 timescale 改成 60,duration 改成 1/60。

参数说明:presentationTimeStamp 非常关键,必须用CMClockGetTime或CACurrentMediaTime换算成 CMTime 传入,不能用 0 或固定值。很多细心的视频处理模块会用时间戳做视频拼接和去重,如果你的时间戳跳变过大,会被判定为丢帧或者插帧。

4.2 注入与分发:AVCaptureVideoDataOutput 消费者的两种接入方式

在自定义采集场景中,有两种常见接法。第一种是 SDK 暴露了自定义视频源协议,比如LiveVideoSource、CustomVideoSource,你把上面构造好的 CMSampleBuffer 交给它即可;第二种是 SDK 允许你把帧喂给 AVCaptureVideoDataOutput 的 delegate,这种情况下帧会走 SDK 内部前处理管线。

// ViewController.swift final class ViewController: UIViewController { private var manager = VirtualCameraManager() override func viewDidLoad() { super.viewDidLoad() manager.onFrameCaptured = { [weak self] pixelBuffer in let sampleBuffer = makeCMSampleBuffer(from: pixelBuffer, timestamp: CMClockGetTime(CMClockGetHostTimeClock())) // 注入自定义视频源 VideoSource.shared.push(sampleBuffer) } manager.startServer() } }

逻辑说明:宿主 App 在前台时开始监听,退后台后主动断开,避免扩展端一直连着一个失去消费能力的进程。这里的push是视频采集的语义,它告诉 SDK「我有新帧了」,SDK 内部会做队列管理。

参数说明:两种接法的选择取决于 SDK 的口子。我实际处理过的场景,有些 SDK 要求帧必须带有 rotation 信息,有些要求像素格式必须是 NV12 而不是 BGRA。如果你的帧格式对不上,SDK 会花大量 CPU 做颜色转换,帧率掉一半。所以集成前先确认三件事:像素格式、是否要求帧自带旋转角、时间戳的时间基准。

<表格> CMSampleBuffer 注入关键参数对照

参数推荐值坑
Pixel FormatkCVPixelFormatType_32BGRA部分 SDK 只认 NV12
Timescale30 或 60与真实传输帧率不一致会跳帧
PresentationTimeStampCMClockGetTime 基准固定时间戳会导致画面抖动
Width / Height与传输端一致不一致会裁剪或变形
Rotation按设备方向设置竖屏画面被横屏显示

5. 避坑指南:iOS虚拟摄像头插件上线前要过的五个坎

这条链路踩过的坑我整理成清单,现象、原因、解决一次说全,省得你逐个搜半天。

5.1 黑屏但日志无报错:格式描述缺失

现象:宿主端收到帧,注入成功,日志一切正常,但画面一直黑。

原因:CMSampleBuffer 里的 CMVideoFormatDescription 与 CVPixelBuffer 的分辨率或像素格式不匹配,SDK 解码失败后静默丢弃。

解决:检查 CMSampleBuffer 里的尺寸和像素格式是否与实际一致。我习惯在解析帧时打一条日志,输出CMVideoFormatDescriptionGetDimensions的结果,和自己构造时的输入对比。不一致就回到CMVideoFormatDescriptionCreateForImageBuffer的入参排查。

5.2 画面撕裂和花屏:JPEG 压缩参数与帧率不匹配

现象:动态画面下出现明显的马赛克和撕裂,静态画面正常。

原因:传输通道带宽不够时,扩展端仍然按固定质量压缩,导致单帧数据量过大,Socket 写入阻塞,帧堆积丢帧。

解决:改用自适应质量策略。我一般会先测出当前网络通道的安全帧大小,再反推压缩质量。比如目标帧率 30fps,单个 100KB 的 JPEG 帧在 TCP 下安全传输,质量在 0.8 时超过 120KB 就降一档,保证帧率稳定优先于单帧清晰度。

5.3 App 切后台后画面停止:扩展进程被系统挂起

现象:宿主 App 退到后台再回来,画面不动了,杀掉重开会恢复。

原因:Broadcast Upload Extension 在宿主 App 退后台后不会立刻被杀,但它会进入低优先级模式,系统不再给它分配足够的 CPU 时间,回调延迟飙到几百毫秒。

解决:在宿主 App 的applicationDidBecomeActive里重建 Socket 连接,让扩展感知到宿主回来,同时扩大 Socket 的接收缓冲区到 1MB 以上,扛过唤醒前的那段时间。真机调试时我还会单独观察扩展进程的内存占用,如果扩展到达 300MB,系统会在后台主动终止它,这是另一个坑。

5.4 时间戳跳变导致视频卡顿:CMTime 基准不一致

现象:画面看起来帧率正常,但视频播放进度忽快忽慢,录制出来的文件有明显停顿感。

原因:扩展端用的时间基准和宿主端构造 CMSampleBuffer 时的时间基准不一致。两端的CMClockGetTime在不同进程里都会漂移,但频率有差异。

解决:不要直接用宿主端的CACurrentMediaTime作为演示时间戳,而是收到帧时用扩展端传过来的时钟信息换算。简化做法:扩展端在封包里写入自己的 hostTime 和 mach_absolute_time,宿主端换算成 CMTime。如果你只想做到「看起来流畅」,用宿主端的CMClockGetHostTimeClock也能达到可以接受的效果,但录制长视频会有累积偏差。

5.5 权限弹窗不出现:Extension 的授权链断裂

现象:装了扩展,但录屏权限弹窗不出现,或者弹窗点了开始后立即结束。

原因:Broadcast Upload Extension 的 bundle ID 和宿主 App 的 App Group 配置不一致,系统认为扩展不可用。

解决:在工程的 Signing & Capabilities 里给两个 Target 同时加上 App Group,并在 entitlements 里确认 group id 完全一致。另外,真机上首次运行扩展,需要在设置里手动开启录屏权限,这一步无法通过代码完成,需要演示给测试人员看熟悉下流程。

6. 进一步落地:帧率控制、低延迟调优与旁路校验方法

链路跑通后,离「能用」还有一段路。我最后一次收尾,把帧率控制、低延迟和验证方法讲透——这三件事决定读者照着做出来的方案是玩具还是工具。

先说帧率控制。传输链路里最容易被忽略的是帧率抖动,扩展端以 30fps 产出,Socket 一堵,宿主端收到的是 25fps、30fps、35fps 的抖动序列。解决思路:在宿主端加一个递进时间戳补偿器。收到帧时,比较当前帧和上一帧的展示时间差,如果差值大于两个帧周期,就把当前时间戳补成上一帧加一个标准帧周期,避免下游编码器错误判断为延迟波动。这个逻辑直接写在makeCMSampleBuffer之前,代码量不大但效果显著。

低延迟调优分两段。一段是减少 Socket 缓冲,把接收缓冲从默认值调到 512KB 以上能防抖,但不要超过 4MB,缓冲越大延迟越高。另一段是解码线程不要用 GCD 的 global queue,固定一个专用串行队列,优先级设为.userInteractive,同时避免在解码队列里做任何日志打印和 UI 操作,日志 I/O 在低速设备上会加剧卡顿。我做过一次验证,同一台设备实时帧率从 22fps 涨到 29fps,就是这两步给提上来的。

最后的旁路校验,是检验方案是否可靠的关键一步。我会在宿主端做一个「帧序号连续性统计」:每收到 100 帧统计一次丢帧率,记录到日志里。稳定状态下丢帧率不超过 1% 才算合格。测试时要覆盖三种场景:屏幕静止、屏幕快速滚动、播放高动态视频。静止场景丢帧率低不代表方案可靠,高动态场景一旦掉到 5% 以上,基本就是压缩参数或 Socket 缓冲没调好,回头看第 5.2 节。

这方面我自己的习惯是:每次调参之后,固定录 30 秒的注入画面,用宿主端日志里的帧序号统计来对比调参前后的丢帧率,而不是靠肉眼判断流畅度。肉眼会骗人,数字不会。这套链路从实验到可以用,踩过去的坑比预想多,但方向如果真的能服务到你的直播辅助或自动化测试项目,值得投进去做扎实一点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询