ReplayKit录屏50MB内存红线:Broadcast Extension分片编码实战
发布时间:2026/9/16 15:07:02 锦皓数字建站

做 iOS 录屏项目的人应该都感受过 ReplayKit 的“神奇”与“折磨”。它把系统采集、画面合成、音频分离这些脏活都封装好了但从你把第一个CMSampleBuffer拿到手开始你就要在一条非常窄的内存红线上做事情。网上很多人叫它“50MB 红线”指的是 Broadcast Extension 这个独立进程在系统里被压得非常紧实际体验下来峰值内存一接近甚至超过 50MB录到一半被系统杀掉是常有的事。这篇文章把我做 iOS 录屏引擎时踩过的坑、试过的方案、验证过的代码结构从头梳理一遍重点讲清楚 Broadcast Extension 里怎么把批量的视频帧高效地变成可播放的视频文件以及如何让主 App 在录屏过程中不断拿到这些分片。适合看这篇内容的人是要做屏幕录制、跨 App 采集、直播推流、或者想在 App 里提供“用户从控制中心直接录屏”能力的 iOS 开发者。如果你只想在 App 内部录自己页面不一定需要 Broadcast Extension但如果你需要系统级的录制体验或者录屏要持续到 App 切到后台这篇文章能省你很多查文档和试错的时间。1. 先搞清楚Broadcast Extension 在整个录屏链路里扮演什么角色1.1 一次录屏涉及四个角色不要把 ReplayKit 想成一个单一的 SDK它更像一套分布式协作框架。一次完整的 Broadcast 录屏至少涉及这么几方操作系统采集进程负责从屏幕、音频设备拿到原始 CMSampleBuffer然后把它们流向你的扩展Broadcast Setup UI Extension用户通过控制中心选择“开始录制”时如果实现了这个扩展系统会先展示一个配置界面Broadcast Upload Extension也就是我们真正要开发的RPBroadcastSampleHandler系统把每帧视频和音频都丢到这里由你决定是编码、落盘还是推流主 App用来发起整个 Broadcast 流程、展示录制状态并且通常会通过 App Group 接收扩展产出的视频文件。关键点在于ReplayKit 的“录屏”发生在系统进程里不是在你的 App 进程里。你只能以扩展的身份处理系统送来的数据。这个设计最大的好处是录屏不受主 App 生命周期影响用户切到桌面、打开其他应用录制依然能继续坏处就是扩展进程的生存条件非常苛刻内存、CPU、后台运行时长都有限制。1.2 “50MB 红线”到底意味着什么iOS 有一个基于 jetsam 的内存管理机制系统每隔一段时间会统计各进程的内存压力。普通 App 可以轻松用到几百 MB系统通常睁一只眼闭一只眼但 Broadcast Extension 是辅助进程权重非常低。当内存紧张时系统第一波清理的对象往往就是这类扩展。我拿下真实项目的统计来看单线程处理一帧 1080p 的 BGRA 像素数据如果临时对象没有及时释放或者AVAssetWriterInput写入速度跟不上、回调积压了几帧内存瞬间就可能从 20MB 冲到 60MB、100MB。一旦接近 jetsam 阈值扩展会毫无征兆地退出主控台上只能看到ReplayKit extension terminated due to memory pressure之类的日志。经常发生的情况是用户录了十分钟前九分钟文件都好好的最后内存一爆连已写入的文件都是不完整的。所以在动手写代码之前我们首先要建立一个认知这个项目的优化目标不是“功能是否能跑通”而是“内存水位能不能一直被按在一根安全线以下”。后面的所有设计思路统统围绕这一点展开。2. 整体设计思路如何在狭窄的内存预算里完成“采集—编码—传输”2.1 为什么不用 RPScreenRecorder 直接录很多人会问ReplayKit 不是还提供了RPScreenRecorder吗直接在 App 里调用它不是更简单吗确实如果你的需求只是“录制 App 内部当前界面”RPScreenRecorder可以用很小的开发量完成任务录完直接保存到相册也方便。但它的场景边界很清晰它更适合 App 内主动发起、和 App 生命周期绑定较深的录制。一旦用户退出 App或者你想让用户从系统控制中心直接点击“开始录屏”RPScreenRecorder就不太行了。我们当时的产品需求是用户可以在控制中心长按录屏按钮从弹出的列表里选中我们的扩展然后全程不需要打开主 App 就能完成录制。这种需求只能走 Broadcast Upload Extension。还有一个附加需求是录制过程中要把视频分片传回主 App做实时进度展示。用扩展的话主 App 可以在后台收到 Darwin 通知后去 App Group 容器里面拿文件这样整个链路是通的。2.2 编码方案AVAssetWriter 优先VideoToolbox 保留备胎拿到 CMSampleBuffer 之后第一件事就是决定用什么方式把它变成视频。有两条主流路线AVAssetWriter写 MP4 文件。这是大多数落地项目用得最多、最稳妥的方案。配置好编码参数后直接把 ReplayKit 送来的 sample buffer append 进去再由系统内部完成 H.264 编码和 MP4 封装。自己用VTCompressionSession做硬编码然后搭配一个视频流封装层。这个方案对内存和延迟的控制更细但工程量也更大而且 MP4 的封装、文件头、关键帧索引都得自己处理调试成本高。我只在需要做实时推流、或者对首帧延迟有严格要求的场景下才建议直接上 VideoToolbox。大多数“录完存文件 / 再上传”的业务用AVAssetWriter完全够而且它在扩展里跑起来的稳定性比手写编码链路要强。因为系统的编码器已经为你做了大部分内存管理我们只需要聚焦在“别积压帧、别持有 sample buffer、别让 writer 长期不可写入”这三件事上。2.3 存储和通知链路规划扩展进程和主 App 进程是隔离的不能直接共享内存或单例必须通过 App Group 共享容器来交换文件。我的设计是扩展侧把视频切成多个分片比如record-1.mp4、record-2.mp4每个分片对应一段时间或者固定大小扩展侧写完一个分片后通过 Darwin 通知中心发一个“新分片已就绪”的通知主 App 侧监听这个通知然后去共享容器目录里检查新文件移动到自己的缓存目录做后续处理主 App 侧录屏结束时把所有分片合并成一个完整视频。这样做的好处是任何一段录屏崩溃前面已落盘的分片还能抢救回来不会因为尾部异常导致全盘皆输。而且分片机制天然解决了“扩展生命周期不稳定”的隐患。3. 核心实现在扩展里安全地把帧数据写成视频3.1 AVAssetWriter 初始化与编码参数配置在SampleHandler里不要在主线程创建 writer也不要在首次拿到帧之前创建。因为AVAssetWriter需要知道视频的真实尺寸而 ReplayKit 送到扩展里的原始分辨率会随着用户设备的横竖屏、外接屏幕情况变化。比较稳妥的做法是在收到第一帧视频 sample buffer 时从CMFormatDescription里取出实际宽高再创建 writer。override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { guard sampleBufferType .video else { return } if writer nil { guard let formatDescription CMSampleBufferGetFormatDescription(sampleBuffer) else { return } let dimensions CMVideoFormatDescriptionGetDimensions(formatDescription) startWriter(width: Int(dimensions.width), height: Int(dimensions.height)) } guard let writer writer, writer.status .writing, let input input, input.isReadyForMoreMediaData else { return } input.append(sampleBuffer) } private func startWriter(width: Int, height: Int) { let fileURL segmentFileURL() do { let writer try AVAssetWriter(outputURL: fileURL, fileType: .mp4) let settings: [String: Any] [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: width, AVVideoHeightKey: height, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: 3_000_000, AVVideoProfileLevelKey: AVVideoProfileLevelH264MainAutoLevel, AVVideoMaxKeyFrameIntervalKey: 30, AVVideoExpectedSourceFrameRateKey: 30 ] ] let input AVAssetWriterInput(mediaType: .video, outputSettings: settings) input.expectsMediaDataInRealTime true if writer.canAdd(input) { writer.add(input) } if writer.startWriting() { writer.startSession(atSourceTime: .zero) self.writer writer self.input input } } catch { // 记录日志不能崩溃 } }这段代码里有几个参数值得展开说。AVVideoAverageBitRateKey设成了 3Mbps这是 720p/1080p 录屏的常见经验值。太高会显著提高 CPU 和内存负担太低又会看到明显马赛克。如果你只录静态页面可以适当降到 2Mbps如果是游戏等高动态画面建议升到 6Mbps 甚至更高。具体值要在真机上跑同一场景对比不要只看理论。AVVideoMaxKeyFrameIntervalKey设成 30意思是每 30 帧一个关键帧。由于我们准备做分片如果分片边界恰好落在关键帧之后合并时会更无缝。这个值也会影响文件大小关键帧越多文件越大但切分越安全。input.expectsMediaDataInRealTime true是给系统一个提示这是实时媒体数据不一定能持续逐帧等待。这样系统在处理时会优先保证实时性而不是为了完整编码所有帧而积累内存。3.2 回调里绝对不能做的事processSampleBuffer这个方法本身是串行回调的但也有不少开发者会在这里犯几个致命错误。第一不要把 sample buffer 丢到一个异步队列里攒着。有些需求希望“把编码过程放到后台线程”就在回调里DispatchQueue.global().async { input.append(sampleBuffer) }。这会让多个 sample buffer 在并发队列里积累内存直接炸掉。ReplayKit 回调本身已经在一个合理的串行线程上你直接同步处理是安全的甚至不要轻易自己做线程切换。第二不要把整个 sample buffer 转成UIImage或者只为了加水印再做一次渲染。UIImage和CoreImage都是以多维像素数据为基础的转一次就是一份完整帧拷贝一帧就是好几兆积压几帧就超线了。如果需要叠加水印、做效果尽量用CIFilter或 Metal 管线实时处理并且要复用 buffer pool如果只是先跑通链路这一步可以完全不做。第三当isReadyForMoreMediaData false时说明AVAssetWriter内部已经积压了数据这时候再硬塞反而会让它继续增加内存消耗。正确做法是丢帧。录屏场景下偶尔丢一两帧用户基本感知不到远好过整个扩展被系统杀掉。3.3 分片文件如何轮转既然设计了分片机制就要在代码里实现“写满一个文件就切下一个”的逻辑。最简朴且有效的方式是维护一个分片序号当当前 writer 已经写了 N 秒或者当前文件大小超过 M MB就结束当前分片并开启新的 writer。private func finishCurrentSegmentIfNeeded() { guard let writer writer, writer.status .writing else { return } let currentTime CMClockGetTime(CMClockGetHostTimeClock()) if lastSegmentStartTime ! nil, CMTimeGetSeconds(CMTimeSubtract(currentTime, lastSegmentStartTime)) 5 { input?.markAsFinished() writer.finishWriting { [weak self] in self?.postSegmentNotification() self?.writer nil self?.input nil } } }这里有个细节不要在finishWriting(completionHandler:)里再同步去创建新 writer。因为 completionHandler 是在内部队列回调的如果你立刻创建新 writer 并开始写时序上容易乱。更稳的做法是在老 writer 的 completion 里只做通知和清理把“开启新分片”的动作放到下一次processSampleBuffer里判断writer nil再创建。实际操作下来5 秒一个分片比较适合“边录边传”的场景。文件不会太大主 App 可以比较及时更新进度太短的话频繁创建和回收 writer反而增加内存和 CPU 抖动。3.4 扩展和主 App 之间的文件通知写文件时App Group 容器路径是固定的。扩展写文件、主 App 读文件都要用同一个 App Group ID。文件写完后我一般用CFNotificationCenterGetDarwinNotifyCenter发一个不带 userInfo 的广播通知。注意 Darwin 通知不需要 App 在线才能发即使主 App 被系统挂起通知也会在它下一次醒来时派发这对“用户录屏时主 App 在后台”的场景特别重要。private func postSegmentNotification() { CFNotificationCenterPostNotification( CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName(com.example.record.newSegment as CFString), nil, nil, true ) }主 App 监听时要尽量在 App 启动早期就注册同时注册 iOS 10 之后的 block 回调接口避免老接口的 selector 方式出现野指针。收到通知后不要直接在回调里做耗时操作而是扫描 App Group 目录把新文件 move 到自己的沙盒目录再做后续处理。4. 实操记录从 Xcode 创建扩展到真机录制一把4.1 Xcode 里的广播扩展工程模板在 Xcode 中给主工程添加扩展的路径是File - New - Target选择ReplayKit Broadcast Upload Extension。模板会自动生成一个SampleHandler.swift文件里面是RPBroadcastSampleHandler的子类。创建完之后最容易被忽略的是 App Group 能力。你既要在主 Target 的Signing Capabilities里添加 App Group也要在 Extension Target 里添加并且保证两个 Target 选中的 Group ID 完全一样。否则即使你在代码里写死了containerURL(forSecurityApplicationGroupIdentifier:)运行时也会返回 nil扩展直接无法写文件。4.2 Info.plist 里的关键配置模板默认已经把扩展声明好了一般情况下不需要改 Info.plist。需要留意的是NSExtensionPointIdentifier它的值必须是com.apple.broadcast-services-upload这个决定了系统会把扩展识别为 Broadcast Upload Extension。如果误改成别的值控制中心里就不会出现你的扩展。关于权限这里有一个很容易踩的坑。如果你需要录麦克风声音必须在主 App 里申请麦克风权限因为扩展没有独立的权限弹窗机制。扩展进程只是在采集链路上收到了音频 sample buffer权限判断是主 App 的事。如果主 App 没做权限处理扩展里就算写了音频落盘逻辑得到的音频数据也是空的。4.3 真机调试和日志查看Broadcast Extension 在模拟器上支持不完整强烈建议直接上真机调试。调试流程大致是把主 App 安装到真机在 Xcode 里选择主 App Scheme 运行让 Extension Target 也一起安装打开系统控制中心长按录屏按钮在弹出的应用列表里选择你扩展的名字开始录屏后Xcode 控制台里通常可以看到 SampleHandler 打印出的日志如果看不到日志打开 macOS 的 Console.app连接真机按进程名过滤 SampleHandler 或者你的扩展 bundle ID。有一点要注意每次修改扩展代码后最好先在设置里彻底杀掉真机上相关的旧扩展进程再重新 Build。否则系统可能仍然加载上一次安装的扩展你会看到“代码改了但行为没变”的假象。这个坑一度让我以为自己的配置没问题其实是旧扩展迟迟没被系统替换掉。4.4 端到端验证的几个指标我建议每次改完代码都记录三组数据扩展进程的内存峰值可以通过 Xcode 的 Debug Gauges 或者 Instruments 查看第一帧到生成第一个有效 MP4 文件的耗时分片文件是否能被主 App 稳定扫描到并成功合并。这三项是录屏引擎是否健康的核心指标。内存峰值代表红线压力首文件耗时代表用户感知分片稳定性代表整个链路是否真正闭环。不要等到用户反馈“录到一半断了”才回头排查用这三个指标主动把系统逼到临界状态去测才是最有效的。5. 常见问题与排查技巧实录5.1 扩展在控制中心里不出现这是最容易被误判的一个问题。扩展不出现通常不是代码问题而是安装/签名/系统识别的问题。先确认 Extension 是否真的安装到了真机上。看主 App 在设备上的“可用扩展”列表或者装一个可以列出扩展的调试工具。然后检查 Extension 的 bundle ID 是否和主 App 的 bundle ID 满足系统要求的包含关系一般建议 Extension 的 bundle ID 是com.example.app.ScreenBroadcast这种前缀必须与主 App 一致。最后检查 App Group 和 Info.plist 配置把控制台里跟 ReplayKit 相关的日志打出来看一眼即可。还有一个很容易被忽略的点如果同时在设备上安装了多个 Broadcast 扩展比如腾讯会议、微信、抖音都有系统列表会很长用户有时压根没注意到你已经装好。调试时宁可把所有其他录屏 App 暂时卸载只保留自己的扩展视野会干净很多。5.2 录了几秒就被系统杀掉出现这个问题第一反应就是看击杀日志。打开真机上的Settings - Privacy Security - Analytics Improvements - Analytics Data搜索JetsamEvent找到对应扩展进程名。日志里通常会写清楚是内存过大被杀还是 CPU 超时被杀。如果是内存重点检查三件事一是AVAssetWriterInput的isReadyForMoreMediaData是否被正确判断有没有强行 append二是音频是不是没有处理、只是不断创建新的 sample buffer 对象三是水印、滤镜、渲染等额外操作是否无意中导致了很多不必要的 pixel buffer 拷贝。处理完内存问题后还有个非常有效的手段把视频 bitrate 降一档让编码器少干点活。如果 3Mbps 仍然被压到临界点可以先降到 1.5Mbps 验证稳定性然后再慢慢提回来。实操中画质小幅下降远好过用户录制中断。5.3 文件损坏或黑屏文件可以生成但播放出来是黑屏通常是因为 writer 没有正确写入第一帧原始数据。比如在某些设备上ReplayKit 送过来的第一帧 sample buffer 是空的 format description或者startWriting之后没有及时调用startSession(atSourceTime:)。建议把startSession的时间戳从第一帧 sample buffer 里取而不是用.zero。还有一个可能你的processSampleBuffer里在 writer 初始化前就把视频帧全部 return 掉了。如果用户点击开始录屏后第一秒内没有画面writer 一直没创建后面即使有帧也无法补上文件头。这种情况下建议在broadcastStarted里先准备好所有配置等第一帧到了立刻创建 writer同时把startSession(atSourceTime:)的时间设成该帧的decodeTimeStamp而不是最早的 0。5.4 主 App 收不到分片通知如果扩展那边日志显示文件已经写完但主 App 收不到通知先不要怀疑 Darwin 通知本身先怀疑你的主 App 是否还在可监听状态。通常问题出在 App Group 配置。你可以在主 App 启动时打印FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group.xxx)的结果如果是 nil就是 Group 没配对。如果容器路径有值再看扩展写入的目录是否和主 App 读取的目录完全一致大小写、路径组件拼接都可能有差异。另外主 App 如果在前台活跃Darwin 通知一般能很快送达如果主 App 被系统挂起通知会在它被唤醒时才投递。所以验收时要分两种情况看前台收通知和后台收通知后台场景务必等用户手动回前台后再检查文件列表是否有更新。5.5 分片合并后的时长或画质异常合并多个 MP4 分片时不要用简单的文件二进制拼接那样出来的视频大概率时长错乱。建议用AVAssetExportSession做多段合并或者用AVMutableComposition把分段 track 拼接到同一条时间线。合并前要确保各分片的编码参数一致尤其是分辨率、帧率、bitrate。只要你的 writer 是根据首帧实际尺寸创建的且不修改过 settings基本都能保持一致性。如果偶尔出现分片参数不一致多半是设备在录制过程中发生了旋转分辨率变化导致新文件被创建时取了新尺寸。这种情况要在合并逻辑里做动态判断要么对某一个分片做缩放要么用固定的输出分辨率强制编码。最后分享一个我自己踩出来的习惯做这类内存受限的工程我的原则是“永远不要相信下一帧一定会按时来”。ReplayKit 把录制这个动作变得很简单但系统的调度随时可能打断你。所以我写代码时会有意识地做三件事一是不管多忙每处理完一帧都主动清理临时对象二是不把编码、写入这些操作无限堆叠到同一个队列里宁可丢帧也不要阻塞三是在测试阶段故意制造低内存场景比如同时开着相机、导航、大型游戏让系统处于压力状态再验证扩展的存活能力。录屏引擎这个东西看起来是“系统给我一帧我写一帧”的简单链路但真正硬核的部分全在边界处理。50MB 红线的核心不是让你去省那几十 KB而是让你想明白每个像素对象、每一帧 sample buffer、每一次没有立刻释放的临时资源最后都可能成为压垮录屏的那根稻草。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。