向工程腐化开炮 | 治理思路全解
Posted 阿里巴巴终端技术
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了向工程腐化开炮 | 治理思路全解相关的知识,希望对你有一定的参考价值。
作者:刘天宇(谦风)
系列文章回顾《向工程腐化开炮 | 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构建,依赖的另一个模块版本号是多少,升级目标工程中对此模块依赖的版本号,重新编译目标工程,看是否方法已被删除,转移或者签名有变化。
接下来,分别对每项辅助分析能力进行简单介绍。
1外部依赖模块列表
外部依赖模块列表,统一输出所有参与到本次apk构建的外部依赖模块,及其版本号、类型。示例结果:
com.youku.arch:testlib:0.1-SNAPSHOT@aar
com.youku.arch:testlib2:0.3@aar
2被依赖关系检测
在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中,那么上述检测结果,也会存在对应的错误信息,例如:漏掉真实依赖模块、依赖类型与实际不符、包含多余依赖模块等。
3不匹配依赖关系检测
在模块化开发模式下,各个模块独立开发,并最终参与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带来不确定性隐患,因此,也成为模块腐化的直接治理目标。
1snapshot版本号
在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构建过程中。
2snapshot依赖
开发阶段,为了方便模块间联合调试,通常会将依赖的模块版本修改为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 篇移动技术实践&干货给你思考!
5年磨一剑|优酷Android包瘦身治理思路全解
稳定性、性能、包大小,在移动端基础用户体验领域“三分天下”,是app承载业务获得稳定、高效、低成本、快速增长的重要基石。其中,包大小对下载转化率、拉新拉活成本等方面的影响至关重要,这在业界已经成为共识,近年来头部app针对下沉市场的极小包策略,更是将包大小的价值提升到了极致。优酷在Android包大小领域,有长达5年的持续投入、实践和积累,尤其是在近2年逐步进入低成本可持续治理的健康状态。现将这些思考、方案设计、技术建设、治理实践统一汇总整理成文并分享出来,希望能够帮助更多同学在所负责或参与的app中,更好的进行包大小治理。
本文聚焦于整体治理思路,以治理实践为依托,讲述瘦身技术、治理模式、治理策略,以及背后的思考与取舍。
五年治理回顾
作为开篇,先给出优酷近5年包大小变化情况:
以2020年9月为分水岭,从治理模式角度,可以将前后划分为两个“风格迥异”的阶段:专项治理、常态治理。包瘦身治理也属于一种软件工程,接下来围绕“术”、“道”、“人”三个维度,展开回顾和总结:
1.1 专项治理3年:三次两反弹
自2017年初至2020年9月这3年时间,共经历三次专项治理以及两次反弹。
2017.05 - 2018.03,第一次专项治理。在瘦身效果上,从最高点73MB降低到51MB,瘦身比例约30%,这次瘦身专项的最大价值,是积累了宝贵的实践经验:
- 技术手段。由于当时几乎没有积累,采用的技术手段相对常规且具有单点性,主要包括:分析并下线无用业务/功能模块、远程化边缘业务、图片压缩&矢量化等。
- 治理策略。缺乏整体目标掌控和拆解,对于头部问题进行单点改造。
- 组织形式。比较松散,涉及范围窄,参与人数少。
2018.04 - 2018.09,第一次反弹期。期间使用“模块级”包大小卡口作为管控手段,由于缺少相关分析技术支撑,申请方和审批方对存量&增量情况都缺少清晰一致认知,导致管控逐渐流于形式。与此同时,包瘦身治理优先级降低,前面负责治理的架构同学撤出,虽然架构团队依然负责跟进相关事项,但几乎没有主动投入治理,包大小接近自然状态下的“野蛮生长”。
2018.10 - 2019.02,第二次专项治理。在瘦身效果上,从最高点80MB降低至40MB,瘦身比例50%,除了实践经验的持续积累,在技术手段上呈现出主动探索、初步沉淀几个特征:
技术手段。远程化大规模使用:远程Bundle、远程so,几乎所有能远程部分都进行了相关改造;业界瘦身手段尝试:代码系列瘦身(混淆精简、同功能模块统一、无用功能模块下线)、资源系列瘦身(裁减、混淆)、整包瘦身(apk的7z压缩、R文件合并裁减),对其中约一半技术手段进行了应用;单点分析技术探索:主要集中在资源方面,包括无用、重复、相似、大尺寸、无透明度png、图片矢量化、多维度,利用分析结果作为瘦身点改造和分发的输入。
治理策略。中心式任务拆解、并行式承接落地。
组织形式。集中时间&人力,核心业务基本都参与了进来。
2019.03 - 2020.03,第二次反弹期。在这一时期的管控上,基于虚拟功能组概念(多个模块聚合)建立了包大小卡口能力,但是未能与研发流程有效结合,无法做到及时感知以及关键的超限拦截阻断,同时申请方和审批方缺少对“一个功能/业务,占用多大合理?有多少可瘦身点,分别具体是什么,瘦身空间是多少?”这些关键问题的共识性认知,导致沟通、推进、瘦身改造等成本居高不下,半年之后的管控开始举步维艰。从增长曲线来看,明显可以分为两段:2019.09之前的6个月时间,属于缓慢增长(虽然中间有一个增长波峰,但很快就得到控制回落),一方面得益于围绕卡口的持续管控,另一方面也因为这段时间没有大型新框架、新能力、新业务接入;2019.10之后的6个月,由于Flutter等新框架的集中爆发式引入,导致包大小出现“疯狂飙升”,在2020.03甚至达到历史最高的126MB水位。
2020.04 - 2020.09,第三次专项治理。在瘦身效果上,最终将包大小降至100MB以下。虽然这次依然是专项模式,但与第二次专项治理完全不同的是:参与团队更广泛,不仅仅是核心业务团队,而是所有客户端团队;牵头同学和参与同学之间的协作方式,由“中心化分配”与“被动完成”(包工队模式),转变为辅助与输出(PVP战队模式),即牵头同学提供更全面、更具体、更具有指导性的分析能力、工具,以及用于降低改造成本和上线风险的各类辅助工具,各团队同学在为自己瘦身目标负责前提下,具有极高的过程自由度,可以集中火力进行瘦身Action的分析制定和执行。另外,这一阶段在技术上的关注点,更多的聚焦到分析&辅助技术,而不是那些能够直接减小包大小的技术:
- 技术手段。分析技术成型:包大小分析工具franky即诞生于此时期,初步实现“对apk大小真实贡献”的分析能力,以及结合模块图谱数据将apk大小拆分到各研发团队,多个可瘦身项检测也逐步沉淀到分析工具中;源头深度瘦身:无论是比较常规的无用和冗余性业务、功能、模块、甚至是方法级代码,还是franky包含的若干可瘦身项,都逐步在源头代码层面,以更细粒度更治本的方式展开。
- 治理策略。中心化拆解,分布式治理。借助分析工具,将瘦身目标逐一拆解到不同团队和业务,各自根据实际情况合理安排人员、方案、进度。
- 组织形式。专项模式,几乎覆盖客户端所有团队和业务。
1.2 常态治理2年:稳中持续降
2020年9月至今(准确的说是1年半多一点),进入常态治理阶段,包大小从期初100MB,逐步降低到2022年3月底的64.9MB(截止本文完成的5月份为64.4MB)。
在这个阶段,包大小卡口能力完成了一次关键进化:与研发流程实现无缝结合,对超限情况实现及时感知,以及拦截阻断,这让整体管控成本得到极大降低。同时,对分析技术、瘦身技术的迭代、探索和应用,始终没有停下脚步:dex排布优化、7z压缩、D8、R8等整体瘦身技术陆续上线,so无用导出符号等可瘦身项持续加入分析工具franky,相关技术也开始得到阿里内部更多app使用,这进一步促进了功能快速发展和丰富。另一方面,治理策略也在逐步完善,客户端各研发团队围绕自己的包大小阈值,把包大小提升为与稳定性、性能一样的日常研发迭代基础考量指标。
治理模式
前面通过术、道、人三个维度对历史进行了回顾,通过对比不难发现它们有着截然不同的特征,据此可以将包瘦身治理分为两种模式:专项式、常态化,前者以短时间快速瘦身为目标,后者以长时间可持续维持为目标(甚至逐步降低)。看到这里,或许会提出一个疑问:治理模式和“术、道、人”三维度有什么关系?如果一定要进行区分,我认为既可以看作不同的思考视角,也可以认为前者是后者的更高层次抽象:“术”的能力所达到的水平,“道”的选择所遵循的原则、“人”的排布所提供的保障,共同决定了当前处于什么样的治理模式;反过来也适用,即治理模式对“术、道、人”的内容和边界,都有明确的要求。
2.1 专项式vs常态化
专项式治理,一般是在包大小持续上升至某个值后,成立专门项目集中时间治理。一般会有多团队多人员参与,同时会有明确的项目负责人,来制定严格且固定的里程碑。此时的apk包由于经过一段时间积累,会存在较多以无用和冗余功能为代表的可瘦身项,相对容易识别和解决,因此一般瘦身见效快,当然专项结束后如果缺乏有效的可持续管控,包大小反弹几乎是必然的。专项式治理的“精神内核”是目标优先,这当然没有任何问题,但在这个过程中,往往很容易忽视瘦身改造所带来的其它负面影响,例如不适当的远程化改造会带来用户体验受损、apk构建过程中采用大量“瘦身黑科技”导致打包耗时明显增加等。这里面的取舍和平衡之道,没有标准答案,只有综合判断“此时此地此景”后作出的选择。
常态化治理,是指在长期的版本迭代过程中始终能够控制好增量,并在维持住当前包大小水位前提下实现“稳中有降”。一般在常态化治理阶段,头部问题已经基本不存在,需要在业务功能和代码源头进行更全面、精细、深入的分析和思考,从而在版本迭代过程中逐步“消化掉”可以瘦身的地方。在治理所需人力投入上,具有较低的整体管控投入,并形成团队、业务、功能的开发者自治局面。在治理节奏上,整体包瘦身目标调整周期较长,同时不再进行细粒度的瘦身里程碑制定,采用相对宽松和灵活的方式,把自主权给到具体负责的团队和开发者。在瘦身效果上,可以较好维持住当前水位而不发生反弹,甚至是缓慢降低。常态化治理的“精神内核”是体验优先,将包瘦身这件事“融入”到日常研发迭代过程,与稳定性(crash/bug等)、性能(启动速度/页面切换/流畅度等)一样,共同成为研发团队(同学)在业务需求和功能之外关注并考量的技术项。
在时间、人力、节奏、效果、精神“内核”这五个维度上,二者的对比情况汇总如下:
常态化和专项式的关系并非简单的“优于”就能够说清楚,首先二者具有演进关系,类似人类文明的“石器、青铜、农业、工业”等代际进化,常态化治理也是在生产力(分析&瘦身技术)不断提高的情况下,促使生产关系(治理模式)等发生变革(嗯,这个比喻不一定准确)。其次,二者有着不同的适用情况,专项式治理用于快速降低包大小,而常态化治理用于低成本可持续维持或者缓降。如果app无论与同类竞品还是自身相比,都明显处于较高的包大小水位,那显然需要先通过专项治理将包大小快速降低下去,然后再衔接上常态化治理来获得“长治久安”;如果app已经处于常态化治理模式,但是由于某些原因需要进一步快速降低,那么就需要切换到专项式治理模式,达成目标后再继续回到常态化治理模式。
常态化治理相对专项式治理,更需要当作一个系统化工程来看待,整体治理思路如下:
由专项式到常态化,首先要做的转变是将关注点从“事”转移到“人”:每一个Byte都是由人(开发者)添加的,对产生的原因(技术、流程、心理等)进行全面分析,并给出有效解决方案,才能够实现专项式到常态化的跃变。这个解决方案,主要包括技术支撑、治理策略两方面,二者相辅相成缺一不可。
2.2 技术支撑
整个技术支撑体系的核心是包大小精准分析,即对apk内任意实体元素(类、资源、so等)获取其在apk文件中实际占用值。因为编译过程会进行各种合并、裁剪、优化、格式转换等,同时apk中不同类型元素的压缩率也不相同,如果使用原始大小作为度量标准,会在包大小治理的整个链路引入较大误差,导致难以抓住瘦身重点,也无法提前精准预判瘦身效果,这对常态治理过程具有非常大的负面影响。
第一点可瘦身项,是指类、资源、so等元素中的“不合理”项,对其进行改造或优化就可以降低包大小。这些可瘦身项细碎并且不易被人工发现,但是累积起来却不容小觑,通过工具化的分析能力可以快速找出这些可瘦身项,一方面提供瘦身指导:为逐步降低包大小提供更多“方向”和“空间”,另一方面用来评判:一个功能/业务/团队,在这段时间内减少/增加了哪些可瘦身项,当前是否已经瘦无可瘦。
第二点归属聚合,是指将apk大小拆解到有效的责任实体,根据app涉及到的研发团队和迭代模式不同,责任实体可以是组织结构中具体的团队,也可以是业务/功能/模块负责人。总之,拆解的目的是明确责任,即团队/业务/功能/模块对apk大小的“贡献”分别是多少。
第三点研发流程,是代码上线的“必经之路”,在这个过程中需要具备及时的增量感知,以及超限后的拦截阻断能力。只有这样才能实现低(人力)成本持续管控,另外这也是去中心化策略的重要技术支撑,可以避免很多低效的增量定位排查、沟通、跟进等工作。
第四点辅助工具,是为研发同学对代码进行瘦身提供一系列切实有效的工具,用来提高效率以及降低风险。目前在优酷已经沉淀了引用分析、代码归属/热度分析、模块下载/版本号同步&查询&对比、progaurd对比(mapping、usage)分析、apk信息查询/对比/反编译等共计十几项工具。
上述技术支撑体系在具体实现上,主要由“分析工具”和“包大小卡口能力“承载,后面会做具体介绍。
2.3 治理策略
常态化治理模式下,治理策略的核心是去中心化。尤其是对于多团队参与研发的app,对每个功能最熟悉的人一定是日常直接负责的(团队)同学,因此最高效的方式其实正是“各家自扫门前雪”的分布式治理模式。其中阈值划分,是实现分布式治理的第一步,即圈定“每家所负责的范围,以及最多允许存在多少雪”。而灵活协作,目的是打造合理、公开、透明、高效、低成本的可持续治理局面,在为业务增长和创新提供更多包大小空间的同时,将整包大小控制在预期范围内。
分析技术
分析技术,是秉承Byte级“较真儿”精神,以包大小真实占用为指导原则,公平公正童叟无欺,实现对不同颗粒度(元素、功能、业务、团队)的包体积占用度量,并在此之上提供切实有效的可瘦身项分析能力,用于指导和评判瘦身情况。先来回答一个问题:分析工具为什么很重要,很重要?
首先,既然要进行包瘦身,那么各种不同的“大小”就如影随形,例如:“xxx模块多大?”、“xx功能/业务占多大”、“最新版本apk相对上个版本增加了1MB,不同功能的变更带来的大小变化分别是多少?”、“我今年的目标是将apk减少10MB,可以通过哪些瘦身Action来达成目标?”。工程领域有几句著名论断:“无度量不改进”、“无度量不管理”,包大小分析工具的核心价值之一就是提供这个度量:各种不同颗粒度的度量,小到一个java类、资源、so,继而到一个模块(jar/aar)、再到一个独立功能、业务、甚至是团队。由此衍生开来,既然有了度量,那么就可以进行有效的责任归属、瘦身目标制定、效果预估等,是不是豁然开朗?
其次,在具备度量能力基础上,站在长远角度考虑,实际瘦身治理过程中还需要能够回答“这个app是不是已经瘦到极限,还有哪些地方可以瘦身?”
分析工具需要具备的另外两个重要价值是:指导和评判。这二者其实是一个事务的不同视角,即:对主动者给予指导,对被动者给予评判,在瘦身前用于指导,在瘦身后用于评判。
一个满足有效的度量、指导和评判需求的包大小分析工具,该具备怎样的自我修养,这就是本章所要探讨的内容以及会给出的答案。在优酷,这个Android端包大小分析工具的名字是franky,潦草诞生于2020年初,逐步迭代完善/增强至今已2年有余,趋于成熟,仍在前行,目前正在筹备开源中,希望能给Android包瘦身治理带来一些帮助。
3.1 方案设计
本章将围绕“度量、指导、评判”这几个核心价值进行方案设计。首先不妨采用问答的方式,来进行具体分析和拆解的推导过程。
度量的对象是谁?度量的值又是一个什么样的值?在包瘦身不同场景,关注的对象也不一样,颗粒度最细的是apk中各种元素(java类、java资源、十几种不同类型的Android资源、动态链接库so),再往上层的是模块(jar/aar),继续往上层则是功能(由多个模块组成的独立完整功能)、业务(由多个功能组成的完整业务)、团队(组织架构中一个团队负责的所有业务),再继续往上就是apk了(这个没有什么意义,一个文件的大小根本不用什么分析工具),当然一个小型app可能在模块之上仅需要一层功能聚合就足够了。至于度量的值,则是在apk中的真实大小占用,即删除后apk可以减少的大小,对于某些元素原始大小和对apk大小的真实占用之间,存在着非常大的差距,这个差距会导致对瘦身Action的效果评估出现不可忽视的误差,从而使瘦身Action的优先级排序、指导和评判失去根基。由此得到“度量”的需求拆解结果是:提供元素、模块、功能等不同颗粒度,在apk中真实占用大小的度量。
指导的内容有多具体?评判的依据又是否公平、透明?瘦身的指导,如果只提供一个大概的方向,是远远不够的,需要非常明确、具体、可操作。举个例子:如果我只告诉你“充分利用proguard,精简优化keep规则,让更多的类被裁减和混淆,就可以有效瘦身”,那么如何让参与app开发的所有同学,都能够据此高效的完成这项瘦身任务?但是如果能够给出“你负责的业务/功能/模块,类未混淆率是80%,数量是600个”,相比前者显然更具有指导性,更进一步,假设还能够给出“每个未混淆类,是被哪些keep规则所影响”,是不是实际瘦身过程变得更加有迹可循,相信任何一名开发同学都能够很好的完成这项工作。再举个例子:“缩减或远程化大尺寸图片,可以有效瘦身”,与“你负责的这个模块,对apk真实大小占用超过10KB的图片,一共有10个,分别是xxxx”,这二者相比显然后者更具指导性。再来说说瘦身的评判,不能依靠人的能力和判断力,这样很难做到公平、透明,需要通过可量化的数据作为依据。由此得到“指导&评判”的需求拆解结果是:提供明确、具体、可量化、可操作的可瘦身项分析,用于对瘦身过程进行指导和评判。
此外,还有两个非常现实的问题,也不可避而不谈。分析覆盖率(能够找到模块归属的元素大小之和,占apk大小的百分比)能够做到多少?对于分析覆盖率,理论值就应该是100%,apk构建过程没有magic,所有在apk中存在的元素皆有来源。当一个元素(比如资源、so)被多个模块(这里的模块是广义上的模块,例如app工程、subproject工程)包含时,这个元素归属到每个模块的大小怎么计算?从公平的角度,多模块包含的重复元素,归属到每个模块的大小应该是等比例分担(Proportional Set Size)的。
根据上面的推导过程,我们来进行提炼和总结:
现在,如果让你来回答以下几个问题,是不是就可以信手拈来,轻松惬意?
- apk为什么这么大,不同模块/业务/团队,分别贡献了多少?
- 每个团队/业务/模块,有哪些可以瘦身的地方,进行删除、优化、改造后,apk能减少多少?
- 在日常迭代中,当前版本相对于上个版本,每个团队/业务/模块增加(减少)大小是多少?
实际上,优酷自研包大小分析工具Franky,几乎完全实现了上述拆解后的需求。只有一点尚未做到:apk中元素,目前还没有做到100%找到模块归属,在优酷apk中的分析覆盖率是99.8% ~ 99.9%(apk 100MB ~ 65MB)。
3.1.1 整体架构
Franky主要由两部分组成:用于application工程的gradle plugin,以及命令行(cli)分析工具。此外,还额外依赖(非必需)两个外部数据:模块图谱数据,用于将模块大小,向上聚合为功能/业务/团队的大小;代码覆盖数据,形成可瘦身项分析中的「代码 - 无用类」(SlimLady:类级别不插桩线上代码覆盖度统计框架[1])。整体架构如下图所示:
franky-plugin的作用,是在apk构建过程中收集apk所有组成模块,以及模块中包含的各类元素,此外还包含类混淆映射关系、无用资源分析结果。这个分析结果数据与apk文件,共同构成了命令行工具franky(cli)的基础(必需)输入文件。接下来,执行cli命令进行最终的包大小分析, 产出具体的分析报告。
纵观这套方案,可能会有一个疑问,为什么要包含一个构建插件,如果能通过一个命令行(cli)工具直接对apk进行分析,使用更简单还能更具通用性,不是更好吗?这里面有一个非常关键的点在于,只有在apk构建过程中,才能够获取apk由哪些模块组成、每个模块又包含哪些元素,在apk构建完成后的apk文件中,这些信息已经丢失,所以构建插件必不可少。那如果是这样,为什么不把命令行工具的所有功能,都放在这个构建插件中来实现呢?这是一个好问题,目前的考虑是这样的:尽量将构建插件做的比较“薄”,这样可以减少构建耗时,而将主要分析功能放在独立工具,可以独立快速迭代,而不用频繁在app工程中升级plugin版本。
3.1.2 关键技术
分析工具看起来简单,但是为了获取真实大小,以及能够将apk中元素100%进行模块归属,在开发过程中还是会遇到不少棘手问题。
首先,应用于构建过程的plugin如何保障兼容性,并不是一个简单的问题。很多Android Gradle Plugin开发者不太重视兼容性,认为针对特定工程实现相关功能就万事大吉,这里不深入讨论此话题,直接给出franky-plugin考虑并实现的构建环境兼容性,或许更能够对这个问题获得直观的认知:
接下来的核心困难是,参与apk构建的原始元素,与最终apk元素之间,存在转换、新增、删除、不变这四种“变化”情况:
上图给出的是基本“变化”情况,还有一些特殊情况也需要考虑,例如java资源可以“伪装”为其它类型元素、Android资源包含api level大于等于22可用的android:xxx属性,且资源的api配置限定符小于22,导致生成“-v22”资源文件、AAPT内嵌资源生成独立资源文件等。对于删除和不变的元素,处理起来比较简单,转换和新增的处理则相对复杂一些:
- 新增。新增元素最大的问题在于“找到归属”,例如有些java8语法在脱糖后生成新的类。目前franky中基于生成类名称规则的方式,解决了lambda 表达式、默认和静态接口方法的归属情况,但是对于方法引用的生成类则暂时无法找到归属(后面计划通过代码Pattern分析来找归属)。
- 转换。发生转换变化的元素,如果还进行了“合并”处理(例如dex、resources.arsc),此时就需要将元素“拆”出来,核心原则是:拆出来后元素大小之和,等于拆之前文件大小。例如,将class从dex文件拆出来后,大小相加必需等于dex文件大小。这个事情的难度来自于两个方面:
- 各种字符串、类型共享池,需要进行按比例(PSS)分担计算。
- 各种二进制结构数据的Header、Padding,需要进行精准计算和归属。
除了这些变化,还有一个细节也要考虑:apk中各文件的真实大小占用如何计算?apk本质是一个zip压缩文件,其中每个文件均为一个zip entry,zip entry占用大小相加小于apk总大小,因此需要将用于记录每条zip entry的额外大小加进来(Local File Header、Central Directory Record),同时将共享部分进行按比例分担。以优酷为例,apk大小为65MB,zip entry压缩大小相加是63MB(97%),如果不计算额外数据大小,仅在计算apk中元素大小时,就已经损失了2MB的真实大小!
3.2 可瘦身项分析
可瘦身项是指导和评判价值的主要承载者,本章对franky包含的全部9个可瘦身项,讲解基本技术原理、分析效果、用于瘦身时的注意事项等。
第一项【代码】无用类,是指在运行时没有被使用到的类。当前无用代码的获取方法,是通过线上采样的方式,采集代码热度数据,并筛选出其中初始化次数为0的类(具体实现方案来自SlimLady:类级别不插桩线上代码覆盖度统计框架[2],此处只是使用了前者的结果数据)。对于无用代码较多的模块,存在线上使用率低(或者完全无使用)的问题,应该安排下线或者使用H5等动态化方式实现。分析报告中的示例结果如下:
第二项【代码】未混淆类,是指由于keep规则存在,导致没有被混淆的类。混淆可以极大降低代码在apk中占用的大小,因此除了一些特殊使用场景,绝大部分类都可以进行混淆。对于未混淆类较多的模块,可能存在混淆keep配置过于宽泛问题。分析报告中的示例结果如下:
第三项【资源】超大,是指在apk中真实占用超过一定阀值的资源(可配置,优酷一直使用的是10KB)。超大资源,可以采用远程化、重新设计更小的等效资源、如果是图片还可以采用压缩率更高的图片格式(jpeg、webp)或者矢量图等方式来降低大小。分析报告中的示例结果如下:
第四项【资源】无用,是指没有被直接引用的资源。从资源整体使用情况来看,一个资源可能在三个地方进行直接引用:java代码,通过R.resourceType.resourceName方式引用(例如R.string.app_name),或者通过资源id方式直接引用(例如0x7fxxxxxx);清单文件AndroidManifest.xml;其它资源。另外,资源还可以通过Resoruces.getIdentifier方式,通过传递资源名称和类型获取id值,运行时性能较差,因此官方并不推荐使用,需要注意的是,分析工具对这种方式会出现误检。对于无用资源,应该在确认未通过Resoruces.getIdentifier方式使用后,进行删除处理。分析报告中的示例结果如下:
第五项【资源】多维度,是指包含大于两个配置的资源。这种资源会在不同配置下,存在多份数据(文件),一些特殊纬度需要做额外考虑,例如:night、land & port。对于多维度资源,一些非必要的配置可以清理掉。分析报告中的示例结果如下:
第六项【资源】无透明度png图片,是指png图片中包含了alpha通道,但是无相关数据,或者数据中的透明度值均为完全不透明。对于这种类型的图片,一般可以通过使用不带透明度信息的其它图片格式(例如jpeg),来降低大小。分析结果中,已经排除了.9类型图片,在分析报告中的示例结果如下:
第七项【资源】相似,是指资源值的相似程度较高。当前分析只包含file-base类型资源(相对的,值仅存在于resources.arsc中的资源,称为value-base类型资源)。对于非图片文件资源,仅筛选出完全一样的资源(md5一致,相似度为1)。对于图片文件资源,额外计算了相似度,采用DHash算法计算图片指纹,然后计算hamming距离作为相似值。对于相似度为1的资源,内容完全一致,因此可以仅保留一份,对于相似度小于1的资源,由于有些图形简单的图片资源特征信息不明显,因此即使相似度较高,是否可互相替代的最终决策,仍然需要根据所在业务场景进行人工判断。分析报告中的示例结果如下:
第八项【so】不规范使用STL,是指动态链接库so对c++ STL库的不规范使用,包括两种情况:动态链接非统一STL,官方建议统一使用的STL为libc++_shared.so,其它非统一STL包括libgnustl_shared.so、libstlport_shared.so;静态链接stl,通过改造为动态链接,可以实现较好的瘦身收益。分析报告中的示例结果如下:
第九项【so】无用导出符号。动态链接库的导出符号(exported symbol),是指在so内定义的对象、方法、全局变量,被设置为可被外部代码引用(导入)。无用导出符号,则是指在依赖这个so的其它so中(apk范围内),未找到任何引用,当然这里存在以下情况需要特殊处理:JNI方法、通过dlsym方式加载并调用的符号。对于确实无用的导出符号,可以在编译so时设置为不导出。具体操作方式并不唯一,比较建议使用编译选项-fvisibility=hidden,同时显示对需要导出符号增加 attribute ((visibility ("default")))标记这套方案来实现,这样新增符号默认不会导出,不至于出现一段时间没人管,无用导出符号持续累积问题。分析报告中的示例结果如下:
3.3 卡口能力建设
后者在研发迭代过程中,低成本维持常态化治理模式的关键之一,其承载的三个核心价值如下:
去中心,需要能够对apk大小进行适当颗粒度的精准拆分,用于卡口阈值、检测以及不通过时进行拦截。
促前置。包大小和代码规范、bug等单点问题不同,无法通过就地修改来完成瘦身,往往需要通过“拆东墙补西墙”的方式,寻找存量可瘦身空间来弥补新功能带来的增量。所以,只有足够前置才能留出更多时间给瘦身治理,从而保障最终发布到用户手中的apk大小保持稳定。前置要求卡口必须在代码变更后“第一时间”发挥作用,识别到包大小变化,如果超过阈值则进行拦截。这个“第一时间”根据不同app迭代模式差异,选取适当的节点即可,例如优酷的一个版本迭代可以分为“提测-集成-灰度-发布”四个流程节点,那么就选择提测和集成两个节点部署卡口。
低成本。卡口是一个比较模糊的概念,1百个工程师可能会有1百个对卡口具体机制的理解和设计,对于包大小卡口本身一定要具备的是低成本维护,包括以下几个方面:
- 与研发流程结合。包大小卡口不是一套独立的流程,而是要融入到整个研发流程中,尽可能减少额外使用成本。
- 100%覆盖。一定是要对所有可能的增量来源,都能够覆盖到,不能存在某种方式,可以绕过卡口机制。一旦被绕过,会拉高后续的识别&治理成本。
- 100%自动化。这一点看起来有点像废话,卡口难道还能手动执行?现实情况是,有些场景下所谓的“卡口”,其实自动化程度较低,还需要不小的人工参与。
优酷在2018年就建立了包大小卡口能力,后续的演进和调整都是朝着更贴合上述核心价值的方向进行,直至2021年初才达到稳定有效的成熟状态。包大小卡口由分析工具franky、卡口能力平台、研发流程管控平台(CI/CD平台)三部分组成,示意图如下:
研发在使用流程(CI/CD)平台进行提测和集成时,都首先需要触发apk构建,franky-plugin作为包大小分析工具在构建期的一款gradle插件,会收集数据并将结果输出到构建产物。接下来,流程平台中的卡口插件负责收集apk和franky-plugin生成的文件,并上传到卡口平台备用。
之后,流程平台会执行准入检测,其中包大小卡口检测会触发能力平台中的包大小分析任务,通过调用franky对应的命令行工具,生成json格式的包大小分析报告。通过解析分析报告,并与预先设置的团队阈值和Buffer值进行对比,以此判定提测/集成单中包含模块所在的团队(1个或多个)是否超限。流程平台中的准入检测在获取检测结果后,通过或者阻断提测/集成流程。如果卡口未通过,可以通过进行瘦身改造来使卡口通过,但这一般无法在当前版本完成,这时可以申请带时限的Buffer来临时通过卡口,从而完成提测/集成。
瘦身技术
前面讲了很多治理模式相关内容,看起来可能有些抽象,接下来会回到具体的瘦身技术,侧重点不在于深入技术原理和实现细节(大多会给出参考链接),而是尝试将每一项瘦身技术的优缺点进行一次概括性讲解和巡展,便于形成一个整体认知,在对具体app进行瘦身时,能够根据实际情况进行选择和优先级安排。
瘦身技术,顾名思义,就是指可以用于包瘦身的任何技术(方案),按照所需技术、生效阶段、影响范围综合评判,将其划分为以下三种类型:
4.1 远程化
远程化是指将原本在apk中的功能,剥离出来放到服务器,app运行时进行下载、加载等一系列动作后,才能够正常使用功能的一种技术方案,其核心特点可以归纳为“本地剥离,远程下载”。远程化瘦身效果显著,也因此容易为了追求瘦身结果而被过度使用,但这并不是远程化本身的原罪,实际上一些边缘、非核心、实验性业务,都比较适合进行远程化改造。远程化框架涉及的关键技术,在业内已经有很成熟的解决方案,但是具体到代码实现,还是有不少需要仔细思考和反复打磨的地方,例如:apk构建体系的兼容性是否足够广泛,远程化改造的代码限制和改造难度是否足够低,app唤端、多进程、用户磁盘占用、下载线程占用、apk升级复用、下载带宽成本等。
按照远程化元素类型,以及业界普遍使用情况,将其分为远程so、远程bundle、远程资源三种。
首先来看远程so。动态链接库so与其它代码的耦合度低,在apk中具有较强的独立性,同时占用apk体积相对较大,因此单独将so进行远程化往往具有很高的瘦身投入产出比。
第二种远程bundle,一般是指可以完整的将一块功能进行远程化,远程部分相当于一个迷你apk(dex、resource、so)。远程bundle相关技术“历史悠久”,动态化、插件化、组件化等虽然有语境、功能以及设计思想上的区别,但在技术上有着很多相似的地方,随着新版本os加强了对系统API调用、拦截和替换等方面限制,这个领域的主流技术方案逐渐演变为对系统侵入越来越轻量的方向。相对于远程so,远程bundle在实现上要复杂很多:在运行时阶段,虽然对系统API的侵入性比较小,但是对唤端、组件路由跳转、后台Activity销毁重建等情况依然需要小心处理;在构建阶段,由于要“分离”出一个迷你apk,因此对构建体系的兼容非常困难,目前业界有一些同类框架,很多时候并不是运行时无法满足需求,而是在构建侧无法做到很好的兼容性和易用性。
最后一种远程资源,是指针对资源文件的远程化。可以通过将资源上传到文件托管平台,获取文件url后直接下载并使用(优酷采用的就是这种方式)。远程资源的实际应用场景较少,投入产出比也不高,所以目前优酷没有专门研发一个这样的框架。当然,如果有大量资源需要进行这种远程化改造,那可能有必要开发一个专用框架。
4.2 整包瘦身
整包瘦身,是指在apk构建阶段整体进行处理的一类瘦身技术,对全部apk元素均可生效(包括无源码的二、三方sdk),新增代码也可以立刻得到同样的处理,其核心特点可以归纳为“中间拦截,整体生效”。也正是由于上述特点,这类瘦身的影响范围较广,因此在首次应用到app时,如何控制好验证成本和线上风险变得非常关键,当然在瘦身效果上,一般可以立竿见影的获得较大收益。自定义的一些整包瘦身方案,往往容易出现处理逻辑考虑不周全而导致的稳定性问题,注意这不属于技术方案本身的特点,而是具体实现代码的问题。在工程效能方面,对代码质量无影响,构建耗时则一般会有增加,有些整包瘦身技术会改变apk中目标元素形态,因此对各类相关问题分析会带来一定程度的效率降低。
这里划分了14项整包瘦身技术,其中Android官方没有提供的能力,都已经沉淀到了优酷自研gradle plugin中,目前正在开源筹备中。
- 【代码】Proguard/R8。利用Proguard工具,对java代码进行裁剪、混淆、优化处理,从而实现无用代码删除、符号(类、变量、方法)混淆、代码逻辑优化,包体积降低效果非常显著。值得注意的是,google官方已经在近几年的Android Gradle Plugin中,使用自研的R8替代了java领域传统的Proguard工具,裁剪和优化效果更为强大,进一步压缩包大小的同时处理耗时更低,优酷从proguard切换到R8后,包大小降低约4.8%(3.2MB),构建耗时降低约25%(2min),当然这也和原progaurd的全局配置有关,尤其是优化次数-optimizationpasses。
- 【代码】D8。在apk构建过程中,java代码需要经历由jvm字节码到dalvik字节码的转换处理,DX/D8就是承担这个责任的工具。在优酷的具体实践中,由DX升级到D8后,包大小降低约9.5%(9.7MB),由于额外对dex合并进行了优化,dex数量降低,导致包大小收益出现一次跃升,因此比官方给出的Benchmark收益5%要更高。
- 【代码】R类合并。将所有<模块package>.R类移除,并将java代码中对前者的引用统一替换为.R类,以此来降低包大小的一种技术手段。在优酷当前情况下(模块800多个,dex24MB),R类裁剪可以减少80万个java类Field,带来近5MB包大小收益。由于每个dex中Field数量也受到65536限制,因此Field数量大幅减少所带来的dex数量减少,是瘦身收益的主要来源。进一步,可以把所有java代码中R..的引用,也全部替换为对应id值,这样.R类也可以删除,但是在已经完成R类合并的情况下,这个处理的收益比较有限,因此优酷并没有实际投入研发和使用,但是如果追求极致瘦身确实可以这么做!
- 【代码】Dex排布优化。Dex排布优化是指通过合理安排dex中包含的类,从而尽可能减少常量池冗余度以及dex数量,进而降低dex整体大小的一种瘦身技术。由于历史原因,Dalvik字节码中调用method和field指令的操作数是16位,因此一个dex中method和field数量上限均为65536,而现代app一般都会包含多个dex,dex数量过多会导致各类常量池冗余度变高,从而导致包大小增加。事实上,Dex排布优化不仅可以用于降低包大小,还可以通过选择不同的优化策略,来提升app运行时的性能,Facebook的Redex即是这一领域的著名开源框架。优酷并没有使用复杂的排布优化策略,而是自定义了简单的Dex合并能力,获得了约2MB左右的包瘦身收益。
- 【代码】字节码指令精简。准确的说这并不是一项瘦身技术,而是一类瘦身技术的集合。通过更精细的字节码上下文分析,以删除、合并、转换等方式精简指令序列,从而达到瘦身目的,例如删除冗余赋值指令(值与类型默认值一致)、access$xxx方法消除(修改private方法为public,避免access方法生成)、常量/短方法内联等等。不知道大家是不是会有个疑问,proguard/R8没有进行这些处理吗?这些“民间”自定义的字节码指令精简方案,可以看作是对前者的一种“极致性”扩展,因为前者在进行字节码优化时对正确性的要求极高,如果有些优化策略存在风险,或者违背原代码设计意图(比如修改private方法为public),那么就不会应用。当需要使用自定义的字节码指令精简之前,建议先把proguard/R8各种优化配置选项研究透彻并充分应用,可能你会发现通过配置就可以实现同样效果,并且处理过程更稳定、高效、可信,如果不得不走到需要进行自定义处理的境地,也一定要谨慎使用。
- 【资源】无用资源裁剪。ShrinkResources[3]是Android官方提供的无用资源裁剪功能,在apk构建时直接对无用资源进行删除。在app中除了通过http://R.xxx.xxx/0xffxxxxxx显式引用资源,还可以通过Resources.getIdentifier在参数中指定资源名称来引用,由于后者可以拼接甚至是动态下发字符串,因此会导致此类资源的引用关系无法被准确获取,对此ShrinkResources提供了两种模式:严格模式、正常(默认)模式。严格模式仅考虑显式引用关系,正常模式则会采用“安全优先原则”,如果资源名称以任何java代码中的常量字符串为前缀,那么会被标记为疑似引用而无法得到删除,还有其它几种“疑似性标记逻辑”不再列举。
- 【资源】多维度(备用)资源裁剪。Android资源可以配置不同维度,从而在运行时灵活适配各种不同情况(语言、屏幕尺寸、屏幕横竖状态、os版本等),这里的多维度(备用)资源裁剪,就是在apk构建期将不需要的维度裁剪掉,AndroidGradlePlugin提供了对应功能,通过android DSL配置(resConfigs)[4]即可直接完成。自研代码一般只会包含需要用到的资源,而二、三方sdk为了提高兼容性会包含尽可能多的维度,在优酷实践中对语言维度进行了裁剪(仅保留中文),瘦身收益约3%(2MB)。
- 【资源】图片压缩。图片压缩,是指在apk构建期批量对图片进行压缩,或者格式转换的一种瘦身技术。优酷自研turbo-plugin中包含了这项功能,在处理上的考量包括:提供配置项对压缩质量(quality)进行设置,这决定了压缩率(图片有损程度);有些图片压缩后,尺寸反而会变大,通过检查压缩结果,当发现这种情况时对这些图片不使用压缩。在优酷实践中,图片压缩带来的瘦身收益约0.8MB,收益较低的原因,主要是有很多模块中的图片,提前已经进行了压缩处理。
- 【资源】resources.arsc压缩。resources.arsc文件在Android原生构建流程中不会进行压缩,而运行时os识别到resources.arsc被压缩后,存在兼容逻辑对其进行解压处理,所以可以通过压缩resources.arsc来进一步降低包大小。需要注意的是,os在运行时解压缩resources.arsc会导致资源查找耗时增加,官方也不建议这么做,并且当apk的targetSdkVersion大于等于30时,无法在Android11[5]及以上设备中安装。
- 【资源】去重。对于值相同的不同名资源仅保留一份,删除重复资源并将所有引用到的地方替换为保留下来的资源。和ShrinkResources一样,在应用这项技术时依然需要注意,通过Resources.getIdentifier以资源名称作为参数方式使用到的资源,不能进行去重处理。优酷曾经使用到了这项技术,瘦身收益在MB级别,现在已经通过对应的「单点瘦身」方案,在源码层面直接处理。
- 【资源】混淆。和java代码Proguard混淆类似,是通过缩短资源名称以及文件类型资源存放路径,实现包瘦身的一种技术方案。AndResGuard[6]是实现这项瘦身技术的一套开源框架,功能相对成熟且完备,后来也有些一些同类框架在基础的资源名称缩短之上,又进一步对resources.arsc、xml资源等进行了更精细化的瘦身处理,具体可以参考相关公开文章。在应用这项技术时,依然需要注意处理Resources.getIdentifier带来的问题。
- 【so】debug信息裁剪。debug信息裁剪,是指将so中携带的debug信息删除掉,这并不会影响so正常功能,只是会导致无法源码调试so。在Android官方apk构建过程中,默认有一项StripDebugSymbol的处理逻辑,正是用于对debug信息进行裁剪,之所以作为一项整包瘦身技术放在这里,是因为如果构建环境中未包含NDK(或者NDK未在可识别路径),那么这项处理就不会执行,但是apk还可以正常生成,这一点需要特别注意。
- 【so】abi分包。abi(application binary interface)是指应用二进制接口,不同Android设备使用不同的CPU,而不同CPU支持不同的指令集,CPU与指令集的每种组合都有专属的应用二进制接口 (ABI)。在Android生态中arm CPU是绝对主流,指令集按支持的CPU(指令寻址)位数可分为32位(armeabi、armeabi-v7a)和64位(arm64-v8a)两大类,32位设备只能运行32位的so,64位设备既可以运行32位so也可以运行64位so。虽然目前市场中64位设备已经成为绝对主流,但32位设备也还没到可以舍弃的量级,因此apk如何同时支持64和32位设备就成了一道选择题:合包,即apk中同时包含32和64位两套so;分包,即分为32位和64位两个apk,各自仅包含一套对应的so。显然,后者可以极大减小包体积,但也会带来app分发的一些问题,需要辅以额外处理逻辑。对于分包方案,在apk构建时应该保障一次构建直接生成两个分包的方式,这样可以避免多次构建不一致带来的32位和64位包代码差异问题。
- 【apk】7z压缩。7z是一种压缩格式,同时也是一个压缩工具。这里的7z压缩是使用7z工具替代Android工具链,使用可以被Android系统所兼容的压缩算法,对apk中(本质就是一个zip文件)原本就会压缩的文件进行效果更好的压缩处理,从而实现瘦身的一种技术方案。由于并未改变apk元素内容值本身,因此基本无需验证即可稳定上线使用。在优酷的实践中,包大小降低约4%(3.5MB)。
4.3 单点瘦身
单点瘦身,是指在源代码层面,通过去除无用、合并冗余、修正不合理等方式实现瘦身,其核心特点可以归纳为“源头处理,轻爽健康”。由于需要在源码级别操作,因此只能针对有源码工程的自研代码,对于无源码的二、三方SDK则无法实施(其实也可以在字节码层面改造sdk,非常规方案)。另外,之所以称为“单点”瘦身,是因为需要对每一个具体的可瘦身点进行改造、验证并上线,因此最好是对代码最熟悉并负责的同学直接上手改造,这类瘦身的应用难度整体较低,但是涉及研发同学范围很广,改造周期通常也非常之久,同时在瘦身效果上一般会比较缓慢。
这里划分了9个单点瘦身技术,在优酷自研的包大小分析工具中,均实现了对应的检测分析能力,具体可以参考前文「分析技术」章节,这里简单列出:
- 【代码】线上无用类
- 【资源】超大
- 【资源】无用
- 【资源】多维度
- 【资源】无透明度png图片
- 【资源】相似
- 【so】静态链接C++ STL
- 【so】链接非标准C++STL
- 【so】无用导出符号
另外,无用和冗余的去除,本身就是一种代码质量的提升,也可以明显降低工程腐化程度,同时对构建耗时也会有正向收益。
还能做些什么
包瘦身是移动app领域长期存在的一个工程问题,无论是否关注和治理,其影响始终客观存在。接下来聊聊一些相关的思考,希望能够给感兴趣的同学带来一些有价值的参考和启发。
5.1 决心
任何新需求迭代几乎不可能做到0代码增加,因此包大小天然是一个与代码增量“对抗”的事情,但又不像稳定性、性能一样可以产生立即、直接的影响,所以在写代码时很容易被忽视。如果包瘦身的重要性并没有在app全开发团队上下,获得一致性的认可以及足够的决心,即使相关技术、卡口能力、治理策略再怎么完善,也无法在这场“包瘦身持久战”中始终利于不败之地。
前文所属的常态化治理模式下,各种技术支撑以及治理策略,究其本质都是为了将“对包大小的考量”融入到每一名研发同学的代码思维中,这样才能够在coding阶段就尽可能减少包大小不友好代码的产生。“不产生”比“产生了再治理”,在对研发同学技术能力的要求上,恐怕要高出不止一个段位。在追求卓越工程师的路上,不妨把代码对包大小的影响也纳入进来吧。
5.2 以包大小为支点
“穷则独善其身,达则兼济天下”,当包瘦身治理已经处于良好的常态化治理局面时,由于包瘦身本质还是对app工程中不合理代码的改进,因此不妨以包大小为支点,撬动用户体验、工程(代码)质量等其它方面的提升。各种以瘦身作为“导火索”的代码清理、优化、改造,实际上是对app整体工程和代码健康度的有效提升,也是促使业务间功能复用的重要推动力量。而这些代码和业务功能设计层面的提高,长期来看也会对app稳定性、性能、研发效率等的全面提升,具有很好的促进作用。
5.3 探索实践永不止步
虽然优酷的包大小治理,已经处于可持续的常态化治理模式,但瘦身相关的技术探索,以及现有技术的完整落地实践,还没有到结束的时候。很多存量技术问题仍有待挖掘,例如:对于动态链接库so的检测分析技术,还有不少可以探索的方向;对于混淆规则精简,如何能够提供更有效的辅助工具,进一步降低分析、改造、验证的成本和风险,也是一件很有挑战的事情;对于各种中间件,如何能够作出对包大小更友好的设计和迭代,这也已经超出个人、单个组织所能够完成的范围,但是如何能够对此带来更好的影响和改变也值得思考。新技术的趋势和影响也需要及时关注:AndroidX包含的新组件、新开发模式,各种手机厂商的特色能力sdk不断引入等等,都会带来新的机遇和挑战。
参考文档:
[1]https://baijiahao.baidu.com/s?id=1661744084264876433
[2]https://baijiahao.baidu.com/s?id=1661744084264876433
[3]https://developer.android.com/studio/build/shrink-code#shrink-resources
[4]https://developer.android.com/studio/build/shrink-code#unused-alt-resources
[5]https://developer.android.com/about/versions/11/behavior-changes-11#compressed-resource-file
[6]https://github.com/shwenzhang/AndResGuard/blob/master/README.zh-cn.md
作者 | 谦风
本文为阿里云原创内容,未经允许不得转载。
以上是关于向工程腐化开炮 | 治理思路全解的主要内容,如果未能解决你的问题,请参考以下文章
不止 Java,Oracle 向 JavaScript 开炮!