1+1=10

扬长避短 vs 取长补短

使用QJSEngine与QUiLoader实现Qt程序动态扩展

接前面Qt中的JavaScript引擎小记QJSEngine在QtWidgets下使用小记,简单看看QJSEngine和QUiLoader能碰撞出什么火花。

这是很老的方案,在QtScript时期,应该不少人折腾过这个。只不过qml时代,似乎意义不大了。

不过,随便折腾一下也无妨...

我们知道:

  • 只要用Qt Designer设计一个ui界面 (x.ui),不需要编译,就可以交由现有的Qt程序来显示(QUiLoader)
  • 只要写一个javascript文件(x.js),不需要编译,就可以交由现有的Qt程序来执行某些逻辑(QJSEngine)

既然界面显示和逻辑代码都可以随时修改而无需编译,那么二者配合作为Qt程序扩展机制,会怎么样?

  • 在客户现场,用随便一个文件编辑器,修改 x.ui 和 x.js 文件。即可影响界面效果
  • 在客户现场,用随便一个文件编辑器,添加 x.ui 和 x.js 文件,即可增加新的功能
  • 在Qt程序编写上,只需要通过QJSEngine暴露几个API接口,再加简单的模块搜索功能

qt-demo-for-qjsengine-quiloader-plugins

准备1

先创建一个main.cpp文件:

  • 主窗口:MainWindow,包含一个QPlainTextEdit作为log窗口,包含一个QTabWidget作为带扩展的容器
  • 该窗口会作为window对象注册进QJSEngine中,以便在js中被访问
class MainWindow: public QMainWindow
{
    Q_OBJECT
public:
    MainWindow(QWidget *parent = nullptr)
        : QMainWindow(parent) {
        setWindowTitle("1+1=10's Demo for QJSEngine and QUiLoader");

        auto tabWidget = new QTabWidget;
        tabWidget->setObjectName("tabWidget");
        setCentralWidget(tabWidget);

        auto logEdit = new QPlainTextEdit;
        logEdit->setObjectName("logEdit");
        auto dock = new QDockWidget("Log");
        dock->setWidget(logEdit);
        addDockWidget(Qt::BottomDockWidgetArea, dock);
    }
};

准备2

准备 widget.ui 和 widget.js 文件,将其放置到特定文件夹下,比如工作目录下的js-ui-1子目录

用Qt Designer设计一个界面

界面上放置两个控件 slider1 和 spinBox1,记住名字,我们在js脚本中要用。

保存为widget.ui

qt-demo-for-qjsengine-quiloader-ui

编写js脚本

  • 输出log信息到主窗口界面
  • 建立ui组件之间的联动关系

完整脚本 widget.js

logEdit = window.findMyChild("logEdit");
logEdit.appendPlainText("---- widget.js loaded ----\n");

slider1 = window.findMyChild("slider1");
spinBox1 = window.findMyChild("spinBox1");
slider1.valueChanged.connect(spinBox1.setValue);

整合一下

其实没什么好整合的,整个程序就一个main.cpp源文件:

src/main.cpp
src/CMakeLists.txt

<app-working-dir>/js-ui-1/widget.ui
<app-working-dir>/js-ui-1/widget.js

完善一下main.cpp文件,使其启动时加载js-ui-1目录下的ui和js文件,程序就完整了:

#include <QApplication>
#include <QMainWindow>
#include <QDockWidget>
#include <QTabWidget>
#include <QPlainTextEdit>
#include <QFile>
#include <QUiLoader>
#include <QJSEngine>

class MainWindow: public QMainWindow
{
    Q_OBJECT
    QTabWidget *tabWidget;
    QPlainTextEdit *logEdit;
public:
    MainWindow(QWidget *parent = nullptr)
        : QMainWindow(parent) {
        setWindowTitle("1+1=10's Demo for QJSEngine and QUiLoader");
        QJSEngine::setObjectOwnership(this, QJSEngine::CppOwnership);

        tabWidget = new QTabWidget;
        tabWidget->setObjectName("tabWidget");
        setCentralWidget(tabWidget);

        logEdit = new QPlainTextEdit;
        logEdit->setObjectName("logEdit");
        auto dock = new QDockWidget("Log");
        dock->setWidget(logEdit);
        addDockWidget(Qt::BottomDockWidgetArea, dock);
    }

