为什么我的 PyQt 应用程序没有正确关闭

Why isn't my PyQt application closing correctly

我正在制作一个图片库,我希望能够在其中一次细读大量图片样本 (~1500)。到目前为止一切正常,但我 运行 遇到了一个错误,该错误在我尝试关闭程序时发生。

加载过程中主要有两个繁重的进程:

我使用多线程执行的第一个任务,效果很好。第二,我必须在主线程上执行,因为那是你唯一可以执行的地方 addWidget。以前,这导致我的应用程序冻结,但现在我使用 QCoreApplication.processEvents() 并且 UI 能够继续正常工作。

但是,如果我在第二个任务仍在运行时尝试关闭程序(使用“X”按钮),UI 会关闭,但 Python 在后台保持打开状态并且shell 仍然没有反应。我必须从任务管理器中手动终止 Python 才能重新获得对 shell 的控制权。当主 window 试图关闭但效果仍然存在时,我尝试使用信号告诉一切停止工作。澄清一下,如果我尝试在 after 完成 addWidget 操作后关闭,一切都会正常关闭。

相关代码如下:

from PySide6.QtCore import QCoreApplication, QObject, QThread, QSize, Signal, Slot
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget
from PySide6.QtGui import QImage, QPixmap
from PIL import Image

import sys, os, time

PLACEHOLDER_PATH = "placeholder.png"
IMAGES_PATH = "gallery_images/"

class Gallery(QWidget):
    tryingToClose = Signal()
    def __init__(self, controller):
        self.controller = controller
        super().__init__()

        self.stopWorking = False
        self.minThumbWidth = 128
        self.thumbs = []
        self.thumbPlaceholderPath = PLACEHOLDER_PATH
        self.thumbPlaceholderPixmap = QPixmap(self.thumbPlaceholderPath)
        self.setupUI()

    def closeEvent(self, event):
        event.accept()
        self.tryingToClose.emit() # let the window close
        self.stopWorking = True

    def clearThumbs(self):
        self.thumbs = []
        while self.thumbsLayout.count():
            child = self.layout.takeAt(0)
            if child.widget():
                child.widget().deleteLater()

    def generateThumbs(self, paths):
        self.clearThumbs()
        self.thumbs = [ImageThumb(path, self.minThumbWidth, self.thumbPlaceholderPixmap) for path in paths]

    def updateThumbs(self):
        print('Starting op2 (closing shouldn\'t work properly now)')
        for i, thumb in enumerate(self.thumbs): #this is heavy operation #2
            if self.stopWorking: 
                print('Aborting thumbs loading (closing will still be broken, but why?)')
                return

            self.thumbsLayout.addWidget(thumb)
            #i add this sleep here to simulate having lots of images to process, in case you dont have a directory with a lot of images
            # without this you have to click very fast to close the window to observe the effect
            time.sleep(0.1)
            QCoreApplication.processEvents()
        print('Finished op2 (closing should work fine now)')

    def setupUI(self):
        self.mainFrame = QVBoxLayout()
        self.scrollArea = QScrollArea()
        self.scrollFrame = QWidget()

        self.thumbsLayout = QHBoxLayout(self.scrollFrame)
        self.scrollArea.setWidgetResizable(True)
        self.scrollArea.setWidget(self.scrollFrame)
        self.scrollFrame.setLayout(self.thumbsLayout)
        self.mainFrame.addWidget(self.scrollArea)
        
        self.setLayout(self.mainFrame)

class ImageThumb(QLabel):
    def __init__(self, path, size, placeholder):
        super().__init__()
        self.thumbSize = size
        self.placeholder = placeholder.scaled(QSize(size, size))
        self.path = path
        self.setMinimumSize(1, 1)
        self.setPixmap(self.placeholder)

    def setup(self):
        pImg = Image.open(self.path)
        pImg.thumbnail((self.thumbSize, self.thumbSize))
        pImg = pImg.convert("RGBA")
        data = pImg.tobytes("raw","RGBA")
        qImg = QImage(data, pImg.size[0], pImg.size[1], QImage.Format.Format_RGBA8888)
        qImg = QPixmap.fromImage(qImg)
        self.setPixmap(qImg)


