Python GUI之tkinter 实战(二)tkinter+多线程

Python3 tkinter系列

一、概述
二、布局管理
三、常用组件
四、实战(一)
五、实战(二)
六、进阶 自定义控件
推荐视频课程 tkinter从入门到实战

自定义对话框

在继续上一篇博客之前,先讲一个东西,这个东西我们待会儿就需要用到

在tkinter中,根窗口只能有一个,也就是通过Tk()方法创建的实例对象。如果需要创建多个窗口该怎么办呢?那就需要使用另一个控件——Toplevel

在第一篇概述的主要控件列表中,我已经列出来了

Toplevel 顶层 类似框架,为其他的控件提供单独的容器

实际上该控件可以当做一个根窗体去使用,API是相同的,想要实现多个窗口,必须使用该控件才能实现

from tkinter import *
from tkinter import ttk

class GressBar():

	def start(self):
		top = Toplevel()
		self.master = top
		top.overrideredirect(True)
		top.title("进度条")
		Label(top, text="任务正在运行中,请稍等……", fg="green").pack(pady=2)
		prog = ttk.Progressbar(top, mode='indeterminate', length=200)
		prog.pack(pady=10, padx=35)
		prog.start()

		top.resizable(False, False)
		top.update()
		curWidth = top.winfo_width()
		curHeight = top.winfo_height()
		scnWidth, scnHeight = top.maxsize() 
		tmpcnf = '+%d+%d' % ((scnWidth - curWidth) / 2, (scnHeight - curHeight) / 2)
		top.geometry(tmpcnf)
		top.mainloop()

	def quit(self):
		if self.master:
			self.master.destroy()

这里写图片描述

如上代码,实际上与我们上一篇创建界面的代码没有什么不同,只是将Tk换成了Toplevel,有一点要说明,上述代码中调用了窗体的overrideredirect()方法,该方法可以去除窗体的边框,效果如上图,没有了最大最小以及关闭按钮

这里有一点必须特别注意,如果在代码中直接使用Toplevel而不创建Tk根窗体,仍然会有一个默认的根窗体,也就是说Toplevel必须依赖根窗体而存在。如果从我们已创建的根窗体启动Toplevel,则它依赖的就是我们所创建的那个根窗体,因此使用该控件时,最好有一个已创建的根窗体

到这里,我们就已经自定义好了一个tkinter的进度条控件,在做磁盘文件扫描时,会比较耗时,因此最好能有一个进度条控件。

tkinter的UI线程与异步线程

如下,我们运行一段程序

a = input('请输入:')
print(a)

执行上面的代码,在命令行界面,会等待用户输入,当我们输入一个内容,将会立刻打印该内容,但是当我们继续输入内容的时候,为什么命令行就没有任何反应了呢?其实原因很简单,当执行完print函数时,代码就已经全部执行完毕,程序结束了,程序都结束了,当然不会再响应你的输入了。

那么我们有没有这样一个疑问,为什么我们在操作带界面的程序时,只要不手动点击退出,它可以一直响应用户的各种操作呢?我们似乎感觉它即使执行完了你的一个点击操作,程序也不会退出,它仍然在随时待命,等待你给的指令。对,抓住这一点灵感,那就是它一直不退出,在Python基础时,我们就知道,一直不退出的程序,那只有死循环!于是我们改进上述代码

while 1:
    a = input('请输入:')
    print(a)

Ok,让我们再次运行上述代码,终于可以实现随时待命的程序了!但是有一点不爽,那就是当我们不想玩了的时候,我们似乎没法退出,当然我指的可不是关电脑!这就好像一个界面程序,他的边框上没有那个小×按钮,这真是让人郁闷难受,我们必须要加上这个×按钮,当然,命令行是没有按钮的,命令行通常用q命令或者exit退出,我们权且把q命令当做×按钮吧

while 1:
    a = input('请输入:')
    if a == 'q':
        break
    print(a)

