包体积优化 · 实战论 · 怎么做包体优化? 做好能晋升吗? 能涨多少钱?

news/2024/12/2 13:40:28/

【小木箱成长营】包体积优化系列文章:

包体积优化 · 方法论 · 揭开包体积优化神秘面纱

包体积优化 · 工具论 · 初识包体积优化

BaguTree 包体积优化录播视频课

一、引言

Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享包体积优化 · 实战论 · 怎么做包体优化? 做好能晋升吗? 能涨多少钱?

上一次分享,小木箱从三个维度将 Android 包体优化方法论解释清楚,第一部分内容是针对 So 优化,第二部分内容是针对 Res 资源优化,第三部分内容是针对 Assets/Raw 资源优化。

关于怎么做包体优化? 小木箱主要是分两部分内容讲解,第一部分内容是包体优化过程,第二部分内容是包体优化面临的业务痛点。

包体优化的过程主要分为七部分,第一部分是优化目标,第二部分是优化排期,第三部分是优化记录,第四部分是阶段成果,第五部分是衡量指标,第六部分是 CI/CD 监控与预警,第七部分是采坑记录。

而业务痛点主要分为五部分。第一部分是 CI/CD 集成监控包体健康度,第二部分是 So 库压缩与解压机制,第三部分是动态加载 So 库与资源,第四部分是本地图片转网图,第五部分是插件化技术预研。

如果学完小木箱包体积优化的工具论、方法论和实战论,那么任何人做包体优化都可以拿到结果。

二、优化过程

2.1 优化目标

首先小木箱来讲解第一部分内容优化目标,优化目标主要分为四部分内容,包体分析、 版本对比 、 竞品对比和攻坚目标。

2.1.1 包体分析

包体分析主要借助的是腾讯 AppChecker 完成的,AppChecker 分析包文件主要还是借助了 andoid build-tool 下面的 aapt 工具。关于 AppChecker 使用指南可以参考下面的链接:

https://github.com/Tencent/matrix#matrix_android_cn

alt

alt

2.1.2 版本对比
APP 安装包大小变化的趋势

然后小木箱利用应用市场发包情况可以绘制 iOS/Android 的安装包大小变化趋势图

alt

不同版本包体波动因子详情分析

接着,小木箱拿最近四个版本的 APP 解包分析资源占用情况,对不同版本包体波动因子详情进行分析,其中倒数第四个版本作为基准线,当然也可以利用 git 工具对比分析代码特征.

image.png

2.1.3 竞品对比

分析完自身 APP 资源占用情况以后,再拿竞品 APP 进行分析,优化流程同时帮我们确认优化目标。

image.png

2.1.4 攻坚目标

保证核心业务稳定性前提下,小木箱就确定优化目标,如 main.apk size 低于 simulation1.apk size xx%。

2.2 优化排期

确认好优化目标后,小木箱对优化内容进行排期分工,排期表格模板如下

image.png

2.3 优化记录

每一个上线的需求我们要做好留档,方便复盘、沉淀、总结

排期 A

image.png
image.png

排期 B

image.png

排期 C

image.png

2.4 阶段成果

最后,要向上输出阶段性成果,如 以 V2.11 正式包提交的节点 xxxx 为基准,apk 大小为 xxxM

image.png

2.5 衡量指标

测试进行回归测试主要的测试点有三个

  1. 打包后体积大小
  2. 安装速度
  3. 埋点

2.6 CI/CD 监控与预警

接着,我们来聊聊 CI/CD 监控与预警,CI/CD 监控与预警主要分为 14 部分内容,分别是机器人告警能力、APK 文件主图、APK 文件大小排行榜、重复资源分析、无用资源分析、依赖树结构图、重复代码分析、无用代码分析、不合规图片转换压缩、方法数汇总报告图、构建产物版本差异图、APK 版本趋势折线图、绿盟黑盒质检报告和差异版本优化建议

  1. 机器人告警能力

    机器人告警能力模块,QA 机器人引入了开发环境,合码大小低于预估目标阈值发送警告通知如下

