Comment maintenir une interface graphique resposive en utilisant QThread avec PyQGIS

11

J'ai développé des outils de traitement par lots en tant que plugins python pour QGIS 1.8.

J'ai constaté que lorsque mes outils sont en cours d'exécution, l'interface graphique devient non réactive.

La sagesse générale est que le travail doit être effectué sur un thread de travail, avec les informations d'état / d'achèvement retransmises à l'interface graphique sous forme de signaux.

J'ai lu les documents riverains et étudié la source de doGeometry.py (une implémentation fonctionnelle de ftools ).

En utilisant ces sources, j'ai essayé de construire une implémentation simple afin d'explorer cette fonctionnalité avant d'apporter des modifications à une base de code établie.

La structure globale est une entrée dans le menu des plugins, qui ouvre une boîte de dialogue avec des boutons de démarrage et d'arrêt. Les boutons contrôlent un thread qui compte jusqu'à 100, renvoyant un signal à l'interface graphique pour chaque numéro. L'interface graphique reçoit chaque signal et envoie une chaîne contenant le numéro à la fois le journal des messages et le titre de la fenêtre.

Le code de cette implémentation est ici:

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()

Malheureusement, ce n'est pas un travail silencieux comme je l'espérais:

  • Le titre de la fenêtre se met à jour "en direct" avec le compteur mais si je clique sur la boîte de dialogue, il ne répond pas.
  • Le journal des messages est inactif jusqu'à la fin du compteur, puis présente tous les messages à la fois. Ces messages sont étiquetés avec un horodatage par QgsMessageLog et ces horodatages indiquent qu'ils ont été reçus "en direct" avec le compteur, c'est-à-dire qu'ils ne sont pas mis en file d'attente par le thread de travail ou la boîte de dialogue.
  • L'ordre des messages dans le journal (extrait suit) indique que startButtonHandler termine l'exécution avant que le thread de travail ne commence à fonctionner, c'est-à-dire que le thread se comporte comme un thread.

    end: startButtonHandler
    Emit: starting work
    Emit: 0
    ...
    Emit: 99
    Emit: finshed work
  • Il semble que le thread de travail ne partage tout simplement aucune ressource avec le thread GUI. Il y a quelques lignes commentées à la fin de la source ci-dessus où j'ai essayé d'appeler msleep () et yieldCurrentThread (), mais aucune ne semblait aider.

Quelqu'un ayant une expérience avec cela est-il capable de détecter mon erreur? J'espère que c'est une erreur simple mais fondamentale qui est facile à corriger une fois qu'elle est identifiée.

Kelly Thomas
la source
Est-il normal que le bouton d'arrêt ne puisse pas être cliqué? L'objectif principal de l'interface graphique réactive est d'annuler le processus s'il est trop long. J'essaie de modifier votre script mais je n'arrive pas à faire fonctionner le bouton correctement. Comment abandonnez-vous votre fil?
etrimaille

Réponses:

6

J'ai donc revu ce problème. J'ai recommencé à zéro et j'ai réussi, puis je suis retourné regarder le code ci-dessus et je ne peux toujours pas le corriger.

Afin de fournir un exemple de travail à toute personne recherchant ce sujet, je fournirai le code fonctionnel ici:

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()

La structure de cet exemple est une classe ThreadManagerDialog à laquelle peut être affecté un WorkerThread (ou une sous-classe). Lorsque la méthode d'exécution de la boîte de dialogue est appelée, elle appelle à son tour la méthode doWork sur le travailleur. Le résultat est que tout code dans doWork s'exécutera dans un thread séparé, laissant l'interface graphique libre de répondre aux entrées de l'utilisateur.

Dans cet exemple, une instance de CounterThread est affectée en tant que travailleur et quelques barres de progression seront occupées pendant environ une minute.

Remarque: il est formaté de sorte qu'il est prêt à être collé dans la console python. Les trois dernières lignes devront être supprimées avant l'enregistrement dans un fichier .py.

Kelly Thomas
la source
Ceci est un excellent exemple plug and play! Je suis curieux de savoir quelle est la meilleure position dans ce code pour implémenter notre propre algorithme de travail. Un tel besoin devrait-il être placé dans la classe WorkerThread, ou plutôt dans la classe CounterThread, def doWork? [Interrogé dans l'intérêt de connecter ces barres de progression aux algorithmes de travail insérés]
Katalpa
Ouais, CounterThreadc'est juste un exemple de classe d'enfant WorkerThread. Si vous créez votre propre classe enfant avec une implémentation plus significative de, doWorkalors ça devrait aller.
Kelly Thomas
Les caractéristiques du CounterThread sont applicables à mon objectif (notifications détaillées à l'utilisateur des progrès) - mais comment cela serait-il intégré à une nouvelle routine «doWork» de c.class? (aussi - en ce qui concerne le placement, 'doWork' dans le CounterThread à droite?)
Katalpa
L'implémentation de CounterThread ci-dessus a) initialise le travail, b) initialise la boîte de dialogue, c) exécute une boucle de base, d) renvoie true en cas de réussite. Toute tâche pouvant être implémentée avec une boucle doit simplement être mise en place. Un avertissement que je proposerai est que l'émission des signaux pour communiquer avec le gestionnaire s'accompagne d'une surcharge, c'est-à-dire que si elle est appelée à chaque itération de la boucle rapide, elle peut entraîner plus de latence que le travail réel.
Kelly Thomas
Merci pour tous les conseils. Cela pourrait être gênant pour que cela fonctionne dans ma situation. À l'heure actuelle, le doWork provoque un crash de minidump dans qgis. Le résultat d'une charge trop lourde ou mes compétences en programmation (novice)?
Katalpa