¿Cómo mantengo una GUI resposiva usando QThread con PyQGIS?

11

He estado desarrollando algunas herramientas de procesamiento por lotes como complementos de Python para QGIS 1.8.

He descubierto que mientras mis herramientas se están ejecutando, la GUI deja de responder.

La sabiduría general es que el trabajo debe realizarse en un subproceso de trabajo, con la información de estado / finalización devuelta a la GUI como señales.

Leí los documentos de la orilla del río y estudié la fuente de doGeometry.py (una implementación funcional de ftools ).

Usando estas fuentes, he intentado construir una implementación simple para explorar esta funcionalidad antes de hacer cambios en una base de código establecida.

La estructura general es una entrada en el menú de complementos, que abre un diálogo con los botones de inicio y parada. Los botones controlan un hilo que cuenta hasta 100, enviando una señal a la GUI para cada número. La GUI recibe cada señal y envía una cadena que contiene el número tanto el registro de mensajes como el título de la ventana.

El código de esta implementación está aquí:

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

Lamentablemente, no funciona en silencio como esperaba:

  • El título de la ventana se actualiza "en vivo" con el contador, pero si hago clic en el cuadro de diálogo, no responde.
  • El registro de mensajes está inactivo hasta que finaliza el contador, luego presenta todos los mensajes a la vez. Estos mensajes están etiquetados con una marca de tiempo por QgsMessageLog y estas marcas de tiempo indican que se recibieron "en vivo" con el contador, es decir, que no están en cola ni por el hilo de trabajo ni por el diálogo.
  • El orden de los mensajes en el registro (sigue el ejercicio) indica que startButtonHandler completa la ejecución antes de que el subproceso de trabajo comience a funcionar, es decir, el subproceso se comporta como un subproceso.

    end: startButtonHandler
    Emit: starting work
    Emit: 0
    ...
    Emit: 99
    Emit: finshed work
    
  • Parece que el hilo de trabajo simplemente no comparte ningún recurso con el hilo de la GUI. Hay un par de líneas comentadas al final de la fuente anterior donde intenté llamar a msleep () y yieldCurrentThread (), pero ninguno pareció ayudar.

¿Alguien con alguna experiencia con esto puede detectar mi error? Espero que sea un error simple pero fundamental que sea fácil de corregir una vez que se identifica.

Kelly Thomas
fuente
¿Es normal que no se pueda hacer clic en el botón de detención? El objetivo principal de la GUI receptiva es cancelar el proceso si es demasiado largo. Intento modificar su script, pero no puedo hacer que el botón funcione correctamente. ¿Cómo abortas tu hilo?
etrimaille

Respuestas:

6

Así que le eché otro vistazo a este problema. Comencé desde cero y tuve éxito, luego volví a mirar el código anterior y aún no puedo solucionarlo.

En aras de proporcionar un ejemplo de trabajo para cualquier persona que investigue este tema, proporcionaré un código funcional aquí:

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 estructura de este ejemplo es una clase ThreadManagerDialog a la que se le puede asignar un WorkerThread (o subclase). Cuando se llama al método de ejecución del diálogo, a su vez llamará al método doWork en el trabajador. El resultado es que cualquier código en doWork se ejecutará en un hilo separado, dejando a la GUI libre para responder a la entrada del usuario.

En este ejemplo, se asigna una instancia de CounterThread como trabajador y se mantendrán ocupadas un par de barras de progreso durante un minuto más o menos.

Nota: esto está formateado para que esté listo para pegar en la consola de Python. Las últimas tres líneas deberán eliminarse antes de guardarlas en un archivo .py.

Kelly Thomas
fuente
¡Este es un gran ejemplo plug and play! Tengo curiosidad sobre la mejor posición en este código para implementar nuestro propio algoritmo de trabajo. ¿Sería necesario ubicarlo en la clase WorkerThread, o más bien en la clase CounterThread, def doWork? [Preguntado por el interés de conectar estas barras de progreso a los algoritmos de trabajo insertados]
Katalpa
Sí, CounterThreades solo un ejemplo básico de clase infantil WorkerThread. Si crea su propia clase secundaria con una implementación más significativa, doWorkentonces debería estar bien.
Kelly Thomas
Las características de CounterThread son aplicables a mi objetivo (notificaciones detalladas al usuario sobre el progreso), pero ¿cómo se integraría con una nueva rutina de do.ork de c.class? (también - en cuanto a ubicación, 'doWork' dentro del CounterThread ¿verdad?)
Katalpa
La implementación de CounterThread anterior a) inicializa el trabajo, b) inicializa el diálogo, c) realiza un bucle central, d) devuelve verdadero al completar con éxito. Cualquier tarea que se pueda implementar con un bucle debe simplemente colocarse en su lugar. Una advertencia que ofreceré es que emitir las señales para comunicarse con el administrador conlleva cierta sobrecarga, es decir, si se llama con cada iteración de bucle rápido, puede causar más latencia que el trabajo real.
Kelly Thomas
Gracias por todos los consejos. Puede ser problemático que esto funcione en mi situación. En la actualidad, doWork provoca un bloqueo de minivolcado en qgis. ¿Un resultado de una carga demasiado pesada o mis habilidades de programación (novato)?
Katalpa