Android性能优化(五):体积优化

APK 瘦身优化的原因

下载转化率

包体积越小,用户下载等待的时间也会越短,所以下载转换成功率也就越高。现在很多大型的 App 一般都会有一个 Lite 版本的 App,这个也是出于下载转化率方面的考虑。

应用市场

Google Play 应用市场强制要求超过 100MB 的应用只能使用 APK 扩展文件方式 上传。

体积过大对 App 性能的影响

  • 安装时间:比如 文件拷贝、Library 解压,并且,在编译 ODEX 的时候,特别是对于 Android 5.0 和 6.0 系统来说,耗费的时间比较久,而 Android 7.0 之后有了 混合编译,所以还可以接受。最后,App 变大后,其 签名校验 的时间也会变长
  • 运行时内存:Resource 资源、Library 以及 Dex 类加载都会占用应用的一部分内存。
  • ROM 空间:如果应用的安装包大小为 50MB,那么启动解压之后很可能就已经超过 100MB 了

    APK组成

  • 代码相关:classes.dex
  • 资源相关:res、assets
  • So 相关

    APK分析

    ClassyShark

    ClassyShark.jar查看类和接口信息。双击运行ClassyShark.jar,然后将apk拖进去。
    链接地址:https://github.com/google/android-classyshark

    反编译

    Apktool主要用来反编译apk或者提取dex
    链接地址:https://github.com/iBotPeaches/Apktool
    1
    2
    3
    4
    //进入当前目录执行
    java -jar apktool_2.4.1.jar apktool d com.xxx.xxx.apk
    //使用apktool提取dex
    java -jar apktool_2.4.1.jar -s d com.xxx.xxx.apk

dex2jar

主要是将dex转为jar,我们可以查看jar中的java类实现方式
链接地址:https://github.com/pxb1988/dex2jar

1
2
//使用dex2jar将apk转为jar
sh d2j-dex2jar.sh -f ../com.xxx.xxx.apk

代码瘦身

Dex

Dex 是 Android 系统的可执行文件,包含 应用程序的全部操作指令以及运行时数据。因为 Dalvik 是一种针对嵌入式设备而特殊设计的 Java 虚拟机,所以 Dex 文件与标准的 Class 文件在结构设计上有着本质的区别。当 Java 程序被编译成 class 文件之后,还需要使用 dx 工具将所有的 class 文件整合到一个 dex 文件中,这样 dex 文件就将原来每个 class 文件中都有的共有信息合成了一体,这样做的目的是 保证其中的每个类都能够共享数据,这在一定程度上 降低了信息冗余,同时也使得 文件结构更加紧凑。与传统 jar 文件相比,Dex 文件的大小能够缩减 50% 左右。关于 Class 文件与 Dex 文件的结果对比图如下所示:

ProGuard

代码混淆也被称为 花指令,它 将计算机程序的代码转换成一种功能上等价,但是难以阅读和直接理解的形式。

  • 压缩(Shrinking)
    默认开启,以减小应用体积,移除未被使用的类和成员,并且 会在优化动作执行之后再次执行,因为优化后可能会再次暴露一些未被使用的类和成员。我们可以使用如下规则来关闭压缩:

    1
    -dontshrink 关闭压缩
  • 优化(Optimization)
    默认开启,在 字节码级别执行优化,让应用 运行的更快。使用如下规则可进行优化相关操作:

    1
    2
    -dontoptimize 关闭优化
    -optimizationpasses n 表示proguard对代码进行迭代优化的次数,Android一般为5
  • 混淆(Obfuscation)
    默认开启,增大反编译难度,类和类成员会被随机命名,除非用 优化字节码 等规则进行保护。使用如下规则可以关闭混淆:

    1
    -dontobfuscate 关闭混淆

Proguard 的配置

