之前用CPack打包QML程序的时候发现,CPack打包程序安装的文件和使用cmake install命令安装的文件存在明显的不同,下面解释并解决这个问题。

1. 问题代码

还是以之前编写的QML程序为例,为了方便改进代码将CMakeLists.txt改写如下,依赖安装和打包模块分别写入cmake/dependencies.cmakecmake/packaging.cmake

cmake_minimum_required(VERSION 3.21)
set(PROJECT_NAME sample)
project(${PROJECT_NAME} VERSION 0.1 LANGUAGES CXX)
set(EXECUTABLE_NAME ${PROJECT_NAME}_app)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if(NOT $ENV{VCPKG_TARGET_TRIPLET} STREQUAL "")
  set(VCPKG_TARGET_TRIPLET $ENV{VCPKG_TARGET_TRIPLET})
  message(STATUS "VCPKG_TARGET_TRIPLET: ${VCPKG_TARGET_TRIPLET}")
  link_libraries("$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/lib")
  message(STATUS "Link libraries from: $ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/$<$<CONFIG:Debug>:Debug>/lib")
  include_directories("$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/include")
  message(STATUS "Include directories from: $ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/include")
  if(${VCPKG_TARGET_TRIPLET} MATCHES "static")
    set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/share/Qt6/qt.toolchain.cmake")
    message(STATUS "Using static Qt6 toolchain file: $ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/share/Qt6/qt.toolchain.cmake")
    set(Qt6_DIR "$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/share/Qt6")
    message(STATUS "Using static Qt6_DIR: $ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/share/Qt6")
  endif()
endif()

find_package(Qt6 6.5 REQUIRED COMPONENTS Quick)

if(Qt6_FOUND)
    message(STATUS "Qt6 found: ${Qt6_DIR}")
else()
    message(FATAL_ERROR "Qt6 not found!")
endif()

qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(${EXECUTABLE_NAME}
    main.cpp
)
qt_add_qml_module(${EXECUTABLE_NAME}
    URI ${PROJECT_NAME}
    VERSION 1.0
    QML_FILES
    Main.qml
)

# set static linking for MSVC
if(MSVC AND (${VCPKG_TARGET_TRIPLET} MATCHES "static"))
    message(STATUS "Configuring for MSVC static linking")
    set_property(TARGET ${EXECUTABLE_NAME} PROPERTY
        MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()

set_target_properties(${EXECUTABLE_NAME} PROPERTIES

    # MACOSX_BUNDLE_GUI_IDENTIFIER com.example.${EXECUTABLE_NAME}
    MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
    MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
    MACOSX_BUNDLE TRUE
    WIN32_EXECUTABLE TRUE
)

target_link_libraries(${EXECUTABLE_NAME}
    PRIVATE Qt6::Quick
)

include(GNUInstallDirs)
install(TARGETS ${EXECUTABLE_NAME}
    BUNDLE DESTINATION .
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

if(MSVC AND (${VCPKG_TARGET_TRIPLET} MATCHES "static"))
    message(STATUS "Linking static MSVC runtime, skipping install of MSVC runtime DLLs")
else()
    message(STATUS "Configuring install of runtime dependencies")
    include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/dependencies.cmake)
endif()

include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/packaging.cmake)

cmake/dependencies.cmake模块定义了如何从qt安装路径中复制依赖带安装路径。

# --- cmake/dependencies.cmake ---

set(deploy_tool_options_arg "")

if(APPLE)
  set(deploy_tool_options_arg --hardened-runtime)
elseif(WIN32)
  set(deploy_tool_options_arg --no-compiler-runtime --no-system-d3d-compiler --no-system-dxc-compiler)
endif()

qt_generate_deploy_qml_app_script(
  TARGET ${EXECUTABLE_NAME}
  OUTPUT_SCRIPT deploy_script
  MACOS_BUNDLE_POST_BUILD
  NO_UNSUPPORTED_PLATFORM_ERROR
  DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
  DEPLOY_TOOL_OPTIONS ${deploy_tool_options_arg}
)

install(SCRIPT ${deploy_script})

