Android技术知识Android开发

Google工程师的建议,解锁性能优化误区

2020-11-28  本文已影响0人  Android开发工作者

这些年来,出现了一些关于 Android 性能的谣言。虽然有些谣言可能是有趣的或好玩的,但是在寻求做一个高性能应用 APP 时它可能把你指向了一个错误的方向,这样并不有趣。

在本博客中,本着谣言终结者的精神,我们将测试这些谣言。在我们的测试中,我们使用了你也可以使用的真实示例和工具。我们集中在一些主流应用范例:作为开发人员,你在应用程序中可能正在做的事情。这里有一个重要提示,请记住,在出于性能原因决定使用一种编码实践之前,必须结合实际进行评估。

谣言1:Kotlin 应用程序比 Java 应用程序更大,更慢

Google 云端硬盘团队已将他们应用程序从 Java 转换为 Kotlin。此转换涉及 170 个文件中的16,000 多行代码,覆盖 40 多个构建目标。在团队监控的指标中,第一个是启动时间。


如你所见,向 Kotlin 的转换没有实质性影响。

甚至在整个基准测试中,团队没有观察到性能差异。他们确实看到编译时间和编译后的代码大小略有增加,但是大约 2%并没有显着影响。

在好的方面,团队的代码行减少了 25%。他们的代码更干净,更清晰,更易于维护。

关于 Kotlin 需要注意的一件事是,你可以并且应该使用代码缩减工具,例如 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();
复制代码

我们把它和使用公共字段 foo 进行了比较,它的代码破坏了对象封装以直接访问这些字段。

我们在配备 Android 10 的 Pixel 3 上使用 Jetpack Benchmark 库对它进行了基准测试。基准测试库提供了一种绝佳的方式来轻松测试代码。该库的功能之一是预热代码,所以得到稳定的测试结果。

那么,基准测试显示了什么?

getter 方式的性能和直接使用公共字段一样。这个结果不足为奇,因为 Android 运行时(ART)内联了代码中所有琐碎的访问方法。因此,在 JIT 或 AOT 编译后执行的代码是相同的。甚至,当你访问 Kotlin 中的字段(在本示例中为 tc.foo )时,你将根据上下文使用 getter 或 setter访问该值。然而,由于我们内联了所有访问器,因此 ART 在这里的处理使得性能没有区别。

如果你不使用 Kotlin,除非有充分的理由使用公开的字段,否则你不应破坏代码的封装性。隐藏类的私有属性很有用,并且没有必要仅出于性能原因而把它公开。坚持使用 getters 和 setters 方法。

谣言3:Lambda 比内部类慢

Lambda(尤其是在流式 API 的情况下)是一种方便的语法,可以实现非常简洁的代码。
让我们来看一些代码,对来自对象数组的一些内部字段的值求和。首先,使用流式 API 的 map-reduce 操作符。

ArrayList<ToyClass> array = build();
int sum = array.stream().map(tc -> tc.foo).reduce(0, (a, b) -> a + b);
复制代码

在这里,第一个 lambda 将对象转换为整数,第二个 lambda 将其产生的两个值相加。
下面代码,相当于和lambda 表达式比较的等价的类。

ToyClassToInteger toyClassToInteger = new ToyClassToInteger();
SumOp sumOp = new SumOp();
int sum = array.stream().map(toyClassToInteger).reduce(0, sumOp);
复制代码

这里有两个嵌套的类:一个是 ToyClassToInteger,它将对象转换为整数,第二个是求和运算。

显然,第一个带有 lambda 的示例,它更为优雅:大多数 code reviewers 都可能会选择第一个选项。

但是,他们的性能差异怎么样呢?我们再次在配备 Android 10 的 Pixel 3 上使用了Jetpack Benchmark 库,没有发现性能差异。

从图中可以看到,我们还定义了一个外部类,并且他们的性能也没有差异。

性能上相似的原因是 lambda 被转换为匿名内部类。

因此,与其编写内部类还不如选择 lambda 表达式:它创建了更加简洁,清晰的代码,你的 code reviewers 会喜欢它。

谣言4:分配对象很昂贵,我应该使用池

Android 使用最先进的内存分配和垃圾回收。对象分配几乎在每个版本中都得到了改善,如下图所示。

垃圾收集在各个版本之间也有了显着改善。如今,垃圾收集对应用程序的流畅度没有影响。下图显示了我们在 Android 10 中对新生代的并发收集所做的改进。新版本 Android 11 中也有明显的改进。

在GC基准测试(例如 H2 )中,吞吐量大幅提高了 170% 以上,而在真实环境中的应用程序(例如Google 表格)中的吞吐量已提高了 68%。

那么,这如何影响编码选择,例如是否使用池分配对象?

如果你认为垃圾收集效率低下并且内存分配昂贵,那么假设创建的垃圾越少,垃圾收集工作就越少。因此,不是每次使用它们都会创建新对象,而是维护一个经常使用的类型的池,然后从那里获取对象?因此,你可能会像下面这样实现:

Pool<A> pool[] = new Pool<>[50];
void foo() {
   A a = pool.acquire();
   …
   pool.release(a);
}
复制代码

这里忽略了一些代码细节,在代码中定义了一个池,从池中获取一个对象,然后最终释放它。

为了测试这一点,我们实施了微基准测试来测量两件事:从池中检索对象的标准分配的开销,以及CPU 的开销,以确定垃圾回收是否会影响应用程序的性能。

