尧图精选

iOS录屏引擎实战:Broadcast Extension与VideoToolbox编码及直播推流

🕒 发布时间:2026/9/16 18:57:46 📁 来源:尧图网络
说出来你可能不信我第一次做 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 编译产物为什么会轻而易举超过 50MBXcode 创建 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设为 YESDead 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、audioMicRPSampleBufferType一共有三种枚举值类型含义常见内容.video屏幕采集的视频帧未压缩的 YUV 420 格式 pixel buffer分辨率跟随屏幕.audioAppApp 内部的音频输出系统采集的 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 推给扩展的视频帧通常是未压缩的 CVPixelBufferYUV 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)这里解释几个参数的取舍AverageBitRate2.5Mbps 是直播场景里 720p 到 1080p 之间的一个平衡点。如果画面是动态操作类比如打游戏码率建议调高到 4-5Mbps否则动态画面会有大量马赛克。如果只是静态界面展示2Mbps 都够了。MaxKeyFrameIntervalGOP60 表示每 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。在扩展里做音频重采样最稳妥的方案是AudioConvertervar 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和关键帧标志来决策切片时机。服务器端要有排序逻辑比如片段文件名里带时间戳或者序号防止上传乱序导致播放错乱。每个片段用独立.mp4faststart比用裸 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 字节 TagType3 字节 DataSize4 字节 Timestamp3 字节 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-uploadNSExtensionPrincipalClass指向你的SampleHandler类。有一个遗漏系统就不会把你的扩展注册到控制中心里。keyNSExtension/key dict keyNSExtensionPointIdentifier/key stringcom.apple.broadcast-services-upload/string keyNSExtensionPrincipalClass/key string$(PRODUCT_MODULE_NAME).SampleHandler/string keyNSExtensionAttributes/key dict keyRPBroadcastProcessMode/key integer2/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 固定偏差音画不同步有两种表象固定偏差和渐进式延迟。固定偏差常见于音频与视频的起始时间没对齐。比如视频帧从pts0开始编码音频帧从pts0.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 预算、进程存活率和系统实时性共同决定。尊重这套约束把扩展做成轻量、稳定、只管数据流的引擎你的录屏功能才能扛住长时间录制和弱网环境。别把它当成一个万能的视频工作站它真的只是一个高效的数据搬运工。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →