11、与find_package协同:优先使用系统包、回退到FetchContent、find_package与FetchContent的混合模式

依赖管理这件事,说白了就是「找得到就用,找不到就自己下」。但实际项目中哪有这么简单?你想想看,开发机上可能已经装了某个库的系统版本,但版本太旧;或者CI环境里干干净净啥都没有;再或者同事的Mac上brew装了一堆,你的Ubuntu上apt源里却没有。

这时候就需要一套灵活的机制——优先用系统包,找不到再自动下载。我这些年折腾CMake,发现这才是最实用的方案。

为什么需要混合模式?

纯用find_package有个硬伤:依赖系统环境。换台机器可能就编译不过。纯用FetchContent呢?每次都要下载源码编译,浪费时间,而且系统里明明有现成的库却不用,浪费资源。

我个人的习惯是:能复用系统包就复用,不行再回退到源码下载。这样既快又稳。

核心思路:先尝试find_package,如果失败则调用FetchContent下载并集成。两者共用一套变量名和target名,上层代码无需关心依赖来自哪里。

方案一:优先系统包,回退到FetchContent

这是最常用的模式。先试试find_package,如果找不到,再走FetchContent

# 尝试找系统包
find_package(spdlog QUIET)

if(NOT spdlog_FOUND)
    message(STATUS "spdlog not found in system, fetching from GitHub...")
    
    include(FetchContent)
    FetchContent_Declare(
        spdlog
        GIT_REPOSITORY https://github.com/gabime/spdlog.git
        GIT_TAG        v1.12.0
    )
    FetchContent_MakeAvailable(spdlog)
endif()

这里有个细节:QUIET关键字让find_package找不到时不报错,只设置spdlog_FOUNDFALSE。然后我们根据这个变量决定是否走FetchContent。

小技巧:我习惯把这种逻辑封装成一个函数,比如ensure_dependency,这样每个依赖只需要一行调用,清爽很多。

方案二:强制指定版本,系统包不满足则回退

有时候系统里有包,但版本太老。比如系统自带的fmt是7.x,而你需要9.x的新特性。这时候不能简单用find_package,得加上版本检查。

# 要求最低版本 9.0
find_package(fmt 9.0 QUIET)

if(NOT fmt_FOUND)
    message(STATUS "fmt >= 9.0 not found, fetching from GitHub...")
    
    include(FetchContent)
    FetchContent_Declare(
        fmt
        GIT_REPOSITORY https://github.com/fmtlib/fmt.git
        GIT_TAG        9.1.0
    )
    FetchContent_MakeAvailable(fmt)
endif()

嗯,这里要注意:find_package(fmt 9.0 QUIET)的意思是「找版本>=9.0的fmt」。如果系统里装的是8.x,那fmt_FOUND就是FALSE,自动回退到下载。

我曾经踩过的坑:有些库的CMake配置不完善,find_package找到了但target名不对。比如系统包导出的target叫fmt::fmt,而FetchContent下载的版本导出的是fmt::fmt-header-only。这就导致上层代码里链接的target名不一致,编译报错。

解决办法:在FetchContent下载后,手动创建一个别名target,统一命名。

方案三:混合模式——部分依赖用系统包,部分用FetchContent

大型项目里,依赖可能有十几个。有些是基础库(如zlibopenssl),系统里大概率有;有些是冷门库或需要特定版本的库,最好自己下载。

我一般这样组织:

# 系统包优先的依赖
find_package(ZLIB REQUIRED)
find_package(OpenSSL REQUIRED)

# 回退模式的依赖
find_package(spdlog QUIET)
if(NOT spdlog_FOUND)
    FetchContent_Declare(spdlog ...)
    FetchContent_MakeAvailable(spdlog)
endif()

# 纯FetchContent的依赖(系统里基本没有)
FetchContent_Declare(nlohmann_json GIT_REPOSITORY ...)
FetchContent_MakeAvailable(nlohmann_json)

你看,三种模式混在一起用,完全没问题。关键是保持target命名一致,这样target_link_libraries里写起来不用区分来源。

流程图:混合依赖管理决策

下面这张图是我自己项目里实际用的决策流程,你可以参考:

混合依赖管理决策流程 开始处理依赖 系统包优先? find_package(QUIET) 找到 使用系统包 否/未找到 FetchContent下载源码 编译并集成到项目 依赖就绪

实际项目中的最佳实践

我在一个中型C++项目里用过这套方案,总结了几条经验:

  1. 统一版本管理:把所有依赖的版本号集中放在一个versions.cmake文件里,方便升级和回滚。
  2. 缓存FetchContent下载:CMake默认会缓存下载的源码,但如果你改了GIT_TAG,记得清理build/_deps目录,否则不会重新下载。
  3. 注意FetchContent的副作用:它会自动调用add_subdirectory,把依赖的target暴露出来。如果依赖的CMakeLists.txt写得不好,可能会污染你的变量或target命名空间。
  4. OVERRIDE_FIND_PACKAGE(CMake 3.24+):新版本CMake支持在FetchContent里直接覆盖find_package的行为,更优雅。但我个人还没完全迁移,因为有些项目还在用3.20。

一句话总结:混合模式的核心就是「先找系统包,找不到就自己下」。上层代码不用管依赖来自哪里,只管链接target就行。这样既利用了系统包的速度,又保证了跨平台的一致性。

嗯,这套方案我用了好几年,从CMake 3.14到现在的3.28,基本没出过问题。唯一要注意的就是target命名一致性,这个坑我踩过两次,后来学乖了——每个依赖都写一个FindXxx.cmake或者用别名target统一命名。

我的个人习惯:在项目根目录建一个cmake/dependencies.cmake,把所有依赖管理逻辑放进去。主CMakeLists.txt里只需要include(cmake/dependencies.cmake),清爽得很。


公众号:蓝海资料掘金营,微信 deep3321