alt
  1. APK 文件主图

    APK 文件主图模块用 AppChecker 工具分析的文件大小占比饼状图汇总(如下,通过 Echarts 或其他组件渲染)

alt

明确需求后,使用墨刀绘制产品设计稿给前端展示 apk_file_size、apk_download_size、应用包名、版本名称、版本号、启动 Activity、目标 SDK 版本号、app 及 arr 依赖权限列表等 APK 表格数据

alt

  1. APK 文件大小排行榜

APK 文件大小排行榜可以参考下图,按照大小从上至下进行排序即可

alt

  1. 重复资源分析

相似图片监测可能需要使用 AI 技术,重复资源分析用 AppChecker 即可完成目的。

alt

  1. 无用资源分析

当然无用资源分析也可以用 AppChecker 完成。

alt

alt

  1. 依赖树结构图

    依赖树版本管控可以通过版本进行映射对比分析,注意要展示仓库之间的依赖层级关系。

image.png
image.png

alt

  1. 重复代码分析

重复代码我们可以用 FireLine 进行扫描分析.

alt

  1. 无用代码分析

无用代码比较麻烦一点,需要我们自定义 Lint 来实现.

alt

alt

  1. 不合规图片转换压缩

不合规图片转换也需要通过插件来实现,如果不想侵入代码,用脚本执行后把图片透传给前端渲染即可.

alt

  1. 方法数汇总报告图

alt

  1. 构建产物版本差异图

小石头.png

木箱.png

  1. APK 版本趋势折线图

alt

  1. 绿盟黑盒质检报告

企业需要和绿盟商务合作,后台服务生成监测报告,CI/CD 不设置卡口阻断流程。但会通过机器人将文档链接提供业务整改。

  1. 差异版本优化建议

alt

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 思考

  1. 绿盟和隐私合规检查如何与 CI 结合渲染?产品设计怎样做更人性化?
  2. 推广业务接受度如何?
  3. 能否打成一个 jar 文件,然后通过命令方式将静态页面渲染生成一份可视化报告给社区使用?
  4. 代码混淆工作能否在打包过程完成?
  5. 对于通过 Google Play 分发的应用,不得采用 Google Play 更新机制以外的其他任何方式修改、替换或更新应用本身。同样地,应用不得从 Google Play 以外的其他来源下载可执行代码(例如 dex、JAR 和 .So 文件)。对此,资源动态化只能满足国内的需求。注意提供开关?

三、业务痛点

3.1 CI/CD 集成监控包体健康度

包体健康度是一项比较重要但是可能被忽略的质量指标。臃肿繁杂的应用安装包不光存在更高的质量和稳定性隐患,使得问题排查的复杂度相对更大、成本更大;另一方面,安装包大小直接影响着用户的下载或保留应用的意愿。

alt

单纯的关注整包大小并不能解决实际问题。很多版本发布流程或平台对应用整包大小都会有一些限制,从实际情况看发挥的效果非常有限,即便超过阈值也常常会因为业务需求开绿灯。如果没有一个有效的方案对应用包中存留以及新增的代码和资源的合理性进行检查和评估,并给出准确的判断结果指导业务方进行优化,应用包体积控制就会变成一个痛苦的反复讨论、对比的过程,甚至会常态化的挣扎在包体积大小的阈值线上面。为此我们技术质量部推出了包大小检查能力,取得了不错的效果。

alt

所谓包大小检查,是根据影响包体积大小的现实问题分别列出对应的指标,例如资源文件的大小和引用情况,PNG 图片的使用情况,代码混淆情况,或者各个模块在线上被访问的热度等等。我们通过这种检查方式避免了对应用体积简单粗暴的一刀切式管理方法,转为数据驱动、以事实说话的方式,让新的需求可以合理的集成进来,同时又最大限度的保持了应用体积处在一个健康的状态。

