VideoToolbox 硬编解码的十个坑:为什么它几乎从不报错
这篇讲的是 iOS 上做硬件编解码时,那些不会让你崩溃、只会让你结果不对的问题。
十个坑里有七个的症状是同一句话:「没有任何错误,但画面/文件/码流是错的」。这不是巧合,最后一节我会解释为什么。
零、先劝退:你可能不需要 VideoToolbox
先把这话说在前面,免得有人白踩坑。
如果你的需求是「把一段素材编码成一个 mp4 文件」,用 AVAssetWriter 或 AVAssetExportSession 就够了。 它们内部走的就是 VideoToolbox,而且替你处理掉了本文九成的内容:属性时序、格式描述、尾帧 flush、pixel buffer 池。你自己下沉一层,收益是零,风险是本文全部。
只有下面这几种情况,才值得直接面对 VTCompressionSession / VTDecompressionSession:
- 你要拿到编码后的裸 NALU ------推流(RTMP / WebRTC / 自定义协议)、自研封装、把编码结果发给另一个进程。
AVAssetWriter只给你文件,不给你码流。 - 你要逐帧控制 ------这一帧强制成 I 帧,下一秒把码率从 4Mbps 降到 1.5Mbps。
AVAssetWriter的参数是创建时定死的。 - 你在极端内存约束下 ------比如 Broadcast Upload Extension 的 50MB(这个我在上一篇写过),
AVAssetWriter自己管的缓冲你看不见也控制不了,而你连一帧 4.4MB 都要计较。 - 你要低延迟 ------实时通话、投屏、云游戏。你需要
RealTime+ 关 B 帧 + 单帧出单帧,而不是一个吞吐优先的黑盒。
不在这四条里,请合上这篇文章去用 AVAssetWriter,真的。
还在的,我们开始。
一、坑 1:VTSessionSetProperty 返回 noErr,不代表这个值被采纳了
这是我认为整个 VideoToolbox 里最阴的一个设计。
你写下这样一段代码,编译通过,运行没有任何日志,你以为一切正常:
swift
// 编码到一半,想把画质档位提上去
VTSessionSetProperty(session,
key: kVTCompressionPropertyKey_ProfileLevel,
value: kVTProfileLevel_H264_High_AutoLevel)
// 返回 noErr。你以为生效了。
它没生效。 而且不会告诉你。
根因:属性有「生效窗口」
VTCompressionSession 的属性分成三类,但 API 层面完全不区分------所有属性都用同一个 VTSessionSetProperty 设置,都返回 OSStatus:
| 类别 | 典型属性 | 什么时候必须设 |
|---|---|---|
| 创建期属性 | ProfileLevel、AllowFrameReordering、RealTime、H264EntropyMode、PixelTransferProperties |
第一帧 EncodeFrame 调用之前 。之后设返回 noErr,静默无效 |
| 运行期属性 | AverageBitRate、DataRateLimits、MaxKeyFrameInterval |
任意时刻,会在后续帧上生效 |
| 单帧属性 | kVTEncodeFrameOptionKey_ForceKeyFrame |
只能通过 EncodeFrame 的 frameProperties 传,走 session 设置无效 |

