原文:
zh.annas-archive.org/md5/24d02b6780ccd97288f1c67371ea0295
译者:飞龙
第六章:安装依赖项和ExternalProject_Add
在本章中,我们将深入探讨FetchContent
来下载其他库,它们最终还是会进入相同的构建文件夹。安装稍微不同。安装时,我们会在构建时将库与应用程序完全分离。然后,我们会采取第二步,将其安装到一个应用程序能够找到的位置。安装可能听起来神秘,但本质上只是将一组文件从一个位置复制到另一个位置(尽管需要遵循既定的约定)。
一旦我们熟悉了手动构建和安装库,我们将探讨如何利用ExternalProject_Add
显著减少安装时所需的手动步骤。这将使我们能够更清洁地将外部库与我们不断发展的应用程序集成。幸运的是,学习的新命令不多,当你完成了一次过程后,它可以轻松地转移到其他项目中。
在本章中,我们将涵盖以下主要主题:
-
什么是安装
-
安装一个库
-
使用已安装的库
-
使用
ExternalProject_Add
简化安装 -
使用
ExternalProject_Add
处理多个库
技术要求
为了跟随本书内容,请确保你已满足第一章《入门》一节中列出的要求。这些要求包括以下内容:
-
一台运行最新操作 系统(OS)的 Windows、Mac 或 Linux 机器
-
一个可用的 C/C++编译器(如果你没有,建议使用系统默认的编译器)
本章中的代码示例可以通过以下链接找到:github.com/PacktPublishing/Minimal-CMake
。
什么是安装?
安装,本质上就是将文件从一个地方复制到另一个地方。一旦这些文件被复制到特定位置,应用程序(或其他库)在构建时就可以在那里查找它们。
安装在实践中有几个优点。第一个优点是,你可以构建一次库,只将必要的文件安装到已知位置,然后让多个应用程序使用它。这可以节省大量时间和资源,避免不必要地重复构建相同的代码。另一个优点是,只有所需的文件才会被复制到安装位置。当我们正常构建时,构建文件夹会充满许多中间文件,这些文件应用程序可能不需要(取决于我们的库)。而当我们安装时,我们只会指定必要的文件(通常是构建后的库文件,如.lib
/.a
或.dll
/.dylib
/.so
、头文件和 CMake 配置文件)。我们还可以通过只安装我们希望公开的头文件,并以比内部构建结构更简单的布局,来更精确地控制库的接口。
默认情况下,当我们安装一个库时,文件会被复制到预定的系统位置。在 macOS 和 Linux 上,这通常是/usr/local/lib
(用于库文件)、/usr/local/include
(用于头文件)、/usr/local/bin
(用于可执行文件)和/usr/local/share
(用于任何类型的文档或许可证文件)。在 Windows 上,这通常是C:/Program Files (x86)/<library-name>
,库名称下会有lib
、include
、share
和bin
子文件夹。当我们进入安装库的阶段时,我们将更详细地回顾文件夹结构,并查看哪些文件被包含。
要找到已安装的库,CMake 需要知道在哪里查找。将库安装到前面提到的默认位置之一的好处是,CMake 已经知道在哪里搜索,因此在配置依赖该库的项目时,我们不需要提供其他信息。此方法的一个缺点是它会改变我们运行的全局主机环境,这可能并非始终是你想要的。这涉及到缺乏隔离性,我们将在稍后的安装库部分中展示如何解决这个问题。另一个需要注意的问题是,安装到系统位置通常需要提升的权限,而库的构建者可能没有这些权限。例如,在 Windows 上将库安装到C:\Program Files\
需要管理员权限。
接下来,我们将查看下载和安装库所需的步骤。
安装库
在使用已安装的库之前,我们先使用 CMake 安装一个库。我们将选择一个库,用于我们生命游戏应用程序,继续改进其功能;我们将使用的库叫做Simple Directmedia Layer(SDL)。SDL 是一个跨平台的窗口库,支持输入、图形、音频等多种功能。SDL 2 是最新的稳定版本,尽管在撰写本文时,SDL 3 已提供预发布版本供试用。SDL 2 以 zlib 许可证发布,允许在任何类型的软件中自由使用。要了解更多关于 SDL 的信息,请访问www.libsdl.org/
。
SDL 是一个开源项目,方便地托管在 GitHub 上;可以通过访问github.com/libsdl-org/SDL
来访问。从 SDL 的 GitHub 主页开始,通过点击ch6/part-1/third-party
来复制.git
URL(作为提醒,Minimal CMake的配套示例可以通过访问github.com/PacktPublishing/Minimal-CMake
找到)。
运行以下命令将仓库克隆到third-party/sdl
(如果你更愿意的话,可以使用clone.sh/.bat
脚本,还有一些其他便捷脚本包含了配置和构建库所需的命令):
git clone https://github.com/libsdl-org/SDL.git sdl
我们创建了新的third-party
文件夹,作为app
和lib
的同级目录,用来存放外部依赖项。这是为了将代码在当前章节中逻辑上分组,但在实际项目中,如果更方便的话,它可以被移动到顶层文件夹。为了避免 SDL 仓库被嵌套在Minimal CMake
仓库中产生问题,third-party
文件夹的.gitignore
文件中已加入了sdl
。我们本可以使用 Git 子模块并运行git submodule init
和git submodule update
,但这里的目的是展示手动安装库的每一步。如果在自己的项目中简化配置,您可以自由使用 Git 子模块,但在使用之前,请务必阅读后面的章节,使用 ExternalProject_Add 简化安装,以查看 CMake 提供的另一种替代方案。
克隆完 SDL 仓库后,在构建之前,我们需要确保使用正确的 SDL 版本。SDL 的默认分支(main
)现在是 SDL 3,但由于此版本仍处于预发布阶段且在积极开发中,我们将使用 SDL 2 进行我们的生命游戏项目。在写这篇文章时,最新版本是2.30.2
。
将目录切换到sdl
文件夹并检查最新的稳定版本(如果使用了clone.sh/.bat
脚本,您已经在正确的分支上):
cd sdl
git checkout release-2.30.2
要找到最新的版本,您可以从 SDL GitHub 仓库中点击release-2.XX.X
行(可以使用release-2.30.2
,示例就是基于这个版本进行测试的)。选择正确的版本后,回到third-party
目录(cd ..
)。
仅克隆我们需要的内容
为了避免克隆整个仓库并执行额外的检查特定标签的步骤,可以改用git clone https://github.com/libsdl-org/SDL.git --branch release-2.30.2 --depth 1 sdl
命令。这样只会克隆我们需要的分支并执行浅克隆,省略除最新 Git 提交以外的所有内容。这样可以将仓库大小从大约 187MB 减少到 91MB,节省了大约 50%。clone.sh
/bat
脚本使用这种方法,可以替代前面的手动步骤。
为了将构建文件夹放在 SDL 源代码树之外,让我们从third-party
文件夹运行 CMake,并将源代码和构建目录的位置传递给 CMake(分别为sdl
和build-sdl
):
cmake -S sdl -B build-sdl -G "Ninja Multi-Config"
执行此命令将为 SDL 配置并生成构建文件,就像我们在前几章的示例中所做的那样。CMake 将输出大量来自 SDL 的诊断信息,显示它为哪个架构构建,能够找到哪些编译器功能以及可以访问哪些标准库函数。这些信息对于了解 SDL 将使用哪些功能以及在出现问题时帮助诊断非常有用。
运行命令后,您应该会在输出的末尾看到以下内容:
...
-- Configuring done (20.7s)
-- Generating done (0.1s)
-- Build files have been written to: path/to/minimal-cmake/ch6/part-1/third-party/build-sdl
在执行构建之前,我们漏掉了一个重要的参数。当我们最初配置时,讨论了 CMake 如果没有指定覆盖位置,默认会安装到一个位置。这有时是你想要的,但一个很大的缺点是这样做会导致项目的构建不再是自包含的。你在项目的外部进行写操作,可能会对系统上的其他应用程序造成无意的更改。
解决此问题的一种方法是为项目选择某种容器化或虚拟化方案(例如,为Minimal CMake创建一个虚拟机,所有必需的依赖项可以安装在默认系统位置)。这样可以保持隔离,但需要更多的时间和精力来设置。幸运的是,还有一种替代方案。
配置 SDL 时,我们可以传递另一个命令行参数,称为CMAKE_INSTALL_PREFIX
:
cmake -S sdl -B build-sdl -G "Ninja Multi-Config" install in the same directory we’re running CMake from (this will have install appear alongside sdl and build-sdl):
└── 第三方
├── build-sdl
├── install
└── sdl
The main advantage of this approach is that we keep everything self-contained within our project. Nothing we do within the confines of *Minimal CMake* will affect the system overall in any way. For example, we won’t inadvertently install a library that overrides a system version that is already on our machine. One downside is that we may lose out a little on the ability to reuse this library when building other applications, but by installing SDL in this way, we’ve divided it from our main application, and we will not need to rebuild it if we decide to destroy and recreate our application’s build folder. It is a separate entity, distinct from *Minimal CMake*, and this is one of the advantages of installing SDL 2, instead of including it in our build as we did with other libraries and `FetchContent` in earlier chapters.
One other thing to note is we created a generic folder called `install`, not a folder called `install-sdl`. This is because it’s fine and often preferred to install multiple libraries in the same location. This makes depending on more than one library a lot simpler when telling CMake where to find the libraries. To learn more about `CMAKE_INSTALL_PREFIX`, see [`cmake.org/cmake/help/latest/variable/CMAKE_INSTALL_PREFIX.html`](https://cmake.org/cmake/help/latest/variable/CMAKE_INSTALL_PREFIX.html).
Ensure that you run the preceding CMake configure command including `-DCMAKE_INSTALL_PREFIX=install` (it will be a lot quicker the second time). It’s also worthwhile checking that the CMake `CMAKE_INSTALL_PREFIX` cache variable is set to the value you expect by using the CMake GUI, or opening `build-sdl/CMakeCache.txt` in a text editor and searching for `CMAKE_INSTALL_PREFIX`.
We are now able to build SDL 2 and install it into our `install` folder. There are two ways to perform this. The first is to provide the install target to CMake when building to have it build and then immediately install the library:
cmake --build build-sdl --target install 在构建命令之后,我们表示我们想要构建安装目标,该目标依赖于库的构建。因此,库必须首先构建,然后才能安装。其依赖图如下:
install -- depends --> SDL2
记住,由于我们使用的是多配置生成器,默认情况下,这将构建并安装`Debug`配置。要构建并安装库的`Release`版本,我们需要显式指定`Release`配置:
cmake --build build-sdl --target install --config Release
也可以使用单独的 CMake `install`命令安装库:
cmake --install build-sdl
要使此命令正常工作,首先需要构建库,因为在仅配置后运行该命令会生成以下错误:
CMake Error at build-sdl/cmake_install.cmake:50 (file):
file INSTALL cannot find
"/path/to/minimal-cmake/ch6/part-1/third-party/build-sdl/Release/libSDL2-2.0.0.dylib":
No such file or directory.
注意,默认情况下,`--install`命令会查找`Release`配置,而不是`Debug`配置。为了让安装命令按预期执行,首先构建库的`Release`版本,然后运行安装命令:
cmake --build build-sdl --config Release
cmake --install build-sdl
如果你构建了`Debug`或`RelWithDebInfo`版本的库,也可以将`--config`传递给`--install`命令来安装这些版本:
cmake --install build-sdl Release version of the library to get the best possible performance. It usually isn’t necessary to install the Debug version unless you need to debug a difficult-to-diagnose issue with how your application is interacting with the library. On Windows, link errors can occur due to conflicting symbols caused by a mismatch between runtime libraries used by the Debug and Release version of a library and application. A simple fix is to ensure that both the library and application are built with the same configuration.
The CMake `--install` command also provides a `--prefix` option to set or override the install directory. This can be useful if you forgot to provide `CMAKE_INSTALL_PREFIX` or want to install the library to a different location without reconfiguring:
cmake --install build-sdl CMAKE_DEBUG_POSTFIX 变量。通常将调试版本和发布版本的库安装到同一文件夹中会很方便(就像我们在安装目录中所做的那样)。如果我们先构建了库的调试版本并安装它,然后构建了发布版本并安装它,调试库文件将被覆盖。为了避免这种情况,CMake 可以将后缀添加到库的调试版本(通常约定使用小写字母 d)。这意味着在安装文件夹中,我们会看到如下内容(在 Windows 上,.dll 文件会在 bin 文件夹中,其他所有文件都会在 lib 文件夹中):
# macOS
libSDL2-2.0.dylib, libSDL2-2.0d.dylib, libSDL2.a, libSDL2d.a
# Windows
SDL2.dll, SDL2d.dll, SDL2.lib, SDL2d.lib
# Linux
libSDL2-2.0.so, libSDL2-2.0Debug or Release version depending on the configuration we’re building.
To summarize, to efficiently clone, build, and install SDL 2, run the following commands from the `ch6/part-1/third-party` folder:
git clone https://github.com/libsdl-org/SDL.git --branch release-2.30.2 --single-branch sdl --depth 1
cmake -S sdl -B build-sdl -G “Ninja Multi-Config” -DCMAKE_INSTALL_PREFIX=install
cmake --build build-sdl --config Release
cmake --install build-sdl --config Release
Several helper scripts have been added to `ch6/part-1/third-party` to automate this process. You can run `everything.sh`/`bat` to perform the preceding steps (`Debug` configs are also built and installed).
With SDL `2` installed to a known folder, we can now review our `CMakeLists.txt` file in `ch6/part-1/app` to see the changes needed to use the new dependency. We’ll walk through what these changes are in the next section.
Using an installed library
With our library installed, what remains is to integrate it into our existing application so we can start using the functionality provided by SDL. Let’s begin by looking at the changes made to our `CMakeLists.txt` file for our application.
We won’t share the entire file here as much of it is the same as before, but we’ll call out the significant changes as we go. To see a complete example, review `ch6/part-1/app/CMakeLists.txt` from the book’s accompanying repository.
The first, and perhaps most important addition, is as follows:
find_package(SDL2 CONFIG REQUIRED)
The `find_package` command is an incredibly useful tool to bring external dependencies into our project. In the example shown above, the first argument is the name of the dependency to find (in our case, this is `SDL2`). The name of the dependency is defined in one of two ways, and that is linked to the second parameter we’ve specified, `CONFIG`.
CMake search modes
The `find_package` command can run in one of two search modes: **Config** mode or **Module** mode. We’ll look at each in turn, starting with Config mode, which we’ll be using most often.
Config mode
It’s easiest to think of Config mode as the native way for CMake to search for packages. Config mode is the mode to use when the dependency has itself been built and installed using CMake. As part of the install process, a file with the `<package-name>-config.cmake` or `<PackageName>Config.cmake` name will have been created by CMake, and this is what CMake will search for. This file includes all the relevant information needed to use the library (the location of built artifacts, include paths, etc.).
If you want to have a look at the file generated for SDL 2, it can be found by going to `ch6/part-1/third-party/install/lib/cmake/SDL2/SDL2Config.cmake` after building and installing SDL 2\. Don’t worry too much about the contents of the file just yet; SDL 2 is quite a complex dependency, so there’s a lot going on. All that’s important for us is understanding that this file exists and why it’s needed. When we install our own library, we’ll walk through things in more detail.
Module mode
The second search mode `find_package` can run in is called Module mode. Instead of searching for a config file, CMake looks for a file called `Find<PackageName>.cmake`. CMake will search for these files in several default locations (listed in `CMAKE_MODULE_PATH`). It’s also possible to add more locations to `CMAKE_MODULE_PATH` if our `Find<PackageName>.cmake` file is found somewhere else.
`Find<PackageName>.cmake` files are hand-crafted files and something not usually generated by CMake. If we are in an ecosystem that uses libraries built by CMake, and we are building libraries or executables using CMake, we largely don’t need to think about Module mode.
The one big advantage to Module mode, however, is being able to integrate libraries that are not built using CMake with our project. The `Find<PackageName>.cmake` file is a bridge between CMake and other build systems. Writing a find module file is usually easier than porting a dependency to CMake (especially if it’s a dependency you have little control over), but for what we’ll be doing, we can mostly avoid them. We’ll show a simplified example of such a script in *Chapter 7*, *Adding Install Support for Your Libraries*, but to make them fully portable requires a lot of effort. Using CMake to generate config files for us tends to be a lot simpler and eliminates the need for us to maintain a `CMakeLists.txt` file and `Find<PackageName>.cmake` at the same time, removing the risk of these two files getting out of sync.
Returning to find_package
If we briefly return to the `find_package` command we added to our `CMakeLists.txt` file, we can cover the remaining arguments:
find_package(SDL2 CONFIG,我们在这里告诉 find_package 命令只查找配置文件,如果找不到依赖项,它不会回退到模块模式。这有助于确保我们能找到我们所需的确切依赖项。第三个参数 REQUIRED 告诉 CMake 如果找不到 SDL2,应该停止处理 CMakeLists.txt 文件。这主要有助于确保我们得到更清晰的错误信息,避免在无效状态下继续配置。
要开始使用 `SDL2`,我们唯一需要做的改动是将其添加到 `target_link_libraries` 命令中。现在的命令看起来像这样:
target_link_libraries(
${PROJECT_NAME} PRIVATE
timer_lib mc-gol SDL2::). This is a find_package command does all this for us behind the scenes; we just need to remember to link against SDL2::SDL2, not SDL2 (we also need SDL2::SDL2main as we’re creating an executable, and not only linking SDL2 to another library). This convention is useful to be able to see which libraries are external (imported), or not, in the target_link_libraries command.
There’s a final change we need to make to ensure things work correctly on Windows. As `SDL2`, by default, is built as a shared library, we need to copy the `SDL2` DLL file (`SDL2.dll` or `SDL2d.dll`) to the same directory as our application. We can do this in the exact same way as we did with `mc_gol.dll` by using the function that follows:
将 SDL2.dll 复制到与可执行文件相同的文件夹中
add_custom_command(
TARGET ${PROJECT_NAME}
POST_BUILD
COMMAND
${CMAKE_COMMAND} -E copy_if_different
$<TARGET_FILE:SDL2::SDL2>
< T A R G E T F I L E D I R : <TARGET_FILE_DIR: <TARGETFILEDIR:{PROJECT_NAME}>
VERBATIM)
That covers all changes our `CMakeLists.txt` file needs to start using SDL `2`. For a complete example, see `ch6/part-1/app/CMakeLists.txt` in the accompanying repository.
Informing CMake where to find our library
We now have everything we need to use SDL `2` from our application, but things won't work if we run our familiar CMake command as follows:
cmake -B build
When we do, we’ll see the following error printed:
CMake 错误位于 CMakeLists.txt 文件的第 4 行(find_package):
无法找到由 “SDL2” 提供的任何以下名称的包配置文件:
SDL2Config.cmake
sdl2-config.cmake
将 “SDL2” 的安装前缀添加到 CMAKE_PREFIX_PATH,或者将 “SDL2_DIR” 设置为包含上述文件之一的目录。如果 “SDL2” 提供了一个单独的开发包或 SDK,请确保已安装。
This is a good thing because we know that CMake can’t find the library we installed at the start of the chapter. If running `cmake -B build` succeeds, then it means CMake has found SDL 2 from another location that we may not be aware of. One example of this happening on macOS was CMake finding SDL 2 in `/opt/homebrew/lib/cmake/SDL2`, which had been installed as a dependency of `ffmpeg` (a cross-platform tool to record, convert, and stream both audio and video).
A sound piece of advice from the software testing community is to *see it fail*, as we then know precisely whether our next change was the thing to fix the problem or not. Otherwise, there’s no guarantee that it wasn’t something else. One way to verify that SDL 2 is found from the location we installed it in is to pass several additional arguments to `find_package`:
find_package(
SDL2 CONFIG REQUIRED
NO_CMAKE_ENVIRONMENT_PATH
NO_CMAKE_SYSTEM_PACKAGE_REGISTRY
NO_SYSTEM_ENVIRONMENT_PATH
NO_CMAKE_PACKAGE_REGISTRY
SDL2_DIR),它会显示依赖项所在的文件夹(对于我们来说,应该是 /path/to/minimal-cmake/ch6/part-1/third-party/install/lib/cmake/SDL2)。可以通过打开 ch6/part-1/app/build/CMakeCache.txt 文件并搜索 SDL2_DIR(或更一般地,<LIBRARY_NAME>_DIR),在 CMake GUI 中检查,或运行 cmake -L 快速列出所有 CMake 缓存变量(也可以使用 ccmake 从终端查看和编辑缓存变量,尽管如 第三章 所述,使用 FetchContent 管理外部依赖,这只适用于 macOS 和 Linux)。
提供库的位置
当我们配置应用程序时,需要告诉 CMake 在哪里找到我们安装的库。我们可以通过在配置步骤中使用命令行设置 `CMAKE_PREFIX_PATH` 来实现:
cmake -B build -DCMAKE_PREFIX_PATH=../third-party/install
CMake 现在能够找到我们在之前步骤中安装的库。
在早期版本的 CMake 中,需要提供 `CMAKE_PREFIX_PATH` 的绝对路径。可以通过在 macOS 和 Linux 上使用 `$(pwd)`,或在 Windows 上使用 `%cd%` 来解决这个问题:
# macOS/Linux
-DCMAKE_PREFIX_PATH=$(pwd)/../third-party/install
# Windows
-DCMAKE_PREFIX_PATH=3.28 and above (just keep this in mind if you encounter any issues finding libraries with CMAKE_PREFIX_PATH using earlier versions of CMake).
With that, we just need to build (`cmake --build build`), and we can now launch our latest incarnation of *Game of Life*. The application has been renamed to `minimal-cmake_game-of-life_window`, as we’ve now moved away from displaying our *Game of Life* simulation in the console/terminal to a full windowed application with the help of SDL. The full list of commands to see things running for yourself are as follows:
cd ch6/part-1/third-party
./everything.sh # (在 Windows 上是 everything.bat)
cd …/app
cmake -B build -DCMAKE_PREFIX_PATH=…/third-party/install
cmake --build build
`everything.sh/bat` more or less unwraps to the following:
git clone https://github.com/libsdl-org/SDL.git --branch release-2.30.2 --depth 1 sdl
cmake -S sdl -B build-sdl -G “Ninja Multi-Config” -DCMAKE_INSTALL_PREFIX=install
cmake --build build-sdl --config Release
cmake --install build-sdl --config Release
After running `minimal-cmake_game-of-life_window`, you should be rewarded with something resembling the following:
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_06_1.jpg>
Figure 6.1: The windowed output of Game of Life with SDL 2
If any problems are encountered when running the preceding steps, the first thing to check is the internet connection. As part of the configure step, CMake needs to download SDL 2, and if there’s no internet, this step will fail. This might not be immediately obvious but do keep it in mind when running these examples (if you are working on Linux, ensure you also have the dependency `libgles2-mesa-dev` installed on your system. This is mentioned in *Chapter 1**, Getting Started*, but is easy to miss. To install it, run `sudo apt-get install` `libgles2-mesa-dev` from your terminal).
The changes needed to display our *Game of Life* implementation are confined to `main.c`. They aren’t terribly important in the context of CMake, but for those who are interested, we first do some initial setup (`SDL_CreateWindow` and `SDL_CreateRenderer`). We then initialize our board as before and start our main loop, where we poll the SDL event loop, then perform our update logic. We’re using `SDL_RenderClear` to clear the display, `SDL_RenderFillRect` to draw the cells on our board, and `SDL_RenderPresent` to update the display. When a quit event is intercepted (the window corner cross is pressed or *Ctrl* + *C* is entered from the terminal), the application quits and cleans up the resources it created at the start.
CMakePreset improvements
We would be remiss not to mention how we can simplify the setup by utilizing CMake presets in our `app` folder. In our `CMakePresets.json` file, we can add `CMAKE_PREFIX_PATH` to our `cacheVariables` entry like so:
…
“cacheVariables”: {
“CMAKE_PREFIX_PATH”: “${sourceDir}/…/third-party/install”
}
This removes the need for us to pass `-DCMAKE_PREFIX_PATH` at the command line. We can now, just as before, run a command such as `cmake --preset shared-ninja` to generate a Ninja Multi-Config project using the shared version of our *Game of* *Life* implementation.
Using ExternalProject_Add to streamline installation
So far, we’ve used several of CMake’s lower-level commands to download, build, and install our new SDL 2 dependency. It’s important to understand this manual process to gain a deep appreciation of one of CMake’s most useful features, `ExternalProject_Add`.
To the uninitiated, using `ExternalProject_Add` can be quite confusing. One of the fundamental things to understand is that `ExternalProject_Add` must run as a separate step before you try to build a project that uses the dependencies it makes available. With everything we’ve just discussed when it comes to installing manually, this should now make more sense. `ExternalProject_Add` is essentially syntactic sugar to streamline the process we outlined. There are some clever ways to more tightly integrate it into our main build (see the discussion of super builds in *Chapter 8*, *Using Super Builds to Simplify Onboarding*), but for now, we’ll continue to keep it separate.
The `ch6/part-2/third-party` folder shows an initial transition from using our configure, build, and install shell scripts, to relying entirely on CMake and `ExternalProject_Add`. The `third-party` folder has a new `CMakeLists.txt` file that looks like the following:
cmake_minimum_required(VERSION 3.28)
project(third-party)
include(ExternalProject)
ExternalProject_Add(
SDL2
GIT_REPOSITORY https://github.com/libsdl-org/SDL.git
GIT_TAG release-2.30.2
GIT_SHALLOW TRUE
CMAKE_ARGS -DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>)
To start, we have the obligatory `cmake_minimum_required` and `project` commands, followed by an `include` command to bring in the `ExternalProject` module (this makes `ExternalProject_Add` and related functions available to us).
Next comes the `ExternalProject_Add` command itself. The first argument is the name of the project; in our case, this is `SDL2`. We then specify the location to find the source code using `GIT_REPOSITORY` (just as we did earlier with `FetchContent`), and a specific `GIT_TAG`, which maps to the version of the code we’d like to build (this is the same value we passed to the `--branch` argument in our `git clone` command earlier). We also specify `GIT_SHALLOW TRUE` to limit the number of files that need to be downloaded.
The last argument, `CMAKE_ARGS`, allows us to set the values to pass to CMake as if we were running it from the command line. In this case, we pass the same argument as we did when running `cmake -B build` ourselves: `-DCMAKE_INSTALL_PREFIX`. For now, instead of using the `install` folder we used earlier, we set the install folder to `<INSTALL_DIR>`. This is a default value provided by CMake in the context of `ExternalProject_Add`. There are several such values provided, and it’s useful to know what their default values map to:
TMP_DIR = /tmp
STAMP_DIR = /src/-stamp
DOWNLOAD_DIR = /src
SOURCE_DIR = /src/
BINARY_DIR = /src/-build
INSTALL_DIR =
LOG_DIR = <STAMP_DIR>
All paths listed are relative to the build folder. Here, `<prefix>` becomes `<Project>-prefix`, which is `SDL-prefix` in our case, so we see the following:
.
└── build
└── SDL2-prefix
├── bin
├── include
│ └── SDL2
├── lib
│ ├── cmake
│ └── pkgconfig
├── share
│ ├── aclocal
│ └── licenses
├── src
│ ├── SDL2
│ ├── SDL2-build
│ └── SDL2-stamp
└── tmp
For more information about the `ExternalProject_Add` folder structure, see [`cmake.org/cmake/help/latest/module/ExternalProject.html#directory-options`](https://cmake.org/cmake/help/latest/module/ExternalProject.html#directory-options).
All that’s needed to download, build, and install the dependency is to run `cmake -B build` and `cmake --build build`. To have our application find the installed library, we just need to update our `CMakePresets.json` file in `ch6/part-2/app` to have it point to the new location using `CMAKE_PREFIX_PATH`:
“cacheVariables”: {
“CMAKE_PREFIX_PATH”: “${sourceDir}/…/third-party/build/SDL2-prefix”
}
By leveraging `ExternalProject_Add`, we can reduce the maintenance overhead of our earlier approach and more tightly integrate our overall build with CMake. It’s worth reiterating, however, that we’re doing the exact same thing we were before with the manual install commands, just slightly more concisely.
Improving our use of ExternalProject_Add
Using `ExternalProject_Add` is a huge improvement over the more manual process we outlined at the start of the chapter, but there are a few further improvements we can still make.
The first thing we’re lacking is a way to set the build type of the `ExternalProject_Add` project. Unfortunately, things behave slightly differently when using single or multi-config generators. In the case of single-config generators, the build type (set with `-DCMAKE_BUILD_TYPE=<Config>` at configure time) is not passed through to the `ExternalProject_Add` command. When installing the library, we see the following displayed in the CMake output:
– 安装配置:“”
This shows that the build config has not been explicitly set, so there’s a little more work we need to do to properly support this.
In the case of multi-config generators, as the build type is provided at build time (with `cmake --build build --config <Config>`), we can build and install the configuration of our choosing (we also get a build folder per config too, which is another reason to prefer multi-config generators where possible).
In `ch6/part-3/third-party/CMakeLists.txt`, we’ve added the following block:
get_property(isMultiConfig GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(NOT isMultiConfig)
if(NOT CMAKE_BUILD_TYPE)
如果没有提供构建类型,则默认为 Debug(匹配 CMake 默认行为)
set(CMAKE_BUILD_TYPE
Debug
CACHE STRING “” FORCE)
endif()
为不同的构建类型分配自己的文件夹,适用于单配置生成器
set(build_type_dir ${CMAKE_BUILD_TYPE})
将构建类型参数传递给 ExternalProject_Add 命令
set(build_type_arg -DCMAKE_BUILD_TYPE=$)
endif()
We first check whether we’re using a multi-config generator; this is performed by querying the `GENERATOR_IS_MULTI_CONFIG` property. If we are, there’s nothing more to do, but if not, we first set `CMAKE_BUILD_TYPE` to `Debug` if it hasn’t already been provided, and we create two new CMake variables, `build_type_dir` and `build_type_arg` (we introduce these two new variables so they evaluate to nothing when using a multi-config generator). These map to the build type directory and the build type argument that are passed to CMake. We’re effectively reimplementing a version of multi-config generators, so if you can use a multi-config generator and avoid this, it’s likely easier, but as we want this to be used by as wide an audience as possible, accommodating single-config generators gracefully is a friendly thing to do.
With this defined, we must make some minor adjustments to our `ExternalProject_Add` command to take advantage of these new variables:
ExternalProject_Add(
…
BINARY_DIR C M A K E C U R R E N T B I N A R Y D I R / S D L 2 − p r e f i x / s r c / S D L 2 − b u i l d / {CMAKE_CURRENT_BINARY_DIR}/SDL2-prefix/src/SDL2-build/ CMAKECURRENTBINARYDIR/SDL2−prefix/src/SDL2−build/{build_type_dir}
INSTALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/install
CMAKE_ARGSBINARY_DIR 指向与当前配置相对应的文件夹(我们会得到与多配置生成器相同的布局),并在 CMAKE_ARGS 中传递构建类型(例如,CMAKE_BUILD_TYPE=Debug)以供配置时使用。
如果我们通过运行以下命令使用单配置生成器进行测试:
cmake -B build -G Ninja
cmake --build build
我们将在安装输出中看到这一点,而不是像之前那样的空字符串:
-- Install configuration: "Debug"
我们可以显式地指定不同的配置,例如以下内容:
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
然后我们将在安装输出中看到以下内容:
-- Install configuration: part-1) to have installed files go to a shared install folder (see ch6/part-3/third-party/CMakeLists.txt for a full example).
Handling multiple libraries with ExternalProject_Add
For the final example in this chapter, we’re going to look at bringing in another larger dependency. The dependency in question is a graphics library called `bgfx` ([`github.com/bkaradzic/bgfx`](https://github.com/bkaradzic/bgfx)). `bgfx` is a cross-platform rendering library that works across Windows, macOS, and Linux, as well as many other platforms including iOS, Android, and consoles. It’s a large project that can take some time to build, which makes it a perfect candidate for installing and using with `ExternalProject_Add`.
As we’re still writing our application in C, we need to use the C interface provided by `bgfx`, and to do so, we must use it as a shared library. One issue with this is that we need to build the shaders we’re going to load using a tool built as part of `bgfx` called `shaderc`, and this needs `bgfx` to be compiled statically (at least on Windows). To account for this, we have two references to `bgfx`: `bgfx` and `bgfxt` (we’ve added the `t` postfix for tools). We compile one version of the library statically and ensure that the tools get built and installed to our `install/bin` directory. Then we build a shared version of the library for use by our application. The setup in `ch6/part-4/third-party/CMakeLists.txt` isn’t perfect – we wind up having to clone the library twice – but it’s a reasonably simple solution, and we only need to do it once and can then forget about it. Building `bgfx` might take a little while to complete, as it’s by far the largest dependency we’ve used so far. Using Ninja Multi-Config as the generator should help, and if you are using a different generator, it’s possible to pass `--parallel <jobs>` after the `cmake --build build` command to ensure that you’re making the most of your available cores.
Shaders
Shaders are special programs that run on a device’s **Graphics Processing Unit** (**GPU**). They perform a wide variety of operations and can become exceedingly complex, achieving all manner of interesting graphical effects. Our application provides basic shader implementations. The first is a vertex shader, responsible for moving/positioning our geometry (often called transforming). The second is a pixel (or fragment) shader, which decides what color the pixels on our screen should be. To learn more about shaders and graphics programming in general, visit [`learnopengl.com/`](https://learnopengl.com/) for a great introduction. It’s OpenGL-specific, but many of the concepts can be applied to any graphics API.
The updated `CMakeLists.txt` file now has two additional `ExternalProject_Add` calls:
ExternalProject_Add(
bgfxt
GIT_REPOSITORY https://github.com/bkaradzic/bgfx.cmake.git
GIT_TAG v1.127.8710-464
GIT_SHALLOW TRUE
BINARY_DIR C M A K E C U R R E N T B I N A R Y D I R / b g f x t − b u i l d / {CMAKE_CURRENT_BINARY_DIR}/bgfxt-build/ CMAKECURRENTBINARYDIR/bgfxt−build/{build_type_dir}
INSTALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/install
CMAKE_ARGS ${build_type_arg} -DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
CMAKE_CACHE_ARGS -DCMAKE_DEBUG_POSTFIX:STRING=d)
ExternalProject_Add(
bgfx
GIT_REPOSITORY https://github.com/bkaradzic/bgfx.cmake.git
GIT_TAG v1.127.8710-464
GIT_SHALLOW TRUE
DEPENDS bgfxt
BINARY_DIR C M A K E C U R R E N T B I N A R Y D I R / b g f x − b u i l d / {CMAKE_CURRENT_BINARY_DIR}/bgfx-build/ CMAKECURRENTBINARYDIR/bgfx−build/{build_type_dir}
INSTALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/install
CMAKE_ARGS ${build_type_arg} -DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
-DBGFX_LIBRARY_TYPE=SHARED -DBGFX_BUILD_TOOLS=OFF
-DBGFX_BUILD_EXAMPLES=OFF
CMAKE_CACHE_ARGS -DCMAKE_DEBUG_POSTFIX:STRING=d)
Most of the arguments are the same as with the `SDL2` `ExternalProject_Add` command (`GIT_REPOSITORY`, `GIT_TAG`, `GIT_SHALLOW`, etc.), just with `bgfx`-specific values. The `BINARY_DIR` path has been condensed slightly for no other reason than to not exceed the 250-character path limit set by CMake on Windows (this isn’t usually a problem, but with this example already being somewhat nested in the file structure, it can occasionally cause problems). We install `bgfx` to the same `third-party/install` directory as `SDL2`, which means that we don’t need to append another folder path to `CMAKE_PREFIX_PATH` in our `CMakePresets.json` file inside our `part-4/app` folder.
One extra argument we pass is `-DCMAKE_DEBUG_POSTFIX:STRING=d`, which ensures that debug versions of the library get a `d` appended to them (e.g., `bgfx` for `Release` becomes `bgfxd` in `Debug`). This is handy to allow both versions of the library to be installed to the same location without stomping on one another (SDL 2 already does this by default, but we need to pass this setting to `bgfx`). We use `CMAKE_CACHE_ARGS` for this to specify cache variables. Behind the scenes, this argument is converted to a CMake `set` call with `FORCE` provided to guarantee that no entry in the cache can override this value.
The next call is remarkably similar to the earlier `bgfxt` command. We just pass several extra arguments when configuring the `bgfx` library to build it as a shared library (and disable building any of the examples and tools):
-DBGFX_LIBRARY_TYPE=SHARED -DBGFX_BUILD_TOOLS=OFF
-DBGFX_BUILD_EXAMPLES=OFF
The last addition worth mentioning is our use of `DEPENDS`. This allows `ExternalProject_Add` commands to define an ordering, so we know `bgfxt` will be built before `bgfx` starts building. In our case, this guarantees that we install the shared library after the static one. This can be useful when you are building multiple libraries at once that may depend on one another. For example, let’s say that we were building `libcurl` ([`curl.se/libcurl`](https://curl.se/libcurl)), an excellent networking library, which itself depends on `OpenSSL` ([`www.openssl.org`](https://www.openssl.org)), which is used for secure network communication. We must ensure that `OpenSSL` is built first, so `libcurl` would need `DEPENDS OpenSSL` added to its `ExternalProject_Add` command.
Running the bgfx example
After building `bgfx` as our new external dependency, one step remains before we can run the application. `bgfx` requires us to provide shaders for our application to transform our geometry and color the pixels to display. We need to compile the shaders before running our application, and to perform this step, we need to run the `compile-shader-<os>.sh/bat` file. This internally invokes `shaderc` (one of the tools built when we compiled `bgfx` statically) and passes parameters specific to the platform (DirectX 11 on Windows, Metal on macOS, and OpenGL on Linux). Compiling the shader source files in `ch6/part-4/app/shader` will produce binary files we’ll load in the main application, located in the `shader/build` subdirectory. It’s not necessary to understand these files in detail – just think of them as resources our application needs to be aware of. Dealing with these files and how to handle loading them will be something we’ll revisit when installing, and later packaging, our application.
Once the shaders are built, we can then build and run our application. It’s fine to compile the application before the shaders, we just need to make sure to build the shaders before attempting to run the application. One thing to note is that for now, we need to run the application from the source folder (the location of `main.c`), as our compiled binary files are loaded relative to that folder. This means ensuring that we start the application from `ch6/part-4/app`, such as in the example below:
./build/shared-ninja/Debug/minimal-cmake_game-of-life_window
If we don’t do this (for example, by changing the directory to the `/build/shared-ninja/Debug` folder from the preceding example), then the shader files we’re loading (`fs_vertcol.bin` and `vs_vertcol.bin`) won’t be found. As mentioned earlier, we’ll cover ways to address this when we discuss installing and packaging in *Chapter 10**, Packaging the Project* *for Sharing*.
It’s possible to set the working directory for our application when using other tools such as Visual Studio Code, Xcode, Visual Studio, and CLion. To do this in Visual Studio Code, open the `ch6/part-4/app` as its own project by running `code .` from that folder. Then open `launch.json` and set `"program"` to the location of the executable, and `"cwd"` to `"${workspaceFolder}"` (see *Chapter 11*, *Supporting Tools and Next Steps*, for a more detailed walkthrough of how to set this up if required).
Running the application from `ch6/part-4/app` will again display *Game of Life*, but this time drawing with `bgfx` instead of `SDL_Renderer`.
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_06_2.jpg>
Figure 6.2: The windowed output of Game of Life with bgfx
The `ch6/part-4/app/main.c` file has changed quite a bit from `part-3` (another reminder to use the excellent **Compare Active File With...** in Visual Studio Code to view the differences). The changes aren’t important in the context of CMake but might be interesting for those wanting to learn more about (very simple) graphics programming.
Summary
A round of applause for reaching this milestone. Installing dependencies is a key skill to master when using CMake, which is why we learned how to do this now. It’s essential to understand what is happening behind the scenes when we install a library and then try to use it from our main application. We outlined several ways to install libraries and discussed various pros and cons of each approach. We then looked at how to integrate those installed libraries into our application. Afterward, we introduced `ExternalProject_Add` and saw how it can simplify installing larger external dependencies in a structured way. Finally, we walked through a slightly more complex example, making use of multiple dependencies and build stages.
In the next chapter, we’ll be changing sides and delving into how we can provide installation support for our own libraries. The CMake install commands are notoriously difficult to understand and can appear quite obscure on first inspection. We’ll walk through each command in detail and look at installing our *Game of Life* library, as well as a helper rendering library. We’ll also be introduced to our first find module file.
第七章:为你的库添加安装支持
在第六章,《安装依赖项和 ExternalProject_Add》中,我们讨论了如何安装现有库并在项目中使用它们。了解如何使用已安装的库非常有用,特别是当与ExternalProject_Add
结合使用时。现在,我们将转变思路,看看如何为我们自己的库添加安装支持。这是一个庞大的话题,提供了许多不同的选项。我们无法涵盖所有内容,但我们将着重介绍 CMake 的install
命令,它的作用以及如何使用它,还会介绍一些可用的配置选项。
在本章中你将学到的技能,如果你以后选择使自己的库可安装并通过ExternalProject_Add
或将你的应用打包,都会非常有用。通过这样做,你将使其他开发者更容易使用你的库,并可能避免他们多次构建它。
本章我们将涵盖以下主要内容:
-
为库添加安装支持
-
处理嵌套依赖项
-
何时以及如何使用
COMPONENTS
-
支持不同版本的库
-
编写查找模块文件
技术要求
为了继续学习,请确保你已经满足第一章,《入门》中列出的要求。包括以下内容:
-
一台运行最新操作系统(OS)的 Windows、Mac 或 Linux 机器
-
一个有效的 C/C++编译器(如果你还没有,建议使用系统默认的编译器)
本章中的代码示例可以通过以下链接找到:github.com/PacktPublishing/Minimal-CMake
。
为库添加安装支持
为库添加安装支持时,一切都围绕着install
命令。install
命令是我们告诉 CMake 要安装什么内容,以及文件的相对布局。好消息是,我们现有的CMakeLists.txt
文件几乎不需要做什么更改,并且在大多数情况下,也不需要添加太多内容。不过,第一次看到我们添加的内容时可能会有些困惑,我们将在这里尽量解释清楚。
与前几章一样,我们将通过一个具体的示例,展示如何为我们现有的最简单库之一mc-array
添加安装支持。这个静态库提供了在 C 语言中支持可调整大小的数组(非常类似于 C++中的std::vector
)。我们在整个《生命游戏》应用中都使用了它,它是一个特别有用的工具。
我们将从查看ch7/part-1/lib/array/CMakeLists.txt
开始。第一个变化是,我们已经明确通过提供STATIC
参数将这个库提交为静态库,来进行使用:
add_library(${PROJECT_NAME} mc-gol in *Chapter 4*, *Creating Libraries for FetchContent*), and we won’t be providing install support for a shared library either (we’ll cover the differences later when looking at adding install support to our mc-gol library). We could have added STATIC at the outset, but making these gradual improvements over time is never a bad thing.
Next, we include a CMake module we haven’t come across before called `GNUInstallDirs`.
include(GNUInstallDirs)
The `GNUInstallDirs` module provides variables for standard installation directories. Even though the name refers to `GNUInstallDirs` will give us a good standard directory structure on whatever platform we’re using. To learn more about `GNUInstallDirs`, please refer to [`cmake.org/cmake/help/latest/module/GNUInstallDirs.html`](https://cmake.org/cmake/help/latest/module/GNUInstallDirs.html).
The next minor change is updating the `target_include_directories` command to handle providing the location of `.h` files for both the regular build (`BUILD_LOCAL_INTERFACE`) and when using the installed version of the library (`INSTALL_INTERFACE`):
target_include_directories(
${PROJECT_NAME}
PUBLIC
< B U I L D L O C A L I N T E R F A C E : <BUILD_LOCAL_INTERFACE: <BUILDLOCALINTERFACE:{CMAKE_CURRENT_SOURCE_DIR}/include>
FetchContent),第一个生成器表达式 BUILD_LOCAL_INTERFACE
将评估为 true,因此
C
M
A
K
E
C
U
R
R
E
N
T
S
O
U
R
C
E
D
I
R
/
i
n
c
l
u
d
e
将被使用。当我们的库被安装时,包含文件的相对位置可能与正常构建时不同,因此我们可以提供相对于安装目录稍微不同的位置。
‘
{CMAKE_CURRENT_SOURCE_DIR}/include 将被使用。当我们的库被安装时,包含文件的相对位置可能与正常构建时不同,因此我们可以提供相对于安装目录稍微不同的位置。`
CMAKECURRENTSOURCEDIR/include将被使用。当我们的库被安装时,包含文件的相对位置可能与正常构建时不同,因此我们可以提供相对于安装目录稍微不同的位置。‘{CMAKE_INSTALL_INCLUDEDIR}是由 GNUInstallDirs 提供的,我们将在稍后的
install` 命令中使用它,将 .h 文件安装(复制)到安装文件夹中。也可以使用相对路径来为 $<INSTALL_INTERFACE> 提供路径,这将从安装前缀的根目录进行评估:
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR} variable here makes the relationship between the target_include_directories command and the later install command clearer.
For good measure, we add the `DEBUG_POSTFIX` property to our target to ensure both the `Debug` and `Release` versions of the library can be installed in the same directory without overwriting one another:
set_target_properties(${PROJECT_NAME} PROPERTIES mc-array,当我们分别构建 Debug 和 Release 时,我们将在 macOS 和 Linux 上得到 libmc-arrayd.a 和 libmc-array.a,在 Windows 上得到 mc-array d.lib 和 mc-array.lib。
有了这些,我们就可以开始添加 `install` 命令本身了。
CMake 安装命令
CMake 的 `install` 命令非常灵活,但在第一次遇到时可能会觉得难以理解。需要注意的一点是,`install` 的功能会根据传递给它的参数而大不相同。对于熟悉 C++ 的人来说,可以将 `install` 的实现看作是某种形式的 `install`,其功能依赖于上下文(它们的顺序或位置是有意义的),这也可能让事情变得相当混乱。考虑到这一点,让我们看一个具体的例子。
传递给 `install` 的第一个参数决定了将执行哪种类型的 `install` 命令;这些被称为 `TARGETS`、`EXPORT` 和 `DIRECTORY`。该规则本质上定义了 `install` 命令所关注的内容。
让我们回顾一下 `CMakeLists.txt` 文件中的第一个安装命令:
install(
TARGETS ${PROJECT_NAME}
EXPORT ${PROJECT_NAME}-config
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
该命令的作用是将我们库的构建产物安装到指定位置,在 macOS/Linux 上是 `libmc-array.a`,在 Windows 上是 `mc-array.lib`,并将其安装到我们使用 `CMAKE_INSTALL_PREFIX` 选定的 `lib` 文件夹中(如果未提供该路径,则为默认的系统位置)。
第一个参数 `TARGETS` 指的是我们要安装的目标。在我们的案例中,这就是我们之前在 `CMakeLists.txt` 文件中使用 `add_library` 命令创建的库。由于这是一个小型库,我们重用了项目名称作为目标,但如果我们选择了其他名称,我们将引用那个名称。下面是一个展示这个的例子:
add_library(dynamic-array STATIC)
...
install(
TARGETS dynamic-array
...
请参见 `ch7/part-2/lib/array/CMakeLists.txt`,了解如何使用不同的库名称(目标),而不是重用项目名称。我们将在本章其余部分采用这种方法,帮助区分项目/包与各个目标/库。
我们传递给第一个`install`命令的第二个参数是`EXPORT ${PROJECT_NAME}-config`。导出意味着将目标提供给其他项目(通常通过`find_package`)。通过添加这行代码,我们通知 CMake,我们希望导出这个目标,并将其与一个以我们项目命名的配置文件(`${PROJECT_NAME}-config`,在我们这里扩展为`mc-array-config`)关联。此步骤尚未创建导出文件,但允许我们在后续的`install`命令中引用该导出,以生成`<project-name>-config.cmake`文件(我们也可以选择`${PROJECT_NAME}Config`,让 CMake 为我们生成`<ProjectName>Config.cmake`文件)。
最后的参数是`ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}`。这告诉 CMake 我们希望安装静态库的`.a`/`.lib`文件的位置。这并不是严格必要的,因为 CMake 会选择一个默认的系统位置,但显式指定并使用`GNUInstallDirs`提供的有用变量`CMAKE_INSTALL_LIBDIR`会使我们的`CMakeLists.txt`文件更加自文档化。`${CMAKE_INSTALL_LIBDIR}`很可能会扩展为`lib`,但使用这种方式提供了一个自定义点,如果用户希望覆盖库安装目录,可以根据需要进行调整。
只需将前面的`install`命令添加到我们的`CMakeLists.txt`文件中,如果我们从`ch7/part-1/lib/array`目录运行以下命令:
cmake -B build -G "Ninja Multi-Config" -DCMAKE_INSTALL_PREFIX=install
cmake --build build --target install
我们会看到以下输出:
[2/3] Install the project...
-- Install configuration: "Debug"
-- Installing: .../array/install/lib/libmc-arrayd.a
(为提供上下文,`ch7/part-1/lib/array/CMakeLists.txt`已经包含了一个完整的安装示例,但可以随意注释掉后面的`install`命令,看看每个命令安装了什么,没安装什么。)
这是一个好的开始,但 CMake 仍然缺少足够的信息来找到并使用我们的库。现在库文件已经安装,导出目标也已经创建,我们可以查看下一个`install`命令:
install(
EXPORT ${PROJECT_NAME}-config
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}
NAMESPACE minimal-cmake::)
这个`install`命令涉及到`EXPORT`规则。第一个参数`EXPORT ${PROJECT_NAME}-config`将这个命令与先前介绍的`${PROJECT_NAME}-config`导出链接在一起。此命令创建并安装导出文件,确保它最终被放入我们的`install`文件夹中(即`mc-array-config.cmake`文件)。我们需要提供此文件应安装的位置。如果我们没有提供,将看到以下错误信息:
install EXPORT given no DESTINATION!
这是通过`DESTINATION`参数实现的。我们再次依赖于`GNUInstallDirs`变量`CMAKE_INSTALL_LIBDIR`,然后指定一个名为`cmake`的文件夹,接着是我们的项目名称。CMake 知道要搜索这个位置,如果需要,也可以调整`${PROJECT_NAME}`和`cmake`的顺序(如果安装到默认系统目录,这一点更为重要,它决定了 CMake 配置文件的组织方式):
DESTINATION ${CMAKE_INSTALL_LIBDIR}/EXPORT install command is NAMESPACE. This adds a prefix to the target to be exported to reduce the chance of naming collisions with other libraries. It’s also a standard convention to disambiguate imported targets from regular targets within a CMake project (something we touched on in *Chapter 6*, *Installing Dependencies* *and ExternalProject_Add*).
NAMESPACE minimal-cmake::
If we now repeat the earlier commands (or just run `cmake --build build --target install` again), we’ll see the newly created config files be created and installed:
[2/3] 正在安装项目…
– 安装配置:“调试”
– 正在安装:…/array/install/lib/libmc-arrayd.a
– 安装:…/array/install/lib/cmake/mc-array/mc-array-config.cmake
– 安装:…/array/install/lib/cmake/mc-array/mc-array-config-debug.cmake
Note that we get two config files, one common one, and one specific to the configuration we built (this will be `Debug` by default). If we pass `--config Release` when building, a corresponding `mc-array-config-release.cmake` file is created:
cmake --build build --target install --config Release
The preceding command will output:
…
– 安装:…/array/install/lib/cmake/mc-array/mc-mc-array-config.cmake 和 mc-array-config-debug.cmake/mc-array-config-release.cmake 文件,以查看底层发生了什么。有许多生成的代码我们可以安全地忽略,但需要注意的关键行如下:
add_library(minimal-cmake::mc-array STATIC IMPORTED)
set_target_properties(minimal-cmake::mc-array PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include")
CMake 正在为我们创建一个导入的目标(因为 `mc-array` 已经构建完成),然后设置该目标的属性以显示在哪里可以找到它的 `include` 文件。然后它会遍历不同的构建配置文件,并在每个文件中根据配置为导入的目标设置更多属性:
set_property(TARGET minimal-cmake::mc-array APPEND PROPERTY IMPORTED_CONFIGURATIONS DEBUG)
set_target_properties(minimal-cmake::mc-array PROPERTIES
IMPORTED_LINK_INTERFACE_LANGUAGES_DEBUG "C"
IMPORTED_LOCATION_DEBUG "${_IMPORT_PREFIX}/lib/libmc-arrayd.a")
这通知 CMake 库的位置,以及构建类型(或配置)。
深入研究这些文件并不是经常需要的,但大致了解它们在做什么,对于理解如何调试无法找到的文件问题非常有帮助。也值得注意的是,这里并没有什么魔法;CMake 只是自动生成了我们本该手动编写的许多命令(而且很可能做得比我们自己写得更好)。
最终(幸运的是最简单的)`install` 命令涉及到 `DIRECTORY` 规则:
install(DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/include/minimal-cmake/
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/minimal-cmake)
在这个安装步骤中,我们只是将一个目录的内容复制到另一个目录,在这种情况下是 `include` 目录。我们使用 `CMAKE_CURRENT_LIST_DIR` 获取当前 `CMakeLists.txt` 文件的路径,然后从那里引用源目录中的 `include` 文件。对于 `DESTINATION`,我们使用 `CMAKE_INSTALL_INCLUDEDIR`(这在之前的 `target_include_directories` 调用中使用过,它与 `INSTALL_INTERFACE` 生成表达式一起使用)。这将把我们的 `include` 文件放在 `include/minimal-cmake` 下(根据约定,`CMAKE_INSTALL_INCLUDEDIR` 会扩展为 `include`)。
如果我们再运行一次 `cmake --build build --target install`,我们会看到我们的 `include` 文件会被复制到预期的安装位置:
...
-- Installing: .../array/install/include/minimal-cmake
-- Installing: .../array/install/include/minimal-cmake/array.h
文件集
CMake `3.23` 引入了一个名为 `target_sources` 命令的新特性。指定一个文件集可以使头文件自动作为 `TARGET` 安装命令的一部分进行安装。我们在本书中坚持传统方法,但请查看 [`cmake.org/cmake/help/latest/command/target_sources.html#file-sets`](https://cmake.org/cmake/help/latest/command/target_sources.html#file-sets) 了解更多关于指定文件集的信息。
有了 `.h` 文件,我们现在拥有了安装我们的库并在另一个项目中使用它所需的一切。
`install` 文件夹结构现在看起来如下:
.
├── include
│ └── minimal-cmake
│ └── array.h
└── lib
├── cmake
│ └── mc-array
│ ├── mc-array-config-debug.cmake
│ ├── mc-array-config-release.cmake
│ └── mc-array-config.cmake
└── libmc-arrayd.a
我们已经涵盖了很多内容,所以如果一切并不完全明了也不用担心。安装、导出和导入目标的概念随着你使用得越多会变得越清晰。一个积极的方面是,添加安装支持仅仅花了大约 10 行代码,在大局上看,这并不算太多。在`ch7/part-2/lib/array`中,除了将`mc-array`目标的名称更改为`dynamic-array`,我们还添加了一个`CMakePreset.json`文件来设置`install`目录(`"installDir"`)。只需运行`cmake --preset default`,然后运行`cmake --build build --target install`。
现在让我们看看如何在我们的*生命游戏*应用中使用我们的数组库。
使用我们新安装的库
好消息是,使用我们刚刚安装的库变得简单多了。如果我们回顾一下`ch7/part-2/app/CMakeLists.txt`,与早期的`ch6/part-4/app/CMakeLists.txt`相比,有两个添加和一个删除。
第一个添加如下:
find_package(mc-array CONFIG REQUIRED)
包的名称(`mc-array`)是我们分配给导出的名称。这与目标名称不同。它相当于我们分配给配置文件(`mc-array-config.cmake`)的名称。
用于将`minimal-cmake-array`引入构建的`FetchContent`调用已经被移除,因为它不再需要,唯一的其他变化是更新`target_link_libraries`命令,使其引用新目标并添加我们之前添加的命名空间前缀:
target_link_libraries(
${PROJECT_NAME} PRIVATE app folder’s CMakeLists.txt file. The last change needed is to update the CMAKE_PREFIX_PATH variable stored in the CMakePresets.json file to include the install path of where mc-array was installed. We could have installed the library to the same install folder as our previously installed dependencies, but installing it to a separate location and providing multiple paths can sometimes be useful. To provide more than one install path, separate each with a semicolon:
“CMAKE_PREFIX_PATH”:
“${sourceDir}/…/third-party/installshared-ninja”,然后构建我们选择的配置,例如:
cmake --preset shared-ninja
cmake --build build/shared-ninja --config Debug
我们现在已经成功地从当前应用中使用了已安装的库。可以像往常一样运行应用,通过在应用目录下运行`./build/shared-ninja/Debug/minimal-cmake_game-of-life_window`来启动应用。由于每个`part-<n>`都是完全独立的,所以每个示例仍然需要像之前一样安装第三方依赖,并通过运行`compile-shader-<platform>.sh/bat`编译着色器。每章的`README.md`文件都描述了构建和运行每个示例所需的命令;请参考它们以回顾所有必要的步骤。
现在我们已经安装了一个简单的静态库,接下来我们将探讨一个稍微复杂一点的案例,处理安装一个有自己依赖关系的库。
处理嵌套依赖
当我们说到嵌套依赖时,我们指的是我们想要依赖的库的依赖项(你可以把这些看作是间接依赖,也叫做**传递性依赖**)。例如,如果一个应用依赖于库 A,而库 A 又依赖于库 B,那么就应用而言,库 B 嵌套在库 A 中。无论这个依赖是私有的(对应用隐藏)还是公共的(对应用可见),都影响我们如何处理它。
在我们刚才看到的示例(`mc-array`)中,幸运的是,当提供安装支持时,我们不需要担心任何依赖项。如果有依赖项,事情会变得稍微复杂一些,但一旦我们理解了需要做什么以及为什么做,支持起来并不复杂。
为了更好地理解这一点,我们将注意力转向我们的*生命游戏*库,`mc-gol`,它位于`ch7/part-2/lib/gol`。最好为这个库以及`mc-array`添加安装支持,所以,如果我们复制刚才演示的三个 CMake `install`命令,并将它们添加到`ch7/part-2/lib/gol/CMakeLists.txt`的底部,同时在顶部添加`include(GNUInstallDirs)`并更新`target_include_directories`与`INSTALL_INTERFACE`,我们可以看看会发生什么(请参阅`part-3`获取确切的更改)。
如果我们从`ch7/part-2/lib/gol`配置、构建并安装共享版本的库(通过设置`MC_GOL_SHARED=ON`),一切都会按预期工作,但如果我们尝试构建库的静态版本,我们会看到 CMake 报告三处错误:
CMake Error: install(EXPORT "mc-gol-config" ...) includes target "mc-gol" which requires target "as-c-math" that is not in any export set.
CMake Error: install(EXPORT "mc-gol-config" ...) includes target "mc-gol" which requires target "mc-array" that is not in any export set.
CMake Error: install(EXPORT "mc-gol-config" ...) includes target "mc-gol" which requires target "mc-utils" that is not in any export set.
这些错误的原因是,我们在`mc-gol`中使用的依赖项是通过`FetchContent`引入的,也需要被导出,因为它们被视为构建和安装的静态库的一部分。即使这些依赖项在`target_link_libraries`中被标记为`PRIVATE`,情况依然如此。处理这个问题有两种不同的方法。我们将作为`mc-gol`的一部分来看第一个方法,并作为我们将在*公共嵌套* *依赖项*部分中提取的新库来看第二个方法。
私有嵌套依赖项
在`mc-gol`的情况下,我们显式地将所有依赖项设置为私有(它们仅在`gol.c`中使用),并且我们不希望客户端或库的用户知道它们。幸运的是,有一个方便的生成器表达式,我们可以使用它来确保这些目标不会被导出,并且只在构建时可见。我们之前在`target_include_directories`的上下文中遇到过它,那就是`BUILD_LOCAL_INTERFACE`:
target_link_libraries(
${PROJECT_NAME} PRIVATE
$<BUILD_LOCAL_INTERFACE will ensure they are not treated as part of the export set and no further work is needed. This is the approach taken in ch7/part-3/lib/gol/CMakeLists.txt. The library is updated to search for mc-array in the installed location instead of using FetchContent and updates the target name to be game-of-life. The target_link_libraries command looks as follows:
target_link_libraries(
game-of-life PRIVATE
$<BUILD_LOCAL_INTERFACE:minimal-cmake::dynamic-array
as-c-math 和 mc-utils)在 BUILD_LOCAL_INTERFACE 内,但将所有内容放入其中可以确保 minimal-cmake::dynamic-array 不会被添加到生成的 mc-gol-config.cmake 文件的 INTERFACE_LINK_LIBRARIES 中。
唯一需要的其他更改是确保我们安装共享库文件(在`mc-array`的情况下我们不需要这个,因为它只能构建为静态库)。现在,`install` `TARGET` 命令如下所示:
install(
TARGETS game-of-life
EXPORT ${PROJECT_NAME}-config
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY and RUNTIME along with the corresponding install locations will ensure the .dylib/.so files on macOS/Linux, and .dll files on Windows, are copied to the correct location when building and installing the library as shared.
Public nested dependencies
When our nested dependencies are public, and are part of the interface of our library, there’s a little more work we need to do. To demonstrate this, we’re going to create a new library called `mc-draw`, found in `ch7/part-4/lib/draw`, that will provide some useful debug drawing functions using `bgfx` for reuse in other projects. It will also give us an example of how to export a nested dependency.
The overall shape of the `mc-draw` `CMakeLists.txt` file is remarkably like that of `mc-gol`. Both can be built as either static or shared libraries, and both now separate the project name from the target name (in the case of `mc-draw`, the target/library name is `draw`, with the `minimal-cmake` namespace).
The first significant difference is a subtle change to the `target_link_libraries` command:
target_link_libraries(
draw
PUBLIC as-c-math
PRIVATE $<BUILD_LOCAL_INTERFACE:bgfx::bgfx minimal-cmake::dynamic-array>)
We’re keeping `minimal-cmake::dynamic-array` and `bgfx` as private dependencies (we could make `bgfx` public too, following the same approach as we’ll describe here, but as the windowed *Game of Life* application we’re building already depends on it, we don’t need to at this time). The main change is making `as-c-math` public. This makes the transitive dependency explicit, so any application using `minimal-cmake::draw` will also get `as-c-math` without also needing to depend on it separately.
The next change is an addition to the `install` `EXPORT` command:
install(
EXPORT ${PROJECT_NAME}-config
DESTINATION C M A K E I N S T A L L L I B D I R / c m a k e / {CMAKE_INSTALL_LIBDIR}/cmake/ CMAKEINSTALLLIBDIR/cmake/{PROJECT_NAME}
NAMESPACE minimal-cmake::
使用find_package
命令首先定位as-c-math
依赖项,然后再尝试定位依赖于as-c-math
的 draw 库。
单独来看,所有前述的变更只是将原本会生成的`mc-draw-config.cmake`文件的名称更改为`mc-draw-targets.cmake`,但这是因为我们将自己制作配置文件,然后从那里引用这个生成的文件。为此,我们在与`CMakeLists.txt`文件相同的目录中创建一个新的文件,命名为`mc-draw-config.cmake.in`。这是一个模板文件,用于生成实际的`mc-draw-config.cmake`文件,其内容如下:
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(as-c-math)
include(${CMAKE_CURRENT_LIST_DIR}/mc-draw-targets.cmake)
第一行在使用`configure_package_config_file`时是必需的(这是我们稍后会介绍的命令),然后我们引入一个 CMake 模块(`CMakeFindDependencyMacro`),以便我们能在`as-c-math`上调用`find_dependency`。`find_dependency`命令是`find_package`的包装器,专门设计用于在包配置文件中使用(有关`find_dependency`的更多信息,请参见[`cmake.org/cmake/help/latest/module/CMakeFindDependencyMacro.html`](https://cmake.org/cmake/help/latest/module/CMakeFindDependencyMacro.html))。
即使我们使用`FetchContent`将`as-c-math`引入,并作为主构建的一部分进行构建,它也需要安装支持,以便我们能够将其作为导出集的一部分。这是为了让调用`find_package(mc-draw)`时首先能找到`as-c-math`,正如之前提到的(要查看如何添加此支持,请参考[`github.com/pr0g/as-c-math`](https://github.com/pr0g/as-c-math)并查看`bfdd853`提交)。最后一行包含了我们之前`install`的`EXPORT`命令生成的文件(`mc-draw-targets.cmake`)。
剩下的就是手动使用此模板生成新的`mc-draw-config.cmake`文件。为此,我们使用前面提到的`configure_package_config_file`命令。我们需要先包含`CMakePackageConfigHelpers` CMake 模块,然后调用代码如下:
configure_package_config_file(
${PROJECT_NAME}-config.cmake.in ${PROJECT_NAME}-config.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake"
INSTALL_DESTINATION. The following install command just copies the config file to the right location as part of the install process (notice how the values passed to INSTALL_DESTINATION and DESTINATION match).
`configure_package_config_file` is used to ensure the config file is relocatable (it avoids the config file using hardcoded paths). For more information about `CMakePackageConfigHelpers`, please see [`cmake.org/cmake/help/latest/module/CMakePackageConfigHelpers.html`](https://cmake.org/cmake/help/latest/module/CMakePackageConfigHelpers.html).
With these changes made, we can now install our library and update our `CMakeLists.txt` application file. Looking at `ch7/part-4/app/CMakeLists.txt`, we can see we now have the obligatory `find_package` call to find the installed library:
find_package(mc-draw CONFIG REQUIRED)
This is followed by an update to `target_link_libraries` to link against the new target (`minimal-cmake::draw`). The only other addition is to remember to copy the `.dll` file to the app build folder using the familiar `add_custom_command` and `copy_if_different` operation so our Windows builds work as expected.
When trying to configure, if the `as-c-math` dependency cannot be found (this can be replicated by commenting out `find_dependency` in the `mc-draw-config.cmake.in` file before installing), then CMake will output an error resembling the following:
找到的包配置文件:
…/minimal-cmake/ch7/part-4/lib/draw/install/lib/cmake/mc-draw/mc-draw-config.cmake
但它将 mc-draw_FOUND 设置为 FALSE,因此包“mc-draw”被认为是未找到。包给出的原因是:
以下导入的目标被引用,但缺失:在问题解决之前显示为 NOT_FOUND。
一切应该已经正常工作,所以从`ch7/part-4/app`开始,如果我们配置并构建(使用 CMake 预设将使这变得更简单),我们可以启动我们更新后的应用程序:
cmake --preset multi-ninja
cmake --build build/multi-ninja
./build/multi-ninja/Debug/minimal-cmake_game-of-life_window
记住,我们还需要构建并安装`part-4`中的其他必需库,包括位于`third-party`文件夹中的`SDL2`和`bgfx`,以及位于`lib`文件夹中的`mc-array`、`mc-gol`和`mc-draw`。每个`lib`文件夹中都有`CMakePreset.json`文件来正确配置安装和前缀路径;只需运行`cmake --preset list`显示可用的预设,然后运行`cmake --preset <preset-name>`进行配置。要构建和安装,请为每个库运行`cmake --build build/<build-folder> --target install`。
关于第三方依赖的提醒,只需在`third-party`文件夹中运行`cmake -B build -G <generator>`和`cmake --build build`,让`ExternalProject_Add`处理所有与`SDL2`和`bgfx`的相关操作。最后要记住的是,如果你还没有编译着色器,可以在构建并安装`bgfx`后,从`app`文件夹运行对应的批处理/脚本来编译。
奖励将是一个更新版的*生命游戏*应用程序,借助我们新的`mc-draw`库,界面颜色和网格线将更加愉悦。
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_07_1.jpg>
图 7.1:改进版生命游戏应用程序
更新后的*生命游戏*应用程序现在也可以互动了。点击网格外的地方将暂停和恢复模拟,而点击一个网格单元将切换该单元的开关状态。通过对网格进行少量修改,生成的各种奇特图案很有趣。
接下来,我们将看看如何将库拆分为独立的部分,这些部分被称为**组件**。
何时以及如何添加组件
随着库的增长,可能会有一个时刻,当将整体库拆分为几个更小的部分时变得有意义。在这一点上,你可能会开始把库看作是一个包,由一个或多个不同的库组成。将包拆分为独立的组件,对于用户来说非常有帮助,因为他们只需要链接所需的功能。这有助于减小应用程序的二进制文件大小,并通过减少与外部依赖项的链接时间来提高构建速度。对于库本身,拆分代码也有利于解耦和打破依赖。
为了演示这个,我们将更新我们的`mc-draw`库,使其包含三个独立的组件:`vertex`、`line`和`quad`。然后,我们可以在`find_package`调用中明确请求所需的组件,如下所示:
find_package(mc-draw CONFIG REQUIRED target_link_libraries:
target_link_libraries(
…
minimal-cmake::vertex
minimal-cmake::line
minimal-cmake::quad
…
To understand how to achieve this, let’s review `ch7/part-5/lib/draw/CMakeLists.txt`. The key difference is instead of creating a single library with `add_library` called `draw`, we instead create three libraries called `vertex`, `line`, and `quad` in a nearly identical way (it’s completely possible to create utility functions to remove some of the boilerplate, but for simplicity, the examples omit this to avoid any potentially confusing abstractions).
Each library (or component) has its own `<component>-config.cmake` file, just as we had with the top-level package. This is the mechanism by which we create the components we refer to in the `find_package` command. Fortunately, we don’t have to create each of these ourselves, but we do need to update our existing `mc-draw-config.cmake.in` file.
Before we do so, the first target we need to talk about is `vertex`. This is depended on by both `quad` and `line` (it appears in their `target_link_libraries` command). It is now responsible for exporting the `as-c-math` dependency, so, the approach we took before of creating a `*-targets.cmake` file goes to `vertex` (we now have a `vertex-config.cmake.in` file with the earlier logic, referring internally to `vertex-targets.cmake` instead of `mc-draw-targets.cmake`). The top-level package config template file, `mc-draw-config.cmake.in`, has been updated to the following:
@PACKAGE_INIT@
可以搜索的有效组件
set(_draw_supported_components vertex line quad)
遍历组件,尝试查找
foreach(component KaTeX parse error: Expected '}', got 'EOF' at end of input: {{CMAKE_FIND_PACKAGE_NAME}_FIND_COMPONENTS})
如果我们找不到组件,设置绘图库为 no
找不到后,通知用户缺少的组件
if (NOT ${component} IN_LIST _draw_supported_components)
set(mc-draw_FOUND False)
set(mc-draw_NOT_FOUND_MESSAGE “不支持的组件:${component}”)
else()
include( C M A K E C U R R E N T L I S T D I R / {CMAKE_CURRENT_LIST_DIR}/ CMAKECURRENTLISTDIR/{component}-config.cmake)
endif()
endforeach()
After the required `@PACKAGE_INIT@` string, we provide a variable holding all the available components provided by the package. What follows is then a check to ensure the components requested from `find_package` are in our list of supported components. Say a user tries to request a component that does not exist, for example:
find_package(mc-draw CONFIG REQUIRED COMPONENTS circle)
Then, they will see the following error message:
找到的包配置文件:
…/minimal-cmake/ch7/part-5/lib/draw/install/lib/cmake/mc-draw/mc-draw-config.cmake
但它将 mc-draw_FOUND 设置为 FALSE,因此包“mc-draw”被认为未找到。包给出的原因是:
不支持的组件:circle
This kind of error message is helpful to let a user know whether they’ve mistyped a component or are trying to use one that does not exist.
One other important detail to note is we do not need to have a component map directly to a single target as we’ve done so previously, with an individual component for `vertex`, `quad`, and `line`. In a larger package, we may choose to create a `geometry` component that contains all the geometric libraries in our application (e.g., `vertex`, `quad`, `line`, `circle`, etc.). For a simple example showing this technique, see [`github.com/pr0g/cmake-examples/tree/main/examples/more/components`](https://github.com/pr0g/cmake-examples/tree/main/examples/more/components), which shows grouping multiple libraries under a single component (two libraries, `hello` and `hey`, are made part of the `greetings` component, with `goodbye` made part of the `farewells` component).
COMPONENT versus COMPONENTS
There is unfortunately another keyword in CMake called `COMPONENT` that also happens to be part of the `install` command. It bears no relation to the `COMPONENTS` keyword that’s part of `find_package`. It is used to split install artifacts based on how the library/package is to be used (`Runtime` and `Development` are commonly suggested components to separate runtime and development functionality, for example). We haven’t covered the `COMPONENT` keyword in the context of the `install` command, but to learn more about how to use it, see the CMake install documentation ([`cmake.org/cmake/help/latest/command/install.html`](https://cmake.org/cmake/help/latest/command/install.html)) and CMake `install` command documentation ([`cmake.org/cmake/help/latest/manual/cmake.1.html#install-a-project`](https://cmake.org/cmake/help/latest/manual/cmake.1.html#install-a-project)) (`cmake --install <build> --``component <comp>`).
Whether you decide to use components or not will very much depend on the type and size of the library you’re building. One example of a library that relies heavily on components is the `s3`, `ec2`, etc.).
Supporting different versions of a library
Back in *Chapter 2*, *Hello, CMake!*, when we introduced the `project` command, we touched on the `VERSION` parameter, but haven’t yet had the opportunity to see how to apply it, and why it’s useful. In this section, we’ll show how to add a version to our `mc-draw` library and how to request the correct version from our `find_package` command.
Versioning for libraries is important for us to know what functionality and interface the library we’re currently using provides. Versioning is used to manage change, and, most importantly, handle API updates that may cause breaking changes. The software industry has largely adopted `<Major>.<Minor>.<Patch>` format (e.g., `1.45.23`) where numbers further to the left represent a more notable change. For a full introduction, see [`semver.org/`](https://semver.org/). Luckily, CMake supports this format, so it’s easy to integrate into our project.
If we look at `ch7/part-6/lib/draw/CMakeLists.txt`, we can see the changes needed to add version support by reviewing the differences between it and the corresponding file in `part-5`. The first change is adding `VERSION` to our project command:
project(
mc-draw
LANGUAGES C
我们可以通过与 CMakeLists.txt 文件相同的方式引用项目名称,只是这次不是使用 ${PROJECT_NAME},而是使用 ${PROJECT_VERSION}。
然后,大多数剩余的修改只是将 `${PROJECT_NAME}` 替换为 `${PROJECT_NAME}-${PROJECT_VERSION}`,或者在 `ARCHIVE`、`LIBRARY` 和 `RUNTIME` 目标的情况下附加它。这样做的原因是为了使得在同一位置安装多个版本的相同库成为可能。
我们需要的最后一个添加是 `write_basic_package_version_file` CMake 命令,用于为我们生成一个 `*-config-version` 文件。它看起来如下:
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}/${PROJECT_NAME}-config-version.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion)
我们将其命名为 `mc-draw-config-version` 并将 `${PROJECT_VERSION}` 传递给 `VERSION` 参数。`COMPATIBILITY` 字段用于决定库在接受版本请求时的严格程度。选项有 `AnyNewerVersion`、`SameMajorVersion`、`SameMinorVersion` 和 `ExactVersion`。我们选择了 `SameMajorVersion`,这意味着只要版本的第一个数字匹配,库就会被找到。
我们应用程序文件夹中的 `CMakeLists.txt` 文件中的 `find_package` 调用稍微更新为以下内容:
find_package(mc-draw 3.5.4 (even if the major versions match), things will fail (the version requested must always be the same or lower than the installed library). By using SameMajorVersion, if we request 2.0, things will fail; the following is an example showing the expected output:
CMake 错误位于 CMakeLists.txt 的第 16 行 (find_package):
找不到与请求的版本 “2.0” 兼容的 “mc-draw” 包的配置文件。
以下配置文件被考虑过,但未被接受:
…/minimal-cmake/ch7/part-6/lib/draw/install/lib/cmake/mc-draw-3.5.4/mc-draw-config.cmake,AnyNewerVersion,当我们安装库时,要求 2.0 不会产生错误,因为已安装的版本 3.5.4 会大于请求的 2.0 版本。决定使用哪种方案可能会很困难,这将取决于你愿意支持的向后兼容性。有关不同兼容模式的更多信息,请参见 cmake.org/cmake/help/latest/module/CMakePackageConfigHelpers.html#generating-a-package-version-file
。
编写查找模块文件
在我们结束关于安装的讨论之前,还有一个话题是有用的。到目前为止,我们只讨论了使用配置模式查找依赖项,但也有一种模式,我们在 *第六章* 中简单提到过,叫做 **模块模式**。模块模式在与没有使用 CMake 本地构建的库集成时很有用(因此无法为我们自动生成配置文件)。
在 `ch7/part-7/cmake` 目录中,添加了一个新的文件 `Findmc-gol.cmake`,它充当了我们将在 `ch7/part-7/lib/gol/install` 中安装的 `mc-gol` 库的查找模块文件。该查找模块文件从技术上讲是多余的,因为我们可以像以前一样使用生成的 `mc-gol-config.cmake` 文件进行配置模式,但假设我们使用一个单独的工具构建了这个库,并知道构建产物(库文件)和头文件的位置。
要将 `mc-gol` 库引入我们的构建中使用 `find_package`,我们首先需要查看 `Findmc-gol.cmake` 查找模块文件。第一行使用 `find_path` CMake 命令来填充 `mc-gol_INCLUDE_DIR` 变量:
find_path(
mc-gol_INCLUDE_DIR minimal-cmake-gol PATHS ${mc-gol_PATH}/include)
`mc-gol_PATH` 变量是我们在配置主应用程序时提供的,用来指定相对于包含文件和库文件的路径(这在 `ch7/part-7/app` 中的 `CMakePresets.json` 文件中设置)。本质上,发生的情况是一种模式匹配,我们传递给 `PATHS` 的值与 `include` 文件所在的路径匹配。
下一行执行几乎相同的操作,不过这次不是填充 `include` 目录变量,而是使用 `find_library` 填充 `mc-gol_LIBRARY`,保存库文件的路径:
find_library(
mc-gol_LIBRARY
NAMES game-of-life game-of-lifed
PATHS ${mc-gol_PATH}/lib)
`NAMES` 参数要求提供我们库的精确名称(这里可以提供多个名称)。我们还必须包含带有 `d` 后缀的库的调试版本名称,因为我们使用 `CMAKE_DEBUG_POSTFIX` 来区分库的 `Debug` 和 `Release` 版本。如果我们不这么做,`find_library` 将找不到库的调试版本。还值得一提的是,`find_library` 并不递归查找,所以我们必须提供库文件存储的精确文件夹位置。
接下来是一个非常有用的 CMake 提供的工具,名为 `find_package_handle_standard_args`,用于在找不到前面提到的两个变量(`mc-gol_INCLUDE_DIR` 和 `mc-gol_LIBRARY`)时进行适当的消息处理。它还处理与 `find_package` 调用相关的其他细节,尽管目前我们不需要关注这些细节。如果你想了解更多关于该命令幕后做了什么,可以访问 [`cmake.org/cmake/help/latest/module/FindPackageHandleStandardArgs.html`](https://cmake.org/cmake/help/latest/module/FindPackageHandleStandardArgs.html) 获取更多信息。
最后,如果库文件被找到,我们会调用 `add_library` 并将 `minimal-cmake::game-of-life` 作为一个导入的目标,同时使用 `set_target_properties` 将我们填充的变量与 `minimal-cmake::game-of-life` 目标关联起来:
if(mc-gol_FOUND AND NOT TARGET minimal-cmake::game-of-life)
add_library(minimal-cmake::game-of-life UNKNOWN IMPORTED)
set_target_properties(
minimal-cmake::game-of-life
PROPERTIES
IMPORTED_LOCATION "${mc-gol_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${mc-gol_INCLUDE_DIR}")
endif()
前面提到的两个命令非常类似于 CMake 在 `ch7/part-7/lib/gol/install/lib/cmake/mc-gol` 文件夹中的 `mc-gol-config-debug/release.cmake` 和 `mc-gol-config.cmake` 文件为我们生成的命令。为了简化,我们没有做太多工作来处理不同的配置(调试版与发布版)或库类型(静态与共享),但如果需要,这一切都是可以实现的。
倒数第二步是让 CMake 知道在哪里找到我们新的 `Findmc-gol.cmake` 文件,并填写 `mc-gol_PATH` 变量。我们在 `ch7/part-7/app/CMakePresets.json` 文件中完成这两项工作,通过更新 `CMAKE_MODULE_PATH` 以包含我们新找模块文件的位置,并设置 `mc-gol_PATH` 为我们库文件所在的位置:
...
"CMAKE_MODULE_PATH": "${sourceDir}/../cmake",
"mc-gol_PATH": "${sourceDir}/../lib/gol/install"
}
最后的修改是在 `ch7/part-7/app` 中的 `CMakeLists.txt` 文件,我们必须明确指定 `MODULE`,而不是 `CONFIG`,用于 `mc-gol`:
find_package(mc-gol cmake --preset multi-config will display:
…
– 找到 mc-gol: /Users/tomhultonharrop/dev/minimal-cmake/ch7/part-
7/lib/gol/install/lib/libgame-of-lifed.dylib
…
It’s likely there won’t be a need to write find module files often, but they can be incredibly helpful in a pinch. It’s much preferable to have CMake do the arduous work of generating and installing config files for us, but if that isn’t a possibility, find modules provide a useful workaround.
Summary
Three cheers for making it to the end of this chapter. Installing is not an easy concept to master, and this is without a doubt the trickiest part of CMake we’ve covered so far. In this chapter, we covered adding simple install support to a static library and then differentiating our package name from the installed library. We then looked at adding install support to a library with private dependencies and how to handle public dependencies as well. Next, we covered splitting up a library or package into components and then looked at how to provide robust versioning support. We closed by reviewing how to create a find module file to integrate libraries built outside of the CMake ecosystem. We also continued to improve and refine our *Game of* *Life* application.
In the next chapter, we’re going to return to streamlining our setup yet again. Right now, we’re back to having to perform a lot of manual installs, which can become tedious, so we’ll look at improving this with the help of `ExternalProject_Add`. To improve things further, we’ll turn to what are often referred to as **super builds**, to neatly combine building our external dependencies and main project in one step. Finally, we’ll look at installing our application and will get things ready for packaging.
第八章:使用超级构建简化入门
在本章中,我们将回到简化和精简项目设置的工作中。在开发过程中,添加功能和应对随之而来的复杂性之间总有一种自然的推拉关系。在第七章《为你的库添加安装支持》中,我们花了大量时间切换目录并运行 CMake 命令。为了构建我们的应用程序,我们需要穿越至少五个文件夹(third-party
、array
、draw
、gol
和 app
),并在途中运行大量 CMake 命令。这是学习 CMake 的一种极好的方式,但当你想要完成工作时,这并不有趣。它还可能阻碍不熟悉的用户访问或贡献你的项目。
现在是时候解决这个问题了。你将在本章中学到的技能将有助于减少启动和运行项目所需的手动步骤。本章将向你展示如何去除平台特定的脚本,并自动化更多的构建过程。
在本章中,我们将介绍以下主要内容:
-
使用
ExternalProject_Add
与你自己的库 -
配置超级构建
-
使用 CMake 自动化脚本
-
在嵌套文件中设置选项
-
安装应用程序
技术要求
要跟随本教程,请确保已满足第一章《入门》的要求。这些要求包括以下内容:
-
一台运行最新操作系统(OS)的 Windows、Mac 或 Linux 机器
-
一个可用的 C/C++ 编译器(如果你还没有,建议使用每个平台的系统默认编译器)
本章中的代码示例可以通过以下链接找到:github.com/PacktPublishing/Minimal-CMake
。
使用 ExternalProject_Add 与你自己的库
在上一章中,我们主要依靠手动 CMake 构建和安装命令(以及一些 CMake 预设的帮助)来增加我们对 CMake 的熟悉度,并以稍低的抽象层次工作,以理解 CMake 在后台所做的事情。现在我们对这些概念已经更加熟悉,是时候去除在每个单独的库文件夹中导航并运行以下熟悉的 CMake 命令的乏味工作了:
cmake --preset <preset-name>
cmake --build <build-folder> --target install
我们可以开始更新项目,利用 CMake 提供的更多有用功能。首先,我们将更新现有的第三方 CMakeLists.txt
文件,不仅引入 SDL 2 和 bgfx,还包括我们创建并依赖的库。这将消除我们手动安装这些库的需要,并允许我们运行一对 CMake 命令(配置和构建/安装)来获取我们所需的所有依赖项,以支持我们的生命游戏应用程序。
让我们首先查看ch8/part-1/third-party/CMakeLists.txt
。该文件与之前大致相同,只是在现有的ExternalProject_Add
命令下,我们添加了对位于ch8/part-1/lib
中的库的引用。
这是mc-array
库的示例:
ExternalProject_Add(
mc-array
SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../lib/array
BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/mc-array-build/${build_type_dir}
INSTALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/install
CMAKE_ARGS ${build_type_arg} -DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
CMAKE_CACHE_ARGS -DCMAKE_DEBUG_POSTFIX:STRING=d)
该命令看起来与我们在第六章中讲解的非常相似,安装依赖项和 ExternalProject_Add。唯一的真正不同之处是对SOURCE_DIR
的引用。由于本书仓库的布局稍显不传统,我们可以直接引用源文件夹,因为库的源代码存储在同一仓库中:
SOURCE_DIR URL or GIT_REPOSITORY link. If we did, for some reason, want to refer to an older version of one of the libraries at a specific moment in the Git history of our project, we could use this approach:
ExternalProject_Add(
mc-array
GIT_REPOSITORY https://github.com/PacktPublishing/Minimal-CMake.git
GIT_TAG 18535c9d140e828895c57dbb39b97a3307f846ab
SOURCE_SUBDIR ch8/part-1/lib/array
…
We did the same thing in *Chapter 3*, *Using FetchContent with External Dependencies*, when using `FetchContent`. The preceding command will clone the entire repository into the build folder of our third-party `CMakeList.txt` file, and then treat the `ch8/part-1/lib/array` directory as the root of the repository (at least as far as `ExternalProject_Add` is concerned). It’s not often needed but can be useful if a repository holds more than one CMake project.
While we’re making changes to `third-party/CMakeLists.txt`, we’ll also make one small improvement to how we handle our bgfx dependency. When bgfx was first introduced in *Chapter 6*, *Installing Dependencies and ExternalProject_Add* (see `ch6/part-4`), we wound up needing to clone the repository twice, once for the static version of the library (needed to build the tools), and again for the shared version of the library that our application linked against. The good news is there’s a handy technique we can apply to download the library once. The following is an extract of the changes with the differences highlighted:
ExternalProject_Add(
bgfxt
GIT_REPOSITORY https://github.com/bkaradzic/bgfx.cmake.git
GIT_TAG v1.127.8710-464
…
ExternalProject_Get_Property(bgfxt SOURCE_DIR)
ExternalProject_Add(
bgfx
URL “file://${SOURCE_DIR}”
DEPENDS bgfxt
…
The first `ExternalProject_Add` call is the same as before; we let it know the repository and specific `GIT_TAG` to download. However, in the second version, instead of repeating those lines, we first call `ExternalProject_Get_Property`, passing the `bgfxt` target and the `SOURCE_DIR` property. `SOURCE_DIR` will be populated with the location of the `bgfxt` source code once it’s downloaded, and in the second command, we point the `bgfx` target to reference that source code location. As the code is identical and it’s just how we’re building it that’s different, this saves a bit of time and network bandwidth downloading the same files all over again.
You might have noticed that we’re using `URL "file://${SOURCE_DIR}"` as opposed to `SOURCE_DIR ${SOURCE_DIR}`. This is because if we tried to use `SOURCE_DIR`, the `ExternalProject_Add` command would fail at configure time because `SOURCE_DIR` is expected to already be present when we configure. As this isn’t the case with our dependency (the source for `bgfxt` will only be downloaded and made available at build time), we can use the `URL` option with a local file path shown by `file://`, which will cause the file path to instead be resolved at build time.
With the addition of the three new `ExternalProject_Add` calls referencing our libraries, when building our project, we only need to visit two directories, as opposed to the earlier five. We can navigate to `ch8/part-1/third-party` and run the following CMake commands:
cmake -B build -G “Ninja Multi-Config”
cmake --build build --config Release
This will download, build, and install all our dependencies at once. We then only need to navigate to `ch8/part-1/app` and run the following:
cmake --preset multi-ninja
cmake --build build/multi-ninja --config Release
This will build and link our application. We’ve also tidied up our `CMakePresets.json` file to have `CMAKE_PREFIX_PATH` only refer to `${sourceDir}/../third-party/install`, as opposed to the numerous install folders we had for each of our internal dependencies in the last chapter.
Finally, to launch the application, we first need to compile our shaders and then launch the application from our application root directory (`ch8/part-1/app`):
./compile-shader-.sh/bat
./build/multi-ninja/Release/minimal-cmake_game-of-life_window
This is a substantial improvement from before, but we can do better. The main build is still split across two stages (dependencies and application), and we still have the annoying issue of needing to remember to compile the shaders (an easy step to overlook). We want to achieve the holy grail of one command to bootstrap everything. Let’s start by seeing how we can solve the first problem by reducing our build steps from two to one.
Configuring a super build
For those unfamiliar with the term **super build**, it is a pattern to bundle external dependencies and the local build together in one step. Behind the scenes, things are still being built separately, but from the user’s perspective, everything happens at once.
Super builds are great for getting up and running with a project quickly. They essentially automate all the configuration and dependency management for you. One other useful quality about them is they’re opt-in. If you want to build the dependencies yourself and install them in a custom location, letting the application explicitly know where to find them, that’s still possible, and the super build won’t get in the way.
Super builds provide the best of both worlds. They are a singular way to simply build a project, and they can also be easily disabled, allowing you to configure your dependencies as you see fit.
Integrating super build support
We’re going to walk through the changes necessary to add super build support by reviewing `ch8/part-2`. All changes are confined to the `app` subfolder.
To begin with, to have our app feel a bit more like a real CMake project (where our CMake project is the root folder), we’re going to move the `third-party` folder inside `app`. The structure now looks like this:
.
├── app
│ └── third-party
└── lib
This is compared to how it was before:
.
├── app
├── third-party
└── lib
This is more representative of a real CMake project and will make enabling and disabling super builds a bit easier.
We’ll start by looking at the changes in `ch8/part-2/app/CMakeLists.txt`. The first and only changes are right at the top of the file:
option(SUPERBUILD “执行超级构建(或不执行)” OFF)
if(SUPERBUILD)
add_subdirectory(third-party)
return()
endif()
The first change is simply the addition of a new CMake option to enable or disable super builds. It’s defaulted to `OFF` for now, but it’s easy to change, and we, of course, have some new CMake presets we’ll cover later in this section, which provide a few different permutations people might want to use.
Next comes the new functionality that only runs if super builds are enabled. It’s worth emphasizing that everything in our application’s `CMakeLists.txt` file stays exactly the same as before. We can easily revert to the earlier way of building if we wish to by simply setting `SUPERBUILD` to `OFF`. Even using both at the same time is easy and convenient (we’ll get into how to do this a bit later).
Inside the super build condition, we call `add_subdirectory` on the `third-party` folder (this was one of the reasons we moved it inside the `app` folder to make composing our `CMakeLists.txt` scripts a little easier). In this instance, we could have used `include` instead of `add_subdirectory`. However, the advantage of `add_subdirectory`, in this case, is that when CMake is processing the file, it will process it where it currently lives in the folder structure. This means that `${CMAKE_CURRENT_SOURCE_DIR}` will refer to `path/to/ch8/part-2/app/third-party`, not `path/to/ch8/part-2/app`.
If we’d instead used `include` and referred to the `CMakeLists.txt` file explicitly (`include(third-party/CMakeLists.txt)`), this would have the effect of copying the contents of the file directly to where the `include` call is (we’re effectively substituting the `include` call with the contents of the file). The issue with this is that `${CMAKE_CURRENT_SOURCE_DIR}`, which is used inside `ch8/part-2/app/third-party/CMakeLists.txt`, will refer to `ch8/part-2/app` instead of `ch8/part-2/app/third-party`. This means that the behavior of our third-party `ExternalProject_Add` commands will differ when called as part of a super build instead of when being invoked directly from the `third-party` folder. In this instance, the relative paths we’re using to refer to our internal library files will not resolve correctly and the location of the third-party install folder will be in `app` instead of `app/third-party`. We want to avoid this, so `add_subdirectory` is a better choice in this instance.
Let’s now follow the execution flow CMake will take and look at the changes made to our third-party `CMakeLists.txt` file found in `ch8/part-2/app/third-party`. The convenient thing is that we’ve kept the ability to build the third-party dependencies separately if we want to. Things will work just as they did before when we’re not using a super build.
The first change is to configure where the third-party build artifacts should go depending on whether we’re using a super build or not. If we’re using a super build, we want to use a separate build folder to store all the third-party dependency files. The reason for this is to keep the same separation we had before when building our third-party dependencies separately. If we don’t do this, our third-party build artifacts will be added to the same build folder as our main application. This means that if we want to remove our application build folder and rebuild, we need to build all our third-party dependencies again.
One way to achieve this is with the following check:
if(SUPERBUILD AND NOT PROJECT_IS_TOP_LEVEL)
set(PREFIX_DIR ${CMAKE_CURRENT_SOURCE_DIR}/build)
else()
set(PREFIX_DIR ${CMAKE_CURRENT_BINARY_DIR})
endif()
When using a super build, the build files will be added to `third-party/build` (`CMAKE_CURRENT_SOURCE_DIR` will refer to the folder that the current `CMakeLists.txt` file is being processed in). The one downside to this approach is that we’ve hard-coded where the third-party build folder is, and it cannot be modified by users (they could still build the third-party libraries separately and specify `CMAKE_CURRENT_BINARY_DIR` using `-B`, but not as part of a super build).
To make things more flexible, we can provide a CMake cache variable with a reasonable default that users can override:
set(THIRD_PARTY_BINARY_DIR
“${CMAKE_SOURCE_DIR}/build-third-party”
CACHE STRING “第三方构建文件夹”)
if(NOT IS_ABSOLUTE ${THIRD_PARTY_BINARY_DIR})
set(THIRD_PARTY_BINARY_DIR
“ C M A K E S O U R C E D I R / {CMAKE_SOURCE_DIR}/ CMAKESOURCEDIR/{THIRD_PARTY_BINARY_DIR}”)
endif()
set(PREFIX_DIR ${THIRD_PARTY_BINARY_DIR})
Here, we introduce `THIRD_PARTY_BINARY_DIR`, which we default to `app/build-third-party` (we could have stuck with `third-party/build`, but this way, our app and third-party build folders will stay closer together to make clean-up easier). We also ensure to handle if a user provides a relative path by using `if(NOT IS_ABSOLUTE ${THIRD_PARTY_BINARY_DIR})`. In this case we append the path provided to `CMAKE_SOURCE_DIR` (which, in our case, will be the `app` folder). This check also treats paths beginning with `~/` on macOS and Linux as absolute paths. It’s a little more code, but the increased flexibility can be incredibly useful for our users.
The extra check of `AND NOT PROJECT_IS_TOP_LEVEL` is to guard against someone accidentally setting `SUPERBUILD` to `ON` when building the third-party dependencies separately as their own project. `SUPERBUILD` will have no effect if this is the case. `PROJECT_IS_TOP_LEVEL` can be used to check whether the preceding call to `project` was from the top-level `CMakeList.txt` file or not (for more information, please see [`cmake.org/cmake/help/latest/variable/PROJECT_IS_TOP_LEVEL.html`](https://cmake.org/cmake/help/latest/variable/PROJECT_IS_TOP_LEVEL.html)).
By introducing the `PREFIX_DIR` variable, we can later pass this to `PREFIX` in the `ExternalProject_Add` command, along with the dependency name, to ensure that the build files wind up in `app/build-third-party/<dep>` instead of `app/build`. When building normally, `CMAKE_CURRENT_BINARY_DIR` will resolve to whatever the user sets as their build folder as part of the third-party CMake configure command.
We use `PREFIX_DIR` in the `ExternalProject_Add` command like so:
ExternalProject_Add(
…
PREFIX ${PREFIX_DIR}/
BINARY_DIR P R E F I X D I R / < n a m e > / b u i l d / {PREFIX_DIR}/<name>/build/ PREFIXDIR/<name>/build/{build_type_dir}
INSTALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/install
…)
The `PREFIX` argument to `ExternalProject_Add` sets the root directory for the dependency. We use the new `PREFIX_DIR` variable, along with the external project name. This ensures that all dependencies we’re building are isolated from one another, avoiding any risk of file naming collisions when downloading and building them. It also makes it easier to rebuild a specific dependency by deleting one of the subfolders and then running the CMake configure and build commands again. Lastly, we will also update `BINARY_DIR` to refer to `PREFIX_DIR` instead of `CMAKE_CURRENT_BINARY_DIR` to handle whether we’re building things as a super build or not.
Each dependency has the same changes applied; there’s only one more change at the end of the file, also wrapped inside a super build check. The change is yet another call to `ExternalProject_Add`, only this time with a bit of a twist: we’re calling it with the source directory of our top-level CMake project:
if(SUPERBUILD AND NOT PROJECT_IS_TOP_LEVEL)
ExternalProject_Add(
${CMAKE_PROJECT_NAME}_superbuild
DEPENDS SDL2 bgfx mc-gol mc-draw
SOURCE_DIR ${CMAKE_SOURCE_DIR}
BINARY_DIR ${CMAKE_BINARY_DIR}
CMAKE_ARGS -DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}
-DSUPERBUILD=OFF ${build_type_arg}
INSTALL_COMMAND “”)
endif()
We start by naming the external project the same as the CMake project currently being processed, with `_superbuild` appended to differentiate them (`CMAKE_PROJECT_NAME` refers to the top-level CMake project). We then ensure that the project depends on all our third-party dependencies using the `DEPENDS` argument so it will only be built when they all are ready. `SOURCE_DIR` is where we set `ExternalProject_Add` to look for our root `CMakeLists.txt` file (`CMAKE_SOURCE_DIR` refers to the top level of the current CMake source tree, which is usually synonymous with the folder containing our root `CMakeLists.txt` file). We also let it know where to find the third-party dependencies with `CMAKE_PREFIX_PATH`.
We next pass through the `SUPERBUILD` option, only this time, we’re explicitly setting it to `OFF`. The key insight to this approach is realizing that we’re calling our root `CMakeLists.txt` script recursively. The second time through, as `SUPERBUILD` is `OFF`, we’ll process the file as normal after waiting for all our dependencies to become available (this is what `DEPENDS` guarantees). Finally, we need to disable the `INSTALL_COMMAND` option, as our application doesn’t currently provide an install target (we haven’t added any install functionality), so we just set it to an empty string.
Looping back to the top-level `CMakeLists.txt` file in `ch8/part-2/app`, after the call to `add_subdirectory`, we simply `return` and finish processing (remember, we will have processed this file in its entirety in the `ExternalProject_Add` command with `SUPERBUILD` set to `OFF`).
Those are all the changes we need to support super builds. To make enabling them a bit easier, we’ve also added a new CMake configure preset called `multi-ninja-super`. It uses a new hidden configure preset called `super`, as shown here, with `SUPERBUILD` set to `ON`:
“name”: “super”,
“hidden”: true,
“cacheVariables”: {
“SUPERBUILD”: “ON”
}
The new preset we added then inherits `super` as well as `multi-ninja`:
“name”: “multi-ninja-super”,
“inherits”: [“multi-ninja”, “super”]
To take advantage of this, from `ch8/part-2/app`, run the following commands:
cmake --preset multi-ninja-super
cmake --build build/multi-ninja-super
Taking it one step further, we can also add a build preset that uses the new configure preset, and finally a workflow preset that uses them both together:
构建预设
“name”: “multi-ninja-super”,
“configurePreset”: “multi-ninja-super”
工作流预设
“name”: “multi-ninja-super”,
“steps”: [
{
“type”: “configure”,
“name”: “multi-ninja-super”
},
{
“type”: “build”,
“name”: “multi-ninja-super”
}
]
This allows us to configure and build everything in one command:
cmake --workflow --preset multi-ninja-super
Ninja and super builds on Windows
It is possible, if you are using super builds on Windows, that after the first build, you may hit the `ninja: error: failed recompaction: Permission denied` error. This appears to be a Windows-specific issue with Ninja related to file paths. Running the same CMake command again will resolve the error. However, if the issue persists, it may be worth experimenting with other generators on Windows such as Visual Studio.
One last reminder is that, by default, this will build in a debug configuration (`Debug`), which we might not want, so adding `"configuration": "Release"` to our build preset is likely a good idea (see `ch8/part-2/app/CMakePreset.json` for a full example).
We can then run our app as usual from the project root directory (`ch8/part-2/app`) with the following command (not forgetting to first build the shaders):
./compile-shaders-.sh/bat
./build/multi-ninja-super/Release/minimal-cmake_game-of-life_window
Earlier in the section, we briefly touched on the fact that super builds and regular builds can coexist seamlessly. For example, we can configure our existing `multi-ninja` preset after configuring and building the super build, as we know all third-party dependencies are now downloaded and installed (`multi-ninja` still has `CMAKE_PREFIX_PATH` set to `app/third-party/install`). This can be quite useful as configuring `multi-ninja-super` again and then building the application will trigger a check of all dependencies. This is usually quite fast, but if the dependencies are stable and aren’t changing often, creating a separate non-super build folder for active development will avoid this. You essentially have two build folders underneath `build`:
build/multi-ninja-super # 超级构建
build/multi-ninja # 常规构建
We get these two build folders thanks to our use of CMake presets and the `binaryDir` property, which lets us use the current preset name (`${sourceDir}/build/${presetName}`). *Chapter 5*, *Streamlining CMake Configuration*, contains more information about this topic for reference.
One final gotcha to mention is that when using a multi-config generator, changing the config passed to the build command will only trigger the application to rebuild in the new configuration, not all the dependencies. To ensure that all dependencies are rebuilt, it is necessary to configure before running the build command (`cmake --``build <build-folder>`).
For example, this might look as follows:
默认构建为 Debug
cmake --preset multi-ninja-super
在 Debug 模式下构建所有内容
cmake --build build/multi-ninja-super
仅在 Release 模式下构建应用程序
cmake --build build/multi-ninja-super --config Release
必须先重新运行配置
cmake --preset multi-ninja-super
现在以 Release 模式构建所有内容
cmake --build build/multi-ninja-super --config Release
Using a single config generator behaves the same. It’s just something to be aware of, as normally, changing the `--config` option passed to the CMake build command with multi-config generators will rebuild everything in the new configuration (this is, unfortunately, one downside of using the super build pattern, but it’s a fairly minor one given the benefits it brings).
The last thing to be aware of with super builds is clean-up. Before, we only needed to delete the build folder to remove all build artifacts, but now, because we’ve split things, there are two folders (three, if you count the install folder) to delete. For completeness, to get back to a clean state, remember to delete the following folders (default locations listed here):
app/build
app/build-third-party
app/third-party/install
With super builds, we can now run a single CMake command and have our project downloaded and built in one step. This is a substantial improvement on what we had before, but there’s one last issue to address: automating the shader compilation step. We’ll look at how to achieve this in the next section.
Automating scripts with CMake
We’ve removed a lot of manual steps that we were dealing with at the start of the chapter, but one remains. This is the requirement to build the shaders needed by bgfx to transform and color our geometry. Up until now, we’ve been relying on running custom `.bat`/`.sh` scripts from the `app` folder before running our *Game of Life* application, but there’s a better possibility. In this section, we’ll show how to make this process part of the build itself, and use CMake to achieve a cross-platform solution without the need for OS-specific scripts.
To start with, we’re going to do away with our existing `.bat`/`.sh` scripts and replace them with `.cmake` files. We’ll pick macOS as the first platform to update; the file will be called `compile-shader-macos.cmake`, and will live under a new `cmake` folder in the `app` directory (equivalent files for Windows and Linux will differ in the exact same way as the existing scripts).
We’re eventually going to invoke these scripts from our top-level `CMakeLists.txt` file. However, before we do, it’s useful to introduce a CMake operation we haven’t covered so far, and that is the ability to run a CMake script from the command line using `cmake –P` (see [`cmake.org/cmake/help/latest/manual/cmake.1.html#run-a-script`](https://cmake.org/cmake/help/latest/manual/cmake.1.html#run-a-script) for more details). As a quick example, we can create a file called `hello-world.cmake` and add a simple `message` command to output `Hello, world!`:
hello-world.cmake
message(STATUS “Hello, World!”)
If we invoke it from the command line by running `cmake -P hello-world.cmake`, we’ll see the following output:
– Hello, World!
(If we include `hello-world.cmake` in a `CMakeLists.txt` file, it will run at configure time and `Hello, World!` will be printed then).
The CMake functionality to invoke a script also supports first providing CMake variables from the command line using the familiar `-D` argument introduced in *Chapter 2*, *Hello, CMake!* (importantly appearing before `-P`):
cmake -DA_USEFUL_SETTING=ON -P cmake-script.cmake
We’ll use this in our shader script example a little later to help control the output when invoking the command.
CMake provides a wealth of useful modules and functions to support file and path manipulation. We’re going to take advantage of them as we craft a CMake script to build our shaders. It’s important to ensure that we have a consistent working directory when invoking `compile-shader-<platform>.cmake`. There are some subtle differences when running from a top-level CMake project and invoking the script using `-P` directly. For example, if we decided to use `CMAKE_SOURCE_DIR` when specifying our paths, this would work correctly when running from the top-level `CMakeLists.txt` file, and when invoking `compile-shader-<platform>.cmake` from the app folder (e.g., `cmake -P cmake/compile-shader-macos.cmake`), but would fail if a user tried to run it from the nested `cmake` folder itself. This is because `CMAKE_SOURCE_DIR` will default to the folder holding the top-level `CMakeLists.txt` file when part of a CMake configure step, and to the folder CMake was invoked from when running `cmake -P path/to/cmake-script.cmake` (this is the same problem we had with the `.``sh`/`.bat` scripts).
To account for these differences, we’re going to use a CMake path-related function to ensure that our script’s working directory is always set to the `app` folder. The function we’re going to use is called `cmake_path`. Added in CMake `3.20`, `cmake_path` provides utilities to manipulate paths, decoupled from the filesystem itself (to learn more about `cmake_path`, see [`cmake.org/cmake/help/latest/command/cmake_path.html`](https://cmake.org/cmake/help/latest/command/cmake_path.html)). In our case, we’d like to find the directory containing our `compile-shader-<platform>.cmake` file. This can be performed with the following command:
cmake_path(
GET CMAKE_SCRIPT_MODE_FILE PARENT_PATH
COMPILE_SHADER_DIR)
In the preceding command, we can see the following arguments:
* The first argument, `GET`, describes the type of operation we’d like to perform.
* The next argument, `CMAKE_SCRIPT_MODE_FILE` ([`cmake.org/cmake/help/latest/variable/CMAKE_SCRIPT_MODE_FILE.html`](https://cmake.org/cmake/help/latest/variable/CMAKE_SCRIPT_MODE_FILE.html)), holds the full path to the current script being processed. It’s important to note that this variable is only set when using `cmake -P` to execute the script. It will not be populated when using `include`. A check for this variable can be included at the top of the script and a warning issued if a user incorrectly tries to include it (see `ch8/part-3/app/cmake/compile-shader-<platform>.cmake` for an example).
* The following argument, `PARENT_PATH`, is the component to retrieve from the preceding path. In this case, we are requesting the parent path of the current script file (essentially, the directory it is in). To see what other components are available, please see [`cmake.org/cmake/help/latest/command/cmake_path.html#decomposition`](https://cmake.org/cmake/help/latest/command/cmake_path.html#decomposition).
* The final argument, `COMPILE_SHADER_DIR`, is the variable to populate the result with.
Now we have this directory, we just need to go one level up to reach the `app` folder. We can achieve this using the same command, only substituting the first argument with the variable we populated in the preceding command.
cmake_path(
GET COMPILE_SHADER_DIR PARENT_PATH
COMPILE_SHADER_WORKING_DIR)
We now have a consistent and portable way to automatically retrieve the `app` folder. We can use the `COMPILE_SHADER_WORKING_DIR` variable in the following CMake script commands.
CMake provides another useful utility called `file` that can be used for a wide array of file and path manipulations (as opposed to `cmake_path`, this command does interact with the filesystem). In our simple case, we just need to create a new folder (the `build` folder in the `app/shader` directory), which can be achieved with the following `file` command:
file(
MAKE_DIRECTORY
${COMPILE_SHADER_WORKING_DIR}/shader/build)
The first argument is the operation to perform, and the second is where to do it. This ensures that we now have an output directory to hold our compiled shader files. To learn more about the `file` command, see [`cmake.org/cmake/help/latest/command/file.html`](https://cmake.org/cmake/help/latest/command/file.html).
We’re next going to make use of a CMake command called `execute_process`, which allows us to run child processes from within a CMake script. In this case, we’re going to replicate the contents of our `compile_shader_macos.sh` file inside the `execute_process` command. The following is an example of what this looks like:
execute_process(
COMMAND
third-party/install/bin/shaderc
-f shader/vs_vertcol.sc
-o shader/build/vs_vertcol.bin
–platform osx --type vertex
-i ./ -p metal --verbose
WORKING_DIRECTORY ${COMPILE_SHADER_WORKING_DIR})
We first call `execute_process`, and then pass the `COMMAND` argument. What follows are the same instructions we would pass at the command line, which were previously invoked from our `.sh` script. We then pass one more argument, `WORKING_DIRECTORY`, to specify where the listed commands should be run relative to (this is populated by the variable we created earlier referring to the `app` directory, regardless of whether the script is being run using `cmake -P` or whether it is being invoked from a `CMakeLists.txt` file). We can now build our shaders using `cmake -P path/to/app/cmake/compile-shader-macos.cmake` from any folder of our choosing (to understand what else `execute_process` can do, see [`cmake.org/cmake/help/latest/command/execute_process.html`](https://cmake.org/cmake/help/latest/command/execute_process.html)).
Before we look at invoking our new scripts from `CMakeLists.txt` as part of the main build, there’s a small improvement we can make to our new `compile-shader-macos.cmake` file. Up until now, we’ve been passing the `--verbose` flag to the *bgfx* `shaderc` program to show the full output of compiling our shaders. This can sometimes be useful, but it’s unlikely that we want to see this as part of the main build every time we either configure or build using CMake. Even with the `--verbose` argument removed, the output is still generated when invoking `shaderc`, which, in the default case, we might want to hide.
To work around this, let’s introduce a new CMake variable called `USE_VERBOSE_SHADER_OUTPUT` to our `compile-shader-<platform>.cmake` scripts. This will default to `OFF` and will control two internal CMake variables. The first is `VERBOSE_SHADER_OUTPUT`, which will substitute the direct reference to `--verbose`:
option(
USE_VERBOSE_SHADER_OUTPUT
“显示着色器编译输出” OFF)
如果(USE_VERBOSE_SHADER_OUTPUT)
set(VERBOSE_SHADER_OUTPUT --verbose)
endif()
execute_process(
COMMAND
…
${VERBOSE_SHADER_OUTPUT}
WORKING_DIRECTORY ${COMPILE_SHADER_WORKING_DIR})
When we invoke `cmake -P cmake/compile-shader-<platform>.cmake`, we now won’t, by default, see the full output from `shaderc`, but we can easily enable it again by setting `VERBOSE_SHADER_OUTPUT` to `ON`:
cmake --verbose,shaderc 仍然会将一些信息输出到终端,这可能会干扰正常的 CMake 构建输出。为了隐藏这些信息,我们可以引入另一个 CMake 变量叫做 QUIET_SHADER_OUTPUT,然后将其设置为 ERROR_QUIET(或在 Linux 上设置为 OUTPUT_QUIET)以抑制execute_process
命令的所有输出(OUTPUT_QUIET 和 ERROR_QUIET 分别对应标准输出和标准错误输出,例如 C 语言中的fprintf
,stdout
和stderr
,以及 C++中的std::cout
和std::cerr
)。
我们的最终代码如下:
if(USE_VERBOSE_SHADER_OUTPUT)
set(VERBOSE_SHADER_OUTPUT --verbose)
else()
set(QUIET_SHADER_OUTPUT ERROR_QUIET OUTPUT_QUIET)
endif()
execute_process(
COMMAND
...
${VERBOSE_SHADER_OUTPUT}
${QUIET_SHADER_OUTPUT}
WORKING_DIRECTORY ${COMPILE_SHADER_WORKING_DIR})
这意味着我们目前无法拥有非详细输出;要么全开,要么全关,但这通常足以满足我们调用这些脚本的需求。所有`compile-shader-<platform>.cmake`文件中的变化几乎是相同的,现在我们已经准备好查看如何从我们应用的`CMakeLists.txt`文件中调用这些脚本。
从 CMakeLists.txt 调用 CMake 脚本
我们的脚本现在可以从`CMakeLists.txt`文件中调用。首先,我们需要根据构建的平台引用正确的文件。我们可以通过简单的条件检查来实现:
if(WIN32)
set(COMPILE_SHADER_SCRIPT
${CMAKE_SOURCE_DIR}/cmake/compile-shader-windows.cmake)
elseif(LINUX)
set(COMPILE_SHADER_SCRIPT
${CMAKE_SOURCE_DIR}/cmake/compile-shader-linux.cmake)
elseif(APPLE)
set(COMPILE_SHADER_SCRIPT
${CMAKE_SOURCE_DIR}/cmake/compile-shader-macos.cmake)
endif()
现在我们可以引用`COMPILE_SHADER_SCRIPT`来获取适合我们平台的文件。接下来有两种不同的方式可以自动调用我们的脚本。一个方法是使用`include`将脚本直接引入到我们的`CMakeLists.txt`文件中:
include(${COMPILE_SHADER_SCRIPT})
不幸的是,现有的`compile-shader-<platform>.cmake`文件不能直接与此方法一起使用。我们需要更新如何填充`COMPILE_SHADER_WORKING_DIR`。我们可以通过以下检查来实现:
if(CMAKE_SCRIPT_MODE_FILE AND NOT CMAKE_PARENT_LIST_FILE)
# existing approach
else()
set(COMPILE_SHADER_WORKING_DIR ${CMAKE_SOURCE_DIR})
endif()
当 CMake 脚本作为`CMakeLists.txt`文件的一部分被调用时,`CMAKE_SCRIPT_MODE_FILE`不会被设置(也叫填充),而`CMAKE_PARENT_LIST_FILE`是包含它的 CMake 文件的完整路径。通过使用这两个检查,我们可以确保只有在文件以脚本模式运行且未被其他文件包含时,才会执行第一个分支。如果我们知道该文件是从`CMakeLists.txt`文件中调用的,我们可以简单地将`COMPILE_SHADER_WORKING_DIR`设置为`CMAKE_SOURCE_DIR`(它将是包含根`CMakeLists.txt`文件的文件夹),这样一切就会按预期工作。
使用这种方法时,每次配置时都会构建着色器。还有一种替代方法可以代替使用`include`,那就是使用我们之前遇到过的 CMake 命令`add_custom_command`。通过`add_custom_command`,我们可以指定一个目标和命令执行的时机(在下面的示例中,我们使用`POST_BUILD`在应用程序构建完成后调用该命令)。完整的命令如下:
add_custom_command(
TARGET ${PROJECT_NAME}
POST_BUILD
COMMAND ${CMAKE_COMMAND} -P ${COMPILE_SHADER_SCRIPT}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
VERBATIM)
这个命令对我们的用户非常方便,并确保在应用程序运行之前先编译着色器。缺点是,目前该命令可能比严格必要时运行得更频繁,如果命令变得更复杂并开始需要更长时间运行,将来可能会成为一个问题。
还有一个`add_custom_command`的替代版本,它不是接受一个目标(`TARGET`),而是接受一个输出文件(`OUTPUT`)。通过`DEPENDS`参数可以列出依赖项,只有在输出文件需要更新时,命令才会执行。这种方法非常高效,但遗憾的是,设置起来稍微复杂一些,而由于前面的命令执行较快,当前使用的是较简单的版本(要了解更多关于`add_custom_command`的信息,请查看[`cmake.org/cmake/help/latest/command/add_custom_command.html`](https://cmake.org/cmake/help/latest/command/add_custom_command.html)))。
添加新命令后,我们已经拥有了使用一个命令构建整个应用程序和附带资源(着色器)所需的一切。从`ch8/part-3/app`目录下,运行以下命令:
cmake --workflow --preset multi-ninja-super
一旦构建完成,剩下的就是运行应用程序本身:
./build/multi-ninja-super/Release/minimal-cmake_game-of-life_window
当然,我们也可以像之前一样使用 CMake 的`--preset`和`--build`参数分别进行配置和构建。不过,利用`--workflow`在这里特别方便。
审查`ch8/part-3/app/CMakeLists.txt`和`ch8/part-3/app/cmake/compile-shader-<platform>.cmake`文件,查看上下文中的所有内容。你可能会注意到一个小变化,那就是在每个`compile-shader-<platform>.cmake`文件的顶部,加入了一个简化的检查,以确保它们必须在脚本模式下运行:
if(NOT CMAKE_SCRIPT_MODE_FILE)
message(
WARNING
"This script cannot be included, it must be executed using `cmake -P`")
return()
endif()
提醒一下,`ch8/part-2/app`和`ch8/part-3/app`。
在嵌套文件中设置选项
我们为简化和精简应用程序构建所采取的步骤已经带来了巨大的变化,并将在未来节省时间和精力。然而,我们在这个过程中不幸失去了一样东西,那就是调整依赖项构建方式的能力。之前,当我们使用`FetchContent`并直接构建依赖项时,我们可以传递各种构建选项来设置是否将特定库构建为静态库或共享库。在*第七章*中,*为库添加安装支持*,当我们考虑单独构建库并手动安装时,我们也可以决定如何构建它们。不幸的是,通过使用`ExternalProject_Add`,我们失去了一些灵活性,因为没有额外的支撑框架,无法直接将选项传递给`ExternalProject_Add`命令。
幸运的是,失去的灵活性并不难恢复。所需的仅仅是创建我们自己的 CMake 选项,然后将它们作为`CMAKE_ARGS`参数的一部分转发给内部的`ExternalProject_Add`命令。
例如,如果我们查看`ch8/part-4/app/third-party/CMakeLists.txt`,我们可以看到在文件顶部,我们添加了两个新选项:
option(
MC_GOL_SHARED
"Enable shared library for Game of Life" OFF)
option(
MC_DRAW_SHARED "Enable shared library for Draw" OFF)
我们使用了与库中实际存在的名称相同的名称,以保持一致性,但如果我们选择将变量与我们正在构建的应用程序分组,仍然可以自由调整命名。然后,我们将这些新值传递给`ExternalProject_Add`,其形式如下:
ExternalProject_Add(
mc-draw
...
CMAKE_ARGS ... -DMC_DRAW_SHARED=${MC_DRAW_SHARED}
...)
这使我们即使在使用`ExternalProject_Add`引入依赖项时,也能决定是否将其构建为静态库或共享库。我们不希望更改的库暴露的其他选项可以硬编码。
我们还需要对`compile-shader-<platform>.cmake`脚本做同样的事情。由于我们从`CMakeLists.txt`文件调用脚本的方式,`USE_VERBOSE_SHADER_OUTPUT`设置不会自动检测到(如果我们使用`include`,它会被拾取并添加到主项目的`CMakeCache.txt`文件中)。为了解决这个问题,我们只需将该设置添加到`CMakeLists.txt`文件中,然后将其传递给脚本的调用:
option(
USE_VERBOSE_SHADER_OUTPUT
"Show output from shader compilation" OFF)
...
add_custom_command(
TARGET ${PROJECT_NAME}
POST_BUILD
COMMAND
${CMAKE_COMMAND} -D USE_VERBOSE_SHADER_OUTPUT=${USE_VERBOSE_SHADER_OUTPUT}
-P ${COMPILE_SHADER_SCRIPT}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
VERBATIM)
请参阅`ch8/part-4/app/CMakeLists.txt`以获取完整示例。
这种变量传递的需求主要出现在`ExternalProject_Add`中。当使用超级构建时,我们需要记住遵循相同的方法,将选项传递到嵌套的应用程序中。这就是为什么有时使用普通的非超级构建项目会很有用(请参见`ch8/part-4/app/CMakePresets.json`中的`multi-ninja`和`multi-ninja-super` CMake 预设作为示例)。配置应用程序的选项数量通常较少,您可以将剩余的、不需要更改的选项直接设置在`ExternalProject_Add`调用中,但有时提供一种更改这些选项的方法会很有用。
安装应用程序
本章的最后,我们将看一个最终的添加内容,那就是如何为我们的应用程序添加安装支持。这有助于为打包做好准备,并确保我们的应用程序具有可移植性。
我们要做的第一个更改是将一个`CMAKE_INSTALL_PREFIX`变量添加到我们应用程序的`CMakePresets.json`文件中,以确保我们的应用程序安装在相对于项目的路径中:
"CMAKE_INSTALL_PREFIX": "${sourceDir}/install"
接下来的一些更改将专门针对`ch8/part-5/app/CMakeLists.txt`。首先,我们需要像为库一样包含`GNUInstallDirs`,以访问标准的 CMake 安装位置(在这个例子中,我们只关心`CMAKE_INSTALL_BINDIR`)。
我们想要实现的高层目标是拥有一个可重定位的文件夹,包含我们的应用程序可执行文件、需要由应用程序加载的共享库以及运行时所需的资源(我们编译的着色器文件)。我们可以通过以下 CMake 安装命令来实现这一目标。
第一个步骤很简单,它将应用程序的可执行文件复制到`install`文件夹:
install(
TARGETS ${PROJECT_NAME}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
我们提供目标以复制,并使用`RUNTIME`类型来指代可执行文件,同时指定复制目标路径(这通常是`bin`,并且会相对于我们在`CMakePresets.json`文件中提供的`CMAKE_INSTALL_PREFIX`变量)。
接下来,我们需要复制应用程序启动时需要加载的共享库文件。由于我们正在开发一个跨平台应用程序,为了简化起见,我们将所有共享库文件(Windows 上的`.dll`、macOS 上的`.dylib`和 Linux 上的`.so`)复制到与应用程序相同的文件夹。这与我们之前在 Windows 上做的非常相似,但现在我们将在所有平台上做同样的事,以保持一致性。复制这些文件的简化安装命令如下所示:
install(
FILES
$<TARGET_FILE:SDL2::SDL2>
...
由于我们的*生命游戏*和*绘图*库可以编译为静态库或共享(动态)库,我们需要检查目标是否是正确的类型,然后再复制它,否则我们将不必要地复制`.lib`/`.a`静态库文件。
我们可以通过这里显示的生成器表达式来实现:
$<$<STREQUAL:$<TARGET_PROPERTY:minimal-cmake::line,TYPE is equal to SHARED_LIBRARY, then substitute the path to the shared library, otherwise, do nothing (the expression will evaluate to an empty string).
Sticking with the dynamic libraries, there’s another minor change we need to make. If you recall *Chapter 4*, *Creating Libraries for FetchContent*, we discussed the topic of making libraries relocatable on macOS and Linux by changing the `RPATH` variable of the executable. We achieved this by using `set_target_properties` to update the `BUILD_RPATH` property of the executable. To ensure things work correctly for both build and install targets, we need to update this command slightly. The changes are shown here:
set_target_properties(
${PROJECT_NAME}
PROPERTIES
INSTALL_RPATH
“ < < <<PLATFORM_ID:Linux>: O R I G I N > ORIGIN> ORIGIN><$<PLATFORM_ID:Darwin>:@loader_path>”
对于 BUILD_RPATH 属性,我们将 INSTALL_RPATH 属性更新为在 Linux 上解析为 $ORIGIN,在 macOS 上解析为 @loader_path(这样做是为了使可执行文件在与自身相同的文件夹中查找共享库)。由于我们希望正常构建的目标和已安装的目标表现一致,因此我们还将 BUILD_WITH_INSTALL_RPATH 设置为 TRUE,这大致相当于将相同的生成器表达式传递给 BUILD_RPATH,就像之前一样(我们在这里的意思是 BUILD_RPATH 应该与 INSTALL_RPATH 相同)。
现在,通过将共享库分别复制到构建和安装文件夹中,我们实现了构建目标和安装目标之间的相同行为。构建完成后,我们可以安全地移除已安装的依赖项(即在 `app/third-party/install` 中的已安装库文件),并继续运行应用程序(然而,这样会破坏我们重新编译和构建的能力,因为在没有先通过生成新的超级构建或配置并从 `third-party` 文件夹构建的情况下,无法恢复第三方依赖项)。
`RPATH` 处理是一个复杂的话题,这里提供的解决方案只是处理共享库安装的一种方法。要了解更多内容,请参考 CMake 属性文档页面上的与 `RPATH` 相关的变量([`cmake.org/cmake/help/latest/manual/cmake-properties.7.html`](https://cmake.org/cmake/help/latest/manual/cmake-properties.7.html))以及 CMake 社区 Wiki 上关于 `RPATH` 处理的部分([`gitlab.kitware.com/cmake/community/-/wikis/doc/cmake/RPATH-handling`](https://gitlab.kitware.com/cmake/community/-/wikis/doc/cmake/RPATH-handling))。
为了确保完全的跨平台兼容性,还需要进行最后一个添加,才能正确处理在 Linux 上为我们的应用程序安装 SDL 2 依赖项。当 SDL 2 在 Linux 上构建时,它会提供作为共享库的一部分的多个文件。这些文件与 `libSDL2-2.0.so.0.3000.2` 相关,但这不是动态链接器查找的文件。`libSDL2-2.0.so.0` 文件是指向 `libSDL2-2.0.so.0.3000.2` 的符号链接,它是动态链接器在查找 SDL 2 库时使用的文件。确保我们安装这两个文件非常重要;否则,应用程序将在运行时找不到共享库。
为了支持这一点,我们只需要在 `add_custom_command` 和 `install` 调用中再添加一条规则,那就是除了 `TARGET_FILE` 之外,还需要安装 `TARGET_NAME_SOFILE`,如下所示:
$<TARGET_FILE:SDL2::SDL2>
$<$<PLATFORM_ID:Linux>:$<TARGET_SONAME_FILE:SDL2::SDL2>>
我们还添加了一个条件生成器表达式,只有在 Linux 平台上才会评估此表达式,因为在其他平台上不需要。
最后,我们需要安装的最后一组文件是我们编译的着色器。我们将它们安装到与从 `app` 文件夹启动时相同的相对位置。实现这一目标的 CMake `install` 命令如下所示:
install(DIRECTORY ${CMAKE_SOURCE_DIR}/shader/build
DESTINATION ${CMAKE_INSTALL_BINDIR}/shader)
我们将`shader`下的`build`目录复制到我们安装可执行文件和共享库文件的相同文件夹中。这样,当我们从`ch8/part-5/app/install/bin`运行应用程序时,它在相对位置上看起来与之前从`ch8/part-5/app`运行时相同。
经过最后的更改,我们拥有了安装应用程序所需的一切。我们现在只需要运行以下命令,从`ch8/part-5/app`文件夹构建并安装我们的应用程序:
cmake --build build/multi-ninja-super
当从超级构建目录(`multi-ninja-super`)构建时,由于我们使用`ExternalProject_Add`来包装我们的项目(在`app/third-party/CMakeLists.txt`的末尾为`${CMAKE_PROJECT_NAME}_superbuild`),安装操作会在运行`cmake --build build/multi-ninja-super`时自动发生(就像我们直接从`third-party`文件夹构建第三方依赖一样)。在配置后首次构建时,无需传递`--target install`(尝试传递该参数实际上会导致错误,因为找不到安装目标)。之后的构建或从普通构建文件夹(例如`build/multi-ninja`)构建时,将需要`--target install`参数,因为安装目标将可用。最后,再次运行配置命令(例如`cmake --preset multi-ninja-super`)将重置此行为,以便用于后续的构建。
如果我们之前使用`--workflow`预设构建了我们的应用程序,我们也可以改用 CMake 的`--install`命令:
cmake --install build/multi-ninja-super
要启动应用程序,安装完成后,切换到`ch8/part-5/app/install/bin`目录并运行`./minimal-cmake_game-of-life_window`。可以自由地探索`ch8/part-5/app`的内容,以查看所有上下文,并通过运行我们到目前为止介绍的不同 CMake 命令进行实验(从`cmake --preset list`开始,然后运行`cmake --preset <preset>`是一个不错的起点)。还可以尝试将`app/install/bin`文件夹复制或移动到新的位置(或与之匹配的操作系统和架构的计算机上),以验证应用程序是否仍然能够启动并成功运行。
总结
是时候再休息一下,让我们所涵盖的内容稍微消化一下了。我们触及了一些高级 CMake 特性,如果你感到有些头晕也不用担心。你练习和实验这些概念的次数越多,理解会越来越清晰。
在本章中,我们从手动安装自己的库转向利用`ExternalProject_Add`来自动化安装过程。这大大减少了设置项目时的繁琐步骤,并且是一种适用于未来项目的有用策略。接着,我们查看了为项目设置超级构建的过程,这提供了一种使用单个命令构建所有内容的方式,同时不失去我们所期望的灵活性。这种技术进一步简化了项目配置,是为用户创建应用程序时提供的极好的默认设置。
之后,我们了解了 CMake 如何替代跨平台脚本,并自动化外部过程,如着色器编译,将其纳入核心构建,而不是事后考虑的事情。这可以节省很多在创建和支持定制的每个平台脚本时的开销。接下来,我们花了一些时间理解如何暴露嵌套依赖中的自定义点,以继续让用户控制他们如何构建库。没有这一点,用户可能不得不编辑 `CMakeLists.txt` 文件,进而带来另一个维护难题。最后,我们演示了如何安装应用程序,使其共享变得轻松。这使我们更接近一个完全可分发的应用程序,并摆脱了依赖项目布局来运行代码的束缚。
在下一章,我们将介绍一个与 CMake 一起捆绑的配套工具——CTest。CTest 是一个非常有用的工具,帮助简化执行各种测试。我们将学习如何将测试添加到我们的库和应用程序中,并了解如何使用另一个 CMake 工具——CDash 来共享测试结果。
第三部分:总结
现在我们已经有了一个完全功能的应用程序,我们希望确保它能够继续正常运行,这时测试就显得尤为重要。我们将展示如何使用附带的 CMake 工具——CTest,为你的库和应用程序提供多种测试支持。在创建应用程序后,最关键的要求是能够将其分享给他人(并确保它不仅仅能在你的机器上运行)。这时打包就派上用场了;我们将展示如何使用另一个 CMake 相关工具——CPack,为 Windows、macOS 和 Linux 制作可分发的包。最后,我们将看看今天有哪些工具可以与 CMake 无缝集成,使得使用 CMake 变得更加容易。到书的最后,你将学到很多内容,但总还有更多可以探索的地方;因此,我们将花一些时间来看看还有哪些资源可以帮助你继续你的 CMake 之旅。
本部分包括以下章节:
-
第九章,为项目编写测试
-
第十章,为项目打包以便共享
-
第十一章,支持工具和下一步
第九章:为项目编写测试
在本章中,我们将讨论 CMake 如何帮助我们处理软件开发中的一个极其重要的方面:测试。测试在任何广泛使用或长期存在的项目中都是至关重要的,它有助于建立对功能的信心,并在添加和改进新特性时帮助避免回归。在一个正常的项目中,强烈建议从一开始就考虑测试;之后引入测试会是一项挑战。幸运的是,借助我们通过将功能拆分为独立库的项目结构,测试变得更加简单。
CMake 提供了一个名为 CTest 的附加应用程序,旨在将多种类型的测试整合到一个平台下。我们将看到如何将测试添加到我们的库中以及应用程序中,并了解如何利用 CTest 使从 CMake 运行它们变得更简单。
在本章中,我们将涵盖以下主要主题:
-
理解 CTest
-
向库中添加单元测试
-
向应用程序添加端到端测试
-
添加其他类型的测试
-
使用 CDash 与 CTest
技术要求
为了跟随本章内容,请确保你已满足 第一章《入门》的要求。这些要求包括以下内容:
-
一台运行最新 操作系统(OS)的 Windows、Mac 或 Linux 计算机
-
一个可工作的 C/C++ 编译器(如果你还没有,建议使用每个平台的系统默认编译器)
本章中的代码示例可以通过以下链接找到:github.com/PacktPublishing/Minimal-CMake
。
理解 CTest
在我们开始查看如何将 CTest 添加到现有的 CMakeLists.txt
文件并使用 ctest
命令行应用程序之前,理解 CTest 是什么,以及,或许更重要的是,理解它不是什麽,十分重要。
CMakeLists.txt
文件有两个组成部分,用于描述和添加测试,另一个是 ctest
命令行界面(CLI),用于在编译测试后运行它们。CTest 本身并不是一个特定语言的测试库。完全可以在一个由 CMake 创建的项目中添加测试,而根本不使用 CTest(例如,通过创建一个依赖于著名测试库的单独测试可执行文件,如 Google Test (github.com/google/googletest
) 或 Catch2 (github.com/catchorg/Catch2
))。CTest 并不是这些库的替代品,后者在编写单元测试和集成测试方面提供了极好的支持。
测试类型
在本章中,我们将提到三种不同类型的测试:单元测试、集成测试和端到端测试。简而言之,单元测试通常测试一个独立的类型或组件,而不会引入任何依赖(例如,测试在特定数学类型(如向量或矩阵)上的操作就算作单元测试)。集成测试则模糊不清;它们通常位于一个范围内,涉及多个类型/类/组件的交互,以确保它们按预期执行(例如,在一个游戏中,集成测试可能会检查玩家角色和相机组件的交互)。在这一阶段,这也是引入桩(stubs)和/或模拟(mocks)的地方(这是一种避免创建昂贵或不可靠依赖(如数据库或远程 API)的方法),事情可能会变得更加复杂(由于在 CMake 上下文中单元测试和集成测试非常相似,本章将专注于单元测试)。最后,端到端测试模拟最终用户与应用程序的交互。这些测试通常是最复杂且最脆弱的,但仍然具有价值,保持少量的端到端测试可以确保整个应用程序按预期执行,而无需手动检查。这三种测试类型通常在测试金字塔中表示(单元测试在底部,集成测试居中,端到端测试在顶部)。一般建议是,金字塔越高,这种类型的测试就越少(单元测试很多,端到端测试很少),这主要由时间、可靠性和成本等指标驱动。
CTest 提供的是一个统一的接口,用于一起运行多种测试并以一致的方式报告失败。这在处理多种语言和风格的不同类型测试时非常有价值。例如,一个应用程序可能有一组使用 C 或 C++ 编写的单元测试和集成测试,这些测试被编译为一个独立的测试可执行文件,还有端到端测试,它启动并运行应用程序让它自我测试(通常通过脚本语言,如 Python,或内置的测试运行器),以及用来验证生成文件的临时 shell 脚本。通过 CTest,所有这些测试方法都可以结合起来并通过单一命令执行,输出结果只显示是否通过或失败。
CTest 是一个极其灵活的工具,支持多种不同类型的测试(甚至可以在测试阶段编译代码)。我们不会涵盖它的所有功能,但我们会尽力覆盖一些最有用的操作,并为您将来在自己的项目中使用 CTest 提供一个起点。
向库添加单元测试
现在我们了解了 CTest 提供的功能,让我们来看一个具体的例子,展示如何向现有的两个库添加单元测试,我们从mc-array
开始。首先要说明的是,我们可以选择几种不同的方式来构建项目以支持测试。一个选择是创建一个与根目录CMakeLists.txt
文件解耦的子目录:
.
├── CMakeLists.txt
├── ...
└── tests
├── CMakeLists.txt
└── tests.cpp
使用这种设置,用户需要进入子文件夹并运行标准的 CMake 配置和构建命令。测试项目将会链接到顶层应用程序,可能依赖于使用SOURCE_DIR
的相对路径的FetchContent
。
另一种选择是保持前述布局,但在启用测试选项时使用add_subdirectory
来添加tests
子文件夹。嵌套的CMakeLists.txt
文件可以链接到库,因为在调用add_subdirectory
时,库会在作用域内。如果库足够小,也可以完全省略tests
文件夹,将测试可执行文件直接放在根级别的CMakeLists.txt
文件中。
在ch9/part-1/lib/array/CMakeLists.txt
中,我们选择了将内容保持在一行,而在ch9/part-1/lib/gol/CMakeLists.txt
中,我们使用了add_subdirectory
。这只是为了给出两种版本的示例;内容几乎是相同的。唯一值得注意的区别是在引用项目中的测试文件时,在嵌套文件夹示例中指定了CMAKE_SOURCE_DIR
。这是为了确保文件路径相对于根CMakeLists.txt
文件,而不是tests
子文件夹。此外,在调用ctest
时,两个版本之间还需要一个细微的区别,我们将在本节后面讨论。
CMakeLists.txt 的 CTest 更改
从ch9/part-1/lib/array/CMakeLists.txt
开始,让我们一步步了解如何添加 CTest 支持。
第一个更改是添加一个名为MC_ARRAY_BUILD_TESTING
的新选项,用于启用或禁用构建测试:
option(MC_ARRAY_BUILD_TESTING "Enable testing" OFF)
请注意,我们使用MC_ARRAY
前缀来减少与其他项目发生冲突的可能性。我们还将其默认为OFF
(CMake 常量表示假;我们也可以使用0
、NO
或FALSE
,但在此上下文中OFF
最为清晰。有关更多信息,请参见cmake.org/cmake/help/latest/command/if.html#constant
)。我们这样做是为了成为一个负责任的公民,防止下游用户在忘记禁用MC_ARRAY_BUILD_TESTING
时,不小心构建测试。
在CMakeLists.txt
文件的底部,我们检查MC_ARRAY_BUILD_TESTING
选项是否已定义,只有在其定义时,我们才会引入 CTest 模块:
include(CTest)
当我们包含此模块时,CMake 会创建一个新的 BUILD_TESTING
选项。不幸的是,这个选项默认设置为 ON
,从用户的角度来看并不理想。如果我们决定在 CMakeLists.txt
文件的顶部包含 CTest 模块,我们可以在测试代码周围使用 if (BUILD_TESTING)
检查;然而,在 FetchContent
的上下文中包含此项目时,这就是一个全有或全无的设置。例如,我们的 生命游戏 库依赖于 mc-array
,如果我们使用 FetchContent
包含了 mc-array
,并且 mc-array
和 mc-gol
都使用 BUILD_TESTING
,那么我们只能运行所有的测试或不运行任何测试。我们可能只希望在更改 生命游戏 库时运行它的测试,因此每个项目的选项让我们能够更好地控制哪些项目构建它们的测试。
在 include(CTest)
之后,我们使用 FetchContent
引入一个名为 dynamic-array-test
的测试库,并添加我们有的、能验证其功能的新测试文件:
add_executable(dynamic-array-test)
target_sources(
dynamic-array-test PRIVATE src/array.test.c)
请注意,我们已将新测试文件添加到与 array.c
相同的物理位置:
.
├── CMakeLists.txt
└── src
├── array.c
└── .test being inserted between the file name and extension. For unit tests, this is a common approach and has the big advantage of making the test code easy to find. It’s well understood that tests provide an incredibly valuable form of documentation, containing lots of examples of how to use a particular type. By keeping both implementation and test code together, it makes maintaining and understanding the code easier. Another benefit of tests as documentation is that tests are more likely to be kept up to date as code changes because not doing so will result in the tests failing or not compiling (that’s as long as you’re building and running them regularly, of course).
The previously outlined approach is recommended for unit tests when there’s a one-to-one mapping between the tests and the type but is less applicable for higher-level integration tests. In those cases, maintaining a dedicated testing folder is usually advisable (either at the project root or split across directories grouped by functionality). Whatever you decide, the most important thing is having any tests at all, wherever they may be.
We then link our new test application against both `unity` and our `dynamic-array` library and set the target compile features we care about:
target_link_libraries(
dynamic-array-test PRIVATE dynamic-array unity)
target_compile_features(
dynamic-array-test PRIVATE c_std_17)
The last and most relevant command for this section is `add_test`:
add_test(
名称 “动态数组单元测试”
COMMAND dynamic-array-test)
This registers our new `dynamic-array-test` executable with CTest so it can invoke it and report the outcome (this essentially means we can use `ctest` to run it). The first argument, `NAME`, allows us to provide a name for the test; this is what will be displayed in the output when running `ctest`. The next argument, `COMMAND`, is the test to run. In our case, this is an executable target, so we pass the target name of our test executable directly, but as we’ll see later, this can be one of many different commands.
Very briefly, one command we haven’t included is `enable_testing()`. You may spot this in other examples, but it is technically redundant as `enable_testing()` is called automatically by the `include(CTest)` command (there are some cases where it is required however, for example when splitting tests across different files and using `add_subdirectory`, see *Chapter 11**, Supporting Tools and Next Steps* for an example). To see the complete example, please refer to `ch9/part-1/lib/array/CMakeLists.txt`. It’s encouraged to use the Visual Studio Code `ch8/part-5/lib/array/CMakeLists.txt` to more easily see the differences.
Running the tests
We now have everything we need to build and run our tests. Navigate to `ch9/part-1/lib/array` and run the following commands:
cmake --preset test
cmake --build build/test
ctest --test-dir build/test-C Debug
The first two commands we’ve seen many times before in one form or another; these will configure and build our application and tests (we’ve updated `CMakePresets.json` to include a new `"test"` preset with `MC_ARRAY_BUILD_TESTING` set to `ON`).
With those out of the way, let’s briefly walk through the `ctest` command. The first argument is `--test-dir`, which we use to specify the build directory containing the tests (this saves having to `cd` into the `build` folder and run `ctest`). The next argument, `-C` (short for `--build-config`), allows us to specify the configuration to test. This is needed because we’re using the `"Ninja Multi-Config"` generator; if we’d used a single config generator, the `build` folder would already have a build type defined through `CMAKE_BUILD_TYPE` and the `-C` argument could be omitted.
Running the preceding command produces the following output:
内部 ctest 更改目录: …/ch9/part-1/lib/array/build/test
测试项目 …/ch9/part-1/lib/array/build/test
开始 1: 动态数组单元测试
1/1 测试 #1: 动态数组单元测试 … 已通过 0.17 秒
100% 测试通过,1 个测试中没有失败
总测试时间(实际) = 0.17 秒
It’s worth briefly mentioning if we omitted `include(CTest)` and `add_test(...)` in our `CMakeLists.txt` file, we’d lose the ability to use `ctest`, but we’d still be able to run our compiled test executable, like so:
./build/test/Debug/dynamic-array-test
For simple use cases, this might be sufficient, but the more consistent `ctest` interface makes test commands portable across different platforms. `ctest` begins to really shine when we want to combine running several different kinds of tests into a single command.
One other useful argument that can be passed to `ctest` is the `--verbose` option. This will display additional output from the tests (in the following case, the output will match running the test executable directly):
ctest --test-dir build -C Debug --output-on-failure 参数可用于使 CTest 仅在测试失败时输出。这可以帮助避免随着测试套件的增长而输出过多的杂乱信息:
ctest --test-dir build -C Debug ctest command has a bewildering number of options, not all of which we can cover here. To learn more about the different arguments and configuration options, please consult https://cmake.org/cmake/help/latest/manual/ctest.1.html for more information.
We’ve covered how to add unit tests to some of our existing libraries and have seen how to invoke them using CTest. Next, we’re moving to the other end of the spectrum and will see an example of adding end-to-end tests for our *Game of* *Life* application.
Adding end-to-end tests to an application
Creating end-to-end tests for an application can be a challenge, and usually relies on an external tool or scripting language to send commands to the application to drive it. To support this, in our *Game of Life* application, we’re going to add one last library that will not only enhance our application but also make it testable end to end.
The library in question is called **Dear ImGui** ([`github.com/ocornut/imgui`](https://github.com/ocornut/imgui)), an open source (MIT licensed) immediate mode **graphical user interface** (**GUI**), originally designed for use in games, but now used across a wide variety of applications.
Immediate versus retained UI
There are two main styles of UI libraries, often referred to as retained mode and immediate mode. A **retained mode** UI tends to require its widgets to be created and managed explicitly. A popular example of this is the Qt (pronounced *cute*) UI library. **Immediate mode** libraries do not require widgets to be created; instead, simply calling a function will display a UI element. There are pros and cons to each approach. Retained mode tends to be favored for UI-heavy applications, while immediate mode is preferred for graphical overlays for games or developer tools (though there are exceptions to both). We’ve opted for Dear ImGui due to its ease of use and simple integration with SDL 2.
Integrating a UI library
Before we look at how we go about creating end-to-end tests for our application, we’re first going to add Dear ImGui to our project. The initial integration is shown in `ch9/part-2`. Dear ImGui, like `bgfx`, does not natively support CMake, however, because Dear ImGui is a relatively small library, it’s easy to add a CMake wrapper around it.
The repository we’ll use is [`github.com/pr0g/imgui.cmake`](https://github.com/pr0g/imgui.cmake), which takes a very similar approach to the `bgfx` CMake repository we saw in *Chapter 6*, *Installing Dependencies and ExternalProject_Add*. The main Dear ImGui repository is embedded as a Git submodule, and a `CMakeLists.txt` file is added at the root of the repository to aggregate the source files and produce a library using CMake (this makes integrating with `FetchContent` or `ExternalProject_Add` possible).
We add Dear ImGui as a new third-party dependency in `ch9/part-2/third-party/CMakeLists.txt` and update our super build project and main `CMakeLists.txt` file accordingly to link against the new dependency.
One other important change we’re going to make is to finally switch our application from using C to C++. This is to prepare it for being able to integrate the Dear ImGui Test Engine. Dear ImGui is written in C++, but C bindings do exist for it (see [`github.com/cimgui/cimgui`](https://github.com/cimgui/cimgui) for an example, which also comes with CMake support). They do not yet unfortunately exist for the testing library, so upgrading to C++ is a necessary step. The changes are minimal though, and as we’ve chosen to use C++ 20, we get to take advantage of designated initializers, which we’d been using in C (essentially a convenient way to initialize structs) with only a minor change in syntax.
There are a few small additions we need before we can integrate Dear ImGui (see `ch9/part-2/app/imgui`). The first is a render backend (as we’re using `bgfx`, we need it to implement a handful of functions required by Dear ImGui), and the second is a platform backend (in this case, we use the SDL 2 platform backend provided by the Dear ImGui repository available from [`github.com/ocornut/imgui/tree/master/backends`](https://github.com/ocornut/imgui/tree/master/backends)).
With these changes added, we can now add our Dear ImGui code. We’re going to add a few simple options to make interacting with our *Game of Life* application a bit easier. The changes include a simulation time control to adjust the amount of time between each update, the ability to pause and resume the simulation, to step the simulation a frame at a time when it’s paused, to clear the board, and to return the board to its original state. The results are shown in *Figure 9**.1*.
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_09_1.jpg>
Figure 9.1: Game of Life with Dear ImGui controls
As a quick reminder, to configure and build the example shown previously, navigate to `ch9/part-2/app` and use the following command:
cmake --workflow --preset multi-ninja-super
The application can then be launched by running the executable produced (again from the same directory):
./build/multi-ninja-super/Release/minimal-cmake_game-of-life_window
Dear ImGui is incredibly powerful and comes with an enormous amount of functionality; our simple example is only scratching the surface. To see what else you can do with Dear ImGui, try adding a call to `ImGui::ShowDemoWindow()` right after `ImGui::NewFrame()` to see more of what it’s capable of.
Integrating end-to-end tests using Dear ImGui
With Dear ImGui integrated, we can now look at bringing in the Dear ImGui Test Engine (available from [`github.com/ocornut/imgui_test_engine`](https://github.com/ocornut/imgui_test_engine)). The Dear ImGui Test Engine has a slightly more restrictive license and requires obtaining a paid license in certain cases (see the `LICENSE.txt` file for more details). However, for derivative software released under an open source license (such as this book’s accompanying source code), it is free to use.
Turning our attention to `ch9/part-3/app`, we’re first going to upgrade our third-party dependency from `imgui.cmake` to `imgui-test-engine.cmake` (see [`github.com/pr0g/imgui-test-engine.cmake`](https://github.com/pr0g/imgui-test-engine.cmake) for reference; it follows the same pattern as the previous `imgui.cmake` library). The `imgui-test-engine.cmake` library publicly depends on `imgui.cmake` (`imgui.cmake` is a transitive dependency), so we can make this small change to our `CMakeLists.txt` files, and things will continue working as they did before.
The Dear ImGui Test Engine requires us to make changes to our source code to integrate it, and we only want this code to be compiled and executed when in test mode. To facilitate this, we can use a CMake `option` to determine whether we’re building a testable version of our application or a regular one. At the top of `ch9/part-3/app/CMakeLists.txt`, we have the following line:
option(MC_GOL_APP_BUILD_TESTING “启用测试” OFF)
This is defaulted to `OFF`, but if a user passes `-DMC_GOL_APP_BUILD_TESTING=ON` when configuring (or adds or updates a CMake preset with this setting), tests will be enabled. The test target itself is wrapped in `if (MC_GOL_APP_BUILD_TESTING)` just as we did when adding tests to our libraries earlier in the chapter.
Because we’re testing an application, and not a library, we can’t add a new test target and link against our application as linking against executables isn’t allowed. We must recompile the application again, only with our testing code turned on. To avoid a lot of repeated code in our `app/CMakeLists.txt` file, we’ve introduced a new `INTERFACE` target called `${PROJECT_NAME}-common`. An `INTERFACE` target allows you to specify usage requirements including source files, compile definitions, libraries, and more. The target won’t be built itself but can be used by other targets (in our case, our normal application and test application), simply by calling `target_link_libraries` with the new `${``PROJECT_NAME}-common` target.
A snippet from `ch9/part-3/app/CMakeLists.txt` using this approach is shown here:
add_library(${PROJECT_NAME}-common INTERFACE)
target_sources(
${PROJECT_NAME}-common
INTERFACE
main.cpp imgui/sdl2/imgui_impl_sdl2.cpp
imgui/bgfx/imgui_impl_bgfx.cpp)
…
add_executable(${PROJECT_NAME})
target_link_libraries(
P
R
O
J
E
C
T
N
A
M
E
P
R
I
V
A
T
E
项目名称变量带有通用后缀,并将其标记为
I
N
T
E
R
F
A
C
E
。然后像之前一样添加源文件和库,只是我们不再直接将它们添加到可执行文件中,而是使用
I
N
T
E
R
F
A
C
E
库。在通过
a
d
d
e
x
e
c
u
t
a
b
l
e
创建可执行文件后,我们只需要链接
‘
{PROJECT_NAME} PRIVATE 项目名称变量带有通用后缀,并将其标记为 INTERFACE。然后像之前一样添加源文件和库,只是我们不再直接将它们添加到可执行文件中,而是使用 INTERFACE 库。在通过 add_executable 创建可执行文件后,我们只需要链接 `
PROJECTNAMEPRIVATE项目名称变量带有通用后缀,并将其标记为INTERFACE。然后像之前一样添加源文件和库,只是我们不再直接将它们添加到可执行文件中,而是使用INTERFACE库。在通过addexecutable创建可执行文件后,我们只需要链接‘{PROJECT_NAME}-common,即可引入它所定义的所有使用要求。好消息是,我们随后可以对
${PROJECT_NAME}-test` 可执行目标做同样的事情,而无需进一步重复。
目标属性仅适用于设置它们的目标,因此如果我们将它们设置在`${PROJECT_NAME}-common`上,它们不会传递到我们的主应用程序(`${PROJECT_NAME}`)或测试目标(`${PROJECT_NAME}-test`)。为了避免这两个目标之间的重复,一个解决方法是创建一个名为`set_common_target_properties`的 CMake 函数,它接受一个目标作为参数。我们可以将共享代码移到这个函数内,并为主应用程序和测试代码调用这个新函数。以下是这段代码的一个片段(完整示例见`ch9/part-3/app/CMakeLists.txt`):
function(set_common_target_properties TARGET_NAME)
set_target_properties(
${TARGET_NAME}
…
endfunction()
set_common_target_properties(CMakeLists.txt file, when defining the new test target, we set the MC_GOL_APP_BUILD_TESTING compile definition (this matches the CMake option for consistency but needn’t be the same):
target_compile_definitions(
${PROJECT_NAME}-test PRIVATE main.cpp 文件,我们可以在其中包装我们的测试初始化代码,并用#ifdef
进行条件编译:
#ifdef MC_GOL_APP_BUILD_TESTING
// register tests
RegisterGolTests(engine, board);
// queue tests
ImGuiTestEngine_QueueTests(
engine, ImGuiTestGroup_Tests, "gol-tests",
ImGuiTestRunFlags_RunFromGui);
#endif
我们在`main.cpp`文件的顶部前向声明了`RegisterGolTests`函数,并在一个单独的文件`gol-tests.cpp`中提供实现,我们仅在测试目标中包含这个文件:
target_sources(
MC_GOL_APP_BUILD_TESTING again to wrap a call to ImGuiTestEngine_IsTestQueueEmpty(engine) to check when all tests have finished running. When this happens, we ensure the total number of tests run is equal to the total number of successful tests, and then terminate the application, returning either 0 for success or 1 for failure.
The tests themselves, residing in `gol-tests.cpp`, allow us to script interactions with Dear ImGui, and because Dear ImGui interfaces with SDL 2, it can simulate mouse movements and clicks our application can respond to. To achieve this, a small change is needed to our input handling in `main.cpp`; we need to switch to using Dear ImGui instead of SDL 2 directly.
For example, the check to see if the left mouse button has been clicked goes from the following:
if (current_event.type == SDL_MOUSEBUTTONDOWN) {
SDL_MouseButtonEvent* mouse_button =
(SDL_MouseButtonEvent*)¤t_event;
if (mouse_button->button == SDL_BUTTON_LEFT) {
…
To instead, look like this:
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
…
Inside `RegisterGolTests`, we then can write a full end-to-end test to move the mouse, issue a click, and check the state of the *Game of* *Life* board:
t = IM_REGISTER_TEST(e, “gol-tests”, “点击棋盘”);
t->UserData = board;
t->TestFunc = [](ImGuiTestContext* ctx) {
const auto* board = (mc_gol_board_t*)ctx->Test->UserData;
ctx->SetRef(“生命游戏”);
ctx->MouseMoveToPos(ImVec2(200, 200));
ctx->MouseClick(ImGuiMouseButton_Left);
ctx->MouseMoveToPos(ImVec2(400, 200));
ctx->MouseClick(ImGuiMouseButton_Left);
IM_CHECK_EQ(mc_gol_board_cell(board, 6, 6), true);
IM_CHECK_EQ(mc_gol_board_cell(board, 19, 6), true);
};
It’s not necessary to understand every line, but the important detail is we can now test our application as if we were a user, which can be incredibly useful for thorny kinds of test cases we’d like to cover.
One other quick thing to mention is the Dear ImGui Test Engine also provides an interactive UI option to selectively run tests and view their output. To enable this, and stop tests from being automatically queued, pass `-D MC_GOL_APP_INTERACTIVE_TESTING=ON` when configuring the project. A CMake preset with this setting enabled has also been added called `multi-ninja-test-interactive` (see `ch9/part-3/app/main.cpp` for the full implementation).
Integrating end-to-end tests with CTest
Returning to our application’s `CMakeLists.txt` file, we can now see how we can integrate the preceding test application with CTest. All that’s needed (other than the obligatory call to `include(CTest)`), is the familiar `add_test` command:
add_test(
NAME “生命游戏端到端测试”
COMMAND ${PROJECT_NAME}-test
我们在本章之前看到的add_test
命令用于注册我们的库测试,这一次,我们传递了一个额外的参数WORKING_DIRECTORY
,并将其设置为CMAKE_SOURCE_DIR
,以确保我们的应用程序使用 CMake 根目录,从而确保着色器文件可以在预期的相对位置访问。
另一种选择是将编译后的着色器文件从`app/shader/build`复制到与编译后的测试应用程序相同的文件夹中,然后将`WORKING_DIRECTORY`设置为`${CMAKE_BINARY_DIR}/$<CONFIG>`(这在单配置生成器和多配置生成器中都能正确工作,因为在单配置生成器中,`$<CONFIG>`会解析为空字符串)。
在一切编译和注册正确之后,剩下的就是运行测试应用程序。这可以通过从`ch9/part-3/app`文件夹执行以下命令来实现:
cmake --preset multi-ninja-super-test
cmake --build build/multi-ninja-super-test
ctest --test-dir build/multi-ninja-super-test -C Debug
在我们结束这一部分之前,值得注意的是,我们可以通过在`CMakePreset.json`文件中添加对测试预设的支持进一步改进这一点。我们可以添加一个名为`"testPresets"`的键,并使用如下所示的 JSON 对象:
{
"name": "multi-ninja-super-test",
"configurePreset": "multi-ninja-super-test",
"configuration": "Debug"
}
然后,我们只需要在配置和构建完成后运行`ctest --preset multi-ninja-super-test`来启动我们的测试(这样可以存储许多我们原本需要在命令行中传递给`ctest`的配置选项)。有关`testPresets`提供的不同选项的更多信息,请参阅[`cmake.org/cmake/help/latest/manual/cmake-presets.7.html#test-preset`](https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html#test-preset)。
最后一步是为之前的所有代码包含一个 CMake 工作流预设,这样我们就可以通过以下命令配置、构建和测试所有内容:
cmake --workflow --preset multi-ninja-super-test
这涵盖了在使用 CMake 和 CTest 帮助下创建可测试版本的应用程序时需要了解的主要内容。接下来,我们将讨论如何将更多类型的测试直接添加到我们的应用程序中,并将它们与 CTest 集成。
添加其他类型的测试
测试是一个非常广泛的话题,通常应用程序需要多种类型的测试来有效地覆盖其行为和功能。CTest 的一个优点是它可以与这些多样化的测试类型集成,并允许它们一起管理和运行。在本节中,我们将讨论 CTest 支持的另外两种类型的测试。
内部测试
我们将讨论的第一个示例仍然严格来说是单元测试,但我们将它添加到应用程序的上下文中,而不是通过提取功能到单独的库来进行。这在短期内很有用,特别是当某些功能无法或不应该被提取时。我们选择的示例是视口投影函数,它将从世界空间映射到屏幕空间,然后再返回。以前,这些函数是添加到我们的`main.c`(现在是`main.cpp`)文件中的,无法在其他文件中使用。我们可以将这两个函数提取到新的文件对中,命名为`screen.h`和`screen.cpp`,并在`main.cpp`中包含`screen.h`。
这种重构使我们能够添加测试,以验证函数的行为,并帮助捕捉回归问题,以防将来我们决定重构或优化内部实现。为了添加测试,我们可以遵循与本章开始时看到的库示例相同的方法,新增一个名为`screen.test.cpp`的文件来保存我们的测试。我们将使用著名的 C++测试库 Catch2 来进行测试。我们选择使用 Catch2 而不是本章开始时介绍的 Unity 测试库的原因是,Catch2 是专为 C++构建的,并且拥有许多有用的功能(如函数重载和不需要手动调用测试,也叫自动测试注册,等等)。我们可以通过`FetchContent`或`ExternalProject_Add`将其作为依赖项添加。由于 Catch2 构建需要一些时间,我们选择了第二种方法。我们在`ch9/part-4/app/third-party`中的更新后的第三方`CMakeLists.txt`文件现在包含以下内容:
if(MC_GOL_APP_BUILD_TESTING)
ExternalProject_Add(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.6.0
...
endif()
if(SUPERBUILD AND NOT PROJECT_IS_TOP_LEVEL)
if(MC_GOL_APP_BUILD_TESTING)
set(TEST_DEPENDENCIES Catch2)
endif()
ExternalProject_Add(
${CMAKE_PROJECT_NAME}_superbuild
DEPENDS
SDL2 bgfx imgui-test-engine.cmake
mc-gol mc-draw ${TEST_DEPENDENCIES}
...
endif()
首先,我们只有在构建应用程序的测试时才会包含 Catch2。然后我们引入了一个变量`TEST_DEPENDENCIES`,如果未设置`MC_GOL_APP_BUILD_TESTING`,它将评估为空字符串,如果设置了,则为`Catch2`。然后我们确保将这个变量传递给`ExternalProject_Add`调用中的`DEPENDS`参数,用于我们的超级构建。
如果你查看`ch9/part-4/app/third-party/CMakeLists.txt`,在文件的顶部,我们还添加了`MC_GOL_APP_BUILD_TESTING` CMake 选项,它出现在`ch9/part-4/app/CMakeLists.txt`中。严格来说,这个设置是多余的,但它确保在单独构建第三方依赖项或作为超级构建时的一致性。
现在 Catch2 作为第三方依赖项可用后,我们可以返回到应用程序的`CMakeLists.txt`文件,并检查需要在那里进行的更改。在`if(MC_GOL_APP_BUILD_TESTING)`块内,在我们的端到端测试可执行文件配置之后,我们添加了测试重构后的`screen.cpp`代码所需的命令。首先,我们使用`find_package`命令来引入我们在前面部分中添加的 Catch2 库:
find_package(Catch2 REQUIRED CONFIG)
然后我们需要设置一个新的可执行文件来编译我们的测试。不幸的是,像这样为应用程序添加测试比我们在本章开始时看到的库案例要复杂一些。正如前面提到的,不能将可执行文件链接到测试中,因此我们不能添加新的测试可执行文件并与应用程序链接进行测试。相反,我们需要指定我们想要测试的文件,并与主应用程序使用的任何库链接,这些库可能在编译时需要。
以下是`ch9/part-4/app/CMakeLists.txt`中的一个提取,展示了如何进行操作:
add_executable(${PROJECT_NAME}-unit-test)
target_sources(
${PROJECT_NAME}-unit-test PRIVATE
src/viewport/screen.cpp
src/viewport/screen.test.cpp)
target_link_libraries(
${PROJECT_NAME}-unit-test
Catch2::Catch2WithMain as-c-math)
target_compile_features(
${PROJECT_NAME}-unit-test PRIVATE cxx_std_20)
我们首先创建一个新的可执行文件 `${PROJECT_NAME}-unit-test`(它会扩展为 `minimal-cmake_game-of-life_window-unit-test`)。接下来,我们添加构建和运行测试所需编译的文件(`screen.cpp` 和 `screen.test.cpp`)。我们必须链接 Catch2(`Catch2WithMain` 有助于避免为测试创建自定义的 `main()` 入口点;有关更多信息,请参见 [`github.com/catchorg/Catch2/blob/devel/docs/cmake-integration.md#cmake-targets`](https://github.com/catchorg/Catch2/blob/devel/docs/cmake-integration.md#cmake-targets))和 `as-c-math`,这是 `screen.h/cpp` 接口和实现所依赖的。最后,我们确保明确设置语言版本(在此情况下为 C++ `20`),以确保在不同编译器和平台之间使用一致的语言版本。
最后的步骤就是使用这里显示的 `add_test` 命令将测试可执行文件注册到 CTest:
add_test(
NAME "game of life unit tests"
COMMAND ${PROJECT_NAME}-unit-test)
默认情况下,如果 CTest 检测到命令返回 `0`,它会报告成功;对于任何非零值,它会报告失败。这是一个普遍遵守的约定,不仅是 CTest,Catch2 和之前提到的 C 测试库 Unity 也都处理这一点。
为了确认这一点,可以通过一个简单的控制台命令检查可执行文件在程序退出时的返回值。在运行应用程序后,在 Windows 上使用此命令(如果使用 PowerShell 或命令提示符):
echo %ERRORLEVEL%
如果使用 macOS 或 Linux(或 Windows 上的 GitBash 或等效工具),请使用此命令:
echo $?
在 Catch2 中,返回的数字是失败测试的数量。为了验证这一点,我们可以在 `screen.test.cpp` 文件中更改一个或两个期望结果值,重新编译测试可执行文件,运行它,然后运行前面的某个命令。如果两个测试失败,我们将看到以下输出:
> .../minimal-cmake_game-of-life_window-unit-test.exe
> echo $?
2
如果由于某种原因,默认行为不足以满足需求,CTest 提供了一个 `PASS_REGULAR_EXPRESSION` 和 `FAIL_REGULAR_EXPRESSION` 属性,可以在测试中设置,以检查来自 `stdout` 或 `stderr` 的特定模式。例如,要验证 Catch2 运行的所有测试都成功,我们可以使用以下正则表达式检查:
set_tests_properties(
"game of life unit tests"
PROPERTIES "All tests passed" when no failures occur, which CTest checks for; it will report success if it detects it. This is a somewhat contrived example but can be useful in different situations where an exit code may not be available. See https://cmake.org/cmake/help/latest/prop_test/PASS_REGULAR_EXPRESSION.html for more information.
CMake script tests
The last kind of test we’ll cover is using CMake itself to run a CMake script file (like the CMake scripts we created to compile our shaders). We’re going to add a simple test to verify that the shaders required for the application to run have been compiled successfully. To achieve this, we create a new CMake script called `shaders-compiled.cmake` and add it to our `tests` directory. All it does is check for the existence of our shader files; a snippet is shown here:
if(NOT EXISTS
${CMAKE_SOURCE_DIR}/shader/build/vs_vertcol.bin)
message(FATAL_ERROR “vs_vertcol.bin 丢失”)
endif()
It’s not a particularly granular test, but if it fails, it is a useful early warning that the overall build is not functioning correctly and gives us a useful indicator of where to look.
We can run this file directly by running `cmake -P tests/shaders-compiled.cmake` from `ch9/part-4/app`. When the `message(FATAL_ERROR ...` command is met, CMake will cease processing and return a non-zero error code (we can again verify this using `echo $?` or equivalent).
To run this test as part of our top-level project, we can add the following to the testing section of our `CMakeLists.txt` file in `ch9/part-4/app`:
add_test(
NAME “着色器编译”
COMMAND ${CMAKE_COMMAND} -P tests/shaders-compiled.cmake
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
This runs the file just as we did from the command line, and by providing a variable for `WORKING_DIRECTORY`, we ensure the test runs from `CMAKE_SOURCE_DIR` rather than the `build` folder so the relative paths to our shader files resolve correctly.
One final tip is that now that we have multiple tests to run, when working on a particular test, it can be useful to skip the others. In this situation, the `--tests-regex` (`-R` for short) command-line option can be used to select only the tests we want to run. For example, to only run the unit tests for our application, we could use the following command:
ctest --test-dir build/multi-ninja-super-test -R “生命游戏单元测试” -C Debug
CMake also offers the ability to associate tests with specific labels. A pattern matching one or more of the labels can be passed to `ctest` to then have it only run the tests with that particular label. This can be achieved using `set_tests_properties`:
set_tests_properties(
“生命游戏端到端测试”
PROPERTIES --label-regex (-L) 和与 ctest 匹配的模式:
ctest --test-dir build/multi-ninja-super-test --label-exclude (-LE) to do the opposite, and not run any tests that match the label (in the preceding example, using -LE slow would run all tests that are not labeled slow).
There are many more command-line arguments available for `ctest`, which are worth reviewing. They can be found by visiting [`cmake.org/cmake/help/latest/manual/ctest.1.html`](https://cmake.org/cmake/help/latest/manual/ctest.1.html).
Using CDash with CTest
One last topic to cover in the context of testing is integrating with another CMake tool called CDash. **CDash** is a web-based software testing server that can be used to present the results of running CTest. CDash displays a dashboard showing which tests are passing and which are failing and can also be used to display the current code coverage, as well as any build warnings or errors.
The good news is adding CDash support to our project requires minimal effort. We’ll briefly walk through the changes required and look at adding code coverage support on macOS and Linux to be displayed from CDash.
Creating a CDash project
The first step we need to take is to create an account and a new project with CDash. While it’s possible to self-host a CDash server, using the CDash service provided by Kitware is a quick and easy way to get set up. This can be achieved by visiting [`my.cdash.org/`](https://my.cdash.org/), creating an account, and then navigating to [`my.cdash.org/user`](https://my.cdash.org/user) and scrolling down to the **Administrator** section. Here, there is then a **Start a new** **project** option.
When creating a project, there are several options to provide, including the project name, description, whether the project is private, protected, or public, and whether submissions should be authenticated or not. For *Minimal CMake*, we have created a new public project, which can be found by visiting [`my.cdash.org/index.php?project=minimal-cmake`](https://my.cdash.org/index.php?project=minimal-cmake).
Once your project has been created, the next step is to connect your local project to CDash. To do this, we add a new file to the root of our CMake project (in our case, this is `ch9/part-5/app`) called `CTestConfig.cmake`. Its contents are as follows:
set(CTEST_PROJECT_NAME minimal-cmake)
set(
CTEST_SUBMIT_URL
https://my.cdash.org/submit.php?project=minimal-cmake)
There are many more options you can set, but for our purposes, we’re simply specifying the project name, and where the build artifacts should be uploaded to. For more complex cases, it’s possible to specify nightly build times, the maximum number of warnings or errors to be detected, and memory checks. For a full list of variables, please see [`cmake.org/cmake/help/latest/manual/cmake-variables.7.html#variables-for-ctest`](https://cmake.org/cmake/help/latest/manual/cmake-variables.7.html#variables-for-ctest).
Uploading test results
With the CDash project created and `CTestConfig.cmake` added to our project, we can run the following CTest command to run our tests and upload the results to CDash:
ctest --test-dir -C Debug -D 选项在此上下文中与我们之前使用的方式略有不同(用于设置 CMake 缓存变量);在这里,-D 指的是 CDash Web 仪表板(–dashboard),并告知 CTest 充当 CDash 客户端。这基本上意味着在运行完测试后,结果将上传到我们在 CTestConfig.cmake 文件中设置的 CDash 项目。
在这里,`Experimental`指的是模式,`Experimental`是供个人开发者测试本地更改的模式。还有多个其他模式(`Nightly`,`Continuous`)可以独立配置,并在不同的上下文中使用。
通过此更改,我们可以查看 CDash Web 界面,了解哪些测试已运行以及它们是否成功或失败。
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_09_2.jpg>
图 9.2:CDash 测试结果
增强的可视性可以让开发团队清楚地知道哪些测试通过或失败,这对于及时发现问题和早期检测回归非常有帮助。
添加代码覆盖率
CDash 提供的另一个有用功能是一个干净的界面,用于报告在运行测试时执行的代码行。不幸的是,这仅在**GNU 编译器集合**(**GCC**)和 Clang 编译器中受支持,因此默认情况下在 Windows 上无法使用(尽管在 Windows 环境中设置 Clang 并不困难,如果你有决心的话)。
为了支持捕获代码覆盖率信息,我们需要在`CMakeLists.txt`文件中做一些小的修改。完整示例请参见`ch9/part-5/app/CMakeLists.txt`,但关键的代码行如下所示:
target_compile_options(${TARGET_NAME} PRIVATE --coverage)
target_link_options(${TARGET_NAME} PRIVATE --coverage to both the compile and link options for our test targets. Internally, CMake is using a tool called gcov to generate coverage information. gcov itself is outside the scope of this book. It can be used without CMake or CTest, but fortunately for us, CTest does a nice job of providing a simple interface that wraps gcov and we can treat it as an implementation detail for now.
One last change is to limit the amount of coverage information that’s reported (to essentially ignore files we don’t care about). This can be achieved by adding a new file called `CTestCustom.cmake.in` that contains the `CTEST_CUSTOM_COVERAGE_EXCLUDE` CTest variable, which allows us to pass coverage paths to ignore to CTest:
set(CTEST_CUSTOM_COVERAGE_EXCLUDE
${CTEST_CUSTOM_COVERAGE_EXCLUDE}
“src/imgui/sdl2” “third-party/install/include”)
CTest will look for this file in the CMake `build` folder (`CMAKE_BINARY_DIR`), so we need to copy the template file to the `build` folder when we run the CMake configure step. To do this, we use `configure_file`, added at the bottom of our testing block in our application’s `CMakeList.txt` file:
configure_file(
CTestCustom.cmake.in
${CMAKE_BINARY_DIR}/CTestCustom.cmake COPYONLY,表示不应进行任何变量替换。现在,当我们运行之前看到的 ctest 命令时,覆盖率信息也会被上传,并与测试结果一起提交。可以查看文件的整体测试覆盖率百分比,并逐行查看在运行测试时执行了哪些代码:
<https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/min-cmk/img/B21152_09_3.jpg>
图 9.3:CDash 覆盖率结果
这只是对 CDash 的一个非常简短的介绍,仅仅触及了它的表面。除了使用默认的`ctest`功能外,还可以完全脚本化`ctest`的执行(请参阅[`cmake.org/cmake/help/latest/manual/cmake-commands.7.html#ctest-commands`](https://cmake.org/cmake/help/latest/manual/cmake-commands.7.html#ctest-commands)以查看`ctest`命令的完整列表)。还可以设置定期的夜间构建和各种类型的报告,以及启用几种形式的静态分析(源代码错误检测)。如果你决定选择其他工具或不需要可视化功能,也完全可以不使用 CDash;CTests 可以独立使用。
摘要
这标志着我们第一次涉足测试的结束。虽然我们没有涵盖很多内容,但希望这已经让你对 CTests 的功能有所了解,并且理解它如何将多种不同的测试方法结合起来。
在本章中,我们介绍了 CTest,以理解它是什么以及它如何帮助我们管理跨库和应用程序的各种测试。测试至关重要,理解 CTest 在测试生态系统中的定位非常重要。我们展示了如何在我们的基础库中添加单元测试时使用 CTest,如何在应用程序内结构化单元测试,以及如何创建一个独立的可测试可执行文件来运行完整的端到端测试。我们还展示了如何编写 CMake 脚本来测试项目的其他部分。所有这些都通过 CTest 进行协调和连接。这些技能将帮助你构建成功且可靠的软件项目。
接着,我们简要浏览了 CDash,了解它提供了哪些功能以及它如何与 CTest 集成。我们查看了测试结果和代码覆盖率报告,了解了像 CDash 这样的工具如何帮助软件团队更有效地协作。
在下一章,我们将把注意力转向 CMake 的另一个配套工具——CPack。我们将使用它来打包我们的应用程序,使其准备好进行分发,并探讨一些与平台特定差异处理相关的挑战。