怎么样,修改后的代码是不是模拟了界面程序的操作流程?实际上GUI程序的原理就和上面这几行代码表达的是一样的,GUI程序必然也有一个死循环,而这个循环所干的事情就是不断的绘制当前的界面,或者说是更新界面。如果去玩一下pygame库,那就一定体会更深了。毋庸置疑,这个循环所在的线程就是UI线程,或者说是程序的主线程。到这里,我们应该终于能明白mainloop()方法的意义了吧,调用它就代表正式进入程序的主循环,所以不调用mainloop,界面就不会被创建出来。

既然在UI线程主要负责界面的绘制与更新,那么如果在UI线程进行耗时操作,势必影响UI操作的流畅性,这也就是为什么我们要另起异步线程干活的原因所在。但是另起异步线程,不可避免的要面对异步线程与主线程交互的问题,也就是两个线程之间的通信。为什么一定要通信呢?因为在几乎所有的GUI界面编程中,异步线程是不能去操作更新当前的界面的,否则就会造成界面混乱。这很好理解,两个不同线程操作相同资源,必然是会造成混乱的,界面毕竟是在主线程创建的,当然内在原因也不仅仅是这么简单,但我们这样去理解是没错的。然而tkinter中并没有提供好的工具,因此我们只能自行实现。

这里我们通过消息队列的方式来简单实现一下,会用到Queue类,该类的相关API可查阅Pyhton文档,实际上该类就是一个队列,此处就在上一篇博客内容中的代码添加,无关代码省略

from tkinter import *
from tkinter import ttk
import tkinter.filedialog as dir
import progressbar
import queue

class AppUI():

    def __init__(self):
	    #创建一个消息队列
        self.notify_queue = queue.Queue()
        root = Tk()
        self.master = root
        self.create_menu(root)
        self.create_content(root)
        self.path = 'C:'
        root.title("磁盘文件搜索工具")
		………省略………
		#创建一个进度条对话框实例
		self.gress_bar = progressbar.GressBar()
		#在UI线程启动消息队列循环
		self.process_msg()
        root.mainloop()

    def process_msg(self):
        self.top.after(400,self.process_msg)
        while not self.notify_queue.empty():
            try:
                msg = self.notify_queue.get()
                if msg[0] == 1:
                     self.gress_bar.quit()

            except queue.Empty:
                pass

在上面代码中,我们首先创建了一个消息队列self.notify_queue,接下来使用了上文我们自定义的进度条对话框,给自定义进度条对话框的代码创建一个模块progressbar,然后导入这个模块中的GressBar即可。

接下来最关键的部分在self.process_msg()方法,我们定义了一个process_msg方法,并在进入主循环前调用了它。看到方法的具体实现,我们首先调用了根窗体的after()方法,这个方法实际上相当于一个定时器,它第一个参数传的是时间的毫秒值,第二个参数指定执行一个函数,也就是说,该方法可以定时的让主循环去执行一个函数(只会执行一次哦),此处我们传入的是self.process_msg,也就是仍然调用自己,这里相当于递归,实际上方法的递归也就是一种是循环了,每隔400毫秒执行一下自己。

process_msg方法的最后,也就是对队列的操作了,其实Queue的方法也都很好理解,通过方法名我们大致都知道是什么意思了。这里首先通过empty方法判断一下队列是否为空,如果不为空就进入while循环,然后就从队列中取出第一条消息msg,此处我们定义往消息队列中存入元组,元组第一个元素用来表示消息类型,这里判断,如果消息类型为1,就关闭进度条对话框,执行完了这一条消息,就继续判断队列是否仍有消息,如果有就继续从消息队列里面取出第二条消息执行,若无消息则退出while循环。这样我们就在主线程建立了一个消息队列,每隔一段时间去消息队列里看看,有没有什么消息是需要主线程去做的,有一点需要特别注意,主线程消息队列里也不要干耗时操作,该队列仅仅用来更新UI。

