Jak utrzymać responsywny GUI za pomocą QThread z PyQGIS

11

Rozwijałem niektóre narzędzia przetwarzania wsadowego jako wtyczki Pythona dla QGIS 1.8.

Przekonałem się, że podczas działania moich narzędzi GUI przestaje odpowiadać.

Ogólna mądrość jest taka, że ​​praca powinna być wykonywana w wątku roboczym, a informacje o statusie / zakończeniu przekazywane z powrotem do GUI jako sygnały.

Przeczytałem dokumentację dotyczącą brzegów rzek i przestudiowałem źródło doGeometry.py (działającej implementacji z narzędzi ftools ).

Korzystając z tych źródeł, próbowałem zbudować prostą implementację w celu zbadania tej funkcjonalności przed wprowadzeniem zmian w ustalonej bazie kodu.

Ogólna struktura to pozycja w menu wtyczek, która wyświetla okno dialogowe z przyciskami start i stop. Przyciski sterują wątkiem, który liczy się do 100, wysyłając sygnał z powrotem do GUI dla każdej liczby. GUI odbiera każdy sygnał i wysyła ciąg zawierający numer zarówno dziennika komunikatów, jak i tytułu okna.

Kod tej implementacji znajduje się tutaj:

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from qgis.core import *

class ThreadTest:

    def __init__(self, iface):
        self.iface = iface

    def initGui(self):
        self.action = QAction( u"ThreadTest", self.iface.mainWindow())
        self.action.triggered.connect(self.run)
        self.iface.addPluginToMenu(u"&ThreadTest", self.action)

    def unload(self):
        self.iface.removePluginMenu(u"&ThreadTest",self.action)

    def run(self):
        BusyDialog(self.iface.mainWindow())

class BusyDialog(QDialog):
    def __init__(self, parent):
        QDialog.__init__(self, parent)
        self.parent = parent
        self.setLayout(QVBoxLayout())
        self.startButton = QPushButton("Start", self)
        self.startButton.clicked.connect(self.startButtonHandler)
        self.layout().addWidget(self.startButton)
        self.stopButton=QPushButton("Stop", self)
        self.stopButton.clicked.connect(self.stopButtonHandler)
        self.layout().addWidget(self.stopButton)
        self.show()

    def startButtonHandler(self, toggle):
        self.workerThread = WorkerThread(self.parent)
        QObject.connect( self.workerThread, SIGNAL( "killThread(PyQt_PyObject)" ), \
                                                self.killThread )
        QObject.connect( self.workerThread, SIGNAL( "echoText(PyQt_PyObject)" ), \
                                                self.setText)
        self.workerThread.start(QThread.LowestPriority)
        QgsMessageLog.logMessage("end: startButtonHandler")

    def stopButtonHandler(self, toggle):
        self.killThread()

    def setText(self, text):
        QgsMessageLog.logMessage(str(text))
        self.setWindowTitle(text)

    def killThread(self):
        if self.workerThread.isRunning():
            self.workerThread.exit(0)


class WorkerThread(QThread):
    def __init__(self, parent):
        QThread.__init__(self,parent)

    def run(self):
        self.emit( SIGNAL( "echoText(PyQt_PyObject)" ), "Emit: starting work" )
        self.doLotsOfWork()
        self.emit( SIGNAL( "echoText(PyQt_PyObject)" ), "Emit: finshed work" )
        self.emit( SIGNAL( "killThread(PyQt_PyObject)"), "OK")

    def doLotsOfWork(self):
        count=0
        while count < 100:
            self.emit( SIGNAL( "echoText(PyQt_PyObject)" ), "Emit: " + str(count) )
            count += 1
#           if self.msleep(10):
#               return
#          QThread.yieldCurrentThread()

Niestety nie jest tak cicho, jak miałem nadzieję:

  • Tytuł okna aktualizuje się „na żywo” za pomocą licznika, ale jeśli kliknę okno dialogowe, nie odpowiada.
  • Dziennik komunikatów jest nieaktywny, dopóki licznik się nie skończy, a następnie wyświetla wszystkie komunikaty jednocześnie. Te wiadomości są oznaczone znacznikiem czasu przez QgsMessageLog i te znaczniki czasu wskazują, że zostały odebrane „na żywo” z licznikiem, tj. Nie są w kolejce ani przez wątek roboczy, ani przez okno dialogowe.
  • Kolejność komunikatów w dzienniku (ćwiczenie poniżej) wskazuje, że startButtonHandler kończy wykonywanie przed uruchomieniem wątku roboczego, tzn. Wątek zachowuje się jak wątek.

    end: startButtonHandler
    Emit: starting work
    Emit: 0
    ...
    Emit: 99
    Emit: finshed work
  • Wygląda na to, że wątek roboczy po prostu nie udostępnia żadnych zasobów wątkowi GUI. Na końcu powyższego źródła znajduje się kilka skomentowanych wierszy, w których próbowałem wywołać msleep () i fedCurrentThread (), ale żadne z nich nie pomogło.

