前言

这篇文章 里我就已经尝试使用过 PyQt, 但是当时因为种种原因 (懒) 没能把折腾的过程写下来.

这次重新捡起 PyQt 的原因是在于最近总是发现系统盘的空间以肉眼可见的速度减少, 每次都要手动移动文件夹并加上符号链接, 关键每次使用命令时都要复制链接, 操作十分繁琐, 因此萌生了制作一个 GUI 小工具的想法.

按理来说这类小工具使用 Python 自带的 Tinker 会是一个更方便的选项, 但是由于我不久之后要完成一个比较复杂的 PyQt GUI 程序, 因此想乘着这个机会复习一下 PyQt 的用法.

什么? 你问我说好的 Qt for Python 呢? emmmm…. 总的来说, 这两者在你不需要制作商业应用时是无所谓的, 而且据说 Qt for Python 还有很多坑, 特别是文档还非常破 (虽然 PyQt 也没好到哪里去), 所以现在先这么用着吧, 反正以后都是要转 Electron 的 #滑稽.

PC 桌面 GUI 应用开发方案选择

首先简单介绍一下目前比较常见的一些桌面 GUI 程序开发方案 (由于 Mac 上基本只有 Objective-C/Swift 这个选项, 因此还是只介绍 PC GUI), 这些方案基本上各有各的优缺点, 而且如果你对技术或语言没有偏好的话其实还是挺难做出选择的. 此外还有一些目前仍然很常用但是比较老旧或者比较复杂的技术就被我抛弃了 (有人甚至用 MATLAB 开发 GUI, 佩服佩服).

技术 特点 备注
C# & WinFroms/WPF Windows GUI 最佳选项 宇宙第一 IDE 加成, 拖控件美滋滋, xml 美滋滋, 但跨平台基本没有. 此外 UWP 尚处于发展之中, 巨硬的态度也比较暧昧 (微软商店可以直接提交传统技术转制的 APP), 建议慎重选用 UWP.
C++ & Qt 跨平台应用开发老大哥 针对复杂的, 对性能有需求的, 要跨平台的应用选 Qt 总是没错的.
Python & PyQt/PySide2 糙快猛, 小工具和原型开发好选择 PySide2 就是官方支持的 Qt for Python, 优点是开发简单, Qt Designer 拖控件美滋滋, 缺点是打包麻烦运行慢, 文档没有靠谱的, 教程质量都一般, 不过可以参考 Qt 的文档…又不是不能用. 此外基于 Python 的 GUI 库还有 wxPython, Tinker 等可供选择.
Java & JavaFX/Swing 基于 Java 的跨平台选项 Swing 目前有一些使用 (似乎 JetBrains 家的 IDE 就是用的魔改 Swing 开发的), 而 JavaFX 可以和 JFoenix 搭配使用做出很好看的 Material Design 效果, 但是目前 JavaFX 已经由官方支持转变成社区支持了, 如果你只熟悉 Java, 建议选这个.
Electron 前端开发者/外貌协会首选 除了打包文件巨大, 开发比较麻烦, 性能不咋地之外没有任何缺点, 特别是基于 Web 技术的界面就一个字: 好看

其他还有 VB, Delphi, golang… 不过这些要不是实在是太老了, 要不就是真不成熟或用的人少, 如果你有一颗不怕折腾的心且确实对桌面应用非常感兴趣…..那么我还是建议你选择 Electron 吧.

本文以 PyQt5 + Python 3.6 (Anaconda 5.1) 作为开发环境, 配合 PyCharm 和 QtDesigner 进行开发.

环境配置

安装

理论上来说使用 PyQt5 你只需要安装 Anaconda 就能有一个完整的环境了 (包括 Qt Designer), 因此这一步骤结束.

PyCharm 配置

由于在开发过程中需要用到一些工具, 因此需要对 PyCharm 进行一些额外的配置工作. 当进入项目之后, 打开 PyCharm 的设置 (File -> Settings), 搜索 External Tools 并点击右边的加号, 新增几个外部工具. (Name 和 Description 部分你可以写任意内容)

