第二十一章:自定义命令与目标:add_custom_command()、add_custom_target()、构建后处理、代码生成器集成

各位同学,今天我们来聊一个在NDK开发中非常实用、但很多人又觉得有点绕的话题——自定义命令和目标。说白了,就是让CMake在构建过程中帮你干一些“额外”的活。

我记得刚接触NDK那会儿,遇到一个需求:每次编译完.so文件后,需要自动把它拷贝到项目的assets目录下。当时我傻乎乎地手动拷贝,直到有一天忘了拷,调试了半天才发现是版本没更新……后来我才知道,CMake早就提供了add_custom_command()add_custom_target()这两个神器。

21.1 为什么需要自定义命令?

你想想看,标准的CMake构建流程无非就是:配置 → 编译 → 链接。但实际项目中,我们经常需要在这前后插入一些操作:

  • 编译前生成一些头文件或数据文件
  • 编译后自动签名、打包、拷贝
  • 调用外部脚本做代码检查或资源处理
  • 集成代码生成器(比如protobuf、AIDL)

这些操作,CMake本身不会帮你做。这时候就需要自定义命令出马了。

核心概念区分:

  • add_custom_command():给某个目标或文件“挂载”一个命令。它本身不产生新目标。
  • add_custom_target():创建一个全新的、没有编译产物的“虚拟目标”。你可以依赖它,也可以让它依赖别人。

21.2 add_custom_command() 的两种用法

我个人习惯把它的用法分成两类:输出文件型目标挂钩型

21.2.1 输出文件型(生成文件)

这种用法最常见。你告诉CMake:“嘿,这个文件是通过执行某个命令生成的”。CMake会帮你管理依赖——如果源文件变了,它会自动重新执行命令。

add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    COMMAND ${CMAKE_COMMAND} -E echo "// Auto-generated" > ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    COMMAND echo "#define BUILD_TIME \"${CMAKE_TIMESTAMP}\"" >> ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h
    DEPENDS config_template.txt
    COMMENT "Generating config header..."
)

这里我用了${CMAKE_COMMAND} -E echo,这是跨平台写法。如果你直接写echo,在Windows上可能就炸了。嗯,这里要注意跨平台兼容性。

然后,你需要让某个目标依赖这个生成的文件:

add_library(mylib SHARED main.cpp ${CMAKE_CURRENT_BINARY_DIR}/generated_config.h)

这样,每次config_template.txt变化时,CMake都会重新生成头文件,然后重新编译mylib

21.2.2 目标挂钩型(构建后处理)

这种用法不产生新文件,而是在某个目标构建完成后执行一些操作。比如我前面提到的拷贝.so文件:

add_custom_command(
    TARGET mylib
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        $<TARGET_FILE:mylib>
        ${CMAKE_SOURCE_DIR}/assets/
    COMMENT "Copying library to assets..."
)

这里POST_BUILD是关键。还有PRE_BUILDPRE_LINK,分别对应构建前和链接前。我曾经在PRE_LINK里做过代码混淆,效果还不错。

小技巧:$<TARGET_FILE:mylib>这种生成器表达式,比硬编码路径靠谱得多。尤其是在多配置生成器(如Visual Studio)下,Debug和Release的路径不同,生成器表达式会自动适配。

21.3 add_custom_target():创建虚拟目标

有时候,你希望有一个“开关”,执行一组操作,但它不产生任何编译产物。比如“清理临时文件”、“运行测试”、“打包APK”等。

add_custom_target(
    run_tests
    COMMAND ${CMAKE_COMMAND} -E echo "Running unit tests..."
    COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Running all tests..."
)

然后你可以这样运行:

cmake --build . --target run_tests

注意,add_custom_target默认总是被认为是过时的(out of date),所以每次构建都会执行。如果你希望它只在某些条件满足时执行,可以加上ALL关键字让它成为默认构建的一部分,或者通过add_dependencies()控制依赖。

21.4 构建后处理:自动化你的工作流

在Android NDK开发中,构建后处理几乎是标配。我总结了几种常见场景:

场景 推荐方式 注意事项
拷贝.so到jniLibs POST_BUILD + copy_if_different 注意ABI目录结构
生成Java包装类 OUTPUT型 + 依赖头文件 需要javac命令
代码格式化检查 自定义目标 + 手动触发 不要放在默认构建中
资源压缩/加密 PRE_BUILD + 自定义脚本 注意构建顺序

我曾经犯过一个错误:在POST_BUILD里直接删除源文件目录……结果可想而知。所以,不要在自定义命令里做不可逆的操作,除非你确认不会误触。

21.5 代码生成器集成

这是自定义命令最强大的应用场景之一。以protobuf为例,我们需要在编译前把.proto文件生成C++代码。

# 找到protobuf编译器
find_program(PROTOBUF_COMPILER protoc REQUIRED)

# 定义proto文件列表
set(PROTO_FILES
    ${CMAKE_CURRENT_SOURCE_DIR}/proto/message.proto
    ${CMAKE_CURRENT_SOURCE_DIR}/proto/config.proto
)

# 生成对应的.cc和.h文件路径
foreach(PROTO_FILE ${PROTO_FILES})
    get_filename_component(PROTO_DIR ${PROTO_FILE} DIRECTORY)
    get_filename_component(PROTO_NAME ${PROTO_FILE} NAME_WE)
    list(APPEND GENERATED_SOURCES
        ${CMAKE_CURRENT_BINARY_DIR}/proto/${PROTO_NAME}.pb.cc
    )
    list(APPEND GENERATED_HEADERS
        ${CMAKE_CURRENT_BINARY_DIR}/proto/${PROTO_NAME}.pb.h
    )
endforeach()

# 添加自定义命令:从.proto生成.pb.cc和.pb.h
add_custom_command(
    OUTPUT ${GENERATED_SOURCES} ${GENERATED_HEADERS}
    COMMAND ${PROTOBUF_COMPILER}
        --proto_path=${CMAKE_CURRENT_SOURCE_DIR}/proto
        --cpp_out=${CMAKE_CURRENT_BINARY_DIR}/proto
        ${PROTO_FILES}
    DEPENDS ${PROTO_FILES}
    COMMENT "Generating protobuf sources..."
)

# 创建生成目录
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/proto)

# 使用生成的源文件
add_library(mylib SHARED
    main.cpp
    ${GENERATED_SOURCES}
)

# 让mylib依赖生成的头文件路径
target_include_directories(mylib PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/proto)

这个模式非常通用。AIDL、Qt的MOC、甚至你自己写的代码生成器,都可以用同样的思路集成。

警告: 代码生成器的输出路径一定要放在CMAKE_CURRENT_BINARY_DIR下,不要放在源目录。否则cmake --build --clean-first会删掉你的生成文件,而且多人协作时会产生冲突。

21.6 知识体系总览

为了让你更直观地理解这些概念之间的关系,我画了一张图:

CMake自定义命令与目标知识体系 add_custom_command() OUTPUT 型(生成文件) • 指定 OUTPUT 文件路径 • 通过 DEPENDS 管理依赖 • 其他目标引用 OUTPUT 文件 TARGET 型(挂钩目标) • PRE_BUILD / PRE_LINK / POST_BUILD • 不产生新文件 • 适合拷贝、签名、通知等 add_custom_target() 虚拟目标 • 无编译产物 • 默认总是过时(always out of date) • 通过 ALL 关键字加入默认构建 典型应用 • 运行测试套件 • 打包/发布 • 代码生成器批量调用 核心原则:用 OUTPUT 型管理文件依赖,用 TARGET 型处理副作用,用自定义目标组织工作流

21.7 避坑指南

最后,分享几个我踩过的坑:

  • 路径空格问题: 如果路径包含空格,一定要用双引号括起来。我曾经在Windows上被C:\Program Files坑过无数次。
  • 命令的跨平台性: 尽量用${CMAKE_COMMAND} -E提供的命令,或者用find_program找到的工具。不要硬编码cprm等Linux命令。
  • 依赖管理: OUTPUT型命令的DEPENDS一定要写全。漏掉依赖会导致CMake认为文件是最新的,从而跳过生成步骤。
  • 并行构建: 自定义命令默认是支持并行的。如果你的命令有副作用(比如写同一个文件),记得用add_dependenciesBYPRODUCTS来避免竞争。

好了,关于自定义命令和目标,今天就聊到这里。这些工具用好了,你的构建流程会变得非常自动化、非常优雅。下次遇到重复性工作,先想想能不能用CMake帮你搞定。

一句话总结: add_custom_command是“在构建过程中做一件事”,add_custom_target是“创建一个可以做的事”。两者配合,几乎可以集成任何外部工具。

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