2022 Android组件化技术方案

 

一.为什么要组件化架构升级

1.1 现状

玩吧 app 架构从一开始的”ALL IN ONE”模式,逐渐演变到现在的单 project 多 module 架构,目前架构图如下: 目前系统架构图

1.2 目前的发现的问题

  • 依赖中心化,core 和 sdk 层都通过 common 层给 feature 层提供服务,api 引入组件的方式造成依赖不清晰,编译时间变长
  • 公共服务中心化,feature 层的公共逻辑都在 common 中
  • 模块对外暴露的服务不可知,都是直接依赖内部代码逻辑
  • core 层、sdk 层级界限不清晰,同层之间存在依赖
  • 代码单仓库,模块无法单独打包,需要全量编译

1.3 架构调整的目标

架构调整核心目标是解耦和提高复用,去除代码中心化问题,解耦现有业务。

  • 公共业务去中心化
  • 改变模块间通信方式
  • 重新设计模块层级
  • 约束代码边界

二.架构调整

2.1 模块粒度划分

组件是什么?

参考 Clean Architecture  对组件的定义:

组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于 Java 来说,它的组件是 jar 文件。而在 Ruby 中,它们是 gem 文件。在 .Net 中,它们则是 DLL 文件

Android 中 Gradle Module 是发布 JAR 或者 AAR 的基本单元,因此 Module 可以看作是一个组件,在 Module 粒度划分上,我们套用书中关于组件划分的三个原则:

  1. 复用发布等价原则(Release Reuse Equivalency Principle)
  2. 共同封闭原则(The Common Closure Principle)
  3. 共同复用原则(The Common Reuse Principle)

复用发布等价原则(REP)

软件复用的最小粒度等同于其发布的最小粒度

REP 告诉我们划分 Module 的基本原则就是代码可复用性,当一些代码有被复用价值的时候就考虑是否拆分为 Module,可以独立发布,此外还要注意不能拆分过度。

如果两个可以独立发布的组件总是作为一个整体被复用,就会出现可复用粒度大于可发布粒度的问题,也就增大了版本冲突的概率,此时可以考虑将他们合二为一,同时发布,避免版本不一致.

共同封闭原则(CCP)

组件中的所有类对于同一种性质的变化应该是共同封闭的,即一个变化的影响应该尽量局限在单个组件内部,而避免同时影响多个组件,我们应该将那些会因为相同目的而同时修改的类放到同一个组件中,而将不会为了相同目的同时修改的那些类放到不同的组件中。

相比 REP 关注的可复用性,CCP 更强调可维护性,很多场景下可维护性相对可复用性更加重要。CCP 要求我们将所有可能被一期修改的类集中到一起,两个总是被一起修改的类应该放入同一组件。 比如我们现在的 Module 项目工程目录,是按照代码工程的属性划分的:

  +activity
  +adatper
  +bean
  +event
  +viewmodel
  ...

实际开发中我们可能只修改了 activity 或者 viewmodel,但是随着业务的增多,我们无法判断哪个类是属于哪个业务,且会逐渐存在相互耦合 因此我们通过业务属性划分可能更合理 ,比如如下的目录:

 +SocialList
   +activity
   +adapter
 +Publish
   +activity
   +adapter
  ...

这样我们可以做到业务隔离,在某个包内或者模块内闭环完成我们的修改。

共同复用原则(CRP)

组件中的类应该同事被复用,即组件中不要依赖不参与复用的类

REP 要求我们将紧密相关的类放在一个组件中一同发布,而 CRP 要求我们强调的是不要把不相关的类放进来,要用大家就一起用。要注意所谓”共同”复用并不意味着所有的类都能被外部访问,有些类可能是服务于内部其他类的,但也是必不可少的。我们虽然不希望组件过度拆分,但是同时要求组件的类不能过度冗余,不应该出现别人只需要依赖它的某几个类而不需要其他类的情况。

三原则权衡

上述三个原则有着互斥关系,REP 和 CCP 是粘合性原则,告诉我们哪些类要放在一起,这会让组件变得更大。CRP 是排除性原则,不需要的类要从组件中移除出去,这会使组件变小。组件设计的重要任务就是在这三个原则之间做出均衡