install(CODE [[
    if("$ENV{VCPKG_TARGET_TRIPLET}" STREQUAL "")
      find_program(QMAKE qmake6 HINTS $ENV{PATH})
      if(NOT QMAKE)
        message(FATAL_ERROR "qmake not found in PATH")
      else()
        message(STATUS "qmake found: ${QMAKE}")
        cmake_path(GET QMAKE PARENT_PATH QT_BIN_DIR)
        message(STATUS "qmake bin dir: ${QT_BIN_DIR}")
      endif()
    else()
      set(QT_BIN_DIR "$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/bin")
      if(NOT EXISTS ${QT_BIN_DIR})
        message(FATAL_ERROR "qmake bin dir not found: ${QT_BIN_DIR}")
      endif()
    endif()
    file(GET_RUNTIME_DEPENDENCIES
        RESOLVED_DEPENDENCIES_VAR RESOLVED_DEPS
        UNRESOLVED_DEPENDENCIES_VAR UNRESOLVED_DEPS
        # path to the executable files
        EXECUTABLES $<TARGET_FILE:sample_app>
        # directories to search for library files
        DIRECTORIES ${QT_BIN_DIR}
        PRE_EXCLUDE_REGEXES "system32" "api-ms-*"
        POST_EXCLUDE_REGEXES "system32"
    )
    foreach(DEP_LIB ${RESOLVED_DEPS})
        file(INSTALL ${DEP_LIB} DESTINATION ${CMAKE_INSTALL_PREFIX}/bin)
    endforeach()
]])

install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/resources" DESTINATION .)

注意在install(CODE)模块之后使用了install(DIRECTORY)复制资源文件,这里的目标路径要设置为相对路径.,不能用绝对路径比如${CMAKE_INSTALL_PREFIX},否则cpack打包的时候会报错。因为cpack调用install模块复制安装文件的时候不能指定绝对路径,但吊诡的是install(CODE)里面的绝对路径打包的时候居然不报错,而且install(CODE)里面也只能使用${CMAKE_INSTALL_PREFIX}表示绝对路径,否则运行cmake install安装的时候路径又会出错。

cmake/packaging.cmake模块定义了如何打包安装文件。

# --- cmake/packaging.cmake ---

include(InstallRequiredSystemLibraries)

set(CPACK_PACKAGE_NAME "${PROJECT_NAME}-installer")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
set(CPACK_PACKAGE_CONTACT "author_email@example.com")
set(CPACK_PACKAGE_VENDOR "author_name")

set(CPACK_PACKAGE_EXECUTABLES "${EXECUTABLE_NAME}" "${EXECUTABLE_NAME}")
set(CPACK_CREATE_DESKTOP_LINKS "bin/${EXECUTABLE_NAME}")

set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "A demo qt quick application with CMake packaging")
set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}")

set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/resources/LICENSE" CACHE FILEPATH "License file")
set(CPACK_WIX_LICENSE_RTF "${CMAKE_CURRENT_SOURCE_DIR}/resources/LICENSE.rtf" CACHE FILEPATH "License file in RTF format")
set(CPACK_RESOURCE_FILE_README "${CMAKE_CURRENT_SOURCE_DIR}/resources/README.md" CACHE FILEPATH "Readme file")

set(CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}")
if(NOT WIN32)
    set(CPACK_PACKAGING_INSTALL_PREFIX "/opt")
endif()

include(CPack)

这里和之前一样,通过include(InstallRequiredSystemLibraries)自动搜索系统依赖,但是也仅限于系统依赖,不会自动复制qt的依赖。qt的依赖在cmake/dependencies.cmake里面已经通过qt_generate_deploy_qml_app_script发布脚本自动复制了一遍。如果换成其他库,比如wxWidgets或者gtk,没有定义发布脚本的话,那么很大可能打包的程序就只有单独的可执行文件,根本不会复制wxWidgets或者gtk的依赖文件。

2. 问题示例

通过以下命令安装和打包,可以比较一下上面的脚本CMake安装和CPack打包效果的区别,这里用msys2的clang64工具链编译输出。

$Env:PATH += ";D:\conda\envs\cpp_env\NSIS"
cmake -B build .
cmake --build build --config Release
cmake --install build --config Release --prefix $PWD/install
cd build
cpack -G "NSIS"

比较CMake安装和CPack打包两个输出目录的差异,使用msys2运行diff工具检查。

