文章

组件化架构

组件化架构

背景

  • 阶段一:2017 年,糗事百科 App,单 module 开发
  • 阶段二:2018 年,百姓头条/热猫直播,模块化开发
  • 阶段三:2019 年,Mashi,组件化开发

模块化

原本一个 app module 承载了所有的功能,而模块化就是拆分成多个模块放在不同的 module 里面。
业务模块
一般的情况下我们会按照 App 的底部 tab 功能还划分 module,以 mashi 为例的话,那就会划分成这样几个业务 module:

  • room_module 房间语音聊天室模块
  • ovo_module 1v1 视频聊天模块
  • me_module 我页面模块
  • login_module 登录模块,其他所有的业务模块都会和这个模块产生交互,需要登录状态

通用基础模块
还会有一个通用的基础模块 common_module,提供 BaseActivity/BaseFragment,各个 module 公共的功能等基础能力,每个业务模块都会依赖这个基础模块
基础组件的模块

  • 工具类
  • 动态权限
  • 日志
  • 图片加载
  • 网络请求

各个模块之间存在着复杂的依赖关系,多个模块间存在着页面跳转、数据传递、方法调用等情况:room_module 需要 login_module 登录的个人信息;me_module 需要能够在个人主页拨打 1v1 主播的电话,那就需要 ovo_module 提供功能
模块间有着高耦合度问题,如果业务复杂,代码量大的话,严重影响了团队的开发效率及质量,这个时候就需要组件化了。组件化去除模块间的耦合,使得每个业务模块可以独立当做 App 存在,对于其他模块没有直接的依赖关系。 此时业务模块就成为了业务组件

组件化

1、为什么需要组件化?

最早使用的是常见的单工程 MVC 架构,所有业务逻辑都放在了主工程 Module 里,网络层和一些公共代码分别被抽成了一个 Module。后续发展成模块化,但随着业务的快速发展,模块化存在了模块之间大量耦合的问题。
引入了组件化,解决模块化之间的耦合问题

2、组件化的好处?

  1. 加快编译速度和提升开发效率 每个业务功能都是一个单独的工程,可独立编译运行,拆分后代码量较少,编译自然变快。
  2. **业务隔离、提高协作效率 **解耦 使得组件之间 彼此互不打扰,组件内部代码相关性极高。 团队中每个人有自己的责任组件,不会影响其他组件;降低团队成员熟悉项目的成本,只需熟悉责任组件即可;对测试来说,只需重点测试改动的组件,而不是全盘回归测试。
  3. **功能重用 **组件 类似我们引用的第三方库,只需维护好每个组件,一建引用集成即可。业务组件可上可下,灵活多变;而基础组件,为新业务随时集成提供了基础,减少重复开发和维护工作量。
  4. **代码更简洁 **比如每个 app 都有在网页拉起 app 的动作,未用路由框架时都是写在一个代理的 Activity 中,为其配置 scheme、data 然后转发到目标 Activity,里面要写一堆判断逻辑分发到目标 Activity;用了路由框架代码就很简洁,日后新增的目标 Activity 也基本没有工作量
  5. 更好的实现组件层面的 AOP 组件化之后,我们能很容易地实现一些组件层面的 AOP
    • 轻易实现页面数据 (网络请求、I/O、数据库查询等) 预加载的功能
    • 组件被调用时,进行页面跳转的同时异步执行这些耗时逻辑
    • 页面跳转并初始化完成后,再将这些提前加载好的数据展示出来
    • 在组件功能调用时进行登录状态校验
    • 借助拦截器机制,可以动态给组件功能调用添加不同的中间处理逻辑

3、组件化整体架构

  • 多工程多仓库。主工程通过 aar 依赖各个组件
    • 每个组件都是一个独立的仓库
    • 组件可以以 aar 依赖方式接入,也可以以源码依赖方式接入,可以切换
  • 组件划分:主工程 app、业务组件(完整业务,可独立运行)、基础组件(基础业务,不可独立运行)、基础 SDK(业务无关)、common 组件(下层接口)

0duor
组件依赖关系是上层依赖下层,修改频率是上层高于下层

  1. 基础组件 通用的基础能力,不包含任何业务逻辑,修改频率低,可作为 SDK 共公司所有项目集成使用,可发布到内网的 maven

常见的基础组件:图片加载、网络服务组件、动态权限、日志、公共 UI 组件、工具类等

  1. **common 组件 **作为支撑业务组件、业务基础组件的基础,同时依赖所有的基础组件,提供多数业务组件需要的基本功能。(所有业务组件、业务基础组件所需的基础能力只需要依赖 common 组件即可)

