尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Broadcast Extension录屏引擎开发:内存红线与性能调优
1. 项目背景与方案选型1.1 为什么偏偏是 Broadcast Extension做 iOS 录屏功能90% 的开发者第一反应是直接用RPPreviewViewController录个屏、播放、然后让用户自己手动保存。这种方案说白了就是系统帮你录好你只负责展示看起来最省事。但一旦走进真实业务场景——比如游戏对战直播、客服远程协助、教学白板回放——这条路立刻走不通。因为RPPreviewViewController拿到的是系统压缩好的成品视频压根不给你原始数据流你想实时编码、加水印、混音、分片上传全部没门。这时候就必须请出Broadcast Upload Extension也就是标题里说的 ReplayKit Broadcast Extension。从 iOS 12 开始系统允许开发者创建一个独立的 Extension 进程专门接收屏幕捕获的原始 CMSampleBuffer然后再通过进程间通信传递给主 App 做后续处理。这里有个关键点这个 Extension 和主 App 不在一个进程里系统会给它一个独立的内存预算。我当年第一次看到自己 Extension 的内存水位在启动瞬间冲到 80MB、然后被系统直接 kill 掉时才意识到“50MB 红线”这个概念不是开玩笑的。我之所以把这个项目定位成“引擎”而不是“录屏 demo”是因为你需要把采集、编码、传输、控制信令这四层全部打通才能真正扛住生产环境的压力。单写一个 SampleHandler 拿数据、AIRDrop 出去那只是皮毛。1.2 50MB 红线到底卡在哪里很多新手会有个误区以为 Extension 挂了主 App 还在所以无所谓。但实际上你录屏的分辨率越高视频编码产生的中间缓冲就越大。1080p 60fps 的裸 BGRA 数据一帧大概就是 8MB 左右现代 iPhone 屏幕输出远不止这个分辨率如果一帧接近 16MB你两帧缓冲就干到内存红线外部去了。系统的内存治理机制是分级的先是 Memory Warning再是 Jetsam直接杀进程。Extension 属于“可牺牲进程”优先级比主 App 低得多主 App 在低内存状态下都可能收到警告后还允许申请内存但 Extension 一旦接近 50MB系统几乎不做任何预告就把它杀了。更坑的是这个 50MB 不是一个绝对硬上限系统会结合设备物理内存、当前压力动态调整。iPhone 7 这种老设备实际可用额度可能只有 30MBiPhone 14 Pro 可能放宽到 80MB。但你的测试机如果是顶配千万别以为真机发布环境也一样宽松。所以我做这个项目时第一原则就是Extension 进程内不做任何重活所有能丢给主 App 的一律丢出去。这听起来像废话但很多人就是败在“顺手在 Extension 里做了点优化”上。2. ReplayKit 架构与核心机制2.1 Broadcast Extension 的完整工作流程先花点时间把整个链路讲透。当用户在控制中心点击“屏幕录制”并选择你的 Extension 时系统会做这几件事唤起你的 Extension 进程调用broadcastStartedWithSetupInfo(_:)。系统将屏幕捕获的像素数据以CMSampleBuffer形式持续回调给processSampleBuffer(_:withType:)。Extension 对每一帧做尽可能轻量的打包然后通过 Datagram Connection 或 File Coordinator 把数据传给主 App。主 App 收到数据后做真正的编码、录制、推流、上传等操作。用户点击停止或主 App 主动调用finishBroadcast()触发broadcastFinished()回调。这里面有一个特别容易忽略的机制ReplayKit 给 Extension 的 sample buffer 是代码帧通常已经是kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange即 NV12而不是屏幕截图常见的 BGRA。NV12 的好处在于 YUV 数据比 RGBA 小了整整一半iOS 的 VideoToolbox 硬编码器又对它天然友好。所以千万别在 Extension 里做 RGB 到 YUV 的转换——系统已经给到了最优格式你只要保证传递过程中不强制转换就行。在processSampleBuffer里你会拿到RPSampleBufferType.video、audioApp、audioMic三种类型。视频帧是高频数据音频帧分 App 声音和麦克风声音两路这两路音频的时序和视频帧不是同步的如果直接聚合就会导致音画不同步。这也是为什么后续要建立自己的时序对齐逻辑。2.2 数据传递通道的选择不要用 UserDefaults 传帧既然 Extension 和主 App 隔离数据就得走 IPC。有三种可选路径NSUserDefaults App Group通常放进 App Group 的 UserDefaults 适合传小配置比如分辨率、码率、录制开关状态。不适合传视频帧因为每次写入都会落盘帧率上去了会把闪存写废速度也根本跟不上。CFMessagePort是双向通信最爽的方案延迟低可以作为信令通道。但大块数据传递时出现消息堆积的概率不小而且它的机制要求主 App 必须活着并且常驻线程处理消息否则会阻塞。Socket 或 NSFileCoordinator可以用 Unix Domain Socket 建立本地连接把视频数据通过 socket 一帧帧发送给主 App 的监听端口。这块需要自己做连接管理、粘包拆包处理。我的选择是CFMessagePort 共享内存文件映射的混合方案小信令走 CFMessagePort视频帧用 Datagram Connection 走二进制分帧传输。不过如果你不想引用太底层的东西单用 GCDWebServer 或 CocoaAsyncSocket 也能实现。这里关注的重点不是选哪个库而是你的主 App 必须常驻后台并保持一个高优先级的接收线程否则 App 被挂起后 socket 无人收数据内存缓冲在 Extension 就会一直堆积迟早触发 50MB 红线。提到后台保活得说明一点iOS 对 Extension 传输数据给主 App 的进程保活给了特殊优待——当 Extension 处于活跃录制状态时主 App 会被系统判定为需要持续前台工作但这不是永久的。长时间录制超过几分钟后用户如果切走 App主 App 可能被挂起这时 Extension 依然在采集。我应对办法是在主 App 里注册endBroadcast的兜底逻辑一旦发现接收超时或 socket 断开自动停止录制避免在后台无限耗电。3. 实操从零搭建录屏引擎3.1 创建 Extension 目标的正确步骤用 Xcode 新建一个 Target选择 iOS 里的Broadcast Upload Extension系统会给你生成一套模板SampleHandler.swift和一个 Info.plist。这里需要正确配置的地方有三处。Info.plist 中的 NSExtension 配置。系统通过NSExtensionPrincipalClass指定主类但最重要的键是RPBroadcastProcessMode。默认不配置是RPBroadcastProcessModeSampleBuffer也就是按帧回调。如果配成RPBroadcastProcessModeBinary系统会直接把编码好的 HLS 切片传给你虽然省了编码但也失去了实时处理帧的可能性。做引擎必须选 SampleBuffer 模式因为我们要自研编码和分发。设置 App Group。在 Signing Capabilities 里为 Extension 和主 Target 都添加同一个 App Group。这是 Extension 访问共享 UserDefaults 的钥匙如果你的通讯走 socket可以不用 App Group只靠 CFMessagePort 通信但我还是建议加上因为兜底逻辑需要它。修改broadcastStartedWithSetupInfo。模板方法里有一个 setupInfo 参数实际上它总是收到一个空字典或系统预设的信息没有太多可用的用户自定义参数。你可以在主 App 里用RPSystemBroadcastPickerView唤起 Extension 之前通过 App Group 的 UserDefaults 预先写入配置参数。这样一来 Extension 启动后可以立刻读取这些参数来决定分辨率、码率、开启哪些声道省去硬编码。3.2 视频帧的轻量化处理与分帧传输processSampleBuffer是每个 Extension 真正的心跳函数。在这个函数里每一帧都会以 60fps 甚至 120fpsProMotion 设备的频率被调用。如果你在这里做重 I/O 或者同步操作很快帧会被丢弃甚至导致 watchdog 超时。我实测下来一个稳定方案的核心流程是第一步拿到 CMSampleBuffer 后立刻用CMSampleBufferGetFormatDescription获取编码格式和尺寸缓存它不要每帧都调。第二步调用CMSampleBufferGetPresentationTimeStamp拿到 PTS。这里有个关键点屏幕录制视频帧的 PTS 和音频帧的 PTS 来源不同但都是基于主时钟的。为了避免音画不同步我统一使用视频帧的 PTS 作为时间基准音频帧到达后在会被添加进 AVAssetWriter 前做一个“就近匹配”寻找与当前音频帧时间最接近的视频 PTS然后把当前音频帧的 PTS 修正为这个视频 PTS。第三步用硬编码器编码这里我用的是VTCompressionSession。但要注意如果把编码也放在 Extension 里内存和 CPU 开销都很大50MB 红线很难保住。所以我最终的方案是Extension 只做采样和转发不编码。把原始的 NV12 CMSampleBuffer 做一次内存拷贝后理论上这块数据不需要保留因为 CMSampleBuffer 在函数结束后会被释放但传给 socket 需要自己管理生命周期。所以我在 Extension 内部维护了一个 2-3 帧深度的环形缓冲池把 CVPixelBuffer 的地址通过CVPixelBufferPool直接锁定时传递。这个池子里的缓冲如果超过 5 帧还没被 socket 读走就直接丢旧帧保证 Extension 内不积压。关于 socket 分帧我定义了一个非常简单但严格的分帧协议头 4 字节为帧类型4 字节为数据长度然后接 N 字节数据末尾跟 4 字节 CRC32。这样主 App 接收时逐个数据块解析出来拼成 CMSampleBuffer 后再交给 AVAssetWriter 或 VideoToolbox。3.3 后台编码与写入的正确姿势主 App 收到 Extension 传来的帧后就可以在 App 内创建AVAssetWriterInputPixelBufferAdaptor把裸帧填充进去。这个过程中有一些能直接影响稳定性的细节。AVAssetWriter的输入必须是编码器接受的格式。你可以设置expectsMediaDataInRealTime true这样写操作会走实时路径确保帧不会被过度缓冲。如果设为默认 falsewriter 会尽可能多地缓冲帧导致长时间录制时内存飙到几百 MB——这是我踩过最深的坑之一。还有一个常被人忽略的点主 App 为了在录屏期间保持活跃需要在Info.plist里声明UIBackgroundModes包含audio否则 App 切后台后立刻挂起socket 没人收数据。但只声明 audio 也不行还需要在 Extension 里用AVAudioSession初始化一条后台录音/播报会话系统才真正给你持续后台运行的权利。这里的安全性和合规性都有点微妙不是让你绕过用户意图而是务必明确告诉用户当前正在录屏并在界面显著位置展示录制状态。编码参数直接决定画质和内存的平衡。我按照目标场景给过两组参数实测非常稳参数游戏直播场景教学录屏场景分辨率原始分辨率1280x720帧率6030码率8 Mbps3 MbpsGOP3030编码器H.264 硬编H.264 硬编如果进一步追求低延迟可以调低 GOP 甚至开 B-frame 关闭。记住码率定得太低黑场和水印场景下会马赛克满天飞定得太高在 LTE 网络下上传容易积压。4. 性能调优与兼容性处理4.1 控制内存峰值对象复用与 Metal 绕行核心瓶颈永远在 CVPixelBuffer 的分配和释放。系统每帧送一个全新的 CVPixelBuffer如果你不在 Extension 里做任何处理就转发内存分配峰值相对稳定。但我试过在 Extension 里做水印叠加直接用了 CoreGraphics 的CGContextDrawImage结果每帧都创建了位图上下文内存瞬间多出 20-30MB直接被 Jetsam。正确做法是用 Metal 做离屏渲染但这种做法需要初始化 Device、CommandQueue、纹理等对整个启动速度影响很大。后来我做了一个妥协凡是需要叠加 UI 的录屏场景把叠加过程放到主 App 侧由主 App 在解码后合成的时机统一做。Extension 只负责扎扎实实地做一个“搬运工”。这样一来内存虽然还是高一点但主 App 的内存额度比 Extension 宽裕得多。另外一个重要技巧是“内存水位监控”。在 Extension 里用mach_task_basic_info查自己的物理内存用量每 1 秒记录一次。如果发现水位稳定在 35MB 以上就主动降低帧率或自动切换为 30fps防止系统杀进程。func currentMemoryUsageMB() - Double { var info mach_task_basic_info() var count mach_msg_type_number_t(MemoryLayoutmach_task_basic_info.size / MemoryLayoutnatural_t.size) let kerr withUnsafeMutablePointer(to: info) { $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self(), task_flavor_t(MACH_TASK_BASIC_INFO), $0, count) } } return kerr KERN_SUCCESS ? Double(info.resident_size) / 1024.0 / 1024.0 : 0 }通过上面这段代码实时观察我发现内存和帧率并不完全成正比真正的影响因素是 socket 发送缓冲区的堆积程度。一旦主 App 处理不过来比如编码线程阻塞socket 发送缓冲就会增长Extension 内核占用立刻攀升。所以核心不是降帧率而是降发送缓冲区的积压上限超过阈值就丢弃旧帧换一个概念叫“丢帧策略”。4.2 各 iOS 版本的行为差异iOS 12 到 iOS 18ReplayKit 的接口基本没变过但系统内部行为差很多。这正是“兼容性处理”需要重点关注的地方。iOS 14 及之前Extension 启动后默认使用全屏录制控制中心显示红色录制状态栏此时如果主 App 被用户切到后台系统严重倾向杀掉 Extension必须在录制期间强制主 App 前台。iOS 15 之后加入了 Control Center API用户可以自定义扩展图标和颜色。同时系统开始直接提供 120fps 支持但如果你不做任何设置默认还是 60fps。这个很坑人120fps 的帧流进入 60fps 的 pipeline时间戳错乱AVAssetWriter 会生成很多重复帧。一定要在启动时读取CADisplayLink的 maximumFramesPerSecond 并动态调整 encoder 的 frame rate。iOS 16 以后Apple 在 ReplayKit 上加入“麦克风 vs App 声音”更精细的分离选项。但你在处理音频时要同步检查AVAudioSession的 recordPermission 状态。如果用户没有授权麦克风ReplayKit 会直接禁用audioMic流但不会崩溃——这容易让人误以为麦克风坏了。iOS 17一个比较隐蔽的改动是 Extension 的内存分类变了50MB 红线的判定策略更严格。在 iOS 17 上我明显感受到卡顿和崩溃频率增多即便内存水位没有达到 50MB 也会被杀。后来查资料发现新版系统把 Extension 的空闲内存回收做了激进化处理只要你长时间处于低活跃状态系统直接回收。我的对策是在录屏期间Extension 每隔 1 秒向主 App 发送一次心跳包保持活跃防止被系统判定为“不活跃进程”。5. 常见问题与排查技巧实录5.1 Extension 刚启动就被反复杀死症状是用户点击开始录屏不到 1 秒屏幕闪烁一下又回到控制中心Extension 直接被系统拒之门外。最常见的原因是 Extension 无法访问 App Group 下的共享 UserDefaults 或者配置非法。排查思路是在broadcastStartedWithSetupInfo里加一个启动日志写入用 Console.app 看系统日志或者用共享 UserDefaults 写一个启动标记主 App 读取这个标记来判定 Extension 是否启动成功。如果日志正常但进程还是被杀看崩溃信息里的异常类型。如果看到EXC_RESOURCE RESOURCE_TYPE_MEMORY就是内存问题如果是0xbaaaaaad则基本是看门狗超时。往往很简单的原因processSampleBuffer里做了同步磁盘写入导致线程阻塞超过系统阈值。5.2 录屏前几秒一直黑屏尤其是首次调用AVAssetWriter写入时如果 startWriting 后你没等到startSessionAtSourceTime成功就直接append帧就会丢帧或产生黑屏。正确做法是等 writer 的status .writing后再 append。另外我遇到过一种情况在主 App 收到第一帧之前用户就已经开始操作屏幕这一小段时间的屏幕内容系统不会补给你导致视频开头的黑场。要尽量避免可以在 Extension 初始化完成前不发开始指令并等主 App 发出“准备完成”信号后才让用户看到可交互的 UI。5.3 音画越来越不同步这个问题的排查路径比较复杂。音频流的 PTS 和视频流的 PTS 来源于不同的时钟域不能直接对齐。我的做法是记录一路时钟的起始时间然后对所有帧做相对时间戳换算。比如在 Extension 中记录第一帧视频 PTS 为startVideoPTS第一帧音频 PTS 为startAudioPTS之后的每个帧 PTS 都减去起始 PTS这样两边就都是相对于 0 开始的。真正造成音画不同步的另一个原因是网络传输延迟不均。当你用 socket 把帧发给主 App 时音频帧和视频帧可能走不同的发送队列网络拥塞时视频帧被延时发送音频帧照常到达主 App 就只能先得到音频。反推就能理解必须把音频和视频帧放入同一个发送队列保证它们在排队顺序上尽量接近原始时序。实际中我用的是“音视频交错优先”策略缓存最近的 5 帧视频当音频帧到达时与这 5 帧中时间最接近的一起打包发送这样延时抖动最小。5.4 录屏结束后的最后几秒缺失用户点击停止后Extension 的broadcastFinished会立即触发主 App 也会收到 socket 断开信号。但此时 Extension 内部 socket 缓冲里可能还有几帧未送出的数据。解决方案是在主 App 收到断开信号之后不立刻结束 writer而是等待 500ms同时主动发送一个“Flush 指令”给 Extension让 Extension 把缓冲区的剩余帧发完后再在 socket 里写入一个 End-Of-Stream 标记。主 App 收到这个标记再调用markAsFinished这样结尾就不会突兀少了半秒。5.5 Extension 和主 App 的参数不一致最容易出问题的点就是主 App 在启动 Extension 前写入 App Group 的分辨率是 1080p码率是 8Mbps但 Extension 启动后写的是 720p、3Mbps。这种参数不一致会导致录屏文件要么模糊不清要么大得吓人。所以我在主 App 和 Extension 之间建立了一个“配置签名”机制——每次写入配置时带上 CRC 校验码Extension 和主 App 各自记录当前生效配置的签名不一致就直接重新读取配置并同步。宁可花个几十毫秒也要保证前后一致。5.6 小技巧录屏期间的内存画像我整理了不同设备在相同参数下 Extension 的典型内存占用供你参考定位设备延伸进程基线峰值主 App 增量缓解建议iPhone 812MB24MB35MB强制 30fpsiPhone 1114MB28MB40MB锁 720piPhone 13 Pro18MB35MB58MB开启 GPU 加速iPhone 15 Pro20MB38MB64MB默认 1080p这些数值会随系统版本浮动但有个规律基线越高、剩余空间越小越要谨慎提升参数。当你做内存画像时别只在 debug 模式测要打 Release 包用 Instruments 的 Allocations 和 VM Tracker 跑一遍真机。6. 启发与扩展思考这个项目做完之后我对 iOS 录屏引擎的整个设计有了一个新的认知录屏不只是“捕捉屏幕”而是“处理高吞吐实时音视频的迷你流媒体系统”。ReplayKit 的封装神神秘秘但只要你理解它的底层机制——进程隔离、采样回调、IPC、编码写入——就抓住了主动权。如果你只是需要一个简单录制那直接用系统内置的 ReplayKit 录制即可。但如果你要做直播、远程协助、游戏观战、屏幕标注那么这套 Broadcast Extension 引擎方案才是真正能落地的底座。我目前还在迭代的方向是把播放端从“录制成 MP4”改为“直播拉流”也就是把 Extension 采集的数据通过 WebRTC 或低延迟 RTMP 推出去。要注意的是因为 Extension 和主 App 之间用的是 socket 通信这套数据链路理论上是可以直接复用的只需取代 AVAssetWriter换成编码 推流模块即可。工程上会复杂不少但底层的坑我已经替大家趟过一遍了。最后再分享一个小技巧调试 ReplayKit 的 Extension 比调试普通 App 麻烦因为它是一个独立进程。你可以在 Xcode 中选择Scheme - Edit Scheme - Run - Executable里选择你的 Extension 来直接 attach 调试但更快的办法是通过 Console.app 看系统日志。我每次修改代码后都会在 Extension 入口写一条明显的日志用print输出到统一日志里调试效率直接翻倍。这套引擎整个下来踩了太多坑能提前看到我这篇总结的读者应该能少走大半个月弯路。
RELATED

