QThread中运行时PyQt5 QProgressBar不出现

PyQt5 QProgressBar Does Not Appear when run in QThread

这个问题被删除了,但是我把代码更新成了一个MRE。我的终端上有 运行 它,它没有任何 compilation/runtime 错误,但其行为如下所述。由于版主没有回应我最初提出的在我更正问题后重新打开问题的请求,所以我删除了旧问题并将新问题放在这里。

我的信号更新了进度值,但进度条本身从未出现。我的代码有错误吗?

(要重新创建,请将下面列出的每个文件的代码放在如下所示的项目结构中。您只需要安装 PyQt5。我在 Windows 10 上并使用 Python 3.8 带诗的虚拟环境。虚拟环境和诗是可选的)

主要

# main.py
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication

from app.controller.controller import Controller
from app.model.model import Model
from app.view.view import View


class MainApp:
    def __init__(self) -> None:
        self.controller = Controller()
        self.model: Model = self.controller.model
        self.view: View = self.controller.view

    def show(self) -> None:
        self.view.showMaximized()


if __name__ == "__main__":
    app: QApplication = QApplication([])
    app.setStyle("fusion")
    app.setAttribute(Qt.AA_DontShowIconsInMenus, True)

    root: MainApp = MainApp()
    root.show()

    app.exec_()

查看

# view.py

from typing import Any, Optional

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt, pyqtSignal


class ProgressDialog(QtWidgets.QDialog):
    def __init__(
        self,
        parent_: Optional[QtWidgets.QWidget] = None,
        title: Optional[str] = None,
    ):
        super().__init__(parent_)

        self._title = title

        self.pbar = QtWidgets.QProgressBar(self)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.pbar)
        self.setLayout(layout)

        self.resize(500, 50)

    def on_start(self):
        self.setModal(True)
        self.show()

    def on_finish(self):
        self.hide()
        self.setModal(False)
        self.pbar.reset()
        self.title = None

    def on_update(self, value: int):
        self.pbar.setValue(value)
        print(self.pbar.value())  # For debugging...

    @property
    def title(self):
        return self._title

    @title.setter
    def title(self, title_):
        self._title = title_
        self.setWindowTitle(title_)


class View(QtWidgets.QMainWindow):
    def __init__(
        self, controller, parent_: QtWidgets.QWidget = None, *args: Any, **kwargs: Any
    ) -> None:
        super().__init__(parent_, *args, **kwargs)
        self.controller: Controller = controller
        self.setWindowTitle("App")

        self.container = QtWidgets.QFrame()
        self.container_layout = QtWidgets.QVBoxLayout()

        self.container.setLayout(self.container_layout)
        self.setCentralWidget(self.container)

        # Create and position widgets
        self.open_icon = self.style().standardIcon(QtWidgets.QStyle.SP_DirOpenIcon)
        self.open_action = QtWidgets.QAction(self.open_icon, "&Open file...", self)
        self.open_action.triggered.connect(self.controller.on_press_open_button)

        self.toolbar = QtWidgets.QToolBar("Main ToolBar")
        self.toolbar.setIconSize(QtCore.QSize(16, 16))

        self.addToolBar(self.toolbar)
        self.toolbar.addAction(self.open_action)

        self.file_dialog = self._create_open_file_dialog()
        self.progress_dialog = ProgressDialog(self)

    def _create_open_file_dialog(self) -> QtWidgets.QFileDialog:
        file_dialog = QtWidgets.QFileDialog(self)

        filters = [
            "Excel Documents (*.xlsx)",
        ]

        file_dialog.setWindowTitle("Open File...")
        file_dialog.setNameFilters(filters)
        file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles)

        return file_dialog

型号

# model.py

import time
from typing import Any

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QObject, pyqtSignal


