腾小云导读
今年是 QQ 空间诞生的第十八年,空间客户端团队也在它十八岁生日前夕完成了架构升级。因为以前不规范的多团队协同开发,导致代码逐渐劣化,有着巨大的风险。于是 QQ 空间面对庞大的历史债务,选择了重构升级,不破不立。这里和大家分享一下在重构过程中遇到的问题和解题思路,欢迎阅读。
目录
1 空间重构项目的背景
2 为什么要重构
3 空间的架构是如何崩坏的
4 架构的生命力
5 渐进式重构如何实现
6 如何保证架构的扩展性与复用性
7 如何降低复杂度并长期可控
8 如何防止劣化
9 性能优化
10 项目重构成果总结
11 展望
18年前,QQ 空间上线,迅速风靡全网,成为了很多人的青春回忆。18年后的今天,QQ 空间的生命力依然强劲,是很多年轻用户的首选社交平台。
而作为最老牌的互联网产品之一,QQ 空间的代码也比较陈旧,代码运行环境复杂,维护成本高,整体架构亟需一场升级。
作为一个平台型的入口,空间承担了为很多兄弟业务引流的责任,许多团队在空间的代码里协作开发。加上自身多年累积的功能迭代,空间的业务变得非常复杂。业务的复杂带来了架构的复杂,架构的复杂意味着维护成本的升高。多年来空间的业务交接频繁,多个团队接手。交到我们团队手上时,空间的代码已经一言难尽。
这里先简单介绍一下空间的业务形态:空间目前主要的入口是在手 Q 里,我们叫做结合版。同时独立版的空间 App 还在维护(没错,空间独立 App 仍然还有一批忠实观众)。可以看到,空间有一套独立于手 Q 之外的架构,结合版与独立版会共用大量技术组件和业务组件。
空间是一个祖上很阔的业务,代码量非常庞大,单统计结合版的代码,就超过了150w 行。同时空间的代码运行环境也极为复杂,涉及5个进程和2个插件。随着频繁的交接和多团队的协同开发,空间的代码逐渐劣化,各项代码质量的指标几乎都在手 Q 里垫底。
空间的代码成了著名的原始森林 - 进得去出不来。代码的劣化导致历史 bug 难以收敛,即使一行代码不改,每个版本也会新增历史 bug30+。
面对如此庞大的历史债务,空间已经到了寸步难行,不破不立的地步,重构势在必行。所以,借着空间 UI 升级的契机,空间团队开始空间历史上最大规模的一次重构。
跳出棋局,站在今天的角度回头看,可以发现空间的代码是个典型案例,很好地展示了一个干净的架构是如何逐步劣化的。
结合版与独立版涉及大量的代码复用,包括组件、页面和跨 App 的复用等。但由于前期架构扩展性不高,导致异化的业务代码无处安放,开始侵入底层技术组件。底层组件代码开始受到污染。
空间是个平台型的业务,广告、会员、游戏、直播、小世界等团队都会在空间的代码里开发。由于没有做好代码隔离,各团队的代码耦合在一起,各写各的。同时由于缺乏编程范式,同一个类中的代码风格迥异。破窗效应发生,污染开始扩散。
空间的业务逻辑本身就很复杂,代码的劣化使其复杂度暴增,后续接手团队已有心无力,只能缝缝补补又三年,恶性循环。
最后陷入怪圈: 代码很乱但是稳定,开发道理都懂但确实不敢动。
以空间的 Feeds 流为例,最开始的架构思路是很清楚的,核心功能在基类实现,上层业务可以低成本地开发一个新的 Feeds 流页面。同时做了很多动态化和容器化的设计,来满足迭代效率。
但后续的需求迅速膨胀,异化出18种 Feeds 流场景,单 Feeds 流可能出现60多种卡片。这导致基类代码与 Feed View 中的代码迅速膨胀。同时 N 个团队在同一批代码中开发,代码行数和圈复杂度逐渐劣化。
痛定思痛,在进行空间重构前的首件事情就是总结经验,避免重蹈覆辙。如何保证这次重构平稳落地并且避免后续每三年一重构?
我们总结了四点:
渐进式重构:高速公路换轮胎,如何平稳落地? 提高扩展性和复用性:是否能低成本迁移到其他业务,甚至是其他 App? 复杂度长期可控:n 个团队跑来做两年需求,复杂度会不会变高? 做好防劣化:劣化代码被引入,能否快速发现? |
空间的重构都围绕着这四个问题来进行。
作为一个亿级日活的业务,空间出现线上问题很容易引起大量投诉。高速公路换轮胎,小步快跑是最合适的方式。因此,平稳落地的关键是渐进式重构,避免步子迈得太大导致工作量扩散。
要做到渐进式重构,核心是保证两点:
|
为了实现以上两点,我们基于以下几点来进行改造:
我们并没有立即开始对旧代码进行重写,而是先基于团队的 RFW-Part 框架对老代码进行拆解。Part 自带生命周期,可以保证老代码平移前后的运行逻辑一致。
尽管代码逻辑没有翻新,但大问题被拆解为一个个小问题,我们再根据优先级对单个 Part 进行重构。保证无论重构了多少,空间都是可用状态,能立即上线验证。
RFW-Part 框架后文会有介绍,此处不做展开。
我们彻底抛弃了空间老的技术组件,与团队内部沉淀的 RFWComponent 进行架构融合,同时也积极接入手 Q 统一的 UI 体系。保证开发能专注于业务中间层开发。
在进行业务重构前,我们先还了一部分技术债。包括去插件化、进程统一、工程结构优化和编译优化等。这些工作都在业务重构前完成并上线验证,简化了空间代码的运行环境,提升开发效率,保证了重构工作的敏捷性,达到了针对单点问题快速重构快速验证的目的。
扩展性和复用性是软件工程永恒的话题。空间历史架构并没有很好处理这两点,其他业务接入时难以处理异化逻辑,使异化逻辑侵入底层代码。同时为了强行实现结合版和独立版的代码复用,使不同的场景耦合在一起,互相干扰。
为了提高架构的扩展性和复用性,我们重新设计了空间的架构层级。
为了避免代码跨层级污染,我们对架构的分层比以往更细,隔离做得更严格。
底层技术组件基于 RFW 框架。RFW 中的组件更干净,没有任何业务侵入,能在其他 App 开箱即用。
中间层负责对 RFW 组件和手 Q 运行环境做桥接,并对底层组件进行扩展,实现一些空间相关但与具体场景无关的功能。中间层的代码能在一周之内迁移到其他 App。
RFWComponent 是一线开发在实际业务中沉淀出的一套组件库,目前由空间和小世界团队共同维护。所有组件都经过了线上业务的验证,保证了易用性和扩展性。组件也很完整,开箱即用。
最重要的是,RFW 的核心组件都可由上层注入代理实现,这使其并不依赖于手 Q 的运行环境,也避免了业务侧逻辑入侵底层代码。
目前整套架构已在空间、小世界、频道、基础等团队深度使用。空间也是第一个使用这套架构重构老代码的业务,整个过程非常省心。
什么是 RFW-Part?RFW-Part 是团队内部沉淀的一套页面级的 UI 容器架构,Part 可感知页面的生命的周期,功能在内部闭环。不同 Part 无法感知对方存在,代码是严格隔离的。
但是 Part 是页面级框架,无法解决 Feeds 流列表复杂的问题,Section 架构作为 Part 的补充,主要解决列表以及 ItemView 的拆解问题。其设计思路与 Part 框架一致。
基于 Part 和 Section 架构,我们将空间的代码拆分为了一个个标准的集装箱。代码复杂度和上手难度大大降低。新人内包入职一周便可独立开发,三天就完成了新功能此刻的消息页。
空间80%的流量和功能都集中在好友动态页和个人主页两个 Feeds 流页面,尽管内部已基于 mvvm 分层,但单层内的复杂度仍然过高:
以空间的好友动态页为例,我们将页面不同功能的代码都拆分到一个个 Part 里,Fragment 仅作为一个容器,负责组装自己需要的 Part。
最终页面被拆分为27个 Part,页面代码由6000多行减少到320行。很多 Part 可以直接拿去被其他 Feeds 流页面复用。
经过 Part 的改造,页面级的功能都被拆分为子模块。但 Feeds 流整体作为一个 Part,复杂度仍然过高,我们需要设计一套新的框架,对 Feeds 流中的卡片进一步拆解。
这里先介绍一下空间老的 Feeds 流框架 - Ditto。
Ditto 框架魔改了 Android 原生的布局体系。其将一个卡片按位置分为不同 Area,每个 Area 作为一个容器。不同类型的卡片根据服务端下发的数据在 Area 内部做异化。
而每个 Area 的布局由 json 文件下发,Ditto 框架解析后使用 canvas 自绘,完成显示。
这套架构的优势是动态化能力强,服务端可定义任意样式,但缺点同样明显:
|
为了降低复杂度,我们决定按以下方向优化:
|
和 Part 一样,我们将一个卡片按照功能逻辑拆分为一个个 Section,形成一个 Section 池。不同卡片根据需要组装自己需要的 Section 即可。
Section 的 UI、数据、业务都是内部闭环的。不同 Section 互不感知,保证了代码物理隔离。
每个 Section 会与 ViewStub 绑定,布局可以按需加载。ViewStub 与 Section 是一对多的关系,Section 在查找 ViewStub 前会先去缓存池找,这样实现了多个 Section 修改同一个 View,保证 Section 拆得尽可能细。
上图中各模块的具体职责如下:
Section:某一切片的完整 UI+逻辑; ViewStub:与 Section 一对多,按需加载; Assembler:负责组装 Section,可根据页面异化; SectionManager:绑定数据、分发生命周期; DataCenter:Feeds 相关数据在各页面间的同步; IOC 框架:控制反转,用于 Section 与页面交互。 |
Section 整体的结构图如下:
基于这套 Feeds 流框架,我们完成了历史卡片的梳理和重构:
|
Part 和 Section 之间会有许多通信的需求,比如数据同步,不同模块交互等。为了保证代码隔离不被打破,我们设计了比较完善的通信机制:
页面与 Part:ViewModel + LiveData; Part 与 Part:页面级事件,事件只在 PartHost 内部生效,无需注册与反注册; 页面与页面:DataCenter 数据同步。 |
除此之外,另一种容易打穿架构的元素是异化逻辑。比如同一张卡片在不同的页面需要显示不同效果,比如数据埋点的参数需要从页面最外层传递到 Section。针对这种跨层级通信的场景,我们设计了一套 IOC 框架来完成依赖注入,将异化逻辑拆分到了一个个 IOC 实现类中。
IOC 机制的核心是:View 树回溯 + ViewTag 存储 + 接口中心管理。我们注册时将 IOC 实现类与 View 绑定,查找时基于 View 树来回溯,保证了 O(N) 的复杂度,且可以跨越任意层级。
过去,即使传递一个 pageId 参数,也要一层层传递:
现在,层级再深我们也可以很方便拿到需要的 IOC 实现。
站在用户的角度,其实对重构与否并没有太大感知,用户只关心稳定性是否有下降。如此大规模的重构,一行代码引起的崩溃便能使几个月的努力功亏一篑。我们上线前的首要目标便是保证用户使用不受影响,不求有功,但求无过。
因此,我们在上线前做了很多容灾设计,保证空间的核心功能可用性。
我们在空间的中间层埋了配置,能通过配置下架任意的 Part 或 Section。业务层编写代码时不用再单独为每个小模块添加开关,只要基于框架做好细粒度的拆分即可。
同时,我们做了崩溃保护的设计,保证非核心功能崩溃不会影响核心功能的使用:
|
同时,为了防止性能劣化,我们做了很多性能监控。
针对线上:
|
针对线下:
我们基于 ARTMethodHook 框架,实现对具体 View 耗时的监控,能快速定位到出问题的控件,节约开发定位性能问题的时间。
整体监控体系如图:
实际效果如图:
第一次灰度后,我们尴尬地发现启动速度并没有大幅提升,流畅率甚至发生了降低。因此我们做了首屏启动和流畅度的专项优化:
我们重新梳理了启动流程中的数据处理,在启动前和启动后做了一定优化:
布局异步渲染
我们将首屏启动前,会根据缓存提前计算需要的布局,实现布局异步预加载。同时,为了保证 Context 的正确性,我们 Hook 了 Activity 的启动流程,提前准备好空的 Activity 对象用于异步 inflate,并在启动后绑定真实的 Context。
精准预加载
在首屏启动前读取缓存,提前计算首屏 Feed 对应的 Section 布局并异步加载。
生命周期扩展
扩展 Part 生命周期,各个 Part 的次要功能在首屏展示后初始化。
优化后的效果
空间好友动态页的冷启动速度提升56%,热启动速度提升53%。
经过分析,我们发现列表卡顿的原因集中在两点:
|
解决思路:
边滑边异步 inflate:为了解决频繁创建新 View 的问题,我们在滑动时,会提前计算后面卡片所需的 ViewStub,并提前异步加载好。 自定义组件,降低层级,提前计算高度:列表中部分组件测量性能较差,比如部分嵌套 RecyclerView 的组件,会频繁触发子 RecyclerView 的测量,拉高整体测量耗时。对于这些组件,我们使用自定义组件的方式进行了替换。降低布局层级,并且提前计算高度,设置布局的高度为固定值,防止频繁测量。 |
优化后的效果:完成优化后,空间首页 FPS 完成了反超,相比老版本提升了 4.9%。
从我们 AB 测试的实验数据来看,重构的整体结果是比较正向的,代码质量提升与性能提升带来了业务指标的提升,业务指标的提升也带来广告指标的提升。
空间的代码历史悠久,错综复杂,使得空间业务在很长一段时间都处于维护状态,难以快速开发新的需求。最大的三个模块是压在空间业务上的三座大山:Feeds 流、相册和发表。通过这次架构升级,我们完成空间底层架构的焕新,完全重写了最复杂的 Feeds 流场景,同时相册模块也已经重构了一半。等剩余模块重构完成,空间的祖传代码就被全部重写了。面向未来,我们也能够更迅速地支撑新需求的落地,让十八岁的 QQ 空间焕然新生,重新上路。欢迎转发分享~
-End-
原创作者|尹述迪
QQ 空间已18年,你有什么关于QQ空间的回忆杀?欢迎在腾讯云公众号评论,我们将选取1则最有意义的评论(请使用加密对话),送出腾讯云开发者-棒球帽1个(见下图)。7月27日中午12点开奖。