Android模块化实践

10,436 阅读11分钟

随着APP的不断迭代,业务越来越复杂,代码量越来越多,单个APP的模式已开始影响开发效率,而且原来的单模块很难进行业务迁移。所以决定采用模块化/组件化的思想对APP进行重构。

组件化与模块化

什么是组件化/模块化

组件化和模块化是当前软件开发中常用的与平台无关的的解耦手段,被广泛应用在软件的架构层面。这两者通常是相辅相成,通过组合的方式来使用。它们只是架构方面的一种思想,在代码的实现层面上没有多大区别。个人觉得差别也就以下两点:

  • 复用性:组件更注重复用性,如开发中用到的网络组件,图片组件等, 在每个项目中都可使用;
  • 应用范围:复用性决定了应用范围,也就是说,组件通常指的是底层模块,公共组件等。而模块既可表示上层的业务,也可表示组件中的某个业务功能,如图片组件中的缓存模块,下载模块等。所以模块的应用范围更广。

为什么需要组件化/模块化

在早期的Java开发中,提倡将整个项目结构按照程序的逻辑结构进行分层,比如表示数据的Dao层,表示控制器的Control层,表示View的View层,但是随着业务的不断迭代,发现这种分层方式有很大的弊端,代码难以定位,且后期难以维护。随后就出现了以业务结构划分的模式,这种结构彻底解决了以上问题。所以当前的APP基本上使用的都是这种以业务划分的模式,但随着App的不断迭代,业务变得越来越复杂,代码量越来越多,维护也变得越来越困难。还有一个明显的问题,Gradle在编译的时候花费的时间越来越长,这大大降低了APP的开发效率。既然单个APP难以解决这个问题,那可以将项目进行拆分,每个人只需要负责开发、维护自己的模块即可。如何拆分呢?使用模块化技术按业务逻辑将APP进行划分,使得这些被拆分出来的模块可单独运行,这样就提高了编译速度,降低了维护成本。

组件化/模块化的优点:
  • 解耦,重用;
  • 降低维护成本,提高开发效率;

模块化的项目结构

这次重构采用层次化的方式,模块化的思想,对APP进行了彻底的重构,具体的项目结构如下图所示:

image

从结构上来看,APP被划分成5层,每层的功能具体如下:

APP壳工程

这是一个空的项目,其中只包含了一个Application的子类和一个IntentService的子类,主要用来对APP中使用的各种组件进行初始化,IntentService的作用是为了提高APP冷启动的速度,将各种组件的初始化放在后台线程异步执行,这里需要注意的是,对于在Applicaiton或SplashActivity中就会使用的组件,最好直接在Application中进行初始化,否则会抛出未初始化的异常。

业务层

这里的业务层被划分为Main模块和其他模块(至于划分几个模块,根据自己APP的业务,选择合适的粒度进行划分即可)。这里的Main模块主要包括:新用户引导页,启动页,主页。具体业务方面的页面,都放在具体的业务模块中。

公共组件层

公共组件层主要包括APP中使用的第三方组件,这些组件基本都是现在APP的通用功能,为上层的业务层提供支持。至于模块划分,虽然这些都是单独的组件,但是每个做为一个Module未免有些繁琐了,所以还是推荐放在一个Module中。在选择第三方的库时,需要做一定的调研,尽量选择大公司,使用用户多的SDK,同时在使用时最好封装一下,这样后面更换时也方便。

基础业务层

基础业务层主要用来统一APP的代码结构,UI风格等,主要包含以下三个方面:

Android组件的二次封装

主要是对Activity/Fragment的封装,提供了不需网络请求的BaseActivity/BaseFragment和需要网络请求的BaseProgressActivity/BaseProgressFragment, 为页面的代码提供统一的结构,页面的样式提供统一的风格。

业务通用UI

主要包含各种样式的Dialog, 自定义View等,根据APP的设计风格提供统一的样式;

图片操作库

