Android组件化方案及组件消息总线modular-event实战

45次阅读

共计 9391 个字符,预计需要花费 24 分钟才能阅读完成。

背景
组件化作为 Android 客户端技术的一个重要分支,近年来一直是业界积极探索和实践的方向。美团内部各个 Android 开发团队也在尝试和实践不同的组件化方案,并且在组件化通信框架上也有很多高质量的产出。最近,我们团队对美团零售收银和美团轻收银两款 Android App 进行了组件化改造。本文主要介绍我们的组件化方案,希望对从事 Android 组件化开发的同学能有所启发。
为什么要组件化
近年来,为什么这么多团队要进行组件化实践呢?组件化究竟能给我们的工程、代码带来什么好处?我们认为组件化能够带来两个最大的好处:
提高组件复用性
可能有些人会觉得,提高复用性很简单,直接把需要复用的代码做成 Android Module,打包 AAR 并上传代码仓库,那么这部分功能就能被方便地引入和使用。但是我们觉得仅仅这样是不够的,上传仓库的 AAR 库是否方便被复用,需要组件化的规则来约束,这样才能提高复用的便捷性。
降低组件间的耦合
我们需要通过组件化的规则把代码拆分成不同的模块,模块要做到高内聚、低耦合。模块间也不能直接调用,这需要组件化通信框架的支持。降低了组件间的耦合性可以带来两点直接的好处:第一,代码更便于维护;第二,降低了模块的 Bug 率。
组件化之前的状态
我们的目标是要对团队的两款 App(美团零售收银、美团轻收银)进行组件化重构,那么这里先简单地介绍一下这两款应用的架构。总的来说,这两款应用的构架比较相似,主工程 Module 依赖 Business Module,Business Module 是各种业务功能的集合,Business Module 依赖 Service Module,Service Module 依赖 Platform Module,Service Module 和 Platform Module 都对上层提供服务,有所不同的是 Platform Module 提供的服务更为基础,主要包括一些工具 Utils 和界面 Widget,而 Service Module 提供各种功能服务,如 KNB、位置服务、网络接口调用等。这样的话,Business Module 就变得非常臃肿和繁杂,各种业务模块相互调用,耦合性很强,改业务代码时容易“牵一发而动全身”,即使改一小块业务代码,可能要连带修改很多相关的地方,不仅在代码层面不利于进行维护,而且对一个业务的修改很容易造成其他业务产生 Bug。

组件化方案调研
为了得到最适合我们业态和构架的组件化方案,我们调研了业界开源的一些组件化方案和公司内部其他团队的组件化方案,在此做个总结。
开源组件化方案调研
我们调研了业界一些主流的开源组件化方案。
CC
号称业界首个支持渐进式组件化改造的 Android 组件化开源框架。无论页面跳转还是组件间调用,都采用 CC 统一的组件调用方式完成。
DDComponentForAndroid
得到的方案采用路由 + 接口下沉的方式,所有接口下沉到 base 中,组件中实现接口并在 IApplicationLike 中添加代码注册到 Router 中。
ModularizationArchitecture
组件间调用需指定同步实现还是异步实现,调用组件时统一拿到 RouterResponse 作为返回值,同步调用的时候用 RouterResponse.getData() 来获取结果,异步调用获取时需要自己维护线程。
ARouter
阿里推出的路由引擎,是一个路由框架,并不是完整的组件化方案,可作为组件化架构的通信引擎。
聚美 Router
聚美的路由引擎,在此基础上也有聚美的组件化实践方案,基本思想是采用路由 + 接口下沉的方式实现组件化。
美团其他团队组件化方案调研
美团收银 ComponentCenter
美团收银的组件化方案支持接口调用和消息总线两种方式,接口调用的方式需要构建 CCPData,然后调用 ComponentCenter.call,最后在统一的 Callback 中进行处理。消息总线方式也需要构建 CCPData,最后调用 ComponentCenter.sendEvent 发送。美团收银的业务组件都打包成 AAR 上传至仓库,组件间存在相互依赖,这样导致 mainapp 引用这些组件时需要小心地 exclude 一些重复依赖。在我们的组件化方案中,我们采用了一种巧妙的方法来解决这个问题。
美团 App ServiceLoader
美团 App 的组件化方案采用 ServiceLoader 的形式,这是一种典型的接口调用组件通信方式。用注解定义服务,获取服务时取得一个接口的 List,判断这个 List 是否为空,如果不为空,则获取其中一个接口调用。
WMRouter
美团外卖团队开发的一款 Android 路由框架,基于组件化的设计思路。主要提供路由、ServiceLoader 两大功能。之前美团技术博客也发表过一篇 WMRouter 的介绍:《WMRouter:美团外卖 Android 开源路由框架》。WMRouter 提供了实现组件化的两大基础设施框架:路由和组件间接口调用。支持和文档也很充分,可以考虑作为我们团队实现组件化的基础设施。
组件化方案
组件化基础框架
在前期的调研工作中,我们发现外卖团队的 WMRouter 是一个不错的选择。首先,WMRouter 提供了路由 +ServiceLoader 两大组件间通信功能,其次,WMRouter 架构清晰,扩展性比较好,并且文档和支持也比较完备。所以我们决定了使用 WMRouter 作为组件化基础设施框架之一。然而,直接使用 WMRouter 有两个问题:

