反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现

反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。

背景

诸如EventBus\RxBus\LiveDataBus的事件总线库在业内正遭滥用。

诚然,事件总线看起来 小而美 ,但随着业务复杂度上升,事件的发送和订阅到处分布,这个优势反而成为了负担,因此,笔者不建议在任何量级的项目中使用事件总线库。更多原因读者可参考 这篇文章 。

更合理的方案是什么呢?在量级较小的项目中,开发者应该通过 依赖注入Callback进行不同层级的依次传递,以保证 层级间的依赖关系足够清晰

而对于体量逐渐增大的项目而言,项目的模块化、组件化、插件化改造被提上日程,各团队负责不同的业务线,将业务分割成组件,并基于组件本身进行开发,于是我们有了新的诉求,即 组件与组件保证是隔离的,同层级的组件间不应该持有其它组件中类的引用。

需要注意的是,即使项目组件化,组件间也仍有通信的场景,但这并非使用事件总线的借口——对大体量的项目而言,EventBus\RxBus\LiveDataBus这种事件总线库太局限了,其能力已完全满足不了项目架构的需求,因此,一个适用于组件化开发的 通信组件 的需求迫在眉睫。

本文将对组件化开发流程中 通信组件 的设计理念与实现方式进行完整的叙述。这里的 通信组件 并非特指某个已有的工具库(比如ARouterWMRouter等),事实上,它们都是组件化开发流程的实践之一。

本文结构如下:

反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现_第1张图片

一、组件间通信的基本实现

1、Android原生通信机制

对于组件间通信,最经典的场景当属页面跳转,对于Android而言,Activity之间相互隔离,原生API对页面跳转提供了两种实现方式:

第一种方式是常用的 显式意图,通过 startActivitystartActivityForResult,这种方案简单且实用,但在组件化开发流程中,组件间未持有其它组件中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架构组件,很多开发者依然对这种单ActivityFragment的开发模式不买账——平白无故增加项目复杂度毫无意义

无论如何,一个简单的计算器app也无必要引入复杂的工程架构,以及组件/插件化的开发流程。

因此,与其热火朝天讨论某个新框架流行与否,读者更想看到它到底是解决了什么问题。

那就是业务的 爆炸性增长

随着微信、支付宝等一众大型和中型应用规模逐渐扩大,即使是原生的跳转机制也无法满足组件化开发的需求,比如,首页的若干个Tab对应的不同Fragment身处不同组件,这时Fragment之间的通信该如何保障?

同时,随着业务粒度的愈发细分,甚至单个Fragment中的View都来自五湖四海(比如商品详情页面, 视频预览商品评论 的控件分别由不同业务组件提供); 更深入思索一下,若商品介绍一栏是由WebView提供的——涉及到H5和原生的交互,我们又该如何定义H5与原生间通信的接口?

由此可见,Activity自身的通信机制确实已经不够用了。

3、组件间通信的基本实现

对于多元化的通信需求而言,首先最重要的是将通信协议进行统一,无论是Activity间跳转,还是FragmentView之间的通信,亦或是H5与原生的交互,我们都通过类似httpurl的形式定义:

// 跳转 用户模块 - 登录页面
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的设计,将网络请求的功能 收敛,并将 反序列化返回类型网络请求扩展 等相关功能通过ConverterAdapterInterceptor的方式抽象出来,交给开发者选择性依赖后,再自行组装,Retrofit自身则绝不多干涉一分一厘。

同样,作为 页面路由框架ARouter目前的设计已满足现有需要。对于进程间通信,ARouter可以在IProvider的实现中,通过声明AIDL进行通信,最终将结果交还给ARouter去分发。这也正符合了其开源时所提倡的口号:简单够用

现在我们知道,对于业务量级不大,尚以 页面跳转 为主要通信手段的应用而言,ARouter这类 页面路由框架 已足够使用;但是,对于更为复杂的项目而言,组件间 数据获取 更加频繁,作为设计者,如何保证灵活性的同时,提供更便捷数据通信的可能呢?

二、更高维度的支持

从更高维度的视角来看,无论是UI层级的 页面跳转,还是业务层级的 数据获取,都可将其抽象为一种 通信:

反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现_第2张图片

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登录,我们都将通信的结果,抽象为一个回调函数,将具体的实现内置在通信库中,其它组件的开发者无需关注实现的细节:

反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现_第3张图片

对于UI通信而言,如何实现成这样的API? 举例来说,我们可以将ActivityonActivityResult()委托给一个不可见的Fragment处理,感兴趣的读者可参考Glide或者ViewModel的源码。

3、多进程的支持

本小节部分内容节选自 @Spiny 的 这篇文章。

目前,因为本身是JVM级别的单例模式,因此我们Router并不支持跨进程通信。

上文我们也同样提到了,想进行跨进程通信也很简单,只需要在接收到需要跨进程通信的url时,自己实现跨进程的调用即可。

既然现在我们的Router已经脱离了类似ARouter这种 页面路由框架 的范畴,将UI和业务都在更高维度进行了抽象,那么,能否提供针对Router本身提供更强大的支持呢,比如跨进程通信?

其实解决的方法也并不复杂。原来的路由系统还可以继续使用,我们可以把整套架构想象成互联网,现在多个进程有多个Router,我们只需要把多个Router连接到一起,那么整个路由系统还是可以正常运行的。所以我们把原有的Router称之为本地路由LocalRouter

现在,我们需要提供一个IPS、DNS供应商,那就创建一个进程,该进程的作用就是注册路由,链接路由,转发报文,我们称之为广域路由WideRouter

我们先来看下路由连接架构图:

反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现_第4张图片

如图所示,竖直方向上,每一列,代表一个进程,通过虚线隔开,分别有 Process WideRouterProcess MainProcess A、···、Process N 这些进程。浅黄色的代表 WideRouter,深黄色 的代表 WideRouter 的守护 Service。浅蓝色 的代表每个进程的 LocalRouter,深蓝色 的代表每个 LocalRouter 的守护 Service

WideRouter 通过 AIDL 与每个进程 LocalRouter 的守护 Service 绑定到一起,每个 LocalRouter 也是通过 AIDLWideRouter 的守护 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的读者不要错过。

2、Android架构思考:模块化、多进程 @Spiny

相比较ARouter, ModularizationArchitecture 这个通信库及其作者似乎更低调,但从文章中可以得知,作者本人对组件化的理解非常深入,尤其是将进程间通信机制的实现,比喻为互联网,非常易于理解,因此直接将部分原文放在了 多进程的支持 一节,再次感谢!

3、Android组件化之(路由 vs 组件总线) @luckybilly

这篇文章是CC的作者的原创文章,针对 路由组件总线 进行了深入的对比,非常深入,推荐。

4、多个维度对比一些有代表性的开源android组件化开发方案 @luckybilly

5、一种更高效的组件自动注册方案(android组件化开发)

luckybilly的另两篇好文,前者针对市面上一众主流的通信库进行了不同角度的对比,后者针对组件自动注册的不同实现进行了深入的对比,强烈推荐!

6、WMRouter:美团外卖Android开源路由框架

美团开源的WMRouter介绍文章,有兴趣的读者可作为引申阅读。


关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。

如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?

  • 我的Android学习体系
  • 关于文章纠错
  • 关于知识付费
  • 关于《反思》系列

你可能感兴趣的:(反思 | 事件总线的局限性,组件化开发流程中通信机制的设计与实现)