$ diff -r `cygpath -u 'D:\example\cpack\qml_sample\install'` `cygpath -u 'D:\example\cpack\qml_sampl
e\build\_CPack_Packages\win64\NSIS\sample-installer-0.1-Windows'`
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:double-conversion.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:freetype.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libb2-1.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libbrotlicommon.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libbrotlidec.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libbz2-1.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libc++.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:libcrypto-3-x64.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libdouble-conversion.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libfreetype-6.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libglib-2.0-0.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libgraphite2.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libharfbuzz-0.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libiconv-2.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libicudt77.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libicuin77.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libicuuc77.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libintl-8.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libmd4c.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libpcre2-16-0.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libpcre2-8-0.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:libpng16.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libpng16-16.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:libzstd.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:msvcp140.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:msvcp140_1.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:msvcp140_2.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:pcre2-16.dll
二进制文件 /d/example/cpack/qml_sample/install/bin/Qt6Core.dll 和 /d/example/cpack/qml_sample/build/
_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin/Qt6Core.dll 不同
二进制文件 /d/example/cpack/qml_sample/install/bin/Qt6Gui.dll 和 /d/example/cpack/qml_sample/build/_
CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin/Qt6Gui.dll 不同
二进制文件 /d/example/cpack/qml_sample/install/bin/Qt6Network.dll 和 /d/example/cpack/qml_sample/bui
ld/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin/Qt6Network.dll 不同
二进制文件 /d/example/cpack/qml_sample/install/bin/Qt6Qml.dll 和 /d/example/cpack/qml_sample/build/_
CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin/Qt6Qml.dll 不同
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:vcruntime140.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:vcruntime140_1.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:zlib.dll
只在 /d/example/cpack/qml_sample/install/bin 中存在:zlib1.dll
只在 /d/example/cpack/qml_sample/build/_CPack_Packages/win64/NSIS/sample-installer-0.1-Windows/bin
中存在:zstd.dll

可以看到CPack打包目录下的bin文件夹少了很多依赖库,包括clang的运行时依赖libc++.dll,导致CPack打包的程序不能正常启动。而CMake安装程序则没有这个问题,可以正常启动。从这里也能看到,CMakeInstallRequiredSystemLibraries模块不会自动复制msys2工具链的依赖库,而且InstallRequiredSystemLibraries更优先从系统PATH中获取依赖,造成某些依赖库的文件二进制不同。

3. 改进代码

这里将cmake/packaging.cmake里面的include(InstallRequiredSystemLibraries)注释掉,其他保持不变。

# --- cmake/packaging.cmake ---

# include(InstallRequiredSystemLibraries)
...

改进cmake/dependencies.cmake,不再使用install(CODE)代码块复制依赖,改用install(RUNTIME_DEPENDENCY_SET)install(IMPORTED_RUNTIME_ARTIFACTS)替代之。

# --- cmake/dependencies.cmake ---
...
if("$ENV{VCPKG_TARGET_TRIPLET}" STREQUAL "")
  find_program(QMAKE qmake6 HINTS $ENV{PATH})
  if(NOT QMAKE)
    message(FATAL_ERROR "qmake not found in PATH")
  else()
    message(STATUS "qmake found: ${QMAKE}")
    cmake_path(GET QMAKE PARENT_PATH QT_BIN_DIR)
    message(STATUS "qmake bin dir: ${QT_BIN_DIR}")
  endif()
else()
  set(QT_BIN_DIR "$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/bin")
  if(NOT EXISTS ${QT_BIN_DIR})
    message(FATAL_ERROR "qmake bin dir not found: ${QT_BIN_DIR}")
  endif()
endif()

install(RUNTIME_DEPENDENCY_SET qt_deps
  PRE_EXCLUDE_REGEXES "system32" "api-ms-*"
  POST_EXCLUDE_REGEXES "system32"
  DIRECTORIES ${QT_BIN_DIR}
)
install(IMPORTED_RUNTIME_ARTIFACTS ${EXECUTABLE_NAME}
  RUNTIME_DEPENDENCY_SET qt_deps
  DESTINATION ${CMAKE_INSTALL_BINDIR}
)
...

这种写法避免了编译期和安装期变量不一致的问题。使用install(CODE)安装期无法获取cmake配置编译期间输入的变量,只能通过环境变量传入参数,类似${PROJECT_NAME}的CMAKE变量在其内部都会失效变成未定义。上面的写法则避免了这个问题,感兴趣的话还可以把vcpkg的环境变量参数改写成配置期的输入变量,这里不做过多展开了。

关于动态链接库文件的安装路径,使用第二种写法也就是install(RUNTIME_DEPENDENCY_SET)+install(IMPORTED_RUNTIME_ARTIFACTS)的写法,最后安装的动态链接库文件,在linux平台上位于安装目录的lib文件夹中(可能需要手动指定LD_LIBRARY_PATH环境变量确保库文件搜索路径,比如wxWidgets),而Windows平台下则是位于bin文件夹中。而第一种写法已经指定了${CMAKE_INSTALL_PREFIX}/bin路径,不会出现这种歧义。