    Q_INVOKABLE QObject *findMyChild(const QString &name, QObject *my=nullptr) {
        if (!my)
            my = this;
        return my->findChild<QWidget *>(name, Qt::FindChildrenRecursively);
    }

    void load(const QString &dir, QJSEngine &engine) {
        doLoadUi(QString("%1/widget.ui").arg(dir));
        doLoadJS(QString("%1/widget.js").arg(dir), engine);
    }

    void doLoadUi(const QString &fileName) {
        QUiLoader loader;
        QFile f( fileName );
        if ( !f.open(QIODevice::ReadOnly) )
            return;
        auto w = loader.load(&f);
        tabWidget->addTab(w, w->windowTitle());
    }

    void doLoadJS(const QString &fileName, QJSEngine &engine) {
        QFile f( fileName );
        if ( !f.open(QIODevice::ReadOnly) )
            return;
        auto js = f.readAll();
        if (auto ret = engine.evaluate(js, fileName); ret.isError()) {
            qDebug() << "Uncaught exception at line"
                     << ret.property("lineNumber").toInt()
                     << ":" << ret.toString();
        }
    }
};

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);

    MainWindow mw;

    QJSEngine engine;
    engine.installExtensions(QJSEngine::ConsoleExtension);
    engine.globalObject().setProperty("window", engine.newQObject(&mw));

    QString js("window.size=\"800x600\"");
    if (auto ret = engine.evaluate(js); ret.isError()) {
        qDebug() << "Uncaught exception at line"
                 << ret.property("lineNumber").toInt()
                 << ":" << ret.toString();
    }

    mw.load("js-ui-1", engine);

    mw.show();
    return a.exec();
}

#include "main.moc"

直接编译运行即可看到效果:

images/qt-demo-for-qjsengine-quiloader-app

然后,就可以在程序发布后:随时修改ui文件和js文件,以便界面上显示各种不同的组件及调整它们之间的联动关系。

完善??

简单折腾可以,用于内部的各种工具程序应该问题也不大。不过真将这种方案用于生产,还有很多很多问题存在。

比如,多个模组之间,怎么约定各自的地盘(作用域),如果重名怎么办,如何规避。javascript脚本如何调试,是不是要有一个简单的环境。

接下来,并不考虑或尝试去完善这些东西,只关注自然而然的扩展方向...

自动搜索和加载

main函数中的load语句,可以改成自动搜索,以便于加载所有符合规则的模组:

    QDir dir(".");
    for( auto module : dir.entryList(QStringList()<<"js-ui-*")) {
        qDebug() << "Loading module" << module;
        mw.load(module, engine);
    }

模组的入口

为了简单,例子中将x.ui和x.js都作为模块的入口分别处理。但从设计上,应该把权力都下发给js文件,让它负责触发ui文件的加载和处理(如果需要ui的话)。

这样的话,Qt程序只需要找到并加载 js文件就行了。

比如,脚本module.js写成下面这样(需要加载什么ui文件,加载几个,都自己控制):

logEdit = window.findMyChild("logEdit");
logEdit.appendPlainText("---- module.js loaded ----\n");

// add logic for the widget
let widget = window.loadUi("js-ui-1/widget.ui");
console.log(widget);
slider1 = window.findMyChild("slider1", widget);
spinBox1 = window.findMyChild("spinBox1", widget);
slider1.valueChanged.connect(spinBox1.setValue);

// add widget to main window
window.addWidgetToTab(widget, widget.windowTitle);

而后在Qt程序中,通过window提供几个API接口就供js使用就行了。

对应的main.cpp的内容如下:

#include <QApplication>
#include <QMainWindow>
#include <QDockWidget>
#include <QTabWidget>
#include <QPlainTextEdit>
#include <QFile>
#include <QDir>
#include <QUiLoader>
#include <QJSEngine>

class MainWindow: public QMainWindow
{
    Q_OBJECT
    QTabWidget *tabWidget;
    QPlainTextEdit *logEdit;
public:
    MainWindow(QWidget *parent = nullptr)
        : QMainWindow(parent) {
        setWindowTitle("1+1=10's Demo for QJSEngine and QUiLoader");
        QJSEngine::setObjectOwnership(this, QJSEngine::CppOwnership);

        tabWidget = new QTabWidget;
        tabWidget->setObjectName("tabWidget");
        setCentralWidget(tabWidget);

        logEdit = new QPlainTextEdit;
        logEdit->setObjectName("logEdit");
        auto dock = new QDockWidget("Log");
        dock->setWidget(logEdit);
        addDockWidget(Qt::BottomDockWidgetArea, dock);
    }

    // helper function to find child named name of the given object
    Q_INVOKABLE QObject *findMyChild(const QString &name, QObject *my=nullptr) {
        if (!my)
            my = this;
        return my->findChild<QWidget *>(name, Qt::FindChildrenRecursively);
    }

    // helper function to load the .ui file
    Q_INVOKABLE QWidget *loadUi(const QString &fileName) {
        QUiLoader loader;
        QFile f( fileName );
        if ( !f.open(QIODevice::ReadOnly) )
            return nullptr;
        return loader.load(&f);
    }

    Q_INVOKABLE void addWidgetToTab(QWidget *widget, const QString &title) {
        tabWidget->addTab(widget, title);
    }

    void loadJSModule(const QString &fileName, QJSEngine &engine) {
        QFile f( fileName );
        if ( !f.open(QIODevice::ReadOnly) )
            return;
        auto js = f.readAll();
        if (auto ret = engine.evaluate(js, fileName); ret.isError()) {
            qDebug() << "Uncaught exception at line"
                     << ret.property("lineNumber").toInt()
                     << ":" << ret.toString();
        }
    }
};

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);

    MainWindow mw;

    QJSEngine engine;
    engine.installExtensions(QJSEngine::ConsoleExtension);
    engine.globalObject().setProperty("window", engine.newQObject(&mw));

    QString js("window.size=\"800x600\"");
    if (auto ret = engine.evaluate(js); ret.isError()) {
        qDebug() << "Uncaught exception at line"
                 << ret.property("lineNumber").toInt()
                 << ":" << ret.toString();
    }

    QDir dir(".");
    for( auto module : dir.entryList(QStringList()<<"js-ui-*")) {
        qDebug() << "Loading module" << module;
        mw.loadJSModule(module+"/module.js", engine);
    }

    mw.show();
    return a.exec();
}

#include "main.moc"

运行结果和上面一样。只不过灵活性更强。

其他

为保持完整,附上widget.ui文件的内容:

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>widget1</class>
 <widget class="QWidget" name="widget1">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>420</width>
    <height>320</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Form</string>
  </property>
  <layout class="QVBoxLayout" name="verticalLayout">
   <item>
    <widget class="QSlider" name="slider1">
     <property name="orientation">
      <enum>Qt::Horizontal</enum>
     </property>
    </widget>
   </item>
   <item>
    <widget class="QSpinBox" name="spinBox1"/>
   </item>
   <item>
    <spacer name="verticalSpacer">
     <property name="orientation">
      <enum>Qt::Vertical</enum>
     </property>
     <property name="sizeHint" stdset="0">
      <size>
       <width>20</width>
       <height>40</height>
      </size>
     </property>
    </spacer>
   </item>
  </layout>
 </widget>
 <resources/>
 <connections/>
</ui>

参考

  • https://doc.qt.io/qt-6/qjsengine.html
  • https://doc.qt.io/qt-6/quiloader.html

Comments