我们的项目已经在使用一个路由框架,如果使用 WMRouter,需要把之前使用的路由框架改成 WMRouter 路由框架。
WMRouter 没有消息总线框架,我们调研的其他项目也没有适合我们项目的消息总线框架,因此我们需要开发一个能够满足我们需求的消息总线框架,这部分会在后面详细描述。

组件化分层结构
在参考了不同的组件化方案之后,我们采用了如下分层结构:

App 壳工程:负责管理各个业务组件和打包 APK,没有具体的业务功能。

业务组件层:根据不同的业务构成独立的业务组件,其中每个业务组件包含一个 Export Module 和 Implement Module。

功能组件层:对上层提供基础功能服务,如登录服务、打印服务、日志服务等。

组件基础设施:包括 WMRouter,提供页面路由服务和 ServiceLoader 接口调用服务,以及后面会介绍的组件消息总线框架:modular-event。

整体架构如下图所示:

业务组件拆分
我们调研其他组件化方案的时候,发现很多组件方案都是把一个业务模块拆分成一个独立的业务组件,也就是拆分成一个独立的 Module。而在我们的方案中,每个业务组件都拆分成了一个 Export Module 和 Implement Module,为什么要这样做呢?
避免循环依赖
如果采用一个业务组件一个 Module 的方式,如果 Module A 需要调用 Module B 提供的接口,那么 Module A 就需要依赖 Module。同时,如果 Module B 需要调用 Module A 的接口,那么 Module B 就需要依赖 Module A。此时就会形成一个循环依赖,这是不允许的。

也许有些读者会说,这个好解决:可以把 Module A 和 Module B 要依赖的接口放到另一个 Module 中去,然后让 Module A 和 Module B 都去依赖这个 Module 就可以了。这确实是一个解决办法,并且有些项目组在使用这种把接口下沉的方法。
但是我们希望一个组件的接口,是由这个组件自己提供,而不是放在一个更加下沉的接口里面,所以我们采用了把每个业务组件都拆分成了一个 Export Module 和 Implement Module。这样的话,如果 Module A 需要调用 Module B 提供的接口,同时 Module B 需要调用 Module A 的接口,只需要 Module A 依赖 Module B Export,Module B 依赖 Module A Export 就可以了。

业务组件完全平等
在使用单 Module 方案的组件化方案中,这些业务组件其实不是完全平等,有些被依赖的组件在层级上要更下沉一些。但是采用 Export Module+Implement Module 的方案,所有业务组件在层级上完全平等。
功能划分更加清晰
每个业务组件都划分成了 Export Module+Implement Module 的模式,这个时候每个 Module 的功能划分也更加清晰。Export Module 主要定义组件需要对外暴露的部分,主要包含:

对外暴露的接口,这些接口用 WMRouter 的 ServiceLoader 进行调用。

对外暴露的事件,这些事件利用消息总线框架 modular-event 进行订阅和分发。

