【Android】浅谈APP的瘦身之路

1,282 阅读11分钟

APK是什么?

首先看一下APK包的构成,Android的APK包和Windows应用程序安装包是不同的, Apk安装包本质上是一个zip压缩文件, 解压之后我们可以看到其中包含的内容:

image.png

  • META-INF目录:包含两个签名文件(CERT.SF和CERT.RSA),以及一个manifest文件(MANIFEST.MF)。
  • assets目录:包含工程中的asset目录下的文件,可以使用AssetManager获取。
  • res目录:包含那些没有被编译到resources.arsc的资源。
  • lib目录:包含适用于不同处理器的第三方依赖库,这里边可以有多个子目录,比如armeabi, armeabi-v7a, arm64-v8a, x86, x86_64, 以及mips。
  • resources.arsc文件:存储编译好的资源,包括项目工程中的res/values目录里的xml文件,它们都被编译成二进制格式,也包括一些路径,指向那些没有被编译的资源,比如layout文件和图片。
  • classes.dex文件:项目中的java类都被编译到该dex文件,这个文件可以被Android的Dalvik/ART虚拟机解析。
  • AndroidManifest.xml:二进制格式的manifest文件,这个文件是必须的。

这些文件是Android系统运行一个应用程序时会用到的数据和代码,下面介绍系统如何安装一个APK包

APK安装过程

通常的安装有四种方式:

1.系统应用安装:开机时加载系统的APK和应用,没有安装界面。

2.网络下载应用安装:通过各种market应用完成,没有安装界面。

3.ADB工具安装:即通过Android的SDK开发tools里面的adb.exe程序安装,没有安装界面。

4.通过第三方应用来安装:通过SD卡里的APK文件安装(比如双击APK文件触发),有安装界面,系统默认已经安装了一个安装卸载应用的程序。

安装应用程序最常用的方法就是在PC上运行命令adb install 加APK的文件路径,回车等待Android设备安装完成,安装成功命令行会显示Success。过程如下:

默认情况下APK会被拷贝到/data/app目录下,这个目录用户是有访问权限的;adb install 最终会发送shell:pm命令,向系统的PackageManagerService(PMS)进程发送消息,通知其安装apk包; apk在安装的时候,会将app的依赖的第三方动态链接库拷贝到/data/app/包名-XXX==/lib/平台名的目录中。 然后,在/data/data/目录下创建应用程序的数据目录(以应用的包名命名),存放应用的相关数据,如数据库、xml文件、cache、二进制的so动态库等等。 解析apk的AndroidManifinest.xml文件并写入/data/system/packages.xml中,包含apk的name、codePath、flags、ts、version、uesrid等信息。解析完apk后将更新信息写入这个文件并保存,下次开机直接从里面读取相关信息添加到内存相关列表中。当有apk升级,安装或删除时会更新这个文件。

APK为什么要瘦身?

安装包要瘦身的主要原因就是考虑应用的下载转化率和留存率. 应用太大了, 用户可能就不下载了, 尤其是移动网络或者流量收费的情况下. 再者, 因为手机空间问题, 用户有时候可能需要选择卸载一些应用, 就会先盯上那些占空间大的, 所以应用大小也会也影响留存率.

Google Play 应用市场强制要求超过 100MB 的应用只能使用 APK 扩展文件方式 上传。如果想避免使用扩展文件,并且想要应用程序的下载大小大于100 MB,则应该使用 Android App Bundles 上传应用程序,此时应用程序最多可提供150 MB的压缩下载大小。

渠道合作商的要求,当我们的 App 做大之后,可能需要跟各个手机厂商合作预装,只有达到相应的要求后才允许你的 App 预装到手机上。

APK体积庞大后对APP性能也会产生影响。App安装时间: 虽然Android 7.0 之后有了混合编译,勉强可以接收,但是其签名校验时间也会变长。运行时内存: Resource 资源、Library 以及 Dex 类加载都会占用应用的一部分内存。ROM 空间: 由于闪存的这种工作方式,必须擦除改写的闪存部分比新数据实际需要的大得多。即最终可能导致实际写入的物理资料量是写入资料量的多倍。

APK包大小指标?

文件尺寸下载尺寸 、  安装尺寸

APK包大小缩小方法?

一、资源减重

在项目中往往会有一些因为产品的变更或者其他原因而产生一些无用资源,我们apk瘦身的第一步便是要移除这些无用资源。

1.优化assets和res中的资源文件

这个目录一般都是图片资源占空间比较多,尤其当App为了适配多种分辨率而存放了多套图时,这时候就会导致res目录打下会非常大。

