☰
QML运行逻辑全解析:启动、数据流通与ListModel刷新
2026/10/3 3:56:19 网站建设 项目流程

先抛开各种花哨的框架不看,搞懂QML程序的运行逻辑,大多数人卡住的地方其实只有一个:QML界面明明照着示例写出来了,一个窗口能弹出来,可一旦涉及数据刷新、控件联动、报错定位,就完全不知道这一步到那一步是怎么发生的。加上关于Qt QML的热搜里常年带着qml编译错误、导入环境变量设置、插件路径、ListModel这些词,说明运行阶段的坑远比写代码阶段多。这篇文章就围绕一条主线展开:“QML程序到底是怎么跑起来的”,把启动、对象树构建、C++与QML的数据流通、常见运行错误的诊断,以及ListModel这类动态数据的刷新机制全部串在一起。不管你是刚开始学QML,还是已经在做具体界面项目,把这条链路走通之后,大多数编译错误和运行时警告都能自己定位。

1. 从main.cpp到第一个窗口:QML程序的启动链路

1.1 一个最小可运行的QML程序由什么组成

几乎所有Qt Quick项目都长一个样,main.cpp加main.qml,顶多再带一个qml.qrc资源文件。最典型的启动代码是这样的:

#include <QGuiApplication> #include <QQmlApplicationEngine> int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/main.qml"))); if (engine.rootObjects().isEmpty()) return -1; return app.exec(); }

别小看这十几行,它藏着整个运行逻辑的第一层关键:QGuiApplication负责创建Qt的事件循环,QQmlApplicationEngine负责加载、解析、实例化QML文档。engine.load()这一步是同步的,它会读取main.qml内容,把里面声明出来的所有元素逐个转成C++对象。如果这一步出错,比如文件路径错了、某个import模块找不到,程序在弹窗口前就挂了;rootObjects().isEmpty()检查的就是加载是否成功。

我自己调试时经常会临时改用engine.loadFromModule("MyApp", "Main"),这是Qt 6.3以后推荐的方式,比如热词里出现的6.8.3版本。loadFromModule的性能差别不大,但它对模块路径和插件路径的处理更规范,尤其是在使用qmldir和Qt Design Studio插件时,比写死的qrc:/main.qml稳得多。

1.2 事件循环、渲染线程与界面线程的边界

app.exec()启动之后,程序进入一个“不退出”的状态:用户点击按钮、键盘输入、定时器发信号、网络数据返回,这些事件都被塞进事件队列,QML运行逻辑在此时才真正展开。

这里有个新手最容易误解的点:QML里的JavaScript代码跑在哪个线程?答案是默认全部跑在GUI线程,也就是主事件循环所在的线程。Qt Quick的渲染则不同,渲染是在独立线程中执行的,由场景图(Scene Graph)负责。所以QML里的属性变化、信号发射、onClicked里的JS逻辑,都是串行执行的;渲染线程则只接收场景图节点,不会去执行QML的JS代码。理解了这一点,就不会再写出“在一个槽函数里做耗时计算导致界面卡死”的代码了。

QML程序启动过程可以简单梳理成四步:

  1. 创建QGuiApplication,初始化Qt运行时和平台窗口系统。
  2. 创建QQmlApplicationEngine,注册内置类型和导入路径。
  3. 加载QML文档,解析语法,生成对象树,执行所有属性绑定。
  4. 进入事件循环,由信号和事件驱动后续的交互与数据变化。

这四步每一步都可能出问题,第三和第四步之间最容易出那种“程序能跑但你总觉得哪里不对”的疑难杂症。后面会展开说。

1.3 为什么QML程序运行起来和QWidget思路完全不同

写QWidget时,代码和界面强绑定:widget->setText("x"),然后repaint()。虽然也走事件循环,但开发者的心智模型是命令式的。QML完全不同,它本质上是声明式的:你声明width: parent.width / 2,这个约束会在整个生命周期持续生效,两侧只要有一个变化,另一个就会被自动更新。

