阿里某淘Android体积优化方案(下)

4,311 阅读7分钟

简介

上一章我们介绍Dex的优化方案,本章主要介绍资源文件的优化方案。
阿里某淘Android体积优化方案(上)

1 无用资源移除。

  1. 使用Android Studio Lint Remove Unused Resource 工具扫描无用图片和xml资源。
  2. 打包后的apk可以使用ApkChecker进行扫描每个模块文件的大小、重复文件、以及可以优化的内容。开源ApkChecker
  3. 使用 gradle shrinkResources true属性,这里shrinkResources属性的作用并不大,因为在扫描无用资源过程中, 系统为了避免使用getIdentifier过程找不到资源,所以在清除资源的时候条件判断非常严格,会判断潜在的反射调用,经过测试,这个功能比较鸡肋,不能完全依赖于shrinkResources帮忙删除无用资源。

2 图片压缩

  • 对于纯色图片使用VectorDrawable来实现。
  • 网上大量的资料介绍webp格式的图片大小优于png,通过大量数据来看其实并不是,有些图片使用tinypng(png8)压缩后转为webp图片反而更大了,主要还是需要看图片的色彩丰富度,由于没办法通过规律判断具体那种格式的图片最优,我们采取的方案是先将所有的图片png压缩,再尝试png转webp,如果webp更小则采用。这里需要注意webp格式4.0+才支持,对于包含透明度的4.2+才支持。开源pngquant、 cwebpMcImage

3 适配资源

我们需要 根据 App 目前所支持的语言版本去选用合适的语言资源,例如使用了 AppCompat,如果不做任何配置的话,最终 APK 包中会包含 AppCompat 中所有已翻译语言字符串,无论应用的其余部分是否翻译为同一语言。对此,我们可以 通过 resConfig 来配置使用哪些语言,从而让构建工具移除指定语言之外的所有资源。同理,也可以使用 resConfigs 去配置你应用需要的图片资源文件类代码如下所示:

resConfigs "zh"

支持特定的分辨率。现在大部分手机都是1080p,对应的xxhdpi的资源,所以我们只支持xxhdpi一套资源即可。
开源工具booster-task-resource-deredundancy

4 AndResGuard资源优化和压缩

4.1 arsc文件优化

1 arsc文件它记录了资源文件id对应的名称与路径,可以看到图中res/drawable的字样,开启优化混淆成短路径 res/s/a,可以减少文件的大小。坑:因为混淆资源路径,所以使用getIdentifier的方法是获取不到资源的。这里需要配置白名单的方式不混淆路径。
arsc_info

2 zip文件压缩有deflate和stored两种方式,stored方式的文件是不会被压缩的,可以看到arsc文件以及png图片采用的是stored模式。如果文件采用deflate模式意味着AssetManager读取的时候需要解压。

  • 对.png、.jpg强制压缩,但是的确普遍压缩率在3%-5%,收益不是特别高;
  • 假若你的resources.arsc小于1M,可以对它进行压缩,这边可压缩50-70%。需要注意的是,如果你的resources.arsc是压缩的,程序需要运行时把它解压读到内存,影响启动速度并会增大运行内存;对于Android 9以上已经不支持arsc文件压缩,否则无法安装

4.2 7zip压缩

7z压缩算法号称优化了字典,完全兼容zip,使用7zip的最大压缩模式比传统zip方式的确有所提升。对于生成的apk重用7zip进行压缩,APK 的 整体压缩率可以提升 3% 左右

此外,抖音 Android 团队还开源了针对于海外市场 App Bundle APK 的 AabResGuard 资源混淆工具。

5 SO文件优化

5.1 支持特定架构

Android 目前一共 支持7种不同类型的 CPU 架构,比如常见的 armeabi、armeabi-v7a、X86 等等。对应架构的 CPU 它的执行效率是最高的,但是这样会导致 在 lib 目录下会多存放了各个平台架构的 So 文件,所以 App 的体积自然也就更大了。

因此,我们就需要对 lib 目录进行缩减,我们 在 build.gradle 中配置这个 abiFiliters 去设置 App 支持的 So 架构,其配置代码如下所示:

defaultConfig {
    ndk {
        abiFilters "armeabi"
    }
}

5.2 SO动态加载

分析业务场景,部分非重要的业务场景可实现so动态下载。
初始正式版本我们直接对SO文件做上传下载,下载成功率在95%,错误主要集中在网络错误。新版本对so进行zip压缩再上传,下载体积减少一半,下载成功率从95.8%提示到98.0%。
下方是整体的设计图,配合打包平台实现so整体的管理,将业务的侵入降到最低。

