android apk二进制资源优化

494 阅读3分钟

本文的目的是实现抖音分享的二进制优化中关于layout文件的优化,具体见

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

优化思路

打包成apk后,layout二进制文件存在一些实际运行中不需要的字段,如namespace,属性名称等,那么是否可以去除呢?经过他们的验证测试,确认可以去掉,结论如下:

layout 优化:

属性字符串名称裁剪:可以实现,取得收益 400K+;

命名空间去除:可以实现,取得收益 200K+

实现思路

文章中仅提到了优化思路及结果,我们来根据思路把它实现了。

优化时机选择

参考aabresguard,可以复用它选择的时机;于是简化下,直接基于它二次开发,我们只需要关注资源优化即可。

参考源码片段

private fun createAabResGuardTask(project: Project, variant: ApplicationVariant) {
    val variantName = variant.name.capitalize()
    val bundleTaskName = "bundle$variantName"
    if (project.tasks.findByName(bundleTaskName) == null) {
        return
    }
    val aabResGuardTaskName = "aabresguard$variantName"
    val aabResGuardTask: AabResGuardTask
    aabResGuardTask = if (project.tasks.findByName(aabResGuardTaskName) == null) {
        project.tasks.create(aabResGuardTaskName, AabResGuardTask::class.java)
    } else {
        project.tasks.getByName(aabResGuardTaskName) as AabResGuardTask
    }
    aabResGuardTask.setVariantScope(variant)

    val bundleTask: Task = project.tasks.getByName(bundleTaskName)
    val bundlePackageTask: Task = project.tasks.getByName("package${variantName}Bundle")
    bundleTask.dependsOn(aabResGuardTask)
    aabResGuardTask.dependsOn(bundlePackageTask)
    // AGP-4.0.0-alpha07: use FinalizeBundleTask to sign bundle file
    // FinalizeBundleTask is executed after PackageBundleTask
    val finalizeBundleTaskName = "sign${variantName}Bundle"
    val task = project.tasks.findByName(finalizeBundleTaskName)
    if (task != null) {
        aabResGuardTask.dependsOn(project.tasks.getByName(finalizeBundleTaskName))
    }
}
优化逻辑

第一步,先熟悉AppBundle的格式,过滤我们需要处理的res资源类型:layout,drawable,anim等。

Map<BundleModuleName, BundleModule> bundleModules = new HashMap<>();
for (Map.Entry<BundleModuleName, BundleModule> entry : rawAppBundle.getModules().entrySet()) {
    bundleModules.put(entry.getKey(), minifyModule(entry.getValue()));
}
private BundleModule minifyModule(BundleModule bundleModule){

        List<ModuleEntry> list = new ArrayList<>();
        for (ModuleEntry entry : bundleModule.getEntries()) {
            ZipPath path = entry.getPath();
            if (!path.startsWith(BundleModule.RESOURCES_DIRECTORY)) {
                list.add(entry);
                continue;
            }
            String toString = path.toString();
            if (!toString.startsWith("res/") || !toString.endsWith(".xml")) {
                list.add(entry);
                continue;
            }
            boolean needMinify = toString.startsWith("res/drawable")
                    || toString.startsWith("res/layout")
                    || toString.startsWith("res/anim")
                    || toString.startsWith("res/color");
            if (needMinify) {
                list.add(minifyLayout(entry));
                continue;
            }
            list.add(entry);
        }

        return bundleModule.toBuilder()
                .setRawEntries(list)
                .build();
    }

第二步,解析app bundle编译生成的目标文件,格式为protobuf

private ModuleEntry minifyLayout(ModuleEntry entry) {
        try {
            Resources.XmlNode raw = Resources.XmlNode.parseFrom(entry.getContent().openBufferedStream());
            if (raw.hasElement()) {
                Resources.XmlNode node = xmlMinifier.minify(raw);
                byte[] bytes = node.toByteArray();
                LogWriter.log("minify file:" + entry.getPath() + ", raw size=" + entry.getContent().size() + ", new size=" + bytes.length);
                return entry.toBuilder().setContent(ByteSource.wrap(bytes)).build();
            }
        } catch (Exception e) {
            String log = "minifyLayout error occur:" + entry.getPath();
            System.out.println(log);
            LogWriter.log(log);
        }
        return entry;
    }

第三步,去掉运行时不需要的属性字段

class XmlMinifier {

    private static String TAG = "XmlMinifier:";

    public Resources.XmlNode minify(Resources.XmlNode node) {
        if (node.hasElement()) {
            Resources.XmlElement element = node.getElement();
            Resources.XmlElement.Builder builder = element.toBuilder();

            // minify XmlNamespace
            int namespaceCount = element.getNamespaceDeclarationCount();
            for (int i = 0; i < namespaceCount; i++) {
                builder.setNamespaceDeclaration(
                        i,
                        minifyNameSpace(element.getNamespaceDeclaration(i))
                );
            }

            // minify attribute
            int attrCount = element.getAttributeCount();
            for (int i = 0; i < attrCount; i++) {
                Resources.XmlAttribute attr = element.getAttribute(i);
                builder.setAttribute(i, minifyAttribute(attr));
            }

            // child
            int childCount = element.getChildCount();
            for (int i = 0; i < childCount; i++) {
                if (element.getChild(i).hasElement()) {
                    builder.setChild(i, minify(element.getChild(i)));
                }
            }

            return node.toBuilder().setElement(builder.build()).build();
        } else {
            LogWriter.log("ignore cause not an element");
            return node;
        }
    }

    private Resources.XmlNamespace minifyNameSpace(Resources.XmlNamespace space) {
        return space.toBuilder().setPrefix("").setUri("").build();
    }

    private Resources.XmlAttribute minifyAttribute(Resources.XmlAttribute attr) {
        boolean hasResId = attr.getResourceId() != 0;
        boolean emptyNS = attr.getNamespaceUri().isEmpty();
        boolean hasCompiledItem = attr.hasCompiledItem();
        
        if (hasCompiledItem && hasResId) {
            return attr.toBuilder().setNamespaceUri("").setName("").setValue("").build();
        } else if (!emptyNS) {
            return attr.toBuilder().setNamespaceUri("").build();
        } else {
            return attr;
        }
    }
}

根据参考文章,及测试结果,去掉namespace及属性名称,属性值,属性的namespace均不影响运行。

注意

  • hasResId为false,这种情况主要为include的layout, fragment的name, style, class等;同时这些属性也是没有namespace的。
  • hasCompiledItem为false,主要是使用原始值的属性,如text使用hard code,constraint_referenced_ids,layoutManager,onClick等。

最后一步,将这个优化流程插入到aabresguard的任务中,具体插入到混淆之前,可交由后续的混淆流程继续优化。

优化结果对比

经过优化apk减小1M多;高于抖音文章里的结果,一是更激进,不仅去掉了属性名称,也去掉了属性值,二是扩大了优化范围,不限于layout,连anim,drawable,color等res资源都一起做了优化。

  • 优化前

image.png

  • 优化后

image.png