4. 项目结构适配要点

项目结构适配,说白了就是让你的 SDK 能舒服地躺在别人的项目里。别小看这事,我见过太多 SDK 因为 Manifest 合并冲突、模块依赖混乱,把宿主项目搞崩的案例。今天咱们就从三个最头疼的地方入手:Manifest 配置、多模块项目、构建变体。

核心原则:用最小的侵入性,完成最大的适配性。你的 SDK 像客人,不是主人。

4.1 AndroidManifest.xml 配置最佳实践

这个文件是 Android 系统的入口清单。你声明了什么,系统就给你什么权限和组件。但 SDK 的 Manifest 和 App 的 Manifest 会合并,冲突就来了。

4.1.1 合并冲突的根源

Gradle 在构建时,会把所有模块的 AndroidManifest.xml 合并成一个。如果两个 Manifest 定义了同一个属性,比如 android:icon,就会报错。

看我常用的解决三板斧:

  • 使用 tools:replace – 告诉合并器「用我的覆盖宿主」
  • 使用 tools:node="remove" – 直接删掉宿主里你不想要的声明
  • 使用 tools:node="merge" – 强制合并列表属性

举个例子。你的 SDK 需要一个 minSdkVersion 为 21,但宿主项目是 19。按下面写:

<!-- SDK 的 AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-sdk
        android:minSdkVersion="21"
        tools:overrideLibrary="com.your.sdk" />

    <activity
        android:name=".SdkActivity"
        android:theme="@style/SdkTheme"
        tools:replace="android:theme" />
</manifest>

tools:overrideLibrary 的意思是「我知道宿主最低版本低,但我这个库必须 21,你忍一下」。而 tools:replace 则是「宿主如果也配了 theme,拿我的替换」。我在字节做 SDK 适配时,因为没加 replace,导致宿主主题盖了我的,整个页面渲染都是乱的。嗯,从那以后我就养成了「能写 replace 就别偷懒」的习惯。

注意:不要滥用 tools:node="remove"。你删了宿主的 activity,万一对方 Debug 要用,你就等着被提 bug 吧。尽量用 replace 或者 merge。

4.1.2 权限声明要克制

很多 SDK 上来就声明 READ_PHONE_STATE、ACCESS_FINE_LOCATION。讲真,用户打开应用发现一个计算器要定位,直接卸载。你的 SDK 应该按需声明,最好做成可选权限。

场景 权限 建议做法
基础广告 SDK INTERNET, ACCESS_NETWORK_STATE 直接声明,无风险
需要获取设备标识 READ_PHONE_STATE 做成可选权限,动态申请
位置相关服务 ACCESS_FINE_LOCATION 用 tools:node="remove" 去掉默认声明,让宿主决定
存储读写 READ/WRITE_EXTERNAL_STORAGE Android 10+ 已废弃,建议用 SAF 或 MediaStore

我个人习惯是:除了网络和 WIFI 状态,其他一律不加默认声明。宿主需要时,他们自己加,或者我在 SDK 接入文档里说明「如果需要 XX 功能,请在宿主 Manifest 中添加 XX 权限」。

4.2 多模块项目适配

现在的大型 App 基本都是多模块架构:基础库模块、业务模块、动态模块。你的 SDK 要同时兼容 单模块项目 和 多模块项目。

4.2.1 模块依赖关系设计

我推荐这样组织 SDK 的模块:

sdk-core          // 核心代码,无 UI 依赖
sdk-ui            // UI 组件,依赖 sdk-core
sdk-third-party   // 第三方集成辅助,依赖 sdk-core
sdk-annotations   // 注解定义,无任何依赖

为什么这么分?你想想看,如果宿主项目只用你的核心算法,他引入 sdk-core 就行;如果要 UI,再引入 sdk-ui。这样依赖粒度小,宿主可以按需选择。

小技巧:用 api 和 implementation 区分依赖关系。api 会透传到宿主,implementation 只在模块内可见。我一般只在 sdk-annotations 上用 api,其他全部 implementation 避免污染宿主。

4.2.2 多模块下的 Manifest 合并策略