Czy ktoś z jakimś doświadczeniem może wykryć mój błąd? Mam nadzieję, że jest to prosty, ale fundamentalny błąd, który można łatwo naprawić po zidentyfikowaniu.

Kelly Thomas
źródło
Czy to normalne, że nie można kliknąć przycisku Stop? Głównym celem responsywnego GUI jest anulowanie procesu, jeśli jest on zbyt długi. Próbuję zmodyfikować skrypt, ale nie mogę poprawnie uruchomić przycisku. Jak przerwać wątek?
etrimaille

Odpowiedzi:

6

Ponownie przyjrzałem się temu problemowi. Zacząłem od zera i odniosłem sukces, a potem wróciłem do kodu powyżej i nadal nie mogę go naprawić.

Aby zapewnić praktyczny przykład dla każdego, kto bada ten temat, podam tutaj kod funkcjonalny:

from PyQt4.QtCore import *
from PyQt4.QtGui import *

class ThreadManagerDialog(QDialog):
    def __init__( self, iface, title="Worker Thread"):
        QDialog.__init__( self, iface.mainWindow() )
        self.iface = iface
        self.setWindowTitle(title)
        self.setLayout(QVBoxLayout())
        self.primaryLabel = QLabel(self)
        self.layout().addWidget(self.primaryLabel)
        self.primaryBar = QProgressBar(self)
        self.layout().addWidget(self.primaryBar)
        self.secondaryLabel = QLabel(self)
        self.layout().addWidget(self.secondaryLabel)
        self.secondaryBar = QProgressBar(self)
        self.layout().addWidget(self.secondaryBar)
        self.closeButton = QPushButton("Close")
        self.closeButton.setEnabled(False)
        self.layout().addWidget(self.closeButton)
        self.closeButton.clicked.connect(self.reject)
    def run(self):
        self.runThread()
        self.exec_()
    def runThread( self):
        QObject.connect( self.workerThread, SIGNAL( "jobFinished( PyQt_PyObject )" ), self.jobFinishedFromThread )
        QObject.connect( self.workerThread, SIGNAL( "primaryValue( PyQt_PyObject )" ), self.primaryValueFromThread )
        QObject.connect( self.workerThread, SIGNAL( "primaryRange( PyQt_PyObject )" ), self.primaryRangeFromThread )
        QObject.connect( self.workerThread, SIGNAL( "primaryText( PyQt_PyObject )" ), self.primaryTextFromThread )
        QObject.connect( self.workerThread, SIGNAL( "secondaryValue( PyQt_PyObject )" ), self.secondaryValueFromThread )
        QObject.connect( self.workerThread, SIGNAL( "secondaryRange( PyQt_PyObject )" ), self.secondaryRangeFromThread )
        QObject.connect( self.workerThread, SIGNAL( "secondaryText( PyQt_PyObject )" ), self.secondaryTextFromThread )
        self.workerThread.start()
    def cancelThread( self ):
        self.workerThread.stop()
    def jobFinishedFromThread( self, success ):
        self.workerThread.stop()
        self.primaryBar.setValue(self.primaryBar.maximum())
        self.secondaryBar.setValue(self.secondaryBar.maximum())
        self.emit( SIGNAL( "jobFinished( PyQt_PyObject )" ), success )
        self.closeButton.setEnabled( True )
    def primaryValueFromThread( self, value ):
        self.primaryBar.setValue(value)
    def primaryRangeFromThread( self, range_vals ):
        self.primaryBar.setRange( range_vals[ 0 ], range_vals[ 1 ] )
    def primaryTextFromThread( self, value ):
        self.primaryLabel.setText(value)
    def secondaryValueFromThread( self, value ):
        self.secondaryBar.setValue(value)
    def secondaryRangeFromThread( self, range_vals ):
        self.secondaryBar.setRange( range_vals[ 0 ], range_vals[ 1 ] )
    def secondaryTextFromThread( self, value ):
        self.secondaryLabel.setText(value)

