反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现
Posted 却把清梅嗅
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现相关的知识,希望对你有一定的参考价值。
反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。
背景
诸如EventBus\\RxBus\\LiveDataBus
的事件总线库在业内正遭滥用。
诚然,事件总线看起来 小而美 ,但随着业务复杂度上升,事件的发送和订阅到处分布,这个优势反而成为了负担,因此,笔者不建议在任何量级的项目中使用事件总线库。更多原因读者可参考 这篇文章 。
更合理的方案是什么呢?在量级较小的项目中,开发者应该通过 依赖注入 将Callback
进行不同层级的依次传递,以保证 层级间的依赖关系足够清晰。
而对于体量逐渐增大的项目而言,项目的模块化、组件化、插件化改造被提上日程,各团队负责不同的业务线,将业务分割成组件,并基于组件本身进行开发,于是我们有了新的诉求,即 组件与组件保证是隔离的,同层级的组件间不应该持有其它组件中类的引用。
需要注意的是,即使项目组件化,组件间也仍有通信的场景,但这并非使用事件总线的借口——对大体量的项目而言,EventBus\\RxBus\\LiveDataBus
这种事件总线库太局限了,其能力已完全满足不了项目架构的需求,因此,一个适用于组件化开发的 通信组件 的需求迫在眉睫。
本文将对组件化开发流程中 通信组件 的设计理念与实现方式进行完整的叙述。这里的 通信组件 并非特指某个已有的工具库(比如ARouter
、WMRouter
等),事实上,它们都是组件化开发流程的实践之一。
本文结构如下:
一、组件间通信的基本实现
1、android原生通信机制
对于组件间通信,最经典的场景当属页面跳转,对于Android
而言,Activity
之间相互隔离,原生API
对页面跳转提供了两种实现方式:
第一种方式是常用的 显式意图,通过 startActivity
或 startActivityForResult
,这种方案简单且实用,但在组件化开发流程中,组件间未持有其它组件中Activity.class
的引用,因此无法支持组件间的跳转。
第二种方式则是相对冷僻的 隐式意图,这种方式支持组件间以及跨进程通信,比如,开发者可以通过隐式意图唤起系统的呼叫页面:
// 唤起拨号页面
private void call()
Intent intent = new Intent();
intent.setAction(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:" + 119));
startActivity(intent);
由于代码中不存在类依赖的关系,隐式意图更适合组件间通信,但其缺陷也很明显:
- 1.隐式意图需将
Activity
对应的配置规则和参数以action
等标签的形式,集中声明在Manifest
中,不利于参数的管理,且扩展性不佳,进而导致团队协作困难; - 2.开发者对路由控制能力不强,由于整个路由跳转行为都由系统控制,因此,当路由出现异常时,无法进行自定义补救,比如跳转一个错误页面(类似H5的404)。
现在看来,原生API
对组件间页面跳转能力的提供,确实还略有不足,但这依然不是真正的问题所在。
能真正引爆这些定时炸弹的,只有 业务需求 本身。
2、导火索
即使Google
推出了Navigation
架构组件,很多开发者依然对这种单Activity
多Fragment
的开发模式不买账——平白无故增加项目复杂度毫无意义。
无论如何,一个简单的计算器app
也无必要引入复杂的工程架构,以及组件/插件化的开发流程。
因此,与其热火朝天讨论某个新框架流行与否,读者更想看到它到底是解决了什么问题。
那就是业务的 爆炸性增长。
随着微信、支付宝等一众大型和中型应用规模逐渐扩大,即使是原生的跳转机制也无法满足组件化开发的需求,比如,首页的若干个Tab
对应的不同Fragment
身处不同组件,这时Fragment
之间的通信该如何保障?
同时,随着业务粒度的愈发细分,甚至单个Fragment
中的View
都来自五湖四海(比如商品详情页面, 视频预览 和 商品评论 的控件分别由不同业务组件提供); 更深入思索一下,若商品介绍一栏是由WebView
提供的——涉及到H5
和原生的交互,我们又该如何定义H5
与原生间通信的接口?
由此可见,Activity
自身的通信机制确实已经不够用了。
3、组件间通信的基本实现
对于多元化的通信需求而言,首先最重要的是将通信协议进行统一,无论是Activity
间跳转,还是Fragment
、View
之间的通信,亦或是H5
与原生的交互,我们都通过类似http
的url
的形式定义:
// 跳转 用户模块 - 登录页面
String loginUrl = "route://com.example.route/user/activity/login"
// 跳转 用户模块 - 注册页面
String registUrl = "route://com.example.route/user/activity/register"
// 跳转 商品模块 - 详情页面, id为商品的id
String detailUrl = "route://com.example.route/buy/activity/detail?id=xxxxx"
定义好了之后,对于组件间页面跳转,可以如下操作:
Router.route(detailUrl); // 在用户模块,发起商品模块中页面的跳转
应用接收到这样自定义、且支持携带参数的url
,通信库内部解析后统一分发,进行对应页面的跳转,这样我们就实现了最基础的通信功能。
4、降级策略与拦截器机制
接下来我们针对 隐式意图 对 错误处理能力不足 这点进行深入性讨论。
在组件化开发流程中,开发者通常在当前的组件的Demo
上进行开发,虽然模块自身是可运行的,但是当涉及到其它组件的通信,问题随之而来。
和完整的工程相比,Demo
上未持有其它组件中Activity
的声明,直接通过 隐式意图 发起通信会导致系统抛出异常。
那么,我们希望当通信发生错误时,可以针对不同的环境提供不同的降级策略,以保证开发者和用户的体验,比如:
- 在
Demo
工程的开发流程中,当尝试跳转其它组件时,获得一个「该url
不在当前组件工程中」的提示; - 在集成了所有组件的主工程中,在遇到不合法的
url
时,则为用户跳转一个通用的404
页面。
如有可能,通信库在路由的过程中,能提供限速、屏蔽等 灵活 且 简单 控制的可能性,那么就更好了。
因此,以ARouter
为首的绝大多数组件通信库都提供了这种能力,实现方式也使用了非常经典的 拦截器 机制,通过 递归 将通信事件向下分发,在需要处理的层级中进行拦截处理。
5、泼冷水时间?
本小节笔者将以ARouter
为例,阐述页面间路由库的一些局限性,以及导致这些局限性的原因。
毫无疑问,ARouter
提供了足够强大的页面间路由跳转能力,它也确实揽括了业内绝大多数开发者的青睐,在开源之初,作者对其的定义就是Android
平台上的 页面路由框架 。
这也变相导致自身对UI
层级的跳转能力很强,但对数据通信的支持很薄弱。
什么是对数据通信的支持呢?读者知道,除了可见的UI
交互,数据的交互也非常频繁,比如通过组件间通信,向用户组件获取当前用户信息、向订单组件获取某个订单数据等等。
ARouter
并不支持这些吗?实际上并非如此,ARouter
自身提供了IProvider
接口实现组件间服务的管理,并提供服务的自动注册和依赖注入。
但遗憾的是,由于ARouter
自身设计原因,其初始化只针对当前进程,这也导致了其路由表的自动注册和拦截器相关机制都是单进程的。
而在目前国内多进程、插件化的多元发展环境下,若想向其它进程的服务直接获取数据,ARouter
是无能为力的,需要开发者通过AIDL
等方式来自己实现。
6、洗白
那么导致这些局限性的原因,是因为ARouter
这类页面路由库自身设计的不足吗,并非完全如此,从技术角度而言,为ARouter
添加进程间通信的支持是可行的。
大而全的框架往往也是掺杂了各种私货的大杂烩,看似 功能强大 ,实则 臃肿不堪 —— 笔者更喜欢类似Retrofit
的设计,将网络请求的功能 收敛,并将 反序列化、返回类型、网络请求扩展 等相关功能通过Converter
、Adapter
、Interceptor
的方式抽象出来,交给开发者选择性依赖后,再自行组装,Retrofit
自身则绝不多干涉一分一厘。
同样,作为 页面路由框架 ,ARouter
目前的设计已满足现有需要。对于进程间通信,ARouter
可以在IProvider
的实现中,通过声明AIDL
进行通信,最终将结果交还给ARouter
去分发。这也正符合了其开源时所提倡的口号:简单 且 够用。
现在我们知道,对于业务量级不大,尚以 页面跳转 为主要通信手段的应用而言,ARouter
这类 页面路由框架 已足够使用;但是,对于更为复杂的项目而言,组件间 数据获取 更加频繁,作为设计者,如何保证灵活性的同时,提供更便捷数据通信的可能呢?
二、更高维度的支持
从更高维度的视角来看,无论是UI
层级的 页面跳转,还是业务层级的 数据获取,都可将其抽象为一种 通信:
1、通信和通信结果的定义
对此,我们可以对通信协议进行如下的定义:
// 跳转 用户模块 - 登录页面
String loginUrl = "route://com.example.route/user/activity/login"
// 获取 用户模块 - 用户数据
String getUserName = "route://com.example.route/user/service/getUserName"
我们可以像http
请求一样,对页面跳转通信的结果进行如下结构的定义:
// 跳转页面的返回值
"code" : "0000", // 跳转失败,可以定义一个错误码,比如 "4000"
"msg" : "success",
"data" : null
而对于数据获取的定义,则可以充分利用data
字段:
// 获取用户信息
"code" : "0000",
"msg" : "success",
"data" :
"userName": "James Moriarty",
"token": "xxxxxxx"
这样,无论是哪种通信,我们都将通信的结果抽象为了Result
,并在代码中进行对应的处理:
class Result
@NonNull String code;
@NonNull String msg;
@Nullable Object data;
// 根据不同种类的通信行为,分别处理result
Result result = Router.route(url); // url可以是跳转页面,也可以是获取数据
现在我们提供了基本的 UI通信 和 数据通信 的支持,并将Result
返回,但是目前的实现还是无法满足所有的场景——服务间的通信并非都是同步的。
2、异步通信的支持
对于异步的通信,我们通常理解为 网络请求 ,实际上,网络请求只是 数据异步通信 的一部分,除此之外还有 数据库操作 、 文件的读写 等等。
难道只有 数据通信 才有异步的场景吗?当然不是, UI通信 中的异步场景同样非常多,最简单的例子就是startActivityForResult
,我们希望将登录的行为交给通信库,通信库异步跳转登录页面,登录成功后,返回如下定义的登录结果:
"code" : "0000", // 登录成功,也可对登录失败、取消定义不同code
"msg" : "success",
"data" : // 返回用户登录信息
"userName": "James Moriarty",
在我们的组件中,就可以针对异步行为进行如下通信:
Callback<Result> callback = Router.routeAsync(loginUrl);
// 执行异步通信
callback.excute(result ->
// 登录页面登录结果(或网络请求结果)返回后,进行处理
);
这样,无论是网络请求,还是异步UI登录,我们都将通信的结果,抽象为一个回调函数,将具体的实现内置在通信库中,其它组件的开发者无需关注实现的细节:
对于UI通信而言,如何实现成这样的API? 举例来说,我们可以将
Activity
的onActivityResult()
委托给一个不可见的Fragment
处理,感兴趣的读者可参考Glide
或者ViewModel
的源码。
3、多进程的支持
本小节部分内容节选自 @Spiny 的 这篇文章。
目前,因为本身是JVM
级别的单例模式,因此我们Router
并不支持跨进程通信。
上文我们也同样提到了,想进行跨进程通信也很简单,只需要在接收到需要跨进程通信的url
时,自己实现跨进程的调用即可。
既然现在我们的Router
已经脱离了类似ARouter
这种 页面路由框架 的范畴,将UI
和业务都在更高维度进行了抽象,那么,能否提供针对Router
本身提供更强大的支持呢,比如跨进程通信?
其实解决的方法也并不复杂。原来的路由系统还可以继续使用,我们可以把整套架构想象成互联网,现在多个进程有多个Router
,我们只需要把多个Router
连接到一起,那么整个路由系统还是可以正常运行的。所以我们把原有的Router
称之为本地路由LocalRouter
。
现在,我们需要提供一个IPS、DNS
供应商,那就创建一个进程,该进程的作用就是注册路由,链接路由,转发报文,我们称之为广域路由WideRouter
。
我们先来看下路由连接架构图:
如图所示,竖直方向上,每一列,代表一个进程,通过虚线隔开,分别有 Process WideRouter
、Process Main
、Process A
、···、Process N
这些进程。浅黄色的代表 WideRouter
,深黄色 的代表 WideRouter
的守护 Service
。浅蓝色 的代表每个进程的 LocalRouter
,深蓝色 的代表每个 LocalRouter
的守护 Service
。
WideRouter
通过 AIDL
与每个进程 LocalRouter
的守护 Service
绑定到一起,每个 LocalRouter
也是通过 AIDL
与 WideRouter
的守护 Service
绑定到一起,这样,就达到了所有路由都是双向互连的目的。
除了
AIDL
之外,市场上的通信库还有各种各样跨进程通信的实现方案,例如BroadcastReceiver、Socket、ContentProvider、Binder
等等,有兴趣的读者可以查看文末的参考链接,分别对比它们不同的实现方式。
三、更多元化的设计
目前,我们已经完成了组件间通信机制核心功能的实现。接下来我们针对其它部分的功能,针对不同开源框架中的不同实现方式,进行简单的讨论。
1、组件的自动注册
不同的组件各自向外暴露不同的功能,我们需要将url
和对应的逻辑进行绑定,以保证Router
能够在接收到对应通信的url
时,作出对应的响应,这个流程我们称之为组件的注册。
举例来说,在完整的项目工程中,我们对所有组件的url
进行注册;而在组件自身的demo
中,我们对demo
自身所需要的组件进行注册。
那么,对于高度组件化的项目而言,组件的粒度切分的非常细,这时在代码中手动对组件一一注册成为了一个苦力活,因此,是否有必要设计一个技术方案,保证在应用启动时,通信库能够对应用依赖的所有组件进行自动注册呢?
1.1 不实现自动注册的理由
首先我们先讨论,通信库不实现自动注册的理由。
不提供自动注册是一种偷懒吗?笔者认为不完全是,手动注册的好处在于,首先,开发者对注册的组件总是已知的——这最简单且直接地提供了组件动态化可插拔的能力,且不易出错。
其次,手动注册的方式,能够更灵活对应用的启动性能优化进行保障,并非所有组件都需要在应用启动时进行立即注册,当组件很多时,组件的注册成本是否会影响App
启动的速度?这些问题都是需要去考量的。
1.2 APT实现自动注册
而对于自动注册,最大的问题在于如何找到所有组件中url
的映射关系,然后对其自动注册处理,而如果在运行期处理则有可能会大量地运用 反射,因此这种方案并非首选。
对此,以ARouter
为代表的通信库使用到了 注解处理器(AnnotationProcessor
),通过在编译期对项目进行扫描处理,解析注解,找出所有组件中对应的映射关系,然后存入并生成对应的映射文件类;在运行时,对这些组件的映射文件类进行一一注册,从而完成整个项目的自动注册。
表面来看,注解处理器 已经满足了我们的需求,实际上还有一个隐藏的问题,那就是编译时注解的特性只在源码编译时生效,并不能针对aar
文件中的注解进行扫描,因此,我们还需要保证APP
在启动时能找到所有的映射文件类,否则注册根本无从谈起。
ARouter
曾经的实现方案是第一次启动对所有dex
文件进行读取,遍历每个entry
查找指定包内的所有类名,然后反射获取指定的类对象,统一进行注册,虽然初次效率并不是非常高,但最后会进行本地缓存,以保证之后启动注册的效率。
1.3 编译期字节码修改注册
有没有更高效的注册方式呢?
CC 组件通信库提供了另外一种 编译期修改字节码 的实现方案,大致思路是:在编译时,扫描所有类,将符合条件的类收集起来,并通过修改字节码生成注册代码到指定的管理类中,从而实现编译时自动注册的功能,不用再关心项目中有哪些组件类了。不会增加新的class
,不需要反射,运行时直接调用组件的构造方法。
对这种方案感兴趣的读者可以参考这篇文章.
由此可见,即使是组件的注册流程,各个库的维护者都做出了各种各样的实践,而只有明白了每种方案的设计理念,才能对库本身的适用场景有更清晰的认知。
2、依赖注入,从Square到Google?
ARouter
在页面的跳转上提供了一个不同于其它通信库的功能,那就是能够将发起页面跳转时传入的参数,通过依赖注入的方式自动注入到对应的Activity
中。
这篇文章中阐述了该功能是如何实现的,很有趣的是,在该功能最初的实现方案中,是运行期通过反射拿到ActivityThread
实例,最终在Activity
实例化的时候,通过反射把Intent
预先存好的参数值写入到需要自动装配的字段中实现的。
这种方案的缺点很明显,除了反射带来的性能影响外,甚至可能导致用户的代码出现NPE
,因此这种实现方式后来被新的方案所代替。
新的方案依然是我们的老朋友AnnotationProcessor
,在编译期间,其为Activity
生成一个对应的注入辅助类,运行时通过辅助类对Activity
中的字段进行赋值。
这也是Square
最初推出的依赖注入库dagger
,被Google
后来居上的dagger2
代替的原因。
还有另外一个问题,为什么其它通信库没有像ARouter
一样提供这样一个依赖注入的功能呢,是因为做不到吗?
并非如此,在其它通信库中,我们将页面的跳转进行了更高维度的抽象,因此,如果设计一个新的功能,这个功能也更应该是针对 通信 整体的概念而服务,而非部分场景。
小结
本文针对组件化开发流程中核心的 通信机制 进行了系统性的描述。
对于组件化而言,其目的在于在 业务模块间的解耦,而事件总线除了能给开发者带来开发上暂时的便利,以及 貌似解耦 的假象之外,更多埋下了组件间依赖关系 混乱的种子,并非长久之计——更合理的方案是针对性引入适合自身项目、且更全面的组件间通信库。
篇幅所限,很多优秀的开源项目中的功能和设计未能一一阐述,有兴趣的读者可以从下文的链接中进行选择性的参考。
参考 & 感谢
细心的读者能够发现,关于 参考&感谢 一节,笔者着墨越来越多,原因无他,笔者 从不认为 一篇文章就能够讲一个知识体系讲解的面面俱到,本文亦如是。
因此,读者应该有选择性查看其它优质内容的权利,甚至是为其增加一些简洁的介绍(因为标题大多都很相似),而不是文章末尾甩一堆
https
开头的链接不知所云。这也是对这些内容创作者的尊重,如果你喜欢本文,也同样希望你能够喜欢下面这些文章。
1、开源最佳实践:Android平台页面路由框架ARouter @刘志龙
对于ARouter
的创作流程和设计理念,没有比作者本人更有发言权的了,这篇文章从理论到实践都讲解的非常清晰、流畅且自然,对于想要深入学习ARouter
的读者不要错过。
相比较ARouter
, ModularizationArchitecture 这个通信库及其作者似乎更低调,但从文章中可以得知,作者本人对组件化的理解非常深入,尤其是将进程间通信机制的实现,比喻为互联网,非常易于理解,因此直接将部分原文放在了 多进程的支持 一节,再次感谢!
3、Android组件化之(路由 vs 组件总线) @luckybilly
这篇文章是CC的作者的原创文章,针对 路由 和 组件总线 进行了深入的对比,非常深入,推荐。
4、多个维度对比一些有代表性的开源android组件化开发方案 @luckybilly
5、一种更高效的组件自动注册方案(android组件化开发)
luckybilly的另两篇好文,前者针对市面上一众主流的通信库进行了不同角度的对比,后者针对组件自动注册的不同实现进行了深入的对比,强烈推荐!
美团开源的WMRouter介绍文章,有兴趣的读者可作为引申阅读。
关于我
Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。
如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?
以上是关于反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现的主要内容,如果未能解决你的问题,请参考以下文章
反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现