Android 性能优化系列(一): 包大小优化

3,212 阅读7分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

概述

包大小优化是一个老生常谈的技术,已经有很多非常好的文章了,这里主要记录一下笔记

APK组成

apk一般由以下几个部分组成

APK组成描述
res主要图片资源和xml文件
assets主要放置不需要编译的文件,比如MP4文件
lib主要放置各种so文件
classes.dex编译后的字节码文件,因为方法数量限制,可能有多个dex文件
AndroidManifest.xml配置文件
META-INF存放签名和证书的地方
resources.arsc编译后的二进制文件主要记录了资源和Id的对应关系

其实要优化包大小,就可以从上面几个方面优化

  • res大小优化
  • assets大小优化
  • lib大小优化
  • dex大小优化
  • resources.arsc大小优化

这几个文件占了包大小的99%,下面依次说一下,从这几个方面怎么优化

res大小优化

这里我写了一个gradle插件来做这件事# Android Gradle插件工具实战:检测三方库权限so适配及压缩图片

这个里面主要是图片和xml,所以有俩个思路

压缩图片

第一个就是压缩图片,这里使用的tinify进行压缩,针对这个压缩,我专门写了一个gradle插件来做

执行命令 ./gradlew checkres

...

[mmm] /app/src/main/res/drawable-xxhdpi/pay_success_bg.png  size = 262KB
[mmm] 压缩前 size =  262
[mmm] 压缩后 size =  56
[mmm] copy 完成
[mmm] /app/src/main/res/drawable-xxhdpi/bg_qr_share.png  size = 582KB
[mmm] 压缩前 size =  582
[mmm] 压缩后 size =  155
[mmm] copy 完成
[mmm] /app/src/main/res/drawable-xxhdpi/bg_every_day_pick.png  size = 119KB
[mmm] 压缩前 size =  119
[mmm] 压缩后 size =  32
[mmm] copy 完成
[mmm] /app/src/main/res/drawable-xxhdpi/bg_secretary_dialog.png  size = 216KB
[mmm] 压缩前 size =  216
[mmm] 压缩后 size =  43
[mmm] copy 完成
[mmm] 此次共压缩 {7706}

可以看到压缩了将近8MB的大小

还可以继续把png图片转为WebP图片,进行继续压缩

android studio 自带功能

点击图片右键->convert  to webp

不能压缩的超大图片

这个种不能压缩的超大图片可以放到cdn上,用url进行加载

删除内容重复的xml

我们在正常开发的时候,有可能要写一个shape.xml,但是有可能这个shape已经被实现了,只不过我不知道,我友写了一遍,这样就会有命名不同,但内容相同的文件,这就是重复文件

针对这个问题,我也写了一个gradle插件,可以检测重复文件


...

[mmm] 重复文件
[mmm] /app/src/main/res/drawable/bg_10dp_chat_white.xml
[mmm] /app/src/main/res/drawable/bg_luck_telephone_part2.xml
[mmm] 重复文件
[mmm] /app/src/main/res/drawable-xhdpi/random_match_avatar_12.png
[mmm] /app/src/main/res/drawable-xhdpi/random_match_avatar_15.png

...

[mmm] 此次共找到重复文件 {15}

删除无用的资源和xml

android studio 的自带查询无用资源和代码

Analyze > Run Inspection by Name > Unused resources

Analyze > Inspect code

gradle 添加配置shrinkResources

