一、Android APK 包体积优化

3,306 阅读11分钟

前言

由于业务发展快,前期视觉提供的照片都是PNG无损格式的,项目里也存在很多无用资源,在初期,团队没有注意这些,现在处于稳定期,就需要考虑着手包体积优化,为用户节省点流量,优化体验。而这个任务分配给了笔者,(后续还有内存优化),接下来将结合极客时间的Android开发高手课 + 项目实际经验来介绍一下体积包的优化。本文将分常规瘦身方案和进阶瘦身方案来记录,常规瘦身方案基本应该是大家都会做的,进阶瘦身有很多方案,可以看看支付宝mPass公众号和抖音的一些相关技术文章。

参考资料:

极客时间-Andorid开发高手课

深入探索Android包体积优化(匠心之作) jsonchao 写的很细,在学习之前可以看一下他写的插桩编译原理基础部分

慕课网-Top团队大牛带你玩转Android性能分析与优化

思维导图目录


一、瘦身优势和APK分析方案

1、 瘦身优势

转化率

在项目初期瘦身基本上都不会做,初期功能模块显得更重要,并且现在网速也很快,下载个百来兆的应用也不慢,更何况5G发展起来了,网速蹭蹭蹭往上飙。但是没有对比就没有伤害,等同条件下的APP,体积包小的APP,用户应该是更倾向于下载它的,那么该APP是不是下载转换率也就会高一点,所以包体积大小也成为了衡量APP业务的重要指标之一。

应用市场

Google Play市场对APK的包是有要求的,超过100MB的应用只可以用APK扩展方式上传,如果要避免使用扩展文件,那么就应该使用Android App Bundles,Android Studio里由个选项Build Bundle。

安装速度

我们的APP体积32MB,在OPPO应用市场检测报告中显示安装时间得60分,16.52s是最佳安装时间,测出来是47.11s,抖音是17.81s是最佳安装时间,实际90.26s,也得60分。也不知道Testin是怎么测出来的,个人感觉安装很快,都要不了16s。安装过程中涉及文件拷贝、Library解压、签名校验(文末有学习链接),安装包体积解压后肯定比原来APK所占内存大,解压后的体积不需要过多考虑,毕竟现在手机内存都很大,当然如果实在不行,可以安装在SD卡上,但是安装在SD卡上有两个问题,安装在SD卡上所需要的内存会比安装在手机上的大很多,还有一个就是例如开机广播不会触发这类问题。

2、APK组成

dex文件 class转成dex文件

lib 存放native需要的so文件

assets 存放打包静态资源文件,目录层级不限制,不会在R文件中生成ID,通过AssetManager获取

res 存放资源文件,会在R文件中生成对应的ID,运行时通过ID找到对应的资源路径

resources.arsc ID和资源文件的映射关系

AndroidManifest.xml 清单文件

META-INF 存放签名相关信息,解压安装时会用到(文末有学习链接),用于验证APK的完成性和保证APK的安全

3、APK分析

a、ApkTool

ApkTool官网 或者 Jadx 配置使用方法

如果比较懒的话,可以用这个软件直接打开apk文件,看smali文件 Smali2Java

反编译后可以看类文件,如果混淆的话,可能就不好看了

b、AS 自带的Analyze APK

这个相信大家都知道在哪,我平时都用它来分析APK,和别的APK做对比,它还能很直观的查看资源对应ID

c、nimbledroid性能分析指标

使用方式简单,直接打开 nimbledroid官网,上传APK就可以了。But 网站注册必须使用公司邮箱注册,个人邮箱注册Error Hint

它的相关性能指标可以从两方面分析

静态分析 可以看APK安装包大文件排行,Dex方法数及比例,包括第三方SDK

动态分析 可以看冷启动时间,Block UI具体方法,Hot Method等

d、android-classyshark 二进制检查工具

Github地址

使用方式很简单,下载jar包,然后拖动APK到打开的窗口内即可,可以看到类、成员变量等信息



二、常规瘦身方案

为什么说是常规瘦身方案呢,其实也就是很容易在代码里实践的方法,类似于网上的APK 九道瘦身程序,下面我们按分类来看看

1、代码瘦身

