Android构建迁移:从Make/Android.mk到Soong/Android.bp实战指南
1. 从Make到SoongAndroid构建引擎为什么非换不可在AOSP里待久了你会发现一个很有意思的现象同样是定义一个模块老模块写在Android.mk里新模块写在Android.bp里你在终端敲一条make命令日志里却反复出现soong、kati、ninja的名字。我第一次面对这套工具链时最大的困惑不是某个语法写不对而是搞不清谁是老大、谁真正干活、谁只是传话的。把android.bp、android.mk、make、soong之间的关系理清之后再回来看Framework开发和平台模块的增删改很多以前靠猜的事情一下子就变得确定了。这篇文章我就把这条构建链路完整拆开重点聊四件事Make体系为什么会被Soong取代一条make命令背后到底发生了什么Android.mk和Android.bp表达同一模块时有哪些对应关系以及Framework开发中从mk迁移到bp、新增模块、排查构建问题时最实用的经验和坑。系统应用、JNI库、HAL服务、SystemServer周边模块的开发都会用到这些东西。1.1 Make体系的天花板Android.mk膨胀带来的维护问题先回到Android早期。整个平台用GNU make构建每个模块是一个Android.mk片段build/core目录下堆了大量重复使用的make函数和模板比如include $(BUILD_SHARED_LIBRARY)这类宏。模块之间通过全局变量LOCAL_*传递信息写完一个模块必须马上include $(CLEAR_VARS)把LOCAL_变量清掉否则下一个模块会自动继承上一个模块的脏数据。用make构建Android这种体量的项目问题不只是LOCAL_变量容易串。GNU make在解析阶段要把成千上万个mk文件层层include进来构成一张巨大的依赖图配置一次要几十秒甚至几分钟内存占用高得吓人。更难受的是make的规则是命令式的模块之间谁依赖谁、产物在哪里都得靠一堆隐式规则和约定去推导做并行化优化非常困难。Google在Android 7时代开始推SoongAndroid 8以后新代码默认走Soong根本原因就是make的处理模型已经不适合Android的工程规模了。这里要补充一个背景GNU make本身的增量解析能力也跟不上。编译器、AIDL、资源打包、Dex处理这些步骤规则复杂makefile里大量使用shel命令动态生成内容构建系统很难准确判断哪些步骤需要重跑。Google早期靠Android.mk约定common/device/host等变体规则越叠越多build/core里的模板文件越来越难读。维护者逐渐意识到必须换一种“声明模块是什么”而不是“描述模块怎么做”的语法。1.2 Soong与Blueprint的分工Soong经常和Blueprint放在一起提但二者不是同一个东西。Blueprint是构建描述文件的解析框架负责把Android.bp这种接近JSON的语法读进来生成一棵模块树Soong是在Blueprint之上实现具体构建逻辑的引擎它把模块树翻译成底层构建工具能识别的规则。底层执行工具是NinjaNinja只做一件事——按照规则文件里的依赖关系快速执行编译命令它不对模块语义做任何理解也根本不认识什么cp_java_library、cc_binary。所以Android.bp里的模块其实经历了两层转换Blueprint解析成对象Soong转成Ninja rule。这也是声明式语法相对make的核心优势bp文件里没有全局变量没有顺序依赖每个模块的属性都是独立的Soong可以更轻松地做并行调度、缓存和增量分析。打个比方make像是让每个模块写一份“怎么做菜”的步骤说明而Soong让每个模块写“我是一道什么菜需要哪些食材”烹饪流程由中央厨房统一设计。后者看到全貌自然比前者各自为战高效。1.3 一个容易忽略的现状mk并没有被立刻清除Android 9之后官方就明确建议新模块使用Android.bp但老代码里的Android.mk不可能一夜之间全部重写。为了兼容构建系统里保留了一个叫Kati的组件它负责把Android.mk按make语义跑一遍最终输出成Ninja规则。这就是为什么今天的AOSP源码树里mk和bp长期并存mk模块经过Kati进构建图bp模块经过Soong进构建图最后两张图合并由Ninja统一执行。想通这一点不少现象就解释得通了你打开一个老目录里面只有Android.mk没有Android.bp它照样能参与全量编译你新写了一个模块放在Android.bp里却没有在product配置里加PRODUCT_PACKAGES编镜像时它就是不会被打进去。mk和bp是两条输入通道但决定模块是否被编译/安装的规则并不完全一样后者更容易踩坑到第5章细说。2. 一条make命令背后mk、bp、Ninja三个世界的握手流程很多人在源码根目录直接敲make如果之前没初始化环境会看到一句很经典的报错make: *** No targets specified and no makefile found. Stop.这句话让许多新手误以为“AOSP没有Makefile”。实际上AOSP的构建不是让你直接面对GNU make而是要经过source build/envsetup.sh和lunch两步把环境变量、目标产品和一堆辅助函数准备好再由构建封装层去协调Soong、Kati和Ninja。2.1 配置阶段Kati和Soong的先后关系在终端执行m或者make之后构建系统先进入配置阶段。这个阶段要做两件事读取所有Android.mk并读取所有Android.bp。mk这边由Kati负责。Kati是Google用Go/ C实现的一个“类make执行器”它会模拟GNU make的解析和变量展开逻辑把build/make目录下那套复杂的模板、以及所有模块里的Android.mk全部跑一遍生成一份包含mk模块的Ninja规则片段。bp这边由soong_build负责。它会调用Blueprint库扫描源码树里所有Android.bp文件解析模块定义再调用Soong的各路模块工厂比如cc_library_shared对应C模块工厂android_app对应应用模块工厂生成另一份Ninja规则片段。两边的处理都完成后构建系统把两套规则合并成总的Ninja文件。合并的时候需要处理模块名冲突、依赖关系跨系统引用等问题这也是接下来要展开的重点。2.2 两个Ninja世界的汇合Kati生成的规则和Soong生成的规则最终会汇总到out/soong/build.ninja不同版本的文件名略有差异比如combined_xxx.ninja然后由Ninja执行真正的编译动作。你看到的m libxx本质上是去Ninja规则里找libxx这个目标Ninja再根据依赖图决定先编谁、后编谁以及能不能并行。这里有个容易混淆的点Kati生成的规则里mk模块的依赖关系是“模拟make语义”得出来的Soong生成的规则里bp模块的依赖关系是“声明式属性”直接翻译出来的。两者的依赖解析方式不同但最终都变成了同一种Ninja语法的文件。你在bp模块里shared_libs指向一个老mk模块Soong也能接受是因为合并时构建系统补齐了跨映射。增量编译的关键也在这份Ninja文件里Ninja会记录每个rule的命令、输入输出文件和restat标记源文件时间戳变化后能精准定位到需要重跑的编译动作。mk时代那种“改一个头文件导致全量重编”的体验到了Ninja时代明显改善但前提是模块依赖声明得正确。2.3 m、mm、mma的真正区别Framework开发日常最常用的三个命令是m、mm、mma。它们都是envsetup.sh里定义的shell函数。m 模块名通过Soong的模块索引直接编译指定模块比如m framework、m services。Soong会先检查模块名是否在bp模块集里再决定触发一次全量配置还是只更新局部规则。mm在模块目录下执行只编译“当前目录及子目录中包含的模块”。它特别适合你只改了一个JNI库、不想花时间做全量配置的场景。mm对bp和mk模块都有效靠的是扫描当前目录的Android.bp或Android.mk。mma在mm基础上会把当前模块依赖的其他模块也一并编译检查适合跨模块改动后做验证。这三个命令背后都会触发Soong/Kati的配置重算只是范围和深度不同。如果你改了Android.bp本身比如新增了一个依赖库旧的配置不会自动感知通常需要重新执行m或者mm让Soong重新解析不能只在编辑器里改了bp文件就以为构建系统会即时跟进。3. android.bp与android.mk语法对拍同义模块的两种表达接下来进入操作层面的核心同一个模块在mk里怎么写在bp里怎么写。搞懂这份对应关系读老代码和写新代码就都不慌了。3.1 一个共享库从mk改写为bp的完整对照假设我有一个C共享库libmysample先看Android.mk的写法LOCAL_PATH : $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE : libmysample LOCAL_SRC_FILES : \ mysample.cpp \ utils.cpp LOCAL_C_INCLUDES : \ $(LOCAL_PATH)/include \ $(TOP)/system/core/include LOCAL_SHARED_LIBRARIES : \ liblog \ libutils \ libbase LOCAL_STATIC_LIBRARIES : libmystatic LOCAL_CFLAGS : -Wall -Werror include $(BUILD_SHARED_LIBRARY)对应的Android.bp写法cc_library_shared { name: libmysample, srcs: [ mysample.cpp, utils.cpp, ], local_include_dirs: [include], shared_libs: [ liblog, libutils, libbase, ], static_libs: [libmystatic], cflags: [-Wall, -Werror], }一眼能看出几个关键差异bp里没有LOCAL_PATH因为模块根目录就是Android.bp所在目录不需要显式声明bp里没有include $(CLEAR_VARS)因为每个模块都是独立作用域不存在全局变量残留bp的属性名是声明式的srcs、shared_libs、cflags直接描述模块“是什么”而不是“怎么编”。LOCAL_C_INCLUDES在bp里通常写成local_include_dirs它只对本模块生效。如果你希望别的模块引用这个库时自动带上某个头文件目录mk里会用LOCAL_EXPORT_C_INCLUDE_DIRSbp里对应export_include_dirs。这个区别特别容易忽略在mk里写LOCAL_C_INCLUDES某些老代码也能“侥幸”让依赖模块拿到头文件但bp里必须明确区分“本地可见”和“导出可见”。3.2 高频LOCAL_*字段到bp属性的映射表以下是我在Framework开发中整理得最多的一份映射基本覆盖系统应用、JNI库、HAL模块的常见场景Android.mkAndroid.bp说明LOCAL_MODULEname模块名构建系统里的唯一标识LOCAL_SRC_FILESsrcs源文件列表LOCAL_C_INCLUDESlocal_include_dirs本模块私有头文件目录LOCAL_EXPORT_C_INCLUDE_DIRSexport_include_dirs需要暴露给依赖方的头文件目录LOCAL_SHARED_LIBRARIESshared_libs动态依赖库LOCAL_STATIC_LIBRARIESstatic_libs静态依赖库LOCAL_HEADER_LIBRARIESheader_libs只依赖头文件的库模块LOCAL_CFLAGScflagsC/C 编译参数LOCAL_CPPFLAGScppflagsC 专属编译参数LOCAL_SDK_VERSIONsdk_version是否使用公共SDK APILOCAL_CERTIFICATEcertificate应用签名证书LOCAL_PRIVILEGED_MODULEprivileged: true是否放入priv-appLOCAL_MODULE_TAGS不用写老字段optional等概念已废弃LOCAL_MODULE_RELATIVE_PATHrelative_install_path模块安装的子目录这张表不是官方文档搬运是实际迁移时改动最多的字段。特别提一下LOCAL_SDK_VERSIONmk时代控制“这个应用/库能不能用隐藏API”主要靠它和LOCAL_SDK_VERSION : currentbp里对应platform_apis或sdk_version处理系统签名应用时要格外注意写错会把整个模块限制到公共API集合里导致编译报错或者运行时NoSuchMethodError。3.3 条件编译从ifeq到arch块和soong_configmk里最常用的写法是按架构或产品变量做条件分支比如ifeq ($(TARGET_ARCH), arm64) LOCAL_CFLAGS -DUSE_ARM64_OPT1 endifbp里处理架构差异靠的是arch块cc_library_shared { name: libmysample, srcs: [mysample.cpp], arch: { arm64: { cflags: [-DUSE_ARM64_OPT1], }, x86_64: { cflags: [-DUSE_X86_OPT1], }, }, }这是bp较优雅的地方把“同一语义的不同架构配置”收敛到一处而不是在mk里散落一堆ifeq。但bp也有不如mk灵活的地方——它不能直接读取PRODUCT_*、TARGET_*这些任意环境变量去写条件。mk里你可以写ifneq ($(filter myapp,$(PRODUCT_PACKAGES)),)bp里没有等价语法。真要按产品开关控制模块属性得用soong_config_module_type定义可配置属性然后在产品mk里通过soong_config_set设置。这个机制写起来比mk复杂但在现代AOSP里已经是很常见的能力。我的建议是如果只是按架构区分直接用arch块如果按产品特性区分优先考虑把公共属性放到defaults里再用soong_config做开关不要为了贪图mk的灵活而硬在bp里塞条件语法。4. 实战迁移演练把一个vendor native服务从mk搬到bp理论说再多不如动手迁一个模块。我拿一个典型的vendor native服务举例它的职责是开机后通过HIDL/AIDL接口向上层提供一个硬件能力。老代码里是Android.mk现在要迁到Android.bp。4.1 迁移前摸底确认依赖边界和编译环境不要一上来就把Android.mk删掉。先做三件事第一确认这个模块的依赖关系。看Android.mk里的LOCAL_SHARED_LIBRARIES、LOCAL_STATIC_LIBRARIES逐个用m 库名试编译一遍确保这些依赖库在现有源码树里都能被Soong索引到。老mk模块如果依赖了某个同样只存在于mk里的库迁移后在bp里引用也不会有大问题因为Kati产物会和Soong产物合并但依赖的库名必须和它在mk里的LOCAL_MODULE完全一致。第二确认模块的目标分区和安装路径。Framework开发的很多模块要进vendor分区mk里通常写得比较隐式比如LOCAL_VENDOR_MODULE : true或者通过include $(BUILD_NATIVE_SERVICE)这类模板自动处理。bp里需要显式写vendor: true或product_available: true。这一步最容易漏漏了之后模块会被编进system分区或者干脆找不到产物。第三确认编译环境。建议在source build/envsetup.sh lunch之后做迁移这样可以直接用androidmk工具辅助转换。4.2 用androidmk做初稿再手工清理残局build/soong/androidmk目录下自带一个转换工具lunch环境里可以直接执行cd $AOSP androidmk Android.mk Android.bp.new自动转换的结果通常能覆盖七八成语法比如LOCAL_MODULE变成nameLOCAL_SRC_FILES变成srcsBUILD_SHARED_LIBRARY变成cc_library_shared。但它对条件分支、特殊模板、自定义函数基本无能为力会生成奇怪的mk_*字段比如mk_local_module_tags、mk_include这样的半成品。拿到初稿后我一般按这个顺序人工清理删除所有mk_*字段逐条看它当初想表达什么语义然后翻映射表改成正式bp属性核对srcs里的文件路径mk里喜欢用相对LOCAL_PATH的路径转换后通常是对的但要注意有没有$(call my-dir)拼接出来的路径残留核对shared_libs、static_libs确认没有丢失依赖如果mk里写了LOCAL_WHOLE_STATIC_LIBRARIESbp里要改成whole_static_libs这两个语义完全不同检查vendor、product_available、proprietary这些分区属性按模块实际归属补上清理Android.bp里的空数组和多余逗号保持格式整洁。顺手展示一个清理后的典型结果cc_binary { name: vendor.mymodule, relative_install_path: hw, vendor: true, srcs: [ main.cpp, service.cpp, ], shared_libs: [ libbinder, libhidlbase, liblog, libutils, ], static_libs: [libmymodule_static], cflags: [-Wall, -Werror], }注意如果模块名带点号比如vendor.mymodule在mk里通常不会这么命名但bp里很常见。还有一点mk里的LOCAL_MODULE如果带路径分隔符转换后很可能需要拆成name加relative_install_path务必逐字检查安装路径。4.3 编译验证与产物确认改完Android.bp后先不要动Android.mk用一个临时编不过的方式确认bp版本没问题。最稳的流程是先把Android.bp.new改成Android.bp同时把Android.mk改名成Android.mk.bak然后执行m vendor.mymodule如果构建通过去out/target/product/device/{system,vendor}/下找产物。比如这个服务声明了vendor: true且relative_install_path是hw那产物大概率在vendor/bin/hw/vendor.mymodule。用find out -name vendor.mymodule确认路径再对比原mk模块的安装位置。确认无误后把Android.mk.bak删掉在版本控制系统里提交。这里有个经验迁移模块时建议同时升级一下依赖这个模块的其他mk文件。比如某个老mk通过LOCAL_SHARED_LIBRARIES : libmysample依赖它迁移后库名没有变mk文件通常不用动。但如果你为了规范把库名也改了比如从libmysample改成vendor.mysample.lib那所有引用老名字的mk和bp都得同步更新牵一发动全身。所以我的原则是迁移时尽量保持模块name不变先做语法迁移不建议顺手改名。5. Framework日常开发里最容易踩的构建坑最后这部分是我在Framework开发中真正吃过的亏列出来给后来人当参考。每一条的触发条件在国内常见架构、厂商源码树里都很容易出现。5.1 模块冲突bp和mk同时定义同一个名字最常见的问题是迁移到一半Android.bp已经存在但某个老目录里还有一个同名模块的Android.mkSoong在合并规则时会报error: duplicate module name: libxxx这时候不要慌先用find全局搜一下这个模块名出现在哪些Android.bp和Android.mk里。通常原因是某次迁移时只改了部分模块或者厂商私有代码里还留了一份同名mk作为prebuilt。处理方式不是简单删掉一个而是要搞清楚哪个才是真正要被依赖的模块。例如系统里可能有源码模块libfoo和一个预编好的prebuilt_libfoo在mk时代可以靠LOCAL_MODULE_CLASS和安装优先级共存迁移到bp后就必须用prebuilt_etc、cc_prebuilt_library_shared这类带前缀的模块类型来区分。5.2 依赖名字对不上mk的LOCAL变量与bp的模块引用bp里shared_libs、static_libs、header_libs写的是模块名不是文件路径也不是LOCAL_MODULE_STEM这种输出名。这个规则看起来简单实际很容易出错。比如mk里有LOCAL_MODULE : libfoo LOCAL_MODULE_STEM : libfoo_release.sobp里依赖它要写libfoo而不是libfoo_release。有些老模块习惯用LOCAL_MODULE_STEM定制输出文件名导致bp引用时按文件名去找模块名怎么编都是“unknown module”。排查方法很直接用m 名字逐个试或者打开out/soong/build.ninja搜模块名看它在Soong规则里的真实注册名是什么。5.3 glob和条件分支声明式语法改变了的游戏规则bp的srcs支持glob写法比如srcs: [src/*.cpp]但我不建议过度使用。Soong确实会对glob目录做追踪新增文件后重新配置能感知到但glob会让依赖关系失去显式性一旦某个目录下的文件被意外挪走编译期可能整个模块突然少文件报错也比显式列出文件更隐晦。mk时代你可以用shell在编译期动态拼文件列表bp里完全不允许任意shell。如果模块确实需要在配置期做复杂处理不要硬在bp里实现应该考虑先定义一个soong_config_module_type或者把需要动态生成的文件提前交给构建流程的其他阶段处理。干脆一点说bp的“不灵活”是为了可预测性一个模块如果依赖一堆运行期动态逻辑建议尽早抽象出稳定的defaults和srcs列表而不是每天靠重新生成bp文件维持。5.4 编出来不等于装进去PRODUCT_PACKAGES才是最后一道门这是我见过最频繁的误解。很多新人写了一个新app或者新native服务放在packages/apps/或vendor/目录下构建时不指定模块名直接全量make系统镜像然后发现产物里根本没有这个模块。原因很简单Android.bp只是定义了模块不代表它会被编进image。一个模块进入系统镜像要么被其他已被打包的模块依赖要么被加进product配置文件里的PRODUCT_PACKAGES。Framework开发场景里framework.jar、services.jar这种系统核心产物因为被系统镜像默认依赖所以不需要在PRODUCT_PACKAGES里显式列出。但你写一个自定义JNI库、一个vendor服务、一个系统特权应用时如果没有在device/厂商/产品/device.mk或对应的product.mk里加上PRODUCT_PACKAGES 模块名全量镜像里永远看不到它。这个坑排查起来也不难m 你的模块名确认编译成功再用find out/target/product/device/ -name 你的模块名*找产物。如果编译成功但镜像里没有直接去PRODUCT_PACKAGES检查。5.5 没初始化环境就执行make的坑回到开头那条“No targets specified and no makefile found”补充一下使用姿势。AOSP的根目录确实有Makefile但它是构建系统生成的前置入口必须依靠envsetup注入的环境才能被正确解析。正确做法是source build/envsetup.sh lunch product-userdebug m target很多厂商源码里还推荐用./build.sh或者repo封装脚本本质上都是先初始化环境再调用make/m。遇到“找不到makefile”或“没有指定目标”的报错先怀疑自己是不是没lunch而不是怀疑构建系统坏了。关于新老工具链的取舍一点个人建议现在的AOSP主干已经非常明确新模块一律写Android.bp老模块能迁就迁。做Framework开发时我通常不会为了追求“全bp化”而疯狂重写历史mk模块而是遵循几个简单原则准备新增模块时用bp老模块只在需要改依赖关系或分区属性时顺手迁移迁移时保持模块名不变避免连锁改动大型迁移一定要配合PRODUCT_PACKAGES核对安装结果。构建系统不是Framework业务逻辑但它决定了你每天改一行代码要等多久才能看到效果。花一个下午把android.bp、android.mk、make、Soong之间的链路理一遍后续在framework/base、packages/apps、vendor各个目录里做增删改查时会明显少走弯路。如果你也刚好处在一个mk和bp长期并存的厂商源码树上希望这篇能帮你看清脚下这条路。