buildTypes {

release {

    minifyEnabled true

    shrinkResources true

}

首先 minifyEnabled打开混淆,混淆的作用

  • 可以删除无用的类,变量,方法
  • 优化字节码并删除无用指令
  • 通过将类名方法名变为无意义的字符来实现混淆结果
  • 最后校验处理后的代码

可以看到minifyEnabled可以删除无用代码变量 配合shrinkResources可以删除无用资源,需要配合minifyEnabled使用

移除备用资源

比如国际化做了很多语言适配,第三方库含有的一些语言,我们可以统一设置语言

defaultConfig {
        resConfigs("en","zh","zh-rCN")
    }

资源图片也是可以指定用那些文件夹

 defaultConfig {
        resConfigs("xxhdpi","xxxhdpi")
    }

lib压缩

为了更方便的查找那个依赖有so,以及so有没有适配32/64位,专门开发了一个插件,可以方便的找出so,大小,已经是否适配32/64


[mmm] ---------------[依赖产物开始] group=com.immomo.momomedia:x264encoder----------------
[mmm] so文件 = jni/arm64-v8a/libx264encoder.so   size = 424KB
[mmm] so文件 = jni/armeabi/libx264encoder.so   size = 477KB
[mmm] ---------------[依赖产物结束] group=com.immomo.momomedia:x264encoder----------------
[mmm] ---------------[依赖产物开始] group=com.immomo.momomedia:voaac----------------
[mmm] so文件 = jni/arm64-v8a/libVoAACEncoder.so   size = 74KB
[mmm] so文件 = jni/armeabi/libVoAACEncoder.so   size = 78KB
[mmm] so文件 = jni/armeabi-v7a/libVoAACEncoder.so   size = 78KB
[mmm] ---------------[依赖产物结束] group=com.immomo.momomedia:voaac----------------

...

[mmm] v8a不包含 = libbsdiff.so   group=com.immomo.android.mklibrary:mk   size = 39KB
[mmm] v8a不包含 = libmkjni.so   group=com.immomo.android.mklibrary:mk   size = 7KB
[mmm] v8a不包含 = libsevenz.so   group=com.immomo.android.mklibrary:mk   size = 33KB
[mmm] v8a不包含 = libMOMOPitchShift.so   group=com.immomo.momomedia:mmaudio   size = 76KB
[mmm] v8a不包含 = libMOMOPitchShift.so   group=com.immomo.momomedia:mmaudio   size = 76KB
[mmm] v8a不包含 = libmjni.so   group=MatchMakerAndroid:momsecurity   size = 62KB

动态加载so

针对so过多的问题,我们可以选择动态加载so,哪一些初始化不需要的so,可以进行动态加载

动态加载so方案

  • 下载到sdcard
  • copy文件到app的缓存
  • 反射修改so文件映射列表(关键:把自定义的native库path插入native数组最前面,即使安装包libs目录里面有同名的so,也优先加载指定路径的外部so。),这个其实和热修复方案原理一样
  • 然后就可以正常使用了
/**
 * com.google.android.apps.photolab.storyboard.soloader.LoadLibraryUtil
 * Description:动态加载so文件的核心,注入so路径到nativeLibraryDirectories数组第一个位置,会优先从这个位置查找so
 * 更多姿势,请参考开源库动态更新so的黑科技,仅供学习交流
 *
 */
public class LoadLibraryUtil {
    private static final String TAG = LoadLibraryUtil.class.getSimpleName() + "-duqian";
    private static File lastSoDir = null;


    public static synchronized boolean installNativeLibraryPath(ClassLoader classLoader, File folder)
            throws Throwable {
        if (classLoader == null || folder == null || !folder.exists()) {
            Log.e(TAG, "classLoader or folder is illegal " + folder);
            return false;
        }
        final int sdkInt = Build.VERSION.SDK_INT;
        final boolean aboveM = (sdkInt == 25 && getPreviousSdkInt() != 0) || sdkInt > 25;
        if (aboveM) {
            try {
                V25.install(classLoader, folder);
            } catch (Throwable throwable) {
                try {
                    V23.install(classLoader, folder);
                } catch (Throwable throwable1) {
                    V14.install(classLoader, folder);
                }
            }
        } else if (sdkInt >= 23) {
            try {
                V23.install(classLoader, folder);
            } catch (Throwable throwable) {
                V14.install(classLoader, folder);
            }
        } else if (sdkInt >= 14) {
            V14.install(classLoader, folder);
        }
        lastSoDir = folder;
        return true;
    }

    private static final class V23 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = ReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);

            Field nativeLibraryDirectories = ReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);

            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir) || folder.equals(lastSoDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove() " + folder.getAbsolutePath());
                    break;
                }
            }

            libDirs.add(0, folder);
            Field systemNativeLibraryDirectories =
                    ReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);

            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());

            Method makePathElements = ReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class, List.class);
            ArrayList<IOException> suppressedExceptions = new ArrayList<>();
            libDirs.addAll(systemLibDirs);

            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs, null, suppressedExceptions);
            Field nativeLibraryPathElements = ReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }

    /**
     * 把自定义的native库path插入nativeLibraryDirectories最前面,即使安装包libs目录里面有同名的so,也优先加载指定路径的外部so
     */
    private static final class V25 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = ReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);
            Field nativeLibraryDirectories = ReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir) || folder.equals(lastSoDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove()" + folder.getAbsolutePath());
                    break;
                }
            }

            libDirs.add(0, folder);
            //system/lib
            Field systemNativeLibraryDirectories = ReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);

            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());

            Method makePathElements = ReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
            libDirs.addAll(systemLibDirs);

            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs);
            Field nativeLibraryPathElements = ReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }


    private static final class V14 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = ReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);

            ReflectUtil.expandFieldArray(dexPathList, "nativeLibraryDirectories", new File[]{folder});
        }
    }

    /**
     * fuck部分机型删了该成员属性,兼容
     *
     * @return 被厂家删了返回1,否则正常读取
     */
    @TargetApi(Build.VERSION_CODES.M)
    private static int getPreviousSdkInt() {
        try {
            return Build.VERSION.PREVIEW_SDK_INT;
        } catch (Throwable ignore) {
        }
        return 1;
    }

}

assets 优化

assets 一般储存一些MP4文件,svga动画文件等,这些都可以放到CND上,然后本地加载url就可以了

dex优化

移除无用三方库

检测三方库,使用有用,无用的三方库删除

对字节码进行优化

使用facebook的开源ReDex,对dex的字节码进行优化

抖音 Android 包体积优化探索:基于 ReDex 的 DEX 优化落地实践

抖音 Android 包体积优化探索:从 Class 字节码入手精简 DEX 体积

resources.arsc 优化

resources.arsc进行混淆,可以把res/drawable/wechat变为r/d/a,开源工具AndResGuard

AndResGuard 插件的工作原理,就是创建了一个资源混淆打包任务,该任务会先调用默认的打包任务,在默认打包工作结束后,会解压打好的 apk 包,识别解析包里的 resources.arsc 资源表,然后再混淆 res 文件夹下面的所有资源文件,同时相对应的修改资源表,最后将修改后的资源重新打包签名,生成新的 apk 包。

资源混淆的核心,就是对 resources.arsc 资源表的解析修改,了解了 resources.arsc 文件的格式,我们再来使用微信的 AndResGuard 插件就很容易理解了。

R文件

  • R文件常量内联,R文件瘦身
  • 无用resource资源检查
  • 无用assets检查

这个可以使用字节开源 shrink-r-plugin

抖音 Android 包体积优化探索:资源二进制格式的极致精简

抖音包大小优化-资源优化

其他优化

插件化

利用插件化进行包大小的压缩

参考

Android包体积优化(常规、进阶、极致)