混淆之后,默认会在工程目录 app/build/outputs/mapping/release 下生成一个 mapping.txt 文件,这就是 混淆规则,所以我们可以根据这个文件把混淆后的代码反推回原本的代码。要使用混淆,我们只需配置如下代码即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
buildTypes {
release {
// 1、是否进行混淆
minifyEnabled true
// 2、开启zipAlign可以让安装包中的资源按4字节对齐,这样可以减少应用在运行时的内存消耗
zipAlignEnabled true
// 3、移除无用的resource文件:当ProGuard 把部分无用代码移除的时候,
// 这些代码所引用的资源也会被标记为无用资源,然后
// 系统通过资源压缩功能将它们移除。
// 需要注意的是目前资源压缩器目前不会移除values/文件夹中
// 定义的资源(例如字符串、尺寸、样式和颜色)
// 开启后,Android构建工具会通过ResourceUsageAnalyzer来检查
// 哪些资源是无用的,当检查到无用的资源时会把该资源替换
// 成预定义的版本。主要是针对.png、.9.png、.xml提供了
// TINY_PNG、TINY_9PNG、TINY_XML这3个byte数组的预定义版本。
// 资源压缩工具默认是采用安全压缩模式来运行,可以通过开启严格压缩模式来达到更好的瘦身效果。
shrinkResources true
// 4、混淆文件的位置,其中 proguard-android.txt 为sdk默认的混淆配置,
// 它的位置位于android-sdk/tools/proguard/proguard-android.txt,
// 此外,proguard-android-optimize.txt 也为sdk默认的混淆配置,
// 但是它默认打开了优化开关。并且,我们可在配置混淆文件将android.util.Log置为无效代码,
// 以去除apk中打印日志的代码。而 proguard-rules.pro 是该模块下的混淆配置。
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}

在执行完 ProGuard 之后,ProGuard 都会在 ${project.buildDir}/outputs/mapping/${flavorDir}/ 生成以下文件:

  • dump.txt
    APK中所有类文件的内部结构
  • mapping.txt
    提供原始与混淆过的类、方法和字段名称之间的转换,可以通过proguard.obfuscate.MappingReader来解析
  • seeds.txt
    列出未进行混淆的类和成员
  • usage.txt
    列出从APK移除的代码

    混淆的基本规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    # * 表示仅保持该包下的类名,而子包下的类名还是会被混淆
    -keep class com.xx.xx.xx.*
    # ** 表示把本包和所含子包下的类名都保持
    -keep class com.xx.xx.xx.**

    # 既保持类名,又保持里面的内容不被混淆
    -keep class com.xx.xx.xx.* {*;}

    # 也可以使用Java的基本规则来保护特定类不被混淆,比如extend,implement等这些Java规则
    -keep public class * extends android.app.Activity

    # 保留MainPagerFragment内部类JavaScriptInterface中的所有public内容不被混淆
    -keepclassmembers class com.json.chao.wanandroid.ui.fragment.MainPagerFragment$JavaScriptInterface {
    public *;
    }

    # 仅希望保护类下的特定内容时需使用匹配符
    <init>; //匹配所有构造器
    <fields>; //匹配所有字段
    <methods>; //匹配所有方法
    # 还可以在上述匹配符前面加上private 、public、native等来进一步指定不被混淆的内 容
    -keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <fields>;
    }
    # 也可以加入参数,以下表示用java.lang.String作为入参的构造函数不会被混淆
    -keep class com.json.chao.wanandroid.app.WanAndroidApp {
    public <init>(java.lang.String);
    }

    # 不需要保持类名,仅需要把该类下的特定成员保持不被混淆时使用keepclassmembers
    # 如果拥有某成员,要保留类和类成员使用-keepclasseswithmembers

了解完上面的这些混淆规则之后,相信我们已经能够根据我们当前的应用写出相应的混淆规则了。需要注意的是,在 AndroidMainfest 中的类默认不会被混淆,所以四大组件和 Application 的子类和 Framework 层下所有的类默认不会进行混淆,并且自定义的 View 默认也不会被混淆。因此,我们不需要手动在 proguard-rules.pro 中去添加如下代码:

1
2
3
4
5
6
7
8
9
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Appliction
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class * extends android.view.View
-keep public class com.android.vending.licensing.ILicensingService

而 Application 和四大组件是必须在 AndroidMainfest 中进行注册的,所以如果想要通过 混淆四大组件和 Application、自定义 View 的方式去减小APK的体积是行不通的,因为没有规则去配置如何混淆四大组件和 Application。因此,对于混淆的优化,我们能做的只能是
尽量保证 keep 范围的最小化,以此实现应用混淆程度的最大化。在混淆配置中添加下列规则还可以在混淆之后输出最终的混淆配置:

1
2
// 输出 ProGuard 的最终配置
-printconfiguration configuration.txt

D8 与 R8 优化

