一、"采集层换掉就行",然后它糊了
手里有一套在 iOS 上跑了很久的录屏引擎:ReplayKit 送帧,VideoToolbox 硬编,自己写的 fMP4 muxer 落盘。现在要做 macOS 版。
你的第一反应多半和我一样:编码器是 VideoToolbox,macOS 也有;muxer 是 C++,平台无关;音频混音、音画同步都是纯逻辑。只有采集层不一样,把 ReplayKit 换成 ScreenCaptureKit,写个 protocol 隔一下,一周的事。
然后你会按顺序遇到这些:
- 第一个 demo 录出来,画面是糊的。不是码率的那种糊,是整个画面像被放大了两倍。
- 用户录了一段操作演示,中间停下来读了十几秒文档。引擎里那个"多久没来帧就报警"的看门狗响了;没有看门狗的版本更糟,成片比实际录制时长短了一截。
- 自己的录制工具条、倒计时浮层、区域选择框,全被录进了成片。
- 接了外接显示器的用户反馈:框选的区域和录下来的区域对不上,偏了一整块。
- 用户明明在系统设置里点了允许,回来还是录不了。
- 录到一半,录制自己停了。不是崩溃,日志里只有一个 delegate 回调。
这六件事没有一件是 ScreenCaptureKit 的 bug。它们全部来自同一个误判:以为两个平台的录屏 API 是"同一件事的两种写法"。 不是。它们回答的是两个不同的问题,设计哲学几乎是反的。这篇先把这个分野讲清楚,再按踩坑顺序过一遍,最后回答那个真正值钱的问题:共用引擎的抽象层,到底该切在哪。
二、先把账算清:约束翻转了
我在第一篇里算过 iOS 的账:Broadcast Extension 内存红线 50MB,一帧全尺寸 BGRA 几 MB,能同时攥在手里的帧数是个位数。整套引擎的架构都是被这个数字逼出来的。
到 macOS 上重新算一遍。一台 5K 显示器:
ini
5120 × 2880 = 14,745,600 像素
BGRA (×4 字节) = 58,982,400 字节 ≈ 56 MB / 帧
420v (×1.5 字节) = 22,118,400 字节 ≈ 21 MB / 帧
一帧 BGRA 就超过了 iOS 上整个进程的预算。 但 macOS 上没人给你设 50MB 的红线------你是一个普通 App 进程,吃几百 MB 系统也不会杀你。
所以约束不是变松了,是换了一个维度。iOS 上唯一的敌人是内存,其他一切都是定死的:录整个屏幕、系统给什么格式就是什么格式、什么时候开始结束由系统的 picker 说了算。macOS 上内存不致命,但其他一切都变成了变量:
| iOS · ReplayKit Broadcast Extension | macOS · ScreenCaptureKit | |
|---|---|---|
| 会话归谁 | 系统。你是被系统拉起来的客人 | 你。SCStream 是你 new 出来的 |
| 跑在哪 | 独立的 Extension 进程,环境被裁剪 | 你自己的 App 进程 |
| 录什么 | 整个屏幕,没得选 | 你用 SCContentFilter 声明:哪块屏、含哪些 App、排除哪些窗口 |
| 怎么给 | 系统定格式和尺寸,你只能接 | 你用 SCStreamConfiguration 声明:尺寸、像素格式、帧率上限、队列深度、裁剪区域 |
| 录制中能改吗 | 不能 | 能,updateConfiguration / updateContentFilter |
| 头号约束 | 内存(50MB) | 变量的数量:多屏、Retina、窗口增减、权限、5K |
| 谁能叫停 | 系统 / 用户从控制中心 | 你、用户从菜单栏、系统 |

