精通安卓游戏开发-全-

精通安卓游戏开发(全)

原文:zh.annas-archive.org/md5/021B82B2841EB90A5AA147BA73C3393A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是学习高级 Android 应用开发的实用指南。本书帮助掌握 Android 的核心概念,并在实际项目中快速应用知识。在整本书中,创建了一个应用,并在每一章中不断进化,以便读者可以轻松地跟随并吸收概念。

本书分为十二章。前三章专注于应用的设计,解释了设计的基本概念以及 Android 中使用的编程模式。接下来的几章旨在改进应用,访问服务器端以下载要在应用中显示的信息。一旦应用功能完成,它将使用 Material Design 组件和其他第三方库进行改进。

在完成之前,应用中添加了额外的服务,如位置服务、分析、崩溃报告和盈利化。最后,导出应用,解释不同的构建类型和证书,并将其上传到 Play 商店,准备进行分发。

本书涵盖的内容

第一章, 入门, 介绍了 Android 6 Marshmallow 的基础知识和 Material Design 的重要概念。我们将设置开始开发所需的工具,并且可选地安装一个比 Android 默认模拟器更快的超快模拟器,这将帮助我们在书中测试我们的应用。

第二章, 设计我们的应用, 介绍了创建应用的第一步——设计导航——以及不同的导航模式。我们将应用带有滑动屏幕的标签页模式,解释并使用 Fragments,这是 Android 应用开发的一个关键组件。

第三章, 从云端创建和访问内容, 涵盖了在我们的应用中显示互联网信息所需的一切。这些信息可以在外部服务器或 API 上。我们将使用 Parse 创建自己的服务器,并使用 Volley 和 OKHttp 进行高级网络请求来访问它,处理信息并使用 Gson 将其转换为可用的对象。

第四章, 并发和软件设计模式, 讨论了 Android 中的并发性和处理它的不同机制,如 AsyncTask、服务、加载器等。本章的下半部分讨论了在 Android 中最常见的编程模式。

第五章, 列表和网格, 讨论了列表和网格,从 ListViews 开始。它解释了这一组件如何在 RecyclerView 中演变,并作为一个示例,展示了如何创建带有不同类型元素的列表。

第六章,CardView 和材料设计,专注于从用户界面角度改进应用,并引入材料设计,解释并实现如 CardView、Toolbar 和 CoordinatorLayout 等功能。

第七章,图像处理和内存管理,主要讨论了如何在我们应用中显示从互联网上下载的图片,使用不同的机制,如 Volley 或 Picasso。它还涵盖了不同类型的图像,如矢量可绘制图像和 Nine patch。最后,它讨论了内存管理以及预防、检测和定位内存泄漏。

第八章,数据库和加载器,主要解释了安卓中数据库的工作原理,内容提供者是什么,以及如何使用 CursorLoaders 让数据库直接与视图通信。

第九章,推送通知和分析,讨论了如何使用 Google Cloud Messaging 和 Parse 实现推送通知。章节的下半部分讨论了分析,这对于理解用户如何与我们的应用互动,捕获错误报告以及保持应用无虫至关重要。

第十章,位置服务,通过在应用中实现一个示例来介绍 MapView,从开发者控制台初始设置到应用中最终显示位置标记的地图视图。

第十一章,在安卓上的调试和测试,主要讨论测试。它涵盖了单元测试、集成测试和用户界面测试。还讨论了使用市场上不同的工具和最佳实践,通过自动化测试开发可维护的应用程序。

第十二章,货币化、构建过程和发布,展示了如何实现应用的货币化,并解释了广告货币化的关键概念。它展示了如何导出具有不同构建类型的应用,并最终如何在 Google Play 商店上传和推广此应用。

阅读本书所需的条件

你的系统必须具备以下软件才能执行本书中提到的代码:

  • 安卓工作室 1.0 或更高版本

  • Java 1.7 或更高版本

  • 安卓 4.0 或更高版本

本书的目标读者

如果你是一名有 Gradle 经验的 Java 或项目开发者,并希望成为专家,那么这本书就是为你而写的。对 Gradle 的基本了解是必不可少的。

约定

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

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名和虚拟 URL 将如下显示:"我们可以通过使用include指令包含其他上下文。"

代码块设置如下:

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

当我们需要引导您关注代码块中的特定部分时,相关的行或项目会以粗体显示:

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

新术语重要词汇以粗体显示。您在屏幕上看到的词,例如菜单或对话框中的,会像这样在文本中显示:"点击下一步按钮,将进入下一个屏幕。"

注意

警告或重要提示会以这样的框显示。

提示

技巧和诀窍会像这样显示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它能帮助我们开发出您真正能从中获得最大收益的图书。

要向我们发送一般反馈,只需将电子邮件发送至<feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。

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

客户支持

既然您已经拥有了 Packt 的一本书,我们有许多方法可以帮助您从购买中获得最大收益。

下载示例代码

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

下载本书的彩色图片

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

勘误

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

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

盗版

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

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

我们感谢您帮助保护我们的作者以及我们为您带来有价值内容的能力。

问题

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

第一章:开始入手

我们将以 Material Design 和 Android 6 Marshmallow 的概述来开篇。谷歌推出的新的 Material Design 概念,在应用的外观和感觉上带来了一场革命。

在本书的进行过程中,我们将构建一个名为 MasteringAndroidApp 的应用。在本章中,我们将解释这个应用的内容。在这个应用中,我们将实践所有章节中解释的概念和理论。在本书结束时,我们应该拥有一个丰富的应用,功能齐全,可以轻松修改以创建您自己的版本,并上传到 Google Play 商店。

我们将确保拥有所有必要的工具,下载最新版本的 Android 并介绍Genymotion,这是本书强烈推荐的最快的 Android 模拟器。

  • Material Design

  • Android 6 Marshmallow 的关键要点

  • 应用概述

  • 准备工具

    • Android Studio

    • SDK 管理器

  • Genymotion

介绍 Material Design

如前所述,Material Design 在应用的外观和感觉上带来了一场革命。你可能之前听说过这个概念,但确切来说它是什么呢?

Material Design 是由谷歌创建的一种新的视觉语言,它被用在所有基于材料、有意义的过渡、动画和响应式交互的平台、网站和移动设备上。

材料是一个可以在表面看到的元素的隐喻,它由可以具有不同高度和宽度的层组成,但它们的厚度始终是一个单位,就像纸张一样。我们可以将材料一个放在另一个上面,为视图引入深度元素,一个Z坐标。同样,我们可以在另一张纸上放置一张纸,投下阴影,定义视觉优先级。

内容显示在材料上,但它们不会增加其厚度。内容可以以任何形状和颜色显示;它可以是一个纯背景色、文本、视频,还有许多其他东西。它被限制在材料的边界内。

材料可以扩展,内容也可以随之扩展,但内容永远不能超过材料的扩展范围。我们不能有两个处于同一Z位置的材质。其中一个总是要在另一个下面或者上面。如果我们与材料互动,我们总是在最顶层进行。例如,触摸事件将在最顶层执行,不会穿透到底层。你可以改变材料的尺寸和形状,两种材料可以合并成一个,但它们不能弯曲或折叠。

介绍 Material Design

这是一个使用 Material Design 风格的应用示例;我们可以看到带有阴影的卡片、不同的内容和一个带有导航抽屉的应用栏。所有这些组件都将在本书中进行解释,我们将致力于使用相同风格构建一个应用。

材料设计也带来了重要的用户界面元素,如 RecyclerView。这是一个视图,将替代早期 Android 中的 ListView,以创建任何类型的可滚动元素列表。我们将在第五章 列表和网格中处理这些组件,从 ListView 的基本版本开始,演进到理解 RecyclerView 是如何诞生的,并以一个示例结束。

CardView 是另一个引入的主要用户界面元素。我们可以在前面的图片中看到一个;这是一个带有背景和阴影的组件,可以自定义以适应我们想要的所有内容。我们将在第六章 CardView 和材料设计中处理它,在那里我们还将介绍下一个组件——设计支持库。

设计支持库是一个包含动画、FAB浮动操作按钮)和导航抽屉的必备库。你可能已经在其他应用中见过从左侧滑出的滑动菜单。设计支持库为旧版 Android 提供了这些组件的支持,使我们能够在旧版本中使用材料设计特性。

所有上述内容都是从用户界面和编程的角度来看的特性,但材料设计还为我们的手机引入了不同的功能,例如带有不同优先级级别的新通知管理系统。例如,我们可以指定哪些通知是重要的,并建立一个时间段,在这个时间段内我们不想被打扰。

介绍材料设计

另一个我们不能忽视的是这一版本在电池消耗方面的改进,与之前的 Android 版本相比,它可以节省高达 90 分钟的电池电量,这是由于一个新的 Android 运行时 ART。用非技术性的方式来解释,它将应用程序翻译成一种语言,当应用程序安装时,Android 可以更快地理解这种语言。之前的运行时 Dalvik 在执行我们的应用程序时必须进行这种翻译,而不是只在安装时翻译一次。这有助于应用程序消耗更少的电池并运行得更快。

介绍 Android 6 Marshmallow

这一版本的主要变化之一与应用程序的权限有关。在 Android M 之前,我们习惯于在下载应用程序之前接受应用程序的权限;播放商店向我们展示了应用程序具有的权限列表,我们需要接受它们才能下载和安装。

介绍 Android 6 Marshmallow

运行时权限

随着运行时权限的引入,这一切都改变了。这里的理念是只在需要时接受权限。例如,当你拨打电话或留下语音信息之前,WhatsApp 可能不需要访问你的麦克风。

运行时权限

这是在我们开发应用时需要考虑的一件事;这对开发者来说是一个改变,现在需要控制如果用户不接受权限,应该做什么。以前,我们不需要进行任何控制,因为在安装时这是一个全有或全无的选择;现在,我们必须要考虑运行时用户的决定。

提示

下载示例代码

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

节能优化

自从 Lollipop 版本以来,我们的手机电池寿命有了另外一项改进;这次,谷歌引入了两种新的状态:休眠模式应用待机

休眠模式提高了空闲设备的睡眠效率。如果我们关闭屏幕且没有使用手机,我们将进入空闲状态。以前,应用可以在后台进行网络操作并继续工作;现在,随着休眠模式的引入,系统会定期允许我们的应用在后台工作一段时间,并执行其他挂起的操作。这同样在开发时带来了一些考虑;例如,在这种模式下,我们的应用无法访问网络。

节能优化

应用待机是一种针对一段时间未被使用且后台没有运行任何进程的应用的诱导空闲模式。如果一个应用没有显示任何通知,且用户没有明确要求它免除优化,那么就会为这个应用启用待机模式。这种空闲模式防止应用访问网络和执行挂起的任务。当连接电源线时,所有处于待机状态的应用都会被释放,空闲限制也会被移除。

文本选择

在之前的版本中,当用户选择文本时,动作栏上会出现一组操作,如复制、剪切和粘贴。在这个版本中,我们可以在一个浮动的栏中显示这些操作以及更多内容,该浮动栏将显示在选定内容的上方:

文本选择

指纹验证

在这个版本的 Android 中,我们可以验证指纹的使用。这种验证可以在设备级别进行,以解锁手机,而不仅仅是解锁某个特定的应用;因此,我们可以根据用户最近解锁设备的情况,在我们的应用中验证用户。

我们有一个新的可用对象,FingerprintManager,它将负责身份验证,并允许我们显示一个请求指纹的对话框。我们需要一个带有指纹传感器的设备才能使用这个功能。

指纹验证

直接分享

直接分享是一个新增加的功能,用以简化内容分享过程。以前,如果我们处于图库中,并希望将图片分享给 WhatsApp 中的联系人,我们必须点击分享,在应用列表中找到 WhatsApp,然后在 WhatsApp 中找到联系人来分享内容。这个过程将被简化,直接显示一个你可以直接分享信息的联系人列表:

直接分享

这些是与 Android 6 Marshmallow 一起发布的主要新功能;完整列表可以在developer.android.com/preview/index.html查看。

创建 MasteringAndroidApp

既然我们已经了解了最新 Android 版本的主要功能,我们可以介绍在本书中将要开发的应用程序了。这个应用程序将包括这些功能的大部分,但我们也会花时间在之前 Android 版本中广泛使用的组件上。

要掌握 Android,我们应该准备好理解遗留代码;例如,我们可能需要在仍然使用ListView而不是新出的RecyclerView的应用程序上工作。我们不会总是用最新的组件从零开始创建应用程序,特别是如果我们是专业的 Android 开发者。同时,查看之前的组件将帮助我们理解这些组件的自然演变,以便更好地了解它们现在的样子。

我们将从零开始创建这个应用程序,从最初的设计开始,看看在 Android 中最常用的设计和导航模式,比如顶部的标签,左侧的滑动菜单等等。

我们将要开发的MasteringAndroidApp应用程序,是一个与服务器端交互的应用程序。这个应用程序将展示存储在云中的信息,我们将创建云组件,使我们的应用程序能够与之通信。我们为应用程序选择的主题是职位公告板,我们将在服务器端创建职位邀请,应用程序的用户可以阅读这些邀请并接收通知。

您可以轻松地自定义主题;这将是一个示例,您可以更改信息并使用相同的结构创建自己的应用程序。实际上,如果你能想出自己的点子会更好,因为我们将讨论如何在 Play 商店发布应用程序以及如何实现盈利;我们将添加广告,当用户点击广告时会产生收入。所以,如果你使用自己的想法来应用所学内容,等到你读完这本书时,你将拥有一个准备发布的应用程序。

我们将开发一个解释在 Android 中最常用的编程模式以及并发技术和连接到 REST API 或服务器不同方法的应用程序。

我们不仅关注后端,也关注 UI;通过有效的方式展示信息,使用列表和网格,从互联网上下载图片,以及使用最新的材料设计特性来自定义字体和视图。

我们将学习如何调试我们的应用,管理日志,并在学习如何识别和防止内存泄漏时考虑内存使用情况。

我们的应用将有一个基于数据库的离线模式,我们将把云中的内容存储在这里。这样,如果手机失去连接,我们仍然可以显示上次在线时可用信息。

为了完善我们的应用,我们将添加额外的功能,如推送通知、崩溃报告和分析。

最后,我们将了解 Android 构建系统的工作原理,以不同版本导出我们的应用,同时混淆代码以保护它,防止被反编译。

我们压缩了大量信息,这将帮助您在本书结束时掌握 Android;然而,在开始我们的应用之前,让我们先准备好工具。

准备工具

在本书中,我们将需要的工具包括最新版本的 Android Studio、更新至 Android M 或更高版本的 Android SDK。还建议您使用Genymotion,这是一个用于测试应用的模拟器。

注意

首先,我们需要下载并安装 Android Studio,这是在 Android 上进行开发的官方工具。可以从developer.android.com/sdk/index.html下载。

在网站顶部,您可以根据您的操作系统版本找到一个下载链接。

准备工具

安装完成后,我们需要下载一个 Android M SDK,它将提供开发特定 Android 版本应用所需的全部类和资源。这是通过 SDK 管理器完成的,它是 Android Studio 内包含的一个工具。

我们可以点击工具 | Android | SDK 管理器,或者在 Android Studio 最上方的工具栏中找到快捷方式。

准备工具

一旦我们打开 SDK 管理器,我们会看到可用 SDK 平台和 SDK 工具的列表。我们需要确保已安装最新版本。

准备工具

这样,我们就有了我们开发应用所需的一切。为了测试它,理想情况下应该有 Genymotion,这是一个 Android 模拟器,它将帮助我们测试应用在不同设备上的运行情况。

我们选择使用这个模拟器而不是 Android 默认模拟器的主要原因在于速度。在 Genymotion 中部署应用甚至比使用物理设备还要快。除此之外,我们还受益于其他功能,比如可调整大小的窗口、从我们的电脑复制和粘贴内容,以及一些在使用默认模拟器时会消耗时间的小细节。可以从www.genymotion.com下载。

我们需要做的就是安装它,一旦打开,我们就可以添加具有现有设备包含功能的模拟器。

准备工具

总结

在本章中,我们回顾了最新 Android 版本中的重要变化,重点介绍了 Android Marshmallow 和 Material Design。

我们解释了在本书的学习过程中将要构建的应用程序中的功能以及创建它所需的工具。

在下一章中,我们将研究 Android 中现有的设计模式并开始设计我们的应用程序。

第二章:设计我们的应用程序

在本章中,我们将为应用程序想出一个创意,并将这个创意转化为真实的应用程序,创建要在屏幕上显示的基本结构,并选择适当的导航模式在它们之间移动。

在查看最常用的导航模式之后,我们将继续实施由片段和ViewPager组成的标签页模式。

在此过程中,我们将回顾我们关于片段的知识,以便能够解释高级概念。我们还将讨论FragmentManager和片段回退栈的重要性。

最后,我们将在屏幕过渡中添加一些美观的动画。因此,在本章中我们将介绍以下主题:

  • 选择应用程序导航模式

  • 精通片段

  • 实施标签页和 ViewPager

  • 屏幕之间的动画过渡

选择应用程序导航模式

想象一下,有一天你醒来时充满灵感;你有一个应用程序的创意,你相信它可能比 WhatsApp 更受欢迎。不要浪费时间,你会想要将这个应用程序创意变为现实!这就是为什么学习如何设计应用程序并选择最合适的导航模式对你很重要。不是要让你失去灵感,但你会发现你的 99%的创意已经在 Google Play 商店里了。事实上,有数十万的应用程序可供选择,而且数量还在不断增加!所以,你可以选择改进已有的应用程序,或者继续头脑风暴,直到你有一个原创的创意。

为了将应用程序变为现实,第一步是在心中可视化应用程序;为此,我们需要确定基本组件。我们需要在屏幕上简化想法,并且我们需要在屏幕之间移动。

请记住,你是在为 Android 用户创建这个应用程序。这些用户习惯于使用像 Gmail、Facebook 和 Spotify 等应用程序中的滑动面板导航模式。

我们将研究三种不同的、常用的导航模式,以确保用户在使用我们的应用程序时不会迷路,并能立即理解应用程序的结构。

基本结构

为了绘制我们的屏幕(请注意,我这里不是指活动或片段;所谓屏幕是指用户在我们应用程序执行期间任何时候实际可以看到的内容),我们需要确定我们创意的关键点。我们需要用软件开发术语来确定用例。

让我们先为我们将在本书过程中构建的应用程序MasteringAndroidApp赋予形状。一开始很难在脑海中想象出所有细节,所以我们将从确定我们肯定需要的组件开始,稍后再填补可能存在的空白。

我们从上一章知道,我们有一个演示屏幕,它会在需要时从互联网下载数据的同时显示应用程序的标志几秒钟。

在这个应用中,我们还将有一个来自互联网的信息列表屏幕,用户可以点击单个项目获取更详细的信息。

作为主要选项,我们将显示一个带有MapView的联系人屏幕,显示我的位置和联系数据。

最后,我们需要一个偏好设置设置屏幕,在这里我们可以打开或关闭通知,禁用广告或购买额外内容。

现在,我们已经准备好创建一个草图。请看以下图片:

基本结构

在顶部,我们有应用程序的入口点,即启动屏幕。这里的导航很直接;我们可以直接导航到下一个屏幕,并且没有按钮或任何其他可能的流程。

在下一层,我们有项目列表的屏幕(这是一个包含联系信息的屏幕)、地图视图和设置屏幕。这三个屏幕在我们的应用中处于同一级别,因此它们具有同等的重要性。

最后,我们还有一个第三层导航,这是列表中一个条目的详细视图。

我们打开这个屏幕的唯一方式是点击列表中的一个元素;因此,这个屏幕的入口点是列表屏幕。

现在我们已经创建了一个基本的结构和流程,我们将研究不同的广泛使用的导航模式,以决定哪一种最适合我们的应用。

注意

有关应用结构和有关材料设计类似信息的更多信息,请参考以下链接:

developer.android.com/design/patterns/app-structure.html

www.google.com/design/spec/patterns/app-structure.html#

仪表板模式

仪表板模式是 Android 中最早使用的模式之一。它由一组在主屏幕上以图标矩阵形式显示的元素组成。在下面的图片中,我们可以看到左侧是 Facebook 应用的一个早期版本,而在右侧,是 Motor Trend 对这个模式的定制:

仪表板模式

这种视图非常适合那些旨在清晰显示非常有限选项的应用;每行不超过两个元素,行数适合屏幕显示。

这些图标清晰地显示了主要功能的符号,所有选项都在同一级别。它非常适合目标受众广泛的应用程序;它简单明了,一目了然,任何人都可以导航。

尽管这个设计看起来很古老,考虑到它曾在 Android 的早期版本中被广泛使用,而现在使用较少,但它的使用取决于你的需求,所以不要因此放弃。前面图片中显示的 Motor Trends 应用对这个模式有一个非常原始的实现。

如果元素不适合屏幕,我们需要滚动才能发现它们,我们需要重新考虑这个模式。当我们有太少的元素时,情况也是如此;这些情况下有更好的选择。在我们的具体示例中,我们有三个主要元素,所以我们不会使用这个模式。

滑动面板

这种模式因 Gmail 和 Facebook 等应用而广为人知。它在用户界面的顶层展示一个布局;当我们执行滑动手势或点击左上或右上按钮时,屏幕会从左或右侧滑出,这个按钮通常是一个显示三条水平线的图标,也被称为汉堡图标。

如果我们的应用在同一层级有大量选项,这种模式是完美的,并且它可以与其他模式结合使用,例如标签模式

这个面板的实现可以通过DrawerLayout类完成,它由两个子视图组成:一个带有内容和导航抽屉的FrameLayout,导航抽屉可以是ListView或任何其他包含选项的自定义布局。

为此,执行以下代码:

<android.support.v4.widget.DrawerLayout    
   android:id="@+id/drawer_layout"   
   android:layout_width="match_parent"   
   android:layout_height="match_parent" >   

   <FrameLayout   
    android:id="@+id/frame_container"   
    android:layout_width="match_parent"   
    android:layout_height="match_parent" />   

   <ListView   
    android:id="@+id/drawer_list"   
    android:layout_width="240dp" 
    android:background="#fff"  
    android:layout_height="match_parent"   
    android:layout_gravity="start" />   

  </android.support.v4.widget.DrawerLayout>

一旦我们在侧边栏选择了一个元素,屏幕中央就会出现一个子元素;这个子元素可以帮助你导航到子子元素,但不能导航到主菜单的元素。子元素和子子元素的导航可以通过后退按钮或在操作栏中使用向上导航来管理。

我们可以通过点击一个项目来关闭面板,并通过设置一个抽屉监听器ActionBarDrawerToggle来知道面板是关闭还是打开,它包含onDrawerClosed(View drawerView)onDrawerOpened(View drawerView)方法。

确保你使用了来自android.support.v7.appActionBarDrawerToggle;v4 中包含的已弃用。

这种模式的另一个大优点是,它允许通过菜单上的一个主项目进行分组导航,可以展开成子项目。正如在以下示例中可以看到,项目 4 在下拉菜单中有三个选项:

滑动面板

抽屉布局的一个示例

这种模式不适合我们的应用,因为我们没有足够的选项来充分利用这个模式。此外,由于这个模式可以与标签模式结合,从教育角度来看,用这个模式开发我们的示例更有意义。

标签

标签模式是一种你可能之前见过并使用过的模式。

它显示了一个具有同一层级的组件的固定菜单。请注意,当我们有标签时,菜单总是可见的,这在滑动和仪表板模式中不会发生。这看起来与网页界面非常相似,并且非常用户友好,考虑到用户可能已经熟悉这个模式。

以下模式有两个变体:固定标签和滑动标签。如果我们只有少量可以一次显示在屏幕上的菜单项,第一个变体将是最合适的,因为它可以一次向用户展示所有项目。

标签

当所有项目不适合屏幕或当前适合但知道未来会增加更多项目而无法容纳时,通常会使用滑动标签。

两种变体的实现略有不同,因此在决定变体时我们需要考虑未来的变化。在这里,我们可以看到一个滑动变体的实现:

标签

提示

请记住,为了平台的一致性,我们必须将标签放置在屏幕顶部;否则,人们会认为您是 iOS 开发者!

以下是一些来自材料设计指南的功能和格式规范供您遵循:

  • 将标签作为单行呈现。如果需要,将标签文本换行到第二行,然后截断。

  • 不要在标签内包含一组标签化内容。

  • 高亮显示与可见内容对应的标签。

  • 按层次结构将标签分组。将一组标签与其内容连接起来。

  • 保持标签与其内容相邻。这有助于减少两者之间的歧义,保持关系。

在以下图片中,我们可以看到一个带有子菜单的滚动/滑动标签的示例:

标签

注意

设计标签时的图形规范以及有关标签规范更多信息可以在 www.google.com/design/spec/components/tabs.html# 找到。

既然我们已经知道了应用程序导航的基础知识,我们可以探索实现这些模式所需的组件。如您所知,主要组件是活动和碎片。我们将实现带有三个碎片的滑动标签的示例。

碎片(Fragments)

在本节中,我们将简要回顾碎片的关键概念,以解释高级功能和组件,如碎片管理器和碎片后退栈。

在我们的示例中,我们将创建一个名为 MainActivity 的活动以及四个碎片:ListFragmentContactFragmentSettingsFragmentDetailsFragment。为此,您可以创建一个 fragments 包,双击该包以进入 新建 | 碎片 | 空白碎片。看看以下对话框:

碎片

目前,您可以不使用碎片工厂方法和接口回调来创建它们。我们将在本章后面介绍这些内容。

到目前为止,我们的项目在项目视图中应该看起来像这样:

碎片

理解碎片的重要性

一个碎片代表活动中的一个行为或用户界面部分。您可以在单个活动中组合多个碎片以构建多窗格 UI,并在多个活动中重用碎片。您可以认为碎片是活动的一个模块化部分,它有自己的生命周期并接收自己的输入事件,您可以在活动运行时添加或删除(有点像可以在不同活动中重用的子活动)。

理解片段的重要性

片段的生命周期与活动的生命周期略有不同。我们注意到的第一个区别是使用了 OnAttach()OnDetach() 方法,它们将片段连接到活动中。

使用 onCreate(),我们可以在 OnCreateView() 中创建视图;在这之后,我们可以在片段中调用 getView(),它将不会是 null。

onActivityCreated() 方法告诉片段其活动已经在它自己的 Activity.onCreate() 中完成。

有两种方法可以显示片段:

第一种方法是在我们的布局 XML 中包含片段。当包含它的视图被填充时,这将创建我们的片段。执行以下代码:

<LinearLayout 
    android:orientation="horizontal"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <fragment android:name="com.example.android.MyFragment"
              android:id="@+id/headlines_fragment"
android:layout_width="match_parent"
              android:layout_height="match_parent" />
</LinearLayout>

第二种方法是程序化地创建我们的片段,并告诉片段管理器在容器中显示它。为此,你可以使用以下代码:

<LinearLayout 
    android:orientation="horizontal"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <Framelayout android:id="@+id/fragment_container"
android:layout_width="match_parent"
             android:layout_height="match_parent" />

</LinearLayout>

之后,使用以下代码行填充一个 FrameLayout 容器,片段将被插入其中:

Myfragment fragment = MyFragment.newInstance();
getSupportFragmentManager().beginTransaction()
                    .add(R.id.fragment_container, fragment).commit();

为了结束关键概念,解释为什么 Android 示例使用 MyFragment.newInstance(params) 工厂方法而不是默认的 new MyFragment(params) 构造函数来创建片段是很重要的。看看以下代码:

public class MyFragment extends Fragment {

 // Static factory method that returns a new fragment
 // receiving a   parameter and initializing the fragment's arguments

    public static MyFragment newInstance(int param) {
        MyFragment fragment = new MyFragment();
        Bundle args = new Bundle();
        args.putInt("param", param);
        fragment.setArguments(args);
        return fragment;
    }
}

这种模式背后的原因是,Android 只会使用默认构造函数重新创建片段;因此,如果我们有一个带参数的构造函数,它将被忽略,参数也将丢失。

提示

请注意,我们将参数作为参数包发送,这样如果片段需要被重新创建(由于设备方向改变,我们使用后退导航),片段就可以检索参数。

片段管理器

片段管理器是一个接口,用于与活动内的片段进行交互。这意味着任何操作,如添加、替换、移除或查找片段,都必须通过它来完成。

为了获得片段管理器,我们的 Activity 需要从 FragmentActivity 继承,这将允许我们调用 getFragmentManager()getSupportFragmentManager(),最好是使用 Android.support.v4 中包含的保持向后兼容的片段管理器。

如果我们想要使用嵌套片段,可以通过 getChildFragmentManager() 来管理它们。当布局包含 <fragment> 时,我们不能将布局填充到片段中。只有动态添加到片段中的时候,嵌套片段才是支持的。

现在,我们将讨论一些在我们使用片段时会迟早遇到的情况。假设我们有一个带有两个片段 A 和 B 的活动。

一个典型的情况是,我们处于一个片段中,想要从活动中执行一个方法。在这种情况下,我们有两个选择;一个是实现在 MyActivity 中的 public 方法,例如 doSomething(),这样我们就可以将 getActivity 强制类型转换为我们的活动,并调用 ((MyActivity)getActivity).doSomething(); 方法。

第二种方法是让我们的活动实现碎片中定义的接口,并在onAttach(Activity)方法中将活动的实例设置为该接口的监听器。我们将在第四章中解释这种软件模式,并发与软件设计模式。反过来,如果要让活动与碎片通信(如果我们没有在活动中将碎片 A 实例化到一个变量中),我们可以通过管理器找到碎片。我们可以在以下部分查看如何使用容器 ID 或标签找到碎片:

FragmentManager fm = getSupportFragmentManger();
FragmentA fragmentA = fm.findFragmentById(R.id.fragment_container);
fragmentA.doSomething(params);

最后一种情况是在碎片 A 中与 B 对话;为此,我们只需从活动中获取管理器并找到碎片。运行以下代码:

FragmentManager fm = getActivity().getSupportFragmentManger();
FragmentA fragmentA = fm.findFragmentById(R.id.fragment_container);
fragmentA.doSomething(params);

碎片堆叠

我们一直在讨论在碎片管理器中查找碎片,这是由于碎片管理器的碎片堆栈,我们可以在事务期间添加或删除碎片。

当我们想要动态显示一个碎片时,我们可以决定是否要将碎片添加到堆栈中。将碎片放在堆栈上可以让我们返回到上一个碎片。

这对于我们的示例非常重要;如果用户在第一个标签上,点击列表中的项目,我们希望他/她看到详情屏幕,即DetailsFragment。现在,如果用户在DetailsFragment上,点击返回按钮,我们不希望他/她离开应用;我们希望应用返回到碎片堆叠。这就是为什么我们在添加DetailsFragment时,必须包含addToBackStack(String tag)选项。标签可以为 null,也可以是允许我们通过标签找到这个新碎片的String类型。它将类似于以下内容:

FragmentTransaction ft = getFragmentManager().beginTransaction();
ft.replace(R.id.simple_fragment, newFragment);
ft.addToBackStack(null);
ft.commit();

为了进一步澄清,如果我们想在三个碎片之间导航,从A 到 B 再到 C,然后返回,拥有堆栈将允许我们从C 到 B 再到 A。然而,如果我们不将碎片添加到返回堆栈中,或者在同一容器中添加或替换它们,A 到 B 再到 C,这将只留下 C 碎片,并且无法返回导航。

现在,为了在DetailsFragment中实现返回导航,我们需要让活动知道当我点击返回时,我想先在碎片中返回,然后再退出应用,这是默认的行为。如果堆栈中不止一个碎片,可以通过重写onKeyDown并处理碎片导航来实现这一点。运行以下命令:

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && getSupportFragmentManager.getBackStackEntryCount > 1) {
getSupportFragment.popBackStack();
return true;
}
return super.onKeyDown(keyCode, event);
}

视图翻页

继续我们的示例,在MainActivity中,我们有两种在碎片之间导航的方法:一种是点击标签,另一种是滑动碎片。为了实现这一点,我们将使用ViewPager,包括其中的滑动标签,这是一个非常优雅的解决方案,代码量最小,并且包括滑动和标签之间的同步。

ViewPager可用于滑动任何类型的视图。我们可以使用ViewPager创建一个图片画廊;在首次运行某些应用时,经常会看到教程,你可以滑动屏幕查看应用操作说明,这是通过ViewPager实现的。要将ViewPager添加到MainActivity,我们可以简单复制并粘贴以下代码:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.view.ViewPager

android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />

ViewPager部分末尾,我们将了解如何使用不同的第三方库来改善标签页的体验,以及如果我们想要自定义解决方案,如何手动创建这些标签页。

适配器

ViewPager与适配器一起工作;适配器是负责创建我们滑动的每个页面的元素。在滑动片段的特殊情况下,有Adapter类的扩展名为FragmentPagerAdapterFragmentStatePagerAdapter,我们可以使用:

  • FragmentStatePagerAdapter保存页面的状态,在屏幕上不显示时销毁它,并在需要时重新创建,这与ListView对其行的处理类似。

  • FragmentPagerAdapter将所有页面保存在内存中;因此,在滑动时没有与保存和恢复状态相关的计算成本。我们可以拥有的页面数量取决于内存。

根据元素数量,我们可以选择其中之一。如果我们正在创建一个阅读新闻的应用,你可以在很多带有图片和不同内容的新闻文章之间滑动,我们不会尝试将它们全部保存在内存中。

我们有三个固定的标签页,因此我们将选择FragmentPagerAdapter。我们将创建一个包装适配器,并创建一个名为MyPagerAdapter的类,这个类将扩展FragmentPagerAdapter。在扩展它的时候,我们需要重写getCount()getItem(int i)方法,这些方法返回项目数量和在给定位置返回一个项目。

创建构造函数并完成方法后,我们的类将类似于以下代码:

public class MyPagerAdapter extends FragmentPagerAdapter {

    public MyPagerAdapter(FragmentManager fm) {
        super(fm);
    }

    @Override
    public Fragment getItem(int i) {
        switch (i) {
            case 0 :
                return new ListFragment();
            case 1 :
                return new ContactFragment();
            case 2 :
                return new SettingsFragment();
            default:
                return null;
        }
    }

    @Override
    public int getCount() {
        return 3;
    }
}

最后,我们需要在MainActivity中将适配器设置到翻页器中。执行以下代码:

public class MainActivity extends FragmentActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        MyPagerAdapter adapter = new MyPagerAdapter(getSupportFragmentManager());
        ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
        viewPager.setAdapter(adapter);

    }

}

滑动标签

在我们的示例中,此时我们已经能够在片段之间滑动。现在,我们将使用PagerTabStripPagerTitleStrip添加标签页。

实现这一点有一个非常优雅的方法,即在ViewPager的 XML 标签中包含PageTabStrip。执行以下代码:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.view.ViewPager

android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="wrap_content">

    <android.support.v4.view.PagerTabStrip
        android:id="@+id/pager_title_strip"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="top"
        android:background="#33b5e5"
        android:textColor="#fff"
        android:textSize="20dp"
        android:paddingTop="10dp"
        android:paddingBottom="10dp" />

</android.support.v4.view.ViewPager>

在这里,PagerTabStrip将找到页面的标题,并为每个页面显示一个标签。我们需要在MyPagerAdapter中添加getPageTitle方法,它将为每个页面返回一个字符串。在我们的例子中,这将是一下部分的名字:列表、联系人和设置。为此,你可以使用以下代码:

@Override
public CharSequence getPageTitle(int position) {
  switch (position) {
    case 0 :
    return "LIST";
    case 1 :
    return "CONTACT";
    case 2 :
    return "SETTINGS";
    default:
    return null;
  }
}

运行应用,瞧!我们轻松实现了一个流畅的标签页和滑动导航支持,支持 Android 1.6(API 4):

滑动标签

定制标签页

Android 中的标签页背后有一个很长的故事;最初,标签页是通过TabActivity实现的,但在 API 13 中被废弃,并演变成了FragmentTabHost

因此,我按照 Android 文档,高兴地使用TabHost开发了一个应用,然后意识到这一点必须改变。起初,我抱着侥幸心理,希望废弃不会影响我的应用,直到一些用户抱怨应用崩溃。然后,不可避免地,我必须移除废弃的TabHost并寻找新的方法。

一开始,FragmentTabHost似乎是一个实现固定标签页的好方法,但它不允许在标签上使用图标。在遇到这个问题,并在 Stack Overflow 上发现其他人也有同样的问题(一个可以提问和找到关于 Android 和其他主题答案的网站),我决定寻找另一种方法。

在 API 11 中,出现了ActionBar.Tab的概念,这是一个允许我们向操作栏添加标签页的类。最终,我找到了在我的应用中实现标签页的方法,这让用户们感到高兴!但这种喜悦并没有持续太久;ActionBar.Tab又被废弃了!

