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

本文转载来之 {Spiny,郭霖}
本篇是 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容

  • 作者简介原创微信公众号郭霖 WeChat ID: guolin_blog 本篇是Spiny的第二篇 投稿,详细地分...
    木木00阅读 1,343评论 1 36
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,049评论 25 707
  • 聚会结束后,回忆涌现,木头就像疯了一样的想念他。每天就像有两个木头的存在,一个是白天的木头,活的没心没肺;一...
    青子ID阅读 279评论 1 1
  • 闲暇时读一本好书,窝在被子里听歌,躺在沙发里跟家人看电视,秋冬的暖阳和咖啡,久违的电话,和不用斟酌字句的人聊天,都...
    顾念阿Sam阅读 476评论 0 0
  • 每个人都有一套自我的心智,在这套独一无二心智模式的运作下,成为了每一个独特的人。 我们可以把心智模式比作电脑运行系...
    鹤一张阅读 298评论 0 0