图片操作库ImageSet是对图片组件库(包括Fresco, Glide, Universer ImageLoader)的封装,同时提供了调用系统相机/相册选择&裁剪照片,类似微信选择图片的组件,图片上传,图片压缩等功能。这个小模块其实也可放在Common组件层,只是觉得这里面也有一些业务相关的功能,所以就放在了这一层。

当然,基础业务层还还包括APP设计风格中需要用到的各种动画,样式,颜色值,尺寸值等资源。在进行业务开发时,统一使用这些资源,为后续修改整个APP风格提供可能。

Common组件层

这里包含了一些通用的组件,包括各种常用的工具类,通用的UI库,数据源的封装(包括网络,文件,数据库)。这是一个APP的基本架构,里面包含的类基本不需要改动。所以在对工具类和通用UI进行定义时,需要考虑放置的位置是否准确。

以上是整个项目使用组件化/模块化后基本结构的详细介绍,但是在开发过程中还是遇到了很多问题。

遇到的问题

模式切换

从Android工程的结构可以看出,app模块和新建的其他Module的结构基本一致,最大的区别就是build.gradle的结构:

// app模块中build.gralde的结构:
apply plugin: 'com.android.application'

// 其他Module中build.gralde的结构:
apply plugin: 'com.android.library'

所以Module是否能够运行,关键就在于plugin的类型。将新建Module的build.gradle中的'com.android.library' 改成 'com.android.application',同步之后选择相应的模块运行即可。

所以模式的切换只需要根据条件进行判断即可,我们可在gradle.properties中定义一个常量,控制Module的运行模式:

gradle.properties中定义IS_MODULE:

IS_MODULE = false

然后在Module的build.gradle中添加条件判断:

if (IS_MODULE.toBoolean()) {
    apply plugin: 'com.android.library'
} else {
    apply plugin: 'com.android.application'
}

这样在进行模式切换时,只需要修改IS_MODULE的值即可。

AndroidManifest的合并问题

APP在进行打包时,会将所有依赖的Module中的AndroidManifest文件进行合并,具体的合并规则参考合并多个清单文件。合并最基本的原则:只能有一个Application配置了name属性,只能有一个Activity被配置成了主Activity。但是Module中如果不配置Applicaition中name属性,就不能进行相应的初始化,如果不指定主Activity,APP也无法运行。这里可使用两种方案来解决:

  • Module依然当做library使用,Module中的AndroidManifest也不需要指定Application的name属性和主Activity,直接加载Main模块(SplashActivity作为主Activity),在SplashActivity中动态修改要加载的模块。
  • 在Module中其他路径下新建一个AndroidManifest文件(其中为application标签指定了name属性,同时指定了主Activity),然后在build.gradle中根据IS_MODULE的值动态指定AndroidManifest的路径,这样Module在不同模式下使用不同的AndroidManifest文件就避免了合并出错的问题。但这种方案每个Module需要提供单独的Application类。

module/build.gradle

sourceSets {
    main {
        if (IS_MODULE.toBoolean()) {
            manifest.srcFile '../模块名/src/main/module/AndroidManifest.xml'
        } else {
            manifest.srcFile '../模块名/src/main/AndroidManifest.xml'
        }
    }
}

如果选择了双AndroidManifest文件的方式,那么在作为Library的AndroidManifest(默认的)中组件的定义应尽量简单,包括主题都不要定义,这样合并时就很少出现合并错误。

如果在AndroidManifest.xml中定义了元数据,如极光推送:

<meta-data
    android:name="JPUSH_CHANNEL"
    android:value="${JPUSH_CHANNEL}" />
<meta-data
    android:name="JPUSH_APPKEY"
    android:value="${JPUSH_APPKEY}" />

那么除了在当前Module的build.gradle中定义JPUSH_CHANNEL和JPUSH_APPKEY:

manifestPlaceholders =[
    JPUSH_PKGNAME : "com.sxu.library",
    JPUSH_APPKEY : "appkey",
    JPUSH_CHANNEL : "channel"
]