这带来的好处是UI同步代码大幅减少;坏处是,一旦程序运行逻辑与你设想的不一致,你很难像QWidget那样沿着调用栈追“到底是谁调用了谁”。QML的调用栈往往是一连串依赖链:model.count变化,触发ListView重新计算count,触发positionViewAtIndex,触发currentIndex变化,再触发其他控件的显示状态……想要定位问题,就得习惯沿着“依赖关系”而不是“调用关系”去思考。这是QML程序运行逻辑和传统GUI框架最本质的区别。

2. QML文档解析与对象树的建立:从声明到实例

2.1 QML的“类型”到底是什么

打开任意一个main.qml文件,看到的是类似这样的内容:

import QtQuick import QtQuick.Controls Window { width: 640 height: 480 visible: true title: qsTr("Demo") Button { id: btn text: "Click" onClicked: { console.log("button clicked") } } }

在QML运行逻辑里,Window和Button并不是两个神奇的标签,而是两个注册好的C++类型。QML引擎在解析文档时,会把顶层Window变成一个QQuickWindow实例,把子级Button变成一个QQuickButton实例。这些类型注册是在引擎初始化阶段完成的,Qt内置类型不需要额外设置,但自定义类型需要手动注册,后面第三部分会细说。

类型注册之后才有对象树:engine维护一个根上下文(root context),每个QML组件都在这棵上下文树上创建。对象树的建立遵循“父亲先创建,子项后创建”的顺序。以Window为例:先创建窗口对象,再依次创建它声明的各个子对象,最后才到Component.onCompleted阶段。很多初学者在这个阶段犯的错误是,在父项的Component.onCompleted里去访问子项的属性,此时子项可能还没有初始化完成。

2.2 属性绑定的求值时机:声明式逻辑的心脏

对象树建立之后,真正驱动界面运转的是属性绑定。QML里的width: parent.width / 2不是一次性的赋值,而是一条持续存在的求值规则。

具体运行逻辑是:

Rectangle { width: parent.width / 2 height: width * 0.618 color: "lightsteelblue" }
  • 引擎会分析这个绑定表达式,找出它依赖的所有属性:parent.width、width。
  • 当被依赖的属性变化(比如顶层Window宽度变化),引擎会自动把依赖它的绑定表达式标记为“脏”。
  • 在下一个事件循环迭代中,引擎重新求值被标记的表达式,width更新后,又触发height的绑定,最终触发Rectangle重新绘制。

这整个过程是自动、异步、级联的。这也是为什么在QML里写界面代码,不需要手动去调用“刷新”函数。真正要小心的反而是循环依赖:a.width依赖b.width,而b.width又依赖a.width,引擎会把这类绑定检测出来并抛出一个类似“Binding loop detected”的警告。这种问题在运行时极难定位,好在控制台会有明确提示。

2.3 对象的创建、完成与销毁顺序

QML对象的生命周期比普通C++对象多了一个“完成”阶段。创建顺序遵循以下规则:

  • 对象属性初始化:先执行property的默认值、显式赋值。
  • 子对象创建:递归创建所有子项。
  • 父对象完成:Component.onCompleted触发。
  • 销毁:Component.onDestruction触发。
Item { Component.onCompleted: { console.log("parent completed") } Rectangle { id: child Component.onCompleted: { console.log("child completed") } } }

上面这段代码输出的顺序是child completed先于parent completed。很多人在父项里通过children或find去拿子项,就必须注意这个顺序。反过来,销毁时父项先于子项触发onDestruction。

动态创建对象时,这个逻辑依然成立。用component.createObject(parent)创建的新对象同样遵循先子后父的完成顺序,但要注意:动态创建的对象如果没有设置parent,或者没有放进某个StackView、Loader容器里,它虽然能创建出来,但不会被显示,也不会被父对象管理,很容易造成内存泄漏。

3. C++与QML的数据流通:运行时桥梁机制

3.1 上下文属性:从C++里把数据丢给QML最快的方式

绝大多数QML程序不是纯QML,而是C++后端加QML界面。数据从C++传到QML,最直白的方式就是上下文属性,代码通常是:

engine.rootContext()->setContextProperty("backend", &backend);

然后在QML里:

Text { text: backend.currentStatus }

上下文属性的运行逻辑是:setContextProperty把一个C++对象塞进了QQmlContext,QML引擎会把它的属性暴露为backend.currentStatus这样的键值路径。注意,QML侧能否实时感知currentStatus变化,取决于这个C++类有没有按Qt的元对象规范暴露属性:

class Backend : public QObject { Q_OBJECT Q_PROPERTY(QString currentStatus READ currentStatus NOTIFY currentStatusChanged) public: QString currentStatus() const; signals: void currentStatusChanged(); };

如果漏掉Q_PROPERTY里那个NOTIFY信号,QML引擎就只知道有一个currentStatus初始值,永远不会在C++里改它后刷新界面。这是QML与C++桥接里最经典、最高频的坑。你在运行时死活看不到界面更新,第一反应应该是检查NOTIFY信号有没有发出来,而不是怀疑绑定写错了。

3.2 注册自定义类型与类型的可见性

setContextProperty适合全局数据对象,但项目一复杂,更常见的是注册QML类型,在QML里直接当元素用。例如:

qmlRegisterType<DeviceController>("App.Controllers", 1, 0, "DeviceController");

注册之后,QML就能写:

import App.Controllers 1.0 DeviceController { id: ctrl }

qmlRegisterType的本质是告诉引擎,DeviceController这个字符串对应哪个C++类型,以及它归哪个命名空间版本管理。从运行逻辑的角度看,注册之后的类型和Qt内置的Rectangle没有任何区别,引擎在创建对象时统一走同样的构造和完成流程。

还有一个细节值得注意:注册的C++类型如果是QQuickItem派生的,QML侧创建出来的对象就会自动挂在场景图树上,可以设x、y、width、height;如果是普通QObject派生,组件创建出来只是一个不可见逻辑对象。具体要注册哪种,取决于你是想做一个可视化控件,还是一个纯后端服务对象。

3.3 信号与槽跨语言绑定的完整链路

C++往QML传数据靠属性,QML往C++传指令靠信号,信号绑定的运行逻辑如下:

// C++ emit requestOpen(deviceId);
Connections { target: backend function onRequestOpen(deviceId) { console.log("request open", deviceId) } }

运行时的信号分发链路是:C++侧emit信号,Qt元对象系统自动在信号与槽、信号与QML函数之间找到连接关系,调用QML侧注册的onRequestOpen函数。这里有个QML特有的小逻辑:QML侧不用connect,而是靠函数命名规则on加信号名自动匹配。信号参数有几个,QML函数就声明几个。如果其中一个参数没声明,QML也不会报错,只会丢弃该参数。

Qt 6里还有一个更现代的做法,把C++方法标记为Q_INVOKABLE,QML里直接调用:

public slots: void submitCommand(const QString &cmd);

QML侧:

onClicked: ctrl.submitCommand("START")

两者运行逻辑的底层是一致的,最终都通过元对象调用机制到达C++。选择用信号还是用Q_INVOKABLE,我的经验是:从QML主动驱动C++,用Q_INVOKABLE更直观;从C++主动告知QML,用信号更合适。

4. 常见运行问题定位:编译错误、导入路径与插件加载

4.1 QML的“编译错误”和C++编译错误是两回事

热门搜索里经常出现qml编译错误,但需要先区分一个概念:QML绝大多数编译错误是语法或类型绑定的解析错误,而不是真正的机器码编译错误。

常见的QML“编译错误”长这样:

property int a:

提示信息可能是QML syntax error,或者Expected token '}'。这类错误发生在engine.load()阶段,引擎根本没有走到对象创建就失败了,所以你会看到窗口空白或者直接rootObjects().isEmpty()返回真。

另一类更隐蔽的错误出现在qmlcachegen阶段。Qt 6对于模块里的QML文件会预编译成缓存文件,如果缓存机制和源文件不一致,可能出现“QML module not found”。处理方式通常是把构建目录里的qmlcache相关内容清理重启,或者检查QT_QML_GC_CACHE环境变量。注意,项目发布后第一次运行时生成的QML缓存路径和开发时不同,应用如果对磁盘没有写权限,可能反复重编缓存导致启动缓慢,这也容易被误报成“编译错误”。

4.2 导入路径与QML_IMPORT_PATH环境变量设置

QML里写import QtQuick.Controls,引擎怎么找到这个模块?它靠一套导入路径规则。在Windows开发机上,默认导入路径是:

D:/Qt/Qt/6.8.3/msvc2022_64/qml

