Invoking a C++ function from QML, asynchronously

In the Imaginario code I had written a C++ method to find all files present in a directory tree (for some reason this code is always encoding file paths as QUrl, but feel free to ignore that):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
QList<QUrl> Utils::findFiles(const QUrl &dirUrl, bool recursive) const
{
    QList<QUrl> files;

    QDir dir(dirUrl.toLocalFile());
    if (Q_UNLIKELY(!dir.exists())) return files;

    auto list = dir.entryInfoList(QDir::NoDotAndDotDot |
                                  QDir::Dirs | QDir::Files,
                                  QDir::Name);
    for (const QFileInfo &info: list) {
        if (info.isDir()) {
            if (recursive) {
                files += findFiles(QUrl::fromLocalFile(info.filePath()), true);
            }
        } else {
            files.append(QUrl::fromLocalFile(info.filePath()));
        }
    }

    return files;
}

The Utils mentioned in this snipped is a QObject-derived class which is registered to QML as

1
2
3
4
5
6
7
8
static QObject *utilsProvider(QQmlEngine *, QJSEngine *)                                               
{                                                                                                      
    return new Utils;                                                                                  
}

...
// In some other part of the code, before entering the main loop:
qmlRegisterSingletonType<Utils>("Imaginario", 1, 0, "Utils", utilsProvider);

This allows me to call the C++ findFiles() method from QML, like this:

1
2
3
4
import Imaginario 1.0

...
    onClicked: importer.addFiles(Utils.findFiles(folder, true))

So far, so good. However, I couldn't help noticing that when the selected folder contains a large number of files, the whole UI freezes until the findUtils() method has returned. So, how can I invoke my C++ method without blocking the UI?

QML offers a WorkerScript element which seems to do exactly what we need, but unfortunately the possibility of invoking C++ code is only there since Qt 5.12 (before that version, worker script could not use import statements), and in any case the requirement to store the script into a separate Javascript file makes the code less readable.

A better option, in my opinion, is to write a C++ method that performs the lengthy task in a thread and invokes a Javascript callback once the job execution is completed. This sounds pretty complex at first, but fortunately Qt's nice APIs make this an almost trivial task. Without modifying our previous findFiles() method, we write a second one which will execute it in a thread, using QtConcurrent::run():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void Utils::findFiles(const QUrl &dirUrl,
                      bool recursive,
                      const QJSValue &callback) const
{
    auto *watcher = new QFutureWatcher<QList<QUrl>>(this);
    QObject::connect(watcher, &QFutureWatcher<QList<QUrl>>::finished,
                     this, [this,watcher,callback]() {
        QList<QUrl> files = watcher->result();
        QJSValue cbCopy(callback); // needed as callback is captured as const
        QJSEngine *engine = qjsEngine(this);
        cbCopy.call(QJSValueList { engine->toScriptValue(files) });
        watcher->deleteLater();
    }); 
    watcher->setFuture(QtConcurrent::run(this, &Utils::findFiles, 
                                         dirUrl, recursive));
}

You might be surprised, but this is all what is needed in the C++ side. The changes to the QML side are equally trivial:

1
2
3
4
5
6
7
8
import Imaginario 1.0

...
    onClicked: {
        Utils.findFiles(folder, true, function(files) {
            importer.addFiles(files)
        })
    }

That's it! Line 6 in this last snippet will be invoked once the findFiles() method has completed its execution, and you'll be glad to see that the UI will be responsive throughout the duration of the operation.

QtConcurrent alternatives

QtConcurrent::run() is very simple to use, but it's not without its shortcomings, the biggest of which is that it doesn't support cancelling. This is something I can live with in this particular case, but, if you can't, don't despair: there are other options. The second simplest one is probably the static QThread::create() method, which behaves similarly to QtConcurrent::run() but returns a QThread object which can be requested to terminate, for example with QThread::requestInterruption(). This method exists only since Qt 5.10, and that's why I didn't list it as my first choice; but if you are using such a recent version of Qt, then it's probably the best option, because one doesn't even need to subclass QThread (and there's no need to depend on QtConcurrent). Of course, subclassing QThread is also an option, but this involves a little more typing.

Beware of threading issues

The most tempting solution (and I confess, my first approach to the problem was exactly this) is to invoke the Javascript callback directly from the thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void Utils::findFiles(const QUrl &dirUrl,
                      bool recursive,
                      const QJSValue &callback) const
{
    QtConcurrent::run([=]() {
        const QList<QUrl> files = findFiles(dirUrl, recursive);
        QJSValue cbCopy(callback);
        QJSEngine *engine = qjsEngine(this);
        cbCopy.call(QJSValueList { engine->toScriptValue(files) });
    });
}

However, this will work 95% of the times only, because QJSValue::call() is not thread-safe, and therefor your application will be victim of random crashes. So, we need to write a couple of lines more and invoke the callback from the main thread, like I'm doing before.

Commenti