ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

iOS虚拟摄像头插件实战:基于AVFoundation的帧注入技术解析

iOS虚拟摄像头插件实战:基于AVFoundation的帧注入技术解析 简介这是一份iOS虚拟摄像头插件源码包面向具备一定iOS开发经验的技术人员演示如何通过MobileSubstrate注入系统相机进程并采用Hook方式拦截AVFoundation框架的AVCaptureSession相关方法从而实现原始视频流替换。压缩包共5个文件约15KB包含两个HTML说明页面、一份JavaScript脚本以及inscode配置文件和gitignore文件结构精简便于直接阅读与调试核心逻辑。核心代码基于AVCaptureVideoDataOutputSampleBufferDelegate协议完成视频帧替换并配套SwiftUI摄像头应用示例覆盖摄像头切换、滤镜选择、视频源配置以及通过PHPickerViewController选取本地视频、UISlider控制时长和裁剪导出等完整实现思路。整体内容涉及系统级编程、框架拦截、视频帧处理与多媒体交互技术密度较高适合希望深入研究iOS底层相机替换机制和SwiftUI多媒体应用的开发者参考。目前已有267人学习浏览下载后可直接对照代码梳理实现路径。1. iOS虚拟摄像头插件画面“身份造假”发生在帧回调这一层第一次接到“给 iOS 做虚拟摄像头”的需求我以为是像 Windows 上装 OBS 虚拟摄像头那样装个驱动结果查了一圈发现iOS 根本没有系统级的虚拟摄像头概念。你能做的只有一件事在 AVFoundation 的采集回调和视频输出路径上做手脚让 App 以为自己收到的 CMSampleBuffer 来自摄像头实际上那是你生成的画面。对直播 App、美颜 SDK、iOS 自动化测试这些场景这意味着你不用再抢真机摄像头也能喂进去一整套可控的视频流。这篇文章写给两类人想给直播工具做画面注入的 iOS 开发以及想在测试设备上模拟摄像头输入的自动化测试工程师。2. 先定技术路线iOS 为什么做不了 OBS 那种系统级虚拟摄像头2.1 桌面虚拟摄像头与 iOS 采集链路的结构差异在 Windows 和 macOS 上做虚拟摄像头本质是注册一个虚拟 DirectShow / CoreMediaIO 设备让 OBS、VisoMaster 这类工具把任意画面输出为一个“摄像头设备”其他应用通过系统 API 枚举设备时能看到它、选中它、采集它。这是操作系统层面的设备抽象Linux 上的 v4l2loopback 也是同一思路。但 iOS 没有暴露类似的设备抽象层。你写 App 时只能通过AVCaptureDevice枚举物理摄像头拿到的是 iPhone 前后摄、USB 摄像头在 iPadOS 上这一类真实硬件。AVCaptureSession负责从这些设备上采集数据通过AVCaptureVideoDataOutput把每一帧回调给 App。设备层是只读的系统不允许你往设备列表里塞一个假节点。更关键的是iOS 设备模拟器也没有摄像头你在模拟器里连AVCaptureDevice都枚举不出来所以想在设计阶段验证“虚拟摄像头”方案只能上真机。这就是很多从桌面端转过来的开发者第一反应是“这玩意是不是不存在”其实它存在只是长得完全不像桌面端那套。2.2 两条可落地路线Tweak 注入 vs 未越狱侧载既然系统不让注册虚拟设备实际能做文章的就剩两个位置帧回调和录屏输出。第一条路线在越狱环境下写一个 Tweak注入到目标 App 进程直接拦截AVCaptureVideoDataOutput的 delegate 回调把真实相机帧换成自绘帧。这是目前唯一能对任意 App 生效、能以“插件”形态分发的方式。虽然 iOS 16 之后的“开发者模式”提供了侧载通道但那只是应用签名安装的通道装进去的 App 依然跑在常规沙箱里不能对别的进程做 Method Swizzling所以未越狱侧载这条路对“插件”形态基本是死的。第二条路线不碰摄像头用 ReplayKit 做屏幕广播把整个屏幕内容作为视频流推给另一端。这个路子在直播场景里确实被大量使用但它的输出身份是“屏幕共享”不是“摄像头”微信、FaceTime 这类应用不会把它当成摄像头画面。对 iOS 自动化测试来说它也算间接实现了“给被测 App 输入外部画面”但控制粒度太粗没法只替换某一帧的某个区域。所以如果你要的是“插件”两个字能走通的方案只有一个越狱 Tweak 帧注入。下文的所有代码都基于这个前提测试设备请用你自己专门用于开发研究的机器。2.3 放弃“伪造 AVCaptureDevice”这条路守住 delegate 转发以前确实有人尝试过更进一步hook 住AVCaptureDevice的类方法让defaultDeviceWithMediaType:返回一个假的设备对象从而让 App 在未开启真实摄像头的情况下也能“采集”到画面。这个方向理论上最接近“虚拟摄像头”的本义但实操里有几个绕不开的坑AVCaptureDevice的初始化不走init它由系统私有框架创建直接alloc出来的实例几乎没有可用属性AVCaptureSession内部对 device 做了大量私有方法调用和 KVC 检查假对象很容易触发NSInvalidArgumentException或AVFoundation内部断言。即使侥幸过了 session 启动AVCaptureConnection的videoOrientation、activeVideoMinFrameDuration也全是基于真实硬件状态计算的假设备的数值大概率不合法。我在自己的测试机上试过仿造 device 这条路翻车记录比成功记录多得多。后来我放弃了这种“身份造假”改成在 delegate 转发层做“帧造假”。App 仍然认为自己在用一个正常的、已经打开的摄像头但实际上它每次从captureOutput:didOutputSampleBuffer:拿到的都是我们注入的帧。这个方案不再关心底层设备长什么样兼容性明显更高而且不管最外层是 uniapp 壳子还是原生 App只要它最终通过 AVFoundation 拿视频帧这个口子都堵不住。3. 从零写一个能跑的 Tweak工程初始化与三个绑定挂钩3.1 初始化 Theos 的 Tweak 工程Makefile 与 control 文件我习惯用 Theos 搭 Tweak 工程。环境变量指向 Theos 目录后一个最小工程只需要两个文件Makefile指定插件名和源码文件control声明包信息。以下是一个针对 iOS 14-17、arm64 越狱设备的模板。# Makefile TARGET : iphone:clang:latest:14.0 INSTALL_TARGET_PROCESSES YourCameraApp include $(THEOS)/makefiles/common.mk TWEAK_NAME VirtualCam VirtualCam_FILES Tweak.xm include $(THEOS_MAKE_PATH)/tweak.mk# control Package: com.example.virtualcam Name: VirtualCam Depends: mobilesubstrate Architecture: iphoneos-arm64 Version: 1.0.0TARGET里的14.0是部署版本下限不是 SDK 版本Theos 会用它决定链接方式。INSTALL_TARGET_PROCESSES很关键它告诉make install安装完成之后要重启哪个 App如果你不给这个值装完插件后目标 App 还留在旧进程里等于没生效。Depends里的mobilesubstrate是 Cydia Substrate 的运行时库所有基于 Logos 的 Tweak 都依赖它。3.2 Hook AVCaptureVideoDataOutput拿到 App 的视频格式偏好目标 App 并不知道自己被注入了它按自己的逻辑创建AVCaptureVideoDataOutput设置视频格式指定 delegate。我们要在这些动作发生时把关键参数记录下来尤其是像素格式和分辨率。先说像素格式后面 4.2 会展开讲格式不对的多严重。// Tweak.xm #import AVFoundation/AVFoundation.h #import CoreVideo/CoreVideo.h #import CoreMedia/CoreMedia.h #import UIKit/UIKit.h static OSType _vcFormat kCVPixelFormatType_32BGRA; static int _vcWidth 1920; static int _vcHeight 1080; %hook AVCaptureVideoDataOutput - (void)setVideoSettings:(NSDictionary *)videoSettings { %orig; NSDictionary *settings [self videoSettings]; NSNumber *fmtNum settings[kCVPixelBufferPixelFormatTypeKey]; if (fmtNum) { _vcFormat [fmtNum unsignedIntValue]; } } - (void)setSampleBufferDelegate:(idAVCaptureVideoDataOutputSampleBufferDelegate)delegate queue:(dispatch_queue_t)queue { _vcRealDelegate delegate; _vcOutput self; static VCFrameForwarder *forwarder nil; forwarder [VCFrameForwarder alloc]; %orig(forwarder, queue); } %endsetVideoSettings:里%orig先让系统处理完配置再从[self videoSettings]读实际生效的设置。读 key 用的是kCVPixelBufferPixelFormatTypeKey不是AVVideoCodecKey很多人在这写错。如果 App 没有显式设置格式AVCaptureVideoDataOutput会默认输出kCVPixelFormatType_32BGRA所以静态变量给一个 BGRA 初始值是安全的。setSampleBufferDelegate:queue:是整个插件的核心绑定口。这里不能直接放行原始 delegate否则后面没法拦截帧。我们把原始 delegate 存到_vcRealDelegate然后换成一个转发代理VCFrameForwarder交给系统。App 之后收到的所有采集回调都会先经过这个代理。3.3 用 NSProxy 转发回调在 didOutputSampleBuffer 这个口子做替换NSProxy是专门用来做消息转发的类它不继承NSObject不需要有实际的类结构所有方法调用都会落到forwardInvocation:。我们用它拦截captureOutput:didOutputSampleBuffer:fromConnection:这个选择子把CMSampleBufferRef参数整体换掉。// VCFrameForwarder 实现 interface VCFrameForwarder : NSProxy end static id _vcRealDelegate nil; static AVCaptureVideoDataOutput *_vcOutput nil; static BOOL _vcEnabled YES; implementation VCFrameForwarder - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel { return [(NSObject *)_vcRealDelegate methodSignatureForSelector:sel]; } - (void)forwardInvocation:(NSInvocation *)invocation { SEL sel invocation.selector; if (sel selector(captureOutput:didOutputSampleBuffer:fromConnection:)) { if (_vcEnabled) { __unsafe_unretained AVCaptureOutput *outObj nil; __unsafe_unretained AVCaptureConnection *conn nil; [invocation getArgument:outObj atIndex:2]; [invocation getArgument:conn atIndex:4]; CMSampleBufferRef fakeBuf _vcCreateVirtualSampleBuffer(); if (fakeBuf) { NSInvocation *newInv [NSInvocation invocationWithMethodSignature:invocation.methodSignature]; newInv.target _vcRealDelegate; newInv.selector sel; [newInv setArgument:outObj atIndex:2]; [newInv setArgument:fakeBuf atIndex:3]; [newInv setArgument:conn atIndex:4]; [newInv invoke]; CFRelease(fakeBuf); return; } } } [invocation invokeWithTarget:_vcRealDelegate]; } endforwardInvocation:里 index 2/3/4 分别对应output、sampleBuffer、connection这个偏移是 Objective-C 方法调用的固定规则index 0 是 selfindex 1 是 _cmd。写代理时最容易犯的错是把 sampleBuffer 的 index 写错一旦写错delegate 拿到的不是 CMSampleBufferRef而是一个野指针App 会在CMSampleBufferGetImageBuffer处直接崩。为什么不用methodSignatureForSelector:返回nil因为 NSProxy 对不存在的选择子默认会进forwardInvocation:但NSInvocation需要方法签名才能构造所以这里必须把原始 delegate 的签名转发出来。代码里用[(NSObject *)_vcRealDelegate methodSignatureForSelector:]强制类型转换为 NSObject是为了让编译器不报警告。3.4 生成虚拟帧CVPixelBufferPool 与 CoreGraphics 绘制有了转发口接下来要用自绘帧填进去。常见做法是维护一个CVPixelBufferPool这样每一帧复用的是池子里已经分配好的内存不用每帧都malloc再释放性能压力小很多。绘制我用 CoreGraphics不依赖 OpenGL简单直观。static CVPixelBufferPoolRef _vcPool NULL; static CMSampleBufferRef _vcCreateVirtualSampleBuffer(void) { if (_vcPool NULL) { NSDictionary *poolAttrs { (id)kCVPixelBufferPoolMinimumBufferCountKey : (6), (id)kCVPixelBufferWidthKey : (_vcWidth), (id)kCVPixelBufferHeightKey : (_vcHeight), (id)kCVPixelBufferPixelFormatTypeKey : (kCVPixelFormatType_32BGRA), (id)kCVPixelBufferIOSurfacePropertiesKey : {} }; CVPixelBufferPoolCreate(NULL, NULL, (__bridge CFDictionaryRef)poolAttrs, _vcPool); } CVPixelBufferRef pb NULL; if (CVPixelBufferPoolCreatePixelBuffer(NULL, _vcPool, pb) ! kCVReturnSuccess) { return NULL; } CVPixelBufferLockBaseAddress(pb, 0); size_t w CVPixelBufferGetWidth(pb); size_t h CVPixelBufferGetHeight(pb); size_t bpr CVPixelBufferGetBytesPerRow(pb); void *base CVPixelBufferGetBaseAddress(pb); CGColorSpaceRef cs CGColorSpaceCreateDeviceRGB(); CGContextRef ctx CGBitmapContextCreate(base, w, h, 8, bpr, cs, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst); CGColorSpaceRelease(cs); UIGraphicsPushContext(ctx); CGRect full CGRectMake(0, 0, w, h); [[UIColor colorWithRed:0.05 green:0.08 blue:0.20 alpha:1] setFill]; UIRectFill(full); NSString *text [NSString stringWithFormat:VirtualCam %dx%d\n%, (int)w, (int)h, [NSDate date]]; NSMutableParagraphStyle *ps [NSMutableParagraphStyle new]; ps.alignment NSTextAlignmentCenter; NSDictionary *attrs { NSFontAttributeName: [UIFont boldSystemFontOfSize:h * 0.05], NSForegroundColorAttributeName: [UIColor whiteColor], NSParagraphStyleAttributeName: ps }; [text drawInRect:full withAttributes:attrs]; UIGraphicsPopContext(); CGContextRelease(ctx); CVPixelBufferUnlockBaseAddress(pb, 0); CMSampleTimingInfo timing {0}; timing.presentationTimeStamp CMTimeMakeWithSeconds(CACurrentMediaTime(), 1e9); timing.decodeTimeStamp kCMTimeInvalid; timing.duration CMTimeMake(1, 30); CMSampleBufferRef sbuf NULL; CMSampleBufferCreateForImageBuffer(NULL, pb, YES, NULL, NULL, 1, timing, sbuf); CFRelease(pb); return sbuf; }绘制部分读起来像黑魔法其实核心就在CGBitmapContextCreate的最后一个参数。kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst对应的是 BGRA 字节序刚好和kCVPixelFormatType_32BGRA对上。如果你写成kCGImageAlphaPremultipliedLast内存里就变成了 RGBAApp 拿到后颜色通道全错位画面会发蓝发紫。duration用CMTimeMake(1, 30)表示每帧期望时长 1/30 秒但这是理论值实际帧间隔由我们自己的发送节奏决定Apple 也建议用kCMTimeInvalid。这里给 30fps 主要是让下游 App 的帧率统计更贴近真实。3.5 编译、安装与加载第一次看到假帧的完整命令工程建好后编译安装的流程不长但每一步都得确认产物存在。# 编译 export THEOS~/theos make clean make package # 确认 deb 产物 ls -lh packages/*.deb # 越狱设备通过 USB 连接后一键安装并重启目标 App make installmake package会生成.debTheos 默认输出在packages/目录。make install内部走dpkg -i但装完不会自动杀 App所以INSTALL_TARGET_PROCESSES要写对。如果安装后 target App 已经在运行你可以手动killall YourCameraApp再重开也可以在make install前确认设备上装了adv-cmds这个包里面才有killall命令。首次验证有个笨但有效的办法让虚拟帧上带时间戳文字打开目标 App 的录制或直播预览如果画面上能看到不断跳动的秒数说明注入链路已经通了。这个“带时间戳的文字画面”我建议一直保留后期调帧率、调格式全靠它观察。4. 四个必调的“可用性参数”分辨率、像素格式、帧率与缓存池4.1 分辨率从 App 的 sessionPreset 来不要自己拍脑袋第 3 章代码里_vcWidth和_vcHeight直接给了默认值但这只是兜底。真实场景里不同 App 对采集分辨率的要求差异极大抖音直播可能请求 720pFaceTime 走 1080p扫码类工具往往只要 480p。如果你固定输出 4K 给一个只消费 720p 的 App不但白白增加绘制开销还会引发下游CVPixelBufferGetWidth与预期不符导致的裁剪问题。常见做法是 hookAVCaptureSession的setSessionPreset:从 preset 字符串反向映射分辨率%hook AVCaptureSession - (void)setSessionPreset:(AVCaptureSessionPreset)preset { %orig; if ([preset isEqualToString:AVCaptureSessionPreset1280x720]) { _vcWidth 1280; _vcHeight 720; } else if ([preset isEqualToString:AVCaptureSessionPreset1920x1080]) { _vcWidth 1920; _vcHeight 1080; } else if ([preset isEqualToString:AVCaptureSessionPresetPhoto]) { _vcWidth 4032; _vcHeight 3024; } else if ([preset isEqualToString:AVCaptureSessionPresetHigh]) { _vcWidth 1920; _vcHeight 1080; } } %end这里有个容易被忽略的点AVCaptureSessionPresetPhoto在部分设备上对应的是 4032x3024但在老设备上可能只有 3024x3024。要拿到精确值最好在运行时通过AVCaptureDevice的activeFormat去读CMVideoFormatDescriptionGetDimensions而不是硬编码。不过对插件开发来说preset 映射已经够用毕竟最终分辨率只影响我们自己绘制的画布大小App 拿到帧之后自己会缩放。4.2 BGRA 与 NV12 的选择色彩翻车的根源iOS 上AVCaptureVideoDataOutput最常用的像素格式就两个kCVPixelFormatType_32BGRA和kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange后者就是 NV12。视频编码链路和图像处理链路往往偏好 NV12带宽小一半而需要直接做渲染或人脸检测的链路偏 BGRA。如果你的插件把 BGRA 帧塞给一个要求 NV12 的 App结果一般是画面发绿或者整体偏色因为下游按 YUV 的字节布局去解释 BGRA 数据U/V 通道全是错的。反过来也是同理。所以第 3.2 节记录的_vcFormat在这里就派上用场了if (_vcFormat kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) { // 不能直接用 CGBitmapContext 绘制需要先走 vImage 或 CoreImage 转成 NV12 }最稳的转换手段是vImageConvert_BGRA8888ToYuv420它是 Accelerate 框架里的纯 C 接口不带 Objective-C 对象创建适合在帧回调这种高频路径上调用。用 CoreImage 的CIImageCIContext也可以但CIContext第一次创建很慢而且内部线程安全属性要单独配不如 vImage 直接。4.3 帧率定在 30 还是 15注入帧和 App 内部编码节奏的关系帧率不是越高越好。目前我见过的大部分直播 App 推流接收端默认是 30fps 或 24fps注入 60fps 只会让 App 内部的编码器做帧率归一化甚至因为输出队列积压触发丢帧策略反而看起来更卡。我一般的取值规则是用于直播/视频通话场景的测试用 30fps和大多数采集设备保持一致用于自动化回归测试15fps 就够能明显降低设备发热和 CPU 占用。帧率的上限由我们自己的发送端 timer 节奏决定而不是由虚拟帧里的duration字段决定这个认知很多人搞反了。发送端我习惯用dispatch_source_t定时器而不是NSTimer。原因很实在NSTimer依赖 RunLoop 模式App 在滑动列表或弹窗时 RunLoop 会在UITrackingRunLoopMode下切换NSTimer可能被暂时挂起CADisplayLink又和屏幕刷新率绑定iPhone Pro 的 120Hz 会让你误以为能跑到 120fps。dispatch_source走的是独立的 GCD 队列不随 UI 卡顿而暂停。static dispatch_source_t _vcTimer; static void _vcStartTimer(void) { if (_vcTimer) dispatch_resume(_vcTimer); _vcTimer dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); dispatch_source_set_timer(_vcTimer, DISPATCH_TIME_NOW, 1.0 / 30.0 * NSEC_PER_SEC, 0); dispatch_source_set_event_handler(_vcTimer, ^{ // 触发一次帧回调 }); dispatch_resume(_vcTimer); }dispatch_source_set_timer的第三个参数是 leeway也就是调度误差容忍度。这里传 0 表示尽量精确但 GCD 并不保证硬实时系统高负载时仍可能漂移。如果对帧间隔的一致性敏感可以在回调里用CACurrentMediaTime()统计真实间隔而不是假设正好 1/30 秒。4.4 缓存池大小与丢帧策略宁可掉帧也不要卡死 UICVPixelBufferPool的kCVPixelBufferPoolMinimumBufferCountKey设多少决定了池子里最少有几块可用 buffer。设太小比如 3遇到 App 内部持有帧做异步编码时池子会枯竭CVPixelBufferPoolCreatePixelBuffer会阻塞当前线程等待 buffer 归还UI 就卡了。设太大比如 30内存占用直接拉满按 1080p BGRA 算每帧约 8MB30 帧就是 240MB测试机容易被打爆。我在实际项目里取 6-8。同时要做一个关键决定池子空了这帧是等还是丢。我的选择是丢。虚拟摄像头是测试工具少一帧画面完全可接受但阻塞 UI 线程是事故。在生成虚拟帧的函数开头加一个空池判断创建失败直接return NULL上层转发代理检测到 NULL 就跳过本帧不阻塞线程。5. 虚拟摄像头插件落地避坑五个高频问题及对策5.1 画面黑屏但声音正常预览层不在帧回调链路上现象目标 App 的音视频通话正常对方能听到声音但画面全黑本机预览也黑。原因大部分音视频 App 有两路独立链路AVCaptureVideoPreviewLayer负责在本机屏幕显示相机预览AVCaptureVideoDataOutput负责把帧交给编码器。我们替换的只是 data output 的 delegate 回调AVCaptureVideoPreviewLayer依然连接着真实摄像头而真实摄像头在预览链路里如果没有画面输入比如被插件外的因素干扰预览自然黑。推流端黑屏则说明 App 的编码器拿到的根本不是我们注入的帧那可能是_vcEnabled没打开或者 App 内部还有第二个AVCaptureSession。解决先在日志里确认forwardInvocation:是否真的命中了。用os_log打点每次进入替换分支记一条。确认命中后再区分是预览黑还是推流黑。如果只是预览黑常见做法是不处理因为很多直播工具只看推流端画面如果推流也黑检查VCFrameForwarder的methodSignatureForSelector:是否返回了正确的签名NSProxy 一旦返回 nil后续所有 delegate 方法都会被丢弃。5.2 注入帧颜色发绿或发粉bitmapInfo 和像素格式对不上现象App 能正常收到帧但画面整体偏绿或者红色蓝色完全错乱。原因这是第 4.2 节说的格式不匹配具体有两种第一种是 App 要求的像素格式是 NV12而_vcCreateVirtualSampleBuffer生成的始终是 BGRA第二种是CGBitmapContextCreate的bitmapInfo参数传错把 BGRA 画成了 RGBA 或 ARGB。解决在setVideoSettings:的 hook 里打日志把kCVPixelBufferPixelFormatTypeKey的值打印出来和_vcFormat对比。如果是 NV12不要直接用 CoreGraphics 画 BGRA 再让 App 自己去解释而是先画到 BGRA buffer再用vImageConvert_BGRA8888ToYuv420转成 NV12 放到另一个 CVPixelBuffer 里最后用那个 NV12 buffer 创建 CMSampleBuffer。这个转换不能省略因为下游编码器对像素格式的校验很严格不合法的 buffer 有时不报错只是颜色错。5.3 摄像头权限弹窗消失且 App 卡死接管时机太早现象Tweak 加载后目标 App 的摄像头权限弹窗不再出现接着整个 App 卡死CPU 占用飙高。原因插件在 App 还没完成权限申请、AVCaptureSession还没正常启动时就把 delegate 换成了 NSProxy。iOS 的隐私提示框依赖系统对相机的占用状态如果此时链路已经被我们接管系统可能认为相机已被一个异常消费者占用权限弹窗逻辑直接中断。解决确保setSampleBufferDelegate:queue:被调用时App 已经完成权限申请。常见做法是在 hook 方法里检查 App 的info.plist里的用途说明是否已触发过或者简单点把_vcEnabled默认设为NO等startRunning这个 hook 点收到后再置为YES。startRunning是 session 真正开始采集的时机权限和 session 配置都在它之前完成这个时机足够安全。5.4 缓存池耗尽导致“30 帧变 5 帧”delegate 持帧不放现象日志里生成帧的调用频率是 30fps但 App 端画面肉眼可见地掉到每秒几帧而且 App 的内存持续上涨。原因下游 App 拿到CMSampleBufferRef后没有及时释放而是把它送进了编码队列、美颜队列或写入队列。这些队列消费速度跟不上生成速度CVPixelBufferPool里的 buffer 全被 App 持有我们的生成端只能阻塞等待。内存上涨就是因为队列里积压了太多未处理的帧。解决这是帧缓存策略问题和插件本身没有直接关系。把kCVPixelBufferPoolMinimumBufferCountKey适当上调到 10同时生成端不做阻塞等待CVPixelBufferPoolCreatePixelBuffer失败就丢帧。还有一个更彻底的思路改从下游消费队列做背压控制。但插件面对的是一个黑匣子 App你无法知道它队列多深所以“丢帧不阻塞”是最保守也最安全的选择。5.5 多线程创建 CVPixelBufferPool 直接闪退全链路只走一个队列现象插件不是每次都崩而是运行几分钟后偶发崩溃崩溃栈指向CVPixelBufferPoolCreatePixelBuffer或CGBitmapContextCreate。原因AVCaptureVideoDataOutput的 delegate 回调队列、我们自己的 timer 队列、App 内部的其他线程都在调用_vcCreateVirtualSampleBuffer而_vcPool是静态变量多个线程同时第一次访问时都会去CVPixelBufferPoolCreate这个函数不是线程安全的。CGContext的创建和释放同理。解决给虚拟帧生成函数加一把锁或者更彻底——把整个生成流程收敛到一个串行队列。我一般用一个专用dispatch_queue_t(com.virtualcam.frame)所有_vcCreateVirtualSampleBuffer的调用都通过dispatch_sync进去执行。注意这里不能改成dispatch_async因为调用方需要在forwardInvocation:里拿到返回的CMSampleBufferRef去构造新 NSInvocation异步就拿不到了。6. 把虚拟摄像头扩展成实时特效引擎水印、时间戳校验与成片验证插件跑通之后下一步一般不是“让它更稳”而是“让画面能表达更多信息”。虚拟摄像头最实用的扩展就是叠加动态水印和校验信息因为它能帮你回答三个问题帧是不是新的、帧是不是对的、链路是不是通的。叠加水印不用额外引入渲染引擎在 CoreGraphics 绘制阶段多画几行文字就行。除了时间戳我建议把帧序号也画上去比如Frame: 00001234这样回看录屏时能精确判断有没有丢帧。帧序号在生成函数里用静态计数维护每成功生成一帧加一。帧间隔的量化验证我习惯这样打点static uint64_t _vcFrameCounter 0; static CFTimeInterval _vcLastLogTime 0; // 在 _vcCreateVirtualSampleBuffer 返回前调用 _vcFrameCounter; CFTimeInterval now CACurrentMediaTime(); if (now - _vcLastLogTime 2.0) { double fps (double)_vcFrameCounter / (now - _vcLastLogTime); os_log(OS_LOG_DEFAULT, [VirtualCam] fps%.2f frames%llu, fps, _vcFrameCounter); _vcLastLogTime now; _vcFrameCounter 0; }日志里的fps是过去 2 秒的真实注入频率如果它稳定在 29-30说明生成端没有问题如果它跳来跳去说明 timer 队列被抢占或者池子里 buffer 不够用。这段日志会成为你排查所有帧率异常的第一现场。成片验证用AVAssetWriter。让插件在设备上把注入帧写入一个 mp4 文件跑 30 秒后停掉用 Filza 或者scp把文件拉出来在电脑上逐帧检查。这一步很重要因为很多 App 的实时预览会对画面做二次处理你在屏幕上看到的未必是推流端实际编码的画面。写文件时注意先AVAssetWriterInput声明kCVPixelFormatType_32BGRA和我们的输出格式保持一致否则写入会报AVErrorInvalidPixelFormat。做完这些我对这类插件的态度一直是一个习惯交付前必须跑 10 分钟以上的“画面指纹”测试也就是让虚拟帧上带时间戳和帧序号录制整段视频然后按帧号检查是否有重复、乱序、时间戳回退。这比任何代码审查都能更快暴露异步队列里的隐性 bug。希望这种验证思路能帮你在自己的项目里少走弯路也希望这套实现能让你对“iOS 虚拟摄像头插件”这个方向有一个清晰、可复现的起点。本文还有配套的精品资源点击获取
返回列表