一套底层代码,N个SDK产品:Android模块合并最佳实践

52 阅读12分钟

痛点场景:为什么需要多SDK合并?

场景描述

假设你是一家物联网公司的SDK开发者,需要为不同客户提供蓝牙标签管理SDK。你的产品矩阵是这样的:

客户A(零售商超)

  • 需要功能:刷图、固件升级
  • 预算有限,只要基础功能

客户B(仓储物流)

  • 需要功能:刷图、固件升级、巡检、定位
  • 愿意为增值功能付费

客户C(智能工厂)

  • 需要功能:刷图、固件升级、点灯提示、修改广播间隔、温湿度监控
  • 需要深度定制

传统方案的困境

方案1:给每个客户提供完整SDK

❌ 问题:
- 客户A可以通过反编译发现隐藏功能,不付费就能使用
- 功能泄露导致定价策略失效
- 竞争对手可能通过客户A获取完整代码

方案2:为每个客户单独维护代码仓库

❌ 问题:
- 底层bug修复需要同步到N个仓库
- 代码维护成本指数级增长
- 版本管理混乱,容易出现不一致

方案3:使用多个Gradle module依赖

❌ 问题:
- 生成的AAR可能不包含依赖模块的代码
- fat-aar插件在Gradle 8+兼容性差
- 打包后体积大,包含不必要的中间产物
- IDE中代码频繁爆红,开发体验差

理想方案的需求

我们需要一个方案能够:

代码复用:底层核心代码只维护一份
功能差异化:不同客户SDK包含不同功能
打包简洁:输出单一AAR/JAR文件,集成简单
开发体验好:IDE不爆红,代码提示正常
混淆灵活:可针对不同客户配置不同的混淆规则
安全可控:客户无法通过反编译获取未授权功能

真实痛点示例

// 客户A的SDK(基础版)
public class MTTagBleManager {
    public void updateImage() { ... }      // ✅ 提供
    public void upgradeFirmware() { ... }  // ✅ 提供
    // private void inspect() { ... }      // ❌ 混淆/删除
    // private void setLED() { ... }       // ❌ 混淆/删除
}

// 客户B的SDK(增强版)
public class MTTagBleManager {
    public void updateImage() { ... }      // ✅ 提供
    public void upgradeFirmware() { ... }  // ✅ 提供
    public void inspect() { ... }          // ✅ 提供
    public void locate() { ... }           // ✅ 提供
    // private void setLED() { ... }       // ❌ 混淆/删除
}

传统module依赖无法实现:

  • 无法针对不同客户定制ProGuard规则
  • 底层module的build.gradle在打包时不生效
  • 需要手动管理每个客户的功能开关

本文解决方案

通过源码合并 + settings.gradle配置的方式,实现:

项目结构:
SDK_Base(基础工具层)
  ↓
SDK_Core(核心功能层)
  ↓
SDK_Standard(客户A)→ 输出 standard.aar
SDK_Enhanced(客户B)→ 输出 enhanced.aar
SDK_Premium(客户C) → 输出 premium.aar

每个SDK壳:

  • 共享底层代码(源码合并,单一维护)
  • 独立混淆配置(功能差异化)
  • 输出独立AAR(客户无法交叉使用)
  • IDE体验完美(代码不爆红)

效果对比:

方案代码维护功能控制打包质量IDE体验安全性
完整SDK简单❌ 无法控制✅ 良好✅ 良好❌ 差
多仓库❌ 复杂✅ 可控✅ 良好✅ 良好✅ 好
module依赖✅ 简单⚠️ 有限⚠️ 一般❌ 差⚠️ 一般
源码合并(本方案)✅ 简单✅ 可控✅ 优秀✅ 优秀✅ 好

一、核心原理

1.1 基本思路

结论先行:源码合并 + 移除module依赖 + 移除settings.gradle配置

在Android开发中,当需要将多个SDK模块打包成一个JAR/AAR文件时,最稳定可靠的方案是将依赖库作为源码目录加入,而非使用传统的Gradle module依赖方式。

1.2 为什么不用依赖方式?

传统的module依赖方式:

dependencies {
    api project(':sdk-core-lib')  // ❌ 不推荐
}

存在的问题:

  • 生成的AAR可能不包含依赖模块的完整代码
  • 需要额外处理传递依赖
  • fat-aar插件在Gradle 8+版本兼容性差
  • SDK壳中的代码会爆红(找不到依赖模块的类)

1.3 源码合并的工作原理

