Como mantenho uma GUI resposiva usando QThread com PyQGIS

11

Estou desenvolvendo algumas ferramentas de processamento em lote como plugins python para o QGIS 1.8.

Descobri que, enquanto minhas ferramentas estão em execução, a GUI fica sem resposta.

A opinião geral é de que o trabalho deve ser realizado em um encadeamento de trabalho, com as informações de status / conclusão passadas de volta à GUI como sinais.

Eu li os documentos da margem do rio e estudei a fonte doGeometry.py (uma implementação de trabalho do ftools ).

Usando essas fontes, tentei criar uma implementação simples para explorar essa funcionalidade antes de fazer alterações em uma base de código estabelecida.

A estrutura geral é uma entrada no menu de plug-ins, que abre uma caixa de diálogo com os botões iniciar e parar. Os botões controlam um segmento que conta até 100, enviando um sinal de volta à GUI para cada número. A GUI recebe cada sinal e envia uma sequência contendo o número do log de mensagens e o título da janela.

O código desta implementação está aqui:

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

Infelizmente, não é tranquilo trabalhar como eu esperava:

  • O título da janela está atualizando "ao vivo" com o contador, mas se eu clicar na caixa de diálogo, ela não responde.
  • O log de mensagens fica inativo até o contador terminar e, em seguida, apresenta todas as mensagens de uma só vez. Essas mensagens são marcadas com um registro de data e hora por QgsMessageLog e indicam que foram recebidas "ativas" com o contador, ou seja, não estão sendo enfileiradas nem pelo thread de trabalho nem pela caixa de diálogo.
  • A ordem das mensagens no log (executada a seguir) indica que startButtonHandler conclui a execução antes que o encadeamento do trabalhador comece a funcionar, ou seja, o encadeamento se comporta como um encadeamento.

    end: startButtonHandler
    Emit: starting work
    Emit: 0
    ...
    Emit: 99
    Emit: finshed work
    
  • Parece que o thread de trabalho simplesmente não está compartilhando nenhum recurso com o thread da GUI. Existem algumas linhas comentadas no final da fonte acima, onde tentei chamar msleep () e yieldCurrentThread (), mas nenhuma delas pareceu ajudar.

Alguém com alguma experiência com isso pode detectar meu erro? Espero que seja um erro simples, mas fundamental, que seja fácil de corrigir uma vez identificado.

Kelly Thomas
fonte
É normal que não seja possível clicar no botão Parar? O principal objetivo da interface gráfica do usuário responsiva é cancelar o processo se for muito longo. Tento modificar o seu script, mas não consigo colocar o botão funcionando corretamente. Como você aborta seu tópico?
Etrimaille

Respostas:

6

Então, eu dei uma outra olhada nesse problema. Comecei do zero e tive sucesso, depois voltei a olhar o código acima e ainda não consigo corrigi-lo.

No interesse de fornecer um exemplo de trabalho para quem pesquisa este assunto, fornecerei o código funcional aqui:

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

A estrutura deste exemplo é uma classe ThreadManagerDialog que pode ser atribuída a um WorkerThread (ou subclasse). Quando o método de execução da caixa de diálogo é chamado, por sua vez, chama o método doWork no trabalhador. O resultado é que qualquer código no doWork será executado em um encadeamento separado, deixando a GUI livre para responder à entrada do usuário.

Nesta amostra, uma instância do CounterThread é atribuída como o trabalhador e algumas barras de progresso serão mantidas ocupadas por um minuto ou mais.

Nota: está formatado para estar pronto para colar no console python. As últimas três linhas precisarão ser removidas antes de salvar em um arquivo .py.

Kelly Thomas
fonte
Este é um ótimo exemplo plug and play! Estou curioso para saber a melhor posição neste código para implementar nosso próprio algoritmo de trabalho. Isso precisaria ser incluído na classe WorkerThread, ou melhor, na classe CounterThread, def doWork? [Colocadas no interesse de ligar estas barras de progresso para o algoritmo trabalhador inserido (s)]
Katalpa
Sim, CounterThreadé apenas um exemplo básico de classe infantil WorkerThread. Se você criar sua própria classe filho com uma implementação mais significativa doWork, deverá ficar bem.
Kelly Thomas
As características do CounterThread são aplicáveis ​​ao meu objetivo (notificações detalhadas ao usuário sobre o progresso) - mas como isso seria integrado a uma nova rotina c.class 'doWork'? (também - colocação sábio, 'doWork' dentro do direito CounterThread?)
Katalpa
A implementação CounterThread acima a) inicializa o trabalho, b) inicializa o diálogo, c) executa um loop principal, d) retorna verdadeiro após a conclusão bem-sucedida. Qualquer tarefa que possa ser implementada com um loop deve apenas cair no lugar. Um aviso que ofereço é que a emissão dos sinais de comunicação com o gerente vem com alguma sobrecarga, ou seja, se chamada a cada iteração de loop rápido, pode causar mais latência do que o trabalho real.
Kelly Thomas
Obrigado por todos os conselhos. Pode ser problemático para isso funcionar na minha situação. No momento, o doWork causa um travamento de minidump no qgis. Um resultado de uma carga muito pesada ou minhas habilidades de programação (iniciantes)?
precisa saber é