REP,CCP,CRP 的中心思想都是追求组件内部合理的内聚性,但是它们的侧重点不同,三者很难同时兼顾,如果考虑不周会落入按下葫芦浮起瓢的窘境。如果只遵守 REP、CCP 而忽略 CRP ,就会依赖了太多没有用到的组件和类,而这些组件或类的变动会导致你自己的组件进行太多不必要的发布;遵守 REP 、CRP 而忽略 CCP,因为组件拆分的太细了,一个需求变更可能要改 n 个组件,带来的成本也是巨大的,如果只遵守 CCP 和 CRP 而忽略 REP 可能因为组件的能力太过于垂直而牺牲了底层能力的可复用性。

三者关系

Module 粒度划分很难有一个普适的结论,应该根据项目类型,项目阶段在三原则作出取舍,比如项目早起更关注业务开发和维护效率,所以 CCP 比 REP 更重要,随着项目发展需要考虑底层能力复用性,REP 变得重要起来,随着项目的业务迭代,组件能力越发臃肿,此时需要借助 CRP 对组件进行合理拆分和重构。

2.2 Module 依赖关系和层级划分

在粒度划分上我们追求组件如何保持合理的内聚性,组件间依赖关系梳理也能更好的维持外部的耦合,Clean Architecture  中关于组件耦合设计也有三个原则:

  1. 无环依赖原则(The Acyclic Dependencies Principle)
  2. 稳定依赖原则(The Stable Dependencies Principle)
  3. 稳定抽象原则(The Stable Abstractions Principle)

无环依赖原则(ADP)

组件依赖关系图不应该出现环,关系图必须是有向无环图

比如 A -> B -> C -> A 这样的环形依赖中,由于 C 依赖了 A ,B 又依赖了 C,A 的变化对 B ,C 都会带来影响,依赖环中的任何一点发生变更都会影响环上的其他节点。设想一下如果没有 C -> A 的依赖,C 的变化只会影响 B,B 只会影响 A,A 的变化将不再影响任何人。

三者关系

android 当中循环依赖编译期间会直接报错,因此我们不用太过担心,但是还是需要思考如何消除循环依赖:

1.依赖倒置

借助 SOLID 中的 依赖倒置原则(DIP),把 C > A 的依赖内容,抽象为 C 中的接口,C 面向接口编程,然后让 A 实现这些接口,依赖关系发生反转

三者关系

2.增加组件

新增 D 组件,C > A 的依赖部分下沉到 D ,让 C 和 A 共同依赖 D,类似于中介者设计模式。

三者关系

当然,这种方式如果滥用会导致工程的组件膨胀,所以是否真的要从 A 中下沉一个 D 组件,还要结合 REP 和 CCP 原则综合考虑

稳定依赖原则(SDP)

依赖关系要趋于稳定的方向,例如 A 依赖 B,则被依赖方 B 应该比依赖方 A 更稳定

如果 A 是一个公共组见需要保持高度稳定,而他依赖一个经常变更的组件 B,就会变得不稳定。想保证 A 的稳定修改 B 就会畏手畏脚南极进行一个预期会经常变更的组件是一个不稳定的组件,这个定义过于太主观,如何客观衡量一个组件的稳定性呢?

稳定度公式

稳定度的衡量方式可以看一个组件依赖了多少组件(入向依赖度)和被多少组件所依赖(出向依赖度)这两个指标:

  • 入向(Fan-in):依赖这个组件的反向依赖的数量,这个值越大,说明这个组件的职责越大。
  • 出向(Fan-out):这个组件正向依赖的其他组件的数量,这个值越大,说明这个组件越不独立,自然越不稳定。
  • 不稳定度:I(Instability) = Fan-out / (Fan-in+Fan-out)

这个值越小,说明这个组件越稳定:

  • 当 Fan-out == 0 时,这个组件不依赖其他任何组件,但是有其他组件依赖它。此时它的 I = 0,是最稳定的组件,我们不希望轻易地改动它,因为它一旦改动了,那么依赖它的其他组件也会受到影响。
  • 当 Fan-in == 0 时,这个组件不被其他任何组件依赖,但是会依赖其他组件。此时它的 I = 1,是最不稳定的组件,它所依赖的组件的改动都可能影响到自身,但是它自身可以自由地改动,不对其他组件造成影响

