微信Android 模块化架构重构实践(上)

开发 开发工具
微信Android诞生之初,用的是常见的分层结构设计。这种架构简单、清晰并一直沿袭至今。这是微信架构的v1.x时代。

微信Android架构历史

微信Android诞生之初,用的是常见的分层结构设计。这种架构简单、清晰并一直沿袭至今。这是微信架构的v1.x时代。

图1-架构演进

图1-架构演进

到了微信架构的v2.x时代,随着业务的快速发展,消息通知不及时和Android 2.3版本之前webview内存泄露问题开始突显。由于代码、内存、apk大小都在增长,对系统资源的占用越来越多,导致微信进程容易被系统回收。因此微信开始转向多进程架构,独立的通信进程保持长连接的稳定性,独立的webview进程也阻隔了内存泄露导致的问题。

时间继续推进,我们也遇到了65535问题和LinearAlloc问题。这时的微信已经具备了许多功能像朋友圈、摇一摇、附近的人等等,分离核心功能和其他业务模块变得越发重要。为此,微信开启了第三次架构改造(v3.x)。我们对各种产品功能进行解耦并拆分到相互独立的p_xxx工程中,这是微信***次进行模块化架构的重构。经过几个月的努力,微信拆出了几十个p工程,它们都通过基础组件访问网络、存储等服务,互相独立并行。新的p工程架构支撑了微信更快速的业务发展,配合多分支开发模式的改进,能够支持团队多分支多team的并行开发。

图2 - 架构图

图2 - 架构图

为何再次重构微信

原本好好的架构出了什么问题?

从上个架构之后的两年多时间里,微信Android基本没有大的架构改动。配合gradle的编译,以及git的多分支并行开发,微信的模块工程数量不断增多,支撑了游戏、支付等大功能,可以说这段时间里原有架构起到了很好的作用。

然而随着代码继续膨胀,一些问题开始突显出来。首先出问题的是基础工程libnetscene和libplugin。基础工程一直处于不断膨胀的状态,同时主工程也在不断变大。同时基础工程存在中心化问题,许多业务Storage类被附着在一个核心类上面,久而久之这个类已经没法看了。此外当初为了平滑切换到gradle避免结构变化太大以及太多module,我们将所有工程都对接到一个module上。缺少了编译上的隔离,模块间的代码边界出现一些劣化。虽然紧接着开发了工具来限制模块间的错误依赖,但这段时间里的影响已经产生。在上面各种问题之下,许多模块已经称不上“独立”了。所以当我们重新审视代码架构时,以前良好模块化的架构设计已经逐渐变了样。

 

图3 - 架构逐渐的变化

图3 - 架构逐渐的变化

“君有疾在腠理,不治将恐深”,在我们还在犹豫到底要不要重构的时候,硬件同学向我们提出了需求。希望将微信Android代码移植到类似微信相册这样产品中。这样就可以快速跟进微信业务***的支撑组件、协议、安全性、后台服务等能力,而且代码要尽可能精简,可以选择和定制模块,可以移植模块来实现原型尝试。但就之前的情况来说,微信一时难以满足。这下定了,还得重构。

于是我们回过头仔细看之前的设计,找找问题究竟是怎么来的。

问题出在哪

先寻找代码膨胀的原因。

翻开基础工程的代码,我们看到除了符合设计初衷的存储、网络等支持组件外,还有相当多的业务相关代码。这些代码是膨胀的来源。但代码怎么来的,非要放这?一切不合理皆有背后的逻辑。在之前的架构中,我们大量适用Event事件总线作为模块间通信的方式,也基本是唯一的方式。使用Event作为通信的媒介,自然要有定义它的地方,好让模块之间都能知道Event结构是怎样的。这时候基础工程好像就成了存放Event的唯一选择——Event定义被放在基础工程中;接着,遇到某个模块A想使用模块B的数据结构类,怎么办?把类下沉到基础工程;遇到模块A想用模块B的某个接口返回个数据,Event好像不太适合?那就把代码下沉到基础工程吧……

就这样越来越多的代码很“自然的”被下沉到基础工程中。