这件事会让任何开发者的耐心耗尽;这让我决定在LinearLayout中以按钮的形式创建自己的标签页。在按钮上设置点击监听器,点击标签页时,我将ViewPager滑动到正确的页面,反之,当检测到ViewPager页面滑动时,我会选择正确的标签页。这样做是值得的,因为它让我在设计标签页时拥有完全的自由度,更重要的是,它给了我一种满足感,除非有一天LinearLayoutButton被废弃,否则它将一直有效。

你总是可以把自定义实现作为最后的选择。如今,如果你不喜欢滑动标签页的设计,你可以使用第三方库的其他替代方案,比如ViewPagerIndicatorPagerSlidingTabStrip

备注

要了解更多信息,你可以查看以下链接:

JakeWharton 的 ViewPagerIndicator

astuetz 的 PagerSlidingTabStrip

过渡效果

像是创建我们自己的屏幕过渡动画这样的小细节,将使我们的应用更加精致,看起来更加专业。

我们的示例非常适合讨论过渡,因为我们有两种类型的屏幕过渡:

  • 第一个是活动之间的过渡,从SplashActivityMainActivity

  • 第二个(尚未实现)是碎片之间的过渡,其中ListFragmentDetailsFragment替换。

对于活动之间的过渡,我们需要在启动新活动之前调用overridePendingTransition。该方法接收两个动画作为参数,这些动画可以是我们创建的 XML 文件中的,也可以是从 Android 中已经创建的动画中选择。运行以下命令:

overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);

在我们的示例中,我们不允许可返回导航到SplashActivity;然而,如果我们处于需要点击返回时具有相同过渡的活动之间的过渡,我们将不得不重写返回键按下并设置我们的过渡。为此,您可以运行以下命令:

@Override public void onBackPressed() {
   super.onBackPressed();        overridePendingTransition(android.R.anim.fade_in,  android.R.anim.fade_out); 
}

在处理片段时,我们需要在FragmentTransaction对象中指定转换。使用对象动画师,我们可以在两个文件中定义这一点:enter.xmlexit.xml。执行以下代码:

FragmentTransaction transaction = getFragmentManager().beginTransaction();
transaction.setCustomAnimations(R.animator.enter, R.animator.exit);
transaction.replace(R.id.container, new DetailsFragment());
transaction.commit();

enter.xml

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

         android:duration="1000"
         android:propertyName="y"
         android:valueFrom="2000"
         android:valueTo="0"
         android:valueType="floatType" />
</set>

exit.xml 
<?xml version="1.0" encoding="utf-8"?>
<set>
    <objectAnimator

        android:duration="1000"
        android:propertyName="y"
        android:valueFrom="0"
        android:valueTo="-2000"
        android:valueType="floatType" />
</set>

对于 Android Lollipop 及更高版本,您可以直接为片段设置过渡。使用以下代码片段:

Fragment f = new MyFragment();
f.setEnterTransition(new Slide(Gravity.RIGHT));
f.setExitTransition(new Slide(Gravity.LEFT));

总结

在本章结束时,您应该了解基本的导航模式,并能将心中应用程序的想法转化为 Android 应用程序的实际结构。片段是 Android 开发中的关键概念,我们在本章中通过复习片段管理器和片段回退堆栈,以及学习如何应对它们之间的通信等常见问题,已经足够时间掌握它们。我们考虑了一个带有PagerTabStripViewPager的工作示例,它将页面标题显示为标签,现在您知道如果需要如何自定义它。我们有一个应用程序的框架;这个项目可以在这一阶段保存,并用作您未来开发的模板。我们已经准备好继续发展我们的应用程序。

在下一章中,我们将了解如何创建和访问将填充我们的片段和ViewPager的内容,使我们的应用程序生动起来。

第三章:从云端创建和访问内容

在本章中,我们将学习如何通过我们的应用程序从网络消费内容;这些内容可以是 XML 或 JSON 文件中的项目列表(我们希望展示的东西),从互联网上获取。例如,如果我们正在构建一个显示当前天气状况的应用,我们将需要联系外部 API 来获取所需的所有信息。

我们将在 Parse 中创建自己的云端数据库,这项服务允许我们非常快速地完成这一操作,而无需创建和维护自己的服务器。除此之外,我们将在MasteringAndroidApp中填充数据库,以展示信息。

我们还将介绍关于使用 Google Volley 进行网络请求的最佳实践,使用超快的 HTTP 库 OkHttp,以及使用 Gson 高效解析请求的对象。我们将在本章中介绍以下主题:

  • 创建你自己的云端数据库

  • 使用 Parse 消费内容

  • Google Volley 和 OkHttp

  • 使用 Gson 解析对象

创建你自己的云端数据库

在项目的这个阶段,我们必须开始构建我们自己的版本的MasteringAndroidApp。随意开发你自己的想法,并将数据库用于你自己的数据。以这个示例作为指导,你不必严格按照我写的代码逐行复制。实际上,如果你在本书的最后开发你自己的示例,你将得到一些你可以使用的东西。例如,你可以创建一个供个人使用的应用,如任务提醒、旅行日记、个人照片画廊,或任何适合在云端存储的东西。

你也可以尝试将这个应用货币化;在这种情况下,你应该尝试开发一些对用户来说有趣的东西。例如,可以是新闻源阅读器或是食谱阅读器;可以是任何可以提交内容到云端并通知用户有新内容可用的应用。

在此过程中,我们将解释Application类的重要性,该类用于在我们的项目中设置 Parse。

Parse

如果你每秒的请求少于 30 次,Parse 是免费的。我想,如果你的应用有足够的用户每秒请求信息 30 次,也就是每分钟 1800 次,你肯定能负担得起升级到付费账户,甚至构建自己的服务器!这项服务是一种非常简单且可靠的方式来为你的应用覆盖服务器端。它还提供推送通知服务和分析,这也是它的一个优点。

我们将开始创建一个新的账户;之后,我们需要在 Parse 中为我们的应用命名。在这里,我将使用MasteringAndroid。命名应用后,你将进入账户的主页。我们需要导航到 数据服务 | 移动 | Android | 原生 Java

下图展示了作为云的数据服务:

Parse

将 Parse SDK 添加到我们的项目中

为了从我们的应用访问数据服务,我们需要安装Parse SDK系统开发工具包)。为此,Parse 指引我们查看一个快速入门指南,其中包含所有代码,包括我们应用的 API 密钥,这些代码可以直接复制并粘贴到我们的项目中。

基本上,我们需要完成两个步骤:

  1. 第一步是下载一个.jar库文件,我们需要将其复制到我们项目中的libs文件夹内。复制后,我们需要告诉我们的构建系统在应用程序中包含这个库。为此,我们需要在我们应用文件夹中找到build.gradle文件(注意,我们的项目中有两个build.gradle文件),并添加以下几行:

    dependencies {
      compile 'com.parse.bolts:bolts-android:1.+'
      compile fileTree(dir: 'libs', include: 'Parse-*.jar')
    }
    
  2. 在下面的图片中,你可以看到两个名为build.gradle的文件;被选中的是正确的那个:将 Parse SDK 添加到我们的项目

  3. 第二步是在我们的项目中初始化 Parse SDK;为此,我们可以直接导航到www.parse.com/apps/quickstart?app_id=masteringandroidapp。在链接中替换你自己的应用 ID,或者通过点击你的主页找到链接,如下面的截图所示:将 Parse SDK 添加到我们的项目

  4. 点击快速入门指南后,转到数据 | 移动 | Android | 原生 | 现有项目

  5. 如果尚未添加,它会要求你在AndroidManifest.xml文件中添加INTERNETACCESS_NETWORK_STATE权限。

Android 的 Application 类

接下来我们要注意的一点是,我们需要将初始化 Parse 的代码添加到我们的Application类中;然而,我们的项目中默认并没有创建Application类。我们需要创建并了解Application类是什么以及它是如何工作的。

要创建一个 Application 类,我们将右键点击我们的包,并创建一个名为MAApplication的新 Java 类,继承Application。一旦继承了Application,我们可以重写onCreate方法。然后,我们将在 | 生成. | 重写方法 | onCreate中右键点击。

这将重写onCreate方法,我们将准备好在那里实现我们自己的功能。每次创建我们的Application时都会调用onCreate方法;因此,这是初始化我们的库和第三方 SDK 的正确位置。现在,你可以按照快速入门指南中所示复制并粘贴 Parse 初始化行:

提示

注意,这是唯一的,并且对于你自己的账户,你应该有自己的密钥。

Parse.initialize(this, "yourKeyHere", "yourKeyHere");

为了完成,我们需要告诉我们的应用有一个新的Application类,并且这是我们想要使用的那个;如果我们不这样做,我们的Application类将不会被识别,onCreate也不会被调用。

在我们的清单文件中,我们需要在<application>标签内设置属性名称以匹配我们自己的应用。执行以下代码:

<application
    android:name="MApplication "
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_newname"
>

应用程序类封装了应用中的所有内容;活动包含在应用程序中,随后,片段包含在活动中。如果我们需要在应用中访问所有活动/片段的全局变量,这将是一个合适的位置。在下一章中,我们将了解如何创建这个全局变量。下面的图表是应用程序的图形结构:

Android 的应用程序类

创建数据库

众所周知,在本书中我们将创建的示例应用是一个包含与 Android 相关职位招聘的应用;因此,我们需要创建一个数据库来存储这些职位信息。

在开发过程中可以更改数据库(当应用程序发布并拥有用户时,这将变得更加困难)。然而,现在我们将从大局出发,创建整个系统,而不是拥有一个包含所有字段完成的数据库的最终版本。

要创建表,请点击如下截图所示的Core部分:

创建数据库

首先,通过点击+ 添加类按钮创建一个表,并将其命名为JobOffer,并添加以下属性,可以通过点击Col+按钮添加:

  • objectId:这是默认创建的:String

  • title:这是工作标题:String

  • description:这是工作描述:String

  • salary:这表示薪水或日薪:String

  • company:这表示提供工作的公司:String

  • type:这表示员工的类型,是永久性、合同工还是自由职业者:String

  • imageLink:这是公司的图片:String

  • Location:这表示工作的地点:String

  • createdAtupdatedAt:这是工作的日期;列是使用默认日期创建的

要向表中添加数据,请选择左侧的表并点击+ 行。我们只需要完成我们创建的列;默认创建的列,如 ID 或日期,将自动完成。到目前为止,我们的表应该如下所示:

创建数据库

可以随意添加更多详细信息,例如联系人、电子邮件和手机号码。你也可以添加更多表;例如,一个新的JobType表,其中包含工作的类型和字段类型,而不是String,应该是Relation<JobType>

我们已经有了示例所需的内容;接下来要做的就是使用我们的应用程序来消费这些数据。

在 Parse 中存储和消费内容

Parse 是一个非常强大的工具,它不仅让我们能够轻松地消费内容,还能将内容从我们的设备存储到云数据库中,如果使用传统方法来完成这项工作则是非常繁琐的。

例如,如果我们想从设备上传图片到自定义服务器,我们得创建一个POST请求并发送一个正确编码的表单,同时将图片作为FileBody对象附加在MultiPartEntity中,并导入 Apache HTTP 库:

HttpClient httpclient = new DefaultHttpClient();
HttpPost httppost = new HttpPost("URL TO SERVER");

MultipartEntity mpEntity = new MultipartEntity(HttpMultipartMode.BROWSER_COMPATIBLE);
File file = new File(filePath);
mpEntity.addPart("imgField", new FileBody(file, "application/octet"));

httppost.setEntity(mpEntity);
HttpResponse response = httpclient.execute(httppost);

现在,让我们看看 Parse 的替代方案:

ParseFile imgFile = new ParseFile ("img.png", ImgByteArray);

ParseObject myParseObject = new ParseObject ("ParseClass");    
 myParseObject.put("imageField", imgFile);
 myParseObject.saveInBackground();

让我们不要忘记在 Parse 上处理错误。你可以非常优雅地简单编写:

imageObj.saveInBackground(new SaveCallback() {
  @Override
  public void done(ParseException e) {
    if (e == null) {
      //Successful
    } else {
      //Error
    }
  }
});

存储内容

为了详细阐述 Parse 的简洁性,我们将从我们的应用程序将职位信息上传到 Parse 云。

为此,我们可以在联系片段内创建一个按钮,在应用的最终版本中设置为不可见,因为我们不希望用户自己上传职位信息。

通过这个按钮,我们将创建一个ParseObject,这类似于一个映射。我们将添加我们想要完成的字段,在这之后,我们将调用saveInBackground()方法,这个方法是用来上传对象的。执行以下代码:

view.findViewById(R.id.addJobOffer).setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {

    ParseObject jobOffer = new ParseObject("JobOffer");

    jobOffer.put("title", "Android Contract");
    jobOffer.put("description", "6 months rolling    contract. /n The client" +
    "is a worldwide known digital agency");
    jobOffer.put("type", "Contract");
    jobOffer.put("salary", "450 GBP/day");
    jobOffer.put("company", "Recruiters LTD");
    jobOffer.put("imageLink", "http://.....recruitersLTD_logo.png");
    jobOffer.put("location","Reading, UK");

    jobOffer.saveInBackground();
  }
});

如果在你的MasteringAndroidApp版本中,你想让用户上传内容,你可以为每个字段显示一个带有EditText的对话框,让用户编写职位信息,点击上传,然后你发送带有用户编写的字段的jobOffer对象。

运行应用,导航到联系,并点击按钮。如果数据正确上传,在浏览器中打开 Parse 云数据库时,你应该能看到刚刚上传的职位信息新增的一行。

提示

记得在AndroidManifest.xml中添加权限,android.permission.ACCESS_NETWORK_STATEandroid.permission.INTERNET

消费内容

我们在 Parse 云中的对象默认有一个对象标识符,它是objectId字段。让我们通过 ID 获取一个对象,之后,我们可以获取带有和没有过滤器的所有对象列表。运行以下代码:

ParseQuery<ParseObject> query = ParseQuery.getQuery("JobOffer");
query.getInBackground("yourObjectID", new GetCallback<ParseObject>() {
  public void done(ParseObject object, ParseException e) {
    if (e == null) {
      // object will be our job offer
    } else {
      // something went wrong
    }
  }
});

当网络请求完成时,ParseQuery对象将异步执行网络查询。回调中包含的方法;done (the ParseObject object, ParseException e)将被执行。

检验结果的一个好方法是打印日志;在异常为null的情况下,意味着一切正常。

if (e == null) {
  Log.d("PARSE_TEST",object.getString("Title"));
} else {
  // something went wrong
}

我们可以从ParseObject中提取每个字段,并在我们的应用中使用一个构造函数创建一个JobOffer类,其参数与对象的字段相匹配。使用以下代码片段:

JobOffer myJobOffer = new JobOffer(object.getString("title), object.getString("description"), … );

然而,还有一种更好的方法。我们可以创建一个JobOffer类,它继承自ParseObject,并且所有字段都会自动转换成我们类中的变量。这样,我们就可以非常方便地使用我们自己的类,而不是ParseObject

public void done(JobOffer jobOffer, ParseException e) 

在类的顶部不要忘记添加@ParseClassName("Name")注解,让 Parse 知道我们要实例化云中的哪个对象,并在MAApplication中初始化 Parse 之前注册这个子类:

public class MAApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();

        // Enable Local Datastore.
        Parse.enableLocalDatastore(this);

        ParseObject.registerSubclass(JobOffer.class);

        Parse.initialize(this, "KEY", "KEY");
    }

}

@ParseClassName("JobOffer")
public class JobOffer extends ParseObject {

    public JobOffer() {
        // A default constructor is required.
    }

    public String getTitle() {
        return getString("title");
    }

    public void setTitle(String title) {
        put("title", title);
    }

    public String getDescription() {
        return getString("description");
    }

    public void setDescription(String description) {
        put("description", description);
    }

    public String getType() {
        return getString("type");
    }

    public void setType(String type) {
        put("type", type);
    }
    //Continue with all the fields..

}

既然我们已经创建了自定义类,获取所有职位列表就更加容易了。如果我们愿意,可以用一个参数来过滤它。例如,我可以使用以下查询检索所有永久职位:

ParseQuery< JobOffer > query = ParseQuery.getQuery("JobOffer");
query.whereEqualTo("type", "Permanent");
query.findInBackground(new FindCallback<JobOffer>() {
    public void done(List<JobOffer> jobsList, ParseException e) {
        if (e == null) {
            Log.d("score", "Retrieved " + jobsList.size() + " jobs");
        } else {
            Log.d("score", "Error: " + e.getMessage());
        }
    }
});

显示内容

一旦检索到对象列表,就可以创建ListView和一个接收对象作为参数的Adapter。为了结束对 Parse 的使用,我们将使用另一个功能,它允许我们直接从查询结果创建适配器;这样,我们就不必自己创建一个Adapter类了。

在这两种情况下,我们需要创建ListView和列表行的视图。现在,只需显示标题和描述的第一行即可。我们将在第七章《图像处理和内存管理》中自定义此内容并添加图像。按照以下方式创建row_job_offer.xml布局:

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

    <TextView
        android:id="@+id/rowJobOfferTitle"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Title"
        android:textColor="#555"
        android:textSize="18sp"
        />

    <TextView
        android:id="@+id/rowJobOfferDesc"
        android:layout_marginTop="5dp"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Description"
        android:textColor="#999"
        android:textSize="16sp"
        android:maxLines="1"
        android:ellipsize="marquee"
        />

</LinearLayout>

我们现在可以创建ParseQueryAdapter并自定义getItemView()方法了。这个适配器的一大优势在于,我们不需要通过查询下载数据,因为它是自动完成的;基本上,我们可以通过创建一个适配器来从云端展示项目列表。从未如此简单!

要覆盖类中的方法——在本例中,我们想要覆盖getItemView——我们可以创建一个子类,一个扩展ParseQueryAdapterMyQueryAdapter类,并在该子类中覆盖方法。这是一个很好的解决方案,特别是如果我们想要在应用程序中多次实例化对象。

然而,有一种方法可以在不扩展类的情况下覆盖方法;我们可以在对象实例化后添加{ }。例如,参考以下代码:

Object object = new Object() {

 //Override methods here

 }

使用这种方法,我可以创建一个新的ParseQueryAdapter并自定义getItemView,如下面的代码所示:

ParseQueryAdapter<JobOffer> parseQueryAdapter = new ParseQueryAdapter<JobOffer>(getActivity(),"JobOffer") {

  @Override
  public View getItemView(JobOffer jobOffer, View v, ViewGroup parent) {

    if (v == null) {
      v = View.inflate(getContext(), R.layout.row_job_offer, null);
    }

    super.getItemView(jobOffer, v, parent);

    TextView titleTextView = (TextView) v.findViewById(R.id.rowJobOfferTitle);
    titleTextView.setText(jobOffer.getTitle());
    TextView descTextView = (TextView) v.findViewById(R.id.rowJobOfferDesc);
    descTextView.setText(jobOffer.getDescription());

    return v;
  }

};

我们现在将在ListFragment的布局中创建ListView,在OnCreateView中找到这个视图,为列表设置适配器,就这么多。不需要更多代码来检索项目并显示它们。如果您的列表为空,请确保在MyPagerAdapter中导入com.packtpub.masteringandroidapp.fragments.ListFragment;而不是android.support.v4.app.ListFragment;它们是不同的对象,使用后者将导致显示一个空的内置ListFragment

显示内容

Google Volley 和 OkHttp

要掌握 Android,我们不能依赖于像 Parse 这样的解决方案。作为开发者,我们必须准备面对不同的服务器端解决方案。我们不能总是使用ParseObjects,因为我们需要能够进行 HTTP Post请求并消费 JSON 或 XML 格式的数据。然而,这并不意味着我们必须手动完成所有这些工作;我们可以使用谷歌的官方库来帮助我们解析数据和网络请求。

为此,我们将研究强大的库Google Volley来管理我们的网络请求。我们还将讨论超快的 HTTP 客户端OkHttp,并将两者结合起来,为网络请求提供一个惊人的解决方案。

Google Volley

根据官方定义和功能列表来自developer.android.com/training/volley/index.html的说明,"Volley 是一个 HTTP 库,它让 Android 应用程序的网络通信变得更加简单,最重要的是,更快”。

Volley 提供以下好处:

  • 自动调度网络请求

  • 支持多个并发网络连接

  • 具有标准 HTTP 缓存一致性的透明磁盘和内存响应缓存

  • 支持请求优先级

  • 取消请求 API;这意味着你可以取消单个请求,或者设置要取消的请求块或作用域

  • 易于定制;例如,重试和退避策略

  • 强有序性,这使得你可以轻松地用从网络异步获取的数据正确填充 UI。

  • 调试和跟踪工具

在 Volley 诞生之前,在 Android 中管理网络请求是一项艰巨的任务。几乎每个应用程序都会执行网络请求。诸如自定义重试(如果连接失败,我们需要再次尝试)以及管理并发网络连接等功能通常需要开发者手动实现。如今,我们习惯了这类库,但如果我们回想几年前的情形,Volley 是解决这一问题的绝佳方案。

在了解如何创建请求之前,我们需要理解 Volley 请求队列对象的概念,RequestQueue。Volley 执行的每个请求都必须添加到这个队列中,以便执行。这个想法是为了在我们的应用程序中有一个单一的请求队列,所有的网络请求都可以添加到其中,并且可以从应用程序的任何部分访问。我们将在第四章,并发与软件设计模式中看到如何拥有一个可以全局访问的对象实例。先看以下请求:

// Instantiate the RequestQueue.
RequestQueue queue = Volley.newRequestQueue(this);

如果设备的 Android 版本晚于 Gingerbread,这个请求队列将只使用以下HttpURLConnectionAndroidHttpClient方法;在 Gingerbread 之前的版本中,HttpURLConnection是不可靠的。

// If the device is running a version >= Gingerbread...
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
    // ...use HttpURLConnection for stack.
} else {
    // ...use AndroidHttpClient for stack.
}

当请求队列实例化时,我们只需要向其中添加一个请求。例如,一个网络请求www.google.com,它会记录响应:

String url ="https://www.google.com";

// Request a string response from the provided URL.
StringRequest stringRequest = new StringRequest(Request.Method.GET, url,
            new Response.Listener<String>() {
    @Override
    public void onResponse(String response) {
        // Display the first 500 characters of the response string.
        Log.d("Volley","Response is: "+ response.substring(0,500));
    }
}, new Response.ErrorListener() {
    @Override
    public void onErrorResponse(VolleyError error) {
        Log.d("Volley","That didn't work!");
    }
});

// Add the request to the RequestQueue.
queue.add(stringRequest);

请求将被执行,并且在应用程序主线程(也称为 UI 线程)中调用onResponse(…)onErrorResponse(…)方法。我们将在第四章,并发与软件设计模式中更详细地解释 Android 中的线程。

OkHttp

OkHttp 是来自 Square 公司的 Android 和 Java 的 HTTP 和 SPDY 客户端。它不是 Volley 的替代品,因为它不包括请求队列。实际上,我们可以将 OkHttp 作为 Volley 的底层,我们将在下一节中看到这一点。

根据官方定义,"HTTP 是现代应用程序联网的方式。它是我们交换数据和媒体的方法。高效地处理 HTTP 能让你的东西加载得更快,节省带宽”。

如果我们不需要处理队列中的请求,优先处理请求,或者安排请求,我们可以在应用程序中直接使用 OkHttp;我们不一定需要 Volley。

例如,以下方法打印给定 URL 响应的内容:

OkHttpClient client = new OkHttpClient();

String run(String url) throws IOException {

  Request request = new Request.Builder()
      .url(url)
      .build();

  Response response = client.newCall(request).execute();
  return response.body().string();

}

除了比使用AsyncTaskHttpUrlConnection进行请求更为简单之外,让我们决定使用 OkHttp 的是 SPDY(快速)协议,它处理、标记化、简化和压缩 HTTP 请求。

极速的网络

如果我们想要保留 Volley 的特性,以便拥有灵活可管理的请求队列,并使用 SPDY 协议快速连接,我们可以结合使用 Volley 和 OkHttp。

这很容易做到;在实例化请求队列时,我们可以指定我们想要的HttpStack方法:

RequestQueue queue = Volley.newRequestQueue(this, new OkHttpStack());

在这里,OkHttpStack是一个我们将通过扩展HurlStack自己创建的类,它将使用OkUrlFactory。这个OkUrlFactory将打开一个 URL 连接;这将在内部完成,无需重写createConnection方法:

/**
 * An HttpStack subclass
 * using OkHttp as transport layer.
 */
public class OkHttpStack extends HurlStack {

    private final OkUrlFactory mFactory;

    public OkHttpStack() {
        this(new OkHttpClient());
    }

    public OkHttpStack(OkHttpClient client) {
        if (client == null) {
            throw new NullPointerException("Null client.");
        }
        mFactory = new OkUrlFactory(client);
    }
}

JSON 和 Gson

作为一名 Android 开发者,迟早你将不得不处理 JSON 格式的网络请求。在某些情况下,你也可能会发现 XML,这使得将其转换为对象更加繁琐。了解如何通过发送 JSON 格式的参数执行网络请求以及如何以 JSON 格式消费数据是非常重要的。

JSON 和 GSON 是两回事;我们需要了解它们之间的区别。JSON,或 JavaScript 对象表示法,是一种开放标准格式,使用人类可读的文本传输由属性-值对组成的数据对象。它主要用于服务器和 Web 应用程序之间传输数据,作为 XML 的替代品。这是一个 JSON 文件的例子;如你所见,我们可以有不同类型的属性,我们还可以有嵌套的 JSON 结构:

{
  "firstName": "Antonio",
  "lastName": "Smith",
  "isDeveloper": true,
  "age": 25,
  "phoneNumbers": [
    {
      "type": "home",
      "number": "212 555-1234"
    },
    {
      "type": "office",
      "number": "646 555-4567"
    }
  ],
  "children": [],
  "spouse": null
}

下面是两个带有参数的 JSON 格式发送网络请求的例子。这些例子涵盖了本章前面讨论过的 Volley 和 OkHttp:

//With Volley

public void post(String param1, String param2, String url) {

  Map<String, String> params = new HashMap<String, String>();
  params.put("param1",param1);
  params.put("param2",param2);

  JsonObjectRequest stringRequest = new  JsonObjectRequest(Request.Method.POST, url, new JSONObject(params),  new Response.Listener<JSONObject>() {

    @Override
    public void onResponse(JSONObject responseJSON) {

    }, new Response.ErrorListener() {

      @Override
      public void onErrorResponse(VolleyError error) {
      }
    });

    // Add the request to the RequestQueue.
    requestQueue.add(stringRequest);
  }

  //With OkHttp

  public static final MediaType JSON
  = MediaType.parse("application/json; charset=utf-8");

  String post(String url, String json) throws IOException {
    RequestBody body = RequestBody.create(JSON, json);
    Request request = new Request.Builder()
    .url(url)
    .post(body)
    .build();
    Response response = client.newCall(request).execute();
    return response.body().string();

  }

  //To create a JSONObject from a string

  JSONObject responseJSON = new JSONObject(String json);

Gson谷歌 Gson)是一个开源的 Java 库,用于将 Java 对象序列化和反序列化为(或从)JSON。

如果我们从自定义服务器以 JSON 格式下载应用程序的工作机会,格式将是以下这样:

{
  "title": "Senior Android developer",
  "description": "A developer is needed for…",
  "salary": "25.000 € per year",
  .
  .
  .
}

同样,我们不想手动创建一个新对象并从 JSON 中获取所有参数进行设置;我们想要的是从 JSON 创建一个JobOffer对象。这称为反序列化

要使用这个功能,我们需要在 build.gradle 中将 GSON 库作为依赖项导入:

dependencies {
compile 'com.google.code.gson:gson:2.2.4'
}

Gson 有 fromJSONtoJSON 方法来分别进行序列化和反序列化。fromJson 方法接受要转换的 JSON 代码,以及我们希望转换成的对象的类作为输入。使用以下代码:

Gson gson = new Gson();
JobOffer offer = gson.fromJson(JSONString, JobOffer.class);

如果我们请求数据时遇到的是一个列表而不是单一对象,这是典型的情况,我们就需要额外一步来获取类型:

Gson gson = new Gson();
Type listType = new TypeToken<List<JobOffer>>(){}.getType();
List<JobOffer> listOffers = gson.fromJson(JSONString, listType);

最后,如果我们希望类的字段在反序列化时与 JSON 代码的字段名称不同,我们可以使用如下注解:

import com.google.gson.annotations.SerializedName;

public class JobOffer extends ParseObject {

    @SerializedName("title")
    private String title;

    @SerializedName("description")
    private String desc;

    @SerializedName("salary")
    private String salary;

本章总结

在本章结束时,你应该能够自己在 Parse 中创建数据库并从应用程序中消费内容。你也应该掌握使用 Volley 和 OkHttp 进行网络请求的所有必要知识,特别是在执行网络请求和以 JSON 格式交换数据时。

在下一章中,我们将更详细地解释本章中用于 HTTP 库的一些模式。例如,我们将了解回调是什么以及它遵循的模式,以及在 Android 中其他常用的软件模式。

第四章:并发和软件设计模式

作为开发者,你不仅要编写可以工作的代码,而且要尽可能使用现有的解决方案,以便将来可以更好地维护你的代码。如果其他开发者需要在你项目中工作,他们会很快理解你在做什么。这要归功于软件设计模式。

为了正确理解这些模式,我们需要了解 Android 中并发工作原理的基本概述。我们将阐明 UI 线程是什么,并讨论在线程中延迟事件的不同机制。

我们将介绍在 Android 中最常用的模式,这将帮助我们进一步了解 Android 功能和开发技术,并成为更好的开发者。

  • 并发

    • 处理器和线程

    • AsyncTask

    • 服务

    • IntentService

    • 加载器

  • Android 中的模式

    • 单例

    • 适配器和持有者

    • 观察者

Android 中的并发

如果你是一个 Android 用户,你可能会对 ANR 消息有所了解。这可能不会让你立刻明白,所以请看以下图片:

Android 中的并发

活动无响应ANR)发生在 UI 或主线程中运行代码时,阻止用户交互超过 5 秒钟。

在 Android 中,一个应用程序运行一个单一的线程,称为用户界面线程。我们将解释线程是什么,即使是没有编程背景的读者也能理解。我们可以将线程视为由 CPU 执行的指令或消息列。这些指令来自不同的地方;它们来自我们的应用程序以及操作系统。这个线程用于处理用户的响应、生命周期方法和系统回调。

CPU 逐个顺序地处理消息;如果它很忙,消息将在队列中等待执行。因此,如果我们在应用程序中执行长时间的操作并向 CPU 发送许多消息,我们将不会让 UI 消息得到执行,这将导致用户感受到手机无响应。

Android 中的并发

这个问题的解决方案似乎很明显:如果一个线程不够用,我们可以使用多个。例如,如果我们发起一个网络请求,这将在另一个线程中完成,并且当它结束时,它会与主线程通信以显示所请求的数据。

只有主线程或 UI 线程可以访问 UI;因此,如果我们 在另一个线程中执行任何后台计算,我们必须告诉主线程显示这些计算的结果,因为我们不能直接从那里做。

处理器和线程

我们之前描述的消息在一个名为MessageQueue的队列中运行,这个队列是每个线程独有的。一个处理器可以向这个队列发送消息。当我们创建一个处理器时,它与创建它的线程的MessageQueue相关联。

处理器用于两种情况:

  • 向同一线程发送延迟消息

  • 向另一个线程发送消息

这就是为什么在我们的SplashActivity中,我们将使用以下代码:

new Handler().postDelayed(new Runnable() {
  @Override
  public void run() {

    Intent intent = new Intent(SplashActivity.this, MainActivity.class)

    startActivity(intent);
  }
},3000);

提示

当你创建一个新的Handler()方法时,请确保导入Android.OS处理程序。

在这里,我们使用了postDelayed(Runnable, time)方法来发送一个延迟的消息。在这种情况下,消息是一个可运行对象,表示可以执行的命令。

runOnUIThread()活动内部有一个方法,允许我们向 UI 线程发送一个可运行对象时,就不需要创建处理程序与它通信。当我们有活动的上下文并且想在 UI 上运行某些内容时,这非常有用,例如从在后台执行的任务中向 UI 发布更新。

如果我们查看 Android 源代码中的方法,我们可以看到它只是简单地使用处理程序在 UI 线程中发布可运行对象:

public final void runOnUiThread(Runnable action) {
  if (Thread.currentThread() != mUiThread) {
    mHandler.post(action);
  } else {
    action.run();
  }
}

通常,当我们想要在后台执行长时间任务并想要管理并行线程执行时,会手动创建线程。线程有一个run()方法,在其中执行指令,并在创建后启动以执行run()

Thread thread = new Thread(){

  @Override
  public void run() {
    super.run();
  }
};

thread.start();

创建线程和处理程序以执行后台任务的缺点是手动处理,如果我们有太多这样的任务,很容易导致应用程序变得难以阅读。Android 有其他机制来执行任务,如AsyncTask

介绍 AsyncTasks

这可能是你在初学者级别看到的内容,但我们将从并发性的角度来查看它。Asynctask基于线程和处理程序,旨在轻松地在后台执行任务并发布 UI 更新。

要使用AsyncTask,需要对其进行子类化,它有四个可以被覆盖的方法:onPreExecutedoInBackgroundonProgressUpdateonPostExecute

OnPreExecute方法在后台执行任何工作之前调用;这意味着它仍在 UI 线程上,并用于在开始任务前初始化变量和进度。

doInBackground方法在后台线程中执行。在这里,你可以调用onProgressUpdate,它向 UI 线程发布一个更新,例如,通过增加ProgressBar的值来显示任务的进度。

最后一个方法,onPostExecute,在后台任务完成并且在 UI 线程上运行时被调用。

以一个例子来说明:一个AsyncTask在后台运行需要x秒完成,并且每秒更新一次进度。进度条对象作为参数在构造函数中传递,秒数作为参数在execute方法中传递,在doInBackground中检索。请注意,在以下代码中,<Integer,Integer,Void>类型分别指的是输入参数、进度更新和后执行阶段的类型:

public class MyAsyncTask extends AsyncTask<Integer,Integer,Void> {

  ProgressBar pB;

  MyAsyncTask(ProgressBar pB) {
    this.pB = pB;
  }

  @Override
  protected void onPreExecute() {
    super.onPreExecute();
    pB.setProgress(0);
  }

  @Override
  protected void onProgressUpdate(Integer... values) {
    super.onProgressUpdate(values);
    pB.setProgress(values[0]);
  }

  @Override
  protected Void doInBackground(Integer... integers) {
    for (int i = 0; i < 10; i++){
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      onProgressUpdate(new Integer[]{i});
    }
    return null;
  }

  @Override
  protected void onPostExecute(Void o) {
    super.onPostExecute(o);
    Log.d("AsyncTask","Completed");
  }

}

创建了AsyncTask类后,以下是我们可以执行它的方式:

new MyAsyncTask( progressBar ).execute(new Integer[]{10});

如果我们同时执行一个以上的AsyncTask,从 Android 3.0 版本开始,它们默认会按顺序运行。如果我们想要并行运行它们,将不得不创建一个执行器,并使用THREAD_POOL_EXECUTOR参数调用executeOnExecutor()

至于限制,我们应该提到AsyncTask必须始终从主线程执行,并且不能在同一个对象中两次调用execute();因此,它们不能循环。

了解服务

当下载文件或执行任何短操作时,AsyncTask是理想的,你可以在任务完成时通知 UI。然而,在 Android 中,有些情况你需要执行一个非常长的任务,可能不需要 UI 交互。例如,你可以有一个应用在服务器上打开一个套接字(直接通道),用于流式传输音频,以便于收听广播应用。

即使应用不在屏幕上显示,服务仍然可以运行;它默认在后台运行,但使用的是主线程。因此,如果我们想要执行长时间的任务,需要在服务内部创建一个线程。它必须在清单文件中声明,如果我们将其声明为公开,也可以从另一个应用中使用。

AsyncTask相反,服务可以从任何线程触发;它们通过onStartService()方法触发,并通过onStopService()停止。

可选地,服务可以绑定到一个组件;一旦你绑定了组件,就会调用onBind()。当绑定发生时,我们有一个接口可供组件与服务交互。

一种服务类型 – IntentService

IntentServiceservices的一个子类,可以从一个意图触发。它创建一个线程并包含回调,以了解任务何时完成。

IntentService背后的想法是,如果你不需要并行运行任务,实现一个接收意图并顺序处理它们的服务,并在任务完成时通知,会更容易。

当我们调用onStart时,服务会不断运行;然而,IntentService被创建后,只在收到意图并完成任务的小时间段内运行。

一个真实的例子,我们可以考虑一个应用,在应用不在屏幕上显示时,需要在后台执行短任务。这可能是新闻阅读器应用的情况,它将新闻存储在您的设备上,以便您在没有网络连接的情况下离线访问。这可能是一个每天发布文章的报纸应用,允许用户在没有网络连接的飞机上或在通勤的火车上阅读文章。

想法是,当文章发布时,应用在后台运行时用户会收到推送通知。这个通知将触发一个意图去下载文章,这样用户下次打开应用时,不需要任何额外交互,文章就已经在那里了。

