优秀的工具往往诞生于开发者对自己痛点的“不妥协”。Sintone 正是这样一位开发者。为了追求更顺手的录屏剪辑体验,他从零开始开发了 ScreenSage Pro,并保持着惊人的迭代速度。
在最初约稿时,我并没想到 Sintone 会毫无保留,倾囊相授。从 ScreenCaptureKit 的诡异 Bug 到高性能视频合成的实战技巧,他将自己踩过的坑和解题思路一一摊开。这份坦诚,着实令人惊喜。
视频正在取代文字成为主流的表达方式,而好工具是创作的加速器。无论你是想开发同类产品,还是对独立开发感兴趣,这篇文章都不容错过。
我是独立开发者 Sintone,最近一年多在做一款 macOS 上的录屏剪辑工具,名字叫 ScreenSage Pro。
在开始讲产品故事之前,我先稍微展开讲讲 ScreenSage Pro 到底是个什么样的软件,然后聊聊如何实现它的核心功能。这样,看完这篇文章,或许你就已经能做一个它的竞品软件了。
录屏有什么好做的?
如果你用过 QuickTime,可能会觉得录屏很简单:把屏幕画面存成一个 MP4 不就行了吗?
很多开发者都这么想。直到我发布了第一款产品,开始制作演示视频时,我才意识到“录屏”和“能看的演示视频”之间隔着多大的鸿沟。
当时我的噩梦流程是这样的:录像 → 拖进剪映 → 手动加关键帧放大重点区域。
这就好比在代码里手动写 100 个 if-else。每一处的画面缩放、每一个关键帧的打点、每一次动画曲线的调整,都需要反复预览和修改。经常是调整好了缩放比例,又发现过渡不自然;调整好了过渡,又发现鼠标没对准。
除了剪辑的繁琐,还有很多硬伤:
- 鼠标太小:高分屏下,观众根本看不清我点了哪里。
- 缺乏互动:想在录屏同时叠加摄像头解说,后期对轨对到崩溃。
一个 1 分钟的视频,往往要耗费我 30 分钟甚至更久去打磨这些细节。对于开发者来说,这种低效是不可忍受的。
我剪视频剪吐了,于是我决定写代码来解决这个问题。
ScreenSage Pro 正是用来解决这些问题的,我来告诉你它是怎么做的。
多录制一些额外的信息,效率提升不止十倍
相比于传统的录屏软件,在 ScreenSage Pro 的理念里,我们做的不是简单的“屏幕像素抓取”,而是“完整的元数据录制和重现”。简单来说,当用户按下录制键时,我们不止录制屏幕像素,还有大量的元数据。我在后台同时跑着几条平行的轨道:
-
多路音视频流(独立且同步):
- 屏幕画面:高分辨率、高帧率的纯净画面。
- 摄像头(Webcam):作为一个独立的图层录制,而不是直接画在屏幕视频上,方便后期随意拖动。
- 麦克风 & 系统声音:独立音轨,互不干扰。
-
关键的“元数据”(Metadata): 这是实现“丝滑自动剪辑”的核心。我并没有把鼠标指针“烧录”进视频里,而是记录了它每一毫秒的数据:
- 鼠标事件:坐标、点击状态、样式变化。
- 键盘事件:用户按下了 Cmd+C 还是 Enter。
- 窗口信息:当前激活的是哪个 App,窗口多大。
拿到了这些原始素材,我们就有能力完成我们想要的核心体验「录完即剪完 」了。有了这些元数据,我们可以做很多事情:
- 🖱️ 智能聚焦:知道鼠标什么时候在哪里点击,就可以程序化放大焦点区域,甚至添加 3D 透视效果
- 🎨 鼠标美化:实时追踪鼠标位置和样式,合成时可以放大指针、替换成个性化图形
- ⌨️ 按键显示:捕获键盘事件,自动在视频中叠加按键提示(Cmd+C、Enter 等)
因为我们获得了能重现当时场景的足够的信息,我们在后期就拥有了足够大的发挥空间,让我们尽情地施展我们的想象力,解放我们的生产力。这是传统的录屏软件所无法提供的。
产品的开始 - 预售
2024 年 11 月底,借着黑色星期五的消费热潮,我的第一款 App 迎来了一个增长高峰。我想,反正也是借,再多借点热度把这个想法预售一下怎么样?后来借助 AI 花了 3 个小时,起名、做 logo、生成网页、发布预售,很快搞定。
本来也没抱太大希望,于是就睡了。
后来可想而知,预售成功了。第二天早上惯例检查收件箱,发现 “You made a sale” 邮件躺在邮箱里,成功收款 $69。接下来几天陆陆续续收到了 20 单。“嗯,这事能成”。于是决定开始做,第一个版本发布日在 4 个月后的 2025 年 4 月 1 日。
开始开发,录屏与视频合成
顺便介绍一下我自己的开发经历。
我正式工作 8 年,这 8 年一直做 Android 开发,偶尔感兴趣接触过 Mac 软件的开发,但一直小打小闹开发都处于 Demo 阶段。2023 年 9 月,成功被公司优化拿着一笔补偿金开心地开始了第一款 Mac 商业软件的开发。
也就是说,对于 Mac 开发,我是业余的,到 2025 年 2 月开始开发录屏时仍旧业余,至今还是业余。所以如果文中有讲的不当之处,请千万帮忙指正。
接下来,我讲讲这个 App 从 0 到 1 开发的一些核心要点,以及从 1 到 2 过程中的一些经验。
可行性研究 - 基于开源项目测试
2024 年 12 月。
此时预售大致成功了,可我的心情很是忐忑。我对屏幕录制一窍不通,对录屏后如何合成视频视频一窍不通。而 12 月下半旬我还要和女友去博乐、去伊犁旅游,1 月要上线我的第一款软件的 iOS 版本。留给这个大项目的时间只有 2、3 月这两个月的时间。这两个月,够用吗?我心里直打鼓。
我没法就这样出去玩,为了让自己能安下心,我下载了当时有点火的开源项目 QuickRecorder,在开始录制时加上了鼠标事件的监听和记录。在预览播放视频的地方读取了鼠标点击事件和鼠标位置。简单学习了一下 VideoCompsition,让它能做到基于目标点中心放大一定的倍数。很快,这个可行性研究的版本诞生了。
// 伪代码:MVP 阶段的验证逻辑
struct MouseEvent {
let time: TimeInterval
let point: CGPoint
}
// 1. 简陋的鼠标监听
var mouseLog: [MouseEvent] = []
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { event in
mouseLog.append(MouseEvent(time: Date().timeIntervalSince1970, point: event.locationInWindow))
}
// 2. 简单的合成逻辑 (AVVideoComposition)
let composition = AVMutableVideoComposition(asset: asset) { request in
let currentTime = request.compositionTime
// 查找当前时间附近的鼠标点击
if let click = mouseLog.first(where: { abs($0.time - currentTime) < 0.5 }) {
// 核心验证:如果有点,就暴力放大 2 倍
let transform = CGAffineTransform(scaleX: 2.0, y: 2.0)
.translatedBy(x: -click.point.x, y: -click.point.y) // 居中
request.finish(with: request.sourceImage.transformed(by: transform), context: nil)
} else {
request.finish(with: request.sourceImage, context: nil)
}
}
这段代码能让视频播放到鼠标点击时刻时,以点击点为中心把画面放大 2 倍。没有过渡、没有动画,硬放大。 别看它从表面简陋到了里子里,可它给了我非常大的开始的信心。我知道流程跑通了,意味着大概率能行了。
这个阶段,我对屏幕录制开发的概念完全不懂,对视频合成一片模糊,但是这个测试让我知道了,这件事能成。
第一行代码 - 屏幕录制
时间是 2025 年 2 月,我和女友旅游完回来了,剪贴板的 iOS 版本也如期发布了。这时候的 AI 还没有大发神威,仍然仅仅能作为辅助使用。
那时候我的 AI 使用方式是,不让他直接写代码,而是和它聊天,通过它学习。通过不断的聊天我了解了 SCK(ScreenCaptureKit)这个新的框架,了解了它相对于传统录制框架的优势,了解了它的便捷性。
到这一步,我对 SCK 的理解已经清晰多了:
| 特性维度 | 传统录制 (CGWindowList / AVCapture) | ScreenCaptureKit (SCK) |
|---|---|---|
| 性能消耗 | 高 (CPU/GPU 占用明显,发热快) | 极低 (基于 GPU 的零拷贝,清凉静音) |
| 帧率稳定性 | 不稳定,高负载下容易丢帧 | 极高,系统级优化 |
| 窗口录制 | 困难,遮挡处理复杂,无法录后台窗口 | 原生支持,窗口由于遮挡或被移出屏幕均不受影响 |
| 系统声音 | 不支持 (通常需要安装 Audio Driver 插件) | 原生支持 (直接捕获 App 或系统音频) |
| 隐私权限 | 需要屏幕录制权限 | 仅需屏幕录制权限,且支持排除自身窗口 |
| 开发难度 | 资料多,但 API 老旧繁琐 | 现代化 API,但坑多文档少 |
依靠和 AI 聊天,我拼凑出了屏幕录制的第一行(段)代码。
元数据录制 - 鼠标事件
想解决问题,鼠标和键盘的元数据必不可少。
鼠标用于跟踪关键的点击时间和位置,进行放大和跟踪。
这步很简单,在开始录制时开始监听,定义好数据结构、保存到内存里就行了。
// 定义元数据结构:我们需要知道“何时、何地、发生了什么”
struct UserActionMetadata: Codable {
enum ActionType { case click, keyPress, windowChange }
let timestamp: Double // 相对录制开始的时间戳
let location: CGPoint? // 鼠标位置
let keyCombo: String? // 键盘按键,如 "Cmd+C"
let activeApp: String? // 当前激活的应用
}
// 监听并记录
func startRecordingMetadata() {
let startTime = Date()
// 监听全局鼠标事件 (需辅助功能权限)
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in
let meta = UserActionMetadata(
type: .click,
timestamp: Date().timeIntervalSince(startTime),
location: NSEvent.mouseLocation,
keyCombo: nil,
activeApp: NSWorkspace.shared.frontmostApplication?.localizedName
)
self.metadataBuffer.append(meta)
}
// 键盘监听同理...
}
如何保证时间线同步?
所有的内容都知道怎么录制了,那问题就来了,这些内容从调用开始录制到获取到第一帧,都会有不同程度的延迟,如何保证它们的时间完全同步?
其中,屏幕内容和系统音频写到了同一个视频文件内,根据帧数据的 pts 会自动同步。而麦克风、摄像头、鼠标事件分别写入到了不同的文件中,又该如何同步呢?
起初,我单纯地以为同时调用它们的开始录制方法就可以做到同步,可事实看来确实是单纯了。特别是连接到外接的设备后,它们的准备工作可能会很久,这个延迟也就显得尤其明显。
研究了一番,发现解决思路也比较简单,“把每个录制的首帧时间记录下来就行了”。
鼠标事件最简单,每次事件触发时直接用当前时间减去录制开始时间即可,天然和录制时间线对齐。相对复杂的是视频流的同步。
SCK 录制的首帧时间戳
SCK 的屏幕内容和系统音频通过 SCStreamOutput 协议的回调方法获取,每一帧数据都带有 CMSampleBuffer,里面包含了精确的时间戳(presentationTimeStamp):
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
// 获取帧的时间戳
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
// 记录首帧时间
if firstFrameTimestamp == nil {
firstFrameTimestamp = timestamp
print("📹 SCK 首帧时间: \(timestamp.seconds)s")
}
// 后续所有帧都以这个首帧时间为基准进行同步
// ...
}
麦克风 & 摄像头的首帧时间戳
一开始为了图简单,我使用的是 AVCaptureMovieFileOutput 来保存麦克风和摄像头的数据。这是一个高度封装的“傻瓜式”类,把它添加到 Session 里,调用 startRecording 就能生成文件。
简单的 AVMovieFileOutput 调用(旧方案):
// 以前的做法:傻瓜式录制,但无法精确控制
// 1. 创建 Output
let movieOutput = AVCaptureMovieFileOutput()
// 2. 添加到 Session (系统会自动处理连接)
if captureSession.canAddOutput(movieOutput) {
captureSession.addOutput(movieOutput)
}
// 3. 开始录制 (直接写入文件,无法触碰每一帧数据)
movieOutput.startRecording(to: fileURL, recordingDelegate: self)
但它的致命缺点是“黑盒化”:它全自动管理写入,无法知道第一帧的确切时间,我无法触碰到每一帧数据,也无法在写入前修改帧的时间戳。
后来我把心一横,全部改写。改成了 AVCaptureVideoDataOutput (配合 AVCaptureAudioDataOutput)。 这条路要难走得多,不仅要自己配置 Output,还要自己创建队列、设置代理、手动管理 AVAssetWriter。但这让我拥有了上帝视角:我可以拿到每一帧的原始数据,手动修正时间戳。
虽然代码量翻了几倍,但好坏是自己解决了。
改为 AVCaptureVideoDataOutput 后的手动控制(新方案):
// 1. 创建 Video Output
let videoOutput = AVCaptureVideoDataOutput()
// 关键点:设置代理,必须在独立的队列中回调,否则会卡主线程
let videoQueue = DispatchQueue(label: "com.app.videoQueue")
videoOutput.setSampleBufferDelegate(self, queue: videoQueue)
// 2. 添加到 Session
if captureSession.canAddOutput(videoOutput) {
captureSession.addOutput(videoOutput)
}
// ... (音频 Output 同理配置) ...
// 3. 代理回调:这里才是我们真正掌控数据的地方
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
// 这里可以拿到每一帧 (CMSampleBuffer)
// 进行时间戳计算、音画同步修正
let adjustedBuffer = adjustTimestamp(for: sampleBuffer)
// 最后手动塞给 AssetWriter
if assetWriterInput.isReadyForMoreMediaData {
assetWriterInput.append(adjustedBuffer)
}
}
稳定意味着低可定制,高定制功能往往意味着打破稳定。 想要精准的控制,就得跳出系统的“舒适圈”,自己去处理那些脏活累活。
这个改动虽然痛苦,但也为后续的功能打下了基础,比如提高音量、降噪、音频波形展示等,因为我终于能碰到原始数据了。
视频合成-数据收集好了,让录制的内容协作起来,自动剪辑成片
在 macOS 上做视频合成,有这么几种方式,其使用的难度和灵活性逐级递增:
- AVVideoComposition (Core Animation):
这是苹果官方最上层的 API。有点像做 PPT,你把视频轨道摆好,设置好 Opacity(透明度)和 Transform(变换)。
- 优点:简单,原生支持。
- 缺点:灵活性一般,只能做基础的移动缩放,想做“运动模糊”、“高级转场”或者“动态背景虚化”非常难。
- Core Image + AVFoundation:这允许你对每一帧视频画面应用滤镜(Filter)。
- 优点:特效丰富(高斯模糊、色彩调整)。
- 缺点:如果不小心,很容易导致内存暴涨(OOM),渲染速度慢。
- Metal / MetalPetal (最终选择): 这是核武器。直接调用 GPU 进行渲染。我最终选择了基于 MetalPetal 这个开源框架。它是由 Yu Ao 开发的基于 Metal 的图像处理框架。为什么选它?因为 ScreenSage Pro 需要实时处理 4K 60FPS 的多层画面(屏幕+摄像头+背景模糊+鼠标特效)。只有 Metal 能在不把 MacBook 变成“直升机”的前提下完成这个任务。通过编写自定义的 Shader(着色器),我能精确控制每一个像素,实现那种丝滑的运动模糊(Motion Blur)效果,这是让视频看起来“专业”的关键。
在 App 的不同阶段,我依次使用过这三类方式:
- 在验证想法以及第一个 MVP 版本时,我使用了 AVVideoComposition 来实现,它帮我快速实现了一个简单的可接受的效果。
- 后来,我有了一些高级点的需求,比如高斯模糊、添加阴影、多图层混合,我切换到了 Core Image + AVFoundation 的方式。
- 再后来,我有了一些 3D 效果的想法,Core Image 无法实现透视效果,我又转到了 Metal 的方式。
关于如何在他们中间做出选择我想上面的信息已经足够有所帮助,这里我只简单介绍一下 MetalPetal:
// 初始化
let context = try MTIContext(device: device)
let asset = AVAsset(url: videoURL)
// 合成的关键方法
let composition = MTIVideoComposition(asset: asset, context: context, queue: DispatchQueue.main, filter: { request in
return FilterGraph.makeImage { output in
request.anySourceImage! => filterA => filterB => output
}!
}
// 在视频播放器中播放合成
let playerItem = AVPlayerItem(asset: asset)
playerItem.videoComposition = composition.makeAVVideoComposition()
player.replaceCurrentItem(with: playerItem)
player.play()
好了,到现在为止,已经实现了这个 App 的核心功能了。快去实现一个吧。
视频尺寸与比特率,怎么让视频文件尺寸更小?
发布了几个版本后,我发现同样时长、分辨率下,竞品生成的视频文件非常小,而我用 AVFoundation 录制的视频体积大得离谱。
起初我以为是分辨率的问题,现在的 MacBook 全是 Retina 屏,录制物理分辨率接近 4K。但我测试后发现,根本原因在于我没有手动限制比特率(Bitrate)。
到底什么是比特率?
在解决问题前,得先搞清楚它是什么。 如果把视频比作一幅画,分辨率(Resolution)决定了画布的大小(比如 4K 就是一面墙那么大),而比特率(Bitrate)决定了每秒钟给你多少预算的颜料去涂满这张画布。
- 分辨率决定了画面的物理尺寸上限。
- 比特率直接决定了文件体积和画面的充实程度。 这就解释了为什么我的文件大:系统看到我有一张 4K 的大画布,为了保险,默认给了我整整一桶颜料(超高码率)。不管我画的是复杂的《清明上河图》,还是一张白纸上写几个字,它都一股脑把颜料泼上去,结果就是数据量爆炸。
为什么必须手动预设码率?
这里我产生了一个很大的疑问:
“比特率为什么要我去预设一个固定值?难道编码器不能根据每一帧图像的复杂程度,自己算出来需要多少数据吗?”
理论上确实应该“实报实销”:画面复杂就多用点数据,画面简单就少用点。但问题就在于,AVFoundation 在默认情况下,就像是一个没有预算上限的土豪。
如果我们不限制它,它为了追求哪怕肉眼看不出来的 1% 画质提升,会毫不吝啬地投入 10 倍的数据量。特别是屏幕录制这种场景,大部分时间画面是静止的,系统默认的高码率可能导致大部分时间是在填充无效数据,导致硬盘爆炸。
理解 AverageBitrate:弹性的预算管理
所以,我们需要设置 AVVideoAverageBitRateKey。
注意,这个 Key 叫“平均比特率”。这很好理解,我们不是在规定每一帧的大小,而是给编码器下达一个“年度预算”。
当我们设置了预算(比如 6Mbps)后,编码器就会变得精打细算:
- 屏幕静止时(淡季):它会把省下来的“预算”存起来。
- 屏幕剧烈变化时(旺季):比如切换窗口或滚动代码,它会动用刚才存下的预算,瞬间飙升码率保证清晰度。
这样既防止了体积失控,又能利用编码器的动态调整能力。
既然要定预算,定多少合适?(BPP)
预算给少了,画面糊;给多了,体积大。我起初简单粗暴地写死 6_000_000 (6Mbps),但这有个坑:在 1080p 屏幕上这可能太多了,而在 5K 显示器上又不够用。
这里需要引入一个概念:BPP (Bits Per Pixel,像素位深)。 简单说,就是根据你的画布大小(分辨率)来决定颜料多少(码率)。
对于屏幕录制这种低动态场景,0.05 是个黄金系数。我们可以写一个简单的算法,让程序根据用户的屏幕分辨率,自动算出一个“刚刚好”的预算。
自适应压缩配置:
func makeCompressionSettings(for size: CGSize, fps: Int = 60) -> [String: Any] {
// 1. 设定预算系数 (BPP)
// 0.05 是屏幕录制的甜点值,既能压住体积,字也不会糊
let bpp: CGFloat = 0.05
// 2. 动态计算年度预算 (目标码率)
// 算法:像素总数 * 帧率 * BPP
// 比如 Retina 屏下,算出来大概是 17Mbps,比系统默认的 40Mbps+ 节省了一大半
let pixelCount = size.width * size.height
let targetBitrate = Int(pixelCount * CGFloat(fps) * bpp)
print("💰 预算核算: \(Int(size.width))x\(Int(size.height)) | 限制码率: \(targetBitrate / 1000) kbps")
return [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: size.width,
AVVideoHeightKey: size.height,
AVVideoCompressionPropertiesKey: [
// 核心干货:手动接管预算
AVVideoAverageBitRateKey: targetBitrate,
// 使用 High Profile:相当于雇佣更高级的画师,在同等预算下画得更好
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
AVVideoExpectedSourceFrameRateKey: fps
]
]
}
通过这套逻辑,不管用户是用 MacBook 原生屏还是外接显示器,我们都能在保证画质的前提下,把文件体积压缩到极致。
录制过程软件崩溃,用户也崩溃了,如何保住录制的文件?
有一天,在 X 上看到一个朋友吐槽前辈软件 Screen Studio 录了 40 分钟但是无法保存,心态炸了。我深知任何程序都无法完全信任,甚至是自己写的程序,即便逻辑考虑地再周全,也无法保证万无一失。意外意外,意料之外的事情才叫意外。所以也立即去寻找办法。
默认情况下,MP4 文件的头部信息是在录制结束时才写入的。如果中间 App 崩溃或电脑断电,没有来得及把这些头部信息写入,整个文件就是一堆废数据。
我了解了一下 hls 并且自己实现了一套分片写文件的方式,把视频文件分割成多个小文件,视频录制完再合并到一起。 整个过程跑通了最后才发现 AVAssetWriter 自带了一个救命的属性叫 movieFragmentInterval。设置了 Fragment 后,它会每隔固定时间就把当前的元数据写入文件,这样文件永远会处于可读取状态。并且相对于 hls 分文件保存,它有一个很大的优势是它是单文件存储,不需要在录制完成再去合并文件了。
let assetWriter = try AVAssetWriter(outputURL: url, fileType: .mp4)
// 设置每 10 秒写入一次片段,防止崩溃导致全盘丢失
assetWriter.movieFragmentInterval = CMTime(seconds: 10, preferredTimescale: 600)
录制静止画面视频长度永远只有 1 秒?
有用户反映录制出来时长不对,明明录了 10 秒,文件只有 1 秒。
研究过后才发现,这是由于 SCK 的节能机制与 AVAssetWriter 的写入机制冲突 的原因。 SCK 为了省电,当屏幕画面静止不动时,它就不会生成新的帧。但 AVAssetWriter 是线性的,如果第 1 秒写入了一帧,第 10 秒结束录制时中间没有新数据,生成的视频文件往往会判定时长只有 1 秒。
我最初想搞个定时器做“心跳补帧”,但后来发现太复杂且消耗资源。 最终的解决方案简单粗暴:在停止录制的那一刻,手动补写最后一帧。
[解决方案]: 在调用 stopRecording 时,把缓存的“最后一帧画面”拿出来,将它的时间戳修改为“当前结束时间”,强制写入。这样 AVAssetWriter 就知道:“哦,原来这个画面一直持续到了现在。”
func stopRecording() async {
// 1. 停止从 SCK 接收新数据
stream.stopCapture()
// 2. 关键步骤:补帧
// 如果最后一次写入的时间是 00:01,而现在是 00:10,
// 我们需要把最后一帧画面再写一次,但时间戳设为 00:10
if let lastSample = self.lastAppendedSampleBuffer {
let currentTimestamp = CMTime(seconds: CACurrentMediaTime() - startTime, preferredTimescale: 600)
// 创建一个新的 SampleBuffer,复用图像数据,修改时间
let finalBuffer = createSampleBuffer(from: lastSample, at: currentTimestamp)
if assetWriterInput.isReadyForMoreMediaData {
assetWriterInput.append(finalBuffer)
}
}
// 3. 完成写入
await assetWriter.finishWriting()
}
SCK 窗口录制在副屏幕上无法录制子窗口?
SCK 可以直接录制窗口(Window Capture),录制期间窗口可以移动位置、藏到其它窗口下都不会影响录制,非常优雅。我一直在用这个模式,但是发现竞品竟然没有使用这个模式,一直不清楚什么原因。
有一次我在录制副屏幕的内容,录制完发现右键菜单内容完全没有被录制上,一番折腾后发现了一个极其诡异的 Bug: 使用 SCK 的窗口录制模式,在 MacBook 主屏幕上录制一切正常,右键菜单(尽管它是独立的 Window ID)也能被捕捉到。 但是,一旦把窗口拖到副屏幕上录制,右键菜单就神秘消失了。
这显然是 macOS 框架层的 Bug 或者是某种未公开的限制(Sub-window 捕获在副屏失效)。
出于此,我立刻明白了竞品为什么不用。宁可牺牲一点使用体验,也不能牺牲用户的内容。 我们这些做内容生产工具的,内容的完整性就是用户的根,内容不可丢失。
因此很快,我也替换成了用 区域模式(Display Mode) 代替窗口录制模式,放弃了一些录制的灵活性,换取了内容的完整性。
SCK 为什么持续出现 3821 问题打断用户录制?
后来不知是升级了系统,还是系统的内容变多。我用软件总是无法持久地录制。总是在10分钟内就快速阵亡。
一开始我以为是哪里改出了问题,使用很多办法来尝试解决,最后甚至回退了大量代码。直到回退代码到了上一个发布的版本,我才意识到,不是我的代码变了,是环境变了。
我战战兢兢地打开老版本,一个版本一个版本尝试,竟发现和以前变得完全不一样,没有一个版本能持续录制超过10分钟的。
控制台里不断跳出同一个错误:
Error Domain=SCStreamErrorDomain Code=-3821
"The display stream was interrupted"
我的天塌了。
当我穷尽所有可能都无法想到原因时,我的大脑开始思考玄学问题: “这是有蹊跷,睡一觉可能就好了”
睡完一觉、一切如故。
我花了挺长时间去排查、复现、找规律。但当我开始查资料时,发现一个很尴尬的事实:相关信息非常少。很多问题看起来”大家都遇到过”,但几乎没人能说清楚”为什么会这样”,网上也没有可靠的解决办法。
科学实验:测试所有竞品
玄学了很久,我终于想起来拿竞品们尝试尝试。我知道它们曾经都能运行地很好,系统自带的、主流的商业软件、OBS、ffmpeg、还有一些会议软件,原来我测试过的软件又被我一个一个地翻了出来。
那几天我像在做科学实验:
- 同一个场景
- 同一段时间
- 同一台机器
- 换不同软件去录,然后看它们会怎么坏
不试不知道这一试吓一跳,众多竞品,竟几乎都夭折在前十分钟。
好在这样,我终于明白了不是我的错,是时辰的错。各家软件面对 SCK 的不稳定,处理方式各不相同:
| 竞品/工具 | 遇到问题时的表现 | 用户体验 |
|---|---|---|
| QuickTime… | 直接停止录制,保存已录制部分。 | 突然中断,用户一脸懵。 |
| Screen Studio… | 弹出错误提示窗口,自动停止。 | 诚实,虽然中断了但保护了用户知情权。 |
| OBS… | 画面卡死在最后一帧,但计时器继续走。 | 最差。用户以为在录,其实录了个寂寞(沉默失败)。 |
| ffmpeg… | 丢帧、卡顿,但顽强地继续录(因为它默认用 Legacy 模式)。 | 画面像幻灯片,但好歹有内容。 |
这组对比实验让我意识到一个挺反直觉的事实:对录屏来说,“不中断”不一定比”中断”更好。
最坑的是”沉默失败”:看起来还在录(计时在走、界面正常),但回放时你才发现后半段内容缺失了,或者画面开始重复同一帧。用户以为一切正常,但其实已经进入了沉默失败。这往往比直接中断更灾难,因为它让用户在最关键的时刻失去了知情权。
技术原因:SCK vs Legacy
为什么大家都这么惨?为什么 ffmpeg 表现不同?
经过长时间的折腾和观察,我大概摸清了 ScreenCaptureKit (SCK) 和传统 Legacy (CGWindowList) 在系统高压下的不同表现逻辑:
Legacy (传统模式):韧性的
- 当系统负载高时,它会丢帧
- 鼠标会卡顿,画面会跳跃,CPU 占用率会飙高
- 但它会咬牙坚持把录制撑下去,不到万不得已不断开
SCK (新框架):脆性的
- 追求高性能和低延迟,GPU 零拷贝,低功耗
- 一旦系统资源(显存或带宽)无法满足它的缓冲区需求
- 它不会降级处理,而是直接抛出 3821 错误并立即断开连接
这也解释了为什么至今没有完美的解决方案,也解释了为什么 ffmpeg 虽然 CPU 占用高,但在 SCK 挂掉的时候它往往还能(虽然是假录或卡顿地)坚持工作——因为它默认使用 Legacy 模式。
我的应对策略
面对这个问题,我做了几个决策:
1. 提供 Legacy 备选方案
虽然 Legacy 模式有性能问题(CPU 占用高、功耗大),但至少能在极端情况下保证录制完整性。所以我在软件里提供了切换选项,让用户在稳定性和性能之间做选择。
2. 监控丢帧率,主动预警
虽然我无法完全阻止 3821 错误的发生,但我可以在问题严重之前给用户提个醒。
我在录制过程中持续监控丢帧率,当丢帧率异常升高时,会主动提醒用户当前系统负载较高,建议他们考虑暂停其他应用或切换到 Legacy 模式。这样至少能让用户有知情权,而不是录完了才发现问题。
3. 诚实报错,不做”假录制”
我宁愿产品诚实地停下来告诉用户出错了,也不要给用户”看似在录”的幻觉。一旦捕捉到 3821 错误,立刻停止录制并提示用户,同时保存已录制的部分。
4. 玄学偏方的提示
在开发者社区流传着一个偏方(我自己验证似乎有效):确保系统磁盘剩余空间大于 12GB。
据说这是因为 WindowServer 进程在处理大量纹理数据时依赖交换空间(Swap),如果磁盘空间紧张,更容易触发缓冲区分配失败,从而导致 SCK 崩溃。虽然听起来玄学,但确实降低了问题出现的频率。我把这个建议也写进了软件的帮助文档里。
到现在为止,我仍然不能说我”消灭了
-3821”。根因仍是谜,苹果官方论坛上也是一片哀嚎,我也没找到明确可验证的”降概率手段”。但至少,我能保证产品对失败的反应方式是可靠的、诚实的。
SwiftUI 写的时间线好卡,怎么优化?
录完屏只是第一步,用户还需要对视频进行后期编辑。ScreenSage Pro 提供了一个类似剪映、Final Cut Pro 的时间线编辑器,用户可以在上面调整每个片段的时长、位置、效果等。
起初这个功能用起来还不错,但随着用户录制的视频越来越长,时间线上的片段越来越多(一个 10 分钟的录屏可能有几十个鼠标点击事件,每个事件对应一个可编辑的片段),问题就来了:
界面开始变得卡顿。
拖动时间线会掉帧,修改某个片段的属性时,整个界面都会闪烁一下。最夸张的是,当时间线上有 100+ 个片段时,仅仅是滚动时间线都能感受到明显的延迟。
问题根源:ObservableObject 的全局刷新
一开始,我的时间线数据模型是这样的:
class TimelineViewModel: ObservableObject {
@Published var clips: [TimelineClip] = []
@Published var currentTime: Double = 0
@Published var playbackState: PlaybackState = .paused
}
class TimelineClip: ObservableObject, Identifiable {
let id = UUID()
@Published var startTime: Double
@Published var duration: Double
@Published var zoomScale: Double
// ... 更多属性
}
看起来很正常对吧?但问题就出在 @Published 上。
当我修改某一个 TimelineClip 的 zoomScale(比如用户调整了某个片段的放大倍数)时,SwiftUI 会做什么?
它会刷新所有依赖 clips 数组的视图。
即使大部分片段完全没有变化,即使某个视图只关心 currentTime 而不关心 clips,只要 clips 数组里有任何变化,SwiftUI 都会认为”整个模型变了”,然后触发大范围的视图重绘。
100 个片段 = 100 次不必要的视图更新。这就是卡顿的根本原因。
解决方案:迁移到 @Observable
2023 年 WWDC,苹果推出了新的 @Observable 宏(iOS 17+ / macOS 14+),它解决的正是这个问题。
我把代码改成了这样:
// 新方案:使用 @Observable
@Observable
class TimelineViewModel {
var clips: [TimelineClip] = []
var currentTime: Double = 0
var playbackState: PlaybackState = .paused
}
@Observable
class TimelineClip: Identifiable {
let id = UUID()
var startTime: Double
var duration: Double
var zoomScale: Double
// ... 更多属性
}
看起来只是去掉了 @Published 和 ObservableObject?但背后的机制完全不同。
为什么 @Observable 更快?
@Observable 使用了 细粒度属性追踪(Fine-Grained Property Tracking):
- SwiftUI 会精确知道每个视图依赖了哪些属性
- 只有当某个视图真正依赖的属性变化时,才会触发该视图的重绘
- 如果一个视图只读取
clip.duration,那么修改clip.zoomScale不会触发它的更新
举个例子:
// 这个视图只依赖 duration
struct ClipDurationLabel: View {
let clip: TimelineClip
var body: some View {
Text("\(clip.duration, specifier: "%.2f")s")
}
}
- 旧方案(ObservableObject):修改
clip.zoomScale会导致这个视图刷新 - 新方案(@Observable):修改
clip.zoomScale不会触发任何更新,因为这个视图根本没用到它
这就是性能飙升的秘密。
迁移效果
迁移到 @Observable 后,性能提升立竿见影:
- 滚动时间线:从掉帧变成 60fps 丝滑
- 修改片段属性:从全局闪烁变成局部更新
- 100+ 片段场景:从明显卡顿变成几乎无感知
迁移时的坑
虽然 @Observable 很香,但迁移时还是踩了几个坑:
1. 不是完全无脑替换
@Observable 不能直接替换 ObservableObject,特别是初始化方式:
ObservableObject用@StateObject(只初始化一次)@Observable用@State(如果写法不当,可能会多次初始化)
我的解决方案是把初始化逻辑提到父视图,避免意外的重复创建。
2. Combine 相关代码需要重写
@Observable 不再使用 objectWillChange,如果你有依赖这个的逻辑,需要用其他方式实现。好在我的时间线编辑器里没有太多 Combine 相关的代码,迁移起来还算顺利。
总之
如果你的 SwiftUI 应用也有类似的场景——大量的列表项、复杂的数据模型、频繁的局部更新——强烈建议迁移到 @Observable。它不仅仅是语法糖,而是 SwiftUI 性能优化的一次质的飞跃。
唯一的限制是需要 iOS 17+ / macOS 14+,但对于新项目来说,这完全不是问题。
最后
回顾这一年多的开发历程,从预售时的忐忑不安,到现在产品逐渐稳定,感觉像是经历了一场漫长的闯关游戏。
有些关卡顺利通过了:视频编码器的比特率问题,找到了 BPP 的甜点值;时间线的性能问题,用 @Observable 完美解决;音画同步问题,摸清了首帧时间戳的逻辑。这些问题虽然当时很头疼,但至少有明确的解决方案,查资料、做实验、改代码,总能攻克。
但也有些关卡至今没有完美通关:SCK 的 3821 错误依然会不定期出现,我只能用 Legacy 模式兜底;副屏录制子窗口的 Bug 是系统框架的问题,我只能绕开;分片录制虽然降低了风险,但还是无法百分百保证不丢数据。
一开始我会为这些”未解决”的问题焦虑,总觉得产品不够完美。但做了一年后我慢慢想明白了:
不是所有问题都有标准答案。
有时候你得接受系统的限制,有时候你得在性能和稳定性之间权衡,有时候你只能做到”诚实报错”而不是”永不出错”。这些听起来像妥协,但其实是成熟——是从”我要做一个完美的产品”到”我要做一个可用的产品”的转变。
现在 ScreenSage Pro 已经有用户在用它录教程、做演示、剪视频了。虽然偶尔还会收到 Bug 反馈,虽然还有很多功能想做但没时间做,但看到用户真的用它解决问题时,那种感觉还挺奇妙的。
你写的代码,从电脑里跑出来,到了别人的电脑上,帮他们省下了时间、解决了麻烦。这大概就是做产品最大的意义吧。
如果你也在做独立开发,也在和各种技术问题死磕,希望这篇文章能给你一些启发。不一定是具体的技术方案(毕竟每个产品遇到的坑都不一样),而是一种心态:
能解决最好,解决不了就绕路。别让完美主义拖进度。
做完总比做完美重要。