干货 | Android工程模块化平台的设计

作者简介

张涛,饿了么资深Android工程师,“开源实验室”博主,Kotlin 技术推广者。2013年开始从事Android开发,带过团队,做过架构,写过应用,做过开源社区。目前在饿了么商户端负责应用模块化平台与插件化平台的设计和开发。本文来自张涛在“携程技术沙龙——无线技术工程化”上的分享。

本文的主题是基于项目模块化来说的,模块化其实跟项目重构很像,只是侧重点不同,分别是:删除、组织、降级、解耦。接下来将跟大家分享我是如何理解这四大块的。

模块化重构

删除:删除不必要的文件,尽可能减小工程体积。这里有一组数据,是饿了么一款 APP 在模块化前后一些文件的数量。

可以看到,.java文件从1677个减少到了1543个。其实这不是重点,重点是下面的drawable,这里drawable只包含图片、和xml布局,当经过模块化重构后文件数从 693 减少到 538 个。图片资源减少接近 200 个,apk 的大小也会随之降低。

而组织呢,指的是:按照有意义的标准将代码分组。这其实也是java的包所存在的目的之一。

但是随着项目的不断迭代,需求很紧的情况下是很难有时间去真正规范的将类分组的。看到图中,我们之前的结构很乱,就是因为项目快速迭代和人员更替的过程中,难免会有这样的现象。所以这也是模块化重构时的一件大事。

接下来就是我们经常说的内聚和耦合了,降级。我们之前有一个类叫:Navigator,它负责几乎所有Activity直接跳转。我们会把所有的startActivity()的跳转放到这个类里面去写。少的时候还好,等我看到这个类的时候,已经有 200 多个方法了,全是Activity跳转的方法。

在做模块化重构时,首先观察自己的项目,这是很重要的一步,要结合自身。把这个类拆分成三大部分,我们有两块业务是会频繁跳转的,但这两个业务跳转的页面又都是在自身的模块内,分别是用户模块和商户模块。因此将这两个模块中分别建立两个用于模块自己内部的跳转叫UserNavigatorShopNavigator,而模块间的跳转或一些小模块内部的则使用Router去做。

之后解耦,如何优雅移除模块间的耦合。 到目前为止,我们能够做到让所有不包含业务状态接口的模块的增删,不需要改动任何一行代码。 一个示例:

或者,也可以是这样:

这两个段代码的区别,一个是手动管理Debug的状态,另一个是交给Gradle的编译任务去控制,原理上是一样的。

而这么做是如何实现的呢?其思路:一个模块就是一个功能,你想要让你的 apk 具备这个功能,就添加这个模块一起编译就可以了。这才是我们说的真正的组件化,模块之间零耦合,增减模块零改动。

例如图中:debug这个模块,肯定不会用在正式的生产环境;而相反的tinker这个模块,热补丁肯定也不会用于调试阶段。所以在开发时就可以不使用这个模块相关的代码。

再举个使用的例子:我有一个订单模块,订单模块需要播放铃声,比如大家在饭店经常听到“您有新的饿了么订单,请及时处理”。但在开发订单模块的时候,如果已经确定铃声播放是没有问题的,那可以选择开发阶段不打铃声的包,直到发布到线上了,再去加上铃声的包。

那没有添加铃声模块的时候,就默认不具备播放铃声的功能,但完全不影响其他的订单模块的业务功能,而这个铃声模块的增删,是不需要修改任何代码的。

听到这里,相信大家都很好奇是怎么实现的。接下来就跟大家分享下内部的原理。

铁金库解耦

所有的核心功能都来自我们自己写的一个库:IronBank。取《自冰与火之歌》中的【铁金库】,叫铁金库不容拖欠。

铁金库的内部实现,其实是使用了 APT 注解处理器,在编译时解析注解生成一个类,让这个类去生成跨模块的对象。铁金库使用了与后端 SOA 设计思路类似的方式:将模块之间的主动依赖倒置,变为功能的提供与使用。

例如图上左边有一个对外提供媒体功能的服务提供者,他告知IronBank我提供媒体服务:“嘿,老铁,我这有个媒体服务,你那边有谁要用的时候可以用我的。”

到了另一边,如果此刻有模块说是,我需要媒体服务:“老铁,你那有没有媒体服务,我这边需要播一个铃声啊!”。

“有的,给你。”

IronBank就会将之前服务提供者提供给他的媒体对象交给服务使用者。

接下来我们来看具体到代码上是如何使用的:首先是作为服务使用方,也就是上一张图右半部分,传统的做法是先声明一个接口类型,然后new出接口的实现类给他赋值。

而使用了IronBank的时候,你是不需要关心接口的实现类到底是谁的。这就是IronBank唯一的用处,隐藏实现类,做到彻底的面相接口编程。

IronBank将模块之间依赖倒置,由之前的服务提供方被动的接受调用方调用变为,服务方主动提供服务给调用方。

那作为服务提供方需要做些什么事呢?非常简单,你只需要给你的对象提供public static方法,并加上一个@Creator注解,告诉IronBank这是一个创建器方法就可以了,其他任何事情,都不需要考虑。

