iOS ReplayKit录屏实战:突破50MB内存红线,实现高效HLS直播推流
做过 iOS 录屏功能的开发者,应该都听过那个数字——50MB。这几乎是 ReplayKit 开发绕不开的一道坎。我去年接到一个项目:帮一款互动教学App做“屏幕共享直播”功能。教学场景里老师要一边讲解一边写板书,学生端要能实时看到老师的屏幕。方案很自然就落到了 iOS 系统级录屏 ReplayKit 上,而真正的挑战不是“能不能录”,而是“在 50MB 内存红线以下,怎么保证长时间稳定地录、压缩、上传”。
项目上线后,我踩了一堆坑,也把这些坑一个一个填平了。这篇文章就把整个实战过程完整地梳理一遍——从架构选型、Broadcast Extension 的启动链路,到 VideoToolbox 硬编码配置、HLS 分片上传策略,再到针对内存限制的优化措施,最后是常见问题的排查实录。如果你正在做 ReplayKit 录屏、直播推流、屏幕分享 SDK,或者只是单纯对“iOS 系统如何管理后台 Extension 进程”感兴趣,这篇内容应该能帮到你。
1. 整体设计与思路拆解
1.1 录屏需求与技术选型:为什么绕不开 ReplayKit
先聊聊需求本身。当时客户的需求是:老师在 iPhone / iPad 上进行屏幕广播,画面要实时传给教室大屏或者远端学生端。这个场景本质上属于“屏幕采集 + 编码 + 网络传输”,和游戏直播、App 内录屏分享非常相似。
iOS 平台上系统级录屏的公开方案,到今天依然只有一个真正靠谱的选择——ReplayKit。有人可能会提自研屏幕采集,比如通过私有API捕获 IOSurface,但这类方案在审核环节有巨大风险,而且系统版本一升级就很容易失效。ReplayKit 从 iOS 12 开始提供了比较完整的屏幕录制接口,加上 Broadcast Upload Extension 机制,能够把录屏数据流式地交给我们自己的 Extension 进程做处理,这是完全合规的公共 API 路线,也是绕不开的路线。
ReplayKit 涉及两个关键组件:一个是主 App 里用来触发录屏的
RPSystemBroadcastPickerView
;另一个是真正的数据处理者
Broadcast Upload Extension
。主 App 调用
RPBroadcastActivityViewController
或者
RPSystemBroadcastPickerView
拉起系统的录屏弹窗,用户点击“开始直播”之后,系统会把带有自定义参数
setupInfo
的启动指令交给 Extension,由 Extension 在独立进程里接管后续的所有数据流。整个过程对用户来说只是“控制中心里点一下”,但对我们开发者来说,这扇门打开之后,真正的内存斗争才刚刚开始。
提示:如果你做的是“App 内嵌入录屏功能”而非系统级录屏,还可以考虑
RPScreenRecorder的startCaptureAPI。它可以直接在 App 进程内拿到视频帧和音频帧,绕开 Extension 的坑。但它的场景限制也很明显——只能录当前 App 内的画面,录不了整个系统。所以对于“课堂广播”“系统屏幕分享”这类需求,还是必须走 Broadcast Upload Extension。
1.2 50MB 红线到底是什么
很多第一次接触 Extension 的开发者会有一个错觉:Extension 也是我们自己写的,内存随便用吧。结果代码一跑,录屏开始 3 秒,Extension 进程直接被系统杀死,控制中心里的录屏按钮也自动熄灭了。翻日志才发现,问题出在 Jetsam 上。
Jetsam 是 iOS 内存管理的一个核心机制,当系统内存压力较大时,它会根据每个进程的优先级和内存占用,选择杀掉一些进程来释放内存。App Extension 的 jetsam 限制历来比主 App 严格很多,网上最普遍的说法就是 50MB。实际上不同 iOS 版本、不同机型上这个阈值不完全一样,有可能接近 60MB,也有可能只有 40MB 出头,但量级基本就是几十 MB。所以业内干脆把这条线叫作“50MB 红线”——意思就是,在这个限制以下,你勉强能活;一旦超过,系统随时会请你出去。
我之前从系统的 jetsam 日志里看到过这样一个条目:
memorystatus_jetsam_snapshot: killed process: ReplayKitUploadExtension [pid: xxx]
这个日志基本可以确诊“Extension 内存爆了”。区别只在于,是高峰期超过了硬上限被立即杀掉,还是内存持续增长导致系统触发整体回收时被优先选中。不管是哪一种,对于录屏这种需要长时间运行的任务来说,都是致命的。
理解了这一层,“设计目标”就很清晰了:我们要把单帧处理、编码缓冲、文件写入、上传队列的峰值内存全部压在一个极小的池子里。所有的数据流经过 Extension 时都要快速通过,能写文件就不留内存,能硬编码就不软编码,能丢帧就绝不等缓存。
1.3 总体架构与进程分工:Extension 少干活,主 App 多干活
基于上面的约束,我把整体架构定成了“采集->硬编码->分片落盘->主App上传”四层链路:
-
ReplayKit 负责系统屏幕和麦克风数据的采集,通过
startCapture的回调把音视频样本交到 Extension 进程; -
Extension 内用 VideoToolbox 的
VTCompressionSession做 H.264 硬编码,音频做 AAC 转码; - 编码后的 ES 流按照固定时长切片,直接写入 App Group 共享容器里的临时文件;
- 主 App 监听共享目录的新分片文件,读取后通过 HTTP 上传到服务端,服务端负责 HLS 协议分发。
这个方案的核心思路是: Extension 永远不持有大块内存,它只做采集和编码,编码后立刻以文件形式吐出去 。上传、网络请求、重试逻辑全部放在主 App 进程里跑。主 App 内存上限比 Extension 宽松得多,即使弱网导致上传队列积压,也还有几百 MB 的空间可以周转。
为什么不用“Extension 内直接上传”的设计?我试过,也见过一些团队这么干。听起来流程短,但坑非常明显:一旦网络抖动,HTTP 任务堆积,内存立刻飙升;而且 Extension 进程随时可能被系统回收或重启,网络状态根本不可控。相比之下,分片文件天然有持久化能力,系统杀掉 Extension 之后,主 App 还能继续上传已经落盘的文件,稳定性高一个量级。
2. Broadcast Extension 基础架构与启动链路
2.1 Extension 生命周期里那四个方法
Broadcast Upload Extension 的核心生命周期非常简洁,总共只有四个入口:
override func broadcastStarted(withSetupInfo setupInfo: [String : NSCoding]?) {
// 用户点击开始直播,Extension 进程被系统拉起
}
override func broadcastPaused() {
// 用户从控制中心暂停录屏
}
override func broadcastResumed() {
// 用户恢复录屏
}
override func broadcastFinished() {
// 用户结束录屏,需要清理所有资源
}
broadcastStarted
里的
setupInfo
是从哪来的?这就涉及 Broadcast Setup UI。系统在做 Broadcast Extension 时,除了 Upload Extension 之外,还会加载一个 Setup UI Extension。用户在系统弹窗里点击“开始直播”后,系统会先启动 Setup UI,等用户在 Setup UI 里确认配置(比如推流地址、房间号),Setup UI 再通过
finishBroadcastWithError
或者
loadBroadcastApplicationWithSetupInfo
回调把参数传回系统,再由系统启动真正的 Upload Extension。
实际操作里,
setupInfo
会被序列化传给 Extension,但它的容量非常有限,实测传一个几百字节的 JSON 没问题,传大图、大字典就有风险。所以当时我把房间号、推流地址、分辨率偏好等都放进了这个 JSON,密码、token 这种敏感信息则不放,由 Extension 启动后通过共享容器里的配置文件读取,避免在启动参数里传敏感数据。
2.2 RPSystemBroadcastPickerView 的启动方式
主 App 这边,触发录屏最直接的方式是用
RPSystemBroadcastPickerView
,iOS 12 以后开放。这个组件比较奇葩,它本质上是一个系统级按钮,你不能接管它的点击逻辑,只能把它加到界面上,用户点它的时候,系统会自动弹起录屏选择窗。
let broadcastPicker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
broadcastPicker.preferredExtension = "com.yourcompany.app.ReplayKitExtension"
broadcastPicker.showsMicrophoneButton = true
有个非常关键的坑:
preferredExtension
必须写成 Extension 的 bundle identifier,如果和实际不符,点击后系统窗口里会一片空白,完全拉不到你的 Extension。另外这个组件在模拟器上经常表现为点击无反应,最终必须要真机调试。
还有一个公认的限制:系统弹窗不能被完全定制,你只能在外面包一层自定义样式,弹窗本身是系统 UI。而且 iOS 14 以后,这个弹窗出现的位置、动画都不可控制,弹窗上甚至不允许开发者显示自己的 logo 或说明文案。只能接受系统规则。
注意:真机上第一次点这个按钮,系统会弹权限确认框,用户允许后才会进入录屏状态。录制过程中状态栏会变成红色,用户切到主 App 时能看到自己的 App 处于“正在录制”的视觉状态。这都是系统行为,不要试图屏蔽或者隐藏,否则审核会有麻烦。
2.3 进程间通信选型:为什么选了 App Group + 本地 Socket
Extension 和主 App 天然是两个进程,所有数据共享都要通过某种 IPC 机制。我对比过几个常见方案:
| 方案 | 优点 | 缺点 | 是否适合视频流 |
|---|---|---|---|
| App Group + 共享文件 | 简单、持久化、不易被系统回收 | 实时性略差,大量写入有 IO 压力 | 合适,按分片写 |
| CFMessagePort | 编程模型简单 | 真机跨进程不稳定,经常收不到消息 | 不合适,尤其不适合大数据 |
| Darwin Distributed Notification Center | 轻量、实时 | 只适合小消息,不适合传数据体 | 不合适 |
| 本地 Socket / Unix Domain Socket | 流式、实时、可控性强 | 需要处理粘包、断线重连 | 合适,适合流式帧数据 |
最终采用了“App Group 共享容器 + 分片文件 + 目录监听”的组合。Extension 每写完一个分片,往共享目录下写入一个
.ts
文件,同时更新
.m3u8
播放列表,主 App 通过
DispatchSource
监听目录变化,发现新文件就读取上传。
这里没有用 Socket 作为主链路,原因是文件方案的容错性好得多:Extension 被系统杀掉后,已经落盘的文件不会丢,主 App 可以续传;Socket 一旦某一端进程死了,连接就断了,恢复逻辑复杂。文件 + 目录监听反而最稳。唯一的缺点是 IO 频繁,但实测在 1080p、5Mbps 码率下,每 2 秒才写一个 1.25MB 的文件,SSD 完全没压力。
3. 核心实现:视频硬编码与 HLS 分段上传
3.1 RPScreenRecorder 的 startCapture 配置
在 Extension 里拿到视频流的标准做法是使用
RPScreenRecorder.shared()
:
let recorder = RPScreenRecorder.shared()
recorder.isMicrophoneEnabled = true
recorder.startCapture { (sampleBuffer, bufferType, error) in
// 每一帧视频或音频都会进入这个回调
} completionHandler: { error in
// 启动完成
}
需要注意几个细节:
-
startCapture的回调不是主线程,而且频率非常高。里面绝对不能做重操作,最好只是丢到一个内部队列里处理,避免阻塞回调导致丢帧。 -
isMicrophoneEnabled必须在startCapture之前设置,否则麦克风数据采不到。 -
回调的
sampleBuffer类型由bufferType区分,可能是.video也可能是.audio。视频帧是CMSampleBuffer,里面带着CVPixelBuffer;音频则是CMSampleBuffer包装的AudioBufferList。 -
Extension 自身没有 UI 界面,所以初始化逻辑一般放在
broadcastStarted之后立刻执行。但要注意,startCapture的调用时机不能太晚,否则用户已经点击了开始直播,画面还要黑一两秒。
采集分辨率不是直接传一个数字,而是通过
recorder
内部默认的分辨率配合视频方向的维度信息。实测在大多数机型上会输出 1920x1080 或者 1080x1920 的画面。直播场景我一般建议在服务端做规格转换,客户端尽量保持源分辨率,避免二次缩放带来的 CPU 开销和画质损失。
3.2 VideoToolbox 硬编码参数:码率、GOP 与画质博弈
视频帧拿回来之后,要立刻交给 VideoToolbox 做 H.264 编码。为什么不直接用系统自带的
RPRecording
保存成文件?因为直播场景需要拿到裸流切片,系统保存文件的方式没法按需分包。所以必须自己挂编码器。
编码器的核心配置如下:
var compressionSession: VTCompressionSession?
VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width,
height: height,
codecType: kCMVideoCodecType_H264,
encoderSpecification: nil,
imageBufferAttributes: nil,
compressedDataAllocator: kCFAllocatorDefault,
outputCallback: outputCallback,
refcon: nil,
&compressionSession
)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_High_AutoLevel)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_AverageBitRate, value: 5_000_000)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: 30)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: 60)
VTSessionSetProperty(compressionSession, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
VTCompressionSessionPrepareToEncodeFrames(compressionSession)
几个参数的选择和背后的逻辑:
- ProfileLevel 选择 High :High profile 的压缩效率明显优于 Baseline/ Main,在同等码率下画质更好。广播教学场景主要是文字、板书、PPT,高频细节多,High profile 更能保住锐利度。
- 码率 5Mbps :1080p 30fps 的直播,5Mbps 在 H.264 下是个比较均衡的档位。如果码率太低,板书文字碾压成一片马赛克,教学场景没法看。码率也不是越低越好,码率低到一定程度,视频编码器反而会因为质量补偿机制产生大量瞬时波动,影响实时性。
- 关键帧间隔 60 帧(2 秒) :这个和 HLS 分片时长强相关。如果分片是 2 秒一个,那关键帧间隔最好也控制在 2 秒,使每个切片都能以 IDR 帧开头。否则用户切换分片时的首帧会出现花屏或者等待问题。
- RealTime = true :告诉编码器走低延迟模式,不回填太多帧做 B 帧重排。这决定了直播场景的 CPU 占用和编码延迟,是必须开的关键项。
- AllowFrameReordering = false :关闭 B 帧重排。B 帧虽然压缩率高,但编码顺序重排会导致延迟增大,直播不需要这种精度,关掉能有效降低内存和延迟。
编码回调中拿到的
CMSampleBuffer
包含了编码后的 H.264 数据,注意通过
CMSampleBufferGetDataBuffer
拿
CMBlockBuffer
,不要用
CMSampleBufferGetImageBuffer
(那是给原始帧用的)。拿到数据后取
startCode
或者长度前缀,切成 NALU 写入分片文件。H.264 裸流如果直接拼接要带上
SPS/PPS
,一般建议把 SPS/PPS 放在每个切片头部,或者用
AVCC
格式,否则播放器可能解不出来。
3.3 音频采集与转码:别把音频数据直接往文件里塞
音频部分相对简单,但坑也不少。
RPScreenRecorder
采集到的音频是 PCM 数据,格式类似播放器解码后的裸数据,不是压缩后的 AAC。如果你直接往 TS 封装格式里塞 PCM,播放器基本不认,标准流媒体传输普遍使用 AAC。
我们需要把 PCM 转成 AAC,用
AudioConverter
或者 AudioToolbox 的编码器:
let audioConverter: AudioConverterRef = ...
// 设置输入格式 outputFormat = AAC,sampleRate = 44100,channels = 1/2
转码过程中需要填
AudioBufferList
,每次回调时拿到的音频样本时长基本恒定(系统每隔一段时间给一个固定大小的 buffer)。这里有个非常重要的内存细节:音频样本如果不及时处理,堆积速度非常快。以 44.1kHz、双声道、16bit 采样计算,1 秒的音频裸数据约 176KB,30 秒就是 5MB+,对 50MB 的 Extension 来说占掉 10% 就属于危险信号。所以音频队列必须要短,处理不过来的情况下宁可丢弃,也不能堆着。
时间戳对齐也是个大坑。视频帧的时间戳来自视频采样时间,音频帧的时间戳来自音频采样时间,两者不能直接相加,否则音画不同步。我的做法是在
broadcastStarted
里记录
zeroTime
,之后视频和音频都使用相对时间戳:
let relativePTS = CMTimeGetSeconds(sampleBuffer.presentationTimeStamp) - zeroTime
这样统一的时间基准才能保证后续封装进 TS 文件时音画不乱。
3.4 HLS 分片写入策略:m3u8 与 .ts 的文件管理
直播分发我们采用了 HLS 协议。HLS 本身不是实时性最强的协议(常规 HLS 有 3~10 秒延迟),但胜在分发成熟、播放器兼容性好,而且配合小分片能逼近“准直播”体验。
分片时长我最初用了 6 秒,后来压到 2 秒。原因是 2 秒对应的文件大小约 1.25MB(5Mbps 码率),上传耗时可控,播放器拉流时也能更快启动,端到端延迟大概在 4~5 秒左右,可接受。分片时长如果太短(比如 0.5 秒),又会造成文件数量爆炸、HTTP 请求过多,反而拖垮上传。
写文件的逻辑:
-
编码器回调拿到一帧 H.264 数据后,直接
write到当前分片文件句柄; -
每凑够 2 秒(按视频 PTS 计算),关闭当前文件,生成
.ts; - 创建下一个分片文件,文件名带序号,保证播放器能按顺序解析;
-
更新
m3u8文件,写入或追加最新分片名,同时清理超过窗口的分片记录。
m3u8
文件很小,每 2 秒更新一次即可。所有文件句柄用完立刻关闭,不要为省一点点 IO 保持长连接,否则
Extension
会积累系统资源占用,长期运行容易触发内存警告。
提示:如果服务端支持的话,也可以让 Extension 只吐裸流的二进制切片,服务端自己拼装成 TS 再分发。这样客户端少做了文件封装逻辑,但需要服务端配合,且调试不如本地看 m3u8 文件方便。两者没有绝对优劣,取决于你的服务端技术栈。
4. 50MB 内存红线下的优化实录
4.1 用真实数据说话:Extension 内存到底怎么涨的
版本最初跑起来的时候,我通过 Xcode 的 Memory Report 观察 Extension 进程,发现数值一直在 60MB 到 100MB 之间跳,录屏 10 秒左右
broadcastFinished
就被系统调走了,再点开始直播也没反应。这就是被 Jetsam 杀掉的典型特征。
为了定位内存去向,我在 Extension 里加了一个内存监控,每 2 秒读取一次进程的
task_vm_info
:
func currentMemoryUsage() -> UInt64 {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &info) {
info_p in
info_p.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
int_p in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), int_p, &count)
}
}
return result == KERN_SUCCESS ? UInt64(info.phys_footprint) : 0
}
phys_footprint
才是进程实际占用的物理内存,和 Xcode 里 Activity Monitor 显示的数值比较接近。加了监控之后,我记录到这样一组数据:
| 运行阶段 | 内存占用 | 说明 |
|---|---|---|
| Extension 启动,未开始采集 | 15~20MB | 系统框架初始占用 |
| 开始录音频 + 视频,视频未编码 | 40~50MB | 屏幕帧回调堆积 |
| 编码器运行中,输出切分文件 | 55~75MB | 编码器缓冲区 + 帧积压 |
| 上传队列堆积 20 个分片 | 80MB+ | 大块 NSData 叠加 |
问题很清楚:既有“单帧缓冲太大”的问题,也有“编码器和文件队列在内存里积压”的问题。接下来逐个拆解。
4.2 内存大户排查:把每一块占用的来源砍掉
第一个内存大户是 CMSampleBuffer 的堆积。
startCapture
回调节奏完全由系统控制,有时候一瞬间会回调好几帧视频,如果我们处理不及时,回调队列里就会堆积大量
CMSampleBuffer
。CMSampleBuffer 持有
CVPixelBuffer
,一块 1920x1080 的 BGRA 原始帧大约 8MB,堆 3 帧就是 24MB,这是致命级别的内存占用。
解决思路是
回调里只保留最新的帧,直接丢弃老帧
。用一个
pixelsBuffer
变量加锁,新帧进来就替换旧帧,不等处理队列,不让系统有积压的机会。代价是可能造成轻微画面掉帧,但直播场景完全可以接受。
第二个内存大户是 VideoToolbox 编码器内部缓冲区。
RealTime
虽然开了,但如果 GOP 太长、CPU 负载太重,编码器还是会积压帧。解决方法是配合前面的丢帧策略,在编码前检查当前帧时间戳与上一帧时间戳的间隔,超过一定阈值(比如 100ms)就跳过这一帧,人为降低输入帧率。这样编码器的输入侧永远保持一个低水位的帧队列。
第三个内存大户是音频转码对象。如果每来一段音频就新建一个 AudioConverter 或相关对象,内存碎片和瞬时峰值都会非常高。音频转换器应该重用,且转换完立刻释放
AudioBufferList
副本。
第四个内存大户是上传文件时的
Data(contentsOf:)
。读取一个 1.25MB 的
.ts
文件到内存再上传,如果主 App 也粗心,会一次性把几十个文件全部读进内存。主 App 内存上限虽然宽,但也不能浪费。改成分片读取、流式上传,读一块发一块。
4.3 运行时内存水位监控与降级策略
代码层面写得再干净,真实使用场景里弱网、复杂画面、后台切换等依然可能造成瞬时内存暴涨。所以必须有一道最后防线:运行时内存监控 + 降级策略。
我的方案是在 Extension 里起一个 1 秒一次的定时器,读取
phys_footprint
,根据水位做分级处理:
| 内存水位 | 动作 |
|---|---|
| < 30MB | 正常模式,全帧率采集编码 |
| 30~40MB | 开启丢帧模式,帧率降到 20fps |
| 40~50MB | 降低视频码率到 3Mbps,强制关键帧间隔翻倍 |
| > 50MB | 暂停非必要操作,重置换入目录,停止上传队列的新分片产出 |
这个策略叫“降级不死亡”。直播可以画质差一点、帧率低一点,但不能整个录屏断掉。实测在极端弱网场景下(上传带宽 500Kbps 左右),通过降级策略撑住了 20 分钟以上的录屏,没有触发 Jetsam 杀掉 Extension。
注意:内存监控本身也要控制频率,不要用
Timer的高频模式,1 秒一次足矣。监控代码里不要做任何分配大块内存的操作,否则监控器自己就是内存杀手。
5. 常见问题与排查技巧实录
5.1 录屏开始几秒就自动结束,控制中心按钮熄灭
这是最典型的“Extension 被 Jetsam 杀”的症状。排查步骤:
- 连接 Xcode,打开 Window -> Devices and Simulators,查看设备日志;
-
搜索关键字
memorystatus或者Jetsam,找到被杀进程名; - 如果确认是内存问题,参考第 4 节的内存优化措施;
-
还有一种可能:Extension 的
broadcastStarted里做了耗时操作(比如网络请求),导致启动超时被系统判死。启动阶段不要做网络请求,等编码器起来后再异步处理。
5.2 直播流首帧黑屏,播放端等了很久才有画面
黑屏原因很多,最常见的是第一个 HLS 切片没有对齐到 IDR 帧。播放器从非 IDR 帧开始解析,无法还原画面。解决思路是:
-
设置
MaxKeyFrameInterval = 60,保证每 2 秒至少有一个关键帧; -
第一帧开始编码前,主动请求一个 IDR 帧(
VTCompressionSessionEncodeFrame加kVTEncodeFrameOptionKey_ForceKeyFrame= kCFBooleanTrue); - 切到下一个 TS 分片时,确保上一片末尾的关键帧安排在切片边界。
5.3 竖屏录屏,播放端画面却横过来了
屏幕采集的视频帧带有旋转信息,但有些播放器不识别这些 metadata 或者编码时把旋转信息丢了。直观表现为老师把手机竖着用,学生端看到的画面是横的。
解决方法是采集侧自己判断宽高比,必要时先旋转再送编码器:
if width > height {
// 横屏画面,如果用竖屏预览需要旋转90°
}
如果服务端只做标准 HLS 分发,建议在 Extension 侧做旋转到统一的 portrait 或 landscape 方向,避免播放器各自为政。
5.4 麦克风打开,但音频一直没声音
音频问题的排查顺序:
-
确认
recorder.isMicrophoneEnabled = true是在startCapture之前设置的; - 确认 iOS 15+ 下 App 有麦克风权限;
-
确认音频回调
bufferType == .audio时有数据进入; - 确认音频转码后的 AAC 数据被正确地写入了 TS 文件,而不是被视频数据覆盖。
常在第三步栽跟头——
startCapture
之后系统音频权限弹窗还没弹出来,回调里没有音频数据。最好在开始录屏之前先主动触发一次麦克风权限请求,不行的话就在开始后等待音频数据稳定 1~2 秒再启动上传。
5.5 问题排查速查表
| 问题现象 | 可能原因 | 解决措施 |
|---|---|---|
| 点击播控无响应 |
preferredExtension
的 bundle ID 和实际 Extension 不一致
| 核对 Info.plist 中的 Extension bundle identifier |
| 录屏几秒被杀 | Extension 内存超限 | 加内存监控,启用丢帧降级策略 |
| 视频画面卡顿 | CPU 过载 / 编码速度不够 | 关闭 B 帧、降分辨率、降帧率 |
| 音频声音很小 | 系统麦克风音量处理不当 | 在服务端做音量增益,或者检查采集增益 |
| 播放器画面不能拖动 | 关键帧间隔太长 |
调小
MaxKeyFrameInterval
|
| 长时间运行后内存缓慢增长 | 句柄未关闭 / 临时文件未清理 |
用
autoreleasepool
包裹回调,及时
fclose
|
最后再分享一个我后来才悟到的经验。如果你在开发过程中频繁调整 Extension 代码,你会发现每次修改后第一次启动录屏都特别慢,甚至黑屏好几秒。这是因为系统对 Extension 有缓存策略,代码变更是重启生效的。调试时建议每次改完代码先杀掉 App,再从 Xcode 重新 run 一次,不要点“重新运行”就让 Extension 进程残留。否则你会在排查问题时浪费大量时间在“其实根本没跑到新代码”这种乌龙上。
ReplayKit 的开发难度不在于 API 本身有多复杂,而在于你永远要在资源约束和功能需求之间做权衡。每次看到录屏稳定跑完一小时,学生端清晰流畅地看到老师板书,我都会觉得这些踩过的坑、炸过的内存,值了。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)