第二十一章:自定义命令与目标: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_BUILD和PRE_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 知识体系总览
为了让你更直观地理解这些概念之间的关系,我画了一张图:
21.7 避坑指南
最后,分享几个我踩过的坑:
- 路径空格问题: 如果路径包含空格,一定要用双引号括起来。我曾经在Windows上被
C:\Program Files坑过无数次。 - 命令的跨平台性: 尽量用
${CMAKE_COMMAND} -E提供的命令,或者用find_program找到的工具。不要硬编码cp、rm等Linux命令。 - 依赖管理: OUTPUT型命令的
DEPENDS一定要写全。漏掉依赖会导致CMake认为文件是最新的,从而跳过生成步骤。 - 并行构建: 自定义命令默认是支持并行的。如果你的命令有副作用(比如写同一个文件),记得用
add_dependencies或BYPRODUCTS来避免竞争。
好了,关于自定义命令和目标,今天就聊到这里。这些工具用好了,你的构建流程会变得非常自动化、非常优雅。下次遇到重复性工作,先想想能不能用CMake帮你搞定。
一句话总结: add_custom_command是“在构建过程中做一件事”,add_custom_target是“创建一个可以做的事”。两者配合,几乎可以集成任何外部工具。