说出来你可能不信,我第一次做 iOS 录屏功能时,第一版只用了RPScreenRecorder,控制中心能录也能存相册,看起来挺顺利。结果产品一句话就把我打回原形:"录屏要后台推到直播服务器,还要能加水印和自定义清晰度。"RPScreenRecorder 能做到的只是把视频交给系统,你连数据流都碰不到,更别提处理了。这时候才意识到,真正的 iOS 录屏引擎必须构建在 ReplayKit 的 Broadcast Extension 上,而它背后还有一道 50MB 的隐形红线在等着你。
这篇文章就用一个可落地的实战项目来讲清楚这件事:从为什么必须用 Broadcast Extension,到 50MB 限制的真相,再到 SampleHandler 源码级拆解、VideoToolbox 编码链路、直播推流的最小实现,最后是踩坑记录。适合已经有 iOS 基础、想认真做录屏或直播功能的开发者参考,我不讲空话,全是实测项目里的经验。
1. ReplayKit 的两条路线:为什么原生录制撑不起自定义引擎
1.1 RPScreenRecorder 能做什么,不能做什么
很多刚接触 ReplayKit 的人都会先拿起RPScreenRecorder,因为它足够简单:
let recorder = RPScreenRecorder.shared() recorder.startRecording { error in // 录屏开始 }你甚至不需要额外申请权限,首次调用系统会弹出录制权限确认,而且录出来的视频自动保存到系统相册。对"只要能把屏幕录下来"的需求来说,这套 API 完全够用。
但它有两个致命短板。
第一,你拿不到采样数据。RPScreenRecorder 内部走的是系统级采集和编码,回调里最多给你一个RPPreviewViewController做预览,或者用stopRecording拿到一个RPPreviewViewController的实例,你无法在录制的过程中处理视频帧。想要在画面上叠加时间戳、Logo、弹幕,或者把视频流转发到直播服务器,全都做不到。
第二,它无法独立于 App 运行。一旦用户把 App 切到后台,或者录制过程中 App 被系统终止,录制也就断了。真实场景里的录屏,经常是用户切到别的 App 操作,这时候你的 App 早就后台挂起了,RPScreenRecorder 束手无策。
还想强调一点:RPScreenRecorder 的停止回调里,RPPreviewViewController的交互能力也很有限,用户只能看、只能保存,你不能对这段录像做任何二次操作。说白了,它是苹果给"傻瓜式录屏"设计的 API,不是给"录屏引擎"设计的。
1.2 Broadcast Extension 的定位:采集进程与 App 进程彻底分离
RPScreenRecorder 做不到的事情,正是 Broadcast Extension 存在的意义。
Broadcast Extension 的全称是Broadcast Upload Extension,它的运行机制是:用户在控制中心或者 App 内点击屏幕录制按钮后,系统会启动一个独立的扩展进程,这个进程负责接收 ReplayKit 输出的屏幕采样数据。注意,它和你 App 的主进程是分开的,你的 App 可以在后台,甚至被用户从 App Switcher 里划掉,录制依然能继续,因为采集工作由系统托管、由扩展进程接收。
这个架构带来的直接好处有三个:
- 采集与 App 生命周期解耦。App 活着也好、死了也好,只要用户没主动停止录制,扩展进程就一直收数据。
- 你能拿到原始样本。
SampleHandler里的processSampleBuffer(_:with:)回调会给你视频帧和音频帧,你可以把数据交给 VideoToolbox 重新编码,也可以直接写入文件,甚至推给服务器。 - 可以做大文件处理。你可以在扩展里把录屏写入 App Group 共享的容器里,由主 App 之后处理,App 本身不需要常驻内存。
但是,独立进程也意味着你要自己处理跨进程通信、内存配额、生命周期管理。很多人在这一层就翻了车:扩展被 jetsam 杀掉不知道怎么排查,App 和扩展之间无法直接互相调用方法只能干瞪眼。这些问题我会在后面的章节里逐个拆开讲。
1.3 50MB 红线到底卡在哪:扩展二进制与内存预算
标题里写了"50MB 红线",这句话要从两个角度理解。
第一个角度,是扩展二进制的大小约束。虽然 App Store 目前对整包大小的总限制已经放宽到 4GB 级别,但系统对扩展进程的加载和运行是有额外考量的,业内通常把扩展二进制的合理上限控制在 50MB 上下。你在扩展里每引入一个像 FFmpeg、OpenCV 这样的重型库,最终编译出来的.appex很容易就飙到几十上百 MB。一旦超过这个量级,不仅 App Store 审核时会被重点盯,实际运行时系统在加载扩展、分配内存方面也会更保守,录屏启动失败的概率会明显上升。
第二个角度,是扩展进程的内存配额。普通 App 在 iPhone 上能拿到几百 MB 甚至上 G 的内存,但扩展进程是从属进程,系统对它的 jetsam 内存限制异常严格,官方虽然没公开具体数字,但实测下来千万不能按普通 App 的标准去写。稍不注意,录屏过程中系统直接静默杀进程,用户毫无感知,但我们的录制任务就这么断了。
所以,这 50MB 不是一句口号,而是贯穿整个工程的设计约束。后面所有关于编码库选型、缓冲策略、数据落盘的讨论,本质上都是在跟这个预算博弈。
2. 50MB 预算从哪来:扩展二进制的收紧与现实影响
2.1 编译产物为什么会轻而易举超过 50MB
Xcode 创建 Broadcast Upload Extension 模板时,默认产物很小,撑死几百 KB,真正撑爆预算的是我们往里加的第三方依赖。
举个例子,你想在扩展里做 H.264 编码,装了一个 FFmpeg 的 iOS 封装库,比如FFmpegKit。运行pod install之后,链接出来的架构文件随便都是几十 MB。而且 FFmpeg 的依赖是体系化的:libavcodec、libavformat、libavutil、libswscale、libswresample,每个都有体量,再加上你可能还需要 libx264、libmp3lame 这类外部编码器,编译产物突破 100MB 根本不费劲。
我曾经见过一个项目,就为了在录屏时做一次 H.264 转码,把整个 FFmpeg 全家桶打进了扩展,结果控制中心点击录制按钮之后,扩展启动要等两秒多,录制过程中还频繁被系统杀进程。后来定位到根因就是扩展二进制太大,导致系统在加载和运行时的资源评估都很紧张。
2.2 裁剪策略一:抛弃通用转码库,改用系统原生框架
很多人下意识觉得,做视频处理必须用 FFmpeg,因为在服务端和桌面端,FFmpeg 就是事实标准。但 iOS 上有一套完全被低估的原生方案:VideoToolbox + AVFoundation + CoreMedia。
VideoToolbox 在 iOS 8 之后就公开了,硬件的 H.264/H.265 编码器通过VTCompressionSession就可以调用。关键区别是:VideoToolbox 是系统框架,编译产物只有几 KB 的调用代码,而 FFmpeg 是把你需要的一切打进你的二进制。同样的编码能力,前者几乎不占体积,后者动辄几十 MB,这个差距不用犹豫。
有人可能会有疑问:VideoToolbox 能做到 FFmpeg 那样的参数调优吗?事实是,基础场景完全够用。通过VTSessionSetProperty,你可以设置码率、帧率、GOP 间隔、Profile 等级,这些都是直播录屏最刚需的参数。它不擅长的是非常规容器封装,比如直接写 MKV、TS 流,但在 iOS 生态里,你最终不是进 MP4 就是推 RTMP,这两个 VideoToolbox + 手写 FLV 都可以满足。
2.3 裁剪策略二:架构剥壳与编译选项
如果项目中有些代码实在绕不开,只存在于扩展 target 里,那也要尽量控制体积来源。几个见效快的操作:
只保留 arm64 架构。iOS 11 之后所有真机都是 arm64,扩展 target 的
VALID_ARCHS和ARCHS_STANDARD只需要保留 arm64,不要顺手把模拟器架构编进去。有人给扩展做 Debug 调试时误把x86_64一起编出来,产物直接翻倍。开启 Strip Linked Product。在 Build Settings 里搜
Strip Linked Product,设为 YES;Dead Code Stripping也打开。这能去掉未引用的符号,减少体积。不要开启 bitcode。bitcode 现在已经被苹果废弃,但它如果开着,会在包内保留中间表示,增加体积,扩展场景没必要留着。
用编译条件隔离扩展用不到的代码。很多项目共用一套核心代码库,主 App 里乱七八糟的埋点、网络库、UI 库全都被链接进扩展。建议在扩展 target 的
Other Linker Flags里手动排除非必要模块,或者用#if宏把扩展相关的类型独立出来。
以上这些不是乱七八糟的优化技巧,而是在 50MB 预算下必须遵守的基本纪律。我自己的经验是:扩展二进制压到 10MB 以内,录制启动速度、系统存活率都会有一种"明显更顺"的感觉。
3. 一次性理清 Broadcast Extension 的进程边界与通信机制
3.1 点击录屏按钮后,系统到底做了什么
先把链路走一遍:
- 用户在你的 App 里点击了一个
RPSystemBroadcastPickerView控件,这是系统提供的广播选择视图。 - 系统弹出可用广播扩展列表,用户选择"我做的那个录屏引擎"。
- 系统启动扩展进程,加载
SampleHandler,并调用broadcastStarted(withSetupInfo:)。 - 系统开始接管屏幕采集,把视频/音频样本源源不断地推给扩展进程的
processSampleBuffer(_:with:)。 - 用户通过控制中心停止录制,或者你的 App 通过某个 API 触发停止,系统调用扩展的
broadcastFinished()。
很多人卡在第 3 步。setupInfo里能拿到什么?取决于你是通过哪种方式启动的。如果用户是从控制中心的录制列表里启动,setupInfo基本是空的;如果你的 App 需要用自定义参数(比如直播间 ID、推流地址),那就要在 App 侧通过RPSystemBroadcastPickerView的preferredExtension指定扩展,并借助共享存储把参数传过去。
3.2 App Group:扩展与主 App 之间的共享通道
由于扩展是独立进程,它和主 App 之间没有直接的调用关系,连单例都不共享。想让扩展拿到 App 侧配置的推流地址,最直接的方式是App Groups + UserDefaults。
在 Xcode 里给主 App target 和扩展 target 都打开 App Groups 能力,添加同一个 Group ID,然后:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.screenrecord") sharedDefaults?.set("rtmp://your-server/live/stream-123", forKey: "streamURL") sharedDefaults?.set("room-abc", forKey: "roomID") sharedDefaults?.synchronize()扩展侧再读:
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.screenrecord") let streamURL = sharedDefaults?.string(forKey: "streamURL") let roomID = sharedDefaults?.string(forKey: "roomID") // 用这两个参数初始化编码器和推流器 }要注意的是,UserDefaults 虽然方便,但它不适合传输高频率数据。你每一帧视频都往 UserDefaults 写?那性能基本就废了。它只适合低频的配置同步、状态同步,比如"录制是否在进行""直播房间号是多少"。
真正的高频数据,比如用户录制完成后把扩展落盘的临时 TS/MP4 文件移交主 App,走的是App Group 容器目录:
let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.com.yourcompany.screenrecord" ) let outputURL = containerURL?.appendingPathComponent("record-\(timestamp).mp4")主 App 再从同一个 container 目录读出来做二次处理或上传。这里有个关键细节:容器目录容量也别当垃圾桶,要定期清理过期文件,否则用户手机空间会因为录屏持续增长而被撑爆。
3.3 跨进程通信:用 Darwin 通知做扩展与 App 的"喊话"
UserDefaults 只能被动地读取,但很多场景需要主动通知。比如用户从控制中心点了停止录制,扩展收到了broadcastFinished(),此时 App 还在前台,需要立刻刷新 UI 状态;或者扩展录制过程中出了错误,需要告诉 App 弹个提示。
这种情况,我推荐用CFNotificationCenter 的 Darwin 通知。它的特点是系统级的跨进程广播,App 和扩展里都能收发。
发送端(扩展侧):
CFNotificationCenterPostNotification( CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("com.yourcompany.screenrecord.broadcastFinished" as CFString), nil, nil, true )接收端(App 侧):
CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), nil, { _, _, _, _, _ in // 收到广播结束的通知,刷新 UI 或触发上传逻辑 }, "com.yourcompany.screenrecord.broadcastFinished" as CFString, nil, .deliverImmediately )需要注意,Darwin 通知本身不能带数据,它只负责"喊一声"。具体要传的数据,仍然建议通过 App Group 的 UserDefaults 或容器文件来读写。这套组合,到目前为止是我见过最稳的方案:高频小数据走 Darwin 通知做触发信号,低频配置走 UserDefaults,大块文件走容器目录。分工清楚,各干各的。
4. SampleHandler 逐回调拆解:样本类型、时间戳与内存释放
4.1 broadcastStarted:初始化编码器而不是做重活
很多人的第一个错误,就是在broadcastStarted里做了太多事情:创建网络连接、拉配置、初始化一堆对象。要知道,这个回调执行在扩展进程刚启动的瞬间,系统在等待你的扩展尽快进入就绪状态,你在这里耗的时间越长,用户看到的"点击录制后黑屏/卡顿"就越明显。
正确的做法是:在这个回调里只做必须的初始化。读共享配置、创建串行队列、创建 VideoToolbox 编码器,然后快速返回。千万别在同步代码里发 HTTP 请求等响应,那直接就是灾难。
示例:
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { videoQueue = DispatchQueue(label: "com.yourcompany.video") audioQueue = DispatchQueue(label: "com.yourcompany.audio") let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.screenrecord") streamURL = sharedDefaults?.string(forKey: "streamURL") setupVideoCompressor() }4.2 processSampleBuffer 里的三类样本:video、audioApp、audioMic
RPSampleBufferType一共有三种枚举值:
| 类型 | 含义 | 常见内容 |
|---|---|---|
.video | 屏幕采集的视频帧 | 未压缩的 YUV 420 格式 pixel buffer,分辨率跟随屏幕 |
.audioApp | App 内部的音频输出 | 系统采集的 App 声音,采样率不是 48K |
.audioMic | 麦克风采集的音频 | 需要用户授权麦克风,采样率和声道数与 audioApp 不同 |
很多新手会在这里犯迷糊:自己录屏没开麦克风,为什么还要处理 audioMic?答案是,只要用户在系统录屏控制条里打开了麦克风,系统就会把麦克风样本也推给扩展。如果你不做任何处理,音频就直接丢了,用户会以为你的功能漏了麦克风。反过来,你如果想主动混合双路音频,又要考虑时间戳对齐,非常复杂。
我自己的建议是:第一版只处理.video和.audioApp,.audioMic直接忽略。如果产品要求录屏带主播解说,那也先走系统原生的双路采集,在服务器端合并,而不是在扩展里自己混音。扩展里混音的复杂度,远比你想象的高。
处理样本时还有个大坑。CMSampleBuffer在回调返回之后就可能被系统回收,如果你要把它的处理放到异步队列里,就必须做一次深拷贝,或者确保在处理完成前引用计数不被释放。最简单的做法是直接用CMSampleBufferCreateCopy复制一份,交给自己的队列再处理。如果你不加处理直接马上同步编码完,那就无所谓,但大多数场景下我们都需要放进队列排队,所以这条必须注意。
下面是基础骨架:
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { switch sampleBufferType { case .video: guard let copy = createSampleBufferCopy(sampleBuffer) else { return } videoQueue.async { [weak self] in self?.encodeVideoFrame(copy) } case .audioApp: guard let copy = createSampleBufferCopy(sampleBuffer) else { return } audioQueue.async { [weak self] in self?.encodeAudioFrame(copy) } case .audioMic: break @unknown default: break } }4.3 时间戳是音画同步的唯一依据
ReplayKit 推给扩展的样本,每一份都带CMSampleTimingInfo,里面有presentationTimeStamp。视频和音频的时间戳基准是不同的时钟域,但 ReplayKit 内部已经把它们做了换算,所以你在扩展里直接取 PTS 即可,不需要自己做基准转换。
真正容易出问题的是把样本交给 VideoToolbox 和音频编码器之后。视频编码有一个 GOF 的概念,音频编码也有AudioBufferList的处理时序。如果编码器的输入顺序和你提交的顺序不一致,最后封装进 MP4 或推流时音画就会对不上。
我的方法是:视频流和音频流各自维护一个时间戳队列,不做复杂同步,以视频 PTS 为基准,音频 PTS 与视频 PTS 差值超过一个阈值时,做音频帧重采样或丢弃。直播场景下,音频延迟是可容忍的,但也不能无限积累。
4.4 finish 与错误处理:宁可显式失败,不要静默消失
broadcastFinished()是正常结束,比如用户从控制中心停止录制。这个回调里你要把编码器 flush、把剩余缓冲写完、关闭文件句柄,释放资源。
如果录制中途出了问题,比如推流服务器连不上、编码器创建失败,一定不要静默吞掉。系统提供了finishBroadcastWithError(_:),调用后系统会弹一个明确的错误提示给用户,而不是让用户以为录屏还在进行。
let error = NSError( domain: "com.yourcompany.screenrecord", code: -1001, userInfo: [NSLocalizedDescriptionKey: "无法连接到直播服务器"] ) finishBroadcastWithError(error)这个 API 是个好东西。很多开发者害怕弹错误提示给用户,觉得不体面。但真实体验是:用户看到明确的"联不上服务器",比看到一段花了五分钟录出来却是坏文件的录屏,体验好一百倍。
4.5 内存释放:躺着被杀进程的元凶
SampleHandler 里最常见的内存泄漏点就是processSampleBuffer里创建的CMSampleBuffer副本没有及时CFRelease。尤其是用了CMSampleBufferCreateCopy之后,Swift 的 ARC 并不会自动管住 CoreFoundation 对象,你必须手动释放。
再一个就是编码器回调里的输出 buffer。VideoToolbox 的编码回调会给你新的CMSampleBuffer,用完不释放,内存曲线就一直往上爬,直到系统 jetsam 把扩展干掉。理论上说,小内存积累看不出来,但录屏是长时间运行的操作,用户很可能录一个小时,任何一个细微泄漏都会被放大到不可忽视。
我的经验是:在扩展里不要依赖系统的内存警告去兜底,而是要自己控制队列深度。比如视频处理队列里最多排队 3 帧,超过就丢帧,丢帧比内存崩溃更容易接受。
5. 用 VideoToolbox 在扩展里做 H.264 编码:选型与参数调优
5.1 为什么 VideoToolbox 是扩展内的唯一合理选择
回到 50MB 这个核心约束。你在扩展里能动的空间就这么大,什么方案能在不引入重型库的情况下完成 H.264 编码?答案就是系统的 VideoToolbox。
VTCompressionSession就是系统硬件编码器的统一入口。ReplayKit 推给扩展的视频帧,通常是未压缩的 CVPixelBuffer(YUV 420 BiPlanar),直接喂给VTSession就能完成编码,中间不需要任何转码。
而且,硬件编码器是耗电最少的编码方式,对长时间录屏非常关键。如果用 FFmpeg 的软件 x264 编码,iPhone 发热量会迅速上升,屏幕录制帧率也会掉得厉害,用户体验很差。这个维度上,VideoToolbox 有压倒性优势。
5.2 创建编码会话与参数配置
创建编码器的核心代码:
var compressionSession: VTCompressionSession? let encoderSpecification: [CFString: Any] = [ kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true ] let status = VTCompressionSessionCreate( allocator: kCFAllocatorDefault, width: width, height: height, codecType: kCMVideoCodecType_H264, encoderSpecification: encoderSpecification as CFDictionary, sourceImageBufferAttributes: nil, compressedDataAllocator: nil, outputCallback: videoCompressOutputCallback, refcon: &compressionSession, &compressionSession )然后设置编码参数:
VTSessionSetProperty(compressionSession!, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) VTSessionSetProperty(compressionSession!, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Main_AutoLevel) VTSessionSetProperty(compressionSession!, key: kVTCompressionPropertyKey_AverageBitRate, value: 2_500_000) VTSessionSetProperty(compressionSession!, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: 60)这里解释几个参数的取舍:
- AverageBitRate:2.5Mbps 是直播场景里 720p 到 1080p 之间的一个平衡点。如果画面是动态操作类(比如打游戏),码率建议调高到 4-5Mbps,否则动态画面会有大量马赛克。如果只是静态界面展示,2Mbps 都够了。
- MaxKeyFrameInterval(GOP):60 表示每 60 帧出一个关键帧,也就是 2 秒一个关键帧(30fps 下)。直播场景下,这个数值决定用户端拉起视频流后多久能看到画面。关键帧间隔时间太长,观众刷新页面会一直黑屏。
- RealTime:这很重要,告诉编码器这是实时会话,别为了一点点压缩率牺牲速度。
5.3 编码回调里拿到 H.264 数据后怎么走
当编码器完成一帧压缩后,回调会产出CMSampleBuffer,从这个 buffer 里可以拿到 H.264 的 NAL Unit 数据。如果是推 RTMP,你要从CMFormatDescription里解析出 SPS/PPS,封装成 FLV 的 Video Tag;如果是本地写 MP4,可以直接把CMSampleBuffer交给 AVAssetWriter 的AVAssetWriterInput。
这里有个新手很容易忽略的问题:H.264 的 NAL 有两种封装格式,AVCC(长度前缀)和 Annex-B(起始码)。VideoToolbox 默认输出 AVCC,而某些直播服务器或播放器需要 Annex-B。RTMP/FLV 规范用的是 AVCC,所以通常不用转,但如果你要生成 HLS 的 TS 流,大概率就需要转 Annex-B。转换本身不难,就是给每个 NAL 前加上00 00 00 01起始码,但有时间戳边界问题,做之前先确认你的接收端到底要哪种格式。
5.4 编码器内存峰值与贝塞尔曲线式的发热控制
加入 VideoToolbox 后,内存峰值主要来自编码队列里等待处理的原始视频帧。我在一个 1080p60 的录屏项目里实测,一帧 1080p 的 YUV 420 数据约 3MB,如果你不做背压控制,队列里堆 30 帧就是 90MB,分分钟触发 jetsam。
所以必须做背压控制。我常用的策略是:编码队列里 pending 的帧数超过 2 帧时,直接CMSampleBufferCreateCopy丢弃这一次的新帧。预想中会丢帧,但对于实时录屏来说,总比被系统杀进程好得多。
5.5 音频编码:从 LPCM 到 AAC 的必经之路
ReplayKit 的 audioApp 样本是可变的采样率,实测常见 16kHz。如果要推 RTMP 或者封装 MP4,几乎都要统一转成 AAC 48kHz / 44.1kHz。
在扩展里做音频重采样,最稳妥的方案是AudioConverter:
var audioConverter: AudioConverterRef? AudioConverterNew( &inputFormat, &outputFormat, &audioConverter )把AudioStreamBasicDescription配好,一个转换器就能完成重采样和格式转换。和视频一样,音频处理也放进自己的串行队列,不要阻塞 ReplayKit 的采集线程。
6. 直播推流的最小实现:分片缓冲、片段落盘与弱网降级
6.1 先想清楚:你要的是真直播,还是边录边传
很多人把需求打包在一起,以为"边录屏边推流"是一回事。实际上有两种完全不同的产品形态:
- 真直播:要求低延迟,用户在观看端几乎实时看到画面。这需要 RTMP/WebRTC,推流端要把 H.264 + AAC 封装成 FLV 或 RTP 包实时发送。
- 边录边传:允许几十秒甚至几分钟的延迟,录了一小段就传一小段,接收端拼接播放。这种可以走 HLS 分片,或者自己定义的分片上传协议。
第一种实时性极强,但对网络要求高,弱网下很容易断流。第二种容忍波动,实现简单得多。产品不明确需求时,我建议先做第二种:扩展里按时间切分文件,每个片段几百 KB 到几 MB,推到服务器后服务器按顺序拼接,就是一条完整的录屏。
6.2 分片落盘:用 AVAssetWriter 按时间切片
如果你要走"边录边传"路线,最简单的方式是用 AVAssetWriter 写入 MP4 临时文件,然后每隔固定秒数endSession一次,再重新创建新的 AVAssetWriter。
// 5 秒一个分片 let segmentDuration = CMTime(seconds: 5, preferredTimescale: 600) func startNewSegment() { // close current segment assetWriter?.finishWriting { [weak self] in // upload this segment file self?.uploadSegment(at: self?.currentSegmentURL) } // create new assetWriter for next segment }这个方案有几个需要注意的点:
- 切片点要放在视频关键帧处,否则每个片段的开头都花屏。你可以根据编码器输出统计
kVTEncodeInfo_FrameDropped和关键帧标志,来决策切片时机。 - 服务器端要有排序逻辑,比如片段文件名里带时间戳或者序号,防止上传乱序导致播放错乱。
- 每个片段用独立
.mp4(faststart)比用裸 H.264 好,兼容性更强。
我用这个方案做过一个最低成本的直播录屏:扩展进程分成 5 秒一个的 MP4,通过 URLSession 逐个上传到服务器,服务器端拼接后转 HLS,端到端延迟约 10-15 秒。对于教育、演示类场景,这个延迟完全能接受,而且极端弱网下用户看到的只是最新分片延迟变大,不会断流。
6.3 真 RTMP 推流的最小步骤
如果你就是要做真直播,RTMP 的增益点就是延迟低。最小实现思路是:
- 在
broadcastStarted里建立到 RTMP 服务器的 TCP 连接,做握手和 publish 命令。 - 编码器每输出一个视频帧,封装成 FLV Video Tag;音频帧封装成 FLV Audio Tag,按 PTS 递增顺序送进发送队列。
- 发送队列用一个串行 DispatchQueue,通过 Socket 发送。
核心代码大概是这样的骨架:
func sendFLVTag(tagType: UInt8, timestamp: UInt32, data: Data) { // 拼 FLV tag header + body // 写入 socket }具体 FLV Tag 的数据结构不复杂:1 字节 TagType,3 字节 DataSize,4 字节 Timestamp,3 字节 StreamID,然后就是数据处理。视频 Tag 的 Data 里第一个字节是帧类型(关键帧/非关键帧)和 CodecID,之后跟着 AVCC 数据;音频 Tag 的第一个字节是音频格式和采样率信息,后面是 AAC 原始数据。
RTMP 的难点不在 FLV 封装,而在时间戳计算:FLV 的时间戳是相对第一帧的毫秒数,你要用每帧的 PTS 减去首帧 PTS。坐标统一做好这个,音画同步的基础就稳了。
6.4 弱网降级:别让网络拖死录制
直播场景最怕的是网络抖动直接导致录制中断。我见过很多实现,推流失败后直接finishBroadcastWithError,用户长长的录屏就这么没了。
更合理的策略是分级降级:
- 网络好的时候:直播推流,码率 2.5Mbps。
- 网络变差时:降低视频码率到 1Mbps,同时把数据同时落盘到容器目录,保留后续补传的可能。
- 网络真的断了:不结束录制,只停止推流,继续本地落盘。恢复后自动发起上传。
这个方案需要一个网络状态检测模块,以及缓冲队列的堆积控制。扩展里尽量别自己造轮子,用Network.framework或者NWPathMonitor监听网络状态,然后调整编码器码率和上传策略。
7. 真机调试与线上问题排查:控制中心、无声、崩溃的三类坑
7.1 控制中心找不到你的"录屏/直播"按钮
这是做 Broadcast Extension 最让人抓狂的问题。代码都是对的,但在控制中心的录制按钮长按菜单里,就是看不到你自己做的扩展。
我的排查顺序是这样的:
确认扩展 target 的 Info.plist 完整。
NSExtensionPointIdentifier必须是com.apple.broadcast-services-upload,NSExtensionPrincipalClass指向你的SampleHandler类。有一个遗漏,系统就不会把你的扩展注册到控制中心里。<key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> <string>com.apple.broadcast-services-upload</string> <key>NSExtensionPrincipalClass</key> <string>$(PRODUCT_MODULE_NAME).SampleHandler</string> <key>NSExtensionAttributes</key> <dict> <key>RPBroadcastProcessMode</key> <integer>2</integer> </dict> </dict>确认扩展已嵌入主 App。在 Build Phases 里有 "Embed App Extensions",把编译出来的
.appex塞进主 App 的 PlugIns 目录。如果缺失,运行时也不报错,但系统就是找不到你的扩展。确认是装到真机,且重新安装过 App。模拟器对 Broadcast Extension 的支持是有限的,自定义扩展在大多数模拟器版本上都不稳定。推荐直接用真机测试,并且在改动扩展的 Info.plist 后,先删除旧 App 再重新安装,防止系统缓存旧的注册信息。
检查系统控制中心设置。iOS 15 之后,广播扩展在控制中心的显示逻辑有变化,用户需要在设置里允许某些扩展显示。不过多数情况下,扩展正确安装后就会自动出现在列表里。
7.2 录到了画面,但没有声音
录屏没声音是高频问题,常见原因有三种:
- 只实现了视频流,没实现音频流。回到
processSampleBuffer,如果你只处理.video,不处理.audioApp,那么用户录到的就是无声视频。 - 音频样本被丢进了错误的队列。音频帧如果送进视频编码队列,会因为格式不匹配直接报错或丢数据,导致音频缺失。
- 音频重采样参数错误。例如把 16kHz 的 LPCM 直接用 44.1kHz 的 OutputFormat 去重采样,没有通过 AudioConverter,出来的就是一片噪声或者彻底无声。
排查方法也很简单:扩展里做日志埋点,在屏幕上录制一小段,然后检查日志里audioApp的CMSampleBuffer是否到达、编码回调是否有音频帧输出。找到断点就很容易定位。
7.3 音画不同步:渐进式延迟 vs 固定偏差
音画不同步有两种表象:固定偏差和渐进式延迟。
固定偏差常见于音频与视频的起始时间没对齐。比如视频帧从pts=0开始编码,音频帧从pts=0.5s开始编码,结果就是音频整体比视频晚 0.5 秒。解决方法是编码前把第一帧 PTS 归一化到 0,后面所有帧减去第一帧 PTS。
渐进式延迟更麻烦,通常是音频重采样引入了额外延迟,或者编码器缓存了过多帧没及时输出。检查一下你的音频重采样模块是否在不停累积数据,以及视频编码器是否有帧等待队列堆积。我处理过的一个真实案例是 AudioConverter 的mBufferSize设置过大,导致每次转换多预留了 100ms 的音频数据,长时间录制后延迟越积越大。
7.4 录制过程中扩展被系统杀掉,怎么办
扩展被 jetSam 杀掉,在用户那里表现为"录着录着突然停了",在你这边是"日志里直接找不到任何 crash 回溯,进程就没了"。
要降低被杀概率,最有效的两件事:
- 把内存峰值压在 50MB 以内。不要积压视频帧队列,不要缓存大块临时数据,能落盘就落盘。
- 控制 CPU 占用。系统对后台进程的 CPU 占用也是监控的,如果长时间高 CPU,即便内存没问题,也可能被系统优化掉。直播场景里合理降低视频分辨率/帧率,比如 30fps 降到 24fps,能明显改善扩展的稳定性。
7.5 性能验证 checklist
项目上线前,按这个清单过一遍:
| 检查项 | 参考标准 |
|---|---|
| 扩展二进制大小 | 目标 10MB 以内,极限不超过 50MB |
| 录制 30 分钟内存峰值 | 稳定在 50MB 附近,不上涨 |
| 1080p60 编码 CPU 占用 | 扩展 + 主 App 合计不超过 60% |
| 断网后的降级行为 | 本地持续录制,网络恢复后自动补传 |
| 停止录制到文件可播放 | 3 秒内完成 flush,文件能正常播放 |
| 音画同步误差 | 全程不超过 80ms |
| 控制中心启动扩展耗时 | 从点击按钮到开始采集 < 2 秒 |
我每次迭代版本,都要在真机上跑一遍这套清单。最容易翻车的不是功能逻辑,而是长时间稳定性和内存曲线,这两项必须反复测。
7.6 一个建议:别在扩展里做"万能"
看完了整套实现你会发现,Broadcast Extension 的核心优势是帮你拿到了原始的屏幕数据流,但它不是用来做重计算的地方。凡是能在主 App 或者服务器端做的事,就别塞进扩展里。
我见过有团队想在扩展里直接做 AI 识别、弹幕渲染、复杂特效,最后无一例外都在稳定性上栽了跟头。正确做法是:扩展只做三件事——采集、编码、传输/落盘。真正的水印叠加如果一定要做,用 VideoToolbox 的SourceImageBufferAttributes配合 Core Image 在编码前处理,也要尽量控制复杂度。其他重活,等录制结束后交给主 App 的服务端组件去处理,从容得多。
讲这么多,其实核心就是想让大家明白:ReplayKit 的 Broadcast Extension 是一个边界非常清晰的"管道"型组件,它的上限由 50MB 预算、进程存活率和系统实时性共同决定。尊重这套约束,把扩展做成轻量、稳定、只管数据流的引擎,你的录屏功能才能扛住长时间录制和弱网环境。别把它当成一个万能的视频工作站,它真的只是一个高效的数据搬运工。