下载文章是一个小而重复的任务,需要在应用在后台、在线程中运行时完成,无需并行操作,这正是IntentService完美适用的场景。

引入装载器(Introducing loaders)

在结束并发部分之前,我们将快速了解一下Loader类。装载器的目的是在活动中更容易地异步加载数据,因此在片段中也是如此。从 Android 3.0 开始,每个活动都有LoaderManager来管理在其中使用的装载器。在基于片段导航的应用程序中,即使在你切换片段之间,也可以在活动级别执行后台操作。

装载器(Loaders)从数据源加载数据;当这个数据源发生变化时,它会自动刷新信息,这也是为什么装载器与数据库配合使用堪称完美。例如,一旦我们将装载器连接到数据库,这个数据库就可能被修改,而装载器将捕获这些更改。这将允许我们即时刷新用户界面,让用户立即看到变化。

在第八章《数据库和装载器》中,我们将实现CursorLoader来查询我们将在MasteringAndroidApp中创建的数据库。

模式的重要性(The importance of patterns)

当软件开发者需要开发具有特定功能的功能或组件时,通常可以用不同的方法来做;可以用不同的代码或不同的结构来实现。很可能同样的问题已经被其他开发者解决了无数次,以至于解决方案从具体实现中抽象出来,变成了一个模式。与其重新发明轮子,不如了解并实施这些模式。

在 Android 开发中,我们每天都在使用模式,即使我们没有意识到这一点。大多数时候,我们使用的是 Android 内置的模式实现。例如,当我们想要对按钮执行点击操作并设置OnClickListener时——换句话说,就是等待onClick()方法被调用——我们使用的是观察者模式实现。如果我们创建一个弹出窗口AlertDialog,我们使用的是AlertDialog.Builder,它使用了生成器模式(Builder pattern)。有许多这样的例子,但我们希望的是能够将这些解决方案应用到我们自己的问题中。

有不同类型的模式分为四类,以下是在开发 Android 应用时我们可能会遇到的一些例子:

  • 创建(Creation)

    • 单例(Singleton)

    • 生成器(Builder)

    • 工厂方法(Factory method)

  • 行为型(Behavioral)

    • 观察者(Observer)

    • 策略(Strategy)

    • 迭代器(Iterator)

  • 结构型(Structural)

    • 适配器(Adapter)

    • 门面(Façade)

    • 装饰器(Decorator)

  • 并发(Concurrency)

    • 锁(Lock)

    • 调度器(Scheduler)

    • 读写锁(Read-write lock)

为了完成MasteringAndroidApp,我们需要实现前三个组的模式。关于第四组(并发),我们需要了解 Android 中的并发概念,但我们不需要自己实现一个并发模式。

提示(Tip)

模式通常由 UML 图表示。

根据维基百科(en.wikipedia.org/wiki/Class_diagram),“在软件工程中,统一建模语言(UML)中的类图是一种静态结构图,通过显示系统的类,它们的属性,操作(或方法)以及对象之间的关系来描述系统的结构”。

单例模式

软件设计模式中的单例(Singleton),限制了对象的创建,使其只能有一个实例。想法是全局访问这个单一对象。

这种模式通过如果之前没有创建过对象就创建对象,如果已经创建过就返回现有实例来实现。以下是 UML 图:

单例模式

在某些情况下,我们希望一个对象能够全局访问,并且我们希望它在应用程序中是唯一的。例如,在使用 Volley 时,我们希望保持一个唯一的请求队列,以便所有请求都在同一个队列中,并且我们希望它能够全局访问,因为我们需要从任何片段或活动中添加请求。

这是一个单例实现的基本示例:

public class MySingleton {

    private static MySingleton sInstance;

    public static MySingleton getInstance(){
        if (sInstance == null) {
            sInstance = new MySingleton();
        }
        return sInstance;
    }
}

为了理解实现,请记住在 Java 中,静态变量与类相关联,而不是与对象相关联。同样,静态方法可以在不创建类实例的情况下调用。

拥有一个静态方法意味着它可以从应用程序的任何地方被调用。我们可以调用 MySingleton.getInstance(),它将始终返回相同的实例。第一次调用时,它会创建并返回它;后续调用时,它会返回已创建的实例。

使用单例和测试框架有一个缺点;我们将在第十一章,在 Android 上调试和测试中讨论这个问题。

应用程序类中的单例

我们可以将单例实现适配到 Android。鉴于在 Application 类中的 onCreate 方法在我们打开应用程序时只被调用一次,且 Application 对象不会被销毁,我们可以在应用程序中实现 getInstance() 方法。

应用这些更改后,我们的应用程序类将类似于以下结构:

public class MAApplication extends Application {

  private static MAApplication sInstance;

  @Override
  public void onCreate() {
    super.onCreate();

    sInstance = this;

    // Enable Local Datastore.
    Parse.enableLocalDatastore(this);

    ParseObject.registerSubclass(JobOffer.class);

    Parse.initialize(this, "KEy", "KEY");
  }

  private static MAApplication getInstance(){
    return sInstance;
  }
}

现在,我可以在应用程序的任何地方调用 MAAplication.getInstance(),并在应用程序类中创建成员变量,通过单例 MAAplication 对象全局访问这些变量。例如,在 Volley 的情况下,我可以在 OnCreate() 中创建 RequestQueue,然后随时从 MAAplication 对象中获取它。执行以下代码:

private RequestQueue mRequestQueue;

@Override
public void onCreate() {
  super.onCreate();

  sIntasnce = this;

  mRequestQueue = Volley.newRequestQueue(this);
  .
  .
  .
}

public RequestQueue getRequestQueue(){
  return mRequestQueue;
}

采用这种方法,我们有一个单例,即我们的 Application 类;其余的全局可访问对象都是成员变量。另一种选择是创建一个新的单例类来存储 Volley 请求队列以及每个需要全局访问的新请求单例。

提示

不要在Application类中使用这种方法来持久化数据。例如,如果我们点击主页按钮进入后台,过了一段时间后,Android 可能需要内存,会杀死应用。因此,下次打开应用时,即使看起来我们回到了上一个实例,也会创建一个新的实例。如果你在onCreate中重新初始化所有变量,并且后来不改变它们的状态,这样做是可以的。为了避免这种情况,请避免使用 setters。

观察者模式

这种模式在 Android 中得到了广泛的使用。我们讨论的大多数网络库都实现了这种模式,如果你是 Android 开发者,你肯定已经多次使用过它——我们需要实现它,甚至是为了检测按钮的点击。

观察者模式基于一个对象,即观察者,它注册其他对象来通知它们状态变化;在这里,监听状态变化的对象就是观察者。这种模式可以用来创建一个发布/订阅系统:

观察者模式

以下是一个注册多个观察者的模式的实现:

public class MyObserved {

  public interface ObserverInterface{
    public void notifyListener();
  }

  List<ObserverInterface> observersList;

  public MyObserved(){
    observersList = new ArrayList<ObserverInterface>();
  }

  public void addObserver(ObserverInterface observer){
    observersList.add(observer);
  }

  public void removeObserver(ObserverInterface observer){
    observersList.remove(observer);
  }

  public void notifyAllObservers(){
    for (ObserverInterface observer : observersList){
      observer.notify();
    }
  }
}

public class MyObserver
implements MyObserved.ObserverInterface {

  @Override
  public void notify(){
    //Do something
  }
}

你会注意到,观察者可以是任何实现了接口—ObserverInterface的对象。这个接口是在被观察对象中定义的。

如果我们将这个与在 Android 中处理按钮点击的方式进行比较,我们执行myButton.setOnClickListener(observer)。在这里,我们添加了一个等待点击的观察者;这个观察者实现了OnClick()方法,这是在我们的案例中通知的方法。

在 Volley 中,当我们创建一个网络请求时,必须指定两个作为参数的监听器:Response.ListenerResponse.ErrorListener,它们分别调用onResponse()onErrorResponse()。这是观察者模式的清晰实现。

我们将在第六章,CardView 和材料设计中实现观察者模式的一个变体,即发布/订阅模式的例子。

介绍适配器模式

适配器是我们在创建ListViewViewPager时在 Android 中使用的元素,但它也是一个众所周知的设计模式。我们将看看两者的定义及其关系。

一方面,适配器作为一种设计模式,它充当两个不兼容接口之间的桥梁。它允许两个不同的接口一起工作。这就像现实世界中的适配器,比如 SD 卡到 micro SD 卡的适配器,它允许两个不兼容的系统一起工作。如图所示,适配器被调用新的所需方法,但在内部,它调用来自被适配者的旧方法。

介绍适配器模式

另一方面,来自android.widget.Adapter的适配器是一个对象,我们用它为列表的每一行或视图分页的每一页创建视图。因此,它适配数据、一组元素和一组视图。

要实现一个适配器,我们必须扩展BaseAdapter并重写getView()getCount()方法。通过这两个方法,适配器将知道它需要创建多少个视图以及如何创建这些视图。

在下一章中,我们将更深入地探讨这个主题,同时使用ListViews,我们还将讨论ViewHolder模式,这是在 Android 中使用适配器和列表时的一种特殊模式。

总结

在本章结束时,你应该能够理解 Android 中的并发性以及与之工作的所有不同机制。你应该知道有一个主线程用于更新 UI,我们可以创建后台线程来执行其他任务。你还必须知道让应用在后台(换句话说,不在屏幕上)执行任务与在后台线程中执行任务的区别。你也应该知道软件设计模式的重要性,并能够实现其中的一些。

在下一章中,我们将学习如何使用列表视图,我们将实现一个适配器,并发现一种新模式ViewHolder,它将是理解从 Android Lollipop 开始引入的ListViewRecyclerView之间区别的关键。

第五章:列表和网格

在本章中,我们将处理列表和网格。几乎在市场上的每个应用中都可以找到列表或元素矩阵。在 Android 上显示元素列表是你在基础层面需要学习的东西;然而,还有很多需要扩展和了解的内容。

了解我们可以在这里使用哪些模式,如何回收视图,以及如何在同一个列表中用不同的视图显示不同类型的元素是非常重要的。

有了这个想法,我们将能够理解为什么 RecyclerViewListView 的继任者,并且我们将学习如何使用这个组件实现列表。因此,在本章中我们将涵盖以下内容:

  • 从列表开始

    • ListView

    • 自定义适配器

    • 视图回收

    • 使用 ViewHolder 模式

  • 介绍 RecyclerView

    • 列表、网格或堆叠

    • 实现

  • OnItemClick

从列表开始

如果你听说过 RecyclerView,你可能会想知道为什么我们要学习 ListViewRecyclerView 小部件是新的,它随着 Android Lollipop 一起出现,在显示项目列表时是一场革命;它可以垂直和水平显示列表或网格,或者带有漂亮的动画以及其他改进。

回答这个问题,即使 RecyclerView 在某些情况下更有效和灵活,它需要额外的编码来实现相同的结果,所以仍然有使用 ListView 的理由。例如,RecyclerView 中没有用于项目选择的 onItemClickListener(),点击项目时也没有视觉反馈。如果我们不需要定制和动画,例如一个简单的数据选择器弹出窗口,这可能是一个我们只需要选择一个国家的对话框。在这种情况下,使用 ListView 而不是 RecyclerView 是完全没问题的。

另一个从 ListView 开始的原因是 RecyclerView 解决了与 ListViews 一起工作时出现的多数问题。因此,通过从 ListView 开始并解决这些问题,我们将完全理解 RecyclerView 的工作原理以及为什么要以这种方式实现它。因此,我们将单独解释使用的模式,以便对组件有一个全局的了解。

这是一个基本的 AlertDialog 示例,目的是选择一个项目;在这种情况下,使用 ListView 是非常有意义的:

Starting with lists

使用内置视图的 ListViews

当你第一次实现 ListView 时,可能会觉得它微不足道且简单;然而,当你花更多时间在 Android 上时,你会意识到它会变得多么复杂。仅仅通过每一行都有图片的大量元素列表,你就很容易发现性能和内存问题。如果你尝试实现复杂的 UI,比如让同一个列表显示不同的项目,创建具有不同视图的不同行,甚至尝试对某些项目进行分组同时显示一个节标题,这些都可能让人头疼。

让我们从最简单的方式来实现一个列表开始,使用 Android 内置的条目布局,这是为了在之前讨论的简单列表中使用而创建的。为了显示列表,我们将在 AlertDialog 中包含它,当我们点击设置片段中的按钮时,它将显示出来。我会将按钮的文本设置为 Lists Example(列表示例)。

第一步是在 settings_fragment.xml 中创建按钮;一旦创建,我们就可以为按钮设置点击监听器。现在,我们对于软件模式有了更多的了解,而不是以下这种方式设置点击监听器:

view.findViewById(R.id.settingsButtonListExample).setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    //Show the dialog here
  }
});

我们将以更有条理的方式进行操作,尤其是因为我们知道在设置屏幕上,将会有很多按钮,我们希望在同一地方处理所有的点击事件。我们不会在方法调用内部创建 onClickListener,而是通过将 onClickListener 设置为 this 来让 Fragment 实现 OnClikListener。这里的 this 关键字指的是整个片段,因此片段将在 onClick 方法中监听点击事件,一旦 Fragment 实现 View.OnClickListener,这是必须实现的。

OnClick() 方法接收一个视图,即被点击的视图。如果我们将该视图的 ID 与按钮的 ID 进行比较,我们就会知道是按钮还是设置了 clickListener 的其他视图被点击了。

在定义类时只需键入 implements View.OnClickListener,你将被要求实现必填的方法:

/**
* Settings Fragment
*/
public class SettingsFragment extends Fragment implements View.OnClickListener {

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container,
  Bundle savedInstanceState) {
    // Inflate the layout for this fragment
    View view = inflater.inflate(R.layout.fragment_settings, container, false);

    view.findViewById(R.id.settingsButtonListExample).setOnClickListener(this);

    view.findViewById(R.id.ViewX).setOnClickListener(this);

    view.findViewById(R.id.imageY).setOnClickListener(this);

    return view;
  }

  @Override
  public void onClick(View view) {
    switch (view.getId()){
      case (R.id.settingsButtonListExample) :
      showDialog();
      break;
      case (R.id.viewX) :
      //Example
      break;
      case (R.id.imageY) :
      //Example
      break;

      //...
    }
  }

  public void showListDialog(){
    //Show Dialog here
  }
}

你会注意到,我们将显示列表对话框的逻辑也移动到了一个外部方法中,这样在 onClick(); 中结构更容易阅读。

继续关于对话框的讨论,我们可以显示一个具有 setAdapter() 属性的 AlertDialog,它自动将内部 ListView 的条目绑定在一起。或者,我们可以为对话框创建一个带有 ListView 的视图,然后将适配器设置给那个 ListView

/**
*  Show a dialog with different options to choose from
*/
public void showListDialog(){

  AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());

  final ArrayAdapter<String> arrayAdapter = new ArrayAdapter<String>(
  getActivity(),
  android.R.layout.select_dialog_singlechoice);
  arrayAdapter.add("Option 0");
  arrayAdapter.add("Option 1");
  arrayAdapter.add("Option 2");

  builder.setTitle("Choose an option");

  builder.setAdapter(arrayAdapter,
  new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialogInterface, int i) {
      Toast.makeText(getActivity(),"Option choosen "+i, Toast.LENGTH_SHORT).show();
      dialogInterface.dismiss();
    }
  });

  builder.show();
}

这个对话框将显示一个指示点击选项的消息。我们使用了 android.R.layout.select_dialog_singlechoice 作为我们行的视图。

这些是我们应用程序内置列表布局的几个不同示例,具体取决于应用的主题。例如,在 4.4 KitKat 和 5.0 Lollipop 中,android.R.layout.simple_list_item_1 的对话框看起来是不同的,以下是它的样子:

使用内置视图的 ListViews

android.R.layout.simple_list_item_2 以下是带有两行的布局看起来类似的:

使用内置视图的 ListViews

这是一个 android.R.layout.simpleListItemChecked 的示例,我们可以将选择模式更改为多选或单选:

使用内置视图的 ListViews

这是 android.R.layout.activityListItem,我们有一个图标和文本:

使用内置视图的 ListViews

在创建布局时,我们可以访问这些内置的布局组件以进一步调整视图。这些组件被命名为android.resource.id.Text1android.resource.id.Text2android.resource.id.Icon等等。

现在,我们知道了如何创建带有功能和视图的列表。是时候创建我们自己的适配器并手动实现功能和视图了。

创建自定义适配器

当你寻找工作时,除了查看职位信息,你还会向不同的软件公司或 IT 招聘公司提交你的简历,这些公司会为你找到合适的公司。

在我们的联系人片段中,我们将创建一个按国家排序的列表,显示这些公司的联系人详细信息。将会有两种不同的行:一种用于国家头部,另一种用于公司详细信息。

我们可以在我们的 Parse 数据库中创建另一个表,名为JobContact,包含以下字段:

创建自定义适配器

我们将从服务器请求工作联系人并构建一个项目列表,该列表将发送到适配器以构建列表。在列表中,我们将发送两个不同的元素:公司和国家。我们可以生成一个项目列表并将它们作为对象添加。我们的两个类将类似于以下内容:

@ParseClassName("JobContact")
public class JobContact extends ParseObject {

  public JobContact() {
    // A default constructor is required.
  }

  public String getName() {
    return getString("name");
  }

  public String getDescription() {
    return getString("description");
  }

  public String getCountry() {
    return getString("country");
  }

  public String getEmail() {
    return getString("email");
  }

}

public class Country {

  String countryCode;

  public Country(String countryCode) {
    this.countryCode = countryCode;
  }

}

一旦我们从www.parse.com按国家排序下载了信息,我们就可以构建我们的项目列表,遍历解析列表,并在检测到不同国家时添加一个国家头部。执行以下代码:

public void retrieveJobContacts(){
  ParseQuery<JobContact> query = ParseQuery.getQuery("JobContact");
  query.orderByAscending("country");
  query.findInBackground(new FindCallback<JobContact>() {
    @Override
    public void done(List<JobContact> jobContactsList, ParseException e) {
      mListItems = new ArrayList<Object>();
      String currentCountry = "";
      for (JobContact jobContact: jobContactsList) {
        if (!currentCountry.equals(jobContact.getCountry())){
          currentCountry = jobContact.getCountry();
          mListItems.add(new Country(currentCountry));
        }
        mListItems.add(jobContact);
      }
    }
  });
}

现在我们有了包含头部的列表,我们可以基于这个列表创建Adapter,它将在构造函数中作为参数发送。自定义Adapter的最佳方式是创建一个扩展BaseAdapter的子类。一旦我们这样做,我们将被要求实现以下方法:

public class JobContactsAdapter extends BaseAdapter {
  @Override
  public int getCount() {
    return 0;
  }

  @Override
  public Object getItem(int i) {
    return null;
  }

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

  @Override
  public View getView(int i, View view, ViewGroup viewGroup) {
    return null;
  }
}

这些方法必须根据我们想要显示的数据来实现;例如,getCount()必须返回列表的大小。我们需要实现一个接收两个参数的构造函数:列表和上下文。上下文在getView()方法中填充列表是必要的。下面是没有实现getView()的适配器样子:

public class JobContactsAdapter extends BaseAdapter {

  private List<Object> mItemsList;
  private Context mContext;

  public JobContactsAdapter(List<Object> list, Context context){
    mItemsList = list;
    mContext = context;
  }

  @Override
  public int getCount() {
    return mItemsList.size();
  }

  @Override
  public Object getItem(int i) {
    return mItemsList.get(i);
  }

  @Override
  public long getItemId(int i) {
    //Not needed
    return 0;
  }

  @Override
  public View getView(int i, View view, ViewGroup viewGroup) {
    return null;
  }
}

在我们的案例中,我们可以创建两种不同的视图;因此,除了必须实现的方法外,我们还需要实现两个额外的方法:

@Override
public int getItemViewType(int position) {
  return mItemsList.get(position) instanceof Country ? 0 : 1;
}

@Override
public int getViewTypeCount() {
  return 2;
}

getItemViewType方法会返回0如果元素是一个国家,或者1如果元素是一个公司。借助这个方法,我们可以实现getView()。如果是国家,我们填充row_job_country.xml布局,其中包含ImageViewTextView;如果是公司,我们填充row_job_contact.xml布局,其中包含三个文本视图:

@Override
public View getView(int i, View view, ViewGroup viewGroup) {

  View rowView = null;
  switch (getItemViewType(i)){

    case (0) :
    rowView = View.inflate(mContext, R.layout.row_job_country,null);
    Country country = (Country) mItemsList.get(i);
    ((TextView) rowView.findViewById(R.id.rowJobCountryTitle)).setText(country.getName());
    ((ImageView) rowView.findViewById(R.id.rowJobCountryImage)).setImageResource(country.getImageRes(mContext));
    break;

    case (1) :
    rowView = View.inflate(mContext, R.layout.row_job_contact,null);
    JobContact company = (JobContact) mItemsList.get(i);
    ((TextView) rowView.findViewById(R.id.rowJobContactName)).setText(company.getName());
    ((TextView) rowView.findViewById(R.id.rowJobContactEmail)).setText(company.getEmail());
    ((TextView) rowView.findViewById(R.id.rowJobContactDesc)).setText(company.getDescription());
  }

  return rowView;
}

最后,我们可以在contact_fragment.xml中创建ListView,并将适配器设置到这个列表。但是,我们将采取捷径,使用android.support.v4.ListFragment;这是一个已经膨胀了包含ListView的视图并包含setListAdapter()方法的片段,该方法将适配器设置到内置的ListView。从该片段扩展,我们的ContactFragment类将类似于以下代码:

public class ContactFragment extends android.support.v4.app.ListFragment {

  List<Object> mListItems;

  public ContactFragment() {
    // Required empty public constructor
  }

  @Override
  public void onViewCreated(View view, Bundle bundle) {
    super.onViewCreated(view,bundle);
    retrieveJobContacts();
  }

  public void retrieveJobContacts(){
    ParseQuery<JobContact> query = ParseQuery.getQuery("JobContact");
    query.orderByAscending("country");
    query.findInBackground(new FindCallback<JobContact>() {
      @Override
      public void done(List<JobContact> jobContactsList, ParseException e) {
        mListItems = new ArrayList<Object>();
        String currentCountry = "";
        for (JobContact jobContact: jobContactsList) {
          if (!currentCountry.equals(jobContact.getCountry())){
            currentCountry = jobContact.getCountry();
            mListItems.add(new Country(currentCountry));
          }
          mListItems.add(jobContact);
        }
        setListAdapter(new JobContactsAdapter(mListItems,getActivity()));
      }
    });
  }
}

在视图创建后调用retrieveJobContacts()方法,我们实现了以下结果:

创建自定义适配器

我们展示的旗帜是drawable文件夹中的图片,其名称与国家代码相匹配,drawable/ "country_code" .png。我们可以在Country类内部通过以下方法设置资源标识符到ImageView并获取它们来展示这些图片:

public int getImageRes(Context ctx){
  return ctx.getResources().getIdentifier(countryCode, "drawable", ctx.getPackageName());
}

这是一个基础版本的ListView,包含两种不同类型的行。这个版本还远非完美,其性能存在不足。它没有回收视图,并且每次创建行时都会查找小部件的 ID。我们将在下一节中解释并解决这个问题。

回收视图

在使用ListView时,我们需要牢记行数是一个变量,即使我们尽可能快速地滚动,我们也希望列表流畅无阻。幸运的是,Android 在这方面为我们提供了很大帮助。

当我们滚动ListView时,屏幕一侧不再可见的视图会被复用并在另一侧重新显示。这样,Android 节省了视图的膨胀;当它膨胀时,视图必须遍历 xml 节点,实例化每个组件。这种额外的计算可能是流畅列表和卡顿列表之间的区别。

回收视图

getView()方法接收一个待回收的视图作为参数,如果没有视图待回收,则接收 null。

为了利用这种视图回收,我们需要停止每次都创建视图,而是复用作为参数传入的视图。我们仍然需要在回收的视图中更改文本视图和行内小部件的值,因为它具有与其先前位置的初始值相对应的值。在我们的示例中,我们有一个额外的复杂性;我们不能将国家视图回收用于公司视图,因此我们只能回收相同视图类型的视图。然而,再次强调,Android 通过内部使用我们实现的getItemViewType方法来为我们进行这个检查:

@Override
public View getView(int i, View view, ViewGroup viewGroup) {

  switch (getItemViewType(i)){

    case (0) :
    if (view == null){
      view = View.inflate(mContext, R.layout.row_job_country,null);
    }
    Country country = (Country) mItemsList.get(i);
    ((TextView) view.findViewById(R.id.rowJobCountryTitle)).setText(country.getName());
    ((ImageView) view.findViewById(R.id.rowJobCountryImage)).setImageResource(country.getImageRes(mContext));
    break;

    case (1) :
    if (view == null){
      view = View.inflate(mContext, R.layout.row_job_contact,null);
    }
    JobContact company = (JobContact) mItemsList.get(i);
    ((TextView) view.findViewById(R.id.rowJobContactName)).setText(company.getName());
    ((TextView) view.findViewById(R.id.rowJobContactEmail)).setText(company.getEmail());
    ((TextView) view.findViewById(R.id.rowJobContactDesc)).setText(company.getDescription());
  }

  return view;
}

应用 ViewHolder 模式

请注意,在getView()中,每次我们想要为TextView设置文本时,都会使用findViewById()方法在行视图中搜索这个TextView;即使行被回收,我们仍然需要再次找到TextView来设置新值。

我们可以创建一个名为ViewHolder的类,它通过保存行内控件搜索的计算来引用控件。这个ViewHolder类将只包含对控件的引用,我们可以通过setTag()方法在行和其ViewHolder类之间保持引用。View对象允许我们设置一个对象作为标签,并在之后检索它;我们可以通过指定一个键来添加任意数量的标签:setTag(key)getTag(key)。如果没有指定键,我们可以保存和检索默认标签。

按照这种模式,在我们第一次创建视图时,我们将创建ViewHolder类并将其设置为视图的标签。如果视图已经创建并且我们正在回收它,我们只需简单地检索持有者。执行以下代码:

@Override
public View getView(int i, View view, ViewGroup viewGroup) {

  switch (getItemViewType(i)){

    case (0) :
    CountryViewHolder holderC;
    if (view == null){
      view = View.inflate(mContext, R.layout.row_job_country,null);
      holderC = new CountryViewHolder();
      holderC.name = (TextView) view.findViewById(R.id.rowJobCountryTitle);
      holderC.flag = (ImageView) view.findViewById(R.id.rowJobCountryImage);
      view.setTag(view);
    } else {
      holderC = (CountryViewHolder) view.getTag();
    }
    Country country = (Country) mItemsList.get(i);
    holderC.name.setText(country.getName());
    holderC.flag.setImageResource(country.getImageRes(mContext));
    break;
    case (1) :
    CompanyViewHolder holder;
    if (view == null){
      view = View.inflate(mContext, R.layout.row_job_contact,null);
      holder = new CompanyViewHolder();
      holder.name = (TextView) view.findViewById(R.id.rowJobContactName);
      holder.email = (TextView) view.findViewById(R.id.rowJobContactEmail);
      holder.desc = (TextView) view.findViewById(R.id.rowJobOfferDesc);
      view.setTag(holder);
    } else {
      holder = (CompanyViewHolder) view.getTag();
    }
    JobContact company = (JobContact) mItemsList.get(i);
    holder.name.setText(company.getName());
    holder.email.setText(company.getEmail());
    holder.desc.setText(company.getDescription());
  }

  return view;
}

private class CountryViewHolder{

  public TextView name;
  public ImageView flag;

}

private class CompanyViewHolder{

  public TextView name;
  public TextView email;
  public TextView desc;

}

为了简化这段代码,我们可以在每个持有者内部创建一个名为bindView()的方法;它将获取一个国家或公司对象并填充控件:

CountryViewHolder holderC;
if (view == null){
  view = View.inflate(mContext, R.layout.row_job_country,null);
  holderC = new CountryViewHolder(view);
  view.setTag(view);
} else {
  holderC = (CountryViewHolder) view.getTag();
}
holderC.bindView((Country)mItemsList.get(i));
break;

private class CountryViewHolder{

  public TextView name;
  public ImageView flag;

  public CountryViewHolder(View view) {
    this.name = (TextView) view.findViewById(R.id.rowJobCountryTitle);
    this.flag = (ImageView) view.findViewById(R.id.rowJobCountryImage);
  }

  public void bindView(Country country){
    this.name.setText(country.getName());
    this.flag.setImageResource(country.getImageRes(mContext));
  }

}

现在我们来完成ListView性能改进列表。如果需要加载图像或执行长时间的操作,我们需要在getView()中创建AsyncTask方法,以避免在滚动时进行繁重操作。例如,如果我们想在每一行显示从互联网下载的图像,我们会有一个LoadImageAsyncTask方法,我们将使用持有者和要下载图像的 URL 来执行它。当Asynctask方法完成时,它将有一个对持有者的引用,因此能够显示图像:

public View getView(int position, View convertView,
ViewGroup parent) {

  ...

  new LoadImageAsyncTask(list.get(position).getImageUrl, holder)
  .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, null);

  return convertView;
}

现在我们知道了所有改进ListView性能的不同技术,我们准备引入RecyclerView。通过在实现中应用这些技术的大部分,我们将能够轻松识别它。

引入 RecyclerView

RecyclerView在 Android 5.0 Lollipop 中引入,并被谷歌定义为比ListView更灵活和先进的版本。它基于一个类似于ListViewAdapter类,但强制使用ViewHolder类以提高性能和模块化,正如我们在前一部分所看到的。当我们把条目表示与组件分离,允许动画、条目装饰和布局管理器来处理工作时,灵活性便体现出来了。

RecyclerView通过使用RecyclerView.ItemAnimator处理添加和移除动画,我们可以通过继承它来自定义动画。如果你从数据源显示数据,或者数据发生变化,例如添加或移除条目,可以调用notifyItemInserted()notifyItemRemoved()来触发动画。

要添加分隔符、分组条目或突出显示一个条目,我们可以使用RecyclerView.ItemDecoration

使用ListView的主要区别之一是使用布局管理器来定位项目。在ListView中,我们知道项目将始终垂直显示,如果我们想要网格,可以使用GridView。布局管理器使我们的列表更加灵活,因为我们可以按需显示元素,甚至可以创建自己的布局管理器。

使用列表、网格或堆叠布局

默认情况下,我们有三个内置的布局管理器:LinearLayoutManagerGridLayoutManagerStaggeredLayoutManager

LinearLayoutManager以列表形式对齐显示项目,我们可以指定方向——垂直或水平。

使用列表、网格或堆叠

GridLayoutManager以矩阵形式显示项目,我们可以指定列和行:

使用列表、网格或堆叠

StaggereGriddLayoutManager以交错的方式显示项目;这些项目可以有不同的宽度和高度,我们可以使用setGapStrategy()控制它们的显示方式。

使用列表、网格或堆叠

实现RecyclerView

继续使用MasteringAndroidApp,我们将再次实现职位列表,移除ParseQueryAdapter并用RecyclerView替代。我们仍然会从 Parse 查询数据,但这次,我们将做的是将项目列表保存在一个变量中,并使用它来构建RecyclerView.Adapter,供RecyclerView使用。

RecyclerView包含在 v7 支持库中;将之包含在项目中的最佳方式是打开项目结构,点击依赖项标签,并搜索RecyclerView。将展示如下截图所示的结果列表:

实现 RecyclerView

这相当于在build.gradle的依赖项中添加以下这行代码:

dependencies {
  compile fileTree(dir: 'libs', include: ['*.jar'])
  compile 'com.android.support:appcompat-v7:21.0.3'
  compile 'com.parse.bolts:bolts-android:1.+'
  compile fileTree(dir: 'libs', include: 'Parse-*.jar')
  compile 'com.mcxiaoke.volley:library-aar:1.0.1'
  compile 'com.android.support:recyclerview-v7:21.0.3'
}

添加完这行代码后,我们将点击同步 Gradle 与项目文件来更新依赖项,并准备在 XML 中使用RecyclerView

打开fragment_list.xml,将现有的ListView替换为RecyclerView,如下所示:

<android.support.v7.widget.RecyclerView
  android:id="@+id/my_recycler_view"
  android:scrollbars="vertical"
  android:layout_width="match_parent"
  android:layout_height="match_parent"/>

添加后如果没有出现错误,说明依赖项已经正确添加。

下一步是创建适配器。这个适配器与我们为工作联系人创建的适配器略有不同;我们不再继承BaseAdapter,而是继承RecyclerView.Adapter <RecyclerView.MyViewHolder>,这是一个在创建JobOfferAdapter适配器类后实现ViewHolder模式的适配器。但在继承之前,我们必须创建一个内部的MyViewHolder类,继承RecylcerView.ViewHolder。至此,我们有以下代码:

public class JobOffersAdapter  {

  public class MyViewHolder extends RecyclerView.ViewHolder{

    public TextView textViewName;
    public TextView textViewDescription;

    public  MyViewHolder(View v){
      super(v);
      textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
      textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    }
  }
}

现在是从RecyclerView.Adapter<JobsOfferAdapter.MyViewHolder>扩展JobOffersAdapter类。我们将被要求实现以下方法:

@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
  return null;
}

@Override
public void onBindViewHolder(MyViewHolder holder, int position) {

}

@Override
public int getItemCount() {
  return 0;
}

JobsContactsAdapter中的方法相同,我们通过接收工作机会列表创建构造函数,并根据该列表实现适配器方法。

OnBindViewHolder方法会接收到带有位置的 holder;我们需要做的就是从列表中获取该位置的工作机会,并用这些值更新 holder 中的文本视图。OnCreateViewHolder方法将填充视图;在这种情况下,我们只有一个类型,因此我们忽略ViewType参数。下面我们将展示一种使用父级上下文来填充视图的方法,它会作为参数传入。

最后,getItemCount将返回工作机会的数量。完成上述所有任务后,我们新的适配器将使用以下代码创建:

public class JobOffersAdapter extends RecyclerView.Adapter<JobOffersAdapter.MyViewHolder>  {

  private  List<JobOffer> mOfferList;

  public JobOffersAdapter(List<JobOffer> offersList) {
    this.mOfferList = offersList;
  }

  @Override
  public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.row_job_offer, parent, false);
    return new MyViewHolder(v);
  }

  @Override
  public void onBindViewHolder(MyViewHolder holder, int position) {
    holder.textViewName.setText(mOfferList.get(position).getTitle());
    holder.textViewDescription.setText(mOfferList.get(position).getDescription());
  }

  @Override
  public int getItemCount() {
    return mOfferList.size();
  }

  public class MyViewHolder extends RecyclerView.ViewHolder{

    public TextView textViewName;
    public TextView textViewDescription;

    public  MyViewHolder(View v){
      super(v);
      textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
      textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    }
  }
}

这就是我们需要适配器完成的所有工作;现在,我们需要初始化RecyclerView并设置布局管理器以及适配器。适配器必须使用从 Parse 获取的对象列表实例化,就像我们在上一个适配器中获取工作联系人一样。首先,在OnCreateView中,我们将初始化RecyclerView

public class ListFragment extends android.support.v4.app.Fragment {

  public List<JobOffer> mListItems;
  public RecyclerView mRecyclerView;

  public ListFragment() {
    // Required empty public constructor
  }

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container,
  Bundle savedInstanceState) {
    // Inflate the layout for this fragment
    View view = inflater.inflate(R.layout.fragment_list, container, false);

    mRecyclerView = (RecyclerView) view.findViewById(R.id.my_recycler_view);

    // use this setting to improve performance if you know that changes
    // in content do not change the layout size of the RecyclerView
    mRecyclerView.setHasFixedSize(true);

    // use a linear layout manager
    mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

    //Retrieve the list of offers
    retrieveJobOffers();

    return view;
  }

最后,我们将调用retrieveOffers()方法,这是一个async操作。只有当从 Parse 获取结果后,我们才能创建适配器并将其设置到列表中:

public void retrieveJobOffers(){

  ParseQuery<JobOffer> query = ParseQuery.getQuery("JobOffer");
  query.findInBackground(new FindCallback<JobOffer>() {

    @Override
    public void done(List<JobOffer> jobOffersList, ParseException e) {
      mListItems = jobOffersList;
      JobOffersAdapter adapter = new JobOffersAdapter(mListItems);
      mRecyclerView.setAdapter(adapter);
    }

  });
}

检验我们所做工作是否有效的最佳方式是查看控制台是否有错误。如果一切运行正常,你应该能够看到工作机会的列表,如下面的截图所示:

实现 RecyclerView

我们有意添加了一个重复的工作机会,以便删除它,以便看到RecyclerView默认包含的删除动画。我们将在长按监听器中实现这个功能。点击监听器仅用于在详情视图中打开工作机会。我们将在下一节中看到如何实现这一点。

点击RecyclerView的条目

ListView中,检测项目点击相当简单;我们可以简单地执行ListView.setOnItemClickListersetOnItemLongClickListener用于长按。然而,在RecyclerView中这种实现并不是那么快,灵活性的代价是存在的。

