关于模块化(组件化)这个问题,我想每个开发者可能都认真的思考过。随着项目的开发,业务不断壮大,业务模块越来越多,各个模块间相互引用,耦合越来越严重,同时有些项目(比如我们公司)还伴随着子应用单独包装推广,影子应用单独发布等等需求,重新调整架构迫在眉睫。今天,我们就来聊聊模块化(组件化),这篇文章同时也是我这几年,对项目架构的理解。

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

最初的超小型项目

当我们最开始做Android项目的时候,大多数人都是没考虑项目架构的,我们先上一张图。
2012年开发的一个小项目

这个分包结构有没有很熟悉,各种组件都码在一个包里,完全没有层级结构,业务、界面、
逻辑都耦合在一起
。这是我12年底刚开始入门Android的时候开发的一个小项目,半年后,来了个小伙伴,然后我们一起开发,然后天天因为谁修改了谁的代码打的不可开交

架构改进,小型项目

再后来开发App,人员比之前多了,所以不能按照以前那样了,必须得重构。于是我把公用的代码提取出来制作成SDK基础库,把单独的功能封装成Library包,不同业务通过分包结构分到不同module下,组内每人开发自己的module。刚开始都还轻松加愉快,并行开发啥的,一片融洽的场景,如下图。

刚刚重构之后的架构

随着时间推移,我们的App迭代了几个版本,这几个版本也没什么别的,大体来讲就是三件事情:

  • 扩展了一些新业务模块,同时模块间相互调用也增加了。
  • 修改增加了一些新的库文件,来支持新的业务模块。
  • 对Common SDK进行了扩展、修复。

很惭愧,就做了一些微小的工作,但是架构就变成下图这样。

做了几件微小的工作之后

可以看到,随着几个版本业务的增加,各个业务某块之间耦合愈发严重,导致代码很难维护,更新,更别说写测试代码了。虽然后期引入统一广播系统,一定程度改善了模块间相互引用的问题,但是局限性和耦合性还是很高,没办法根治这个问题。这个架构做到最后,扩展性和可维护性都是很差,并且难以测试,所以最终被历史的进程所抛弃。

中小型项目,路由架构

时间很快就来到了2015年,这一年动态加载、热修复很火,360、阿里等大公司先后开源了自己的解决方案,如droidplugin、andfix等。在研究了一圈发现,这些技术对架构升级有一定的帮助,尤其是droidplugin的加载apk的思想,能很好地解决耦合度高、方法数超过65535、动态修复bug等问题,不过由于项目本身不是很大,并且没有专门的人来维护架构,所以最后放弃了功能强大、但是问题也同样多的插件化,退而求其次,选择了利用路由机制来实现组件化解耦。

关于路由机制,熟悉iOS开发的朋友可能并不陌生,在iOS上有很多架构方案都是采用路由机制来时间模块之间的解耦的,比如VIPER(View Interactor Presenter Entity Routing)思想等等。其实思路都是相同的,Android上面组件化也是通过公用的路由,来实现模块与模块之间的隔离。

实现原理

我们先来看下路由架构图。

路由架构

大图点我

通过上图可以看到,我们在最基础的Common库中,创建了一个路由Router,中间有n个模块Module,这个Module实际上就是Android Studio中的module,这些Module都是Android Library Module,最上面的Module Main是可运行的Android Application Module

这几个Module都引用了Common库,同时Main Module还引用了A、B、N这几个Module,经过这样的处理之后,所有的Module之间的相互调用就都消失了,耦合性降低,所有的通信统一都交给Router来处理分发,而注册工作则交由Main Module去进行初始化。这个架构思想其实和Binder的思想很类似,采用C/S模式,模块之间隔离,数据通过共享区域进行传递。模块与模块之间只暴露对外开放的Action,所以也具备面向接口编程思想

图中的红色矩形代表的是行动ActionAction是具体的执行类,其内部的invoke方法是具体执行的代码逻辑。如果涉及到并发操作的话,可以在invoke方法内加入锁,或者直接在invoke方法上加上synchronized描述

图中的黄色矩形代表的是供应商Provider,每个Provider中包含1个或多个Action,其内部的数据结构以HashMap来存储Action。首先HashMap查询的时间复杂度是O(1),符合我们对调用速度上的要求,其次,由于我们是统一进行注册,所以在写入时并不存在并发线程并发问题,在读取时,并发问题则交由Action的invoke去具体处理。在每一个Module内都会有1个或多个供应商Provider(如果不包含Provider,那么这个Module将无法为其他Module提供服务)。