最佳方案:把依赖库当做"源代码目录"加入

这种方式不将其视为独立的Gradle module,而是直接作为源码目录,让Android Studio能正常识别和引用。

目录结构示例:

项目根目录/
├── SDK_Standard/          (主模块)
│   ├── src/main/java/
│   └── build.gradle
├── SDK_Core/              (作为源码目录)
│   └── src/main/java/
└── SDK_Base/              (作为源码目录)
    └── src/main/java/

第一步:在主模块的build.gradle中配置sourceSets

android {
    sourceSets {
        main {
            java.srcDirs += ['../SDK_Core/src/main/java']
            java.srcDirs += ['../SDK_Base/src/main/java']
            // 如果有资源文件
            res.srcDirs += ['../SDK_Core/src/main/res']
            res.srcDirs += ['../SDK_Base/src/main/res']
        }
    }
}

dependencies {
    // ⚠️ 删除原来的module依赖
    // api project(':SDK_Core')
    // api project(':SDK_Base')
}

第二步:从settings.gradle中移除依赖模块

// settings.gradle
rootProject.name = 'MySDKProject'
include ':SDK_Standard'  // ✅ 保留主模块
// include ':SDK_Core'     // ❌ 删除
// include ':SDK_Base'     // ❌ 删除

关键优势:

  • ✅ Android Studio自动识别源码,代码不再爆红
  • ✅ 无Gradle module依赖问题
  • ✅ 效果等同于直接拷贝代码,但目录结构更清晰
  • ✅ 便于多SDK壳共享底层代码

注意事项:

  • 不支持Gradle的dependency graph(因为不是module)
  • 这正是我们想要的"作为源码目录存在"的效果

二、实践场景

2.1 典型SDK分层结构

在实际项目中,SDK通常采用分层架构:

SDK_Standard(标准版)
├── SDK_Core(核心功能层)
│   └── SDK_Base(基础工具层)
├── 功能:刷图 + 升级

SDK_Custom_A(定制版A)
├── SDK_Core
│   └── SDK_Base
├── 功能:刷图 + 升级 + 巡检

SDK_Custom_B(定制版B)
├── SDK_Core
│   └── SDK_Base
├── 功能:刷图 + 升级 + 点灯 + 修改广播间隔

2.2 合并策略

每个SDK壳只保留一个对外接口类(如MTTagBleManager),底层代码通过源码合并方式整合:

  1. 源码合并:将SDK_Core和SDK_Base的代码合并到SDK壳中
  2. 配置统一:所有混淆、打包配置在顶层SDK壳中完成
  3. 功能差异化:通过不同的混淆规则控制对外开放的功能

三、核心规则(⭐ 极重要)

规则1:所有module必须以源码形式合并

正确的合并层级:

SDK_Standard(顶层AAR输出)
  ↓ 源码合并
SDK_Core
  ↓ 源码合并
SDK_Base

规则2:只有顶层build.gradle有效

使用源码合并后:

  • ✅ SDK_Standard的build.gradle有效
  • ❌ SDK_Core的build.gradle失效
  • ❌ SDK_Base的build.gradle失效

失效的配置项包括:

  • dependencies(需要手动迁移到顶层)
  • buildConfigField(需要手动生成BuildConfig)
  • proguard-rules.pro(需要合并到顶层混淆文件)
  • manifestPlaceholders(需要在顶层重新配置)

规则3:资源文件自动合并,但需注意冲突

资源合并规则:

  • 同名资源会被顶层module覆盖
  • 建议使用命名前缀避免冲突:
    • SDK_Basebase_xxx
    • SDK_Corecore_xxx
    • SDK_Standardstandard_xxx

规则4:避免包名和类名冲突

错误示例:

SDK_Base/com/company/utils/Utils.kt
SDK_Core/com/company/utils/Utils.kt  // ❌ 冲突!

正确做法:

SDK_Base/com/company/base/utils/Utils.kt
SDK_Core/com/company/core/utils/CoreUtils.kt
SDK_Standard/com/company/sdk/utils/SdkUtils.kt

四、完整实现步骤

4.0 调试与打包的配置切换(⭐ 重要实践经验)

在实际开发中,我们发现了一个调试时代码爆红的问题及其解决方案:

问题描述:

  • 使用 sourceSets 方式合并源码后,IDE 中代码会爆红
  • 特别是多个 SDK 模块同时引用同一个源码目录时,会出现 Duplicate content roots detected 警告
  • 虽然项目可以正常编译运行,但开发体验很差

