先说结论:想在 iOS 上做一套真正可落地的“录屏引擎”,绕不开 ReplayKit 的 Broadcast Extension。这个方案能拿到系统级的屏幕画面和 App 音频,同时又是所有方案里最“憋屈”的一种——你的扩展进程被死死摁在 50MB 内存红线内,稍不留神就会被 Jetsam 直接干掉。这篇文章把我从做录屏 SDK 到跑通直播推流的完整经验写出来,包括工程搭建、SampleHandler 数据处理、内存治理、常见崩溃与黑屏无声问题排查,给正准备踩坑的你一个直接能抄的作业。
业内做录屏的方案归纳起来就三种:一是 RPScreenRecorder 在 App 内录屏,简单但只能录主 App 自己的界面,无法录系统桌面和其他 App;二是 Broadcast Setup UI + Upload Extension,可以在系统级录屏后把帧流交给你的扩展进程;三是在扩展里拿到 H.264/H.265 编码帧后直接推流或落盘,这也是绝大多数商用录屏功能的实际形态。真正折腾人的是第三种,因为你面对的不是“怎么写代码”,而是“如何在 50MB 内存下优雅地处理每一帧”。这篇文章我就按这条主线展开,讲清楚从建工程到稳定上线的每一个关键决策。
1. 为什么会有“50MB 红线”这道坎?
1.1 先搞懂 ReplayKit 的三种玩法
ReplayKit 从 iOS 9 开始出现,最早只是系统级录屏,录完给你一个 RPPreviewViewController 让用户预览和分享。这类方案对开发者来说基本零成本,App 内嵌一个按钮就能让用户录屏分享,但权限和格式都被系统锁死,你拿不到实时帧数据,更别想做自定义推流。
iOS 10 之后系统开放了 RPScreenRecorder 的 startCapture(handler:),App 可以实时拿到视频帧。这个阶段很多录屏、直播类 App 都用它,但有个致命缺陷:它只能录制“App 自身”的内容。注意,这里不是指整机屏幕,而是你 App 的 view 层级。用户一旦切到别的 App,录屏内容就断了,根本不适合做系统级录屏。
真正让录屏引擎变成“完整产品”的是 iOS 11 引入的 Broadcast Upload Extension。用户在控制中心点击“屏幕录制”并选择你的 Extension 后,系统会把整块屏幕的 H.264/HEVC 视频流以及 App 音频、麦克风音频,以一个 CMSampleBuffer 一个 CMSampleBuffer 的形式推到你的扩展进程里。你拿到的是已经编码好的压缩帧,不是原始像素。这意味着你不需要在黄金时间做昂贵的软编码,但也意味着你没有太多自由去改分辨率、码率。这个 API 形态决定了整个引擎的架构围绕“如何处理编码帧”展开,而不是“如何编码”。
1.2 Broadcast Extension 的资源真相:50MB 够干什么?
iOS 对 App Extension 有严格的内存限制,尽管这个数值不公开,实测下来大概是每进程 50MB 左右,且不同机型、不同 iOS 版本会有浮动。50MB 是什么概念?一帧 1920x1080 的 YUV420 原始数据约为 3MB。你要是天真地在内存里缓存七八帧做滤波或转码,基本上就触碰红线了。但好消息是系统送过来的是压缩后的编码帧,单帧量级小得多,大约几十 KB 到几百 KB,这给了我们一点点操作空间。
可别高兴太早。扩展进程里不是只有你写的一小段处理逻辑,它还承载着系统的运行环境、框架加载、日志、网络状态等开销。你再塞一个 RTMP 推流库、一个大的 JSON 解析器、几个串行队列,内存立刻吃掉大半。更麻烦的是,扩展进程是一个独立于主 App 的进程,你的主 App 在后台保活不了它;系统随时可能因为内存压力把扩展进程杀了。所以“50MB 红线”本质上不是一道选择题,而是一整套资源管理纪律:能用硬件编码就不要软件编码,能用流式写入就不要累积缓冲,能同步处理就不要把帧派发到多个并发队列。
1.3 什么样的场景必须走这条路
如果你的需求只是“给 App 里的某段操作录个短视频”,用 RPScreenRecorder + RPPreviewViewController 就够了,没必要碰 Extension 的地狱模式。但如果你的产品是以下类型,Broadcast Extension 就是必选项:
- 直播伴侣类 App,需要把主播屏幕实时推流到 CDN;
- 客服系统、远程协助工具,需要在不打断用户操作的情况下把屏幕画面传给对方;
- 游戏高光时刻、电竞复盘,需要录下整个系统的画面而不只是游戏 App 内部;
- 教育录播、会议记录,需要连续录制系统声音和麦克风声音,并在结束后快速导出。
这些场景有一个共同点:录屏过程不可预测,用户随时会切后台、会锁屏、会接电话,而你的引擎必须在这种环境里把每一帧稳定送出去。能够应对这些不确定性的,只有 Broadcast Extension 这套机制。理解了这一点,你就明白为什么明明 ReplayKit 有更简单的 API,我们还是要把工程重心放在 Extension 上。
2. 工程搭建:从零开始一个 Broadcast Extension 录屏引擎
2.1 Target 创建与 Info.plist 配置
打开 Xcode,新建一个 Target,选择 Broadcast Upload Extension,而不是 Broadcast Setup UI Extension。后者只是提供一个可选的预览/设置界面,真正处理帧数据的是前者。系统会自动生成 SampleHandler.swift,继承 RPBroadcastSampleHandler,这就是整个引擎的心脏。
工程建好后先别急着写代码,Info.plist 里有一个关键配置必须手动确认:NSExtensionPointIdentifier 的值必须是 com.apple.broadcast-services-upload。如果这个值不对,用户从控制中心选择你的 Extension 后系统不会调用你的广播方法。我见过最诡异的一个 bug 是 picker 里能看到 App 名字,点了之后却毫无反应,最后检查发现就是这个键被复制错了。
另一个经常被忽略的配置是 RPBroadcastProcessMode。把它设置为 1,可以让扩展在用户点击开始广播后立刻启动,而不需要等待 Setup UI 返回配置参数。我们在直播类 App 里强烈建议用这个模式,因为 Setup UI 多一跳,不仅启动变慢,还容易在弱网时让用户觉得“点了没反应”。录制过程中如果用户从控制中心手动停止,系统会先调用 broadcastFinished(),此时你应该在这里做收尾工作,比如关闭文件、上报事件、清理临时目录。
2.2 权限、UI 唤起和主 App 联动
Extension 本身不能自己唤起,必须由主 App 提供一个 RPSystemBroadcastPickerView 让用户主动点击。这是 Apple 的硬性设计,任何绕过这个机制直接触发录屏的尝试在非越狱设备上都不可能成功。实战中一般把 RPSystemBroadcastPickerView 放在一个透明的 UIButton 上,因为系统自带的 picker 样式很固定,不好调整外观。下面这段代码是标准的唤起方式:
let broadcastPicker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) broadcastPicker.preferredExtension = "com.yourcompany.yourapp.broadcast" broadcastPicker.showsMicrophoneButton = false view.addSubview(broadcastPicker)preferredExtension 填的是 Broadcast Extension 的 Bundle Identifier,注意不是主 App 的。如果这里不填或填错,picker 会显示所有支持广播的 Extension,让用户手动选,体验很割裂。showsMicrophoneButton 决定是否在系统弹窗里显示麦克风开关,如果你不需要麦克风声音,建议直接关掉,少一个权限就少一个投诉点。
关于麦克风权限:如果你确实需要麦克风音频,必须在主 App 中提前向用户申请 AVAudioSession 的录音权限,同时把 RPScreenRecorder.shared().isMicrophoneEnabled 设置为 true。这里有个很容易踩的坑——广播已经启动后,你在 Extension 里改 isMicrophoneEnabled 是没用的,必须在用户点击开始广播之前配置好。我们在产品里把“是否收录麦克风”做成一个开关,用户切换开关时先申请权限,再更新 picker 里 showsMicrophoneButton 的状态。
主 App 和 Extension 之间的通信,推荐用 App Group + UserDefaults(suiteName:) 或者 CFNotificationCenterGetDarwinNotifyCenter。但注意,UserDefaults 共享存储本质是读写 plist 文件,频繁写入会非常消耗内存和磁盘 IO。建议只用来传状态量:开始时间、错误码、推流地址、当前分辨率等低频数据。真正的高频数据,比如进度回调、帧率统计,应通过 socket 或本地文件透传,而不是走 UserDefaults。
2.3 视频流入口:SampleHandler 里到底能拿到什么
SampleHandler 的 processSampleBuffer(_:with:) 是唯一的帧入口,系统会按采集顺序持续回调。关键来了:这个回调不是在你自己的线程池里跑的,也不是在主线程,而是系统实时音频/视频采集线程。在这个线程里做任何耗时操作都会直接影响采集,甚至导致黑屏、丢帧。官方没有明确说回调间隔,但 30fps 下大约每 33ms 一次,你必须在这么短的时间内处理完一帧,否则下一帧就会堆积,内存和 CPU 双双失控。
代码里首先要做的事是区分 buffer 类型。判断依据不是 CMSampleBufferGetFormatDescription 里的媒体类型,而是 CMGetAttachment 里的 RPSampleBufferType,它的取值如下:
- 1 表示视频帧;
- 2 表示 App 音频流(即系统正在播放的 App 声音);
- 3 表示麦克风采集到的外录音频。
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { autoreleasepool { switch sampleBufferType { case .video: handleVideo(sampleBuffer) case .audioApp: handleAudioApp(sampleBuffer) case .audioMic: handleAudioMic(sampleBuffer) @unknown default: break } } }如果你不需要麦克风,直接忽略 type == .audioMic 的 buffer 即可。但 Attention:如果你配置了 isMicrophoneEnabled = true,用户也授权了,结果你这里不处理,系统一样会把麦克风数据源源不断送进来,白白消耗 CPU。所以不用的音频类型要在入口丢掉,而不是留到后面处理。
这里必须说一个最容易被忽略的“坑”:系统传给扩展进程的 CMSampleBuffer 在回调返回后会被底层缓冲池回收,你不能把这个对象保存下来等会儿用。我见过有同学用 DispatchQueue.async 把 sampleBuffer 丢到后台队列去封装,结果画面花成一片,甚至出现崩溃。正确做法是在回调里同步处理完并拷贝出需要的数据,或者对原始 buffer 做深拷贝。后面我会给出一个实用的深拷贝方案。
3. 在 50MB 内存下把画面喂给编码器:视频处理与推流/落盘实现
3.1 用 VideoToolbox 硬编码 H.264:帧拷贝要快
系统送过来的视频帧已经是编码后的 H.264/HEVC,大多数情况下你不需要再走一遍 VideoToolbox 转码。直接把它封装进 MPEG-TS 或者推给 RTMP 服务器是最省内存、最稳的方式。但有些场景你必须重新编码:比如为了降低上行带宽,要把 1080p 重编码为 720p;或者要对画面叠加水印。别急着上 VTCompressionSession,先想清楚一个残酷事实:在 50MB 内存限制下做硬编码,每一次像素级处理都可能成为压垮进程的最后一根稻草。
如果你确定需要转码,有几个原则能救你:
- 只在关键帧(IDR)边界切换参数,不要尝试在 P 帧中间改分辨率或码率;
- 用 VTCompressionSession 设置 kVTCompressionPropertyKey_RealTime = true,让编码器走实时低延迟路径;
- 转码前没必要把像素数据拷贝到内存再处理,尽量通过 AVVideoComposition 或 Core Image 在 GPU 上完成,避免 CPU 参与像素级运算;
- 输出码率不要追求极致清晰,720p、2~3Mbps、30fps 在直播场景已经足够。
关于深拷贝 sampleBuffer,我给你一个可以直接用的思路:对视频压缩帧,核心数据在 CMBlockBuffer 里,把它 copy 到一块新的内存,然后基于新的内存构造一个 CMSampleBuffer,再手动带上时间戳和格式描述。代码大致如下:
func deepCopySampleBuffer(_ sampleBuffer: CMSampleBuffer) -> CMSampleBuffer? { guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return nil } let length = CMBlockBufferGetDataLength(blockBuffer) var copiedData = Data(count: length) _ = copiedData.withUnsafeMutableBytes { dst in CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: dst.baseAddress!) } var newBlockBuffer: CMBlockBuffer? try? copiedData.withUnsafeBytes { src in CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: UnsafeMutableRawPointer(mutating: src.baseAddress!), blockLength: length, blockAllocator: kCFAllocatorNull, customBlockSource: nil, offsetToData: 0, dataLength: length, flags: 0, blockBufferOut: &newBlockBuffer) } var timingInfo = CMSampleTimingInfo() CMSampleBufferGetSampleTimingInfo(sampleBuffer, at: 0, timingInfoOut: &timingInfo) var newSampleBuffer: CMSampleBuffer? CMSampleBufferCreateReady(allocator: kCFAllocatorDefault, dataBuffer: newBlockBuffer, formatDescription: CMSampleBufferGetFormatDescription(sampleBuffer), sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timingInfo, sampleSizeEntryCount: 1, sampleSizeArray: [length], sampleBufferOut: &newSampleBuffer) return newSampleBuffer }注意这个过程本身就涉及内存分配,所以不要为每一帧都做深拷贝,只有在需要跨线程异步处理时才有必要。对我而言,更推荐的做法是:在 processSampleBuffer 里同步完成 TS 封装或 RTMP 发送,整个过程控制在几毫秒内,不深拷贝、不异步。这样内存最稳,CPU 也最稳。
3.2 音频流接入与同步:一套能落地的方案
音频在录屏引擎里是最容易被看轻、却最容易出问题的部分。SampleHandler 里收到的音频 buffer 同样是压缩后的 AAC 格式,而视频是 H.264/HEVC,你只需要把每一路音频数据按时间顺序写入对应的时间轴,剩下的封装格式会帮你处理同步。但这里有个现实问题:App 音频和麦克风音频是两路独立的 buffer,如果两路都收,你就要考虑混音。
混音是强烈不建议在扩展进程内做的操作。首先两路 AAC 不能直接相加,你得先解码成 PCM,混完再编码,一套流程下来 CPU 和内存占用直接翻倍,50MB 限制下玩不转。更合理的方案是只收录一路:如果场景是录系统声音,就只处理 audioApp;如果场景是主播解说,就只处理 audioMic。大多数录屏直播、客服会话场景,一路音频完全够了。如果你真的需要同时收录系统声音和解说,那就把两路 AAC 分别封装成两个音轨放进容器里,让后端或播放端去混音,不要自己碰 PCM。
音频时间戳的同步问题也不得不提。视频帧 PTS 和音频帧 PTS 来自系统同一个时钟,理论上天然同步,但你在封装时会发现音频 buffer 的 sampling rate 在 iOS 不同版本上可能不同。封装 TS 时,音频 PES 头里的 PTS 要以 90kHz 为单位,和视频共用同一个时间基准。我的经验是做一个小工具函数,把 CMTime 统一换算成 Int64 的 90kHz 时间戳,再送给封装层,避免不同采样率导致音画不同步。
3.3 数据出口:RTMP 推流 / HLS 分段落盘的取舍
数据出口决定了整套引擎的架构。如果你的目标是低延迟直播,直接走 RTMP 推流;如果目标是事后回放、审核留档,建议写 HLS 分段文件,由主 App 负责上传。两条路各有各的坑。
RTMP 推流要引入 librtmp 或者基于它的开源封装库,比如 pili-librtmp。在扩展进程里用 RTMP 推流,最需要注意的是网络阻塞问题。Socket 发送在弱网下会阻塞,一旦阻塞发生在系统采集线程回调里,会导致后面的帧全部卡住,表现为画面冻住、内存疯涨。我的解法是把网络发送从回调里拆出去:SampleHandler 只负责把封装好的 TS 流字节塞进一个固定大小的环形缓冲区,另起一个线程从缓冲区取数据发送。缓冲区大小用 5 秒的数据量上限,超过上限直接丢非关键帧,绝不用内存无限堆积。
HLS 分段落盘则简单许多,本质是把封装好的 TS 数据按 2 到 4 秒切一个文件,写到 App Group 共享目录。extension 进程写完一个分片后,通过 Darwin 通知告诉主 App“有新分片”,主 App 再把文件上传到服务器。好处是弱网下不会阻塞采集线程,内存波动很小;坏处是实时性差,至少增加一个分片时长的延迟。如果产品能接受 5 到 10 秒延迟,我强烈推荐这个方案,稳定性比 RTMP 高一个量级。
这里要顺手给一个封装 TS 的思路。TS 封装不建议完全自己造轮子,直接用 AudioVideoBox 这类开源库可以省很多时间。但如果你要追求标准化,自己写也不复杂:把每个视频关键帧打成包含 PAT/PMT/PES 的完整 TS 包,把非关键帧打成只包含视频 PES 的 TS 包;音频写成独立的 PES 包。核心就是维护好连续计数器和 90kHz PTS,这部分代码网上有很多参考,建议拿小录像反复调,直到播放器能流畅播放。
3.4 内存与 CPU 的精细控制清单
50MB 红线不是靠一句“我注意点”就能扛过去的,要有一套可执行的约束。我把自己在项目里强制执行的清单列出来,每一条都是从线上事故里换来的:
- 不要缓存帧。任何把多帧数据保存在内存里的做法都是找死,必须做成“一帧进一帧出”的流水线;
- 每帧处理都用 autoreleasepool 包住,确保临时对象不会跨帧累积;
- 尽量不加载重型库。扩展进程里只需要 VideoToolbox、AudioToolbox、CoreMedia 这些系统框架就够,不要集成整个 FFmpeg 或 OpenCV,启动时间和内存直接爆;
- 所有处理逻辑使用一个串行队列,不要开并发队列处理帧,并发会带来锁竞争和内存碎片;
- 网络发送缓冲区严格设上限,超了丢帧,尤其是处理瞬时弱网时,宁可画面跳一下,也不能让进程被杀;
- 周期性检查当前内存占用,超过 40MB 时主动降级:降码率、降帧率、关闭麦克风,给自己留出缓冲空间;
- 不要 print 日志。用 os_log,避免大量字符串拼接在采集线程里产生内存峰值。
这套清单看着简单,但每一条都能写出一篇事故复盘。我的经验是,把内存检查做成一个单例的小工具,每隔 1 秒 dispatch 到后台队列读一次 task_vm_info,一旦达到阈值就通过状态通道通知主 App 展示“网络不稳定,已降低清晰度”这类 UI 反馈。这比在代码里写死一堆 if 判断要直观得多。
4. 上线前必须踩过的那些坑:问题排查与优化实录
4.1 黑屏、无声、时间戳异常:按症状排查
录屏引擎千奇百怪的问题,九成集中在三个症状上:黑屏、没声音、音画不同步。这三个问题光靠堆日志很难定位,得按链路一层层排查。
黑屏先别怀疑编码器,先确认系统有没有把关键帧送到扩展进程。在 SampleHandler 入口加一行日志,输出每个视频 buffer 的尺寸和时间戳,如果尺寸为零或长时间没有 key frame,那问题大概率出在工程配置,而不是你的处理逻辑。另一个常见原因是系统在扩展启动后的前几帧还没准备好,你如果立刻创建封装器,可能拿到的是不完整的格式描述。解决办法是等第一个关键帧到达后再初始化输出模块。
没有声音则优先检查两个地方:一是 Info.plist 里有没有声明 NSMicrophoneUsageDescription,只要你想录麦克风,这个键必须存在,否则系统直接拒绝录音权限;二是检查你是不是在 processSampleBuffer 里把 audioApp 和 audioMic 都当成了同一路数据处理,导致音频帧被错误丢弃。
时间戳异常是封装层的重灾区。最典型的症状是播放器画面卡顿、音画对不上。排查思路很简单:在封装前统一用 CMTimeGetSeconds 打印每个视频帧和音频帧的 PTS,看是不是单调递增。如果音频 PTS 跳变,看看是不是系统在来电、插拔耳机等音频中断事件后重置了时钟;此时必须重新初始化音频封装器,否则后面全部错位。
4.2 扩展被杀、断流、上传失败:稳定性三板斧
线上最常见的崩溃不是代码逻辑错误,而是扩展进程被 Jetsam 干掉。你在控制台会看到一条类似 “Jetsam Event” 的日志,这个信息的价值远大于任何堆栈。它会精确告诉你当时的内存占用排行,如果前三名里有你的扩展,说明内存优化还没做到位。
第一板斧是降内存峰值。检查你的代码,把所有一次性分配大对象的地方都找出来。常见元凶是深拷贝、大缓冲区、图片资源。把 TS 封装缓冲从 1MB 压缩到 256KB,把网络发送缓冲从 5 秒压缩到 2 秒,再一次压测,你会看到明显好转。
第二板斧是提升进程存活率。扩展进程的优先级天然低于主 App,你没法改变这一点,但可以通过两种方式稳定:减少 CPU 峰值,避免在一帧里同时做拷贝、封装、网络发送;以及在用户点击开始广播前,通过主 App 给系统一个“我正在做重要录制”的暗示。实际上就是在开始前提前调用 AVAudioSession 的 setActive(true),让系统认为是音频会话正在进行,这样进程被清理的概率会低一些。
第三板斧是异常恢复。bradcastFinished 不是一定会在进程被杀时执行的,所以必须做“死前留痕”。在每次写完一个 TS 分片后,立刻更新 App Group 里的进度标记。下次主 App 启动时,发现上次录制的进度标记不完整,就知道录制被中断,可以做一个恢复提示。网络上传失败也一样,先落盘再上传,上传失败就重试,重试还失败就保留分片,等网络恢复后由主 App 补传。
4.3 调优参数速查:分辨率、码率、帧率的平衡
所有参数最终都要在“清晰度”和“稳定性”之间找平衡。直接上参数表,这条是我在真机压测多轮后的经验值,适合大多数直播和录制场景:
| 场景 | 分辨率 | 帧率 | 视频码率 | 音频码率 | 备注 |
|---|---|---|---|---|---|
| 客服会话回放 | 640x360 | 15fps | 800Kbps | 32Kbps | 最稳,内存占用极低 |
| 网课录制 | 1280x720 | 30fps | 2.5Mbps | 64Kbps | 画面复杂时适当提高码率 |
| 游戏直播 | 1920x1080 | 60fps | 6Mbps | 128Kbps | 仅建议在 Pro 机型开启 |
| 远程协助 | 1280x720 | 20fps | 1.5Mbps | 48Kbps | 优先保证流畅度 |
码率不是越高越好,尤其是在扩展进程里,码率越高,单位时间产生的数据量越大,内存和网络压力都跟着涨。我见过不少团队把 1080p 拉到 8Mbps,结果用户设备一热就疯狂掉帧。真机上 6Mbps 和 8Mbps 肉眼几乎分不清,但稳定性差很多。
帧率也要谨慎。60fps 会让系统采集线程每 16ms 就回调一次,你的处理时间窗口直接减半,如果逻辑不够精简,哪怕不崩溃也会有持续卡顿。优先保证 30fps 的稳定,后期再针对高性能机型开放 60fps 开关。
音频采样率建议统一为 44.1kHz,兼容性最好。如果你收到系统送来的 48kHz 音频,转封装时可以交给播放器端处理,扩展进程内不做重采样,省下 CPU 和内存。
5. 最后再分享一个实战小技巧
很多教程不会提的一件事是:Broadcast Extension 的调试非常痛苦,断点容易不生效,日志也不好抓。我的习惯是在扩展里用 os_log 写日志,然后在 Mac 上通过 Console App 连接真机查看扩展进程的日志。这样可以看到扩展生命周期和每一帧处理耗时,比 Xcode 的 Debug 面板好用得多。另外,iPhone 连接 Mac 后,用 Xcode 的 “Debug -> Attach to Process by PID or Name” 可以直接附加到扩展进程,查看内存占用分布,这对排查 Jetsam 很有用,但注意附加本身会拉高内存,只做静态分析,别边调试边压测。
录屏引擎这条路,我踩过的最大坑就是一开始总想炫技,又是转码又是滤镜,结果连稳定跑完一分钟都做不到。后来想明白了,在这个 50MB 的舞台上,真正的核心竞争力不是功能多,而是“在资源极限下不崩、不冻、不黑屏”。把最基本的封装、同步、内存纪律做到极致,比任何炫酷功能都值钱。这篇是我目前最完整的一次复盘,希望能帮你少走几个月的弯路。