我们再看看主工程,它膨胀的原因不一样。分析一下基本能确定的是,首先作为主干业务一直还有需求在开发,膨胀在所难免,缺少适当的内部重构但暂时不是问题的核心。另一部分原因,则是因为模块的生命周期设计好像已经不满足使用需要。之前的模块生命周期是从“Account初始化”到“Account已注销”,所以可以看出在这时机之外肯定还有逻辑。放在以前这不是个大问题,刚启动还不等“Account初始化”就要执行的逻辑哪有那么多。而现在不一样,再简单的逻辑堆积起来也会变复杂。此时,在模块生命周期外的逻辑基本上只能放主工程。

此外的问题,模块边界破坏、基础工程中心化,都是代码持续劣化的帮凶。

总之在模块化上我们忽视了一些重要的问题,必须重塑。

重塑模块化

重塑模块化,我们分解为三个目标:

  • 改变通信方式
  • 重新设计模块
  • 约束代码边界

改变通信方式

前面讲过,我们使用Event总线作为模块间通信的媒介,这种设计很常见。然而当回顾整体代码时能发现,Event并非所有通信需要的***形式。它的特点适合一对多的广播场景,依赖关系弱。一旦遇到需要一组业务接口时,用Event写起来那是十分痛苦的。也正因如此,这种情况下大家都跳过了Event的使用,直接将代码下沉到了基础工程,共享代码,进而导致基础工程的不断膨胀。

所以选个合适的通信方式很有必要,我们希望兼顾考虑开发的便利性和协议的约束性。

Event不合适。协议通信如何?

我们理解的协议通信,是指跨平台/序列化的通信方式,类似终端和服务器间的通信或restful这种。现在这种形式在终端内很常见了。协议通信具备一种很强力解耦能力,但也有不可忽视的代价。无论什么形式的通信,所有的协议定义需要让通讯两方都能获知。通常为了方便会在某个公共区域存放所有协议的定义,这情况和Event引发的问题有点像。另外,协议如果变化了,两端怎么同步就变得有点复杂,至少要配合一些框架来实现。在一个应用内,这样会不会有点复杂?用起来好像也不那么方便?更何况它究竟解决多少问题呢。

所以我们想要简单点。经过权衡,我们决定用模块提供“SDK”的方式作为它与其他模块进行通信的手段。

通常“SDK”提供的是什么,是接口 + 数据结构。这种方式好处明显:实现简单也能解决问题,IDE容易补全、调用接口方便,不用配合工具,协议变化直接反映在编译上,维护接口也简单了。

其实想想,用协议的方式在终端内作为通信手段,开发效率低,也容易出错。因此可能会诞生各种框架和工具来提升这里损失的效率。到头来,是不是大家都实现了一套类似RPC这样的封装。其实本地的通信,能用接口就挺好,不能用的时候,再用协议封装也来得及。

确定了方案,实现起来就很简单。我们的注册方式和接口访问都很简单。用接口注册,再用接口访问,不暴露实现细节。如下图。

图4 - 注册接口

图4 - 注册接口

图5 - 访问接口

图5 - 访问接口

接下来,怎么暴露接口更方便?

模块暴露“SDK”的方式无非就是新建个“SDK”工程,剥离接口和数据结构到该工程里面,然后让其他模块引用编译。但这样有点麻烦,能不能再方便点?

当然有办法。我们实现了另一种接口暴露的形式——“.api化”。

使用方式和思路都很简单。对于java文件,将工程里想要暴露出去的接口类后缀名从“.java”改成“.api”,就可以了。

而且并不只是java文件,其他文件如果也想暴露,在文件名后增加".api”,也一样可以。

图6 - “.api化”

图6 - “.api化”

当然,要让工程支持这样的方式,gradle文件肯定会有一点改变。

settings.gradle

build.gradle

图 7 - settings.gradle & build.gradle

图 7 - settings.gradle & build.gradle

就这样,可以说暴露接口变得非常容易,不用担心实现类也被人引用到。而它的实现原理也相当简单:自动生成一个“SDK”工程,拷贝.api后缀文件到工程中就行了,后面其他工程依赖编译的只是这个生成的工程。简单好用。

还有个细节,如果想编辑.api后缀的java文件,为了能让Android Studio继续高亮该怎么办?可以在File Type中把.api作为java文件类型。

图8 - 设置File Types

图8 - 设置File Types

重新设计模块

