向工程腐化开炮 | 治理思路全解

向工程腐化开炮 | 治理思路全解
文章图片

作者:刘天宇(谦风)

系列文章回顾《向工程腐化开炮 | proguard治理》《向工程腐化开炮 | manifest治理》《向工程腐化开炮:Java代码治理》《向工程腐化开炮|资源治理》《向工程腐化开炮|动态链接库so治理》。本文为系列文章最后一篇文章,聚焦于整体治理思路,方案设计,以及背后的思考与取舍。
工程质量是任何一个产品,能够快速、高效、稳定地进行业务功能迭代的基础,也是给用户带来良好产品使用体验不可忽视的因素,更是任何一位优秀工程师的期望和卓越追求。而工程腐化,却是任何一个大型工程都不得不面对的问题,其广泛而细碎,隐藏在不易被察觉的“角落”,对工程方方面面均有所影响。
工程腐化与工程本身相伴相生,贯穿工程生命周期的每一阶段,时间、人、代码、流程、规则,任一因素的变化都会导致腐化发生,从觉察到修补、系统性分析到应对方案制定、再到坦然接受与常态化可持续治理,本文对此逐一道来。
源起 在一个工程趋于成熟之前,腐化问题深深隐藏于代码中,一般会明显降低研发效率,但是引发的线上问题却并不频繁,因此很容易当成单点问题进行修复。但是随着腐化程度加剧,同一类型问题出现的频率越来越高,才逐渐嗅到淡淡的“腐化味道”,也因此才有了后续一系列的分析、方案设计、工具&平台研发,以及治理实践。我们来看下面这张图,可能很多研发同学会有切身感受:

1.1 嗅到腐化味道 笔者在Android架构领域有多年经验,直接负责或者间接参与了稳定性、启动性能、包瘦身、工程效能、新版本os适配等多个方向,随着各治理项的不断深入以及时间的推移,遇到过各种各样的问题,例如:冲突资源导致即使代码无变化,多次构建后的apk中也会出现资源值不一致,最终引发线上问题;java代码修改导致不兼容调用,最终引发线上java异常;线程随意使用,缺乏统一管控,一方面性能堪忧,另一方面过多线程数量超过某些设备的自定义限制,从而引发OOM异常;无用代码&资源&功能模块,导致包体积持续增加;apk构建耗时越来越长,严重影响研发效率;这样的例子,可以举出几十项,此处不再一一赘述。
向工程腐化开炮 | 治理思路全解
文章图片

当尝试以一个整体的视角去看待和思考这些问题时,才发现背后隐藏着的强大敌人——工程腐化。工程腐化,简单来说就是无用/冗余/不合理代码的持续堆积,从而更容易出问题,出了问题更难定位,而且迭代越快腐化越快,即使无任何迭代,随着新版本os上市、隐私合规监管态势日趋严格等等外部环境变化,都会导致存量代码出现问题。接下来,深入到研发迭代过程,看看腐化自何而来。
1.2 分析腐化产生 前面讲到有很多因素会导致工程腐化产生,但最源头因素只有两个:时间和人。时间意味着工程外部环境的变化,例如:目标设备中os版本号会不断升级、研发工具链、IDE等迭代更新,一份静止不动的工程代码,会随着时间的推移慢慢腐化。相比时间对工程腐化带来的慢变性影响,由人主导的快速工程迭代,才是工程快速腐化的最大来源。既然如此,我们就重点看看一个app版本迭代&交付过程中,都有哪些角色参与,其核心诉求分别是什么,工程腐化又如何在这样的“土壤”中不断积累。
向工程腐化开炮 | 治理思路全解
文章图片

上图是一个典型的移动端app版本迭代&交付过程,对于大型app和研发团队,可能每个角色都有专门的岗位和人来负责,而对于小型app和研发团队,则可能1人分饰多个角色:
  • 产品和设计,负责功能、UI、交互设计,关心的是创意和功能给用户带来的价值,以及视觉和交互的流畅炫酷;
  • 研发和测试,在接到产品需求以及设计稿后,负责代码开发、实现、效果&质量保障,研发和测试同学,往往希望需求和设计一旦确定后不要总发生变化,此外还希望尽可能复用现有的逻辑和功能,对不断推倒重做式的需求和设计有着天然的“抗拒”,最后还希望能多点时间,再多点时间,来保障代码质量和验收效果;
  • PMO和PTM负责版本节奏、管控发布过程,关心整体的需求吞吐量,以及过程和线上质量;
  • 渠道和运营负责将新版本app,通过各种渠道准时交付到用户手中,并通过层出不穷的运营手段,来获取新用户以及用户对app功能使用的全面、快速增长;
  • 在前面这个过程中,安全和法务需要保障app的安全漏洞得到及时解决,隐私合规等相关事项不出现风险性问题。