解决方案:调试与打包分离配置

android {
    namespace 'com.company.sdk.standard'
    compileSdk 34
    
    defaultConfig {
        minSdk 21
        targetSdk 34
        versionCode 1
        versionName "1.0.0"
    }
    
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 
                         'proguard-rules.pro'
        }
    }
    
    // 🔴 打包时启用 sourceSets(注释掉 dependencies 中的 api project)
    // 🟢 调试时注释掉 sourceSets(启用 dependencies 中的 api project)
    
//    sourceSets {
//        main {
//            java.srcDirs += ['../mi.ard.java.esl-v3-lib/src/main/java']
//            java.srcDirs += ['../SDK_Core/src/main/java']
//            java.srcDirs += ['../SDK_Base/src/main/java']
//            
//            res.srcDirs += ['../mi.ard.java.esl-v3-lib/src/main/res']
//            res.srcDirs += ['../SDK_Core/src/main/res']
//            res.srcDirs += ['../SDK_Base/src/main/res']
//        }
//    }
}

dependencies {
    // 🟢 调试时启用:IDE 可以正常识别类,代码不爆红
    // 🔴 打包时注释掉:避免 module 依赖打包
    api project(':mi.ard.java.esl-v3-lib')
    api project(':SDK_Core')
    api project(':SDK_Base')
    
    // 其他第三方依赖
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.code.gson:gson:2.10.1'
}

使用方式:

场景sourceSetsdependencies 中的 api project效果
日常开发调试❌ 注释掉✅ 启用IDE 识别正常,代码不爆红
打包发布 AAR✅ 启用❌ 注释掉源码合并,输出单一 AAR

操作步骤:

// ========== 开发调试模式 ==========
android {
    // sourceSets 全部注释
}
dependencies {
    api project(':mi.ard.java.esl-v3-lib')  // ✅ 启用
}

// ========== 打包发布模式 ==========
android {
    sourceSets {
        main {
            java.srcDirs += ['../mi.ard.java.esl-v3-lib/src/main/java']  // ✅ 启用
        }
    }
}
dependencies {
    // api project(':mi.ard.java.esl-v3-lib')  // ❌ 注释掉
}

核心原理:

  • 调试时:使用 Gradle module 依赖,IDE 可以通过依赖图找到所有类
  • 打包时:使用 sourceSets 合并源码,确保最终 AAR 包含完整代码

⚠️ 注意事项:

  1. 两种配置不能同时启用,否则会导致类重复定义
  2. 切换配置后必须执行 Gradle Sync
  3. 如果多个 SDK 模块共享源码,调试模式下需要在 settings.gradle 中保留共享模块的声明
  4. 打包前务必检查配置,避免将 module 依赖打进 AAR

4.1 配置顶层build.gradle(打包模式)

android {
    namespace 'com.company.sdk.standard'
    compileSdk 34
    
    defaultConfig {
        minSdk 21
        targetSdk 34
        versionCode 1
        versionName "1.0.0"
    }
    
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 
                         'proguard-rules.pro'
        }
    }
    
    // ⭐ 关键配置:将下层SDK作为源码目录加入
    sourceSets {
        main {
            // 方式1:使用相对路径(推荐)
            java.srcDirs += ['../SDK_Core/src/main/java']
            java.srcDirs += ['../SDK_Base/src/main/java']
            
            // 方式2:使用绝对路径
            // java.srcDirs += file("${rootDir}/SDK_Core/src/main/java")
            // java.srcDirs += file("${rootDir}/SDK_Base/src/main/java")
            
            // 合并资源文件(如需要)
            res.srcDirs += ['../SDK_Core/src/main/res']
            res.srcDirs += ['../SDK_Base/src/main/res']
        }
    }
}

dependencies {
    // 迁移SDK_Core的依赖
    implementation 'androidx.core:core-ktx:1.12.0'
    
    // 迁移SDK_Base的依赖
    implementation 'com.google.code.gson:gson:2.10.1'
    
    // 其他必要依赖
    implementation 'androidx.appcompat:appcompat:1.6.1'
}

4.2 修改settings.gradle

配置方式取决于当前是调试模式还是打包模式:

调试模式的 settings.gradle

// settings.gradle(项目根目录)
rootProject.name = 'MySDKProject'

// ✅ 保留所有模块声明(包括共享源码模块)
include ':SDK_Standard'
include ':SDK_Enhanced'
include ':SDK_Premium'
include ':mi.ard.java.esl-v3-lib'  // ✅ 调试时需要
include ':SDK_Core'                // ✅ 调试时需要
include ':SDK_Base'                // ✅ 调试时需要

