陈杰,智行火车票高级开发工程师,目前主要负责智行火车票 Android 客户端的架构和公共基础业务开发,热衷于 Android 技术的研究和开源分享。
智行火车票早期以火车票业务起步,随着整体的业务发展和扩张,先后增加了汽车票、机票和酒店模块,逐渐打造成了一个提供出行、旅行和住宿一站式预订服务的 OTA 平台。
在业务扩张过程中,之前 Android 项目单一工程的架构模式慢慢暴露出一些问题,例如业务间耦合较多,整体项目编译耗时等,渐渐无法满足业务开发需求。
为了解决面临的问题,综合主流的 Android 项目架构方案,团队选择了组件化架构方案对项目进行了调整和实践,抽离出基础组件库、独立的业务模块,实现了各独立业务的拆分和独立运行,可以单独进行需求开发,发版时再合并到一起编译打包和发布。
在组件化架构实践过程中,团队解决了组件化调整中遇到的一些难题,对组件化技术在 Android 项目中的应用有一定的参考价值和实践经验。同时根据业务需求,还实现同一个项目进行多个应用差异化适配打包的功能,便于开发和维护团队旗下的其他应用。
二、概述
本文主要根据智行 Android 团队在组件化架构调整中的实践过程以及最终的实践成果,从以下几个方面来进行阐述:
为什么要进行组件化架构调整
组件化结构调整的实施步骤
组件化调整过程中遇到的难题以及解决方案
组件化架构调整的成果
2.1 组件化调整的原因和目标
如前面提到的,在调整之前,项目是单一工程的架构模式,这也是常见的 Android 项目架构模式,但是一旦项目整体业务增多,扩张出相对较为独立的业务模块,这种架构就会带来一些问题,例如:
业务间代码层面耦合太重,业务之间隔离不明确:由于各业务间代码存在较多的耦合,经常出现某个业务线功能开发迭代影响其他业务线,出现代码冲突,影响其他业务功能。
项目整体源码较多,编译耗时久:各业务开发人员主要开发各自业务线需求,但是需要编译整个项目,耗时较多,影响开发效率。
多应用差异化适配方案不完善:在业务扩张过程中,还衍生出一些独立应用,例如智行旗下的订票助手、智行机票等应用,实际是使用同一个项目打包,更改一些主题配色和首页入口,进行差异化的编译打包。之前使用的多应用打包方案存在一些的问题,逐渐无法满足实际需求。
参考技术社区的 Android 架构方案,以及结合项目实际情况和业务场景,我们选择了组件化方案来进行架构的调整。
Android 项目组件化,最早是冯森林老师在 2016 年 MDCC 大会上的《回归初心,从容器化到组件化》演讲中提出来的,当时该方案刚提出,实际应用到项目中的还是比较少得,毕竟一般的公司项目业务不是很复杂,项目结构也是较为单一,没有使用组件化的必要。
但对于此时的智行 Android 项目而言,正是组件化架构最适合实践的项目,多个业务线,项目整体比较庞大,业务间不必要的耦合过多,因此组件化架构的调整方案也就应运而生。
在进行调整之前,团队也定下了调整预期的目标:
1)业务解耦,使得各业务模块可以独立运行,同时可以组合编译打包
2)拆分基础组件,抽离出基础组件 Library
3)各业务间通信和业务交叉调用的实现
4)实现多应用差异化适配打包
以上大的目标点主要是来解决之前遇到的问题,也是项目架构调整的首要目的。
2.2.1 基础组件的拆分
智行 Android 项目的基础组件主要分为业务基础组件和功能基础组件,其中业务基础组件包含登录组件、自定义 View 组件、项目网络层组件等,这些和业务有关联,提供给各业务模块的基础组件,根据具体情况拆分成 aar 或者 library,像登录,基础网络层这样较为稳定的组件,一般直接打包成 aar,减少编译耗时。而像自定义 View 组件,由于随着版本迭代会有较多变化,就直接以源码形式抽离成 Library。
基础组件的调整相对较为简单,主要就是按照功能或者业务拆分成 Library ,处理好之前的引用的地方即可,但是对于拆分出来的 Library 的质量和后续维护工作是要求相对较高的,作为基础的组件,是需要为各业务模块提供基础的功能的,重要性是相对较高的。
基础组件库的编译版本设置一般是和主工程同步的,为了方便后续升级和维护配置,可以使用如下的方式来实现 library 使用同一份配置:
ext.libDefaultConfig = {
minSdkVersion 19
targetSdkVersion 25
javaCompileOptions {
annotationProcessorOptions {
includeCompileClasspath = true
}
}
}
定义一个通用的 DefaultConfig 配置,设置统一的 SDK 版本信息和编译选项,在 Library 的 build.gralde 文件中使用如下方式即可应用到配置:
android {
...
defaultConfig libDefaultConfig
}
这样既可以保证基础组件库的编译配置统一,也方便后期统一修改和升级。
对于再基础点的组件,例如 Support Library、json 库等,绝大多数基础组件都会使用到。为了避免每个独立基础组件都去引入对应的依赖,还要尽可能得保证版本的统一,我们使用了一个空壳 Library 来一次性引入这些基础的依赖组件。
这个 Library 叫做 BaseDependencies,然后其他的基础组件去依赖 BaseDependencies,这样就可以保证基础组件对这些基础的依赖版本做到一致,后续的升级改动位置也相对较为集中。如此调整后的依赖如下图所示:
当然这样的也有一定的弊端,就是每个基础组件都可能存在引入冗余的依赖,对于后续可能需要提供给第三方的基础组件,还需要进行改动才可以独立出来。
2.2.2 业务模块的拆分和配置
业务模块的调整是组件化中最重要的,这里的模块也是组件,对于这类组件的调整目标就是做到能够独立运行。业务模块的调整主要分两步:
1)业务模块的拆分独立
2)业务模块的配置
由于之前各业务的代码以不同包名来进行区分,各业务代码也是比较集中的,拆分出来还是相对较为容易的。遇到两个业务模块都会使用到的类的话,就将对应的类下沉到 Base Module,当然这种情况也是尽可能去避免,否则 Base Module 会越来越臃肿,如果不加以控制,那么业务模块就变成了一个空壳,失去了组件化原本的意义。
拆分成独立模块业务,彼此之间是平级的关系,无依赖关系,从而从结构层面达成了分离的目的,避免了之前一不小心就出现的类相互引用,耦合严重的问题。
业务模块拆分成独立的 Library 以后,就是对其进行配置,这也是组件化的关键步骤,既要使得各个业务模块可以独立运行,又要保证各个模块作为整体 App 的一部分,关键就在于不同场景下给每个业务模块应用不同的插件类型。
独立运行时,需要使用:
apply plugin: 'com.android.application'
而分独立运行时,则需要使用:
apply plugin: 'com.android.library'
为了方便业务模块的在这两种模式下的快速切换和统一调整,我们使用了以下的设置方式:
// 项目的 build.gradle 中配置模块是否独立运行
def isSingleCompile = false
ext.isSingleCompile = isSingleCompile
if (isSingleCompile) {
ext.COMPLIEMODE = 'com.android.application'
} else {
ext.COMPLIEMODE = 'com.android.library'
}
// 业务模块的 build.gradle 中应用
apply plugin: COMPLIE_MODE ```
这样在切换时,只需要修改 isSingleCompile 的值,就可以在独立运行和作为模块运行之间切换。
当业务模块独立运行时,还需要配置独立的 Application 和启动页,以及一些特殊的资源文件,这里同样是根据 isSingleCompile 的值来配置 sourceSets 中的属性:
sourceSets {
main {
manifest.srcFile MANIFEST_FILE
res.srcDirs = RESOURCES
java.srcDirs = JAVA_SOURCES
...
}
}
这里配置内容不再赘述,可以参考 Android 官网的说明进行设置,主要是针对独立运行时配置 Manifest 文件和添加入口页面的调整。
2.2.3 业务间的通信
对于拆分成独立部分的业务模块而言,彼此业务出现关联的场景还是比较多的,比如火车票推荐酒店,就需要从火车票模块跳转到酒店模块。
对于这样的业务场景,除了之前提到的将部分业务下沉到 BaseModule 以外,针对页面间跳转,我们采用了路由的方式。由于跳转的场景可能是原生页面、网页和 React Native 页面,我们制定了一套规则来进行通用的跳转。
跳转的链接按照以下的格式来实现:
sy://suanya.cn/xxx?url=xxx&type=1
其中的 type 则是跳转的类型, url 参数的值就是实际的跳转的地址。对于 网页和 React Native 页面,url 的值是比较容易直观的,对于原生页面,则引入了 ARouter 实现,对于需要传递的参数的场景,则采用 query parameters 的方式进行传递,在统一的地方进行处理,转换成 ARouter 传参的形式。
2.3.1 业务模块的 Manifest 文件维护
在之前提到,业务模块独立运行时需要指定 Application 和启动页面,Manifest 文件内容如下:
android:name=".ModuleApplication"
android:label="@string/app_name">
android:name=".ModuleLaunchActivity">
android:name=".ModuleHomeActivity"/>
android:name=".activity.ModuleXXXActivity"/>
...
其中的 ModuleApplication、ModuleLaunchActivity 和 ModuleHomeActivity 合并打包时,根据 sourceSets 的设置,是不会编译进来的,需要调整 AndroidManifest 文件,最简单的办法就是写两份 AndroidManifest 文件,通过 sourceSets 中的 manifest.srcFile 来指定。但是这样存在一个问题,例如添加一个 ModuleXXXActivity,这个在独立和非独立运行时都需要的 Activity,则需要在两个 AndroidManifest 中都添加一次,这样显然是不够合理的,对开发而言是不友好的。
我们通过 manifest merge 规则找到解决办法,业务模块只需要维护独立运行时的一份 AndroidManifest 文件即可,在合并的 App 的 AndroidManifest 中,对 application 节点,使用 replace 操作:
android:name=".MainApplication"
android:theme="@styleAppTheme"
tools:replace="android:theme,android:name,">
而对于只有模块独立运行才使用到的 activity ,则采用 remove 操作进行移除:
android:name="com.xx.xxx.ModuleLaunchActivity"
tools:node="remove"/>
android:name="com.xx.xxx.ModuleHomeActivity"
tools:node="remove"/>
如此操作之后,就可以保证最终合并打包时,业务模块设置的 application 被替换成 app 的,而模块独立运行才应用到的 activity 则被移除了,只需要维护一份模块的 AndroidManifest 文件即可。
2.3.2 多应用差异化配置打包
在前面也提到,我们的业务场景对于同一个项目打包出不同的应用,这种需求的我们使用 ProductFlavors 进行了实现。
通过设置不同的 ProductFlavors,通过 manifestPlaceholders 来配置每个应用差异化的参数,例如接入微信的 appId,地图的 key 等。对于每个应用使用不同的主题色和资源问题,则采用在对应的 ProductFlavors 文件夹中以同名文件、同名资源名称的方式进行覆盖设置,这种同名的资源最终以 ProductFlavors 文件夹中的设置为准。
除此以外,还需要针对每个应用的签名配置进行设置:
productFlavors.all { flavor ->
signingConfigs.create(flavor.name, getSigningConfigsByFlavorName(flavor.name))
flavor.signingConfig signingConfigs.getByName(flavor.name)
}
需要定义 getSigningConfigsByFlavorName 方法来根据 flavor name 获取到对应的 signingConfigs。
根据之前设定的目标,组件化调整后基本都完成的预期的目标:
1)业务模块分离,从结构层面做到了代码隔离,减少了之前不必要的耦合
2)基础组件的拆分,按照业务和功能拆分出基础组件,便于后期开发和维护
3)简单实现了业务间的通信,实现了跨模块的多种类型的通用跳转
4)实现同一个项目多应用差异化适配打包,支持主题适配等
整体项目进行组件化调整以后,模块的划分更为清晰,结构上实现了代码隔离,减少了耦合。业务模块支持独立运行和整体打包,单个模块完整编译耗时约 20 秒左右,合并打包完整编译整个项目耗时约 1 分钟,极大地提升了开发效率。