a、ProGuard

混淆的重要性就不过多阐述了,其实每个上线的APP都会做混淆,可能大部分初级开发只了解混淆能将字段、方法、类的名称改成简短的名字,增加了反编译的难度,起到一定的安全作用当然这个也算瘦身。还有一个作用,它会检测移除未使用的类、方法、字段、冗余代码,对字节码进行深度优化。想要具体了解可以学习底部链接知识,包括混淆的规则。

优点

在将枚举类型简化为原始整数方面会更加强大

在删除所有跟踪(包括组成日志消息的字符串操作)方面更有效

应用的模式匹配算法可以识别和替换短指令序列,从而提高代码效率并为更多优化打开了机会。在优化遍历的顺序中,尤其是数学运算和字符串运算可从中受益

具有独特的能力来优化使用 GSON 库将对象序列化或反序列化为 JSON 的代码,访问更加高效

buildTypes{
    release{
        //开启混淆
        minifyEnabled true
        //sdk默认的混淆配置,默认打开了优化开关 /-dontoptimize 关闭优化 /-dontobfuscate 关闭混淆
        proguardFiles getDefaultProguardFile('..'),'我们自定义的混淆配置.pro'
    }
}

b、Dex分包

与传统jar文件比较,dex文件的大小能够缩小50%左右,而且dex是分区结构,内部各个区块通过offset来进行索引,class是流式接构,一个Dex中包含了很多class。个人理解,为什么android不用class,而重新设计一个dex,从优化角度理解,完全是因为class只能针对某个类优化,而dex包含了很多类,文件结构紧凑,类之间还可以优化,优化空间更大。

当我们APK方法数超过65536时,(原因是首次加载dex时,DexOpt是把所有方法id存起来,放到链表里,链表长度定义的是short类型,所以超过2的16次方-1就报错了)会报错,必须采用 mutildex 进行分包

defaultConfig{
    multiDexEnabled true
}
dependencies{
    //仅对当前包可见
    implementation 'com.android.support.multidex:1.0.0'
}

但是,分包后会有什么问题?想过没,说实话,作为渣渣的我确实没想过。读者可以想一下,分包后有什么问题,答案在本文下面的进阶瘦身方案Dex模块会讲解。

c、D8 R8

官网介绍 只要gradle版本大于3.4.0,R8都是默认开启的,但是R8是3.3.0引入的。下面两张图可以看出使用R8的区别



下面了解一下D8 R8的优化效果以及开启方式

D8

Dex的编译实践更多

.dex文件更小并且拥有更好的运行时性能

包含java 8语言支持的处理

使用方式(AS3.1默认)

gradle.properties  
android.enableD8=true

R8

和混淆类似,使用随机无意义的名称

inline内联,避免对象分配

在删除未使用的类、字段、防范上更好

与proguard相比,R8能使apk减小约10%,体积更小

使用方式

android.enableR8=true
android.enableR8.libraries=true

d、移除无用代码

随着业务增加,许多之前使用的类可能并没有用到,然后又想着以后万一又能用到呢?反正就一个文件,删不删除无所谓,占内存又不大,长此以往,apk就越来越大了。

我平时都用Android Studio自带的 Lint 检测工具无效代码

慕课网的性能优化视频里讲到过一种方案,通过ASpectJ(简单入门)对某个包下的类就行构造函数切面

@After("execution(org.jay.launchstarter.Task.new(..)")
public void newObject(JoinPoint point) {
    LogHelper.i(" new " + point.getTarget().getClass().getSimpleName());
}

感觉这个确实能打印出来使用的类,可以搜集起来,那还得写一个脚本找出没使用得类,删除,有点风险。

我觉得还是jsonchao提出的方案简单有效,通过 coverage插件 基于线上用户上报无用代码分析

2、资源瘦身

首先我们了解一下APK解压后,资源相关的目录

a、资源混淆

混淆优点

  • resources.arsc中需要记录资源文件名与路径,混淆后短路径会减少整个文件大小,例如res/drawable/icon -> res/s/a

  • metadata签名文件,使用短路径也会减小文件大小。这块想详细了解可以看 深入探索编译插桩技术之编译基础Android APK 签名原理

  • ZIP文件索引。zip文件格式里面也需要记录每个文件的Entry路径、压缩算法、CRC校验、文件大小等信息,使用短路径也可以减小文件路径的字符串大小。

