捕获 macOS 窗口截图

13
注意: 该问题故意非常普遍(例如,请求Objective-C和Swift代码示例),因为它旨在记录如何在macOS上尽可能方便地捕获窗口截图。
我想在Objective-C/Swift代码中捕获macOS窗口的屏幕截图。 我知道这是可能的,因为有多种在macOS上截取屏幕截图的方法(⇧⌘4、Grab实用程序、命令行上的“ screencapture ”等),但我不确定如何在自己的代码中实现它。 理想情况下,我将能够指定特定应用程序的窗口,然后在NSImageCGImage中捕获它,然后对其进行处理并显示给用户或存储到文件中。
2个回答

42
macOS上的屏幕截图可以通过Quartz Window Services实现,这是Core Graphics框架的一个功能。我们在这里的关键函数是CGWindowListCreateImage,它“基于动态生成的窗口列表返回一个组合图像”,换句话说,根据指定的条件查找窗口并创建包含每个窗口内容的图像。太完美了!其声明如下:
CGImageRef CGWindowListCreateImage(CGRect screenBounds, 
                                   CGWindowListOption listOption, 
                                   CGWindowID windowID, 
                                   CGWindowImageOption imageOption);

因此,为了捕获屏幕上的一个特定窗口,我们需要它的窗口ID(CGWindowID)。为了检索它,我们首先需要获取系统上所有可用窗口的列表。我们通过CGWindowListCopyWindowInfo获取该列表,它需要CGWindowListOption和相应的CGWindowID,两者共同选择要包含在结果列表中的窗口。要获取所有窗口,我们分别指定kCGWindowListOptionAllkCGNullWindowID。此外,如果您还没有弄清楚,这是一个C API,因此我们将使用桥接转换来使用更友好的Objective-C容器而不是Core Foundation容器。

Objective-C:

NSArray<NSDictionary*> *windowInfoList = (__bridge_transfer id)
    CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);

Swift:

let windowInfoList = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID)!
    as NSArray

从这里开始,我们需要将我们的windowInfoList过滤到我们想要的特定窗口。很有可能我们首先想要按应用程序进行过滤。为此,我们需要我们选择的应用程序的进程ID。我们可以使用NSRunningApplication来实现:

Objective-C:

NSArray<NSRunningApplication*> *apps = 
    [NSRunningApplication runningApplicationsWithBundleIdentifier:
        /* Bundle ID of the application, e.g.: */ @"com.apple.Safari"];
if (apps.count == 0) {
    // Application is not currently running
    puts("The application is not running");
    return; // Or whatever
}
pid_t appPID = apps[0].processIdentifier;

Swift:

let apps = NSRunningApplication.runningApplications(withBundleIdentifier:
    /* Bundle ID of the application, e.g.: */ "com.apple.Safari")
if apps.isEmpty {
    // Application is not currently running
    print("The application is not running")
    return // Or whatever
}
let appPID = apps[0].processIdentifier

有了appPID,我们现在可以过滤掉窗口信息列表中与匹配所有者PID的窗口:

Objective-C:

NSMutableArray<NSDictionary*> *appWindowsInfoList = [NSMutableArray new];
for (NSDictionary *info in windowInfoList) {
    if ([info[(__bridge NSString *)kCGWindowOwnerPID] integerValue] == appPID) {
        [appWindowsInfoList addObject:info];
    }
}

Swift:

var appWindowsInfoList = [NSDictionary]()
for info_ in windowInfoList {
    let info = info_ as! NSDictionary
    if (info[kCGWindowOwnerPID as NSString] as! NSNumber).intValue == appPID {
        appWindowsInfoList.append(info)
    }
}

我们可以通过测试info字典的其他键(例如名称(kCGWindowName)或窗口是否在屏幕上(kCGWindowIsOnscreen))来进行额外的过滤,但现在,我们将只获取列表中的第一个窗口:

Objective-C:

NSDictionary *appWindowInfo = appWindowsInfoList[0];
CGWindowID windowID = [appWindowInfo[(__bridge NSString *)kCGWindowNumber] unsignedIntValue];

Swift

let appWindowInfo: NSDictionary = appWindowsInfoList[0];
let windowID: CGWindowID = (appWindowInfo[kCGWindowNumber as NSString] as! NSNumber).uint32Value