D8 的 优化效果 总的来说可以归结为如下 四点:

  • Dex的编译时间更短。
  • .dex文件更小。
  • D8 编译的 .dex 文件拥有更好的运行时性能。
  • 包含 Java 8 语言支持的处理。
    开启 D8,Android Studio 3.1 或之后的版本 D8 将会被作为默认的 Dex 编译器。在 Android Studio 3.0 需要主动在 gradle.properties 文件中新增:
    1
    android.enableD8 = true

R8 是 Proguard 压缩与优化部分的替代品,并且它仍然使用与 Proguard 一样的 keep 规则。如果我们仅仅想在 Android Studio 中使用 R8,当我们在 build.gradle 中打开混淆的时候,R8 就已经默认集成进 Android Gradle plugin 中了。
如果我们当前使用的是 Android Studio 3.4 或 Android Gradle 插件 3.4.0 及其更高版本,R8 会作为默认编译器。否则,我们 必须要在 gradle.properties 中配置如下代码让 App 的混淆去支持 R8,如下所示:

1
2
android.enableR8=true
android.enableR8.libraries=true

R8 与混淆相比优势在哪里呢?
R8 在 inline 内联容器类中更有效,并且在删除未使用的类,字段和方法上则更具侵略性。R8 本身集成在 ProGuard V6.1.1 版本中,在压缩 apk 的大小方面,与 ProGuard 的 8.5% 相比,使用 R8 apk 尺寸减小了约 10%。并且,随着 Kotlin 现在成为 Android 的第一语言,R8 进行了 ProGuard 尚未提供的一些 Kotlin 的特定的优化。ProGuard 在将枚举类型简化为原始整数方面会更加强大。ProGuard 具有独特的能力来优化使用 GSON 库将对象序列化或反序列化为 JSON 的代码。该库严重依赖反射,这很方便,但效率低下。而 ProGuard 的优化功能可以 通过更高效,直接的访问方式 来代替它。

去除 debug 信息与行号信息

1
-keepattributes SourceFile, LineNumberTable

根据 Google 官方的数据,debugItem 一般占 Dex 的比例有 5% 左右,如果我们能去除 debug 与行号信息,就能更进一步对 Dex 进行瘦身,但是会失去调试信息的功能,那么,有什么方式可以去掉 debugItem,同时又能让 crash 上报的时候能拿到正确的行号呢?
我们可以尝试直接修改 Dex 文件,保留一小块 debugItem,让系统查找行号的时候指令集行号和源文件行号保持一致,这样任何监控上报的行号都直接变成了指令集行号。
关于如何去除 Dex 中的 Debug 信息是通过 ReDex 的 StripDebugInfoPass 来完成的,其配置如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"redex" : {
"passes" : [
"StripDebugInfoPass",
"RegAllocPass"
]
},
"StripDebugInfoPass" : {
"drop_all_dbg_info" : false,
"drop_local_variables" : true,
"drop_line_numbers" : false,
"drop_src_files" : false,
"use_whitelist" : false,
"cls_whitelist" : [],
"method_whitelist" : [],
"drop_prologue_end" : true,
"drop_epilogue_begin" : true,
"drop_all_dbg_info_if_empty" : true
},
"RegAllocPass" : {
"live_range_splitting": false
}
}

使用 XZ Utils 进行 Dex 压缩

XZ Utils 是具有高压缩率的免费通用数据压缩软件,它同 7-Zip 一样,都是 LZMA Utils 的后继产品,内部使用了 LZMA/LZMA2 算法。LZMA 提供了高压缩比和快速解压缩,因此非常适合嵌入式应用。

三方库处理

  • 将图片加载库、网络库、数据库以及其他基础库进行统一,去掉冗余的库。
  • 选择第三方 SDK 的时候,我们可以将包大小作为选择的指标之一,我们应该 尽可能地选择那些比较小的库来实现相同的功能
  • 引入三方库的时候,可以 只引入部分需要的代码

移除无用代码

这里,有一个很好的方法可以 准确地判断哪些类在线上环境下用户肯定不会用到了。我们可以通过 AOP 的方式来做,对于 Activity 来说,其实非常简单,我们只需要 在每个 Activity 的 onCreate 当中加上统计 即可,然后到了线上之后,如果这个 Activity 被统计了,就说明它还在被使用。而对于那些 不是 Activity 的类,我们可以 利用 AOP 来切它们的构造函数,一个类如果它被使用,那它的构造函数肯定会被调用到。例如,下面就是 使用 AspectJ 对某个包下的类进行构造函数切面 的代码

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

