迁移通知 本站内容正在逐步向 [[https://www.weiran.ink]] 迁移,更新内容请到新站查找。 --- # 在Qt Webengine中使用Alpaca获取本地文件路径 上一篇文章“[[:Coding:Web:Custom_input_file_contorl_under_specific_class_via_CSS|使用CSS定制某类元素下的input文件选择控件]]”提到,折腾半天实现了对于Qt web engine的定制和修复,然后才发现,由于安全机制的作用,html中的``是不能获取本地文件路径的。 还好,环境不是公众浏览器而是Qt web engine客户端,本身就提供了`JS`与`C++`通讯的功能,相当于可以魔改浏览器。文章“[[:Coding:Cpp:Qt_webengine_runJavaScript_cannot_assign_to_local_variable|Qt Webengine回调函数内赋值引发Segment Fault的灵异事件]]”已经提供了一个这样的案例,不过这次要做的通讯更复杂得多,包括: + 通过对`Alpaca`表单生成器的定制,使得在用户点击某个需要填写本地文件路径的表单输入框时,主动发起`C++`侧选择本地文件的操作; + 在`C++`侧完成文件选择之后,将路径字符串传递回到`JS`侧; + 将回传的文件/文件夹路径写入到`Alpaca`表单中; ## 对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`之后就可以运行。其中出现了两个奇怪的东西,分别是`localFileHelper`和`while (userIsChoosing) { await sleep(100); }`这么一个循环。 `localFileHelper`是通过`Qt`跟`C++`侧通讯用的对象,可以简单的理解为它实际上指向那个`C++`对象,可以调用其中的一些函数。`chooseFile()`和`chooseDir()`就是处理好了可以调用的函数。 由于这些调用全都是通过`Qt`的`信号/槽`机制进行的,所以全都是没有返回值的异步调用。为此,需要设定等待机制:先把全局变量`userIsChoosing`设定为`true`,再发起调用,随后一直等待(注意`onClick`函数的`async`标记)。直到`C++`侧用户操作完毕,由`C++`侧主动把选择后的数据传递到全局变量`recentChosenFilePath/recentChosenDirPath`中,再主动将`userIsChoosing`置为`false`。这头发现`userIsChoosing`被置为`false`之后,跳出等待,把`C++`送来的值写入到表单(`SetValue`函数),随后需要主动触发一次`refreshValidationState`函数,把表单框可能有的“不能为空”或者别的验证提示刷掉。 截至到此,都是些民用版的`JS`相关,随便什么非战斗人员都能看懂的。麻烦的在下面。 ## C++与JS的通信 这里需要两头操作。首先,通讯是通过`Qt`的`signal/slot`进行的,涉及这玩意儿的代码很容易就会变得比较细碎。为了干净,先搞一个`C++`类用来做通信中转: #ifndef LOCALFILESYSTEMJSHELPER_H #define LOCALFILESYSTEMJSHELPER_H #include 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`的对话框提示用户选择文件/文件夹。 信号和槽都是没有返回值的——因为这套机制本身就是建立在异步和多线程的基础上,文章“[[:Coding:CAEDevelop:Call_external_executable_and_fetch_output_by_qt|通过Qt优雅地调用命令行计算程序并抓取输出显示在窗口上]]”提供了姿势更丰富的案例。因此,为了把选择结果传回去,还需要从`C++`侧发送两个信号,分别是`chosenFilePathChanged`和`chosenDirPathChanged`,这两个信号应该在`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`中的那两个`信号`的,具体干了啥在第一部分都说过了。 这样的异步消息传递模式,还依赖全局变量,其实挺没有安全感的。但尝试了一下,又好像没什么问题,同时自动实现了多线程异步调用的感觉也还挺不错的。