在 Tkinter 中执行函数期间程序 Toplevel 冻结

Program Toplevel freezing during the execution of a function in Tkinter

我有一个结构如下的 tkinter 应用程序:

import tkinter as tk
import tkinter.ttk as ttk


class TopLevelWindow(tk.Toplevel):
    def __init__(self, root, *args, **kargs):
        super().__init__(root, *args, **kargs)
        self.root = root
        self.button= ttk.Button(self, text="Exportar excel", command=self.go)
        self.button.pack()
        self.prog_bar = ttk.Progressbar(self, orient = "horizontal", mode= "determinate")
        self.prog_bar.pack()

    def go(self):
        global dic_Data
        # Create word file
        value_bar = 100/len(dic_Data)
        for k, v in dic_Data.items():
            # Calculate some stuff
            # Create Pil images and add them to the word file
            # Create Matplotlib images and add them to the word file
            self.prog_bar["value"] += value_bar
            self.root.update_idletasks()
        #Save the word file
        self.quit()
        self.destroy()


class App():
    def __init__(self, root, *params):
        self.root = root
        # Code
        # dic_Data gets populated

    def open_window(self):
        popup = TopLevelWindow(self.root)
        popup.mainloop()


dic_Data = {}

root = tk.Tk()
app = App(root)
root.mainloop()

我遇到的问题是函数 go 最终冻结了我的 Toplevel window。

奇怪的是它开始为 for 循环的第一项做 okey, 然后它冻结但功能保持 运行 正常(栏上没有视觉更新), 直到 for 循环结束,然后 windows 按预期关闭,所有内容再次解冻。

为什么会这样,是否可以解决?

已编辑: 一个答案建议使用 root.after 函数来避免冻结,但我在实现它时遇到了问题。这就是我所拥有的:

import tkinter as tk
import tkinter.ttk as ttk


class TopLevelWindow(tk.Toplevel):
    def __init__(self, root, *args, **kargs):
        super().__init__(root, *args, **kargs)
        self.root = root
        self.button = ttk.Button(self, text="Exportar excel", command=self.go)
        self.button.pack()
        self.prog_bar = ttk.Progressbar(self, orient = "horizontal", mode= "determinate")
        self.prog_bar.pack()

    def heavy_function(self, k, v):
        # Calculate some stuff
        # Create Pil images and add the to the word file
        # Create Matplotlib images and add the to the word file
        # Add changes to word file
        self.prog_bar["value"] += value_bar

    def save_word(self):
        #Save the word file
        self.quit()
        self.destroy()

    def go(self):
        global dic_Data
        # Create word file
        value_bar = 100/len(dic_Data)
        for k, v in dic_Data.items():
            self.root.after(10, lambda: self.heavy_function(k, v))
        self.root.after(10, self.save_word)


class App():
    def __init__(self, root, *params):
        self.root = root
        # Code
        # dic_Data gets populated

    def open_window(self):
        popup = TopLevelWindow(self.root)
        popup.mainloop()


dic_Data = {}

root = tk.Tk()
app = App(root)
root.mainloop()

您已将按钮命名为与函数 (go) 同名的名称。相反,您必须将其命名为其他名称并将其与进度条一起打包:

import time
import tkinter as tk
import tkinter.ttk as ttk


class TopLevelWindow(tk.Toplevel):
    def __init__(self, root, *args, **kargs):
        super().__init__(root, *args, **kargs)
        self.root = root
        self.button = ttk.Button(self, text="Exportar excel", command=self.go)
        self.button.pack()  # packing button
        self.prog_bar = ttk.Progressbar(self, orient="horizontal", mode="determinate")
        self.prog_bar.pack()  # packing progress bar

    def go(self):
        global dic_Data
        # Create word file
        value_bar = 100 / len(dic_Data)
        for k, v in dic_Data.items():
            time.sleep(1)  # to visualize the progressbar
            # Calculate some stuff
            # Create Pil images and add them to the word file
            # Create Matplotlib images and add them to the word file
            self.prog_bar["value"] += value_bar
            self.root.update_idletasks()
        # Save the word file
        self.quit()
        self.destroy()