class Model(QObject):

    start_task: pyqtSignal = pyqtSignal()
    finish_task: pyqtSignal = pyqtSignal()
    update_task: pyqtSignal = pyqtSignal(int)

    def __init__(
        self,
        controller,
        *args: Any,
        **kwargs: Any,
    ) -> None:
        super().__init__()
        self.controller = controller

    def open_file(self, files: str) -> None:
        self.start_task.emit()

        for ndx, file_ in enumerate(files):
            print(file_)  # In truth, here, I'm actually performing processing
            time.sleep(1)  # Only here for simulating a long-running task
            self.update_task.emit(int((ndx + 1) / len(files) * 100))

        self.finish_task.emit()

控制器

# controller.py

from typing import Any

from app.model.model import Model
from app.view.view import View
from PyQt5 import QtCore, QtGui, QtWidgets


class Controller:
    def __init__(
        self,
        *args: Any,
        **kwargs: Any,
    ) -> None:
        self.model = Model(controller=self, *args, **kwargs)
        self.view = View(controller=self, *args, **kwargs)

    def on_press_open_button(self) -> None:
        if self.view.file_dialog.exec_() == QtWidgets.QDialog.Accepted:
            file_names = self.view.file_dialog.selectedFiles()
            self.view.progress_dialog.title = "Opening files..."

            self.thread = QtCore.QThread()
            self.model.moveToThread(self.thread)

            self.thread.started.connect(lambda: self.model.open_file(file_names))
            self.thread.finished.connect(self.thread.deleteLater)

            self.model.start_task.connect(self.view.progress_dialog.on_start)
            self.model.update_task.connect(
                lambda value: self.view.progress_dialog.on_update(value)
            )
            self.model.finish_task.connect(self.view.progress_dialog.on_finish)
            self.model.finish_task.connect(self.thread.quit)
            self.model.finish_task.connect(self.model.deleteLater)
            self.model.finish_task.connect(self.thread.deleteLater)

            self.thread.start()

当我 运行 在一个包含 6 个文件的文件夹中执行上述操作时,它并没有 运行 处理得太快(我实际上正在执行总共需要大约 5 秒的处理)。它成功完成并且我的终端输出:

16
33
50
66
83
100

但是我的ProgressDialogwindow整个过程就是这样:

如果我在 View 中的 __init__() 末尾添加 self.progress_dialog.show()(为简洁起见被删减)

# view.py

# Snip...

class View(QtWidgets.QMainWindow):

    def __init__( ... ):
        # Snip...
        self.progress_dialog.show()

然后添加进度条:

打开文件后,对话框按预期运行:

在 Kiwi Pycon 2019 上进行了一次启发性的演讲,帮助我确定了问题所在:"Python, Threads & Qt: Boom!"

  1. Every QObject is owned by a QThread
  2. A QObject instance must not be shared across threads
  3. QWidget objects (i.e. anything you can "see") are not re-entrant. Thus, they can only be called from the main UI thread.

第 3 点是我的问题。 Qt 不会阻止从主线程外部调用 QWidget 对象,但它不起作用。即使将我的 ProgressDialog 移动到创建的 QThread 也无济于事。因此,显示和隐藏 ProgressDialog 必须由主线程处理

此外,一旦 QObject 被移动到一个单独的线程,重新运行代码将给出错误:

QObject::moveToThread: Current thread (0xoldbeef) is not the object's thread (0x0).
Cannot move to target thread (0xnewbeef)

因为它没有创建新的模型对象,而是复用了旧对象。因此,不幸的是,代码必须移动到一个单独的工作对象中。

正确的代码是:

  1. on_starton_finishProgressDialog移动到View(我将它们重命名为show_progress_dialoghide_progress_dialog
  2. 创建将 open_file 逻辑放在单独的 QObject worker
  3. 自己调用 view.progress_dialog.show()(尽管发出 thread.finishedthread 可以调用 hideopen;我猜这是因为特殊逻辑当线程结束时在 Qt 中实现)

查看

from typing import Any, Optional

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt, pyqtSignal


class ProgressDialog(QtWidgets.QDialog):
    def __init__(
        self,
        parent_: Optional[QtWidgets.QWidget] = None,
        title: Optional[str] = None,
    ):
        super().__init__(parent_)

        self._title = title

        self.pbar = QtWidgets.QProgressBar(self)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.pbar)
        self.setLayout(layout)

        self.resize(500, 50)

    def on_update(self, value: int):
        self.pbar.setValue(value)

    @property
    def title(self):
        return self._title

    @title.setter
    def title(self, title_):
        self._title = title_
        self.setWindowTitle(title_)


