iOS录屏引擎实战:基于Broadcast Extension的H.264硬编与50MB体积控制
2026/9/16 17:43:02 网站建设 项目流程

在 iOS 生态里,凡是涉及“录屏”、“直播”、“屏幕共享”这几个场景,开发者几乎都会遇到同一个词:ReplayKit。而真正把屏幕内容作为一个独立扩展进程交到你手上,靠的就是 Broadcast Extension。我这次做的是一个面向全量用户的录屏引擎,团队定的硬性指标是主包体积必须卡在 50MB 以内,任何额外的 SDK 引入都要严格评估。这个项目踩了不少坑,也积累了一整套从工程搭建、核心链路实现到体积瘦身的实战打法,今天把完整过程整理出来。

先说结论:如果你的产品想做到“用户点一下系统录屏按钮,屏幕内容就能实时进入你的编码器”,Broadcast Extension 是当前唯一既合法又可控的方案。它能把 App 主进程的体积增长控制在极小范围内,因为所有重活都发生在系统单独拉起的扩展进程里。你要做的,就是在扩展里完成视频样本的接收、硬编和转发。本文会详细拆解这套引擎的实现思路、关键代码和体积控制手段,适合正在做 iOS 直播 SDK、录屏分享、客服远程协助或白板协作的团队参考。

1. 整体设计思路:为什么录屏引擎必须走 Broadcast Extension

1.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 Extension

2.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") ?? false

App Group 还有一个重要作用是共享文件目录,因为录屏的数据量很大,尤其是要落盘转存时,放到 App Group 的容器里才能让主 App 直接读取。用FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)就能拿到这个共享目录。这个目录的读写权限是系统保证的,不需要额外做沙盒逃逸,也是整套链路里唯一合法的“跨进程通讯”方式。

2.3 扩展 Target 的构建配置与体积基线

创建完扩展后,第一件事就是控制它的构建体积。Xcode 默认的工程设置会对 Debug 和 Release 做很多通用优化,但对于扩展 target,我建议单独收紧:编译优化等级设为-Os(Optimize for Size),Strip 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 bit,1080p ≈ 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 可以在VTCompressionSessionkVTCompressionPropertyKey_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 上实时展示。这样一来,每次线上用户反馈录屏异常,我都能快速拿到一份结构化的运行指标,定位问题的速度比对着系统日志看快得多。这套方式也同步解决了我最头疼的“录到一半扩展被杀”和“丢帧严重”的复现难题,如果你们也在做类似的录屏引擎,强烈建议照抄一份。

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

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

立即咨询