要把模块重新设计,还要做好几件事。首先,消灭代码经常下沉的“三不管区域”——基础工程。这意味着原来的模块要把之前下沉的代码重新认领回去。

图9 - 分层结构改造

图9 - 分层结构改造

为了巩固替代基础工程的mmkernel层,不被滥用为新的代码堆放处,顺便还要解决中心化问题。就必须强化它的职责和设计。

mmkernel结构可以很通用的定义为CoreAccount/CoreNetwork/CoreStorage三个部分,分别提供了核心账号状态(初始化、注销)、网络状态回调(链接建立)、存储状态生命周期(db创建、销毁、用户存储路径切换、sdcard挂起)。

图10

图10

再然后是生命周期问题,我们需要重新设计正确的生命周期。

之前讲过,我们的模块生命周期大体上只有“Account初始化”和“Account注销”两个阶段。这已经不够用了。

所以扩大模块的生命周期,就给了模块实现各种代码需要的时机,才能避免大家往主工程塞代码。

图11

图11

实现新的生命周期是一个正确的选择,同时产生了解决另一个问题的机会——复杂的启动流程。

要知道主工程的代码一部分原因是启动流程堆积造成的,逻辑多了代码自然多。随之而来的问题就是代码多了,逻辑也就跟着复杂起来。微信的初始化逻辑是顺序排列在一起并从上到下执行,某种情况下还会异步启动。当程序启动流程比较复杂时,这样的代码会产生“隐性依赖”的问题。“隐性依赖”顾名思义就是:原本并应该存在依赖的代码,随着版本的迭代逐渐产生了依赖,而且还不明显。这样的情况会让情况恶化,大家只敢往里面堆代码,但却不敢“乱动”。

所以重新设计的模块应该要彻底避免这些问题。

我们重新定义了模块的生命周期,将模块的生命周期延长到应用启动和退出。而后,每个模块都可以定义一个Plugin类,作为模块的“支柱”或“起点”。作为解决初始化问题的手段,它具备几个主要阶段:dependency()、configure()、execute()

图12 - plugin初始化的几个阶段

图12 - plugin初始化的几个阶段

dependency()阶段,用于设置需要依赖的其他Plugin,当然提供那个Plugin的别名接口类就可以了。

图13 - 设置dependency

图13 - 设置dependency

依赖阶段我们会生成整个模块的依赖树。这与编译时的依赖不同。通常的依赖关系是分为两种的,一种是类型依赖也就是编译期依赖,需要被依赖模块提供具体类型才能编译通过;另一种依赖则是运行期的逻辑依赖或数据一致性依赖,当一个模块用这种方式依赖另一个模块,就意味着,前者的执行要依赖后者执行已完成,通常是为了数据准备妥当或保证所需服务已被注册。

显性的运行期依赖把之前启动逻辑的“隐性依赖”完全暴露在阳光之下,改启动逻辑不用提心吊胆。

图14 - 依赖关系树状图

图14 - 依赖关系树状图

configure()阶段,该阶段是根据之前的依赖树遍历执行。通常用于初始化一些数据配置、注册IService服务、向前面依赖的模块注册一些回调等等。

此外这个阶段还有额外的作用是插入BootTask,用于后面execute()阶段的执行。

图15 - configure阶段

图15 - configure阶段

execute()阶段,为了改变启动流程不清楚的情况,强调启动逻辑之间的依赖关系,我们现在将每个要执行的启动步骤封装为BootTask。前面的configure阶段时,我们可以将BootTask插入到通过dependency()得到的依赖树。每个Plugin同时也是一个BootTask,也因此拥有execute()接口。最终得到了包含所有BootTask的启动树,将遍历执行所有节点执行execute()。

 

图16 - BootTask

图16 - BootTask

独立使用BootTask的方式并不十分常见,通常Plugin本身的execute已经够用。不过在一些通用型组件初始化尝试会需要用到,如某些给某个全局使用的预加载资源提前初始化的逻辑。

为何设计configure()和execute(),这可以理解为“收集任务”和“执行任务”的两个阶段。另外这样的抽象还可以实现从外部调度execute的执行线程,将启动逻辑和启动异步代码分开。顺便解决了,原来异步启动代码混乱不堪的情况。

约束代码边界

从之前的经验看,要想约束好代码的边界不被破坏,编译上的隔离是唯一法宝。