组件的 Router Path,组件化之前的工程虽然也使用了 Router 框架,但是所有 Router Path 都是定义在了一个下沉 Module 的公有 Class 中。这样导致的问题是,无论哪个模块添加 / 删除页面,或是修改路由,都需要去修改这个公有的 Class。设想如果组件化拆分之后,某个组件新增了页面,还要去一个外部的 Java 文件中新增路由,这显然难以接受,也不符合组件化内聚的目标。因此,我们把每个组件的 Router Path 放在组件的 Export Module 中,既可以暴露给其他组件,也可以做到每个组件管理自己的 Router Path,不会出现所有组件去修改一个 Java 文件的窘境。

Implement Module 是组件实现的部分,主要包含:

页面相关的 Activity、Fragment,并且用 WMRouter 的注解定义路由。
Export Module 中对外暴露的接口的实现。
其他的业务逻辑。

组件化消息总线框架 modular-event
前文提到的实现组件化基础设施框架中,我们用外卖团队的 WMRouter 实现页面路由和组件间接口调用,但是却没有消息总线的基础框架,因此,我们自己开发了一个组件化消息总线框架 modular-event。
为什么需要消息总线框架
之前,我们开发过一个基于 LiveData 的消息总线框架:LiveDataBus,也在美团技术博客上发表过一篇文章来介绍这个框架:《Android 消息总线的演进之路:用 LiveDataBus 替代 RxBus、EventBus》。关于消息总线的使用,总是伴随着很多争论。有些人觉得消息总线很好用,有些人觉得消息总线容易被滥用。
既然已经有了 ServiceLoader 这种组件间接口调用的框架,为什么还需要消息总线这种方式呢?主要有两个理由:
更进一步的解耦
基于接口调用的 ServiceLoader 框架的确实现了解耦,但是消息总线能够实现更彻底的解耦。接口调用的方式调用方需要依赖这个接口并且知道哪个组件实现了这个接口。消息总线方式发送者只需要发送一个消息,根本不用关心是否有人订阅这个消息,这样发送者根本不需要了解其他组件的情况,和其他组件的耦合也就越少。
多对多的通信
基于接口的方式只能进行一对一的调用,基于消息总线的方式能够提供多对多的通信。
消息总线的优点和缺点
总的来说,消息总线最大的优点就是解耦,因此很适合组件化这种需要对组件间进行彻底解耦的场景。然而,消息总线被很多人诟病的重要原因,也确实是因为消息总线容易被滥用。消息总线容易被滥用一般体现在几个场景:
消息难以溯源
有时候我们在阅读代码的过程中,找到一个订阅消息的地方,想要看看是谁发送了这个消息,这个时候往往只能通过查找消息的方式去“溯源”。导致我们在阅读代码,梳理逻辑的过程不太连贯,有种被割裂的感觉。
消息发送比较随意,没有强制的约束
消息总线在发送消息的时候一般没有强制的约束。无论是 EventBus、RxBus 或是 LiveDataBus,在发送消息的时候既没有对消息进行检查,也没有对发送调用进行约束。这种不规范性在特定的时刻,甚至会带来灾难性的后果。比如订阅方订阅了一个名为 login_success 的消息,编写发送消息的是一个比较随意的程序员,没有把这个消息定义成全局变量,而是定义了一个临时变量 String 发送这个消息。不幸的是,他把消息名称 login_success 拼写成了 login_seccess。这样的话,订阅方永远接收不到登录成功的消息,而且这个错误也很难被发现。
组件化消息总线的设计目标
消息由组件自己定义
以前我们在使用消息总线时,喜欢把所有的消息都定义到一个公共的 Java 文件里面。但是组件化如果也采用这种方案的话,一旦某个组件的消息发生变动,都会去修改这个 Java 文件。所以我们希望由组件自己来定义和维护消息定义文件。
区分不同组件定义的同名消息
如果消息由组件定义和维护,那么有可能不同组件定义了重名的消息,消息总线框架需要能够区分这种消息。
解决前文提到的消息总线的缺点
解决消息总线消息难以溯源和消息发送没有约束的问题。
基于 LiveData 的消息总线
之前的博文《Android 消息总线的演进之路:用 LiveDataBus 替代 RxBus、EventBus》详细阐述了如何基于 LiveData 构建消息总线。组件化消息总线框架 modular-event 同样会基于 LiveData 构建。使用 LiveData 构建消息总线有很多优点:

使用 LiveData 构建消息总线具有生命周期感知能力,使用者不需要调用反注册,相比 EventBus 和 RxBus 使用更为方便,并且没有内存泄漏风险。
使用普通消息总线,如果回调的时候 Activity 处于 Stop 状态,这个时候进行弹 Dialog 一类的操作就会引起崩溃。使用 LiveData 构建消息总线完全没有这个风险。

组件消息总线 modular-event 的实现
解决不同组件定义了重名消息的问题
其实这个问题还是比较好解决的,实现的方式就是采用两级 HashMap 的方式解决。第一级 HashMap 的构建以 ModuleName 作为 Key,第二级 HashMap 作为 Value;第二级 HashMap 以消息名称 EventName 作为 Key,LiveData 作为 Value。查找的时候先用组件名称 ModuleName 在第一级 HashMap 中查找,如果找到则用消息名 EventName 在第二级 HashName 中查找。整个结构如下图所示:

对消息总线的约束
我们希望消息总线框架有以下约束:

只能订阅和发送在组件中预定义的消息。换句话说,使用者不能发送和订阅临时消息。
消息的类型需要在定义的时候指定。
定义消息的时候需要指定属于哪个组件。

如何实现这些约束

在消息定义文件上使用注解,定义消息的类型和消息所属 Module。
定义注解处理器,在编译期间收集消息的相关信息。
在编译器根据消息的信息生成调用时需要的 interface,用接口约束消息发送和订阅。
运行时构建基于两级 HashMap 的 LiveData 存储结构。
运行时采用 interface+ 动态代理的方式实现真正的消息订阅和发送。

整个流程如下图所示:

消息总线 modular-event 的结构

modular-event-base:定义 Anotation 及其他基本类型

modular-event-core:modular-event 核心实现

modular-event-compiler:注解处理器

modular-event-plugin:Gradle Plugin

Anotation

@ModuleEvents:消息定义
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface ModuleEvents {
String module() default “”;
}

@EventType:消息类型
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
public @interface EventType {
Class value();
}
消息定义
通过 @ModuleEvents 注解一个定义消息的 Java 类,如果 @ModuleEvents 指定了属性 module,那么这个 module 的值就是这个消息所属的 Module,如果没有指定属性 module,则会把定义消息的 Java 类所在的包的包名作为消息所属的 Module。
在这个消息定义 java 类中定义的消息都是 public static final String 类型。可以通过 @EventType 指定消息的类型,@EventType 支持 java 原生类型或自定义类型,如果没有用 @EventType 指定消息类型,那么消息的类型默认为 Object,下面是一个消息定义的示例:
// 可以指定 module,若不指定,则使用包名作为 module 名
@ModuleEvents()
public class DemoEvents {

// 不指定消息类型,那么消息的类型默认为 Object
public static final String EVENT1 = “event1”;

// 指定消息类型为自定义 Bean
@EventType(TestEventBean.class)
public static final String EVENT2 = “event2”;

// 指定消息类型为 java 原生类型
@EventType(String.class)
public static final String EVENT3 = “event3”;
}
interface 自动生成
我们会在 modular-event-compiler 中处理这些注解,一个定义消息的 Java 类会生成一个接口,这个接口的命名是 EventsDefineOf+ 消息定义类名,例如消息定义类的类名为 DemoEvents,自动生成的接口就是 EventsDefineOfDemoEvents。消息定义类中定义的每一个消息,都会转化成接口中的一个方法。使用者只能通过这些自动生成的接口使用消息总线。我们用这种巧妙的方式实现了对消息总线的约束。前文提到的那个消息定义示例 DemoEvents.java 会生成一个如下的接口类:
package com.sankuai.erp.modularevent.generated.com.meituan.jeremy.module_b_export;