class App:
    def __init__(self, root, *params):
        self.root = root
        # Code
        # dic_Data gets populated

    def open_window(self):
        popup = TopLevelWindow(self.root)
        popup.mainloop()


# some data to avoid divide by zero
dic_Data = {'a': 'Something', 'B': 'Something else', 'C': 'Nothing', 'D': 'Nothing else'}

root = tk.Tk()
app = App(root)
app.open_window()  # calling the open window function
root.mainloop()

tl;dr 发生的事情是 self.go 只是花费了太长时间并停止了事件的处理。

self.go 这样的回调会在 中执行 mainloop 每当您激活控件时。 只要回调是运行,事件处理就会中断。 因此,如果所述回调花费的时间超过 50 毫秒,你会注意到它。

在使用 event-driven 工具包(如 tkinter)的程序中,基本上有三种方法可以完成 long-running 工作。

  1. 将较长的 运行 作业分成小块,使用 after 方法执行。这使得更新进度条变得容易。下面显示了我自己的代码示例。

  2. 使用 multiprocessing 在单独的进程中执行 long-running 作业。这绝对不会 阻止 GUI。但是您将需要使用通信原语来告诉 GUI 操作已完成,并且 GUI 必须使用 after 方法定期检查这些操作。

  3. 在单独的线程中执行 long-running 作业。另一个复杂因素是在 CPython 中一次只能有一个线程可以执行 Python 字节码。因此,不能保证不会阻止 GUI。但总的来说,Python 3 会经常尝试切换线程。 您将需要一个在启用线程的情况下构建的 tkinter。 示例如下所示。

示例 (1):使用小步骤解锁 excel 个文件

"""Remove passwords from modern excel 2007+ files (xlsx, xlsm)."""

from types import SimpleNamespace
import os
import re
import shutil
import stat
import sys
import zipfile

from tkinter import filedialog
from tkinter import ttk
from tkinter.font import nametofont
import tkinter as tk

__version__ = "2022.01.28"
widgets = SimpleNamespace()
state = SimpleNamespace()


def create_widgets(root, w):
    """Create the window and its widgets.
    Arguments:
        root: the root window.
        w: SimpleNamespace to store widgets.
    """
    # Set the font.
    default_font = nametofont("TkDefaultFont")
    default_font.configure(size=12)
    root.option_add("*Font", default_font)
    # General commands and bindings
    root.bind_all('q', do_exit)
    root.wm_title('Unlock excel files v' + __version__)
    root.columnconfigure(3, weight=1)
    root.rowconfigure(5, weight=1)
    # First row
    ttk.Label(root, text='(1)').grid(row=0, column=0, sticky='ew')
    w.fb = ttk.Button(root, text="Select file", command=do_file)
    w.fb.grid(row=0, column=1, columnspan=2, sticky="w")
    w.fn = ttk.Label(root)
    w.fn.grid(row=0, column=3, columnspan=2, sticky="ew")
    # Second row
    ttk.Label(root, text='(2)').grid(row=1, column=0, sticky='ew')
    w.backup = tk.IntVar()
    w.backup.set(0)
    ttk.Checkbutton(root, text='backup', variable=w.backup,
                    command=on_backup).grid(row=1, column=1, sticky='ew')
    w.suffixlabel = ttk.Label(root, text='suffix:', state=tk.DISABLED)
    w.suffixlabel.grid(row=1, column=2, sticky='ew')
    w.suffix = tk.StringVar()
    w.suffix.set('-orig')
    se = ttk.Entry(root, justify='left', textvariable=w.suffix, state=tk.DISABLED)
    se.grid(row=1, column=3, columnspan=1, sticky='w')
    w.suffixentry = se
    # Third row
    ttk.Label(root, text='(3)').grid(row=2, column=0, sticky='ew')
    w.gobtn = ttk.Button(root, text="Go!", command=do_start, state=tk.DISABLED)
    w.gobtn.grid(row=2, column=1, sticky='ew')
    # Fourth row
    ttk.Label(root, text='(4)').grid(row=3, column=0, sticky='ew')
    ttk.Label(root, text='Progress:').grid(row=3, column=1, sticky='w')
    # Fifth row
    sb = tk.Scrollbar(root, orient="vertical")
    w.status = tk.Listbox(root, width=60, yscrollcommand=sb.set)
    w.status.grid(row=4, rowspan=5, column=1, columnspan=3, sticky="nsew")
    sb.grid(row=4, rowspan=5, column=5, sticky="ns")
    sb.config(command=w.status.yview)
    # Ninth row
    ttk.Button(root, text="Quit", command=do_exit).grid(row=9, column=1, sticky='ew')


