每当 NSPasteboard 被写入时,我能否收到回调?

56
我已经阅读了苹果的Pasteboard编程指南,但它没有回答我特定的问题。
我正在尝试编写一个Cocoa应用程序(适用于OS X,而不是iOS),该应用程序将跟踪写入通用剪贴板的所有内容(因此,每当任何应用程序复制和粘贴时,但不会拖放,这也使用NSPasteboard)。我可以通过基本上在后台线程上不断轮询通用剪贴板并检查changeCount来(几乎)完成此操作。当然,这样做会让我感到非常肮脏。
我的问题是,是否有一种方法可以要求Pasteboard服务器通过某种回调通知我任何时候对通用剪贴板进行更改?我在NSPasteboard类参考中找不到任何内容,但我希望它潜藏在其他地方。
另一种我可以想象实现这一点的方法是,如果有一种方法可以交换通用剪贴板实现与NSPasteboard的子类定义自己发出回调。也许像这样的东西是可能的?

我希望可以使用公开的、符合App Store法规的API来实现这个功能,但如果必须使用私有API,那也可以。

谢谢!


如果您正在监视粘贴板,以下内容并非答案,但需要注意:有一个非正式协议可用于标记瞬态和应用程序生成的数据,该协议详见:http://nspasteboard.org/。 - Smilin Brian
6个回答

51

不幸的是,唯一可用的方法是轮询(booo!)。 没有通知,也没有可以观察更改后的剪贴板内容的东西。 查看苹果的ClipboardViewer示例代码,了解它们如何检查剪贴板。 添加一个(希望不要过度热衷的)定时器以保持检查差异,你就得到了一个基本(虽然很笨重)的解决方案,应该是App Store友好的。

bugreporter.apple.com上提交增强请求以请求通知或其他回调。 不幸的是,直到最早的下一个主要操作系统发布之前,这都对你没什么帮助,但现在只能进行轮询,直到我们所有人要求他们给我们更好的东西。


3
我害怕了。谢谢! :) - Adrian Petrescu
4
2.5年前以来有任何变化吗? - tofutim
4
三年前以来有任何变化吗? - tofutim
3
@Supertecnoboff 看起来是这样,可惜没有新的API允许回调。 - Joshua Nozzi
1
哈哈,我又来到了2021年... 我猜没有什么变化。 - tofutim
显示剩余5条评论

14

曾经有一篇邮件列表中讨论了不使用通知API的决定,但我现在找不到它了。底线是,即使某些应用程序其实并不需要它,可能仍会有太多的应用程序注册该API。如果你复制了什么东西,整个系统会疯狂地遍历新的剪贴板内容,给计算机带来很多工作量。因此,我认为他们不会很快改变这种行为。整个NSPasteboard API也是围绕着使用changeCount内部构建的。因此,即使是你自定义的NSPasteboard子类,仍然必须保持轮询。

如果你真的想检查剪贴板是否发生了变化,只需每隔半秒钟观察一下changeCount。比较整数非常快,所以这里真的没有性能问题。


假设“瞬间”是必要的。我想知道是否真的有必要记录每一个变化(因此需要像孩子一样反复问,这是一种令人讨厌的资源浪费,“我们到了吗???”)。;-) 当用户调用您的服务时,查看时间很好(否则您将成为一款浪费资源的好奇应用程序)。如果您必须捕获后台更新(例如日志),那么每隔几秒钟就可以了。如果用户在几秒钟内复制了两次某些内容,则第一次可能是他们稍后更正的错误... - Joshua Nozzi
1
每半秒轮询并不会消耗电池,特别是如果它只是检查更改计数。我刚刚创建了一个测试项目,它显示零能量影响。然而,唯一有用的情况是为那些构建剪贴板历史记录的应用程序。 - Karsten
在一篇相关的帖子中(这也引导我来到了这里),我提到了关于“唯一的原因”你可能需要如此频繁地轮询的同样事情。但是,重复的计时器会导致应用程序定期被唤醒工作,从而永远不允许它长时间“打盹”。这就是常识所建议的,但您似乎已经证明了相反的情况。您介意在某个地方分享该项目吗?(完全友好的专业兴趣,而不是攀比,我保证。:-)) - Joshua Nozzi
一个更新:在现代Apple设备上,轮询肯定会浪费电池电力,因为它会防止系统进入低功耗状态。 - Joshua Nozzi
它只会摆动到一定程度(不够远)。请参阅文档以获取详细信息。 - Joshua Nozzi
显示剩余8条评论

