浅谈项目重构之路——模块化

忙了一个多月,一直没时间写文章。终于把项目重构完了,借此机会浅谈一下对Android架构的见解。笔者将会把重构分为三个部分讲解。
本文为全局架构,主要设计模块化架构开发。
上一篇为概述篇
下一篇为组件化+MVP篇

[如有解释错误的地方,欢迎评论区指正探讨]


模块化能解决什么问题

先来看一下笔者项目的旧版架构:

全局架构-旧.png

是不是很眼熟这样的架构?整个应用即为一个工程,所有业务之间不存在编译隔离,所以可以互相引用。对于早期小型的App而言,这样的架构清晰简单,同时也便于快速开发。
不过随着业务的积攒,整个App变得臃肿,这样的架构不仅容易出现模块耦合问题,同时容易造成开发混乱,改一处地方却涉及到多个模块。

在上一篇文章中,笔者也有提到为什么需要重构,并提出使用模块化进行重构,那么我们来看看,使用模块化能解决什么问题:

  • 解决由于模块边界定义不清而导致的耦合问题
  • 统一规定模块之间通信方式,去除过分使用EventBus而臃肿的event包
  • 隔离各个模块代码,利于并行开发测试
  • 可单独编译打包某一模块,提升开发效率
  • 模块实现可复用,快速集成影子App
  • 开发时,可以进行单业务编译,避免全量编译耗时过长

这些问题都是从笔者的项目中反应出来的,也正是解决代码劣化的关键。

什么是模块化

讲了那么久模块化,那么到底什么是模块化?网上对于模块化的解释有很多,基本上每个人的解释都不太一样,往往模块化组件化总被混淆在一起。
这大概是因为组件和模块在英文翻译里都被叫为module,而在AS中lib模块都被定义为module。所以这些module都容易被混淆在一起。

组件

这里提到的组件,翻译成module并不准确,他其实是一个通用的Lib,只不过组件在AS中的实现,多数以module的形式实现。在Android App中,组件应该是构成业务模块或业务功能的基本单位

举个例子,笔者项目中存在类似朋友圈一样的业务,那么必不可少的就需要一个图片上传组件Uploader
这里的Uploader不管是功能上还是业务上都无法继续拆分,所以Uploader组件而并非Uploader模块,朋友圈才可以称之为模块。

对于组件化,其实也是本次重构方案的关键之一,不过笔者将其归为局部架构里的内容,所以在这里只简单介绍一下概念,不展开过多描述。

模块

对于模块,这才是真正意义上的module。模块由多个组件甚至多个模块构成,并通过特定的逻辑讲这些组件连接起来实现一定的业务。

还是以刚才的朋友圈为例子,朋友圈将网络组件,上传组件,日志组件,图片组件通过特定的逻辑构成其特定的业务。对于微信朋友圈,其内部可能还有他特有的广告模块,Gps模块等等,所以说模块也可能由多个小模块构成。

模块具有可拆分性,正如朋友圈,我们可以将其拆分成多个组件。
模块一般与业务相关联
一个健康的模块应该具有可复用性,要做这点,必然要和其他模块保持独立。
听上去可服用性和业务相关联,似乎互相矛盾,其实不然,如果这里的可复用性没有组件的复用性那么强,强调的是与其他模块保持独立,假如两个app都有朋友圈业务,那么大可以复用该模块,改改ui即可。

区别

通过上面的介绍,其实也就大致了解了什么模块,什么是组件。

简而言之: 模块 = 组件A + 组件B + …… 组件B

其实归根结底,只要目的确定,把臃肿的工程,拆分为更小的部分,解耦各种复杂的逻辑,便于代码管理。管他叫什么模块还是什么。

技术难点

为了实现模块化,并使各个模块达成上述特性,笔者将整个过程划分为三个问题,也是三个技术难点。

  1. 隔离模块边界
  2. 模块间的跳转
  3. 模块间的通信

