
做macOS桌面端的录屏采集说难不难说简单也不简单。我最近接手的一个Mac客户端项目正好需要完整的屏幕共享能力从CGDisplayStream到AVCaptureScreenInput再到ScreenCaptureKit三个方案全部试过一轮踩了不少权限、黑屏、性能、多屏设备上的坑。这篇就当一次完整复盘三个采集方案的选型对比、屏幕录制权限的处理、ScreenCaptureKit的接入代码、窗口级采集和系统音频采集的进阶用法最后附上我实际跑出来的性能数据和问题排查清单。不管你是刚接触客户端开发还是在做视频会议、远程协助、在线教育这类需要桌面共享的产品这篇内容都可以直接照抄参考。1. 选型三条路线各有各的脾气1.1 三套API的本质区别macOS上录屏采集官方能给到的主要是下面这三条路第一条是CGDisplayStream。它挂在CoreGraphics体系下接口很老我现在回头看它的主要价值在于“轻”。你给它一个显示器ID它把桌面帧回调给你数据是IOSurface或者像素缓冲想做最简单的屏幕抓取很直接。但缺点也明显结构太简单了没有窗口过滤的概念想单独采集某一个App窗口它做不到。采集屏幕的同时想拿到系统音频也做不到。所以它更适合老项目维护或者只做轻量截图新项目我不会首选它。第二条是AVCaptureScreenInput属于AVFoundation家族的屏幕输入源。它对熟悉摄像头采集的人非常友好因为和麦克风、摄像头完全是一套AVCaptureSession的玩法。把AVCaptureScreenInput加进session再加一个AVCaptureVideoDataOutput就能在回调里拿帧。这套方案的优点是生态统一你很容易同时采集摄像头和屏幕缺点是屏幕采集本来就不是AVFoundation的主场可配置项少CPU占用偏高帧率也不稳定。我在原型验证阶段用过它功能是能跑通但距离“好用”还差很远。第三条是ScreenCaptureKitmacOS 12.3开始提供。它才是真正为屏幕采集设计的框架从底层到API都在解决屏幕采集的问题。窗口级过滤、高帧率、系统音频流、HDR支持这些都内置了在Apple Silicon上性能表现非常好。缺点是要求系统版本高API设计也偏复杂需要花一点时间理解内容过滤器、流配置、输出回调这一整套模型。1.2 我的选型结论与原因我最后把正式方案定在ScreenCaptureKit上原因很简单项目部署目标可以接受macOS 13没必要用旧方案给自己埋坑。三个方案的差异我整理了一张表方便你对比对比项CGDisplayStreamAVCaptureScreenInputScreenCaptureKit最低系统要求老接口兼容性好macOS 10.7macOS 12.3输出数据IOSurface/像素缓冲CMSampleBufferCMSampleBuffer窗口级采集不支持不支持支持SCWindow过滤系统音频采集不支持不支持支持HDR桌面支持无无支持实现复杂度中等较低中等偏高性能表现一般一般CPU偏高Apple Silicon上明显更好如果你需要兼容macOS 12以下的系统又不想用CGDisplayStream那就用AVCaptureScreenInput保底如果部署目标允许直接ScreenCaptureKit没有纠结的必要。我后面讲的核心代码都以ScreenCaptureKit为主只有权限部分会涉及一点老接口。2. 开局先过权限关2.1 录屏权限为什么和相机权限不一样macOS的屏幕录制权限走的是TCC体系但和摄像头、麦克风权限有个非常大的区别Info.plist里不需要添加NSCameraUsageDescription那种用途描述键。很多第一次做录屏的人会下意识去补一个ScreenCaptureUsageDescription实际上没有这个键系统弹窗照样会出你加了反而没用。判断当前App是否已经获得屏幕录制权限可以用CGPreflightScreenCaptureAccess()这个接口从macOS 10.15开始就有。请求授权用CGRequestScreenCaptureAccess()调用后系统会弹出提示框用户点了“允许”你才有权限。注意这个权限和摄像头权限不一样用户不会收到“App想访问屏幕”这种明确文案的可能性也存在很多情况下系统弹窗是静默模式你唯一能稳定感知权限变化的方式就是捕获API开始能正常输出帧了。我自己习惯的顺序是启动App后先CGPreflightScreenCaptureAccess()检查没权限就先弹提示引导用户去系统设置里打开拿到权限之后再开始采集流程。用ScreenCaptureKit的SCShareableContent.current也能触发权限申请首次调用时如果没权限会直接throw错误所以这也可以作为权限触发的入口。func ensureScreenCaptureAccess() - Bool { if CGPreflightScreenCaptureAccess() { return true } CGRequestScreenCaptureAccess() return false }这里有一个必须说清楚的细节CGRequestScreenCaptureAccess()调用后立即返回返回值不代表用户同意。用户授权需要时间弹窗出现后正确的做法是等用户完成操作再去检测一次权限状态不要弹窗都没来得及出现就立刻开始采集。2.2 工程侧的最小配置与授权代码在Xcode里新建一个macOS App工程工程配置上其实没有太多要动的东西语言选Swift界面模板随便我习惯用纯代码App不依赖Storyboard。Target的Signing Capabilities里可以开App Sandbox录屏权限不需要额外的entitlement键这点和网络、文件访问不一样。部署目标按你的实际情况来我的建议是直接设到macOS 13以上这样能放心用ScreenCaptureKit而不用写降级分支。如果你打算做成命令行工具或者后台服务要格外注意屏幕录制权限需要有图形用户登录会话用SSH远程登录的会话里是拿不到桌面内容的这问题排查起来特别隐蔽。工程建好后我建议先写一个极简的权限测试页进去先检查权限没有权限就弹一个按钮让用户去系统设置开启拿到权限后显示当前主屏的ID和尺寸。这一步花不了几分钟但能帮你确认签名、沙盒、权限链路都是通的后面调试采集流程会省很多事。很多新人容易在权限这里卡一整天原因就是没分清“系统设置弹窗”和“TCC判定结果”之间的时序关系。你在App启动时调用CGRequestScreenCaptureAccess()用户点了允许但SCShareableContent.current下一次调用仍然可能抛错因为权限状态更新有延迟多试几次或者重新询问一下就正常了。这不是代码写错了是系统的TCC状态刷新需要时间。3. ScreenCaptureKit最常用的采集链路3.1 获取可分享内容并构造内容过滤器ScreenCaptureKit整个采集流程可以拆成四步获取可分享内容、构造内容过滤器、配置流参数、启动流并处理回调。获取可分享内容用SCShareableContent.current它是个异步接口返回当前可采集的显示器、窗口和App列表。只要没有录制权限这个接口就会抛错所以这里天然可以作为权限状态的一次校验。let content try await SCShareableContent.current print(content.displays.count) // 显示器数量 print(content.windows.count) // 当前可采集的窗口数量 print(content.applications.count) // 正在运行的App数量构造内容过滤器是ScreenCaptureKit里最核心的一个概念它决定你要采集什么。最简单的用法是采集整个显示器guard let display content.displays.first else { return } let filter SCContentFilter(display: display, excludingWindows: [])excludingWindows: []表示不排除任何窗口整个桌面包括壁纸、所有窗口都会被采集。如果你只想要桌面壁纸和图标不想带上某些窗口可以把窗口传进excludingWindows数组里比如let filter SCContentFilter(display: display, excludingWindows: [window1, window2])这里的关键点在于SCContentFilter并不只是定义了一个矩形区域它定义了“哪些内容允许出现在输出画面里”。这对隐私场景很有用比如会议共享屏幕时用户勾选“不共享通知中心”你只需要把对应窗口加进排除列表数据层面就直接过滤掉了不用自己在推流端涂黑处理。3.2 配置采集参数分辨率、帧率与光标过滤器确定好采集对象之后下一步是配置SCStreamConfiguration。这个类控制输出的分辨率、帧率、光标、音频等参数。let config SCStreamConfiguration() config.width 1920 config.height 1080 config.minimumFrameInterval CMTime(value: 1, timescale: 30) // 最高30fps config.showsCursor true config.capturesAudio false config.queueDepth 3逐项说下我的考虑宽高直接设置你期望的输出像素尺寸。这里不要盲目跟着显示器原始分辨率走4K显示器直接传3840x2160的话编码和推流压力会大不少在很多场景下1080P已经足够清晰。minimumFrameInterval是帧之间的最小间隔1/30代表最高30帧每秒。如果你想要60fps就设成CMTime(value: 1, timescale: 60)。但帧率不是越高越好看桌面静止内容的时候30fps和60fps肉眼几乎没区别CPU和GPU开销却差很多。showsCursor控制鼠标指针是否出现在输出画面里。做远程协助类产品一般要显示做录屏Demo或者某些自动化测试场景可能不想显示按需设置。queueDepth是内部缓冲队列深度我建议不要调太大。它越大帧缓冲越多抗瞬时波动能力越强但延迟会变高。视频会议这类实时场景3左右比较合适。capturesAudio这里先关掉音频采集放到后面单独讲。这段配置里还有一个容易被忽略的细节如果显示器是Retina逻辑分辨率和物理像素分辨率不一样设备会按你的width/height设置输出不一定等于显示器的原生分辨率。这一点以实际输出为准调试时直接打一行日志看看实际拿到的CVPixelBuffer尺寸是不是你想要的。3.3 创建流、启动采集并在回调里拿帧配置好之后创建SCStream注册输出回调然后启动。一个完整的最小实现大概长这样final class ScreenCaptureManager: NSObject, SCStreamOutput, SCStreamDelegate { private var stream: SCStream? func start() async throws { let content try await SCShareableContent.current guard let display content.displays.first else { return } let filter SCContentFilter(display: display, excludingWindows: []) let config SCStreamConfiguration() config.width 1920 config.height 1080 config.minimumFrameInterval CMTime(value: 1, timescale: 30) config.showsCursor true let stream SCStream(filter: filter, configuration: config, delegate: self) try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) self.stream stream try await stream.startCapture() } func stop() { stream?.stopCapture(completionHandler: nil) stream nil } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { guard type .screen else { return } guard let imageBuffer CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // 到这里就拿到了CVPixelBuffer下一步是编码、预览或推流 } func stream(_ stream: SCStream, didStopWithError error: Error) { // 采集被中断比如显示器被拔掉、权限被关闭 } }这里有几个实践要点第一sampleHandlerQueue不要直接用.main。屏幕帧回调非常频繁在主线程里做像素格式转换、编码或者网络发送都会卡UI。我一般单独创建一个串行队列给它。第二回调里拿到的CVPixelBuffer格式不一定固定。常见的是BGRA或者YUV 420做编码器输入前要主动检查CVPixelBufferGetPixelFormatType不能写死。我之前在对接编码器时就踩过坑以为系统输出一定是BGRA结果在某个系统版本上拿到的是YUV编码器英伟达接口直接花了。第三didStopWithError不能忽略。我碰到过用户突然在系统设置里关闭屏幕录制权限把所有采集进程都杀掉了的情况。如果没有这个回调App的表现就是画面突然冻结非常难排查。正确的做法是在这里做采集状态上报触发界面的“重连采集”逻辑。4. 窗口级采集与系统音频的进阶处理4.1 独立窗口采集被遮挡也能整窗入库全屏采集只是ScreenCaptureKit最基础的能力真正体现它价值的是窗口级采集。比如在线教育里共享某个PPT窗口用户不希望把邮件、聊天记录一起共享出去你就可以只采集目标窗口。let content try await SCShareableContent.current let targetWindow content.windows.first { window in window.owningApplication?.applicationName 某App window.title 某个窗口标题 } guard let window targetWindow else { return } let filter SCContentFilter(desktopIndependentWindow: window)注意desktopIndependentWindow这个参数它表示这个窗口的采集不依赖桌面合成即使窗口被其他窗口完全挡住输出画面里依然能看到窗口的完整内容。换成普通采集方案你想做到这点很难你要不就得把窗口提到最前面要不就得在采集端做复杂的抠图合成。窗口级采集还有一个优势是分辨率可以直接按窗口尺寸来不用全屏采集再裁切省下不少CPU。let config SCStreamConfiguration() config.width Int(window.frame.width) config.height Int(window.frame.height)这里有一个坑如果目标窗口是动画渲染的帧率拉高一点会顺畅很多如果是文档类的静态内容30fps就够。要灵活处理不要一套参数走天下。另一个必须提醒的点是窗口生命周期。用户拖拽窗口、缩放窗口、关闭窗口之后你手里的SCWindow对象可能已经失效了。我的做法是在窗口变化通知比如窗口尺寸变更、应用激活变化时重新获取一次SCShareableContent.current找相同窗口ID的新对象然后重建SCContentFilter和SCStream。这个重建逻辑写起来有点啰嗦但少了它窗口最小化之后画面冻结的问题一定会出现。4.2 系统音频的采集与回音规避窗口级采集解决的是画面问题如果你做的产品还需要声音比如视频会议要共享对方能听到的系统声音那就要打开ScreenCaptureKit的音频采集能力。config.capturesAudio true config.sampleRate 48000 config.channelCount 2 config.streamsAudio true config.excludesCurrentProcessAudio true这些参数的含义capturesAudio总开关打开后ScreenCaptureKit同时采集屏幕视频和系统音频。sampleRate和channelCount输出音频的采样率和声道数。我通常直接设48kHz、双声道避免后面和音频引擎、编码器之间的格式转换。streamsAudio打开后音频会以独立的CMSampleBuffer流输出到回调里SCStreamOutputType会是.audio。excludesCurrentProcessAudio排除当前App自己播放出来的声音。这一步非常重要如果你在做视频会议对方的声音通过你App的扬声器播放这个声音又被系统音频采集进去就会形成回声和啸叫。打开这个参数SDK直接从源头上把你的App播放音频排除掉。音频回调里的处理和视频不太一样switch type { case .screen: // 处理视频帧 case .audio: // 处理PCM音频帧 let format sampleBuffer.formatDescription // 注意从format里读出AudioStreamBasicDescription // 再做重采样、混音或转发给编码器 unknown default: break }音频的CMSampleBuffer里是PCM裸数据你需要从CMAudioFormatDescription里拿到AudioStreamBasicDescription才知道当前的采样率、位深、声道布局。这一步千万别写死不同系统版本、不同音频设备输出格式可能会有差异。音视频同步问题在这里也会暴露出来。ScreenCaptureKit输出的视频帧和音频帧各自带PTS但起点不一定一致频率也不一定一样。你不能简单地把第一帧视频和第一帧音频当成同一时刻。我的做法是维护一个按PTS排序的缓冲队列编码时先看音频PTS和视频PTS的差值对齐好起点之后再交给复用器。不然做出来的录屏文件声音会比画面早几百毫秒或者晚几百毫秒特别明显。5. 性能和工程取舍上的实测心得5.1 CPU占用与帧间隔的真实对比我在同一个macOS设备上做过一次直接对比采集对象都是主屏1080P、30fps、显示动态画面。AVFoundation方案跑下来CPU占用率大致在12%到18%之间波动ScreenCaptureKit稳定在5%左右差距非常直观。如果桌面内容静止下来AVFoundation那套管线还是会按固定节奏往外出帧CPU掉不下去ScreenCaptureKit在画面没有变化的时候会智能减少有效帧的输出资源占用明显降低。为什么差距这么大AVFoundation的屏幕采集是拿现成的采集管线硬套的它帮你把屏幕当成一个虚拟视频源内部走的还是摄像头那套处理流程ScreenCaptureKit则是为屏幕采集重写的可以直接复用WindowServer的合成结果还充分利用了Metal和GPU。所以如果你要做长时录屏或者长时间在线共享选型直接决定用户体验这不是代码层面能补回来的差距。内存方面两者差距不算大但有一个细节ScreenCaptureKit在Apple Silicon上的GPU开销更可控。这一点在双屏、4K分辨率的场景里尤其明显我测过4K 30fps采集配合H264硬件编码整体CPU还是能压在个位数。5.2 分辨率、帧率与队列深度的匹配策略性能优化不只是换框架参数配置同样关键。我总结出一套比较固化的策略文档/PPT共享分辨率1280x720或1920x1080帧率15fps就够。人眼看静态文档20fps以上不会有明显区别帧率压低能省出大量CPU给摄像头视频流。视频播放共享优先保证帧率30fps打底如果显示器支持且编解码压力不大可以试60fps。分辨率可以适当降到1080P但帧率千万不能掉。远程操作/远程协助分辨率按窗口尺寸走帧率15fps到30fps都不错关键是把延迟压住queueDepth保持在2到3不要给缓冲队列留太深。全屏录屏Demo如果画面包含动态桌面就按照显示器的原生刷新率采集一次录成文件用不追求实时队列可以稍微调大一点抗抖动。queueDepth这个参数我单独说一句。它经常被忽略但对延迟影响很大。队列深度从3调到5延迟可能从100毫秒涨到200毫秒以上。实时交互场景里超过150毫秒的延迟用户就能明显感觉到鼠标飘、声音对不上。所以不是越大越流畅而是要在稳定和延迟之间取一个平衡点。另外一个工程上的建议是不要把一个全局配置写死。我会把分辨率、帧率、是否采集光标、是否采集音频都做成动态配置根据用户当前共享的窗口类型和使用场景去调整。做采集模块的时候多留几个参数后面产品提需求会少改很多代码。6. 高频问题排查与避坑实录6.1 画面黑屏或没有输出屏幕采集里最让人头疼的问题就是黑屏。我把它分成了几类第一类是受系统保护的内容。macOS对某些敏感内容默认不允许采集比如Apple Pay的支付界面、部分DRM加密的视频播放画面、某些安全输入框。即使你App已经拿到屏幕录制权限这些区域在采集结果里仍然是黑的。这是系统层面的安全策略不是你的bug产品层面需要接受或者做占位提示。第二类是虚拟机或者远程桌面环境。ScreenCaptureKit在虚拟机里有时会拿不到真实画面或者只能拿到黑帧。这个跟WindowServer的合成机制有关开发调试尽量在实体Mac上进行虚拟机里出现的黑屏现象不一定代表你的代码有问题。第三类是采集目标窗口本身的问题。某些基于特殊渲染框架的窗口视频内容不走普通的窗口合成路径ScreenCaptureKit可能采不到。我在调试某类基于OpenGL/外部渲染的应用窗口时遇到过最后方案是把采集对象从窗口改为显示器区域裁切绕开了这个限制。排查黑屏问题有一个固定的路径先确认权限状态再确认采集目标是否为空再看系统输出帧格式最后检查是否被系统保护策略拦截。每一步打上日志黑屏原因很快能定位到。6.2 权限不弹窗或一直拿不到权限权限不弹窗的原因通常有这么几个不仅没有Info.plist描述键的问题还有可能会因为第一次点击“拒绝”之后系统不再主动弹窗。App是未签名状态运行时TCC弹窗在某些系统版本下会出现异常。当前进程实际上已经有权限了但你的代码在弹窗出现前就去调用了SCShareableContent.current导致一进去就throw错误给用户的感知就是“永远拿不到权限”。最省事的处理方法是提供一个引导按钮点击之后调用CGRequestScreenCaptureAccess()同时在界面上提示用户打开“系统设置-隐私与安全性-屏幕录制”把开关打开。如果用户之前误点了拒绝系统设置里会显示这个App手动开启即可。如果你需要测试权限的重置流程终端里执行下面命令会重置所有App的屏幕录制权限tccutil reset ScreenCapture重置之后下次调用采集API会重新触发系统弹窗方便你测试从零到授权的完整流程。6.3 多显示器、外接屏与尺寸变化多显示器场景最常见的坑是采集目标选错。content.displays返回的是多块屏幕的数组如果你只取first在某些Mac上拿到的是外接显示器而不是内建屏。用户明明共享的期待是主屏结果你采集到副屏画面完全不对。正确做法是根据SCDisplay.displayID去匹配你想要的那块屏不要依赖数组顺序。屏幕参数变化也需要处理。外接显示器拔插、分辨率切换、刷新率变化都可能导致正在运行的SCStream直接报错或者输出异常画面。我踩过一次雷显示器从60Hz切到144Hz之后旧流还在跑但输出帧率一直卡在原有配置上画面也出现了明显的撕裂感。正确的做法是监听屏幕参数变化通知收到通知后主动停止旧流重新获取SCShareableContent.current重建filter和config再启动新流。监听我一般用这个通知NotificationCenter.default.addObserver( self, selector: #selector(screenConfigChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil )还有一个Retina相关的问题采集结果比实际窗口看起来模糊。这是因为逻辑分辨率和物理像素分辨率不一致你设置的输出尺寸如果小于窗口物理像素尺寸画面被缩放就糊了。希望保留清晰度就要把窗口的物理像素尺寸直接作为输出尺寸。这个在窗口级采集时尤其要注意别采出来一个模糊的PPT。我再补一个经验值显示器如果开了HDRScreenCaptureKit对HDR内容的输出格式会和SDR完全不同。如果你的编码器或者后续处理链路不支持HDR建议在配置阶段就把输出色域约束在SDR避免出现色彩发灰、闪烁、甚至黑屏的问题。我这边测试下来HDR内容在普通H264编码链路上的兼容性还很差不是必须HDR的产品先躲开它。7. 最后想说的几句体己话录屏采集这个模块看起来就是“把屏幕变成一堆像素缓冲”真正落地之后你会发现它牵扯到权限、系统安全策略、窗口生命周期、多显示器、音频同步、性能优化这么多事情。我做这个项目最大的体会是一定要把“能出第一帧”当作第一个里程碑先别管窗口过滤、音频、多屏这些进阶能力。我一开始就是贪心想一次把全套能力做齐结果是集成了一个星期各种边缘情况不断暴露连最基础的“主屏出帧”都因为权限问题没跑通。后来换了个方式先写了个极简Demo只做三件事弹权限、采集主屏、把帧实时传到预览窗口。这个Demo跑通之后我再去加窗口采集、音频、动态分辨率每一步都有明确的验证点反而快了很多。如果你现在正准备做macOS录屏或者桌面共享功能我给你一个可执行的建议先花一天时间把最小链路调到最顺包括权限初始化、ScreenCaptureKit出帧、像素格式检查、编码器接入剩下的事情顺水推舟。采集层代码一旦稳定下来后面基本不用再动你会有大把时间处理产品业务层的问题。这个模块的坑我都替你踩过了照着上面的路线走能省下好几个晚上的调试时间。