12

参考了Joshua提供的答案后,我用Swift编写了类似的实现方式,以下是其代码片段:

PasteboardWatcher.swift
class PasteboardWatcher : NSObject {

    // assigning a pasteboard object
    private let pasteboard = NSPasteboard.generalPasteboard()

    // to keep track of count of objects currently copied
    // also helps in determining if a new object is copied
    private var changeCount : Int

    // used to perform polling to identify if url with desired kind is copied
    private var timer: NSTimer?

    // the delegate which will be notified when desired link is copied
    weak var delegate: PasteboardWatcherDelegate?

    // the kinds of files for which if url is copied the delegate is notified
    private let fileKinds : [String]

    /// initializer which should be used to initialize object of this class
    /// - Parameter fileKinds: an array containing the desired file kinds
    init(fileKinds: [String]) {
        // assigning current pasteboard changeCount so that it can be compared later to identify changes
        changeCount = pasteboard.changeCount

        // assigning passed desired file kinds to respective instance variable
        self.fileKinds = fileKinds

        super.init()
    }
    /// starts polling to identify if url with desired kind is copied
    /// - Note: uses an NSTimer for polling
    func startPolling () {
        // setup and start of timer
        timer = NSTimer.scheduledTimerWithTimeInterval(2, target: self, selector: Selector("checkForChangesInPasteboard"), userInfo: nil, repeats: true)
    }

    /// method invoked continuously by timer
    /// - Note: To keep this method as private I referred this answer at stackoverflow - [Swift - NSTimer does not invoke a private func as selector](https://dev59.com/uYzda4cB1Zd3GeqPiyuy#30947182)
    @objc private func checkForChangesInPasteboard() {
        // check if there is any new item copied
        // also check if kind of copied item is string
        if let copiedString = pasteboard.stringForType(NSPasteboardTypeString) where pasteboard.changeCount != changeCount {

            // obtain url from copied link if its path extension is one of the desired extensions
            if let fileUrl = NSURL(string: copiedString) where self.fileKinds.contains(fileUrl.pathExtension!){

                // invoke appropriate method on delegate
                self.delegate?.newlyCopiedUrlObtained(copiedUrl: fileUrl)
            }

            // assign new change count to instance variable for later comparison
            changeCount = pasteboard.changeCount
        }
    }
}

注意: 在这段共享代码中,我试图确定用户是否复制了一个文件的url,提供的代码可以轻松地修改为其他常见用途。

注意:在这段共享代码中,我是在尝试判断用户是否已经复制了一个文件的URL。提供的代码可轻松地修改以实现其他通用目的。


1
我今天刚看到这个并点了赞。一个不错、简单的解决方案。建议:在初始化时要求委托(这样它就不是可选的),或者在委托上实现didSet,如果给定了委托,则创建/启动计时器,如果被取走则停止/销毁。你还应该将委托设置为弱引用,以避免保留循环。这样,如果委托消失,就可以避免资源消耗。(可能在你当前的使用中不可能,但请考虑“重用”)。 - Joshua Nozzi

8
对于那些需要在Swift 5.7中完成工作的简化代码片段的人来说,它只是起作用的(基于@Devarshi的代码)。
func watch(using closure: @escaping (_ copiedString: String) -> Void) {
    let pasteboard = NSPasteboard.general
    var changeCount = NSPasteboard.general.changeCount

    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        guard let copiedString = pasteboard.string(forType: .string),
              pasteboard.changeCount != changeCount else { return }

        defer {
            changeCount = pasteboard.changeCount
        }
        
        closure(copiedString)
    }
}

