给 Qt 软件启动动画加上一段开场,同时兼容图片和视频两种方式,是我这两年在好几个桌面端项目里反复做过的活。起因基本都一样:客户或者产品经理看了默认那个灰底白字的 QSplashScreen,觉得太素,希望软件一打开就像正经商业软件那样,有一段几秒的开场画面。听起来是个小需求,但真正动手就会发现里面有几个绕不开的坎——视频窗口为什么黑屏、动图为什么卡在最后一帧、主窗口为什么先闪一下白底再出现、装到别人机器上为什么解码失败。这些问题查文档基本查不到,只能自己踩。
这篇文章就把我踩过的坑一次性讲清楚。内容覆盖技术选型逻辑、图片与 GIF 的几种实现路径、QMediaPlayer 配 QGraphicsVideoItem 播视频的完整代码、主窗口接管时机的时序控制,以及启动耗时和内存占用的实测对比。代码以 Qt 5.15.2 为基准,同时标注 Qt 6 的差异点,因为现在不少项目已经切到 Qt 6,而 QtMultimedia 在 6.x 上做过一次大重构,后端和信号名都变了,照着 5.x 的教程抄会直接编译不过。适合手上正要做启动动画、或者做出来效果不对想排查的同学。
1. 启动动画的定位与技术选型思路
1.1 启动动画到底该不该做,它解决什么问题
先说个容易被忽略的事实:启动动画本身不解决任何功能问题,它解决的是感知问题。一个 Qt 客户端冷启动,如果要把配置文件读一遍、数据库连上、插件扫描完、缓存预热好,很容易就是 800 毫秒到 3 秒。这段时间里如果屏幕上什么都没有,用户第一反应是"卡死了",第二反应是再点一次图标,然后开出两个进程。
所以启动动画的核心价值有三个:第一,用一个明确的视觉信号告诉用户"程序在动,别急";第二,遮住主窗口构建时那段不体面的空白和闪烁;第三,顺便承载品牌信息,比如 logo、版本号、公司名。理解了这三点,后面很多设计决策就顺了——比如为什么启动动画必须比初始化流程先出现、为什么它必须无条件在规定时间内退场。
反过来说,如果你的程序冷启动只要 200 毫秒,那加启动动画就是负优化,用户还没看清画面主界面就蹦出来了,反而显得闪一下。这类程序老老实实优化启动速度更实在。我的判断标准是:从点击图标到主窗口可交互超过 600 毫秒,才值得上启动动画。
1.2 图片方案与视频方案的能力边界对比
图片和视频这两条路,不是"哪个高级用哪个",而是能力边界完全不同,选错了会把自己坑死。下面这张表是我在实际项目里总结出来的对照,数字都是量级参考,不是精确值:
| 维度 | 静态图片 (PNG) | 动态图 (GIF) | 视频 (H.264 MP4) |
|---|---|---|---|
| 依赖模块 | QtGui | QtGui | QtMultimedia + MultimediaWidgets |
| 资源体积 | 50 KB ~ 500 KB | 1 MB ~ 8 MB | 1 MB ~ 5 MB |
| 首帧显示延迟 | < 20 ms | < 50 ms | 300 ms ~ 900 ms |
| 单核 CPU 占用 | 接近 0 | 5% ~ 15% | 硬解 < 5%,软解 15% ~ 30% |
| 透明背景 | 支持 alpha | 支持 alpha | 基本不支持 |
| 能否带声音 | 否 | 否 | 可以,但启动画面建议静音 |
| 部署复杂度 | 极低 | 极低 | 中,依赖系统解码器 |
| 观感上限 | 一般 | 一般 | 高,可做完整片头 |
看这张表能得出很直接的结论:如果只是想让启动画面别那么寒酸,PNG 加一行进度文字就够了;如果想做出轻微的呼吸感、旋转 loading,GIF 是最划算的选择,代价是体积膨胀;如果产品真的要求"开场三秒短片,有镜头运动有转场",那只有视频这条路,但要接受启动延迟增加和部署环境不确定性。
我个人的默认策略是:PNG 打底,GIF 进阶,视频只在明确要求下才上。原因很现实——视频方案在客户机器上翻车的概率,比另外两种加起来还高,而且排查成本极大,你没法远程看他的解码器状态。
1.3 工程目录与模块依赖规划
开工前先把目录和依赖定好,后面改起来省事。我习惯的目录结构是这样:
myapp/ ├── src/ │ ├── main.cpp │ ├── mainwindow.cpp │ ├── splash/ │ │ ├── splashmanager.h │ │ ├── splashmanager.cpp │ │ ├── imagesplash.h │ │ ├── gifsplash.h │ │ └── videosplash.h ├── resources/ │ ├── app.qrc │ ├── splash.png │ ├── loading.gif │ └── intro.mp4 └── myapp.pro把 splash 相关的类全部收在一个子目录里,好处是将来要换方案,直接替换这个目录,不用翻遍整个工程。对应的 qmake 配置:
QT += core gui widgets QT += multimedia multimediawidgets CONFIG += c++11如果用的是 CMake,对应写法是:
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Multimedia MultimediaWidgets) target_link_libraries(myapp PRIVATE Qt6::Widgets Qt6::Multimedia Qt6::MultimediaWidgets)注意:multimediawidgets 这个模块很容易被漏掉。QVideoWidget 和 QGraphicsVideoItem 都属于它,而不是 multimedia。只加 multimedia 的话,编译时报的就是 unknown module 或者找不到 QVideoWidget 头文件。
还有一个坑:Qt 5.15 起,部分离线安装包默认不勾选 QtMultimedia 组件,装完发现QT += multimedia报 unknown module。这时候要重新跑一遍维护工具(MaintenanceTool),在组件列表里把 Multimedia 勾上。别硬扛,这个模块没法手动补。
2. 图片方式:从静态 PNG 到动态 GIF 的完整实现
2.1 用 QSplashScreen 三行代码拉起静态启动图
Qt 自带的 QSplashScreen 是被严重低估的一个类。很多人以为它只能显示一张图,其实它内置了消息文字、进度绘制、点击穿透、置顶显示这几件事,而且它天生就是"启动画面"这个语义,finish()方法会自动等主窗口显示后再关闭自己,时序处理得很干净。
最简版本长这样:
#include <QApplication> #include <QSplashScreen> #include <QPixmap> #include <QThread> int main(int argc, char *argv[]) { QApplication app(argc, argv); QPixmap pixmap(":/resources/splash.png"); QSplashScreen splash(pixmap, Qt::WindowStaysOnTopHint); splash.setWindowFlag(Qt::FramelessWindowHint); splash.show(); // 手动吐一次事件循环,让窗口真正绘制出来 app.processEvents(); // 模拟一段耗时初始化 QThread::msleep(800); MainWindow w; w.show(); splash.finish(&w); // 主窗口显示后再关掉启动画面 return app.exec(); }这里有个细节必须强调:splash.show()之后如果不调app.processEvents(),紧接着就跑耗时初始化,那么启动画面根本没机会绘制,等初始化完才一起出现,等于白做。processEvents()的作用是把当前积压的绘制事件先处理掉,让窗口真正上屏。
另一个细节是splash.finish(&w)的位置。它的内部实现在主窗口显示之前会一直等,所以顺序一定是先w.show()再splash.finish(&w)。写反了会出现启动画面先消失、主窗口后出现的空档,视觉上就是闪一下黑。
2.2 QSplashScreen 的进阶玩法:进度文字与自绘进度条
QSplashScreen 支持showMessage()显示一行文字,但它默认的字体和颜色很不讲究,白底图上直接就是白字看不清。要好看一点,得重写drawContents()自己画。下面这个类我用了好几个项目,直接拿走能用:
#include <QSplashScreen> #include <QPainter> class ProgressSplash : public QSplashScreen { public: explicit ProgressSplash(const QPixmap &pixmap) : QSplashScreen(pixmap, Qt::WindowStaysOnTopHint) { setWindowFlag(Qt::FramelessWindowHint); } void setProgress(int value) { m_progress = qBound(0, value, 100); repaint(); // 关键:repaint 而不是 update,立刻重绘 } void setTip(const QString &text) { m_tip = text; repaint(); } protected: void drawContents(QPainter *painter) override { QSplashScreen::drawContents(painter); painter->setRenderHint(QPainter::Antialiasing, true); // 底槽 QRect barRect(40, height() - 72, width() - 80, 10); painter->setPen(Qt::NoPen); painter->setBrush(QColor(255, 255, 255, 55)); painter->drawRoundedRect(barRect, 5, 5); // 进度条本体 if (m_progress > 0) { QRect fillRect = barRect; fillRect.setWidth(barRect.width() * m_progress / 100); painter->setBrush(QColor(0, 160, 233)); painter->drawRoundedRect(fillRect, 5, 5); } // 提示文字 if (!m_tip.isEmpty()) { painter->setPen(QColor(240, 240, 240)); QFont f = painter->font(); f.setPointSize(9); painter->setFont(f); painter->drawText(QRect(40, height() - 56, width() - 80, 24), Qt::AlignLeft | Qt::AlignVCenter, m_tip); } } private: int m_progress = 0; QString m_tip; };drawContents()是 QSplashScreen 预留的虚函数,参数里的 painter 已经平移到了图片的坐标系,直接画就行。
关键点:更新进度一定要用
repaint()而不是update()。update()只是投递一个重绘请求,要等事件循环跑起来才执行。而启动阶段主线程往往被初始化代码占着,事件循环根本没在转,用update()会出现"进度条一动不动,初始化完直接跳满"的尴尬效果。
2.3 GIF 动图方案与自绘 SplashWidget
QSplashScreen 本身只认 QPixmap,播不了 GIF。要播动图就得自己写一个 Widget,用 QMovie 驱动。这类需求我做过好几次,下面这个 GifSplash 是比较稳的版本:
#include <QWidget> #include <QMovie> #include <QPainter> #include <QScreen> #include <QGuiApplication> class GifSplash : public QWidget { Q_OBJECT public: explicit GifSplash(const QString &gifPath, const QSize &size, QWidget *parent = nullptr) : QWidget(parent, Qt::SplashScreen | Qt::FramelessWindowHint) { // 透明背景,让 GIF 的 alpha 通道生效 setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_DeleteOnClose); // 屏蔽鼠标事件,防止用户点击干扰 setAttribute(Qt::WA_TransparentForMouseEvents); m_movie = new QMovie(gifPath, QByteArray(), this); m_movie->setCacheMode(QMovie::CacheAll); m_movie->setScaledSize(size); connect(m_movie, &QMovie::frameChanged, this, [this](int) { update(); }); resize(size); centerOnPrimaryScreen(); } void startPlay() { m_movie->start(); } void centerOnPrimaryScreen() { if (QScreen *screen = QGuiApplication::primaryScreen()) { const QRect geo = screen->availableGeometry(); move(geo.center() - rect().center()); } } protected: void paintEvent(QPaintEvent *) override { QPainter p(this); p.setRenderHint(QPainter::SmoothPixmapTransform, true); p.drawPixmap(rect(), m_movie->currentPixmap()); } private: QMovie *m_movie = nullptr; };这里有三个设计点值得说明。WA_TranslucentBackground是为了让 GIF 的透明区域真的透明,配合无边框窗口使用;WA_TransparentForMouseEvents是为了让启动画面期间用户的点击穿透到桌面,避免误触发;setCacheMode(QMovie::CacheAll)是把解码后的帧全部缓存到内存里,代价是内存占用上升,收益是播放期间不再有解码抖动。
QMovie 默认loopCount是 -1,也就是无限循环。启动动画一般不需要循环,播完就退场,所以要么setLoopCount(1),要么干脆不管它——因为退场时机由主窗口的初始化进度决定,跟播放次数无关。
动图素材这块我踩过一个坑:网上下的 GIF 经常是 24 位色加索引调色板,在深色主题的启动背景上边缘会有明显的锯齿和白边。解决办法是把 GIF 背景做成和启动画面同色,而不是透明,或者干脆用 APNG(Qt 的 QMovie 也支持 APNG,只是文件大不少)。
2.4 资源打包与 qrc 的那些细节
不管图片还是视频,都建议走 Qt 资源系统(qrc),而不是相对路径读文件。原因有三个:第一,发布时不用管资源目录结构,全打进可执行文件或者外部 rcc;第二,不存在工作目录被改导致找不到文件的问题;第三,访问速度比磁盘 IO 快。
qrc 文件写法:
<RCC> <qresource prefix="/resources"> <file alias="splash.png">splash.png</file> <file alias="loading.gif">loading.gif</file> <file alias="intro.mp4">intro.mp4</file> </qresource> </RCC>然后在 pro 里加RESOURCES += app.qrc。
这里有个必须提醒的事:视频文件打进 qrc 之后,QMediaPlayer 不一定能直接播。原因是 QMediaPlayer 的底层后端(Windows 上是 Media Foundation,Linux 上是 GStreamer)走的是本地文件路径或流,对qrc:/这种虚拟路径支持不一致。实测在 Windows + Qt 5.15 上,QMediaPlayer是读不了 qrc 里的 mp4的,会静默失败,mediaStatus停在NoMedia。
所以视频素材要么放到可执行文件旁边的 resources 目录,用QCoreApplication::applicationDirPath()拼绝对路径;要么在程序启动时把 qrc 里的视频先拷贝到临时目录再播。我一般选前者,简单直接:
const QString videoPath = QCoreApplication::applicationDirPath() + "/resources/intro.mp4";注意:
QDir::currentPath()和applicationDirPath()不是一回事。前者是进程的工作目录,双击图标启动时可能是桌面或者别的什么位置;后者才是 exe 所在目录。启动动画读资源一定要用后者。
3. 视频方式:QMediaPlayer 播放启动短片
3.1 模块依赖与后端环境准备
视频这条路的第一道门槛不是代码,是环境。QMediaPlayer 是个壳,真正解码的是底层后端,不同平台差异巨大,必须先搞清楚。
Windows 上,Qt 5.15 默认使用WMF(Windows Media Foundation)后端,也就是系统自带的解码框架。好处是 H.264 MP4 开箱即播,不用带任何额外 DLL;坏处是只支持系统装的解码器,某些冷门编码(比如 H.265、VP9)在老系统上直接播不了。所以视频素材一律用 H.264 编码的 MP4,这是兼容性最好的选择。
Linux 上是 GStreamer 后端,需要确保系统装了gstreamer1.0-plugins-good、gstreamer1.0-plugins-bad、gstreamer1.0-libav这几个包。少一个都可能出现"有声音没画面"或者"完全黑屏"的现象。国内发行版如果没有预装,apt 一行搞定。
Qt 6 这边情况变了。Qt 6.5 起,QtMultimedia 引入了FFmpeg 后端,跨平台行为统一了很多,不再依赖系统解码器。这是个大改进,如果你的项目能上 Qt 6.5+,视频方案的部署问题基本消失了。但如果你在 Qt 6.0 到 6.4 之间,那还是平台后端,问题照旧。
| Qt 版本 | Windows 后端 | Linux 后端 | 备注 |
|---|---|---|---|
| Qt 5.15 | WMF | GStreamer | 依赖系统解码器 |
| Qt 6.0 ~ 6.4 | WMF | GStreamer | 依赖系统解码器 |
| Qt 6.5+ | FFmpeg | FFmpeg | 自带解码能力,推荐 |
3.2 QGraphicsVideoItem 构建无边框视频窗口
视频播放的显示载体有两条路:QVideoWidget 和 QGraphicsVideoItem。前者更简单,后者更灵活。做启动画面我强烈推荐 QGraphicsVideoItem,原因是QVideoWidget 是个原生窗口(native window),没法做透明、没法叠加普通 QWidget、图形效果也基本不生效。而 QGraphicsVideoItem 走的是 QGraphicsScene 体系,可以自由叠加图层、做缩放和裁剪,配合setAspectRatioMode还能控制填充模式。
下面是完整的 VideoSplash 实现:
#include <QWidget> #include <QGraphicsScene> #include <QGraphicsView> #include <QGraphicsVideoItem> #include <QMediaPlayer> #include <QScreen> #include <QGuiApplication> #include <QGraphicsOpacityEffect> #include <QPropertyAnimation> class VideoSplash : public QWidget { Q_OBJECT public: explicit VideoSplash(const QString &filePath, QWidget *parent = nullptr) : QWidget(parent, Qt::SplashScreen | Qt::FramelessWindowHint) { setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_ShowWithoutActivating); m_scene = new QGraphicsScene(this); m_item = new QGraphicsVideoItem; m_item->setAspectRatioMode(Qt::KeepAspectRatioByExpanding); m_scene->addItem(m_item); m_view = new QGraphicsView(m_scene, this); m_view->setFrameShape(QFrame::NoFrame); m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_view->setStyleSheet("background:transparent;border:0;"); m_player = new QMediaPlayer(this); m_player->setVideoOutput(m_item); m_player->setVolume(0); // 启动动画静音 #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) m_player->setMedia(QUrl::fromLocalFile(filePath)); connect(m_player, &QMediaPlayer::errorOccurred, this, [this](QMediaPlayer::Error, const QString &msg) { emit failed(msg); }); #else m_player->setMedia(QMediaContent(QUrl::fromLocalFile(filePath))); connect(m_player, QOverload<QMediaPlayer::Error>::of(&QMediaPlayer::error), this, [this](QMediaPlayer::Error) { emit failed(m_player->errorString()); }); #endif connect(m_player, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) { if (status == QMediaPlayer::EndOfMedia) emit finished(); }); } void start(const QSize &size) { resize(size); m_item->setSize(size); m_scene->setSceneRect(0, 0, size.width(), size.height()); m_view->resize(size); centerOnPrimaryScreen(); show(); m_player->play(); } void fadeOutAndClose(int durationMs = 300) { auto *effect = new QGraphicsOpacityEffect(this); setGraphicsEffect(effect); auto *anim = new QPropertyAnimation(effect, "opacity", this); anim->setDuration(durationMs); anim->setStartValue(1.0); anim->setEndValue(0.0); anim->setEasingCurve(QEasingCurve::OutCubic); connect(anim, &QPropertyAnimation::finished, this, &QWidget::close); anim->start(QAbstractAnimation::DeleteWhenStopped); } signals: void finished(); void failed(const QString &message); private: void centerOnPrimaryScreen() { if (QScreen *screen = QGuiApplication::primaryScreen()) { const QRect geo = screen->availableGeometry(); move(geo.center() - rect().center()); } } QGraphicsScene *m_scene = nullptr; QGraphicsView *m_view = nullptr; QGraphicsVideoItem *m_item = nullptr; QMediaPlayer *m_player = nullptr; };几个关键点解释一下。KeepAspectRatioByExpanding的意思是视频按比例缩放,直到铺满整个 item 区域,超出部分裁剪掉。启动动画一般希望画面铺满全屏或者铺满一个固定尺寸的窗口,周围不留黑边,所以用 Expanding 比 KeepAspectRatio 更合适。
WA_ShowWithoutActivating这个属性容易被忽略。它让启动窗口显示时不抢焦点。因为启动阶段主窗口还在构造,抢焦点会导致后续主窗口显示时焦点状态混乱,特别是带输入框的界面。
fadeOutAndClose里用了 QGraphicsOpacityEffect。这里要提醒一句:图形特效对视频渲染不一定生效。因为视频帧在某些后端上是直接送到窗口的渲染层,不走 QWidget 的绘制管线。实测在 Qt 5.15 + WMF 后端上,淡出动画对 QVideoWidget 完全无效,对 QGraphicsVideoItem 时好时坏。如果淡出效果是硬需求,稳妥的做法是用一个纯色的顶层 Widget 做遮罩淡出,而不是给视频本身加特效。
3.3 播放时序控制:从首帧到主窗口接管
视频方案最难的部分不是播放,是什么时候让主窗口接管。这里有个反直觉的点:视频播放本身占用主线程的事件循环,如果同时做重初始化,视频会卡帧,用户看到的就是一顿一顿的片头,比不做还难看。
我的处理方式是分片初始化:把初始化拆成一组小任务,用 QTimer 逐个执行,每个任务之间让事件循环喘口气。
#include <QApplication> #include <QTimer> #include <QVector> #include <functional> int main(int argc, char *argv[]) { #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif QApplication app(argc, argv); const QString videoPath = QCoreApplication::applicationDirPath() + "/resources/intro.mp4"; VideoSplash splash(videoPath); MainWindow window; bool handoverDone = false; auto handover = [&]() { if (handoverDone) return; handoverDone = true; window.show(); splash.fadeOutAndClose(); }; // 视频自然播完 QObject::connect(&splash, &VideoSplash::finished, handover); // 视频出错,兜底退场 QObject::connect(&splash, &VideoSplash::failed, [&](const QString &msg) { qWarning() << "splash video failed:" << msg; handover(); }); // 分片初始化任务 QVector<std::function<void()>> tasks = { [] { /* 读配置文件 */ }, [] { /* 打开数据库连接 */ }, [] { /* 扫描插件目录 */ }, [] { /* 预热缓存 */ }, }; auto *pump = new QTimer(&app); int index = 0; QObject::connect(pump, &QTimer::timeout, [&]() { if (index < tasks.size()) { tasks[index++](); } else { pump->stop(); handover(); // 任务跑完,正常交接 } }); // 硬性超时:4 秒必须退场,绝不让用户干等 QTimer::singleShot(4000, handover); splash.start(QSize(960, 540)); // 16:9,匹配视频比例 pump->start(30); // 30ms 一片,足够视频流畅播放 return app.exec(); }这段代码里有三个兜底,缺一不可。第一个兜底是视频播放失败——用户机器上解码器缺失、文件损坏都可能触发,这时候不能卡死,直接进主界面。第二个兜底是初始化任务跑完——如果任务比视频短,提前交接,不用等视频播完。第三个兜底是硬性超时——如果某个任务卡住了(比如数据库连不上在等超时),4 秒后强制交接,保证用户体验。
pump->start(30)这个 30 毫秒也很讲究。太短了(比如 0 或 1 毫秒)事件循环被初始化任务挤占,视频掉帧;太长了(比如 200 毫秒)初始化总时长被拉长,用户等得久。30 毫秒大约对应 33 帧的节奏,够用。
3.4 视频素材的制作与编码参数建议
素材这关我自己也折腾过很久,最后固定下来一套 ffmpeg 参数,基本没出过问题:
ffmpeg -i raw_intro.mp4 \ -c:v libx264 \ -profile:v main \ -level 4.0 \ -pix_fmt yuv420p \ -g 15 \ -bf 0 \ -crf 23 \ -an \ -movflags +faststart \ intro.mp4逐个参数说下为什么。-profile:v main加上-pix_fmt yuv420p是兼容性底线,几乎所有后端都能解;-g 15把关键帧间隔设成 15 帧,意味着拖到任意位置最多 15 帧就能出画,对首帧显示速度有帮助;-bf 0关掉 B 帧,解码器不用做双向预测,起播更快;-crf 23是画质和体积的平衡点;-an去掉音轨,启动画面不需要声音,还能省几百 KB;-movflags +faststart是最重要的一条,它把 moov atom(记录视频元数据的那块)从文件末尾挪到文件开头,播放器不用扫完整个文件就能开始解码。
另外几个经验值:视频时长控制在2 到 4 秒,再长用户就不耐烦了;分辨率不要超过窗口实际尺寸,你播 4K 素材但窗口只有 960x540,等于白白浪费解码算力;文件体积控制在3 MB 以内,超过这个数加载 IO 会明显拖慢起播。
提示:如果说手上只有一张静态图但需要做出视频感,可以用 ffmpeg 的 zoompan 滤镜做缓慢推镜,几行命令就能生成一段有镜头运动的短片,比真拍一段省事得多。
4. 两种方案硬碰硬:性能实测与坑位排查
4.1 启动耗时、内存与 CPU 占用实测
我在一台 i5-8250U、8GB 内存、Windows 10、SSD 的笔记本上做过一组对比测试,测试方法是把主窗口初始化固定在 1500 毫秒,记录从main()第一行到主窗口可交互的耗时,以及启动动画期间的峰值资源占用。
| 测试项 | 静态 PNG (1280x720) | GIF (640x360, 45帧) | MP4 (960x540, 3秒) |
|---|---|---|---|
| 首帧可见耗时 | 18 ms | 42 ms | 610 ms |
| 内存峰值增量 | 约 12 MB | 约 48 MB | 约 65 MB |
| 启动动画期间 CPU | < 2% | 8% ~ 14% | 硬解 3%,软解 22% |
| 主窗口接管总耗时 | 1520 ms | 1545 ms | 1680 ms |
| 冷启动二次波动 | 很小 | 小 | 较大(首次解码器初始化慢) |
几个数据背后的解释。静态图的 12 MB 是 1280x720 的位图解码结果,正好是1280 × 720 × 4 字节 ≈ 3.6 MB加上 Qt 内部的纹理拷贝。GIF 的 48 MB 是因为CacheAll把 45 帧全缓存了,如果换成CacheNone内存能降到 15 MB 左右,代价是 CPU 上去、偶尔掉帧。视频的 65 MB 主要来自解码缓冲和显存占用,硬解时 CPU 很低但显存吃得多。
还有一个容易被忽略的指标:冷启动第一次的视频解码器初始化特别慢。因为 WMF 或 FFmpeg 要加载一堆 DLL 和初始化硬件加速上下文,第一次可能要 800 毫秒以上,第二次就降到 400 毫秒左右。这就意味着用户每次开机后第一次打开你的软件,片头动画会明显延迟出现。这个只能接受,没法优化。
4.2 常见问题速查表
下面这张表是我这些年遇到过的所有启动动画相关问题的汇总,按出现频率排序:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 视频黑屏但有声音 | 缺解码后端或缺 gstreamer 插件 | Windows 换 H.264 MP4;Linux 装 gstreamer1.0-libav |
| 视频完全没反应,无报错 | qrc 路径 QMediaPlayer 读不了 | 改用 applicationDirPath() 拼绝对路径 |
| mediaStatus 卡在 NoMedia | 同上,或者文件权限被拒 | 打印 errorString() 定位,检查文件与 rcc 的存在 |
| 启动画面根本没出现 | 没调 processEvents(),初始化把事件循环堵死 | show() 之后立刻 processEvents() |
| 进度条不动,结束后直接跳满 | 用了 update() 而不是 repaint() | 换成 repaint() 强制同步重绘 |
| GIF 播完停在最后一帧 | QMovie 循环相关设置 | setLoopCount(-1) 或干脆不管,靠主窗口接管 |
| GIF 边缘有白边 | GIF 调色板不支持真透明 | 用同色背景替代透明,或改 APNG/PNG 序列 |
| 高 DPI 下图片糊 | 没开 High DPI 缩放,也没提供高分素材 | 设 AA_EnableHighDpiScaling,准备 @2x 素材 |
| 多显示器时启动画面跑到副屏 | 直接 move 到固定坐标 | 用 primaryScreen()->availableGeometry() 计算居中 |
| 主窗口出现前闪一下白 | 主窗口背景未设置,显示瞬间才绘制 | 设主窗口背景色、或在 show 前先 resize+move |
| 主窗口显示后启动画面还在 | finish() 和 show() 顺序反了 | 先 window.show(),再 splash.finish(&window) |
| 启动画面被其它窗口盖住 | 没有置顶标志 | 构造时传 Qt::WindowStaysOnTopHint |
| 视频淡出效果无效 | QGraphicsOpacityEffect 对视频层不生效 | 改用一个纯色遮罩 Widget 做淡出 |
| 用户点击启动画面触发了主界面操作 | 没有屏蔽鼠标事件 | setAttribute(Qt::WA_TransparentForMouseEvents) |
4.3 高 DPI、多屏与无边框的三个隐蔽坑
这三个问题单独拎出来说,因为它们的表现都不在"启动动画"这个关键词上,很容易查偏方向。
第一个是高 DPI。在 150% 缩放的显示器上,如果你没开AA_EnableHighDpiScaling,Qt 会把 1280x720 的图片按逻辑像素处理,实际显示出来是 853x480 逻辑像素撑满屏幕,看起来就是整体模糊加锯齿。反过来如果你开了缩放但只提供了 1x 素材,Qt 会做上采样,同样模糊。正确做法是开缩放 + 提供 @2x 素材,qrc 里写成splash@2x.png,Qt 会自动按需选择。
第二个是多屏。QSplashScreen默认会显示在主屏中央,但如果用户在设置里把主屏改成了副屏,或者显示器排列是上下结构,直接move(固定坐标)就会跑到屏幕外面去。统一用这段代码算位置:
QScreen *screen = QGuiApplication::primaryScreen(); const QRect geo = screen->availableGeometry(); // 排除任务栏 move(geo.center() - rect().center());注意是availableGeometry()而不是geometry(),前者排除了任务栏区域,后者包含,用错了启动画面会被任务栏遮住一点。
第三个是无边框窗口的焦点问题。设置Qt::FramelessWindowHint之后,Windows 上这个窗口可能不会自动出现在任务栏,也不会自动获得焦点。对于启动画面来说这其实是好事,但有个副作用:如果启动画面显示期间用户用 Alt+Tab 切换,可能切不到这个窗口,视觉上就是"启动画面不见了但程序还没起来"。要避免这种情况,可以给启动画面加Qt::Tool属性,或者干脆接受这个行为——毕竟启动画面只存在几秒钟。
5. 把启动动画封装成可复用组件
5.1 SplashManager 接口设计
前面三套实现如果每次都往 main() 里塞,工程会越来越乱。我一般会封一个 SplashManager 出来,把方案选择、超时兜底、进度上报、淡出交接全部收进去,对外只暴露几个方法:
// splashmanager.h #pragma once #include <QObject> #include <QSize> #include <QPointer> class SplashManager : public QObject { Q_OBJECT public: enum Mode { Image, Gif, Video }; explicit SplashManager(QObject *parent = nullptr); ~SplashManager() override; // maxMs 是硬性超时,到点无条件交接 bool start(Mode mode, const QString &resPath, const QSize &size, int maxMs = 4000); void setProgress(int value); // 0~100,仅图片模式有效 void setMessage(const QString &text); void handoverTo(QWidget *mainWindow); // 交给主窗口,做淡出 signals: void finished(); private: void onNativeFinished(); QPointer<QWidget> m_splash; QPointer<QWidget> m_target; QTimer *m_timeout = nullptr; bool m_done = false; };核心逻辑是:start()根据 Mode 创建对应的 splash 对象并显示;同时启动一个maxMs的超时定时器;handoverTo()记录目标窗口,等 splash 自然结束或超时后,显示目标窗口并关闭 splash;m_done布尔量保证交接只发生一次,避免超时和自然结束同时触发导致两次 show。
这个m_done标志看起来多余,实际上是必需的。我最早就是没加,结果视频 3 秒结束和 4 秒超时几乎同时触发,主窗口被 show 了两次,在某些平台上会闪一下。
5.2 与主窗口初始化流程的协作方式
SplashManager 只负责动画,初始化任务流是另一套东西。我习惯再做一个 Bootstrap 类,把两者串起来:
class Bootstrap : public QObject { Q_OBJECT public: void run(SplashManager *splash, QWidget *mainWindow) { QVector<std::function<void()>> tasks = buildTaskList(); auto *pump = new QTimer(this); int index = 0; connect(pump, &QTimer::timeout, this, [=]() mutable { if (index < tasks.size()) { tasks[index++](); splash->setProgress(index * 100 / tasks.size()); } else { pump->stop(); splash->handoverTo(mainWindow); } }); pump->start(30); } private: QVector<std::function<void()>> buildTaskList(); };把任务列表和 UI 分开之后,加一个初始化步骤就是往buildTaskList()里加一行,完全不用碰动画代码。这套结构我在三个项目里复用,没改过。
有一点要提醒:分片任务里不要做 UI 操作。因为任务是在事件循环的回调里跑的,这时候界面可能还没完全就绪,碰 UI 容易出各种偶发问题。任务里只做数据层面的准备,UI 相关全部留到主窗口的构造函数或者showEvent()里。
5.3 一些实战体会
做启动动画这几年,我最大的感受是:它是个细节活,不是功能活。功能上半小时就能跑通,但要做得不露破绽,让用户看不出有启动延迟、看不出有窗口切换,得反复调。
几个印象比较深的教训。给一个工业控制软件做启动动画,客户要求用视频,现场机器是集成显卡加老驱动,硬解直接失败回落到软解,1080p 视频把 CPU 干到 40%,整个启动过程拖到 8 秒。最后是把视频降到 720p、时长压到 2.5 秒才勉强能用。那次之后我定了个规矩:视频素材的分辨率上限是主窗口设计尺寸,绝不做超采样。
还有一个是给触屏设备做的,启动画面用 GIF 做了个旋转 loading。上线后用户反馈说点屏幕没反应。排查半天发现是 GIF 有 60 多帧,CacheAll缓存了 200 多 MB,在 2GB 内存的设备上直接把内存打满,系统开始疯狂 swap。改成CacheNone加降低帧率之后正常了。这件事告诉我:移动端和嵌入式设备上,GIF 的帧数和分辨率必须严格控,CacheAll这个选项要慎用。
再有就是退出环节。有次做视频启动动画,用户从启动画面直接点了关闭按钮想退出程序,结果视频还在播,主窗口没构造完,退出流程走到一半崩了。后来在 splash 上加了按键拦截,启动期间按 Esc 或者点关闭一律忽略,等主窗口起来之后再交给它处理。这种边缘场景不做测试基本想不到。
如果这几套代码你打算直接拿去用,我建议先把图片方案跑通,确认整个启动流程的事件循环、进程退出、窗口切换都正常了,再往上加 GIF,最后才是视频。因为视频方案的失败模式最多,如果一开始就上视频,出了问题你会分不清是流程问题还是解码问题,排查成本会翻倍。