这里的创建器方法是可以有参数的,在接收时实际是使用另一个变长Object参数来接收。

而相对于繁杂的应用场景,也有对应的解决办法,例如这里的创建器方法是含参数的。看到示例第一个参数是 tag,第二个是 context 。但是你希望调用者在传的时候将Context作为第一个参数,tag作为第二个参数。

那你在声明的时候,需要显示的声明参数,加一个 params,然后写上你希望的参数顺序。

这个@Creator注解里面还有很多参数,比如这里返回的是IMedia类型的对象,如果IMedia接口还继承了一个A接口,这里我虽然返回的是IMedia,但我不想外部知道,我就想外部知道我返回的只是个A,这样也是可以显示的,在注解参数中声明就行了。以及还有方法的类所在文件自定义等等等等…… 就不一一列举了。

在使用上,为了接入方使用方便,我们也对IronBank做了非常多的体验优化。

我们通过自定义lint来使 IDE 可以检查参数类型是否正确。比如前面举的例子,如果声明的时候第一个参数是String,第二个参数是Context,如果你传错了,IDE 直接就报红了。

还有前面我们看到了,IronBank提供了一种类似依赖注入的方式去创建对象,既然是类似依赖注入,一定会碰上循环引用问题。我们自定义的Lint,也完美通过静态代码分析,在编译前就避免了这个问题。

同时在开发的时候还提供了一个Android Studio IDE插件,可以用来帮你把参数智能补全,自动生成代码。前面看到,在写IronBank.get()方法的时候得写很多字,如果有智能补全会少写很多。

业务状态解耦

前面讲的IronBank适用的场景是无状态的服务,而做业务APP开发的时候,更多的是有业务状态的对象。比方说通常长链与推送功能是等到用户登录了以后才会去启动,但具体到代码上,推送模块是根本不知道用户什么时候登录的,这就是一个业务状态的问题。

对此我们引入了BizLifecycle的接口,它与Android上的Application对象功能类似。只不过它用来管理的是业务的生命周期,而不是应用的。

在代码逻辑上,每个模块如果关心你所需要的业务生命周期,只需要注册一个Lifecycle就行了,同时注册的过程也只需要一个注解,由编译插件解决了。

可以看到,其实这样的一种能力用事件通知也可以做到,比方说广播或者EventBus,但是我们刻意屏蔽了这种方式,就是因为事件通知这种功能你是很难去追踪的。你不知道一个消息发送了以后,它的接受者是在哪里。

相信大家也能够想象到,一个应用如果广播泛滥,到处都是事件接收事件发送,项目代码会变得多么吓人。

讲到这里,整个模块化解耦的全部能力就介绍完了。

接下来,我们再从宏观角度去看一下整个项目的结构,分为三级,最上层是业务模块,紧接着是一些可选的功能组件,最底层则是与项目无关的公共依赖。

最终,项目结构就是如图中所示的这样。但如果你真直接这么做,你一定是会烦死的。

为什么?

第一:这么多的模块,直接用源码依赖去编译,编译时间至少在10分钟以上;

第二:模块的隔离几乎为0,任何一个人依旧可以修改任何一个模块的代码,并且很容易;

第三:在发版本以后,如果某一个模块有BUG,再去修复,缺乏一个版本的概念,尤其是在跨团队的时候,最终一定会出现版本分裂问题。

平台化支持

解决办法我想大家都知道,就是将模块引用改为aar引用。aar引用最大的优势就在于模块版本的管理与跨团队的协作。

目前国内对Android领域的探索越来越深,应用规模也越来越大,为了降低大型项目的复杂性和耦合度,同时也为了适应模块重用、多团队并行开发测试等等需求,必须有一套合适的模块化平台。

这里是饿了么目前使用的模块化平台,大家可以从这张图中感受一下。

模块化平台,主要的功能是很明显的,就是用于构建模块,在这之上,还有隐含的功能,就是集中了构建模块的权限,可以更便于统一管理;

最重要的优势在于模块版本的管理,你可以很清晰的知道当前主应用所接入的模块的版本是哪个,当前最新构建的SNAPSHOT是哪个,以及每个版本的更新日志;

这样做了以后,在跨团队协作上的沟通就大大降低了,如果你已经接入或者即将接入的模块是另一个团队开发的模块组件,那你可以直接关注它,它的所有版本变动日志,最新版本全都一目了然;

并且可以通过平台简化模块的测试与模块发布的流程,比如提测的时候,如果是一次兼容版本的发布,你只需要告诉测试提测分支,测试可以自己根据现在线上应用的tag,同时引入当前提测的模块替换老版本的模块重新编译,很容易就能控制变量。

引入了平台化以后,我们再从工程结构的角度看一下:就目前尝试下来,这两种结构是最合适Android工程模块化的。一种是submodule,一种是multi-project