alt

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 是全路径加载。

alt

对于 apk 中常⽤到本地类库(so)进⾏压缩,达成优化包⼤小的目的。不过这里也有一个前提,能够优化的 so 是能够延迟加载的,即不是必须 app 启动时就要即时加载的 。

实现思路

传送门 https://github.com/Android-Mainli/Android-So-Handler

SO 压缩和解压思路比较简单,即⼲预 gradle apk 打包流程,在 gradle merge 本地库之后,打包 apk 之前将 SO 进行压缩,生成压缩⽂件保存到 assets ⽬录之下。

task 的执⾏顺序(Develop 为 productFlavor 名称)如下

image.png

在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 装载流程。

image.png
image.png
下载资源包流程

动态资源的加载系统的下载一个资源包的主流程如下,首先根据资源包 id 创建对应的下载目录,之后判断资源包指定版本号和本地数据库版本号是否相同,如果想同,进入本地资源包校验流程,否则进入下载流程。

alt

下载校验解压流程

我们在下载前,首先判断资源版本号是否和本地数据版本号一致,如果一致,直接走本地资源包校验流程,如果不一致,先删除掉本地文件。之后判断存储空间是否足够,存储空间足够时,调用FileDownloader进行资源的下载,下载完成后,我们进行下载文件的校验,如果校验成功,再判断该文件是否为压缩包,对于压缩包,我们还需要进行解压缩操作,这就是我们整个下载校验解压流程。

alt

本地资源包校验流程

对于下载并解压的压缩包资源,以及本地数据库版本和资源包版本号相同的资源,我们需要进行本次资源包校验流程。该流程很简单,只要遍历资源包指定的字文件列表,对他们进行逐个文件检验就可以了

alt

文件校验流程

单个文件资源,包含了资源的 id,文件名称,资源类型,下载地址,版本号,文件长度以及 md5 码。多个文件资源,除了包含上述信息外。还包含了该压缩包解压后,里面每个文件的名称,文件长度以及 md5 码

alt

单个文件校验的流程,当资源包中指定的文件名称,文件长度,文件 md5 码和本地文件相同本地文件相同时,我们认为该文件校验成功了

alt

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 库加载。

alt

那么,如何使用拦截并将 System.loadLabrary 替换成我们封装的 SoLoadUtil.loadLibrary 方法呢?当然是自定义 Plugin+ASM 的方式呢。自定义 Plugin 一共有三个任务,第一个任务是Hook System.loadLibrary,第二个任务是删除并拷贝 So 文件,第三个任务是压缩 So 资源和其他多个文件资源。主流程是 首先读取并解析自定义 Plugin 配置文件。然后根据配置信息,决定是否将 3 个 task 加入任务队列。最后启动任务队列。

alt

System.loadLibrary()和 System.load()最后都会调用 DexPathList 的 findLibrary(),通过 DexPathList 中的 nativeLibraryDirectories 和 systemNativeLibraryDirectories 两个文件夹集合,生成一个 NativeLibraryElement[],然后从这里面找对应的 So,返回全路径,hook 了 DexPathList 中的 nativeLibraryDirectories,在这个文件夹集合中又添加一个咱们自己定义的文件夹

alt

关于 Hook System.loadLibrary 任务,大家可以参考下面一张图,主要就是通过 tranform api 和 asm 框架的使用,我们在其中加入了扫描 class 范围的可配置项,等待 asm 框架扫描 class。判断该 class 名称是否在我们配置的替换列表中,如果不在,就直接返回。创建 ClassVisitor 和 MethodVisitor,等待 asm 框架扫描每个方法。如果该方法的名称,参数列表和调用者,都和 System.loadLibrary 方法相符合。我们替换为自己的 SoloadUtil.loadLibrary 方法

alt

