在Qt Webengine中使用Alpaca获取本地文件路径

上一篇文章“使用CSS定制某类元素下的input文件选择控件”提到,折腾半天实现了对于Qt web engine的定制和修复,然后才发现,由于安全机制的作用,html中的<input type="file">是不能获取本地文件路径的。

还好,环境不是公众浏览器而是Qt web engine客户端,本身就提供了JSC++通讯的功能,相当于可以魔改浏览器。文章“Qt Webengine回调函数内赋值引发Segment Fault的灵异事件”已经提供了一个这样的案例,不过这次要做的通讯更复杂得多,包括:

  • 通过对Alpaca表单生成器的定制,使得在用户点击某个需要填写本地文件路径的表单输入框时,主动发起C++侧选择本地文件的操作;
  • C++侧完成文件选择之后,将路径字符串传递回到JS侧;
  • 将回传的文件/文件夹路径写入到Alpaca表单中;

Alpaca原生提供了定义自定义field的功能,基本上就是声明一个类,重载一些处理函数,然后注册一下该类型的名字,最后在表单生成时标明某个field属于这个自定义的类就行了:

自定义类,这里我定义了两个,分别用来选择文件和文件夹:

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
 
$.alpaca.Fields.LocalFileChooser = $.alpaca.Fields.TextField.extend({
    getFieldType: function () {
        return "localfile";
    },
    onClick: async function (e) {
        userIsChoosing = true;
        localFileHelper.chooseFile();
        while (userIsChoosing) { await sleep(100); }
        this.setValue(recentChosenFilePath);
        this.refreshValidationState();
    },
});
Alpaca.registerFieldClass("localfile", Alpaca.Fields.LocalFileChooser);
 
$.alpaca.Fields.LocalDirChooser = $.alpaca.Fields.TextField.extend({
    getFieldType: function () {
        return "localdir";
    },
    onClick: async function (e) {
        userIsChoosing = true;
        localFileHelper.chooseDir();
        while (userIsChoosing) { await sleep(100); }
        this.setValue(recentChosenDirPath);
        this.refreshValidationState();
    },
});
Alpaca.registerFieldClass("localdir", Alpaca.Fields.LocalDirChooser);

这一段不涉及什么别的对象,在加载alpaca之后就可以运行。其中出现了两个奇怪的东西,分别是localFileHelperwhile (userIsChoosing) { await sleep(100); }这么一个循环。

localFileHelper是通过QtC++侧通讯用的对象,可以简单的理解为它实际上指向那个C++对象,可以调用其中的一些函数。chooseFile()chooseDir()就是处理好了可以调用的函数。

由于这些调用全都是通过Qt信号/槽机制进行的,所以全都是没有返回值的异步调用。为此,需要设定等待机制:先把全局变量userIsChoosing设定为true,再发起调用,随后一直等待(注意onClick函数的async标记)。直到C++侧用户操作完毕,由C++侧主动把选择后的数据传递到全局变量recentChosenFilePath/recentChosenDirPath中,再主动将userIsChoosing置为false。这头发现userIsChoosing被置为false之后,跳出等待,把C++送来的值写入到表单(SetValue函数),随后需要主动触发一次refreshValidationState函数,把表单框可能有的“不能为空”或者别的验证提示刷掉。

截至到此,都是些民用版的JS相关,随便什么非战斗人员都能看懂的。麻烦的在下面。

这里需要两头操作。首先,通讯是通过Qtsignal/slot进行的,涉及这玩意儿的代码很容易就会变得比较细碎。为了干净,先搞一个C++类用来做通信中转:

LocalFileSystemJSHelper.h
#ifndef LOCALFILESYSTEMJSHELPER_H
#define LOCALFILESYSTEMJSHELPER_H
 
#include <QObject>
 
class LocalFileSystemJSHelper : public QObject {
    Q_OBJECT
 