比如业务组件依赖的公共资源文件,公共网络请求接口,数据 model,组件间通信的下沉接口

  • 组件接口下沉可以采用 组件 API 模块:如果直接将这些组件接口下沉到一个公共组件中,由于业务的频繁更新,这个公共组件可能会更新得十分频繁,开发也十分的不方便,所以使用公共组件是行不通的。为每个有对外暴露需求的组件添加一个 API 模块,API 模块中只包含对外暴露的 Model 和组件通信用的 Interface 与 Event。有需要引用这些类的组件只要依赖 API 即可。
  1. **业务基础组件 **对一些系统通用的业务能力进行封装,业务基础组件之间不存在依赖关系,为业务组件提供了一些可复用的基础功能组件;业务基础组件不能单独运行

比如分享能力组件:封装了微信、QQ、微博等分享能力,其他业务只要集成该组件就能进行分享;还有其他的业务基础组件:推送、支付、广告等

  1. **业务组件 **真正的业务组件,通常按照功能模块来划分业务组件;业务组件之间不存在依赖关系,业务组件依赖所需的业务基础组件;业务组件可单独运行;比如注册登录、用户个人中心、APP 首页模块

room 房间、userhome 用户中心、message 消息中心、me 我的页,login 登录页、family 家族、store 商店等

  1. app 壳工程 壳工程依赖了需要集成的业务组件,它可能只有一些配置文件,没有任何代码逻辑。根据你的需要选择集成你的业务组件,不同的业务组件就组成了不同的 APP。

组件之间必须遵守一下规则:

  • 只有上层的组件才能依赖下层组件,不能反向依赖,否则可能会出现循环依赖的情况
  • 同一层之间的组件不能互相依赖(为了组件之间的彻底解耦)

4、组件化开发的问题点

组件化核心点就是去除了组件间的耦合、各个组件间没有了依赖关系。业务组件之间就会有以下问题:

  • 业务组件,如何实现单独运行调试?
  • 业务组件间没有依赖,如何实现页面的跳转?
  • 业务组件间没有依赖,如何实现组件间通信/方法调用?
  • 业务组件间没有依赖,如何获取 fragment 实例?
  • 业务组件不能反向依赖壳工程,如何获取 Application 实例、如何获取 Application onCreate() 回调(用于任务初始化)?

4-1、组件独立调试

  1. gradle.properties 配置变量,通过控制 gradle 的配置,让业务组件可在 library 和 application module 之间切换
  2. module_dependency.gradle 实现,核心就是通过 substitute 来进行转换,依赖的都是 aar,通过 json 文件配置,达到可以切换到源码依赖

具体可参考:组件化下如何优雅进行本地调试,即aar依赖与module依赖动态切换

  1. 自定义插件来实现切换

4-2、组件页面跳转

用 ARouter

比较著名的路由框架 有阿里的 ARouter、美团的 WMRouter,它们原理基本是一致的。TheRouter

4-3、组件间如何通信/方法调用(服务发现)

组件间没有依赖,又如何进行通信呢?服务暴露组件
平时开发中我们常用接口进行解耦,对接口的实现不用关心,避免接口调用与业务逻辑实现紧密关联。这里组件间的解耦也是相同的思路,仅依赖和调用服务接口,不会依赖接口的实现。

  1. 将要通信的接口下沉到 core_module 公共 module 中去
  2. 调用方如何找到提供方的接口实现类?
    1. 在调用方可通过 SPI(Service Provider Interfaces),其实就是找 META-INF/services/ 接口全名配置的接口所有实现类的全路径,通过反射创建类的实现,这样就可以找到了实现、
    2. 提供方通过 ARouter 的 IProvider 暴露服务,提供方添加 @Route 注解,ARouter 通过 apt 就会在指定包中生成对应的代码,在 ARouter 初始化时,会装载到 WareHouse 路由表中去,在调用方使用通过查找路由表,反射创建实例

不足: 下沉到 core module 膨胀怎么优化?

具体见 Android开源库→ARouter

需要暴露服务的 module,通过一个单独的 api module 来提供,其他需要用到该服务的依赖该 module,避免需要下沉到 core module 导致 core module 的膨胀。不需要用的时候也可以直接丢弃掉,这样避免了 core_module 代码膨胀和无关紧要的依赖关系

4-4、Fragment 实例获取

用 ARouter,fragment 添加注解@Route,指定路由路径

4-5、组件间如何初始化