最终,用户获取或者升级到最新版本app,其核心诉求是这个新版本app“好用吗?好玩吗?”。随之而来的除了用户,还有各方监管&检测机构,在获取到新版本app后,会检查根据当前法律、法规,仔细检查app使用过程中是否存在“违规”现象。
在这样一个app版本交付过程中,可以看到各角色的侧重点并不相同,同时所有角色的诉求最终都要通过代码来承载。工程腐化直接来源于开发者的代码生产活动,开发者本身的意愿、技能和经验,确实会极大影响代码质量,但现代企业级app的功能之复杂,绝不可能所有参与其中的开发者,都能够对app所有代码了如指掌,因此这种对工程或者说代码掌握的局部性,可能是工程腐化产生的更重要因素。
1.3 拆解腐化问题 分析完腐化产生,我们再进一步对Android工程腐化项,进行更细粒度的拆解。从Android工程包含所有“代码”的类型来看,可以分为以下五种:
向工程腐化开炮 | 治理思路全解
文章图片

其中,工程配置是指在apk构建过程中使用到的相关配置,配置内容本身并不会进入到最终apk,这种工程配置腐化,主要是影响工程本身的复杂度,甚至是构建过程耗时,例如大量的proguard配置项。其它四种类型,manifest、java代码、资源、动态链接库so,也是组成apk的所有可能“元素”,自身或者相互之间都可能存在各种各样的腐化问题,直接导致apk稳定性、性能、包大小、UI&功能异常、隐私合规风险等等,或者提高这些问题出现的可能性。
在实际工具开发和治理实践中,也正是按照上述类型实现分而治之。
应对方案 在完成腐化产生分析,以及按类型拆解后,接下来需要制定有效的应对方案。
首先,必须明确并时刻牢记的指导原则是:“用正确的方式,做正确的事,无论简单还是困难”。“正确的事”往往比较容易界定,并达成共识,但是“用正确的方式”却有些困难,因为有时候“不正确的方式”意味着捷径,可以快速取得目标成果,例如:假设我们需要将app中所有线程使用切换到统一线程池实现,有两种方式可以完成,一种是直接使用构建时aop技术对线程调用代码直接进行替换,另一种是建立非统一线程池使用的检测&卡口机制,在保障有效防控增量代码情况下,逐步修改存量代码。显然,第一种方式可以快速达成目标,但是却会增加apk构建耗时,同时如果这个aop处理过程本身,一旦出现问题导致替换不成功,或者替换过程异常终止导致字节码替换不完整,那么又是另一种“工程腐化”。第二种方式无法快速达成目标,但是可以有效止住腐化趋势,并逐步消化存量问题,虽然卡口本身需要日常审批评估,并且存量代码清理也并非一蹴而就,但代码源头上的直接改正,才是解决工程腐化问题的”正确方式“。
2.1 人vs流程 工程腐化来自于人在版本迭代流程中,对工程代码进行的不合理变更,因此,工程腐化治理需要围绕“人”和“流程”来进行。
对于人这个因素,业界已经有非常成熟有效的做法,例如:进行代码review、制定代码规范、定制IDE的Lint规则、持续进行技术培训等,这些都能够提高开发者的代码设计和编码水平,从而在源头减少腐化代码产生。此外,能够潜移默化的提高研发团队整体工程质量和素养,对工程质量带来更为全面的提升。但是,这种方式有一些问题,也绝不能忽视:参与到一个工程的开发者,其技术认知、水平、理解能力并不一致,这些规范/规则的执行效果难以保障,带来的潜在成本可能也会很高。
对于工程腐化来讲,完全依靠这些围绕人的方案,不确定性非常高,而腐化的防治需要一种确定性的机制来“守好这道门”,同时,防治本身需要做到较低的成本,因此,我们将重点放在流程上面。流程具有客观、固定、有保障的特性,一方面以全面的apk检测分析技术为核心,对腐化项精准定位并在流程关键节点部署卡口,及时感知,有问题就地处理,从而实现零新增。另一方面,对于存量腐化项,提供多样化的辅助工具,降低整改风险和成本,提高效率。冰冻三尺,非一日之寒,因此解冻的过程,也不能够搞成大跃进式的清理模式,而是需要在尽量不影响日常研发活动前提下逐步迭代,最终实现存量清零。
向工程腐化开炮 | 治理思路全解
文章图片