class View(QtWidgets.QMainWindow):
    def __init__(
        self, controller, parent_: QtWidgets.QWidget = None, *args: Any, **kwargs: Any
    ) -> None:
        super().__init__(parent_, *args, **kwargs)
        self.controller: Controller = controller
        self.setWindowTitle("App")

        self.container = QtWidgets.QFrame()
        self.container_layout = QtWidgets.QVBoxLayout()

        self.container.setLayout(self.container_layout)
        self.setCentralWidget(self.container)

        # Create and position widgets
        self.open_icon = self.style().standardIcon(QtWidgets.QStyle.SP_DirOpenIcon)
        self.open_action = QtWidgets.QAction(self.open_icon, "&Open file...", self)
        self.open_action.triggered.connect(self.controller.on_press_open_button)

        self.toolbar = QtWidgets.QToolBar("Main ToolBar")
        self.toolbar.setIconSize(QtCore.QSize(16, 16))

        self.addToolBar(self.toolbar)
        self.toolbar.addAction(self.open_action)

        self.file_dialog = self._create_open_file_dialog()
        self.progress_dialog = ProgressDialog(self)

    def _create_open_file_dialog(self) -> QtWidgets.QFileDialog:
        file_dialog = QtWidgets.QFileDialog(self)

        filters = [
            "Excel Documents (*.xlsx)",
        ]

        file_dialog.setWindowTitle("Open File...")
        file_dialog.setNameFilters(filters)
        file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles)

        return file_dialog

    def show_progress_dialog(self):
        self.progress_dialog.setModal(True)
        self.progress_dialog.show()

    def hide_progress_dialog(self):
        self.progress_dialog.hide()
        self.progress_dialog.setModal(False)
        self.progress_dialog.pbar.reset()
        self.progress_dialog.title = None

型号

# model.py

import time
from typing import Any, Optional

from PyQt5.QtCore import QObject, pyqtSignal


class Model:
    def __init__(
        self,
        controller,
        *args: Any,
        **kwargs: Any,
    ) -> None:
        super().__init__()
        self.controller = controller


class OpenFileWorker(QObject):

    update: pyqtSignal = pyqtSignal(int)
    finished: pyqtSignal = pyqtSignal()

    def __init__(self) -> None:
        super().__init__()

    def open_file(self, files: str) -> None:
        for ndx, file_ in enumerate(files):
            print(file_)  # In truth, here, I'm actually performing processing
            time.sleep(1)  # Only here for simulating a long-running task
            self.update.emit(int((ndx + 1) / len(files) * 100))

        self.finished.emit()

控制器

# controller.py

from typing import Any

from app.model.model import Model, OpenFileWorker
from app.view.view import View
from PyQt5 import QtCore, QtGui, QtWidgets


class Controller:
    def __init__(
        self,
        *args: Any,
        **kwargs: Any,
    ) -> None:
        self.model = Model(controller=self, *args, **kwargs)
        self.view = View(controller=self, *args, **kwargs)

    def on_press_open_button(self) -> None:
        if self.view.file_dialog.exec_() == QtWidgets.QDialog.Accepted:
            file_names = self.view.file_dialog.selectedFiles()
            self.view.progress_dialog.title = "Opening files..."

            self.thread = QtCore.QThread()
            self.open_worker = OpenFileWorker()

            self.open_worker.moveToThread(self.thread)
            self.view.show_progress_dialog()

            self.thread.started.connect(lambda: self.open_worker.open_file(file_names))
            self.open_worker.update.connect(
                lambda value: self.view.progress_dialog.on_update(value)
            )

            self.open_worker.finished.connect(self.view.hide_progress_dialog)
            self.open_worker.finished.connect(self.thread.quit)
            self.thread.finished.connect(self.open_worker.deleteLater)

            self.thread.start()