如何使用如下所示:
watch {
    print("detected : \($0)")
}

然后,如果你尝试复制粘贴板中的任何文本,它将会监视并打印到控制台上,如下所示...
detected : your copied message in pasteboard
detected : your copied message in pasteboard

如果需要,在SwiftUI中使用它的完整代码示例:

import SwiftUI

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    watch {
                        print("detect : \($0)")
                    }
                }
        }
    }
    
    func watch(using closure: @escaping (_ copiedString: String) -> Void) {
        let pasteboard = NSPasteboard.general
        var changeCount = NSPasteboard.general.changeCount

        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            guard let copiedString = pasteboard.string(forType: .string),
                  pasteboard.changeCount != changeCount else { return }

            defer {
                changeCount = pasteboard.changeCount
            }
            
            closure(copiedString)
        }
    }
}

Swift异步等待版本:

import SwiftUI

@main
struct Test2App: App {
    var isWatch = true
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    while true {
                        let copy = await watch()
                        
                        if let copy {
                            print("copy : \(copy)")
                        }
                    }
                }
        }
    }
    
    func watch() async -> String? {
        let pasteboard = NSPasteboard.general
        var changeCount = NSPasteboard.general.changeCount

        try? await Task.sleep(nanoseconds: 500_000_000)
        guard let copyString = pasteboard.string(forType: .string),
              pasteboard.changeCount != changeCount else { return nil }

        changeCount = pasteboard.changeCount
        return copyString
    }
}

请注意这只是样本。 你可以在此基础上做任何你想做的事情。

2

不必轮询。剪贴板通常只会在当前视图无效或没有焦点时更改。剪贴板有一个计数器,当内容变化时会递增。当窗口重新获得焦点 (windowDidBecomeKey) 时,检查 changeCount 是否已更改,然后相应地处理。

这并不能捕捉到每一个更改,但可以让您的应用程序在重新激活时响应 Pasteboard 是否不同。

在 Swift 中...

var pasteboardChangeCount = NSPasteboard.general().changeCount
func windowDidBecomeKey(_ notification: Notification)
{   Swift.print("windowDidBecomeKey")
    if  pasteboardChangeCount != NSPasteboard.general().changeCount
    {   viewController.checkPasteboard()
        pasteboardChangeCount  = NSPasteboard.general().changeCount
    }
}

这是处理查找剪贴板的绝佳见解。应用文本搜索工具栏/面板等(当您键入Cmd-F时看到的内容)应该跟踪全局查找剪贴板。 - MtnViewJohn

-2

我有一个更严格的解决方案:检测您在NSPasteboard中的内容被替换为其他内容的情况。

如果您创建一个符合NSPasteboardWriting协议的类,并将其与实际内容一起传递给-writeObjects:NSPasteboard将保留此对象,直到其内容被替换。如果没有对此对象的其他强引用,它将被释放。

释放此对象是新的NSPasteboard获取新内容的时刻。


这对于系统中缓存/唯一的事物是行不通的。比如NSString常量、NSIndexPath等,它们会在应用程序的生命周期内保留下来。还有许多其他情况,由于“原因”,某些东西被保留在预期寿命之外。请不要做任何依赖于其他东西何时被解除分配的操作。只有在被释放实例的dealloc/deinit本身内部才能进行操作。 - Joshua Nozzi
我说的是自定义类,而不是系统类。你怎么可能覆盖系统类的-dealloc方法呢?绑定对象生命周期和回调之间存在有效的使用情况。 - Tricertops
我的观点是,你不能依赖于释放内存作为其他操作发生的标志。这是一个不好的想法,有很多原因。 - Joshua Nozzi
你可以依赖于释放内存的操作作为对象生命周期即将结束的信号。这样,你就可以将一个对象的生命周期与数据库中的条目或外部资源的生命周期等联系起来。Objective-C 的内存管理是确定性的,可以可靠地使用这种方式。 - Tricertops

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