基于 Facebook Redex 实现 Android APK 的压缩和优化

image

随着世界各地越来越多的人开始登录Facebook,我们有越来越大的责任保证她的快捷使用性。在发展中地区尤为如此,其中旧设备在市场上滞留的时间变长,人们对新设备的升级周期同样页边长了。我们希望确保我们的程序在所有主要移动平台上改进性能的可能的方法。

Android是我们最大的平台之一,它也是具有很大的多样性设备的移动平台。在这些设备上的任何性能或效率改进都可以更好地为全世界数百万人的体验到。

今天,我们想分享一些我们的努力,通过一个我们称之为Redex的项目来优化Android的Java字节码。Redex是一个用于优化Android .dex文件的管道。通过应用一系列可定制的转换,使得在源.dex在被合并到APK之前被优化。

image

在我们的优化项目开始时,我们决定做最优化的最佳优化切入点是在创建.dex文件之后和将.dex文件组装到APK之前。在字节码级别(而不是直接在源代码上)执行我们的优化,这样的优点是,它使我们能够在整个全局的二进制文件上执行类间优化,而不是仅仅执行本地类级别优化。我们选择对dex字节码而不是Java字节码执行变换,因为某些变换只能在DX之后完成。这将类似于C风格编译过程中的后链接阶段,您可以在整个二进制文件中进行全局优化。

我们意识到工程师可能会继续提出新的和创造性的字节码优化随着时间的推移。Facebook工程师往往快速移动,所以我们想要构建一个能从多个工程师工作的许多优化的好处。我们将我们的优化管道组织为一系列阶段,其中“原始”.dex在管道的开始处进入,并且“转换的”.dex在结束处离开。流水线中的每个阶段都可以被认为是一个独立的“优化插件”,它在前一阶段之后捕捉到位置,允许我们将多个不同的,可能无关的变换链接在一起。这使得它从工程角度来说非常灵活,因为多个工程师可以并行地尝试不同的优化,然后只有当它们准备就绪时才将它们插入最终管道。

image

Redex管道被推广以允许任何种类的.dex变换,但是今天我们要专注于使用Redex来最小化APK中的字节码。

减少字节码

在传输应用程序中减少字节码的数量有很多好的方法。在发展中国家,闪存大小通常限于保持设备成本可承受 - 即使是几兆字节,也可以在少于1 GB的存储空间的手机上产生影响。更少的字节也意味着更快的下载时间,更快的安装时间,并降低单元用户的数据使用。最后,较少的字节码通常也转化为更快的运行时性能。所有其他条件相同,较少的字节码意味着更少的执行指令和更少的代码页故障进入内存,这将是资源密集型场景(如应用程序冷启动)的性能改进。

在这个项目的开始,我们开始查看使用默认编译工具链生成的一些原始.dex文件。当我们盯着字节码时,我们开始找到一些我们可以用Redex优化的常见模式。让我们来看看我们添加到管道以减少.dex文件大小的几个优化阶段。

缩小和压缩

通常对诸如Javascript和HTML之类的网络语言进行缩小以通过在不改变功能的情况下减少和最小化字符串来减少字节。这些技术在Java中有较好效。

字符串在开发期间对于工程师是非常有价值的 - 例如类路径,源文件的文件路径,函数名等等 - 但是字符串在编译的字节码中实际占用了很多字节。此外,运行时通常不关心这些描述性命名的字符串:它将调用函数“abc()”就像调用函数“MyFooSuccessCallback()”一样容易。

因此,我们通常可以用任何(较短的)占位符字符串来替换长的人类可读的字符串。这减少了专用于字符串而不影响应用程序功能的字节数。

image

There are some qualifications to this, though. Source file paths, while not explicitly necessary for code execution at runtime, are extremely useful for engineers when something goes wrong. If we were to replace them with some short but random placeholder string, or even to strip them out entirely, we’d lose an important hint when following up on bug reports.

image

