4、创建动态功能模块:在项目中添加feature模块

前面几章我们聊了动态交付的原理和整体配置。这一章,咱们动手干点实在的——在项目里真正创建一个 feature 模块。

说白了,feature 模块就是你的功能单元。它跟普通 Android Library 最大的区别是什么?它可以被单独打包、按需交付。用户不需要的功能,咱就不给他下载。我刚开始接触这个的时候,觉得它跟 library 没啥两样,后来踩了几个坑才明白——差别大了去了。

核心理解:feature 模块 = 独立功能 + 按需交付 + 生命周期自管理。它不是普通 library,它是可插拔的功能包。

4.1 理解 feature 模块的角色

在动态交付体系里,feature 模块就是那个「可拆分的零件」。你想想看,一个完整的 App 就像一辆汽车,发动机、座椅、天窗都是可选的 feature。用户选了天窗版本,咱才把天窗的代码和数据发给他。

feature 模块有几个关键特征:

  • 独立性:它可以单独编译、单独打包。
  • 按需性:只有用户触发了功能入口,才触发下载安装。
  • 自包含:模块自己的界面、资源、逻辑都放在内部。
  • 依赖公共层:它通常会依赖一个 base 模块,但这个依赖是编译期的。

我个人习惯把 feature 模块比作「微应用」。每个 feature 都有自己的 Activity、Fragment、ViewModel,只不过它们共享了 base 层的网络、数据库、公共组件。

那它跟普通 library 到底有啥区别?我整理了一张表:

对比维度 普通 Library 模块 Feature 模块
打包方式 随主包一起打包 独立打包,按需下发
交付时机 安装时全部交付 使用到才交付
模块配置 普通 library 配置 需要配置 dist 和 split 属性
生命周期 随 App 启动 由 SplitInstallManager 管理
卸载能力 无法单独卸载 可以按需卸载

小提示:不要把公共组件放在 feature 模块里。feature 模块之间不能互相依赖——否则就失去了「按需交付」的意义了。

4.2 创建 feature 模块的完整步骤

好,咱们现在开始实操。假设你已经在 Android Studio 里打开了项目。创建 feature 模块分四步走:

  1. 新建模块:右键项目根目录 → New → New Module → Android Library。
  2. 命名规范:我建议统一用 feature_<功能名> 的格式,比如 feature_payment、feature_search。
  3. 配置 build.gradle:添加动态交付相关的插件和属性。
  4. 配置清单文件:添加 <dist> 和 <split> 标签。

第二步里的命名规范,我多说一句。我在项目中遇到过团队用 :payment 这种短名字的,结果项目大了之后根本分不清哪个是 feature、哪个是 library。统一前缀真的能救命。

下面是 build.gradle 的核心配置:

// feature_payment/build.gradle
plugins {
    id 'com.android.library'
    id 'org.jetbrains.kotlin.android'
    // 注意:这里不需要 com.android.dynamic-feature 插件
}

android {
    namespace 'com.example.feature.payment'
    compileSdk 33

    defaultConfig {
        minSdk 21
        // 不要设置 applicationId
    }

    // 开启动态交付功能
    dynamicFeatures = []
}

dependencies {
    // 依赖公共基础库
    implementation project(':base')
    // 依赖 app 模块中的公共代码(非必须)
    implementation project(':app')
}

然后,最重要的一步——配置清单文件。这是 feature 模块和普通 library 模块最明显的差异点:

<!-- feature_payment/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.example.feature.payment">

    <dist:module
        dist:instant="false"
        dist:title="@string/module_payment_title">
        <dist:delivery>
            <dist:on-demand />
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <activity
            android:name=".PaymentActivity"
            android:exported="false" />
    </application>
</manifest>

注意:dist:module 标签里的 dist:title 必须引用字符串资源,不能写硬编码字符串。不然编译会报错。我曾经因为这个查了整整一个下午。

这里有几个关键属性你需要知道:

  • dist:on-demand:标记这个模块是按需下载的。用户不点进来,它就不存在。
  • dist:fusing dist:include="true":允许这个模块被融合到基础安装包中。说白了,如果用户手机不支持动态交付,这个模块还能退化成普通 library 打进主包里。
  • dist:instant:是否是 instant App。一般设 false 就行。

4.3 管理模块间的依赖关系

创建完 feature 模块之后,依赖关系得理清楚。我见过不少新手把依赖搞成循环,编译直接炸掉。

咱们的依赖原则就三条:

  • app 模块依赖 feature 模块——但这里的依赖是「动态依赖」,不是编译期强制依赖。
  • feature 模块依赖 base 模块——这是编译期依赖,公共代码放 base 里。
  • feature 模块之间不能互相依赖——这一点非常重要!一旦有了互相依赖,你就没法单独按需交付了。

