写项目模板这事儿,说实话比写业务代码更容易翻车。业务代码写错了最多功能跑不通,项目模板要是有问题,那真的是一传十、十传百,整个团队、整个仓库的工程化地基都跟着歪。尤其是Qt的QML项目,C++和QML混着写,资源、翻译、类型注册、模块导入、打包部署全搅在一起,配置起来比纯Widgets项目麻烦得多。我梳理了一份自用的Qt QML项目CMake模板,整套思路从Qt 5.15一直用到Qt 6.7,中间踩了不少坑,今天把这套东西的来龙去脉、核心配置和实操过程摊开讲清楚,希望对正在折腾CMake的Qt开发者有帮助。
1. 为什么QML项目需要一套CMake模板,而不是继续用qmake
我先说一个观点:如果你现在还在用qmake管新的QML项目,后面十有八九要后悔。Qt官方在6.0之后已经把CMake扶正成默认构建系统,qmake虽然还在维护,但新特性基本不再往上面叠。更关键的是,QML模块化、静态编译、Android/iOS交叉编译、CI流水线里矩阵并行构建这些需求,CMake的处理能力比qmake强一个量级。
那为什么专门强调“QML项目”而不是泛泛的Qt项目?因为在QML项目里,构建系统不止是“编译C++代码”这么简单,它还要解决几件qmake时代很痛苦的事:
第一,QML文件本身不算编译单元,但它有导入路径、有模块URI、有类型注册信息。qmake时代你经常要在.pro里手工维护QML_IMPORT_PATH和一些别扭的资源路径,稍不留神,Main.qml里引一个自定义控件就报module not found。CMake的qt_add_qml_module把这一摊子事自动收口了,qml文件、C++类型、资源前缀、qmldir文件全部声明式管理,省掉大量手工配置。
第二,QML项目几乎必然要混编C++,不管是做核心算法、封装第三方库,还是暴露一些Model给前端。CMake对C++的生态支持显然是碾压级的——vcpkg、conan、FetchContent这些包管理工具都是优先兼容CMake,你一个Qt项目如果要引一个Hash库、一个网络库,用CMake会顺滑很多。
第三,跨平台部署。QML项目比Widgets项目更依赖插件和QML模块的运行时文件,单靠手工拷贝根本不可能。这套模板里把windeployqt/macdeployqt/linuxdeployqt全部接进CMake的POST_BUILD阶段,构建完自动打完包,双击就能跑,不存在“在自己机器上能跑,换台机器就白屏”的尴尬。
还有一个很实际的原因:团队协作。模板把所有人的构建姿势统一了,新人拉下来代码,或者用CMakePresets跑一条命令,环境就一样了。不用每个人在本地手动配qmake路径、装这装那,也不容易出现“在我这是好的”这种经典甩锅。
所以这篇模板不是炫技,是给有真实QML工程需求的开发者一个可以直接抄的基线。我自己在几个真实项目里反复调整过它,现在这套结构在Windows上配MSVC和Ninja都能跑,在Linux上配GCC也没问题,放到macOS上一样可以编出dmg包。下面我把它拆开讲。
2. 模板整体设计与目录结构拆解
先看模板的整体结构。我采用的是一个偏中型项目的组织方式,既不是单文件堆到底,也没有过度抽象到每个QML页面一个子模块。目录大概长这样:
MyQmlApp/ ├── CMakeLists.txt ├── CMakePresets.json ├── cmake/ │ ├── DeployMac.cmake │ ├── DeployLinux.cmake │ └── DeployWindows.cmake ├── src/ │ ├── main.cpp │ ├── AppEngine.h │ ├── AppEngine.cpp │ └── Models/ │ ├── TaskModel.h │ └── TaskModel.cpp ├── qml/ │ ├── Main.qml │ ├── pages/ │ │ ├── HomePage.qml │ │ └── SettingsPage.qml │ ├── components/ │ │ ├── AppButton.qml │ │ └── AppListView.qml │ └── assets/ │ ├── images/ │ │ └── logo.svg │ └── fonts/ ├── resources/ │ ├── translations/ │ │ ├── app_zh_CN.ts │ │ └── app_en_US.ts │ └── config/ │ └── app.ini └── tests/ └── tst_AppEngine/ ├── CMakeLists.txt └── tst_AppEngine.cpp2.1 为什么把源码、QML、资源分开而不是全塞进qrc
有人习惯把所有QML文件一股脑塞进qrc资源里,然后用qrc:/路径访问。这对小demo没问题,项目一复杂就蛋疼——合并冲突频繁、每次改QML都要重新编译资源、无法在运行时动态加载插件或主题资源。所以我把qml/目录当成源码目录来处理,通过CMake的qt_add_qml_module自动把它们纳入资源编译;真正常变的图片、字体、配置文件放在resources/目录里单独管理,可以按需决定打进QRC还是走外部路径。
src/下只放C++源文件,qml/下只放QML相关文件。这种分离有一个额外好处:CI里可以做很细粒度的缓存和增量编译,改一个QML文件不会触发整个C++文件树的重编;反过来改C++时,QML文件也不用全部重新处理。
tests/目录单独拆出来是给后续接入CTest留的口子。纯QML项目可能不太需要,但一旦C++模型逻辑变多,单元测试基本是必需品。
2.2 CMakeLists.txt主文件全貌与逐段说明
主CMakeLists.txt看起来是这样的,我直接贴一个可运行版本:
cmake_minimum_required(VERSION 3.24) project(MyQmlApp VERSION 1.0.0 DESCRIPTION "A CMake template for Qt Quick application" LANGUAGES CXX ) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Gui Widgets ) qt_standard_project_setup() qt_add_executable(MyQmlApp src/main.cpp src/AppEngine.h src/AppEngine.cpp src/Models/TaskModel.h src/Models/TaskModel.cpp ) qt_add_qml_module(MyQmlApp URI MyQmlApp VERSION 1.0 QML_FILES qml/Main.qml qml/pages/HomePage.qml qml/pages/SettingsPage.qml qml/components/AppButton.qml qml/components/AppListView.qml SOURCES src/AppEngine.h src/AppEngine.cpp src/Models/TaskModel.h src/Models/TaskModel.cpp RESOURCE_PREFIX /qt/qml OUTPUT_DIRECTORY qml/MyQmlApp ) target_compile_definitions(MyQmlApp PRIVATE $<$<CONFIG:Debug>:QT_QML_DEBUG> ) qt_finalize_executable(MyQmlApp) if(WIN32) include(cmake/DeployWindows.cmake) deploy_windows_qt(MyQmlApp) elseif(APPLE) include(cmake/DeployMac.cmake) deploy_mac_qt(MyQmlApp) else() include(cmake/DeployLinux.cmake) deploy_linux_qt(MyQmlApp) endif() enable_testing() add_subdirectory(tests)这里有几个点我必须着重强调一下,它们是我反复试错之后总结出来的关键:
第一,CMAKE_AUTOMOC一定要开。QML模块里的C++类一般会带Q_OBJECT宏,如果不开AUTOMOC,你会在链接阶段遇到一堆“undefined reference to vtable for xxx”之类的玄学错误。这个不要手工去逐个添加moc文件,CMake的AUTOMOC能自动处理。
第二,CMAKE_EXPORT_COMPILE_COMMANDS ON强烈建议开着。生成compile_commands.json之后,不管有没有Qt Creator,你都能用clangd或者各种代码补全工具拿到准确的编译参数,否则QML的C++侧自动补全经常会抽风。
第三,qt_standard_project_setup()是Qt 6.3往后才有的,它统一设置了包括CMAKE_AUTOMOC在内的一些Qt相关默认值。不过我在模板里仍然显式写了AUTOMOC这些选项,因为老项目里可能有自定义的生成器或者子目录覆写了全局设置,显式写出来更稳。
第四,qt_add_executable和qt_add_qml_module都引用了同一个可执行目标MyQmlApp。这是Qt官方推荐的做法——先建可执行目标,再用qt_add_qml_module给这个目标挂上QML模块配置。这样QML和C++最终打进同一个可执行文件里,关键是在qt_add_executable里不用重复放QML文件,那些文件只属于qt_add_qml_module管理。
第五,qt_finalize_executable这个函数必须在所有和该目标相关的配置完成后调用。尤其是你要打包部署、加翻译文件、生成插件的时候,顺序不能乱。我之前有个项目因为把它提前了,导致macOS上部署脚本拿不到info.plist,折腾了小半天。
2.3 CMakePresets.json:一条命令统一所有环境
CMakePresets是CMake 3.21之后引入的,目的就是解决“不同人用不同参数配置CMake”的混乱。下面是我模板里的presets文件:
{ "version": 6, "cmakeMinimumRequired": { "major": 3, "minor": 24, "patch": 0 }, "configurePresets": [ { "name": "default", "displayName": "默认开发配置", "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } }, { "name": "release", "displayName": "发布配置", "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } }, { "name": "vs2022", "displayName": "Visual Studio 2022", "generator": "Visual Studio 17 2022", "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_PREFIX_PATH": "C:/Qt/6.6.2/msvc2019_64" } } ], "buildPresets": [ { "name": "default", "configurePreset": "default" }, { "name": "release", "configurePreset": "release" }, { "name": "vs2022-debug", "configurePreset": "vs2022", "configuration": "Debug" }, { "name": "vs2022-release", "configurePreset": "vs2022", "configuration": "Release" } ] }用Ninja做默认生成器是因为它在Windows上构建速度快,增量编译体验比VS好得多。但有些团队依赖VS的调试器和性能分析器,所以我保留了一个vs2022预设。注意VS是多配置生成器,构建时用--config指定Debug还是Release,而Ninja是单配置,构建类型在配置阶段就定死了,必须分开预设。
CMAKE_PREFIX_PATH是Qt开发里最容易踩坑的地方。很多人以为装完Qt就能被find_package找到,其实CMake并不知道你的Qt装在哪。如果你不想每次配置都传-DCMAKE_PREFIX_PATH=...,就把路径写进preset里。Windows上务必分清msvc2019_64和mingw_64目录,Toolchain不一样,混用会编出各种奇怪错误。
3. QML模块注册、类型导出与资源编译的核心细节
这一节是最能体现QML项目模板特殊性的地方。很多人把CMake配上跑通就觉得完事了,结果QML里import MyQmlApp 1.0就是找不到,或者自定义类型在QML里显示为不可见对象。这些问题的根源几乎都在于模块注册和类型导出没有配齐。
3.1 qt_add_qml_module这个函数到底干了什么
qt_add_qml_module是Qt 6.x里面管理QML模块的核心函数,它做的事情非常多:
- 把
QML_FILES列出来的QML文件收集起来,作为QML模块的内容。 - 把
SOURCES里列出的C++类型注册到QML运行时。 - 自动生成
qmldir文件和模块类型信息(也就是QML Designer里能看到类型列表的那个基础数据)。 - 管理虚拟目录/资源前缀,让QML模块在代码里能够通过
qrc:///qt/qml这样的路径被访问。
理解了这个函数,很多问题就迎刃而解了。比如你在QML里import MyQmlApp 1.0,CMake会根据qt_add_qml_module里的URI MyQmlApp生成对应的模块目录。如果URI和QML文件里的import语句对不上,运行时100%报module not found。这种错误编译器不会提示,只有启动应用时才炸。
再比如,SOURCES和QML_FILES的区别。QML_FILES只管纯QML定义,SOURCES是你用C++实现并注册给QML使用的类型。这两种文件在Qt里会被QML编译器以不同的方式处理,不能混放。
3.2 QML_ELEMENT与类型注册的方式
C++类型要暴露给QML,除了放在qt_add_qml_module的SOURCES之外,类定义本身就带有注册标记:
#pragma once #include <QObject> #include <QQmlEngine> class AppEngine : public QObject { Q_OBJECT QML_ELEMENT QML_SINGLETON public: explicit AppEngine(QObject *parent = nullptr); Q_INVOKABLE QString greeting() const; };其中的QML_ELEMENT宏是关键。它告诉Qt的这个构建系统:“把我这个类导出到QML模块里”。如果没有这个宏,即使你把.h/.cpp放在qt_add_qml_module的SOURCES里,QML侧也new不出来对应对象。
我在模板中把AppEngine和TaskModel都列为SOURCES,并且用了QML_ELEMENT和QML_SINGLETON宏。QML_SINGLETON只在确实需要一个全局单例对象时才用——比如应用配置、主题管理器——如果你的模型需要多个实例,千万别打上这个宏。一个常见错误是给普通的Model类加了QML_SINGLETON,结果在QML里创建第二个实例时报错,排查起来特别迷惑。
还有一个细节:qt_add_qml_module默认生成的模块类型属于“static”模式,也就是说只有你显式列在QML_FILES或SOURCES里的类型才会被注册。这比qmake时代那种扫描整个目录树的“野路子”可靠很多,不容易重复注册,也不会漏注册。
3.3 资源前缀、OUTPUT_DIRECTORY与QML路径到底怎么对应
资源前缀是QML模块比较绕的一个点,我甚至觉得这是整个模板里最容易被误解的配置。
qt_add_qml_module默认的RESOURCE_PREFIX是/qt/qml。所有模块文件会以/qt/qml/<URI>/<文件相对路径>的形式挂在Qt资源系统里。比如我们的URI是MyQmlApp,Main.qml的完整资源路径就是qrc:/qt/qml/MyQmlApp/Main.qml。这样做的好处是各模块之间不会撞路径。
OUTPUT_DIRECTORY qml/MyQmlApp这一段则控制编译产物中QML模块文件在构建目录里的存放位置。如果你不设置这个选项,Qt默认会放到构建目录下某个层级生成的目录里。设置这个选项的主要原因是让调试、查看编译输出的QML文件、以及后续部署脚本拿文件时,路径是可预期和稳定的。
关于资源路径有个坑值得一提:如果你在QML里用Loader动态加载一个qml文件,Loader的source如果写成qrc:/qt/qml/MyQmlApp/pages/HomePage.qml,那路径必须和资源前缀严格一致。一旦改了RESOURCE_PREFIX,所有手工写的路径都要跟着改。所以模板里尽量不要在QML代码里硬编码长路径,最好用相对路径配合Qt.resolvedUrl,或者直接用qmldir里的模块导出。
3.4 图片、字体、配置文件在哪里放
真正的项目不可能没有图片图标字体。我经验是:小的、固定不变的资源(logo、某些固定图标)放qml/assets里并在QML_FILES里逐项声明,让Qt的QML编译器做优化;大体积的、可能会按需加载的资源(比如多语言文档、皮肤包)放resources/下面,通过普通QRC或者运行时文件目录加载。
在qt_add_executable里是看不到这些QML资源文件的——它们归qt_add_qml_module管。如果是纯资源文件,还有另一个函数qt_add_resources可以用,它适合把乱七八糟的非QML资源打包成QRC。
翻译文件(.ts/.qm)则建议用qt_add_translations或者qt_add_lupdate来处理,这样可以集成到构建流程里。我模板中的resources/translations目录就专门放翻译文件,后续可以在CMake里用QT_TRANSLATIONS_DIR把它们带上。当然,如果你的项目根本不做多语言,这块可以整个砍掉,不用追求大而全。
4. 构建类型、输出路径与VS工程相对路径写法详解
这块看起来是很基础的CMake知识,但实际项目里总有人反复踩坑,尤其是从Windows/VS环境入门的Qt开发者。我在模板里特意把构建配置设计得清晰一些,目的就是减少这类“低级但致命”的问题。
4.1 Debug与Release的多配置管理
CMake有两种构建方式,理解这个你后面所有配置都会顺:
- 单配置生成器:Ninja、Unix Makefiles。这类生成器在
cmake -S . -B build配置阶段就必须定下构建类型,通过CMAKE_BUILD_TYPE指定。所以我在presets里为Ninja分别准备了default(Debug)和release两个configure preset。 - 多配置生成器:Visual Studio、Xcode。它们可以在同一个构建目录里同时生成Debug和Release两套配置,构建时通过
--config来选。
Qt官方包在Windows上默认提供了MSVC和MinGW两种ABI的库。用VS生成器时,CMAKE_PREFIX_PATH必须指向msvc版本的Qt,不能指向MinGW版本,否则链接阶段会因为ABI不兼容报一堆无法解析的错误。这个我吃了不少亏,写在这里提醒大家。
target_compile_definitions里那行$<$<CONFIG:Debug>:QT_QML_DEBUG>也值得解释下:它的意思是,当配置为Debug时,给目标加一个QT_QML_DEBUG宏。这个宏会开启QML运行时的一系列调试信息输出,比如加载器日志、模型调试信息等。Release模式下不加,避免性能损耗和信息泄露。
4.2 让输出目录不再套一层Debug/Release子目录
很多从VS工程转过来的人都很烦CMake默认把输出文件放到build/Debug、build/Release这种子目录里,找exe还得先点两层目录。热搜词里就有“cmake输出路径去掉debug”,这个痛点确实大。
其实解决办法非常直接:显式设置输出目录,把配置名从路径里去掉。在以Visual Studio为代表的多配置生成器下,如果不做设置,默认输出目录会带上$(Configuration)子目录。为了让所有配置的输出都落在同一个目录,可以在顶层CMakeLists里统一指定:
if(MSVC) foreach(config Debug Release RelWithDebInfo MinSizeRel) string(TOUPPER ${config} config_upper) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${config_upper} "${CMAKE_BINARY_DIR}/bin") set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_${config_upper} "${CMAKE_BINARY_DIR}/bin") set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_${config_upper} "${CMAKE_BINARY_DIR}/lib") endforeach() else() set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") endif()这段代码的思路:对于多配置生成器,逐个配置设置一次输出目录;对于单配置生成器,直接设置不带配置名后缀的变量。这样所有配置的exe/dll都会落在build/bin,动态库和静态库落在build/lib,干净利落。
有个副作用要注意:如果Debug和Release都用同一个输出目录,后构建的那一个可能会覆盖前一个的同名DLL。解决办法是干脆用不同构建目录(默认不就是build/default和build/release吗),presets里已经天然分开了,互相不干扰。如果你非要在同一个构建目录里来回切换VS的配置,那请给DLL加版本后缀,或者干脆别合bin目录,省得自找麻烦。
4.3 VS工程里的相对路径写法
“cmake生成的vs工程使用相对路径”这个痛点,我也遇到过。默认情况下,VS工程文件里会写入很多绝对路径,比如你的源码路径如果从D盘挪到E盘,或者拷给别人,重新打开工程可能就有一堆红波浪线、找不到头文件。
CMake其实是支持相对路径的,核心原则是:在你的CMakeLists.txt里不要写任何硬编码绝对路径,全部基于${CMAKE_CURRENT_SOURCE_DIR}、${CMAKE_CURRENT_BINARY_DIR}、${CMAKE_SOURCE_DIR}来拼。CMake在生成VS工程时,会自动把能够相对化的路径相对化。你只要别手动传一个D:/projects/...给target_include_directories,它生成的工程就是可以整体搬走的。
再配合CMAKE_SUPPRESS_REGENERATION或者干脆用Ninja + compile_commands.json,很多路径问题都会消失。因为compile_commands.json里存的路径是统一基于构建目录的,不依赖IDE的工程文件。
如果你确实需要在生成VS工程时强制使用相对路径,CMake 3.25之后有了CMAKE_USE_RELATIVE_PATHS这个选项,不过它默认是OFF,而且支持得不是特别完美。我的建议是不要在CMakeLists里刻意搞相对路径魔法,把源码和构建目录放得层级关系稳定一些,配置里坚持用CMake变量引用路径,效果反而最好。
4.4 在CMake里执行自定义命令或脚本
CMake有时候需要在构建前后干点别的活,比如生成代码、拷贝文件、调脚本。热搜词里的“cmake执行bash命令”指的就是这类场景。我的模板里,尤其是部署脚本,大量使用自定义命令,这里简单说一下跨平台的做法:
add_custom_command(TARGET MyQmlApp POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/resources/config $<TARGET_FILE_DIR:MyQmlApp>/config COMMENT "Copying config files" )这里不要直接调bash -c或者cmd /c,要用${CMAKE_COMMAND} -E提供的跨平台命令集。copy_directory、copy、rm、make_directory这些都有原生的跨平台实现。如果你确实要调外部脚本,可以用${CMAKE_COMMAND} -E env配合脚本路径,但前提是脚本本身是可移植的,不然Windows和Linux一换就崩。
这样设计的好处是构建脚本可以不用改就在所有平台跑。当然也不是说不能用bash脚本——macOS和Linux上bash天然可用,Windows上如果装了Git Bash也能跑,但那就失去了跨平台一致性。所以我在模板的CMake部署脚本里尽量用cmake -E原生命令,只有像windeployqt这种特定平台的工具才按平台分支去调。
5. 自动打包、windeployqt接入与跨平台部署配置
QML项目的打包比Widgets项目麻烦,这是公认的。光是Qt Quick的底层渲染引擎、场景图插件、QML模块导入文件这一堆东西,手工拷贝就会漏这漏那。好在CMake可以把这个过程自动化,我模板的cmake/DeployWindows.cmake里专门封装了一个函数,构建完自动执行部署,输出一个可以直接分发的文件夹。
5.1 Windows平台:windeployqt + CMake的POST_BUILD集成
windeployqt是Qt Windows平台部署的官方工具。它能自动扫描exe依赖的Qt DLL、插件、以及QML模块文件。QML项目使用它,有一点必须注意:必须指定--qmldir参数,指向你QML源文件的目录,否则工具只会拷C++依赖的DLL,QML模块相关的文件不会全部带齐,结果就是目标机器上exe起来了但界面空白/报module not found。
下面是我DeployWindows.cmake里的核心片段:
function(deploy_windows_qt target_name) find_program(WINDEPLOYQT_EXECUTABLE windeployqt HINTS "${QT_BIN_DIR}") if(NOT WINDEPLOYQT_EXECUTABLE) message(FATAL_ERROR "windeployqt not found. Check your Qt installation.") endif() set(DEPLOY_BIN_DIR "$<TARGET_FILE_DIR:${target_name}>") add_custom_command(TARGET ${target_name} POST_BUILD COMMAND "${WINDEPLOYQT_EXECUTABLE}" --qmldir "${CMAKE_SOURCE_DIR}/qml" --release --no-translations --no-system-d3d-compiler --no-opengl-sw "$<TARGET_FILE:${target_name}>" WORKING_DIRECTORY "${DEPLOY_BIN_DIR}" COMMENT "Running windeployqt for ${target_name}..." ) add_custom_command(TARGET ${target_name} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/resources/config" "${DEPLOY_BIN_DIR}/config" COMMENT "Copying runtime config files" ) endfunction()用$<TARGET_FILE_DIR:${target_name}>拿到exe所在目录的方式非常灵活,不会因为输出目录改了而失配。--release这个参数根据实际构建配置可选,如果Debug打包也可以去掉。
有几个参数值得展开:
--no-translations:如果项目没有做多语言,把它加上可以省掉大量qt_*.qm翻译文件。如果做多语言,就不要加,并且把resources/translations下生成的app_zh_CN.qm等文件拷过去。
--no-opengl-sw:默认windeployqt会把软件OpenGL的dll也带过去,如果确定目标机器有GPU驱动,这个参数可以减小体积;但对一些老旧电脑或者虚拟机环境,软件OpenGL反而是救命稻草。要不要加上取决于你的目标用户。我一般发布给企业用户时是去掉这个参数的,保险。
5.2 到了部署阶段,输出目录和安装规则也要一起搞定
如果你的目标是做一个正式的安装包,而不是拷贝文件夹给别人用,那就应该用CMake的install规则结合CPack。下面是一个install(DIRECTORY ...)的例子:
install(TARGETS MyQmlApp BUNDLE DESTINATION . RUNTIME DESTINATION bin ) install(DIRECTORY "${CMAKE_BINARY_DIR}/bin/" DESTINATION bin )如果你用了windeployqt把所有依赖都拷到了exe旁边,那install时就只需要把整个bin目录拷贝过去。Qt官方在6.5之后也支持在qt_add_executable里加QT_DEPLOY_TARGET这种方式,但我觉得在POST_BUILD里执行windeployqt更直观,而且对老版本Qt(5.15)也兼容。
5.3 macOS与Linux的部署说明
这两个平台相对Windows要简单一些。macOS上有macdeployqt,Linux上有linuxdeployqt(社区维护)。它们的原理都是扫描可执行文件的依赖库并拷贝到相应目录。在CMake里接入的方式和windeployqt几乎一样,只是要注意路径分隔符和工具名不同。
macOS下如果用了QML模块,macdeployqt也需要-qmldir参数。而且从Qt 6开始,如果你的应用需要提交App Store,还要额外处理签名和sandbox,那又是一个独立的主题了。Linux上需要注意的是不同发行版的库版本差异,如果目标机器比较旧,最好在打包机上也跑一个较旧的发行版容器,避免“打包机太新,目标机器跑不了”的尴尬(也就是glibc版本太新导致启动报错)。
我模板里给Linux用的DeployLinux.cmake大概这样:
function(deploy_linux_qt target_name) find_program(LINUXDEPLOYQT_EXECUTABLE linuxdeployqt) if(NOT LINUXDEPLOYQT_EXECUTABLE) message(WARNING "linuxdeployqt not found, skip automatic deployment.") return() endif() add_custom_command(TARGET ${target_name} POST_BUILD COMMAND "${LINUXDEPLOYQT_EXECUTABLE}" "$<TARGET_FILE:${target_name}>" -qmldir="${CMAKE_SOURCE_DIR}/qml" -appimage WORKING_DIRECTORY "$<TARGET_FILE_DIR:${target_name}>" COMMENT "Running linuxdeployqt for ${target_name}..." ) endfunction()这套逻辑比较直接,构建完成后跑一次就能得到一个AppImage,Linux下的分发基本不用操心动态库依赖问题。
6. QML模板开发中的典型报错与排查技巧
最后这部分,我把自己在多个项目里攒下来的排错经验整理一下。每一条都对应真实的运行/构建问题,能帮你省下大量搜索时间。
6.1 QML模块找不到:import MyQmlApp 1.0 not found
这个报错在QML项目里出现频率最高。排查思路按顺序来:
- 检查CMakeLists里
qt_add_qml_module的URI和QML文件里import的URI是否完全一致,大小写敏感,一个字母都不能差。 - 检查
RESOURCE_PREFIX设置是否正确。如果你改了前缀,QML文件的导入器搜索路径也会跟着变。 - 检查
qt_add_qml_module是否真的被编译进了目标。用Qt Creator打开构建目录,看能不能找到生成的qmldir文件。找不到就说明函数根本没执行到。 - 运行时检查程序输出,看看有没有关于模块路径的警告。如果是在Windows上,确认QML模块相关的DLL/文件是否被部署工具拷到了exe旁边。
有一种特别隐蔽的情况:模块A依赖模块B,模块B没被部署工具扫描到,导致模块A的import也一起失败。这种问题通常换一台干净机器测一下就能暴露出来。
6.2 QML控件点击事件报错之后如何恢复界面状态
这个热搜词其实和CMake模板没直接关系,但它其实是一个很现实的QML开发坑——如果运行时抛了JavaScript异常,界面可能卡在一个异常状态里。
最靠谱的办法是在窗口级别捕获未处理异常,然后重置视图。简单做法是在main.cpp里设置QQmlEngine的异常钩子:
qmlEngine->setNetworkAccessManagerFactory(...) // 不相关 qmlEngine::setErrorCallback? // API各版本不同,需查更通用的做法是在QML侧用Qt.application的aboutToQuit等信号做清理,或者在Loader加载页面时包一层异常处理。但这种方式治标不治本,核心是保证模型层数据的一致性,比如按钮点击里做状态翻转时,要先备份再执行,catch到异常立刻回滚。模板里我建议把这种状态管理逻辑下沉到C++的AppEngine,别写在QML的onClicked里,这样天然免疫很多异常。
6.3 自动构建过了但启动白屏或插件加载失败
白屏排查顺序和模块导入类似。第一步先看控制台输出有没有Cannot load library ...之类的报错。第二步检查Qt插件的目录结构是否完整。Windows上windeployqt之后,exe旁会有platforms、imageformats、qml等目录,如果目录不完整,应用可以启动但界面可能空白。
还有一个坑是Qt版本混用。比如当前CMake找到的是Qt 6.6,但PATH环境变量里残留着一个Qt 5的bin目录,运行时动态库优先加载了旧版Qt的DLL,导致崩溃或白屏。这种问题用ListDLLs这类工具看exe实际加载的Qt DLL路径就能确认。
6.4 CMake配置时报Could NOT find Qt6
这个也常见,特别是刚装的Qt。诊断步骤:
- 确认
CMAKE_PREFIX_PATH是否正确指向Qt安装目录。比如C:/Qt/6.6.2/msvc2019_64,目录下要有lib/cmake/Qt6/Qt6Config.cmake。 - 检查是否装了对应的编译器ABI。MSVC的Qt库只能用MSVC编译器去找,MinGW的Qt库只能用MinGW编译器去找。我见过有人在VS工程里配了MinGW的Qt路径,CMake怎么都找不到。
- 查看Qt安装包是否漏装了我们需要的那几个组件。比如只装了qt6-base,没装qt6-quick,那
find_package(Qt6 COMPONENTS Quick)就会失败。可以在Qt安装器里确认Quick相关组件是否勾选。
如果找不到,可以把CMake错误信息里的提示贴到Qt安装目录确认下路径拼写。Windows上经常出现的就是C:/Qt写成了C:\Qt,在CMake里反斜杠转义很讨厌,统一用正斜杠。
6.5 编译错误和自动MOC相关的奇怪问题
AUTOMOC偶尔会对自定义的.h文件产生误判,比如一个头文件里有Q_OBJECT,但文件后缀不是.h,或者它不是一个完整的类定义。有时候CMake会提示Unknown CMake command "qt_add_qml_module"——这说明你用的不是Qt 6,或者版本太老没有这个函数。Qt 6.0是引入qt_add_qml_module的早期版本,但真正稳定下来是6.2、6.3。如果你还在Qt 5.15,那只能退回qt5_add_resources那套旧写法,或者至少用qt_add_resources加上手工配置qmldir。
另外,纯头文件的QML类型,比如用QML_ELEMENT写在头文件里,有些版本需要在qt_add_qml_module的SOURCES里同时列出.h和对应的.cpp,否则链接期会报undefined reference。确保头文件同时被AUTOMOC看到,这一点别省略。
7. 常见问题速查表
为了让大家排查起来更顺手,我把以上问题整理成一张速查表:
| 问题现象 | 最可能的原因 | 解决方案 |
|---|---|---|
| import 模块 not found | qmldir没有生成或URI不匹配 | 检查qt_add_qml_module的URI和QML中的import是否一致 |
| 构建成功但启动白屏 | QML模块依赖没有全部拷贝 | windeployqt加--qmldir参数,确认插件目录齐全 |
| find_package找不到Qt6 | CMAKE_PREFIX_PATH错误或ABI不匹配 | 检查路径指向msvc/mingw对应目录,确认组件完整 |
| 链接期undefined reference to vtable | AUTOMOC没开或头文件没在SOURCES里 | 确保CMAKE_AUTOMOC ON,头文件和cpp不放漏 |
| VS工程换机器后很多路径错误 | 工程里写死了绝对路径 | 配置里改用CMAKE_SOURCE_DIR等变量,不要硬编码路径 |
| 输出目录多一层Debug/Release | VS多配置默认输出路径带配置名 | 逐个配置覆盖CMAKE_RUNTIME_OUTPUT_DIRECTORY |
| Linux打包后目标机器报GLIBC错误 | 打包机的glibc比目标机器新 | 在较旧的发行版容器里打包 |
| QML类型在界面里看不到 | 没有QML_ELEMENT宏或者没有注册 | 给C++类加QML_ELEMENT,确保在SOURCES里声明 |
| macdeployqt后库加载失败 | qt.conf或依赖库路径异常 | 检查macdeployqt输出,必要时用otool查看依赖路径 |
| 构建目录越来越大 | 各个preset输出混在一起 | 用独立的binaryDir,或者定期清理build目录 |
这个表只覆盖了高频问题,真正复杂的项目里还会有很多特殊坑,但解决思路是通用的:先看CMake配置能不能生成正确的qmldir,再看运行时有没有找到正确的模块/插件目录,最后才怀疑代码本身。
最后再分享一个小技巧。如果你只是想在几分钟内跑起一个QML小项目做验证,不需要整套模板,可以试试只用这几行CMake:
cmake_minimum_required(VERSION 3.24) project(TestQml) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Quick) qt_standard_project_setup() qt_add_executable(TestQml main.cpp) qt_add_qml_module(TestQml URI TestQml QML_FILES Main.qml) qt_finalize_executable(TestQml)这个极简模板也踩过了Qt 6.5和6.6的坑,能跑通。真正的完整模板,就是把这一套再加上目录划分、部署脚本、Presets和测试框架。我自己在实际项目里最满意的不是某一行命令,而是整套路清晰:构建、运行、打包、测试,每件事都有明确的入口和出口。照着这个思路搭,不管项目后面膨胀成什么样,地基都不会歪。