稳定抽象原则(SAP)

一个组件的抽象化程度应该与其稳定性保持一致,越稳定的组件应该越抽象

SAP 为组件的稳定性和抽象化程度建立了一种关联,稳定组件需要变更时应该避免修改自己,而是通过其派生类的扩展来实现变更,这就要求稳定组件具备良好的抽象能力。而至于抽象类的实现部分,应该从稳定组件中剥离,放到不稳定组件中,这样可以无压力的对其代码进行修改而不必担心影响他人。

抽象度公式
  • Nc:组件中类的数量
  • Na:组件中抽象类和接口的数量
  • A:抽象程度, A = Na / Nc

A 的取值范围从 0 到 1,值越大表示组件内的抽象化程度越高,0 代表组件中没有任何抽象类,1 代表组件只有抽象没有任何实现

业务架构思路

根据以上组件划分和依赖原则可以将 app 架构划分为如下层级: 架构层级图

纵向分层

先看纵向分层,根据业务耦合度从上到下依次是业务层、核心功能层、基础框架层。

  • 业务层:位于架构最上层,根据业务模块划分(比如商城、社区。首页等),与产品业务相对应;

  • 核心功能层:App 的一些基础功能(比如登录、用户信息)和业务公用的组件(比如分享、评论、hybrid,im),提供一定的复用能力;

  • 基础框架层:完全与业务无关、通用的基础组件(比如网络请求、图片加载),提供完全的复用能力。

框架层级从上往下,业务相关性越来越低,代码稳定性越来越高,抽象度越来越高,代码入仓要求越来越严格(可以考虑代码权限收紧,越底层的代码,入仓要求越高)。

横向模块划分

在每一层上根据一定的粒度和边界,拆分独立模块。比如业务层,根据产品业务进行拆分。核心功能层则根据功能进行拆分。

大模块可以独立一个代码仓(比如商城、社区),小模块则多个模块组成一个代码仓。

模块要高内聚低耦合,尽量减少与其他模块的依赖。

模块依赖规则

  • 只有上层代码仓才能依赖下层代码仓,不能反向依赖,否则可能会出现循环依赖的问题;

  • 同一层的代码仓不能相互依赖,保证模块间彻底解耦。

2.3 公共业务去中心化

业务方面

common 设计的初衷是对于上层和底层的隔离,存放一定量的基础类,存储网络相关通用组件支持。其实里面还有很多业务相关代码,这是 common 膨胀的来源。但是原因是什么呢,之前的架构中,我们使用了大量 event 事件作为模块间通信的方式,这时候 common 自然而然成了存放他们的唯一选择;接着如果模块 A 要使用模块 B 的 entity,下沉到 common;如果模块 A 想要模块 B 的某个功能,怎么办,下沉到 common 吧……

就这样越多越多的代码很自然的下沉到 common 中,他会造成什么问题呢?

  • 冗余:比如工具类,很多时候,当你找不到工具类的时候,会塞一个新的进去
  • 维护成本高:所有公共业务逻辑都在 common 里,对一些业务逻辑的影响面无法掌控
  • 耦合高:缺少相应的代码隔离,可能会出现模块内耦合,一团乱麻

依赖方面

现在所有的 core 层和 sdk 层都是通过 common 提供给上层,根据 grade 依赖关系底层每个 sdk 的变动都会更新 common,common 的更新也会刷新上层的所有 feature 模块,造成 common 是一个极度不稳定的模块。

另外我们现在引入包的方式简单粗暴的用了 wbapi,造成了 common 的所有依赖对上层都是可见的,底层模块的更新也会引起最上层模块的刷新,拖慢了编译速度。

api 和 implementation 区别:

  • api 或 compile 关键字引用的包对于其他 module 来说是可见的
  • 而 implementation 关键字引用的包对于其他 module 来说是不可见的,即编译期是隔离的

比如三个 module A、B、C,A 依赖 B,B 依赖 C,如果 B 依赖 C 是 api 的形式,A 是可以访问 C 的代码的,即 C 对 A 是可见的;如果采用 implementation 方式,A 无法访问 C 的代码,C 对 A 是不可见的。