这里有两种实现项目点击的方法:一种是通过创建一个实现RecyclerView.OnItemTouchListener的类,并调用RecyclerView的方法addOnItemTouchListener,如下所示:

mrecyclerView.addOnItemTouchListener(new MyRecyclerItemClickListener(getActivity(), recyclerView, new MyRecyclerItemClickListener.OnItemClickListener() {

  @Override
  public void onItemClick(View view, int position){
    // ...
  }

  @Override
  public void onItemLongClick(View view, int position){
    // ...
  }
}));

public class MyRecyclerItemClickListener implements RecyclerView.OnItemTouchListener
{
  public static interface OnItemClickListener
  {
    public void onItemClick(View view, int position);
    public void onItemLongClick(View view, int position);
  }

  private OnItemClickListener mListener;
  private GestureDetector mGestureDetector;

  public MyRecyclerItemClickListener(Context context, final RecyclerView recyclerView, OnItemClickListener listener)
  {
    mListener = listener;

    mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener()
    {
      @Override
      public boolean onSingleTapUp(MotionEvent e)
      {
        return true;
      }

      @Override
      public void onLongPress(MotionEvent e)
      {
        View child = recyclerView.findChildViewUnder(e.getX(), e.getY());

        if(child != null && mListener != null)
        {
          mListener.onItemLongClick(child, recyclerView.getChildPosition(child));
        }
      }
    });
  }

  @Override
  public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e)
  {
    View child = view.findChildViewUnder(e.getX(), e.getY());

    if(child != null && mListener != null && mGestureDetector.onTouchEvent(e))
    {
      mListener.onItemClick(child, view.getChildPosition(child));
    }

    return false;
  }

  @Override
  public void onTouchEvent(RecyclerView view, MotionEvent motionEvent){
    //Empty
  }
}
@Override
public void onRequestDisallowInterceptTouchEvent(RecyclerView view){
  //Empty
}

这种方法的好处在于,我们可以在每个活动或片段中定义onClick内部的操作。点击逻辑不在视图上,一旦我们构建了这个组件,我们就可以在不同的应用程序中重复使用它。

第二种方法是在ViewHolder内部设置和管理点击事件。如果我们想在应用程序的另一部分或在另一个应用程序中重用这个ViewHolder,这里将会有一个问题,因为点击的逻辑在视图内部,而我们在不同的片段或活动中可能需要有不同的逻辑。然而,这种方法可以更容易地检测同一行内不同组件的点击。例如,如果我们在一行内有一个小图标用于删除,另一个用于分享优惠,这种方法更有意义。这样,我们可以在每一行的职位名称上设置点击事件,并在整行上设置长按监听器:

public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener{

  public TextView textViewName;
  public TextView textViewDescription;

  public  MyViewHolder(View v){
    super(v);
    textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
    textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    textViewName.setOnClickListener(this);
    v.setOnLongClickListener(this);
  }

  @Override
  public void onClick(View view) {
    switch (view.getId()){
      case R.id.rowJobOfferTitle :
      //Click
      break;
    }
  }

  @Override
  public boolean onLongClick(View view) {
    //Delete the element here
    return false;
  }
}

你应该能够判断在每种情况下应该使用哪种实现,并为其辩护。为了能够测试这一点,我们将在长按后删除一个元素(这里应该有一个确认对话框以避免误删元素,但我们将省略这部分)。元素将在本地被删除以显示移除动画。注意,我们并没有从 Parse 中的源数据中删除这个元素;我们需要做的是从列表中删除元素并调用notifyItemRemoved来触发通知。我们将使用getPosition()方法来知道哪个条目被点击了。

@Override
public boolean onLongClick(View view) {
  mOfferList.remove(getPosition());
  notifyItemRemoved(getPosition());
  return true;
}

总结

在本章的最后,你将了解到如何实现一个适配器,如何在列表中处理不同类型的条目,以及我们如何以及为什么应用ViewHolder模式。你最早是在学习ListView类时了解到这些,并手动实现了视图回收技术。因此,你将能够完全理解RecyclerView的功能以及它是如何工作的,以及展示不同条目显示方式和实现条目点击监听器的方法。

在下一章,我们将探索在 Android 5.0 中与RecyclerView一起引入的一个新组件—CardView。我们将将其与RecyclerView结合使用,以获得灵活且专业外观的卡片列表。

第六章:CardView 和材料设计

在本章的第一部分,我们将从 UI 角度显著改进我们的应用程序,使其看起来更专业,从新的小部件:CardView 开始。我们将学习如何使用设计时属性,这将提高我们的设计和开发速度,并且我们将使用第三方库轻松地在整个应用程序中包含自定义字体。

第二部分将重点介绍设计支持库,将材料设计概念添加到我们的应用程序中,改进标签,并在工作机会视图中添加视差效果。在此过程中,我们将阐明工具栏、操作栏和应用程序栏是什么,以及如何从应用程序栏实现上导航。

  • CardView 和 UI 技巧:

    • CardView

    • 设计时布局属性

    • 自定义字体

  • 设计支持库:

    • TabLayout

    • Toolbar、操作栏和应用程序栏

    • CoordinatorLayout

    • 上导航

CardView 和 UI 设计技巧

目前,我们的应用程序以行显示工作机会,其中包含两个文本视图;它展示了所需的信息,我们可以说应用程序目前是好的,并且达到了它的目的。然而,我们仍然可以让应用程序实用,同时拥有专业且美观的界面,使我们能够保持原创并与竞争对手不同。例如,为了展示工作机会,我们可以模拟一个带有广告海报的职位公告板。为此,我们可以使用 CardView 小部件,它将赋予其深度和纸张卡片的外观。我们将改变我们应用程序的字体。这样的简单改变会产生很大的不同;当我们把默认字体改为自定义字体时,从用户的角度来看,这个应用程序就是一个定制的应用程序,开发者已经注意到了最小的细节。

介绍 CardView

CardView 随 Android 5.0 一起发布。它是一个带有圆角和阴影高度的视图,因此具有深度感,并模拟出卡片的效果。将此与回收视图结合使用,我们可以得到一个外观出色的项目列表,其行为和外观与许多应用程序保持一致。以下是使用 CardView 和自定义字体列表的示例图片:

介绍 CardView

在使用 CardView 时,请记住,圆角根据 Android 版本的不同实现方式也不同。在 Android 5.0 之前的版本中,会添加内边距以避免裁剪子视图,同时也是为了实现阴影效果。在 Android 5.0 及以后的版本中,阴影会根据 CardView 的 elevation 属性显示,任何与圆角相交的子视图都会被裁剪。

要开始使用 CardView,我们需要从项目结构窗口将其作为依赖项添加,或者在 build.gradle 的依赖项内部添加以下行:

dependencies {
  ...
  compile 'com.android.support:cardview-v7:21.0.+'
}

我们可以修改 row_job_offer.xml 文件,将基础视图设置为带有内部内容的 CardView。这个 CardView 将具有一些高度和圆角属性。要设置这些属性,我们需要通过在 XML 中添加以下架构来导入 CardView 自有的属性:

下面的代码将创建新的布局:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView

    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="170dp"
    android:layout_margin="10dp"
    card_view:cardElevation="4dp"
    card_view:cardCornerRadius="4dp"
    >
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="wrap_content"
        android:padding="15dp"
        android:layout_height="wrap_content">
        <TextView
            android:id="@+id/rowJobOfferTitle"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:text="Title"
            android:textColor="#555"
            android:textSize="18sp"
            android:layout_marginBottom="20dp"
            />
        <TextView
            android:id="@+id/rowJobOfferDesc"
            android:layout_marginTop="5dp"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:text="Description"
            android:textColor="#999"
            android:textSize="16sp"
            />
    </LinearLayout>
</android.support.v7.widget.CardView>

我们找到了一块软木塞的纹理,将其设置为背景,并在每张卡片上添加了一个带有ImageView对象的图钉。下面是实现的效果:

介绍 CardView

应用看起来比之前好多了;现在它真成了一个职位公告板。仅仅通过展示同样的信息——同样的两个带有标题和职位描述的TextView——并简单地改变外观,它就从演示应用变成了一个完全可以发布在 Play 商店的应用。

我们可以通过更改字体来继续优化这个,但在那之前,我们将介绍设计时布局属性,这将使视图的设计更加简单快捷。

设计时布局属性

在使用设计时属性时,我总会想起我在第一份工作中发生的一个有趣故事。我需要显示联系人列表,所以在创建联系人视图时,我使用了虚拟数据,这在你创建视图时用于分配一些文本,以便在设计视图中可以看到大小、颜色和整体外观。

我创建的联系人名为Paco el churrero,即弗兰克,这位吉事果制作师。Paco 是弗朗西斯科的昵称,而吉事果——如果你不知道——是一种油炸面食。不管怎样,这个虚拟数据被替换成了一个合适的联系人姓名,当显示联系人列表时,这些联系人是从服务器获取的。我不记得是因为急于发布应用,我忘记了这件事,还是我简单地忽略了它,但应用就这样上线了。我开始处理另一个组件,一切都很顺利,直到有一天,服务器端出现了问题,服务器开始发送空联系人。应用无法用联系人姓名覆盖虚拟数据,结果 Paco el churrero 作为联系人显示了出来!幸运的是,在用户注意到之前,服务器得到了修复。

这之后,我创建视图时使用了虚拟数据,一旦我对视图满意了,我就移除了虚拟数据。然而,这种方法是,当有人要求更改 UI 时,我不得不再次添加虚拟数据。

随着 Android Studio 0.2.11 版本的发布,设计时布局属性应运而生。这些属性允许我们在设计视图中显示文本或任何属性,这些在应用运行时不会出现;这些数据只在设计视图中可见。

要使用这些属性,我们需要在布局中添加工具的命名空间。该命名空间总是在视图的根元素中定义;你可以找到这样的一行,informalexample">

<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android 

为了测试这个,我们将在职位信息和职位描述的TextView中添加一些虚拟文本:

<TextView
    android:id="@+id/rowJobOfferTitle"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    tools:text="Title of the job"
    android:textColor="#555"
    android:textSize="18sp"
    android:layout_marginBottom="20dp"
    />
<TextView
    android:id="@+id/rowJobOfferDesc"
    android:layout_marginTop="5dp"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    tools:text="Description of the job"
    android:textColor="#999"
    android:textSize="16sp"
    android:ellipsize="marquee"
    />

如果你遇到设计视图渲染问题,可以更改 Android 版本或主题,如下面的图片所示。如果问题依旧,请确保你已经安装了最新版本的 Android Studio 和下载了最新的 Android API。

设计时的布局属性

当视图渲染后,我们可以看到使用设计时属性的职业提供信息,包括标题和描述。

设计时布局属性

你可以使用任何属性,文本颜色,背景颜色,甚至图片源,这对于创建包含从互联网下载图片的视图非常有用,但你需要一个预览图像来查看在创建视图时的视图外观。

在 Android 中处理自定义字体

在 Android 上使用自定义字体时,有一个令人惊叹的开源库——Chris Jenkins 的Calligraphy——它允许我们为整个应用设置默认字体。这意味着每个带有文本的组件,如 Button、TextView 和 EditText 默认都会显示这种字体,我们不需要为应用中的每个单独的项目分别设置字体。让我们更详细地了解这一点,并考虑一些支持 Calligraphy 的论据。

如果我们想要应用一个自定义字体,我们需要做的第一件事是将该字体放置在应用的assets文件夹中。如果我们没有这个文件夹,我们需要在main目录中创建它,与javasrc同一级别。在assets中创建一个名为fonts的第二个文件夹,并将字体放在那里。在我们的示例中,我们将使用Roboto字体;可以从 Google 字体获取它,地址是www.google.com/fonts#UsePlace:use/Collection:Roboto。下载字体后,应用结构应与以下截图类似:

在 Android 中使用自定义字体

字体放置好之后,我们需要从这个字体创建一个Typeface对象,并将其设置为myTextView

Typeface type = Typeface.createFromAsset(getAssets(),"fonts/Roboto-Regular.ttf"); myTextView.setTypeface(type);

如果我们现在想将同一种字体应用到我们应用中的所有组件,比如标签、标题和职位卡片,我们不得不在应用的不同地方重复相同的代码。除此之外,我们还会遇到性能问题。从资源中创建字体需要访问文件;这是一个昂贵的操作。如果我们改变适配器中职位名称和职位描述的字体,我们应用在滚动时将不再流畅。这就带来了额外的考虑;例如,我们不得不在一个静态类中一次加载字体,并在整个应用中使用它。Calligraphy 为我们处理了所有这些事情。

使用 Calligraphy 的另一个好理由是它允许我们在 XML 中设置字体,这样我们可以在同一个视图中拥有不同的字体,而且无需通过编程方式设置字体。我们只需要在组件中添加fontPath属性,并可选地添加ignore属性以避免 Android Studio 未检测到fontPath的警告:

<TextView     android:text="@string/hello_world"     android:layout_width="wrap_content"     android:layout_height="wrap_content"     
fontPath="fonts/Roboto-Bold.ttf"
tools:ignore="MissingPrefix"/>

既然我们已经解释了 Calligraphy 的优点,我们可以将其应用到我们的应用中。在build.gradle中的依赖项中添加以下行:

compile 'uk.co.chrisjenx:calligraphy:2.1.0'

要应用默认字体,请在MAApplication内的Oncreate()中添加以下代码:

CalligraphyConfig.initDefault(new CalligraphyConfig.Builder().setDefaultFontPath("fonts/Roboto-Regular.ttf").setFontAttrId(R.attr.fontPath).build());

以及以下内容,添加到任何想要显示默认字体的活动中:

@Override protected void attachBaseContext(Context newBase) {super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase)); }

最后,我们可以找到一个我们喜欢的手写字体,并将其设置为卡片标题和描述,输出将类似于以下内容:

在 Android 中使用自定义字体

设计支持库

设计支持库以官方方式引入材料设计组件,并兼容从 Android 2.1 开始的 Android 的所有版本。材料设计是与 Android Lollipop 一起引入的一种新的设计语言。在这个库发布之前,我们观看了使用这些组件的应用程序的视频和示例,但没有官方的方法来使用它。这为应用程序设定了一个基线;因此,要掌握 Android,我们需要掌握材料设计。你可以使用以下行进行编译:

compile 'com.android.support:design:22.2.0'

这个库包括视觉组件作为输入文本,带有浮动文本、浮动动作按钮、TabLayout…等等。然而,材料设计不仅仅是视觉组件;它还涉及到其元素之间的移动和过渡,因此引入了CoordinatorLayout

介绍 TabLayout

TabLayout设计库允许我们使用固定或可滚动的标签,标签可以是文本、图标或自定义视图。正如你在书中第一次遇到这个实例时记得的那样,自定义标签并不是那么容易,而且要从滚动标签改为固定标签,我们需要不同的实现方式。

现在,我们想要改变标签的颜色和设计使其固定;我们首先需要做的是进入activity_main.xml,添加TabLayout,移除之前的PagerTabStrip标签。我们的视图将如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:layout_height="fill_parent"
    android:layout_width="fill_parent"
    android:orientation="vertical"
    >
    <android.support.design.widget.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="50dp"/>
    <android.support.v4.view.ViewPager
        android:id="@+id/pager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    </android.support.v4.view.ViewPager>
</LinearLayout>

当我们有这个时,我们需要将标签添加到Layout标签中。有两种方法可以做到这一点;一种是通过以下方式手动创建标签并将它们添加:

tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));

第二种方法,也就是我们将要实现的标签方式,是将视图页面设置为TabLayout。我们的MainActivity.java类应该如下所示:

public class MainActivity extends ActionBarActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    MyPagerAdapter adapter = new MyPagerAdapter(getSupportFragmentManager());
    ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
    viewPager.setAdapter(adapter);

    TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);

    tabLayout.setupWithViewPager(viewPager);
  }

  @Override
  protected void attachBaseContext(Context newBase) {
    super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase));
  }

}

如果我们没有指定任何颜色,TabLayout会使用主题中的默认颜色,并且标签的位置是固定的。我们新的标签栏将如下所示:

介绍 TabLayout

工具栏、动作栏和应用程序栏

在为我们的应用程序添加动作和动画之前,我们需要澄清工具栏、动作栏、应用程序栏和AppBarLayout的概念,因为这些可能会引起一些混淆。

动作栏和应用程序栏是同一个组件;“应用程序栏”只是动作栏在材料设计中获得的新名字。这是位于我们活动顶部的不透明栏,通常显示应用程序的标题、导航选项,并显示不同的操作。图标的显示与否取决于主题:

工具栏、动作栏和应用程序栏

自从 Android 3.0 开始,默认使用 Holo 主题或其任何后代主题,并且这些主题会显示操作栏。

接下来是下一个概念——工具栏。在 API 21,即 Android Lollipop 中引入,它是对操作栏的泛化,不必固定在活动的顶部。我们可以使用setActionBar()方法指定工具栏是否作为活动的操作栏。这意味着工具栏将根据我们的需求作为操作栏或不起作用。

如果我们创建一个工具栏并将其设置为操作栏,我们必须使用带有.NoActionBar选项的主题,以避免在主题中默认出现的操作栏与我们刚刚转换成操作栏的工具栏重复。

在设计支持库中引入了一个名为AppBarLayout的新元素。它是一个LinearLayout,旨在包含工具栏以基于滚动事件显示动画。我们可以通过app:layout_scrollFlag属性指定子视图在滚动时的行为。它旨在被包含在CoordinatorLayout中,而且该组件也在设计支持库中引入,我们将在下一节中进行描述。

使用 CoordinatorLayout 添加动作。

CoordinatorLayout允许我们向应用程序添加动作,将触摸事件和手势与视图连接起来。例如,我们可以协调滚动动作与视图折叠动画。这些手势或触摸事件由Coordinator.Behaviour类处理,而AppBarLayout已经拥有这个私有类。如果我们想要在自定义视图中使用这个动作,我们将不得不自己创建这个行为。

CoordinatorLayout可以实现在我们应用程序的顶层,因此我们可以将其与应用程序栏或活动或片段内的任何元素结合使用。它也可以作为一个容器,与子视图进行交互。

继续我们的应用程序,当我们点击一张卡片时,将显示一份工作机会的完整视图。这将在一个新活动中展示。该活动将包含一个工具栏,显示工作机会的标题和公司标志。如果描述很长,我们需要向下滚动来阅读;此时,我们希望顶部不再相关的公司标志可以折叠起来。同样,当我们向上滚动时,我们希望它再次展开。为了控制工具栏的折叠,我们将需要CollapsingToolbarLayout

描述将包含在NestedScrollView中,这是来自 Android v4 支持库的滚动视图。使用NestedScrollView的原因是,这个类可以将滚动事件传递给工具栏,而ScrollView则不能。确保compile 'com.android.support:support-v4:22.2.0'是最新版本。

我们将在下一章学习如何下载图片,所以现在我们可以从 drawable 文件夹中放置一个图片来实现 CoordinatorLayout 的功能。在下一章,我们将为每个提供工作的公司加载相应的图片。

我们提供的详情视图 activity_offer_detail.xml 将如下所示:

<android.support.design.widget.CoordinatorLayout 

    android:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_height="256dp"
        android:layout_width="match_parent">
        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/collapsingtoolbar"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:id="@+id/logo"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerInside"
                android:src="img/googlelogo"
                app:layout_collapseMode="parallax" />
            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_height="?attr/actionBarSize"
                android:layout_width="match_parent"
                app:layout_collapseMode="pin"/>
        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>
    <android.support.v4.widget.NestedScrollView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:paddingLeft="20dp"
        android:paddingRight="20dp"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
            <TextView
                android:id="@+id/rowJobOfferDesc"
                android:layout_width="fill_parent"
                android:layout_height="fill_parent"
                android:text="Long scrollabe text"   
                android:textColor="#999"
                android:textSize="18sp"
                />
    </android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>

如你所见,CollapsingToolbar 布局对滚动标志做出反应,并告诉其子元素如何反应。工具栏将固定在顶部,始终保持可见,app:layout_collapseMode="pin"。然而,标志通过视差效果消失,app:layout_collapseMode="parallax"。不要忘记在 NestedScrollview 属性中添加 app:layout_behavior="@string/appbar_scrolling_view_behavior",并清理项目以内部生成此字符串资源。如果你遇到问题,可以直接设置字符串,"android.support.design.widget.AppBarLayout$ScrollingViewBehavior",这将帮助你定位问题。

当我们点击一个职位提供时,我们需要导航到 OfferDetailActivity,并且需要发送该职位提供的信息。正如你可能从初级水平就知道,要在活动之间发送信息,我们使用意图。在这些意图中,我们可以放置数据或序列化的对象。为了能够发送 JobOffer 类型的对象,我们必须创建一个实现 SerializableJobOffer 类。完成这一步后,我们可以在 JobOffersAdapter 中检测元素的点击,如下所示:

public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener{

  public TextView textViewName;
  public TextView textViewDescription;

  public  MyViewHolder(View v){
    super(v);
    textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
    textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    v.setOnClickListener(this);
    v.setOnLongClickListener(this);
  }

  @Override
  public void onClick(View view) {
    Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
    JobOffer selectedJobOffer = mOfferList.get(getPosition());
    intent.putExtra("job_title", selectedJobOffer.getTitle());
    intent.putExtra("job_description",selectedJobOffer.getDescription());
    view.getContext().startActivity(intent);
  }

启动活动后,我们需要获取标题并将其设置到工具栏上。首先使用虚拟数据测试,在 NestedScrollView 内的 TextView 描述中添加长文本。我们希望可以滚动以测试动画:

public class OfferDetailActivity extends AppCompatActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_offer_detail);

    String job_title = getIntent().getStringExtra("job_title");

    CollapsingToolbarLayout collapsingToolbar =
    (CollapsingToolbarLayout) findViewById(R.id.collapsingtoolbar);
    collapsingToolbar.setTitle(job_title);

  }

}

最后,确保在 values 文件夹中的 styles.xml 文件默认使用没有操作栏的主题:

<resources>

  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <!-- Customize your theme here. -->
</style>

</resources>

现在我们准备测试行为。启动应用并滚动到底部。看看图片如何折叠以及工具栏如何固定在顶部。它将与以下截图相似:

使用 CoordinatorLayout 添加动效

我们遗漏了一个属性,以在动画中实现良好的效果。仅仅折叠图片还不够;我们需要使图片平滑地消失,并由工具栏的背景色替换。

CollapsingToolbarLayout 中添加 contentScrim 属性,这将使图片在折叠时使用主题的主要颜色淡入,这与工具栏当前使用的颜色相同:

<android.support.design.widget.CollapsingToolbarLayout
    android:id="@+id/collapsingtoolbar"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_scrollFlags="scroll|exitUntilCollapsed"
    app:contentScrim="?attr/colorPrimary">

使用此属性,应用在展开和折叠时看起来更好:

使用 CoordinatorLayout 添加动效

我们只需通过更改颜色和为图片添加填充来进一步美化应用;我们可以在 styles.xml 中更改主题的颜色:

<resources>
  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">#8bc34a</item>
    <item name="colorPrimaryDark">#33691e</item>
    <item name="colorAccent">#FF4081</item>
  </style>
</resources>

AppBarLayout 调整为 190dp 并给 ImageView 添加 50dp paddingLeftpaddingRight 以实现以下效果:

使用 CoordinatorLayout 添加动效

返回导航和向上导航

有两种方法可以返回上一个屏幕。一种称为返回导航,是通过返回按钮执行的导航,该按钮可能是硬件按钮或软件按钮,具体取决于设备。

向上导航是 Android 3.0 中与操作栏一同引入的导航方式;在这里,我们可以通过操作栏中显示的指向左边的箭头来返回到上一个屏幕,如下面的截图所示:

返回导航和向上导航

有时我们需要覆盖返回导航的功能。例如,如果我们有一个自定义的WebView并通过浏览器导航,当我们点击返回时,默认情况下返回按钮将导致我们离开活动;然而,我们想要的是返回浏览器使用的历史记录:

@Override
public void onBackPressed() {
  if (mWebView.canGoBack()) {
    mWebView.goBack();
    return;
  }

  // Otherwise defer to system default behavior.
  super.onBackPressed();
}

除了这些,返回导航是默认实现的,与向上导航不同。要实现向上导航,我们需要一个操作栏(或者作为操作栏的工具栏),并且需要通过setDisplayHomeAsUpEnabled(true)方法激活此导航。在我们的活动中的onCreate里,我们将添加以下几行代码,以将我们的工具栏设置为操作栏并激活向上导航:

final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);

这将在我们活动的顶部显示返回箭头,如下面的截图所示。但目前,我们还没有任何功能:

返回导航和向上导航

一旦激活,我们需要捕获操作栏中返回箭头的点击。这将被检测为菜单中带有android.R.id.home ID 的动作选择;我们只需在我们的活动中添加以下代码即可:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
  switch (item.getItemId()) {
    case android.R.id.home:
    finish();
    return true;
  }
  return super.onOptionsItemSelected(item);
}

概述

在本章中,我们的应用程序发生了巨大变化;我们完全改变了工作机会列表,现在它看起来类似于一张张手写的纸张卡片,钉在软木板上。同时,你学习了来自材料设计的概念以及如何使用应用栏和工具栏。设计支持库中还有更多小部件,如InputTextFloatingButton,它们非常容易实现。只需将小部件添加到视图中,这就是为什么我们专注于更复杂的组件,如CoordinatorLayoutCollapsingToolbarLayout

在下一章中,我们将学习如何下载公司标志,直接从 URL 宣传工作,讨论内存管理,并查看如何确保我们的应用中没有内存泄漏。

第七章:图像处理与内存管理

在本章中,我们将探讨如何显示从 URL 下载的图片。我们将讨论如何使用 Android 原生 SDK 以及常用的第三方库来实现这一点。我们会考虑关键概念和特性,如下载、压缩、缓存系统以及在内存或磁盘上的存储。

我们还将讨论什么是九宫格图片以及如何创建它,并且会通过介绍矢量图像,讲解不同尺寸和密度文件夹中的可绘制资源。

最后一节将重点关注内存管理。识别我们应用中的内存泄漏是一项关键任务,这通常发生在处理图像时。我们将探讨可能导致这些泄漏的常见错误以及如何预防的一般性技巧。

  • 显示网络图像

    • 传统方式

    • Volley ImageDownloader

    • Picasso 库

  • 图像

    • 矢量图像

    • 动画矢量图像

    • 九宫格图片

  • 内存管理

    • 检测和定位内存泄漏
  • 防止内存泄漏

下载图片

使用ImageView下载并显示图片可以在一行代码中完成。自从 Android 开发起步,这是每个开发者都会做的事情。Android 是一项超过五年历史的技术,因此我们可以预期这项技术相当先进,也能找到简化它的第三方解决方案。话虽如此,如果这本书不解释在没有任何第三方库的情况下下载和显示图片的过程,它就不会被称为《精通 Android》。

在你的应用中使用最新库是好的,但理解你所实施的解决方案更好,能够自己构建这个库则是最好不过了。

在处理图像时,我们需要从网络连接到数组字节的下载及其转换为位图的一切。在某些情况下,将图像存储在磁盘上有意义,这样下次我们打开应用时,这些图像就已经在那里了。

即使我们能够显示一张图片,事情并没有就此结束;我们应该能够在列表视图中管理图片的下载。下载、存储和显示系统的同步是应用无故障运行、拥有流畅列表且能无问题滚动的关键。请记住,当我们滚动列表时,视图是被回收的。这意味着如果我们快速滚动,可能会开始下载一张图片。等到下载完成时,该视图可能已经不再屏幕上显示,或者它可能被回收用于另一个视图。

下载图片的传统方式

要在不使用任何第三方库(互联网上托管的带有 URL 的图片)的情况下显示图片,我们需要使用HttpURLConnection建立连接。我们需要打开一个输入流并消费信息,这可以通过工厂方法BitmapFactory.decodeStream(InputStream istream)转换为 Bitmap 图像。我们可以将其从输入流转换为文件,以便将图片存储在磁盘上,以后再访问。目前,让我们先尝试下载它并将其转换为 Bitmap 图像,我们将把它保存在内存中并在ImageView中显示。

我们将在OfferDetailActivity中为每个职位展示公司的标志。请记住,在 Parse 中,我们创建了一个数据库,并创建了一个名为imageLink的字段。你只需要用该公司的标志 URL 填充该字段。

传统下载图片的方式

我们需要在OfferDetailActivity中添加图片链接;为此,在JobOfferAdapter中点击卡片时,我们需要在意图中发送一个额外的参数。使用以下代码:

@Override
public void onClick(View view) {
  Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
  JobOffer offer = mOfferList.get(getPosition());
  intent.putExtra("job_title", offer.getTitle());
  intent.putExtra("job_description",offer.getDescription());
  intent.putExtra("job_image",offer.getImageLink());
  view.getContext().startActivity(intent);
}

负责图片下载的方法将是一个静态方法,可以从应用程序的任何位置调用。这个方法将被放在名为utils的包内的ImageUtils类中。我们首先检查 URL 是否正确,然后从HttpURLConnection消费内容,将输入流转换为之前解释的 Bitmap 图像:

public static Bitmap getImage(String urlString) {

  URL url = null;

  try {
    url = new URL(urlString);
  } catch (MalformedURLException e) {
    return null;
  }

  HttpURLConnection connection = null;
  try {
    connection = (HttpURLConnection) url.openConnection();
    connection.connect();
    int responseCode = connection.getResponseCode();
    if (responseCode == 200) {
      return BitmapFactory.decodeStream(connection.getInputStream());
    } else
      return null;
  } catch (Exception e) {
    return null;
  } finally {
    if (connection != null) {
      connection.disconnect();
    }
  }
}

我们将创建一个名为displayImageFromUrl()的方法,该方法接收ImageView和一个带有链接的字符串,以代替在onCreate中拥有所有这些逻辑。在onCreate中,我们只需要检索参数并调用该方法:

String imageLink = getIntent().getStringExtra("job_image");
ImageView imageViewLogo = (ImageView) findViewById(R.id.logo);

displayImageFromUrl(imageViewLogo,imageLink);

在这个阶段,我们可能会想要调用ImageUtils.getImage(link)并将 Bitmap 设置到ImageView。然而,我们忽略了一件事;我们不能在主活动线程中直接调用打开网络连接的方法。我们需要在后台进行,否则可能会引发异常。AsyncTask方法是这个问题的不错解决方案:

String imageLink = getIntent().getStringExtra("job_image");
ImageView imageViewLogo = (ImageView) findViewById(R.id.logo);

displayImageFromUrl(imageViewLogo,imageLink);

public void displayImageFromUrl(ImageView imageView, String link){

  new AsyncTask<Object,Void,Bitmap>(){

    ImageView imageView;
    String link;

    @Override
    protected Bitmap doInBackground(Object... params) {
      imageView = (ImageView) params[0];
      link = (String) params[1];

      return ImageUtils.getImage(link);
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
      super.onPostExecute(bitmap);
      imageView.setImageBitmap(bitmap);
    }

  }.execute(imageView, link);
}

根据所使用的图片的形状和背景,使用ImageView的属性scaleType,设置为centerInsidecenterCrop值会更好看。centerInside值会将图片缩小以确保它适应接收器同时保持比例。centerCrop值将放大图片,直到它填满接收器最短的一边。图片的其余部分将超出ImageView的边界。

在本章开头,我提到这可以用一行代码就完成,但正如你所见,自己动手做远不止一行代码,还涉及不同的概念,比如后台线程、HttpURLConnection等。这仅仅是开始;我们实现了最简单的情况。如果我们以同样的方式在列表视图的行中设置图像,会遇到问题。这些问题之一就是在滚动时无限触发AsyncTask调用。如果我们有一个带有最大AsyncTask数量的队列以及一个取消机制,以忽略或取消不在屏幕上的视图的请求,这种情况是可以控制的。

当我们启动AsyncTask时,我们有一个指向ImageView的引用,在PostExecute中,我们将Bitmap设置给它。这个下载操作可能需要一些时间,这样在滚动时ImageView可能会被回收。这意味着我们正在为在列表不同位置回收的ImageView下载图像,以显示不同的元素。例如,如果我们有一个带有联系人面孔的列表,我们可能会看到与名字不符的人脸。为了解决这个问题,我们可以做的是将图像链接的字符串设置为ImageView的标签,myImageView.setTag(link)。如果视图被回收,它将具有带有新链接的不同项目;因此,我们可以在onPostExecute中检查,在显示图像之前,当前的链接是否与ImageView标签中的链接相同。

这两个是常见问题及其相应的解决方案,但我们还没有就此结束。如果继续这样下去,最繁琐的事情就是创建一个缓存系统。根据应用和情况的不同,我们可能希望永久存储下载的图像。例如,如果我们正在创建一个带有你最喜欢的专辑列表的音乐应用,将专辑封面存储在磁盘上是有意义的。如果你每次打开应用都会看到最喜欢的列表,并且我们知道封面不会改变,为什么不永久存储图像,以便下次打开应用时加载更快,不消耗任何数据呢?对于用户来说,这意味着每次都能立即看到首屏加载,这将大大提升用户体验。为此,我们需要将图像下载到文件中,并有一个第三种方法稍后从文件中读取图像,包括检查我们是否已经下载了此图像,或者这是我们第一次请求它。

另一个例子可以是新闻源阅读应用。我们知道图片几乎每天都会变化,所以将其保存在磁盘上没有意义。然而,在应用中导航时,我们可能仍然希望将它们保留在内存中,这样在从另一个活动回到当前活动时,在同一个会话中不需要重新下载。在这种情况下,我们需要密切关注内存使用情况。

是时候引入一些第三方库来帮助我们处理这个问题了。我们可以从 Volley 开始,就是我们之前为网络请求实现的那个 Volley。

使用 Volley 下载图片

Volley 提供了两种请求图片的机制。第一种机制ImageRequest与我们刚才使用 Volley 请求队列和按需调整图片大小完成的AsyncTask非常相似。以下是请求的构造函数:

public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight, Config decodeConfig, Response.ErrorListener errorListener) { … }

maxWidthmaxHeight参数将用于调整图片大小;如果我们不想调整大小,可以将其值设置为0。这是我们示例中用于获取图片的方法:

public void displayImageWithVolley(final ImageView imageView, String url){

  ImageRequest request = new ImageRequest(url,
  new Response.Listener<Bitmap>() {
    @Override
    public void onResponse(Bitmap bitmap) {
      imageView.setImageBitmap(bitmap);
    }
  }, 0, 0, null,
  new Response.ErrorListener() {
    public void onErrorResponse(VolleyError error) {

    }
  });

  MAApplication.getInstance().getRequestQueue().add(request);
}

第二个机制,真正有趣的是ImageLoader。它可以同时处理多个请求,并且是我们之前解释的原因在列表视图中使用的机制。我们可以创建我们希望它使用的缓存机制——内存或磁盘。

它通过使用一种特殊的ImageViewNetworkImageView来工作。当ImageLoader对象准备就绪时,我们可以使用NetworkImageView仅用一行代码下载图片:

myNetworkImageView.setImage(urlString, imageloader);

它允许我们执行不同的操作,例如设置默认图片或设置请求失败时的图片。使用以下代码:

myNetworkImageView.sesetDefaultImageResId(R.id.default_image);
myNetworkImageView.setErroImageResId(R.id.image_not_found);

这里的复杂性,如果有的话,在我们实现ImageLoader时出现。首先,我们需要以在Application类中创建RequestQueue的相同方式创建它,以便可以在我们应用中的任何位置访问:

@Override
public void onCreate() {
  super.onCreate();

  sInstance = this;

  mRequestQueue = Volley.newRequestQueue(this);

  mImageLoader = new ImageLoader(mRequestQueue, new myImageCache());

构造函数需要一个缓存实现。Google 是基于内存缓存的示例,其大小等于三个屏幕的图片量:

public class LruBitmapCache extends LruCache<String, Bitmap>
implements ImageCache {

  public LruBitmapCache(int maxSize) {
    super(maxSize);
  }

  public LruBitmapCache(Context ctx) {
    this(getCacheSize(ctx));
  }

  @Override
  protected int sizeOf(String key, Bitmap value) {
    return value.getRowBytes() * value.getHeight();
  }

  @Override
  public Bitmap getBitmap(String url) {
    return get(url);
  }

  @Override
  public void putBitmap(String url, Bitmap bitmap) {
    put(url, bitmap);
  }

  // Returns a cache size equal to approximately three screens worth of images.
  public static int getCacheSize(Context ctx) {
    final DisplayMetrics displayMetrics = ctx.getResources().
    getDisplayMetrics();
    final int screenWidth = displayMetrics.widthPixels;
    final int screenHeight = displayMetrics.heightPixels;
    // 4 bytes per pixel
    final int screenBytes = screenWidth * screenHeight * 4;

    return screenBytes * 3;
  }
}

我们可以看到,选择缓存实现是一个手动过程;我们必须创建具有所需实现的类,并在ImageLoader的构造函数中设置它。这就是为什么接下来我们要了解的库在推出时是一次革命。

引入 Picasso

创建OkHttp的同一批人将 Picasso 引入了 Android 社区。Picasso 允许我们仅用一行代码下载并显示图片,无需创建ImageLoader,并自动使用磁盘和内存的缓存实现。它包括图像转换,ImageView回收和请求取消。所有这些都是免费的。Square 公司带给社区的东西令人难以置信。

如果这还不够,调试模式将在图片中显示指示器,角落里的小三角形有不同的颜色,以表示我们第一次下载图片时(即从网络获取时),从内存缓存中获取,以及从磁盘缓存中获取:

引入 Picasso

掌握图片处理

在结束关于图片这一章节之前,本书必须介绍两个概念。正如你所知,图片可以根据屏幕密度放在多个文件夹中——从低密度drawable-ldpi到高密度drawable-hdpi,超超高密度drawable-xxxhdpi,将来可能还有更多。当我们这样做时,需要考虑是希望所有屏幕都有高质量的图片,还是一个轻量级的 APK。复制图片将增加我们安装程序的大小。这个问题在 Android 5.0 引入的以下组件中将会消失。

矢量可绘制资源

这些可绘制资源基于矢量图形;矢量图形可以放大或缩小而不损失任何质量。有了这个特点,我们只需要一个单一的可绘制资源,无论在哪种屏幕上使用,它都能保持优良的品质,无论是安卓手表还是安卓电视。

矢量可绘制资源的定义方式与形状的定义方式相同——在 XML 文件中。这是一个简单的vectordrawable.xml文件:

<vector  android:height="64dp" android:width="64dp" android:viewportHeight="600" android:viewportWidth="600">
  <group>
    <path android:fillColor="@color/black_primary" android:pathData="M12 36l17-12-17-12v24zm20-24v24h4V12h-4z" />
  </group>
</vector>

注意矢量标签有高度和宽度;如果我们把这个可绘制资源设置在ImageView中,且大小小于容器,它看起来会像素化。

你可能会问,我们从哪里获取pathData属性?你可能有一个.svg图像,这是可扩展图形的一种格式。这个图像可以用文本编辑器打开,你应该能看到类似以下路径数据的内容:

<svg  width="48" height="48" viewBox="0 0 48 48">
  <path d="M12 36l17-12-17-12v24zm20-24v24h4V12h-4z"/>
</svg>

谷歌提供了一系列的材料设计图标,这些图标包含 SVG 版本;有了这个,你可以在应用中添加无限可扩展的图像。我们展示的路径是这组图标中的媒体播放器图标。

矢量可绘制资源

矢量可绘制资源将被添加到设计支持库中,因此它也可以在 Android 5.0 之前的版本中使用。

下一个组件可能不包括在设计支持库中,因此我们需要考虑是否要使用它,这取决于 Android 5.0 及以上版本的普及程度。无论如何,它值得解释,因为迟早它会因为其惊人的效果而被更广泛地看到。

使用AnimatedVectorDrawable进行动画制作

顾名思义,AnimatedVectorDrawable是一个带有动画的矢量可绘制资源,这是一个重要的特性。这些动画不仅仅是旋转、缩放、透明度等,这些都是我们在之前的 Android 版本中见过的;这些动画还允许我们改变可绘制资源的pathData属性。这意味着我们可以有一个改变形状的图像,或者一个转换成另一个图像的图像。

这带来了无限多的 UI 可能性。例如,我们可以有一个播放按钮变成一个不断旋转的半圆进度条,或者一个播放按钮变成暂停按钮。

我们可以如下定义传统动画,比如旋转:

<objectAnimator

  android:duration="6000"
  android:propertyName="rotation"
  android:valueFrom="0"
  android:valueTo="360" />

下面是如何定义从三角形到矩形的形状转换:

<set
  >
  <objectAnimator
    android:duration="3000"
    android:propertyName="pathData"
    android:valueFrom="M300,70 l 0,-70 70,70 0,0   -70,70z"
      android:valueTo="M300,70 l 0,-70 70,0  0,140 -70,0 z"
    android:valueType="pathType"/>
</set>

要将它们组合在一个 AnimatedVectorDrawable 对象中,执行以下代码:

<animated-vector

  android:drawable="@drawable/vectordrawable" >
  <target
    android:name="rotationGroup"
    android:animation="@anim/rotation" />
  <target
    android:name="v"
    android:animation="@anim/path_morph" />
</animated-vector>

这仅限于具有相同长度和相同命令长度的路径。

使用九宫格

在解释九宫格是什么之前,我将先展示在什么情况下需要它。如果我们正在开发一个消息应用,并且需要在聊天气泡中显示用户输入的内容,我们可以考虑创建一个 TextView 并将消息气泡的图像设置为背景。如果消息非常长,下面分别展示了没有九宫格背景和有九宫格背景的情况。

使用九宫格

我们可以看到第一张图像被拉伸了,看起来很糟糕;然而,我们不想拉伸边界。我们想要保持边界不变,但根据消息内容使文本区域变高或变宽。

九宫格图像是一种可以根据其内容调整大小的图像,但它涉及到留下一些区域不进行拉伸。它可以从 PNG 文件中的图像创建。基本上,它和 PNG 文件一样,只是在每个边上多了一个像素,并以 .9.png 的扩展名保存。当我们把这个文件放在 drawable 文件夹中时,Android 会知道在额外的像素中有关哪些区域需要拉伸和不需要拉伸的信息。

如果你观察这幅图像,你会看到左侧和顶部额外的像素线用于指定哪些内容是可缩放的,底部和右侧的线条用于指定哪些空间可以被填充。我们希望完全填充这个盒子,但我们只想对左侧的某部分进行缩放。

使用九宫格

Android 提供了一个工具来创建这些九宫格图像,你可以在 SDK 文件夹下的 tools 目录中找到它。只需打开 draw9patch 并将图像拖入其中。

使用九宫格

内存管理

每个 Java 开发者都听说过 垃圾回收器 (GC);这是一个自动为我们释放内存资源的机制。在某些情况下,我们可以防止垃圾回收器释放某些资源;如果资源持续增长,我们不可避免地会遇到 OutOfMemoryError

如果发生这种情况,我们需要定位泄漏并阻止它。在本节中,我们将了解如何定位问题的来源以及一系列防止这种情况发生的好习惯。

这不是只有在发生错误时才需要关注的事情;我们的应用程序可能存在内存泄漏,这些泄漏在快速测试中可能不足以被检测出来,但在内存堆较小的设备上可能会导致错误。因此,在发布应用程序之前,对内存水平进行快速检查是很有必要的。

检测和定位内存泄漏

Android Studio 提供了一种快速检查内存状态的方法。在底部窗口中,你会在 logcatADB 日志旁边找到一个名为 Memory 的标签页。

检测和定位内存泄漏

如果你点击了被称为垃圾收集器的小卡车图标,你会看到可用内存增加了。

不要将此作为可用内存的参考,因为堆内存是动态的。这意味着堆内存最初可能是 64 MB,我们有 60 MB 已分配和 4 MB 空闲,但我们再分配 10 MB。堆内存可能会增长,最终我们会有一个 128 MB 的堆内存,其中 70MB 已分配和 58 MB 空闲。

要检测泄漏,我们需要获取分配的内存引用。不断点击垃圾收集器,并在应用中导航,打开和关闭活动,加载图片,滚动列表,多次执行这些操作。如果分配的内存持续增长并且从未下降,这意味着我们在泄漏内存,阻止了一些资源被回收。我们可以大致定位泄漏发生在哪个活动或片段,因为我们将始终在相同点看到增长(假设不止一个泄漏)。

为了更精确地定位问题源头,我们需要使用Android 设备监控器

检测和定位泄漏

选择你的应用进程,并点击更新堆内存

检测和定位泄漏

选择此项后,我们可以看到对象的分配情况;在位图或线程泄漏的情况下,这将是一个很好的线索:

检测和定位泄漏

如果我们仍然不清楚是什么在泄漏内存,可以点击导出 HPROF 文件按钮,并使用来自 Eclipse 的内存分析工具MAT打开此文件。为此,我们将需要下载 Eclipse。

当我们导入文件时,可以双击我们的进程并点击列出对象,这将识别正在发生的情况。例如,我们可以看到活动中有多少对象以及使用了多少堆内存:

检测和定位泄漏

防止泄漏

比起修复内存泄漏,更好的办法是根本不让它发生。如果在开发过程中,我们牢记导致泄漏最常见的原因,这将为我们将来省去许多问题。

活动和上下文引用

活动引用是导致此问题的主要原因之一。我们经常将活动的引用发送给下载监听器或事件监听器,这是很常见的。如果另一个对象持有了我们活动的引用,这将阻止垃圾收集器释放我们的活动。例如,如果我们改变了屏幕方向,默认情况下将重新创建我们的活动,而具有旧方向的老活动将被销毁。

记得在活动的onDestroy方法中取消监听器的订阅,并关注你发送上下文的对象;这是我们活动的强引用。

使用 WeakReference(弱引用)

在 Java 中,当我们默认创建一个对象时,它是以强引用的形式创建的。非 null 对象若持有强引用,则不会被垃圾回收器回收。

只包含弱引用的对象将在下一个周期被垃圾回收。同一个对象可以有多个引用;因此,如果我们需要临时使用一个对象,可以为其创建弱引用,当硬引用被移除时,它将被垃圾回收。

这是一个包含在 Facebook SDK 源代码中的真实世界示例。他们创建了一个名为ToolTipPopup的自定义弹出窗口,其外观类似于以下图像:

使用弱引用

这个弹出窗口需要一个锚视图,这个锚视图是通过弱引用来引用的:

private final WeakReference<View> mAnchorViewRef;

这背后的原因是,在弹出窗口显示的时候,我们不再需要锚视图了。一旦弹出窗口显示,锚视图可以设置为 null 或使其消失,这不会影响我们。因此,使用弱引用,如果原始锚视图被销毁并失去了其硬引用,它也会在ToolTipPopup类中释放弱引用对象。

总结

在本章中,你学习了如何在不依赖任何第三方库的情况下下载图像,以理解它们的用法。对 Volley 和 Picasso 的概述使我们准备好实现任何具有完美处理的应用程序。我们还花了一些时间研究添加到我们应用程序中的图像,如矢量可绘制图像和九宫格图像。为了完成本章,我们探讨了如何在应用程序中管理内存问题,更重要的是,如何预防这些问题。

在下一章中,我们将创建一个 SQLite 数据库。我们将通过内容提供者导出此数据库,并通过CursorLoader与内容提供者同步 UI 数据。

第八章:数据库和加载器

在本章中,我们将根据数据库契约创建一个 SQLite 数据库,并使用名为DAO数据访问对象)的数据库执行读写操作。我们还将解释查询与原始查询之间的区别。

你将了解内容提供者是什么以及如何创建它,这将使得我们可以通过CursorLoader访问这个数据库。我们将通过内容解析器访问内容提供者,并同时对数据库的不同表格进行查询,你将学会如何在内容提供者中使用联合查询。

使用CursorLoader,我们将能够通过创建一个机制,实现列表视图与数据库的同步,如果我们存储或修改数据库中的任何数据,这些变化将自动反映在我们的视图中。

最后,我们将添加流行的下拉刷新功能,以便按需更新内容。因此,在本章中,我们将涵盖以下主题:

  • 创建数据库

    • 数据库契约

    • 数据库开放助手

    • 数据库访问对象

  • 创建和访问内容提供者

    • 内容提供者

    • 内容解析器

  • 同步数据库与 UI

    • CursorLoader

    • RecyclerViewCursorAdapter

  • 下拉刷新

创建数据库

为了了解 Android 中数据库的工作原理,我们将继续在我们的示例应用MasteringAndroidApp上工作,创建一个数据库来存储职位信息,以便在离线模式下查看内容。这意味着如果我们打开应用一次,职位信息将保存在设备上,即使在没有互联网连接的情况下打开,也能查看信息。

在 Android 中有四种机制可以持久化数据:

  • 共享偏好设置:这些偏好设置用于以键值结构存储基本信息

  • 内部存储:这种存储用于保存仅属于你应用私有的文件

  • 外部存储:这种存储用于保存可以与其他应用共享的文件

  • SQLite 数据库:这个基于流行的 SQL 的数据库,允许我们以结构化的方式编写和读取信息

我们可以创建简单的结构,如单表数据库,也可以创建包含多个表格的复杂结构。我们可以合并不同表格的输出以创建复杂的查询。

我们将创建两个表格,以展示如何使用内容提供者创建联合查询。

我们将为公司创建一个表格,其中包括公司 ID、公司相关信息、名称、网站、额外信息等。第二个表格将包含职位信息;这个表格也需要包含一个列,用以存放公司的 ID。如果我们想要一个整洁的结构,而不是一个拥有众多字段的大型表格,最好是将公司信息放在公司表格中,将职位信息放在职位表格中,并通过一个引用关联公司。

为了清晰起见,并且专注于 SQLite,我们不会改变 Parse 中的数据结构。因此,我们将下载内容并手动分割公司和职位数据,将它们插入到不同的表中。

我们的公司表格将具有以下结构:

RowId 名称 图片链接
0 Yahoo ….
1 Google

rowId列是 Android 自动添加的,因此在创建表时我们不需要指定这个列。

下表是工作机会表:

RowId 职位 描述 薪水 地点 类型 Company_id
24 高级安卓开发.. 2 倍开发者 55,000 英国伦敦 全职 1
25 初级安卓开发.. 有经验的开发者 20,000 英国伦敦 全职 0

我们将创建一个视图,作为这两个表连接的结果;在这里,连接将基于company_id

职位 描述 薪水 地点 类型 公司 ID 名称 图片链接
--- --- --- --- --- --- --- ---
高级安卓开发 2 倍开发者.. 55,000 英国伦敦 全职 1 Google
初级安卓开发 有经验的开发者 20,000 英国伦敦 全职 0 Yahoo

这个视图将允许我们获取所需的所有数据,只需一行。

数据库合同

数据库合同是一个类,我们在其中定义了数据库的名称以及所有表和列的名称作为常量。

它有两个目的:首先,它是一种快速了解数据库结构的方法。

要创建数据库包和DatabaseContract.java类,请使用以下代码:

public class DatabaseContract {

  public static final String DB_NAME = "mastering_android_app.db";

  public abstract class JobOfferTable {

    public static final String TABLE_NAME = "job_offer_table";

    public static final String TITLE = "title";
    public static final String DESC = "description";
    public static final String TYPE = "type";
    public static final String SALARY = "salary";
    public static final String LOCATION = "location";
    public static final String COMPANY_ID = "company_id";
  }

  public abstract class CompanyTable {

    public static final String TABLE_NAME = "company_table";

    public static final String NAME = "name";
    public static final String IMAGE_LINK = "image_link";
  }
}

其次,使用对常量的引用避免错误,并允许我们只更改一个常量值,并在整个应用中传播这个更改。

例如,在数据库中创建此表时,我们需要使用 SQL 语句,CREATE TABLE "name"…; 我们将要做的是使用与合同中的表名 CREATE TABLE DatabaseContract.CompanyTable.TABLE_NAME…

数据库合同只是第一步。它不会创建数据库;它只是一个我们用作架构的文件。要创建数据库,我们需要SQLiteOpenHelper的帮助。

数据库开放助手

开放助手是一个管理数据库创建和更新的类。更新是我们需要牢记的重要方面。假设我们将应用上传到 Play 商店,一段时间后,我们想要改变数据库的结构。例如,我们想要向表中添加一列,而不丢失之前版本用户在旧架构中存储的数据。将新版本上传到 Play 商店,当用户更新我们的应用时删除之前的资料,这对用户体验来说是非常不好的。

为了知道何时需要更新数据库,我们有一个静态整数,其中包含数据库版本,如果我们更改数据库,必须手动增加它,如下所示:

/**
* DATABASE VERSION
*/
private static final int DATABASE_VERSION = 1;

我们需要创建一个扩展SQLiteOpenHelperDatabaseOpenHelper类。在扩展这个类时,要求我们实现两个方法:

@Override
public void onCreate(SQLiteDatabase db) {
  //Create database here
}

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  //Update database here
}

当我们创建此类的一个对象时,SQLiteOpenHelper会自动调用onCreate。但是,它仅在数据库之前未创建且仅调用一次时这样做。同样,当我们将数据库版本升级时,它会调用onUpgrade。这就是为什么我们在创建此类对象时需要传递带有数据库名称和当前版本的参数:

public DBOpenHelper(Context context){
  super(context, DatabaseContract.DB_NAME, null, DATABASE_VERSION);
}

让我们从创建数据库开始;onCreate方法需要在数据库上执行一个 SQL 语句来创建表:

db.execSQL(CREATE_JOB_OFFER_TABLE);
db.execSQL(CREATE_COMPANY_TABLE);

我们将在静态变量中定义这些语句,如下所示:

/**
* SQL CREATE TABLE JOB OFFER sentence
*/
private static final String CREATE_JOB_OFFER_TABLE = "CREATE TABLE "
+ DatabaseContract.JobOfferTable.TABLE_NAME + " ("
+ DatabaseContract.JobOfferTable.TITLE + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.DESC + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.TYPE + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.SALARY + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.LOCATION + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.COMPANY_ID + INTEGER_TYPE + " )";

默认情况下,Android 在每一行中创建一个column_id列,该列是唯一的且自动递增的;因此,在 companies 表中我们不需要创建列 ID。

如您所见,我们在变量中也有逗号和类型以避免错误。直接编写句子时遗漏逗号或犯错误是非常常见的,而且找出错误非常耗时:

/**
* TABLE STRINGS
*/
private static final String TEXT_TYPE = " TEXT";
private static final String INTEGER_TYPE = " INTEGER";
private static final String COMMA = ", ";

我们已经看到了如何创建我们的表,现在我们必须管理更新。在这种情况下,我们将简单地删除以前的信息并重新创建数据库,因为表中没有重要的信息。更新后打开应用程序时,它将重新下载工作机会并填充新数据库:

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  db.execSQL(DROP_JOB_OFFER_TABLE);
  db.execSQL(DROP_COMPANY_TABLE);
  onCreate(db);
}

/**
* SQL DELETE TABLE SENTENCES
*/
public static final String DROP_JOB_OFFER_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.JobOfferTable.TABLE_NAME;
public static final String DROP_COMAPNY_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.CompanyTable.TABLE_NAME;

我们类的完整版本将如下所示:

public class DBOpenHelper extends SQLiteOpenHelper {

  private static final int DATABASE_VERSION = 1;

  /**
  * TABLE STRINGS
  */
  private static final String TEXT_TYPE = " TEXT";
  private static final String INTEGER_TYPE = " INTEGER";
  private static final String COMMA = ", ";

  /**
  * SQL CREATE TABLE sentences
  */
  private static final String CREATE_JOB_OFFER_TABLE = "CREATE TABLE "
  + DatabaseContract.JobOfferTable.TABLE_NAME + " ("
  + DatabaseContract.JobOfferTable.TITLE + TEXT_TYPE + COMMA
  + DatabaseContract.JobOfferTable.DESC + TEXT_TYPE + COMMA
  + DatabaseContract.JobOfferTable.TYPE + TEXT_TYPE +

  COMMA       + DatabaseContract.JobOfferTable.SALARY + TEXT_TYPE +

  COMMA       + DatabaseContract.JobOfferTable.LOCATION + TEXT_TYPE +

  COMMA + DatabaseContract.JobOfferTable.COMPANY_ID +

  INTEGER_TYPE + " )";

  private static final String CREATE_COMPANY_TABLE = "CREATE TABLE "
  + DatabaseContract.CompanyTable.TABLE_NAME + " ("
  + DatabaseContract.CompanyTable.NAME + TEXT_TYPE + COMMA
  + DatabaseContract.CompanyTable.IMAGE_LINK + TEXT_TYPE +  " )";

  /**
  * SQL DELETE TABLE SENTENCES
  */
  public static final String DROP_JOB_OFFER_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.JobOfferTable.TABLE_NAME;
  public static final String DROP_COMPANY_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.CompanyTable.TABLE_NAME;

  public DBOpenHelper(Context context){
    super(context, DatabaseContract.DB_NAME, null, DATABASE_VERSION);
  }

  @Override
  public void onCreate(SQLiteDatabase db) {
    db.execSQL(CREATE_JOB_OFFER_TABLE);
    db.execSQL(CREATE_COMPANY_TABLE);
  }

  @Override
  public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    db.execSQL(DROP_COMPANY_TABLE);
    db.execSQL(DROP_JOB_OFFER_TABLE);
    onCreate(db);
  }
}

数据库访问对象

数据库访问对象,通常称为DAO,是一个管理应用程序与数据库所有访问的对象。从概念上讲,它是数据库和我们的应用程序之间的一个类:

数据库访问对象

这是在J2EEJava 2 Enterprise Edition)服务器端通常使用的模式。在这种模式中,数据库的实现可以被更改,并增加一层独立性,从而允许在不需要更改应用程序中任何数据的情况下更改数据库实现。即使我们在 Android 中不更改数据库的实现(它始终是通过SQLiteOpenHelper获取的 SQLite 数据库),使用这种模式仍然是有意义的。从结构的角度来看,我们将在同一个地方拥有所有的数据库访问操作。同时,将 DAO 作为单例,并使用同步方法,可以防止诸如同时从两个不同的地方尝试打开数据库的问题,如果我们正在写入,可能会被锁定。当然,从应用程序的任何地方检索此单例的可能性也使得访问数据库变得非常简单。

在下一节中,我们将了解如何创建一个内容提供者,它可以替代我们的 DAO 对象;然而,如果我们只是想要存储和从数据库中读取数据,实现内容提供者是非常繁琐的。让我们继续MasteringAndroidApp项目,创建一个名为MasteringAndroidDAO的类,它将存储工作机会和公司信息,并从数据库中展示信息,以便拥有一个离线也能工作的应用。

这个类将是一个单例,有两个公共同步方法:一个用于存储工作机会(在工作机会表和公司表中),另一个用于读取它们。即使我们将信息分成两个表,在读取时我们也会再次合并它,这样我们就可以继续使用当前的适配器显示工作机会,而无需进行重大更改。通过这种方式,你将学会如何在查询中连接两个表。

如果一个方法是同步的,我们保证它不能同时从两个地方执行。因此,使用以下代码:

public class MasteringAndroidDAO {

  /**
  * Singleton pattern
  */
  private static MasteringAndroidDAO sInstane = null;

  /**
  * Get an instance of the Database Access Object
  *
  * @return instance
  */
  public static MasteringAndroidDAO getInstance(){
    if (sInstane == null){
      sInstane = new MasteringAndroidDAO();
    }
    return sInstane;
  }

  public synchronized boolean storeOffers(Context context, List<JobOffer> offers){
    //Store offers
  }

  public synchronized List<JobOffer> getOffersFromDB(Context context){
    //Get offers
  }

}

我们将从storeOffers()方法开始。我们需要做的第一件事是使用DatabaseOpenHelper打开数据库,之后我们需要在数据库中开始一个事务。我们将存储一个项目列表,所以为每个项目执行事务是没有意义的。如果我们打开一个事务,执行所有需要的插入操作,然后结束事务,批量提交所有更改要高效得多:

try {
  SQLiteDatabase db = newDBOpenHelper(context).getWritableDatabase();

  db.beginTransaction();
  //insert single job offer
  db.setTransactionSuccessful();
  db.endTransaction();
  db.close();
} catch ( Exception e){
  Log.d("MasteringAndroidDAO",e.toString());
  return false;
}

提示

记得在最后使用db.close()关闭数据库。否则,它将保持打开状态并消耗资源,如果我们尝试再次打开它,将会抛出异常。

如果我们只需向单个表中插入数据,那么只需创建一个ContentValue对象——这是一个基于我们想要存储的列构建的键值对象——并调用db.insert(contentValue)。然而,我们的示例要稍微复杂一些。为了存储一个工作机会,我们需要知道公司 ID,而要获得这个 ID,我们需要询问数据库是否已经存储了该公司。如果没有,我们需要将其存储并知道分配给它的 ID 是什么,因为正如之前提到的,ID 是自动生成并递增的。

为了确定公司是否已经在表中,我们需要执行一个查询,搜索所有行,看是否有任何行与我们要查找的公司名称匹配。有两种执行查询的方法:query()rawQuery()

执行查询

查询需要以下参数:

  • tableColumns:这是投影。我们可能想要返回整个表中我们想要在游标中返回的列。在这种情况下,它将是 null,等同于SELECT * FROM。或者,我们可能只想返回一列,new String[]{"column_name"},甚至是原始查询。(这里,new String[]{SELECT ….})。

  • whereClause:通常使用"column_name > 5"条件;然而,如果参数是动态的,我们使用"column_name > ?"。问号用于指定参数的位置,这些参数将在下面的whereArgs参数中。

  • whereArgs:这是where子句中的参数,将替换问号。

  • groupByhavingorderbylimit):这些是其他参数,如果不用可以设置为 null。

在我们的案例中,这就是我们如何询问数据库中是否存在公司的。它将返回只有一个列的游标,这是我们获取 ID 所需的所有信息:

Cursor cursorCompany = db.query(DatabaseContract.CompanyTable.TABLE_NAME,
  new String[]{"rowid"},
  DatabaseContract.CompanyTable.NAME +" LIKE ?",
  new String[]{offer.getCompany()},
  null,null,null);

使用QueryBuilder而不是rawQuery的好处是它能防止 SQL 注入。同时,它不容易出错。在性能方面,它并没有任何优势,因为它内部创建了rawQuery

使用原始查询

原始查询只是一个带有 SQL 查询的字符串。在我们的示例中,它将是这样的:

String queryString = "SELECT rowid FROM company_table WHERE name LIKE '?'"; 
Cursor c = sqLiteDatabase.rawQuery(queryString, whereArgs);

在大多数情况下,原始查询的可读性更强,需要实现的代码更少。在这种情况下,有不良意图的用户可以在whereArgs变量中添加更多的 SQL 代码以获取更多信息,产生错误或删除任何数据。它不能防止 SQL 注入。

介绍游标

当我们调用query()rawQuery()时,结果在游标中返回。游标是行的集合,有许多方法可以访问和迭代它。当不再使用时,它应该被关闭。

迭代游标最简单的方法是在循环中调用moveToNext(),这个方法在没有下一个元素时会返回 false:

Cursor c = query….
while (c.moveToNext()) {
  String currentName = c.getString(c.getColumnIndex("column_name"));
}

为了读取这些信息,我们有不同的方法,如getString(),它接收需要值的列的索引。

要知道一个公司是否已经在表中,我们可以执行一个查询,这将返回只有一个整数 ID 列的行集合。如果有结果,ID 将在索引为0的列中:

public int findCompanyId(SQLiteDatabase db, JobOffer offer){
  Cursor cursorCompany = db.query(DatabaseContract.CompanyTable.TABLE_NAME,
  new String[]{"rowid"},
  DatabaseContract.CompanyTable.NAME +" LIKE ?",
  new String[]{offer.getCompany()},
  null,null,null);

  int id = -1;

  if (cursorCompany.moveToNext()){
    id = cursorCompany.getInt(0);
  }
  return id;
}

另一个选项是定义公司名称列为唯一,并使用insertWithOnConflict指定忽略冲突。这样,如果公司已经存在于数据库中或者刚刚插入,它将返回 ID:

db.insertWithOnConflict(DATABASE_TABLE, null, initialValues, SQLiteDatabase.CONFLICT_IGNORE);

我们可以为查询创建一个方法,如果查询结果存在,则从游标中获取 ID。如果没有结果,则结果将是-1。在存储工作机会之前,我们将检查公司是否存在。如果不存在,我们将存储公司,ID 将在插入时返回:

public boolean storeOffers(Context context, List<JobOffer> offers){

  try {
    SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();

    db.beginTransaction();

    for (JobOffer offer : offers){

      ContentValues cv_company = new ContentValues();
      cv_company.put(DatabaseContract.CompanyTable.NAME, offer.getCompany());
      cv_company.put(DatabaseContract.CompanyTable.IMAGE_LINK,offer.getImageLink());

      int id = findCompanyId(db,offer);

      if (id < 0) {
        id = (int) db.insert(DatabaseContract.CompanyTable.TABLE_NAME,null,cv_company);
      }

      ContentValues cv = new ContentValues();
      cv.put(DatabaseContract.JobOfferTable.TITLE,offer.getTitle());
      cv.put(DatabaseContract.JobOfferTable.DESC,offer.getDescription());
      cv.put(DatabaseContract.JobOfferTable.TYPE, offer.getType());
      cv.put(DatabaseContract.JobOfferTable.DESC, offer.getDescription());
      cv.put(DatabaseContract.JobOfferTable.SALARY,offer.getSalary());
      cv.put(DatabaseContract.JobOfferTable.LOCATION,offer.getLocation());
      cv.put(DatabaseContract.JobOfferTable.COMPANY_ID,id);

      db.insert(DatabaseContract.JobOfferTable.TABLE_NAME,null,cv);
    }

    db.setTransactionSuccessful();
    db.endTransaction();

    db.close();

  } catch ( Exception e){
    Log.d("MasteringAndroidDAO", e.toString());
    return false;
  }

  return true;
}

在测试这个之前,理想的情况是准备好从数据库读取的方法,这样我们就可以检查一切是否正确存储。我们的想法是同时查询两个表,使用连接查询,以便返回包含我们需要所有字段的一个游标。

在 SQL 中,这将是一个SELECT * FROM job_offer_table JOIN company_table ON job_offer_table.company_id = company_table.rowid …查询。

我们需要在使用数据库合约中表名的一个查询中这样做。这将是它的样子:

public List<JobOffer> getOffersFromDB(Context context){

  SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();

  String join = DatabaseContract.JobOfferTable.TABLE_NAME + " JOIN " +
  DatabaseContract.CompanyTable.TABLE_NAME + " ON " +
  DatabaseContract.JobOfferTable.TABLE_NAME+"."+DatabaseContract.JobOfferTable.COMPANY_ID
  +" = " + DatabaseContract.CompanyTable.TABLE_NAME+".rowid";

  Cursor cursor = db.query(join,null,null,null,null,null,null);

  List<JobOffer> jobOfferList = new ArrayList<>();

  while (cursor.moveToNext()) {
    //Create job offer from cursor and add it
    //to the list
  }

  cursor.close();
  db.close();

  return jobOfferList;
}

下一步是从游标行创建一个工作机会对象,并将其添加到工作机会列表中:

while (cursor.moveToNext()) {

  JobOffer offer = new JobOffer();
  offer.setTitle(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TABLE_NAME)));
  offer.setDescription(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.DESC)));
  offer.setType(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TYPE)));
  offer.setSalary(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.SALARY)));
  offer.setLocation(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.LOCATION)));
  offer.setCompany(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.NAME)));
  offer.setImageLink(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.IMAGE_LINK)));

  jobOfferList.add(offer);
}

在本例中,我们在添加新数据时将清除数据库。为此,我们将在MasteringAndroidDAO中创建一个方法:

/**
* Remove all offers and companies
*/
public void clearDB(Context context)
{
  SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();
  // db.delete(String tableName, String whereClause, String[] whereArgs);
  // If whereClause is null, it will delete all rows.
  db.delete(DatabaseContract.JobOfferTable.TABLE_NAME, null, null);
  db.delete(DatabaseContract.CompanyTable.TABLE_NAME, null, null);
}

一旦数据库访问对象拥有我们需要的所有方法,我们就需要转到ListFragment并实现逻辑。理想的流程是首先显示数据库中的数据,并启动下载获取新的工作机会。在后台,当更新完成时,优惠将被更新,列表也将刷新。我们将通过内容提供者和一个游标加载器来实现这一点,该加载器能够自动将数据库与列表视图连接起来。为了测试 DAO,如果不存在网络连接,我们将简单地从数据库显示数据,或者获取新的工作机会列表。当新列表下载完成后,我们将清除数据库并存储新的优惠。

如果我们想构建一个系统,以保存工作机会的历史记录,而不是清除数据库,我们需要做的是检查是否有来自服务器的新优惠,这些优惠尚未存储在数据库中,并且只保存新优惠。通过创建一个带有 Parse ID 的新列,我们可以轻松地做到这一点,这样我们就可以使用唯一标识符比较工作机会。

为了检查是否有互联网连接,我们将使用以下代码向连接管理器查询:

public boolean isOnline() {
  ConnectivityManager cm =
  (ConnectivityManager) getActivity().getSystemService(Context.CONNECTIVITY_SERVICE);
  NetworkInfo netInfo = cm.getActiveNetworkInfo();
  return netInfo != null && netInfo.isConnectedOrConnecting();
}

onCreateView方法中,我们需要判断是否存在网络连接。如果存在连接,我们可以下载新的优惠列表,这些优惠将展示并存储在数据库中,从而清除之前的优惠:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
  // Inflate the layout for this fragment
  View view = inflater.inflate(R.layout.fragment_list, container, false);

  mRecyclerView = (RecyclerView) view.findViewById(R.id.my_recycler_view);

  // use this setting to improve performance if you know that changes
  // in content do not change the layout size of the RecyclerView
  mRecyclerView.setHasFixedSize(true);

  // use a linear layout manager
  mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

  //Retrieve the list of offers

  if (isOnline()){
    retrieveJobOffers();
  } else {
    showOffersFromDB();
  }

  return view;
}

public void retrieveJobOffers(){
  ParseQuery<JobOffer> query = ParseQuery.getQuery("JobOffer");
  query.findInBackground(new FindCallback<JobOffer>() {

    @Override
    public void done(List<JobOffer> jobOffersList, ParseException e) {
      MasteringAndroidDAO.getInstance().clearDB(getActivity());
      MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
      mListItems = MasteringAndroidDAO.getInstance().getOffersFromDB(getActivity());
      JobOffersAdapter adapter = new JobOffersAdapter(mListItems);
      mRecyclerView.setAdapter(adapter);
    }

  });
}

public void showOffersFromDB(){
  mListItems = MasteringAndroidDAO.getInstance().getOffersFromDB(getActivity());
  JobOffersAdapter adapter = new JobOffersAdapter(mListItems);
  mRecyclerView.setAdapter(adapter);
}

目前,我们将创建一个带有新元素列表的适配器。如果我们想要在屏幕上用新的工作机会更新列表视图,并使用这个方法,它将重新启动适配器,这会使列表在瞬间为空,并将滚动位置移到顶部。我们不应该创建一个适配器来刷新列表;现有的适配器应该更新元素列表。

为此,我们将在适配器中创建一个updateElements()方法,该方法将替换当前的优惠列表,并调用notifiyDataSetChanged(),导致适配器刷新所有元素。如果我们确切知道更新了多少元素,我们可以使用notifyItemInserted()notifyRangeItemInserted()来更新并仅对添加的新元素进行动画处理,这比notifyDataSetChanged()更有效。

没有必要手动将视图与数据同步。Android 为我们提供了CursorLoader机制,它可以直接将列表视图与数据库连接起来。因此,我们需要做的就是将新的优惠存储在数据库中,列表视图将自动反映我们的更改。然而,所有这些自动化都有代价;它需要一个内容提供者才能工作。

内容提供者

内容提供者与 DAO 的概念非常相似;它是数据与应用程序之间的接口,允许不同的应用程序交换信息。我们可以决定它是公开的还是私有的,是否允许其他应用程序从中获取数据,以及它是否只在我们应用程序内部使用。数据可以存储在数据库中,例如我们即将创建的那个。它也可以存储在文件中;例如,如果我们想要访问图库中的视频或图片,我们将使用 Android 内置的媒体内容提供者。另外,它也可以从网络获取:

内容提供者

内容提供者必须在清单中声明,因为它是我们应用程序的一个组件,并且要指定它是否可以被其他应用程序访问,这是由 exported 属性控制的。让我们从创建我们自己的内容提供者开始。

要创建一个内容提供者,请创建一个MAAProvider类并继承ContentProvider。我们将需要实现以下方法:

public class MAAProvider extends ContentProvider {

  @Override
  public boolean onCreate() {
    return false;
  }

  @Override
  public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    return null;
  }

  @Override
  public Uri insert(Uri uri, ContentValues values) {
    return null;
  }

  @Override
  public int delete(Uri uri, String selection, String[] selectionArgs) {
    return 0;
  }

  @Override
  public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
    return 0;
  }

  @Override
  public String getType(Uri uri) {
    return null;
  }
}

OnCreate方法将在提供者启动时被调用;它将初始化提供者工作所需的所有元素。提供者在应用程序启动时同时启动。系统知道要启动哪个提供者,因为这在清单中定义了。接下来的四个方法是访问和管理数据的方法。最后一个方法返回对象的 MIME 类型。

如我们之前提到的,手机中有不同的内容提供者可供使用;例如,我们可以使用内容提供者访问短信、联系人或图库中的媒体项目。因此,必须有方法来识别和访问它们每一个。这是通过URI统一资源标识符)完成的,它是一个类似于我们在浏览器中访问网站时使用的 URL 的字符串。

