这篇文章是我在 2018[携程技术沙龙移动技术专场 https://mp.weixin.qq.com/s/B3eWQmgUZYFeGPs4DWo8gw] 技术分享时所讲内容的文字版本, 修改删减了演讲时的冗余言语. 发布在 [掘金写作] , 希望能给买不到票参加大会的朋友带来帮助.
大家好, 今天跟大家分享的主题是Android 工程模块化平台的设计
首先自我介绍一下: 我叫张涛, 目前就职于饿了么移动技术部. 可能有些朋友认识我, 我之前也会在我博客 [开源实验室 https://kymjs.com ] 写一些 Android 相关的技术点, 如果对今天讲的模块化设计, 你觉得有什么问题或者可以深入探讨的, 也欢迎加我微信 kymjs123 详聊.
今天我们讲的主题是基于项目模块化来说的, 模块化是什么大家肯定都是知道了的, 这里问一下大家, 有多少人在此之前有做过模块化的, 举个手我看一下; 了解过听说过模块化的呢? 这次比较多.
我们说, 做模块化其实跟项目重构很像, 都是从这几个点来做的, 只是侧重点不同. 分别是: 删除, 组织, 降级, 解耦. 那么这四点是什么意思呢, 那么接下来跟大家分享一下我是如何理解这四大块的:
删除: 删除不必要的文件, 尽可能减小工程体积. 这里有一组数据, 是我统计我们饿了么的一款 APP 在模块化前后一些文件的数量.
可以看到,.java 文件从 1677 个减少到了 1543 个. 其实这不是重点, 重点是下面的 drawable, 这里 drawable 只包含图片, 和 xml 布局, 当经过模块化重构后文件数从 693 减少到 538 个. 图片资源减少接近 200 个, apk 的大小也会随之降低.
而组织呢, 指的是: 按照有意义的标准将代码分组. 这其实也是 java 的包所存在的目的之一.
但是随着项目的不断迭代, 需求很紧的情况下是很难有时间去真正规范的将类分组的. 看到图中, 我们之前的结构很乱, 就是因为项目快速迭代和人员更替的过程中, 不免会有这样的现象. 所以这也是模块化重构时所作的一件大事.
接下来就是我们经常说的内聚和耦合了, 降级. 我们之前有一个类叫: Navigator, 它是负责几乎所有 Activity 直接跳转的. 就是我们会把所有的 startActivity() 的跳转放到这个类里面去写. 之前少的时候还好, 结果等我看到这个类的时候, 这个类已经有 200 多个方法了, 全是 Activity 跳转的方法, 其中还有重复的, 就是很早之前有人写了一个跳到某个界面, 结果之后来了个人, 他不知道又写一个.
而我们在做模块化重构时的做法就是, 首先观察自己的项目, 这是重构很重要的一步, 就是要结合自身. 把这个类拆分成了三大部分, 我们有两块业务是会频繁跳转的但这两个业务跳转的页面又都是在自身的模块内, 分别是用户模块和商户模块. 因此我们将这两个模块中分别建立两个用于模块自己内部的跳转叫 UserNavigator 和 ShopNavigator, 而模块间的跳转或一些小模块内部的则使用 Router 去做, 我们自己定义了一个路由库, 其实实现跟现在开源的区别不大.
最后解耦, 也是今天的重点, 如何优雅移除模块间的耦合. 到目前为止, 我们已经能够做到让所有不包含业务状态接口的模块的增删, 不需要改动任何一行代码. 具体到一个示例就是这样:
或者, 也可以是这样:
这两个段代码的区别就是一个是手动管理 Debug 的状态, 另一个是交给 Gradle 的编译任务去控制, 原理上是一样的.
而这么做是如何实现的呢, 其本质就是: 一个模块就是一个功能, 你想要让你的 apk 具备这个功能, 就添加这个模块一起编译就可以了. 这才是我们说的真正的组件化, 模块之间零耦合, 增减模块零改动.
例如图中: debug 这个模块, 肯定不会用在正式的生产环境; 而相反的 tinker 这个模块, 热补丁肯定也不会用于调试阶段. 所以我在开发时就可以不使用这个模块相关的代码.
另外再举个使用的例子: 我有一个订单模块, 订单模块需要播放铃声, 比如大家在饭店经常听到 "您有新的饿了么订单, 请及时处理". 但我在开发订单模块的时候, 如果我已经确定铃声播放是没有问题的, 那我可以选择开发阶段不打铃声的包, 直到发布到线上了再去加上铃声的包. 那我没有添加这个铃声模块的时候, 我就默认不具备播放铃声的功能, 但完全不影响其他的订单模块的业务功能, 而这个铃声模块的增删, 是不需要修改任何代码的.
听到这里相信大家都很好奇这是怎么实现的. 接下来就跟大家讲讲内部的原理.
所有的核心功能都来自我们自己写的一个库: IronBank. 取自冰与火之歌中的 [铁金库] , 叫铁金库不容拖欠.
铁金库的内部实现, 其实是使用了 APT 注解处理器, 去在编译时解析注解生成一个类, 让这个类去生成跨模块的对象. 铁金库使用了与后端 SOA 设计思路类似的方式: 将模块之间的主动依赖倒置, 变为功能的提供与使用.
那什么是 SOA 的设计思路呢, 我们看到一张我画的漫画图: SOA 它是一种面向服务的架构模型.
例如图上左边有一个对外提供媒体功能的服务提供者, 他告知 IronBank 我提供媒体服务:"嘿, 老铁, 我这有个媒体服务, 你那边有谁要用的时候可以用我的."
到了另一边, 如果此刻有模块说是, 我需要媒体服务:"老铁, 你那有没有媒体服务, 我这边需要播一个铃声啊!".
"有的, 给你."
IronBank 就会将之前服务提供者提供给他的媒体对象交给服务使用者.
接下来我们来看具体到代码上是如何使用的: 首先是作为服务使用方, 也就是上一张图右半部分. 我们看到传统的做法是首先声明一个接口类型, 然后 new 出接口的实现类给他赋值.
而使用了 IronBank 的时候, 你是不需要关心接口的实现类到底是谁的. 这就是 IronBank 唯一的用处, 隐藏实现类, 做到彻底的面相接口编程.
之前说过, IronBank 将模块之间依赖倒置, 由之前的服务提供方被动的接受调用方调用变为, 服务方主动提供服务给调用方.
那作为服务提供方需要做些什么事呢, 非常简单, 你只需要给你的对象提供 public static 方法, 并加上一个 @Creator 注解, 告诉 IronBank 这是一个创建器方法就可以了, 其他任何事情, 都不需要考虑.
前面讲的 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 仓库.
但是这种结构就对工程名会有一定的规范要求, 主要原因是在模块联调的时候.
我们看到这段代码是写在 setting.gradle 文件中的, 他根据读取本地的 local.properties 文件, 来 include 一个模块的源码, 方便在模块联调的时候可以很容易的修改多模块的代码.
但是他就要求每个模块工程的文件夹名称是以模块名加上 Project 这样来命名, 比如 order 模块所在的工程文件夹名就叫 OrderProject
当然, 你也可以不遵守, 只不过不遵守就得写更多代码, 我这里是直接用了循环, 不遵守的话可能就需要把循环拆开手敲了.
以上两种工程结构各有各的好处, 没有好坏, 只有合不合适, 我们内部两种结构也都有团队在用.
然后, 这里是模块联调的注意事项, 就是如果你模块是以源码引入的, 可能还有其他模块引用了同样模块的 aar, 就会造成冲突, 你需要自己判断一下, 加个自定义方法也好, 用编译插件也可以, 都能做到让源码引用与 aar 引用互斥.
模块化架构主要思路就是分而治之, 在拆分的时候最重要的就是把依赖整理清楚, 那些是业务模块, 哪些是可选的功能组件. 最后为了团队方便以及更快的适应, 还需要开发一些辅助工具, 比方说我前面说的 IronBank,BizLifecycle, 初始化脚本等等, 都是必不可少的.
来源: https://juejin.im/post/5adc03b2518825670f7b6f05