Python界面开发示例:用PyMe实现自动使用nuitka生成pyd文件和相应pyinstaller打包命令
文章目录
前言
作为一个业余人士,平时我会用Python开发一些小的工具,在这个过程中,界面的实现往往会让人头疼。一方面,那些较好的如PyQt之类,学习难度较高,另一方面,普通的tk等库,学习倒是容易些,但调整和维护起来,还是有些麻烦的。所以目前,我在使用PyMe作为我的python程序的界面设计工具
一、PyMe简介
1. 什么是PyMe
官方答案:PyMe是一款基于 Python 的流程化的项目开发工具软件,主要用于流程化、可视化、组件化的设计与开发应用和游戏项目
个人答案:使用PyMe能让我更方便的为自己的Python程序写界面
换言之,我使用的只是其中的一小部分功能,不过我相信,在Python被越来越多和我一样的“非专业人士”关注和应用的今天,界面设计对我们来说才是最重要的功能。它能够大大降低编程的门槛,让那些没有深厚编程背景的人也能够快速上手,创建出较为实用的桌面应用程序,未来或许还会有安卓程序(目前虽然已有打包安卓功能,但比较麻烦)。
顺便一提,即使是界面设计,我也只用了一点点功能,作为非专业人士写这个文章,只能说虽然是班门弄斧,但同时也是希望更多的人能够看到PyMe,了解PyMe,能够使用它来解决自身遇到的问题。
由于自身水平极为有限,也欢迎各路大神指点与指正。
2. PyMe的现状与展望
优点
PyMe的作者多年来仍然坚持在不断更新,确保该项目保持活力和发展。当前,PyMe的界面设计功能已经较为完善
1.易用性:PyMe提供了直观的拖拽式界面设计工具,使得创建用户界面变得非常简单,即使是编程新手也能快速上手。
2.强大功能:通过丰富的组件库和事件处理机制,PyMe支持实现复杂的交互逻辑,大大扩展了应用的功能范围。
3.便于维护:由于其模块化的设计理念,使用PyMe开发的应用程序易于维护和扩展。可以轻松集成现成的功能,减少开发时间和难度。
缺点
尽管PyMe有许多优点,但它也存在一些不足之处
1.不够稳定:在个别情况下可能会出现稳定性问题,比如表现不一致,或者一些bug,当然这些bug也会在更新中消失。
2.美观度有待提升:默认主题和样式可能不如一些专注界面的UI框架那样精致和现代化,这个也需要作者在功能完善后发力解决
3.官方只支持Python3.8:其实3.11,3.12也有人试过可以用,但以我的经历来看,极偶尔情况下,高版本可能会有一点问题
总的来说,PyMe已经在简化开发流程和降低技术门槛方面展现了巨大潜力,随着不断的更新,缺点也在逐步变少,期待在不久的将来,能够看到作为“完全体”的大成版本
3. 下载及收费方式
下载
可在PyMe官方网站下载,也可以进入作者创建的q或vx群下载
收费
下载和普通使用是免费的,部分功能收费,详见官网。在我个人看来,如果是开发些小工具什么的,使用免费功能就够了。相对重要的收费功能是,解除单个项目只能创建5个窗口的限制。当然,个人的小项目一般也用不上。
二、PyMe的界面介绍
PyMe主要有三大类窗口,分别是项目管理页,设计界面和代码编辑界面,先简单看一下,有个印象。比如后面提到“属性窗口”,如果不记得可以返回来看。
1. 项目管理页
在打开PyMe主程序后,显示的是项目管理页,这里主要用于新建项目和打开已有项目,以及登陆,也可以看一些实例项目等等。直接点确定就进入了设计界面
2. 设计界面
中间斜黑白格区域是设计区,可以看到有一个空窗口。左侧是最常用的,用来拖拽控件到窗口上。
右上是运行窗口,以及可以打包项目。
控件列表用于显示界面上有哪些控件,可以看到目前只有一个Form_1,也就是中间的灰色窗口(有时部分控件不好选择时,也可以点击控件列表)
右中是控件的属性窗口、事件窗口等,目前显示的是Form_1的属性.
下方是项目相关的文件,都是PyMe自动生成的。BackUp用于存备份,Resources用于存项目需要的一些资源文件,ico是一个默认的图标。
其中特别要注意的是2个文件,一是蛇形文件,它是窗口文件本身,在PyMe内双击它显示的就是窗口,实际它是一个.py的文件一般不需要手动改动,在这个项目中,它也是项目的入口文件。
另一个是CMD文件(也是个.py文件),虽然它也是自动生成的,但目前没多少东西,后续我们和窗口相关的事件代码主要写在这个文件内,CMD文件很重要,一定要记住它
3. 代码编辑界面
双击cmd文件后,会进入代码编辑页面。中间代码编辑区域就不说了,下方是调试,右侧是界面预览,作用是不用回设计界面就能知道哪个控件在哪里,它下方是控件可用的事件
值得一得的是,在代码编辑区域右键,会出现下图的快速代码菜单。由于目前只有一个Form_1控件,因此可以看到界面函数中,除了“全局函数”,仅有Form这一项(因为目前只有一个窗口,没有其它控件),如果有其它控件,会有更多选项。这里也可以让你知道一个控件有哪些可用的命令。比如对于Form来说,能够取得界面元素,能够销毁界面……。如果点击选项,就会自动生成相应的代码。
但是在我个人看来,PyMe的代码编辑器做的确实不太好用,毕竟个人作品,除非拿时间堆,否则不可能面面俱到,但缺的就是时间。所以我的建议是,特别对于离开代码智能提示就不会写代码的人来说,最好是用PyMe画控件、生成事件和PyMe的函数。
然后使用自己常用的编辑器,例如pycharm等,打开项目根目录,对自己的核心代码,以及窗口的cmd文件进行编辑,和项目的调试(调试的入口文件就是建立项目时自动生成的主窗口的py文件)。
三、案例应用演示
1. 需求描述
在用pyinstaller打包时(Pystand之类也一样),会有个问题,打包出来的程序的源代码可能会被反编绎,哪怕写的不一定多好,但毕竟自己的劳动成果,如何防范呢?方法之一就是可以使用nuitka或cython把自己的重要代码变成pyd文件,它是比较难被反编绎的。然后再用Pyinstaller打包pyd文件。但是项目里的py文件可能比较多,那么如何自动把这些文件变成pyd,并且生成pyinstaller能用的打包命令呢?
本次的案例就是从空界面开始,完整的做一个界面,在设置好项目目录路径、入口文件路径、pyd文件的保存路径等事项之后,把所有相关的py文件用nuitka --module打包,并生成类似于–add-binary=pydFiles\Function.pyd:. --exclude-module=Function这样的命令即可。这样用pyinstaller打包时就可以把这些命令粘贴过去,不需要手写了
受限于本人水平,案例的目的不是为了真的把能够通用的全套打包流程弄完,而是主要为了展示如何使用PyMe,所以某些细节可能说说的很琐碎。文末附项目代码。
观前提醒
1.本项目的完整代码,对于一些高分辨率电脑,可能会出现控件文字较大导致显示不全的情况,如果遇到此类问题手动把控件的文字字号调小即可。
2.由于csdn不好插入视频,所以都是以动图形式展示的,有时可能会不好分清动图是否刚刚开始,所以可以右键动图,在新标签页中打开图像,看完再关掉标签页,一般的浏览器都会有类似的功能
2. 新建项目
在项目管理页面选择空界面,修改一下项目路径,让目录名字更好记好认,然后确定,就新建了一个项目。顺便一提,一个新功能是,在项目管理页面,打开项目之前,可以右键项目,自动使用ai生成一个项目的图标
接着就进入了设计界面
3. 添加控件
3.1 修改窗口标题
选中Form后,可以先在Form的标题栏修改一下标题,当然,不只Form,其它控件都是一样道理,先选中控件,再修改属性,以后不再赘述
除了属性窗口,直接标题栏右键也可以修改图标,还有些自带的图标可用,改好了是下面这样:
3.2 添加控件
添加控件主要有2种方法,第1种是从左侧列表中单击想要的控件不放,拖动到Form上。第2种是在Form上右键,从菜单中创建控件。(其实还可以用快捷键添加,就不细说了)
3.3 修改控件的文字内容
一般有三种方法:
①右键控件,”设置文字内容“
②在右侧属性栏修改
③还可以在选中控件时,ctrl+Backspace键清空文字内容,就可以直接输入文字。
特别提醒,这个修改的是”控件显示的文字“,要把它和控件的名字区别开来,后面会说控件名称相关内容
在设计界面的左上角,还可以修改文字的字体和大小等:
3.4 复制控件
对于已有的控件,可以按住alt键不放,单击左键即完成复制,然后把上方的复制体拖走即可,当然也可以按住alt后左键不放,直接拖动到指定位置,类似下面这样。注意,对于锁定的控件无法使用后者,只能用前种方法先复制再拖走复制体
3.5 排布控件
排布控件有几种方式:
①直接鼠标拖动
②键盘的上下左右微调
③使用坐标
④框选多个控件后在快捷工具里对齐
特别说一下坐标的方式,如下图所示,想精确控制可以用
使用前面所说的生成和复制控件的方法,经过一通操作,此时一共完成了3个Label,3个Entry,3个Button和1个CheckButton,是下面这样:
3.6 添加表格
注意对于部分控件,如ListView,菜单栏等,可能需要编辑一些额外的信息,当然这些操作也可以通过代码来实现。
比如表格ListView的列名编辑,默认是ID,Name,age,现在需要修改为序号,py文件路径,pyd是否存在。这三列
3.7 添加信息显示用的Text,以及几个功能按钮
如下图,增加几个功能按钮,注意,如果使用框选来平均分布,需要把首尾2个按钮放到起止位置,中间的会自动平均分布
然后打包过程中需要显示一些信息,对于少量信息,可以用Label显示,但是对于打包这种信息较多情况,还是用text控件更好。
注意:text控件的创建,gif里没录到,简单说就是从左侧找到Text控件,拖到Form_1里,然后调整一下大小
3.8 锁定控件位置
对于确定好位置的控件,可以使用锁定,免得误操作把排好的控件拖走了(其实前面过程中就可以锁)。可以右键控件,选择锁定;也可以在右上的列表里右键锁定。已锁定的控件在列表里就是红色。注意,锁定的只是位置,大小是仍然可以调整的。下面是锁定的演示
3.9 已锁定控件的解锁
如果有位置不合适的控件,可以右键控件,原本的“锁定”选项就变成了“解锁”。
可以解锁后,调整好位置再锁定,然后结果大致是这样的(后面才发现,第2,3列最好设置为“居左”,此时可以右键表格,编辑列信息,选中相应列,把默认的居中变为居左,保存修改。这样后面的操作中,表格内容更方便看)
显然,使用拖拽来生成控件,比手写代码方便容易多了。
4. 修改控件名称
修改控件名称这一步骤并非是必要的,但是我认为有了这些步骤,才更方便管理和拓展我们的界面功能。
4.1 修改控件名称的方法
每个控件是有自己的名称的,以下图为例,这个Entry的默认名称是“Entry_1",既可以从右上的控件列表,也可以从右侧属性窗口看到。但是这样对我们后面对界面进行各种操作时不太方便,我们最好把它起个自己能看懂、好记的名字。
可以在控件上右键,”修改名称“,也可以属性窗口双击”名称“进行修改。
注意:虽然之前说过,但现在再次强调,控件名称是一个字符串,它是控件的名字,在一个Form里,不同的控件名字是不能重复的(例如自动生成的名称Entry_1和Entry_2是不同的,但是控件上显示的内容是可以重复的(例如2个按钮同样显示”浏览“)
4.2 控件命名示例
本项目中,控件命名的思路是这样的:
①对于Label,Form,我们不关心它能够提供什么值,可以不修改名称,用默认的就可以
②对于Entry,ListView,Text,CheckButton,我们需要取它们的值当参数,又或用它们显示一些信息,所以需要修改其名称,以便后续操作
③对于Button,我们不关心它能提供什么值,但由于按钮被点击时,会触发相应的事件函数,由于事件函数的命名是根据按钮的名称自动生成的。而我们希望能识别出,哪个事件函数对应哪个按钮,所以把它也要起个合适的名称。
下图就是给每个控件起的名称:
以第一个“浏览”按钮为例,我们起名为“项目目录浏览”,那么它的单击事件对应的函数,会自动命名为"项目目录浏览_onCommand",其它事件函数也都会命名为“项目目录浏览_xxxxxxx”之类的名称
5 生成按钮事件
5.1 生成事件的两种方法
此时我们可以点击右上角的运行,试着运行一下界面,会发现按钮可以点击,但没有其它反应,这是因为缺少事件。
那么如何生成事件?也有两种常用方法,一个是直接在控件上右键,选择“事件响应”,另一个是选中后,在属性窗口标签的旁边,点击“事件响应”标签,就能从属性窗口切到事件窗口,见下图。其中,方法一较常用于添加事件,方法二更适合“查看这个控件有什么对应的事件函数”
5.2 生成一个事件示例
见下图。我们使用右键点击第一个浏览按钮,选择”事件响应“(弹出窗口中的选项,和右侧事件窗口中的选项是一样的),选择第一个选项Command(即左键单击事件),点击”编辑代码”(也可以不点击编辑代码,直接双击Command,效果是一样的),就会自动进入代码界面,同时给CMD文件生成一个按钮的事件函数,意为“当按钮被点击时,做这个函数中写的事”,而且光标会自动停在这个函数里。
可以看到生成的事件函数名字为"项目目录浏览_onCommand",这也是修改按钮控件名称的目的,这个名称显然比默认的"Button_1_onCommand"好识别的多了。
5.3 事件函数的参数说明
事件函数自动生成了3个参数,uiName,widgetName,threadings。
uiName是界面的名称(对这个cmd文件而言,即蛇形窗口文件的名称AutoPack),从CMD文件的开头代码也能查到uiName=AutoPack。
有关uiName的作用,例如:
①如果我们要从A界面跳转到B界面,那么这个A、B指的就是它们的uiName,跳转语句需要用到
②比如我们写一个代码,让本界面某处显示“123”,这个代码中必然存在一个参数uiName。如果把uiName修改为"ABC",那么就是让ABC界面显示“123”了。先简单了解这些就够了,之后慢慢会熟悉的。
widgetName是控件的名称,也就是之前说的,比如名为"Button_1"的按钮,生成的事件函数,它的widgetName默认就是"Button_1"。而若我们把它的名字改为“项目目录浏览”,那么此按钮的widgetName=“项目目录浏览”。
threadings是多线程,如果事件函数耗时较久,那么函数运行完成前界面会卡住。为了让界面在程序运行时,不会卡住,我们往往需要使用多线程。而在PyMe中,threading=n就是“最多开n个线程”,n的目的是防止误动,比如n=1,就是连续点2下时第2下无效。如果n=2,那么最多同时运行2次该函数,和3次无效。默认0就是不用多线程。
5.4 解绑事件函数
如果一个控件有1个或多个事件,那么在选择事件的界面可以看到+符号。而右侧事件窗口虽然没+号但能直接看到对应的函数名称。
如果在PyMe的代码界面中,删除事件函数并ctrl+S保存,PyMe会自动提示,询问是否解绑,意思是“单击函数没了,按钮就没有单击事件了”,+号也就消失了
5.5 使用双击快速设置“鼠标单击”事件
除了左键单击外,还有很多事件,比如右击,双击,键盘按键等等,都可以作为事件触发各自的函数。
但对于最常用的”单击按钮“事件而言,有个更快的设置方式,就是直接在设计界面的控件上双击,下面是解绑和双击设置按钮事件的演示(提示:从代码界面退回到设计界面,除了右键外还可以直接esc键):
在上面的演示中可以看到,随着每次双击不同按钮,都会在CMD文件中增加一个该按钮的事件函数,而在第3个按钮的事件函数中,写入了一个Fun.MessageBox函数。
因此当界面运行时,点击此按钮就会触发单击事件,得到一个消息提示窗口
5.6 使用双击生成本项目所有按钮的单击事件
把界面上的7个按钮全部双击一遍,就会得到各按钮的事件函数。就像下图这样:
6. Fun的使用简介
这里顺便一提,Fun.py中存储着各项功能函数,我们需要的功能都是通过调用Fun函数实现的。调用的方式可以直接手写,也可以右键代码区域,手动选取需要的函数,例如下图:
这个右键菜单的意思是,当前的界面中有Form,Label,Button,Entry,CheckButton,Text,ListView这7种控件,其中Entry控件有3个,分别名为project_dir,main_file_path和pyd_dir。也就是说,通过这个菜单,我们可以知道任意界面已存在的控件,能够做哪些事,并且用鼠标点击相应命令,生成代码。而且代码的widgetName参数,会自动填入这个控件的名字,如下图:
由于在info_output控件的菜单下单击,生成的命令,所以widgetName="info_output"会自动填上,而如果是手动打这行代码,控件名称就需要自己填写了。
也有的Fun函数,可以不直接关联控件,例如下图示例的消息窗口函数:
总的来说,如果想知道某个控件,“能够执行什么命令”,一个是前面说的,可以右键后,在菜单里选择相应控件,查看有哪些选项。另外也可以右下角查看。例如下图,我们想知道作为ListView的table控件,能有哪些命令,下图是演示2种方法
7. PyMe和IDE的搭配使用
之所以要生成所有事件,是为了使用IDE打开这个项目,然后主要在IDE里编码,不用频繁切回PyMe了。
以Pycharm为例,使用它打开AutoPack文件夹,找到CMD文件,就可以在每个函数的下方编写事件内的代码了,当然,如果有新增的事件,或者不知道如何使用控件命令,都可以切回PyMe生成命令,保存后切回IDE就会看到生成好的命令了。
在IDE里运行主窗口AutoPack.py(它就是pyme里面显示蛇形的文件)同样能够正常运行该项目
8. 获取界面的输入数据
8.1 获取控件显示的文本
我们先熟悉一下PyMe的取值。假如我们希望获得“项目目录”输入框中的文本,先在PyMe中生成Fun的调用语句,再去IDE中编辑后续代码:
这里额外提醒一下,如果我们在PyMe中手动输入命令,会获得更多的提示:
上图可以看到,GetText适用于Label、Button等控件。等到熟练之后,其实在IDE里手写这些命令也不算麻烦
8.2 获取控件当前的值
以CheckButton为例,获取它当前的值,操作和8.1一样,就不录屏了,代码是
value = Fun.GetCurrentValue(uiName, ‘recompile’)
打勾时value为True,否则为False
9. 设置界面的输入数据类
这个步骤是我自己的习惯,不是必须的,但我们制作界面的重要目的之一,是为了获取数据,来当作参数传入函数的,那么,一个界面到底需要且能够传递哪些控件的参数?我认为是需要做一个“统计”的,这样才能方便来“取界面数据”,以及便于之后的维护和扩展。
我们在CMD文件中设置一个这样的类:(第35行代码需要修改为return copy.deepcopy(self.DATA) )
这个类的作用,主要就是通过get_ui_data方法从界面中获取需要的内容(也可以用来对输入数据的合法性进行校验)
其实在PyMe代码界面右键–界面函数–全局函数–获取界面的输入数据字典,就可以得到Fun.GetUIDataDictionary函数,可以把界面上的所有数据以{控件名1:数据1,控件名2:数据2}的方式获取到。但里面有很多无用的数据,有点累赘。
而设置一个数据类既能方便获取数据,又能对“这个界面到底能获取哪些有用的数据”做个记录,而且后续可能还有别的用处(比如有时需要数据回退),可谓一举多得,不算浪费时间。
现在我们可以试验一下这个类的效果:
10. 设置界面的其它事件
10.1 浏览按钮
我们希望点击“浏览”之后,能够选取相应的路径,并把确定的路径显示在浏览前面的输入框内,注意第一个事件函数完成后,复制到其它函数时,对控件名称的修改,结合5.3的参数说明,这有助于理解事件函数的使用
可以运行一下试试:
10.2 Fun函数的封装
封装信息显示窗口
可以看到一直在使用print来显示信息。但是界面上明明有信息显示窗口,那么我们完全可以在这个窗口里显示,当然,我们可以直接使用Fun.。。。。来控制信息显示,但这样比较麻烦,毕竟我们有很多地方可能都要显示信息。
那么可以做个封装,在CMD文件中增加一个函数,注意info_output就是信息显示的Text类型控件的名字,不记得的话,可以翻回4.2节看控件名称的那张图
以后我们想在界面上显示信息,只需要用output代替掉print就可以了,是不是方便了很多?
封装消息窗口
其实还可以封装其它函数,例如下图中普通的消息窗口。注意,这个WINDOW不是必须的,也可以不写。
如果有其它常用函数,也可以自行封装。
11. 数据有效性的验证
11.1 验证哪些数据
用户输入的数据,有可能是错误的,甚至可能会造成程序报错,所以需要对界面的输入数据进行有效性验证,以本案例为例,需要对2个目录路径,和1个文件路径做校验,如果输入不符合要求,就弹出提示
当然,我们可以做2个函数,一个校验目录,一个校验文件,在但是Fun里已经自带了CheckExist函数了,这次就不用自己写了,统一用自带函数。
问题来了,这个校验加在哪里呢?我们回到PyMe的设计界面,右键任意一个输入框选择“事件响应”,或者在右侧事件窗口查看它有哪些事件
11.2 验证方法的选择
所以现在有3个选择:
1.当文字改变时检测输入框中的字符串是否合规
2.当失去焦点(即光标不在输入框内)时进行检测
3.当点击按钮时,对界面传入的所有数据进行验证
如果选1,会存在一个问题,比如手动输入E:\,当输入E时,它不是路径,这样就会造成在输入过程中触发很多不应有的错误提示。为了避免这点,我们需要把输入框的属性设置为“只读”,禁止手动输入就可以了。优点是省心、简洁,缺点是不能手动输入了
如果选2,同样会有一个问题,比如我在一个框里输入字符,然后直接点击按钮,虽然失去焦点的检测会正常触发,但是按钮已经点了,错误的参数已经随着按钮中的事件开始运行了。虽然有方法能限制这点(直接把失去焦点的函数在按钮事件内调用一次),但总归会多一步
如果选3,唯一缺点是不能在输入过程中就提示,只能在运行时才会提示
综合来说,还是3比较合适,但为了演示,还是暂时先用2来做一下,之后再注释掉这段代码即可。
11.3 试用“失去焦点”事件
我们就增加一个“失去焦点”的事件——右键后选择事件响应,然后双击FocusOut事件,生成相应事件函数
以此类推,把三个输入框都加上事件,然后封装一个验证的函数check_path,把这个函数写在三个事件内。
演示一下是这样的:当输入正常的路径时,就没反应,如果输入非路径的字符串,就会弹出提示窗口,如果有必要,这里甚至可以加上回退等功能。
不过,就像我之前说的那样,如果输入错误数据后直接点击按钮,等于让按钮事件无视数据检验直接发生了,所以还得在按钮里再检验一次数据,那还不如直接在按钮里检验了。
11.4 在输入数据类中添加数据验证方法
不论如何,失去焦点事件只是个演示,希望能够以此明确事件的添加方法,所以现在我还是把刚才的验证代码注释掉。用方法3,重新写一个数据检验函数check_params放入UiData类。
然后把check_params放到get_ui_data,这样只要获取界面内容,就会触发检测,进而影响获取的结果,然后只要根据获取的结果就能判断是否中止程序运行。
其实还有其它事件或按钮需要设置,但是暂时先不做,等遇到了再加
12. 完成功能函数并在界面中调用
cmd文件内主要用于存放界面相关的事件等函数,所以最好新建一个文件,专门写功能函数,就在项目目录内新建一个pack.py文件
从现在开始,gif可能比较难照抄代码了,甚至可能演示的代码和最终的代码会有细微不同,如果觉得麻烦,可以到文末先拿到pack.py的代码,以及cmd文件中输入数据类的代码。其它cmd文件的函数尽量自己写,因为本文的重点是如何使用PyMe而不是项目本身
12.1 “获取py文件”按钮的功能实现
这个功能需要读取项目文件夹内所有的符合要求的py文件,并判断其pyd是否存在。这里用到了我之前一篇文章中写的函数 筛选文件函数 ,可直接复制到pack.py内使用,然后写主要功能。可以在写的过程中调用一下,看有什么问题。(为了验证,特地在项目内新建了Function文件夹,里面新建了一个sy.py的文件里面只有一行print代码)
这里涉及到导入和调用,所以详细演示一下第1个函数get_table,之后的就不再赘述了
然后在cmd文件里,获取py文件按钮的事件中,输入代码,如果不知道表格的Fun函数,就切去PyMe的代码界面,右键-界面函数-ListView-table里看(其中table是之前给表格起的名字)
然后演示一下效果:
12.2 “nuitka打包”按钮的功能实现
既然已经拿到了所有的py文件信息,接着就可以用nuitka打包了,这个打包的相关代码,仍然写在Pack类中,然后在cmd文件中的“nuitka打包_onCommand”事件函数下调用打包(注意这个函数的参数改为threadings=1,因为如果等于0就会造成函数运行过程中界面卡顿,1即为新开1个线程处理任务,这样不会对界面自身造成影响)。在示例中,scons-debug.py是我特地复制的py文件,打包它时会报错,大概是因为名字中间有横杠。另外,中文路径也会造成错误。此外,这里的打包py文件都很小,所以一个个打也不会很慢。但如果大文件可能就非常慢了。后续可能需要用多进程优化一下。这里暂时就不做了。(此处显示的pack.py代码有点问题,以文末的最终代码为准)
12.3 增加表格的右键菜单
此时我们会发现,总有些会报错的,或者是不想打包的py文件出现在表格里,例如scons-debug.py和Fun.py,那么怎么办呢?我们可以为table增加一个右键菜单,让我们能手动删除选中的行,这样在打包时就不会被它干扰了。如何增加?这就得切回到PyMe的设计界面了
添加右键菜单
因为是右键菜单,所以触发事件方式肯定是右键table,下面是添加的事件的演示,过程中顺便添加了一个快捷键标识Ctrl+D,点击确定后,自动进入代码界面,并且新增了相关的函数。其中:
“table_onButton3”是右击table,出现菜单的事件
“table_onButton3_Menu_删除”则是在菜单中点击“删除”后触发的事件
接下来,只需要在"table_onButton3_Menu_删除"里,写入删除表格内选中行的代码就可以了。但是删除后,序号就不连贯了,所以我提前写了个update_ui_table函数,下面是添加删除函数的过程演示
解释一下,在删除时我使用了sort排序,这是因为一个小bug,已经提交了,在我发文时,已经修复了,不过这段代码不受更新的影响。
其实既然有删除py文件的功能,自然也可以有手动单独添加1个py文件的功能,但为了节约时间,这里就不做这功能了
12.4 添加表格右键菜单的快捷键
虽然之前已经选择了快捷键Ctrl+D,但那只是个菜单上的标识,实际上快捷键仍然没有生效,需要再添加实际功能
所以我们需要先选中Form_1,然后把右侧属性窗口切到事件窗口,找到Ctrl+D的事件并双击,下面是过程演示
注意最后的函数调用,按钮、菜单之类的事件函数都是可以作为一个普通函数被调用的,前提是传入正确的,符合相应类型的参数,在填写参数时,如果正常情况widgetName我会写"table",毕竟是table控件的事件,但是这里随便写个"123"也能运行,因为这个函数目前的功能不会受widget_name的影响。
再多嘴一句,什么情况下传入的参数无所谓呢?自然是看函数里面用不用的上这个参数,比如说你在table_onButton3_Menu_删除函数里加一行output(widgetName),那么显然传入widgetName就不能那么随意了——这个虽然简单,但有的基础薄弱者未必能转过弯来。
12.5 "打开pyd目录"按钮功能的实现
这个就简单了,直接看图,三步走:1.获取pyd目录(其实也可以用get_ui_data方法,但嫌麻烦) 2.判断是否是目录 3.打开目录
12.6 "保存设置"按钮功能的实现
其实演示过程中应该早有人感觉到了,每次进界面都要设置一遍,太麻烦了,所以从中间开始,我已经把输入框填好了默认的路径,但实际使用时,必须要能把上次的输入信息保存下来(包括表格中的数据,这样免得每次都得获取py文件然后手动删除一遍),而不是固定的数据。
为了方便,可以使用json来存储界面信息
由于这个是界面的功能,就还写在cmd文件里的UiData类下吧
12.7 已保存的设置文件的自动载入
每次界面刚开始运行时,需要先检查有没有存储文件,如果有就读取并显示到界面上。这个也是各类程序常用的功能。如何实现呢?
对于PyMe的界面,都会有些特殊事件,比如我们这次要用到的onload事件,它的作用是在界面加载完成后立刻触发。
也就是说,我们先写一个读取设置文件并显示到界面上的的函数set_ui_data,把它放到onload事件里,这样界面就会在加载完成后,自动运行set_ui_data函数,这样就不用每次都设置半天了。
顺便提一下,从上面的截图除了onload事件,也能够看到Exit事件,如果创建这个事件,并且把保存设置的save_ui_data放进去,就能实现每次关闭页面时自动保存设置,不需要手动保存了,不过这里就不做这个功能了
下面是自动加载设置文件功能实现的演示:
13. 增加清理垃圾功能
此时我们发现,打包后会有一堆过程产物,没什么用,看着闹心,那么不妨加一个清理按钮。此时又需要切回PyMe的设计界面了,制作一个名为"clean"的按钮,这里也使用了一些批量操作的功能:
然后加上一个清理垃圾文件的代码试运行一下:
14. 使用PyMe的发布功能为项目打包
在PyMe的设计界面右上角,有运行和发布按钮,代表着运行程序和打包两个功能,运行按钮的左边可以选择Windows、Web和Android。代表着想让我们的界面以何种方式运行和打包。当然,目前后两者还不够好用。所以就先打包默认的Window就好。下面是打包截图,其实倒也没什么好说的,因为填什么都有提示,是自动完成的。
顺带一提,我用nuitka把这个项目打包了一个单文件,也放到后面的网盘里了,打包前稍微优化了一点代码,最终以网盘文件为准
四、总结与案例代码下载
1. 完整功能演示
至此,我们的功能已基本实现,删掉设置文件,完整演示一遍吧:
2. 部分代码
完整的项目代码在后面的网盘下载链接。但为了方便使用,这里先把pack.py的全部代码贴出来,然后cmd的代码只把数据类和部分功能函数贴出来。从而能够让你专注于PyMe的操作和使用。
这里也建议,在使用这些代码的情况下,最好把PyMe本身的操作,以及事件相关的函数自己写一遍。相信练习完毕,你会对PyMe的使用有个初步的认知,和如何探索其更多功能的思路
pack.py文件:
# pack.py
import os
import subprocess
import shutil
import tkinter
from os.path import isfile
import Fun
def is_sub_path(path, dir_path):
"""
判断给定的路径是否位于指定目录或其子目录中。
:param path: 要检查的路径 (字符串)
:param dir_path: 基准目录路径 (字符串)
:return: 如果 path 位于 dir_path 或其子目录中则返回 True,否则返回 False
"""
try:
# 获取两个路径的绝对路径
abs_path = os.path.abspath(path)
abs_dir_path = os.path.abspath(dir_path)
# 使用 commonpath 找出公共路径部分
common = os.path.commonpath([abs_path, abs_dir_path])
# 检查公共路径是否等于基准目录路径
return common == abs_dir_path
except Exception as e:
print(f"发生错误: {e}")
return False
def walk_file(path, ext=None, not_in=None, is_in=None, get_full_paths=True, deep=True):
"""
列出文件夹中的文件, 并根据后缀、关键字筛选出来
注意:若get_full_paths为False时,可能得到重复的文件名
:param path: 根目录
:param ext: 需要的文件的后缀名,可为单个后缀,或后缀列表。例如:'exe'或['exe', 'txt']
:param not_in: 文件名中不能包含的字符。例如:'~$'或['~$', 'tmp']
:param is_in: 文件名中必须包含的字符。例如:'abc'或['abc', 'def']
:param get_full_paths: 默认True时返回文件的完整路径列表,否则返回文件名列表
:param deep: 默认True,表示深度遍历所有子文件夹下的文件;若为False,只获取当前目录path下的文件
:return: [files] or [file_paths]
"""
# 检查给定路径是否为目录,如果不是则返回None
if not os.path.isdir(path):
return None
# 参数规范化函数,将参数转换为小写列表
def normalize_param(param):
if param is None:
return None
if not isinstance(param, list):
return [param.lower()]
return [x.lower() for x in param]
# 规范化处理ext,not_in,is_in参数
ext = normalize_param(ext)
not_in = normalize_param(not_in)
is_in = normalize_param(is_in)
# 初始化文件列表
filelist = []
# 根据deep参数决定是否深度遍历
if not deep:
# 只遍历当前目录
for file in os.listdir(path):
one_file_path = os.path.join(path, file)
file_name = os.path.basename(one_file_path)
f_ext = os.path.splitext(file_name)[1].lower()
# 根据文件后缀,not_in,is_in筛选文件
if ext is None or f_ext[1:] in ext:
if not_in is None or all(ni not in file_name for ni in not_in):
if is_in is None or any(ii in file_name for ii in is_in):
# 根据get_full_paths参数决定添加文件的完整路径还是文件名
if get_full_paths:
filelist.append(os.path.abspath(one_file_path))
else:
filelist.append(file)
else:
# 深度遍历
for root, _, files in os.walk(path):
for file in files:
one_file_path = os.path.join(root, file)
file_name = os.path.basename(one_file_path)
f_ext = os.path.splitext(file_name)[1].lower()
# 同样的筛选逻辑
if ext is None or f_ext[1:] in ext:
if not_in is None or all(ni not in file_name for ni in not_in):
if is_in is None or any(ii in file_name for ii in is_in):
# 根据get_full_paths参数决定添加文件的完整路径还是文件名
if get_full_paths:
filelist.append(os.path.abspath(one_file_path))
else:
filelist.append(file)
# 返回文件列表
return filelist
class Pack:
def __init__(self):
# *由界面指定的参数
self.project_dir = ''
self.main_file_path = ''
self.pyd_dir = ''
self.recompile = False
# *手动指定的参数
# !生成的pyd文件默认会带上这个后缀,根据各人电脑系统和python版本不同,可能有变化
self.pyd_original_suffix = '.cp38-win_amd64'
# 一般收到的界面的参数都需要先处理才能用
def deal_params(self, uiData):
# 以下为界面传来的参数提示
"""
uiData = {
'project_dir': '',
'main_file_path': '',
'pyd_dir': '',
'recompile': False,
}
"""
self.project_dir = uiData.get('project_dir', '')
self.main_file_path = uiData.get('main_file_path', '')
# 如果没有输入pyd路径(即空字符串""),就默认是项目根目录
self.pyd_dir = uiData.get('pyd_dir', '') if uiData.get('pyd_dir') else self.project_dir
self.recompile = uiData.get('recompile', '')
# 获取项目内所有的py文件列表,并检查其对应的pyd是否已经存在,返回结果的二维列表,用于界面上表格内显示
def get_table(self, uiData):
self.deal_params(uiData)
# *如果嫌找到的不相干py文件太多,可以用walk_file的not_in参数,过滤掉包含['__init__', '__main__']之类字符的py文件
pyfiles = walk_file(self.project_dir, ext='py', not_in=['__init__'], get_full_paths=True, deep=True)
# 如果不用normpath,就无法比较路径,因为有的路径是\\有的是/组成
main_path = os.path.normpath(self.main_file_path)
# 一般项目的入口文件,以及pyd文件夹内的py文件,不需要变成pyd
pyfiles = [item for item in pyfiles if not (
is_sub_path(item, self.pyd_dir) or os.path.normpath(item) == main_path)]
table = []
for i, pyfile in enumerate(pyfiles):
basename = os.path.basename(pyfile)
pyd_file = os.path.join(self.pyd_dir, basename + 'd')
if not os.path.exists(pyd_file):
is_exist = '不存在'
else:
is_exist = os.path.relpath(pyd_file, self.project_dir) # pyd在项目目录中的相对路径
py_relpath = os.path.relpath(pyfile, self.project_dir) # py在项目目录中的相对路径
table.append([str(i + 1), py_relpath, is_exist])
return table
def nuitka_pack(self, table, uiData):
# !注意:①项目路径中不要包含中文字符②即使是不同路径也不要包含同名的py文件,否则pyd文件也会重名
pyinstaller_order_list = []
self.deal_params(uiData)
# os.chdir(self.project_dir)
for i, row in enumerate(table):
py_path = os.path.join(self.project_dir, row[1])
py_base_name = os.path.basename(py_path)
py_rel_path = os.path.relpath(os.path.dirname(py_path), self.project_dir)
# 打包出的pyd初始名称会带后缀,此处记录初始名称和要重命名的名称,用于后面重命名
pyd_original_file_path = os.path.join(self.pyd_dir, f'{py_base_name[:-3]}{self.pyd_original_suffix}.pyd')
pyd_file_path = os.path.join(self.pyd_dir, f'{py_base_name[:-3]}.pyd')
pyd_rel_path = os.path.relpath(pyd_file_path, self.project_dir)
# 要么重编绎为真,要么pyd不存在,那么就需要编绎。否则不需要再编绎了
if self.recompile or not os.path.isfile(pyd_file_path): # *此处和文章12.2节的代码略有不同
order = f'nuitka --module --output-dir={self.pyd_dir} {py_path}'
output(f'开始打包 {py_path}...')
res = self.run_order(order)
if res is True:
# 防止重名文件,先删除
if os.path.isfile(pyd_file_path):
os.remove(pyd_file_path)
try:
# 将初始的pyd文件重命名
os.rename(os.path.normpath(pyd_original_file_path), os.path.normpath(pyd_file_path))
# 更新显示的表格的信息
table[i][2] = os.path.relpath(pyd_file_path, self.project_dir)
# 重新编绎后,将pyd加入命令
pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
output(f'打包 {py_path}成功')
except FileExistsError:
table[i][2] = os.path.relpath(pyd_file_path, self.project_dir)
pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
output(f'打包{py_path}时检测到已存在相应pyd文件')
except Exception as e:
output(f'打包{py_path}时出现未知错误:{e}')
table[i][2] = '打包失败'
else:
output(f'打包 {py_path}失败')
# 更新显示的表格的信息
table[i][2] = '打包失败'
else:
# 即使不重新编绎,但仍有相应的pyd,直接加入命令
pyinstaller_order_list.append(f'--add-binary={pyd_rel_path}:{py_rel_path}')
pyinstaller_order_list.append(f'--exclude-module={py_base_name[:-3]}')
output(f'已存在 {pyd_rel_path},跳过此打包')
pyinstaller_order = ' '.join(pyinstaller_order_list)
return table, pyinstaller_order
@staticmethod
def run_order(order):
result = subprocess.run(order, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.returncode == 0
def clean_artifacts(self, data):
self.deal_params(data)
if not os.path.isdir(self.pyd_dir):
output('清理失败,pyd目录设置错误')
return
artifacts = walk_file(self.pyd_dir, ext='pyi')
for root, dirs, files in os.walk(self.pyd_dir):
# 过滤出名字以 .build 结尾的文件夹
for dir_name in dirs:
if dir_name.endswith('.build'):
full_path = os.path.join(root, dir_name)
artifacts.append(full_path)
if not artifacts:
output(f'在{self.pyd_dir}内未找到可清理的文件')
return
for path in artifacts:
res = self.delete(path)
if res:
output(f'清理 {path}成功')
else:
output(f'清理 {path}失败,错误为{res}')
@staticmethod
def delete(path):
try:
if os.path.isfile(path):
os.remove(path)
elif os.path.isdir(path):
shutil.rmtree(path)
else:
return "未找到文件"
except Exception as e:
return e
return True
def output(text):
Fun.InsertText('AutoPack', 'info_output', tkinter.END, f'{text}\n\n', '')
CMD文件中的部分代码:
# AutoPack_cmd.py
WINDOW = Fun.GetElement(uiName, 'Form_1')
packer = pack.Pack()
def msg(text, title='提示'):
Fun.MessageBox(text, title, 'info', WINDOW)
class UiData:
msg_info = {
'project_dir': '项目目录路径',
'pyd_dir': 'pyd文件存储目录',
'main_file_path': '项目入口文件'
}
json_path = 'option.json'
def __init__(self):
self.DATA = {
'project_dir': '',
'main_file_path': '',
'pyd_dir': '',
'recompile': False,
}
def check_params(self):
for widget_name in UiData.msg_info:
if not Fun.CheckExist(self.DATA.get(widget_name)):
msg(f'{UiData.msg_info[widget_name]} 输入错误,请重新输入')
return False
return True
def get_ui_data(self):
"""获取界面中,有用的值,便于功能函数获取参数"""
for widget_name in self.DATA:
if isinstance(self.DATA[widget_name], str):
# 从控件中取文本
self.DATA[widget_name] = Fun.GetText(uiName, widget_name)
elif isinstance(self.DATA[widget_name], bool):
# 从控件中取值
self.DATA[widget_name] = Fun.GetCurrentValue(uiName, widget_name)
else:
raise '未找到合适的取值方式'
# 参数的校验
if self.check_params(): # 也可以在上面的for循环中校验
return copy.deepcopy(self.DATA)
else:
return None
def reset(self):
"""重置界面参数"""
self.__init__()
def save_ui_data(self):
data = self.get_ui_data()
if data is None:
msg('界面的设置有错误,无法保存')
return None
table = Fun.GetAllRowTextList(uiName, 'table')
data['table'] = table
with open(self.json_path, 'w', encoding='utf-8') as file:
json.dump(data, file)
return True
def set_ui_data(self, data):
if not data or not isinstance(data, dict):
return None
try:
for widget_name in data:
widget_value = data.get(widget_name, '')
if isinstance(self.DATA.get(widget_name), str):
# 在控件中设置文本
Fun.SetText(uiName, widget_name, widget_value)
elif isinstance(self.DATA.get(widget_name), bool):
# 在控件中设置值
Fun.SetCurrentValue(uiName, widget_name, widget_value)
elif widget_name == 'table':
# 如果没有值,或者是二维的空列表,就不写入表格数据
if (not widget_value) or all(not sublist for sublist in data.get(widget_name)):
continue
Fun.AddMultiRowText(uiName, 'table', 'end', widget_value, [])
else:
output('界面设置时,未找到合适的设置方式,因此不读取设置文件')
self.set_ui_data(self.DATA)
return False
return True
except Exception as e:
output(f"对ui进行设置时出现错误: {e}")
self.set_ui_data(self.DATA)
return False
def read_ui_data(self):
if not os.path.exists(self.json_path):
return None
try:
with open(self.json_path, 'r', encoding='utf-8') as file:
data = json.load(file)
return data
except Exception as e:
output(f"读取ui的设置文件时出现错误: {e}")
return False
def output(text):
Fun.InsertText(uiName, 'info_output', tkinter.END, f'{text}\n', '')
uiData = UiData()
def update_ui_table():
"""删除部分文件后更新界面上的表格"""
# 获取删除后的表格
table = Fun.GetAllRowTextList(uiName, 'table')
for i, row in enumerate(table):
# 每行的序号重新编号
row[0] = str(i + 1)
# table有内容时,展示内容
if table and table != [[]]:
# 清空表格
Fun.DeleteAllRows(uiName, 'table')
# 展示
Fun.AddMultiRowText(uiName, 'table', 'end', table, [])
3. 项目下载
其实这个项目只是作为一个案例,还有很多可以改进的地方。
例如对项目入口文件、pyd目录,和项目目录之间关系的判断。
例如把读取设置文件做成一个按钮,然后保存设置时可以手动选择路径,放进各项目的文件夹内,这样就可以同时保存或读取多个项目的打包情况了
甚至可以把Pyinstaller其它相关命令乃至打包完整流程,都放进去
还有很多内容,有兴趣的可以自行发挥。下面是项目的下载方式(项目的代码可能会与前面稍有不同):
百度网盘
里面有3个文件,1.案例使用的PyMe1.4.8.3版本。2.项目的压缩包。3.项目打包后的exe单文件。
特别提醒:目前的exe文件,如果在高分辨率的电脑上,有可能显示出来的样子会和前面的截图不一样(因为高分辨率电脑往往会设置150%,200%等缩放比例,而非100%)。所以如果想要方便使用,最好还是自己做一遍,可以把字号调整小一些就正常了