简介:面向具备一定C++基础的Qt桌面开发者,这份基于Qt的流程图编辑器完整工程源码,系统演示了自定义图形组件、拖拽交互、连线自动吸附、序列化保存与撤销重做等核心功能的实现思路。工程以QGraphicsView / QGraphicsItem框架为基础,通过继承方式定制矩形节点、菱形与连线等图形元素,重写paint()完成绘制,并维护操作历史栈以支持Undo/Redo;同时引入QDataStream实现流程图状态的保存与恢复,整体代码组织清晰。资源共43个文件,含14个cpp源文件、14个头文件、3个ui界面文件与6张png图标等,源码与界面分离,目录模块化,压缩包仅68KB,便于直接导入Qt Creator学习。目前已有1579人学习下载,适合希望深入掌握Qt图形视图框架、并参考实战项目完成图形化编辑器开发的开发者。
1. 为什么我会把流程图编辑器从 Web 迁回 Qt
之前带团队做产线工位配置工具,业务方每周都要改流程:加一个质检节点、把某个工位的下游改到另一条线。最早用 Web 版拖拽流程图,改一次要过服务端,等三分钟是常事,还要处理浏览器兼容;后来我把这套编辑逻辑整体搬到 Qt 上,用 Widgets 加 Graphics View 重写,流程图编辑器不再依赖网络,保存的 JSON 直接灌给产线执行引擎,落地反而比预期顺利。
基于 Qt 的流程图编辑器不新鲜,但它正好卡在桌面工具和低代码平台之间——你要的不是一个画图工具,而是一块能嵌进业务系统、能撤销、能序列化、能被 C++ 代码直接调用的画布。这篇按我做 MES 流程编排和通讯调试工具的路径写,适合想给产线、算法编排、上位机做内嵌流程设计的 Qt 开发者;新手能跟着把最小版本跑起来,熟手能直接跳到避坑清单看边界。
2. 先把图模型立住:节点、连线与端口的数据结构决定编辑器上限
很多人拿到流程图编辑器的第一个动作是往 scene 里addItem一个QGraphicsRectItem,结果做到端口、属性、撤销时全部返工。我一般先写数据模型:节点、连线、端口是三个独立对象,视图层只是把它们画出来的投影。模型设计得越早,后面序列化、校验、undo 越省事。
2.1 节点不只是一个矩形:让节点成为带业务字段的数据容器
直接用QGraphicsItem存业务字段是最常见的返工点。节点类型不同,需要的字段也不同:启动节点没有上游,执行节点有超时时间,分支节点有条件表达式。为每一种节点建子类会把类爆炸,而且QGraphicsItem不应该参与数据存储。我的做法是让每个节点都继承QObject,再用一个QVariantMap存扩展属性。
// FlowNode.h class FlowNode : public QObject { Q_OBJECT Q_PROPERTY(QString nodeId READ nodeId WRITE setNodeId) Q_PROPERTY(QString type READ type WRITE setType) public: explicit FlowNode(QObject *parent = nullptr); QString nodeId() const { return m_nodeId; } void setNodeId(const QString &id) { m_nodeId = id; } QString type() const { return m_type; } void setType(const QString &t) { m_type = t; } QPointF pos() const { return m_pos; } void setPos(const QPointF &p) { m_pos = p; } void setProp(const QString &key, const QVariant &val); QVariant prop(const QString &key) const; QVariantMap props() const { return m_props; } private: QString m_nodeId; QString m_type; QPointF m_pos; QVariantMap m_props; };选QObject而不是纯 struct,是因为后面要做三件事:第一,属性系统配合Q_PROPERTY可以统一走 Qt 的序列化机制;第二,节点属性变化时能发信号,视图层接到信号只刷新自身,不需要在每次修改属性后手动调update();第三,QUndoCommand里持有的是模型对象指针,命令只管数据,视图自动跟随。
nodeId我统一用QUuid::createUuid().toString(QUuid::WithoutBraces)生成,保证同一张图在不同机器上合并时不会撞 ID。type对应流程模板名,真正的业务字段放进m_props,例如{"timeout": 3000, "retry": 3}。这样以后新增字段不需要改类,只改模板配置。
2.2 连线和端口:从“无脑连线”到“类型校验 + 防环检测”
端口是节点上的连接锚点,连线的合法性判断应该发生在模型层,而不是让鼠标随便一拉就能连上。端口最少需要四个字段:端口 ID、所属节点 ID、方向、数据类型。数据类型用来阻止把“整数”接到“图片”这种无意义连接。
struct FlowPort { QString portId; QString nodeId; QString portName; int direction; // 0 输入, 1 输出 QString dataType; // any / int / bool / image }; class FlowEdge : public QObject { Q_OBJECT public: FlowEdge(FlowPort *src, FlowPort *dst, QObject *parent = nullptr) : QObject(parent), m_srcPortId(src->portId), m_dstPortId(dst->portId) {} bool canConnect(FlowPort *src, FlowPort *dst) const { if (!src || !dst) return false; if (src->direction == dst->direction) return false; // 不能输出对输出 if (src->nodeId == dst->nodeId) return false; // 不能自己连自己 if (src->dataType != "any" && dst->dataType != "any" && src->dataType != dst->dataType) return false; return !hasPath(dst, src); // 防成环 } private: bool hasPath(FlowPort *from, FlowPort *to) const; QString m_srcPortId; QString m_dstPortId; };canConnect里最后的hasPath是环路检测。MES 流程、算法编排这类场景,执行引擎一旦遇到 A→B→A 的环会直接跑飞,所以我在创建连线时先做一次可达性判断。注意这里我先用端口 ID 而不是节点 ID 做连接关系,因为端口 ID 是唯一的,序列化和加载时只靠两个字符串就能恢复连接。
2.3 用 QUndoStack 把每一步编辑变成可撤销
自己维护一个QVector<QByteArray>保存快照,图一大将卡到用户怀疑人生。Qt 自带的QUndoStack保存的是命令对象,每个命令只记录“怎么改回去”,不是整张图的副本,内存占用小得多。节点增删这类命令,模板如下:
class AddNodeCommand : public QUndoCommand { public: AddNodeCommand(FlowScene *scene, const QPointF &pos, QUndoCommand *parent = nullptr) : QUndoCommand(parent), m_scene(scene), m_pos(pos) {} void redo() override { auto *node = new FlowNode; node->setNodeId(QUuid::createUuid().toString(QUuid::WithoutBraces)); node->setType("default"); node->setPos(m_pos); m_nodeId = node->nodeId(); m_scene->addNode(node); setText("添加节点"); } void undo() override { m_scene->removeNode(m_nodeId); } private: FlowScene *m_scene; QPointF m_pos; QString m_nodeId; };关键参数是m_nodeId必须在redo()里生成并保存,而不是构造函数里生成。因为QUndoCommand入栈后第一次执行走的是redo(),如果 ID 在构造时固定,命令行宏、重放、撤销后的 redo 都会复用同一个 ID,两次插入同一 ID 会让场景里的节点映射表崩溃。
命令只操作模型层,视图层不感知撤销内容。FlowScene::addNode内部在m_nodes里插入节点,同时创建一个QGraphicsItem并建立信号连接。这条信号链我建议这样写:
connect(node, &FlowNode::propChanged, item, [item](const QString &key, const QVariant &val) { Q_UNUSED(key); Q_UNUSED(val); item->update(); });QUndoStack还有一个容易被忽略的参数:undoLimit。默认无限制,长时间运行会积累命令对象。我会在初始化时设stack->setUndoLimit(200),超过后最早的命令被丢弃,避免内存无限增长。
3. 用 QGraphicsView 搭画布:缩放、平移、框选的最小配置
数据模型建好后,画布部分反而简单。流程图编辑器最核心的交互就是:缩放、平移、框选、双击创建、连线。QGraphicsView 把这套交互的底层全部处理完,剩下的只是参数配置和事件转发。
3.1 为什么是 Graphics View 而不是 QPainter 硬画
如果只是静态画几张图,QPainter足够;但编辑器要求鼠标点中某个节点时能拿到“这个 item”,拖拽时能检测碰撞,框选时能批量选中,缩放时坐标自动换算。QGraphicsView内部维护了空间索引,itemAt()、items()的拾取效率远高于自己遍历。这也是官方 Diagram Scene 示例选它的原因。
不要被它的学习曲线吓退:你真正要实现的类只有三个,QGraphicsScene管模型层不受欢迎,QGraphicsView管视口,QGraphicsItem管节点和连线的绘制。
3.2 最小可运行场景代码
先搭一个能跑起来的最小工程:
#include <QApplication> #include <QGraphicsScene> #include <QGraphicsView> int main(int argc, char *argv[]) { QApplication app(argc, argv); auto *scene = new QGraphicsScene; scene->setSceneRect(-500, -500, 1000, 1000); auto *view = new QGraphicsView(scene); view->setRenderHint(QPainter::Antialiasing); view->setDragMode(QGraphicsView::RubberBandDrag); view->setTransformationAnchor(QGraphicsView::AnchorUnderMouse); view->setResizeAnchor(QGraphicsView::AnchorViewCenter); view->show(); return app.exec(); }setSceneRect决定可滚动区域,不设置的话控件缩小时内容会被裁掉。Antialiasing必须开,否则斜向连线会成为锯齿阶梯。RubberBandDrag让左键拖拽出矩形选区,这是节点编辑器的基础操作。AnchorUnderMouse让滚轮缩放时鼠标指向的位置保持不动,这是编辑器手感的关键。
3.3 缩放锚点与中键平移的参数设置
滚轮缩放不要直接乘以常数,用指数函数让每一格滚动的增量感一致,同时限制缩放范围:
void FlowView::wheelEvent(QWheelEvent *event) { const qreal currentZoom = transform().m11(); if (event->angleDelta().y() > 0 && currentZoom > 5.0) return; if (event->angleDelta().y() < 0 && currentZoom < 0.1) return; qreal factor = std::pow(1.0015, event->angleDelta().y()); scale(factor, factor); event->accept(); }transform().m11()是当前 X 轴缩放系数,用m22()也可以,只要视图没做错切,两者相等。限制区间[0.1, 5.0]是我调出来比较舒服的范围:缩得太小新手找不到节点,放得太大绘制开销成倍增加。
中键平移有个经典坑:直接setDragMode(ScrollHandDrag)后,左键拖拽也会变成平移,节点永远拖不动。我一般只在按下中键时临时切换模式:
void FlowView::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::MiddleButton) { setDragMode(QGraphicsView::ScrollHandDrag); QGraphicsView::mousePressEvent(event); return; } QGraphicsView::mousePressEvent(event); } void FlowView::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::MiddleButton) setDragMode(QGraphicsView::RubberBandDrag); QGraphicsView::mouseReleaseEvent(event); }注意不要在mousePressEvent里手写event->accept()然后不调用父类,否则场景收不到事件,节点拖拽会失灵。
3.4 把双击、右键菜单和属性编辑串起来
画布双击空白处创建一个新节点,右键节点弹出菜单。双击事件要区分是否点到了 item:
void FlowView::mouseDoubleClickEvent(QMouseEvent *event) { QGraphicsItem *item = itemAt(event->pos()); if (!item) { QPointF scenePos = mapToScene(event->pos()); emit createNodeRequested(scenePos); } else { QGraphicsView::mouseDoubleClickEvent(event); } }itemAt查到的可能是节点里的子 item,所以创建节点信号只由空白区域触发。弹出的右键菜单我建议放在QGraphicsItem::contextMenuEvent里实现,而不是视图层统一处理,因为不同节点类型的菜单不同。
节点绘制本身也要注意边界。boundingRect()返回的是 item 的局部坐标矩形,绘制代码必须只在这个矩形内画,否则会出现重影:
class NodeItem : public QGraphicsItem { public: QRectF boundingRect() const override { return QRectF(-60, -30, 120, 60); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { Q_UNUSED(option); Q_UNUSED(widget); painter->setBrush(QColor(240, 244, 248)); painter->setPen(QPen(QColor(60, 96, 140), 1.5)); painter->drawRoundedRect(boundingRect(), 6, 6); } };paint里尽量不要动态new对象,QPen、QBrush可以设为成员变量,一次构造多处使用。Debug 和 Release 性能差异过大时,十有八九是绘制函数里有频繁的堆分配。
4. 把编辑器做成能交付的形态:端口吸附、连线校验与 JSON 序列化
画布能拖节点后,最影响“像不像编辑器”的是连线体验和文件格式。连线要能吸附到端口,非法连接要在松开鼠标前就给出反馈;保存的文件要能被后端执行引擎直接解析,而不是只能被自己的编辑器读。
4.1 端口吸附:拉一条线,靠近端口时自动高亮
连线交互的做法是:在源端口按下鼠标,创建一条临时QGraphicsLineItem,鼠标移动时更新终点,同时检测终点附近有没有可连接的端口;检测到就把端口高亮,松开鼠标时如果目标合法,删除临时线并创建正式FlowEdge。
void FlowView::mouseMoveEvent(QMouseEvent *event) { QPointF scenePos = mapToScene(event->pos()); FlowPortItem *hoverPort = scene->portAt(scenePos, 12.0); if (hoverPort && m_currentPort != hoverPort) { hoverPort->setHighlight(true); if (m_prevHoverPort) m_prevHoverPort->setHighlight(false); m_prevHoverPort = hoverPort; } m_connectionLine->setLine(QLineF(m_startPos, scenePos)); QGraphicsView::mouseMoveEvent(event); }portAt的实现可以直接遍历场景所有FlowPortItem,计算距离小于吸附半径的端口。吸附半径的单位是像素,但mapToScene返回的是场景坐标,所以判断时要换算:qreal threshold = 12.0 / transform().m11();。不换算的话,放大 5 倍后吸附范围会变成 60 像素,用户会感觉“还没靠近就被吸走了”。
4.2 连线合法性检查:类型不一致和成环连接都要拦
在mouseReleaseEvent里拿到目标端口后,调用FlowEdge::canConnect做最终校验。防环检测用 BFS 从目标端口反向找源端口,能走到就说明新连线会造成环:
bool FlowEdge::hasPath(FlowPort *from, FlowPort *to) const { QSet<QString> visited; QQueue<FlowPort *> queue; queue.enqueue(from); while (!queue.isEmpty()) { FlowPort *cur = queue.dequeue(); if (cur->portId == to->portId) return true; visited.insert(cur->portId); const auto outs = cur->outputs(); // 当前端口作为源的所有下游端口 for (FlowPort *next : outs) if (!visited.contains(next->portId)) queue.enqueue(next); } return false; }这里有个语义选择:执行引擎拒绝环,但状态机语义允许环。我不建议在canConnect里硬编码防环逻辑,而是把校验器做成策略对象:默认拒绝成环,状态机编辑器则传入允许环的校验器。FlowScene持有校验器指针,新增连线、加载文件时统一走同一个入口。
4.3 序列化:JSON 而不是 XML,后端执行引擎直接吃
Qt 对 JSON 的支持是原生的,QJsonObject可以直接和QVariantMap互转,这意味着节点的m_props可以直接落盘,不需要手写字段映射。我的FlowScene::toJson长这样:
QJsonObject FlowScene::toJson() const { QJsonObject root; root["version"] = 1; QJsonArray nodesArr; for (const FlowNode *node : std::as_const(m_nodes)) { QJsonObject obj; obj["id"] = node->nodeId(); obj["type"] = node->type(); obj["x"] = node->pos().x(); obj["y"] = node->pos().y(); obj["props"] = QJsonObject::fromVariantMap(node->props()); nodesArr.append(obj); } root["nodes"] = nodesArr; QJsonArray edgesArr; for (const FlowEdge *edge : std::as_const(m_edges)) { QJsonObject obj; obj["src"] = edge->srcPortId(); obj["dst"] = edge->dstPortId(); edgesArr.append(obj); } root["edges"] = edgesArr; return root; }保存时用QSaveFile做原子写入,避免写到一半崩溃把老文件也毁掉:
bool FlowScene::saveToFile(const QString &path) { QSaveFile file(path); if (!file.open(QIODevice::WriteOnly)) return false; file.write(QJsonDocument(toJson()).toJson(QJsonDocument::Indented)); return file.commit(); }加载 JSON 时有一个必须做的校验:边引用的端口 ID 必须真实存在。JSON 文件可能是人手改过的,也可能从旧版本升级过来,缺失端口就直接qWarning()并跳过,不要让一个坏边拖垮整个场景。版本号字段也建议从一开始就写上,后续格式变更时用版本号做迁移,否则上线三个月后改格式只能拍大腿。
4.4 导出图片和与其他模块的对接
流程图编辑器通常要交付两种产物:给后端执行的 JSON,给人看的 PNG。导出图片用QGraphicsScene::render,但有个坑:导出前view如果有滚动条或者缩放,render会按 sceneRect 推导的矩形导出,实际结果经常被裁。安全做法:
QImage image(sceneRect.size().toSize(), QImage::Format_ARGB32_Premultiplied); QPainter p(&image); scene->render(&p, QRectF(image.rect()), scene->sceneRect()); p.end(); image.save(exportPath);scene->render的第二个参数是目标矩形,第三个是源矩形。把目标矩形设成image.rect(),源矩形设成sceneRect(),就能保证整张图完整导出。导出前别忘了把所有 item 的选中状态清掉,否则 PNG 里会带着蓝色选框。
5. Qt 流程图编辑器避坑清单:库版本、platform 插件和发布闪退
这一部分是从实际部署和用户反馈里挑出来的高频问题。每条都是现象到原因到解决,照着排查能省大半天。
5.1 编译报错 “cannot mix incompatible Qt library version”
现象:用 Qt 5.15.2 开发,程序一运行就打日志fatal: cannot mix incompatible Qt library (version 0x50e01) with this library (0x50c04),随后退出。
原因:编译器链接的库和运行时加载的库版本不一致。最常见的是机器上存在多个 Qt 安装目录,系统 PATH 或LD_LIBRARY_PATH指向了另一个版本,导致libQt5Core.so.5或Qt5Core.dll加载了两次不同版本。如果你在 Windows 上装了 MinGW 版的 Qt,又额外装了 MSVC 版的 Qt,两个安装目录同时在 PATH 里,触发的概率极高。
解决:先用ldd 可执行文件 | grep Qt5看实际加载路径;Windows 上用dumpbin /dependents 可执行文件.exe检查。然后在工程里显式指定 Qt 库路径,运行时不要在 PATH 里混入第二个 Qt 的bin目录。最好的是用windeployqt或linuxdeployqt发布,把依赖整理成相对路径。
5.2 板子或工控机上报 “could not find the Qt platform plugin linuxfb”
现象:程序在开发电脑上正常,拷到 Linux 工控机后启动报qt.qpa.plugin: could not find the Qt platform plugin "linuxfb",再在无桌面环境下运行直接黑屏退出。
原因:Qt 的平台插件没有被打包进部署目录。linuxfb是 Linux 无窗口系统下的 framebuffer 插件,默认不在可执行文件同目录,程序找不到platforms目录下的libqlinuxfb.so。
解决:把 Qt 安装目录下plugins/platforms整体拷到可执行文件旁边的platforms目录,并设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向它。临时测试可以执行export QT_QPA_PLATFORM=linuxfb启动,但如果目标机器带 X11 或 Wayland,这会让程序放弃 xcb 而走 framebuffer,画面渲染会完全不同。正确做法是打包时带上所有平台插件,让 Qt 按运行时环境自动选择。
5.3 发布版闪退 “0xc0000005”:节点和边的生命周期没管好
现象:Debug 构建一切正常,Release 构建里一拖动节点就闪退,Windows 事件查看器报0xc0000005访问冲突。
原因:QGraphicsItem被释放了两次,或者持有了悬空指针。最常见的是removeNode里手动delete item,而QGraphicsScene析构时又会删除 scene 里的所有 item;另一个常见操作是把QGraphicsItem的 parent 设置成scene,随后又手动 delete,两次释放叠加。
解决:统一释放入口。所有节点删除都走FlowScene::removeNode,这个函数内部同时清理节点连出的边,再删除节点对象,命令模式里的AddNodeCommand::undo也只能调用它。不要在removeItem之后再用旧指针去访问坐标。发布版对未定义行为的惩罚比 Debug 严格得多,这类问题基本都指向“同一个资源两个释放点”。
5.4 Qt 5.15.2 里嵌入 QChart 后画布缩放异常
现象:在流程图节点里嵌QChartView显示采集曲线,鼠标滚轮缩放整个画布时,图表跟着乱跳,甚至 chart 内部曲线被拉伸变形。
原因:QChartView接收了滚轮事件,且它的内部场景坐标与外部QGraphicsView的变换叠加。Qt Charts 在 5.15 版本里对ItemIgnoresTransformations的支持不完整,直接给QGraphicsProxyWidget设这个标志会出现图表尺寸不变的副作用。
解决:最简单的做法是不在节点里放实时QChartView,改为在节点paint里用QPainter绘制缩略曲线,双击节点弹出独立QChart窗口。如果一定要内嵌,就重写外部FlowView::wheelEvent,当鼠标位置落在QGraphicsProxyWidget上时不执行缩放,把事件转发给 chart。对流程编辑器来说,节点内部放不必要的高频刷新图表是性能隐患,优先拆出去。
5.5 中文路径和文本加载乱码
现象:流程文件放在D:\生产数据\流程A\xxx.json,QFile打开成功,但QJsonDocument::fromJson返回空对象;或 MSVC 编译的源码里写中文节点名,程序里显示成乱码。
原因:Qt 5 的QFile打开文件后直接readAll()时,文本流的编码依赖本地字节流,而 JSON 文件保存的是 UTF-8。MSVC 环境下源文件的字符串字面量默认按本地代码页(GBK)解释,和 UTF-8 冲突。
解决:读取文件用QTextStream显式指定编码:
QTextStream stream(&file); stream.setCodec("UTF-8"); QJsonObject root = QJsonDocument::fromJson(stream.readAll().toUtf8()).object();MSVC 工程建议在.pro里加QMAKE_CXXFLAGS += /utf-8,或使用#pragma execution_character_set("utf-8")。路径里的中文一般不影响QFile,真正会出错的是把QString直接当作const char*传给旧的 C 接口。流程图编辑器里所有文件操作统一走QSaveFile和QTextStream,能避开大部分编码坑。
6. 进阶:把用户操作录成宏,用性能数据说服自己
6.1 用 QUndoStack 录制宏
流程图编辑器经常有“插入一个分支”这类包含多个节点和连线的复合操作。手动按 Ctrl+Z 要把每一步都撤销一遍,体验很差。QUndoStack::beginMacro可以把多个命令打包成一个撤销单位:
void FlowScene::insertBranch(const QPointF &pos) { m_undoStack->beginMacro("插入分支节点"); m_undoStack->push(new AddNodeCommand(this, pos + QPointF(60, -30))); m_undoStack->push(new AddNodeCommand(this, pos + QPointF(60, 30))); m_undoStack->push(new AddEdgeCommand(this, srcPortId, dstPortId1)); m_undoStack->push(new AddEdgeCommand(this, srcPortId, dstPortId2)); m_undoStack->endMacro(); }beginMacro之后的所有push都归入同一个宏,只有生命周期对称才能正确让undo()反序执行内部命令。复合操作里如果一个命令失败,后续命令要保证不执行,否则宏内部会产生半成品状态。这个技巧用来做固定套路库非常有效:把常用业务片段保存成模板,一键插入整段流程。
6.2 用 QElapsedTimer 验证性能
编辑器做大后,加载慢比操作卡更难排查。我用QElapsedTimer给加载流程计时,并且只信 Release 构建的数据:
QElapsedTimer timer; timer.start(); scene->loadFromJson(json); qDebug() << "load ms:" << timer.elapsed();我给自己定的目标是:100 个节点时加载少于 150ms,1000 个节点时少于 800ms。如果加载超时,大概率是在批量创建 item 时每创建一个都触发了视图更新。解法是批量操作前后包一层:
view->setUpdatesEnabled(false); // 批量创建节点和边 view->setUpdatesEnabled(true);个别情况下还要配合scene->blockSignals(true),因为节点属性变化信号会触发 item 重绘,批量加载时这种信号风暴是性能杀手。
6.3 一个习惯
以前我做第一个流程图版本,直接在QGraphicsRectItem上挂业务数据,做 undo 时发现命令操作的是视图,序列化时还要从视图反推数据,改到自己都想删库。后来所有模型数据都放QObject,QGraphicsItem只做呈现,命令只操作模型,视图靠信号刷新,这个习惯让后面所有新功能都变简单了。流程图编辑器的复杂度不在画布,而在你愿不愿意在动手画矩形之前把模型层想清楚。希望帮到你。
本文还有配套的精品资源,点击获取