【小木箱成长营】包体积优化系列教程:
一、引言
Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享包体积优化 · 实战论 · 怎么做包体优化? 做好能晋升吗? 能涨多少钱?
上一次分享,小木箱从三个维度将Android包体优化方法论解释清楚,第一部分内容是针对So优化,第二部分内容是针对Res资源优化,第三部分内容是针对Assets/Raw资源优化。
关于怎么做包体优化? 小木箱主要是分两部分内容讲解,第一部分内容是包体优化过程,第二部分内容是包体优化面临的业务痛点。
包体优化的过程主要分为七部分,第一部分是优化目标,第二部分是优化排期,第三部分是优化记录,第四部分是阶段成果,第五部分是衡量指标,第六部分是CI/CD监控与预警,第七部分是采坑记录。
而业务痛点主要分为五部分。第一部分是CI/CD集成监控包体健康度,第二部分是So库压缩与解压机制,第三部分是动态加载So库与资源,第四部分是本地图片转网图,第五部分是插件化技术预研。
如果学完小木箱包体积优化的工具论、方法论和实战论,那么任何人做包体优化都可以拿到结果。
二、优化过程
2.1 优化目标
首先小木箱来讲解第一部分内容优化目标,优化目标主要分为四部分内容,包体分析、 版本对比 、 竞品对比和攻坚目标。
2.1.1 包体分析
包体分析主要借助的是腾讯AppChecker完成的,AppChecker分析包文件主要还是借助了andoid build-tool下面的 aapt工具。关于AppChecker使用指南可以参考下面的链接:
2.1.2 版本对比
APP安装包大小变化的趋势
然后小木箱利用应用市场发包情况可以绘制iOS/Android的安装包大小变化趋势图
不同版本包体波动因子详情分析
接着,小木箱拿最近四个版本的APP解包分析资源占用情况,对不同版本包体波动因子详情进行分析,其中倒数第四个版本作为基准线,当然也可以利用git工具对比分析代码特征.
2.1.3 竞品对比
分析完自身APP资源占用情况以后,再拿竞品APP进行分析,优化流程同时帮我们确认优化目标。
2.1.4 攻坚目标
保证核心业务稳定性前提下,小木箱就确定优化目标,如 main.apk size 低于 simulation1.apk size xx%。
2.2 优化排期
确认好优化目标后,小木箱对优化内容进行排期分工,排期表格模板如下
2.3 优化记录
每一个上线的需求我们要做好留档,方便复盘、沉淀、总结
排期A
排期B
排期C
2.4 阶段成果
最后,要向上输出阶段性成果,如 以V2.11正式包提交的节点xxxx为基准,apk大小为xxxM
2.5 衡量指标
测试进行回归测试主要的测试点有三个
- 打包后体积大小
- 安装速度
- 埋点
2.6 CI/CD监控与预警
接着,我们来聊聊CI/CD监控与预警,CI/CD监控与预警主要分为14部分内容,分别是机器人告警能力、APK文件主图、APK文件大小排行榜、重复资源分析、无用资源分析、依赖树结构图、重复代码分析、无用代码分析、不合规图片转换压缩、方法数汇总报告图、构建产物版本差异图、APK版本趋势折线图、绿盟黑盒质检报告和差异版本优化建议
- 机器人告警能力
机器人告警能力模块,QA机器人引入了开发环境,合码大小低于预估目标阈值发送警告通知如下
- APK文件主图
APK文件主图模块用AppChecker工具分析的文件大小占比饼状图汇总(如下,通过Echarts或其他组件渲染)
明确需求后,使用墨刀绘制产品设计稿给前端展示apk_file_size、apk_download_size、应用包名、版本名称、版本号、启动Activity、目标SDK版本号、app及arr依赖权限列表等APK表格数据
- APK文件大小排行榜
APK文件大小排行榜可以参考下图,按照大小从上至下进行排序即可
- 重复资源分析
相似图片监测可能需要使用AI技术,重复资源分析用AppChecker即可完成目的。
- 无用资源分析
当然无用资源分析也可以用AppChecker完成。
- 依赖树结构图
依赖树版本管控可以通过版本进行映射对比分析,注意要展示仓库之间的依赖层级关系。
- 重复代码分析
重复代码我们可以用FireLine进行扫描分析.
- 无用代码分析
无用代码比较麻烦一点,需要我们自定义Lint来实现.
- 不合规图片转换压缩
不合规图片转换也需要通过插件来实现,如果不想侵入代码,用脚本执行后把图片透传给前端渲染即可.
- 方法数汇总报告图
- 构建产物版本差异图
- APK版本趋势折线图
- 绿盟黑盒质检报告
企业需要和绿盟商务合作,后台服务生成监测报告,CI/CD不设置卡口阻断流程。但会通过机器人将文档链接提供业务整改。
- 差异版本优化建议
2.7 采坑记录
2.7.1 问题与挑战
知识层面,努力提升Docker 、计算机组成原理 、K8S、Linux 、gitwebhook 、机器人、Gitlab-ruuner、Githook、Kubernetes、反编译技术栈
产品层面,熟练掌握墨刀、Axure、Pencil、 Mockups和Visio等产品工具使用
工作方式层面,熟练掌握基础工作方法论,多进行头脑风暴,有时刻挨怼的心态,沟通能力非常重要
业务层面,包体检测独立任务并发还是并行?
2.7.2 原因
2.7.3 解决方式
看金字塔原理?
Devops && SRE开发知识巩固?
.....
2.7.4 思考
- 绿盟和隐私合规检查如何与CI结合渲染?产品设计怎样做更人性化?
- 推广业务接受度如何?
- 能否打成一个jar文件,然后通过命令方式将静态页面渲染生成一份可视化报告给社区使用?
- 代码混淆工作能否在打包过程完成?
- 对于通过 Google Play 分发的应用,不得采用 Google Play 更新机制以外的其他任何方式修改、替换或更新应用本身。同样地,应用不得从 Google Play 以外的其他来源下载可执行代码(例如 dex、JAR 和 .So 文件)。对此,资源动态化只能满足国内的需求。注意提供开关?
三、业务痛点
3.1 CI/CD集成监控包体健康度
包体健康度是一项比较重要但是可能被忽略的质量指标。臃肿繁杂的应用安装包不光存在更高的质量和稳定性隐患,使得问题排查的复杂度相对更大、成本更大;另一方面,安装包大小直接影响着用户的下载或保留应用的意愿。
单纯的关注整包大小并不能解决实际问题。很多版本发布流程或平台对应用整包大小都会有一些限制,从实际情况看发挥的效果非常有限,即便超过阈值也常常会因为业务需求开绿灯。如果没有一个有效的方案对应用包中存留以及新增的代码和资源的合理性进行检查和评估,并给出准确的判断结果指导业务方进行优化,应用包体积控制就会变成一个痛苦的反复讨论、对比的过程,甚至会常态化的挣扎在包体积大小的阈值线上面。为此我们技术质量部推出了包大小检查能力,取得了不错的效果。
所谓包大小检查,是根据影响包体积大小的现实问题分别列出对应的指标,例如资源文件的大小和引用情况,PNG 图片的使用情况,代码混淆情况,或者各个模块在线上被访问的热度等等。我们通过这种检查方式避免了对应用体积简单粗暴的一刀切式管理方法,转为数据驱动、以事实说话的方式,让新的需求可以合理的集成进来,同时又最大限度的保持了应用体积处在一个健康的状态。
3.2 So库压缩与解压机制
掌握So库压缩与解压机制之前我们首先需要掌握正常So加载流程 ,大致可以分为以下四个环节。
- 安装 APK 包的时候,PMS 根据当前设备的 abi 信息,从 APK 包里拷贝相应的 so 文件。到 data/data/[包名]/lib
- 启动 APP 的时候,PMS会把系统的So文件夹,以及安装包的So文件夹位置给BaseDexClassLoader中的属性DexPathList下面属性的nativeLibraryDirectories和systemNativeLibraryDirectories两个File集合 ,Android Framework 创建应用的 ClassLoader 实例,并将当前应用相关的所有 so 文件所在目录注入到当前 ClassLoader 相关字段。
- 调用 System.loadLibrary("xxx"), framework 从当前上下文 ClassLoader 实例(或者用户指定)的目录数组里查找并加载名为 libxxx.so 的文件。
- 调用 so 相关 JNI 方法。
其中System加载SO的代码如下,有两种方式,System.load是加载data/data/包名/lib下面的so文件,System.loadloadLibrary是全路径加载。
对于apk中常⽤到本地类库(so)进⾏压缩,达成优化包⼤小的目的。不过这里也有一个前提,能够优化的so是能够延迟加载的,即不是必须app启动时就要即时加载的 。
实现思路
SO压缩和解压思路比较简单,即⼲预gradle apk打包流程,在gradle merge本地库之后,打包apk之前将SO进行压缩,生成压缩⽂件保存到assets⽬录之下。
task的执⾏顺序(Develop为productFlavor名称)如下
在app启动时,解压assets⽬目录下的压缩⽂件,反射classloader,加入解压后的本地库路径 。压缩和解压配置脚本如下
soCompressConfig { // tarFileNameArray定义了了需要打包压缩的本地库⽂文件列列表 tarFileNameArray = ['test1.so', 'test2.so', 'test3.so'] // compressFileNameArray 需要压缩本地库⽂文件⽂文件名 compressFileNameArray = ['test4.so', 'test5.so'] // optinal属性 是否打印整个过程的⽇日志 , 默认false printLog = true // optional属性 本地库filter,默认armeabi-v7a abiFilters = ['armeabi-v7a'] // optional属性 压缩算法,apache commons compress⽀支持的算法,默认为lzma algorithm = 'lzma' // optional属性 debug包时是否执⾏行行本⼯工具,默认为false debugModeEnable = false // optional属性,压缩过程中是否对⽂文件进⾏行行校验,默认为true verify = true }
自定义Task压缩代码如下
@TaskAction void taskAction(){ // 如果输入文件目录和输出文件目录不存在,打断执行流程 if(inputFileDir==null||outputFileDir==null){ return } // optional属性 压缩算法,apache commons compress⽀支持的算法,默认为lzma ,内部不支持该压缩算法 if(!SUPPORT_ALGORITHM.contains(config.algorithm)){ throw new IllegalArgumentException( "only support one of ${Arrays.asList(SUPPORT_ALGORITHM).toString()}" ) } def gradleVersion=0 project.rootProject.buildscript.configurations.classpath.resolvedConfiguration.resolvedArtifacts.each { if(it.name== 'gradle' ){ gradleVersion=it.moduleVersion.id.version.replace( '.' , '' ).toInteger() }} // 找到输⼊入输出⽬目录 def libInputFileDir=null def libOutputFileDir=null inputFileDir.each{file-> if(file.getAbsolutePath().contains( 'transforms/mergeJniLibs' )){libInputFileDir=file}} outputFileDir.forEach{file-> if(gradleVersion>=320&&file.getAbsolutePath().contains( 'intermediates/merged_assets' )){libOutputFileDir=file }else if(gradleVersion< 320&&file.getAbsolutePath().contains( 'intermediates/assets' )){libOutputFileDir=file }} // 如果lib输入文件夹为空和lib输出文件夹为空,抛异常 if(libInputFileDir==null){ throw new IllegalStateException( 'libInputFileDir is null' ) } if(libOutputFileDir==null){ throw new IllegalStateException( 'libOutputFileDir is null' )} // tarFileNameArray定义了需要打包压缩的本地库⽂件列表 String[]tarFileArray=config.tarFileNameArray // compressFileNameArray 需要压缩本地库⽂件名 String[]compressFileArray=config.compressFileNameArray // 遍历lib的文件,需要打包压缩的本地库⽂件列表里面有目标文件 tarFileArray.each{fileName-> // 被压缩的文件目录里有目标文件 if(compressFileArray.contains(fileName)){ // 抛异常处理 throw new IllegalArgumentException( "${fileName} both in tarFileNameArray & compressFileNameArray" ) }} def soCompressDir=new File(libOutputFileDir,CompressConstant.SO_COMPRESSED)soCompressDir.deleteDir() if(tarFileArray.length!=0){ // 打包压缩的本地库⽂件进行排序 tarFileArray.sort() compressTar(tarFileArray,libInputFileDir,libOutputFileDir) } if(compressFileArray.length!=0){ // 压缩本地库⽂件名进行排序 compressFileArray.sort() // 压缩本地库⽂件名进行压缩 compressSoFileArray(compressFileArray,libInputFileDir,libOutputFileDir) }}
3.3 动态加载So库与资源
动态资源管理主要分为两个方向 ,第一个方向是动态加载So库,第二个方向是动态加载资源。
首先,我们先思考一个问题,Android开发动态加载So库技术是什么?
动态加载So库其实是一些边缘功能的So库或者使用时机比较晚的So库可以考虑动态加载;其中我们需要处理32、64位两套动态加载So库。
其二,动态加载资源,主要包含动画包资源或者drawable、assets的字体或html等其他类型资源,当然我们也可以加载单个文件,多个文件这2种可自定义资源。
动态资源加载主流程
关于So动态化笔者确实没有什么实战经验,既然没有实战经验就权当我做技术方案好了。So动态加载技术思考来源于货拉拉 Android 动态资源管理系统原理与实践 、我的 Android 重构之旅,动态下发 So 库(上)、动态下发 So 库在 Android APK 安装包瘦身方面的应用 、【保姆级】包体积优化教程 、SoLoader,android动态加载So库 、Android动态加载So!这一篇就够了!ReLinker 、SoLoader 和 阿里某淘Android体积优化方案九篇文章。
众所周知的原因,即使我们从APK资源文件和Dex的大小动刀,占据APK体积最大的一块依然是So和Res资源。那么我们的包体积还有没有优化空间呢?其实还是有的,我们可以把一些使用频率相对低一些的资源不打包进apk,需要的时候在下载到本地进行使用(这些资源可能包括动画文件,字体文件,So库,zip压缩包等)。针对此情况,我们需要一个动态资源的加载系统。
动态资源下载主流程大概分为五个,分别是下载资源包流程、 下载校验解压流程、本地资源包校验流程、文件校验流程和So装载流程。
下载资源包流程
动态资源的加载系统的下载一个资源包的主流程如下,首先根据资源包id创建对应的下载目录,之后判断资源包指定版本号和本地数据库版本号是否相同,如果想同,进入本地资源包校验流程,否则进入下载流程。
下载校验解压流程
我们在下载前,首先判断资源版本号是否和本地数据版本号一致,如果一致,直接走本地资源包校验流程,如果不一致,先删除掉本地文件。之后判断存储空间是否足够,存储空间足够时,调用FileDownloader进行资源的下载,下载完成后,我们进行下载文件的校验,如果校验成功,再判断该文件是否为压缩包,对于压缩包,我们还需要进行解压缩操作,这就是我们整个下载校验解压流程。
本地资源包校验流程
对于下载并解压的压缩包资源,以及本地数据库版本和资源包版本号相同的资源,我们需要进行本次资源包校验流程。该流程很简单,只要遍历资源包指定的字文件列表,对他们进行逐个文件检验就可以了
文件校验流程
单个文件资源,包含了资源的id,文件名称,资源类型,下载地址,版本号,文件长度以及md5码。多个文件资源,除了包含上述信息外。还包含了该压缩包解压后,里面每个文件的名称,文件长度以及md5码
单个文件校验的流程,当资源包中指定的文件名称,文件长度,文件md5码和本地文件相同本地文件相同时,我们认为该文件校验成功了
So装载流程
完成文件的校验的流程,我们进入So的装载流程,首先获取系统支持abi列表,根据该列表,找到合适的So动态资源实体类。如果该资源已经被加载缓存,则回调加载完成监听器。否则,开始资源通用加载流程,并异步等待资源加载成功。再次判断下载校验后的资源,是否支持本机abi。将So包路径加入DexPathList的数组头部。遍历等待加载So列表,尝试加载所有So文件,并将成功加载的So文件,移除该列表。将资源id和本地路径加入缓存,防止So被重复加载。回调加载完成监听器。
为了保证So库不存在时,程序不崩溃和So库下载检索完成后,能自动完成之前失败的加载 ,我们使用开源库Relinder的封装成一个工具类SoLoadUtil.loadLibrary进行加载,流程如下,当接收到SoLoadUtil.loadLibrary方法调用,判断加载系统是否初始化完成,如果已完成,则调用Relinkder库尝试加载So文件,未完成则将该So库加入待加载队列中。如果Relinker加载So文件成功,我们从待加载队列中移除So,并且完成本次加载。否则我们依然将So文件加入待加载队列中。根据上面的So加载流程,当So动态资源真正下载校验完成后,我们会遍历待加载队列,并完成所有之前未成功的So库加载。
那么,如何使用拦截并将System.loadLabrary替换成我们封装的SoLoadUtil.loadLibrary方法呢?当然是自定义Plugin+ASM的方式呢。自定义Plugin一共有三个任务,第一个任务是Hook System.loadLibrary,第二个任务是删除并拷贝So文件,第三个任务是压缩So资源和其他多个文件资源。主流程是 首先读取并解析自定义Plugin配置文件。然后根据配置信息,决定是否将3个task加入任务队列。最后启动任务队列。
System.loadLibrary()和System.load()最后都会调用DexPathList 的 findLibrary(),通过 DexPathList 中的 nativeLibraryDirectories 和systemNativeLibraryDirectories两个文件夹集合,生成一个NativeLibraryElement[],然后从这里面找对应的So,返回全路径,hook了DexPathList 中的 nativeLibraryDirectories,在这个文件夹集合中又添加一个咱们自己定义的文件夹
关于Hook System.loadLibrary任务,大家可以参考下面一张图,主要就是通过tranform api和asm框架的使用,我们在其中加入了扫描class范围的可配置项,等待asm框架扫描class。判断该class名称是否在我们配置的替换列表中,如果不在,就直接返回。创建ClassVisitor和MethodVisitor,等待asm框架扫描每个方法。如果该方法的名称,参数列表和调用者,都和System.loadLibrary方法相符合。我们替换为自己的SoloadUtil.loadLibrary方法
关于删除并拷贝So文件任务,详见下图,主要是根据配置文件,找到系统的merge和strip task。然后将我们的task插入到2个系统task之间,并等待系统回调我们的doLast方法。接着遍历系统的mergeTask的输出目录,判断该so文件是否在我们配置的待扫描列表中。如果配置了需要拷贝so文件,则我们将它拷贝到指定位置。如果配置了需要删除so文件,则我们将该so文件删除。
关于压缩So资源和其他多个文件资源, 首先,拷贝字体文件,将文件信息加入资源列表。然后,压缩帧动画文件,将压缩后的文件信息加入资源列表。其次,压缩so文件,将压缩后的文件信息加入资源列表。接着,压缩zip文件夹下文件,将压缩后的文件信息加入资源列表。最后,遍历资源文件,为其生成相应的资源实体类。
自定义Plugin三个任务说完了,小木箱简单总结一下,整个工程模块我们可以看做两个大的方面,第一方面是SO和资源的加载和应用,也就是打包构建后的基础行为能力,整体架构图可以参考如下
第二个能力是插件层的能力,也就是小木箱上面所说的三个任务,Hook System.loadLibrary、删除并拷贝So文件和压缩So资源和其他多个文件资源。 模块分层清晰合理,大概分为系统插件层、任务模块层和底层实现层 。
整体设计流程大概如此,关于动态加载So库与资源主要是参考自 货拉拉 Android 动态资源管理系统原理与实践一文,虽然源码没有开源,但文章讲解非常细致,如果企业APP刚好有动态化缩包业务,那么到掘金学习一下此文章事半功倍,也期待货拉拉更优秀的开源项目早日与大家见面。
动态资源加载业务痛点
实现这个动态资源加载方案有16个痛点需要解决。
痛点一 资源包下载功能由自己实现还是业务实现?
关于下载能力,企业一般是有相关的基础能力支持,当然业界也有不错的开源工具可以借鉴,如
英语流利说的FileDownloader,为了让组件职责单一化,避免重复造轮子。因此,我们将要实现的能力聚焦在资源包版本对比、资源包校验、 解压、加载So和统计上报五种能力。
痛点二 如何确定使用网络资源包还是使用本地历史资源包?
关于资源包版本对比,我们不妨把所有的资源信息存储在一个Bean对象里面,如文件名称、长度、md5、下载地址、版本号等以常量形式写在java文件中,并且打包到apk中,后继可以考虑自动生成该java文件。
痛点三 资源包如何校验,校验资源包信息,判断资源包是否正常?
通过资源信息存储类的Bean对象的版本号对比功能,使用数据库记录本地资源版本号,和资源包信息对比即可,如果资源包校验、校验文件名称、长度和md5码都相同,认为校验通过
痛点四 解压缩资源包的依据是什么
判断文件格式是否为zip文件,如果是Zip文件,那么就使用Java内置java.util.zip包下工具解压
痛点五 如何保证第三方sdk缺少So文件时,不崩溃?
很多三方sdk都要求在应用启动时,进行初始化,一个使用so库的类的典型类代码如下,
public class ThirdLib{ //静态方法加载so库 static{ System.loadLibrary("third"); } }
如果此时so库没有被加载好,直接使用ThirdLib类,则会执行static代码段中的System.loadLibrary方法,导致UnsatisfiedLinkError的错误,造成App崩溃。由于我们无法直接修改第三方sdk的源码,因此我们只能采用动态字节码技术,替换掉System.loadLibrary方法了。我们采用android的transform加asm技术,动态的将System.loadLibrary替换成我们自己的SoLoadUtil中的loadLibrary方法。Gradle Transform 是 Android 官方提供给开发者在项目构建阶段,即由 .class 到 .dex 转换期间修改 .class 文件的一套 API, 无论是class还是jar都可以控制。ASM是一种通用Java字节码操作和分析框架。它可以用于修改现有的class文件或动态生成class文件。
替换后的方法主要逻辑为,使用第三方库Relinker替代System.loadLibrary方法进行so文件加载,并且catch住加载异常,来防止应用直接奔溃,并且在加载so库异常时,将该库的名称保存下来,在我们的so包被正常下发加载后,再次调用本方法,将so库load到系统中
protected void realSoLoad(Context c, String libName) { try { ReLinker. recursively ().loadLibrary(c, libName); removeFormWaitList(libName); } catch (Throwable t) { addToWaitList(libName); } }
这样就解决了SO动态加载崩溃的问题。只需要在工程的主Application中,直接调用loadSo方法,对so动态资源进行加载。加载完成后,so库就能正常使用了。
public void loadSo(DynamicSoInfo soInfo, ILoadSoListener listener) { if (soInfo == null) { return; } //根据本机abi,获取适合的动态资源实体类DynamicPkgInfo DynamicPkgInfo pkg = soInfo.getPkgInfo(Build.SUPPORTED_ABIS); if (pkg == null) { return; } //如果该so资源,已经被加载缓存过了,直接listener的成功回调,并返回 if (isLoadAndDispatchSo(pkg, listener)) { return; } //开启资源加载,和普通资源流程一致 DynamicResManager manager = DynamicResManager.getInstance(); manager.load(pkg, new DefaultLoadResListener() { @Override public void onSucceed(LoadResInfo info) { super.onSucceed(info); //so成功下载校验后,执行加载逻辑 handleLoadSoSucceed(pkg, info, listener); } }); }
痛点六 如何下载So文件,并保证它的正确性?
当外界调用System.loadLibrary方法时,系统最终会调用到DexPathList类的findLibrary方法,该方法会在nativeLibraryPathElements数组中查找对应的路径,我们将自己的so加入到nativeLibraryPathElements最前面,由此达到动态加入so的目标。
private static void install(ClassLoader classLoader, File soFolder) throws Throwable { Field pathListField = findField(classLoader, "pathList" ); // DexPathList类的实例 Object dexPathList = pathListField.get(classLoader); // 包含了本App自带so文件的查找路径(如data/app/包名/lib/arm64) Field nativeLibraryDirectories = findField(dexPathList, "nativeLibraryDirectories" ); List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList); libDirs.add(0, soFolder); // 包含系统so文件查找路径(如system/lib64) Field systemNativeLibraryDirectories = findField(dexPathList, "systemNativeLibraryDirectories" ); List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList); // 系统使用此方法,为所有so文件,生成对应的 NativeLibraryElement对象 Method makePathElements = findMethod(dexPathList, "makePathElements" , List.class); libDirs.addAll(systemLibDirs); Object[] elements = (Object[]) makePathElements. invoke(dexPathList, libDirs); // 系统用来存储所有的so文件路径 Field nativeLibraryPathElements = findField(dexPathList, "nativeLibraryPathElements" ); nativeLibraryPathElements.setAccessible(true); nativeLibraryPathElements.set(dexPathList, elements); }
痛点七 怎么了解 APK 里所有 So 文件具体的依赖信息呢?
参考包体积优化 · 工具论 · 初识包体优化 #4.2.2.7 Android-classyshark 的使用
痛点八 对于So加载异常情况有具体的兜底方案吗?
如果so下载或者应用失败,sdk使用者会收到失败回调。使用者在此回调处对此失败情况进行处理,例如弹出toast提示用户,或者界面上展示其他失败提示信息等。所以用户是否感知此情况,取决于sdk使用者。
痛点九 支持断点续传吗?会重复下载吗?
Java代码中,使用DynamicPkgInfo类来描述资源,该类中包含了资源的版本号。我们比较该类和本地数据库中的资源版本号,如果不同,才会下载资源。本sdk只提供了下载接口,未提供实际下载功能,因此如需这些功能,需要调用者自己实现。
痛点十 有文件完整性校验吗?
DynamicPkgInfo同样包含了zip包中所有子文件的校验信息,我们利用它,来校验所有解压后的文件。
痛点十一 怎么避免64位设备下到32位So文件?
我们把arm64-v8a,armeabi-v7a等abi分开打包,上传到服务器。使用时,本地判断abi支持,下载对应的abi包。这样做的优点是节省流量和下载后占据的空间。
至于判断系统需要哪些abi的so包,并按需正确应用,则比较简单,读取系统的SUPPORTED_ABIS常量,这里包含了系统支持的abi列表,而排在前面的表示优先级更高。我们只要遍历它,然后查找我们的动态资源包是否有匹配,就达到了正确加载的目标。
private Map<String,DynamicPkgInfo> mSoInfos; public DynamicPkgInfo getPkgInfo(){ //获取本地系统支持的abi列表String[] supportAbis = Build.SUPPORTED_ABIS; if(supportAbis==null || supportAbis.length== 0 ){ return null; } //遍历abi支持列表for(String abi supportAbis){ //从so动态资源中,查找对应的abi信息DynamicPkgInfo pkg = mSoInfos.get(abi); //找到则直接返回该信息if(pkg != null){ return pkg; } } return null; }
痛点十二 远程So的选定标准是什么?
动态加载So库其实是一些边缘功能的So库或者使用时机比较晚的So库可以考虑动态加载;
痛点十三 统计上报功能,如何统计并上报资源加载的成功率?
统计上报主要埋点信息由success、error code/message、so name、retry、demotion、storage size、download type、download time、设备信息、网络信息和用户信息这些功能,
为了规避动态资源加载过程中,可能因为各种原因,导致加载未能得到成功或者失败的结果,而在中间状态被中断,如应用进程被杀死,手机关机等等。为了避免加载意外中断的情况下,完全从头开始进行加载,我们设计了一个动态资源加载的恢复流程,如果异常中断,我们下次加载资源时,可以恢复到当前状态,继续进行加载。首先,下载过程的恢复和断点续传,需要下载接口的实现者负责。然后,其他状态,我们在状态改变时,将资源id,当前状态和待处理文件路径,保存到数据库。其次,每次加载动态开始时,根据资源id查找数据库中是否有待恢复数据。接着,有待恢复数据,转到待恢复的状态,否则,直接去检查版本号状态。
最后,资源加载成功或者失败时,从数据库中删除当前资源id对应的恢复状态。并提供回调给业务要求进行成功率埋点。
痛点十四 动态资源应用如何加载到对应View上?
首先,根据资源id,从缓存中获取动态资源对应的本地文件。然后,文件获取成功,直接设置到view上,获取失败,进入下一步。其次,参数列表中的占位资源不为空,则将占位资源设置到View上。其四,将资源id设置到View的tag上,尝试清除上次动态资源加载失败状态。其五,使用管理器Manager类的load方法,执行之前的加载流程。接着,异步等待加载完成回调,判断资源id是否和View的tag相同,防止view被复用,导致的资源错乱情况。最后,如果Activity没有被销毁,则将资源设置到View上。
痛点十五 如何移除apk中的So文件,并将他们收集起来?
在编译时期,自动删除并收集so文件是最优解,那么在编译时期进行以上操作呢?小木箱注意到as在进行build时,会有大量的系统提供的task在运行,那么这些系统task是否就完成了编译并收集各个地方的so文件,并把他们打包进apk的任务。
有2个系统task会用来处理合并so库并且删除debug符号 。一般来说,应该在stripSymbols结束后去剔除 stripped_native_libs 目录下的文件。但是剔除debug符号操作,可能导致不同as版本得到的so文件md5码不相同。
因此,我们采用了可配置方案,可以由用户配置决定,在MergeNativeLibsTask或者stripDebugDebugSymbols后,执行删除输出文件夹中so文件操作。第三方 so 一般都是 Release 编译出来的,不进行strip影响也不大。而我们自己的so文件,则strip操作可能会对so体积造成较大影响。下面我们以在MergeNativeLibsTask之后,执行删除输出文件夹中so文件的方式
通过自定义gradle task并将它插入到系统的merge和strip之间,利用该Task完成删除merged_native_libs目录下对应so文件,并将其拷贝到我们指定的新目录下。这样apk打包时,就不会包含动态化的so文件了
//获取系统的mergeTask Task mergeNativeTask = TaskUtil.getMergeNativeTask(project); //获取系统的skipTask Task stripTask = TaskUtil.getStripSymbol(project); //创建我们的DeleteAndCopySo task Task deleteTask = project.getTasks().create(PluginConst.Task.DELETE_SO); deleteTask.doLast(new Action<Task>() { @Override public void execute(Task task) { deleteAndCopySo(project, param); } }); //将我们的Task插入到merge和strip之间 stripTask.dependsOn(deleteTask); deleteTask.dependsOn(mergeNativeTask);
痛点十六 如何将多个So文件压缩打包,并生成对应的信息?
首先,将so文件打包成.zip压缩包。使用java.util.zip内置包完成即可,比较简单
然后,生成该资源对应的实体类DynamicPkgInfo。包括文件id,文件名称,文件类型,版本号,下载地址等基本信息,以及文件md5,文件长度等校验信息。以及压缩包下的所有子文件及文件夹相关信息。利用了开源库javapoet实现的。
//创建DynamicResConst类,用来存储资源实体常量 TypeSpec.Builder typeBuilder = TypeSpec.classBuilder( "DynamicResConst" ) .addModifiers(Modifier.PUBLIC, Modifier.FINAL); //遍历资源列表,生成对应实体类DynamicPkgInfo for (DynamicPkgInfo pkg pkgs) { FieldSpec fsc = createField(pkg); typeBuilder.addField(fsc); } //插件java文件,并写入 JavaFile javaFile = JavaFile.builder(param.getmCreateJavaPkgName(), typeBuilder.build()).build(); try { javaFile.writeTo(new File(param.getmOutputPath())); } catch (Exception e) { }
最后,将该zip文件上传到服务器,以方便下载和使用。将so压缩包上传到服务器,我们在配置文件中提供了一个上传方法,不过默认实现为空,用户可以手动上传也可以修改默认方法实现自动上传。自动生成的资源文件中,版本号需要手动修改控制,下载地址手动上传的话,也需要手动修改。
//创建DynamicResConst类,用来存储资源实体常量 TypeSpec.Builder typeBuilder = TypeSpec.classBuilder( "DynamicResConst" ) .addModifiers(Modifier.PUBLIC, Modifier.FINAL); //遍历资源列表,生成对应实体类DynamicPkgInfo for (DynamicPkgInfo pkg pkgs) { FieldSpec fsc = createField(pkg); typeBuilder.addField(fsc); } //插件java文件,并写入 JavaFile javaFile = JavaFile.builder(param.getmCreateJavaPkgName(), typeBuilder.build()).build(); try { javaFile.writeTo(new File(param.getmOutputPath())); } catch (Exception e) { }
至此,SO和资源动态化管理就全部说完了
3.4 本地图片转网图
说完动态加载So库与资源,小木箱再说说本地图片转网图,我们可以手动把本地图片上传到oss-browser进行预加载,然后删除本地图片,修改代码加载网络图片。
如果嫌弃麻烦,可以用插桩的方式去实现,具体思路是编译时,批量上传图片,删除图片源文件并保存链接信息。然后在运行时,解析链接信息,Hook Android Drawable图片加载流程,自定义Drawable,触发网络图片下载,还原系统的Drawable图片绘制流程。具体思路如下,腾讯的ImageBus(闭源)应该是最好的实践。
3.5 插件化技术预研
插件化,商业收益非常明显,基本上各个大厂都有做插件化,方便生成轻量级Android应用,通过插件化去加载非核心模块,大家可以看一下市面上常见的八种插件化工具对比图,再选择更适合自己企业的插件化工具。
小木箱推荐大家使用Shadow插件化工具,因为Shadow主要具有五个特点,第一、复用独立安装App的源码,插件App的源码原本就是可以正常安装运行的。第二、零反射无Hack实现插件技术,从理论上就已经确定无需对任何系统做兼容开发,更无任何隐藏API调用和Google限制非公开SDK接口访问的策略完全不冲突。第三、全动态插件框架,一次性实现完美的插件框架很难,但Shadow将这些实现全部动态化起来,使插件框架的代码成为了插件的一部分。插件的迭代不再受宿主打包了旧版本插件框架所限制。第四、宿主增量极小,得益于全动态实现,真正合入宿主程序的代码量极小(15KB,160方法数左右)。第五、Kotlin实现,core.loader,core.transform核心代码完全用Kotlin实现,代码简洁易维护,最重要的是Shadow经过腾讯线上亿级用户量检验,号称“零hook”。感兴趣可以听一下Shadow教程。
四、 总结与展望
回归到主题,做好包体优化能不能晋升和加薪呢,问这个问题不如问晋升和加薪的底层逻辑是什么?主要看包体健康度是否纳入当年年度规划目标。对于一个技术型互联网软件公司而言,在未来相当一段时间,包体健康度一定是长期有效的监控指标,所以对缩包有杰出贡献的主力开发,向上汇报是相对亮眼的,且不说晋升和加薪,年度绩效不至于难看。
本次分享,小木箱主要是分享两部分内容,第一部分内容是包体优化过程,第二部分内容是包体优化面临的业务痛点。
包体优化的过程主要分为七部分,第一部分是优化目标,第二部分是优化排期,第三部分是优化记录,第四部分是阶段成果,第五部分是衡量指标,第六部分是CI/CD监控与预警,第七部分是采坑记录。
而业务痛点主要分为五部分。第一部分是CI/CD集成监控包体健康度,第二部分是So库压缩与解压机制,第三部分是动态加载So库与资源,第四部分是本地图片转网图,第五部分是插件化技术预研。
包体优化的系列文章分享已经结束,包大小健康度检查和动态资源管理是实战论的重中之重。包大小健康度检查,在构建打包阶段,通过合理的能力调整和部署、针对性的解决执行环节和实现常态化的痛点,可以实现质量控制能力的有效落实。涉及到比较有挑战性的技术栈如逆向解包、Docker、K8S、Githook等等。动态资源管理阶段,降级方案处理、CI/CD撤包和ASM插桩Hook等等有许许多多的坑,做一个高稳定和高可用的动态化SDK不仅工作量大,而且需要长期有耐心。项目复杂,需要设计合理的架构以支撑扩展,遇到疑难杂症,我们要对问题保持足够的信心。总结下来八个字 "胆大心细,小步快跑"。
我是小木箱,如果本文对你有启发,点赞和关注吧~
优质技术方案推荐
- 货拉拉 Android 动态资源管理系统原理与实践
- 我的 Android 重构之旅,动态下发 So 库(上)
- 动态下发 So 库在 Android APK 安装包瘦身方面的应用
- 【保姆级】包体积优化教程
- SoLoader,android动态加载So库
- Android动态加载So!这一篇就够了!ReLinker
- SoLoader
- 阿里某淘Android体积优化方案
- 货拉拉 Android 包体积优化实践