关于Python起线程的方法,和java倒有一点相似,都有两种形式,一种是定义一个类继承Thread类并实现run方法,一种是直接创建Thread类实例并传入需要被执行的方法。这些都属于Python基础知识,不再赘述。
在代码中添加如下方法:

    def execute_asyn(self):
	    #定义一个scan函数,放入线程中去执行耗时扫描
        def scan(_queue):
            pass
            _queue.put((1,))

        th = threading.Thread(target=scan,args=(self.notify_queue,))
        th.setDaemon(True)
        th.start()
        #启动进度条
        self.gress_bar.start()

具体scan扫描代码还未实现,但是架子基本就是这样了。
首先绑定调用的函数:

 file_menu.add_command(label="扫描",command=self.execute_asyn)

我们这里通过模拟耗时操作验证一下,加一点测试代码

	#定义一个scan函数,放入线程中去执行耗时扫描
        def scan(_queue):
            time.sleep(2) #让线程睡眠2秒
            _queue.put((1,))

另外补充一点,队列嘛,当然都是单向的,就像人在操场排队,肯定都是朝向主席台一个方向咯,这里我们只说明了异步线程主动去联系主线程,还没有主线程去联系异步线程,这个其实也很简单了,一个队列当然只能管一边,我们按图索骥,再创建一个队列,通过两个队列,就能实现两个线程的沟通了。

实现磁盘文件快速搜索

实际上everything原理也很简单,它所谓的秒搜功能,实际上只是在启动的时候将所有磁盘文件的路径信息存入本地数据库中,当用户需要搜索时只需要检索数据库就行,我们知道,数据库的检索是相当快速的,几乎就等同于秒搜了,但是这样实现也有一些缺陷,那就是当你不断下载大量的小文件时,检索工具的数据库需要不断更新,否则最新下载的文件是搜索不到的,往往这个时候会造成磁盘的卡顿,当然everything中也不是如此简单,其中也肯定会有一些算法来优化性能,现在我们只是简单模仿一些,做为tkinter的一个实战练习

这次我们使用的是SQLite数据库,相信有移动开发经验的都相当熟悉了,之所以使用它,因为它是如此的简单,无需各种配置,操作它只相当于操作一个文件,且性能也相当不错,完全满足我们的需求。

好了,上代码
创建一个disk.py文件

import os
import os.path as pt

def scan_file(path):
    result = []
    for root,dirs,files in os.walk(path):
        for f in files:
            file_path = pt.abspath(pt.join(root,f))

            result.append((file_path,file_path[0])) #保存路径与盘符

    return result

os.walk函数用来遍历一个给定路径下的所有文件,通常这种磁盘文件遍历,都需要写递归实现,但是Python就是这么的强大,直接提供了这样一个生成器函数,从这一个个小点就可以看出,写Python代码能省多少事,多少心!

实际上,上述代码我们是可以通过列表生成式用一行代码表达,但是作为一个长期写java的程序员,这也正是我要吐槽的地方,Python的这些语法糖,我认为往往是毒药,极大的破坏了代码的可读性,简洁只是对于写代码的人而言,对于大量阅读Python代码的人往往是灾难!我始终秉持的一个观点就是,代码是写给人看的,绝不是给机器看的,机器只认二进制!所以我认为任何破坏代码可读性的无意义的语法糖就是毒药!或许只有接手过烂代码维护的人才懂得什么叫问候对方八辈祖宗,对于大型工程生命而言,代码可读性扩展性真的是第一重要的。

Python中还有很多类似的用一句话表达一些逻辑的语法糖,可自行体会
这里还是给出列表生成式的写法

def scan_file(path):
    return [(pt.abspath(pt.join(root,f)),pt.abspath(pt.join(root,f))[0]) for root,dirs,files in os.walk(path) for f in files]

上面代码的主要功能就是扫描文件,并将路径与盘符存到一个列表中返回

下面是写入数据库的代码,创建一个database.py文件

import sqlite3