采用方案 微信的AndResGuard

b、无用资源删除

android{
    ....
    buildTypes{
        release{
            shrinkResources true
            minifyEnable true
        }
    }
}

一般我们都会在build.gradle里面配置上述代码,并通过 Android Studio 自带的Lint检测工具 进行 删除无用资源,这里注意,笔者操作过程中,发现用Lint检测出来的无用资源,很多其实都引用了的。

注意,这里配置 shrinkResources true 是真的将无用资源去除了吗?答案:不是

理由有两点

  • 没有处理resources.arsc文件,这样导致大量无用的String 、ID、Attr、Dimen等资源并没有被删除

  • 没有真正删除资源文件,对于Drawable、Layout这些无用资源,shrinkResources 也没有真正把它们删除,仅仅是替换为了一个空文件,为什么不删除呢?因为resources.arsc里面还有这些文件的路径

综上,所以说系统目前的这种做法并没有真正减少文件数量,resources.arsc、签名信息、以及ZIP文件信息依然没有改善。

为什么系统不做删除呢?因为resources.arsc和R文件的资源ID默认是连续的,删除无用资源文件,资源ID会改变,这个时候代码中已经替换过的ID就会出现资源找不到的情况,所以只是替换成了空文件。

思路方案:删除文件,同时keep住R文件和resources.arsc中的ID

还有一种,利用 android-arscblamer 分析,具体可以查看 在 Android 构建工具执行 package${flavorName}Task 之前通过修改 Compiled Resources 来实现自动去除无用资源

c、图片压缩

这个比较好实现,笔者将项目中大于10k的图片都压缩了一下,体积减小了3%,笔记使用了cwebp 工具,转换成webp

# coding=utf-8
import os
from pathlib import Path
​
'''
file_directory : 目录
modules : 变长参数 指定模块才解析
'''
​
​
def findAndConvert(file_directory, *modules):
    if len(modules) == 0:
        print("modules must Designated")
        return
    if file_directory.is_file():
        print("this is a file , need a directory")
        return
    if os.path.exists(file_directory):
        print("has this directory")
        for homes, dirs, files in os.walk(file_directory):
            # print(homes)
            # print(dirs)
            # print(files)
            for module in list(modules):
                check_path = os.path.join(file_directory.absolute(), module)
​
                if check_path in homes:
                    # print("============== convert  this path " + check_path + " ================== ")
                    # 寻找 src/main/res/darwable开头的文件
                    # 或者直接找png jpg图片
                    if "src\\main\\res" in homes and "drawable" in homes:
                        # print(homes)
                        for file in files:
                            if file.endswith(".png") or file.endswith(".jpg"):
                                file_path = Path(os.path.join(homes, file))
                                if file_path.is_file() and os.path.getsize(file_path.absolute()) > 10 * 1024:
                                    # print(file)
                                    # print(file[:-4])
                                    current_file = os.path.join(homes, file)
                                    target_file = os.path.join(homes, file[:-4] + ".webp")
                                    cmd = "cwebp " + current_file + " -o " + target_file
                                    result = os.system(cmd)
                                    if result == 0:
                                        # os.remove(file_path.absolute())
                                        os.system("svn add " + target_file)
                                        os.system("svn del " + current_file)
                # else:
                # print("please check this path " + check_path)
​
​
    else:
        print("this directory has not exists")
​
​
if __name__ == '__main__':
    path = Path("D:/WorkSVN/项目Prject/")
    # 指定包名 变长参数必填
    findAndConvert(path, "模块名","模块名2")
 

d、资源文件规范化

  • 尽量每张图片只保留一份,一般准备一份xhdpi的就可以

  • 资源图片尽量让后端返回,如果后端能根据前端上传的图片宽高进行裁剪再返回,这更加好,还能优化Bitmap相关内存

  • 在resConfig中配置保留哪些资源,减少不必要的资源

    android {
        ...
        defaultConfig {
            ...
            resConfigs "zh-rCN"
            resConfigs "xhdpi"
        }
        ...
    } 