class ImageWorker(QObject):
    tookThumb = Signal(int, int)
    finishedThumb = Signal(int, int)
    finished = Signal(int)

    def __init__(self, workerIndex, manager):
        super().__init__()
        self.index = workerIndex
        self.manager = manager

    @Slot()
    def run(self):
        try:
            while self.manager.doesWorkExist():
                thumbIndex, thumb = self.manager.takeThumb(self.index)
                if thumbIndex == -1:
                    self.finished.emit(self.index)
                    return

                self.tookThumb.emit(self.index, thumbIndex)
                thumb.setup()
                self.finishedThumb.emit(self.index, thumbIndex)
        except Exception as e:
            print(f'Worker {self.index} died to {e}')
        self.finished.emit(self.index)

class ImageManager(QObject):
    def __init__(self, ):
        super().__init__()
        self.thumbWidgets = []#thumbList
        self.maxThreads = 4
        self.thumbLocks = {}
        self.threads = [QThread() for i in range(self.maxThreads)]
        self.workers = [ImageWorker(i, self) for i in range(self.maxThreads)]
        self.ignoreWork = False

    def setThumbsList(self, newList):
        self.thumbWidgets = newList
        self.thumbLocks = {i: False for i in range(len(self.thumbWidgets))}

    def doesWorkExist(self):
        allThumbsLocked = all(self.thumbLocks.values())
        return (not allThumbsLocked) and (not self.ignoreWork)

    def stopWorking(self):
        self.ignoreWork = True
        for thread in self.threads:
            thread.quit()

    def takeThumb(self, workerIndex):
        for thumbIndex, isLocked in self.thumbLocks.items():
            if isLocked == False:
                self.thumbLocks[thumbIndex] = True
                return thumbIndex, self.thumbWidgets[thumbIndex]
        
        return -1, None

    def spawnThreads(self): #heavy operation #1 but on different threads
        for index, thread in enumerate(self.threads):
            worker = self.workers[index]
            worker.moveToThread(thread)
            thread.started.connect(worker.run)
            worker.finished.connect(thread.quit)
            thread.start()
    

class GalleryController(object):
    def __init__(self):
        print("Controller initialized")
        self.app = QApplication([])
        self.window = Gallery(self)
        self.thumbsLoader = ImageManager()
        self.window.tryingToClose.connect(self.thumbsLoader.stopWorking)
        self.paths = []
        print("Window initialized")

    def loadPaths(self, newPaths):
        self.paths = self.filterPaths(newPaths)
        self.window.generateThumbs(self.paths)
        self.thumbsLoader.setThumbsList(self.window.thumbs)
        self.thumbsLoader.spawnThreads()
        self.window.updateThumbs()

    def isPicture(self, path):
        pictureExts = ['png', 'jpg', 'jpeg']
        entryName = os.path.basename(path)
        ext = ('.' in entryName) and entryName[entryName.index('.') + 1:]
        return ext and len(ext) > 0 and (ext in pictureExts)
    
    def filterPaths(self, pathList):
        return filter(self.isPicture, pathList)

    def start(self):
        self.window.show()
        mainPath = IMAGES_PATH
        paths = os.listdir(mainPath)
        paths = [os.path.join(mainPath, path) for path in paths]
        self.loadPaths(paths)
        sys.exit(self.app.exec())

control = GalleryController()
control.start()

如果你想尝试 运行 这个,尝试将 IMAGES_PATH 设置为至少包含 10-15 张图像的目录,或者增加 time.sleep 以便观察效果.我删除了一些不必要的代码,因此如果您加载很多图像,图像将相互剪辑,但只是为了澄清这不是问题。

编辑:问题已解决! @Demi-Lune 指出。我不小心 运行 在主事件循环之前使用了阻塞代码:

self.loadPaths(paths) #<--- long running
sys.exit(self.app.exec()) #<--- start of event loop

只需更改它,以便 运行ning 长任务以按钮启动即可解决问题。

问题是我在主事件循环开始之前执行了长运行代码。

self.loadPaths(paths) #<--- long running
sys.exit(self.app.exec()) #<--- start of main event loop

@Demi-Lune 指出解决此问题的一种方法是在承载阻塞代码的事件循环之前启动 QThread,但我的用例是通过将阻塞代码连接到 QPushButton 的单击来解决的。