写点什么

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

  • 2022 年 3 月 29 日
  • 本文字数:8180 字

    阅读完需:约 27 分钟

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


作者:刘天宇(谦风)


系列文章回顾《向工程腐化开炮 | 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@aarcom.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:MtRec
com.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-SNAPSHOTcom.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.2
com.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 篇移动技术实践 &干货给你思考!

发布于: 刚刚阅读数: 2
用户头像

还未添加个人签名 2018.07.07 加入

阿里巴巴移动&终端技术官方账号。

评论

发布
暂无评论
向工程腐化开炮 | 治理思路全解_Java_阿里巴巴移动技术_InfoQ写作平台