资讯动态

macOS录屏采集实战:从ScreenCaptureKit到性能调优

发布时间:2026/10/21 2:22:57 来源:尧图企业网站定制
做macOS桌面端的录屏采集最怕的不是写代码而是选错方案。不管是做课程录制、直播伴侣、远程协助还是给视频工具做屏幕抓帧第一步都是从系统里稳定地拿到屏幕画面。macOS对屏幕内容的隐私管控又特别严格权限、窗口列表、像素格式、帧率控制里藏着大量的坑稍不注意就是黑屏、空窗口、权限弹窗刷屏。这篇文章把我做macOS桌面端录屏采集的实现过程完整复盘一遍从方案选型讲到权限处理再讲到基于ScreenCaptureKit的采集、编码和性能调优。如果你正准备给自己的应用加一个录屏功能或者对macOS音视频采集感兴趣这篇文章可以直接拿来当操作手册用。1. 技术选型解析先看清几种录屏方案的差异很多人在项目第一天就开始写代码结果写到一半发现手里的方案拿不到窗口列表或者CPU占用压不下来然后推倒重来。这个环节值得多花一点时间。1.1 从CGDisplayStream到ScreenCaptureKit的技术演进macOS上能用的屏幕采集方案我梳理了一下大概经历了三个阶段。早期做法是直接用CoreGraphics接口比如通过CGDisplayStream对显示器做逐帧采集拿到数据后自己处理成CMSampleBuffer。这套方案的好处是足够底层灵活性高由开发者完全掌控缓冲区和输出格式但坏处也非常明显它只认显示器不认窗口。如果你想录制某个特定App的窗口需要自己去查窗口列表再手动计算它的位置和尺寸自己处理窗口遮挡关系。写起来非常繁琐遇到Retina屏幕的坐标换算问题更是头疼。AVFoundation那一代提供了AVCaptureScreenInput比CoreGraphics友好不少。它支持传入显示器ID和区域坐标也可以指定录制某个窗口还顺带做了鼠标指针显示等基础能力。这套接口用起来简单配合AVAssetWriter就能比较快地落一个录屏Demo。但它的性能表现不够理想帧率高了容易掉帧颜色格式要做转换而且对开发者的掌控能力限制很大。大概从macOS 12.3开始苹果推出了ScreenCaptureKit框架。它在系统级做了一个高性能的采集管道可以同时在显示器、窗口、App这几个维度上筛选内容还能直接采集系统音频内部又做了硬件加速优化性能和稳定程度都高了一个台阶。这也是我目前的主力方案。1.2 不同技术方案的能力对比为了让你看得更清楚我把几种方案的差异整理成了一张表。对比项CGDisplayStreamAVCaptureScreenInputScreenCaptureKit最低版本macOS 10.xmacOS 10.15macOS 12.3采集显示器支持支持支持采集单个窗口无需自行处理支持有对应的初始化方式支持可选窗口过滤采集系统音频不支持不支持支持帧率控制需自行限流通过minFrameDuration控制通过minimumFrameInterval控制硬件加速能力一般一般好API复杂度高低中等对Apple Silicon优化无一般好从表格里能明显看出来ScreenCaptureKit是当前最推荐的方案。但要注意它有一个硬性前提系统版本必须在macOS 12.3及以上。如果项目的兼容范围覆盖了更老的系统那你就不能只依赖这一套需要做版本判断在旧系统上回退到AVCaptureScreenInput或CGDisplayStream。选型时还要想清楚应用的真实场景。如果你的工具只录一块固定屏幕不需要窗口级采集那用老方案也能稳住如果要做“录制某一App窗口”或者“分屏分区录制”直接上ScreenCaptureKit能省掉很多自己造轮子的工作量。我的建议是新项目直接按ScreenCaptureKit来设计老系统作为降级分支处理不要花精力在老方案上做深度优化。2. 权限、隐私与工程配置录屏前最容易被忽视的一步很多初次接触macOS录屏开发的人都会在这里卡住。明明代码逻辑没问题运行起来却拿不到任何屏幕数据或者在系统设置里找不到自己的App又或者授权弹窗点了同意之后依然黑屏。这些基本都是权限处理没做好。2.1 屏幕录制权限的申请与检测macOS有一个独立于摄像头和麦克风的隐私权限叫“屏幕录制”。只要App试图采集屏幕内容系统就会要求用户授权。这个权限没有Info.plist里的usage description字段不像相机那样可以在配置文件里声明用途。你只能在运行时申请而且系统只会弹一次授权框。我常用的权限申请逻辑是这样的import CoreGraphics func ensureScreenCaptureAuthorization() { if CGPreflightScreenCaptureAccess() { // 已经有屏幕录制权限 return } if #available(macOS 10.15, *) { CGRequestScreenCaptureAccess() } }注意几个细节。CGRequestScreenCaptureAccess()会在首次调用时弹出系统授权框这个动作应该放在用户主动触发的位置比如点击“开始录制”按钮之后而不是App一启动就弹。否则用户不知道发生了什么会习惯性点掉。另外有一个很坑的现象用户点击“允许”之后部分系统版本上CGPreflightScreenCaptureAccess()仍然返回false原因是权限状态没有马上刷新。这时候最有效的办法是提示用户重启应用或者重新登录账户。实际开发中我一般会在用户授权后弹一个小提示告诉用户“如果检测按钮一直没有变绿请重启一下应用”。这个提示能减少大量反馈工单。2.2 授权状态的持续检测与应用兜底屏幕录制权限还有一个明显区别于其他权限的特点它对应用的“身份”很敏感。你改了Bundle ID、换了签名证书、更新了签名配置系统都会当成一个“新应用”来处理之前授予的权限就失效了。所以在工程层面要做三件事。第一在应用启动或者从后台回到前台时重新检查权限状态func checkAuthorizationAgain() { if CGPreflightScreenCaptureAccess() { // 权限就绪刷新UI并继续 } else { // 弹出引导页面告诉用户去系统设置里开启权限 } }第二不要把权限请求和录制流程耦合在一起。我先做一个单独的权限引导界面用户点击一个按钮去授权授权成功后再进入录屏参数设置页。这么做的好处是录制页面不会因为权限弹窗问题出现状态错乱。第三如果用户取消了一次授权后面能不能再次触发弹窗实际上不能。系统不会反复弹授权框这时候只能引导用户去“系统设置—隐私与安全性—屏幕录制”里手动打开对应App的开关。我在引导页上加了明确的文字和跳转路径实测下来能解决绝大多数权限问题。还有一个工程细节建议个人开发、内部工具、公司测试类项目我倾向于直接关闭App Sandbox用开发者ID签名分发给测试机。如果开启Sandbox会遇到容器内路径、权限继承等一堆额外的坑。当然如果你的应用要上应用商店就必须遵守Sandbox规则那一套配置又是另一个话题了。注意ScreenCaptureKit和CGDisplayStream的权限接口在系统内部是共享同一份“屏幕录制”授权状态。不要觉得新版API就不用处理权限了一样要过这一关。3. 基于ScreenCaptureKit的采集实现从零跑通录制流程权限解决之后就到了最核心的代码实现环节。这一章我按一个最小可运行的录制流程来讲目标是先把屏幕内容采集出来并写入一个MP4文件。3.1 获取共享内容显示器和窗口列表ScreenCaptureKit的核心思路是先从系统拿到一个“可共享内容”的集合这个集合包含了显示器列表、App列表和窗口列表然后你基于这个集合构建过滤器和配置最后启动采集流。获取内容的代码非常简单import ScreenCaptureKit let content try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)这个方法返回SCShareableContent里面有displays、windows、applications这三个数组。参数说明一下就清楚了。excludingDesktopWindows表示是否排除桌面上的桌面窗口元素比如图标区域内的特殊窗口。onScreenWindowsOnly表示只返回当前屏幕上可见的窗口。做普通录屏工具时这两个参数建议设为true否则拿到的窗口列表里会出现大量系统内部的透明窗口和隐藏窗口干扰你选择目标。如果发现content.displays为空几乎可以断定是屏幕录制权限没通过。我第一次遇到的时候在窗口列表上翻来覆去排查了很久最后才发现是权限状态问题。获取目标显示器时一般直接取主屏guard let targetDisplay content?.displays.first else { return }如果你想录制某个窗口而不是全屏可以遍历content.windows按窗口标题或者进程名筛选。3.2 构建内容过滤器与采集配置有了目标显示器之后要创建SCContentFilter。过滤器的作用是告诉采集管道“我到底要录什么东西”。let filter SCContentFilter(display: targetDisplay, excludingWindows: [])这段代码的含义是录制整个显示器不排除任何窗口。如果你想录制某几个窗口可以先拿到窗口对象然后放到excludingWindows里或者用窗口初始化过滤器。窗口级过滤在录制单个App窗口时特别有用因为录出来的画面上不会出现其他App遮挡的痕迹。接下来是SCStreamConfiguration这里是控制分辨率、帧率、像素格式和音频抓取的地方let config SCStreamConfiguration() config.width Int(targetDisplay.width) * 2 config.height Int(targetDisplay.height) * 2 config.minimumFrameInterval CMTime(value: 1, timescale: 60) config.pixelFormat kCVPixelFormatType_32BGRA config.showsCursor true config.capturesAudio false这里有一个容易出错的地方。targetDisplay.width和targetDisplay.height返回的是像素尺寸而SCStreamConfiguration里的width和height也是像素尺寸。在Retina屏幕上如果直接把逻辑分辨率拿过来用录出来的视频会模糊而且编码器输出的尺寸和你预期不符。稳妥的做法是直接用display.width和display.height作为基准。minimumFrameInterval控制的是最低采集间隔CMTime(value: 1, timescale: 60)代表每秒最多60帧。不要把它理解成“强制60帧”而是“最多每1/60秒取一帧”。如果显示器刷新率低于60Hz或者性能跟不上实际帧率会自动下降。pixelFormat设置成BGRA可以保证后续和AVAssetWriter、Core Image、Metal都能顺畅衔接。不设置的话系统给的默认格式在部分设备上会是YUV后面做图像处理时又要多一步格式转换。3.3 创建采集流并实现数据回调过滤器和配置准备好了接下来创建SCStreamlet stream SCStream(filter: filter, configuration: config, delegate: delegate) try? await stream.startCapture()这里的delegate需要实现SCStreamOutputDelegate和SCStreamDelegate。关键的输出代理方法是func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: // 视频帧处理 break case .audio: // 音频帧处理 break default: break } }当type是.screen时sampleBuffer就是视频帧。要从里面取出原始数据可以用CMSampleBufferGetImageBuffer拿到CVPixelBuffer后续要缩略图、滤镜或者编码都基于这个buffer操作。需要注意stream这个对象必须被强引用。很多人把stream写成局部变量启动采集后对象被释放代理方法就再也不回调了。我建议把它作为类的属性或单例持有。还有一点代理回调不一定在哪个队列里执行如果直接把CVPixelBuffer传给UI线程做预览会遇到卡顿。处理预览时建议在回调里先把PixelBuffer转换成一个缩略图再切回主线程更新UI。3.4 把视频帧写入MP4文件采集到帧之后最基础的需求是保存成文件。这里我选用AVAssetWriter配合AVAssetWriterInputPixelBufferAdaptor来做因为直接从ScreenCaptureKit拿到的sampleBuffer如果不经过格式匹配直接喂给AVAssetWriterInput很容易出现格式描述不兼容的崩溃。用PixelBufferAdaptor则更直接它只认CVPixelBuffer适配性好得多。初始化写入器let outputURL URL(fileURLWithPath: /tmp/recording.mp4) let writer try AVAssetWriter(outputURL: outputURL, fileType: .mp4) let videoSettings: [String: Any] [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: captureWidth, AVVideoHeightKey: captureHeight, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: 8_000_000, AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel ] ] let videoInput AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) videoInput.expectsMediaDataInRealTime true let adaptor AVAssetWriterInputPixelBufferAdaptor( assetWriterInput: videoInput, sourcePixelBufferAttributes: [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String: captureWidth, kCVPixelBufferHeightKey as String: captureHeight ] ) writer.add(videoInput) writer.startWriting() writer.startSession(atSourceTime: .zero)在didOutputSampleBuffer回调里把PixelBuffer写入func appendPixelBuffer(_ sampleBuffer: CMSampleBuffer, to adaptor: AVAssetWriterInputPixelBufferAdaptor, input: AVAssetWriterInput) { guard let pixelBuffer CMSampleBufferGetImageBuffer(sampleBuffer) else { return } guard input.isReadyForMoreMediaData else { return } let time sampleBuffer.presentationTimeStamp adaptor.append(pixelBuffer, withPresentationTime: time) }编码参数这里我给的是一个比较通用的值H.264、8Mbps码率、High Profile。如果你做的是高动态内容录制建议把码率提高到12Mbps以上如果只是录PPT和文档4Mbps就够文件体积能小不少。停止录制的过程也要小心。先调用videoInput.markAsFinished()再关闭stream最后异步调用writer.finishWriting()。顺序反了的话偶尔会出现文件损坏或者视频头部信息写不进去。videoInput.markAsFinished() await stream.stopCapture() await writer.finishWriting()到这里一个最小可用的录屏工具就跑通了。4. 性能调优与高频问题排查把录制质量和稳定性拉满能跑通只是第一步真正让人抓狂的是性能问题和各种边界情况。这一章我把自己踩过的、开发群里高频出现的问题汇总一下。4.1 帧率、分辨率怎么调才不卡很多录屏工具在开发阶段测试时用的是小窗口帧率也低看不出来问题。一到正式使用就是全屏录制、60帧、还得同时推流CPU一下子就爆了。我的经验是分三档调参。第一档静态内容固化。如果你录的只是操作演示、PPT讲解、代码编辑器30帧完全够用没必要上60帧。把minimumFrameInterval设置成1/30CPU负载能降很多。第二档动态内容平衡。录游戏画面或者视频播放时建议用60帧但分辨率不要无脑拉高。如果目标显示器是4K可以先按2560x1440输出画质损失很小CPU占用却低很多。不要用display.width的2倍直接用原生像素值。第三档硬件编码一定要开启。AVAssetWriter里设置AVVideoCodecType.h264或.hevc时系统会自动选择硬件编码器。Apple Silicon上的硬件编码性能非常好如果不小心用了软件编码录制同时做其他的事情就会出现肉眼可见的卡顿。另外ScreenCaptureKit内部有缓冲队列机制。默认情况下队列深度可能偏大录制时间长了会吃不少内存。可以在SCStreamConfiguration里调整queueDepth我一般设置成3到5既能保证流畅度又不会让内存涨得太离谱。4.2 高频问题窗口列表为空、首帧黑屏、CPU占用过高窗口列表为空90%是权限问题不用去查代码。可以先在应用里打印CGPreflightScreenCaptureAccess()的结果如果返回false直接去系统设置打开权限。还有一种情况你用的是局域网调试机屏幕本身处于锁屏状态也会出现“有显示器但拿不到窗口”的现象。解锁屏幕再试一次。首帧黑屏的问题常见原因是用户刚触发录制、权限弹窗也刚出现系统还没完成采集管道初始化。这时候第一帧拿到的是全黑数据但后面会恢复正常。我的处理方式是丢弃采集流启动后的前3帧避免把黑屏写入视频文件开头。代码里加一个计数前3个.screen回调不写入即可。CPU占用过高先确认是不是自己在回调里做了重活。比如每帧都转NSImage、做Core Image复杂滤镜、写文件时同步IO都会拖垮性能。正确的做法是回调里只做轻量操作复杂逻辑放到其他串行队列或者用autoreleasepool包住每一帧处理。还有如果同时开着多个录制流CPU会翻倍必须先停掉不需要的流。4.3 声音采集的坑与解决方案录屏不带声音体验会差很多。ScreenCaptureKit支持系统音频采集配置方式很简单config.capturesAudio true音频回调的数据会在didOutputSampleBuffer中以.audio类型返回。把这个CMSampleBuffer喂给AVAssetWriterInput音频输入即可。但有两个常见的坑。第一声音采集依赖系统当前输出设备。如果系统没有播放任何声音或者输出设备被切换到某个无声设备录出来的音频会是空的。录制工具界面上最好展示“当前输出设备”状态提醒用户不要切到静音输出。第二音频的sampleBuffer格式和AAC编码不是天然兼容的。直接塞给AVAssetWriterInput时有些系统版本会报错。我一般会为音频单独建一个输入并设置明确的输出格式让系统完成转码let audioSettings: [String: Any] [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVNumberOfChannelsKey: 2, AVSampleRateKey: 44100, AVEncoderBitRateKey: 128000 ] let audioInput AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)有的开发者还会遇到“明明在录屏但是听不到自己耳机里播放的声音”。这是因为系统把声音路由到了自己的音频会话和屏幕录制采集的设备不一致。遇到这种问题先检查是不是有虚拟声卡类工具在中间做路由有的话直接退出它们再测。4.4 多场景扩展区域录制、窗口跟随、声音混音录屏采集的基本功做好之后可以往实际业务场景扩展。如果你想录制屏幕中的一个区域比如直播伴侣只采集游戏画面中的一个区块可以直接在拿到帧后用Core Image做裁剪也可以用更高级的过滤器来限定区域。不过区域过滤在不同系统版本上的行为差异比较大稳定性还没有全屏采集那么省心。我目前的建议是先用全屏采集在业务层做裁剪逻辑最清晰。如果你要录制某个窗口并且希望窗口位置变化时录制画面跟随移动那就需要监听窗口位置变化事件再同步更新SCStreamConfiguration或重新创建stream。这个功能复杂度不低但只要底层的采集链路已经打通做起来只是时间问题。声音混音是更进一步的需求。ScreenCaptureKit采集到的系统声音可以做成一条音轨麦克风采集可以做成另一条音轨后期剪辑时灵活处理。这里要注意两个音轨的采样率、声道数尽量保持一致否则输出文件在播放器里可能音画不同步。写在最后录屏采集这个功能表面上就是“把屏幕画面拿到手”但实际做下来最花时间的从来不是编码而是权限状态、缓冲机制、不同显示器和系统版本下的表现差异。我自己的经验是先把最小Demo跑通确认了权限和首帧稳定再去叠加音频、窗口级捕获、预览这些功能会顺畅很多。最后分享一个小技巧。我在调试期间会在界面上实时显示“当前权限状态、目标帧率、实际帧率、内存占用”这四项数据。看似简单但排查问题特别管用。别人报Bug时你远程让对方截一张状态图基本就能定位到是权限失效、帧率没达标还是内存有泄漏。这个习惯帮我省了无数沟通成本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