多模块意味着多个 AndroidManifest.xml。每个模块的声明都会参与合并。我曾经遇到一个问题:UI 模块声明了一个 Activity,但 core 模块也声明了一个同名的 provider,两个模块的 authorities 一样,合并时直接报错。

解决方法是:使用 BuildConfig 动态生成 authorities。

<!-- UI 模块的 AndroidManifest.xml -->
<provider
    android:name=".SdkFileProvider"
    android:authorities="${applicationId}.sdk.fileprovider"
    android:exported="false" />

在模块的 build.gradle 里:

android {
    defaultConfig {
        manifestPlaceholders = [
            sdkAuthority: "${applicationId}.sdk.fileprovider"
        ]
    }
}

这样每个宿主的 applicationId 不同,provider 的 authority 就不会冲突。记住,多模块下 authorities、permission、content provider 的 URI 最容易冲突,最怕死磕静态字符串。

4.3 构建变体(Build Variants)的使用

构建变体 = Build Type + Product Flavor。这是 Gradle 体系里最有用的特性,没有之一。

4.3.1 场景:开发版 vs 正式版

你的 SDK 在调试时需要日志输出,但上线后必须关闭。以前有人用 BuildConfig.DEBUG,但这个变量在 SDK 模块里不一定可靠。我用的是构建变体:

android {
    buildTypes {
        debug {
            buildConfigField "boolean", "LOG_ENABLE", "true"
        }
        release {
            buildConfigField "boolean", "LOG_ENABLE", "false"
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

在代码中:

if (BuildConfig.LOG_ENABLE) {
    Log.d("SdkTag", "request params: $params")
}

注意:release 变体下,BuildConfig.LOG_ENABLE 会被编译为 false,加上 minifyEnabled 后,Log 代码会被 ProGuard 直接删掉——性能也兼顾了。

4.3.2 场景:不同渠道的 API 地址

你的 SDK 可能对接不同的后端环境:测试环境、预发布环境、生产环境。用 Product Flavor 解决:

android {
    flavorDimensions "server"
    productFlavors {
        dev {
            dimension "server"
            buildConfigField "String", "BASE_URL", "\"https://dev-api.yoursdk.com/\""
        }
        staging {
            dimension "server"
            buildConfigField "String", "BASE_URL", "\"https://staging-api.yoursdk.com/\""
        }
        prod {
            dimension "server"
            buildConfigField "String", "BASE_URL", "\"https://api.yoursdk.com/\""
        }
    }
}

然后宿主 Gradle 里选中 devDebug 或 prodRelease。我个人习惯是把 所有与环境相关的配置都丢进 buildConfigField,不写在代码里。这样换环境只要改 Gradle,不用发版。

最佳实践:构建变体也可以控制 Manifest 的声明。比如调试变体下注册一个 Stetho 的 Activity,生产变体下不注册。用 sourceSets 的 manifest.srcFile 或者 manifestPlaceholders 都能实现。

4.3.3 避坑:变体矩阵爆炸

如果你定义了 2 个 flavor dimension(比如 server + market),每个维度 3 个值,那么构建变体就是 3 × 3 × 2(两个 build type)= 18 个。我之前在阿里做过一个 SDK,就是被变体矩阵拖慢了一半的 CI 构建时间。

解决方案:只在 SDK 层使用一个 dimension。或者用 filter 忽略不需要的组合。

android {
    variantFilter { variant ->
        if (variant.flavors*.name.contains("dev") && variant.buildType.name == "release") {
            setIgnore(true)
        }
    }
}

这样 devRelease 就不会生成了,节省 Gradle 解析时间。

项目结构适配核心逻辑 AndroidManifest.xml 合并冲突处理 tools:replace tools:node="remove" manifestPlaceholders 多模块适配 sdk-core sdk-ui sdk-third-party sdk-annotations 构建变体配置 Build Type (debug/release) Product Flavor (dev/staging/prod) buildConfigField + sourceSets 目标:最小侵入性 + 最大灵活性

这张图是你今天的记忆地图。Manifest 在最顶层,三个分支分别是合并冲突、多模块、构建变体。无论你接入什么样的宿主项目,把这三点理清了,SDK 适配就不会出大乱子。