iOS虚拟摄像头插件实现:AVCaptureSession帧注入与Theos实战
2026/9/1 10:35:39 网站建设 项目流程

简介:面向iOS开发者和逆向工程爱好者的虚拟摄像头插件代码包,详细演示了通过MobileSubstrate注入系统相机进程,并Hook AVFoundation框架的AVCaptureSession相关方法,从而实现原始视频流数据的实时替换,可帮助读者理解系统级拦截与视频数据处理的核心链路。整个资源包共5个文件,以HTML说明文档、JavaScript脚本及配置文件为主,其中HTML负责展示说明,JS脚本承载核心逻辑,压缩包仅15KB,便于快速浏览和按需修改。核心代码展示了基于AVCaptureVideoDataOutputSampleBufferDelegate协议完成视频帧替换的完整流程,同时附带基于SwiftUI的摄像头应用,涵盖摄像头切换、滤镜选择、视频源配置,以及通过PHPickerViewController选择本地视频、用UISlider控制时长并裁剪导出的功能,可实际运行验证。已有245人学习下载,适合希望掌握iOS系统级Hook、相机流处理和短视频选择裁剪技术的人员作为轻量参考,可有效支撑相关插件开发工作。 说实话,第一次有朋友跑来问我“iOS能不能上虚拟摄像头插件”的时候,我第一反应是想笑——苹果这套系统从设计上就把摄像头链路焊死了,正常情况下你想在App里看到一个不存在的摄像头,基本等于在混凝土里种花。但这个需求确实存在,而且问的人越来越多:有人想在直播工具里推测试画面,有人做iOS自动化测试需要伪造视频输入源,还有人想在Demo环境里演示扫码逻辑而不想拿真机对着屏幕晃。折腾了一段时间,我把能跑通的方案、核心代码思路和踩过的坑整理成这篇东西,给想在这个方向上动手的人一个参考。

先说结论:iOS虚拟摄像头插件在非越狱环境下没法做到像macOS那样装个驱动就全局生效,但在越狱调试机或者开发者自签环境下,通过插件注入的方式完全可以实现“给指定App喂一路虚拟视频流”。这篇文章不会讲灰产用途,只聊开发调试、自动化测试、Demo演示这些正当场景。整个方案的技术栈是Theos + Logos钩子 + CoreVideo像素缓冲,核心思路是劫持视频采集会话,把真实摄像头输出的SampleBuffer替换成我们程序生成的帧。

1. 项目整体设计与技术路线选型

1.1 为什么iOS没有“原生虚拟摄像头”

在苹果生态里,macOS从Catalina开始支持了虚拟摄像头接口,通过系统扩展可以挂一个虚拟设备给所有App用。但iOS这边完全是另一套逻辑——AVFoundation里的AVCaptureDevice是系统统一管理的,没有公开API允许你注册新设备,也没有类似DAL(Dalvik?不对,是Device Abstraction Layer)插件机制。这意味着在封闭环境里,你连“让系统看到一个不存在的摄像头”这一步都做不到。

那还能怎么做?答案是把问题降维:不要骗系统,去骗App。

iOS上大部分用到摄像头的App,走的都是AVCaptureSession这条路:初始化一个采集会话,加一个输入设备(摄像头),加一个输出(比如AVCaptureVideoDataOutput),然后帧数据通过代理方法回调给上层。如果我们能在这个链路里插入一层“中间人”,让输出端收到的SampleBuffer不是来自真实摄像头,而是来自我们自己构造的帧,那App层面感知到的就是一个虚拟摄像头。

这也是整个插件的核心设计思路:不碰底层驱动,不注册虚拟设备,只在用户态hook采集会话的代理回调,把数据流的源头换掉。

1.2 技术路线三选一:怎么挑最合适的

我实际调研和试过的方案有三条,各自适用场景不一样:

方案原理优点缺点适用场景
AVCaptureSession hookhook摄像头的输出代理,替换SampleBuffer实现简单、帧内容完全可控只能影响单个App、依赖越狱环境快速验证、Demo演示
CMIO虚拟设备(移植思路)把macOS的虚拟摄像头驱动思路移植到iOS系统级生效、多App可用iOS上驱动签名和加载机制几乎不可行基本不推荐,工作量巨大
硬件层模拟(通过外部采集卡)用外接HDMI采集卡+模拟信号源不越狱也能用、全局生效需要硬件成本、手机需要OTG/采集卡支持自动化测试、需要真信号的场景

实际做下来,我强烈建议走方案一。原因很直接:别的路不是不能走,是在iOS这个生态里性价比太低。你花两周搞一个CMIO驱动,最后可能连加载都过不了签名这关。而hook方案,一个晚上就能跑通核心链路。

1.3 插件的功能边界与目标场景

这套插件我给它划了一个明确的能力边界:

  • 支持生成纯色测试画面、图片画面、视频文件画面三类视频源
  • 支持自定义输出分辨率和帧率(常见如720p/30fps、1080p/30fps)
  • 只对指定的Bundle ID生效(避免影响系统其他App)
  • 不录制、不存储任何真实摄像头数据

这几个限制不是偷懒,是刻意做的。如果插件让所有App都生效,系统里其他App调用摄像头时就会出现“画面错乱”这种莫名其妙的问题,排查起来极其痛苦。所以第一版就加了Bundle ID白名单,只在你指定的测试App里激活。

2. 核心原理解析:iOS摄像头采集链路

2.1 AVCaptureSession的正常工作流程

要理解hook点在哪,先得清楚正常流程里每一环是谁在干什么。iOS摄像头的核心链路是这样的:

  1. App创建AVCaptureSession实例,并设置sessionPreset(比如HD1920x1080)
  2. 创建AVCaptureDeviceInput,用AVCaptureDevice.default(for: .video)拿到后置摄像头
  3. 创建AVCaptureVideoDataOutput,设置像素格式(通常BGRA),设置代理和队列
  4. 调用session.startRunning(),系统开始从摄像头硬件到App层的帧传递
  5. 每一帧到达时,AVCaptureVideoDataOutput的代理方法captureOutput(_:didOutput:from:)被回调,CMSampleBufferRef是帧载体

这里有个关键点:代理方法是注册在AVCaptureVideoDataOutput上的,它和AVCaptureSession之间是通过内部的消息机制连接的。我们对上层App“说谎”的最优位置,就是这个代理回调的入口——在App的代理方法被调用之前,把sampleBuffer换成我们生成的虚拟帧。

2.2 Hook点的选取逻辑

我一开始想的是hook AVCaptureSession的startRunning,启动后直接把整个session停掉,然后手动调App的代理方法喂帧。试下来发现一个问题:很多App会检查session的running状态,也会在回调里读取设备属性(比如帧率、曝光参数),只喂帧不维护状态会导致上层逻辑异常。

后来调整了方案:保留真实session正常运行,让系统以为摄像头还在工作,但我们在代理回调这里做“偷梁换柱”,把真实帧丢弃,把虚拟帧返回给上层。这样App对外看到的是“session在跑、设备参数正常、帧在持续输出”,完全不会感知到底层画面被替换过了。

这个思路和Web开发里的反向代理很像——上游服务器(真实摄像头)还活着,但客户端拿到的响应对应的是另一个来源。好处是兼容性好,坏处是有一个帧的额外开销。

2.3 真实摄像头数据与虚拟帧的切换策略

很多场景下你不想直接丢掉真实摄像头:比如你想做一个“画中画”效果,或者需要在虚拟画面和真实画面之间做切换。这套插件里我留了一个切换开关,通过一个全局状态控制当前是passthrough模式还是virtual模式。

  • passthrough模式:原样转发真实帧,不处理
  • virtual模式:丢弃真实帧,生成虚拟帧回调给上层