class DataMgr():

    def __init__(self):
	    #创建或打开一个数据库
        self.conn = sqlite3.connect("file.db",check_same_thread=False)
		#创建表
        self.conn.execute('create table if not exists disk_table('\
                        'id integer primary key autoincrement,'\
                        'file_path text,'\
                        'drive_letter text)')
		#创建索引,主要用来提高搜索速度
        self.conn.execute('create index if not exists index_path on disk_table (file_path)')

	#批量插入数据
    def batch_insert(self,data):
        for line in data:
            self.conn.execute("insert into disk_table values (null,?,?)",line)

        self.conn.commit()

	#模糊搜索
    def query(self,key):
        cursor = self.conn.cursor()

        cursor.execute("select file_path from disk_table where file_path like ?",('%{0}%'.format(key),))
        r = [row[0] for row in cursor]
        cursor.close()
        return r

    def close(self):
        self.conn.close()

关于sql语言的内容,不是本文的范畴,需要掌握基本的sql知识,特别是SQLite的使用,不过Python文档中已经给出了详细示例,可自行学习,这里推荐看我新写的博客入门 Python小白的数据库入门
Python GUI之tkinter 实战(二)tkinter+多线程_第1张图片
修改异步任务与搜索代码:

    def execute_asyn(self):
        #定义一个scan函数,放入线程中去执行耗时扫描
        def scan(_queue):
            if self.path:
                paths = disk.scan_file(self.path)
                self.data_mgr.batch_insert(paths)

            _queue.put((1,))

        th = threading.Thread(target=scan,args=(self.notify_queue,))
        th.setDaemon(True)
        th.start()
        #启动进度条
        self.gress_bar.start()

    def search_file(self):
        if self.search_key.get():
            result_data = self.data_mgr.query(self.search_key.get())
            if result_data:
                self.list_val.set(tuple(result_data))

到此基本功能完成,有一点需要说明,为了追求代码逻辑尽量简单,这里的实现也是有一些问题的,比如数据库是在主线程创建的,IO操作可能会耗时,影响界面流畅,且在主线程创建,在子线程操作也是有一点问题的,故设置了check_same_thread属性来规避多线程操作数据库的问题。

首先我们通过设置菜单设置一个文件较少的路径,减少测试耗时,然后通过扫描菜单完成数据的初始化加载,最后就可以在搜索框愉快的搜索文件了,如果想要全硬盘搜索,建议使用多线程,分别分配给不同的线程去扫描不同的磁盘用以提升速度,另外磁盘操作属于IO密集型,多线程的方式足矣,如果是计算密集型,则建议多进程方式,因为Python是假的多线程,所以往往习惯多进程的方式,实际上IO密集型的任务,Python的多线程是够用的

到此tkinter系列的几篇博客结束,实际上只是大体写了一下tkinter怎么玩,但我觉得这些已经足够了,剩下的只是一些组件的具体api,通过自行查文档的方式基本无压力,这里过滤了tkinter的事件处理部分,因为我觉得tkinter的事件处理部分实在太小儿科,而且也并没有什么卵用,通过command属性关联函数基本就可以实现点击事件,够用,且tkinter提供的功能相对比较弱,真正需要去处理键盘与鼠标各按键的时候,我想是不会去用tkinter库的,因为有这种需求的往往是功能比较复杂的应用,况且事件处理这一块也是比较简单的,看一下文档也基本搞定了。

有一点东西想要补充一下,在ttk模块中,为tkinter添加了一下新的控件,这些在我们第一篇博客概述中是没有罗列的,我感觉意义好像不太大,但有个东西我想提一下,就是Notebook控件,它其实就是一个选项卡控件,这个控件使用异常简单,但是对界面的设计还是有很大好处的,使用它可以让界面更加简洁美观,如何使用,只需看一下文档即可,非常简单,下面给出一张ttk新增控件目录
Python GUI之tkinter 实战(二)tkinter+多线程_第2张图片

最后给出完整的主界面模块代码:

from tkinter import *
from tkinter import ttk
import tkinter.filedialog as dir
from database import DataMgr
import queue,progressbar,threading,disk