在这种情况下,我们将安装了 Android 10 的系统的 Pixel 2 XL,紧密地循环运行了数千次分配代码。我们还通过添加额外的字段来模拟不同的对象大小,因为对于小对象或大对象,性能可能会有所不同。

首先,对象分配开销的结果:

其次,用于垃圾回收的 CPU 开销的结果是:

你可以看到标准分配和池取出对象之间的差异很小。但是,当涉及到较大对象的垃圾回收时,池的方案会变稍微高一点。

这个并不意外,因为通过池化对象,会增加应用程序的内存占用量。突然的占用了太多的内存,即使由于池化对象而减少了垃圾回收调用的数量,每个垃圾回收调用的成本也更高。这是因为垃圾收集器必须遍历更多的内存才能确定哪些仍然存活哪些应该回收。

那么,这个谣言破灭了吗?不完全是。对象池是否更有效取决于你的APP需求。首先,请记住除了使用代码复杂性之外,使用池的缺点有:

但是,池的方法对于大或昂贵的分配对象可能很有用。

关键是在选择方案之前进行测试和评估。

谣言5:在 debug 模式下进行性能分析

在debug模式的同时对应用程序进行性能分析非常方便,通常我们也是在 debug 模式下进行编码。即使在debug模式中的性能分析可能有点不准确,但可以更快灵活的处理似乎弥补了这个缺点。事实上是并没有。

为了检验这个谣言,我们分析了一些 Activity 相关的常见操作过程。测试结果在下图中。

在某些测试(例如反序列化)上,没有影响。然而有一些结果有 50% 或以上的差别。我们甚至发现了速度慢 100% 的例子。这是因为 runtime 在debug模式时对其代码几乎没有优化,因此与用户在生产设备上运行的代码有很大不同。

在 debug 状态下进行性能分析的结果是,可能会误导你并且会浪费时间来优化不需要优化的内容。

疑点

我们现在要注意上面的谣言关注一下下面的疑点。这些并不是我们能验证的谣言。相反,它们可能不是显而易见或难以分析的,但结果可能会和你预想的完全不同。

疑问1:Multidex:会影响应用程序性能吗?

APK 越来越大。他们暂时没有适应传统 dex 规范的约束。如果代码超出方法数量限制,则应使用Multidex 解决方案。

问题是,多少方法算多?而且,如果应用程序包含大量 dex 文件,会对性能产生影响吗?这可能不是因为你的应用程序太大,你可能只是想根据功能拆分 dex 文件,以便于开发。

为了探索多个 dex 文件对性能的影响,我们使用了计算器 APP。默认情况下,它是个单 dex 文件应用程序。然后,根据其 package 边界将其拆分为五个 dex 文件,以根据功能部件模拟拆分。

我们从启动时间开始测试了性能的几个方面,结果如下

因此,拆分 dex 文件对此没有影响。对于其他应用程序,可能有一些开销,具体取决于几个因素:应用程序的大小以及如何拆分。但是,只要合理地分割 dex 文件并且不添加数百个 dex 文件,对启动时间的影响就应该很小。

接下来是 APK 大小和内存如何?

如你所见,APK 大小和运行时内存占用量都有轻微增加。这是因为,当你将应用程序拆分为多个 dex 文件时,每个 dex 文件都有一些符号表和缓存表的重复数据。

但是,你可以通过减少 dex 文件之间的依赖关系来最大程度地减少这种增加。在我们的测试中,没有努力将其最小化。如果我们尝试最小化依赖关系,我们将使用 R8 和 D8 工具。这些工具可以自动拆分 dex 文件,并帮助你避免常见的陷阱并最大程度地减少依赖关系。例如,这些工具创建的dex 文件不会超过需要的文件,并且不会将所有启动类都放置在主文件中。但是,如果你对 dex 文件进行了自定义拆分,请合理的进行评估。

疑问2:无用代码

使用带有 JIT 编译器的 ART 的好处之一是,运行时可以分析代码,然后对其进行优化。有一种理论认为,如果编译器/ JIT 系统未对代码进行概要分析,则也可能不会执行该代码。为了验证这一理论,我们检查了 Google 应用程序生成的 ART 配置文件。我们发现,ART 编译器-JIT 系统未对应用程序代码的重要部分进行概要分析。这表明许多代码实际上从未在设备上执行过。

有几种类型的代码可能无法剖析:

但是,我们看到的偏态分布强烈表明应用程序中可能有很多不必要的代码。

删除不必要的代码的快速,简便,免费的方法是用 R8 进行缩小。接下来,如果尚未进行转换,你可以使用Android App BundlePlay Feature Delivery。它们通过按需下载改善用户体验。

总结

我们分析了 Android 性能优化的一些谣言,但也看到在某些情况下,一些事情还不明确。因此,在选择复杂的优化甚至是打破以前的编码习惯前,尽量做好相关的测试评估。

有很多工具可以帮助你评估和确定最适合你的应用的工具。例如,Android Studio 的 Profilers ,它具有用于 native 和非 native 代码分析,甚至有电量和网络分析。有一些工具可以进行更深入的研究,例如 Perfetto 和 Systrace。这些工具可以提供非常详细的视图,例如在应用程序启动或执行过程中发生的情况。

Jetpack Benchmark 库使得测量和基准测试变得简单。我们强烈建议你在持续集成中使用它来跟踪性能,并查看你的应用程序在向其添加更多功能时的行为。最后但需要注意的一点是,不要在调试模式下进行性能分析。

上一篇下一篇

猜你喜欢

热点阅读