为什么要这样区分?

一个是更好的解耦,将 A 和 C 完全解耦

另外可以提升编译速度,C 对 A 不可见,当 C 变化了,只会重新编译 B。

2.3.1 现在的 common 里面有什么

  1. 工具类 util
  2. 公用的 UI(widget,activity,fragment,dialog)
  3. 共有业务类比如 bean,event
  4. 基础 sdk 的封装类,低业务 support 模块
  5. 封装的基类(BaseActivity,BaseFragemt)

2.3.2 解决的思路

现在 common 的体量无疑是一个超级模块了,如何解决目前的问题呢,此时我们需要借助共同复用原则(CRP)对组件进行合理的拆分和重构,拆分过程中还需要遵守拆 module 层级依赖原则确定层级。

  1. 依照业务属性强度将 UI 业务类上升到 business 业务层
  2. 遵循复用发布等价原则(REP)将 common 里的独立业务抽离成独立组件
  3. 基础服务类下沉到 sdk 层,抽离里面的通用 util 和 widget
  4. 梳理 common 里面的层级依赖关系,解除 common 中心依赖和传递问题,各模块按需依赖,做到编译期隔离

2.4 改变模块间通信方式

我们 app 模块化根据业务划分了多个模块,模块之间交互以相互提供服务的方式来完成,随着业务模块增多,必然存在模块之间业务依赖的情况,Android 端的依赖方式分为两种:

  1. A 模块直接依赖 B 模块,直接调用 B 模块代码逻辑
  2. 将 A 和 B 模块中共有代码部分放到 common 中,通过调用 Common 模块实现依赖,这也是 common 膨胀的原因

这种方式实现简单,但是耦合很严重,不方便开发维护,随着模块增多,依赖关系越来越复杂,有没有其他通信方式呢

事件或者广播

订阅发布模式非常灵活和解耦,但是代码的可塑性差,难以追溯事件

路由通信

模块之间不存在依赖关系,通过路由映射,比如 arouter,通过外部 URL 映射到内部页面,进行传递,页面跳转等跨模块 API 调用。

面向接口通信

这里所说的面向接口不只是 java 的 interface,而是超类型,可以是接口,也可以是抽象类。它的核心思想是将抽象和实现分离,从组件级别设计代码,达到高内聚低耦合的目的。面向接口编程的方法是先定义底层模块,也就是通信的协议和功能约定。在架构中层次分明,不关注具体实现,开发中可以通过接口快速制定协议和提供能力的 api,对于上层通过接口显露能力,对于下层只需要依赖接口层相当于依赖 API

面向接口编程的好处?

灵活性高,没有依赖具体实体,实现层可以任意切换,在模块化中可以相互依赖 service(api 层),或者依赖多个。

2.4.1 业务模块服务依赖的实现

参考面向接口通信的思想,想要使用对方的服务,只需要依赖对方对外暴露的 API 模块。app 也采用类似的方式实现模块的通信,可以将模块分成两部分,内部的业务实现层,对外提供一个 API 层,用来对外暴露数据结构和服务(接口层):

新的模块结构

2.4.2 业务模块同级之间依赖方式

将模块拆分为模块业务层和 api 实现层之后,其他模块要调用另一个模块的方法的时候就可以只依赖对方提供的 api 层,而不需要直接依赖对方模块,模块之间的依赖关系如下:

两个模块结构

api 层边界处理

如上图,如果 user-api 中有个类 C 需要依赖 socai-api 里的一个类 D 怎么办, 两个 api 之间时隔离的,杜绝相互引用的,这个时候只能采用下沉的方式,下沉到 core 层建立一个 core_model.

2.4.3 组件之间通信方式

组件化架构下,同级别业务组件无法直接依赖,所以组件之间通信方式需要一个中转站,这里采用阿里 Arouter 的方式,具体流程如下:

组件通信机制

2.4.4 API 层技术实现

  1. 面向接口编程,接口下沉
  2. 利用 arouter 组件之间传递数据的方式
  3. 采用只增不减的模式

在 module-api 中声明接口