除了工程和工程之间的分割,在工程的内部如果也能实现约束代码就更好了,算是将问题扼杀在摇篮里。之前通常的做法是以module工程为单位的相互分离,但在工程内部并不限制代码相互引用。所以为了规范代码,常能看到用包名作为约定,区分内部功能职责,靠约定维持解耦。随着时间推移,很快就能发现包名约定作为约束太弱了,在快速迭代的代码上很难一直维持下去。不管怎么样解决,总要通过一些手段审查代码引用的对不对。感觉有点防不胜防。为此,我们实现了一种简单易用、粒度更细的工程组织结构——pins工程结构

图18 - code-check

图18 - code-check

这样的工程组织形式的两个明显好处:

  • 约束代码粒度和小代码边界的利器

粒度极小,一个pin工程也许只有一个源文件,只要它能表达一个独立职责。对于任何一个模块,从内部约束自己的功能结构,是对整体代码边界约束的极大补充。以前面插的结构为例,一个gallery业务可能提供了几种不同的产品功能,以及支撑能力。那么将其相互独立的代码进行区分,避免混杂,就会显得十分必要。清晰的结构,意味着后期维护成本的降低和开发效率的提高,留下了灵活性。

  • 避免的超量module的创建,轻量

pins工程某种程度上能减少一些粒度太小的module工程,也一定程度的缓解太多module工程时的gradle编译性能问题。

至此,我们基本完成了重塑模块化的设计目标,解决掉很多之前没有考虑的问题。算是模块化的加强版。另外设计是一方面,拆分解耦原来代码以及迁移还是另一回事,这个过程也是十分艰难和枯燥这里就不细讲了。接下来想办法看看重构的效果。

看看效果

重新设计的模块化加上代码的重构。我们终于能满足之前硬件同学的需要。同时一并解决许多拖欠的问题。

在编译上,整体编译速度会因为module增多而下降一些。但拆分module之后,却能显著加快单工程增量编译的速度。和之前相比,一行代码的增量编译耗时能减少60%。

除了满足需求外,架构设计的效果并不好量化,不过我们尝试用一个demo来说明。

WeChat nano

基于前面介绍过的轻量的微信内核mmkernel层,再配合一个不包含界面的基础聊天模块和Auth模块,可以在短时间里开发出一个及精简版本的微信——WeChat nano。

图19 - WeChat nano

图19 - WeChat nano

模拟这个console的界面是单独开发的,时间的大头都花在这上面。

它的效果不错:

  • 可以让安装包大小缩减到3.5M,大概是完整版本的10%
  • 能大幅减小内存占用,约占用完整版本的25% (注:只计算应用相关有不同的部分PSS)

大概就是这样。

下篇:http://zhuanlan.51cto.com/art/201708/547813.htm

原文链接:https://www.qcloud.com/community/article/441423  作者:carlguo

【本文是51CTO专栏作者“腾讯云技术社区”的原创稿件,转载请通过51CTO联系原作者获取授权】

戳这里,看该作者更多好文

 

责任编辑:武晓燕 来源: 51CTO专栏
相关推荐

2017-08-11 16:10:36

微信Android实践

2017-05-18 11:43:41

Android模块化软件

2013-08-20 16:45:22

重构Web App模块化

2015-07-02 13:21:44

模块化数据中心

2016-12-14 14:50:26

CSS预处理语言模块化实践

2010-02-03 09:01:01

Java动态模块化

2019-08-28 16:18:39

JavaScriptJS前端

2021-10-11 09:51:37

模块化UPS架构

2017-02-13 18:46:38

Android模块化组件化

2017-06-09 10:06:54

微信小程序架构分析

2016-11-08 20:31:19

同方服务器模块化

2023-06-28 08:12:49

Python代码重构

2022-01-10 08:43:25

CanonicalSnap应用Linux

2021-12-24 07:10:36

架构分层模块化

2017-07-11 11:02:03

APP模块化架构

2013-08-20 15:31:18

前端模块化

2017-05-18 10:23:55

模块化开发RequireJsJavascript

2020-09-17 10:30:21

前端模块化组件

2018-09-18 09:30:17

微信热补丁Android

2022-03-11 13:01:27

前端模块
点赞
收藏

51CTO技术栈公众号