我们如何让Android客户端暴瘦了100M

一、 引言

随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的时候,效果往往很有限,本文将详细介绍我们在包大小优化方面的实践经验,并通过一系列技术手段实现了显著的包体积缩减。

二、 安装包大小分析

Android的apk通常有以下几部分组成:

  1. 代码:包含应用中Java/Kotlin代码,在包中以dex的形式存在
  2. 资源:包含图片、布局文件等
  3. lib库: 包含了应用的Native代码库,以.so文件的形式存在
  4. assets:包含了应用运行时所需的非代码资源,如音频、视频、字体、配置文件等
  5. 其他:签名文件、资源索引文件等

通过分析安装包大小的组成,我们发现项目中lib库和assets占比达到70%,代码占比20%,资源和其他占比10%。

三、基础优化方案

  1. 代码优化:开启代码混淆,混淆可以帮助缩减代码尺寸、移除无用代码,通过分析反编译后的代码,我们发现很多本该混淆的类没有混淆,最终定位到工程中引入的一些三方库的混淆规则keep范围过大,导致混淆效果不理想;通过混淆规则的优化,包大小缩减了6M左右;
  2. 资源优化:解压apk文件,把res目录下的图片按照大小进行排序,我们发现项目中有一些尺寸较大的图片,把图片格式转为webp格式后,尺寸大大降低;同时通过对比资源的md5值,发现一些资源名字虽然不一样,但是内容是一样的,这些重复资源可以移除;通过资源的优化,包大小缩减了15M左右;
  3. assets资源优化:通过分析apk assets目录下的文件,我们发现里面有很多不用的文件,比如arm64位包中存在x86、armeabi-v7a等其他架构的so库,这些assets目录下的so库是三方库引入的,运行时动态加载,由于设置abiFilters无法过滤掉这些so库,导致打包进apk中;我们通过自定义构建流程,在mergeAssetsTask执行结束后移除assets中不用的so库;通过assets资源的优化,包大小缩减了4M左右.
ini 复制代码
project.afterEvaluate {
    android.applicationVariants.all { variant ->
        def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
        mergeAssetsTask.doLast {
            def assetsDir = mergeAssetsTask.outputDir.get().toString()
            // 移除无用的assets资源
            removeUnusedAssets(assetsDir)
        }
    }
}

通过基础的代码和资源等的优化,包大小缩减了25M左右,但是对于一个180M的apk来说,效果非常有限,需要探索其他方案进一步降低包大小。

三、 进阶优化方案

上面我们分析过apk中的lib库和assets文件占比达到70%,因此我们重点针对lib库和assets文件尺寸大的问题进行优化,我们可以把这些文件放到云端,在应用启动的时候下载到本地,但是这样做有以下问题:

  1. 一些lib库和assets文件在应用启动的时候就会用到,如果放到云端,会导致应用启动时间变长甚至崩溃;
  2. 应用中加载assets资源是通过系统API AssetManager.open来加载的,但是把assets文件从apk中移除后,需要修改使用AssetManager.open的地方,改为从本地私有目录加载,这样会导致改动的地方很多,而且容易漏改和错改;一些三方库由于没有开源,修改起来会更加困难;
  3. 应用中加载lib库是通过系统API System.loadLibrary来加载的,如果把so库从apk中移除后,需要修改为使用System.load加载私有目录下的so库,同样存在改动地方多,不开源的三方库修改困难的问题;
  4. 把移除的so库和assets文件打包成一个文件下发会存在由于文件尺寸大导致下载时间长,容易下载失败问题,同时会导致当用户使用到相关功能的时候需要长时间的等待,体验差。

针对以上问题,我们采用了以下优化策略:

  1. 选择性移除:只把一些尺寸大,用户使用频次较低的功能中使用的assets和so库从apk包中移除,在不影响用户体验的同时,降低安装包大小。
  2. 分包下载:需要移除的so库和assets文件按功能模块进行分包,首次使用时再去下载对应的资源包,这样能确保功能模块依赖的云端资源尽可能的小,大幅降低下载时间,提升下载成功率,减少用户等待时间。
  3. 自动化构建:通过编写gradle脚本,自定义构建过程,在构建阶段自动把assets和so库从apk包中移除并打包。