关于删除并拷贝 So 文件任务,详见下图,主要是根据配置文件,找到系统的 merge 和 strip task。然后将我们的 task 插入到 2 个系统 task 之间,并等待系统回调我们的 doLast 方法。接着遍历系统的 mergeTask 的输出目录,判断该 so 文件是否在我们配置的待扫描列表中。如果配置了需要拷贝 so 文件,则我们将它拷贝到指定位置。如果配置了需要删除 so 文件,则我们将该 so 文件删除。

alt

关于压缩 So 资源和其他多个文件资源 首先,拷贝字体文件,将文件信息加入资源列表。然后,压缩帧动画文件,将压缩后的文件信息加入资源列表。其次,压缩 so 文件,将压缩后的文件信息加入资源列表。接着,压缩 zip 文件夹下文件,将压缩后的文件信息加入资源列表。最后,遍历资源文件,为其生成相应的资源实体类。

alt

自定义 Plugin 三个任务说完了,小木箱简单总结一下,整个工程模块我们可以看做两个大的方面,第一方面是 SO 和资源的加载和应用,也就是打包构建后的基础行为能力,整体架构图可以参考如下

alt

第二个能力是插件层的能力,也就是小木箱上面所说的三个任务,Hook System.loadLibrary、删除并拷贝 So 文件和压缩 So 资源和其他多个文件资源。 模块分层清晰合理,大概分为系统插件层、任务模块层和底层实现层

alt

整体设计流程大概如此,关于动态加载 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 文件。

alt

替换后的方法主要逻辑为,使用第三方库 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 的使用

alt

痛点八 对于 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 查找数据库中是否有待恢复数据。接着,有待恢复数据,转到待恢复的状态,否则,直接去检查版本号状态。

alt

最后,资源加载成功或者失败时,从数据库中删除当前资源 id 对应的恢复状态。并提供回调给业务要求进行成功率埋点。

痛点十四 动态资源应用如何加载到对应 View 上?

首先,根据资源 id,从缓存中获取动态资源对应的本地文件。然后,文件获取成功,直接设置到 view 上,获取失败,进入下一步。其次,参数列表中的占位资源不为空,则将占位资源设置到 View 上。其四,将资源 id 设置到 View 的 tag 上,尝试清除上次动态资源加载失败状态。其五,使用管理器 Manager 类的 load 方法,执行之前的加载流程。接着,异步等待加载完成回调,判断资源 id 是否和 View 的 tag 相同,防止 view 被复用,导致的资源错乱情况。最后,如果 Activity 没有被销毁,则将资源设置到 View 上。

alt

痛点十五 如何移除 apk 中的 So 文件,并将他们收集起来?

在编译时期,自动删除并收集 so 文件是最优解,那么在编译时期进行以上操作呢?小木箱注意到 as 在进行 build 时,会有大量的系统提供的 task 在运行,那么这些系统 task 是否就完成了编译并收集各个地方的 so 文件,并把他们打包进 apk 的任务。

alt

有 2 个系统 task 会用来处理合并 so 库并且删除 debug 符号 。一般来说,应该在 stripSymbols 结束后去剔除 stripped_native_libs 目录下的文件。但是剔除 debug 符号操作,可能导致不同 as 版本得到的 so 文件 md5 码不相同。

因此,我们采用了可配置方案,可以由用户配置决定,在 MergeNativeLibsTask 或者 stripDebugDebugSymbols 后,执行删除输出文件夹中 so 文件操作。第三方 so 一般都是 Release 编译出来的,不进行 strip 影响也不大。而我们自己的 so 文件,则 strip 操作可能会对 so 体积造成较大影响。下面我们以在 MergeNativeLibsTask 之后,执行删除输出文件夹中 so 文件的方式

alt

通过自定义 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进行预加载,然后删除本地图片,修改代码加载网络图片。

alt

如果嫌弃麻烦,可以用插桩的方式去实现,具体思路是编译时,批量上传图片,删除图片源文件并保存链接信息。然后在运行时,解析链接信息,Hook Android Drawable 图片加载流程,自定义 Drawable,触发网络图片下载,还原系统的 Drawable 图片绘制流程。具体思路如下,腾讯的 ImageBus(闭源)应该是最好的实践。