class WorkerThread( QThread ):
    def __init__( self, parentThread):
        QThread.__init__( self, parentThread )
    def run( self ):
        self.running = True
        success = self.doWork()
        self.emit( SIGNAL( "jobFinished( PyQt_PyObject )" ), success )
    def stop( self ):
        self.running = False
        pass
    def doWork( self ):
        return True
    def cleanUp( self):
        pass

class CounterThread(WorkerThread):
    def __init__(self, parentThread):
        WorkerThread.__init__(self, parentThread)
    def doWork(self):
        target = 100000000
        stepP= target/100
        stepS=target/10000
        self.emit( SIGNAL( "primaryText( PyQt_PyObject )" ), "Primary" )
        self.emit( SIGNAL( "secondaryText( PyQt_PyObject )" ), "Secondary" )
        self.emit( SIGNAL( "primaryRange( PyQt_PyObject )" ), ( 0, 100 ) )
        self.emit( SIGNAL( "secondaryRange( PyQt_PyObject )" ), ( 0, 100 ) )
        count = 0
        while count < target:
            if count % stepP == 0:
                self.emit( SIGNAL( "primaryValue( PyQt_PyObject )" ), int(count / stepP) )
            if count % stepS == 0:  
                self.emit( SIGNAL( "secondaryValue( PyQt_PyObject )" ), count % stepP / stepS )
            if not self.running:
                return False
            count += 1
        return True

d = ThreadManagerDialog(qgis.utils.iface, "CounterThread Demo")
d.workerThread = CounterThread(qgis.utils.iface.mainWindow())
d.run()

Struktura tego przykładu jest klasą ThreadManagerDialog, której można przypisać WorkerThread (lub podklasę). Po wywołaniu metody uruchamiania okna dialogowego wywoła ona metodę doWork na pracowniku. W rezultacie dowolny kod w doWork będzie działał w osobnym wątku, pozostawiając GUI swobodny w odpowiedzi na dane wejściowe użytkownika.

W tym przykładzie wystąpienie CounterThread jest przypisane jako proces roboczy, a kilka pasków postępu będzie przez chwilę zajęte.

Uwaga: jest sformatowany w taki sposób, że jest gotowy do wklejenia w konsoli Pythona. Ostatnie trzy wiersze będą musiały zostać usunięte przed zapisaniem do pliku .py.

Kelly Thomas
źródło
To świetny przykład plug and play! Jestem ciekawy najlepszej pozycji w tym kodzie do implementacji naszego działającego algorytmu. Czy taka pozycja musiałaby być umieszczona w klasie WorkerThread, czy raczej w klasie CounterThread, def doWork? [Pytanie w interesie połączenia tych pasków postępu z wstawionym algorytmem (-ami) pracownika]
Katalpa
Tak, CounterThreadto tylko przykładowa klasa dla dzieci WorkerThread. Jeśli utworzysz własną klasę podrzędną z bardziej znaczącą implementacją, doWorkwszystko powinno być w porządku.
Kelly Thomas,
Cechy CounterThread mają zastosowanie do mojego celu (szczegółowe powiadomienia użytkownika o postępach) - ale w jaki sposób można je zintegrować z nową procedurą „doWork” klasy c. (także - mądre miejsce, „doWork” w CounterThread, prawda?)
Katalpa
Implementacja CounterThread powyżej a) inicjuje zadanie, b) inicjuje okno dialogowe, c) wykonuje pętlę rdzeniową, d) zwraca true po pomyślnym zakończeniu. Każde zadanie, które można zrealizować za pomocą pętli, powinno po prostu upaść. Jednym z ostrzeżeń, które przedstawię, jest to, że wysyłanie sygnałów do komunikowania się z menedżerem wiąże się z pewnym narzutem, tzn. Jeśli wywoływane przy każdej iteracji szybkiej pętli, może powodować większe opóźnienia niż rzeczywiste zadanie.
Kelly Thomas
Dziękuję za wszystkie porady. Może to być kłopotliwe, aby działało w mojej sytuacji. Obecnie działanie powoduje awarię minidump w qgis. W wyniku zbyt dużego obciążenia lub moich (początkujących) umiejętności programowania?
Katalpa