class AppUI():

    def __init__(self):
        self.notify_queue = queue.Queue()
        root = Tk()
        self.master = root
        self.create_menu(root)
        self.create_content(root)
        self.path = 'C:'
        root.title("磁盘文件搜索工具")
        root.update()
        # root.resizable(False, False) 调用方法会禁止根窗体改变大小
        #以下方法用来计算并设置窗体显示时,在屏幕中心居中
        curWidth = root.winfo_width()
        curHeight = root.winfo_height()
        scnWidth, scnHeight = root.maxsize()
        tmpcnf = '+%d+%d' % ((scnWidth - curWidth) / 2, (scnHeight - curHeight) / 2)
        root.geometry(tmpcnf)

        self.gress_bar = progressbar.GressBar()
        self.data_mgr = DataMgr()
        self.process_msg()
        root.mainloop()


    def create_menu(self,root):
        #创建菜单栏
        menu = Menu(root)

        file_menu = Menu(menu,tearoff=0)
        # 创建二级菜单
        file_menu.add_command(label="设置路径",command=self.open_dir)
        file_menu.add_separator()
        file_menu.add_command(label="扫描",command=self.execute_asyn)

        about_menu = Menu(menu,tearoff=0)
        about_menu.add_command(label="version:1.0")

        #在菜单栏中添加菜单
        menu.add_cascade(label="文件",menu=file_menu)
        menu.add_cascade(label="关于",menu=about_menu)
        root['menu'] = menu

    def create_content(self, root):
        lf = ttk.LabelFrame(root, text="文件搜索")
        lf.pack(fill=X, padx=15, pady=8)

        top_frame = Frame(lf)
        top_frame.pack(fill=X,expand=YES,side=TOP,padx=15,pady=8)

        self.search_key = StringVar()
        ttk.Entry(top_frame, textvariable=self.search_key,width=50).pack(fill=X,expand=YES,side=LEFT)
        ttk.Button(top_frame,text="搜索",command=self.search_file).pack(padx=15,fill=X,expand=YES)

        bottom_frame = Frame(lf)
        bottom_frame.pack(fill=BOTH,expand=YES,side=TOP,padx=15,pady=8)

        band = Frame(bottom_frame)
        band.pack(fill=BOTH,expand=YES,side=TOP)

        self.list_val = StringVar()
        listbox = Listbox(band,listvariable=self.list_val,height=18)
        listbox.pack(side=LEFT,fill=X,expand=YES)

        vertical_bar = ttk.Scrollbar(band,orient=VERTICAL,command=listbox.yview)
        vertical_bar.pack(side=RIGHT,fill=Y)
        listbox['yscrollcommand'] = vertical_bar.set

        horizontal_bar = ttk.Scrollbar(bottom_frame,orient=HORIZONTAL,command=listbox.xview)
        horizontal_bar.pack(side=BOTTOM,fill=X)
        listbox['xscrollcommand'] = horizontal_bar.set

        #给list动态设置数据,set方法传入一个元组,注意此处是设置,不是插入数据,此方法调用后,list之前的数据会被清除
        self.list_val.set(('jioj',124,"fjoweghpw",1,2,3,4,5,6))

    def process_msg(self):
        self.master.after(400,self.process_msg)
        while not self.notify_queue.empty():
            try:
                msg = self.notify_queue.get()
                if msg[0] == 1:
                    self.gress_bar.quit()

            except queue.Empty:
                pass

    def execute_asyn(self):
        #定义一个scan函数,放入线程中去执行耗时扫描
        def scan(_queue):
            if self.path:
                paths = disk.scan_file(self.path)
                self.data_mgr.batch_insert(paths)

            _queue.put((1,))

        th = threading.Thread(target=scan,args=(self.notify_queue,))
        th.setDaemon(True)
        th.start()
        #启动进度条
        self.gress_bar.start()

    def search_file(self):
        if self.search_key.get():
            result_data = self.data_mgr.query(self.search_key.get())
            if result_data:
                self.list_val.set(tuple(result_data))


    def open_dir(self):
        d = dir.Directory()
        self.path = d.show(initialdir=self.path)

if __name__ == "__main__":
    AppUI()

你可能感兴趣的:(Python3新天地,Tkinter从入门到项目实战)