Android 瘦身不反弹最佳实践


作者 | sunshine8

地址 | https://www.jianshu.com/p/e853c0467775

声明 | 本文是 sunshine8 原创,已获授权发布,未经原作者允许请勿转载


业界方案

在网上随便搜索一下就能发现瘦身有好多方案,但是实践一下就能发现好多都不靠谱

方案作用瘦身效果
proguard代码混淆效果明显
abiFilter "armeabi"去除其他平台so效果明显
resConfigs "zh"语言文件去除0.1M
shrinkResources无用资源去除需维护keep文件1M
TinyPng图片压缩,账号收费3M
ThinR移除R文件0.3M
AndResGuard资源混淆白名单维护难资源混淆0.3M,7zip压缩2M
webpandroid兼容性差不推荐
Lint 无用资源去除有可能删除getIdentifier调用的资源不推荐
redex安全风险高,对于加固、热修复等功能有影响未实践
so动态加载风险高,大部分so都需要实时加载未实践
加固隐藏dex1M
重复资源优化对比资源文件 md5,删除重复文件和resources.arsc中的定义0.2M
移除TINY_PNG文件通过android-chunk-utils把resources.arsc中对应的定义和文件移除,风险高美团文章一带而过,我实践一下,实际代码特别复杂,arsc文件索引value要重新计算,减小0.1M都不到

方案实践

Smallapk Gradle插件减小APK体积25%

apply plugin: 'smallapk'


动态资源查找

其他方案网上都有,我重点讲讲SmallApk插件怎么解决getIdentifier方法带来的动态资源问题。

  1. ShrinkResources只能去除小部分无用资源的问题

  2. 解决AndResGuard需要配置白名单的问题

首先需要了解ShrinkResources的原理:

通过ResourceUseModel建立一个资源引用树,找到有可能是resource.getIdentifier调用的资源标记为reachable,找到无用资源并替换成tiny的小文件

用这种方式查找到的动态资源会特别多,因为用正则表达式匹配了所有的字符串,那么如何精确找到动态资源呢,你会发现android源码里面写着Todo,哈哈。

 @Override
               public void visitMethodInsn(int opcode, String owner, String name,
                       String desc, boolean itf
)
{
                   super.visitMethodInsn(opcode, owner, name, desc, itf);
                   if (owner.equals("android/content/res/Resources")
                           && name.equals("getIdentifier")
                           && desc.equals(
                           "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I")) {
                         mFoundGetIdentifier = true;
                       // TODO: Check previous instruction and see if we can find a literal
                       // String; if so, we can more accurately dispatch the resource here
                       // rather than having to check the whole string pool!
                   }
               }

那就只能自己想个方案找到getIdentifier引用的所有资源了。
先来看看效果,这个是getIdentifier的多种调用方式

这个是用SmallApk插件找到的动态资源

这个是找到的动态资源调用关系图

那么SmallApk是怎么做的呢

思路和android源码ResourceUsageAnalyzer是一样的,都是匹配字符串常量,唯一的区别就是加入了方法有向图搜索节点,排除大部分无用字符串。

首先形成调用有向图

/**
* KeepResUsageVisitor会把methodNode、constantNode、fieldNode、classNode调用关系转换成有向图
*/

class KeepResUsageVisitor extends ClassVisitor {
   private String className;
   public KeepResUsageVisitor() {
       super(Opcodes.ASM5);
   }
   @Override
   public void visit(int version, int access, String name, String signature,
                     String superName, String[] interfaces)
{
       super.visit(version, access, name, signature, superName, interfaces);
       className = name;
   }
   @Override
   public MethodVisitor visitMethod(int access, final String name,
                                    String desc, String signature, String[] exceptions)
{
       String methodName = name;
       return new MethodVisitor(Opcodes.ASM5) {
           @Override
           public void visitLdcInsn(Object cst) {
               super.visitLdcInsn(cst);
               if (cst instanceof String) {//常量节点
                   String constant = (String) cst;
                     GraphNode caller = new GraphNode();
                       caller.putClass(className);
                       caller.putMethod(methodName);
                       caller.putConstant(constant);
                       GraphNode called = new GraphNode();
                       called.putClass(className);
                       called.putMethod(methodName);
                       GraphHolder.addNode(caller, called);
               }
           }
           @Override
           public void visitFieldInsn(int opcode, String owner, String name, String desc) {
               super.visitFieldInsn(opcode, owner, name, desc);//变量节点
               GraphNode caller = new GraphNode();
               caller.putClass(owner);
               caller.putField(name);
               GraphNode called = new GraphNode();
               called.putClass(className);
               called.putMethod(methodName);
               GraphHolder.addNode(caller, called);
           }
           @Override
           public void visitMethodInsn(int opcode, String owner, String name,
                                       String desc, boolean itf)
{//方法节点
               super.visitMethodInsn(opcode, owner, name, desc, itf);
               GraphNode caller = new GraphNode();
               caller.putClass(className);
               caller.putMethod(methodName);
               GraphNode called = new GraphNode();
               called.putClass(owner);
               called.putMethod(name);
               GraphHolder.addNode(caller, called);
           }
       };
   }
   @Override
   public FieldVisitor visitField(int access, String name, String desc, String signature,
                                  Object value)
{
       final String field = name;
       if (value instanceof String) {//变量节点
           String constant = (String) value;
            GraphNode caller = new GraphNode();
               caller.putClass(className);
               caller.putField(field);
               caller.putConstant(constant);
               GraphNode called = new GraphNode();
               called.putClass(className);
               called.putField(field);
               GraphHolder.addNode(caller, called);
       }
       return new FieldVisitor(Opcodes.ASM5) ;
   }
}