URI 由前缀"content://"、紧随其后的字符串标识符(称为权限)组成。它通常是类名加上包名"com.packtpub.masteringandoridapp.MAAProvider",然后是一个斜杠和表名,例如"/company_table"。还可以选择在表内行号后面加上斜杠和数字"/2"

因此,公司表的完整 URI 将是"content://com.packtub.masteringandroidapp.MAAProvider/company_table

具有 ID 编号 2 的公司完整 URI 将是"content://com.packtub.masteringandroidapp.MAAProvider/company_table/2"。这种 URI 在一般情况下表示为company_table/#,其中#将被整数替换。

鉴于我们有两个不同的表和一个通过连接得到的第三个表(可以访问以获取表中的所有元素或获取单行数据),我们有六个可能的 URI:

  • content://com.packtub.masteringandroidapp.MAAProvider/company_table

  • content://com.packtub.masteringandroidapp.MAAProvider/company_job_offer

  • content://com.packtub.masteringandroidapp.MAAProvider/offer_join_company

  • content://com.packtub.masteringandroidapp.MAAProvider/company_table/#

  • content://com.packtub.masteringandroidapp.MAAProvider/company_job_offer/#

  • content://com.packtub.masteringandroidapp.MAAProvider/offer_join_company/#

我们只有一个内容提供者;理论上,这个提供者可以实现所有六个 URI 的queryinsertupdatedeletegetType方法,每个方法都有六种不同的实现。因此,当执行myMAAProvider.insert(URI …)时,我们需要有一个if语句来判断哪个表需要插入,并选择正确的实现方式。类似这样:

@Override
public Uri insert(Uri uri, ContentValues values) {
  if (uri.equals("content://com.packtub.masteringandroidapp.MAAProvider/company_table")){
    //Do an insert in company_table
} else if (uri.equals("content://com.packtub.masteringandroidapp.MAAProvider/offer_table")){
//Do an insert in offer table
} else if ... {
  .
  .
  .
}
}

通过比较这些字符串,您可以看出这似乎是不正确的,如果我们添加一个带有整数结尾的 URI,我们需要一种机制来验证"company_table/2"与通用 URI "company_table/#"相对应。这就是为什么我们有UriMatcherUriMatcher将包含与整数相关联的可能 URL 列表。因此,当它接收到一个 URI 时,它会告诉我们在使用字符串模式时应该使用哪个整数。

创建了UriMatcher并定义了所有可能的情况后,我们可以将可能的情况添加到UriMatcher中,并调用UriMatcher.match(Uri uri),这将返回一个带有情况的整数。我们需要做的就是使用开关来判断我们处于哪种情况:

public class MAAProvider extends ContentProvider {

  public final String authority = "com.packtpub.masteringandroidapp.MAAProvider";

  private UriMatcher mUriMatcher;

  private static final int COMPANY_TABLE = 0;
  private static final int COMPANY_TABLE_ROW = 1;
  private static final int OFFER_TABLE = 2;
  private static final int OFFER_TABLE_ROW = 3;
  private static final int JOIN_TABLE = 4;
  private static final int JOIN_TABLE_ROW = 5;

  @Override
  public boolean onCreate() {
    mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    mUriMatcher.addURI(authority,DatabaseContract.CompanyTable.TABLE_NAME,COMPANY_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.CompanyTable.TABLE_NAME+"/#",COMPANY_TABLE_ROW);
    mUriMatcher.addURI(authority,DatabaseContract.JobOfferTable.TABLE_NAME,OFFER_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.JobOfferTable.TABLE_NAME+"/#",OFFER_TABLE_ROW);
    mUriMatcher.addURI(authority,DatabaseContract.OFFER_JOIN_COMPANY,JOIN_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.OFFER_JOIN_COMPANY+"/#",JOIN_TABLE_ROW);

    mDB = new DBOpenHelper(getContext()).getWritableDatabase();

    return true;
  }

  @Override
  public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    switch (mUriMatcher.match(uri)){
      case COMPANY_TABLE:
      //Query company table
      break;
      case COMPANY_TABLE_ROW:
      //Query company table by id
      break;
      .
      .

我们可以开始实现查询方法,以获取与公司合并的报价列表,并将其设置到适配器中,以检查到目前为止一切是否运行良好。我们需要有以下几个与数据库相关的变量:

private SQLiteDatabase mDB;

这将在onCreate中如下赋值:

mDB = new DBOpenHelper(getContext()).getWritableDatabase();

同样,在查询方法中,我们需要为六种可能性创建查询,如下所示:

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
  switch (mUriMatcher.match(uri)){
    case COMPANY_TABLE:
    return mDB.query(DatabaseContract.CompanyTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case COMPANY_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DatabaseContract.CompanyTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case OFFER_TABLE:
    return mDB.query(DatabaseContract.JobOfferTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case OFFER_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DatabaseContract.JobOfferTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case JOIN_TABLE:
    return mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
    case JOIN_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
  }
  return null;
}

我们需要用以下定义的DBOpenHelper.OFFER_JOIN_COMPANY变量这样做:

public static final String OFFER_JOIN_COMPANY = DatabaseContract.JobOfferTable.TABLE_NAME + " JOIN " +
DatabaseContract.CompanyTable.TABLE_NAME + " ON " +
DatabaseContract.JobOfferTable.TABLE_NAME+"."+DatabaseContract.JobOfferTable.COMPANY_ID
+" = " + DatabaseContract.CompanyTable.TABLE_NAME+".rowid";Content Resolver

要访问内容提供者,我们将使用ContentResolver。这是一个通用实例,它提供了对所有可用内容提供者的访问以及 CRUD 操作(创建、读取、更新和删除):

ContentResolver cr = getContentResolver();

要使用内容解析器,我们需要一个指向内容提供者的 URI。我们可以在调用之前从字符串变量中创建它:

Uri uriPath = Uri.parse("content://"+MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY);
Cursor cursor = cr.query(uriPath, null, null, null, null);

另外,我们可以在提供者中定义一个静态变量列表,以便访问它们。

如果我们现在尝试运行这段代码,将会得到错误:'failed to find provider info for com.packtub.masteringandroidapp.MAAProvider'. 这意味着系统找不到提供者,因为我们还没有将其添加到清单文件中。

要添加一个提供者,我们需要在<application>标签内添加<provider>元素;它需要我们提供者的路径和名称以及权限。在我们的案例中,这两者是相同的:

.
.
.
    <activity
        android:name=".OfferDetailActivity"
        android:label="@string/title_activity_offer_detail" >
    </activity>
    <provider android:name="com.packtpub.masteringandroidapp.MAAProvider"
        android:authorities="com.packtpub.masteringandroidapp.MAAProvider">
    </provider>
</application>

即使我们使用CursorLoader显示数据,并且不使用优惠内容列表,创建一个临时方法来显示内容提供者中的优惠列表也不是一个坏主意。这有助于确保在深入CursorLoader道路之前,内容提供者是可访问的并返回预期的数据:

public void showOffersFromContentProvider(){
  ContentResolver cr = getActivity().getContentResolver();
  Uri uriPath = Uri.parse("content://"+MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY);
  Cursor cursor = cr.query(uriPath, null, null, null, null);

  List<JobOffer> jobOfferList = new ArrayList<>();
  while (cursor.moveToNext()) {

    JobOffer offer = new JobOffer();
    offer.setTitle(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TITLE)));
    offer.setDescription(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.DESC)));
    offer.setType(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TYPE)));
    offer.setSalary(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.SALARY)));
    offer.setLocation(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.LOCATION)));
    offer.setCompany(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.NAME)));
    offer.setImageLink(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.IMAGE_LINK)));

    jobOfferList.add(offer);
  }
  JobOffersAdapter adapter = new JobOffersAdapter(jobOfferList);
  mRecyclerView.setAdapter(adapter);
}

通过将调用showOffersFromDB()替换为showOffersFromContentProvider(),我们应看到完全相同的信息,并以相同的顺序:

if (isOnline()){
  retrieveJobOffers();
} else {
  showOffersFromContentProvider();
}

一旦创建了提供者,CursorLoader对象就可以很容易地实现。在这个阶段,我们可以说大部分工作已经完成。

同步数据库与 UI

当我们使用CursorLoader与内容提供者配合时,游标中返回的数据与数据库中的数据直接关联,这样数据库中的任何更改都会立即反映在 UI 上。当我们有了这个系统,我们只需要关心将数据存储在数据库中以及更新数据。当我们准备好这个系统后,我们将讨论如何实现流行的下拉刷新系统,以在用户需要时更新工作机会。目标是向 Parse 中添加新的工作机会,下拉刷新列表,并立即看到新元素的到来,所有这些都在后台通过内容提供者处理。

实现CursorLoader

为了实现这个目标,下一步是创建CursorLoader。我们之前在书中讨论过加载器;正如我们提到的,它们是在后台加载数据的机制。这个特定的加载器将返回游标中的数据,并从内容提供者加载。它还在检测到源中的任何更改时刷新数据。

要开始使用CursorLoader,我们的活动或片段—在我们的案例中是FragmentList—需要实现LoaderManager.LoaderCallback<Callback>。这个接口将要求我们实现以下方法:

public class ListFragment extends android.support.v4.app.Fragment implements LoaderManager.LoaderCallbacks<Cursor>

@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
  return null;
}

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {

}

@Override
public void onLoaderReset(Loader<Cursor> loader) {

}

让我们从第一个方法开始—onCreateLoader。这个方法接收一个整数 ID 作为参数,这将是我们的加载器的 ID。我们可以在同一个活动中拥有多个加载器,因此我们将为它们分配一个 ID,以便能够识别它们。我们的加载器将定义为:

public static final int MAA_LOADER = 1;

OnCreateLoader方法将在我们告诉LoaderManager初始化我们的加载器时执行。这可以在onCreateView()中完成:

getLoaderManager().initLoader(MAA_LOADER, null, this);

这种方法需要创建所有可以初始化的不同加载器(它们可以是不同类型的加载器);在我们的案例中,我们只有一个,那就是CursorLoader。它将查询表,并将优惠表与公司表连接起来作为结果。内容 URI 的字符串之前已在MAAProvider中定义:

public static final String JOIN_TABLE_URI =  "content://" + MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY;
@Override
public Loader<Cursor> onCreateLoader(int loaderID, Bundle bundle)
{
  switch (loaderID) {
    case MAA_LOADER:
    return new CursorLoader(
    getActivity(),   // Parent activity context
    Uri.parse(MAAProvider.JOIN_TABLE_URI),
    // Table to query
    null,            // Projection to return
    null,            // No selection clause
    null,            // No selection arguments
    null             // Default sort order
    );
    default:
    //Invalid ID
    return null;
  }
}

当我们告诉加载器管理器初始化我们的加载器时,它会自动创建并开始运行到数据库的查询;异步地,它会调用我们实现的第二个方法,即onLoadFinished。在这个方法中,例如,我们可以检索游标并显示数据,就像我们之前从内容解析器获取游标时所做的那样。将我们从课程中创建工作机会的代码移动到JobOffer类的静态方法中,我们的onLoadFinished方法将类似于以下内容:

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {

  List<JobOffer> jobOfferList = new ArrayList<>();

  while (cursor.moveToNext()) {
    jobOfferList.add(JobOffer.createJobOfferfromCursor(cursor));
  }

  JobOffersAdapter adapter = new JobOffersAdapter(jobOfferList);
  mRecyclerView.setAdapter(adapter);
}

这个解决方案在后台查询数据库,并异步显示结果,但它仍然远非完美。我们将遍历游标创建对象列表,之后,我们将列表发送到适配器,适配器再次遍历列表以创建元素。如果我们有一个可以直接从游标构建列表的适配器会怎样呢?我们问题的解决方案是存在的,它被称为CursorAdapter。然而,在转向这个解决方案之前,我们需要实现第三个方法,这个方法仍然悬而未决。

第三个方法,onLoaderReset,在数据无效时被调用。例如,如果源发生了变化,这可能会发生。它移除了对游标的引用,防止内存泄漏,通常与CursorAdapter一起使用。这是三个中最容易实现的。在我们的示例中,我们可以将其留空;由于我们不会在方法外部使用游标,因此不会有任何内存泄漏。如果我们使用CursorAdapter,它将有一个在我们onLoadFinished方法之外的引用,我们需要将适配器设置为null

@Override
public void onLoaderReset(Loader<Cursor> loader) {
  //mAdapter.changeCursor(null);
}

RecyclerViewCursorAdapter

CursorAdapter类基于游标创建适配器,旨在与ListsView一起使用。它继承自BaseAdapter

传递给适配器的游标必须有一个名为_id的列。为此,我们不需要更改我们的数据库;我们可以在创建CursorLoader时,简单地将字段从rowid重命名为_id

这是一个基本的CursorAdapter示例:

SimpleCursorAdapter mAdapter =
new SimpleCursorAdapter(
  this,                // Current context
  R.layout.list_item,  // Layout for a single row
  null,                // No Cursor yet
  mFromColumns,        // Cursor columns to use
  mToFields,           // Layout fields to use
  0                    // No flags
);

创建后,我们可以在onLoadFinished中传递新的游标:

mAdapter.changeCursor(cursor);

如果您正在使用ListView,这个解决方案是完美的;不幸的是,RecyclerView使用RecyclerView.Adapter,并且与BaseAdapter不兼容。因此,CursorLoader类不能与RecyclerViews一起使用。

在这一点上,我们有两个选择:一个是寻找开源解决方案,例如CursorRecyclerAdaptergist.github.com/quanturium/46541c81aae2a916e31d#file-cursorrecycleradapter-java)并将此类包含在我们的应用程序中。

第二个选项是创建我们自己的。为此,我们将创建一个名为JobOfferCursorAdapter的类,它继承自RecyclerView.Adapter<JobOffersAdapter.MyViewHolder>。这个类与JobOfferAdapter一样,会有onCreateViewonBindView方法。它们的实现方式相同,除了优惠信息在游标中而不是列表中。为了从游标行获取JobOffer,我们将创建一个名为getItem(int position)的额外方法。除此之外,我们还需要getCount方法,它将返回游标的大小,以及一个changeCursor方法,它将允许我们在适配器中更换游标。请看以下代码:

public class JobOfferCursorAdapter extends RecyclerView.Adapter<JobOfferCursorsAdapter.MyViewHolder>{

  Cursor mDataCursor;

  @Override
  public int getItemCount() {
    return (mDataCursor == null) ? 0 : mDataCursor.getCount();
  }

  public void changeCursor(Cursor newCursor) {
    //If the cursors are the same do nothing
    if (mDataCursor == newCursor){
      return;
    }

    //Swap the cursors
    Cursor previous = mDataCursor;
    mDataCursor = newCursor;

    //Notify the Adapter to update the new data
    if (mDataCursor != null){
      this.notifyDataSetChanged();
    }

    //Close previous cursor
    if (previous != null) {
      previous.close();
    }
  }

  private JobOffer getItem(int position) {
    //To be implemented
    return null;
  }

  @Override
  public JobOfferCursorAdapter.MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    //To be implemented
    return null;
  }

  @Override
  public void onBindViewHolder(MyViewHolder holder, int position) {
    //To be implemented
  }

  private class MyViewHolder..

}

getItem方法需要从游标中的一行获取Joboffer。为此,我们首先需要使用moveToPosition(int position)方法将游标移动到这个位置,之后,我们可以提取这一行的值:

private Object getItem(int position) {
  mDataCursor.moveToPosition(position);
  return JobOffer.createJobOfferfromCursor(mDataCursor);
}

有了这个方法,我们可以基于之前的JobOffersAdapter在适配器上实现其余的功能:

@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
  View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.row_job_offer, parent, false);
  return new MyViewHolder(v);
}

@Override
public void onBindViewHolder(JobOfferCursorAdapter.MyViewHolder holder, int position) {
  JobOffer jobOffer =  getItem(position);
  holder.textViewName.setText(jobOffer.getTitle());
  holder.textViewDescription.setText(jobOffer.getDescription());
}

public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener{

  public TextView textViewName;
  public TextView textViewDescription;

  public  MyViewHolder(View v){
    super(v);
    textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
    textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    v.setOnClickListener(this);
  }

  @Override
  public void onClick(View view) {
    Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
    JobOffer selectedJobOffer = getItem(getAdapterPosition());
    intent.putExtra("job_title", selectedJobOffer.getTitle());
    intent.putExtra("job_description",selectedJobOffer.getDescription());
    intent.putExtra("job_image",selectedJobOffer.getImageLink());
    view.getContext().startActivity(intent);
  }
}

当我们自定义的CursorAdapter适配到RecyclerView完成后,我们就可以在加载管理器完成时创建游标并设置适当的游标了。在OncreateView中,我们将从服务器检索新数据,并同时用当前数据更新视图:

mAdapter = new JobOfferCursorAdapter();
mRecyclerView.setAdapter(mAdapter);

getLoaderManager().initLoader(MAA_LOADER, null, this);

retrieveJobOffers();

return view;

为了显示数据,我们将在加载管理器完成之后更换游标:

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
  Log.d("ListFragment", "OnLoader Finished :" + cursor.getCount());
  mAdapter.changeCursor(cursor);
}

@Override
public void onLoaderReset(Loader<Cursor> loader) {
  mAdapter.changeCursor(null);
  Log.d("ListFragment", "OnLoader Reset :");
}

当数据库中已有先前数据时,这可以完美工作。但是,如果我们尝试卸载 app 并在第一次运行它,我们会看到列表为空。同时,查看日志,我们可以看到我们正在后台正确地存储新的工作优惠信息:

07-25 16:45:42.796  32059-32059/com.packtpub.masteringandroidapp D/ListFragment﹕ OnLoader Finished :0
07-25 16:45:43.507  32059-32059/com.packtpub.masteringandroidapp D/ListFragment﹕ Storing offers :7

这里发生的情况是,我们数据库中的更改目前没有被检测到,但当我们使用CursorLoaders时,这个问题很容易解决。无需手动注册内容观察者或重新启动加载器;我们可以在游标中设置一个CursorLoader使用的监听器,并在数据库中进行任何更改时通知它。在我们的提供者中,我们可以将通知 URI 设置为游标:

case JOIN_TABLE:
Cursor cursor =  mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;

每当数据库更改时,我们可以调用:

Context.getContentResolver().notifyChange(Uri.parse(MAAProvider.JOIN_TABLE_URI), null);

结果是,CursorLoader将自动刷新列表。如果我们从内容提供者进行插入、更新或删除操作,我们可以在这些操作之前加上这一行来通知内容更改。在我们的示例中,我们将在从 Parse 存储新数据到数据库后简单地手动添加它。你可以使用以下代码来实现这个功能:

public void done(List<JobOffer> jobOffersList, ParseException e) {
  Log.d("ListFragment","Storing offers :"+jobOffersList.size());
  MasteringAndroidDAO.getInstance().clearDB(getActivity());
  MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
  getActivity().getContentResolver().notifyChange(Uri.parse (MAAProvider.JOIN_TABLE_URI), null);
}

现在我们可以卸载 App 并重新安装,我们会看到在后台下载优惠信息的时候列表会空几秒钟。下载一完成,游标加载器就会刷新列表,所有的优惠信息都会出现。为了锦上添花,我们将实现下拉刷新功能。

介绍使用 SwipeRefreshLayout 的下拉刷新功能

通过这个功能,用户可以在列表视图处于顶部时向上滚动来随时刷新列表。这是在如 Gmail 和 Facebook 等应用中常见的流行功能。

引入 SwipeRefreshLayout 实现下拉刷新

为了实现这一功能,Google 发布了一个名为SwipeRefreshLayout的组件,该组件包含在 v4 支持库中。在此库的修订版 21之前,这被显示为屏幕顶部的水平线,颜色会变化。后来,它被改为一个随着滑动动作旋转的半圆形的圆形。

要使用这个功能,我们需要在视图中用这个元素包裹我们的列表:

<android.support.v4.widget.SwipeRefreshLayout  android:id="@+id/swipeRefreshLayout" android:layout_width="match_parent" android:layout_height="match_parent">
  <android.support.v7.widget.RecyclerView android:id="@+id/my_recycler_view" android:scrollbars="vertical" android:layout_width="match_parent" android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>

我们可以创建一个名为mSwipeRefreshLayout的类变量,并设置一个onRefresh监听器,当用户想要刷新时会调用它:

mSwipeRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.swipeRefreshLayout);
mSwipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
  @Override
  public void onRefresh() {
    retrieveJobOffers();
  }
});

当数据下载完成后,我们需要调用setRefresh,并传递false值以停止圆圈无限旋转:

@Override
public void done(List<JobOffer> jobOffersList, ParseException e) {
  Log.d("ListFragment","Storing offers :"+jobOffersList.size());
  MasteringAndroidDAO.getInstance().clearDB(getActivity());
  MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
  getActivity().getContentResolver().notifyChange(Uri.parse(MAAProvider.JOIN_TABLE_URI), null);
  mSwipeRefreshLayout.setRefreshing(false);
}

刷新时,它应该类似于以下截图:

介绍使用 SwipeRefreshLayout 的下拉刷新

我们还可以通过SwipeRfreshLayoutsetColorScheme()方法在旋转时改变箭头的颜色。只需在 XML 中定义三种颜色,并设置三种不同颜色的 ID:

<resources>
  <color name="orange">#FF9900</color>
  <color name="green">#009900</color>
  <color name="blue">#000099</color> 
</resources>

setColorSchemeResources(R.color.orange, R.color.green, R.color.blue);

我们已经实现了我们的目标。有一个简单的方法可以测试整个系统是否工作正常,从SwipeToRefreshLayout到后台的 Parse 请求、内容提供者、数据库和游标加载器。我们可以打开应用,在列表屏幕上时,前往 Parse 创建一个新的工作机会,然后返回应用并下拉刷新。我们应在刷新后看到新的工作机会出现。

总结

在本章中,你学习了如何创建数据库、使用数据库契约和数据库打开助手。我们了解了 DAO 的模式,并对其进行了基本操作。此外,我们用内容提供者替换了 DAO,解释了 URI 匹配器的工作原理以及如何通过内容解析器访问它。

这使得我们可以将CursorLoader与我们自己的CursorAdapter实现结合使用,该实现与RecyclerView兼容,从而使得 UI 与数据库保持同步。在本章的结尾,我们了解了如何使用流行的下拉刷新功能按需更新内容。

在下一章中,我们将了解如何向我们的应用程序添加推送通知以及分析服务,并概述当前市场上可用的分析服务和推送通知选项之间的差异。

第九章:推送通知和分析

我们将从讨论推送通知开始本章。你将学习如何使用谷歌云消息传递(Google Cloud Messaging)实现带有通知的自定义解决方案,包括服务器端和应用程序端。然后,我们将在示例中添加使用 Parse 的通知。为了完成通知部分,我们将使用NotificationCompat显示我们的自定义通知。

在本章的下半部分,我们将讨论分析。拥有分析功能来追踪用户在我们应用中的行为是了解用户如何行为的关键,这使我们能够识别模式并改善体验。我们将使用 Parse 实现一个示例,并概述市场上最受欢迎的解决方案。

  • 推送通知

    • 使用 GCM 发送和接收

    • 来自 Parse 的通知

    • NotificationCompat

  • 分析

    • 使用 Parse 的分析
  • 错误报告

推送通知

推送通知对于吸引用户和提供实时更新非常重要。它们有助于提醒用户有待执行的操作。例如,在万事达卡(MasterCard)创建的Qkr!应用中,用户可以在一些餐厅订餐订饮料,如果用户在相当长一段时间后仍未付款,他们会发送一个通知提醒用户在离开餐厅前需要付款。

当我们需要告诉用户我们有新内容或者其他用户给他们发送了消息时,它们也非常有效。服务器端发生的任何需要通知用户的变化都是使用通知的完美场景。

通知也可以从我们自己的应用程序本地发送;例如,我们可以设置一个闹钟并显示通知。它们不一定非要从服务器发送。

它们显示在屏幕顶部的状态栏中,在一个称为通知区域的地方。

推送通知

通知所需的最少信息包括一个图标、一个标题和详细文本。随着材料设计的到来,我们可以以不同方式自定义通知;例如,我们可以为它们添加不同的操作:

推送通知

如果我们从屏幕顶部向下滚动,将会显示通知抽屉,我们可以在其中看到所有由通知显示的信息:

推送通知

通知不应作为双向通道通信的一部分。如果我们的应用程序需要与服务器持续通信,例如在即时通讯应用中,我们应该考虑使用套接字、XMPP 或其他任何消息传递协议。理论上,通知是不可靠的,我们无法控制它们将在何时确切收到。

然而,不要滥用通知;它们是用户卸载你应用的一个很好的理由。尽量将通知降至最少,只在必要时使用。

你可以为通知分配一个优先级,从 Android Lollipop 开始,你可以根据这个优先级过滤你想接收的通知。

这些是你处理通知时应该牢记的关键点和概念。在深入了解更多理论知识之前,我们将实践向我们的应用发送通知。

使用 GCM 发送和接收通知

市场上存在不同的解决方案来发送推送通知;其中之一是 Parse,它有一个友好的控制面板,任何人都可以轻松地发送推送通知。我们将以 Parse 为例,但首先,了解其内部工作原理以及我们如何构建自己的通知发送系统是有好处的。

GCM谷歌云消息服务)使用推送通知,我们将这些通知发送到我们的移动设备。谷歌有名为 GCM 连接服务器的服务器来处理这个过程。如果我们想要发送推送通知,我们首先需要告诉这些服务器,然后它们会在稍后发送到我们的设备。我们需要创建一个服务器或使用第三方服务器,通过 HTTP 或 XMPP 与 GCM 服务器通信,因为可以使用这两种协议进行通信。

使用 GCM 发送和接收通知

如我们之前所述,我们无法精确控制消息接收的时间,因为我们无法控制 GCM 服务器。它会将消息排队并在设备在线时发送。

要创建我们自己的解决方案,首先需要在谷歌开发者网站上的我们的应用中启用消息传递服务,网址为developers.google.com/mobile/add?platform=android

使用 GCM 发送和接收通知

创建应用后,启用 GCM 消息传递,系统会提供给你一个发送者 ID 和一个服务器 API 密钥。发送者 ID 以前被称为项目编号。

如果我们想要接收 GCM 消息,需要将我们的客户端(也就是我们的移动应用)注册到这个项目中。为此,我们的应用将使用 GCM API 进行注册并获得一个令牌作为确认。完成这一步后,GCM 服务器就会知道你的设备已准备好接收来自这个特定项目/发送者的推送通知。

使用 GCM 发送和接收通知

我们需要添加 Play 服务以使用这个 API:

 compile "com.google.android.gms:play-services:7.5.+"

注册是通过实例 ID API 完成的,调用instanceID.getToken时传入SenderID作为参数:

InstanceID instanceID = InstanceID.getInstance(this);
String token = instanceID.getToken(getString(R.string.gcm_defaultSenderId),
GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);

我们需要异步调用这个方法,并在我们的应用中保持一个布尔变量,以记录我们是否已成功注册。我们的令牌可能会随时间变化,通过onRefreshToken()回调我们可以知道何时发生变化。需要将令牌发送到我们的服务器:

@Override
public void onTokenRefresh() {
  //Get new token from Instance ID with the code above
  //Send new token to our Server
}

完成这一步后,我们需要创建一个GCMListener并向 Android 清单中添加一些权限:

<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />

<permission android:name="com.example.gcm.permission.C2D_MESSAGE"
  android:protectionLevel="signature" />
<uses-permission android:name="com.example.gcm.permission.C2D_MESSAGE" />

<application ...>
  <receiver
    android:name="com.google.android.gms.gcm.GcmReceiver"
    android:exported="true"
    android:permission="com.google.android.c2dm.permission.SEND" >
    <intent-filter>
      <action android:name="com.google.android.c2dm.intent.RECEIVE" />
      <category android:name="com.example.gcm" />
    </intent-filter>
  </receiver>
  <service
    android:name="com.example.MyGcmListenerService"
    android:exported="false" >
    <intent-filter>
      <action android:name="com.google.android.c2dm.intent.RECEIVE" />
    </intent-filter>
  </service>
  <service
    android:name="com.example.MyInstanceIDListenerService"
    android:exported="false">
    <intent-filter>
      <action android:name="com.google.android.gms.iid.InstanceID"/>
    </intent-filter>
  </service>
</application>

</manifest>

GCMListener将包含onMessageReceived方法,当我们接收到任何消息时会被调用。

这就是我们需要的客户端方面的全部内容;至于服务器端,由于它完全取决于所选的技术和语言,本书将不详细介绍。网上有很多用于发送通知的 Python、Grails、Java 等代码片段和脚本,很容易找到。

我们实际上并不需要一个服务器来发送通知,因为我们可以直接与 GCM 通信。我们需要做的就是向gcm-http.googleapis.com/gcm/send发送一个POST请求。这可以通过任何在线的POST发送服务轻松完成,比如hurl.it或 Postman——一个用于发送网络请求的 Google Chrome 扩展程序。我们的请求需要如下所示:

Content-Type:application/json
Authorization:key="SERVER_API_LEY"
{
  "to" : "RECEIVER_TOKEN"
  "data" : {
    "text":"Testing GCM"
  },
}

使用 GCM 发送和接收通知

继续使用MasteringAndroidApp,我们将通过 Parse 实现推送通知。

使用 Parse 的推送通知

对于我们的示例,我们将坚持使用 Parse。主要原因是,我们不需要担心服务器端,也不需要使用这个解决方案在 Google 开发者控制台创建应用程序。另一个原因是它有一个很好的内置控制面板来发送通知,如果我们提前跟踪了不同参数的用户,我们还可以针对不同的用户。

使用 Parse 的推送通知

使用 Parse,我们不需要创建 GCM 监听器。相反,它使用 Parse 库中已经包含的服务,我们只需为这项服务注册一个订阅者。我们需要做的就是向我们的应用程序添加权限和接收器,然后就可以开始了:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<permission android:protectionLevel="signature" android:name="com.packtub.masteringandroidapp.permission.C2D_MESSAGE" />
<uses-permission android:name="com.packtpub.masteringandroidapp.permission.C2D_MESSAGE" />

确保最后两个权限与您的包名相匹配。接收器需要放在application标签内:

<service android:name="com.parse.PushService" />
<receiver android:name="com.parse.ParseBroadcastReceiver">
  <intent-filter>
    <action android:name="android.intent.action.BOOT_COMPLETED" />
    <action android:name="android.intent.action.USER_PRESENT" />
  </intent-filter>
</receiver>

<receiver android:name="com.parse.ParsePushBroadcastReceiver"
  android:exported="false">
  <intent-filter>
    <action android:name="com.parse.push.intent.RECEIVE" />
    <action android:name="com.parse.push.intent.DELETE" />
    <action android:name="com.parse.push.intent.OPEN" />
  </intent-filter>
</receiver>

<receiver android:name="com.parse.GcmBroadcastReceiver"
  android:permission="com.google.android.c2dm.permission.SEND">
  <intent-filter>
    <action android:name="com.google.android.c2dm.intent.RECEIVE" />
    <action android:name="com.google.android.c2dm.intent.REGISTRATION" />
    <category android:name="com.packtpub.masteringandroidapp" />
  </intent-filter>
</receiver>

</application>

为了监听通知,我们可以在Application类的OnCreate方法中注册一个订阅者:

ParsePush.subscribeInBackground("", new SaveCallback() {
  @Override
  public void done(com.parse.ParseException e) {
    if (e == null) {
      Log.d("com.parse.push", "successfully subscribed to the broadcast channel.");
    } else {
      Log.e("com.parse.push", "failed to subscribe for push", e);
      }
  }
});

现在,一切准备就绪。只需进入 Parse 网页,选择推送标签,点击+ 发送推送。你可以指定受众,选择立即发送还是延迟发送以及其他参数。它会跟踪已发送的推送,并指明发送给了哪些人。

使用 Parse 的推送通知

如果你在推送发送列中看到1,然后查看设备中的通知,那么一切正常。你设备中的通知应该如下所示:

使用 Parse 的推送通知

使用 NotificationCompat

目前,我们可以看到由 Parse 接收器创建的默认通知。但是,我们可以设置自己的接收器,并通过NotificationCompat创建更美观的通知。这个组件在支持 v4 库中引入,可以显示 Android L 和 M 以及之前版本直到 API 4 的最新功能通知。

简而言之,我们需要做的是借助NotificationCompat.Builder创建一个通知,并通过NotificationManager.notify()将这个通知传递给系统:

public class MyNotificationReceiver  extends BroadcastReceiver {

  @Override
  public void onReceive(Context context, Intent intent) {
    Notification notification = new NotificationCompat.Builder(context)
    .setContentTitle("Title")
    .setContentText("Text")
    .setSmallIcon(R.drawable.ic_launcher)
    .build();
    NotificationManagerCompat.from(context).notify(1231,notification);
  }

}

这将显示我们的通知。标题、文本和图标是必填项;如果我们不添加这三个属性,通知将不会显示。要开始使用我们的自定义接收器,我们需要在清单中指定我们想要使用的注册,而不是 Parse 推送接收器:

receiver android:name="com.packtpub.masteringandroidapp.MyNotificationReceiver" android:exported="false">
  <intent-filter>
    <action android:name="com.parse.push.intent.RECEIVE" />
    <action android:name="com.parse.push.intent.DELETE" />
    <action android:name="com.parse.push.intent.OPEN" />
  </intent-filter>
</receiver>

我们讨论了如何使用NotificationCompat显示自定义通知。通知有自己的设计指南,并且是材料设计的重要组成部分。建议查看这些指南,并在在应用中使用此组件时牢记它们。

注意

你可以在developer.android.com/design/patterns/notifications.html找到指南。

分析的重要性

了解用户如何使用你的应用非常重要。分析帮助我们理解哪些屏幕访问量最大,用户在我们的应用中购买哪些产品,以及为什么某些用户在注册过程中退出,同时获取有关性别、位置、年龄等信息。

我们甚至可以追踪用户在我们的应用中遇到的崩溃,以及有关设备型号、Android 版本、崩溃日志等信息。

这些数据帮助我们改善用户体验,例如,如果我们发现用户的行为并不像我们预期的那样。它有助于定义我们的产品;如果我们的应用中有不同的功能,我们可以确定哪个功能使用最多。它帮助我们了解受众,这对市场营销可能有益。通过崩溃报告,更容易保持应用的免费和崩溃。

我们将使用 Parse 作为一个例子来开始追踪一些事件。

Parse 的统计分析

仅通过我们已经在调用的Parse.init()方法,无需添加任何额外代码,在 Parse 控制台的分析标签下就能看到一些统计数据。

Parse 的统计分析

受众部分,我们可以看到每日、每周和每月显示的活跃安装和活跃用户。这有助于我们了解我们有多少用户以及其中有多少是活跃的。如果我们想知道有多少用户卸载了应用,我们可以查看留存部分。

我们将追踪一些事件和崩溃信息,以在这两个部分显示信息,但首先,我们将查看资源管理器。如果你点击左侧的资源管理器按钮,你应该会看到以下选项:

Parse 的统计分析

这将显示一个表格,我们可以从中看到过滤我们应用数据的各种选项。一旦我们开始追踪事件和动作,这里将会有更多的列,我们将能够创建复杂的查询。

默认情况下,如果我们点击运行查询,我们将看到以下表格图像:

使用 Parse 的分析

它显示了默认列下可用的所有信息;目前不需要额外的列。我们可以看到所有不同的请求类型以及操作系统、操作系统版本和我们应用程序的版本。

我们可以使用过滤器来产生不同的输出。一些有趣的输出可能是,例如,按应用版本排序和分组,以便了解每个版本有多少人在使用。

如果我们使用相同的 Parse 数据库用于不同的平台,比如 Android 和 iOS,我们可以按平台进行过滤。

下面是一个按操作系统版本过滤的示例,我们可以看到用户当前正在使用的所有 Android 版本:

使用 Parse 的分析

为了收集更多关于应用程序何时以及被打开频率的数据,我们可以在启动画面或第一个活动的oncreate方法中添加以下行。

ParseAnalytics.trackAppOpenedInBackground(getIntent());

这是一个我们可以追踪的事件示例,但通常我们提到事件追踪时,是指自定义事件。例如,如果我们想追踪哪个职位招聘信息访问量最高,我们会在JobOfferDetailActivity中追踪一个事件,并附上访问文章的标题。我们还可以在点击行打开招聘信息时的onlick监听器中追踪此事件。这没有固定的规则;实现方式可能有所不同。但我们需要知道,目标是追踪查看招聘信息时的事件。

OfferDetailActivityOnCreate方法中选择跟踪事件的选项的代码将类似于以下代码:

public class OfferDetailActivity extends AppCompatActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_offer_detail);

    String job_title = getIntent().getStringExtra("job_title");

    Map<String, String> eventParams = new HashMap<>();
    eventParams.put("job_title", job_title);
    ParseAnalytics.trackEventInBackground("job_visited", eventParams);

trackEventInBackground方法会启动一个后台线程来为我们创建网络上传请求。参数作为具有最多八个的Map字符串发送。

