当前位置: 首页 > 科技观察

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

时间:2023-03-15 23:22:52 科技观察

微信安卓架构历史微信安卓诞生之初,采用了通用的分层结构设计。这种结构简单明了,一直沿用至今。这是微信架构的v1.x时代。图1——架构进化到微信架构v2.x时代。随着业务的快速发展,Android2.3之前的消息通知不及时、webview内存泄露的问题开始浮出水面。随着代码、内存、apk的体积都越来越大,占用的系统资源也越来越多,导致微信进程很容易被系统回收。于是,微信开始转向多进程架构。独立的通信进程保持了长连接的稳定性,独立的webview进程也屏蔽了内存泄露带来的问题。时间继续推进,我们也遇到了65535问题和LinearAlloc问题。这个时候,微信已经具备了朋友圈、摇一摇、附近的人等诸多功能,将核心功能与其他业务模块分离就显得越来越重要。为此,微信启动了第三次架构改造(v3.x)。我们将各种产品功能解耦,拆分成相互独立的p_xxx项目。这是微信首次对模块化架构进行重构。经过几个月的努力,微信拆解了几十个p项目,都是通过基础组件接入网络、存储等服务,相互独立并行。全新的p-engineering架构支持微信更快的业务发展。多分支开发模型的完善,可以支持多个分支、多个团队的并行开发。图2-架构图微信原本好的架构怎么又出问题了?上次架构以来的两年时间里,微信安卓基本没有大的架构变化。随着gradle的编译和git的多分支并行开发,微信模块项目数量不断增加,支持游戏、支付等主要功能。可以说,原建筑在这一时期发挥了很好的作用。然而,随着代码不断膨胀,一些问题开始出现。第一个问题是基础工程libnetscene和libplugin。基础项目一直处于不断扩张的状态,而主体项目也在不断做大。同时,基础工程存在中心化问题。很多业务存储类都依附于一个核心类,久而久之这个类就变得不可读了。另外,为了顺利切换到gradle,避免结构改动太多,模块太多,我们把所有的项目都连接到一个模块上。在没有编译隔离的情况下,模块之间的代码边界有所恶化。虽然开发了工具来限制紧随其后的模块之间的错误依赖关系,但这段时间的影响已经显现。在以上种种问题之下,很多模块已经不能再称得上“独立”了。所以当我们重新审视代码架构时,之前良好的模块化架构设计已经逐渐发生了变化。图3——架构的渐变“有病就怕死”。当我们还在犹豫要不要重构的时候,硬件同学向我们提出了他们的需求。希望将微信Android代码移植到微信相册等产品中。这样我们就可以快速跟进微商的配套组件、协议、安全、后台服务等能力,而且代码尽量精简,可以选择模块定制,可以移植模块实现原型尝试。但就之前的情况来看,微信一时难以满足。这已经解决了,它必须被重构。于是我们回过头来仔细看了之前的设计,想知道问题是怎么来的。问题是首先从哪里寻找代码膨胀的原因。查看基础项目的代码,我们可以看到除了存储、网络等符合设计初衷的支撑组件外,业务相关的代码也相当多。这些代码是膨胀的根源。但是代码是从哪里来的呢?你必须把它放在这里吗?一切不合理的事情背后都有一个逻辑。在之前的架构中,我们广泛使用Event事件总线作为模块间的通信方式,基本上是唯一的方式。当使用Event作为通信媒介时,自然要有一个地方来定义它,这样所有的模块都可以知道Event结构是什么。这时,基础项目似乎是存储Events的唯一选择——Event定义放在基础项目中;那么,当某个模块A要使用模块B的数据结构类时,怎么办呢?下沉班级到基础项目Engineering;当模块A要使用模块B的接口返回数据时,Event似乎不合适?然后把代码下沉到基础工程中……于是越来越多的代码“自然而然”下沉到基础工程中。再来看主项目,其扩容的原因各不相同。经过分析,可以确认的是,首先,作为主业,还有待发展,扩张在所难免。缺乏适当的内部改造暂时不是问题的核心。另一部分原因是模块的生命周期设计似乎已经不能满足使用的需要。之前的模块生命周期是从“账户初始化”到“账户已注销”,由此可见一定有超越这个时序的逻辑。在过去,这不是什么大问题。在“账户初始化”开始之前,需要执行的逻辑非常多。但是现在不一样了,再简单的逻辑堆起来也会变得复杂。这时候模块生命周期之外的逻辑基本上只能放在主工程中。其他问题、模块边界违规和基础工程集中化,都是代码持续退化的帮凶。总之,我们在模块化中忽略了一些重要的问题,必须重塑。ReshapemodularityReshapemodularity,我们将其分解为三个目标:改变通信方式重新设计模块约束代码边界改变通信方式前面提到,我们使用Eventbus作为模块之间的通信媒介,这是一种很常见的设计。但是,在回顾整体代码时,可以发现Event并不是满足所有通信需求的最佳形式。其特性适用于一对多的广播场景,依赖性弱。一旦遇到需要一套业务接口,用Event写起来就很痛苦。也正是因为如此,大家在这种情况下就跳过了Event的使用,直接将代码下沉到基础项目中,共享代码,从而导致基础项目的不断扩容。因此,有必要选择合适的沟通方式。我们希望兼顾发展的便利性和协议的约束性。事件不合适。协议通信怎么样?我们理解的协议通信是指跨平台/序列化的通信方式,类似于终端与服务器或restful之间的通信。这种形式现在在终端中很常见。协议通信具有很强的解耦能力,但也有不可忽视的代价。无论采用何种形式的通信,双方都需要知道所有协议定义。通常,为了方便,所有协议的定义都存放在一个公共区域,这类似于Event引起的问题。另外,如果协议发生变化,两端如何同步就变得有点复杂,至少需要配合一些框架来实现。在应用程序中,这会不会有点复杂?好像用起来不是很方便?更不用说它解决了多少问题。所以我们想让事情变得简单。经过权衡,我们决定使用模块提供“SDK”作为与其他模块通信的手段。通常“SDK”提供的是接口+数据结构。这种方式的优点很明显:实现简单就可以解决问题,IDE很容易完成,接口调用方便,不需要配合工具,协议变化直接反映在编译中,维护接口也简单。其实想想,在终端使用协议作为通信手段,开发效率低,而且容易出错。因此,可能会诞生各种框架和工具来改善这里丢失的效率。最后大家都实现了RPC之类的封装了吗?其实对于本地通讯来说,使用接口还是不错的。不能用的时候,还有时间用协议封装。方案一旦确定,实施起来就非常简单。我们的注册方式和接口接入都非常简单。使用接口注册,然后使用接口访问,不暴露实现细节。如下所示。图4-注册界面图5-访问界面接下来,如何暴露界面更方便?模块暴露“SDK”的方式无非就是新建一个“SDK”工程,将接口和数据结构剥离到工程中,然后让其他模块引用它编译。不过这样有点麻烦,能不能方便点呢?当然有办法。我们实现了另一种形式的接口公开——“.apiization”。使用方法和思路都很简单。对于java文件,只需将项目中要暴露的接口类的后缀名由“.java”改为“.api”即可。而且不仅仅是java文件,如果你想暴露其他文件,你可以在文件名后面加上“.api”,同样可以。图6-“.apiization”当然,要使项目支持这种方式,必须对gradle文件稍作改动。settings.gradlebuild.gradle图7-settings.gradle&build.gradle这样一来,可以说暴露接口变得非常容易,不用担心实现类被引用了。而它的实现原理也相当简单:自动生成一个“SDK”工程,只需将.api后缀的文件复制到该工程中,其他工程将只依赖这个生成的工程进行编译。简单易用。还有一个细节。如果要编辑后缀为.api的java文件,应该怎么做才能让AndroidStudio继续高亮呢?您可以使用.api作为文件类型中的java文件类型。图8-设置文件类型重新设计模块要重新设计模块,需要做一些事情。首先,剔除代码常下沉的“三无区”——基础工程。这意味着原始模块必须回收以前沉没的代码。图9-层次结构转换为了巩固取代基础工程的mmkernel层,它不会被滥用为新的代码转储,顺便说一句,必须解决中心化问题。有必要加强其职责和设计。mmkernel结构体一般可以定义为三部分:CoreAccount/CoreNetwork/CoreStorage,分别提供核心账户状态(初始化、注销)、网络状态回调(链接建立)、存储状态生命周期(db创建、销毁、用户存储路径))开关,SD卡挂起)。图10再就是生命周期问题,我们需要重新设计正确的生命周期。前面说过,我们的模块生命周期一般只有两个阶段:“账户初始化”和“账户注销”。这已经不够了。所以,扩展模块的生命周期,让模块有机会实现各种代码,防止大家往主工程里塞代码。图11.实现新的生命周期是一个正确的选择,同时也为解决另一个问题——复杂的启动过程创造了机会。要知道主工程的部分代码是启动过程积累造成的。有更多的逻辑和更多的代码。随之而来的问题是代码变多了,逻辑变复杂了。微信的初始化逻辑是按顺序排列的,从上到下执行,在某些情况下会异步启动。当程序启动过程比较复杂时,这样的代码会造成“隐式依赖”的问题。“隐式依赖”顾名思义:原来和应该有依赖的代码,随着版本的迭代,依赖逐渐产生,而且并不明显。这样的情况会使情况变得更糟。大家只敢往里面堆代码,不敢“打扰”。所以重新设计的模块应该完全避免这些问题。我们重新定义了模块的生命周期,将模块的生命周期扩展到应用程序的启动和退出。然后每个模块可以定义一个插件类作为模块的“支柱”或“起点”。作为解决初始化问题的手段,它主要有几个阶段:dependency(),configure(),execute()图12-插件初始化的几个阶段dependency()阶段用来设置其他需要依赖的Plugins,当然提供Plugin别名接口类就可以了。图13-设置依赖阶段我们将生成整个模块的依赖树。这与编译时依赖性不同。通常的依赖分为两种,一种是类型依赖,即编译时依赖,需要被依赖的模块提供特定的类型才能编译;另一种是运行时逻辑依赖或数据一致性依赖,当一个模块以这种方式依赖另一个模块时,意味着前者的执行依赖于后者的执行完成,通常用于数据准备好或确保所需的服务已经注册。显式运行时依赖,将之前启动逻辑的“隐式依赖”完全暴露给sun,不用担心更改启动逻辑。Figure14-DependencyTreeDiagramconfigure()阶段,根据之前的依赖树遍历执行。通常用来初始化一些数据配置,注册IService服务,注册一些回调给之前依赖的模块等等。另外,这个阶段还有一个额外的功能,就是插入BootTask,用于后面execute()阶段的执行。图15-configurestageexecute()阶段,为了改变启动流程不清晰,强调启动逻辑之间的依赖关系,我们现在将每个启动步骤封装为一个BootTask来执行。在前面的configure阶段,我们可以将BootTask插入到通过dependency()得到的依赖树中。每个Plugin也是一个BootTask,因此有一个execute()接口。最后得到包含所有BootTasks的启动树,遍历所有节点执行execute()。图16-BootTask独立使用BootTask并不常见,通常Plugin本身执行即可。但是,它将用于一些通用组件初始化尝试,例如全局预加载资源的一些预初始化逻辑。为什么要设计configure()和execute(),可以理解为“收集任务”和“执??行任务”两个阶段。另外,这样的抽象还可以从外部调度execute的执行线程,将启动逻辑和启动异步代码分离。顺便把原来的异步启动代码搞乱了。约束代码边界从以往的经验来看,编译隔离是约束代码边界不被破坏的唯一法宝。除了项目与项目之间的划分,如果约束代码也能在项目内部实现就更好了,也算是把问题扼杀在摇篮里。以前常见的做法是以模块项目为单位相互分离,但是对于项目内的代码相互引用没有限制。因此,为了规范代码,经常会看到使用包名作为约定来区分内部函数职责,通过约定来保持解耦。随着时间的推移,很快就会发现包名称约定对快速迭代代码的约束太弱而无法维护。不管怎么解决,总要通过一些手段检查代码引用是否正确。感觉有点不知所措。为此,我们实现了一种易于使用、粒度更细的工程组织结构——pins工程结构图18——code-check等工程组织形式的两个明显好处:约束代码粒度和小代码的利器boundaries粒度极小,一个pin项目可能只有一个源文件,只要能表达一个独立的职责即可。对于任何一个模块来说,从内部约束自己的功能结构,都是对整体代码边界约束的一个很好的补充。以上面插入的结构为例,一个画廊业务可能会提供几种不同的产品功能和支持能力。那么就非常有必要区分它们各自独立的代码,避免混淆。清晰的结构意味着降低维护成本和提高开发效率,留下灵活性。为了避免创建过多的模块,轻量级的pins项目可以在一定程度上减少一些粒度过小的模块项目,也可以缓解模块项目过多时的gradle编译性能问题。至此,我们基本完成了重塑模块化的设计目标,解决了很多之前没有考虑到的问题。它是模块化的增强版本。另外,设计是一回事,原代码的拆解解耦和迁移又是另一回事。这个过程也是非常辛苦和枯燥的,这里就不赘述了。接下来想办法看看重构的效果。看看重新设计的模块化加上代码重构的效果。终于可以满足以前硬件同学的需求了。同时,解决多方欠费问题。编译方面,整体编译速度会因为模块的增多而降低。但是拆分模块后,可以显着加快单个项目的增量编译。与之前相比,一行代码的增量编译时间可以减少60%。除了满足需求外,架构设计的效果也不容易量化,但我们尝试用一个demo来说明。WeChatnano是在前面介绍的轻量级微信内核mmkernel层的基础上,加上基本的聊天模块和不包含接口的Auth模块,可以在短时间内开发出一个精简版的微信——WeChatnano。图19-微信nano模拟console界面单独开发,大部分时间花在上面。效果不错:可以将安装包大小缩小到3.5M,大约是完整版的10%。可以大大减少内存占用,占完整版的25%左右(注意:只计算与应用相关的不同部分的PSS)仅此而已。下一篇:http://zhuanlan.51cto.com/art/201708/547813.htm原文链接:https://www.qcloud.com/community/article/441423作者:carlguo【本文为专栏作者“腾讯《云技术社区》原创稿件,转载请联系原作者获得授权】点此查看该作者更多好文章