QtDesigner

按照图中配置即可, 其中被两个 $ 包起来的变量可以在右侧的 Insert Macro... 中找到.

PyUIC

这个工具的作用是将 QtDesigner 产生的 .ui 文件编译为 Python 能读取的 .py 文件.

其中参数部分 (Argument) 的几个参数分别指定了 要处理的文件 ($Filename$), 编译结果文件 (-o $FileNameWithoutExtension$_ui.py, 这里的意思是去掉原来的 .ui 后缀, 加上 _ui.py 后缀), 在编译结果中添加执行部分 (-x, 也就是 if __name__ == "__main__": ... 这段, 主要用于测试界面, 如果不需要的话可以去掉这个参数)

PyRcc

这个工具的作用是将资源文件编译为 .py 文件, 例如图片文件. 参数部分的解释与 PyUIC 类似.

使用外部工具

当你完成上述设置之后, 就可以测试工具了, 在左边的项目视图里找个空白的地方点击右键, 选择 External Tools -> QtDesigner (这个名字就是你在之前制定的 Name 属性).

然后你就能看到弹出的 QtDesigner 窗口了. 另外两个工具可以在对应的 .ui 文件和资源文件上右键调用, 这样就能实现在 PyCharm 里使用外部工具的功能了.

开工!

逻辑

废话不多说, 首先分析一下这个项目的逻辑:

将文件夹从位置 A 迁移到位置 B

  1. 检测 A B 是否存在, 不存在则弹窗报错; 存在则转到 2.
  2. 重命名源文件夹 AA.old 并返回是否成功. 若失败则弹窗报错, 并要求用户确保文件夹可移动; 成功则转至 3.
  3. 将文件夹自 A.old 复制到 B, 返回是否成功. 若失败则弹窗报错但不撤销操作, 将 A.old 重命名为 A, 并提示用户文件夹没有完全复制成功; 成功则转至 4.
  4. 建立一个从 BA 的软链接以保证引用正常. 有两种方法: 1. 通过 MKLINK , 用法请使用 /? 参数详细了解. 需要注意当 MKLINK 返回 0 时表示操作成功; 2. 使用 os.symlink(), 当不出现异常时执行成功, 此时转至 5; 若失败则弹窗提示建立符号链接失败, 并将 A.old 重命名为 A.
  5. 删除文件夹 A.old. 不弹窗提示成功.

此外还要在程序执行的过程中将控件冻结以阻止用户进行多余的操作.

可以看到, 程序似乎做了很多复杂的操作, 但是这些都是为了考虑到移动巨大的文件夹可能带来的性能损耗, 因此只有在确保文件夹可以移动并且保证文件安全的情况下才进行移动.

界面

当理顺逻辑之后, 剩下的工作就是实现它了, 首先我们把界面先做出来. 打开 QtDesigner, 选择 MainWindow, 点击创建.

现在你就可以从左边拖控件到你的界面上了, 具体每个控件的作用是什么应该从名字就能猜出来, 细节可以上网搜一下, 总之, 经过一番操作得到了下面这个界面.

大致用到了网格布局器, 标签, 单行编辑框, 按钮这几个控件, 注意底端的空白处有一个状态栏. 在右侧的属性编辑器编辑各个对象的属性.

  • objectName, 对象名称, 用于在程序中调用这个对象.
  • geometry, 对象的位置和大小. 在布局中的对象不需要调整此项.
  • minimunSizemaximunSize, 对象的最小最大属性, 这里主要是针对 MainWindow 对象, 也就是整个窗口对象, 当这两个值都确定且相等之后, 窗口就不能调整大小了.
  • font, 选择一个好看的字体以及是否粗体, 如果调整父对象的该属性将会影响到子对象的该属性.
  • windowTitle, text, 对象显示的内容, 注意和对象名称区分.
  • alignment, 对象显示内容的对齐方式.
  • sizeGripEnabled, 状态栏右下角的小三角形.