围绕人和流程的这些应对方案,并不是二选一而应该是相辅相成,前者重在从源头全面减少腐化项产生,后者重在无差别的阻止其中能够有效检测的腐化项进入到最终apk,同时增强开发者防腐化意识,并促进代码Review、代码规范等有效执行,从而形成良性循环。
2.2 分析工具 作为核心的apk检测分析技术,到底包含哪些具体的能力呢?来看下面这张图:
向工程腐化开炮 | 治理思路全解
文章图片

上图是当前检测分析技术汇总,可以分为冗余冲突、关键配置、引用关系、辅助提效四个类型。前三种类型直接对应具体的腐化项,最后一种则是帮助开发者在日常研发过程中,更好的定位和分析问题。对于每一项检测能力,此处先不详述,在“向工程腐化开炮”系列文章中,分别与具体实践相结合进行了相关讲解。
2.3 卡口体系 这些检测能力,是如何与流程相结合的呢,来看下面这个流程卡口示意图:
向工程腐化开炮 | 治理思路全解
文章图片

对于开发/测试同学,在提测、集成、灰度/正式版本发布这些关键节点,都需要进行apk构建,同时,会自动触发已经部署好的各项检测分析。如果是本地打包,检测不通过,会直接构建失败,并在失败原因中,给出相关信息;如果是CI/CD平台打包,卡口结果会以平台页面形式呈现;无论哪种模式,都会中断流程,待研发同学修复问题后,再继续进行。这样,就实现了腐化问题的及时感知,就地修改。
以平台模式为例,每次提交测试/集成时,apk构建都会触发卡口检测,如果有卡口项未通过则阻断流程。卡口结果示例如下:
向工程腐化开炮 | 治理思路全解
文章图片

在具备了这样一套机能力和机制后,我们接下来看看,如何对各类腐化问题进行治理和防控。首先,先明确“模块”这个概念,对工程腐化与治理的影响,以及工具建设和治理实践。
模块治理 一个完整apk的产生,可以认为是一个“拼积木”的过程;每一块积木,都可能包含java代码/资源、Android资源、AndroidManifest文件、动态链接库so、proguard配置,将这些积木按照一定规则拼接,同类元素混合&压缩,即成为最终的apk文件。上述这些“积木”,用更贴近技术的术语来讲,就是模块。模块为功能复用提供可能,也为并行研发模式提供基础,一般来讲,越大型和复杂的工程,其模块化程度也越高。
工程腐化的产生,本质是由功能的复杂度以及代码变更导致,模块化本身虽然会带来一定的腐化问题,但更重要的是,为工程腐化问题治理提供便利。试想一下,一个由上百人划分为十多个团队,共同参与迭代的app,如果都在一个app工程中开发代码,先不说如何解决代码协作,一旦发生腐化问题,如何进行分配本身就是一个极大的挑战。在现实工程领域,模块化程度一般(正常的工程选择)都会随着功能和开发人员的增加而不断提高,在这个前提下,工程腐化治理首先要做的事情,就是要明确知道每一个具体的腐化问题,来自哪几个模块,这是将问题进行分发和处理的前提。接下来,首先会给出模块的分类,然后讲述针对模块开发的几个“辅助分析能力”,以及在此之上的治理实践。
3.1 模块分类 app工程中以外部依赖形式引入的jar/aar,以及与app工程平行的subproject,可能是日常研发过程中接触最多的模块类型,除此之外,Andriod原生还支持其它类型模块。从apk构建视角来看,模块的完整分类图如下:
向工程腐化开炮 | 治理思路全解
文章图片

上图展示了5种模块类型,以及几个维度:在apk构建过程中是否需要经历源码编译、是否在maven仓库中存在,以及可能存在的依赖关系。下面分别进行讲解:
  • app-project有且仅有1个,用于生成apk,包含源代码,因此需要源码编译。可以依赖sub-project、local jar、flat aar、external module;
  • sub-project可以有0或多个,一般与app-project平行,同样包含源代码,可以依赖sub-project、local jar、external module;
  • local jar不能单独存在,java代码已经以编译后的class字节码形式存在,不能依赖其它类型模块;
  • flat aar是Android原生提供的一种引入非maven中aar的方式,同样无需源码编译,并且不能依赖其它类型模块;
  • external module,即外部依赖模块,无需源码编译,可以依赖其它外部模块,依赖信息位于maven仓库对应pom文件中。