alt

alt

3.5 插件化技术预研

插件化,商业收益非常明显,基本上各个大厂都有做插件化,方便生成轻量级 Android 应用,通过插件化去加载非核心模块,大家可以看一下市面上常见的八种插件化工具对比图,再选择更适合自己企业的插件化工具。

image.png

小木箱推荐大家使用 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 包体积优化实践

本文由 mdnice 多平台发布


http://www.ppmy.cn/news/11892.html

相关文章

锁策略和synchronized

1.常见的锁策略&#xff08;1&#xff09;乐观锁 和 悲观锁乐观锁&#xff1a;预测锁竞争的情况不激烈&#xff08;工作量较少&#xff09;悲观锁&#xff1a;预测锁竞争的情况很激烈&#xff08;工作量较多&#xff09;&#xff08;2&#xff09;轻量级锁 和 重量级锁轻量级锁…

【每日一题】【LeetCode】【第十二天】区域和检索 - 数组不可变

解决之路 题目描述 测试案例&#xff08;部分&#xff09; 第一次 emmm&#xff0c;说实话&#xff0c;一开始我还真没看懂题目是什么意思。。。。 自己按我自己理解的方式写了一下代码&#xff0c;用测试案例跑了下&#xff0c;成功了。 不过&#xff0c;放进去跑不通&…

【Linux】六、Linux 基础IO(一)|重谈文件|C语言文件操作|操作系统文件操作(系统文件I/O)|文件描述符

目录 一、重谈文件 二、C语言文件操作 2.1 重谈C语言文件操作 2.2 补充细节 三、操作系统文件操作&#xff08;系统文件I/O&#xff09; 3.1 文件相关系统调用&#xff1a;close 3.2 文件相关系统调用&#xff1a;open 3.2.1 open 的第二个参数 flags 3.2.2 open 的第…

自定义类型:结构体,枚举,联合(2)

TIPS 1. 类型的定义可以考虑放在头文件里头。 2. 一个汉字存储的时候占两个字节空间 3. 关于结构体变量初始化的一些细节 4. 关于结构体内存对齐的补充 1. 2. S1和S2类型的成员一模一样&#xff0c;但是S1和S2所占空间的大小有了一些区别。 3. 这两个结构体类型成员都…

C++ Socket 构造函数参数解析

int socket(int af, int type, int protocol); 1、) af 为地址族&#xff08;Address Family&#xff09;&#xff0c;也就是 IP 地址类型&#xff0c;常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的简写&#xff0c;INET是“Inetnet”的简写。AF_INET 表示 IPv4 地…

Android ANR bugreport log分析

最近工作中频繁遇到设备ANR问题&#xff0c;而且是概率性的那种&#xff0c;于是决定花点时间找找规律复现分析下 说道这里&#xff0c;抓日志是问题解决的最有效途径&#xff0c;这里不得不说一下 bugreport log,其实网上关于它的分析方法有很多&#xff0c;在此仅仅是为了记录…

自定义类型:结构体,枚举,联合(3)

TIPS 1. 2. 枚举 1. 枚举顾名思义就是一一列举可能的取值&#xff0c;比如一周的星期一到星球天是有限的七天&#xff0c;可以一一列举。有比如性别&#xff0c;月份。 2. 像这种容易并且可以被一一列举的数据我们就可以定义为枚举类型。 枚举类型 1. 枚举的关键字为e…

【Git】GitHub 操作

6、GitHub 操作 GitHub 网址&#xff1a;https://github.com/ Ps:全球最大同性交友网站&#xff0c;技术宅男的天堂&#xff0c;新世界的大门&#xff0c;你还在等什么? 账号姓名验证邮箱atguiguyueyue岳不群atguiguyueyuealiyun.comatguigulinghuchong令狐冲atguigulinghu…