构建和运行时,引擎会在这条路径下寻找QtQuick/Controls等子目录,并读取里面的qmldir文件。如果模块目录存在但版本信息缺失,也照样报“module not found”。

很多人在自定义模块放到了其他目录之后,就遇到了找不到模块的问题。这时直接设置环境变量是最快的:

set QML_IMPORT_PATH=D:\my_qml_modules

Linux环境同理:

export QML_IMPORT_PATH=/opt/my_qml_modules

QML_IMPORT_PATH和QML2_IMPORT_PATH的区别需要注意:Qt 5的老项目习惯用QML2_IMPORT_PATH;Qt 6已经统一用QML_IMPORT_PATH。如果你两个都设过,并且路径不同,引擎会优先查找Qt安装目录自带路径,然后才是自定义路径,最后才是工作目录。我建议生产项目不要依赖环境变量来做导入路径,因为在发布后的目标机器上,谁也不知道环境变量被配成了什么样。更可靠的是在main.cpp里主动追加:

engine.addImportPath(":/qml"); engine.addImportPath("qrc:/qml");

或者用qt.conf把导入路径固定下来。环境变量适合开发和临时验证,正式产品还是用代码里配置。

4.3 插件“d:/qt/qt/6.8.3/msvc2022_64/qml/qtquick/studio/components/quickstudioco”这类报错意味着什么

热词里出现了一整段插件路径,这其实包含了两个信息:路径和插件名。路径指向Qt 6.8.3安装目录下的Qt Quick Studio Components模块,插件名是quickstudioco带dll后缀。出现这个报错通常有三种原因:

  1. 你在QML里写了import QtQuick.Studio.Components 1.0,但当前Qt安装包没有安装Qt Design Studio对应的QML插件,或者插件位于另一个安装包目录。
  2. 开发机上有多个Qt版本(比如Qt 5.15和Qt 6.8并存),环境变量QML_IMPORT_PATH里指向了5.15的路径,但工程用的编译器是msvc2022_64,两个版本的内置模块不兼容。
  3. 插件dll本身依赖的Qt库版本不匹配。记住,Qt的QML插件是二进制模块,路径必须与当前使用的Qt版本、编译器、构建方式完全一致,任何一项对不上,运行时都加载不了。

排查这类问题,先看完整的报错信息,引擎会给出具体寻找过的路径列表。之后再检查当前进程加载的是哪一个Qt库:

qDebug() << QLibraryInfo::path(QLibraryInfo::QmlImportsPath);

打印出来的路径如果和实际安装目录不一致,就是环境变量或者qt.conf把路径带偏了。清理掉多余的环境变量,再保证工程构建时的Qt版本与运行时一致,这种问题基本都能解决。

4.4 遥控器场景下的QML运行逻辑:从交互到界面

热词里出现了qml遥控器,这其实是QML很典型的物联网/嵌入式应用场景。遥控器界面的运行逻辑和桌面程序基本一致,差别主要在于:

  • 输入设备不同,界面响应的是遥控器按键事件,而不是鼠标触摸。
  • 资源受限,QML运行时的渲染压力和加载性能需要额外注意。
  • 常驻运行,程序启动后长期不退出,对象的生命周期管理更严格。

QML处理遥控器按键非常直接,可以用Keys附加属性:

Rectangle { focus: true Keys.onPressed: (event) => { if (event.key === Qt.Key_Left) { listView.decrementCurrentIndex() event.accepted = true } } }

运行逻辑本质上就是:焦点控件捕获按键事件,然后通过调用ListView的方法去改变当前索引,索引变化又会触发视图滚动和具体项的高亮更新。也就是说,遥控器场景下你依然要遵循声明式逻辑,把“按键”映射为“状态变化”,界面就会主动跟随状态刷新。

在嵌入式遥控器设备上,QML程序的运行效率和CPU占用大头通常在图片解码和粒子渲染,所以建议在运行逻辑上多利用ListView、Repeater等按需实例化机制,千万不要一次性创建几百个复杂控件常驻内存。

5. ListModel为核心的动态数据运行逻辑

5.1 ListModel在运行时的定位