def initialize_state(s):
    """
    Initialize the global state.
    Arguments:
        s: SimpleNamespace to store application state.
    """
    s.interval = 10
    s.path = ''
    s.inzf, s.outzf = None, None
    s.infos = None
    s.currinfo = None
    s.worksheets_unlocked = 0
    s.workbook_unlocked = False
    s.directory = None
    s.remove = None


def statusmsg(text):
    """Append a message to the status listbox, and make sure it is visible."""
    widgets.status.insert(tk.END, text)
    widgets.status.see(tk.END)


# Step functions to call in the after() method.
def step_open_zipfiles():
    path = widgets.fn['text']
    state.path = path
    statusmsg(f'Opening “{path}”...')
    first, last = path.rsplit('.', maxsplit=1)
    if widgets.backup.get():
        backupname = first + widgets.suffix.get() + '.' + last
    else:
        backupname = first + '-orig' + '.' + last
        state.remove = backupname
    shutil.move(path, backupname)
    state.inzf = zipfile.ZipFile(backupname, mode="r")
    state.outzf = zipfile.ZipFile(
        path, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=1
    )
    root.after(state.interval, step_discover_internal_files)


def step_discover_internal_files():
    statusmsg(f'Reading “{state.path}”...')
    state.infos = [name for name in state.inzf.infolist()]
    state.currinfo = 0
    statusmsg(f'“{state.path}” contains {len(state.infos)} internal files.')
    root.after(state.interval, step_filter_internal_file)


def step_filter_internal_file():
    current = state.infos[state.currinfo]
    stat = f'Processing “{current.filename}” ({state.currinfo+1}/{len(state.infos)})...'
    statusmsg(stat)
    # Doing the actual work
    regex = None
    data = state.inzf.read(current)
    if b'sheetProtect' in data:
        regex = r'<sheetProtect.*?/>'
        statusmsg(f'Worksheet "{current.filename}" is protected.')
    elif b'workbookProtect' in data:
        regex = r'<workbookProtect.*?/>'
        statusmsg('The workbook is protected')
    else:
        state.outzf.writestr(current, data)
    if regex:
        text = data.decode('utf-8')
        newtext = re.sub(regex, '', text)
        if len(newtext) != len(text):
            state.outzf.writestr(current, newtext)
            state.worksheets_unlocked += 1
            statusmsg(f'Removed password from "{current.filename}".')
    # Next iteration or next step.
    state.currinfo += 1
    if state.currinfo >= len(state.infos):
        statusmsg('All internal files processed.')
        state.currinfo = None
        root.after(state.interval, step_close_zipfiles)
    else:
        root.after(state.interval, step_filter_internal_file)


def step_close_zipfiles():
    statusmsg(f'Writing “{state.path}”...')
    state.inzf.close()
    state.outzf.close()
    state.inzf, state.outzf = None, None
    root.after(state.interval, step_finished)


def step_finished():
    if state.remove:
        os.chmod(state.remove, stat.S_IWRITE)
        os.remove(state.remove)
        state.remove = None
    else:
        statusmsg('Removing temporary file')
    statusmsg(f'Unlocked {state.worksheets_unlocked} worksheets.')
    statusmsg('Finished!')
    widgets.gobtn['state'] = 'disabled'
    widgets.fn['text'] = ''
    state.path = ''