public interface EventsDefineOfDemoEvents extends com.sankuai.erp.modularevent.base.IEventsDefine {
com.sankuai.erp.modularevent.Observable<java.lang.Object> EVENT1();

com.sankuai.erp.modularevent.Observable<com.meituan.jeremy.module_b_export.TestEventBean> EVENT2(
);

com.sankuai.erp.modularevent.Observable<java.lang.String> EVENT3();
}
关于接口类的自动生成,我们采用了 square/javapoet 来实现,网上介绍 JavaPoet 的文章很多,这里就不再累述。
使用动态代理实现运行时调用
有了自动生成的接口,就相当于有了一个壳,然而壳下面的所有逻辑,我们通过动态代理来实现,简单介绍一下代理模式和动态代理:

代理模式:
给某个对象提供一个代理对象,并由代理对象控制对于原对象的访问,即客户不直接操控原对象,而是通过代理对象间接地操控原对象。

动态代理:
代理类是在运行时生成的。也就是说 Java 编译完之后并没有实际的 class 文件,而是在运行时动态生成的类字节码,并加载到 JVM 中。
在动态代理的 InvocationHandler 中实现查找逻辑:

根据 interface 的 typename 得到 ModuleName。
调用的方法的 methodname 即为消息名。
根据 ModuleName 和消息名找到相应的 LiveData。
完成后续订阅消息或者发送消息的流程。

消息的订阅和发送可以用链式调用的方式编码:
订阅消息
ModularEventBus
.get()
.of(EventsDefineOfModuleBEvents.class)
.EVENT1()
.observe(this, new Observer<TestEventBean>() {
@Override
public void onChanged(@Nullable TestEventBean testEventBean) {
Toast.makeText(MainActivity.this, “MainActivity 收到自定义消息: ” + testEventBean.getMsg(),
Toast.LENGTH_SHORT).show();
}
});
发送消息
ModularEventBus
.get()
.of(EventsDefineOfModuleBEvents.class)
.EVENT1()
.setValue(new TestEventBean(“aa”));
订阅和发送的模式
订阅消息的模式

observe:生命周期感知,onDestroy 的时候自动取消订阅。

observeSticky:生命周期感知,onDestroy 的时候自动取消订阅,Sticky 模式。

observeForever:需要手动取消订阅。

observeStickyForever:需要手动取消订阅,Sticky 模式。

发送消息的模式

setValue:主线程调用。

postValue:后台线程调用。

组件化总结
本文介绍了美团行业收银研发组 Android 团队的组件化实践,以及业界首创强约束组件消息总线 modular-event 的原理和使用。我们团队很早之前就在探索组件化改造,前期有些方案在落地的时候遇到很多困难。我们也研究了很多开源的组件化方案,以及公司内部其他团队(美团 App、美团外卖、美团收银等)的组件化方案,学习和借鉴了很多优秀的设计思想,当然也踩过不少的坑。我们逐渐意识到:任何一种组件化方案都有其适用场景,我们的组件化架构选择,应该更加面向业务,而不仅仅是面向技术本身。
后期工作展望
我们的组件化改造工作远远没有结束,未来可能会在以下几个方向继续进行深入的研究:

组件管理:组件化改造之后,每个组件是个独立的工程,组件也会迭代开发,如何对这些组件进行版本化管理。

组件重用:现在看起来对这些组件的重用是很方便的,只需要引入组件的库即可,但是如果一个新的项目到来,需求有些变化,我们应该怎样最大限度的重用这些组件。

CI 集成:如何更好的与 CI 集成。

集成到脚手架:集成到脚手架,让新的项目从一开始就以组件化的模式进行开发。

参考资料

Android 消息总线的演进之路:用 LiveDataBus 替代 RxBus、EventBus
WMRouter:美团外卖 Android 开源路由框架
美团外卖 Android 平台化架构演进实践

作者简介
海亮,美团高级工程师,2017 年加入美团,目前主要负责美团轻收银、美团收银零售版等 App 的相关业务及模块开发工作。
招聘
美团餐饮生态诚招 Android 高级 / 资深工程师和技术专家,Base 北京、成都,欢迎有兴趣的同学投递简历到 chenyuxiang@meituan.com。

正文完
 0