qml listmodel是热搜常客,这并不奇怪。ListView+ListModel是QML里最核心的列表数据驱动模式。ListModel本身是纯QML侧的轻量级数据容器,不需要C++介入就能实现增删改查。它的运行逻辑遵循一套典型的“数据-视图”模式。

ListModel { id: model ListElement { name: "Item A"; value: 1 } ListElement { name: "Item B"; value: 2 } } ListView { anchors.fill: parent model: model delegate: Text { text: name + ": " + value } }

稍微展开一下这个运行链路:ListView被创建后,它会向model要“有多少行”,然后根据cacheBuffer和当前可视区域,向model请求对应行的角色数据(name、value),再用delegate描述的样子创建真正显示的项。滚动发生时,视图层会复用已经创建的delegate,而不是反复新建,这是QML运行性能得以上升的关键机制。

5.2 数据增删改查如何触发视图刷新

理解了模型-视图运行逻辑后,最关键的问题是:数据变了,界面为什么刷新?答案在信号。ListModel每次调用append、insert、remove、set,都会发射对应的数据变化信号,视图对象监听这些信号并重新拉取受影响的行。

比如你在一个界面响应遥控器上下键,用model.append({name: "New", value: 3})加了一行,那么:

  1. 新数据被塞进ListModel内部的数据数组。
  2. count属性自动更新,同时发射countChanged和rowsInserted信号。
  3. ListView收到rowsInserted后,根据新行的位置决定插入位置,或者更新count显示。
  4. 如果新行在可视区内,delegate会被创建并显示;如果在可视区外,等滚动到附近时再创建。

用set修改某一行字段时,逻辑更细:不仅模型数据变了,还要求视图把这一行重新显示出来。所以set内部会发射itemChanged信号,delegate里针对该行绑定的属性会重新求值。这里要特别注意:修改ListModel中的角色字段有两种写法,一种是model.itemModel = 5(直接赋值赋的是行数据本身,容易出错),更稳定的是model.setProperty(index, "name", "new value"),它会精确地定位到某一行某个角色,并触发最小范围的刷新。

5.3ListModel和C++的QAbstractListModel在运行内存上的本质差异

QML侧用ListModel确实方便,数据量一旦大了,问题就来了。ListModel的数据以QVariantHash或类似结构存在QML引擎的JavaScript绑定环境中,每一行都是独立的动态对象。1000行以内体验还不错,到5000行以上,滑动时就能感到明显的吃力。

生产项目里更推荐把数据放到C++侧,用QAbstractListModel:

class DeviceListModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) public: enum Roles { NameRole = Qt::UserRole + 1, StatusRole }; QHash<int, QByteArray> roleNames() const override { return { { NameRole, "name" }, { StatusRole, "status" } }; } int rowCount(const QModelIndex &parent) const override; QVariant data(const QModelIndex &index, int role) const override; protected: void updateDevice(int row, const QString &status) { emit dataChanged(index(row), index(row), { StatusRole }); } };

从运行逻辑上看,QAbstractListModel和ListModel对QML视图是等价的——ListView向model请求rowCount、data,模型返回对应数据,通通通过roleNames()映射到delegate里的name和status。不同之处在于数据存储和刷新粒度:C++模型的内存是紧凑的结构体数组,不是一堆动态属性;数据更新时由开发者显式调用dataChanged,精准控制通知范围。

5.4 实时数据更新与UI刷新隔离的一个小示例

我在实际项目中经常要处理这种场景:设备状态上报线程每秒钟更新几十个遥控器设备的在线状态,UI必须实时反映。正确做法是,让数据线程只修改模型内部数据,然后打包发一个跨线程信号,真正操作模型和触发视图刷新都放在主线程。

// 数据线程 emit d->model->deviceStatusChanged(deviceId, online);

模型类内部接到信号后,再对数据做实际更新:

void DeviceListModel::onStatusChanged(const QString &deviceId, bool online) { int row = findRow(deviceId); if (row < 0) return; m_data[row].online = online; emit dataChanged(index(row), index(row), { OnlineRole }); }

为什么一定要走主线程?因为QML视图的刷新和模型操作必须发生在GUI线程,线程安全问题一旦出现,程序可能在数据量上来之后偶发崩溃,很难复现。遵循“数据线程计算,主线程改模型”的原则后,这个问题就从根上规避了。这里也顺便回应了ListView的一个运行机制:即使你只改了第10行的数据,只要没动其他行,视图就只会更新第10行的delegate,其他行完全不受影响。