现在, 经过一番设置, 得到了一个新的界面:

自此界面部分就结束了, 保存文件, 返回 PyCharm.

界面编译

在左侧的项目视图里右键 .ui 文件, 选择 PyUIC, 等待执行.

接下来你会发现在左侧出现了一个 原文件名_ui.py 的文件, 打开并执行 (执行当前 .py 文件快捷键 Ctrl + Shift + F10) 这个 Python 文件.

功能实现

一般来说, 遵循着界面逻辑分离的原则, 我们需要在另外的地方实现应用具体的功能, 因此我们新建一个文件, 在新的文件里以继承的方式创建一个新的类, 我们接下来的工作, 包括实际上运行的就是这个类了. (这也能避免我们后期在修改 .ui 文件时覆盖我们的修改)

在新的文件里先加入基本的代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from directory_porter_ui import Ui_MainWindow


class Porter(Ui_MainWindow):
def __init__(self):
# 设置版本和作者信息, 注意这里不需要调用父类的构造函数
self.__version__ = '0.0.1'
self.__author__ = 'ZhZYX (i@zhzyx.me)'


# 这部分代码是自动生成的 (从 _ui.py 复制来的), 修改一下类名即可
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Porter() # 修改部分
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec_())

然后测试一下这个文件能不能正常显示界面, 如果成功, 则继续下一步.

权限检查

由于删除文件可能涉及到高级权限, 因此应当在程序执行之前检查是否具有管理员权限, 如果没有, 则应当以管理员权限重启应用:

__init__() 函数, 也就是初始化界面时检查权限:

1
2
3
4
5
6
# 检查是不是管理员权限
if not ctypes.windll.shell32.IsUserAnAdmin():
# 如果不是, 则以管理员权限重启应用
ctypes.windll.shell32.ShellExecuteW(
None, "runas", sys.executable, __file__, None, 1)
sys.exit() # 退出当前程序

设置槽函数并连接

关于槽函数的定义请搜索 PyQt 信号与槽, 或者参考文档: Support for Signals and Slots — PyQt Reference Guide, 这里仅做出简要解释: PyQt 在控件中提供了一系列的动作 (例如点击, 改变状态等等), 每个动作将发出 (emit) 一个特定的信号 (signal), 同时我们可以在程序中定义一些特殊的函数, 这些函数会在信号出现时被调用, 这种函数被称为槽函数 (slot).

一个槽函数的结构可以基本上写成这样:

1
2
3
4
# 注意这里有括号且括号里是可以带参数的, 具体参考上面给出的文档
@QtCore.pyqtSlot()
def on_[控件名称]_[动作类型](self):
pass

同时我们还需要在类初始化的时候将信号和对应的槽函数连接起来, 例如在 __init__() 函数中连接一个名称为 button1 的按键按下时调用对应的槽函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainWindow(object):
def __init__(self):
self.connect_slots()
# 还需要设置 UI, 这里省略了创建控件的步骤

self.button1.clicked.connect(
self.on_button1_clicked)

@QtCore.pyqtSlot()
def on_button1_clicked(self):
# 函数名称是随意的, 不过这样的名称比较有规律
# 但是版本的 PyQt 似乎会自动连接这样格式的槽函数
# 具体你可以在测试时在控制台中打印一些数据
print('on_button1_clicked')
# 当打印结果出现两次时槽函数就有可能被自动连接了
# 参考: https://stackoverflow.com/questions/27582796/pyqt-slot-is-called-many-times

# do something
return

知道这么连接槽函数之后, 接下来的工作就是重复了, 为方便起见, 我把所有的连接操作拿出来单独定义了一个 connect_slots 函数, 并在设置完 UI 后调用它, 下面的代码有缩减.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Porter(Ui_MainWindow):
def __init__(self):
self.__version__ = '0.0.1'
self.__author__ = 'ZhZYX (i@zhzyx.me)'

def connect_slots(self):
self.pushButton_selectSource.clicked.connect(
self.on_pushButton_selectSource_clicked)