一句话概括:ReplayKit 是"系统录,你旁听";ScreenCaptureKit 是"你录,系统审批"。 前者把所有决定替你做了,代价是你没有任何余地;后者把所有决定交还给你,代价是每一个决定都可能做错。
开头那六个症状,每一个都对应表里的一行。下面按顺序来。
三、坑一:画面糊------point 和 pixel 是两个单位
SCShareableContent 给你的 SCDisplay 有 width 和 height。很自然地:
swift
let cfg = SCStreamConfiguration()
cfg.width = display.width // ← 糊的根源
cfg.height = display.height
SCDisplay.width/height 的单位是 point ,SCStreamConfiguration.width/height 的单位是 pixel。Retina 屏上两者差一个 scale factor(通常是 2)。你请求了一张一半边长的图,系统很配合地把 2x 的画面缩小给你,播放时再被拉回去------于是就是那种"像被放大了两倍"的糊。
症状:Retina 屏上成片发虚,外接 1x 显示器上正常。开发机如果恰好外接着一台普通显示器,这个 bug 能藏很久。
解法:
swift
// macOS 14+:filter 自己知道 scale
let scale = CGFloat(filter.pointPixelScale)
var w = Int(filter.contentRect.width * scale)
var h = Int(filter.contentRect.height * scale)
// 老系统:找到对应的 NSScreen 取 backingScaleFactor
// let scale = nsScreen.backingScaleFactor
// 4:2:0 采样要求宽高为偶数,奇数会在编码器那边出问题
w &= ~1; h &= ~1
cfg.width = w; cfg.height = h
iOS 上这个问题不存在,因为 ReplayKit 不让你选尺寸。这是"系统把决定交还给你"的第一个代价:一个在 iOS 上不存在的参数,在 macOS 上有一个看起来很合理的错误默认值。
顺带一个细节:同一个用户把窗口从 Retina 内屏拖到 1x 外接屏,scale 会变。如果你录的是单个窗口,要么监听变化后 updateConfiguration,要么接受画质变化。录整屏没这个问题。
四、坑二:没来帧不是故障,是"屏幕没变"
这是全篇最反直觉的一条,也是 iOS 引擎直接搬过来一定会踩的一条。
我在第一篇里写过:ReplayKit 的帧池被耗尽时,系统不报错,只是静默停止送帧。所以在 iOS 引擎里,"一段时间没来帧"是一个异常信号,值得一个看门狗。
ScreenCaptureKit 的语义正好相反。它是内容驱动的:屏幕内容没变,就没有新画面给你。 你设的 minimumFrameInterval 是帧率上限 ,不是帧率。用户停下来读文档、看一张静态图、等一个进度条,这期间你收到的要么是什么都没有,要么是一种特殊的 sample buffer------它的 attachment 里 status 是 .idle,里面没有 image buffer。
于是 iOS 那套代码搬过来有两种死法:
CMSampleBufferGetImageBuffer返回 nil,被当成错误处理,日志刷屏或者直接走了异常路径。- 看门狗把"用户在读文档"判定为"采集挂了",触发重启采集甚至终止录制。
正确的入口长这样:
swift
func stream(_ stream: SCStream, didOutputSampleBuffer sb: CMSampleBuffer,
of type: SCStreamOutputType) {
guard type == .screen, sb.isValid else { return }
guard let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false)
as? [[SCStreamFrameInfo: Any]],
let raw = arr.first?[.status] as? Int,
let status = SCFrameStatus(rawValue: raw) else { return }
switch status {
case .complete:
guard let pb = sb.imageBuffer else { return }
engine.submit(pb, pts: sb.presentationTimeStamp)
case .idle:
engine.noteIdle(at: sb.presentationTimeStamp) // 内容没变:这是信息,不是错误
case .blank, .suspended, .started, .stopped:
break
@unknown default:
break
}
}
真正的麻烦在下游
处理掉 nil 只是开始。"没帧 = 没变化"这个语义会一路传导到文件里:
- 成片时长变短 。我在 fMP4 那篇里讲过:最后一个 sample 的 duration 在写它的时候还不知道,要等下一帧来了才能回填。iOS 上帧是持续来的,这个"下一帧"总会到。macOS 上用户最后十秒没动鼠标就点了停止,最后一帧的 duration 如果按"默认一帧"收尾,这十秒就从成片里消失了,音频还比视频长一截。
- 下游对 VFR 不友好。把长间隔如实写成一个 duration 很长的 sample,文件是合法的,播放器也能播。但用户拿去导进剪辑软件,可变帧率素材音画不同步是出了名的老问题。录屏工具的成片,很大比例是要进剪辑软件的。
两个层次的解法:
最低限度:停止录制时,用"停止时刻"去闭合最后一个 sample 的 duration,而不是用默认帧间隔。这一条不做,就是开头那个"成片短了一截"。
更稳的做法:心跳补帧。引擎侧留住最近一帧,如果超过某个间隔(比如半秒到一秒)没有新的 complete 帧,就把它重新送进编码器。画面完全相同,硬编出来的 P 帧几乎不占码率,换来的是一条帧间隔有上限的流------对剪辑软件友好,对分片落盘也友好(fragment 能按时闭合,进程意外退出时丢的时长有上限)。