ini 复制代码
project.afterEvaluate {
    android.applicationVariants.all { variant ->
        def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
        mergeAssetsTask.doLast {
            def assetsDir = mergeAssetsTask.outputDir.get().toString()
            // 把assetsDir中需要移除的assets文件移除,放到模块指定的目录下
        }

        def mergeNativeLibsTask = project.getTasksByName("merge${variant.name.capitalize()}NativeLibs", false)
        mergeNativeLibsTask.doLast {
            def libDir = mergeNativeLibsTask.outputDir.get().toString()
            // 把 libDir中需要移除的so库移除,放到模块指定的目录下
            // 打包压缩模块目录
        }
    }
}
  1. 字节码插桩 :开发gradle插件,使用字节码插桩技术,在编译阶段自动把调用AssetManager.openSystem.loadLibrary的地方替换为我们的自定义加载器,工程中的代码和三方闭源库无需做任何改动。
scala 复制代码
public class MyMethodVisitor extends MethodVisitor {
    @Override
    public void visitMethodInsn(int opcode, String owner, String name, string desc, boolean isInterface) {
        // 替换System.loadLibrary为DynamicLoader.loadLibrary
        if ("java/lang/System".equals(owner) && "loadLibrary".equals(name)) {
            return super.visitMethodInsn(opcode, "com/xxx/loader/DynamicLoader", name, desc, isInterface);
        }

        // 替换AssetManager.open为DynamicLoader.openAsset
        if ("android/content/res/AssetManager".equals(owner) && "open".equals(name) && "(Ljava/lang/String;)Ljava/io/InputStream".equals(desc)) {
            return super.visitMethodInsn(Opcodes.INVOKESTATIC, "com/xxx/loader/DynamicLoader", "openAsset", "(Landroid/content/res/AssetManager;Ljava/lang/String;)Ljava/io/InputStream");
        }
        return super.visitMethodInsn(opcode, owner, name, desc, isInterface);
    }
}
  1. 双重加载机制:在自定义加载器中先尝试加载apk内置的so库和assets文件,如果出现异常,则从动态下发的文件中查找并加载,这样可以保证无论so库是否移除都可以正常加载。
java 复制代码
// 自定义加载器

public class DynamicLoader {
    public static void loadLibrary(string libname) throw Throwable {
        try {
            // 先加载apk包中的so库
            System.loadLibrary(libname);
            return;
        } catch(Throwable e) {
        }

        String soPath = findLibrary(libName);
        // apk包中的so库加载失败时加载动态下发的so库
        return System.load(soPath);
    }

    public static InputStream openAsset(AssetManager am, String fileName) throw IOException {
        try {
            // 先加载apk包中的asset文件
            return am.open(fileName);
        } catch(IOException e) { 
        }

        // apk包中的asset文件加载失败时加载动态下发的asset文件
        String assetPath = findAsset(fileName);
        return new FileInputStream(assetPath);
    }
}

四、 实施效果

采用上述包优化方案后,我们的Android客户端安装包大小从180M缩减到78M,实现了显著的包体积缩减。同时,通过监控优化后版本的崩溃率和用户反馈,未出现明显的崩溃率升高和用户体验下降的情况。

五、 未来展望

应用的安装包大小优化是一个长期的过程,需要建立一套包大小的监控、预警、原因分析、自动优化等机制,确保安装包大小在合理范围,我们将从以下几个方面进行探索:

  1. 设定安装包大小基准,持续监控安装包大小的变化,当安装包大小偏移基准值过大的时候,触发预警,并自动分析包大小增加原因,找出导致包大小增大的文件;
  2. 优化构建流程,构建阶段自动压缩大图片为webp格式,自动合并重复资源;
  3. 持续优化应用的性能表现和用户体验,并根据实际情况进行进一步的优化调整。
相关推荐
众拾达人31 分钟前
Android自动化测试实战 Java篇 主流工具 框架 脚本
android·java·开发语言
吃着火锅x唱着歌1 小时前
PHP7内核剖析 学习笔记 第四章 内存管理(1)
android·笔记·学习
_Shirley3 小时前
鸿蒙设置app更新跳转华为市场
android·华为·kotlin·harmonyos·鸿蒙
hedalei4 小时前
RK3576 Android14编译OTA包提示java.lang.UnsupportedClassVersionError问题
android·android14·rk3576
锋风Fengfeng5 小时前
安卓多渠道apk配置不同签名
android
枫_feng5 小时前
AOSP开发环境配置
android·安卓
叶羽西5 小时前
Android Studio打开一个外部的Android app程序
android·ide·android studio
qq_171538857 小时前
利用Spring Cloud Gateway Predicate优化微服务路由策略
android·javascript·微服务
Vincent(朱志强)8 小时前
设计模式详解(十二):单例模式——Singleton
android·单例模式·设计模式
mmsx9 小时前
android 登录界面编写
android·登录界面