在 app 模块的 build.gradle 里,配置动态依赖的方式是这样的:

// app/build.gradle
dependencies {
    // 普通编译依赖
    implementation project(':base')

    // 动态功能模块依赖——使用 dynamicFeatures 声明
    dynamicFeatures = [':feature_payment', ':feature_search']
}

嗯,注意这里的写法。它不是 implementation project,而是用 dynamicFeatures 数组来声明。这个数组告诉 Gradle:这些模块是动态交付的,不要把它们的代码打进主包。

我个人的做法:在 feature 模块里用 api project(':base') 而不是 implementation。这样 feature 模块暴露出来的接口里如果引用了 base 的类型,调用方不用再单独依赖 base。当然,这个看项目规范,没有对错。

4.4 代码组织与包结构

功能模块的代码组织,直接关系到后续维护成本。我踩过坑之后,形成了一套固定的模式:

feature_payment/
├── src/main/
│   ├── java/com/example/feature/payment/
│   │   ├── di/              # 依赖注入
│   │   │   └── PaymentModule.kt
│   │   ├── ui/              # 界面层
│   │   │   ├── PaymentActivity.kt
│   │   │   └── PaymentFragment.kt
│   │   ├── viewmodel/       # 业务逻辑
│   │   │   └── PaymentViewModel.kt
│   │   ├── data/            # 数据层
│   │   │   ├── PaymentRepository.kt
│   │   │   └── PaymentApi.kt
│   │   └── PaymentNavigator.kt  # 模块入口
│   └── res/
│       ├── layout/
│       └── values/
└── build.gradle

每个 feature 模块都是一个独立的小宇宙。它有自己的 UI、ViewModel、数据层。唯一共享的,是 base 模块里的网络客户端、数据库实例、公共工具类。

入口设计上,我习惯用一个 Navigator 或者 Router 类来暴露模块的入口 Intent。这样 app 模块只需要知道这个 Navigator 类,不需要了解模块内部的具体实现:

// PaymentNavigator.kt
object PaymentNavigator {
    fun createIntent(context: Context, orderId: String): Intent {
        return Intent(context, PaymentActivity::class.java).apply {
            putExtra("order_id", orderId)
        }
    }
}

app 模块里调用的时候,只需要通过 SplitInstallManager 确保模块已安装,然后直接启动 Intent 就行:

// 在 app 模块中启动 feature 模块
val intent = PaymentNavigator.createIntent(this, "order_123")
startActivity(intent)

4.5 避坑指南——我曾遇到的问题

这一节全是实战中淌出来的经验。你仔细看,能省好几个小时的调试时间。

  • 模块安装成功但启动崩溃?我曾经遇到一个诡异的问题——feature 模块里的资源文件引用不到。后来发现是 dist:fusing 配了 false,导致模块在某些设备上资源加载失败。解决方案:dist:fusing 一定要设 true。
  • 编译报错 "Module not found"?检查一下 settings.gradle 里是否 include 了 feature 模块路径。我忘了加过至少三次。
  • 构建产物里没有 feature 的 apk?这是因为你没有在 app/build.gradle 的 dynamicFeatures 数组里声明。只创建模块不声明,等于白干。
  • 模块间共享数据导致耦合?千万别让 feature 模块直接访问另一个 feature 模块的类。如果非要共享数据,走 base 模块里的接口或者事件总线。

终极提醒:feature 模块的清单文件中,package 不能和 app 模块或者其他 feature 模块重复。否则安装时会提示冲突。我就因为这个被 QA 打回来过两次……

4.6 小结一下

创建 feature 模块这件事,说白了就是三步:新建模块 → 配好 dist 标签 → 声明动态依赖。但每一步都有细节,漏一个就编译不过。

我个人觉得,动态交付最迷人的地方不是技术本身,而是它逼着你把项目架构理清楚。模块边界、依赖方向、入口设计——这些在普通项目里经常被忽略的东西,在动态交付体系里,一个都逃不掉。

好,这一章就到这儿。下面这张图总结了 feature 模块在项目中的位置和关系,你可以收藏着用。

动态功能模块项目架构 动态功能模块组 基础设施层 App 壳模块 宿主应用 · 管理模块生命周期 feature_payment 支付模块 · 按需交付 feature_search 搜索模块 · 按需交付 base 公共模块 网络 · 数据库 · 公共组件 SplitInstallManager 动态交付 · 安装/卸载/进度管理 按需安装 按需安装 编译依赖 交付管理 监听状态 注意:feature 模块之间不可互相依赖;所有 feature 统一依赖 base 模块