使用 Lint 检测无效代码

1
步骤:点击菜单栏 Analyze -> Run Inspection by Name -> unused declaration -> Moudule ‘app’ -> OK

资源瘦身

众所周知,Android 构建工具链中使用了 AAPT/AAPT2 工具来对资源进行处理,Manifest、Resources、Assets 的资源经过相应的 ManifesMerger、ResourcesMerger、AssetsMerger 资源合并器将多个不同 moudule 的资源合并为了 MergedManifest、MergedResources、MergedAssets。然后,它们被 AAPT 处理后生成了 R.java、Proguard Configuration、Compiled Resources。如下图左上方所示:

冗余资源优化

  • 使用 Lint 的 Remove Unused Resource
    APK 的资源主要包括图片、XML,与冗余代码一样,它也可能遗留了很多旧版本当中使用而新版本中不使用的资源,这点在快速开发的 App 中更可能出现。我们可以通过点击右键,选中 Refactor,然后点击 Remove Unused Resource => preview 可以预览找到的无用资源,点击 Do Refactor 可以去除冗余资源。

需要注意的,Android Lint 不会分析 assets 文件夹下的资源,因为 assets 文件可以通过文件名直接访问,不需要通过具体的引用,Lint 无法判断资源是否被用到。

  • 优化 shrinkResources 流程真正去除无用资源

图片压缩

一般来说,1000行代码在APK中才会占用 5kb 的空间,而图片呢,一般都有 100kb 左右,所以说,对图片做压缩,它的收益明显是更大的,而往往处于快速开发的 App 没有相关的开发规范,UI 设计师或开发同学如果忘记了添加图片时进行压缩,添加的就是原图,那么包体积肯定会增大很多。对于图片压缩,我们可以在 tinypng 这个网站进行图片压缩,但是如果 App 的图片过多,一个个压缩也是很麻烦的。因此,我们可以 使用 McImage、TinyPngPlugin 或 TinyPIC_Gradle_Plugin 来对图片进行自动化批量压缩。

需要注意的是,在 Android 的构建流程中,AAPT 会使用内置的压缩算法来优化 res/drawable/ 目录下的 PNG 图片,但这可能会导致本来已经优化过的图片体积变大,因此,可以通过在 build.gradle 中 设置 cruncherEnabled 来禁止 AAPT 来优化 PNG 图片,代码如下所示:

1
2
3
aaptOptions {
cruncherEnabled = false
}

此外,我们还要注意对图片格式的选择,对于我们普遍使用更多的 png 或者是 jpg 格式来说,相同的图片转换为 webp 格式之后会有大幅度的压缩。对于 png 来说,它是一个无损格式,而 jpg 是有损格式。jpg 在处理颜色图片很多时候根据压缩率的不同,它有时候会去掉我们肉眼识别差距比较小的颜色,但是 png 会严格地保留所有的色彩。所以说,在图片尺寸大,或者是色彩鲜艳的时候,png 的体积会明显地大于 jpg。

使用针对性的图片格式

在 Google I/O 2016 中,讲到了如何选择相应的图片格式。首先,如果能用 VectorDrawable 来表示的话,则优先使用 VectorDrawable;否则,看是否支持 WebP,支持则优先用 WebP;如果也不能使用 WebP,则优先使用 PNG,而 PNG 主要用在展示透明或者简单的图片,对于其它场景可以使用 JPG 格式。简单来说可以归结为如下套路:

1
VD(纯色icon)->WebP(非纯色icon)->Png(更好效果) ->jpg(若无alpha通道)

资源混淆

同代码混淆类似,资源混淆将 资源路径混淆成单个资源的路径,这里我们可以使用 AndroidResGuard,它可以使冗余的资源路径变短,例如将 res/drawable/wechat 变为 r/d/a。

AndroidResGuard 实战

1、首先,我们在项目的根 build.gradle 文件下加入下面的插件依赖:

1
classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.17'

2、然后,在项目 module 下的 build.gradle 文件下引入其插件:

1
apply plugin: 'AndResGuard'

