Eclipse-ADT-教程-全-
Eclipse ADT 教程(全)
原文:
zh.annas-archive.org/md5/D0CC09ADB24DCE3B2F724DF3004C1363
译者:飞龙
前言
欢迎阅读《Android 设计模式与最佳实践》,这是一本全面介绍如何利用经过验证的编程哲学、设计模式使你的应用程序发挥最大价值的指南。这些模式为解决开发者面临的许多开发问题提供了逻辑清晰且优雅的方法。这些模式作为指南,为从问题到解决方案的清晰路径提供了指导,尽管应用设计模式本身并不能保证最佳实践,但它将极大地促进这个过程,并使发现设计缺陷变得更加容易。设计模式可以在许多平台上实施,并使用多种编程语言编写。一些代码库甚至将模式作为其内部机制的一部分,许多读者可能已经熟悉 Java 的 Observer 和 Observable 类。我们将要探讨的 Android SDK 大量使用了多种模式,如工厂、生成器和监听器(实际上只是观察者模式)。尽管我们会涵盖这些内置的设计模式,但本书主要探讨我们如何构建自己的、定制的模式,并将它们应用于 Android 开发中。本书不是依次介绍每个设计模式,而是从开发者的视角出发,通过应用程序开发的各个方面探索在构建 Android 应用程序过程中可能出现的个别设计模式。为了明确这一旅程,我们将专注于一个单一的虚构应用程序,旨在支持小企业。这将带领我们从应用程序的构思到发布,在此过程中涵盖 UI 设计、内部逻辑和用户交互等主题。在每一个步骤中,我们都会探索与该过程相关的设计模式,首先探索模式的抽象形式,然后将其应用于特定情况。通过本书的学习,你将了解如何将设计模式应用于 Android 开发的各个方面,以及使用它们如何有助于最佳实践。设计模式的概念比任何特定的模式本身都更为重要。模式可以也应该根据我们的具体目的进行调整,通过这种方式学习应用程序开发,我们甚至可以继续创建完全原创的模式。
本书涵盖的内容
第一章,设计模式,介绍了开发环境,以及两种常见的设计模式:工厂模式和抽象工厂模式。
第二章,创建型模式,涵盖了材料与界面设计,探索了设计支持库和生成器设计模式。
第三章,材料设计模式,介绍了 Android 用户界面以及一些最重要的材料设计组件,如应用栏和滑动导航抽屉。这将介绍菜单和操作图标以及如何实现它们,以及如何使用抽屉监听器来检测用户活动。
第四章,布局模式,从前一章开始,进一步深入探讨 Android 布局设计模式以及如何使用重力和权重来创建在各种设备上工作的布局。这将引导我们了解 Android 如何处理设备方向、屏幕大小和形状差异。介绍了策略模式并进行了演示。
第五章,结构型模式,深入探讨了设计库,并创建了一个由协调布局管理的布局,其中包含一个回收视图。这需要探索适配器设计模式,首先内部版本,然后我们自己构建一个,以及桥梁模式、外观模式和过滤模式。
第六章,激活型模式,展示了如何将模式直接应用于我们的应用。我们涵盖了更多设计库功能,如可折叠工具栏、滚动和分隔线。我们创建了一个自定义对话框,由用户活动触发。我们重新审视了工厂模式,并展示如何使用构建器模式来填充 UI。
第七章,组合模式,介绍并演示了两种新的结构型模式:原型和装饰器,涵盖它们的灵活性。然后我们将其付诸实践,使用这些模式控制由不同的复合按钮(如开关和单选按钮组)组成的 UI。
第八章,组合型模式,专注于组合模式以及它可以在许多情况下如何使用和如何选择正确的情况。然后我们继续在实践演示中使用它来填充嵌套的 UI。这导致了持久数据的存储和检索,使用内部存储、应用文件,最终以共享偏好设置的形式存储用户设置。
第九章,观察型模式,探讨了从一个活动过渡到另一个活动时涉及的视觉过程,以及这如何能远超出简单的装饰。读者将学习如何应用过渡和共享元素,以有效地使用移动设备的有限屏幕空间,并简化应用程序的使用和操作。
第十章,行为型模式,专注于主要的行为模式,如模板、策略、访问者和状态。它为每个模式提供了工作演示,并介绍了它们的灵活性和使用方法。
第十一章,可穿戴模式,展示了 Android Wear、TV 和 Auto 的工作原理,演示如何逐个设置和配置。我们检查这些与标准手持应用之间的区别。
第十二章,社交模式,展示了如何添加网络功能和社交媒体。首先探索 WebView 以及如何用它创建内部网页应用。接下来,探讨如何将我们的应用连接到 Facebook,展示这是如何完成的以及我们可以用它做什么。章节最后检查其他社交平台,如 Twitter。
第十三章,发布模式,涵盖了安卓应用的部署、发布和盈利。引导读者完成注册和发布过程,我们查看广告选项以及哪些最适合哪种用途。最后,我们通过一些部署的技巧和诀窍来探讨如何最大化潜在用户。
阅读本书所需的材料
Android Studio 和 SDK 都是开源的,可以从一个单独的包中安装。除了一处小例外,这在相关章节中有详细说明,这是本书所需的全部软件。
本书的目标读者
本书面向具有一定基础安卓开发经验的安卓开发者。要充分理解本书内容,必须具备基本的 Java 编程知识。
约定
在本书中,你会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式将如下显示:"向你的布局中添加三个 TextView,然后在 MainActivity 的onCreate()
方法中添加代码。"
代码块设置如下:
Sequence prime = (Sequence) SequenceCache.getSequence("1");
primeText.setText(new StringBuilder()
.append(getString(R.string.prime_text))
.append(prime.getResult())
.toString());
当我们希望引起你注意代码块中的特定部分时,相关的行或项目会以粗体显示:
@Override
public String getDescription() {
return filling.getDescription() + " Double portion";
}
任何命令行输入或输出都会如下编写:
/gradlew clean:
新术语和重要词汇会以粗体显示。你在屏幕上看到的词,例如菜单或对话框中的,会像这样出现在文本中:"在你的手机上启用开发者选项。在某些型号上,这可能需要导航至设置 | 关于手机"
注意事项
警告或重要提示会以这样的框显示。
提示
技巧和诀窍会像这样显示。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要给我们发送一般反馈,只需发送电子邮件到 feedback@packtpub.com,并在邮件的主题中提及书名。如果您在某个主题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请查看我们的作者指南www.packtpub.com/authors。
客户支持
既然您已经拥有了 Packt 的一本书,我们有一些事情可以帮助您充分利用您的购买。
下载示例代码
您可以从www.packtpub.com
的账户下载本书的示例代码文件。如果您在别处购买了这本书,可以访问www.packtpub.com/support
注册,我们会直接将文件通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载和勘误。
-
在搜索框中输入书名。
-
选择您要下载代码文件的书。
-
从下拉菜单中选择您购买本书的地方。
-
点击代码下载。
下载文件后,请确保您使用最新版本的以下软件解压或提取文件夹:
-
WinRAR / 7-Zip 用于 Windows
-
Zipeg / iZip / UnRarX 用于 Mac
-
7-Zip / PeaZip 用于 Linux
本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Android-Design-Patterns-and-Best-Practice
。我们还有其他丰富的书籍和视频代码包,可以在github.com/PacktPublishing/
查看。请查看!
下载本书的彩色图片
我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。彩色图片将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/AndroidDesignPatternsandBestPractice.pdf
下载此文件。
勘误
尽管我们已经竭尽全力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现了一个错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做,您可以避免其他读者感到沮丧,并帮助我们改进该书的后续版本。如果您发现任何勘误信息,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来进行报告。一旦您的勘误信息得到验证,您的提交将被接受,勘误信息将被上传到我们的网站或添加到该标题勘误部分下现有的勘误列表中。
要查看之前提交的勘误信息,请前往www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将显示在勘误部分下。
盗版
网络上对版权材料的盗版行为是所有媒体持续面临的问题。在 Packt,我们非常重视对我们版权和许可的保护。如果您在互联网上以任何形式遇到我们作品的非法副本,请立即提供其位置地址或网站名称,以便我们可以寻求补救措施。
如果您发现疑似盗版材料,请通过 copyright@packtpub.com 联系我们,并提供链接。
我们感谢您帮助保护我们的作者和我们提供有价值内容的能力。
问题
如果您对这本书的任何方面有问题,可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章:设计模式
设计模式长期以来被认为是解决常见软件设计问题最可靠和最有用的方法之一。模式为经常出现的发展问题提供了一般性和可重用的解决方案,例如如何在不对对象结构进行修改的情况下添加功能,或者如何最佳地构建复杂对象。
应用模式有几个优点,不仅仅是这种方法帮助开发者遵循最佳实践,以及它如何简化大型项目的管理。这些好处是通过提供可以重复使用的整体软件结构(模式)来实现的,以解决类似问题。这并不意味着代码可以从一个项目简单地剪切和粘贴到另一个项目,而是这些概念本身可以在许多不同情况下反复使用。
应用编程模式有许多其他好处,本书的某个部分将会涵盖这些内容,但以下一两个好处是现在值得一提的:
-
模式为团队工作的开发者之间提供了一种高效的通用语言。当一个开发者将一个结构描述为例如适配器或外观时,其他开发者能够理解其含义,并会立即识别代码的结构和目的。
-
模式提供的额外抽象层使得对已经处于开发阶段的代码进行修改和调整变得更加容易。甚至还有专门为这些情况设计的模式。
-
模式可以在许多尺度上应用,从项目的整体架构结构到最基本对象的制造。
-
应用模式可以大大减少所需的内联注释和一般文档的数量,因为模式本身也充当了自身的描述。仅类的名称或接口就能解释其目的和在模式中的位置。
安卓开发平台非常适合采用模式,因为不仅应用程序主要是用 Java 创建的,而且 SDK 包含许多自身使用模式的 API,例如用于创建对象的工厂接口和用于构建对象的建造者。像单例这样的简单模式甚至可以作为模板类类型使用。在本书中,我们不仅将看到如何构建自己的大型模式,还将了解如何利用这些内置结构来促进最佳实践并简化编码。
在本章中,我们首先简要概述整本书的布局、我们将使用的模式、我们接近它们的顺序,以及我们将构建的演示应用,看看如何在现实世界中应用模式。接下来,我们将快速检查 SDK 以及哪些组件将最好地协助我们的旅程,尤其是支持库所扮演的角色,使我们能够同时为多个平台版本开发。没有比实际经验更好的学习方式,因此本章的剩余部分将用于开发一个非常简单的演示应用,并使用我们的第一个模式——工厂模式及其相关的抽象工厂模式。
在本章中,你将学习以下内容:
-
模式如何分类以及本书涵盖哪些模式
-
本书演示应用的目的
-
如何定位平台版本
-
支持库的作用是什么
-
工厂模式是什么以及如何构建一个工厂模式
-
如何遵循 UML 类图
-
如何在实机和虚拟设备上测试应用
-
如何在运行时监控应用
-
如何使用简单的调试工具来测试代码
-
抽象工厂模式是什么以及如何使用它
本书如何运作
本书的目的是展示设计模式的运用如何直接协助开发 Android 应用。在本书的进程中,我们将专注于开发一个完整的客户端移动应用,特别关注在 Android 开发过程中何时、为何以及如何使用这些模式。
历史上,对于什么构成模式存在一定争议。然而,在 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 于 1994 年出版的《设计模式》一书中提出的 23 种模式,被称为四人帮的模式,被广泛认为是权威集合,并为我们在软件工程中可能遇到的几乎所有问题提供解决方案,因此这些模式将成为本书的核心。这些模式可以分为三类:
-
创建型 - 用于创建对象
-
结构型 - 用于组织对象群组
-
行为型 - 用于对象之间的通信
本书的实践性质意味着我们不会按照这里出现的顺序来处理这些类别;相反,我们将在开发应用时自然地探索各个模式,这通常意味着首先创建一个结构。
将所有设计模式集成到一个应用程序中是困难、笨拙且不现实的,因此我们将尝试应用尽可能多的看起来现实的模式。对于那些我们决定不直接使用的模式,我们至少会探讨我们可能如何使用它们,并且在每种情况下至少提供一个实际的使用示例。
模式并非刻在石头上,也不能解决所有可能的问题。在本书的末尾,我们将探讨一旦掌握了这个主题,我们如何可以创建自己的模式或调整现有的模式以适应那些既定模式不适用的情况。
简而言之,模式并非一套规则,而是一系列从已知问题通往经过验证的解决方案的熟悉路径。如果你在路上发现了一条捷径,那么尽可以采用它。如果你坚持这样做,那么你就创造了自己的一种模式,这种模式与我们在这里将要介绍的传统模式一样有效。
书的前几章主要关注 UI 设计,并介绍了一些基本的设计模式及其概念上的工作原理。从大约第六章《激活模式》开始,我们将开始将这些和其他模式应用于现实世界的例子,特别是针对一个应用程序。最后几章集中在开发的最后阶段,例如,调整应用程序以适应不同的设备,这项任务几乎是专为设计模式而设的,旨在达到最广泛的市场,以及如何使我们的应用程序盈利。
注意事项
如果你刚接触 Android 开发,前两三章中的说明会讲解得非常详细。如果你已经熟悉 Android 开发,你将能够跳过这些部分,专注于模式本身。
在深入我们第一个模式之前,仔细看看在本书过程中将要构建的应用程序,以及它所带来的挑战和机遇是有意义的。
我们将要构建的内容
如前所述,在本书的过程中,我们将构建一个虽小但完整的 Android 应用程序。现在了解一下我们将要构建的内容及其原因会是一个好主意。
我们将设身处地地考虑一个独立 Android 开发者的角色,这位开发者被一个潜在客户接近,这个客户经营着一家小企业,制作并配送新鲜三明治到当地的几栋办公楼。我们的客户面临几个问题,他们认为可以通过一个移动应用程序来解决。为了了解应用程序可能提供的解决方案,我们将把情况分为三个部分:场景、问题和解决方案。
场景概述
客户运营着一家小而成功的业务,为附近的上班族制作并送递新鲜的三明治,让他们可以在办公桌上购买并食用。三明治非常美味,由于口碑宣传,越来越受欢迎。业务有很大的扩展机会,但商业模式中存在一些明显的低效问题,客户认为可以通过使用移动应用程序来解决。
问题所在
客户几乎无法预测需求。很多时候某种三明治做多了,导致浪费。同样,也有准备三明治品种不足的时候,导致销售额的损失。不仅如此,顾客提供的口碑宣传也限制了业务扩展到较小的地理区域。客户没有可靠的方法来判断是否值得投资更多员工、摩托车以扩大送餐范围,甚至是否在其他镇区开设新的厨房。
解决方案
一款面向所有客户免费的移动应用程序不仅解决了这些问题,还提供了一系列全新的机会。不仅仅是应用程序能够解决无法预料的需求问题;我们现在有机会将这个业务提升到一个全新的层次。为何只向顾客提供固定菜单呢?我们可以提供让他们从一系列食材中构建自己的个性化三明治的机会。也许他们喜欢我们客户已经制作好的芝士和腌菜三明治,但想要加一两片苹果,或者更喜欢芒果酱而不是腌菜。也许他们是素食主义者,更喜欢从选择中过滤掉肉类产品。也许他们有过敏症。所有这些需求都可以通过一个设计良好的移动应用程序来满足。
此外,口碑宣传的地理限制,甚至当地广告如广告牌或当地报纸上的通知,都无法指示业务在更大舞台上的可能成功程度。而另一方面,社交媒体的使用不仅可以让我们客户清晰地了解当前趋势,还能将信息传播给尽可能广泛的受众。
我们的客户现在不仅能够准确判断他们业务的范围,还能添加完全新的、与现代数字生活特性相关的功能,比如应用程序的游戏化。竞赛、谜题和挑战可以为吸引顾客提供全新的维度,并呈现一种强大的增加收入和市场影响力的技术手段。
面前的任务现在更加清晰,我们现在可以开始编码了。我们将从工厂模式的简单演示开始,一路上看一些在开发过程中会用到的一些 SDK 功能。
定位平台版本
为了跟上最新技术,Android 平台的新版本会频繁发布。作为开发者,这意味着我们可以将最新的功能和发展融入到我们的应用程序中。显然,这样做的缺点是只有最新的设备才能运行这个平台,而这些设备在整个市场上只占很小的一部分。来看看开发者仪表板上的这张图表:
仪表板可以在developer.android.com/about/dashboards/index.html找到,其中包含了这个以及其他最新的信息,这些信息在项目初步规划时非常有用。
如你所见,绝大多数的 Android 设备仍然运行在较旧的平台上。幸运的是,Android 允许我们针对这些旧设备进行开发,同时还能融入最新平台版本的功能。这主要是通过使用支持库和设置最低 SDK 级别来实现的。
决定要针对哪些平台进行开发是我们需要做出的第一个决定之一,尽管我们可以在以后的日期更改这一点,但尽早决定要融入哪些功能以及了解这些功能在旧设备上的表现,可以大大简化整个任务。
要了解如何做到这一点,请启动一个新的 Android Studio 项目,随意为其命名,选择手机和平板电脑作为形态因素,并选择API 16作为最低 SDK。
在模板列表中,选择空活动,其他保持默认设置。
Android Studio 会自动选择可用的最高 SDK 版本作为目标级别。要查看如何应用,请从项目面板中打开build.gradle (Module: app)
文件,并注意defaultConfig
部分,它将类似于以下代码:
defaultConfig {
applicationId "com.example.kyle.factoryexample"
minSdkVersion 16
targetSdkVersion 25
versionCode 1
versionName "1.0"
}
这确保了我们的项目将针对这个 API 级别范围正确编译,但如果我们正在构建一个打算发布的 app,那么我们需要告诉 Google Play 商店哪些设备上可以提供我们的 app。这可以通过build.gradle
模块文件来完成,如下所示:
minSdkVersion 21
targetSdkVersion 24
我们还需要编辑AndroidManifest.xml
文件。对于这个例子,我们将在manifest
节点中添加以下uses-sdk
元素:
<uses-sdk
android:minSdkVersion="16"
android:targetSdkVersion="25" />
一旦我们确定了我们希望针对的平台范围,我们就可以继续了解支持库如何让我们在许多最旧的设备上融入许多最新的功能。
支持库
在构建向后兼容的应用程序方面,支持库无疑是我们的最强大工具。实际上,它是一系列单独的代码库,通过提供标准 API 中找到的类和接口的替代品来工作。
大约有 12 个单独的库,它们不仅提供兼容性;它们还包括常见的 UI 组件,如滑动抽屉和浮动操作按钮,否则这些组件必须从头开始构建。它们还可以简化针对不同屏幕大小和形状的开发过程,以及添加一个或两个杂项功能。
注意
由于我们是在 Android Studio 中进行开发,因此应该下载支持仓库而不是支持库,因为该仓库是专门为 Studio 设计的,提供的功能完全相同,而且效率更高。
在本章中我们正在工作的示例中,将不使用任何支持库。项目包含的唯一支持库是v7 appcompat library
,它在我们开始项目时自动添加。在书中,我们将经常回到支持库,所以现在,我们可以集中精力应用我们的第一个模式。
工厂模式
工厂模式是最常用的创建型模式之一。顾名思义,它制造东西,或者更准确地说,它创建对象。它的有用之处在于它使用一个通用接口将逻辑与使用分离。了解这一机制的最佳方式就是现在就构建一个。打开我们在前一页或两页之前开始的项目,或者开始一个新项目。对于这个练习来说,最低和目标 SDK 级别并不重要。
提示
选择 API 级别为 21 或更高,允许 Android Studio 使用一种称为热交换的技术。这避免了每次运行项目时都完全重新构建项目,极大地加快了应用测试的速度。即使你打算最终针对一个更低的平台,热交换节省的时间也使得在应用开发得差不多时降低这个目标是非常值得的。
我们将要构建一个非常简单的示例应用,该应用生成对象来表示我们三明治制作应用可能提供的不同类型的面包。为了强调这个模式,我们会保持简单,让我们的对象返回的仅是一个字符串:
-
在项目视图中找到
MainActivity.java
文件。 -
右键点击它,并创建一个名为
Bread
的接口类型的New | Java Class
: -
完成接口如下:
public interface Bread { String name(); String calories(); }
-
创建
Bread
的具体类,如下所示:public class Baguette implements Bread { @Override public String name() { return "Baguette"; } @Override public String calories() { return " : 65 kcal"; } } public class Roll implements Bread { @Override public String name() { return "Roll"; } @Override public String calories() { return " : 75 kcal"; } } public class Brioche implements Bread { @Override public String name() { return "Brioche"; } @Override public String calories() { return " : 85 kcal"; } }
-
接下来,创建一个名为
BreadFactory
的新类,如下所示:public class BreadFactory { public Bread getBread(String breadType) { if (breadType == "BRI") { return new Brioche(); } else if (breadType == "BAG") { return new Baguette(); } else if (breadType == "ROL") { return new Roll(); } return null; } }
UML 图表
理解设计模式的关键在于理解它们的结构以及各组成部分之间的相互关系。查看模式的一个最佳方式是图形化,统一建模语言(UML)类图是完成这一任务的好方法。
考虑一下我们刚才创建的模式以图表的形式表达,如下所示:
拥有了我们的模式,需要做的就是看到它的实际效果。在这个演示中,我们将使用模板为我们生成的布局中的 TextView 和每次主活动启动时都会调用的 onCreate()
方法:
-
以 文本 模式打开
activity_main.xml
文件。 -
为文本视图添加一个
id
,如下所示:<TextView android:id="@+id/text_view" android:layout_width="match_parent" android:layout_height="wrap_content" />
-
打开
MainActivity.java
文件,并编辑onCreate()
方法以匹配以下代码:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView textView = (TextView) findViewById(R.id.text_view); BreadFactory breadFactory = new BreadFactory(); Bread bread = breadFactory.getBread("BAG"); textView.setText(new StringBuilder() .append(bread.name()) .toString()); }
提示
根据您设置 Android Studio 的方式,您可能需要导入 TextView 控件:
import android.widget.TextView;
。通常,编辑器会提示您,只需按 Alt + Enter 就可以导入控件。
您现在可以在模拟器或真实设备上测试这个模式:
初看之下,这或许会让人觉得是实现一个简单目标的一种极其冗长的方式,但模式的魅力正在于此。增加的抽象层使我们能够修改类,而无需编辑我们的活动,反之亦然。随着我们开发更复杂的对象,遇到需要不仅仅一个工厂的情况时,这种实用性会更加明显。
我们在这里创建的例子过于简单,实际上并不需要测试,但现在是一个探索如何在真实和虚拟设备上测试 Android 应用,以及如何监控性能和使用调试工具测试输出而不必添加不必要的屏幕组件的好时机。
运行和测试应用
现在市面上有大量的 Android 设备,它们有着各种各样的形状和大小。作为开发者,我们希望我们的应用程序能在尽可能多的设备和形态因素上运行,并且我们希望用最少的编码就能实现这一点。幸运的是,Android 平台非常适合应对这一挑战,它让我们可以轻松调整布局,构建虚拟设备以匹配我们所能想象到的任何形态因素。
提示
Google 在 firebase.google.com/docs/test-lab/ 提供了一个非常便捷的基于云的应用测试工具。
显然,虚拟设备是任何测试环境的重要组成部分,但这并不是说直接插入我们自己的设备并在此上进行应用测试就不方便。这不仅比任何模拟器都快,而且正如我们现在将要看到的,设置起来非常简单。
连接到真实设备
实际设备不仅比虚拟设备快,还允许我们在真实世界的情况中测试我们的应用。
将真实设备连接到我们的开发环境需要两个步骤:
-
在您的手机上启用开发者选项。在某些型号上,这可能涉及到导航到
设置 | 关于手机
并点击Build number
七次,之后会在设置中添加开发者选项
。使用它来启用 USB 调试 并选择 允许模拟位置。 -
你现在很可能能够通过 USB 或 WiFi 插件电缆将你的设备连接到工作站,并在打开 Android Studio 时显示出来。如果不是这样,你可能需要打开 SDK 管理器,并从“工具”选项卡安装Google USB 驱动程序。在某些罕见的情况下,你可能需要从设备制造商处下载 USB 驱动程序。
实际设备对于快速测试应用程序功能更改非常有用,但要开发应用程序在各种屏幕形状和尺寸上的外观和行为,意味着我们将创建一些虚拟设备。
连接到虚拟设备
Android 虚拟设备(AVD)允许开发者自由地实验各种硬件配置的模拟,但它们速度慢,可能会耗尽许多计算机系统的资源,并且缺少实际设备中的许多功能。尽管有这些缺点,虚拟设备仍然是 Android 开发者工具箱中不可或缺的一部分,通过考虑一些事项,可以最小化许多这些障碍:
-
将你的虚拟设备精简到只包含你的应用程序所需的功能。例如,如果你的应用不需要拍照,就从模拟器中移除摄像头功能;以后可以根据需要添加。
-
将 AVD 的内存和存储需求降到最低。当应用程序需要时,可以轻松创建另一个设备。
-
只有在需要测试特定新功能时,才创建具有非常新的 API 级别的 AVD。
-
从测试低屏幕分辨率和密度的虚拟设备开始。这些设备运行速度更快,并且仍然允许你测试不同的屏幕尺寸和宽高比。
-
尝试将资源需求非常大的功能分离出来,单独测试。例如,如果你的应用使用了大量高清晰度的图片集,你可以通过单独测试这个功能来节省时间。
通常构建针对特定目的的虚拟设备比构建一个全能型的设备来测试我们所有的应用程序要快,而且现在有越来越多的第三方 Android 模拟器可用,如Android-x86和Genymotion,它们通常速度更快且拥有更多开发功能。
值得注意的是,当仅测试布局时,Android Studio 提供了一些强大的预览选项,允许我们在众多形态、SDK 级别和主题上查看我们潜在的用户界面,如下一个图像所示:
现在,创建一个基本的 AVD 来运行并测试当前项目。实际上并没有什么需要测试的,但我们将了解如何监控应用程序在运行时的行为,以及如何使用调试监控服务来测试输出,而无需使用设备屏幕,这不是一个吸引人的调试项目的方式。
监控设备
下面的演示在模拟设备或真实设备上同样有效,所以选择对你来说最简单的一个。如果你在创建 AVD,那么它不需要大屏幕或高密度屏幕,也不需要大量内存:
-
打开我们刚才工作的项目。
-
从
工具 | 安卓
菜单中,启用ADB 集成。 -
从同一菜单中,选择Android 设备监控器,尽管它可能已经在运行。
-
现在,在连接的设备上使用 Android Monitor 运行应用程序。
设备监控器在多种方式上非常有用:
-
监控器标签可以在运行时使用,以查看实时的系统信息,例如我们的应用使用了多少内存或 CPU 时间。当我们想要查看应用不在前台运行时使用了哪些资源时,这尤其有帮助。
-
监控器可以设置为收集各种数据,如方法跟踪和资源使用,并将这些数据存储为文件,可以在捕获窗格中查看(通常可以从左侧边栏打开)。
-
捕获应用运行时的屏幕截图和视频非常简单。
-
LogCat是一个特别有用的工具,它不仅可以实时报告应用的行为,而且如我们接下来将看到的,还可以生成用户定义的输出。
使用文本视图测试我们的工厂模式是一种方便但笨拙的方法,但一旦我们开始开发复杂的布局,它很快就会变得非常不方便。一种更优雅的解决方案是使用可以在不影响我们 UI 的情况下查看的调试工具。本练习的其余部分将演示如何做到这一点:
-
打开
MainActivity.java
文件。 -
声明以下常量:
private static final String DEBUG_TAG = "tag";
-
再次,你可能需要确认导入
android.util.Log;
。 -
替换
onCreate()
方法中设置文本视图文本的行,使用以下行:Log.d(DEBUG_TAG, bread);
-
再次打开设备监控器。这可以通过按Alt + 6来完成。
-
在监控器右上角的下拉菜单中,选择编辑过滤器配置。
-
完成如图所示的对话框:
运行应用并测试我们的工厂演示应该在 logcat 监控器中产生类似于这里的输出:
05-24 13:25:52.484 17896-17896/? D/tag: Brioche
05-24 13:36:31.214 17896-17896/? D/tag: Baguette
05-24 13:42:45.180 17896-17896/? D/tag: Roll
提示
当然,如果你愿意,你仍然可以使用System.out.println()
,它将在 ADB 监控器中打印出来,但你将不得不在其他输出中搜索它。
我们已经了解了如何在真实和虚拟设备上测试应用,以及如何使用调试和监控工具在运行时对应用进行询问。现在,我们可以进入一个更真实的情况,涉及不止一个工厂,输出的结果也远比一个双词字符串复杂。
抽象工厂模式
制作三明治时,面包只是我们第一个也是最基础的原料;显然,我们需要某种填充物。在编程术语中,这可能意味着像Bread
一样简单地构建另一个接口,但将其称为Filling
,并为它提供自己的关联工厂。同样,我们可以创建一个名为Ingredient
的全局接口,并将Bread
和Filling
作为它的示例。无论哪种方式,我们都需要在其他地方进行大量的重新编码。
设计模式范式提供了抽象工厂模式,这可能是解决这一困境最灵活的解决方案。抽象工厂仅仅就是创建其他工厂的工厂。这种所需的额外抽象层次,在我们考虑到主活动中的顶层控制代码几乎不需要修改(如果有的话)时得到了充分的回报。能够修改低级结构而不影响之前的结构,正是应用设计模式的主要原因之一,当应用于复杂架构时,这种灵活性可以节省许多开发时间,并比其他方法提供更多的实验空间。
使用一个以上的工厂工作
下一个项目与上一个项目之间的相似性非常明显,应该是这样;模式最好的事情之一是我们可以重用结构。你可以编辑之前的示例或从头开始。在这里,我们将开始一个新项目;希望这将有助于使模式本身更加清晰。
抽象工厂的工作方式与我们的上一个示例略有不同。在这里,我们的活动使用了一个工厂生成器,该生成器进而使用一个抽象工厂类来处理决定调用哪个实际工厂的任务,从而创建哪个具体类。
与之前一样,我们不关心输入和输出的实际机制,而是专注于模式的结构。在继续之前,启动一个新的 Android Studio 项目。可以随意命名,将最低 API 级别设为你喜欢的低水平,并使用空白活动模板:
-
我们开始,就像之前一样,创建接口;但这次,我们需要两个:一个用于面包,一个用于填充物。它们应该如下所示:
public interface Bread { String name(); String calories(); } public interface Filling { String name(); String calories(); }
-
与之前一样,创建这些接口的具体示例。为了节省空间,这里我们只创建每种两个。它们几乎都是相同的,所以这里只有一个:
public class Baguette implements Bread { @Override public String name() { return "Baguette"; } @Override public String calories() { return " : 65 kcal"; } }
-
创建另一个名为
Brioche
的Bread
类和两种填充物,分别叫做Cheese
和Tomato
。 -
接下来,创建一个可以调用每种类型工厂的类:
public abstract class AbstractFactory { abstract Bread getBread(String bread); abstract Filling getFilling(String filling); }
-
现在,创建工厂本身。首先,
BreadFactory
:public class BreadFactory extends AbstractFactory { @Override Bread getBread(String bread) { if (bread == null) { return null; } if (bread == "BAG") { return new Baguette(); } else if (bread == "BRI") { return new Brioche(); } return null; } @Override Filling getFilling(String filling) { return null; } }
-
然后,
FillingFactory
:public class FillingFactory extends AbstractFactory { @Override Filling getFilling(String filling) { if (filling == null) { return null; } if (filling == "CHE") { return new Cheese(); } else if (filling == "TOM") { return new Tomato(); } return null; } @Override Bread getBread(String bread) { return null; } }
-
最后,添加工厂生成器类本身:
public class FactoryGenerator { public static AbstractFactory getFactory(String factory) { if (factory == null) { return null; } if (factory == "BRE") { return new BreadFactory(); } else if (factory == "FIL") { return new FillingFactory(); } return null; } }
-
我们可以像之前一样测试我们的代码,使用一个调试标签,如下所示:
AbstractFactory fillingFactory = FactoryGenerator.getFactory("FIL"); Filling filling = fillingFactory.getFilling("CHE"); Log.d(DEBUG_TAG, filling.name()+" : "+filling.calories()); AbstractFactory breadFactory = FactoryGenerator.getFactory("BRE"); Bread bread = breadFactory.getBread("BRI"); Log.d(DEBUG_TAG, bread.name()+" : "+bread.calories());
测试时,这应该在 Android 监视器中产生以下输出:
com.example.kyle.abstractfactory D/tag: Cheese : : 155 kcal
com.example.kyle.abstractfactory D/tag: Brioche : : 85 kcal
到本书结束时,每个成分都将是一个复杂的对象,拥有相关的图像和描述性文本、价格、卡路里价值等等。这时遵循模式将真正带来好处,但像这里一个非常简单的例子就能很好地展示出如何使用抽象工厂这样的创建型模式,让我们在不影响客户端代码或部署的情况下对产品进行修改。
与之前一样,通过视觉表示可以增强我们对模式的了解:
假设我们想在菜单中包含软饮料。这些既不是面包也不是填充物,我们需要引入一种全新的对象类型。添加这种模式的方案已经制定好了。我们需要一个新的接口,它与其他接口相同,只是叫做Drink
;它将使用相同的name()和 calories()
方法,具体的类如IcedTea
可以按照上面的完全相同的线路实现,例如:
public class IcedTeaimplements Drink {
@Override
public String name() {
return "Iced tea";
}
@Override
public String calories() {
return " : 110 kcal";
}
}
我们需要扩展我们的抽象工厂,如下所示:
abstract Drink getDrink(String drinkType);
当然,我们还需要实现一个DrinkFactory
类,但这个类的结构与其他工厂相同。
换句话说,我们可以添加、删除、更改,以及随意摆弄项目的细节,而无需真正关心这些更改是如何被我们软件的高级逻辑所感知的。
工厂模式是所有模式中使用最频繁的模式之一。它可以在许多情况下使用,也应该被使用。然而,像所有模式一样,如果不仔细考虑,它可能会被过度使用或使用不足。当我们考虑项目的整体架构时,正如我们将会看到的,还有许多其他模式可供我们使用。
总结
考虑到这是一个介绍性的章节,我们已经涵盖了很多内容。我们已经构建了两种最著名和最有用的设计模式的示例,并希望了解它们为什么对我们有用。
我们首先探讨了模式是什么,以及为什么在 Android 环境中可能会使用它们。这得益于我们查看了一下可用的开发工具,以及我们如何以及为什么应该针对特定的平台版本和形态因素进行定位。
然后,我们将这一知识应用于创建两个非常简单的应用程序,这些程序使用了基本的工厂模式,并看到了如何测试并从运行在任何设备上的应用程序中检索数据,无论是真实的还是虚拟的。
这让我们有机会看看其他模式,并考虑在构建一个完全工作的应用程序时使用哪些模式。我们将在下一章更详细地介绍这一点,其中将介绍构建器模式以及如何生成 Android 布局。
第二章:创建型模式
在上一章中,我们了解了工厂模式及其相关的抽象工厂模式。然而,我们以相当普遍的方式查看了这些模式,并没有查看一旦创建后,这些对象如何在 Android 设备上被表示和操作。换句话说,我们构建的模式可以应用于许多其他软件环境,为了使它们更具 Android 特色,我们需要查看 Android UI 及其组成方式。
在本章中,我们将集中讨论如何将我们的产品表现为 Android UI 组件。我们将使用卡片视图来展示这些内容,每个卡片将包含一个标题、一幅图像、一些描述性文本以及成分的热量值,如下面的截图所示:
这将引导我们初步了解材料设计,这是一种强大的、越来越受欢迎的视觉设计语言,用于创建清晰直观的 UI。最初为移动设备的小屏幕而设计,材料设计现在被认为是一个非常有价值的 UI 范例,其应用已经从 Android 设备扩展到网站,甚至其他移动平台。
材料设计不仅仅是一种时尚,它提供了一系列遵循最佳 UI 构建实践的非常有效的指南。材料设计提供了与我们已经讨论过的编程模式相似的视觉模式。这些模式提供了清晰、简单的操作结构。材料设计涵盖了比例、缩放、排版和间距等概念,所有这些都可以在 IDE 中轻松管理,并由材料设计指南整齐地规定。
当我们了解了如何将我们的成分表现为可操作的 UI 组件后,我们将查看另一种常用的创建型模式,即构建器模式。这将展示一种允许我们从单个配料对象构建一个三明治对象的模式。
在本章中,你将学习如何进行以下操作:
-
编辑材料样式和主题
-
应用调色板
-
自定义文本设置
-
管理屏幕密度
-
包含卡片视图支持库
-
理解 Z 轴深度和阴影
-
将材料设计应用于卡片视图
-
创建构建器模式
尽管可以在任何时候进行更改,但在构建 Android 应用时,我们首先应该考虑的就是配色方案。这是框架允许我们自定义许多熟悉屏幕组件的颜色和外观的方式,例如标题和状态栏背景颜色以及文本和突出显示的阴影。
应用主题
作为开发者,我们希望我们的应用程序能够从众多应用中脱颖而出,但同时我们也希望融入 Android 用户熟悉的全部功能。实现这一点的方法之一是在整个应用程序中应用特定的颜色方案。这最简单的方法是定制或创建 Android 主题。
自从 API 级别 21(Android 5.0)起,材质主题已成为 Android 设备的默认主题。然而,它不仅仅是一个新的外观。材质主题还默认提供了我们与材质设计相关的触摸反馈和过渡动画。与所有 Android 主题一样,材质主题也是基于 Android 样式的。
Android 样式是一组定义特定屏幕组件外观的图形属性。样式允许我们从字体大小、背景颜色、内边距和高度等方面进行定义,还有更多。Android 主题实际上就是应用于整个活动或应用程序的样式。样式被定义为 XML 文件,并存储在 Android Studio 项目的资源(res
)目录中。
幸运的是,Android Studio 带有一个图形化的主题编辑器,它为我们生成 XML。不过,了解幕后发生的情况总是好的,这最好通过打开上一章的抽象工厂项目或开始一个新项目来查看。从项目浏览器中,打开res/values/styles.xml
文件。它将包含以下样式定义:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
在这里,虽然只定义了三种颜色,但我们也可以定义更多,例如主要和次要文本颜色、窗口背景颜色等。颜色本身在colors.xml
文件中定义,该文件也位于values
目录中,并将包含以下定义:
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
完全可以应用多个主题,并融入我们喜欢的多种样式,但通常来说,在整个应用程序中应用单一主题,并定制其中一个默认的材质主题是最简单、最整洁的方法。
定制默认主题的最简单方式是使用主题编辑器,可以从工具 | Android
菜单中打开。编辑器提供了一个强大的所见即所得预览窗格,使我们能够在我们进行更改时即时查看任何更改,如下所示:
尽管我们可以为我们的主题自由选择任何喜欢的颜色,但材质设计指南对于如何一起使用颜色非常明确。这最好通过查看材质调色板来解释。
定制颜色和文本
应用主题时我们首先需要考虑的是颜色和文本。材质设计指南建议从预定义的一系列调色板中选择这些颜色。
使用调色板
在材料主题中,我们可以编辑的最重要的两种颜色是主色。它们直接应用于状态栏和应用程序栏,使应用具有独特的观感,而不会影响整个平台的统一感。这两种颜色都应该从同一个色板中选择。有许多这样的色板可供选择,整个系列可以在 www.google.com/design/spec/style/color.html#color-color-palette 找到。
无论你决定使用哪个色板作为你的主色,谷歌建议你使用值为500和700的阴影。
这不需要严格执行,但通常最好遵循这些值,并且总是选择同一颜色的两种阴影。
提示
在这里,主题编辑器会非常有帮助;它的实色块不仅提供了提示工具,告诉我们阴影值,而且一旦我们选定了主色,它还会推荐一个合适的深色版本。
选择强调色时,需要考虑我们选择的主要色调。这将应用于开关和高亮显示,并且需要与主色形成良好对比。除了选择看起来不错且具有浅色值100的颜色之外,没有简单的规则来决定哪些颜色之间形成对比。
提示
可以使用navigationBarColor
改变屏幕底部的导航栏颜色,但不建议这样做,因为导航栏不应被视为应用的一部分。
对于大多数目的,其他主题设置可以保持原样。但是,如果你希望更改文本颜色,有一两件事需要注意。
自定义文本
材料文本不是通过使用更浅的色调来生成浅色阴影,而是通过使用 alpha 通道来创建不同级别的透明度。这样做的原因是,当它被用在不同的背景色或图像上时,看起来更加悦目。文本透明度的规则如下:
关于样式和主题,我们可以做很多事情,但现在只需创建一个简单的配色方案,并知道它将在整个应用程序中一致应用就足够了。我们下一个任务将是探讨如何将我们之前考虑的“三明治成分”对象扩展成一个用户友好的界面。毫无疑问,吸引用户的一个最佳方式就是使用诱人的照片。
添加图像资源
安卓提供的最具挑战性的问题之一是我们要适应的众多屏幕密度和尺寸。在显示位图图像时,这一点尤为正确,这里有两个需要解决的竞争性问题:
-
低分辨率图像在拉伸以适应大屏幕或高分辨率屏幕时显示效果非常差。
-
高质量图像在较小、低密度的屏幕上显示时,所使用的内存远大于所需。
屏幕尺寸先放一边,通过使用密度独立像素(dp)基本上解决了不同屏幕密度的问题。
管理屏幕密度
dp 是一个基于 160 dpi 屏幕显示的抽象测量单位。这意味着无论屏幕密度如何,宽度为 320 dp 的组件始终为 2 英寸宽。当涉及到屏幕的实际物理尺寸时,这可以通过各种布局类型、库和属性(如权重和重力)来管理,但现在我们将了解如何提供适合尽可能广泛的屏幕密度的图像。
安卓系统用以下限定符划分屏幕密度:
-
低密度(
ldpi
)- 120 dpi -
中等密度(
mdpi
)- 160 dpi -
高密度(
hdpi
)- 240 dpi -
超高密度(
xhdpi
)- 320 dpi -
超超超高密度(
xxhdpi
)- 480 dpi -
超超超高密度(
xxxhdpi
)- 640 dpi
注意事项
在应用安装期间,每个设备只会下载与其规格相匹配的图像。这节省了旧设备的内存,同时为有能力的设备提供了尽可能丰富的视觉体验。
从开发者的角度来看,我们可能需要为每个项目生成六种不同版本的图像。幸运的是,通常情况下并非如此。在大多数手持设备上,640 dpi 图像与 320 dpi 图像之间的差别几乎无法察觉。考虑到我们三明治制作应用的大多数用户只想浏览食材菜单,而不是仔细检查图像质量,我们可以只安全地提供中等、高和超高密度设备的图像。
提示
在考虑高端设备图像质量时,一个很好的经验法则是将我们的图像尺寸与设备原生相机产生的尺寸进行比较。提供更大的图像不太可能足以改善用户体验,从而证明需要额外的内存是合理的。
在本例中,我们希望提供适合卡片视图的图像,该视图在纵向模式下将占据屏幕宽度的绝大部分。现在,找一个大约 2,000 像素宽的图像。在下面的例子中,它被称为sandwich.png
,尺寸为 1,920×1,080 像素。你的图像不必与这些尺寸匹配,但稍后我们会看到,选择合适的图像比例是良好 UI 实践的重要组成部分。
在超高密度设备上显示 320 dpi 时,宽度为 1,920 像素的图像将显示为六英寸宽。现在至少假设我们的应用将来自移动设备,而不是计算机或电视,所以在高密度 10 英寸的平板电脑上,六英寸对我们来说已经足够了。接下来,我们还将了解如何为其他屏幕密度做准备。
使用指定资源
通过分配特定资源目录来满足不同屏幕密度的需求是很容易实现的。在 Android Studio 中,我们可以通过以下步骤从项目资源管理器中创建这些目录:
-
接下来,创建两个同级的目录,分别命名为
drawable-hdpi
和drawable-xhdpi
。 -
通过从项目资源管理器中选择
drawable
上下文菜单中的在资源管理器中显示直接打开这些新文件夹。 -
将
sandwich.png
图片添加到drawable-xhdpi
文件夹中。 -
制作这张图片的两个副本,并按 3:4 和 1:2 的比例缩放它们。
-
将这些副本分别放置在
drawable-hdpi
和drawable-mdpi
目录中。
这些变化现在可以在项目资源管理器中看到,如下所示:
这样一来,我们可以确保只有最适合设备原生屏幕密度的图像资源会被下载。
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="img/sandwich" />
首先,从res
文件夹中创建一个新 | 目录
,并将其命名为drawable-mdpi
。
要查看效果,请在项目的activity_main.xml
文件中添加以下图像视图:
这种方法的优点是,一旦我们正确指定了图片资源,就可以简单地通过引用@drawable/sandwich
来忽略它实际存储的目录。
卡片视图是 Material Design 中最容易识别的组件之一,它设计用来以统一的方式展示多个相关的片段内容。这种内容通常包括图形、文本、操作按钮和图标等。卡片是展示像三明治配料和相关价格或热量信息这类选择的好方法。
创建一个卡片视图(CardView)。
输出可以在任何模拟器或真实设备上的预览屏幕上查看:
了解卡片视图属性。
如果您的最低目标 SDK 是 21 或更高,那么卡片视图小部件将作为标准包含。否则,您需要包含卡片视图支持库。这可以在build.gradle
文件中通过添加以下高亮行轻松完成:
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
testCompile 'junit:junit:4.12'
compile 'com.android.support:appcompat-v7:23.4.0'
compile 'com.android.support:cardview-v7:23.4.0'
}
正如支持库的名字所暗示,我们只能支持回溯到 API 级别 7 的卡片视图。
不必手动编辑build.gradle
文件,尽管了解如何操作是有用的,可以通过文件 | 项目结构...
菜单选择以下所示的项目来完成:
提示
一些开发者使用+
符号来版本化他们的支持库,如:com.android.support:cardview-v7:23.+
。这是为了预测未来的库。这通常运作得很好,但这并不能保证这些应用在未来不会崩溃。在开发过程中使用编译的 SDK 版本,然后在应用发布后定期更新,虽然更耗时,但更明智。
在我们能够将卡片视图添加到我们的布局之前,你需要重新构建项目。首先,我们需要设置一些卡片的属性。打开res/values/dimens.xml
文件,并添加以下三个新的尺寸资源:
<dimen name="card_height">200dp</dimen>
<dimen name="card_corner_radius">4dp</dimen>
<dimen name="card_elevation">2dp</dimen>
现在,我们可以在主 XML 活动文件中将卡片作为小部件添加,如下所示:
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="@dimen/card_height"
android:layout_gravity="center"
card_view:cardCornerRadius="@dimen/card_corner_radius"
card_view:cardElevation="@dimen/card_elevation">
</android.support.v7.widget.CardView>
阴影的使用不仅仅是为了给界面提供三维外观;它还通过图形化地展示布局层次结构,让用户清楚地知道哪些功能可用。
提示
如果你花时间检查过卡片视图属性,你会注意到translationZ
属性。这看起来与elevation
有相同的效果。然而,elevation
将设置卡片的绝对高度,而translationZ
是一个相对设置,它的值将会加到或从当前高度中减去。
现在我们已经设置好了卡片视图,可以根据材料设计指南填充它,以表示我们的三明治成分。
应用 CardView 的度量标准
设计指南对字体、内边距和缩放等问题非常明确。一旦我们开始使用 CoordinatorLayout,这些设置中的许多将会自动设置,但现在,了解这些度量标准是如何应用的还是一个好主意。
关于卡片有许多不同的模式,它们的完整描述可以在这里找到:
我们将在这里创建的卡片将包含一个图片、三个文本项和一个动作按钮。卡片可以被看作是容器对象,通常它们包含自己的根布局。这可以直接放置在卡片视图内,但如果我们把卡片内容作为独立的 XML 布局创建,代码将更具可读性和灵活性。
下一个练习中,我们将至少需要一张图片。根据材料设计,照片应该是清晰、明亮、简单,并呈现单一、明确的主题。例如,如果我们想将咖啡添加到菜单中,左边的图片将是最合适的:
卡片图片的宽高比应为 16:9 或 1:1。这里,我们将使用 16:9,理想情况下我们应该生成缩放版本以适应各种屏幕密度,但既然这只是一个演示,我们可以偷懒直接将原始图片放入drawable
文件夹。这种方法远非最佳实践,但对于初步测试是没问题的。
在找到并保存你的图片后,下一步是创建一个卡片的布局:
-
从项目浏览器中,导航到
新建 | XML | 布局 XML 文件
,并将其命名为card_content.xml
。它的根视图组应该是一个垂直方向的线性布局,应该看起来像这样:<LinearLayout android:id="@+id/card_content" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> </LinearLayout>
-
使用图形或文本编辑器,创建一个与这里看到的组件树相匹配的布局结构:
-
现在,将此布局包含在主活动布局文件中的卡片视图中,如下所示:
<android.support.v7.widget.CardView android:id="@+id/card_view" android:layout_width="match_parent" android:layout_height="wrap_content"> <include android:id="@+id/card_content" layout="@layout/card_content" /> </android.support.v7.widget.CardView>
提示
尽管可以编辑,但建议卡片视图的默认高度为 2 dp,除非它已被选中和/或正在移动,在这种情况下,它的高度为 8 dp。
你无疑知道,在 XML 资源中硬编码字符串的使用是强烈不推荐的。至少,这使得将我们的应用程序翻译成其他语言的过程几乎不可能。然而,在布局设计的早期阶段,提供一些占位符值以了解布局可能的外观是有帮助的。稍后,我们将使用 Java 控制卡片内容,并根据用户输入选择此内容;但现在,我们将选择一些典型值,以便我们可以轻松快速地看到我们的设置产生的影响。为了了解这是如何有用的,请在values
目录下的strings.xml
文件中添加以下属性或等价物:
<string name="filling">Cheddar Cheese</string>
<string name="filling_description">A ripe and creamy cheddar from the south west</string>
<string name="calories">237 kcal per slice</string>
<string name="action">ADD</string>
<string name="alternative_text">A picture of some cheddar cheese</string>
现在,我们将使用这些占位符来评估我们进行的任何更改。我们刚刚创建的布局,在预览中查看时,应该看起来像这样:
将其转化为材质设计组件只需要进行一些格式化处理,并了解一些材质设计指南的知识。
此布局的度量如下:
-
图片的长宽比必须是 16:9。
-
标题文本应为 24 sp。
-
描述性文本为 16 sp。
-
文本底部右侧和左侧的边距为 16 dp。
-
标题文本上方的边距为 24 dp。
-
动作文本的大小为 24 sp,并从强调色中获取其颜色。
这些属性可以通过属性面板或直接编辑 XML 非常容易地设置。这里有一两件事情没有提到,所以值得单独查看每个元素。
首先,必须指出的是,这些值绝不应像以下代码段中那样直接在代码中描述;例如,android:paddingStart="24dp"
应该像这样编码 android:paddingStart="@dimen/text_paddingStart"
,其中 text_paddingStart
在 dimens.xml
文件中定义。这里,值是硬编码的,只是为了简化解释。
顶部图像视图的代码应该如下所示:
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/alternative_text"
android:src="img/cheddar" />
这非常简单,但请注意 contentDescription
的使用;当视力受损的用户设置了辅助功能选项时,这会被用来让设备通过语音合成器朗读图像的描述,以便用户欣赏。
下面是以下三个文本视图。
<TextView
android:id="@+id/text_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="24dp"
android:paddingStart="24dp"
android:paddingTop="24dp"
android:text="@string/filling"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="24sp" />
<TextView
android:id="@+id/text_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="24dp"
android:paddingStart="24dp"
android:text="@string/filling_description"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="14sp" />
<TextView
android:id="@+id/text_calories"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:text="@string/calories"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="14sp" />
这些也非常容易理解。真正需要指出的是,我们使用 Start
和 End
而不是 Left
和 Right
来定义内边距和重力,这有助于在将布局翻译成从右到左运行文本的语言时,让布局自我纠正。我们还包含了 textAppearance
属性,尽管我们直接设置了文本大小,这看起来可能有些多余。像 textAppearanceMedium
这样的属性很有用,因为它们不仅可以根据我们自定义的主题自动应用文本颜色,还可以根据个别用户的全局文本大小设置调整其大小。
这只剩下底部的动作按钮,由于这里使用的是文本视图而不是按钮,这可能需要一些解释。XML 看起来像这样:
<TextView
android:id="@+id/text_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:clickable="true"
android:paddingBottom="16dp"
android:paddingEnd="40dp"
android:paddingLeft="16dp"
android:paddingRight="40dp"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:text="@string/action"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="@color/colorAccent"
android:textSize="24sp" />
我们在这里选择文本视图而不是按钮控件有两个原因。首先,Android 推荐在卡片视图中使用只有文本可见的扁平按钮;其次,触发动作的可触摸区域需要比文本本身大。这可以通过设置内边距属性轻松实现,正如我们之前所做的那样。要让文本视图像按钮一样工作,我们只需添加一行 android:clickable="true"
。
我们完成的卡片现在应该看起来像这样:
关于卡片视图的设计还有很多内容,但这应该足以作为我们需要遵循的一些原则的介绍,现在我们可以看到这些呈现对象的新方式如何反映在我们的工厂模式代码上。
更新工厂模式
设计模式的美之一在于它们可以轻松地适应我们希望做出的任何变化。如果我们选择,可以保留工厂代码不变,并使用单一字符串输出将客户端代码指向适当的数据集。然而,根据模式的本质,我们应该将它们适应到与我们稍微复杂的成分对象相匹配。
我们上一章代码结构中的思考现在得到了回报,因为尽管我们需要编辑我们的接口和具体示例,但我们可以将工厂类本身保持原样,这很好地展示了模式的一个优点。
使用我们构建卡片时使用的四个标准,我们更新后的接口可能看起来像这样:
public interface Bread {
String image();
String name();
String description();
int calories();
}
单个对象可能看起来像这样:
public class Baguette implements Bread {
@Override
public String image() {
return "R.drawable.baguette";
}
@Override
public String name() {
return "Baguette";
}
@Override
public String description() {
return "Fresh and crunchy";
}
@Override
public int calories() {
return 150;
}
}
随着我们向前发展,我们的对象将需要更多的属性,比如价格以及它们是否是素食或含有坚果等。随着对象的变得更加复杂,我们将不得不应用更复杂的方式来管理我们的数据,但原则上这里使用的方法并没有错。它可能有些笨重,但肯定易于阅读和维护。工厂模式显然非常有用,但它们只创建单一对象。为了更真实地模拟三明治,我们需要能够将配料对象组合在一起,并将整个集合视为一个单独的三明治对象。这正是构建模式发挥作用的地方。
应用构建模式
构建器设计模式是最有用的创建模式之一,因为它从更小的对象构建更大的对象。这正是我们想要从配料列表构造三明治对象所做的。构建器模式的另一个优点是,可选特性稍后很容易加入。像之前一样,我们将从创建一个接口开始;我们将它称为Ingredient
,用它来表示面包
和填充物
。这次,我们需要用整数来表示卡路里,因为我们需要计算成品三明治中的总含量。
打开一个 Android Studio 项目,或者开始一个新项目,并按照以下步骤创建一个基本的三明治构建模式:
-
创建一个名为
Ingredient.java
的新接口,并完成如下:public interface Ingredient { String name(); int calories(); }
-
现在像这样为
Bread
创建一个抽象类:public abstract class Bread implements Ingredient { @Override public abstract String name(); @Override public abstract int calories(); }
-
并创建一个名为
Filling
的相同接口。 -
接下来,像这样创建
Bread
的具体类:public class Bagel extends Bread { @Override public String name() { return "Bagel"; } @Override public int calories() { return 250; } }
-
对
Filling
也做同样的处理。为了演示目的,每种类型两个类应该就足够了:public class SmokedSalmon extends Filling { @Override public String name() { return "Smoked salmon"; } @Override public int calories() { return 400; } }
-
现在我们可以创建我们的
Sandwich
类:public class Sandwich { private static final String DEBUG_TAG = "tag"; // Create list to hold ingredients private List<Ingredient> ingredients = new ArrayList<Ingredient>(); // Calculate total calories public void getCalories() { int c = 0; for (Ingredient i : ingredients) { c += i.calories(); } Log.d(DEBUG_TAG, "Total calories : " + c + " kcal"); } // Add ingredient public void addIngredient(Ingredient ingredient) { ingredients.add(ingredient); } // Output ingredients public void getSandwich() { for (Ingredient i : ingredients) { Log.d(DEBUG_TAG, i.name() + " : " + i.calories() + " kcal"); } } }
-
最后,像这样创建
SandwichBuilder
类:public class SandwichBuilder { // Off the shelf sandwich public static Sandwich readyMade() { Sandwich sandwich = new Sandwich(); sandwich.addIngredient(new Bagel()); sandwich.addIngredient(new SmokedSalmon()); sandwich.addIngredient(new CreamCheese()); return sandwich; } // Customized sandwich public static Sandwich build(Sandwich s, Ingredient i) { s.addIngredient(i); return s; } }
这完成了我们的构建器设计模式,至少目前是这样。当它作为一个图表被查看时,看起来像这样:
在这里,我们为构建器提供了两个功能:返回一个现成的三明治和一个用户定制的三明治。我们目前还没有可用的接口,但我们可以通过客户端代码模拟用户选择。
我们还将输出职责委托给了Sandwich
类本身,这样做通常是个好主意,因为它有助于保持客户端代码的清晰和明显,正如您在这里看到的:
// Build a customized sandwich
SandwichBuilder builder = new SandwichBuilder();
Sandwich custom = new Sandwich();
// Simulate user selections
custom = builder.build(custom, new Bun());
custom = builder.build(custom, new CreamCheese());
Log.d(DEBUG_TAG, "CUSTOMIZED");
custom.getSandwich();
custom.getCalories();
// Build a ready made sandwich
Sandwich offTheShelf = SandwichBuilder.readyMade();
Log.d(DEBUG_TAG, "READY MADE");
offTheShelf.getSandwich();
offTheShelf.getCalories();
这应该会产生类似这样的输出:
D/tag: CUSTOMIZED
D/tag: Bun : 150 kcal
D/tag: Cream cheese : 350 kcal
D/tag: Total calories : 500 kcal
D/tag: READY MADE
D/tag: Bagel : 250 kcal
D/tag: Smoked salmon : 400 kcal
D/tag: Cream cheese : 350 kcal
D/tag: Total calories : 1000 kcal
构造者最大的优势之一是添加、删除和修改具体类非常容易,甚至接口或抽象的变更也无需修改客户端源代码。这使得构造者模式成为最强大的模式之一,并且可以应用于众多场景。但这并不是说构造者模式总是比工厂模式更优。对于简单对象,工厂通常是最佳选择。当然,模式存在于不同规模上,构造者中嵌套工厂或者工厂中嵌套构造者都是常见的情况。
概述
在本章中,我们介绍了大量关于如何展示产品的内容,这是任何成功应用的关键要素。我们学习了如何管理颜色和文本方案,并进一步探讨了更严肃的问题:如何管理应用可能运行在各种屏幕密度上的情况。
接下来,我们介绍了材料设计中使用最频繁的组件之一:卡片视图,并强调了支持库的重要性,尤其是设计库。我们需要进一步了解这个库,因为它对于创建我们应用所需的布局和交互至关重要。下一章将专注于更多这些视觉元素,聚焦于更常见的材料组件,如应用栏和滑动抽屉。
第三章:材料模式
在本书的这部分,我们探讨了如何通过使用设计模式来创建对象和对象集合,以及如何使用卡片视图来展示它们。在我们开始构建一个可工作的应用程序之前,我们需要考虑用户将如何输入他们的选择。在移动设备上有许多方式可以从用户那里收集信息,比如菜单、按钮、图标和对话框。安卓布局通常有一个应用栏(以前称为操作栏),它通常位于屏幕顶部,紧挨着状态栏,而实现材料设计的布局,通常会采用滑动导航抽屉来提供对应用顶级功能的访问。
通常情况下,使用支持库,尤其是设计库,可以非常容易地实现如导航栏这样的材质模式,材料设计本身包含了一些视觉模式,有助于促进最佳的 UI 实践。在本章中,我们将学习如何实现应用栏、导航视图,并探索材料设计提供的一些视觉模式。最后,我们还将快速了解一下单例模式。
在本章中,你将学习如何进行以下操作:
-
用应用栏替换操作栏
-
使用资产工作室添加操作图标
-
应用应用栏操作
-
在运行时控制应用栏
-
使用抽屉布局
-
添加菜单和子菜单
-
应用比例关键线
-
包含一个抽屉监听器
-
向应用中添加片段
-
管理片段回退栈
应用栏
安卓应用一直以来都在屏幕顶部包含一个工具栏。传统上,这被用来提供一个标题以及访问顶级菜单,被称为操作栏。自从安卓 5(API 级别 21)和材料设计的出现,这就可以用更灵活的应用栏来替代。应用栏允许我们设置其颜色,将其放置在屏幕的任何位置,并包含比其前身更广泛的内容。
大多数 Android Studio 模板使用的主题默认包含旧的操作栏,我们首先需要做的就是移除旧版本。要了解如何移除旧的操作栏并用定制的应用栏替换它,请按照以下步骤操作:
-
使用空活动模板启动一个新的安卓项目,并通过主题编辑器设置你的材料主题。
-
打开
styles.xml
文件,并编辑style
定义以匹配这里的定义:<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
-
在
activity_main.xml
旁边创建一个新的 XML 文件,并将其命名为toolbar.xml
。 -
完成如下操作:
<android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" android:theme="@android:style/Theme.Material" android:translationZ="4dp" />
-
接下来,向
activity_main.xml
文件中添加以下元素:<include android:id="@+id/toolbar" layout="@layout/toolbar" />
<android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" android:theme="@android:style/Theme.Material" android:translationZ="4dp" />
-
最后,像这样编辑
dimens.xml
文件中的边距值:<resources> <dimen name="activity_horizontal_margin">0dp</dimen> <dimen name="activity_vertical_margin">0dp</dimen> </resources>
这个工具栏与其他任何 ViewGroup 一样,位于根布局内,因此与原始操作栏不同,它并不紧贴屏幕边缘。这就是为什么我们需要调整布局边距的原因。稍后,我们将使用 CoordinatorLayout,它会自动完成其中许多工作,但现在了解其工作原理是有用的。
工具栏现在虽然位置和阴影与原始工具栏类似,但并没有内容和功能。这可以在活动的 Java 元素中通过编辑onCreate()
方法来实现:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
if (toolbar != null) {
setSupportActionBar(toolbar);
}
}
这将产生一个错误。这是因为这里可能导入两个库。按下Alt + Enter并选择如下所示的支持版本的 Toolbar:
提示
为了在处理 Java 时节省时间,更改设置以便在代码中包含 Java 库时自动导入。这可以通过文件 | 设置菜单中的编辑器 | 常规 | 自动导入来完成。
在运行 API 20 或更低版本的模拟器上测试项目,会立即展示 AppCompat 主题的一个缺点;尽管我们为状态栏with colorPrimaryDark
声明了一个颜色,这在 API 21 及更高版本上完美运行,但在这里它仍然是黑色:
然而,考虑到我们现在能够触及的受众数量,这种做法以及缺少自然阴影的代价是微不足道的。
既然我们已经用工具栏替换了过时的操作栏,并将其设置为应用栏(有时称为主工具栏),我们可以更仔细地了解其工作原理以及如何使用 Asset Studio 应用符合材质设计规范的行动图标。
图像资源
在应用栏中包含文本菜单是可能的,但由于空间有限,通常使用图标更为常见。Android Studio 通过其 Asset Studio 提供了一组材质图标的访问。以下步骤将展示如何操作:
-
在项目资源管理器中,从 drawable 文件夹的菜单选择新建 | 图像资源。
-
然后选择操作栏和标签图标作为资源类型,接着点击剪贴画图标,从剪贴画集中选择一个图标:
-
这张图片需要修剪,且填充为 0%。
-
根据工具栏背景颜色是浅色还是深色选择一个主题。
-
提供一个合适的名称并点击下一步:
提示
可以从以下 URL 下载更多材质图标集合:
design.google.com/icons
资产工作室自动为我们跨四种屏幕密度创建图标,并将它们放置在正确的文件夹中,以便它们能够部署在适当的设备上。它甚至应用了材料设计用于图标所需的54%不透明黑色。要在我们的应用栏中包含这些,只需在适当的菜单项中添加一个图标属性。稍后,我们将使用导航抽屉提供顶级访问,但要了解如何使用应用栏,我们将添加一个搜索功能。我们为此选择的图标叫做 ic_action_search
。
应用操作
操作图标保存在可绘制文件夹中,并且可以通过在菜单 XML 文件中包含 items
来包含在我们的操作栏中。根据您最初创建项目时使用的模板,您可能需要添加一个新目录 res/menu
和一个名为 main.xml
或 menu_main.xml
的文件,或者您选择作为 新建 | 菜单资源文件 的其他名称。可以像这样添加操作:
<menu
tools:context="com.example.kyle.appbar.MainActivity">
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:
app:showAsAction="collapseActionView" />
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_action"
android:orderInCategory="100"
android:
app:showAsAction="ifRoom" />
</menu>
请注意,前面的示例使用了对字符串资源的引用,因此必须在 strings.xml
文件中伴有如下定义:
<string name="menu_search">Search</string>
菜单项会自动包含在应用栏中,其标题取自字符串文件中的 string name="app_name"
定义。以这种方式构建时,这些组件会根据材料设计指南进行定位。
要查看实际效果,请按照以下步骤操作:
-
打开主 Java 活动并添加这个字段:
private Toolbar toolbar;
-
然后将这些行添加到
onCreate()
方法中:Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); if (toolbar != null) { setSupportActionBar(toolbar); } toolbar = (Toolbar) findViewById(R.id.toolbar); toolbar.setTitle("A toolbar"); toolbar.setSubtitle("with a subtitle");
-
最后,在活动中添加以下方法:
@Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.menu_main, menu); return true; }
现在我们应该能够在设备或模拟器上看到我们的新工具栏:
能够将我们喜欢的任何视图添加到工具栏,这使得它比旧的操作栏更有效。我们可以同时拥有多个,并且通过应用布局重力属性,它们甚至可以被放置在其他地方。正如之前所见,工具栏还带有自己的方法,可以通过这些方法添加图标和标志,但在这样做之前,根据材料设计指南探索应用栏的最佳实践会是一个好主意。
应用栏结构
尽管我们在这里应用的技术符合材料设计指南,我们除了确保其高度外不需要做很多工作,但在用自定义工具栏布局替换操作栏时,我们仍需要知道如何间隔和定位组件。这些在平板电脑和桌面上略有不同。
手机
在应用栏方面,只需记住一些简单的结构规则。这些规则涵盖了边距、填充、宽度、高度和定位,并且在不同平台和屏幕方向上有所不同。
-
应用栏在纵向模式下的
layout_height
为56 dp
,在横向模式下为48 dp
。 -
应用栏填充屏幕宽度或其包含列的宽度。它们不能被分成两个部分。它们的
layout_width
为match_parent
。 -
应用栏的
elevation
比它控制的材质纸张高2 dp
。 -
前一条规则的例外情况是,如果卡片或对话框有自己的工具栏,那么两者可以共享相同的阴影高度。
-
应用栏的填充恰好为
16 dp
。这意味着包含的图标不能有自己的填充或边距,因此与这个边距共享边缘: -
标题文本的颜色取自您主题的主文本颜色,图标则取自辅助文本颜色。
-
标题应位于工具栏左侧
72 dp
和底部20 dp
的位置。即使工具栏展开时也适用此规则: -
标题文本大小通过
android:textAppearance="?android:attr/textAppearanceLarge"
进行设置。
平板电脑
在为平板电脑和桌面构建应用栏时,规则相同,以下是一些例外:
-
工具栏的高度始终为
64 dp
。 -
标题向内缩进
80 dp
,并且在工具栏展开时不会向下移动。 -
应用栏的填充为
24 dp
,顶部除外,那里是20 dp
。
我们已经按照材质设计指南构建了一个应用栏,但如果没有执行操作,操作图标是没有用的。本质上,当应用栏承担操作栏功能时,它实际上只是一个菜单的访问点。我们稍后会回到菜单和对话框,但现在我们将快速了解一下如何使用 Java 代码在运行时操作工具栏。
对旧操作栏所做的更改使其成为一个放置全局操作的简单直观视图。然而,空间有限,对于更复杂和图形化的导航组件,我们可以转向滑动抽屉。
导航抽屉
尽管可以让抽屉从屏幕的任一侧滑出,但导航抽屉应始终位于左侧,并且其阴影高度应高于除状态栏和导航栏之外的所有其他视图。将导航抽屉视为一个大部分时间隐藏在屏幕边缘之外的固定装置:
在设计库之前,必须使用其他视图构建如导航视图之类的组件,尽管库极大地简化了这一过程,并使我们不必手动实现许多材质原则,但仍有一些指南需要我们注意。了解这些的最佳方式是从头开始构建一个导航滑动抽屉。这将涉及创建布局,应用关于组件比例的材质设计指南,并通过代码将所有这些连接起来。
抽屉构建
你在设置项目时无疑已经注意到,Android Studio 提供了一个 Navigation Drawer Activity 模板。这为我们创建了很多可能需要的内容,并节省了大量工作。一旦我们决定了我们的三明治制作应用将具有哪些功能,我们将使用这个模板。然而,从头开始构建一个更有教育意义,可以看到它是如何工作的,基于这个想法,我们将创建一个需要通过 Asset Studio 轻松找到图标的抽屉布局:
-
打开一个最低 SDK 级别为 21 或更高的 Android Studio 项目,并提供你自己的自定义颜色和主题。
-
在你的
styles.xml
文件中添加以下行:<item name="android:statusBarColor"> @android:color/transparent </item>
-
确保你已经编译了以下依赖项:
compile 'com.android.support:design:23.4.0'
-
如果你没有使用前一部分中的同一个项目,请设置一个名为
toolbar.xml
的 app-bar 布局。 -
打开
activity_main
并用以下代码替换:<android.support.v4.widget.DrawerLayout android:id="@+id/drawer" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:context=".MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <include android:id="@+id/toolbar" layout="@layout/toolbar" /> <FrameLayout android:id="@+id/fragment" android:layout_width="match_parent" android:layout_height="match_parent"> </FrameLayout> </LinearLayout> <android.support.design.widget.NavigationView android:id="@+id/navigation_view" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="start" app:headerLayout="@layout/header" app:menu="@menu/menu_drawer" /> </android.support.v4.widget.DrawerLayout>
如你所见,这里的根布局是由支持库提供的 DrawerLayout。注意 fitsSystemWindows
属性;这就是使得抽屉延伸到状态栏下方的屏幕顶部的原因。在样式中将 statusBarColor
设置为 android:color/transparent
,抽屉现在可以通过状态栏看到。
即使使用 AppCompat,这个效果在运行 Android 5.0(API 21)以下版本的设备上也是不可用的,这将改变标题的显示宽高比并裁剪任何图片。为了解决这个问题,创建一个不设置 fitsSystemWindows
属性的替代 styles.xml
资源。
布局的其余部分包括一个 LinearLayout 和 NavigationView 本身。这个线性布局包含我们的应用栏和一个空的 FrameLayout。FrameLayout 是最简单的布局,只包含单个条目,通常用作占位符,在这种情况下,它将根据用户从导航菜单中的选择来包含内容。
从前面的代码可以看出,我们需要一个用于标题的布局文件和一个用于抽屉本身的菜单文件。header.xml
文件应该在 layout
目录中创建,并如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="header_height"
android:background="@drawable/header_background"
android:orientation="vertical">
<TextView
android:id="@+id/feature"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/details"
android:gravity="left"
android:paddingBottom="8dp"
android:paddingLeft="16dp"
android:text="@string/feature"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@+id/feature"
android:layout_alignParentBottom="true"
android:layout_marginBottom="16dp"
android:gravity="left"
android:paddingLeft="16dp"
android:text="@string/details"
android:textColor="#FFFFFF"
android:textSize="14sp" />
</RelativeLayout>
你需要向 dimens.xml
文件中添加以下值:
<dimen name="header_height">192dp</dimen>
如你所见,我们需要一张用作标题的图片。这里称它为 header_background
,它的宽高比应该是 4:3。
如果你在这个布局在不同的屏幕密度设备上进行测试,你很快就会发现这个宽高比没有得到保持。我们可以通过类似管理图像资源的方式,使用配置限定符来轻松解决这个问题。为此,请按照这里概述的简单步骤操作:
-
为每个密度范围创建新的目录,其名称如
values-ldpi
、values-mdpi
等,直至values-xxxhdpi
。 -
在每个文件夹中复制一份
dimens.xml
文件。 -
在每个文件中设置
header_height
的值,以匹配该屏幕密度。
菜单文件名为menu_drawer.xml
,应放置在menu
目录中,你可能需要创建这个目录。每个项目都有一个关联的图标,这些都可以在资源工作室中找到。代码本身应与以下内容相匹配:
<?xml version="1.0" encoding="utf-8"?>
<menu >
<item
android:id="@+id/drama"
android:icon="@drawable/drama"
android: />
<item
android:id="@+id/film"
android:icon="@drawable/film"
android: />
<item
android:id="@+id/sport"
android:icon="@drawable/sport"
android: />
<item
android:id="@+id/news"
android:>
<menu>
<item
android:id="@+id/national"
android:icon="@drawable/news"
android: />
<item
android:id="@+id/international"
android:icon="@drawable/international"
android: />
</menu>
</item>
</menu>
由于设计库,滑动抽屉和导航视图的大部分度量标准(如边距和文本大小)都为我们处理好了。然而,抽屉标题上的文本大小、位置和颜色并没有。尽管共享背景,但文本应该被认为是一个本身高度为 56-dp 的组件。它应该有 16-dp 的内部填充和 8-dp 的行间距。这,加上正确的文本颜色、大小和权重可以从前面的代码中得出。
比例关键线
当一个元素(如滑动抽屉)填满整个屏幕的高度,并被分为垂直段时,如我们的抽屉在标题和内容之间,那么这些分段只能在某些点发生,这些点称为比例关键线。这些点由元素的宽度和距离顶部发生分割的比例决定。在材料布局中有六种这样的比例,它们定义为宽高比(width:height
),如下所示:
-
16:9
-
3:2
-
4:3
-
1:1
-
3:4
-
2:3
在此示例中,选择了 4:3 的比例,抽屉的宽度为 256 dp。我们还可以制作一个具有 16:9 比例的标题,并将layout_height
设置为 144 dp。
比例关键线仅与包含元素的顶部距离有关;你不能在一个 16:9 视图下方再放置另一个。但是,如果另一个视图从顶部视图的底部延伸到另一条比例关键线,你可以在其下方放置另一个视图:
激活抽屉
现在剩下的就是用 Java 实现一些代码,让布局工作。这是通过监听回调方法实现的,当用户与抽屉交互时调用。以下步骤演示了如何实现这一点:
-
打开 MainActivity 文件,并在
onCreate()
方法中添加以下行,用我们的工具栏替换动作栏:toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar);
-
在此之下,添加以下行来配置抽屉:
drawerLayout = (DrawerLayout) findViewById(R.id.drawer); ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(this, drawerLayout, toolbar, R.string.openDrawer, R.string.closeDrawer) { public void onDrawerOpened(View v) { super.onDrawerOpened(v); } public void onDrawerClosed(View v) { super.onDrawerClosed(v); } }; drawerLayout.setDrawerListener(toggle); toggle.syncState();
-
最后,添加此代码来设置导航视图:
navigationView = (NavigationView) findViewById(R.id.navigation_view); navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() { @Override public boolean onNavigationItemSelected(MenuItem item) { drawerLayout.closeDrawers(); switch (item.getItemId()) { case R.id.drama: Log.d(DEBUG_TAG, "drama"); return true; case R.id.film: Log.d(DEBUG_TAG, "film"); return true; case R.id.news: Log.d(DEBUG_TAG, "news"); return true; case R.id.sport: Log.d(DEBUG_TAG, "sport"); return true; default: return true; } } });
上述 Java 代码允许我们在设备或模拟器上查看抽屉,但在选择导航项时几乎不起作用。我们真正需要做的是实际上跳转到应用程序的另一部分。这很容易实现,我们稍后会介绍。首先,在前面代码中有一两点需要提一下。
以 ActionBarDrawerToggle
开头的这行代码是导致应用栏上出现打开抽屉的汉堡包图标的代码,当然,你也可以从屏幕左侧向内滑动来打开它。两个字符串参数 openDrawer
和 closeDrawer
是出于可访问性考虑的,它们会被读给那些看不清屏幕的用户听,应该表述为类似“导航抽屉打开”和“导航抽屉关闭”。两个回调方法 onDrawerOpened()
和 onDrawerClosed()
在这里留空了,但它们展示了可以拦截这些事件的位置。
调用 drawerLayout.closeDrawers()
是必不可少的,因为否则抽屉将保持打开状态。在这里,我们使用了调试器来测试输出,但理想情况下我们希望菜单能引导我们到应用的其他部分。这并不是一个困难的任务,同时也提供了一个很好的机会来介绍 SDK 中最有用和多功能的类之一,即碎片。
添加碎片
根据我们目前所学到的知识,可以想象为具有多个功能的 apps 会使用单独的活动,尽管这通常是情况,但它们可能会成为资源的昂贵消耗,并且活动总是占据整个屏幕。碎片就像迷你活动,它们既有 Java 也有 XML 定义,并且具有与活动相同的许多回调和功能。与活动不同,碎片不是顶级组件,必须驻留在宿主活动中。这种做法的优点是我们可以拥有一个屏幕上的多个碎片。
要了解如何做到这一点,请创建一个新的 Java 类,比如叫 ContentFragment
,然后按照以下步骤完成它,确保导入的是 android.support.v4.app.Fragment
而不是标准版本:
public class ContentFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View v = inflater.inflate(R.layout.content,container,false);
return v;
}
}
至于 XML 元素,创建一个名为 content.xml
的布局文件,并在其中放置你选择的任意视图和小部件。现在需要的只是当选择导航项时调用它的 Java 代码。
打开 MainActivity.Java
文件,并在 switch
语句中用以下内容替换一个 Debug 调用:
ContentFragment fragment = new ContentFragment();
android.support.v4.app.FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
transaction.replace(R.id.fragment, fragment);
transaction.addToBackStack(null);
transaction.commit();
我们在这里构建的示例只是为了演示抽屉布局和导航视图的基本结构。显然,要添加任何实际的功能,我们需要为菜单中的每个项都准备一个碎片,除非这样做,否则 transaction.addToBackStack(null);
这行代码实际上是多余的。它的功能是确保系统以记录使用哪个活动的方式记录用户访问每个碎片的顺序,这样当用户按下返回键时,他们将返回到上一个碎片。如果没有它,他们将被返回到上一个应用,而容器活动将被销毁。
右手抽屉
作为顶级导航组件,滑动抽屉应该只从左侧滑入,并遵循之前概述的度量标准。然而,要实现从右侧滑入的抽屉非常容易,对于许多次要功能来说,这可能是有吸引力的:
让滑动抽屉从右侧出现仅是设置布局重力的问题,例如:
android:layout_gravity="end"
与传统的导航视图不同,它不应比屏幕宽度减去主应用栏的高度更宽,而右手侧抽屉可以延伸到整个屏幕。
本章的内容一直关于 UI 设计,我们还没有遇到过任何设计模式。我们本可以在这里使用设计模式,但选择专注于 Android UI 的机制。我们将在本书的后面看到,门面模式对于简化复杂菜单或布局的编码非常有用。
几乎可以在任何地方引入的一个设计模式是单例模式。这是因为它几乎可以在任何地方使用,其目的是提供一个对象的全球实例。
单例模式。
单例模式无疑是所有模式中最简单的一个,但同时也是最具争议的一个。许多开发者认为它完全没必要,认为将一个类声明为静态可以达到相同的功能,而且更为简单。尽管单例模式确实在许多本可以使用静态类的情况下被过度使用,但确实存在一些场合,单例模式比静态类更为合适:
-
当你想要对一个传递给它的变量执行函数时,使用静态类,例如,计算价格变量的折扣值。
-
当你想要一个完整的对象,但只有一个,并且希望这个对象可以被程序的任何部分访问时,使用单例模式,例如,代表当前登录应用的个人用户的对象。
单例模式的类图,正如你所想象的,非常简单,正如你在这里看到的:
如前面的图表所示,以下示例假设我们一次只登录一个用户到我们的应用,并且我们将创建一个可以从代码任何部分访问的单例对象。
Android Studio 在项目资源管理器的新建菜单下提供了单例创建功能,因此我们可以从这里开始。这个演示只有两个步骤,如下所示。
-
将这个类添加到你的项目中:
public class CurrentUser { private static final String DEBUG_TAG = "tag"; private String name; // Create instance private static CurrentUser user = new CurrentUser(); // Protect class from being instantiated private CurrentUser() { } // Return only instance of user public static CurrentUser getUser() { return user; } // Set name protected void setName(String n) { name = n; } // Output user name protected void outputName() { Log.d(DEBUG_TAG, name); } }
-
通过向活动中添加如下代码来测试这个模式:
CurrentUser user = CurrentUser.getUser(); user.setName("Singleton Pattern"); user.outputName();
单例模式可能非常有用,但很容易不必要地使用它。在异步任务时非常有用,如文件系统,并且当我们希望从代码的任何地方访问其内容时,比如前面示例中的用户名。
总结
无论应用的目的如何,用户需要一种熟悉的方式来访问它的功能。应用栏和导航抽屉不仅容易被用户理解,而且提供了极大的灵活性。
在本章中,我们了解了如何在安卓设备上应用两种最重要的输入机制以及控制它们外观的材料设计模式。"SDK,尤其是设计库,使得编写这些结构既简单又直观。尽管与迄今为止我们遇到的设计模式不同,但材料设计模式发挥着类似的功能,并指导我们走向更好的实践。
下一个章节将继续探讨布局设计,并研究在组合整个布局时我们可以使用的工具,以及我们如何设法开发适应各种屏幕形状和大小的应用。
第四章:布局模式
在前面的章节中,我们已经了解了创建对象时最常用的模式以及一些最常使用的材质组件。为了将这些内容整合在一起,我们需要考虑应用程序可能需要的整体布局。这使我们能够更详细地规划我们的应用程序,同时也带来了为不同尺寸屏幕和方向设计应用程序的有趣挑战。Android 平台为开发各种屏幕尺寸和形状提供了非常简单直观的方法,并且只需编写很少的额外代码。最后,我们将探索并创建一个策略模式。
在本章中,您将学习如何:
-
使用相对布局和线性布局
-
应用重力和权重
-
使用 weightSum 缩放权重
-
使用百分比支持库
-
为特定屏幕尺寸开发布局
-
创建策略模式
Android 平台提供了一系列布局类。从非常简单的帧布局到支持库提供的相当复杂的布局。最广泛使用且最灵活的是线性布局和相对布局。
线性布局
在相对布局和线性布局之间选择通常非常简单。如果您的组件是从一边到另一边堆叠的,那么线性布局是明显的选择。尽管嵌套视图组是可能的,但对于更复杂的布局,相对布局通常是最好的选择。这主要是因为嵌套布局会消耗资源,应尽可能避免深层层次结构。相对布局可以用来创建许多复杂的布局,而无需大量嵌套。
无论哪种形式最适合我们的需求,一旦开始在形状不同的屏幕上测试我们的布局,或者将屏幕旋转 90°,我们很快就会发现我们在创建具有美观比例的组件上所做的所有思考都白费了。通常,这些问题可以通过使用重力属性定位元素并通过权重属性进行缩放来解决。
权重和重力
能够设置位置和比例而不必过分关注屏幕的确切形状可以为我们节省大量工作。通过设置组件和控件的权重属性,我们可以确定单个组件占用的屏幕宽度或高度的比例。当我们希望大多数控件使用wrap_content
,以便根据用户需求进行扩展,但同时也希望一个视图占用尽可能多的空间时,这特别有用。
例如,在以下布局中的图像将随着上方文本的增长而适当缩小。
在此图中,只有图像视图应用了权重,其他视图的height
都使用wrap_content
声明。正如这里所看到的,我们需要将layout_height
设置为0dp
以避免在设置视图高度时发生内部冲突:
<ImageView
android:id="@+id/feedback_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="@string/content_description"
android:src="img/tomatoes" />
提示
权重不仅可以应用于单个小部件和视图,还可以应用于视图组和嵌套布局。
自动填充可能变化的屏幕空间非常有用,但权重可以应用于多个视图,以创建每个视图占用活动指定相对面积的布局。例如,以下图片就使用了1
、2
、3
和2
的权重进行缩放。
尽管通常应避免在一个布局中嵌套另一个布局,但考虑一两个层级往往是有价值的,因为这可以产生一些非常实用的活动。例如:
这个布局仅使用两个嵌套的视图组,且权重的使用可以使得结构在相当广泛的屏幕尺寸上都能很好地工作。当然,这个布局在竖屏模式下看起来会很糟糕,但我们在本章后面会看到如何解决这个问题。生成此类布局的 XML 代码如下所示:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="56dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2" />
</LinearLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
上面的示例引出了一个有趣的问题。如果我们不想填满布局的整个宽度和高度怎么办?如果我们想要留出一些空间呢?这可以通过weightSum属性轻松管理。
要了解weightSum
是如何工作的,可以在上一个示例中的内部线性布局定义中添加以下突出显示的属性:
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:weightSum="10">
通过为布局设置最大权重,内部权重将按比例设置。在这个例子中,weightSum
为10
设置了内部权重,即3
和2
,分别占布局高度的 3/10 和 2/10,如下所示:
提示
请注意,权重和weightSum
都是浮点属性,使用如下这样的行可以取得更高的精确度:android:weightSum="20.5"
。
使用权重是充分利用未知屏幕大小和形状的极其有用的方法。管理整体屏幕空间的另一种技术是使用重力来定位组件及其内容。
gravity属性用于对齐视图及其内容。在之前给出的示例中,以下标记被用于将动作定位在活动的底部:
<TextView
android:id="@+id/action_post"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_gravity="right"
android:clickable="true"
android:padding="16dp"
android:text="@string/action_post"
android:textColor="@color/colorAccent"
android:textSize="24sp" />
这个示例演示了如何使用layout_gravity
来对齐容器内的视图(或视图组)。单个视图的内容也可以通过gravity
属性在视图内部定位,可以像这样设置:
android:layout_gravity="top|left"
将布局按行和列排序可能是考虑屏幕布局的最简单方法,但这不是唯一的方法。相对布局提供了一种基于位置而非比例的替代技术。相对布局还允许我们使用百分比支持库来对其内容进行比例调整。
相对布局
相对布局最大的优势可能是它能够减少在构建复杂布局时嵌套视图组数量。这是通过定义视图的位置以及它们如何通过属性如layout_below
和layout_toEndOf
相互定位和对齐来实现的。要看这是如何操作的,可以考虑上一个示例中的线性布局。我们可以将其重新创建为一个没有嵌套视图组的相对布局,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true" />
<FrameLayout
android:id="@+id/main_panel"
android:layout_width="320dp"
android:layout_height="match_parent"
android:layout_alignParentStart="true"
android:layout_below="@+id/header" />
<FrameLayout
android:id="@+id/center_column_top"
android:layout_width="160dp"
android:layout_height="192dp"
android:layout_below="@+id/header"
android:layout_toEndOf="@+id/main_panel" />
<FrameLayout
android:id="@+id/center_column_bottom"
android:layout_width="160dp"
android:layout_height="match_parent"
android:layout_below="@+id/center_column_top"
android:layout_toEndOf="@+id/main_panel" />
<FrameLayout
android:id="@+id/right_column"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/header"
android:layout_toEndOf="@+id/center_column_top" />
</RelativeLayout>
尽管这种方法的明显优势是不需要嵌套视图组,但我们必须明确设置单个视图的尺寸,一旦在不同屏幕上预览输出,这些比例很快就会丢失,或者至少会被扭曲。
解决这个问题的方法之一可能是为不同的屏幕配置创建单独的dimens.xml
文件,但如果我们想要填充屏幕的精确百分比,那么我们永远无法保证在每种可能的设备上都能实现这一点。幸运的是,Android 提供了一个非常有用的支持库。
百分比支持库
在相对布局中为给定组件定义确切比例可能是一个问题,因为我们只能描述事物在哪里,而不能描述它们在组内的突出程度。幸运的是,百分比库提供了PercentRelativeLayout来解决这一问题。
与其他支持库一样,百分比库必须包含在build.gradle
文件中:
compile 'com.android.support:percent:23.4.0'
要创建之前的相同布局,我们将使用以下代码:
<android.support.percent.PercentRelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
app:layout_heightPercent="20%" />
<FrameLayout
android:id="@+id/main_panel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_alignParentStart="true"
android:layout_below="@+id/header"
app:layout_widthPercent="50%" />
<FrameLayout
android:id="@+id/center_column_top"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@+id/header"
android:layout_toEndOf="@+id/main_panel"
app:layout_heightPercent="48%"
app:layout_widthPercent="25%" />
<FrameLayout
android:id="@+id/center_column_bottom"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@+id/center_column_top"
android:layout_toEndOf="@+id/main_panel"
app:layout_heightPercent="32%"
app:layout_widthPercent="25%" />
<FrameLayout
android:id="@+id/right_column"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_below="@+id/header"
android:layout_toEndOf="@+id/center_column_top"
app:layout_widthPercent="25%" />
</android.support.percent.PercentRelativeLayout>
百分比库提供了一种直观且简单的方法来创建比例,这些比例在未测试的形态因素上显示时不容易被扭曲。这些模型在其他具有相同方向的设备上测试时工作得非常好。然而,一旦我们将这些布局旋转 90°,我们就能看到问题所在。幸运的是,Android SDK 允许我们重用我们的布局模式,以最小的重新编码创建替代版本。正如我们所料,这是通过创建指定的布局配置来实现的。
屏幕旋转
大多数,如果不是全部的移动设备,都允许屏幕重新定向。许多应用程序(如视频播放器)更适合一个方向而不是另一个。一般来说,我们希望我们的应用程序无论旋转多少度都能看起来最好。
当从竖屏转换为横屏或反之亦然时,大多数布局看起来都很糟糕。显然,我们需要为这些情况创建替代方案。幸运的是,我们不需要从头开始。要看这是如何实现的,可以从这里的一个标准的竖屏布局开始:
这可以通过以下代码重新创建:
<android.support.percent.PercentRelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:background="@color/colorPrimary"
android:elevation="6dp"
app:layout_heightPercent="10%" />
<ImageView
android:id="@+id/main_panel"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_alignParentStart="true"
android:layout_below="@+id/header"
android:background="@color/colorAccent"
android:contentDescription="@string/image_description"
android:elevation="4dp"
android:scaleType="centerCrop"
android:src="img/cheese"
app:layout_heightPercent="40%" />
<FrameLayout
android:id="@+id/panel_b"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignParentEnd="true"
android:layout_below="@+id/main_panel"
android:background="@color/material_grey_300"
app:layout_heightPercent="30%"
app:layout_widthPercent="50%" />
<FrameLayout
android:id="@+id/panel_c"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignParentEnd="true"
android:layout_below="@+id/panel_b"
android:background="@color/material_grey_100"
app:layout_heightPercent="20%"
app:layout_widthPercent="50%" />
<FrameLayout
android:id="@+id/panel_a"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_alignParentStart="true"
android:layout_below="@+id/main_panel"
android:elevation="4dp"
app:layout_widthPercent="50%" />
</android.support.percent.PercentRelativeLayout>
同样,一旦旋转,它看起来设计得非常糟糕。为了创建一个可接受的横屏版本,请在设计模式下查看你的布局,并点击设计面板左上角的配置图标,选择创建横屏变体:
这会在一个文件夹中创建我们文件的副本,该文件夹在应用程序处于横屏模式时会引用其布局定义。这个目录与res/layout
文件夹并列,名为res/layout-land
。现在只需重新排列我们的视图以适应这种新格式,实际上,我们可以使用本章早些时候的布局,如下所示:
<android.support.percent.PercentRelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:background="@color/colorPrimary"
android:elevation="6dp"
app:layout_heightPercent="15%" />
<ImageView
android:id="@+id/main_panel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_alignParentStart="true"
android:layout_below="@+id/header"
android:background="@color/colorAccent"
android:contentDescription="@string/image_description"
android:elevation="4dp"
android:scaleType="centerCrop"
android:src="img/cheese"
app:layout_widthPercent="50%" />
<FrameLayout
android:id="@+id/panel_a"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@+id/header"
android:layout_toRightOf="@id/main_panel"
android:background="@color/material_grey_300"
app:layout_heightPercent="50%"
app:layout_widthPercent="25%" />
<FrameLayout
android:id="@+id/panel_b"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@+id/panel_a"
android:layout_toRightOf="@id/main_panel"
android:background="@color/material_grey_100"
app:layout_heightPercent="35%"
app:layout_widthPercent="25%" />
<FrameLayout
android:id="@+id/panel_c"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_alignParentEnd="true"
android:layout_below="@+id/header"
android:elevation="4dp"
app:layout_widthPercent="25%" />
</android.support.percent.PercentRelativeLayout>
应用这些更改并创建横屏布局只需几秒钟,但我们还可以做得更多。特别是,我们可以创建专门为大屏幕和平板电脑设计的布局。
大屏幕布局
当我们从配置菜单创建我们布局的横屏版本时,你无疑注意到了创建 layout-xlarge 版本的选项,正如你所想象的,这是用于为平板电脑和甚至电视的大屏幕创建合适的布局。
如果你选择这个选项,你会立即看到我们对百分比库的明智使用产生了相同的布局,可能会觉得这个布局是不必要的,但这会忽略重点。像 10 英寸平板这样的设备提供了更多的空间,我们不仅应该放大我们的布局,还应该利用这个机会提供更多的内容。
在这个例子中,我们只为 xlarge 版本添加一个额外的框架。这很容易做到,只需添加以下 XML,并调整其他视图的高度百分比值:
<FrameLayout
android:id="@+id/panel_d"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignParentEnd="true"
android:layout_below="@+id/panel_c"
android:background="@color/colorAccent"
android:elevation="4dp"
app:layout_heightPercent="30%"
app:layout_widthPercent="50%" />
除了充分利用大屏幕,我们也可以通过使用small
限定符为小屏幕实现相反的效果。这有助于优化小屏幕的布局,通过缩小元素大小,甚至移除不那么重要的内容。
我们在这里看到的限定符非常有用,但它们仍然相当宽泛。根据设备分辨率,我们可能会很容易地发现相同的布局被应用于大手机和小平板上。幸运的是,框架提供了让我们在定义布局时更加精确的方法。
宽度限定符
作为开发者,我们花费大量时间和精力寻找和创造优质的图像和其他媒体内容。重要的是,我们要让这些工作得到应有的展示,确保它们以最佳效果呈现。想象一下,你有一个至少需要 720 像素宽才能最好地欣赏的布局。在这种情况下,我们可以做两件事。
首先,我们可以确保我们的应用程序只在至少具有我们所需屏幕分辨率的设备上可用,这可以通过编辑AndroidManifest
文件来实现,在manifest
元素内添加以下标签:
<supports-screens android:requiresSmallestWidthDp="720" />
通常,让我们的应用对小型屏幕用户不可用是一件遗憾的事,我们可能这样做的情况很少。为大型电视屏幕设计或精确照片编辑的应用可能是例外。更常见的是,我们更愿意创建适合尽可能多的屏幕尺寸的布局,这导致了我们的第二个选项。
安卓平台允许我们根据诸如 最小和可用宽度(以像素为单位)的具体屏幕尺寸标准来设计布局。通过最小,我们指的是两个屏幕尺寸中最窄的一个,无论方向如何。对于大多数设备来说,这意味着在纵向模式下查看时的宽度,以及横向模式下的高度。使用可用宽度提供了另一个级别的灵活性,即宽度是根据屏幕的方向来测量的,这允许我们设计一些非常特定的布局。根据最小宽度优化布局非常简单,就像以前使用限定符一样。所以一个名为:
res/layout-sw720dp/activity_main.xml
将替换
res/layout/activity_main.xml
在最短边为 720 dp 或更大的设备上。
当然,我们可以创建任意大小文件夹,例如 res/layout-sw600dp
。
这种技术非常适合为大型屏幕设计布局,无论方向如何。然而,根据设备在特定时刻的方向来应用基于外观宽度的布局设计可能非常有用。这是通过指定目录以类似方式实现的。为了设计可用宽度,使用:
res/layout-w720dp
为了优化可用高度,使用:
res/layout-h720dp
这些限定符提供了确保我们的设计充分利用可用硬件的非常有用的技术,但如果我们想要为运行 Android 3.1 或更低版本的设备开发,就有一个小缺点。在这些设备上,最小和可用宽度限定符不可用,我们必须使用 large
和 xlarge
限定符。这可能导致两个相同的布局,浪费空间并增加我们的维护成本。幸运的是,有一种方法可以解决这个问题,那就是布局别名。
布局别名
为了演示布局别名如何工作,我们将想象一个简单的案例,我们只有两个布局,一个是默认的 activity_main.xml
文件,其中只有两个视图,另一个是我们称之为 activity_main_large.xml
的布局,它有三个视图,以利用更大的屏幕。要了解如何完成此操作,请按照以下步骤操作:
-
打开
activity_main
文件,为其提供以下两个视图:<ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="256dp" android:layout_alignParentLeft="true" android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:contentDescription="@string/content_description" android:scaleType="fitStart" android:src="img/sandwich" /> <TextView android:id="@+id/text_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@+id/image_view" android:layout_centerHorizontal="true" android:layout_centerVertical="true" android:text="@string/text_value" android:textAppearance="?android:attr/textAppearanceLarge" />
-
复制此文件,将其命名为
activity_main_large
并添加以下视图:<TextView android:id="@+id/text_view2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" android:layout_below="@+id/text_view" android:layout_marginTop="16dp" android:text="@string/extra_text" android:textAppearance="?android:attr/textAppearanceMedium" />
<ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="256dp" android:layout_alignParentLeft="true" android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:contentDescription="@string/content_description" android:scaleType="fitStart" android:src="img/sandwich" /> <TextView android:id="@+id/text_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@+id/image_view" android:layout_centerHorizontal="true" android:layout_centerVertical="true" android:text="@string/text_value" android:textAppearance="?android:attr/textAppearanceLarge" />
-
创建两个名为
res/values-large
和res/values-sw720dp
的 新建 | 安卓资源目录。 -
在
values-large
文件夹中,创建一个名为layout.xml
的文件,并完成如下:<resources> <item name="main" type="layout">@layout/activity_main_large</item> </resources>
-
最后,在
values-sw720dp
文件夹中创建一个相同的文件:
以这种方式使用布局别名意味着我们只需要创建一个大型布局,无论设备运行的是哪个 Android 平台,它都将应用于大屏幕。
在这个例子中,我们选择720dp
作为我们的阈值。在大多数情况下,这将针对 10 英寸平板和更大的设备。如果我们希望我们的较大布局在大多数 7 英寸平板和大手机上运行,我们会使用600dp
,当然我们可以选择任何符合我们目的的值。
提示
有时,我们可能希望限制应用仅支持横屏或竖屏。这可以通过在清单文件的 activity 标签中添加android:screenOrientation="portrait"
或android:screenOrientation="landscape"
来实现。
注意
通常来说,我们应该为手机、7 英寸平板和 10 英寸平板创建横屏和竖屏布局。
设计吸引人且直观的布局是我们作为开发者面临的最重要任务之一,这里引入的快捷方式大大减少了我们的工作量,使我们能够专注于设计吸引人的应用程序。
与上一章一样,我们关注的是更实际的布局结构问题,这当然是进一步开发的前提。然而,有很多模式需要我们熟悉,我们越早熟悉它们越好,这样我们就越有可能识别出那些可能从应用模式中受益的结构。本章探讨的情况中可以应用的一种模式就是策略设计模式。
策略模式
策略模式是另一种被广泛使用且极其有用的模式。其美妙之处在于它的灵活性,因为它可以应用于众多场景中。其目的是在运行时为给定问题提供一系列解决方案(策略)。一个很好的例子就是,一个应用在安装于 Windows、Mac OS 或 Linux 系统时,会采用不同的策略来运行不同的代码。如果我们上面用来为不同设备设计 UI 的系统如此高效,我们可以轻松地使用策略模式来完成这项任务。它看起来会像这样:
目前,我们将稍微向前迈进一步,设想一下我们的三明治制作应用用户准备支付的情况。我们将假设三种支付方式:信用卡、现金和优惠券。现金支付的用户将直接支付设定的价格。有些不公平的是,信用卡支付的用户将被收取小额费用,而持有优惠券的用户将获得 10%的折扣。我们还将使用单例来表示应用这些策略之前的基本价格。按照以下步骤设置策略模式:
-
我们通常从接口开始:
public interface Strategy { String processPayment(float price); }
-
接下来,创建这个接口的具体实现,如下所示:
public class Cash implements Strategy{ @Override public String processPayment(float price) { return String.format("%.2f", price); } } public class Card implements Strategy{ ... return String.format("%.2f", price + 0.25f); ... } public class Coupon implements Strategy{ ... return String.format("%.2f", price * 0.9f); ... }
-
现在添加以下类:
public class Payment { // Provide context for strategies private Strategy strategy; public Payment(Strategy strategy) { this.strategy = strategy; } public String employStrategy(float f) { return strategy.processPayment(f); } }
-
最后,添加将提供我们基本价格的单例类:
public class BasicPrice { private static BasicPrice basicPrice = new BasicPrice(); private float price; // Prevent more than one copy private BasicPrice() { } // Return only instance public static BasicPrice getInstance() { return basicPrice; } protected float getPrice() { return price; } protected void setPrice(float v) { price = v; } }
这就是我们需要创建模式所做的一切。使用单例是因为当前三明治的价格是需要只有一个实例并且在代码的任何地方都能访问到的东西。在我们构建用户界面并测试我们的模式之前,让我们快速查看一下策略类图:
从图中我们可以看到,活动包含了一个onClick()
回调。在我们了解这是如何工作的之前,我们需要创建一个带有三个操作按钮的布局,以测试我们的三种支付选项。按照以下步骤来实现这一点:
-
创建一个以水平线性布局为根的布局文件。
-
添加以下视图和内部布局:
<ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:scaleType="centerCrop" android:src="img/logo" /> <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingTop="@dimen/layout_paddingTop"> </RelativeLayout>
-
现在给相对布局添加按钮。前两个按钮看起来像这样:
<Button android:id="@+id/action_card" style="?attr/borderlessButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_gravity="end" android:gravity="center_horizontal" android:minWidth="@dimen/action_minWidth" android:padding="@dimen/padding" android:text="@string/card" android:textColor="@color/colorAccent" /> <Button android:id="@+id/action_cash" style="?attr/borderlessButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="end" android:layout_toStartOf="@id/action_card" android:gravity="center_horizontal" android:minWidth="@dimen/action_minWidth" android:padding="@dimen/padding" android:text="@string/cash" android:textColor="@color/colorAccent" />
-
第三个与第二个相同,除了以下例外:
<Button android:id="@+id/action_coupon" ... android:layout_toStartOf="@id/action_cash" ... android:text="@string/voucher" ... />
-
现在打开 Java 活动文件,扩展它,使其实现这个监听器:
public class MainActivity extends AppCompatActivity implements View.OnClickListener
-
接下来添加以下字段:
public BasicPrice basicPrice = BasicPrice.getInstance();
-
在
onCreate()
方法中包含以下这些行:// Instantiate action views Button actionCash = (TextView) findViewById(R.id.action_cash); Button actionCard = (TextView) findViewById(R.id.action_card); Button actionCoupon = (TextView) findViewById(R.id.action_coupon); // Connect to local click listener actionCash.setOnClickListener(this); actionCard.setOnClickListener(this); actionCoupon.setOnClickListener(this); // Simulate price calculation basicPrice.setPrice(1.5f);
-
最后添加
onClick()
方法,如下所示:@Override public void onClick(View view) { Payment payment; switch (view.getId()) { case R.id.action_card: payment = new Payment(new Card()); break; case R.id.action_coupon: payment = new Payment(new Coupon()); break; default: payment = new Payment((new Cash())); break; } // Output price String price = new StringBuilder() .append("Total cost : $") .append(payment.employStrategy(basicPrice.getPrice())) .append("c") .toString(); Toast toast = Toast.makeText(this, price, Toast.LENGTH_LONG); toast.show(); }
现在我们可以测试在设备或模拟器上的输出了:
策略模式可以应用于许多情况,并且在你开发几乎任何软件时,你都会遇到可以一次又一次应用它的情况。我们肯定会在这里再次讨论它。希望现在介绍它能够帮助你发现可以利用它的情况。
总结
在本章中,我们了解了如何充分利用 Android 布局。这包括决定哪种布局类型适合哪种用途,尽管还有很多其他类型,但线性布局和相对布局提供了非常多的可能布局的功能和灵活性。选择了一个布局之后,我们可以使用权重和重力属性来组织空间。通过使用百分比库和 PercentRelativeLayout,大大简化了为各种可能的屏幕尺寸设计布局的过程。
开发者在为可能运行我们应用的众多现实世界设备设计 Android 布局时面临的最大挑战。幸运的是,资源指定的使用使得这项工作变得轻松。
当我们有了可用的布局后,我们可以继续了解如何利用这个空间显示一些有用的信息。这将引导我们在下一章中探讨 recycler view 如何管理列表及其数据。
第五章:结构型模式
到目前为止,在这本书中,我们已经了解了用于保存和返回数据的模式,以及将对象组合成更大的对象的模式,但我们还没有考虑如何向用户提供选择的方式。
在规划我们的三明治制作应用时,我们理想情况下希望能为客户提供多种可能的食材选择。展示这些选择的最佳方式可能是通过列表,或者对于大量数据集合,一系列的列表。Android 通过回收视图(RecyclerView)很好地管理这些过程,它是一个列表容器和管理器,取代了之前的 ListView。这并不是说我们不应该使用普通的旧列表视图,在只需要短列表、简单文本列表几个项目的情况下,使用回收视图可能被认为是大材小用,列表视图通常是更好的选择。话虽如此,回收视图在管理数据方面要优越得多,特别是当它包含在协调器布局中时,可以保持内存占用小、滚动平滑,并允许用户拖放或滑动删除列表项。
为了了解如何完成所有这些工作,我们将构建一个界面,该界面将由用户从中选择的一系列食材列表组成。这将需要回收视图来持有列表,进而将介绍我们适配器模式。
在本章中,你将学习如何:
-
应用回收视图(RecyclerView)
-
应用协调器布局(CoordinatorLayout)
-
生成列表
-
翻译字符串资源
-
应用视图持有者(ViewHolder)
-
使用回收视图适配器(RecyclerView adapter)
-
创建适配器设计模式
-
构建桥接设计模式
-
应用外观模式(facade patterns)
-
使用模式来过滤数据
生成列表
回收视图是相对较新的添加项,取代了旧版本中的 ListView。它执行相同的功能,但数据管理效率要高得多,特别是对于非常长的列表。回收视图是 v7 支持库的一部分,需要在build.gradle
文件中编译,以及这里显示的其他内容:
compile 'com.android.support:appcompat-v7:24.1.1'compile 'com.android.support:design:24.1.1'compile 'com.android.support:cardview-v7:24.1.1'compile 'com.android.support:recyclerview-v7:24.1.1'
协调器布局将形成主活动的根布局,看起来会像这样:
<android.support.design.widget.CoordinatorLayoutandroid:id="@+id/content"android:layout_width="match_parent"android:layout_height="match_parent"></android.support.design.widget.CoordinatorLayout>
然后,回收视图可以被放置在布局内:
<android.support.v7.widget.RecyclerView
android:id="@+id/main_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
回收视图为我们提供了一个虚拟列表,但我们将从卡片视图中创建我们的列表。
列表项布局
使用卡片视图显示列表中的单个项目非常诱人,你可以找到许多这样的例子。然而,这种做法并不被谷歌推荐,并且有充分的理由。卡片设计用于显示大小不一的内容,而圆角和阴影只会让屏幕显得杂乱。当列表项大小相同并符合相同的布局时,它们应该显示为简单的矩形布局,有时用简单的分隔线隔开。
在本书的后面,我们将创建复杂的、可交互的列表项,所以现在我们只将图像和字符串作为我们的项目视图。
创建一个以水平线性布局为根的布局文件,并将这两个视图放在其中:
<ImageView
android:id="@+id/item_image"
android:layout_width="@dimen/item_image_size"
android:layout_height="@dimen/item_image_size"
android:layout_gravity="center_vertical|end"
android:layout_margin="@dimen/item_image_margin"
android:scaleType="fitXY"
android:src="img/placeholder" />
<TextView
android:id="@+id/item_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:paddingBottom="24dp"
android:paddingStart="@dimen/item_name_paddingStart"
tools:text="placeholder"
android:textSize="@dimen/item_name_textSize" />
我们在这里使用了tools
命名空间,稍后应该移除它,这样我们就可以在不编译整个项目的情况下看到布局的外观:
提示
你可能已经注意到,在旧设备上测试时,CardViews 的一些边距和填充看起来不同。与其创建替代布局资源,通常使用card_view:cardUseCompatPadding="true"
属性可以解决此问题。
我们在这里应用的文本大小和边距不是任意的,而是由材料设计指南指定的。
材料字体大小
在材料设计中,文本大小非常重要,且在特定上下文中只允许使用特定大小的文本。在当前示例中,我们为名称选择了 24sp,为描述选择了 16sp。一般来说,我们在材料设计应用程序中显示的几乎所有文本都将是 12、14、16、20、24 或 34sp 的大小。在选择使用哪种大小以及何时使用时,有一定的灵活性,但以下列表应提供良好的指导:
连接数据
Android 配备了SQLite库,这是一个创建和管理复杂数据库的强大工具。关于这个主题,可以轻松地填满整整一个章节甚至整本书。这里我们没有处理大量数据集,创建我们自己的数据类会更简单,希望也更清晰。
注意
如果你想了解更多关于 SQLite 的信息,可以在以下链接找到全面的文档:developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html
稍后我们将创建复杂的数据结构,但现在我们只需要了解设置是如何工作的,因此我们只创建三个条目。要添加这些,请创建一个名为Filling
的新 Java 类,如下完成:
public class Filling {
private int image;
private int name;
public Filling(int image, int name) {
this.image = image;
this.name = name;
}
}
这可以在主活动中这样定义:
static final Filling fillings[] = new Filling[3];
fillings[0] = new Filling(R.drawable.cheese, R.string.cheese);
fillings[1] = new Filling(R.drawable.ham, R.string.ham);
fillings[2] = new Filling(R.drawable.tomato, R.string.tomato);
如你所见,我们在strings.xml
文件中定义了我们的字符串资源:
<string name="cheese">Cheese</string>
<string name="ham">Ham</string>
<string name="tomato">Tomato</string>
这有两个很大的优势。首先,它允许我们保持视图和模型分离;其次,如果我们有朝一日将应用程序翻译成其他语言,现在只需要一个替代的strings
文件。实际上,Android Studio 使这个过程变得如此简单,值得花时间了解如何完成。
翻译字符串资源
Android Studio 提供了一个翻译编辑器,以简化提供替代资源的过程。正如我们为不同的屏幕尺寸创建指定文件夹一样,我们也为不同的语言创建替代的值目录。编辑器为我们管理这些操作,我们实际上并不需要了解太多,但知道这一点很有用:如果我们希望将应用翻译成意大利语,例如,编辑器将创建一个名为values-it
的文件夹,并将替代的strings.xml
文件放在其中。
要访问翻译编辑器,只需在项目资源管理器中右键点击现有的strings.xml
文件,并选择它。
尽管 RecyclerView 是一个在高效管理绑定数据方面非常出色的工具,但它确实需要相当多的设置。除了视图和数据之外,还需要两个其他元素来将数据绑定到我们的活动上,即布局管理器和数据适配器。
适配器和布局管理器
RecyclerView 通过使用RecyclerView.LayoutManager
和RecyclerView.Adapter
来管理其数据。可以将 LayoutManager 视为属于 RecyclerView 的一部分,它是与适配器通信的,而适配器则以以下图表所示的方式绑定到我们的数据:
创建布局管理器非常简单。只需按照以下两个步骤操作。
-
打开
MainActivity.Java
文件,并包含以下字段:RecyclerView recyclerView; DataAdapter adapter;;
-
然后,将以下行添加到
onCreate()
方法中:final ArrayList<Filling> fillings = initializeData(); adapter = new DataAdapter(fillings); recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(adapter);
这段代码很容易理解,但RecyclerView.setHasFixedSize(true)
命令的目的可能需要一些解释。如果我们提前知道列表总是相同长度,那么这个调用将使列表的管理更加高效。
要创建适配器,请按照以下步骤操作:
-
创建一个新的 Java 类,名为
DataAdapter
,并让它继承RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>
。 -
这将生成一个错误,点击红色的快速修复图标并实施建议的方法。
-
这三个方法应按照这里所示填写:
// Inflate recycler view @Override public DataAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { Context context = parent.getContext(); LayoutInflater inflater = LayoutInflater.from(context); View v = inflater.inflate(R.layout.item, parent, false); return new ViewHolder(v); } // Display data @Override public void onBindViewHolder(DataAdapter.ViewHolder holder, int position) { Filling filling = fillings.get(position); ImageView imageView = holder.imageView; imageView.setImageResource(filling.getImage()); TextView textView = holder.nameView; textView.setText(filling.getName()); } @Override @Overridepublic int getItemCount() { return fillings.size();}
-
最后,是 ViewHolder:
public class ViewHolder extends RecyclerView.ViewHolder { ImageView imageView; TextView nameView; public ViewHolder(View itemView) { super(itemView); imageView = (ImageView) itemView.findViewById(R.id.item_image); nameView = (TextView) itemView.findViewById(R.id.item_name); } }
ViewHolder通过只调用一次findViewById()
来加速长列表,这是一个资源密集型的过程。
该示例现在可以在模拟器或手机上运行,并且将产生类似于这里看到的输出:
显然,我们想要的填充物远不止三个,但从这个例子中可以很容易看出,我们可以根据需要添加更多。
我们在这里已经详细介绍了如何使用 RecyclerView,足以让我们在各种情况下实现一个。这里我们使用了一个 LinearLayoutManager 来创建我们的列表,但还有GridLayoutManager和StaggeredGridLayoutManager以非常类似的方式工作。
适配器模式
在我们一直研究的这个例子中,我们使用了适配器模式将我们的数据以DataAdapter
的形式与布局连接起来。这是一个现成的适配器,尽管它的工作原理很清晰,但它并没有告诉我们关于适配器结构或如何自己构建适配器的内容。
在很多情况下,Android 提供了内置的模式,这非常有用,但经常会有我们需要为自己创建的类适配器的时候,现在我们将看到如何做到这一点,以及如何创建相关的设计模式——桥接(bridge)。最好是从概念上了解这些模式开始。
适配器的作用可能是最容易理解的。一个好的类比就是当我们把电子设备带到其他国家时使用的物理适配器,那些国家的电源插座工作在不同的电压和频率上。适配器有两面,一面接受我们的插头,另一面适合插座。一些适配器甚至足够智能,可以接受多种配置,这正是软件适配器的工作原理。
在很多情况下,我们遇到的接口并不能像插头与插座那样完美匹配,适配器(adapter)就是最广泛采用的设计模式之一。我们之前看到,Android API 本身就使用了这种模式。
解决不兼容接口问题的一种方法是改变接口本身,但这可能导致代码非常混乱,并且类之间的联系像意大利面条一样复杂。适配器解决了这个问题,同时也允许我们在不真正破坏整体结构的情况下对软件进行大规模更改。
假设我们的三明治应用已经推出并且运行良好,但是后来我们送达的办公室改变了他们的楼层计划,从独立小办公室变成了开放式办公结构。之前我们使用建筑、楼层、办公室和办公桌字段来定位客户,但现在办公室字段不再有意义,我们必须相应地重新设计。
如果我们的应用程序稍微复杂一些,无疑会有许多地方引用和使用位置类,重写它们可能会非常耗时。幸运的是,适配器模式意味着我们可以非常轻松地适应这种变化。
这是原始的位置接口:
public interface OldLocation {
String getBuilding();
void setBuilding(String building);
int getFloor();
void setFloor(int floor);
String getOffice();
void setOffice(String office);
int getDesk();
void setDesk(int desk);
}
这是它的实现方式:
public class CustomerLocation implements OldLocation {
String building;
int floor;
String office;
int desk;
@Override
public String getBuilding() { return building; }
@Override
public void setBuilding(String building) {
this.building = building;
}
@Override
public int getFloor() { return floor; }
@Override
public void setFloor(int floor) {
this.floor = floor;
}
@Override
public String getOffice() { return office; }
@Override
public void setOffice(String office) {
this.office = office;
}
@Override
public int getDesk() { return desk; }
@Override
public void setDesk(int desk) {
this.desk = desk;
}
}
假设这些类已经存在,并且是我们希望适配的类,那么只需要一个适配器类和一些测试代码就可以将整个应用程序从旧系统转换到新系统:
-
适配器类:
public class Adapter implements NewLocation { final OldLocation oldLocation; String building; int floor; int desk; // Wrap in old interface public Adapter(OldLocation oldLocation) { this.oldLocation = oldLocation; setBuilding(this.oldLocation.getBuilding()); setFloor(this.oldLocation.getFloor()); setDesk(this.oldLocation.getDesk()); } @Override public String getBuilding() { return building; } @Override public void setBuilding(String building) { this.building = building; } @Override public int getFloor() { return floor; } @Override public void setFloor(int floor) { this.floor = floor; } @Override public int getDesk() { return desk; } @Override public void setDesk(int desk) { this.desk = desk; } }
-
测试代码:
TextView textView = (TextView)findViewById(R.id.text_view); OldLocation oldLocation = new CustomerLocation(); oldLocation.setBuilding("Town Hall"); oldLocation.setFloor(3); oldLocation.setDesk(14); NewLocation newLocation = new Adapter(oldLocation); textView.setText(new StringBuilder() .append(newLocation.getBuilding()) .append(", floor ") .append(newLocation.getFloor()) .append(", desk ") .append(newLocation.getDesk()) .toString());
尽管适配器模式非常有用,但它的结构非常简单,正如这里所示的图表:
适配器模式的关键在于适配器类实现新接口并包装旧接口的方式。
很容易看出这种模式如何应用于其他许多情况,在这些情况下,我们需要将一种接口转换为另一种接口。适配器是最有用和最常应用的结构型模式之一。在某种意义上,它与我们将遇到的下一个模式——桥接模式相似,因为它们都有一个用于转换接口的类。然而,正如我们接下来将看到的,桥接模式具有完全不同的功能。
桥接模式
适配器和桥接的主要区别在于,适配器是为了解决设计中出现的不兼容问题而构建的,而桥接是在之前构建的,其目的是将接口与其实现分离,这样我们就可以在不更改客户端代码的情况下修改甚至替换实现。
在以下示例中,我们将假设我们的三明治制作应用程序的用户可以选择开放或封闭的三明治。除了这一因素外,这些三明治在可以包含任意填充组合方面是相同的,尽管为了简化问题,只会有最多两个配料。这将演示如何将抽象类与其实现解耦,以便可以独立修改它们。
以下步骤解释了如何构建一个简单的桥接模式:
-
首先,创建一个像这样的接口:
public interface SandwichInterface { void makeSandwich(String filling1, String filling2); }
-
接下来,像这样创建一个抽象类:
public abstract class AbstractSandwich { protected SandwichInterface sandwichInterface; protected AbstractSandwich(SandwichInterface sandwichInterface) { this.sandwichInterface = sandwichInterface; } public abstract void make(); }
-
现在像这样扩展这个类:
public class Sandwich extends AbstractSandwich { private String filling1, filling2; public Sandwich(String filling1, String filling2, SandwichInterface sandwichInterface) { super(sandwichInterface); this.filling1 = filling1; this.filling2 = filling2; } @Override public void make() { sandwichInterface.makeSandwich(filling1, filling2); } }
-
然后创建两个具体类来表示我们选择的三明治:
public class Open implements SandwichInterface { private static final String DEBUG_TAG = "tag"; @Override public void makeSandwich(String filling1, String filling2) { Log.d(DEBUG_TAG, "Open sandwich " + filling1 + filling2); } } public class Closed implements SandwichInterface { private static final String DEBUG_TAG = "tag"; @Override public void makeSandwich(String filling1, String filling2) { Log.d(DEBUG_TAG, "Closed sandwich " + filling1 + filling2); } }
-
现在,可以通过向客户端代码中添加以下几行来测试此模式:
AbstractSandwich openSandwich = new Sandwich("Cheese ", "Tomato", new Open()); openSandwich.make(); AbstractSandwich closedSandwich = new Sandwich("Ham ", "Eggs", new Closed()); closedSandwich.make();
-
然后调试屏幕上的输出将与以下内容相匹配:
D/tag: Open sandwich Cheese Tomato D/tag: Closed sandwich Ham Eggs
这展示了该模式如何允许我们使用相同的抽象类方法以不同的方式制作三明治,但使用不同的桥接实现类。
适配器和桥接模式都通过创建清晰的结构来工作,我们可以使用这些结构来统一或分离类和接口,以解决出现的结构不兼容问题,或者在规划期间预测这些问题。从图解上观察,两者的区别变得更加明显:
大多数结构型模式(以及一般的设计模式)依赖于创建这些额外的层次来澄清代码。简化复杂结构无疑是设计模式最大的优点,而门面模式帮助我们简化代码的能力很少有模式能比肩。
门面模式
门面模式或许是最简单的结构型模式之一,易于理解和创建。顾名思义,它就像一个位于复杂系统前面的面孔。在编写客户端代码时,如果我们有一个门面来代表它,我们永远不必关心系统其余部分的复杂逻辑。我们只需要处理门面本身,这意味着我们可以设计门面以最大化简化。
将外观模式想象成在典型自动售货机上可能找到的简单键盘。自动售货机是非常复杂的系统,结合了各种机械和物理组件。然而,要操作它,我们只需要知道如何在它的键盘上输入一两个数字。键盘就是外观,它隐藏了所有背后的复杂性。我们可以通过考虑以下步骤中概述的假想自动售货机来演示这一点:
-
从创建以下接口开始:
public interface Product { int dispense(); }
-
接下来,像这样添加三个具体实现:
public class Crisps implements Product { @Override public int dispense() { return R.drawable.crisps; } } public class Drink implements Product { ... return R.drawable.drink; ... } public class Fruit implements Product { ... return R.drawable.fruit; ... }
-
现在添加外观类:
public class Facade { private Product crisps; private Product fruit; private Product drink; public Facade() { crisps = new Crisps(); fruit = new Fruit(); drink = new Drink(); } public int dispenseCrisps() { return crisps.dispense(); } public int dispenseFruit() { return fruit.dispense(); } public int dispenseDrink() { return drink.dispense(); } }
-
在适当的可绘制目录中放置合适的图像。
-
创建一个简单的布局文件,其中包含类似于这样的图像视图:
<ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="match_parent" />
-
向活动类中添加一个
ImageView
:ImageView imageView = (ImageView) findViewById(R.id.image_view);
-
创建一个外观:
Facade facade = new Facade();
-
然后通过类似于此处的调用测试输出:
imageView.setImageResource(facade.dispenseCrisps());
这构成了我们的外观模式。它非常简单,容易可视化:
当然,此示例中的外观模式可能看起来毫无意义。dispense()
方法所做的不过是显示一个图像,并不需要简化。然而,在一个更现实的模拟中,分发过程将涉及各种调用和检查,需要计算找零,检查库存可用性,以及设置多个伺服电机的动作。外观模式的优点是,如果我们要实施所有这些程序,我们不需要更改客户端代码或外观类中的任何一行。对dispenseDrink()
的单个调用将产生正确的结果,不管背后的逻辑有多复杂。
尽管外观模式非常简单,但在许多情况下它都非常有用,比如我们想要为复杂的系统提供一个简单且有序的接口。不那么简单但同样有用的是标准(或过滤)模式,它允许我们查询复杂的数据结构。
标准模式
标准设计模式为根据设定标准过滤对象提供了一种清晰且简洁的技术。它可能是一个非常强大的工具,接下来的练习将证明这一点。
在此示例中,我们将应用一个过滤模式来筛选一系列食材,并根据它们是否为素食以及产地来过滤它们:
-
从创建如下所示的过滤器接口开始:
public interface Filter { List<Ingredient> meetCriteria(List<Ingredient> ingredients); }
-
接着添加如下所示的配料类:
public class Ingredient { String name; String local; boolean vegetarian; public Ingredient(String name, String local, boolean vegetarian){ this.name = name; this.local = local; this.vegetarian = vegetarian; } public String getName() { return name; } public String getLocal() { return local; } public boolean isVegetarian(){ return vegetarian; } }
-
现在实现满足素食标准的过滤器:
public class VegetarianFilter implements Filter { @Override public List<Ingredient> meetCriteria(List<Ingredient> ingredients) { List<Ingredient> vegetarian = new ArrayList<Ingredient>(); for (Ingredient ingredient : ingredients) { if (ingredient.isVegetarian()) { vegetarian.add(ingredient); } } return vegetarian; } }
-
然后添加一个测试本地产品的过滤器:
public class LocalFilter implements Filter { @Override public List<Ingredient> meetCriteria(List<Ingredient> ingredients) { List<Ingredient> local = new ArrayList<Ingredient>(); for (Ingredient ingredient : ingredients) { if (Objects.equals(ingredient.getLocal(), "Locally produced")) { local.add(ingredient); } } return local; } }
-
再为非本地食材添加一个:
public class NonLocalFilter implements Filter { @Override public List<Ingredient> meetCriteria(List<Ingredient> ingredients) { List<Ingredient> nonLocal = new ArrayList<Ingredient>(); for (Ingredient ingredient : ingredients) { if (ingredient.getLocal() != "Locally produced") { nonLocal.add(ingredient); } } return nonLocal; } }
-
现在我们需要包含一个
AND
标准过滤器:public class AndCriteria implements Filter { Filter criteria; Filter otherCriteria; public AndCriteria(Filter criteria, Filter otherCriteria) { this.criteria = criteria; this.otherCriteria = otherCriteria; } @Override public List<Ingredient> meetCriteria(List<Ingredient> ingredients) { List<Ingredient> firstCriteria = criteria.meetCriteria(ingredients); return otherCriteria.meetCriteria(firstCriteria); } }
-
接着是一个
OR
标准:public class OrCriteria implements Filter { Filter criteria; Filter otherCriteria; public OrCriteria(Filter criteria, Filter otherCriteria) { this.criteria = criteria; this.otherCriteria = otherCriteria; } @Override public List<Ingredient> meetCriteria(List<Ingredient> ingredients) { List<Ingredient> firstCriteria = criteria.meetCriteria(ingredients); List<Ingredient> nextCriteria = otherCriteria.meetCriteria(ingredients); for (Ingredient ingredient : nextCriteria) { if (!firstCriteria.contains(ingredient)) { firstCriteria.add(ingredient); } } return firstCriteria; } }
-
现在,添加如下所示的小型数据集:
List<Ingredient> ingredients = new ArrayList<Ingredient>(); ingredients.add(new Ingredient("Cheddar", "Locally produced", true)); ingredients.add(new Ingredient("Ham", "Cheshire", false)); ingredients.add(new Ingredient("Tomato", "Kent", true)); ingredients.add(new Ingredient("Turkey", "Locally produced", false));
-
在主活动中,创建以下过滤器:
Filter local = new LocalFilter(); Filter nonLocal = new NonLocalFilter(); Filter vegetarian = new VegetarianFilter(); Filter localAndVegetarian = new AndCriteria(local, vegetarian); Filter localOrVegetarian = new OrCriteria(local, vegetarian);
-
创建一个带有基本文本视图的简单布局。
-
向主活动添加以下方法:
public void printIngredients(List<Ingredient> ingredients, String header) { textView.append(header); for (Ingredient ingredient : ingredients) { textView.append(new StringBuilder() .append(ingredient.getName()) .append(" ") .append(ingredient.getLocal()) .append("\n") .toString()); } }
-
现在可以使用类似于此处的调用测试该模式:
printIngredients(local.meetCriteria(ingredients), "LOCAL:\n"); printIngredients(nonLocal.meetCriteria(ingredients), "\nNOT LOCAL:\n"); printIngredients(vegetarian.meetCriteria(ingredients), "\nVEGETARIAN:\n"); printIngredients(localAndVegetarian.meetCriteria(ingredients), "\nLOCAL VEGETARIAN:\n"); printIngredients(localOrVegetarian.meetCriteria(ingredients), "\nENVIRONMENTALLY FRIENDLY:\n");
在设备上测试该模式应产生此输出:
我们在这里只应用了一些简单的标准,但我们同样可以轻松地包含有关过敏、卡路里、价格以及我们选择的任何其他信息,以及相应的过滤器。正是这种能够从多个标准创建单一标准的能力,使得这个模式如此有用和多变。它可以像这样视觉化地呈现:
过滤器模式,像许多其他模式一样,并没有做任何我们之前没有做过的事情。相反,它展示了执行熟悉和常见任务(如根据特定标准过滤数据)的另一种方式。只要我们为正确的任务选择正确的模式,这些经过验证的结构模式几乎必然会使最佳实践成为可能。
总结
在本章中,我们介绍了一些最常应用和最有用的结构模式。我们从框架如何将模型与视图分离开始,然后学习了如何使用 RecyclerView 及其适配器管理数据结构,以及这与适配器设计模式的相似之处。建立这种联系后,我们接着创建了一个示例,说明如何使用适配器来解决对象之间不可避免的兼容性问题,而我们随后构建的桥接模式则是在设计之初就预定好的。
这一章以非常实用的内容开始,最后通过深入探讨另外两个重要的结构模式作结:门面模式,用于简化结构的明显功能;以及标准模式,它处理数据集,返回经过筛选的对象集,像我们可能只应用一个标准那样简单地应用多个标准。
在下一章中,我们将探讨用户界面以及如何应用设计库来实现滑动和取消行为。我们还将重新审视工厂模式,并将其应用于我们的布局,使用自定义对话框来显示其输出。
第六章:激活模式
之前的章节作为扩展介绍,探讨了 Android 开发的实用性以及设计模式应用的理论。我们已经涵盖了 Android 应用许多基本组件,并了解了最有用的模式是如何构建的,但我们还没有将这两者结合起来。
在本章中,我们将构建应用的一个主要部分:成分选择菜单。这将涉及一个可滚动的填充物列表,可以选中、展开和关闭。在途中,我们还将看看可折叠工具栏以及其他一两个有用的支持库功能,为操作按钮、浮动操作按钮和警告对话框添加功能。
在这段代码的核心,我们将应用一个简单的工厂模式来创建每个成分。这将很好地展示这种模式如何将创建逻辑从客户类中隐藏起来。在本章中,我们将只创建一个填充类型的示例,以了解其实现方式,但相同的结构和过程稍后会在添加更多复杂性时使用。这将引导我们探索回收视图格式和装饰,如网格布局和分隔线。
然后,我们将继续生成并自定义一个警告对话框,通过点击按钮来实现。这将需要使用内置的构建器模式,并引导我们了解如何为膨胀布局创建自己的构建器模式。
在本章中,你将学习如何:
-
创建应用栏布局
-
应用可折叠工具栏
-
控制滚动行为
-
包含嵌套滚动视图
-
应用数据工厂
-
创建列表项视图
-
将文本视图转换为按钮
-
应用网格布局
-
添加分隔线装饰
-
配置操作图标
-
创建警告对话框
-
自定义对话框
-
添加第二个活动
-
应用滑动和关闭行为
-
创建布局构建器模式
-
在运行时创建布局
我们的应用用户需要某种方式来选择成分。我们当然可以向他们展示一个长长的列表,但这会既麻烦又不吸引人。显然,我们需要将成分分类。在以下示例中,我们将专注于这些组中的一个,这将有助于简化稍后考虑更复杂场景时的底层过程。我们将从创建必要的布局开始,首先从可折叠工具栏布局开始。
可折叠工具栏
工具栏能够方便地滑出是材料设计 UI 的一个常见特性,并为手机甚至笔记本电脑上有限的空间提供了优雅和聪明的利用方式。
如你所想,CollapsingToolbarLayout是设计支持库的一部分。它是AppBarLayout的子视图,后者是一个线性布局,专门为材料设计特性而设计。
折叠工具栏优雅地管理空间,也提供了一个展示吸引人图形和推广我们产品的好机会。它们实现起来不需要太多时间,而且很容易适应。
看它们如何工作的最佳方式是构建一个,以下步骤将展示如何进行:
-
开始一个新项目,并包含回收视图和设计支持库。
-
通过更改主题来移除操作栏:
Theme.AppCompat.Light.NoActionBar
-
打开
activity_main.xml
文件,并应用以下根布局:<android.support.design.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> </android.support.design.widget.CoordinatorLayout>
-
在此内部,添加这个
AppBarLayout
:<android.support.design.widget.AppBarLayout android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true"> </android.support.design.widget.AppBarLayout>
-
将此
CollapsingToolbarLayout
放在应用栏内:<android.support.design.widget.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" app:contentScrim="?attr/colorPrimary" app:layout_scrollFlags="scroll|exitUntilCollapsed|enterAlwaysCollapsed"> </android.support.design.widget.CollapsingToolbarLayout>
-
折叠工具栏的内容是以下两个视图:
<ImageView android:id="@+id/toolbar_image" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:scaleType="centerCrop" android:src="img/some_drawable" app:layout_collapseMode="parallax" /> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_collapseMode="pin" />
-
现在,在 app-bar 布局下方,添加这个回收视图:
<android.support.v7.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:scrollbars="vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-
最后,添加这个浮动操作按钮:
<android.support.design.widget.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/fab_margin_end" app:layout_anchor="@id/app_bar" app:layout_anchorGravity="bottom|end" />
提示
有时我们希望将状态栏设置为半透明,以便我们的应用栏图片能够显示在状态栏后面。这通过在 styles.xml 文件中添加以下两项来实现:
<item name="android:windowDrawsSystemBarBackgrounds">true</item> <item name="android:statusBarColor">@android:color/transparent</item>
在前面的章节中我们已经遇到了协调布局,并看到了它如何实现许多材料设计功能。AppBarLayout
做类似的事情,通常用作折叠工具栏的容器。
另一方面,CollapsingToolbarLayout 需要解释一两个要点。首先,使用 android:layout_height="wrap_content"
将根据其 ImageView 包含的图片高度产生不同的效果。这样做的目的是,当我们为不同的屏幕尺寸和密度设计替代布局时,我们可以相应地缩放此图像。这里配置的是小(480 x 854dp)240dpi 设备,高度为 192dp。当然,我们也可以在 dp 中设置布局高度,并在不同的 dimens.xml
文件中缩放此值。然而,我们仍然需要缩放图像,所以这个方法是一石二鸟。
关于折叠工具栏布局的另一个有趣点是我们可以控制它的滚动方式,正如你所想象的,这是通过 layout_scrollFlags 属性处理的。这里我们使用了 scroll
、exitUntilCollapsed
、enterAlwaysCollapsed
。这意味着工具栏永远不会从屏幕顶部消失,且当列表无法再向下滚动时,工具栏不会展开。
有五种滚动标志,它们是:
-
scroll
- 启用滚动 -
exitUntilCollapsed
- 当向上滚动时防止工具栏消失(省略此项,直到向下滚动时工具栏才会消失) -
enterAlways
- 列表向下滚动时工具栏展开 -
enterAlwaysCollapsed
- 工具栏仅从列表顶部展开 -
snap
- 工具栏直接定位而不是滑动
折叠工具栏内的图像视图几乎与我们可能见过的任何其他图像视图相同,除了可能有的 layout_collapseMode
属性。这个属性有两个可能的设置,pin
和 parallax
:
-
pin
- 列表和工具栏一起移动 -
视差
- 列表和工具栏分别移动
欣赏这些效果的最佳方式就是尝试一下。我们也可以将这些布局折叠模式之一应用于图片下方的工具栏,但由于我们希望工具栏保持屏幕显示,因此无需关心其折叠行为。
这里将包含我们数据的回收视图与本书前面使用的唯一区别在于包含以下这行:
app:layout_behavior="@string/appbar_scrolling_view_behavior"
这个属性是我们需要添加到任何位于应用栏下方的视图或视图组中的,以允许它们协调滚动行为。
这些简单的类在实现材料设计时为我们节省了大量工作,并让我们专注于提供功能。除了图片的大小,要创建一个在大数量可能设备上工作的布局,几乎不需要重构。
尽管这里我们使用了回收视图,但完全有可能在应用栏下方放置任意数量的视图和视图组。只要它们具有app:layout_behavior="@string/appbar_scrolling_view_behavior"
属性,它们就会与栏一起移动。有一个特别适合此目的的布局,那就是NestedScrollView。举个例子,它看起来像这样:
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<TextView
android:id="@+id/nested_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/nested_text_padding"
android:text="@string/some_text"
android:textSize="@dimen/nested_text_textSize" />
</android.support.v4.widget.NestedScrollView>
下一步逻辑上是创建一个布局来填充回收视图,但首先我们需要准备数据。在本章中,我们将开发一个应用程序组件,负责向用户展示特定类别(在本例中是奶酪)的配料列表。我们将使用工厂模式来创建这些对象。
应用数据工厂模式
在本节中,我们将应用工厂模式来创建类型为奶酪的对象。这将进而实现一个填充物接口。每个对象将由几个属性组成,如价格和热量值。其中一些值将在我们的列表项中展示,其他值则只能通过扩展视图或在代码中访问。
设计模式为数不多的缺点之一是很快就会累积大量的类。因此,在开始以下练习之前,请在java
目录中创建一个名为fillings
的新包。
按照以下步骤生成我们的奶酪工厂:
-
在
fillings
包中创建一个名为Filling
的新接口,并按照以下方式完成它:public interface Filling { String getName(); int getImage(); int getKcal(); boolean isVeg(); int getPrice(); }
-
接下来,创建一个实现
Filling
的抽象类,名为Cheese
,如下所示:public abstract class Cheese implements Filling { private String name; private int image; private String description; private int kcal; private boolean vegetarian; private int price; public Cheese() { } public abstract String getName(); public abstract int getImage(); public abstract int getKcal(); public abstract boolean getVeg(); public abstract int getPrice(); }
-
创建一个名为
Cheddar
的具体类,如下所示:public class Cheddar extends Cheese implements Filling { @Override public String getName() { return "Cheddar"; } @Override public int getImage() { return R.drawable.cheddar; } @Override public int getKcal() { return 130; } @Override public boolean getVeg() { return true; } @Override public int getPrice() { return 75; } }
-
按照与
Cheddar
类似的方式创建其他几个Cheese
类。
创建了工厂之后,我们需要一种方法来表示每一种奶酪。为此,我们将创建一个条目布局。
定位条目布局
为了保持界面整洁,我们将为回收视图列表创建一个非常简单的条目。它将只包含一个图片、一个字符串和一个用户添加配料到三明治的操作按钮。
初始项目布局将如下所示:
这可能看起来是一个非常简单的布局,但它比看上去要复杂得多。以下是三个视图的代码:
图片如下:
<ImageView
android:id="@+id/item_image"
android:layout_width="@dimen/item_image_size"
android:layout_height="@dimen/item_image_size"
android:layout_gravity="center_vertical|end"
android:layout_margin="@dimen/item_image_margin"
android:scaleType="fitXY"
android:src="img/placeholder" />
标题:
<TextView
android:id="@+id/item_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:paddingBottom="@dimen/item_name_paddingBottom"
android:paddingStart="@dimen/item_name_paddingStart"
android:text="@string/placeholder"
android:textSize="@dimen/item_name_textSize" />
操作按钮:
<Button
android:id="@+id/action_add"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|bottom"
android:layout_marginEnd="@dimen/action_marginEnd""
android:minWidth="64dp"
android:padding="@dimen/action_padding"
android:paddingEnd="@dimen/action_paddingEnd"
android:paddingStart="@dimen/action_paddingStart"
android:text="@string/action_add_text"
android:textColor="@color/colorAccent"
android:textSize="@dimen/action_add_textSize" />
值得一看的是这里管理各种资源的方式。以下是dimens.xml
文件:
<dimen name="item_name_paddingBottom">12dp</dimen>
<dimen name="item_name_paddingStart">24dp</dimen>
<dimen name="item_name_textSize">16sp</dimen>
<dimen name="item_image_size">64dp</dimen>
<dimen name="item_image_margin">12dp</dimen>
<dimen name="action_padding">12dp</dimen>
<dimen name="action_paddingStart">16dp</dimen>
<dimen name="action_paddingEnd">16dp</dimen>
<dimen name="action_marginEnd">12dp</dimen>
<dimen name="action_textSize">16sp</dimen>
<dimen name="fab_marginEnd">16dp</dimen>
很明显,这些属性中有几个携带相同的值,我们可能只需要五个就能达到同样的效果。然而,这可能会导致代码混淆,尤其是在后期进行修改时,尽管这种方法有些过分,但仍然存在一定的效率。操作按钮的填充和边距设置对于整个应用程序中的所有此类按钮都将相同,从它们的名称可以清晰地读取,并且只需要声明一次。同样,此布局中的文本和图像视图在此应用程序中是唯一的,因此也相应地命名。这也使得调整单个属性更加清晰。
最后,使用android:minWidth="64dp"
是材料规定,旨在确保所有这样的按钮宽度都能适应平均手指大小。
这完成了此活动的布局,并且我们的对象工厂也准备就绪,现在我们可以像之前一样,使用数据适配器和视图持有者填充我们的回收视图。
使用工厂与 RecyclerView
正如我们在本书前面简要看到的那样,RecyclerView 利用了一个内部的 LayoutManager。这进而通过适配器与数据集通信。这些适配器与我们之前在书中探讨的适配器设计模式完全相同。这个功能可能不是那么明显,但它充当数据集和回收视图的布局管理器之间的桥梁。适配器通过其 ViewHolder 跨过这座桥。适配器的工作与客户端代码整洁地分离,我们只需要几行代码就可以创建一个新的适配器和布局管理器。
考虑到这一点,我们的数据准备就绪,可以按照以下简单步骤快速组合一个适配器:
-
首先,在主包中创建这个新类:
public class DataAdapter extends RecyclerView.Adapter<DataAdapter.ViewHolder> {
-
它需要以下字段和构造函数:
private List<Cheese> cheeses; public DataAdapter(List<Cheese> cheeses) { this.cheeses = cheeses; }
-
现在,像这样将
ViewHolder
添加为一个内部类:public static class ViewHolder extends RecyclerView.ViewHolder { public ImageView imageView; public TextView nameView; public ViewHolder(View itemView) { super(itemView); imageView = (ImageView) itemView.findViewById(R.id.item_image); nameView = (TextView) itemView.findViewById(R.id.item_name); } }
-
有三个必须重写的方法。
onCreateViewHolder()
方法:@Override public DataAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { Context context = parent.getContext(); LayoutInflater inflater = LayoutInflater.from(context); View cheeseView = inflater.inflate(R.layout.item_view, parent, false); return new ViewHolder(cheeseView); }
-
onBindViewHolder()
方法:@Override public void onBindViewHolder(DataAdapter.ViewHolder viewHolder, int position) { Cheese cheese = cheeses.get(position); ImageView imageView = viewHolder.imageView; imageView.setImageResource(cheese.getImage()); TextView nameView = viewHolder.nameView; nameView.setText(cheese.getName()); }
-
getItemCount()
方法:@Override public int getItemCount() { return cheeses.size(); }
这样适配器就完成了,我们需要关心的就是将其连接到我们的数据和回收视图。这是在主活动的onCreate()
方法中完成的。首先,我们需要创建一个包含所有奶酪的列表。有了我们的模式,这非常简单。以下方法可以放在任何地方,但这里放在主活动中:
private ArrayList<Cheese> buildList() {
ArrayList<Cheese> cheeses = new ArrayList<>();
cheeses.add(new Brie());
cheeses.add(new Camembert());
cheeses.add(new Cheddar());
cheeses.add(new Emmental());
cheeses.add(new Gouda());
cheeses.add(new Manchego());
cheeses.add(new Roquefort());
return cheeses;
}
注意
需要注意的是,你需要从 Fillings 包中导入这些类。
我们现在可以通过适配器将这个连接到我们的回收视图,在主活动的onCreate()
方法中添加以下几行:
RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
ArrayList<Cheese> cheeses = buildList();
DataAdapter adapter = new DataAdapter(cheeses);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(adapter);
recyclerView.setHasFixedSize(true);
首先值得注意的是,所需的客户端代码非常少,而且非常易懂。不仅仅是设置回收视图和适配器的代码,还包括构建列表的代码。如果没有这种模式,我们最终可能会得到这样的代码:
cheeses.add(new Cheese("Emmental", R.drawable.emmental), 120, true, 65);
项目现在可以在设备上进行测试了。
我们在这里使用的线性布局管理器不是唯一可用的。还有另外两个管理器,一个用于网格布局,另一个用于交错布局。可以这样应用:
recyclerView.setLayoutManager(new StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL));
recyclerView.setLayoutManager(new GridLayoutManager(this, 2));
这只需要稍微调整布局文件,我们甚至可以提供替代布局并允许用户选择他们喜欢的布局。
从视觉角度来看,我们基本上已经准备就绪。然而,由于这样一个稀疏的项目设计,在项目之间添加分隔线可能会更好。这不像人们想象的那么简单,但这个过程简单而优雅。
添加分隔线
在回收视图之前,ListView 带有自己的分隔元素。而回收视图则没有。然而,这不应当被视为缺点,因为后者允许更大的灵活性。
添加一个非常窄的视图在项目布局底部以创建分隔线可能看起来很诱人,但这被认为是非常不好的做法,因为当项目移动或被移除时,分隔线也会随之移动。
回收视图使用内部类ItemDecoration来提供项目之间的分隔线,以及间距和突出显示。它还有一个非常有用的子类,即 ItemTouchHelper,当我们看到如何滑动和关闭卡片时会遇到它。
首先,按照以下步骤向我们的回收视图添加分隔线:
-
创建一个新的 ItemDecoration 类:
public class ItemDivider extends RecyclerView.ItemDecoration
-
包含这个 Drawable 字段:
Private Drawable divider;
-
接着是这个构造函数:
public ItemDivider(Context context) { final TypedArray styledAttributes = context.obtainStyledAttributes(ATTRS); divider = styledAttributes.getDrawable(0); styledAttributes.recycle(); }
-
然后重写
onDraw()
方法:@Override public void onDraw(Canvas canvas, RecyclerView parent, RecyclerView.State state) { int left = parent.getPaddingLeft(); int right = parent.getWidth() - parent.getPaddingRight(); int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View child = parent.getChildAt(i); RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); int top = child.getBottom() + params.bottomMargin; int bottom = top + divider.getIntrinsicHeight(); divider.setBounds(left, top, right, bottom); divider.draw(canvas); } }
-
现在,需要做的就是在
onCreate()
方法中实例化分隔线,在设置了LayoutManager
之后:recyclerView.addItemDecoration(new ItemDivider(this));
这段代码提供了我们项目之间的系统分隔线。项目装饰还可以非常简单地创建自定义分隔线。
按照以下两个步骤看看是如何完成的:
-
在
drawable
目录中创建一个名为item_divider.xml
的 XML 文件,内容如下:<?xml version="1.0" encoding="utf-8"?> <shape android:shape="rectangle"> <size android:height="1dp" /> <solid android:color="@color/colorPrimaryDark" /> </shape>
-
向
ItemDivider
类中添加第二个构造函数,如下所示:public ItemDivider(Context context, int resId) { divider = ContextCompat.getDrawable(context, resId); }
-
然后将活动中的分隔符初始化替换为此处:
recyclerView.addItemDecoration(new ItemDivider(this, R.drawable.item_divider));
当运行时,这两种技术将产生如下所示的结果:
提示
前面的方法是在视图之前绘制分隔符。如果您有一个花哨的分隔符,并希望其部分与视图重叠,那么您需要重写
onDrawOver()
方法,这将导致在视图之后绘制分隔符。
现在是时候为我们的项目添加一些功能了。我们将从考虑为我们的小悬浮操作按钮提供哪些功能开始。
配置悬浮操作按钮
到目前为止,我们的布局只提供了一个操作,即每个列表项上的添加操作按钮。这将用于包括用户最终的三明治填充。确保用户始终只需点击一次就能消费,因此我们将在活动中添加结账功能。
我们首先需要的是一个图标。图标最佳的来源可能是我们在书中早些时候使用的资产工作室。这是在项目中包含图标的好方法,主要是因为它自动为所有可用的屏幕密度生成版本。然而,图标的数量有限,没有结账篮子。在这里我们有两个选择:我们可以在网上找一个图标,或者我们可以自己设计一个。
网上有大量的符合材料设计规范的图标,谷歌也有自己的图标,可以在以下位置找到:
许多开发者喜欢设计自己的图形,而且总会有我们找不到所需图标的时候。谷歌还提供了图标设计的综合指南,可在以下位置找到:
无论您选择哪个选项,都可以通过按钮的src
属性添加,如下所示:
android:src="img/ic_cart"
创建了我们的图标后,现在需要考虑颜色。根据材料设计指南,操作和系统图标应与主文本或次文本颜色相同。它们不是如我们所想的两种灰色阴影,而是通过透明度级别定义的。这样做是因为在彩色背景上效果远比灰色阴影好。到目前为止,我们使用了默认的文本颜色,并没有在我们的styles.xml
文件中包含这一点。根据材料文本颜色的规则,这样做是很容易的,规则如下:
要为我们的主题添加主文本和次文本颜色,请在colors
文件中添加以下这些行:
<color name="text_primary_dark">#DE000000</color>
<color name="text_secondary_dark">#8A000000</color>
<color name="text_primary_light">#FFFFFFFF</color>
<color name="text_secondary_light">#B3FFFFFF</color>
然后根据背景阴影,在styles
文件中添加适当的行,例如:
<item name="android:textColorPrimary">@color/text_primary_light</item>
<item name="android:textColorSecondary">@color/text_secondary_light</item>
如果您使用了图像资源或下载了谷歌的材料图标之一,系统将自动将主文本颜色应用到我们的 FAB 图标上。否则,您需要直接为您的图标着色。
现在我们可以通过以下两个步骤激活工具栏和 FAB:
-
在主活动的
onCreate()
方法中添加以下几行代码:Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar);
-
在其活动的
onCreate()
方法中添加以下点击监听器:FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab); fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { // SYSTEM DISMISSES DIALOG } });
现在,当视图滚动时,FAB 图标和工具栏标题将可见并正确动画:
点击悬浮操作按钮(FAB)应将用户带到另一个活动,即结账活动。然而,用户可能误点击了按钮,因此我们首先应该弹出一个对话框,让用户确认选择。
对话框构建器
除了少数应用外,Android 对话框对所有应用都是必不可少的,它也是了解框架本身如何应用设计模式的好方法。在这个例子中,它是对话框构建器,它通过一系列 setter 来构建我们的对话框。
在当前情况下,我们真正需要的只是一个非常简单的对话框,允许用户确认他们的选择,但对话框构建是一个非常有趣的话题,因此我们将更详细地了解它是如何完成的,以及内置构建器模式是如何用于构建它们的。
我们即将构建的对话框,如果得到确认,将把用户带到另一个活动,因此在这样做之前,我们应该创建该活动。通过从项目资源管理器菜单中选择新建 | 活动 | 空白活动
可以轻松完成。这里我们称它为CheckoutActivity.java
。
创建此活动后,请按照以下两个步骤操作:
-
悬浮操作按钮的点击监听器将构建并显示我们的对话框。它相当长,所以创建一个名为
buildDialog()
的新方法:并在onCreate()
方法的底部添加以下两行:fab = (FloatingActionButton) findViewById(id.fab); buildDialog(fab);
-
然后像这样定义方法:
private void buildDialog(FloatingActionButton fab) { fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this); LayoutInflater inflater = MainActivity.this.getLayoutInflater(); builder.setTitle(R.string.checkout_dialog_title) .setMessage(R.string.checkout_dialog_message) .setIcon(R.drawable.ic_sandwich_primary) .setPositiveButton(R.string.action_ok_text, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { Intent intent = new Intent(MainActivity.this, CheckoutActivity.class); startActivity(intent); } }) .setNegativeButton(R.string.action_cancel_text, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { // SYSTEM DISMISSES DIALOG } }); AlertDialog dialog = builder.create(); dialog.show(); } }); }
对于这样一个简单的对话框,标题和图标是不必要的,这里包括它们只是为了示例。AlertDialog.Builder
提供了许多其他属性,并且可以在以下位置找到全面的指南:
developer.android.com/reference/android/app/AlertDialog.Builder.html
这为我们几乎可以想到的任何警告对话框提供了一种便捷的构建方式,但它有一些不足之处。例如,上述对话框使用默认主题给按钮文字上色。在我们的自定义主题中,将这种颜色应用到我们的对话框会很不错。通过创建自定义对话框,可以轻松实现这一点。
自定义对话框
如您所料,自定义对话框是用 XML 布局文件定义的,这与我们设计其他任何布局的方式相同。此外,我们可以在构建器链中填充此布局,这意味着我们可以在同一个对话框中组合自定义和默认功能。
要自定义我们的对话框,只需以下两个步骤:
-
首先,创建一个名为
checkout_dialog.xml
的新布局资源文件,并完成如下:<?xml version="1.0" encoding="utf-8"?> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:theme="@style/AppTheme"> <ImageView android:id="@+id/dialog_title" android:layout_width="match_parent" android:layout_height="@dimen/dialog_title_height" android:src="img/dialog_title" /> <TextView android:id="@+id/dialog_content" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="@dimen/dialog_message_padding" android:text="@string/checkout_dialog_message" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/text_secondary_dark" /> </LinearLayout>
-
然后,将
buildDialog()
方法编辑成与这里看到的一致。与之前方法的变化已被突出显示:private void buildDialog(FloatingActionButton fab) { fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this); LayoutInflater inflater = MainActivity.this.getLayoutInflater(); builder.setView(inflater.inflate(layout.checkout_dialog, null)) .setPositiveButton(string.action_ok_text, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { Intent intent = new Intent(MainActivity.this, CheckoutActivity.class); startActivity(intent); } }) .setNegativeButton(string.action_cancel_text, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { // System dismisses dialog } }); AlertDialog dialog = builder.create(); dialog.show(); Button cancelButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); cancelButton.setTextColor(getResources().getColor(color.colorAccent)); Button okButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); okButton.setTextColor(getResources().getColor(color.colorAccent)); } }); }
在这里,我们使用了AlertDialog.Builder
将视图设置为我们的自定义布局。这需要布局资源和父级,但在这个例子中,我们从监听器内部构建,所以它保持为null
。
在设备上测试时,输出应该类似于以下屏幕截图:
提示
值得注意的是,在为按钮定义字符串资源时,最好不要将整个字符串大写,只大写首字母。例如,以下定义创建了上一个示例中按钮上的文本:
<string name="action_ok_text">Eat now</string>
<string name="action_cancel_text">Continue</string>
在这个例子中,我们自定义了对话框的标题和内容,但仍然使用了提供的确定和取消按钮,我们可以将我们自己的自定义与对话框的许多设置器混合匹配。
在我们继续之前,我们将为回收视图提供另一种功能,即滑动并取消的行为。
添加滑动并取消操作
在这个特定的应用中,我们不太可能需要滑动并取消的行为,因为列表很短,允许用户编辑它们也没有太大的好处。然而,为了让我们了解这个重要且有用的功能是如何应用的,即使最终设计中不会包含它,我们也将在这里实现它。
滑动以及拖放操作主要由ItemTouchHelper管理,它是一种 RecyclerView.ItemDecoration 的类型。这个类提供的回调允许我们检测项目的移动和方向,并拦截这些操作,然后在代码中响应它们。
如您在此处所见,实现滑动并取消行为只需几个步骤:
-
首先,我们的列表现在将改变长度,因此删除这行代码
recyclerView.setHasFixedSize(true);
或者将其设置为false
。 -
保持
onCreate()
方法尽可能简单总是一个好主意,因为那里通常有很多事情发生。我们将创建一个单独的方法来初始化我们的项目触摸助手,并在onCreate()
中调用它。以下是该方法:private void initItemTouchHelper() { ItemTouchHelper.SimpleCallback callback = new ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) { @Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder viewHolder1) { return false; } @Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { int position = viewHolder.getAdapterPosition(); adapter.removeItem(position); } }; ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback); itemTouchHelper.attachToRecyclerView(recyclerView); }
-
现在将以下行添加到
onCreate()
方法中:InitItemTouchHelper();
尽管执行了半个函数的功能,
onCreate()
方法仍然保持简短和清晰:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(id.toolbar); setSupportActionBar(toolbar); final ArrayList<Cheese> cheeses = buildList(); adapter = new DataAdapter(cheeses); recyclerView = (RecyclerView) findViewById(id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.addItemDecoration(new ItemDivider(this)); recyclerView.setAdapter(adapter); initItemTouchHelper(); fab = (FloatingActionButton) findViewById(id.fab); buildDialog(fab); }
如果您在此时测试应用,您会注意到尽管项目在滑动时会从屏幕上消失,但间隙并没有关闭。这是因为我们还没有通知回收视图它已被移除。尽管这可以在initItemTouchHelper()
方法中完成,但它实际上属于适配器类,因为它使用了它的方法。在适配器中添加以下方法以完成此任务:
public void removeItem(int position) {
cheeses.remove(position);
notifyItemRemoved(position);
notifyItemRangeChanged(position, cheeses.size());
现在当移除一个项目时,回收视图列表将会重新排序:
在此示例中,用户可以左右滑动以关闭项目,这对我们这里的目的来说是可以的,但在很多情况下这种区分非常有用。许多移动应用程序使用向右滑动来接受一个项目,向左滑动来关闭它。这可以通过使用onSwiped()
方法的方向参数轻松实现。例如:
if (direction == ItemTouchHelper.LEFT) {
Log.d(DEBUG_TAG, "Swiped LEFT");
} else {
Log.d(DEBUG_TAG, "Swiped RIGHT");
}
在本章前面,我们使用了一个本地模式,即 AlertDialog.Builder 来构建布局。正如创建性模式的本意,背后的逻辑对我们是隐藏的,但构建器设计模式为从单个视图组件构建布局和视图组提供了一个非常好的机制,我们将在下面看到这一点。
构造布局构建器
到目前为止,在这本书中,我们构建的所有布局都是静态的 XML 定义。然而,正如你所期望的,完全可以从我们的源代码中动态构建和填充 UI。此外,Android 布局非常适合构建器模式,正如我们在警告对话框中所看到的,因为它们由一系列有序的小对象组成。
下面的示例将遵循构建器设计模式,从一系列预定义的布局视图中填充一个线性布局。像之前一样,我们将从接口构建到抽象和具体类。我们将创建两种布局项,标题或头条视图和内容视图。然后我们制作这些的具体示例,可以通过构建器来构建。因为所有视图都有一些共同的特征(在这种情况下是文本和背景颜色),我们将通过另一个接口来避免重复方法,这个接口有自己的具体扩展来处理这种着色。
为了更好地了解这是如何工作的,请启动一个新的 Android 项目,并按照以下步骤构建模型:
-
创建一个名为
builder
的内部包。将以下所有类添加到这个包中。 -
为我们的视图类创建以下接口:
public interface LayoutView { ViewGroup.LayoutParams layoutParams(); int textSize(); int content(); Shading shading(); int[] padding(); }
-
现在创建文本和背景颜色的接口,如下所示:
public interface Shading { int shade(); int background(); }
-
我们将创建
Shading
的具体示例。它们看起来像这样:public class HeaderShading implements Shading{ @Override public int shade() { return R.color.text_primary_dark; } @Override public int background() { return R.color.title_background; } } public class ContentShading implements Shading{ ... return R.color.text_secondary_dark; ... ... return R.color.content_background; ... }
-
现在我们可以创建我们想要的两种视图类型的抽象实现。这些应该符合以下要求:
public abstract class Header implements LayoutView { @Override public Shading shading() { return new HeaderShading(); } } public abstract class Content implements LayoutView { ... return new ContentShading(); ... }
-
接下来,我们需要创建这两种类型的具体类。首先是标题:
public class Headline extends Header { @Override public ViewGroup.LayoutParams layoutParams() { final int width = ViewGroup.LayoutParams.MATCH_PARENT; final int height = ViewGroup.LayoutParams.WRAP_CONTENT; return new ViewGroup.LayoutParams(width,height); } @Override public int textSize() { return 24; } @Override public int content() { return R.string.headline; } @Override public int[] padding() { return new int[]{24, 16, 16, 0}; } } public class SubHeadline extends Header { ... @Override public int textSize() { return 18; } @Override public int content() { return R.string.sub_head; } @Override public int[] padding() { return new int[]{32, 0, 16, 8}; } ...
-
然后是内容:
public class SimpleContent extends Content { @Override public ViewGroup.LayoutParams layoutParams() { final int width = ViewGroup.LayoutParams.MATCH_PARENT; final int height = ViewGroup.LayoutParams.MATCH_PARENT; return new ViewGroup.LayoutParams(width, height); } @Override public int textSize() { return 14; } @Override public int content() { return R.string.short_text; } @Override public int[] padding() { return new int[]{16, 18, 16, 16}; } } public class DetailedContent extends Content { ... final int height = ViewGroup.LayoutParams.WRAP_CONTENT; ... @Override public int textSize() { return 12; } @Override public int content() { return R.string.long_text; } ...
这样我们的模型就完成了。我们有两个单独的视图以及每种视图的颜色设置。现在我们可以创建一个助手类,按照我们希望的顺序组合这些视图。这里我们只需要两个,一个用于简单的输出,另一个用于更详细的布局。
构建器的样子如下:
public class LayoutBuilder {
public List<LayoutView> displayDetailed() {
List<LayoutView> views = new ArrayList<LayoutView>();
views.add(new Headline());
views.add(new SubHeadline());
views.add(new DetailedContent());
return views;
}
public List<LayoutView> displaySimple() {
List<LayoutView> views = new ArrayList<LayoutView>();
views.add(new Headline());
views.add(new SimpleContent());
return views;
}
}
此模式的类图如下:
正如构建器模式和其他一般模式所期望的,我们所做的一切工作都是为了将模型逻辑从客户端代码中隐藏起来,在我们的例子中,特别是当前活动和onCreate()
方法。
当然,我们可以在主 XML 活动提供的默认根视图组中扩展这些视图,但动态生成这些视图通常也很有用,特别是如果我们想要生成嵌套布局。
下一个活动演示了我们现在如何使用构建器动态扩展布局:
public class MainActivity extends AppCompatActivity {
TextView textView;
LinearLayout layout;
@Override
protected void onCreate(Bundle savedInstanceState) {
final int width = ViewGroup.LayoutParams.MATCH_PARENT;
final int height = ViewGroup.LayoutParams.WRAP_CONTENT;
super.onCreate(savedInstanceState);
layout = new LinearLayout(this);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setLayoutParams(new ViewGroup.LayoutParams(width, height));
setContentView(layout);
// COULD USE layoutBuilder.displaySimple() INSTEAD
LayoutBuilder layoutBuilder = new LayoutBuilder();
List<LayoutView> layoutViews = layoutBuilder.displayDetailed();
for (LayoutView layoutView : layoutViews) {
ViewGroup.LayoutParams params = layoutView.layoutParams();
textView = new TextView(this);
textView.setLayoutParams(params);
textView.setText(layoutView.content());
textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, layoutView.textSize());
textView.setTextColor(layoutView.shading().shade());
textView.setBackgroundResource(layoutView.shading().background());
int[] pad = layoutView.padding();
textView.setPadding(dp(pad[0]), dp(pad[1]), dp(pad[2]), dp(pad[3]));
layout.addView(textView);
}
}
}
您还需要以下方法,该方法用于从px
转换为dp
:
public int dp(int px) {
final float scale = getResources().getDisplayMetrics().density;
return (int) (px * scale + 0.5f);
}
在设备上运行时,将产生以下两种 UI 之一:
如预期的那样,客户端代码简单、简短且易于理解。
不必使用程序化布局或静态布局,两者可以混合使用。视图可以在 XML 中设计,然后像我们在这里用 Java 所做的那样进行扩展。我们可以甚至保持这里使用的相同模式。
这里还有很多内容可以介绍,比如如何使用适配器或桥接模式包含其他类型的视图,例如图片,但我们将在书中稍后介绍组合模式。现在,我们已经了解了布局构建器的工作原理以及它是如何将其逻辑与客户端代码分离的。
总结
本章内容相当丰富。我们从创建一个折叠工具栏和一个功能性的回收视图开始。我们了解了如何为布局的大部分添加基本功能,以及如何将工厂模式应用于特定案例。这引导我们探索构建器(内部和创建的)如何用于构建详细布局。
在下一章中,我们将进一步探讨如何响应用户活动,现在我们有了某些工作的控件和视图,我们将了解如何将它们连接到有用的逻辑。
第七章:组合模式
我们已经看到模式如何帮助我们组织代码,以及如何将其具体应用于 Android 应用,但我们一次只应用了一个模式。随着我们需要执行的任务变得更加复杂,我们将需要同时应用多个模式,比如装饰器和生成器,甚至将它们组合成混合模式,这正是我们将在本章中要做的事情。
我们将从考虑更复杂的用户界面(UI)及其背后的代码开始。这将要求我们更精确地思考我们实际希望应用程序做什么。这也将引导我们研究原型模式,它提供了一种非常有效的方法,可以从原始对象或克隆对象创建对象。
接下来,我们将探讨装饰器模式,看看它如何用于向现有类添加额外功能。通常被称为包装器,装饰器用于为现有代码提供附加功能。这对于我们的三明治制作应用特别有用,因为它允许我们包含如下选项:订购开放式三明治或者选择烤面包。这些本身不是配料,但三明治销售商可能希望提供这些服务。装饰器模式非常适合这项任务。
在简要了解了其他选择之后,我们构建了一个生成器模式作为我们系统的核心,并将其连接到一个用户界面(UI),以便用户可以组合一个简单的三明治,并选择选项和配料。然后,我们连接一个装饰器到这个生成器以提供更多选项。
在本章中,你将学习如何做到以下几点:
-
创建原型模式
-
创建装饰器模式
-
扩展装饰器
-
将生成器连接到 UI
-
管理复合按钮
-
组合模式
现在我们能够开始更多地考虑我们应用的细节以及它能做什么、应该做什么。我们需要考虑潜在客户,并设计出简单、易用的产品。功能需要易于访问且直观,最重要的是,用户需要用最少的点击次数就能构建出他们想要的三明治。稍后,我们将看到用户如何存储他们的最爱,以及我们如何为用户提供部分构建的三明治以进行自定义,而不是从头开始构建。现在,我们将看看如何对我们的三明治相关对象和类进行分类。
制定规范
在上一章中,我们使用工厂模式创建了一个简单的三明治配料对象列表,并将其连接到布局中。然而,我们只表示了一种填充类型。在创建更复杂的系统之前,我们需要规划我们的数据结构,为此我们需要考虑我们向用户呈现的选择。
首先,我们可以提供哪些选项来使这个过程简单、有趣且直观?以下是一个潜在用户可能希望从这类应用中获得的功能列表:
-
订购现成的三明治,无需定制
-
定制现成的三明治
-
从一些基本食材开始并逐步构建
-
订购或定制他们之前吃过的三明治
-
从零开始制作三明治
-
随时查看并编辑他们的三明治
之前,我们为奶酪创建了一个单独的菜单,但为每种食品类型提供一个类别可能是一个笨拙的解决方案:想要一个培根、生菜和番茄三明治的用户可能需要访问三个不同的菜单。我们有很多不同的方法可以解决这个问题,这在很大程度上是个人选择的问题。在这里,我们将尝试遵循我们自己制作三明治时可能会采取的过程,可以描述如下列表:
-
面包
-
黄油
-
内馅
-
配料
我所说的配料是指蛋黄酱、胡椒、芥末等。我们将把这些类别作为我们类结构的基础。如果它们都能属于同一个类类型会很不错,但有一两个细微的差别禁止这样做:
面包:没有人会订购没有面包的三明治;那就不叫三明治了,我们可能会认为它可以像其他任何食材一样处理。然而,我们将提供开放式三明治的选择,并且为了使情况复杂化,还有烤面包的选项。
黄油:人们可能会认为添加黄油是理所当然的,但有些顾客可能想要低脂涂抹酱,或者根本不要。幸运的是,有一个非常适合此目的的模式:装饰者模式。
内馅和配料:尽管如果这两个类都从同一个类扩展而来,它们很容易共享相同的属性和实例,但我们将分别处理它们,因为这样构建菜单会更清晰。
在这些规格到位之后,我们可以开始考虑顶级菜单的外观。我们将使用滑动抽屉导航视图,并提供以下选项:
这为我们大致展示了我们的目标。使用模式的一个优点是它们易于修改,这意味着我们可以更直观地处理开发,同时放心地知道即使是大规模的更改通常也只需编辑最少的代码。
我们的下一步是选择一个适合概述任务的合适模式。我们对工厂和建造者都很熟悉,也知道它们如何实现我们想要的功能,但还有一个创建型模式,即原型模式,也非常方便,尽管在这种情况下我们不会使用它,但将来我们可能会使用,你肯定也会遇到需要使用的时候。
原型模式
原型设计模式与其他创建型模式(如构建器和工厂)执行类似任务,但采用的方法截然不同。它不是重度依赖许多硬编码的子类,正如其名,原型从原始对象进行复制,大大减少了所需的子类数量和任何冗长的创建过程。
设置原型
当实例的创建在某种程度上是昂贵的时,原型最为有用。这可能是加载大文件、详细检查数据库,或是其他计算成本高昂的操作。此外,它允许我们将克隆对象与其原始对象解耦,使我们能够进行修改而无需每次重新实例化。在以下示例中,我们将使用首次创建时计算时间较长的函数来演示这一点:第 n 个素数和第 n 个斐波那契数。
从图解上看来,我们的原型将如下所示:
在我们的主应用中,由于昂贵的创建非常少,因此不需要原型模式。然而,在许多情况下它至关重要,不应被忽视。以下是应用原型模式的步骤:
-
我们将从以下抽象类开始:
public abstract class Sequence implements Cloneable { protected long result; private String id; public long getResult() { return result; } public String getId() { return id; } public void setId(String id) { this.id = id; } public Object clone() { Object clone = null; try { clone = super.clone(); } catch (CloneNotSupportedException e) { e.printStackTrace(); } return clone; } }
-
接下来,添加这个可克隆的具体类:
// Calculates the 10,000th prime number public class Prime extends Sequence { public Prime() { result = nthPrime(10000); } public static int nthPrime(int n) { int i, count; for (i = 2, count = 0; count < n; ++i) { if (isPrime(i)) { ++count; } } return i - 1; } // Test for prime number private static boolean isPrime(int n) { for (int i = 2; i < n; ++i) { if (n % i == 0) { return false; } } return true; } }
-
再添加一个
Sequence
类,用于斐波那契数列,如下所示:// Calculates the 100th Fibonacci number public class Fibonacci extends Sequence { public Fibonacci() { result = nthFib(100); } private static long nthFib(int n) { long f = 0; long g = 1; for (int i = 1; i <= n; i++) { f = f + g; g = f - g; } return f; } }
-
接下来,创建缓存类,如下所示:
public class SequenceCache { private static Hashtable<String, Sequence> sequenceHashtable = new Hashtable<String, Sequence>(); public static Sequence getSequence(String sequenceId) { Sequence cachedSequence = sequenceHashtable.get(sequenceId); return (Sequence) cachedSequence.clone(); } public static void loadCache() { Prime prime = new Prime(); prime.setId("1"); sequenceHashtable.put(prime.getId(), prime); Fibonacci fib = new Fibonacci(); fib.setId("2"); sequenceHashtable.put(fib.getId(), fib); } }
-
在你的布局中添加三个
TextViews
,然后在你的MainActivity
的onCreate()
方法中添加代码。 -
在客户端代码中添加以下几行:
// Load the cache once only SequenceCache.loadCache(); // Lengthy calculation and display of prime result Sequence prime = (Sequence) SequenceCache.getSequence("1"); primeText.setText(new StringBuilder() .append(getString(R.string.prime_text)) .append(prime.getResult()) .toString()); // Lengthy calculation and display of Fibonacci result SSequence fib = (Sequence) SequenceCache.getSequence("2"); fibText.setText(new StringBuilder() .append(getString(R.string.fib_text)) .append(fib.getResult()) .toString());
如你所见,前面的代码创建了模式,但并未演示它。一旦加载,缓存就可以创建我们之前昂贵的输出的即时副本。此外,我们可以修改副本,当我们有一个复杂的对象并且只想修改一个或两个属性时,原型非常有用。
应用原型
考虑一个在社交媒体网站上可能找到的详细用户资料。用户修改诸如图片和文本等详细信息,但所有资料的整体结构是相同的,这使得它成为原型模式的理想选择。
为了将这一原则付诸实践,请在客户端源代码中包含以下代码:
// Create a clone of already constructed object
Sequence clone = (Fibonacci) new Fibonacci().clone();
// Modify the resultlong result = clone.getResult() / 2;
// Display the result quickly
cloneText.setText(new StringBuilder() .append(getString(R.string.clone_text)) .append(result) .toString());
在许多场合,原型是一个非常实用的模式,尤其是当我们需要创建昂贵的对象或面临子类激增的情况时。然而,这并不是唯一有助于减少过度子类化的模式,这引导我们了解另一个设计模式:装饰器。
装饰器设计模式
无论对象创建的成本如何,我们的模型性质有时仍会迫使产生不合理数量的子类,这正是装饰器模式极其方便之处。
以我们三明治应用中的面包为例。我们希望提供几种类型的面包,但除此之外,我们还希望提供选择烤过、开口的三明治以及一系列涂抹酱。为每种面包类型创建烤过和开口版本,项目很快就会变得难以管理。装饰器允许我们在运行时向对象添加功能和属性,而无需对原始类结构进行任何更改。
设置装饰器
有人可能会认为像烤过和开口这样的属性可以作为bread类的一部分包含,但这本身可能导致代码越来越难以管理。假设我们希望bread和filling继承自同一个类,比如ingredient。这是有道理的,因为它们有共同的属性,比如价格和热量值,我们希望它们都通过相同的布局结构显示。然而,将烤过和涂抹这样的属性应用于填充物是没有意义的,这会导致冗余。
装饰器解决了这两个问题。要了解如何应用,请按照以下步骤操作:
-
从创建这个抽象类来表示所有面包开始:
public abstract class Bread { String description; int kcal; public String getDescription() { return description; } public int getKcal() { return kcal; } }
-
接下来,创建具体实例,如下所示:
public class Bagel extends Bread { public Bagel() { description = "Bagel"; kcal = 250; } } public class Bun extends Bread { public Bun() { description = "Bun"; kcal = 150; } }
-
现在我们需要一个抽象的装饰器,它看起来像这样:
// All bread treatments extend from this public abstract class BreadDecorator extends Bread { public abstract String getDescription(); public abstract int getKcal(); }
-
我们需要四个此类装饰器的扩展来表示两种类型的涂抹酱以及开口和烤过的三明治。首先,是
Butter
装饰器:public class Butter extends BreadDecorator { private Bread bread; public Butter(Bread bread) { this.bread = bread; } @Override public String getDescription() { return bread.getDescription() + " Butter"; } @Override public int getKcal() { return bread.getKcal() + 50; } }
-
其他三个类中,只有 getter 返回的值不同。它们如下:
public class LowFatSpread extends BreadDecorator { return bread.getDescription() + " Low fat spread"; return bread.getKcal() + 25; } public class Toasted extends BreadDecorator { return bread.getDescription() + " Toasted"; return bread.getKcal() + 0; } public class Open extends BreadDecorator { return bread.getDescription() + " Open"; return bread.getKcal() / 2; }
这样就完成了装饰器模式的设置。我们现在需要做的就是将其连接到某种工作接口。稍后,我们将使用菜单选择面包,然后使用对话框添加装饰。
应用装饰器
用户将需要在黄油和低脂涂抹酱之间做出选择(尽管通过添加另一个装饰器可以包含一个不涂抹的选项),但可以选择让三明治既烤过又开口。
现在,我们将使用调试器通过向管理活动的onCreate()
方法添加如下几行来测试各种组合。注意对象是如何链式调用的:
Bread bagel = new Bagel();
LowFatSpread spread = new LowFatSpread(bagel);
Toasted toast = new Toasted(spread);
Open open = new Open(toast);
Log.d(DEBUG_TAG, open.getDescription() + " " + open.getKcal());
这应该会产生如下输出:
D/tag: Bagel Low fat spread 275
D/tag: Bun Butter Toasted 200
D/tag: Bagel Low fat spread Toasted Open 137
在图表上,我们的装饰器模式可以这样表示:
装饰器设计模式是一个极其有用的开发工具,可以应用于多种情况。除了帮助我们保持可管理的具体类数量,我们还可以让面包超类从与填充物类相同的接口继承,并仍然表现出不同的行为。
扩展装饰器
将前面的模式扩展到填充物同样很简单。我们可以创建一个名为Fillings
的抽象类,它除了名字与 Bread 相同,具体扩展如下所示:
public class Lettuce extends Filling {
public Lettuce() {
description = "Lettuce";
kcal = 1;
}
}
我们甚至可以创建针对填充物(如点双份)的特定装饰器。FillingDecorator
类将从Filling
扩展而来,但除此之外与BreadDecorator
相同,具体的例子如下所示:
public class DoublePortion extends FillingDecorator {
private Filling filling;
public DoublePortion(Filling filling) {
this.filling = filling;
}
@Override
public String getDescription() {
return filling.getDescription() + " Double portion";
}
@Override
public int getKcal() {
// Double the calories
return filling.getKcal() * 2;
}
}
我们将装饰器串联起来生成复合字符串的方式与构造者工作的方式非常相似,实际上我们可以使用这个模式生成整个三明治及其所有配料。然而,通常情况下,这项任务有多个候选者。正如本书前面所看到的,构造者和抽象工厂都能生产复杂对象。在我们决定模型之前,需要找到最适合的模式,或者更好的是,模式的组合。
构造者模式似乎是最明显的选择,因此我们首先来看看这个模式。
三明治构造者模式
构造者模式专为将简单对象组合成一个复杂对象而设计,这形成了制作三明治的完美类比。在本书前面我们已经遇到了一个通用的构造者模式,但现在我们需要将其适配为一个特定功能。此外,我们还将把模式连接到一个工作用户界面,以便根据用户选择构建三明治,而不是之前示例中的套餐。
应用模式
为了保持代码简短和简单,我们每种食材类型只创建两个具体类,我们将使用按钮和文本视图来显示输出,而不是回收视图。只需按照以下步骤创建我们的三明治构造者模式:
-
从以下接口开始:
public interface Ingredient { public String description(); public int kcal(); }
-
创建这两个
Ingredient
的抽象实现。现在它们是空的,但稍后我们会需要它们:public abstract class Bread implements Ingredient { // Base class for all bread types } public abstract class Filling implements Ingredient { // Base class for all possible fillings }
-
我们将只需要每种食材类型的两个具体示例。下面是其中一个,
Bagel
类:public class Bagel extends Bread { @Override public String description() { return "Bagel"; } @Override public int kcal() { return 250; } }
-
创建另一个名为
Bun
的Bread
类和两个名为Egg
和Cress
的Filling
类。 -
为这些类提供您喜欢的任何描述和卡路里值。
-
现在我们可以创建三明治类本身,如下所示:
public class Sandwich { private List<Ingredient> ingredients = new ArrayList<Ingredient>(); // Add individual ingredients public void addIngredient(Ingredient i) { ingredients.add(i); } // Calculate total calories public int getKcal() { int kcal = 0; for (Ingredient ingredient : ingredients) { kcal += ingredient.kcal(); } return kcal; } // Return all ingredients when selection is complete public String getSandwich() { String sandwich = ""; for (Ingredient ingredient : ingredients) { sandwich += ingredient.description() + "\n"; } return sandwich; } }
-
三明治构造者类不像之前的示例那样构建套餐,而是用于按需添加食材。如下所示:
public class SandwichBuilder { public Sandwich build(Sandwich sandwich, Ingredient ingredient) { sandwich.addIngredient(ingredient); return sandwich; } }
这完成了模式本身,但在我们继续创建用户界面之前,需要处理空抽象类Bread
和Filling
。它们看似完全多余,但我们之所以这样做有两个原因。
首先,通过在公共接口中定义它们的方法description()
和kcal()
,我们可以更容易地创建既不是填充物也不是面包的食材,只需实现接口本身即可。
要了解如何操作,请将以下类添加到项目中:
public class Salt implements Ingredient {
@Override
public String description() {
return "Salt";
}
@Override
public int kcal() {
return 0;
}
}
这给我们带来了以下的类结构:
包含这些抽象类的第二个原因更有趣。上一个示例中的BreadDecorator
类直接与抽象的Bread
类一起工作,并且通过保持该结构,我们可以轻松地将装饰器连接到我们的成分类型。我们很快就会继续这个话题,但首先我们要构建一个 UI 来运行我们的三明治构建器。
连接到 UI
在这个演示中,我们有两种类型的填充和两种面包。他们可以选择任意多或少的填充,但只能选择一种面包,这使得选择成为使用复选框和单选按钮的良好候选者。还有一个添加盐的选项,这种二元选择非常适合开关小部件。
首先,我们需要一个布局。以下是所需的步骤:
-
从垂直线性布局开始。
-
然后像这样包括单选按钮组:
<RadioGroup android:layout_width="fill_parent" android:layout_height="wrap_content" android:orientation="vertical"> <RadioButton android:id="@+id/radio_bagel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="false" android:paddingBottom="@dimen/padding" android:text="@string/bagel" /> <RadioButton android:id="@+id/radio_bun" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="true" android:paddingBottom="@dimen/padding" android:text="@string/bun" /> </RadioGroup>
-
接下来,包括复选框:
<CheckBox android:id="@+id/check_egg" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="false" android:paddingBottom="@dimen/padding" android:text="@string/egg" /> <CheckBox android:id="@+id/check_cress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="false" android:paddingBottom="@dimen/padding" android:text="@string/cress" />
-
然后添加开关:
<Switch android:id="@+id/switch_salt" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="false" android:paddingBottom="@dimen/padding" android:paddingTop="@dimen/padding" android:text="@string/salt" />
-
这是一个内部相对布局,包含以下操作按钮:
<TextView android:id="@+id/action_ok" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_gravity="end" android:background="?attr/selectableItemBackground" android:clickable="true" android:gravity="center_horizontal" android:minWidth="@dimen/action_minWidth" android:onClick="onActionOkClicked" android:padding="@dimen/padding" android:text="@android:string/ok" android:textColor="@color/colorAccent" /> <TextView android:id="@+id/action_cancel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="end" android:layout_toStartOf="@id/action_ok" android:background="?attr/selectableItemBackground" android:clickable="true" android:gravity="center_horizontal" android:minWidth="@dimen/action_minWidth" android:padding="@dimen/padding" android:text="@string/action_cancel_text" android:textColor="@color/colorAccent" />
注意 OK 按钮中使用的android:onClick="onActionOkClicked"
。这可以代替点击监听器,并标识拥有活动中的方法,当点击视图时会被调用。这是一种非常方便的技术,尽管它确实模糊了模型和视图之间的界限,并且可能容易产生错误。
在我们添加这种方法之前,需要声明并实例化一个或两个字段和视图。按照以下步骤完成练习:
-
在类中包括以下字段声明:
public SandwichBuilder builder; public Sandwich sandwich; private RadioButton bagel; public CheckBox egg, cress; public Switch salt; public TextView order;
-
像这样实例化小部件:
bagel = (RadioButton) findViewById(R.id.radio_bagel); egg = (CheckBox) findViewById(R.id.check_egg); cress = (CheckBox) findViewById(R.id.check_cress); salt = (Switch) findViewById(R.id.switch_salt); order = (TextView) findViewById(R.id.text_order);
-
现在我们可以添加我们在 XML 布局中声明的
onActionOkClicked()
方法:public void onActionOkClicked(View view) { builder = new SandwichBuilder(); sandwich = new Sandwich(); // Radio button group if (bagel.isChecked()) { sandwich = builder.build(sandwich, new Bagel()); } else { sandwich = builder.build(sandwich, new Bun()); } // Check boxes if (egg.isChecked()) { sandwich = builder.build(sandwich, new Egg()); } if (cress.isChecked()) { sandwich = builder.build(sandwich, new Cress()); } // Switch if (salt.isChecked()) { sandwich = builder.build(sandwich, new Salt()); } // Display output order.setText(new StringBuilder() .append(sandwich.getSandwich()) .append("\n") .append(sandwich.getKcal()) .append(" kcal") .toString()); }
我们现在可以在设备上测试这段代码,尽管成分数量较少,但应该清楚这是如何让用户构建他们选择的三明治:
多个小部件
我们只需要包括更多的成分和一个更复杂的 UI 来处理这个问题。尽管如此,原则将保持不变,相同的结构和逻辑可以应用。
尽管有潜力,但前面的示例缺少了我们之前看到的装饰性功能,例如提供烤面包品种和低脂涂抹酱。幸运的是,将装饰器附加到我们的面包和填充类是一个简单的任务。在我们这样做之前,我们将快速查看为什么构建器不是唯一能够执行此任务的可候选模式。
选择模式
检查以下比较构建器和抽象工厂的图:
构建器和抽象工厂模式之间的比较
尽管方法不同,构建器和抽象工厂模式之间有惊人的相似之处,它们执行类似的功能。我们可以很容易地使用抽象工厂来完成这项任务。在添加或修改产品时,工厂更具灵活性,结构上也稍微简单一些,但两种模式之间有一个重要的区别,这真正决定了我们的选择。
工厂和构建器都生产对象,但主要区别在于工厂在每次请求时返回其产品。这就像一次送来一个三明治配料。而构建器则在所有产品选择完毕后一次性构建其输出,这更像制作和送达三明治的行为。这就是为什么在这种情况下构建器模式提供最佳解决方案的原因。做出这个决定后,我们可以坚持使用前面的代码,并添加一些额外的功能。
添加装饰器
众所周知,增加进一步功能的最佳方式之一是使用装饰器模式。我们已经了解了它们是如何工作的,现在我们可以将一个添加到我们的简单三明治构建器中。单个装饰在结构上几乎相同,只是它们返回的值不同,因此我们只需创建一个作为示例。
附加模式
按以下步骤添加提供烤三明治的选项:
-
打开空的
Bread
类,并像这样完成它:public abstract class Bread implements Ingredient { String decoration; int decorationKcal; public String getDecoration() { return decoration; } public int getDecorationKcal() { return decorationKcal; } }
-
创建一个像这里找到的
BreadDecorator
类:public abstract class BreadDecorator extends Bread { public abstract String getDecoration(); public abstract int getDecorationKcal(); }
-
现在添加具体的装饰器本身:
public class Toasted extends BreadDecorator { private Bread bread; public Toasted(Bread bread) { this.bread = bread; } @Override public String getDecoration() { return "Toasted"; } @Override public int getDecorationKcal() { return 0; } // Required but not used @Override public String description() { return null; } @Override public int kcal() { return 0; } }
使用装饰器不仅可以最小化我们需要创建的子类数量,它还提供了一个也许更有用的功能,即允许我们包含诸如烤制和/或开放等选项,这些严格来说不是配料,这有助于保持我们的类有意义。
显然,我们现在可以添加任意多的此类装饰,但首先我们需要对主源代码进行一两个更改,以便看到装饰的实际效果。
将模式连接到 UI
按照以下简单步骤编辑主 XML 布局和 Java 活动,以实现这一点:
-
在单选按钮组下面添加以下开关:
<Switch android:id="@+id/switch_toasted" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="false" android:paddingBottom="@dimen/padding" android:paddingTop="@dimen/padding" android:text="@string/toasted" />
-
打开
MainActivity
类,并提供以下两个字段:public Switch toasted; public Bread bread;
-
实例化小部件如下:
toasted = (Switch) findViewById(R.id.switch_toasted);
-
在
onActionOkClicked()
方法中添加以下方法变量:String toast; int extraKcal = 0;
-
在单选按钮下面添加这段代码:
// Switch : Toasted if (toasted.isChecked()) { Toasted t = new Toasted(bread); toast = t.getDecoration(); extraKcal += t.getDecorationKcal(); } else { toast = ""; }
-
最后,像这样修改文本输出代码:
order.setText(new StringBuilder() .append(toast + " ") .append(sandwich.getSandwich()) .append("\n") .append(sandwich.getKcal() + extraKcal) .append(" kcal") .append("\n") .toString());
这就是向现有模式添加装饰器并使其成为我们 UI 工作部分所需的一切。
提示
请注意,虽然这里将填充类重构为更美味的内容,但代码保持不变。从变量到类和包,都可以使用Shift + F6进行重构。这也会重命名所有出现、调用,甚至包括获取器和设置器。要重命名整个项目,只需在 Android Studio 项目文件夹中重命名目录,然后从文件菜单中打开它。
作为 UML 类图,我们可以这样表达这个新的结构:
这涵盖了使用简单设计模式连接模型和视图的基本过程。然而,我们的工作使得主活动看起来相当混乱和复杂,这是我们需要避免的。在这里实现这一点并不是必须的,因为这仍然是一个非常简单的程序。但是,有时客户端代码会因监听器和各种回调而变得非常混乱,了解如何最好地使用模式来整理这些内容是很有用的。
对于这类事情,外观模式是最有用的,它快速且易于实现。我们之前已经遇到过这种模式,在这里实现它留给读者作为练习。类结构大致如下:
概述
在本章中,我们了解了如何结合设计模式来执行复杂任务。我们创建了一个构建器,允许用户构建他们选择的三明治,并通过装饰器模式进行定制。我们还探索了另一个重要的模式——原型模式,并了解了在处理大型文件或缓慢进程时它有多么重要。
除了深入探讨设计模式的概念,本章还包含了更实际的方面,如设置、读取和响应复合按钮(如开关和复选框),这是开发更复杂系统的重要步骤。
在下一章中,我们将更深入地了解如何通过各种 Android 通知工具与用户进行通信,例如小吃栏,以及服务和广播在 Android 开发中的作用。
第八章:组合模式
我们已经看到如何使用模式来操作、组织和呈现数据,但这些数据是短暂的,我们还没有考虑如何确保数据从一个会话持续到下一个会话。在本章中,我们将探讨如何使用内部数据存储机制来实现这一点。特别是,我们将探索用户如何保存他们的偏好设置,使应用程序更简单、更有趣。在我们开始之前,本章将首先检查组合模式及其用途,尤其是在构建类似于 Android UIs 这样的层次结构时。
在本章中,你将学习如何做到以下几点:
-
构建组合模式
-
使用组合器创建布局
-
使用静态文件
-
编辑应用程序文件
-
存储用户偏好
-
理解活动生命周期
-
添加唯一标识符
我们可以将设计模式应用于 Android 项目的一个最直接的方法是布局膨胀,在第六章《激活模式》中,我们使用了一个构建器模式来膨胀一个简单的布局。这个例子有一些严重的不足之处。它只处理文本视图,并没有考虑嵌套布局。为了使动态布局膨胀对我们真正有用,我们需要能够在布局层次的任何级别上包含任何类型的控件或视图,这正是组合设计模式发挥作用的地方。
组合模式
初看起来,组合模式可能与构建器模式非常相似,因为它们都从小型组件构建复杂对象。然而,这些模式在方法上有一个显著的区别。构建器以非常线性的方式工作,一次添加一个对象。而组合模式可以添加对象组以及单个对象。更重要的是,它以这样的方式添加,即客户端可以添加单个对象或对象组,而无需关心它正在处理哪个。换句话说,我们可以使用完全相同的代码添加完成的布局、单个视图或视图组。
除了能够组合分支数据结构的能力之外,隐藏客户端正在操作的对象的细节是使组合器模式如此强大的原因。
在创建布局组合器之前,我们将先看看这个模式本身,应用于一个非常简单的模型,以便我们更好地理解模式的工作原理。这就是整体结构。如您所见,它在概念上非常简单。
按照以下步骤构建我们的组合模式:
-
从一个接口开始,该接口可以表示单个组件和组件集合,如下所示:
public interface Component { void add(Component component); String getName(); void inflate(); }
-
添加这个类以扩展单个组件的接口:
public class Leaf implements Component { private static final String DEBUG_TAG = "tag"; private String name; public Leaf(String name) { this.name = name; } @Override public void add(Component component) { } @Override public String getName() { return name; } @Override public void inflate() { Log.d(DEBUG_TAG, getName()); } }
-
接下来为集合添加类:
public class Composite implements Component { private static final String DEBUG_TAG = "tag"; // Store components List<Component> components = new ArrayList<>(); private String name; public Composite(String name) { this.name = name; } @Override public void add(Component component) { components.add(component); } @Override public String getName() { return name; } @Override public void inflate() { Log.d(DEBUG_TAG, getName()); // Inflate composites including children for (Component component : components) { component.inflate(); } } }
如您在这里看到的,这个模式非常简单,但非常有效:
要查看实际效果,我们需要定义一些组件和组合。我们可以使用如下这样的代码行来定义组件:
Component newLeaf = new Leaf("New leaf");
我们可以使用 add()
方法创建组合集合,如下所示:
Component composite1 = new Composite("New composite");
composite1.add(newLeaf);
composite1.add(oldLeaf);
在彼此内部嵌套组合同样简单,因为我们编写的代码使得我们可以忽略我们是创建 Leaf
还是 Composite
,并为两者使用相同的代码。以下是一个示例:
Component composite2 = Composite("Another composite");
composite2.add(someLeaf);
composite2.add(composite1);
composite2.add(anotherComponent);
显示一个组件,在这个例子中它仅仅是一段文本,只需调用其 inflate()
方法即可。
添加构建器
定义并打印出一系列输出的公平选择将导致客户端代码相当混乱,我们将在这里采用的方法是从另一个模式中借鉴一个想法,并使用一个构建器类来完成构建我们所需组合的工作。这些可以是任何我们喜欢的内容,以下是一个可能的构建器:
public class Builder {
// Define individual components
Component image = new Leaf(" image view");
Component text = new Leaf(" text view");
Component list = new Leaf(" list view");
// Define composites
Component layout1(){
Component c = new Composite("layout 1");
c.add(image);
c.add(text);
return c;
}
// Define nested composites
Component layout2() {
Component c = new Composite("layout 2");
c.add(list);
c.add(layout1());
return c;
}
Component layout3(){
Component c = new Composite("layout 3");
c.add(layout1());
c.add(layout2());
return c;
}
}
这样,我们的活动的 onCreate()
方法保持清晰简洁,正如您在这里看到的:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Builder builder = new Builder();
// Inflate a single component
builder.list.inflate();
// Inflate a composite component
builder.layout1().inflate();
// Inflate nested components
builder.layout2().inflate();
builder.layout3().inflate();
}
尽管我们只生成了一个基本的输出,但应该清楚我们现在如何将其扩展到充气实际布局,以及这项技术可能有多么有用。
布局构建器
在第六章《激活模式》中,我们使用构建器构建了一个简单的 UI。构建器是这项任务的完美选择,因为我们只关心包含一种类型的视图。我们可以通过适配器(字面上)来调整这个方案以适应其他视图类型,但最好使用一种不关心它正在处理哪种类型组件的模式。希望前面的示例展示了组合模式适合这类任务。
在以下示例中,我们将同样的原则应用于一个实际的 UI 充气器,它处理不同类型的视图,视图组合群以及最重要的动态嵌套布局。
为了这个练习的目的,我们将假设我们的应用程序有一个新闻页面。这主要是一个促销特性,但已经证明,当广告装扮成新闻时,消费者更容易接受广告。许多组件,如标题和标志,将保持静态,而其他组件将频繁更改内容和布局结构。这使得它成为我们组合模式的理想主题。
这是我们将要开发的 UI:
添加组件
我们将逐个解决问题,一边构建代码。首先,我们将按照以下步骤解决创建和显示单个组件视图的问题:
-
与之前一样,我们从
Component
接口开始:public interface Component { void add(Component component); void setContent(int id); void inflate(ViewGroup layout); }
-
现在使用以下类来实现这一点:
public class TextLeaf implements Component { public TextView textView; public TextLeaf(TextView textView, int id) { this.textView = textView; setContent(id); } @Override public void add(Component component) { } @Override public void setContent(int id) { textView.setText(id); } @Override public void inflate(ViewGroup layout) { layout.addView(textView); } }
-
接下来,添加
Builder
,目前它非常简单,只包含两个属性和构造函数:public class Builder { Context context; Component text; Builder(Context context) { this.context = context; init(); text = new TextLeaf(new TextView(context), R.string.headline); } }
-
最后,编辑活动的
onCreate()
方法,使用我们自己的布局作为根布局,并添加我们的视图,如下所示:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Replace default layout LinearLayout layout = new LinearLayout(this); layout.setOrientation(LinearLayout.VERTICAL); layout.setLayoutParams(new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); setContentView(layout); // Add component Builder builder = new Builder(this); builder.headline.inflate(layout); }
目前我们所做的工作并不令人印象深刻,但通过之前例子的演练,我们会很清楚接下来要做什么,下一步是创建一个处理图像视图的组件。
如以下代码段所示,ImageLeaf
类几乎与其文本兄弟类相同,唯一的区别在于它生成的视图类型以及使用setImageResource()
操作id
参数:
public class ImageLeaf implements Component {
private ImageView imageView;
public ImageLeaf(ImageView imageView, int id) {
this.imageView = imageView;
setContent(id);
}
@Override
public void add(Component component) { }
@Override
public void setContent(int id) {
imageView.setImageResource(id);
}
@Override
public void inflate(ViewGroup layout) {
layout.addView(imageView);
}
}
这可以像文本视图一样轻松地添加到构建器中,但现在我们将为此创建一个小方法,并在构造函数中调用它,因为我们可能想要添加许多其他内容。现在代码应该看起来像这样:
Builder(Context context) {
this.context = context;
initLeaves();
}
private void initLeaves() {
header = new ImageLeaf(new ImageView(context),
R.drawable.header);
headline = new TextLeaf(new TextView(context),
R.string.headline);
}
正如预期的那样,对于客户端代码来说,这与任何其他组件没有区别,可以使用以下方式来填充它:
builder.header.inflate(layout);
图像视图和文本视图都可以将它们的主要内容(图像和文本)作为资源 ID 整数,因此我们可以为两者使用相同的int
参数。在setContent()
方法中处理这一点可以让我们解耦实际的实现,并允许我们简单地将它们每个都作为Component
引用。当我们应用一些格式化属性时,setContent()
方法也会很快证明其有用。
这仍然非常基础,如果我们像这样创建所有组件,无论它们如何组合在一起,构建器代码很快就会变得非常冗长。我们刚刚创建的横幅视图不太可能改变,所以这个系统适合这种设置。然而,对于更易变的内容,我们将需要找到一种更灵活的方法,但在我们这样做之前,我们将了解如何创建我们类的组合版本。
创建组合组件
复合模式的真正价值在于其将一组对象视为一个的能力,我们的两个头部视图提供了一个很好的机会来展示如何做到这一点。由于它们总是同时出现,将它们视为一个是有道理的。我们可以通过以下三种方式做到这一点:
-
调整其中一个现有的叶类,使其能够创建子项
-
创建一个没有父级的组合
-
创建一个以布局为父级的组合
我们将了解如何完成所有这些工作,但首先我们将在这种情况下实现最高效的方法,基于我们其中一个叶类创建一个组合类。我们希望标题图像在文本上方,因此我们将使用ImageLeaf
类作为模板。
完成这项任务只需三个简单步骤:
-
CompositeImage
类与ImageLeaf
完全相同,除了以下例外:public class CompositeImage implements Component { List<Component> components = new ArrayList<>(); ... @Override public void add(Component component) { components.add(component); } ... @Override public void inflate(ViewGroup layout) { layout.addView(imageView); for (Component component : components) { component.inflate(layout); } } }
-
在构建器中构建这个组就像这样简单:
Component headerGroup() { Component c = new CompositeImage(new ImageView(context), R.drawable.header); c.add(headline); return c; }
-
现在我们也可以替换活动中的调用:
builder.headerGroup().inflate(layout);
这也可以像所有其他组件一样处理,制作一个等效的文本版本会非常简单。这些类可以看作是它们叶节点版本的扩展,在这里很有用,但创建一个没有容器的复合组件会更整洁,这将使我们能够组织可以在稍后插入到布局中的组。
下面的类是一个精简的复合类,可用于组合任何组件,包括其他组:
class CompositeShell implements Component {
List<Component> components = new ArrayList<>();
@Override
public void add(Component component) {
components.add(component);
}
@Override
public void setContent(int id) { }
@Override
public void inflate(ViewGroup layout) {
for (Component component : components) {
component.inflate(layout);
}
}
}
假设我们想要将三个图像组合在一起,以便稍后添加到布局中。按照当前的代码,我们不得不在构建时添加这些定义。这可能导致代码庞大且不美观。我们将通过为构建器添加方法来解决这一问题,使我们能够按需创建组件。
这两个方法如下:
public TextLeaf setText(int t) {
TextLeaf leaf = new TextLeaf(new TextView(context), t);
return leaf;
}
public ImageLeaf setImage(int t) {
ImageLeaf leaf = new ImageLeaf(new ImageView(context), t);
return leaf;
}
我们可以使用构建器像这样构建这些组:
Component sandwichArray() {
Component c = new CompositeShell();
c.add(setImage(R.drawable.sandwich1));
c.add(setImage(R.drawable.sandwich2));
c.add(setImage(R.drawable.sandwich3));
return c;
这个组可以像其他任何组件一样从客户端处进行填充,因为我们的布局具有垂直方向,所以将显示为列。如果我们希望它们以行输出,我们将需要水平方向,因此需要生成一个类。
创建复合布局
这是一个复合组件的代码,它将生成线性布局作为其根布局,并将任何添加的视图放置在其中:
class CompositeLayer implements Component {
List<Component> components = new ArrayList<>();
private LinearLayout linearLayout;
CompositeLayer(LinearLayout linearLayout, int id) {
this.linearLayout = linearLayout;
setContent(id);
}
@Override
public void add(Component component) {
components.add(component);
}
@Override
public void setContent(int id) {
linearLayout.setBackgroundResource(id);
linearLayout.setOrientation(LinearLayout.HORIZONTAL);
}
@Override
public void inflate(ViewGroup layout) {
layout.addView(linearLayout);
for (Component component : components) {
component.inflate(linearLayout);
}
}
}
在构建器中构建此类的代码与其他代码没有区别:
Component sandwichLayout() {
Component c = new CompositeLayer(new LinearLayout(context),
R.color.colorAccent);
c.add(sandwichArray());
return c;
}
现在我们可以通过在活动中编写少量清晰易懂的代码来填充我们的组合:
Builder builder = new Builder(this);
builder.headerGroup().inflate(layout);
builder.sandwichLayout().inflate(layout);
值得注意的是,我们是如何使用复合层的setContent()
方法来设置方向的。从整体结构来看,这显然是正确的位置,这也引出了我们的下一个任务,格式化用户界面。
运行时格式化布局
尽管我们现在有能力生成任意数量的复杂布局,但快速查看以下输出可以看出,在外观和设计方面,我们距离理想的设计还有很长的路要走:
我们之前看到过如何通过其setContent()
方法设置插入布局的方向,这样我们就可以更控制组件的外观。进一步这样做只需一两分钟就能产生一个可接受的布局。只需遵循以下简单步骤:
-
首先,编辑
TextLeaf
的setContent()
方法,如下所示:@Override public void setContent(int id) { textView.setText(id); textView.setPadding(dp(24), dp(0), dp(0), dp(16)); textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); textView.setLayoutParams(new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); }
-
这还需要以下将 px 转换为 dp 的方法:
private int dp(int px) { float scale = textView.getResources() .getDisplayMetrics() .density; return (int) (px * scale + 0.5f); }
-
ImageLeaf
组件只需要这些更改:@Override public void setContent(int id) { imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); imageView.setLayoutParams(new ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, dp(R.dimen.imageHeight))); imageView.setImageResource(id); }
-
我们还为构建器添加了更多的构造,如下所示:
Component story(){ Component c = new CompositeText(new TextView(context) ,R.string.story); c.add(setImage(R.drawable.footer)); return c; }
-
这可以通过在活动中添加以下几行代码来实现:
Builder builder = new Builder(this); builder.headerGroup().inflate(layout); builder.sandwichLayout().inflate(layout); builder.story().inflate(layout);
这些调整现在应该能产生符合我们原始规格的设计。尽管我们添加了大量代码并创建了特定的 Android 对象,但查看以下图表将显示整体模式保持不变:
我们在这里还可以做更多的事情,例如,处理开发横屏布局和针对不同屏幕配置的缩放问题,所有这些都可以使用相同的方法简单地管理。然而,我们已经做得足够了,可以展示如何使用组合模式在运行时动态构建布局。
我们现在将暂时放弃这种模式,去探索如何提供一些定制功能,并考虑用户偏好以及我们如何存储持久数据。
存储选项
几乎所有的应用程序都有某种形式的设置菜单,允许用户存储经常访问的信息,并根据个人偏好定制应用程序。这些设置可以是更改密码、个性化颜色方案,或者是许多其他的调整和修改。
如果你拥有大量数据并且可以访问网络服务器,通常最好是从这个来源缓存数据,这将节省电池消耗并加快应用程序的运行速度。
首先我们应该考虑这些设置如何为用户节省时间。没有人希望在每次订购三明治时都输入所有详细信息,也不希望一次又一次地构建同一个三明治。这引出了一个问题,即我们如何在系统中表示三明治,以及如何将订单信息发送给供应商并接收。
无论我们采用哪种技术来传输订单数据,我们都可以假设在这个过程中某个时刻会有一个人类实际制作三明治。一个简单的文本字符串似乎是我们所需要的全部,它当然足以作为供应商的指令以及存储用户喜好。然而,这里有一个宝贵的机会,错过它将是愚蠢的。每个放置的订单都包含有价值的销售数据,通过汇总这些数据,我们可以了解哪些产品卖得好,哪些不好。因此,我们需要在订单信息中尽可能多地包含数据。购买历史可以包含许多有用的数据,购买的时间和日期也是如此。
无论我们选择收集哪些支持数据,有一件事将非常有用,那就是能够识别单个客户,但人们不喜欢透露个人信息,他们也不应该透露。没有理由为了买一个三明治而需要提供出生日期或性别。然而,正如我们将会看到的,我们可以为每个下载的应用程序和/或运行它的设备附加一个唯一的标识符。此外,我们或其他人无法从这些信息中识别个人,因此这对他们的安全或隐私没有威胁,保护这些是至关重要的。
我们有几种方法可以在用户的设备上存储数据,以便属性在会话之间持久化。通常,我们希望这些数据保持私密,在下一节中,我们将了解如何实现这一点。
创建静态文件
在本章的这一部分,我们的主要关注点是存储用户偏好。在开始之前,我们应该看看一两个其他的存储选项,首先从设备的内部存储开始。
在本章的前半部分,我们使用了strings.xml
值文件分配了一个相当长的字符串。这类资源文件最适合存储单个单词和短句,但用于存储长句或段落则显得不太吸引人。在这种情况下,我们可以使用文本文件,并将其存储在res/raw
目录中。
raw
目录的方便之处在于,它作为R
类的一部分被编译,这意味着它的内容可以像引用任何其他资源(如字符串或可绘制资源)一样引用,例如R.raw.some_text
。
要了解如何在不弄乱字符串文件的情况下包含长文本,请按照以下简单步骤操作:
-
默认情况下不包含
res/raw
文件夹,因此首先创建它。 -
在这个文件夹中创建一个包含你文本的新文件。这里,它被称为
wiki
,因为它取自三明治的维基百科条目。 -
打开你的活动或你用来填充布局的任何代码,并添加这个方法:
public static String readFile(Context context, int resId) { InputStream stream = context.getResources() .openRawResource(R.raw.wiki); InputStreamReader inputReader = new InputStreamReader(stream); BufferedReader bufferedReader = new BufferedReader(inputReader); String line; StringBuilder builder = new StringBuilder(); try { while ((line = bufferedReader.readLine()) != null) { builder.append(line) .append('\n'); } } catch (IOException e) { return null; } return builder.toString(); }
-
现在只需添加这些行来填充你的视图。
TextView textView = (TextView) findViewById(R.id.text_view); String data = readFile(this, R.raw.wiki); textView.setText(data);
将原始文件夹像其他资源目录一样处理的好处之一是,我们可以为不同的设备或地区创建指定的版本。例如,这里我们创建了一个名为raw-es
的文件夹,并在其中放入了相同名称的西班牙语文本翻译:
提示
如果你使用的是外部文本编辑器,如记事本,你将需要确保文件以UTF-8
格式保存,以便非拉丁字符能正确显示。
这种资源非常有用,而且非常容易实现,但这种文件是只读的,肯定会有我们想要创建和编辑这类文件的时候。
创建和编辑应用程序文件
当然,在这里我们能做的远不止方便地存储长字符串,而且能够在运行时更改这些文件的内容为我们提供了很大的范围。如果没有已经存在的用于存储用户偏好的方便方法,这将是一个很好的选择,而且有时共享偏好结构仍不足以满足我们所有的需求。这是使用这类文件的主要原因之一;另一个是作为定制功能,允许用户制作和存储笔记或书签。编码的文本文件甚至可以被创建者理解并用于重建包含用户喜欢的成分的三明治对象。
我们即将探讨的方法使用了一个内部应用目录,这个目录对设备上的其他应用是隐藏的。在下面的练习中,我们将展示用户如何使用我们的应用存储持久且私密的文本文件。启动一个新项目或打开一个你希望添加内部存储功能的项目,然后按照以下步骤操作:
-
从创建一个简单的布局开始。基于以下组件树进行设计:
-
为了简单起见,我们将使用 XML 的 onClick 属性,分别为每个按钮指定代码,使用
android:onClick="loadFile"
和android:onClick="saveFile"
。 -
首先,构建
saveFile()
方法:public void saveFile(View view) { try { OutputStreamWriter writer = new OutputStreamWriter(openFileOutput(fspc, 0)); writer.write(editText.getText().toString()); writer.close(); } catch (IOException e) { e.printStackTrace(); } }
-
然后制作
loadFile()
方法:public void loadFile(View view) { try { InputStream stream = openFileInput(fspc); if (stream != null) { InputStreamReader inputReader = new InputStreamReader(stream); BufferedReader bufferedReader = new BufferedReader(inputReader); String line; StringBuilder builder = new StringBuilder(); while ((line = bufferedReader.readLine()) != null) { builder.append(line) .append("\n"); } stream.close(); editText.setText(builder.toString()); } } catch (IOException e) { e.printStackTrace(); } }
这个例子非常简单,但它只需要展示以这种方式存储数据的潜力。使用前面的布局,代码很容易测试。
存储用户数据,或我们想要了解的用户数据,这种方式非常方便且安全。我们当然也可以加密这些数据,但这不是本书讨论的内容。Android 框架与其他移动平台相比,安全性并没有更高或更低,由于我们不会存储比偏好设置更敏感的信息,这个系统将完全满足我们的需求。
当然,也可以在设备的外部存储上创建和访问文件,比如微型 SD 卡。这些文件默认是公开的,通常在我们需要与其他应用共享内容时创建。这个过程与我们刚才探讨的类似,因此这里不再赘述。相反,我们将继续使用内置的SharedPreferences接口存储用户偏好设置。
存储用户偏好设置
我们已经讨论过能够存储用户设置的重要性,并简要思考了我们想要存储的设置。共享偏好设置使用键值对来存储数据,这对于像name="desk" value="4"
这样的值是合适的,但我们想要存储一些更详细的信息。例如,我们希望用户能够轻松地存储他们最喜欢的三明治以便快速回忆。
这里的第一步是了解 Android 共享偏好设置接口通常如何工作以及应该在何处应用它。
活动生命周期
使用SharedPreferences接口存储和检索用户偏好设置时,使用键值对来存储和检索基本数据类型。应用这一点非常简单,当询问何时何地执行这些操作时,这个过程才真正变得有趣。这就引出了活动生命周期的话题。
与桌面应用程序不同,移动应用通常不是被用户故意关闭的。相反,它们通常是被导航离开,经常在后台保持半活动状态。在运行时,一个活动将进入各种状态,如暂停、停止或恢复。这些状态每个都有一个关联的回调方法,比如我们非常熟悉的onCreate()
方法。我们可以使用其中几个来保存和加载用户设置,为了决定使用哪个,我们需要查看生命周期本身:
前面的图表可能有些令人困惑,要了解何时发生什么,编写一些调试代码是最佳方式。包括onCreate()
在内,在活动的生命周期期间可能会调用七个回调方法:
-
onCreate()
-
onStart()
-
onResume()
-
onPause()
-
onStop()
-
onDestroy()
-
onRestart()
初看起来,从onDestroy()
方法保存用户设置似乎是有道理的,因为它是最后一个可能的状态。要了解为什么这通常不起作用,打开任何项目并覆盖前面列表中的每个方法,并添加一些调试代码,如这里的示例所示:
@Override
public void onResume() {
super.onResume();
Log.d(DEBUG_TAG, "Resuming...");
}
稍作实验即可发现onDestroy()
并不总是被调用。为了确保我们的数据得到保存,我们需要从onPause()
或onStop()
方法中存储我们的偏好设置。
应用偏好设置
要了解偏好设置的存储和检索方式,请启动一个新项目或打开一个现有项目,并按照以下步骤操作:
-
首先,创建一个名为
User
的新类,如下所示:// Singleton class as only one user public class User { private static String building; private static String floor; private static String desk; private static String phone; private static String email; private static User user = new User(); public static User getInstance() { return user; } public String getBuilding() { return building; } public void setBuilding(String building) { User.building = building; } public String getFloor() { return floor; } public void setFloor(String floor) { User.floor = floor; } public String getDesk() { return desk; } public void setDesk(String desk) { User.desk = desk; } public String getPhone() { return phone; } public void setPhone(String phone) { User.phone = phone; } public String getEmail() { return email; } public void setEmail(String email) { User.email = email; } }
-
接下来,根据以下预览创建一个 XML 布局以匹配这些数据:
-
修改活动,使其实现以下监听器。
public class MainActivity extends AppCompatActivity implements View.OnClickListener
-
按照通常的方式包含以下字段,并将它们与它们的 XML 对应项相关联:
private User user = User.getInstance(); private EditText editBuilding; private EditText editFloor; private EditText editDesk; private EditText editPhone; private EditText editEmail; private TextView textPreview;
-
在
onCreate()
方法中本地添加按钮,并设置它们的点击监听器:Button actionLoad = (Button) findViewById(R.id.action_load); Button actionSave = (Button) findViewById(R.id.action_save); Button actionPreview = (Button) findViewById(R.id.action_preview); actionLoad.setOnClickListener(this); actionSave.setOnClickListener(this); actionPreview.setOnClickListener(this);
-
创建以下方法,并在
onCreate()
内部调用它:public void loadPrefs() { SharedPreferences prefs = getApplicationContext() .getSharedPreferences("prefs", MODE_PRIVATE); // Retrieve settings // Use second parameter if never saved user.setBuilding(prefs.getString("building", "unknown")); user.setFloor(prefs.getString("floor", "unknown")); user.setDesk(prefs.getString("desk", "unknown")); user.setPhone(prefs.getString("phone", "unknown")); user.setEmail(prefs.getString("email", "unknown")); }
-
创建一个如下所示的方法来存储偏好设置:
public void savePrefs() { SharedPreferences prefs = getApplicationContext().getSharedPreferences("prefs", MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); // Store preferences editor.putString("building", user.getBuilding()); editor.putString("floor", user.getFloor()); editor.putString("desk", user.getDesk()); editor.putString("phone", user.getPhone()); editor.putString("email", user.getEmail()); // Use apply() not commit() // to perform operation in background editor.apply(); }
-
添加
onPause()
方法以调用它:@Override public void onPause() { super.onPause(); savePrefs(); }
-
最后,像这样添加点击监听器:
@Override public void onClick(View view) { switch (view.getId()) { case R.id.action_load: loadPrefs(); break; case R.id.action_save: // Recover data from form user.setBuilding(editBuilding.getText().toString()); user.setFloor(editFloor.getText().toString()); user.setDesk(editDesk.getText().toString()); user.setPhone(editPhone.getText().toString()); user.setEmail(editEmail.getText().toString()); savePrefs(); break; default: // Display as string textPreview.setText(new StringBuilder() .append(user.getBuilding()).append(", ") .append(user.getFloor()).append(", ") .append(user.getDesk()).append(", ") .append(user.getPhone()).append(", ") .append(user.getEmail()).toString()); break; } }
这里添加了加载和预览功能,仅仅是为了让我们测试代码,但正如你所见,这个过程可以用来存储和检索任何数量的相关信息:
提示
如果需要清空偏好设置文件,可以使用edit.clear()
方法。
通过工具 | 安卓菜单访问的 Android 设备监视器,可以很容易地找到并查看我们的共享偏好设置。打开文件浏览器,导航到data/data/com.your_app/shared_prefs/prefs.xml
。它应该看起来像这样:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="phone">+44 0102 555 6789</string>
<string name="email">kyle@blt.com</string> <string name="floor">5</string>
<string name="desk">13</string> <string name="user_id">
fbc08fca-f375-4786-9e2d-d610c9cd0377</string>
<boolean name="new_user" value="false" /> <string name="building">Bagel Building</string> </map>
尽管共享偏好设置很简单,但它几乎是所有 Android 移动应用程序不可或缺的元素,除了这些明显的优势之外,我们还可以在这里执行一个很酷的技巧。我们可以使用共享偏好设置文件的内容来确定应用程序是否是第一次运行。
添加唯一标识符
在收集销售数据时,有一个方法来识别单个客户总是一个好主意。这不必是姓名或任何个人信息,一个简单的 ID 号码可以为数据集增加一个全新的维度。
在许多情况下,我们会使用简单的递增系统,并为每位新客户分配一个比上一个值高一位的数字 ID。当然,在像我们这样的分布式系统中这是不可能的,因为每个安装实例都无法了解可能存在的其他实例数量。在理想情况下,我们会说服所有客户向我们注册,或许通过提供免费三明治的优惠,但除了贿赂客户之外,还有一种相当聪明的技术可以在分布式系统上生成真正唯一的标识符。
通用唯一标识符(UUID)是创建唯一值的一种方法,它作为java.util
的一部分。有几个版本,其中一些基于命名空间,它们本身就是唯一的标识符。我们在这里使用的版本(版本 4)使用随机数生成器。可能会诱人地认为这可能会产生重复,但标识符的构建方式意味着在二十亿年内每秒下载一次,才会有严重重复的风险,所以对于我们三明治销售商来说,这个系统可能已经足够了。
提示
我们还可以在这里使用许多其他功能,例如将点击计数器添加到偏好设置中,用它来统计应用程序被访问的次数,以及我们卖出的三明治数量,或者记录消费总额。
欢迎新用户并添加 ID 是我们只想在应用程序首次运行时执行的操作,因此我们将同时添加这两个功能。以下是添加欢迎功能并分配唯一用户 ID 所需的步骤:
-
向
User
类中添加这两个字段、设置器和获取器:private static boolean newUser; private static String userId; ... public boolean getNewUser() { return newUser; } public void setNewUser(boolean newUser) { User.newUser = newUser; } public String getUserId() { return userId; } public void setUserId(String userId) { User.userId = userId; }
-
在
loadPrefs()
方法中添加以下代码:if (prefs.getBoolean("new_user", true)) { // Display welcome dialog // Add free credit for new users String uuid = UUID.randomUUID().toString(); prefs.edit().putString("user_id", uuid); prefs.edit().putBoolean("new_user", false).apply(); }
我们的应用程序现在可以欢迎并识别每一位用户的。使用共享偏好设置来运行代码的美妙之处在于,这种方法会忽略更新,并且只在应用程序首次运行时执行。
提示
创建用户 ID 的一个相对简单但不够优雅的解决方案是使用设备的序列号,可以通过如下代码实现:user.setId(
Build.SERIAL .toString())
。
总结
在本章中,我们讨论了两个完全不同的话题,并涵盖了理论和实践方面的内容。组合模式非常有用,我们看到了它如何轻松地替代其他模式,比如建造者模式。
如果我们对软件必须执行的一些更机械的过程没有掌握,比如文件存储,那么模式将无用武之地。应该清楚,类似列表的数据文件(如我们之前使用的共享首选项)的性质非常适合构建器模式,而更复杂的数据结构可以使用组合模式来处理。
在下一章中,我们将探讨更多非即时性结构,研究当我们的应用程序当前未激活时如何创建服务和向用户发送通知。这将引入观察者模式,无疑您已经以监听器方法的形式遇到过。
第九章:观察模式
在上一章,我们探讨了如何通过允许用户存储经常使用的数据,如位置和饮食偏好,来简化交互。这只是让应用使用尽可能愉快的一种方式。另一种有价值的方法是向用户提供及时的通知。
所有移动设备都有接收通知的机制;通常这些通知是通过屏幕顶部的狭窄状态栏传递的,Android 也不例外。对于我们开发者来说,这个过程之所以有趣,是因为这些通知需要在我们的应用可能并未使用时发送。显然,在活动中没有回调方法来处理此类事件,因此我们将不得不查看如服务这样的后台组件来触发此类事件。
就设计模式而言,有一个几乎专为管理一对多关系而设计的模式,即观察者模式。尽管它完美适用于通知的发送和接收,但观察者模式在软件设计的各个领域无处不在,你无疑已经遇到了Observer和Observed的 Java 实用工具。
我们将从观察者模式本身以及 Android 通知的设计、构建和自定义方法开始本章的学习。
在本章中,你将学习如何:
-
创建一个观察者模式
-
发出通知
-
使用 Java 观察者工具
-
应用一个待定意图
-
配置隐私和优先级设置
-
自定义通知
-
创建一个服务
本章主要关注观察者模式,以及如何将其应用于管理通知。最好的起点是查看模式本身,它的目的和结构。
观察者模式
你可能没有意识到,其实你已经多次遇到观察者模式,因为每个点击监听器(以及其他任何监听器)实际上都是一个观察者。同样,对于任何桌面或图形用户界面的图标和功能,这些类型的监听器接口非常清晰地展示了观察者模式的目的。
- 观察者像一个哨兵,监视其主体(或主体)的特定事件或状态变化,然后将这些信息报告给感兴趣的相关方。
如已经提到,Java 有自己的观察者工具,尽管在某些情况下它们可能很有用,但 Java 处理继承的方式和模式的简单性使得编写我们自己的版本更为可取。我们将了解如何使用这些内置类,但在大多数示例中,我们将构建自己的版本。这还将提供对模式工作原理的更深入理解。
使用通知时必须谨慎,因为没有什么比不希望收到的消息更能激怒用户了。然而,如果谨慎使用,通知可以提供一个非常有价值的推广工具。秘诀在于允许用户选择加入和退出各种消息流,这样他们只接收他们感兴趣的通知。
创建模式
考虑到我们的三明治制作应用,似乎很少有发送通知的机会。如果我们要提供让客户除了外卖还可以取三明治的选项,那么用户可能会感激在他们的三明治准备好时收到通知。
为了在设备间有效通信,我们需要一个带有相关应用程序的中心服务器。我们在这里无法涵盖这一点,但这不会阻止我们了解模式的工作原理以及如何发布通知。
我们将从构建一个简单的观察者模式开始,以及一个基本的通知管理器来跟踪和报告订单进度。
要了解如何执行此操作,请按照以下步骤操作:
-
观察者模式的核心是一个用于主体的接口和一个用于观察者的接口。
-
主体接口如下所示:
public interface Subject { void register(Observer o); void unregister(Observer o); boolean getReady(); void setReady(boolean b); }
-
这是观察者接口:
public interface Observer { String update(); }
-
接下来,将正在订购的三明治实现为主体,如下所示:
-
接下来,像这样实现观察者接口:
public class Sandwich implements Subject { public boolean ready; // Maintain a list of observers private ArrayList<Observer> orders = new ArrayList<Observer>(); @Override // Add a new observer public void register(Observer o) { orders.add(o); } @Override // Remove observer when order complete public void unregister(Observer o) { orders.remove(o); } @Override // Update all observers public void notifyObserver() { for (Observer order : orders) { order.update(); } } @Override public boolean getReady() { return ready; } public void setReady(boolean ready) { this.ready = ready; } }
public class Order implements Observer { private Subject subject = null; public Order(Subject subject) { this.subject = subject; } @Override public String update() { if (subject.getReady()) { // Stop receiving notifications subject.unregister(this); return "Your order is ready to collect"; } else { return "Your sandwich will be ready very soon"; } } }
这完成了模式本身;其结构非常简单,如下所示:
在这里,主体完成所有工作。它保存了所有观察者的列表,并为观察者提供订阅和取消订阅更新的机制。在前一个示例中,我们从观察者中在update()
时调用unregister()
,一旦订单完成,因为我们的监听器将不再对此主体感兴趣。
Observer
接口看起来可能过于简单而不必要,但它允许Sandwich
与其观察者之间进行松耦合,这意味着我们可以独立修改它们中的任何一个。
尽管我们只包含了一个观察者,但应该清楚的是,我们在主体中实现的方法允许任何数量的单独订单并相应地响应。
添加通知
order.update()
方法为我们提供了适当的通知文本。要测试该模式并将通知发送到状态栏,请按照以下步骤操作:
-
首先,创建一个包含以下嵌套布局的 XML 布局:
<LinearLayout ... android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:gravity="end" android:orientation="horizontal"> <Button android:id="@+id/action_save" style="?attr/borderlessButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:minWidth="64dp" android:onClick="onOrderClicked" android:padding="@dimen/action_padding" android:text="ORDER" android:textColor="@color/colorAccent" android:textSize="@dimen/action_textSize" /> <Button android:id="@+id/action_update" ... android:onClick="onUpdateClicked" android:padding="@dimen/action_padding" android:text="UPDATE" ... /> </LinearLayout>
-
打开你的 Java 活动并添加这些字段:
Sandwich sandwich = new Sandwich(); Observer order = new Order(sandwich); int notificationId = 1;
-
添加监听订单按钮被点击的方法:
public void onOrderClicked(View view) { // Subscribe to notifications sandwich.register(order); sendNotification(order.update()); }
-
为更新按钮添加一个:
public void onUpdateClicked(View view) { // Mimic message from server sandwich.setReady(true); sendNotification(order.update()); }
-
最后,添加
sendNotification()
方法:
private void sendNotification(String message) {
NotificationCompat.Builder builder =
(NotificationCompat.Builder)
new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_stat_bun)
.setContentTitle("Sandwich Factory")
.setContentText(message);
NotificationManager manager = (NotificationManager)
getSystemService(NOTIFICATION_SERVICE);
manager.notify(notificationId, builder.build());
// Update notifications if needed
notificationId += 1;
}
我们现在可以在设备或模拟器上运行代码:
上面的代码负责发送通知,它展示了发布通知的最简单方式,其中图标和两个文本字段是此操作的最小要求。
注意
由于这只是一个演示,观察者模式实际上所做的并不比模拟服务器更多,因此重要的是不要将其与原生的通知 API 调用混淆。
通知 ID 的使用值得注意。这主要用于更新通知。使用相同的 ID 发送通知将更新之前的消息,在前面提到的情况下,实际上我们应该这样做,这里 ID 的递增只是为了演示如何使用它。为了纠正这一点,注释掉该行并重新运行项目,以便只生成一个消息流。
我们还可以并且应该做更多的事情来充分利用这个宝贵的工具,例如在应用不活跃时执行操作并传递通知,我们将在后面的章节回到这些问题,但现在看看 Java 如何提供自己的工具来实现观察者模式是值得的。
实用观察者和可观察对象
如前所述,Java 提供了自己的观察者工具,即java.util.observer
接口和java.util.observable
抽象类。它们配备了注册、注销和通知观察者的方法。正如通过以下步骤可以看到的,前一个示例可以很容易地使用它们实现:
-
在这个例子中,主题是通过扩展可观察类来实现的,如下所示:
import java.util.Observable; public class Sandwich extends Observable { private boolean ready; public Sandwich(boolean ready) { this.ready = ready; } public boolean getReady() { return ready; } public void setReady(boolean ready) { this.ready = ready; setChanged(); notifyObservers(); } }
-
Order
类是一个观察者,因此实现了这个接口,如下所示:import java.util.Observable; import java.util.Observer; public class Order implements Observer { private String update; public String getUpdate() { return update; } @Override public void update(Observable observable, Object o) { Sandwich subject = (Sandwich) observable; if (subject.getReady()) { subject.deleteObserver(this); update = "Your order is ready to collect"; } else { update = "Your sandwich will be ready very soon"; } } }
-
XML 布局和
sendNotification()
方法与之前完全相同,活动中源代码唯一的变化如下所述:public class MainActivity extends AppCompatActivity { Sandwich sandwich = new Sandwich(false); Order order = new Order(); private int id; @Override protected void onCreate(Bundle savedInstanceState) { ... } public void onOrderClicked(View view) { sandwich.addObserver(order); sandwich.setReady(true); sendNotification(order.getUpdate()); } public void onUpdateClicked(View view) { sandwich.setReady(true); sendNotification(order.getUpdate()); } private void sendNotification(String message) { ... } }
如你所见,这段代码执行的任务与我们的前一个示例相同,值得比较这两个清单。观察者的setChanged()
和notifyObservers()
方法替换了我们自定义版本中实现的方法。
你未来采用哪种观察者模式的方法主要取决于特定情况。通常,Java 可观察工具适用于简单情况,如果你不确定,从这种方法开始是个好主意,因为很快你就会看到是否需要更灵活的方法。
以上示例仅介绍了观察者模式和通知。该模式展示了一个非常简单的情况,为了充分发挥其潜力,我们需要将其应用于更复杂的情况。不过首先,我们会看看我们还能用通知系统做些什么。
通知
向用户发送简单的字符串消息是通知系统的主要目的,但它还能做更多的事情。首先,通知可以被设置为执行一个或多个操作;通常其中之一是打开相关的应用程序。也可以创建扩展的通知,其中可以包含各种媒体,这对于单行消息无法容纳过多信息的情况非常有用,但我们又想省去用户打开应用程序的麻烦。
从 API 21 开始,已经可以发送弹窗通知和用户锁屏上的通知。这个功能是从其他移动平台上借鉴来的,尽管它显然很有用,但应该谨慎使用。几乎不用说,通知应该只包含相关及时的信息。经验法则是,只有在信息不能等到用户下次登录时才能发出通知。一个有效的通知的例子可能是你的三明治已经延迟了,而不是新款奶酪即将推出。
除了可能打扰用户的风险,锁屏通知还包含另一个危险。在锁定设备上显示的消息对于所有意图和目的都是公开的。任何经过留在桌上的手机的人都能看到内容。现在尽管大多数人可能不介意他们的老板看到他们喜欢的三明治类型,毫无疑问,你将编写的一些应用程序将包含更敏感的材料,幸运的是 API 提供了可编程的隐私设置。
尽管需要谨慎使用,但通知功能的完整范围仍然值得熟悉,从让通知实际执行某些操作开始。
设置意图
与启动活动或其他任何顶级应用组件一样,意图为我们提供了从通知到操作的路径。在大多数情况下,我们希望使用通知来启动活动,这就是我们在这里要做的事情。
移动设备的用户希望能够在活动和应用程序之间轻松快速地移动。当用户在应用程序之间导航时,系统会跟踪其顺序并将其存储在返回栈中。这通常已经足够,但是当用户被通知从应用程序中引开,然后按下返回按钮时,他们不会返回之前参与的应用程序。这很可能会激怒用户,但幸运的是,通过创建一个人工的返回栈可以轻松避免这个问题。
创建我们自己的返回栈并不像听起来那么困难,以下示例证明了这一点。实际上它非常简单,这个例子还详细介绍了如何包含一些其他通知功能,例如更详细的通知图标和当通知首次送达时在状态栏上滚动的提示文本。
按照以下步骤了解如何实现这一点:
-
打开我们之前工作的项目,并创建一个新的活动类,如下所示:
public class UserProfile extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_profile); } }
-
接下来,我们需要一个布局文件以匹配之前在
onCreate()
方法中设置的内容视图。这可以留空,只需包含一个根布局。 -
现在在主活动中的
sendNotification()
方法顶部添加以下行:Intent profileIntent = new Intent(this, UserProfile.class); TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); stackBuilder.addParentStack(UserProfile.class); stackBuilder.addNextIntent(profileIntent); PendingIntent pendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
-
在通知构建器中添加这些设置:
.setAutoCancel(true) .setTicker("the best sandwiches in town") .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_sandwich)) .setContentIntent(pendingIntent);
-
最后,在清单文件中包含新的活动:
<activity android:name="com.example.kyle.ordertracker.UserProfile"> <intent-filter> <action android:name="android.intent.action.DEFAULT" /> </intent-filter> </activity>
这些更改的效果是显而易见的:
注释掉生成回退堆栈的行,并在使用另一个应用时打开通知,以了解它如何保持直观的导航。setAutoCancel()
的调用意味着当跟随通知时,状态栏图标会被取消。
通常,我们希望用户从通知中打开我们的应用,但从用户的角度来看,最重要的是以最少的努力完成任务,如果他们不需要打开另一个应用就能获取相同的信息,那么这是件好事。这就是扩展通知的作用所在。
定制和配置通知
扩展通知是在 API 16 中引入的。它提供了一个更大、更灵活的内容区域,与其他移动平台保持一致。扩展通知有三种样式:文本、图像和列表。以下步骤将演示如何实现每一种样式:
-
下一个项目可以从我们之前使用的项目修改,或者从头开始。
-
编辑主布局文件,使其包含以下三个按钮和观察者方法:
android:onClick="onTextClicked" android:onClick="onPictureClicked" android:onClick="onInboxClicked"
-
对
sendNotification()
方法进行以下更改:private void sendNotification(NotificationCompat.Style style) { ... NotificationCompat.Builder builder = (NotificationCompat.Builder) new NotificationCompat.Builder(this) .setStyle(style) ... manager.notify(id, builder.build()); }
-
现在创建三种样式方法。首先是大型文本样式:
public void onTextClicked(View view) { NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle(); bigTextStyle.setBigContentTitle("Congratulations!"); bigTextStyle.setSummaryText("Your tenth sandwich is on us"); bigTextStyle.bigText(getString(R.string.long_text)); id = 1; sendNotification(bigTextStyle); }
-
大图片样式需要以下设置:
public void onPictureClicked(View view) { NotificationCompat.BigPictureStyle bigPictureStyle = new NotificationCompat.BigPictureStyle(); bigPictureStyle.setBigContentTitle("Congratulations!"); bigPictureStyle.setSummaryText("Your tenth sandwich is on us"); bigPictureStyle.bigPicture(BitmapFactory.decodeResource(getResources(), R.drawable.big_picture)); id = 2; sendNotification(bigPictureStyle); }
-
最后添加列表样式或收件箱样式,如下所示:
public void onInboxClicked(View view) {
NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
inboxStyle.setBigContentTitle("This weeks most popular sandwiches");
inboxStyle.setSummaryText("As voted by you");
String[] list = {
"Cheese and pickle",
...
};
for (String l : list) {
inboxStyle.addLine(l);
}
id = 3;
sendNotification(inboxStyle);
}
这些通知现在可以在设备或 AVD 上进行测试:
最新的通知将始终展开,其他通知可以通过向下轻扫来展开。与大多数材料列表一样,可以通过水平轻扫来消除通知。
这些功能在通知设计上为我们提供了很大的灵活性,如果我们想要做更多,甚至可以自定义它们。通过向构建器传递一个 XML 布局,可以非常简单地完成此操作。为此,我们需要 RemoteViews 类,它是一种布局填充器。创建一个布局,然后在代码中包含以下行以实例化它:
RemoteViews expandedView = new RemoteViews(this.getPackageName(), R.layout.notification);
然后将其传递给构建器:
builder.setContent(expandedView);
在实现 Android 通知方面,我们需要了解的是如何发出弹窗通知和锁定屏幕通知。这更多的是关于设置优先级和用户权限及设置,而不是编码。
可见性和优先级
通知显示的位置和方式通常取决于两个相关属性:隐私和重要性。这些是通过元数据常量应用的,也可以包括如闹钟和促销等类别,系统可以使用这些类别对多个通知进行排序和过滤。
当涉及到向用户锁屏发送通知时,不仅是我们如何设置元数据,还取决于用户的安全设置。为了查看这些通知,用户必须选择一个安全的锁,如 PIN 码或手势,然后在安全 | 通知设置中选择以下选项之一:
只要用户设置了这些选项,我们的通知就会被发送到用户的锁屏。为了保护用户的隐私,我们可以通过构建器设置通知的可见性。有三个值可供选择:
-
VISIBILITY_PUBLIC
- 显示整个通知 -
VISIBILITY_PRIVATE
- 显示标题和图标但隐藏内容 -
VISIBILITY_SECRET
- 完全不显示任何内容
要实现这些设置之一,请使用如下代码行:
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
顶部提醒会在屏幕顶部以基本(折叠)通知的形式出现五秒钟,然后恢复到状态栏图标,以此来提醒用户其重要性。它们只应用于需要用户立即注意的信息。这是通过优先级元数据控制的。
默认情况下,每个通知的优先级是 PRIORITY_DEFAULT。五个可能的值分别是:
-
PRIORITY_MIN
= -2 -
PRIORITY_LOW
= -1 -
PRIORITY_DEFAULT
= 0 -
PRIORITY_HIGH
= 1 -
PRIORITY_MAX
= 2
这些也可以通过构建器设置,例如:
builder.setPriority(NotificationCompat.PRIORITY_MAX)
任何大于 DEFAULT 的值都会触发顶部提醒,前提是同时触发声音或振动。这也可以通过我们的构建器添加,形式如下:
builder.setVibrate(new long[]{500, 500, 500})
振动器类接收一个长整型数组,并将其作为毫秒级的振动脉冲,因此前面的例子会振动三次,每次半秒钟。
在应用中的任何位置包含设备振动都需要在安装时获得用户权限。这些权限会作为根元素的直接子元素添加到清单文件中,如下所示:
<manifest
package="com.example.yourapp">
<uses-permission
android:name="android.permission.VIBRATE" />
<application
...
</application>
</manifest>
关于显示和配置通知,我们还需要了解的并不多。然而,到目前为止,我们一直在应用内部发出通知,而不是像在野外那样远程发出。
服务
服务是顶级应用组件,如活动。它们的目的是管理长时间运行的背景任务,如播放音频或触发提醒或其他计划事件。服务不需要 UI,但在其他方面与活动类似,具有类似的生命周期和相关的回调方法,我们可以使用它们来拦截关键事件。
尽管所有服务一开始都是相同的,但它们基本上分为两类:绑定和非绑定。与活动绑定的服务将继续运行,直到收到停止指令或绑定活动停止。而非绑定的服务,无论调用活动是否活跃,都会继续运行。在这两种情况下,服务通常负责在完成分配的任务后自行关闭。
下面的示例演示了如何创建一个设置提醒的服务。该服务会在设定的延迟后发布通知,或者由用户操作取消。要了解如何实现这一点,请按照以下步骤操作:
-
首先创建一个布局。这将需要两个按钮:
-
在两个按钮中都包含 onClick 属性:
android:onClick="onReminderClicked" android:onClick="onCancelClicked"
-
创建一个新的类来扩展 Service:
public class Reminder extends Service
-
onBind()
方法虽然会被要求实现,但我们不需要它,所以可以像这样保留:@Override public IBinder onBind(Intent intent) { return null; }
-
我们不会使用
onCreate()
或onDestroy()
方法,但是了解后台活动的行为总是有用的,所以像这样完成方法:@Override public void onCreate() { Log.d(DEBUG_TAG, "Service created"); } @Override public void onDestroy() { Log.d(DEBUG_TAG, "Service destroyed"); }
-
该类将需要以下字段:
private static final String DEBUG_TAG = "tag"; NotificationCompat.Builder builder; @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(DEBUG_TAG, "Service StartCommand"); // Build notification builder = new NotificationCompat.Builder(this) .setSmallIcon(R.drawable.ic_bun) .setContentTitle("Reminder") .setContentText("Your sandwich is ready to collect"); // Issue timed notification in separate thread new Thread(new Runnable() { @Override public void run() { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); manager.notify(0, builder.build()); cancel(); } // Set ten minute delay }, 1000 * 60 * 10); // Destroy service after first use stopSelf(); } }).start(); return Service.START_STICKY; }
-
将服务添加到清单文件中,与您的活动并列,如下所示:
<service android:name=".Reminder" />
-
最后,打开您的 main Java 活动,并完成这两个按钮的监听器:
public void onReminderClicked(View view) { Intent intent = new Intent(MainActivity.this, Reminder.class); startService(intent); } public void onCancelClicked(View view) { Intent intent = new Intent(MainActivity.this, Reminder.class); stopService(intent); }
上面的代码演示了如何使用服务在后台运行代码。在许多应用程序中,这是一个非常重要的功能。我们唯一真正需要考虑的是确保当不再需要时,所有的服务都能正确地被释放,因为服务特别容易遭受内存泄漏。
总结
在本章中,我们看到了观察者模式如何作为一个工具来管理用户通知的传递,以及跟踪许多其他事件并相应地作出反应。我们从模式本身开始,然后了解了 Android 通知 API,尽管它们使用系统控制的状态栏和通知抽屉,但它们在设计和应用通知方面给了我们很大的自由度。
在下一章中,我们将以此和其他模式为例,看看如何扩展现有的 Android 组件,并直接应用我们的设计模式。我们还将了解这在开发除手机和平板电脑以外的其他形态因素时如何帮助我们。
第十章:行为模式
到目前为止,在这本书中,我们已经详细研究了许多最重要的创建性和结构性设计模式。这使我们能够构建各种各样的架构,但是为了执行我们所需的任务,这些结构需要能够在自身的元素之间以及与其他结构之间进行通信。
行为模式旨在解决我们在日常开发中遇到的许多通用问题,例如响应特定对象状态的变化或调整行为以适应硬件变化。我们在上一章的观察者模式中已经遇到了一个,在这里我们将进一步了解一些最有用的行为模式。
与创建性和结构性模式相比,行为模式在能够执行的任务类型上具有更高的适应性。虽然这种灵活性很好,但在选择最佳模式时,它也可能使问题复杂化,因为通常会有两三个候选模式可供选择。看看这些模式中的几个,了解它们之间有时微妙的差异,可以帮助我们有效地应用行为模式,这是一个好主意。
在本章中,你将学习如何:
-
创建模板模式
-
向模式中添加专业化层次。
-
应用策略模式
-
构建和使用访问者模式
-
创建一个状态机
这些模式的通用性意味着它们可以应用于大量的不同场景中。它们能够执行的任务类型的一个很好的例子就是点击或触摸监听器,当然还有上一章中的观察者模式。在许多行为模式中经常看到的另一个共同特性是使用抽象类来创建通用算法,正如我们将在本章中看到的访问者和策略模式以及我们即将探讨的模板模式。
模板模式
即使你完全不了解设计模式,你也会熟悉模板模式的工作方式,因为它使用抽象类和方法形成一个通用的(模板)解决方案,可以用来创建特定的子类,这正是 OOP 中抽象意图的使用方式。
最简单的模板模式不过是抽象类形式的泛化,至少有一个具体的实现。例如,模板可能定义了一个空的布局,而其实现则控制内容。这种方法的一个很大的优点是,公共元素和共享逻辑只需在基类中定义,这意味着我们只需要在我们实现之间不同的地方编写代码。
如果在基础类中增加一层抽象,模板模式可以变得更加强大和灵活。这些可以作为其父类的子类别,并类似地对待。在探索这些多层次的模式之前,我们将先看一个最简单的基模板例子,它提供了根据其具体实现产生不同输出的属性和逻辑。
一般来说,模板模式适用于可以分解为步骤的算法或任何程序集。这个模板方法在基础类中定义,并通过具体实现来明确。
要理解这个概念,最好的方式是通过例子。这里我们将设想一个简单的新闻源应用,它有一个通用的故事模板,以及新闻和体育的实现。按照以下步骤来创建这个模式:
-
开始一个新项目,并根据以下组件树创建一个主布局:
-
创建一个新的抽象类
Story
,作为我们的泛化,如下所示:abstract class Story { public String source; // Template skeleton algorithm public void publish(Context context) { init(context); setDate(context); setTitle(context); setImage(context); setText(context); } // Placeholder methods protected abstract void init(Context context); protected abstract void setTitle(Context context); protected abstract void setImage(Context context); protected abstract void setText(Context context); // Calculate date as a common property protected void setDate(Context context) { Calendar calendar = new GregorianCalendar(); SimpleDateFormat format = new SimpleDateFormat("MMMM d"); format.setTimeZone(calendar.getTimeZone()); TextView textDate = (TextView) ((Activity) context) .findViewById(R.id.text_date); textDate.setText(format.format(calendar.getTime())); } }
-
现在,按照如下方式扩展以创建
News
类:public class News extends Story { TextView textHeadline; TextView textView; ImageView imageView; @Override protected void init(Context context) { source = "NEWS"; textHeadline = (TextView) ((Activity) context).findViewById(R.id.text_headline); textView = (TextView) ((Activity) context).findViewById(R.id.text_view); imageView = (ImageView) ((Activity) context).findViewById(R.id.image_view); } @Override protected void setTitle(Context context) { ((Activity) context).setTitle(context.getString(R.string.news_title)); } @Override protected void setImage(Context context) { imageView.setImageResource(R.drawable.news); } @Override protected void setText(Context context) { textHeadline.setText(R.string.news_headline); textView.setText(R.string.news_content); } }
-
Sport
实现是相同的,但有以下例外:public class Sport extends Story { ... @Override protected void init(Context context) { source = "NEWS"; ... } @Override protected void setTitle(Context context) { ((Activity) context).setTitle(context.getString(R.string.sport_title)); } @Override protected void setImage(Context context) { imageView.setImageResource(R.drawable.sport); } @Override protected void setText(Context context) { textHeadline.setText(R.string.sport_headline); textView.setText(R.string.sport_content); } }
-
最后,将这些行添加到主活动中:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { String source = "NEWS"; Story story = new News(); @Override protected void onCreate(Bundle savedInstanceState) { ... Button button = (Button) findViewById(R.id.action_change); button.setOnClickListener(this); story.publish(this); } @Override public void onClick(View view) { if (story.source == "NEWS") { story = new Sport(); } else { story = new News(); } story.publish(this); } }
在真实或虚拟设备上运行这段代码,允许我们在Story
模板的两个实现之间切换:
这个模板例子既简单又熟悉,但尽管如此,模板可以应用于许多情况,并为组织代码提供了一种非常方便的方法,特别是当需要定义许多派生类时。类图与代码一样直接:
扩展模板
当各个实现非常相似时,前面的模式非常有用。但通常情况下,我们想要建模的对象虽然彼此足够相似,以至于可以共享代码,但仍然具有不同类型或数量的属性。一个很好的例子可能是阅读图书馆的数据库。我们可以创建一个名为阅读材料的基础类,并拥有合适的属性,这可以用来涵盖几乎任何书籍,无论其类型、内容或年龄。然而,如果我们想要包括杂志和期刊,我们可能会发现我们的模型无法表示这类期刊的多样性。在这种情况下,我们可以创建一个全新的基础类,或者创建新的专门抽象类来扩展基础类,而这些类本身也可以被扩展。
我们将使用上面的例子来演示这个更功能性的模板模式。现在这个模型有三个层次:泛化、专化和实现。由于这里重要的是模式的结构,我们将节省时间并使用调试器输出我们实现的对象。要了解如何将其实际应用,请按照以下步骤操作:
-
首先,创建一个抽象的基类,如下所示:
abstract class ReadingMaterial { // Generalization private static final String DEBUG_TAG = "tag"; Document doc; // Standardized skeleton algorithm public void fetchDocument() { init(); title(); genre(); id(); date(); edition(); } // placeholder functions protected abstract void id(); protected abstract void date(); // Common functions private void init() { doc = new Document(); } private void title() { Log.d(DEBUG_TAG,"Title : "+doc.title); } private void genre() { Log.d(DEBUG_TAG, doc.genre); } protected void edition() { Log.d(DEBUG_TAG, doc.edition); } }
-
接下来,为书籍类别创建另一个抽象类:
abstract class Book extends ReadingMaterial { // Specialization private static final String DEBUG_TAG = "tag"; // Override implemented base method @Override public void fetchDocument() { super.fetchDocument(); author(); rating(); } // Implement placeholder methods @Override protected void id() { Log.d(DEBUG_TAG, "ISBN : " + doc.id); } @Override protected void date() { Log.d(DEBUG_TAG, doc.date); } private void author() { Log.d(DEBUG_TAG, doc.author); } // Include specialization placeholder methods protected abstract void rating(); }
-
Magazine
类应该如下所示:abstract class Magazine extends ReadingMaterial { //Specialization private static final String DEBUG_TAG = "tag"; // Implement placeholder methods @Override protected void id() { Log.d(DEBUG_TAG, "ISSN : " + doc.id); } @Override protected void edition() { Log.d(DEBUG_TAG, doc.period); } // Pass placeholder on to realization protected abstract void date(); }
-
现在我们可以创建具体的实现类。首先是书籍类:
public class SelectedBook extends Book { // Realization private static final String DEBUG_TAG = "tag"; // Implement specialization placeholders @Override protected void rating() { Log.d(DEBUG_TAG, "4 stars"); } }
-
接着是杂志类:
public class SelectedMagazine extends Magazine { // Realization private static final String DEBUG_TAG = "tag"; // Implement placeholder method only once instance created @Override protected void date() { Calendar calendar = new GregorianCalendar(); SimpleDateFormat format = new SimpleDateFormat("MM-d-yyyy"); format.setTimeZone(calendar.getTimeZone()); Log.d(DEBUG_TAG,format.format(calendar.getTime())); } }
-
创建一个 POJO 作为假数据,如下所示:
public class Document { String title; String genre; String id; String date; String author; String edition; String period; public Document() { this.title = "The Art of Sandwiches"; this.genre = "Non fiction"; this.id = "1-23456-789-0"; this.date = "06-19-1993"; this.author = "J Bloggs"; this.edition = "2nd edition"; this.period = "Weekly"; } }
-
现在可以通过以下主活动中的代码测试此模式:
// Print book
ReadingMaterial document = new SelectedBook();
document.fetchDocument();
// Print magazine
ReadingMaterial document = new SelectedMagazine();
document.fetchDocument();
通过更改虚拟文档代码,可以测试任何实现,并将产生如下输出:
D/tag: The Art of Sandwiches
D/tag: Non fiction
D/tag: ISBN : 1-23456-789-0
D/tag: 06-19-1963
D/tag: 2nd edition
D/tag: J Bloggs
D/tag: 4 stars
D/tag: Sandwich Weekly
D/tag: Healthy Living
D/tag: ISSN : 1-23456-789-0
D/tag: 09-3-2016
D/tag: Weekly
上一个例子简短且简单,但它演示了使模式如此有用和多变的每个特性,如下列表详细说明:
-
基类提供标准化的骨架定义和代码,正如
fetchDocument()
方法所展示的。 -
实现中共同的代码在基类中定义,例如
title()
和genre()
-
占位符在基类中定义,用于专门的实现,就像
date()
方法的管理方式一样。 -
派生类可以覆盖占位符方法和已实现的方法;请参阅
rating()
-
派生类可以使用
super
回调到基类,就像Book
类中的fetchDocument()
方法一样。
尽管模板模式一开始可能看起来很复杂,但由于有这么多元素是共享的,因此经过深思熟虑的概括和特殊化可以导致具体类中的代码非常简单和清晰,当我们处理的不仅仅是 一个或两个模板实现时,我们会为此感到庆幸。这种在抽象类中定义的代码集中,在模式类图中可以非常清楚地看到,派生类只包含与其单独相关的代码:
如章节开头所述,在给定情况下通常可以使用多种行为模式,我们之前讨论的模板模式,以及策略模式、访问者模式和状态模式,都适合这个类别,因为它们都是从概括的概要中派生出特殊情况的。这些模式都值得进行一些详细的探讨。
策略模式
策略模式与模板模式非常相似,真正的唯一区别在于个体实现创建的时机。模板模式在编译时发生,但策略模式在运行时发生,并且可以动态选择。
策略模式反映变化的发生,其输出取决于上下文,就像天气应用程序的输出取决于位置一样。我们可以在这个演示中使用这个场景,但首先考虑一下策略模式的类图:
使用天气示例可以轻松实现这一点。打开一个新项目,按照以下步骤查看如何操作:
-
从策略接口开始;它看起来像这样:
public interface Strategy { String reportWeather(); }
-
按照这里的类创建几个具体实现:
public class London implements Strategy { @Override public String reportWeather() { return "Constant drizzle"; } }
-
接下来,创建上下文类,这里就是位置:
public class Location { private Strategy strategy; public Location(Strategy strategy) { this.strategy = strategy; } public void executeStrategy(Context context) { TextView textView=(TextView) ((Activity)context) .findViewById(R.id.text_view); textView.setText(strategy.reportWeather()); } }
-
通过用字符串值模拟位置,我们可以使用以下客户端代码测试该模式:
Location context; String location = "London"; switch (location) { case "London": context = new Location(new London()); break; case "Glasgow": context = new Location(new Glasgow()); break; default: context = new Location(new Paris()); break; } context.executeStrategy(this);
正如这个例子所示,策略模式虽然与模板相似,但用于不同的任务,因为它们分别在运行时和编译时应用。
与此同时,除了应用我们自己的模板和策略外,大多数平台还会将其作为系统的一部分应用。在 Android 框架中,策略模式工作中的一个好例子就是每次设备旋转时,都会应用模板为不同设备安装布局。我们很快就会更详细地了解这一点,但首先还有另外两种模式我们需要检查。
访问者模式
与模板和策略模式一样,访问者模式足够灵活,可以执行我们迄今为止考虑的任何任务,与其他行为模式一样,关键在于将正确的模式应用于正确的问题。术语“访问者”可能不如“模板”或“策略”那么不言自明。
访问者模式旨在让客户端可以将一个过程应用于一组不相关对象,而无需关心它们之间的差异。一个现实世界的好例子就是我们去超市购物,可能会购买可以扫描条形码的罐装产品,以及需要称重的新鲜商品。这种差异在超市中不需要我们关心,因为收银员会帮我们处理所有这些事情。在这种情况下,收银员充当访问者,做出关于如何处理单个商品的所有必要决策,而我们(客户端)只需考虑最终的账单。
这并不完全符合我们对“访问者”一词的直观理解,但从设计模式的角度来看,这就是它的含义。另一个现实世界的例子是,如果我们希望穿越城镇。在这个例子中,我们可能会选择出租车或公交车。在这两种情况下,我们只关心最终目的地(也许还有费用),而让司机/访问者协商实际路线的细节。
按照以下步骤,看看如何实现一个访问者模式,以模拟之前概述的超市场景:
-
开始一个新的 Android 项目,并添加以下接口来定义购物项目,如下所示:
public interface Item { int accept(Visitor visitor); }
-
接下来,创建两个项目示例。首先是罐装食品:
public class CannedFood implements Item { private int cost; private String name; public CannedFood(int cost, String name) { this.cost = cost; this.name = name; } public int getCost() { return cost; } public String getName() { return name; } @Override public int accept(Visitor visitor) { return visitor.visit(this); } }
-
接着,添加新鲜食品项目类:
public class FreshFood implements Item { private int costPerKilo; private int weight; private String name; public FreshFood(int cost, int weight, String name) { this.costPerKilo = cost; this.weight = weight; this.name = name; } public int getCostPerKilo() { return costPerKilo; } public int getWeight() { return weight; } public String getName() { return name; } @Override public int accept(Visitor visitor) { return visitor.visit(this); } }
-
现在我们可以添加访问者接口本身,如下所示:
public interface Visitor { int visit(FreshFood freshFood); int visit(CannedFood cannedFood); }
-
然后,可以将其实现为以下
Checkout
类:public class Checkout implements Visitor { private static final String DEBUG_TAG = "tag"; @Override public int visit(CannedFood cannedFood) { int cost = cannedFood.getCost(); String name = cannedFood.getName(); Log.d(DEBUG_TAG, "Canned " + name + " : " + cost + "c"); return cost; } @Override public int visit(FreshFood freshFood) { int cost = freshFood.getCostPerKilo() * freshFood.getWeight(); String name = freshFood.getName(); Log.d(DEBUG_TAG, "Fresh " + name + " : " + cost + "c"); return cost; } }
-
我们现在可以看到模式如何让我们编写干净的客户端代码,如下所示:
public class MainActivity extends AppCompatActivity { private static final String DEBUG_TAG = "tag"; private int totalCost(Item[] items) { Visitor visitor = new Checkout(); int total = 0; for (Item item : items) { System.out.println(); total += item.accept(visitor); } return total; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Item[] items = new Item[]{ new CannedFood(65, "Tomato soup"), new FreshFood(60, 2, "Bananas"), new CannedFood(45, "Baked beans"), new FreshFood(45, 3, "Apples")}; int total = totalCost(items); Log.d(DEBUG_TAG, "Total cost : " + total + "c"); } }
这应该会产生如下输出:
D/tag: Canned Tomato soup : 65c D/tag: Fresh Bananas : 120c D/tag: Canned Baked beans : 45c D/tag: Fresh Apples : 135c D/tag: Total cost : 365
访问者模式有两个特别的优势。第一个是它使我们不必使用复杂的条件嵌套来区分项目类型。第二个,也是更重要的优势在于,访问者和被访问者是分开的,这意味着可以添加和修改新的项目类型,而无需对客户端进行任何更改。要了解如何做到这一点,只需添加以下代码:
-
打开并编辑
Visitor
接口,使其包含如下高亮显示的额外行:public interface Visitor { int visit(FreshFood freshFood); int visit(CannedFood cannedFood); int visit(SpecialOffer specialOffer); }
-
按如下方式创建一个
SpecialOffer
类:public class SpecialOffer implements Item { private int baseCost; private int quantity; private String name; public SpecialOffer(int cost, int quantity, String name) { this.baseCost = cost; this.quantity = quantity; this.name = name; } public int getBaseCost() { return baseCost; } public int getQuantity() { return quantity; } public String getName() { return name; } @Override public int accept(Visitor visitor) { return visitor.visit(this); } }
-
在
Checkout
访问者类中按如下方式重载visit()
方法:@Override public int visit(SpecialOffer specialOffer) { String name = specialOffer.getName(); int cost = specialOffer.getBaseCost(); int number = specialOffer.getQuantity(); cost *= number; if (number > 1) { cost = cost / 2; } Log.d(DEBUG_TAG, "Special offer" + name + " : " + cost + "c"); return cost; }
正如所示,访问者模式可以扩展以管理任意数量的项目和任意数量的不同解决方案。访问者可以一次使用一个,或者作为一系列处理过程的一部分,并且通常在导入具有不同格式的文件时使用。
我们在本章中看到的所有行为模式都有非常广泛的应用范围,可以用来解决各种软件设计问题。然而,有一个模式的应用范围甚至比这些还要广泛,那就是状态设计模式或状态机。
状态模式
状态模式无疑是所有行为模式中最灵活的一个。该模式展示了我们如何在代码中实现有限状态机。状态机是数学家艾伦·图灵的发明,他使用它们来实现通用计算机并证明任何数学上可计算的过程都可以机械地执行。简而言之,状态机可以用来执行我们选择的任何任务。
状态设计模式的工作机制简单而优雅。在有限状态机的生命周期中的任何时刻,该模式都知道其自身的内部状态和当前的外部状态或输入。基于这两个属性,机器将产生一个输出(可能没有)并改变其自身的内部状态(可能相同)。信不信由你,通过适当配置的有限状态机可以实现非常复杂算法。
展示状态模式的传统方式是使用在体育场馆或游乐场可能找到的投币式旋转门作为例子。这有两种可能的状态,锁定和解锁,并接受两种形式的输入,即硬币和物理推力。
要了解如何建模,请按照以下步骤操作:
-
启动一个新的 Android 项目,并构建一个类似于以下布局的界面:
-
添加以下接口:
public interface State { void execute(Context context, String input); }
-
接下来是
Locked
状态:public class Locked implements State { @Override public void execute(Context context, String input) { if (Objects.equals(input, "coin")) { Output.setOutput("Please push"); context.setState(new Unlocked()); } else { Output.setOutput("Insert coin"); } } }
-
接着是
Unlocked
状态:public class Unlocked implements State { @Override public void execute(Context context, String input) { if (Objects.equals(input, "coin")) { Output.setOutput("You have already paid"); } else { Output.setOutput("Thank you"); context.setState(new Locked()); } } }
-
创建以下单例以保存输出字符串:
public class Output { private static String output; public static String getOutput() { return output; } public static void setOutput(String o) { output = o; } }
-
接下来添加
Context
类,如下所示:public class Context { private State state; public Context() { setState(new Locked()); } public void setState(State state) { this.state = state; } public void execute(String input) { state.execute(this, input); } }
-
最后,编辑主活动以匹配以下代码:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { TextView textView; Button buttonCoin; Button buttonPush; Context context = new Context(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); textView = (TextView) findViewById(R.id.text_view); buttonCoin = (Button) findViewById(R.id.action_coin); buttonPush = (Button) findViewById(R.id.action_push); buttonCoin.setOnClickListener(this); buttonPush.setOnClickListener(this); } @Override public void onClick(View view) { switch (view.getId()) { case R.id.action_coin: context.execute("coin"); break; case R.id.action_push: context.execute("push"); break; } textView.setText(Output.getOutput()); } }
这个例子可能很简单,但它完美地展示了这个模式有多么强大。很容易看出同样的方案如何扩展来模拟更复杂的锁定系统,而有限状态机通常用于实现组合锁。正如前面提到的,状态模式可以用来模拟任何可以数学建模的事物。前面的例子很容易测试,也很容易扩展:
状态模式的真正魅力不仅在于它极其灵活,而且在于它在概念上的简单性,这一点在类图上可以看得最清楚:
状态模式,就像本章中的所有模式和其他行为模式一样,具有非常高的灵活性,这种能够适应大量情况的能力源于它们的抽象性质。这可能会使得行为模式在概念上更难以掌握,但通过一些尝试和错误是找到适合各种情境的正确模式的好方法。
总结
行为模式在结构上可能非常相似,功能上也有很多重叠,本章大部分内容是理论性的,以便我们可以集体地接近它们。一旦我们熟悉了这些结构,我们就会发现自己会经常在许多情况下返回到它们。
在下一章中,我们将专注于更多技术性的事务,并了解如何为各种可用的表单因子开发应用程序,例如手表和电视屏幕。从我们目前完成的工作来看,我们可以发现如何使用访问者模式等模式来管理这些选择。正如我们已经经历过的,系统为我们管理了大部分这些工作,经常使用它自己的内置模式。尽管如此,在设计模式中,我们仍有很多机会简化并合理化我们的代码。
第十一章:可穿戴设备模式
迄今为止,在这本书中,我们考虑的所有 Android 应用程序都是为移动设备(如手机和平板电脑)设计的。正如我们所见,框架提供了极大的便利,确保我们的设计能在各种屏幕大小和形状上良好工作。然而,还有三种形态因素是我们至今未涉及的,那就是如手表、车载控制台和电视机等可穿戴设备。
当涉及到将这些设计模式应用于这些替代平台时,我们选择哪种模式取决于应用程序的目的,而不是平台本身。由于我们在上一章中重点讨论了模式,本章将主要涵盖为这些设备类型构建应用程序的实际操作。然而,当我们查看电视应用程序时,会发现它们采用了模型-视图-呈现者模式。
由于我们尚未处理编码传感器的部分,章节将包括探索如何读取用户的心率,并让我们的代码对此作出响应。物理传感器(如心率监测器和加速度计)的管理方式非常相似,通过研究其中一个,我们可以了解如何处理其他传感器。
在本章中,你将学习如何:
-
设置电视应用程序
-
使用 leanback 库
-
应用 MVP 模式
-
创建横幅和媒体组件
-
理解浏览器和消费视图
-
连接到可穿戴设备
-
管理可穿戴设备的屏幕形状
-
处理可穿戴设备的通知
-
读取传感器数据
-
理解自动安全特性
-
为媒体服务配置自动应用程序
-
为消息服务配置自动应用程序
在为这个广泛的形态因素开发时,首先要考虑的不仅仅是需要准备图形的大小,还有观看距离。大多数 Android 设备从几英寸远的地方使用,并且经常设计为可旋转、移动和触摸。这里的例外是电视屏幕,通常是从大约 10 英尺远的地方观看。
安卓电视
电视通常最适合于观看电影、电视节目和玩游戏等放松活动。然而,在这些活动中仍然有很大的重叠区域,尤其是在游戏方面,许多应用程序可以轻松转换为在电视上运行。观看距离、高清晰度和控制器设备意味着需要做出一些适应,这主要得益于 leanback 支持库的帮助。这个库促进了模型-视图-呈现者(model-view-presenter)设计模式的实现,这是模型-视图-控制器(model-view-controller)模式的一种适应。
对于电视,可以开发各种类型的应用,但其中很大一部分属于两类:游戏和媒体。与通常受益于独特界面和控制的游戏不同,基于媒体的应用通常应使用平台熟悉的和一致的控件和小部件。这就是leanback 库发挥作用的地方,它提供了各种详细、浏览器和搜索小部件,以及覆盖层。
leanback 库并不是唯一对电视开发有用的支持库,CardView 和 RecyclerView 也很有用,实际上 RecyclerView 是必需的,因为一些 leanback 类依赖于它。
Android Studio 提供了一个非常实用的电视模块模板,它提供了十几个展示许多基于媒体的电视应用所需功能的类。仔细研究这个模板是非常值得的,因为它是一个相当好的教程。然而,除非项目性质相当通用,否则它不一定是单个项目的最佳起点。如果你计划进行任何原创项目,有必要了解有关如何设置电视项目的一些知识,从设备主屏幕开始。
电视主屏幕
主屏幕是 Android TV 用户的入口点。从这里,他们可以搜索内容,调整设置,访问应用和游戏。用户对我们的应用的第一印象将是在这个屏幕上以横幅图像的形式出现。
每个电视应用都有一个横幅图像。这是一个 320 x 180 dp 的位图,应该以简单高效的方式展示我们的应用功能。例如:
横幅也可以包含丰富多彩的摄影图像,但文本应始终保持粗体并尽量简练。然后可以在项目清单中声明横幅。要了解如何进行此操作,以及如何设置其他与电视应用相关的清单属性,请按照以下步骤操作:
-
开始一个新项目,选择TV作为Target Android Device,选择Android TV Activity作为活动模板。
-
将你的图像添加到 drawable 文件夹中,并命名为
banner
或类似名称。 -
打开
manifests/AndroidManifest.xml
文件。 -
删除以下行:
android:banner="@drawable/app_icon_your_company"
-
编辑开头的
<application>
节点,包含以下高亮行:<application android:allowBackup="true" android:banner="@drawable/banner" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.Leanback">
-
在根
<manifest>
节点中,添加以下属性:<uses-feature android:name="android.hardware.microphone" android:required="false" />
最后一个<uses-feature>
节点不是严格必需的,但它将使你的应用适用于没有内置麦克风的老款电视。如果你的应用依赖于语音控制,那么省略这个属性。
我们还需要为我们的主活动声明一个 leanback 启动器,操作如下:
<intent-filter>
<action
android:name="android.intent.action.MAIN" />
<category
android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
如果您仅针对电视构建应用,那么在 Play 商店的电视部分使您的应用可用需要做的就是这些。然而,您可能正在开发可以在其他设备上玩的游戏等应用程序。在这种情况下,请包含以下条款以使其适用于可以旋转的设备:
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
在这些情况下,您还应该将android.software.leanback
设置为required="false"
,并恢复到材料或appcompat主题。
您可能想知道为什么我们将横幅声明从主活动移动到整个应用。这并非绝对必要,我们所做的是将一个横幅应用于整个应用,不管它包含多少个活动。除非您希望每个活动都有不同的横幅,否则这通常是最佳做法。
电视模型-视图-呈现器模式
Leanback 库是少数几个直接促进设计模式使用的库之一,即模型-视图-呈现器(MVP)模式,它是模型-视图-控制器(MVC)的衍生物。这两种模式都非常简单和明显,有些人可能会说它们实际上并不真正符合模式的定义。即使您以前从未接触过设计模式,您也可能会应用其中一种或两种架构。
我们之前简要介绍了 MVC 和 MVP,但回顾一下,在 MVC 模式中,视图和控制器是分开的。例如,当控制器从用户那里接收输入,比如按钮的点击,它会将此传递给模型,模型执行其逻辑并将这些更新的信息转发给视图,然后视图向用户显示这些更改,依此类推。
MVP 模式结合了视图和控制器两者的功能,成为用户和模型之间的中介。这是我们之前在适配器模式中看到过的,特别是回收视图及其适配器的工作方式。
Leanback 呈现器类也与嵌套的视图持有者一起工作,在 MVP 模式方面,视图可以是任何 Android 视图,模型可以是任何我们选择的 Java 对象或对象集合。这意味着我们可以使用呈现器作为我们选择的任何逻辑和任何布局之间的适配器。
尽管这个系统很自由,但在开始项目开发之前,了解一下电视应用开发中的一些约定是值得的。
电视应用结构
大多数媒体电视应用提供有限的功能集,这通常就是所需要的一切。大多数情况下,用户希望:
-
浏览内容
-
搜索内容
-
消费内容
Leanback 库为这些提供了片段类。一个典型的浏览器视图由BrowserFragment
提供,模板通过一个简单的示例演示了这一点,以及一个SearchFragment
:
消费视图由PlaybackOverlayFragment
提供,可能是最简单的视图,包含的元素比 VideoView 和控制按钮多不了多少。
还有一个DetailsFragment
,它提供特定内容的信息。这个视图的内容和布局取决于主题内容,可以采取你选择的任何形式,常规的材料设计规则同样适用。设计视图从消费视图的底部向上滚动:
Leanback 库使得将材料设计引入电视设备变得轻而易举。如果你决定使用其他地方的视图,那么适用于其他地方的同材料规则在这里同样适用。在继续之前,值得一提的是背景图片需要在边缘留出 5%的出血区域,以确保它们能够覆盖所有电视屏幕的边缘。这意味着一个 1280 x 720 像素的图片需要是 1408 x 792 像素。
之前,我们介绍了用于启动应用程序的横幅图像,但我们还需要一种方法来引导用户访问个别内容,尤其是熟悉或相关的内容。
推荐卡片
安卓电视主屏幕的顶部行是推荐行。这允许用户根据他们的观看历史快速访问内容。内容之所以被推荐,可能是因为它是之前观看内容的延续,或者基于用户的观看历史以某种方式相关。
设计推荐卡片时,我们需要考虑的设计因素寥寥无几。这些卡片由图片或大图标、标题、副标题和应用程序图标构成,如下所示:
在卡片图片的宽高比方面有一定的灵活性。卡片的宽度绝不能小于其高度的 2/3 或超过 3/2。图片内部不能有透明元素,且高度不得小于 176 dp。
提示
大面积的白色在许多电视上可能相当刺眼。如果你需要大面积的白色,使用#EEE 而不是#FFF。
如果你查看一下实时安卓电视设置中的推荐行,你会看到每个卡片被选中时,背景图像会发生变化,我们也应该为每个推荐卡片提供背景图像。这些图像必须与卡片上的图像不同,并且是 2016 x 1134 像素,以允许 5%的出血,并确保它们不会在屏幕边缘留下空隙。这些图像也不应有透明部分。
设计如此大屏幕的挑战为我们提供了机会,可以包含丰富多彩、高质量的图像。在这个尺寸范围的另一端是可穿戴设备,空间极为宝贵,需要完全不同的方法。
安卓穿戴
可穿戴 Android 应用由于另一个原因也值得特别对待,那就是几乎所有 Android Wear 应用都作为伴侣应用,并与在用户手机上运行的主模块结合工作。这种绑定是一个有趣且直接的过程,许多移动应用可以通过添加可穿戴组件大大增强功能。另一个使可穿戴设备开发变得非常有趣的特点是,有许多激动人心的新型传感器和设备。特别是,许多智能手表中配备的心率监测器在健身应用中已经证明非常受欢迎。
可穿戴设备是智能设备开发中最激动人心的领域之一。智能手机和其他配备一系列新型传感器的可穿戴设备为开发者开启了无数新的可能性。
在可穿戴设备上运行的应用需要连接到在手机上运行的主应用,最好将其视为主应用的一个扩展。尽管大多数开发者至少能接触到一部手机,但可穿戴设备对于仅用于测试来说可能是一个昂贵的选项,特别是因为我们至少需要两部设备。这是因为方形和圆形屏幕处理方式的不同。幸运的是,我们可以创建带有模拟器的 AVD,并将其连接到真实的手机或平板电脑,或者是虚拟设备。
与可穿戴设备配对
要最好地了解圆形和方形屏幕管理的区别,首先为每种屏幕创建一个模拟器:
提示
还有一个带下巴的版本,但对于编程目的我们可以将其视为圆形屏幕。
您如何配对可穿戴 AVD 取决于您是将其与真实手机还是另一个模拟器配对。如果您使用手机,需要从以下位置下载 Android Wear 应用:
play.google.com/store/apps/details?id=com.google.android.wearable.app
然后找到 adb.exe
文件,默认情况下位于 user\AppData\Local\Android\sdk\platform-tools\
在此打开命令窗口,并输入以下命令:
adb -d forward tcp:5601 tcp:5601
您现在可以启动伴侣应用并按照说明配对设备。
注意
您每次连接手机时都需要执行这个端口转发命令。
如果您要将可穿戴模拟器与模拟手机配对,那么您需要一个针对 Google APIs 而不是常规 Android 平台的 AVD。然后您可以下载 com.google.android.wearable.app-2.apk
。在网上有许多地方可以找到这个文件,例如:www.file-upload.net/download
apk 文件应放在您的 sdk/platform-tools
目录中,可以用以下命令安装:
adb install com.google.android.wearable.app-2.apk
现在启动您的可穿戴 AVD,并在命令提示符中输入 adb devices
,确保两个模拟器都能用类似以下输出显示出来:
List of devices attached
emulator-5554 device
emulator-5555 device
输入:
adb telnet localhost 5554
在命令提示符下,其中 5554
是手机模拟器。接下来,输入 adb redir add tcp:5601:5601\.
现在你可以使用手持式 AVD 上的 Wear 应用连接到手表。
创建 Wear 项目时,你需要包含两个模块,一个用于可穿戴组件,另一个用于手机。
Android 提供了一个 可穿戴 UI 支持库,为 Wear 开发者和设计师提供了一些非常有用的功能。如果你使用向导创建了一个可穿戴项目,这将在设置过程中包含。否则,你需要在 Module: wear
的 build.gradle
文件中包含以下依赖项:
compile 'com.google.android.support:wearable:2.0.0-alpha3'
compile 'com.google.android.gms:play-services-wearable:9.6.1'
你还需要在 Module: mobile 构建文件中包含以下这些行:
wearApp project(':wear')
compile 'com.google.android.gms:play-services:9.6.1'
管理屏幕形状
我们无法提前知道应用将在哪些形状的屏幕上运行,对此有两个解决方案。第一个,也是最明显的,就是为每种形状创建一个布局,这通常是最佳解决方案。如果你使用向导创建了一个可穿戴项目,你会看到模板活动已经包含了这两种形状。
当应用在实际设备或模拟器上运行时,我们仍然需要一种方法来检测屏幕形状,以便知道要加载哪个布局。这是通过 WatchViewStub 实现的,调用它的代码必须包含在我们主活动文件的 onCreate()
方法中,如下所示:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final WatchViewStub stub = (WatchViewStub)
findViewById(R.id.watch_view_stub);
stub.setOnLayoutInflatedListener(
new WatchViewStub.OnLayoutInflatedListener() {
@Override
public void onLayoutInflated(WatchViewStub stub) {
mTextView = (TextView) stub.findViewById(R.id.text);
}
});
}
这可以在 XML 中如下实现:
<android.support.wearable.view.WatchViewStub
android:id="@+id/watch_view_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rectLayout="@layout/rect_activity_main"
app:roundLayout="@layout/round_activity_main"
tools:context=".MainActivity"
tools:deviceIds="wear">
</android.support.wearable.view.WatchViewStub>
为每种屏幕形状创建独立布局的替代方法是使用一种本身能感知屏幕形状的布局。这就是 BoxInsetLayout 的形式,它会为圆形屏幕调整内边距设置,并且只在该圆圈中最大可能的正方形内定位视图。
BoxInsetLayout 可以像其他任何布局一样使用,作为主 XML 活动中的根 ViewGroup:
<android.support.wearable.view.BoxInsetLayout
android:layout_height="match_parent"
android:layout_width="match_parent">
. . .
</android.support.wearable.view.BoxInsetLayout>
这种方法确实有一些缺点,因为它并不总是能充分利用圆形表盘上的空间,但 BoxInsetLayout 在灵活性方面的不足,通过易用性得到了弥补。在大多数情况下,这根本不是缺点,因为设计良好的 Wear 应用应该只通过简单信息短暂吸引用户的注意力。用户不希望在手表上导航复杂的 UI。我们在手表屏幕上显示的信息应该能够一眼就被吸收,响应动作应该限制在不超过一次点击或滑动。
智能设备的主要用途之一是当用户无法访问手机时接收通知,例如在锻炼时。
可穿戴设备通知
在任何移动应用中添加可穿戴通知功能非常简单。回想一下通知是如何从 第九章,观察模式 中传递的:
private void sendNotification(String message) {
NotificationCompat.Builder builder =
(NotificationCompat.Builder)
new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_stat_bun)
.setContentTitle("Sandwich Factory")
.setContentText(message);
NotificationManager manager =
(NotificationManager)
getSystemService(NOTIFICATION_SERVICE);
manager.notify(notificationId, builder.build());
notificationId += 1;
}
要使通知也发送到配对的穿戴设备,只需将这两行添加到构建器字符串中:
.extend(new NotificationCompat.WearableExtender()
.setHintShowBackgroundOnly(true))
可选的setHintShowBackgroundOnly
设置允许我们不显示背景卡片而只显示通知。
大多数时候,穿戴设备被用作输出设备,但它也可以作为输入设备,并且当传感器靠近身体时,可以派生出许多新功能,比如许多智能手机中包含的心率监测器。
读取传感器
目前大多数智能设备上都配备了越来越多的传感器,智能手表为开发者提供了新的机会。幸运的是,这些传感器编程非常简单,毕竟它们只是另一种输入设备,因此我们使用监听器来观察它们。
尽管单个传感器的功能和用途存在很大差异,但读取它们的方式几乎相同,唯一的区别在于它们输出的性质。下面我们将看看许多可穿戴设备上找到的心率监测器:
-
打开或启动一个 Wear 项目。
-
打开穿戴模块,并在主活动 XML 文件中添加一个带有 TextView 的 BoxInsetLayout,如下所示:
<android.support.wearable.view.BoxInsetLayout android:layout_height="match_parent" android:layout_width="match_parent"> <TextView android:id="@+id/text_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" /> </android.support.wearable.view.BoxInsetLayout>
-
打开穿戴模块中的 Manifest 文件,并在根
manifest
节点内添加以下权限。<uses-permission android:name="android.permission.BODY_SENSORS" />
-
打开穿戴模块中的主 Java 活动文件,并添加以下字段:
private TextView textView; private SensorManager sensorManager; private Sensor sensor;
-
在活动上实现一个
SensorEventListener
:public class MainActivity extends Activity implements SensorEventListener {
-
实现监听器所需的两个方法。
-
如下编辑
onCreate()
方法:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); textView = (TextView) findViewById(R.id.text_view); sensorManager = ((SensorManager) getSystemService(SENSOR_SERVICE)); sensor = sensorManager.getDefaultSensor (Sensor.TYPE_HEART_RATE); }
-
添加这个
onResume()
方法:protected void onResume() { super.onResume(); sensorManager.registerListener(this, this.sensor, 3); }
-
以及这个
onPause()
方法:@Override protected void onPause() { super.onPause(); sensorManager.unregisterListener(this); }
-
如下编辑
onSensorChanged()
回调:@Override public void onSensorChanged(SensorEvent event) { textView.setText(event.values[0]) + "bpm"; }
如你所见,传感器监听器与点击和触摸监听器一样,完全像观察者一样工作。唯一的真正区别是传感器需要显式注册和注销,因为它们默认不可用,并且在完成操作后需要关闭以节省电池。
所有传感器都可以通过传感器事件监听器以相同的方式管理,通常最好在初始化应用时检查每个传感器的存在,方法是:
private SensorManager sensorManagerr = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null){
. . .
}
else {
. . .
}
穿戴设备开启了应用可能性的全新世界,将 Android 带入我们生活的各个方面。另一个例子就是在我们的汽车中使用 Android 设备。
Android Auto
与 Android TV 一样,Android Auto 可以运行许多最初为移动设备设计的应用。当然,在车载软件中,安全是首要考虑的因素,这也是为什么大多数 Auto 应用主要集中在音频功能上,比如信息和音乐。
注意
由于对安全的重视,Android Auto 应用在发布前必须经过严格的测试。
几乎不用说,开发车载应用时安全是首要原则,因此,Android Auto 应用程序几乎都分为两类:音乐或音频播放器和信息传递。
所有应用在开发阶段都需要进行广泛测试。显然,在实车上测试 Auto 应用是不切实际且非常危险的,因此提供了 Auto API 模拟器。这些可以从 SDK 管理器的工具标签中安装。
Auto 安全考虑因素
许多关于 Auto 安全的规则都是简单的常识,比如避免动画、分心和延迟,但当然需要对这些进行规范化,谷歌也这样做了。这些规则涉及驾驶员注意力、屏幕布局和可读性。最重要的可以在这里找到:
-
Auto 屏幕上不能有动画元素
-
只允许有声广告
-
应用必须支持语音控制
-
所有按钮和可点击控件必须在两秒内响应
-
文本必须超过 120 个字符,并且始终使用默认的 Roboto 字体
-
图标必须是白色,以便系统控制对比度
-
应用必须支持日间和夜间模式
-
应用必须支持语音命令
-
应用特定按钮必须在两秒内响应用户操作
您可以在以下链接找到详尽的列表:
developer.android.com/distribute/essentials/quality/auto.html
重要提示:在发布之前,谷歌会测试这些以及其他一些规定,因此您自己运行所有这些测试是至关重要的。
提示
设计适用于日间和夜间模式的应用,并使系统可以控制对比度,以便在不同光线条件下自动保持可读性,这是一个非常详细的课题,谷歌提供了一个非常有用的指南,可以在以下链接找到:commondatastorage.googleapis.com/androiddevelopers/shareables/auto/AndroidAuto-custom-colors.pdf
除了安全和应用类型的限制之外,Auto 应用与我们所探讨的其他应用在设置和配置上的唯一不同。
配置 Auto 应用
如果您使用工作室向导来设置 Auto 应用,您会看到,与 Wear 应用一样,我们必须同时包含移动和 Auto 模块。与可穿戴项目不同,这并不涉及第二个模块,一切都可以从移动模块管理。添加 Auto 组件会提供一个配置文件,可以在res/xml
中找到。例如:
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
对于消息应用,我们会使用以下资源:
<uses name="media" />
通过检查模板生成的清单文件,可以找到其他重要的 Auto 元素。无论您选择开发哪种类型的应用,都需要添加以下元数据:
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
您可以想象,音乐或音频提供者需要伴随启动活动的一个服务,而消息应用则需要一个接收器。音乐服务标签如下所示:
<service
android:name=".SomeAudioService"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
对于一个消息应用,我们需要一个服务以及两个接收器,一个用于接收消息,一个用于发送消息,如下所示:
<service android:name=".MessageService">
</service>
<receiver android:name=".MessageRead">
<intent-filter>
<action android:name="com.kyle.someapplication.ACTION_MESSAGE_READ" />
</intent-filter>
</receiver>
<receiver android:name=".MessageReply">
<intent-filter>
<action android:name="com.kyle.someapplication.ACTION_MESSAGE_REPLY" />
</intent-filter>
</receiver>
车载设备是 Android 开发中增长最快的领域之一,随着免提驾驶变得越来越普遍,这一领域预计将进一步增长。通常,我们可能只想将单个 Auto 功能集成到主要为其他形态因子设计的应用程序中。
与手持和可穿戴设备不同,我们不必过分关注屏幕尺寸、形状或密度,也不必担心特定车辆的制造商或型号。随着驾驶和交通方式的变化,这无疑将在不久的将来发生变化。
总结
本章描述的替代形态因子为开发人员以及我们可以创建的应用类型提供了令人激动的新平台。这不仅仅是针对每个平台开发应用程序的问题,完全有可能在单个应用程序中包含这三种设备类型。
以我们之前看过的三明治制作应用为例;我们可以轻松地调整它,让用户在观看电影时下单三明治。同样,我们也可以将订单准备好的通知发送到他们的智能手机或自动控制台。简而言之,这些设备为新的应用程序和现有应用程序的附加功能开辟了市场。
无论我们的创造多么巧妙或多功能,很少有应用程序不能从社交媒体提供的推广机会中受益。一个单一的tweet或like可以在不花费广告费用的情况下,触及无数的人。
在下一章中,我们将看到向应用程序中添加社交媒体功能是多么容易,以及我们如何将 Web 应用功能构建到 Android 应用中,甚至使用 SDK 的 webkit 和 WebView 构建完整的 Web 应用。
第十二章:社交模式
到目前为止,在这本书中我们已经涵盖了移动应用开发的许多方面。然而,即使设计得最好和最有用的应用也可以通过采用社交媒体和其他网页内容获得巨大的好处。
我们在前面章节中介绍的快餐制作应用是一个很好的例子,这个应用可以通过生成 Facebook 点赞和推文来提升其知名度,而这些以及其他社交媒体都提供了直接将这些功能整合到我们应用中的技术。
除了将现有的社交媒体平台整合到我们的应用中,我们还可以使用 WebView 类将任何喜欢的网页内容直接嵌入到活动中。这个视图类的扩展可以用来向应用添加单个网页,甚至构建完整的网页应用。当我们的产品或数据需要定期更新时,WebView 类非常有用,因为这样可以实现,无需重新编码和发布更新。
我们将从查看 WebView 类开始本章,并了解如何引入 JavaScript 以赋予页面功能;然后,我们将探索一些社交媒体 SDK,它们允许我们整合许多功能,如分享、发布和点赞。
在本章中,你将学习如何执行以下操作:
-
在 WebView 中打开网页
-
在浏览器中打开网页
-
启用和使用 JavaScript
-
使用 JavaScriptInterface 将脚本与原生代码绑定
-
为网页应用编写高效的 HTML
-
创建一个 Facebook 应用
-
添加一个 LikeView 按钮
-
创建一个 Facebook 分享界面
-
集成 Twitter
-
发送推文
添加网页
使用 WebView 类在活动或片段中包含单个网页几乎和添加其他类型的视图一样简单。以下是三个简单步骤:
-
在清单中添加以下权限:
<uses-permission android:name="android.permission.INTERNET" />
-
WebView
本身看起来像这样:<WebView android:id="@+id/web_view" android:layout_width="match_parent" android:layout_height="match_parent" />
-
最后,添加页面的 Java 代码如下:
WebView webView = (WebView) findViewById(R.id.web_view);
webView.loadUrl("https://www.packtpub.com/");
这就是全部内容,尽管你可能想要移除或减少大多数页面默认的 16dp 边距。
当处理专门为我们的应用设计的页面时,这个系统非常理想。如果我们想将用户发送到任何其他网页,那么使用链接被认为是更好的做法,这样用户就可以使用他们选择的浏览器打开它。
包含一个链接
为此,任何可点击的视图都可以作为链接,然后点击监听器可以像这样响应:
@Override
public void onClick(View v) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setData(Uri.parse("https://www.packtpub.com/"));
startActivity(intent);
}
我们可以看到,正确使用 WebView 是将专门设计为应用一部分的页面融入进来。尽管用户需要知道他们处于在线状态(可能产生费用),但我们的 WebView 应该看起来和表现得像应用的其他部分一样。在屏幕上可以有多个 WebView,并且可以与其他小部件和视图混合使用。如果我们正在开发一个存储用户详细信息的应用,通常使用网页工具来管理会比使用 Android API 更容易。
WebView 类附带了一系列全面的设置,可以用来控制许多属性,比如缩放功能、图片加载和显示设置。
配置 WebSettings 和 JavaScript
尽管我们可以设计网页视图,使其看起来像其他应用程序组件,但它们当然拥有许多与网页相关的属性,并且可以作为网页元素,像在浏览器中一样进行导航。这些和其他设置都由WebSettings类优雅地管理。
这个类主要由一系列设置器和获取器组成。整个集合可以这样初始化:
WebView webView = (WebView) findViewById(R.id.web_view);
WebSettings webSettings = webView.getSettings();
我们现在可以使用这个对象来查询网页视图的状态,并将它们配置为我们所希望的样子。例如,默认情况下禁用 JavaScript,但可以轻松更改:
webSettings.setJavaScriptEnabled(true);
有许多这样的方法,所有这些都在文档中列出:
developer.android.com/reference/android/webkit/WebSettings.html
这些设置并不是我们控制网页视图的唯一方式,它还有一些非常有用的自有方法,其中大部分在这里列出:
-
getUrl()
- 返回网页视图当前的 URL -
getTitle()
- 如果 HTML 中指定了页面标题,则返回页面标题。 -
getAllAsync(String)
- 简单的搜索功能,突出显示给定字符串的出现 -
clearHistory()
- 清空当前历史缓存 -
destroy()
- 关闭并清空网页视图 -
canGoForward()
和canGoBack()
- 启用本地历史堆栈
这些方法,连同网页设置,使我们能够使用网页视图做更多的事情,而不仅仅是访问可更改的数据。只要稍加努力,我们就能提供大部分网络浏览器功能。
无论我们是选择将网页视图作为应用程序的无缝部分呈现,还是为用户提供更全面的基于互联网的体验,我们很可能会希望在自己的页面中包含一些 JavaScript。我们之前了解到如何启用 JavaScript,但这仅允许我们运行独立的脚本;更好的是,如果我们能从 JavaScript 调用 Android 方法,这正是JavaScriptInterface
所做的。
使用这种接口来管理两种语言之间的自然不兼容性,这当然是适配器设计模式的经典示例。要了解如何实现这一点,请按照以下步骤操作:
-
将以下字段添加到用于任务的任何活动中:
public class WebViewActivity extends Activity { WebView webView; JavaScriptInterface jsAdapter;
-
按如下方式编辑
onCreate()
方法:@Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); webView = (WebView) findViewById(R.id.web_view); WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); jsAdapter = new JavaScriptInterface(this); webView.addJavascriptInterface(jsAdapter, "jsAdapter"); webView.loadUrl("http://someApp.com/somePage.html"); }
-
创建适配器类(这也可能是内部类)。
newActivity()
方法可以是任何我们选择的内容。这里,仅作为示例,它启动了一个新的活动:public class JavaScriptInterface { Context context; JavaScriptInterface(Context c) { context = c; } // App targets API 16 and higher @JavascriptInterface public void newActivity() { Intent i = new Intent(WebViewActivity.this, someActivity.class); startActivity(i); } }
-
剩下的就是编写 JavaScript 来调用我们的原生方法。这里可以使用任何可点击的 HTML 对象。在您的页面上创建以下按钮:
<input type="button" value="OK" onclick="callandroid()" />
-
现在,只需在脚本中定义函数,如下所示:
<script type="text/javascript"> function callandroid() { isAdapter.newActivity(); } </script>
这个过程实施起来非常简单,使 WebView 成为一个非常强大的组件,而且能够从网页中调用我们的 Java 方法意味着我们可以将网页功能整合到任何应用中,而无需牺牲移动功能。
尽管在构建网页时你不需要任何帮助,但在最佳实践方面仍有一两点需要注意。
为 WebViews 编写 HTML
人们可能会认为移动网页应用的设计遵循与移动网页类似的约定,在许多方面确实如此,但以下列表指出了一两个细微的差别:
- 确保你使用了正确的
DOCTYPE
,在我们的情况下是这样的:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN"
"http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">
-
创建单独的 CSS 和脚本文件可能会导致连接变慢。请将此代码内联,理想情况下放在 head 中或 body 的最后。遗憾的是,这意味着我们必须避免使用 CSS 和网页框架,而且像材料设计这样的特性必须手动编码。
-
尽可能避免水平滚动。如果应用确实需要这样做,那么请使用标签页,或者更好的是,使用滑动导航抽屉。
如我们所见,WebView 是一个强大的组件,它使得开发复杂的移动/网页混合应用变得非常简单。这个主题非常广泛,实际上可以专门用一整本书来介绍。但现在,仅仅理解这个工具的范围和力量就足够了。
使用内置的网页工具只是我们利用互联网力量的方式之一。能够连接到社交媒体可能是推广产品最有效且成本最低的方法之一。其中最实用且最简单设置的是 Facebook。
连接到 Facebook
Facebook 不仅是最大的社交网络之一,而且它的设置非常完善,能够帮助那些希望推广产品的人。这种方式可以通过提供自动登录、可定制的广告以及用户与他人分享他们喜欢的产品等多种方式实现。
要将 Facebook 功能整合到我们的 Android 应用中,我们需要Android 的 Facebook SDK,为了充分利用它,我们还需要一个 Facebook 应用 ID,这需要我们在 Facebook 上创建一个简单的应用:
添加 Facebook SDK
将 Facebook 功能添加到我们的应用中的第一步是下载 Facebook SDK。可以在以下位置找到:
developers.facebook.com/docs/android
SDK 是一套强大的工具,包括视图、类和接口,Android 开发者将会非常熟悉。Facebook SDK 可以被视为我们本地 SDK 的有用扩展。
在 Facebook 开发者页面上可以找到一个方便的快速入门指南,但像往常一样,在这种情况下,按照以下步骤手动操作会更加具有指导意义:
-
使用最低 API 级别为 15 或更高启动新的 Android Studio 项目。
-
打开模块化的
build.gradle
文件,并做出这里强调的更改:repositories { mavenCentral() } dependencies { . . . compile 'com.android.support:appcompat-v7:24.2.1' compile 'com.facebook.android:facebook-android-sdk:(4,5)' testCompile 'junit:junit:4.12' }
-
在清单文件中添加以下权限:
<uses-permission android:name="android.permission.INTERNET" />
-
然后,将以下库导入到您的主活动或应用类中:
import com.facebook.FacebookSdk; import com.facebook.appevents.AppEventsLogger;
-
最后,从启动活动的
onCreate()
方法中初始化 SDK,如下所示:FacebookSdk.sdkInitialize(getApplicationContext()); AppEventsLogger.activateApp(this);
这并不是我们前进所需的全部,但在我们继续之前,我们需要一个 Facebook App ID,我们只能通过在 Facebook 上创建应用来获得。
获取 Facebook App ID
如您所见,Facebook 应用可以非常复杂,它们的功能仅受创建者的想象力和编程能力的限制。它们可以,而且经常是,仅仅是一个简单的页面,当我们的重点是 Android 应用时,我们只需要最简单的 Facebook 应用即可。
目前,使用 Facebook 快速入门流程,可以在以下位置找到:
https://developers.facebook.com/quickstarts
一旦您点击 创建 App ID,您将被带到开发者仪表盘。App ID 可以在窗口的左上角找到。以下两个步骤演示了如何完成我们之前开始的过程:
-
打开
res/values/strings.xml
文件,并添加以下值:<string name="facebook_app_id">APP ID HERE</string>
-
现在,在清单文件的 application 标签中添加以下元数据:
<meta-data android:name="com.facebook.sdk.ApplicationId" android:value="@string/facebook_app_id" />
这完成了将我们的 Android 应用连接到其 Facebook 对应应用的过程,但我们需要通过向 Facebook 应用提供有关我们的移动应用的信息来完善这个连接。
为此,我们需要回到 Facebook 开发者仪表盘,从您的个人资料(右上角)下拉菜单中选择 开发者设置,然后点击 示例应用 选项卡。这将要求您输入您的包名、启动活动以及 哈希密钥。
如果您正在开发打算发布的应用,或者为所有项目使用同一个哈希密钥,您会知道它,或者能马上拿到它。否则,以下代码会为您找到它:
PackageInfo packageInfo;
packageInfo = getPackageManager()
.getPackageInfo("your.package.name",
PackageManager.GET_SIGNATURES);
for (Signature signature : packageInfo.signatures) {
MessageDigest digest;
digest = MessageDigest.getInstance("SHA");
digest.update(signature.toByteArray());
String key = new
String(Base64.encode(digest.digest(), 0));
System.out.println("HASH KEY", key);
}
如果您直接输入这段代码,Studio 会通过快速修复功能提供一系列库供您选择导入。正确的选择如下:
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.util.Base64;
import com.facebook.FacebookSdk;
import com.facebook.appevents.AppEventsLogger;
import java.security.MessageDigest;
这其中的内容比想象中要多,但现在我们的应用已经连接到了 Facebook,我们可以利用所有的推广机会。其中最重要的之一就是 Facebook 的点赞按钮。
添加 LikeView
您可以想象,Facebook SDK 配备了传统的 点赞 按钮。这个按钮作为一个视图提供,可以像添加其他任何视图一样添加:
<com.facebook.share.widget.LikeView
android:id="@+id/like_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
与其他视图和小部件一样,我们可以在 Java 活动内部修改这个视图。我们可以用这个和其他 Facebook 视图做很多事情,Facebook 对此有详尽的文档。例如,LikeView 的文档可以在这里找到:
developers.facebook.com/docs/reference/android/current/class/LikeView
目前,我们可以考虑用户点赞的是什么。这是通过 setObjectId()
方法实现的,它接受一个字符串参数,可以是您的应用 ID 或者一个 URL,如下所示:
LikeView likeView = (LikeView) findViewById(R.id.like_view);
likeView.setObjectId("Facebook ID or URL");
应用内点赞视图与网页上的点赞视图之间存在一两个差异。与网页点赞不同,Android 点赞视图不会告知用户还有多少用户点击了赞,在没有安装 Facebook 的设备上,我们的点赞视图将完全无法工作。通过使用 WebView 来包含点赞视图,可以轻松解决 Android LikeView 的这些限制,这样它就会像在网页上一样工作。
LikeView 为我们和用户提供了查看特定项目受欢迎程度的机会,但要真正利用这个社交平台的力量,我们希望用户通过现代口碑营销方式,即通过分享我们的产品给他们的朋友来推广我们。
内容构建器
拥有大量赞是吸引流量的好方法,但这里有一个规模经济在起作用,它有利于下载量非常大的应用。应用不必做得很大才能成功,特别是如果它们提供个人或本地服务,比如定制三明治。在这些情况下,一个标签显示只有 12 个人喜欢某物并不是一个很好的推荐。然而,如果这些人向他们的朋友分享他们的三明治有多棒,那么我们就拥有了一个非常强大的广告工具。
Facebook 成为一个如此成功的平台的主要因素之一是它理解人类对自己的朋友比对无名陌生人更感兴趣和受影响,对于中小型企业来说,这可能是无价的。最简单的方式,我们可以像添加点赞按钮一样添加一个分享按钮,这将打开分享对话框。ShareButton的添加就像 LikeView 一样简单,如下所示:
<com.facebook.share.widget.ShareButton
android:id="@+id/share_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
我们还需要在清单文件中设置一个内容提供者。以下代码应插入到根节点中:
<provider
android:authorities="com.facebook.app.FacebookContentProvider{
your App ID here
}"
android:name="com.facebook.FacebookContentProvider"
android:exported="true"/>
与点赞视图不同,在分享时我们可以更多地选择要分享的内容类型,我们可以选择分享链接、图片、视频甚至多媒体。
Facebook SDK 为每种内容类型提供了一个类,以及一个构建器,用于将多个项目组合成一个可分享的对象。
在分享照片或图片时,SharePhotoContent
类使用位图对象,这是一种比我们迄今为止使用的可绘制资源更复杂且可序列化的图像格式。尽管有许多方法可以创建位图,包括从代码动态创建,但将我们的任何可绘制资源转换为位图也相对简单,如下面的代码段所示:
Context context;
Bitmap bitmap;
bitmap = BitmapFactory.decodeResource(context.getResources(),
R.drawable.some_drawable);
然后可以通过以下两个简单步骤将其定义为可分享内容:
// Define photo to be used
SharePhoto photo = new SharePhoto.Builder()
.setBitmap(bitmap)
.build();
// Add one or more photos to the shareable content
SharePhotoContent content = new SharePhotoContent.Builder()
.addPhoto(photo)
.build();
ShareVideo
和ShareVideoContent
类的工作方式几乎相同,并使用文件的 URI 作为其来源。如果你之前没有处理过视频文件和 URI,以下简要步骤将介绍包含它们的最简单方法:
-
如果你还没有这样做,直接在
res
目录内创建一个名为raw
的文件夹。 -
将你的视频放在这个文件夹里。
-
确保文件名不包含空格或大写字母,并且是接受的格式,如
mp4
、wmv
或3gp
。 -
下面的代码可以用来提取视频的 URI:
VideoView videoView = (VideoView)context .findViewById(R.id.videoView) String uri = "android.resource://" + getPackageName() + "/" + R.raw.your_video_file;
-
现在可以使用这个 URI 来定义我们的共享视频内容,如下所示:
ShareVideo = new ShareVideo.Builder() .setLocalUrl(url) .build(); ShareVideoContent content = new ShareVideoContent.Builder() .setVideo(video) .build();
这些技术非常适合分享单个项目,甚至是同一类的多个项目,但当然有时候我们希望混合内容,这可以通过更通用的 Facebook SDK ShareContent
类实现。以下代码演示了如何做到这一点:
// Define photo content
SharePhoto photo = new SharePhoto.Builder()
.setBitmap(bitmap)
.build();
// Define video content
ShareVideo video = new ShareVideo.Builder()
.setLocalUrl(uri)
.build();
// Combine and build mixed content
ShareContent content = new ShareMediaContent.Builder()
.addMedium(photo)
.addMedium(video)
.build();
ShareDialog dialog = new ShareDialog(...);
dialog.show(content, Mode.AUTOMATIC);
这些简单的类提供了一种灵活的方式,允许用户与朋友们分享内容。还有一个发送按钮,允许用户将我们的内容私密地分享给个人或群组,尽管这对用户很有用,但这个功能几乎没有商业价值。
测试共享内容时,Facebook 共享调试器提供了一个非常有价值的工具,可以在以下链接找到:
`developers.facebook.com/tools/debug/sharing/?q=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fsharing%2Fandroid`
这特别有用,因为没有其他简单的方法可以看到我们的共享内容实际上是如何被他人查看的。
Facebook 不仅是最受欢迎的社交网络之一,还拥有一个非常周到的 SDK,可能是对开发者最友好的社交网络。当然,这并不是忽略其他社交平台的原因,其中 Twitter 最为重要。
整合 Twitter
Twitter 提供了一个与 Facebook 截然不同的社交平台,人们使用它的方式也大不相同。然而,它也是我们武器库中的另一个强大工具,与 Facebook 一样,它提供了无与伦比的推广机会。
Twitter 使用一个强大的框架集成工具,名为Fabric,它允许开发者将 Twitter 功能集成到我们的应用程序中。Fabric 可以直接作为插件下载到 Android Studio 中。在下载插件之前,需要先在 Fabric 上注册。这是免费的,可以在 fabric.io 上找到。
注册后,打开 Android Studio,然后从设置 > 插件中选择浏览仓库...:
安装完成后,Fabric 有一个逐步教程系统,不需要进一步指导。然而,如果应用程序只需要发布单个推文,完全可以不使用这个框架,因为这可以通过普通的 SDK 实现。
发送推文
Fabric 是一个复杂的工具,多亏了内置的教学功能,它的学习曲线很快,但仍然需要时间来掌握,并且提供了大多数应用程序不需要的许多功能。如果你只想让应用程序发布一条推文,可以不使用 Fabric,像这样:
String tweet
= "https://twitter.com/intent/tweet?text
=PUT TEXT HERE &url="
+ "https://www.google.com";
Uri uri = Uri.parse(tweet);
startActivity(new Intent(Intent.ACTION_VIEW, uri));
即使我们对 Twitter 的所有操作仅限于发送推文,这仍然是一个非常实用的社交功能。如果我们选择利用 Fabric,我们可以构建严重依赖 Twitter 的应用程序,发布实时流并进行复杂的流量分析。与 Facebook 一样,考虑使用 WebView 可以实现的功能总是一个好主意,将部分网页应用嵌入我们的移动应用通常是最简单的解决方案。
总结
将社交媒体集成到我们的移动应用中是一项强大的工具,它可以使应用程序的成功与否产生巨大差异。在本章中,我们看到了 Facebook 和 Twitter 提供了哪些软件开发工具来促进这一点,当然,其他社交媒体,如 Instagram 和 WhatsApp,也提供了类似的开发工具。
社交媒体是一个不断变化的世界,新的平台和开发工具层出不穷,没有理由相信 Twitter 甚至 Facebook 有一天不会步 MySpace 的后尘。这也是我们尽可能考虑使用 WebView 的另一个原因:在主应用内创建简单的网页应用可以让我们拥有更高的灵活性。
这几乎是我们旅程的终点,在下一章我们将要了解通常开发过程的最后阶段——发布。然而,这也是我们必须考虑潜在收入的时候,尤其是广告和应用程序内购买。
第十三章:分发模式
在覆盖了安卓开发的大部分重要方面之后,我们只需要处理部署和发布的过程。简单来说,将应用发布在谷歌应用商店并不是一个复杂的流程,但我们可以应用一些技巧和诀窍来最大化应用的可能覆盖范围,当然,我们应用获利的方式也在不断增加。
在本章中,我们将探讨如何在使用支持库提供的向后兼容性之外增加兼容性,然后继续了解注册和分发过程是如何工作的,接着我们将探索各种让我们的应用程序盈利的方式。
在本章中,你将学习如何进行以下操作:
-
准备应用分发
-
生成数字证书
-
注册成为谷歌开发者
-
准备宣传材料
-
在谷歌应用商店发布应用
-
加入应用内购
-
包含广告
扩展平台范围
我们在整个书中一直在使用的支持库在让应用在旧设备上可用方面做得非常出色,但它们并不适用于所有情况,许多新的创新在一些旧机器上根本无法实现。看看下面的设备仪表盘,很明显,我们希望将应用扩展回 API 级别 16:
我们已经看到 AppCompat 库是如何让我们的应用运行在比当前平台更旧的平台上,但我们不得不避免使用某些功能。例如,view.setElevation()
方法(以及其他材料特性)在 API 级别 21 以下将不起作用,如果调用它会导致机器崩溃。
我们可能会很自然地认为,我们可以简单地为了吸引更广泛的受众而牺牲这些功能,但幸运的是,这并不必要,因为我们可以使用以下条件子句动态检测我们的应用正在运行的平台:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
someView.setElevation(someValue);
}
这总是取决于个别开发者,但这种轻微的质量下降通常值得潜在用户采用的大幅增加。
然而,前面的例子很简单,添加这种动态向后兼容性通常需要额外的编码工作。一个很好的例子是 camera2 API,它比其前身复杂得多,但只在携带 API 21 及更高版本的设备上可用。在这种情况下,我们可以应用完全相同的原理,但需要设置一个更复杂系统。该子句可能导致调用不同的方法,甚至启动不同的活动。
然而,无论我们选择如何实现这一点,当然可以采用设计模式。这里有几种可能被使用,但最适合的可能是在这里看到的策略模式:
这种方法可能经常需要额外的编码工作,但扩大的潜在市场往往使这些额外工作变得非常值得。一旦我们像这样设置了我们应用的范畴,它就可以发布了。
发布应用
不言而喻,你应该在各种各样的手机和模拟器上彻底测试你的应用,并可能准备好你的推广材料,查看 Google Play 的政策和协议。在发布之前有很多事情要考虑,比如内容分级和国家分布。从编程的角度来看,在我们继续之前,只需检查三件事情:
- 从项目中删除所有日志记录,例如以下内容:
private static final String DEBUG_TAG = "tag";
Log.d(DEBUG_TAG, "some info");
- 确保你的清单中声明了应用
label
和icon
。以下是一个示例:
android:icon="@mipmap/my_app_icon"
android:label="@string/my_app_name"
- 确保在清单中声明了所有必要的权限。以下是一个示例:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
现在我们只需三个步骤就能在 Google Play 商店上看到我们的应用。我们需要做的就是生成一个已签名的发布 APK,注册成为 Google Play 开发者,最后将应用上传到商店或发布在自己的网站上。还有一两种发布应用的其他方式,我们将在本节末尾了解它们是如何完成的。首先,我们将开始生成一个准备上传到 Google Play 商店的 APK。
生成签名的 APK
所有发布的 Android 应用都需要一个数字签名的证书。这用于证明应用程序的真实性。与其他许多数字证书不同,它没有权威机构,你持有签名的私钥,这显然需要被安全保护。为此,我们需要生成一个私钥,然后使用它来生成签名的 APK。GitHub 上有些工具可以方便地完成这个过程,但为了帮助理解,我们将遵循传统的方法。这可以在 Android Studio 中的“生成签名的 APK 向导”中完成。以下步骤将引导你完成:
-
打开你想要发布的应用。
-
从构建 | 生成签名的 APK...菜单启动生成签名的 APK 向导。
-
在第一个屏幕上选择创建新的...。
-
在下一个屏幕上,为你的密钥库提供一个路径和名称,以及一个强密码。
-
对别名做同样的操作。
-
选择一个有效期超过 27 年的选项,如下所示:
-
至少填写一个证书字段。点击确定,你将被带回向导。
-
选择release作为构建变体,然后点击完成。
-
你现在有一个准备发布的已签名 APK。
密钥库(一个.jks
文件)可以用来存储任意数量的密钥(别名)。对所有应用使用同一个密钥是完全可以的,而且在产生应用更新时也必须使用相同的密钥。谷歌要求证书有效期至少到 2033 年 10 月 22 日,任何超过这个日期的数字都足够。
提示
重要:至少保留一份密钥的安全备份。如果丢失了,你将无法开发这些应用程序的未来版本。
一旦我们有了数字签名,我们就可以注册成为 Google 的开发者。
注册为开发者
与签名 APK 一样,注册为开发者也同样简单。请注意,Google 收取一次性费用 25 美元,以及你的应用程序可能产生的任何收入的 30%。以下说明假设你已经有一个 Google 账户:
-
查阅以下链接中的支持的位置:
support.google.com/googleplay/android-developer/table/3541286?hl=en&rd=1
-
前往开发者 Play 控制台:
play.google.com/apps/publish/
-
使用你的 Google 账户登录,并输入以下信息:
-
阅读并接受Google Play 开发者分发协议。
-
使用 Google Checkout 支付 25 美元,如有必要请创建一个账户,这样你就成为了注册的 Google 开发者。
如果你打算让你的应用程序在全球范围内可用,那么检查支持的位置页面总是值得的,因为它经常变化。剩下要做的就是上传我们的应用程序,我们接下来会做。
在 Google Play 商店上发布应用程序
将我们的应用程序上传并发布到 Play 商店是通过开发者控制台完成的。正如你将看到的,在这个过程中,我们可以提供大量关于我们应用程序的信息和推广材料。只要你按照本章前面的步骤操作,并且有一个准备好的已签名的.apk
文件,那么就按照以下说明发布它。或者,你可能只是想看看此时涉及的内容以及推广材料的形式。在这种情况下,确保你有以下四张图片和一个已签名的 APK,并在最后选择保存草稿而不是发布应用:
-
至少两张应用程序的截图。这些截图的任何一边不得短于 320 像素或长于 3840 像素。
-
如果你希望你的应用程序在 Play 商店中对搜索设计用于平板电脑的应用程序的用户可见,那么你应该至少准备一张 7 英寸和一张 10 英寸的截图。
-
一个 512 x 512 像素的高分辨率图标图像。
-
一个 1024 x 500 像素的特色图形。
准备好这些图片和一个已签名的.apk
文件后,我们就可以开始了。决定你希望为应用程序收取多少费用(如果有的话),然后按照以下说明操作:
-
打开你的开发者控制台。
-
填写标题并点击上传 APK按钮。
-
点击上传你的第一个 APK 到生产环境。
-
定位到你的已签名
app-release.apk
文件。它将在AndroidStudioProjects\YourApp\app
目录中。 -
将此内容拖放到建议的空间中。
-
完成后,你将被带到应用程序页面。
-
按照前四个部分进行操作:
-
完成所有必填字段,直到“发布应用”按钮可以点击。
-
如果您需要帮助,按钮上方的为什么我不能发布?链接将列出未完成的必填字段。
-
当所有必填字段都填写完毕后,点击页面顶部的发布应用(或保存草稿)按钮。
-
恭喜!您现在已成为一名已发布的安卓开发者。
我们现在知道如何将应用发布到 Play 商店。当然,还有许多其他的应用市场,它们都有各自的上传流程。然而,Google Play 提供了最广泛的受众群体,是发布应用的自然选择。
尽管 Play 商店是理想的市场,但仍然值得看看两种其他的分发方法。
通过电子邮件和网站进行分发
这两种方法中的第一种就像听起来一样简单。如果您将 APK 作为电子邮件附件发送,并在安卓设备上打开,用户在打开附件时会被邀请安装应用。在较新的设备上,他们可以直接在电子邮件中点击安装按钮。
提示
对于这两种方法,用户将必须在设备的 安全设置 中允许安装未知来源。
从您的网站分发应用几乎和通过电子邮件发送一样简单。您需要做的就是在网站上托管 APK 文件,并提供如下所示的下载链接:
<a href="download_button.jpg" download="your_apk">.
当用户从安卓设备浏览您的网站时,点击您的链接将在他们的设备上安装您的应用。
提示
通过电子邮件分发无法防止盗版,因此只有在考虑到这一点时才应使用此方法。其他方法尽可能安全,但如果您想采取额外措施,谷歌提供了一项许可服务,可以在 developer.android.com/google/play/licensing 找到。
无论我们是发布付费应用还是免费应用,我们都希望能够触达尽可能多的用户。谷歌提供了几种工具来帮助我们实现这一点,以及我们接下来将看到的盈利方法。
推广和盈利应用
很少有应用在没有经过良好推广的情况下就能成功。有无数种推广方法,毫无疑问,您将遥遥领先于如何推广您的产品。为了帮助您触达更广泛的受众,谷歌提供了一些实用的工具来协助推广。
在了解了推广工具之后,我们将探索两种通过应用赚钱的方法:应用内支付和广告。
推广应用
谷歌提供了两种非常简单的方法,帮助引导人们从网站和我们的应用中关注 Play 商店上的产品:链接以及谷歌 Play 徽章,它为我们的链接提供官方品牌标识。
我们可以添加指向单个应用和我们发布商页面的链接,在发布商页面可以浏览我们所有的应用,并且我们可以在我们的应用和网站中包含这些链接:
- 如果要包含指向 Play 商店中特定应用页面的链接,请使用以下格式中的清单中找到的完整包名:
http://play.google.com/store/apps/details?id=com.full.package.name
- 要在 Android 应用中包含这个,请使用这个:
market://details?id= com.full.package.name
- 如果你想要一个指向你的发布者页面以及你所有产品的列表的链接,请使用这个:
http://play.google.com/store/search?q=pub:my publisher name
- 当从应用中链接时,请像之前一样进行相同的更改:
Market://search?q=pub:my publisher name
- 要链接到特定的搜索结果,请使用这个:
search?q=my search query&c=apps.
- 如果要使用官方 Google 徽章作为你的链接,请用下面突出显示的 HTML 替换前面元素之一:
<a href="https://play.google.com/store/search?q=pub: publisher name">
<img alt="Get it on Google Play"
src="img/en_generic_rgb_wo_60.png" />
</a>
徽章有两种尺寸,60.png
和45.png
,以及两种样式,Android 应用在 Google Play 上和在 Google Play 上获取。只需更改相关代码以选择最适合你目的的徽章:
随着我们的应用发布,并在合适的位置放置了指向我们 Play 商店页面的链接,现在是考虑如何从不可避免的下载中获利的时候了,因此我们来看看如何实现 Android 应用的盈利。
应用盈利
有很多方法可以从应用中赚钱,但最流行和有效的两种方法是应用内购买和广告。应用内购买可能会相当复杂,或许值得用一整章来讲述。这里,我们将看到一个有效的模板,你可以将其作为开发可能的应用内产品的基础。它将包括所有需要的库和包,以及一些非常有用的帮助类。
相比之下,现在我们在应用中包含 Google AdMob 广告对我们来说是一个非常熟悉的过程。实际上,广告只是另一个 View,并且可以像其他任何 Android 小部件一样被识别和引用。本章的最后一个练习,也是整本书的最后一个练习,将构建一个简单的 AdMob 演示。不过,首先让我们看看应用内购买。
应用内购买
用户可以从应用内购买大量产品,从升级和可解锁内容到游戏内物品和货币,这当然为我们在书中前面开发的那个三明治制作应用提供了一个支付选项。
无论用户购买什么,Google 结账流程都会确保他们以与其他 Play 商店产品相同的方式支付。从开发者的角度来看,每次购买都会归结为响应一个按钮的点击。我们需要安装 Google Play Billing Library,并向我们的项目中添加一个 AIDL 文件和一些帮助类。以下是方法:
-
开始一个新的 Android 项目,或者打开一个你想要添加应用内购买功能的已有项目。
-
打开 SDK 管理器。
-
在 Extras 下,确保你已经安装了 Google Play Billing Library。
-
打开清单并应用这个权限:
<uses-permission android:name="com.android.vending.BILLING" />
-
在项目窗格中,右键点击 app 并选择新建 | 文件夹 | AIDL 文件夹。
-
从这个 AIDL 文件夹中,创建一个新建 | 包,并将其命名为 com.android.vending.billing。
-
在
sdk\extras\google\play_billing
目录中找到并复制IinAppBillingService.aidl
文件。 -
将文件粘贴到
com.android.vending.billing
包中。 -
在 Java 文件夹中创建一个名为
com.
你的包名.util
的新包,然后点击完成。 -
从
play_billing
目录中找到并打开TrivialDrive\src\com\example\android\trivialdrivesample\util
文件夹。 -
将九个 Java 文件复制到你刚刚创建的 util 包中。
现在你已经拥有了一个适用于任何想要加入应用内购买功能的应用的模板。或者,你也可以在已经开发好应用内产品的项目中完成上述步骤。无论哪种方式,无疑你都将利用IabHelper 类
,它极大地简化了编码工作,并为购买过程的每一步提供了监听器。相关文档可以在这里找到:
developer.android.com/google/play/billing/index.html
提示
在开始实现应用内购买之前,你需要为你的应用获取一个许可密钥。这可以在开发者控制台中的应用详情中找到。
付费应用和应用内产品只是从应用中赚钱的两种方式,很多人选择通过广告来获取收入,这通常是一种更有利可图的途径。Google AdMob提供了很大的灵活性以及熟悉的编程接口,我们将在下一节中看到。
包含广告
广告赚钱的方式有很多,但 AdMob 提供的方法最为简单。该服务不仅允许你选择想要推广的产品类型,还提供了优秀的分析工具,并能无缝地将收入转入你的 Checkout 账户。
此外,我们将会看到,AdView可以通过几乎与我们熟悉的方法一样的编程方式来处理,我们将在最后的练习中开发一个带有演示横幅 AdMob 广告的简单应用。
在开始这个练习之前,你需要先在 google.com/admob 上注册一个 AdMob 账户。
-
打开你想要测试广告的项目,或者开始一个新的 Android 项目。
-
确保你已经通过 SDK Manager 安装了 Google Repository。
-
在
build.gradle
文件中,添加这个依赖项:compile 'com.google.android.gms:play-services:7.0.+'
-
重建项目。
-
在清单文件中设置这两个权限:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
-
在应用节点内,添加这个
meta-data
标签:<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" />
-
在清单文件中包含这个第二个 Activity。
<activity android:name="com.google.android.gms.ads.AdActivity" android:configChanges= "keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize" android:theme="@android:style/Theme.Translucent" />
-
在
res/values/strings.xml
文件中添加以下字符串:<string name="ad_id">ca-app-pub-3940256099942544/6300978111</string>
-
打开
main_activity.xml
布局文件。 -
在根布局中添加这个第二个命名空间:
-
在
TextView
下方添加这个AdView
:<com.google.android.gms.ads.AdView android:id="@+id/ad_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" ads:adSize="BANNER" ads:adUnitId="@string/ad_id"></com.google.android.gms.ads.AdView>
-
在
MainActivity
的onCreate()
方法中,插入以下代码行:AdView adView = (AdView) findViewById(R.id.ad_view); AdRequest adRequest = new AdRequest.Builder() .addTestDevice(AdRequest.DEVICE_ID_EMULATOR) .build(); adView.loadAd(adRequest);
-
现在在设备上测试应用。
我们在这里所做的几乎与我们编程任何其他元素的方式相同,有一两个例外。使用ACCESS_NETWORK_STATE
权限并不是严格必要的;它在这里用于在请求广告之前检查网络连接。
任何显示广告的活动都将需要一个单独的 ID,并在清单中声明。这里提供的 ID 仅用于测试目的,因为不允许使用实时 ID 进行测试。android.gms.ads
包中只有六个类,它们的全部文档可以在developer.android.com/reference/com/google/android/gms/ads/package-summary找到。
AdMob 广告有两种形式,我们在这里看到的横幅广告和插屏广告,或全屏广告。我们在这里只处理了横幅广告,但插屏广告的处理方式非常相似。了解了如何实现付费应用、应用内购买和 AdMob,我们现在有能力收获辛勤工作的回报,最大限度地利用我们的应用程序。
总结
本章概述了应用程序开发的最后阶段,尽管这些阶段只占工作量的很小一部分,但它们至关重要,当涉及到应用程序的成功时,它们可以起到决定性的作用。
在整本书中,我们大量依赖支持库来增加我们应用程序可以在其上运行的设备数量,但在这里,我们看到了如何通过动态确定平台并相应地运行适当的代码来进一步扩大这一范围。这个过程很好地展示了设计模式如何渗透到编程的所有方面。
一旦我们使用这些工具扩大了我们的影响范围,我们还可以通过谨慎的推广来进一步提高我们应用程序成功的可能性,并希望我们的工作能够得到回报,无论是直接向用户收取应用程序或其功能的费用,还是通过投放广告间接盈利。
在整本书中,我们探讨了设计模式如何在开发的许多方面帮助我们,但真正有用的是设计模式背后的思考方式,而不是任何一个单独的模式。设计模式提供了一种解决问题的方法和一条通往解决方案的清晰路径。这是一种旨在引导我们找到新的创造性解决方案的方法,设计模式不应被视为一成不变的,而应更多地视为一种指导,任何模式都可以根据其目的进行修改和调整。
本书中的模式和示例并非设计为可以直接复制粘贴到其他项目中,而是作为帮助我们发现解决自己原始情况的最优雅解决方案的方法论。如果这本书完成了它的任务,那么你接下来设计的模式将不是这里所概述的,而是你自己全新的原创作品。