×

Android瘦身不反弹最佳实践

96
sunshine8 595a1b60 08f6 4beb 998f 2bf55e230555
2018.01.26 16:23 字数 1121

业界方案

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

方案 作用 瘦身效果
proguard 代码混淆 效果明显
abiFilter "armeabi" 去除其他平台so 效果明显
resConfigs "zh" 语言文件去除 0.1M
shrinkResources 无用资源去除需维护keep文件 1M
TinyPng 图片压缩,账号收费 3M
ThinR 移除R文件 0.3M
AndResGuard 资源混淆白名单维护难 资源混淆0.3M,7zip压缩2M
webp android兼容性差 不推荐
Lint 无用资源去除 有可能删除getIdentifier调用的资源 不推荐
redex 安全风险高,对于加固、热修复等功能有影响 未实践
so动态加载 风险高,大部分so都需要实时加载 未实践
加固 隐藏dex 1M
重复资源优化 对比资源文件 md5,删除重复文件和resources.arsc中的定义 0.2M
移除TINY_PNG文件 通过android-chunk-utilsresources.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最佳实践
Web note ad 1