image.png

以PNG资源为例,PNG 图片相对于 JPEG 图片来说,它是一种无损的图像存储格式,同时多了一条透明度A通道,所以一般情况下,PNG 图片要比 JPEG 图片要大;将PNG图片通过 AndroidStudio 转换工具将PNG/PSD图片转为 SVG图片。或者Indexed_color 和 Color_quantization方式对图片进行压缩。

资源压缩:AndResGuard缩小APK大小的工具,他的原理类似Java Proguard,但是只针对资源。他会将原本冗长的资源路径变短,例如将res/drawable/xxxxxxx 变为 r/d/x。

还可以做:只保留一套图使用webp替换png   、非重要图片动态加载使用lint删除无用资源打开shrinkResources

image.png

2.lib目录优化

三方库处理 在开发过程中,我们因为业务需求,可能会引用很多第三方库,而这些库可能有着相同功能。比如Picasso和Glide,这两个库都是图片加载的功能。如果没有特殊要求的话,根据场景的选择任选其一即可。

当然,本着瘦身的原则,我们通常选择一个包体积更小的较好。 对于第三方库的管理,通常建议封装一个统一管理三方库的基础库,这样有利于后期库的维护 如果我们需要某个库的某小部分功能,那么建议就是仅引入所需部分功能代码即可,如果我们使用了Picasso,但是我们只需要它支持webp功能,那么这时候我们就只引入webp功能就好了 原则就是 能小则小,不用最好。

远程so

so库就是由ndk编译出来的动态库,app运行在不同的手机中,而so库是由c\c++编译的,不是跨平台的,所以不同平台(不同CPU)需要使用不同的so库。

ABI 是应用程序二进制接口简称(Application Binary Interface),定义了二进制文件(尤其是.so文件)如何运行在相应的系统平台上,从使用的指令集,内存对齐到可用的系统函数库。在Android 系统上,每一个CPU架构对应一个ABI,即:armeabi,armeabi-v7a,arm64-v8a,x86,x86_64,mips,mips64。

ABISupported Instruction Set(s)
armeabiARMV5TE and later,Thumb-1
armeabi-v7aarmeabi,Thumb-2,VFPv3-D16,Other,optional
arm64-v8aAArch-64
x86x86(IA-32),MMX,SSE/2/3,SSSE3
x86_64x86-64,MMX,SSE/2/3,SSE3,SSE4.1,SSE4.2,POPCNT
mipsMIPS32r1 and later
mips64MIPS64r6
  • 将所有的so文件都放到armeabi文件夹下,通过获取用户的CPU架构进行动态加载
  • 可以将需要的so文件上传到服务器,然后先获取用户的CPU架构,再从服务器上下载对应的so文件进行加载
  • 通过插件化插拔的方式进行下发
3.旧的业务逻辑代码下线

随着版本的迭代,部分功能可能已被去掉,但是其代码还存在项目中。僵尸代码过多也会导致Dex文件过大。移除无用代码以及无用功能,有助于减少代码量,直接体现就是Dex的体积会变小。

调试代码单独管理,做到隔离,禁止release包使用,如下:

image.png

dependencies {
  ......
     
   debugImplementation project(path: ':DaiMao-testUnit')//debug引入
   releaseImplementation project(path: ':DaiMao-testUnit')//release引入
   
  ......
}

二、项目混淆

Java字节码中包括了很多源代码信息,如变量名、方法名,并且通过这些名称来访问变量和方法,这些 符号带有许多语义信息,很容易被反编译成 Java 源代码。为了防止这种现象,我们可以使用 Java 混淆器对 Java 字节码进行混淆。

混淆可以检测并移除未使用到的类、方法、字段以及指令、冗余代码,并能够对字节码进行深度优化。最后,它还会将类中的字段、方法、类的名称改成简短无意义的名字。除此之外增加代码被反编译的难度,一定程度上保证代码的安全。

#---------------------------------基本指令区----------------------------------
# 代码混淆压缩比,在0~7之间
-optimizationpasses 5
# 混合时不使用大小写混合,混合后的类名为小写
-dontusemixedcaseclassnames
# 指定不去忽略非公共库的类
-dontskipnonpubliclibraryclasses
-dontskipnonpubliclibraryclassmembers
# 不做预校验,preverify是proguard的四个步骤之一,Android不需要preverify,去掉这一步能够加快混淆速度。
-dontpreverify
-verbose
# 避免混淆泛型
-keepattributes Signature

-printmapping proguardMapping.txt