途中蓝色矩形代表的是路由Router,每个Router中包含多个Provider,其内部的数据结构也是以HashMap来存储Provider,原理也和Provider是一样的。之所以用了两次HashMap,有两点原因,一个是因为这样做,不容易导致Action的重名,另一个是因为在注册的时候,只注册Provider减少注册代码,更易读。并且由于HashMap的查询时间复杂度是O(1),所以两次查找不会浪费太多时间。当查找不到对应Action的时候,Router会生成一个ErrorAction,会告之调用者没有找到对应的Action,由调用者来决定接下来如何处理。

一次请求流程

通过Router调用的具体流程是这样的:

Router时序图

  1. 任意代码创建一个RouterRequest,包含ProviderAction信息,向Router进行请求。
  2. Router接到请求,通过RouterRequestProvider信息,在内部的HashMap中查找对应的Provider
  3. Provider接到请求,在内部的HashMap中查找到对应的Action信息。
  4. Action调用invoke方法。
  5. 返回invoke方法生成的ActionResult
  6. Result封装成RouterResponse,返回给调用者。

耦合降低

所有的Module之间的相互依赖没有了,我们可以在主app中,取消任意的Module引用而不影响整体App的编译及运行。

取消对Module N的依赖

如图所示,我们取消了对Module N的依赖,整体应用依然可以稳定运行,遇到调用Module N的地方,会返回Not Found提示,实际开发中可以根据需求做具体的处理。

可测试性增强

由于每个Module并不依赖其他的Module,所以在开发过程中,我们只针对自己的模块进行开发,并可以建一个测试App来进行白盒测试。

测试Module A

复用性增强

关于复用性这块。作者所处的行业是招商投资这块,这个行业需要围绕主业务开发很多影子APP,将覆盖面扩大(有点类似58->58租房、58招聘,美团->美团外卖等)。这个时候,这个架构的复用性就体现出来了,我们可以把业务进行拆分,然后写一个包装App,就可以生成一个独立的影子APP,这个影子APP用到哪些Module就引用哪些就可以了,开发迅速,并且后期Module业务有变化,也不用更改所有的代码,减少了代码的复制。比如我们就曾经把IM模块和投资咨询模块单独拿出来,写了一些界面和样式,就生成了“招商经纪人”App。

支持并行开发

整套架构很类似Git的Branch思想,基于主线,分支单独开发,最后再回归主线这种思路。这里只是思路和branch相似,实际的开发过程中,我们每个module可以是一个branch,也可以是一个仓库。每个模块都需要自己有单独的版本控制,便于问题管理及溯源。主项目对各个模块的引用可以是直接引用,也可以是导出aar引用,或者是上传JCenter Maven等等方式。不过思路是统一的:继承公共->独立开发->主线合并。

基础库

2017.2.20新增

最近有朋友在评论里问公共的类还有共有资源怎么处理,其实非常简单,我们在Router和Module之间再加一层,加一层CommonBaseLibrary,里面放一些所有项目都会用到的资源文件,Model类,工具类等等,然后CommonBaseLibrary再引入Router即可。

如下图

引入基础库

大图点我

需要注意的是,我们的Module A,不需要CommonBaseLibrary中的公共资源,所以没有引用CommonBaseLibrary,但是实际其他还是可以被其他模块所调用,因为它内部有Router。

多进程思考,中型项目

随着项目的不断扩大,App在运行时的内存消耗也在不断增加,而且有时线上的BUG也会导致整体崩溃。为了保证良好的用户体验,减少对系统资源的消耗,我们开始考虑采取多进程重新架构程序,通过按需加载,及时释放,达到优化的目的。

多进程优势

多进程的优点和使用场景,之前在《Android多进程使用场景》中也做过介绍,大体优点有这么几个:

  • 提高各个进程的稳定性,单一进程崩溃后不影响整个程序。
  • 对于内存的时候更可控,可以通过手工释放进程,达到内存优化目的。
  • 基于独立的JVM,各个模块可以充分解耦。
  • 只保留daemon进程的情况下,会使应用存活时间更长,不容易被回收掉。

潜在问题

但是启用多进程,那就意味着Router系统的失效。Router是JVM级别的单例模式,并不支持跨进程访问。也就是说,你的后台进程的所有ProviderAction,是注册给后台Router的。当你在前台进程调用的时候,根本调用不到其他进程的Action

解决方案

其实解决的方法也并不复杂。原来的路由系统还可以继续使用,我们可以把整套架构想象成互联网,现在多个进程有多个路由,我们只需要把多个路由连接到一起,那么整个路由系统还是可以正常运行的。所以我们把原有的路由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绑定到一起,这样,就达到了所有路由都是双向互连的目的。