3、SO瘦身

这个不多说了,直接上代码

defaultConfig {
    ndk {
        abiFilters "armeabi"
    }
}

现在市面上大多都是armeabi架构,例如,微信APK分析一下,也能发现只有armeabi,虽然微信对加载armeabi做了CPU 架构的适配,什么时候加载armeabi的哪个so文件,都有适配。

详细可以了解 为何大厂APP如微信、支付宝、淘宝、手Q等只适配了armeabi-v7a/armeabi?

这里给出一个abi工作规则图

从图中可以得出另一个规则

  • 只适配armeabi的APP可以跑在armeabi,x86,x86_64,armeabi-v7a,arm64-v8

  • 只适配armeabi-v7a可以运行在armeabi-v7aarm64-v8a

  • 只适配arm64-v8a 可以运行在arm64-v8a

三、进阶瘦身方案

1、代码瘦身

a、三方库处理

其实这个都不能算做进阶瘦身方案,但在实际操作中,我确实没有想到对三方库进行处理,导致没必要的依赖加载进来的,毕竟将所有模块代码看一遍,很多模块不是自己写的,优化起来丢三落四的,所以放到进阶方案里了。

在选择第三方库时,需要评审一下,以性能为第一指标,包体积为第二指标,尽量选择性能好,体积小的第三方SDK,并且在使用到第三方SDK某个功能时,尽量继承某个功能依赖,而不是全部,例如只需要Fresco的某个webp功能,那就只集成webp依赖就好了。

我按照方案,将项目里所有依赖都过了一遍,发现图片相关Glide和Fresco都使用了,GG,并不能选择一个用,改动太大,涉及测试也很多,只能将这种方案在组内会议规范一下。(PS:Fresco加载长图会显示空白的)

b、去除debug信息与行号信息

dex文件结构图(摘自极客时间Andoid开发高手课包体积优化)

一般,我们都会在混淆配置中以下面的方式保留行号信息

-keepattributes SourceFile, LineNumberTable

如果不保留行号信息,dex大约可以减少5%的体积,但是Crash上报后,会拿不到行号怎么办?问题该怎么定位?同学,莫慌,支付宝Android 包大小极致压缩方案 帮你解决。

当然,支付宝这个方案是参考的Facebook的 ReDex ,这个只支持linux、和mac。因为我之前了解过ReDex,所以在接到压缩体积包时,也调研过,可是编译阶段硬是没有编译过去,后面试了两次,还是有点问题,就没有使用该方案。

ReDex 有六个优点

  • 基于反馈(启动加载顺序测试)的Class字节码布局

  • 混淆和压缩

  • 内联函数,将一些函数直接展开到调用它的函数中,减少函数调用切换的时间消耗

  • 删除无用的interface,删除只有一个实现的接口,用实现类直接代替,加快了函数调用时间,减少了内存空间及函数引用

  • 删除无用代码,类似早期内存回收的标记-清除策略,删除无用代码

  • 删除metadata,一些数据在运行中并不需要,用dex中已有的字符串代替文件引用以及删除无用的注解来减小Dex大小

c、Dex分包优化

上面讲到了ReDex的一个优点,基于反馈的Class字节码布局,这是啥意思呢?

类字节码在单个 Dex 中的布局是根据编译顺序而不是运行时行为决定,即便 Multidex 会将 App 定义的组件以及部分启动需要的类放在 Main Dex 中,但依然不是根据运行时顺序布局,这会导致程序启动时需要查找随机分布在 Dex 中的类。

ReDex 会将 APK 在 Lab 中试运行,并跟踪启动时哪些类需要加载,然后将这些类字节码放到 Dex 前部,减少启动时从闪存中寻找类的时间,从而提高 App 启动速度。

好的,大致了解了,那你可能会问,这个跟Dex分包优化有半毛钱关系?