4. 改进效果

安装和打包命令同2. 问题示例

比较CMake安装和CPack打包目录的差异,没有任何区别,都能正常启动。

$ diff -r `cygpath -u 'D:\example\cpack\qml_sample\install'` `cygpath -u 'D:\example\cpack\qml_sampl
e\build\_CPack_Packages\win64\NSIS\sample-installer-0.1-Windows'`

65d5ae74dda5617b871783b97e3a6c93.png

5. 定位搜索路径

上面的cmake/dependencies.cmake模块还有个细节,复制依赖库的时候先搜索qmake工具,再通过qmake工具获取Qt的安装路径,然后从该安装路径中搜索安装二进制依赖库。这和CMake搜索Qt库的方式也是一样的,先定位qmake的路径再定义其他变量。这种方式可以最大程度确保安装的二进制依赖库不受系统PATH的"污染"。

对于其他库,比如wxWidget或者gtk4,可以通过定位wxrcgtk4-demo来确保路径准确。

不过也存在例外,比如vcpkg的运行时路径和工具路径是分开的,需要单独考虑。

# --- cmake/dependencies.cmake ---
...
if("$ENV{VCPKG_TARGET_TRIPLET}" STREQUAL "")
  find_program(GTK4DEMO gtk4-demo HINTS $ENV{PATH})
  if(NOT GTK4DEMO)
    message(FATAL_ERROR "gtk4-demo not found in PATH")
  else()
    message(STATUS "gtk4-demo found: ${GTK4DEMO}")
    cmake_path(GET GTK4DEMO PARENT_PATH GTK4_BIN_DIR)
    message(STATUS "gtk4-demo bin dir: ${GTK4_BIN_DIR}")
  endif()
else()
  set(GTK4_BIN_DIR "$ENV{VCPKG_ROOT}/installed/$ENV{VCPKG_TARGET_TRIPLET}/bin")
  if(NOT EXISTS ${GTK4_BIN_DIR})
    message(FATAL_ERROR "gtk4-demo bin dir not found: ${GTK4_BIN_DIR}")
  endif()
endif()
...

6. 搜索安装资源文件

对于资源文件,比如gtk4程序发布除了复制二进制依赖之外还需要复制应用资源文件的1,可以通过install(DIRECTORY)install(FILES)的方式拷贝发布目录或文件,目标路径注意需要用相对路径.替代${CMAKE_INSTALL_PREFIX}

# --- cmake/dependencies.cmake ---
...
cmake_policy(SET CMP0177 NEW)

function(install_dir SEARCH_DIR DESTINATION_DIR SUB_DIR PATTERN)
  if(IS_DIRECTORY "${SEARCH_DIR}/${SUB_DIR}")
    file(GLOB DIRECTORIES LIST_DIRECTORIES true RELATIVE "${SEARCH_DIR}/${SUB_DIR}" "${SEARCH_DIR}/${SUB_DIR}/${PATTERN}")
    foreach(DIR ${DIRECTORIES})
      if(IS_DIRECTORY "${SEARCH_DIR}/${SUB_DIR}/${DIR}")
        install(DIRECTORY "${SEARCH_DIR}/${SUB_DIR}/${DIR}" DESTINATION "${DESTINATION_DIR}/${SUB_DIR}")
        message(STATUS "Install copy dir: ${SEARCH_DIR}/${SUB_DIR}/${DIR} -> ${DESTINATION_DIR}/${SUB_DIR}")
      endif()
    endforeach()
  endif()
endfunction()

cmake_path(GET GTK4_BIN_DIR PARENT_PATH GTK_SEARCH_DIRS)
install_dir(${GTK_SEARCH_DIRS} . "etc" "*gtk-4*")
install_dir(${GTK_SEARCH_DIRS} . "share" "*gtk-4*")
install_dir(${GTK_SEARCH_DIRS} . "share" "themes")
install_dir(${GTK_SEARCH_DIRS} . "share" "icons")
install_dir("${GTK_SEARCH_DIRS}/share" "./share" "glib-2.0" "schemas")

install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/resources" DESTINATION .)
...

这里设置cmake_policy(SET CMP0177 NEW),避免出现因install()目标路径为相对路径...导致的错误,但是CMake版本必须>=3.31,否则会报错。