第1章 自定义NDK模块:模块化设计思想
各位同学,今天我们来聊聊NDK开发中一个特别重要的概念——模块化。说实话,我刚开始做NDK开发那会儿,也犯过把所有代码塞进一个CMakeLists.txt里的毛病。结果项目一大了,编译慢得像蜗牛,改一行代码要等半分钟。后来我才意识到,模块化设计才是正道。
1.1 为什么要模块化?
模块化说白了,就是把一个大工程拆成若干个小模块。每个模块各司其职,互不干扰。你想想看,如果你把所有C++源文件都放在一个目录下,用一个大CMakeLists.txt管理,会是什么后果?
- 编译时间爆炸:改一个文件,整个项目重新编译
- 依赖关系混乱:谁依赖谁都说不清楚
- 复用性差:想在其他项目里用某个功能,得把整个代码库搬过去
我在项目中遇到过最典型的例子:一个视频处理App,所有算法代码混在一起。后来要单独把人脸检测模块抽出来给另一个团队用,结果花了整整两周才拆干净。要是当初就模块化设计,可能两小时就搞定了。
1.2 模块化设计的基本原则
嗯,这里我总结了几条原则,都是血泪教训换来的:
- 高内聚低耦合:模块内部功能要紧密相关,模块之间依赖要尽量少
- 接口清晰:每个模块只暴露必要的头文件和API
- 独立编译:每个模块可以单独编译和测试
- 版本管理:模块应该有明确的版本号
核心思想:模块化不是把代码拆开就完事了,而是要设计出可以独立演进、独立测试、独立部署的组件。
1.3 创建独立的CMakeLists.txt
每个模块都应该有自己的CMakeLists.txt。我习惯这样组织目录结构:
app/
├── src/main/cpp/
│ ├── CMakeLists.txt # 主构建文件
│ ├── module_audio/
│ │ ├── CMakeLists.txt # 音频模块
│ │ ├── audio_processor.cpp
│ │ └── audio_processor.h
│ ├── module_video/
│ │ ├── CMakeLists.txt # 视频模块
│ │ ├── video_encoder.cpp
│ │ └── video_encoder.h
│ └── module_common/
│ ├── CMakeLists.txt # 公共模块
│ ├── utils.cpp
│ └── utils.h
每个子模块的CMakeLists.txt长这样:
# module_audio/CMakeLists.txt
add_library(audio_module STATIC
audio_processor.cpp
)
target_include_directories(audio_module PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(audio_module PUBLIC
common_module
)
注意这里用的是STATIC库。为什么?因为静态库在Android上更容易管理,不会出现so文件冲突的问题。我曾经被动态库的版本兼容问题坑过好几次,后来就默认用静态库了。
1.4 add_subdirectory() 的正确用法
主CMakeLists.txt里用add_subdirectory()把子模块引进来:
# 主CMakeLists.txt
cmake_minimum_required(VERSION 3.18)
project("MyApp")
# 添加子模块
add_subdirectory(module_common)
add_subdirectory(module_audio)
add_subdirectory(module_video)
# 最终的目标库
add_library(native-lib SHARED
native-lib.cpp
)
target_link_libraries(native-lib
audio_module
video_module
common_module
)
小技巧:add_subdirectory() 的顺序很重要。被依赖的模块要放在前面,否则CMake会报找不到依赖的错误。
这里有个坑:add_subdirectory() 默认会把子目录的构建目标全部暴露出来。如果你只想暴露部分目标,可以用 EXCLUDE_FROM_ALL 参数:
add_subdirectory(module_test EXCLUDE_FROM_ALL)
这样测试模块就不会被默认构建了,只有显式指定才会编译。
1.5 模块间通信
模块之间怎么通信?说白了就是两个问题:
- 编译时通信:头文件包含和链接依赖
- 运行时通信:函数调用和回调
编译时通信靠CMake的target_link_libraries和target_include_directories搞定。运行时通信就要靠接口设计了。
我推荐用抽象接口类来实现模块间通信:
// module_common/audio_callback.h
class AudioCallback {
public:
virtual ~AudioCallback() = default;
virtual void onAudioData(const uint8_t* data, size_t size) = 0;
};
// module_audio/audio_processor.h
#include "audio_callback.h"
class AudioProcessor {
public:
void setCallback(AudioCallback* callback);
void process();
private:
AudioCallback* mCallback = nullptr;
};
这样音频模块和视频模块之间就没有直接依赖了,都只依赖公共模块定义的接口。我在实际项目中用这种方式,成功把6个模块的耦合度降到了最低。
1.6 模块化设计实战:SVG架构图
下面这张图展示了模块化设计的整体架构:
注意:模块间不要出现循环依赖。比如module_audio依赖module_common,module_common又依赖module_audio,这会导致编译失败。我曾经在一个遗留项目里见过这种"死亡循环",花了整整一天才理清楚。
1.7 模块化带来的好处
最后总结一下,模块化设计到底能带来什么:
| 场景 | 非模块化 | 模块化 |
|---|---|---|
| 编译时间 | 改一行代码,全量编译3分钟 | 只编译受影响模块,30秒 |
| 代码复用 | 复制粘贴,维护噩梦 | 直接引用模块,一行代码搞定 |
| 团队协作 | 改同一个文件,冲突不断 | 各管各的模块,互不干扰 |
| 测试 | 要测就得跑整个App | 单独编译测试模块,快速验证 |
说白了,模块化设计就是花点前期功夫,换来后期无数倍的效率提升。我做了这么多年NDK开发,可以负责任地说:模块化不是可选项,而是必选项。项目一旦超过5个源文件,就应该考虑模块化了。
好了,这一章的内容就到这里。记住:好的模块化设计,就像搭积木一样,每个模块都是独立的、可替换的、可复用的。下一章我们会深入讲解如何用CMake管理这些模块的依赖关系。