终结 Android 性能流言 Posted 2021-03-30 谷歌开发者
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了终结 Android 性能流言相关的知识,希望对你有一定的参考价值。
作者 / Calin Juravle, Google 软件工程师
近些年来出现了一些关于 android 性能的流言。虽然有些流言可能是搞笑或好玩的,但有时候它们在我们写高性能的 Android 应用的时候却将我们带上了歪路。
在本篇文章中,我们将本着「流言终结者」的精神来检验这些流言,以真实案例与常用工具来击破这些流言。我们将聚焦于一些主流应用的范例,可能就是开发者在 App 中正在做的事情。重要提示,请记住在决定出于性能原因使用一种编码实践之前,结合实际情况权衡利弊是非常重要的。
流言 1: Kotlin 应用比 Java 应用更大、更慢
Google Drive 团队已经把他们的应用从 Java 转换成了 Kotlin。这次转换涉及 170 个文件,共 16,000 多行代码,覆盖了 40 多个构建目标。在该团队监控的指标中,首先是启动时间。
您可以看到,转换到 Kotlin 以后没有实质上的影响。
事实上,通过完整的基准测试,团队没有观察到性能差异。他们的确发现在编译时间和编译后代码体积上有细微的增加,不过也只有大约 2% ,没有明显的影响。
在另一方面,团队获得了减少 25% 的代码行数的好处。他们的代码更干净,更清晰,更容易维护。
还有需要注意的是,在 Kotlin 上您也应该使用代码优化工具,比如 R8,R8 甚至有针对 Kotlin 的特定优化。
流言 2: Getter 和 Setter 的调用增加开销
一些开发者出于性能考虑选择使用 public 的字段,而不是使用 setter 和 getter。通常的代码模式是这样的,用 getFoo() 作为我们的 getter。
public class ToyClass {
public int foo;
public int getFoo ( ) { return foo; }
}
ToyClass tc = new ToyClass();
我们把 tc.getFoo() 和 tc.foo 进行了比较,后者代码无视对象的封装,直接访问字段。
我们使用
Jetpack Benchmark 库
在 Android 10 的 Pixel 3 上进行了基准测试。这个基准测试库提供了一种神奇的方式来轻松测试您的代码。它的特点之一是,它对代码进行了预热,因此测试结果是稳定的数值。
Getter 的表现和直接访问字段的表现一样好。这个结果并不意外,因为 Android Runtime (ART) 会在您的代码中内联所有琐碎访问方法。所以在 JIT 或者 AOT 编译后被执行的代码是一样的。的确,当您在 Kotlin 中通过这样访问一个字段时——比如这里的 tc.foo——会根据上下文不同使用 getter 或 setter 来访问这个值。然而,因为我们内联了所有的访问器,所以 ART 在这里为您提供了保障: 在性能上没有任何差异。
如果您没有使用 Kotlin,除非您有很好的理由让字段 public,否则您不应该破坏良好的封装实践。隐藏您的类的私有数据是有用的,但没有必要为了性能原因而将它设置为 public,请放心使用 getter 和 setter。
Lambda 是一种方便的语言结构,随着流式 API 的引入,它可以帮助实现非常简洁的代码。
我们来看一些代码,我们从一个对象数组中求出一些内部字段的值。首先,使用流式 API 的 map-reduce 操作。
ArrayList<ToyClass> array = build();
int sum = array .stream().map (tc -> tc.foo).reduce(0 , (a, b) -> a + b);
在这里第一个 lambda 将一个对象转换成为一个 integer,第二个 lambda 将其产生的两个值相加。
ToyClassToInteger toyClassToInteger = new ToyClassToInteger();
SumOp sumOp = new SumOp();
int sum = array .stream().map (toyClassToInteger).reduce(0 , sumOp);
他们有两个嵌套类: 一个是将对象转换为 integer 的 ToyClassToInteger,第二个是求和操作。
显然,第一个例子也就是带 lambda 的例子,要优雅的多: 大多数 code reviewers 可能会说喜欢第一种方案。
然而性能上的差异呢?我们再次在 Android 10 的 Pixel 3 上使用 Jetpack Benchmark 库,我们发现没有性能差异。
从图中可以看到,我们还定义了一个顶层类,对比它的性能也没有差异。
造成这种性能相似的原因是 lambda 被转换成匿名内部类。所以,与其写内部类,不如去写 lambda——它能创造出更简洁、更干净的代码,您的 code reviewer 会喜欢的。
Android 使用了先进的内存分配和垃圾收集。对象分配几乎在每个版本中都有改进,如下图所示:
垃圾收集在每个版本中也有显著的改进。如今,垃圾收集对 jank (卡顿) 和应用流畅度没有影响,下图显示了我们在 Android 10 中对临时对象的收集与分代并发收集的改进,在 Android 11 中也能看到有所改进。
在 H2 等 GC 基准测试中,吞吐量大幅提升了 170% 以上,在 Google Sheets 等实际应用中,吞吐量提升了 68%。那么,这对编码选择有什么影响,比如是否使用对象池来分配对象?
如果您假设垃圾收集是低效的,内存分配是昂贵的,您会认为您创建的垃圾越少,垃圾收集就必须越少工作。所以,与其每次使用新的对象时都创建新的对象,不如维护一个经常使用的类型池,然后从那里获取对象?因此,您可能会实现这样的代码:
Pool<A> pool[] = new Pool<>[50 ];
void foo ( ) {
A a = pool.acquire();
…
pool.release(a);
}
这里略过一些代码细节,我们在代码中定义一个对象池,从池中获取一个对象,然后最终释放它。
为了测试这一点,我们实现了微基准以测量两件事: 标准分配从池中取出对象的开销和 CPU 的开销,以弄清楚垃圾收集是否影响应用程序的性能。
在这个案例中,我们使用了一台搭载 Android 10 的 Pixel 2 XL,在一个非常紧凑的循环中运行了数千次分配代码。我们还通过增加额外的字段来模拟不同的对象大小,因为小对象或大对象的性能可能会有所不同。
首先,对象分配开销的结果:
您可以看到,标准分配和对象池之间的差别很小。然而当涉及到较大对象的垃圾收集时,对象池方案会变得稍差。
这种行为实际上是我们对垃圾收集的期望,因为通过对象池,您会增加您的应用程序的内存占用。突然间,您占用了太多的内存,即使因为您将对象池化而减少了垃圾收集调用的次数,但每次垃圾收集调用的成本却更高。这是因为垃圾收集器必须遍历更多的内存,才能决定哪些是还活着的,哪些是应该被收集的。
那么,这个流言破灭了吗?并非完全如此。对象池是否更高效取决于您的应用需求。请先记住,除了代码复杂度之外,使用对象池的缺点:
不过,对象池的方案可能对大型或昂贵的分配对象有用。要记住的关键是在选择方案之前进行测试和估量。
在调试时对您的应用进行性能分析真的很方便,毕竟您通常是在调试模式下进行编码的。而且,即使在可调试模式下的性能分析有点不准确,但能够更快地迭代应该可以弥补。不幸的是并非如此。
为了检验这个流言,我们查看了一些常见的 Activity 相关工作流的基准。结果见下图:
在一些测试中,如反序列化,没有影响。但是,对于其他的测试,基准上会有 50% 甚至更多的退步。我们甚至发现有的例子慢了 100%。这是因为 Runtime 在可调试的时候,对您的代码做的优化很少,所以和用户在生产设备上运行的代码是非常不同的。
在可调试中进行性能分析的结果是,您可能会被误导到您的应用中的热点代码 (译者注: 这部分热点代码很可能会被自动优化掉),可能会浪费时间优化一些不需要优化的东西。
我们现在要从流言终结中走出来,把注意力转向更奇怪的事情。这些事情并不是我们真正可以破除的。相反,他们是一些可能并不明显或不容易分析的事情,但其结果可能会颠覆您的世界。
意料之外 1: Multidex 它是否会影响我的应用性能?
APK 的体积越来越大。它们已经有一段时间没有适应传统 dex 规范的约束了。如果您的代码超过了方法数的限制,Multidex 是您应该使用的解决方案。问题是,多少方法才算多?而且,如果一个应用有大量的 dex 文件,是否会影响性能?这可能并不是因为您的应用太大,您可能只是想根据功能拆分 dex 文件,以便于开发。为了探讨多个 dex 文件对性能的影响,我们以计算器应用为例。默认情况下,它是一个单一的 dex 文件应用。然后,我们根据它的包边界,将其拆分成五个 dex 文件,以模拟根据功能进行拆分。然后我们测试了几个方面的性能,首先是启动时间。
所以拆分 dex 文件在这里没有影响。对于其他应用来说,可能会有轻微的开销,这取决于几个因素: 应用有多大,以及如何拆分。然而,只要您合理地拆分 dex 文件,不增加数百个,对启动时间的影响应该是最小的。APK 大小和内存怎么办?
正如您所看到的那样,APK 大小和应用程序的运行时内存占用都略有增加。这是因为当您将应用程序分割成多个 dex 文件时,每个 dex 文件都有一些重复的符号表和缓存数据。
然而,您可以通过减少 dex 文件之间的依赖关系来尽量减少这种增加。在我们的案例中,我们没有努力将其最小化。如果我们试图将依赖性降到最低,我们会寻求 R8 和 D8 工具。这些工具可以自动分割 dex 文件,帮助您避免常见的陷阱,并将依赖性降到最低。例如,这些工具不会创建超过需要的 dex 文件,也不会把所有的启动类放在主文件中。但是,如果您对 dex 文件进行自定义拆分,一定要衡量您拆出来的东西。
使用像 ART 这样的 JIT 编译器的运行时的一个好处是,运行时可以对代码进行分析,然后进行优化。有一种理论认为,如果代码没有被解释器/JIT 系统剖析,那么它可能也不会被执行。为了测试这个理论,我们检查了 Google app 产生的 ART 配置文件。我们发现,相当一部分应用代码没有被 ART 解释器——JIT 系统剖析。这说明很多代码实际上从未在设备上执行过。
向后兼容的代码,这些代码不会在所有设备上被执行,特别是不会在 Android 5 或更高版本的设备上被执行;
然而,我们看到的倾斜分布是一个强烈的迹象,表明应用程序中可能有很多不必要的代码。
快速、简单、免费的删除不必要代码的方法是用 R8 进行 minify。接下来,如果您还没有这样做,要将您的应用转换为使用 Android App Bundle 和 Play Feature Delivery。它们允许您只安装被使用的功能,从而提高用户体验。
我们已经终结了许多关于 Android 性能的流言,但也看到,在某些情况下,事情并不是一目了然的。因此,在选择复杂的优化或甚至是破坏良好编码实践的小优化之前,进行基准测试和测量是至关重要的。
有很多工具可以帮助您衡量和决定什么是最适合您的应用的。例如,Android Studio 有针对本地和非本地代码的分析器,它甚至有针对电池和网络使用的分析器。还有一些工具可以深入挖掘,比如 Perfetto 和 Systrace。这些工具可以提供一个非常详细的视图,例如,在应用程序启动期间或执行的一段期间发生的事情。
Jetpack Benchmark 库消除了围绕测量和基准测试的所有复杂性。我们强烈鼓励您在您的持续集成中使用它来跟踪性能,并查看您的应用程序在您添加更多功能时的表现。最后,但并非最不重要的是,不要在调试模式下进行配置文件。
Java 是 Oracle 和/或其附属公司的注册商标。
本文由扔物线学堂中文翻译并发布在扔物线学堂公众号。
一本手册尽览 Android 11 最新特性与开发技巧
更有成功心得助您举一反三
☟ 即刻下载 ☟
点击屏末 | 阅 读 原 文 | 获取 Android 11 开发者手册
以上是关于终结 Android 性能流言的主要内容,如果未能解决你的问题,请参考以下文章
手机取证流言终结
Unicode的流言终结者和编码大揭秘
流言终结者!Angular未来的版本发布计划,官方的博客以及我的解读
学点编码知识又不会死:Unicode的流言终结者和编码大揭秘
Xamarin.Android WebView App性能问题
为啥终结器会有“严重的性能损失”?