尧图精选

ReplayKit录屏扩展内存优化:50MB预算下的存活指南

🕒 发布时间:2026/9/16 23:52:56 📁 来源:尧图网络
在控制中心按下“开始录制”的那一刻ReplayKit 会立刻拉起一个全新的进程也就是 Broadcast Upload Extension。这个进程从出生那天起就被系统关进了一个内存预算只有 50MB 上下的“隔离舱”而它要做的事却是接住屏幕上的每一帧画面、实时压缩成 H.264 视频、再把数据包推到网络服务器上。三件事里任何一件做崩了都不是卡顿或者降帧这么简单而是被系统直接判死刑——录屏在用户面前无声无息地断掉。我最早做录屏引擎时第一次压力测试就是这么翻车的一边开着高刷游戏一边点录屏二十多秒后扩展进程原地阵亡。查了 Jetsam 日志才意识到我持有的帧数据早就把 50MB 的预算烧穿了。这篇文章不聊概念只聊怎么把一个 ReplayKit Broadcast Extension 的内存占用稳稳压在这条红线以内。适合正在做直播推流、录屏回放、屏幕共享类 App 的 iOS 开发同学尤其是那种对扩展进程认识还停留在“能用就行”阶段的团队。1. 50MB 红线扩展进程里的一次“生死判决”1.1 为什么系统对 Broadcast Extension 这么抠门先明确一个事这里的 50MB 不是安装包体积而是扩展进程的内存预算。两者完全不是一个量级的问题。iOS 对普通 App 进程的内存限制其实很宽松主流机型上主 App 往往能用上几百 MB跑复杂的渲染、加载大图都没什么感觉。但 App Extension 是另一套规则Today Widget、Share Extension、Broadcast Upload Extension 这类进程系统给的内存配额都要小得多。尤其是录屏扩展它经常以“后台进程”的身份存在而 iOS 的资源调度永远优先保证当前前台 App 的流畅度所以录屏扩展很容易在被系统判定“吃相难看”时被清掉。这里有个关键的机制叫 Jetsam也就是 iOS 的内存回收器。它持续盯着系统里所有进程的内存占用一旦某个进程的 footprint 超过阈值或整机内存吃紧它就会挑一个“不重要的进程”杀掉优先杀扩展。扩展为什么排在最前面因为在系统看来主 App 挂了用户会立刻发现但录屏扩展挂了顶多就是录屏中断用户还可以再点一次重新录。牺牲扩展换取前台体验对 iOS 来说是笔划算的买卖。所以在做 ReplayKit 录屏引擎时我心里始终有一条默认准则这个扩展进程随时可能只有 50MB 可用。所有设计都得按照这个预算来做不能指望系统多分一点。1.2 屏幕一帧到底有多大先把账算明白很多第一次做录屏的人对“一帧图片有多大”完全没有概念。我先给几个常用数字你感受一下。如果是 1920x1080 的全高清屏系统给到的视频帧通常是 BGRA 格式也就是每个像素 4 个字节。那这一帧的裸数据大小就是1920 × 1080 × 4 8,294,400 字节 ≈ 8.29MB如果是 1280x720 的分辨率BGRA 单帧是1280 × 720 × 4 3,686,400 字节 ≈ 3.69MB再换算一下一个 50MB 的预算如果什么都不优化理论上只够缓存约 6 张 1080p 的 BGRA 原始帧。但实际使用时还得算上编码器、网络缓冲、音视频 metadata、代码本身占用的内存所以哪怕只缓存两三帧原始画面就已经很危险了。这就是为什么录屏引擎的优化本质上是一场“尽量少持有帧数据”的战斗。理解了每个 buffer 几 MB 起步再看后面所有优化手段逻辑就顺了。2. 录屏链路逐段拆解从 SampleHandler 进来到 H.264 输出2.1 一次录屏会话的完整生命周期ReplayKit 的 Broadcast Upload Extension 实现起来并不复杂系统会通过RPBroadcastSampleHandler这个类把事件回调给你。核心方法就四个import ReplayKit class SampleHandler: RPBroadcastSampleHandler { override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { // 用户点击了控制中心的录屏按钮扩展进程被拉起 // 在这里读取 App Group 中的配置、创建编码器、建立网络连接 } override func broadcastPaused() { // 用户从控制中心点了暂停 // 停掉编码器输入但保持网络连接 } override func broadcastResumed() { // 继续录屏 } override func broadcastFinished() { // 用户结束录屏或扩展被系统终止前调用 // 关闭编码器、断开网络、释放所有引用 } override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { // 系统不断把屏幕帧和音频帧送到这里 switch sampleBufferType { case .video: // 视频帧走缩放 - 编码 - 推流 break case .audioApp: // 应用内部音频比如正在播放的视频声音 break case .audioMic: // 麦克风采集的声音 break unknown default: break } } }这套 API 的调用频率非常高。视频帧通常 30fps 甚至 60fps再加上两路音频processSampleBuffer在忙碌的时候一秒会被调用上百次。所以它里面的逻辑必须极其轻量任何一处稍重的操作累积起来都会变成内存压力。2.2 采集侧与编码侧的内存消耗对照为了把内存分配讲明白我把整个录屏引擎里涉及内存的部分拆成了四块分别算一下账。环节主要内存消耗典型数据量采集侧系统传入的 CMSampleBuffer/CDPixelBufferBGRA 原始帧1080p 每帧约 8.29MB编码侧VideoToolbox 内部缓冲、色彩转换、编码 Pipeline高分辨率下可能额外占 10-20MB音频侧音频 CMSampleBuffer 暂存每段音频很小几十 KB 级网络侧发送队列、RTMP 打包缓冲、Socket 缓冲弱网时可无限增长从表格能看得很清楚真正会撑爆 50MB 预算的是“采集侧”和“网络侧”。编码器虽然在内部也会吃内存但只要你用的是硬编码VideoToolbox 通常能把内部缓冲控制在合理范围内。反而是我们自己在工程上不注意多缓存几帧 BGRA、多往网络发送队列里塞几个包内存就直接失控。所以做这条链路的时候我心里始终有根弦内存不是“攒着”的东西而是“流过”的东西。数据从采集进来到变成编码后的网络包送出去生命周期越短越好。3. 把内存峰值按下去编码、缩放与缓冲三场硬仗3.1 用 VTCompressionSession 的 RealTime 属性和 Buffer Pool 复用空间视频编码这块我强烈建议直接用 VideoToolbox 的VTCompressionSession而不是引入 FFmpeg 之类的重库。硬编码效率高而且内存更可控。创建编码器时有几个参数是决定内存表现的关键var session: VTCompressionSession? let status VTCompressionSessionCreate( allocator: kCFAllocatorDefault, width: 1280, height: 720, codecType: kCMVideoCodecType_H264, encoderSpecification: nil, sourceImageBufferAttributes: nil, compressedDataAllocator: nil, outputCallback: compressionCallback, outputCallbackRefCon: context, compressionSessionOut: session ) // 打开实时模式这是内存控制的灵魂 VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) // 编码参数1280x720 30fps平均码率 2.5Mbps VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Main_AutoLevel) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: 2_500_000)) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: 30)) VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: 60))这里最容易被忽略的是kVTCompressionPropertyKey_RealTime。把它设为 true等于告诉编码器“我们是实时流不要攒帧不要为了压缩率优先而在内部做大量缓存。”打开之后VideoToolbox 会调整自己的编码策略减少内部的帧缓存内存表现会好很多。代价是画面压缩率会稍微低一点码率波动会大一点但对于实时录屏来说完全值得。另外还要注意VTCompressionSession内部会有一块 Buffer Pool用来循环复用编码用的 Pixel Buffer前提是你传给它的 PixelBuffer 是它能识别的类型并且不要长期持有。如果你每次传入一个新 buffer编码器被迫复制一份到自己的池子里那内存开销立刻就会翻一倍。3.2 别把全尺寸 BGRA 当宝贝让数据流快速缩小前面算过1080p BGRA 一帧就是 8.29MB。如果直接把这样的大块头丢给编码器哪怕 VideoToolbox 内部做了颜色转换整个过程的内存压力和 CPU 开销也都不小。更聪明的做法是在喂给编码器之前先把画面缩到目标分辨率并把像素格式转换到更紧凑的 YUV 格式。缩放的推荐方案是用 vImage 或者 CoreImage。CoreImage 渲染时会有 GPU 配合内存表现得看机型搞不好反而增加虚拟内存开销。我的经验是在这种扩展进程里老老实实用 vImage 做 CPU 缩放更稳。具体流程是创建一个目标尺寸的 CVPixelBufferPool提前池化 720p 的输出 buffer。从processSampleBuffer里拿到系统原始 buffer 后立刻通过 vImage 缩放到目标分辨率。缩放结果从 PixelBufferPool 取出喂给VTCompressionSessionEncodeFrame编码完成后立刻释放。var destinationBuffer: CVPixelBuffer? CVPixelBufferPoolCreatePixelBuffer(nil, pixelBufferPool, destinationBuffer) // 假设 source 和 destination 都准备好了调用 vImage 缩放 vImageScale_ARGB8888(sourceBuffer, destinationBuffer, nil, 0) // 缩放好了直接编码编码完不要持有 destinationBuffer这个流程里有一个非常关键的心理认知PixelBuffer 是资源不是数据。用完必须立刻放回池子里绝不能在队列里囤积。很多崩溃就是开发者想着“我把这一帧留一会儿等有空再处理”结果系统分分钟教你做人。3.3 网络阻塞时宁可丢帧绝不排队有了采集、编码接下来是推流。无论你用的是 RTMP、SRT 还是自定义协议网络传输层都是内存失控的高发区。弱网环境下如果发送方来不及把编码后的视频包发出去而推流库内部有一个“发送队列”在兜底那么队列里的内容会像滚雪球一样越滚越大。这种场景下工程师的第一反应往往是“扩扩容”加大发送缓冲区。但在 50MB 红线下这完全是错误方向。正确的做法是设一个发送队列高水位线如果队列积压超过阈值下一帧视频数据直接丢掉绝不入队。音频可以稍微宽容一点因为音频数据量小而且声音一旦断断续续用户感知非常明显。视频就无所谓了丢一帧人眼根本看不出来。这套“保音频、弃视频”的策略是行业里做直播引擎的共识。4. 实战踩坑记录我的扩展进程被 Jetsam 干掉了三次4.1 第一次崩溃只缓存了三帧 1080p 就阵亡我最早实现 SampleHandler 时为了不阻塞系统回调线程很自然地想到了异步处理把每个视频CMSampleBuffer丢到一个串行队列里后台慢慢处理。这听起来很合理对吧但问题来了当屏幕内容变化剧烈比如游戏画面每秒 60 帧输出时系统回调线程往队列里塞帧的速度远快于我后台处理的速度。队列里的帧越来越多而那会儿我还没有做任何缩放每帧都是 1080p 的 BGRA8.29MB 一个。等队列里堆了五六帧内存已经逼近 50MB再加上编码器、代码本身的开销扩展当场被 Jetsam 杀掉。排障链路是这样的录屏中断控制中心显示“录制已停止”没有任何崩溃弹窗。去 App 的崩溃日志里看发现是一堆 JetsamEvent 日志。打开最长的那条日志找到largestProcess和rpages字段发现就是我们扩展进程内存页数远超阈值。然后用 Instruments 的 Allocations 工具跟踪发现大量 CMSampleBuffer 和 CVPixelBuffer 堆积在 DispatchQueue 上。最后解决办法是干掉异步队列processSampleBuffer里同步做处理。如果当前帧来不及就直接丢弃绝不积压。现在回想起来当时纯粹是把“多线程处理”和“效率高”划了等号但在扩展进程这种资源受限的环境里同步 丢帧才是对的选择。4.2 第二次崩溃推流库的发送缓冲区把我埋了第一次修完后录屏能跑五六分钟了。但客户反馈说在弱网环境下录屏还是会中断。我特意开着 4G 信号测试发现网络稍差一点RTMP 推流库内部的发送队列就开始积累数据。因为编码后的 H.264 数据一直从 VideoToolbox 回调里出来网络却发不出去推流库只能把数据先放内存里排队。队列积压到 100 多 MB 时系统不出手才是怪事。这一次的排障链路比第一次要绕先开 Console.app用 os_log 打点看内存走势发现内存持续上涨。观察上涨节奏发现和网络信号强弱直接挂钩。用 vm_track 工具看内存类型发现大量 VM 内存被 socket 相关的对象占着。最后确认是推流库的发送缓冲于是给推流库加了“发送队列水位检查”的逻辑超过阈值就丢弃视频包。顺带一提如果你的推流库不支持丢弃视频包宁可自己做一层封包缓冲也不要把所有编码数据直接塞给第三方库的缓冲队列。这个控制权必须握在自己手里否则内存永远是不可控的。4.3 第三次崩溃加锁引发的 PixelBuffer 池耗尽第三次翻车让我印象最深因为它完全是“自作聪明”的代价。当时想着既然视频处理这么重不如把颜色转换和编码并行做多线程不是更快吗于是我把缩放、编码分到了两条线程用锁保护共享的 buffer。结果 iOS 的 PixelBuffer 池天生就不适合这种玩法。因为 PixelBuffer 是有限的资源池里一共就那几块 buffer多线程加锁抢用就会出现 A 线程取走了 bufferB 线程等不到 buffer 只能原地等待最后在一帧里同时申请多块 buffer池子被掏空进程再次被 Jetsam 带走。关键在于VideoToolbox 的编码回调本来就在自己的异步线程上执行编码过程本身已经异步了。在这个基础上再并发处理原始帧不仅不会提速反而徒增复杂性。排障手段很简单用 Memory Graph 把进程堆栈调出来发现多个线程同时持有 PixelBuffer且都处于 waiting 状态。把这些并发全部去掉回到同步单线程转录模型内存立刻稳如老狗。5. 调试、验证与发布前的检查清单5.1 用 Instruments 和 Memory Graph 定位内存真凶扩展进程的内存问题最让人痛苦的是它不是必现的严重依赖使用场景。所以我做调试一般按以下步骤来第一步在开发阶段用 Xcode 直接给扩展进程 attach 调试器。方法是在 Scheme 里把 Broadcast Upload Extension 作为一个可执行的 target 跑起来或者用 Xcode 的 “Debug Attach to Process” 找到扩展的进程名。如果 attach 不到就在打点日志里输出进程 PID再手动 attach。第二步用 Instruments 的 Allocations 和 VM Tracker 两个模板。Allocations 能看出堆上分配了哪些对象、数量多少VM Tracker 能看出 Dirty Memory 到底是多少。这两个工具的配合能快速定位内存大头是 code 还是 data。第三步在代码里自己加一个 footprint 监控点。用task_info拿到当前进程的phys_footprint每分钟 os_log 打一次点。上线后在后台日志里就能看到真实用户设备上的内存走势比测试机数据靠谱得多。5.2 把 Jetsam 日志读明白当扩展进程被杀后系统会在“设置 - 隐私与安全性 - 分析与改进 - 分析数据”里留下 JetsamEvent 日志文件名类似JetsamEvent-2025-xx-xx-xxxxxx.ips。打开后不用全看懂盯着几个字段就行字段含义reason被杀的原因常见是per-process-limit或hard内存上限largestProcess当时占用内存最高的进程名确认是不是自己的扩展rpages进程占用的内存页数一页是 16KB可以换算出字节数freedMemory/memoryStatus整机内存压力情况如果发现largestProcess就是自己的扩展rpages换算出来的内存接近或超过 50MB那基本可以确诊是内存超限被杀。再配合自己打的 footprint 日志画出时间线就知道它是在哪一秒开始失控的了。5.3 上线前必须过一遍的自查项最后我把自己每次发布录屏引擎前保留的检查清单贴出来这份清单帮我在不同项目里躲掉了不少雷检查项具体要求分辨率强制缩放到 720p 或以下禁止把 1080p 原始帧直接喂给编码器码率根据场景控制在 1.5 - 4Mbps不要在弱网下用高码率实时模式VTCompressionSession必须开启RealTime属性帧缓存任何队列里不允许积压超过 1 帧视频数据超了就丢网络缓冲发送队列设高水位线弱网时丢弃视频包保音频内存打点用 os_log 记录phys_footprint线上可追踪App Group确认扩展和主 App 用同一 App Group配置通过 UserDefaults 传递退出清理broadcastFinished里必须释放编码器、断开网络、清空全部 buffer 引用实机压测开着高帧率游戏录屏 10 分钟再在弱网环境下重复一次这里面最容易忽略的其实是“退出清理”。broadcastFinished不是每次都会被系统调到如果扩展是被 Jetsam 强杀的它可能根本来不及执行清理。所以不要在扩展里持有任何“下次还能用”的资源所有东西都是这次会话专属的。我个人现在的经验是做 ReplayKit 录屏更多时间花在监控和降级策略上而不是功能实现上。扩展现成的架构很简单真正的门槛在于怎么让它在极度苛刻的内存环境里稳定运行。每次看到线上反馈“录屏又断了”我的第一反应永远是去查 Jetsam 日志里那个rpages数字而不是怀疑用户操作有问题。这种排查思路就是我推荐你也要建立的工程直觉先用内存数据说话再看其他可能性。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →