第1章 自定义NDK模块:模块化设计思想

各位同学,今天我们来聊聊NDK开发中一个特别重要的概念——模块化。说实话,我刚开始做NDK开发那会儿,也犯过把所有代码塞进一个CMakeLists.txt里的毛病。结果项目一大了,编译慢得像蜗牛,改一行代码要等半分钟。后来我才意识到,模块化设计才是正道。

1.1 为什么要模块化?

模块化说白了,就是把一个大工程拆成若干个小模块。每个模块各司其职,互不干扰。你想想看,如果你把所有C++源文件都放在一个目录下,用一个大CMakeLists.txt管理,会是什么后果?

  • 编译时间爆炸:改一个文件,整个项目重新编译
  • 依赖关系混乱:谁依赖谁都说不清楚
  • 复用性差:想在其他项目里用某个功能,得把整个代码库搬过去

我在项目中遇到过最典型的例子:一个视频处理App,所有算法代码混在一起。后来要单独把人脸检测模块抽出来给另一个团队用,结果花了整整两周才拆干净。要是当初就模块化设计,可能两小时就搞定了。

1.2 模块化设计的基本原则

嗯,这里我总结了几条原则,都是血泪教训换来的:

  1. 高内聚低耦合:模块内部功能要紧密相关,模块之间依赖要尽量少
  2. 接口清晰:每个模块只暴露必要的头文件和API
  3. 独立编译:每个模块可以单独编译和测试
  4. 版本管理:模块应该有明确的版本号

核心思想:模块化不是把代码拆开就完事了,而是要设计出可以独立演进、独立测试、独立部署的组件。

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_librariestarget_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架构图

下面这张图展示了模块化设计的整体架构:

native-lib (SHARED) module_audio module_video module_common 图例说明: 主模块(最终生成的so库) 子模块(静态库,独立编译) 依赖关系(箭头指向被依赖方) 模块间通信(通过接口)

注意:模块间不要出现循环依赖。比如module_audio依赖module_common,module_common又依赖module_audio,这会导致编译失败。我曾经在一个遗留项目里见过这种"死亡循环",花了整整一天才理清楚。

1.7 模块化带来的好处

最后总结一下,模块化设计到底能带来什么:

场景 非模块化 模块化
编译时间 改一行代码,全量编译3分钟 只编译受影响模块,30秒
代码复用 复制粘贴,维护噩梦 直接引用模块,一行代码搞定
团队协作 改同一个文件,冲突不断 各管各的模块,互不干扰
测试 要测就得跑整个App 单独编译测试模块,快速验证

说白了,模块化设计就是花点前期功夫,换来后期无数倍的效率提升。我做了这么多年NDK开发,可以负责任地说:模块化不是可选项,而是必选项。项目一旦超过5个源文件,就应该考虑模块化了。

好了,这一章的内容就到这里。记住:好的模块化设计,就像搭积木一样,每个模块都是独立的、可替换的、可复用的。下一章我们会深入讲解如何用CMake管理这些模块的依赖关系。


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