# Widget callbacks
def do_file():
    """Callback to open a file"""
    if not state.directory:
        state.directory = ''
        available = [os.environ[k] for k in ('HOME', 'HOMEDRIVE') if k in os.environ]
        if available:
            state.directory = available[0]
    fn = filedialog.askopenfilename(
        title='Excel file to open',
        parent=root,
        defaultextension='.xlsx',
        filetypes=(
            ('excel files', '*.xls*'), ('all files', '*.*')
        ),
    )
    if not fn:
        return
    state.directory = os.path.dirname(fn)
    state.worksheets_unlocked = 0
    state.workbook_unlocked = False
    state.path = fn
    widgets.fn['text'] = fn
    widgets.gobtn['state'] = 'enabled'
    widgets.status.delete(0, tk.END)


def on_backup():
    if widgets.backup.get() == 1:
        widgets.suffixlabel['state'] = 'enabled'
        widgets.suffixentry['state'] = 'enabled'
    else:
        widgets.suffixlabel['state'] = 'disabled'
        widgets.suffixentry['state'] = 'disabled'


def do_start():
    root.after(state.interval, step_open_zipfiles)


def do_exit(arg=None):
    """
    Callback to handle quitting.
    """
    root.destroy()


if __name__ == '__main__':
    # Detach from the command line on UNIX systems.
    if os.name == 'posix':
        if os.fork():
            sys.exit()    # Create the GUI window.
    root = tk.Tk(None)
    # Use a dialog window so that it floats even when using a tiling window
    # manager.
    root.attributes('-type', 'dialog')
    # Don't show hidden files in the file dialog
    # 
    try:
        # call a dummy dialog with an impossible option to initialize the file
        # dialog without really getting a dialog window; this will throw a
        # TclError, so we need a try...except :
        try:
            root.tk.call('tk_getOpenFile', '-foobarbaz')
        except tk.TclError:
            pass
        # now set the magic variables accordingly
        root.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1')
        root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
    except Exception:
        pass
    create_widgets(root, widgets)
    initialize_state(state)
    root.mainloop()

(3) 使用线程解锁 excel 个文件的示例

"""Remove passwords from modern excel 2007+ files (xlsx, xlsm).
This is a multithreaded version of unlock-excel.pyw.  All the work that was
there done in steps in the mainloop is now done in a single additional thread.
There is some confusion whether tkinter is thread-safe.  That is, if one can
call tkinter functions and methods from any but the main thread.  The
documentation for Python 3 says “yes”.  Comments in the C source code for
tkinter say “its complicated” depending on how tcl is built.  *Many* online
sources say “no”, but that could just be an echo chamber effect.
The author has tested this code on FreeBSD 12.1-STABLE amd64 using CPython
3.7.7 combined with a tcl built with threading enabled.  There at least it
seems to work without problems.
"""

from types import SimpleNamespace
import os
import re
import shutil
import stat
import sys
import threading
import zipfile

from tkinter import filedialog
from tkinter import ttk
from tkinter.font import nametofont
import tkinter as tk

__version__ = "2022.01.28"
widgets = SimpleNamespace()
state = SimpleNamespace()