相关推荐

云原生PACS架构设计:医疗影像上云的落地实践

云原生PACS架构设计:医疗影像上云的落地实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/20 1:29:03
TI-ADC非线性失配的MP建模与FPGA实时校正

TI-ADC非线性失配的MP建模与FPGA实时校正

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/20 1:29:03
first-contributions 实践指南:从 Fork 到 Pull Request 完成你的第一次开源贡献

first-contributions 实践指南:从 Fork 到 Pull Request 完成你的第一次开源贡献

文档教程开源治理 【免费下载链接】first-contributions 🚀✨ Help beginners to contribute to open source projects 项目地址: https://gitcode.com/gh_mirrors/fi/first-contributions 点击查看 免费下载 导读:本文基于 first-contribut…

📅 2026/9/20 1:24:03
MORE NEWS

更多资讯

📰

通达信凹底淘金战法:主图副图选股源码与实战调参指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

recommenders 通用工具模块全解:从相似度计算到 GPU、Spark、TF 的一站式工具箱

人工智能机器学习深度学习 【免费下载链接】recommenders Best Practices on Recommendation Systems 项目地址: https://gitcode.com/gh_mirrors/re/recommenders 点击查看 免费下载 导读 在 Recommendation Systems 开源项目 recommenders 中,recomm…

📰

Enzyme ReactWrapper 的 `.render()` 方法:把已挂载组件转为 HTML 后用 Cheerio 断言

测试前端 【免费下载链接】enzyme JavaScript Testing utilities for React 项目地址: https://gitcode.com/gh_mirrors/en/enzyme 点击查看 免费下载 本指南围绕 Enzyme 中 ReactWrapper.prototype.render() 方法展开,它能把当前 ReactWrapper 所包裹单…

📰

GD32H759工业HMI开发:SDRAM、SDIO与触摸屏调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

BIOS/UEFI固件原理与实战:从启动协议到Win11兼容性调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

QQ空间历史说说完整导出指南:三步把十年的说说搬进本地文件

QQ空间历史说说完整导出指南:三步把十年的说说搬进本地文件 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 翻空间找一条几年前的老说说,图片早就变成灰色裂开的…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