一般来讲,一个app的“出生”,是从一个app-project工程开始的:所有代码、资源都写在此工程中,当然也会以外部模块形式引入(依赖)一些二、三方库;随着app承载功能增加,复杂度随之上升,此时也很可能会有更多的开发者加入进来,持续迭代一段时间后,可能会迎来第一次模块化“变革”:将通用功能拆分为多个sub-project;开发人员的增多,会引发代码协作成本提高,此时可能需要从单个代码仓库拆分为多个,便于并行化开发,此时迎来第二次模块化“变革”:代码仓库拆分,以及更细粒度的模块拆分,研发并行程度继续提高。最终,会演进为模块化的究极形态:app-project成为用于打包apk的一个“壳子”,几乎所有代码全部拆分到单独模块和仓库,在app-project中以外部模块形式对其进行依赖(引入),研发高度并行化。
很多大型app,基本都完成了上述这样的演进过程,同时也引发了新的问题。接下来,就来逐一讲述在模块这个维度,研发了哪些工具,进行了哪些治理。
3.2 辅助分析能力 辅助分析能力,主要是站在apk完整构建角度,为开发同学提供模块及其依赖信息,用于解决各种日常问题,例如:
  • “我更新了一个模块的版本号,为什么apk中的代码还是旧的?” —— 查看本次apk构建,目标模块最终使用的版本号是多少,如果没有更新,那么肯定会出现这个问题。
  • “我删除了模块,为什么apk中还有相关代码/资源?” —— 查看本次apk构建,目标模块是否参与到apk构建过程,是app工程直接依赖引入,还是其它模块间接依赖引入,快速定位原因。
  • “我在一个模块工程中,使用了另一个模块中的方法,但是在apk中却找不到此方法,是什么原因?” —— 查看本次apk构建,依赖的另一个模块版本号是多少,升级目标工程中对此模块依赖的版本号,重新编译目标工程,看是否方法已被删除,转移或者签名有变化。
接下来,分别对每项辅助分析能力进行简单介绍。
外部依赖模块列表
外部依赖模块列表,统一输出所有参与到本次apk构建的外部依赖模块,及其版本号、类型。示例结果:
com.youku.arch:testlib:0.1-SNAPSHOT@aar com.youku.arch:testlib2:0.3@aar

被依赖关系检测
【向工程腐化开炮 | 治理思路全解】在apk构建过程中,有一些外部依赖模块是通过间接依赖(没有在app工程中直接声明依赖)引入进来的,这个间接依赖关系,存在于maven仓库中模块对应的POM文件。通过被依赖关系检测功能,可以方便的找到一个模块,被哪些其它模块所直接依赖,用于进行模块下线,或者归属关系判定(根据依赖关系,判断模块属于哪个上层业务)。示例分析结果:
com.youku.android:y-core |-- [provided] com.youku.android:ct-ad |-- [compile] com.youku.android:catl |-- [runtime] com.youku.android:MtReccom.tb.android:z_dev |-- [compile] com.tb.android:zcore

注意,这里的分析结果,是被依赖关系。在这个例子中,com.youku.android:ct-ad模块以provided方式,声明了依赖com.youku.android:y-core模块;com.youku.android:catl模块以compile方式,声明了依赖com.youku.android:y-core模块;其它内容以此类推。其中,依赖类型一般包括以下几种:
  • compile。此类型依赖,如果不额外添加exclude设置,会导致模块被打入apk;
  • provided。此类型依赖,不会导致模块被打入apk;
  • runtime。此类型依赖,不会导致模块被打入apk。
当然,模块在发布到maven仓库时,可以定制pom文件内容,所以如果模块发布时,并未正确的将工程中对其它模块的依赖关系写入到pom中,那么上述检测结果,也会存在对应的错误信息,例如:漏掉真实依赖模块、依赖类型与实际不符、包含多余依赖模块等。
不匹配依赖关系检测
在模块化开发模式下,各个模块独立开发,并最终参与apk构建,这会导致很难感知到其依赖的模块进行了升级:模块自己在进行构建时,使用的还是对应依赖模块的旧版本,所以可以编译通过,但是在apk编译时,很可能其所依赖的模块已经进行了版本号升级,从而导致一些不匹配引用情况发生。不匹配依赖关系检测,正是为了便于各模块开发同学,清晰的掌握模块编译时依赖的其它模块版本号,与apk编译时这些模块使用的版本号之间的差异,从而及时在模块工程中进行依赖模块版本号的升级操作。示例分析结果:
com.youku.android:YTask |-- com.youku.android:BFra:1.0.0-SNAPSHOT ==> 1.0.0.44 |-- com.youku.android:BUIKit:20190617-SNAPSHOT ==> 1.0.1.66 |-- com.youku.android:YUI:1.4.2.16-SNAPSHOT ==> 1.4.10

