作者简介原创微信公众号郭霖 WeChat ID: guolin_blog
本篇是Spiny的第二篇 投稿,详细地分享了随着项目的发展,不断升级的架构之路。感兴趣的朋友要仔细阅读一下啦。
Spiny的博客地址:
http://blog.spinytech.com
前言
关于模块化(组件化)这个问题,我想每个开发者可能都认真的思考过。随着项目的开发,业务不断壮大,业务模块越来越多,各个模块间相互引用,耦合越来越严重,同时有些项目(比如我们公司)还伴随着子应用单独包装推广,影子应用单独发布等等需求,重新调整架构迫在眉睫。今天,我们就来聊聊模块化(组件化),这篇文章同时也是我这几年,对项目架构的理解。
最初的超小型项目
当我们最开始做Android项目的时候,大多数人都是没考虑项目架构的,我们先上一张图:
这个分包结构有没有很熟悉,各种组件都码在一个包里,完全没有层级结构,业务、界面、逻辑都耦合在一起。这是我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,所以也具备面向接口编程思想。
图中的红色矩形代表的是行动 Action,Action 是具体的执行类,其内部的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,包含 Provider 和 Action 信息,向 Router 进行请求。
2.Router 接到请求,通过 RouterRequest的Provider 信息,在内部的 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 等等方式。不过思路是统一的:继承公共->独立开发->主线合并。
基础库
公共的类还有共有资源怎么处理,其实非常简单,我们在 Router 和 Module 之间再加一层,加一层 CommonBaseLibrary,里面放一些所有项目都会用到的资源文件,Model类,工具类等等,然后 CommonBaseLibrary 再引入 Router 即可。
引入基础库
需要注意的是,我们的 Module A,不需要 CommonBaseLibrary 中的公共资源,所以没有引用 CommonBaseLibrary,但是实际其他还是可以被其他模块所调用,因为它内部有 Router。
多进程思考,中型项目
随着项目的不断扩大,App在运行时的内存消耗也在不断增加,而且有时线上的BUG也会导致整体崩溃。为了保证良好的用户体验,减少对系统资源的消耗,我们开始考虑采取多进程重新架构程序,通过按需加载,及时释放,达到优化的目的。
多进程优势
多进程的优点和使用场景,之前在《Android多进程使用场景》(点击可查看)中也做过介绍,大体优点有这么几个:
提高各个进程的稳定性,单一进程崩溃后不影响整个程序。
对于内存的时候更可控,可以通过手工释放进程,达到内存优化目的。
基于独立的JVM,各个模块可以充分解耦。
只保留daemon进程的情况下,会使应用存活时间更长,不容易被回收掉。
潜在问题
但是启用多进程,那就意味着 Router系统 的失效。Router是JVM级别的单例模式,并不支持跨进程访问。也就是说,你的后台进程的所有 Provider、Action,是注册给后台 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 查找 Provider 和 Action,进行事件下发。那么现在在外面加了一层 WideRouter,那么我们再加一层 Domain,Domain 对应的是Android应用内,各个进程的进程名。
通常情况下,如果事件是在同一进程下,那么就类似于局域网内部事件传递,不需要通过 WideRouter,直接内部按照之前的路由逻辑进行转发,如果不在相同进程内,就由 WideRouter 进行进程间通信,达到跨进程调用的效果。
事件请求 RouterRequest 可以写成两种,一种是 URL,一种 JSON。(内部处理的时候统一使用 JSON),同时也提供了对 URL 和 JSON 的解析方法,方便使用。
URL:xxxDomain/xxxProvider/xxxAction?data1=xxx&data2=xxx
这就和 Http 请求很像了。这样做的好处就是对后续 WebView 上可以非常便利得直接调用本地 Action。
//JSON:
{ domain:xxx, provider:xxx, action:xxx, data { data1:xxx, data2:xxx }}
JSON方式简单明了,可作为接口返回值由服务器下发给客户端。
下面仔细讲一下一次跨进程请求,事件是如何传递的:
事件传递图
从图中可以清晰地看出,我们主要是分两大部分去完成事件分发传递的。
第一部分,跨进程判断目标 Action 是否是异步程序。
第二部分,跨进程执行目标 Action 调用。
首先我们先通过 Domain、Provider、Action 去跨进程查找是否是异步程序。
如果是异步程序,那么我们直接生成 RouterResponse(Step13),并且,将 Step14-Step24 统一封装成 Future,放在 RouterResponse中,直接返回。
如果是同步程序,那么就在当前方法内执行 Step14-Step24,将返回结果放入 RouterResponse内(Step25),直接返回。这么做的目的是,我们的路由调用方法 route(RouterRequest) 默认是同步方法,不耗时的,可以直接在主线程里调用而不造成阻塞,不造成 ANR。
如果调用的目标 Action 是异步的,那么可以利用 Java 的 FutureTask 原理,调用 RouterResponse的get() 方法,获取结果。这个 get()方法 有可能是耗时的,是否耗时,取决于 RouterResponse.isAsync 的值是否是 true。
至于本地事件分发,还是与之前的 Router 模式,从 Step17到Step21,都是我们上文中,单进程同步 Router 分发机制,没有作任何改变。
多进程Application逻辑分发
在多进程中,每启动一个新的进程,都会重新创建一次 Application,所以,我们需要把各个进程的 Application 逻辑剥离出来,然后根据不同的 Process Name,选择不同的 Application 逻辑进行处理。
实际的 Application 启动流程如下:
首先,我们先把所有 ApplicationLogic 注册到 Application 中,然后,Application 会根据注册时的进程名信息进行筛选,选择相同进程名的 ApplicationLogic,保存到本进程中,然后,对这些本进程的 ApplicationLogic 进行实例化,最后,调用 ApplicationLogic 的onCreate方法,实现 ApplicationLogic 与 Application 生命周期同步,同时还有 onTerminate、onLowMemory、onTrimMemory、onConfigurationChanged 等方法,与 onCreate 一致。
结束进程,释放内存
在我们不使用某些进程的时候,比如听音乐的时候,可以把主界面关掉等等。我们可以调用对应进程的 LocalRouter 的 stopSelf()方法,该方法可以使本进程与 WideRouter 进行解绑,然后我们在手动关掉进程内的其他组件,最后调用 System.exit(),达到释放内存的目的。合理的释放内存,能有效的改善用户体验。
小结
这篇文章大概讲了一下作者这几年对Android架构的理解。其实本文中没有什么很深的技术点,大多是一些设计模式,架构思想。这套框比起大公司的一些优秀的动态更新、编译分包、apk插件化加载,还是简单很多的,更适合中小型应用。
这套框架目前还有比较多可以改进的地方,目前正在整理的:
增加对Action的动态关闭功能。
通过Instant Run原理,实现Action的热更新。
增加Message Pool,实现Request、Response的循环利用,减少GC触发。
已解决《高并发对象池思考》
http://blog.spinytech.com/2017/01/10/concurrent_object_pool
优化Message在传递过程中的打包,拆包的速度,提升整体性能。
本文项目地址:
ModularizationArchitecture
https://github.com/SpinyTech/ModularizationArchitecture
完。。。。。。。。。。。。。。。。。。。。。
文章原创作者GuoLin 书籍推荐
郭林大神原创android 书籍:《第一行代码 android》
淘宝链接: https://s.click.taobao.com/t?e=m%3D2%26s%3DgKUfuKdAZKocQipKwQzePOeEDrYVVa64K7Vc7tFgwiHjf2vlNIV67p2n%2BQBNMyE6Rku8%2Bpj6eJall3bs%2B3NRhNHnsKI%2BqxhyM0iVZhTFBom4YIorMPnmg8G0g2OJi%2FzmXHfenomYtn5EW9vzeG8LzfPUwktUBEmkxg5p7bh%2BFbQ%3D&pvid=10_106.6.161.154_3367_1490163222155