打包模式的 settings.gradle

// settings.gradle(项目根目录)
rootProject.name = 'MySDKProject'

// ✅ 只保留顶层主模块
include ':SDK_Standard'
include ':SDK_Enhanced'
include ':SDK_Premium'

// ❌ 删除共享源码模块的声明(它们作为 sourceSets 存在)
// include ':mi.ard.java.esl-v3-lib'
// include ':SDK_Core'
// include ':SDK_Base'

为什么要这样配置:

配置项调试模式打包模式原因
共享模块声明✅ 保留❌ 删除调试时需要依赖图,打包时作为源码目录
module 依赖✅ 启用❌ 注释调试时让 IDE 找到类
sourceSets❌ 注释✅ 启用打包时合并源码

⚠️ 实践建议:

由于频繁切换 settings.gradle 比较麻烦,推荐保持调试模式的配置

// settings.gradle - 推荐配置(始终保留所有模块)
include ':SDK_Standard'
include ':mi.ard.java.esl-v3-lib'
include ':SDK_Core'
include ':SDK_Base'

然后只在 build.gradle 中切换 sourceSetsdependencies,这样更方便。

4.3 处理BuildConfig失效问题

问题描述: 源码合并后,原模块的BuildConfig类无法生成,导致编译错误:

import com.company.sdk.core.BuildConfig; // ❌ 找不到该类

解决方案A:修改源码引用(推荐)

将原模块中的BuildConfig引用改为顶层模块:

// 修改前
import com.company.sdk.core.BuildConfig;

// 修改后
import com.company.sdk.standard.BuildConfig;

解决方案B:手动创建BuildConfig(无需改源码)

在顶层模块中创建对应包名的BuildConfig:

// SDK_Standard/src/main/java/com/company/sdk/core/BuildConfig.java
package com.company.sdk.core;

public final class BuildConfig {
    public static final boolean DEBUG = Boolean.parseBoolean("true");
    public static final String APPLICATION_ID = "com.company.sdk.standard";
    public static final String BUILD_TYPE = "debug";
    public static final int VERSION_CODE = 1;
    public static final String VERSION_NAME = "1.0.0";
}

4.3 合并混淆规则

将各层级的混淆规则整合到顶层proguard-rules.pro

# SDK_Base的混淆规则
-keep class com.company.base.** { *; }

# SDK_Core的混淆规则
-keep class com.company.core.** { *; }

# SDK_Standard对外接口(不混淆)
-keep public class com.company.sdk.MTTagBleManager {
    public <methods>;
}

# 内部实现类(可混淆)
-keepclassmembers class com.company.sdk.internal.** {
    private <fields>;
}

4.4 最终目录结构

项目根目录/
├── settings.gradle(只包含 include ':SDK_Standard')
├── SDK_Standard/(主模块 - Gradle module)
│   ├── build.gradle(唯一有效的配置文件)
│   ├── proguard-rules.pro(合并所有混淆规则)
│   ├── src/main/
│   │   ├── java/
│   │   │   ├── com/company/sdk/(本层代码)
│   │   │   └── com/company/sdk/core/BuildConfig.java(手动创建)
│   │   └── res/(本层资源)
│   └── build/(构建输出)
│       └── outputs/aar/SDK_Standard-release.aar
│
├── SDK_Core/(纯源码目录 - 非Gradle module)
│   ├── src/main/
│   │   ├── java/(源码被SDK_Standard引用)
│   │   └── res/(资源被SDK_Standard引用)
│   └── build.gradle(已失效,可选删除)
│
└── SDK_Base/(纯源码目录 - 非Gradle module)
    ├── src/main/
    │   ├── java/(源码被SDK_Standard引用)
    │   └── res/(资源被SDK_Standard引用)
    └── build.gradle(已失效,可选删除)

关键说明:

  • ✅ SDK_Core和SDK_Base 不在settings.gradle中声明
  • ✅ 它们通过sourceSets作为源码目录被引用
  • ✅ Android Studio能正常识别代码,不会爆红
  • ✅ 最终AAR只包含SDK_Standard一个模块的输出

五、注意事项与最佳实践

5.1 不适合源码合并的场景

以下情况不建议使用源码合并:

  • 下层模块需要单独发布给第三方
  • 模块包含Kotlin Multiplatform或C++代码
  • 模块有复杂的buildConfig逻辑需要独立维护
  • 需要保持严格的模块隔离