还需要在所有包含此Module的build.gradle中定义这两个常量,否则会出现manifest合并错误。

build.gradle的定义问题

引用Module问题

在引用可切换运行模式的Module时,要根据isModule的值动态添加依赖,如果isModule为true时,引用的模块是以Application的形式存在,此时引入时就会报错。

设置applicationId

applicationId属性在Application模式下才有效,所以在Module中设置时也需要添加isModule的条件判断。

依赖管理

为了便于对依赖和各种SDK版本的管理,最好将这些版本号定义在gradle.properties中统一管理,这样便于后续的修改。

模块之间的通信

组件之间的通信可使用EventBus来实现, 可在每个模块中新建一个Event类,将同模块中通信需要的类都定义在这个Event类中。至于模块间通信需要的类,可定义在公共组件层的Event类中(虽然不是很合理,但暂未想到更好的方案)。

模块对ApplicationContext的引用

在Module的开发中,我们可能需要引用ApplicationContext对象,但我们没有Context对象,无法直接获取到,此时可通过以下三种方式解决:

  • 为需要ApplicationContext对象的类提供init静态方法,在Application的onCreate中调用:
  • 在Common组件层中提供一个继承自Application的BaseApplication, 其中包含一个静态的Context对象,在APP中重写Application时继承BaseApplication并对这个静态的Context对象进行赋值;
  • 在Common组件中提供一个ContentProvider组件,使用静态的Context对象保存ApplicationContext对象(ContentProvider在系统创建Application对象后就会加载,具体细节查看APP的启动过程的源码);

模块的依赖

模块之间的依赖

除了Common组件层外,其他的层尽量遵循 【同层不依赖,下层不依赖上层】的原则。

同层之间的依赖主要表现在业务层,这是不可避免的,但我们需要避免互相引用的问题,在业务层,我们可使用隐式跳转的方式或使用阿里开源的路由框架ARouter实现。

模块之外的依赖

Gradle3.4中提供了新的依赖配置的关键字:

implementation:依赖项在编译时对所在模块可用,在运行时对依赖该模块的模块可用;

api: 依赖项在编译时对所在模块可用,在编译时和运行时对依赖该模块的模块可用;

Gradle3.4中提供的两个关键字相当于对是之前的compile关键字进行细化。使用Gradle3.4之前的版本,引入依赖项时都使用compile关键字,compile关键字容易引起多次引入的问题。使用Gradle3.4之后的版本引入library时,对于外部不需要直接引用的library,最好使用implementation关键字,而对于外部需要引用的library, 可使用api(此时就相当于compile)。

资源的命名问题

为了避免资源冲突的问题,我们可在Module中的build.gradle配置资源名的前缀,一方面避免资源冲突,另一方面,也便于标识资源所在的模块:

android {
    resourcePrefix "moduleName_"
}

其他问题

语法问题

Module中生成的资源Id不是final类型的,所以在onClick中不能使用switch语句块,只能使用if...else if结构代替。

重构与版本迭代的问题

重构与版本迭代之间冲突是不可避免的问题。 通常重构的时候还需要版本迭代,此时可根据情况进行人员分配:

  • 有足够重构的时间:让大多数人进行重构,只留一两个进行版本迭代。重构完成后将新版本的内容进行合并;
  • 没有足够重构时间:这种情况就不要做整体重构,而是根据模块逐渐进行。

重构是一项体力活,也是一项出力不讨好的活,毕竟重构之后不可避免地会出现很多Bug, 如果用户量庞大,那后果可能很严重,所以在重构时最好阅读一下原来的代码,认真梳理一下业务逻辑再进行。

单Activity模式

在重构的过程中对其中一个模块尝试使用了单Activity模式(页面统一使用Fragment实现)。体验感觉不错,值得一试。

总结

模块化/组件化是一种与技术无关的架构思想,合理的应用可大大降低项目的耦合度。为了能够快速开发一款新的应用,现已开源了一个通用的APP框架SimpleProject,采用分层+模块化的思想,详见Android模块化框架介绍