如何在Tkinter的事件循环中运行自己的代码?

159

我弟弟刚开始学编程,他的科学展项目是模拟天空中一群鸟。他已经写了大部分代码并且运行良好,但鸟需要每时每刻移动。

然而,Tkinter会占用时间来运行自己的事件循环,因此他的代码无法运行。执行root.mainloop()会一直运行,并且唯一运行的就是事件处理程序。

是否有一种方法可以使他的代码与mainloop同时运行(不使用多线程,这会让事情变得复杂,应保持简单),如果有,是什么?

目前,他想出了一个丑陋的hack方法,将他的move()函数链接到<b1-motion>,只要他按住按钮并摇动鼠标,它就能工作。但肯定有更好的方法。


更全面、深入的讨论请参考:理解主循环 - metatoaster
5个回答

185

使用Tk对象上的after方法:

from tkinter import *

root = Tk()

def task():
    print("hello")
    root.after(2000, task)  # reschedule event in 2 seconds

root.after(2000, task)
root.mainloop()

这是after方法的声明和文档:

def after(self, ms, func=None, *args):
    """Call function once after given time.

    MS specifies the time in milliseconds. FUNC gives the
    function which shall be called. Additional parameters
    are given as parameters to the function call.  Return
    identifier to cancel scheduling with after_cancel."""

43
如果您将超时时间指定为0,任务完成后会立即放回事件循环。这不会阻塞其他事件,同时尽可能频繁地运行您的代码。 - Nathan
2
在花费数小时拉扯头发尝试让opencv和tkinter正常协作并在单击[X]按钮时干净地关闭后,这个方法连同win32gui.FindWindow(None, 'window title')一起解决了问题!我是一个新手;-) - JxAxMxIxN
2
这不是最佳选择;虽然它在这种情况下可以工作,但对于大多数脚本来说并不好(它只运行每2秒一次),而且根据@Nathan的建议将超时设置为0,因为它只在tkinter没有忙碌时运行(这可能会在某些复杂程序中引起问题)。最好坚持使用“线程”模块。 - Anonymous
哇,我花了好几个小时来调试我的图形用户界面为什么一直卡住。感觉自己很蠢,谢谢你! - Jason Waltz
1
如果你的 task() 是 CPU 密集型的,可能需要使用线程解决方案(例如由 KevinBjorn 发布的)。我最初使用 after() 来处理我的 opencv 任务,因为它看起来很简单,结果 GUI 的速度非常慢 --- 调整窗口大小大约需要 2-3 秒钟。 - weeix
同样也可以用于OpenCV,感谢提醒! - ArduinoBen

75

Bjorn的解决方案在我的计算机(RedHat Enterprise 5, python 2.6.1)上导致“RuntimeError: Calling Tcl from different appartment”错误消息。 Bjorn可能没有收到此消息,因为根据我检查的某个地方的说法,使用Tkinter处理线程不可预测且依赖于平台。

问题似乎是因为app.start()被视为对Tk的引用,因为它包含Tk元素。我通过将app.start()替换为__init__内部的self.start()来修复了这个问题。我还确保所有的Tk引用都要么在调用mainloop()的函数中,要么在被调用的函数中(这显然是避免“不同公寓”错误所必需的)。

最后,我添加了一个带有回调的协议处理程序,因为如果没有这个,当用户关闭Tk窗口时,程序会退出并显示错误。

修改后的代码如下:

# Run tkinter code in another thread

import tkinter as tk
import threading

class App(threading.Thread):

    def __init__(self):
        threading.Thread.__init__(self)
        self.start()

    def callback(self):
        self.root.quit()

    def run(self):
        self.root = tk.Tk()
        self.root.protocol("WM_DELETE_WINDOW", self.callback)

        label = tk.Label(self.root, text="Hello World")
        label.pack()

        self.root.mainloop()


app = App()
print('Now we can continue running code while mainloop runs!')

