超谈 SwiftUI:配合 CoreGraphics 实现区域截屏功能
此文为「超谈 SwiftUI」系列的文章,主要输出一些使用 SwiftUI 开发几个 apps 后的一些心得、经验和踩坑记录。希望能帮助到你。
我的翻译软件 Photon AI Translator 有一个屏幕截图翻译的功能,通过快捷键激活后,会将屏幕画面冻结同时变暗,然后用户可以通过鼠标拖拽屏幕画出一个区域,然后识别里面的文本,然后进行翻译并显示结果。
这里的一个核心技术是,如何实现截屏功能?其实这个没有你想象的难。
先来剖析一下这个需求,要实现这个功能,按照步骤来,我们需要:
- 截取当前键盘聚焦的屏幕画面,得到一个 Image
- 往当前键盘聚焦的屏幕上绘制上述得到的 Image
- 覆盖一层黑色半透明的遮罩,然后在上面检测手势操作
- 手势操作将会产生一个 Rect,然后往黑色遮罩抠出这么一个 Rect
- 把截图的屏幕截图 Image,裁剪 Rect 的区域,得到 Cropped Image
把技术点理清楚后,接下来将会以以下几个方面来介绍技术实现:
- 如何截取屏幕画面
- 如何实现遮罩抠图功能
截取屏幕画面
截取屏幕画面有多种方式:
CGDisplayCreateImage
只能以显示器为单位截取屏幕画面,不支持设置要截取的 WindowCGWindowListCreateImage
支持设置要截取的 Window 以及其他选项,在 macOS 14.0 里废弃⚠️了
当然从我们的场景上来说,使用 CGDisplayCreateImage
是最合适的,因为也调研出上述的其他方式,所以下文也会同时提及。
获取权限
不管是使用上述三种的哪种方式截取屏幕,在调用之前,我们需要取得对应的权限。
在 macOS 里,这个权限是录制屏幕的权限,对应设置里的:
在调用 CGWindowListCreateImage 的时候,系统会自动询问用户来获取权限,但如果你想要提前向用户询问权限,可以使用 CGRequestScreenCaptureAccess
方法。同时,你也可以使用 CGPreflightScreenCaptureAccess
方法来提前检测是否获取到了该权限。
使用 CGDisplayCreateImage 截取
Core Graphics 提供了 CGDisplayCreateImage 这个方法截取屏幕画面,返回一个 CGImage。
该方法的签名是:
func CGDisplayCreateImage(_ displayID: CGDirectDisplayID) -> CGImage?
此 CGDirectDisplayID
表示要截取的显示器的 ID。
那么如何获取呢?注意一下,通过 CGMainDisplayID()
获取到的是在系统设置里设置的主显示器,而我们的截屏功能,需要截取的是当前 NSScreen.main 绑定的显示器。
💡 要想得到当前键盘聚焦的屏幕,我们通过NSScreen.main
来获取,但如果想要获得系统设置的主显示器,那么它则是固定获取到的NSScreen
的第一个:NSScreen.screens[0]
。
在拿到 NSScreen
后,可以这样获取到对应的 CGDirectDisplayID
:
extension NSScreen {
var displayID: CGDirectDisplayID? {
return deviceDescription[NSDeviceDescriptionKey(rawValue: "NSScreenNumber")] as? CGDirectDisplayID
}
}
之后,直接通过调用 CGDisplayCreateImage
方法,传入 NSScreen.main
的 displayID 即可获取到包含 MenuBar 在内的整个显示器区域的,全分辨率(逻辑分辨率 * 缩放比例)的 CGImage
。
使用 CGWindowListCreateImage 截取
💡 注意,此方法在 macOS 14.0 已经废弃,不推荐使用了。
Core Graphics 提供了 [CGWindowListCreateImage](<https://developer.apple.com/documentation/coregraphics/1454852-cgwindowlistcreateimage>)
这个方法截取所有显示器拼接起来的屏幕画面,返回一个 CGImage。
CGWindowListCreateImage
的方法签名是:
func CGWindowListCreateImage(
_ screenBounds: CGRect,
_ listOption: CGWindowListOption,
_ windowID: CGWindowID,
_ imageOption: CGWindowImageOption
) -> CGImage?
你可以通过修改参数来达到不同的截取效果,比如只截取某个 Window 的同时不带阴影效果。在这里,我们需要截取整个屏幕画面,所以可以传入以下参数:
CGWindowListCreateImage(clipRect, .optionOnScreenOnly, .zero, .bestResolution)
需要注意的是 :
- 我们传入了
clipRect
来指定截取的区域,这是为了适配多显示器的场景的,往后会着重介绍这个 clipRect 的计算方式 .optionOnScreenOnly
+.zero
表示我们不关心特定的 Windows,截取整个 屏幕画面- 最后一个
.bestResolution
表示的是,我们截取到的屏幕画面是跟显示器原像素一致的画面,而不是经过缩放计算的;如果传入.nominalResolution
则会拿到经过缩放计算的,比如原来的宽是 1000px,在 2x 缩放下,会得到宽为 500px 的图。
接下来介绍一下 clipRect。
如果用户只有一块显示屏,那么 screenBounds 参数传入 .infinity 是完全没有问题的,这个时候你拿到的就是一整个屏幕的图像。但如果用户连接了多个显示器,那么你拿到的,将是几个显示器,按照你系统设置里布局拼接起来的图像。
如果你的系统设置里,显示器布局是这样子的话:
那么会得到这样子的图:
要想得到当前键盘聚焦的显示器的正确截图,我们需要计算好一个 Rect,然后传入 CGWindowListCreateImage
的第一个参数 screenBounds
来进行裁剪。
要想得到当前键盘聚焦的屏幕,我们通过 NSScreen.main
来获取,但如果想要获得系统设置的主显示器,那么它则是固定获取到的 NSScreen
的第一个: NSScreen.screens[0]
。拿到 NSScreen 后,访问它的 frame 可以得到它在所有 NSScreens 里的 frame 信息。
在进行计算之前,需要先知道一些概念:
CGWindowListCreateImage
的参数screenBounds
,它的坐标系是以左上角为原点,往右为 x 的正方向,往下为 y 的正方向NSScreen
的 frame,是以左下角为原点,往右为 x 的正方向,往上为 y 的正方向
因此,我们需要转换以下坐标系,把 NSScreen.main.frame
转换为以左上角为原点的坐标。具体计算的方式如下,这里就不详说了。
let mainDisplay = NSScreen.screens[0]
// Note that main NSScreen is the one with keyboard focused, and NSScreen.screens[0] should be the one as main display in macOS settings.
if let currentScreen = NSScreen.main {
let currentScreenRect = currentScreen.frame
let x = currentScreenRect.minX
let w = currentScreenRect.width
let h = currentScreenRect.height
let y = -(currentScreenRect.minY - mainDisplay.frame.height) - currentScreenRect.height
let clipRect = CGRect(x: x, y: y, width: w, height: h)
// The first parameter is screenBounds, which:
// - If it is `.infinity`, then CGWindowListCreateImage will return the image contains all screens in all displays
// - It's coordinate is the one with origin at the upper-left; y-value increasing downward
// - The NSScreen/frame is the one with origin at the bottom-left; y-value increasing upwawrd, so we need to transfer the coordinate
let cgImage = CGWindowListCreateImage(clipRect, .optionOnScreenOnly, .zero, bestResolution ? .bestResolution : .nominalResolution)
}
在拿到通过 CGWindowListCreateImage
拿到 CGImage 后,就可以在一个跟当前聚焦的屏幕一样大的 NSWindow 上,绘制这个 CGImage 了。关于如何实现这个 NSWindow,可以参考上一篇文章:
JuniperPhoton:超谈 SwiftUI:一个向下兼容的纯 Menu Bar app 方案3 赞同・0 评论文章
具体绘制的方式,我这里使用了 SwiftUI 的 Canvas:
Canvas { context, size in
if let fullFrameCGImage = viewModel.fullFrameCGImage {
let image = Image(fullFrameCGImage, scale: 1.0, label: Text(""))
.resizable()
let resolvedImage = context.resolve(image)
context.blendMode = .normal
context.draw(resolvedImage, in: .init(x: 0, y: 0, width: size.width, height: size.height))
}
// draw masks here
}
不直接使用 SwiftUI 的 Image (cgImage:) 来显示,是因为后面还需要往上方绘制遮罩,为了正确地显示 Blend 效果,屏幕截图跟遮罩都在 Canvas 里绘制(当然也可以直接使用 Core Graphics 来绘制)。
这里是否需要把这个截取的屏幕截图绘制出来,安全取决于你的需求。如果用户在看一个视频,想要截取一段文本的话,如果不把这个屏幕截图绘制出来并覆盖屏幕,那么用户截取的画面,跟实际看到的画面有可能会因为时间差导致不一致。
实现遮罩抠图
遮罩抠图的效果如上。
要达到此抠图的效果,我们可以使用 BlendMode 来实现:
- 先使用
.normal
的 BlendMode 来绘制一个黑色半透明的覆盖全屏幕的 Rect - 然后使用
.destinationOut
来绘制用户鼠标所划过的区域的 Rect - 以上绘制因为使用了 BlendMode,为了不影响底下绘制的屏幕截图
Canvas { context, size in
if let fullFrameCGImage = viewModel.fullFrameCGImage {
// draw image
}
context.drawLayer { layer in
let selectedFrame = viewModel.selectedFrame
layer.blendMode = .normal
let backgroundRect = Rectangle().path(in: .init(x: 0, y: 0, width: size.width, height: size.height))
layer.fill(backgroundRect, with: .color(.black.opacity(0.3)))
let selectedRect = Rectangle().path(in: .init(x: selectedFrame.minX, y: selectedFrame.minY,
width: selectedFrame.width, height: selectedFrame.height))
layer.blendMode = .destinationOut
layer.fill(selectedRect, with: .color(.white))
}
}
这里的 viewModel.selectedFrame
表示用户通过鼠标画的截取的区域,我们通过 SwiftUI 的 DragGesture
来实现:
.gesture(DragGesture(minimumDistance: 0).onChanged { value in
self.isDrawing = true
let x = value.startLocation.x
let y = value.startLocation.y
let currentX = value.location.x
let currentY = value.location.y
let originX = min(currentX, x)
let originY = min(currentY, y)
viewModel.selectedFrame = .init(origin: .init(x: originX, y: originY),
size: .init(width: abs(currentX - x), height: abs(currentY - y)))
}
在手势完成后,你可以得到一个完整的屏幕截图 CGImage
和一个用户截取的区域 Rect
。
想要获取裁切的 CGImage,也很简单:
let cropped = image.cropping(to: selectedFrame)
最后,Photon AI Translator 里的截屏翻译,会使用 VisionKit 的 VNRecognizeTextRequest
提取 CGImage 里的文本信息,然后进行翻译。VNRecognizeTextRequest
就不在这里介绍了,有兴趣请查阅官方文档。
总结
以上介绍了结合 SwiftUI + Core Graphics 来实现一个截屏功能,结合前面的「一个向下兼容的纯 Menu Bar app 方案」文章,完全实现一个 Mac 上的截屏软件已经不是难事了。
我的独立开发作品,Photon 和 Myer 系列的 app 没有开源,但里面用到的一些技术和关键实现,会在此「超谈 SwiftUI」系列里介绍。
希望此文能帮到你。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox