九分喜欢,一分尊严,放弃你,也放过自己,愿你安好,在多年以后不要记起深爱你的我。

絮絮叨

工作不长不短,之前未曾考虑过深处,只是停留写出来了,便是完美。

而今的处境,不尴不尬,岁月刚好,背起行囊,继续前行。

如今的 5 G 也在万众瞩目瞩目下翩翩起舞,而 Android 近些年也惹得不少争议,所谓的谣言,不过尔尔。

每个人的追求不一样,尽自己最大努力吧。

如何减少 Apk 大小,一直以来都是处于观望状态,懒得折腾,其实还是不会,Low 的一批。

Today,一起来搞一波~

欢迎各位指正~

现学现卖~

一脑图,览无余

玲珑宝塔镇万物

首先附上一张现在 Apk 大小图:
null
未做任何处理原包大小为 10 MB,加固之后将近 11 MB。

以此为例,一起看看经过我们玲珑宝塔升级完,最终还剩下多少精华?

一层镇妖魔(减少 4.1 MB)

来到第一层,我们先来简单分析下是什么造成 Apk 包如此“庞大”?null
上图可看到 lib 下兼容了全面的 CPU 架构,试想一下,假设未来的未来多了短视频、直播、地图导航等等(不接受杠精),这块的大小会不会成倍数的增长。
null
上图可看到默认支持了 89 种语言类型,目前的应用暂时未国际化,这块也可直接设置兼容中文即可,原谅我这个强迫症。

占比排行榜依次为:源代码、资源文件、lib。

我们先挑个软柿子玩玩。

1.1 设置支持语言(减少 0.2 MB)

关于这块,个人觉得虽然占比较小,但是用啥玩啥,用不到的直接干掉。

在 build.gradle 中设置仅支持中文:

    defaultConfig {
        ...
        // 仅支持 中文
        resConfigs "zh"
    }

这块主要是根据现有项目需求来定,中心思想只有一个,兼容哪儿个就设置哪儿个国家语言,其他的直接忽略。

设置完之后打个包,看下有没有什么变化。
null
从上图中可以很清晰的看到,经过设置仅支持的国家语言后,包大小减少了 0.2 MB。随后我们看下资源映射文件中关于 string 中会有什么变化。
null
默认语言中设置为中文,且应用也只支持了中文,少了好多东西,爽得很~

1.2 设置支持的 CPU 架构类型(减少 1.5 MB)

话说这里的 lib 为何兼容了这么多的 CPU 架构类型???

正好走到这里,关于这块的小知识再次重温下,瞅瞅 Google 为我们提供的解释:

不同的 Android 手机使用不同的 CPU,而不同的 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口,即 ABI。ABI 可以非常精确地定义应用的机器代码在运行时如何与系统交互。您必须为应用要使用的每个 CPU 架构指定 ABI。

貌似 Google 商店现在支持对应的架构模式分发对应的 Apk 包,这点爽的每个包只需要兼容一种就好了。But,ummm。

目前而言,项目中使用到真正用到 So 库没几个,全部兼容太过于浪费,据说 arm 属于通用,那么这里同语言设置一样,仅支持 arm 即可。

    defaultConfig {
        ...
        ndk {
            // 设置支持的SO库架构
            abiFilters "armeabi"
        }
    }

打包运行后,继续查看现在包大小:
null
这块一直属于个心病,之前的项目光是 So 库就占了很大一部分空间,很湿蛋疼。

1.3 开启压缩、混淆(减少 2.4 MB)

根据 Google 官网解释,当我们使用 Android Gradle 3.4.0 或者更高版本时,默认会启用 R8 编译器进行压缩、混淆以及优化,主要项以及作用如下:

  • 代码优化: 通过检测并安全移除未使用的类、字段、方法和属性;
  • 资源压缩: 从应用中移除未使用的资源,此过程包含移除库依赖项中未使用的资源文件。此项常常和代码压缩配合使用;
  • 混淆: 缩短类和成员的名称,从而减小 Dex 文件大小;
  • 优化: 检查并重写代码,进一步减小 Dex 文件大小。例如,如果 R8 检测到从未采用过给定 if/else 语句的 else {} 分支,R8 便会移除 else {} 分支的代码。

这里需要注意一下:

  • 默认情况下并不启用压缩、混淆和代码优化功能。 因为开启后会造成 Debug 模式下编译时间较久。

关于混淆文件,这里需要正好学习一下。

混淆的意义在于什么?(引入官方解释)

  • 混淆处理的目的是通过缩短应用的类、方法和字段的名称来减小应用的大小

混淆效果(摘自官方):

androidx.appcompat.app.ActionBarDrawerToggle$DelegateProvider -> a.a.a.b:
    androidx.appcompat.app.AlertController -> androidx.appcompat.app.AlertController:
        android.content.Context mContext -> a
        int mListItemLayout -> O
        int mViewSpacingRight -> l
        android.widget.Button mButtonNeutral -> w
        int mMultiChoiceItemLayout -> M
        boolean mShowTitle -> P
        int mViewSpacingLeft -> j
        int mButtonPanelSideLayout -> K

混淆需注意:

  • Android 四大组件不能混淆;
  • 反射、注解、枚举不能混淆;
  • JS、Native 调用的方法不能混淆;
  • 基础 Bean 类以及序列化实体类不能混淆;
  • 自定义控件不能混淆;
  • 资源文件不能混淆(当然也有骚操作);

随后列举常用混淆规则(语法):

  • 保留某个类
    -keep public class com.hlq.Love
  • 保留某包下的所有类及其内部类
       -keep class com.hlq.** {*;}
  • 不显示指定类警告
    dontwarn com.hlq.**

具体规则可文末查看官方手册。

接下来跟着官网一起实践一波~

    buildTypes {
        release {
            // 打开资源压缩
            shrinkResources true
            // 开启混淆操作
            minifyEnabled true 
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.config
        }
        debug {
            // 关闭资源压缩以及混淆操作
            shrinkResources false
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.config
        }
    }

这里需要注意,在 Debug 模式下需要关闭资源压缩以及混淆操作,否则会增加编译时间,一般在发布正式包时打开即可。

这里附上现在项目使用的混淆文件,基于大芬儿提供混淆文件做了部分修改:

#############################################
#
# 混淆基本指令
#
#############################################
# 代码混淆压缩比,在0~7之间,默认为5,一般不做修改
-optimizationpasses 5

# 混合时不使用大小写混合,混合后的类名为小写
-dontusemixedcaseclassnames

# 指定不去忽略非公共库的类
-dontskipnonpubliclibraryclasses

# 这句话能够使我们的项目混淆后产生映射文件
# 包含有类名->混淆后类名的映射关系
-verbose

# 指定不去忽略非公共库的类成员
-dontskipnonpubliclibraryclassmembers

# 不做预校验,preverify是proguard的四个步骤之一,Android不需要preverify,去掉这一步能够加快混淆速度。
-dontpreverify

# 保留Annotation不混淆
-keepattributes *Annotation*,InnerClasses

# 避免混淆泛型
-keepattributes Signature

# 抛出异常时保留代码行号
-keepattributes SourceFile,LineNumberTable

