PyQt5小技巧整理2:剪贴板的介绍和遇到的坑

字数 1679阅读 420

完成一个项目可以学到很多东西,最近利用PyQt5写的一个七牛云管理器也让自己长进了一些。今天这篇博文讲一下使用PyQt5中的剪贴板时遇到的坑。不得不补充一句,PyQt5是跨平台的,剪贴板功能也是跨平台的,写好了在Win/Mac/Linux上都能用。

Qclipboard在哪

首先,PyQt5中剪贴板模块的位置为:

PyQt5 -> QtWidget -> QApplication -> QClipboard

项目中可以这样来实例化:

from PyQt5 import QtWidgets

class MainUi(...):

    def __init__(self);
        self.cb = QtWidgets.QApplication.clipboard()
        ...

需要注意的是,必须在一个Gui类里面才能访问QClipboard,如果你尝试在类以外实例化它:


image.png

是的,肯定会报错;这个Gui的类可以是PyQt的QMainWindow、QWidgets等。

QClipboard的API

查看PyQt5的API,就像我在QyQt5入门系列博文中提到的,要养成翻阅QT官方文档库的习惯,目前PyQt5的官方文档只有英文的,而且是C++语言的应用,但是其实熟悉之后并不影响。
QT官方文档地址:https://doc.qt.io/
当然,现在Qt for Python的官方文档库也在慢慢完善,不过是Qt的另外一个分支叫PySide2,然而用法基本一致。
Qt for Python官方文档地址:https://doc.qt.io/qtforpython/
善用搜索找到Qclipboard所在的位置:http://doc.qt.io/qt-5/qclipboard.html
我们可以看到,PyQt5对系统剪贴板提供了不少的接口,这里介绍几个可能比较常用的。假如你已经如上面介绍的一样创建了一个MainUi的类,无论这个类继承的是QMainwindow还是QWidget还是其他Gui窗口类型,都是没有问题的。

from PyQt5 import QtWidgets

class MainUi(...):

    def __init__(self);
        self.cb = QtWidgets.QApplication.clipboard()
        ...

常用的公共方法和信号如下。

self.cb.clear()

清除剪贴板的内容

self.cb.text()
self.cb.image()
  1. 若剪贴板内容为文本,则返回文本;若剪贴板中内容不是文本,则会返回一个空字符串;
  2. 若剪贴板内容为图像,则返回图像;若剪贴板中内容不是图像或者是格式不支持的图像,则会返回一个空图像;这个函数我没用,因为我实际应用中需要判断剪贴板中是否是图片,通过读取剪贴板的mimeData来识别,这个后面会讲到。
mdata = self.cb.mimeData()
if mdata.hasImage():
    ...
elif mdata.hasText():
    ...

获取剪贴板的mimeData,其实返回的是PyQt5定义的QMimeData类型,点击查看QMimeData官方文档说明。其实mimeData自身也有很多操作,这里提到的hasImage和hasText是判断剪贴板中是否有图片或者是否有文本,然后根据判断结果进行其他操作。

self.cb.setImage(...)
self.cb.setText(...)

向剪贴板中写入图像或者文本。

self.cb.dataChanged.connect(...)

这是PyQt5的QClipboard的一个很有用的信号,当剪贴板中的数据发生改变时,会发出这个信号,通过connect链接到其他方法上,这样可以监控剪贴板来做一些事情。本篇博文说的坑,也就是在这个地方遇到的。

使用QClipboard.dataChanged()遇到的坑

我的七牛云助手小项目中,QClipboard.dataChange()是为了实现监控剪贴板是否拷贝了新的图片、如果有就询问是否上传、并自动拷贝上传后的文件链接的功能。其实我觉得想法还不错,不过直到项目上传完了自己日常使用的时候才发现一个问题:上传文件完成后总不能自动拷贝文件链接。允许我用流程图来表示一下项目中的这个功能(这里安利一下processon,在线画流程图非常方便)。

image.png

代码实现也非常简单。

# 将剪贴板中的图片保存成本地图片,利用PIL,这里不详写
def save_tmp_bmp(...):
    ...

# 将本地图片上传到七牛云,具体过程不详写
def qiniu_upload(...):
    upload... # 上传文件
    link = ... # 获取文件在七牛云上的链接
    ui.cb.setText(link) # 向剪贴板中写入链接

class MainUi(...):
    ...
    setupFunction(...):
    # 实例化QClipboard,开启监控
    self.cb = QtWidgets.QApplication.clipboard()
    self.cb.dataChanged.connect(self.monitor_clipboard)
    ...

    def monitor_clipboard(...):
        if self.cb.mimeData.hasImage(): # 当剪贴板中有图片
            save_tmp_image('tmp.png') # 将剪贴板中的图片保存成本地图片tmp.png
            qiniu_upload('tmp.img') # 讲tmp.png上传到七牛云

...
ui = MainUi() # 创建主窗口,实例名称为ui
ui.setupFunction() #执行setupFunction方法
...

看起来合情合理对不对?然而事实上,不管怎么调试,复制图片没问题、剪贴板监控和触发没问题、上传图片没问题,就是最后复制不了链接。通过添加print来看,setText确实运行了,但是剪贴板中最后一条记录任然是之前复制的图片而不是链接。(关于便捷查看剪贴板中的内容,这里安利2个软件:Windows上用Ditto,Mac上用Paste。这2个软件都是各自平台上非常优秀的剪贴板增强软件,可以很大程度上增加日常的办事效率。)