for i in range(100000):
    print(i)

6
通常您会将参数传递给 __init__(..),将它们存储在 self 中,并在 run(..) 中使用它们。 - Andre Holzner
3
根本没有显示根目录,而是出现了警告: 警告:NSWindow拖动区域只应在主线程上失效!这将在未来引发异常 - Bob Bobster
这真是救命稻草。如果你想在退出GUI后能够退出Python脚本,GUI之外的代码应该检查tkinter线程是否存活。类似于 while app.is_alive(): etc - m3nda
很棒的答案! 通常情况下,异步代码和特别是tkinter的难点在于如何清理退出。上面的代码在我的Ubuntu系统中保持了窗口显示。使用self.root.destroy()代替.quit()有所帮助,"del self.root"也有用,但仍然无法每次都干净地关闭。 看起来有效的组合是保留self.root.quit()并在self.root.mainloop()之后添加"del self.root"。 - Amnon Harel
顺便提一下:您可以使用全局变量进行线程间通信(需要注意常见的陷阱),甚至可以通过从主线程发出tk事件来实现,就像这个链接中所示:https://dev59.com/iXVC5IYBdhLWcg3whBaj - Amnon Harel
显示剩余4条评论

28
当你编写自己的循环,例如在模拟中,你需要调用 update 函数,它会执行与 mainloop 相同的操作:更新窗口并呈现你所做的更改,但你需要在自己的循环中实现这一点。
def task():
   # do something
   root.update()

while 1:
   task()  

14
处理此类编程任务需要非常小心。如果任何事件导致调用“task”,您将得到嵌套的事件循环,这是不好的。除非您完全理解事件循环的工作原理,否则应尽一切可能避免调用“update”。 - Bryan Oakley
我曾经使用过这种技术——效果还可以,但是根据你的实现方式,可能会导致用户界面出现一些卡顿。 - jldupont
@Bryan Oakley,更新是一个循环吗?那会有什么问题吗? - Green05

8
另一种选择是让tkinter在单独的线程上执行。做法之一如下所示:
import Tkinter
import threading

class MyTkApp(threading.Thread):
    def __init__(self):
        self.root=Tkinter.Tk()
        self.s = Tkinter.StringVar()
        self.s.set('Foo')
        l = Tkinter.Label(self.root,textvariable=self.s)
        l.pack()
        threading.Thread.__init__(self)

    def run(self):
        self.root.mainloop()


app = MyTkApp()
app.start()

# Now the app should be running and the value shown on the label
# can be changed by changing the member variable s.
# Like this:
# app.s.set('Bar')

不过需要注意的是,多线程编程很难,并且很容易犯错。例如,在更改上面示例类的成员变量时,您必须小心,以免干扰Tkinter的事件循环。


3
不确定这个方法是否可行。我尝试了类似的东西,但出现了“RuntimeError: main thread is not in main loop”的错误提示。 - jldupont
6
jldupont: 我遇到了“RuntimeError:在不同的公寓中调用Tcl”(可能是不同版本中的相同错误)。修复方法是在run()中初始化Tk,而不是在__init __()中。这意味着您正在在调用mainloop()的同一线程中初始化Tk。 - mgiuca

4

这是将成为GPS阅读器和数据呈现器的第一个工作版本。tkinter是一种非常脆弱的东西,错误消息太少了。它不会呈现内容,也不能经常告诉你原因。对于一个优秀的所见即所得表单开发者来说,这很困难。无论如何,它每秒运行一次小程序,并在表单上呈现信息。要让它发生需要一段时间。当我尝试使用计时器值为0时,表单从未出现过。现在我的头疼了!每秒10次或更多次对我来说足够好了。我希望它能帮助其他人。Mike Morrow

import tkinter as tk
import time