# 指定混淆是采用的算法,后面的参数是一个过滤器
# 这个过滤器是谷歌推荐的算法,一般不做更改
-optimizations !code/simplification/cast,!field/*,!class/merging/*

# 忽略警告
-ignorewarnings

#############################################
#
# 需要保留的公共部分
#
#############################################

# 保留我们使用的四大组件,自定义的 Application 等等这些类不被混淆
# 因为这些子类都有可能被外部调用
-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

# 保留support下的所有类及其内部类
-keep class android.support.** {*;}

# 保留继承的
-keep public class * extends android.support.v4.**
-keep public class * extends android.support.v7.**
-keep public class * extends android.support.annotation.**

# 保留R下面的资源
-keep class **.R$* {*;}

# 保留本地native方法不被混淆
-keepclasseswithmembernames class * {
    native <methods>;
}

# 保留在Activity中的方法参数是view的方法,保障layout中写的onClick不会被影响
-keepclassmembers class * extends android.app.Activity{
    public void *(android.view.View);
}

# 保留枚举类不被混淆
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# 保留我们自定义控件(继承自View)不被混淆
-keep public class * extends android.view.View{
    *** get*();
    void set*(***);
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

# 保留Parcelable序列化类不被混淆
-keep class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}

# 保留Serializable序列化的类不被混淆
-keepclassmembers class * implements java.io.Serializable {
    static final long serialVersionUID;
    private static final java.io.ObjectStreamField[] serialPersistentFields;
    !static !transient <fields>;
    !private <fields>;
    !private <methods>;
    private void writeObject(java.io.ObjectOutputStream);
    private void readObject(java.io.ObjectInputStream);
    java.lang.Object writeReplace();
    java.lang.Object readResolve();
}

# 对于带有回调函数的onXXEvent、**On*Listener的,不能被混淆
-keepclassmembers class * {
    void *(**On*Event);
    void *(**On*Listener);
}

# webView处理,项目中没有使用到webView忽略即可
-keepclassmembers class fqcn.of.javascript.interface.for.webview {
    public *;
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap);
    public boolean *(android.webkit.WebView, java.lang.String);
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.webView, jav.lang.String);
}

# 移除Log类打印各个等级日志的代码,打正式包的时候可以做为禁log使用,这里可以作为禁止log打印的功能使用
# 记得proguard-android.txt中一定不要加-dontoptimize才起作用
# 另外的一种实现方案是通过BuildConfig.DEBUG的变量来控制
-assumenosideeffects class android.util.Log {
    public static int v(...);
    public static int i(...);
    public static int w(...);
    public static int d(...);
    public static int e(...);
}

#############################################
#
# 处理项目中我们的部分
#
#############################################

#-----------处理实体类---------------
# 在开发的时候我们可以将所有的实体类放在一个包内,这样我们写一次混淆就行了
-keep public class 实体类.**{*;}

# Js 交互
-keepclassmembers class JS 交互类地址{
  public *;
}

-keepattributes *JavascriptInterface*

#############################################
#
# 处理第三方依赖库部分
#
#############################################

# 此处按照实际项目中使用去官方查找对应的混淆代码块

随后我们继续打包,查看混淆、资源压缩后 Apk 大小以及部分变化:
null
dex 从 3 个降低到 2 个。未 Keep 的文件均已混淆,而 Keep 的文件依旧傲娇挺立,如下图:
null
混淆操作,在一定程度增大了破解的难度。当然,也没有绝对的安全。

R8 每次运行时都会创建一个 mapping.txt 文件,其中列出了混淆过的类、方法和字段名称与原始名称的映射关系。此映射文件还包含用于将行号映射回原始源文件行号的信息。R8 将此文件保存在 /build/outputs/mapping// 目录中。
null
线上版本肯定要进行混淆,那么针对线上版本报出的异常,我们又该如何处理呢?毕竟关键内容都变成无意义字符,鉴名其意不存在了。
null
iTerm 2 打开:
null
点击 ReTrace:
null
这块步骤如下:

  • 导入 Mapping 文件
  • 将混淆后错误日志拷贝黏贴到 Obfuscated stack trace 中
  • 点击右下角的 ReTrace!

1.4 开启 Zipalign 优化

这块我看的很湿懵逼,估计唯有鸡大行云流水了。简单摘自官方解释:

zipalign 是一种归档对齐工具,可对 Android 应用文件进行重要的优化。其目的是要确保所有未压缩数据的开头均相对于文件开头部分执行特定的对齐。具体来说,它会使 APK 中的所有未压缩数据(例如图片或原始文件)在 4 字节边界上对齐。这样一来,即可使用 mmap() 直接访问所有部分,即使其中包含具有对齐限制的二进制数据也没关系。这样做的好处是可以减少运行应用时消耗的 RAM 容量。

如何使用?很是 easy~

    buildTypes {
        release {
            // 开启Zipalign 优化
            zipAlignEnabled true
        }
        debug {
            zipAlignEnabled false
        }
    }

看一下结果:
null
貌似没啥用哦,能加还是加上吧。

二层镇仙神(减小 1.5 MB)

来到第二层,我们再来开下资源映射文件中关于图片这块:
null
其实对于图片而言,真的是个很蛋疼的操作,不过还好,一些简单的小背景、小效果,现在大部分都直接采用 shape、selector 等实现,多少也避免引入了一些图片。

对于图片优化,主要分为以下几点:

  • 套图的优化 -SVG
  • 套图的优化 - Thit 着色器的应用
  • webp 的使用

    2.1 套图的优化 - SVG

    什么是套图呢?

好比应用中的某个 Icon,一般来讲,UI 都会为我们提供 n 套图,以便于我们适配不同分辨率记性,大概的目录如下:
null
例如下面的这些大大小小的 Icon,一个个拷贝、改名也是比较痛苦的:
null
这个时候,SVG 便派上了用场。

可缩放矢量图形(英語:Scalable Vector Graphics,SVG)是一种基于可扩展标记语言(XML),用于描述二维矢量图形的图形格式。 SVG由W3C制定,是一个开放标准。

SVG 优势:

  • 节约空间、内存

SVG 劣势:

  • 不支持透明度以及渐变

ummm,需要注明一点,Android 6.0 + 支持,6.0 以下需要做兼容处理。不过现在应该也没必要兼容那么低的版本了吧?

如何在 Android Studio 中创建一张 SVG 图片呢?如下所示:
null
弹出如下界面,在此页面可以选择直接导入 Android 内置 Icon 库图,还是手动加载 SVG or PSD 格式,看需选择。
null
放个操作图省事儿点:
null
使用也很 easy:

    <androidx.appcompat.widget.AppCompatImageView
            android:id="@+id/iv_toolbar_back"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            app:srcCompat="@drawable/ic_arrow_back" />

别忘记在 build 里面对此设置:

    defaultConfig {
        ...
        // 强制 Gradle 在编译时不自动生成兼容低版本的位图资源
        vectorDrawables.useSupportLibrary = true
        // 生成指定类型的图片资源
        vectorDrawables.generatedDensities = ['xhdpi', 'xxhdpi', 'xxxhdpi'] 
    }

建议直接下载 svg,导入 svg,这个真心比较爽。

推荐下良心企业,阿里的 iconfont:

经过一通折腾,阿里下载 SVG,之后 Android Studio 导入 SVG,巴拉巴拉,好歹改完了,哈哈哈

2.2 套图的优化 - Tint 着色器

先来举个天马行空的例子,例如同一张图片在不同的状态下显示不同,比如成功绿色,失败红色等等。按照以往的习惯,那肯定要求至少提供每种状态对应的图片,不然我怎么搞?

但是有个实实在在的问题,那就是,图片一样,只是颜色发生了变化,假设五种状态,我们就需要引入至少五张图片,那么,可否只需要一张图片,针对不同的状态,我们渲染不同的颜色呢?

当然可以,这就是今天要说到的 Tint 着色器。有了它,最简洁明了一点,至少能帮我剩下很多可谓是“无用”图片,大大节省了很多空间,我们的 Apk 更加“干练”。

再举一个我们项目中常见的例子,首页 Tab 栏,如下图:
null
Tab 切换,字体变色、图片变色,这个见怪不怪了吧。上来至少给我提供八张图,四张默认,四张选中,然后通过 selector 文件设置,不给图没法做。对吧,这就是之前最实际的想法,嗯,还感觉自己可 dei 了。

一起先来回顾下以前的 low 写法:

Step 1:至少提供八张图后,设置 icon 引用 selector 文件:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/nav_home"
        android:icon="@drawable/selector_menu_home"
        android:title="@string/nav_home" />
    ...
</menu>

Step 2:定义 Selector:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_checked="true" android:drawable="@drawable/ic_tab_home_sel"/>
    <item android:drawable="@drawable/ic_tab_home"/>
</selector>

这样写固然木有问题,但是平白无故多了很多图片,在今天的我来看,必须不能忍,今天只用四张图,走起~

这里吐槽下,由于之前底部导航采用 BottomNavigationView 方式,折腾好半天踩折腾出来,中间无数次想放弃了。但回头一想,我好歹也是跟随我鸡大的,况且抽烟的时候还要和文哥交流呢。艾玛,不容易,容小弟我抽根烟,ummm,没烟了😅😅😅。

先来看个效果图吧,毕竟我费了好大力气~ (其实想得瑟下~)
null
首先是布局:

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/nav_bottom_menu"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/colorWhite"
            app:itemBackground="@null"
            app:itemIconTint="@color/tint_selector_menu_color" // 重点在这里
            app:itemTextColor="@color/tint_selector_menu_color"
            app:labelVisibilityMode="labeled"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:menu="@menu/nav_bottom_menu" />

编写渲染颜色选择器:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/comm_app_color" android:state_checked="true" />
    <item android:color="@color/color_tab_def" />
</selector>

最后,修改我们的 menu 文件中 icon 直接采用默认图即可:
null

2.3 图片的升级 - WebP

引入官方描述:

WebP 是 Google 开发的一种图片文件格式,提供有损压缩(如 JPEG)并支持透明性(如 PNG),不过与 JPEG 或 PNG 相比,这种格式可以提供更好的压缩效果。Android 4.0(API 级别 14)及更高版本支持有损 WebP 图片,Android 4.3(API 级别 18)及更高版本支持无损且透明的 WebP 图片。

注意:由于只有 Android 4.3 及更高版本支持无损且透明的 WebP 图片,因此您的项目声明的 minSdkVersion 必须为 18 或更高,才能使用 Android Studio 创建无损或透明的 WebP 图片。

操作步骤如下:

点击要转换的图片,选择 Convert to WebP...
null
采用默认配置:
null
预览结果图:
null
200 多 kb 降到 20 多 kb,如果项目中此类图片较多时,使用 WebP 确实很爽啊~

当然,关于转换可单张、多张,看个人心情。

经历了最痛苦的时候,我们一起打包看下经过二层过滤,我们精简了多少?
null
Apk 大小减少 1.5 MB,res 占比从 28 % 降低到 15.5 %。其实里面还是有很大的优化空间,奈何懒癌上身,矫情开始作祟。

三层镇万物(偷个懒,困,减少了 0.4 MB)

经过前俩层的打怪历练,我们终于要见大 Boss 了。Come on,篇幅过长,灰常感谢能够看到这里,比个心心~

3.1 AndResGuard - 微信资源压缩应用(减少 0.4 MB)

在正式玩微信资源压缩前,我们先来回顾下之前的混淆,说白了混淆不仅仅优化了代码,而且将关键的一些信息通过无意义的标示进行替换,从而进一步加深了反编译破解的难度,仅仅是加深了。而我们的布局、图片还是属于赤裸裸的状态,如下所示:
null
针对某些安全要求比较高或者有那么一丢丢追求的小伙伴,就是不想赤裸裸给你看,怎么办呢?

AndResGuard 上场~ (对比美团的方案,微信真香)

Step 1:项目根目录 build 添加依赖

    dependencies {
        ...
        classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.17'
    }

Step 2:app 目录下新建 wechat-ResGuard.gradle 文件:

apply plugin: 'AndResGuard'

andResGuard {
    // mappingFile = file("./resource_mapping.txt")
    mappingFile = null
    use7zip = true
    useSign = true
    // 打开这个开关,会keep住所有资源的原始路径,只混淆资源的名字
    keepRoot = false
    // 设置这个值,会把arsc name列混淆成相同的名字,减少string常量池的大小
    fixedResName = "arg"
    // 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
    mergeDuplicatedRes = true
    whiteList = [
            // App Logo 这里对应找自己的 App Logo
            "R.mipmap.ic_launcher",
            "R.mipmap.ic_launcher_foreground",
            "R.mipmap.ic_launcher_round",
            // 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"
}

Step 3:app 下 build 中添加末尾添加依赖

apply from: 'wechat-ResGuard.gradle'

Step 4:打包
null
null
最后我们看下包里面资源相应的资源是否被混淆了?
null
Apk 减少 0.4 MB,资源被混淆~

看了下双十一数据,真有钱。。。

Analyze 选中 Run Inspection by Name...
null
输入 unused re,选择 Unused resources:
null
直接采用默认即可:
null
根据提示修改即可:
null
困了,不改了,改天再说。

3.3 Remove Unused Resources(不推荐使用,除非和我一样脸皮厚,喜欢作)

物理删除未使用的资源文件。
null
记得点击中间的,不然给你删没了
null
一个个对应,然后开始删除吧~
null
选中要移除的,直接 Remove 即可
null
丢个包瞅瞅。
null
ummm,我好像啥都没弄。算求了。呼呼呼。。。

结束语

墨迹了好几天,终于迈出了一小步。

加油呀~

参考资料

  1. ABI 管理
  2. 使用 Translations Editor 本地化界面
  3. 压缩、混淆和优化您的应用
  4. ProGuard manual
  5. zipalign
  6. 添加多密度矢量图形
  7. Android支持库23.2
  8. 创建 WebP 图片
  9. ImageView
  10. AndResGuard