# 保留Annotation不混淆
-keepattributes *Annotation*,InnerClasses
#google推荐算法
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
# 避免混淆Annotation、内部类、泛型、匿名类
-keepattributes *Annotation*,InnerClasses,Signature,EnclosingMethod
# 重命名抛出异常时的文件名称
-renamesourcefileattribute SourceFile
# 抛出异常时保留代码行号
-keepattributes SourceFile,LineNumberTable

#---------------------------------默认保留区---------------------------------
# 处理support包
-dontnote android.support.**
-dontwarn 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$* {*;}
# 保留四大组件,自定义的Application等这些类不被混淆
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-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.preference.Preference
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.view.View

# 保留在Activity中的方法参数是view的方法,
# 这样以来我们在layout中写的onClick就不会被影响
-keepclassmembers class * extends android.app.Activity{
   public void *(android.view.View);
}
# 对于带有回调函数的onXXEvent、**On*Listener的,不能被混淆
-keepclassmembers class * {
   void *(**On*Event);
   void *(**On*Listener);
}

具体哪些地方给你瘦身了,请看以下文件

dump.txt
Describes the internal structure of all the class files in the APK.
mapping.txt
Provides a translation between the original and obfuscated class, methodand field names.
seeds.txt
Lists the classes and members that were not obfuscated.
usage.txt
Lists the code that was removed from the APK.

三、项目插件化

如果使用了插件化后,需要对整个项目进行重构,这种方案相比起改改Dex和图片,整个工程量极大,但是收益也会很高。

image.png

 public void enterShadow(Context reactContext, String paramKey, String jsonParams, PluginInfoBean pluginInfoBean) {
        //       /data/user/0/ 应用包名/files
       HostUiLayerProvider.setParams(paramKey, jsonParams);
       // 得到sp数据中的值
       String start_pluginVersion = KvSpUtil.Companion.getSIntance().decodeString(pluginInfoBean.pluginVersionKey);
       String pluginName = pluginInfoBean.pluginName;
       String pluginDir = FILE_DIR_PLUGIN;
       File pluginfile = new File(pluginDir);
       if (TextUtils.isEmpty(start_pluginVersion)) {//本地不存在乐屋装饰包
           if (pluginfile.exists()) {//避免下载一半关闭app
               pluginfile.delete();
          }
           checkPluginFiles(reactContext, pluginInfoBean);
      } else {
           if (start_pluginVersion.equals(pluginInfoBean.pluginVersion)) {//本地存在乐屋装饰包且不需更新
               checkPluginFiles(reactContext, pluginInfoBean);
          } else {//本地存在乐屋装饰包但需要更新
               if (pluginfile.exists()) {
                   pluginfile.delete();
              }
               checkPluginFiles(reactContext, pluginInfoBean);
          }
      }

       if(!isLoaclManager) {
           //manager
           String managerName = DW_PLUGIN_MANAGER_NAME;
           String managerDir = FILE_DIR_MANAGER;
           File managerfile = new File(managerDir);
           // 得到sp数据中的值
           String start_managerVersion = KvSpUtil.Companion.getSIntance().decodeString("start_managerVersion");
           if (TextUtils.isEmpty(start_managerVersion)) {//本地不存在乐屋装饰包
               if (managerfile.exists()) {
                   managerfile.delete();
              }
               checkManagerFiles(reactContext, pluginInfoBean.managerVersion, pluginInfoBean.managerUrl);
          } else {
               if (start_managerVersion.equals(pluginInfoBean.managerVersion)) {//本地存在乐屋装饰包且不需更新
                   checkManagerFiles(reactContext, pluginInfoBean.managerVersion, pluginInfoBean.managerUrl);
              } else {//本地存在乐屋装饰包但需要更新
                   if (managerfile.exists()) {
                       managerfile.delete();
                  }
                   checkManagerFiles(reactContext, pluginInfoBean.managerVersion, pluginInfoBean.managerUrl);
              }
          }
      }
  }

小注:APK分析工具

ApkTool反编译工具分析 APK

使用 android-classshark 进行 APK 分析

redex优化

XZ Utils 进行 Dex 压缩

使用Simian工具扫描重复代码

总结

apk-瘦身.png

最终成果:

image.png

下一步计划

项目的腐败代码治理持续进行!

可持续的常态化治理模式,瘦身相关的技术探索。包体积监控应该作为发布流程的一个环节,最好是做到平台化、流程化,否则很难持续,也许经过几个版本包体积又涨上来了。接下来会进行发版版本比对,当前版本与上一个版本的包大小做对比,超过500KB需要审批,临时审批需要给出后续优化方案等等。