@QtCore.pyqtSlot()
def on_pushButton_selectSource_clicked(self):
pass

if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Porter()
ui.setupUi(MainWindow)
ui.connect_slots() # 先 setupUI, 才能连接函数
MainWindow.show()
sys.exit(app.exec_())

由于连接函数需要在对象初始化之后 (也就是 setupUI() 函数执行之后) 才能找到控件对象, 此时需要在执行部分增加调用 connect_slots() 的部分, 为了结构上的优雅, 我还是把设置 UI 的工作放到了窗口类里, 并修改了最后的执行部分.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from directory_porter_ui import Ui_MainWindow


class Porter(Ui_MainWindow):
def __init__(self, window):
# 把 QMainWindow 当参数传进来
self.__version__ = '0.0.1'
self.__author__ = 'ZhZYX (i@zhzyx.me)'

self.setupUi(window) # 设置 UI
self.connect_slots()

def connect_slots(self):
# 连接槽函数
self.pushButton_selectSource.clicked.connect(
self.on_pushButton_selectSource_clicked)
self.pushButton_selectTarget.clicked.connect(
self.on_pushButton_selectTarget_clicked)
self.pushButton_execute.clicked.connect(
self.on_pushButton_execute_clicked)

@QtCore.pyqtSlot()
def on_pushButton_selectSource_clicked(self):
pass

@QtCore.pyqtSlot()
def on_pushButton_selectTarget_clicked(self):
pass

@QtCore.pyqtSlot()
def on_pushButton_execute_clicked(self):
pass


if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Porter(MainWindow)
MainWindow.show()
sys.exit(app.exec_())

当完成函数的连接之后, 我们实际上已经实现了对空间交互的相应, 只是我们的响应是什么都不做而已, 如果你想要看看效果, 你可以在槽函数体内部进行一些测试.

打开文件夹

Qt 内置了一个弹出打开文件夹窗口的函数 QtWidgets.QFileDialog.getExistingDirectory(), 返回的值是选中文件夹的绝对位置, 当什么都不选, 直接点确定时, 返回空的链接.

当直接使用 QtWidgets.QFileDialog.getExistingDirectory(self, "Select Source directory") 发现报错:

1
2
3
4
TypeError:
getExistingDirectory(parent: QWidget = None, caption: str = '', directory: str = '',
options: Union[QFileDialog.Options, QFileDialog.Option] = QFileDialog.ShowDirsOnly):
argument 1 has unexpected type 'Porter'

大概可以猜出来是因为 Porter 类以及它的父类都不是 PyQt 中的类型, 可能缺少一些功能, 在调用函数时传进的第一个参数 self 类型不对, 因此 Porter 还需要额外从 QtWidgets.QMainWindow 中继承一些信息, 此时使用多重继承即可. 同时在 __init__() 函数里调用一下 QtWidgets.QMainWindow 的构造程序:

1
2
3
4
5
6
class Porter(Ui_MainWindow, QtWidgets.QMainWindow):
def __init__(self, window):
# PyQt 规定的写法, 要加上 parent, 不过不加似乎也没事
QtWidgets.QMainWindow.__init__(self, parent=None)

# 其他代码

再次尝试发现能够弹出文件夹选择窗口了, 将返回值检查一下, 如果存在则把这个值传递给 lineEditsetText 函数, 将内容显示在文本框中.

1
2
3
4
5
6
7
8
# 以 Source 文件夹选择为例
@QtCore.pyqtSlot()
def on_pushButton_selectSource_clicked(self):
self.statusbar.showMessage('')
source_path = QtWidgets.QFileDialog.getExistingDirectory(
self, "Select Source directory")
if source_path:
self.lineEdit_source.setText(source_path)

用同样的方式实现目标文件夹选择功能.

实现执行按钮

冻结控件

大部分 PyQt 控件都提供了一个 setEnabled() 的函数来控制控件是否可用, 因此直接调用即可.