我们已经获得了窗口 ID!现在,我们还需要为该调用准备什么?

CGImageRef CGWindowListCreateImage(CGRect screenBounds, 
                                   CGWindowListOption listOption, 
                                   CGWindowID windowID, 
                                   CGWindowImageOption imageOption);
首先,我们需要一个screenBounds来进行捕获。根据文档,我们可以将此参数指定为CGRectNull,以尽可能紧密地包含所有指定的窗口。 对我很有用。
其次,我们必须指定如何使用listOption选择我们的窗口。 我们实际上之前在调用CGWindowListCopyWindowInfo时使用了其中之一,但是那里我们想要系统中的所有窗口; 在这里,我们只想要一个,因此我们将指定kCGWindowListOptionIncludingWindow,与其文档页面相反,这对于CGWindowListCreateImage本身是有意义的,因为它指定了我们传递的窗口,仅传递该窗口。
第三,我们将我们的windowID作为我们要捕获的窗口传递。
第四,也是最后一点,我们可以使用 imageOption 参数指定 CGWindowImageOption。这些选项会影响所得到的图像外观;你可以通过按位 OR 结合它们。完整列表 在此, 但常见的包括 kCGWindowImageDefault,它捕获窗口内容及其框架和阴影或 kCGWindowImageBoundsIgnoreFraming,它只捕获内容, kCGWindowImageBestResolution 捕获最佳分辨率可用的窗口内容,而不考虑实际大小(并且可能相当大),或者 kCGWindowImageNominalResolution,它以屏幕上的当前大小捕获窗口。在这里,我选择了 kCGWindowImageBoundsIgnoreFramingkCGWindowImageNominalResolution 来捕获与屏幕上相同大小的内容。

鼓声响起:

Objective-C:

CGImageRef windowImage =
    CGWindowListCreateImage(CGRectNull, kCGWindowListOptionIncludingWindow,
                            windowID, kCGWindowImageBoundsIgnoreFraming|
                            kCGWindowImageNominalResolution);
// NOTE: windowImage may be NULL if the capture failed

Swift:

let windowImage: CGImage? =
    CGWindowListCreateImage(.null, .optionIncludingWindow, windowID,
                            [.boundsIgnoreFraming, .nominalResolution])

这是非常有用的答案,非常感谢。但是如何从ViewController的viewDidAppear方法中获取窗口ID?我尝试了view.window?.windowNumber,但它不是由窗口服务器分配的全局窗口号。 - Kevin Yue
@Kevin 很大一部分的答案都是关于使用 CGWindowListCopyWindowInfo 查找窗口 ID 的;你试过那些指示了吗? - ThatsJustCheesy
1
我使用CGWindowListCopyWindowInfo来找到窗口信息列表,然后通过从ProcessInfo().processIdentifier获取的当前进程ID过滤列表,解决了我的问题。 - Kevin Yue
1
在我的Objective-C程序中,我似乎得到了有效的(非0)窗口ID值,但尝试捕获窗口图像却一直返回空。我能够通过将“kCGWindowListOptionOnScreenOnly”替换为“kCGWindowListOptionAll”来解决这个问题。 - Jon Schneider
1
这是一个不错的回答,但如果窗口部分超出屏幕,则只能捕获可见部分。而屏幕截图工具可以截取部分超出屏幕的窗口的屏幕截图。 - Kibernetik
牛逼的指南!谢谢。 - arthas

0

这里是Objective C的代码,没有冗长的解释,并且无需事先知道您的Bundle ID:

int processID = [[NSProcessInfo processInfo] processIdentifier];
NSArray<NSDictionary*>* windowInfoList = (__bridge_transfer id) CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
int windowID = -1;
for (NSDictionary* info in windowInfoList) {
    int thisProcess = [info[(__bridge NSString *)kCGWindowOwnerPID] integerValue];
    if (thisProcess == processID) {
        windowID = [info[(__bridge NSString *)kCGWindowNumber] integerValue];
        break;
        }
    }

CGImageRef screenCG = nil;
if (windowID != -1)
    screenCG = CGWindowListCreateImage(CGRectNull, kCGWindowListOptionIncludingWindow, windowID, kCGWindowImageBoundsIgnoreFraming);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接