iOS录屏引擎实战:基于Broadcast Extension的H.264硬编与50MB体积控制

发布时间:2026/9/16 17:43:08
iOS录屏引擎实战:基于Broadcast Extension的H.264硬编与50MB体积控制 在 iOS 生态里凡是涉及“录屏”、“直播”、“屏幕共享”这几个场景开发者几乎都会遇到同一个词ReplayKit。而真正把屏幕内容作为一个独立扩展进程交到你手上靠的就是 Broadcast Extension。我这次做的是一个面向全量用户的录屏引擎团队定的硬性指标是主包体积必须卡在 50MB 以内任何额外的 SDK 引入都要严格评估。这个项目踩了不少坑也积累了一整套从工程搭建、核心链路实现到体积瘦身的实战打法今天把完整过程整理出来。先说结论如果你的产品想做到“用户点一下系统录屏按钮屏幕内容就能实时进入你的编码器”Broadcast Extension 是当前唯一既合法又可控的方案。它能把 App 主进程的体积增长控制在极小范围内因为所有重活都发生在系统单独拉起的扩展进程里。你要做的就是在扩展里完成视频样本的接收、硬编和转发。本文会详细拆解这套引擎的实现思路、关键代码和体积控制手段适合正在做 iOS 直播 SDK、录屏分享、客服远程协助或白板协作的团队参考。1. 整体设计思路为什么录屏引擎必须走 Broadcast Extension1.1 录屏方案的取舍私有 API、录屏辅助 App 还是 Broadcast Extension很多人刚接触 iOS 录屏时第一反应是找有没有私有 API 可以直接拿到屏幕内容。这条路确实有人走比如基于某种私有框架去截屏拼接成视频流但代价是审核风险极高而且系统一升级就大概率挂掉稳定性完全没有保障。另一种思路是做“录屏辅助 App”让用户手动去控制中心开启系统录屏但这样你拿不到实时样本只能事后从相册读取文件根本做不到直播级体验。剩下真正能打的就是 ReplayKit 的 Broadcast Upload Extension。这套机制的设计意图非常清晰系统帮你把屏幕画面采集好、按 60fps 的节奏回调给扩展进程扩展只负责编码和转发。它的优势在于三点第一系统级采集性能和兼容性有保障不用自己处理屏幕适配第二扩展进程与主 App 进程隔离主 App 即使被杀掉或进入后台录制链路依然可以继续第三它的体积开销很小扩展 target 只需依赖系统框架完全有可能把整个录屏插件控制在几 MB 以内。复盘这个项目的选型过程我更看重的是“确定性”。私有 API 短期能用但长期是定时炸弹辅助 App 方案体验太割裂。Broadcast Extension 虽然调试起来比普通 App 麻烦但它是一个正规的系统级 API苹果自己也在持续优化这棵大树底下站得住。1.2 50MB 红线从哪来它影响的范围有多大App Store 对蜂窝网络下载 App 的大小限制早先是 100MB后来逐步放宽到 150MB、200MB。但“能上线”和“用户愿意下载”完全是两码事。团队做用户转化分析后发现主包体积每增加 10MB下载转化率都会肉眼可见地往下掉尤其对于非游戏类工具产品用户对包体大小非常敏感。于是定了 50MB 这个红线凡是新功能要引入任何二进制增量都要先做体积预算。录屏引擎最容易毁掉体积预算的地方就是第三方推流 SDK。市面上主流的直播推流库动不动就 5MB 到 20MB 起步再算上音频处理、信令库、网络库录一个屏能把主包撑大一大截。所以我们在设计录屏引擎时定了一条铁律扩展内部只允许依赖系统框架ReplayKit、VideoToolbox、CoreMedia、Network.framework不引入任何第三方二进制。这样做的好处不仅仅是体积还顺带解决了扩展进程的动态库兼容问题因为扩展与主 App 的启动时间和运行环境都更敏感少一个动态库就少一分崩溃风险。实际上50MB 红线的影响不止是“别把东西做太大”它倒逼了整个架构的清晰化。主 App 只做 UI 和业务录屏引擎只做采集编码两者通过 App Group 和本地数据通路协作边界分明扩展包整体可以做到很小。这样反而比随便引一个“全家桶”方案更干净、更好维护。2. 工程搭建实战从零创建 Broadcast Upload Extension2.1 Xcode 添加扩展 Target 的完整步骤搭建录屏引擎的第一步是在 Xcode 里给主工程添加一个 Broadcast Upload Extension target。具体路径是 File - New - Target然后搜索 Broadcast Upload Extension名字按团队规范取我一般叫ScreenBroadcastUploader。这个 target 其实是一个独立的可执行程序系统会在用户触发录屏时把它拉起来运行在单独的扩展进程里。创建完成后Xcode 会自动生成一个继承自RPBroadcastSampleHandler的类和一个Info.plist。关键配置全部藏在Info.plist里最核心的是NSExtension字典下的NSExtensionPointIdentifier它的值必须是com.apple.broadcast-services-upload告诉系统这个 extension 是负责接收样本并上传广播的。还有一个RPBroadcastProcessMode字段我实测下来建议填SampleBuffer这样系统回调的是原始样本缓冲如果选了Application你拿到的是应用级数据可操作空间少很多不适合做视频编码。这里有个非常容易踩的坑Bundle Identifier 的命名。扩展的 Bundle ID 必须以前缀$(PRODUCT_BUNDLE_IDENTIFIER)方式组织官方建议格式是主 App 的 Bundle ID 后面加一段比如com.example.app.ScreenBroadcastUploader。如果前缀不匹配系统在控制中心广播列表里根本不会显示你这个扩展。我们之前有个内部测试包就是因为 Bundle ID 前缀写死错了导致在真机上怎么都找不到入口排查了大半天。2.2 App Group 与共享容器的前置配置扩展进程和主 App 进程是两个独立的沙盒默认情况下谁也看不到谁的数据。要让它们协同工作第一件事就是开启 App Group。在 Xcode 的 Signing Capabilities 里主 App 和扩展 target 都要加上 App Groups capability并且使用同一个 group identifier我的工程里统一叫group.com.example.screenbcast这里有一个非常值得关注的前置问题使用 App Group 前需要明确的是实际开发中如果延迟竞争高很多团队会改用别的方式中转但 App Group 依然是扩展数据落地最稳妥的基础设施。配置好后扩展和主 App 就能通过UserDefaults(suiteName:)来同步轻量状态。比如录屏是否开始、编码参数是什么、推流地址是什么。我在实际项目里就是这样用的// 扩展侧写入 let sharedDefaults UserDefaults(suiteName: group.com.example.screenbcast) sharedDefaults?.set(true, forKey: isBroadcasting) sharedDefaults?.set(streamURLString, forKey: streamURL) // 主 App 侧读取 let sharedDefaults UserDefaults(suiteName: group.com.example.screenbcast) let isBroadcasting sharedDefaults?.bool(forKey: isBroadcasting) ?? falseApp Group 还有一个重要作用是共享文件目录因为录屏的数据量很大尤其是要落盘转存时放到 App Group 的容器里才能让主 App 直接读取。用FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)就能拿到这个共享目录。这个目录的读写权限是系统保证的不需要额外做沙盒逃逸也是整套链路里唯一合法的“跨进程通讯”方式。2.3 扩展 Target 的构建配置与体积基线创建完扩展后第一件事就是控制它的构建体积。Xcode 默认的工程设置会对 Debug 和 Release 做很多通用优化但对于扩展 target我建议单独收紧编译优化等级设为-OsOptimize for SizeStrip Linked Product 设为 Yes同时把 Debug 模式的DEBUG_INFORMATION_FORMAT改成dwarf而不是dwarf-with-dsym这样包体内不会塞太多符号表冗余。另外一个长期有效的做法是检查扩展 target 的 Link Binary With Libraries确保只链接了 VideoToolbox、CoreMedia、ReplayKit 这类系统库不要因为手滑把主 App 的某个 Pod 加进来。这些看似微小的配置直接决定扩展最终是 1MB 还是 8MB。我们在项目初期就出现过扩展自动链接了一个日志库包体重了 2MB 多后来通过构建日志发现手动移除后恢复到了几百 KB 的极优秀水平。3. 核心链路拆解从样本回调到 H.264 硬编3.1 RPBroadcastSampleHandler 的完整生命周期拿到扩展骨架后核心工作就集中在RPBroadcastSampleHandler子类中。它的生命周期非常简洁但每个方法背后都有讲究。broadcastStarted(withSetupInfo:)是扩展启动入口系统会把你在Info.plist和 UI 里传入的启动参数带过来实际 App 端通过RPSystemBroadcastPickerView可以传一个preferredExtension和启动上下文进去然后你可以在这里初始化编码器和网络连接。broadcastPaused()和broadcastResumed()对应控制中心里的暂停/继续操作broadcastFinished()是录制结束必须在这个方法里释放编码器、关闭文件或断开推流。最核心的是processSampleBuffer(_:with:)系统把视频和音频样本都往这里塞你需要根据CMSampleBufferGetFormatDescription里的kCMFormatDescriptionMediaType来区分是视频还是音频。这里的注意事项是不要在这个方法里做耗时操作因为系统调度样本是有节奏的如果你同步阻塞掉帧和内存堆积会非常明显。我的做法是尽量只做必要的前置判断然后把样本分发到并行队列去处理。生命周期上有一个特别容易忽略的点扩展进程是系统临时拉起的它没有常规 App 那种长时间驻留能力。你可以把扩展理解为“临时工”用户一旦停止广播系统几乎立即杀掉进程。所以所有需要持久化的状态都必须通过 App Group 先落盘不能指望扩展内存里保存的东西能活到下一秒。3.2 核心代码用 VideoToolbox 完成 H.264 硬编码视频编码是整个录屏引擎的技术核心。系统回调进来的是CMSampleBuffer里面包裹着CVPixelBuffer我们需要把它喂给VTCompressionSession硬编码成 H.264。为什么用硬编而不是软编首先VideoToolbox 是苹果官方的硬件编码器A9 以上的芯片都支持 H.264 硬编CPU 占用非常低而软编比如 x264 转 arm 版本在 1080p 60fps 的屏幕录制场景下很容易把 CPU 打满手机发热掉电快扩展进程还容易因为资源占用过高被系统干掉。其次硬编是系统能力不占用包体体积这对 50MB 红线来说太关键了。创建编码器时关键参数是分辨率、码率和帧率。由于屏幕录制的输入分辨率是不固定的取决于用户手机型号和方向我选择输出固定为 1280x720 或 1920x1080由 App 端根据 IPad 和 iPhone 来决定。如果输入分辨率比例与编码目标不一致需要用VTCompressionSessionEncodeFrame附带kVTEncodeFrameOptionKey控制 Crop 或缩放。更多时候我们直接拿输入 buffer 的尺寸去创建 session避免转码带来的画质损失。下面是核心的硬编配置代码我加了比较完整的注释var compressionSession: VTCompressionSession? func setupCompressionSession(width: Int, height: Int) { let status VTCompressionSessionCreate( allocator: kCFAllocatorDefault, width: width, height: height, codecType: kCMVideoCodecType_H264, encoderSpecification: nil, imageBufferAttributes: nil, compressedDataAllocator: nil, outputCallback: compressionOutputCallback, refcon: nil, compressionSessionOut: compressionSession ) guard status noErr, let session compressionSession else { return } // 码率设置决定画质和体积的平衡 let bitrate width * height * 3 // 经验公式每像素约 3 bit1080p ≈ 6Mbps VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: bitrate as CFTypeRef) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: true as CFTypeRef) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Main_AutoLevel as CFTypeRef) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: false as CFTypeRef) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: 48 as CFTypeRef) VTCompressionSessionPrepareToEncodeFrames(session) }码率这块我用了经验公式但实际使用时需要根据场景调整。屏幕录制和摄像头直播不一样静态页面大块色块多运动区域小码率可以压得很低但如果是在录游戏画面或地图滑动画面变化剧烈码率太低就会出现明显马赛克。我后来干脆做成动态码率每一分钟检测一次编码输出缓冲占用如果缓冲快满就主动降分辨率或降帧率避免长时间花屏。视频编码输出通过回调函数返回这里会拿到编码后的CMSampleBuffer里面是 H.264 的 Annex-B 格式数据。我一般会把 SPS/PPS 和 IDR 帧单独处理SPS/PPS 可以在VTCompressionSession的kVTCompressionPropertyKey_ProfileLevel初始化后通过VTCompressionSessionCopySupportedPropertyDictionary获取但更常用的做法是解析第一个关键帧的 SampleBuffer把CMSampleBufferGetFormatDescription里的 AVCC 头转换为 Annex-B后续推流时每帧写入0x00000001起始码。3.3 音频样本处理系统声音和麦克风一起收音频这部分比视频更容易踩坑。在processSampleBuffer中如果CMSampleBufferGetFormatDescription的媒体类型是kCMMediaType_Audio说明系统正在向你传送声音。iOS 13 之后ReplayKit 支持采集 App 内部声音 麦克风声音通过控制中心选择扩展后系统会询问用户是否允许屏幕录制及声音采集。语音这一块的常见困惑是用户明明开大了声音为什么接收到的音频流是空的排查发现很多情况下是系统把采集到的音频路由到了扩展进程但扩展并没有做正确的CMSampleBuffer处理。音频的CMSampleBuffer需要直接送给音频编码器比如AudioConverter转成 AAC或者在推流场景下原样封装。建议在扩展启动时设置音频会话为 playback 模式并允许 mixWithOthers否则可能出现“录了但没有声音”或者是“录的时候其他 App 突然静音”的体验。另外提醒一下音频和视频样本的时间戳是独立推进的你必须要做音视频同步。我的做法是取首个样本的CMSampleBufferGetPresentationTimeStamp作为零点后续所有条目都做偏移这样在播放端不会出现音画不同步的尴尬。3.4 数据通路编码数据如何交给主 App 或直接推流录屏引擎做完编码后数据有三种典型去向推给 CDN 直播、发给主 App 做本地录制、或直接存文件。考虑到 50MB 主包红线和扩展进程生命周期我最终采用的是“扩展进程自己完成编码 本地 socket 中转”的方案扩展通过 Network.framework 监听一个本地 TCP 端口主 App 去连接这个端口接收数据。这样做有几个好处编码和推流之间的耦合度低如果未来要扩展成多端同步复用性很强而且 TCP 本地连接不受跨进程沙盒限制的困扰不需要反复读写文件。如果只是想做一个轻量级录屏分享功能完全可以不用 socket而是直接在扩展里写文件片段到 App Group 共享目录主 App 用 reader 去读取并拼接。这个方案更稳代码量也少。两种方案我都实现了最后根据业务需要选择了 socket 方案因为它能支撑实时推流和低延迟场景。数据通路的设计对体积红线也有影响如果选择引入第三方推流库包体可能会直接增加几 MB 到十几 MB而自建一条基于 URLSession 或 Network.framework 的推流通道即使包含回退逻辑扩展部分的总增重大约只在几百 KB 到 1MB 之间。这个权衡在这个项目的 50MB 红线约束下非常明显。4. 体积红线保卫战50MB 内的瘦身策略与实测数据4.1 瘦身前后的体积构成对比我做了一次完整的体积审计把主 App 在引入录屏引擎前后的变化量拆开来看。瘦身前如果直接使用市面上某款直播推流 SDK它的静态库和资源文件大约会为主包增加 8MB 到 15MB 的增量如果依赖了该 SDK 的音频前处理模块增重还会更多。而我们自研的这套录屏引擎在主包侧只增加了一个RPSystemBroadcastPickerView按钮和一段设置页文案扩展 target 本身的二进制只有约 1.2MB编译优化后甚至能到 800KB 以下。从 .app 包里可以看到结构大致是这样YourApp.app/ YourApp // 主二进制 PlugIns/ ScreenBroadcastUploader.appex // 扩展二进制约 1MB Frameworks/ // 主 App 动态库主 App 二进制里其实不需要放任何录屏相关的核心逻辑所有采集编码都在ScreenBroadcastUploader.appex里而这种扩展是系统在需要时才拉起的。这个设计天然把体积增量压到了最小让我在控制 50MB 红线时非常从容。4.2 瘦身手段清单从编译选项到资源裁剪要给读者可以直接用的方法我梳理了几条最实用的瘦身经验第一扩展 target 的OTHER_LDFLAGS里尽量不要加-all_load或-force_load这会让所有符号都进可执行文件体积一下就上去了。第二所有资源文件必须放进 Asset Catalog 并使用 App Thinning这样下载包时只保留当前设备需要的分辨率切片。第三如果扩展里不需要本地化文案删掉多余的.lproj目录有些人会不经意向导一堆默认本地化资源积少成多也恼人。第四关闭 Bitcode 或按需开启Bitcode 其实不会直接让 App Store 分发包变大但会在归档时增加额外处理时间和一些元数据对内部工具类 App 意义不大。最重要的还是控制“引入依赖”的冲动。我们最终在扩展里只系统框架视频编解码、网络传输全部基于系统能力代码很少依赖第三方开源库。这样做不仅是体积考虑也有效降低了供应链安全和版本兼容风险。扩展代码量少了崩溃率也肉眼可见地降下来因为系统扩展和主 App 的异常隔离机制不同更多代码意味着更多状态需要同步。4.3 红线之外的内存与性能预算体积红线之外另一个隐藏限制是扩展进程的内存资源。iOS 对扩展进程有内存上限大约是 90MB 左右不同系统版本和机型略有波动。一旦超过系统不会给你机会直接杀进程。录屏编码是很吃内存的操作尤其是像素缓冲区和编码器的内部缓冲积压多了很容易触顶。我的经验是在processSampleBuffer入口就做采样帧率控制比如用户屏幕是 60fps但推流目标是 30fps就在扩展里做帧丢弃逻辑丢弃非关键帧保留关键帧。帧率控制的核心逻辑很简单维护一个时间戳如果当前帧与上一帧发送时间差不到 33ms就直接掉头返回不出编码器。private var lastVideoPTS: CMTime CMTime.zero func handleVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) { let pts CMSampleBufferGetPresentationTimeStamp(sampleBuffer) if lastVideoPTS ! CMTime.zero { let diff CMTimeSubtract(pts, lastVideoPTS).seconds if diff 0.033 { return // 丢弃这一帧控制在 30fps } } lastVideoPTS pts // 继续转编码器 }像这类主动降帧的机制不只是保体积更是保扩展进程的存活。毕竟用户正在录屏主 App 是可以自由操作的但如果录到一半扩展被杀体验就完全毁了。5. 踩坑实录录屏扩展最常见的问题与排查思路5.1 从“点了没反应”到“黑屏红条”八问录屏扩展的调试体验比普通 App 痛苦得多因为断点、日志都不那么顺手。我整理了项目里真实遇到的 8 个高频问题做成速查表供参考问题现象可能原因排查方式控制中心或 Picker 里看不到扩展Bundle ID 前缀错误、NSExtensionPointIdentifier 写错检查 Info.plist 和 target 的 Bundle ID用户点击后扩展一直不启动App Group 未正确配置扩展启动时崩溃看设备日志Console.app 选择扩展进程录屏后没有任何视频帧回调用户没有开启屏幕录制权限或选择了“App 内部录制”而非扩展确认系统提示里选择的是你的扩展画面出现花屏/绿屏像素缓冲时序问题发送缓冲后未等编码器处理完就释放检查 buffer 的引用计数和异步处理的拷贝只有声音没有画面音频被识别成视频样本或视频样本分发逻辑出错打印媒体类型确认走对了分支画面模糊马赛克严重码率太低或关键帧间隔太长提高 AverageBitRate降低 MaxKeyFrameInterval录到一半扩展被系统杀死内存超限或 CPU 占用太高控帧率、清晰度检查不用的缓冲主 App 读不到扩展写入的数据App Group 容器目录权限或路径错误打印共享目录路径确认主 App 能访问这里要尤其提醒“绿屏”问题。屏幕录制样本的CVPixelBuffer在扩展进程里生命周期是受系统控制的如果你把 buffer 直接用异步线程往后传很有可能系统已经复用或释放这块内存画面就会出现莫名的绿色条纹。正确做法有两个一是用CMSampleBufferCreateForImageBuffer做深层拷贝二是把 buffer 的引用CFRetain住明确交给编码器后再释放。纠结这个细节的时间不会白费上线后稳定性差异非常明显。5.2 调试技巧与上线前检查单扩展的调试不像普通 App 那样一键 Run最高效的方式是在 Xcode 里把 Scheme 选到扩展 target然后选择“等待扩展启动”再回到真机上触发控制中心录屏。这样 Xcode 就可以直接捕获扩展进程的运行日志、断点和内存信息。这个方法也是我在项目中期才发现的此前一直用旧模式撞运气浪费了不少时间。上线前的检查单也很重要我每次发版前都会做这几件事真机检查 Picker 能正常弹出并启动扩展录制 30 秒视频验证音画同步切到后台再切回来确认扩展不崩在低端机型比如 iPhone SE 2上试一次观察内存和帧率最后检查主包体积看是否还保持在 50MB 红线内。不要只依赖模拟器因为 ReplayKit 在模拟器上的行为跟真机差别很大。要特别注意的是这类录屏功能对隐私合规要求很高。系统在用户点下“开始广播”时会展示明确的系统提示并给出红色状态栏你要做的是在 App 内也同步展示清晰的说明文字告诉用户录屏会采集哪些内容、用途是什么以及如何停止。不要试图隐藏这些信息系统层的设计本来就是为了让用户知情。开发完之后我最大的体会是录屏引擎并不是一个你写完就能放着的功能它对系统版本、设备型号、网络环境都很敏感需要有持续监控和应急降级预案。好在 ReplayKit 这套机制的边界足够清晰只要你把“采集编码”和“业务”彻底分开把体积和内存预算前置考虑调试起来就会顺利很多。最后再分享一个实用小技巧扩展里尽量不要直接打印大段日志到 os_log因为扩展进程的日志量一旦多了反而会影响系统调度。我会在主 App 里做一个“自检模式”把扩展的启动时间、关键回调时间点和帧率全部写到 App Group 的共享目录主 App 在 UI 上实时展示。这样一来每次线上用户反馈录屏异常我都能快速拿到一份结构化的运行指标定位问题的速度比对着系统日志看快得多。这套方式也同步解决了我最头疼的“录到一半扩展被杀”和“丢帧严重”的复现难题如果你们也在做类似的录屏引擎强烈建议照抄一份。