    Q_PROPERTY(QString m_chosenFilePath MEMBER m_chosenFilePath NOTIFY chosenFilePathChanged)
    Q_PROPERTY(QString m_chosenDirPath MEMBER m_chosenDirPath NOTIFY chosenDirPathChanged)
 
public:
    LocalFileSystemJSHelper(QWidget *targetWebview, QWidget *parent = nullptr);
 
public slots:
    void chooseFile();
    void chooseDir();
 
signals:
    void chosenFilePathChanged(const QString &newChosenFilePath);
    void chosenDirPathChanged(const QString &newChosenDirPath);
 
private:
    QWidget *_targetWebview, *_parentWidget;
 
    QString m_chosenFilePath, m_chosenDirPath;
 
    void setWebViewLock(bool status);
};
 
#endif // LOCALFILESYSTEMJSHELPER_H

要搞信号和槽,首先必须是QObject,这个不解释。紧随其后的Q_PROPERTY相关语句,有些教程里提到了,但经我实验没有也行,估计是跟QML通信用的,Qt webengine中与JS的通信并不依赖这个。

要通信的关键是下面的几个信号和槽。若要让一个函数能够被JS主动调用,其必须是一个slot,如ChooseFile()函数和ChooseDir()函数。这俩函数分别调用Qt的对话框提示用户选择文件/文件夹。

信号和槽都是没有返回值的——因为这套机制本身就是建立在异步和多线程的基础上,文章“通过Qt优雅地调用命令行计算程序并抓取输出显示在窗口上”提供了姿势更丰富的案例。因此,为了把选择结果传回去,还需要从C++侧发送两个信号,分别是chosenFilePathChangedchosenDirPathChanged,这两个信号应该在ChooseFile()函数和ChooseDir()函数的末尾被发送:emit chosenDirPathChanged(m_chosenDirPath);,信号发送出去之后,JS侧绑定好了的函数就会被调用并收到m_chosenDirPath中的数据。

其他成员、参数什么的都是为了配合GUI而存在的,不说了。

这个LocalFileSystemJSHelper类声明晚了之后,使用下面的姿势跟webengine实例发生关系:

// 这里是窗体构造函数
 
localFileSystemJSHelper = new LocalFileSystemJSHelper(ui->webEngineView, this);
 
// QWebChannel qWebChannel(this) 实例化过了
qWebChannel.registerObject("localFileHelper", localFileSystemJSHelper);
ui->webEngineView->page()->setWebChannel(&qWebChannel);
 
ui->webEngineView->setHtml(generateFormHtml(), QUrl("qrc:/html/"));

显然,registerObject("localFileHelper", localFileSystemJSHelper);这一行中,localFileHelper就是上文JS里面调用的那个神奇的东西,这个名字在这里与LocalFileSystemJSHelper这个C++类的一个实例发生绑定,后面就可以在JS侧用了,直接调用其某个的同名JS函数即可触发对应的C++函数,不需要特殊处理。

注意,setWebChannel函数的调用必须在setHtml函数或者其他什么打开网页的调用之前发生,否则页面加载后就开始在另一个线程中执行JS,这时候qWebChannel还没绑定给页面的话,JS侧的的初始化操作会失败,具体就是下面这些:

window.recentChosenFilePath = "";
window.recentChosenDirPath = "";
window.userIsChoosing = false;
new QWebChannel(qt.webChannelTransport,
    function (channel) {
        var localFileHelper = channel.objects.localFileHelper;
 
        window.localFileHelper = localFileHelper;
 
        localFileHelper.chosenFilePathChanged.connect(function (newChosenFilePath) {
            recentChosenFilePath = newChosenFilePath;
            userIsChoosing = false;
        });
 
        localFileHelper.chosenDirPathChanged.connect(function (newChosenDirPath) {
            recentChosenDirPath = newChosenDirPath;
            userIsChoosing = false;
        });
    });

new QWebChannel这里,使用了一个叫qt.webChannelTransport的对象,如果上文说的还没执行setWebChannel就开始载入页面,则这一步就会因为不存在qt.webChannelTransport而失败。

下面的两个xxxxx.xxxx.connect(xxx)语句,是用来响应C++LocalFileSystemJSHelper中的那两个信号的,具体干了啥在第一部分都说过了。

这样的异步消息传递模式,还依赖全局变量,其实挺没有安全感的。但尝试了一下,又好像没什么问题,同时自动实现了多线程异步调用的感觉也还挺不错的。

  • 最后更改: 2019/07/20 15:06