5.3 SO压缩

鉴于动态下载成功率无法达到理想状态,对于某些重要场景希望成功率要求高,我们调研了Facebook APP,发现他们的app so异常的小。我们调研了他们的压缩算法,发现使用了非标准的zip算法。最终我们落地后压缩so方案的so加载失败率只有万分之一

压缩前大小压缩后大小压缩比压缩速度解压速度
zip35999380187695101.92--
zstd-level2235999380138855612.591.14MB/S113.67MB/S
zstd-level1935999380141804972.541.47MB/S119.62MB/S
xz-level935999380114120723.151.70MB/S11.76MB/S
xz-level9-block235999380116799843.081.70MB/S23.29MB/S
xz-level9-block435999380121120802.971.79MB/S33.93MB/S

调研后我们针对非核心的so在编译期做so压缩,运行时解压后加载。 image.png

SO加载

  • So加载我们分析有两种模式、一种是通过打包全局插桩、将System.loadLibrary全部替换成自己的方法,解压后通过System.load方式加载文件,但是这种方式有个缺点就是对于so有依赖的情况下比较难处理。
  • 对于解压后的避免使用System.load()的方法,我们使用nativeLibraryPath注入的方式,这种方案使用也较为普遍、在插件化及热修复的方案随处可见。

System.loadLibrary()流程

要想知道如何进行nativeLibraryPath注入,我们必须了解loadLibrary的整个流程。

在我们调用了System.loadLibrary()之后,实际上是进入了Runtime的loadLibrary0()方法中:

// System.java

@CallerSensitive

public static void loadLibrary(String libname) {

    Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);

}



// Runtime.java

synchronized void loadLibrary0(ClassLoader loader, String libname) {

    if (libname.indexOf((int)File.separatorChar) != -1) {

        throw new UnsatisfiedLinkError(

"Directory separator should not appear in library name: " + libname);

    }

    String libraryName = libname;

    if (loader != null) {

        String filename = loader.findLibrary(libraryName);

        if (filename == null) {

            // It's not necessarily true that the ClassLoader used

            // System.mapLibraryName, but the default setup does, and it's

            // misleading to say we didn't find "libMyLibrary.so" when we

            // actually searched for "liblibMyLibrary.so.so".

            throw new UnsatisfiedLinkError(loader + " couldn't find "" +

                                           System.mapLibraryName(libraryName) + """);

        }

        String error = nativeLoad(filename, loader);

        if (error != null) {

            throw new UnsatisfiedLinkError(error);

        }

        return;

    }



    // ...

}

这里的VMStack.getCallingClassLoader()通常情况下是调用者的ClassLoader,即PathClassLoader,而findLibrary()方法的实现存在于BaseDexClassLoader

// BaseDexClassLoader.java

@Override

public String findLibrary(String name) {

   return pathList.findLibrary(name);

}

从而进入了DexPathList的实现

// DexPathList.java 

public String findLibrary(String libraryName) {

        String fileName = System.mapLibraryName(libraryName);



        for (Element element : nativeLibraryPathElements) {

            String path = element.findNativeLibrary(fileName);



            if (path != null) {

                return path;

            }

        }



        return null;

}

因此想要我们解压的so也能是由System.loadLibrary()加载的话,则我们需要在nativeLibraryPathElements中加入我们解压完后的路径。

Redex

Redex中也介绍了多种优化方案,但是从功能上看和proguard的部分功能是重复的,经过数据对比,不开启proguard优化效果在5%左右,开启proguard优化模式后dex的优化效果在1%以下。
然后还需要解决redex工程化的问题、dex优化后crash堆栈对不上的问题,整体ROI不高。

建立监控

建立合理的流程规范MR合入,在开发期解决问题。 image.png

总结

整体优化下来,项目App最终整体从60M减少到35M,在快速发展中也遇到了大量的业务集成,算上其中的业务增量总共减少30M,在核心链路使用flutter的项目中拿到这个结果实属不易。
在没有建立合理的流程和机制过程前各种问题接踵而至,各种资源新增不规范,好不容易的缩包被新增业务抵消,在后续我们建立合理的规范,形成责任制管理,让开发形成主观意识,实行管控流程,包大小长期收获正向效果。
从我们做的实验来看,优化后转化率在4g情况提升2.5%。 image.png

如果能帮助到你 帮忙点个star Github掘金