心跳补帧有两个细节,都是踩了才知道:
时间戳要防回退。 真实帧的 PTS 是它的显示时刻,送到你手上时已经过去了一小段。如果心跳用"现在"打戳,紧接着到达的真实帧 PTS 可能比心跳帧还早 ------时间戳回退,编码器和 muxer 都会出乱子。我的做法是心跳帧打 上一帧 PTS + 心跳间隔(而不是 now),并且只在 now - 上一帧 PTS 明显大于间隔时才补;真实帧进来时再做一次单调性钳制。原则是:补的帧永远给真的帧让路。
留住一帧是要付租金的。 留着的那个 CVPixelBuffer 背后是 SCK 帧池里的一块 surface,你不放,它就不回池。这直接引出下一节。
五、坑三:同一个池,两本账
ScreenCaptureKit 的帧和 ReplayKit 一样来自一个定长的 surface 池,池的大小由 SCStreamConfiguration.queueDepth 决定(个位数,具体默认值和上限以文档为准)。你攥着不放,池子见底,采集就卡住------机制和 iOS 上一模一样,零拷贝那篇讲的"池只认 CVPixelBuffer 这个壳"在这里原样成立。
Apple 在 WWDC22 讲 ScreenCaptureKit 进阶的那场里给过两条经验式,大意是:
scss
不想帧被延迟:单帧处理时间 < minimumFrameInterval
不想丢帧: surface 的持有时间 < minimumFrameInterval × (queueDepth − 1)
机制一样,但两个平台要算的账完全不同:
- iOS:池不归你管,大小你改不了。你能做的只有"尽快还"。账是时间账。
- macOS :
queueDepth是你自己填的。填大了抗抖动,但每多一格就多一帧的内存------5K BGRA 下queueDepth = 8是 450MB 左右的常驻 surface。账是空间账,而且是你自己开的价。
所以在 macOS 上有三个旋钮要一起拧:
- 像素格式 。要送硬编就直接要
420v(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange),一帧 21MB 而不是 56MB,而且编码器直接吃,省掉一次颜色转换。只有当你要在帧上做 CPU 侧的图像处理时才要 BGRA。range 和矩阵的坑我在《YUV→RGB 的颜色范围与矩阵》那篇里讲过,SCStreamConfiguration上有colorMatrix/colorSpaceName可以显式声明,别留默认。 - queueDepth 。按上面的经验式倒推,别拍脑袋填最大。如果做了心跳补帧,你常驻占着一格,预算要 +1。
- 输出尺寸。5K 原生分辨率录屏,先问一句值不值。这不只是内存问题:H.264 Level 5.2 以内的帧面积上限大约 943 万像素,5K 是 1474 万,装不下。Level 6 纸面上够,但你用户机器上的硬编支不支持要实测。现实的选项是 HEVC,或者让 SCK 直接缩放输出(它在 GPU 上做,比你自己缩便宜)。
这一节的反差值得记住:iOS 上我花了最多精力的事情是"怎么在 50MB 里活下来",到了 macOS 上同一个池、同一个机制,问题变成了"我自己一不小心就申请了半个 G"。 约束是别人给的时候你会很小心,约束是自己填的时候最容易失控。
六、坑四:把自己录进去了------filter 是快照,不是规则
macOS 录屏工具的 UI 形态和 iOS 完全不同。iOS 上录制一开始,你的 UI 就退场了。macOS 上你的 UI 要和被录的内容同屏共存:悬浮工具条、倒计时、区域选择框、摄像头画中画。
先说浮层本身。SwiftUI 的 WindowGroup / Window 表达不了录屏工具条需要的那些窗口行为------不抢焦点、浮在所有窗口之上、跟着用户切 Space、在别人全屏时也能显示。这些是 AppKit 的概念,要落到 NSPanel 上,SwiftUI 只负责内容:
swift
final class OverlayPanel: NSPanel {
init<V: View>(content: V) {
super.init(contentRect: .zero,
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered, defer: false)
level = .floating
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
isOpaque = false; backgroundColor = .clear; hasShadow = false
contentView = NSHostingView(rootView: content)
}
override var canBecomeKey: Bool { false } // 点工具条不能抢走被录 App 的焦点
}
canBecomeKey = false 这一行对录屏工具是功能性的:用户正在录一段键盘操作演示,点了一下你的暂停按钮,焦点要是被你抢走,他接下来的按键就全打空了。
然后是怎么不把这些浮层录进去。直觉写法:
swift
let mine = content.windows.filter { $0.owningApplication?.bundleIdentifier == myBundleID }
let filter = SCContentFilter(display: display, excludingWindows: mine)
症状:开始录制时已经存在的工具条确实没被录进去。但录制过程中新弹出的窗口------倒计时结束提示、一个 tooltip、一个右键菜单------全进了成片。
根因 :SCShareableContent 是你调用那一刻的快照 ,excludingWindows: 排除的是一个写死的窗口列表。之后创建的窗口不在列表里。它是一份名单,不是一条规则。
解法:按 App 排除。这才是规则,对未来的窗口也成立:
swift
let me = content.applications.filter { $0.bundleIdentifier == myBundleID }
let filter = SCContentFilter(display: display,
excludingApplications: me,
exceptingWindows: [])
那个 exceptingWindows: 参数就是为下一个需求准备的:有些自家窗口是要被录进去的,比如摄像头画中画。这时"按 App 排除 + 例外名单":
swift
// 先把画中画窗口显示出来,再重新取一次 shareable content ------ 快照里要有它
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
let pip = content.windows.first { $0.windowID == CGWindowID(pipPanel.windowNumber) }
let filter = SCContentFilter(display: display,
excludingApplications: me,
exceptingWindows: pip.map { [$0] } ?? [])
两个顺序上的坑:窗口必须先上屏 再取 content,否则快照里没有它;NSWindow.windowNumber 和 SCWindow.windowID 是同一个 ID,用它对号。录制中途用户开关画中画,用 stream.updateContentFilter 换 filter,不用重启流。
还有人用 NSWindow.sharingType = .none 来躲录屏。这是老 API 时代的手段,我不建议依赖------它在 ScreenCaptureKit 下的行为随系统版本有过变化的报告,我没有逐版本验证过。filter 是 ScreenCaptureKit 明确给你的契约,用契约,别用副作用。
七、坑五:区域录制录偏了------三个坐标系
区域录制的流程:全屏铺一个透明浮层,用户拖一个框,你把框转成 SCStreamConfiguration.sourceRect。单屏上测试一切正常,多屏用户说录偏了。
这里叠了三个不一致:
| AppKit(你的选择框) | sourceRect(SCK 要的) |
|
|---|---|---|
| 原点 | 左下 | 左上 |
| 范围 | 全局:所有屏幕拼成的大平面 | 局部:相对于被录的那一块屏 |
| 单位 | point | point(但输出 width/height 是 pixel) |
主屏单屏时,全局坐标和局部坐标恰好重合,只差一个 y 翻转,很多人翻完就以为对了。副屏在主屏左边或上方时,它的全局原点是负数或者带偏移,不减掉就是整块偏移。
swift
func sourceRect(for selection: NSRect, on screen: NSScreen) -> CGRect {
let f = screen.frame // 全局坐标,左下原点
let local = selection.offsetBy(dx: -f.minX, dy: -f.minY) // ① 全局 → 屏内
return CGRect(x: local.minX,
y: f.height - local.maxY, // ② 左下 → 左上
width: local.width, height: local.height)
}
// ③ 输出尺寸是 pixel
cfg.sourceRect = sourceRect(for: sel, on: screen)
cfg.width = Int(cfg.sourceRect.width * screen.backingScaleFactor) & ~1
cfg.height = Int(cfg.sourceRect.height * screen.backingScaleFactor) & ~1
NSScreen 和 SCDisplay 的对应关系靠 display ID:
swift
let id = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
let display = content.displays.first { $0.displayID == id }
这个坑没什么深刻的机制,但它有一个通用的教训:单屏开发机是多屏 bug 的完美掩体。 三个不一致里有两个在单主屏上恰好为零。测试矩阵里必须有"副屏在主屏左侧""副屏在主屏上方""两块屏 scale 不同"这三种摆法,缺一种就漏一类。
八、坑六:权限,以及"录制自己停了"
iOS 上的授权模型很简单:用户每次从系统 picker 里点"开始直播",这个动作本身就是授权。没有持久权限,没有状态要管。
macOS 是 TCC 的"屏幕录制"权限,持久的,而且有几个行为和直觉不符:
授权了不等于立刻能用。 用户在系统设置里打开开关,系统通常会提示需要退出并重新打开 App。如果你的 App 没退出,当前进程里的采集可能依旧拿不到内容。这就是开头第五个症状。所以授权引导流程的最后一步不是"好了",而是"重启 App"------并且要替用户做(保存状态、重新拉起),别指望他自己懂。
先查再问。 CGPreflightScreenCaptureAccess() 只查不弹窗,CGRequestScreenCaptureAccess() 才会触发系统提示。启动时先 preflight,没权限就展示你自己的引导页,解释清楚为什么要这个权限,再触发系统弹窗。直接调 SCShareableContent 也会触发,但用户面对一个没头没尾的系统弹窗,拒绝率高得多。
系统的态度在收紧。 macOS 14 加了系统级的 SCContentSharingPicker,由用户在系统 UI 里选要共享什么;macOS 15 起,对不走系统 picker、直接全量采集的应用,系统会周期性地重新弹确认。方向很明确:Apple 希望录屏的"选择权"回到系统 UI 手里。对一个录屏工具来说这不是好消息也不是坏消息,是一个要排进路线图的事实------你的"自定义选区"体验和系统 picker 之间,迟早要做一次整合。
然后是录制自己停了。ScreenCaptureKit 的会话归你,但不只有你能叫停:
swift
func stream(_ stream: SCStream, didStopWithError error: Error) {
// 用户从菜单栏的系统录屏指示器里点了停止 → SCStreamError.userStopped
// 以及:被录的显示器断开、被录的窗口关闭、权限被撤销......
engine.finalize(reason: .init(error)) // 收尾落盘,和用户点停止走同一条路径
}
关键在那条注释:被动停止必须和主动停止走同一条收尾路径。 闭合最后一个 sample、flush 编码器、写完 muxer、通知 UI。这类实现最常见的样子是:主动停止那条路径被打磨得很好,didStopWithError 里只打了一行日志------于是用户从菜单栏停掉录制,得到一个没有正常收尾的文件。fMP4 的好处是这种情况下文件依然能播,但时长和音画对齐是错的。
把"显示器被拔""合盖再开""录制中撤销权限"都放进测试清单。具体会收到什么错误码我不在这里列------它们随系统版本有变化,以你目标版本上的实测为准------但不管是哪个码,处理方式应该是同一个:收尾,落盘,告诉用户发生了什么。
九、音频:难度掉了一档,但别高兴太早
我在录屏音频那篇里说过,iOS 录屏的音频比视频难:App 音是稀疏流,没有一条流能当主时钟。
macOS 在这件事上友好得多:capturesAudio = true(macOS 13+),系统音频以 .audio 类型的 sample buffer 和视频从同一个 stream、同一个时钟域 送出来。excludesCurrentProcessAudio = true 可以把你自己 App 的提示音排除掉。
两个要留神的地方:
- 别假设 PCM 的布局。 拿到的通常是 Float32 的 LPCM,而且通道是非交错 的(每个通道一个 buffer)。iOS 那边的混音器如果写死了 interleaved Int16,直接喂进去出来的是噪声。以
CMAudioFormatDescriptionGetStreamBasicDescription读出来的 ASBD 为准,这个检查写进适配层。 - 麦克风是另一回事。 macOS 15 起 SCK 才能顺带采麦克风;在那之前,麦克风要另开
AVCaptureSession或AVAudioEngine------于是又回到了两个时钟、两条流、持续纠偏的老问题,那篇文章里的所有内容原样适用。
十、所以,抽象层该切在哪
回到开头那个一周的计划:"写个 protocol 把采集层隔一下"。大多数人写出来是这样:
swift
protocol ScreenCapturer {
func start() async throws
func stop() async
var onFrame: ((CVPixelBuffer, CMTime) -> Void)? { get set }
}
这个接口是两个平台的最大公约数:只保留了双方都有的东西。它的问题前面九节已经全部演示过了:
- "没帧"在两个平台含义相反,接口上看不出来 → 坑二。
- 帧池的账一边是时间账、一边是空间账,接口上没有地方表达"我还要常驻占一格" → 坑三。
- macOS 能录制中改 filter、改配置,iOS 不能,接口里放不放
update?放了 iOS 端是空实现,不放 macOS 端的能力就丢了。 - 被动停止的原因五花八门,
stop()表达不了"不是我停的"。
在 API 形状的层面做抽象,抽出来的一定是最大公约数。 两套 API 的哲学是反的,它们形状上的交集小到没有用。
我最后落下来的切法是:不抽象"采集",抽象"采集的产物"。 共用引擎不认识任何采集 API,它只认一份契约------一条被规范化过的帧流:
swift
/// 共用引擎的输入契约。平台适配层负责把各自的 API 行为"翻译"成这份契约。
protocol NormalizedFrameSink: AnyObject {
/// 一个 segment 内,尺寸、像素格式、颜色属性不变;变了就开新 segment
func beginSegment(_ format: FrameFormat, clock: CMClock)
/// PTS 严格单调递增,时钟域由 beginSegment 声明
func submit(_ pixelBuffer: CVPixelBuffer, pts: CMTime)
/// 显式声明"到这个时刻为止内容没变"。不是错误。
func noteUnchanged(until pts: CMTime)
/// 任何原因的结束都走这里,原因只用于上报和 UI
func end(at pts: CMTime, reason: EndReason)
}
这份契约的要点是它把否定性的事实也写了进去:
- "没变化"是一个显式事件(
noteUnchanged),而不是靠"没有调用 submit"来暗示。iOS 适配层从不发这个事件,引擎的看门狗于是只在 iOS 上有意义------看门狗属于适配层,不属于引擎。 - "格式变了"是一个显式边界(
beginSegment),macOS 上用户拖窗口换屏、改录制区域都落到这里;iOS 上横竖屏旋转也落到这里。引擎只有一套逻辑。 - "结束"只有一个入口。主动停、被动停、系统杀,对引擎是同一件事。
平台各自的脏活全部留在适配层里:

| 留在适配层(每平台一份) | 进共用引擎(只有一份) |
|---|---|
| 权限、picker、TCC 引导 | VideoToolbox 编码会话管理 |
| filter / 浮层排除 / 坐标换算 | fMP4 muxer |
| point→pixel、偶数对齐 | 音频混音与重采样 |
| idle 帧识别、心跳补帧、PTS 钳制 | 音画同步与漂移纠偏 |
| 帧池预算(时间账 / 空间账) | 分片落盘与异常收尾 |
| 看门狗(仅 iOS) | |
| 内存策略(50MB 红线 / 自定预算) |
左边那一列比很多人预期的长得多。这就是开头那个"一周"估错的地方:共用的部分确实很大,但不共用的部分不是"一个采集类",而是一整层。 这一层的工作量,和当初在 iOS 上做采集侧的工作量是同一个量级。
加一个新后端(比如以后接系统 picker 的那条路径,或者 visionOS)时,只增加一个适配层文件,引擎一行不动。这是检验抽象切对没有的唯一标准。
十一、诚实对比:你可能不需要这一整套
如果你的需求是"把屏幕录成一个文件",并且可以只支持 macOS 15+:SCRecordingOutput 直接把 stream 录成文件,编码、封装、音频都不用你管。用它,别学我。 这篇讲的所有坑里,你只需要关心坑一(尺寸)、坑四(filter)、坑五(坐标)和坑六(权限)。
需要自己接帧的理由只有这些:
- 要支持 macOS 15 以前的系统。
- 要和其他平台共用编码/封装引擎,保证各端成片行为一致(我的情况)。
- 要在帧上做实时处理:标注、鼠标点击高亮、摄像头合成、水印。
- 要推流而不是落盘。
- 对文件的崩溃安全性有要求------这是自己写 fMP4 muxer 的原始动机。
另外别再往回看了:CGDisplayStream、CGWindowListCreateImage 这一系列老 API 已经被标记废弃,方向很明确。新项目直接 ScreenCaptureKit。
十二、收尾:三条可以带走的
第一,两个看起来是同一件事的 API,先问"控制权在谁手里"。 ReplayKit 是系统录你旁听,ScreenCaptureKit 是你录系统审批。控制权的位置决定了约束的形状:控制权在对方手里,约束是硬的、单一的、别人给的;控制权在你手里,约束是软的、多维的、你自己填的。后者并不更容易------自己填的约束最容易失控,半个 G 的帧池就是这么来的。
第二,"没有发生"也是一种信息,契约里要给它一个名字。 没来帧,在一个平台上是故障,在另一个平台上是"屏幕没变"。如果接口只描述"发生了什么",那"没发生"的含义就只能靠调用方猜,而两个平台的猜法是反的。把 noteUnchanged 写成一个显式事件,比任何文档注释都可靠。这条适用于所有事件流接口:心跳、空闲、无变化,要么显式表达,要么迟早被误读成超时。
第三,跨平台共用的边界,切在"产物"上,不切在"API 形状"上。 照着两边 API 的交集写 protocol,得到的是最大公约数;定义一份下游真正需要的、规范化的数据契约,让每个平台各自想办法满足它,得到的才是可扩展的结构。适配层会比你预期的厚,这是对的------厚度就是两个平台真实的差异量,它不在适配层里,就会漏进引擎里。
回到开头那六个症状:糊是单位,短了一截是 idle,把自己录进去是快照,录偏了是坐标系,录不了是 TCC,自己停了是 didStopWithError。六个问题,没有一个出在编码器或 muxer 里------共用的那部分确实一行没改。估错的从来不是"能共用多少",而是"不能共用的那部分有多厚"。
如果你正在把一套 iOS 录屏/采集管线搬到 macOS,先做一件事:在采集回调里把每一帧的 status 和到达间隔打出来,然后停手十秒不要碰鼠标。你看到的那段空白,就是你的引擎接下来要重新理解的东西。
关于本文代码
文中 Swift 示例用于说明思路和接口形态,未逐行编译验证 。SCStreamConfiguration / SCContentFilter 的可用属性随 macOS 版本有增减(pointPixelScale、contentRect、SCContentSharingPicker 为 macOS 14+;麦克风采集、SCRecordingOutput 为 macOS 15+),请以你的部署目标对应的文档为准。queueDepth 的默认值与上限、didStopWithError 的具体错误码、系统周期性授权确认的频率,均以目标系统版本上的文档与实测为准,本文刻意没有写死。
帧大小的数字是按分辨率直接算出来的,你可以自己复算;WWDC 经验式为转述,原话见 WWDC22 ScreenCaptureKit 相关 session。NormalizedFrameSink 是为说明"抽象切在产物上"而简化过的接口形态,不是任何产品的实际代码。