Android 组件化方案的设计与实现
1、认识组件化
传统项目中通常包含一个app module和一个或多个lib module。这种项目结构在创建初期是没有任何问题的,代码敲起来也轻快自由。我们可以把所有的业务逻辑和代码都写在app module下。但是,随着功能的扩充,工程越来越庞大,不仅是代码将会变得越来越难维护,每次修改完Bug哪怕是极小的改动,重新运行时build(全工程编译)的耗时也越来越长。这样就极大的增加了开发难度和维护成本,效率也随之降低。最后,不管你愿不愿意,都不得不花时间对项目结构进行调整优化,来解决这些传统工程架构中逐渐显露出来的弊端。
- 项目缺乏层次感,增大阅读难度。
- 模块之间高度耦合,一个模块出问题连带整个工程无法使用,增加维护成本。
- 编译时全工程编译,耗时过长,降低了开发调试效率。
- 多人协同开发提交或合并代码时,容易引发大面积的代码冲突。
组件化在这种场景下应运而生。
1.1、什么是组件化
模块
分属同一功能/业务的代码进行隔离,成为独立的模块,可以独立运行,以页面、功能或其他不同粒度划分。模块间通过接口调用,目的是降低模块间的耦合,由之前的主应用与模块耦合,变为主应用与接口耦合,接口与模块耦合,模块之间不直接产生关联。
组件
相较于模块拥有更细的粒度,把重复的代码提取出来合并成为一个个组件,多个组件又可以组合成组件库(组件组),方便调用及复用。其他功能模块可以依赖于组件,拥有低耦合、独立性强的特点。
组件化
组件化是解耦复杂系统时将多个功能模块拆分、重组的过程,是一种高效的处理复杂应用系统,更好的明确功能模块作用的方式。其目的是为了解耦和复用,把复杂系统拆分成多个组件,分离组件边界和责任,以便于独立升级和维护。
1.2、为什么选择组件化
我们之所以选择组件化,自然是因为它在一定程度上解决了传统项目存在的弊端。相比于传统模式,有了一些显而易见的优势。
- 项目可以按照功能或业务分成若干组件(module),多个组件可以组合成一个组件组,使项目的层次更清晰,便于后期代码查找和维护。
- 把复杂系统拆分成多个组件,分离组件边界和责任,降低了耦合度,便于独立升级和维护。
- 组件可独立开发、编译、测试,如果一个模块产生了bug,不会影响其他模块的使用。大大提高了开发效率。
- 允许多人同时协作开发、研究不同的功能模块,且不易产生过多的代码冲突或覆盖情况。
2、组件化的架构
2.1、结构划分
用箭头表示依赖关系,可将一个标准组件化的结构大致描述为下图:
-
base
基础封装组件,主要包括:通用资源文件(color/style/shape等)、日志管理、项目架构封装、第三方SDK的二次封装、自定义View、Utils类等。能被core/common/module/app依赖。不能依赖其他任何组件。 -
core
系统核心组件,主要包括:网络请求、SQLite、消息推送、IM、地图、Media(相机、音视频)等等,不包含任何业务,不能依赖除base以外的其他任何组件(若想做到更高程度的解耦也应不依赖Base组件)。可被除core以外的模块依赖。 -
common
通用业务组件,公共的业务模块,主要包含项目中被不同模块多次的调用的公共业务。可添加对core/base模块的依赖,只能被module/app依赖。 -
module
业务组件,项目的主要业务模块,可建立对core、base、common的依赖,module互相之间不能有任何依赖关系,应做到绝对解耦,module下的每一个组件应可满足独立编译/运行/打包的条件。 -
app
app最上层的应用模块,主工程项目统一入口。使用ARouter的路由方式调用业务模块,不对业务模块产生直接引用。
按照这种层次划分可以将项目创建为如下结构:
采用分组的方式使得工程的结构更清晰、更具有层次感,也便于代码查阅。
2.2、统一配置
在Android Studio中创建的每个module(组件)都有自己的build.gradle
文件。这样就会导致不同组件可能拥有不同的版本号,SDK版本、不同的第三方依赖库版本。显然,这样不仅可能导致组件调用时版本冲突,同时还会增大APP的体积。因此,一个统一的版本管理方案就显得尤为重要。
在主工程的根目录下创建文件“config.gradle
”,用于作为统一配置的管理。
ext {
isLibraryModuleAudio = true
isLibraryModuleCamera = true
isLibraryModuleVideo = true
android = [
applicationId : "usage.ywb.wrapper",
versionCode : 1, //版本号
versionName : "1.0.0", //版本名称
compileSdkVersion: 30,
buildToolsVersion: "30.0.2",
minSdkVersion : 19,
targetSdkVersion : 30,
]
/**
* 版本统一管理
*/
versions = [
junitVersion : "4.13",
runnerVersion : "1.2.0",
espressoVersion : "3.2.0",
appcompatVersion : "1.1.0",
designVersion : "1.0.0",
constraintlayoutVersion : "1.1.3",
annotationsVersion : "30.0.0",
multidexVersion : "2.0.1",
... ...
]
/**
* 统一依赖管理
*/
dependencies = [
"junit" : "junit:junit:${versions["junitVersion"]}",
"runner" : "androidx.test:runner:${versions["runnerVersion"]}",
"espresso_core" : "androidx.test.espresso:espresso-core:${versions["espressoVersion"]}",
"appcompat" : "androidx.appcompat:appcompat:${versions["appcompatVersion"]}",
"design" : "com.google.android.material:material:${versions["designVersion"]}",
"constraintlayout" : "androidx.constraintlayout:constraintlayout:${versions["constraintlayoutVersion"]}",
//注释处理器
"support_annotations" : "com.android.support:support-annotations:${versions["annotationsVersion"]}",
//方法数超过65535解决方法64K MultiDex分包方法
"multidex" : "androidx.multidex:multidex:${versions["multidexVersion"]}",
... ...
]
}
然后在主工程下的build.gradle
文件首行添加如下代码,以引用上述配置:
apply from: "config.gradle"
组件模块如果想要依赖这些库,则在其“build.gradle
”文件中以如下方式添加依赖:
implementation rootProject.ext.dependencies["appcompat"]
implementation rootProject.ext.dependencies["design"]
implementation rootProject.ext.dependencies["constraintlayout"]
implementation rootProject.ext.dependencies["support_annotations"]
implementation rootProject.ext.dependencies["multidex"]
SDK以及APP的版本也通过下面这种方式配置,以方便统一维护:
compileSdkVersion rootProject.ext.android["compileSdkVersion"]
buildToolsVersion rootProject.ext.android["buildToolsVersion"]
defaultConfig {
minSdkVersion rootProject.ext.android["minSdkVersion"]
targetSdkVersion rootProject.ext.android["targetSdkVersion"]
versionCode rootProject.ext.android["versionCode"]
versionName rootProject.ext.android["versionName"]
}
3、组件化的实践
3.1、组件的分装和配置
组件的分装
组件之间的分装和依赖关系应当遵循2.1中的结构划分。然而实际项目开发中可能无法提前预知某一部分“代码/功能”是否应当被作为一个单一组件分装,更多时候是根据开发者的经验来决定的。我们做组件化是为了更高效的开发,如果只是为了组件化而组件化,强行把一些完全没必要的代码也独立成一个组件,就有些本末倒置了。在这里我总结了一些组件化过程中的注意点。
- 编写代码时,应将解耦作为重要元素考虑在设计内,以便于在需要的时候提取代码或模块下沉。
- 某部分“功能”不涉及任何业务数据和业务相关的逻辑处理,那么可以将其作为具备通用性的组件,根据其职能置于base或core中。
- 某部分“功能”涉及到了业务逻辑处理或操作了相关业务数据,那么他应该被置于对应module的业务组件中。
- 如果某部分位于module组件中的代码/功能将要被其他业务module组件复用时,那么这部分代码/功能应该被作为公共业务组件下沉到common分组中。
- 某些业务实体类可能被多个模块引用,如果这些实体类在每个模块中所指代的“对象”不同,仅仅只是属性相同的话,那么他们应该被定义在各自的module组件中。只有当他们指代同一“对象”时才能置于common组件中。
组件的配置
一个组件作为一个独立的module如果想被其他module调用,必须被申明为library
,即在其所属module下的“build.gradle
”中申明:
apply plugin: 'com.android.library'
既然组件可以做到独立运行测试,那么可以这样改:
if (rootProject.ext.isLibraryModuleVideo) {
apply plugin: 'com.android.library'
} else {
apply plugin: 'com.android.application'
}
其中isLibraryModuleVideo
作为“config.gradle
”中的一个配置参数,通过修改参数值来决定组件是作为一个application
还是library
。
相应的,组件的applicationId
可以设置为:
defaultConfig {
if (!rootProject.ext.isLibraryModuleVideo) {
applicationId "usage.ywb.wrapper.video"
}
... ...
}
上层组件对他的依赖相应地修改为:
dependencies {
if (rootProject.ext.isLibraryModuleVideo) {
implementation project(path: ":core:video")
}
... ...
}
3.2、manifest的管理和Merge
组件可以独立运行测试,除了在配置文件中需要被申明为application
之外,还需要在其manifest
文件中申明作为程序入口的activity
。即:
<activity android:name="usage.ywb.wrapper.video.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
但是在作为library
时又不能申明上述action
和category
属性,否则APP会在桌面上生成两个快捷方式。 这时候我们需要写出两套AndroidManifest.xml
文件以配置的方式选择引用。
在组件module下的“build.gradle
”文件根节点“android
”下添加如下配置:
/*
* java插件引入了一个概念叫做SourceSets,通过修改SourceSets中的属性,
* 可以指定哪些源文件(或文件夹下的源文件)要被编译,哪些源文件要被排除。
*/
sourceSets {
main {
if (rootProject.ext.isLibraryModuleVideo) {
manifest.srcFile 'src/main/AndroidManifest.xml'
java {
//library模式下,排除java/debug文件夹下的所有文件
exclude '*module'
}
} else {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
}
}
}
3.3、Application管理
为了保证Application
的一致性,我们所有的组件应使用相同的BaseApplication
或者其衍生类。BaseApplication
应置于base组件下。
public class BaseApplication extends Application {
private static Application application;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
application = this;
}
public static Application getApplication() {
return application;
}
}
3.4、解决res文件冲突
在AndroidStudio中允许不同的module中拥有相同名称的资源文件。在实际开发中会经常遇到资源文件名冲突的情况。最直接的现象就是引用混乱,比如引用组件A中的style
结果引用到组件B中同名的style
,导致样式显示错误,引用了错误的layout
甚至会因为id
找不到而直接引发程序奔溃。
但是,这个问题目前似乎还没有一个很好的统一解决方案,只能靠我们自己通过规范的命名来规避。同时,我们可以通过在主工程下的“build.gradle
”文件的最外层添加如下约束,让编译器来替我们校验:
/**
* 限定所有子module中的xml资源文件的前缀,否则编译不通过
* 注意:图片资源,限定失效,需要手动添加前缀
*/
subprojects {
afterEvaluate {
android {
resourcePrefix "${project.name}_"
}
}
}
3.5、第三方sdk的集成
项目中使用的第三方库通过添加依赖的方式集成。以base中的mvp组件为例:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation rootProject.ext.dependencies["appcompat"]
implementation rootProject.ext.dependencies["multidex"]
implementation rootProject.ext.dependencies["gson"]
implementation rootProject.ext.dependencies["retrofit"]
implementation rootProject.ext.dependencies["retrofit_rxjava"]
implementation rootProject.ext.dependencies["retrofit_gson"]
implementation rootProject.ext.dependencies["okhttp"]
implementation rootProject.ext.dependencies["okhttp_logging_interceptor"]
implementation rootProject.ext.dependencies["rxjava"]
implementation rootProject.ext.dependencies["rxlifecycle"]
implementation rootProject.ext.dependencies["rxlifecycle_android"]
implementation rootProject.ext.dependencies["rxlifecycle_components"]
// 替换成最新版本, 需要注意的是api
// 要与compiler匹配使用,均使用最新版可以保证兼容
implementation rootProject.ext.dependencies["arouter_api"]
annotationProcessor rootProject.ext.dependencies["arouter_compiler"]
implementation rootProject.ext.dependencies["butterknife"]
annotationProcessor rootProject.ext.dependencies["butterknife_compiler"]
}
使用implementation
,依赖只对当前module有效;使用api
,依赖对于其上层依赖同样有效。如果在base中将项目中所有使用到的库都以api
的方式添加依赖,那么上层所有依赖于base的module就不再需要添加对这些库的依赖。 但是,建议还是使用implementation
,因为使用api的方式在debug的时候不论单独编译/打包哪一个组件都会把所有的依赖库添加进去。
3.6、组件间的跳转
为了做到最大程度的解耦,业务组件之间不产生任何直接性的调用,这样才能做到组件的“即插即拔”,而不需要担心程序报错。这里涉及到“路由”的概念,不过有人已经为我们提供了成熟的解决方案。
使用阿里的 ARouter 来完成组件间的跳转。
第一步,在每一个需要用到组件间跳转的module配置文件中添加依赖库:
implementation rootProject.ext.dependencies["arouter_api"]
annotationProcessor rootProject.ext.dependencies["arouter_compiler"]
然后在组件配置文件中“android
”节点下的“defaultConfig
”中添加路由配置:
//Arouter路由配置
javaCompileOptions {
annotationProcessorOptions {
arguments = [AROUTER_MODULE_NAME: project.getName()]
includeCompileClasspath = true
}
}
第二步,为了统一注册,我们直接在BaseApplication
中初始化。
public class BaseApplication extends Application {
private static Application application;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
application = this;
//MultiDexf分包初始化,必须最先初始化
MultiDex.install(this);
}
@Override
public void onCreate() {
super.onCreate();
if (BuildConfig.DEBUG) { // 这两行必须写在init之前,否则这些配置在init过程中将无效
ARouter.openLog(); // 打印日志
ARouter.openDebug(); // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
}
ARouter.init(application); // 尽可能早,推荐在Application中初始化
}
public static Application getApplication() {
return application;
}
}
第三步,在需要跳转的目标Activity
类上添加注解。
@Route(path = "/video/MainActivity")
public class MainActivity extends AppCompatActivity {
}
然后将原有startActivity
的启动方式替换为ARouter
的方式:
@OnClick(R.id.video_btn)
protected void onClickVideo() {
ARouter.getInstance().build("/video/MainActivity").navigation();
}
至此,就完成了一个完整的组件间跳转。更多的使用方式(传参)可以参考 ARouter的官方使用文档。