1
2
3
4
5
6
def set_enable(self, flag=True):
self.lineEdit_target.setEnabled(flag)
self.lineEdit_source.setEnabled(flag)
self.pushButton_execute.setEnabled(flag)
self.pushButton_selectTarget.setEnabled(flag)
self.pushButton_selectSource.setEnabled(flag)

同时由于这个函数的作用是在执行复制时禁用控件的交互, 而在执行结束或者执行出错时恢复交互, 因此可以考虑将完整的处理逻辑封装为一个 execute_migration() 函数, 在点击执行按钮后, 分别执行冻结->执行->解冻操作. 实际上如果你了解装饰器的话, 你会发现这和装饰器非常类似.

1
2
3
4
5
6
7
8
@QtCore.pyqtSlot()
def on_pushButton_execute_clicked(self):
self.set_widget_enable(False)
self.execute_migration()
self.set_widget_enable(True)

def execute_migration(self):
pass

文件夹存在检测

使用 os.path.exist() 函数即可检查文件夹是否存在. os.path.isdir()os.path.isfile() 用来检测是文件还是文件夹. 这三个函数的返回值都是一个布尔值. 可以在执行按钮被按下时, 首先检查两个文本框中的链接是否是合法的文件夹, 再执行接下来的操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def execute_migration(self):
source_path = self.lineEdit_source.text()
target_path = self.lineEdit_target.tex
if not self.check_paths(source_path, target_path):
return False

def check_paths(self, source_path, target_path):
def check_dir(dir_path):
if not os.path.isdir(dir_path):
return False
return True

if not (check_dir(source_path) and check_dir(target_path)):
return False # 目标不存在或者不是文件夹则中止
if source_path == target_path:
return False # 两个文件夹不能相同
if os.path.split(source_path)[0] == target_path:
return False
return True

重命名

使用 os.rename() 函数重命名文件夹. 注意, 该函数没有返回值, 因此需要使用 try 语法来检测是否出错, 并给出相应的处理方法.

当出现错误时, 要求弹出警告. 最后返回重命名是否成功.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def execute_migration(self):
# ...
raw_source_name = os.path.split(source_path)[1]
temp_source_path = source_path + '.old'
full_target_path = os.path.join(
target_path + '/' + raw_source_name)

if not self.rename_dir(source_path, temp_source_path):
return False # 重命名失败则中止

def rename_dir(self, src, dst):
if src and dst: # 两者同时存在
try:
os.rename(src, dst)
except FileNotFoundError:
return False # src 未找到
except FileExistsError:
return False # dst 已经存在
except Exception as e:
pass # 其他未知错误
return True
else:
# src 或 dst 不能为空
return False

这里的路径存在检查看上去似乎与前面的文件夹存在检测重复, 因此你可以选择去掉这部分的检查, 我这样写的目的是以后如果要用到这段代码的话可以直接复制而不再需要进一步考虑函数的执行条件. 特别地, rename_dir() 的条件要比 check_paths() 更宽松, 这是因为 rename_dir() 实际上也能修改文件的名字.

复制

使用 shutil 模块提供的 copytree 移动文件夹, 这样就可以不用考虑文件夹是否有内容了. 整体的思路是先把源文件夹 (改名后) 的名称取出, 然后拼接到目标文件夹结尾, 检查目标链接是否可用, 不可用则报错 (实际上不需要检查, 而是让命令执行并检查错误).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def execute_migration(self):
# ...
if not self.copy_dir(temp_source_path, full_target_path):
# 失败则复原名称
self.rename_dir(temp_source_path, source_path)
return False

def copy_dir(self, source_path, full_target_path):
try:
shutil.copytree(source_path, full_target_path)
except FileExistsError: # 目标已经存在, 要求用户手动删除
# 帮用户打开资源管理器, 位置是目标文件夹的父文件夹
os.startfile(os.path.split(full_target_path)[0])
except Exception as e:
pass # 未知异常
else: # 没有异常发生
return True
return False # 有异常发生

创建软链接