如果我们在不同时间访问不同的招聘信息,然后进入分析浏览器部分,我们可以轻松创建一个查询来查看每个职位招聘信息被打开的次数。

使用 Parse 的分析

通过将数据按维度分组,这些维度包括与事件追踪一起发送的不同参数,并使用计数的聚合,我们可以得到每个访问过的职位招聘信息的计数。

接下来,我们将看看如何利用这种事件追踪来使用 Parse 作为错误报告工具。

错误报告

当我们的应用程序被分发时,报告崩溃对于保持应用程序无错误和崩溃至关重要。市场上有成百上千的 Android 设备,以及即使是最好的质量保证人员或测试员在发布应用程序时也可能失误的不同情况,最终导致应用程序崩溃。

我们需要假设我们的应用程序可能会崩溃。我们必须尽可能编写最好的代码,但如果发生崩溃,我们需要有工具来报告并修复它。

Parse 允许我们使用以下代码跟踪错误:

Map<String, String> dimensions = new HashMap<String, String>(); dimensions.put('code', Integer.toString(error.getCode())); ParseAnalytics.trackEvent('error', dimensions);

然而,这种解决方案只允许我们跟踪受控代码段的错误。例如,假设我们有一个网络请求,它返回了一个错误。这种情形可以轻松处理;我们只需追踪来自服务器的错误响应事件。

当我们的应用程序中出现 NullPointerException 时,就会有一个问题,这就是因为发生了我们无法在代码中检测到的意外情况而导致崩溃。例如,如果工作的图片链接为空,而我尝试读取链接而不检查属性是否为空,我将得到 NullPointerException,应用程序将崩溃。

如果我们不能控制发生错误的代码部分,我们如何跟踪这个问题呢?幸运的是,市场上有些工具可以为我们完成这项工作。HockeyApp 是一个帮助分发测试版本并收集实时崩溃报告的工具。这是一个在网页面板上显示我们应用程序错误报告的工具。集成非常简单;我们只需添加以下内容到库中:

compile 'net.hockeyapp.android:HockeySDK:3.5.0-b.4'

然后,我们需要调用以下方法来报告错误:

CrashManager.register(this, APP_ID);

当你将 APK 上传到 HockeyApp 或者在 Hockey 网站上手动创建一个新应用程序时,你将找到 APP_ID

错误报告

一旦我们知道 App_ID 并注册了崩溃报告,如果我们遇到崩溃,我们将看到一个带有发生次数的列表,如下面的屏幕截图所示:

错误报告

在分析方面,我们最后要说的是,Parse 只是一个选择之一;通常还会使用 Google Analytics,它包含在 Google Play 服务库中。Google Analytics 允许我们创建更复杂的报告,例如漏斗跟踪,以查看在漫长的注册过程中我们失去了多少用户,我们还可以在不同的图表和直方图中查看数据。

如果你属于一个大机构,可以看看 Adobe Omniture。这是一个企业工具,可以帮助你跟踪不同的事件作为变量,然后创建公式来显示这些变量。它还允许你将移动分析数据与其他部门(如销售、市场营销和客户服务)的数据结合起来。根据我的个人经验,我看到使用 Omniture 的公司通常会有专人全职从事分析研究。在这种情况下,开发者需要知道的只是如何实现 SDK 和跟踪事件;创建复杂报告不是开发者的任务。

总结

在本章中,你学习了如何为我们的应用程序添加通知。我们使用 Parse 实现了推送通知,并讨论了如何使用 Google Cloud Messaging 创建我们自己的自定义通知服务,包括客户端所需的所有代码和测试服务器端的工具。在章节的后半部分,我们介绍了分析,解释了它们的重要性,并用 Parse 跟踪事件。在分析领域,一个重要的方面是错误报告。我们还使用 Parse 和 HockeyApp 跟踪了应用程序中的错误。最后,我们概览了其他分析解决方案,例如 Google Analytics 和 Adobe Omniture。

在下一章中,我们将使用位置服务,并学习如何将MapView添加到我们的示例中,显示带有位置标记的谷歌地图。

第十章:位置服务

在本章中,我们将学习如何使用 Google 的 Map Fragment 向我们的应用程序添加地图视图。我们将在地图上添加标记,用于指出感兴趣的位置。

为了实现这一点,我们还将讨论如何在 Google 开发者控制台创建项目,并设置我们的应用程序以使用 Google Play 服务 SDK,这是在任何 Android 应用程序中使用 Google 服务的必要条件。

Parse 中的每个工作机会都有一个位置字段;基于此,我们将在地图上显示标记。

  • 配置项目

    • 获取 Google Maps API 密钥

    • 配置AndroidManifest.xml

  • 添加地图

    • 为 ViewPager 创建片段

    • 实现地图片段

  • 添加标记

    • 从 Parse 检索数据

    • 为每个位置显示标记

  • 添加标题

配置项目

为了让我们使用 Google Play 服务 API,我们需要使用 Google Play 服务 SDK 设置我们的项目。如果你还没有安装,请前往 Android SDK 管理器并获取 Google Play 服务 SDK。

既然我们的应用使用了 Google Play 服务,为了测试应用,你必须确保在以下设备之一上运行应用:

  1. 配备有 Android 2.3 或更高版本且安装有 Google Play 商店的 Android 设备(推荐)。

  2. 已设置 Google Play 服务的模拟器。如果你使用的是 Genymotion,则默认不会安装Google Play 服务配置项目

我们需要让 Google Play 服务 API 对应用可用。

打开应用的build.gradle文件,并在 dependencies 下添加play-services库。添加到build.gradle文件的行应该类似于这样:

compile 'com.google.android.gms:play-services:7.8.0'

确保将此更新为play-services的最新版本,并在发布新版本时进行更新。

保存文件,并点击与 Gradle 文件同步项目

获取 API 密钥

为了使用 Google Maps API,我们需要在 Google 开发者控制台注册我们的项目并获得一个 API 密钥,然后将其添加到我们的应用中。

首先,我们需要获取我们唯一应用程序的 SHA-1 指纹。我们可以从调试证书发布证书中获得此信息。

  • 调试 证书在执行调试构建时会自动创建。此证书只能用于当前正在测试的应用。不要使用调试证书发布应用。

  • 发布 证书在执行发布构建时创建。也可以使用keytool程序创建证书。当应用准备发布到 Play 商店时,必须使用此证书。

显示调试证书指纹

  • 找到名为debug.keystore的调试密钥库文件。此文件通常在与 Android 虚拟设备文件相同的目录中:

    • OS X 和 Linux: ~/.android/

    • Windows Vista 和 Windows 7: C:\Users\your_user_name\.android\

  • 要显示 SHA-1 指纹,请打开终端或命令提示符窗口,并输入以下内容:

    • OS X 和 Linux:我们使用 keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android 命令。

    • Windows Vista 和 Windows 7:我们使用 keytool -list -v -keystore "%USERPROFILE%\.android\debug.keystore" -alias androiddebugkey -storepass android -keypass android 命令。

输入命令并按下 Enter 键后,您将看到类似这样的输出:

Alias name: androiddebugkey
Creation date: Dec 16, 2014
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=Android Debug, O=Android, C=US
Issuer: CN=Android Debug, O=Android, C=US
Serial number: 32f30c87
Valid from: Tue Dec 16 11:35:40 CAT 2014 until: Thu Dec 08 11:35:40 CAT 2044
Certificate fingerprints:
         MD5:  7E:06:3D:45:D7:1D:48:FE:96:88:18:20:0F:09:B8:2A
         SHA1: BD:24:B2:7C:DA:67:E5:80:78:1D:75:8C:C6:66:B3:D0:63:3E:EE:84
         SHA256: E4:8C:BD:4A:24:CD:55:5C:0E:7A:1E:B7:FC:A3:9E:60:28:FB:F7:20:C6:C0:E9:1A:C8:13:29:A6:F2:10:42:DB
         Signature algorithm name: SHA256withRSA
         Version: 3

创建 Google 开发者控制台项目

前往 console.developers.google.com/project 并创建一个账户,如果您还没有这么做的话。首先,创建一个以您希望的名字命名的新项目。项目创建后,执行以下步骤:

  1. 在左侧边栏中,点击 APIs & auth,然后选择 APIs 选项:创建 Google 开发者控制台项目

  2. 选择 Google Maps Android API 并启用它。

  3. 打开 凭据,然后点击 [创建新密钥]

  4. 选择 Android 密钥,并输入您的 SHA-1 指纹,然后是您的项目包名,两者之间用分号隔开,如下所示:

    BD:24:B2:7C:DA:67:E5:80:78:1D:75:8C:C6:66:B3:D0:63:3E:EE:84;com.packtpub.masteringandroidapp
    
  5. 完成这些操作后,您将能够像以下截图一样查看凭据:创建 Google 开发者控制台项目

配置 AndroidManifest.xml

既然我们已经为 Android 应用获取了 API 密钥,我们需要将其添加到 AndroidManifest.xml 文件中。

打开您的 AndroidManifest.xml 文件,并在 <application> 元素下添加以下代码作为子元素:

<meta-data
  android:name="com.google.android.geo.API_KEY"
  android:value="API_KEY"/>

value 属性中的 API_KEY 替换为 Google 开发者控制台给出的 API 密钥。

我们还需要将其他几个设置添加到 AndroidManifest 中。按如下方式设置 Google Play 服务版本:

<meta-data
  android:name="com.google.android.gms.version"
  android:value="@integer/google_play_services_version" />

按如下方式设置必要的权限:

  • INTERNET:此权限用于从 Google Maps 服务器下载地图数据。

  • ACCESS_NETWORK_STATE:这将允许 API 检查连接状态,以确定是否能够下载数据。

  • WRITE_EXTERNAL_STORAGE:这将允许 API 缓存地图数据。

  • ACCESS_COARSE_LOCATION:这允许 API 使用 Wi-Fi 或移动数据获取设备的位置。

  • ACCESS_FINE_LOCATION:这将比 ACCESS_COARSE_LOCATION 提供更精确的位置,并且还将使用 GPS 以及 Wi-Fi 或移动数据。请查看以下代码:

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    

您还需要设置您的 OpenGL ES。Maps API 使用 OpenGL ES 来渲染地图,因此需要安装它才能显示地图。为了通知其他服务这一需求,并防止不支持 OpenGL 的设备在 Google Play 商店显示您的应用,请在 AndroidManifest.xml 文件的 <manifest> 下添加以下内容作为子元素:

<uses-feature
  android:glEsVersion="0x00020000"
  android:required="true"/>

您当前的 AndroidManifest.xml 文件应与以下代码类似:

<?xml version="1.0" encoding="UTF-8"?>
<manifest  package="com.packtpub.masteringandroidapp">
  <uses-feature android:glEsVersion="0x00020000" android:required="true" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <application android:name=".MAApplication" android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme">
    <activity android:name=".SplashActivity" android:label="@string/app_name">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:name=".MainActivity" android:label="@string/title_activity_main" />
    <activity android:name=".OfferDetailActivity" android:label="@string/title_activity_offer_detail" />
    <provider android:name=".MAAProvider" android:authorities="com.packtpub.masteringandroidapp.MAAProvider" />
    <meta-data android:name="com.google.android.geo.API_KEY" android:value="AIzaSyC9o7cLdk_MIX_aQhaOLvoqYywK61bN0PQ" />
    <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" />
  </application>
</manifest>

添加地图

既然我们的应用程序已配置好使用地图服务,我们可以开始讨论如何在应用程序中添加视觉地图。对于地图,我们将创建另一个 Fragment,它将被加载到ViewPager的第二个页面上。

有两种方法可以显示谷歌地图;一个MapFragment或一个MapView对象都可以表示它。

添加 fragment

在我们的fragments目录中创建一个新的 Java 类,名为MyMapFragment。该类应扩展Fragment类型。然后,重写OnCreateView方法,并让它返回fragment_my_map的膨胀视图:

package com.packtpub.masteringandroidapp.fragments;

import …

/**
* Created by Unathi on 7/29/2015.
*/
public class MyMapFragment extends Fragment {

  @Nullable
  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.fragment_my_map, container, false);

    return view;
  }
}

接下来,创建 fragment 的布局文件,并将其命名为fragment_my_map。将布局的根元素设置为FrameLayout。我们暂时将TextView添加到我们的布局中,只是为了验证它是否有效。fragment_my_map.xml文件的代码应类似于以下内容:

<?xml version="1.0" encoding="UTF-8"?>
<FrameLayout  android:layout_width="match_parent" android:layout_height="match_parent">
  <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="This is a TextView" android:layout_gravity="center" android:textSize="25dp" />
</FrameLayout>

将我们的 fragment 添加到应用程序的最后一步将是编辑MyPagerAdapter.java文件,使其作为第二个页面显示。为此,将getItem方法的第二个 case 更改为返回MyMapFragment的实例,并将getPageTitle方法的第二个 case 中的页面标题更改为返回MAP

@Override
public Fragment getItem(int i) {
  switch (i) {
    case 0 :
    return new ListFragment();
    case 1 :
    return new MyMapFragment();
    case 2 :
    return new SettingsFragment();
    default:
    return null;
  }
}

@Override
public CharSequence getPageTitle(int position) {
  switch (position) {
    case 0 :
    return "LIST";
    case 1 :
    return "MAP";
    case 2 :
    return "SETTINGS";
    default:
    return null;
  }
}

现在,当您运行应用程序时,ViewPager的第二个页面应该被我们新的 fragment 替换。

添加 fragment

实现 MapFragment

我们现在将使用MapFragment在我们的应用程序上显示地图。我们可以通过添加一个带有android:namecom.google.android.gms.maps.MapFragment<fragment>布局来实现。这样做将自动将MapFragment添加到activity

以下是fragment_my_map.xml的代码:

<?xml version="1.0" encoding="UTF-8"?>
<FrameLayout  android:layout_width="match_parent" android:layout_height="match_parent">
  <fragment android:name="com.google.android.gms.maps.MapFragment" android:id="@+id/map" android:layout_width="match_parent" android:layout_height="match_parent" />
</FrameLayout>

接下来,为了能够处理我们添加到布局中的MapFragment,我们需要使用FragmentManager,我们从getChildFragmentManager获取它,通过findFragmentById。这将在OnCreateView方法中完成:

FragmentManager fm = getChildFragmentManager();
mapFragment = (SupportMapFragment) fm.findFragmentById(R.id.map);
if (mapFragment == null) {
  mapFragment = SupportMapFragment.newInstance();
  fm.beginTransaction().add(R.id.map, mapFragment).commit();
}

我们将把我们的 fragment 分配给SupportMapFragment,而不是仅仅MapFragment,这样应用程序就可以支持低于12的 Android API 级别。使用以下代码:

以下是MyMapFragment.java的代码:

public class MyMapFragment extends Fragment{

  private SupportMapFragment mapFragment;

  @Nullable
  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.fragment_my_map, container, false);

    FragmentManager fm = getChildFragmentManager();
    mapFragment = (SupportMapFragment) fm.findFragmentById(R.id.map);
    if (mapFragment == null) {
      mapFragment = SupportMapFragment.newInstance();
      fm.beginTransaction().add(R.id.map, mapFragment).commit();
    }

    return view;
  }

}

现在,当我们运行应用程序时,地图将在屏幕上显示。

实现 MapFragment

添加标记

谷歌地图现在可见,但它还没有为用户显示任何有用的数据。为了实现这一点,我们将添加地图标记来指示用户感兴趣的点。这些将是不同工作机会的位置,我们将从 Parse 数据库中下载。

我们还将学习如何将用于在地图上标记点的图标更改为自定义图像,并在标记上加上标题。这将使我们的应用程序看起来更有趣、更具有信息性。

从 Parse 获取数据

在我们能够显示所有标记之前,需要从 Parse 下载所有必要的数据。

MyMapFragment.java 中,我们将使用 ParseQuery 获取位置列表,并在显示每个职位之前获取相关信息。执行以下步骤:

  • 创建一个名为 googleMapGoogleMap 类型的私有成员变量,并覆盖 onResume() 方法。

  • onResume() 中,检查 googleMap 是否为空;如果是,这意味着我们还没有向当前地图实例添加标记。如果 googleMap 为空,从我们已创建的 MapFragment 分配地图。这是使用 getMap() 实现的:

    if (googleMap == null) {
    
      googleMap = MapFragment.getMap();
    
    }
    
  • 创建一个 ParseQuery,它将检索我们 Parse 数据库中 JobOffer 表的所有数据。使用 findInBackground() 函数和 FindCallback,这样我们就可以在数据全部下载完成后开始处理数据。使用以下代码:

    ParseQuery<JobOffer> query = ParseQuery.getQuery("JobOffer");
    query.findInBackground(new FindCallback<JobOffer>() {
      @Override
      public void done(List<JobOffer> list, ParseException e) {
    
      }
    });
    

为每个位置显示一个标记

现在,我们将遍历从 Parse 收到的职位列表,并使用 addMarker()googleMap 添加标记。执行以下步骤:

  1. findInBackground 执行完毕后,创建一个 ParseGeoPoint 变量和一个循环,该循环将遍历列表中的每个项目。我们将使用 ParseGeoPoint 变量来存储来自 Parse 数据库的坐标:

    ParseGeoPoint geoPoint = new ParseGeoPoint();
    
    for(int i =0;i<list.size();i++){
    
    }
    
  2. 在循环内,从列表中获取 GeoPoint 数据并保存到我们的 ParseGeoPoint 变量中:

     geoPoint = list.get(i).getParseGeoPoint("coordinates");
    
  3. 最后,在每次迭代中使用 addMarker()googleMap 添加标记:

    googleMap.addMarker(new MarkerOptions()
    .position(new LatLng(geoPoint.getLatitude(), geoPoint.getLongitude())));
    

你的 MyMapFragment.java 文件应该与以下内容类似:

public class MyMapFragment extends Fragment{

  private SupportMapFragment mapFragment;
  private GoogleMap googleMap;

  @Nullable
  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.fragment_my_map, container, false);

    FragmentManager fm = getChildFragmentManager();
    mapFragment = (SupportMapFragment) fm.findFragmentById(R.id.map);
    if (mapFragment == null) {
      mapFragment = SupportMapFragment.newInstance();
      fm.beginTransaction().add(R.id.map, mapFragment).commit();
    }

    return view;
  }

  @Override
  public void onResume() {
    super.onResume();

    if (googleMap == null) {
      googleMap = mapFragment.getMap();

      ParseQuery<JobOffer> query = ParseQuery.getQuery("JobOffer");
      query.findInBackground(new FindCallback<JobOffer>() {
        @Override
        public void done(List<JobOffer> list, ParseException e) {

          ParseGeoPoint geoPoint;

          for(int i =0;i<list.size();i++){
            geoPoint = list.get(i).getParseGeoPoint("coordinates");

            googleMap.addMarker(new MarkerOptions()
            .position(new LatLng(geoPoint.getLatitude(), geoPoint.getLongitude())));
          }

        }
      });

    }
  }
}

这些标记现在应该在应用中可见:

为每个位置显示标记

向标记添加标题

地图上标记的实用性不仅仅在于显示一个点,还在于为用户提供一种简单且易于访问的方式来获取此位置的信息。当用户点击标记时,我们将通过显示标题来实现这一点。

这可以通过简单地在我们的 addMarker() 方法中添加 .title(string) 来实现:

googleMap.addMarker(new MarkerOptions()
.position(new LatLng(geoPoint.getLatitude(), geoPoint.getLongitude()))
.title(list.get(i).getTitle()));

现在我们有一个完全功能的地图显示,当用户点击它时,会在标记上方显示标题,如下面的图片所示:

为标记添加标题

概述

在本章中,你学习了如何向我们的应用添加地图。这需要我们在 Google Developer Console 上创建一个项目,并配置我们的应用以访问必要的 API。一旦我们的应用完全配置好,我们就继续将地图添加到我们选择的视图中。我们讨论了在片段中处理一个片段(MapFragment 与我们的 MyMapFragment)。尽管单个 MapFragment 可以通过代码单独添加,但将其放在带有布局的另一个片段中,如果我们需要,可以让我们有可能向页面添加其他 UI 小部件,例如 FloatingActionButton。最后,我们通过显示从 Parse 下载的位置的标记和信息,使地图变得有用。

在下一章中,你将学习如何调试和测试我们的应用程序。

第十一章:在 Android 上进行调试和测试

在本章中,你将学习如何在 Android 中进行调试,这是在开发应用程序时查找和解决问题的必备实践,可以节省大量时间。

我们将学习如何创建自动化测试,用以测试按钮的点击或单个方法的结果。这是一组你可以在 Android Studio 中运行的测试,以确保每次开发新功能时,不会破坏现有的功能。

你还将学习如何使用Robolectric进行单元测试以及使用 Espresso 进行集成测试。

在本章末尾,我们将讨论如何使用 Monkey 对 UI 进行数百万次随机点击测试,如何通过应用记录点击序列,以及如何使用 MonkeyTalk 基于这些记录配置测试。

  • 日志与调试模式

  • 测试

    • 使用 Robolectric 进行单元测试

    • 使用 Espresso 进行集成测试

  • UI 测试

    • 使用 MonkeyRunner 进行随机点击

    • 使用 MonkeyTalk 记录点击

  • 持续集成

日志与调试模式

如果不提及日志以及如何在开发过程中解决问题,这本书就不算完整。如果你知道如何解决自己的问题,那么在 Android 上的开发就不仅仅是复制粘贴 Stack Overflow 的内容。

调试模式和日志是帮助开发者定位问题的机制。随着时间的推移,每个开发者都会进步并减少使用这些技术的频率,但在开始时,应用中充满了日志是很常见的。我们不希望用户在应用发布后能够看到日志,也不希望手动移除日志并在发布新版本时再次添加。我们将探讨如何避免这种情况。

处理日志

Log类用于打印消息和错误,我们可以通过LogCat实时读取。以下是记录消息的一个例子:

Log.i("MyActivity", "Example of an info log");

Log类有五种方法,它们用于对日志设置不同的优先级。这允许我们在LogCat中按优先级进行筛选。在某些情况下,我们会显示不同的日志,例如,查看每次请求下载的工作机会数量。如果我们的应用崩溃了,此时错误类型的日志是我们的优先关注点,我们希望隐藏其他优先级较低的日志,以便尽快找到错误。

五个优先级分别是(从低到高):详细、调试、信息、警告和错误(Log.vLog.dLog.iLog.wLog.e)。

我们可以通过日志窗口顶部的条形栏按进程进行筛选。我们可以按优先级和关键词进行筛选,并且可以创建自定义过滤器,按标签、进程 ID 等进行筛选。

处理日志

如果日志不显示或者它们是旧的且不刷新,尝试打开右侧的下拉菜单,选择 过滤,然后再次选择仅显示选定应用程序。这会强制控制台刷新。

处理日志

为了完成日志,我们将创建一个包装器,并使用第三方库,目的是只需更改一个布尔值,就能在项目中禁用所有日志。为此,我们只需创建一个具有与Log类相同方法的类,这些方法依赖于这个布尔值:

public class MyLogger {

  static final boolean LOG = false;

  public static void i(String tag, String string) {
    if (LOG) android.util.Log.i(tag, string);
  }

  public static void e(String tag, String string) {
    if (LOG) android.util.Log.e(tag, string);
  }
  …

我们每次想要编写日志时都需要使用这个包装器—使用MyLogger.d()而不是Log.d()。这样,如果我们更改MyLogger类中的布尔值LOG,它将同时停止我们项目中的所有日志。

建议使用BuildConfing.DEBUG变量的值:

static final boolean LOG = BuildConfing.DEBUG; 

如果我们的应用处于调试模式,这将返回 true,发布应用时将返回 false。因此,我们不需要记住在发布模式下关闭日志,也没有日志会显示给最终用户的风险。

使用 Timber,日志包装器

Timber 是 Jake Wharton 创建的日志包装器,它将日志提升到了一个高级水平,允许我们使用日志树概念来拥有不同的日志行为。看看以下代码:

compile 'com.jakewharton.timber:timber:3.1.0'

使用 Timber 的一个优点是,我们在同一个活动中不需要多次编写日志标签:

Timber.tag("LifeCycles");
Timber.d("Activity Created");

我们的日志树可以有不同的行为;例如,我可能想在发布模式下禁用日志,但我仍然想处理错误;所以,我会种植一个错误树,它将向 Parse 报告错误:

if (BuildConfig.DEBUG) {
  Timber.plant(new Timber.DebugTree());
} else {
  Timber.plant(new CrashReportingTree());
}

/** A tree which logs important information for crash reporting. */
private static class CrashReportingTree extends Timber.Tree {
  @Override protected void log(int priority, String tag, String message, Throwable t) {
    if (priority == Log.VERBOSE || priority == Log.DEBUG) {
      return;
    }
    //Track error to parse.com
  }
}

调试我们的应用

日志可以在开发过程中用于查找问题,但如果我们掌握了调试模式,会发现这个实践要快得多。

当我们处于调试模式时,可以在代码中设置断点。通过这些断点,我们可以指定一个代码行,我们希望执行在这里停止,以显示那一刻变量的值。要设置断点,只需在左侧边栏上双击:

调试我们的应用

我们在获取职位信息的方法响应中设置了一个调试点。我们可以从顶部栏启动调试模式:

调试我们的应用

如果我们在调试模式下运行应用,当执行到这一点时,Android Studio 将会暂停:

调试我们的应用

Android Studio 将自动提示调试器窗口,我们可以在执行点的位置看到变量。我们可以在前面的截图中看到职位列表,并导航查看每个职位内部的内容。

这里重要的特性是左侧的绿色播放按钮,它会继续执行我们的应用到下一个断点,以及红色方块,它会退出调试模式并继续执行应用。

我们还有不同的控制选项可以跳转到下一行,进入一个方法,或者跳出到方法外。例如,假设我们在以下命令的第一行设置了一个断点:

MasteringAndroidDAO.getInstance().clearDB(getActivity());
MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);

在此情况下,单步跳过(指向下方的蓝色箭头)将把我们的执行移到下一行。如果我们点击单步进入(指向右下角的蓝色箭头),我们将进入 getInstace() 方法。结合这些控制,我们可以实时控制流程。

解释了调试模式后,我们现在可以继续进行自动化测试。

在 Android 上进行测试

任何新功能在完成之前都需要先进行测试。我们作为开发者,曾多次掉入在未先编写通过测试的情况下提交代码更改的陷阱,结果在后续迭代中发现预期的行为被破坏了。

我们通过艰难的方式学到了编写测试可以提升我们的生产力,提高代码质量,并帮助我们更频繁地发布。因此,Android 提供了多种工具,以帮助我们从早期阶段开始测试应用。

在接下来的两个部分,我们将讨论我最喜欢的设置:用 Robolectric 进行单元测试和用 Espresso 进行集成测试。

使用 Robolectric 的单元测试

在 Robolectric 出现之前,编写单元测试意味着我们必须在真实设备或模拟器上运行它们。这个过程可能需要几分钟,因为 Android 构建工具需要打包测试代码,将其推送到连接的设备,然后运行它。

Robolectric 通过使我们能够在工作站的 JVM 中运行单元测试,而无需 Android 设备或模拟器,从而减轻了这个问题。

要使用 Gradle 包含 Robolectric,我们可以在 build.gradle 文件中添加以下依赖:

testCompile "org.robolectric:robolectric:3.0"

我们使用 testCompile 来指定我们希望这个依赖被包含在测试项目中。对于测试项目,默认源目录是 src/test

Robolectric 配置

在撰写本文时,Robolectric 3.0 版本支持以下 Android SDK:

  • Jelly Bean,SDK 版本 16

  • Jelly Bean MR1,SDK 版本 17

  • Jelly Bean MR2,SDK 版本 18

  • KitKat,SDK 版本 19

  • Lollipop,SDK 版本 21

默认情况下,测试将针对 AndroidManifest 文件中定义的 targetSdkVersion 运行。如果你想针对不同的 SDK 版本运行测试,或者如果你的当前 targetSdkVersion 不被 Robolectric 支持,你可以使用位于 src/test/resources/robolectric.properties 的属性文件手动覆盖它,内容如下:

robolectric.properties
sdk=<SDK_VERSION>

我们的第一单元测试

我们将从设置一个非常简单且常见的场景开始:一个带有登录按钮的欢迎活动,该按钮将用户导航到登录活动。欢迎活动的布局如下:

<?xml version="1.0" encoding="UTF-8"?>
<LinearLayout  android:layout_width="match_parent" android:layout_height="match_parent">
  <Button android:id="@+id/login" android:text="Login" android:layout_width="wrap_content" android:layout_height="wrap_content" />
</LinearLayout>

WelcomeActivity 类中,我们只需设置登录按钮以启动登录活动:

public class WelcomeActivity extends Activity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.welcome_activity);

    View button = findViewById(R.id.login);
    button.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View view) {
        startActivity(new Intent(WelcomeActivity.this, LoginActivity.class));
      }
    });
  }
}

为了测试这一点,我们可以确保通过发送正确的 Intent 启动 LoginActivity。因为 Robolectric 是一个单元测试框架,LoginActivity 实际上不会启动,但我们能够检查框架是否捕获了正确的意图。

首先,我们将在src/test/java/路径中正确包内创建测试文件WelcomeActivityTest.java。Robolectric 依赖于 JUnit 4,因此我们将从指定 Robolectric 的 Gradle 测试运行器开始,并添加一些额外的配置,框架将使用这些配置来查找AndroidManifest资源和资产。运行以下命令:

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)

现在,我们可以编写第一个测试。首先创建并将欢迎活动带到前台:

WelcomeActivity activity = Robolectric.setupActivity(WelcomeActivity.class);

既然我们已经有了WelcomeActivity的实例,点击登录按钮就变得简单了:

activity.findViewById(R.id.login).performClick();

最后,我们需要验证框架是否捕获了本应启动LoginActivity的意图:

Intent expectedIntent = new Intent(activity, LoginActivity.class);
assertThat(shadowOf(activity).getNextStartedActivity(), is(equalTo(expectedIntent)));

shadowOf静态方法返回一个ShadowActivity对象,该对象存储了与当前待测活动的大部分交互。我们需要使用@Test注解,这会告诉 JUnit 该方法可以作为测试用例运行。将所有内容整合在一起,我们得到以下测试类(WelcomeActivityTest.java):

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class WelcomeActivityTest {

  @Test
  public void loginPress_startsLoginActivity() {
    WelcomeActivity activity = Robolectric.setupActivity(WelcomeActivity.class);

    activity.findViewById(R.id.login).performClick();

    Intent expectedIntent = new Intent(activity, LoginActivity.class);
    assertThat(shadowOf(activity).getNextStartedActivity(), is(equalTo(expectedIntent)));
  }
}

运行单元测试

在能够运行单元测试之前,我们需要在 Android Studio 中选择正确的测试构件。为此,我们将打开构建变种工具栏并选择单元测试构件,如下截图所示:

运行单元测试

现在,从项目窗口中,我们可以通过右键点击测试类并选择运行选项来运行测试。确保项目路径中没有空格;否则,Robolectric 在执行单元测试之前会抛出异常。

运行单元测试

我们还可以从命令行运行单元测试。为此,使用带有--continue选项的test任务命令:

./gradlew test --continue

如果我们配置了持续集成系统,例如 Jenkins、Travis 或 wercker,这个选项是理想的。

这是 Robolectric 部分的结束。接下来,我们将讨论使用Espresso进行集成测试。

使用 Espresso 进行集成测试

由于 Android 的固有特性和市面上大量的设备,我们无法确定发布应用时它的表现。

我们倾向于尽可能在多个不同设备上手动测试我们的应用,这是一个繁琐的过程,每次发布时都必须重复。在本节中,我们将简要讨论 Espresso 以及如何编写可以在真实设备上运行的测试。

Espresso 配置

在编写我们的第一个集成测试之前,需要安装并配置测试环境。执行以下步骤:

  1. 从 Android SDK 管理器中,我们需要选择并安装Extras文件夹中的Android Support Repository,如下截图所示:Espresso 配置

  2. 创建用于集成测试代码的文件夹;该文件夹应位于app/src/androidTest

  3. 我们还需要在项目的build.gradle中指定一些依赖项。使用以下代码:

    dependencies {
      androidTestCompile 'com.android.support.test:runner:0.3'
      androidTestCompile 'com.android.support.test:rules:0.3'
      androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2'
      androidTestCompile 'com.android.support.test.espresso:espresso-intents:2.2'
    }
    

最近,Android 添加了对 JUnit 4 风格测试案例的支持。为此,我们将在build.gradle文件中将AndroidJUnitRunner添加为默认的测试仪器运行程序:

android {
  defaultConfig {
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
  }
}

编写集成测试

对于这个例子,我们将从 Robolectric 停下的地方继续;我们将为LoginActivity编写一个测试。对于这个活动,我们将设置一个简单的布局,包含两个EditTexts和一个登录按钮。运行以下代码(activity_login.xml):

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

  <EditText
    android:id="@+id/input_username"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:inputType="textEmailAddress" />

  <EditText
    android:id="@+id/input_password"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:inputType="textPassword" />

  <Button
    android:id="@+id/button_signin"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/signin"/>
</LinearLayout>

LoginActivity中,当用户点击登录按钮时,我们将使用以下代码(LoginActivity.java)将凭据发送到闪屏活动:

public class LoginActivity extends Activity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setContentView(R.layout.activity_login);

    final EditText inputUsername = (EditText) findViewById(R.id.input_username);
    final EditText inputPassword = (EditText) findViewById(R.id.input_password);

    Button buttonLogin = (Button) findViewById(R.id.button_signin);

    buttonLogin.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        startActivity(new Intent(getApplicationContext(), SplashActivity.class)
        .putExtra("username", inputUsername.getText().toString())
        .putExtra("password", inputPassword.getText().toString()));
        finish();
      }
    });
  }
}

对于这个测试,我们将在两个输入字段中输入用户凭据,并验证我们是否在意图中正确地捆绑它们。

首先,我们将在src/test/androidTest/路径中正确的包内创建LoginActivityTest.java测试文件。我们将使用 JUnit 4,因此我们将从指定AndroidJUnit4测试运行器开始。使用以下命令:

@RunWith(AndroidJUnit4.class)

与 Robolectric 的另一个区别是,在 Espresso 中,我们需要指定一个规则来准备被测试的活动。为此,使用以下命令:

@Rule
public IntentsTestRule<LoginActivity> mActivityRule =
  new IntentsTestRule<>(LoginActivity.class);

现在,我们可以开始编写测试。首先,我们需要在两个输入字段中输入登录详情:

String expectedUsername = "mastering@android.com";
String expectedPassword = "electric_sheep";

onView(withId(R.id.input_username)).perform(typeText(expectedUsername));
onView(withId(R.id.input_password)).perform(typeText(expectedPassword));

然后,我们将通过点击登录按钮发送意图:

onView(withId(R.id.button_signin)).perform(click());

最后,我们必须验证捕获的意图是否包含登录凭据:

intended(hasExtras(allOf(
  hasEntry(equalTo("username"), equalTo(expectedUsername)),
  hasEntry(equalTo("password"), equalTo(expectedPassword)))));

将所有内容整合在一起,我们将得到以下测试类(LoginActivityTest.java):

@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {

  @Rule
  public IntentsTestRule<LoginActivity> mActivityRule =
  new IntentsTestRule<>(LoginActivity.class);

  @Test
  public void loginButtonPressed_sendsLoginCredentials() {
    String expectedUsername = "mastering@android.com";
    String expectedPassword = "electric_sheep";

    onView(withId(R.id.input_username)).perform(typeText(expectedUsername));
    onView(withId(R.id.input_password)).perform(typeText(expectedPassword));

    onView(withId(R.id.button_signin)).perform(click());

    intended(hasExtras(allOf(
    hasEntry(equalTo("username"), equalTo(expectedUsername)),
    hasEntry(equalTo("password"), equalTo(expectedPassword)))));
  }
}

运行集成测试

与我们使用 Robolectric 所做的类似,要运行集成测试,我们需要在 Android Studio 中切换到正确的测试工件(Test Artifact)。为此,我们将打开构建变体(Build Variants)工具栏并选择Android Instrumentation Tests工件:

运行集成测试

现在,从项目窗口,我们可以通过右键点击测试类并选择运行选项来运行测试。

我们还可以从命令行运行集成测试。为此,我们将调用connectedCheck(或cC)任务:

./gradlew cC

如果我们有连接设备或模拟器的持续集成系统,使用命令行是首选方式。一旦我们编写了足够的集成测试,我们可以轻松地在数百个真实设备上部署和运行它们,使用如Testdroid之类的服务。

运行集成测试

从 UI 的角度进行测试

我们现在将进行的测试与使用应用程序的人可能会进行的测试类似。实际上,在拥有质量保证(QA)的的公司中,人们将这些工具作为手动测试的补充。

UI 测试也可以自动化,但它们与单元测试和集成测试不同;这些是在屏幕上执行的操作,从点击按钮到使用记录的事件完成注册过程。

我们将从使用猴子(The Monkey)进行压力测试开始。

启动猴子

