Search code examples
pythonqtqmlqthreadpyside2

How to maintain UI responsive with long-running slot


I have a python-defined worker QObject that has a slow work() slot that is called by the QML UI (in my actual UI, the method is called on each item in a FolderListModel dynamically as the user goes through the list, but for he sample code I'm just calling it on the window's completion as an example).

I'd like to run the slow work asynchronously to prevent the UI from blocking. I thought to do so by moving the Worker instance on a QThread and calling the slot there, but this is not working, as the UI is still blocked waiting for the result of work() to come.

This is the code of my attempt so far:

mcve.qml:

import QtQuick 2.13
import QtQuick.Window 2.13

Window {
    id: window
    visible: true
    width: 800
    height: 600
    title: qsTr("Main Window")

    Component.onCompleted: console.log(worker.work("I'm done!")) // not the actual usage, see note in the question
}

mcve.py:

import sys
from PySide2.QtWidgets import QApplication
from PySide2.QtQml import QQmlApplicationEngine
from PySide2.QtCore import QUrl, QThread, QObject, Slot
from time import sleep

class Worker(QObject):
  def __init__(self, parent=None):
    super().__init__(parent)

  @Slot(str, result=str)
  def work(self, path):
    sleep(5) # do something lengthy
    return path

if __name__ == '__main__':

    app = QApplication(sys.argv)
    engine = QQmlApplicationEngine()

    workerThread = QThread()
    worker = Worker()
    worker.moveToThread(workerThread)
    engine.rootContext().setContextProperty("worker", worker)
    engine.load(QUrl.fromLocalFile('mcve.qml'))
    if not engine.rootObjects():
        sys.exit(-1)

    sys.exit(app.exec_())

How do I invoke work() asynchronously so that only when it's done its effects are applied? And, as a bonus, what am I doing/understanding wrong in the use of QThreads?


Solution

  • Explanation:

    • Where is the "work" method currently executed? Well, if you add the following code and check what you get:
    # ...
    
    import threading
    
    class Worker(QObject):
        @Slot(str, result=str)
        def work(self, path):
            print(threading.current_thread())
            sleep(5)  # do something lengthy
            return path
    
    # ...
    

    Output:

    <_MainThread(MainThread, started 140409078408832)>
    qml: I'm done!
    

    As you can see the "work" method is executed in the main thread causing it to block the GUI.

    • Why is the "work" method executed in main thread? A method or function is executed in the context where it is called, in your case in QML that is executed in the main thread.

    • So how do you execute a method in the thread where the QObject lives? Well you have to do it asynchronously using QMetaObject::invokeMethod() (this method is not possible in PySide2 for a bug), through the call of a signal, or using QTimer::singleShot().


    Solution:

    In these cases it is better to create a bridge(QObject) that calls the function/method that is executed in another thread, and to notify the changes through the signals.

    import sys
    from time import sleep
    from functools import partial
    
    from PySide2 import QtCore, QtWidgets, QtQml
    
    
    class Worker(QtCore.QObject):
        resultChaged = QtCore.Signal(str)
    
        @QtCore.Slot(str)
        def work(self, path):
            sleep(5)  # do something lengthy
            self.resultChaged.emit(path)
    
    
    class Bridge(QtCore.QObject):
        startSignal = QtCore.Signal(str)
        resultChaged = QtCore.Signal(str, arguments=["result"])
    
        def __init__(self, obj, parent=None):
            super().__init__(parent)
            self.m_obj = obj
            self.m_obj.resultChaged.connect(self.resultChaged)
            self.startSignal.connect(self.m_obj.work)
    
    
    if __name__ == "__main__":
    
        app = QtWidgets.QApplication(sys.argv)
        engine = QtQml.QQmlApplicationEngine()
    
        workerThread = QtCore.QThread()
        workerThread.start()
    
        worker = Worker()
        worker.moveToThread(workerThread)
    
        bridge = Bridge(worker)
    
        engine.rootContext().setContextProperty("bridge", bridge)
        engine.load(QtCore.QUrl.fromLocalFile("mcve.qml"))
        if not engine.rootObjects():
            sys.exit(-1)
    
        ret = app.exec_()
        workerThread.quit()
        workerThread.wait()
        sys.exit(ret)
    
    import QtQuick 2.13
    import QtQuick.Window 2.13
    
    Window {
        id: window
        visible: true
        width: 800
        height: 600
        title: qsTr("Main Window")
    
        Component.onCompleted: bridge.startSignal("I'm done!")
    
        Connections{
            target: bridge
            onResultChaged: console.log(result)
        }
    }