VideoToolbox 硬编解码的十个坑:为什么它几乎从不报错

VideoToolbox 硬编解码的十个坑:为什么它几乎从不报错

这篇讲的是 iOS 上做硬件编解码时,那些不会让你崩溃、只会让你结果不对的问题。

十个坑里有七个的症状是同一句话:「没有任何错误,但画面/文件/码流是错的」。这不是巧合,最后一节我会解释为什么。


零、先劝退:你可能不需要 VideoToolbox

先把这话说在前面,免得有人白踩坑。

如果你的需求是「把一段素材编码成一个 mp4 文件」,用 AVAssetWriterAVAssetExportSession 就够了。 它们内部走的就是 VideoToolbox,而且替你处理掉了本文九成的内容:属性时序、格式描述、尾帧 flush、pixel buffer 池。你自己下沉一层,收益是零,风险是本文全部。

只有下面这几种情况,才值得直接面对 VTCompressionSession / VTDecompressionSession

  1. 你要拿到编码后的裸 NALU ------推流(RTMP / WebRTC / 自定义协议)、自研封装、把编码结果发给另一个进程。AVAssetWriter 只给你文件,不给你码流。
  2. 你要逐帧控制 ------这一帧强制成 I 帧,下一秒把码率从 4Mbps 降到 1.5Mbps。AVAssetWriter 的参数是创建时定死的。
  3. 你在极端内存约束下 ------比如 Broadcast Upload Extension 的 50MB(这个我在上一篇写过),AVAssetWriter 自己管的缓冲你看不见也控制不了,而你连一帧 4.4MB 都要计较。
  4. 你要低延迟 ------实时通话、投屏、云游戏。你需要 RealTime + 关 B 帧 + 单帧出单帧,而不是一个吞吐优先的黑盒。

不在这四条里,请合上这篇文章去用 AVAssetWriter,真的。

还在的,我们开始。


一、坑 1:VTSessionSetProperty 返回 noErr,不代表这个值被采纳了

这是我认为整个 VideoToolbox 里最阴的一个设计。

你写下这样一段代码,编译通过,运行没有任何日志,你以为一切正常:

swift 复制代码
// 编码到一半,想把画质档位提上去
VTSessionSetProperty(session,
                     key: kVTCompressionPropertyKey_ProfileLevel,
                     value: kVTProfileLevel_H264_High_AutoLevel)
// 返回 noErr。你以为生效了。

它没生效。 而且不会告诉你。

根因:属性有「生效窗口」

VTCompressionSession 的属性分成三类,但 API 层面完全不区分------所有属性都用同一个 VTSessionSetProperty 设置,都返回 OSStatus

类别 典型属性 什么时候必须设
创建期属性 ProfileLevelAllowFrameReorderingRealTimeH264EntropyModePixelTransferProperties 第一帧 EncodeFrame 调用之前 。之后设返回 noErr,静默无效
运行期属性 AverageBitRateDataRateLimitsMaxKeyFrameInterval 任意时刻,会在后续帧上生效
单帧属性 kVTEncodeFrameOptionKey_ForceKeyFrame 只能通过 EncodeFrameframeProperties 传,走 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)   // 同步错误也要走同一条路
    }
}

两个容易漏的点:

  1. 重建后第一帧必须强制 I 帧。 否则解码端收到的是一串没有参考帧的 P 帧,画面会花到下一个自然 GOP 边界。
  2. 不要被单帧失败触发重建。 重建 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 吃掉了一个必须处理的信号

sampleBuffernil 的情况:

  • 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 要换 APICMVideoFormatDescriptionGetHEVCParameterSetAtIndex),而且参数集是三个(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:让解码器不用池。

VTDecompressionSessionDecodeFramedecodeFlags 里传 _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),发一个网络请求也是这样。

对应的工程习惯就三条,简单到没有借口不做:

  1. 设置完要回读。 别信 setter 的返回值,读回来对一遍(坑 1)。
  2. 异步结果要有自己的通道,并且必须被检查。 同步返回值只是「收到了」(坑 5、坑 7)。
  3. 要有对账。 送进去 300 帧,出来 300 帧吗?文件时长和录制时长差多少?这些数字必须被记录、被监控。没有对账的异步管道,等于没有测试。

第三条尤其重要,也最常被跳过。在编码链路上加一个「入帧计数 / 出帧计数 / 丢帧计数 / 端到端时延」的埋点,成本是二十行代码,但它是唯一能让上面这些静默问题变得可见的东西。等你在线上发现文件短了半秒才回头补,你连是哪一环丢的都定位不了。


附:一份可以直接抄的自查清单

创建期

  • ProfileLevel / AllowFrameReordering / RealTime 全部在第一帧之前设完
  • 每个属性设完回读校验(VTSessionCopyProperty
  • VTSessionCopySupportedPropertyDictionary 打印过当前设备的真实支持列表
  • 创建失败有退避重试,不当成「设备不支持」
  • 没有依赖 EnableHardwareAcceleratedVideoEncoder(iOS 上无效)

码率与 GOP

  • 走网络的话,AverageBitRate + DataRateLimits 都设了
  • DataRateLimits 第一个元素是字节 不是比特,且做了 /8 换算
  • MaxKeyFrameIntervalMaxKeyFrameIntervalDuration 两个都设

运行期

  • 回调里检查了 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、做对账。

这套心态换到任何一个「你调用、别人执行」的边界上都成立。


代码说明 :本文的示例代码用于说明问题和解法,为了聚焦重点省略了部分错误处理和上下文(如 MetricsmakeSession()textureCache 等辅助类型未给出定义)。这些片段未逐行编译验证,直接粘贴到工程里需要按你的实际类型和线程模型调整。文中描述的 API 行为和错误码语义均基于公开文档与实际使用经验。


如果这篇对你有用,欢迎交流。iOS 音视频、录屏、编解码、FFmpeg 相关的问题都可以聊。

GitHub: @DongQi-Yang

相关推荐
bcbnb1 小时前
Flutter-Notebook代码混淆:Android与iOS平台安全配置
后端·ios
2501_915106322 小时前
第一次开发 iPhone App,可能碰到的问题,解决办法
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915106323 小时前
iOS数据采集技术详解:从性能监控到崩溃分析的全链路实践
android·ios·小程序·https·uni-app·iphone·webview
末代iOS程序员华仔18 小时前
iOS 5.6 条例解决方法
flutter·ios·swift
末代iOS程序员华仔1 天前
Guideline 5.6 - Developer Code of Conduct
flutter·ios·swift
2501_915918411 天前
Flutter项目配置iOS混淆的详细步骤与工具推荐
android·flutter·ios·小程序·uni-app·cocoa·iphone
凡泰AI1 天前
如何通过小程序多端框架,让一个小程序同时运行在iOS、安卓、鸿蒙和微信客户端,实现开发层面的降本增效
android·ios·微信小程序·小程序·harmonyos
2501_915106322 天前
安卓抓包软件2026,免证书抓包 应用层抓包 代理抓包全解析
网络协议·计算机网络·网络安全·ios·adb·https·udp
kango2 天前
Xcode、模拟器Runtime、真机符号完整知识手册
ios·app