def GetDateTime():
  # Get current date and time in ISO8601
  # https://en.wikipedia.org/wiki/ISO_8601 
  # https://xkcd.com/1179/
  return (time.strftime("%Y%m%d", time.gmtime()),
          time.strftime("%H%M%S", time.gmtime()),
          time.strftime("%Y%m%d", time.localtime()),
          time.strftime("%H%M%S", time.localtime()))

class Application(tk.Frame):

  def __init__(self, master):

    fontsize = 12
    textwidth = 9

    tk.Frame.__init__(self, master)
    self.pack()

    tk.Label(self, font=('Helvetica', fontsize), bg = '#be004e', fg = 'white', width = textwidth,
             text='Local Time').grid(row=0, column=0)
    self.LocalDate = tk.StringVar()
    self.LocalDate.set('waiting...')
    tk.Label(self, font=('Helvetica', fontsize), bg = '#be004e', fg = 'white', width = textwidth,
             textvariable=self.LocalDate).grid(row=0, column=1)

    tk.Label(self, font=('Helvetica', fontsize), bg = '#be004e', fg = 'white', width = textwidth,
             text='Local Date').grid(row=1, column=0)
    self.LocalTime = tk.StringVar()
    self.LocalTime.set('waiting...')
    tk.Label(self, font=('Helvetica', fontsize), bg = '#be004e', fg = 'white', width = textwidth,
             textvariable=self.LocalTime).grid(row=1, column=1)

    tk.Label(self, font=('Helvetica', fontsize), bg = '#40CCC0', fg = 'white', width = textwidth,
             text='GMT Time').grid(row=2, column=0)
    self.nowGdate = tk.StringVar()
    self.nowGdate.set('waiting...')
    tk.Label(self, font=('Helvetica', fontsize), bg = '#40CCC0', fg = 'white', width = textwidth,
             textvariable=self.nowGdate).grid(row=2, column=1)

    tk.Label(self, font=('Helvetica', fontsize), bg = '#40CCC0', fg = 'white', width = textwidth,
             text='GMT Date').grid(row=3, column=0)
    self.nowGtime = tk.StringVar()
    self.nowGtime.set('waiting...')
    tk.Label(self, font=('Helvetica', fontsize), bg = '#40CCC0', fg = 'white', width = textwidth,
             textvariable=self.nowGtime).grid(row=3, column=1)

    tk.Button(self, text='Exit', width = 10, bg = '#FF8080', command=root.destroy).grid(row=4, columnspan=2)

    self.gettime()
  pass

  def gettime(self):
    gdt, gtm, ldt, ltm = GetDateTime()
    gdt = gdt[0:4] + '/' + gdt[4:6] + '/' + gdt[6:8]
    gtm = gtm[0:2] + ':' + gtm[2:4] + ':' + gtm[4:6] + ' Z'  
    ldt = ldt[0:4] + '/' + ldt[4:6] + '/' + ldt[6:8]
    ltm = ltm[0:2] + ':' + ltm[2:4] + ':' + ltm[4:6]  
    self.nowGtime.set(gdt)
    self.nowGdate.set(gtm)
    self.LocalTime.set(ldt)
    self.LocalDate.set(ltm)

    self.after(100, self.gettime)
   #print (ltm)  # Prove it is running this and the external code, too.
  pass

root = tk.Tk()
root.wm_title('Temp Converter')
app = Application(master=root)

w = 200 # width for the Tk root
h = 125 # height for the Tk root

# get display screen width and height
ws = root.winfo_screenwidth()  # width of the screen
hs = root.winfo_screenheight() # height of the screen

# calculate x and y coordinates for positioning the Tk root window

#centered
#x = (ws/2) - (w/2)
#y = (hs/2) - (h/2)

#right bottom corner (misfires in Win10 putting it too low. OK in Ubuntu)
x = ws - w
y = hs - h - 35  # -35 fixes it, more or less, for Win10

#set the dimensions of the screen and where it is placed
root.geometry('%dx%d+%d+%d' % (w, h, x, y))

root.mainloop()

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接