Python目前已经是一个“家喻户晓”的名词了,她可能用在很多行业领域,最牛逼的人工智能(AI)、大数据(big data)。今天要介绍的是Python图形化界面实现(GUI),记得在Python刚出来的时候,开发一个功能强大而美观的GUI还是挺不方便的,最早期也就是Python自己的一个GUI模块库,叫Tkinter,当然现在这个模块功能与比当初要丰富很多了,除此之外,像WxPython、pyGtk、Jython、Pywin32、PyQt、PySide\PySide2等等。这么多的图形开发工具中,个人比较喜欢的是PySide2。下面重点要给大家介绍的是,如何将开发好的程序打包成一个可执行文件发布,可以在别人电脑上使用(这里仅介绍在Windows电脑上使用)。
PySide2介绍
PySide2其实是Python版的Qt,在Qt官方叫"Qt for Python",这样说大家应该就明白了,其实就是Qt的各种图形化库、功能模块库给Python开发调用,在Python中导入一个模块相信大家都知道"import",所以使用PySide2就这么简单。而需要学的,就是"Qt for Python"相关的API函数接口的使用,它按模块分为很多API,比如图形控件类API、网络通讯类API、串口类API、Web相关API等等,我们需要什么API就去看这些API就可以了,就这么easy!
另外,要说明一下PySide2是Qt这家公司的亲儿子,好比kotlin语言是google公司为Android APP开发而准备的一门语言一样(最初Android APP开发只能用java语言开发),现在我们发现很多Android APP都是Kotlin开发,而且功能支持也是Kotlin语言越来越好,毕竟是亲生的,所以PySide2也是这样的。那领养的又是谁呢,就是上面我提到的PyQt,它是一个专门的公司为Qt而开发Python相关API的公司,当然了,它比PySide2要出世早了。
关于版本问题,PySide2是基于Qt5而开发的,而她前面还有一个PySide,是早期的版本,基于Qt4的,也是最早的版本,现在几乎没人用了,原因是功能还不完善。而最新的是PySide6,是不是感觉这跳得有点快,从2一下子跳到6,可能是跟随老子的步伐吧,Qt现在发展到6.0版本,所以直接就PySide6了,但6.0版本由于很多API还没有完全支持,所以本文以PySide2介绍,她几乎与Qt5是同步的,Qt5有的功能她几乎也是能实现的。
(PyQt—>PyQt5—>PyQt6这是领养的版本发展图)
打包神器Pyinstaller
打包python的工具很多,除这个,比如py2exe,cx_freeze也都是不错的工具。
1.安装Pyinstaller打包工具
Microsoft Windows [版本 10.0.19041.867]
(c) 2020 Microsoft Corporation. 保留所有权利。
C:\Users\Gary>pip install -i https://pypi.douban.com/simple pyinstaller
Looking in indexes: https://pypi.douban.com/simple
Collecting pyinstaller
Downloading https://pypi.doubanio.com/packages/b4/83/9f6ff034650abe9778c9a4f86bcead63f89a62acf02b1b47fc2bfc6bf8dd/pyinstaller-4.2.tar.gz (3.6 MB)
|████████████████████████████████| 3.6 MB 46 kB/s
Installing build dependencies ... done
Getting requirements to build wheel ... done
Preparing wheel metadata ... done
Collecting altgraph
Downloading https://pypi.doubanio.com/packages/ee/3d/bfca21174b162f6ce674953f1b7a640c1498357fa6184776029557c25399/altgraph-0.17-py2.py3-none-any.whl (21 kB)
Collecting pyinstaller-hooks-contrib>=2020.6
Downloading https://pypi.doubanio.com/packages/27/c7/58a634d861e4744ac62dca4a4992ace8def8b05dab91e6b25e5043e79acf/pyinstaller_hooks_contrib-2021.1-py2.py3-none-any.whl (181 kB)
|████████████████████████████████| 181 kB ...
Requirement already satisfied: setuptools in d:\users\python\python39\lib\site-packages (from pyinstaller) (49.2.1)
Collecting pefile>=2017.8.1; sys_platform == "win32"
Downloading https://pypi.doubanio.com/packages/36/58/acf7f35859d541985f0a6ea3c34baaefbfaee23642cf11e85fe36453ae77/pefile-2019.4.18.tar.gz (62 kB)
|████████████████████████████████| 62 kB 408 kB/s
Collecting pywin32-ctypes>=0.2.0; sys_platform == "win32"
Downloading https://pypi.doubanio.com/packages/9e/4b/3ab2720f1fa4b4bc924ef1932b842edf10007e4547ea8157b0b9fc78599a/pywin32_ctypes-0.2.0-py2.py3-none-any.whl (28 kB)
Collecting future
Downloading https://pypi.doubanio.com/packages/45/0b/38b06fd9b92dc2b68d58b75f900e97884c45bedd2ff83203d933cf5851c9/future-0.18.2.tar.gz (829 kB)
|████████████████████████████████| 829 kB 819 kB/s
Using legacy 'setup.py install' for pefile, since package 'wheel' is not installed.
Using legacy 'setup.py install' for future, since package 'wheel' is not installed.
Building wheels for collected packages: pyinstaller
Building wheel for pyinstaller (PEP 517) ... done
Created wheel for pyinstaller: filename=pyinstaller-4.2-py3-none-any.whl size=2413076 sha256=fa9cdc6ec88e2d0c3df74e7aec0f71330feb1b2b6fb751f188bb498631837d06
Stored in directory: c:\users\gary\appdata\local\pip\cache\wheels\af\ea\26\5812f58861dc385ff5573249b18dfe1624c5f84ea67fa76397
Successfully built pyinstaller
Installing collected packages: altgraph, pyinstaller-hooks-contrib, future, pefile, pywin32-ctypes, pyinstaller
Running setup.py install for future ... done
Running setup.py install for pefile ... done
Successfully installed altgraph-0.17 future-0.18.2 pefile-2019.4.18 pyinstaller-4.2 pyinstaller-hooks-contrib-2021.1 pywin32-ctypes-0.2.0
WARNING: You are using pip version 20.2.3; however, version 21.0.1 is available.
You should consider upgrading via the 'd:\users\python\python39\python.exe -m pip install --upgrade pip' command.
C:\Users\Gary>
可以查看Python第三方包所在目录,发现多了两个:(Pywin32会被同时安装)
2.Pyinstaller常用指令参数
下面挑了一些常用参数:
D:\py\MFGTool>pyinstaller --help
usage: pyinstaller [-h] [-v] [-D] [-F] [--specpath DIR] [-n NAME] [--add-data <SRC;DEST or SRC:DEST>]
[--add-binary <SRC;DEST or SRC:DEST>] [-p DIR] [--hidden-import MODULENAME]
[--additional-hooks-dir HOOKSPATH] [--runtime-hook RUNTIME_HOOKS] [--exclude-module EXCLUDES]
[--key KEY] [-d {all,imports,bootloader,noarchive}] [-s] [--noupx] [--upx-exclude FILE] [-c] [-w]
[-i <FILE.ico or FILE.exe,ID or FILE.icns or "NONE">] [--version-file FILE] [-m <FILE or XML>]
[-r RESOURCE] [--uac-admin] [--uac-uiaccess] [--win-private-assemblies] [--win-no-prefer-redirects]
[--osx-bundle-identifier BUNDLE_IDENTIFIER] [--runtime-tmpdir PATH] [--bootloader-ignore-signals]
[--distpath DIR] [--workpath WORKPATH] [-y] [--upx-dir UPX_DIR] [-a] [--clean] [--log-level LEVEL]
scriptname [scriptname ...]
positional arguments:
scriptname name of scriptfiles to be processed or exactly one .spec-file. If a .spec-file is specified,
most options are unnecessary and are ignored.
optional arguments:
-h, --help show this help message and exit
-v, --version Show program version info and exit.
--distpath DIR Where to put the bundled app (default: .\dist)指定打包后的文件保存在哪里,默认是dist这个目录
--workpath WORKPATH Where to put all the temporary work files, .log, .pyz and etc. (default: .\build)
-y, --noconfirm Replace output directory (default: SPECPATH\dist\SPECNAME) without asking for confirmation
--upx-dir UPX_DIR Path to UPX utility (default: search the execution path)
-a, --ascii Do not include unicode encoding support (default: included if available)
--clean Clean PyInstaller cache and remove temporary files before building.打包成功后会把打包过程中产生的文件清理掉
-D, --onedir Create a one-folder bundle containing an executable (default) 这是默认的,会把与可执行文件相关的所有文件都复制到目录dist中
-F, --onefile Create a one-file bundled executable. 这只会生成一个exe文件,文件会很大
--hidden-import MODULENAME, --hiddenimport MODULENAME
Name an import not visible in the code of the script(s). This option can be used multiple
times.隐藏掉某些模块,这些模块通常不是实际程序中的,是编译器、或者打包器所需要的模块,如果不隐藏会打包不成功,说缺少某个模块
-p DIR, --paths DIR A path to search for imports (like using PYTHONPATH). Multiple paths are allowed, separated by
';', or use this option multiple times
指定需要包含的库的路径,如果没有要指定的这个参数是不需要的
-c, --console, --nowindowed
Open a console window for standard i/o (default). On Windows this option will have no effect
if the first script is a '.pyw' file.
-w, --windowed, --noconsole
Windows and Mac OS X: do not provide a console window for standard i/o. On Mac OS X this also
triggers building an OS X .app bundle. On Windows this option will be set if the first script
is a '.pyw' file. This option is ignored in *NIX systems.
上面-c或-w指打包后是否要显示控制台,也就是类似dos这样一个界面
打包程序
GUI界面是动态方式加载:
import os
import sys
from PySide2.QtWidgets import QApplication, QMainWindow, QMessageBox
from PySide2.QtCore import QFile, QRegExp, QByteArray, QIODevice
from PySide2.QtUiTools import QUiLoader
from PySide2.QtGui import QRegExpValidator
from PySide2 import QtSerialPort
#from ui_mfgtool import Ui_MfgTool
class MfgTool(QMainWindow):
def __init__(self):
super(MfgTool, self).__init__()
self.load_ui()
#self.ui = Ui_MfgTool() #静态加载UI
#self.ui.setupUi(self)
self.initUI()
self.ConnectFun()
self.m_ba = QByteArray(b"")
def load_ui(self):
loader = QUiLoader() #动态加载UI
path = os.path.join(os.path.dirname(__file__), "mfgtool.ui")
ui_file = QFile(path)
ui_file.open(QFile.ReadOnly)
self.ui = loader.load(ui_file, self)
ui_file.close()
self.ui.show()
执行打包指令如下:
D:\py\MFGTool>pyinstaller mfgtool.py
171 INFO: PyInstaller: 4.2
171 INFO: Python: 3.9.2
171 INFO: Platform: Windows-10-10.0.19041-SP0
171 INFO: wrote D:\py\MFGTool\mfgtool.spec
171 INFO: UPX is not available.
188 INFO: Extending PYTHONPATH with paths
['D:\\py\\MFGTool', 'D:\\py\\MFGTool']
202 INFO: checking Analysis
327 INFO: Building because hiddenimports changed
327 INFO: Initializing module dependency graph...
327 INFO: Caching module graph hooks...
344 WARNING: Several hooks defined for module 'win32ctypes.core'. Please take care they do not conflict.
359 INFO: Analyzing base_library.zip ...
4468 INFO: Processing pre-find module path hook distutils from 'd:\\users\\python\\python39\\lib\\site-packages\\PyInstaller\\hooks\\pre_find_module_path\\hook-distutils.py'.
4468 INFO: distutils: retargeting to non-venv dir 'd:\\users\\python\\python39\\lib'
.........
23921 INFO: Bootloader d:\users\python\python39\lib\site-packages\PyInstaller\bootloader\Windows-64bit\run.exe
23921 INFO: checking EXE
23953 INFO: Building because icon changed
23953 INFO: Building EXE from EXE-00.toc
23953 INFO: Copying icons from ['d:\\users\\python\\python39\\lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-console.ico']
24140 INFO: Writing RT_GROUP_ICON 0 resource with 104 bytes
24140 INFO: Writing RT_ICON 1 resource with 3752 bytes
24140 INFO: Writing RT_ICON 2 resource with 2216 bytes
24140 INFO: Writing RT_ICON 3 resource with 1384 bytes
24140 INFO: Writing RT_ICON 4 resource with 37019 bytes
24140 INFO: Writing RT_ICON 5 resource with 9640 bytes
24140 INFO: Writing RT_ICON 6 resource with 4264 bytes
24140 INFO: Writing RT_ICON 7 resource with 1128 bytes
24156 INFO: Appending archive to EXE D:\py\MFGTool\build\mfgtool\mfgtool.exe
24327 INFO: Building EXE from EXE-00.toc completed successfully.
24327 INFO: checking COLLECT
WARNING: The output directory "D:\py\MFGTool\dist\mfgtool" and ALL ITS CONTENTS will be REMOVED! Continue? (y/N)y
On your own risk, you can use the option `--noconfirm` to get rid of this question.
36014 INFO: Removing dir D:\py\MFGTool\dist\mfgtool
36093 INFO: Building COLLECT COLLECT-00.toc
37921 INFO: Building COLLECT COLLECT-00.toc completed successfully.
D:\py\MFGTool>
直接到dist\mfgtool\目录下运行打包后的mfgtool.exe,发现直接弹出一个框报错:
上面都是默认参数,带有控制台的(运行时类似DOS的界面)。
下面直接在命令行运行:
D:\py\MFGTool\dist\mfgtool>mfgtool.exe
Traceback (most recent call last):
File "mfgtool.py", line 8, in <module>
File "C:\Users\Gary\AppData\Local\Temp\embedded.r2luoun3.zip\shibokensupport\__feature__.py", line 142, in _import
ImportError: could not import module 'PySide2.QtXml'
[544] Failed to execute script mfgtool
D:\py\MFGTool\dist\mfgtool>
运行提示缺少’PySide2.QtXml’,这个库其实在我们的实际代码开发中并没有,而是打包时需要的,所以要用上面提到的参数"–hidden-import"将其隐藏。另外,为什么打包时先带上控制台,因为这样方便查找问题,如果没有控制台直接运行,就看不到真正出错在哪里。
下面带上这个参数再试一下:
D:\py\MFGTool>pyinstaller mfgtool.py --hidden-import PySide2.QtXml
D:\py\MFGTool\dist\mfgtool>mfgtool.exe
QIODevice::read (QFile, "mfgtool.ui"): device not open
Designer: An error has occurred while reading the UI file at line 1, column 0: Premature end of document.
Traceback (most recent call last):
File "mfgtool.py", line 245, in <module>
File "mfgtool.py", line 17, in __init__
File "mfgtool.py", line 29, in load_ui
RuntimeError: Unable to open/read ui device
[440] Failed to execute script mfgtool
D:\py\MFGTool\dist\mfgtool>
运行后以报了一个新错,“RuntimeError: Unable to open/read ui device”,这是因为我们的程序加载界面是动态的,需要将界面对应的.ui文件复制到exe程序所在位置。我们这个程序是一个串口通讯演示程序,对应的ui界面文件叫mfgtool.ui,所以将这个文件复制到mfgtool.exe(打包后的可执行文件)所在目录,也就是默认dist\mfgtool\下面。
复制进去后直接双击exe文件就可看到UI界面,这时会在后面看到一个黑色的dos界面,去掉的方法就是加一个参数“-w”重新打包一下,然后再运行就看不到后面的控制台窗口了,如下:
D:\py\MFGTool>pyinstaller mfgtool.py --hidden-import PySide2.QtXml -w
动态加载UI运行后的程序界面如下:
这个程序界面其实有一个地方显示不全,下面是程序初化的部分代码,从代码中可以看到,我们有设置这个程序的主题title,但这里的主题名称却是“Mfgtool”(默认的主题名)。
def initUI(self):
desktop = QApplication.desktop()
self.screenWidth = desktop.width() * 0.4
self.screenHeight = desktop.height() * 0.6
# print("Screen width:", self.screenWidth, "height:", self.screenHeight)
self.setGeometry(0, 0, self.screenWidth, self.screenHeight)
self.setWindowTitle("MfgTool V1.0.0") #正确应该是self.ui.setWindowTitle("MfgTool V1.0.0")
以上这个问题,其实是动态加载UI的一点不足,这可能是PySide2设计的问题,也许后续会有改善。下面说下出现这个问题的场景:
-
UI的加载过程放在一个自定义的类中
-
在main主函数中创建上面这个类的实例
-
代码程序是这样的:
class MfgTool(QMainWindow):
def __init__(self):
super(MfgTool, self).__init__()
self.load_ui()
def load_ui(self):
loader = QUiLoader()
path = os.path.join(os.path.dirname(__file__), "mfgtool.ui")
ui_file = QFile(path)
ui_file.open(QFile.ReadOnly)
self.ui = loader.load(ui_file, self)
ui_file.close()
self.ui.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
mainUI = MfgTool()
sys.exit(app.exec_())
上面动态加载UI场景存在的问题:
-
self是我们自定义类的,不是ui的,所以上面主题设置无效
-
1的问题不算大问题,最要命的问题是,我们的主窗口QMainWindow与我们的ui这个窗口相互独立了,也就是说不会在主窗口QMainWindow中加载ui。所以上面代码运行后界面就如上图所示,但细心的人可能会发现怎么在我的win10(本人开发主机是win10)任务栏里没有看到程序图标啊,为什么没有呢,因为那个图标是主程序的,而上面代码主程序QMainWindow根本就没有show出来,那下面我们就show出来看看,需要在main中添加一行:
mainUI.show()
或者,直接在load_ui函数的末尾添加一行,效果也是一样:
self.show()
显示的界面怎么是两个,的确是两个,那个空的是QMainWindow主程序的,而且win10任务栏也有一个主程序图标(默认是python图标),这个显示结果也说明了上面2的问题,主程序与我们UI是两个独立的界面。
- 另外,上面问题2还会带来一系列问题,比如不会触发QMainWindow窗口的事件等等。当然要能接收窗口事件,需要重写对应的事件接口,而且这样重写出来的效果也不是最佳,所以这里不作详细说明了。同样的问题,别人也遇到过,下面贴一段老外写的贴子,大致问题跟我上面遇到的是一样的。
https://stackoverflow.com/questions/53828666/pyside2-qmainwindow-loaded-from-ui-file-not-triggering-window-events
总结:
- 看到这,是不是对PySide2开发python GUI有点失望了,不用担心,这只是PySide2的一个设计,如果研究过PyQt5,你就会发现它没有这个问题,它是将ui作为主界面QMainWindow的一个部件来处理的,也就是类中的父子关系来处理。
另外,按Qt官方的做法,将加载ui的动作直接放在main里,也就不会有上面这些问题了,下面是直接copy官方的例子:
# File: main.py
import sys
from PySide2.QtUiTools import QUiLoader
from PySide2.QtWidgets import QApplication
from PySide2.QtCore import QFile, QIODevice
if __name__ == "__main__":
app = QApplication(sys.argv)
ui_file_name = "mainwindow.ui"
ui_file = QFile(ui_file_name)
if not ui_file.open(QIODevice.ReadOnly):
print("Cannot open {}: {}".format(ui_file_name, ui_file.errorString()))
sys.exit(-1)
loader = QUiLoader()
window = loader.load(ui_file)
ui_file.close()
if not window:
print(loader.errorString())
sys.exit(-1)
window.show()
sys.exit(app.exec_())
- 最好的UI调用方式,采用静态方式加载,这样事件也可以覆盖,非常类似于直接使用Qt IDE开发工具一样方便。
GUI界面是静态方式加载:
首先,PySide2提供了一个工具,用来将ui转成py文件:
pyside2-uic mfgtool.ui > ui_mfgtool.py
转换后的py文件内容,如果你对Qt开发很熟悉,很像是Qt Creator对ui界面文件转化后的文件,是的,其实它们原理是类似的,都是将UI内容构建成一个类,然后直接调用这个类就可以了。下面是部分转换后的代码:
## Created by: Qt User Interface Compiler version 5.15.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
class Ui_MfgTool(object):
def setupUi(self, MfgTool):
if not MfgTool.objectName():
MfgTool.setObjectName(u"MfgTool")
MfgTool.resize(800, 600)
MfgTool.setStyleSheet(u"QLabel{\n"
"font:20px;\n"
"}\n"
"QPushButton{\n"
"font:25px;\n"
"}\n"
"QComboBox,QLineEdit{\n"
"font:18px;\n"
"}")
self.centralwidget = QWidget(MfgTool)
self.centralwidget.setObjectName(u"centralwidget")
self.label_port = QLabel(self.centralwidget)
self.label_port.setObjectName(u"label_port")
self.label_port.setGeometry(QRect(139, 30, 141, 31))
self.label_port.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter)
......
MfgTool.setStatusBar(self.statusbar)
self.retranslateUi(MfgTool)
QMetaObject.connectSlotsByName(MfgTool)
# setupUi
def retranslateUi(self, MfgTool):
MfgTool.setWindowTitle(QCoreApplication.translate("MfgTool", u"MfgTool", None))
self.label_port.setText(QCoreApplication.translate("MfgTool", u"Serial Port:", None))
self.pushButton_refresh.setText(QCoreApplication.translate("MfgTool", u"Refresh", None))
self.pushButton_open.setText(QCoreApplication.translate("MfgTool", u"Open", None))
self.pushButton_read.setText(QCoreApplication.translate("MfgTool", u"Read", None))
self.pushButton_write.setText(QCoreApplication.translate("MfgTool", u"Write", None))
self.label_type.setText(QCoreApplication.translate("MfgTool", u"Device Type:", None))
self.label_sn.setText(QCoreApplication.translate("MfgTool", u"Device S/N:", None))
self.label_mfgsn.setText(QCoreApplication.translate("MfgTool", u"MFG S/N:", None))
self.label_pdate.setText(QCoreApplication.translate("MfgTool", u"PDate:", None))
self.label_hwver.setText(QCoreApplication.translate("MfgTool", u"HW Version:", None))
# retranslateUi
再看下主程序是怎样的:
from ui_mfgtool import Ui_MfgTool
class MfgTool(QMainWindow):
def __init__(self):
super(MfgTool, self).__init__()
# self.load_ui()
self.ui = Ui_MfgTool()
self.ui.setupUi(self)
self.initUI()
self.ConnectFun()
........
if __name__ == "__main__":
app = QApplication(sys.argv)
mainUI = MfgTool()
mainUI.show()
sys.exit(app.exec_())
这样我们先用命令行方式运行,界面如下:
从上图发现,程序的主题title也显示正常,win10任务栏也有主程序图标了,另外也没有显示两个界面(QMainWindow与ui),这就是静态加载的好处,完全跟在Qt Creator下开发一样的效果,完美了!
最后再演示下,主程序窗口与Ui之间已经完美对接,完全是父子类的关系。
def initUI(self):
desktop = QApplication.desktop()
self.screenWidth = desktop.width() * 0.4
self.screenHeight = desktop.height() * 0.6
# print("Screen width:", self.screenWidth, "height:", self.screenHeight)
self.setGeometry(0, 0, self.screenWidth, self.screenHeight)
self.ui.setWindowTitle("MfgTool V1.0.0")#静态加载应该写成self.setWindowTitle("MfgTool V1.0.0")
上面代码中设置title按照动态加载方式写,就会遇到下面报错:
D:\py\MFGTool>python mfgtool.py
Traceback (most recent call last):
File "D:\py\MFGTool\mfgtool.py", line 245, in <module>
mainUI = MfgTool()
File "D:\py\MFGTool\mfgtool.py", line 20, in __init__
self.initUI()
File "D:\py\MFGTool\mfgtool.py", line 39, in initUI
self.ui.setWindowTitle("MfgTool V1.0.0")
AttributeError: 'Ui_MfgTool' object has no attribute 'setWindowTitle'
到这就把静态加载UI也介绍完了,至打包成exe,方式与动态打包所用的指令一样,不重复了,说明的是不用再把ui文件复制到打包文件夹下面了。
另外,如果只想打包成一个exe执行文件,不需要其他关联的库等文件,可以使用“-F”参数,这样只会打包成一个exe文件,只是这个exe文件有点大。