// 声明接口,其他组件通过接口来调用服务
public interface UserService extends IProvider {
     String getUserName(String name);
}

在 module 实现接口:

// 实现接口
@Route(path = "/user/user_info", name = "获取用户信息")
public class UserServiceImpl implements UserService {

 @Override
 public String getUserName(String name) {
     return "hello, " + name;
 }

 @Override
 public void init(Context context) {

 }
}

在使用的时候

public class Test {

  @Autowired(name = "/user/user_info")
  UserService userService;

  public Test() {
      ARouter.getInstance().inject(this);
  }

  public void testService() {
          //方式一
      userService.getUserName("Vergil");
      //方式二
      UserService  userService1 = ARouter.getInstance().navigation(UserService.class);
              userService1.getUserName("Vergil");
      //方式三
      UserService  userService2 = (UserService)ARouter.getInstance().build("/user/user_info").navigation();
      userService2.getUserName("Vergil");
  }
}

最后 api 层里的内容包含 对外提供服务的接口,服务相关的实体类,路由信息,event,便于服务的 utils

2.4.5 module 层技术实现

实现自己的业务,实现 api 层提供的接口,完成对外提供的功能

2.5 约束代码边界

维持代码边界

代码的边界就像一堵墙,架构的劣化也是因为这堵墙瓦解开始的。按照以往的经验来说,编译隔离是最好的约束手段,单纯的约定和开发规范并不能永远保持下去。所以在任何情况下都不要尽可能放开编译的约束。接着将接口和实现分离,依赖关系只有模块提供的接口,不依赖实现。由于是业务驱动开发,遇到紧急业务就有可能破坏当前的接口,所以要加强代码的审查和 review,也可以利用 CI 的能力。

对于模块的划分,我们依照 2.1 依据模块粒度划分的复用发布,共同封闭,共同复用原则三者权衡已经基本可以划分大部分模块,但是由于业务藕断丝连的关系,并没有哪种规则能完全适用于每个业务,这个时候该如何选择呢,我认为只要是符合逻辑的行为,你的解耦的行为可以让人理解,就可以将它作为拆分的选择。我们应该避免单纯为了解耦而解耦的情况。

代码负责人制

这样一句话,“不被监管的权利一定会发生腐败” 。如果放到软件开发的行当来说,就是“不被监管的代码也一定会发生劣化”。所以代码应该要接受“监管”。

为了长期保持代码质量,我们应该加强代码的审查机制-模块负责人制度。

我们现在的代码有很多,几乎每个人都修改过很多模块的代码,也导致了无主代码特别多,大家对代码没有归属感,也降低了大家维护代码的欲望。模块负责人制度通过大家认领模块,对模块代码和设计负责,也监督其他同学对自己负责模块的修改,改变代价修改和优化代码的动机。

模块内部的代码边界

在实现模块之间的隔离之后,模块内部代码还说可以相互引用的,那其实模块内部边界是模糊的,这次改造希望可以做到页面级别的代码隔离。之前按照代码工程属性的模式分包,随着业务发展,业务越来越多,模块内部的边界越来越模糊,很容易被打破,资源集中在一起也造成迁移成本变高。

如何约束模块内部的代码边界呢?

依照共同封闭原则(CCP),我们希望封闭的粒度是页面级别的,即一个 Activity 是组成一个 App 的页面单元,围绕着这个 activity,也有一堆资源。每个 activity 都创建 module 的话又回出现巨量化的 module。

参考《微信 Android 模块化架构重构实践》中提到的 p(pins)工程概念,我们通过 gradle 里配置 sourceSets,改变工程内的代码目录结构,完成代码隔离。

2.6 新的架构图

结合以上问题,和解决方式,设计架构图如下: 新的系统架构图

2.6.1 宿主层

app 入口组合层(壳工程)

  • 包含一些全局配置不包含业务代码
  • 只依赖每个 business 层组件

2.6.2 business 业务层

业务组件

最上层的业务,彼此之间相互独立,同级别之间不存在相互依赖,通过路由通信,包含强业务相关,包含承载业务的界面 UI 和逻辑

  • 可以依赖 business-api 层 core 层 ,sdk 层
  • 最后打包以 aar 打包
  • 每个层级都需要上传到 maven 仓库,开发者只需要打开自己所需的模块