这些场景应考虑使用fat-aar方案(但需注意Gradle版本兼容性)。

5.2 依赖管理建议

dependencies {
    // ✅ 使用统一版本管理
    def kotlin_version = "1.9.10"
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    
    // ✅ 明确标注依赖来源
    // 来自SDK_Base
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
    
    // 来自SDK_Core
    implementation 'io.reactivex.rxjava3:rxjava:3.1.8'
}

5.3 版本发布流程

  1. 在顶层module更新版本号
  2. 执行混淆构建:./gradlew assembleRelease
  3. 输出文件位置:SDK_Standard/build/outputs/aar/
  4. 验证AAR内容:unzip -l SDK_Standard-release.aar

六、常见问题排查

Q1:SDK壳中的代码爆红,提示找不到类

症状: 在SDK_Standard中引用SDK_Core的类时,IDE显示红色下划线,提示"Cannot resolve symbol"

原因:

  • 未在sourceSets中添加源码路径
  • 或者settings.gradle中仍然声明了下层模块

解决步骤:

  1. 检查SDK_Standard的build.gradle是否添加了sourceSets配置:
sourceSets {
    main {
        java.srcDirs += ['../SDK_Core/src/main/java']
    }
}
  1. 检查settings.gradle,确保已删除下层模块声明:
// ❌ 错误:保留了SDK_Core
include ':SDK_Standard'
include ':SDK_Core'

// ✅ 正确:只保留主模块
include ':SDK_Standard'
  1. 执行Gradle Sync:File -> Sync Project with Gradle Files
  2. 如仍未解决,尝试清理缓存:File -> Invalidate Caches -> Invalidate and Restart

Q2:编译时提示"Duplicate class"

原因: 多个层级存在相同包名和类名

解决: 检查并重构包结构,确保每层使用不同的包名前缀

Q3:运行时找不到某些类

原因: 原模块的依赖未迁移到顶层

解决: 检查各层build.gradle的dependencies,全部迁移到顶层

Q4:资源文件丢失

原因: 未在sourceSets中添加res.srcDirs

解决:

sourceSets {
    main {
        res.srcDirs += ['../SDK_Core/src/main/res']
    }
}

Q5:混淆后功能异常

原因: 混淆规则不完整

解决: 逐层检查原模块的混淆文件,合并所有keep规则

Q6:Gradle Sync失败

原因: 路径配置错误

解决: 检查sourceSets中的路径是否正确,建议使用相对路径:

// ✅ 推荐
java.srcDirs += ['../SDK_Core/src/main/java']

// ✅ 也可以
java.srcDirs += file("${rootDir}/SDK_Core/src/main/java")

七、总结

核心要点

  1. 源码目录方式:使用sourceSets将下层SDK作为源码目录加入,而非Gradle module
  2. 删除settings声明:从settings.gradle中移除下层模块,避免代码爆红
  3. 唯一配置点:只有顶层SDK的build.gradle生效
  4. 依赖迁移:所有下层依赖必须手动迁移到顶层
  5. BuildConfig处理:手动创建或修改引用路径
  6. 避免冲突:使用包名前缀和资源命名规范
  7. 混淆合并:整合所有层级的混淆规则

完整配置清单

1. 主模块build.gradle配置:

android {
    sourceSets {
        main {
            java.srcDirs += ['../SDK_Core/src/main/java', '../SDK_Base/src/main/java']
            res.srcDirs += ['../SDK_Core/src/main/res', '../SDK_Base/src/main/res']
        }
    }
}
dependencies {
    // 所有下层依赖迁移到此处
}

2. settings.gradle配置:

include ':SDK_Standard'  // 只保留主模块

3. 删除主模块dependencies中的module依赖:

// api project(':SDK_Core')  // ❌ 删除

优势

  • ✅ 输出单一AAR/JAR,集成简单
  • ✅ 无需处理复杂的传递依赖
  • ✅ Gradle 8+完全兼容
  • ✅ 构建速度快,稳定性高
  • Android Studio自动识别源码,代码不爆红
  • ✅ 目录结构清晰,便于维护

适用范围

适合大多数多层级SDK的打包需求,特别是需要为不同客户提供差异化功能的场景。

快速排查步骤

如果遇到代码爆红问题:

  1. ✅ 检查sourceSets是否配置
  2. ✅ 检查settings.gradle是否已删除下层模块
  3. ✅ 执行Gradle Sync
  4. ✅ 清理缓存重启IDE