接着找到getIdentifier的方法节点

 @Override
           public void call(GraphNode caller, GraphNode called)
{
               if (called.getClassName().equals("android/content/res/Resources")
                       && called.getMethod().equals("getIdentifier")) {
                   if (!caller.getClassName().startsWith("android/support/v7")) {
                       dynamicCallGraph.add(caller);
                   }
               }
           }

然后找到所有调用getIdentifier的字符串常量

private void addCodeStrings() {
       mLogPrinter.println("Dynamic String---->CodeString:");
       List<GraphNode> list  = new ArrayList<>();
       Set<String> codeStrings  = new HashSet<>();
       for (GraphNode callGraph : dynamicCallGraph) {
           Collection<GraphCall> set = GraphHolder.findParentNode(callGraph);
           if (set != null) {
               for (GraphCall call : set) {
                   GraphNode caller = call.getCaller();
                   String value = caller.getConstant();
                   if (value != null) {
                       list.add(caller);
                       codeStrings.add(value);
                   }
               }
           }
       }
   }

最后匹配字符串常量找到动态资源

                // getResources().getIdentifier("ic_video_codec_" + codecName, "drawable", ...)
               for (Resource resource : mModel.getResources()) {
                   if (resource.name.startsWith(name)) {
                       mDynamicUsed.add(resource);
                   }
               }

找到动态资源以后就能去解决AndResGuardShrinkResources的问题了

解决ShrinkResources只能去除小部分无用资源的问题,只要把找到的动态资源文件写入到/build/intermediates/res/merged/release/raw/keep.xml

static void writeKeepXml(Set<ResourceUsageModel.Resource> list, File keepFile) {
   if (list == null || list.size() == 0) {
       return
   }
   StringBuffer buffer = new StringBuffer()
   list.each { value ->
       buffer.append(“@“ + value.type.getName() + “/“ + value.name)
       buffer.append(“,”)
   }
   buffer.deleteCharAt(buffer.length() - 1)
   def builder = new groovy.xml.StreamingMarkupBuilder()
   builder.encoding = “UTF-8
   def result = builder.bind {
       mkp.xmlDeclaration()
       mkp.declareNamespace(‘tools’: ‘http://schemas.android.com/tools’)
       resources(‘tools:shrinkMode’: ‘strict’, ‘tools:keep’: buffer)
   }
   def writer = new FileWriter(keepFile)
   writer << result
}

解决AndResGuard需要配置白名单的问题,只要把动态资源加入到白名单就可以

 Set<String> keepResSet = new HashSet<>();
       if (mDynamicUsed != null){
           for (Resource resource : mDynamicUsed) {
               keepResSet.add("R."+resource.type.getName()+"."+resource.name);
           }
       }
resproguardTask.setWhiteList(keepResSet)

你问我答

  1. AndResGuard会混淆资源文件名,xml资源文件里面也使用了文件名的字符串,那为什么apk没有崩溃?
    因为编译完以后布局xml文件里变成了int常量,AndResGuard修改的是字符串,int索引没变

  2. proguard也会去除R文件,那为什么用ThinR还会减小包体积?
    因为aar包里不存在R.class的,app打包的时候会重新生成lib库的R文件,但是因为生成lib库的class文件时R文件的变量不是final,所以aar里面是直接引用引用了lib.R.id,
    然后proguard判断lib库R文件是有引用关系的不能去除,ThinR相当于接着把lib库里面的R文件删除

  3. 在mac上解压缩apk再压缩会去,你会发现这个apk已经没法安装了,为什么,照理说不做任何操作应该不影响apk签名呀?
    因为MAC解压缩的时候会存在.DS_Store文件,直接压缩会把外面的文件夹目录也压缩进去

  4. 重新压缩apk以后体积会小,为什么apk自己不是压缩过了吗?
    因为默认图片是不压缩的

  5. shrinkResources不是删除了无用资源吗,那为什么我用Lint去删除无用资源,包体积还是会变小?
    一个是资源问题,一个是代码问题。
    资源问题:shrinkResources匹配字符串常量得到的无用资源会比较少,而lint扫描会只扫描硬静态引用资源,这样扫描的资源文件会比较多
    代码问题:lint还会删掉java文件,而shrinkResources只会去除无用资源,虽然android源码里面二次打包TWO_PASS_AAPT,但是默认没开启

  6. android gradle插件默认是开启v2签名的,为什么在我们的app里面用修改meta-inf文件的方式加入渠道号还可以运行?
    因为我们先加固,然后重新v1签名,再打渠道包,运气好,刚好绕过了v2签名的坑,哈哈

  7. zipalign会影响v1签名和v2签名吗?
    请在v1签名后使用zipalign,v2签名前使用zipalign,v1签名和v2签名可以同时存在,不能只用v2签名,因为在7.0手机只会校验v1签名


更新推荐...

Android APK 瘦身实践

Android应用瘦身,从18MB到12.5MB




  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值