两种方法: 1.调用系统命令 MKLINK, 2. 使用 os.symlink().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 方法 1, 使用 `os.system()` 调用系统命令
# 这里只需要注意使用双引号以避免路径中的空格就行了
self.statusbar.showMessage('正在建立符号链接...')
status = os.system(
f'MKLINK /D "{source_path}" "{full_target_path}"')
if status != 0:
self.warning('创建链接失败', f'错误码: {status}')
self.rename_dir(source_path, source_path[:-4])
return False

# 方法2, 使用 `os.symlink()` 创建链接
def make_link(self, source_path, full_target_path):
if not os.path.isdir(full_target_path):
# 链接目标不是文件夹
return False

try:
# 注意这里第一个参数是文件实际的位置
# 第二个参数是要设置软链接的位置
os.symlink(full_target_path, source_path)
except FileExistsError:
# 存在同名文件夹, 要求用户手动移除
print(os.path.split(full_target_path)[0])
os.startfile(os.path.split(full_target_path)[0])
except Exception as e:
pass # 未知错误
else: # 没有异常发生
return True
return False # 有异常发生

删除

shutil.copytree() 类似, 使用 shutil.rmtree() 删除目录.

1
2
3
4
5
6
7
def remove_dir(self, dir_path):
try:
shutil.rmtree(dir_path)
except Exception as e:
return False
else:
return True

控制状态栏

总的来说, 使用状态栏只需要关注一个函数即可: showMessage(msg), 使用这个函数能够设置状态栏内容. 对于大部分项目来说这就足够了.

1
2
3
def execute_migration(self):
self.statusbar.showMessage('执行文件迁移中...')
pass

此外还可以优化一下状态栏的显示, 例如当按下任意选择文件夹按钮时, 将状态栏进行修改等等.

弹窗警告

为了让用户交互感受更好, 在出现错误时应当能够提示用户错误类型, 并指导用户对错误进行处理, 使用 QtWidgets.QMessageBox.warning() 来弹出错误窗口

1
2
3
4
def warning(self, title, msg):
QtWidgets.QMessageBox.warning(
self, title, msg, QtWidgets.QMessageBox.Ok)
self.statusbar.showMessage(title)

在其他地方使用 warning(弹窗标题, 弹窗内容) 来实现弹窗.

测试与使用

接下来就到了测试的时间了, 当然其实我是每写一段代码都会进行一次测试, 所以最后的代码只需要修改几个小问题就可以了. 现在, 一个小型的文件夹迁移工具就实现了, 再也不用为 C 盘的空间担心了嘤嘤嘤.

特别地, 当我们使用程序时, 可以使用 pythonw filename.py 来调用我们的脚本, 这样就不会显示控制台了.

完整版代码

我已经将完整版的代码 (主要是增加了提示和报错) 上传到了 zhzyX/directory_porter: 一个基于 PyQt5 的 Windows 简易文件夹迁移小工具, 你可以到这里阅读详细的代码, 如果有更多问题或者想要交流的部分, 你可以在这篇文档留言, 给我发邮件, 或者 到 Github 提交 issue, 如果你发现代码中存在问题也欢迎你告诉我.

后记

其实这篇文章在现在的参考价值已经不高了, 因为当前 Qt 开发实际上有一个更好的选择, 就是使用 QML 开发界面和逻辑, 然后使用 Python 或 C++ 驱动, 99% 的代码都写在 QML 里面, 而 QML 实际上就是魔改的 JavaScript 语言, 通过这样的方式能够做出很好看的效果.

使用 QML 配合 Material 控件的效果

使用 QML 配合 Material 控件的效果

官网风格展示图片

官网风格展示图片

实际上除了 Material 风格还有巨硬的 Universal 风格, 你可以参考 Styling Qt Quick Controls 2 | Qt Quick Controls 2 来进一步了解.

另外不得不说一句的就是, 用 PyQt 不仅能让你学习它本身, 还能让你学习 Qt 和 C++, 毕竟 PyQt 的资料实在还是太少了… (而如果你想要使用 QML 的话, 你还能顺便学一学 Javascript, 不亏.jpg

Javascript 天下第一 !