def create_widgets(root, w):
    """Create the window and its widgets.
    Arguments:
        root: the root window.
        w: SimpleNamespace to store widgets.
    """
    # Set the font.
    default_font = nametofont("TkDefaultFont")
    default_font.configure(size=12)
    root.option_add("*Font", default_font)
    # General commands and bindings
    root.bind_all('q', do_exit)
    root.wm_title('Unlock excel files v' + __version__)
    root.columnconfigure(3, weight=1)
    root.rowconfigure(5, weight=1)
    # First row
    ttk.Label(root, text='(1)').grid(row=0, column=0, sticky='ew')
    w.fb = ttk.Button(root, text="Select file", command=do_file)
    w.fb.grid(row=0, column=1, columnspan=2, sticky="w")
    w.fn = ttk.Label(root)
    w.fn.grid(row=0, column=3, columnspan=2, sticky="ew")
    # Second row
    ttk.Label(root, text='(2)').grid(row=1, column=0, sticky='ew')
    w.backup = tk.IntVar()
    w.backup.set(0)
    ttk.Checkbutton(root, text='backup', variable=w.backup,
                    command=on_backup).grid(row=1, column=1, sticky='ew')
    w.suffixlabel = ttk.Label(root, text='suffix:', state=tk.DISABLED)
    w.suffixlabel.grid(row=1, column=2, sticky='ew')
    w.suffix = tk.StringVar()
    w.suffix.set('-orig')
    se = ttk.Entry(root, justify='left', textvariable=w.suffix, state=tk.DISABLED)
    se.grid(row=1, column=3, columnspan=1, sticky='w')
    w.suffixentry = se
    # Third row
    ttk.Label(root, text='(3)').grid(row=2, column=0, sticky='ew')
    w.gobtn = ttk.Button(root, text="Go!", command=do_start, state=tk.DISABLED)
    w.gobtn.grid(row=2, column=1, sticky='ew')
    # Fourth row
    ttk.Label(root, text='(4)').grid(row=3, column=0, sticky='ew')
    ttk.Label(root, text='Progress:').grid(row=3, column=1, sticky='w')
    # Fifth row
    sb = tk.Scrollbar(root, orient="vertical")
    w.status = tk.Listbox(root, width=60, yscrollcommand=sb.set)
    w.status.grid(row=4, rowspan=5, column=1, columnspan=3, sticky="nsew")
    sb.grid(row=4, rowspan=5, column=5, sticky="ns")
    sb.config(command=w.status.yview)
    # Ninth row
    ttk.Button(root, text="Quit", command=do_exit).grid(row=9, column=1, sticky='ew')


def initialize_state(s):
    """
    Initialize the global state.
    Arguments:
        s: SimpleNamespace to store application state.
    """
    s.directory = None


def statusmsg(text):
    """Append a message to the status listbox, and make sure it is visible."""
    widgets.status.insert(tk.END, text)
    widgets.status.see(tk.END)


def process_zipfile_thread():
    """Function to process a zip-file. This is to be run in a thread."""
    path = widgets.fn['text']
    statusmsg(f'Opening “{path}”...')
    first, last = path.rsplit('.', maxsplit=1)
    if widgets.backup.get():
        backupname = first + widgets.suffix.get() + '.' + last
        remove = None
    else:
        backupname = first + '-orig' + '.' + last
        remove = backupname
    shutil.move(path, backupname)
    with zipfile.ZipFile(backupname, mode="r") as inzf, \
            zipfile.ZipFile(
                path, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=1
            ) as outzf:
        statusmsg(f'Reading “{path}”...')
        infos = [name for name in inzf.infolist()]
        statusmsg(f'“{path}” contains {len(infos)} internal files.')
        worksheets_unlocked = 0
        for idx, current in enumerate(infos, start=1):
            smsg = f'Processing “{current.filename}” ({idx}/{len(infos)})...'
            statusmsg(smsg)
            # Doing the actual work
            regex = None
            data = inzf.read(current)
            if b'sheetProtect' in data:
                regex = r'<sheetProtect.*?/>'
                statusmsg(f'Worksheet "{current.filename}" is protected.')
            elif b'workbookProtect' in data:
                regex = r'<workbookProtect.*?/>'
                statusmsg('The workbook is protected')
            else:
                outzf.writestr(current, data)
            if regex:
                text = data.decode('utf-8')
                newtext = re.sub(regex, '', text)
                if len(newtext) != len(text):
                    outzf.writestr(current, newtext)
                    worksheets_unlocked += 1
                    statusmsg(f'Removed password from "{current.filename}".')
    statusmsg('All internal files processed.')
    statusmsg(f'Writing “{path}”...')
    if remove:
        os.chmod(remove, stat.S_IWRITE)
        os.remove(remove)
    else:
        statusmsg('Removing temporary file')
    statusmsg(f'Unlocked {state.worksheets_unlocked} worksheets.')
    statusmsg('Finished!')
    widgets.gobtn['state'] = 'disabled'
    widgets.fn['text'] = ''


