ScreenCaptureKit 滚动缓存录制最后30秒屏幕的queueDepth与API流程问题
我完全懂你现在的纠结——想用ScreenCaptureKit做一个「随时保存最后30秒屏幕内容」的工具,之前靠硬拉queueDepth到900凑出了功能,但苹果明确说这个参数不能超过8,改回合规值后又只能拿到寥寥几帧,完全达不到需求。其实核心问题是你误解了queueDepth的作用,把它当成了自己的滚动缓存,现在我来帮你理清正确的API使用逻辑,以及怎么用合规的方式实现需求。
问题根源:误解了queueDepth的作用
苹果文档里的queueDepth是ScreenCaptureKit内部的帧缓冲区大小,用来临时存储还没来得及被你的代码处理的帧,防止因为你的回调处理不及时导致丢帧。这个参数的合理范围是1-8,设成900不仅会占用巨量内存,还违反了SDK的设计意图,甚至可能导致性能问题或者被系统限制。
你之前能靠900的queueDepth「录到30秒」,本质是让SDK帮你存了900帧,但这完全不是正确的用法——你的代码应该自己接收每一个输出的帧,然后维护自己的滚动缓存,而不是依赖SDK的内部缓冲区。
正确的API流程
- 初始化流时,保持queueDepth在1-8之间:比如设为8,遵循苹果的建议,让SDK负责内部的帧缓冲防丢帧。
- 持续接收每一个输出的有效帧:通过自定义的
SCStreamOutput(你的ScreenStreamOutput)接收每一个状态为.complete的帧。 - 维护自己的线程安全滚动缓存:把每一个有效帧存入缓存,同时定时清理超过30秒的旧帧。
- 触发保存时,从缓存提取帧生成视频:用
AVAssetWriter把缓存中的帧转成MP4文件。
针对你的代码的具体修改建议
1. 修正queueDepth的值
首先把流配置里的queueDepth改成8:
config.queueDepth = 8 // 严格遵循苹果建议的最大值
2. 优化滚动缓存的帧存储逻辑
你的rollingFrameBuffer是对的方向,但要注意:ScreenCaptureKit输出的CMSampleBuffer可能会被SDK回收,所以必须复制一份再存入缓存,同时要高效清理旧帧:
private func handleFrame(_ sampleBuffer: CMSampleBuffer) { rollingFrameBufferQueue.async { [weak self] in guard let self = self else { return } // 复制Sample Buffer,确保我们持有独立的引用,避免SDK回收原帧 guard let copiedBuffer = CMSampleBufferCreateCopy(nil, sampleBuffer) else { print("[DEBUG] Failed to copy sample buffer for cache") return } let presentationTime = CMSampleBufferGetPresentationTimeStamp(copiedBuffer) self.rollingFrameBuffer.append((copiedBuffer, presentationTime)) // 清理超过30秒的旧帧,保持滚动缓存的大小 let cutoffTime = CMTimeSubtract( presentationTime, CMTimeMakeWithSeconds(self.rollingBufferDuration, preferredTimescale: 1000000000) ) self.rollingFrameBuffer = self.rollingFrameBuffer.filter { CMTimeCompare($0.1, cutoffTime) >= 0 } // 可选:打印缓存当前状态,调试用 print("[DEBUG] Rolling buffer now contains \(self.rollingFrameBuffer.count) frames") } }
3. 确保流持续输出帧
你之前改queueDepth后只能拿到8帧,大概率是误解了queueDepth的作用——只要你的ScreenStreamOutput回调能及时处理帧,SDK会持续输出新的帧,不会只停在8帧。检查你的ScreenStreamOutput实现,确保没有过滤掉后续的帧。
4. 优化缓存的线程安全
你已经用了rollingFrameBufferQueue,继续保持所有对rollingFrameBuffer的读写操作都在这个队列里执行,避免多线程竞争。
5. 修正保存逻辑的细节
保存时,确保你正确使用AVAssetWriter写入帧,注意设置和缓存帧匹配的视频格式、帧率、分辨率:
private func writeFramesToDisk(frames: [(CMSampleBuffer, CMTime)], to outputURL: URL, completion: @escaping (Bool) -> Void) { guard !frames.isEmpty else { completion(false) return } do { let assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) let firstFrame = frames.first!.0 // 获取视频格式信息 guard let formatDescription = CMSampleBufferGetFormatDescription(firstFrame) else { completion(false) return } let videoSettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: NSNumber(value: CMSampleBufferGetWidth(firstFrame)), AVVideoHeightKey: NSNumber(value: CMSampleBufferGetHeight(firstFrame)) ] let assetWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) assetWriterInput.expectsMediaDataInRealTime = false // 非实时写入,因为我们是批量处理缓存帧 assetWriterInput.transform = .identity // 根据需要调整旋转 if assetWriter.canAdd(assetWriterInput) { assetWriter.add(assetWriterInput) } else { completion(false) return } assetWriter.startWriting() assetWriter.startSession(atSourceTime: frames.first!.1) let dispatchGroup = DispatchGroup() for (sampleBuffer, _) in frames { dispatchGroup.enter() if assetWriterInput.isReadyForMoreMediaData { assetWriterInput.append(sampleBuffer) dispatchGroup.leave() } else { // 等待输入准备好 assetWriterInput.requestMediaDataWhenReady(on: .global()) { assetWriterInput.append(sampleBuffer) dispatchGroup.leave() } } } dispatchGroup.notify(queue: .global()) { assetWriterInput.markAsFinished() assetWriter.finishWriting { completion(assetWriter.status == .completed) } } } catch { print("[DEBUG] Failed to initialize AVAssetWriter: \(error)") completion(false) } }
关键注意事项
- 内存管理:
CMSampleBuffer占用内存较大,30秒30fps的1080p帧大概会占用数GB内存,你可以考虑降低帧率(比如15fps)或者在缓存中存储压缩后的帧,避免内存溢出。 - 显示器切换处理:当用户切换显示器时,要正确停止旧的流,启动新的流,此时可以选择清空缓存或者继续保留(根据产品需求)。
- 帧状态检查:一定要只处理状态为
.complete的帧,避免无效或部分渲染的帧影响最终视频质量。 - 流的生命周期:确保
stream和screenStreamOutput的引用正确管理,避免提前释放导致流中断。
内容来源于stack exchange