问题分析和解决

经过一番艰苦卓绝的debug,最后发现问题症结所在:
在QClipboard.dataChanged.connect()所链接到的方法中,不能含有QClipboard.setText()
其实也很好理解,这可能是Qt为了防止出现因为剪贴板内容改变引发的死循环触发:

检测到剪贴板内改变 -> setText() -> 检测到剪贴板内改变 -> setText() -> 检测到剪贴板内改变 -> setText()...

不管这个链接的方法多么复杂,在多少个子方法/函数之间跳转,都不可以。这里可以通过一个比较简单的例子来说明一下。且看下面的代码(点击下载完整工程文件

import sys
from PyQt5 import QtWidgets
from src.mainwindow import Ui_MainWindow as MW
import time


class MainUI(MW):
    # 初始化方法,实例化clipboard()
    def __init__(self):
        self.cb = QtWidgets.QApplication.clipboard()

    # 功能设置方法
    def setupFunction(self):
        # 按钮按下时,执行self.set_cb()
        self.pushButton.clicked.connect(self.set_cb)
        # 当剪贴板内容发生改变时,执行self.cb_changed()
        self.cb.dataChanged.connect(self.cb_changed)

    # 当剪贴板内容改变时执行
    # 如果当前剪贴板内容为图片,则转到执行self.set_cb()
    # 如果当前姐铁板内容为其他,则打印信息提示
    def cb_changed(self):
        print('Entering set_cb...')
        mdata = self.cb.mimeData()
        if mdata.hasImage():
            print('Img in clipboard.')
            self.set_cb()
        else:
            print('Not a img in clipboard!')

    # 向剪贴板中写入当前日期
    def set_cb(self):
        localtime = time.asctime(time.localtime(time.time()))
        self.cb.setText(localtime)


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)

    # 主窗口
    MainWindow = QtWidgets.QMainWindow()
    ui = MainUI()
    ui.setupUi(MainWindow)
    ui.setupFunction()

    MainWindow.show()
    sys.exit(app.exec_())

这是个非常简化的应用,注释已经对主要语句做了说明,我也在关键地方增加了print打印,下面是这段代码的流程图。


image.png

运行一下main.py,点击一下中间的按钮,可以看到打印信息。

Write time to cb...   # 点击按钮触发,向剪贴板写入当前时间
Clipboard data changed.  # 写入时间后检测到剪贴板内容变化
Not a img in clipboard!  # 这时剪贴板中是文本不是图片,什么也不做

我们再用Ditto或者Paste查看一下剪贴板内容。


image.png

第一条是当前的时间,并且是文本内容,和打印的信息相符合,说明通过点击按钮向剪贴板写入文本信息没有问题。接着,我们重新运行一下main.py,截一张图(键盘的PrtSc或者QQ截图都可以),再看一下打印信息。

Clipboard data changed.   # 截图之后自然会检测到剪贴板内容变化
Img in clipboard.    # 这时剪贴板中是图片
Write time to cb...    # 根据代码规则,检测到是图片则触发write_time_to_cb,向剪贴板写入当前时间
Clipboard data changed.   # 写入时间后剪贴板再次检测到数据变化
Not a img in clipboard!  # 这时剪贴板中不是图片了,什么都不干

看打印的信息,好像流程没什么问题,这时我们想一下,正常情况下运行完上述代码之后剪贴板会是什么情况?对,第一条应该是当前时间,第二条是刚才截图,第三条是上一次运行时写入的时间。但是打开剪贴板一看,好像并不是那么回事...

image.png

很明显,截图触发的write_time_to_cb并没有成功向剪贴板中写入时间。这就是我说的问题症结所在:这一次,setText()在write_time_to_db()中,而write_time_to_db()在dataChanged的链接中,这时候,为了防止死循环触发(当然我的代码中通过判断是否是图片避免了无限触发),最后一次setText并没有生效。

解决办法

解决办法简单的来说就是:放弃使用QClipboard.dataChanged信号,改用PyQt5的定时器QTimer定期扫描剪贴板内容,发生变化则进入下一步。简单代码如下,对具体实现感兴趣的请自行下载我的项目查看。

class MainUi(...):
    setupFunction(...):
        self.timer_clipboard = QtCore.QTimer()  # 声明定时器
        self.timer_clipboard.timeout.connect(self.monitor_clipboard) # 定时器触发monitor_clipboard方法
        self.timer_clipboard.start(3000)  # 定时器触发间隔为3秒

    def monitor_clipboard(...):
        mdata = self.cb.mimeData()  # 获取剪贴板内容
        if mdata != mdata_tmp:    # 如果当前剪贴板内容mdata和上次剪贴板内容mdata_tmp不一样
            mdata_tmp = mdata   # 讲当前简体版内容赋值到mdata_tmp中
            if mdata.hasImage(): # 当剪贴板中有图片
                save_tmp_image('tmp.png') # 将剪贴板中的图片保存成本地图片tmp.png
                qiniu_upload('tmp.img') # 讲tmp.png上传到七牛云
...       

推荐阅读更多精彩内容