Android 组件化架构设计与实现方案详解
前言
随着移动应用规模的扩大,单体架构逐渐暴露出维护困难、耦合度高、编译速度慢等问题。在大多数大厂或具备一定规模的公司中,App 重构时通常会考虑采用组件化或模块化架构。其核心目的是为了方便 App 解耦和优化。
在我的理解中,组件化即将每个功能相同的模块一个个封装起来,然后以 Library 的形式供主 App 模块调用。在主 App 模块中不会进行任何的业务逻辑,只管打包组装;而除了主 App 模块外,其他模块各回各家,各找各妈,干自己的业务逻辑去。简单来说,组件化就是让 Library 和 Application 之间可以互相切换,Library 可以作为单独的 App 运行,也可以作为依赖库给主 App 调用。这种构建思想能大大提升开发效率,降低代码的维护成本,减少代码的耦合度,让他人简单易懂。
整体结构设计
一个标准的组件化项目通常包含以下结构:
- common:基础组件部分,与业务无关,需要所有组件共同依赖的部分。如网络请求封装、图片加载封装、UI 相关基类、工具集合等(当然这些内容可以依据分层原则放在不同的基础 Module 中)。
- router-comp:路由驱动组件,承载整个项目的路由工作。
- comp1:业务组件 1,如视频组件,可独立运行。
- comp2:业务组件 2,如新闻组件,可独立运行。
- comp3:业务组件 3,如支付组件,可独立运行。
- app:壳工程,用于将各个组件组装成一个完整的 App。
组件化所面临的核心问题
在实施组件化过程中,主要面临以下几个技术挑战:
- 集成模式与组件模式转换(热插拔):如何在不修改大量配置的情况下,让模块在独立开发和集成调试之间自由切换。
- 组件之间页面跳转(路由):不同组件间如何实现解耦的页面导航。
- 组件之间通信、调用彼此服务:如何在无直接依赖的情况下调用其他组件的服务接口。
- 打包混淆:如何在多模块环境下统一处理代码混淆规则。
组件化的实现方案
针对上述问题,我们逐一说明它们的解决方案。当解决完这些问题后,你就搭建了一个基于组件化的项目。
1. 集成模式与组件模式转换(热插拔)
Android 工程通过 Gradle 构建,通过配置每个 Module 的 Gradle 文件,来实现 Module 的不同表现。Android Studio 的 Module 有两种属性,分别是:
- Application 属性:可独立运行,也就是我们的 App。
- Library 属性:不可独立运行,被 App 依赖的库。
Module 属性通过其目录下的 build.gradle 文件配置。当 Module 属性为 Application 时,该 Module 作为完整的 App 存在,可以独自运行,方便编译和调试;当 Module 属性为 Library 时,该 Module 作为一个依赖库,被壳工程依赖并组装成一个 App。
那么如何让这两种模式可以自动转换呢?如果每次切换模式的时候,都手动去修改每个组件的配置,组件少的情况下还可以接受,组件多了会非常不方便。下面我们来聊聊如何实现两种模式的自动转换。
第一步:声明全局配置变量
首先,声明全局配置变量,来标识 Module 的属性(App 或 Library)。如在工程根目录下的 build.gradle 文件中声明布尔变量 ext.isModule,true 代表组件作为可独立运行的 App,false 代表组件作为被依赖的 Library。
buildscript {
ext.kotlin_version = '1.3.21'
ext.isModule = true // true - 每个组件都是单独的 module,可独立运行;false - 组件作为 library 存在
repositories {
google()
jcenter()
}
}
第二步:配置组件的 build.gradle 文件
在业务组件的 build.gradle 文件中,根据 isModule 的值动态应用插件和配置 Manifest 路径。
// 1. 根据 isModule 设置 Module 属性
if (rootProject.ext.isModule) {
// 可独立运行的 app
apply plugin: 'com.android.application'
} else {
// 被依赖的 library
apply plugin: 'com.android.library'
}
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 28
defaultConfig {
// 2. 如果没有 applicationId,默认包名为 applicationId
// 当 module 属性为 library 时,不能设置 applicationId;当为 app 时,如果未设置 applicationId,默认包名为 applicationId
minSdkVersion 19
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_7
targetCompatibility JavaVersion.VERSION_1_7
}
// 3. 动态指定 Manifest 路径
sourceSets {
main {
if (rootProject.ext.isModule) {
manifest.srcFile 'src/main/java/module/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/java/library/AndroidManifest.xml'
java {
// 移除 module 包下的代码
exclude 'module'
}
}
}
}
}
上面是截取的组件 Gradle 的部分代码,包含了组件化需要配置的所有内容,每一点都进行了注释:
- 注释 1:如上所述,根据
isModule 的值,来设置 Module 的属性,作为 App 或 Library。
- 注释 2:当 Module 属性为 Library 时,不能设置 applicationId;当为 App 时,如果未设置 applicationId,默认包名为 applicationId,所以为了方便,此处不设置 applicationId。
- 注释 3:Android Studio 会为每个 Module 生成对应的 AndroidManifest.xml 文件,声明自身需要的权限、四大组件、数据等内容。当 Module 属性为 App 时,其对应的 AndroidManifest.xml 需要具备完整 App 所需要的所有配置,尤其是声明 Application 和 Launch 的 Activity;当 Module 属性为 Library 时,如果每个组件都声明自己的 Application 和 Launch 的 Activity,那在合并的时候就会发生冲突,编译也不会通过。所以,就需要为当前 Module 重新定义一个 AndroidManifest.xml 文件,不声明 Application 和 Launch 的 Activity,然后根据
isModule 的值指定 AndroidManifest.xml 的路径。
为了避免集成模式下的命名冲突,每个文件都以自身 Module 名为前缀来命名会是一个很好的方法。在需要切换 Module 属性的时候,改变步骤 1 处声明的变量值,然后重新编译即可。
2. 组件之间页面跳转(路由)
在组件化架构中,不同的组件之间是平衡的,不存在相互依赖的关系。因此,假设在组件 A 中,想要跳转到组件 B 中的页面,如果使用 Intent 显式跳转就行不通了,而且大家都知道,Intent 隐式跳转管理起来非常不方便。所以 ARouter 出现了,并且有强大的技术团队支持,可以放心使用。
依赖处理
在 common 组件中将 ARouter 依赖进来,并配置编译参数;在业务组件中引入 ARouter 编译器插件,同时配置编译器参数。下面是 Common 组件 Gradle 文件的部分片段:
// 配置 arouter 编译器参数,每个组件都需配置
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}
dependencies {
// arouter api,只需在 common 组件中引入一次
api('com.alibaba:arouter-api:1.4.1') {
exclude group: 'com.android.support'
}
// arouter 编译器插件,每个组件都需引入
kapt 'com.alibaba:arouter-compiler:1.2.2'
}
初始化及编码实现
在组件架构中,经常会遇到组件需要使用全局 Context 的情况。当组件属性为 App 时,可以通过自定义 Application 实现;当组件属性为 Library 时,由于组件被 App 依赖,导致无法调用 App 的 Application 实例,而且自身不存在 Application。所以,这里给出的方案是在 common 组件中创建一个 BaseApplication,然后让集成模式(组件模式)下的 Application 继承 BaseApplication。在 BaseApplication 中获取全局 Context,并做一些初始化的工作,这里需要初始化 ARouter。
abstract class BaseApplication : Application() {
companion object {
var _context: Application? = null
fun getContext(): Application {
return _context!!
}
}
override fun onCreate() {
super.onCreate()
_context = this
initARouter()
}
private fun initARouter() {
if (BuildConfig.DEBUG) {
ARouter.openDebug()
ARouter.openLog()
}
ARouter.init(this)
}
override fun onTerminate() {
super.onTerminate()
ARouter.getInstance().destroy()
}
}
根据 ARouter 的路由特性,初始化之后,就可以通过 @Route 注解注册页面,然后调用 ARouter API 实现页面的跳转了(这里所谓的跨组件页面跳转是指在集成模式下,而非组件模式下),无关乎是否在同一个组件下面。假设我们要从组件 1 页面携带参数跳转到组件 2 页面,请看下面示例:
@Route(path = "/comp2/msg", name = "我是组件 2 的 MSGActivity")
class Comp2MsgActivity : BaseActivity() {
@Autowired(name = "msg")
@JvmField
var msg: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ARouter.getInstance().inject(this)
setContentView(R.layout.comp_activity_msg)
comp_msg_msg.text = msg!!
}
}
ARouter.getInstance()
.build("/comp2/msg")
.withString("msg", "hello Im from Comp1")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.navigation()
以上便完成了一次简单的跨越组件的页面跳转,仅仅是 ARouter 的基本使用而已。
3. 组件之间通信、调用彼此服务
组件间通信功能和路由功能有着共通的地方,即都是利用 ARouter 的基础功能实现。在 ARouter 驱动层定义各个组件对外提供的接口,然后在组件自身模块实现该接口,通过 ARouter 调用其他组件服务。
假设我们在组件 2 中需要调用组件 1 中的服务,可以总结为以下 3 点:
- 定义接口:在 common 组件中定义组件 1 对外提供的接口 CompServer1,注意该接口类型为 ARouter 模板类型 IProvider。
interface CompServer1 : IProvider {
fun showMsg(msg: String)
}
- 实现接口:在 comp1 中实现上面定义的接口,并通过 @Route 注解注册。
@Route(path = "/comp1/server", name = "comp1 对外提供的服务")
class CompServer : CompServer1 {
var mContext: Context? = null
override fun showMsg(msg: String) {
Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show()
}
override fun init(context: Context?) {
this.mContext = context!!
}
}
- 调用服务:在完成组件 1 接口的定义和实现之后,在组件 2 中需要的地方调用该接口即可。
val server1 = ARouter.getInstance().build("/comp1/server").navigation() as CompServer1
server1.showMsg("我从 comp2 调起了 comp1 的接口")
4. 打包混淆
说到混淆,有人可能会疑惑,如果在各个组件中混淆可不可以?不建议这样混淆!因为组件在集成模式下被 Gradle 构建成了 Release 类型的 AAR 包,如果在组件中进行混淆,一旦代码出现了 Bug,这个时候就很难根据日志去追踪 Bug 产生的原因,而且不同组件分别进行混淆非常不方便维护和修改,这也是不推荐在业务组件中配置 BuildType(构建类型)的原因。
所以,组件化项目的代码混淆放在集成模式下的 App 壳工程,各个业务组件不配置混淆。集成模式下在 App 壳工程 Gradle 文件的 Release 构建模式下开启混淆,其他 BuildType 配置和普通项目相同,混淆文件放在 App 壳工程下,各个组件的代码混淆均放在该混淆文件中。
5. 资源冲突与优化
在组件化过程中,资源 ID 冲突是一个常见问题。例如多个组件定义了同名的 Layout 或 Drawable。解决方案包括:
- 资源隔离:每个组件的资源文件前缀加上组件名,如
comp1_layout_main。
- Gradle 配置:在
build.gradle 中配置 resourcePrefix,强制要求资源命名规范。
- 依赖管理:避免重复引入同一版本的第三方库,使用 Gradle 的 dependency resolution strategy 统一管理版本。
6. 性能优化建议
为了进一步提升组件化项目的构建和运行性能,建议采取以下措施:
- 增量编译:合理配置 Gradle 缓存,启用 Gradle Daemon。
- 并行构建:在多核机器上开启
org.gradle.parallel=true。
- 按需加载:对于非核心组件,可以在运行时动态下载或按需初始化,减少冷启动时间。
- 内存优化:注意组件间 Context 的泄露问题,尽量使用 Application Context 而非 Activity Context。
最后总结
以上,我们已经逐一解决了组件化所面对的各个问题,至此,我们已经搭建了一个简单的组件化架构的项目。并不是很难哦!现在,我来总结一下组件化的优势了:
- 解耦:将业务组件代码 90% 与工程解耦。之所以是 90% 而非 100%,是因为业务组件需要在 common 组件中声明对外开放的接口。目前还没有发现更好的办法可以做到完全解耦,但已足够应对大部分场景。
- 提高开发效率:依赖解耦这一优势,团队成员可以只专注于自己负责的组件,开发效率更高;而且,组件开发过程中只需编译自身的 Module,这样大大缩短了编译时长,避免了漫长的等待编译局面。
- 结构清晰:在业务组件明确拆分的前提下,项目结构变得异常清晰,非常方便全局掌控。
通过合理的架构设计和规范的代码管理,组件化能够显著提升大型 Android 项目的可维护性和扩展性。开发者在实际应用中应结合具体业务需求,灵活调整组件粒度,以达到最佳效果。