接下来,将一一解答这些问题。

隔离模块边界

对于以前的App而言,为了避免耦合问题,采取以包为分界,同时笔者的团队制定了一系列代码规范,然而,在赶工的情况下,并不是所有人都能遵守这套规范,尤其是刚进来并不熟悉团队的新伙伴。因此,要想从根本上隔离代码,解决耦合问题,在编译上约束权限是最佳的方法

那么如何做到编译时的约束呢?很显然,这就需要将原本以包为分界的模块抽出来以AS中的module形式隔离。
同时制定规则,模块与模块直接不允许同时直接产生依赖关系
对于多个模块通用的组件,应该采取先前提及的组件化,同样以module的形式隔离。这一块将在下一篇文章中叙述。

规则

对于模块的划分,需要制定一定的规则,如果划分粒度过小,那么会导致项目Module冗余,如果粒度过大,那么又会出现耦合问题,与初衷相悖。错误的划分,将导致项目结构复杂。
因此,对于笔者的项目而言,指定这几个规则来划分:

  • 业务之间是否强关联?强关联应该合并
  • 共用的功能是否可组件化?可组件化应该拆分
  • 业务是否复杂?复杂应该拆分
  • 空壳模块能否与其他空壳合并

举个例子可能比较好懂,以微信为例,在首页底部有四个tab:

微信首页.jpg

可能你会这么想,底部四个tab就对应四个大模块。
如果这么划分的话,那么又该如何处理朋友圈,摇一摇等功能呢?都归于发现模块还是单独开一个模块呢?
显然,如果都将朋友圈和摇一摇都归为一个模块,那么这个模块将过度复杂,这两者没有明显的业务关系,归于一个模块,很容易因为跳转或信息通信产生耦合问题
如果单独开一个模块,那么显然发现模块将成为一个空壳,而四个tab,就对应了四个空壳,这就造成了Module冗余

那么应该如何处理好呢?
针对笔者定制的规则,我们一一考虑:

  1. 朋友圈与摇一摇之间业务并不是强关联
  2. 这里并无复用功能
  3. 单纯四个tab的业务并不复杂。朋友圈与摇一摇业务复杂
  4. 四个tab其实都可以作为空壳模块,仅作为承载体

综合考虑,我们应该合并四个空壳tab,拆分朋友圈与摇一摇。
所以项目结构如下:


微信项目结构.png

对于不同项目,实际情况可能比这里更复杂,这就需要对业务足够了解,具有一定经验了。目前笔者团队划分模块时需要各业务Leader商讨决定。

隔离好各个模块,就应该来考虑模块间跳转,通信的问题了。虽然我们将不存在强关联的模块隔离开,但模块之间终究需要通信与跳转,这由应该如何处理呢?

模块间跳转

在我们隔离完模块后,跳转的问题也就出来了。因为编译隔离,我们也就无法直接引用,不能通过的显示方式跳转。

隐性跳转

既然显示跳转不行,自然而然的我们就想到隐式跳转:

Intent intent=new Intent("action");   
startActivity(intent);  

不过使用隐式跳转存在几个问题:

  1. 每个模块各自管理各自的AndroidManifest.xml,这就容易出现action重复的问题。
  2. 过多的Activity被导出,容易引发安全问题
  3. 可配置性较差Manifest限制于xml格式,书写麻烦,配置复杂,可以自定义的东西也较少。
  4. 代码写起来繁琐,出错时难以定位问题
  5. 直接通过Intent的方式跳转,跳转过程开发者无法干预,一些面向切面的事情难以实施,比方说登录、埋点这种非常通用的逻辑,在每个子页面中判断又很不合理,毕竟activity已经实例化了

显然我们不可能采用难以管理的隐式跳转。

路由跳转

既然隐式跳转不行,那我们只能另寻他法。
这里我们参考了路由器工作原理:

路由跳转.png