6. QML程序在真实项目里的运行节奏与调试经验

6.1 用运行时检查替代“对着代码猜”

QML程序运行时的问题不像编译错误那么明确,调试方式也和C++差别很大。我排障的顺序通常是从外部到内部:

  1. 先看控制台有没有qml:开头的警告。QML引擎的警告都很直白,比如“Unable to assign [undefined] to QString”,说明绑定表达式返回了空值。
  2. 再打开QML调试器。Qt Creator里启动应用时勾选“Enable QML debugging”,程序运行后会弹出调试端口,可以在QML里打断点。这对于梳理信号流动链特别有用。
  3. console.log是最朴素但最有效的工具。用console.log('[bind] width', width)这种方式标记关键绑定,能看出属性的求值和更新顺序是不是符合预期。

还要提醒一点:发布Release版时,console.log默认还是会打进控制台,只是在Windows GUI程序里看不到。如果发布版遇到“程序没反应”,先打开DebugView或重定向日志,比直接加断点更快。

6.2 对象生命周期里的常见泄漏点

QML写起来随意,内存泄漏也比C++更容易藏。最常见的三种运行期泄漏:

  • 动态创建的Component对象没有指定父对象,也没有显式销毁。
  • 在Component.onCompleted里创建的事件监听,组件销毁后没有被disconnect。
  • Timer、Animation这类持续运行的组件,页面切走之后没有停掉。

我的习惯是:凡是在代码里出现.createObject(,旁边一定伴随一个destroy(的逻辑,要么在父对象销毁时用parent管理,要么在信号槽里显式销毁。对于Timer组件,页面退出前要手动stop()。这些不是QML框架帮你解决的事,开发阶段不注意,切换到遥控器这种常驻设备上就会变成“跑一整天后内存涨几十兆”的隐患。

6.3 属性绑定与手动onChanged的取舍

QML运行逻辑里最强大的部分是绑定,最容易被误用的也是绑定。当你在一个Rectangle里写:

width: parent.width / 2

这就是一次性的依赖声明。但如果你写:

onWidthChanged: myLabel.text = "width is " + width

这也是一个绑定,但它变成隐式的。如果多处代码都在用onXxxChanged手动赋值,很容易出现“赋值互相覆盖”的竞态。我的经验是:能用显式绑定text: "width is " + width就尽量用显式绑定;onXxxChanged只在做副作用(比如调用方法、发信号、开动画)时才用。显式绑定让引擎帮你维护依赖关系,而手动赋值则要你自己保证时机,后者出错概率大得多。

6.4 一个完整的小链路:遥控器按键到列表刷新的运行顺序

最后把以上逻辑串成一个具体场景,假设遥控器界面有一个设备列表,按右键切换选中的设备,状态栏显示当前设备信息:

  1. 用户按遥控器右键,Rectangle获得按键事件,调用listView.incrementCurrentIndex()。
  2. ListView发现currentIndex变化,更新当前选中项的高亮delegate,同时发射currentIndexChanged。
  3. 状态栏的Text因为有绑定text: listView.currentItem ? listView.currentItem.deviceName : "",所以自动刷新文字。
  4. 如果设备状态在后台变化,C++模型发射dataChanged,ListView对应行的status角色更新,delegate里相关的显示内容跟随刷新。
  5. 整个过程中,没有任何一步是“手动调用repaint”或“手动查找控件去改文字”,全部由QML运行时的绑定和信号机制自动完成。

这条链路就是QML程序运行逻辑在真实产品里的一个缩影:启动时构建对象树,运行时靠属性和信号编织数据流,数据变化通过模型通知视图,视图刷新通过绑定级联。

我自己做了多年QML之后,最大的体会是,别把QML当作写界面的脚本语言来用,它其实是一套带完整生命周期和依赖追踪的运行时系统。刚开始写QML,遇到界面不刷新,先反思自己是不是还在用命令式的思路在改UI;遇到莫名其妙的插件加载失败,先确认路径和Qt版本是否完全匹配,再考虑是不是环境变量串了。把这些基础链路吃透了,剩下的都是一层一层往下追的问题而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询