问题在于:这三类的边界在文档里没有一张表,你只能靠试。 而「试」这个动作本身也很难------因为无效时返回值是 noErr。
更麻烦的是第三类:某些属性在特定编码器实现 上不支持。同一台设备,H.264 编码器支持 H264EntropyMode,HEVC 编码器直接返回 kVTPropertyNotSupportedErr (-12900)。很多人看到这个错误码第一反应是「设备太老不支持硬编」,其实设备好得很,只是你选的这个 codec 的编码器没这个概念。
解法:设完回读,别信返回值
我的做法是把所有属性设置包一层,强制回读校验:
swift
enum VTPropertyError: Error {
case setFailed(key: String, status: OSStatus)
case notApplied(key: String, expected: Any, actual: Any?)
}
/// 设置属性并回读校验。任何「设了但没生效」都会在这里暴露,而不是等到线上。
@discardableResult
func setAndVerify(_ session: VTCompressionSession,
_ key: CFString,
_ value: CFTypeRef) throws -> CFTypeRef? {
let status = VTSessionSetProperty(session, key: key, value: value)
guard status == noErr else {
// -12900 kVTPropertyNotSupportedErr:这个编码器不认识这个属性
throw VTPropertyError.setFailed(key: key as String, status: status)
}
var readBack: CFTypeRef?
let readStatus = VTSessionCopyProperty(session,
key: key,
allocator: kCFAllocatorDefault,
valueOut: &readBack)
// 有些属性是只写的(copy 会返回 not supported),这种情况不能算失败
guard readStatus == noErr else { return nil }
if let actual = readBack, CFEqual(actual, value) {
return actual
}
throw VTPropertyError.notApplied(key: key as String,
expected: value,
actual: readBack)
}
另外,在开发期跑一次这个,把当前设备 + 当前 codec 真正支持的属性全打出来,比翻文档快:
swift
var supported: CFDictionary?
if VTSessionCopySupportedPropertyDictionary(session,
supportedPropertyDictionaryOut: &supported) == noErr,
let dict = supported as? [String: [String: Any]] {
for (name, spec) in dict.sorted(by: { $0.key < $1.key }) {
// spec 里有 ReadWriteStatus / SupportedValueRanges / SupportedValueList
print("\(name) rw=\(spec["ReadWriteStatus"] ?? "-") range=\(spec["SupportedValueRanges"] ?? "-")")
}
}
ReadWriteStatus 那一栏会直接告诉你这个属性是 ReadOnly 还是 ReadWrite。这比任何博客(包括这篇)都权威,因为它是当前这台设备、当前这个编码器实例的真实回答。
二、坑 2:AverageBitRate 管的是平均值,它不是限速器
症状特别典型:
- 你设了
AverageBitRate = 4_000_000(4Mbps); - 录出来的文件用
ffprobe一看,平均码率 3.9Mbps,完美达标; - 但推流到服务端,每次画面剧烈变化时观众就卡一下;
- 或者播放器的 buffer 每隔十几秒爆一次。
你去查网络、查服务器、查播放器,全是对的。
根因:平均值约束下,瞬时值可以任意高
kVTCompressionPropertyKey_AverageBitRate 是一个长期平均 目标。编码器为了达到这个平均,完全可以在一个高动态场景(比如画面整体切换、快速滚动)里给出一个 5~10 倍于目标码率的 GOP,然后在后面的静态画面里省回来。从文件的角度,平均值是对的;从传输管道的角度,你刚刚发了一个远超带宽的突发。
真正的硬闸是另一个属性:
swift
// kVTCompressionPropertyKey_DataRateLimits
// 语义:在任意 <seconds> 秒的滑动窗口内,输出不超过 <bytes> 字节
// 注意这个数组的诡异之处:两个元素类型不同,[0] 是字节数,[1] 是秒数
let targetBitrate = 4_000_000 // bit/s
let windowSeconds = 1.0
let maxBytesPerWindow = Double(targetBitrate) / 8.0 * windowSeconds * 1.4 // 留 40% 突发余量
let limits = [
NSNumber(value: Int(maxBytesPerWindow)), // bytes
NSNumber(value: windowSeconds) // seconds
] as CFArray
try setAndVerify(session, kVTCompressionPropertyKey_DataRateLimits, limits)
这个 API 的形状本身就是个坑:一个 CFArray,里面两个 NSNumber,但第一个是字节 、第二个是秒 ------单位既不统一,顺序也没有任何提示。写反了(比如传成 [秒, 字节])不会报错,只会得到一个荒谬的限制。而且注意是字节 不是比特 ,从 bitrate 换算过来那个 / 8 忘了写,你的限制就宽了 8 倍,等于没设。
两者的正确关系
AverageBitRate:告诉编码器「你的预算是多少」------质量目标。DataRateLimits:告诉编码器「你的管道有多粗」------传输约束。
录文件到本地:只设 AverageBitRate 就够,甚至可以不设 DataRateLimits,让编码器在高动态场景多花点码率换质量,这笔交易划算。
要走网络:两个都必须设,窗口取 1 秒,余量给 30%~50%。余量太小会让编码器为了守住窗口而疯狂降质量(画面糊成马赛克),太大等于没限制。
三、坑 3:EnableHardwareAcceleratedVideoEncoder 在 iOS 上是个安慰剂
几乎所有中文教程都会抄这么一段:
swift
let spec: [CFString: Any] = [
kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true
]
然后告诉你「这样就启用硬编了」。
在 iOS 上这一行是空操作。 iOS 的 VideoToolbox 根本没有软件编码器可以 fallback------要么硬编,要么创建失败。这个 key 是为 macOS 设计的,因为 macOS 上确实存在软编 fallback 路径,所以才需要一个开关强制要求硬件。你在 iOS 上设 true,不会让任何事情变得更好;设 false,也不会变差。
真正有意义的是它的兄弟 key,在 macOS 上:
swift
// macOS:要求必须硬编,拿不到就创建失败(而不是静默 fallback 到软编)
kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder: true
以及创建成功后回读这个,确认自己拿到的是什么:
swift
var usingHW: CFTypeRef?
VTSessionCopyProperty(session,
key: kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder,
allocator: kCFAllocatorDefault,
valueOut: &usingHW)
那 iOS 上怎么判断能力?
解码侧有明确 API:
swift
if VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC) {
// 可以走 HEVC 硬解
}
编码侧没有对等的查询 API。 唯一可靠的办法就是------建一个试试:
swift
/// 探测某个 codec 在当前设备上能否创建编码 session。
/// 注意:这个探测本身会占用一个编码器实例,必须立刻销毁(见坑 6)。
func canEncode(_ codec: CMVideoCodecType, width: Int32, height: Int32) -> Bool {
var probe: VTCompressionSession?
let status = VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width, height: height,
codecType: codec,
encoderSpecification: nil,
imageBufferAttributes: nil,
compressedDataAllocator: nil,
outputCallback: nil, // 用 nil 回调 + 后面走 VTCompressionSessionEncodeFrameWithOutputHandler
refcon: nil,
compressionSessionOut: &probe)
if let p = probe {
VTCompressionSessionInvalidate(p)
}
return status == noErr
}
顺带说一句常见的判断误区:「A17 之后才支持 HEVC 硬编」这类按机型写死的白名单是最糟的做法。 同一台设备上,HEVC 编码在 1080p 可用、在某些非常规分辨率下创建失败,是完全可能的。能力探测必须带上你实际要用的分辨率和 codec,而不是查一张机型表。
四、坑 4:GOP 的两个 key 是「或」关系,不是「且」
swift
try setAndVerify(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, 60 as CFNumber)
「我设了 60 帧,30fps,那就是 2 秒一个关键帧。」
只有在帧率恒定是 30 时才成立。
实时采集的帧率几乎从来不恒定。屏幕录制在静止画面时可能掉到 5fps,摄像头在暗光下会自动降到 15fps。帧率掉到 15 时,60 帧 = 4 秒一个 I 帧。
后果不是画质问题,是结构问题:
- HLS/DASH 切片必须切在关键帧上。GOP 时长漂移 → 切片时长忽长忽短 → 播放器 ABR 决策失灵。
- 播放器拖动定位只能定到关键帧。GOP 变成 4 秒,用户拖动的手感就变差。
- 直播首屏必须等到第一个 I 帧。GOP 越长,秒开越难。
解法:两个都设,让「谁先到算谁」为你工作
swift
// 语义:max(60 帧, 2 秒) ------ 哪个条件先满足就插 I 帧
try setAndVerify(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, 60 as CFNumber)
try setAndVerify(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, 2.0 as CFNumber)
两个同时设,编码器取先满足的那个。帧率正常时 60 帧先到,帧率掉了 2 秒先到------GOP 时长被钉死在 2 秒上限,这正是你想要的。
附赠:关键帧的判定方式是反的
拿到编码结果后,判断这一帧是不是关键帧,正确写法是:
swift
func isKeyFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer, createIfNecessary: false) as? [[CFString: Any]],
let first = attachments.first else {
// 拿不到 attachments,保守当作关键帧(否则可能整段码流缺 SPS/PPS)
return true
}
// 关键点:kCMSampleAttachmentKey_NotSync 这个键在关键帧上【根本不存在】
// 不是存在且为 false,是不存在
let notSync = first[kCMSampleAttachmentKey_NotSync] as? Bool ?? false
return !notSync
}
我见过的错误写法:
swift
// ❌ 崩溃:关键帧上这个 key 不存在,强解包直接爆
let notSync = first[kCMSampleAttachmentKey_NotSync] as! Bool
// ❌ 逻辑反了:DependsOnOthers 表达的是别的意思,不能拿来判 I 帧
let isKey = first[kCMSampleAttachmentKey_DependsOnOthers] == nil
?? false 那个默认值不是随手写的,它承载了「不存在 = 是关键帧」这个语义。写代码时值得在旁边留一行注释,否则下一个人会觉得这个 ?? false 是多余的然后删掉它。
强制关键帧只能走单帧属性
想在某个时刻强制插一个 I 帧(比如新观众进入直播间、检测到丢包需要恢复),不能改 session 属性:
swift
let frameProps: [CFString: Any] = [
kVTEncodeFrameOptionKey_ForceKeyFrame: true
]
VTCompressionSessionEncodeFrame(session,
imageBuffer: pixelBuffer,
presentationTimeStamp: pts,
duration: .invalid,
frameProperties: frameProps as CFDictionary,
sourceFrameRefcon: nil,
infoFlagsOut: nil)
五、坑 5:EncodeFrame 返回 noErr,也不代表这一帧编码成功了
这是坑 1 的兄弟,但后果严重得多。
swift
let status = VTCompressionSessionEncodeFrame(session, ...)
if status != noErr {
log("编码失败")
}
// 很多代码到这里就结束了。
VTCompressionSessionEncodeFrame 的返回值只回答一个问题:这一帧有没有被编码器队列接收 。它是同步返回的,而编码是异步的。真正的编码结果------成功、失败、被丢弃------全部在回调 里,通过回调的 status 参数告诉你。
而绝大部分教程里的回调长这样:
swift
let callback: VTCompressionOutputCallback = { refcon, sourceRefcon, status, flags, sampleBuffer in
guard let sampleBuffer = sampleBuffer else { return } // ← status 呢?
// ... 处理数据
}
status 被完全忽略了。于是当编码器开始持续失败时,你的表现是:没有任何日志,没有崩溃,画面就是不动了。
最需要处理的两个错误码
| 错误码 | 名字 | 含义 |
|---|---|---|
-12903 |
kVTInvalidSessionErr |
session 已失效。后续所有帧全部静默丢弃,直到你重建 |
-12902 |
kVTParameterErr |
参数错误,常见于分辨率变了但 session 没重建 |
-12912 |
kVTVideoEncoderMalfunctionErr |
编码器故障 |
-12915 |
kVTVideoEncoderNotAvailableNowErr |
编码器此刻不可用------资源被别人占着(见坑 6) |
kVTInvalidSessionErr 是重点。它的触发场景在真机上很日常:
- App 切到后台(尤其是编码器在 Extension 里);
mediaserverd因为内存压力被系统重启------你的 session 会一起死掉,而你的进程活得好好的;- 另一个更高优先级的媒体客户端(来电、FaceTime、系统相机)抢走了硬件资源。
注意最后这一条的可怕之处:你的代码一行没改,用户只是接了个电话,你的编码链路就永久停了。
解法:把「session 失效 → 重建 → 强制 I 帧」做成状态机
swift
final class EncoderSupervisor {
private var session: VTCompressionSession?
private var needsKeyFrame = true
private let queue = DispatchQueue(label: "vt.encoder")
/// 回调里检测到致命错误时调用
private func handleEncodeStatus(_ status: OSStatus) {
guard status != noErr else { return }
switch status {
case kVTInvalidSessionErr, kVTVideoEncoderMalfunctionErr, kVTVideoEncoderNotAvailableNowErr:
queue.async { [weak self] in self?.rebuild() }
default:
// 单帧失败,记录但不重建------重建成本高,不能被一帧抖动触发
Metrics.count("vt.encode.frame_failed", status)
}
}
private func rebuild() {
if let old = session {
// 注意顺序:先 Complete 再 Invalidate(见坑 9)
VTCompressionSessionCompleteFrames(old, untilPresentationTimeStamp: .invalid)
VTCompressionSessionInvalidate(old)
session = nil
}
session = try? makeSession()
// 重建后的第一帧必须是 I 帧,否则解码端拿到的 P 帧没有参考,整段花屏
needsKeyFrame = true
}
func encode(_ pixelBuffer: CVPixelBuffer, pts: CMTime) {
guard let session else { return }
var props: CFDictionary?
if needsKeyFrame {
props = [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary
needsKeyFrame = false
}
let status = VTCompressionSessionEncodeFrame(
session, imageBuffer: pixelBuffer,
presentationTimeStamp: pts, duration: .invalid,
frameProperties: props,
sourceFrameRefcon: nil, infoFlagsOut: nil)
handleEncodeStatus(status) // 同步错误也要走同一条路
}
}
两个容易漏的点:
- 重建后第一帧必须强制 I 帧。 否则解码端收到的是一串没有参考帧的 P 帧,画面会花到下一个自然 GOP 边界。
- 不要被单帧失败触发重建。 重建 session 是几十毫秒级的开销,还会丢掉 pipeline 里的帧。只有
kVTInvalidSessionErr这种「不重建就永远好不了」的错误才值得重建。
六、坑 6:「硬件编码器」里的硬件,是设备的,不是你 App 的
这是我认为整篇文章里最反直觉、也最值钱的一条。
绝大多数人对 VTCompressionSessionCreate 的心智模型是:「我调一个 API,系统给我一个对象」------就像 URLSession 或者 CIContext。错了。
VTCompressionSession 是一个跨进程句柄 。真正干活的编码器实例活在 mediaserverd 里,那是一个系统级守护进程,它管理着整台设备上数量有限的硬件编码器。你的 App、你的 Extension、系统相机、FaceTime、正在后台上传的某个 App------大家在抢同一个池子。
于是你会遇到这些「不讲道理」的现象:

现象 A:模拟器上好好的,真机上创建失败。 不奇怪,模拟器走的是完全不同的路径。任何 VideoToolbox 的稳定性结论,模拟器测试都不算数。
现象 B:单独跑没问题,主 App 和 Extension 同时录就失败。 Broadcast Upload Extension 在录屏,主 App 同时又想编码一段导出------两个进程各要一个编码器。这个组合在中低端设备上很容易踩到上限,表现是其中一个 VTCompressionSessionCreate 返回 kVTCouldNotFindVideoEncoderErr (-12908) 或 kVTVideoEncoderNotAvailableNowErr (-12915)。
顺便说,这个错误码的名字本身就是 Apple 的自白 :VideoEncoderNotAvailableNow------不是「不支持」,不是「找不到」,而是**「现在没有」**。一个 API 会专门定义一个带 Now 的错误码,只可能是因为它管理的东西是可竞争、会变化的共享资源。你从这个命名就能反推出整个模型。
现象 C:连续创建/销毁几十次后开始失败。 每次都创建新 session 但没有正确 Invalidate,或者 Invalidate 了但还有强引用吊着,跨进程那一侧的编码器实例不会释放。这是最难查的一种:你的 App 内存看起来完全正常,因为泄漏的东西不在你的进程里。
现象 D:切后台再回前台,编码全挂。 系统在 App 进后台时会回收媒体资源。前台恢复后,你手上那个 session 已经是个死句柄了(kVTInvalidSessionErr)。
工程上应该怎么办
1. 编码器当作稀缺资源来管,一个进程一个,全局复用。
不要「每次导出创建一个」。建一个持有者,编码任务排队复用同一个 session(分辨率变了才重建)。
2. 创建失败必须有重试和退避,不能一次失败就当作不支持。
资源竞争是瞬态的。别人的 FaceTime 挂了,你就能拿到了:
swift
func makeSessionWithRetry(maxAttempts: Int = 3) throws -> VTCompressionSession {
var lastStatus: OSStatus = noErr
for attempt in 0..<maxAttempts {
do { return try makeSession() }
catch let VTError.createFailed(status) {
lastStatus = status
// -12908 / -12915 是资源竞争类错误,等一下再试有意义;
// 其他错误(比如参数非法)重试多少次都一样,直接抛
guard status == kVTCouldNotFindVideoEncoderErr // -12908
|| status == kVTVideoEncoderNotAvailableNowErr // -12915
else { throw VTError.createFailed(status) }
Thread.sleep(forTimeInterval: 0.05 * Double(attempt + 1)) // 只在非主线程调用
}
}
throw VTError.createFailed(lastStatus)
}
3. 监听生命周期,进后台主动交还资源。
与其等系统把 session 弄死然后你在回调里发现 -12903,不如在 willResignActive 时主动 CompleteFrames + Invalidate,回前台再重建。主动释放比被动失效可控得多------至少你知道发生了什么,而且你选的时机是安全的。
4. 降级路径必须存在。
拿不到硬编时,AVAssetWriter 是可用的退路(它内部会做自己的资源协调,成功率往往更高)。如果连它也不行,起码给用户一个明确提示,而不是让录制按钮点了没反应。
这一条可以浓缩成一句话:你不是在申请一个对象,你是在向一个全局仲裁者租用一段硬件时间。 任何「租用」都有拿不到、被收回、需要归还的可能,你的代码结构必须承认这一点。
七、坑 7:回调的三个隐藏契约
VTCompressionOutputCallback 的签名很短,但藏了三个不写清楚就会出事的约定。
swift
let callback: VTCompressionOutputCallback = {
outputCallbackRefCon, sourceFrameRefCon, status, infoFlags, sampleBuffer in
// ...
}
契约一:它不在你的线程上
回调在 VideoToolbox 自己的队列上执行。你在里面碰的任何共享状态(写文件的 FileHandle、统计计数器、muxer 的状态机)都必须是线程安全的。
更隐蔽的问题是:回调之间不保证串行到你期望的那个队列上。稳妥做法是回调里只做最小的事------取数据、扔进自己的串行队列------重逻辑不要写在回调里。回调阻塞会直接反压编码器。
契约二:sampleBuffer 可以是 nil,而且不只在失败时
swift
guard let sampleBuffer else { return } // ← 这个 return 吃掉了一个必须处理的信号
sampleBuffer 为 nil 的情况:
status != noErr:编码失败;infoFlags含.frameDropped:这一帧被主动丢弃了。
这两种情况下你仍然要做清理 (下一条),而且丢帧率是一个应该被监控的指标。静静 return 掉,等于把编码器的健康状况从你的可观测范围里删掉了。
契约三:sourceFrameRefCon 的释放责任在你,而且两条路径都要走
这是最容易泄漏的地方。典型场景:你想把「这一帧的业务上下文」(帧序号、时间戳、原始 buffer 的引用)带到回调里,于是用 Unmanaged 传指针:
swift
final class FrameContext {
let index: Int
let captureTime: CFAbsoluteTime
init(index: Int, captureTime: CFAbsoluteTime) {
self.index = index
self.captureTime = captureTime
}
}
// 送帧时:+1 引用,转成裸指针
let ctx = FrameContext(index: frameIndex, captureTime: CFAbsoluteTimeGetCurrent())
let ptr = Unmanaged.passRetained(ctx).toOpaque()
VTCompressionSessionEncodeFrame(session,
imageBuffer: pixelBuffer,
presentationTimeStamp: pts,
duration: .invalid,
frameProperties: nil,
sourceFrameRefcon: ptr, // ← 责任转移
infoFlagsOut: nil)
回调里必须无条件取回并释放,不管成功失败:
swift
let callback: VTCompressionOutputCallback = {
_, sourceFrameRefCon, status, infoFlags, sampleBuffer in
// 第一件事:无条件回收 refcon。放在最前面,避免任何 early return 绕过它
var context: FrameContext?
if let raw = sourceFrameRefCon {
context = Unmanaged<FrameContext>.fromOpaque(raw).takeRetainedValue()
}
guard status == noErr else {
Metrics.count("vt.encode.failed", status)
return
}
guard let sampleBuffer else {
// 走到这里通常是 frameDropped,refcon 已经回收了,安全
Metrics.count("vt.encode.dropped")
return
}
let latency = CFAbsoluteTimeGetCurrent() - (context?.captureTime ?? 0)
Metrics.observe("vt.encode.latency_ms", latency * 1000)
// ... 写文件 / 推流
}
错误写法是把回收放在成功分支里:
swift
// ❌ 编码失败时 FrameContext 永久泄漏,而且失败越多漏得越快
guard status == noErr, let sampleBuffer else { return }
let context = Unmanaged<FrameContext>.fromOpaque(sourceFrameRefCon!).takeRetainedValue()
这个泄漏的恶劣之处在于它和错误率正相关:一切正常时你测不出来,一旦线上开始出现编码失败,泄漏速度就跟着失败率一起涨。
顺带一提:如果不需要跨语言/跨模块的裸指针,Swift 里更省心的是用 VTCompressionSessionEncodeFrameWithOutputHandler,闭包捕获直接搞定上下文,没有 Unmanaged 的事。代价是每帧一个闭包分配。在录屏这种 30~60fps 的场景,我倾向于用 handler 版本------那点分配开销远小于一个内存泄漏的排查成本。
八、坑 8:从 VideoToolbox 出来的东西,不是能直接推流的码流
症状:本地播放完全正常,推到服务器就黑屏或花屏。
这一条有两个独立的坑,通常一起踩。
8.1 长度前缀 vs 起始码(AVCC vs Annex B)
CMSampleBuffer 里的 CMBlockBuffer 装的是 AVCC 格式 :每个 NALU 前面是一个 4 字节大端长度。
而 RTMP/FLV、MPEG-TS、大部分推流服务端和 ffmpeg 的裸流输入,要的是 Annex B 格式 :每个 NALU 前面是起始码 00 00 00 01。
两者字节数一样,所以你不转换的话,长度前缀会被当成起始码解析------解码器看到的是一堆垃圾。转换代码本身很直白:
swift
private let startCode: [UInt8] = [0x00, 0x00, 0x00, 0x01]
/// AVCC -> Annex B。注意长度前缀是大端,且长度字段宽度来自 avcC 的 lengthSizeMinusOne+1
func convertToAnnexB(_ sampleBuffer: CMSampleBuffer, nalUnitHeaderLength: Int = 4) -> Data? {
guard let block = CMSampleBufferGetDataBuffer(sampleBuffer) else { return nil }
var totalLength = 0
var dataPointer: UnsafeMutablePointer<Int8>?
guard CMBlockBufferGetDataPointer(block,
atOffset: 0,
lengthAtOffsetOut: nil,
totalLengthOut: &totalLength,
dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
let base = dataPointer else { return nil }
var out = Data()
var offset = 0
while offset < totalLength - nalUnitHeaderLength {
var naluLength: UInt32 = 0
memcpy(&naluLength, base + offset, nalUnitHeaderLength)
naluLength = CFSwapInt32BigToHost(naluLength) // ← 大端,忘了转就全乱
out.append(contentsOf: startCode)
out.append(UnsafeBufferPointer(
start: UnsafeRawPointer(base + offset + nalUnitHeaderLength)
.assumingMemoryBound(to: UInt8.self),
count: Int(naluLength)))
offset += nalUnitHeaderLength + Int(naluLength)
}
return out
}
nalUnitHeaderLength 不要写死成 4。它来自 format description 里的 lengthSizeMinusOne + 1,虽然 VideoToolbox 实际上基本都给 4,但既然能问就问:
swift
var nalHeaderLength: Int32 = 4
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
formatDesc, parameterSetIndex: 0,
parameterSetPointerOut: nil, parameterSetSizeOut: nil,
parameterSetCountOut: nil, nalUnitHeaderLengthOut: &nalHeaderLength)
8.2 SPS / PPS 根本不在码流里
这是更致命的一条。解码器必须先拿到 SPS/PPS 才能解任何一帧 ,而 VideoToolbox 输出的 CMSampleBuffer 里没有 SPS/PPS------它们在 CMFormatDescription 里,作为「参数集」单独存放。
写成 mp4 时,AVAssetWriter 会把它们写进 avcC box,所以你本地播放没问题。但你自己取 NALU 推流时,如果不手动把 SPS/PPS 插到每个 I 帧前面,服务端收到的就是一堆无法解析的数据。
swift
/// 每个关键帧前必须重新插入 SPS/PPS。
/// 为什么是"每个"而不是"第一个":中途加入的观众收不到开头,
/// 而且丢包重连后解码器状态是空的。
func parameterSetsAnnexB(from formatDesc: CMFormatDescription) -> Data? {
var count = 0
guard CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
formatDesc, parameterSetIndex: 0,
parameterSetPointerOut: nil, parameterSetSizeOut: nil,
parameterSetCountOut: &count, nalUnitHeaderLengthOut: nil) == noErr else { return nil }
var out = Data()
for i in 0..<count {
var ptr: UnsafePointer<UInt8>?
var size = 0
guard CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
formatDesc, parameterSetIndex: i,
parameterSetPointerOut: &ptr, parameterSetSizeOut: &size,
parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) == noErr,
let p = ptr else { continue }
out.append(contentsOf: startCode)
out.append(UnsafeBufferPointer(start: p, count: size))
}
return out // H.264: SPS(0) + PPS(1);HEVC 用 ...GetHEVCParameterSetAtIndex,有 VPS/SPS/PPS 三个
}
HEVC 要换 API (CMVideoFormatDescriptionGetHEVCParameterSetAtIndex),而且参数集是三个(VPS、SPS、PPS)不是两个。用 H.264 的 API 去取 HEVC 的参数集会失败,这个错误如果没检查返回值,表现就是「推 H.264 好好的,切 HEVC 就黑屏」。
还有一个容易漏的时机:横竖屏切换时分辨率变了,format description 会变,SPS/PPS 也会变。 你如果只在第一帧缓存了一份 SPS/PPS 就一直用,旋转之后整段码流就废了。正确做法是每次拿到 sampleBuffer 都从它自己的 format description 里取,或者比较 CMFormatDescriptionEqual 后更新缓存。
九、坑 9:不调 CompleteFrames 就销毁,尾部的帧会静默消失
症状很有辨识度:录了 10 秒的视频,文件是 9.4 秒的,而且末尾少了一小段画面。 没有任何错误。
根因:编码器内部有一条流水线
你调 EncodeFrame 送进去的帧,不会立刻出来。编码器内部有队列和参考帧窗口(尤其在开了帧重排的情况下)。当你停止录制、直接 VTCompressionSessionInvalidate 时,还在流水线里没吐出来的帧就跟着 session 一起没了。
swift
func stop() {
// 关键:等所有已提交的帧都通过回调吐出来
// 传 .invalid 表示"完成全部",也可以传一个具体 PTS 表示"完成到这个时间点"
VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
// 此时所有回调都已经执行完了,可以安全收尾
writer.finishWriting()
VTCompressionSessionInvalidate(session)
self.session = nil
}
但这个调用是同步阻塞的
VTCompressionSessionCompleteFrames 会阻塞当前线程直到所有帧的回调都执行完。这带来两个次生问题:
1. 不要在主线程调。 阻塞时长取决于流水线深度和设备性能,几十到几百毫秒,足够触发一次卡顿甚至 watchdog。
2. 小心死锁。 如果你的输出回调里会往某个队列 sync 派发,而你恰好在那个队列上调 CompleteFrames,就是一个标准的死锁。我的原则是:CompleteFrames 在一个专用的、不参与回调处理的队列上调用。
swift
private let controlQueue = DispatchQueue(label: "vt.control") // 只做 create/complete/invalidate
private let outputQueue = DispatchQueue(label: "vt.output") // 回调里的数据只往这里扔
func stopAsync(completion: @escaping () -> Void) {
controlQueue.async { [weak self] in
guard let self, let session = self.session else { completion(); return }
VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
VTCompressionSessionInvalidate(session)
self.session = nil
completion()
}
}
顺序也不能反:先 CompleteFrames,再收尾 writer / muxer,最后 Invalidate。 反过来先 Invalidate 的话,CompleteFrames 面对的已经是个死 session,直接返回 kVTInvalidSessionErr,尾帧照丢。
十、坑 10:解码侧的背压------你握着 CVPixelBuffer 多久,解码器就停多久
前九个坑都在编码侧,最后这个在解码侧,而且是解码里最常见的一个。
症状:解码开始很流畅,跑几秒后越来越慢,最后完全卡住。 CPU 占用不高,内存看起来也正常。
swift
// ❌ 一个很自然、但会锁死解码器的写法
let decodeCallback: VTDecompressionOutputCallback = {
_, _, status, _, imageBuffer, _, _ in
guard let imageBuffer else { return }
// 存起来,给渲染层慢慢消费
self.pendingFrames.append(imageBuffer) // ← 这一行就是那个坑
}
根因:输出 buffer 来自一个有上限的池
VTDecompressionSession 输出的 CVPixelBuffer 不是每帧新分配的,它们来自一个 CVPixelBufferPool,底层由 IOSurface 支撑。这个池的容量是有限的(通常是个位数到十几个)。
当你把 buffer 存进队列、持有它的引用时,这个 buffer 就回不到池里 。池空了之后,解码器拿不到可写的输出目标,VTDecompressionSessionDecodeFrame 就会阻塞或者返回错误------它没有别的选择,只能等你还回来。
补充一句:这和录屏采集侧的送帧机制是同一个道理的两种表现。ReplayKit 给你的
CMSampleBuffer也是池里的,你持有太久,系统就会停止给你送新帧。只要看到「一开始正常、跑一会儿就没数据了、但没有任何错误」,第一个该怀疑的就是某个池被你握住了。
解法
方案 A(推荐):不要缓存 CVPixelBuffer,缓存渲染结果。
在回调里立刻把内容转成你真正需要的东西(Metal 纹理、上传到 GPU、编码成另一种格式),然后立刻释放引用。
swift
let decodeCallback: VTDecompressionOutputCallback = {
_, _, status, _, imageBuffer, pts, _ in
guard status == noErr, let imageBuffer else { return }
// 立刻转成 Metal 纹理,用完就还
// CVMetalTextureCache 走的是零拷贝:纹理和 pixelBuffer 共享同一块 IOSurface
let texture = textureCache.makeTexture(from: imageBuffer)
renderer.enqueue(texture, at: pts)
// 作用域结束,imageBuffer 的引用释放,buffer 归还给池
}
注意这里的细节:CVMetalTextureCache 创建的纹理和 pixelBuffer 共享同一块 IOSurface 内存 ,所以你必须持有 CVMetalTexture 对象(而不只是 MTLTexture)直到 GPU 用完。这里有一个「零拷贝 vs 早释放」的权衡,取决于你的渲染管线深度。
方案 B:如果确实需要缓冲若干帧,把池的容量显式调大,并配上背压。
swift
// 建 session 时通过 destinationImageBufferAttributes 指定池的行为
let bufferAttributes: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
kCVPixelBufferMetalCompatibilityKey: true,
]
同时在自己的队列上做显式的背压 :队列超过 N 帧就停止调 DecodeFrame,而不是无脑往里灌。这一点是关键------池容量调大只是把崩溃点往后推,真正解决问题的是承认「消费速度决定生产速度」并显式建模它。
方案 C:让解码器不用池。
VTDecompressionSessionDecodeFrame 的 decodeFlags 里传 _EnableTemporalProcessing / 走异步解码时,配合每帧独立分配的输出(不复用池),代价是内存和分配开销上升。只在「必须长时间持有多帧」且帧数可控的场景下考虑,比如做逐帧分析而不是播放。
十一、为什么这些坑长这个样子
写到这里,回头看这十个坑,会发现一个非常一致的模式:

| # | 坑 | 会报错吗 |
|---|---|---|
| 1 | 属性设了没生效 | ❌ 返回 noErr |
| 2 | 瞬时码率超限 | ❌ 平均值达标 |
| 3 | HW 加速 key 无效 | ❌ 静默忽略 |
| 4 | GOP 时长漂移 | ❌ 参数「正确」 |
| 5 | session 失效吞帧 | ⚠️ 有错误码,但在回调里 |
| 6 | 编码器资源被抢 | ✅ 创建时报错 |
| 7 | refcon 泄漏 | ❌ 内存慢慢涨 |
| 8 | 缺 SPS/PPS | ❌ 本地播放正常 |
| 9 | 尾帧丢失 | ❌ 文件短一点 |
| 10 | 解码器背压 | ❌ 只是变慢 |
十个里只有一个会在你调用的那一刻明确地报错。
这不是 Apple 偷懒。原因在于 VideoToolbox 的本质:它是一个跨进程 + 跨硬件边界的门面 。你调的每一个函数,真正的执行者在 mediaserverd 里,甚至在编码器 IP 核上。而跨越这种边界时,「错误」这个概念本身就变得模糊了------
- 你设的属性被硬件拒绝了,算错误吗?编码器认为它可以用一个近似的配置继续工作,于是它继续工作了。
- 系统回收了你的编码器,算错误吗?从系统的角度这是正常的资源调度。
- 你握着 buffer 不放导致解码变慢,算错误吗?这是流控,不是故障。
在这类边界上,API 的设计倾向永远是**「尽量继续跑」**而不是「立刻失败」。因为对系统来说,一个卡住但活着的 App,比一个崩溃的 App 好。
但对你来说,一个静默产出错误结果的功能,比一个崩溃的功能糟糕得多。 崩溃有堆栈,有日志,有 Crashlytics 报表。而「录出来的视频末尾少半秒」这种问题,可能上线三个月都没人报,直到某个客户拿它做了一件对时间敏感的事情。
可迁移的原则
如果这篇文章只留下一句话,我希望是这句:
任何跨越进程或硬件边界的 API,返回值只代表「请求已投递」,不代表「意图已达成」。
VideoToolbox 是这样,跨进程 XPC 是这样,写文件到磁盘是这样(write() 返回成功不代表数据落盘了,所以有 fsync),发一个网络请求也是这样。
对应的工程习惯就三条,简单到没有借口不做:
- 设置完要回读。 别信 setter 的返回值,读回来对一遍(坑 1)。
- 异步结果要有自己的通道,并且必须被检查。 同步返回值只是「收到了」(坑 5、坑 7)。
- 要有对账。 送进去 300 帧,出来 300 帧吗?文件时长和录制时长差多少?这些数字必须被记录、被监控。没有对账的异步管道,等于没有测试。
第三条尤其重要,也最常被跳过。在编码链路上加一个「入帧计数 / 出帧计数 / 丢帧计数 / 端到端时延」的埋点,成本是二十行代码,但它是唯一能让上面这些静默问题变得可见的东西。等你在线上发现文件短了半秒才回头补,你连是哪一环丢的都定位不了。
附:一份可以直接抄的自查清单
创建期
-
ProfileLevel/AllowFrameReordering/RealTime全部在第一帧之前设完 - 每个属性设完回读校验(
VTSessionCopyProperty) - 用
VTSessionCopySupportedPropertyDictionary打印过当前设备的真实支持列表 - 创建失败有退避重试,不当成「设备不支持」
- 没有依赖
EnableHardwareAcceleratedVideoEncoder(iOS 上无效)
码率与 GOP
- 走网络的话,
AverageBitRate+DataRateLimits都设了 -
DataRateLimits第一个元素是字节 不是比特,且做了/8换算 -
MaxKeyFrameInterval和MaxKeyFrameIntervalDuration两个都设
运行期
- 回调里检查了
status,不是只guard let sampleBuffer -
kVTInvalidSessionErr有重建逻辑,重建后强制 I 帧 -
sourceFrameRefCon的回收在回调最开头,任何分支都会走到 - 关键帧判定用的是「
NotSync键不存在」而不是强解包 - 丢帧数、编码失败数、端到端时延有埋点
推流 / 自封装
- AVCC → Annex B 转换了,长度前缀按大端解析
- 每个 I 帧前插入 SPS/PPS
- HEVC 用的是
GetHEVCParameterSetAtIndex(三个参数集) - 横竖屏切换后重新取了 format description
销毁
-
CompleteFrames→ 收尾 muxer →Invalidate,顺序不能反 -
CompleteFrames不在主线程、也不在回调队列上调
解码
- 没有长期持有
CVPixelBuffer - 消费侧有显式背压,不是无脑
DecodeFrame - 用
VTIsHardwareDecodeSupported做过 HEVC 能力探测
测试
- 真机测过(模拟器结论不算数)
- 录制中接电话 / 切后台 / 锁屏
- 主 App 与 Extension 同时编码
- 长时间运行(30 分钟以上)看内存曲线
- 低端老设备实测
写在最后
VideoToolbox 是一个把复杂度藏得很深的 API:它的接口面很小,VTCompressionSessionCreate / EncodeFrame / CompleteFrames / Invalidate 四个函数就能跑通一个 demo。但它背后是一整套跨进程的资源仲裁、一块被全设备共享的硬件、以及一个为「尽量不失败」而设计的容错策略。
这三件事加起来,就构成了这篇文章的全部内容:接口的简单,是靠把不确定性推给调用方换来的。
所以用它的正确姿势不是「照着教程抄一段能跑的代码」,而是默认它会以你看不见的方式偏离你的预期,然后把「验证」这件事写进代码里------回读、查 status、做对账。
这套心态换到任何一个「你调用、别人执行」的边界上都成立。
代码说明 :本文的示例代码用于说明问题和解法,为了聚焦重点省略了部分错误处理和上下文(如
Metrics、makeSession()、textureCache等辅助类型未给出定义)。这些片段未逐行编译验证,直接粘贴到工程里需要按你的实际类型和线程模型调整。文中描述的 API 行为和错误码语义均基于公开文档与实际使用经验。
如果这篇对你有用,欢迎交流。iOS 音视频、录屏、编解码、FFmpeg 相关的问题都可以聊。
GitHub: @DongQi-Yang