# Widget callbacks
def do_file():
    """Callback to open a file"""
    if not state.directory:
        state.directory = ''
        available = [os.environ[k] for k in ('HOME', 'HOMEDRIVE') if k in os.environ]
        if available:
            state.directory = available[0]
    fn = filedialog.askopenfilename(
        title='Excel file to open',
        parent=root,
        defaultextension='.xlsx',
        filetypes=(('excel files', '*.xls*'), ('all files', '*.*')),
    )
    if not fn:
        return
    state.directory = os.path.dirname(fn)
    state.worksheets_unlocked = 0
    state.workbook_unlocked = False
    widgets.fn['text'] = fn
    widgets.gobtn['state'] = 'enabled'
    widgets.status.delete(0, tk.END)


def on_backup():
    if widgets.backup.get() == 1:
        widgets.suffixlabel['state'] = 'enabled'
        widgets.suffixentry['state'] = 'enabled'
    else:
        widgets.suffixlabel['state'] = 'disabled'
        widgets.suffixentry['state'] = 'disabled'


def do_start():
    worker = threading.Thread(target=process_zipfile_thread)
    worker.start()


def do_exit(arg=None):
    """
    Callback to handle quitting.
    """
    root.destroy()


if __name__ == '__main__':
    # Detach from the command line on UNIX systems.
    if os.name == 'posix':
        if os.fork():
            sys.exit()
    # Create the GUI window.
    root = tk.Tk(None)
    # Use a dialog window so that it floats even when using a tiling window manager.
    if os.name == 'posix':
        root.attributes('-type', 'dialog')
    # Don't show hidden files in the file dialog
    # 
    try:
        # call a dummy dialog with an impossible option to initialize the file
        # dialog without really getting a dialog window; this will throw a
        # TclError, so we need a try...except :
        try:
            root.tk.call('tk_getOpenFile', '-foobarbaz')
        except tk.TclError:
            pass
        # now set the magic variables accordingly
        root.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1')
        root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
    except Exception:
        pass
    create_widgets(root, widgets)
    initialize_state(state)
    root.mainloop()

我已经成功实现了 Roland Smith 的答案选项 1。 现在它可以工作了,希望这是有道理的:

import tkinter as tk
import tkinter.ttk as ttk


class TopLevelWindow(tk.Toplevel):
    def __init__(self, root, *args, **kargs):
        super().__init__(root, *args, **kargs)
        self.root = root
        self.button = ttk.Button(self, text="Exportar excel", command=self.go)
        self.button.pack()
        self.prog_bar = ttk.Progressbar(self, orient = "horizontal", mode= "determinate")
        self.prog_bar.pack()

    def heavy_function(self):
        try:
            k, v = next(self.order)
        except StopIteration:
            self.after(10, self.save_word)
            return
        # Calculate some stuff
        # Create Pil images and add the to the word file
        # Create Matplotlib images and add the to the word file
        self.prog_bar["value"] += self.value_bar
        self.after(10, self.heavy_function)

    def save_word(self):
        #Save the word file
        self.quit()
        self.destroy()

    def go(self):
        global dic_Data
        # Create word file
        self.value_bar = 100/len(dic_Data)
        self.order = iter(dic_Data.items())
        self.after(10, self.heavy_function)


class App():
    def __init__(self, root, *params):
        self.root = root
        # Code
        # dic_Data gets populated

    def open_window(self):
        popup = TopLevelWindow(self.root)
        popup.mainloop()


dic_Data = {}

root = tk.Tk()
app = App(root)
root.mainloop()