首先看submodule:这种结构是Android默认的多模块结构,在一个工程下面有多个模块。图上每个绿色的方块都代表了一个git仓库,所有子模块都包含在主工程模块内。这种结构也是git默认支持的submodule结构,你只需要用最下面的这句git命令就可以将他们关联在一起。

它的好处就是所有都是默认的,任何一个人理解起来都是很直观。当然,它也有不适合的,就是协作开发的时候,所有人都在app module上测试自己的模块,很容易互相影响,主工程的git分支也会非常繁杂。

与之对应的,multi-project能很好的解决这个问题:所有模块都是一个独立的工程,他们在文件系统上是并列关系,每个模块所在的工程才是一个git仓库。

对于单模块的操作达到最大化的遍历当然也是有牺牲的,就是这种结构很不利于全仓库整体的管理,对新人很不友好。比如我想所有模块同时初始化,同时切换到develop分支,对此,我们内部的处理方式是通过shell脚本达到全仓库批量处理。

同时还会对工程名会有一定的规范要求(非必须),主要原因是在模块联调的时候。

我们看到这段代码是写在setting.gradle文件中的,他根据读取本地的local.properties文件,来include一个模块的源码,方便在模块联调的时候可以很容易的修改多模块的代码。

但是要求每个模块工程的文件夹名称是以模块名加上Project这样来命名,比如order模块所在的工程文件夹名就叫OrderProject。 当然,你也可以不遵守,只不过不遵守就得写更多代码,我这里是直接用了循环,不遵守的话可能就需要把循环拆开手敲了。

以上两种工程结构各有各的好处,没有好坏,只有合不合适,我们内部两种结构也都有团队在用

这里是模块联调的注意事项,如果你模块是以源码引入的,可能还有其他模块引用了同样模块的aar,就会造成冲突,需要自己判断一下,加个自定义方法也好,用编译插件也可以,都能做到让源码引用与aar引用互斥。

模块化架构主要思路就是分而治之,在拆分的时候最重要的是,把依赖整理清楚,哪些是业务模块,哪些是可选的功能组件。最后为了团队方便以及更快的适应,还需要开发一些辅助工具,比如前面说的IronBank、BizLifecycle、初始化脚本等等,都是必不可少的。

原文发布于微信公众号 - 携程技术中心(ctriptech)

原文发表时间:2018-05-09

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏cloudskyme

hbase实战——(1.1 nosql介绍)

什么是nosql NoSQL(NoSQL = Not Only SQL),意思是不仅仅是SQL的扩展,一般指的是非关系型的数据库。 随着互联网web2.0网站的...

3698
来自专栏ThoughtWorks

聊一聊契约测试 | 洞见

如果从契约产生的阶段来说,现有资料表明最早要追溯到西周时期的《周恭王三年裘卫典田契》,将契约文字刻写在器皿上,就是为了使契文中规定的内容得到多方承认、信守,“万...

1275
来自专栏H2Cloud

游戏服务器设计之任务系统

游戏服务器设计之任务系统 介绍 任务系统是游戏中最重要的系统之一,本文旨在设计一个轻量清晰的任务系统。通用易扩展是本系统关注的重点。任务系统中当角色的条件满足时...

6584
来自专栏腾讯Bugly的专栏

《手Q Android线程死锁监控与自动化分析实践》

手Q每个版本上线以后研发同学都会收到各种问题反馈。在跟进手Q内部用户反馈的问题时,发现多例问题,其表象和原因如下:

8288
来自专栏FreeBuf

子域名枚举的艺术

写在前面的话 当我们在查找某个域名的有效子域名时,我们通常需要使用子域名枚举这项技术。但是,除非DNS服务器暴露了完整的DNS空间(涉及到AXFR协议),否则我...

3599
来自专栏Java后端技术栈

面试必备:如何将一个长URL转换为一个短URL?

前几天整理面试题的时候,有一道试题是《如何将一个很长的URL转换为一个短的URL,并实现他们之间的相互转换?》,现在想起来这是一个绝对不简单的问题,需要考虑很多...

2372
来自专栏IT米粉

Redis常见的应用场景解析

缓存是Redis最常见的应用场景,之所有这么使用,主要是因为Redis读写性能优异。而且逐渐有取代memcached,成为首选服务端缓存的组件。而且,Redis...

7318
来自专栏乐沙弥的世界

Percona XtraDB Cluster集群节点重启及故障转移

要重新启动集群节点,请关闭MySQL并重新启动它。该节点将离开集群(并且法定人数的总计数应该减少)。发布命令 systemctl restart mysql

912
来自专栏VMCloud

【解析向】腾讯云的Windows Server日志配置收集工具是个什么鬼?(2)

继上一篇,上一篇重点介绍了腾讯云Windows Server日志收集工具的“场景”功能,那么场景功能究竟是以什么标准来分级的呢?

4127
来自专栏Java技术分享

java系统高并发的解决方案

一个小型的网站,比如个人网站,可以使用最简单的html静态页面就实现了,配合一些图片达到美化效果,所有的页面均存放在一个目录下,这样的网站对系统架构、性能的要求...

7128

扫码关注云+社区