为什么预览、录制、离线导出不能共享同一个背压策略?
预览优先新鲜度,录制需要可控资源,离线导出必须保留完整性;这三种目标不能由同一个队列策略同时满足。
问题与复现条件
做 iOS/macOS 视频功能时,很容易把预览、录制和离线导出看成同一条链路:媒体源产生帧,处理节点消费帧,再交给不同输出。表面上只是输出目的不同;但处理速度暂时落后于输入速度时,三者对"该怎么办"的答案完全不同:预览关心现在看到什么,录制关心实时采集与资源边界,离线导出关心每一帧都不能缺失。
本文只讨论帧处理落后于输入时的交付策略,不讨论编解码参数、画质指标或具体设备性能。
先给结论
- 预览通常应优先时间新鲜度,适合只保留最新待处理帧的策略。
- 录制需要把在途帧数量设为显式上限,并让丢帧可观察。
- 离线、不可丢帧的导出不能套用实时策略;它必须为完整性承担在途工作的成本。
- 背压应成为媒体节点的合同,并由测试验证,而不是散落在调用方的临时队列逻辑。
处理不过来时,到底损失什么?
假设媒体源持续产生帧:
swift
while source.hasNextFrame {
let frame = source.nextFrame()
node.enqueue(frame)
}
当处理节点暂时来不及消费,系统必须选择保留哪些帧、丢弃哪些帧,以及是否允许在途任务继续增长。这才是背压策略,而不只是"有没有一个队列"。
最直接的做法是全部排队:
swift
queue.append(frame)
它不会主动丢帧;但若输入长期快于处理,队列会持续增长。对离线导出,这可能是正确方向;对实时预览,用户会逐渐看到越来越旧的画面。
另一种做法是仅保留最新帧:
swift
if hasInFlightFrame {
pendingLatest = frame
} else {
startProcessing(frame)
}
这让预览尽快追上当前时刻,却会跳过中间帧。还有一种折中是在途数量达到上限后丢弃新到帧:
swift
if inFlightCount >= maximumInFlight {
drop(frame)
} else {
startProcessing(frame)
}
上面的代码都是简化示例,只表达交付语义,并非完整的线程、取消或错误处理实现。
预览:旧画面通常比跳过中间帧更糟
预览的目标不是把每一帧都显示出来,而是尽快显示接近当前时刻的画面。如果渲染一时跟不上,继续堆积所有帧会让画面越来越滞后;即使最终每帧都显示,交互也已不再像实时预览。
因此,预览适合 latest-only 思路:当前帧正在处理时,不再为每个后续帧建立任务,而是保留一个最新待处理帧;当前处理完成后只消费它。中间帧被有意跳过,换来时间新鲜度。
这也不能成为不可见的行为。上层应能区分"没有新帧"和"有帧,但在背压阶段被跳过"。丢帧计数正是让这种压力可观察的最小接口。
录制:实时性和资源上限之间的折中
录制比预览更严格:它要持续接收输入并写入输出,但也不能把短暂抖动变成无限增长的在途任务。更清晰的选择是有界队列:设定最大在途帧数,未达到上限继续接收,达到上限后按明确规则丢弃新帧,同时记录发生了多少次丢弃。
重点不是"录制一定要丢帧",而是把资源边界写进合同。隐式增长的队列不会消除压力,只会把问题延后到更难定位的位置。
预览和录制可能消费同一个媒体源,但不应因此共享背压配置:预览可以优先最新帧,录制可以使用一个有界实时队列。
离线导出:不能用实时策略替代完整性
离线导出的目标是生成完整结果,而不是追上当前时间。若导出使用 latest-only,中间帧会被主动跳过;若在途满载后丢新帧,输出同样可能缺少内容。因此,离线且不可丢帧的任务应使用不主动丢帧的策略。
这不表示它没有成本:调用方仍要评估输入规模、处理速度、在途资源占用,以及取消或重建任务的时机。但这些是完整性要求带来的成本,不能通过复用实时预览策略来规避。
让策略成为节点合同
更容易审阅和测试的设计,是把策略放进媒体节点:
swift
enum DeliveryPolicy {
case unbounded
case latestOnly
case boundedDropNewest(maximumInFlightFrames: Int)
}
let preview = MediaNode(policy: .latestOnly)
let recording = MediaNode(
policy: .boundedDropNewest(maximumInFlightFrames: 6)
)
let export = MediaNode(policy: .unbounded)
这段同样是简化示例。它的价值在于:意图在创建节点时可见;节点内部统一处理在途、pending 与丢弃规则;压力统计不再散落;测试可以直接断言每种策略的行为。
策略名本身也成为设计文档:latestOnly 表明优先新鲜度;unbounded 则提醒调用方,这是一个不能主动丢帧、可能增加在途工作的场景。
Kakapos 的实现案例
Kakapos 将 MediaSourceDeliveryPolicy 定义为 .unbounded、.latestOnly 与 .boundedDropNewest(maximumInFlightFrames:)。其中,源码注释直接指定离线、不可丢帧的媒体处理应使用 .unbounded(Sources/MediaCore/Pipeline/MediaSource.swift)。
节点入队逻辑也把三种语义放在同一处:unbounded 增加在途工作;latest-only 在已有在途帧时保存一个 pending latest;有界策略达到上限便拒绝当前帧(Sources/MediaCore/Pipeline/MediaNode.swift)。这使背压成为媒体编排层的明确行为,而不是处理器内部的偶然细节。
现有 XCTest 进一步把分支意图写成了可复核事实:MediaEngineTests 分别构造了 latest-only 预览与最大在途帧数为 6 的录制管线;另一个 latest-only 测试断言有一次丢帧,并最终得到帧序列 [1, 3](Tests/KakaposIntegrationTests/MediaEngineTests.swift)。这只是当前的静态测试证据,不等于已经完成任意设备、编解码配置或长时间录制的运行验证。
Kakapos 负责媒体来源、背压、录制与导出编排;逐帧图像或纹理处理可以由可替换 GPU 后端承担。例如 Harbeth 可用于 image、texture、pixelBuffer 或 sampleBuffer 的单帧渲染,但它不拥有相机或视频编辑流程。把"如何处理一帧"与"何时交付一帧"分开,才能让各条媒体分支采用合适的背压合同。
适用边界
本文没有给出队列上限的通用数值,也没有宣称任一策略在所有设备上更快。上限、处理器耗时、输入尺寸、像素格式、编解码和设备条件都需要在目标场景中测量。若导出需要严格控制资源,应在不牺牲完整性的前提下另行设计任务预算、取消与重试策略。
小结与讨论问题
预览、录制和离线导出可以共享媒体输入,但不应共享一个背压策略:
- 预览优先新鲜度,适合 latest-only;
- 录制需要实时性和资源边界,适合有界策略;
- 离线导出优先完整性,适合不主动丢帧的 unbounded;
- 丢帧应可观察,策略应由测试保护。
设计媒体系统时,先问"处理不过来时,哪种损失可以接受",通常比先选择某种队列实现更关键。你会如何为预览、录制与离线导出分别定义这个合同?
源码与参考实现
标签:iOS macOS AVFoundation Swift 视频处理 背压
封面文字:预览、录制、导出为什么不能共用一种背压?