/ blog

超谈 SwiftUI:配合 CoreGraphics 实现区域截屏功能

此文为「超谈 SwiftUI」系列的文章,主要输出一些使用 SwiftUI 开发几个 apps 后的一些心得、经验和踩坑记录。希望能帮助到你。

我的翻译软件 Photon AI Translator 有一个屏幕截图翻译的功能,通过快捷键激活后,会将屏幕画面冻结同时变暗,然后用户可以通过鼠标拖拽屏幕画出一个区域,然后识别里面的文本,然后进行翻译并显示结果。

这里的一个核心技术是,如何实现截屏功能?其实这个没有你想象的难。

先来剖析一下这个需求,要实现这个功能,按照步骤来,我们需要:

  • 截取当前键盘聚焦的屏幕画面,得到一个 Image
  • 往当前键盘聚焦的屏幕上绘制上述得到的 Image
  • 覆盖一层黑色半透明的遮罩,然后在上面检测手势操作
  • 手势操作将会产生一个 Rect,然后往黑色遮罩抠出这么一个 Rect
  • 把截图的屏幕截图 Image,裁剪 Rect 的区域,得到 Cropped Image

把技术点理清楚后,接下来将会以以下几个方面来介绍技术实现:

  • 如何截取屏幕画面
  • 如何实现遮罩抠图功能

截取屏幕画面

截取屏幕画面有多种方式:

  • CGDisplayCreateImage 只能以显示器为单位截取屏幕画面,不支持设置要截取的 Window
  • CGWindowListCreateImage 支持设置要截取的 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」系列里介绍。

希望此文能帮到你。