和纯缩小一样,我们可以通过我们的.dex并用一个较短的占位符字符串替换文件路径字符串。然而,我们需要保持反向映射到原始字符串,所以我们可以重建文件的路径来源,当我们需要它们,例如在错误报告。我们实际上不需要在APK本身中发送这个反向映射,因为对于所有实际目的,它将永远不会被现场的机器代码使用,所以这最终是我们的发运APK大小的改进。

内联

内联是用于将被调用函数的功能移动到其调用函数中以减少函数调用的开销而不改变功能的一般技术。除了潜在地提高代码执行的性能之外,如果正确执行,这也可能导致尺寸减小。但是,如果操作不当,内联可以很容易地增加最终二进制文件的大小,而不是。

良好的软件工程实践通常鼓励适当地封装范围和类责任。虽然这些策略在工程过程中很重要,但它们也可以在最终的字节码中留下一些优化机会。

image

最简单的例子之一是包装函数。这些通常是添加的小功能,为工程师提供更简单的类API或调整功能以接受不同的参数列表。这也可能包括简单的访问器函数(setter / getter),这些函数必须包含在类API中,但在运行时可能永远不会被调用。在类文件的初始编译期间,不是立即显而易见哪些函数是多余的,但是当我们有我们的初始.dex文件时,我们可以看到哪些全局优化可用。

此外,对于调用其他类的类,可能会有一些包装器函数调用其他包装函数 - 我们应该删除中间人。在许多情况下,这正是我们所做的。每当我们将一个函数内联到另一个函数中时,我们可以减少与函数跳转相关联的开销(和字节码)。

image

自然,这需要非常仔细地做。很多时候,仍然有一些值由访问器或包装函数提供 - 运行时访问检查或多态性 - 但在实例中,当我们可以确保这种技术不会导致任何正确性错误,那么上行是字节码减少。

还有另一种情况,内联可能是可能的。说一个父进程调用一个子函数,但需要先绕过一个中间函数。这可能是类型协方差检查的情况,其中我们要运行一些简单的运行时检查。与前面的例子不同,这个中间函数可能是微不足道的,但是我们不能完全剥离它; 它在运行时对我们有用。然而,这些函数中的许多都很小,以至于调用它们的字节码开销几乎与函数本身一样大。

在一个简单的情况下,我们在父函数和子函数之间有一个1:1的调用关系,我们可能只是将迂回函数内联到父函数或子函数中,如上所述。

然而,当父和子之间的调用关系是1:N或N:1 - 甚至N:N时,这变得更复杂。在这些情况下,鲁莽内联可以增加我们的二进制大小,而不是减少它!在这些情况下的伎俩是找出是否将绕行功能嵌入父节点或子节点(ren),或者是否没有改进的机会,我们应该完全中止内联。这些是在编译时基本不可能做出的决定,但可以通过分析.dex文件的全局状态来完成。

消除冗余代码

在任何大型项目中,死亡或不可达代码都不可避免地累积在源代码中。删除死代码通常是一个非常简单的技术,用于减少二进制大小,没有任何其他缺点。

在某些方面,死代码消除类似于标记和清除垃圾回收。如果你开始一些你知道可以访问的入口点,例如清单文件中定义的主要活动,你可以运行分支和函数调用。在遍历函数调用的这个图表时,我们可以标记沿途访问的每个函数。在我们遍历了每个可能的分支和函数调用之后,我们可以假设所有未标记的函数都是可以安全删除的死代码。

这种用于去除死代码的技术在理论上是简单的,但是还存在一些需要考虑的实际复杂性。在处理反射时,你需要小心,因为在构建时可能不明显函数被间接调用。您还需要注意通过布局或其他资源引用的类和函数 - 仅分析Dalvik字节码本身不足以识别类中的所有不同入口点。这些例子需要他们自己的专门技术来识别死代码,但从根本上说,它们的功能类似于简单的情况。

摘要

我们希望这给了你一个Redex提供的品味。在这篇文章中,我们专注于减少字节码大小,但我们一直在努力的其他一些令人兴奋的优化管道,我们希望在未来分享。在此之前,敬请期待!

感谢David Alves,David Tarjan和其他人帮助写这篇文章。

英文原文