3、接着,加入 AndroidResGuard 的配置项,如下是默认设置好的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
andResGuard {
// mappingFile = file("./resource_mapping.txt")
mappingFile = null
use7zip = true
useSign = true
// 打开这个开关,会keep住所有资源的原始路径,只混淆资源的名字
keepRoot = false
// 设置这个值,会把arsc name列混淆成相同的名字,减少string常量池的大小
fixedResName = "arg"
// 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
mergeDuplicatedRes = true
whiteList = [
// for your icon
"R.drawable.icon",
// for fabric
"R.string.com.crashlytics.*",
// for google-services
"R.string.google_app_id",
"R.string.gcm_defaultSenderId",
"R.string.default_web_client_id",
"R.string.ga_trackingId",
"R.string.firebase_database_url",
"R.string.google_api_key",
"R.string.google_crash_reporting_api_key"
]
compressFilePattern = [
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
]
sevenzip {
artifact = 'com.tencent.mm:SevenZip:1.2.17'
//path = "/usr/local/bin/7za"
}

/**
* 可选: 如果不设置则会默认覆盖assemble输出的apk
**/
// finalApkBackupPath = "${project.rootDir}/final.apk"

/**
* 可选: 指定v1签名时生成jar文件的摘要算法
* 默认值为“SHA-1”
**/
// digestalg = "SHA-256"
}

4、最后,我们点击右边的项目 module/Tasks/andresguard/resguardRelease 即可生成资源混淆过的 APK。

尽量每张图片只保留一份

比如说,我们统一只把图片放到 xhdpi 这个目录下,那么 在不同的分辨率下它会做自动的适配,即 等比例地拉伸或者是缩小。

资源在线化

我们可以 将一些图片资源放在服务器,然后 结合图片预加载 的技术手段,这些 既可以满足产品的需要,同时可以减小包大小。

统一应用风格

如设定统一的 字体、尺寸、颜色和按钮按压效果、分割线 shape、selector 背景 等等。

So 瘦身

对于主要由 C/C++ 实现的 Native Library 而言,常规的优化方式就是 去除 Debug 信息,使用 C++_shared 等等。下面,对于 So 瘦身,我们看看还有哪些方案。

So 移除方案

So 是 Android 上的动态链接库,在我们 Android 应用开发过程中,有时候 Java 代码不能满足需求,比如一些 加解密算法或者音视频编解码功能,这个时候就必须要通过 C 或者是 C++ 来实现,之后生成 So 文件提供给 Java 层来调用,在生成 So 文件的时候就需要考虑生成市面上不同手机 CPU 架构的文件。目前,Android 一共 支持7种不同类型的 CPU 架构,比如常见的 armeabi、armeabi-v7a、X86 等等。理论上来说,对应架构的 CPU 它的执行效率是最高的,但是这样会导致 在 lib 目录下会多存放了各个平台架构的 So 文件,所以 App 的体积自然也就更大了。
因此,我们就需要对 lib 目录进行缩减,我们 在 build.gradle 中配置这个 abiFiliters 去设置 App 支持的 So 架构,其配置代码如下所示:

1
2
3
4
5
defaultConfig {
ndk {
abiFilters "armeabi"
}
}

一般情况下,应用都不需要用到 neon 指令集,我们只需留下 armeabi 目录就可以了。因为 armeabi 目录下的 So 可以兼容别的平台上的 So,相当于是一个万金油,都可以使用。但是,这样 别的平台使用时性能上就会有所损耗,失去了对特定平台的优化。

So 移除方案优化版

上面我们说到了想要完美支持所有类型的设备代价太大,那么,我们能不能采取一个 折中的方案,就是 对于性能敏感的模块,它使用到的 So,我们都放在 armeabi 目录当中随着 Apk 发出去,然后我们在代码中来判断一下当前设备所属的 CPU 类型,根据不同设备 CPU 类型来加载对应架构的 So 文件。这里我们举一个小栗子,比如说我们 armeabi 目录下也加上了 armeabi-v7 对应的 So,然后我们就可以在代码当中做判断,如果你是 armeabi-v7 架构的手机,那我们就直接加载这个 So,以此达到最佳的性能,这样包体积其实也没有增加多少,同时也实现了高性能的目的,比如 微信和腾讯视频 App 里面就使用了这种方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String abi = "";
// 获取当前手机的CPU架构类型
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
abi = Buildl.CPU_ABI;
} else {
abi = Build.SUPPORTED_ABIS[0];
}

if (TextUtils.equals(abi, "x86")) {
// 加载特定平台的So

} else {
// 正常加载

}

总结

  • 代码:Proguard、统一三方库、无用代码删除。
  • 资源:无用资源删除、资源混淆。
  • So:只保留 Armeabi、更优方案。
您的支持是我原创的动力