Android 组件化方案的设计与实现

  • 1、认识组件化
  • 1.1、什么是组件化
  • 1.2、为什么选择组件化
  • 2、组件化的架构
  • 2.1、结构划分
  • 2.2、统一配置
  • 3、组件化的实践
  • 3.1、组件的分装和配置
  • 3.2、manifest的管理和Merge
  • 3.3、Application管理
  • 3.4、解决res文件冲突
  • 3.5、第三方sdk的集成
  • 3.6、组件间的跳转


1、认识组件化

传统项目中通常包含一个app module和一个或多个lib module。这种项目结构在创建初期是没有任何问题的,代码敲起来也轻快自由。我们可以把所有的业务逻辑和代码都写在app module下。但是,随着功能的扩充,工程越来越庞大,不仅是代码将会变得越来越难维护,每次修改完Bug哪怕是极小的改动,重新运行时build(全工程编译)的耗时也越来越长。这样就极大的增加了开发难度和维护成本,效率也随之降低。最后,不管你愿不愿意,都不得不花时间对项目结构进行调整优化,来解决这些传统工程架构中逐渐显露出来的弊端。

  1. 项目缺乏层次感,增大阅读难度。
  2. 模块之间高度耦合,一个模块出问题连带整个工程无法使用,增加维护成本。
  3. 编译时全工程编译,耗时过长,降低了开发调试效率。
  4. 多人协同开发提交或合并代码时,容易引发大面积的代码冲突。

组件化在这种场景下应运而生。

1.1、什么是组件化

模块
分属同一功能/业务的代码进行隔离,成为独立的模块,可以独立运行,以页面、功能或其他不同粒度划分。模块间通过接口调用,目的是降低模块间的耦合,由之前的主应用与模块耦合,变为主应用与接口耦合,接口与模块耦合,模块之间不直接产生关联。

组件
相较于模块拥有更细的粒度,把重复的代码提取出来合并成为一个个组件,多个组件又可以组合成组件库(组件组),方便调用及复用。其他功能模块可以依赖于组件,拥有低耦合、独立性强的特点。

组件化
组件化是解耦复杂系统时将多个功能模块拆分、重组的过程,是一种高效的处理复杂应用系统,更好的明确功能模块作用的方式。其目的是为了解耦和复用,把复杂系统拆分成多个组件,分离组件边界和责任,以便于独立升级和维护。

1.2、为什么选择组件化

我们之所以选择组件化,自然是因为它在一定程度上解决了传统项目存在的弊端。相比于传统模式,有了一些显而易见的优势。

  1. 项目可以按照功能或业务分成若干组件(module),多个组件可以组合成一个组件组,使项目的层次更清晰,便于后期代码查找和维护。
  2. 把复杂系统拆分成多个组件,分离组件边界和责任,降低了耦合度,便于独立升级和维护。
  3. 组件可独立开发、编译、测试,如果一个模块产生了bug,不会影响其他模块的使用。大大提高了开发效率。
  4. 允许多人同时协作开发、研究不同的功能模块,且不易产生过多的代码冲突或覆盖情况。

2、组件化的架构

2.1、结构划分

用箭头表示依赖关系,可将一个标准组件化的结构大致描述为下图:

android组件化通信原理 android组件化方案_android组件化通信原理

  • 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的路由方式调用业务模块,不对业务模块产生直接引用。

按照这种层次划分可以将项目创建为如下结构:

android组件化通信原理 android组件化方案_ci_02


采用分组的方式使得工程的结构更清晰、更具有层次感,也便于代码查阅。

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中的结构划分。然而实际项目开发中可能无法提前预知某一部分“代码/功能”是否应当被作为一个单一组件分装,更多时候是根据开发者的经验来决定的。我们做组件化是为了更高效的开发,如果只是为了组件化而组件化,强行把一些完全没必要的代码也独立成一个组件,就有些本末倒置了。在这里我总结了一些组件化过程中的注意点。

  1. 编写代码时,应将解耦作为重要元素考虑在设计内,以便于在需要的时候提取代码或模块下沉。
  2. 某部分“功能”不涉及任何业务数据和业务相关的逻辑处理,那么可以将其作为具备通用性的组件,根据其职能置于base或core中。
  3. 某部分“功能”涉及到了业务逻辑处理或操作了相关业务数据,那么他应该被置于对应module的业务组件中。
  4. 如果某部分位于module组件中的代码/功能将要被其他业务module组件复用时,那么这部分代码/功能应该被作为公共业务组件下沉到common分组中。
  5. 某些业务实体类可能被多个模块引用,如果这些实体类在每个模块中所指代的“对象”不同,仅仅只是属性相同的话,那么他们应该被定义在各自的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时又不能申明上述actioncategory属性,否则APP会在桌面上生成两个快捷方式。 这时候我们需要写出两套AndroidManifest.xml文件以配置的方式选择引用。

android组件化通信原理 android组件化方案_移动开发_03

在组件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的官方使用文档