很显然,我们需要在路由器中维护一个路由表,也就是Activityurl的映射,在我们发出一个跳转请求时,就由路由器去路由表中寻找映射并跳转。

这样的操作就类似于我们在浏览器中输入www.baidu.com,我们本地完全不与百度产生依赖关系,却可以跳转访问百度。百度是如何实现,是好是坏,我们完全不懂担心,这样的流程很适合我们的实现模块化。

那么如何实现呢?
显然路由器的核心是维护路由表,我们需要做的就是把每个Activity到路由器里。从原理上来看并不难实现,关键是如何做到好用易用。

这里笔者并没有自己重复造轮子,而是选择了阿里开源的框架ARouterARouter处理实现基本的路由功能外,还兼备拦截器降级策略等功能。
ARouter在实现维护路由表功能时,借助Annotation Processor来实现。这样我们在使用时便十分方便,也不会在代码中插入生硬的逻辑。
简单的看一下使用:

添加注解

// 在支持路由的页面上添加注解
@Route(path = "/baidu/index")
public class BaiDuActivity extend Activity {
}

执行跳转

//  实现简单的跳转
ARouter.getInstance().build("/baidu/index").navigation();

是不是很简单?这样我们就仿造出了跳转www.baidu.com的操作了。

简单看看ARouter的工作流程,其实跟我们前面的讲的原理差不多,需要提一下的是,ARouter在使用注解处理器的同时还使用了反射,经过测试,这里的反射很好的解决了模块之间的耦合问题同时并不会出性能问题。

arouter.png

解决完跳转问题,还有通信的问题要解决,比如朋友圈模块需要使用用户模块的用户信息。那么又该如何解决呢?

模块间通信

在模块独立之后,模块之间没办法直接耦合,所以原先的通信方式(setListenerstartActivityForResult)便失效了。所以,模块化的一个关键便是如何实现与其他模块保持独立,又建立良好的通信方式。
我们需要寻找一种新的方案。

广播

作为四大组件之一,我们借助Broadcast实现模块之间的通讯,不过我们也知道,广播作为一个重量级的通讯工具,并不适合频繁通信,同时广播仅支持基本数据类型可序列化对象,传递大数据时还有限制,可见局限性很大,并不适用。

EventBus

作为一个轻量级的通讯框架, EventBus解决了广播存在的那些问题,同时十分灵活,**不依赖于上下文v,任何地方都可以进行通讯。重构之前的项目也有很多地方利用EventBus来进行通讯,确实实现了松耦合
不过EventBus也存在他的弊端:

  • 大量的通讯Event沉淀在Common层
  • 基于发布订阅模式,注定无法主动获取数据

这些弊端,让我们最后放弃使用EventBus作为模块之间的通讯工具,不过同一模块内的通讯依旧可以选择EventBus

协议通信

我们一开始参照了RPC机制, 也就是通过restful这样的形式去进行通信。


协议通信.png

通过访问定制的协议,经由路由器访问相关的服务获取数据,这种方式十分灵活,具备很强的解耦能力,但也有不可忽视的代价——高度文档化
想必大家都有体验过,我们开发时总是需要去翻阅后台给我们的接口文档,这样的事情我们不想在本地通信时再次发生,不仅维护文档困难,开发效率低下,也非常容易出错。
我们希望协议的检测能够让编译器帮我们分担,写错了编译器会报错,然而协议通信是依赖于文档的,eg:www.baidu.com/getsomethings/id=xxx&passw=xxx,编译器无法识别这样的手写是否符合协议,需要运行时才能发现错误。

说了好几种常见的通讯方式都不行,那到底应该怎么做呢?

接口协议通信

既然协议通信不好用,那么有没有办法解决他高度依赖文档问题。
方法是有的,就是改文档化接口化。如果将原本由文档规定的协议,交给接口来规定,那么编译器就可以帮我们检测协议是否正确了。
这也就是接口协议通信的原理。

简单的看一下流程:


接口协议通信.png