初期版本,没有依赖关系,通过自定义接口的方式

  1. core module 定义一个接口 IAppInit,里面可定义 onCreate、onLowMemory 和 onTrimMemory 等 Application 中的生命周期的函数
  2. 其他需要初始化的 module 新建一个类实现 IAppInit 接口
  3. 在 app module 弄一个配置类,通过反射来创建初始化类
  4. 依赖关系就靠 list 的前后顺序来保证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
object AppInitConfig {
    private val appInitList by lazy {
        listOf(
            "me.hacket.mylibrary1.MyLib1AppInit",
            "me.hacket.mylibrary2.MyLib2AppInit",
            "me.hacket.appinitdemo.MainAppInit"
        )
    }
    private val appInitMap by lazy { mutableMapOf<String, IAppInit>() }
    fun onCreate(application: Application) {
        appInitList.forEach {
            try {
                val clazz = Class.forName(it)
                val obj = clazz.newInstance() as? IAppInit
                obj?.let { appInitObj ->
                    appInitMap[it] = appInitObj
                    obj.onCreate(application)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}

SPI 版本,不能处理模块间的依赖关系

通过 SPI(Service Proider interface),将 Application 初始化的逻辑分发到各个 module 中去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
object AppInitSPI {

    fun find(application: Application) {
        val serviceLoader = ServiceLoader.load(
            IAppInit::class.java
        )
        for (item in serviceLoader) {
            item.onCreate(application)
        }

        application.registerComponentCallbacks(object : ComponentCallbacks2 {
            override fun onConfigurationChanged(configuration: Configuration) {
                for (item in serviceLoader) {
                    item.onConfigurationChanged(configuration)
                }
            }

            override fun onLowMemory() {
                for (item in serviceLoader) {
                    item.onLowMemory()
                }
            }

            override fun onTrimMemory(level: Int) {
                for (item in serviceLoader) {
                    item.onTrimMemory(level)
                }
            }
        })
    }
}

官方的 StartUp 框架,可处理依赖关系

  • 统一到了一个 ContentProvider,支持了简单的顺序依赖
  • 更多的是收拢 ContentProvider,实际上对启动优化的帮助不是很大

有依赖关系的初始化

解决:自定义组件间路由框架

如何解决组件间初始化依赖关系?

AppInit

可参考:知乎的Task依赖初始化

组件资源冲突

各组件之间的资源名不能相同,需要配置 resourcePrefix,避免资源重名

1
2
3
4
5
6
7
8
9
10
## 切图规范
- 只需要放@3x一张图片到drawable-xxhdpi文件即可
- 切图名称module名称_ic/bg_业务模块名_功能名wisdomsite_ic_home_empty

## 资源规范-string
- 命名module名称_业务模块名_功能名wisdomsite_labor_title

## 资源规范-color
- colors.xml中定义业务无关的颜色值colors_feature.xml中使用前者的颜色定义业务相关的颜色值
- colors_feature.xml中的命名module名称_业务模块名_功能名wisdomsite_machine_error

不同组件资源冲突,如何抉择的?

App module 内资源冲突
  • 同一个 module,同一个资源文件
  • 同一个 module,同一个资源文件中出现两个命名、类型一样的资源定义,比如:
1
2
3
 <!--strings.xml-->
<string name="fb_app_id">fb_app_id_lib1</string>
<string name="fb_app_id">fb_app_id_lib1</string>

编译报错:

AGPBI: {“kind”:”error”,”text”:”Found item String/fb_app_id more than one time”,”sources”:[{“file”:”/Users/xxx/mylibrary1/src/main/res/values/strings.xml”}],”tool”:”Resource and asset merger”} Execution failed for task ‘:mylibrary1:packageDebugResources’.

/Users/xxx/mylibrary1/src/main/res/values/strings.xml: Error: Found item String/fb_app_id more than one time

  • 同一个 module,不同资源文件
1
2
3
4
5
6
7
8
9
 <!--strings.xml-->
 <resources>
 		<string name="fb_app_id">fb_app_id_lib1</string>
 </resources>

 <!--other_strings.xml-->
 <resources>
 		<string name="fb_app_id">fb_app_id_lib1</string>
 </resources>

编译报错:

1
2
3
4
> AGPBI: {"kind":"error","text":"Duplicate resources","sources":[{"file":{"description":"string/fb_app_id","path":"/Users/xxx/mylibrary1/src/main/res/values/sother_strings.xml"}},{"file":{"description":"string/fb_app_id","path":"/Users/xxx/mylibrary1/src/main/res/values/strings.xml"}}],"tool":"Resource and asset merger"}
> Execution failed for task ':mylibrary1:packageDebugResources'.
>
> > [string/fb_app_id] /Users/xxx/mylibrary1/src/main/res/values/sother_strings.xml	[string/fb_app_id] /Users/xxx/mylibrary1/src/main/res/values/strings.xml: Error: Duplicate resources
Library 和 App module 的资源冲突

Library module 的资源和独立的 App module 资源进行合并。如果双方均具备一个资源 ID 的话,将采用 App 的资源。

Library 之间的资源冲突

如果多个 AAR 库之间发生了冲突,依赖列表里第一个列出(在依赖关系块的顶部)的资源将会被使用。如果 2 个 lib 之间有依赖关系,比如 lib2 依赖 lib1, 那么 lib2 会覆盖 lib1 的同名标识;没有依赖关系,则按照依赖顺序决定

1
2
3
4
5
6
7
8
9
 <!--library1/../strings.xml-->
 <resources>
     <string name="hello">Hello from Library 1!</string>
 </resources>
 
 <!--library2/../strings.xml-->
 <resources>
     <string name="hello">Hello from Library 2!</string>
 </resources>
  1. library1 优先 library2 声明,且 library1 和 library2 没有互相依赖关系
1
2
3
4
5
 dependencies {
     implementation project(":library1")
     implementation project(":library2")
     // ...
 }

最后 string/hello 的值将会被编译成 Hello from Library 1!。

  1. 如果这两个 implementation 代码调换顺序,比如 implementation project(“:library2”) 在前、 implementation project(“:library1”) 在后,资源值则会被编译成 Hello from Library 2!
  2. app 先后依赖 library2 和 library1,但 library1 依赖 library2,资源值则会被编译成 Hello from Library 1!
冲突解决

不同 module 之间,定义资源的前缀

1
2
3
4
5
6
7
8
9
10
//resourcePrefix资源前缀限定,只能限定布局文件名和value资源的key,并不能限定图片资源的文件名
android {
    //给电商工程加上前缀约束shopping_
    resourcePrefix "shopping_"
}

android {
    //给直播工程加上前缀约束live_
    resourcePrefix "live_"
}

组件资源冲突检测 Gradle 插件

多仓库 vs 单仓库

组件化过程中遇到的问题?

老项目如何实施组件化?

老项目存在问题

  1. 代码年久失修,文档缺失,不敢随意修改,否则牵一发而动全身,引起现有正常业务的运行
  2. 进行组件化重构需要花费比较长的时间,业务不可能停下来等着你去重构
  3. 组件化重构后,需要全量回归测试,测试比较花费时间

老项目组件化过程

  1. 优先集成路由框架

新开发的功能页面跳转一律采用路由框架,老的页面跳转逐步替换;这样可以尽量减少代码耦合,为后面进行模块拆分打下基础

  1. 抽取出基础功能组件

抽取出来公共的独立的组件,比如日志、网络请求、图片加载、常见的工具类、BaseActivity 等 Base 类

  1. 业务模块拆分

将老项目中的所有业务安装功能进行业务划分,不能太细,否则组件会太多,业务划分一般不要超过 2 层,这样就可以得到一张完整的业务架构图,对 App 所有业务员的模块划分

  1. 抽取基础业务组件

根据 3 画出来的业务架构图,将一个个组件抽离出来。这个过程会出现各种依赖问题,如果是两个组件间的以依赖问题,我们需要用路由框架的组件间通信的功能来将这两个组件间的依赖给去除掉

  1. 新老代码共存

老项目的组件化需要一定的时间,这个过程中,新开发的功能与重构并行的进行。经过一段时间逐步将老工程的业务全部组件化

组件源码和 aar

组件的源码和 aar 依赖关系的如何切换?

  1. 通过 gradle 脚本,配合在 gradle.properties 配置变量来控制源码和 aar 之间的切换
  2. 通过自定义插件来实现(吸音就是这样)

组件间 aar 和源码依赖传递 merge 失败?

组件间源码依赖和 aar 依赖传递导致的 merge 失败问题,通过 substitute 强制转换为同一种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
allprojects { p ->
    /*
     * 修复构建的依赖传递导致merge失败, 如:
     * qsbk.app.remix
     *   ↳ :live (local and changed) <-----------┒
     *   ↳ qsbk.app:feed:x.y.z (remote)          ┆
     *     ↳ qsbk.app:live:x.y.z (remote) <------┚
     */
    configurations.all {
        resolutionStrategy.dependencySubstitution {
            def libs = ['core', 'libcommon', 'libwidget']
            libs.each {
                if (libsInSource.toBoolean()) {
//                    println("----------- substitute module(${Config.Maven.groupId + ":${it}:" + Config.moduleVersion[it]}) with project(:${it})")
                    substitute module(Config.Maven.groupId + ":${it}:" + Config.moduleVersion[it]) with project(":${it}")
                }
            }
        }
    }
}

组件的 aar 如何更新?

通过 jenkins ci 工具,启一个定时 Job,每天早上将有更新的组件的代码编译成功后 push 到 maven 仓库

众多组件,可能几十个,如何拉取,更新的问题,版本如何维护?

  • clone 的问题:采用的多仓库开发,不太可能一个个仓库去手动拉取,用 shell 脚本来拉取
  • pull:shell 脚本批量进行 pull 更新
  • 组件的版本如何维护:通过 git hooks+CI 工具自动维护更新;每次代码 push 到 git 库时会自动触发 jenkins 编译,编译通过后会生成类似 9.2.4.230519200727.release.f8c5ba285 这种到一个 dependency.properties 文件
  • 如何维护版本冲突问题?导致的编译失败

多仓库问题

  1. 切换分支麻烦,拉取慢
  2. 分支容易对不齐,打包失败

组件化过程中常见小问题

组件中 ButterKnife 报错—R2

在 Library 中,ButterKnife 注解中使用 R.id 会报错,这是因为在 library 中生成的 R 文件,这些属性值都不是常量
解决:通过 ButterKnife 提供的 Gradle 插件,引用 R2 来解决该问题

路由框架如何抉择?

架构设计→路由框架设计.md

ARouter 遇到的问题

  1. 一个 group 不能在多个 module 中出现
  2. 注入空问题,声明我非空,但调用方没有带该字段,还是有可能为空,

ARouter 有什么不足?

具体见 Android开源库→ARouter

  1. 路由表扫描和注册在启动时扫描 dex?

用三方插件可编译器通过 transform+ASM 装载路由表避免在启动时扫描 dex 浪费性能

  1. 接口下沉到 core_module 导致该 module 膨胀?

服务暴露方提供单独的 xxx_export 暴露组件供需要方依赖,避免下沉到 core_module

  1. 一个页面只支持一个 path,不支持多个 path,也不支持正则表达式
  2. startActivityForResult 后需要重写 onActivityResult() 方法,导致路由和结果的代码分散难以维护
  3. 缺少组件间初始化,需要支持组件间初始化依赖关系
  4. 拦截器是全局的,所有路由时都会走拦截器,不支持对某个目标页面指定特定的拦截器
  5. 跨进程调佣支持不咋地

组件化思考

1、路由和总线?

市面上的路由框架基本都是基于路由的。

相同点

路由和组件总线都需要将分布在不同组件 module 中的某些类按照一定规则生成映射表(通常是 Map,key 为字符串,Value 为类或对象),然后在需要用到的时候从映射表中根据字符串取出类或对象

不同点

  1. 路由方案
  • 路由的本质是类的查找,其工作原理类似于仓库管理员,先把类全部放到仓库,有人需要的时候,仓库管理员就根据所提供的字符串找出存放在仓库中的类。(基于 apt 生成路由表,transform+ASM 加载路由表)。
  • 组件之间的服务调用时,调用方需要持有接口类,需要将接口类定义下沉到 base 层,向接口编程,遵循依赖倒置原则
  • 由于路由本质是类查找,所以需要通信的组件必须要打包在同一个 app 内部才能获取到
  1. 组件总线方案
  • 组件总线的本质是转发调用请求,其工作原理类似于电话接线员(中介者模式):组件总线负责收集所有组件类并形成映射表(Key 为字符串,Value 为组件类的对象)。调用组件时,总线根据字符串找到对应的组件类并将调用信息转发给该组件类,组件执行完成后再通过组件总线将结果返回给调用方。
  • 组件总线只负责通信,即转发调用请求和返回执行结果
  • 不需要下沉接口,面向通信协议编程(类似于 app 客户端调用服务器端接口的通信协议)
  • 由于组件总线的本质是转发调用请求,可以通过跨进程通信方式将调用请求转发给其它 app,从而实现跨 app 进行组件调用。

2、路由框架通信时下沉接口导致 core module 膨胀,怎么解决?

  1. 两个 module 之间通信的接口不下沉到 core module,而是由服务提供的 module 新建一个 module_export module 专门用来通信用的,调用方只需要依赖这个 module 即可,就可以避免 core module 接口膨胀问题
  2. 服务发现采用组件总线方案,而不是路由方案

如何设计一个路由框架?

本文由作者按照 CC BY 4.0 进行授权