
简介面向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允许你注册新设备也没有类似DALDalvik不对是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摄像头的核心链路是这样的App创建AVCaptureSession实例并设置sessionPreset比如HD1920x1080创建AVCaptureDeviceInput用AVCaptureDevice.default(for: .video)拿到后置摄像头创建AVCaptureVideoDataOutput设置像素格式通常BGRA设置代理和队列调用session.startRunning()系统开始从摄像头硬件到App层的帧传递每一帧到达时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:(idAVCaptureVideoDataOutputSampleBufferDelegate)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 帧源扩展从纯色到图片到视频文件第一版我做了纯色填充能跑通但太弱了——演示效果好说做测试不太够用。后来扩展了三个帧源纯色帧用于链路测试和排查看注入本身是否生效图片帧加载一张本地图片用CoreGraphics绘制到CVPixelBuffer上用于模拟固定画面比如二维码、菜单、人脸照视频帧用AVAssetReader读取本地视频文件每一帧解码后转成BGRA格式填充到CVPixelBuffer里相当于用一段视频冒充摄像头画面视频帧源是最实用的因为很多测试场景需要动态画面比如模拟一个走动的人脸或者滚动的文字。AVAssetReader解码出来的原始格式通常是NV12YUV420需要做一次格式转换。转换可以用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 30fpsiPhone XS上CPU占用约15%发热在可接受范围内。如果你在低端机器上跑高分辨率高帧率建议要么降低fps到24要么把画面尺寸缩到720p否则容易出现明显掉帧。4. 实操过程从零搭建插件工程4.1 环境准备Theos、签名与越狱调试机说句实话这玩意儿最大的门槛不在代码在前置环境。你需要三样东西一台可越狱的iOS设备我测试用的是iPhone XiOS 14.8用unc0ver越狱。建议用老设备新系统越狱工具不一定跟得上。Theos工具链在macOS上用Homebrew装然后配置THEOS环境变量。也可以用Docker方式但不如本地爽。开发者签名虽然是越狱环境但插件安装包deb在安装时最好还是签名一下避免部分插件冲突。这步不是必须的但能省不少麻烦。Theos的安装其实很无脑官方README写得很清楚。真正花时间的是环境变量配置和SDK路径选择建议直接选最近版本的iOS SDK老SDK在新Xcode上经常编译不过。4.2 工程创建NIC和Tweak模板Theos自带一个NICNew 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 IDARCHSarm64就够了老的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、720p30fps指标纯色模式图片模式视频文件模式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问题。如果你需要预览层也显示虚拟画面有两个思路思路Ahook AVCaptureVideoPreviewLayer的session属性在attach之前偷换成一个我们自建的session思路Bhook掉预览层内部的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 rootlessiOS 15之后根文件系统变了老格式装不上。前期我用iOS 14测试没遇到这个问题换到iOS 15.0的A12设备上就翻车了折腾了好一阵子。另外需要注意“iOS开发者模式”和开发者App证书更新的问题——每七天需要重签一次如果测试机长期不用证书过期会导致插件加载失败。建议在自动化脚本里加一步证书状态的检查。写在后面这套方案的实际使用心得这套插件做出来后我最常拿它干的其实是自动化回归测试。做扫码类App开发时每次都要拿手机去对着一张二维码拍人累不说角度和光线还不可控。现在直接把一张二维码图片塞进虚拟帧里App的扫码功能稳定复现跑测试脚本的时候再也不用担心环境干扰了。另外一个很实用的场景是做视频通话App的画面调试。对方看到的画面是可以“造假”的你可以在不同帧之间切换测试画面验证弱网下画面卡顿、清晰度切换等逻辑是否正常。这个用真实摄像头很难做到精确控制虚拟帧反而成了唯一可靠的手段。如果你是想把这个方案用在线上产品里我的建议是趁早换个思路。没有越狱环境的普通用户设备上这条路是走不通的。但作为开发者工具、测试工具、Demo演示工具这套方案的价值非常大。最后分享一个调试小技巧在中转类的虚拟帧生成逻辑里每隔30帧让画面闪烁一次比如快速闪一下白色这样你在真机上看App画面时能直观确认“当前显示的确实是虚拟帧而不是真实摄像头”——这比在后台打印日志高效多了因为日志只能证明注入被执行闪烁证明了你看到的就是注入的画面。本文还有配套的精品资源点击获取