和上面提到的协议通信很相似,多了Provider这一层次:

  1. ModuleB 向 Router 注册 ProviderB 接口服务
  2. ModuleA 向 Router 请求 ProviderB 接口服务
  3. Router 返回 ProviderB 接口服务

这样边解决了原本的高度文档化的问题,同时保持原来的灵活性和解耦能力。
刚好ARouter具备这样的功能,于是我们也采用了ARouter的实现方案。使用起来是这样的:

首先在公共组件(路由组件)中 声明接口,其他组件通过接口来调用服务

public interface IProviderB extends IProvider {
    String getUserName();
}

然后在具体模块中实现接口,并注册

@Route(path = "/moduleb/providerb", name = "测试服务")
public class ProviderB implements IProviderB {

    @Override
    String getUserName(){
    }
}

在其他模块中通过路由去寻找相关服务

IProviderB provider = (IPoviderB) ARouter.getInstance().build("/moduleb/providerb").navigation();

是不是同样和很简单?而且因为都使用了ARouter,所以调用操作与跳转的操作很像,也就便于代码的阅读。

至此,我们就解决了模块化的三个关键性问题。

再思考

解决完上述的上的三个关键性问题后,一个基于ARouter的模块化架构也就诞生了,不过还存在一些问题。

app module

在我们隔离完业务模块后,该如何处理app module呢?
在上面的微信的例子中,我们将app module作为home界面的载体,装载了主界面的几个空壳。那么app module就只做这样的功能了吗?
并不,app module作为特殊的一个模块,链接着所以模块的生命周期,也就包括了模块的初始化与销毁。
同时app module作为一个中介,可以实现一些简单的模块间通讯。

缺点

那么是否实现模块化之后就高枕无忧了呢?并不。
模块化很好的解决了模块之间的耦合问题,同时便于进行单业务拆分编译。但是也暴露几个问题:

  1. 因为模块数量的增加,全量编译时间变长
    虽然我们平时开发时做到了单业务编译,加快了编译速度,但是最终打包合并的时候需要全量编译,事实证明全量编译的时间将随着模块数量的增加而增加。不过,这点还能接受。

  2. 模块的划分有时纠结不清
    当对模块进行解耦时,即便大体上的业务划分已经清晰,但因为业务间各种微妙的关系,细节上仍会遇到纠缠不清的情况。那么这个时候就会出现纠结于这个模块到底该不该细分的问题。
    我们能做到的只是尽量让他更加"面向对象",同时避免随意拼凑和单纯为了类型解耦而解耦的情况。

  3. 模块划分粒度容易过细,导致模块数剧增
    这是笔者项目中实际遇到的问题,对于部分业务,功能比较零散,如果划分多一个模块或组件,这个模块或组件又只有这个业务在使用。如果不划分,又容易与这个业务里的其他功能耦合。
    引用微信模块化的例子,对于Gallery模块,内部还有存储,编辑等小功能,如果直接与Gallery揉合,那么很容易就产生耦合问题,为此微信团队提出了自己的解决方案,构建pins工程

    image.png

    这一块笔者就不再阐述,可以跳转微信的文章进行学习。

总结

对于中大型App而言,往往都积累了一些年份,很多时候,全局架构都停留最初的状态,各个业务相互交叉耦合,这样其实并不利于整个App的发展。代码只会逐渐劣化,到最后发现拓展新业务时,需要大规模修改旧业务,那就为时已晚了。
所以,一个良好的项目周期,需要适时推动一些重构计划,提高代码质量,而并不是只停留在业务代码层次。
看一下采用模块化之后的项目架构,对比一下文章开头的架构:

全局架构-新.png

模块化的架构不仅解决了模块耦合问题,同时也调高了整个App的拓展性与维护性。这样的重构,何乐而不为?


最后希望笔者分享的一点经验能对大家提高代码有些帮助,如有错误的地方,欢迎指正探讨。

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