事件分发

之前单一路由的事件分发是通过两层HashMap查找ProviderAction,进行事件下发。那么现在在外面加了一层WideRouter,那么我们再加一层DomainDomain对应的是Android应用内,各个进程的进程名。通常情况下,如果事件是在同一进程下,那么就类似于局域网内部事件传递,不需要通过WideRouter,直接内部按照之前的路由逻辑进行转发,如果不在相同进程内,就由WideRouter进行进程间通信,达到跨进程调用的效果。

事件请求RouterRequest可以写成两种,一种是URL,一种JSON。(内部处理的时候统一使用JSON),同时也提供了对URL和JSON的解析方法,方便使用。

URL:xxxDomain/xxxProvider/xxxAction?data1=xxx&data2=xxx
这就和Http请求很像了。这样做的好处就是对后续WebView上可以非常便利得直接调用本地Action

JSON:

1
2
3
4
5
6
7
8
9
{
domain: xxx,
provider: xxx,
action: xxx,
data{
data1: xxx,
data2: xxx
}
}

JSON方式简单明了,可作为接口返回值由服务器下发给客户端。

下面仔细讲一下一次跨进程请求,事件是如何传递的:

事件传递图

点击大图

从图中可以清晰地看出,我们主要是分两大部分去完成事件分发传递的。

  • 第一部分,跨进程判断目标Action是否是异步程序。
  • 第二部分,跨进程执行目标Action调用。

首先我们先通过DomainProviderAction去跨进程查找是否是异步程序。如果是异步程序,那么我们直接生成RouterResponse(Step13),并且,将Step14-Step24统一封装成Future,放在RouterResponse中,直接返回。如果是同步程序,那么就在当前方法内执行Step14-Step24,将返回结果放入RouterResponse内(Step25),直接返回。这么做的目的是,我们的路由调用方法route(RouterRequest)默认是同步方法,不耗时的,可以直接在主线程里调用而不造成阻塞,不造成ANR。如果调用的目标Action是异步的,那么可以利用Java的FutureTask原理,调用RouterResponseget()方法,获取结果。这个get()方法有可能是耗时的,是否耗时,取决于RouterResponse.isAsync的值是否是true

至于本地事件分发,还是与之前的Router模式,从Step17到Step21,都是我们上文中,单进程同步Router分发机制,没有作任何改变。

多进程Application逻辑分发

在多进程中,每启动一个新的进程,都会重新创建一次Application,所以,我们需要把各个进程的Application逻辑剥离出来,然后根据不同的Process Name,选择不同的Application逻辑进行处理。

实际的Application启动流程如下:

多进程Application启动流程

首先,我们先把所有ApplicationLogic注册到Application中,然后,Application会根据注册时的进程名信息进行筛选,选择相同进程名的ApplicationLogic,保存到本进程中,然后,对这些本进程的ApplicationLogic进行实例化,最后,调用ApplicationLogiconCreate方法,实现ApplicationLogicApplication生命周期同步,同时还有onTerminateonLowMemoryonTrimMemoryonConfigurationChanged等方法,与onCreate一致。

结束进程,释放内存

在我们不使用某些进程的时候,比如听音乐的时候,可以把主界面关掉等等。我们可以调用对应进程的LocalRouterstopSelf()方法,该方法可以使本进程与WideRouter进行解绑,然后我们在手动关掉进程内的其他组件,最后调用System.exit(),达到释放内存的目的。合理的释放内存,能有效的改善用户体验。

小结

这篇文章大概讲了一下作者这几年对Android架构的理解。其实本文中没有什么很深的技术点,大多是一些设计模式,架构思想。这套框比起大公司的一些优秀的动态更新、编译分包、apk插件化加载,还是简单很多的,更适合中小型应用。

这套框架目前还有比较多可以改进的地方,目前正在整理的:

  • 增加对Action的动态关闭功能。
  • 通过Instant Run原理,实现Action的热更新。
  • 增加Message Pool,实现RequestResponse的循环利用,减少GC触发。
    已解决《高并发对象池思考》
  • 优化Message在传递过程中的打包,拆包的速度,提升整体性能。
  • etc.

本文项目地址:ModularizationArchitecture,欢迎大家star、fork、提建议。

或者直接在项目中引入:

1
compile 'com.spinytech.ma:macore:0.2.0'

相关教程:ModularizationArchitecture 使用教程

如果有疑问或者建议,欢迎联系我,邮箱是:spiny.tech@gmail.com
谢谢大家阅读


原创文章,转载请先联系作者:spiny.tech@gmail.com