在上述示例中,YTask模块在编译时,依赖的BFra模块是1.0.0-SNAPSHOT版本,而在apk构建时使用的BFra模块是1.0.0.44版本,其它以此类推。此外,还提供额外功能,将所有外部依赖模块的pom文件,统一输出到apk构建产物文件中,便于集中查看和定位问题。
3.3 治理实践 在上述几项辅助分析能力的基础上,有两种情况会对构建出的apk带来不确定性隐患,因此,也成为模块腐化的直接治理目标。
snapshot版本号
在apk构建开始阶段,直接从maven仓库下载外部依赖模块对应版本号的jar/aar文件,参与后续构建过程。其中,SNAPSHOT版本号由于可以随时更新jar/aar到maven仓库,而在app发布版本构建时,并不希望这种情况发生,这会带来各种难以预期的线上风险。因此apk构建过程,是否存在SNAPSHOT版本号的外部依赖模块,需要被严格管控住。
为了,研发了snapshot版本号检测功能,筛选出参与到apk构建过程所有版本号为snapshot的外部模块。示例内容如下:
com.youku.arch:testlib:0.1-SNAPSHOT com.youku.arch:testlib2:0.2-SNAPSHOT

进一步,在app版本迭代关键节点,例如:集成、灰度/正式版本发布,利用此项检测能力形成卡口。优酷在几年前,就已经以本地卡口形式(apk构建失败)上线此功能,并在2021年将此卡口融入到整个卡口体系,成为其中一个卡口项,累计拦截7次,有效防止snapshot版本模块引入到apk构建过程中。
向工程腐化开炮 | 治理思路全解
文章图片

snapshot依赖
开发阶段,为了方便模块间联合调试,通常会将依赖的模块版本修改为SNAPSHOT,在完成联合调试后的正式版本打包过程中,如果没有将依赖模块的SNAPSHOT版本号修改回正式版本,而这个时间窗口内,依赖模块的SNAPSHOT版本一旦有更新,会导致模块正式版本编译时依赖非预期代码,最终导致apk运行时出现各种不兼容问题,例如:API不兼容(类、变量、方法签名不匹配)、常量不一致(常量在模块编译时,会进行常量展开)。
snapshot依赖检测功能,正是为此而生,在检测结果中列出每个模块依赖的snapshot版本号模块,以及apk构建时此模块对应的版本号。示例内容如下:
com.youku.android:YHPage:1.9.35.5 |-- com.ali.android:VCommon:20210309-SNAPSHOT ==> 11.1.6.4 |-- com.youku.android:YRes:20210309-SNAPSHOT ==> 1.0.44.2com.youku.android:OUtil:1.0.4.11 |-- com.youku.android:OService:20210105-SNAPSHOT ==> 1.3.8.2

作为腐化治理项,优酷在2021年初上线此功能,当时有200多个模块在pom文件中存在snapshot模块依赖,当时统一添加到了白名单,在接下来版本迭代过程中逐步清理,截止目前已清理近40%,效果显著。在同一时间于app版本迭代关键节点,形成了对应流程卡口,近一年时间累计拦截25次,有效防止由此导致的线上风险问题发生。
向工程腐化开炮 | 治理思路全解
文章图片

其它治理实践 上述模块相关腐化治理,只是与工程腐化这场持久战的前哨。针对前面工程腐化的元素级分类拆解,开辟了以下“五大战场”,可以前往查看详情(点击跳转):
  • proguard配置
  • manifest
  • java代码
  • 资源
  • 动态链接库so
还能做些什么 在优酷近两年的工程腐化实践中,得到了很多研发同学的支持,他们怀抱匠心、热情与勇气,及时解决出现的新增问题,一点一点的去消化存量技术债,长期的坚持和努力共同换来目前工程腐化问题的全面显著降低。“用正确的方式,做正确的事,无论简单还是困难”,这既是优酷进行工程腐化解决方案设计和治理实践时,所坚定遵循的原则,也是本系列文章想要传达出来的技术理念。
目前能够通过工具检测到的具体腐化问题,加起来不过20余项,相对于工程腐化的冰山,毫不夸张的说这真的只是一角儿。况且,这里所给出的应对方案,也仅仅能够解决其中一类问题,面对那些极度复杂,甚至牵一发而动全身的腐化问题,尚缺少有效解决方案。面对工程腐化,还有很长的路要走,还有很多事情可以并且需要去做,向工程腐化开炮,是一种直接而切中要害去解决问题的态度,积跬步行千里,与诸君共勉。
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践&干货给你思考!

    推荐阅读