macOS 录屏:ScreenCaptureKit 与 iOS 的分野,共用引擎的抽象层该切在哪

一、"采集层换掉就行",然后它糊了

手里有一套在 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 给你的 SCDisplaywidthheight。很自然地:

swift 复制代码
let cfg = SCStreamConfiguration()
cfg.width  = display.width     // ← 糊的根源
cfg.height = display.height

SCDisplay.width/height 的单位是 pointSCStreamConfiguration.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 那套代码搬过来有两种死法:

  1. CMSampleBufferGetImageBuffer 返回 nil,被当成错误处理,日志刷屏或者直接走了异常路径。
  2. 看门狗把"用户在读文档"判定为"采集挂了",触发重启采集甚至终止录制。

正确的入口长这样:

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:池不归你管,大小你改不了。你能做的只有"尽快还"。账是时间账。
  • macOSqueueDepth 是你自己填的。填大了抗抖动,但每多一格就多一帧的内存------5K BGRA 下 queueDepth = 8450MB 左右的常驻 surface。账是空间账,而且是你自己开的价。

所以在 macOS 上有三个旋钮要一起拧:

  1. 像素格式 。要送硬编就直接要 420vkCVPixelFormatType_420YpCbCr8BiPlanarVideoRange),一帧 21MB 而不是 56MB,而且编码器直接吃,省掉一次颜色转换。只有当你要在帧上做 CPU 侧的图像处理时才要 BGRA。range 和矩阵的坑我在《YUV→RGB 的颜色范围与矩阵》那篇里讲过,SCStreamConfiguration 上有 colorMatrix / colorSpaceName 可以显式声明,别留默认。
  2. queueDepth 。按上面的经验式倒推,别拍脑袋填最大。如果做了心跳补帧,你常驻占着一格,预算要 +1。
  3. 输出尺寸。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.windowNumberSCWindow.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

NSScreenSCDisplay 的对应关系靠 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 才能顺带采麦克风;在那之前,麦克风要另开 AVCaptureSessionAVAudioEngine------于是又回到了两个时钟、两条流、持续纠偏的老问题,那篇文章里的所有内容原样适用。

十、所以,抽象层该切在哪

回到开头那个一周的计划:"写个 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 的原始动机。

另外别再往回看了:CGDisplayStreamCGWindowListCreateImage 这一系列老 API 已经被标记废弃,方向很明确。新项目直接 ScreenCaptureKit。


十二、收尾:三条可以带走的

第一,两个看起来是同一件事的 API,先问"控制权在谁手里"。 ReplayKit 是系统录你旁听,ScreenCaptureKit 是你录系统审批。控制权的位置决定了约束的形状:控制权在对方手里,约束是硬的、单一的、别人给的;控制权在你手里,约束是软的、多维的、你自己填的。后者并不更容易------自己填的约束最容易失控,半个 G 的帧池就是这么来的。

第二,"没有发生"也是一种信息,契约里要给它一个名字。 没来帧,在一个平台上是故障,在另一个平台上是"屏幕没变"。如果接口只描述"发生了什么",那"没发生"的含义就只能靠调用方猜,而两个平台的猜法是反的。把 noteUnchanged 写成一个显式事件,比任何文档注释都可靠。这条适用于所有事件流接口:心跳、空闲、无变化,要么显式表达,要么迟早被误读成超时。

第三,跨平台共用的边界,切在"产物"上,不切在"API 形状"上。 照着两边 API 的交集写 protocol,得到的是最大公约数;定义一份下游真正需要的、规范化的数据契约,让每个平台各自想办法满足它,得到的才是可扩展的结构。适配层会比你预期的厚,这是对的------厚度就是两个平台真实的差异量,它不在适配层里,就会漏进引擎里。

回到开头那六个症状:糊是单位,短了一截是 idle,把自己录进去是快照,录偏了是坐标系,录不了是 TCC,自己停了是 didStopWithError。六个问题,没有一个出在编码器或 muxer 里------共用的那部分确实一行没改。估错的从来不是"能共用多少",而是"不能共用的那部分有多厚"。


如果你正在把一套 iOS 录屏/采集管线搬到 macOS,先做一件事:在采集回调里把每一帧的 status 和到达间隔打出来,然后停手十秒不要碰鼠标。你看到的那段空白,就是你的引擎接下来要重新理解的东西。


关于本文代码

文中 Swift 示例用于说明思路和接口形态,未逐行编译验证SCStreamConfiguration / SCContentFilter 的可用属性随 macOS 版本有增减(pointPixelScalecontentRectSCContentSharingPicker 为 macOS 14+;麦克风采集、SCRecordingOutput 为 macOS 15+),请以你的部署目标对应的文档为准。queueDepth 的默认值与上限、didStopWithError 的具体错误码、系统周期性授权确认的频率,均以目标系统版本上的文档与实测为准,本文刻意没有写死。

帧大小的数字是按分辨率直接算出来的,你可以自己复算;WWDC 经验式为转述,原话见 WWDC22 ScreenCaptureKit 相关 session。NormalizedFrameSink 是为说明"抽象切在产物上"而简化过的接口形态,不是任何产品的实际代码。

相关推荐
HouWan3 小时前
Flutter iOS UISceneDelegate 迁移指南:理清新的 Scene 生命周期
flutter·ios·app
00后程序员张3 小时前
Xcode vs KXApp,体积差几十G,选轻量方案还是完整工具链?
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915909067 小时前
怎么用 FlutterFlow 把应用发布到 App Store?
android·ios·小程序·https·uni-app·iphone·webview
2501_916008891 天前
全平台抓包工具,Windows、iPhone、Linux三个平台抓包测试
网络协议·计算机网络·网络安全·ios·adb·https·udp
2501_915918411 天前
怎么把 Python 写的 Flet 应用打包成 iOS App 并上架 App Store?
android·ios·小程序·https·uni-app·iphone·webview
李游Leo1 天前
HarmonyOS 7 实战开发 04:适配手机、折叠屏与大屏布局
ios·harmonyos
终端安全笔记1 天前
iOS 27 强制 TLS 1.2:租赁设备的注册链路会在哪一环断
android·网络·安全·ios·智能手机
黑科技iOS上架1 天前
iOS深度混淆flutter应用的最佳实践
flutter·ios·混淆·审核·深度混淆