Qt看图工具源码解析:QGraphicsView缩放与GIF播放技巧
发布时间:2026/9/13 20:51:57来源:尧图网络
简介这是一个基于 Qt 开发的多功能图片及动画查看工具支持 JPG、JPEG、PNG、BMP、GIF 等常用格式的打开与显示面向需要在桌面端快速浏览、缩放和演示图片的开发者或普通用户。工具具备贴近日常看图的操作方式鼠标滚轮以光标为中心缩放、拖动查看细节右键菜单可切换适应宽度、适应窗口、原图大小也能截图保存幻灯片播放支持自定义间隔时间GIF 动画会自动适应当前窗口大小。资源包共五十个文件以 C 源代码为主体包含十四个源文件与十四个头文件另有三个界面文件、十二个界面图标、工程文件与资源配置文件等压缩后约一点一六兆。代码实现上设计了自定义图形视图、图形场景、图元对象和图像处理线程能清晰展示 Qt 图形视图框架在图片浏览类应用中的典型用法适合学习 Qt 界面编程和图形图像处理的开发者研读。目前已有1090人学习下载工程结构完整可直接在 Qt 环境中打开运行也可继续扩展截图保存、定时播放、批量管理等交互功能。1. 一个能放gif又能当看图器的Qt小工具我拆完源码后理出的门道如果你经常在 Windows 上处理来自测试仪器的 jpg 和 png 截图又偶尔需要确认 bmp 格式的扫描件肯定会注意到系统自带查看器对 gif 非常敷衍只渲染第一帧想要播放得双击到浏览器里。这个项目做的就是把这几种格式统一到一个 Qt 界面里既能像专业看图器那样以鼠标为中心滚动缩放、拖动画布又能在 gif 播放时自动适配窗口尺寸还带幻灯片和截图保存。它基于 Qt 5.15 的 Graphics View 框架核心代码在 mainwindow.cpp 和 mygraphicview.cpp 这组文件里适合想学 QGraphicsView 缩放机制、QMovie 动画播放以及 Qt 多线程图片加载的开发者拆着看。2. 为什么缩放和拖动要用 QGraphicsView场景、视图、图元的分工一张图片在 Qt 里有多种显示方式最简单的 QLabel 只能整体缩放无法局部观察而 QGraphicsView 引入了 view、scene、item 三层结构。view 负责滚动条和变换scene 维护图元坐标item 描述图像本身。在 EditPic 这类看图工具里图片本身是一个 QGraphicsPixmapItem或者像源码里那样用 ImageWidget 包装成自定义 QGraphicsWidget。使用这一套而不是 QLabel最大的收益是缩放和拖动都由视图层统一处理不需要每次手动计算图片的几何变换。2.1 以鼠标为锚点的滚轮缩放setTransformationAnchor 与 wheelEvent 的参数选择用 QGraphicsView 做缩放第一个要解决的问题是锚点。默认调用 scale() 时视图以中心为固定点缩放后鼠标指向的内容会跑出视口。正确的做法是设置setTransformationAnchor(QGraphicsView::AnchorUnderMouse)让视图在 scale() 时自动保持鼠标下的场景点位置不变。如果你想要完全控制缩放逻辑也可以手动 mapToScene 再 centerOn但常见项目里直接设置锚点更简单稳定。// mygraphicview.cpp 构造函数 MyGraphicView::MyGraphicView(QWidget *parent) : QGraphicsView(parent) { // 缩放时以鼠标位置为锚点坐标不漂移 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // 调整窗口大小后参考视图中心保持缩放位置 setResizeAnchor(QGraphicsView::AnchorViewCenter); setDragMode(QGraphicsView::NoDrag); // 拖动由鼠标事件单独接管 } // 重写滚轮事件 void MyGraphicView::wheelEvent(QWheelEvent *event) { // 滚轮上滚放大下滚缩小保持缩放中心不跳动 qreal factor 1.25; if (event-angleDelta().y() 0) { scale(factor, factor); } else { scale(1.0 / factor, 1.0 / factor); } event-accept(); }AnchorUnderMouse在 Qt 5.15 中是一个稳定的枚举值它改变的是 QGraphicsView 内部的 transformation anchor而不是 scene 内容。angleDelta().y()返回垂直滚动量单位是 1/8 度不同鼠标滚轮的步进值可能有差异所以不要直接用这个值作为缩放倍数。factor 一般取 1.1 到 1.25太大会让图片在快速滚动时跳动太小滚很多格才有效果。这里没有调用centerOn因为锚点已经保证了鼠标下的点不会漂移。2.2 ScrollHandDrag 与自定义鼠标事件拖动图片的取舍用QGraphicsView::ScrollHandDrag是最快的拖动方案setDragMode(QGraphicsView::ScrollHandDrag)之后按住左键拖动会移动滚动条。但它在触摸屏上表现不稳定而且会在拖动过程中改变光标有时还会误触发 item 的鼠标事件。在 EditPic 这种纯看图工具里我们需要“任意位置按住左键拖动画布”但也要保留右键菜单独立触发的空间所以自定义 mousePressEvent 更顺手。void MyGraphicView::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_lastDragPos event-pos(); // 记录按下点的视图坐标 setCursor(Qt::ClosedHandCursor); event-accept(); return; } QGraphicsView::mousePressEvent(event); } void MyGraphicView::mouseMoveEvent(QMouseEvent *event) { if (event-buttons() Qt::LeftButton) { // 根据鼠标位移量移动滚动条实现画布跟随拖动 QPoint delta event-pos() - m_lastDragPos; horizontalScrollBar()-setValue(horizontalScrollBar()-value() - delta.x()); verticalScrollBar()-setValue(verticalScrollBar()-value() - delta.y()); m_lastDragPos event-pos(); event-accept(); return; } QGraphicsView::mouseMoveEvent(event); } void MyGraphicView::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { unsetCursor(); event-accept(); return; } QGraphicsView::mouseReleaseEvent(event); }这里没有调用translate()或修改 scene 坐标而是直接改滚动条值。原因很简单缩放和旋转都作用于视图变换滚动条维护 view 的可视区域两者天然解耦。delta.x() 取反是因为内容应该跟随鼠标移动鼠标向右拖滚动条往左移动否则图片会反向滑动。如果图片比视口小水平滚动条的 value 范围是 0setValue不会产生效果所以不会出现拖过头的问题。实现方式优点缺点适用场景ScrollHandDrag代码量少内置光标反馈与右键菜单事件可能冲突触摸屏不友好纯展示窗口不处理复杂命中自定义鼠标事件可精确控制拖动阈值可加加速度需要处理滚动条边界看图工具、地图引擎2.3 fitInView 与 scale 的本质区别适应窗口的底层逻辑看图软件里最常见的三个菜单项适应窗口、适应宽度、原图大小。在 QGraphicsView 中它们的调用方式完全不同。fitInView 是按场景内容的 boundingRect 计算缩放比率把整个内容塞进视口会重置平移量而 scale 是在当前变换基础上乘以一个比例不会清除已经设置的平移。很多新手把 fitInView 当成 scale 的替代品结果在 GPS 定位或图像标注时缩放坐标对不上。// 适应窗口图片完整显示在窗口中 void MyGraphicView::fitToWindow() { if (!scene() || scene()-items().isEmpty()) return; QRectF r scene()-itemsBoundingRect(); // 获取图元总范围 fitInView(r, Qt::KeepAspectRatio); // 保持宽高比图片不变形 } // 适应宽度只约束宽度高度不限制适合长截图 void MyGraphicView::fitToWidth() { if (!scene() || scene()-items().isEmpty()) return; QRectF r scene()-itemsBoundingRect(); qreal w viewport()-width(); qreal sx w / r.width(); // 宽度缩放系数 resetTransform(); // 先清掉之前的缩放 scale(sx, sx); centerOn(r.center()); } // 原图大小 void MyGraphicView::resetZoom() { resetTransform(); // 清除所有缩放/旋转 // 图片位置归零 if (!scene()-items().isEmpty()) { scene()-items().first()-setPos(0, 0); } }需要注意执行“适应宽度”之前必须调用resetTransform()。如果之前做过一次fitInView视图内部保存了一个基础变换此时再scale(sx, sx)会在那个基础上叠加得到完全错误的缩放比例。Qt::KeepAspectRatio是 QGraphicsView 的枚举它保证缩放后的图元不发生横向或纵向拉伸但如果图片本身的长宽比和视口不一致页面两侧会出现留白。对于需要整图铺满的场景可以改用Qt::IgnoreAspectRatio代价是截图保存的像素比例会被改变。动作实际调用参考点典型应用滚轮缩放scale(factor, factor)鼠标位置局部观察细节适应窗口fitInView(rect, KeepAspectRatio)视图中心查看整图适应宽度resetTransform scale(sx, sx)左边缘与中心宽幅长截图原图大小resetTransform setPos(0,0)坐标原点检查实际像素点3. 让 gif 动起来QMovie 帧更新与后台线程加载的配合打开静态图很容易但 gif 是动态的QGraphicsView 里没有专门的动画图元。EditPic 的做法是用 QMovie 加载动画然后每帧更新一个 QGraphicsPixmapItem 的 pixmap。这里最大的坑是如果直接把 QMovie 绑定到主窗口的 QLabel窗口拖动、缩放时动画会卡顿尤其是 GIF 还带透明通道时。所以要么把解码工作放到后台线程要么至少让 QMovie 的缓存模式匹配当前界面的缩放需求。3.1 用 QMovie 在 QGraphicsItem 上播放动图避免每帧 new QPixmap加载 gif 不能像静态图一样用 QPixmap::load因为 QPixmap 只保留第一帧。常见做法是创建 QMovie监听 frameChanged 信号在槽函数中取 currentPixmap() 更新图元。如果每次帧更新都 new 一个 QPixmap旧的图元 pixmap 会被替换内存抖动很明显。更好的做法是让图元内部持有一个 QMovie 指针复用同一个 QPixmap 缓冲区。void ImageWidget::startGif(const QString filePath) { // 如果已有 movie先停止并释放避免连接残留 if (m_movie) { m_movie-stop(); m_movie-deleteLater(); } m_movie new QMovie(filePath, QByteArray(), this); // CacheAll 缓存全部帧适合尺寸小的 gif避免重复解码 m_movie-setCacheMode(QMovie::CacheAll); // 先用原始尺寸播放待窗口 resize 后再做整体缩放 connect(m_movie, QMovie::frameChanged, this, ImageWidget::updateGifFrame); m_movie-start(); } void ImageWidget::updateGifFrame(int frame) { Q_UNUSED(frame); QPixmap pix m_movie-currentPixmap(); if (pix.isNull()) return; QSize targetSize pix.size(); if (!m_targetSize.isEmpty()) { targetSize m_targetSize; // 由 resize 事件计算出的缩放后尺寸 } setPixmap(pix.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation)); }setCacheMode(QMovie::CacheAll)适合几十帧的小尺寸 gif比如 200x200 的表情包如果 gif 是相机录制的 720p 动图CacheAll 会占用几百 MB 内存这种情况应该使用 CacheNone并接受逐帧解码的延迟。m_targetSize是窗口大小改变时计算出来的这样 updateGifFrame 每次都做相同的 scaled 操作不会因为缩放而反复调整。如果代码中直接使用movie-setScaledSize(viewportSize)会把 gif 的原始帧大小整个改掉切换回原图大小时需要重新加载文件反而麻烦。缓存模式内存占用解码速度适用场景CacheNone低慢超大 gif帧间隔时间短CacheAll高快尺寸小、帧数少的 gif自定义帧缓存中中需要循环跳帧预览的长图3.2 把图片文件读取拆到 processor threadQImageReader 预处理打开几十兆的 jpg 或高分辨率 png 时如果直接在打开文件槽函数里QPixmap::load窗口会卡死几百毫秒。EditPic 源码里单独有一个 processorthread.cpp说明项目把读取和解析放到了后台。Qt 5.15 中QImageReader 可以在 QThread 的 run() 方法里创建和销毁只要不跨线程调用同一个 reader 实例即可。推荐使用 QThread 子类配合信号槽传递 QImage避免 lambda 捕获这一帧的临时对象。// processorthread.h class ProcessorThread : public QThread { Q_OBJECT public: explicit ProcessorThread(QObject *parent nullptr); void setFilePath(const QString filePath); signals: void imageReady(const QImage image, const QString fileName); void loadFailed(const QString reason); protected: void run() override; private: QString m_filePath; }; // processorthread.cpp void ProcessorThread::run() { QImageReader reader(m_filePath); // 自动应用照片的 EXIF 旋转信息避免手机拍的 jpg 横过来 reader.setAutoTransform(true); // 超过 4096px 的大图直接缩一半并标记为预览模式 QSize originalSize reader.size(); if (originalSize.width() 4096 || originalSize.height() 4096) { reader.setScaledSize(originalSize / 2); } QImage image reader.read(); if (image.isNull()) { emit loadFailed(reader.errorString()); return; } emit imageReady(image, m_filePath); }setAutoTransform(true)会读取 jpg 的 EXIF orientation 字段在解码时自动旋转像素这样不会出现“拍完照片竖着变横着”的问题。reader.size()不触发完整解码只读取文件头因此先用它做大图降采样非常合适。注意emit imageReady(image, m_filePath)中的 QImage 是隐式共享的信号跨线程传输时 Qt 会做一次拷贝而它的底层数据仍然是只读的所以不会产生数据竞争。调用线程的写法通常是thread-setFilePath(path); thread-start();并在主线程连接imageReady到显示槽函数。3.3 gif 自适应窗口等比缩放不拉伸gif 播放时如果窗口被拖大需要重新计算显示尺寸。高 DPI 屏幕下视口的 width() 返回的是物理像素所以正确做法是除以devicePixelRatioF()得到逻辑尺寸再基于逻辑尺寸计算缩放否则在 2K 屏上 gif 会显示得比实际小一半。void MainWindow::resizeGifToFitView() { if (!currentMovie() || !m_gifItem) return; // 逻辑视口尺寸兼容 2x 高分屏 QSizeF viewportSize ui-graphicsView-viewport()-size(); viewportSize.setWidth(viewportSize.width() / devicePixelRatioF()); viewportSize.setHeight(viewportSize.height() / devicePixelRatioF()); QSize movieSize currentMovie()-currentPixmap().size(); QSize scaledSize movieSize; if (movieSize.width() viewportSize.width() || movieSize.height() viewportSize.height()) { // 以短边为基准等比缩小不改变纵横比 scaledSize movieSize.scaled(viewportSize.toSize(), Qt::KeepAspectRatio); } m_targetSize scaledSize; // 跳到当前帧触发 frameChanged 重新走 updateGifFrame currentMovie()-jumpToFrame(currentMovie()-currentFrameNumber()); }QSize::scaled返回一个同时满足宽高约束的新尺寸但Qt::KeepAspectRatio默认是判断原始比例。如果先把 viewportSize 转成 toSize 再计算应对非整数 DPR 时会有四舍五入但视觉上可以接受。jumpToFrame在这里是主动触发一次 frameChanged 信号否则用户调整窗口大小时只有下一帧动画到来才会刷新画面看起来像是卡住了。注意devicePixelRatioF()在 Qt 6 已废弃Qt 5.15 仍然可用。4. 右键菜单、幻灯片与配置保存看图工具的工程化扩展一个看图工具除了能打开图片还要解决“用起来顺不顺手”的问题。右键菜单、幻灯片播放、参数配置保存这些功能在项目里看似零散实际上都围绕着同一个主线把视图操作和业务动作解耦。EditPic 里 mainwindow.ui 定义了 popMenu但真正的事件处理应该放在主窗口让 QGraphicsView 只负责响应鼠标并发出请求。4.1 contextMenuEvent 里组织动作适应宽度、原图、截图保存在 QGraphicsView 子类中重写contextMenuEvent弹出一个 QMenu。菜单项不要直接在 view 里实现而是通过信号把请求抛给主窗口。这样如果以后要支持快捷键或者触摸板右键只需要把信号映射到同样的槽函数。void MyGraphicView::contextMenuEvent(QContextMenuEvent *event) { QMenu menu(this); // 每个动作对应一个 QAction返回后通过信号发出 QAction *fitToWindowAct menu.addAction(适应窗口); QAction *fitToWidthAct menu.addAction(适应宽度); QAction *originalAct menu.addAction(原图大小); menu.addSeparator(); QAction *screenshotAct menu.addAction(截图保存); QAction *slideAct menu.addAction(幻灯片播放); menu.addSeparator(); QAction *settingsAct menu.addAction(播放间隔设置); QAction *selected menu.exec(event-globalPos()); if (selected fitToWindowAct) { emit requestFitToWindow(); } else if (selected fitToWidthAct) { emit requestFitToWidth(); } else if (selected originalAct) { emit requestOriginalSize(); } else if (selected screenshotAct) { emit requestScreenshot(); } else if (selected slideAct) { emit requestSlideShow(); } else if (selected settingsAct) { emit requestSettingsDialog(); } }menu.exec(event-globalPos())会阻塞事件循环直到用户选择或关闭所以不用担心菜单弹出期间又触发鼠标事件。信号命名采用“request”前缀是为了让阅读代码的人明确这只是请求不等于动作已经完成。右键菜单里每一项都可以在 mainwindow 里做成独立的 QAction配合 QKeySequence 添加快捷键。这里需要注意如果在菜单里调用了fitInView但是 scene 为 null程序会崩溃所以槽函数里要加空指针判断。4.2 截图保存grab() 和 QGraphicsScene::render() 的分辨率差异很多开发者用view-grab()截取当前视口但这种方法只能拿到视口大小而且会把滚动条空白区域也算进去。如果图片正在缩放截出来的图根本不是用户看到的全画面。更稳的方式是从 scene 直接渲染所有图元输出原始像素分辨率。bool MainWindow::saveScreenshot(const QString fileName) { QGraphicsScene *scene m_view-scene(); if (!scene) return false; // 取图元边界排除空白背景 QRectF sceneRect scene-itemsBoundingRect(); QImage image(sceneRect.size().toSize(), QImage::Format_ARGB32); image.fill(Qt::transparent); QPainter painter(image); painter.setRenderHint(QPainter::Antialiasing, true); // 将 sceneRect 渲染到 image 的整块区域 scene-render(painter, image.rect(), sceneRect); painter.end(); return image.save(fileName); // 后缀决定格式 }scene-render的第二个参数是目标矩形第三个是源矩形。这里目标是image.rect()源是整个图元包围盒所以不会裁剪到空白区域。QImage::Format_ARGB32保留 alpha 通道但如果背景要做成白色需要先image.fill(Qt::white)否则保存为 jpg 时透明部分会变成黑色。如果希望截图跟视口观感一致可以在 render 之前对 painter 做scale(2, 2)输出两倍分辨率的图片这也是批量出高清图常用的技巧。4.3 幻灯片播放QTimer 与目录遍历的顺序切换幻灯片播放的本质是定时器触发下一张。QTimer 的 interval 可以通过设置对话框修改建议使用QSettings保存而不是硬编码。需要考虑的问题有两个一是播放 gif 时定时器触发后会切换到下一张当前 gif 的 QMovie 对象必须在 openFile 内被正确释放二是文件列表只取当前目录下的图片文件排序使用 QDir::Name 可以让 jpg、png、gif 按文件名稳定排列。void MainWindow::startSlideShow() { if (!m_timer) { m_timer new QTimer(this); connect(m_timer, QTimer::timeout, this, MainWindow::showNextImage); } // 从 editpic.ini 读取间隔默认 3000ms 3 秒 int interval m_settings.value(slideshow/interval, 3000).toInt(); m_timer-start(interval); m_slideIndex 0; // 注意大小写敏感需要同时列出大写后缀 m_slideImageList QDir(m_currentDir) .entryList({*.jpg, *.jpeg, *.png, *.bmp, *.gif, *.JPG, *.PNG, *.BMP, *.GIF}, QDir::Files | QDir::Readable, QDir::Name); } void MainWindow::showNextImage() { if (m_slideImageList.isEmpty()) return; // 循环播放到达末尾后再回到 0 m_slideIndex (m_slideIndex 1) % m_slideImageList.size(); openFile(m_currentDir / m_slideImageList.at(m_slideIndex)); }QDir::entryList的通配符匹配在 Qt 5.15 中默认区分大小写所以上面把.JPG和.PNG都列了一遍。如果你处理的文件后缀统一可以只写小写但很多相机生成的 jpg 后缀是大写。m_settings对象在类初始化时创建指向editpic.ini的绝对路径。停止幻灯片时调用m_timer-stop()但不要delete m_timer因为下次启动还要用。4.4 用 QSettings 读写 editpic.ini 的常用键配置管理在 configfilemanager.cpp 中组织QSettings 用 IniFormat 读写。键名分成组和键值两部分比如view/background表示视图组中的背景色。下面是常见的键表键类型默认值说明view/backgroundQColor#333333视口背景颜色view/smoothScalebooltrue缩放是否启用平滑变换slideshow/intervalint3000幻灯片切换间隔毫秒数window/geometryQByteArray空主窗口位置和大小file/lastOpenDirQString上次打开文件所在目录// 写入配置 QSettings settings(QApplication::applicationDirPath() /editpic.ini, QSettings::IniFormat); settings.setValue(view/background, QColor(50, 50, 50)); settings.setValue(slideshow/interval, ui-spinInterval-value()); settings.sync(); // 立即落盘防止崩溃时丢配置 // 读取配置第二个参数是默认值 int interval settings.value(slideshow/interval, 3000).toInt(); QColor bg settings.value(view/background, QColor(30, 30, 30)).valueQColor();QSettings的 IniFormat 会把键值中的路径自动转义所以 Windows 下路径含冒号和反斜杠没有问题。但要注意使用applicationDirPath()在开发环境下没问题发布后如果程序装在 Program Files 下可能没有写入权限。此时应该用QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)拼接 ini 路径。另外QSettings 的构造和析构比较重不要在每次读配置时临时创建最好像 configfilemanager.cpp 那样把 settings 对象作为成员复用。5. 从 EditPic 里可直接复用的三段代码线程加载、全尺寸截图、ini 键值约定第一段可复用的是线程加载模板。在ProcessorThread的 run 方法里不需要关心主线程是否在拖拽窗口只管读文件。一个关键的细节是线程对象不能在 run 返回后立即 delete否则信号还没发出去。正确做法是在主线程中thread-wait()后thread-deleteLater()或者直接把 thread 的 finished 连接到它的 deleteLater。如果项目用 Qt 6不推荐继承 QThread而是用QtConcurrent::run或者QRunnable但 EditPic 这里线程只做一次图片解析用 QThread 子类足够清晰。第二段可复用代码是全尺寸截图。除了scene-render之外还有一个更实用的技巧在保存前根据导出尺寸设置QImage::setDevicePixelRatio。比如需要以 2 倍像素导出先创建宽度为原始宽两倍的 QImage再设置 DPR 为 2.0这样 QPainter 在绘制时自动认为画布是 2 倍缩放导出图片在放大镜查看时不会发虚。配合上文中image.save(fileName)可以在不改变 painter 绘制逻辑的情况下输出高清截图。第三段是 ini 的键值约定。建议所有配置读取统一走ConfigFileManager并提供类型化方法int ConfigFileManager::slideInterval() const { return m_settings.value(slideshow/interval, 3000).toInt(); } void ConfigFileManager::setSlideInterval(int ms) { m_settings.setValue(slideshow/interval, ms); m_settings.sync(); }调用方不再关心配置项是否存在默认值集中在实现文件里。这样后续修改默认间隔为 5000ms 时只需要改一处。如果最初设计时就把这些封装好后面加“适应打印尺寸”或者“导出放大两倍”之类的功能会轻松很多也是这个项目里最值得带走的部分。本文还有配套的精品资源点击获取
网站建设高端定制企业官网