使用Qt实现自定义GraphicsView
在本文中,我们将介绍如何使用Qt实现一个自定义的GraphicsView,主要是作为笔记使用QGraphicsView框架方面的使用手法、套路,对代码就不做过多的解释了,它具有以下功能:
- 显示图像
- 可拖动的十字标记(CrossMarkItem)
- 可调整大小的ROI(Region of Interest)矩形
- FPS和日期时间显示
- 保存和加载十字标记和ROI矩形的位置
1. 项目结构
我们的项目包含以下主要文件:
- MainWindow.h / MainWindow.cpp
- GraphView.h / GraphView.cpp
- CrossMarkItem.h / CrossMarkItem.cpp
- ROIRectItem.h / ROIRectItem.cpp
- CMakeLists.txt
2. 实现CrossMarkItem
首先,我们实现一个可拖动的十字标记:
CrossMarkItem.h:
#pragma once #include <QGraphicsItem> #include <QRectF> class CrossMarkItem : public QObject, public QGraphicsItem { Q_OBJECT Q_INTERFACES(QGraphicsItem) public: CrossMarkItem(QGraphicsItem *parent = nullptr); ~CrossMarkItem(); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget = nullptr) override; void setSize(qreal size); protected: QVariant itemChange(GraphicsItemChange change, const QVariant& value) override; signals: void positionChanged(const QPointF& pos); private: qreal m_size; };
CrossMarkItem.cpp:
#include <QPainter> #include "CrossMarkItem.h" CrossMarkItem::CrossMarkItem(QGraphicsItem* parent) : QGraphicsItem(parent), m_size(10) { setFlag(QGraphicsItem::ItemIsMovable); setFlag(QGraphicsItem::ItemSendsGeometryChanges); } CrossMarkItem::~CrossMarkItem() { } QRectF CrossMarkItem::boundingRect() const { return QRectF(-m_size / 2, -m_size / 2, m_size, m_size); } void CrossMarkItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->setPen(QPen(Qt::green, 2)); painter->drawLine(QPointF(-m_size / 2, 0), QPointF(m_size / 2, 0)); painter->drawLine(QPointF(0, -m_size / 2), QPointF(0, m_size / 2)); } void CrossMarkItem::setSize(qreal size) { m_size = size; update(); } QVariant CrossMarkItem::itemChange(GraphicsItemChange change, const QVariant& value) { if (change == GraphicsItemChange::ItemPositionChange && scene()) emit positionChanged(value.toPointF()); return QGraphicsItem::itemChange(change, value); }
3. 实现ROIRectItem
接下来,我们实现一个可调整大小的ROI矩形:
ROIRectItem.h:
#pragma once #include <QGraphicsItem> #include <QGraphicsRectItem> class ROIRectItem : public QGraphicsRectItem { public: explicit ROIRectItem(QGraphicsItem *parent = nullptr); ~ROIRectItem(); enum ResizeHandle { None, TopLeft, TopRight, BottomLeft, BottomRight }; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget = nullptr) override; QRectF boundingRect() const override; void setRelativeRect(const QRectF& relativeRect); QRectF relativeRect() const { return m_relativeRect; } void updateAbsoluteRect(const QRectF& imageRect); protected: void mousePressEvent(QGraphicsSceneMouseEvent* event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override; void hoverMoveEvent(QGraphicsSceneHoverEvent* event) override; private: ResizeHandle getResizeHandle(const QPointF& pos) const; void updateCursor(ResizeHandle handle); void resizeRect(const QPointF& delta); private: ResizeHandle m_activeHandle; QPointF m_lastPos; QRectF m_imageRect; QRectF m_relativeRect; };
ROIRectItem.cpp:
#include <QPainter> #include <QCursor> #include <QGraphicsSceneMouseEvent> #include <QGraphicsSceneHoverEvent> #include "ROIRectItem.h" ROIRectItem::ROIRectItem(QGraphicsItem* parent): QGraphicsRectItem(parent), m_activeHandle(None) { setFlag(QGraphicsItem::ItemIsMovable); setFlag(QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); } ROIRectItem::~ROIRectItem() { } void ROIRectItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->setPen(QPen(Qt::green, 2)); painter->drawRect(rect()); } QRectF ROIRectItem::boundingRect() const { return rect(); } void ROIRectItem::setRelativeRect(const QRectF& relativeRect) { m_relativeRect = relativeRect; } void ROIRectItem::updateAbsoluteRect(const QRectF& imageRect) { m_imageRect = imageRect; QRectF r( m_imageRect.x() + m_relativeRect.x() * m_imageRect.width(), m_imageRect.y() + m_relativeRect.y() * m_imageRect.height(), m_relativeRect.width() * m_imageRect.width(), m_relativeRect.height() * m_imageRect.height() ); setRect(r); } void ROIRectItem::mousePressEvent(QGraphicsSceneMouseEvent* event) { m_activeHandle = getResizeHandle(event->pos()); m_lastPos = event->pos(); QGraphicsItem::mousePressEvent(event); } void ROIRectItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event) { QPointF delta = event->pos() - m_lastPos; QRectF r; if (m_activeHandle != None) { resizeRect(delta); r = rect(); } else { r = rect().translated(delta); setRect(r); } // Ensure the rect is within the bounds of m_imageRect if (r.left() < m_imageRect.left()) r.moveLeft(m_imageRect.left() + 10); if (r.top() < m_imageRect.top()) r.moveTop(m_imageRect.top() + 10); if (r.right() > m_imageRect.right()) r.moveRight(m_imageRect.right() - 10); if (r.bottom() > m_imageRect.bottom()) r.moveBottom(m_imageRect.bottom() - 10); m_lastPos = event->pos(); // Update relative rect m_relativeRect = QRectF( (r.x() - m_imageRect.x()) / m_imageRect.width(), (r.y() - m_imageRect.y()) / m_imageRect.height(), r.width() / m_imageRect.width(), r.height() / m_imageRect.height() ); update(); } void ROIRectItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) { m_activeHandle = None; QGraphicsItem::mouseReleaseEvent(event); } void ROIRectItem::hoverMoveEvent(QGraphicsSceneHoverEvent* event) { updateCursor(getResizeHandle(event->pos())); QGraphicsItem::hoverMoveEvent(event); } ROIRectItem::ResizeHandle ROIRectItem::getResizeHandle(const QPointF& pos) const { QRectF r = rect(); qreal handleSize = 12; if (QRectF(r.topLeft() - QPointF(handleSize / 2, handleSize / 2), QSizeF(handleSize, handleSize)).contains(pos)) return TopLeft; if (QRectF(r.topRight() - QPointF(handleSize / 2, handleSize / 2), QSizeF(handleSize, handleSize)).contains(pos)) return TopRight; if (QRectF(r.bottomLeft() - QPointF(handleSize / 2, handleSize / 2), QSizeF(handleSize, handleSize)).contains(pos)) return BottomLeft; if (QRectF(r.bottomRight() - QPointF(handleSize / 2, handleSize / 2), QSizeF(handleSize, handleSize)).contains(pos)) return BottomRight; return None; } void ROIRectItem::updateCursor(ResizeHandle handle) { switch (handle) { case ROIRectItem::TopLeft: case ROIRectItem::BottomRight: setCursor(Qt::SizeFDiagCursor); break; case ROIRectItem::TopRight: case ROIRectItem::BottomLeft: setCursor(Qt::SizeBDiagCursor); break; default: setCursor(Qt::ArrowCursor); break; } } void ROIRectItem::resizeRect(const QPointF& delta) { QRectF r = rect(); switch (m_activeHandle) { case ROIRectItem::TopLeft: r.setTopLeft(r.topLeft() + delta); break; case ROIRectItem::TopRight: r.setTopRight(r.topRight() + delta); break; case ROIRectItem::BottomLeft: r.setBottomLeft(r.bottomLeft() + delta); break; case ROIRectItem::BottomRight: r.setBottomRight(r.bottomRight() + delta); break; default: break; } QRectF normalizedRect = r.normalized(); qreal minWidth = 50; qreal minHeight = 50; if (normalizedRect.width() < minWidth) { if (m_activeHandle == ROIRectItem::TopLeft || m_activeHandle == ROIRectItem::BottomLeft) normalizedRect.setLeft(normalizedRect.right() - minWidth); else normalizedRect.setRight(normalizedRect.left() + minWidth); } if (normalizedRect.height() < minHeight) { if (m_activeHandle == ROIRectItem::TopLeft || m_activeHandle == ROIRectItem::TopRight) normalizedRect.setTop(normalizedRect.bottom() - minHeight); else normalizedRect.setBottom(normalizedRect.top() + minHeight); } setRect(normalizedRect); }
4. 实现GraphView
现在,我们来实现主要的GraphView类:
GraphView.h:
#pragma once #include <QGraphicsView> #include <QGraphicsScene> #include <QGraphicsPixmapItem> #include <QTimer> #include <QDateTime> #include "CrossMarkItem.h" #include "ROIRectItem.h" class GraphView : public QGraphicsView { Q_OBJECT public: explicit GraphView(QWidget *parent = nullptr); ~GraphView(); void setImage(const QImage& image); void setKeepAspectRatio(bool keep); QPointF getMarkItemPositionInImage(); void setMarkItemPositionInImage(const QPointF& relativePos = QPointF(0.5, 0.5)); QRectF getROIRectInfoInImage(); void setROIRectInfoInImage(const QRectF& relativeRect = QRectF(0.25, 0.25, 0.5, 0.5)); protected: void resizeEvent(QResizeEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; private: void loadMarkItemPositionInImage(); void saveMarkItemPositionInImage(); void loadROIRectInfoInImage(); void saveROIRectInfoInImage(); public slots: void setMarkItemMovable(bool movable) { m_markItem->setFlag(QGraphicsItem::ItemIsMovable, movable); } void setMarkItemVisible(bool visible) { m_markItem->setVisible(visible); } void setROIRectMovable(bool movable) { m_roiRectItem->setFlag(QGraphicsItem::ItemIsMovable, movable); } void setROIRectVisible(bool visible) { m_roiRectItem->setVisible(visible); } private slots: void updateFPS(); void updateImageScale(); void updateTextItems(); void updateMarkItem(); void updateROIRect(); void onMarkItemPositionChanged(const QPointF& pos) { Q_UNUSED(pos); saveMarkItemPositionInImage(); } private: QGraphicsScene* m_scene; QGraphicsPixmapItem* m_imageItem; QGraphicsTextItem* m_fpsItem; QGraphicsTextItem* m_dateTimeItem; CrossMarkItem* m_markItem; ROIRectItem* m_roiRectItem; QTimer* m_fpsTimer; QTimer* m_dateTimeTimer; float m_fps; int m_frameCount; bool m_keepAspectRatio; bool m_drawingROIRect; QPointF m_roiRectStartPos; };
GraphView.cpp:
#include <QResizeEvent> #include <QOPenGLWidget> #include <QSettings> #include <QApplication> #include <QDir> #include <QDebug> #include "GraphView.h" GraphView::GraphView(QWidget *parent) : QGraphicsView(parent), m_scene(new QGraphicsScene(this)), m_frameCount(0), m_fps(0.0f), m_keepAspectRatio(false), m_drawingROIRect(false) { #ifdef WITH_GPU_RENDER // 使用 OpenGL QOpenGLWidget* glWidget = new QOpenGLWidget(this); QSurfaceFormat format; format.setSwapInterval(1); // 启用 VSync glWidget->setFormat(format); setViewport(glWidget); #endif // WITH_GPU_RENDER // 优化设置 setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); setCacheMode(QGraphicsView::CacheBackground); setOptimizationFlags(QGraphicsView::DontSavePainterState); setRenderHint(QPainter::Antialiasing, true); setRenderHint(QPainter::SmoothPixmapTransform, true); setScene(m_scene); m_imageItem = new QGraphicsPixmapItem(); m_scene->addItem(m_imageItem); m_markItem = new CrossMarkItem(); m_markItem->setZValue(100); m_scene->addItem(m_markItem); m_roiRectItem = new ROIRectItem(); m_scene->addItem(m_roiRectItem); m_fpsItem = new QGraphicsTextItem(); m_fpsItem->setDefaultTextColor(Qt::white); m_scene->addItem(m_fpsItem); m_dateTimeItem = new QGraphicsTextItem(); m_dateTimeItem->setDefaultTextColor(Qt::white); m_scene->addItem(m_dateTimeItem); // Update FPS every second m_fpsTimer = new QTimer(this); connect(m_fpsTimer, &QTimer::timeout, this, &GraphView::updateFPS); m_fpsTimer->start(1000); // Update date time every second m_dateTimeTimer = new QTimer(this); connect(m_dateTimeTimer, &QTimer::timeout, this, &GraphView::updateTextItems); m_dateTimeTimer->start(1000); setBackgroundBrush(Qt::black); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); } GraphView::~GraphView() { saveMarkItemPositionInImage(); saveROIRectInfoInImage(); } void GraphView::setImage(const QImage& image) { if (image.isNull()) return; m_imageItem->setPixmap(QPixmap::fromImage(image)); updateImageScale(); static bool firstImageArrival = true; if (firstImageArrival) { firstImageArrival = false; // 首次设置图像时载入坐标 loadMarkItemPositionInImage(); loadROIRectInfoInImage(); } m_frameCount++; } void GraphView::setKeepAspectRatio(bool keep) { m_keepAspectRatio = keep; updateImageScale(); } void GraphView::resizeEvent(QResizeEvent* event) { QGraphicsView::resizeEvent(event); updateImageScale(); updateTextItems(); } void GraphView::mousePressEvent(QMouseEvent* event) { QGraphicsView::mousePressEvent(event); } void GraphView::mouseMoveEvent(QMouseEvent* event) { QGraphicsView::mouseMoveEvent(event); } void GraphView::mouseReleaseEvent(QMouseEvent* event) { QGraphicsView::mouseReleaseEvent(event); } void GraphView::loadMarkItemPositionInImage() { // 获取执行文件目录并构建自定义路径 QString settingsDir = qApp->applicationDirPath() + "/settings"; QString settingsFile = settingsDir + "/" + this->metaObject()->className() + ".ini"; QSettings settings(settingsFile, QSettings::IniFormat); qreal x = settings.value("markItem/X", 0.5).toDouble(); qreal y = settings.value("markItem/Y", 0.5).toDouble(); setMarkItemPositionInImage(QPointF(x, y)); } void GraphView::saveMarkItemPositionInImage() { // 获取执行文件目录并构建自定义路径 QString settingsDir = qApp->applicationDirPath() + "/settings"; // 确保路径存在 QDir().mkpath(settingsDir); QString settingsFile = settingsDir + "/" + this->metaObject()->className() + ".ini"; QSettings settings(settingsFile, QSettings::IniFormat); QPointF relativePos = getMarkItemPositionInImage(); settings.setValue("markItem/X", relativePos.x()); settings.setValue("markItem/Y", relativePos.y()); } void GraphView::loadROIRectInfoInImage() { QString settingsDir = qApp->applicationDirPath() + "/settings"; QString settingsFile = settingsDir + "/" + this->metaObject()->className() + ".ini"; QSettings settings(settingsFile, QSettings::IniFormat); QRectF rect = settings.value("ROIRect/Rect", QRectF(0.25, 0.25, 0.5, 0.5)).toRectF(); setROIRectInfoInImage(rect); } void GraphView::saveROIRectInfoInImage() { QString settingsDir = qApp->applicationDirPath() + "/settings"; QDir().mkpath(settingsDir); QString settingsFile = settingsDir + "/" + this->metaObject()->className() + ".ini"; QSettings settings(settingsFile, QSettings::IniFormat); settings.setValue("ROIRect/Rect", getROIRectInfoInImage()); } void GraphView::updateFPS() { m_fps = m_frameCount; m_frameCount = 0; updateTextItems(); } void GraphView::updateImageScale() { if (m_imageItem->pixmap().isNull()) return; QRectF imageRect = m_imageItem->boundingRect(); QRectF viewRect = this->rect(); qreal scaleX = viewRect.width() / imageRect.width(); qreal scaleY = viewRect.height() / imageRect.height(); // 保存当前的相对位置 QPointF relativeMarkPos = getMarkItemPositionInImage(); QRectF relatvieROIRect = getROIRectInfoInImage(); if (m_keepAspectRatio) { qreal scale = qMin(scaleX, scaleY); m_imageItem->setScale(scale); // 居中位置显示 m_imageItem->setPos((viewRect.width() - imageRect.width() * scale) / 2, (viewRect.height() - imageRect.height() * scale) / 2); } // 拉伸填充 else { m_imageItem->setScale(1.0); // 重置缩放 m_imageItem->setPos(0, 0); // 设置到左上角 QTransform trans = QTransform::fromScale(scaleX, scaleY); // 创建缩放矩阵 m_imageItem->setTransform(trans); // 应用缩放矩阵 } m_scene->setSceneRect(viewRect); // 更新信息 setMarkItemPositionInImage(relativeMarkPos); setROIRectInfoInImage(relatvieROIRect); } void GraphView::updateTextItems() { QFont font = m_fpsItem->font(); font.setPointSize(12); m_fpsItem->setFont(font); m_dateTimeItem->setFont(font); m_fpsItem->setPlainText(QString::fromLocal8Bit("帧率:%1").arg(m_fps, 0, 'f', 1)); m_fpsItem->setPos(10, 0); QString currentDateTime = QDateTime::currentDateTime().toString(QString::fromLocal8Bit("yyyy/MM/dd hh:mm:ss")); m_dateTimeItem->setPlainText(currentDateTime); m_dateTimeItem->setPos(this->rect().width() - m_dateTimeItem->boundingRect().width() - 10, 0); } void GraphView::updateMarkItem() { if (m_imageItem->pixmap().isNull()) return; QRectF imageRect = m_imageItem->sceneBoundingRect(); // 10% of the smaller dimension m_markItem->setSize(qMin(imageRect.width(), imageRect.height()) * 0.1); } void GraphView::updateROIRect() { if (m_imageItem->pixmap().isNull()) return; QRectF imageRect = m_imageItem->sceneBoundingRect(); m_roiRectItem->updateAbsoluteRect(imageRect); } void GraphView::setMarkItemPositionInImage(const QPointF& relativePos) { if (m_imageItem->pixmap().isNull()) return; QRectF imageRect = m_imageItem->sceneBoundingRect(); qreal x = imageRect.left() + relativePos.x() * imageRect.width(); qreal y = imageRect.top() + relativePos.y() * imageRect.height(); m_markItem->setPos(x, y); updateMarkItem(); } QPointF GraphView::getMarkItemPositionInImage() { // 默认居中 if (m_imageItem->pixmap().isNull()) return QPointF(0.5, 0.5); QRectF imageRect = m_imageItem->sceneBoundingRect(); QPointF markItemPos = m_markItem->pos(); qreal x = (markItemPos.x() - imageRect.left()) / imageRect.width(); qreal y = (markItemPos.y() - imageRect.top()) / imageRect.height(); // 确保值在 0.1~0.99 范围内 x = qBound(0.01, x, 0.99); y = qBound(0.01, y, 0.99); return QPointF(x, y); } void GraphView::setROIRectInfoInImage(const QRectF& relativeRect) { m_roiRectItem->setRelativeRect(relativeRect); updateROIRect(); } QRectF GraphView::getROIRectInfoInImage() { // 默认居中矩形 if (m_imageItem->pixmap().isNull()) return QRectF(0.25, 0.25, 0.5, 0.5); // relativeRect的更新在 ROIRectItem 内容处理 return m_roiRectItem->relativeRect(); }
5. 实现MainWindow
最后,我们实现MainWindow类来使用我们的GraphView:
MainWindow.h:
#pragma once #include <QMainWindow> #include <QImage> class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); private: QImage m_image; };
MainWindow.cpp:
#include <QVBoxLayout> #include <QTimer> #include "MainWindow.h" #include "GraphView.h" MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { resize(800, 600); m_image.load("snipaste.png"); GraphView* imageView = new GraphView(); imageView->setMarkItemMovable(true); imageView->setMarkItemVisible(true); setCentralWidget(imageView); QTimer* timer = new QTimer(this); connect(timer, &QTimer::timeout, [=] { imageView->setImage(m_image); }); timer->start(15); } MainWindow::~MainWindow() { }
6. CMakeLists.txt
为了编译我们的项目,我们需要一个CMakeLists.txt文件:
# CMakeList.txt: GraphicsViewGettingStarted 的 CMake 项目,在此处包括源代码并定义 # 项目特定的逻辑。 # set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(QT NAMES Qt5 Qt6) message(STATUS "FOUND QT_VERSION_MAJOR IS ${QT_VERSION_MAJOR}") find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Widgets REQUIRED) # 定义源图片文件和目标目录 set(SOURCE_IMAGE "${CMAKE_CURRENT_LIST_DIR}/snipaste.png") # 设置执行文件的输出目录 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") # 复制图片文件 file(COPY ${SOURCE_IMAGE} DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) file(GLOB SAMPLE1_HEADER "${CMAKE_CURRENT_LIST_DIR}/*.h") file(GLOB SAMPLE1_SOURCE "${CMAKE_CURRENT_LIST_DIR}/*.cpp") # 将源代码添加到此项目的可执行文件。 add_executable (sample1 ${SAMPLE1_HEADER} ${SAMPLE1_SOURCE}) if (CMAKE_VERSION VERSION_GREATER 3.12) set_property(TARGET sample1 PROPERTY CXX_STANDARD 20) endif() # TODO: 如有需要,请添加测试并安装目标。 target_link_libraries(sample1 Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Widgets)
在这个项目中,我们实现了一个自定义的GraphView类,它能够:
- 显示图像并支持保持宽高比或拉伸填充
- 显示一个可拖动的十字标记(CrossMarkItem)
- 显示一个可调整大小的ROI矩形(ROIRectItem)
- 显示FPS和当前日期时间
- 保存和加载十字标记和ROI矩形的位置
这个例子展示了如何使用Qt的Graphics View Framework来创建复杂的自定义图形界面。通过使用QGraphicsScene和QGraphicsItem,我们可以轻松地在场景中添加和操作各种图形元素。
要运行这个项目,确保你有Qt环境,然后使用CMake生成项目文件,编译并运行。你应该能看到一个窗口,显示一张图片,上面有一个可拖动的十字标记和一个可调整大小的绿色矩形,以及FPS和日期时间显示。
这个项目为进一步开发图像处理或计算机视觉应用提供了一个良好的起点。你可以在此基础上添加更多功能,如图像滤镜、目标检测等。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】