Monkey 是一个可以从命令行通过 ADB 启动的程序。它可以在我们的设备或模拟器中生成随机事件,并且通过使用一个种子,我们可以重现相同的随机事件。为了解释清楚,让我们考虑一个数字的例子。想象一下,我执行了 Monkey,它产生了 1 到 10 的随机数;如果我再次启动它,我会得到不同的数字。当我用种子(这个种子是一个数字)执行 Monkey 时,我得到了一组不同的从 1 到 10 的数字,如果我用相同的种子再次启动它,我会得到相同的数字。这很有用,因为如果我们使用一个种子来生成随机事件并且出现了崩溃,我们可以修复这个崩溃,并再次运行相同的种子,以确保我们解决了问题。

这些随机事件可以从点击和滚动手势到系统级别的事件(如音量增大、音量减小、截图等)不等。我们可以限制事件的数量和类型以及运行它的包。

终端中的基本语法是以下命令:

$ adb shell monkey [options] <event-count>

如果你从未使用过 ADB,你可以在你的系统安装的 Android SDK 目录中的platform-tools文件夹里找到它:

../sdk/platform-tools/adb

当我们打开终端并导航到这个目录时,我们可以写下以下代码行:

adb shell monkey -p com.packtpub.masteringandroidapp -v 500

当你尝试使用adb并且输出是command not found时,你可以重启adb,使用adb kill-serveradb start-server,以及在 Linux 或 Mac 上使用./adb(点斜杠 adb)。

我们可以增加事件的数量到5000或者产生无限事件,但通常建议设置一个数字限制;否则,你将不得不重启设备来停止 Monkey。当你执行命令时,你将能够看到产生的随机事件,并且它将指示使用的种子,以防你想重复相同的事件链:

启动 Monkey

根据应用程序的不同,我们可能需要调整事件之间的时间,使用节流毫秒属性以模拟真实用户。

使用下一个测试工具,我们将进行一种不同类型的 UI 测试,目的是跟随一个流程。例如,如果我们有一个由三个屏幕组成的注册过程,每个屏幕都有不同的表单,并且我们想要记录一个测试,用户填写表单并通过三个屏幕逻辑地继续。在这种情况下,Monkey 实际上并不太有帮助;在大量的事件中,它最终会用随机字符填写所有输入字段,并点击按钮进入下一个屏幕,但这并不是我们真正想要的。

使用 MonkeyTalk 记录 UI 测试

记录一系列测试(如注册过程)的目的是为了保存这些测试,以便在我们对代码进行更改后能够再次运行它。我们可能需要修改注册过程的网络请求,而不改变 UI,所以这些测试非常完美。我们可以在完成修改后运行它们,而无需手动完成注册或填写表单。这里我们并不是在偷懒;如果我们有数百个测试,这对于一个人来说将是大量的工作。此外,通过自动化测试,我们可以确保事件序列始终如一。

MonkeyTalk是一个免费且开源的工具,有两个版本;在我们的示例中,我们将使用社区版。

注意

有关社区版和专业版的比较列表,可以在他们的网站www.cloudmonkeymobile.com/monkeytalk上查看。

MonkeyTalk 可以在真实设备和模拟器上使用。它通过在录制模式下记录一系列事件来工作:

使用 MonkeyTalk 录制 UI 测试

一旦我们通过点击工具中的录制进入录制模式,每个事件都将按执行的操作和使用的参数顺序记录下来。在上面的截图中,我们可以看到点击TextView并在上面输入一些内容是如何被记录为两个事件的。

我们可以在一个脚本文件中创建这个,MonkeyTalk 将会复现它;这样,我们就可以在不录制的情况下创建自己的事件序列。对于前面的事件,我们将编写如下脚本:

Input username tap
Input username enterText us

如果我们点击立即播放按钮,我们将在任何设备上看到所有这些步骤的执行。我们可以在安卓手机上录制脚本,然后在 iOS 设备上播放它们。

除了录制和播放脚本,我们还可以有验证命令。例如,如果我们有一个清除所有输入字段的按钮,我们可以在脚本中使用currentValue添加一个验证命令:

Input username tap
Input username enterText us
Input clearform click
Input currentvalue ""

这将在执行过程中报告验证的结果,这样我们就能检查我们的所有验证是否都正确通过了。例如,点击清除表单的按钮需要一个点击监听器来清除每个输入文本。如果由于某种原因我们进行了修改,元素的 ID 发生了变化,MonkeyTalk 测试会通过一个失败的命令来报告问题。

如果我们有一个工具,可以在我们每次更改应用时为我们运行这些 UI 测试以及单元测试和集成测试,那不是很好吗?这个解决方案是存在的,它被称为持续集成

持续集成

我们无意解释如何构建一个持续集成系统,因为这超出了本书的范围,而且通常设置环境不是安卓开发者的职责。然而,你应该了解它是什么以及它是如何工作的,因为它与安卓直接相关。

一套良好的自动化测试套件总是与 CI(持续集成)解决方案结合使用更为有效。这个解决方案将允许我们在每次代码更改时构建和测试我们的应用程序。

这就是大多数拥有大型项目的公司的工作方式。如果他们有一个开发团队,代码通常会在一个仓库中共享,并且他们会构建一个与仓库相连的 CI 系统。每当开发者对仓库进行更改并提交时,就会执行测试集合,如果结果成功,就会构建一个新的 Android 可执行文件(APK)。

这样做是为了尽量减少问题的风险。在一个大型的应用程序中,需要数年时间由不同的人共同开发,对于新开发者来说,在不破坏或更改任何现有功能的情况下开始进行更改是不可能的。这是因为项目中的所有人并不都了解所有代码的用途,或者代码过于复杂,以至于修改一个组件就会影响其他组件。

注意

如果你对接入这个解决方案感兴趣,我们可以为你推荐Jenkins,它最初被称为 Hudson,更多信息请访问wiki.jenkins-ci.org/display/JENKINS/Meet+Jenkins

持续集成

除了测试和构建我们的应用程序之外,Jenkins 还会生成一个测试覆盖率报告,这将使我们能够了解单元测试和集成测试覆盖了我们代码的百分比。

概要

在本章中,我们开始学习如何在我们的应用程序中以高级方式使用日志,并快速概览了调试过程。我们解释了什么是测试,以及如何分别使用 Robolectric 和 Espresso 创建单元测试和集成测试。

我们还创建了 UI 测试,从使用 The Monkey 进行压力测试开始,然后生成随机事件,后来开始使用 MonkeyTalk 测试,记录可以再次播放以验证输出的事件流程。最后,我们讨论了持续集成,以了解公司是如何为 Android 应用程序将测试和构建系统集成在一起的。

在下一章,也就是本书的最后一章中,我们将探讨如何对我们的应用程序进行货币化,如何使用不同的构建版本构建应用程序,以及混淆代码,使其准备好上传到 App Store。

第十二章:盈利、构建过程和发布

这是本书的最后一章;我们剩下要做的就是使我们的应用盈利,生成其不同版本,并将其发布并上传到 Play 商店。

我们将通过创建不同的构建类型来完成构建过程,生成不带广告的付费版本和带广告的免费版本的应用。所有这些都在同一个项目中,但将作为两个不同的应用导出。

构建过程完成后,我们将开始实施广告并解释关于广告盈利的关键点;这将使我们的应用程序能够产生收入。

最后,我们将发布应用,并使用发布证书对我们的 APK 进行签名,混淆代码以防止被反编译。我们将上传到 Play 商店,并解释在应用发布过程中需要牢记的关键点。

  • 构建变体

  • 盈利

    • 广告盈利的关键点

    • 添加广告

  • 发布

    • 代码混淆和签名

    • 使用 Gradle 导出

  • 上传至 Play 商店

使用构建变体

为了解释如何在 Android 上通过广告实现盈利,我们将在应用中添加广告,但在这一步之前,我们将设置一个构建过程,允许我们导出两个版本:付费版和免费版。这种策略在 Play 商店中很常见(提供一个带广告的免费版本和一个不带广告的付费版本),这样所有用户都能免费使用该应用,但那些不喜欢广告并希望支持你应用的用户可以选择购买付费版本。

实施此策略的另一种方法是只创建一个版本,并在应用内提供购买附加组件以移除广告的选项,通过应用内购买产品来实现。这种方式的缺点是,你的应用在 Play 商店中不会列为免费应用;它将被归类为“提供应用内购买”,因此可能有些用户对此感到不适应,或者家长不允许孩子使用付费应用或包含支付的应用。第二个问题是应用内购买不容易实现;这个过程非常复杂,涉及很多步骤,包括设置服务、在 Play 商店中创建产品、从应用中消费这些产品,以及设置一个测试环境,我们可以在不产生费用的前提下测试购买。

使用构建变体

构建变体是构建类型和产品风味的组合。

如果我们有构建类型AB,以及产品风味12,那么结果将如下构建变体:

A 1
A 2
B 1
B 2

为了更好地理解这一点,我们可以了解构建类型和构建风味的概念以及如何创建它们。

创建构建类型

构建类型允许我们为调试或发布目的配置应用的打包。

让我们先看看我们的 build.gradle 文件:

buildTypes {
  release {
    minifyEnabled false
    proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
  }
}

build.gradle 中,我们可以看到构建类型 release 有两个属性,我们将在本章末尾解释它们。

默认情况下,我们有两个构建类型:debugrelease。即使我们没有看到 debug 构建类型,所有变体也将在 release 和 debug 模式下生成。

我们可以创建更多具有不同参数的构建类型;我们可以使用的参数包括:

  • 签名配置

  • 调试签名标志

  • 更改版本名称或包名后缀

这意味着我们可以拥有不同类型的签名,使用不同的证书,启用或禁用 debug 模式,以及不同的包名。

构建类型并不是用来创建我们应用程序的不同版本,如演示版或完整版,免费或付费等。为此,我们有产品风味。每个构建类型应用于每个构建风味,创建我们之前看到的构建变体。

产品风味

我们将创建两个产品风味,并在 build.gradle 中使用以下代码声明它们:

productFlavors {
  paid {
    applicationId "com.packtpub.masteringandroidapp"
  }
  free {
    applicationId "com.packtpub.masteringandroidapp.free"
  }
}

我们有一个付费风味,即没有广告的应用,还有一种名为 free 的风味,包含广告的免费版本。对于每个产品风味,我们可以在项目的 ../src/ 级别创建一个文件夹。我们的付费版本默认是主要的,所以不需要文件夹。

产品风味

这样,我们可以为每个构建拥有不同的类和资源,甚至一个不同的 AndroidManifest.xml 文件。我们的应用程序将在 main 文件夹中共享付费和免费版本的通用代码,在 free 文件夹中有针对广告的特定代码。

要在不同版本之间切换,我们可以简单地更改构建变体窗口中的下拉菜单,如下面的截图所示:

产品风味

选择一个构建变体后,我们可以运行应用或导出它,相应地运行或导出所选的风味。这些可以配置为具有不同的包名和不同的版本名称。

现在,我们将看看如何向免费版本添加一个特定代码,该代码不会包含在主要付费版本中。

安卓的货币化

我们将描述通过应用程序赚钱的三种常见方式。

首先,我们可以在 Play Store 中为应用程序设定一个价格。在某些情况下,为你的应用收费比提供带有广告或应用内产品的免费应用更有意义。如果你为一小部分用户创造了高价值的应用,你绝对应该考虑这个选项。例如,如果我们发布一个为建筑师专业设计房屋的应用,我们知道我们的应用不会被数百万用户下载;它是针对特定和目标用户群的高质量软件需求。我们无法通过广告获得足够的利润,而我们的用户将愿意为使他们的工作更轻松的软件支付一笔不错的费用。要求用户预先支付费用总是存在风险的;即使用户可以选择退还应用费用,他/她可能也不够吸引人去尝试。这就是为什么我们应该考虑第二种模式的原因。

第二个选项被称为免费增值模式。我们发布一个免费的应用程序,但其中包括应用内购买。以同样的房屋设计应用为例,我们可以提供三种免费设计,以便当用户对我们的产品感到满意时,我们可以要求他/她购买一次性许可或订阅以继续使用该应用。这在游戏中非常常见,你可以为你的角色购买物品。在游戏中,我们可以看到这个模型也可以与第三种模型结合,以获得尽可能最大的收益。

第三种货币化模型是广告模型;我们在应用中放置广告,当用户点击它们时,我们获得收入。我们可以使用不同类型的广告——从全屏广告到底部的小横幅。我们将关注这个模型。其实施比你想象的要容易。但在实施之前,我们需要解释诸如CPC每次点击成本)、CTR点击通过率)、填充率等术语,这将帮助我们选择一个好的广告平台和提供商。这对于理解指标并能够阅读图表以了解应用中的广告表现也是必要的。在不同的位置放置广告可能会改变收入;然而,我们需要在不烦扰用户的情况下最大化收益。如果我们为用户提供以小额费用通过应用内产品或无广告的付费版本来移除广告的选项,我们可以增加广告的数量。如果用户知道他们有选择,这对他们来说是最好的。如果他们选择与广告共存,这是他们的决定,不会像我们没有提供移除广告的选项而放置大量广告那样让他们感到烦恼。

广告收益化的关键点

我们将解释基础知识,以了解广告收益化是如何运作的。商业中有一些带有缩写的概念,一开始可能会让人感到困惑。

一旦我们在广告平台上注册,我们就会看到一个关于我们应用的统计数据报告页面。以下是来自广告网络AdToApp的仪表盘示例:

广告货币化的关键点

在这里,我们可以看到请求、填充率、展示量、点击量、CTR、eCPM 和收入。让我们逐一考虑它们。

请求是指我们的应用向广告网络请求广告的次数。例如,如果我们决定在应用开始时添加一个全屏广告,那么每次启动应用时,都会向服务器发送请求以获取广告。

我们的应用中并没有实际的广告内容;我们拥有的是一个占位符、一个框架以及一个AdView,它将由广告网络提供的内容填充。有时,在请求时刻广告网络可能没有广告给我们,这就是下一个概念重要的原因。

填充率是通过已投放广告数除以请求广告数得出的百分比。例如,如果我们启动应用 10 次,只收到 5 次广告,那么我们的填充率为 50%。在一个好的广告网络中,我们希望填充率为 100%。我们希望展示尽可能多的广告,并且点击单价要高。

CPC,即每次点击成本,是指用户在我们的应用中点击广告时我们获得的收入;这个数值越高,我们获得的收入越多。广告商决定了广告的 CPC。一些广告商可能愿意为每次点击支付更多的费用。

许多低 CPC 的点击不一定比少量高 CPC 的点击更好。这就是为什么我们拥有的广告质量重要的原因。

展示量是指广告向用户展示的次数。在上一个例子中,如果有 10 次广告请求,其中 5 次失败,我们就会有 5 次展示量。如果用户不点击,展示量不会产生收入。

点击量是指用户点击广告的次数。这是基于 CPC 产生收入的方式。因此,5 次点击,每次点击 0.5 美元,将会产生 5x0.5,即 2.5 美元的收入。

CTR,即点击通过率,是通过应用收到的点击量除以展示量得出的百分比。如果我们有 100 次广告展示,并且有 1 次点击,我们的 CTR 为 1%。这个数值通常低于 5%,因为用户不会点击他们看到的每一个广告。如果你通过强制用户点击广告来作弊,比如Admob这样的广告平台可能会取消你的账户和支付。例如,如果我们在应用开始时显示一个对话框,要求用户点击广告才能继续使用我们的应用,这将基本上给我们带来 100%的 CTR;每个展示量都会有一次点击,这是不允许的。在任何情况下,我们都不可以推广点击。

广告提供商希望他们的广告能被对其感兴趣的人看到;他们不希望为那些对广告不感兴趣、一秒钟后就会关闭广告的人的点击付费。可能你有一个高的点击通过率(CTR),因为你在应用中有一个好的广告位置,而且广告对每个用户都有吸引力。如果发生这种情况,你将不得不向你的广告网络解释,或者像Admob这样的广告网络可能会关闭你的账户。但我们也不应该对他们太不公平;他们这样做是因为他们发现很多人试图破坏规则,这样一个庞大的公司无法专注于个人,所以他们需要有客观的筛选机制。

其他广告网络公司在这一点上更加灵活;他们通常会为你分配一个代理人,你可以通过 Skype 或电子邮件频繁联系他,在有任何问题时,他们通常会通知你。

eCPM 代表“每千次展示的有效成本”。它是通过将总收入除以总展示次数(以千为单位)来计算的。这基本上是一种只需查看一个数字就能快速了解你表现如何的方法——非常有助于比较广告网络。这个数字通常在\(0 到 3\)之间。

我们需要考虑到这并不包括填充率。它是每千次展示的成本,而不是每千次请求的成本。一个 3 美元的 eCPM,如果填充率为 50%,与 1.5 美元的 eCPM 和 100%的填充率是相同的。

一个优秀的广告网络应具备高填充率和高的 eCPM。我们需要这两者都高;如果广告点击费用昂贵但填充率不足,将无法产生收入,因为它们根本不会被展示。

AdToApp的团队制作了一张不错的图来解释这一点:

广告货币化的关键点

这张图展示了我们一直在讨论的内容;一个高端广告网络,其 eCPM 非常高而填充率低,被表现为一座高但空荡荡、灯光熄灭的大楼。

我们已经完成了理论学习,可以开始整合广告解决方案;在本例中,我们将选择 AdToApp。

使用 AdToApp 添加广告

没有办法知道哪个广告提供商更适合你;你能做的最好的事情就是尝试不同的提供商,并查看统计数据。

根据经验,我们喜欢使用 AdToApp,除了它良好的投放效果外,它真的很容易集成,即使你已经集成了其他网络,也可以将它包含在你的应用中。因此,衡量其性能真的很简单。

在这本书中使用它搭配MasteringAndroidApp非常理想,因为它允许我们使用不同类型的广告,包括全屏广告、横幅、视频等。

有超过 20 个不同广告网络的调解者,因此包含他们的 SDK,我们将有大量保证高填充率的广告可供选择。至于他们的 eCPM,他们会分析哪个网络能为你带来更好的结果;因此,如果他们可以从多个网络投放广告,他们会选择效果更好的。

通过 AdToApp 添加广告

我们可以开始创建一个账户,地址是adtoapp.com/?r=OZ-kU-W9Q2qeMmdJsaj3Ow

创建账户后,我们将使用我们应用的包名创建一个应用。

通过 AdToApp 添加广告

我们将点击 SDK 按钮下载他们的 SDK,并获取集成所需的配置值。

通过 AdToApp 添加广告

集成非常简单;SDK 将包含一个AdToAppSDK.jar文件,我们需要将其复制到libs目录中。我们还需要在build.gradle中添加 Google Play Services 和v7支持库,但我们已经有这些了。

我们需要在清单中添加基本权限,我们已经有这些了,使用以下代码:

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

然后,我们需要在清单中添加额外的必要资产,这可以复制自同一网站;它包含我们账户的密钥。你可以在以下截图所示的第一部分找到它们:

通过 AdToApp 添加广告

最后,我们可以看看如何实现插屏广告和横幅广告或激励广告。激励广告是游戏中弹出的广告,提示观看此视频并获得(金币、宝石等)。是否观看这些广告完全取决于用户是否想要奖励。

通过 AdToApp 添加广告

如果我们选择插屏广告和横幅广告,我们需要根据是需要仅视频广告、仅图片(横幅)广告,还是插屏中同时包含图片和视频广告来初始化它们。

在网站上,根据你想要的广告类型,将展示必要的代码。

SDK 非常灵活;我们可以更进一步设置回调,以了解横幅广告何时加载和点击。这允许我们跟踪广告的点击次数,并验证它们与 AdToApp 控制台中的数据一致,使整个过程透明。

如果我们需要额外的帮助,可以在 SDK 中激活日志,它会在出现任何问题时通知我们。

现在请记住我们在本节开始时提到的良好实践,即在不过多打扰用户的前提下最大化广告数量,并在你的应用中实施这些方法,开始获得收益!

通过 AdToApp 添加广告

将我们的应用发布到 Play 商店。

最后,我们的应用准备好了!这是开发新应用时最美好的时刻;是时候将其上传到 Play 商店,获取用户的反馈,并希望获得成千上万的下载量。

我们需要将应用导出为 APK 文件;为了上传到 Play 商店,它必须使用发布证书进行签名。这一点非常重要;一旦应用程序用证书签名,如果我们将其上传到 Play 商店,并在将来想要上传新版本,就必须使用同一证书进行签名。

我们在发布过程中会创建这个证书。它需要一个别名和密码,因此请确保你记住这些细节并将证书文件保存在安全的地方。否则,假设你的应用得到了好的评分和大量的下载,当你想要更新版本时,如果没有证书或忘记了密码,就无法进行更新。在这种情况下,你将不得不上传一个具有不同包名的全新应用,并且它的下载量和评分将从零开始。

代码混淆

在发布应用时需要考虑的另一个重要事项是代码混淆。如果我们导出应用而不混淆代码,任何人都可以下载 APK 并反编译它,使他们能够看到你的代码,如果你在其中包含了 Parse IDs、服务器访问细节、GCM 项目编号等,这可能会成为一个安全问题。

我们可以使用Proguard混淆代码。Proguard 是 Android 构建系统中包含的一个工具。它混淆、缩小和优化代码,移除未使用的代码,并重命名类、字段和方法,以防止逆向工程。

注意类和方法的重命名,它可能会影响你的崩溃和错误报告,因为堆栈追踪将会被混淆。然而,这不是问题,因为我们可以在发布应用时保存一个映射文件,用它可以重新追踪,这将允许我们将崩溃和报告转换成可读的、未被混淆的代码。

要激活 Proguard,我们需要在buildTypes中将minifyEnabled属性设置为true。你可以执行以下代码来实现这一点:

buildTypes {
  release {
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
  }
}

在我们的项目中,有一个proguard-rules.pro文件,我们可以在其中添加混淆时要考虑的规则。例如,如果我们混淆一些第三方库,它们可能无法正常工作,而这些库没有混淆的风险,因为它们不是我们创建的;我们只是将它们添加到我们的项目中。

代码混淆

为了防止第三方库被混淆,我们可以添加-keep规则以及-dontwarn规则来忽略警告。例如,我们添加了calligraphy以使用自定义字体;这样我们可以在混淆过程中忽略它:

# DONT OBFUSCATE EXTERNAL LIBRARIES

# CALLIGRAPHY
-dontwarn uk.co.chrisjenx.calligraphy.**
-keep class uk.co.chrisjenx.calligraphy.** {*;}
# TIMBER
-dontwarn timber.log.**
-keep class timber.log.** {*;}

使用keep和包名,我们将保留该包内所有的类。

我们将在调试模式下添加 Proguard,故意创建一个崩溃,以查看混淆后的堆栈追踪的样子:

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.view.View.setVisibility(int)' on a null object reference
            at com.packtpub.masteringandroidapp.SplashActivity.onCreate(Unknown Source)

我们可以复制这个stracktrace到文本文件中,并前往app/build/outputs/mapping/product_flavor_name/ release_or_debug/mapping.txt获取我们的mapping.txt文件。

请考虑我们在<sdk_root>/tools/proguard中执行以下代码的 retrace 命令:

retrace.sh [-verbose] mapping.txt [<stacktrace_file>]

在这种情况下,我们将有正确的行号出现崩溃,如下所示:

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.view.View.setVisibility(int)' on a null object reference
at com.packtpub.masteringandroidapp.SplashActivity.onCreate(SplashActivity.java:21)
at android.app.Activity.performCreate(Activity.java:6289)

请记住,在每次发布应用时保存mapping.txt文件的副本;每次发布时这个文件都会被覆盖,因此在每次发布时保存文件非常重要。或者,如果你有一个仓库,并且为每次发布标记提交,你可以回退并重新生成相同的发布,理论上这将具有相同的映射文件。

既然我们的应用已经针对逆向工程进行了保护,我们可以继续发布流程。

导出应用

当我们导出应用程序时,我们实际上是在发布模式下创建一个 APK 文件,并用证书对其进行签名。这个证书证明了 Play Store 中的应用程序属于我们,有了它,我们可以上传我们之前解释的同一个应用程序。这次我们将导出应用并创建一个证书。

要导出我们的应用程序,有两种方法:一种是在 Android Studio 中使用 Gradle 和终端,第二种是使用 Android Studio 中的向导。我们将了解这两种方法,但首先使用第二种方法创建证书。

导航到构建 | 生成签名的 APK;你会看到一个类似的对话框:

导出应用

如果我们之前导出过这个应用并为其创建了证书,那么我们只需选择一个路径并输入别名和密码,这将使用现有证书导出一个新版本的应用。

对我们来说,这是第一次导出MasteringAndroidApp,所以我们将点击创建新的...。在下一个屏幕上,我们需要选择保存证书的路径,这是一个.keystore文件。

我们还需要为keystore和证书中的别名设置密码。对于有效期为 100 年的日期来说是可以的;如果你的应用比你活得更长,那就不是你的问题了!最后,在这里至少需要一个字段填写个人信息:

导出应用

最后,它会询问我们想要导出哪个版本的应用,并创建.apk文件,同时指出文件的路径。

这种方法很直接,但还有一种使用命令行和 Gradle 自动导出应用的方法;如果我们想用 Jenkins 构建应用程序,这非常有用。

为此,我们需要在build.gradle中添加一个签名配置,以便在自动生成应用程序时,它知道要使用哪个keystore以及哪个别名和密码。以下代码将有助于实现这一点:

signingConfigs {
  release {
    storeFile file("certificate.keystore")
    storePassword "android"
    keyAlias "android"
    keyPassword "android"
  }
}

不用说,这可能导致安全问题;密码写在 build.gradle 中,证书文件包含在我们的项目中。如果我们这样做,需要确保项目安全。如果这是一个关注点,你可以使用以下代码在运行时读取密码和别名:

storePassword new String(System.console().readPassword("\n\$ Enter keystore password: "))
keyAlias System.console().readLine("\n\$ Enter key alias: ")
keyPassword new String(System.console().readPassword("\n\$ Enter key password: "))

当我们运行生成签名 APK 的命令时,它会要求我们输入密码别名和别名密码。我们可以使用以下代码行来完成这个操作:

>./gradlew assembleRelease

导出应用

当我们的应用导出后,可以继续最后一步:上传到 Play 商店。

将我们的应用上传到 Play 商店

要发布一个应用,我们需要一个 Google 开发者账户。如果你没有,可以从 play.google.com/apps/publish/ 获取一个。

创建发布者账户

创建发布者账户的第一步是输入基本信息,并阅读并接受开发者分销协议。第二步是支付 25 美元的开发许可费用以创建账户。这是我们发布应用所需支付的全部费用,只需一次性支付——一次付费,终身许可。考虑到在 iOS 上,费用是每年 99 美元,我们不应该抱怨。

最后第三步需要开发者姓名,它将显示在我们应用程序名称下方。以下是 Google Inc 的一个示例:

创建发布者账户

我们还需要电子邮件、手机号码以及我们的网站(可选)。根据 Google 的说法,这是为了在有人需要就发布的内容联系我们时使用。

Google Play 开发者控制台

当我们打开发布者账户时,如果我们还没有发布任何应用,我们将看到开发者控制台四个主要功能,如下面的图片所示:

Google Play 开发者控制台

第一个选项是发布一个 Android 应用,这是我们将在书中遵循的选项。然而,在此之前,我们将快速描述其他需要记住的选项。

第二个选项是关于 Google Play 游戏服务。如果你开发了一个游戏,希望玩家保存并提交他们的得分,并有一个得分排行榜,你需要一个服务器来存储这些分数并检索它们,甚至可能需要玩家用户名和登录。游戏服务为我们完成这些工作。

它提供了一个 API,跨游戏共享,与用户的 Google 账户关联,我们可以管理排行榜和成就。它甚至提供了 API 和基础设施来实现多人游戏,包括实时多人和回合制游戏。

左侧底部的第三个选项是关于分享开发者控制台的。我们可能希望允许其他开发者更新应用。例如,在公司中,这将有助于那些负责设置应用名称、描述、图片以及整体市场营销的人员,以及其他负责应用上传和开发的人员。我们可以配置对控制台和特定应用的访问权限。

Google Play 开发者控制台

第四个也是最后一个选项是商家账户;如果我们想要销售付费应用或应用内产品,就需要这个。这是来自付费应用的商家账户示例;我们可以看到完成的支付和取消的支付。如果用户购买了我们的应用,他/她在两小时内可以申请退款,如果他/她不喜欢这个应用。

Google Play 开发者控制台

因为我们还没有发布应用,所以我们看到了一个空的开发者控制台和四个主要选项;如果我们有已发布的应用,我们就会看到这个样子。在这种情况下,发布按钮在顶部:

Google Play 开发者控制台

在初始屏幕上,我们可以看到不同的应用,无论它们是免费的还是付费的,活跃安装数量和总安装数量。活跃安装意味着目前拥有该应用并且下载后没有卸载的人。总安装数量意味着应用被安装的所有次数的总计。

我们还可以看到评分和崩溃次数。如果我们点击应用并进入详细视图,我们还可以查看更多详细信息,例如用户的评论和错误崩溃报告。

发布应用

继续上传过程,当我们点击+ 添加新应用时,系统会要求我们输入名称和默认语言。在此之后,我们可以选择通过上传 APK 或准备商店列表来开始流程。

发布应用

这两个是不同的过程:一个是上传 APK 文件,另一个是设置应用的标题、描述、图片、是否付费或免费等——所有在 Play 商店中显示的不同选项。

让我们从上传 APK 文件和不同的测试组开始。

上传 APK 文件

请记住,当我们上传 APK 时,我们应用的包名必须在 Play 商店中是唯一的;如果我们想要更新之前由我们发布的某个应用,并且使用签名的初始下载的证书与签名新 APK 的证书相同,那么我们可以上传具有现有包名的 APK。

当我们点击上传 APK时,首先注意到的就是带有名称的三个不同标签:生产环境测试版Alpha 版

上传 APK 文件

我们可以在两个测试组中以及生产环境中发布我们的应用。生产环境意味着它在 Play 商店中发布;它是公开的,每个人都可以看到。一段时间以来,开发人员控制台只有这个选项,直到他们添加了分阶段推出的功能。

分阶段推出使我们能够将应用发布给一组有限的用户。为了选择这些用户,我们有多种选项;我们可以通过电子邮件邀请这些用户,分享链接,或者创建一个 Google 群组或 G+ 社区,邀请用户加入该群组并在其中分享应用的链接。只有这些用户才会在 Play 商店中看到应用。这有助于在应用面向全球发布之前,从部分用户那里获取反馈,当然,还可以防止生产环境中的应用出现错误和负面评价。我们还可以选择在生产环境中发布我们应用的用户百分比;例如,如果我们有百万用户,可以先向 10%的用户发布,然后再进行双重检查,确保一切正常,再进行大规模发布。

我们的应用可以在不同的阶段有不同的版本;例如,我们可以发布版本 1.0.0,1.0.1 处于 beta 测试阶段,1.0.2 处于 alpha 测试阶段。我们可以从 alpha 向 beta 推出 APK,从 beta 向生产环境推出,但我们不能回滚。

我们现在要解释的概念非常重要。一旦我们发布了应用的一个版本,我们就不能回到之前的发布版本。可能会发生这样的情况:我们在 Play 商店中有一个应用的工作版本,我们开发了一个新版本,在我们的设备上运行良好,我们认为它已经可以上传了。现在是周五下午,我们不想进行测试,因为我们认为,“哦,我确信它没问题。我只是做了两行小改动,不会影响任何东西”。我们上传了版本 1.0.4。几小时后,我们开始收到来自 Play 商店的崩溃报告。这是恐慌的时刻;我们现在能做的唯一事情是撤销当前应用的发布,以防止更多损失,并尽快开始修复。然而,如果修复不容易,最明智的做法是再次生成最后一个已知的工作版本(1.0.3),将版本号和代码增加到 1.0.5,并将其上传到 Play 商店。

然而,情况可能会更糟;如果我们有一个数据库,并且其结构从 1.0.3 变化到 1.0.4,如果我们的代码还没有准备好接受从 1.0.4 到作为 1.0.5 重新命名的 1.0.3 的数据库降级,我们将知道我们整个周末都要工作,结果在周一早上被解雇。总之,我们的观点是,预防胜于治疗;因此,使用分阶段推出,在发布之前进行所有必要的测试,并且避免在周五下午发布,以防万一。

准备商店列表

对于开发者来说,准备商店列表可能是最无聊的部分,但为了发布应用,这是必须要做的;有一些我们不能跳过的必填资产和字段。

首先,我们需要为我们的应用起一个标题,一个最多 80 个字符的简短描述和一个最多 4000 个字符的长描述。标题将是我们搜索应用时首先看到的内容;简短描述例如在平板电脑浏览应用时可见。这是我们的应用的elevator pitch(电梯游说),我们需要在这里的主函数中描述它:

准备应用商店列表

长描述将在我们查看应用详情视图时显示。为了在更多搜索中出现并获得可见性,在描述中识别并添加与应用相关的关键词是很有帮助的。使用不相关关键词吸引用户下载是被 Google 禁止的,如果你这样做,你将在开发者控制台收到警告,并且你的应用在重新获得批准和发布之前需要做出一些更改。

在这一点上,我们有权选择国际化我们应用列表,重复这些前面提到的三个字段,以我们想要的任何语言,它们将根据用户的语言自动显示在不同的语言中。

下一步是开发图形,我们需要在这里拍摄截图。截图可以通过设备上的按键组合轻松完成;例如,在三星 Galaxy 3 上,这是通过同时按下音量下菜单键完成的。也可以从 Android Studio 中通过选择 Android 视图中的相机图标来拍摄。

准备应用商店列表

除了截图,我们还需要一个 512 x 512 高分辨率的图标;这必须与我们在上传版本中为应用使用的图标相同或非常相似,否则它会发出警告。因此,始终创建 512 x 512 的图标,然后将其缩小以在我们的应用中使用是很好的做法。反其道而行之将导致图像质量差地放大。以下是图标显示的一个示例:

准备应用商店列表

我们需要的最后一张图片是功能图。这是一张 1024 x 500 的图片,展示了我们应用的功能。如果我们的应用在 Google Play 上被精选,这张图片将被展示。它将在 Play 商店应用中显示;如果我们有促销视频,当视频不播放时,功能图仍然会显示。

准备应用商店列表

我们需要继续进行分类;根据我们的应用是游戏还是应用程序,我们需要选择不同的类别。如果你不确定选择哪个类别,可以在 Play 商店查看与你应用相似的应用。

之后,我们需要选择内容评级;从 2015 年 5 月开始,每个应用程序都需要有新的评级系统。根据谷歌的说法,这个新的内容评级提供了一种简单的方法,向用户传达熟悉且与本地相关的内容评级,并通过针对您内容的目标受众来帮助提高应用程序的参与度,具体内容请参阅support.google.com/googleplay/android-developer/answer/188189

我们的联系方式会自动填写,所以我们需要做的最后一件事是接受隐私政策,然后我们可以点击定价与分发

准备商店列表

在这里,我们可以决定应用程序是免费还是付费;这一步无法撤销。如果应用程序是付费的,我们可以设置一个价格,谷歌会将它转换成不同国家的不同货币;尽管如此,我们可以为每个国家设置不同的价格。我们可以选择加入不同的开发者群体;例如,如果我们开发了一个儿童应用程序,我们可以将其包含在为家庭设计中。这将增加我们在儿童专区被突出显示的机会,并关联到与儿童应用程序相关的第三方网络。

在这一部分,我们还可以选择希望发布应用程序的国家。这也可以用作应用程序首次发布时的分阶段发布策略。

准备商店列表

完成以上所有步骤后,我们可以通过点击右上角的发布来发布我们的应用程序。

准备商店列表

如果按钮不可用,您可以点击为什么我不能发布?,它将在左侧列出要求。应用程序发布后,可能需要几个小时才能在 Play 商店中显示。确定应用程序是否已发布的最简单方法是使用包名在 URL 中导航到我们的应用程序。在我们的例子中,URL 将是play.google.com/store/apps/details?id=com.packtpub.masteringandroidapp

就这样!我们从初学者到更高级别完成了本书的学习,具备了上传设计良好、构建完善、向下兼容并能实现盈利的应用程序所需的知识。

我们祝愿您的应用程序取得成功,并希望您能打造出下一个《愤怒的小鸟》或下一个 WhatsApp!

注意

非常感谢您购买并读完这本书。对于建议、改进或任何反馈,请毫不犹豫地通过<Antonio@suitapps.com>联系我,或者在 Twitter 上关注我@AntPachon

总结

在本书的最后一章中,我们开始学习如何创建应用程序的不同版本,将构建类型与产品风味结合以获得构建变体。

之后,我们学习了如何对我们的应用程序进行货币化,添加了不同类型的广告,并解释了广告货币化的关键要点。

我们还从 Android Studio 和使用 Gradle 命令行导出了应用程序,进行了混淆并使用发布证书进行了签名。

最后,我们在 Play 商店上传并发布了我们的应用程序。

posted @ 2024-05-22 15:14  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报