在我们使用mutildex分包后,此时的每一个Dex可能会调用到其它的Dex中的方法,这种跨Dex调用的方式会造成许多冗余信息,具体有两点

  • method id爆表。每个dex 的method id 需要小于65536 ,因为method id的大量冗余导致每个Dex真正可以放的Class 变少,会造成最终编译的Dex数量增多

  • 信息冗余 。因为我们需要记录跨Dex调用的方法的详细信息,所以在调用方还需要记录另一个Dex中相关类、方法的定义,造成string_ids、type_ids、proto_ids这几部分信息的冗余

为了保证Dex有效率在80%以上,通过以下公式来衡量优化效果

Dex 信息有效率 = define methods数量 / reference methods 数量

为了实现上述所说的信息冗余去除,ReDex配置一下就可以

{
    "redex" : {
        "passes" : [
            "InterDexPass",
            "RegAllocPass"
        ]
    },
    "InterDexPass" : {
        "minimize_cross_dex_refs": true,
        "minimize_cross_dex_refs_method_ref_weight": 100,
        "minimize_cross_dex_refs_field_ref_weight": 90,
        "minimize_cross_dex_refs_type_ref_weight": 100,
        "minimize_cross_dex_refs_string_ref_weight": 90
    },
    "RegAllocPass" : {
        "live_range_splitting": false
    },
    "string_sort_mode" : "class_order",
    "bytecode_sort_mode" : "class_order"
}

d、Dex压缩

你可以分析一下Facebook APP的dex文件,他把真正的代码放到了assets下面,导致只有一个700多kb的classes.dex,他通过XZ Utils将所有dex压缩成了一个。

XZ 压缩算法 和 7-Zip 一样,内部使用的都是 LZMA 算法。对于 Dex 格式来说,XZ 的压缩率可以比 Zip 高 30% 左右。但是这套方案存在一些问题

  • 首次启动解压。应用首次启动的时候,需要将 secondary.dex.jar.xzs 解压缩,根据上图的配置信息,应该一共有 11 个 Dex。Facebook 使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要 3~5 秒。这里为什么不采用 Zstandard 或者 Brotli 呢?主要是压缩率与解压速度的权衡。

  • ODEX 文件生成。当 Dex 非常多的时候会增加应用的安装时间。对于 Facebook 的这个做法,首次生成 ODEX 的时间可能就会达到分钟级别。Facebook 为了解决这个问题,使用了 ReDex 另外一个超级硬核的方法,那就是oatmeal

上面的讲解摘自极客时间Android开发高手课,这里仅当了解了一下,因为oatmeal还需要分版本适配,臣妾做不到,难度对于目前的我来说很高。

2、资源瘦身

真正去除无用资源

利用 android-arscblamer 分析,具体可以查看 在 Android 构建工具执行 package${flavorName}Task 之前通过修改 Compiled Resources 来实现自动去除无用资源

重复资源优化

这个在项目开始时就需要制定好规范,但是也避免不了,多个地方用到的图片或资源存放在不同的模块下,毕竟这是团队开发。这个问题在任务开展过程中,确实不好搞,因为存在太多相同字符串,名称不同,文案一样,然后图片又有相同的,命名又不一样,难搞。

具体实施方案查看 重复资源优化

3、SO瘦身

a、对Library的压缩与合并、裁剪

跟Dex一样,可以通过XZ Utils压缩

在默认的lib目录中,我们只需要加载少数启动过程相关的 Library ,其它Library放在首次启动时解压。对于压缩率来说,可以比ZIP压缩高30%,效果很好。缺点和Dex类似

实施方案,配合采用Facebook的一个开源库 SoLoader

合并

在Android 4.3 之前,进程加载的Library数量是由限制的,可以点击查看

然后Facebook又出来方案了 Android native merginDemo

裁剪

又是Facebook的方案 relinker

原理:分析代码中的JNI方法以及不同Library方法调用,找到没有无用的导出symbol,然后删除掉,这样linker在编译的时候也会把对应的无用代码同时删除掉。

四、长效治理APK包体积

  • 包体积监控 ApkChecker ,刚好项目中集成了Matrix

  • 在需要引入依赖库或者jar包等第三方SDK时,需要进行分析,拉个小会讨论下最好

  • 每位同学上传代码前,进行包体积大小对比,超过阀值必须优化,同时review一下是否不小心引入了一些不必要的资源


笔记一完结