双模式的设计很实用。调试的时候先在passthrough模式确认整个链路没问题,再切成virtual模式看虚拟帧效果,出了任何问题都能快速定位是“渲染问题”还是“注入问题”。

3. 核心代码实现:虚拟帧生成与注入实战

3.1 虚拟帧的生成:CVPixelBuffer与CMSampleBuffer

这个部分是整个插件的“弹药库”。你要喂给App的帧必须符合它预期的格式:大多数iOS摄像头输出的像素格式是kCVPixelFormatType_32BGRA,即每个像素用4字节表示,蓝绿红 alpha 的顺序。所以我们的虚拟帧也必须用同样的格式,否则部分App在后续处理时会Crash或者花屏。

生成一帧的核心代码长这样:

- (CVPixelBufferRef)createVirtualPixelBufferWithSize:(CGSize)size { NSDictionary *pixelAttributes = @{ (id)kCVPixelBufferIOSurfacePropertiesKey: @{}, (id)kCVPixelBufferCGImageCompatibilityKey: @YES, (id)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES }; CVPixelBufferRef pixelBuffer = NULL; CVReturn status = CVPixelBufferCreate(kCFAllocatorDefault, size.width, size.height, kCVPixelFormatType_32BGRA, (__bridge CFDictionaryRef)pixelAttributes, &pixelBuffer); if (status != kCVReturnSuccess) { NSLog(@"[VirtualCamera] pixel buffer create failed: %d", status); return NULL; } // 锁定缓冲区,开始写像素数据 CVPixelBufferLockBaseAddress(pixelBuffer, 0); uint8_t *baseAddress = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer); size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer); // 给整帧填充一个颜色,比如绿色(方便肉眼观察) memset(baseAddress, 0x00, bytesPerRow * size.height); // 实际生产级代码建议用CoreGraphics绘制具体图案 CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); return pixelBuffer; }

注意bytesPerRow这个东西,它和size.width * 4往往不相等,因为系统可能做内存对齐(通常是64字节对齐)。写数据的时候千万不要直接用width * 4去遍历每一行,要老老实实用bytesPerRow,否则会出现“斜着撕裂”的画面。

生成CVPixelBuffer之后,还需要包装成CMSampleBuffer,因为AVCaptureVideoDataOutput的代理回调是CMSampleBuffer类型。这一步有固定的套路,核心代码:

- (CMSampleBufferRef)createSampleBufferFromPixelBuffer:(CVPixelBufferRef)pixelBuffer { CMSampleBufferRef sampleBuffer = NULL; // 设置时间戳,帧率相关的timing info CMVideoFormatDescriptionRef formatDesc = NULL; CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, &formatDesc); CMTime timestamp = CMClockGetTime(kCMClockHostTimeClock); CMSampleTimingInfo timingInfo = {0}; timingInfo.duration = CMTimeMake(1, 30); // 30fps timingInfo.presentationTimeStamp = timestamp; timingInfo.decodeTimeStamp = kCMTimeInvalid; CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixelBuffer, formatDesc, &timingInfo, &sampleBuffer); if (formatDesc) { CFRelease(formatDesc); } return sampleBuffer; }

3.2 注入逻辑:Logos钩子与AVCaptureVideoDataOutput代理替换

有了“子弹”之后要解决“怎么打出去”的问题。注入的框架我用的是Theos + Logos,这是iOS越狱开发最成熟的方案,语法简单、Hook能力强,社区资料也多。核心逻辑分两步:

第一步,把AVCaptureVideoDataOutput的代理换成我们自己的类:

%hook AVCaptureVideoDataOutput - (void)setSampleBufferDelegate:(id<AVCaptureVideoDataOutputSampleBufferDelegate>)delegate queue:(dispatch_queue_t)queue { // 保存App的真实代理和队列 if (delegate != nil) { self.virtualDelegate = delegate; self.virtualQueue = queue; // 把代理替换成我们的中转类 %orig(self.virtualInterceptor, queue); } else { %orig(nil, queue); } } %end

这里有个容易踩的坑:你如果只替换成自己的类,而不转发给原始代理,那App自己写在代理方法里的逻辑就全废了。所以中转类的代理方法里,要“收下”虚拟帧之后主动调用原代理的方法:

// VirtualCameraInterceptor.m - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection { if ([VirtualCameraManager sharedInstance].enabled) { // 虚拟模式:生成虚拟帧 CVPixelBufferRef virtualBuffer = [[VirtualCameraManager sharedInstance] createCurrentFrame]; CMSampleBufferRef virtualSampleBuffer = [[VirtualCameraManager sharedInstance] createSampleBufferFromPixelBuffer:virtualBuffer]; if (virtualSampleBuffer) { // 转发给App的真实代理 [self.realDelegate captureOutput:output didOutputSampleBuffer:virtualSampleBuffer fromConnection:connection]; CFRelease(virtualSampleBuffer); CVPixelBufferRelease(virtualBuffer); return; } } // passthrough模式:原样转发真实帧 [self.realDelegate captureOutput:output didOutputSampleBuffer:sampleBuffer fromConnection:connection]; }

3.3 帧源扩展:从纯色到图片到视频文件

第一版我做了纯色填充,能跑通但太弱了——演示效果好说,做测试不太够用。后来扩展了三个帧源:

  1. 纯色帧:用于链路测试和排查,看注入本身是否生效
  2. 图片帧:加载一张本地图片,用CoreGraphics绘制到CVPixelBuffer上,用于模拟固定画面(比如二维码、菜单、人脸照)
  3. 视频帧:用AVAssetReader读取本地视频文件,每一帧解码后转成BGRA格式,填充到CVPixelBuffer里,相当于用一段视频冒充摄像头画面

视频帧源是最实用的,因为很多测试场景需要动态画面,比如模拟一个走动的人脸或者滚动的文字。AVAssetReader解码出来的原始格式通常是NV12(YUV420),需要做一次格式转换。转换可以用Accelerate框架的vImage,性能高效且不会发热太严重:

// 视频帧转BGRA的关键步骤(精简版) vImage_Buffer srcBuffer = { .data = (void *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0), .height = CVPixelBufferGetHeight(pixelBuffer), .width = CVPixelBufferGetWidth(pixelBuffer), .rowBytes = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) }; vImage_Buffer dstBuffer = { .data = CVPixelBufferGetBaseAddress(outputBuffer), .height = CVPixelBufferGetHeight(outputBuffer), .width = CVPixelBufferGetWidth(outputBuffer), .rowBytes = CVPixelBufferGetBytesPerRow(outputBuffer) }; vImageConvert_NV12toBGRA8888(&srcBuffer, &srcBufferChroma, &dstBuffer, kvImageNoFlags);

我自己用测试视频跑1080p 30fps,iPhone XS上CPU占用约15%,发热在可接受范围内。如果你在低端机器上跑高分辨率高帧率,建议要么降低fps到24,要么把画面尺寸缩到720p,否则容易出现明显掉帧。

4. 实操过程:从零搭建插件工程

4.1 环境准备:Theos、签名与越狱调试机

说句实话,这玩意儿最大的门槛不在代码,在前置环境。你需要三样东西:

  • 一台可越狱的iOS设备:我测试用的是iPhone X(iOS 14.8),用unc0ver越狱。建议用老设备,新系统越狱工具不一定跟得上。
  • Theos工具链:在macOS上用Homebrew装,然后配置THEOS环境变量。也可以用Docker方式,但不如本地爽。
  • 开发者签名:虽然是越狱环境,但插件安装包deb在安装时最好还是签名一下,避免部分插件冲突。这步不是必须的,但能省不少麻烦。

Theos的安装其实很无脑,官方README写得很清楚。真正花时间的是环境变量配置和SDK路径选择,建议直接选最近版本的iOS SDK,老SDK在新Xcode上经常编译不过。

4.2 工程创建:NIC和Tweak模板

Theos自带一个NIC(New Instance Creator)工具,帮你生成工程骨架。命令行里敲:

nic.pl

然后按提示选择tweak模板,填项目名、包名、作者、Bundle ID过滤条件。生成出来的目录结构大概长这样:

VirtualCameraPlugin/ ├── Makefile # 编译配置,重点看THEOS_TARGET和ARCHS ├── control # deb包描述文件 ├── Tweak.x # Logos写入文件,所有hook写在这 └── VirtualCameraManager.h/m # 自定义帧生成逻辑(自己加的)

Makefile里有两个关键参数:

  • BUNDLE_ID_FILTER:插件只对匹配的App生效,这里填你要注入的测试App的Bundle ID
  • ARCHS:arm64就够了,老的armv7不用管

4.3 帧生成器与注入代码整合

整个工程的结构我建议按“两个模块”分层:

  • VirtualCameraManager:负责生成虚拟帧(纯色/图片/视频),输出CVPixelBuffer。这个模块是纯上层逻辑,可以单测。
  • Tweak.x:负责hook AVCaptureVideoDataOutput和相关的session逻辑,把真实代理替换成中转类。

为什么这么分?因为Tweak.x里的Logos代码没法用XCTest做单元测试,把所有可测试的业务逻辑放在普通OC类里,后续维护会轻松很多。我就是一开始把代码全堆在Tweak.x里,后来发现调试一个帧格式问题要反复重装deb,极其痛苦。

编译和安装命令:

make clean && make make package # 生成的deb在 packages/ 目录下 # 用scp拷到手机,然后用dpkg -i安装

每次改动都要走“编译-打包-传输-安装-重启SpringBoard”这条链路。我刚做的时候一天能来回装几十次,后来学聪明了,在电脑上写好一个自动化的deploy脚本,一条命令搞定,大大提升效率。

4.4 验证插件是否生效、帧率与资源占用实测

安装完插件后,怎么确认虚拟帧真的生效了?最直观的方法是打开测试App,看摄像头画面是否变成了你设置的内容。但这里“看”的时候有个坑:部分App会有前置预览层(AVCaptureVideoPreviewLayer),它走的路径和AVCaptureVideoDataOutput不完全一样。我第一版实现时只hook了DataOutput,结果画面预览还是真实摄像头,只有录制出来的视频才是虚拟内容。如果你要连预览画面一起替换,需要额外处理AVCaptureVideoPreviewLayer的layer session关联关系。

资源占用方面,我用Instruments跑了几项数据(测试设备iPhone X、iOS 14.8、720p@30fps):

指标纯色模式图片模式视频文件模式
CPU占用3%8%15%
内存增量12MB25MB40MB
帧间隔抖动极低极低偶发卡顿

视频模式内存增量大的原因是AVAssetReader有缓冲区预读机制,加上我们每帧都新建了CVPixelBuffer。如果要控制内存,可以用内存池复用CVPixelBuffer而不是每帧新建,但我做的过程中发现坑很多(CVPixelBufferPool在跨线程使用时容易踩到野指针),所以第一版没做,后续有精力再优化。

5. 常见问题与排查技巧实录

5.1 画面黑屏但App不崩溃

这个现象多数不是注入失败,而是“注入成功但帧格式不对”。常见原因有三个:

  • 像素格式不匹配:App期望的是kCVPixelFormatType_32BGRA,但你生成的帧是别的格式。检查一下输出端的设置里有没有显式指定像素格式。
  • 时间戳异常:CMSampleBuffer的presentationTimeStamp不能倒退,如果时间戳处理不对,部分App的视频渲染器会直接丢帧,表现就是黑屏。
  • 帧尺寸不匹配:App可能设置了sessionPreset为1280x720,你塞一个1920x1080的帧进去,虽然理论上能显示,但有些渲染管线会出问题。建议虚拟帧尺寸严格跟sessionPreset一致。

排查方法很简单:在中转类的代理方法里加一行NSLog,打印虚拟帧的尺寸、格式、时间戳,看哪个环节出了问题。

5.2 注入后App闪退

闪退原因大概率在内存管理。CMSampleBuffer和CVPixelBuffer都是CF对象,需要手动管理内存。我遇到最多的问题是:中转类里把虚拟sampleBuffer转发给App后,马上CFRelease了它,但App是在异步队列里处理帧的,等它处理完,内存已经被释放了,导致野指针崩溃。

正确的做法是:如果App的代理方法和中转类在同一队列(同步调用),可以在转发完后释放;如果是异步队列,需要用CFRetain把buffer引用计数加一,等App处理完再释放。实际开发中你无法确定App的队列模型,所以我建议干脆不主动释放,靠自动释放池兜底(性能损失约5%,可接受)。

5.3 只有录制结果变了、但预览没变

这个就是我上面提到的AVCaptureVideoPreviewLayer问题。如果你需要预览层也显示虚拟画面,有两个思路:

  • 思路A:hook AVCaptureVideoPreviewLayer的session属性,在attach之前偷换成一个我们自建的session
  • 思路B:hook掉预览层内部的connection,强制它使用我们输出的sampleBuffer

思路A侵入性小,实现简单,推荐。不过要注意,部分App会通过layer.session获取session并手动调整session属性,你替换了session对象后,这些操作会失效或者直接崩溃。比较好的折中是只替换session的输入设备,把所有AVCaptureDeviceInput都移除,这样预览层就只能显示黑屏或雪花,然后你再用UIWindow加一个覆盖层显示自己的虚拟画面。

当然这个方案“欺骗”程度不完美,但多数测试场景够用。

5.4 性能问题排查表

现象可能原因解决方案
帧率明显低于设置值视频解码跟不上降低分辨率到720p,关闭视频源高级特性
CPU飙高每帧都新建CVPixelBuffer使用CVPixelBufferPool复用缓冲
内存持续增长视频源读取器预读过多限制读取器的最大缓冲帧数
画面偶尔卡顿主线程和采集线程竞争用队列给帧生成加锁,减少跨线程共享

5.5 签名与安装问题

de包装好后dpkg安装失败,多半是deb包结构不对。检查一下Makefile里是不是漏了THEOS_PACKAGE_SCHEME = rootless(iOS 15之后根文件系统变了,老格式装不上)。前期我用iOS 14测试没遇到这个问题,换到iOS 15.0的A12设备上就翻车了,折腾了好一阵子。

另外需要注意“iOS开发者模式”和开发者App证书更新的问题——每七天需要重签一次,如果测试机长期不用,证书过期会导致插件加载失败。建议在自动化脚本里加一步证书状态的检查。

写在后面:这套方案的实际使用心得

这套插件做出来后,我最常拿它干的其实是自动化回归测试。做扫码类App开发时,每次都要拿手机去对着一张二维码拍,人累不说,角度和光线还不可控。现在直接把一张二维码图片塞进虚拟帧里,App的扫码功能稳定复现,跑测试脚本的时候再也不用担心环境干扰了。

另外一个很实用的场景是做视频通话App的画面调试。对方看到的画面是可以“造假”的,你可以在不同帧之间切换测试画面,验证弱网下画面卡顿、清晰度切换等逻辑是否正常。这个用真实摄像头很难做到精确控制,虚拟帧反而成了唯一可靠的手段。

如果你是想把这个方案用在线上产品里,我的建议是趁早换个思路。没有越狱环境的普通用户设备上,这条路是走不通的。但作为开发者工具、测试工具、Demo演示工具,这套方案的价值非常大。

最后分享一个调试小技巧:在中转类的虚拟帧生成逻辑里,每隔30帧让画面闪烁一次(比如快速闪一下白色),这样你在真机上看App画面时,能直观确认“当前显示的确实是虚拟帧而不是真实摄像头”——这比在后台打印日志高效多了,因为日志只能证明注入被执行,闪烁证明了你看到的就是注入的画面。

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

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

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

立即咨询