2.6.3 business-API 层

api 放置的东西都是外部组件和自身所需要的,其他的都应该放到 business 层

  • 为了上层 business 之间交互,其他 business 可以依赖
  • 最后打包以 library 打包

关于 api 层可以放置的东西列表:

类型 能否放 business-api 备注
功能页面(Activity,Fragment,DIalog) 不能 通过 provider 方式提供
基类页面 部分能 其他 business 需要使用的可以
adapter 部分能 其他 business 需要使用的可以
provider 部分能 接口定义在 api 中,具体实现类在 business 中
entity 部分能 其他 business 需要使用的可以
event 部分能 其他 business 需要使用的可以
util 部分能 其他 business 需要使用的可以
api route 不能 通过 provider 提供

2.6.4 core 功能组件

通用功能组件,需要依赖 sdk 的基础能力实现的一类功能组件,也可以是 sdk 层抽象的具体实现

比如登录:登录流程抽象在 core,具体实现还是在 business,即面向接口编程

  • 对于一些可复用的功能进行封装(分享,登录,评论,聊天)
  • 最后打包以 library 打包
  • 同级别之间不相互调用,如果需要通信则需要通过 core-api 层调用
  • 随着演进有下沉为 sdk 的能力

2.6.5 core-API 层

纯接口层,是 core 层对外暴露的能力和接口

  • 为了 core 层之间交互可能存在的交互
  • 直接依赖
  • 最后打包以 library 打包

2.6.6 sdk 组件

sdk 层的定义为 不依赖任何其他业务实现的一个功能组件,sdk 层组件应该是抽象度最高的一层组件,即最符合稳定抽象原则(SAP)的一层 module,同时稳定度也最高。

sdk 层按照内部依赖关系分为两层 sdk 基础服务层:为了解决 sdk 存在的依赖问题,考虑分为虚拟的两层,比如日志上报,比如线程池,工具类,所以分配到基础服务层 基础组件层: app 通用的能力比如 http,file,media,其他三方库的简单封装.

按照玩吧特有和通用的形态去分类可以把 T 票体系,用户体系,river 协议,游戏语音房状态体系这些抽象到 sdk 基础服务层,具体实现再 core 层,这些是我们玩吧特有的业务 另外的是通用 sdk 组件

  • 封装公用的基础组件
  • 网络访问框架,图片加载,长链接,短链接,rust,工具类
  • 各种三方 sdk
  • 最后打包以 library 打包

三 组件化过程中的可能问题

3.1 单独编译某个业务模块

  1. 采用多壳 app 比如 app_ home 是 home 业务的壳工程,那 app_ home 还需要哪些同级别的 module 才能跑起来呢,就靠之前依赖的 business-api 确定需要几个模块才能正常运行起来。

  2. 根据 gradle 配置每个模块配置 library/application 模式进行切换

3.2 gradle 代码的复用

版本号统一管理,依赖统一管理到一个 build.gradle 文件里

3.3 资源规范

比如 business_ home 和 business_ user 在 drawable 目录下有一张同名图片 最后只有一张会被打包,容易出现错误

解决方式是增加 gradle 的 resourcePrefix 配置 如下配置:

android {
    resourcePrefix 'home_'
}

还是需要按照约束命名,不按照要求会有 waring

注:layout,drawable 等文件也应该根据模块区分命名方式 (ps:类和资源文件命名规范)

最后需要 新建 lib_design 模块存放 app 共有的 图片,String, style…

3.4 skin 夜间模式

现状是在 common 和 application 里,考虑拆分出去

3.5 dp sp 统一

统一使用 wb _ sdk_ dp 里面提供的值

3.6 模块管理

  1. 无论是 business 层业务模块还是 api 层,core 和 sdk 层模块都需要打包上传 maven 仓库,最终打包都以 aar 引入壳 app 完成 整个 apk 的组装
  2. 目前 Android 代码大小超过 3G,每次游戏引擎 so 库更新拉取代码是很痛的的一个过程,所以需要拆分多仓库,单独模块打包 aar,默认采用 aar 依赖

四.参考资料