原文:
zh.annas-archive.org/md5/09787EDC0EF698C9109E8B809C38277C
译者:飞龙
第四章:内存
当我们试图达到应用程序的性能目标时,内存是需要关注的问题:管理不善的应用程序内存可能会影响整个系统的行为。它还可能以同样的方式影响设备上安装的其他应用程序,正如其他应用程序可能影响我们的应用程序一样。众所周知,Android 在市场上拥有广泛的不同配置和内存量的设备。开发者需要解决如何应对这种碎片化的策略,开发时遵循哪种模式,以及使用哪些工具来分析代码。这正是本章的目标。我们将关注堆内存,而关于缓存的处理将在第十章“性能提示”中进行讨论。
我们将探讨设备如何处理内存,深化对垃圾回收的了解,以及它是如何工作的,从而理解如何避免常见的开发错误,并明确我们将要讨论的内容以定义最佳实践。我们还将通过模式定义,以大幅降低我们认为是内存泄漏和内存膨胀的风险。第四章将总结官方工具和 API,Android 提供这些工具和 API 来分析代码并找到可能导致内存泄漏的原因,这些内容在第二章“高效调试”中并未涉及。
演练
在讨论如何改进和分析我们的代码之前,了解 Android 设备如何处理内存是非常重要的。接下来,在以下几页中,我们将分析 Android 使用的运行时的差异,我们将更深入地了解垃圾回收,理解内存泄漏和内存膨胀,以及 Java 如何处理对象引用。
内存的工作原理
你是否想过餐厅是如何运作的?让我们思考一下:当新的顾客群体进入餐厅时,有服务员会寻找地方为他们安排座位。但餐厅空间有限,因此需要尽可能释放桌子:这就是为什么当一个群体吃完后,另一个服务员会清理并准备桌子供其他群体使用。第一个服务员必须找到每个新群体合适座位的桌子。然后,第二个服务员的工作应该是迅速的,不应妨碍或阻碍其他人的任务。另一个重要方面是每组占用的座位数量:餐厅老板希望有尽可能多的空座位来安排新客户。因此,确保每个群体占用的座位数量正确,而不占用可能被其他新群体使用的桌子是非常重要的。
这与 Android 系统的情形类似:每次我们在代码中创建一个新对象,它都需要被保存在内存中。因此,它被分配为我们应用程序私有内存的一部分,以便在需要时访问。在整个应用程序生命周期内,系统会持续为我们分配内存。然而,系统可使用的内存是有限的,它不能无限期地分配内存。那么,系统如何保证应用程序始终有足够的内存呢?为什么 Android 开发者不需要手动释放内存?让我们来了解一下。
垃圾回收
垃圾回收是一个基于两个主要概念的老概念:
-
寻找不再被引用的对象
-
释放那些对象的引用内存
当不再有对该对象的引用时,可以清理并释放它的“表”。这就是为了提供未来对象分配的内存所做的事情。这些操作,包括新对象的分配和不再被引用的对象的释放,都由设备上使用的特定运行时执行,开发者无需做任何事情,因为这些都是自动管理的。与其他语言,如 C 和 C++发生的情况不同,开发者无需手动分配和释放内存。特别是,当需要时进行分配,当达到内存上限时执行垃圾回收任务。后台的这些自动操作并不意味着开发者可以忽视他们应用程序的内存管理:如果内存管理做得不好,应用程序可能会出现延迟、故障,甚至在抛出OutOfMemoryError
时崩溃。
共享内存
在 Android 中,每个应用程序都有自己的进程,完全由运行时管理,目的是回收内存,以便在必要时为其他前台进程释放资源。我们应用程序可用的内存量完全位于 RAM 中,因为 Android 不使用交换内存。这种做法的主要后果是,除了取消引用不再使用的对象之外,我们的应用程序没有其他方式来获得更多内存。但 Android 使用分页和内存映射:第一种技术定义了相同大小的内存块,称为页,在辅助存储中;而第二种技术使用与辅助存储中相关文件关联的内存映射,作为主要使用。当系统需要为其他进程分配内存时,会使用它们,因此系统创建分页内存映射文件来保存 Dalvik 代码文件、应用程序资源或本地代码文件。这样,这些文件可以在多个进程之间共享。
实际上,Android 使用共享内存以更好地处理来自许多不同进程的资源。此外,每个要创建的新进程都是由一个已存在的名为Zygote的进程分叉出来的。这个特殊的进程包含常见的框架类和资源,以加快应用程序的首次启动。这意味着 Zygote 进程在进程和应用程序之间共享。这种大量使用共享内存使得分析我们应用程序的内存使用情况变得困难,因为在达到正确的内存使用分析之前需要考虑许多方面。
运行时
内存管理的某些功能和操作取决于所使用的运行时。这就是为什么我们要了解 Android 设备使用的两个主要运行时的一些特定特性。它们如下:
-
Dalvik
-
Android 运行时 (ART)
ART 是后来添加的,用于从不同角度提升性能,替代 Dalvik。它在 Android KitKat(API 级别 19)中以开发者可启用的选项引入,并从 Android Lollipop(API 级别 21)开始成为主要的唯一运行时。除了在编译代码、文件格式和内部指令方面 Dalvik 和 ART 之间的差异之外,我们现在关注的是内存管理和垃圾回收。因此,让我们了解谷歌团队如何在运行时垃圾回收方面随时间提升性能,以及在我们开发应用程序时需要注意什么。
让我们退一步,回到餐厅的例子。如果所有员工,比如其他服务员和厨师,以及所有服务,比如洗碗工,在等待一个服务员空出桌子时停止他们的任务会发生什么?整个餐厅的成功与否都依赖于这个单一员工的表现在这种情况下拥有一个快速的服务员非常重要。但如果你负担不起他呢?店主希望他尽可能快地完成必须完成的工作,通过最大化他的生产力,然后合理分配所有顾客。这正是我们作为开发者需要做的:我们必须优化内存分配,以实现快速的垃圾回收,即使这会暂停所有其他操作。这里描述的就是运行时垃圾回收的工作原理:当达到内存上限时,垃圾回收开始执行任务,暂停所有其他方法、任务、线程和进程的执行。这些对象在垃圾回收任务完成之前不会恢复。因此,垃圾回收的速度足够快,以不影响我们在第三章,构建布局中讨论的每帧 16 毫秒的规则至关重要,否则会导致 UI 出现延迟和卡顿:垃圾回收占用的时间越多,系统准备要在屏幕上渲染的帧的时间就越少。
提示
请记住,自动垃圾收集并非没有代价:糟糕的内存管理可能导致 UI 性能变差,从而影响用户体验。没有任何运行时特性可以替代良好的内存管理。这就是为什么我们需要谨慎处理新对象的分配,尤其是引用。
显然,在 Dalvik 时代之后,ART 在这一过程中引入了许多改进,但背后的概念是相同的:它减少了收集步骤,为位图对象添加了特定的内存,使用了新的快速算法,还做了其他很酷的事情,这些将在未来变得更好,但如果我们想让应用程序有最佳性能,就没有办法逃避对我们的代码和内存使用进行剖析。
Android N JIT 编译器
ART 运行时使用提前编译,顾名思义,在应用程序首次安装时执行编译。这种方法以不同的方式为整个系统带来优势,因为系统可以执行以下操作:
-
由于预编译,减少电池消耗,从而提高自主性
-
比 Dalvik 更快地执行应用程序
-
改进内存管理和垃圾收集
然而,这些优势与安装时间相关的成本有关:系统需要在那时编译应用程序,然后它比其他类型的编译器要慢。
因此,谷歌在新的 Android N 中为 ART 的提前编译器增加了一个即时编译器。这个编译器在需要时才激活,即在应用程序执行期间,然后它采用了与提前编译不同的方法。这个编译器使用代码剖析技术,它不是提前编译器的替代品,而是对它的补充。它是系统的一个很好的增强,因为它在性能方面带来了优势。
基于剖析的编译增加了预编译的可能性,然后根据使用情况或设备条件缓存并重用应用程序的方法。这个特性可以节省编译时间,提高各种系统的性能。因此,所有设备都能从这种新的内存管理中受益。主要优势如下:
-
使用更少的内存
-
减少 RAM 访问
-
对电池的影响更低
然而,在 Android N 中引入的所有这些优势,都不应该让我们避免在应用程序中进行良好的内存管理。为此,我们需要知道代码背后潜伏着哪些陷阱,更重要的是,如何在特定情况下改善系统的内存管理,让应用程序保持活跃。
内存泄漏
从内存性能的角度来看,开发者在开发 Android 应用程序时可能犯的主要错误是所谓的内存泄漏,它指的是一个不再使用的对象,但被另一个仍然活跃的对象引用。在这种情况下,垃圾收集器会跳过它,因为足够的引用会让这个对象留在内存中。
实际上,我们是在避免垃圾收集器为其他未来的分配释放内存。因此,由于这个原因,我们的堆内存会变小,导致垃圾回收被更频繁地调用,从而阻塞应用程序的其他执行。这可能导致没有更多内存来分配新对象的情况,然后系统会抛出OutOfMemoryError
。考虑一个已使用对象引用不再使用的对象,而这些不再使用的对象又引用其他不再使用的对象,依此类推:由于根对象仍在使用,它们都不能被回收。
内存抖动
内存管理的另一个异常称为内存抖动,它指的是在很短时间内大量实例化的新对象造成的运行时无法承受的内存分配量。在这种情况下,许多垃圾回收事件会被多次调用,影响应用程序的整体内存和 UI 性能。
我们在第三章《构建布局》中讨论了避免在View.onDraw()
方法中分配内存的必要性,这与内存抖动密切相关:我们知道,每次需要重新绘制视图和刷新屏幕时,大约每 16.6667 毫秒调用一次这个方法。如果我们在这个方法内部实例化对象,可能会引起内存抖动,因为那些对象在View.onDraw()
方法中被实例化后很快不再使用,因此它们很快就会被回收。在某些情况下,这会导致每次在屏幕上绘制帧时执行一次或多次垃圾回收事件,根据回收事件的持续时间,可能会将可用于绘制的时间减少到 16.6667 毫秒以下。
引用
让我们快速了解一下 Java 提供的不同对象引用类型。通过这种方式,我们将了解何时可以使用它们,以及 Java 如何定义四种不同的引用强度:
-
普通引用:这是主要的引用类型。它对应于简单创建一个对象,当这个对象不再被使用和引用时,它将被回收,这就是传统的对象实例化方式:
SampleObject sampleObject = new SampleObject();
-
软引用:这是一种在垃圾回收事件触发时不足以将对象保留在内存中的引用,因此它可以在执行期间的任何时间变为 null。使用这种引用,垃圾收集器会根据系统的内存需求来决定何时释放对象内存。要使用它,只需创建一个
SoftReference
对象,在构造函数中传递实际对象作为参数,并调用SoftReference.get()
来获取对象:SoftReference<SampleObject> sampleObjectSoftRef = new SoftReference<SampleObject>(new SampleObject()); SampleObject sampleObject = sampleObjectSoftRef.get();
-
弱引用:这类似于
SoftReferences
,但强度更弱:WeakReference<SampleObject> sampleObjectWeakRef = new WeakReference<SampleObject>(new SampleObject());
-
Phantom:这是最弱的引用;对象符合终结条件。这种引用很少使用,
PhantomReference.get()
方法总是返回 null。这是针对我们不感兴趣的引用队列,但有用的是,我们也提供了这种类型的引用。
如果我们知道哪些对象的优先级较低,可以在不导致应用程序正常执行问题的前提下被收集,那么这些类在开发时可能很有用。我们将在接下来的页面中看到它们如何帮助我们管理内存。
内存侧项目
在 Android 平台的发展过程中,谷歌一直在努力改进平台的内存管理系统,以保持与性能不断提高的设备和低资源设备的广泛兼容性。这是谷歌并行开发两个项目的主要目的,然后,每个新发布的 Android 版本都意味着对这些项目的改进和变化,以及它们对系统性能的影响。这些侧项目中的每一个都关注不同的问题:
-
项目 Butter:在 Android Jelly Bean 4.1(API 级别 16)中引入,并在 Android Jelly Bean 4.2(API 级别 17)中改进;它增加了与平台图形方面的特性(VSync 和缓冲是主要的增加内容),以提高设备在使用时的响应性。
-
项目 Svelte:在 Android KitKat 4.4(API 级别 19)中引入,它处理内存管理的改进,以支持低 RAM 设备。
-
项目 Volta:在 Android Lollipop(API 级别 21)中引入,它关注设备的电池寿命。然后,它添加了重要的 API 来处理批处理耗电操作,例如 JobScheduler,或者新的工具,如 Battery Historian。
项目 Svelte 和 Android N
当项目 Svelte 首次引入时,它减少了内存占用并改进了内存管理,以支持内存可用性低的入门级设备,然后扩大了支持设备的范围,这对平台有明显的好处。
随着 Android N 的发布,谷歌希望提供一种优化方式来在后台运行应用程序。我们知道,即使应用程序在屏幕上看不到,也没有运行的活动,但由于服务可能正在执行某些操作,应用程序的进程仍然在后台运行。这是内存管理的一个关键特性:后台进程的内存管理不当可能会影响整个系统的性能。
但是在新的 Android N 中,应用程序行为和 API 有哪些变化呢?为了改善内存管理,减少后台进程影响的策略是避免为以下操作发送应用程序广播:
-
ConnectivityManager.CONNECTIVITY_ACTION
:从 Android N 开始,只有那些在前台并注册了此操作的BroadcastReceiver
的应用程序才能接收到新的连接动作。任何在清单文件中声明了隐式意图的应用程序将不再接收到它。因此,应用程序需要改变其逻辑以实现之前的相同功能。第六章,网络连接,讨论了这一点,所以请参考那一章以了解更多关于这个特定主题的信息。 -
Camera.ACTION_NEW_PICTURE
:这用于通知刚刚拍摄了一张照片并添加到媒体库中。此操作将不再可用,无论是接收还是发送,对于任何应用程序来说都是如此,不仅仅是那些针对新 Android N 的应用程序。 -
Camera.ACTION_NEW_VIDEO
:这用于通知刚刚拍摄了一段视频并添加到媒体库中。与之前的操作一样,此操作已不再可用,对于任何应用程序来说也是如此。
在针对新 Android N 的应用程序时,请记住这些更改,以避免不希望或意外的行为。
所有列出的操作都已被谷歌更改,以强制开发者不要在应用程序中使用它们。通常情况下,我们不应使用隐式接收者也是出于同样的原因。因此,我们应该始终检查应用程序在后台运行时的行为,因为这可能导致意外的内存使用和电池耗电。隐式接收者可以启动我们的应用程序组件,而显式接收者在活动在前台时设置的时间有限,之后它们就不能影响后台进程了。
提示
开发应用程序时避免使用隐式广播是一个好习惯,这样可以减少对后台操作的影响,可能导致不希望的记忆浪费,进而导致电池耗电。
此外,Android N 在 ADB 中引入了一个新命令,用于测试应用程序忽略后台进程的行为。使用以下命令忽略后台服务和进程:
adb shell cmd appops set RUN_IN_BACKGROUND ignore
使用以下命令来恢复初始状态:
adb shell cmd appops set RUN_IN_BACKGROUND allow
请参考第五章,多线程,了解进程如何在 Android 设备上工作。
最佳实践
现在我们知道了应用程序在活跃时内存中可能发生的情况,让我们看看我们能做些什么来避免内存泄漏和内存翻滚,并优化我们的内存管理,以便达到性能目标,不仅仅是内存使用,还有垃圾回收的参与,因为正如我们所知,它会阻止任何其他操作运行。
在接下来的页面中,我们将采用自下而上的策略,通过大量的提示和技巧,从 Java 代码的低级巧妙方法到 Android 实践的高级方法进行讲解。
数据类型
我们不是在开玩笑:我们真的是在谈论 Java 原始类型,因为它们是所有应用程序的基础,了解如何处理它们非常重要,尽管这可能很显然。事实并非如此,我们很快就会明白为什么。
Java 提供了在用时需要保存在内存中的原始类型:系统会分配与该特定类型请求的内存量相关的内存量。以下是 Java 原始类型及其相关位数的列表:
-
byte
:8 位 -
short
:16 位 -
int
:32 位 -
long
:64 位 -
float
:32 位 -
double
:64 位 -
boolean
:8 位,但取决于虚拟机 -
char
:16 位
乍一看,很明显,每次使用时,你应该小心选择正确的原始类型。
提示
如果你不需要,不要使用更大的原始类型:如果可以用整数表示数字,就不要使用long
、float
或double
。这将是一种浪费内存和计算,每次 CPU 需要处理它时。记住,为了计算一个表达式,系统需要对参与计算的最大原始类型进行拓宽的隐式转换。
自动装箱
“自动装箱”是指原始类型与其对应的包装类对象之间的自动转换。原始类型包装类如下:
-
java.lang.Byte
-
java.lang.Short
-
java.lang.Integer
-
java.lang.Long
-
java.lang.Float
-
java.lang.Double
-
java.lang.Boolean
-
java.lang.Character
可以使用赋值运算符实例化它们,就像原始类型一样,它们也可以被当作原始类型使用:
Integer i = 0;
这与以下内容完全相同:
Integer i = new Integer(0);
但使用自动装箱并不是提高应用程序性能的正确方法。它有很多相关成本:首先,包装对象比相应的原始类型大得多。例如,Integer
对象在内存中需要 16 字节,而原始类型只需要 16 位。因此,处理它需要更多的内存。然后,当我们使用包装对象声明变量时,对该变量的任何操作至少意味着另一个对象的分配。看看以下代码段:
Integer integer = 0;
integer++;
每个 Java 开发者都知道这是什么,但这段简单的代码需要逐步解释发生了什么:
-
首先,从
Integer
值integer
中取出整数值,并将其增加 1:int temp = integer.intValue() + 1;
-
然后,结果被赋值给整数,但这意味着需要执行一个新的自动装箱操作:
i = temp;
毫无疑问,这些操作比我们使用包装类而不是原始类型时要慢:不需要自动装箱,因此,不再有糟糕的分配。在循环中,情况可能会变得更糟,因为前面的操作在每个周期都会重复。例如,以下代码段:
Integer sum = 0;
for (int i = 0; i < 500; i++) {
sum += i;
}
在这种情况下,由于自动装箱导致了很多不适当的分配,如果我们将其与基本类型的for
循环进行比较,我们会注意到没有分配:
int sum = 0;
for (int i = 0; i < 500; i++) {
sum += i;
}
注意
应尽可能避免自动装箱:我们在应用执行时使用基本包装类代替基本类型的次数越多,浪费的内存就越多。这种浪费可能会在循环中使用自动装箱时传播,不仅影响内存,还影响 CPU 的定时。
稀疏数组家族
因此,在上一段中描述的所有情况下,我们可以使用基本类型代替对象对应物。然而,这并不总是那么简单。如果我们处理泛型时会发生什么?例如,考虑集合:我们无法将基本类型用作实现以下接口的对象的泛型。我们必须像这样使用包装类:
List<Integer> list;
Map<Integer, Object> map;
Set<Integer> set;
每当我们使用集合中的Integer
对象时,至少会发生一次自动装箱,产生前面概述的浪费。我们都知道在日常工作开发中处理这类对象的频率。但在这些情况下有没有办法避免自动装箱呢?Android 提供了一系列有用的对象,用于替换Map
对象并避免自动装箱,从而保护内存免受无谓的大分配:它们就是稀疏数组。
以下是稀疏数组列表,以及它们可以替换的相关类型的映射:
-
SparseBooleanArray: HashMap<Integer, Boolean>
-
SparseLongArray: HashMap<Integer, Long>
-
SparseIntArray: HashMap<Integer, Integer>
-
SparseArray<E>: HashMap<Integer, E>
-
LongSparseArray<E>: HashMap<Long, E>
在下一节中,我们将特别讨论SparseArray
对象,但我们对所有之前提到的对象所说的都是正确的。
SparseArray
对象使用两个不同的数组来存储散列和对象。第一个收集排序后的散列,而第二个根据图 1中的键散列数组排序存储键值对。
图 1:SparseArray 的散列结构
当你需要添加一个值时,你必须在SparseArray.put()
方法中指定整数键和要添加的值,就像在HashMap
中一样。如果多个键散列被添加到同一个位置,这可能会导致冲突。
需要值时,只需调用SparseArray.get()
,并指定相关键:在内部,键对象用于二分查找散列的索引,然后获取相关键的值,如图 2所示:
图 2:SparseArray 的工作流程
当二分搜索产生的索引中的键与原始键不匹配时,发生了碰撞,因此搜索会继续在两个方向上进行,以找到相同的键并提供值(如果它仍然在数组内)。因此,如果数组包含大量对象,找到值所需的时间将显著增加。
相比之下,HashMap
只包含一个数组来存储哈希、键和值,并且它使用大型数组作为一种避免碰撞的技术。这对于内存来说并不好,因为它分配的内存比实际需要的更多。所以HashMap
之所以快速,是因为它实现了一种更好的避免碰撞的方法,但它不是内存效率高的。相反,SparseArray
在内存使用上更高效,因为它使用正确数量的对象分配,执行时间的增加是可以接受的。
这些数组使用的内存是连续的,因此每次从SparseArray
中删除键/值对时,它们可以被压缩或调整大小:
-
压缩:要删除的对象被移到末尾,所有其他对象都向左移动。包含要删除项的最后一个块可以重新用于将来的添加,以节省分配。
-
调整大小:数组的所有元素都被复制到其他数组,旧的数组被删除。另一方面,添加新元素会产生与将所有元素复制到新数组相同的效果。这是最慢的方法,但它完全保证了内存安全,因为没有无用的内存分配。
通常,在进行这些操作时,HashMap
速度更快,因为它包含的块比实际需要的多,从而造成了内存浪费。
注意
使用SparseArray
系列对象取决于内存管理和 CPU 性能模式的策略,因为与内存节省相比,计算性能成本较高。因此,在某些情况下使用它是正确的。在以下情况下考虑使用它:
-
你正在处理的对象数量不到一千,并且你不会进行大量的添加和删除操作。
-
你正在使用包含少量项目但有很多迭代的地图集合
这些对象的另一个有用特性是,它们允许你遍历索引,而不是使用更慢且内存效率低下的迭代器模式。以下代码段显示了迭代不涉及对象:
// SparseArray
for (int i = 0; i < map.size(); i++) {
Object value = map.get(map.keyAt(i));
}
相反,需要Iterator
对象来遍历HashMap
:
// HashMap
for (Iterator iter = map.keySet().iterator(); iter.hasNext(); ) {
Object value = iter.next();
}
一些开发者认为HashMap
对象是更好的选择,因为它可以从 Android 应用程序导出到其他 Java 应用程序,而SparseArray
家族的对象则不能。但我们在这里分析的内存管理收益适用于任何其他情况。作为开发者,我们应该努力在每个平台上达到性能目标,而不是在不同平台上重复使用相同的代码,因为从内存的角度来看,不同的平台可能会受到不同的影响。这就是为什么我们主要的建议是始终在每个我们工作的平台上分析代码,并根据结果做出关于最佳和最差方法的个人判断。
ArrayMap
ArrayMap
对象是 Android 平台上对Map
接口的一种实现,它比HashMap
更节省内存。这个类从 Android KitKat(API 级别 19)开始提供,但在支持包 v4 中也有另一种实现,因为其主要在老旧和低端设备上使用。
它的实现和用法与SparseArray
对象类似,这涉及到内存使用和计算成本的所有含义,但其主要目的是允许你像HashMap
一样使用Objects
作为映射的键。因此,它提供了两者的最佳结合。
语法
有时,我们在日常的 Android 应用程序开发中对那些简单且常见的 Java 结构并不够小心。但我们确定那些基本的 Java 语法总是适合性能吗?让我们找出答案。
集合
在上一段中我们已经处理了集合。现在我们想要面对遍历集合的含义,以找出在集合内部迭代对象的最好选择,然后改善内存管理。让我们比较三种不同循环的时间结果:
-
Iterator
循环 -
while
循环 -
for
循环
我们使用了以下代码片段来比较它们的时间:
public class CyclesTest {
public void test() {
List list = createArray(LENGTH);
iteratorCycle(list);
whileCycle(list);
forCycle(list);
}
private void iteratorCycle(List<String> list) {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String stemp = iterator.next();
}
}
private void whileCycle(List<String> list) {
int j = 0;
while (j < list.size()) {
String stemp = (String) list.get(j);
j++;
}
}
private void forCycle(List<String> list) {
for (int i = 0; i < list.size(); i++) {
String stemp = (String) list.get(i);
}
}
private List createArray(int length) {
String sArray[] = new String[length];
for (int i = 0; i < length; i++)
sArray[i] = "Array " + i;
return Arrays.asList(sArray);
}
}
我们使用不同数量的列表项测试了循环的性能十次,并平均了这些测量结果。《图 3》展示了这些测量的结果。
图 3:循环测量内存统计结果
结果可能会受到许多不同因素的影响:内存、CPU、设备上运行的应用程序等等。但我们感兴趣的是找到这些循环的平均性能。从图表中显而易见的是,Iterator
循环类型是最慢的,而for
循环在我们的测量中始终是最快的。
那么,创建for
循环只有一种方法吗?不,有多种选择。让我们看看它们:
private void classicCycle(Dummy[] dummies) {
int sum = 0;
for (int i = 0; i < dummies.length; ++i) {
sum += dummies[i].dummy;
}
}
private void fasterCycle(Dummy[] dummies) {
int sum = 0;
int len = dummies.length;
for (int i = 0; i < len; ++i) {
sum += dummies[i].dummy;
}
}
private void enhancedCycle(Dummy[] dummies) {
int sum = 0;
for (Dummy a : dummies) {
sum += a.dummy;
}
}
第一个案例是最慢的,因为在每个周期中都需要进行数组长度计算,这增加了额外的成本,因为即时编译每次都需要转换它。第二个案例通过只计算一次长度来避免这个成本,而最后一个案例是 Java 5 引入的增强的for
循环语法,这是使用for
循环进行索引的最快方式。
注意
增强的for
循环语法是遍历数组的最快方式,即使设备具有即时编译,因此每次处理数组迭代时都应考虑使用它,并尽可能避免使用iterator
对象的迭代,因为这是最慢的。
枚举
枚举对开发者来说非常方便:有限数量的元素、描述性的名称,从而提高了代码的可读性。它们还支持多态。因此,它们在我们的代码中被广泛使用。但从性能角度来看,它们真的好吗?枚举的主要替代品是声明公开可访问且静态的整数。例如,让我们看一下以下代码段:
public enum SHAPE {
RECTANGLE,
TRIANGLE,
SQUARE,
CIRCLE
}
这可以被以下代码替换:
public class SHAPE {
public static final int RECTANGLE = 0;
public static final int TRIANGLE = 1;
public static final int SQUARE = 2;
public static final int CIRCLE = 3;
}
现在,从内存的角度来看,哪一个成本更高?这个问题的答案有两方面:我们可以检查为我们的应用程序生成的 DEX 大小,这会影响在执行时使用枚举或整数值时的堆内存使用情况。
我们的示例枚举被转换成四个对象分配,其中String
表示名称,integer
值作为序号,以及数组和一个包装类。相比之下,类的实现较轻量,因为它只需分配四个整数值,从而在内存上节省了大量空间。
更糟糕的是,枚举需要在应用程序使用的每个进程中复制,因此在多进程应用程序中,其成本会增加。
对于枚举的经典用法,需要使用switch...case
语句,所以让我们使用我们的枚举来查看它:
public void calculateSurface(SHAPE shape) {
switch (shape) {
case RECTANGLE:
//calculate rectangle surface
break;
case TRIANGLE:
//calculate triangle surface
break;
case SQUARE:
//calculate square surface
break;
case CIRCLE:
//calculate circle surface
break;
}
}
现在,让我们使用整数值更改之前的代码:
public void calculateSurface(int shape) {
switch (shape) {
case RECTANGLE:
//calculate rectangle surface
break;
case TRIANGLE:
//calculate triangle surface
break;
case SQUARE:
//calculate square surface
break;
case CIRCLE:
//calculate circle surface
break;
}
}
这种代码更改非常简单。因此,我们应该考虑计划重新格式化我们的代码,以减少或移除使用的枚举,这是基于我们之前的推理。
安卓提供了一个有用的注解,以简化从枚举到整数值的过渡:@IntDef
。这个注解可以用来通过以下方式使用flag
属性启用多个常量:
@IntDef(flag = true,
value = {VALUE1, VALUE2, VALUE3})
public @interface MODE {
}
这个注解表示可能的值是注解内部指定的那些值。例如,让我们将整数值更改为使用注解,并将这些值转换为类似枚举的东西,同时避免所有内存性能问题:
public static final int RECTANGLE = 0;
public static final int TRIANGLE = 1;
public static final int SQUARE = 2;
public static final int CIRCLE = 3;
@IntDef({RECTANGLE, TRIANGLE, SQUARE, CIRCLE})
public @interface Shape {
}
现在,要在我们的代码中使用它,只需在你期望有Shape
值的地方指定新的注解:
public abstract void setShape(@Shape int mode);
@Shape
public abstract int getShape();
提示
枚举由于它们不必要的分配,影响整体内存性能。因此,尽量避免使用它们,尽可能用 static final 整数替换。然后创建自己的注解,像使用枚举一样使用这些整数值,以限制值的数量。
在某些情况下,你可能无法移除枚举。然而,可以通过增强 Proguard 来减少枚举对应用程序内存性能的影响。参考第十章,性能技巧,了解更多关于这个话题的信息。
常量
通常,我们需要一个与类特定实例无关的变量,但它被整个应用程序使用。是的,我们说的是静态变量。它们在许多情况下都很有用。但系统是如何管理它们的呢?这背后有什么内存影响?让我们退一步,谈谈编译器在执行期间如何处理静态变量。Java 编译器中有一个特殊的方法叫做<clinit>
。顾名思义,它处理类的初始化,但它只用于变量和静态代码块,并按照它们在类中的顺序进行初始化。它从类的超类和接口开始执行,一直到类本身。因此,我们的静态变量在应用程序启动时就被初始化了。
如果静态变量也是 final 的,那就另当别论了:在这种情况下,它们不是由<clinit>
方法初始化的,而是存储在 DEX 文件中,具有双重好处。它们既不需要更多的内存分配,也不需要分配内存的操作。这只适用于基本类型和字符串常量,所以对于对象来说没有必要这样做。
提示
代码中的常量应该是 static 和 final 的,以便利用内存节省,并避免在 Java 编译器的<clinit>
方法中进行初始化。
对象管理
让我们探讨一个更高阶的 Java 话题,涵盖正确管理对象和一些避免内存陷阱的做法。
让我们从一些看似平凡却并不简单的事情开始:注意不要实例化不必要的对象。我们对此从不厌倦重复。内存分配是昂贵的,同样,释放内存也是:系统为其分配内存,垃圾收集的界限会更快达到,众所周知,这将从内存可用性到用户体验的延迟,整体降低应用程序的性能。
提示
每个开发者都应该知道并完成代码中清理不必要对象的任务。这方面没有绝对的规定:只要记住,几个有用的对象比大量很少使用的对象在内存上更安全。
尽量创建较少的临时对象,因为它们经常被垃圾收集,避免实例化不必要的对象,因为它们对内存和计算性能来说是昂贵的。
以下几页内容提供了简单实践,以尽可能限制我们应用程序的内存消耗,避免出现延迟。接下来几段,我们将讨论 Java 的对象管理技术,稍后我们会介绍与 Android 相关的方法论。不过,这些内容与 Android 开发者的常见情况有关。
字符串
String
对象是不可变的。以这种方式实例化字符串,你将强制分配两个不同的对象:
String string = new String("example");
这两个对象如下所示:
-
String
"example"
本身就是一个对象,其内存必须被分配 -
新的
String string
因此,另一种初始化String
对象的方式对内存性能来说更为合适:
String string = "example";
字符串拼接
通常,我们在操作字符串时,不会考虑内存使用后果。有人可能会认为,当我们需要拼接两个或更多字符串时,以下代码片段对内存性能是有好处的,因为它没有使用更多的对象分配:
String string = "This is ";
string += "a string";
然而,对于这类操作,StringBuffer
和StringBuilder
比String
类更有效率,因为它们是基于字符数组工作的。因此,为了更好的执行效率,前面的代码片段应该改为如下形式:
StringBuffer stringBuffer = new StringBuffer("This is ");
stringBuffer.append("a string");
如果你经常进行字符串拼接操作,这种方式是可取的,但也可以作为一项始终遵循的好习惯,因为与字符串拼接相比,StringBuffer
和StringBuilder
的效率更高。记住StringBuffer
和StringBuilder
之间的区别:前者是线程安全的,因此速度较慢,但可以在多线程环境中使用;而StringBuilder
不是线程安全的,因此速度更快,但只能在单线程中使用。
另外需要注意的是,StringBuilder
和StringBuffer
的初始容量都是 16 个字符,当它们因容量满而需要增加时,会实例化并分配一个容量加倍的新对象,而旧对象则等待下一次垃圾回收。为了避免这种不必要的内存浪费,如果你知道自己要处理的字符串容量的估计值,可以通过指定不同的初始容量来实例化StringBuffer
或StringBuilder
:
StringBuffer stringBuffer = new StringBuffer(64);
stringBuffer.append("This is ");
stringBuffer.append("a string");
stringBuffer.append…
这样,如果字符串容量低于 64 个字符,就不需要重新创建对象,且在它不再被引用之前不会被回收。
局部变量
查看我们的代码,有时我们会注意到,在方法的整个执行过程中,一个对象没有被修改就被使用了。这意味着它可以被移出方法外部,这样它只需分配一次且不会被回收,从而改善内存管理。例如,下面的代码就建议这样做:
public String format(Date date) {
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM- dd'T'HH:mm:ss.SSSZ");
return dateFormat.format(date);
}
在此情况下,DateFormat
对象无需在每次方法执行时都进行实例化。此外,每次都会分配一个新对象,并且在垃圾收集器达到限制之前不会被收集,这期间会不必要的占用内存。将这个对象从方法中提取出来,并使其可以从外部访问,这样它只需实例化一次,并且在class
对象的生命周期内都可以使用,这将好得多。整体性能的提升将来自于在需要DateFormat.format()
方法调用的多个地方重用一个单一对象。然后,可以使用以下解决方案:
private DateFormat dateFormat = new SimpleDateFormat("yyyy-MM- dd'T'HH:mm:ss.SSSZ");
public String format(Date date) {
return dateFormat.format(date);
}
通常,有许多不同的场合需要处理可以提取的局部变量,并且有许多不同的解决方案:由你决定哪个最适合你的代码。
数组与集合
集合可以根据需要自动扩大或缩小,并提供许多有用的方法来添加、移除、获取、更改和移动对象,以及其他很酷的功能。这是有高昂代价的。如果你处理的对象数量是固定的,原始数组比集合在内存上更有效率。bigocheatsheet.com
网站对数组和集合之间的成本比较进行了更深入的分析。为此,使用了大 O 表示法:它描述了算法与数组/集合元素数量增长的趋势。
流
在处理 Java 的 I/O 流对象时,一个常见的错误是没有适当地释放和回收它们,或者根本不释放,这显然会导致内存泄漏。请记住,每次都要释放它们,因为这个错误可能会影响整体性能。让我们看看以下示例代码:
InputStream is = null;
OutputStream os = null;
try {
is = new FileInputStream("../inputFile.txt");
os = new FileOutputStream("../outputFile.txt");
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (is != null)
is.close();
if (os != null)
os.close();
} catch (IOException e) {
}
}
前述的释放代码是错误的。许多开发者使用它,但仍然存在内存泄漏的源头。如果在关闭InputStream
时抛出异常,OutputStream
将不会被关闭并且仍然被引用,导致前面提到的内存泄漏。以下代码段展示了如何正确处理它:
InputStream is = null;
OutputStream os = null;
try {
is = new FileInputStream("../inputFile.txt");
os = new FileOutputStream("../outputFile.txt");
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (is != null)
is.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (os != null)
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
通常,你应该在try...catch
语句中使用finally
关键字来释放资源和内存,并且分别关闭每个可关闭的对象。
内存模式
在本节中,我们将看看一些有用的设计模式,如果妥善处理,它们可以减少内存抖动(churn)的风险,或者限制用于对象的内存。它们的目的是减少如果大量使用对象时的内存分配。它们也减少了垃圾收集器的调用。是否使用它们取决于具体情况、需求和开发者的专业知识。它们可能非常有用,但如果你使用它们,你一定要注意可能引入的内存泄漏,这可能会抵消它们使用的效果。
对象池模式
在创建型设计模式中,对象池模式对于重用已分配的对象非常有帮助,从而避免内存抖动及其可能对应用程序性能造成的影响。当我们处理昂贵的创建对象并且需要创建很多这样的对象时,它特别有用。
这背后的想法是为了避免对将来可能需要重用的对象进行垃圾回收,以及节省创建它的时间。为此,一个名为ObjectPool
的对象管理许多可重用对象,使它们对请求者可用。这些请求对象被称为客户端。因此,这个模式处理三种类型的对象:
-
可重用对象
:这些是可供客户端使用并由池管理的对象。 -
客户端
:这是需要一个可重用对象来完成某些操作的对象,因此它必须向池请求,并在操作完成后返回它。 -
对象池
:这保存了每个可重用对象,以便提供和回收每一个。
对象池
应该是单例对象,以便集中管理所有可重用对象,避免不同池之间的混乱交换,并确保每个可重用对象的创建都有正确且一致的政策方法。
池可以对其包含的对象数量设置上限。这意味着,如果客户端请求一个可重用对象而池已满且没有空闲的可重用对象,服务请求将被延迟,直到另一个对象从另一个客户端释放出来。图 4展示了一个流程图,解释了当客户端需要一个对象时会发生什么:
图 4:对象池流程图
暂停一下查看图表,我们可以看到每个客户端在不再需要时立即返回对象是多么重要:当达到限制时,池无法创建新的可重用对象,客户端将无限期等待,阻塞所有执行。因此,我们需要确保每个客户端都有这种行为。从客户端的角度来看,使用池只是通过添加返回已使用对象的这一特定行为来改变其行为。同时,它还需要意识到有时候池无法返回对象,因为那一刻没有任何对象可用:这时,它需要处理这种特定异常情况。
另外需要注意的一点是,刚刚使用过的对象在传递给另一个请求的客户端之前,应该恢复到一个特定的稳定状态,以保持对象的清洁管理:客户端不知道获取到的对象已经被另一个客户端使用过,不能以可能导致意外行为的状态接收对象。这也可能导致内存泄漏,如果可重用对象引用了在客户端释放后仍然被引用的其他对象。因此,在大多数情况下,可重用对象应该被恢复到就像刚刚创建时的状态。
如果这种模式需要在多线程环境中使用,那么必须以线程安全的方式进行实现,以避免对池中对象的并发修改。
当首次使用对象池时,它是空的,每次客户端需要一个可重用对象时,都会从头开始创建。因此,对于新创建的对象,在分配上会有延迟。在某些情况下,如果这符合你的策略,可以在创建池的时候预先分配一定数量的对象,以节省未来访问的时间。
让我们快速了解一下这种模式的简单代码实现。以下是ObjectPool
的代码:
public abstract class ObjectPool<T> {
private SparseArray<T> freePool;
private SparseArray<T> lentPool;
private int maxCapacity;
public ObjectPool(int initialCapacity, int maxCapacity) {
initialize(initialCapacity);
this.maxCapacity = maxCapacity;
}
public ObjectPool(int maxCapacity) {
this(maxCapacity / 2, maxCapacity);
}
public T acquire() {
T t = null;
synchronized (freePool) {
int freeSize = freePool.size();
for (int i = 0; i < freeSize; i++) {
int key = freePool.keyAt(i);
t = freePool.get(key);
if (t != null) {
this.lentPool.put(key, t);
this.freePool.remove(key);
return t;
}
}
if (t == null && lentPool.size() + freeSize < maxCapacity) {
t = create();
lentPool.put(lentPool.size() + freeSize, t);
}
}
return t;
}
public void release(T t) {
if (t == null) {
return;
}
int key = lentPool.indexOfValue(t);
restore(t);
this.freePool.put(key, t);
this.lentPool.remove(key);
}
protected abstract T create();
protected void restore(T t) {
}
private void initialize(final int initialCapacity) {
lentPool = new SparseArray<>();
freePool = new SparseArray<>();
for (int i = 0; i < initialCapacity; i++) {
freePool.put(i, create());
}
}
}
我们使用了两个稀疏数组来保存对象集合,并防止这些对象在借出时被回收。我们为池定义了初始容量和最大容量:这样,如果有太多的请求需要处理,可以创建新对象直到达到最大容量或满足所有请求。我们将对象的创建委托给具体类或直接实现,以使其具有更大的灵活性。两个公共方法是ObjectPool.acquire()
和ObjectPool.release()
:客户端可以使用它们来请求预先分配的对象,并将对象返回给池。
Apache Commons 内部有一个ObjectPool
接口,其中包含一些有用的实现。这个类为客户端使用的方法使用了不同的名称:它们是ObjectPool.borrowObject()
和ObjectPool.returnObject()
,并且增加了一个特殊的方法ObjectPool.close()
,在完成使用后释放池的内存。
也许不是每个人都了解这种模式,但在日常开发中它被广泛使用:AsyncTask
工作线程的执行和RecyclerView
的回收视图都是这种模式使用的例子。这并不意味着我们应在任何情况下都使用它。由于其陷阱,应该谨慎使用,但在某些情况下它确实非常有帮助。
注意
当我们的代码需要分配很多昂贵的实例对象时,我们可以使用ObjectPool
来限制垃圾回收并避免内存波动。在所有其他情况下,经典的垃圾回收足以处理我们对象的生命周期。如果我们决定使用这种模式,我们需要谨慎使用,因为我们有责任从客户端释放每个对象,并恢复重用对象的起始状态以避免内存泄漏。如果是在多线程环境中,我们也需要确保以线程安全的方式进行。
FlyWeight 模式。
许多开发者将对象池模式与 FlyWeight 模式混淆,但它们有不同的范围:对象池的目标是减少在有很多非常昂贵的对象的环境中分配和垃圾回收的影响,而 FlyWeight 模式的目标是通过保存所有对象共享的状态来减少内存负载。因此,我们将考虑客户端请求的对象的两种状态:
-
内部状态:这是由标识对象的字段组成,并且不与其他对象共享。
-
外部状态:这是在所有交换对象之间共享的字段集合。
所以,FlyWeight 模式所做的就是通过为所有对象创建一个实例来重用它们的内部状态,从而节省了复制它的成本。
图 5展示了这种模式的流程图:
图 5:FlyWeight 模式的流程图
在这个模式中,有三个参与者:
-
FlyWeightObjects
:它们可以改变内部状态并访问内部对象。 -
FlyWeightFactory
:当客户端请求时,它创建FlyWeightObjects
,并管理它们的内部状态。它还可以负责存储一个FlyWeightObject
池,以便借给客户端使用。 -
Clients
:它们请求FlyWeightObjects
并可以改变它们的内部状态。
然后,有一个FlyWeightObjects
池,但这次没有借用。当不再引用FlyWeight
对象时,与FlyWeight
对象相关的内存将被垃圾回收释放,就像在经典的 Java 案例中一样。
让我们看看这个模式的代码。我们需要一个接口来定义FlyWeightObjects
的方法:
public interface Courier<T> {
void equip(T param);
}
然后,我们需要至少实现一次我们的接口:
public class PackCourier implements Courier<Pack> {
private Van van;
public PackCourier(int id) {
super(id);
van = new Van(id);
}
public void equip(Pack pack) {
van.load(pack);
}
}
这次的客户端是一个对象,它将接口的实现作为其状态的一部分:
public class Delivery extends Id {
private Courier<Pack> courier;
public Delivery(int id) {
super(id);
courier = new Factory().getCourier(0);
}
public void deliver(Pack pack, Destination destination) {
courier.equip(pack);
}
}
如你所见,Delivery
向Factory
请求Courier
并加入了对象状态。但让我们看看Factory
:
public class Factory {
private static SparseArray<Courier> pool;
public Factory() {
if (pool == null)
pool = new SparseArray<>();
}
public Courier getCourier(int type) {
Courier courier = pool.get(type);
if (courier == null) {
courier = create(type);
pool.put(type, courier);
}
return courier;
}
private Courier create(int type) {
Courier courier = null;
switch (type) {
case 0:
courier = new PackCourier(0);
}
return courier;
}
}
Factory
持有已定义的快递员稀疏数组。请注意,每种类型的实例不会超过一个。然后每次创建新的Delivery
时,Factory
会为它提供相同的Courier
对象。因此,它将被共享,在这种情况下,每个Delivery
都将由同一个Courier
完成,如下面的代码段所示:
for (int i = 0; i < DEFAULT_COURIER_NUMBER; i++) {
new Delivery(i).deliver(new Pack(i), new Destination(i));
}
安卓组件泄漏
在下一节中,我们将关注一些特别讨厌的内存泄漏,而我们经常没有意识到它们。在处理主要组件时,内存泄漏对应用程序的整体性能有着重要影响:如果我们了解如何避免它们,并且对这些细节非常小心,我们将看到应用程序响应性的显著提高。
活动
活动是 Android 应用程序中最常用的组件,并且是唯一具有用户界面的组件。活动和每个包含的视图之间存在强引用。这使得它们特别容易受到内存泄漏的影响。
活动相关的内存泄漏有很多种,让我们一一对付它们,记住我们必须避免所有这些情况,以使我们的应用程序有一个快速的环境。
当不再有引用时,保留活动在内存中是非常昂贵的。它引用了很多其他对象,如果活动本身不能被回收,这些对象也不能被回收。此外,活动在应用程序的生命周期中可能会被销毁和重新创建多次,这可能是由于配置更改或内存回收。如果活动被泄漏,它的每个实例可能会无限期地存储在内存中,这对内存的影响是极其昂贵的。因此,这是我们在代码中可能犯的最严重的错误:永远不要泄漏活动。但活动是如何被泄漏的呢?你会惊讶于这是多么容易。请记住,当特定事件发生时,系统会为你销毁和创建活动,比如配置更改。在了解如何避免常见错误之前,先来看一些简单的提示:
提示
寻找内存泄漏要比找出其原因容易得多。但它们大多数都与静态类有关,既有带有活动依赖的静态字段,也有单例模式。当你寻找活动的内存泄漏时,首先检查静态字段是否对活动本身有引用。然后,如果这还不够,找出你在活动代码中所有使用this
关键字的地方,因为实例可以用不同的方式使用,可能会对生命周期更长的对象的强引用。
为了避免活动泄漏,通常的一个规则是,当我们不需要特定的活动方法时,可以通过调用Context.getApplicationContext()
方法来使用应用上下文而不是活动本身:这使用的是一个肯定在应用程序结束前不需要被回收的对象,因为它就是应用程序本身。
静态字段
静态字段真的很危险:它们可以引用活动或/和其他对象,导致我们大多数的内存问题。众所周知,静态对象的寿命与应用程序的寿命相匹配,这意味着它只有在最后才能被回收。例如,如果我们在代码中声明一个静态View
,只要它不为 null,它就会泄漏其活动,因为每个视图都持有对其自身活动的引用。以下代码显示了一个典型的情况:
public class MainActivity extends Activity {
private static View view;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
view = findViewById(R.id.textView);
}
}
当调用Activity.setContentView()
方法时,布局 XML 文件中的每个View
都使用Activity
类作为Context
的引用来实例化。看看它的构造函数:
public View(Context context) {
super(context);
}
public View(Context context, AttributeSet attrs) {
super(context, attrs);
}
public View(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
无论View
是如何实例化的:它都需要引用Activity
类,因此如果将View
声明为static
字段,就会发生内存泄漏。这不仅仅与视图有关,任何引用Activity
的对象都可能发生这种情况。此外,这可以扩展到被视图引用的对象:背景Drawable
强引用它的View
,而View
又强引用Activity
。这意味着以下代码与之前的代码有同样的副作用,即使这次View
是非静态的,活动泄漏仍然会发生:
public class MainActivity extends Activity {
private static Drawable drawable;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
View view = findViewById(R.id.textView);
view.setBackground(drawable);
}
}
有人可能会认为,在活动生命周期即将结束时,例如在Activity.onStop()
或者在Activity.onDestroy()
回调中,将视图设置为 null 可以更容易地解决这个问题,但这可能导致如果创建时的实例化处理不当,会引发NullPointerException
,使得这个解决方案变得危险。简单来说,避免使用静态变量以避免前面提到的内存泄漏。
非静态内部类
非静态内部类在 Android 中被广泛使用,因为它们允许我们访问外部类的字段,而无需直接传递其引用。然后,很多时候 Android 开发者为了节省时间,不考虑对内存性能的影响而添加内部类。让我们创建一个内部类来说明在这种情况下会发生什么:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
new MyAsyncTask().execute();
}
private class MyAsyncTask extends AsyncTask {
@Override
protected Object doInBackground(Object[] params) {
return doSomeStuff();
}
private Object doSomeStuff() {
//do something to get result
return new Object();
}
}
}
一个简单的AsyncTask
在Activity
启动时创建并执行。但内部类需要在其整个生命周期内访问外部类,因此每次Activity
被销毁,但AsyncTask
仍在工作时,都会发生内存泄漏。这不仅仅是在调用Activity.finish()
方法时发生,即使Activity
由于配置更改或内存需求被系统强制销毁后再次创建时也会发生。AsyncTask
持有对每个Activity
的引用,在它被销毁时使其不能被垃圾回收。
考虑一下如果用户在任务运行时旋转设备会发生什么:整个Activity
实例需要一直可用,直到AsyncTask
完成。此外,大多数时候我们希望AsyncTask
通过AsyncTask.onPostExecute()
方法将结果显示在屏幕上。这可能导致崩溃,因为当任务仍在运行时Activity
被销毁,视图引用可能为空。
那么这个问题的解决方案是什么呢?如果我们把内部类设置为static
,我们就无法访问外部类,因此我们需要提供对该外部类的引用。为了增加两个实例之间的分离,并让垃圾收集器正确处理Activity
,我们使用弱引用来实现更干净的内存管理。之前的代码改为如下形式:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
new MyAsyncTask(this).execute();
}
private static class MyAsyncTask extends AsyncTask {
private WeakReference<MainActivity> mainActivity;
public MyAsyncTask(MainActivity mainActivity) {
this.mainActivity = new WeakReference<>(mainActivity);
}
@Override
protected Object doInBackground(Object[] params) {
return doSomeStuff();
}
private Object doSomeStuff() {
//do something to get result
return new Object();
}
@Override
protected void onPostExecute(Object o) {
super.onPostExecute(o);
if (mainActivity.get() != null){
//adapt contents
}
}
}
}
这样,类被分离,一旦不再使用Activity
就可以立即回收,AsyncTask
对象在WeakReference
对象中找不到Activity
实例,也就不会执行AsyncTask.onPostExecute()
方法的代码。
我们在示例中使用了AsyncTask
,但我们可以在Activity.onDestroy()
方法中取消它,但这只是使用非静态内部类可能发生的情况的一个例子。例如,以下代码将因内部类非静态且对MainActivity
持有强引用而导致同样的问题:
public class MainActivity extends Activity {
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
new MyTask(this).run();
}
private class MyTask {
private MainActivity mainActivity;
public MyAsyncTask(MainActivity mainActivityOld) {
this.mainActivity = mainActivityOld;
}
protected void run() {
new Thread(new Runnable() {
@Override
public void run() {
try {
wait(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
mainActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
textView.setText("Done!");
}
});
}
}).run();
}
}
}
作为一种通用良好的实践,当处理线程时,即使线程不是一个内部类,也请使用比Activity
更弱的引用。
单例模式
众所周知,singleton
是在整个应用程序生命周期内只能实例化一次的对象。这有助于避免数据重复,与代码中的多个对象共享数据,以及全局访问。但是,我们需要注意singleton
所引用的内容,因为它的生命周期很长。如果在singleton
中使用Activity
的引用并且不释放它,那么它将在应用程序结束时泄漏。这可以应用于任何其他类型的对象,但众所周知,Activity
泄漏特别可怕,我们想先关注这个问题。
让我们看一下以下代码,它代表了一个带有接口的Singleton
类:
public class Singleton {
private static Singleton singleton;
private Callback callback;
public static Singleton getInstance() {
if (singleton == null)
singleton = new Singleton();
return singleton;
}
public Callback getCallback() {
return callback;
}
public void setCallback(Callback callback) {
this.callback = callback;
}
public interface Callback {
void callback();
}
}
现在,让我们看看Activity
的代码:
public class MainActivity extends Activity implements Singleton.Callback {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Singleton.getInstance().setCallback(this);
}
@Override
public void callback() {
//doSomething
}
}
在这种情况下,Singleton
对象将持有对MainActivity
的引用,直到它被销毁,然后直到应用程序被销毁。在这种情况下,当需要释放MainActivity
时,移除引用非常重要。然后,之前的MainActivity
代码可以改为如下形式:
public class MainActivity extends Activity implements Singleton.Callback {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Singleton.getInstance().setCallback(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
Singleton.getInstance().setCallback(null);
}
@Override
public void callback() {
//doSomething
}
}
否则,我们可以采用上一个示例中的同样解决方案:如果在singleton
中的回调使用WeakReference
,那么在需要时可以回收Activity
。这个解决方案将代码改为如下形式:
public class Singleton {
private static Singleton singleton;
private WeakReference<Callback> callback;
public static Singleton getInstance() {
if (singleton == null)
singleton = new Singleton();
return singleton;
}
public Callback getCallback() {
return callback.get();
}
public void setCallback(Callback callback) {
this.callback = new WeakReference<Callback>(callback);
}
public interface Callback {
void callback();
}
}
匿名内部类
类或接口在类中的特化遇到了与非静态内部类和单例情况相同的问题:匿名内部类需要保存外部类,然后会泄露它。让我们看看以下代码段:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Singleton.getInstance().setCallback(new Singleton.Callback() {
@Override
public void callback() {
//doSomething
}
});
}
@Override
protected void onDestroy() {
super.onDestroy();
}
}
这段代码与之前的单例示例相同,但Activity
没有实现Callback
接口,而是作为匿名内部类实例化。正如所提到的,这仍然是一个问题,之前讨论的解决方案仍然有效。
处理器(Handlers)
与迄今为止讨论的所有泄露相关的一个问题是Handler
泄露。这很隐蔽,因为不是那么明显。幸运的是,Lint 检查会对此发出警告。所以,检查你的代码来找出这个问题。Handler
对象可以使用Handler.postDelayed()
方法执行延迟代码,这就是问题所在。看看以下代码段:
public class MainActivity extends Activity {
private Handler handler = new Handler();
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
textView = (TextView) findViewById(R.id.textView);
handler.postDelayed(new Runnable() {
@Override
public void run() {
textView.setText("Done!");
}
}, 10000);
}
}
Handler
对象将其Runnable
接口发送给LooperThread
直到执行完毕。我们知道,匿名内部类持有对外部类的引用,在我们的例子中就是Activity
,因此会导致活动泄露。但LooperThread
有一个消息队列用来执行Runnable
。即使我们的 Handler 没有发送延迟消息,仅仅是因为需要更改 UI(我们知道只有主线程才能执行这些更改,因此我们使用Handler
对象在主线程上执行它们),如果队列很大,也可能会发生内存泄露。因此,像匿名内部类一样,我们将这个类导出为static
,并将对TextView
的引用传递进去,因为它是static
的,所以无法再访问它了:
public class MainActivity extends Activity {
private Handler handler = new Handler();
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handler.postDelayed(new MyRunnable(textView), 10000);
}
private static class MyRunnable implements Runnable {
private TextView textView;
public MyRunnable(TextView textView) {
this.textView = textView;
}
@Override
public void run() {
textView.setText("Done!");
}
}
}
我们摆脱泄露了吗?不幸的是,没有。TextView
仍然持有对容器Activity
的引用,因为它是视图并且仍然被引用。因此,让我们对内部类使用找到的第二个解决方案,使用WeakReference
来存储TextView
:
public class MainActivity extends Activity {
private Handler handler = new Handler();
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handler.postDelayed(new MyRunnable(textView), 10000);
}
private static class MyRunnable implements Runnable {
private WeakReference<TextView> textViewRef;
public MyRunnable(TextView textView) {
this.textViewRef = new WeakReference<TextView>(textView);
}
@Override
public void run() {
if (textViewRef.get() != null)
textViewRef.get().setText("Done!");
}
}
}
这样,当需要时,活动可以被正确地回收,不会发生泄露。但对于这段代码还有一个改进点:可能有助于从队列中移除每条消息。这样,我们可以确保队列被清理,Activity
可以被销毁,当Activity
不再可用时,Runnable
对象中的代码不会被执行:
@Override
protected void onDestroy() {
super.onDestroy();
handler.removeCallbacksAndMessages(null);
}
服务(Services)
服务在第五章 多线程 中有深入讨论,但我们要了解服务在应用程序正常生命周期中如何影响内存性能。系统使用具有最近最少使用(LRU)模式的缓存来存储活动进程,这意味着它可以强制关闭之前使用的进程,保留最新的进程。那么,每次我们保持一个不再使用的服务活动时,我们不仅与服务产生了内存泄漏,还阻止了系统清理堆栈以插入新进程。因此,适当关注刚完成后台工作服务的关闭和释放非常重要。
正如我们将在下一章看到的,如果内部调用可以使用Service.stopSelf()
停止服务,如果外部调用可以使用Context.stopService()
。每次不再工作时都必须这样做,因为Service
对象不会自行结束。但为了改善我们应用程序的内存和进程管理,我们应该尽可能使用IntentService
而不是简单的Service
,因为这种类型的服务在后台工作完成后会自动结束。
提示
尽可能使用IntentService
,因为它能自动结束并避免因服务产生的内存泄漏。这是我们能犯的最糟糕的内存错误之一。如果你不能使用IntentService
,请确保Service
在完成任务后立即结束。
进程
一些应用程序使用特殊技术通过不同的进程分离内存负载。正如我们将在第五章 多线程 中看到的,Android 中的每个组件默认都在主进程中执行,但通过在清单文件中为每个希望单独执行的组件定义进程名称,它们也可以在单独的进程中执行。
<service
android:name=".MainService"
android:process=":MainService"></service>
这样做有利于代码剖析,因为你可以在不影响其他进程的情况下分析单个进程。此外,它简化了 Android 系统进程管理。但我们必须注意适当管理内存,否则我们可能会产生相反的效果,不仅没有减少内存分配,反而增加了它。因此,创建多进程应用程序的一些简单建议如下:
-
每个进程中的常见实现都是重复的,因此尽量减少它们。进程之间的分离应该是清晰的,共同的对象应尽可能减少。
-
UI 应由一个进程处理,因为为其分配的内存取决于许多因素,如位图和资源分配。无论如何,应用程序一次只能显示一个活动。
-
进程间的关系非常重要,因为如果一个进程依赖于另一个进程,系统就无法删除它。这意味着我们需要注意使用那些可以访问更多进程的组件,因为在这种情况下,内存性能的优势会被抵消。因此,在使用诸如
ContentProvider
和Service
这类可能被多个进程访问的组件时要特别小心。分析这种情况下的影响,以改进解决方案的架构。
内存 API
如果我们的应用程序处于低内存状态,我们该怎么办?如果我们的应用程序需要分配过多内存又该如何?让我们看看平台提供的内容是否真的有帮助。
不同的设备意味着分配内存的不同 RAM 量。那么,我们的应用程序将必须响应这一特定要求。Android 提供了一种特定方式,允许我们在应用程序中请求大堆内存。这可以通过在清单文件的application
节点中添加属性来实现,如下例所示:
<application
…
android:largeHeap="true">
…
</application>
但是,这一大块内存是针对应用程序创建的每个进程请求的。这仅仅是向系统提出的一个请求,我们不确定我们的进程是否会比正常情况下有更大的堆内存。请记住,如果我们无法在应用程序中进行自由的内存管理,或者面临OutOfMemoryError
,则不应使用此功能。如果你遇到这样的错误,那么请分析你的代码,捕捉任何可能的内存异常,并减少内存泄漏。只有少数应用程序应该能够请求大堆内存:那些对内存有极端正当需求的应用程序。一般来说,它们是处理高级照片、视频和多媒体编辑的应用程序。然后这个技巧可能避免OutOfMemoryError
,但也可能产生与垃圾收集时间相关的效果:可用堆越高,收集限制越高,收集器需要的时间就越长。因此,收集时间的增加可能会影响我们 16 毫秒的目标,导致 UI 卡顿。
提示
切勿在 Android 清单文件中使用largeHeap
属性以避免OutOfMemoryError
:这并非解决方案,也不是技巧。相反,它可能导致用户体验问题,并可能影响设备的整体性能。
有一个有用的类叫做ActivityManager
,它提供了请求内存消耗和可用性信息的方法。其中一些如下:
-
getMemoryClass
:这返回了分配给应用程序的兆字节。这可以用来估计我们将使用的内存量或应用程序中使用的图片质量。 -
getLargeMemoryClass
:这与getMemoryClass()
方法相同,但适用于请求大堆内存的情况。 -
getMemoryInfo
:这会返回一个包含有关内存系统相关状态有用信息的MemoryInfo
对象:-
availMem
:可用的系统内存。 -
lowMemory
:一个布尔值,表示系统是否处于低内存状态。 -
threshold
:系统处于低内存状态并可以开始移除进程的内存阈值。
-
-
getMyMemoryState
:这将返回包含有关调用进程有用信息的RunningAppProcessInfo
:-
lastTrimLevel
:这是进程的最后修剪级别。 -
importance
:进程的重要性。正如我们将在第五章 多线程 中看到的,每个进程都有自己的优先级,系统会根据其级别决定是否移除它。
-
-
isLowRamDevice
:这将返回设备是否需要被视为低内存设备。根据我们需要的内存,这可以用来启用或禁用功能。
例如,请看以下代码段:
ActivityManager activityManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
int capacity = 20;
if (activityManager.isLowRamDevice()) {
capacity = 10;
}
…
这个特别的方法是从 Android KitKat(API 级别 19)开始添加到平台的,但有一个兼容类执行相同的操作:
int capacity = 20;
if (ActivityManagerCompat.isLowRamDevice()) {
capacity = 10;
}
…
最后一个,让我们谈谈System.gc()
方法,它强制请求触发垃圾收集器。它可以在任何地方使用,但不能保证垃圾收集器何时会被触发。此外,我们更应该遵循一致的策略来管理应用程序生命周期中的内存,并分析我们的代码以找到内存泄漏和内存波动。
主要组件和内存管理
在 Android 提供的四个主要组件中,BroadcastReceivers
是唯一不需要特定内存管理策略的组件:它们的生命周期与唯一的BroadcastReceiver.onReceive()
方法相关,执行完毕后就会被销毁。显然,这对于其他三个主要组件并不适用,因为它们会一直存在,直到我们销毁它们或系统在需要内存时销毁它们。
因此,它们都实现了ComponentCallback
接口。我们特别关注一个方法:ComponentCallback.onLowMemory()
方法。每次系统在低内存状态下运行并开始杀死进程之前,都会执行其实现。因此,这是一个释放我们应用程序部分内存的好机会。这里我们不是在谈论内存泄漏,而是其他类型的内存保持,比如堆缓存对象。然后,重写该方法以释放持有的对象。
不幸的是,这个ComponentCallback.onLowMemory()
方法是在系统已经开始杀死其他进程之后被调用的。这不好,因为从零开始重新创建应用比从后台恢复要昂贵得多。这就是为什么在 Android 平台开发过程中,上面描述的回调被改进了,通过定义一个名为ComponentCallback2
的ComponentCallback
的子接口。它引入了一个更具体的方法,并继承了ComponentCallback.onLowMemory()
方法。它从 Android Ice Cream Sandwich(API 级别 14)开始可用。这意味着从 Android 14 开始,Android 的主要组件实现这个接口,而不是ComponentCallback
接口,因此早期版本中没有ComponentCallback
方法。
我们正在讨论的方法是ComponentCallback2.onTrimMemory()
。它背后的想法与ComponentCallback.onLowMemory()
方法相同,但在这里,系统为我们提供了系统中内存消耗紧急程度的级别。我们的应用可以处于两种不同的状态,与它的可见性相关,每个状态可以接收不同级别的内存。如前所述,系统中的所有进程都使用 LRU 策略进行管理,定义了一个从当前进程到更老进程的列表。位于底部的进程是首先被删除以回收内存的。
让我们看看应用的可视性和它们的 LRU 位置:
-
Visible:应用当前正在运行,位于 LRU 的顶部
-
Invisible:应用不再可见,开始从列表中下降,直到到达尾部被销毁,或者再次变得可见时移到顶部。
ComponentCallback.onTrimMemory()
方法传递一个整数值作为参数。根据这个参数,我们可以采取不同的行动,以防止进程到达底部并被销毁。在这种情况下,需要重新初始化应用程序:这比恢复缓存的前一个状态获取数据要昂贵得多。
这些方法中用作参数的常量如下:
-
TRIM_MEMORY_RUNNING_MODERATE
:应用可见,系统开始进入低内存状态。 -
TRIM_MEMORY_RUNNING_LOW
:应用可见,内存设备正在变低。 -
TRIM_MEMORY_RUNNING_CRITICAL
:应用可见,内存设备处于临界状态,可能需要销毁其他进程以释放内存。 -
TRIM_MEMORY_UI_HIDDEN
:应用不可见。这只是通知应用不再可见,你应该释放一些内存的回调。 -
TRIM_MEMORY_BACKGROUND
:应用不可见,并且已在 LRU 列表中开始下降,且设备内存不足。 -
TRIM_MEMORY_MODERATE
:应用不可见,已达到 LRU 列表的中间位置,且设备内存不足。 -
TRIM_MEMORY_COMPLETE
:应用不可见,已达到 LRU 列表的底部,且设备内存不足,因此应用程序即将被杀死。
当系统开始杀死进程时,它会通过分析内存消耗来决定杀死哪个进程。这意味着我们的应用程序消耗的内存越少,被杀死的可能性就越小,恢复速度也就越快。
如果应用程序在内存管理上结构良好,那么在触发此类事件时释放内存的一个好做法可能是:
@Override
public void onTrimMemory(int level) {
switch (level) {
case TRIM_MEMORY_COMPLETE:
//app invisible - mem low - lru bottom
case TRIM_MEMORY_MODERATE:
//app invisible - mem low - lru medium
case TRIM_MEMORY_BACKGROUND:
//app invisible - mem low - lru top
case TRIM_MEMORY_UI_HIDDEN:
//app invisible - lru top
case TRIM_MEMORY_RUNNING_CRITICAL:
//app visible - mem critical - lru top
case TRIM_MEMORY_RUNNING_LOW:
//app visible - mem low - lru top
case TRIM_MEMORY_RUNNING_MODERATE:
//app visible - mem moderate - lru top
break;
}
}
如果你从不同的缓存或级别释放对象,移除switch
语句中的断点,每个案例都会再次执行,以在更关键的状态下释放内存。
除了主要组件外,此接口还由Application
和Fragment
类实现。这样我们也可以在单个片段内部释放内存,使用onTrimMemory()
方法。
调试工具
了解内存泄漏和内存碎片是什么,以及我们可以采取哪些策略来避免它们,现在我们需要知道如何找到它们,以及如何从内存角度分析我们的代码。
正如本章多次提到的,我们必须始终关注应用程序进程使用的堆内存量,尽量保持其尽可能低,并在检查垃圾收集器行为的同时尽可能释放资源。我们的应用程序需要能够与具有各种不同 RAM 量的设备上的其他应用程序共存。因此,考虑到这一点,我们将关注有助于分析内存使用的工具,并了解如何读取与垃圾回收相关的常见日志。
LogCat
最简单的工具无疑是 LogCat,它用于打印通知我们关于内存趋势和垃圾回收事件的消息。LogCat 中与内存相关的每条消息根据设备运行时都有相同的格式。因此,我们将检查两个 Android 运行时,先从 Dalvik 开始,然后是 ART。通常,开发者没有花足够的时间分析这些日志。如果我们想要了解应用程序的行为是否正确,这些日志非常重要。
Dalvik
在 LogCat 中,Dalvik 内存日志打印的格式如下:
D/dalvikvm: <GcReason> <AmountFreed>, <HeapStats>, <ExternalMemoryStats>, <PauseTime>
让我们了解日志中每个元素的含义:
-
GcReason
:这是触发垃圾收集的原因。所有应用程序线程都被阻塞,等待收集完成。可能的值如下:-
GC_CONCURRENT
:当堆需要清理时,它跟随 GC 事件。 -
GC_FOR_MALLOC
:跟随新内存分配的请求,但没有足够的空间进行分配。 -
GC_HPROF_DUMP_HEAP
:跟随一个调试请求,对堆进行剖析。我们将在接下来的页面中了解这意味着什么。 -
GC_EXPLICIT
:跟随强制明确的System.gc()
请求,正如我们提到的,应该避免这样做。 -
GC_EXTERNAL_ALLOC
:跟随外部内存的请求。这只能在 Android Gingerbread(API 级别 10)或更低版本的设备上发生,因为那些设备内存有不同的条目,但对于后来的设备,内存作为一个整体在堆中处理。
-
-
AmountFreed
:这是垃圾收集器能够释放的内存量。 -
HeapStats
:这是指内部堆,由以下内容组成:-
自由堆占总额的百分比
-
分配的堆大小
-
总堆大小
-
-
ExternalMemoryStats
:这是指 Android Gingerbread(API 级别 10)或更低版本的设备的外部内存。它包含以下内容:-
分配的外部内存大小
-
总外部内存大小
-
-
PauseTime
:这是垃圾收集的暂停持续时间。
以下是 Dalvik 日志的一个示例,以展示它在 LogCat 中可能的样子:
D/dalvikvm(9932): GC_CONCURRENT freed 1394K, 14% free 32193K/37262K, external 18524K/24185K, paused 2ms
ART
ART 内存日志格式相当不同,但仍然可读。然而,ART 与 Dalvik 运行时的行为不同:并非每个垃圾收集器事件都会记录在 LogCat 中。ART 仅记录强制事件以及垃圾收集器暂停时间超过 5 毫秒或持续时间超过 100 毫秒的事件。
这是其格式:
I/art: <GcReason> <GcName> <ObjectsFreed>(<SizeFreed>) AllocSpace Objects, <LargeObjectsFreed>(<LargeObjectSizeFreed>) <HeapStats> LOS objects, <PauseTimes>
这一次,日志中的元素如下:
-
GcReason
:这是触发垃圾收集的原因。可能的值如下:-
Concurrent
:跟随并发 GC 事件。这种事件在不同于分配线程的不同线程中执行,因此它不会强制其他应用程序线程停止,包括 UI 线程。 -
Alloc
:跟随新内存分配的请求,但没有足够的空间进行分配。这时,所有应用程序线程都会被阻塞,直到垃圾回收结束。 -
Explicit
:跟随强制明确的System.gc()
请求,对于 ART 和 Dalvik 都应该避免这样做。 -
NativeAlloc
:跟随本地分配的内存请求。 -
CollectorTransition
:在低内存设备上跟随垃圾收集器切换。 -
HomogenousSpaceCompact
:跟随系统减少内存使用和堆碎片整理的需要。 -
DisableMovingGc
:在调用特定内部方法GetPrimitiveArrayCritical
之后,跟随收集块。 -
HeapTrim
:因为堆修剪未完成,跟随收集块。
-
-
GcName
:ART 使用不同的垃圾收集器来释放内存,它们有不同的行为,但我们对此没有选择,而且这些信息对我们的分析并不非常有用。无论如何,名称的可能值如下:-
并发标记清除(CMS)
-
并发部分标记清除
-
并发粘性标记清除
-
标记清除 + 半空间
-
-
ObjectFreed
:释放的对象数量。 -
SizeFreed
:释放对象的总大小。 -
LargeObjectFreed
:从大空间释放的对象数量。 -
LargeObjectSizeFreed
:从大空间释放的对象总大小。 -
HeapStats
:这类似于 Dalvik 的功能。它包含自由堆空间的百分比、已分配堆的大小和总堆大小。 -
PauseTimes
:这是垃圾回收暂停的持续时间。
让我们看一个 ART 日志的例子:
I/art : Explicit concurrent mark sweep GC freed 125742(6MB) AllocSpace objects, 34(576KB) LOS objects, 22% free, 25MB/32MB, paused 1.621ms total 73.285ms
ActivityManager API
我们之前已经讨论过这个类,但这次我们想要展示其他在从内存角度分析应用程序时可能有所帮助的方法。有两种方法可以帮助我们在调试时找到与内存相关的问题,但只有在应用程序可调试的情况下才能使用。我们讨论的是以下方法:
-
setWatchHeapLimit
-
clearWatchHeapLimit
第一个方法特别允许我们对堆内存设置一个警报:当达到设定的堆内存量时,设备会自动进行堆转储,我们可以分析结果以了解是否发生了内存泄漏。第二个方法旨在移除设定的限制。此外,这个类提供了一个由Activity
或BroadcastReceiver
处理的行为,以通知我们已达到限制并已进行堆转储。这个行为如下:
ActivityManager.ACTION_REPORT_HEAP_LIMIT
不幸的是,这些方法仅在 Android Marshmallow(API 级别 23)及以上版本可用,但这样我们可以在系统对内存进行分析以供后续分析时继续测试。
StrictMode
平台提供的另一个非常有用的 API 是StrictMode
。这个类用于查找内存和网络问题。在这里我们只处理内存部分,而在第六章网络中,我们将处理网络方面的问题。
如果启用,它将在后台运行,并通知我们存在问题以及发生的时间,这取决于我们选择的政策。然后,在使用这个功能时需要定义两件事:跟踪什么以及如何跟踪。为此,我们可以使用StrictMode.VmPolicy
类和StrictMode.VmPolicy.Build
类,如下所示:
if (BuildConfig.DEBUG) {
StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build();
StrictMode.setVmPolicy(policy);
}
让我们看看我们可以观察到什么:
-
detectActivityLeaks
:它会检测活动泄漏。 -
detectLeakedClosableObjects
:它会检测Closable
对象是否被终结,但未被关闭。 -
detectLeakedRegistrationObjects
:当Context
被销毁时,它会检测是否泄漏了ServiceConnection
或BroadcastReceiver
。 -
detectSqlLiteObjects
:它会检测 SQLite 对象是否被终结,但未被关闭。 -
detectAll
:它会检测所有可疑行为。
它们可以一起使用来检测多个事件。现在,让我们看看它是如何通知开发者的:
-
penaltyDeath
:当检测到问题时,进程将被杀死,应用将崩溃。 -
penaltyDropBox
:当检测到问题时,相关的日志会被发送到DropBoxManager
,后者会收集它们以供调试。 -
penaltyLog
:当检测到问题时,它会记录日志。
通过指定类名及其出现的次数,可以很有效地了解哪个类没有遵守限制。以下是日志的一个示例:
E/StrictMode: class com.packtpub.androidhighperformanceprogramming.TestActivity; instances=2; limit=1 android.os.StrictMode$InstanceCountViolation: class com.packtpub.androidhighperformanceprogramming.TestActivity; instances=2; limit=1
at android.os.StrictMode.setClassInstanceLimit(StrictMode.java:1)
注意
在调试和测试环境中启用StrictMode
,以检测任何内存问题,最重要的是,正如我们在本章前面所讨论的,活动泄漏。记得在发布版本中禁用它,因为它可能会在将来 Android 版本中用于不同的检测,并且即使它不发声,在后台也是活跃的,消耗我们可能需要的达到性能目标的资源。
Dumpsys
Dumpsys 工具在每一部 Android 设备中都有,它让我们能够获取关于设备内每个服务的令人印象深刻的信息量。它可以通过在终端调用以下命令来使用:
adb shell dumpsys <SERVICE>
该服务是可选的,但如果你不指定你感兴趣的是哪个服务,那么所有服务的结果都会被打印出来,这可能会有些混淆。服务的可用性取决于设备上安装的特定 Android 版本。然后,为了获取设备上可用的服务完整列表,请调用以下命令:
adb shell service list
对于它们中的每一个,你可以通过简单地像之前一样调用并最后加上–h
参数,来查看可以添加的可能参数:
adb shell dumpsys <SERVICE> -h
在接下来的页面中,我们将展示两个特别有用的dumpsys
服务,从内存的角度来分析我们的代码。
Meminfo
Meminfo 工具显示了关于设备上内存使用情况的重要信息。调用它的命令如下:
adb shell dumpsys meminfo
让我们看看以下打印内容:
Applications Memory Usage (kB):
Uptime: 239111 Realtime: 239111
Total PSS by process:
64798 kB: system (pid 1299)
33811 kB: com.android.systemui (pid 1528)
30001 kB: com.google.android.gms (pid 2006)
29371 kB: com.android.launcher3 (pid 2388 / activities)
25394 kB: com.google.process.gapps (pid 1923)
21991 kB: com.google.android.gms.persistent (pid 1815)
21069 kB: com.google.android.apps.maps (pid 2075)
20067 kB: com.google.android.apps.messaging (pid 2245)
17678 kB: zygote (pid 966)
17176 kB: com.android.phone (pid 1750)
15637 kB: com.google.android.gms.unstable (pid 2576)
10041 kB: android.process.acore (pid 1555)
9961 kB: com.android.inputmethod.latin (pid 1744)
9692 kB: android.process.media (pid 1879)
9333 kB: com.google.android.gms.wearable (pid 2112)
8748 kB: com.android.email (pid 2054)
PSS是 Linux 的比例集大小指标。它指的是应用程序使用的总内存量。
我们可以通过询问有关特定进程的详细信息的 pid 来进一步了解:
adb shell dumpsys meminfo <PID>
然后,我们将在屏幕上看到如下内容:
Applications Memory Usage (kB):
Uptime: 6489195 Realtime: 6489195
** MEMINFO in pid 2693 [com.packtpub.androidhighperformanceprogramming.chap4] **
Pss Private Private Swapped Heap
Total Dirty Clean Dirty Size
------ ------ ------ ------ ------
Native Heap 3150 3060 0 0 16384
Dalvik Heap 2165 2088 0 0 2274
Dalvik Other 292 280 0 0
Stack 128 128 0 0
Other dev 4 0 4 0
.so mmap 862 100 8 0
.apk mmap 218 0 52 0
.ttf mmap 20 0 0 0
.dex mmap 3848 0 3844 0
.oat mmap 1134 0 40 0
.art mmap 1015 520 0 0
Other mmap 7 4 0 0
Unknown 77 76 0 0
TOTAL 12920 6256 3948 0 18658
Objects
Views: 36 ViewRootImpl: 1
AppContexts: 3 Activities: 1
Assets: 2 AssetManagers: 2
Local Binders: 8 Proxy Binders: 13
Parcel memory: 3 Parcel count: 12
Death Recipients: 0 OpenSSL Sockets: 0
SQL
MEMORY_USED: 0
PAGECACHE_OVERFLOW: 0 MALLOC_SIZE: 0
它包含了我们应用程序在前台的内存使用情况。表的前两列指的是我们应该监控的已分配内存:那里的意外值可能意味着内存泄漏。
ProcStats
Android KitKat(API 级别 19)引入了 ProcStats 工具,它能够提供关于进程及其内存的重要信息。它可以分析与应用程序相关的所有进程的使用情况,跟踪后台或前台进程,它们的内存使用情况以及运行时间。
用来查看整个系统的一般统计信息的命令如下:
adb shell dumpsys procstats –hours 3
这将输出一个按运行时间排序的进程列表。让我们看一个例子,以了解如何阅读它:
AGGREGATED OVER LAST 3 HOURS:
* system / 1000 / v23:
TOTAL: 100% (62MB-64MB-67MB/55MB-57MB-59MB over 16)
Persistent: 100% (62MB-64MB-67MB/55MB-57MB-59MB over 16)
* com.android.systemui / u0a14 / v23:
TOTAL: 100% (35MB-36MB-36MB/29MB-30MB-31MB over 16)
Persistent: 100% (35MB-36MB-36MB/29MB-30MB-31MB over 16)
Service: 0.01%
* com.android.inputmethod.latin / u0a33 / v23:
TOTAL: 100% (11MB-11MB-11MB/8.2MB-8.2MB-8.2MB over 16)
Imp Bg: 100% (11MB-11MB-11MB/8.2MB-8.2MB-8.2MB over 16)
* com.google.android.gms.persistent / u0a7 / v8185470:
TOTAL: 100% (22MB-22MB-23MB/17MB-17MB-17MB over 16)
Imp Fg: 100% (22MB-22MB-23MB/17MB-17MB-17MB over 16)
* com.android.phone / 1001 / v23:
TOTAL: 100% (18MB-18MB-19MB/14MB-15MB-16MB over 16)
Persistent: 100% (18MB-18MB-19MB/14MB-15MB-16MB over 16)
* com.android.launcher3 / u0a8 / v23:
TOTAL: 100% (28MB-29MB-32MB/23MB-24MB-28MB over 119)
Top: 100% (28MB-29MB-32MB/23MB-24MB-28MB over 119)
Run time Stats:
SOff/Norm: +1s478ms
SOn /Norm: +4h1m17s720ms
TOTAL: +4h1m19s198ms
Memory usage:
Persist: 117MB (96 samples)
Top : 29MB (238 samples)
ImpFg : 23MB (198 samples)
ImpBg : 11MB (40 samples)
Service: 56MB (127 samples)
Receivr: 1.1KB (69 samples)
CchEmty: 76MB (146 samples)
TOTAL : 312MB
ServRst: 18 (11 samples)
Start time: 2015-11-29 07:19:00
Total elapsed time: +4h1m21s462ms (partial) libart.so
列表中显示的每个进程都包含过去三小时的内存状态,格式如下:
percent (minPss-avgPss-maxPss / minUss-avgUss-maxUss)
虽然我们已经了解了 PSS 是什么,但USS代表单元集大小,它是私有内存。那么,让我们看看这些值的含义:
-
percent:
是进程执行三小时内的百分比时间 -
minPss
:最小总内存 -
avgPss
:平均总内存 -
maxPss
:最大总内存 -
minUss
:最小私有内存 -
avgUss
:平均私有内存 -
maxUss
:最大私有内存
当我们想要查看有关特定应用程序的详细信息时,可以使用以下方法,这与前一个相同,但这次我们添加了要分析的应用程序的包名:
adb shell dumpsys procstats com.packtpub.androidhighperformanceprogramming --hours 3
对此的打印结果如下所示:
AGGREGATED OVER LAST 3 HOURS:
System memory usage:
SOn /Norm: 1 samples:
Cached: 260MB min, 260MB avg, 260MB max
Free: 185MB min, 185MB avg, 185MB max
ZRam: 0.00 min, 0.00 avg, 0.00 max
Kernel: 43MB min, 43MB avg, 43MB max
Native: 39MB min, 39MB avg, 39MB max
Mod: 1 samples:
Cached: 240MB min, 240MB avg, 240MB max
Free: 18MB min, 18MB avg, 18MB max
ZRam: 0.00 min, 0.00 avg, 0.00 max
Kernel: 43MB min, 43MB avg, 43MB max
Native: 39MB min, 39MB avg, 39MB max
Low: 1 samples:
Cached: 232MB min, 232MB avg, 232MB max
Free: 15MB min, 15MB avg, 15MB max
ZRam: 0.00 min, 0.00 avg, 0.00 max
Kernel: 43MB min, 43MB avg, 43MB max
Native: 39MB min, 39MB avg, 39MB max
Crit: 1 samples:
Cached: 211MB min, 211MB avg, 211MB max
Free: 12MB min, 12MB avg, 12MB max
ZRam: 0.00 min, 0.00 avg, 0.00 max
Kernel: 43MB min, 43MB avg, 43MB max
Native: 39MB min, 39MB avg, 39MB max
Summary:
Run time Stats:
SOff/Norm: +1s478ms
SOn /Norm: +4h25m22s212ms
Mod: +5m2s547ms
Low: +1m21s22ms
Crit: +2m54s947ms
TOTAL: +4h34m42s206ms
在此情况下,我们可以分析在不同系统内存相关状态下的内存使用情况。上述输出意味着设备状态从正常变为中等,或低内存,或临界状态。我们的应用程序释放了资源,因此总内存量也因此下降。我们还知道,在那些特定状态下所花费的时间,是基于摘要中的运行时间统计内部的内容。
这对于理解当系统触发onTrimMemory()
事件时所使用的策略是否正确,或者是否可以通过释放更多对象来改进非常有用。
ProcStats 工具也可以直接在设备内部使用:打开开发者设置,然后选择进程统计。你会看到类似图 6展示的内容,左屏显示了后台进程及其随时间变化的百分比,而右屏则展示了进程的详细信息:
图 6:设备上的 ProcStats
使用菜单,可以更改以下进程的持续时间和切换类型:
-
后台进程
-
前台进程
-
缓存进程
在进程统计屏幕中的进度条可以根据内存状态改变颜色:
-
当内存状态正常时显示绿色
-
当内存状态为中等时显示黄色
-
当内存状态低或为临界时显示红色
摘要
在研究如何提高 Android 应用程序性能的过程中,内存至关重要,它是用户对我们应用程序感知的核心,尽管在开发过程中开发者往往最容易忽视这一方面。每位开发者都应该花时间检查他们正在开发的应用的内存管理:存在许多内存泄漏的可能性。因此,我们重点关注了 Android 垃圾回收的运作机制、内存泄漏的主要原因以及内存波动是什么。
我们定义了许多最佳实践,以帮助保持良好的内存管理,引入了有帮助的设计模式,并在开发过程中分析了最佳选择,这些选择实际上可以影响内存和性能。然后,我们查看了 Android 中最严重的泄漏的主要原因:与活动和服务等主要组件相关的原因。作为实践的结论,我们介绍了应该使用和不使用的 API,然后是其他能够为与系统和应用程序外部相关的事件定义策略的 API。
本章节最后一部分的目标是让开发者能够阅读内存日志,并使他们能够在调试阶段寻找内存异常的正确工具,并收集数据分析以对应用程序进行剖析。这样一来,他们可以轻松找到内存泄漏,进而搜索触发代码,并最终按照既定最佳实践应用修复,或改进他们应用程序的内存管理。
第五章:多线程
当手机市场开始下滑,智能手机市场兴起时,用户显然需要在移动设备上拥有强大的计算能力。对计算能力的不断增长的需求以及合适硬件的可用性导致了设备上的多核 CPU,允许并行执行多个任务。Android 工程师在这一切发生之前就已经知道了这一点。此外,这就是为什么我们有多种选项可以同时执行不同的任务,具有很大的灵活性,以及许多不同的组件可供选择以应用于我们的多线程策略。然而,我们做得好吗?为了回答这个问题,我们将了解线程的所有方面,从 Android 平台继承的 Java 框架线程基础到 Android 为此目的提供的所有类。我们还将了解 Android 如何处理其进程,以及我们如何在不同情况下正确选择合适的组件,因为并非所有组件都是可互换的。
处理多线程看似简单,但在多个线程之间的通信中存在许多陷阱。因此,我们将了解 Android 平台如何帮助我们处理这类问题,提供了一些有用的类,我们将在许多情况下使用它们。我们的目标是了解如何正确使用它们以及如何处理它们,以提高我们应用程序的性能。
作为开发者,我们的目标是衡量应用程序的性能。因此,在本章的最后部分,我们将介绍一个工具,用于检测某些代码是否在主线程中执行,从而降低了应用程序的响应性。
演练
我们将在这里定义所有需要了解的内容,以便处理像 Android 这样的多线程环境。理解线程是什么以及处理线程时可能遇到的问题至关重要。因此,我们会暂时聚焦于 Java 框架,因为每位 Android 开发者都应该了解这些概念,然后我们将关注该平台在 Android 中的定义以及与更多对象的集成。这为从应用程序内的多线程到不同进程间通信的所有级别提供了多种分离执行的方式,定义了一种特定的语言以实现目标。那么,让我们看看我们讨论的是什么。
线程基础
我们可以将线程视为一系列按顺序执行的指令。这些指令被翻译成由设备硬件执行的指令。当有多个部分指令需要执行时,环境就被称为多线程。这种技术有助于加速任何系统,因为并行执行总是比串行执行快。此外,这提高了具有用户界面的应用程序的响应性,并可能导致更好的资源管理和整个系统的管理。
Java 提供了java.lang.Thread
包,其中包含许多用于处理多个线程间并发性的类。这是对实际后台执行的包装,对开发者不可见。因此,在深入理解 Android 框架之前,我们需要先了解 Java 框架。
多核 CPU
几年前,处理器一次只能执行一条指令。然而,线程框架已经存在。然后,使用时间分片技术按顺序执行来自多个线程的代码,而多线程只是一个虚构的概念。在这种情况下,我们无法知道虚拟机将按照什么顺序执行来自多个线程的代码。但是,具有多核技术的处理器已经存在了好几年。它们可以同时执行多个代码,使多线程成为现实。
线程
要创建一个线程,你可以使用Thread
对象,然后调用Thread.start()
方法以与当前线程并行启动其执行。这样,调用线程通知虚拟机需要一个新线程,然后虚拟机创建一个新线程并执行与Thread.run()
方法内部代码相关的字节码。然而,该方法默认实现什么都不做。必须指出的是,直接调用Thread.run()
方法而不是Thread.start()
将在不创建新线程的情况下调用该方法,因此这是启动新线程的错误方式。有两种方法可以向线程的执行中添加代码:
-
扩展
Thread
类:这种方式是创建一个扩展了Thread
类的类,然后需要重写Thread.run()
方法,以指定当调用Thread.start()
时要执行的操作:public class ThreadActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); MyThread thread = new MyThread(); thread.start(); } private class MyThread extends Thread { @Override public void run() { //code... } } }
-
实现
Runnable
接口:这种方式,当调用Thread.start()
时,要执行的代码将是Runnable.run()
方法中的代码:public class ThreadActivity extends Activity implements Runnable { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Thread thread = new Thread(this); thread.start(); } @Override public void run() { //code... } }
线程总是由另一个线程启动,因此总有一个特殊的线程称为主线程,它是应用程序首次启动和执行的地方。每次我们启动一个新线程时,主线程的执行都会分成两个独立的线路,如图 1*所示:
图 1:线程操作
在图 1中,展示了线程的不同操作:
-
线程 1只是被创建和执行。它结束后就会被销毁,因为没有更多的执行在队列中。
-
线程 2像线程 1一样被创建和执行,但在其生命周期内会被暂停一段时间。这可以通过使用
Thread.sleep()
方法实现,指定要等待的毫秒数。在这段时间内,线程停止等待,直到达到超时时间,然后恢复运行操作。 -
线程-3 被创建并执行,在其生命周期内,它启动了一个新线程并等待它。这意味着它不知道应该等待多久。这就是为什么,如果你不知道需要等待的时间,但需要等待另一个线程完成工作,你可以调用
Thread.join()
方法。当创建的线程完成其任务后,线程-3 可以继续执行直到结束。还可以为等待指定一个超时时间;当达到这个时间,线程-3 无论如何都会继续执行。
Java 为线程提供了优先级系统。这意味着我们可以改变线程的优先级,让它相对于其他线程执行得更快或更慢。有 10 个优先级级别。Java 还定义了最大、最小或正常优先级的三个常量。我们可以使用以下方法来改变线程优先级:
thread.setPriority(Thread.MAX_PRIORITY);
thread.setPriority(Thread.NORM_PRIORITY);
thread.setPriority(Thread.MIN_PRIORITY);
多线程应用程序
使用多线程的应用程序和系统需要面对一些问题,这些问题涉及到开发者,并迫使他们小心处理不同线程如何访问对象的问题。
应用程序中多个线程的执行顺序是不可预测的。无法保证哪个线程会先执行或先完成。而且,这里所指的不仅仅是代码块,还包括单行代码。在一些需要按预定顺序访问单一对象的临界情况下,这可能会引起担忧。想象一下,如果洗衣店的洗衣机和干衣机可以随意顺序地处理衣物,会出现什么情况。如果洗衣机先开始工作当然没问题,但如果干衣机先工作呢?或者更糟的是,如果它们交替进行短期工作会怎样?我们希望衣物先被洗净再烘干。也就是说,应该依次并按正确顺序访问这些负载。换句话说,我们需要防止当一个线程正在访问对象时,另一个线程也尝试访问。这意味着对负载的访问需要是同步的。
线程安全
线程安全的概念与多线程环境紧密相关。它指的是代码的安全执行,这种执行不会以并发方式改变共享数据。虽然对对象的读取访问可能不会对安全性构成问题,但写入访问却可能。如果一个多线程应用程序在共享对象上没有并发操作,那么它是线程安全的。
让我们看看这在 Java 框架中意味着什么。Java 使用监视器的概念:每个对象都有一个监视器,线程可以锁定和解锁它。监视器确保一次只有一个锁定。任何其他锁定尝试都会被排队。这些操作在低级代码中,可以使用特殊类来显式调用对象的锁定或解锁,但 Java 提供了一个特殊的关键字来完成同样的操作:synchronized
。它可以作为语句使用,也可以用来声明同步方法的签名。在第一种情况下,你需要指定需要锁定哪个对象以及哪些代码受到锁的影响:
synchronized (object) {
//code...
}
这样,在括号内的代码执行完毕之前,其他线程无法访问该对象。开发者必须了解所谓的死锁。当两个或更多线程相互锁定等待对方时,这种情况就会发生,然后这些线程将永远被阻塞。当使用带有交叉引用锁定的synchronized
关键字时,可能会发生这种情况;这种条件必须被避免。
同步方法的目标是锁定该方法引用的对象:
public synchronized void update() {
//code...
}
Android 多线程环境
Android 平台从 Linux 继承了进程和线程系统。系统至少为不同的应用程序生成一个进程,每个进程都有其线程。在处理内存时我们已经讨论过进程。让我们分析它们是什么以及如何管理它们:这有助于理解如何处理应用程序的线程和组件。
进程
在 Android 中,进程是主要组件(如活动、服务、广播接收器和内容提供者)的容器。因此,每个进程都会影响内存,如果系统在这方面处于关键状态,它会开始销毁这些进程。系统通过使用最近最少使用(LRU)策略来完成这个操作:在需要时,首先销毁最近最少使用的对象以释放内存。为此设计了一个优先级系统:在其生命周期中,进程可以是以下几种状态之一:
-
前台:如果一个进程正在托管用户正在与之交互的组件,那么它是前台进程。此时,该进程位于堆栈顶部。
-
可见:如果一个进程不是前台进程,但它仍然可以被用户看到,那么它是可见的。
-
服务:这是一个仅包含刚刚启动的服务进程。
-
后台:这包含不再对用户可见的组件。
-
空:这样的进程不包括任何组件。它用于缓存目的,以加快未来应用程序的恢复速度。它位于堆栈底部;当系统回收内存时,它会首先被丢弃。
当应用程序首次启动时,会创建一个默认进程,并且其所有组件都在该进程中执行。然而,我们可以通过在清单文件中使用特定属性来处理应用程序的组件,为每个组件强制创建新进程,或者让它们加入同一个自定义进程。
<service
android:name=".MyService"
android:process=".MyProcess">
</service>
只需要指定进程的名称。当名称以冒号开头时,该进程是应用程序私有的。当以小写字母开头时,该进程可以与其他应用程序共享。
安卓应用程序线程
本章前面讨论的关于线程的内容在 Android 系统中同样适用:当应用程序启动时,会创建一个新的主线程,并且其代码按顺序执行。从该线程,我们可以启动新线程来进行后台操作。为应用程序创建的任何其他线程都被称为后台线程或工作线程。另一种类型的线程是 Binder 线程,用于进程间的通信。
UI 线程
了解主线程是唯一可以管理用户界面的线程至关重要。这就是它也被称作 UI 线程的原因。UI 线程的生命周期与应用程序和进程的生命周期相同,因为需要有一个线程能够随时让用户进行交互。然而,为什么会有这样一个严格的要求?为什么不能在 UI 线程外部访问视图呢?因为 Android UI 不是线程安全的,如果视图可以被不同的线程访问和修改,那么在应用程序执行期间可能会出现不可预期的行为和并发错误。
这一选择是为了加快 UI 的响应速度,因为对对象进行加锁和解锁操作是昂贵的,这会影响到 Android 的用户体验,仅仅是为了让开发者能够从多个线程访问视图。因此,平台强制要求只能从主线程访问 UI。这意味着无需同步视图,因为它们只能由 UI 线程访问。所以,在代码结构中加入同步是多余的。实际上,每当后台线程尝试访问视图实例时,都会抛出以下异常:
CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views
工作线程
在 Android 平台中另一个需要注意的方面是,主线程不仅负责 UI,而且应当只做这件事:任何不必要的 UI 操作都应该在不同的线程中完成,以保持 UI 的流畅,进而提供良好的用户体验,这是工作线程的主要目标。它们用于执行可能影响 UI 的长时间运行的操作。更重要的是,如果这些操作在 UI 线程中执行,它们可能会让 UI 冻结直到操作结束。这可能导致所谓的应用无响应对话框的出现。当 UI 被阻塞时,系统会向用户显示这个对话框,告知应用无响应,并询问用户是否关闭应用。这对用户体验来说非常糟糕,对性能来说则是灾难。我们将了解 Android 提供了哪些结构来达到我们希望应用拥有的响应性。
Binder 线程
当我们需要来自不同进程的不同线程进行通信时,我们不能使用标准代码,而需要一些更高级的技术来实现。Android 平台使用 Binder 线程让来自不同进程的线程进行通信。这种线程简化了进程间通信,我们将在后续页面中看到这一点。不过,我们不需要直接处理 Binder 线程。有一种特定的语言允许我们在进程间交换数据,称为Android 接口定义语言(AIDL)。
Android 线程消息传递
让我们来看看处理应用中线程间通信的框架。一些对象参与了消息传递操作,它们如下:
-
Message
或Runnable
对象:它们是用于线程间通信和发送的对象。 -
MessageQueue
:这是一个待处理的有序消息和可运行对象的容器。 -
Looper
:这是将Message
和Runnable
对象分派给正确Handler
对象的对象。 -
Handler
:这是Message
和Runnable
对象的来源,也是Looper
的接收者。因此,它具有双重责任,即将消息和可运行对象放入MessageQueue
,并在Looper
将它们送回时执行它们。这里的奥秘在于:发送操作是在发送线程上进行的,而执行操作是在接收线程上进行的。因此,实现了不同线程之间的通信。
图 2展示了这些对象之间主要的关系:
图 2:两个线程之间的消息传递过程
并非所有线程都有Looper
。相反,只有主线程有自己的Looper
。这意味着如果你想让两个线程进行通信,需要为该通信分配一个Looper
对象并创建MessageQueue
。这可以通过在Thread.run()
中调用静态的Looper.prepare()
方法来完成。现在我们有了MessageQueue
和Looper
,我们需要这个Looper
开始向Handler
分派消息和可运行对象。这可以通过调用静态的Looper.loop()
方法来完成。以下是展示所说内容的代码片段:
public class LooperThread extends Thread {
public Handler mHandler;
public void run() {
Looper.prepare();
mHandler = new Handler() {
public void handleMessage(Message msg) {
// code…
}
};
Looper.loop();
}
}
现在,让我们看看Handler
对象是如何工作的,以及它可以如何发送消息和可运行对象。Handler
对象在构造时需要与一个Looper
相关联。然后空的Handler
构造函数将与创建它的线程的Looper
关联。以下只有在主线程中或调用后台线程的Looper.prepare()
方法之后,才能实例化处理器:
Handler mHandler = new Handler();
这就是为什么如果不这样做,将会抛出RuntimeException
,应用程序将在堆栈跟踪中显示以下消息后崩溃:
java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()
从操作的角度来看,Handler
通过以下方法将消息和可运行对象发送到MessageQueue
:
-
post(Runnable r)
-
sendEmptyMessage(int what)
-
sendMessage(Message m)
这三个都有指定特定执行时间或延迟的可能性,而Handler
可以使用以下方法从MessageQueue
中移除它们:
-
removeCallbacks(Runnable r)
-
removeMessages(int what)
-
removeCallbacksAndMessages(Object token)
当Runnable
对象包含要执行的代码时,消息应该由Handler
使用Handler.handleMessage()
方法处理,该方法提供了Message
本身。
最佳实践
携带线程概念,让我们通过代码了解谷歌是如何改进从 Java 继承的多线程框架的,以及 Android 平台为开发者提供了哪些 API 来处理 UI 线程与工作线程之间的主要问题。我们还将看到由此可能产生的问题以及 Android 在其开发过程中引入的解决方案。
然后,我们将处理高级技术以管理主要组件和 AIDL 以及跨进程通信的 Messenger。
线程
标准的 Java 线程是我们将在以下页面中看到的其他框架的基础。它们包装线程或可运行对象以实现一些平台要求,如与 UI 线程的通信。对于不需要通知 UI 线程的简短后台操作,它们仍然是轻量级的解决方案。
提示
作为使用线程时需要遵守的一般规则,避免在循环内进行同步,因为获取和释放锁是一个昂贵的操作。然后,这可能导致时间增加和资源的无谓消耗。
HandlerThread
在典型的应用程序开发中,我们处理线程和处理器,有时我们会忘记准备在后台线程上处理消息所需的内容。这就是为什么 Android 提供了一个有用的Thread
子类,它包装了线程本身、Looper
和MessageQueue
。这就是HandlerThread
,它会自行准备Looper
。这样开发者就不需要手动准备。此外,如果需要更多的初始化,我们可以在HandlerThread.onLooperPrepared()
方法中进行:这样我们就知道Looper.prepare()
已经被调用,且HandlerThread.getLooper()
的返回结果不会为 null。
让我们来看以下代码片段:
public class HandlerThreadActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MyHandlerThread handlerThread = new MyHandlerThread("HandlerThread");
handlerThread.start();
}
private class MyHandlerThread extends HandlerThread {
private Handler handler;
public MyHandlerThread(String name) {
super(name);
}
@Override
protected void onLooperPrepared() {
handler = new Handler(getLooper()) {
@Override
public void handleMessage(Message msg) {
//code...
}
};
}
public void post(Runnable r) {
handler.post(r);
}
}
}
与经典线程不同,HandlerThread
可以被复用,因为它会保持活动状态,直到调用HandlerThread.quit()
方法。这个特殊的方法会终止Looper
,使其无法再处理消息和可运行对象。之后发送的任何Message
或Runnable
都会失败,MessageQueue
会被清空。该方法将强制挂起的消息和可运行对象退出,它们不会再被分派给Handler
。为确保没有挂起的消息被终止和分派,请使用HandlerThread.quitSafely()
方法。当调用这些方法之一时,HandlerThread
对象将不能再使用,因为线程已经执行完毕。
何时使用
HandlerThread
通过Looper
和MessageQueue
保持线程存活,并提供可控的消息处理。因此,当我们需要一个始终可用的线程时,使用它是很好的选择。
提示
当处理多个线程之间的消息传递时,HandlerThread
是一个很好的选择,可以将Looper
的管理委托给它。它还可以被复用于多个消息和可运行对象。但是,请记住,在不再需要时退出,以释放资源。
AsyncTask
如先前讨论的,从多线程的角度来看,开发者主要的目标是尽可能让 UI 线程从可以在并行线程中执行的操作中解脱出来,以保持用户界面的流畅。从一开始,开发者可以使用的主要工具就是AsyncTask
。它不是一个线程框架,只是一个辅助类,用于让工作线程与 UI 线程通信。
AsyncTask
对象只能启动一次,就像Thread
一样。它可以在 UI 线程中创建、加载和启动。AsyncTask
的子类可以覆盖以下方法:
public class MyAsyncTask extends AsyncTask<Params, Progress, Result> {
@Override
protected void onPreExecute() {}
@Override
protected Result doInBackground(Params... p) {return result;}
@Override
protected void onProgressUpdate(Progress... values) {}
@Override
protected void onPostExecute(Result result) {}
@Override
protected void onCancelled() {}
}
考虑到这一点,让我们了解这意味着什么。
方法
在前面提到的方法中,只有AsyncTask.doInBackground()
是抽象的,并在工作线程中执行。其他方法如果需要可以覆盖,并具有以下目的:
-
onPreExecute()
: 在开始后台工作之前调用。它用于通知用户后台正在发生某些操作。 -
onProgressUpdate()
: 这用于在从工作线程收到一些更新后更新 UI。 -
onPostExecute()
: 这处理来自工作线程的结果。 -
onCancelled()
: 这用于在 UI 线程上处理AsyncTask
的取消。
泛型参数
类签名中的泛型是为了指定以下内容:
-
Params
: 这是AsyncTask.doInBackground()
期望的输入类型。 -
Progress
: 这是用来通知AsyncTask.onProgressUpdate()
更新的类型。 -
Result
: 这是AsyncTask
的doInBackground()
方法的返回结果,也是AsyncTask.onPostExecute()
的输入。
状态管理
一个AsyncTask
对象可以经历三个连续的AsyncTask.Status
:
-
PENDING
: 开始前 -
RUNNING
: 执行中 -
FINISHED
: 在AsyncTask.onPostExecute()
完成之后
Executor
每当需要执行AsyncTask
时,都必须提供一个Executor
对象。AsyncTask
有两种默认执行方式。如下所示:
-
SERIAL_EXECUTOR
: 这会一次完成所有任务,并按照顺序执行。 -
THREAD_POOL_EXECUTOR
: 这会并行执行任务。
有三种方法可以启动AsyncTask
的执行:
-
execute(Params)
: 这会将任务添加到SERIAL_EXECUTOR
的队列中。 -
execute(Runnable)
: 这是一个静态方法,用于使用SERIAL_EXECUTOR
执行Runnable
对象。 -
executeOnExecutor(Executor, Params)
: 这允许你指定想要使用的Executor
对象。
这是性能的关键部分,因为工作线程的执行取决于所使用的特定执行器;如果队列已满且任务运行时间较长,串行执行可能会导致意外的延迟。另一方面,默认的并行执行是全局的:因此,线程池中的线程在多个应用程序之间共享。作为替代方案,我们可以创建自己的执行器,以在AsyncTask.executeOnExecutor()
方法中使用。为此,有一个Factory
类可以创建执行器。这个类叫做Executors
,其方法如下:
-
newCachedThreadPool()
: 这首先检查是否有可用的线程,如果没有,它会创建一个新的线程并将其缓存以供未来请求使用。 -
newFixedThreadPool()
: 这与缓存情况相同,但线程数量是固定的。 -
newScheduledThreadPool()
: 这会创建一个可以安排线程在定义的时间执行任务的执行器。 -
newSingleThreadExecutor()
: 这会创建一个单线程执行器 -
newSingleThreadScheduledExecutor()
: 这会创建一个具有单个线程的执行器,可以安排在定义的时间执行。
这样,我们可以创建并重用私有的线程池,作为单例或者在Application
类中使用。例如:
public class ApplicationExecutor extends Application {
private Executor executor;
public static Executor getExecutor() {
if (executor == null)
executor = Executors.newCachedThreadPool();
return executor;
}
}
使用时机
AsyncTask
的目标是让工作线程与 UI 线程通信。那么,如果我们的后台操作不需要通知用户,或者一般而言,不需要更新 UI,那么就没有必要使用AsyncTask
:一个线程就足够了,而且比AsyncTask
性能更好。
提示
如果你使用的是带有所有 void 参数的AsyncTask
,或者只实现了AsyncTask.doInBackground()
方法,那么你不需要AsyncTask
。将实现更改为经典线程,因为 UI 不会通过AsyncTask
改变。
除了这种情况,AsyncTask
实现由于Activity
生命周期面临一些问题。它经常作为Activity
内的内部类使用。然后,如第四章 Memory 所讨论的,内存泄漏很容易发生。除此之外,它在Activity
内部使用,当由于配置更改而销毁Activity
的实例时,AsyncTask
仍然活跃并运行,但 UI 引用不再可用。然后,当Activity
被销毁并重新创建时,需要将AsyncTask
的结果数据缓存到某处。否则,必须再次执行AsyncTask
。
Loaders
了解到AsyncTask
的限制,Android 开始提供加载器框架,在某些情况下作为AsyncTask
的有效替代。让我们看看加载器提供了什么。
它们处理异步操作,例如从远程服务器检索数据,然后触发回调通知调用者有新数据可用。调用者可能是活动或片段。加载器与生命周期无关:无论Activity
或Fragment
在配置更改后是否被销毁并重新创建,它仍然在后台运行并通知新创建的Activity
或Fragment
实例。此外,如果在配置更改之前后台工作已完成,加载器将缓存后台产生的数据,无论如何通知新实例。这种与活动生命周期无关的特殊功能意味着加载器与活动本身之间没有连接:因此,加载器使用应用程序上下文,降低了活动泄漏的风险。
LoaderManager
每个Activity
或Fragment
都有一个且仅有一个LoaderManager
。可以通过以下Activity
和Fragment
的方法来获取:
getLoaderManager();
LoaderManager
类处理一些关于加载器的操作,如下方法所述:
-
initLoader(int id, Bundle args, LoaderCallbacks<D> cb)
:这初始化一个加载器,为其分配一个 ID,传递额外的参数,并指定如何处理回调。如果已经存在具有相同 ID 的加载器,它将被使用,而不是创建另一个。 -
restartLoader(int id, Bundle args, LoaderCallbacks<D> cb)
:这将会重新启动一个加载器,如果指定的 ID 没有关联的加载器,则创建一个新的加载器,传递额外的参数和回调实例以处理响应。 -
getLoader(int id)
:这返回具有指定 ID 的加载器。 -
destroyLoader(int id)
:这停止具有指定 ID 的加载器。
LoaderCallbacks
用于处理加载器操作结果的回调接口是由以下方法组成的:
-
onCreateLoader(int id, Bundle args)
:这返回一个新的加载器。 -
onLoadFinished(Loader<D> loader, D data)
:这通知加载器完成了后台操作,并将结果传递出去。 -
onLoaderReset(Loader<D> loader)
:这通知加载器已被重置,数据不再可用。
提供的加载器
使用加载器时,我们需要使用CursorLoader
或创建加载器的子类或其他一些加载器专业化,如AsyncTaskLoader
。让我们看看这些选项和区别。
AsyncTaskLoader
这个加载器用于使用包装的AsyncTask
进行后台工作,我们知道,它处理通过工作者线程和 UI 线程的数据传递。然而,它是一个抽象类,因为我们需要覆盖AsyncTaskLoader.loadInBackground()
方法,告诉类哪些操作必须在工作者线程内执行:
public class MyAsyncTaskLoader extends AsyncTaskLoader<Result>{
@Override
public Result loadInBackground() {
//code...
return result;
}
}
因此,AsyncTaskLoader
可以用于Activity
或Fragment
类所需的每个后台操作。
CursorLoader
CursorLoader 是一个专门用于从ContentProvider
检索数据的工具,因此,如果没有ContentProvider
来存储数据,这不是使用加载器的正确选择。然而,它是AsyncTaskLoader<Cursor>
的一个实现。那么,它有助于在工作者线程中查询ContentProvider
,而不会影响 UI。它旨在与CursorAdapter
或SimpleCursorAdapter
一起使用,以简化活动开发:例如,看看以下代码段:
public class CursorLoaderActivity extends ListActivity implements LoaderManager.LoaderCallbacks<Cursor>{
private static final int CURSOR_LOADER_ID = 0;
private SimpleCursorAdapter simpleCursorAdapter;
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
simpleCursorAdapter = new SimpleCursorAdapter(this,
android.R.layout.simple_list_item_1, null,
new String[] { "name" },
new int[] { android.R.id.text1}, 0);
setListAdapter(simpleCursorAdapter);
getLoaderManager().initLoader(CURSOR_LOADER_ID, null, this);
}
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new CursorLoader(this, URI, null, null, null, "name ASC");
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
simpleCursorAdapter.swapCursor(c);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
simpleCursorAdapter.swapCursor(null);
}
}
使用时机
加载器框架增强了AsyncTask
的功能,使我们不必担心活动或片段的生命周期,并为我们缓存数据。因此,它是使用AsyncTask
的有效替代方案。然而,加载器的管理比AsyncTask
更容易。在光标情况下的专业化也易于使用。
注意
当我们需要获取数据时,AsyncTaskLoader
是一个正确的选择:它提供了AsyncTask
的相同功能,加上活动生命周期的独立性和数据缓存。因此,在应用程序的响应性和稳定性方面有性能提升。
服务
服务是 Android 平台提供的主要组件之一,因此您需要在清单文件中声明它。与活动不同,服务没有 UI 要处理。然后,其主要目的是在后台执行长时间运行的操作。但是,我们需要另一种创建和控制工作线程的方法吗?
想想我们在前面几页看到的所有其他方式:它们依赖于 UI 更新的活动生命周期。而且,这里出现了服务。它是一个独立的对象,可以在没有限制和用户交互的情况下在后台使用,然后,不需要用户界面。因此,不需要与用户交互的大量操作可以在服务中执行。
提示
在处理服务时需要记住的最重要的事情是,它们不是线程,相反,它们默认在 UI 线程上执行。因此,在创建新线程之前,永远不要在服务中启动长时间运行的操作:它会影响应用程序的所有 UI。然后,当用户在 UI 上执行其他操作时,可能会显示一个应用程序无响应对话框。
生命周期
作为活动,服务有两个方法来标识其创建和销毁。此外,这些方法与活动的名称相同:
public class LocalService extends Service {
@Override
public void onCreate() {
super.onCreate();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return super.onStartCommand(intent, flags, startId);
}
@Override
public boolean onUnbind(Intent intent) {
return super.onUnbind(intent);
}
@Override
public void onDestroy() {
super.onDestroy();
}
}
Service
类是抽象的,唯一需要覆盖的方法是Service.onBind()
。但是,它是用来做什么的呢?让我们从生命周期的角度定义两种类型的服务:
-
启动服务:使用
Context.startService()
方法或Intent
启动服务,并且它将一直保持活跃状态,直到调用了Context.stopService()
或Service.stopSelf()
方法。 -
绑定服务:当另一个组件请求与它绑定时,服务开始启动,并且只要至少有一个外部组件与之绑定,它就会保持活跃状态。当不再绑定到其他组件时,它将被销毁。
由于启动的服务在其生命周期中的任何时刻都可能被绑定,因此两者之间没有明确的界限。然而,即使所有其他绑定的组件都消失了,它仍然会保持活跃状态。
启动服务
当我们想要创建一个启动服务时,无论如何都必须覆盖Service.onBind()
方法,因为它是一个抽象方法。因此,如果我们不希望它被绑定,可以将其留空,返回 null。接下来我们将看到如何绑定服务。相反,我们需要覆盖的是Service.onStartCommand()
方法。这个方法有三个参数:
-
Intent intent
:这是在调用Context.startService()
方法时向服务提供额外信息的方式。 -
int flags
:用于确定传递的意图类型。我们将在本节后面看到它。 -
int startId
:这是调用者的 ID。它可以用来判断是否从同一组件重新启动或终止后重新启动。
我们已经知道系统可以根据进程优先级基于策略开始销毁进程。在这种情况下,我们的服务可能会被终止,它正在执行的背景操作可能不会完成。这就是为什么Service.onStartCommand()
方法需要返回一个整数值的原因。这样我们可以指定我们希望系统处理服务本身意外终止的方式。该方法可能返回的值如下:
-
START_STICKY
:使用此标志,在终止后服务将被重新创建。为了重新创建,系统会发送一个 nullIntent
。在Service.onStartCommand()
方法中使用它之前,检查是否为 null。当服务在意外终止后需要重新启动以完成一些工作时,考虑使用它。 -
START_NOT_STICKY
:除非通过正常的Context.startService()
方法调用新的Intent
类或新的Intent
与Service IntentFilter
匹配,否则服务在意外终止后不会被重新创建。然后,不会有 null 意图被触发到方法中。当服务在意外终止时不需要重新启动以完成某些工作时使用。 -
START_REDELIVER_INTENT
:当服务因调用Service.stopSelf()
方法或Context.stopService()
以外的其他原因而终止时,服务将使用最后一次调用Service.onStartCommand()
方法的意图重新启动。当我们需要知道哪个操作因终止而中断时使用。
根据采用重启服务的策略使用前面的常量,作为Service.onStartCommand()
参数传递的Intent
可能有不同的含义。让我们看看可能的值:
-
0
:这是默认值,意图通常像第一次一样传递。 -
START_FLAG_REDELIVERY
:由于重传策略,Intent
类已被重新传递。它之前已经被赋予过,但在处理之后,服务意外停止了。因此,意图再次传递,此标志有助于了解这一事实。 -
START_FLAG_RETRY
:意图即将传递给服务,但已被终止,然后再次传递此标志的意图。这一次,我们可以知道服务从未处理过意图,与之前的情况相反。
让我们看看一个启动服务的实现示例。记住它是在 UI 线程上执行的,然后我们需要创建必要的线程来运行长时间运行的操作,以不影响 UI,并且不要忘记从第四章,内存关于内部类和内存影响的教训:
public class MyService extends Service {
private Thread thread;
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
switch (intent.getAction()) {
case "action1":
handleAction1();
break;
}
return START_NOT_STICKY;
}
private void handleAction1() {
thread = new Thread(new MyRunnable());
thread.start();
}
private static class MyRunnable implements Runnable {
@Override
public void run() {
//code...
}
}
}
在这个例子中,我们使用了经典线程,但对于不同线程之间的通信,我们可以根据需要使用Handler
或HandlerThread
对象或Executor
框架或AsyncTask
。
何时使用
启动的服务有助于处理多个同时请求。你将不得不设计你的多线程策略,因为它是在 UI 线程中执行的,但从线程的角度来看,这是最灵活的组件。
绑定服务
在谈到绑定服务时,我们需要定义一个客户端和一个服务器端。服务是这种客户端服务器架构中的服务器,而活动或其他服务则是客户端。因此,我们需要一个接口来让它们正确地通信。平台提供了Context.bindService()
方法。
如前所述,绑定服务持有对客户端的引用,当不再有客户端被引用时,服务会自动终止。当我们需要在多个活动之间共享后台操作,而无需关闭服务时,这种行为非常有用,因为它会自动终止。
从服务器客户端的角度来看,绑定服务生命周期仅由两个方法组成:
-
Service.onBind()
-
Service.onUnbind()
与普遍看法相反,这些方法并不是每次服务绑定到客户端或从同一客户端解绑时都会被调用;Service.onBind()
方法只在第一个客户端时被调用,而Service.onUnbind()
方法在最后一个客户端解绑时被调用。因此,这些方法用于初始化和释放Service
对象或变量。
为了让客户端和服务器端进行通信而创建的接口,在客户端使用了ServiceConnection
接口的一个实例,在服务器端使用了绑定器。让我们看看这在两者的代码中意味着什么。以下是Service
类的代码:
public class MyService extends Service {
private final ServiceBinder binder = new ServiceBinder();
public class ServiceBinder extends Binder {
public MyService getService() {
return MyService.this;
}
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return binder;
}
}
返回我们的ServiceBinder
对象,它有一个获取对Service
类本身引用的方法,我们允许客户端获取对该引用的引用,然后调用其方法。现在让我们看看客户端的代码:
public class ClientActivity extends Activity {
private MyService myService;
private ServerServiceConnection serverServiceConnection = new ServerServiceConnection();
private boolean isBound = false;
private class ServerServiceConnection implements ServiceConnection {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
myService = ((MyService.ServiceBinder) service).getService();
isBound = true;
}
@Override
public void onServiceDisconnected(ComponentName name) {
myService = null;
isBound = false;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = new Intent(this, MyService.class);
bindService(intent, serverServiceConnection, Service.BIND_AUTO_CREATE);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (isBound) {
unbindService(serverServiceConnection);
}
}
}
ServiceConnection.onServiceConnected()
方法有一个IBinder
作为参数,然后,我们可以将其转换为在Service
类中定义的ServiceBinder
,并通过我们定义的ServiceBinder.getService()
方法来获取服务本身。
这样我们就可以在活动中使用myService
对象来调用服务的方法。当不再需要该引用时,记得调用Context.unbindService()
方法。
何时使用
如果你需要一个组件与服务之间的直接通信,绑定服务是正确的选择,因为它将启动服务的灵活性扩展到另一个组件,同时保持两个绑定组件实现的分离。
IntentService
平台提供的一个特定服务实现是IntentService
类。在某些情况下它非常有用,原因我们将会了解到。这个类包装了一个后台线程,以执行与其队列中与意图相关的不同请求。当队列变空时,IntentService
类会自动销毁。因此,它具有与Service
类不同的生命周期。它仅在后台线程中运行时处于活动状态。了解了这一点,让我们看看Service
和IntentService
之间的区别:
-
Service.onStartCommand()
方法的默认实现返回Service.START_NOT_STICKY
。因此,如果服务意外终止,将不会重新传递意图。不过,我们可以使用Service.setIntentRedelivery()
方法来更改此行为。 -
由于其生命周期,不可能绑定这样的服务。因此,没有可能为此创建 Binder,并且
Service.onBind()
方法的默认实现是返回 null。 -
与其使用
System.onStartCommand()
方法来处理传入的意图,该类提供了IntentService.handleIntent()
方法。这个方法在后台线程中执行;因此,在这种情况下,无需创建工作线程。该类为我们处理线程的创建和管理。这种线程管理是使用HandlerThread
完成的;这就是为什么会有一个顺序执行消息和 runnables 的队列。 -
如前所述,
IntentService
类不能被绑定,因此启动它的方式只能是使用Context.startService()
方法。
IntentService
类的代码如下所示:
public class MyService extends IntentService {
public MyService() {
super("MyService");
}
@Override
protected void onHandleIntent(Intent intent) {
switch (intent.getAction()) {
case "action1":
handleAction1();
break;
}
}
private void handleAction1() {
//code...
}
}
何时使用
当你需要在一个单独的线程中在后台执行顺序操作,并且不需要处理Service
的生命周期时,IntentService
类是正确的选择:它提供了进行异步操作所需的一切,而不会影响 UI。
进程间通信
两个来自不同进程的线程之间的通信并不像前一个案例那么简单,因为两个独立的进程不能共享内存,因此Handler
对象无法在两个线程上执行。在这种情况下,我们之前讨论的 Binder 线程帮助我们让在不同进程中的线程进行通信。
远程过程调用
框架让我们定义远程过程调用(RPC),它允许本地进程中的客户端线程像调用本地方法一样调用远程方法。图 3展示了这意味着什么:
图 3:远程过程调用方案
适当的流程如下:
-
客户端调用服务器方法。
-
数据和方法被转换成适合传输的格式。这项操作也被称为编组(marshaling)。
-
通过 Binder 线程,传输数据和方法。
-
数据和方法通过解编(demarshaling)转换回原始格式。
-
服务器端用数据执行方法,并为客户端原路返回准备结果。
需要在进程间传递的数据必须实现 Parcelable 接口。
AIDL
RPC 可以使用一种特殊的语言定义,称为Android 接口定义语言(AIDL)。客户端和服务器之间的接口在.aidl
文件中定义,其内容在客户端和服务器进程中被复制。编组(marshaling)和解编组(demarshaling)操作被委托给两个特殊的内部类,客户端侧称为Proxy,服务器侧称为Stub。在这种情况下,图 3的方案变成了图 4的方案:
图 4:Android 接口定义语言方案
要使用这种语言,你需要在.aidl
文件中定义带有方法签名的接口。例如,查看以下在.aidl
文件中的声明:
interface IRemoteInterface {
boolean sendResult(in Result result);
}
然后,这将转换成一个.java
文件,并在进程间共享。因此,RemoteService
类可以以这种方式拥有其存根的实例:
public class RemoteService extends Service {
private final IRemoteInterface.Stub binder = new IRemoteInterface.Stub() {
@Override
public boolean sendResult(Result result) throws RemoteException {
return false;
}
};
public RemoteService() {
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
}
此外,最后,客户端活动可以绑定远程服务,并按以下方式调用接口的方法:
public class AidlActivity extends Activity implements View.OnClickListener{
private boolean bound = false;
private IRemoteInterface mIRemoteService;
private ServiceConnection mConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
mIRemoteService = IRemoteInterface.Stub.asInterface(service);
bound = true;
}
public void onServiceDisconnected(ComponentName className)
{
mIRemoteService = null;
bound = false;
}
};
@Override
protected void onStart() {
super.onStart();
Intent intent = new Intent(AidlActivity.this, RemoteService.class);
intent.setAction(IRemoteInterface.class.getName());
bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
}
@Override
public void onClick(View v) {
if (bound) {
try {
mIRemoteService.sendResult(result);
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
}
Messenger
另一种将方法和数据发送到远程进程的方法是使用Messenger
对象。它更简单,但是单线程的,因此较慢。Messenger
对象有一个指向一个进程中Handler
对象的引用,然后另一个进程处理它。让我们从远程服务的代码开始:
public class RemoteService extends Service {
MyThread thread;
Messenger messenger;
@Override
public void onCreate() {
super.onCreate();
thread.start();
}
private void onThreadPrepared() {
messenger = new Messenger(thread.handler);
}
public IBinder onBind(Intent intent) {
return messenger.getBinder();
}
@Override
public void onDestroy() {
super.onDestroy();
thread.quit();
}
private class MyThread extends Thread {
Handler handler;
@Override
public void run() {
Looper.prepare();
handler = new Handler() {
@Override
public void handleMessage(Message msg) {
// Implement message processing
}
};
onThreadPrepared();
Looper.loop();
}
public void quit() {
handler.getLooper().quit();
}
}
}
然后,客户端Activity
使用Messenger
对象来发送消息:
public class MessengerActivity extends Activity implements View.OnClickListener {
private boolean bound = false;
private Messenger remoteService = null;
private ServiceConnection connection = new ServiceConnection()
{
public void onServiceConnected(ComponentName className, IBinder service) {
remoteService = new Messenger(service);
bound = true;
}
public void onServiceDisconnected(ComponentName className)
{
remoteService = null;
bound = false;
}
};
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = new Intent(action);
bindService(intent, connection, Context.BIND_AUTO_CREATE);
}
@Override
public void onClick(View v) {
if (bound) {
try {
remoteService.send(message);
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
}
高级技术
我们到此为止了解了在 Android 应用程序中处理多线程的主要技术概览。现在我们想要看看有助于提高性能的高级技术,特别是在开发者不一定清楚多线程策略如何工作的情况下,将昂贵的操作从 UI 线程移动到工作线程。
BroadcastReceiver
异步技术
BroadcastReceiver
类是 Android 平台另一个主要组件。它与其他主要组件的区别在于其生命周期短暂。BroadcastReceiver
类仅在执行BroadcastReceiver.onReceive()
方法时处于活动状态。其主要用途是接收消息。因此,其生命周期短暂。然后,这个组件并非用于执行长时间运行的操作。然而,它非常适合用于启动后台任务,例如启动IntentService
:
public class MyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Intent sericeIntent = new Intent();
sericeIntent.setClass(context, MyService.class);
sericeIntent.setAction(MyService.ACTION);
context.startService(sericeIntent);
}
}
从 Android Honeycomb(API 级别 11)开始,平台提供了一种特殊的方法来扩展 BroadcastReceiver
类的生命周期,并等待后台线程结束:调用 BroadcastReceiver.goAsync()
方法,会返回一个 PendingResult
对象。这个对象用于处理后台线程的状态。直到调用了 PendingResult.finish()
方法,接收器的生命周期才会结束。这是至关重要的:如果你打算在线程完成任务时使用这种特殊技术,请调用 PendingResult.finish()
方法来释放 BroadcastReceiver
类。否则,接收器将不会被关闭,导致内存泄漏,并在下一次接收广播事件时产生预期之外的结果。让我们看看使用这种技术的代码:
public class AsyncReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case "myAction":
final PendingResult pendingResult = goAsync();
new Thread() {
public void run() {
// Do background work
pendingResult.finish();
}
}.start();
break;
}
}
}
ContentProvider 异步技术
ContentProvider
类是另一个主要组件,用于跨其他主要组件、进程和应用共享数据。其主要目的是持有数据库以共享信息。大多数时候,提供者是不同进程中的远程对象。因此,不直接访问提供者,而是使用 ContentResolver
对象来查询、插入、删除和更新提供者。这种方式处理了进程间通信。
ContentProvider
类无法知晓同一时间发生多少并发修改。因此,需要线程安全,因为需要查询数据的连贯性。幸运的是,SQLite 数据库是锁定的,因此它是线程安全的。此外,SQLiteDatabase 类有一个名为 SQLiteDatabase.setLockingEnabled()
的方法,可以改变数据库的线程安全行为。其默认值为 true
,并且已经被弃用,从 Android JellyBean(API 级别 16)开始甚至被禁用,因此你不能从数据库访问中移除锁和线程安全。不过,你可以使用 SQLiteDatabase.enableWriteAheadLogging()
方法来启用 SQLiteDatabase 的并行数据写入。这样,写入操作在与读取操作在不同的日志文件中执行时进行,以实现并行读写执行。因此,读者将读取到的值是在写入操作开始前的状态。这种同时让多个线程访问的方式从内存角度来看是昂贵的,因为后台在写入时会复制数据。因此,只有在你严格需要多个线程访问数据库时才使用它。在所有其他用例中,数据库访问的默认锁实现就足够了。
当我们需要对ContentProvider
进行操作时,应避免在 UI 线程上执行,这些操作可能会很耗时并阻塞 UI。我们在讨论CursorLoader
时已经涉及了后台数据库查询:CursorLoader
对象仅用于从数据库中读取。然而,现在我们要处理ContentProvider
,并且没有直接访问权限。此外,我们还希望对其进行写入以及读取操作。Android 提供了一个特定的 API 来完成这项工作:我们讨论的是AsyncQueryHandler
类。它包装了ContentResolver
,以在ContentProvider
上启动异步操作。
AsyncQueryHandler
是Handler
的抽象子类。它没有抽象方法,但我们可以定义如何处理不同的读写操作完成。以下是AsyncQueryHandler
的回调:
public class MyAsyncQueryHandler extends AsyncQueryHandler {
public MyAsyncQueryHandler(ContentResolver cr) {
super(cr);
}
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
}
@Override
protected void onInsertComplete(int token, Object cookie, Uri uri) {
}
@Override
protected void onUpdateComplete(int token, Object cookie, int result) {
}
@Override
protected void onDeleteComplete(int token, Object cookie, int result) {
}
}
启动对ContentResolver
对象特定请求执行的方法如下所示。操作完成后,将调用上面指定的相应回调方法:
-
startQuery(int token, Object cookie, Uri uri, String[]projection, String selection, String[] selectionArgs, String orderBy)
-
startInsert(int token, Object cookie, Uri uri, ContentValues initialValues)
-
startUpdate(int token, Object cookie, Uri uri, ContentValues values, String selection, String[] selectionArgs)
-
startDelete(int token, Object cookie, Uri uri, String selection, String[] selectionArgs)
传递给前面方法的是与相关回调方法中作为参数传递的相同 token。这样我们可以知道调用者是谁,然后执行特定的操作而不是其他操作。如果我们想取消一个特定的操作,这很有用:我们可以通过调用AsyncQueryHandler.cancelOperation()
方法来实现。现在让我们看看如何在Activity
中使用它:
public class MyAsyncQueryHandler extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AsyncQueryHandler asyncQueryHandler = new AsyncQueryHandler(getContentResolver()) {
@Override
protected void onDeleteComplete(int token, Object cookie, int result) {
//code to handle the delete operation...
}
@Override
protected void onUpdateComplete(int token, Object cookie, int result) {
//code to handle the update operation...
}
@Override
protected void onInsertComplete(int token, Object cookie, Uri uri) {
//code to handle the insert operation...
}
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
//code to handle the query operation...
}
};
asyncQueryHandler.startQuery(1, null,
contentUri,
projection,
selectionClause,
selectionArgs,
sortOrder);
}
}
AsyncQueryHandler
类只是一个处理器,其回调方法是从创建AsyncQueryHandler
对象的线程中调用的,而操作是在工作线程中完成的。
提示
每当处理ContentProvider
时,选择AsyncQueryHandler
是正确的,它可以释放 UI 线程不必要的操作,并将工作线程委托给ContentResolver
处理。这样,你可以提高应用程序的 UI 性能。此外,它易于使用,让我们无需处理Looper
和MessageQueue
。
重复任务
在我们的开发经验中,很多时候我们需要启动一个周期性任务。但是,采用的战略是正确的吗?从性能角度来看,它可以改进吗?让我们检查一下,我们有哪些选项来创建一个周期性定时器,以启动后台操作而不影响 UI 线程。
定时器
Timer
类是创建周期性任务最常用的方法:
Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
//code...
}
}, delay, period);
Timer
对象创建了一个线程,用于执行周期性任务的代码。因此,TimerTask
不是在主线程上执行的。
完成后,必须使用Timer.cancel()
方法取消Timer
,以释放资源,否则可能会无限期地占用这些资源。这个 API 可以用于短周期的周期性任务。
ScheduledExecutorService
Executor
框架的这种特定实现允许我们按固定间隔安排重复任务。可以以下列方式完成:
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
//code...
}
}, delay, period, TimeUnit.SECONDS);
当不再需要执行时,调用ScheduledExecutorService.shutdown()
或ScheduledExecutorService.shutdownNow()
。
这个比Timer
API 更灵活且功能强大。因此,对于短周期的周期性任务,应该优先考虑使用它。
AlarmManager
AlarmManager
对象可以用来在特定时间启动新的组件,以开始重复操作:
AlarmManager alarmManager = (AlarmManager) getSystemService(Activity.ALARM_SERVICE);
Intent intent = new Intent();
//intent preparation...
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, intervalMillis, pendingIntent);
我们可以使用两种方法来启动新的重复闹钟:
-
setRepeating()
-
setInexactRepeating()
AlarmManager
类由于其内部检查系统状态,比其他类更高效,但它不适合短周期任务。因此,在可能的情况下,用它代替Timer
和Executor
框架,考虑到它的限制。记得在重新启动完成后恢复闹钟:你可以使用与Intent.ACTION_BOOT_COMPLETED
一起使用的BroadcastReceiver
来通知此事件。
调试工具
我们已经了解了创建多线程应用程序的不同技术以及何时使用它们。使用正确的结构取决于许多不同因素;这需要开发者珍惜我们所讲的,并在每种情况下应用适当的框架。然而,我们的主要目标是向用户提供流畅的 UI,避免出现应用程序无响应对话框、延迟以及 UI 线程正确执行过程中的任何障碍。为此,Android 提供了一些工具,我们将在接下来的页面中看到。
StrictMode
在第四章 内存 中讨论内存泄漏时,我们已经处理过这个工具。然而,这个工具还可以帮助我们查找并通知线程问题。
要使用它,我们需要知道我们在寻找什么以及如何获知正在发生的线程问题。为此,我们需要将ThreadPolicy
设置为StrictMode
类,使用ThreadPolicy.Builder
类。这样,我们可以获知以下发生的问题:
-
detectCustomSlowCalls()
-
detectDiskReads()
-
detectDiskWrites()
-
detectNetwork()
-
detectResourceMismatches()
-
detectAll()
我们获知的方式取决于我们调用的方法。我们可以从以下选项中选择:
-
penaltyDeath()
-
penaltyDeathOnNetwork()
-
penaltyDialog()
-
penaltyDropBox()
-
penaltyFlashScreen()
-
penaltyLog()
因此,以下代码段是我们应该执行的检查任何线程问题的好例子:
if (BuildConfig.DEBUG) {
StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build();
StrictMode.setVmPolicy(policy);
}
概要
从线程的基本定义出发,我们通过 Java 线程框架,谈及了 Android 进程管理、线程类型以及消息框架。我们分析了多线程环境中的陷阱,定义了线程安全。指出了在一个应用程序中我们可以用多个线程做什么,从多线程性能的角度描述了 Android 开发者追求的主要目标。UI 线程只需处理 UI,任何其他操作都应该在后台使用工作线程执行。因此,我们评估了平台为各种情况提供的许多不同解决方案,定义了何时应该或不应使用它们。总之,选择正确的框架取决于开发者所面对的具体情况,但是,了解了所有可能性后,他更有机会提升应用程序的性能。在本章的最后,我们了解了有哪些工具可以帮助我们检测线程异常,以保持应用程序的响应性。