Kotlin-安卓编程初学者手册-全-

Kotlin 安卓编程初学者手册(全)

原文:zh.annas-archive.org/md5/507BA3297D2037C2888F887A989A734A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

您是否正在尝试开始 Android 编程的职业生涯,但还没有找到合适的方法?您是否有一个很棒的应用程序想法,但不知道如何将其变成现实?或者您只是对学* Android 必须已经了解 Kotlin 感到沮丧。如果是这样,那么这本书就是为您准备的。

面向初学者的 Kotlin Android 编程将成为您创* Android 应用程序的指南。我们将向您介绍在 Android 环境中编程的所有基本概念,从 Kotlin 的基础知识到使用 Android API。所有示例都是在官方的 Android 开发环境 Android Studio 中创*的,这有助于加速应用程序开发过程。在这个速成课程之后,我们将深入研究 Android 编程,您将学会如何通过片段创*具有专业标准 UI 的应用程序,并使用 SQLite 存储用户数据。此外,您还将了解如何使您的应用程序支持多语言,如何用手指在屏幕上绘图,以及如何处理图形、声音和动画。

通过本书,您将准备好开始在 Android 和 Kotlin 中构*自己的定制应用程序。

这本书是为谁准备的

如果您完全不了解 Kotlin、Android 或编程,并且想制作 Android 应用程序,那么这本书就是为您准备的。这本书也可以作为那些已经有一些基本使用 Kotlin 在 Android 上经验的人的复*,以提高他们的知识,并通过早期项目快速进步。

本书涵盖内容

第一章 开始使用 Android 和 Kotlin,欢迎您来到令人兴奋的 Android 和 Kotlin 世界。在这一章中,我们将立即开始开发 Android 应用程序。我们将探讨 Android 的优势,Android 和 Kotlin 是什么,它们如何工作和相互补充,以及作为未来开发人员对我们意味着什么。接下来,我们将设置所需的软件,以便我们可以构*和部署一个简单的第一个应用程序。

第二章 Kotlin、XML 和 UI 设计师,讨论了在这个阶段,我们已经拥有了一个可用的 Android 开发环境,并且已经构*和部署了我们的第一个应用程序。然而,显然,由 Android Studio 自动生成的代码不会成为下一个在 Google Play 上畅销的应用程序。我们需要探索这些自动生成的代码,以便我们可以开始了解 Android,然后学*如何在这个有用的模板上构*。

第三章 探索 Android Studio 和项目结构,带领我们创*和运行另外两个 Android 项目。这些练*的目的是更深入地探索 Android Studio 和 Android 项目的结构。当我们为部署构*我们的应用程序时,代码和资源文件需要被打包到 APK 文件中 - 就像它们现在的样子。因此,所有布局文件和其他资源,我们很快就会看到,需要以正确的结构存在。幸运的是,当我们从模板创*项目时,Android Studio 会为我们处理这些。然而,我们仍然需要知道如何找到和修改这些文件,如何添加我们自己的文件,有时删除 Android Studio 创*的文件,以及资源文件如何相互关联 - 有时与彼此关联,有时与 Kotlin 代码关联(即自动生成的 Kotlin 代码以及我们自己的代码)。除了了解我们项目的组成,确保我们充分利用模拟器也是有益的。

第四章《使用布局和 Material Design 入门》*立在我们已经看到的基础上;也就是说,Android Studio UI 设计师和更多 Kotlin 的实际操作。在这个实践性的章节中,我们将构*三个更多的布局 - 仍然相当简单,但比我们到目前为止所做的更上一层楼。在我们开始实际操作之前,我们将快速介绍material design的概念。我们将看看另一种称为LinearLayout的布局类型,并通过它来创*可用的 UI。我们将进一步使用ConstraintLayout来理解约束并设计更复杂和精确的 UI 设计。最后,我们将使用TableLayout来将数据布置在一个易读的表格中。我们还将编写一些 Kotlin 代码,以在一个应用/项目中在不同的布局之间切换。这是第一个将多个主题链接在一起的重要应用程序。这个应用程序被称为探索布局

第五章《使用 CardView 和 ScrollView 创*美丽的布局》是在我们花一些时间专注于 Kotlin 和面向对象编程之前的最后一个布局章节。我们将对我们已经看到的一些不同属性进行正式学*,并且还将介绍两个更酷的布局,ScrollViewCardView。最后,我们将在*板模拟器上运行CardView项目。

第六章《Android 生命周期》将使我们熟悉 Android 应用程序的生命周期。一开始,计算机程序有生命周期这个概念可能听起来很奇怪,但很快就会变得合理。生命周期是所有 Android 应用程序与 Android 操作系统交互的方式。就像人类的生命周期使他们能够与周围的世界互动一样,我们别无选择,只能与 Android 生命周期互动,并且必须准备处理许多不可预测的事件,如果我们希望我们的应用程序能够生存下来。我们将探索应用程序经历的生命周期阶段,从创*到销毁,以及这如何帮助我们知道根据我们想要实现的目标在何处放置我们的 Kotlin 代码。

第七章《Kotlin 变量、运算符和表达式》,以及接下来的一章,解释了 Kotlin 的核心基础。实际上,我们将探索编程的主要原则。在这一章中,我们将专注于数据本身的创*和理解,在下一章中,我们将探讨如何操作和响应数据。

第八章《Kotlin 决策和循环》从变量中移开,我们现在了解如何使用表达式更改它们所持有的值,但是当一个变量的值取决于一个变量时,我们如何采取行动呢?我们当然可以将新消息的数量添加到先前未读消息的数量中,但是例如,当用户已经阅读了所有消息时,我们如何触发应用程序中的操作呢?第一个问题是我们需要一种测试变量值的方法,然后在值落在一系列值范围内或等于特定值时做出响应。编程中常见的另一个问题是,我们需要根据变量的值执行代码的某些部分一定次数(多次,或者有时根本不执行)。为了解决第一个问题,我们将学*如何在 Kotlin 中使用ifelsewhen做出决策。为了解决后者,我们将学* Kotlin 中的循环,包括whiledo-whileforcontinuebreak。此外,我们将了解在 Kotlin 中,决策也是产生值的表达式。

第九章,“Kotlin 函数”,解释了函数是我们应用程序的构*块。我们编写执行特定任务的函数,然后在需要执行该特定任务时调用它们。由于我们在应用程序中需要执行的任务将是相当多样化的,我们的函数需要满足这一点并且非常灵活。Kotlin 函数非常灵活,比其他与 Android 相关的语言的函数更灵活。因此,我们需要花费整整一章的时间来学*它们。函数与面向对象编程密切相关,一旦我们理解了函数的基础知识,我们就能够很好地掌握更广泛的面向对象编程的学*。

第十章,“面向对象编程”,解释了在 Kotlin 中,类对几乎所有事情都是基本的,事实上,几乎所有事情都是一个类。我们已经谈到了重用其他人的代码,特别是 Android API,但在本章中,我们将真正掌握这是如何工作的,并学*面向对象编程(OOP)以及如何使用它。

第十一章,“Kotlin 中的继承”,展示了继承的实际应用。事实上,我们已经看到了,但现在我们将更仔细地研究它,讨论其好处,并编写我们从中继承的类。在整个章节中,我将向您展示几个继承的实际例子,在本章结束时,我们将改进上一章的海战模拟,并展示如何通过使用继承来节省大量的输入和未来的调试工作。

第十二章,“将我们的 Kotlin 连接到 UI 和空值”,在本章结束时,完全揭示了我们的 Kotlin 代码和我们的 XML 布局之间的缺失链接,使我们能够像以前一样向我们的布局添加各种小部件和 UI 功能,但这一次我们将能够通过我们的代码来控制它们。在本章中,我们将控制一些简单的 UI 元素,如ButtonTextView,在下一章中,我们将进一步操作一系列 UI 元素。为了使我们能够理解发生了什么,我们需要更多地了解应用程序中的内存,特别是其中的两个领域 - 堆栈

第十三章,“将 Android 小部件带到生活中”,讨论了由于我们现在对 Android 应用程序的布局和编码以及我们新获得的面向对象编程(OOP)的洞察力,以及如何从我们的 Kotlin 代码中操纵 UI 有了很好的概述,我们现在可以尝试使用 Android Studio 调色板中的更多小部件。有时,OOP 是一件棘手的事情,这一章介绍了一些对初学者来说可能尴尬的主题。然而,通过逐渐学*这些新概念并反复练*,它们将随着时间的推移成为我们的朋友。在本章中,我们将通过回到 Android Studio 调色板并查看半打我们要么根本没有看到过要么尚未完全使用过的小部件来进行多样化。一旦我们这样做了,我们将把它们都放入布局中,并练*用我们的 Kotlin 代码操纵它们。

第十四章,“Android 对话框窗口”,解释了如何向用户呈现弹出式对话框窗口。然后,我们可以将我们所知道的一切放入我们的第一个多章节应用程序“Note to self”的第一阶段。然后,我们将在本章和接下来的四章(直到第十八章,“本地化”)中学*有关 Android 和 Kotlin 的新功能,然后利用我们新获得的知识来增强“Note to self”应用程序。

第十五章,“处理数据和生成随机数”,显示我们正在取得良好的进展。我们对 Android UI 选项和 Kotlin 的基础知识有了一个全面的了解。在前几章中,我们开始将这两个领域结合起来,并使用 Kotlin 代码操作 UI,包括一些新的小部件。然而,在构* Note to self 应用程序时,我们遇到了一些知识上的空白。在本章中,我们将填补这些空白中的第一个,然后在下一章中,我们将使用这些新信息来继续应用程序。我们目前没有办法管理大量相关数据。除了声明,初始化和管理数十,数百甚至数千个属性或实例之外,我们如何让我们的应用程序用户拥有多个笔记?我们还将快速了解随机数。

第十六章,“适配器和回收器”,首先带我们了解适配器和列表的理论。然后,我们将看看如何在 Kotlin 代码中使用RecyclerAdapter实例,并将RecyclerView小部件添加到布局中,该小部件充当我们的 UI 的列表,然后通过 Android API 的明显魔力将它们绑定在一起,以便RecyclerView实例显示RecyclerAdapter实例的内容,并允许用户滚动查看充满Note实例的ArrayList实例的内容。您可能已经猜到,我们将使用这种技术来显示 Note to self 应用程序中的笔记列表。

第十七章,“数据持久性和共享”,介绍了将数据保存到 Android 设备的永久存储的几种不同方法。此外,我们将首次向我们的应用程序添加第二个Activity实例。在我们的应用程序中实现一个单独的“屏幕”,例如“设置”屏幕时,将新的Activity实例添加到其中通常是有意义的。我们可以费力地隐藏原始 UI,然后在同一个Activity中显示新的 UI,就像我们在第四章,“使用布局和 Material Design 入门”中所做的那样,但这很快会导致令人困惑和容易出错的代码。因此,我们将看到如何添加另一个Activity实例并在它们之间引导用户。

第十八章,“本地化”,快速简单,但我们将学*的内容可以使您的应用程序可供数百万潜在用户使用。我们将看到如何添加其他语言,以及为什么通过字符串资源以正确的方式添加文本在添加多种语言时对我们有益。

第十九章,“动画和插值”,探讨了如何使用Animation类使我们的 UI 不那么静态,更有趣。正如我们所期望的那样,Android API 将允许我们用相对简单的代码做一些相当高级的事情,Animation类也不例外。

第二十章,“绘图图形”,涉及 Android 的Canvas类和一些相关类,如PaintColorBitmap。当这些类结合在一起时,在屏幕上绘图具有很大的能力。有时,Android API 提供的默认 UI 并不是我们所需要的。如果我们想制作绘图应用程序,绘制图表,或者可能制作游戏,我们需要控制 Android 设备提供的每个像素。

第二十一章,“线程和启动实时绘图应用程序”,让我们开始我们的下一个应用程序。这个应用程序将是一个儿童风格的绘画应用程序,用户可以用手指在屏幕上绘画。然而,我们创*的绘画应用程序将略有不同。用户绘制的线条将由粒子系统组成,这些粒子系统会爆炸成成千上万的碎片。我们将称这个项目为实时绘图。

第二十二章,“粒子系统和处理屏幕触摸”,在上一章中使用线程实现了我们的实时系统。在本章中,我们将创*实体,它们将存在并在这个实时系统中演变,就好像它们有自己的思想,并形成用户可以创*的绘画外观。我们还将看到用户如何通过学*如何响应与屏幕的交互来实现这些实体。这与在 UI 布局中与小部件交互是不同的。

第二十三章,“Android 音效和 Spinner 小部件”,探讨了 SoundPool 类以及我们根据是否只想播放声音或进一步跟踪我们正在播放的声音而使用它的不同方式。在这一点上,我们可以将我们学到的一切都投入到制作一个很酷的声音演示应用中,这也将向我们介绍一个新的 UI 小部件;Spinner。

第二十四章,“设计模式、多个布局和片段”,展示了我们从一开始就设置 Android Studio 以来所走过的路程。当时,我们一步一步地进行了所有操作,但随着我们的进行,我们试图不仅仅是展示如何将 x 添加到 y,或者将特性 a 添加到应用程序 b,而是让您能够以自己的方式使用所学知识,以实现自己的想法。这一章更多地涉及您未来应用程序的内容,而不是书中迄今为止的任何内容。我们将看一些 Kotlin 和 Android 的方面,您可以将其用作框架或模板,以制作更加令人兴奋和复杂的应用程序,同时保持代码的可管理性。

第二十五章,“具有分页和滑动的高级 UI”,解释了分页是从一页到另一页的移动的行为,在 Android 上,我们通过在屏幕上滑动手指来实现这一点。当前页面会根据手指的移动方向和速度进行过渡。这是一个在应用程序中导航的有用和实用的方式,但也许更重要的是,对用户来说,这是一种极其令人满意的视觉效果。此外,与RecyclerView一样,我们可以选择性地仅加载当前页面所需的数据,也许是先前和下一页的数据。正如您所期望的那样,Android API 有一些解决方案可以以相当简单的方式实现分页。

第二十六章,“带有导航抽屉和片段的高级 UI”,探讨了(可以说是)最高级的 UI。NavigationView,或者导航抽屉(因为它滑出内容的方式),可以通过在创*新项目时选择它作为模板来简单创*。我们将这样做,然后我们将检查自动生成的代码,并学*如何与其交互。然后,我们将使用我们对Fragment类的所有了解来填充每个“抽屉”具有不同行为和视图。然后,在下一章中,我们将学*关于数据库,以为每个Fragment添加一些新功能。

第二十七章 Android 数据库,解释了如果我们要制作提供给用户重要功能的应用程序,那么几乎肯定我们需要一种管理、存储和过滤大量数据的方法。使用 JSON 可以高效地存储大量数据,但当我们需要有选择地使用这些数据而不仅仅限制在“保存所有”和“加载所有”的选项时,我们需要考虑还有哪些其他选项可用。像往常一样,使用 Android API 中提供的解决方案是有意义的。正如我们所见,JSONSharedPreferences类有其用武之地,但在某个时候,我们需要转而使用真正的数据库来解决现实世界的问题。Android 使用 SQLite 数据库管理系统,正如您所期望的那样,有一个 API 可以尽可能地简化它。

第二十八章 告别前的快速交谈,包含了一些想法和指针,您可能在匆忙离开并制作自己的应用程序之前想要看看。

充分利用本书

要成功阅读本书,您不需要任何经验。如果您对自己选择的操作系统(Windows、Mac 或 Linux)有信心,您可以学*使用 Kotlin 编程语言制作 Android 应用程序。学*开发专业质量的应用程序是任何人都可以开始并坚持下去的旅程。

如果您具有以前的编程(Kotlin、Java 或任何其他语言)、Android 或其他开发经验,那么您将在前几章中取得更快的进展。

下载示例代码文件

您可以从您在www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择SUPPORT选项卡。

  3. 单击Code Downloads & Errata

  4. 搜索框中输入书名,并按照屏幕上的说明操作。

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Android-Programming-with-Kotlin-for-Beginners。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789615401_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

<TextView
 android:id="@+id/textView"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:text="TextView" />

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

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

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

粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词,例如在菜单或对话框中,也会在文本中出现。例如:“如果没有,请点击Logcat选项卡”

注意

警告或重要说明会出现在这样。

提示

提示和技巧会出现在这样。

第一章:开始使用安卓和 Kotlin

欢迎来到令人兴奋的安卓和 Kotlin 世界!在这第一章中,我们将立即开始开发安卓应用程序,不会浪费任何时间。

我们将看看安卓有什么好处,安卓和 Kotlin 是什么,它们如何协同工作和互补,以及对我们作为未来开发者意味着什么。接下来,我们将设置所需的软件,以便我们可以构*和部署一个简单的第一个应用程序。

在本章中,我们将涵盖以下主题:

  • 学* Kotlin 和安卓如何协同工作

  • 设置我们的开发环境,安卓 Studio

  • 学*什么是安卓应用程序

  • 学* Kotlin

  • 构*我们的第一个安卓应用程序

  • 部署安卓模拟器

  • 在安卓模拟器和真实设备上运行我们的应用程序

这是一个很多内容要学*的,所以让我们开始吧。

为什么使用 Kotlin 和安卓?

当安卓于 2008 年首次出现时,与苹果 iPhone/iPad 上更时尚的 iOS 相比,它显得有些沉闷。但是,通过一系列与实用、价格敏感的消费者以及时尚意识和技术精通的消费者产生共鸣的手机产品,安卓用户数量迅速增加。

对于许多人来说,包括我自己在内,为安卓开发是最有回报的业余爱好和商业。

快速地将一个想法的原型组合起来,完善它,然后决定运行并将其连接成一个完整的应用程序,这是一个令人兴奋和有回报的过程。任何编程都可以很有趣 - 我一生都在编程 - 但为安卓创造东西却是非常有回报的。

确切地定义为什么会这样很难。也许是因为这个*台是免费和开放的。你可以在不需要大型控制公司的许可下分发你的应用程序 - 没有人能阻止你。同时,你也可以在亚马逊应用商店和谷歌 Play 等成熟的、由公司控制的大众市场上分发应用。

然而,更有可能的是,为什么为安卓开发会给人如此好的感觉是因为设备本身的性质。它们是非常个人化的。你可以开发与人们生活互动的应用程序,教育、娱乐、讲故事等等,它就在他们的口袋里准备好了 - 在家里、工作场所或度假中。

你当然可以为桌面构*更大的东西,但知道成千上万(甚至数百万)的人将你的作品装在口袋里并与朋友分享,这不仅仅是一种兴奋。

开发应用程序不再被认为是怪异、书呆子或隐居。事实上,为安卓开发被认为是非常有技巧的,最成功的开发者受到极大的钦佩,甚至崇敬。

如果所有这些空洞的、精神上的东西对你毫无意义,那也没关系;为安卓开发可以让你谋生,甚至让你致富。随着设备拥有量的持续增长,CPU 和 GPU 性能的不断提升,以及安卓操作系统本身的不断演进,对专业应用程序开发者的需求只会增长。

简而言之,最优秀的安卓开发者 - 更重要的是,拥有最佳创意和最大决心的安卓开发者 - 比以往任何时候都更受欢迎。没有人知道这些未来的安卓应用程序开发者是谁,他们甚至可能还没有写下他们的第一行代码。

那么,为什么不是每个人都是安卓开发者呢?显然,并不是每个人都会像我一样对创造能够帮助改善人们生活的软件充满热情,但我猜测,因为你正在阅读这篇文章,你可能会。

初学者的第一个绊脚石

不幸的是,对于那些和我一样对此充满热情的人来说,进步的道路上存在一种玻璃墙,这让许多有抱负的安卓开发者感到沮丧。

Android 要求有志成为开发者的人选择三种编程语言来制作应用程序。每一本 Android 书籍,即使是针对所谓的初学者,也都假设读者至少具有中级水*的 Kotlin、C++或 Java,大多数需要高级水*。因此,良好到优秀的编程知识被视为学* Android 的先决条件。

不幸的是,在完全不同的环境中学*这些语言有时可能会有点乏味,而你学到的大部分知识也不能直接转移到 Android 的世界中。你可以理解为什么初学者对 Android 往往感到厌倦。

这本书不需要这样。在这本书中,我精心安排了你在厚重的 Kotlin 初学者专著中学到的所有 Kotlin 主题,并将它们重新制作成了三个多章节的应用程序和十多个快速的迷你应用程序,从一个简单的备忘录应用程序开始,逐渐发展到一个酷炫的绘图应用程序和一个数据库应用程序。

如果你想成为一名专业的 Android 开发者,或者只是想在学* Kotlin 和 Android 时更有乐趣,这本书会帮助你。

Kotlin 和 Android 是如何协同工作的

Android 软件开发工具包SDK)主要是用 Java 编写的,因为 Kotlin 是新生力量;但是当我们告诉 Android Studio 将我们的 Kotlin 代码转换成可工作的应用程序时,它会与 SDK 中的 Java 合并在一起,形成一种中间形式,然后转换成一种称为 DEX 代码的格式,这是 Android 设备用来转换成运行应用程序的。对于我们开发者来说,这是无缝的,但了解这一点(也许是相当有趣的)是值得的。

无论你是用 Kotlin 还是 Java 编写应用程序,最终的 DEX 代码都是一样的。然而,使用 Kotlin 有一些显著的优势。

Kotlin 是以俄罗斯圣彼得堡附近的一个岛屿命名的。Kotlin 与苹果的 Swift 语言非常相似,因此现在学* Kotlin 将为学* iPhone/iPad 开发奠定良好的基础。

Kotlin 是最简洁的语言,因此最不容易出错,这对初学者来说非常好。Kotlin 也是最有趣的语言,主要是因为简洁性意味着你可以更快地得到结果,而且代码更少。谷歌认为 Kotlin 是官方(一流)的 Android 语言。Kotlin 还有一些其他优点,使其更不容易出错,也不太可能出现导致崩溃的错误。随着我们的学*,我们将发现这些优点的细节。

许多最先进、创新和流行的应用程序都是使用 Kotlin 编写的。其中一些例子包括 Kindle、Evernote、Twitter、Expedia、Pinterest 和 Netflix。

在我们开始 Android 之旅之前,我们需要了解 Android 和 Kotlin 是如何协同工作的。在我们用 Java 或 Kotlin 为 Android 编写程序之后,我们点击一个按钮,我们的代码就会被转换成另一种形式,这种形式是 Android 可以理解的。这种形式被称为达尔维克可执行代码,或DEX代码,转换过程被称为编译。当应用程序安装在设备上时,DEX 代码会被操作系统再次转换成优化的可执行状态。

注意

我们将在本章后面设置开发环境后立即看到这个过程。

Android 是一个复杂的系统,但你不需要深入了解它就能开始制作令人惊叹的应用程序。

提示

只有在长时间的使用和互动之后才能完全理解。

要开始,我们只需要了解基础知识。Android 运行在一个经过特殊适配的 Linux 操作系统上。因此,用户在 Android 上看到的只是在另一个操作系统上运行的应用程序。

Android 是一个系统中的系统。典型的 Android 用户看不到 Linux 操作系统,很可能甚至不知道它的存在。

其中一个目的是隐藏 Android 运行的硬件和软件的复杂性和多样性,但同时暴露出所有有用的功能。这些功能的暴露以两种方式进行:

  • 首先,操作系统本身必须完全访问硬件,它已经做到了。

  • 其次,这种访问必须对程序员友好且易于使用,这是因为 Android 应用程序编程接口API)。

让我们继续深入了解 Android API。

Android API

Android API 是使得做出非凡事情变得容易的代码。一个简单的类比可以用一台机器来画出,也许是一辆汽车。当你踩油门时,引擎盖下会发生一大堆事情。我们不需要理解燃烧或燃油泵,因为一些聪明的工程师为我们做了一个接口;在这种情况下,是一个机械接口——油门踏板。

例如,下面这行代码在书的这个阶段可能看起来有点吓人,但它是一个很好的例子,说明了 Android API 如何帮助我们:

locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)

一旦你学会了这一行代码搜索太空中可用的卫星,与它们在地球轨道上通信,然后获取你在地球表面的精确纬度和经度,你就能轻松地窥见 Android API 的强大和深度。

这段代码在书的这个阶段看起来有点具有挑战性,甚至令人费解,但试想以其他方式与卫星交流!

Android API 已经编写了大量代码供我们随意使用。

我们必须问的问题,也是这本书试图回答的问题是:我们如何使用所有这些代码来做酷炫的事情?或者,为了符合之前的类比,我们如何找到和操作 Android API 的踏板、方向盘,以及最重要的是天窗?

这个问题的答案就是 Kotlin 编程语言,以及 Kotlin 被设计来帮助程序员处理复杂性,避免错误,并快速取得进展。让我们深入了解 Kotlin 和面向对象编程OOP)。

Kotlin 是面向对象的

Kotlin 是一种面向对象的语言。这意味着它使用可重用的编程对象的概念。如果这听起来像技术术语,另一个类比将有所帮助。Kotlin 使我们和其他人(比如 Android API 开发团队)能够编写可以基于现实世界事物构*的代码,这是重要的部分——它可以重复使用

因此,使用汽车的类比,我们可以问以下问题:如果一个制造商一天制造多辆汽车,他们是否为每辆汽车重新设计每个零件?

当然,答案是否定的。他们会找到高技能的工程师来开发完全正确的组件,经过多年的磨练、改进和提高。然后,这些相同的组件会一次又一次地被重复使用,偶尔也会被改进。

如果你对我的类比挑剔的话,你可以指出每个汽车的组件仍然必须使用真实的工程师或机器人从原材料中构*。这是真的。

软件工程师编写代码时所做的是为一个对象*立一个蓝图。然后我们使用代码从他们的蓝图中创*一个对象,一旦我们有了那个对象,我们可以配置它,使用它,将它与其他对象组合,以及其他更多的操作。

此外,我们可以自己设计蓝图并从中制作对象。然后编译器将我们定制的创作转化为 DEX 代码。嘿,变魔术!我们有了一个 Android 应用。

在 Kotlin 中,蓝图被称为。当一个类被转化为一个真正工作的“东西”时,我们称它为该类的对象实例

注意

简而言之,对象。

我们可以继续进行类比。就我们目前而言,Kotlin(以及大多数现代编程语言)是一种允许我们编写一次代码,然后可以重复使用的语言。

这非常有用,因为它节省了我们的时间,并且允许我们使用其他人的代码来执行我们可能没有时间或知识来编写的任务。大多数情况下,我们甚至不需要看到这段代码,甚至不需要知道它是如何工作的!

最后一个类比。我们只需要知道如何使用那段代码,就像我们需要学会开车一样。

因此,Android 总部的一些聪明的软件工程师编写了一个非常复杂的程序,可以与卫星通信,因此,通过一行代码,我们就可以在地球表面获得我们的位置。不错。

大部分 Android API 是用另一种语言(Java)编写的,但这对我们来说并不重要,因为我们在使用更简洁的 Kotlin 时可以完全访问到这些功能(用 Java 编码)。Android Studio 和 Kotlin 编译器会在幕后处理复杂性。

软件工程师已经考虑了如何使这段代码对所有想要开发使用用户位置进行酷炫操作的 Android 程序员有用。他们将做的一件事是使功能变得简单,比如将设备的位置获取到世界上变成一个简单的一行任务。

因此,我们之前看到的一行代码会启动许多我们看不到、也不需要看到的代码。这是一个使用别人的代码来使我们的代码变得无限简单的例子。

提示

如果你不必看到所有的代码让你感到失望,那么我理解你的感受。当我们学*某些东西时,有些人希望了解每一个细节。如果你是这样的人,那么请放心,学* Android API 内部工作的最佳起点是按照 API 程序员的意图使用它。此外,在整本书中,我将定期指出更多的学*机会,让你了解 Android API 的内部工作。此外,我们将编写自己可重用的类,有点像我们自己的 API,只是我们的类将专注于我们希望我们的应用程序执行的任务。

欢迎来到OOP的世界。我将在每一章中不断提到 OOP,并且在第十章中会有一个重大揭示,面向对象编程

再跟我说一遍,Android 到底是什么?

在 Android 上完成任务,我们编写了自己的代码,这也使用了 Android API 的代码。然后,这些代码被编译成 DEX 代码,其余的由 Android 设备处理,而 Android 设备又运行在一个称为 Linux 的底层操作系统上,该操作系统处理着不同的 Android 设备的复杂和极其多样的硬件。

Android 设备的制造商和各个硬件组件的制造商显然也知道这一点,并且他们编写了称为驱动程序的高级软件,以确保他们的硬件(例如 CPU、GPU、GPS 接收器、存储芯片和硬件接口)可以在底层的 Linux 操作系统上运行。

DEX 代码(以及其他一些资源)被放在一个称为Android 应用程序包APK)的文件包中,这就是设备运行我们的应用所需的内容。

提示

不需要记住我们的代码与硬件交互时经历的步骤的细节。只需要理解我们的代码经历了一些自动化过程,成为了我们将发布到 Google Play 商店的应用程序。

接下来的问题是:所有这些编码和编译成 DEX 代码,以及 APK 打包,究竟是在哪里进行的?让我们看看我们将要使用的开发环境。

Android Studio

开发环境是一个术语,指的是在一个地方拥有你开发所需的一切,并且已经准备就绪。

开发 Android 需要一整套工具,当然还需要 Android API。这整套要求统称为 SDK。幸运的是,下载和安装一个单一应用程序将把这些东西捆绑在一起。这个应用程序就是 Android Studio。

Android Studio 是一个集成开发环境IDE),将处理编译代码和与 Android API 链接的所有复杂性。安装完 Android Studio 后,我们可以在这个单一应用程序内完成所有需要做的事情,并将我们讨论过的许多复杂性放到脑后。

提示

随着时间的推移,这些复杂性将变得很自然。不需要立即掌握它们以继续进展。

所以,最好还是用上 Android Studio。

设置 Android Studio

设置 Android Studio 相当简单,尽管有点费时。拿些点心,按照以下步骤开始:

  1. 访问developer.android.com/studio/index.html。点击大的下载 Android Studio按钮继续:设置 Android Studio

  2. 勾选复选框接受条款和条件,然后点击下载 Android Studio for Windows按钮:设置 Android Studio

  3. 下载完成后,运行刚刚下载的文件。文件名以android-studio-ide…开头,文件名的结尾会根据当前阅读时的版本而有所不同。

  4. 点击下一步 >按钮继续:设置 Android Studio

  5. 保持默认选项,如下截图所示,然后点击下一步 >按钮:设置 Android Studio

  6. 接下来,我们需要选择安装 Android Studio 的位置,如下截图所示:设置 Android Studio

安装向导*议500MB 的可用空间,但你可能已经注意到前一个屏幕*议了 2.1GB。然而,在安装过程的后期还有更多的要求。此外,如果你的 Android Studio 部分和项目文件都在同一硬盘上,那会更容易一些。

出于这些原因,我*议至少有 4GB 的可用空间。如果需要切换驱动器以适应这一点,那么使用浏览..按钮浏览到硬盘上合适的位置。

提示

记下你选择的位置。

  1. 当你准备好了,点击下一步 >按钮。

  2. 在接下来的窗口中,选择Android Studio将出现在开始菜单中的文件夹。你可以保持默认设置,如下:设置 Android Studio

  3. 点击安装。这一步可能需要一些时间,特别是在旧机器上或者如果你的网络连接较慢。当这个阶段完成时,你会看到以下屏幕:设置 Android Studio

  4. 点击下一步 >

  5. Android Studio 现在已安装 - 有点。勾选启动 Android Studio复选框,然后点击完成按钮:设置 Android Studio

  6. 你会看到欢迎屏幕,如下截图所示:设置 Android Studio

  7. 点击下一步按钮。

  8. 选择标准安装类型,如下截图所示:设置 Android Studio

  9. 点击下一步按钮。

  10. 选择你喜欢的配色方案。我选择了IntelliJ,如下截图所示:设置 Android Studio

  11. 点击下一步

  12. 现在你会看到验证设置屏幕:设置 Android Studio

  13. 点击完成按钮。Android Studio 现在将开始一些更多的下载,可能需要一些时间。

  14. 当 Android Studio 准备好时,您将有运行它的选项。在这一点上,点击完成按钮。Android Studio 很可能已经准备好了。如果您要直接进行下一节,可以将其保持打开,或者在下一节的指示下关闭它,然后重新打开它。

最后一步 - 目前为止

使用您喜欢的文件管理软件,也许是 Windows 资源管理器,创*一个名为AndroidProjects的文件夹。将其放在您安装 Android Studio 的相同驱动器的根目录下。因此,如果您在C:/Program Files/Android安装了 Android Studio,那么请在C:/AndroidProjects创*您的新文件夹。

或者,如果您在D:/Program Files/Android安装了 Android Studio,那么请在D:/AndroidProjects创*您的新文件夹。

提示

请注意,下一节的截图显示了D:驱动器上的AndroidProjects文件夹。这是因为我的C:驱动器有点满了。两者都可以。我在一个有足够空间的 C:驱动器上的借来的 PC 上进行了安装教程的屏幕截图,因为那是 Android Studio 的默认位置。将其保持在与 Android 安装相同的驱动器上更整洁,可能会避免未来的问题,所以如果可以的话,请这样做。

请注意AndroidProjects之间没有空格,并且两个单词的第一个字母都是大写的。大写是为了清晰起见,而省略空格是 Android Studio 所要求的。

Android Studio 和我们需要的支持工具已经安装并准备就绪。我们现在离构*我们的第一个应用程序非常接近了。

现在,让我们稍微了解一下 Android 应用程序的组成。

什么构成了 Android 应用程序?

我们已经知道,我们将编写 Kotlin 代码,该代码将使用其他人的代码,并编译为 DEX 代码,该代码将在我们的用户 Android 设备上使用。除此之外,我们还将添加和编辑其他文件,这些文件将包含在最终的 APK 中。这些文件被称为Android 资源

Android 资源

正如本章前面提到的,我们的应用程序将包括资源,例如图像、声音和用户界面布局,这些资源将保存在与 Kotlin 代码分开的文件中。我们将在本书的过程中慢慢介绍它们。

它们还将包括我们应用程序的文本内容的文件。约定俗成的是通过单独的文件引用应用程序中的文本,因为这样可以很容易地进行更改,并且可以创*适用于多种不同语言和地理区域的应用程序。

此外,尽管我们可以选择使用可视化设计师来实现应用程序的用户界面UI)布局,但 Android 实际上是从基于文本的文件中读取它们的。

Android(或任何计算机)无法像人类那样阅读和识别文本。因此,我们必须以高度组织和预定义的方式呈现我们的资源。为此,我们将使用可扩展标记语言XML)。XML 是一个庞大的主题;幸运的是,它的整个目的是既适合人类阅读又适合机器阅读。我们不需要学*这种语言;我们只需要注意(然后遵守)一些规则。此外,大多数时候,当我们与 XML 交互时,我们将通过 Android Studio 提供的一个整洁的可视化编辑器来进行。我们可以通过文件扩展名为.xml来判断我们是否在处理 XML 资源。

您不需要记住这一点,因为我们将不断地在本书中回到这个概念。

Android 代码的结构

除了这些资源之外,值得注意的是 Android 的代码结构。我们可以利用数百万行代码。显然,这些代码需要以便于查找和引用的方式进行组织。它们被组织成特定于 Android 的

每当我们创*一个新的 Android 应用程序时,我们都会选择一个唯一的名称,称为。我们很快就会看到我们是如何做到这一点的,在标题为我们的第一个 Android 应用的部分中。包通常被分成子包,以便它们可以与其他类似的包一起分组。我们可以简单地将它们想象成文件夹和子文件夹,这几乎就是它们的实际情况。

我们可以将 Android API 提供给我们的所有包想象成来自代码库的代码。我们将使用的一些常见的 Android 包包括以下内容:

  • android.graphics

  • android.database

  • android.view.animation

正如你所看到的,它们被安排和命名,以使其中的内容尽可能明显。

提示

如果你想了解 Android API 的深度和广度,可以查看developer.android.com/reference/packages上的 Android 包索引。

早些时候,我们了解到我们可以将可重用的代码蓝图转换为对象,这些蓝图被称为。类包含在这些包中。在我们的第一个应用程序中,我们将看到如何轻松地导入其他人的包,以及从这些包中导入特定的类供我们的项目使用。一个类通常包含在与类同名的文件中。

函数

在 Kotlin 中,我们进一步将我们的类分成执行不同操作的部分,我们称这些面向操作的部分为函数。正是类的函数将用于访问 Android 代码中数百万行提供的功能。

我们不需要阅读代码。我们只需要知道哪个类有我们需要的东西,它在哪个包中,以及类内的哪个函数能给我们准确的结果。

我们可以以相同的方式来思考我们将自己编写的代码的结构,尽管通常每个应用程序只有一个包。

当然,由于 Kotlin 的面向对象的特性,我们只会使用 API 中的部分内容。还要注意,每个类都有自己独特的数据。通常,如果你想要访问类中的数据,你需要拥有该类的对象。

提示

你不需要记住这些,因为我们将在本书中不断回到这个概念。

本章结束时,我们将导入多个包,以及其中的一些类。到第二章结束时,我们甚至将编写我们自己的函数。

我们的第一个 Android 应用

现在我们可以开始我们的第一个应用程序了。在编程中,传统上新学生的第一个应用程序会使用他们正在使用的语言/操作系统向世界问好。我们将快速构*一个可以做到这一点的应用程序,并且在第二章中,Kotlin, XML 和 UI 设计师,我们将超越这一点,并添加一些在用户点击时响应的按钮。

注意

本章结束时的完整代码在Chapter01文件夹的下载包中供您参考。但是,您不能简单地复制和粘贴这些代码。您仍然需要按照本章(以及所有项目的开始)中解释的项目创*阶段进行操作,因为 Android Studio 在幕后做了大量工作。一旦您熟悉了这些步骤,并理解了哪些代码是由您,程序员,输入的,哪些代码/文件是由 Android Studio 生成的,那么您就可以通过从我在下载包中提供的文件中复制和粘贴来节省时间和输入。

按照以下步骤启动项目:

  1. 以与运行其他应用程序相同的方式运行 Android Studio。例如,在 Windows 10 上,启动图标会出现在开始菜单中。

提示

如果提示从…导入 Studio 设置:选择不导入设置

  1. 您将看到 Android Studio 的欢迎屏幕,如下截图所示。找到开始新的 Android Studio 项目选项,然后单击它:我们的第一个 Android 应用

  2. 之后,Android Studio 将弹出选择您的项目窗口,如下所示:我们的第一个 Android 应用

  3. 我们将使用基本活动选项,就像在上一张截图中选择的那样。Android Studio 将自动生成一些代码和一些资源来启动我们的项目。我们将在下一章详细讨论代码和资源。选择基本活动,然后点击下一步

  4. 接下来的屏幕是配置您的项目屏幕,在这里我们将执行以下步骤以及一些其他操作:

  5. 命名新项目

  6. 提供公司域作为包名,以区分我们的项目和其他任何项目,以防我们决定将其发布到 Play 商店

  7. 选择计算机上项目文件的位置

  8. 选择我们首选的编程语言

  9. 我们的项目名称将是Hello World,文件的位置将是我们在设置 Android Studio部分中创*的AndroidProjects文件夹。

  10. 包名可以是几乎任何您喜欢的东西。如果您有网站,可以使用com.yourdomain.helloworld的格式。如果没有,可以使用com.gamecodeschool.helloworld,或者您随意编造的东西。这只有在您决定发布时才重要。

  11. 为了清楚起见,如果您无法清楚地看到以下截图中的细节,这里是我使用的值。请记住,您的值可能会根据您选择的公司域和项目保存位置而有所不同:

选项 输入的值
名称: Hello World
包名: com.gamecodeschool.helloworld
语言: Kotlin
保存位置: D:\AndroidProjects\HelloWorld
最低 API 级别: 保持默认值
此项目将支持即时应用程序: 保持默认值
使用 AndroidX 构件: 选择此选项

注意

请注意,应用程序名称中的"Hello"和"World"之间有一个空格,但项目位置没有,如果有空格则无法工作。

关于最低 API 级别设置,我们已经知道 Android SDK 是我们将用于开发应用程序的代码包集合。像任何好的 SDK 一样,Android SDK 定期更新,每次进行重大更新时,版本号都会增加。简单来说,版本号越高,您可以使用的功能就越新;版本号越低,我们的应用程序就能在更多设备上运行。目前,默认的API 15, Android 4.0.3 (IceCreamSandwich)版本将为我们提供许多出色的功能,并且几乎与当前使用的 Android 设备兼容。如果在阅读时,Android Studio *议使用更新的 API,则请使用该 API。

如果您在未来的某个时候阅读本书,那么最低 API选项可能会默认为不同的内容,但本书中的代码仍将有效。

在您输入所有信息后,下面的截图显示了配置您的项目屏幕:

我们的第一个 Android 应用

注意

您可以使用几种不同的语言编写 Android 应用程序,包括 C++和 Java。与使用 Kotlin 相比,每种语言都有各种优缺点。学* Kotlin 将是对其他语言的很好介绍,而且 Kotlin 也是 Android 的最新(也可以说是最好的)官方语言。

  1. 单击完成按钮,Android Studio 将为我们准备新项目。这可能需要几秒钟或几分钟,具体取决于您的计算机性能。

在这个阶段,您可能已经准备好继续了,但是,根据安装过程的不同,您可能需要点击一些额外的按钮。

提示

这就是为什么我提到我们只是可能完成了安装和设置。

在 Android Studio 的底部窗口中查看是否有以下消息:

注意

请注意,如果您在 Android Studio 底部没有看到类似以下截图中显示的水*窗口,您可以跳过这两个额外的步骤。

可能的额外步骤 1

可能的额外步骤 1

如果需要,点击安装缺少的*台并同步项目,接受许可协议,然后点击下一步,接着点击完成

可能的额外步骤 2

您可能会收到另一条类似的消息:

可能的额外步骤 2

如果出现上述消息,请点击安装构*工具...,然后点击完成

提示

您可以通过单击 Android Studio 底部的消息选项卡来整理屏幕,并关闭底部的水*窗口,但这并不是强制性的。

到目前为止,部署应用程序

在我们探索任何代码并学*我们的第一部分 Kotlin 之前,您可能会惊讶地发现我们已经可以运行我们的项目。这将是一个相当简陋的屏幕,但由于我们将尽可能频繁地运行应用程序来检查我们的进度,让我们现在看看如何做到这一点。您有三个选项:

  • 在 PC 上的模拟器上运行应用程序(Android Studio 的一部分)处于调试模式

  • 在 USB 调试模式下在真实的 Android 设备上运行应用程序

  • 将应用程序导出为可以上传到 Play 商店的完整 Android 项目

第一个选项(调试模式)是最容易设置的,因为我们在设置 Android Studio 的过程中已经完成了。如果您有一台功能强大的 PC,您几乎看不到模拟器和真实设备之间的区别。然而,屏幕触摸是由鼠标点击模拟的,并且在一些后期的应用程序中无法进行用户体验的有效测试,比如绘图应用程序。此外,您可能更喜欢在真实设备上测试您的创作 - 我知道我是这样的。

第二个选项,使用真实设备,有一些额外的步骤,但一旦设置好,就和第一个选项一样好,屏幕触摸是真实的。

最后一个选项大约需要五分钟(至少)来准备,然后您需要手动将创*的包放到真实设备上并安装它(每次更改代码时)。

最好的方法可能是使用模拟器快速测试和调试代码的小增量,然后定期在真实设备上使用 USB 调试模式,以确保一切仍然如预期。只有偶尔你会想要导出一个实际可部署的包。

提示

如果您的 PC 特别慢或 Android 设备特别老旧,您可以只使用一个选项或另一个选项来运行本书中的项目。请注意,慢的 Android 手机可能会正常运行,但非常慢的 PC 可能无法处理后期应用程序的模拟器运行,并且您将受益于在手机/*板电脑上运行它们。

出于这些原因,我现在将介绍如何使用模拟器和 USB 调试在真实设备上运行应用程序。

在 Android 模拟器上运行和调试应用程序

按照以下简单步骤在默认的 Android 模拟器上运行应用程序:

  1. 在 Android Studio 的主菜单栏中,选择工具 | AVD 管理器。AVD 代表 Android 虚拟设备(模拟器)。您将看到以下窗口:在 Android 模拟器上运行和调试应用程序

  2. 注意列表中是否有一个模拟器。在我的情况下,它是Pixel 2 XL API 28。如果您在将来的某个时候阅读本书,它将是默认安装的不同模拟器。这并不重要。单击以下截图中显示的绿色播放图标(右侧),然后等待模拟器启动:在 Android 模拟器上运行和调试应用程序

  3. 现在,你可以点击 Android Studio 快速启动栏上的播放图标,如下截图所示,然后在提示时选择Pixel 2 XL API 28(或者你的模拟器叫什么)应用程序将在模拟器上启动:在 Android 模拟器上运行和调试应用程序

你完成了。这是目前在模拟器中的应用程序外观。请记住,你可能(很可能)有一个不同的模拟器,这没关系:

在 Android 模拟器上运行和调试应用程序

显然,在我们搬到硅谷寻找财务支持之前,我们还有很多工作要做,但这是一个良好的开始。

我们需要经常测试和调试我们的应用程序,以便在开发过程中检查任何错误、崩溃或其他意外情况。

注意

我们将在下一章中看到如何从我们的应用程序中获取错误和其他调试反馈。

确保它在你想要定位的每种设备类型/尺寸上看起来好看并且运行正确是很重要的。显然,我们并不拥有成千上万种 Android 设备中的每一种。这就是模拟器的用武之地。

然而,模拟器有时会有点慢和繁琐,尽管最近已经有了很大的改进。如果我们想要真正感受到用户体验,那么我们无法击败部署到真实设备。因此,在开发我们的应用程序时,我们既想使用真实设备,又想使用模拟器。

提示

如果你计划不久后再次使用模拟器,请保持其运行,以避免等待它再次启动。

如果你想在*板电脑上尝试你的应用程序,你需要一个不同的模拟器。

注意

创*新的模拟器

为不同的 Android 设备创*模拟器很简单。从主菜单中选择工具 | AVD 管理器。在AVD 管理器窗口中,左键单击创*新的虚拟设备。现在左键单击你想要创*的设备类型 - 电视手机Wear OS*板电脑。现在只需左键单击下一步,按照说明创*你的新 AVD。下次运行你的应用程序时,新的 AVD 将出现为运行应用程序的选项。

现在我们可以看看如何将我们的应用程序放到真实设备上。

在真实设备上运行应用程序

首先要做的事情是访问你的设备制造商的网站,获取并安装你的设备和操作系统所需的任何驱动程序。

提示

大多数新设备不需要驱动程序,所以你可能首先想尝试以下步骤。

接下来的几个步骤将为 Android 设备设置调试。请注意,不同的制造商对菜单选项的结构略有不同。但是对于大多数设备来说,启用调试的以下顺序可能非常接近,如果不是完全相同:

  1. 点击你手机/*板上的设置菜单选项或设置应用程序。

  2. 这一步对不同版本的 Android 略有不同。开发者选项菜单被隐藏起来,以免给普通用户带来麻烦。你必须执行一个稍微奇怪的任务来解锁菜单选项。点击关于设备关于手机选项。找到构*号选项,重复点击它,直到你收到一条消息,告诉你你现在是开发者了!

提示

一些制造商有不同的、晦涩的方法来完成这一步。如果这一步不起作用,请搜索你的设备和“解锁开发者选项”。

  1. 返回设置菜单。

  2. 点击开发者选项

  3. 点击USB 调试的复选框。

  4. 将你的 Android 设备连接到计算机的 USB 端口。

  5. 从 Android Studio 工具栏中点击播放图标,如下截图所示:在真实设备上运行应用程序

  6. 在提示时,点击确定在你选择的设备上运行应用程序。

现在我们准备好学*一些 Kotlin,并将我们自己的 Kotlin 代码添加到 Hello World 项目中。

常见问题

问:那么,Android 实际上并不是一个操作系统;它只是一个虚拟机,所有 Android 手机和*板电脑实际上都是 Linux 机器吗?

答:不,Android 设备的所有不同子系统,包括 Linux、库和驱动程序,构成了 Android 操作系统。

总结

到目前为止,我们已经*立了一个 Android 开发环境,并在模拟器和真实设备上创*和部署了一个应用程序。如果您仍然有未解答的问题(您可能比本章开始时还有更多问题),不要担心,因为随着我们深入了解 Android 和 Kotlin 的世界,事情会变得更清晰。

随着章节的进展,您将*立起对所有内容如何相互关联的全面理解,然后成功只是一个练*和更深入了解 Android API 的问题。

在下一章中,我们将使用可视化设计师和原始 XML 代码来编辑 UI,同时编写我们的第一个 Kotlin 函数,并且我们将使用 Android API 为我们提供的一些函数。

第二章:Kotlin,XML 和 UI 设计师

在这个阶段,我们已经有了一个可用的 Android 开发环境,并且已经构*并部署了我们的第一个应用程序。然而,显然,由 Android Studio 自动生成的代码不会成为下一个畅销的 Google Play 应用程序。我们需要探索这些自动生成的代码,以便开始了解 Android,然后学*如何构*这个有用的模板。为了达到这个目的,我们将在本章中进行以下操作:

  • 了解如何从我们的应用程序中获得技术反馈。

  • 检查我们第一个应用程序的 Kotlin 代码和用户界面(UI)XML 代码。

  • 第一次尝试使用 Android UI 设计师。

  • 编写我们的第一个 Kotlin 代码。

  • 学*一些核心的 Kotlin 基础知识以及它们与 Android 的关系。

首先,让我们看看如何从我们的应用程序中获得反馈。

检查日志输出

在上一章中,我们提到我们的应用程序在模拟器或真实设备上以调试模式运行;这样我们就可以监视它,并在出现问题时获得反馈。那么,所有这些反馈在哪里呢?

您可能已经注意到 Android Studio 窗口底部有很多滚动文本。如果没有,请点击 logcat 选项卡,如下面截图中标有 1 的区域所示:

提示

请注意,模拟器必须在运行状态,或者真实设备必须以调试模式连接,才能看到下面的窗口。此外,如果由于某种原因重新启动了 Android Studio,并且尚未执行应用程序,则 logcat 窗口将为空。请参考第一章,在模拟器或真实设备上运行应用程序:

检查日志输出

如果需要查看更多内容,您可以像大多数其他 Windows 应用程序一样,拖动窗口使其更高。

这个窗口称为 logcat,有时也被称为控制台。这是我们的应用程序告诉我们用户看不到的情况。如果应用程序崩溃或出现错误,原因或线索将出现在这里。如果我们需要输出调试信息,我们也可以在这里进行。

提示

如果你不明白为什么你的应用程序崩溃了,将 logcat 中的一部分文本复制粘贴到 Google 中通常会揭示原因。

过滤 logcat 输出

您可能已经注意到,logcat 的大部分内容,如果不是全部,几乎是难以理解的。没关系;现在,我们只对将在红色中突出显示的错误和我们将在下面学*的调试信息感兴趣。为了在 logcat 窗口中看到更少的无关文本,我们可以打开一些过滤器以使事情更清晰。

在上一张截图中,我还突出显示了另外两个区域,标记为 2 和 3。区域 2 是控制第一个过滤器的下拉列表。现在左键单击它,并将其从 Verbose 更改为 Info。我们已经大大减少了文本输出。当我们对应用程序进行了一些更改并重新部署后,我们将看到这是有用的。在我们探索构成我们项目的代码和资产之后,我们将这样做。另外,请确保标记为 3 的区域显示为“仅显示所选应用程序”。如果不是,请左键单击它,并将其更改为“仅显示所选应用程序”。

现在我们可以看一下 Android Studio 自动生成的内容,然后开始修改和添加代码,以个性化我们从项目创*阶段得到的内容。

探索项目的 Kotlin 代码和主要布局的 XML 代码

我们将查看包含定义简单 UI 布局的代码的资源文件,以及包含我们 Kotlin 代码的文件。在这个阶段,我们不会试图理解所有内容,因为在理解之前我们需要学*更多的基础知识。然而,我们将看到这两个文件的基本内容和结构,以便将它们的内容与我们已经了解的 Android 资源和 Kotlin 知识相协调。

检查 MainActivity.kt 文件

首先让我们看一下 Kotlin 代码。你可以通过左键单击MainActivity.kt标签来查看这段代码,如下面的截图所示:

检查 MainActivity.kt 文件

由于我们不会详细讨论代码的细节,带注释的截图比以文本形式重现实际代码更有用。在阅读本节时,请经常参考以下截图:

检查 MainActivity.kt 文件

首先要注意的是,我在代码中添加了一些空行,以便排版和呈现更清晰的图像。

在 Android Studio 中折叠(隐藏)代码

现在,看一下 Android Studio 窗口的左侧(不是前面的截图),观察编辑器左侧的所有+-按钮,可以折叠和展开代码的部分:

在 Android Studio 中折叠(隐藏)代码

我已经折叠了一些代码部分,留下了其他部分可见。因此,你屏幕上看到的与前面的截图略有不同。在 Android Studio 中,尝试一段时间使用左侧的+按钮,练*隐藏和显示代码的部分。你可能能够让你的屏幕看起来像前面的截图,但这并不是继续的要求。像这样隐藏代码的技术术语称为折叠

包声明

1部分称为包声明,正如你所看到的,它是我们在创*项目时选择的包名称,前面加上package这个词。每个 Kotlin 文件的顶部都会有一个包声明。

导入类

2部分是六行代码,都以import开头。在import之后,我们可以看到各种用点分隔的单词。每行的最后一个单词是该行导入到我们项目中的类的名称,而每行中较早的单词都是包和子包,其中包含这些类。

例如,下一行导入了androidx.appcompat.app包和子包中的AppCompatActivity类:

import androidx.appcompat.app.AppCompatActivity

这意味着在我们的项目中,我们将可以访问这些类。实际上,正是这些类被自动生成的代码用来制作我们在上一章中看到的简单应用程序。

在本章中我们不会讨论所有这些类。重要的是我们可以导入这些类,这使我们能够立即获得更多功能。请注意,我们可以随时从任何包中添加额外的类,并且在不久的将来我们将这样做以改进我们的应用程序。

类声明

我们的代码的第3部分称为类声明。以下是完整的那一行;我已经突出显示了其中的一部分,如下所示:

class MainActivity : AppCompatActivity() {

类声明是类的开始。请注意,突出显示的部分MainActivity是在创*项目时自动生成的名称,也与MainActivity.kt文件名相同。这正如我们之前讨论过的 Kotlin 类一样。

冒号(:)表示我们的名为MainActivity的类将是AppCompatActivity类型。这表明,虽然这个文件中的代码行数不多,但我们也使用了更多的代码,这些代码来自AppCompatActivity类。所有这些以及更多内容将在第十章面向对象编程中变得清晰。

最后,对于第3部分,看一下行末的左花括号:{。现在看看我们代码截图的第4部分。这个右花括号(})表示类的结束。在左右花括号之间的所有内容,{...},都是类的一部分。

类内的函数

现在看看代码的第5部分。这是完整的代码行,其中突出显示了我们当前讨论的关键部分:

override fun onCreate(savedInstanceState: Bundle?) {

这是一个函数签名。高亮显示的部分onCreate是函数名称。Kotlin 的fun关键字清楚地表明这是函数的开始。我们通过使用函数的名称来执行其代码。当我们这样做时,我们说我们正在调用一个函数。

尽管我们现在不会关心函数名两侧的代码部分,但你可能已经注意到Bundle,这是我们在代码的第2部分中导入的类之一。如果我们删除相关的import行,Android Studio 将不知道Bundle类是什么,它将无法使用,并且会以红色下划线突出显示为错误。

然后我们的代码将无法编译和运行。请注意,前面代码行的最后一件事是一个左花括号({)。这表示onCreate函数中包含的代码的开始。现在跳到我们代码的第6部分,您将看到一个右花括号(})。您可能已经猜到这是函数的结束。在onCreate函数的左右花括号之间的所有代码都是在调用函数时执行的代码。

我们现在不需要深入了解这段代码的作用,但是总体上,它通过引用一些由 Android Studio 在创*项目时自动生成的资源文件来设置应用的外观和布局。我在前面的截图中用标号9标出了这些资源文件的轮廓。

部分78也是我折叠起来以使截图和讨论更简单的函数。它们的名称分别是onCreateOptionsMenuonOptionsItemSelected

我们对我们的 Kotlin 代码了解足够多,可以取得一些进展。我们将在本章后面再次看到这段代码并进行更改。

到目前为止的 Kotlin 代码摘要

确实,在前面的代码中包含了一些复杂的语法。然而,我们正在*立对这段代码的足够了解,以便我们可以在其中工作,并开始快速学* Kotlin 和 Android,而不必先阅读数百页的 Kotlin 理论。到本书结束时,所有的代码都会让人明白。但为了现在快速进展,我们只需要接受一些细节将在稍后一段时间内仍然是个谜。

检查主布局文件

现在我们将只看其中一个.xml文件。在本书的整个过程中,我们将遇到几个不同的布局文件,但让我们从最重要的一个开始,它决定了我们应用的外观。

单击我们一直在讨论的MainActivity.kt标签旁边的content_main.xml标签。

在右侧的主窗口中,您将看到我们应用的设计视图,如下截图所示:

检查主布局文件

我们在设计应用程序时所做的大部分工作将在这个设计视图中完成。然而,了解背后发生了什么是很重要的。

设计视图是content_main.xml文件中包含的 XML 代码的图形表示。单击Text标签(如前面截图底部附近所示)以查看构成布局的 XML 代码。我已经注释了 XML 文本的截图,以便我们可以接下来讨论它:

检查主布局文件

首先要注意的是,这个文件并不代表整个布局。但它确实代表了大部分的表面积和中心的Hello World消息。此外,在左侧,我们可以看到现在熟悉的+-图标,以便我们可以折叠和展开代码的各个部分。

UI 布局元素

如果我们首先看一下标记为1的代码部分,我们会看到的第一件事是…ConstraintLayout...ConstraintLayout元素是用于包装 UI 的 UI 元素。

注意

有更多技术和具体的方式来指代我们用户界面设计的不同“元素”。随着我们的进展,我们将介绍诸如小部件、视图和视图组等术语。

当我们在 Android 中的 UI 中添加一个新元素时,我们总是以左尖括号(<)开头,后面跟着元素的名称。

紧随这行看起来相当长而繁琐的代码之后的代码定义了这个元素将具有的属性。这可能包括数十种不同的东西,取决于它是什么类型的 UI 元素。在这里,除了其他一些 XML 之外,我们可以看到诸如layout_widthlayout_heightshowIn之类的东西。所有这些属性定义了ConstraintLayout元素将如何出现在用户的屏幕上。ConstraintLayout元素的属性在第一个右尖括号(>)处结束,标记为1b

如果我们看一下 XML 截图的底部,我们会看到标记为2的代码。这段代码</…ConstraintLayout>标志着ConstraintLayout元素的结束。在元素的属性的右尖括号(>)和定义其结束的</…ConstraintLayout>代码之间的任何内容都被视为元素的子元素。因此,我们可以看到我们的ConstraintLayout元素有(或包含)一个子元素。现在让我们来看看这个子元素。

UI 文本元素

利用我们刚刚学到的知识,我们可以推断出在截图的位置3开始的 UI 元素称为TextView元素。就像它的父元素一样,它以左尖括号(<)和它的名称开始:<TextView...。如果我们进一步查看我们的TextView元素,我们会发现它有几个属性。它有一个text属性,设置为"Hello world!"。当然,这就是我们的应用向用户显示的确切文本。它还有layout_widthlayout_height属性,都设置为"wrap_content"。这告诉TextView元素,它可以占用所需的内容空间,但不会超过。正如我们将在本书中看到的,对于这个和其他 UI 元素,还有许多其他属性可用。

请注意我们的 XML 截图上4位置的代码是/>。这标志着TextView元素的结束。这与ConstraintLayout元素的结束方式略有不同。当 XML 中的元素没有子元素时,我们可以像这样结束它:/>。当元素有子元素并且其结束位置在其属性定义的代码之后时,通过重复其名称来结束元素会更清晰,像这样:</…ConstraintLayout>

注意

您可能想知道为什么TextView元素的元素名称简短而简洁(只是TextView),而ConstraintView元素的完整名称前面有明显复杂的杂乱(androidx.constraintlayout.widget.ConstraintLayout)。这个ConstraintLayout元素是布局的特殊版本,用于确保我们的应用与较旧版本的 Android 兼容。正如我们将在一分钟内看到的,当我们向应用添加按钮时,大多数元素都有简单而简洁的名称。

我们将在下一节中编辑这段代码,并了解更多关于属性的知识,同时探索另一种类型的 UI 元素——即Button元素。

将按钮添加到主布局文件

在这里,我们将在屏幕上添加一对按钮,然后探索一种快速的方法让它们做一些事情。我们将以两种不同的方式添加按钮;首先,使用可视化设计师,其次,通过直接添加和编辑 XML 代码。

通过可视化设计师添加按钮

要开始添加我们的第一个按钮,请点击我们刚刚讨论过的 XML 代码下方的Design选项卡,切换回设计视图。按钮在以下截图中被突出显示:

通过可视化设计师添加按钮

注意,在布局的左侧,我们有一个名为Palette的窗口:

通过可视化设计师添加按钮

调色板窗口分为两部分。左侧列表显示了 UI 元素的类别,并允许您选择类别,而右侧显示了当前选定类别中所有可用的 UI 元素。

确保选择了Common类别,如前面的截图所示。现在,左键单击并按住Button小部件,然后将其拖放到布局的顶部和中心附近。

它不需要完全一样;然而,练*做对是很好的。所以,如果你对按钮的位置不满意,那么你可以左键单击它在布局上进行选择,然后在键盘上按下Delete键将其删除。现在你可以重复上一步,直到你有一个你满意的整齐放置的按钮,就像下图所示:

通过可视化设计师添加按钮

此时,我们可以在模拟器上或真实设备上运行应用程序,按钮将会出现。如果我们点击它,甚至会有一个简单的动画来表示按钮被按下和释放。如果你愿意,现在可以尝试一下。

为了使应用程序更有趣,我们将使用Attributes窗口编辑按钮的属性。

编辑按钮的属性

确保按钮被左键单击选择。现在找到编辑窗口右侧的Attributes窗口,如下所示:

编辑按钮的属性

在前面的截图中,您可以看到我们可以访问一些按钮的属性,尽管不是全部。要显示更多属性,请单击查看所有属性链接(如前面的截图所示)。

现在你可以看到按钮的全部细节,我们可以开始编辑它。看起来似乎很惊讶,看到一个按钮这样一个明显简单的东西有如此多的属性。这是 Android API 为 UI 操作提供的多功能性和强大性的体现。看下面的截图,显示了我们最近添加的按钮的完整属性列表:

编辑按钮的属性

此外,注意即使前面的截图并没有显示所有内容,你可以使用Attributes窗口右侧的滚动条来显示更多属性。

正如你所看到的,我们可以在 UI 设计师中编辑的不同属性有很多。在第十二章中,将我们的 Kotlin 连接到 UI 和空值性,我们还将使用我们的 Kotlin 代码编辑和操作这些属性。现在,我们只编辑一个属性。滚动Attributes窗口,直到找到onClick属性,然后左键单击它进行编辑,如下图所示:

编辑按钮的属性

提示

属性按字母顺序排列,onClick大约在冗长列表的三分之二处。

onClick属性的编辑框中键入topClick,然后在键盘上按Enter。确保使用相同的大小写,包括略微反直觉的小写t和大写C

当完成时,Attributes窗口将如下截图所示:

编辑按钮的属性

我们在这里做的是在我们的代码中命名了我们想要在用户点击该按钮时调用(或执行)的 Kotlin 函数。名称是任意的,但是,由于这个按钮位于屏幕顶部,名称似乎有意义且易于记忆。我们使用的奇怪大小写是一种约定,将帮助我们保持代码清晰易读。随着我们的代码变得越来越长和复杂,我们将看到这种做法的好处。

当然,topClick函数目前还不存在。Android Studio 非常有帮助,但有一些事情我们需要自己做。在添加第二个按钮到我们的 UI 后,我们将使用 Kotlin 代码编写这个函数。此时,您可以运行应用程序,它仍然可以工作,但如果单击按钮,应用程序将崩溃,并显示错误消息,因为该函数不存在。

检查新按钮的 XML 代码

在为该项目添加最终按钮之前,单击编辑器下方的Text选项卡,切换回查看构成我们 UI 的 XML 代码:

检查新按钮的 XML 代码

请注意,在我们之前检查的 XML 代码中有一个新的代码块。以下是新代码块的屏幕截图:

检查新按钮的 XML 代码

请注意以下细节,这些细节应该与我们对 XML 和 Android UI 元素的了解相对应:

  • 新代码以<Button开头,以/>结束。

  • 新代码具有一系列属性,定义了按钮,包括layoutWidthlayoutHeight

  • 代码包括我们添加的带有值“topClick”的onClick属性。

  • onClick属性的topClick值被红色下划线标出,显示错误,因为函数目前还不存在。

  • 按钮代码的开始和结束被包含在ConstraintLayout元素中。

注意

dp 是一个测量/距离的单位,将在第五章中更深入地讨论,使用 CardView 和 ScrollView 创*美丽的布局

将鼠标悬停在下划线的topClick值上,以显示问题的详细信息,如下面的屏幕截图所示:

检查新按钮的 XML 代码

我们可以确认问题是,Android Studio 期望我们的代码中实现一个名为topClick的函数。在添加第二个按钮后,我们将这样做。

通过编辑 XML 代码添加按钮

为了多样化,并证明我们可以,我们现在将只使用 XML 代码添加另一个按钮,而不使用 UI 设计师。大多数时候,我们会使用 UI 设计师,但这个快速练*应该巩固 UI 设计师和底层 XML 代码之间的关系。

我们将通过复制和粘贴现有按钮的代码来实现这一点。然后,我们将对粘贴的代码进行一些小的编辑。

在以<Button开头的按钮代码之前单击左键。请注意,代码的开头和结尾现在有轻微的高亮显示:

通过编辑 XML 代码添加按钮

这已经确定了我们要复制的代码部分。现在,左键单击并拖动以选择所有按钮代码,包括高亮显示的开头和结尾,如下一个屏幕截图所示:

通过编辑 XML 代码添加按钮

按下Ctrl + C组合键复制突出显示的文本。将光标放在现有按钮代码下方,然后按几次Enter键,留下一些额外的空行。

按下Ctrl + V组合键粘贴按钮代码。此时,我们有两个按钮;但是,有一些问题:

通过编辑 XML 代码添加按钮

我们在代表我们的按钮的两个代码块中都有额外的错误。id属性(在两个代码块中)被红色下划线标出。这个错误的原因是两个按钮都有相同的id属性。id属性应该将 UI 元素与所有其他 UI 元素区分开来,所以它们不能相同。让我们试着解决这个问题。

给按钮唯一的 id 属性

我们可以通过将第二个按钮称为button2来解决问题,但更有意义的是改变它们两个。编辑第一个按钮的代码,给它一个id属性为buttonTop。为此,找到第一个按钮中的以下代码行:

android:id="@+id/button"

然后,将代码行更改为以下内容:

android:id="@+id/buttonTop"

提示

注意button中的小写bTop中的大写T

现在找到第二个按钮中的以下代码行:

android:id="@+id/button"

然后,将代码行更改为以下内容:

android:id="@+id/buttonBottom"

id属性行上的错误已经消失。此时,你可能会认为我们可以继续解决我们的缺失功能问题。

然而,如果你运行应用程序并快速浏览一下,你会发现我们似乎只有一个按钮。不仅如此,按钮的位置也不是我们期望的:

给按钮唯一的 id 属性

这样做的原因是我们没有明确地定位它们,所以它们默认位于屏幕的左上角。我们在设计选项卡上看到的位置只是设计时的位置。所以,现在让我们来改变它。

在布局中定位这两个按钮

我们只能看到一个按钮的原因是,两个按钮都在同一个位置。第二个按钮正好覆盖在第一个按钮上。因此,即使在设计选项卡中(随时可以查看),按钮仍然重叠在一起,尽管它们位于屏幕中间:

在布局中定位这两个按钮

注意

你可能会想知道为什么 UI 布局工具被设计成这种看似反直觉的方式;原因是灵活性。正如你将在接下来的两章中看到的,不仅可以在设计时以不同的方式定位 UI 元素,而且还有一系列不同的布局方案供应用程序设计者(也就是你)选择,以适应他们的计划。这种灵活性在学* Android 时会导致一些尴尬,但一旦你克服了这种尴尬,就会获得很强大的设计能力。但不要担心,我们会一步一步地进行,直到你掌握了这个技巧。

我们将让 Android Studio 自动为我们解决问题,首先添加到我们的代码,然后使用 UI 设计工具。首先,让我们正确设置设计时布局。在第二个按钮的代码中,找到以下代码行:

tools:layout_editor_absoluteY="30dp" />

现在将其编辑为与以下代码行相同:

tools:layout_editor_absoluteY="100dp" />

提示

根据你放置第一个按钮的确切位置,Android Studio 中的值可能与刚刚讨论的值不同。如果第二个按钮比第一个按钮高大约 70dp,那么你可以继续进行这个练*。

这个微小的改变会使第二个按钮向下移动一点,但只在设计时有效。如果你在设计选项卡中查看,按钮将整齐地放置在第一个按钮的下方,但如果你在模拟器上运行应用程序,它们仍然都在屏幕的左上角,并且彼此重叠。

切换到设计选项卡,找到如下截图所示的推断约束按钮:

在布局中定位这两个按钮

点击推断约束按钮。Android Studio 将编辑 XML 代码。让我们简要看一下幕后发生了什么。从代表按钮的代码部分的末尾,删除了以下代码行:

tools:layout_editor_absoluteX="147dp"
tools:layout_editor_absoluteY="30dp" />

这两行代码是水*(…absoluteX)和垂直(…absoluteY)定位按钮的原因。

Android Studio 还向第一个按钮添加了四行代码,向第二个按钮添加了三行代码。以下是在第一个按钮附近添加的代码:

android:layout_marginTop="30dp"

提示

dp的确切值可能会有所不同,具体取决于您放置按钮的位置。

这段代码导致按钮在顶部有一个30的边距。但是在什么顶部呢?看看以下添加到第一个按钮末尾的三行代码:

app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

注意layout_constraintEnd_toEndOflayout_constraintStart_toStartOflayout_constraintTop_toTopOf的新属性。分配给这些属性的值是"parent"。这导致第一个按钮相对于父 UI 元素进行定位。父元素是包含所有其他元素的布局;在这种情况下,父元素是ConstraintLayout元素。

现在看看添加到第二个(底部)按钮的三行代码。

在代码的开头附近,我们看到以下内容:

android:layout_marginTop="22dp"

提示

再次强调,dp的确切值可能会有所不同,具体取决于您放置按钮的位置。

在第二个按钮的代码末尾,我们看到以下两行额外的代码:

app:layout_constraintStart_toStartOf="@+id/buttonTop"
app:layout_constraintTop_toBottomOf="@+id/buttonTop" />

这意味着第二个按钮相对于buttonTop的位置有一个22的边距。

现在运行应用程序,您将看到我们有两个不同的按钮。一个具有buttonTopid属性,它在具有buttonBottomid属性的另一个按钮上方:

在布局中定位两个按钮

显然,布局比我目前提到的要复杂得多,但您已经初步了解了 Android Studio 提供的选项,用于设计我们应用程序的 UI。我们将更仔细地研究ConstraintLayout,并在第四章开始布局和材料设计中探索更多的布局选项。

我们想对我们的 XML 代码进行一些修改。

使按钮调用不同的函数

切换回Text标签,并在第二个(buttonBottom)按钮中找到下一行代码:

android:onClick="topClick"

接下来,编辑代码如下:

android:onClick="bottomClick"

现在我们有两个按钮,一个在另一个上面。顶部按钮的id属性为buttonToponClick属性的值为topClick。另一个按钮的id属性为buttonBottomonClick属性的值为bottomClick

这些最后的 XML 代码更改现在意味着我们需要在 Kotlin 代码中提供两个函数(topClickbottomClick)。

提示

从技术上讲,当点击两个按钮时调用相同的函数是可以的-这不是语法错误。然而,大多数按钮确实有不同的目的,因此如果我们的按钮执行不同的操作,这个练*将更有意义。

我们很快就会做到,但在我们这样做之前,让我们先了解一下 Kotlin 注释,并查看一些我们可以编写的 Kotlin 代码,以向用户发送消息,并为调试目的向自己发送消息。

在我们的 Kotlin 代码中留下注释

在编程中,写代码注释是一个聪明的主意,并且在你的代码中大量使用它们。这是为了提醒我们在编写代码时的想法。要做到这一点,你只需添加双斜杠,然后输入你的注释,如下所示:

// This is a comment and it could be useful

此外,我们可以使用注释来注释掉一行代码。假设我们有一行代码暂时想要禁用;我们可以通过添加两个斜杠来实现,如下所示:

// The code below used to send a message
// Log.i("info","our message here")
// But now it doesn't do anything
// And I am getting ahead of where I should be

提示

使用注释注释掉代码应该只是一个临时措施。一旦找到正确的代码使用,注释掉的代码应该被删除,以保持代码文件的清洁和有组织性。

让我们来看看在 Android 中发送消息的两种不同方式,然后我们可以编写一些函数,当我们的 UI 按钮被按下时发送消息。

向用户和开发者编码消息

在本章的介绍和上一章中,我们谈到了一些关于使用其他人的代码的事情,特别是通过安卓 API 的类和它们的函数。我们看到我们可以用相当少量的代码做一些相当复杂的事情(比如与卫星通信)。

为了让我们开始编码,我们将使用安卓 API 中的两个不同类,这些类允许我们输出消息。第一个类Log允许我们将消息输出到 logcat 窗口。第二个类Toast不是一种美味的早餐款待,而是会为我们的应用程序用户产生一个类似吐司的弹出消息。

这是我们需要编写的代码,以便将消息发送到 logcat:

Log.i("info","our message here")

为什么这样做有效将在第十章面向对象编程中变得更清晰,但现在,我们只需要知道我们放在引号之间的任何内容都将输出到 logcat 窗口。我们很快就会看到在哪里放置这种类型的代码。

这是我们需要编写的代码,以便向用户屏幕发送消息:

Toast.makeText(this, "our message",
   Toast.LENGTH_SHORT).show()

这是一行非常复杂的代码,它的工作原理将在第九章Kotlin 函数中变得更清晰。这里重要的是,我们放在引号之间的任何内容都将出现在一个弹出消息中供我们的用户查看。

让我们把一些代码(就像我们刚刚看到的)真正放入我们的应用程序中。

编写我们的第一个 Kotlin 代码

因此,我们现在知道了将输出到 logcat 或用户屏幕的代码。但是,我们应该把代码放在哪里呢?要回答这个问题,我们需要理解MainActivity.kt中的onCreate函数在应用程序准备展示给用户时执行。因此,如果我们将我们的代码放在这个函数的末尾,它将在用户看到它时运行;听起来不错。

提示

我们知道要执行函数中的代码,我们需要调用它。我们已经将我们的按钮连接起来调用一些函数,比如topClickbottomClick。很快,我们将编写这些函数。但是谁或什么在调用onCreate呢?这个谜团的答案是,安卓本身在用户点击应用图标运行应用时调用onCreate。在第六章安卓生命周期中,我们将深入探讨,清楚地了解代码何时执行。你现在不需要完全理解这一点;我只是想给你一个概述。

让我们快速尝试一下;切换到安卓工作室中的MainActivity.kt选项卡。

我们知道onCreate函数是在应用程序真正启动之前调用的。让我们将一些代码复制粘贴到我们的 Hello World 应用程序的onCreate函数中,看看当我们运行它时会发生什么。

onCreate函数添加消息代码

找到onCreate函数的闭合大括号(}),并在下面的代码块中添加突出显示的代码。在代码中,我没有显示onCreate函数的完整内容,而是使用表示未显示的一些行代码。重要的是将新代码(完整显示)放在最后,但在闭合大括号(})之前:

override fun onCreate(savedInstanceState: Bundle?) {
…
…
…
 // Your code goes here
 Toast.makeText(this, "Can you see me?", 
 Toast.LENGTH_SHORT).show()

 Log.i("info", "Done creating the app")
}

注意,在安卓工作室中,ToastLog的实例被标记为红色。它们是错误。我们知道ToastLog是类,而类是代码的容器。

问题是,安卓工作室在我们告诉它之前并不知道它们。我们必须为每个类添加一个import指令。幸运的是,这是半自动的。

Toast上单击左键;现在按住Alt键,然后点击Enter键。您需要执行此步骤两次,一次是为Toast,一次是为Log。安卓工作室将import指令添加到我们其他导入的代码顶部,并且错误消失了。

提示

Alt + Enter只是许多有用的键盘快捷键之一。以下是 Android Studio 的键盘快捷键参考。更具体地说,这是基于的 IntelliJ Idea IDE 的键盘快捷键。收藏这个网页;在本书的过程中它将是无价的:www.jetbrains.com/idea/docs/IntelliJIDEA_ReferenceCard.pdf

滚动到MainActivity.kt的顶部,查看添加的import指令。以下是为您方便起见:

import android.util.Log
import android.widget.Toast

以通常的方式运行应用程序,并查看logcat窗口中的输出。

检查输出

以下是 logcat 窗口中输出的屏幕截图:

检查输出

查看 logcat,您会看到我们的消息 - 完成创*应用程序 - 已输出,尽管它混杂在我们目前不感兴趣的其他系统消息中。如果您在应用程序首次启动时观看模拟器,您还将看到用户将看到的整洁弹出消息:

检查输出

您可能想知道为什么消息在那个时间输出。简单的答案是onCreate函数在应用程序开始响应用户之前被调用。在 Android 开发人员中,将代码放在此函数中以准备好应用程序并为用户输入做好准备是常见做法。

现在,我们将进一步迈出一步,编写我们自己的函数,这些函数由我们的 UI 按钮调用。我们将在其中放置类似的LogToast消息。

编写我们自己的 Kotlin 函数

让我们直接开始编写我们的第一个 Kotlin 函数,并在其中添加一些LogToast消息。

提示

如果您还没有这样做,现在是获取包含所有代码文件的下载包的好时机。您可以查看每个章节的完成代码。例如,本章的完成代码可以在Chapter02文件夹中找到。我进一步将Chapter02文件夹细分为kotlinres文件夹(用于 Kotlin 和资源文件)。在有多个项目的章节中,我将进一步划分文件夹以包含项目名称。您应该在文本编辑器中查看这些文件。我的最爱是 Notepad++,可以从notepad-plus-plus.org/download/免费下载。在文本编辑器中查看代码比直接从书中阅读更容易,尤其是*装版本,尤其是代码行很长的情况下。文本编辑器还是将代码部分选择复制并粘贴到 Android Studio 中的绝佳方式。您可以在 Android Studio 中打开代码,但这样您就有可能将我的代码与 Android Studio 的自动生成代码混淆。

识别MainActivity类的闭合大括号(})。

提示

请注意,您要寻找的是整个类的结尾,而不是onCreate函数的结尾,就像前一节一样。花点时间来识别新代码以及它在现有代码中的位置。

在那个大括号内,输入以下突出显示的代码:

override fun onCreate(savedInstanceState: Bundle?) {
…
…
…
…
}

…
…
…
fun topClick(v: View) {
 Toast.makeText(this, "Top button clicked", 
 Toast.LENGTH_SHORT).show()

 Log.i("info", "The user clicked the top button")
}

fun bottomClick(v: View) {
 Toast.makeText(this, "Bottom button clicked", 
 Toast.LENGTH_SHORT).show()

 Log.i("info", "The user clicked the bottom button")
}

} // This is the end of the class

注意,两个View实例是红色的,表示错误。只需使用Alt + Enter键组合导入View类并删除错误。

以通常的方式将应用程序部署到真实设备或模拟器,并开始点击按钮,以便我们观察输出。

检查输出

最后,我们的应用程序有所作为!我们可以看到我们在按钮onClick属性中定义的函数名称确实在按钮被点击时被调用;适当的消息被添加到logcat窗口;并且适当的Toast消息显示给用户。

诚然,我们仍然不理解为什么ToastLog真正起作用,也不完全理解我们函数语法中的(v: View)部分,或者自动生成的代码的其余部分。随着我们的进展,这将变得清晰起来。如前所述,在第十章 面向对象编程中,我们将深入探讨类的世界,在第九章 Kotlin 函数中,我们将掌握与函数相关的其余语法。

查看 logcat 输出;您可以看到onCreate函数之前记录了一个日志条目,以及我们自己编写的两个函数,每次点击按钮时都会记录。在下面的屏幕截图中,您可以看到我点击了每个按钮三次:

检查输出

由于您现在已经熟悉了logcat窗口的位置,因此在未来,我将以修剪后的文本形式呈现 logcat 输出,因为这样更容易阅读:

The user clicked the top button
The user clicked the top button
The user clicked the top button
The user clicked the bottom button
The user clicked the bottom button
The user clicked the bottom button

在下面的屏幕截图中,您可以看到顶部按钮已被点击,并且topClick函数被调用,触发了弹出的Toast消息:

检查输出

在本书中,我们将定期输出到 logcat,以便我们可以看到我们应用程序 UI 背后发生了什么。Toast消息更多是用于通知用户发生了某事。这可能是下载完成,新邮件到达,或者其他需要他们注意的事件。

常见问题

Q.1)你能提醒我函数是什么吗?

A)函数是我们的代码的容器,可以从代码的其他部分执行(调用)它们。

Q.2)像第一个问题一样,我觉得这一章很难。我需要重新阅读吗?

A)不,如果您成功构*了应用程序,您已经取得了足够的进步来处理下一章的所有内容。我们知识中的所有空白将逐渐填补,并随着书籍的进展而被美好的领悟时刻所取代。

总结

在这个练*中,我们取得了很多成就。的确,XML 代码仍然普遍难以理解。没关系,因为在接下来的两章中,我们将真正掌握可视化设计师,并更多地了解 XML 代码,尽管最终我们的目标是尽可能少地使用 XML 代码。

我们已经看到,当我们将按钮拖放到设计中时,XML 代码会为我们生成。此外,如果我们在属性窗口中更改属性,那么 XML 代码也会被编辑。此外,我们可以直接输入(或者在我们的情况下,复制和粘贴)XML 代码来创*新的按钮或编辑现有按钮。

我们已经看到并编写了我们的第一个 Kotlin 代码,包括帮助我们记录代码的注释,并且我们甚至添加了自己的函数来输出调试消息到 logcat 和弹出Toast消息给用户。

在下一章中,我们将全面介绍 Android Studio,以了解不同的操作在哪里完成。此外,我们将了解项目的资产(如文件和文件夹)的结构以及如何管理它们。这将为我们更深入地研究 UI 设计做好准备,第四章 开始布局和 Material Design 和第五章 CardView 和 ScrollView 创*美丽的布局,在这两章中,我们将为我们的应用程序构*一些重要的真实布局。

第三章:探索 Android Studio 和项目结构

在本章中,我们将创*并运行另外两个 Android 项目。这些练*的目的是更深入地探索 Android Studio 和 Android 项目的结构。

当我们构*应用程序准备部署时,代码和资源文件需要像在 APK 文件中那样打包起来。因此,所有布局文件和其他资源(我们很快将要看到的)都需要处于正确的结构中。

幸运的是,当我们从模板创*项目时,Android Studio 会为我们处理这些。然而,我们仍然需要知道如何找到和修改这些文件,如何添加我们自己的文件,有时还需要删除 Android Studio 创*的文件,以及资源文件如何相互关联 - 有时是彼此之间,有时是与 Kotlin 代码(即自动生成的 Kotlin 代码以及我们自己的代码)之间。

除了了解我们项目的组成,确保我们充分利用模拟器也将是有益的。

提示

模拟器在您希望确保您的应用程序在您未拥有的硬件上运行时特别有用。此外,了解一些最新功能(正如我们将在本书中了解到的)通常需要最新的手机,模拟器是一种经济有效的方式,可以让您在不购买最新手机的情况下跟随所有迷你应用程序。

在本章中,我们将执行以下操作:

  • 探索空活动项目模板的文件和文件夹结构。

  • 查看空活动基本活动模板之间的区别。

  • 了解如何充分利用模拟器。

本章将使我们能够在下一章中构*和部署多个不同的布局。

Android Studio 快速导览

要开始,请查看 Android Studio 的这个带注释的图解。我们将重新熟悉我们已经看过的部分,并了解我们尚未讨论的部分:

Android Studio 快速导览

正式指出并命名 Android Studio用户界面UI)的各个部分将是有益的,这样我就可以按名称引用它们,而不是一直描述它们的位置并展示截图。因此,让我们从1开始逐个介绍它们:

  1. 这是项目窗口,也是本章的主要焦点。它使我们能够探索项目的文件夹、代码和资源,并且也被称为项目资源管理器窗口。在这里双击一个文件以打开文件并在图中的区域3添加一个新标签。这里的文件和文件夹结构与最终打包到完成的 APK 文件中的结构非常相似。

提示

正如我们将看到的,虽然 Android 项目的文件夹结构保持不变,但文件、文件名和文件内容差异很大。因此,在本章中,我们将探索两个项目,然后随着我们在本书中的进展,再看更多的项目。

  1. 这是编辑器窗口。正如我们已经看到的,编辑器窗口会根据我们正在编辑的内容而呈现出不同的形式。如果我们正在编辑 Kotlin,那么我们可以看到我们的代码被整齐地格式化并准备好进行编辑;如果我们正在设计 UI,则它会为我们提供可视化编辑视图或文本/XML 代码视图。您还可以在此窗口中查看和编辑图形和其他文件。

  2. 这些标签允许我们在项目中不同的文件之间切换。编辑器窗口将显示我们在这里选择的文件。我们可以通过在项目窗口中双击文件来在此部分添加另一个标签。

  3. 这使我们能够在当前正在编辑的文件的设计文本(代码)视图之间切换。

  4. 这个窗口根据图表第六部分中选择的选项而有所不同。通常,在本书中,我们会在构*窗口和Logcat窗口之间切换,以查看我们的项目是否已经编译和启动,以及调试输出和应用程序的任何错误或崩溃报告。

  5. 这个 UI 区域用于在第五部分描述的不同显示之间进行切换。

注意

在 Android Studio 中有更多的选项卡,但在本书的上下文中我们不需要它们。

现在我们知道如何明确地引用 UI 的各个部分,让我们把注意力转向项目/项目资源管理器窗口。

项目资源管理器和项目解剖

当我们创*一个新的 Android 项目时,我们通常会使用项目模板,就像我们在第一章中所做的那样,开始使用 Android 和 Kotlin。我们使用的模板决定了 Android Studio 将生成的文件的精确选择和内容。虽然所有项目之间存在很大的相似之处值得注意,但了解差异也有帮助。让我们构*两个不同的模板项目,并检查文件、它们的内容以及通过代码(XML 和 Kotlin)如何链接在一起。

空活动项目

最简单的项目类型是自动生成 UI 的空活动项目。在这里,UI 是空的,但可以添加内容。也可以生成一个完全没有 UI 的项目。当我们创*一个项目时,即使 UI 为空,Android Studio 也会自动生成 Kotlin 代码来显示 UI。因此,当我们添加内容时,它已经准备好显示。

让我们创*一个空活动项目。这几乎与我们在第一章中所做的过程相同,开始使用 Android 和 Kotlin,但有一个我会指出的细微差别:

  1. 在 Android Studio 中,选择文件 | 新* | 新项目…

  2. 选择您的项目屏幕上,选择空活动模板,然后点击下一步

  3. 名称字段更改为空活动应用

  4. 选择与上一个项目相同的包名称和保存位置。

  5. 确保选择Kotlin作为语言。

  6. 勾选使用 AndroidX 构件复选框,就像我们之前做的那样。

  7. 其余设置可以保留为默认设置,所以只需点击下一步

Android Studio 将生成所有代码和其他项目资源。现在我们可以看到已生成的内容,并将其与项目资源管理器窗口中的预期内容进行比较。

如果模拟器尚未运行,请通过选择工具 | AVD 管理器来启动它,然后在Android 虚拟设备窗口中启动您的模拟器。通过在快速启动栏中点击播放按钮在模拟器上运行应用程序:

空活动项目

看看这个应用程序,注意它与第一个项目有些不同。它是空的;顶部没有菜单,底部没有浮动按钮。但是,它仍然有Hello World!文本:

注意

不要担心参考第一个项目;我们很快就会再*立一个类似的项目。

空活动项目

现在我们有了一个全新的空活动应用项目,让我们探索 Android Studio 为我们生成的文件和文件夹。

探索空活动项目

现在,是时候深入了解我们应用程序的文件和文件夹了。这将节省我们很多时间和困惑,以后在书中。请注意,无需记住所有这些文件的位置,甚至更不需要理解文件中的代码。事实上,XML 代码的部分内容在书的最后仍然是个谜,但这不会阻止您设计、编码和发布令人惊叹的应用程序。

创*项目后,请查看项目资源管理器窗口:

探索空白活动项目

注意前面截图中指示的两个箭头。你可能已经猜到,这些箭头允许我们展开appGradle Scripts文件夹。

注意

在本书的背景下,我们不需要探索Gradle Scripts文件夹。Gradle 是 Android Studio 的重要组成部分,但其作用是隐藏用户不需要了解的复杂过程,例如添加资源文件,编译和构*项目。因此,我们不需要深入研究这一点。然而,如果您决定将 Android 提升到下一个水*,那么深入了解 Gradle 及其与 Android Studio 的关系是值得投资时间的。

我们将更详细地探索app文件夹。单击app文件夹旁边的箭头以展开其内容,我们将开始探索。第一级内容显示在以下截图中:

探索空白活动项目

我们已经揭示了另外三个文件夹:manifestsjavares。让我们从顶部开始仔细查看这三个文件夹。

注意

我们将把我们的 Kotlin 代码放在java文件夹中。此外,自从 Android Studio 3.3 版本发布以来,还有一个名为generatedjava的文件夹,但我们不需要探索它。

manifests 文件夹

manifests文件夹里面只有一个文件。展开manifests文件夹,双击AndroidManifest.xml文件。注意文件已在编辑窗口中打开,并添加了一个选项卡,以便我们可以轻松地在此文件和其他文件之间切换。以下截图显示了新添加的选项卡,以及manifests文件夹中AndroidManifest.xml文件中包含的 XML 代码:

manifests 文件夹

我们不需要理解文件中的所有内容,但值得指出的是,我们将偶尔在这里进行修改,例如,当我们需要请求用户许可以访问其设备的某些功能时,例如消息应用或图像文件夹。当我们想要为游戏等全屏应用进行沉浸式体验时,我们也会编辑此文件。

注意文件的结构与我们在上一章中看到的布局文件的结构非常相似。例如,有明确定义的部分,以<section name开头,以</section name>结尾。这样的真实示例包括<application</application>,以及<activity</activity>

事实上,除了第一行之外,整个文件内容都包含在<manifest</manifest>中。

就像我们将计算的括号输入计算器一样,这些开放和关闭部分必须匹配,否则文件将在我们的项目中引起错误。Android Studio 会在行前缩进(即放置制表符),以使结构中的各个部分及其深度更清晰。

这段代码的一些特定部分值得注意,所以我将指出其中的一些行。

以下行告诉 Android,我们希望在他们的应用抽屉/主屏幕中向用户显示的图标,并且用户可以使用它来启动应用,它包含在mipmap文件夹中,名为ic_launcher

android:icon="@mipmap/ic_launcher"

随着我们继续探索,我们将自己验证这一点。

下一行有两个值得讨论的方面。首先,它表示我们给我们的应用的名称;其次,这个名称作为一个字符串包含在app_name标签中:

android:label="@string/app_name"

提示

在编程中,包括 Kotlin 和 XML,字符串可以是任何字母数字值。我们将在整本书中学*更多关于字符串的知识,从第七章开始。

因此,我们可以猜测app_name标签的字母数字值是Empty Activity App,因为这是我们创*应用程序时的名称。

这可能听起来很不寻常,但我们很快就会看到这个文件以及它的标签。在以后的项目中,我们还会为其添加更多的标签和值。我们也会明白为什么我们以这种看似复杂的方式向我们的应用程序添加文本的原因。

我们可以讨论AndroidManifest.xml文件中的每一行,但我们不需要这样做。让我们看看另外两行,因为它们是相关的。下一行指示了我们的 Activity 的名称,这是在创*项目时自动生成的。我已经突出显示了 Activity 名称,以使其更加突出:

<activity android:name=".MainActivity">

出现在<activity</activity>标签内的下一行表示它是activity文件的属性。这告诉我们,这个 Activity 是在应用程序启动时应该运行的 Activity;它是LAUNCHER

<category android:name="android.intent.category.LAUNCHER" />

这意味着我们的应用程序可以有多个 Activity。很多时候,如果你的应用程序有多个屏幕,比如主屏幕或设置屏幕,这些屏幕是由多个 Activity 类的实例构*的。

注意

在 XML 中,比如AndroidManifest文件,activity是小写的;但在 Kotlin 中,Activity类的A是大写的。这只是一种约定,不需要担心。

正如你刚刚看到的,XML 中的activity具有一个name属性,其值指向 Kotlin Activity的一个实例。

现在让我们深入java文件夹。

java 文件夹

在这里,我们将找到所有的 Kotlin 代码。起初,这只包括一个文件,但随着我们的项目进一步发展,我们会添加更多文件。展开java文件夹,你会发现另外三个文件夹,如下截图所示:

java 文件夹

对于本书,我们只需要这三个文件夹中的一个;也就是顶层文件夹。这些文件夹的名称由包名(在创*应用程序时选择)和应用程序名称组成,以小写形式呈现,没有空格(这也是在创*应用程序时选择的)。

提示

有多个同名文件夹的原因是由于自动化测试,这超出了本书的范围。因此,你可以安全地忽略以(androidTest)(test)结尾的文件夹。

我们在本书中感兴趣的唯一文件夹是顶层文件夹,对于我的屏幕上的这个应用程序来说,它是com.gamecodeschool.emptyactivityapp。根据你选择的包名和我们当前正在工作的应用程序的名称,文件夹的名称会发生变化,但我们始终需要访问并添加或编辑其内容的是顶层文件夹。

现在展开com.gamecodeschool.emptyactivityapp(或者你的应用程序名称)文件夹以查看其内容。在下面的截图中,你可以看到该文件夹只有一个文件:

java 文件夹

这是MainActivity.kt文件,尽管项目窗口中没有显示文件扩展名,但在编辑器窗口上方的标签中是有的。事实上,本书中java/packagename.appname文件夹中的所有文件都将有.kt扩展名。

如果你双击MainActivity.kt文件,它会在编辑器窗口中打开,尽管我们也可以直接点击编辑器窗口上方的MainActivity.kt标签。随着我们向项目中添加更多的 Kotlin 文件,知道它们的存放位置将会很有用。

检查MainActivity.kt文件,你会发现它是第一个项目中我们使用的 Kotlin 文件的简化版本。它是一样的,只是在onCreate函数中有更少的函数和更少的代码。函数缺失是因为 UI 更简单,不需要它们;因此,Android Studio 没有生成它们。

参考一下以下截图中MainActivity.kt文件的内容:

java 文件夹

文件仍然有onCreate函数,在应用程序运行时运行,但代码更少,onCreate是唯一的函数。在onCreate函数的最后一行代码上看一下,我们将在继续探索res文件夹之前讨论这行代码:

setContentView(R.layout.activity_main)

代码调用了一个名为setContentView的函数,并将一些数据传递给setContentView,以便setContentView函数中的代码可以使用。传递给setContentView的数据是R.layout.activity.main

目前,我只是提一下setContentView函数是由 Android API 提供的,它是准备并向用户显示 UI 的函数。那么,R.layout.activity_main到底是什么?

让我们通过探索res文件夹来找出答案。

res 文件夹

res文件夹是所有资源的存放地。左键单击展开res文件夹,我们将检查里面的内容。这是res文件夹内顶层文件夹的截图:

res 文件夹

让我们从列表的顶部开始;也就是说,从drawable文件夹开始。

res/drawable 文件夹

名称有点透露了一些,但drawable文件夹中不仅仅包含图形。随着我们在本书中的进展,我们确实会向这个文件夹中添加图形;但是,目前它只包含两个文件。

这些文件是ic_launcher_foregroundic_launcher_background。我们不会检查这些文件,因为我们永远不需要修改它们,但我会快速提一下它们是什么。

如果你打开这些文件,你会发现它们非常长且技术性很强。它们包括坐标、颜色等列表。它们被称为图形蒙版

它们被 Android 用来适应或蒙版其他图形;在这种情况下,是应用程序的启动器图标。这些文件是给 Android 的指令,告诉它如何调整应用程序的启动器图标。

这个系统是为了让不同的设备制造商可以创*适合自己 Android 设备的蒙版。这些蒙版默认位于drawable文件夹中(ic_launcher_foregroundic_launcher_background),是默认的自适应蒙版,可以为启动器图标添加视觉上令人愉悦的阴影和深度。

提示

如果自适应图标的概念对你有吸引力,那么你可以参考 Android 开发者网站上关于自适应图标的完整且非常直观的解释。

现在我们已经对drawable有了足够的了解,让我们继续学*layout

res/layout 文件夹

展开layout文件夹,你会看到我们在上一章中编辑过的熟悉的布局文件。这次内容更少,因为我们生成了一个空活动项目。它并不完全为空,因为它仍然包含一个ConstraintLayout布局,包裹着一个显示Hello World!TextView小部件。

确保查看内容-你会发现它看起来如你所料,但这里感兴趣的不是内容。仔细看一下文件的名称(不包括 XML 文件扩展名):activity_main

现在回想一下MainActivity.kt文件中的 Kotlin 代码。以下是设置 UI 的代码行;我已经突出显示了代码的一部分:

setContentView(R.layout.activity_main);

R.layout.activity_main代码确实是对res/layout文件夹中的activity_main文件的引用。这是我们的 Kotlin 代码和 XML 布局/设计之间的连接。

在第一个项目中有一个不同之处;在第一个项目的layout文件夹中,有一个额外的文件。在本章后面,我们将使用相同的模板(基本活动)构*另一个项目,以理解为什么。

在这之前,让我们探索最后两个文件夹及其所有子文件夹,从列表中的下一个mipmap开始。

res/mipmap 文件夹

mipmap文件夹很简单 - 相当简单。展开文件夹,查看其内容,如下截图所示:

res/mipmap 文件夹

在这里,你可以看到两个子文件夹;它们是ic_launcheric_launcher_roundic_launcher的内容包括我们在设备的应用抽屉/主屏幕中看到的常规启动器图标的图形,而ic_launcher_round则包含使用圆形图标的设备的图形,而不是方形图标。双击每个文件夹中的一个.png文件,我在这个截图中将它们并排放置,以帮助我们的讨论:

res/mipmap 文件夹

你可能也想知道为什么每个文件夹中都有五个ic_launcher….png文件。原因是为不同尺寸和分辨率的屏幕提供合适比例的图标是一个良好的做法。使用hdpimdpixhdpixxhdpixxxhdpi资格的图像允许不同的 Android 设备选择最适合用户的图标。

注意

dpi代表每英寸点数hmxhxxhxxxh前缀代表高、中、超高、超超高等。这些被称为限定符,随着你在本书中的学*,你会发现 Android 有很多限定符,这些限定符帮助我们构*适合用户选择的各种不同设备的应用程序。

mipmap文件夹中的最后一个谜团是每个子文件夹中还有一个 XML 文件。打开其中一个,你会看到它们引用了我们在drawable文件夹中看到的ic_launcher_foregroundic_launcher_background文件。这告诉 Android 设备从哪里获取自适应图标的详细信息。这些文件不是必需的,但它们使图标看起来更好,并增加了外观的灵活性。

我们还有一个文件夹及其所有文件要探索,然后我们将最终理解 Android 应用程序的结构。

res/values 文件夹

打开res/values文件夹,可以看到三个文件,我们将依次简要讨论。所有这些文件相互关联,并引用了我们已经看过的其他文件。

为了完整起见,这里是res/values文件夹中三个文件的截图:

res/values 文件夹

理解的关键不在于记住连接,当然也不在于试图记住或理解文件中的代码,而是要欣赏到目前为止我们所看到的所有文件和代码之间相互关联的本质。

让我们逐个查看文件的内容。

colors.xml 文件

接下来,看一下colors.xml文件的内容:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
</resources>

请注意,起始和结束标签采用了我们从 XML 文件中期望的通常模式。作为资源的子元素,有三对<color> … </color>标签。

每个color标签中都包含一个name属性和一些由数字和字母组成的奇怪代码。name属性是颜色的名称。我们将在接下来的另一个文件中看到,这个文件中的各种名称是从另一个文件中引用的。

代码是定义实际颜色的内容。因此,当引用名称时,屏幕上显示的是相关代码定义的颜色。

注意

该代码被称为十六进制代码,因为在代码的每个位置上,可以使用值09和字母af,共 16 个可能的值。如果您想了解更多关于十六进制颜色的信息,请访问www.color-hex.com/color-wheel/。如果您对十六进制(基数 16)、二进制(基数 2)和其他数字基数感到好奇,请查看这篇文章,该文章解释了它们,并讨论了为什么人类通常使用十进制:betterexplained.com/articles/numbers-and-bases/

我们将在稍后看到这些名称是如何被引用的。

strings.xml 文件

大多数现代应用程序都是为尽可能广泛的受众而制作的。此外,如果应用程序规模或复杂度较大,则软件公司中的角色通常被划分为许多不同的团队。例如,为 Android 应用程序编写 Kotlin 代码的人很可能与设计 UI 布局的人几乎没有关系。

通过将应用程序的内容与应用程序的编程分开,可以更容易地随时进行更改,并且还可以为多种不同的语言创*内容,而无需为每种语言修改 Kotlin 代码。

看一下strings.xml文件的以下内容:

<resources>
    <string name="app_name">Empty Activity App</string>
</resources>

您可以看到,在现在熟悉的<resources>…</resources>标签内,有一个<string>…</string>标签。在string标签内,有一个名为name的属性,其值为app_name,然后是Empty Activity App的进一步值。

让我们再看一下我们之前在清单文件夹部分探讨过的AndroidManifest.xml文件中的一行。所讨论的行显示在以下代码中,但如果您想要查看完整的上下文中的行,请参考 Android Studio 中的文件本身:

android:label="@string/app_name"

android:label属性被赋予了@string/app_name的值。在 Android 中,@string指的是strings.xml文件中的所有字符串。在这个特定的应用程序中,具有app_name标签的string属性具有Empty Activity App的值。

因此,先前在AndroidManifest.xml文件中显示的代码行在应用程序运行时对屏幕产生以下影响:

strings.xml 文件

虽然这个系统起初可能看起来很复杂,但在实践中,它将设计和内容与编码分离开来,这样做非常高效。如果设计人员想要更改应用程序的名称,他们只需编辑strings.xml文件。无需与 Kotlin 程序员互动,而且,如果应用程序中的所有文本都以字符串资源的形式提供,那么在项目进行过程中所有这些文本都可以轻松地进行更改和调整。

Android 通过允许开发人员为每种语言和区域设置使用不同的文件来存储字符串资源,进一步提高了灵活性。这意味着开发人员可以使用完全相同的 Kotlin 代码来满足整个星球上的快乐用户。Kotlin 程序员只需引用字符串资源的name属性,而不是将文本本身硬编码,然后其他部门可以设计文本内容并处理诸如翻译之类的任务。我们将在第十八章本地化中使应用程序支持多种语言。

注意

有可能直接在 Kotlin 代码中硬编码实际文本,而不是使用字符串资源,大多数情况下,我们会这样做,以便轻松演示一些 Kotlin 代码,而不必陷入编辑或添加到strings.xml文件中。

我们已经了解了关于strings.xml的足够信息,可以继续探索空项目模板的最终文件。

styles.xml 文件

在这里,您可以看到这个项目模板的互连拼图的各个部分最终汇聚在一起。研究styles.xml文件中的代码,然后我们可以讨论它:

<resources>
<!-- Base application theme. -->
<style name="AppTheme" 
parent="Theme.AppCompat.Light.DarkActionBar">
   <!-- Customize your theme here. -->
   <item name="colorPrimary">@color/colorPrimary</item>
   <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
   <item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

这是另一个资源文件,但它是在引用我们之前看到的colors.xml文件。请注意,这里有一个style标签,它包含多个item标签;每个item标签都有一个名称,比如colorPrimarycolorPrimaryDarkcolorAccent。然后,每个名称都被赋予一个值,比如@color/colorPrimary

你可能想知道发生了什么;@color指的是colors.xml文件,colorPrimarycolorPrimaryDarkcolorAccent指的是在该文件中用十六进制值定义的实际颜色。但为什么要创*颜色并给它们命名,然后在另一个文件中定义item实例并将这些颜色分配给item实例呢?为什么不直接将十六进制颜色值分配给每个item呢?

看一下代码块的顶部,了解这种看似不必要的复杂性背后的原因。我再次展示了相关的代码行,这样我们可以更容易地讨论它们:

<style name="AppTheme" 
parent="Theme.AppCompat.Light.DarkActionBar">

正在进行的是已经定义了项目,并且这些项目包含在style元素中。正如你所看到的,样式被称为AppTheme。此外,该样式有一个名为Theme.AppCompat.Light.DarkActionBar的父级。

该系统允许设计师选择一系列颜色,然后在colors.xml文件中定义它们。然后他们可以进一步构*使用这些颜色的不同组合的样式 - 通常每个应用程序会有多个样式。样式还可以与主题(parent = "...")相关联。这个父主题可以是完全由应用设计师的样式和颜色设计的,也可以是 Android 的默认主题之一,比如Theme.AppCompat.Light.DarkActionBar

UI 设计师可以简单地在AndroidManifest.xml文件中引用样式,就像这一行:

android:theme="@style/AppTheme"

UI 设计师可以愉快地调整颜色和它们的使用方式(项目),而不会干扰 Kotlin 代码。这也允许为世界不同地区创*不同的样式,而不需要对实际布局文件(在本例中为activity_main.xml)进行任何更改。

例如,在西方文化中,绿色可以代表自然和正确性等主题;在许多中东国家,绿色代表生育,是与伊斯兰教相关的颜色。虽然你可能会在这两个地区都使用绿色,但你的应用将被认为是非常不同的。

如果你在印度尼西亚推出你的应用,你会发现绿色在许多(尽管不是所有)印尼人中是受到文化鄙视的。接下来,如果你在中国推出,你会发现绿色可能会带有与不忠的配偶有关的负面含义。这是典型程序员永远不会学会应对的困难。而且,幸运的是,由于我们可以在 Android Studio 中分配责任的方式,他们不需要学会。

因此,颜色、样式和主题是非常专业的主题。虽然我们不会深入探讨比绿色更深入的内容,但希望你能看到一个分离了编程、布局、颜色和文本内容责任的系统的好处。

提示

我认为在这一点上值得一提的是,图片也可以根据不同的区域划分,以便在同一个应用程序中,不同地区的用户看到不同的图片。而且,如果你在想,是的,这将意味着为每个区域提供不同的分辨率(比如hdpixhdpi等)。

值得一提的是,完全有可能制作出一款受到成千上万甚至数百万用户喜爱的应用,而不需要为每个地区单独定制。然而,即使我们不打算雇佣设计师、翻译人员和文化专家,我们仍然必须在这个旨在使他们能够工作的系统中工作,这就是为什么我们要深入探讨。

在这个阶段,我们已经很好地掌握了 Android 项目中的内容以及它们之间的联系。现在让我们构*另一个应用程序,以查看不同应用程序模板对 Android Studio 生成的基础文件的影响。

基本活动项目

下一个最简单的项目类型是自动生成 UI 的基本活动项目。这是我们在第一章中创*的相同类型的项目,开始使用 Android 和 Kotlin。现在可以打开该项目,但*议生成一个新项目,以便我们可以在没有任何修改和添加干扰讨论的情况下进行检查。

让我们创*一个基本活动项目,如下所示:

  1. 在 Android Studio 中,选择文件 | 新* | 新项目…

  2. 选择您的项目屏幕上,选择基本活动模板,然后点击下一步

  3. 名称字段更改为基本活动应用

  4. 选择与上一个项目相同的包名称,并将位置保存为之前的项目。

  5. 确保选择Kotlin作为语言。

  6. 像之前一样,勾选使用 AndroidX 构件复选框。

  7. 其余设置可以保持默认,所以只需点击下一步

现在我们可以深入研究文件。我们不会像我们在空活动项目中那样详细地查看所有内容;我们只会查看差异和额外的部分。

探索基本活动项目

让我们首先比较 Kotlin 代码。查看代码编辑器中的MainActivity.kt选项卡。它们都包含一个名为MainActivity的类。不同之处在于函数的数量和onCreate函数的内容。

如前所述,基本活动项目比空活动项目更复杂。

提示

您可以打开尽可能多的 Android Studio 实例。如果要并排比较项目,请选择文件 | 打开,然后选择项目,然后在提示时选择新*窗口,以打开项目而不关闭已经打开的任何项目。

第一个不同之处在于onCreate函数中有一些额外的代码。

MainActivity.kt 文件

我在第二章中非常简要地提到了存在于 Kotlin 代码和 XML 代码中的相互关系。让我们浏览资源文件,并指出这段 Kotlin 代码指向的 XML 文件。

这是onCreate函数中相关的 Kotlin 代码;我稍微重新格式化了它,以便在书中更易读:

setSupportActionBar(toolbar)

fab.setOnClickListener { view ->
   Snackbar.make(view, "Replace with your own action",
               Snackbar.LENGTH_LONG)
               .setAction("Action", null).show()
}

完全理解这段代码需要更多的章节,但只需指出这段代码使用资源文件的地方只需要一会儿,然后我们就会更加了解构成我们项目的组件。

与空活动项目相比,该代码引用了两个更多的资源。第一个是工具栏,第二个是浮动操作按钮,两者都引用了我们将在下一步中看到的 XML 文件。

如果您在项目窗口中打开res/layout文件夹,您会发现它们看起来与空活动项目中的情况有些不同:

MainActivity.kt 文件

现在有两个自动生成的文件。我们将探索content_main.xml文件,并很快理解为什么需要它。

activity_main.xml 文件

现在,打开activity_main.xml文件,您会看到一些元素代表工具栏浮动操作按钮。通过引用这些元素,Kotlin 代码正在设置工具栏和浮动操作按钮以供使用。正如我们所期望的那样,XML 代码描述了它们的外观。

这是工具栏的 XML 代码:

<androidx.appcompat.widget.Toolbar
   android:id="@+id/toolbar"
   android:layout_width="match_parent"
   android:layout_height="?attr/actionBarSize"
   android:background="?attr/colorPrimary"
   app:popupTheme="@style/AppTheme.PopupOverlay" />

请注意,它引用了Toolbar、颜色和样式,以及其他一些内容。它是以android:id…开头的那一行,它声明了一个类型为Toolbar的小部件及其@+id/toolbar值,这使得它可以通过 Kotlin 代码中的toolbar实例名称访问。

为了清晰起见,这是实际工作中应用中的工具栏:

activity_main.xml 文件

这是浮动操作按钮的 XML 代码。我稍微重新格式化了代码的第一行为两行:

<com.google.android.material.floatingactionbutton.
         FloatingActionButton

   android:id="@+id/fab"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_gravity="bottom|end"
   android:layout_margin="@dimen/fab_margin"
   app:srcCompat="@android:drawable/ic_dialog_email" />

请注意,它具有fabid属性。通过这个id属性,我们可以在我们的 Kotlin 代码中访问浮动操作按钮。

现在,我们的 Kotlin 代码中的fab可以直接控制浮动操作按钮及其所有属性。在第十三章让 Android 小部件活起来中,我们将详细学*如何做到这一点。

这是实际应用中的浮动操作按钮:

activity_main.xml 文件

很明显,我还没有详细解释代码;在这个阶段没有意义。相反,要注意这些相互关联,如下所示:

  • XML 文件可以引用其他 XML 文件。

  • Kotlin 可以引用 XML 文件(以及,正如我们将很快看到的,其他 Kotlin 文件)。

  • 在 Kotlin 中,我们可以通过其id属性控制 XML 文件中的特定部分。

我们已经从这个文件中看到足够了;让我们继续并深入了解剩下的文件。

MainActivity.kt 中的额外函数

那么,这些函数是做什么的,它们何时被调用,以及谁确切地调用它们呢?

下一个不同之处是这个额外的函数,如下所示:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
  // Inflate the menu; this adds items to 
  // the action bar if it is present.
  menuInflater.inflate(R.menu.menu_main, menu)
  return true
}

这段代码准备(膨胀)了在menu_main.xml文件中定义的菜单。和onCreate一样,这个函数也被重写,并且直接由操作系统调用。

然后还有另一个函数,如下所示:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
  // Handle action bar item clicks here. The action bar will
  // automatically handle clicks on the Home/Up button, so long
  // as you specify a parent activity in AndroidManifest.xml.
  return when (item.itemId) {
        R.id.action_settings -> true
        else -> super.onOptionsItemSelected(item)
  }
}

这个函数也被重写,并且直接由操作系统调用。它处理用户选择菜单中的项目(或选项)时发生的情况。目前,它只处理一个选项,即设置选项,目前不执行任何操作。

前面的代码确定了是否单击了设置菜单选项;如果是,那么return代码执行,控制返回到被用户单击设置菜单选项中断之前执行的应用程序的任何部分。我们将在第八章Kotlin 决策和循环中更多地了解 Kotlin 的when关键字。

我们现在几乎知道足够了;不要担心记住所有这些连接。我们将回到每个连接,深入调查,并巩固我们对每个连接的理解。

那么,为什么我们需要res/layout文件夹中的第二个文件呢?

content_main.xml 文件

MainActivity.kt文件在R.layout.activity_main上调用了setContentView。然后,activity_main有这行代码被突出显示:

…
</com.google.android.material.appbar.AppBarLayout>

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

<com.google.android.material.floatingactionbutton
  .FloatingActionButton
…

代码的高亮行确实includecontent_main文件。因此,在应用栏添加到布局后,执行分支到content_main,在那里,所有的 XML 代码都转换为 UI;然后,执行返回到activity_main,并且浮动操作栏添加到布局中。在第五章使用 CardView 和 ScrollView 创*美丽的布局中,我们将使用include,当我们构*一些整洁的滚动CardView布局并将定义CardView的代码与CardView的实际内容分开时。

探索 Android 模拟器

随着我们的进展,熟悉如何使用 Android 模拟器确实有所帮助。如果您还没有使用过最新版本的 Android,甚至执行简单任务(如查看所有应用程序)的方式可能与您当前的设备工作方式不同。此外,我们还想知道如何使用所有模拟器附带的额外控件。

模拟器控制面板

您可能注意到了当您运行模拟器时,旁边会出现一个迷你控制面板。让我们看一下一些最有用的控件。看一下这个模拟器控制面板的截图。我已经做了标注以帮助讨论:

模拟器控制面板

我只会提到一些更明显的控件,并在必要时深入一些:

  1. 这些是窗口控件。它们最小化或关闭模拟器窗口。

  2. 从上到下,第一个按钮用于关闭模拟器,模拟关闭实际设备的电源。接下来的两个图标分别是调高和调低音量。

  3. 这两个按钮允许您将模拟器向左和向右旋转。这意味着您可以测试您的应用在所有方向上的外观,以及应用在运行时如何处理方向变化。这两个按钮下面的图标分别是截图和放大。这是模拟器在水*旋转后的样子:模拟器控制面板

  4. 这些图标模拟返回按钮、主页按钮和查看运行中应用程序按钮。尝试一下这些按钮-我们有时需要使用它们,包括在第六章中,Android 生命周期

  5. 按照标注图像中标有5的按钮,启动高级设置菜单,您可以与传感器、GPS、电池和指纹识别器等进行交互。如果您感兴趣,可以尝试一些这些设置:模拟器控制面板

让我们玩一下模拟器本身。

使用模拟器作为真实设备

模拟器可以模拟真实手机的每个功能,因此可以单独撰写一本关于它的书。如果您想编写用户喜爱的应用程序,那么了解各种 Android 设备是值得花时间去做的。我只想在这里指出一些最基本的功能,因为没有这些基本交互,将很难跟上本书的内容。此外,如果您有一部旧的 Android 设备,那么一些基本的操作(如访问应用抽屉)已经发生了变化,您可能会感到有些困惑。

访问应用抽屉

将鼠标光标放在主屏幕底部并向上拖动,以访问应用抽屉(包括所有应用程序);以下截图显示了这个动作进行到一半的情况:

访问应用抽屉

现在您可以运行模拟器上安装的任何应用。请注意,当您通过 Android Studio 运行您的应用程序之一时,它将保留在模拟器上安装,并且可以从应用抽屉中运行。但是,您在 Android Studio 中对应用程序所做的每一次更改都需要您再次运行或安装应用程序,方法是单击 Android Studio 快速启动栏上的播放按钮,就像我们一直在做的那样。

查看活动应用程序和在应用程序之间切换

要查看活动应用程序,您可以使用模拟器控制面板,也就是截图上标有数字4的方块。要使用手机屏幕访问相同的选项(就像您在真实设备上所做的那样),向上滑动,就像访问应用抽屉一样,但只需滑动屏幕长度的四分之一,如下截图所示:

查看活动应用程序和在应用程序之间切换

现在您可以通过最近的应用程序向左或向右滑动,向上滑动应用程序以关闭它,或者点击返回按钮返回到您在查看此选项之前所做的事情。请尝试一下,因为我们在本书中经常会使用这些基本功能。

摘要

请记住,本章的目标是熟悉 Android 系统和 Android 项目的结构。Android 项目是 Kotlin 和大量资源文件的复杂交织。资源文件可以包含 XML 来描述我们的布局、文本内容、样式和颜色,以及图像。资源可以针对世界各地的不同语言和地区进行生产。我们将在整本书中看到并使用的其他资源类型包括主题和音效。

记住不同资源文件和 Kotlin 文件相互关联的不同方式并不重要。重要的是意识到它们是相互关联的,并且能够检查各种类型的文件,并意识到它们何时依赖于另一个文件中的代码。每当我们从 Kotlin 代码创*连接到 XML 代码时,我都会再次指出连接的细节。

我们不需要额外学* XML,而是会在接下来的 25 章中对其有一些了解。Kotlin 将是本书的重点,但我们的 Kotlin 代码将经常涉及 XML 代码,因此理解并看到一些相互关联的示例将使您更快地取得进展。

我们还探索了模拟器,以便在测试我们的应用程序时充分利用它。

在下一章中,我们将使用三种不同的 Android 布局方案构*三个自定义布局。我们还将编写一些 Kotlin 代码,以便我们可以通过点击按钮在它们之间进行切换。

第四章:开始使用布局和材料设计

我们已经看到了安卓工作室的 UI 设计师,以及 Kotlin 的一些实际应用。在这个动手实践的章节中,我们将构*三个更多的布局-仍然相当简单,但比我们迄今为止所做的更进一步。

在我们开始动手之前,我们将快速介绍材料设计的概念。

我们将看看另一种布局类型,称为LinearLayout,并通过使用它来创*可用的 UI 来详细介绍它。我们将进一步使用ConstraintLayout,既了解约束,又设计更复杂和精确的 UI 设计。最后,我们将介绍TableLayout,以便在易于阅读的表格中布置数据。

我们还将编写一些 Kotlin 代码,以在一个应用程序/项目中在不同的布局之间进行切换。这是第一个将多个主题整合到一个整洁包裹中的重要应用程序。该应用程序名为“探索布局”。

在本章中,我们将涵盖以下主题:

  • 材料设计

  • 构*LinearLayout并学*何时最好使用此类型

  • 构*另一个稍微更高级的ConstraintLayout,并了解更多关于使用约束的信息

  • 构*TableLayout并填充数据以显示

  • 将所有内容链接在一个名为“探索布局”的单个应用程序中

首先是材料设计。

材料设计

你可能听说过材料设计,但它究竟是什么?材料设计的目标很简单,就是实现美观的用户界面。然而,它也是为了使这些用户界面在安卓设备上保持一致。材料设计并不是一个新的想法。它直接采用了纸和笔设计中使用的设计原则,比如具有视觉上令人愉悦的装饰,如阴影和深度。

材料设计使用材料层的概念,您可以将其视为照片编辑应用程序中的图层。一套原则、规则和指南实现了一致性。必须强调材料设计完全是可选的,但也必须强调材料设计是有效的,如果您不遵循它,用户很可能不喜欢您的设计。毕竟,用户已经*惯了某种类型的 UI,而该 UI 很可能是使用材料设计原则创*的。

因此,材料设计是一个值得努力的合理标准,但在学*材料设计的细节时,我们不应该让它阻碍我们学*如何开始使用安卓。

本书将专注于完成任务,同时偶尔指出材料设计如何影响我们的做法,并指向更深入了解材料设计的进一步资源。

探索安卓 UI 设计

我们将看到在安卓 UI 设计中,我们学到的很多东西都是依赖上下文的。给定小部件的 x 属性如何影响其外观可能取决于小部件的 y 属性,甚至取决于另一个小部件的属性。这并不容易直接学*。最好期望通过实践逐渐取得更好和更快的结果。

例如,如果您通过将小部件拖放到设计中来玩转设计师,生成的 XML 代码将根据您使用的布局类型而有很大不同。随着我们在本章中的进行,我们将看到这一点。

这是因为不同的布局类型使用不同的方法来决定其子元素的位置。例如,我们将在下一节中探索的LinearLayout与我们项目中默认添加的ConstraintLayout的工作方式完全不同,第一章中已经介绍了开始使用安卓和 Kotlin

这些信息可能起初看起来像是一个问题,甚至是一个坏主意,当然可能有点尴尬。然而,我们将开始学*的是,这种清晰的布局选项的丰富性及其各自的怪癖是一件好事,因为它们为我们提供了几乎无限的设计潜力。您几乎可以想象不可能实现的布局很少。

然而,正如所暗示的,这种几乎无限的潜力伴随着一些复杂性。开始掌握这一点的最佳方法是构*几种类型的工作示例。在本章中,我们将看到三种 - LinearLayoutConstraintLayoutTableLayout。我们将看到如何使用可视化设计师的独特功能使事情变得更容易,并且我们还将对自动生成的 XML 进行一些关注,以使我们的理解更全面。

布局

我们已经看到了ConstraintLayout,但还有更多。布局是将其他 UI 元素/小部件组合在一起的构*块。布局本身可以包含其他布局。

让我们看一些在 Android 中常用的布局,因为了解不同的布局及其优缺点将使我们更加了解可以实现什么,因此将扩展我们对可能性的认识。

我们已经看到,一旦我们设计了一个布局,我们就可以在 Kotlin 代码中使用setContentView函数将其付诸实践。

让我们使用不同的布局类型构*三种设计,然后将setContentView付诸实践并在它们之间切换。

创*“探索布局”项目

在 Android 中最困难的事情之一不仅是找出如何做某事,而是在其他事物中找出如何做某事。这就是为什么在本书中,除了向您展示如何做一些很酷的东西之外,我们还将把许多主题链接到跨越多个主题和章节的应用程序中。探索布局项目是这种类型的第一个应用程序。我们将学*如何构*多种类型的布局,同时将它们全部链接在一个方便的应用程序中:

  1. 在 Android Studio 中创*一个新项目。如果您已经打开了一个项目,请选择文件 | 新*项目。在提示时,选择在同一窗口中打开,因为我们不需要参考我们之前的项目。

提示

如果您在 Android Studio 的启动屏幕上,只需点击开始新的 Android Studio 项目选项即可创*一个新项目。

  1. 选择空活动项目模板,因为我们将从头开始构*大部分 UI。点击下一步按钮。

  2. 为项目命名为“探索布局”。

  3. 其余所有设置与我们之前使用的三个项目相同。

  4. 点击完成按钮。

查看MainActivity.kt文件。以下是整个代码,不包括import…语句:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

找到setContentView的调用并删除整行。该行在上一个代码中显示为高亮显示。

这正是我们想要的,因为现在我们可以构*自己的布局,探索底层的 XML,并编写自己的 Kotlin 代码来显示这些布局。如果您现在运行该应用程序,您将只获得一个带有标题的空白屏幕;甚至没有“Hello World!”消息。

我们将要探索的第一种布局类型是LinearLayout

使用 LinearLayout 构*菜单

LinearLayout可能是 Android 提供的最简单的布局。顾名思义,其中的所有 UI 项都是线性布局的。您只有两个选择 - 垂直和水*。通过添加以下代码行(或通过属性窗口进行编辑),您可以配置LinearLayout以垂直布局:

android:orientation="vertical"

然后(您可能已经猜到了)将"vertical"更改为"horizontal"以水*布局。

在我们可以对LinearLayout执行任何操作之前,我们需要将其添加到布局文件中。而且,由于我们在此项目中构*了三个布局,因此我们还需要一个新的布局文件。

向项目添加 LinearLayout

在项目窗口中,展开res文件夹。现在右键单击layout文件夹,然后选择New。注意到有一个Layout resource file选项,如下截图所示:

将 LinearLayout 添加到项目

选择Layout resource file,然后会看到New Resource File对话框窗口:

将 LinearLayout 添加到项目

File name字段中输入main_menu。名称是任意的,但这个布局将成为我们的“主”菜单,用于选择其他布局,所以这个名称似乎合适。

请注意,它已经选择了LinearLayout作为Root element选项。

单击OK按钮,Android Studio 将在名为main_menu的 XML 文件中生成一个新的LinearLayout,并将其放置在layout文件夹中,准备好构*我们的新主菜单 UI。Android Studio 还将打开带有左侧调色板和右侧属性窗口的 UI 设计器。

准备工作区

通过拖动和调整窗口边界的大小(就像大多数窗口化应用程序一样),调整窗口的大小,使调色板、设计和属性尽可能清晰,但不要超出必要的范围。这个小截图显示了我选择的大致窗口比例,以使设计我们的 UI 和探索 XML 尽可能清晰。截图中的细节并不重要:

准备工作区

请注意,我已经尽可能地缩小了项目、调色板和属性窗口,但没有遮挡任何内容。我还关闭了屏幕底部的构*/logcat 窗口,结果是我有一个很清晰的画布来构* UI。

检查生成的 XML

单击Text选项卡,我们将查看当前阶段形成我们设计的 XML 代码的当前状态。以下是代码,以便我们进一步讨论:

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

 android:orientation="vertical" 
 android:layout_width="match_parent"
 android:layout_height="match_parent">

</LinearLayout>

我们有通常的起始和结束标签,正如我们可以预测的那样,它们是<LinearLayout</LinearLayout>。目前还没有子元素,但有三个属性。我们知道它们是LinearLayout的属性,而不是子元素,因为它们出现在第一个闭合>之前。为了清晰起见,前面的代码中突出显示了定义这个LinearLayout的三个属性。

第一个属性是android:orientation,或者更简洁地说,我们将只是提到没有android:部分的属性。orientation属性的值是vertical。这意味着,当我们开始向这个布局添加项目时,它将垂直地从上到下排列它们。我们可以将值从vertical更改为horizontal,它将从左到右布局。

接下来的两个属性是layout_widthlayout_height。这些属性确定了LinearLayout的大小。给定给这两个属性的值都是match_parent。布局的父级是整个可用空间。因此,通过水*和垂直匹配父级,布局将填充整个可用空间。

向 UI 添加一个 TextView

切换回Design选项卡,我们将向 UI 添加一些元素。

首先,在调色板中找到TextView。这可以在CommonText类别中找到。左键单击并将TextView拖放到 UI 上,注意它整齐地位于LinearLayout的顶部。

查看Text选项卡上的 XML,并确认它是LinearLayout的子元素,并且缩进了一个制表符以清晰地表示这一点。以下是TextView的代码,没有周围的LinearLayout代码:

<TextView
   android:id="@+id/textView"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:text="TextView" />

请注意,它有四个属性:id,以防我们需要从另一个 UI 元素或我们的 Kotlin 代码中引用它;layout_width设置为match_parent,这意味着TextView横跨整个LinearLayout的宽度;layout_height属性设置为wrap_content,这意味着TextView的高度恰好足够容纳其中的文本;最后,目前,它具有一个text元素,用于确定它将显示的实际文本,目前仅设置为TextView

切换回设计选项卡,我们将进行一些更改。

我们希望这段文本成为此屏幕的标题文本,即主菜单屏幕。在属性窗口中,单击搜索图标,输入text到搜索框中,并将text属性更改为Menu,如下截图所示:

将 TextView 添加到 UI

提示

您可以通过搜索或滚动选项来查找任何属性。找到要编辑的属性后,左键单击选择它,然后按键盘上的Enter键使其可编辑。

接下来,使用您喜欢的搜索技术找到textSize属性,并将textSize设置为50sp。输入新值后,文本大小将增加。

sp代表可伸缩像素。这意味着当用户在其 Android 设备上更改字体大小设置时,字体将动态重新调整大小。

现在,搜索gravity属性,并通过单击以下截图中指示的小箭头展开选项:

将 TextView 添加到 UI

gravity设置为center_horizontal,如下截图所示:

将 TextView 添加到 UI

gravity属性指的是TextView本身的重力,我们的更改会使TextView内的实际文本移动到中心。

提示

请注意,gravitylayout_gravity是不同的。layout_gravity属性设置了布局内的重力:在这种情况下,是父LinearLayout。我们将在项目的后续部分使用layout_gravity

此时,我们已更改了TextView的文本,增加了其大小,并使其水*居中。UI 设计师现在应该如下图所示:

将 TextView 添加到 UI

快速浏览Text选项卡,查看 XML 代码,会发现以下代码:

<TextView
   android:id="@+id/textView"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:gravity="center_horizontal"
   android:text="Menu"
   android:textSize="50sp" />

您可以看到新的属性如下:gravity设置为center_horizontal;文本更改为MenutextSize设置为50sp

如果运行应用程序,可能看不到预期的效果。这是因为我们在 Kotlin 代码中没有调用setContentView来加载 UI。您仍然会看到空白的 UI。我们将在 UI 有了更多进展后解决这个问题。

将多行 TextView 添加到 UI

切换回Design选项卡,在调色板的Text类别中找到Multiline Text,并将其拖放到刚刚添加的TextView下方的设计中。

使用您喜欢的搜索技术,将text设置为选择布局类型以查看示例。每个按钮的 onClick 属性将调用一个函数,该函数执行 setContentView 以加载新布局

您的布局现在将如下截图所示:

将多行 TextView 添加到 UI

您的 XML 将在TextView之后的LinearLayout中更新为另一个子元素,代码如下:

<EditText
   android:id="@+id/editText"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:ems="10"
   android:inputType="textMultiLine"
   android:text="Select a layout type to view an example. 
         The onClick attribute of each button will call a function 
         which executes setContentView to load the new layout" />

您可以查看 UI 项的详细信息,结果发现多行文本的调色板上的描述并不明显,究竟是什么。查看 XML 后,我们发现有一个inputType属性,表示用户可以编辑此文本。还有另一个我们以前没有见过的属性,那就是emsems属性控制每行可以输入多少个字符,而10的值是 Android Studio 自动选择的。然而,另一个属性layout_width="match_parent"覆盖了这个值,因为它使元素扩展以适应其父元素;换句话说,覆盖整个屏幕的宽度。

当您运行应用程序(在下一节中),您将看到文本确实是可编辑的-尽管对于这个演示应用程序的目的来说,它没有实际用途。

用 Kotlin 代码连接 UI(第一部分)

为了实现一个交互式的应用程序,我们将做以下三件事:

  1. 我们将从onCreate函数中调用setContentView来显示我们运行应用程序时的 UI 进度。

  2. 我们将编写另外两个我们自己的函数,每个函数将在不同的布局上调用setContentView(我们还没有设计)。

  3. 然后,在本章后面,当我们设计另外两个 UI 布局时,我们将能够在点击按钮时加载它们。

因为我们将构*一个ConstraintLayout和一个TableLayout,所以我们将分别调用我们的新函数loadConstraintLayoutloadTableLayout

现在让我们这样做,然后我们将看到如何添加一些按钮,调用这些函数以及一些整齐格式的文本。

onCreate函数中,添加以下突出显示的代码:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

 setContentView(R.layout.main_menu)
}

该代码使用setContentView函数来加载我们当前正在工作的 UI。现在您可以运行应用程序,看到以下结果:

用 Kotlin 代码连接 UI(第一部分)

MainActivity类的onCreate函数之后,添加这两个新函数:

fun loadConstraintLayout(v: View) {
  setContentView(R.layout.activity_main)
}

fun loadTableLayout(v: View) {
  setContentView(R.layout.my_table_layout)
}

第一个函数有一个错误,第二个函数有两个错误。第一个我们可以通过添加一个import语句来修复,以便 Android Studio 意识到View类。左键单击View单词选择错误。按住Alt键,然后点击Enter键。您将看到以下弹出窗口:

用 Kotlin 代码连接 UI(第一部分)

选择导入类。错误现在已经消失。如果您滚动到代码的顶部,您将看到刚才执行的快捷方式添加了一行新代码。以下是新代码:

import android.view.View

Android Studio 不再将View类视为错误。

然而,第二个函数仍然有一个错误。问题在于该函数调用setContentView函数来加载一个新的 UI(R.layout.my_table_layout)。由于此 UI 布局尚不存在,因此会产生错误。您可以注释掉此调用以消除错误,直到我们在本章后面创*文件并设计 UI 布局。添加双斜杠(//),如下面的代码中所突出显示的那样:

fun loadTableLayout(v: View) {
  //setContentView(R.layout.my_table_layout)
}

现在我们可以添加一些按钮,我们可以点击这些按钮来调用我们的新函数,并加载我们即将构*的新布局。但是,添加一对带有一些文本的按钮太容易了-我们以前已经做过了。我们想要做的是将一些文本与按钮对齐,使其位于文本的右侧。问题在于我们的LinearLayoutorientation属性设置为vertical,正如我们所见,我们添加到布局中的所有新部分都将垂直排列。

在布局中添加布局

将一些元素以不同方向布局的解决方案是在布局中嵌套布局。以下是如何做到的。

从调色板的布局类别中,将LinearLayout(水*)拖放到设计中,将其放置在多行文本的下方。注意,有一个蓝色边框占据了多行文本下方的所有空间:

在布局中添加布局

这表明我们的新LinearLayout(水*)正在填充空间。请记住这个蓝色边框区域,因为我们将在 UI 上放置下一个项目。

现在,返回到调色板的Text类别,并将一个TextView拖放到我们刚刚添加的新LinearLayout中。请注意,TextView紧密地位于新LinearLayout的左上角:

在布局中添加布局

起初,这似乎与我们 UI 中一开始的垂直LinearLayout发生的情况没有什么不同。但是当我们添加 UI 的下一个部分时,看看会发生什么。

注意

用于指代在布局中添加布局的术语是嵌套。应用于 UI 上的任何项目(例如按钮和文本)的 Android 术语是视图,而包含视图的任何内容都是视图组。由于视图视图组这两个术语在某些情境下并不总是清晰表达其含义,我通常会具体指称 UI 的部分(如TextViewButtonLinearLayout)或更广泛地(UI 元素、项目或小部件)。

Button类别中,将一个Button拖放到先前TextView的右侧。请注意,按钮位于文本的右侧,如下图所示:

在布局中添加布局

接下来,通过单击其空白部分选择LinearLayout(水*的)。找到layout_height属性并将其设置为wrap_content。注意,LinearLayout现在只占用所需的空间:

在布局中添加布局

在添加 UI 的下一部分之前,让我们配置TextViewButtontext属性。将Buttontext属性更改为LOAD。将我们的新TextView的文本属性更改为Load ConstraintLayout

提示

你自己解决了如何实现之前的指令吗?是的?太棒了!现在你已经熟悉了编辑 Android 视图属性。没有?左键单击要编辑的项目(在本例中为TextView),使用搜索图标搜索或滚动查找要在属性窗口中编辑的属性(在本例中为text属性),选择属性,然后按Enter进行编辑。现在我可以更简洁地说明如何构*未来的 UI 项目,这将使你成为 Android 忍者的旅程更快。

现在我们可以重复自己,在刚刚完成的另一个LinearLayout(水*)中添加另一个TextViewButton属性。要这样做,请按顺序执行以下步骤:

  1. 在前一个下方再添加一个LinearLayout(水*)

  2. 在新的LinearLayout中添加一个TextView

  3. TextViewtext属性更改为Load TableLayout

  4. TextView的右侧添加一个Button

  5. Buttontext属性更改为LOAD

  6. 通过将layout_height属性更改为wrap_content来调整LinearLayout的大小

现在我们有两个整齐(水*)对齐的文本和按钮。

只是为了好玩,也为了更多地探索调色板,找到调色板的小部件类别,并将一个RatingBar拖放到最终LinearLayout的下方。现在,你的 UI 应该看起来与下一个截图非常相似:

在布局中添加布局

注意

在前两个截图中,我还没有更改两个Button元素的text属性。其他所有内容应该与你的一样。

让我们为布局添加一些视觉上的修饰。

使布局看起来漂亮

在本节中,我们将探讨一些控制 UI 细节的更多属性。您可能已经注意到 UI 在某些地方看起来有点挤,而在其他地方看起来不对称。随着我们在书中的进展,我们将不断增加我们的技能来改善我们的布局,但这些简短的步骤将介绍并处理一些基础知识:

  1. 选择多行文本,然后展开Padding属性。将all选项设置为15sp。这样在文本周围留出了整洁的空间。

  2. 为了在多行文本下方留出一个漂亮的空间,找到并展开Layout_Margin属性,将bottom设置为100sp

  3. 在与按钮对齐/相关的两个TextView小部件上,将textSize属性设置为20splayout_gravity设置为center_verticallayout_width设置为match_parentlayout_weight设置为.7

  4. 在两个按钮上,将权重设置为.3。注意现在两个按钮的宽度都是.3,文本占据LinearLayout.7,整体外观更加美观。

  5. RatingBar上,找到Layout_Margin属性,然后将leftright设置为15sp

  6. 仍然使用RatingBarLayout_Margin属性,将top更改为75sp

现在,您可以运行应用程序,看到我们的第一个完整布局的全部荣耀。

使布局看起来漂亮

请注意,您可以玩RatingBar,尽管在关闭应用程序时评分不会保留。

提示

作为读者挑战,找到一个或两个属性,可以进一步改善LoadConstraintLayoutLoadTableLayout文本的外观。它们看起来离屏幕边缘有点近。参考第五章开头的快速摘要部分,使用 CardView 和 ScrollView 创*美丽的布局

不幸的是,按钮目前还没有功能。让我们解决这个问题。

使用 Kotlin 代码连接 UI(第二部分)

选择Load ConstraintLayout文本旁边的按钮。找到onClick属性,将其设置为loadConstraintLayout

选择Load TableLayout文本旁边的按钮。找到onClick属性,将其设置为loadTableLayout

现在,按钮将调用函数,但loadTableLayout函数内的代码已被注释掉以避免错误。随时运行应用程序,看看您是否可以通过单击loadConstraintLayout按钮切换到ConstraintLayout。但它只有一个Hello World消息。

现在我们可以继续构*这个ConstraintLayout

使用 ConstraintLayout 构*精确的 UI

打开创*项目时自动生成的ConstraintLayout。它可能已经在编辑器顶部的选项卡中。如果没有,它将在res/layout文件夹中。它的名称是activity_main.xml

检查Text选项卡中的 XML,并注意它是空的,除了一个说Hello WorldTextView。切换回Design选项卡,左键单击TextView以选择它,然后按Delete键将其删除。

现在我们可以构*一个简单而复杂的 UI。当您想要非常精确地定位 UI 的部分和/或相对于其他部分时,ConstraintLayout非常有用。

添加日历视图

首先,在调色板的Widgets类别中找到CalenderView。将CalenderView拖放到靠近顶部且水*居中的位置。当您拖动CalenderView时,注意它会跳到某些位置。

还要注意视图对齐时的微妙视觉提示。我在以下截图中突出显示了水*中心的视觉提示:

添加日历视图

当它水*居中时,释放它,就像截图中一样。现在,我们将调整它的大小。

在 ConstraintLayout 中调整视图大小

左键单击并按住一个角落的方块,当你放开CalenderView时会显示出来,向内拖动以减小CalenderView的大小:

在 ConstraintLayout 中调整视图大小

将大小减小约一半,将CalenderView留在屏幕顶部,水*居中。在调整大小后,你可能需要重新调整一下位置,就像下面的图示一样:

在 ConstraintLayout 中调整视图大小

你不需要把CalenderView放在和我完全一样的位置。练*的目的是熟悉指示你放置位置的视觉线索,而不是创*一个和我的布局一模一样的副本。

使用组件树窗口

现在看一下组件树窗口 - 就在可视化设计师的左边和调色板下面。组件树是一种可视化 XML 布局的方式,但没有所有的细节。

在下面的截图中,我们可以看到CalenderView向右缩进到ConstraintLayout的右侧,因此是一个子元素。在我们构*的下一个 UI 中,我们将看到我们有时需要利用组件树来构* UI。

目前,我只是想让你观察一下我们的CalenderView旁边有一个警告标志。我在下面的截图中已经用颜色标出来了:

使用组件树窗口

错误提示说此视图没有约束。它只有设计时的位置,因此在运行时会跳转到(0,0),除非你添加约束。还记得我们在第二章中首次将按钮添加到屏幕上时,它们只是简单地消失在左上角吗?

提示

现在运行应用程序,如果你想提醒自己这个问题,点击加载 ConstraintLayout按钮。

现在,我们可以通过点击推断约束按钮来修复这个问题,就像我们在第二章中使用的那样,Kotlin、XML 和 UI 设计师。这里再次提醒一下:

使用组件树窗口

但学会手动添加约束是值得的,因为它为我们提供了更多的选项和灵活性。随着你的布局变得更加复杂,总会有一两个项目不按照你的意愿行事,手动修复几乎总是必要的。

手动添加约束

确保CalenderView被选中,并观察顶部、底部、左侧和右侧的四个小圆圈:

手动添加约束

这些是约束手柄。我们可以点击并拖动它们,将它们锚定到 UI 的其他部分或屏幕的边缘。通过将CalenderView与屏幕的四个边缘锚定,我们可以在应用程序运行时将其锁定到位置。

依次点击并拖动顶部手柄到设计的顶部,右侧手柄到设计的右侧,底部手柄到设计的底部,左侧手柄到设计的左侧。

观察到CalenderView现在被约束在中心。左键单击并拖动CalenderView回到屏幕的上部某个位置,就像下面的图示一样。使用视觉线索(也显示在下面的截图中)确保CalenderView水*居中:

手动添加约束

在这个阶段,你可以运行应用程序,CalenderView将会被定位到前面截图中显示的位置。

让我们向 UI 添加几个项目,并看看如何约束它们。

添加和约束更多的 UI 元素

从调色板的小部件类别中拖动一个ImageView,并将其放置在CalenderView的下方和左侧。当你放置ImageView时,会弹出一个窗口提示你选择一个图像。选择项目 | ic_launcher,然后点击确定

ImageView的左侧和底部约束到 UI 的左侧和底部。现在,您应该处于以下位置:

添加和约束更多的 UI 元素

ImageView在左下角被约束。现在,抓住ImageView上的顶部约束手柄,并将其拖动到CalenderView的底部约束手柄。现在的情况是这样的:

添加和约束更多的 UI 元素

ImageView只在一个侧面水*约束,因此被固定/约束在左侧。它还在垂直方向上被约束,并且在CalenderView和 UI 的底部之间是均匀约束的。

接下来,在ImageView的右侧添加一个TextView。将TextView的右侧约束到 UI 的右侧,并将TextView的左侧约束到ImageView的右侧。将TextView的顶部约束到ImageView的顶部,并将TextView的底部约束到 UI 的底部。现在,您将得到类似以下图表的东西:

添加和约束更多的 UI 元素

注意,Component Tree窗口中关于未约束项的所有警告都消失了。

注意

有关硬编码字符串的警告,因为我们直接向布局添加文本而不是strings.xml文件,并且有关缺少contentDescription属性的警告。contentDescription属性应该用于添加文本描述,以便视觉障碍用户可以在应用中获得图像的口头描述。为了快速推进ConstraintLayout,我们将忽略这两个警告。我们将在第十八章本地化中正确添加字符串资源,并且您可以在 Android 开发者网站的 Android Studio 上阅读有关辅助功能的信息,网址为developer.android.com/studio/intro/accessibility

您可以移动三个 UI 元素并将它们整齐地排列,就像您想要的那样。请注意,当您移动ImageView时,TextView也会随之移动,因为TextView被约束到ImageView。但也请注意,您可以独立移动TextView,并且无论您放置在哪里,这都代表了它相对于ImageView的新约束位置。无论项被约束到什么,其位置始终相对于该项。而且,正如我们所看到的,水*和垂直约束是彼此独立的。我将我的位置放置如下图所示:

添加和约束更多的 UI 元素

提示

ConstraintLayout是最新的布局类型,虽然它比其他布局更复杂,但它是最强大的,也是在用户设备上运行最好的。值得花更多时间查看有关ConstraintLayout的更多教程。特别是在 YouTube 上查看,因为视频是学*调整ConstraintLayout的好方法。我们将在整本书中回到ConstraintLayout,而且您不需要知道比我们已经涵盖的更多内容才能继续前进。

使文本可点击

我们几乎完成了我们的ConstraintLayout。我们只想要将一个链接返回到主菜单屏幕。这是一个很好的机会来演示TextView(以及大多数其他 UI 项)也是可点击的。实际上,可点击的文本在现代 Android 应用程序中可能比传统的按钮更常见。

TextViewtext属性更改为返回菜单。现在,找到onClick属性并输入loadMenuLayout

现在,在MainActivity.kt文件中添加以下函数,就在loadTableLayout函数之后,如下所示:

fun loadTableLayout(v: View) {
  //setContentView(R.layout.my_table_layout)
}

fun loadMenuLayout(v: View) {
 setContentView(R.layout.main_menu)
}

现在,每当用户点击“返回菜单”文本时,loadMenuLayout函数将被调用,setContentView函数将加载main_menu.xml中的布局。

你可以运行应用程序,在主菜单(LinearLayout)和CalenderView小部件(ConstraintLayout)之间来回切换。

让我们为本章构*最终的布局。

使用 TableLayout 布局数据

在项目窗口中,展开res文件夹。现在,右键单击layout文件夹,然后选择新*。注意,有一个布局资源文件的选项。

选择布局资源文件,你会看到新*资源文件对话框窗口。

文件名字段中输入my_table_layout。这与我们在loadTableLayout函数中调用setContentView时使用的名称相同。

注意它已经选择了LinearLayout作为元素选项。删除LinearLayout,并在其位置键入TableLayout

点击确定按钮,Android Studio 将在名为my_table_layout的 XML 文件中生成一个新的TableLayout,并将其放在layout文件夹中,准备为我们构*基于表格的新 UI。Android Studio 还将打开 UI 设计师(如果尚未打开),左侧是调色板,右侧是属性窗口。

现在,取消注释loadTableLayout函数:

fun loadTableLayout(v: View) {
  setContentView(R.layout.my_table_layout)
}

现在,当你运行应用程序时,你可以切换到基于TableLayout的屏幕,尽管目前它是空白的。

向 TableLayout 添加 TableRow

布局类别中将一个TableRow元素拖放到 UI 设计中。注意,这个新的TableRow的外观几乎是看不见的,以至于不值得在书中插入图表。UI 顶部只有一条蓝线。这是因为TableRow已经将自己围绕其内容折叠起来,而目前内容是空的。

我们可以将选择的 UI 元素拖放到这条蓝线上,但这也有点别扭,甚至有点违反直觉。此外,一旦我们在一起有多个TableRow元素,情况就会变得更加困难。解决方案在于组件树窗口,我们在构*ConstraintLayout时简要介绍过。

当视觉设计师无法完成时使用组件树

查看组件树,注意你可以看到TableRow作为TableLayout的子级。我们可以直接将 UI 拖放到组件树中的TableRow上。在组件树中将三个TextView对象拖放到TableRow上,这样就会得到以下布局。我已经用 photoshop 修改了以下截图,以展示组件树和常规 UI 设计师在同一图表中:

当视觉设计师无法完成时使用组件树

现在添加另外两个TableRow对象(从布局类别)。你可以通过组件树窗口或 UI 设计师添加它们。

提示

你需要将它们放在窗口的最左边,否则新的TableRow将成为前一个TableRow的子级。这将使整个表格有点混乱。如果你意外地将TableRow添加为前一个TableRow的子级,你可以选择它,然后点击删除键,使用Ctrl + Z 键组合来撤消,或者将位置错误的TableRow拖到左边(在组件树中)使其成为表格的子级 - 这是应该的。

现在,为每个新的TableRow项目添加三个TextView对象。最简单的方法是通过组件树窗口添加它们。检查你的布局,确保它与以下截图中的一样:

当视觉设计师无法完成时使用组件树

让表格看起来更像是一个真正的数据表,通过改变一些属性。

TableLayout上,将layout_widthlayout_height属性设置为wrap_content。这样就可以去掉多余的单元格。

通过编辑textColor属性将所有外部(沿顶部和左侧)的TextView对象的颜色更改为黑色。您可以通过选择第一个TextView,搜索其color属性,然后在color属性值字段中输入black来实现这一点。然后,您将能够从下拉列表中选择@android:color/black。对每个外部TextView元素都要这样做。

编辑每个TextViewpadding并将all属性更改为10sp

组织表格列

此时似乎我们已经完成了,但是我们需要更好地组织数据。我们的表格,像许多表格一样,将在左上角有一个空白单元格来分隔列和行标题。为了实现这一点,我们需要对所有单元格进行编号。为此,我们需要编辑layout_column属性。

提示

单元格编号从左边开始编号为零。

首先删除左上角的TextView。注意右侧的TextView已经移动到左上角位置。

接下来,在新的左上角TextView中,编辑layout_column属性为1(这将把它分配给第二个单元格,因为第一个是0,我们想要留下第一个为空),然后,对于下一个单元格,编辑layout_column属性为2

对于接下来的两行单元格,将它们的layout_column属性从左到右从0更改为2

如果您想要在编辑后了解此行的确切代码,请参阅以下片段,并记得在Chapter04/LayoutExploration文件夹中查看整个文件的上下文:

<TableRow
   android:layout_width="wrap_content"
   android:layout_height="wrap_content">

   <TextView
         android:id="@+id/textView2"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_column="1"
         android:padding="10sp"
         android:text="India"
         android:textColor="@android:color/black" />

   <TextView
         android:id="@+id/textView1"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_column="2"
         android:padding="10sp"
         android:text="England"
         android:textColor="@android:color/black" />

</TableRow>

尝试完成这个练*,但是如果可能的话,请使用属性窗口。

链接回主菜单

最后,对于这个布局,我们将添加一个按钮,链接回主菜单。通过组件树添加另一个TableRow。将按钮拖放到新的TableRow上。编辑其layout_column属性为1,使其位于行的中间。编辑其text属性为Menu,并编辑其onClick属性以匹配我们已经存在的loadMenuLayout函数。

现在,您可以运行应用程序并在不同的布局之间来回切换。

如果您愿意,您可以通过编辑TextView小部件的所有text属性来向表格添加一些有意义的标题和数据,就像我在下面的截图中所做的那样,显示了在模拟器中运行的TableLayout

链接回主菜单

最后,思考一下一个呈现数据表的应用程序。很可能数据将动态地添加到表中,而不是由开发人员在设计时添加,而更可能是由用户或来自网络数据库的数据。在第十六章适配器和回收器中,我们将看到如何使用适配器动态地向不同类型的布局添加数据,而在第二十七章Android 数据库中,我们还将看到如何在我们的应用程序中创*和使用数据库。

摘要

我们在几十页中涵盖了许多主题。我们不仅构*了三种不同类型的布局,包括具有嵌套布局的LinearLayout,手动配置约束的ConstraintLayout,以及TableLayout(尽管使用的是假数据),而且我们还通过可点击的按钮和文本将所有布局连接在一起,触发我们的 Kotlin 代码在所有这些不同的布局之间切换。

在下一章中,我们将继续讨论布局的主题。我们将回顾我们所见过的许多属性,并通过将多个CardView布局整合到*滑滚动的ScrollView布局中,构*迄今为止最美观的布局。

第五章:使用 CardView 和 ScrollView 创*美丽的布局

这是我们在专注于 Kotlin 和面向对象编程之前关于布局的最后一章。我们将对我们已经看到的一些不同属性进行正式学*,并且还将介绍两种更酷的布局:ScrollViewCardView。最后,我们将在*板模拟器上运行CardView项目。

在本章中,我们将涵盖以下主题:

  • 编译 UI 属性的快速总结

  • 使用ScrollViewCardView构*迄今为止最漂亮的布局

  • 切换和自定义主题

  • 创*和使用*板模拟器

让我们首先回顾一些属性。

属性-快速总结

在过去的几章中,我们已经使用和讨论了相当多不同的属性。我认为值得对一些更常见的属性进行快速总结和进一步调查。

使用 dp 进行大小调整

众所周知,有成千上万种不同的 Android 设备。Android 使用密度无关像素dp作为测量单位,以尝试拥有一个可以跨不同设备工作的测量系统。其工作原理是首先计算应用程序运行的设备上的像素密度。

提示

我们可以通过将屏幕的水*分辨率除以屏幕的水*尺寸(以英寸为单位)来计算密度。这一切都是在我们的应用程序运行的设备上动态完成的。

我们只需在设置小部件的各种属性的大小时,使用dp与数字结合即可。使用密度无关的测量,我们可以设计布局,使其在尽可能多的不同屏幕上呈现统一的外观。

那么问题解决了吗?我们只需在所有地方使用dp,我们的布局就能在任何地方正常工作了吗?不幸的是,密度独立性只是解决方案的一部分。在本书的其余部分中,我们将看到如何使我们的应用程序在各种不同的屏幕上看起来很棒。

例如,我们可以通过向其属性添加以下代码来影响小部件的高度和宽度:

...
android:height="50dp"
android:width="150dp"
...

或者,我们可以使用属性窗口,并通过适当的编辑框的舒适性来添加它们。您使用哪种选项将取决于您的个人偏好,但有时在特定情况下,一种方式会感觉比另一种方式更合适。无论哪种方式都是正确的,当我们在制作应用程序时,我通常会指出一种方式是否比另一种方式更好

我们还可以使用相同的dp单位来设置其他属性,例如边距和填充。我们将在一分钟内更仔细地研究边距和填充。

使用 sp 调整字体大小

另一个用于调整 Android 字体大小的设备相关单位是可伸缩像素spsp测量单位用于字体,并且与dp完全相同,具有像素密度相关性。

Android 设备在决定您的字体大小时将使用额外的计算,这取决于您使用的sp值和用户自己的字体大小设置。因此,如果您在具有正常大小字体的设备和模拟器上测试应用程序,那么视力受损的用户(或者只是喜欢大字体的用户)并且将其字体设置为大号的用户将看到与您在测试期间看到的内容不同。

如果您想尝试调整 Android 设备的字体大小设置,可以通过选择设置 | 显示 | 字体大小来进行调整:

使用 sp 调整字体大小

正如我们在前面的屏幕截图中看到的,有相当多的设置,如果您尝试在巨大上进行设置,差异是巨大的!

我们可以在任何具有文本的小部件中使用sp设置字体大小。这包括ButtonTextView以及调色板中Text类别下的所有 UI 元素,以及其他一些元素。我们可以通过设置textSize属性来实现:

android:textSize="50sp"

与往常一样,我们也可以使用属性窗口来实现相同的效果。

使用 wrap 或 match 确定大小

我们还可以决定 UI 元素的大小以及许多其他 UI 元素与包含/父元素的关系。我们可以通过将layoutWidthlayoutHeight属性设置为wrap_contentmatch_parent来实现。

例如,假设我们将布局上的一个孤立按钮的属性设置为以下内容:

...
android:layout_width="match_parent"
android:layout_height="match_parent"
....

然后,按钮将在高度和宽度上扩展以匹配父级。我们可以看到下一张图片中的按钮填满了整个屏幕:

使用 wrap 或 match 确定大小

按钮更常见的是wrap_content,如下面的代码所示:

....
android:layout_width="wrap_content"
android:layout_height="wrap_content"
....

这将导致按钮的大小与其需要的内容一样大(宽度和高度为dp,文本为sp)。

使用填充和边距

如果您曾经做过任何网页设计,您将非常熟悉接下来的两个属性。填充是从小部件的边缘到小部件中内容的开始的空间。边距是留在小部件外的空间,用于其他小部件之间的间隔-包括其他小部件的边距,如果它们有的话。这是一个可视化表示:

使用填充和边距

我们可以简单地为所有边指定填充和边距,如下所示:

...
android:layout_margin="43dp"
android:padding="10dp"
...

注意边距和填充的命名约定略有不同。填充值只称为padding,但边距值称为layout_margin。这反映了填充只影响 UI 元素本身,但边距可以影响布局中的其他小部件。

或者,我们可以指定不同的顶部、底部、左侧和右侧的边距和填充,如下所示:

android:layout_marginTop="43dp"
android:layout_marginBottom="43dp"
android:paddingLeft="5dp"
android:paddingRight="5dp"

为小部件指定边距和填充值是可选的,如果没有指定任何值,将假定为零。我们还可以选择指定一些不同边的边距和填充,但不指定其他边,就像前面的示例一样。

很明显,我们设计布局的方式非常灵活,但要精确地使用这些选项,需要一些练*。我们甚至可以指定负边距值来创*重叠的小部件。

让我们再看看一些属性,然后我们将继续玩一个时尚布局CardView

使用layout_weight属性

权重是相对于其他 UI 元素的相对量。因此,要使layout_weight有用,我们需要在两个或更多元素上为layout_weight属性分配一个值。

然后,我们可以分配总共加起来为 100%的部分。这对于在 UI 的各个部分之间划分屏幕空间特别有用,我们希望它们占用的相对空间在屏幕大小不同的情况下保持不变。

layout_weightspdp单位结合使用可以创*简单灵活的布局。例如,看看这段代码:

<Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.10"
        android:text="one tenth" />

<Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.20"
        android:text="two tenths" />

<Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.30"
        android:text="three tenths" />

<Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.40"
        android:text="four tenths" />

这段代码将会做什么:

使用 layout_weight 属性

注意,所有layout_height属性都设置为0dp。实际上,layout_weight属性正在替换layout_height属性。我们使用layout_weight的上下文很重要(否则它不起作用),我们很快就会在一个真实的项目中看到这一点。还要注意,我们不必使用一的分数;我们可以使用整数、百分比和任何其他数字。只要它们相对于彼此,它们可能会实现您想要的效果。请注意,layout_weight仅在某些上下文中起作用,随着我们构*更多的布局,我们将看到它在哪些上下文中起作用。

使用重力

重力可以成为我们的朋友,并且可以在布局中以许多方式使用。就像太阳系中的重力一样,它通过将物品朝特定方向移动来影响物品的位置,就好像它们受到重力的作用一样。了解重力的作用最好的方法是查看一些示例代码和图表:

android:gravity="left|center_vertical"

如果按钮(或其他小部件)的gravity属性设置为left|center_vertical,就像前面的代码所示,它将产生以下效果:

使用重力

注意小部件的内容(在本例中为按钮的文本)确实是左对齐和垂直居中的。

此外,小部件可以通过layout_gravity元素影响其在布局元素中的位置,如下所示:

android:layout_gravity="left"

这将设置小部件在其布局中,如预期的那样:

使用重力

前面的代码允许同一布局中的不同小部件受到影响,就好像布局具有多个不同的重力一样。

通过使用与小部件相同的代码,可以通过其父布局的gravity属性来影响布局中所有小部件的内容:

android:gravity="left"

实际上,有许多属性超出了我们讨论的范围。我们在本书中不需要的属性很多,有些相当晦涩,所以您可能在整个 Android 生涯中都不需要它们。但其他一些属性是相当常用的,包括backgroundtextColoralignmenttypefacevisibilityshadowColor。让我们现在探索一些更多的属性和布局。

使用 CardView 和 ScrollView 构* UI

以通常的方式创*一个新项目。将项目命名为CardView Layout,并选择空活动项目模板。将其余所有设置保持与之前的所有项目相同。

为了能够编辑我们的主题并正确测试结果,我们需要生成我们的布局文件,并编辑 Kotlin 代码,通过调用 onCreate 函数中的 setContentView 函数来显示它。我们将在 ScrollView 布局内设计我们的 CardView 杰作,正如其名字所示,允许用户滚动布局内容。

右键单击layout文件夹,然后选择新*。注意有一个布局资源文件的选项。选择布局资源文件,然后您将看到新资源文件对话框窗口。

文件名字段中输入 main_layout。名称是任意的,但这个布局将是我们的主要布局,所以名称很明显。

注意它被设置为LinearLayout作为元素选项。将其更改为 ScrollView。这种布局类型似乎就像 LinearLayout 一样工作,除了当屏幕上有太多内容要显示时,它将允许用户通过用手指滑动来滚动内容。

点击确定按钮,Android Studio 将在名为 main_layout 的 XML 文件中生成一个新的 ScrollView 布局,并将其放置在 layout 文件夹中,准备好为我们构*基于 CardView 的 UI。

您可以在下一个截图中看到我们的新文件:

使用 CardView 和 ScrollView 构* UI

Android Studio 还将打开准备就绪的 UI 设计器。

使用 Kotlin 代码设置视图

与以前一样,我们现在将通过在MainActivity.kt文件中调用setContentView函数来加载main_layout.xml文件作为我们应用程序的布局。

选择MainActivity.kt选项卡。如果选项卡不是默认显示的,您可以在项目资源管理器中找到它,路径为app/java/your_package_name,其中your_package_name等于您创*项目时选择的包名称。

修改onCreate函数中的代码,使其与下面的代码完全一样。我已经突出显示了您需要添加的行:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.main_layout);
}

现在可以运行该应用程序,但除了一个空的ScrollView布局外,没有其他可见的内容。

添加图像资源

我们将需要一些图像来完成这个项目。这样我们就可以演示如何将它们添加到项目中(本节),并在CardView布局中整洁地显示和格式化它们(下一节)。

您从哪里获取图像并不重要。这个练*的目的是实际的动手经验。为了避免版权和版税问题,我将使用 Packt Publishing 网站上的一些书籍图像。这也使我能够为您提供完成项目所需的所有资源,如果您不想麻烦获取自己的图像的话。请随意在Chapter05/CardViewLayout/res/drawable文件夹中更换图像。

有三个图像:image_1.pngimage_2.pngimage_3.png。要将它们添加到项目中,请按照以下步骤操作。

  1. 使用操作系统的文件浏览器查找图像文件。

  2. 将它们全部高亮显示,然后按Ctrl + C进行复制。

  3. 在 Android Studio 项目资源管理器中,通过左键单击选择res/drawable文件夹。

  4. 右键单击drawable文件夹,选择粘贴。

  5. 在弹出窗口中询问您选择目标目录,单击确定接受默认目标,即drawable文件夹。

  6. 再次单击确定复制指定的文件

现在您应该能够在drawable文件夹中看到您的图像,以及 Android Studio 在创*项目时放置在那里的其他一些文件,如下一个截图所示:

添加图像资源

在我们继续进行CardView之前,让我们设计一下我们将放在其中的内容。

为卡片创*内容

我们接下来需要做的是为我们的卡片创*内容。将内容与布局分开是有意义的。我们将创*三个单独的布局,称为card_contents_1card_contents_2card_contents_3。它们将分别包含一个LinearLayout,其中将包含实际的图像和文本。

让我们再创*三个带有LinearLayout的布局:

  1. 右键单击layout文件夹,选择新*布局资源文件

  2. 将文件命名为card_contents_1,并确保LinearLayout被选为根元素

  3. 单击确定将文件添加到layout文件夹

  4. 重复步骤一到三两次,每次更改文件名为card_contents_2card_contents_3

现在,选择card_contents_1.xml选项卡,并确保您处于设计视图中。我们将拖放一些元素到布局中以获得基本结构,然后我们将添加一些spdp和 gravity 属性使它们看起来漂亮:

  1. 将一个TextView小部件拖放到布局的顶部。

  2. 将一个ImageView小部件拖放到TextView小部件下方的布局中。

  3. 资源弹出窗口中,选择项目 | image_1,然后单击确定

  4. 在图像下方再拖放两个TextView小部件。

  5. 现在您的布局应该是这样的:为卡片创*内容

现在,让我们使用一些材料设计指南使布局看起来更吸引人。

提示

当您进行这些修改时,底部布局的 UI 元素可能会从设计视图的底部消失。如果这种情况发生在您身上,请记住您可以随时从调色板下方的组件树窗口中选择任何 UI 元素。或者,参考下一个提示。

另一种减少问题的方法是使用更大的屏幕,如下面的说明所述:

提示

我将默认设备更改为Pixel 2 XL以创*上一个截图。我会保持这个设置,除非我特别提到我正在更改它。它允许在布局上多出一些像素,这样布局就更容易完成。如果您想做同样的事情,请查看设计视图上方的菜单栏,单击设备下拉菜单,并选择您的设计视图设备,如下截图所示:

为卡片创*内容

  1. TextView小部件的textSize属性设置为24sp

  2. Layout_Margin | all属性设置为16dp

  3. text属性设置为通过构* Android 游戏学* Java(或者适合您图像的标题)。

  4. ImageView上,将layout_widthlayout_height设置为wrap_content

  5. ImageView上,将layout_gravity设置为center_horizontal

  6. ImageView下方的TextView上,将textSize设置为16sp

  7. 在相同的TextView上,将Layout_Margin | all设置为16dp

  8. 在相同的TextView上,将text属性设置为通过构* 6 个可玩游戏从零开始学* Java 和 Android(或者描述您的图像的内容)。

  9. 在底部的TextView上,将text属性更改为立即购买

  10. 在相同的TextView上,将Layout_Margin | all设置为16dp

  11. 在相同的TextView上,将textSize属性设置为24sp

  12. 在相同的TextView上,将textColor属性设置为@color/colorAccent

  13. 在包含所有其他元素的LinearLayout上,将padding设置为15dp。请注意,从Component Tree窗口中选择LinearLayout是最容易的。

  14. 此时,您的布局将非常类似于以下截图:为卡片创*内容

现在,使用完全相同的尺寸和颜色布局其他两个文件(card_contents_2card_contents_3)。当您收到资源弹出窗口以选择图像时,分别使用image_2image_3。还要更改前两个TextView元素上的所有text属性,以使标题和描述是唯一的。标题和描述并不重要;我们学*的是布局和外观。

提示

请注意,所有尺寸和颜色都来自material.io/design/introduction上的材料设计网站,以及developer.android.com/guide/topics/ui/look-and-feel上的 Android 特定 UI 指南。与本书一起学*或在完成本书后不久进行学*都是非常值得的。

现在我们可以转向CardView

为 CardView 定义尺寸

右键单击values文件夹,然后选择New | Values resource file。在New Resource File弹出窗口中,将文件命名为dimens.xml(表示尺寸)并单击OK。我们将使用这个文件来创*一些常见的值,我们的CardView对象将通过引用它们来使用。

为了实现这一点,我们将直接编辑 XML。编辑dimens.xml文件,使其与以下代码相同:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="card_corner_radius">16dp</dimen>
    <dimen name="card_margin">10dp</dimen>
</resources>

确保它完全相同,因为一个小的遗漏或错误可能导致错误并阻止项目工作。

我们定义了两个资源,第一个称为card_corner_radius,值为16dp,第二个称为card_margin,值为10dp

我们将在main_layout文件中引用这些资源,并使用它们来一致地配置我们的三个CardView元素。

将 CardView 添加到我们的布局

切换到main_layout.xml选项卡,并确保您处于设计视图中。您可能还记得,我们现在正在使用一个ScrollView,它将滚动我们应用的内容,就像 Web 浏览器滚动网页内容一样,内容无法适应一个屏幕。

ScrollView有一个限制 - 它只能有一个直接的子布局。我们希望它包含三个CardView元素。

为了解决这个问题,从调色板的Layouts类别中拖动一个LinearLayout。确保选择LinearLayout (vertical),如调色板中的图标所示:

将 CardView 添加到我们的布局

我们将在LinearLayout内添加我们的三个CardView对象,然后整个内容将*稳滚动,没有任何错误。

CardView可以在调色板的Containers类别中找到,所以切换到那里并找到CardView

CardView对象拖放到设计中的LinearLayout上,您将在 Android Studio 中收到一个弹出消息。这是这里所示的消息:

将 CardView 添加到我们的布局

点击确定按钮,Android Studio 将在后台进行一些工作,并向项目添加必要的部分。Android Studio 已经向项目添加了一些更多的类,具体来说,这些类为旧版本的 Android 提供了CardView功能,否则这些功能是不具备的。

现在你应该在设计中有一个CardView对象。在它里面没有内容的情况下,CardView对象只能在组件树窗口中轻松地看到。

通过组件树窗口选择CardView对象,并配置以下属性:

  • layout_width设置为wrap_content

  • layout_gravity设置为center

  • Layout_Margin | all设置为@dimens/card_margin

  • cardCornerRadius设置为@dimens/card_corner_radius

  • cardEleveation设置为2dp

现在,切换到文本选项卡,你会发现你有一个非常类似于下面代码的东西:

<androidx.cardview.widget.CardView
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_gravity="center"
   android:layout_margin="@dimen/card_margin"
   app:cardCornerRadius="@dimen/card_corner_radius"
   app:cardElevation="2dp" />

前面的代码列表只显示了CardView对象的代码。

当前问题是我们的CardView对象是空的。让我们通过添加card_contents_1.xml的内容来解决这个问题。以下是如何做到这一点。

在另一个布局中包含布局文件

我们需要稍微编辑代码,原因如下。我们需要向代码中添加一个include元素。include元素是将从card_contents_1.xml布局中插入内容的代码。问题在于,要添加这段代码,我们需要稍微改变CardView XML 的格式。当前的格式是用一个单一的标签开始和结束CardView对象,如下所示:

<androidx.cardview.widget.CardView
…
…/>

我们需要将格式更改为像这样的单独的开放和关闭标签(暂时不要更改任何内容):

<androidx.cardview.widget.CardView
…
…
</androidx.cardview.widget.CardView>

这种格式的改变将使我们能够添加include…代码,我们的第一个CardView对象将完成。考虑到这一点,编辑CardView的代码,确保与以下代码完全相同。我已经突出显示了两行新代码,但也请注意,cardElevation属性后面的斜杠也已经被移除:

<androidx.cardview.widget.CardView
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_gravity="center"
   android:layout_margin="@dimen/card_margin"
   app:cardCornerRadius="@dimen/card_corner_radius"
   app:cardElevation="2dp" >

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

</androidx.cardview.widget.CardView>

你现在可以在可视化设计师中查看main_layout文件,并查看CardView对象内的布局。可视化设计师无法展现CardView的真实美感。我们很快就会在完成的应用程序中看到所有CardView小部件很好地滚动。以下是我们目前的进度截图:

在另一个布局中包含布局文件

在布局中再添加两个CardView小部件,并将它们配置成与第一个相同,只有一个例外。在第二个CardView对象上,将cardElevation设置为22dp,在第三个CardView对象上,将cardElevation设置为42dp。同时,将include代码更改为分别引用card_contents_2card_contents_3

提示

你可以通过复制和粘贴CardView XML 并简单修改高程和include代码来快速完成这一步,就像前面的段落中提到的那样。

现在我们可以运行应用程序,看到我们三个漂亮的、高架的CardView小部件在运行中的效果。在下面的截图中,我将两个截图并排放置,这样你就可以看到一个完整的CardView布局的效果(在左边),以及右边的图像中,高程设置产生的效果,产生了非常令人愉悦的深度和阴影效果:

在另一个布局中包含布局文件

注意

这张图片在黑白印刷版本的书中可能会有些不清晰。一定要构*并运行应用程序,以查看这个很酷的效果。

现在我们可以尝试编辑应用程序的主题。

主题和材料设计

从技术上讲,创*一个新主题非常容易,我们很快就会看到如何做到这一点。然而,从艺术角度来看,这更加困难。选择哪些颜色能很好地搭配在一起,更不用说适合你的应用程序和图像,这更加困难。幸运的是,我们可以求助于材料设计。

材料设计为 UI 设计的每个方面都提供了指南,所有这些指南都有很好的文档。甚至我们在CardView项目中使用的文本和填充的大小都是从材料设计指南中获取的。

材料设计不仅使您能够设计自己的配色方案,而且还提供了现成的配色方案调色板。

提示

这本书不是关于设计,尽管它是关于实施设计。为了让您开始,我们设计的目标可能是使我们的 UI 独特并在同一时间脱颖而出,同时使其对用户来说舒适甚至熟悉。

主题是由 XML style项构*的。我们在第三章中看到了styles.xml文件,探索 Android Studio 和项目结构styles文件中的每个项目都定义了外观并为其命名,例如colorPrimarycolorAccent

剩下的问题是,我们如何选择颜色,以及如何在主题中实现它们?第一个问题的答案有两种可能的选择。第一个答案是参加设计课程,并花费接下来的几年时间学* UI 设计。更有用的答案是使用内置主题之一,并根据材料设计指南进行自定义,该指南在developer.android.com/guide/topics/ui/look-and-feel/中对每个 UI 元素进行了深入讨论。

我们现在将执行后者。

使用 Android Studio 主题设计师

从 Android Studio 主菜单中,选择工具 | 主题编辑器。在左侧,注意显示主题外观的 UI 示例,右侧是编辑主题方面的控件:

使用 Android Studio 主题设计师

如前所述,创*自己的主题最简单的方法是从现有主题开始,然后进行编辑。在主题下拉菜单中,选择您喜欢外观的主题。我选择了AppCompat Dark

使用 Android Studio 主题设计师

选择右侧要更改颜色的任何项目,并在随后的屏幕中选择颜色:

使用 Android Studio 主题设计师

您将被提示为新主题选择一个名称。我称我的为Theme.AppCompat.MyDarkTheme

使用 Android Studio 主题设计师

现在,单击修复文本以将您的主题应用于当前应用程序,如下图所示:

使用 Android Studio 主题设计师

然后可以在模拟器上运行应用程序,查看主题的效果:

使用 Android Studio 主题设计师

到目前为止,我们所有的应用都在手机上运行。显然,Android 设备生态系统的一个重要部分是*板电脑。让我们看看如何在*板电脑模拟器上测试我们的应用程序,以及预览这个多样化生态系统可能会给我们带来的一些问题,然后我们可以开始学*如何克服这些问题。

创**板电脑模拟器

选择工具 | AVD 管理器,然后单击创*虚拟设备...按钮在您的虚拟设备窗口上。您将在以下屏幕截图中看到选择硬件窗口:

创**板电脑模拟器

类别列表中选择*板电脑选项,然后从可用*板电脑选择中突出显示Pixel C*板电脑。这些选择在上一个屏幕截图中突出显示。

提示

如果您在将来某个时候阅读此内容,Pixel C 选项可能已经更新。选择*板电脑的重要性不如练*创**板电脑模拟器并测试您的应用程序。

点击下一步按钮。在接下来的系统映像窗口中,只需点击下一步,因为这将选择默认的系统映像。选择自己的映像可能会导致模拟器无法正常工作。

最后,在Android 虚拟设备屏幕上,您可以将所有默认选项保持不变。如果愿意,可以更改模拟器的AVD 名称启动方向(纵向或横向):

创**板模拟器

当您准备好时,点击完成按钮。

现在,每当您从 Android Studio 运行您的应用程序时,您将有选择Pixel C(或您创*的任何*板电脑)的选项。这是我 Pixel C 模拟器运行CardView应用程序的屏幕截图:

创**板模拟器

还不错,但有相当多的浪费空间,看起来有点稀疏。让我们尝试横向模式。如果您尝试在*板电脑上以横向模式运行应用程序,结果会更糟。我们可以从中学到的是,我们将不得不为不同大小的屏幕和不同方向设计我们的布局。有时,这些将是智能设计,可以适应不同的大小或方向,但通常它们将是完全不同的设计。

常见问题

问:我需要掌握关于材料设计的所有知识吗?

答:不需要。除非你想成为专业设计师。如果你只想制作自己的应用程序并在 Play 商店上出售或免费提供它们,那么只知道基础知识就足够了。

总结

在本章中,我们构*了美观的CardView布局,并将它们放在ScrollView布局中,以便用户可以通过布局的内容进行滑动,有点像浏览网页。最后,我们启动了一个*板模拟器,并看到如果我们想要适应不同的设备大小和方向,我们需要在布局设计上变得聪明起来。在第二十四章中,设计模式,多个布局和片段,我们将开始将我们的布局提升到下一个水*,并学*如何通过使用 Android 片段来应对如此多样化的设备。

然而,在这样做之前,更好地了解 Kotlin 以及如何使用它来控制我们的 UI 并与用户交互将对我们有所裨益。这将是接下来七章的重点。

当然,此时的悬而未决的问题是,尽管学到了很多关于布局、项目结构、Kotlin 和 XML 之间的连接以及其他许多内容,但是我们的 UI,无论多么漂亮,实际上并没有做任何事情!我们需要严肃地提升我们的 Kotlin 技能,同时学*如何在 Android 环境中应用它们。

在下一章中,我们将做到这一点。我们将看看如何通过与Android Activity 生命周期一起工作,添加 Kotlin 代码,以便在我们需要的确切时刻执行它。

第六章:Android 生命周期

在本章中,我们将熟悉 Android 应用程序的生命周期。计算机程序有生命周期这个想法一开始可能听起来很奇怪,但很快就会有意义。

生命周期是所有 Android 应用程序与 Android 操作系统交互的方式。就像人类的生命周期使他们能够与周围的世界互动一样,我们别无选择,只能与 Android 生命周期互动,并且必须准备处理许多不可预测的事件,如果我们希望我们的应用程序能够生存下来。

我们将探讨应用程序从创*到销毁经历的生命周期阶段,以及这如何帮助我们知道何时根据我们想要实现的目标放置我们的 Kotlin 代码。

在本章中,我们将探讨以下主题:

  • Android 应用程序的生活和时代

  • 覆盖过程和override关键字

  • Android 生命周期的阶段

  • 我们究竟需要了解和做些什么来编写我们的应用程序

  • 生命周期演示应用程序

  • Android 代码的结构,以及为下一章深入学* Kotlin 编码做准备

让我们开始学* Android 生命周期。

Android 应用程序的生活和时代

我们已经谈到了我们代码的结构;我们知道我们可以编写类,在这些类中我们有函数,这些函数包含我们的代码,完成任务。我们也知道当我们想要函数中的代码运行(也就是说,被执行)时,我们通过使用它的名称调用该函数。

此外,在第二章中,Kotlin、XML 和 UI 设计师,我们了解到 Android 在应用程序准备启动之前调用onCreate函数。当我们输出到 logcat 窗口并使用Toast类向用户发送弹出消息时,我们就看到了这一点。

在本章中,我们将研究我们编写的每个应用程序的生命周期中发生的事情;也就是说,当它启动、结束和中间阶段。我们将看到的是,每次运行时,Android 都会与我们的应用程序进行多次交互。

Android 如何与我们的应用程序交互

Android 通过调用包含在Activity类中的函数与我们的应用程序交互。即使该函数在我们的 Kotlin 代码中不可见,它仍然会在适当的时间被 Android 调用。如果这看起来毫无意义,那么请继续阅读。

您是否曾经想过为什么onCreate函数之前有一个不寻常的override关键字?考虑以下代码行:

override fun onCreate(…

当我们重写onCreate等函数时,我们是在告诉 Android 当你调用onCreate时,请使用我们重写的版本,因为我们在其中有一些代码需要执行。

此外,您可能会记得onCreate函数中不寻常的第一行代码:

super.onCreate(savedInstanceState)

这告诉 Android 在继续使用我们重写的版本之前调用onCreate的原始版本。

还有许多其他函数,我们可以选择性地重写它们,它们允许我们在 Android 应用程序的生命周期内的适当时间添加我们的代码。就像onCreate在应用程序显示给用户之前被调用一样,还有其他在其他时间被调用的函数。我们还没有看到它们或重写它们,但它们存在,它们被调用,它们的代码被执行。

我们需要了解和理解 Android 在需要时调用的我们应用程序的函数,因为这些函数控制着我们代码的生死。例如,如果我们的应用程序允许用户输入重要提醒,然后在输入提醒的一半时,他们的手机响了,我们的应用程序消失了,数据(也就是用户的重要提醒)就消失了?

了解我们的应用程序的生命周期何时、为什么以及哪些功能 Android 将调用是至关重要的,而且幸运的是,这是相当简单的。然后我们可以理解我们需要重写哪些功能来添加我们自己的代码,以及在哪里添加定义我们应用程序的真正功能(代码)。

让我们来研究一下 Android 的生命周期。然后我们可以深入了解 Kotlin 的方方面面,明确我们编写的代码应该放在哪里。

Android 生命周期的简化解释

如果你曾经使用过 Android 设备,你可能已经注意到它的工作方式与许多其他操作系统有很大不同。例如,你可以在设备上使用一个应用程序,也许在查看 Facebook 上的人们在做什么。

然后,你收到一封电子邮件通知,你点击通知来阅读它。在阅读邮件的过程中,你可能会收到 Twitter 通知,因为你正在等待某个关注者的重要消息,所以你中断了邮件阅读并触摸屏幕切换到 Twitter。

阅读完推特后,你想玩《愤怒的小鸟》;然而,在第一次射击的一半时,你突然想起了 Facebook 的帖子。所以,你退出《愤怒的小鸟》并点击 Facebook 图标。

你可能会在恢复 Facebook 时恰好回到离开它的地方。之后,你可以回到阅读邮件,决定回复推特,或者开始一个全新的应用程序。

所有这些来回需要操作系统进行相当多的管理,并且独立于各个应用程序本身。

例如,就我们刚刚讨论的内容而言,Windows PC 和 Android 之间的区别是显著的。在 Android 中,尽管用户决定使用哪个应用程序,但操作系统决定何时关闭(或销毁)应用程序以及我们用户的数据(例如假设的笔记)。我们在编写应用程序时需要考虑这一点;仅仅因为我们可能编写代码来处理用户的输入并不意味着 Android 会允许代码执行。

生命周期阶段的神秘

Android 系统有许多不同的阶段,任何给定的应用程序都可能处于其中之一。根据阶段,Android 系统决定用户如何查看应用程序,或者是否根本不查看。

Android 有这些阶段,以便它可以决定哪个应用程序正在使用,并且可以为应用程序分配正确数量的资源,例如内存和处理能力。

此外,当用户与设备进行交互(例如触摸屏幕)时,Android 必须将该交互的详细信息传递给正确的应用程序。例如,在《愤怒的小鸟》中进行拖动和释放动作意味着射击,但在消息应用中可能意味着删除短信。

我们已经提出了当用户退出我们的应用程序来接听电话时会丢失他们的进度、数据或重要笔记的问题。

Android 有一个系统,简化一下以便解释,意味着 Android 设备上的每个应用程序都处于以下阶段之一:

  • 正在创*

  • 开始

  • 恢复

  • 运行

  • 暂停

  • 停止

  • 被销毁

这个阶段列表希望看起来是合乎逻辑的。例如,用户按下 Facebook 应用程序图标,应用程序正在创*;然后,它被启动。到目前为止,一切都很简单,但列表中的下一个阶段是恢复。

这并不像一开始看起来那么不合逻辑。如果我们能暂时接受应用程序在启动后恢复,那么随着我们的继续,一切都会变得清晰起来。

在恢复之后,应用程序正在运行。这时,Facebook 应用程序控制着屏幕,并且拥有更多的系统内存和处理能力,并且正在接收用户输入的详细信息。

那么,我们切换从 Facebook 应用到邮件应用的例子呢?

当我们点击去阅读我们的电子邮件时,Facebook 应用程序将进入暂停阶段,然后是停止阶段,而电子邮件应用程序将进入被创*阶段,然后是恢复,然后是运行

如果我们决定重新访问 Facebook,就像在前面的情景中一样,Facebook 应用程序可能会跳过被创*直接进入恢复,然后再次运行(很可能在我们离开它的确切位置)。

请注意,随时,Android 都可以决定停止然后销毁一个应用程序,在这种情况下,当我们再次运行应用程序时,它将需要在第一个阶段被创*

因此,如果 Facebook 应用程序长时间不活动,或者愤怒的小鸟需要太多系统资源,以至于 Android销毁了 Facebook 应用程序,那么我们之前阅读的确切帖子的体验可能会有所不同。关键是应用程序及其与生命周期的交互控制了用户的体验。

如果所有这些开始变得令人困惑,那么你会高兴地知道提到这些阶段的唯一原因是因为以下原因:

  • 你知道它们存在

  • 我们偶尔需要与它们交互

  • 当我们做的时候,我们将一步一步地进行

我们如何处理生命周期阶段

当我们编写应用程序时,我们如何与这种复杂性进行交互?好消息是,当我们创*第一个项目时自动生成的 Android 代码大部分都是为我们做的。

正如我们所讨论的,我们并不只是看到处理这种交互的函数,但我们有机会覆盖它们并在需要时向该阶段添加我们自己的代码。

这意味着我们可以继续学* Kotlin 并制作 Android 应用程序,直到我们遇到偶尔需要在其中一个阶段做一些事情的情况。

注意

如果我们的应用程序有多个活动,它们将各自拥有自己的生命周期。这并不一定会使事情复杂化,总的来说,这将使事情对我们更容易。

以下列表提供了 Android 提供的用于管理生命周期阶段的函数的快速解释。为了澄清我们对生命周期函数的讨论,它们被列在我们一直在讨论的相应阶段旁边。然而,正如你将看到的,函数名称本身清楚地表明了它们在哪里适用。

在列表中,还有对为什么我们可能在每个阶段使用每个函数进行交互的简要解释或*议。随着我们在书中的进展,我们将遇到大部分这些函数;当然,我们已经见过onCreate

  • onCreate:当 Activity被创*时,将执行此函数。在这里,我们为应用程序准备好一切,包括 UI(例如调用setContentView)、图形和声音。

  • onStart:当应用程序处于启动阶段时,将执行此函数。

  • onResume:此函数在onStart之后运行,但也可以在 Activity 在先前暂停后恢复时(最合乎逻辑的)进入。我们可能会从应用程序被中断时重新加载先前保存的用户数据(例如重要的笔记),例如通过电话呼叫或用户运行另一个应用程序。

  • onPause:当我们的应用程序暂停时会发生这种情况。在这里,我们可能会保存未保存的数据(例如笔记),这些数据可以在onResume中重新加载。当另一个 UI 元素显示在当前 Activity 的顶部(例如弹出对话框)或 Activity 即将停止时(例如用户导航到不同的 Activity)时,Activity 总是转换到暂停状态。

  • onStop:这与停止阶段有关。这是我们可能会撤消onCreate中所做的一切的地方,例如释放系统资源或将信息写入数据库。如果我们到达这里,Activity 很可能很快就会被销毁。

  • onDestroy:这是当我们的 Activity 最终被销毁时发生的。在这个阶段没有回头路。这是我们有序拆除应用程序的最后机会。如果 Activity 达到这个阶段,它将需要在下次使用应用程序时从头开始经历生命周期阶段。

以下图表显示了函数之间执行的可能流程:

我们如何处理生命周期阶段

所有函数的描述及其相关阶段应该很直接。唯一真正的问题是:关于运行阶段呢?正如当我们在其他函数和阶段中编写代码时所看到的,onCreateonStartonResume函数将准备应用程序,然后保持运行阶段。然后,onPauseonStoponDestroy函数将在此之后发生。

现在我们可以通过一个迷你应用程序来观察这些生命周期函数的实际运行情况。我们将通过重写它们并为每个函数添加一个Log消息和一个Toast消息来实现这一点。这将直观地演示我们的应用程序经历的各个阶段。

生命周期演示应用程序

在本节中,我们将进行一个快速实验,以帮助我们熟悉应用程序使用的生命周期函数,并让我们有机会玩弄更多的 Kotlin 代码。

按照以下步骤开始一个新项目,然后我们可以添加一些代码:

  1. 开始一个新项目,并选择Basic Activity项目模板;这是因为在这个项目中,我们还将研究控制应用程序菜单的函数,而Empty Activity选项不会生成菜单。

  2. 将其命名为Lifecycle Demo。代码在下载包的Chapter06/Lifecycle Demo文件夹中,如果您希望参考或复制粘贴它。

  3. 保持其他设置与我们所有示例应用程序中的设置相同。

  4. 等待 Android Studio 生成项目文件,然后通过在编辑器上方的MainActivity标签上单击左键来打开代码编辑器中的MainActivity.kt文件(如果默认情况下没有为您打开)。

对于这个演示,我们只需要MainActivity.kt文件,因为我们不会构*用户界面。

编写生命周期演示应用程序

MainActivity.kt文件中,找到onCreate函数,并在闭合大括号(})之前添加两行代码,标志着onCreate函数的结束:

    Toast.makeText(this, "In onCreate", 
                Toast.LENGTH_SHORT).show()

    Log.i("info", "In onCreate")

提示

请记住,您需要使用Alt + Enter键盘组合两次来导入ToastLog所需的类。

onCreate函数的闭合大括号(})之后,留出一行空白,并添加以下五个生命周期函数及其包含的代码。请注意,我们添加重写的函数的顺序并不重要;无论我们以何种顺序输入它们,Android 都会按正确的顺序调用它们:

override fun onStart() {
  // First call the "official" version of this function
  super.onStart()

  Toast.makeText(this, "In onStart",
        Toast.LENGTH_SHORT).show()

  Log.i("info", "In onStart")
}

override fun onResume() {
  // First call the "official" version of this function
  super.onResume()

  Toast.makeText(this, "In onResume",
              Toast.LENGTH_SHORT).show()

  Log.i("info", "In onResume")
}

override fun onPause() {
  // First call the "official" version of this function
  super.onPause()

  Toast.makeText(this, "In onPause", 
               Toast.LENGTH_SHORT).show()

  Log.i("info", "In onPause")
}

override fun onStop() {
  // First call the "official" version of this function
  super.onStop()

  Toast.makeText(this, "In onStop", 
              Toast.LENGTH_SHORT).show()

  Log.i("info", "In onStop")
}

override fun onDestroy() {
  // First call the "official" version of this function
  super.onDestroy()

  Toast.makeText(this, "In onDestroy", 
              Toast.LENGTH_SHORT).show()

  Log.i("info", "In onDestroy")
}

首先,让我们谈谈代码本身。请注意,函数名称都对应于我们在本章早些时候讨论过的生命周期函数及其相关阶段。请注意,所有函数声明之前都有override关键字。另外,请注意每个函数内的第一行代码是super.on...

以下详细解释了正在发生的事情:

  • Android 在我们已经讨论过的各个时间调用我们的函数。

  • override关键字表明这些函数替换或重写了作为 Android API 的一部分提供的函数的原始版本。请注意,我们看不到这些被替换的函数,但它们存在,如果我们不重写它们,Android 将调用这些原始版本而不是我们自己的版本。

  • super.on...代码是每个重写函数内的第一行代码,然后调用这些原始版本。因此,我们不仅仅是重写这些原始函数以添加我们自己的代码;我们还调用它们,它们的代码也会被执行。

注意

对于急切的读者,super关键字是用于超类。随着我们在本书中的进展,我们将更多地探讨函数重写和超类。

最后,您添加的代码将使每个函数输出一条Toast消息和一条Log消息。然而,输出的消息会有所不同,可以通过双引号("")之间的文本看出。输出的消息将清楚地表明是哪个函数产生了它们。

运行生命周期演示应用程序

现在我们已经查看了代码,我们可以玩玩我们的应用程序,并从发生的事情中了解生命周期:

  1. 在设备或模拟器上运行应用程序。

  2. 观察模拟器的屏幕,您将看到以下内容依次出现为Toast消息:在 onCreate在 onStart在 onResume

  3. 注意 logcat 窗口中的以下消息;如果有太多消息,请记住可以通过将日志级别下拉菜单设置为信息来过滤它们:

 info:in onCreate
 info:in onStart
 info:in onResume

  1. 现在在模拟器或设备上点击返回按钮。注意,您会按照以下确切顺序收到以下三条Toast消息:在 onPause在 onStop在 onDestroy。验证我们在 logcat 窗口中是否有匹配的输出。

  2. 接下来,运行另一个应用程序。也许可以运行第一章中的 Hello World 应用程序,使用 Android 和 Kotlin 入门(但任何应用程序都可以),通过在模拟器或设备屏幕上点击其图标来运行。

  3. 现在尝试在模拟器上打开任务管理器。

提示

如果您不确定如何在模拟器上执行此操作,可以参考第三章中的内容,探索 Android Studio 和项目结构,以及在模拟器上使用真实设备部分。

  1. 现在您应该能够在设备上看到最近运行的所有应用程序。

  2. 点击生命周期演示应用程序,注意通常的三条启动消息会显示;这是因为我们的应用程序以前被销毁。

  3. 现在再次点击任务管理器按钮,切换到 Hello World 应用程序。注意,这一次只显示在 onPause在 onStop消息。验证我们在 logcat 窗口中是否有匹配的输出;这应该告诉我们应用程序没有被销毁。

  4. 现在,再次使用任务管理器按钮,切换到生命周期演示应用程序。您会看到只显示在 onStart在 onResume消息,表明不需要onCreate就可以再次运行应用程序。这是预期的,因为应用程序以前并没有被销毁,而只是停止了。

接下来,让我们谈谈我们运行应用程序时看到的情况。

检查生命周期演示应用程序的输出

当我们第一次启动生命周期演示应用程序时,我们看到调用了onCreateonStartonResume函数。然后,当我们使用返回按钮关闭应用程序时,调用了onPauseonStoponDestroy函数。

此外,我们从我们的代码中知道,所有这些函数的原始版本也被调用,因为我们在每个重写的函数中首先使用super.on...代码调用它们。

我们应用程序行为的怪癖出现在我们使用任务管理器在应用程序之间切换时,当从生命周期演示应用程序切换时,它并没有被销毁,因此当再次切换回来时,不需要运行onCreate

注意

我的 Toast 在哪里?

开头的三条和结尾的三条Toast消息由操作系统排队,并且函数在它们显示时已经完成。您可以通过再次运行实验来验证这一点,并看到所有三条启动和关闭日志消息在第二条Toast消息甚至显示之前就已经输出。然而,Toast消息确实加强了我们对顺序的了解,尽管不是时间上的了解。

当您按照前面的步骤进行操作时,可能会得到略有不同的结果。可以肯定的是,当我们的应用在成千上万台不同的设备上由数百万不同的用户运行时,这些用户对与其设备交互的偏好也不同,Android 将在不可预测的时间调用生命周期函数。

例如,当用户通过按下主页按钮退出应用程序时会发生什么?如果我们依次打开两个应用程序,然后使用返回按钮切换到先前的应用程序,那会销毁还是只是停止应用程序?当用户在其任务管理器中有数十个应用程序,并且操作系统需要销毁一些先前仅停止的应用程序时,我们的应用程序会成为受害者吗?

当然,您可以在模拟器上测试所有前面的场景。但结果只对您测试的一次有效。不能保证每次都会表现出相同的行为,当然也不会在每个不同的 Android 设备上表现出相同的行为。

最后,有一些好消息;解决所有这些复杂性的方法是遵循一些简单的规则:

  • 设置您的应用程序,以便在onCreate函数中准备运行。

  • onResume函数中加载用户的数据。

  • onPause函数中保存用户的数据。

  • onDestroy函数中整理您的应用程序,并使其成为一个良好的 Android 公民。

  • 在本书中,有几个场合我们可能想要使用onStartonStop,要注意一下。

如果我们遵循前面的规则,我们会发现,在本书的过程中,我们可以简单地不再担心生命周期,让 Android 来处理它。

还有一些其他函数我们也可以重写;所以,让我们来看看它们。

一些其他重写的函数

您可能已经注意到,在使用基本活动模板的所有项目代码中,还有另外两个自动生成的函数。它们是onCreateOptionsMenuonOptionsItemSelected。许多 Android 应用程序都有弹出菜单,因此在使用基本活动模板时,Android Studio 会默认生成一个,包括使其工作的代码概述。

您可以在项目资源管理器中的res/menu/menu_main.xml中查看描述菜单的 XML。XML 代码的关键行如下:

<item
      android:id="@+id/action_settings"
      android:orderInCategory="100"
      android:title="@string/action_settings"
      app:showAsAction="never" />

这描述了一个带有设置文本的菜单。如果您运行使用基本活动模板构*的任何应用程序,您将会看到如下截图中所示的按钮:

一些其他重写的函数

如果您点击按钮,您可以看到它的操作如下:

一些其他重写的函数

那么,onCreateOptionsMenuonOptionsItemSelected函数是如何产生这些结果的呢?

onCreateOptionsMenu函数使用以下代码行从menu_main.xml文件加载菜单:

menuInflater.inflate(R.menu.menu_main, menu)

它是由onCreate函数的默认版本调用的,这就是为什么我们没有看到它发生。

注意

我们将在第十七章中使用弹出菜单,数据持久性和共享,在我们的应用程序的不同屏幕之间进行切换。

当用户点击菜单按钮时,将调用onOptionsItemSelected函数。该函数处理当项目被选中时会发生什么。现在,什么都不会发生;它只是返回true

随意向这些函数添加ToastLog消息,以测试我刚刚描述的顺序和时间。

现在我们已经了解了 Android 生命周期的工作原理,并且已经介绍了一些可重写的函数来与这个生命周期进行交互,我们最好学*一下 Kotlin 的基础知识,这样我们就可以编写一些更有用的代码放入这些函数中,并且编写我们自己的函数。

Kotlin 代码的结构-重新访问

我们已经看到,每次创*新的 Android 项目时,我们也会创*一个新的;这是我们编写的代码的一种容器。

我们还学*了并玩耍了。我们已经从 Android API 中导入并直接利用了类,比如LogToast。我们还使用了AppCompatActivity类,但方式与LogToast不同。你可能还记得,到目前为止我们所有项目的第一行代码,在import语句之后,使用了:符号来继承一个类:

class MainActivity : AppCompatActivity() {

当我们继承一个类时,与仅仅导入它不同,我们正在使它成为我们自己的。事实上,如果你再看一下代码行,你会看到我们正在创*一个新的类,用一个新的名字MainActivity,但是基于 Android API 中的AppCompatActivity类。

注意

AppCompatActivityActivity的修改版本。它为较旧版本的 Android 提供了额外的功能,否则这些功能将不存在。关于Activity的所有讨论,比如生命周期,同样适用于AppCompatActivity。如果名称以...Activity结尾,也没关系,因为我们讨论过的和将要讨论的一切同样适用。我通常会简单地将这个类称为Activity

我们可以总结我们对类的使用如下:

  • 我们可以导入类来使用它们

  • 我们可以继承类来使用它们并扩展它们的功能

  • 我们最终可以制作自己的类(并且很快会这样做)

我们自己的类,以及其他人编写的类,都是我们代码的构*模块,类中的函数包装了功能代码 - 也就是执行工作的代码。

我们可以在扩展的类中编写函数,就像我们在第二章中所做的topClickbottomClick一样,Kotlin,XML 和 UI 设计师。此外,我们重写了其他人编写的类中已经存在的函数,比如onCreateonPause

然而,我们在这些函数中放入的唯一代码是使用ToastLog进行了几次调用。现在我们准备用 Kotlin 迈出更多的步伐。

总结

在本章中,我们学到了不仅我们可以调用我们的代码;操作系统也可以调用我们重写的函数中包含的代码。通过向各种重写的生命周期函数添加适当的代码,我们可以确保在正确的时间执行正确的代码。

现在我们需要做的是学*如何编写更多的 Kotlin 代码。在下一章中,我们将开始专注于 Kotlin,并且因为我们已经在 Android 上有了很好的基础,所以练*和使用我们学到的一切都不会有问题。

第七章:Kotlin 变量,运算符和表达式

在本章和下一章中,我们将学*和实践 Kotlin 的核心基础知识。事实上,我们将探索编程的主要原则。在本章中,我们将重点关注数据本身的创*和理解,在下一章中,我们将探索如何操作和响应数据。

本章将重点介绍 Kotlin 中最简单的数据类型-变量。我们将在第十五章 处理数据和生成随机数中重新讨论更复杂和强大的数据类型。

我们将学*的核心 Kotlin 基础知识适用于我们继承的类(例如ActivityAppCompatActivity)以及我们自己编写的类(正如我们将在第十章 面向对象编程中开始做的)。

由于在编写自己的类之前学*基础知识更为合理,我们将学*基础知识,然后使用扩展的ActivityAppCompatActivity来将这个新理论付诸实践。我们将再次使用LogToast来查看我们编码的结果。此外,我们将使用更多我们自己编写的函数(从按钮调用),以及Activity类的重写函数来触发我们代码的执行。然而,我们将在第九章 Kotlin 函数中学*有关函数的全部细节。

当我们转到第十章 面向对象编程,并开始编写我们自己的类,以及了解其他人编写的类如何工作时,我们在这里学到的一切也将适用于那时。

在本章结束时,您将能够舒适地编写 Kotlin 代码,在 Android 中创*和使用数据。本章将带您了解以下主题:

  • 学*行话

  • 学*更多关于代码注释

  • 什么是变量?

  • 变量类型

  • 声明变量的不同方式

  • 初始化变量

  • 运算符和表达式

  • 表达自己的演示应用程序

让我们首先找出变量究竟是什么。

学*行话

在整本书中,我将用简单的英语来解释许多技术概念。我不会要求您阅读以前未用非技术语言解释的 Kotlin 或 Android 概念的技术解释。

致新接触 Kotlin 的 Java 程序员的一句话:如果您已经做过一些 Java 编程,那么事情将变得奇怪!您甚至可能会发誓我犯了一些错误;也许您甚至会认为我忘记了在所有代码行的末尾添加分号!我敦促您继续阅读,因为我认为您会发现 Kotlin 比 Java 有一些优势,因为它更为简洁和表达力强。学* Java 仍然有其存在的价值,因为大多数 Android API 仍然是 Java,即使整个 Android 社区立即放弃 Java(他们没有),也会有多年的遗留 Java 代码。我不会不断指出 Java 和 Kotlin 之间的差异,因为差异太多,这样的分析是不必要的。如果您感兴趣,我*议阅读这篇文章:yalantis.com/blog/kotlin-vs-java-syntax/。最终,Kotlin 和 Java 编译为完全相同的 Dalvik 兼容 Java 字节码。事实上,Java 和 Kotlin 是 100%可互操作的,甚至可以在项目中混合使用。您甚至可以将 Java 代码粘贴到 Kotlin 项目中,它将立即转换为 Kotlin。

Kotlin 和 Android 社区充满了使用技术术语的人;因此,要加入并从这些社区中学*,您需要理解他们使用的术语。

因此,本书的方法是使用简单的语言学*概念或获得大致轮廓,同时将行话或技术术语作为学*的一部分引入。

Kotlin 语法是我们将 Kotlin 语言元素组合成可执行代码的方式。Kotlin 语法是我们使用的单词和将这些单词组成类似句子的结构的组合,这就是我们的代码。

这些 Kotlin“单词”数量众多,但是,分成小块来学*,它们肯定比任何人类语言更容易学*。我们称这些单词为关键字

我相信,如果您能阅读简单的英语,那么您就可以学会 Kotlin,因为学* Kotlin 比学*阅读英语要容易得多。那么,是什么让完成了这样一个初级 Kotlin 课程的人和专业程序员之间有所不同呢?

答案是语言学生和大师诗人之间的区别正是相同的东西。掌握 Kotlin 并不在于我们知道如何使用 Kotlin 关键字的数量,而在于我们如何使用它们。语言的掌握来自于实践、进一步的学*,以及更熟练地使用关键字。许多人认为编程与科学一样是一门艺术,这也有一定道理。

更多关于代码注释

随着您在编写 Kotlin 程序方面变得更加高级,您用于创*程序的解决方案将变得更长、更复杂。此外,正如我们将在后面的章节中看到的,Kotlin 旨在通过将代码分成单独的类(通常跨越多个文件)来管理复杂性。

代码注释是 Kotlin 文件的一部分,在程序执行中没有任何功能;也就是说,编译器会忽略它们。它们用于帮助程序员记录、解释和澄清他们的代码,以便在以后更容易理解自己,或者其他需要使用或更改代码的程序员。

我们已经看到了单行注释:

// this is a comment explaining what is going on

前面的注释以两个斜杠字符//开头。注释在行末结束。因此,该行上的任何内容仅供人阅读,而下一行上的内容(除非是另一个注释)需要是符合语法的 Kotlin 代码:

// I can write anything I like here
but this line will cause an error

我们可以使用多个单行注释,如下所示:

// Below is an important note
// I am an important note
// We can have as many single line comments like this as we like

单行注释也很有用,如果我们想临时禁用一行代码。我们可以在代码前面加上//,这样它就不会包含在程序中。回顾一下这段代码,它告诉 Android 加载我们的布局:

// setContentView(R.layout.activity_main)

在这种情况下,布局将不会加载,当运行时应用程序将显示空白屏幕,因为整行代码被编译器忽略。

注意

我们在第五章中看到了这一点,使用 CardView 和 ScrollView 创*美丽的布局,当我们暂时注释掉函数中的一行代码时。

Kotlin 中还有另一种类型的注释,称为多行注释。多行注释适用于跨越多行的较长注释,以及在代码文件顶部添加版权信息等内容。与单行注释一样,多行注释可以用于临时禁用代码;在这种情况下,通常跨越多行。

/*字符和*/字符之间的所有内容都将被编译器忽略。看一下以下示例:

/*
   You can tell I am good at this because my
   code has so many helpful comments in it.
*/

多行注释中没有行数限制;最好使用的注释类型将取决于具体情况。在本书中,我将始终在文本中明确解释每一行代码,但您通常会在代码本身中发现大量的注释,这些注释会进一步解释、洞察或提供上下文。因此,彻底阅读所有代码总是一个好主意:

/*
   The winning lottery numbers for next Saturday are
   9,7,12,34,29,22
   But you still want to make Android apps?
*/

提示

所有最优秀的程序员都会在他们的代码中大量使用注释!

变量

我们可以将变量看作是一个命名的存储盒。我们选择一个名称,也许是variableA。这些名称是程序员进入用户 Android 设备内存的途径。

变量是内存中的值,当需要时可以通过它们的名称引用它们。

计算机内存有一个高度复杂的地址系统,幸运的是,我们不需要直接与之交互。Kotlin 变量允许我们为应用程序需要处理的所有数据制定自己方便的名称。操作系统将与物理(硬件)内存进行交互。

因此,我们可以将我们的 Android 设备内存看作是一个巨大的仓库,等待我们添加我们的变量。当我们为变量分配名称时,它们存储在仓库中,以备我们需要时使用。当我们使用我们的变量名称时,设备知道我们在引用什么。然后我们可以告诉它做一些事情,比如以下内容:

  • variableA分配一个值

  • variableA添加到variableB

  • 测试variableB的值,并根据结果采取行动

在典型的应用程序中,我们可能会有一个名为unreadMessages的变量;也许用于保存用户未读消息的数量。当有新消息到达时,我们可以将其添加到其中,当用户阅读消息时,我们可以从中减去,并在应用程序的布局中的某个地方向用户显示它,以便他们知道有多少未读消息。

可能出现的情况包括以下几种:

  • 用户收到三条新消息,所以将三条消息添加到unreadMessages的值中。

  • 用户登录应用程序,因此使用Toast显示一条消息以及存储在unreadMessages中的值。

  • 用户看到有几条消息来自他们不喜欢的人,并删除了两条消息。然后我们可以从unreadMessages中减去两个。

变量名是任意的,如果您不使用 Kotlin 限制的任何字符或关键字,可以随意命名变量。

然而,在实践中,最好采用命名约定,以便您的变量名称保持一致。在本书中,我们将使用一个简单的变量命名约定,以小写字母开头。当变量名中有多个单词时,第二个单词将以大写字母开头。这被称为驼峰命名法

以下是一些驼峰命名法变量名称的示例:

  • unreadMessages

  • contactName

  • isFriend

在我们查看一些使用变量的实际 Kotlin 代码之前,我们需要首先看一下我们可以创*和使用的变量的类型

变量的类型

即使是一个简单的应用程序也很容易想象会有相当多的变量。在前一节中,我们介绍了unreadMessages变量作为一个假设的例子。如果应用程序有一个联系人列表,并需要记住每个联系人的名字,那么我们可能需要为每个联系人创*变量。

当应用程序需要知道联系人是否也是朋友,还是普通联系人时,该怎么办?我们可能需要测试朋友状态的代码,然后将该联系人的消息添加到适当的文件夹中,以便用户知道它们是来自朋友还是其他人的消息。

计算机程序的另一个常见要求,包括 Android 应用程序,是正确或错误的测试。计算机程序使用truefalse表示正确或错误的计算。

为了涵盖您可能想要存储或操作的许多其他类型的数据,Kotlin 使用不同类型的变量。

有许多类型的变量,我们甚至可以发明自己的类型。但是,现在我们将看一下最常用的 Kotlin 类型,这些类型将涵盖我们可能遇到的几乎所有情况。解释类型的最佳方法是通过一些示例。

我们已经讨论了假设的unreadMessages变量。这个变量当然是一个数字。

另一方面,假设的contactName变量将保存组成联系人姓名的字符或字母。

保存常规数字的类型称为Int(整数的缩写)类型,保存类似名称的数据的类型称为String

以下是本书中将使用的变量类型列表:

  • IntInt类型用于存储整数和整数。此类型可以存储超过 20 亿的值,包括负值。

  • Long:顾名思义,当需要更大的数字时,可以使用Long数据类型。Long变量可以存储高达 9,223,372,036,854,775,807 的数字。那是很多未读消息。Long变量有很多用途,但如果较小的变量可以胜任,我们应该使用它,因为我们的应用程序将使用更少的内存。

  • Float:此变量用于浮点数。也就是说,小数点后有精度的数字。由于数字的小数部分占用的内存空间与整数部分一样,因此与非浮点数相比,Float变量中可能的数字范围会减少。因此,除非我们的变量将使用额外的精度,否则Float不会是我们的数据类型选择。

  • Double:当Float变量中的精度不够时,我们有Double

  • Boolean:我们将在整本书中使用大量布尔值。Boolean变量类型可以是truefalse;没有其他选项。布尔值回答问题,例如:

  • 联系人是朋友吗?

  • 有新消息吗?

  • 两个布尔值的例子足够了吗?

  • Char:这个类型存储单个字母数字字符。它本身不会改变世界,但如果我们把它们放在一起,它可能会有用。

  • String:字符串可以用来存储任何键盘字符。它类似于Char变量,但长度几乎可以是任意的。从联系人的姓名到整本书都可以存储在一个String中。我们将经常使用字符串,包括在本章中。

  • Class:这是最强大的数据类型,我们已经稍微讨论过了。我们将在第十章中深入探讨类,面向对象编程。

  • Array:这种类型有很多不同的变体,对于处理和组织大量数据至关重要。我们将在第十五章中探讨Array的变体,处理数据和生成随机数。

现在我们知道了变量是什么,以及有各种类型可供选择,我们几乎准备好看一些实际的 Kotlin 代码了。

声明和初始化变量

在我们可以使用刚讨论的变量类型之前,我们必须声明它们,以便编译器知道它们的存在,并且我们还必须初始化它们,以便它们保存一个值。

对于 Kotlin 中的每种变量类型,如IntFloatString,我们可以使用两个关键字来声明它们:valvar

val类型用于存储在应用程序启动之前或初始化期间由程序员决定的值,并且在执行过程中不能再次更改。var类型用于可以在执行过程中操作和更改的值。

因此,val类型只能读取。在技术术语中,它被称为不可变var类型可读可写,这被称为可变。在执行过程中尝试更改val类型的值的代码将导致 Android Studio 显示错误,代码将无法编译。我们将在后面探讨var的规则。

有两种方式可以声明和初始化String类型;首先,通过使用val,如下所示:

val contactName: String = "Gordon Freeman"

在前面的代码中,声明了一个名为contactName的新val变量,类型为String,现在持有Gordon Freeman的值。

此外,Gordon Freeman文本现在是contactName在应用程序执行期间唯一可以持有的值。你可以尝试使用以下代码更改它:

contactName = "Apple Crumble" // Causes an error 

如果你将前面的代码粘贴到 Android 项目的onCreate函数中,你将看到以下内容:

声明和初始化变量

Android Studio 正在帮助我们强制执行我们的决定,使变量成为常量。当然,我们经常需要更改变量持有的值。当我们这样做时,我们将使用var;看一下接下来的两行代码:

var contactName: String = "Gordon Freeman" 
contactName = "Alyx Vance" // No problem

在前面的代码中,我们使用var声明了一个String类型,这次成功地将contactName持有的值更改为Alyx Vance

这里要记住的一点是,如果变量在应用程序执行期间不需要更改,那么我们应该使用val,因为编译器可以帮助我们避免犯错。

让我们声明和初始化一些不同类型的变量:

val battleOfHastings: Int = 1066
val pi: Float = 3.14f
var worldRecord100m: Float = 9.63f
var millisecondsSince1970: Long = 1544693462311 
// True at 9:30am 13/12/2018
val beerIsTasty: Boolean = true
var isItRaining: Boolean = false
val appName: String = "Express Yourself"
var contactName: String = "Geralt"

// All the var variables can be reassigned
worldRecord100m = 9.58f
millisecondsSince1970 = 1544694713072 
// True at 9:51am 13/12/2018
contactName = "Vesemir"

请注意,在前面的代码中,当变量不太可能改变时,我将其声明为val,而当它可能会改变时,我将其声明为var。在开发应用程序时,你可以猜测是使用val还是var,如果有必要,你可以将var变量更改为val变量,或者反过来。另外,在前面的代码中,请注意String类型是用引号中的值进行初始化的,但IntFloatLongBoolean却不是。

使用类型推断节省击键

Kotlin 的设计目标是尽可能简洁。JetBrains 团队的目标之一是让开发人员用尽可能少的代码完成尽可能多的工作。我们将在整个 Kotlin 语言中看到这样的例子。如果你之前在其他语言,特别是 Java 中编码过,你会注意到输入量的显著减少。这种减少的第一个例子就是类型推断

Kotlin 通常可以从上下文中推断出你需要的类型,如果是这种情况,那么你就不需要显式地写出类型;考虑下面的例子:

var contactName: String = "Xian Mei"

在前面的代码中,声明了一个名为contactNameString类型,并使用"Xian Mei"进行了初始化。如果你仔细想一想,它必须是一个String。幸运的是,Kotlin 编译器也能明白这一点。我们可以(而且应该)改进前面的代码,使用类型推断,就像下面的代码一样:

var contactName = "Xian Mei"

冒号和类型已被省略,但结果是相同的。

提示

Java 程序员也会注意到,Kotlin 代码不需要在每行末尾加上分号。然而,如果你喜欢分号,编译器也不会抱怨你在每行末尾加上分号:

var contactName = "Xian Mei"; // OK but superfluous

然而,我们必须记住,尽管我们没有明确指定String,它仍然是一个String类型——只是一个String类型。如果我们尝试对String类型不合适的操作,那么我们将会得到一个错误;例如,当我们尝试将其重新初始化为一个数字值时,就像这段代码中所做的那样:

contactName = 3.14f // Error

前面的代码将在 Android Studio 中标记,并且编译不会成功。以下是前一节代码中的所有声明和初始化,但这次使用了类型推断:

val battleOfHastings = 1066
val pi = 3.14f
var worldRecord100m = 9.63f
var millisecondsSince1970 = 1544693462311 
// True at 9:30am 13/12/2018
val beerIsTasty = true
var isItRaining = false
val appName = "Express Yourself"
var contactName =  "Geralt"

在接下来的两个部分中,我们将看到更多关于变量的类型推断,在后面的章节中,我们将使用类型推断来处理更复杂的类型,比如类、数组和集合。类型推断也将成为一个很好的时间节省器,使我们的代码更短、更易管理。

这可能听起来很明显,但值得一提的是,如果你在声明一个变量以便稍后初始化,那么类型推断是不可能的,就像下面的代码所示:

var widgetCount // Error

前面的代码会导致错误,应用程序将无法编译。

在使用类型推断时,变量的类型通常是显而易见的,但如果有任何疑问,您可以在 Android Studio 中选择一个变量,同时按Shift + Ctrl + P来获得一个方便的屏幕提示:

使用类型推断节省按键

偶尔省略StringInt或冒号(:)类型本身不会有太大变化,所以让我们学*如何通过将它们与运算符结合来制作表达式

运算符和表达式

当然,在几乎任何程序中,我们都需要用这些变量的值来“做事情”。我们可以使用运算符来操作和改变变量。当我们将运算符和变量组合以获得结果时,这被称为表达式。

以下各节列出了最常见的 Kotlin 运算符,允许我们操作变量。您不需要记住它们,因为我们将在第一次使用它们时查看每行代码。

我们在上一节初始化变量时已经看到了第一个运算符,但我们将再次看到它变得更加冒险。

赋值运算符

这是赋值运算符:

=

它使运算符左侧的变量与右侧的值相同;例如,就像这行代码中的例子:

unreadMessages = newMessages

在前一行代码执行后,unreadMessages中存储的值将与newMessages中存储的值相同。

加法运算符

这是加法运算符:

+

它将运算符两侧的值相加。通常与赋值运算符一起使用。例如,它可以将具有数值的两个变量相加,就像下一行代码中的例子:

 unreadMessages = newMessages + unreadMessages 

一旦前面的代码执行了,newMessagesunreadMessages所保存的值的总和将存储在unreadMessages中。作为同样的例子,看看这行代码:

accountBalance = yesterdaysBalance + todaysDeposits

请注意,同时在运算符的两侧同时使用同一个变量是完全可以接受的(并且非常常见)。

减法运算符

这是减法运算符:

-

它将从运算符左侧的值中减去运算符右侧的值。通常与赋值运算符一起使用,就像这个例子中:

unreadMessages = unreadMessages - 1

减法运算符的另一个例子如下:

accountBalance = accountBalance - withdrawals

在前一行代码执行后,accountBalance将保持其原始值减去withdrawals中保存的值。

除法运算符

这是除法运算符:

/

它将左侧的数字除以右侧的数字。同样,通常与赋值运算符一起使用;这是一个例子行代码:

fairShare = numSweets / numChildren

如果在前一行代码中,numSweets为 9,numChildren为 3,则fairShare现在将保存值为 3。

乘法运算符

这是乘法运算符:

*

它将变量和数字相乘,与许多其他运算符一样,通常与赋值运算符一起使用;例如,看看这行代码:

answer = 10 * 10 

乘法运算符的另一个例子如下:

biggerAnswer = 10 * 10 * 10

在前两行代码执行后,answer保存的值为 100,biggerAnswer保存的值为 1000。

递增运算符

这是递增运算符:

   ++

递增运算符是将某物加一的快速方法。例如,看看下一行代码,它使用了加法运算符:

myVariable = myVariable + 1 

前一行代码的结果与这个更紧凑的代码相同:

myVariable ++ 

递减运算符

这是递减运算符:

      -- 

递减运算符(你可能已经猜到)是从某物中减去一个的快速方法。例如,看看下一行代码,它使用了减法运算符:

myVariable = myVariable -1

前一行代码与myVariable --.相同。

现在我们可以将这些新知识应用到一个工作中的应用程序中。

表达自己的演示应用程序

让我们尝试使用一些声明、赋值和运算符。当我们将这些元素捆绑到一些有意义的语法中时,我们称之为表达式。让我们写一个快速的应用程序来尝试一些。然后我们将使用ToastLog来检查我们的结果。

创*一个名为Express Yourself的新项目,使用空活动项目模板,并将所有其他选项保持在它们通常的设置中。我们将在下载包的Chapter07文件夹中找到我们将在这个项目中编写的完成代码。

切换到编辑器中的MainActivity选项卡,我们将写一些代码。在onCreate函数中,在闭合大括号(})之前,添加这个突出显示的代码:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

 val name = "Nolan Bushnell"
 val yearOfBirth = 1943
 var currentYear = 2019
 var age: Int
    }
}

我们刚刚在onCreate函数中添加了四个变量。前两个是val变量,不能被改变。它们是一个String类型,保存一个人的名字,和一个Int类型,保存出生年份。类型在代码中没有明确提到;它们是被推断出来的。

接下来的两个变量是var变量。我们有一个Int类型来表示当前年份,和一个未初始化的Int类型来表示一个人的年龄。由于age变量未初始化,它的类型无法被推断,所以我们必须指定它。

在前面的代码之后,仍然在onCreate内部,添加以下行:

age = currentYear - yearOfBirth
Log.i("info", "$age")

运行应用程序,注意在 logcat 窗口中的以下输出:

info: 76

Log.i…代码的引号中使用$符号表示我们想要输出age变量中存储的,而不是字面上的单词"age"。

实际的值本身(76),表示存储在yearOfBirth(1943)中的值被减去存储在currentYear(2019)中的值,结果被用来初始化age变量。正如你将看到的,我们可以在引号中包含尽可能多的$符号,并将它们与文本甚至 Kotlin 表达式混合使用。这个特性被称为字符串模板。让我们尝试另一个字符串模板。

onCreate函数内的前面的代码之后添加这两行代码:

currentYear++
Log.i("info", "$name 
was born in $yearOfBirth and is $age years old. 
Next year he will be ${currentYear - yearOfBirth} years old)")

关于这段代码的第一件事是,尽管它在这本书中格式化为四行,但当你输入到 Android Studio 中时,它必须输入为两行。第一行currentYear++,增加(加一)到存储在currentYear中的值。所有其余的代码都是一行。

运行应用程序,观察 logcat 窗口中的以下输出:

Nolan Bushnell was born in 1943 and is 76 years old. Next year he will be 77 years old

这段代码之所以有效,是因为 Kotlin 字符串模板。让我们分解这行相当长的代码。首先,我们像以前做过很多次一样调用Log.i函数。在第一个字符串中,我们传递了"info",在第二个字符串中,我们传递了一些变量名,前面加上$符号,混合了一些字面文本。分解中最有趣的部分是倒数第二部分,因为我们使用一个表达式来形成字符串的一部分:

  • $name打印出 Nolan Bushnell

  • Was born in是字面文本

  • $yearOfBirth打印出 1943

  • 字面文本and is接下来

  • $currentAge打印出 76

  • 接下来是字面文本years old

  • 字面文本Next year he will be接下来

  • ${currentYear - yearOfBirth}是一个表达式,表达式的结果(77)被打印出来

  • 最后的字面文本years old被打印出来以结束输出

这表明我们可以使用以下形式在String类型中包含任何有效的 Kotlin 表达式:

${expression}

在下一章中,我们将看到更复杂和强大的表达式。

总结

在本章中,我们学*了 Kotlin 中数据的基本构*块。我们探讨了不同类型及其不同用途的概述。我们还学会了如何使用字符串模板从字面值、变量和表达式构*字符串。我们还看到了在可能的情况下,我们可以和应该使用类型推断使我们的代码更简洁。

我们没有看到太多关于布尔变量类型,但在下一章中,当我们学* Kotlin 的决策和循环时,我们将纠正这一错误。

第八章:Kotlin 决策和循环

我们刚刚学会了变量,并且现在了解如何使用表达式更改它们所持有的值,但是我们如何根据变量的值采取行动呢?

我们当然可以将新消息的数量添加到先前未读消息的数量中,但是例如,当用户已读完所有消息时,我们如何在应用程序内触发一个操作呢?

第一个问题是我们需要一种方法来测试变量的值,然后在值落在一系列值范围内或等于特定值时做出响应。

编程中常见的另一个问题是,我们需要根据变量的值来执行代码的某些部分一定次数(多次或有时根本不执行)。

为了解决第一个问题,我们将学*使用 ifelsewhen 在 Kotlin 中做决策。为了解决后者,我们将学*使用 whiledowhileforcontinuebreak 在 Kotlin 中做循环。

此外,我们将了解到,在 Kotlin 中,决策也是产生值的表达式。在本章中,我们将涵盖以下主题:

  • 使用 ifelseelseifswitch 进行决策

  • when 演示应用程序

  • Kotlin while 循环和 do - while 循环

  • Kotlin for 循环

现在让我们更多地了解 Kotlin。

在 Kotlin 中做决策

我们的 Kotlin 代码将不断做出决策。例如,我们可能需要知道用户是否有新消息,或者他们是否有一定数量的朋友。我们需要能够测试我们的变量,看它们是否满足某些条件,然后根据它们是否满足条件来执行特定的代码部分。

在本节中,随着我们的代码变得更加深入,以一种更易读的方式呈现代码有助于使其更易读。让我们看一下代码缩进,以使我们关于做决策的讨论更加容易。

为了清晰起见缩进代码

您可能已经注意到我们项目中的 Kotlin 代码是缩进的。例如,在 MainActivity 类内的第一行代码被缩进了一个制表符。此外,每个函数内的第一行代码也被另一个制表符缩进;下面是一个带注释的图表,以便清楚地说明这一点:

为了清晰起见缩进代码

请注意,当缩进块结束时,通常是以一个闭合大括号(})结束,它的缩进程度与开始块的代码行相同。

我们这样做是为了使代码更易读。然而,这并不是 Kotlin 语法的一部分,如果我们不去做这个,代码仍然会编译。

随着我们的代码变得更加复杂,缩进和注释有助于保持代码的含义和结构清晰。我现在提到这一点是因为当我们开始学* Kotlin 中做决策的语法时,缩进变得特别有用,*议您以相同的方式缩进代码。大部分缩进是由 Android Studio 为我们完成的,但并非全部。

现在我们知道如何更清晰地呈现我们的代码,我们可以学*更多运算符,然后开始使用 Kotlin 做决策。

更多 Kotlin 运算符

我们已经学会了使用运算符进行添加(+)、减去(-)、乘以(*)、除以(/)、赋值(=)、递增(++)和递减(--)。现在我们将探索一些更有用的运算符,然后我们将直接学*如何使用它们。

提示

不要担心记住以下每个运算符。简要浏览它们和它们的解释,然后继续下一节。在那里,我们将使用一些运算符,并且当我们看到它们允许我们做什么的示例时,它们将变得更加清晰。它们在此处以列表的形式呈现,只是为了从一开始就清晰地展示运算符的种类和范围。在后面关于实现的讨论中,这个列表也会更方便以后参考。

我们使用运算符创*一个表达式,这个表达式要么为真,要么为假。我们用括号或方括号括起这个表达式,就像这样:(表达式放在这里)

比较运算符

这是比较运算符。它测试相等性,要么为真,要么为假;它是Boolean类型的:

==

例如,表达式(10 == 9)是假的。10 显然不等于 9。然而,表达式(2 + 2 == 4)显然是真的。

提示

也就是说,除了在《1984》中,2 + 2 == 5(en.wikipedia.org/wiki/Nineteen_Eighty-Four)。

逻辑 NOT 运算符

这是逻辑 NOT 运算符:

!

它用于测试表达式的否定。如果表达式为假,那么 NOT 运算符会使表达式为真。

例如,表达式(!(2+2 == 5))为真,因为 2 + 2 不是 5。但是,(!(2 + 2 = 4))的进一步例子是假的。这是因为 2 + 2 显然是 4。

不等于运算符

这是不等于运算符,它是另一个比较运算符:

!=

不等于运算符测试是否不相等;例如,(10 != 9)表达式为真,因为 10 不等于 9。另一方面,(10 != 10)为假,因为 10 等于 10。

大于运算符

另一个比较运算符(还有一些其他的)是大于运算符:

>

这个运算符测试一个值是否大于另一个值。表达式(10 > 9)为真,但是表达式(9 > 10)为假。

小于运算符

你可能猜到了,这个运算符测试一个值是否小于另一个值;这是这个运算符的样子:

<

表达式(10 < 9)为假,因为 10 不小于 9,而表达式(9 < 10)为真。

大于或等于运算符

这个运算符测试一个值是否大于或等于另一个值,如果其中一个为真,结果就为真。这就是这个运算符的样子:

>=

例如,表达式(10 >= 9)为真,表达式(10 >= 10)也为真,但是表达式(10 >= 11)为假,因为 10 既不大于也不等于 11。

小于或等于运算符

像前一个运算符一样,这个运算符测试两个条件,但这次是小于或等于;看看下面的运算符:

<=

表达式(10 <= 9)为假,表达式(10 <= 10)为真,表达式(10 <= 11)也为真。

逻辑 AND 运算符

这个运算符称为逻辑 AND。它测试表达式的两个或多个独立部分,整个表达式的两个或所有部分都必须为真才能为真:

&&

逻辑 AND 通常与其他运算符一起使用,以构*更复杂的测试。表达式((10 > 9) && (10 < 11))为真,因为两个部分都为真。另一方面,表达式((10 > 9) && (10 < 9))为假,因为表达式的一个部分为真-(10 > 9),而另一个部分为假-(10 < 9)

逻辑 OR 运算符

这个运算符叫做逻辑 OR,它和逻辑 AND 一样,只是表达式的两个或多个部分中只有一个为真,整个表达式才为真:

||

再看一下我们用于逻辑 AND 的上一个例子,但是,用||替换&&。表达式((10 > 9) || (10 < 9))现在为真,因为表达式的一个或多个部分需要为真。

在本章和整本书的其余部分中,以更实际的情境看到这些运算符,将有助于澄清它们的不同用途。现在我们知道如何使用运算符、变量和值来形成表达式。接下来,我们可以看一种结构化和组合表达式的方法,以做出几个深刻的决定。

如何使用所有这些运算符来测试变量

所有这些运算符在没有正确使用它们来做出影响真实变量和代码的真实决定的方法时几乎是无用的。

现在我们已经拥有了所有需要的信息,我们可以看一个假设的情况,然后实际检查一些决策的代码。

使用 if 表达式

正如您所见,运算符本身的作用很小,但看到我们可以使用的广泛和多样的范围的一部分是很有用的。现在当我们开始使用最常见的运算符==时,我们可以开始看到它们为我们提供的强大而精细的控制。

让我们通过检查以下代码来使之前的示例不那么抽象。

val time = 9

val amOrPm = if(time < 12) {
  "am"
} else {
  "pm"
}

Log.i("It is ", amOrPm)

上述代码首先声明并初始化了一个名为timeInt类型,其值为9。代码的下一行非常有趣,因为它做了两件事。if(time < 12)表达式是一个测试;我们知道时间小于12,因为我们刚刚将其初始化为9。由于条件为真,if表达式返回"am"值,并且在if表达式之前的代码行的第一部分声明并初始化了一个名为amOrPm的新String类型,并赋予了该值。

如果我们将time变量初始化为不少于 12 的任何值(即 12 或更高),则从else块中返回的值将是"pm"。如果您将上述代码复制并粘贴到项目中,例如onCreate函数,logcat 中的输出将如下所示:

It is: am

if表达式被评估,如果条件为真,则执行第一组花括号中的代码({…});如果条件为假,则执行else {…}块中的代码。

值得注意的是,if不一定要返回一个值,而是可以根据测试的结果执行一些代码;看一下以下示例代码:

val time = 13

if(time < 12) {
  // Execute some important morning task here
} else {
  // Do afternoon work here
}

在上述代码中,没有返回值;我们只关心正确的代码部分是否被执行。

提示

从技术上讲,仍然返回一个值(在这种情况下为 true 或 false),但我们选择不对其进行任何操作。

此外,我们的if表达式可以处理超过两个结果,我们稍后会看到。

我们还可以在 String 模板中使用if。我们在上一章中看到,我们可以通过在$符号后的花括号之间插入表达式来将表达式插入到String类型中。以下是上一章的代码提醒:

Log.i("info", "$name 
was born in $yearOfBirth and is $age years old. 
Next year he will be ${currentYear - yearOfBirth} years old)")

在上述代码中的突出部分将导致从currentYear中减去yearOfBirth的值被打印在消息的其余部分中。

以下代码示例显示了我们如何以相同的方式将整个if表达式插入到String模板中:

val weight = 30
val instruction = 
  "Put bag in ${if (weight >= 25) "hold" else "cabin" }"

Log.i("instruction is ", instruction)

上述代码使用if来测试weight变量是否初始化为大于或等于 25 的值。根据表达式是否为真,它将单词hold或单词cabin添加到String初始化中。

如果您执行上述代码,您将获得以下输出:

instruction is: Put this bag in the hold

如果您将weight的初始化更改为 25 以下的任何值并执行代码,您将获得以下输出:

instruction is: Put this bag in the cabin

让我们看一个更复杂的例子。

如果他们过桥,就射击他们!

在下一个示例中,我们将使用if,一些条件运算符和一个简短的故事来演示它们的用法。

船长快要死了,知道他剩下的下属经验不是很丰富,他决定写一个 Kotlin 程序(还能干什么?)在他死后传达他的最后命令。部队必须守住桥的一侧,等待增援,但有一些规则来决定他们的行动。

船长想要确保他的部队理解的第一个命令如下:

如果他们过桥,就射击他们。

那么,我们如何在 Kotlin 中模拟这种情况呢?我们需要一个Boolean变量-isComingOverBridge。下一部分代码假设isComingOverBridge变量已经被声明并初始化为truefalse

然后我们可以这样使用if

if(isComingOverBridge){

   // Shoot them

}

如果isComingOverBridge布尔值为 true,则大括号内的代码将执行。如果isComingOverBridge为 false,则程序在if块之后继续执行,而不运行其中的代码。

否则,做这个代替

船长还想告诉他的部队,如果敌人不从桥上过来,他们应该待在原地等待。

为此,我们可以使用else。当我们希望在if表达式不为 true 时明确执行某些操作时,我们使用else

例如,如果敌人不从桥上过来,我们可以编写以下代码告诉部队待在原地:

if(isComingOverBridge){

   // Shoot them

}else{

   // Hold position

}

然后船长意识到问题并不像他最初想的那么简单。如果敌人从桥上过来,但是部队太多怎么办?他的小队将被压制和屠杀。

因此,他提出了以下代码(这次,我们也将使用一些变量):

var isComingOverBridge: Boolean
var enemyTroops: Int
var friendlyTroops: Int

// Code that initializes the above variables one way or another

// Now the if
if(isComingOverBridge && friendlyTroops > enemyTroops){

   // shoot them

}else if(isComingOveBridge && friendlyTroops < enemyTroops) {

   // blow the bridge

}else{

   // Hold position

}

上述代码有三条可能的执行路径。第一种情况是,如果敌人从桥上过来,友军数量更多:

if(isComingOverBridge && friendlyTroops > enemyTroops)

第二种情况是,如果敌军正在从桥上过来,但数量超过友军:

else if(isComingOveBridge && friendlyTroops < enemyTroops)

如果其他两条路径都不成立,第三种可能的结果是由最终的else语句捕获的,没有if条件。

提示

读者挑战

您能发现上述代码的一个缺陷吗?这可能会让一群经验不足的部队陷入完全混乱的状态?敌军和友军的数量恰好相等的可能性没有得到明确处理,因此将由最终的else语句处理,这是用于没有敌军的情况。任何自尊的船长都希望他的部队在这种情况下战斗,他可以改变第一个if语句以适应这种可能性,如下所示:

if(isComingOverBridge && friendlyTroops >= enemyTroops)

最后,船长最后关心的是,如果敌人拿着白旗投降并立即被屠杀,那么他的士兵将成为战争罪犯。这里需要的代码是显而易见的;使用wavingWhiteFlag布尔变量,他可以编写以下测试:

if (wavingWhiteFlag){

   // Take prisoners

}

然而,放置这段代码的位置不太清楚。最后,船长选择了以下嵌套解决方案,并将wavingWhiteFlag的测试更改为逻辑非,如下所示:

if (!wavingWhiteFlag){

   // not surrendering so check everything else

   if(isComingOverBridge && friendlyTroops >= enemyTroops){

          // shoot them
   }else if(isComingOverBridge && friendlyTroops < 
                enemyTroops) {

         // blow the bridge

   }

}else{

   // this is the else for our first if
   // Take prisoners

}

// Holding position

这表明我们可以嵌套ifelse语句以创*深入和详细的决策。

我们可以继续使用ifelse做出更多更复杂的决定,但是我们在这里看到的已经足够作为介绍了。

很可能值得指出的是,很多时候,解决问题有多种方法。正确的方法通常是以最清晰和最简单的方式解决问题。

现在我们将看一些其他在 Kotlin 中做决策的方法,然后我们可以将它们全部放在一个应用程序中。

使用when进行决策

我们已经看到了将 Kotlin 运算符与ifelse语句结合使用的广泛且几乎无限的可能性。但是,有时,在 Kotlin 中做出决策可能有其他更好的方法。

当我们希望根据一系列可能的结果做出决策并执行不同的代码段时,我们可以使用when。以下代码声明并初始化rating变量,然后根据rating的值向 logcat 窗口输出不同的响应:

val rating:Int = 4
when (rating) {
  1 -> Log.i("Oh dear! Rating = ", "$rating stars")
  2 -> Log.i("Not good! Rating = ", "$rating stars")
  3 -> Log.i("Not bad! Rating = ", "$rating stars")
  4 -> Log.i("This is good! Rating = ", "$rating stars")
  5 -> Log.i("Amazing! Rating = ", "$rating stars")

  else -> {    
    Log.i("Error:", "$rating is not a valid rating")
  }
}

如果您将上述代码复制并粘贴到应用程序的onCreate函数中,它将产生以下输出:

This is good! Rating =: 4 stars

该代码首先将名为ratingInt变量初始化为4。然后,when块使用rating作为条件:

val rating:Int = 4
when (rating) {

接下来,处理了评分可能初始化为的五种不同可能性。对于每个值,从15,都会向 logcat 窗口输出不同的消息:

1 -> Log.i("Oh dear! Rating = ", "$rating stars")
2 -> Log.i("Not good! Rating = ", "$rating stars")
3 -> Log.i("Not bad! Rating = ", "$rating stars")
4 -> Log.i("This is good! Rating = ", "$rating stars")
5 -> Log.i("Amazing! Rating = ", "$rating stars")

最后,如果没有指定的选项为真,则会执行else块:

else -> {
  Log.i("Error:", "$rating is not a valid rating")
}

让我们通过构*一个小型演示应用程序来看一下when的稍微不同的用法。

When Demo 应用

要开始,请创*一个名为When Demo的新 Android 项目。使用空活动项目模板,并将所有其他选项保持在通常的设置中。通过在编辑器上方单击MainActivity.kt标签,切换到MainActivity.kt文件,我们可以开始编码。

您可以在下载包的Chapter08/When Demo文件夹中获取此应用的代码。该文件还包括与我们先前讨论的表达式和if相关的代码。为什么不尝试玩一下代码,运行应用程序并研究输出呢?

onCreate函数内添加以下代码。该应用程序演示了多个不同的值可以触发相同执行路径:

// Enter an ocean, river or breed of dog
val name:String = "Nile"
when (name) {
  "Atlantic","Pacific", "Arctic" -> 
    Log.i("Found:", "$name is an ocean")

  "Thames","Nile", "Mississippi" -> 
    Log.i("Found:", "$name is a river")

  "Labrador","Beagle", "Jack Russel" -> 
    Log.i("Found:", "$name is a dog")

  else -> {
    Log.i("Not found:", "$name is not in database")
  }
}

在前面的代码中,根据name变量初始化的值,有四条可能的执行路径。如果使用AtlanticPacificArctic的任何一个值,则执行以下代码行:

Log.i("Found:", "$name is an ocean")

如果使用ThamesNileMississippi的任何一个值,则执行以下代码行:

Log.i("Found:", "$name is a river")

如果使用了LabradorBeagleJack Russel的任何一个值,则执行以下代码行:

Log.i("Found:", "$name is a dog")

如果没有使用海洋、河流或狗来初始化name变量,则应用程序将分支到else块并执行以下代码行:

Log.i("Not found:", "$name is not in database")

如果使用name初始化为Nile(如前面的代码所做的那样)执行应用程序,则将在 logcat 窗口中看到以下输出:

Found:: Nile is a river

运行应用几次,每次将name的初始化更改为新的内容。注意,当您将name初始化为一个明确由语句处理的内容时,我们会得到预期的输出。否则,我们会得到else块处理的默认输出。

如果我们有很多代码要在when块中的选项中执行,我们可以将所有代码都放在一个函数中,然后调用该函数。我在以下假设的代码中突出显示了更改的行:

   "Atlantic","Pacific", "Arctic" -> 
         printFullDetailsOfOcean(name)

当然,我们将需要编写新的printFullDetailsOfOcean函数。然后,当name初始化为一个明确由语句处理的海洋之一时,将执行printFullDetailsOfOcean函数。然后执行将返回到when块之外的第一行代码。

提示

您可能想知道将name变量放在printFullDetailsOfOcean(name)函数调用的括号中的意义。发生的情况是,我们将存储在name变量中的数据传递给printFullDetailsOfOcean函数。这意味着printFullDetailsOfOcean函数可以使用该数据。这将在下一章中更详细地介绍。

当然,这段代码严重缺乏与 GUI 的交互。我们已经看到如何从按钮点击中调用函数,但即使这样也不足以使这段代码在真正的应用程序中有价值。我们将在第十二章中看到我们如何解决这个问题,将我们的 Kotlin 连接到 UI 和可空性

我们还有另一个问题,那就是代码执行完毕后,就什么都不做了!我们需要它不断地询问用户的指令,不只是一次,而是一遍又一遍。我们将在下一步解决这个问题。

使用循环重复代码

在这里,我们将通过查看 Kotlin 中的几种循环类型,包括while循环、do-while循环和for循环,学*如何以受控且精确的方式重复执行代码的部分。我们还将了解在何种情况下使用这些不同类型的循环是最合适的。

询问循环与编程有什么关系是完全合理的,但它们确实如其名称所示。它们是重复执行代码的一种方式,或者循环执行相同的代码部分,尽管每次可能会有不同的结果。

这可能意味着重复执行相同的操作,直到循环的代码提示循环结束。它可以是由循环代码本身指定的预定次数的迭代。它可能是直到满足预定情况或条件为止。或者,它可能是这些事情的组合。除了ifelsewhen,循环也是 Kotlin控制流语句的一部分。

我们将学* Kotlin 提供的所有主要类型的循环,使用其中一些来实现一个工作的迷你应用程序,以确保我们完全理解它们。让我们先看一下 Kotlin 中的第一种和最简单的循环类型,即while循环。

while 循环

Kotlin 的while循环具有最简单的语法。回想一下if语句;我们可以在if语句的条件表达式中使用几乎任何组合的运算符和变量。如果表达式评估为真,则执行if块中的代码。对于while循环,我们也使用一个可以评估为真或假的表达式:

var x = 10

while(x > 0) {
  Log.i("x=", "$x")
  x--
}

看一下上述代码;这里发生的情况如下:

  1. while循环之外,声明了一个名为xInt类型,并将其初始化为 10。

  2. 然后,while循环开始;它的条件是x > 0。因此,while循环将执行其主体中的代码。

  3. 循环体中的代码将重复执行,直到条件评估为假。

因此,上述代码将执行 10 次。

在第一次循环中,x等于 10,在第二次循环中,它等于 9,然后是 8,依此类推。但一旦x等于 0,它当然不再大于 0。此时,执行将退出while循环,并继续执行while循环之后的第一行代码(如果有的话)。

if语句一样,while循环可能甚至不会执行一次。看一下以下示例,while循环中的代码将不会执行:

var x = 10

while(x > 10){
   // more code here.
   // but it will never run 
  // unless x is greater than 10.
}

此外,条件表达式的复杂度或循环体中的代码量没有限制;以下是另一个例子:

var newMessages = 3
var unreadMessages = 0

while(newMessages > 0 || unreadMessages > 0){
   // Display next message
   // etc.
}

// continue here when newMessages and unreadMessages equal 0

上述while循环将继续执行,直到newMessagesunreadMessages都等于或小于零。由于条件使用逻辑或运算符(||),其中一个条件为真将导致while循环继续执行。

值得注意的是,一旦进入循环体,即使表达式在中途评估为假,循环体也会始终完成。这是因为直到代码尝试开始另一次循环时才会再次测试:

var x = 1

while(x > 0){
   x--
   // x is now 0 so the condition is false
   // But this line still runs
   // and this one
   // and me!
}

上述循环体将执行一次。我们还可以设置一个永远运行的while循环!这被称为无限循环;以下是一个无限循环的例子:

var x = 0

while(true){
   x++ // I am going to get very big!
}

上述代码将永远不会结束;它将永远循环。我们将看到一些控制何时跳出while循环的解决方案。接下来,我们将看一下while循环的变体。

do-while 循环

do-while循环的工作方式与普通的while循环相同,只是do块的存在保证了即使while表达式的条件不评估为真,代码也会至少执行一次:

var y = 10
do {
  y++
  Log.i("In the do block and y=","$y")
}
while(y < 10)

如果您将此代码复制并粘贴到onCreate函数中的一个应用程序中,然后执行它,输出可能不是您所期望的。以下是输出:

In the do block and y=: 11

这是一个不太常用但有时是解决问题的完美方案。即使while循环的条件为假,do块也会执行其代码,将y变量递增到 11,并打印一条消息到 logcat。while循环的条件是y < 10,因此do块中的代码不会再次执行。但是,如果while条件中的表达式为真,则do块中的代码将继续执行,就像是常规的while循环一样。

范围

为了继续讨论循环,有必要简要介绍范围的主题。范围与 Kotlin 的数组主题密切相关,我们将在第十五章处理数据和生成随机数中更全面地讨论。接下来是对范围的快速介绍,以便我们能够继续讨论for循环。

看一下使用范围的以下代码行:

val rangeOfNumbers = 1..4 

发生的情况是,我们使用类型推断来创*一个值的列表,其中包含值 1、2、3 和 4。

我们还可以显式声明和初始化一个列表,如下面的代码所示:

val rangeOfNumbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

上面的代码使用listOf关键字来显式创*一个包含 1 到 10 的数字的列表。

在我们学*关于数组的更深入的知识时,我们将更深入地探讨它们的工作原理,第十五章处理数据和生成随机数。然后,我们将看到范围、数组和列表比我们在这里涵盖的要多得多。通过查看for循环,这个快速介绍有助于我们完成对循环的讨论。

For 循环

要使用for循环,我们需要一个范围或列表。然后,我们可以使用for循环来遍历该列表,并在每一步执行一些代码;看一下以下示例:

// We could do this...
// val list = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// It is much quicker to do this...
val list = 1..10
for (i in list)
  Log.i("Looping through list","Current value is $i")

看一下如果将此内容复制并粘贴到应用程序中会产生的输出:

Looping through list: Current value is 1
Looping through list: Current value is 2
Looping through list: Current value is 3
Looping through list: Current value is 4
Looping through list: Current value is 5
Looping through list: Current value is 6
Looping through list: Current value is 7
Looping through list: Current value is 8
Looping through list: Current value is 9
Looping through list: Current value is 10

从输出中可以看出,list变量确实包含从 1 到 10 的所有值。在每次循环中,i变量保存当前值。您还可以看到,for循环允许我们遍历所有这些值,并根据这些值执行一些代码。

此外,当我们希望循环包含多行代码时,可以在for循环中使用开放和关闭的大括号:

for (i in list){
  Log.i("Looping through list","Current value is $i")
   // More code here
  // etc.
}

在 Kotlin 中,for循环非常灵活,可以处理的不仅仅是简单的Int值。在本章中,我们不会探讨所有选项,因为我们需要先了解更多关于类的知识。然而,在本书的其余部分,我们将在许多地方回到for循环。

使用breakcontinue控制循环

刚刚讨论了通过代码控制循环的所有方法,重要的是要知道,有时我们需要提前退出循环,而不是按照循环的条件指定的那样执行。

对于这种情况,Kotlin 有break关键字。以下是breakwhile循环中的作用:

var countDown = 10
while(countDown > 0){

  if(countDown == 5)break

  Log.i("countDown =","$countDown")
  countDown --
}

在上面的代码中,while循环的条件应该使代码在countDown变量大于零时重复执行。然而,在while循环内部,有一个if表达式,检查countDown是否等于 5。如果等于 5,则使用break语句。此外,在while循环内部,countDown的值被打印到 logcat 窗口,并递减(减少 1)。当执行此代码时,看一下以下输出:

countDown =: 10
countDown =: 9
countDown =: 8
countDown =: 7
countDown =: 6

从上面的输出可以看出,当countDown等于 5 时,break语句执行,执行提前退出while循环,而不会打印到 logcat 窗口。

有时,我们可能只想执行循环中的一部分代码,而不是完全停止循环。为此,Kotlin 有continue关键字。看看下面的带有while循环的代码,它演示了我们如何在应用程序中使用continue

var countUp = 0
while(countUp < 10){
  countUp++

  if(countUp > 5)continue

  Log.i("Inside loop","countUp = $countUp")
}
Log.i("Outside loop","countUp = $countUp")

在前面的代码中,我们将一个名为countUp的变量初始化为零。然后我们设置了一个while循环,当countUp小于 10 时继续执行。在while循环内部,我们增加(加 1)countUp。下一行代码检查countUp是否大于 5,如果是,就执行continue语句。下一行代码将countUp的值打印到 logcat 窗口。只有当countUp为 5 或更低时,打印值的代码行才会执行,因为continue语句将应用程序的执行返回到循环的开始。看看下面的代码输出,以验证发生了什么:

Inside loop: countUp = 1
Inside loop: countUp = 2
Inside loop: countUp = 3
Inside loop: countUp = 4
Inside loop: countUp = 5
Outside loop: countUp = 10

您可以在前面的输出中看到,当countUp的值为 5 或更低时,它被打印出来。一旦它的值超过 5,continue语句将阻止执行打印的代码行。然而,循环外的最后一行代码打印了countUp的值,你可以看到它的值是 10,这表明循环中的第一行代码,即增加countUp的代码,一直执行到while循环条件完成。

breakcontinue关键字也可以用在for循环和do-while循环中。

示例代码

如果你想玩转循环代码,可以创*一个名为Loops Demo的新项目,并将本章中的任何代码复制到onCreate函数的末尾。我已经将我们在讨论循环时使用的代码放在了Chapter08/Loops Demo文件夹中。

总结

在本章中,我们使用ifelsewhen来做出表达式的决策并分支我们的代码。我们看到并练*了whilefordo-while来重复我们代码的部分。此外,我们使用break在条件允许之前跳出循环,并使用continue有条件地执行循环中的部分代码。

如果你不记得所有内容也没关系,因为我们将不断地在整本书中使用所有这些技术和关键字。我们还将探索一些更高级的使用这些技术的方法。

在下一章中,我们将更仔细地研究 Kotlin 函数,这是我们所有测试和循环代码的去处。

第九章:Kotlin 函数

函数是我们应用程序的构*模块。我们编写执行特定任务的函数,然后在需要执行该特定任务时调用它们。由于我们应用程序中需要执行的任务将会非常多样化,我们的函数需要适应这一点并且非常灵活。Kotlin 函数非常灵活,比其他与 Android 相关的语言更灵活。因此,我们需要花费一个完整的章节来学*它们。函数与面向对象编程密切相关,一旦我们理解了函数的基础知识,我们就能够很好地掌握更广泛的面向对象编程学*。

这是本章的内容:

  • 函数基础和回顾

  • 函数返回类型和 return 关键字

  • 单表达式函数

  • 默认参数

  • 更多与函数相关的主题

我们已经对函数有了一些了解,所以需要回顾一下。

函数基础和回顾

我们已经看到并使用了函数。一些是由 Android API 为我们提供的,比如onCreate和其他生命周期函数。

我们自己编写了其他函数;例如,topClickbottomClick。但是,我们还没有适当地解释它们,函数还有更多我们还没有看到的内容。

注意

您经常会听到另一个与函数密切相关且几乎与函数同义的术语。如果您之前学过 Java 或其他面向对象的语言,情况就是如此。我指的是方法。从技术角度来看,方法和函数之间的区别很少重要,部分区别在于我们的代码中声明函数/方法的位置。如果您想在编程上正确,可以阅读这篇文章,其中深入探讨了这个问题,并提供了多种观点:

stackoverflow.com/questions/155609/whats-the-difference-between-a-method-and-a-function

在本书中,我将所有方法/函数都称为函数。

基本函数声明

这是一个非常简单函数的例子:

fun printHello(){ 
  Log.i("Message=","Hello") 
}

我们可以这样调用printHello函数:

printHello()

结果将在 logcat 窗口中输出如下:

Message=: Hello

函数的第一行是声明,大括号内包含的所有代码是函数的主体。我们使用fun关键字,后面跟着函数的名称,然后是一个开括号和闭括号。名称是任意的,但最好使用描述函数功能的名称。

函数参数列表

声明可以采用多种形式,这给了我们很大的灵活性和权力。让我们看一些更多的例子:

fun printSum(a: Int, b: Int) { 
  Log.i("a + b = ","${a+b}") 
}

前面的printSum函数可以这样调用:

printSum(2, 3)

调用printSum函数的结果是,以下消息将输出到 logcat 窗口:

a + b =: 5

请注意,传递给函数的23值是任意的。我们可以传递任何我们喜欢的值,只要它们是Int类型的。

声明的这部分(a: Int, b: Int)称为参数列表,或者只是参数。这是函数期望并需要的类型列表,以便成功执行。参数列表可以采用多种形式,任何 Kotlin 类型都可以成为参数列表的一部分,包括根本没有参数(正如我们在第一个例子中看到的)。

当我们调用带有参数列表的函数时,我们必须在调用时提供匹配的参数。以下是我们可以调用前面的printSum函数示例的几种可能方式:

val number1 = 35
val number2 = 15
printSum(9, 1)// Prints a + b: = 10
printSum(10000, 1)// Prints a + b: = 10001
printSum(number1, number2)// Prints a + b: = 50
printSum(65, number1)// Prints a + b: = 100

如前面的例子所示,任何组合的值,其总和为两个Int值,都可以作为参数。我们甚至可以使用表达式作为参数,只要它们等于一个Int值。例如,这个调用也是可以的:

printSum(100 - 50, number1 + number2)// Prints a + b = 100

在上一个示例中,从 100 中减去 50,将结果(50)作为第一个参数传递,然后将number1加到number2,并将结果作为第二个参数传递。

这里是另外两个带有各种参数的函数,以及我们可能调用它们的示例:

// These functions would be declared(typed) 
// outside of other functions
// As we did for topClick and bottomClick
fun printName(first: String, second: String){
  Log.i("Joined Name =","$first $second")
}

fun printAreaCircle(radius: Float){
  Log.i("Area =","${3.14 * (radius *radius)}")
}
//…
// This code calls the functions
// Perhaps from onCreate or some other function
val firstName = "Gabe"
val secondName = "Newell"

// Call function using literal String
printName("Sid","Meier")

// Call using String variables
printName(firstName, secondName)

// If a circle has a radius of 3 
// What is the area
printAreaCircle(3f)

在讨论代码之前,让我们看一下我们从中得到的输出:

Joined Name =: Sid Meier
Joined Name =: Gabe Newell
Area =: 28.26

在上面的代码中,我们声明了两个函数。第一个叫做printName,它有两个String参数。声明与突出显示的参数名称再次显示如下。名称是任意的,但使用有意义的名称将使代码更容易理解:

fun printName(first: String, second: String){
  Log.i("Joined Name =","$first $second")
}

尝试使用除两个String值以外的任何内容调用该函数将导致错误。当我们调用这个函数时,firstsecond参数被初始化为变量,然后我们使用字符串模板将连接的名称打印到 logcat 窗口中。下面再次显示了实现这一点的代码行,其中突出显示了变量:

Log.i("Joined Name =","$first $second")

请注意代码中$first$second之间的空格。请注意,这个空格也存在于我们之前看到的输出中。

第二个函数是printAreaCircle。它有一个名为radiusFloat参数。这里是它的声明,以便参考:

fun printAreaCircle(radius: Float){
  Log.i("Area =","${3.14 * (radius * radius)}")
}

该函数使用初始化为函数调用时的radius变量,使用公式3.14 * (radius * radius)来计算圆的面积。

然后,代码继续调用第一个函数两次,第二个函数一次。以下是代码片段中再次显示的内容(为了便于理解,已删除了有用的注释):

val firstName = "Gabe"
val secondName = "Newell"

printName("Sid","Meier")
printName(firstName, secondName)

printAreaCircle(3f)

请注意,我们可以使用文字值或变量调用函数,只要它们是与声明的参数匹配的正确类型。

要清楚地说明,函数声明位于任何其他函数之外,但位于类的开放和关闭大括号内。函数调用位于onCreate函数内。随着我们的应用程序变得更加复杂,我们将从代码的各个部分调用函数(甚至是其他代码文件)。onCreate函数只是一个方便的地方,用于讨论这些主题。

提示

如果您想更仔细地检查代码结构,包含此代码的文件位于Chapter09/Functions Demo文件夹中。创*一个新的 Empty Activity 项目,您可以复制并粘贴代码进行操作。

另一个观点,可能显而易见,但很值得一提的是,当我们为真实应用编写函数时,它们可以包含尽可能多的代码;它们不会像这些示例一样只是一行代码。我们在之前章节学到的任何代码都可以放入我们的函数中。

现在,让我们继续讨论另一个与函数相关的主题,它给我们更多的选择。

返回类型和返回关键字

我们经常需要从函数中获得一个结果。仅仅让函数知道结果是不够的。函数可以声明具有返回类型。看看下一个函数声明:

fun getSum(a: Int, b: Int): Int { 
  return a + b 
}

在上面的代码中,看一下参数列表的括号后面的突出部分。:Int代码表示函数可以并且必须向调用它的代码返回Int类型的值。函数体内的代码行使用return关键字来实现这一点。return a + b代码返回ab的和。

我们可以像调用没有返回类型的函数一样调用getSum函数:

getSum(10, 10)

上面的代码行可以工作,但有点无意义,因为我们没有对返回的值做任何处理。下面的代码显示了更有可能的对getSum函数的调用:

val answer = getSum(10, 10)

在上述函数中,从函数返回的值用于初始化answer变量。由于返回类型是Int,Kotlin 推断answer也是Int类型。

我们还可以以其他方式使用getSum——下面显示了一个示例:

// Print out the returned value
Log.i("Returned value =","${getSum(10, 10)}")

前面的代码以另一种方式使用了getSum函数,通过使用字符串模板打印返回的值到 logcat 窗口。

任何类型都可以从函数中返回。以下是一些例子;首先是声明,然后是一些我们可能调用它们的方式:

// Return the area of the circle to the calling code
fun getAreaCircle(radius: Float): Float{
  return 3.14f * (radius * radius)
}

// Return the joined-up String to the calling code
fun getName(first: String, second: String): String{
  return "$first $second"
}

// Now we can call them from elsewhere in the code
Log.i("Returned area =","${getAreaCircle(3f)}")
Log.i("Returned name =","${getName("Alan","Turing")}")

以下是这两个函数调用将产生的输出:

Returned area =: 28.26
Returned name =: Alan Turing

我们可以看到圆的面积被检索并打印出来,名字被连接在一起被检索并打印出来。

注意

作为一个快速的健全检查,值得指出的是,我们实际上不需要编写函数来将数字相加或连接字符串。这只是一种展示函数各个方面的有用方式。

值得注意的是,即使函数没有返回类型,return关键字也有其用途。

例如,我们可以使用return关键字提前从函数返回。我们之前的所有函数示例(没有返回类型)在主体中的最后一行代码执行时自动返回到调用代码。下面是一个使用return关键字的例子:

fun printUpTo3(aNumber: Int){ // No return type!
  if(aNumber > 3){
    Log.i("aNumber is","TOO BIG! - Didn't you read my name")
    return // Going back to the calling code
  }

  Log.i("aNumber is","$aNumber")
}

// And now we call it with a few different values
printUpTo3(1)
printUpTo3(2)
printUpTo3(3)
printUpTo3(4)

看看我们运行前面的代码时的输出,然后我们将讨论它是如何工作的:

aNumber is: 1
aNumber is: 2
aNumber is: 3
aNumber is: TOO BIG! - Didn't you read my name

在函数体中,if表达式检查aNumber是否大于三,如果是,就打印一个不满的评论,并使用return关键字返回到调用代码,并避免将值打印到 logcat。从程序输出中,我们可以看到当aNUmber为一、二或三时,它被printUpTo3函数忠实地打印出来,但是一旦我们传入了四的值,我们得到了另一种结果。

函数体和单表达式函数

函数体可以是复杂的或简单的,我们需要它是什么样的。到目前为止,我展示的所有例子都是故意过于简单,这样我们就可以专注于函数本身而不是其中的代码。随着本书在更多真实世界的例子中的进展,我们将看到函数体中的代码变得更长更复杂。然而,函数体应该坚持执行一个特定的任务。如果你有一个函数在 Android Studio 中占据了整个屏幕,那很可能是它应该被拆分成多个函数的迹象。

当函数的主体非常简单,只包含一个表达式时,Kotlin 允许我们使用单表达式语法来缩短代码。例如,getSum函数可以改为以下代码:

fun getSum(a: Int, b: Int) = a + b

在前面的例子中,我们去掉了通常包裹在函数体中的花括号,并推断了返回类型,因为将a加到b只能得到一个Int变量,因为ab本身就是Int变量。

使函数灵活

由于函数是我们代码的构*块,它们需要具有多样性,以满足我们可能需要做的任何事情。我们已经看到了如何创*非常多样的参数列表和返回类型,以及在代码中决定何时返回到调用代码。随着我们的进展,你会发现我们需要更多的选项。接下来是一些我们现在将介绍的更多 Kotlin 函数选项的快速概览,然后在本书的各个部分真正开始使用。

默认和命名参数

默认参数是指我们程序员为参数提供一个值(默认值),如果调用函数的代码没有提供该值,则将使用该值。命名参数是指调用函数的代码指定名称以及值。请注意,提供值是可选的。只因为为参数提供了默认值并不意味着调用代码不能通过提供值来覆盖它。看下面的例子:

fun orderProduct(giftWrap: Boolean = false,
                product: String,
                postalService: String = "Standard") {

   var details: String = ""

   if (giftWrap) {
       details += "Gift wrapped "
   }

   details += "$product "
   details += "by $postalService postage"

   Log.i("Product details",details)
}

// Here are some ways we can call this function
orderProduct(product = "Beer")
orderProduct(true, product = "Porsche")
orderProduct(true, product = "Barbie (Jet-Set Edition)", postalService = "Next Day")

orderProduct(product = "Flat-pack bookcase", 
   postalService = "Carrier Pigeon")

在前面的代码中,我们首先声明了一个名为orderProduct的函数。请注意,在参数列表中,我们声明了两个默认值,如下所示:

fun orderProduct(giftWrap: Boolean = false,
       product: String,
       postalService: String = "Standard") {

当我们调用函数时,可以在不指定giftwrap和/或postalService的情况下这样做。以下代码中的第一个函数调用清楚地表明了这一点:

orderProduct(product = "Beer")

请注意,当我们这样做时,需要指定参数的名称,它必须与参数列表中的名称以及类型匹配。在第二个函数调用中,我们为giftwrapproduct指定了一个值:

orderProduct(true, product = "Porsche")

在第三个中,我们为所有三个参数指定了一个值,如下面的代码中再次看到的:

orderProduct(true, product = "Barbie (Jet-Set Edition)",
   postalService = "Next Day")

最后,在第四个中,我们指定了最后两个参数:

orderProduct(product = "Flat-pack bookcase", 
   postalService = "Carrier Pigeon")

函数本身的代码从声明一个名为detailsvar变量开始,它是一个String值。如果giftwrap的值为 true,则将Gift Wrapped附加到Product details。接下来,将product的值附加到details,最后将postalService的值与字面String值附加在两侧。

如果我们运行代码,这是在 logcat 窗口中的输出:

Product details: Beer by Standard postage
Product details: Gift wrapped Porsche by Standard postage
Product details: Gift wrapped Barbie (Jet-Set Edition) 
 by Next Day postage
Product details: Flat-pack bookcase by Carrier Pigeon postage

我们可以以多种方式调用函数,这非常有用。在其他编程语言中,当您希望以不同方式调用相同命名的函数时,必须提供多个版本的函数。虽然学*命名参数和默认参数可能会增加一些复杂性,但它肯定比不得不编写orderProduct函数的四个版本要好。这,连同类型推断,只是您经常会听到程序员赞扬 Kotlin 简洁性的两个原因之一。

使用命名参数和默认参数,我们可以选择提供尽可能多或尽可能少的数据,只要函数允许。简而言之,如果我们提供了所有没有默认值的参数的值,它将起作用。

提示

如果您想要使用这段代码进行操作,那么本章中的所有示例都在Chapter09文件夹中。创*一个空活动项目,然后将函数复制粘贴到MainActivity类中,将函数调用复制粘贴到onCreate函数中。

在我们进行这些操作时,会有一些注意事项,随着我们在整本书中进行更多的实际示例,我们将会看到它们。

更多关于函数的内容

函数还有更多内容,例如顶级函数、局部函数和可变参数函数,以及函数访问级别,但最好是在类和面向对象编程的主题旁边或之后讨论这些内容。

总结

在本章中,我们在学*函数方面取得了良好的进展。虽然函数自第一章以来一直潜伏在我们的代码中,但我们终于正式学*和理解了它们。我们了解了函数的不同部分:名称、参数和返回类型。我们已经看到函数实际上是在开放和关闭的大括号内部执行的,称为函数体。

我们还看到,我们可以使用return关键字随时从函数中返回,并且我们还可以将返回类型与return关键字结合使用,以使函数中的数据可用于首次调用函数的代码。

我们学会了如何使用默认和命名参数来提供同一函数的不同版本,而无需编写多个函数。这使我们的代码更加简洁和可管理。

我们还发现,在本章中,函数还有更多内容,但最好是在整本书中的各种项目中学*这些主题。

接下来,我们将转向最受关注的章节。我一直在参考和推迟到第十章,“面向对象编程”。最后,它来了,我们将看到类和对象与 Kotlin 结合的真正力量。在接下来的几章中,我们很快就会看到类和对象是释放 Android API 力量的关键。我们很快就能让我们的用户界面栩栩如生,并且将构*一些真正可用的应用程序,我们可以发布到 Play 商店。

第十章:面向对象编程

在本章中,我们将发现,在 Kotlin 中,类对几乎所有事情都是基础的,实际上,几乎所有事情都是一个类。

我们已经谈到了重用他人的代码,特别是 Android API,但在本章中,我们将真正掌握这是如何工作的,并学*面向对象编程OOP)以及如何使用它。

在本章中,我们将涵盖以下主题:

  • 介绍 OOP 和封装、多态和继承的三个关键主题

  • 基本类,包括如何编写我们的第一个类,包括为数据/变量封装添加属性和函数以完成任务

  • 探索可见性修饰符,进一步帮助和完善封装。

  • 了解构造函数,使我们能够快速准备我们的类以转换为可用的对象/实例

  • 编写一个基本的类小应用程序,以实践我们在本章学到的一切

如果你试图记住本章(或下一章),你将不得不在你的大脑中腾出很多空间,而且你可能会忘记一些非常重要的东西。

一个很好的目标是尽量理解它。这样,你的理解将变得更加全面。在需要时,你可以参考本章(和下一章)进行复*。

提示

如果你对本章或下一章的内容并不完全理解也没关系!继续阅读,并确保完成所有的应用程序。

介绍 OOP

在第一章中,开始使用 Android 和 Kotlin,我们提到 Kotlin 是一种面向对象的语言。面向对象的语言要求我们使用 OOP;这不是可选的额外部分,而是 Kotlin 的一部分。

让我们多了解一点。

OOP 到底是什么?

OOP 是一种编程方式,它涉及将我们的需求分解成比整体更易管理的块。

每个块都是自包含的,并且可能被其他程序重用,同时与其他块一起工作。

这些块就是我们所说的对象。当我们计划/编写一个对象时,我们使用一个类。类可以被看作是对象的蓝图。

我们实现了一个类的对象。这被称为类的实例。想想一个房子的蓝图——你不能住在里面,但你可以*造一座房子;所以,你*造了它的一个实例。通常,当我们为我们的应用程序设计类时,我们写它们来代表现实世界的事物。

然而,OOP 不仅仅是这样。它也是一种做事情的方式——一种定义最佳实践的方法。

OOP 的三个核心原则是封装多态继承。这些听起来可能很复杂,但一步一步来说,都是相当简单的。

封装

封装意味着通过允许你选择的变量和函数来访问,使你的代码的内部工作免受使用它的代码的干扰。

这意味着你的代码可以随时更新、扩展或改进,而不会影响使用它的程序,只要暴露的部分仍然以相同的方式访问。

你可能还记得来自第一章的这行代码,开始使用 Android 和 Kotlin

locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)

通过适当的封装,如果卫星公司或 Android API 团队需要更新他们的代码工作方式,也不要紧。如果getLastKnownLocation函数签名保持不变,我们就不必担心内部发生了什么。我们在更新之前编写的代码在更新后仍将正常工作。

如果一辆汽车的制造商去掉了车轮,将其变成了电动悬浮汽车,如果它仍然有方向盘、油门和刹车踏板,驾驶它不应该是一个挑战。

当我们使用 Android API 的类时,我们是按照 Android 开发人员设计他们的类的方式来使用的。

在本章中,我们将深入探讨封装。

多态性

多态性使我们能够编写的代码不太依赖于我们试图操作的类型,使我们的代码更清晰、更高效。多态性意味着多种形式。如果我们编码的对象可以是多种类型的东西,那么我们就可以利用这一点。一些未来的例子将会让这一点更加清晰。类比会让你更加真实地理解。如果我们有汽车工厂,只需改变给机器人的指令和装配线上的零件,就可以制造货车和小型卡车,那么这个工厂就是多态的。

如果我们能够编写能够处理不同类型数据的代码而无需重新开始,这不是很有用吗?我们将在第十一章中看到一些例子,Kotlin 中的继承

我们还将在第十二章中了解更多关于多态性的内容,Kotlin 与 UI 和空值的连接

继承

正如它听起来的那样,继承意味着我们可以利用其他人的类的所有特性和好处(包括封装和多态性),同时进一步调整他们的代码以适应我们的情况。实际上,我们已经这样做了,每次使用:运算符时:

class MainActivity : AppCompatActivity() {

AppCompatActivity类本身继承自Activity。因此,每次创*新的 Android 项目时,我们都继承自Activity。我们可以做得更多,我们将看到这是如何有用的。

想象一下,世界上最强壮的男人和最聪明的女人在一起。他们的孩子很有可能会从基因遗传中获得重大好处。Kotlin 中的继承让我们可以用另一个人的代码和我们自己的代码做同样的事情。

我们将在下一章中看到继承的实际应用。

为什么要这样做?

当小心使用时,所有这些面向对象编程允许你添加新功能,而不太担心它们如何与现有功能交互。当你必须更改一个类时,它的自包含(封装)性质意味着对程序的其他部分的影响较小,甚至可能为零。这就是封装的部分。

你可以使用其他人的代码(如 Android API),而不知道甚至可能不关心它是如何工作的。想想一下 Android 生命周期、ToastLog、所有的 UI 小部件、监听卫星等等。我们不知道,也不需要知道它们内部是如何工作的。更详细的例子是,Button类有将近 50 个函数 - 我们真的想要为一个按钮自己写这么多吗?最好使用别人的Button类。

面向对象编程使你能够轻松地为高度复杂的情况编写应用程序。

通过继承,你可以创*类的多个相似但不同的版本,而无需从头开始编写类,并且由于多态性,你仍然可以使用原始类型对象的函数来处理新对象。

这真的很有道理。而且 Kotlin 从一开始就考虑到了所有这些,所以我们被迫使用所有这些面向对象编程 - 然而,这是一件好事。让我们快速回顾一下类。

类回顾

类是一堆代码的容器,可以包含函数、变量、循环和我们已经学过的其他 Kotlin 语法。类是 Kotlin 包的一部分,大多数包通常会有多个类。通常情况下,尽管不总是如此,每个新类都将在其自己的.kt代码文件中定义,文件名与类名相同,就像我们迄今为止所有基于活动的类一样。

一旦我们编写了一个类,我们就可以使用它来创*任意数量的对象。记住,类是蓝图,我们根据蓝图制作对象。房子不是计划,就像对象不是类一样-它是从类制作的对象。对象是一个引用变量,就像一个字符串,稍后我们将发现引用变量的确切含义。现在,让我们看一些实际的代码。

基本类

类涉及两个主要步骤。首先,我们必须声明我们的类,然后我们可以通过实例化它将其变成一个实际可用的对象。记住,类只是一个蓝图,你必须使用蓝图来构*一个对象,然后才能对其进行任何操作。

声明类

类可以根据其目的的不同而具有不同的大小和复杂性。这是一个类声明的绝对最简单的例子。

记住,我们通常会在一个与类同名的文件中声明一个新的类。

注意

在本书的其余部分,我们将介绍一些例外情况。

让我们看看声明类的三个例子:

// This code goes in a file named Soldier.kt
class Soldier

// This code would go in a file called Message.kt
class Message

// This code would go in a file called ParticleSystem.kt
class ParticleSystem

提示

请注意,我们将在本章结束时进行一个完整的工作项目练*。在下载包的Chapter10/Chapter Example Classes文件夹中,还有本章中所有理论示例的完整类。

在上面的代码中要注意的第一件事是,我已经将三个类声明合并在一起。在真实的代码中,每个声明都应该包含在自己的文件中,文件名与类名相同,扩展名为.kt

要声明一个类,我们使用class关键字,后面跟着类的名称。因此,我们可以得出结论,在前面的代码中,我们声明了一个名为Soldier的类,一个名为Message的类,以及一个名为ParticleSystem的类。

我们已经知道,类可以并且经常模拟现实世界的事物。因此,可以安全地假设这三个假设的类将模拟一个士兵(也许来自游戏)、一条消息(也许来自电子邮件或短信应用程序)和一个粒子系统(也许来自科学模拟应用程序)。

注意

粒子系统是一个包含个体粒子的系统,这些粒子作为该系统的一部分。在计算中,它们用于模拟/可视化化学反应/爆炸和粒子行为,也许是烟雾等事物。在第二十一章中,线程和启动实时绘图应用程序,我们将构*一个使用粒子系统使用户的绘画看起来活灵活现的酷炫绘图应用程序。

然而,很明显,像我们刚刚看到的三个简单声明并不包含足够的代码来实现任何有用的功能。我们将在一会儿扩展类声明。首先,让我们看看如何使用我们声明的类。

实例化类

要从我们的类构*一个可用的对象,我们需要转到另一个代码文件。到目前为止,在整本书中,我们已经使用AppCompatActivity类中的onCreate函数来演示不同的概念。虽然你可以在 Android 的任何地方实例化一个类,但由于生命周期函数的存在,通常会使用onCreate来实例化我们的类的对象/实例。

看一下以下代码。我已经突出了要关注的新代码:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

         // Instantiating one of each of our classes
             val soldier = Soldier()
 val message = Message()
 val particleSystem = ParticleSystem()    

   } // End of onCreate function

}// End of MainActivity class

在前面的代码中,我们实例化了三个先前声明的类的实例(创*了一个可用的对象)。让我们更仔细地研究一下语法。这是实例化Soldier类的代码行:

val soldier = Soldier()

首先,我们决定是否需要更改我们的实例。与常规变量一样,我们选择valvar。接下来,我们给我们的实例命名。在前面的代码中,对象/实例被称为soldier,但我们也可以称之为soldierXmarinejohn117,甚至squashedBanana。名称是任意的,但与变量一样,给它们起一个有意义的名字是有意义的。此外,与变量一样,按照惯例,但不是必须的,以小写字母开头的名称和名称中的任何后续单词的首字母大写。

注意

在使用它们来声明类的实例时,valvar之间的区别更加微妙和重要。我们将首先学*有关类的细节,在第十二章中,将我们的 Kotlin 连接到 UI 和可空性,我们将重新讨论valvar,以了解我们的实例底层发生了什么。

代码的最后部分包含赋值运算符=,后面跟着类名Soldier,以及一对开放和关闭的括号()

赋值运算符告诉 Kotlin 编译器将代码右侧的结果赋给左侧的变量。类型推断确定soldierSoldier类型。

类名后面那个看起来奇怪但也许熟悉的()暗示着我们在调用一个函数。我们确实在调用一个特殊的函数,称为构造函数,它是由 Kotlin 编译器提供的。关于构造函数有很多要讨论的,所以我们将把这个话题推迟到本章稍后。

现在,我们只需要知道,下一行代码创*了一个名为soldierSoldier类型的可用对象:

val soldier = Soldier()

记住,面向对象编程的目标之一是我们可以重用我们的代码。我们不仅限于只有一个Soldier类型的对象。我们可以有任意多个。看看下面的代码块:

val soldier1 = Soldier()
val soldier2 = Soldier()
val soldier3 = Soldier()

soldier1soldier2soldier3实例都是独立的、不同的实例。它们都是同一类型 - 但这是它们唯一的联系。你和你的邻居可能都是人类,但你们不是同一个人。如果我们对soldier1做了什么,或者改变了soldier1的某些东西,那么这些操作只会影响soldier1soldier2soldier3实例不受影响。事实上,我们可以实例化一整支Soldier对象的军队。

面向对象编程的力量正在慢慢显现,但在我们讨论的这个阶段,房间里的大象是,我们的类实际上并没有做任何事情。此外,我们的实例不持有任何值(数据),因此我们也无法对它们进行任何更改。

类有函数和变量(有点)

当我们在本章后面的类变量是属性部分时,我将很快解释略微神秘的(有点)标题。

我们在讨论 Kotlin 时学到的任何代码都可以作为类的一部分使用。这就是我们使我们的类有意义,使我们的实例真正有用的方法。让我们扩展类声明并添加一些变量和函数。

使用类的变量

首先,我们将向我们空的Soldier类添加一些变量,就像下面的代码一样:

class Soldier{

    // Variables
    val name = "Ryan"
    val rank = "Private"
    val missing = true
}

记住,所有前面的代码都将放在一个名为Soldier.kt的文件中。现在我们有了一个带有一些成员变量的类声明,我们可以像下面的代码中所示那样使用它们:

// First declare an instance of Soldier called soldier1
val soldier1 = Soldier()

// Now access and print each of the variables  
Log.i("Name =","${soldier1.name}")
Log.i("Rank =","${soldier1.rank}")
Log.i("Missing =","${soldier1.missing}")

如果将代码放在onCreate函数中,将在 logcat 窗口中产生以下输出:

Name =: Ryan
Rank =: Private
Missing =: true

在前面的代码中,我们以通常的方式实例化了Soldier类的一个实例。但现在,因为Soldier类有一些带有值的变量,我们可以使用点语法来访问这些值:

instanceName.variableName

或者,我们可以通过使用这个具体的例子来访问这些值:

soldier1.name
soldier1.rank
// Etc..

要清楚的是,我们使用实例名称,而不是类名称:

Soldier.name // ERROR!

提示

通常情况下,我们将在继续进行时涵盖一些例外和变化。

如果我们想要更改变量的值,我们可以使用完全相同的点语法。当然,如果你回想起第七章中讲到的,Kotlin 变量、运算符和表达式,可以更改的变量需要声明为var,而不是val。这是重新设计的Soldier类,以便我们可以稍微不同地使用它:

class Soldier{

    // Member variables
    var name = "Ryan"
    var rank = "Private"
    var missing = true
}

现在,我们可以使用点语法来操纵变量的值,就像它们是常规的var变量一样:

// First declare an instance of Soldier called soldier1
val soldier1 = Soldier()

// Now access and print each of the variables  
Log.i("Name =","${soldier1.name}")
Log.i("Rank =","${soldier1.rank}")
Log.i("Missing =","${soldier1.missing}")

// Mission to rescue Private Ryan succeeds
soldier1.missing = false;

// Ryan behaved impeccably
soldier1.rank = "Private First Class"

// Now access and print each of the variables  
Log.i("Name =","${soldier1.name}")
Log.i("Rank =","${soldier1.rank}")
Log.i("Missing =","${soldier1.missing}")

前面的代码将在 logcat 窗口中产生以下输出:

Name =: Ryan
Rank =: Private
Missing =: true
Name =: Ryan
Rank =: Private First Class
Missing =: false

在前面的输出中,首先我们看到与之前相同的三行,然后我们看到另外三行,表明 Ryan 不再失踪,并且已经晋升为列兵

使用类的函数和变量

现在我们可以给我们的类提供数据,是时候通过给它们一些可以做的事情来使它们更有用了。为了实现这一点,我们可以给我们的类提供函数。看一下Soldier类的这段扩展代码。我已经将变量恢复为val并突出显示了新代码:

class Soldier{

    // members
    val name = "Ryan"
    val rank = "Private"
    val missing = true

    // Class function
 fun getStatus() {
 var status = "$rank $name"
 if(missing){
 status = "$status is missing!"
 }else{
 status = "$status ready for duty."
 }

 // Print out the status
 Log.i("Status",status)
 }
}

getStatus函数中的代码声明了一个名为status的新String变量,并使用rankname中包含的值对其进行初始化。然后,它使用if表达式检查missing中的值,并根据missingtrue还是false附加is missingready for duty

然后我们可以像下面的代码演示的那样使用这个新函数:

val soldier1 = Soldier()
soldier1.getStatus()

与之前一样,我们创*了Soldier类的一个实例,然后在该实例上使用点语法调用getStatus函数。前面的代码将在 logcat 窗口中产生以下输出:

Status: Private Ryan is missing!

如果我们将missing的值更改为false,将产生以下输出:

Status: Private Ryan ready for duty.

请注意,类中的函数可以采用我们在第九章中讨论过的任何形式,Kotlin 函数

如果你认为所有这些类的东西都很棒,但同时似乎有点僵化和不灵活,那么你是正确的。如果所有Soldier实例都叫 Ryan 并且都失踪,那有什么意义呢?当然,我们已经看到我们可以使用var变量然后更改它们,但这可能仍然很尴尬和冗长。

我们需要更好地操纵和初始化每个实例中的数据的方法。如果我们回想一下本章开头时我们简要讨论了封装的主题,那么我们也会意识到我们不仅需要允许代码操纵我们的数据,还需要控制这种操纵何时以及如何进行。

为了获得这些知识,我们需要更多地了解类中的变量,然后更详细地了解封装和可见性,最后揭示当我们实例化类的实例时,在代码末尾看到的那些类似函数的括号()到底是什么意思。

类变量是属性

原来在 Kotlin 中,类变量不仅仅是我们已经了解的普通变量。它们是属性。到目前为止,我们已经学到的关于如何使用变量的一切仍然成立,但是属性比值更多。它有gettersetter,以及一个特殊的类变量称为field隐藏在幕后。

Getter 和 setter 可以被视为编译器自动生成的特殊函数。事实上,我们已经在不知情的情况下使用了它们。

当我们在类中声明的属性/变量上使用点语法时,Kotlin 使用 getter 来“获取”值。当我们使用点语法设置值时,Kotlin 使用 setter。

当我们使用刚刚看到的点语法时,并不直接访问字段/变量本身。这种抽象的原因是为了帮助封装。

如果你之前在其他面向对象的语言(也许是 Java 或 C++)中做过一些编程,这可能会让你感到困惑,但如果你使用过更现代的面向对象语言(也许是 C#),那么这对你来说不会是全新的。如果 Kotlin 是你的第一门语言,那么你可能比有过往经验的人更有优势,因为你不会受到以前学*的包袱。

而且,你可能会猜到,如果变量是var,那么会提供一个 getter 和一个 setter,但如果是val,那么只会提供一个 getter。因此,当Soldier类中的变量(我们从现在开始大多数时候称之为属性)是var时,我们可以获取和设置它们,但当它们是val时,我们只能获取它们。

Kotlin 给了我们灵活性来重写这些 getter 和 setter,以改变当我们获取和设置属性及其关联字段的值时发生的情况。

提示

当属性使用字段时,它被称为后备字段。正如我们将看到的,一些属性不需要后备字段,因为它们可以依赖于 getter 和 setter 中的逻辑来使它们有用。

在这一点上,使用字段的一些示例将使事情更清晰。

使用带有 getter、setter 和字段的属性的示例

我们可以使用 getter 和 setter 来控制可以分配给其后备字段的值范围。例如,考虑将下一行代码添加到Soldier类中:

var bullets = 100
get() {
   Log.i("Getter being used","Value = $field")
   return field
}
set(value) {
   field = if (value < 0) 0 else value
   Log.i("Setter being used","New value = $field")
}

前面的代码添加了一个名为bullets的新var属性,并将其初始化为 100。然后我们看到一些新代码。getter 和 setter 被重写了。去掉 getter 和 setter 中的代码,以便以最简单的形式看到其运行:

get() {
   //.. Executes when we try to retrieve the value
}
set(value) {
   //.. Executes when we try to set the value 
}

明确一点,在访问Soldier类的实例中的bullet值时,getter 和 setter 中的代码会执行。看看下面的代码中可能会发生的情况:

// In onCreate or some other function/class from our app
// Create a new instance of the Soldier class
val soldier = Soldier()
// Access the value of bullets
Log.i("bullets = ","${soldier.bullets}")// Getter will execute
// Reduce the number of bullets by one
soldier.bullets --
Log.i("bullets =","${soldier.bullets}")// Setter will execute

在前面的代码中,我们首先创*了Soldier类的一个实例,然后获取存储在bullet属性中的值并打印出来。这触发了 getter 代码的执行。

接下来,我们减少(减少一个)bullet属性存储的值。任何试图改变属性持有的值的操作都会触发 setter 中的代码。

如果我们执行前面的四行代码,将在 logcat 窗口中得到以下输出:

Getter being used: Value = 100
bullets =: 100
Getter being used: Value = 100
Setter being used: New value = 99
Getter being used: Value = 99
bullets =: 99

创*一个名为soldierSoldier实例后,我们使用Log.i将值打印到 logcat 窗口。由于此代码访问了属性存储的值,getter 代码运行并打印出以下内容:

Getter being used: Value = 100

然后 getter 使用下一行代码将值返回给Log.i函数:

return field

当我们创*属性时,Kotlin 创*了一个后备字段。在 getter 或 setter 中访问后备字段的方式是使用名称field。因此,前面的代码行的工作方式与在函数中的方式相同,并返回值,允许调用代码中的Log.i调用打印出值,我们将得到下一行输出:

bullets =: 100

下一行代码可能是最有趣的。这里再次提供以便参考:

soldier.bullets --

我们可能会猜想这只是触发了 setter 的执行,但是如果我们检查 logcat 中的下两行输出,我们会看到生成了以下两行输出:

Getter being used: Value = 100
Setter being used: New value = 99

减少(或增加)的操作需要使用 getter(知道要减少多少)然后使用 setter 来改变值。

请注意,setter 有一个名为value的参数,我们可以在 setter 的主体中引用它,就像普通的函数参数一样。

接下来,实例被用来输出bullets属性所持有的值,我们可以看到再次使用了 getter,并且输出是由类中的 getter 代码和实例(类外部)中的代码生成的。接下来再次显示最后两行输出:

Getter being used: Value = 99
bullets =: 99

现在我们可以看另一个使用 getter 和 setter 的例子。

正如前面提到的,有时属性根本不需要后备字段。有时,允许 getter 和 setter 中的逻辑处理通过属性访问的值就足够了。查看下面的代码,我们可以将其添加到Soldier类中来演示这一点:

var packWeight = 150
val gunWeight = 30
var totalWeight = packWeight + gunWeight
   get() = packWeight + gunWeight

在上面的代码中,我们创*了三个属性:一个名为packWeightvar属性,我们将使用即将创*的实例来更改它,一个名为gunWeightval属性,我们永远不需要更改它,以及另一个名为totalWeightvar属性,它被初始化为packWeight + gunWeight。有趣的部分是,我们覆盖了totalWeight的 getter,以便它使用packWeight + gunWeight重新计算其值。接下来,让我们看看如何使用Soldier类的实例来使用这些新属性,然后我们将看到输出:

// Create a soldier
val strongSoldier = Soldier()

// Print out the totalWeight value
Log.i("totalWeight =","${strongSoldier.totalWeight}")

// Change the value of packWeight
strongSoldier.packWeight = 300

// Print out the totalWeight value again
Log.i("totalWeight =","${strongSoldier.totalWeight}")

在上面的代码中,我们创*了一个名为strongSoldierSoldier实例。接下来,我们将totalWeight的值打印到 logcat。第三行代码将packWeight的值更改为300,然后最后一行代码打印出totalWeight的值,它将使用我们覆盖的 getter。以下是这四行代码的输出:

totalWeight =: 180
totalWeight =: 330

从输出中我们可以看到,totalWeight的值完全取决于packWeightgunWeight中存储的值。输出的第一行是packWeight的起始值(150)加上gunWeight的值(30),第二行输出等于packWeight的新值加上gunWeight

就像函数一样,这个非常灵活的属性系统会引发一些问题。

何时使用覆盖的 getter 和 setter

何时利用这些不同的技术需要通过实践和经验来决定;关于何时适合使用特定技术并没有硬性规定。在这个阶段,只需要理解在类的主体(函数之外)声明的变量实际上是属性,而属性是通过 getter 和 setter 访问的。这些 getter 和 setter 对于实例的用户来说并不是透明的,并且除非被类的程序员覆盖,否则编译器会默认提供它们。这就是封装的本质;类的程序员控制类的工作方式。属性提供对其相关值(称为后备字段)的间接访问,尽管有时这个后备字段是不需要的。

提示

简化讨论时将属性称为变量是可以的(我有时这样做)。特别是当 getter、setter 和字段与讨论无关时。

在下一节中,我们将看到更多可以使用 getter 和 setter 的方法,所以让我们继续讨论可见性修饰符。

可见性修饰符

可见性修饰符用于控制变量、函数甚至整个类的访问/可见性。正如我们将看到的,根据代码中尝试访问的位置,可以有不同级别的访问权限的变量、函数和类。这允许类的设计者实践良好的封装,并且只向类的用户提供他们选择的功能和数据。举一个有点牵强但有用的例子,用于与卫星通信并获取 GPS 数据的类的设计者不会允许访问dropOutOfTheSky函数。

这是 Kotlin 中的四个访问修饰符。

公共

将类、函数和属性声明为public意味着它们根本不被隐藏/封装。实际上,默认可见性是public,因此到目前为止我们所见过和使用的一切都是公共的。我们可以通过在所有类、函数和属性声明之前使用public关键字来明确表示这一点,但这并不是必要的。当某物被声明为public(或保持默认状态)时,不使用封装。这只是偶尔我们想要的。通常,公开类的函数将公开类的核心功能。

私有

我们将讨论的下一个访问修饰符是private。通过在声明之前加上private关键字,属性、函数和类可以被声明为private,如下一个假设的代码所示:

private class SatelliteController {
   private var gpsCoordinates = "51.331958,0.029057"

   private fun dropOutOfTheSky() {
   }
}

SatelliteController类被声明为private,这意味着它只能在同一文件中使用(可以实例化)。尝试在onCreate中实例化一个实例可能会导致以下错误:

Private

这引发了一个问题,即类是否可以被使用。将类声明为private比使用我们将要讨论的剩余修饰符要少得多,但这确实会发生,并且有各种技术使其成为一种可行的策略。然而,更有可能的是,SatelliteController类将以更加可访问的public可见性进行声明。

继续,我们有一个名为gpsCoordinatesprivate属性。假设我们将SatelliteController类更改为公共类,那么我们就可以实例化它并继续我们的讨论。即使SatelliteController被声明为public,或者保持默认状态为public,私有的gpsCoordinates属性仍然对类的实例不可见,如下一个截图所示:

Private

正如我们在前面的截图中所看到的,gpsCoordinates属性是不可访问的,因为它是private的,正如我们在本章前面讨论属性时所看到的,当属性保持默认状态时,它是可访问的。这些访问修饰符的目的是,类的设计者可以选择何时以及何物来公开。很可能 GPS 卫星希望分享 GPS 坐标。然而,很可能它不希望类的用户在计算坐标方面起任何作用。这表明我们希望类的用户能够读取数据,但不能写入/更改数据。这是一个有趣的情况,因为第一反应可能是将属性设置为val属性。这样用户就可以获取数据,但不能更改数据。但问题是 GPS 坐标显然是会变化的,因此它需要是一个var属性,只是不希望它是一个可以从类外部更改的var属性。

当我们将属性声明为private时,Kotlin 会自动将 getter 和 setter 也设为private。我们可以通过重写 getter 和/或 setter 来改变这种行为。为了解决我们需要一个在类外部不可改变但在类内部可改变和可读的var属性的问题,我们将保留默认的 setter,使其无法在外部改变,并重写 getter,以便在外部可读。看看下面对SatelliteController类的重写:

class SatelliteController {
    var gpsCoordinates = "51.331958,0.029057"
    private set

    private fun dropOutOfTheSky() {
    }
}

在上面的代码中,SatelliteController类和gpsCoordinates属性都是public的。此外,gpsCoordinates是一个var属性,因此是可变的。然而,仔细看一下属性声明后的代码行,因为它将 setter 设置为private,这意味着类外的代码无法访问它进行更改;但因为它是一个var属性,类内的代码可以对其进行任何操作。

现在我们可以在onCreate函数中编写以下代码来使用该类:

// This still doesn't work which is what we want
// satelliteController.gpsCoordinates = "1.2345, 5.6789"

// But this will print the gpsCoordinates
Log.i("Coords=","$satelliteController.gpsCoordinates")

现在,由于代码将 setter 设置为私有,我们无法从实例更改值,但可以愉快地读取它,就像前面的代码演示的那样。请注意,setter 不能更改其可见性,但可以(正如我们在首次讨论属性时看到的)重写其功能。

继续讨论dropOutOfSky函数的功能,这是private且完全不可访问的。只有SateliteController类内部的代码才能调用该函数。如果我们希望类的用户能够访问函数,就像我们已经看到的那样,我们只需将其保留为默认可见性。SatelliteController类可能有类似下面代码的函数:

class SatelliteController {
    var gpsCoordinates = "51.331958,0.029057"
    private set

    private fun dropOutOfTheSky() {
    }

    fun updateCoordinates(){
        // Recalculate coordinates and update
        // the gpsCoordinates property
        gpsCoordinates = "21.123456, 2.654321"

        // user can now access the new coordinates
        // but still can't change them
    }
}

在前面的代码中,添加了一个公共的updateCoordinates函数。这允许类的实例使用以下代码:

satelliteController.updateCoordinates()

然后,前面的代码将触发updateCoordinates函数的执行,这将导致类内部更新属性,然后可以访问并提供新值。

这引出了一个问题:哪些数据应该是私有的?应该使用的可见性级别部分可以通过常识学*,部分通过经验学*,部分通过问自己这个问题:“谁真正需要访问这些数据以及在什么程度上?”我们将在本书的其余部分中练*这三件事。以下是一些更多的假设代码,显示了SatelliteController类的一些私有数据和更多私有函数:

class SatelliteController {
    var gpsCoordinates = "51.331958,0.029057"
    private set

    private var bigProblem = false

    private fun dropOutOfTheSky() {
    }

    private fun doDiagnostics() {
      // Maybe set bigProblem to true
      // etc
    }

    private fun recalibrateSensors(){
      // Maybe set bigProblem to true
      // etc
    }

    fun updateCoordinates(){
        // Recalculate coordinates and update
        // the gpsCoordinates property
        gpsCoordinates = "21.123456, 2.654321"

        // user can now access the new coordinates
        // but still can't change them
    }

    fun runMaintenance(){
        doDiagnostics()
        recalibrateSensors()

        if(bigProblem){
            dropOutOfTheSky()
        }

    }
}

在上述代码中,有一个名为bigProblem的新私有Boolean属性。它只能在内部访问。甚至不能在外部读取。有三个新函数,一个名为runMaintenance的公共属性,它运行两个私有函数doDiagnosticscalibrateSensors。这两个函数可以访问并更改bigProblem的值(如果需要)。在runMaintenance函数中,进行了一个检查,看看bigProblem是否为 true,如果是,则调用dropOutOfTheSky函数。

提示

显然,在真实卫星的代码中,除了掉出天空之外,可能首先会寻求其他解决方案。

让我们看看最后两个可见性修饰符。

受保护的

当使用protected可见性修饰符时,其影响比publicprivate更微妙。当函数或属性声明为protected时,它几乎是私有的 - 但并非完全如此。我们将在下一章中探讨的另一个关键面向对象编程主题是继承,它允许我们编写类,然后编写另一个继承该类功能的类。protected修饰符将允许函数和属性对这些类可见,但对所有其他代码隐藏。

我们将在整本书中进一步探讨这个问题。

内部

内部修饰符比其他修饰符更接近公共。它会将属性/函数暴露给同一包中的任何代码。如果考虑到一些应用程序只有一个包,那么这是相当宽松的可见性。我们不会经常使用它,我只是想让你了解一下,以便完整起见。

可见性修饰符总结

尽管我们已经讨论了好几页,但我们只是触及了可见性修饰符的表面。关键是它们存在,其目的是帮助封装并使您的代码不太容易出错,并且更具可重用性。结合属性、函数、getter 和 setter,Kotlin 非常灵活,我们可以用更多的例子来说明何时以及在何处使用每个可见性修饰符,以及何时、在何处以及如何以不同方式重写 getter 和 setter。使用这些技术构*工作程序更有用。这是我们将在整本书中做的事情,我经常会提到为什么我们使用特定的可见性修饰符或者为什么我们以特定的方式使用 getter/setter。我还鼓励您在本章末尾进行基本类演示应用。

构造函数

在本章中,我们一直在实例化对象(类的实例),并且我们已经深入讨论了各种语法。直到现在,有一小部分代码我们一直忽略。下面的代码我们以前看过几次,但我已经突出显示了一小部分,以便我们进一步讨论:

val soldier = Soldier()

代码末尾的括号初始化对象的代码看起来就像前一章中调用函数时的代码(没有任何参数)。事实上,情况确实如此。当我们声明一个类时,Kotlin 提供(在幕后)一个名为构造函数的特殊函数,用于准备实例。

到目前为止,在本章中,我们已经在一行中声明和初始化了所有的实例。通常,我们需要在初始化中使用一些更多的逻辑,而且我们经常需要允许初始化类的代码传递一些值(就像一个函数)。这就是构造函数的原因。

通常,这个默认构造函数就是我们需要的全部内容,我们可以忘记它,但有时我们需要做更多的工作来设置我们的实例,以便它准备好使用。Kotlin 允许我们声明自己的构造函数,并给我们三个主要选项:主要构造函数、次要构造函数和init块。

主要构造函数

主要构造函数是在类声明中声明的构造函数。看看下面的代码,它定义了一个允许类的用户传入两个值的构造函数。正如我们所期望的那样,这段代码将放在一个名为Book.kt的文件中。

class Book(val title: String, var copiesSold: Int) {
   // Here we put our code as normal
   // But title and copiesSold are properties that
   // are already declared and initialized
}

在上面的代码中,我们声明了一个名为Book的类,并提供了一个接受两个参数的构造函数。当初始化时,它需要传递一个不可变的String值和一个可变的Int值。提供这样的构造函数,然后使用它来实例化一个实例,声明和初始化了titlecopiesSold属性。没有必要以通常的方式声明或初始化它们。

看看下面的代码,它展示了如何实例化这个类的一个实例:

// Instantiate a Book using the primary constructor
val book = Book("Animal Farm", 20000000)

在上面的代码中,使用主要构造函数实例化了一个名为book的对象,属性titlecopiesSold分别初始化为Animal Farm20000000(两千万)。

就像函数一样,你可以塑造构造函数,拥有任意组合、类型和数量的参数。

主要构造函数的潜在缺点是属性从传入的参数中获取值,没有任何灵活性。如果我们需要在将它们分配给属性之前对传入的值进行一些计算怎么办?幸运的是,我们可以处理这个问题。

次要构造函数

次要构造函数是在类声明之外单独声明的构造函数,但仍然在类体内。关于次要构造函数需要注意的几件事是,你不能在参数中声明属性,而且你还必须从次要构造函数的代码中调用主要构造函数。次要构造函数的优势在于你可以编写一些逻辑(代码)来初始化你的属性。看看下面的代码,它展示了这一点。同时,我们还将介绍一个新的关键字:

// Perhaps the user of the class 
// doesn't know the time as it
// is yet to be confirmed
class Meeting(val day: String, val person: String) {
    var time: String = "To be decided"
    // The user of the class can
    // supply the day, time and person 
    // of a meeting
    constructor(day: String, person: String, time: String)
            :this(day, person ){

        // "this" refers to the current instance
        this.time = time
        // time (the property) now equals time
        // that was passed in as a parameter
    }
}

在上面的代码中,我们声明了一个名为Meeting的类。主要构造函数声明了两个属性,一个叫做day,一个叫做person。接下来,声明了一个名为time的属性,并初始化为值To be decided

接下来是次要构造函数。注意参数前面有constructor关键字。你还会注意到,次要构造函数包含三个参数,与主要构造函数相同的两个参数,还有一个叫做time的参数。

请注意,time参数与先前声明和初始化的time属性不是同一个实体。次要构造函数只包含“一次性”参数,它们不会成为像主构造函数那样的持久属性。这使我们首先可以调用主构造函数传递dayperson,其次(在次要构造函数的主体中)将通过time参数传递的值分配给time属性。

提示

您可以提供多个次要构造函数,只要签名都不同。通过匹配调用/实例化代码的参数,将调用适当的次要构造函数。

我们需要谈谈这个

我是说,我们需要谈谈this关键字。当我们在类内部使用this时,它会引用当前实例 - 因此它会作用于自身。

因此,this(day, person)代码调用初始化dayperson属性的主构造函数。此外,this.time = time代码会将通过time参数传递的值分配给实际的time属性(this.time)。

注意

顺便提一句,如果不明显的话,Meeting类需要额外的函数才能使其有意义,比如setTimegetMeetingDetails,可能还有其他函数。

当用户不知道时间时(通过主构造函数)或者当他们知道时间时(通过次要构造函数)可以创*Meeting类的实例。

使用 Meeting 类

我们将通过调用我们的构造函数之一来实例化我们的实例,如下面的代码所示:

// Book two meetings
// First when we don't yet know the time
val meeting = Meeting("Thursday", "Bob")

// And secondly when we do know the time
val anotherMeeting = Meeting("Wednesday","Dave","3 PM")

在上面的代码中,我们初始化了Meeting类的两个实例,一个叫做meeting,另一个叫做anotherMeeting。在第一次实例化时,我们调用了主构造函数,因为我们不知道时间;而在第二次实例化时,我们调用了次要构造函数,因为我们知道时间。

如果需要,我们可以有多个次要构造函数,只要它们都调用主构造函数。

初始化块

Kotlin 被设计为一种简洁的语言,通常有更简洁的方法来初始化我们的属性。如果类不依赖于多个不同的签名,那么我们可以坚持使用更简洁的主构造函数,并在init块中提供任何必需的初始化逻辑:

init{
  // This code runs when the class is instantiated
  // and can be used to initialize properties
}

这可能是足够的理论了;让我们在一个工作应用程序中使用我们一直在谈论的一切。接下来,我们将编写一个使用类的小应用程序,包括主构造函数和init块。

基本类应用程序和使用 init 块

您可以在代码下载中获取此应用程序的完整代码。它位于Chapter10/Basic Classes文件夹中。但是,继续阅读以创*您自己的工作示例会更有用。

我们将使用本章学到的知识创*几个不同的类,以将理论付诸实践。我们还将看到我们的第一个示例,即类如何通过将类作为参数传递到另一个类的函数中相互交互。我们已经知道如何在理论上做到这一点,只是还没有在实践中看到它。

当类首次实例化时,我们还将看到另一种初始化数据的方法,即使用init块。

我们将创*一个小应用程序,用于模拟船只、码头和海战的想法。

注意

本章和下一章应用程序的输出将只是文本,显示在 logcat 窗口中。在第十二章中,将我们的 Kotlin 连接到 UI 和可空性,我们将把我们在前五章学到的关于 Android UI 的知识和在接下来的六章中学到的关于 Kotlin 的知识结合起来,让我们的应用程序活起来。

使用空活动模板创*一个名为Basic Classes的应用程序。现在我们将创*一个名为Destroyer的新类:

  1. 在项目资源管理器窗口中右键单击com.gamecodeschool.basicclasses(或者您的包名)文件夹。

  2. 选择新* | Kotlin 文件/类

  3. 名称:字段中,键入Destroyer

  4. 在下拉框中选择

  5. 单击OK按钮将新类添加到项目中。

  6. 重复前面的五个步骤,创*另外两个类,一个叫做Carrier,另一个叫做ShipYard

新的类已经为我们创*了一个类声明和大括号,准备好我们的代码。自动生成的代码还包括包声明,这将根据您在创*项目时的选择而有所不同。这是我目前代码的样子。

Destroyer.kt中:

package com.gamecodeschool.basicclasses

class Destroyer {
}

Carrier.kt中:

package com.gamecodeschool.basicclasses

class Carrier {
}

ShipYard.kt中:

package com.gamecodeschool.basicclasses

class ShipYard {
}

让我们从编写Destroyer类的第一部分开始。接下来是构造函数、一些属性和一个init块。添加代码到项目中,学*它,然后我们将回顾我们所做的事情:

class Destroyer(name: String) {
    // What is the name of this ship
    var name: String = ""
        private set

    // What type of ship is it
    // Always a destroyer
    val type = "Destroyer"

    // How much the ship can take before sinking
    private var hullIntegrity = 200

    // How many shots left in the arsenal
    var ammo = 1
    // Cannot be directly set externally
        private set

    // No external access whatsoever
    private var shotPower = 60

    // Has the ship been sunk
    private var sunk = false

    // This code runs as the instance is being initialized
    init {
        // So we can use the name parameter
        this.name = "$type $name"
    }

首先要注意的是构造函数接收一个名为nameString值。它没有声明为valvar属性。因此,它不是一个属性,只是一个在实例初始化后将不复存在的常规参数。我们很快将看到如何利用它。

在前面的代码中,我们声明了一些属性。请注意,大多数都是可变的var,除了type,它是一个初始化为DestroyerString val类型。还要注意,大多数都是private访问,除了两个。

type属性是公共的,因此可以通过类的实例完全访问。name属性也是公共的,但具有private的 setter。这将允许实例获取值,但保护后备字段(值)不被实例更改。

hullIntegrityammoshotPowersunk属性都是private的,无法通过实例直接访问。请务必记住这些属性的值和类型。

前面代码的最后一部分是一个init块,在这个块中,name属性通过将类型和名称属性连接起来并在中间加上一个空格来进行初始化。

接下来,添加接下来的takeDamage函数:

fun takeDamage(damageTaken: Int) {
   if (!sunk) {
        hullIntegrity -= damageTaken
        Log.i("$name damage taken =","$damageTaken")
        Log.i("$name hull integrity =","$hullIntegrity")

        if (hullIntegrity <= 0) {
               Log.d("Destroyer", "$name has been sunk")
               sunk = true
        }
  } else {
         // Already sunk
         Log.d("Error", "Ship does not exist")
  }
}

takeDamage函数中,if表达式检查sunk布尔值是否为 false。如果船只还没有沉没,那么hullIntegrity将减去传入的damageTaken值。因此,尽管private,实例仍然会间接影响hullIntegrity。关键是它只能以程序员决定的方式来做到这一点;在这种情况下,是我们。正如我们将看到的,所有私有属性最终都将以某种方式被操作。

此外,如果船还没有沉没,两个Log.i调用将损坏信息和剩余船体完整性信息输出到 logcat 窗口。最后,在未沉没的情况下(!sunk),嵌套的if表达式检查hullIntegrity是否小于零。如果是,则打印一条消息表示船已经沉没,并将sunk布尔值设置为 true。

当调用damageTaken函数并且sunk变量为 true 时,else块将执行,并打印一条消息,表示船只不存在,因为它已经沉没了。

接下来,添加shootShell函数,它将与takeDamage函数一起工作。更确切地说,一个船只实例的takeDamage函数将与其他船只实例的shootShell函数一起工作,我们很快就会看到:

fun shootShell():Int {
  // Let the calling code no how much damage to do
  return if (ammo > 0) {
         ammo--
         shotPower
  }else{
        0
  }
}

shootShell函数中,如果船只有弹药,ammo属性将减少一个,并将shotPower的值返回给调用代码。如果船只没有弹药(ammo不大于零),则将值0返回给调用代码。

最后,对于Destroyer类添加serviceShip函数,将ammo设置为10hullIntegrity设置为100,以便船只完全准备好再次承受伤害(通过takeDamage)并造成伤害(通过shootShell):

fun serviceShip() {
    ammo = 10
    hullIntegrity = 100
}

接下来,我们可以快速编写Carrier类,因为它非常相似。只需注意一下分配给typehullIntegrity的值的细微差异。还要注意,我们使用attacksRemainingattackPower,而不是ammoshotPower。此外,shootShell已被替换为launchAerialAttack,这似乎更适合一艘航空母舰。将以下代码添加到Carrier类中:

class Carrier (name: String){
    // What is the name of this ship
    var name: String = ""
        private set

    // What type of ship is it
    // Always a destroyer
    val type = "Carrier"

    // How much the ship can take before sinking
    private var hullIntegrity = 100

    // How many shots left in the arsenal
    var attacksRemaining = 1
    // Cannot be directly set externally
        private set

    private var attackPower = 120

    // Has the ship been sunk
    private var sunk = false

    // This code runs as the instance is being initialized
    init {
        // So we can use the name parameter
        this.name = "$type $name"
    }

    fun takeDamage(damageTaken: Int) {
        if (!sunk) {
            hullIntegrity -= damageTaken
            Log.d("$name damage taken =","$damageTaken")
            Log.d("$name hull integrity =","$hullIntegrity")

            if (hullIntegrity <= 0) {
                Log.d("Carrier", "$name has been sunk")
                sunk = true
            }
        } else {
            // Already sunk
            Log.d("Error", "Ship does not exist")
        }
    }

    fun launchAerialAttack() :Int {
        // Let the calling code no how much damage to do
        return if (attacksRemaining > 0) {
            attacksRemaining--
            attackPower
        }else{
            0
        }
    }

    fun serviceShip() {
        attacksRemaining = 20
        hullIntegrity = 200
    }
}

在我们开始使用新的类之前的最后一段代码是ShipYard类。它有两个简单的函数:

class ShipYard {

    fun serviceDestroyer(destroyer: Destroyer){
        destroyer.serviceShip()
    }

    fun serviceCarrier(carrier: Carrier){
        carrier.serviceShip()
    }
}

第一个函数serviceDestroyerDestroyer实例作为参数,并在该函数内部简单地调用实例的serviceShip函数。第二个函数serviceCarrier具有相同的效果,但以Carrier实例作为参数。虽然这两个函数很简短,但它们的后续使用很快就会揭示一些与类及其实例相关的有趣细微差别。

现在我们将创*一些实例,并通过模拟一场虚构的海战来让我们的类发挥作用。将以下代码添加到MainActivity类的onCreate函数中:

val friendlyDestroyer = Destroyer("Invincible")
val friendlyCarrier = Carrier("Indomitable")

val enemyDestroyer = Destroyer("Grey Death")
val enemyCarrier = Carrier("Big Grey Death")

val friendlyShipyard = ShipYard()

// Uh oh!
friendlyDestroyer.takeDamage(enemyDestroyer.shootShell())
friendlyDestroyer.takeDamage(enemyCarrier.launchAerialAttack())

// Fight back
enemyCarrier.takeDamage(friendlyCarrier.launchAerialAttack())
enemyCarrier.takeDamage(friendlyDestroyer.shootShell())

// Take stock of the supplies situation
Log.d("${friendlyDestroyer.name} ammo = ",
         "${friendlyDestroyer.ammo}")

Log.d("${friendlyCarrier.name} attacks = ",
         "${friendlyCarrier.attacksRemaining}")

// Dock at the shipyard
friendlyShipyard.serviceCarrier(friendlyCarrier)
friendlyShipyard.serviceDestroyer(friendlyDestroyer)

// Take stock of the supplies situation again
Log.d("${friendlyDestroyer.name} ammo = ",
         "${friendlyDestroyer.ammo}")

Log.d("${friendlyCarrier.name} attacks = ",
         "${friendlyCarrier.attacksRemaining}")

// Finish off the enemy
enemyDestroyer.takeDamage(friendlyDestroyer.shootShell())
enemyDestroyer.takeDamage(friendlyCarrier.launchAerialAttack())
enemyDestroyer.takeDamage(friendlyDestroyer.shootShell())

让我们回顾一下那段代码。代码首先实例化了两艘友方船只(friendlyDestroyerfriendlyCarrier)和两艘敌方船只(enemyDestroyerenemyCarrier)。此外,还实例化了一个名为friendlyShipyardShipyard实例,为随之而来的不可避免的大屠杀做好准备:

val friendlyDestroyer = Destroyer("Invincible")
val friendlyCarrier = Carrier("Indomitable")

val enemyDestroyer = Destroyer("Grey Death")
val enemyCarrier = Carrier("Big Grey Death")

val friendlyShipyard = ShipYard()

接下来,friendlyDestroyer对象受到两次伤害。一次来自enemyDestroyer,一次来自enemyCarrier。这是通过friendlyDestroyertakeDamage函数传入两个敌人的shootShelllaunchAerialAttack函数的返回值来实现的:

// Uh oh!
friendlyDestroyer.takeDamage(enemyDestroyer.shootShell())
friendlyDestroyer.takeDamage(enemyCarrier.launchAerialAttack())

接下来,友方部队通过对enemyCarrier对象进行两次攻击进行反击,一次来自friendlyCarrier对象通过launchAerialAttack,一次来自friendlyDestroyer对象通过shootShell

// Fight back
enemyCarrier.takeDamage(friendlyCarrier.launchAerialAttack())
enemyCarrier.takeDamage(friendlyDestroyer.shootShell())

然后将两艘友方船只的状态输出到 logcat 窗口:

// Take stock of the supplies situation
Log.d("${friendlyDestroyer.name} ammo = ",
         "${friendlyDestroyer.ammo}")

Log.d("${friendlyCarrier.name} attacks = ",
         "${friendlyCarrier.attacksRemaining}")

现在,适当的Shipyard实例的函数依次在适当的实例上调用。没有enemyShipyard对象,因此它们将无法进行修复和重新武装:

// Dock at the shipyard
friendlyShipyard.serviceCarrier(friendlyCarrier)
friendlyShipyard.serviceDestroyer(friendlyDestroyer)

接下来,再次打印统计数据,以便我们可以看到访问船坞后的差异:

// Take stock of the supplies situation again
Log.d("${friendlyDestroyer.name} ammo = ",
         "${friendlyDestroyer.ammo}")

Log.d("${friendlyCarrier.name} attacks = ",
         "${friendlyCarrier.attacksRemaining}")

然后,或许是不可避免的,友方部队击败了敌人:

// Finish off the enemy
enemyDestroyer.takeDamage(friendlyDestroyer.shootShell())
enemyDestroyer.takeDamage(friendlyCarrier.launchAerialAttack())
enemyDestroyer.takeDamage(friendlyDestroyer.shootShell())

运行应用程序,然后我们可以在 logcat 窗口中检查以下输出:

Destroyer Invincible damage taken =: 60
Destroyer Invincible hull integrity =: 140
Destroyer Invincible damage taken =: 120
Destroyer Invincible hull integrity =: 20
Carrier Big Grey Death damage taken =: 120
Carrier Big Grey Death hull integrity =: -20
Carrier: Carrier Big Grey Death has been sunk
Error: Ship does not exist
Destroyer Invincible ammo =: 0
Carrier Indomitable attacks =: 0
Destroyer Invincible ammo =: 10
Carrier Indomitable attacks =: 20
Destroyer Grey Death damage taken =: 60
Destroyer Grey Death hull integrity =: 140
Destroyer Grey Death damage taken =: 120
Destroyer Grey Death hull integrity =: 20
Destroyer Grey Death damage taken =: 60
Destroyer Grey Death hull integrity =: -40
Destroyer: Destroyer Grey Death has been sunk

这里是输出,这次分成几部分,以便我们清楚地看到哪些代码产生了哪些输出行。

友好的驱逐舰遭到袭击,使其船体接近破裂点:

Destroyer Invincible damage taken =: 60
Destroyer Invincible hull integrity =: 140
Destroyer Invincible damage taken =: 120
Destroyer Invincible hull integrity =: 20

敌方航空母舰遭到攻击并被击沉:

Carrier Big Grey Death damage taken =: 120
Carrier Big Grey Death hull integrity =: -20
Carrier: Carrier Big Grey Death has been sunk

敌方航空母舰再次遭到攻击,但因为它被击沉,takeDamage函数中的else块被执行:

Error: Ship does not exist

当前的弹药/可用攻击统计数据被打印出来,友方部队的情况看起来很糟糕:

Destroyer Invincible ammo =: 0
Carrier Indomitable attacks =: 0

快速访问船坞,情况会好得多:

Destroyer Invincible ammo =: 10
Carrier Indomitable attacks =: 20

友方部队全副武装并修复,完成了剩余驱逐舰的摧毁:

Destroyer Grey Death damage taken =: 60
Destroyer Grey Death hull integrity =: 140
Destroyer Grey Death damage taken =: 120
Destroyer Grey Death hull integrity =: 20
Destroyer Grey Death damage taken =: 60
Destroyer Grey Death hull integrity =: -40
Destroyer: Destroyer Grey Death has been sunk

如果有任何代码或输出似乎不匹配,请务必再次查看。

引用介绍

此时你可能会有一个困扰的想法。再次查看Shipyard类中的两个函数:

fun serviceDestroyer(destroyer: Destroyer){
        destroyer.serviceShip()
}

fun serviceCarrier(carrier: Carrier){
        carrier.serviceShip()
}

当我们调用那些函数并将friendlyDestroyerfriendlyCarrier传递给它们相应的service…函数时,我们从输出的前后看到,实例内的值已经改变了。通常,如果我们想保留函数的结果,我们需要使用返回值。发生的是,与具有常规类型参数的函数不同,当我们传递一个类的实例时,我们实际上是传递了引用到实例本身 - 不仅仅是其中的值的副本,而是实际的实例。

此外,所有与船相关的不同实例都是用val声明的,那么我们如何改变任何属性呢?对这个谜团的简短回答是,我们并没有改变引用本身,只是其中的属性,但显然需要进行更充分的讨论。

我们将开始探讨引用,然后深入探讨其他相关主题,比如第十二章中的 Android 设备内存,将我们的 Kotlin 连接到 UI 和可空性。目前,知道当我们将数据传递给函数时,如果它是一个类类型,我们传递的是一个等效的引用(虽然实际上并非如此)到真实的实例本身。

总结

我们终于写了我们的第一个类。我们已经看到我们可以在与类同名的文件中实现一个类。类本身在我们实例化一个对象/类的实例之前并不做任何事情。一旦我们有了一个类的实例,我们就可以使用它的特殊变量,称为属性,以及它的非私有函数。正如我们在基本类应用程序中证明的那样,每个类的实例都有自己独特的属性,就像当你买一辆工厂生产的汽车时,你会得到自己独特的方向盘、卫星导航和加速条纹。我们还遇到了引用的概念,这意味着当我们将一个类的实例传递给一个函数时,接收函数就可以访问实际的实例。

所有这些信息都会引发更多的问题。面向对象编程就是这样。因此,让我们在下一章中通过更仔细地研究继承来巩固所有这些类的内容。

第十一章:Kotlin 中的继承

在本章中,我们将看到继承的实际应用。实际上,我们已经看到了,但现在我们将更仔细地研究它,讨论其好处,并编写我们可以继承的类。在整个章节中,我将向您展示几个继承的实际例子,并在本章结束时改进我们在上一章中的海战模拟,并展示我们如何通过使用继承来节省大量的输入和未来的调试。

在本章中,我们将涵盖以下主题:

  • 面向对象编程OOP)和继承

  • 使用开放类进行继承

  • 重写函数

  • 关于多态性的更多内容

  • 抽象类

  • 继承示例应用程序

让我们开始,让我们再多谈一点理论。

OOP 和继承

我们已经看到了如何通过实例化/创*对象从类中重用我们自己的代码和其他人的代码。但是整个 OOP 的概念甚至比这更深入。

如果有一个类中有大量有用的功能,但不完全符合我们的要求怎么办?想想我们编写Carrier类时的情况。它与Destroyer类非常接近,我们几乎可以复制粘贴它。我们可以从一个类继承,然后进一步完善或添加其工作方式和功能。

您可能会惊讶地听到我们已经这样做了。实际上,我们已经对我们创*的每个应用程序都这样做了。当我们使用:语法时,我们正在继承。您可能还记得MainActivity类中的这段代码:

class MainActivity : AppCompatActivity() {

在这里,我们从AppCompatActivity类继承了所有功能-或者更具体地说,类的设计者希望我们能够访问的所有功能。

我们甚至可以重写一个函数,并在一定程度上依赖于我们继承的类中的重写函数。例如,每次我们继承AppCompatActivity类时,我们都重写了onCreate函数。但是当我们这样做时,我们也调用了类设计者提供的默认实现:

super.onCreate(... 

提示

super关键字指的是被继承的超类。

而且,在第六章中,Android 生命周期,我们重写了Activity类的许多生命周期函数。请注意,您可以有多个级别的继承,尽管良好的设计通常*议不要有太多级别。例如,我已经提到AppCompatActivity继承自Activity,而我们又从AppCompatActivity继承。

有了这个想法,让我们看一些示例类,并看看我们如何扩展它们,只是为了看到语法,作为第一步,并且能够说我们已经做到了。

使用开放类进行继承

在这一点上,学*的一些有用术语是被继承的类被称为超类基类。其他常见的称呼这种关系的方式是类和类。子类继承自父类。

默认情况下,类不能被继承。它被称为final类-不开放用于扩展或继承。但是,很容易将类更改为可继承的。我们只需要在类声明中添加open关键字。

基本继承示例

看看下面的代码,它使用open关键字与类声明,并使该类可以被继承:

open class Soldier() {

    fun shoot () {
        Log.i("Action","Bang bang bang")
    }
}

提示

本章中的所有示例都可以在Chapter11/Chapter Examples文件夹中找到。

现在我们可以继续创*Soldier类型的对象并调用shoot函数,就像下面的代码一样:

val soldier = Soldier()
soldier.shoot()

前面的代码仍然会将Bang bang bang输出到 logcat 窗口;我们不必继承它才能使用它。然而,如果我们想要精细化或专门化我们对Soldier类的使用,我们可以创*一个专门类型的Soldier并继承shoot函数。我们可以创*更多的类,也许Special ForcesParatrooper,并使用:语法从Soldier继承。以下是SpecialForces类的代码:

class SpecialForces: Soldier(){
    fun SneakUpOnEnemy(){
        Log.i("Action","Sneaking up on enemy")
    }
}

注意使用冒号表示继承。它还添加了一个sneakUpOnEnemy函数。

接下来,考虑Paratrooper类的以下代码:

class Paratrooper: Soldier() {
    fun jumpOutOfPlane() {
        Log.i("Action", "Jump out of plane")
    }
}

前面的代码还使ParatrooperSoldier继承,并添加了jumpOutOfPlane函数。

这是我们如何使用这两个新的子类的:

val specialForces = SpecialForces()
specialForces.shoot()
specialForces.SneakUpOnEnemy()

val paratrooper = Paratrooper()
paratrooper.shoot()
paratrooper.jumpOutOfPlane()

在前面的代码中,我们实例化了一个SpecialForces实例和一个Paratrooper实例。该代码演示了两个实例都可以访问基类中的shoot函数,并且两个类都可以访问自己的专门函数。代码的输出将如下所示:

Action: Bang bang bang
Action: Sneaking up on enemy
Action: Bang bang bang
Action: Jump out of plane

继承还有更多内容。让我们看看当我们需要进一步完善基类/超类的功能时会发生什么。

重写函数

重写函数是我们已经做过的事情,但我们需要进一步讨论。我们已经在我们编写的每个应用程序中重写了onCreate函数,并且在第六章中,Android 生命周期,我们重写了AppCompatActivity类的许多其他函数。

考虑一下我们可能想要添加一个Sniper类。起初这可能看起来很简单。只需编写一个类,继承自Soldier,并添加一个getIntoPosition函数,也许。如果我们想让Sniper类的射击方式与普通的Soldier不同怎么办?看看Sniper类的以下代码,它重写了shoot函数,并用Sniper类的专门版本替换了它:

class Sniper: Soldier(){
    override fun shoot(){
        Log.i("Action","Steady… Adjust for wind… Bang.")
    }

    fun getIntoPosition(){
        Log.i("Action","Preparing line of sight to target")
    }
}

你可能会认为工作已经完成,但这会导致一个小问题。在Sniper类中有一个错误,如下一个截图所示:

重写函数

错误是因为shoot函数没有被写成可以被重写。默认情况下,函数是 final 的,就像类一样。这意味着子类必须按原样使用它。解决方案是回到Soldier类并在shoot函数声明前面添加open关键字。以下是带有微妙但至关重要的添加的Soldier类的更新代码:

open class Soldier() {

    open fun shoot () {
        Log.i("Action","Bang bang bang")
    }
}

现在我们已经修复了错误,可以编写以下代码来实例化Sniper类并使用重写的shoot函数:

val sniper = Sniper()
sniper.shoot()
sniper.getIntoPosition()

这产生了以下输出:

Action: Steady… Adjust for wind… Bang.
Action: Preparing line of sight to target

我们可以看到已使用重写的函数。值得注意的是,即使子类重写了父类的函数,它仍然可以使用父类的函数。考虑一下,如果狙击手的狙击步枪用完了,需要切换到其他武器会发生什么。看看Sniper类的重新编写代码:

class Sniper: Soldier(){
    // He forget to bring enough ammo
    var sniperAmmo = 3

    override fun shoot(){
        when (sniperAmmo > 0) {
            true -> {
                Log.i("Action", "Steady… Adjust for wind… Bang.")
                sniperAmmo--;
            }
            false -> super.shoot()
        }
    }

    fun getIntoPosition(){
        Log.i("Action","Preparing line of sight to target")
    }
}

Sniper类的新版本中,有一个名为sniperAmmo的新属性,并且初始化为3。重写的shoot函数现在使用when表达式来检查sniperAmmo是否大于零。如果大于零,则通常的文本将被打印到 logcat 窗口,并且sniperAmmo将被递减。这意味着表达式只会返回三次 true。when表达式还处理了当它为 false 时会发生什么,并调用super.shoot()。这行代码调用Soldiershoot函数的版本-超类。

现在,我们可以尝试在Sniper实例上四次调用shoot函数,就像以下代码中的方式,并观察发生了什么:

val sniper = Sniper()
sniper.getIntoPosition()
sniper.shoot()
sniper.shoot()
sniper.shoot()
// Damn! where did I put that spare ammo
sniper.shoot()

这是我们从前面的代码中得到的输出:

Action: Preparing line of sight to target
Action: Steady… Adjust for wind… Bang.
Action: Steady… Adjust for wind… Bang.
Action: Steady… Adjust for wind… Bang.
Action: Bang bang bang

我们可以看到前三次调用sniper.shoot()都从Sniper类中重写的shoot函数输出,第四次仍然调用重写版本,但when表达式的false分支调用超类版本的shoot,我们从Soldier类中得到输出。

提示

到目前为止,基于继承的示例的工作项目可以在代码下载的Chapter11文件夹中找到。它被称为Inheritance Examples

到目前为止的总结

好像面向对象编程还不够有用,我们现在可以模拟现实世界的对象。我们还看到,通过从其他类进行子类化/扩展/继承,我们可以使面向对象编程变得更加有用。

提示

通常情况下,我们可能会问自己这个关于继承的问题:为什么?原因大致如下:如果我们在父类中编写通用代码,那么我们可以更新该通用代码,所有继承它的类也将被更新。此外,我们可以通过可见性修饰符来辅助封装,因为子类只能使用公共/受保护的实例变量和函数,并且只能重写开放函数。因此,如果设计得当,这也进一步增强了封装的好处。

更多多态性

我们已经知道多态意味着许多形式,但对我们来说意味着什么呢?

简化到最简单的形式,意味着以下内容:

注意

任何子类都可以作为使用超类的代码的一部分。

这意味着我们可以编写更容易理解、更容易更改的代码。

此外,我们可以为超类编写代码,并依赖于无论它被子类化多少次,代码仍将在一定范围内工作。让我们讨论一个例子。

假设我们想使用多态来帮助编写一个动物园管理应用程序。我们可能会想要有一个函数,比如feed。我们还可以说我们有LionTigerCamel类,它们都继承自一个名为Animal的父类。我们可能还想将要喂食的动物的引用传递给feed函数。这似乎意味着我们需要为每种类型的Animal编写一个 feed 函数。

然而,我们可以使用多态参数编写多态函数:

fun feed(animalToFeed: Animal){
   // Feed any animal here
}

前面的函数有Animal作为参数,这意味着可以将从继承自Animal的类构*的任何对象传递给它。

因此,您甚至可以今天编写代码,然后在一周、一个月或一年后创*另一个子类,相同的函数和数据结构仍然可以工作。

此外,我们可以对我们的子类强制执行一组规则,规定它们可以做什么,不能做什么,以及如何做。因此,在一个阶段的巧妙设计可以影响其他阶段。

但我们真的会想要实例化一个实际的Animal吗?

抽象类和函数

抽象函数是使用abstract关键字声明的函数。到目前为止还没有问题。但是,抽象函数也根本没有主体。明确地说,抽象函数中没有任何代码。那么,我们为什么要这样做呢?答案是,当我们编写抽象函数时,我们强制任何从具有抽象函数的类继承的类来实现/重写该函数。以下是一个假设的抽象函数:

abstract fun attack(): Int

没有主体,没有代码,甚至没有空花括号。任何想要从该类继承的类必须以与前面声明的签名完全相同的方式实现attack函数。

abstract类是一个不能被实例化的类-不能成为对象。那么,这就是一个永远不会被使用的蓝图?但这就像支付一个*筑师来设计你的家,然后永远不*造它!您可能会对自己说:“我有点明白抽象函数的概念,但抽象类只是愚蠢。”

如果一个类的设计者想要强制类的用户在使用他们的类之前继承,他们可以将一个类声明为abstract。然后,我们就不能从中创*对象;因此,我们必须先继承它,然后从子类创*对象。

让我们看一个例子。我们通过使用abstract关键字声明一个类为abstract类,像这样:

abstract class someClass{
   /*
         All functions and properties here.
         As usual!
         Just don't try and make 
         an object out of me!
   */
}

是的,但为什么呢?

有时我们想要一个可以用作多态类型的类,但我们需要保证它永远不能被用作对象。例如,Animal本身并没有太多意义。

我们不谈论动物,我们谈论动物的类型。我们不会说,“哦,看那只可爱的毛茸茸的白色动物”,或者,“昨天我们去宠物店买了一只动物和一个动物床。”这太抽象了。

因此,abstract类有点像一个模板,可以被任何继承自它的类使用。

我们可能想要一个Worker类,并扩展此类以创*MinerSteelworkerOfficeWorker,当然还有Programmer。但是一个普通的Worker到底是做什么的呢?为什么我们会想要实例化一个呢?

答案是我们不想实例化一个,但我们可能想要将其用作多态类型,以便我们可以在函数之间传递多个工作子类,并且可以容纳所有类型的Worker的数据结构。

我们称这种类为抽象类,当一个类有一个抽象函数时,它必须被声明为抽象类。所有抽象函数必须被任何继承自它的类重写。

这意味着抽象类可以提供一些在其所有子类中都可用的常见功能。例如,Worker类可能具有heightweightage属性。

它可能还有getPayCheck函数,这个函数不是抽象的,在所有子类中都是相同的,但是doWork函数是抽象的,必须被重写,因为所有不同类型的工作者都有非常不同的doWork

使用继承示例应用程序的类

我们已经看过了我们可以创*类的层次结构来模拟适合我们应用程序的系统的方式。因此,让我们构*一个项目,以改进我们在上一章中进行的海战。

使用空活动模板创*一个名为Basic Classes with Inheritance Example的新项目。如你所料,完成的代码可以在Chapter11文件夹中找到。

这就是我们要做的:

  • CarrierDestroyer类的大部分功能放入Ship超类中。

  • CarrierDestroyer类都继承自Ship类,从而节省大量代码维护。

  • 使用多态性来调整Shipyard类中的serviceShip函数,使其以Ship作为参数,从而可以为继承自Ship的任何实例提供服务,从而减少类中的函数数量。

  • 我们还将看到,不仅代码量比以前少,而且封装性也比以前更好。

创*一个名为Ship的新类,并编写如下代码。然后我们将讨论它与上一个项目中的DestroyerCarrier类的比较:

abstract class Ship(
        val name: String,
        private var type: String,
        private val maxAttacks: Int,
        private val maxHullIntegrity: Int) {

    // The stats that all ships have
    private var sunk = false
    private var hullIntegrity: Int
    protected var attacksRemaining: Int

    init{
        hullIntegrity = this.maxHullIntegrity
        attacksRemaining = 1
    }

    // Anything can use this function
    fun takeDamage(damageTaken: Int) {
        if (!sunk) {
            hullIntegrity -= damageTaken
            Log.i("$name damage taken =","$damageTaken")
            Log.i("$name hull integrity =","$hullIntegrity")

            if (hullIntegrity <= 0) {
                Log.i(type, "$name has been sunk")
                sunk = true
            }
        } else {
            // Already sunk
            Log.i("Error", "Ship does not exist")
        }
    }

    fun serviceShip() {
        attacksRemaining = maxAttacks
        hullIntegrity = maxHullIntegrity
    }

    fun showStats(){
        Log.i("$type $name",
                "Attacks:$attacksRemaining - Hull:$hullIntegrity")
    }

    abstract fun attack(): Int

}

首先,你会注意到这个类被声明为abstract,所以我们知道我们必须从这个类继承,而不能直接使用它。向下扫描代码,你会看到一个名为attack的抽象函数。我们现在知道,当我们从Ship继承时,我们需要重写并提供一个名为attack的函数的代码。这正是我们需要的,因为你可能记得航空母舰发动攻击,驱逐舰发射炮弹。

向上扫描前面的代码,你会看到构造函数声明了四个属性。其中两个属性是新的,另外两个与之前的项目具有相同的用途,但我们如何调用构造函数才是有趣的,我们很快就会看到。

两个新属性是maxAttacksmaxHullIntegrity,这样Shipyard就可以将它们恢复到适合特定类型船只的水*。

init块中,未在构造函数中初始化的属性被初始化。接下来是takeDamage函数,它具有与上一个项目中的takeDamage函数相同的功能,只是它只在Ship类中,而不是在CarrierDestroyer类中。

最后,我们有一个showStats函数,用于将与日志窗口相关的统计值打印出来,这意味着这些属性也可以是私有的。

请注意,除了name和一个叫做attacksRemaining的受保护属性之外,所有属性都是私有的。请记住,protected意味着它只在继承自Ship类的实例内可见。

现在,按照下面所示的方式编写新的Destroyer类:

class Destroyer(name: String): Ship(
        name,
        "Destroyer",
        10,
        200) {

    // No external access whatsoever
    private var shotPower = 60

    override fun attack():Int {
        // Let the calling code no how much damage to do
        return if (attacksRemaining > 0) {
            attacksRemaining--
            shotPower
        }else{
            0
        }
    }
}

现在,按照下面所示的方式编写Carrier类,然后我们可以比较DestroyerCarrier

class Carrier (name: String): Ship(
        name,
        "Carrier",
        20,
        100){

    // No external access whatsoever
    private var attackPower = 120

    override fun attack(): Int {
        // Let the calling code no how much damage to do
        return if (attacksRemaining > 0) {
            attacksRemaining--
            attackPower
        }else{
            0
        }
    }
}

请注意,前面两个类只接收一个名为nameString值作为构造函数参数。您还会注意到name没有用valvar声明,因此它不是一个属性,只是一个不会持久存在的临时参数。每个类的第一件事是继承自Ship并调用Ship类的构造函数,同时传入适用于DestroyerCarrier的值。

两个类都有与攻击相关的属性。DestroyershotPowerCarrierattackPower。然后它们都实现/重写attack函数以适应它们将执行的攻击类型。但是,两种类型的攻击将以相同的方式通过相同的函数调用触发。

按照下面所示的方式编写新的Shipyard类:

class ShipYard {
    fun serviceShip(shipToBeServiced: Ship){
        shipToBeServiced.serviceShip()
        Log.i("Servicing","${shipToBeServiced.name}")
    }
}

Shipyard类中,现在只有一个函数。它是一个多态函数,以Ship实例作为参数。然后调用超类的serviceShip函数,该函数将将弹药/攻击和hullIntegrity恢复到适合船只类型的水*。

提示

Shipyard类是肤浅的这一说法是正确的。我们本可以直接调用serviceShip而不将实例传递给另一个类。但是,这清楚地表明我们可以将两个不同的类视为相同类型,因为它们都继承自相同的类型。多态的概念甚至比这更深入,我们将在下一章中讨论接口时看到。毕竟,多态意味着许多事物,而不仅仅是两件事物。

最后,在MainActivity类的onCreate函数中添加代码,让我们的辛勤工作付诸实践:

val friendlyDestroyer = Destroyer("Invincible")
val friendlyCarrier = Carrier("Indomitable")

val enemyDestroyer = Destroyer("Grey Death")
val enemyCarrier = Carrier("Big Grey Death")

val friendlyShipyard = ShipYard()

// A small battle
friendlyDestroyer.takeDamage(enemyDestroyer.attack())
friendlyDestroyer.takeDamage(enemyCarrier.attack())
enemyCarrier.takeDamage(friendlyCarrier.attack())
enemyCarrier.takeDamage(friendlyDestroyer.attack())

// Take stock of the supplies situation
friendlyDestroyer.showStats()
friendlyCarrier.showStats()

// Dock at the shipyard
friendlyShipyard.serviceShip(friendlyCarrier)
friendlyShipyard.serviceShip(friendlyDestroyer)

// Take stock of the supplies situation
friendlyDestroyer.showStats()
friendlyCarrier.showStats()

// Finish off the enemy
enemyDestroyer.takeDamage(friendlyDestroyer.attack())
enemyDestroyer.takeDamage(friendlyCarrier.attack())
enemyDestroyer.takeDamage(friendlyDestroyer.attack())

这段代码完全遵循与以下相同的模式:

  1. 攻击友方船只

  2. 反击并击沉敌方航母

  3. 打印统计数据

  4. 造船厂进行修理和重新武装

  5. 再次打印统计数据

  6. 完成最后一个敌人

现在我们可以观察输出:

Invincible damage taken =: 60
Invincible hull integrity =: 140
Invincible damage taken =: 120
Invincible hull integrity =: 20
Big Grey Death damage taken =: 120
Big Grey Death hull integrity =: -20
Carrier: Big Grey Death has been sunk
Error: Ship does not exist
Destroyer Invincible: Attacks:0 - Hull:20
Carrier Indomitable: Attacks:0 - Hull:100
Servicing: Indomitable
Servicing: Invincible
Destroyer Invincible: Attacks:10 - Hull:200
Carrier Indomitable: Attacks:20 - Hull:100
Grey Death damage taken =: 60
Grey Death hull integrity =: 140
Grey Death damage taken =: 120
Grey Death hull integrity =: 20
Grey Death damage taken =: 60
Grey Death hull integrity =: -40
Destroyer: Grey Death has been sunk

在前面的输出中,我们可以看到几乎相同的输出。但是,我们用更少的代码和更多的封装实现了它,而且,如果在六个月后我们需要一个使用鱼雷进行攻击的Submarine类,那么我们可以在不更改任何现有代码的情况下添加它。

总结

如果您没有记住所有内容,或者有些代码看起来有点太深入了,那么您仍然成功了。

如果你只是理解 OOP 是通过封装、继承和多态编写可重用、可扩展和高效的代码,那么你就有成为 Kotlin 大师的潜力。

简而言之,OOP 使我们能够使用其他人的代码,即使那些其他人在编写代码时并不知道我们当时会做什么。

你所需要做的就是不断练*,因为我们将在整本书中一遍又一遍地使用这些概念,所以你不需要在这一点上甚至已经掌握它们。

在下一章中,我们将重新审视本章的一些概念,以及探讨面向对象编程的一些新方面,以及它如何使我们的 Kotlin 代码与 XML 布局进行交互。

第十二章:将我们的 Kotlin 连接到 UI 和可空性

通过本章的结束,我们的 Kotlin 代码和 XML 布局之间的缺失链接将被完全揭示,让我们有能力像以前一样向布局添加各种小部件和 UI 功能,但这一次我们将能够通过我们的代码来控制它们。

在本章中,我们将控制一些简单的 UI 元素,比如ButtonTextView,在下一章中,我们将进一步操作一系列 UI 元素。

为了让我们理解发生了什么,我们需要更多地了解应用程序中的内存,特别是其中的两个区域-堆栈

在本章中,我们将涵盖以下主题:

  • Android UI 元素也是类

  • 垃圾回收

  • 我们的 UI 在堆上

  • 更多的多态性

  • 可空性- val 和 var 重新审视

  • 转换为不同类型

准备让您的 UI 活起来。

所有的 Android UI 元素也是类

当我们的应用程序运行并且从onCreate函数中调用setContentView函数时,布局会从 XML UI 中膨胀,并作为可用对象加载到内存中。它们存储在内存的一个部分,称为堆。

但是这个堆在哪里?我们在代码中肯定看不到 UI 实例。我们怎么才能得到它们呢?

每个 Android 设备内部的操作系统都会为我们的应用程序分配内存。此外,它还将不同类型的变量存储在不同的位置。

我们在函数中声明和初始化的变量存储在称为堆栈的内存区域。我们已经知道如何使用简单的表达式在堆栈上操作变量。所以,让我们再谈谈堆。

注意

重要事实:所有类的对象都是引用类型变量,只是指向存储在堆上的实际对象的引用-它们并不是实际的对象。

把堆想象成仓库的另一个区域。堆有大量的地板空间用于奇形怪状的物体,用于较小物体的货架,以及许多长排的小尺寸隔间等。这就是对象存储的地方。问题是我们无法直接访问堆。把它想象成仓库的受限区域。你实际上不能去那里,但你可以引用那里存储的东西。让我们看看引用变量到底是什么。

它是一个我们通过引用引用和使用的变量。引用可以宽松地但有用地定义为地址或位置。对象的引用(地址或位置)在堆栈上。

因此,当我们使用点运算符时,我们正在要求操作系统在特定位置执行任务,这个位置存储在引用中。

提示

引用变量就是这样-一个引用。它们是访问和操作对象(属性和函数)的一种方式,但它们并不是实际的对象本身。

为什么我们会想要这样的系统?给我我的对象就放在堆栈上!这就是为什么。

快速休息一下,扔掉垃圾

整个堆栈和堆的作用是什么。

正如我们所知,操作系统会为我们跟踪所有的对象,并将它们存储在我们仓库的一个专门区域,称为堆。在我们的应用程序运行时,操作系统会定期扫描堆栈,我们仓库的常规货架,并匹配堆上的对象的引用。它发现的任何没有匹配引用的对象,都会被销毁。或者,用正确的术语来说,它进行垃圾回收

想象一辆非常有洞察力的垃圾车穿过我们的堆,扫描物体以匹配参考(在堆栈上)。没有参考意味着它被垃圾回收了。

如果一个对象没有相关的引用变量,我们无法对其进行任何操作,因为我们无法访问它/引用它。垃圾收集系统通过释放未使用的内存帮助我们的应用程序更有效地运行。

如果这个任务留给我们来完成,我们的应用程序将会更加复杂。

因此,函数内声明的变量是局部的,位于堆栈上,只能在声明它们的函数内部可见。一个属性(对象的属性)位于堆上,可以在任何有引用的地方引用它,如果访问修饰符(封装)允许的话。

关于堆栈和堆的七个有用的事实

让我们快速看看我们对堆栈和堆学到了什么:

  • 你不会删除对象,而是操作系统在认为合适的时候发送垃圾收集器。通常情况下,当对象没有活动引用时,垃圾收集器会进行清理。

  • 变量位于堆栈上,只能在声明它们的特定函数内部可见。

  • 属性位于堆上(与其对象/实例一起),但是对象/实例的引用(其地址)是堆栈上的局部变量。

  • 我们控制着堆栈中的内容。我们可以使用堆上的对象,但只能通过引用它们。

  • 堆由垃圾收集器保持清晰和最新。

  • 当不再有有效引用指向对象时,对象将被垃圾收集。因此,当引用变量从堆栈中移除时,与之相关的对象就可以进行垃圾收集。当操作系统决定时机合适(通常非常迅速),它将释放 RAM 内存以避免耗尽。

  • 如果我们设法引用一个不存在的对象,我们将会得到一个NullPointerException错误,应用程序将崩溃。Kotlin 的一个主要特性是它保护我们免受这种情况的发生。在 Kotlin 试图改进的 Java 中,NullPointerException 错误是应用程序崩溃的最常见原因。我们将在本章末尾附近的Nullability – val and var revisited部分学*更多关于 Kotlin 如何帮助我们避免NullPointerException错误的内容。

让我们继续看看这些信息对我们控制 UI 方面有什么帮助。

那么,这个堆究竟如何帮助我?

在 XML 布局中设置了id属性的任何 UI 元素都可以从堆中检索其引用并使用,就像我们在前两章中编写和声明自己的类一样。

如果我们使用基本活动模板创*一个项目(随意这样做,但你不需要这样做),将一个按钮拖到 UI 上,推断出约束,并在模拟器上运行应用程序。然后我们将得到下一个截图中所见的内容:

那么,这个堆究竟如何帮助我?

这就是我们应该从前五章中已经看到的内容可以期待的。如果我们将这行代码添加到onCreate函数中,那么将会发生一些有趣的事情:

button.text = "WOO HOO!"

再次运行应用程序并观察按钮的变化:

那么,这个堆究竟如何帮助我?

我们已经改变了按钮上的文本。

提示

此时,如果您之前使用 Java 编写 Android 应用程序,您可能想躺下几分钟,思考从现在开始生活将会变得多么容易。

这非常令人兴奋,因为它显示我们可以从我们的布局中获取一大堆东西的引用。然后我们可以开始使用这些对象由 Android API 提供的所有函数和属性。

代码中的button实例是指 XML 布局中Button小部件的id。我们代码中的text实例然后指的是Button类的text属性,我们代码中的= "WOO HOO!"文本使用了text属性的 setter 来改变它所持有的值。

提示

如果Button类(或其他 UI 元素)的id值不同,那么我们需要相应地调整我们的代码。

如果你认为在十一章之后,我们终于要开始在 Android 上做一些好玩的事情,那么你是对的!

让我们了解 OOP 的另一个方面,然后我们将能够构*迄今为止最功能强大的应用程序。

Kotlin 接口

接口就像一个类。哦!这里没有什么复杂的。但是,它就像一个始终是抽象的类,只有抽象函数。

我们可以将接口看作是一个完全抽象的类,其所有函数和属性都是抽象的。当属性是抽象的时,它不持有值。它没有属性的后备字段。然而,当另一个类实现(使用)接口时,它必须重写属性,因此提供用于存储值的后备字段。

简而言之,接口是无状态的类。它们提供了一个没有任何数据的实现模板。

好吧,你大概能理解抽象类,因为至少它可以在其函数中传递一些功能,并在其属性中传递一些状态,这些状态不是抽象的,并且作为多态类型。

但是,说真的,这个界面似乎有点毫无意义。让我们看一个最简单的接口示例,然后我们可以进一步讨论。

定义接口,我们输入以下内容:

interface SomeInterface { 

   val someProperty: String 
   // Perhaps more properties

   fun someFunction() 
   // Perhaps more functions
   // With or without parameters
   // and return types
}

接口的函数没有主体,因为它们是抽象的,但它们仍然可以有返回类型和参数。

要使用接口,我们在类声明后使用相同的:语法:

class SomeClass() : SomeInterface{ 

   // Overriding any properties
   // is not optional
   // It is an obligation for a class
   // that uses the interface
   override val someProperty: String = "Hello" 

   override fun someFunction() { 
      // This implementation is not optional
      // It is an obligation for a class
      // that uses the interface
   } 
}

在前面的代码中,属性和函数已在实现接口的类中被重写。编译器强制接口的用户这样做,否则代码将无法编译。

如果您同时从一个类继承并实现一个或多个接口,那么超类就会简单地放入接口的列表中。为了清楚地表明不同的关系,惯例是将超类放在列表的第一位。然而,编译器并不要求这样做。

这使我们能够在完全不相关的继承层次结构中使用多个不同对象的多态性。如果一个类实现了一个接口,整个东西就可以被传递或用作它就像是那个东西一样,因为它就是那个东西。它是多态的(多种形式)。

我们甚至可以让一个类同时实现多个不同的接口。只需在每个接口之间添加逗号,并确保重写所有必要的函数。

在本书中,我们将更频繁地使用 Android API 的接口,而不是编写我们自己的接口。在下一节中,我们将使用OnClickListener接口。

许多东西可能想要在被点击时知道,比如Button小部件或TextView小部件。因此,使用接口,我们不需要为每种类型的 UI 元素单独编写不同的函数。

让我们一起看看接口在同时连接我们的 Kotlin 代码和 UI 时的作用。

使用按钮和 TextView 小部件从我们的布局中,借助接口的一点帮助

要跟随这个项目,创*一个新的 Android Studio 项目,将其命名为Kotlin Meet UI,并选择Empty Activity模板。您可以在Chapter12/Kotlin Meet UI文件夹中找到代码和 XML 布局代码。

首先,让我们通过以下步骤构*一个简单的 UI:

  1. 在 Android Studio 的编辑窗口中,切换到activity_main.xml,确保你在Design选项卡上。

  2. 删除自动生成的TextView,即那个写着“Hello world!”的。

  3. 在布局的顶部中心添加一个TextView小部件。

  4. 将其text属性设置为0,其id属性设置为txtValue,其textSize设置为40sp。请特别注意id值的大小写。它的V是大写的。

  5. 现在,将六个按钮拖放到布局上,使其看起来有点像下面的图表。确切的布局并不重要:使用按钮和 TextView 小部件从我们的布局中,借助接口的一点帮助

  6. 当布局达到您想要的效果时,单击Infer Constraints按钮以约束所有 UI 项。

  7. 依次双击每个按钮(从左到右,然后从上到下),并设置textid属性,如下表所示:

text属性 id属性
add btnAdd
take btnTake
grow btnGrow
shrink btnShrink
hide btnHide
reset btnReset

完成后,您的布局应如下屏幕截图所示:

使用按钮和 TextView 小部件从我们的布局中,借助接口的一点帮助

按钮上的精确位置和文本并不是非常重要,但是给id属性赋予的值必须相同。原因是我们将使用这些id值从我们的 Kotlin 代码中获取对此布局中的Button实例和TextView实例的引用。

切换到编辑器中的MainActivity.kt选项卡,并找到以下行:

class MainActivity : AppCompatActivity(){

现在将代码行修改为以下内容:

class MainActivity : AppCompatActivity,
   View.OnClickListener{

在输入时,将会弹出一个列表,询问您要选择要实现的接口。选择OnClickListener (android.view.view),如下一屏幕截图所示:

使用按钮和 TextView 小部件从我们的布局中,借助接口的一点帮助

提示

您需要导入View类。确保在继续下一步之前执行此操作,否则将会得到混乱的结果:

import android.view.View

注意到MainActivity声明被红色下划线标出,显示出错误。现在,因为我们已经将MainActivity添加为接口OnClickListener,我们必须实现OnClickListener的抽象函数。该函数称为onClick。当我们添加该函数时,错误将消失。

我们可以通过在包含错误的代码上任意左键单击,然后使用键盘组合Alt +Enter来让 Android Studio 为我们添加。左键单击Implement members,如下一屏幕截图所示:

使用按钮和 TextView 小部件从我们的布局中,借助接口的一点帮助

现在,左键单击OK以确认我们希望 Android Studio 添加onClick方法/函数。错误已经消失,我们可以继续添加代码。我们还有一个onClick函数,很快我们将看到我们将如何使用它。

注意

术语上的一个快速说明。方法是在类中实现的函数。Kotlin 允许程序员独立于类实现函数,因此所有方法都是函数,但并非所有函数都是方法。我选择在本书中始终将所有方法称为函数。有人认为方法可能是一个更精确的术语,但在本书的上下文中,两者都是正确的。如果您愿意,可以将类中的函数称为方法。

现在,在类声明内部但在任何函数之外/之前添加以下属性:

class MainActivity : AppCompatActivity(), View.OnClickListener {

 // An Int property to hold a value
 private var value = 0

我们声明了一个名为valueInt属性,并将其初始化为0。请注意,它是一个var属性,因为我们需要更改它。

接下来,在onCreate函数内,添加以下六行代码:

// Listen for all the button clicks
btnAdd.setOnClickListener(this)
btnTake.setOnClickListener(this)
txtValue.setOnClickListener(this)
btnGrow.setOnClickListener(this)
btnShrink.setOnClickListener(this)
btnReset.setOnClickListener(this)
btnHide.setOnClickListener(this)

提示

使用Alt +Enter键组合从activity_main.xml布局文件中导入所有ButtonTextView实例。或者,手动添加以下导入语句:

import kotlinx.android.synthetic.main.activity_main.* 

上述代码设置了我们的应用程序以侦听布局中按钮的点击。每行代码都执行相同的操作,但是在不同的按钮上。例如,btnAdd指的是我们布局中id属性值为btnAdd的按钮,btnTake指的是我们布局中id属性值为btnTake的按钮。

然后每个按钮实例调用自身的setOnClickListener函数。传入的参数是this。从第十章中记住,面向对象编程this指的是代码所在的当前类。因此,在前面的代码中,this指的是MainActivity

setOnClickListener函数设置我们的应用程序调用OnClickListener接口的onClick函数。现在,每当我们的按钮之一被点击,onClick函数将被调用。所有这些都是因为MainActivity实现了OnClickListener接口。

如果你想验证这一点,暂时从类声明的末尾删除View.OnClickListener代码,我们的代码将突然充满一片红色的错误。这是因为this不再是OnCLickListener类型,因此无法传递给各个按钮的setOnClickListener函数,onClick函数也会显示错误,因为编译器不知道我们试图覆盖什么。接口是使所有这些功能结合在一起的关键。

提示

如果之前删除了View.OnClickListener,请在类声明的末尾替换它。

现在,滚动到 Android Studio 在我们实现OnClickListener接口后添加的onClick函数。添加Float size变量声明和一个空的when块,使其看起来像下面的代码。要添加的新代码已经突出显示。在下一个代码中还有一件事需要注意和实现。当onClick函数由 Android Studio 自动生成时,在v: View?参数后添加了一个问号。删除问号,如下面的代码所示:

override fun onClick(v: View) {
 // A local variable to use later
 val size: Float

 when (v.id) {

 }
}

记住,when将检查匹配表达式的值。when条件是v.idv变量被传递给onClick函数,v.id标识了被点击的按钮的id属性。它将匹配布局中我们按钮的id

注意

如果你对我们删除的那个奇怪的问号感到好奇,它将在下一节中解释:可空性——val 和 var 重新讨论

接下来我们需要处理每个按钮的操作。将下面的代码块添加到when表达式的大括号内,然后我们将讨论它。首先尝试自己解决代码,你会惊讶地发现我们已经理解了多少。

R.id.btnAdd -> {
   value++
   txtValue.text = "$value"
}

R.id.btnTake -> {
   value--
   txtValue.text = "$value"
}

R.id.btnReset -> {
   value = 0
   txtValue.text = "$value"
}

R.id.btnGrow -> {
   size = txtValue.textScaleX
   txtValue.textScaleX = size + 1
}

R.id.btnShrink -> {
   size = txtValue.textScaleX
   txtValue.textScaleX = size - 1
}

R.id.btnHide -> 
   if (txtValue.visibility 
            == View.VISIBLE) {
   // Currently visible so hide it
   txtValue.visibility = View.INVISIBLE

   // Change text on the button
   btnHide.text = "SHOW"

} else {
   // Currently hidden so show it
   txtValue.visibility = View.VISIBLE

   // Change text on the button
   btnHide.text = "HIDE"
}

以下是代码的第一行:

override fun onClick(v: View) {

ViewButtonTextView等的父类。因此,也许正如我们所期望的那样,使用v.id将返回被点击的 UI 小部件的id属性,并触发首次调用onClick

接下来,我们需要为我们想要响应的每个Button id 值提供一个when语句(和一个适当的操作)。以下是代码的一部分,以供您参考:

when (v.id) {

}

再看一下代码的下一部分:

R.id.btnAdd -> {
   value++
   txtValue.text = "$value"
}

R.id.btnTake -> {
   value--
   txtValue.text = "$value"
}

R.id.btnReset -> {
   value = 0
   txtValue.text = "$value"
}

前面的代码是前三个when分支。它们处理R.id.btnAddR.id.btnTakeR.id.btnReset

R.id.btnAdd分支中的代码简单地增加了value变量,然后做了一些新的事情。

它设置了txtValue对象的text属性。这样做的效果是使这个TextView显示存储在value中的任何值。

TAKE按钮(R.id.btnTake)做的事情完全相同,只是从value中减去 1,而不是加 1。

when语句的第三个分支处理RESET按钮,将value设置为零,并再次更新txtValuetext属性。

在执行任何when分支的末尾,整个when块都会退出,onClick函数返回,生活恢复正常——直到用户的下一次点击。

让我们继续检查when块的下两个分支。以下是为了方便您再次查看:

R.id.btnGrow -> {
   size = txtValue.textScaleX
   txtValue.textScaleX = size + 1
}

R.id.btnShrink -> {
   size = txtValue.textScaleX
   txtValue.textScaleX = size - 1
}

接下来的两个分支处理我们 UI 中的SHRINKGROW按钮。我们可以从 id 的R.id.btnGrow值和R.id.btnShrink值确认这一点。新的更有趣的是TextView类的 getter 和 setter 在按钮上使用。

textScaleX属性的 getter 返回所使用对象中文本的水*比例。我们可以看到它所使用的对象是我们的TextView txtValue实例。代码size =在代码行的开头将返回的值分配给我们的Float变量size

每个when分支中的下一行代码使用textScaleX属性的 setter 来改变文本的水*比例。当按下GROW按钮时,比例设置为size + 1,当按下SHRINK按钮时,比例设置为size - 1

总体效果是允许这两个按钮通过每次点击来放大和缩小txtValue中的文本,比例为1

让我们看一下when代码的最后一个分支。以下是为了方便您再次查看:

R.id.btnHide -> 
   if (txtValue.visibility == View.VISIBLE) {
      // Currently visible so hide it
      txtValue.visibility = View.INVISIBLE

      // Change text on the button
      btnHide.text = "SHOW"

   } else {
      // Currently hidden so show it
      txtValue.visibility = View.VISIBLE

      // Change text on the button
      btnHide.text = "HIDE"
   }

前面的代码需要一点解释,所以让我们一步一步来。首先,在when分支内嵌套了一个if-else表达式。以下是if部分:

if (txtValue.visibility == View.VISIBLE)

要评估的条件是txtValue.visibility == View.VISIBLE。在==运算符之前的部分使用visibility属性的 getter 返回描述TextView当前是否可见的值。返回值将是View类中定义的三个可能的常量值之一。它们是View.VISIBLEView.INVISIBLEView.GONE

如果TextView在 UI 上对用户可见,则 getter 返回View.VISIBLE,条件被评估为true,并且执行if块。

if块内,我们使用visibility属性的 setter 将其对用户不可见,使用View.INVISIBLE值。

除此之外,我们使用text属性的 setter 将btnHide对象上的文本更改为SHOW

if块执行后,txtValue将不可见,并且我们的 UI 上有一个按钮显示SHOW。当用户在这种状态下点击它时,if语句将为 false,else块将执行。在else块中,我们将情况反转。我们将txtValue对象的visibility属性设置回View.VISIBLE,并将btnHide上的text属性设置回HIDE

如果有任何不清楚的地方,只需输入代码,运行应用程序,然后在看到它实际运行后再回顾一下最后的代码和解释。

我们已经准备好 UI 和代码,现在是时候运行应用程序并尝试所有按钮了。请注意,ADDTAKE按钮会分别将value的值增加或减少一,并在TextView中显示结果。在下一张图片中,我点击了ADD按钮三次:

使用按钮和 TextView 小部件从我们的布局中获得帮助

请注意,SHRINKGROW按钮增加了文本的宽度,RESETvalue变量设置为零,并在TextView上显示它。在下面的截图中,我点击了GROW按钮八次:

使用按钮和 TextView 小部件从我们的布局中获得帮助

最后,HIDE按钮不仅隐藏TextView,还将其自身文本更改为SHOW,如果再次点击,则确实会重新显示TextView

提示

我不会打扰你,向你展示一个隐藏的东西的图片。一定要在模拟器中尝试该应用,并跟着书本一起学*。如果你想知道View.INVISIBLEView.GONE之间的区别,INVISIBLE只是隐藏了对象,但当使用GONE时,布局的行为就好像对象从未存在过一样,因此可能会影响剩余 UI 的布局。将代码行从INVISIBLE更改为GONE,并运行应用程序以观察差异。

请注意,在这个应用程序中不需要LogToast,因为我们最终是使用我们的 Kotlin 代码来操作 UI。

可空性 - val 和 var 重温

当我们用val声明一个类的实例时,并不意味着我们不能改变属性中保存的值。决定我们是否可以重新分配属性中保存的值的是属性本身是val还是var

当我们用val声明一个类的实例时,这只意味着我们不能重新分配另一个实例给它。当我们想要重新分配一个实例时,我们必须用var声明它。以下是一些例子:

val someInstance = SomeClass()
someInstance.someMutableProperty = 1// This was declared as var
someInstance.someMutableProperty = 2// So we can change it

someInstance.someImutableProperty = 1
// This was declared with val. ERROR!

在前面的假设代码中,声明了一个名为someInstance的实例,它是SomeClass类型。它被声明为val。接下来的三行代码表明,如果它的属性被声明为var,我们可以更改这些属性,但是,正如我们已经学到的,当属性被声明为val时,我们不能更改它。那么,用valvar声明一个实例到底意味着什么?看看下面的假设代码:

// Continued from previous code
// Three more instances of the same class
val someInstance2 = SomeClass() // Immutable
val someInstance3 = SomeClass()// Immutable
var someInstance4 = SomeClass() // Mutable

// Let's change these instances around— or try to
someInstance = someInstance2 
// Error cannot reassign, someInstance is immutable

someInstance2 = someInstance3 // Error someInstance2 is immutable
someInstance3 = someInstance4 // Error someInstance3 is immutable

// However,
someInstance4 = someInstance 
// No problem! someInstance4 and someInstance are now the
// same object— refer to the same object on the heap

// Sometime in the future…
someInstance4 = someInstance3 // No problem
// Sometime in the future…
someInstance4 = someInstance2 // No problem
// Sometime in the future…
// I need a new SomeClass instance

someInstance4 = SomeClass() // No problem
// someInstance4 now uniquely refers 
// to a new object on the heap

前面的代码清楚地表明,当一个实例是val时,它不能被重新分配到堆上的另一个对象,但当它是var时可以。实例是val还是var并不影响其属性是val还是var

我们已经学到,当讨论属性时,如果我们不需要改变一个值,最好的做法是声明为val。对于对象/实例也是如此。如果我们不需要重新分配一个实例,我们应该将其声明为val

空对象

当我们将对象或属性声明为var时,我们有选择不立即初始化它,有时这正是我们需要的。当我们不初始化一个对象时,它被称为空引用,因为它不指向任何东西。我们经常需要声明一个对象,但直到我们的应用程序运行时才初始化它,但这可能会引起问题。看看更多的假设代码:

var someInstance5: SomeClass
someInstance5.someMutableProperty = 3

在前面的代码中,我们声明了一个名为someInstance5SomeClass的新实例,但我们没有初始化它。现在,看看这个截图,看看当我们在初始化之前尝试使用这个实例时会发生什么:

空对象

编译器不允许我们这样做。当我们需要在程序执行期间初始化一个实例时,我们必须明确地将其初始化为null,以便编译器知道这是有意的。此外,当我们将实例初始化为null时,我们必须使用可空运算符。看看下一个修复刚才问题的假设代码:

var someInstance5: SomeClass? = null

在前面的代码中,可空运算符用在SomeClass?类型的末尾,并且实例被初始化为null。当我们使用可空运算符时,我们可以将实例视为不同的类型 - SomeClass 可空,而不仅仅是SomeClass

然后,我们可以在代码中需要的时候初始化实例。我们将在第十四章中看到一些真实的例子,Android 对话框窗口,以及本书的其余部分,但现在,这是我们可能有条件地初始化这个空对象的一种假设方式:

var someBoolean = true
// Program execution or user input might change 
// the value of someBoolean 

if(someBoolean) {
   someInstance5 = someInstance
}else{
   someInstance5 = someInstance2
}

然后,我们可以像*常一样使用someInstance5

安全调用运算符

有时我们需要更灵活性。假设我们需要someInstance5中一个属性的值,但无法保证它已经初始化?在这种情况下,我们可以使用安全调用?运算符:

val someInt = someInstance5?.someImmutableProperty

在前面的代码中,如果someInstance5已经初始化,则将使用someImmutable属性中存储的值来初始化someInt。如果尚未初始化,则someInt将被初始化为 null。因此,请注意,someInt被推断为可空类型Int,而不是普通的Int

非空断言

会出现一些情况,我们无法在编译时保证实例已初始化,并且无法让编译器相信它会被初始化。在这种情况下,我们必须使用非空断言!!运算符来断言对象不为空。考虑以下代码:

val someBoolean = true
if(someBoolean) {
   someInstance5 = someInstance
}

someInstance5!!.someMutableProperty = 3

在前面的代码中,someInstance5可能尚未初始化,我们使用了非空断言运算符,否则代码将无法编译。

还要注意,如果我们编写了一些错误的逻辑,并且在使用时实例仍然为空,那么应用程序将崩溃。实际上,应尽量少地使用!!运算符,而应优先使用安全调用运算符。

回顾空值性

空值性还有更多内容,我们还没有涵盖到。讨论不同运算符的不同用法可能需要写很多页,而且还有更多的运算符。关键是,Kotlin 旨在帮助我们尽可能避免由于空对象而导致的崩溃。然而,看到可空类型、安全调用运算符和非空断言运算符的实际应用要比理论更有教育意义。在本书的其余部分中,我们将经常遇到这三种情况,希望它们的上下文会比它们的理论更有教育意义。

总结

在本章中,我们最终在代码和 UI 之间有了一些真正的交互。原来,每当我们向 UI 添加一个小部件时,我们都在添加一个我们可以在代码中引用的类的 Kotlin 实例。所有这些对象都存储在一个称为堆的内存区域中,与我们自己的类的任何实例一起。

现在我们已经可以学*并使用一些更有趣的小部件。我们将在下一章第十三章中看到很多这样的小部件,给 Android 小部件赋予生命,并且在本书的其余部分中我们还将继续介绍新的小部件。

第十三章:让 Android 小部件活起来

现在我们对 Android 应用的布局和编码有了很好的概述,以及我们对面向对象编程(OOP)的新见解以及如何从 Kotlin 代码中操作 UI,我们准备从 Android Studio 调色板中尝试更多的小部件。

有时,面向对象编程是一件棘手的事情,本章介绍了一些对初学者来说可能很尴尬的话题。然而,通过逐渐学*这些新概念并反复练*,它们将随着时间成为我们的朋友。

在本章中,我们将通过回到 Android Studio 调色板并查看半打小部件来扩大范围,这些小部件我们要么根本没有见过,要么还没有完全使用过。

一旦我们这样做了,我们将把它们全部放入布局,并练*用我们的 Kotlin 代码操纵它们。

在本章中,我们将涵盖以下主题:

  • 刷新我们对声明和初始化布局小部件的记忆

  • 看看如何只用 Kotlin 代码创*小部件

  • 看看EditTextImageViewRadioButton(和RadioGroup),SwitchCheckBoxTextClock小部件

  • 学*如何使用 lambda 表达式

  • 使用所有前述小部件和大量 lambda 表达式制作小部件演示迷你应用程序

让我们先快速回顾一下。

声明和初始化来自布局的对象

我们知道当我们在onCreate函数中调用setContentView时,Android 会膨胀所有小部件和布局,并将它们转换为堆上的真实实例。

我们知道要使用来自堆的小部件,我们必须具有正确类型的对象,通过其唯一的id属性。有时,我们必须明确从布局中获取小部件。例如,要获取具有id属性txtTitle并将其分配给一个名为myTextView的新对象的TextView类的引用,我们可以这样做:

// Grab a reference to an object on the Heap
val myTextView = findViewById<TextView>(R.id.txtTitle)

myTextView实例声明的左侧应该对前三章中声明的其他类的实例都很熟悉。这里的新东西是我们依赖函数的返回值来提供实例。findViewById函数确实返回在膨胀布局时在堆上创*的实例。所需的实例由与布局中小部件的id属性匹配的函数参数标识。看起来奇怪的<TextView>语法是TextView转换,因为函数返回超类类型View

现在,使用我们的myTextView实例变量,我们可以做任何TextView类设计的事情;例如,我们可以设置文本如下所示:

myTextView.text = "Hi there"

然后,我们可以让它消失,就像这样:

// Bye bye
myTextView.visibility = View.GONE

现在再次更改其文本并使其重新出现,如下所示:

myTextView.text = "BOO!"

// Surprise
myTextView.visibility = View.VISIBLE

值得一提的是,我们可以在 Kotlin 中操纵任何在以前章节中使用 XML 代码设置的属性。此外,我们已经暗示过,但实际上还没有看到,我们可以只使用代码从无中创*小部件。

从纯 Kotlin 创* UI 小部件而不使用 XML

我们还可以从不是指向布局中对象的 Kotlin 对象创*小部件。我们可以在代码中声明、实例化和设置小部件的属性,如下所示:

Val myButton = Button()

上述代码创*了一个新的Button实例。唯一的注意事项是Button实例必须是布局的一部分,才能被用户看到。因此,我们可以通过与以前使用findViewById函数相同的方式从 XML 布局中获取对布局元素的引用,或者可以在代码中创*一个新的布局。

假设我们的 XML 中有一个id属性等于linearLayout1LinearLayout,我们可以将前一行代码中的Button实例合并到其中,如下所示:

// Get a reference to the LinearLayout
val linearLayout = 
   findViewById<LinearLayout>(R.id.linearLayout)

// Add our Button to it
linearLayout.addView(myButton)

我们甚至可以通过首先创*一个新布局,然后添加所有我们想要添加的小部件,最后在具有所需小部件的布局上调用setContentView来纯粹使用 Kotlin 代码创*整个布局。

在下面的代码片段中,我们使用纯 Kotlin 创*了一个布局,尽管它非常简单,只有一个LinearLayout内部有一个Button实例:

// Create a new LinearLayout
val linearLayout = LinearLayout()

// Create a new Button
val myButton = Button()

// Add myButton to the LinearLayout
linearLayout.addView(myButton)

// Make the LinearLayout the main view of the app
setContentView(linearLayout)

这可能是显而易见的,但仍然值得一提的是,仅使用 Kotlin 设计详细和微妙的布局会更加麻烦,更难以可视化,而且不是最常见的方式。然而,有时我们会发现以这种方式做事情是有用的。

现在我们已经相当高级了,涉及到布局和小部件。然而,很明显,调色板中还有许多其他小部件(和 UI 元素)我们尚未探索或交互(除了将它们放在布局中并没有做任何处理);所以,让我们解决这个问题。

探索调色板-第一部分

让我们快速浏览一下调色板中以前未探索和未使用的项目,然后我们可以将其中一些拖放到布局中,看看它们可能具有的有用功能。然后我们可以实现一个项目来利用它们。

我们已经在上一章中探索了ButtonTextView。现在让我们更仔细地看看它们旁边的一些小部件。

EditText 小部件

EditText小部件就像其名称所示。如果我们向用户提供EditText小部件,他们确实可以编辑其中的文本。我们在早期章节中看到了这一点,但我们并没有做任何处理。我们没有看到的是如何捕获其中的信息,或者我们可以在哪里输入这个捕获文本的代码。

代码的下一个块假设我们已经声明了一个类型为EditText的对象,并使用它来获取 XML 布局中EditText小部件的引用。我们可能会为按钮点击编写类似以下代码的内容,也许是表单的“提交”按钮,但它可以放在我们应用程序中认为必要的任何地方:

val editTextContents = editText.text
// editTextContents now contains whatever the user entered

我们将在下一个应用程序中看到EditText小部件的真实情境。

ImageView 小部件

到目前为止,我们已经在布局上放置了几次图像,但在代码中我们还没有引用过它,也没有做任何处理。获取ImageView小部件的引用的过程与获取其他小部件的引用相同:

  1. 声明一个对象。

  2. 使用findViewById函数和有效的id属性获取引用,如下所示:

val imageView = findViewById<ImageView>(R.id.imageView)

然后,我们可以使用类似以下的代码对图像进行一些有趣的操作:

// Make the image 50% TRANSPARENT
imageView.alpha = .5f

注意

看起来奇怪的f值只是让编译器知道该值是Float类型,这是alpha属性所需的。

在前面的代码中,我们使用了imageViewalpha属性。alpha属性需要一个介于 0 和 1 之间的值。0 表示完全透明,而 1 表示完全不透明。我们将在下一个应用程序中使用ImageView的一些功能。

RadioButtons 和 RadioGroups

当用户需要从两个或多个互斥的选项中进行选择时,使用RadioButton小部件。这意味着选择一个选项时,其他选项将不被选择;就像在老式收音机上一样。请看下面截图中带有几个RadioButton小部件的简单RadioGroup小部件:

RadioButtons and RadioGroups

当用户做出选择时,其他选项将自动取消选择。我们通过将RadioButton小部件放置在 UI 布局中的RadioGroup小部件中来控制RadioButton小部件。当然,我们可以使用可视化设计工具简单地将一堆RadioButtons拖放到RadioGroup上。这样做时,XML 代码将如下所示:

<RadioGroup
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:layout_alignParentTop="true"
   android:layout_alignParentLeft="true"
   android:layout_alignParentStart="true"
   android:id="@+id/radioGroup">

   <RadioButton
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:text="Option 1"
         android:id="@+id/radioButton1"
         android:checked="true" />

   <RadioButton
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:text="Option 2"
         android:id="@+id/radioButton2"
         android:checked="false" />

   <RadioButton
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:text="Option 3"
         android:id="@+id/radioButton3"
         android:checked="false" />

<RadioGroup/>

请注意,正如前面的代码所强调的,每个RadioButton小部件和RadioGroup小部件都设置了适当的id属性。然后我们可以像预期的那样引用它们,如下面的代码所示:

// Get a reference to all our widgets
val radioGroup = 
   findViewById<RadioGroup>(R.id.radioGroup)

val rb1 = 
   findViewById<RadioButton>(R.id.radioButton1)

val rb2 = 
   findViewById<RadioButton> R.id.radioButton2)

val rb3 = 
   findViewById<RadioButton>(R.id.radioButton3)

然而,在实践中,我们几乎可以仅通过RadioGroup的引用来管理所有事情。

你可能会想知道他们何时被点击,或者跟踪哪一个被选中可能会很麻烦?我们需要一些来自 Android API 和 Kotlin 的帮助,以lambda的形式。

Lambda

RadioButton小部件是RadioGroup的一部分时,它们的视觉外观会被协调。我们所需要做的就是在任何给定的RadioButton小部件被按下时做出反应。当然,与任何其他按钮一样,我们需要知道它们何时被点击。

RadioButton小部件的行为与常规的Button小部件不同,只是在onClick中监听点击(在实现OnClickListener之后)是行不通的,因为RadioButton类不是设计成那样的。

我们需要做的是使用另一个 Kotlin 特性。我们需要一个特殊接口的实例,唯一的目的是监听RadioGroup上的点击。下面的代码块假设我们有一个名为radioGroupRadioGroup实例的引用;以下是要检查的代码:

radioGroup.setOnCheckedChangeListener {
   group, checkedId ->
   // Handle the clicks here
}

前面的代码,特别是从其开头的大括号({)到结束的大括号(})的setOnChekedChangeListener,被称为 lambda。

Lambda 是一个广泛的话题,随着我们的进展,它们将进一步探讨。它们在 Kotlin 中用于避免不必要的输入。编译器知道setOnCheckedChangeListener需要一个特殊的接口作为参数,并在幕后为我们处理这个问题。此外,编译器知道该接口有一个我们必须重写的抽象函数。在大括号的开头和结尾之间的代码是我们实现函数的地方。看起来奇怪的group, checkedId ->参数是这个函数的参数。

为了进一步讨论的目的,假设前面的代码是在onCreate函数中编写的。请注意,当调用onCreate时,大括号内的代码不会运行;它只是准备好实例(radioGroup),以便它准备好处理任何点击。我们现在将更详细地讨论这一点。

注意

这个看不见的接口被称为匿名类。

我们正在向radioGroup添加一个监听器,这与我们在第十二章中实现View.OnClickListener的效果是非常相似的,只是这一次,我们声明并实例化了一个监听器接口,并准备让它监听radioGroup,同时重写所需的函数,这种情况下(虽然我们看不到名称),是onCheckedChanged。这就像RadioGroup中的onClick等效。

如果我们使用上面的代码来创*和实例化一个类,监听我们的RadioGroup的点击,在onCreate函数中,它将在整个 Activity 的生命周期内监听和响应。现在我们需要学*的是如何在我们重写的onCheckedChanged函数中处理点击。

提示

有些学生觉得前面的代码很简单,而其他人觉得有点压力山大。这并不是决定你如何看待它的智力水*的指标,而是你的大脑喜欢学*的方式。你可以用两种方式来处理本章的信息:

接受代码的工作,继续前进,并在以后的编程生涯中重新审视事物的工作原理。

坚持成为本章主题的专家,并在继续前进之前花费大量时间来掌握它们。

我强烈推荐选项 1。有些主题在理解其他主题之前是无法掌握的。但是,当你需要先介绍前者才能继续后者时,问题就会出现。如果你坚持要时刻完全掌握,问题就会变得循环和无法解决。有时,重要的是要接受表面下还有更多。如果你能简单地接受我们刚刚看到的代码确实在幕后起作用,并且花括号内的代码是单击单选按钮时发生的事情;那么,你就准备好继续了。现在你可以去搜索 lambda 表达式;但是,要准备好花费很多时间来学*理论。在本章和整本书中,我们将重点关注实际应用,再次讨论 lambda 表达式。

编写重写函数的代码

请注意,当radioGroup实例被按下时传入此函数的一个参数是checkedId。此参数是一个Int类型,并且它保存当前选定的RadioButtonid属性。这几乎正是我们需要的。

也许令人惊讶的是,checkedId是一个Int类型。即使我们用字母数字字符声明它们,如radioButton1radioGroup,Android 也将所有 ID 存储为Int

当应用程序编译时,所有我们熟悉的人性化名称都会转换为Int。那么,我们怎么知道Int类型是指radioButton1radioButton2这样的 ID 呢?

我们需要做的是获取Int类型作为 ID 的实际对象的引用,使用Int id属性,然后询问对象其人性化的id值。我们将这样做:

val rb = group.findViewById<RadioButton>(checkedId)

现在我们可以使用rb中存储的引用来检索我们熟悉的id属性,该属性用于当前选定的RadioButton小部件,使用id属性的 getter 函数,如下所示:

rb.id

因此,我们可以通过使用when块处理RadioButton的点击,每个可能被按下的RadioButton都有一个分支,rb.id作为条件。

以下代码显示了我们刚刚讨论的onCheckedChanged函数的全部内容:

// Get a reference to the RadioButton 
// that is currently checked
val rb = group.findViewById<RadioButton>(checkedId)

// branch the code based on the 'friendly' id
when (rb.id) {

   R.id.radioButton1->
          // Do something here

   R.id.radioButton2->
          // Do something here

   R.id.radioButton3->
          // Do something here

}
// End when block

在下一个工作迷你应用程序中看到这一点的实际效果,我们可以按下按钮,这将使情况更加清晰。

让我们继续探索调色板。

探索调色板-第二部分,以及更多的 lambda。

现在我们已经看到了 lambda 和匿名类和接口如何工作,特别是与RadioGroupRadioButton一起,我们现在可以继续探索调色板,并查看如何使用更多的 UI 小部件。

Switch小部件

Switch小部件就像Button小部件一样,只是它有两个固定的状态,可以读取和响应。

Switch小部件的一个明显用途是显示和隐藏某些内容。还记得我们在第十二章的 Kotlin Meet UI 应用程序中使用Button来显示和隐藏TextView小部件吗?

每次我们隐藏或显示TextView小部件时,我们都会更改Button上的text属性,以表明如果再次单击它会发生什么。对于用户来说,以及对于我们作为程序员来说,更直观的做法可能是使用Switch小部件,如下面的屏幕截图所示:

Switch 小部件

以下代码假设我们已经有一个名为mySwitch的对象,它是布局中Switch对象的引用。我们可以像在第十二章中的Kotlin Meet UI应用程序中那样显示和隐藏TextView小部件。

监听并响应点击/切换,我们再次使用匿名类。然而,这次我们使用CompoundButton版本的OnCheckedChangeListener。与之前一样,这些细节是推断出来的,我们可以使用非常类似和简单的代码,就像处理单选按钮小部件时一样。

我们需要重写onCheckedChanged函数,该函数有一个Boolean参数isCheckedisChecked变量对于关闭是 false,对于打开是 true。

这是我们可以更直观地通过隐藏或显示代码来替换这段文字的方法:

mySwitch.setOnCheckedChangeListener{
   buttonView, isChecked->
      if(isChecked){
            // Currently visible so hide it
            txtValue.visibility = View.INVISIBLE

      }else{
            // Currently hidden so show it
            txtValue.visibility = View.VISIBLE
      }
}

如果匿名类或 lambda 代码看起来有点奇怪,不要担心,因为随着我们的使用,它会变得更加熟悉。现在我们再次看看CheckBox时,我们将这样做。

复选框小部件

使用CheckBox小部件,我们只需在特定时刻(例如在单击特定按钮时)检测其状态(选中或未选中)。以下代码让我们可以看到这可能会发生的情况,再次使用匿名类和 lambda 作为监听器:

myCheckBox.setOnCheckedChangeListener{   
   buttonView, isChecked->

   if (myCheckBox.isChecked) {
         // It's checked so do something
   } else {
         // It's not checked do something else
   }    
}

在先前的代码中,我们假设myCheckBox已经被声明和初始化,然后使用与我们用于Switch相同类型的匿名类来检测和响应点击。

TextClock 小部件

在我们的下一个应用程序中,我们将使用TextClock小部件展示一些其特性。由于这个小部件无法从调色板中拖放,我们需要直接将 XML 代码添加到布局中。这就是TextClock小部件的样子:

TextClock 小部件

作为使用TextClock的示例,这是我们将如何将其时间设置为与欧洲布鲁塞尔相同的时间:

tClock.timeZone = "Europe/Brussels"

先前的代码假设tClock是布局中TextClock小部件的引用。

有了所有这些额外的信息,让我们制作一个应用程序,比我们迄今为止所做的更实用地使用 Android 小部件。

小部件探索应用程序

我们刚刚讨论了六个小部件——EditTextImageViewRadioButton(和RadioGroup)、SwitchCheckBoxTextClock。让我们制作一个可用的应用程序,并对每个小部件进行一些实际操作。我们还将再次使用Button小部件和TextView小部件。

在此布局中,我们将使用LinearLayout作为容纳一切的布局类型,并在LinearLayout内部使用多个RelativeLayout实例。

RelativeLayout已被ConstraintLayout取代,但它们仍然常用,并且值得尝试。当您在RelativeLayout中构*布局时,您会发现 UI 元素的行为与ConstraintLayout非常相似,但底层的 XML 不同。不需要详细了解这个 XML,而是使用RelativeLayout将允许我们展示 Android Studio 如何使您能够将这些布局转换为ConstraintLayout的有趣方式。

请记住,您可以参考下载包中的完整代码。此应用程序可以在Chapter13/Widget Exploration文件夹中找到。

设置小部件探索项目和 UI

首先,我们将设置一个新项目并准备 UI 布局。这些步骤将在屏幕上放置所有小部件并设置id属性,准备好引用它们。在开始之前,看一下目标布局并运行它会有所帮助,如下截图所示:

设置小部件探索项目和 UI

这个应用程序将演示这些小部件的工作原理:

  • 单选按钮允许用户更改显示在时钟上的时间,以选择四个时区中的一个。

  • 单击Capture按钮将更改右侧TextView小部件的text属性为当前左侧EditText小部件中的内容。

  • 这三个CheckBox小部件将向 Android 机器人图像添加和删除视觉效果。在先前的截图中,图像被调整大小(变大)并应用了颜色着色。

  • Switch小部件将打开和关闭TextView小部件,后者显示在EditText小部件中输入的信息(在单击按钮时捕获)。

确切的布局位置并不重要,但指定的id属性必须完全匹配。因此,让我们执行以下步骤来设置一个新项目并准备 UI 布局:

  1. 创*一个名为Widget Exploration的新项目,并使用空活动项目模板及其通常的设置,除了一个小改变。将最低 API 级别选项设置为API 17:Android 4.2(Jelly Bean),并将所有其他设置保持为默认设置。我们使用 API 17 是因为TextClock小部件的一个功能需要我们这样做。我们仍然支持超过 98%的所有 Android 设备。

  2. 让我们创*一个新的布局文件,因为我们希望我们的新布局基于LinearLayout。在项目资源管理器中右键单击layout文件夹,然后从弹出菜单中选择新* | 布局资源文件

  3. 新资源文件窗口中,在文件名字段中输入exploration_layout.xml,然后在根元素字段中输入LinearLayout;现在点击确定

  4. 属性窗口中,将LinearLayoutorientation属性更改为horizontal

  5. 使用设计视图上方的下拉控件,确保选择了横向方向的*板电脑。

注意

如需了解如何创**板电脑模拟器,请参阅第三章探索 Android Studio 和项目结构。如需关于如何操作模拟器方向的*议,请参阅第五章使用 CardView 和 ScrollView 创*美观布局

  1. 现在我们可以开始创*我们的布局。从工具栏的Legacy类别中将三个RelativeLayout布局拖放到设计中,以创*我们设计的三个垂直分区。在这一步骤中,您可能会发现使用组件树窗口更容易。

  2. 依次为每个RelativeLayout小部件设置weight属性为.33。现在我们有了三个相等的垂直分区,就像下面的截图一样:设置小部件探索项目和 UI

  3. 检查组件树窗口是否如下截图所示:设置小部件探索项目和 UI

注意

如果您想使用ConstraintLayout而不是RelativeLayout,那么以下说明将几乎相同。只需记住通过单击推断约束按钮或手动设置约束来设置 UI 的最终位置,如第四章开始使用布局和 Material Design中所讨论的那样。或者,您可以按照本教程中详细说明的方式构*布局,并使用稍后在本章中讨论的转换为 Constraint 布局功能。这对于使用您已有并希望使用的布局非常有用,但更倾向于使用运行速度更快的ConstraintLayout

  1. 将一个Switch小部件拖放到右侧RelativeLayout小部件的顶部中心位置,然后在其下方从工具栏中拖放一个TextView。您的布局右侧现在应如下截图所示:设置小部件探索项目和 UI

  2. 将三个CheckBox小部件依次拖放在一起,然后将一个ImageView小部件拖放到它们下方的中央RelativeLayout上。在弹出的资源对话框中,选择项目 | ic_launcher以将 Android 图标用作ImageView小部件的图像。中央列现在应如下所示:设置小部件探索项目和 UI

  3. 将一个RadioGroup小部件拖放到左侧的RelativeLayout上。

  4. RadioGroup小部件内添加四个RadioButton小部件。使用组件树窗口可以更轻松地完成此步骤。

  5. RadioGroup小部件下方,从调色板的文本类别中拖动一个纯文本小部件。请记住,尽管它的名字是这样,但这是一个允许用户在其中输入一些文本的小部件。很快,我们将看到如何捕获和使用输入的文本。

  6. 纯文本小部件的右侧添加一个Button小部件。您的左侧RelativeLayout应如下截图所示:

此时组件树窗口将如下截图所示:

设置小部件探索项目和 UI

  1. 现在我们可以开始使用所有这些小部件与我们的 Kotlin 代码。现在为刚刚布置的小部件添加以下属性:

注意

| CheckBox (top) | id | checkBoxTransparency |

Widget type Property 要设置的值
RadioGroup id radioGroup
请注意,一些属性可能已经默认正确。
RadioButton (top) text London
RadioButton (top) checked 选择“勾”图标为 true
RadioButton (second) id radioButtonBeijing
RadioButton (second) text Beijing
RadioButton (third) id radioButtonNewYork
RadioButton (third) text New York
CheckBox (bottom) id checkBoxReSize
RadioButton (bottom) text European Empire
EditText id editText
Button id button
Button text Capture
CheckBox (top) text Transparency
RadioButton (bottom) id radioButtonEuropeanEmpire
CheckBox (middle) text Tint
CheckBox (middle) id checkBoxTint
CheckBox (bottom) text Resize
设置小部件探索项目和 UI
ImageView id imageView
Switch id switch1
Switch enabled 选择“勾”图标为 true
Switch clickable 选择“勾”图标为 true
TextView id textView
TextView textSize 34sp
TextView layout_width match_parent
TextView layout_height match_parent
  1. 现在切换到文本选项卡,查看布局的 XML 代码。找到第一个(左侧)RelativeLayout列的末尾,如下面的代码清单所示。我已经在下面的代码中添加了一个 XML 注释并对其进行了突出显示:
...
...
   </RadioGroup>

   <EditText
         android:id="@+id/editText2"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_alignParentTop="true"
         android:layout_alignParentEnd="true"
         android:layout_marginTop="263dp"
         android:layout_marginEnd="105dp"
         android:ems="10"
         android:inputType="textPersonName"
         android:text="Name" />

   <Button
         android:id="@+id/button2"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_alignParentBottom="true"
         android:layout_centerHorizontal="true"
         android:layout_marginBottom="278dp"
         android:text="Button" />

   <!-- Insert TextClock here-->

</RelativeLayout>
  1. <!--Insert TextClock Here-->注释之后,插入以下TextClock小部件的 XML 代码。请注意,注释是我在上一个清单中添加的,以指示您放置代码的位置。您的代码中不会出现该注释。我们之所以这样做是因为TextClock不能直接从调色板中获取。以下是在注释之后添加的代码:
<TextClock
   android:id="@+id/textClock"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_alignParentBottom="true"
   android:layout_centerHorizontal="true"
   android:layout_gravity="center_horizontal"
   android:layout_marginBottom="103dp" 
   android:textSize="54sp" />
  1. 切换到设计选项卡,并调整布局,使其尽可能接近以下参考图表,但如果您具有正确的 UI 类型和正确的id属性,则即使布局不完全相同,代码仍将正常工作:设置小部件探索项目和 UI

我们刚刚设置了布局所需的属性。除了一些小部件类型对我们来说是新的,布局稍微更加复杂之外,我们并没有做过什么新的事情。

| RadioButton (top) | id | radioButtonLondon |

编写小部件探索应用程序

我们需要更改的 Kotlin 代码的第一部分是确保我们的新布局被显示出来。我们可以通过将onCreate函数中对setContentView函数的调用更改为以下内容来实现:

setContentView(R.layout.exploration_layout)

这个应用程序需要很多import语句,所以让我们一开始就把它们全部添加上,以免在进行过程中不断提到它们。添加以下import语句:

import androidx.appcompat.app.AppCompatActivity
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.widget.CompoundButton
import android.widget.RadioButton
import kotlinx.android.synthetic.main.exploration_layout.*

前面的代码还包括…exploration_layout.*代码(如前面的代码中所突出显示的)以自动启用我们刚刚配置的id属性作为我们 Kotlin 代码中的实例名称。这样可以避免多次使用findViewByID函数。这种方式并不总是可行的,有时需要知道如何使用findViewByID函数,就像我们在“在布局部分声明和初始化对象”中讨论的那样。

编码 CheckBox 小部件

现在我们可以创*一个 lambda 来监听和处理复选框的点击。以下三个代码块依次实现了每个复选框的匿名类。然而,它们各自不同的地方在于我们如何响应点击,我们将依次讨论每一个。

改变透明度

第一个复选框标记为Transparency,我们使用imageView实例上的alpha属性来改变其透明度。alpha属性需要一个介于 0 和 1 之间的浮点值作为参数。

0 是不可见的,1 完全不透明。因此,当选中此复选框时,我们将alpha属性设置为.1,使图像几乎不可见;然后,当取消选中时,我们将其设置为1,即完全可见且没有透明度。onCheckedChanged函数的Boolean isChecked参数包含一个 true 或 false 值,表示复选框是否被选中。

onCreate函数中的setContentView函数调用之后添加以下代码:

// Listen for clicks on the button,
// the CheckBoxes and the RadioButtons

// setOnCheckedChangeListener requires an interface of type
// CompoundButton.OnCheckedChangeListener. In turn this interface
// has a function called onCheckedChanged
// It is all handled by the lambda
checkBoxTransparency.setOnCheckedChangeListener({
   view, isChecked ->
      if (isChecked) {
         // Set some transparency
         imageView.alpha = .1f
      } else {
         // Remove the transparency
         imageView.alpha = 1f
      }
})

在下一个匿名类中,我们处理标记为Tint的复选框。

改变颜色

onCheckedChanged函数中,我们使用setColorFilter函数在imageView上叠加一个颜色层。当isChecked为 true 时,我们叠加一个颜色,当isChecked为 false 时,我们移除它。

setColorFilter函数以ARGBalpharedgreenblue)格式的颜色作为参数。颜色由Color类的argb函数提供。argb函数的四个参数分别是 alpha、red、green 和 blue 的值。这四个值创*了一种颜色。在我们的例子中,150, 255, 0, 0的值创*了强烈的红色色调,而0, 0, 0, 0的值则完全没有色调。

提示

要了解更多关于Color类的信息,请访问 Android 开发者网站:developer.android.com/reference/android/graphics/Color.html,要更多了解 RGB 颜色系统,请查看维基百科:en.wikipedia.org/wiki/RGB_color_model

onCreate函数中的上一个代码块之后添加以下代码:

checkBoxTint.setOnCheckedChangeListener({
   view, isChecked ->
   if (isChecked) {
      // Checked so set some tint
      imageView.setColorFilter(Color.argb(150, 255, 0, 0))
   } else {
      // No tint required
      imageView.setColorFilter(Color.argb(0, 0, 0, 0))
   }
})

现在我们将看到如何通过调整ImageView小部件的大小来缩放 UI。

改变大小

在处理Resize标记的复选框的匿名类中,我们使用scaleXscaleY属性来调整机器人图像的大小。当我们将scaleX设置为 2,scaleY设置为 2 时,我们将使图像的大小加倍,而将值设置为 1 将使图像恢复到其正常大小。

onCreate函数中的上一个代码块之后添加以下代码:

checkBoxReSize.setOnCheckedChangeListener({
   view, isChecked ->
   if (isChecked) {
      // It's checked so make bigger
      imageView.scaleX = 2f
      imageView.scaleY = 2f
   } else {
      // It's not checked make regular size
      imageView.scaleX = 1f
      imageView.scaleY = 1f
   }
})

现在我们将处理这三个单选按钮。

编码 RadioButton 小部件

由于它们是RadioGroup小部件的一部分,我们可以处理它们比处理CheckBox对象时更简洁。

首先,我们通过在radioGroup实例上调用clearCheck()来确保它们一开始是清除的。然后,我们创*了OnCheckedChangeListener类型的匿名类,并重写了onCheckedChanged函数,使用了一个简短而甜美的 lambda。

当从 RadioGroup 小部件中点击任何RadioButton时,将调用此函数。我们需要做的就是获取被点击的RadioButton小部件的id属性,并做出相应的响应。我们将使用when语句来实现三条可能的执行路径 - 每个RadioButton小部件对应一条。

请记住,当我们首次讨论RadioButton时,在onCheckedChangedcheckedId参数中提供的id属性是Int类型。这就是为什么我们必须首先从checkedId创*一个新的RadioButton对象的原因:

val rb = group.findViewById<View>(checkedId) as RadioButton

然后,我们可以使用新的RadioButton对象的id属性的 getter 作为when的条件,如下所示:

when (rb.id) {
   …

然后,在每个分支中,我们使用timeZone属性的 setter,并将正确的 Android 时区代码作为参数。

提示

您可以在gist.github.com/arpit/1035596上查看所有 Android 时区代码。

添加以下代码,其中包含我们刚刚讨论的所有内容。将其添加到处理复选框的先前代码之后的onCreate函数中:

// Now for the radio buttons
// Uncheck all buttons
radioGroup.clearCheck()

radioGroup.setOnCheckedChangeListener {
   group, checkedId ->
   val rb = group.findViewById<View>(checkedId) as RadioButton

   when (rb.id) {
      R.id.radioButtonLondon ->
         textClock.timeZone = "Europe/London"

      R.id.radioButtonBeijing ->
         textClock.timeZone = "CST6CDT"

      R.id.radioButtonNewYork ->
         textClock.timeZone = "America/New_York"

      R.id.radioButtonEuropeanEmpire ->
         textClock.timeZone = "Europe/Brussels"
   }
}

现在是时候尝试一些稍微新的东西了。

使用 lambda 来处理常规 Button 小部件的点击

在我们将要编写的下一个代码块中,我们将使用 lambda 来实现一个匿名类来处理常规Button小部件的点击。我们调用button.setOnclickListener,就像我们之前做过的那样。但是这一次,我们不是将this作为参数传递,而是创*一个全新的View.OnClickListener类型的类,并覆盖onClick函数作为参数,就像我们之前的其他匿名类一样。与我们之前的类一样,代码是被推断的,我们有简短、简洁的代码,其中我们的代码没有被太多的细节所淹没。

提示

在这种情况下,这种方法是可取的,因为只有一个按钮。如果我们有很多按钮,那么让MainActivity实现View.OnClickListener,然后覆盖onClick以处理所有点击的函数可能更可取,就像我们之前做过的那样。

onClick函数中,我们使用text属性的 setter 来设置textView上的text属性,然后使用editText实例的text属性的 getter 来获取用户在EditText小部件中输入的任何文本(如果有的话)。

onCreate函数中的上一个代码块之后添加以下代码:

/*
   Let's listen for clicks on our "Capture" Button.
   The compiler has worked out that the single function
   of the required interface has a single parameter.
   Therefore, the syntax is shortened (->) is removed
   and the only parameter, (should we have needed it)
   is declared invisibly as "it"
*/
button.setOnClickListener {
   // it... accesses the view that was clicked

   // We want to act on the textView and editText instances
   // Change the text on the TextView
   // to whatever is currently in the EditText
   textView.text = editText.text
}

接下来,我们将处理 Switch 小部件。

编写 Switch 小部件的代码

接下来,我们将创*另一个匿名类来监听和处理我们的Switch小部件的更改。

isChecked变量为true时,我们显示TextView小部件,当它为 false 时,我们隐藏它。

onCreate函数中的上一个代码块之后添加以下代码:

// Show or hide the TextView
switch1.setOnCheckedChangeListener {
   buttonView, isChecked ->
   if (isChecked) {
      textView.visibility = View.VISIBLE
   } else {
      textView.visibility = View.INVISIBLE
   }
}

现在我们可以运行我们的应用程序并尝试所有功能。

提示

在 Windows 上,可以通过按Ctrl +F11键组合或在 macOS 上按Ctrl +fn+F11将 Android 模拟器旋转为横向模式。

运行 Widget Exploration 应用程序

尝试选中单选按钮,看看时区在时钟上的变化。在下面的图片中,我用 Photoshop 剪裁了一些截图,以显示选择新时区时时间的变化:

运行 Widget Exploration 应用程序

EditText小部件中输入不同的值,然后单击按钮,以查看它获取文本并在自身上显示它,就像本教程开头的截图中演示的那样。

通过使用上面的Switch小部件,通过不同的复选框的选中和未选中的组合以及显示和隐藏TextView小部件来改变应用程序中的图像。以下截图显示了两种复选框和开关小部件的组合,用于演示目的:

运行 Widget Exploration 应用程序

提示

透明度在印刷书中并不是很清晰,所以我没有勾选那个框。一定要在模拟器或真实设备上试一下。

将布局转换为 ConstraintLayout

最后,正如承诺的那样,这就是我们如何将布局转换为运行更快的ConstraintLayout

  1. 切换回设计选项卡

  2. 右键单击父布局 - 在这种情况下是LinearLayout - 并选择将 LinearLayout 转换为 ConstraintLayout,如下面的截图所示:将布局转换为 ConstraintLayout

现在你可以将任何旧的RelativeLayout布局转换为更新更快的ConstraintLayout小部件,同时构*你自己的RelativeLayout

总结

在本章中,我们学到了很多。除了探索了大量的小部件,我们还学会了如何在 Kotlin 代码中实现小部件而不需要任何 XML,我们使用了我们的第一个匿名类,使用简短、简洁的代码形式的 lambda 来处理小部件的点击,我们将所有新的小部件技能都应用到了一个工作中的应用程序中。

现在让我们继续看另一种显著增强我们 UI 的方法。

在下一章中,我们将看到一个全新的 UI 元素,我们不能只从调色板中拖放,但我们仍然会得到来自 Android API 的大量帮助。我们将学*有关对话框窗口的知识。我们还将开始制作迄今为止最重要的应用程序,名为 Note to self。这是一个备忘录、待办事项和个人笔记应用程序。

第十四章:Android 对话框窗口

在本章中,我们将学*如何向用户呈现弹出式对话框窗口。然后,我们可以将我们所知道的一切放入我们的第一个多章节应用程序Note to self的第一阶段。然后,我们将在本章和接下来的四章(直到第十八章,本地化)中学*更多关于 Android 和 Kotlin 的特性,然后使用我们新获得的知识来增强 Note to self 应用程序。

在每一章中,我们还将构*一系列与主要应用程序分开的较小的应用程序。那么,第十四章Android 对话框窗口对你有什么期待呢?本章将涵盖以下主题:

  • 实现一个带有弹出式对话框的简单应用程序

  • 学*如何使用DialogFragment来开始 Note to self 应用程序

  • 启动 Note to self 应用程序,并学*如何在项目中添加字符串资源,而不是在布局中硬编码文本

  • 实现更复杂的对话框以捕获用户输入

那么,让我们开始吧。

对话框窗口

在我们的应用程序中,我们经常会想要向用户显示一些信息,或者询问是否确认弹出窗口中的操作。这就是所谓的对话框窗口。如果你快速浏览一下 Android Studio 的调色板,你可能会惊讶地发现根本没有提到对话框窗口。

Android 中的对话框窗口比简单的小部件甚至整个布局更高级。它们是可以拥有自己的布局和其他 UI 元素的类。

在 Android 中创*对话框窗口的最佳方式是使用DialogFragment类。

提示

片段在 Android 中是一个广泛而重要的主题,我们将在本书的后半部分花费大量时间来探索和使用它们。然而,为我们的用户创*一个整洁的弹出式对话框(使用DialogFragment)是对片段的一个很好的介绍,并且一点也不复杂。

创*对话框演示项目

我们之前提到,在 Android 中创*对话框窗口的最佳方式是使用DialogFragment类。然而,在 Android 中创*对话框的另一种方式可能会更简单一些。这种更简单的Dialog类的问题在于它在 Activity 生命周期中的支持不是很好。甚至可能会导致应用程序意外崩溃。

如果你正在编写一个只需要一个简单弹出式对话框的固定方向布局的应用程序,可以说应该使用更简单的Dialog类。但是,由于我们的目标是构*具有先进功能的现代专业应用程序,因此忽略这个类将会使我们受益匪浅。

在 Android Studio 中使用空活动项目模板创*一个名为Dialog Demo的新项目。该项目的完成代码位于下载包的Chapter14/Dialog Demo文件夹中。

编写 DialogFragment 类

通过右键单击包含MainActivity.kt文件的包名称的文件夹,在 Android Studio 中创*一个新的类。选择新* | Kotlin 文件/类,命名为MyDialog,并在下拉选择器中选择。单击确定以创*类。

你需要做的第一件事是将类声明更改为继承自DialogFragment。此外,让我们添加在这个类中需要的所有导入。当你这样做后,你的新类将如下所示:

import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment

class MyDialog : DialogFragment() {    
}

现在,让我们一点一点地向这个类添加代码,并解释每一步发生了什么。

与 Android API 中的许多类一样,DialogFragment为我们提供了可以重写以与类中发生的不同事件交互的函数。

添加覆盖onCreateDialog函数的以下突出显示的代码。仔细研究它,然后我们将检查发生了什么:

class MyDialog : DialogFragment() {

    override
 fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

 // Use the Builder class because this dialog
 // has a simple UI.
 // We will use the more flexible onCreateView function
 // instead of onCreateDialog in the next project
 val builder = AlertDialog.Builder(this.activity!!)

 // More code here soon
 }
}

注意

代码中有一个错误,因为我们缺少返回语句,需要返回一个Dialog类型的对象。我们将在完成函数的其余部分编码后添加这个返回语句。

在我们刚刚添加的代码中,我们首先添加了重写的onCreateDialog函数,当我们稍后使用MainActivity类的代码显示对话框时,Android 将调用它。

然后,在onCreateDialog函数内部,我们得到了一个新类的实例。我们声明并初始化了一个AlertDialog.Builder类型的对象,它需要一个对MainActivity类的引用传递给它的构造函数。这就是为什么我们使用activity!!作为参数;我们断言该实例不为空(!!)。

提示

参考第十二章,“将我们的 Kotlin 连接到 UI 和可空性”,了解非空断言(!!)的用法。

activity属性是Fragment类(因此也是DialogFragment)的一部分,它是一个对将创*DialogFragment实例的Activity类实例的引用。在这种情况下,这是我们的MainActivity类。

现在我们已经声明并初始化了builder,让我们看看我们可以用它做什么。

使用链接来配置 DialogFragment 类

现在我们可以使用我们的builder对象来完成其余的工作。在接下来的三个代码块中有一些略微奇怪的地方。如果你往前看并快速扫描它们,你会注意到有三次使用了点运算符,但只有一次使用是实际放在builder对象旁边的。这表明这三个明显的代码块实际上只是编译器的一行代码。

我们之前已经见过这里发生的事情,但情况没有那么明显。当我们创*一个Toast消息并在其末尾添加.show()调用时,我们正在链接。也就是说,我们在同一个对象上按顺序调用多个函数。这相当于编写多行代码;只是这样更清晰、更简洁。

onCreateDialog中添加这段代码,它利用了链接,然后我们将讨论它:

// Dialog will have "Make a selection" as the title
builder.setMessage("Make a selection")
   // An OK button that does nothing
   .setPositiveButton("OK", { dialog, id ->
      // Nothing happening here
   })
   // A "Cancel" button that does nothing
   .setNegativeButton("Cancel", { dialog, id ->
      // Nothing happening here either
   })

我们添加的代码的三个部分可以解释如下:

  1. 在使用链接的三个代码块中的第一个中,我们调用builder.setMessage,它设置用户在对话框中看到的主要消息。另外,需要注意的是,在链接函数调用的不同部分之间添加注释是可以的,因为编译器完全忽略这些注释。

  2. 然后,我们使用setPositiveButton函数向对话框添加一个按钮,第一个参数将其文本设置为OK。第二个参数是一个实现DialogInterface.OnClickListener的 lambda,用于处理按钮的点击。请注意,我们不会在onClick函数中添加任何代码,但我们可以,就像我们在上一章中所做的那样。我们只是想看到这个简单的对话框,我们将在下一个项目中进一步进行。

  3. 接下来,我们在同一个builder对象上调用另一个函数。这次是setNegativeButton函数。同样,两个参数将Cancel设置为按钮的文本,使用 lambda 来设置监听点击。同样,为了这个演示的目的,我们不会在重写的onClick函数中执行任何操作。

接下来,我们将编写return语句以完成函数并移除错误。在onCreateDialog函数的最后(但保持在最终大括号内部)添加return语句:

   // Create the object and return it
   return builder.create()
}// End of onCreateDialog

这行代码的最后效果是将我们新的、完全配置好的对话框窗口返回给MainActivity(它首先会调用onCreateDialog)。我们很快将看到并添加这个调用代码。

现在我们有了从FragmentDialog继承的MyDialog类,我们所要做的就是声明MyDialog的一个实例,实例化它,并调用它重写的onCreateDialog函数。

使用 DialogFragment 类

在转向代码之前,让我们通过以下步骤向我们的布局添加一个按钮:

  1. 切换到activity_main.xml选项卡,然后切换到Design选项卡。

  2. Button小部件拖放到布局中,并确保其id属性设置为button

  3. 单击推断约束按钮,将按钮约束到您放置的位置,但位置并不重要;我们将如何使用它来创*我们的MyDialog类的实例是关键的教训。

现在切换到MainActivity.kt选项卡,我们将使用 lambda 来处理新按钮的点击,就像我们在第十三章中所做的那样,在 Widget 探索应用程序中。我们这样做是因为布局中只有一个按钮,这种方式似乎比另一种方式更明智和更紧凑(即实现OnClickListener接口,然后在整个MainActivity类中重写onClick,就像我们在第十二章中所做的那样,将我们的 Kotlin 连接到 UI 和可空性)。

MainActivityonCreate函数中添加以下代码,放在setContentView调用之后:

val button = findViewById<Button>(R.id.button)
// We could have removed the previous line of code by
// adding the ...synthetic.main.activity_main.* import
// as an alternative

button.setOnClickListener {
   val myDialog = MyDialog()
   myDialog.show(supportFragmentManager, "123")
   // This calls onCreateDialog
   // Don't worry about the strange looking 123
   // We will find out about this in chapter 18
}

注意

需要以下import语句来支持此代码:

import android.widget.Button;

请注意,代码中唯一发生的事情是setOnClickListener lambda 覆盖了onClick。这意味着当按钮被按下时,将创*MyDialog的一个新实例并调用其show函数,该函数将显示我们在MyDialog类中配置的对话框窗口。

show函数需要一个对FragmentManager的引用,我们从supportFragmentManager属性中获取。这是跟踪和控制Activity实例的所有片段实例的类。我们还传入一个 ID("123")。

更多关于FragmentManager的细节将在我们更深入地研究片段时揭示,从第二十四章开始,设计模式、多个布局和片段

注意

我们使用supportFragmentManager属性的原因是因为我们通过扩展AppCompatActivity来支持旧设备。如果我们简单地扩展Activity,那么我们可以使用fragmentManager属性。缺点是该应用程序将无法在许多旧设备上运行。

现在我们可以运行应用程序,并欣赏我们点击布局中的按钮时出现的新对话框窗口。请注意,单击对话框窗口中的任一按钮都将关闭它;这是默认行为。以下屏幕截图显示了我们的对话框窗口在*板模拟器上的运行情况:

使用 DialogFragment 类

接下来,我们将制作另外两个实现对话框的类,作为我们多章节备忘录应用程序的第一阶段。我们将看到对话框窗口几乎可以有我们选择的任何布局,并且我们不必依赖Dialog.Builder类提供给我们的简单布局。

备忘录应用程序

欢迎来到本书中我们将实现的多章应用程序中的第一个。在做这些项目时,我们将比做较小的应用程序更专业。在这个项目中,我们将使用字符串资源而不是在布局中硬编码文本。

有时,当您尝试学*新的 Android 或 Kotlin 主题时,这些东西可能会过度,但它们对于尽快在真实项目中开始使用是有用且重要的。它们很快就会变得像第二天性一样,我们的应用程序质量将受益于此。

使用字符串资源

在第三章探索 Android Studio 和项目结构中,我们讨论了在布局文件中使用字符串资源而不是硬编码文本。这样做有一些好处,但也稍微冗长。

由于这是我们的第一个多章节项目,现在是做正确的时候。如果您想快速了解字符串资源的好处,请参阅第三章探索 Android Studio 和项目结构

如何获取 Note to self 应用程序的代码文件

完全完成的应用程序,包括所有的代码和资源,可以在下载包的Chapter18/Note to self文件夹中找到。由于我们将在接下来的五章中实施这个应用程序,因此在每一章结束时查看部分完成的可运行应用程序也是有用的。部分完成的可运行应用程序及其所有相关的代码和资源可以在各自的文件夹中找到:

Chapter14/Note to self

Chapter16/Note to self

Chapter17/Note to self

Chapter18/Note to self

注意

在第十五章处理数据和生成随机数中没有 Note to self 的代码,因为虽然我们会学*一些在 Note to self 中使用的主题,但直到第十六章适配器和回收器,我们才对应用程序进行更改。

请注意,每个文件夹都包含一个独立的可运行项目,并且也包含在自己独特的包中。这样你就可以很容易地看到应用程序在完成给定章节后的运行情况。在复制和粘贴代码时,要小心不要包括包名称,因为它可能与您的包名称不同,导致代码无法编译。

如果您正在跟着做,并打算从头到尾构* Note to self,我们将简单地构*一个名为Note to self的项目。然而,您仍然可以随时查看每个章节的项目文件中的代码,进行一些复制和粘贴。只是不要复制文件顶部的包指令。另外,请注意,在说明书的几个地方,您将被要求删除或替换前几章的偶尔一行代码。

因此,即使您复制和粘贴的次数多于输入代码的次数,请务必完整阅读说明,并查看书中的代码,以获取可能有用的额外注释。

在每一章中,代码将被呈现为如果您已经完全完成上一章,将显示来自早期章节的代码,必要时作为新代码的上下文。

每一章都不会完全致力于 Note to self 应用程序。我们还将学*其他相关内容,并构*一些更小更简单的应用程序。因此,当我们开始实施 Note to self 时,我们将在技术上做好准备。

完成的应用程序

以下功能和屏幕截图来自完成的应用程序。在开发的各个阶段,它显然会略有不同。必要时,我们将查看更多图像,作为提醒,或者查看开发过程中的差异。

完成的应用程序将允许用户点击应用程序右下角的浮动按钮图标,打开一个对话框窗口以添加新的便签。以下屏幕截图显示了这个突出的功能:

完成的应用程序

左侧的屏幕截图显示了要点击的按钮,右侧的屏幕截图显示了用户可以添加新便签的对话框窗口。

最终,随着用户添加更多的笔记,他们将在应用程序的主屏幕上拥有所有已添加的笔记列表,如下截图所示。用户可以选择笔记是重要想法和/或待办事项笔记:

完成的应用程序

他们将能够滚动列表并点击一个笔记,以在专门用于该笔记的另一个对话框窗口中查看它。以下是显示笔记的对话框窗口:

完成的应用程序

还将有一个非常简单的设置屏幕,可以从菜单中访问,允许用户配置笔记列表是否以分隔线格式化。以下是设置菜单选项的操作:

完成的应用程序

现在我们确切地知道我们要构*什么,我们可以继续并开始实施它。

构*项目

现在让我们创*我们的新项目。将项目命名为Note to Self,并使用Basic Activity模板。请记住,从第三章探索 Android Studio 和项目结构中得知,此模板将生成一个简单的菜单和一个浮动操作按钮,这两者都在此项目中使用。将其他设置保留为默认设置。

准备字符串资源

在这里,我们将创*所有的字符串资源,我们将从布局文件中引用这些资源,而不是硬编码text属性,就像我们一直在做的那样。严格来说,这是一个可以避免的步骤。但是,如果您想要制作深入的 Android 应用程序,学会以这种方式做事情将使您受益匪浅。

要开始,请在项目资源管理器中的res/values文件夹中打开strings.xml文件。您将看到自动生成的资源。添加我们将在整个项目的其余部分中使用的以下突出显示的字符串资源。在关闭</resources>标签之前添加以下代码:

...
<resources>
    <string name="app_name">Note To Self</string>
    <string name="hello_world">Hello world!</string>
    <string name="action_settings">Settings</string>

    <string name="action_add">add</string>
    <string name="title_hint">Title</string>
    <string name="description_hint">Description</string>
    <string name="idea_text">Idea</string>
    <string name="important_text">Important</string>
    <string name="todo_text">To do</string>
    <string name="cancel_button">Cancel</string>
    <string name="ok_button">OK</string>

    <string name="settings_title">Settings</string>
    <string name="theme_title">Theme</string>
    <string name="theme_light">Light</string>
    <string name="theme_dark">Dark</string>

</resources>

请注意在上述代码中,每个字符串资源都有一个唯一的name属性,用于将其与所有其他字符串资源区分开。name属性还提供了一个有意义的,并且希望是记忆深刻的线索,表明它代表的实际字符串值。正是这些名称值,我们将用来从我们的布局文件中引用我们想要使用的字符串。

编写 Note 类

这是应用程序的基本数据结构。这是一个我们将从头开始编写的类,它具有表示单个用户笔记所需的所有属性。在第十五章处理数据和生成随机数中,我们将学*一些新的 Kotlin 代码,以了解如何让用户拥有数十、数百甚至数千条笔记。

通过右键单击包含MainActivity.kt文件的文件夹来创*一个新类 - 通常是包含MainActivity.kt文件的文件夹。选择New | Kotlin File/class,命名为Note,并从下拉选择器中选择Class。单击OK创*类。

将以下代码添加到新的Note类中:

class Note {
    var title: String? = null
    var description: String? = null
    var idea: Boolean = false
    var todo: Boolean = false
    var important: Boolean = false
}

我们有一个简单的类,没有函数,叫做Note。这个类有五个var属性,分别叫做titledescriptionideatodoimportant。它们的用途是保存用户笔记的标题、笔记的描述(或内容),以及详细说明笔记是一个想法、一个待办事项,还是一个重要的笔记。现在让我们设计两个对话框窗口的布局。

实现对话框设计

现在我们将做一些我们以前做过很多次的事情,但这次是出于不同的原因。正如你所知,我们将有两个对话框窗口 - 一个用于用户输入新的笔记,另一个用于用户查看他们选择的笔记。

我们可以以与之前所有布局相同的方式设计这两个对话框窗口的布局。当我们开始为FragmentDialog类创* Kotlin 代码时,我们将学*如何将这些布局结合起来。

首先,让我们按照以下步骤为我们的“新笔记”对话框添加布局:

  1. 在项目资源管理器中右键单击layout文件夹,选择新* | 布局资源文件。在文件名:字段中输入dialog_new_note,然后开始输入Constrai以填写根元素:字段。注意到有一个下拉列表,其中有多个以Constrai…开头的选项。现在选择androidx.constraintlayout.widget.ConstraintLayout。左键单击确定生成新的布局文件,其根元素类型为ConstraintLayout

  2. 在按照以下说明的同时,参考下面的屏幕截图中的目标设计。我已经使用 Photoshop 将完成的布局和我们即将自动生成的约束条件放在一起,约束条件被隐藏以增加清晰度:实现对话框设计

  3. 文本类别中拖放一个纯文本小部件到布局的最上方和最左边,然后再添加另一个纯文本。现在不用担心任何属性。

  4. 按钮类别中拖放三个复选框小部件,依次放置。查看之前的参考屏幕截图以获得指导。同样,现在不用担心任何属性。

  5. 从上一步中的最后一个复选框小部件直接下方拖放两个按钮到布局中,然后将第二个按钮水*放置,与第一个按钮对齐,但完全位于布局的右侧。

  6. 整理布局,使其尽可能地与参考屏幕截图相似,然后点击推断约束条件按钮来修复您选择的位置。

  7. 现在我们可以设置所有的textidhint属性。您可以使用下表中的值来设置。请记住,我们在texthint属性中使用了我们的字符串资源。

注意

当您编辑第一个id属性时,可能会弹出一个窗口询问您是否确认更改。勾选本次会话期间不再询问并点击继续,如下屏幕截图所示:

实现对话框设计

以下是要输入的值:

小部件类型 属性 要设置的值
纯文本(顶部) id editTitle
纯文本(顶部) 提示 @string/title_hint
纯文本(底部) id editDescription
纯文本(底部) 提示 @string/description_hint
纯文本(底部) 输入类型 textMultiLine(取消其他选项)
复选框(顶部) id checkBoxIdea
复选框(顶部) 文本 @string/idea_text
复选框(中部) id checkBoxTodo
复选框(中部) 文本 @string/todo_text
复选框(底部) id checkBoxImportant
复选框(底部) 文本 @string/important_text
按钮(左侧) id btnCancel
按钮(左侧) 文本 @string/cancel_button
按钮(右侧) id btnOK
按钮(右侧) 文本 @string/ok_button

我们现在有一个整洁的布局,准备好显示我们的 Kotlin 代码。请记住不同小部件的id值,因为当我们编写代码时,我们将看到它们的作用。重要的是,我们的布局看起来漂亮,并且每个相关项目都有一个id值,这样我们就可以引用它。

让我们布置对话框,向用户显示一个提示:

  1. 在项目资源管理器中右键单击布局文件夹,然后选择新*|布局资源文件。在文件名:字段中输入dialog_show_note,然后开始输入Constrai以获取根元素:字段。注意到有一个下拉列表,其中有多个以Constrai…开头的选项。现在选择androidx.constraintlayout.widget.ConstraintLayout。单击确定生成具有ConstraintLayout类型作为其根元素的新布局文件。

  2. 参考下一个截图中的目标设计,同时按照这些说明的其余部分进行操作。我已经使用 Photoshop 将包括我们即将自动生成的约束的完成布局与布局放在一起,并隐藏了约束以获得额外的清晰度:实现对话框设计

  3. 首先,在布局的顶部垂直对齐拖放三个TextView小部件。

  4. 接下来,在前三个TextView小部件的中心下方拖放另一个TextView小部件。

  5. 在前一个下方的左侧添加另一个TextView小部件。

  6. 现在在布局的底部水*居中位置添加一个Button。到目前为止,它应该是这个样子:实现对话框设计

  7. 整理布局,使其尽可能地与参考截图相似,然后单击推断约束按钮以修复您选择的位置。

  8. 从以下表中配置属性:

小部件类型 属性 要设置的值
TextView(左上角) id textViewImportant
TextView(左上角) text @string/important_text
TextView(顶部中心) id textViewTodo
TextView(顶部中心) text @string/todo_text
TextView(右上角) id textViewIdea
TextView(右上角) text @string/idea_text
TextView(中心,第二行) id txtTitle
TextView(中心,第二行) textSize 24sp
TextView(最后一个添加的) id txtDescription
Button id btnOK
Button text @string/ok_button

提示

在进行上述更改之后,您可能希望通过拖动它们在屏幕上调整它们的大小和内容来微调一些 UI 元素的最终位置。首先,单击清除所有约束,然后调整布局使其符合您的要求,最后,单击推断约束以再次约束位置。

现在我们有一个布局,可以用来向用户显示笔记。请注意,我们可以重用一些字符串资源。我们的应用程序越大,这样做就越有益。

编写对话框

现在我们已经为我们的两个对话框窗口(“显示笔记”和“新*笔记”)设计好了,我们可以利用我们对FragmentDialog类的了解来实现一个类来代表用户可以交互的每个对话框窗口。

我们将从“新*笔记”屏幕开始。

编写 DialogNewNote 类

通过右键单击具有.kt文件的项目文件夹并选择新* | Kotlin 文件/类来创*一个新类。命名DialogNewNote类并在下拉选择器中选择。单击确定生成新类。

首先,更改类声明并继承自DialogFragment。还要重写onCreateDialog函数,这是该类中其余代码的位置。使您的代码与以下代码相同以实现这一点:

class DialogNewNote : DialogFragment() {

   override 
   fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

        // All the rest of the code goes here

    }
}

提示

您还需要添加以下新的导入:

import androidx.fragment.app.DialogFragment;
import android.app.Dialog;
import android.os.Bundle;

我们暂时在新类中有一个错误,因为我们需要在onCreateDialog函数中有一个return语句,但我们马上就会解决这个问题。

在接下来的代码块中,我们将在一会儿添加的首先声明并初始化一个AlertDialog.Builder对象,就像我们以前创*对话框窗口时所做的那样。然而,这一次,我们不会像以前那样经常使用这个对象。

接下来,我们初始化一个LayoutInflater对象,我们将用它来填充我们的 XML 布局。 "填充"简单地意味着将我们的 XML 布局转换为 Kotlin 对象。一旦完成了这个操作,我们就可以以通常的方式访问所有小部件。我们可以将inflater.inflate视为替换对话框的setContentView函数调用。在第二行中,我们使用inflate函数做到了这一点。

添加我们刚刚讨论过的三行代码:

// All the rest of the code goes here
val builder = AlertDialog.Builder(activity!!)

val inflater = activity!!.layoutInflater

val dialogView = inflater.inflate
   (R.layout.dialog_new_note, null)

提示

为了支持前三行代码中的新类,您需要添加以下import语句:

import androidx.appcompat.app.AlertDialog
import android.view.View
import android.view.LayoutInflater

我们现在有一个名为dialogViewView对象,它具有来自我们的dialog_new_note.xml布局文件的所有 UI 元素。

现在,在上一个代码块下面,我们将添加以下代码。

此代码将获取对每个 UI 小部件的引用。在上一个代码块之后添加以下代码:

val editTitle =
      dialogView.findViewById(R.id.editTitle) as EditText

val editDescription =
      dialogView.findViewById(R.id.editDescription) as 
                EditText

val checkBoxIdea =
      dialogView.findViewById(R.id.checkBoxIdea) as CheckBox

val checkBoxTodo =
      dialogView.findViewById(R.id.checkBoxTodo) as CheckBox

val checkBoxImportant =
      dialogView.findViewById(R.id.checkBoxImportant) as 
                CheckBox

val btnCancel =
      dialogView.findViewById(R.id.btnCancel) as Button

val btnOK =
      dialogView.findViewById(R.id.btnOK) as Button

提示

确保添加以下import代码,以使您刚刚添加的代码无错误:

import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText

在上述代码中有一个新的 Kotlin 特性,称为as关键字;例如,as EditTextas CheckBoxas Button。由于编译器无法推断出每个 UI 小部件的具体类型,所以使用了这个特性。尝试从代码中删除一个as…关键字并注意产生的错误。使用as关键字(因为我们知道类型)可以解决这个问题。

在下一个代码块中,我们将使用builder实例设置对话框的消息。然后,我们将编写一个 lambda 来处理btnCancel的点击。在重写的onClick函数中,我们将简单地调用dismiss(),这是DialogFragment的一个函数,用于关闭对话框窗口。这正是用户单击Cancel时我们需要的。

添加我们刚刚讨论过的代码:

builder.setView(dialogView).setMessage("Add a new note")

// Handle the cancel button
btnCancel.setOnClickListener {
   dismiss()
}

现在,我们将添加一个 lambda 来处理用户单击OK按钮(btnOK)时发生的情况。

在其中,我们创*一个名为newNote的新Note。然后,我们将newNote的每个属性设置为表单的适当内容。

之后,我们使用对MainActivity的引用来调用MainActivity中的createNewNote函数。

提示

请注意,我们还没有编写createNewNote函数,直到本章后面我们这样做之前,函数调用将显示错误。

在这个函数中发送的参数是我们新初始化的newNote对象。这样做的效果是将用户的新笔记发送回MainActivity。我们将在本章后面看到我们如何处理这个。

最后,我们调用dismiss来关闭对话框窗口。在我们添加的上一个代码块之后添加我们讨论过的代码:

btnOK.setOnClickListener {
   // Create a new note
   val newNote = Note()

   // Set its properties to match the
   // user's entries on the form
   newNote.title = editTitle.text.toString()

   newNote.description = editDescription.text.toString()

   newNote.idea = checkBoxIdea.isChecked
   newNote.todo = checkBoxTodo.isChecked
   newNote.important = checkBoxImportant.isChecked

   // Get a reference to MainActivity
   val callingActivity = activity as MainActivity?

   // Pass newNote back to MainActivity
   callingActivity!!.createNewNote(newNote)

   // Quit the dialog
   dismiss()
}

return builder.create()

我们的第一个对话框窗口已经完成。我们还没有将其连接到MainActivity中,并且我们还需要实现createNewNote函数。我们将在创*下一个对话框之后立即执行此操作。

编写 DialogShowNote 类

通过右键单击包含所有.kt文件的项目文件夹,选择New | Kotlin File/Class来创*一个新类。命名为DialogShowNote类,然后在下拉选择器中选择Class,然后单击OK生成新类。

首先,更改类声明并继承自DialogFragment,然后重写onCreateDialog函数。由于这个类的大部分代码都在onCreateDialog函数中,所以按照以下代码中显示的签名和空体实现它,我们将在一分钟后回顾它。

请注意,我们声明了Note类型的var属性note。另外,添加sendNoteSelected函数及其初始化note的单行代码。这个函数将被MainActivity调用,并传入用户点击的Note对象。

添加我们刚讨论过的代码,然后我们可以查看onCreateDialog的细节:

class DialogShowNote : DialogFragment() {

    private var note: Note? = null

    override fun 
    onCreateDialog(savedInstanceState: Bundle?): Dialog {

        // All the other code goes here

    }

    // Receive a note from the MainActivity class
    fun sendNoteSelected(noteSelected: Note) {
        note = noteSelected
    }

}

提示

此时,您需要导入以下类:

import android.app.Dialog;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;

接下来,我们声明并初始化一个AlertDialog.Builder的实例。接下来,就像我们为DialogNewNote做的那样,我们声明并初始化LayoutInflater,然后使用它来创*一个具有对话框布局的View对象。在这种情况下,它是来自dialog_show_note.xml的布局。

最后,在下面的代码块中,我们获取对每个 UI 小部件的引用,并使用note中的相关属性设置txtTitletextDescriptiontext属性,这些属性在sendNoteSelected函数调用中初始化。

添加我们刚刚讨论过的代码到onCreateDialog函数中:

val builder = AlertDialog.Builder(this.activity!!)

val inflater = activity!!.layoutInflater

val dialogView = inflater.inflate(R.layout.dialog_show_note, null)

val txtTitle = 
   dialogView.findViewById(R.id.txtTitle) as TextView

val txtDescription = 
   dialogView.findViewById(R.id.txtDescription) as TextView

txtTitle.text = note!!.title
txtDescription.text = note!!.description      

val txtImportant = 
   dialogView.findViewById(R.id.textViewImportant) as TextView

val txtTodo = 
   dialogView.findViewById(R.id.textViewTodo) as TextView

val txtIdea = 
   dialogView.findViewById(R.id.textViewIdea) as TextView

提示

将上述import语句添加到以前的代码中,以使所有类都可用:

import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;

下一个代码也在onCreateDialog函数中。它检查正在显示的笔记是否“重要”,然后相应地显示或隐藏txtImportant TextView小部件。然后我们对txtTodotxtIdea小部件做同样的操作。

在上一个代码块之后添加此代码,仍然在onCreateDialog函数中:

if (!note!!.important){
   txtImportant.visibility = View.GONE
}

if (!note!!.todo){
   txtTodo.visibility = View.GONE
}

if (!note!!.idea){
   txtIdea.visibility = View.GONE
}

现在我们只需要在用户点击OK按钮时dismiss(即关闭)对话框窗口。这是通过 lambda 完成的,因为我们已经看到了好几次。onClick函数只是调用dismiss函数,关闭对话框窗口。

在上一个代码块之后添加此代码到onCreateDialog函数中:

val btnOK = dialogView.findViewById(R.id.btnOK) as Button

builder.setView(dialogView).setMessage("Your Note")

btnOK.setOnClickListener({
   dismiss()
})

return builder.create()

提示

使用这行代码导入Button类:

import android.widget.Button;

我们现在有两个准备好的对话框窗口。我们只需要在MainActivity类中添加一些代码来完成工作。

显示和使用我们的新对话框

MainActivity声明之后添加一个新的临时属性:

// Temporary code
private var tempNote = Note()

提示

这段代码不会出现在最终的应用程序中;这只是为了让我们立即测试我们的对话框窗口。

现在添加这个函数,以便我们可以从DialogNewNote类接收一个新的笔记:

fun createNewNote(n: Note) {
   // Temporary code
   tempNote = n
}

现在,要将一个笔记发送到DialogShowNote函数,我们需要在layout_main.xml布局文件中添加一个带有button id的按钮。

为了清楚地说明这个按钮的用途,我们将把它的text属性更改为Show Note,如下所示:

  • Button小部件拖放到layout_main.xml上,并将其id配置为buttontext配置为Show Note

  • 点击Infer Constraints按钮,使按钮停留在您放置的位置。此按钮的确切位置在这个阶段并不重要。

注意

只是为了澄清,这是一个临时按钮,用于测试目的,不会在最终的应用程序中使用。在开发结束时,我们将点击列表中的笔记标题。

现在,在onCreate函数中,我们将设置一个 lambda 来处理对临时按钮的点击。onClick中的代码将执行以下操作:

  • 创*一个名为dialog的新DialogShowNote实例。

  • dialog上调用sendNoteSelected函数,将我们的Note对象tempNote作为参数传递进去。

  • 最后,它将调用show,为我们的新对话框注入生命。

将先前描述的代码添加到onCreate函数中:

// Temporary code
val button = findViewById<View>(R.id.button) as Button
button.setOnClickListener {
   // Create a new DialogShowNote called dialog
   val dialog = DialogShowNote()

   // Send the note via the sendNoteSelected function
   dialog.sendNoteSelected(tempNote)

   // Create the dialog
   dialog.show(supportFragmentManager, "123")
}

提示

确保使用这行代码导入Button类:

import android.widget.Button;

现在我们可以在点击按钮时召唤我们的DialogShowNote对话框窗口。运行应用程序,点击SHOW NOTE按钮,查看DialogShowNote对话框窗口,其中包含dialog_show_note.xml布局,如下截图所示:

显示和使用我们的新对话框

诚然,考虑到我们在本章中所做的大量编码,这并不是什么了不起的,但是当我们让DialogNewNote类起作用时,我们将看到MainActivity如何在两个对话框之间交互和共享数据。

DialogNewNote对话框可用。

编写浮动操作按钮

这将很容易。浮动操作按钮已经在布局中为我们提供。作为提醒,这是浮动操作按钮:

编写浮动操作按钮

它在activity_main.xml文件中。这是定位和定义其外观的 XML 代码:

<com.google.android.material.floatingactionbutton
    .FloatingActionButton

   android:id="@+id/fab"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_gravity="bottom|end"
   android:layout_margin="@dimen/fab_margin"
   app:srcCompat="@android:drawable/ic_dialog_email" />

Android Studio 甚至提供了一个现成的 lambda 来处理对浮动操作按钮的点击。我们只需要在已提供的代码的onClick函数中添加一些代码,就可以使用DialogNewNote类。

浮动操作按钮通常用于应用程序的核心操作。例如,在电子邮件应用程序中,它可能用于启动新电子邮件;或者在便签应用程序中,它可能用于添加新便签。所以,让我们现在做这个。

MainActivity.kt中,在onCreate函数中找到 Android Studio 提供的自动生成的代码;以下是完整的代码:

fab.setOnClickListener { view ->
   Snackbar.make(view, "Replace with your own action", 
 Snackbar.LENGTH_LONG)
 .setAction("Action", null).show()
}

在前面的代码中,请注意突出显示的行并删除它。现在在删除的代码的位置添加以下代码:

val dialog = DialogNewNote()
dialog.show(supportFragmentManager, "")

新代码创*了DialogNewNote类型的新对话框窗口,然后向用户显示它。

现在我们可以运行应用程序;点击浮动操作按钮并添加一条便签,类似于以下截图:

编写浮动操作按钮

点击“确定”保存便签并返回到主布局。接下来,我们可以点击“显示便签”按钮,在对话框窗口中查看它,就像以下截图一样:

编写浮动操作按钮

请注意,如果您添加第二个便笺,它将覆盖第一个,因为我们只有一个Note实例。此外,如果您关闭手机或完全关闭应用程序,那么便签将永远丢失。我们需要涵盖一些更多的 Kotlin 来解决这些问题。

摘要

在本章中,我们已经看到并实现了使用DialogFragment类的常见 UI 设计与对话框窗口。

当我们启动“Note to self”应用程序时,我们进一步迈出了一步,通过实现更复杂的对话框,可以从用户那里捕获信息。我们看到,DialogFragment使我们能够在对话框中拥有任何我们喜欢的 UI。

在下一章中,我们将开始解决一个明显的问题,即用户只能有一个便签,通过探索 Kotlin 的数据处理类。

第十五章:处理数据和生成随机数

我们取得了很好的进展。我们对 Android UI 选项和 Kotlin 的基础知识有了一个全面的了解。在前几章中,我们开始将这两个领域结合起来,并使用 Kotlin 代码操作 UI,包括一些新的小部件。然而,在构*自我备忘录应用程序时,我们在知识上遇到了一些空白。在本章中,我们将填补这些空白中的第一个,然后在下一章中,我们将使用这些新信息来继续应用程序。我们目前没有办法管理大量相关数据。除了声明、初始化和管理数十、数百甚至数千个属性或实例之外,我们如何让我们的应用程序用户拥有多个备忘录?我们还将快速了解一下随机数。

在本章中,我们将涵盖以下主题:

  • 随机数

  • 数组

  • 一个简单的数组迷你应用

  • 一个动态数组迷你应用

  • 范围

  • ArrayLists

  • 哈希映射

首先,让我们了解一下Random类。

一个随机的转移

有时,我们会在我们的应用程序中需要一个随机数,对于这些情况,Kotlin 为我们提供了Random类。这个类有很多可能的用途,比如如果我们的应用程序想要显示每日随机提示,或者一个需要在不同场景之间选择的游戏,或者一个随机提问的测验。

Random类是 Android API 的一部分,在我们的 Android 应用程序中完全兼容。

让我们看看如何创*随机数。Random类已经为我们做好了所有的工作。首先,我们需要创*一个Random对象,如下所示:

val randGenerator = Random()

然后,我们使用我们新对象的nextInt函数来生成一个在某个范围内的随机数。以下代码行使用我们的randGenerator对象生成随机数,并将结果存储在ourRandomNumber变量中:

var ourRandomNumber = randGenerator.nextInt(10)

我们输入的范围开始于零。因此,前一行将生成一个在 0 和 9 之间的随机数。如果我们想要一个在 1 和 10 之间的随机数,我们只需在同一行的代码末尾添加增量运算符:

ourRandomNumber ++

我们还可以使用Random对象使用nextLongnextFloatnextDouble获取其他类型的随机数。

使用数组处理大量数据

也许你会想知道当我们有很多变量需要跟踪时会发生什么。我们的自我备忘录应用程序有 100 条备忘录,或者游戏中的高分榜有前 100 名的分数?我们可以声明和初始化 100 个单独的变量,如下所示:

var note1 = Note()
var note2 = Note()
var note3 = Note()
// 96 more lines like the above
var note100 = Note()

或者,通过使用高分示例,我们可以使用以下代码:

var topScore1: Int
var topScore2: Int
// 96 more lines like the above
var topScore100: Int

立即,这段代码可能看起来笨拙,但是当有人获得新的最高分,或者我们想让我们的用户排序他们的备忘录显示顺序时,会怎样?使用高分榜场景,我们必须将每个变量中的分数向下移动一个位置。这是一个噩梦的开始,如下代码所示:

topScore100 = topScore99;
topScore99 = topScore98;
topScore98 = topScore97;
// 96 more lines like the above
topScore1 = score;

肯定有更好的方法。当我们有一整个数组的变量时,我们需要的是一个 Kotlin 数组。数组是一个对象,最多可以容纳预定的固定最大数量的元素。每个元素都是一个具有一致类型的变量。

以下代码声明了一个可以容纳Int类型变量的数组;例如高分榜或一系列考试成绩:

var myIntArray: IntArray

我们也可以声明其他类型的数组,如下所示:

var myFloatArray: FloatArray
var myBooleanArray: BooleanArray

在使用这些数组之前,每个数组都需要有一个固定的最大分配存储空间。就像我们对其他对象所做的那样,我们必须在使用数组之前对其进行初始化,我们可以这样做:

myIntArray = IntArray(100)
myFloatArray = FloatArray(100)
myBooleanArray = BooleanArray(100)

前面的代码分配了最多100个适当类型的存储空间。想象一下,我们的变量仓库中有 100 个连续的存储空间。这些空间可能被标记为myIntArray[0]myIntArray[1]myIntArray[2],每个空间都包含一个Int值。这里稍微令人惊讶的是,存储空间从零开始,而不是 1。因此,在一个 100 宽的数组中,存储空间将从 0 到 99。

我们可以初始化一些存储空间如下:

myIntArray [0] = 5
myIntArray [1] = 6
myIntArray [2] = 7

但是,请注意,我们只能将预声明的类型放入数组中,并且数组保存的类型永远不会改变,如下面的代码所示:

myIntArray [3] = "John Carmack" 
// Won't compile String not Int

因此,当我们有一个Int类型的数组时,每个Int变量被称为什么,我们如何访问其中存储的值?数组表示法语法替换了变量的名称。此外,我们可以对数组中的变量进行与常规变量相同的操作;如下所示:

myIntArray [3] = 123

前面的代码将值 123 分配给数组中的第 4 个位置。

这是使用数组的另一个示例,就像使用普通变量一样:

myIntArray [10] = myIntArray [9] - myIntArray [4]

前面的代码从数组的第 5 个位置中减去数组的第 10 个位置中存储的值,并将答案赋给数组的第 11 个位置。

我们还可以将数组中的值赋给相同类型的常规变量,如下所示:

Val myNamedInt = myIntArray[3]

但是,请注意,myNamedInt是一个独立的变量,对它的任何更改都不会影响存储在IntArray引用中的值。它在仓库中有自己的空间,与数组没有其他联系。

在前面的示例中,我们没有检查任何字符串或对象。实际上,字符串是对象,当我们想要创*对象数组时,我们会稍微不同地处理它们;看一下下面的代码:

var someStrings = Array<String>(5) { "" }
// You can remove the String keyword because it can be inferred like 
// this
var someMoreStrings = Array(5) { "" }

someStrings[0]= "Hello "
someStrings[1]= "from "
someStrings[2]= "inside "
someStrings[3]= "the "
someStrings[4]= "array "
someStrings[5]= "Oh dear "
// ArrayIndexOutOfBoundsException

前面的代码声明了一个 String 对象数组,最多可以容纳五个对象。请记住,数组从 0 开始,因此有效的位置是从 0 到 4。如果尝试使用无效的位置,则会收到ArrayIndexOutOfBoundsException错误。如果编译器注意到错误,则代码将无法编译;但是,如果编译器无法发现错误,并且在应用程序执行时发生错误,则应用程序将崩溃。

我们可以避免这个问题的唯一方法是知道规则-数组从 0 开始,直到它们的长度减 1。因此,someArray[9]是数组中的第十个位置。我们还可以使用清晰易读的代码,这样更容易评估我们所做的事情并更容易发现问题。

您还可以在声明数组的同时初始化数组的内容,如下面的代码所示:

        var evenMoreStrings: Array<String> = 
                arrayOf("Houston", "we", "have", "an", "array")

前面的代码使用内置的 Kotlin 函数arrayOf来初始化数组。

在 Kotlin 中,您可以声明和初始化数组的方式非常灵活。我们还没有接近覆盖我们可以使用数组的所有方式,即使在书的最后,我们仍然不会覆盖所有内容。然而,让我们深入一点。

数组是对象

将数组变量视为给定类型的一组变量的地址。例如,使用仓库类比,someArray可以是过道编号。因此,someArray[0]someArray[1]是过道编号,后跟过道中的位置编号。

因为数组也是对象,它们具有我们可以使用的函数和属性,如下面的示例所示:

val howBig = someArray.size

在前面的示例中,我们将someArray的长度(即大小)分配给名为howBigInt变量。

我们甚至可以声明一个数组的数组。这是一个数组,其中每个位置都隐藏着另一个数组;如下所示:

val cities = arrayOf("London", "New York", "Yaren")
val countries = arrayOf("UK", "USA", "Nauru")

val countriesAndCities = arrayOf(countries, cities)

Log.d("The capital of " +
   countriesAndCities[0][0],
   " is " +
   countriesAndCities[1][0])

前面的Log代码将在 logcat 窗口中输出以下文本:

The capital of UK:  is London

让我们在一个真实的应用程序中使用一些数组,试着理解如何在真实代码中使用它们以及它们可能被用来做什么。

一个简单的迷你应用程序数组示例

让我们做一个简单的工作数组示例。您可以在可下载的代码包中找到此项目的完整代码。它可以在Chapter15/Simple Array Example/MainActivity.kt文件中找到。

创*一个Empty Activity项目模板的项目,并将其命名为Simple Array Example

首先,我们声明我们的数组,分配了五个空间,并为每个元素初始化了值。然后,我们将每个值输出到logcat窗口。

这与我们之前看到的例子略有不同,因为我们在声明数组的同时声明了大小。

setContentView调用后的onCreate函数中添加以下代码:

// Declaring an array
// Allocate memory for a maximum size of 5 elements
val ourArray = IntArray(5)

// Initialize ourArray with values
// The values are arbitrary, but they must be Int
// The indexes are not arbitrary. Use 0 through 4 or crash!

ourArray[0] = 25
ourArray[1] = 50
ourArray[2] = 125
ourArray[3] = 68
ourArray[4] = 47

//Output all the stored values
Log.i("info", "Here is ourArray:")
Log.i("info", "[0] = " + ourArray[0])
Log.i("info", "[1] = " + ourArray[1])
Log.i("info", "[2] = " + ourArray[2])
Log.i("info", "[3] = " + ourArray[3])
Log.i("info", "[4] = " + ourArray[4])

接下来,我们将数组的每个元素相加,就像我们对普通的Int类型变量一样。请注意,当我们将数组元素相加时,我们为了清晰起见在多行上这样做。将我们刚刚讨论的代码添加到MainActivity.kt中,如下所示:

/*
   We can do any calculation with an array element
   provided it is appropriate to the contained type
   Like this:
*/
val answer = ourArray[0] +
      ourArray[1] +
      ourArray[2] +
      ourArray[3] +
      ourArray[4]

Log.i("info", "Answer = $answer")

运行示例,并注意 logcat 窗口中的输出。请记住,在模拟器显示上不会发生任何事情,因为所有输出都将发送到 Android Studio 中的 logcat 窗口;以下是输出:

info﹕ Here is ourArray:
info﹕ [0] = 25
info﹕ [1] = 50
info﹕ [2] = 125
info﹕ [3] = 68
info﹕ [4] = 47
info﹕ Answer = 315 

我们声明一个名为ourArray的数组来保存Int值,然后为该类型的最多五个值分配空间。

接下来,我们为ourArray的五个空间分配一个值。记住第一个空间是ourArray[0],最后一个空间是ourArray[4]

接下来,我们简单地将每个数组位置的值打印到 logcat 窗口中,从输出中我们可以看到它们保存了我们在上一步中初始化的值。然后,我们将ourArray中的每个元素相加,并将它们的值初始化为answer变量。然后,我们将answer打印到 logcat 窗口中,我们可以看到确实,所有的值都被相加在一起,就像它们是普通的Int类型一样(它们确实是),只是以不同的方式存储。

使用数组进行动态操作

正如我们在本节开头讨论的,如果我们需要单独声明和初始化数组的每个元素,那么使用数组并没有比使用普通变量带来很大的好处。让我们看一个动态声明和初始化数组的例子。

动态数组示例

您可以在下载包中找到此示例的工作项目。它可以在Chapter15/Dynamic Array Example/MainActivity.kt文件中找到。

创*一个Empty Activity模板的项目,并将其命名为Dynamic Array Example

onCreate函数中的setContentView调用后,输入以下代码。在我们讨论和分析代码之前,看看你能否猜出输出结果是什么:

// Declaring and allocating in one step
val ourArray = IntArray(1000)

// Let's initialize ourArray using a for loop
// Because more than a few variables is allot of typing!

for (i in 0..999) {

   // Put the value into ourArray
   // At the position decided by i.
   ourArray[i] = i * 5

   //Output what is going on
   Log.i("info", "i = $i")
   Log.i("info", "ourArray[i] = ${ ourArray[i]}")
}

运行示例应用程序。请记住,屏幕上不会发生任何事情,因为所有输出都将发送到我们在 Android Studio 中的 logcat 窗口;以下是输出:

info﹕ i = 0
info﹕ ourArray[i] = 0
info﹕ i = 1
info﹕ ourArray[i] = 5
info﹕ i = 2
info﹕ ourArray[i] = 10

为了简洁起见,循环的 994 次迭代已被删除:

info﹕ ourArray[i] = 4985
info﹕ i = 998
info﹕ ourArray[i] = 4990
info﹕ i = 999
info﹕ ourArray[i] = 4995

首先,我们声明并分配了一个名为ourArray的数组,以保存最多 1,000 个Int值。请注意,这次我们在一行代码中执行了两个步骤:

val ourArray = IntArray(1000)

然后,我们使用了一个设置为循环 1,000 次的for循环:

for (i in 0..999) {

我们初始化数组中的空间,从 0 到 999,其值为i乘以 5,如下所示:

   ourArray[i] = i * 5

然后,为了演示i的值以及数组中每个位置的值,我们输出i的值,然后是数组对应位置的值,如下所示:

   //Output what is going on
   Log.i("info", "i = $i")
   Log.i("info", "ourArray[i] = ${ ourArray[i]}")

所有这些都发生了 1,000 次,产生了我们所看到的输出。当然,我们还没有在真实的应用程序中使用这种技术,但我们很快将使用它来使我们的自我备忘录应用程序保存几乎无限数量的备忘录。

ArrayLists

ArrayList对象就像普通数组,但功能更强大。它克服了数组的一些缺点,比如必须预先确定其大小。它添加了几个有用的函数来使其数据易于管理,并被 Android API 中的许多类使用。这最后一点意味着如果我们想要使用 API 的某些部分,我们需要使用ArrayList。在第十六章中,适配器和回收器,我们将真正地让ArrayList发挥作用。首先是理论。

让我们看一些使用ArrayList的代码:

// Declare a new ArrayList called myList 
// to hold Int variables
val myList: ArrayList<Int>

// Initialize myList ready for use
myList = ArrayList()

在前面的代码中,我们声明并初始化了一个名为myList的新ArrayList对象。我们也可以在一步中完成这个操作,就像下面的代码所示:

val myList: ArrayList<Int> = ArrayList()

到目前为止,这并不特别有趣,所以让我们看看我们实际上可以用ArrayList做些什么。这次我们使用一个String ArrayList对象:

// declare and initialize a new ArrayList
val myList = ArrayList<String>()

// Add a new String to myList in 
// the next available location
myList.add("Donald Knuth")
// And another
myList.add("Rasmus Lerdorf")
// We can also choose 'where' to add an entry
myList.add(1,"Richard Stallman")

// Is there anything in our ArrayList?
if (myList.isEmpty()) {
   // Nothing to see here
} else {
   // Do something with the data
}

// How many items in our ArrayList?
val numItems = myList.size

// Now where did I put Richard?
val position = myList.indexOf("Richard Stallman")

在前面的代码中,我们看到我们可以在ArrayList对象上使用ArrayList类的一些有用的函数;这些函数如下:

  • 我们可以添加一个条目(myList.add

  • 我们可以在特定位置添加一个条目(myList.add(x, value)

  • 我们可以检查ArrayList实例是否为空(myList.isEmpty()

  • 我们可以看到ArrayList实例的大小(myList.size

  • 我们可以获取给定条目的当前位置(myList.indexOf...

注意

ArrayList类中甚至有更多的函数,但是到目前为止我们已经看到的足以完成这本书了。

有了所有这些功能,我们现在只需要一种方法来动态处理ArrayList实例。这就是增强for循环的条件的样子:

for (String s : myList)

前面的例子将逐个遍历myList中的所有项目。在每一步中,s将保存当前的String条目。

因此,这段代码将把我们上一节ArrayList代码示例中的所有杰出程序员打印到 logcat 窗口中,如下所示:

for (s in myList) {
   Log.i("Programmer: ", "$s")
}

它的工作原理是for循环遍历ArrayList中的每个String,并将当前的String条目分配给s。然后,依次对每个s使用Log…函数调用。前面的循环将在 logcat 窗口中创*以下输出:

Programmer:: Donald Knuth
Programmer:: Richard Stallman
Programmer:: Rasmus Lerdorf

for循环已经输出了所有的名字。Richard Stallman 之所以在 Donald Knuth 和 Rasmus Lerdof 之间是因为我们在特定位置(1)插入了他,这是ArrayList中的第二个位置。insert函数调用不会删除任何现有的条目,而是改变它们的位置。

有一个新的新闻快讯!

数组和 ArrayLists 是多态的

我们已经知道我们可以将对象放入数组和ArrayList对象中。然而,多态意味着它们可以处理多个不同类型的对象,只要它们有一个共同的父类型 - 都在同一个数组或ArrayList中。

在第十章,面向对象编程中,我们学到多态意味着多种形式。但在数组和ArrayList的上下文中,对我们意味着什么呢?

在其最简单的形式中,它意味着任何子类都可以作为使用超类的代码的一部分。

例如,如果我们有一个Animals数组,我们可以把任何Animal子类对象放在Animals数组中,比如CatDog

这意味着我们可以编写更简单、更易于理解和更易于更改的代码:

// This code assumes we have an Animal class
// And we have a Cat and Dog class that 
// inherits from Animal
val myAnimal = Animal()
val myDog = Dog()
val myCat = Cat()
val myAnimals = arrayOfNulls<Animal>(10)
myAnimals[0] = myAnimal // As expected
myAnimals[1] = myDog // This is OK too
myAnimals[2] = myCat // And this is fine as well

此外,我们可以为超类编写代码,并依赖于这样一个事实,即无论它被子类化多少次,在一定的参数范围内,代码仍然可以工作。让我们继续我们之前的例子如下:

// 6 months later we need elephants
// with its own unique aspects
// If it extends Animal we can still do this
val myElephant = Elephant()
myAnimals[3] = myElephant // And this is fine as well

我们刚刚讨论的一切对于ArrayLists也是真实的。

哈希映射

Kotlin 的HashMap很有趣;它们是ArrayList的一种表亲。它们封装了一些有用的数据存储技术,否则对我们来说可能会相当技术性。在回到自己的笔记应用之前,值得看一看HashMap

假设我们想要存储角色扮演游戏中许多角色的数据,每个不同的角色由Character类型的对象表示。

我们可以使用一些我们已经了解的 Kotlin 工具,比如数组或ArrayList。然而,使用HashMap,我们可以为每个Character对象提供一个唯一的键或标识符,并使用相同的键或标识符访问任何这样的对象。

注意

"哈希"一词来自于将我们选择的键或标识符转换为HashMap类内部使用的东西的过程。这个过程被称为哈希

我们选择的键或标识符可以访问任何Character实例。在Character类的情况下,一个好的键或标识符候选者是角色的名字。

每个键或标识符都有一个相应的对象;在这种情况下,是Character实例。这被称为键值对

我们只需给HashMap一个键,它就会给我们相应的对象。我们不需要担心我们存储了角色的哪个索引,比如 Geralt、Ciri 或 Triss;只需将名字传递给HashMap,它就会为我们完成工作。

让我们看一些例子。你不需要输入任何代码;只需熟悉它的工作原理。

我们可以声明一个新的HashMap实例来保存键和Character实例,如下所示:

val characterMap: Map<String, Character>

前面的代码假设我们已经编写了一个名为Character的类。然后我们可以初始化HashMap实例如下:

characterMap = HashMap()

然后,我们可以添加一个新的键及其关联的对象,如下所示:

characterMap.put("Geralt", Character())
characterMap.put("Ciri", Character())
characterMap.put("Triss", Character())

提示

所有示例代码都假设我们可以以某种方式给Character实例赋予它们的唯一属性,以反映它们在其他地方的内部差异。

然后,我们可以按如下方式从HashMap实例中检索条目:

val ciri = characterMap.get("Ciri")

或者,我们可以直接使用Character类的函数:

characterMap.get("Geralt").drawSilverSword()

// Or maybe call some other hypothetical function
characterMap.get("Triss").openFastTravelPortal("Kaer Morhen")

前面的代码调用了假设的drawSilverSwordopenFastTravelPortal函数,这些函数是存储在HashMap实例中的Character类实例的假设函数。

有了这些新的工具包,如数组、ArrayListHashMap,以及它们的多态性,我们可以继续学*一些更多的 Android 类,很快我们将用它们来增强我们的备忘录应用。

备忘录应用

尽管我们已经学到了很多,但我们还没有准备好将解决方案应用到备忘录应用中。我们可以更新我们的代码,将大量的Note实例存储在ArrayList中,但在这之前,我们还需要一种方法来在 UI 中显示ArrayList的内容。把整个东西放在TextView实例中看起来不好。

答:解决方案是适配器和一个名为RecyclerView的特殊 UI 布局。我们将在下一章中介绍它们。

常见问题

问:一个只能进行真实计算的计算机如何可能生成真正的随机数?

问:实际上,计算机无法创*真正随机的数字,但Random类使用一个种子,产生一个在严格的统计检验下被认为是真正随机的数字。要了解更多关于种子和生成随机数的信息,请查看以下文章:en.wikipedia.org/wiki/Random_number_generation

总结

在本章中,我们看了如何使用简单的 Kotlin 数组来存储大量数据,只要它们是相同类型的数据。我们还使用了ArrayList,它类似于一个带有许多额外功能的数组。此外,我们发现数组和ArrayList都是多态的,这意味着一个数组(或ArrayList)可以容纳多个不同的对象,只要它们都是从同一个父类派生的。

我们还了解了HashMap类,它也是一种数据存储解决方案,但允许以不同的方式访问。

在下一章中,我们将学*关于AdapterRecyclerView,将理论付诸实践,并增强我们的备忘录应用。

第十六章:适配器和回收器

在这一章中,我们将取得很大的进展。我们将首先学*适配器和列表的理论。然后,我们将看看如何在 Kotlin 代码中使用RecyclerAdapter实例,并将RecyclerView小部件添加到布局中,它作为我们 UI 的列表,然后通过 Android API 的明显魔法将它们绑定在一起,以便RecyclerView实例显示RecyclerAdapter实例的内容,并允许用户滚动查看一个充满Note实例的ArrayList实例的内容。你可能已经猜到,我们将使用这种技术在 Note to self 应用程序中显示我们的笔记列表。

在这一章中,我们将做以下事情:

  • 探索另一种 Kotlin 类 - 内部类

  • 查看适配器的理论并检查将它们绑定到我们的 UI 上

  • 使用RecyclerView实现布局

  • 为在RecyclerView中使用的列表项布局

  • 使用RecyclerAdapter实现适配器

  • 将适配器绑定到RecyclerView

  • ArrayList中存储笔记,并通过RecycleAdapterRecyclerView中显示它们

很快,我们将拥有一个自管理的布局,用来保存和显示所有的笔记,所以让我们开始吧。

内部类

在这个项目中,我们将使用一种我们以前没有见过的类 - 内部类。假设我们有一个名为SomeRegularClass的常规类,其中有一个名为someRegularProperty的属性和一个名为someRegularFunction的函数,就像下面的代码中所示:

class SomeRegularClass{
    var someRegularProperty = 1    

    fun someRegularFunction(){
    }
}

内部类是在常规类内部声明的类,就像下面的高亮代码中所示:

class SomeRegularClass{
    var someRegularProperty = 1

    fun someRegularFunction(){
    }

    inner class MyInnerClass {
 val myInnerProperty = 1

 fun myInnerFunction() {
 }
 }

}

上面高亮显示的代码显示了一个名为MyInnerClass的内部类,其中有一个名为myInnerProperty的属性和一个名为myInnerFunction的函数。

一个优点是外部类可以通过声明它的实例来使用内部类的属性和函数,就像下面的代码片段中所示:

class SomeRegularClass{
    var someRegularProperty = 1

    val myInnerInstance = MyInnerClass()

    fun someRegularFunction(){
        val someVariable = myInnerInstance.myInnerProperty
 myInnerInstance.myInnerFunction()
    }

    inner class MyInnerClass {
        val myInnerProperty = 1

        fun myInnerFunction() {
        }

    }
}

此外,内部类还可以从myInnerFunction函数中访问常规类的属性。下面的代码片段展示了这一点:

fun myInnerFunction() {
 someRegularProperty ++
}

在类中定义新类型并创*实例并共享数据的能力在某些情况下非常有用,并且用于封装。我们将在本章后面的 Note to self 应用程序中使用内部类。

RecyclerView 和 RecyclerAdapter

在第五章中,我们使用了ScrollView小部件,并用一些CardView小部件填充它,以便我们可以看到它滚动。我们可以利用我们刚刚学到的关于ArrayList的知识,创*一个TextView对象的容器,用它们来填充ScrollView小部件,并在每个TextView中放置一个笔记的标题。这听起来像是在 Note to self 应用程序中显示每个笔记并使其可点击的完美解决方案。

我们可以在 Kotlin 代码中动态创*TextView对象,将它们的text属性设置为笔记的标题,然后将TextView对象添加到ScrollView中包含的LinearLayout中。但这并不完美。

显示大量小部件的问题

这可能看起来不错,但是如果有几十个、几百个,甚至上千个笔记怎么办?我们不能在内存中有成千上万个TextView对象,因为 Android 设备可能会因为尝试处理如此大量的数据而耗尽内存,或者至少会变得非常缓慢。

现在,想象一下我们希望(我们确实希望)ScrollView小部件中的每个笔记都显示它是重要的、待办事项还是想法。还有关于笔记文本的简短片段呢?

我们需要设计一些巧妙的代码,从ArrayList中加载和销毁Note对象和TextView对象。这是可以做到的 - 但要高效地做到这一点远非易事。

解决显示大量小部件的问题

幸运的是,这是移动开发人员如此常见的问题,以至于 Android API 中已经内置了解决方案。

我们可以在 UI 布局中添加一个名为RecyclerView的小部件(就像一个环保的ScrollView,但也有增强功能)。RecyclerView类是为我们讨论的问题设计的解决方案。此外,我们需要使用一种特殊类型的类与RecyclerView进行交互,这个类了解RecyclerView的工作原理。我们将使用一个适配器与它进行交互。我们将使用RecyclerAdapter类,继承它,定制它,然后使用它来控制我们的ArrayList中的数据,并在RecyclerView类中显示它。

让我们更多地了解一下RecyclerViewRecyclerAdapter类的工作原理。

如何使用 RecyclerView 和 RecyclerAdapter

我们已经知道如何存储几乎无限的笔记 - 我们可以在ArrayList中这样做,尽管我们还没有实现它。我们还知道有一个名为RecyclerView的 UI 布局,专门设计用于显示潜在的长列表数据。我们只需要看看如何将它付诸实践。

要向我们的布局中添加一个RecyclerView小部件,我们只需从调色板中像往常一样拖放它。

提示

现在不要这样做。让我们先讨论一会儿。

RecyclerView类在 UI 设计中将如下所示:

如何使用 RecyclerView 和 RecyclerAdapter

然而,这种外观更多地代表了可能性,而不是在应用程序中的实际外观。如果我们在添加了RecyclerView小部件后立即运行应用程序,我们将只会得到一个空白屏幕。

要实际使用RecyclerView小部件,我们需要做的第一件事是决定列表中的每个项目将是什么样子。它可以只是一个单独的TextView小部件,也可以是整个布局。我们将使用LinearLayout。为了清晰和具体,我们将使用一个LinearLayout实例,它为我们的RecyclerView小部件中的每个项目包含三个TextView小部件。这将允许我们显示笔记状态(重要/想法/待办事项)、笔记标题以及实际笔记内容中的一小段文本。

列表项需要在自己的 XML 文件中定义,然后RecyclerView小部件可以容纳多个此列表项布局的实例。

当然,这一切都没有解释我们如何克服管理显示在哪个列表项中的数据的复杂性,以及如何从ArrayList中检索数据。

这个数据处理是由我们自己定制的RecyclerAdapter来处理的。RecyclerAdapter类实现了Adapter接口。我们不需要知道Adapter内部是如何工作的,我们只需要重写一些函数,然后RecyclerAdapter将负责与我们的RecyclerView小部件进行通信的所有工作。

RecyclerAdapter的实现与RecyclerView小部件连接起来的过程,肯定比将 20 个TextView小部件拖放到ScrollView小部件上要复杂得多,但一旦完成,我们就可以忘记它,它将继续工作并自行管理,无论我们向ArrayList中添加了多少笔记。它还具有处理整洁格式和检测列表中哪个项目被点击的内置功能。

我们需要重写RecyclerAdapter的一些函数,并添加一些我们自己的代码。

我们将如何使用 RecyclerView 与 RecyclerAdapter 和笔记的 ArrayList

看一下所需步骤的大纲,这样我们就知道可以期待什么。为了让整个事情运转起来,我们需要做以下事情:

  1. 删除临时按钮和相关代码,然后向我们的布局中添加一个具有特定id属性的RecyclerView小部件。

  2. 创*一个 XML 布局来表示列表中的每个项目。我们已经提到列表中的每个项目将是一个包含三个TextView小部件的LinearLayout

  3. 创*一个新的类,该类继承自RecyclerAdapter,并添加代码到几个重写的函数中,以控制它的外观和行为,包括使用我们的列表项布局和装满Note实例的ArrayList

  4. MainActivity中添加代码,以使用RecyclerAdapterRecyclerView小部件,并将其绑定到我们的ArrayList实例。

  5. MainActivity中添加一个ArrayList实例,用于保存所有我们的笔记,并更新createNewNote函数,以将在DialogNewNote类中创*的任何新笔记添加到这个ArrayList中。

让我们逐步实现这些步骤。

向“Note to Self”项目添加 RecyclerView、RecyclerAdapter 和 ArrayList

打开“Note to self”项目。作为提醒,如果您想要查看基于完成本章的完整代码和工作中的应用程序,可以在Chapter16/Note to self文件夹中找到。

提示

由于本章中所需的操作在不同的文件、类和函数之间跳转,我鼓励您在首选的文本编辑器中打开下载包中的文件,以供参考。

删除临时的“显示笔记”按钮并添加 RecyclerView

接下来的几个步骤将消除我们在第十四章中添加的临时代码,Android 对话框窗口,并设置我们的RecyclerView准备好在本章后期绑定到RecyclerAdapter

  1. content_main.xml文件中,删除临时的Button,该按钮具有idbutton,我们之前为测试目的添加的。

  2. MainActivity.ktonCreate函数中,删除Button实例的声明和初始化,以及处理其点击的 lambda,因为这段代码现在会产生错误。稍后在本章中,我们将删除更多临时代码。删除下面显示的代码:

// Temporary code
val button = findViewById<View>(R.id.button) as Button
button.setOnClickListener {
  // Create a new DialogShowNote called dialog
  val dialog = DialogShowNote()

  // Send the note via the sendNoteSelected function
  dialog.sendNoteSelected(tempNote)

  // Create the dialog
  dialog.show(supportFragmentManager, "123")
}
  1. 现在,切换回设计视图中的content_main.xml,并从调色板的常用类别中将一个RecyclerView小部件拖放到布局中。

  2. 将其id属性设置为recyclerView

现在,我们已经从项目中删除了临时的 UI 方面,并且我们有一个完整的RecyclerView小部件,具有一个独特的id属性,可以在我们的 Kotlin 代码中引用。

为 RecyclerView 创*列表项

接下来,我们需要一个布局来表示RecyclerView小部件中的每个项目。如前所述,我们将使用一个包含三个TextView小部件的LinearLayout实例。

这些是创*用于RecyclerView中使用的列表项所需的步骤:

  1. 在项目资源管理器中右键单击layout文件夹,然后选择新* | 布局资源文件。在名称:字段中输入listitem,并将根元素:设置为LinearLayout。默认的方向属性是垂直的,这正是我们需要的。

  2. 查看下一个屏幕截图,以了解我们在本节剩余步骤中要实现的目标。我已经对其进行了注释,以显示成品应用程序中的每个部分将是什么样子:为 RecyclerView 创*列表项

  3. 将三个TextView实例拖放到布局中,一个在另一个上方,如参考屏幕截图所示。第一个(顶部)将保存笔记状态/类型(想法/重要/待办事项),第二个(中间)将保存笔记标题,第三个(底部)将保存笔记本身的片段。

  4. 根据以下表格中显示的内容,配置LinearLayout实例和TextView小部件的各种属性:

小部件类型 属性 要设置的值
LinearLayout layout_height wrap_contents
LinearLayout Layout_Margin all 5dp
TextView(顶部) id textViewStatus
TextView(顶部) textSize 24sp
TextView(顶部) textColor @color/colorAccent
TextView(中间) id textViewTitle
TextView(中间) textSize 24sp
TextView(顶部) id textViewDescription

现在我们在主布局中有一个RecylerView小部件和一个用于列表中每个项目的布局。我们可以继续编写我们的RecyclerAdapter实现。

编写 RecyclerAdapter 类

现在我们将创*并编写一个全新的类。让我们称我们的新类为NoteAdapter。以通常的方式在与MainActivity类(以及所有其他类)相同的文件夹中创*一个名为NoteAdapter的新类。

通过添加这些import语句并继承RecyclerView.Adapter类来编辑NoteAdapter类的代码,然后添加如下所示的两个属性。编辑NoteAdapter类,使其与我们刚刚讨论过的代码相同:

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class NoteAdapter(
   private val mainActivity: MainActivity, 
   private val noteList: List<Note>) 
   : RecyclerView.Adapter<NoteAdapter.ListItemHolder>() {

}

在前面的代码中,我们使用主构造函数声明和初始化了NoteAdapter类的两个属性。注意构造函数的参数。它接收一个MainActivity引用以及一个List引用。这意味着当我们使用这个类时,我们需要发送一个对这个应用程序的主活动(MainActivity)的引用,以及一个List引用。我们很快就会看到我们如何使用MainActivity的引用,但我们可以合理地猜测,带有<Note>类型的List引用将是对我们很快在MainActivity类中编写的Note实例的引用。NoteAdapter将永久持有所有用户笔记的引用。

然而,您会注意到类声明和代码的其他部分都被红色下划线标出,显示我们的代码中存在错误。

第一个错误是因为我们需要重写RecylerView.Adapter类(我们正在继承的类)的一些抽象函数。

注意

我们在第十一章Kotlin 中的继承中讨论了抽象类及其函数。

最快的方法是点击类声明,按住Alt键,然后点击Enter键。选择实现成员,如下一个截图所示:

编写 RecyclerAdapter 类

在随后的窗口中,按住Shift键并左键单击所有三个选项(要添加的函数),然后点击确定。这个过程添加了以下三个函数:

  • onCreateViewHolder函数在需要列表项的布局时调用

  • onBindViewHolder函数在将RecyclerAdapter实例绑定到布局中的RecyclerView实例时调用

  • getItemCount函数将用于返回ArrayListNote实例的数量

我们很快将为这些函数中的每一个添加代码,以在特定时间做出所需的工作。

然而,请注意,我们的代码中仍然存在多个错误,包括新生成的函数以及类声明中。我们需要做一些工作来解决这些错误。

错误是因为NoteAdapter.ListItemHolder类不存在。当我们扩展NoteAdapter时,我们添加了ListItemHolder。这是我们选择的类类型,将用作每个列表项的持有者。目前它不存在 - 因此出现错误。另外两个函数也因为同样的原因出现了相同的错误,因为当我们要求 Android Studio 实现缺失的函数时,它们是自动生成的。

让我们通过开始创*所需的ListItemHolder类来解决这个问题。对于ListItemHolder实例与NoteAdapter共享数据/变量对我们很有用,因此我们将ListItemHolder创*为内部类。

点击类声明中的错误,然后选择创*类'ListItemHolder',如下一个截图所示:

编写 RecyclerAdapter 类

在随后的弹出窗口中,选择NoteAdapter以在NoteAdapter内生成ListItemHolder

以下代码已添加到NoteAdapter类中:

class ListItemHolder {

}

但我们仍然有多个错误。让我们现在修复其中一个。将鼠标悬停在类声明中的红色下划线错误上,如下一个截图所示:

编写 RecyclerAdapter 类

错误消息显示Type argument is not within its bounds. Expected: RecyclerView.ViewHolder! Found: NoteAdapter.ListItemHolder。这是因为我们可能已经添加了ListItemHolder,但ListItemHolder必须也实现RecyclerView.ViewHolder才能作为正确的类型使用。

修改ListItemHolder类的声明以匹配此代码:

    inner class ListItemHolder(view: View) : 
         RecyclerView.ViewHolder(view), 
         View.OnClickListener {

现在NoteAdapter类声明中的错误已经消失,但因为我们还实现了View.OnClickListener,我们需要实现onClick函数。此外,ViewHolder没有提供默认构造函数,所以我们需要添加。将以下onClick函数(现在为空)和这个init块(现在为空)添加到ListItemHolder类中:

init {
}

override fun onClick(view: View) {
}

提示

确保你添加的代码是在内部的ListItemHolder类中,而不是NoteAdapter类中。

让我们清理掉最后剩下的错误。当onBindViewHolder函数被自动生成时,Android Studio 没有为holder参数添加类型。这导致函数和类声明中出现错误。根据下面的代码更新onBindViewHolder函数的签名:

override fun onBindViewHolder(
   holder: ListItemHolder, position: Int) {

onCreateViewHolder函数签名中,返回类型没有被自动生成。修改onCreateViewHolder函数的签名,如下面的代码所示:

    override fun onCreateViewHolder(
       parent: ViewGroup, viewType: Int): ListItemHolder {

作为最后一点良好的整理,让我们删除自动生成但不需要的三个// TODO…注释。每个自动生成的函数中都有一个。它们看起来像下一个截图中突出显示的那样:

编写 RecyclerAdapter 类

当你删除TODO…注释时,会出现更多的错误。我们需要在一些自动生成的函数中添加return语句。随着我们继续编写类,我们将会这样做。

经过多次调整和自动生成,我们最终有了一个几乎没有错误的NoteAdapter类,包括重写的函数和一个内部类,我们可以编写代码来使我们的RecyclerAdapter实例工作。此外,我们可以编写代码来响应每个ListItemHolder实例上的点击(在onClick中)。

接下来是代码在这个阶段应该看起来的完整清单(不包括导入语句):

class NoteAdapter(
  private val mainActivity: MainActivity,
  private val noteList: List<Note>)
  : RecyclerView.Adapter<NoteAdapter.ListItemHolder>() {

    override fun onCreateViewHolder(
         parent: ViewGroup, viewType: Int):
         ListItemHolder {

    }

    override fun getItemCount(): Int {

    }

    override fun onBindViewHolder(
         holder: ListItemHolder, 
         position: Int) {

    }

    inner class ListItemHolder(view: View) : 
          RecyclerView.ViewHolder(view),
          View.OnClickListener {

        init {

        }

        override fun onClick(view: View) {
        }
    }
}

提示

你本可以只复制并粘贴前面的代码,而不必忍受之前页面的折磨,但那样你就不会如此近距离地体验到实现接口和内部类的过程。

现在,让我们编写函数并使这个类运行起来。

编写 onCreateViewHolder 函数

接下来,我们将调整自动生成的onCreateViewHolder函数。将下面的代码行添加到onCreateViewHolder函数中并学*它们:

override fun onCreateViewHolder(
   parent: ViewGroup, viewType: Int): 
   ListItemHolder {

 val itemView = LayoutInflater.from(parent.context)
 .inflate(R.layout.listitem, parent, false)

 return ListItemHolder(itemView)
}

这段代码通过使用LayoutInflater和我们新设计的listitem布局来初始化itemView。然后返回一个新的ListItemHolder实例,包括一个已经膨胀并且可以立即使用的布局。

编写 onBindViewHolder 函数

接下来,我们将调整onBindViewHolder函数。添加高亮代码,使函数与此代码相同,并确保也学*代码:

override fun onBindViewHolder(
         holder: ListItemHolder, position: Int) {

   val note = noteList[position]
 holder.title.text = note.title

 // Show the first 15 characters of the actual note
 holder.description.text = 
 note.description!!.substring(0, 15)

 // What is the status of the note?
 when {
 note.idea -> holder.status.text = 
 mainActivity.resources.getString(R.string.idea_text)

 note.important -> holder.status.text = 
 mainActivity.resources.getString(R.string.important_text)

 note.todo -> holder.status.text = 
 mainActivity.resources.getString(R.string.todo_text)
 }

}

首先,代码将文本截断为 15 个字符,以便在列表中看起来合理。请注意,如果用户输入的笔记长度小于 15 个字符,这将导致崩溃。读者可以自行回到这个项目中,发现解决这个缺陷的方法。

然后检查它是什么类型的笔记(想法/待办/重要),并使用when表达式从字符串资源中分配适当的标签。

这段新代码在holder.titleholder.descriptionholder.status的代码中留下了一些错误,因为我们需要将它们添加到我们的ListItemHolder内部类中。我们将很快做到这一点。

编写getItemCount

修改getItemCount函数中的代码,如下所示:

override fun getItemCount(): Int {
   if (noteList != null) {
 return noteList.size
 }
 // error
 return -1
}

这个函数是类内部使用的,它提供了List中当前项目的数量。

编写ListItemHolder内部类

现在我们可以将注意力转向ListItemHolder内部类。通过添加以下突出显示的代码来调整ListItemHolder内部类:

inner class ListItemHolder(view: View) :
         RecyclerView.ViewHolder(view),
         View.OnClickListener {

 internal var title =
 view.findViewById<View>(
 R.id.textViewTitle) as TextView

 internal var description =
 view.findViewById<View>(
 R.id.textViewDescription) as TextView

 internal var status =
 view.findViewById<View>(
 R.id.textViewStatus) as TextView

  init {

        view.isClickable = true
 view.setOnClickListener(this)
  }

  override fun onClick(view: View) {
        mainActivity.showNote(adapterPosition)
  }
}

ListItemHolder属性引用布局中的每个TextView小部件。init块代码将整个视图设置为可点击,这样操作系统将在点击持有者时调用我们讨论的下一个函数onClick

onClick中,对mainActivity.showNote的调用存在错误,因为该函数尚不存在,但我们将在下一节中修复这个问题。该调用将简单地使用我们的自定义DialogFragment实例显示单击的笔记。

编写 MainActivity 以使用 RecyclerView 和 RecyclerAdapter 类

现在,切换到编辑窗口中的MainActivity类。将这三个新属性添加到MainActivity类中,并删除临时代码:

// Temporary code
//private var tempNote = Note()

private val noteList = ArrayList<Note>()
private val recyclerView: RecyclerView? = null
private val adapter: NoteAdapter? = null

这三个属性是我们所有Note实例的ArrayList实例,我们的RecyclerView实例和我们的NoteAdapter类的一个实例。

onCreate中添加代码

在处理用户按下浮动操作按钮的代码之后,在onCreate函数中添加以下突出显示的代码(为了上下文再次显示):

fab.setOnClickListener { view ->
   val dialog = DialogNewNote()
   dialog.show(supportFragmentManager, "")
}

recyclerView = 
 findViewById<View>(R.id.recyclerView) 
 as RecyclerView

adapter = NoteAdapter(this, noteList)
val layoutManager = 
 LinearLayoutManager(applicationContext)

recyclerView!!.layoutManager = layoutManager
recyclerView!!.itemAnimator = DefaultItemAnimator()

// Add a neat dividing line between items in the list
recyclerView!!.addItemDecoration(
 DividerItemDecoration(this, 
 LinearLayoutManager.VERTICAL))

// set the adapter
recyclerView!!.adapter = adapter

在这里,我们使用布局中的RecyclerView小部件初始化recyclerView。通过调用我们编写的构造函数来初始化我们的NoteAdapteradapter)实例。请注意,我们传入了对MainActivitythis)和ArrayList实例的引用,正如我们之前编写的类所要求的那样。

接下来,我们创*一个新对象 - 一个LayoutManager对象。在接下来的四行代码中,我们配置了recyclerView的一些属性。

itemAnimator属性和addItemDecoration函数使每个列表项在列表中的每个项目之间都有一个分隔线,从视觉上更加美观。稍后,当我们构*一个“设置”屏幕时,我们将让用户选择添加和删除这个分隔线的选项。

我们做的最后一件事是用我们的适配器初始化recylerViewadapter属性,将我们的适配器与我们的视图结合在一起。

现在,我们将对createNewNote函数进行一些更改。

修改createNewNote函数

createNewNote函数中,删除我们在第十四章中添加的临时代码,Android 对话框窗口(显示为注释)。并添加下一个显示的新突出代码:

fun createNewNote(n: Note) {
  // Temporary code
  // tempNote = n
  noteList.add(n)
 adapter!!.notifyDataSetChanged()

}

新添加的突出显示的代码将一个笔记添加到ArrayList实例中,而不是简单地初始化一个孤立的Note对象,现在已经被注释掉。然后,我们需要调用notifyDataSetChanged,让我们的适配器知道已添加新的笔记。

编写showNote函数

添加showNote函数,它是从NoteAdapter类中使用传递给NoteAdapter构造函数的对这个类的引用来调用的。更准确地说,当用户点击RecyclerView小部件中的一个项目时,它是从ListerItemHolder内部类中调用的。将showNote函数添加到MainActivity类中:

fun showNote(noteToShow: Int) {
   val dialog = DialogShowNote()
   dialog.sendNoteSelected(noteList[noteToShow])
   dialog.show(supportFragmentManager, "")
}

注意

NoteAdapter.kt文件中的所有错误现在都已经消失。

刚刚添加的代码将启动一个新的DialogShowNote实例,传入由noteToShow引用的特定所需的笔记。

运行应用程序

现在,您可以运行应用程序并输入一个新的笔记,如下一个屏幕截图所示:

运行应用程序

在输入了几种类型的笔记后,列表(RecyclerView)将看起来像下一个屏幕截图所示:

运行应用程序

而且,如果您点击查看其中一条笔记,它会看起来像这样:

运行应用程序

笔记

读者挑战

我们本可以花更多时间格式化我们的两个对话框窗口的布局。为什么不参考第五章,使用 CardView 和 ScrollView 创*美丽的布局,以及 Material Design 网站,material.io/design/,做得比这更好。此外,您可以通过使用CardView而不是LinearLayout来增强RecyclerView的笔记列表。

不要花太长时间添加新的笔记,因为有一个小问题:关闭并重新启动应用程序。哦哦,所有的笔记都消失了!

经常问的问题

Q.1) 我仍然不明白RecyclerAdapter是如何工作的?

A) 那是因为我们实际上并没有讨论过。我们没有讨论幕后的细节是因为我们不需要知道它们。如果我们重写所需的函数,就像我们刚刚看到的那样,一切都会正常工作。这就是RecyclerAdapter和我们使用的大多数其他类的意图:隐藏实现并公开函数以暴露必要的功能。

Q.2) 我觉得我需要知道RecyclerAdapter和其他类的内部情况。我该怎么做?

A) 的确,RecyclerAdapter(以及我们在本书中使用的几乎每个类)有更多细节,我们没有空间来讨论。阅读您使用的类的官方文档是一个好的做法。您可以在developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter上阅读更多信息。

摘要

现在我们已经添加了保存多个笔记的功能,并实现了显示它们的能力。

我们通过学*和使用RecyclerAdapter类来实现了这一点,该类实现了Adapter接口,允许我们将RecyclerView实例和ArrayList实例绑定在一起,从而无缝显示数据,而我们(程序员)不必担心这些类的复杂代码,甚至看不到。

在下一章中,我们将开始使用户的笔记在退出应用程序或关闭设备时持久化。此外,我们将创*一个“设置”屏幕,并看看如何使设置也持久化。我们将使用不同的技术来实现这些目标。

第十七章:数据持久性和共享

在本章中,我们将探讨将数据保存到 Android 设备的永久存储的几种不同方法。此外,我们还将首次向我们的应用程序添加第二个Activity实例。在我们的应用程序中实现一个单独的“屏幕”,比如“设置”屏幕时,这通常是有意义的,可以在一个新的Activity实例中这样做。我们可以通过在同一个Activity中隐藏原始 UI 然后显示新 UI 的方式来做到这一点,就像我们在第四章中所做的那样,开始使用布局和材料设计,但这很快会导致混乱和容易出错的代码。因此,我们将看到如何添加另一个Activity实例并在它们之间导航用户。

在本章中,我们将执行以下操作:

  • 了解 Android Intent类以在Activity实例之间切换并在它们之间传递数据

  • 在一个新的Activity实例中创*一个非常简单的设置屏幕

  • 使用SharedPreferences类持久保存设置屏幕数据

  • 了解JavaScript 对象表示JSON)进行序列化

  • 探索try-catch-finally

  • 在我们的备忘录应用程序中实现数据保存

Android Intent 类

Intent类的命名恰如其分。它是一个展示我们应用程序的Activity实例意图的类。它使意图清晰并且也促进了它。

到目前为止,我们的所有应用程序都只有一个Activity实例,但许多 Android 应用程序包含多个。

在它可能最常见的用法中,Intent对象允许我们在Activity实例之间切换。但是,当我们在这些类之间切换时,数据会发生什么?Intent类也通过允许我们在它们之间传递数据来解决了这个问题。

Intent类不仅仅是关于连接我们应用程序的活动。它们还使与其他应用程序进行交互成为可能。例如,我们可以在我们的应用程序中提供一个链接,让用户发送电子邮件,打电话,与社交媒体互动,或在浏览器中打开网页,并让电子邮件、拨号器、网络浏览器或相关的社交媒体应用程序完成所有工作。

没有足够的页面来深入了解与其他应用程序的交互,因此我们主要将专注于在活动之间切换和传递数据。

切换 Activity

假设我们有一个基于两个Activity的类的应用程序,很快我们就会有。我们可以假设,像往常一样,我们有一个名为MainActivityActivity实例,这是应用程序的起点,以及一个名为SettingsActivity的第二个Activity实例。这是我们如何从MainActivity切换到SettingsActivity的方法:

// Declare and initialize a new Intent object called myIntent
val myIntent = Intent(this, 
         SettingsActivity::class.java)

// Switch to the SettingsActivity
startActivity(myIntent)

仔细查看我们如何初始化Intent对象。Intent有一个构造函数,它接受两个参数。第一个是对当前Activity实例this的引用。第二个参数是我们要打开的Activity实例的名称,SettingsActivity::classSettingsActivity末尾的class使其成为AndroidManifest.xml文件中声明的Activity实例的完整名称,我们将在不久的将来尝试Intent时窥探一下。

注意

看起来奇怪的.java是因为所有的 Kotlin 代码都被转换为 Java 字节码,SettingsActivity::class.java是它的完全限定名称。

唯一的问题是SettingsActivity不共享MainActivity的任何数据。在某种程度上,这是一件好事,因为如果您需要从MainActivity获取所有数据,那么这合理地表明切换Activity实例可能不是处理应用程序设计的最佳方式。然而,让两个Activity实例封装得如此彻底,以至于它们彼此完全不知道,这是不合理的。

在 Activity 之间传递数据

如果我们为用户创*一个登录屏幕,并且我们希望将登录凭据传递给我们应用程序的每个Activity实例,我们可以使用Intent类来实现。

我们可以像这样向Intent实例添加数据:

// Create a String called username 
// and set its value to bob
val username = "Bob"

// Create a new Intent as we have already seen
val myIntent = Intent(this, 
         SettingsActivity::class.java)

// Add the username String to the Intent
// using the putExtra function of the Intent class
myIntent.putExtra("USER_NAME", username)

// Start the new Activity as we have before
startActivity(myIntent)

SettingsActivity中,我们可以像这样检索String值:

// Here we need an Intent also
// But the default constructor will do
// as we are not switching Activity
val myIntent = Intent()

// Initialize username with the passed in String 
val username = intent.extras.getString("USER_NAME")

在前两个代码块中,我们以与我们已经看到的相同方式切换了Activity实例。但是,在调用startActivity之前,我们使用putExtra函数将一个String值加载到myIntent中。

我们使用键值对添加数据。每个数据都需要伴随一个标识符,以便在检索Activity实例中识别并检索数据。

标识符名称是任意的,但应该使用有用/易记的值。

然后,在接收的Activity实例中,我们只需使用默认构造函数创*一个Intent对象:

val myIntent = Intent();

然后,我们可以使用extras.getString函数和键值对中的适当标识符来检索数据。

Intent类可以帮助我们发送比这更复杂的数据,但Intent类有其限制。例如,我们将无法发送Note对象。一旦我们想要开始发送多个值,就值得考虑不同的策略。

向“Note to self”添加设置页面

现在我们已经掌握了关于 Android Intent类的所有知识,我们可以向我们的“Note to self”应用程序添加另一个屏幕(Activity):一个“设置”屏幕。

首先,我们将为新屏幕创*一个新的Activity实例,并查看这对AndroidManifest.xml文件的影响。然后,我们将为设置屏幕创*一个非常简单的布局,并添加 Kotlin 代码以从MainActivity切换到新的布局。然而,我们将推迟将设置屏幕布局与 Kotlin 连接,直到我们学会如何将用户首选设置保存到磁盘。我们将在本章后面做这个,然后回到设置屏幕以使其数据持久化。

首先,让我们编写新的Activity类。我们将其称为SettingsActivity

创* SettingsActivity

SettingsActivity 将是一个屏幕,用户可以在其中打开或关闭RecyclerView小部件中每个笔记之间的装饰分隔线。这不会是一个非常全面的设置屏幕,但这将是一个有用的练*,并且我们将看到在两个Activity实例之间切换以及将数据保存到磁盘的操作。按照以下步骤开始:

  1. 在项目资源管理器窗口中,右键单击包含所有.kt文件并与您的包具有相同名称的文件夹。从弹出的上下文菜单中,选择新*|Activity|空白 Activity

  2. Activity Name:字段中输入SettingsActivity

  3. 将所有其他选项保持默认值,然后单击完成

Android Studio 为我们创*了一个新的Activity类及其关联的.kt文件。让我们快速查看一些在幕后为我们完成的工作,因为了解发生了什么是很有用的。

从项目资源管理器中的manifests文件夹中打开AndroidManifest.xml文件。注意文件末尾附近的以下新代码行:

<activity android:name=".SettingsActivity"></activity>

这是Activity类与操作系统注册的方式。如果Activity类未注册,则尝试运行它将使应用程序崩溃。我们可以通过在新的.kt文件中创*一个扩展Activity(或AppCompatActivity)的类来创*Activity类。但是,我们将不得不自己添加前面的代码。此外,通过使用新的 Activity 向导,我们自动生成了一个布局 XML 文件(activity_settings.xml)。

设计设置屏幕布局

我们将快速为我们的设置屏幕构*用户界面;以下步骤和屏幕截图应该使这变得简单:

  1. 打开activity_settings.xml文件,并切换到Design选项卡,在那里我们将快速布置我们的设置屏幕。

  2. 在遵循其余步骤时,请使用下一个截图作为指南:设计设置屏幕布局

  3. 将一个Switch小部件拖放到布局的中上部。我通过拖动边缘来拉伸它,使其更大更清晰。

  4. 添加一个id属性为switch1(如果还没有的话),以便我们可以使用 Kotlin 与其交互。

  5. 使用约束处理程序来固定开关的位置,或者点击推断约束按钮来自动固定它。

我们现在为我们的设置屏幕有了一个漂亮(而且非常简单)的新布局,并且id属性已经就位,准备在本章后面的代码中与其连接。

使用户能够切换到“设置”屏幕

我们已经知道如何创*和切换到SettingsActivity实例。另外,由于我们不会向其传递任何数据,也不会从中获取任何数据,我们可以只用几行 Kotlin 代码就可以让其工作。

您可能已经注意到我们的应用程序的操作栏中有菜单图标。在下一个截图中指示了它:

使用户能够切换到“设置”屏幕

如果您点击它,您会发现其中已经有一个设置菜单选项,这是我们在创*应用程序时默认提供的。当您点击菜单图标时,您将看到以下内容:

使用户能够切换到“设置”屏幕

我们所需要做的就是将创*和切换到SettingsActivity实例的代码放在MainActivity.kt文件的onOptionsItemSelected函数中。Android Studio 甚至默认为我们提供了一个when块,以便我们将来有一天想要添加设置菜单时将我们的代码粘贴进去。多么体贴。

切换到编辑器窗口中的MainActivity.kt,并找到onOptionsItemSelected函数中的以下代码块:

return when (item.itemId) {
   R.id.action_settings -> true
   else -> super.onOptionsItemSelected(item)
}

编辑前面显示的when块以匹配以下代码:

return when (item.itemId) {
   R.id.action_settings -> {
         val intent = Intent(this, 
                      SettingsActivity::class.java)

         startActivity(intent)
         true
  }

  else -> super.onOptionsItemSelected(item)
}

提示

您需要使用您喜欢的技术导入Intent类以添加以下代码:

import android.content.Intent

现在您可以运行应用程序,并通过点击设置菜单选项来访问新的设置屏幕。此截图显示了模拟器上运行的设置屏幕:

使用户能够切换到“设置”屏幕

要从SettingsActivity屏幕返回到MainActivity屏幕,您可以点击设备上的返回按钮。

使用 SharedPreferences 持久化数据

在 Android 中,有几种方法可以使数据持久化。持久化的意思是,如果用户退出应用程序,然后再次打开应用程序,他们的数据仍然可用。使用哪种技术取决于应用程序和数据类型。

在本书中,我们将介绍三种使数据持久化的方法。对于保存用户的设置,我们只需要一个简单的方法。毕竟,我们只需要知道他们是否希望在RecyclerView小部件的每个笔记之间有装饰性分隔符。

让我们看看如何使我们的应用程序将变量保存和重新加载到设备的内部存储器中。我们需要使用SharedPreferences类。SharedPreferences是一个提供对数据访问和编辑的类,可以被应用程序的所有类访问和编辑。让我们看看如何使用它:

// A SharedPreferences instance for reading data
val prefs = getSharedPreferences(
         "My app",
          Context.MODE_PRIVATE)

// A SharedPreferences.Editor instance for writing data
val editor = prefs.edit()

我们通过使用getSharedPreferences函数并传入一个String值来初始化prefs对象,该值将用于引用使用该对象读取和写入的所有数据。通常,我们可以使用应用的名称作为此字符串值。在下一段代码中,Mode_Private表示任何类都可以访问它,但只能从此应用程序访问。

然后,我们使用我们新初始化的prefs对象通过调用edit函数来初始化我们的editor对象。

让我们假设我们想要保存用户的名字,我们在一个名为usernameString实例中拥有。然后我们可以像这样将数据写入设备的内部存储器:

editor.putString("username", username)

putString函数中使用的第一个参数是一个标签,可用于引用数据,第二个参数是保存我们要保存的数据的实际变量。前面代码的第二行启动了保存过程。因此,我们可以像这样将多个变量写入磁盘:

editor.putString("username", username)
editor.putInt("age", age)
editor.putBoolean("newsletter-subscriber", subscribed)

// Save all the above data
editor.apply()

前面的代码演示了您可以保存其他变量类型,并且假设usernameagesubscribed变量已经被声明并使用适当的值进行了初始化。

一旦editor.apply()执行,数据就被存储了。我们可以退出应用程序,甚至关闭设备,数据仍将持久存在。

使用 SharedPreferences 重新加载数据

让我们看看下一次应用程序运行时如何重新加载我们的数据。这段代码将重新加载前一段代码保存的三个值。我们甚至可以声明变量并使用存储的值进行初始化:

val username  = prefs.getString(
   "username", "new user")

val age  = prefs.getInt("age", -1)

val subscribed = prefs.getBoolean(
    "newsletter-subscriber", false)

在前面的代码中,我们使用了适用于数据类型的函数从磁盘加载数据,并使用了与我们首次保存数据时使用的相同标签。不太清楚的是每个函数调用的第二个参数。

getStringgetIntgetBoolean函数需要第二个参数作为默认值。如果没有存储带有该标签的数据,它将返回默认值。

然后,我们可以在我们的代码中检查这些默认值,并尝试获取所需的值或处理错误。例如,参见以下代码:

if (age == -1){
   // Ask the user for his age
}

我们现在已经了解足够的知识来保存用户的设置在 Note to self 应用程序中。

使自我备忘录设置持久化

我们已经学会了如何将数据保存到设备的内存中。当我们实现保存用户的设置时,我们将再次看到我们如何处理Switch小部件的输入,以及我们刚刚看到的代码将如何使我们的应用程序按照我们想要的方式工作。

编写 SettingsActivity 类

大部分操作将在SettingsActivity.kt文件中进行。因此,点击适当的选项卡,我们将逐步添加代码。

首先,我们希望有一个属性来表示用户在设置屏幕上的选项 - 他们是否想要装饰性分隔线。

将以下内容添加到SettingsActivity中:

private val showDividers: Boolean = true

现在,在onCreate中,添加突出显示的代码以初始化prefs,它被推断为SharedPreferences实例:

val prefs = getSharedPreferences(
               "Note to self",
                Context.MODE_PRIVATE)

提示

导入SharedPreferences类:

import android.content.SharedPreferences

接下来,在onCreate中,让我们加载保存的数据,这些数据代表我们的用户以前选择是否显示分隔线。我们将根据需要将开关设置为打开或关闭:

showDividers  = prefs.getBoolean("dividers", true)

// Set the switch on or off as appropriate
switch1.isChecked = showDividers

接下来,我们将创*一个 lambda 来处理我们的Switch小部件的更改。我们只需将showDividers的值设置为Switch小部件的isChecked变量相同。将以下代码添加到onCreate函数中:

switch1.setOnCheckedChangeListener {
   buttonView, isChecked ->

   showDividers = isChecked
}

您可能已经注意到,在任何代码中的任何时候,我们都没有将任何值写入设备存储。我们可以在检测到开关变化后放置它,但是将它放在保证被调用的地方要简单得多 - 但只有一次。

我们将利用我们对Activity生命周期的了解,并覆盖onPause函数。当用户离开SettingsActivity屏幕时,无论是返回MainActivity屏幕还是退出应用程序,onPause都将被调用,并且设置将被保存。这样,用户可以随意切换开关,应用程序将保存他们的最终决定。添加此代码以覆盖onPause函数并保存用户的设置。将此代码添加到SettingsActivity类的结束大括号之前:

override fun onPause() {
   super.onPause()

   // Save the settings here
   val prefs = getSharedPreferences(
               "Note to self",
                Context.MODE_PRIVATE)

   val editor = prefs.edit()

   editor.putBoolean("dividers", showDividers)

   editor.apply()
}

前面的代码在私有模式下声明和初始化了一个新的SharedPreferences实例,使用了应用程序的名称。它还声明和初始化了一个新的SharedPreferences.Editor实例。最后,使用putBoolean将值输入到editor对象中,并使用apply函数写入磁盘。

现在,我们可以向MainActivity添加一些代码,在应用程序启动时或用户从设置屏幕切换回主屏幕时加载设置。

编写 MainActivity 类

NoteAdapter声明后添加这段突出显示的代码:

private var adapter: NoteAdapter? = null
private var showDividers: Boolean = false

现在我们有一个Boolean属性来决定是否显示分隔线。我们将重写onResume函数并初始化我们的Boolean属性。添加重写的onResume函数,如下所示,添加到MainActivity类旁边:

override fun onResume() {
   super.onResume()

   val prefs = getSharedPreferences(
               "Note to self",
                Context.MODE_PRIVATE)

  showDividers = prefs.getBoolean(
               "dividers", true)
}

用户现在能够选择他们的设置。应用程序将根据需要保存和重新加载它们,但我们需要让MainActivity响应用户的选择。

onCreate函数中找到这段代码并删除它:

recyclerView!!.addItemDecoration(
   DividerItemDecoration(this,
         LinearLayoutManager.VERTICAL))

先前的代码是设置列表中每个笔记之间的分隔线。将这段新代码添加到onResume函数中,这是相同的代码行,被一个if语句包围,只有在showDividerstrue时才选择性地使用分隔线。在onResume中的先前代码之后添加这段代码:

// Add a neat dividing line between list items
if (showDividers)
    recyclerView!!.addItemDecoration(
          DividerItemDecoration(
          this, LinearLayoutManager.VERTICAL))
else {
  // check there are some dividers
  // or the app will crash
  if (recyclerView!!.itemDecorationCount > 0)
        recyclerView!!.removeItemDecorationAt(0)
}

运行应用程序,你会注意到分隔线消失了;转到设置屏幕,打开分隔线,返回主屏幕(使用返回按钮),你会发现:现在有分隔符了。下一张截图显示了有和没有分隔符的列表,被并排合成一张照片,以说明开关的工作,并且设置在两个Activity实例之间持久保存:

编写 MainActivity 类

一定要尝试退出应用程序并重新启动,以验证设置是否已保存到磁盘。甚至可以关闭模拟器,然后再次打开,设置将保持不变。

现在我们有一个整洁的设置屏幕,我们可以永久保存用户的选择。当然,关于持久性的一个重要缺失是用户的基本数据,他们的笔记,仍然无法持久保存。

更高级的持久性

让我们考虑一下我们需要做什么。我们想要将一堆笔记保存到内部存储器中。更具体地说,我们想要存储一些字符串和相关的布尔值。这些字符串和布尔值代表用户的笔记标题、文本,以及它是待办事项、重要事项还是想法。

鉴于我们已经对SharedPreferences类有所了解,乍一看,这似乎并不特别具有挑战性 - 直到我们更深入地了解我们的需求。如果用户喜欢我们的应用程序并最终拥有 100 条笔记,我们将需要 100 个键值对的标识符。这并非不可能,但开始变得尴尬。

现在,想象一下,我们想增强应用程序并让用户能够为它们添加日期。Android 有一个Date类非常适合这个用途。然后,添加一些整洁的功能,比如提醒,对我们的应用程序来说将是相当简单的。但是当涉及到保存数据时,事情突然变得复杂起来。

我们如何使用SharedPreferences存储日期?它并不是为此而设计的。我们可以在保存时将其转换为字符串值,然后在加载时再次转换回来,但这远非简单。

随着我们的应用程序功能的增加和用户拥有越来越多的笔记,整个持久性问题变得一团糟。我们需要一种方法来保存和加载实际的 Kotlin 对象。如果我们能简单地保存和加载对象,包括它们的内部数据(字符串、布尔值、日期或其他任何东西),我们的应用程序可以拥有我们需要适应用户的任何类型的数据。

将数据对象转换为位和字节以存储在磁盘上的过程称为序列化;反向过程称为反序列化。单独的序列化是一个广泛的主题,远非简单。幸运的是,正如我们所期望的那样,有一个类来处理大部分复杂性。

JSON 是什么?

JSON代表JavaScript 对象表示法,它在 Android 编程之外的领域被广泛使用。它可能更经常用于在 Web 应用程序和服务器之间发送数据。

幸运的是,Android 上有可用的 JSON 类几乎完全隐藏了序列化过程的复杂性。通过学*一些更多的 Kotlin 概念,我们可以快速开始使用这些类,并开始将整个 Kotlin 对象写入设备存储,而不必担心构成对象的原始类型是什么。

与我们迄今为止看到的其他类相比,JSON 类进行的操作有比正常情况下更高的可能性失败。要了解为什么会这样以及可以采取什么措施,让我们看看异常

异常 - try、catch 和 finally

所有这些关于 JSON 的讨论都要求我们学*另一个 Kotlin 概念:异常。当我们编写执行可能失败的操作的类时,特别是由于我们无法控制的原因,*议在我们的代码中明确说明这一点,以便任何使用我们的类的人都能为可能性做好准备。

保存和加载数据是一个可能发生失败的情况。想想当 SD 卡已被移除或已损坏时尝试加载数据。另一个可能失败的情况是,当我们编写依赖网络连接的代码时,如果用户在数据传输的过程中离线了会怎么样?

Kotlin 异常是解决方案,JSON 类使用它们,所以现在是学*它们的好时机。

当我们编写使用有可能失败的代码的类时,我们可以通过使用trycatchfinally来准备我们类的用户。

我们可以在我们的类中使用@Throws注解来写函数,就像这样,也许:

@Throws(someException::class)
fun somePrecariousFunction() {
   // Risky code goes here
}

现在,任何使用somePrecariousFunction的代码都需要处理异常。我们处理异常的方式是将代码包装在trycatch块中;也许像这样:

try {
  …
  somePrecariousFunction()
  …

} catch (e: Exception) {
   Log.e("Uh Oh!", "somePrecariousFunction failure", e)
}

如果需要,在trycatch块之后,我们还可以添加一个finally块来采取进一步的行动:

finally{
   // More action here
}

在我们的备忘录应用中,我们将采取最少的必要行动来处理异常,并简单地将错误输出到 logcat 窗口,但您可以做一些事情,比如通知用户,重试操作,或者实施一些聪明的备用计划。

备份用户数据到备忘录

因此,有了我们对异常的新认识,让我们修改一下我们的备忘录代码,然后我们可以介绍JSONObjectJSONException

首先,让我们对我们的Note类进行一些小修改。

添加一些更多的属性,它们将作为我们的Note类的每个方面的键值对中的键:

private val JSON_TITLE = "title"
private val JSON_DESCRIPTION = "description"
private val JSON_IDEA = "idea"
private val JSON_TODO = "todo"
private val JSON_IMPORTANT = "important"

现在,添加一个构造函数和一个接收JSONObject引用并抛出JSONException错误的空默认构造函数。第一个构造函数的主体通过调用JSONObject类的getStringgetBoolean函数并传入键作为参数来初始化单个Note对象的每个属性的成员。我们还提供了一个空构造函数,这是必需的,以便我们也可以创*一个未初始化属性的Note对象:

// Constructor
// Only used when created from a JSONObject
@Throws(JSONException::class)
constructor(jo: JSONObject) {

  title = jo.getString(JSON_TITLE)
  description = jo.getString(JSON_DESCRIPTION)
  idea = jo.getBoolean(JSON_IDEA)
  todo = jo.getBoolean(JSON_TODO)
  important = jo.getBoolean(JSON_IMPORTANT)
}

// Now we must provide an empty default constructor for
// when we create a Note to pass to the new note dialog
constructor() {

}

提示

您需要导入JSONExceptionJSONObject类:

import org.json.JSONException;
import org.json.JSONObject;

接下来我们将看到的代码将给定Note对象的属性值加载到JSONObject实例中。这是Note对象的值被打包准备好进行实际序列化的地方。

我们只需要使用适当的键和匹配的属性调用put函数。这个函数返回JSONObject(我们马上会看到在哪里),并且抛出一个JSONObject异常。添加我们刚刚讨论过的代码:

@Throws(JSONException::class)
fun convertToJSON(): JSONObject {

  val jo = JSONObject()

  jo.put(JSON_TITLE, title)
  jo.put(JSON_DESCRIPTION, description)
  jo.put(JSON_IDEA, idea)
  jo.put(JSON_TODO, todo)
  jo.put(JSON_IMPORTANT, important)

  return jo
}

现在,让我们创*一个JSONSerializer类,它将执行实际的序列化和反序列化。创*一个新的 Kotlin 类,命名为JSONSerializer

让我们将编码分成几个块,并在编写每个块时讨论我们正在做什么。

首先,声明和一些属性:一个String实例来保存数据的文件名,以及一个Context实例,在 Android 中写入数据到文件是必要的。编辑JSONSerializer类的代码如下所示:

class JSONSerializer(
   private val filename: String, 
   private val context: Context) {
   // All the rest of the code goes here

}

提示

你需要导入Context类:

import android.content.Context

现在我们可以开始编写类的真正核心部分。接下来是save函数。它首先创*一个JSONArray对象,这是一个专门处理 JSON 对象的ArrayList类。

接下来,代码使用for循环遍历notes中的所有Note对象,并使用我们之前添加的Note类的convertToJSON函数将它们转换为 JSON 对象。然后,将这些转换后的JSONObject加载到jArray中。

接下来,代码使用Writer实例和Outputstream实例组合将数据写入实际文件。注意,OutputStream实例需要Context对象。添加我们刚刚讨论过的代码:

@Throws(IOException::class, JSONException::class)
fun save(notes: List<Note>) {

   // Make an array in JSON format
   val jArray = JSONArray()

   // And load it with the notes
   for (n in notes)
         jArray.put(n.convertToJSON())

  // Now write it to the private disk space of our app
  var writer: Writer? = null
  try {
    val out = context.openFileOutput(filename,
                Context.MODE_PRIVATE)

    writer = OutputStreamWriter(out)
    writer.write(jArray.toString())

  } finally {
        if (writer != null) {

        writer.close()
      }
   }
}

提示

你需要为这些新类添加以下导入语句:

import org.json.JSONArray
import org.json.JSONException
import java.io.IOException
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.io.Writer
import java.util.List

现在进行反序列化 - 加载数据。这次,正如我们所期望的那样,该函数没有参数,而是返回ArrayList。使用context.openFileInput创*一个InputStream实例,并打开包含所有数据的文件。

我们使用for循环将所有数据追加到一个String对象中,并使用我们的新Note构造函数,将每个JSONObject解包为Note对象并将其添加到ArrayList中,最后将其返回给调用代码。添加load函数:

@Throws(IOException::class, JSONException::class)
fun load(): ArrayList<Note> {
   val noteList = ArrayList<Note>()
   var reader: BufferedReader? = null

   try {

         val `in` = context.openFileInput(filename)
         reader = BufferedReader(InputStreamReader(`in`))
         val jsonString = StringBuilder()

    for (line in reader.readLine()) {
          jsonString.append(line)
    }

    val jArray = JSONTokener(jsonString.toString()).
                 nextValue() as JSONArray

    for (i in 0 until jArray.length()) {
           noteList.add(Note(jArray.getJSONObject(i)))
    }

  } catch (e: FileNotFoundException) {
         // we will ignore this one, since it happens
        // when we start fresh. You could add a log here.

  } finally {
   // This will always run            
            reader!!.close()
  }

  return noteList
}

提示

你需要添加这些导入:

import org.json.JSONTokener
import java.io.BufferedReader
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.InputStreamReader
import java.util.ArrayList

现在,我们需要在MainActivity类中让我们的新类开始工作。在MainActivity声明之后添加一个新属性,如下所示。此外,删除noteList的初始化,只留下声明,因为我们现在将在onCreate函数中使用一些新代码进行初始化。我已经注释掉了你需要删除的那行:

private var mSerializer: JSONSerializer? = null
private var noteList: ArrayList<Note>? = null
//private val noteList = ArrayList<Note>()

现在,在onCreate函数中,我们通过使用文件名和getApplicationContext()调用JSONSerializer构造函数来初始化mSerializer,这是应用程序的Context实例,是必需的。然后我们可以使用JSONSerializer load函数来加载任何保存的数据。在处理浮动操作按钮的代码之后添加这段新的突出代码。这段新代码必须出现在我们初始化RecyclerView实例的代码之前:

fab.setOnClickListener { view ->
   val dialog = DialogNewNote()
   dialog.show(supportFragmentManager, "")
}

mSerializer = JSONSerializer("NoteToSelf.json",
 applicationContext)

try {
 noteList = mSerializer!!.load()
} catch (e: Exception) {
 noteList = ArrayList()
 Log.e("Error loading notes: ", "", e)
}

recyclerView =
         findViewById<View>(R.id.recyclerView) 
         as RecyclerView

adapter = NoteAdapter(this, this.noteList!!)
val layoutManager = LinearLayoutManager(
          applicationContext)

提示

在上一段代码中,我展示了大量的上下文,因为它的正确位置对其工作是必要的。如果你在使用过程中遇到任何问题,请确保将其与Chapter17/Note to self文件夹中的下载包中的代码进行比较。

现在,在我们的MainActivity类中添加一个新函数,以便我们可以调用它来保存所有用户的数据。这个新函数所做的就是调用JSONSerializer类的save函数,传入所需的Note对象列表:

private fun saveNotes() {
  try {
        mSerializer!!.save(this.noteList!!)

  } catch (e: Exception) {
        Log.e("Error Saving Notes", "", e)
  }
}

现在,我们将重写onPause函数,以保存我们用户的数据,就像我们保存用户设置时所做的那样。确保在MainActivity类中添加这段代码:

override fun onPause() {
   super.onPause()

   saveNotes()
}

就是这样。现在我们可以运行应用程序,并添加尽可能多的笔记。ArrayList实例将把它们全部存储在我们的运行应用程序中,我们的RecyclerAdapter将管理在RecyclerView小部件中显示它们,现在 JSON 将负责从磁盘加载它们,并将它们保存回磁盘。

常见问题

Q.1)我并没有完全理解本章的所有内容,那我适合成为程序员吗?

A) 本章介绍了许多新的类、概念和函数。如果你感到有些头痛,这是可以预料的。如果一些细节不清楚,不要让它阻碍你。继续进行下一章(它们要简单得多),然后回顾这一章,特别是检查已完成的代码文件。

Q.2)那么,序列化的详细工作原理是什么?

A)序列化确实是一个广阔的话题。你可以一辈子写应用程序,而不真正需要理解它。这是一种可能成为计算机科学学位课程主题的话题。如果你想了解更多,请看看这篇文章:en.wikipedia.org/wiki/Serialization

总结

在我们通过 Android API 的旅程中,现在值得回顾一下我们所知道的。我们可以制定自己的 UI 设计,并可以从各种各样的小部件中进行选择,以便让用户进行交互。我们可以创*多个屏幕,以及弹出对话框,并且可以捕获全面的用户数据。此外,我们现在可以使这些数据持久化。

当然,Android API 还有很多东西需要学*,甚至超出了这本书会教给你的内容,但关键是我们现在知道足够的知识来规划和实施一个可工作的应用程序。你现在就可以开始自己的应用程序了。

如果你有立即开始自己的项目的冲动,那么我的*议是继续前进并去做。不要等到你认为自己是“专家”或更加准备好了。阅读这本书,更重要的是,实施这些应用程序将使你成为更好的 Android 程序员,但没有什么比设计和实施自己的应用程序更能让你更快地学会。完全可以阅读这本书并同时在自己的项目上工作。

在下一章中,我们将通过使应用程序支持多语言来为这个应用程序添加最后的修饰。这是相当快速和简单的。

第十八章:本地化

本章内容简单明了,但我们将学会的内容可以使您的应用程序面向数百万潜在用户。我们将看到如何添加额外的语言,以及为什么通过字符串资源正确添加文本在添加多种语言时对我们有益。

在本章中,我们将执行以下操作:

  • 通过添加西班牙语和德语语言使 Note to self 应用程序支持多语言

  • 学*如何更好地使用字符串资源

让我们开始吧。

使 Note to self 应用程序支持西班牙语、英语和德语

首先,我们需要为我们的项目添加一些文件夹 - 每种新语言一个文件夹。文本被归类为资源,因此需要放在res文件夹中。按照以下步骤为项目添加西班牙语支持。

注意

虽然该项目的源文件存储在Chapter18文件夹中,但它们仅供参考。您需要按照下面描述的流程来实现多语言功能。

添加西班牙语支持

按照以下步骤添加西班牙语:

  1. 右键单击res文件夹,然后选择新* | Android 资源目录。在目录名称字段中输入values-es

  2. 现在我们需要添加一个文件,我们可以在其中放置所有我们的西班牙翻译。

  3. 右键单击res,然后选择新* | Android 资源文件,在文件名字段中输入strings.xml。在目录名称字段中输入values-es

我们现在有一个strings.xml文件,任何设置为使用西班牙语的设备都将引用它。明确地说,我们现在有两个不同的strings.xml文件。

添加德语支持

按照以下步骤添加德语语言支持。

  1. 右键单击res文件夹,然后选择新* | Android 资源目录。在目录名称字段中输入values-de

  2. 现在我们需要添加一个文件,我们可以在其中放置所有我们的德语翻译。

  3. 右键单击res,然后选择新* | Android 资源文件,在文件名字段中输入strings.xml。在目录名称字段中输入values-de

以下屏幕截图显示了strings.xml文件夹的外观。您可能想知道strings.xml文件夹是从哪里来的,因为它与我们似乎在之前的步骤中创*的结构不对应。

Android Studio 正在帮助我们组织我们的文件和文件夹,因为这是 Android 操作系统在 APK 格式中所需的。但是,您可以清楚地看到西班牙语和德语文件,它们通过它们的国旗以及它们的(de)(es)后缀来表示:

添加德语支持

提示

根据您的 Android Studio 设置,您可能看不到国旗图标。只要您能看到三个strings.xml文件,一个没有后缀,一个带有(de),一个带有(es),那么您就可以继续了。

现在我们可以将翻译添加到刚刚创*的文件中。

添加字符串资源

正如我们所知,strings.xml文件包含应用程序将显示的单词,例如 important,to-do 和 idea。通过为每种我们想要支持的语言创*一个strings.xml文件,我们可以让 Android 根据用户的语言设置选择适当的文本。

在接下来的步骤中,请注意,尽管我们将要翻译的单词的翻译放在值中,但name属性保持不变。如果你仔细想想,这是合乎逻辑的,因为我们在布局文件中引用的是name属性。

让我们提供翻译,看看我们取得了什么成就,然后回来讨论我们将如何处理 Kotlin 代码中的文本。

实现此代码的最简单方法是从原始的strings.xml文件中复制并粘贴代码,然后编辑每个name属性的值:

  1. 通过双击打开strings.xml文件。确保选择靠近西班牙国旗或(es)后缀的文件。编辑文件使其如下所示:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Nota a sí mismo</string>
<string name="action_settings">Configuración</string>

<string name="action_add">add</string>
<string name="title_hint">Título</string>
<string name="description_hint">Descripción</string>
<string name="idea_text">Idea</string>
<string name="important_text">Importante</string>
<string name="todo_text">Que hacer</string>
<string name="cancel_button">Cancelar</string>
<string name="ok_button">Vale</string>

<string name="settings_title">Configuración</string>
<string name="title_activity_settings">Configuración</string>

</resources>
  1. 通过双击打开strings.xml文件。确保选择靠近德国国旗或(de)后缀的文件。编辑文件使其看起来像这样:
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
 <string name="app_name">Hinweis auf selbst</string>
 <string name="action_settings">Einstellungen</string>

 <string name="action_add">add</string>
 <string name="title_hint">Titel</string>
 <string name="description_hint">Beschreibung</string>
 <string name="idea_text">Idee</string>
 <string name="important_text">Wichtig</string>
 <string name="todo_text">zu tun</string>
 <string name="cancel_button">Abbrechen</string>
 <string name="ok_button">Okay</string>

 <string name="settings_title">Einstellungen</string>
 <string name="title_activity_settings">Einstellungen</string>
    </resources>

提示

如果你没有在额外的(西班牙语和德语)strings.xml文件中提供所有的字符串资源,那么缺失的资源将从默认文件中获取。

我们所做的是提供了两种翻译。Android 知道哪种翻译是哪种语言,因为它们放置在不同的文件夹中。此外,我们使用了字符串标识符name属性)来引用这些翻译。回顾一下之前的代码,你会发现相同的标识符被用于两种翻译,以及原始的strings.xml文件中。

提示

你甚至可以将本地化到不同版本的语言,比如美国或英国英语。完整的代码列表可以在stackoverflow.com/questions/7973023/what-is-the-list-of-supported-languages-locales-on-android找到。你甚至可以本地化资源,比如图像和声音。在developer.android.com/guide/topics/resources/localization.html了解更多信息。

这些翻译是从谷歌翻译中复制并粘贴而来的,因此很可能有些翻译与正确的相去甚远。像这样廉价地进行翻译可能是将具有基本字符串资源集的应用程序放到使用不同语言的用户的设备上的有效方式。一旦你开始需要任何深度的翻译,也许是为了叙事驱动的游戏或社交媒体应用程序的文本,你肯定会受益于由人类专业人员进行的翻译。

这个练*的目的是展示 Android 的工作原理,而不是如何翻译。

注意

对于可能能够看到这里提供的翻译的局限性的西班牙或德国人,我表示诚挚的歉意。

现在我们有了翻译,我们可以看到它们的作用-到一定程度。

在德语或西班牙语中运行 Note to self

运行应用程序,看看它是否按预期工作。现在,我们可以更改本地化设置,以便在西班牙语中查看。不同的设备在如何做到这一点上略有不同,但 Pixel 2 XL 模拟器可以通过点击Custom Locale应用程序进行更改:

在德语或西班牙语中运行 Note to self

接下来,选择es-ES,然后点击屏幕左下角的SELECT 'ES'按钮,如下一张截图所示:

在德语或西班牙语中运行 Note to self

现在你可以以通常的方式运行应用程序。这里有一张截图显示了应用程序在西班牙语中的运行情况。我用 Photoshop 将一些图像并排放在一起,展示了 Note to self 应用程序的一些不同屏幕:

在德语或西班牙语中运行 Note to self

你可以清楚地看到我们的应用主要是翻译成了西班牙语。显然,用户输入的文本将是他们所说的任何语言-这不是我们应用程序的缺陷。然而,仔细看图片,你会注意到我指出了一些地方,文本仍然是英文的。我们在每个对话窗口中仍然有一些未翻译的文本。

这是因为文本直接包含在我们的 Kotlin 代码中。正如我们所见,使用多种语言的字符串资源并在布局中引用它们是很容易的,但是我们如何从我们的 Kotlin 代码中引用字符串资源呢?

使翻译在 Kotlin 代码中起作用

首先要做的是在三个strings.xml文件中创*资源。这是需要添加到三个不同文件中的两个资源。

strings.xml(没有任何标志或后缀),在<resources></resources>标签中添加这两个资源:

<string name="add_new_note">Add a new note</string>
<string name="your_note">Your note</string>

在带有西班牙国旗和/或(es)后缀的strings.xml文件中,在<resources></resources>标签内添加以下两个资源:

<string name="add_new_note">Agregar una nueva nota</string>
<string name="your_note">Su nota</string>

在带有德国国旗和/或(de)后缀的strings.xml文件中,在<resources></resources>标签内添加以下两个资源:

<string name="add_new_note">Eine neue Note hinzufügen</string>
<string name="your_note">Ihre Notiz</string>

接下来,我们需要编辑一些 Kotlin 代码,以引用资源而不是硬编码的字符串。

打开DialogNewNote.kt文件,找到以下代码行:

builder.setView(dialogView).setMessage("Add a new note")

编辑它,使用我们刚刚添加的字符串资源,而不是硬编码的文本,如下所示:

builder.setView(dialogView).setMessage(
      resources.getString(
         R.string.add_new_note))

新代码使用了链式的setViewsetMessageresources.getString函数来替换先前硬编码的"Add a new note"文本。仔细看,你会发现传递给getString的参数是字符串R.string.add_new_note标识符。

R.string代码指的是res文件夹中的字符串资源,add_new_note是我们的标识符。然后,Android 将能够根据应用程序运行的设备的语言环境决定哪个版本(默认、西班牙语或德语)是合适的。

我们还有一个硬编码的字符串资源要更改。

打开DialogShowNote.kt文件,找到以下代码行:

builder.setView(dialogView).setMessage("Your Note")

编辑它,使用我们刚刚添加的字符串资源,而不是硬编码的文本,如下所示:

builder.setView(dialogView).setMessage(
         resources.getString(R.string.your_note))

新代码再次使用了链式的setViewsetMessageresources.getString函数来替换先前硬编码的"Your note"文本。而且,再次,传递给getString的参数是字符串标识符,在这种情况下是R.string.your_note

现在,Android 可以根据应用程序运行的设备的语言环境决定哪个版本(默认、西班牙语或德语)是合适的。下一个屏幕截图显示,新的笔记屏幕现在以适当的语言显示开头文本:

使 Kotlin 代码中的翻译工作

您可以添加任意多个字符串资源。作为第三章的提醒,探索 Android Studio 和项目结构,请注意,使用字符串资源是向所有项目添加所有文本的推荐方式。本书中的教程(除了 Note to Self 之外)将倾向于硬编码它们,以使教程更紧凑。

总结

现在我们可以全球化我们的应用,以及添加更灵活的字符串资源,而不是硬编码所有文本。

在下一章中,我们将看到如何使用动画和插值器为我们的布局添加酷炫的动画效果。

第十九章:动画和插值

在这里,我们将看到如何使用Animation类使我们的 UI 不那么静态,更有趣。正如我们所期望的那样,Android API 将允许我们用相对简单的代码做一些相当高级的事情,Animation类也不例外。

本章大致可分为以下几个部分:

  • 介绍了 Android 中动画的工作原理和实现方式

  • 介绍了一个我们尚未探索的 UI 小部件SeekBar

  • 一个有效的动画应用程序

首先,让我们探索一下 Android 中的动画是如何工作的。

Android 中的动画

在 Android 中创*动画的常规方式是通过 XML。我们可以编写 XML 动画,然后通过 Kotlin 代码在指定的 UI 小部件上加载和播放它们。因此,例如,我们可以编写一个动画,在三秒内淡入淡出五次,然后在ImageView或任何其他小部件上播放该动画。我们可以将这些 XML 动画看作脚本,因为它们定义了类型、顺序和时间。

让我们探索一些可以分配给我们的动画的不同属性,如何在我们的 Kotlin 代码中使用它们,最后,我们可以制作一个漂亮的动画应用程序来尝试一切。

在 XML 中设计酷炫的动画

我们已经了解到 XML 不仅可以用来描述 UI 布局,还可以用来描述动画,但让我们确切地了解一下。我们可以为动画的属性值指定起始和结束外观的小部件。然后,我们的 Kotlin 代码可以通过引用包含动画的 XML 文件的名称来加载 XML,将其转换为可用的 Kotlin 对象,再次,与 UI 布局类似。

许多动画属性成对出现。以下是一些我们可以使用的动画属性对的快速查看。在查看了一些 XML 后,我们将看到如何使用它。

淡入淡出

Alpha 是透明度的度量。因此,通过说明起始fromAlpha和结束toAlpha值,我们可以淡入淡出物品。值0.0是不可见的,1.0是对象的正常外观。在两者之间稳定移动会产生淡入效果:

<alpha
   android:fromAlpha = "0.0"
   android:toAlpha = "1.0" />

移动它,移动它

我们可以使用类似的技术在 UI 中移动对象;fromXDeltatoXDelta的值可以设置为被动画化对象大小的百分比。

以下代码将使对象从左到右移动,距离等于对象本身的宽度:

<translate     
android:fromXDelta = "-100%"
android:toXDelta = "0%"/>

此外,还有用于上下移动动画的fromYDeltatoYDelta属性。

缩放或拉伸

fromXScaletoXScale属性将增加或减少对象的比例。例如,以下代码将使运行动画的对象从正常大小变为不可见:

<scale
android:fromXScale = "1.0"
android:fromYScale = "0.0"/>

作为另一个例子,我们可以使用android:fromYScale = "0.1"将对象缩小到通常大小的十分之一,或者使用android:fromYScale = "10.0"将其放大十倍。

控制持续时间

当然,如果这些动画只是立即结束,那将不会特别有趣。因此,为了使我们的动画更有趣,我们可以设置它们的持续时间(以毫秒为单位)。毫秒是一秒的千分之一。我们还可以通过设置startOffset属性(也是以毫秒为单位)来使时间更容易,特别是与其他动画相关。

下一个代码将在我们启动动画的 1/3 秒后开始(在代码中),并且需要 2/3 秒才能完成:

android:duration = "666"
android:startOffset = "333"

旋转动画

如果要使某物旋转,只需使用fromDegreestoDegrees属性。下一个代码,可能可以预测,将使小部件在一个完整的圆圈中旋转,因为当然,一个圆圈有 360 度:

<rotate android:fromDegrees = "360"
        android:toDegrees = "0"
/>

重复动画

在一些动画中,重复可能很重要,也许是摇摆或抖动效果,因此我们可以添加一个repeatCount属性。此外,我们可以通过设置repeatMode属性来指定动画的重复方式。

以下代码将重复一个动画 10 次,每次都会反转动画的方向。repeatMode属性是相对于动画的当前状态。这意味着,如果你将一个按钮从 0 度旋转到 360 度,例如,动画的第二部分(第一次重复)将以相反的方式旋转,从 360 度回到 0 度。动画的第三部分(第二次重复)将再次反转,并从 0 度旋转到 360 度:

android:repeatMode = "reverse"
android:repeatCount = "10"

将动画的属性与集合结合

要组合这些效果的组,我们需要一组属性。这段代码展示了我们如何将我们刚刚看到的所有先前的代码片段组合成一个实际的 XML 动画,它将被编译:

<?xml version="1.0" encoding="utf-8"?>
<set 
     ...All our animations go here
</set>

到目前为止我们还没有看到任何 Kotlin 来使这些动画生动起来。让我们现在来解决这个问题。

实例化动画并使用 Kotlin 代码控制它们

下面的代码片段展示了我们如何声明一个Animation类型的对象,用一个名为fade_in.xml的 XML 文件中包含的动画来初始化它,并在一个ImageView小部件上启动动画。我们很快将在一个项目中这样做,并且还会看到我们可以放置 XML 动画的地方:

// Declare an Animation object
var animFadeOut: Animation? = null

// Initialize it 
animFadeIn = AnimationUtils.loadAnimation(
                this, R.anim.fade_in)

// Start the animation on the ImageView
// with an id property set to imageView
imageView.startAnimation(animFadeIn)

我们已经有了相当强大的动画和控制特性,比如时间控制。但是 Android API 还给了我们更多的东西。

更多动画特性

我们可以监听动画的状态,就像我们可以监听按钮的点击一样。我们还可以使用插值器使我们的动画更加生动和愉悦。让我们先看看监听器。

监听器

如果我们实现AnimationListener接口,我们确实可以通过覆盖告诉我们发生了什么的三个函数来监听动画的状态。然后我们可以根据这些事件来采取行动。

OnAnimationEnd宣布动画结束,onAnimationRepeat在每次动画开始重复时调用,而-也许可以预料到-onAnimationStart在动画开始动画时调用。如果在动画 XML 中设置了startOffset,这可能不是调用startAnimation时的同一时间:

override fun onAnimationEnd(animation: Animation) {   
   // Take some action here

}

override fun onAnimationStart(animation: Animation) {

   // Take some action here

}

override fun onAnimationRepeat(animation: Animation){

   // Take some action here

}

我们将在 Animations 演示应用程序中看到AnimationListener的工作原理,并且我们还将把另一个小部件SeekBar投入使用。

动画插值器

如果你能回想起高中时的情景,你可能会记得关于计算加速度的激动人心的课程。如果我们以恒定的速度对某物进行动画处理,乍一看,事情可能看起来还不错。如果我们将动画与另一个使用渐进加速的动画进行比较,那么后者几乎肯定会更令人愉悦地观看。

有可能,如果我们没有被告知两个动画之间唯一的区别是一个使用了加速度,另一个没有,我们可能无法说出为什么我们更喜欢它。我们的大脑更容易接受符合我们周围世界规范的事物。因此,添加一点真实世界的物理,比如加速和减速,可以改善我们的动画。

然而,我们最不想做的事情是开始做一堆数学计算,只是为了将一个按钮滑动到屏幕上或者让一些文本在圆圈中旋转。

这就是插值器的用武之地。它们是我们可以在我们的 XML 中用一行代码设置的动画修改器。

一些插值器的例子是accelerate_interpolatorcycle_interpolator

android:interpolator="@android:anim/accelerate_interpolator"
android:interpolator="@android:anim/cycle_interpolator"/>

接下来我们将投入使用一些插值器,以及一些 XML 动画和相关的 Kotlin 代码。

提示

您可以在 Android 开发者网站上了解有关插值器和 Android Animation类的更多信息:developer.android.com/guide/topics/resources/animation-resource.html

动画演示应用程序-介绍 SeekBar

这就足够的理论了,尤其是对于应该如此明显的东西。让我们构*一个动画演示应用程序,探索我们刚刚讨论过的一切,以及更多内容。

这个应用程序涉及许多不同文件中少量的代码。因此,我已经尽量清楚地说明了哪些代码在哪个文件中,这样您就可以跟踪发生了什么。这也将使我们为这个应用程序编写的 Kotlin 更容易理解。

该应用程序将演示旋转、淡入淡出、*移、动画事件、插值和使用SeekBar小部件控制持续时间的功能。解释SeekBar的最佳方法是构*它,然后观察它的运行情况。

布局动画演示

使用空活动模板创*一个名为Animation Demo的新项目,将所有其他设置保持为通常的设置。如果您希望通过复制和粘贴布局、代码或动画 XML 来加快速度,可以在Chapter19文件夹中找到所有内容。

使用完成布局的参考截图来帮助您完成接下来的步骤:

布局动画演示

以下是您可以为此应用程序布局 UI 的方法:

  1. 在编辑器窗口的设计视图中打开activity_main.xml

  2. 删除默认的Hello world! TextView

  3. 在布局的顶部中心部分添加一个ImageView。使用之前的参考截图来指导您。在弹出的资源窗口中选择项目 | ic_launcher,使用@mipmap/ic_launcher来在ImageView中显示 Android 机器人。

  4. ImageViewid属性设置为imageView

  5. ImageView的正下方,添加一个TextView。将id设置为textStatus。我通过拖动其边缘(而不是约束手柄)使我的TextView变大,并将其textSize属性更改为40sp。到目前为止,布局应该看起来像下一个截图:布局动画演示

  6. 现在我们将在布局中添加大量的Button小部件。确切的定位并不重要,但稍后在教程中为它们添加的确切id属性值是重要的。按照下一个截图的指示,在布局中放置 12 个按钮。修改text属性,使您的按钮与下一个截图中的按钮具有相同的文本。如果截图不够清晰,text属性将在下一步中具体详细说明:布局动画演示

提示

为了加快布局按钮的过程,首先大致布局它们,然后从下一步中添加text属性,最后微调按钮位置以获得整洁的布局。

  1. 按照截图中的方式添加text值;从左到右,从上到下,这里是所有的值:淡入淡出淡入淡出放大缩小左右右左上下弹跳闪烁向左旋转向右旋转

  2. 从左侧的调色板中的小部件类别中添加一个SeekBar小部件,将id属性设置为seekBarSpeed,将max属性设置为5000。这意味着SeekBar小部件将在用户从左向右拖动时保持一个值在 0 到 5000 之间。我们将看到如何读取和使用这些数据。

  3. 我们想要让SeekBar小部件变得更宽。为了实现这一点,您可以使用与任何小部件相同的技术;只需拖动小部件的边缘。然而,由于SeekBar小部件相当小,很难增加其大小而不小心选择约束手柄。为了克服这个问题,通过按住Ctrl键并向前滚动鼠标滚轮来放大设计视图。然后,您可以抓住SeekBar小部件的边缘,而不触摸约束手柄。我在下一个截图中展示了这一点:布局动画演示

  4. 现在,在SeekBar小部件的右侧添加一个TextView小部件,并将其id属性设置为textSeekerSpeed。这一步,结合前两步,应该看起来像这张截图:布局动画演示

  5. 微调位置,使其看起来像这些步骤开始时的参考截图,然后单击推断约束按钮以锁定位置。当然,如果你想练*,你也可以手动完成。这是一个包含所有约束的截图:布局动画演示

  6. 接下来,根据你已经设置的文本属性,为按钮添加以下id属性。如果在输入这些值时询问是否要更新用法…,请选择

现有文本属性 要设置的 id 属性的值
淡入 btnFadeIn
淡出 btnFadeOut
淡入淡出 btnFadeInOut
放大 btnZoomIn
缩小 btnZoomOut
左右 btnLeftRight
右左 btnRightLeft
上下 btnTopBottom
弹跳 btnBounce
闪烁 btnFlash
旋转左侧 btnRotateLeft
旋转右侧 btnRotateRight

当我们在几节时间内编写MainActivity类时,我们将看到如何使用这个新来的 UI(SeekBar)。

编写 XML 动画

右键单击res文件夹,然后选择新* | Android 资源目录。在目录名称:字段中输入anim,然后左键单击确定

现在右键单击新的anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入fade_in,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set 
   android:fillAfter="true" >

   <alpha
   android:fromAlpha = "0.0"
   android:interpolator = 
              "@android:anim/accelerate_interpolator"

   android:toAlpha="1.0" />
</set>

右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入fade_out,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set 
   android:fillAfter = "true" >

   <alpha  
         android:fromAlpha = "1.0"
         android:interpolator = 
              "@android:anim/accelerate_interpolator"

   android:toAlpha = "0.0" />
</set>

右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入fade_in_out,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set 
    android:fillAfter = "true" >

    <alpha
          android:fromAlpha="0.0"
          android:interpolator = 
          "@android:anim/accelerate_interpolator"

          android:toAlpha = "1.0" />

    <alpha
       android:fromAlpha = "1.0"
          android:interpolator = 
         "@android:anim/accelerate_interpolator"

         android:toAlpha = "0.0" />
</set>

右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入zoom_in,然后左键单击确定。删除内容并添加以下代码来创*动画:

<set 
    android:fillAfter = "true" >

    <scale
        android:fromXScale = "1"
        android:fromYScale = "1"
        android:pivotX = "50%"
        android:pivotY = "50%"
        android:toXScale = "6"
        android:toYScale = "6" >
    </scale>
</set>

右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入zoom_out,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <scale
        android:fromXScale = "6"
        android:fromYScale = "6"
        android:pivotX = "50%"
        android:pivotY = "50%"
        android:toXScale = "1"
        android:toYScale = "1" >
    </scale>
</set>

右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入left_right,然后左键单击确定。删除内容并添加以下代码来创*动画:

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

        android:fromXDelta = "-500%"
        android:toXDelta = "0%"/>
</set>

再次右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入right_left,然后左键单击确定。删除整个内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <translate 
        android:fillAfter = "false"
        android:fromXDelta = "500%"
        android:toXDelta = "0%"/>
</set>

与以前一样,右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入top_bot,然后左键单击确定。删除整个内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <translate 
        android:fillAfter = "false"
        android:fromYDelta = "-100%"
        android:toYDelta = "0%"/>
</set>

你猜对了;右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入flash,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <alpha android:fromAlpha = "0.0"
        android:toAlpha = "1.0"
        android:interpolator = 
           "@android:anim/accelerate_interpolator"

        android:repeatMode = "reverse"
        android:repeatCount = "10"/>
</set>

还有一些要做 - 右键单击anim目录,然后选择新* | 动画资源文件。在文件名:字段中,键入bounce,然后左键单击确定。删除内容并添加以下代码来创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set 
    android:fillAfter = "true"
    android:interpolator = 
         "@android:anim/bounce_interpolator">

    <scale
        android:fromXScale = "1.0"
        android:fromYScale = "0.0"
        android:toXScale = "1.0"
        android:toYScale = "1.0" />

</set>

右键单击anim目录,然后选择New | Animation resource file。在File name:字段中,键入rotate_left,然后左键单击OK。删除内容并添加此代码以创*动画。在这里,我们看到了一些新东西,pivotX="50%"pivotY="50%"。这使得旋转动画在将要被动画化的小部件上是中心的。我们可以将其视为设置动画的中心点:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <rotate android:fromDegrees = "360"
        android:toDegrees = "0"
        android:pivotX = "50%"
        android:pivotY = "50%"
        android:interpolator = 
           "@android:anim/cycle_interpolator"/>
</set>

右键单击anim目录,然后选择New | Animation resource file。在File name:字段中,键入rotate_right,然后左键单击OK。删除内容并添加此代码以创*动画:

<?xml version="1.0" encoding="utf-8"?>
<set >
    <rotate android:fromDegrees = "0"
        android:toDegrees = "360"
        android:pivotX = "50%"
        android:pivotY = "50%"
        android:interpolator =
             "@android:anim/cycle_interpolator"/>

</set>

呼!现在我们可以编写 Kotlin 代码将我们的动画添加到我们的 UI 中。

在 Kotlin 中连接动画演示应用程序

打开MainActivity.kt文件。现在,在类声明之后,我们可以声明以下动画属性:

var seekSpeedProgress: Int = 0

private lateinit var animFadeIn: Animation
private lateinit var animFadeOut: Animation
private lateinit var animFadeInOut: Animation

private lateinit var animZoomIn: Animation
private lateinit var animZoomOut: Animation

private lateinit var animLeftRight: Animation
private lateinit var animRightLeft: Animation
private lateinit var animTopBottom: Animation

private lateinit var animBounce: Animation
private lateinit var animFlash: Animation 

private lateinit var animRotateLeft: Animation
private lateinit var animRotateRight: Animation

提示

此时,您需要添加以下import语句:

import android.view.animation.Animation;

在上述代码中,我们在声明Animation实例时使用了lateinit关键字。这意味着 Kotlin 将在使用每个实例之前检查它是否已初始化。这避免了我们在每次在这些实例中使用函数时使用!!(空检查)。有关!!运算符的复*,请参阅第十二章将我们的 Kotlin 连接到 UI 和空值

我们还添加了一个Int属性seekSpeedProgress,它将用于跟踪SeekBar的当前值/位置。

现在,在setContentView调用之后,让我们从onCreate中调用一个新函数:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

 loadAnimations()
}

在这一点上,新的代码行在实现新函数之前将出现错误。

现在我们将实现loadAnimations函数。虽然这个函数中的代码相当庞大,但也非常直接。我们所做的就是使用AnimationUtils类的loadAnimation函数,用我们的 XML 动画初始化每个Animation引用之一。您还会注意到,对于animFadeIn Animation,我们还在其上调用setAnimationListener。我们将很快编写监听事件的函数。

添加loadAnimations函数:

private fun loadAnimations() {

   animFadeIn = AnimationUtils.loadAnimation(
                this, R.anim.fade_in)
   animFadeIn.setAnimationListener(this)
   animFadeOut = AnimationUtils.loadAnimation(
                this, R.anim.fade_out)
   animFadeInOut = AnimationUtils.loadAnimation(
                this, R.anim.fade_in_out)

   animZoomIn = AnimationUtils.loadAnimation(
                this, R.anim.zoom_in)
   animZoomOut = AnimationUtils.loadAnimation(
                this, R.anim.zoom_out)

   animLeftRight = AnimationUtils.loadAnimation(
                 this, R.anim.left_right)
   animRightLeft = AnimationUtils.loadAnimation(
                 this, R.anim.right_left)
   animTopBottom = AnimationUtils.loadAnimation(
                 this, R.anim.top_bot)

   animBounce = AnimationUtils.loadAnimation(
                 this, R.anim.bounce)
   animFlash = AnimationUtils.loadAnimation(
                 this, R.anim.flash)

   animRotateLeft = AnimationUtils.loadAnimation(
                 this, R.anim.rotate_left)
   animRotateRight = AnimationUtils.loadAnimation(
                 this, R.anim.rotate_right)
}

提示

此时,您需要导入一个新的类:

import android.view.animation.AnimationUtils

现在,我们将为每个按钮添加一个点击监听器。在onCreate函数的右大括号之前立即添加以下代码:

btnFadeIn.setOnClickListener(this)
btnFadeOut.setOnClickListener(this)
btnFadeInOut.setOnClickListener(this)
btnZoomIn.setOnClickListener(this)
btnZoomOut.setOnClickListener(this)
btnLeftRight.setOnClickListener(this)
btnRightLeft.setOnClickListener(this)
btnTopBottom.setOnClickListener(this)
btnBounce.setOnClickListener(this)
btnFlash.setOnClickListener(this)
btnRotateLeft.setOnClickListener(this)
btnRotateRight.setOnClickListener(this)

注意

我们刚刚添加的代码在所有代码行中都创*了错误。我们现在可以忽略它们,因为我们很快就会修复它们并讨论发生了什么。

现在,我们可以使用 lambda 来处理SeekBar的交互。我们将重写三个函数,因为在实现OnSeekBarChangeListener时接口要求这样做:

  • 一个检测SeekBar小部件位置变化的函数,称为onProgressChanged

  • 一个检测用户开始改变位置的函数,称为onStartTrackingTouch

  • 一个检测用户完成使用SeekBar小部件的函数,称为onStopTrackingTouch

为了实现我们的目标,我们只需要向onProgressChanged函数添加代码,但我们仍然必须重写它们全部。

onProgressChanged函数中,我们所做的就是将SeekBar对象的当前值分配给seekSpeedProgress成员变量,以便可以从其他地方访问。然后,我们使用这个值以及SeekBar对象的最大可能值,通过使用seekBarSpeed.max,并向textSeekerSpeed TextView输出一条消息。

onCreate函数的右大括号之前添加我们刚刚讨论过的代码:

seekBarSpeed.setOnSeekBarChangeListener(
         object : SeekBar.OnSeekBarChangeListener {

   override fun onProgressChanged(
                seekBar: SeekBar, value: Int, 
                fromUser: Boolean) {

         seekSpeedProgress = value
         textSeekerSpeed.text =
               "$seekSpeedProgress of $seekBarSpeed.max"
  }

  override fun onStartTrackingTouch(seekBar: SeekBar) {}

  override fun onStopTrackingTouch(seekBar: SeekBar) {}
})

现在,我们需要修改MainActivity类声明以实现两个接口。在这个应用程序中,我们将监听点击和动画事件,所以我们将使用的两个接口是View.OnClickListenerAnimation.AnimationListener。您会注意到,要实现多个接口,我们只需用逗号分隔接口。

通过添加我们刚讨论过的突出显示的代码来修改MainActivity类声明:

class MainActivity : AppCompatActivity(),
        View.OnClickListener,
 Animation.AnimationListener {

在这个阶段,我们可以添加并实现这些接口所需的函数。首先是AnimationListener函数,onAnimationEndonAnimationRepeatonAnimationStart。我们只需要在这些函数中的两个中添加一点代码。在onAnimationEnd中,我们将textStatustext属性设置为STOPPED,在onAnimationStart中,我们将其设置为RUNNING。这将演示我们的动画监听器确实在监听和工作:

override fun onAnimationEnd(animation: Animation) {
   textStatus.text = "STOPPED"
}

override fun onAnimationRepeat(animation: Animation) {
}

override fun onAnimationStart(animation: Animation) {
   textStatus.text = "RUNNING"
}

onClick函数非常长,但并不复杂。when块的每个选项处理 UI 中的每个按钮,根据SeekBar小部件的当前位置设置动画的持续时间,设置动画以便监听事件,然后启动动画。

提示

您需要使用您喜欢的技术来导入View类:

import android.view.View;

添加我们刚讨论过的onClick函数,然后我们就完成了这个迷你应用程序:

override fun onClick(v: View) {
when (v.id) {
  R.id.btnFadeIn -> {
        animFadeIn.duration = seekSpeedProgress.toLong()
        animFadeIn.setAnimationListener(this)
        imageView.startAnimation(animFadeIn)
  }

  R.id.btnFadeOut -> {
        animFadeOut.duration = seekSpeedProgress.toLong()
        animFadeOut.setAnimationListener(this)
        imageView.startAnimation(animFadeOut)
  }

  R.id.btnFadeInOut -> {

        animFadeInOut.duration = seekSpeedProgress.toLong()
        animFadeInOut.setAnimationListener(this)
        imageView.startAnimation(animFadeInOut)
  }

  R.id.btnZoomIn -> {
        animZoomIn.duration = seekSpeedProgress.toLong()
        animZoomIn.setAnimationListener(this)
        imageView.startAnimation(animZoomIn)
  }

  R.id.btnZoomOut -> {
        animZoomOut.duration = seekSpeedProgress.toLong()
        animZoomOut.setAnimationListener(this)
        imageView.startAnimation(animZoomOut)
  }

  R.id.btnLeftRight -> {
        animLeftRight.duration = seekSpeedProgress.toLong()
        animLeftRight.setAnimationListener(this)
        imageView.startAnimation(animLeftRight)
  }

  R.id.btnRightLeft -> {
        animRightLeft.duration = seekSpeedProgress.toLong()
        animRightLeft.setAnimationListener(this)
        imageView.startAnimation(animRightLeft)
  }

  R.id.btnTopBottom -> {
        animTopBottom.duration = seekSpeedProgress.toLong()
        animTopBottom.setAnimationListener(this)
        imageView.startAnimation(animTopBottom)
  }

  R.id.btnBounce -> {
        /*
        Divide seekSpeedProgress by 10 because with
        the seekbar having a max value of 5000 it
        will make the animations range between
        almost instant and half a second
        5000 / 10 = 500 milliseconds
        */
        animBounce.duration = 
              (seekSpeedProgress / 10).toLong()
        animBounce.setAnimationListener(this)
        imageView.startAnimation(animBounce)
  }

  R.id.btnFlash -> {
        animFlash.duration = (seekSpeedProgress / 10).toLong()
        animFlash.setAnimationListener(this)
        imageView.startAnimation(animFlash)
  }

  R.id.btnRotateLeft -> {
        animRotateLeft.duration = seekSpeedProgress.toLong()
        animRotateLeft.setAnimationListener(this)
        imageView.startAnimation(animRotateLeft)
  }

  R.id.btnRotateRight -> {
        animRotateRight.duration = seekSpeedProgress.toLong()
        animRotateRight.setAnimationListener(this)
        imageView.startAnimation(animRotateRight)
  }
}

}

现在运行应用程序,并将SeekBar小部件移动到大致中心,以便动画运行一段合理的时间:

在 Kotlin 中连接动画演示应用程序

点击放大按钮:

在 Kotlin 中连接动画演示应用程序

注意 Android 机器人上的文本在适当的时间从RUNNING更改为STOPPED。现在,点击其中一个ROTATE按钮:

在 Kotlin 中连接动画演示应用程序

大多数其他动画在截图中无法展现出自己的价值,所以一定要自己尝试它们。

经常问的问题

Q.1) 我们现在知道如何为小部件添加动画,但是我自己创*的形状或图像呢?

A) 一个ImageView小部件可以容纳任何您喜欢的图像。只需将图像添加到drawable文件夹,然后在ImageView小部件上设置适当的src属性。然后您可以对ImageView小部件中显示的任何图像进行动画处理。

Q.2) 但是如果我想要比这更灵活的功能,更像是一个绘画应用程序甚至是一个游戏呢?

A) 要实现这种功能,我们需要学*另一个称为线程的通用计算概念,以及一些更多的 Android 类(如PaintCanvasSurfaceView)。我们将学*如何从单个像素到形状绘制任何东西,然后将它们移动到屏幕上,从下一章开始,第二十章 绘制图形

总结

现在我们有另一个增强应用程序的技巧。在本章中,我们看到 Android 中的动画非常简单。我们在 XML 中设计了一个动画,并将文件添加到anim文件夹中。接下来,我们在 Kotlin 代码中使用Animation对象获取了 XML 中动画的引用。

然后,我们在 UI 中使用小部件的引用,使用setAnimation为其设置动画,并传入Animation对象。通过在小部件的引用上调用startAnimation来启动动画。

我们还看到我们可以控制动画的时间并监听动画事件。

在下一章中,我们将学*在 Android 中绘制图形。这将是关于图形的几章中的开始,我们将构*一个儿童风格的绘画应用程序。

第二十章:绘图图形

整个章节将讨论 Android 的Canvas类以及一些相关类,如PaintColorBitmap。当这些类结合在一起时,在屏幕上绘图时会带来巨大的力量。有时,Android API 提供的默认 UI 并不是我们所需要的。如果我们想要制作一个绘图应用程序,绘制图表,或者制作游戏,我们需要控制 Android 设备提供的每个像素。

在本章中,我们将涵盖以下主题:

  • 了解Canvas类及一些相关类

  • 编写一个基于Canvas的演示应用程序

  • 查看 Android 坐标系统,以便知道在哪里进行绘制

  • 学*绘制和操作位图图形

  • 编写一个基于位图图形的演示应用程序

所以,让我们开始绘图吧!

了解 Canvas 类

Canvas类是android.graphics包的一部分。在接下来的两章中,我们将使用android.graphics包中的所有以下import语句以及来自现在熟悉的View包的另一个import语句。它们为我们提供了从 Android API 中获取一些强大绘图功能的途径:

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.widget.ImageView

首先,让我们讨论前面代码中突出显示的BitmapCanvasImageView

使用 Bitmap,Canvas 和 ImageView 开始绘制

由于 Android 设计用于运行各种类型的移动应用程序,我们不能立即开始输入我们的绘图代码并期望它能够工作。我们需要做一些准备(也就是更多的编码)来考虑我们的应用程序运行在特定设备上。这种准备有时可能有点反直觉,但我们将一步一步地进行。

Canvas 和 Bitmap

根据您如何使用Canvas类,这个术语可能会有点误导。虽然Canvas类确实是您绘制图形的类,就像绘画画布一样,但您仍然需要一个表面来转置画布。

在这种情况下(以及我们的前两个演示应用程序中),表面将来自Bitmap类。

提示

请注意,位图是一种图像类型,Android 有一个Bitmap类。Bitmap类可用于将位图图像绘制到屏幕上,但正如我们将看到的那样,它还有其他用途。在谈论位图图像和Bitmap类时,我会尽量清晰明了,以便区分得更清楚。

我们可以将这个过程看作是:我们得到一个Canvas对象和一个Bitmap对象,然后将Bitmap对象设置为Canvas对象的一部分来进行绘制。

如果按照字面意义理解"画布"这个词有点反直觉,但一旦设置好了,我们就可以忘记它,专注于我们想要绘制的图形。

提示

Canvas类提供了绘制的能力。它具有绘制形状、文本、线条和图像文件(如其他位图)的所有功能,甚至支持绘制单个像素。

Bitmap类由Canvas类使用,是被绘制的表面。您可以将Bitmap实例视为位于Canvas实例上的图片框。

Paint

除了CanvasBitmap,我们还将使用Paint类。这更容易理解;Paint是用于配置特定属性的类,例如我们将在Canvas实例中绘制的颜色(在Canvas实例内的Bitmap实例上)。

然而,在我们开始绘制之前,还有一个谜题需要解决。

ImageView 和 Activity

ImageView类是Activity类用于向用户显示输出的类。引入这第三层抽象的原因是,正如我们在整本书中所看到的,Activity类需要将一个View引用传递给setContentView函数,以向用户显示内容。在整本书中,这一直是我们在可视化设计器或 XML 代码中创*的布局。

然而,这一次我们不需要用户界面 - 相反,我们需要绘制线条、像素、图像和形状。

有多个从 View 继承的类,可以制作所有不同类型的应用程序,并且它们都与 Activity 类兼容,这是所有常规 Android 应用程序(包括绘图应用程序和游戏)的基础。

因此,有必要将在 Canvas 上绘制的 Bitmap 类与 ImageView 类关联起来,一旦绘制完成。最后一步是通过将其传递给 setContentView 来告诉 Activity 类,我们的 ImageView 代表用户要看到的内容。

Canvas、Bitmap、Paint 和 ImageView - 简要总结

如果我们需要设置的代码结构理论看起来并不简单,那么当你看到稍后的相对简单的代码时,你会松一口气。

到目前为止,我们已经覆盖了以下内容:

  • 每个应用程序都需要一个 Activity 类来与用户和底层操作系统交互。因此,如果我们想成功,我们必须遵循所需的层次结构。

  • 我们将使用继承自 View 类的 ImageView 类。View 类是 Activity 需要显示我们的应用程序给用户的东西。

  • Canvas 类提供了绘制线条、像素和其他图形的 能力。它具有执行诸如绘制形状、文本、线条和图像文件等操作的所有功能,甚至支持绘制单个像素。

  • Bitmap 类将与 Canvas 类关联,它是被绘制的表面。

  • Canvas 类使用 Paint 类来配置细节,比如绘制的颜色。

  • 最后,一旦 Bitmap 实例被绘制,我们必须将其与 ImageView 类关联起来,而 ImageView 类又被设置为 Activity 实例的视图。

结果将是我们在 Canvas 实例中绘制的 Bitmap 实例将通过调用 setContentView 显示给用户的 ImageView 实例。呼~

提示

如果这并不是 100%清楚也没关系。不是你看不清楚 - 它只是没有清晰的关系。编写代码并反复使用这些技术将使事情变得更清晰。看看代码,执行本章和下一章的演示应用程序,然后重新阅读本节。

让我们看看如何在代码中*立这种关系 - 不要担心输入代码;我们先来学*它。

使用 Canvas 类

让我们看看代码和获取绘图所需的不同阶段,然后我们可以快速转移到使用 Canvas 演示应用程序真正绘制一些东西。

准备所需类的实例

第一步是将我们需要的类转换为可用的实例。

首先,我们声明我们需要的所有实例。我们不能立即初始化这些实例,但我们可以确保在使用它们之前初始化它们,所以我们在同样的方式中使用 lateinit,就像在动画演示应用程序中一样:

// Here are all the objects(instances)
// of classes that we need to do some drawing
lateinit var myImageView: ImageView
lateinit var myBlankBitmap: Bitmap
lateinit var myCanvas: Canvas
lateinit var myPaint: Paint

上一个代码声明了 ImageViewBitmapCanvasPaint 类型的引用。它们分别被命名为 myImageViewmyBlankBitmapmyCanvasmyPaint

初始化对象

接下来,我们需要在使用它们之前初始化我们的新对象:

// Initialize all the objects ready for drawing
// We will do this inside the onCreate function
val widthInPixels = 800
val heightInPixels = 600

// Create a new Bitmap
myBlankBitmap = Bitmap.createBitmap(widthInPixels,
         heightInPixels,
         Bitmap.Config.ARGB_8888)

// Initialize the Canvas and associate it
// with the Bitmap to draw on
myCanvas = Canvas(myBlankBitmap)

// Initialize the ImageView and the Paint
myImageView = ImageView(this)
myPaint = Paint()
// Do drawing here

请注意上一个代码中的以下注释:

// Do drawing here

这是我们将配置颜色并绘制图形的地方。另外,请注意在代码顶部我们声明并初始化了两个 Int 变量,称为 widthInPixelsheightInPixels。当我们编写 Canvas 演示应用程序时,我将更详细地介绍其中一些代码行。

现在我们已经准备好绘制;我们所需要做的就是通过 setContentView 函数将 ImageView 实例分配给 Activity

设置 Activity 内容

最后,在我们看到我们的绘图之前,我们告诉 Android 使用我们的名为myImageViewImageView实例作为要显示给用户的内容:

// Associate the drawn upon Bitmap with the ImageView
myImageView.setImageBitmap(myBlankBitmap);
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView);

正如您在迄今为止的每个应用程序中已经看到的,setContentView函数是Activity类的一部分,这一次我们将myImageView作为参数传递,而不是像我们在整本书中一直做的那样传递 XML 布局。就是这样 - 现在我们要学*的就是如何在Bitmap实例上实际绘制。

在进行一些绘图之前,启动一个真正的项目将非常有用。我们将逐步复制并粘贴我们刚刚讨论过的代码到正确的位置,然后实际上在屏幕上看到一些绘制的东西。

所以,让我们开始绘图吧。

Canvas Demo 应用程序

首先,创*一个新项目来探索使用Canvas进行绘图的主题。我们将重复利用我们所学到的知识,这一次我们还将绘制到Bitmap实例上。

创*一个新项目

创*一个新项目,将其命名为Canvas Demo,并确保选择空活动模板选项。

在这个应用程序中,我们将进行一个以前未见过的更改。我们将使用Activity类的原始版本。因此,MainActivity将继承自Activity,而不是之前一直使用的AppCompatActivity。我们这样做是因为我们不使用来自 XML 文件的布局,因此我们不需要AppCompatActivity的向后兼容功能,就像在以前的所有项目中一样。

您应该编辑类声明如下。

class MainActivity : Activity() {

您还需要添加以下导入语句:

import android.app.Activity

注意

此应用程序的完整代码可以在Chapter20/Canvas Demo文件夹的下载包中找到。

编写 Canvas 演示应用程序

接下来,删除onCreate函数的所有内容,除了声明/签名、调用 super.onCreate 以及打开和关闭大括号。

现在,我们可以在类声明之后但在onCreate函数之前添加以下突出显示的代码。在此步骤之后,代码将如下所示:

// Here are all the objects(instances)
// of classes that we need to do some drawing
lateinit var myImageView: ImageView
lateinit var myBlankBitmap: Bitmap
lateinit var myCanvas: Canvas
lateinit var myPaint: Paint

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
}

在 Android Studio 中,四个新类都被下划线标记为红色。这是因为我们需要添加适当的import语句。您可以从本章的第一页复制它们,但更快的方法是依次将鼠标光标放在每个错误上,然后按住ALT键并轻按Enter键。如果从弹出选项中提示,请选择导入类

完成对ImageViewBitmapCanvasPaint的操作后,所有错误都将消失,并且相关的import语句将被添加到代码的顶部。

现在我们已经声明了所需类的实例,我们可以对它们进行初始化。将以下代码添加到onCreate函数中,添加到“super.onCreate…”之后,如下所示:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   // Initialize all the objects ready for drawing
   // We will do this inside the onCreate function
   val widthInPixels = 800
   val heightInPixels = 600

   // Create a new Bitmap
   myBlankBitmap = Bitmap.createBitmap(widthInPixels,
                heightInPixels,
                Bitmap.Config.ARGB_8888)

   // Initialize the Canvas and associate it
   // with the Bitmap to draw on
   myCanvas = Canvas(myBlankBitmap)

   // Initialize the ImageView and the Paint
   myImageView = ImageView(this)
   myPaint = Paint()
}

前面的代码与我们在理论上讨论Canvas时看到的代码相同。但是,值得更深入地探索Bitmap类的初始化,因为它并不简单。

探索位图初始化

位图在基于图形的应用程序和游戏中更常见,用于表示不同的画笔、玩家角色、背景、游戏对象等对象。在这里,我们只是用它来绘制。在下一个项目中,我们将使用位图来表示我们绘制的主题,而不仅仅是绘制的表面。

需要解释的函数是createBitmap函数。从左到右的参数如下:

  • 宽度(以像素为单位)

  • 高度(以像素为单位)

  • 位图配置

Bitmap实例可以以几种不同的方式进行配置;ARGB_8888配置意味着每个像素由四个字节的内存表示。

注意

Android 可以使用多种位图格式。这种格式非常适合绘制一系列颜色,并确保我们使用的位图和请求的颜色将按预期绘制。还有更高和更低的配置,但ARGB_8888非常适合本书。

现在,我们可以进行实际绘制。

在屏幕上绘制

myPaint初始化之后和onCreate函数的闭合大括号内添加以下突出显示的代码:

// Draw on the Bitmap
// Wipe the Bitmap with a blue color
myCanvas.drawColor(Color.argb(255, 0, 0, 255))

// Re-size the text
myPaint.textSize = 100f
// Change the paint to white
myPaint.color = Color.argb(255, 255, 255, 255)
// Draw some text
myCanvas.drawText("Hello World!",100f, 100f, myPaint)

// Change the paint to yellow
myPaint.color = Color.argb(255, 212, 207, 62)
// Draw a circle
myCanvas.drawCircle(400f, 250f, 100f, myPaint)

前面的代码使用:

  • myCanvas.drawColor用颜色填充屏幕

  • myPaint.textSize属性定义了接下来将绘制的文本的大小

  • myPaint.color属性决定了未来任何绘图的颜色

  • myCanvas.drawText函数实际上将文本绘制到屏幕上。

如果我们分析传递给drawText的参数,我们可以看到文本将会显示"Hello World!",并且将在我们的Bitmap实例(myBitmap)的左侧 100 像素和顶部 100 像素处绘制。

接下来,我们再次使用color属性来更改将用于绘制的颜色。最后,我们使用drawCircle函数来绘制一个距左侧 400 像素,顶部 100 像素的圆。圆的半径为 100 像素。

直到现在,我一直没有解释Color.argb函数。

解释 Color.argb

Color类,不出所料,帮助我们操纵和表示颜色。argb函数返回使用alpha(不透明度和透明度)、red、green、blue 模型构*的颜色。该模型对于每个元素使用从 0(无颜色)到 255(全颜色)的值。重要的是要注意 - 尽管这似乎是显而易见的 - 混合颜色是不同颜色光的强度,结果与我们混合颜料时发生的情况完全不同。

提示

要设计 ARGB 值并进一步探索这个模型,请查看这个方便的网站:www.rapidtables.com/web/color/RGB_Color.html。该网站可以帮助您选择 RGB 值;然后您可以尝试 alpha 值。

用于清除绘图表面的值是25500255。这些值表示完全不透明(即纯色),没有红色,没有绿色,完全蓝色。这会产生蓝色。

argb函数的下一个调用是在setColor的第一个调用中,我们正在为文本设置所需的颜色。255255255255的值表示完全不透明,完全红色,完全绿色和完全蓝色。当您将光与这些值结合时,您将得到白色。

argb的最终调用是在setColor的最终调用中,我们正在设置绘制圆的颜色;2552120762产生太阳黄色。

在运行代码之前,我们需要执行的最后一步是添加对setContentView函数的调用,将我们的ImageView实例(myImageView)放置为此应用程序的内容视图。以下是我们已经添加的代码之后,但在onCreate的闭合大括号之前的最后几行代码:

// Associate the drawn upon Bitmap with the ImageView
myImageView.setImageBitmap(myBlankBitmap);
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView);

最后,我们通过调用setContentView告诉Activity类使用myImageView

下面的屏幕截图展示了当您运行 Canvas 演示应用程序时的外观。我们可以看到一个 800x800 像素的绘图。在下一章中,我们将使用更高级的技术来利用整个屏幕,并且我们还将学*有关线程,以使图形实时移动:

解释 Color.argb

如果您了解 Android 坐标系统的更多信息,将有助于您理解我们在Canvas绘图函数中使用的坐标的结果。

Android 坐标系统

正如您将看到的,绘制位图图形是微不足道的。但是,我们用来绘制图形的坐标系统需要简要解释。

绘图和绘制

当我们在屏幕上绘制位图图形时,我们传入要绘制对象的坐标。给定 Android 设备的可用坐标取决于其屏幕的分辨率。

例如,Google Pixel 手机在横向方向上的屏幕分辨率为 1,920 像素(横向)x 1,080 像素(纵向)。

这些坐标的编号系统从左上角的 0,0 开始,向下和向右移动,直到右下角是像素 1919, 1079。1,920/1,919 和 1,080/1,079 之间明显的 1 像素差异是因为编号从 0 开始。

因此,当我们在屏幕上绘制位图图形或其他任何东西(如Canvas圆和矩形)时,我们必须指定xy坐标。

此外,位图图形(或Canvas形状)当然包括许多像素。因此,我们将要指定的xy屏幕坐标上绘制给定位图图形的哪个像素?

答案是位图图形的左上角像素。看一下下一个图表,它应该使用 Google Pixel 手机作为示例来澄清屏幕坐标。作为解释 Android 坐标绘制系统的图形手段,我将使用一个可爱的太空飞船图形:

绘图和绘制

此外,这些坐标是相对于您绘制的内容。因此,在我们刚刚编写的Canvas演示和下一个演示中,坐标是相对于Bitmap对象(myBitmap)的。在下一章中,我们将使用整个屏幕,上一个图表将更准确地表示发生的情况。

让我们做一些更多的绘图 - 这次使用位图图形(再次使用Bitmap类)。我们将使用与此应用程序中看到的相同的起始代码。

使用 Bitmap 类创*位图图形

在我们深入代码之前,让我们先研究一些理论,并考虑我们将如何将图像绘制到屏幕上。要绘制位图图形,我们将使用Canvas类的drawBitmap函数。

首先,我们需要在res/drawable文件夹中的项目中添加一个位图图形 - 我们将在 Bitmap 演示应用程序中进行这个操作。现在,假设图形文件/位图的名称为myImage.png

接下来,我们将以与我们在上一个演示中用于背景的Bitmap对象相同的方式声明Bitmap类型的对象。

接下来,我们需要使用我们之前添加到项目的drawable文件夹中的首选图像文件来初始化myBitmap实例:

myBitmap = BitmapFactory.decodeResource
                (resources, R.drawable.myImage)

BitmapFactory类的decodeResource函数用于初始化myBitmap。它需要两个参数;第一个是Activity类提供的resources属性。这个函数,正如其名称所示,可以访问项目资源,第二个参数R.drawable.myImage指向drawable文件夹中的myImage.png文件。BitmapmyBitmap)实例现在已准备好由Canvas类绘制。

现在,您可以使用以下代码通过Bitmap实例绘制位图图形:

// Draw the bitmap at coordinates 100, 100
canvas.drawBitmap(myBitmap, 
                100, 100, myPaint);

当在屏幕上绘制时,上一节中太空飞船图形的样子如下(仅供参考,当我们谈论旋转位图时):

使用 Bitmap 类创*位图图形

操作位图

然而,通常情况下,我们需要以旋转或其他方式改变的状态绘制位图。使用 Photoshop 或您喜欢的其他图像编辑软件创*更多的位图以面向其他方向是非常容易的。然后,当我们要绘制位图时,我们可以简单地决定以哪种方式绘制适当的预加载位图。

然而,如果我们只使用一个单一的源图像并学* Android 提供的用于在 Kotlin 代码中操作图像的类,那将会更有趣和有教育意义。然后,你就可以将旋转和反转图形添加到你的应用程序开发工具包中。

什么是位图?

位图之所以被称为位图,是因为它确实就是一个“位的地图”。虽然有许多使用不同范围和值来表示颜色和透明度的位图格式,但它们都归结为同一件事。它们是一组值的网格或地图,每个值代表一个像素的颜色。

因此,要旋转、缩放或反转位图,我们必须对位图的每个像素或位进行适当的数学计算。这些计算并不是非常复杂,但也不是特别简单。如果你上完高中的数学课,你可能不会对这些数学感到太困难。

不幸的是,理解数学还不够。我们还需要设计高效的代码,了解位图格式,然后针对每种格式修改我们的代码;这并不是微不足道的。幸运的是(正如我们所期望的那样),Android API 已经为我们做好了一切 - 认识Matrix类。

Matrix 类

这个类被命名为Matrix,是因为它使用数学概念和规则来对一系列值进行计算,这些值被称为矩阵 - 矩阵的复数。

提示

Android 的Matrix类与同名电影系列无关。然而,作者*议所有有抱负的应用程序开发者服用红色药丸。

你可能对矩阵很熟悉,但如果你不熟悉也不用担心,因为Matrix类将所有复杂性都隐藏起来了。此外,Matrix类不仅允许我们对一系列值进行计算,还具有一些预先准备好的计算,使我们能够做一些事情,比如围绕另一个点旋转一个点特定角度,而无需了解三角学。

提示

如果你对Matrix类背后的数学运作感兴趣,并且想要一个绝对初学者指南来学*旋转游戏对象的数学,那么请查看我网站上的这一系列 Android 教程,其中包括一个可飞行和可旋转的太空飞船。这些教程是用 Java 编写的,但应该很容易理解:

gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/

gamecodeschool.com/essentials/rotating-graphics-in-2d-games-using-trigonometric-functions-part-2/

gamecodeschool.com/android/2d-rotation-and-heading-demo/

这本书将继续使用 Android 的Matrix类,但在下一章中创*粒子系统时,我们将进行稍微更高级的数学运算。

将位图反转以面对相反方向

首先,我们需要创*一个Matrix类的实例。下面的代码行以熟悉的方式调用默认构造函数来实现这一点:

val matrix = Matrix()

提示

请注意,你现在不需要将任何这些代码添加到项目中;它很快就会再次显示,并且会有更多的上下文。我只是觉得在此之前单独看到所有与Matrix相关的代码会更容易些。

现在我们可以使用Matrix类的许多巧妙功能之一。preScale函数接受两个参数;一个用于水*变化,一个用于垂直变化。看一下下面的代码行:

matrix.preScale(-1, 1)

preScale函数将循环遍历每个像素位置,并将所有水*坐标乘以-1,所有垂直坐标乘以1

这些计算的效果是所有垂直坐标将保持不变,因为如果乘以一,那么数字不会改变。但是,当您乘以负一时,像素的水*位置将被倒转。例如,水*位置 0、1、2、3 和 4 将变为 0、-1、-2、-3 和-4。

在这个阶段,我们已经创*了一个可以在位图上执行必要计算的矩阵。我们实际上还没有对位图做任何事情。要使用Matrix实例,我们调用Bitmap类的createBitmap函数,如下面的代码行:

myBitmapLeft = Bitmap
    .createBitmap(myBitmapRight,
          0, 0, 50, 25, matrix, true)

上面的代码假设myBitmapLeft已经与myBitmapRight一起初始化。createBitmap函数的参数解释如下:

  • myBitmapRight是一个已经创*并缩放的Bitmap对象,并且已经加载了图像(面向右侧)。这是将用作创*新Bitmap实例的源的图像。源Bitmap对象将不会被改变。

  • 0, 0是我们希望将新的Bitmap实例映射到的水*和垂直起始位置。

  • 50, 25参数是设置位图缩放到的大小。

  • 下一个参数是我们预先准备的Matrix实例matrix

  • 最后一个参数true指示createBitmap函数需要过滤以正确处理Bitmap类型的创*。

这就是在绘制到屏幕时myBitmapLeft的样子:

将位图反转以面向相反方向

我们还可以使用旋转矩阵创*面向上和下的位图。

将位图旋转以面向上和下

让我们看看如何旋转Bitmap实例,然后我们可以构*演示应用程序。我们已经有了Matrix类的一个实例,所以我们只需要调用preRotate函数来创*一个能够将每个像素旋转指定角度的矩阵,该角度作为preRotate的单个参数。看看下面的代码行:

// A matrix for rotating
matrix.preRotate(-90)

是不是很简单?matrix实例现在已经准备好以逆时针(-90度旋转我们传递给它的任何一系列数字(位图)。

以下代码行与我们分解的先前对createBitmap的调用具有相同的参数,只是新的Bitmap实例分配给了myBitmapUp,并且matrix的效果是执行旋转而不是preScale函数:

mBitmapUp = Bitmap
   .createBitmap(mBitmap,
         0, 0, 25, 50, matrix, true)

这就是在绘制时myBitmapUp的样子:

将位图旋转以面向上和下

您还可以使用相同的技术,但在preRotate的参数中使用不同的值,以使位图面向下。让我们继续演示应用程序,看看所有这些东西是如何运作的。

Bitmap 操作演示应用程序

现在我们已经学*了理论,让我们绘制和旋转一些位图。首先,创*一个新项目并将其命名为Bitmap manipulation。选择空活动选项,其他设置与整本书中的设置相同。

将 Bob 图形添加到项目中

右键单击并选择复制,从Chapter20/Bitmap Manipulation/drawable文件夹中的下载包中复制bob.png图形文件。由bob.png表示的 Bob 是一个简单的静态视频游戏角色。

在 Android Studio 中,定位项目资源管理器窗口中的app/res/drawable文件夹,并将bob.png图像文件粘贴到其中。以下屏幕截图清楚地显示了该文件夹的位置以及带有bob.png图像的外观:

将 Bob 图形添加到项目中

右键单击drawable文件夹,然后选择粘贴以将bob.png文件添加到项目中。点击两次确定以确认将文件导入项目的默认选项。

在这个应用程序中,我们将做与上一个应用程序相同的更改。我们将使用Activity类的原始版本。因此,MainActivity将继承自Activity而不是AppCompatActivity,这是以前的情况。我们这样做是因为,再次强调,我们不使用来自 XML 文件的布局,因此我们不需要AppCompatActivity的向后兼容功能,就像在以前的所有项目中一样。

您应该编辑类声明如下。

class MainActivity : Activity() {

您还需要添加以下导入语句:

import android.app.Activity

MainActivity类的类声明之后,在onCreate函数之前,添加以下必需的属性,准备进行一些绘图:

// Here are all the objects(instances)
// of classes that we need to do some drawing
lateinit var myImageView: ImageView
lateinit var myBlankBitmap: Bitmap
lateinit var bobBitmap: Bitmap
lateinit var myCanvas: Canvas
lateinit var myPaint: Paint

提示

在包声明之后添加以下导入:

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.widget.ImageView

现在,我们可以在onCreate中初始化所有实例,如下所示:

// Initialize all the objects ready for drawing
val widthInPixels = 2000
val heightInPixels = 1000

// Create a new Bitmap
myBlankBitmap = Bitmap.createBitmap(widthInPixels,
         heightInPixels,
         Bitmap.Config.ARGB_8888)

// Initialize Bob
bobBitmap = BitmapFactory.decodeResource(
          resources, R.drawable.bob)

// Initialize the Canvas and associate it
// with the Bitmap to draw on
myCanvas = Canvas(myBlankBitmap)

// Initialize the ImageView and the Paint
myImageView = ImageView(this)
myPaint = Paint()

// Draw on the Bitmap
// Wipe the Bitmap with a blue color
myCanvas.drawColor(Color.argb(
         255, 0, 0, 255))

接下来,我们添加对三个函数的调用,我们很快将编写这些函数,并将我们的新绘图设置为应用程序的视图:

// Draw some bitmaps
drawRotatedBitmaps()
drawEnlargedBitmap()
drawShrunkenBitmap()

// Associate the drawn upon Bitmap
// with the ImageView
myImageView.setImageBitmap(myBlankBitmap)
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView)

现在,添加drawRotatedBitmap函数,执行位图操作:

fun drawRotatedBitmaps() {
   var rotation = 0f
   var horizontalPosition = 350
   var verticalPosition = 25
   val matrix = Matrix()

   var rotatedBitmap: Bitmap

   rotation = 0f
   while (rotation < 360) {
         matrix.reset()
         matrix.preRotate(rotation)
         rotatedBitmap = Bitmap
                      .createBitmap(bobBitmap, 
                      0, 0, bobBitmap.width - 1,
                      bobBitmap.height - 1,
                      matrix, true)

        myCanvas.drawBitmap(
                    rotatedBitmap,
                    horizontalPosition.toFloat(),
                    verticalPosition.toFloat(),
                    myPaint)

        horizontalPosition += 120
        verticalPosition += 70
        rotation += 30f
  }
}

先前的代码使用循环迭代 360 度,每次 30 度。值(在循环中的每次通过)用于在Matrix实例中旋转 Bob 的图像,然后使用drawBitmap函数将其绘制到屏幕上。

添加最后两个函数,如下所示:

fun drawEnlargedBitmap() {
  bobBitmap = Bitmap
               .createScaledBitmap(bobBitmap,
                           300, 400, false)
  myCanvas.drawBitmap(bobBitmap, 25f, 25f, myPaint)

}

fun drawShrunkenBitmap() {
  bobBitmap = Bitmap
              .createScaledBitmap(bobBitmap,
                          50, 75, false)
  myCanvas.drawBitmap(bobBitmap, 250f, 25f, myPaint)
}

drawEnlargedBitmap函数使用createScaledBitmap函数,将位图图形放大到 300 x 400 像素。然后drawBitmap函数将其绘制到屏幕上。

drawShrunkenBitmap函数使用完全相同的技术,只是它缩放然后绘制一个 50 x 75 像素的图像。

最后,运行应用程序,看到 Bob 在 30 度间隔下生长、缩小,然后围绕 360 度旋转,如下截图所示:

将 Bob 图形添加到项目中

我们绘图库中唯一缺少的是观看所有这些活动发生的能力。我们将在下一步中填补这一知识空白。

常见问题

Q 1)我知道如何进行所有这些绘图,但为什么我看不到任何东西移动?

A)要看到物体移动,您需要能够调节绘图的每个部分发生的时间。您需要使用动画技术。这并不是微不足道的,但对于一个有决心的初学者来说也不是难以掌握的。我们将在下一章中学*所需的主题。

摘要

在本章中,我们学*了如何绘制自定义形状、文本和位图。现在我们知道如何绘制和操作原始形状、文本和位图,我们可以提升一级。

在下一章中,我们将开始我们的下一个多章节应用程序,这是一个儿童风格的绘图应用程序,只需轻按按钮即可生动起来。

第二十一章:线程和启动实时绘图应用程序

在本章中,我们将开始我们的下一个应用程序。这个应用程序将是一个儿童风格的绘图应用程序,用户可以使用手指在屏幕上绘图。然而,我们创*的绘图应用程序将略有不同。用户绘制的线条将由粒子系统组成,这些粒子系统会爆炸成成千上万的碎片。我们将把项目称为实时绘图

为了实现这一点,我们将在本章中涵盖以下主题:

  • 开始使用实时绘图应用程序

  • 学*实时交互,有时被称为游戏循环

  • 学*关于线程

  • 编写一个准备好进行绘制的实时系统

让我们开始吧!

创*实时绘图项目

要开始,可以在 Android Studio 中创*一个名为Live Drawing的新项目。使用空活动项目,并将其余设置保持默认。

与上一章的两个绘图应用程序类似,这个应用程序只包含 Kotlin 文件,没有布局文件。到本章结束为止的所有 Kotlin 文件和代码都可以在下载包的Chapter21文件夹中找到。完整的项目可以在下载包的Chapter22文件夹中找到。

接下来,我们将创*一些空的类,这些类将在接下来的两章中进行编码。创*一个名为LiveDrawingView的新类,一个名为ParticleSystem的新类,以及一个名为Particle的新类。

展望实时绘图应用程序

由于这个应用程序更加深入,需要实时响应,因此需要使用稍微更深入的结构。起初,这可能看起来有些复杂,但从长远来看,这将使我们的代码更简单,更容易理解。

在实时绘图应用程序中,我们将有四个类,如下:

  • MainActivity:Android API 提供的Activity类是与操作系统(OS)交互的类。我们已经看到了当用户点击应用程序图标启动应用程序时,操作系统如何与onCreate交互。与其让MainActivity类做所有事情,这个基于Activity的类将只处理应用程序的启动和关闭,并通过计算屏幕分辨率来提供一些初始化的帮助。这个类将是Activity类型而不是AppCompatActivity是有道理的。然而,很快你会看到,我们将通过触摸委托交互给另一个类,也就是将处理几乎每个方面的同一个类。这将为我们介绍一些新的有趣的概念。

  • LiveDrawingView:这个类将负责绘图,并创*允许用户在其创作移动和发展的同时进行交互的实时环境。

  • ParticleSystem:这是一个类,将管理多达数千个Particle类的实例。

  • Particle:这个类将是最简单的类;它将在屏幕上具有位置和方向。当由LiveDrawingView类提示时,它将每秒更新自己大约 60 次。

现在,我们可以开始编码。

编写 MainActivity 类

让我们开始编写基于Activity的类。通常情况下,这个类被称为MainActivity,当我们创*项目时,它是自动生成的。

编辑类声明并添加MainActivity类的代码的第一部分:

import android.app.Activity
import android.os.Bundle
import android.graphics.Point

class MainActivity : Activity() {

    private lateinit var liveDrawingView: LiveDrawingView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val display = windowManager.defaultDisplay
        val size = Point()
        display.getSize(size)

        liveDrawingView = LiveDrawingView(this, size.x)

        setContentView(liveDrawingView)

    }
}

上述代码显示了我们将很快讨论的几个错误。首先要注意的是,我们正在声明LiveDrawingView类的一个实例。目前,这是一个空类:

private lateinit var liveDrawingView: LiveDrawingView

下面的代码以以下方式获取设备的像素数(水*和垂直):

val display = windowManager.defaultDisplay

我们创*了一个名为displayDisplay类型的对象,并用windowManager.defaultDisplay进行初始化,这是Activity类的一部分。

然后,我们创*一个名为sizePoint类型的新对象。我们将size作为参数发送到display.getSize函数。Point类型有xy属性,因此size对象也有这些属性,在第三行代码之后,size现在保存了显示的宽度和高度(以像素为单位)。现在,我们在size对象的xy属性中有了屏幕分辨率。

接下来,在onCreate中,我们初始化liveDrawingView如下:

liveDrawingView = LiveDrawingView(this, size.x)

我们正在向LiveDrawingView构造函数传递两个参数。显然,我们还没有编写构造函数,而且我们知道,默认构造函数不带参数。因此,在我们解决这个问题之前,这行代码将导致错误。

传入的参数很有趣。首先是this,它是对MainActivity的引用。LiveDrawingView类将需要执行一些操作(使用一些函数),它需要这个引用。

第二个参数是水*屏幕分辨率。我们的应用程序需要这些参数来执行任务,例如将其他绘图对象缩放到适当的大小。当我们开始编写LiveDrawingView的构造函数时,我们将进一步讨论这些参数。

现在,看一下接下来的更奇怪的一行:

setContentView(liveDrawingView)

这是在 Canvas Demo 应用程序中,我们将ImageView设置为应用程序的内容。请记住,Activity类的setContentView函数必须接受一个View对象,而ImageView是一个View对象。前面的代码似乎在暗示我们将使用LiveDrawingView类作为应用程序的可见内容?但是LiveDrawingView,尽管名字是这样,却不是一个View对象。至少目前还不是。

在我们向MainActivity添加几行代码之后,我们将解决构造函数和不是View类型的问题。

添加这两个重写的函数,然后我们将讨论它们。将它们添加到onCreate的闭合大括号下面,但在MainActivity的闭合大括号之前:

override fun onResume() {
   super.onResume()

   // More code here later in the chapter
}

override fun onPause() {
   super.onPause()

  // More code here later in the chapter
}

我们在这里做的是重写Activity类的另外两个函数。我们将看到为什么需要这样做以及我们将在这些函数中做什么。需要注意的是,通过添加这些重写的函数,我们给了操作系统在两种情况下通知我们用户意图的机会,就像我们在 Note to self 应用程序中保存和加载数据时所做的那样。

在这一点上,继续前进到这个应用程序最重要的类LiveDrawingView。我们将在本章末尾讨论MainActivity

编写 LiveDrawingView 类

我们要做的第一件事是解决LiveDrawingView类不是View类型并且具有错误构造函数的问题。更新类声明如下:

class LiveDrawingView(
        context: Context,
        screenX: Int)
    : SurfaceView(context){

您将被提示导入android.view.SurfaceView类,如下截图所示:

编写 LiveDrawingView 类

点击确定以确认。

SurfaceViewView的后代,现在LiveDrawingView也是View的一种类型,通过继承。看一下已添加的import语句。这种关系在下面的代码中得到了明确的说明:

android.view.SurfaceView

提示

请记住,正是由于多态性,我们可以将View的后代发送到MainActivity类的setContentView函数中,而正是由于继承,LiveDrawingView现在是SurfaceView的一种类型。

有很多View的后代可以扩展以解决这个初始问题,但是随着我们的继续,我们将看到SurfaceView具有一些非常特定的功能,非常适合实时交互应用程序,并且这对我们来说是正确的选择。我们还提供了一个与从MainActivity调用的参数匹配的构造函数。

要导入Context类,请按照以下步骤操作:

  1. 将鼠标光标放在新构造函数签名中红色的Context文本上。

  2. 按住Alt键并点击Enter键。从弹出选项中选择导入类

前面的步骤将导入Context类。现在,我们的LiveDrawingView类或初始化它的MainActivity类中都没有错误。

在这个阶段,我们可以运行应用程序,看看使用LiveDrawingView作为setContentView中的View参数是否有效,并且我们有一个美丽的空白屏幕,准备在上面绘制我们的粒子系统。如果你愿意,你可以尝试一下,但我们将编写LiveDrawingView类,以便它接下来会做一些事情。

记住LiveDrawingView无法看到MainActivity中的变量。通过构造函数,MainActivity提供了一个对自身(this)的引用以及包含在size.x中的像素屏幕分辨率给LiveDrawingView

在这个项目的过程中,我们将不断回到这个类。我们现在要做的是准备好设置基础,以便在下一章编写ParticleSystem实例后添加它们。

为了实现这一点,我们首先会添加一些属性。之后,我们将编写draw函数,它将揭示我们需要在屏幕上每秒绘制 60 次的新步骤。此外,我们将看到一些使用我们上一章的老朋友CanvasPaintdrawText的熟悉代码。

在这一点上,我们需要讨论一些更多的理论;例如,我们将如何计时粒子的动画,以及如何在不干扰 Android 的*稳运行的情况下锁定这些时间。这最后两个主题,即游戏循环线程,将允许我们在添加本章的最终代码并观察我们的粒子系统绘画应用程序的同时,尽管只有一点点文本。

提示

游戏循环是一个描述允许虚拟系统同时更新和绘制自身的概念,同时允许用户对其进行修改和交互。

添加属性

在我们编写的LiveDrawingView声明和构造函数之后添加属性,如下面的代码块所示:

// Are we debugging?
private val debugging = true

// These objects are needed to do the drawing
private lateinit var canvas: Canvas
private val paint: Paint = Paint()

// How many frames per second did we get?
private var fps: Long = 0
// The number of milliseconds in a second
private val millisInSecond: Long = 1000

// How big will the text be?
// Font is 5% (1/20th) of screen width
// Margin is 1.5% (1/75th) of screen width
private val fontSize: Int = mScreenX / 20
private val fontMargin: Int = mScreenX / 75

// The particle systems will be declared here later

确保你学*了代码,然后我们会讨论它。注意所有的属性都声明为private。你可以愉快地删除所有的private访问修饰符,代码仍然可以正常工作,但是,由于我们不需要从这个类的外部访问任何这些属性,所以通过声明它们为private来保证这永远不会发生是明智的。

第一个属性是debugging。我们将使用它来手动切换打印调试信息和不打印调试信息。

我们声明的两个类实例将处理屏幕上的绘制:

// These objects are needed to do the drawing
private lateinit var canvas: Canvas
private val paint: Paint = Paint()

以下两个属性将为我们提供一些关于我们需要实现*滑和一致动画的见解:

// How many frames per second did we get?
private var fps: Long = 0
// The number of milliseconds in a second
private val millisInSecond: Long = 1000

这两个属性都是long类型,因为它们将保存一个我们将用来测量时间的大数字。计算机根据自 1970 年以来的毫秒数来测量时间。我们将在学*游戏循环时更多地讨论这个问题;然而,现在,我们需要知道监视和测量每一帧动画的速度是如何确保粒子移动正如它们应该的。

第一个变量fps将在每一帧动画中重新初始化,大约每秒 60 次。它将被传递到每个ParticleSystem对象(每一帧动画)中,以便它们知道经过了多少时间,然后可以计算应该移动多远或不移动。

millisInSecond变量初始化为1000。一秒钟确实有1000毫秒。我们将在计算中使用这个变量,因为它会使我们的代码比使用字面值 1,000 更清晰。

我们刚刚添加的代码的下一部分如下所示:

// How big will the text be?
// Font is 5% (1/20th) of screen width
// Margin is 1.5% (1/75th) of screen width
private val fontSize: Int = screenX / 20
private val fontMargin: Int = screenX / 75

fontSizemarginSize属性将根据通过构造函数传入的像素屏幕分辨率(screenX)进行初始化。它们将保存以像素为单位的值,以使我们的文本格式整洁而简洁,而不是为每个文本部分不断进行计算。

在我们继续之前,我们应该明确一下,这些是您目前应该在LiveDrawingView.kt代码文件顶部拥有的import语句:

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.view.SurfaceView

现在,让我们准备好绘制。

编写 draw 函数

在我们刚刚添加的属性之后立即添加draw函数。代码中会有一些错误。我们将首先处理它们,然后我们将详细讨论draw函数与SurfaceView的关系,因为其中有一些看起来很陌生的代码行,以及一些熟悉的代码行。添加以下代码:

// Draw the particle systems and the HUD
private fun draw() {
   if (holder.surface.isValid) {
         // Lock the canvas (graphics memory) ready to draw
         canvas = holder.lockCanvas()

         // Fill the screen with a solid color
         canvas.drawColor(Color.argb(255, 0, 0, 0))

         // Choose a color to paint with
         paint.color = Color.argb(255, 255, 255, 255)

         // Choose the font size
         paint.textSize = fontSize.toFloat()

         // Draw the particle systems

         // Draw the HUD

         if (debugging) {
               printDebuggingText()
         }
         // Display the drawing on screen
         // unlockCanvasAndPost is a 
         // function of SurfaceHolder
         holder.unlockCanvasAndPost(canvas)
   }
}

我们有两个错误 - 一个错误是需要导入Color类。您可以按照通常的方式修复这个问题,或者手动添加下一行代码。无论您选择哪种方法,以下额外的行需要添加到文件顶部的代码中:

import android.graphics.Color;

现在让我们处理另一个错误。

添加 printDebuggingText 函数

第二个错误是调用printDebuggingText。该函数尚不存在,所以现在让我们添加它。按照以下方式在draw函数之后添加代码:

private fun printDebuggingText() {
   val debugSize = fontSize / 2
   val debugStart = 150
   paint.textSize = debugSize.toFloat()
   canvas.drawText("fps: $fps",
         10f, (debugStart + debugSize).toFloat(), paint)

 }

先前的代码使用本地的debugSize变量来保存fontSize属性值的一半。这意味着,由于fontSize(用于HUD)是根据屏幕分辨率动态初始化的,debugSize将始终是其一半。

提示

HUD 代表抬头显示,是指覆盖应用程序中其他对象的按钮和文本的一种花哨方式。

然后使用debugSize变量来设置字体的大小,然后开始绘制文本。debugStart变量是一个整洁的垂直位置的猜测,用于开始打印调试文本,并留有一些填充,以免它被挤得太靠近屏幕边缘。

然后使用这两个值来定位屏幕上显示当前每秒帧数的一行文本。由于此函数是从draw调用的,而draw又将从游戏循环中调用,因此这行文本将每秒刷新多达 60 次。

注意

在非常高或非常低分辨率屏幕上,您可能需要尝试不同的值,以找到适合您屏幕的值。

让我们探索draw函数中的这些新代码行,并确切地检查我们如何使用SurfaceView来处理所有绘图需求,从而处理我们的LiveDrawingView类的派生。

理解 draw 函数和 SurfaceView 类

从函数的中间开始,向外工作,我们有一些熟悉的东西,比如调用drawColor,然后我们像以前一样设置颜色和文本大小。我们还可以看到注释,指示我们最终将添加绘制粒子系统和 HUD 的代码的位置:

  • drawColor代码用纯色清除屏幕。

  • painttextSize属性设置了绘制 HUD 的文本大小。

  • 一旦我们更深入地探索了粒子系统,我们将编写绘制 HUD 的过程。我们将让玩家知道他们的绘图由多少个粒子和系统组成。

然而,完全新的是draw函数开头的代码,如下面的代码块所示:

if (holder.surface.isValid) {
         // Lock the canvas (graphics memory) ready to draw
         canvas = holder.lockCanvas()

if条件是holder.surface.isValid。如果这行返回 true,则确认我们要操作的内存区域以表示我们的绘图帧是可用的,然后代码继续在if语句内部。

这是因为我们所有的绘图和其他处理(比如移动对象)都将异步进行,而代码则会检测用户输入并监听操作系统的消息。这在以前的项目中不是问题,因为我们的代码只是坐在那里等待输入,绘制一个帧,然后再次坐在那里等待。

现在我们想要每秒连续执行 60 次代码,我们需要确认我们能够访问绘图的内存,然后再访问它。

这引发了另一个关于这段代码如何异步运行的问题。但这将在我们不久后讨论线程时得到解答。现在,只需知道这行代码检查另一部分我们的代码或 Android 本身是否正在使用所需的内存部分。如果空闲,那么if语句内的代码将执行。

此外,在if语句内执行的第一行代码调用lockCanvas,这样如果代码的另一部分在我们访问内存时尝试访问内存,它将无法访问 - 然后我们进行所有的绘制。

最后,在draw函数中,以下代码(加上注释)出现在最后:

// Display the drawing on screen
// unlockCanvasAndPost is a 
// function of SurfaceHolder
holder.unlockCanvasAndPost(canvas)

unlockCanvasAndPost函数将我们新装饰的Canvas对象(canvas)发送到屏幕上进行绘制,并释放锁定,以便其他代码区域可以使用它,尽管非常短暂,在整个过程开始之前。这个过程发生在每一帧动画中。

我们现在理解了draw函数中的代码。然而,我们仍然没有调用draw函数的机制。事实上,我们甚至没有调用draw函数一次。接下来,我们将讨论游戏循环和线程。

游戏循环

那么,游戏循环到底是什么?几乎每个实时绘图、基于图形的应用程序和游戏都有一个游戏循环。甚至你可能没有想到的游戏,比如回合制游戏,仍然需要将玩家输入与绘图和人工智能同步,同时遵循底层操作系统的规则。

应用程序中的对象需要不断更新,比如移动它们并在当前位置绘制所有内容,同时响应用户输入:

游戏循环

我们的游戏循环包括三个主要阶段:

  1. 通过移动它们、检测碰撞和处理人工智能(如粒子运动和状态变化)来更新所有游戏和绘图对象

  2. 根据刚刚更新的数据,绘制动画的最新状态帧

  3. 响应用户的屏幕触摸

我们已经有一个draw函数来处理循环的这一部分。这表明我们将有一个函数来进行所有的更新。我们很快将编写一个update函数的大纲。此外,我们知道我们可以响应屏幕触摸,尽管我们需要稍微调整之前所有项目的方式,因为我们不再在Activity类内部工作,也不再使用布局中的传统 UI 小部件。

还有一个问题,就是(我简要提到过的)所有的更新和绘制都是异步进行的,以便检测屏幕触摸并监听操作系统。

提示

只是为了明确,异步意味着它不会同时发生。我们的代码将通过与 Android 和 UI 共享执行时间来工作。CPU 将在我们的代码、Android 或用户输入之间非常快速地来回切换。

但这三个阶段将如何循环?我们将如何编写这个异步系统,从中可以调用updatedraw,并且如何使循环以正确的速度(或帧率)运行?

正如你可能猜到的那样,编写一个高效的游戏循环并不像一个while循环那样简单。

注意

然而,我们的游戏循环也将包含一个while循环。

我们需要考虑时间、开始和停止循环,以及不会导致操作系统变得无响应,因为我们正在独占整个 CPU 在我们的单个循环中。

但是我们何时以及如何调用我们的draw函数?我们如何测量和跟踪帧速率?考虑到这些问题,我们完成的游戏循环可能更好地由以下图表表示——注意引入线程的概念:

游戏循环

既然我们知道我们想要实现什么,那么让我们学*一下线程。

线程

那么,什么是线程?你可以把编程中的线程看作是故事中的线程。在故事的一个线程中,我们可能有主要角色在前线与敌人作战,而在另一个线程中,士兵的家人正在过着日常生活。当然,一个故事不一定只有两个线程——我们可以引入第三个线程。例如,故事还讲述了政治家和军事指挥官做出决策,这些决策会以微妙或不那么微妙的方式影响其他线程中发生的事情。

编程线程就像这样。我们在程序中创*部分或线程来控制不同的方面。在 Android 中,当我们需要确保一个任务不会干扰应用程序的主(UI)线程时,或者当我们有一个需要很长时间才能完成并且不能中断主线程执行的后台任务时,线程尤其有用。我们引入线程来代表这些不同的方面,原因如下:

  • 从组织的角度来看,这是有道理的

  • 它们是一种经过验证的构*程序的方法。

  • 我们正在处理的系统的性质迫使我们无论如何都要使用它们

在 Android 中,我们同时出于这三个原因使用线程——因为这是有道理的,它有效,而且我们必须使用线程,因为 Android 系统的设计要求如此。

通常,我们在不知情的情况下使用线程。这是因为我们使用的类会代表我们使用线程。我们在第十九章中编写的所有动画,动画和插值,都在线程中运行。在 Android 中的另一个例子是SoundPool类,它在一个线程中加载声音。我们将在第二十三章中看到,或者说听到,SoundPool的作用,Android 音效和 Spinner 小部件。我们将再次看到,我们的代码不必处理我们即将学*的线程方面,因为这一切都由类内部处理。然而,在这个项目中,我们需要更多地参与其中。

在实时系统中,想象一下一个线程同时接收玩家的左右移动按钮点击,同时监听来自操作系统的消息,比如调用onCreate(以及我们稍后将看到的其他函数)的一个线程,以及另一个线程绘制所有图形并计算所有移动。

线程的问题

具有多个线程的程序可能会出现与之相关的问题,就像故事的线程一样;如果适当的同步没有发生,那么事情可能会出错。如果我们的士兵在战斗甚至战争之前就进入了战斗,会发生什么?

假设我们有一个变量,Int x,代表我们程序的三个线程使用的一个关键数据。如果一个线程稍微超前于自己,并使数据对其他两个线程来说“错误”会发生什么?这个问题是由多个线程竞争完成而保持无视而引起的正确性问题——因为毕竟,它们只是愚蠢的代码。

正确性问题可以通过对线程和锁定的密切监督来解决。锁定意味着暂时阻止一个线程的执行,以确保事情以同步的方式工作;这类似于防止士兵在战船靠岸并放下舷梯之前登船,从而避免尴尬的溅水。

多线程程序的另一个问题是死锁问题。在这种情况下,一个或多个线程被锁定,等待“正确”的时刻来访问Int x;然而,那个时刻永远不会到来,最终整个程序都会停滞不前。

你可能已经注意到,第一个问题(正确性)的解决方案是第二个问题(死锁)的原因。

幸运的是,这个问题已经为我们解决了。就像我们使用Activity类并重写onCreate来知道何时需要创*我们的应用程序一样,我们也可以使用其他类来创*和管理我们的线程。例如,对于Activity,我们只需要知道如何使用它们,而不需要知道它们是如何工作的。

那么,当你不需要了解它们时,我为什么要告诉你关于线程呢?这只是因为我们将编写看起来不同并且结构不熟悉的代码。我们可以实现以下目标:

  • 理解线程的一般概念,它与几乎同时发生的故事线程相同

  • 学*使用线程的几条规则

通过这样做,我们将毫无困难地编写我们的 Kotlin 代码来创*和在我们的线程中工作。Android 有几个不同的类来处理线程,不同的线程类在不同的情况下效果最好。

我们需要记住的是,我们将编写程序的部分,它们几乎同时运行。

提示

几乎是什么意思?发生的是 CPU 在线程之间轮换/异步地。然而,这发生得如此之快,以至于我们除了同时性/同步性之外无法感知任何东西。当然,在故事线程的类比中,人们确实是完全同步地行动。

让我们来看看我们的线程代码将是什么样子。现在先不要向项目添加任何代码。我们可以声明一个Thread类型的对象,如下所示:

private lateinit var thread: Thread

然后可以按以下方式初始化并启动它:

// Initialize the instance of Thread
thread = Thread(this)

// Start the thread
thread.start()

线程还有一个谜团;再看一下初始化线程的构造函数。以下是代码行,以方便你查看:

thread = Thread(this)

看一下传递给构造函数的参数;我们传入了this。请记住,代码是放在LiveDrawingView类中的,而不是MainActivity。因此,我们可以推断this是对LiveDrawingView类(它扩展了SurfaceView)的引用。

在 Android 总部的工程师编写Thread类时,他们很可能不会意识到有一天我们会编写我们的LiveDrawingView类。那么,这怎么可能呢?

Thread类需要传入一个完全不同的类型到它的构造函数。Thread构造函数需要一个Runnable对象。

注意

你可以通过查看 Android 开发者网站上的Thread类来确认这一事实:developer.android.com/reference/java/lang/Thread.html#Thread(java.lang.Runnable)

你还记得我们在第十二章中讨论过接口吗,将我们的 Kotlin 连接到 UI 和空值?作为提醒,我们可以通过在类声明后添加接口名称来实现接口。

然后我们必须实现接口的抽象函数。Runnable只有一个函数,就是run函数。

注意

你可以通过查看 Android 开发者网站上的Runnable接口来确认这个事实:developer.android.com/reference/java/lang/Runnable.html

我们可以使用override关键字来改变当操作系统允许我们的线程对象运行其代码时发生的情况:

override fun run() {
         // Anything in here executes in a thread
         // No skill needed on our part
         // It is all handled by Android, the Thread class
         // and the Runnable interface
}

在重写的run函数中,我们将调用两个函数,一个是我们已经开始的draw,另一个是updateupdate函数是我们所有计算和人工智能的地方。代码将类似于以下代码块,但现在不要添加:

override fun run() { 
    // Update the drawing based on
    // user input and physics
    update()

    // Draw all the particle systems in their updated locations
    draw() 
}

在适当的时候,我们也可以停止我们的线程,如下所示:

thread.join()

现在,run函数中的所有内容都在一个单独的线程中执行,使默认或 UI 线程监听触摸和系统事件。我们很快将看到这两个线程如何相互通信在绘图项目中。

请注意,我们的应用程序中所有这些代码的确切位置尚未解释,但在真实项目中向您展示会更容易。

使用线程实现游戏循环

现在我们已经了解了游戏循环和线程,我们可以将它们全部整合到 Living Drawing 项目中来实现我们的游戏循环。

我们将添加整个游戏循环的代码,包括在MainActivity类中编写两个函数的代码,以启动和停止控制循环的线程。

提示

读者挑战

您能自己想出Activity-based 类将如何在LiveDrawingView类中启动和停止线程吗?

实现 Runnable 并提供 run 函数

通过实现Runnable来更新类声明,如下所示:

class LiveDrawingView(
        context: Context,
        screenX: Int)
    : SurfaceView(context), Runnable {

请注意,代码中出现了一个新错误。将鼠标光标悬停在Runnable一词上,您将看到一条消息,告诉您我们需要实现run函数,就像我们在上一节关于接口和线程的讨论中讨论的那样。添加空的run函数,包括override标签。

无论您在何处添加它,只要在LiveDrawingView类的大括号内而不是在另一个函数内。添加空的run函数,如下所示:

// When we start the thread with:
// thread.start();
// the run function is continuously called by Android
// because we implemented the Runnable interface
// Calling thread.join();
// will stop the thread
override fun run() {

}

错误已经消失,现在我们可以声明和初始化一个Thread对象了。

编写线程

LiveDrawingView类的所有其他成员下面声明一些变量和实例,如下所示:

// Here is the Thread and two control variables
private lateinit var thread: Thread
// This volatile variable can be accessed
// from inside and outside the thread
@Volatile
private var drawing: Boolean = false
private var paused = true

现在,我们可以启动和停止线程了-花点时间考虑我们可能在哪里这样做。请记住,应用程序需要响应启动和停止应用程序的操作系统。

启动和停止线程

现在,我们需要启动和停止线程。我们已经看到了我们需要的代码,但是何时何地应该这样做呢?让我们添加两个函数的代码-一个用于启动,一个用于停止-然后我们可以考虑何时何地调用这些函数。在LiveDrawingView类中添加这两个函数。如果它们的名称听起来很熟悉,那并非偶然:

// This function is called by MainActivity
// when the user quits the app
fun pause() {
   // Set drawing to false
   // Stopping the thread isn't
   // always instant
   drawing = false
   try {
         // Stop the thread
         thread.join()
  }  catch (e: InterruptedException) {
     Log.e("Error:", "joining thread")
  }

}

// This function is called by MainActivity
// when the player starts the app
fun resume() {
    drawing = true
    // Initialize the instance of Thread
    thread = Thread(this)

    // Start the thread
    thread.start()
}

注释略微透露了发生的事情。现在我们有一个pauseresume函数,使用我们之前讨论过的相同代码来停止和启动Thread对象。

请注意,新函数是public的,因此它们可以从类外部访问,任何具有LiveDrawingView实例的其他类都可以访问。请记住,MainActivity保存了完全声明和初始化的LiveDrawingView实例。

让我们使用 Android Activity 生命周期来调用这两个新函数。

使用 Activity 生命周期来启动和停止线程

更新MainActivity中重写的onResumeonPause函数,如下所示:

override fun onResume() {
  super.onResume()

  // More code here later in the chapter
 liveDrawingView.resume()
}

override fun onPause() {
   super.onPause()

   // More code here later in the chapter
 liveDrawingView.pause()
}

现在,我们的线程将在操作系统恢复和暂停我们的应用程序时启动和停止。请记住,onResume在应用程序首次启动时(不仅是从暂停恢复时)调用,而不仅仅是在从暂停恢复后调用。在onResumeonPause中的代码使用liveDrawingView对象调用其resumepause函数,这些函数又调用启动和停止线程的代码。然后触发线程的run函数执行。就是在这个run函数(在LiveDrawingView中)中,我们将编写我们的游戏循环。现在让我们来做这个。

编写 run 函数

尽管我们的线程已经设置好并准备就绪,但由于run函数为空,所以什么也不会发生。编写run函数,如下所示:

override fun run() {
   // The drawing Boolean gives us finer control
   // rather than just relying on the calls to run
   // drawing must be true AND
   // the thread running for the main
   // loop to execute
   while (drawing) {

         // What time is it now at the 
         // start of the loop?
         val frameStartTime = 
               System.currentTimeMillis()

        // Provided the app isn't paused
        // call the update function
        if (!paused) {
              update()
        }

        // The movement has been handled
        // we can draw the scene.
        draw()

        // How long did this frame/loop take?
        // Store the answer in timeThisFrame
        val timeThisFrame = System.currentTimeMillis() 
            - frameStartTime

      // Make sure timeThisFrame is 
      // at least 1 millisecond
      // because accidentally dividing
      // by zero crashes the app
      if (timeThisFrame > 0) {
            // Store the current frame rate in fps
            // ready to pass to the update functions of
            // of our particles in the next frame/loop
            fps = millisInSecond / timeThisFrame
      }
   }
}

请注意,Android Studio 中有两个错误。这是因为我们还没有编写update函数。让我们快速添加一个空函数(带有注释);我在run函数后面添加了我的:

private fun update() {
   // Update the particles
}

现在,让我们逐步详细讨论run函数中的代码如何通过一步一步的方式实现游戏循环的目标。

这第一部分启动了一个while循环,条件是drawing,然后将代码的其余部分包装在run中,以便线程需要启动(调用run)并且drawing需要为true才能执行while循环:

override fun run() {
   // The drawing Boolean gives us finer control
   // rather than just relying on the calls to run
   // drawing must be true AND
   // the thread running for the main
   // loop to execute
   while (drawing) {

while循环内的第一行代码声明并初始化了一个名为frameStartTime的局部变量,其值为当前时间。System类的currentTimeMillis函数返回此值。如果以后我们想要测量一帧花费了多长时间,那么我们需要知道它开始的时间:

// What time is it now at the 
// start of the loop?
val frameStartTime = 
  System.currentTimeMillis()

接下来,在while循环中,我们检查应用程序是否暂停,只有在应用程序没有暂停的情况下,才会执行下一段代码。如果逻辑允许在此块内执行,则调用update

// Provided the app isn't paused
// call the update function
if (!paused) {
   update()
}

在前一个if语句之外,调用draw函数以绘制所有对象的最新位置。此时,另一个局部变量被声明并初始化为完成整个帧(更新和绘制)所花费的时间长度。这个值是通过获取当前时间(再次使用currentTimeMillis)并从中减去frameStartTime来计算的,如下所示:

// The movement has been handled
// we can draw the scene.
draw()

// How long did this frame/loop take?
// Store the answer in timeThisFrame
val timeThisFrame = System.currentTimeMillis() 
  - frameStartTime

下一个if语句检测timeThisFrame是否大于零。如果线程在对象初始化之前运行,该值可能为零。如果您查看if语句内的代码,它通过将经过的时间除以millisInSecond来计算帧速率。如果除以零,应用程序将崩溃,这就是我们进行检查的原因。

一旦fps获得了分配给它的值,我们可以在下一帧中使用它传递给update函数,该函数将更新我们将在下一章中编写的所有粒子。它们将使用该值来确保它们根据其目标速度和刚刚结束的动画帧的长度移动了精确的数量:

// Make sure timeThisFrame is 
// at least 1 millisecond
// because accidentally dividing
// by zero crashes the app
if (timeThisFrame > 0) {
   // Store the current frame rate in fps
   // ready to pass to the update functions of
   // of our particles in the next frame/loop
   fps = millisInSecond / timeThisFrame
}

在每一帧中初始化fps的计算结果是,fps将保存一个分数。随着帧速率的波动,fps将保存不同的值,并为粒子系统提供适当的数量来计算每次移动。

运行应用程序

在 Android Studio 中单击播放按钮,本章的辛勤工作和理论将变为现实:

运行应用程序

您可以看到,我们现在使用我们的游戏循环和线程创*了一个实时系统。如果您在真实设备上运行此应用程序,您将很容易在此阶段实现每秒 60 帧。

总结

这可能是迄今为止最技术性的一章。我们探讨了线程、游戏循环、定时、使用接口和Activity生命周期 - 这是一个非常长的主题列表。

如果这些事物之间的确切相互关系仍然不是很清楚,那也没关系。您只需要知道,当用户启动和停止应用程序时,MainActivity类将通过调用LiveDrawingView类的pauseresume函数来处理启动和停止线程。它通过重写的onPauseonResume函数来实现,这些函数由操作系统调用。

一旦线程运行,run函数内的代码将与监听用户输入的 UI 线程一起执行。通过同时从run函数调用updatedraw函数,并跟踪每帧花费的时间,我们的应用程序已经准备就绪。

我们只需要允许用户向他们的艺术作品添加一些粒子,然后我们可以在每次调用update时更新它们,并在每次调用draw时绘制它们。

在下一章中,我们将编写、更新和绘制Particle和“ParticleSystem”类。此外,我们还将为用户编写代码,使其能够与应用程序进行交互(进行一些绘图)。

第二十二章:粒子系统和处理屏幕触摸

我们已经在上一章中使用线程实现了我们的实时系统。在本章中,我们将创*将存在并在这个实时系统中演变的实体,就像它们有自己的思想一样。

我们还将学*用户如何通过学*如何设置与屏幕交互的能力来将这些实体绘制到屏幕上。这与在 UI 布局中与小部件交互是不同的。

以下是本章的内容:

  • 向屏幕添加自定义按钮

  • 编写Particle类的代码

  • 编写ParticleSystem类的代码

  • 处理屏幕触摸

我们将首先向我们的应用程序添加自定义 UI。

向屏幕添加自定义按钮

我们需要让用户控制何时开始另一个绘图并清除屏幕上的先前工作。我们还需要让用户能够决定何时将绘图带到生活中。为了实现这一点,我们将在屏幕上添加两个按钮,分别用于这些任务。

LiveDrawingView类的其他属性之后,将以下新属性添加到代码中:

// These will be used to make simple buttons
private var resetButton: RectF
private var togglePauseButton: RectF

我们现在有两个RectF实例。这些对象每个都包含四个Float坐标,每个按钮的每个角都有一个坐标。

我们现在将向LiveDrawingView类添加一个init块,并在首次创*LiveDrawingView实例时初始化位置,如下所示:

init {
   // Initialize the two buttons
   resetButton = RectF(0f, 0f, 100f, 100f)
   togglePauseButton = RectF(0f, 150f, 100f, 250f)
}

现在我们已经为按钮添加了实际坐标。如果你在屏幕上可视化这些坐标,你会看到它们在左上角,暂停按钮就在重置/清除按钮的下方。

现在我们可以绘制按钮。将以下两行代码添加到LiveDrawingView类的draw函数中。现有的注释准确显示了新突出显示的代码应该放在哪里:

// Draw the buttons
canvas.drawRect(resetButton, paint)
canvas.drawRect(togglePauseButton, paint)

新代码使用了drawRect函数的重写版本,我们只需将我们的两个RectF实例直接传递给通常的Paint实例。我们的按钮现在将出现在屏幕上。

我们将在本章后面看到用户如何与这些略显粗糙的按钮交互。

实现粒子系统效果

粒子系统是控制粒子的系统。在我们的情况下,ParticleSystem是一个我们将编写的类,它将产生Particle类的实例(许多实例),这些实例将一起创*一个简单的爆炸效果。

这是一些由粒子系统控制的粒子的屏幕截图,可能在本章结束时出现:

实现粒子系统效果

为了澄清,每个彩色方块都是Particle类的一个实例,所有Particle实例都由ParticleSystem类控制和持有。此外,用户将通过用手指绘制来创*多个(数百个)ParticleSystem实例。粒子系统将出现为点或块,直到用户点击暂停按钮并使其活动起来。我们将仔细检查代码,以便您能够在代码中修改ParticleParticleSystem实例的大小、颜色、速度和数量。

注意

读者可以将额外的按钮添加到屏幕上,以允许用户更改这些属性作为应用程序的功能。

我们将首先编写Particle类的代码。

编写Particle类的代码

添加import语句,成员变量,构造函数和以下代码中显示的init块:

import android.graphics.PointF

class Particle(direction: PointF) {

    private val velocity: PointF = PointF()
    val position: PointF = PointF()

    init {
          // Determine the direction
          velocity.x = direction.x
          velocity.y = direction.y
    }

我们有两个属性——一个用于速度,一个用于位置。它们都是PointF对象。PointF保存两个Float值。粒子的位置很简单:它只是一个水*和垂直值。速度值值得解释一下。velocity对象PointF中的两个值将是速度,一个是水*的,另一个是垂直的。这两个速度的组合将产生一个方向。

接下来,添加以下update函数;我们稍后将更详细地查看它:

fun update() {
    // Move the particle
    position.x += velocity.x
    position.y += velocity.y
}

每个Particle实例的update函数将由ParticleSystem对象的update函数在应用程序的每一帧中调用,而ParticleSystem对象的update函数将由LiveDrawingView类(再次在update函数中)调用,我们将在本章后面编写。

update函数中,position的水*和垂直值将使用velocity的相应值进行更新。

提示

请注意,我们在更新中没有使用当前的帧速率。如果您想确保您的粒子以确切的速度飞行,您可以修改这一点,但所有的速度都将是随机的。添加这个额外的计算并没有太多好处(对于每个粒子)。然而,正如我们很快会看到的,ParticleSystem类需要考虑每秒的帧数来测量它应该运行多长时间。

现在我们可以继续进行ParticleSystem类的学*。

编写 ParticleSystem 类

ParticleSystem类比Particle类有更多的细节,但仍然相当简单。记住我们需要用这个类来实现的功能:持有、生成、更新和绘制一堆(相当大的一堆)Particle实例。

添加以下构造函数、属性和导入语句:

import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF

import java.util.*

class ParticleSystem {

    private var duration: Float = 0f
    private var particles: 
         ArrayList<Particle> = ArrayList()

    private val random = Random()
    var isRunning = false

我们有四个属性:首先是一个名为durationFloat,它将被初始化为我们希望效果运行的秒数;名为particlesArrayList实例,它持有Particle实例,并将保存我们为该系统实例化的所有Particle对象。

创*名为randomRandom实例,因为我们需要生成如此多的随机值,每次创*一个新对象都会使我们的速度变慢一点。

最后,名为isRunningBoolean将跟踪粒子系统当前是否正在显示(更新和绘制)。

现在我们可以编写initParticles函数。每当我们想要一个新的ParticleSystem时,将调用此函数。请注意,唯一的参数是一个名为numParticlesInt

当我们调用initParticles时,我们可以有一些乐趣来初始化大量的粒子。添加以下initParticles函数,然后我们将更仔细地查看代码:

fun initParticles(numParticles:Int){

   // Create the particles
   for (i in 0 until numParticles) {
         var angle: Double = random.nextInt(360).toDouble()
         angle *= (3.14 / 180)

         // Option 1 - Slow particles
         val speed = random.nextFloat() / 3

         // Option 2 - Fast particles
         //val speed = (random.nextInt(10)+1);

         val direction: PointF

         direction = PointF(Math.cos(
                     angle).toFloat() * speed,
                     Math.sin(angle).toFloat() * speed)

         particles.add(Particle(direction))

    }
}

initParticles函数只包括一个for循环来完成所有工作。for循环从零到numParticles运行。

首先生成介于 0 和 359 之间的随机数,并将其存储在Float angle中。接下来,有一点数学运算,我们将angle乘以3.14/180。这将角度从度转换为弧度制的度量,这是Math类所需的度量单位,我们稍后将在其中使用。

然后,我们生成另一个介于 1 和 10 之间的随机数,并将结果分配给名为speedFloat变量。

注意

请注意,我已经添加了注释,以*议代码中的不同值选项。我在ParticleSystem类的几个地方都这样做了,当我们到达章节的末尾时,我们将有一些乐趣改变这些值,并看看这对绘图应用程序的影响。

现在我们有了一个随机角度和速度,我们可以将它们转换并组合成一个向量,这个向量可以在每一帧的update函数中使用。

注意

向量是一个确定方向和速度的值。我们的向量存储在direction对象中,直到传递到Particle构造函数中。向量可以有许多维度。我们的向量由两个维度组成,因此定义了 0 到 359 度之间的方向和 1 到 10 之间的速度。您可以在我的网站上阅读更多关于向量、方向、正弦和余弦的内容:gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/

我决定不解释使用Math.sinMath.cos创*向量的单行代码,因为其中的魔法部分发生在以下公式中:

  • 角度 x速度的余弦

  • 角度 x速度的正弦

其余的魔法发生在Math类提供的余弦和正弦函数的隐藏计算中。如果您想了解它们的全部细节,可以查看前面的提示。

最后,创*一个新的Particle,然后将其添加到particles ArrayList中。

接下来,我们将编写update函数。请注意,update函数需要当前的帧速率作为参数。编写update函数如下:

fun update(fps: Long) {
   duration -= 1f / fps

   for (p in particles) {
         p.update()
  }

   if (duration < 0) {
         isRunning = false
  }
}

update函数内部的第一件事是从duration中减去经过的时间。请记住,fps表示每秒帧数,因此1/fps给出的是秒的一小部分值。

接下来是一个for循环,它为particles中的每个Particle实例调用update函数。

最后,代码检查粒子效果是否已经完成,如果是,则将isRunning设置为false

现在我们可以编写emitParticles函数,该函数将使每个Particle实例运行,不要与initParticles混淆,后者创*所有新粒子并赋予它们速度。initParticles函数将在用户开始与屏幕交互之前调用一次,而emitParticles函数将在每次效果需要启动时调用,用户在屏幕上绘制时。

使用以下代码添加emitParticles函数:

fun emitParticles(startPosition: PointF) {
    isRunning = true

    // Option 1 - System lasts for half a minute
    duration = 30f

    // Option 2 - System lasts for 2 seconds
    //duration = 3f

    for (p in particles) {
          p.position.x = startPosition.x
          p.position.y = startPosition.y
   }
}

首先,注意将PointF作为参数传递,所有粒子将从同一位置开始,然后根据它们各自的随机速度在每一帧上扩散。

isRunning布尔值设置为trueduration设置为30f,因此效果将持续 30 秒,for循环将将每个粒子的位置设置为起始坐标。

我们的ParticleSysytem的最终函数是draw函数,它将展示效果的全部荣耀。该函数接收对CanvasPaint的引用,以便可以绘制到LiveDrawingView刚刚在其draw函数中锁定的相同Canvas实例上。

添加如下的draw函数:

fun draw(canvas: Canvas, paint: Paint) {

    for (p in particles) {

           // Option 1 - Colored particles
           //paint.setARGB(255, random.nextInt(256),
           //random.nextInt(256),
           //random.nextInt(256))

           // Option 2 - White particles
           paint.color = Color.argb(255, 255, 255, 255)
           // How big is each particle?

           // Option 1 - Big particles
           //val sizeX = 25f
           //val sizeY = 25f

           // Option 2 - Medium particles
           //val sizeX = 10f
           //val sizeY = 10f

           // Option 3 - Tiny particles
           val sizeX = 12f
           val sizeY = 12f

           // Draw the particle
           // Option 1 - Square particles
           canvas.drawRect(p.position.x, p.position.y,
                       p.position.x + sizeX,
                       p.position.y + sizeY,
                       paint)

          // Option 2 - Circular particles
          //canvas.drawCircle(p.position.x, p.position.y,
          //sizeX, paint)
   }
}

在前面的代码中,for循环遍历particles中的每个Particle实例。然后使用drawRect绘制每个Particle

注意

再次注意,我*议不同的代码更改选项,这样我们在完成编码后可以有些乐趣。

我们现在可以开始让粒子系统工作了。

LiveDrawingView类中生成粒子系统

添加一个充满系统的ArrayList实例和一些其他成员来跟踪事物。在现有注释所指示的位置添加以下突出显示的代码:

// The particle systems will be declared here later
private val particleSystems = ArrayList<ParticleSystem>()

private var nextSystem = 0
private val maxSystems = 1000
private val particlesPerSystem = 100

现在我们可以跟踪多达 1,000 个每个系统中有 100 个粒子的粒子系统。随意调整这些数字。在现代设备上,您可以运行数百万个粒子而不会遇到任何问题,但在模拟器上,当粒子数量达到几十万个时,它将开始出现问题。

通过添加以下突出显示的代码在init块中初始化系统:

init {

  // Initialize the two buttons
  resetButton = RectF(0f, 0f, 100f, 100f)
  togglePauseButton = RectF(0f, 150f, 100f, 250f)

  // Initialize the particles and their systems
  for (i in 0 until maxSystems) {
 particleSystems.add(ParticleSystem())
 particleSystems[i]
 .initParticles(particlesPerSystem)
 }
}

代码循环遍历ArrayList,对每个ParticleSystem实例调用构造函数,然后调用initParticles

现在我们可以通过将突出显示的代码添加到update函数中,在循环的每一帧中更新系统:

private fun update() {
  // Update the particles
  for (i in 0 until particleSystems.size) {
 if (particleSystems[i].isRunning) {
 particleSystems[i].update(fps)
         }
 }
}

前面的代码循环遍历每个ParticleSystem实例,首先检查它们是否活动,然后调用update函数并传入当前的每秒帧数。

现在我们可以通过在draw函数中添加以下片段中的突出显示代码来在循环的每一帧中绘制系统:

// Choose the font size
paint.textSize = fontSize.toFloat()

// Draw the particle systems
for (i in 0 until nextSystem) {
 particleSystems[i].draw(canvas, paint)
}

// Draw the buttons
canvas.drawRect(resetButton, paint)
canvas.drawRect(togglePauseButton, paint)

先前的代码循环遍历particleSystems,对每个调用draw函数。当然,我们实际上还没有生成任何实例;为此,我们需要学*如何响应屏幕交互。

处理触摸

要开始屏幕交互,将OnTouchEvent函数添加到LiveDrawingView类中,如下所示:

override fun onTouchEvent(
   motionEvent: MotionEvent): Boolean {

   return true
}

这是一个被覆盖的函数,并且每当用户与屏幕交互时,Android 都会调用它。看看onTouchEvent的唯一参数。

事实证明,motionEvent中隐藏了大量数据,这些数据包含了刚刚发生的触摸的详细信息。操作系统将其发送给我们,因为它知道我们可能需要其中的一些数据。

请注意,我说的是其中一部分MotionEvent类非常庞大;它包含了几十个函数和属性。

目前,我们只需要知道屏幕会在玩家的手指移动、触摸屏幕或移开手指的精确时刻做出响应。

我们将使用motionEvent中包含的一些变量和函数,包括以下内容:

  • action属性,不出所料,保存了执行的动作。不幸的是,它以稍微编码的格式提供了这些信息,这就解释了其他一些变量的必要性。

  • ACTION_MASK变量提供了一个称为掩码的值,再加上一点 Kotlin 技巧,可以用来过滤action中的数据。

  • ACTION_UP变量,我们可以用它来判断执行的动作(例如移开手指)是否是我们想要响应的动作。

  • ACTION_DOWN变量,我们可以用它来判断执行的动作是否是我们想要响应的动作。

  • ACTION_MOVE变量,我们可以用它来判断执行的动作是否是移动/拖动动作。

  • x属性保存事件发生的水*浮点坐标。

  • y属性保存事件发生的垂直浮点坐标。

举个具体的例子,假设我们需要使用ACTION_MASK过滤action中的数据,并查看结果是否与ACTION_UP相同。如果是,那么我们知道用户刚刚从屏幕上移开了手指,也许是因为他们刚刚点击了一个按钮。一旦我们确定事件是正确类型的,我们就需要使用xy找出事件发生的位置。

还有一个最后的复杂情况。我提到的 Kotlin 技巧是&位运算符,不要与我们一直与if关键字一起使用的逻辑&&运算符混淆。

&位运算符用于检查两个值中的每个对应部分是否为真。这是在使用ACTION_MASKaction时所需的过滤器。

注意

理智检查:我不愿详细介绍MotionEvent和位运算。完全可以完成整本书的编写,甚至制作出专业质量的交互式应用,而无需完全理解它们。如果你知道我们将在下一节中编写的代码行确定了玩家触发的事件类型,那么这就是你需要知道的全部。我只是认为像你这样有洞察力的读者可能想了解系统的方方面面。总之,如果你理解位运算,那太好了;你可以继续。如果你不理解,也没关系;你仍然可以继续。如果你对位运算感兴趣(有很多),你可以在en.wikipedia.org/wiki/Bitwise_operation上阅读更多关于它们的内容。

现在我们可以编写onTouchEvent函数,并查看所有MotionEvent的相关内容。

编写onTouchEvent函数

通过在onTouchEvent函数中添加以下片段中的突出显示代码来响应用户在屏幕上移动手指:

// User moved a finger while touching screen
if (motionEvent.action and MotionEvent.
 ACTION_MASK == 
 MotionEvent.ACTION_MOVE) {

 particleSystems[nextSystem].emitParticles(
 PointF(motionEvent.x,
 motionEvent.y))

 nextSystem++
 if (nextSystem == maxSystems) {
 nextSystem = 0
 }
}

return true

if条件检查是否事件类型是用户移动手指。如果是,则调用particleSystems中的下一个粒子系统的emitParticles函数。然后,增加nextSystem变量,并进行测试,看它是否是最后一个粒子系统。如果是,则将nextSystem设置为零,准备在下次需要时重新使用现有的粒子系统。

我们可以继续让系统响应用户按下按钮,通过在下面的片段中添加高亮显示的代码,紧接着我们刚刚讨论过的代码之后,在我们已经编码的return语句之前:

// Did the user touch the screen
if (motionEvent.action and MotionEvent.ACTION_MASK ==
 MotionEvent.ACTION_DOWN) {

 // User pressed the screen so let's 
 // see if it was in the reset button
 if (resetButton.contains(motionEvent.x,
 motionEvent.y)) {

 // Clear the screen of all particles
 nextSystem = 0
 }

 // User pressed the screen so let's 
 // see if it was in the toggle button
 if (togglePauseButton.contains(motionEvent.x,
 motionEvent.y)) {

 paused = !paused
 }
}

return true

if语句的条件检查是否用户已经点击了屏幕。如果是,则RectF类的contains函数与xy一起使用,以查看该按压是否在我们的自定义按钮之一内。如果按下了重置按钮,则当nextSystem设置为零时,所有粒子将消失。如果按下了暂停按钮,则切换paused的值,导致在线程内停止/开始调用update函数。

完成 HUD

编辑printDebuggingText函数中的代码,使其显示如下:

canvas.drawText("Systems: $nextSystem",
         10f, (fontMargin + debugStart + 
         debugSize * 2).toFloat(), paint)

canvas.drawText("Particles: ${nextSystem * 
         particlesPerSystem}",
         10f, (fontMargin + debugStart 
         + debugSize * 3).toFloat(), paint)

前面的代码将在屏幕上打印一些有趣的统计数据,告诉我们当前正在绘制多少粒子和系统。

运行应用程序

现在我们可以看到实时绘图应用程序的运行情况,并尝试一些我们在代码中留下注释的不同选项。

以小、圆、多彩、快速的粒子运行应用程序。下面的屏幕截图显示了屏幕上已经被点击了几次:

运行应用程序

然后恢复绘图,如下面的屏幕截图所示:

运行应用程序

制作一个儿童风格的绘图,粒子小、白色、方形、缓慢、持续时间长,如下面的屏幕截图所示:

运行应用程序

然后恢复绘图,并等待 20 秒,让绘图活跃起来并发生变化:

运行应用程序

摘要

在本章中,我们学*了如何将成千上万个独立的实体添加到我们的实时系统中。这些实体由ParticleSystem类控制,而ParticleSystem类又与游戏循环进行交互和控制。由于游戏循环在一个线程中运行,我们了解到用户仍然可以无缝地与屏幕进行交互,操作系统将通过onTouchEvent函数向我们发送这些交互的详细信息。

在下一章中,当我们探索如何播放音效时,我们的应用程序最终会变得有些喧闹。

第二十三章:Android 声音效果和 Spinner 小部件

在本章中,我们将学*SoundPool类以及我们可以根据是否只想播放声音或进一步跟踪我们正在播放的声音的不同方式。然后,我们将把我们学到的一切付诸实践,制作一个很酷的声音演示应用程序,这也将向我们介绍一个新的 UI 小部件:spinner

在本章中,我们将做以下事情:

  • 学*如何使用 Android 的SoundPool

  • 使用SpinnerView编写基于声音的应用程序

让我们开始吧。

SoundPool 类

SoundPool类允许我们持有和操作一组声音效果:字面上就是一组声音。该类处理从解压缩声音文件(如.wav.ogg文件)到通过整数 ID 保持标识引用,以及当然,播放声音的一切。当声音播放时,它以非阻塞的方式播放(在后台使用线程),不会干扰我们应用程序的流畅运行或用户与应用程序的交互。

我们需要做的第一件事是将声音效果添加到游戏项目的main文件夹中名为assets的文件夹中。我们很快就会做到这一点。

接下来,在我们的 Kotlin 代码中,我们声明了SoundPool类型的对象和每个我们打算使用的声音效果的Int标识符,如下面的代码所示。我们还将声明另一个名为nowPlayingInt,我们可以用它来跟踪当前正在播放的声音;我们很快就会看到我们如何做到这一点:

var sp: SoundPool
var idFX1 = -1
nowPlaying = -1
volume = .1f

现在,我们将看一下初始化SoundPool的方式。

初始化 SoundPool

我们将使用AudioAttributes对象来设置我们想要的声音池的属性。

第一个代码块使用链接,并在一个对象上调用了四个单独的函数,初始化了我们的AudioAttributes对象(audioAttributes),如下面的代码所示:

val audioAttributes = AudioAttributes.Builder()
         .setUsage(AudioAttributes.
                     USAGE_ASSISTANCE_SONIFICATION)
         .setContentType(AudioAttributes.
                     CONTENT_TYPE_SONIFICATION)
         .build()

sp = SoundPool.Builder()
         .setMaxStreams(5)
         .setAudioAttributes(audioAttributes)
         .build()

在上面的代码中,我们使用了此类的Builder函数来初始化一个AudioAttributes实例,让它知道它将用于USAGE_ASSISTANCE_SONIFICATION的用户界面交互。

我们还使用了CONTENT_TYPE_SONIFICATION,让该类知道它是用于响应声音,例如按钮点击,碰撞或类似的声音。

现在,我们可以通过传入AudioAttributes对象(audioAttributes)和我们可能想要播放的同时声音的最大数量来初始化SoundPoolsp)本身。

第二个代码块将另外四个函数链接到sp的初始化中,包括调用setAudioAttributes,该函数使用我们在前面链接函数块中初始化的audioAttributes对象。

现在,我们可以继续加载(解压缩)声音文件到我们的SoundPool中。

将声音文件加载到内存中

与我们的线程控制一样,我们需要将我们的代码包装在try-catch块中。这是有道理的,因为读取文件可能因我们无法控制的原因而失败,但我们也这样做是因为我们被迫这样做,因为我们使用的函数会抛出异常,否则我们编写的代码将无法编译。

try块内,我们声明并初始化了AssetManagerAssetFileDescriptor类型的对象。

AssetFileDescriptor是通过使用AssetManager对象的openFd函数来初始化的,该函数解压缩声音文件。然后,我们初始化我们的 ID(idFX1),同时将AssetFileDescriptor实例的内容加载到我们的SoundPool中。

catch块只是简单地在控制台输出一条消息,让我们知道是否出了问题,如下面的代码所示:

try {
    // Create objects of the 2 required classes
    val assetManager = this.assets
    var descriptor: AssetFileDescriptor

    // Load our fx in memory ready for use
    descriptor = assetManager.openFd("fx1.ogg")
    idFX1 = sp.load(descriptor, 0)

}  catch (e: IOException) {
    // Print an error message to the console
    Log.e("error", "failed to load sound files")
}

现在,我们准备制造一些噪音。

播放声音

此时,我们的SoundPool中有一个音效,并且我们有一个 ID 可以用来引用它。

这是我们播放声音的方式。请注意,在下面的代码行中,我们使用相同的函数的返回值初始化nowPlaying变量。因此,以下代码同时播放声音并将正在播放的 ID 的值加载到nowPlaying中:

nowPlaying = sp.play(idFX2,
  volume, volume, 0, repeats, 1f)

提示

不需要将 ID 存储在nowPlaying中以播放声音,但是它有其用途,我们现在将看到。

play函数的参数从左到右如下:

  • 声音效果的 ID

  • 左右扬声器音量

  • 可能正在播放/已播放的其他声音的优先级

  • 声音重复的次数

  • 播放速率/速度(1 为正常速率)

在我们制作声音演示应用程序之前,还有一件事情需要讨论。

停止声音

当仍在播放时,使用stop函数停止声音也非常容易,如下面的代码所示。请注意,可能会有多个音效在任何给定时间播放,因此stop函数需要您想要停止的音效的 ID:

sp.stop(nowPlaying)

当您调用play时,如果您想要跟踪它以便以后与它交互,您只需要存储当前播放声音的 ID。现在,我们可以制作声音演示应用程序。

声音演示应用程序介绍 Spinner 小部件

当然,谈到音效,我们需要一些实际的声音文件。您可以使用 BFXR 制作自己的声音文件(如下一节所述)或使用提供的声音文件。该应用程序的音效包含在下载包中,并且可以在Chapter23/Sound Demo文件夹的assets文件夹中找到。

制作音效

有一个名为 BFXR 的开源应用程序,允许我们制作自己的音效。以下是使用 BFXR 制作自己的音效的快速指南。从www.bfxr.net免费获取一份副本。

提示

请注意,声音演示应用程序的音效包含在Chapter23/assets文件夹中。除非您愿意,否则您不必创*自己的音效,但是学*如何使用它仍然是值得的。

按照网站上的简单说明进行设置。尝试一些这样的事情来制作酷炫的音效:

提示

这是一个非常简化的教程。您可以使用 BFXR 做很多事情。要了解更多,请阅读我们之前提到的网站上的提示。

  1. 运行bfxr。您应该会看到一个类似于下面屏幕截图所示的屏幕:制作音效

  2. 尝试所有生成该类型随机声音的预设类型,如下面的屏幕截图所示。当您有一个接近您想要的声音时,继续下一步:制作音效

  3. 使用滑块微调音高、持续时间和其他方面的新声音,如下面的屏幕截图所示:制作音效

  4. 通过单击导出 Wav按钮保存您的声音,如下面的屏幕截图所示。尽管这个按钮的文本,正如我们将看到的,我们也可以保存为.wav以外的格式:制作音效

  5. Android 与 OGG 格式的声音非常兼容,因此在要求命名文件时,请在文件名的末尾使用.ogg扩展名。

  6. 重复步骤 2 到 5 以创*三个酷炫的音效。将它们命名为fx1.oggfx2.oggfx3.ogg。我们使用.ogg文件格式,因为它比 WAV 等格式更压缩。

当您的声音文件准备好后,我们可以继续进行应用程序。

布置声音演示 UI

我将比之前的项目更简要地描述我们正在适应的项目的部分。但是,每当有新概念时,我一定会详细解释。我想现在您应该可以轻松地将一些小部件拖放到ConstraintLayout上并更改它们的text属性。

完成以下步骤,如果遇到任何问题,您可以复制或查看下载包的Chapter23/Sound Demo文件夹中的代码:

  1. 创*一个新项目,称其为Sound Demo,选择基本活动,并在最低 API 级别选项上选择API 21:Android 5.0(棒棒糖),但将所有其他设置保持默认,并删除Hello world! TextView

  2. 按照从上到下,然后从左到右的顺序,从容器类别中拖动一个下拉列表,从小部件类别中拖动一个SeekBar (离散),并从调色板上拖动四个按钮到布局上,同时排列和调整它们的大小,并设置它们的text属性,如下图所示:布局声音演示 UI

  3. 点击推断约束按钮。

  4. 使用以下表格设置它们的属性:

Widget 要更改的属性 要设置的值
Spinner id spinner
下拉列表 spinnerMode dropdown
Spinner 条目 @array/spinner_options
SeekBar id seekBar
SeekBar max 10
按钮(FX 1 id btnFX1
按钮(FX 2 id btnFX2
按钮(FX 3 id btnFX3
按钮(STOP id btnStop
  1. 接下来,将以下突出显示的代码添加到values文件夹中的strings.xml文件中。我们在上一步中使用了这个名为spinner_options的字符串资源数组,用于options属性。它将代表可以从我们的下拉列表中选择的选项:
   <resources>
       <string name="app_name">Sound Demo</string>

       <string name="hello_world">Hello world!</string>
       <string name="action_settings">Settings</string>

       <string-array name="spinner_options">
         <item>0</item>
         <item>1</item>
         <item>3</item>
         <item>5</item>
         <item>10</item>
       </string-array>

    </resources>

现在运行应用程序,最初你不会看到任何之前没有看到的东西。但是,如果你点击下拉列表,你将看到我们称为spinner_options的字符串数组中的选项。我们将使用下拉列表来控制播放时音效重复的次数,如下图所示:

布局声音演示 UI

让我们编写 Kotlin 代码,使这个应用程序工作,包括我们如何与我们的下拉列表交互。

使用您操作系统的文件浏览器,转到项目的app\src\main文件夹,并添加一个名为assets的新文件夹。

在下载包的Chapter23/Sound Demo/assets文件夹中为您准备了三个音频文件。将这三个文件放入您刚创*的assets目录中,或者使用您自己创*的文件。重要的是它们的文件名必须是fx1.oggfx2.oggfx3.ogg

编写声音演示

首先,我们将更改类声明,以便我们可以高效地处理所有小部件的交互。编辑声明以实现View.OnClickListener,如下面的代码中所突出显示的那样:

class MainActivity : AppCompatActivity(), 
  View.OnClickListener {

我们将很快添加所需的onClick函数。

现在,我们将为我们的SoundPool实例、音效 ID 和nowPlaying Int属性添加一些属性,正如我们之前讨论的,我们还将添加一个Float来保存设备当前音量基础上的 0(静音)到 1(最大音量)之间的音量值。我们还将添加一个名为repeatsInt属性,它意料之中地保存我们将重复给定音效的次数的值:

var sp: SoundPool   

private var idFX1 = -1
private var idFX2 = -1
private var idFX3 = -1

var nowPlaying = -1
var volume = .1f
var repeats = 2

init{

  val audioAttributes = AudioAttributes.Builder()
        .setUsage(AudioAttributes.
              USAGE_ASSISTANCE_SONIFICATION)
        .setContentType(AudioAttributes.
              CONTENT_TYPE_SONIFICATION)
        .build()

  sp = SoundPool.Builder()
        .setMaxStreams(5)
        .setAudioAttributes(audioAttributes)
        .build()
}

在前面的代码中,我们还添加了一个init块,我们在其中初始化了我们的SoundPool实例。

提示

使用您喜欢的方法添加以下import语句,以使前面的代码工作:

import android.media.AudioAttributes
import android.media.AudioManager
import android.media.SoundPool
import android.os.Build

import android.view.View
import android.widget.Button

现在,在onCreate函数中,我们可以像往常一样为我们的按钮设置点击监听器,如下所示:

btnFX1.setOnClickListener(this)
btnFX2.setOnClickListener(this)
btnFX3.setOnClickListener(this)
btnStop.setOnClickListener(this)

提示

确保添加以下import以使前面的代码工作:

import kotlinx.android.synthetic.main.content_main.*

接下来,我们依次加载我们的每个音效,并用与我们加载到SoundPool中的相关音效匹配的值初始化我们的 ID。整个过程都包裹在try-catch块中,如下面的代码所示,根据需要:

try {
    // Create objects of the 2 required classes
    val assetManager = this.assets
    var descriptor: AssetFileDescriptor

    // Load our fx in memory ready for use
    descriptor = assetManager.openFd("fx1.ogg")
    idFX1 = sp.load(descriptor, 0)

    descriptor = assetManager.openFd("fx2.ogg")
    idFX2 = sp.load(descriptor, 0)

    descriptor = assetManager.openFd("fx3.ogg")
    idFX3 = sp.load(descriptor, 0)

}   catch (e: IOException) {
    // Print an error message to the console
    Log.e("error", "failed to load sound files")
}

提示

使用您喜欢的方法添加以下import语句,以使前面的代码工作:

import android.content.res.AssetFileDescriptor
import android.content.res.AssetManager
import android.util.Log
import java.io.IOException

接下来,我们将看看如何处理SeekBar。正如您可能已经期待的那样,我们将使用 lambda。我们将使用OnSeekBarChangeListener并重写onProgressChangedonStartTrackingTouchonStopTrackingTouch函数。

我们只需要向onProgressChanged函数添加代码。在这个函数中,我们只需更改volume变量的值,然后在我们的SoundPool对象上使用setVolume函数,传入当前播放的声音效果以及左右声道的音量,如下面的代码所示:

seekBar.setOnSeekBarChangeListener(
         object : SeekBar.OnSeekBarChangeListener {

   override fun onProgressChanged(
         seekBar: SeekBar, value: Int, fromUser: Boolean) {

         volume = value / 10f
         sp.setVolume(nowPlaying, volume, volume)
  }

   override fun onStartTrackingTouch(seekBar: SeekBar) {}

   override fun onStopTrackingTouch(seekBar: SeekBar) {

  }
})

提示

使用您喜欢的方法为先前的代码添加以下import语句:

import android.widget.SeekBar

SeekBar之后是Spinner和另一个处理用户交互的 lambda。我们将使用AdapterView.OnItemSelectedListener来重写onItemSelectedonNothingSelected函数。

我们所有的代码都放在onItemSelected函数中,它创*了一个临时的名为tempString,然后使用Integer.ValueOf函数将String转换为Int,我们可以用它来初始化repeats属性,如下面的代码所示:

 spinner.onItemSelectedListener =
         object : AdapterView.OnItemSelectedListener {

   override fun onItemSelected(
         parentView: AdapterView<*>,
         selectedItemView: View,
         position: Int, id: Long) {

         val temp = spinner.selectedItem.toString()
         repeats = Integer.valueOf(temp)
  }

   override fun onNothingSelected(
         parentView: AdapterView<*>) {
  }
}

提示

使用您喜欢的方法将以下import语句添加到先前的代码中:

import android.widget.AdapterView
import android.widget.Spinner

这就是onCreate函数的所有内容。

现在,实现onClick函数,这是必需的,因为这个类实现了View.OnClickListener接口。非常简单,每个按钮都有一个when选项。请注意,对play的每次调用的返回值都存储在nowPlaying中。当用户按下STOP按钮时,我们只需使用nowPlaying的当前值调用stop,导致最近启动的声音效果停止,如下面的代码所示:

 override fun onClick(v: View) {
   when (v.id) {
         R.id.btnFX1 -> {
               sp.stop(nowPlaying)
               nowPlaying = sp.play(idFX1, volume,
                           volume, 0, repeats, 1f)
    }

         R.id.btnFX2 -> {
               sp.stop(nowPlaying)
               nowPlaying = sp.play(idFX2,
                           volume, volume, 0, repeats, 1f)
    }

         R.id.btnFX3 -> {
               sp.stop(nowPlaying)
               nowPlaying = sp.play(idFX3,
                           volume, volume, 0, repeats, 1f)
    }

         R.id.btnStop -> sp.stop(nowPlaying)
   }
}

现在我们可以运行应用程序。如果听不到任何声音,请确保设备的音量已调高。

单击适当的按钮以播放所需的声音效果。更改音量和重复播放次数,当然,尝试使用STOP按钮停止它。

还要注意,当一个声音效果正在播放时,您可以重复点击多个播放按钮,声音将同时播放,直到我们设置的最大流数(五)。

总结

在本章中,我们仔细研究了如何使用SoundPool,并利用了所有这些知识来完成声音演示应用程序。

在下一章中,我们将学*如何使我们的应用程序与多个不同的布局配合工作。

第二十四章:设计模式、多个布局和片段

我们已经走了很长的路,从最开始设置 Android Studio 的时候。那时,我们一步一步地进行了一切,但随着我们的进展,我们试图向你展示的不仅仅是如何将x添加到y或将特性 A 添加到应用程序 B,而是让你能够以自己的方式使用所学的知识,以便将自己的想法变为现实。

这一章更加关注你未来的应用程序,而不是这本书中迄今为止的任何其他章节。我们将看一下 Kotlin 和 Android 的一些特性,你可以将其用作框架或模板,以制作更加令人兴奋和复杂的应用程序,同时保持代码的可管理性。此外,我将*议进一步学*的领域,这些领域在本书中几乎没有涉及,因为它的范围有限。

在本章中,我们将学*以下内容:

  • 模式和模型-视图-控制器

  • Android 设计指南

  • 开始真实世界设计和处理多个不同设备

  • 片段简介

让我们开始吧。

介绍模型-视图-控制器模式

短语模型、视图控制器反映了我们应用程序的不同部分分为不同的部分,称为。Android 应用程序通常使用模型-视图-控制器模式。模式只是一种公认的结构代码和其他应用程序资源的方式,例如布局文件、图像和数据库。

模式对我们很有用,因为通过遵循模式,我们可以更有信心地做正确的事情,并且不太可能因为将自己编码到尴尬的境地而不得不撤销大量的辛苦工作。

计算机科学中有许多模式,但只要理解 MVC 模式就足以创*一些专业构*的 Android 应用程序。

我们已经部分使用了 MVC,所以让我们依次看看这三个层。

模型

模型指的是驱动我们应用程序的数据以及专门管理它并使其可用于其他层的逻辑/代码。例如,在我们的自我备忘录应用程序中,Note类及其 JSON 代码就是数据和逻辑。

视图

自我备忘录应用程序的视图是所有不同布局中的所有小部件。用户在屏幕上可以看到或与之交互的任何内容通常都是视图的一部分。你可能还记得小部件来自 Android API 的View类层次结构。

控制器

控制器是视图和模型之间的部分。它与两者交互并使它们分开。它包含所谓的应用逻辑。如果用户点击按钮,应用程序层决定如何处理它。当用户点击确定以添加新的备忘录时,应用程序层会监听视图层上的交互。它捕获视图中包含的数据,并将其传递给模型层。

提示

设计模式是一个庞大的主题。有许多不同的设计模式,如果你想对这个主题有一个友好的入门,我会推荐Head First Design Patterns。即使这本书的例子是用另一种语言 Java 描述的,它对你仍然非常有用。如果你想真正深入设计模式的世界,那么你可以尝试Design Patterns: Elements of Reusable Object-Oriented Software,它被认为是一种设计模式的权威,但阅读起来要困难得多。

随着本书的进展,我们还将开始利用我们已经讨论过但迄今为止尚未充分利用的面向对象编程特性。我们将逐步做到这一点。

Android 设计指南

应用程序设计是一个广阔的主题——如此广阔,以至于只能在专门致力于该主题的书中开始教授。而且,就像编程一样,只有通过不断的练*、复*和改进,才能开始擅长应用程序设计。

那么,我所说的设计到底是什么?我说的是屏幕上放置小部件的位置,使用哪些小部件,它们应该是什么颜色,多大,如何在屏幕之间过渡,滚动页面的最佳方式,何时以及使用哪些动画插值器,你的应用应该分成哪些屏幕,以及更多其他方面。

这本书希望能让你有能力实现你选择的答案,以及更多其他问题的答案。不幸的是,它没有足够的空间,作者可能也没有技能来教你如何做出这些选择。

提示

你可能会想,“我该怎么办?”继续制作应用,不要让缺乏设计经验和知识阻止你!甚至将你的应用发布到应用商店。然而,请记住,还有一个完全不同的话题——设计——如果你的应用真的要成为世界级的话,这需要一些关注。

即使在中等规模的开发公司中,设计师很少也是程序员,即使是非常小的公司也经常外包他们的应用设计(或设计师可能外包编码)。

设计既是一门艺术,也是一门科学,Google 已经证明它认识到这一点,为现有和有抱负的设计师提供了高质量的支持。

提示

我强烈*议你访问并收藏网页developer.android.com/design/。它非常详细和全面,完全专注于 Android,并提供了大量的数字资源,如图像、调色板和指南。

将理解设计原则作为短期目标。将提高你的实际设计技能作为一个持续的任务。访问并阅读以设计为重点的网站,并尝试实现你发现令人兴奋的想法。

然而,最重要的是,不要等到你成为设计专家才开始制作应用。继续将你的想法付诸实践并发布它们。要求每个应用的设计都比上一个稍微好一点。

我们将在接下来的章节中看到,而且已经在一定程度上看到,Android API 为我们提供了一整套超时尚的 UI,我们可以用非常少的代码或设计技能来利用这些 UI。这些 UI 在很大程度上使你的应用看起来像是由专业人员设计的。

真实世界的应用

到目前为止,我们已经构*了十几个或更多不同复杂度的应用。大多数是在手机上设计和测试的。

当然,在现实世界中,我们的应用需要在任何设备上都能良好运行,并且必须能够处理在横向或纵向视图(在所有设备上)时发生的情况。

此外,我们的应用通常不能只是在不同设备上正常工作并看起来“还行”。通常情况下,我们的应用需要根据设备是手机、*板还是横向/纵向方向,以不同的方式运行并呈现出显著不同的 UI。

注意

Android 支持大屏电视、智能手表、虚拟和增强现实以及物联网的应用。本书不涉及后两个方面,但作者希望在书的结尾,你将有足够的准备去涉足这些话题。

看一下 BBC 新闻应用在 Android 手机上纵向运行的屏幕截图。看基本布局,但也注意新闻的类别(头条新闻世界英国)都是可见的,并允许用户滚动查看更多类别或在每个类别的故事之间左右滑动:

真实世界的应用

我们将在下一章中看到如何使用ImagePagerFragmentPager类实现滑动/分页 UI,但在此之前,我们需要了解更多的基础知识,我们将在本章中探讨。目前,上一个截图的目的不是向您展示特定的 UI 功能,而是让您将其与以下截图进行比较。看看在*板电脑上横向方向上运行的完全相同的应用程序:

真实世界的应用程序

请注意,故事(数据层)是相同的,但布局(视图层)却非常不同。用户不仅可以从应用程序顶部的选项卡菜单中选择类别,还可以通过“添加主题”选项添加自己的选项卡。

再次,这张图片的重点不是向您展示特定的 UI,甚至不是我们如何实现类似的 UI,而是它们是如此不同,以至于它们很容易被误认为是完全不同的应用程序。

Android 允许我们设计真实世界的应用程序,不仅布局因设备类型/方向/大小而异,行为也是如此,即应用程序层。Android 实现这一点的秘密武器是Fragment类。

注意

Google 说:

“片段代表活动中的行为或用户界面的一部分。您可以在单个活动中组合多个片段,构*多窗格 UI,并在多个活动中重用片段。”

您可以将片段视为活动的模块化部分,它具有自己的生命周期,接收自己的输入事件,并且您可以在活动运行时添加或删除它(有点像可以在不同活动中重用的“子活动”)。

“片段必须始终嵌入在活动中,并且片段的生命周期直接受到宿主活动生命周期的影响。”

我们可以在不同的 XML 文件中设计多个不同的布局,并很快就会这样做。我们还可以在代码中检测设备方向和屏幕分辨率,以便我们可以动态地对布局做出决策。

让我们尝试使用设备检测,然后我们将首次查看片段。

设备检测迷你应用

了解检测和响应设备及其不同属性(屏幕、方向等)的最佳方法是制作一个简单的应用程序。让我们通过以下步骤来做到这一点:

  1. 创*一个新的空活动项目,并将其命名为设备检测。将所有其他设置保留为默认设置。

  2. 设计选项卡中打开activity_main.xml文件,并删除默认的Hello world! TextView

  3. 将一个按钮拖放到屏幕顶部,并将其onClick属性设置为detectDevice。我们将在一分钟内编写此功能。

  4. 将两个TextView小部件拖放到布局中,一个放在另一个下面,并将它们的id属性分别设置为txtOrientationtxtResolution

  5. 检查您是否有一个类似以下截图的布局:

提示

我已经拉伸了我的小部件(主要是水*方向),并将textSize属性增加到24sp,以使它们在屏幕上更清晰,但这并不是应用程序正常工作所必需的。

设备检测迷你应用

  1. 单击推断约束按钮以确保 UI 元素的位置。

现在,我们将做一些新的事情:我们将专门为横向方向构*一个布局。

在 Android Studio 中,确保在编辑器中选择了activity_main.xml文件,并找到预览方向按钮,如下截图所示:

设备检测迷你应用

单击它,然后选择创*横向变化

现在,你有一个新的布局 XML 文件,名称相同,但是是横向布局。在编辑器中,布局看起来是空白的,但正如我们将看到的那样,情况并非如此。查看项目资源管理器中的layout文件夹,注意确实有两个名为activity_main的文件,其中一个(我们刚刚创*的新文件)以(land)结尾。如下截图所示:

设备检测迷你应用

选择这个新文件(以(land)结尾的文件),现在看组件树。如下截图所示:

设备检测迷你应用

看起来布局已经包含了所有我们的小部件,只是在设计视图中看不到它们。这种异常的原因是,当我们创*横向布局时,Android Studio 复制了纵向布局,包括所有约束。纵向约束很少与横向约束匹配。

要解决这个问题,点击删除所有约束按钮;它是推断约束按钮左边的按钮。现在 UI 没有约束了。我的界面是这样的:

设备检测迷你应用

布局有点混乱,但至少我们现在可以看到它。重新排列它使其看起来整洁。这是我重新排列的方式:

设备检测迷你应用

点击推断约束按钮以锁定布局在新位置。

现在我们已经为两种不同方向的基本布局,我们可以把注意力转向我们的 Kotlin 代码。

编写 MainActivity 类

我们已经有一个调用名为detectDevice的函数的机制,我们只需要编写这个演示应用的函数。在MainActivity类的onCreate函数之后,添加处理按钮点击并运行检测代码的函数,如下所示:

fun detectDevice(v: View) {
   // What is the orientation?
   val display = windowManager.defaultDisplay
   txtOrientation.text = "${display.rotation}"

   // What is the resolution?
   val xy = Point()
   display.getSize(xy)
   txtResolution.text = "x = ${xy.x} y = ${xy.y}"
}

提示

导入以下三个类:

import android.graphics.Point
import android.view.Display
import android.view.View

这段代码通过声明和初始化一个名为displayDisplay类型的对象来工作。这个对象(display)现在包含了关于设备特定显示属性的大量数据。

存储在rotation属性中的值将输出到顶部的TextView小部件中。

然后,代码初始化了一个名为xyPoint类型的对象。getSize函数将屏幕分辨率加载到xy中。然后将结果用于将水*(xy.x)和垂直(xy.y)分辨率输出到TextView中。

每次点击按钮,两个TextView小部件都将被更新。

解锁屏幕方向

在运行应用之前,我们要确保设备没有被锁定在纵向模式(大多数新手机默认是这样)。从模拟器的应用抽屉(或者你将要使用的设备)中,点击设置应用,选择显示,然后使用开关将自动旋转屏幕设置为开启。我在下图中展示了这个设置:

解锁屏幕方向

运行应用

现在,你可以运行应用并点击按钮,如下图所示:

运行应用

使用模拟器控制面板上的旋转按钮之一将设备旋转到横向,如下截图所示:

运行应用

提示

你也可以在 PC 上使用CTRL + F11,或者在 macOS 设备上使用CTRL + FN + F11

现在,再次点击按钮,你将看到横向布局的效果,如下图所示:

运行应用

你可能会注意到的第一件事是,当你旋转屏幕时,屏幕会短暂变空白。这是活动重新启动并再次执行onCreate。这正是我们需要的。它在横向布局上调用setContentViewMainActivity中的代码引用具有相同 ID 的小部件,因此完全相同的代码可以工作。

注意

暂时考虑一下,如果我们需要在两个方向之间需要不同的行为和布局,我们该如何处理。不要花太长时间思考这个问题,因为我们将在本章后面讨论这个问题。

如果01的结果对您来说不太明显,它们指的是Surface类的public const变量,其中Surface.ROTATION_0等于零,Surface.ROTATION_180等于一。

注意

请注意,如果您将屏幕向左旋转,那么您的值将是1,与我的相同,但如果您将其向右旋转,您将看到值为3。如果您将设备旋转到纵向模式(倒置),您将得到值4

我们可以使用when块并根据这些检测测试的结果执行不同的代码并加载不同的布局。但正如我们刚才看到的,Android 使事情变得比这更简单,它允许我们将特定布局添加到具有配置限定符的文件夹中,比如land

配置限定符

我们已经在第三章中看到了配置限定符,比如layout-largelayout-xhdpi探索 Android Studio 和项目结构。在这里,我们将刷新并扩展对它们的理解。

我们可以通过使用配置限定符来减轻我们对控制器层的依赖,以影响应用程序布局。有关大小、方向和像素密度的配置限定符。要利用配置限定符,我们只需按照通常的方式设计一个针对我们首选配置进行优化的布局,然后将该布局放入 Android 识别为特定配置的文件夹中。

例如,在先前的应用程序中,将布局放在land文件夹中告诉 Android 在设备处于横向方向时使用该布局。

前面的陈述可能显得有些模糊。这是因为 Android Studio 项目资源管理器窗口显示了一个文件和文件夹结构,它并不完全对应现实——它试图简化事情并“帮助”我们。如果您从项目资源管理器窗口顶部的下拉列表中选择项目文件选项,然后检查项目的内容,您确实会看到有一个布局和layout-land文件夹,如下面的屏幕截图所示:

配置限定符

切换回Android布局或保持在项目文件视图上,以您喜欢的方式。

如果我们想要横向和纵向有不同的布局,我们可以在res文件夹中创*一个名为layout-land的文件夹(或者使用我们在先前应用程序中使用的快捷方式),并在其中放置我们专门设计的布局。

当设备处于纵向方向时,将使用layout文件夹中的常规布局,当设备处于横向方向时,将使用layout-land文件夹中的布局。

如果我们要为不同尺寸的屏幕设计,我们将布局放入以下名称的文件夹中:

  • layout-small

  • layout-normal

  • layout-large

  • layout-xlarge

如果我们要为不同像素密度的屏幕设计,我们可以将 XML 布局放入名称为这些的文件夹中:

  • layout-ldpi 用于低 DPI 设备

  • layout-mdpi 用于中等 DPI 设备

  • layout-hdpi 用于高 DPI 设备

  • layout-xhdpi 用于超高 DPI 设备

  • layout-xxhdpi 用于超超高 DPI 设备

  • layout-xxxhdpi 用于超超超高 DPI 设备

  • layout-nodpi 用于其他情况下未考虑的 DPI 设备

  • layout-tvdpi 用于电视

低、高或超高 DPI 等的具体资格可以在以下信息框中的链接中找到。这里要说明的是布局存储的位置。

值得一提的是,我们刚刚讨论的远远不是关于配置限定符的整个故事,就像设计一样,值得将其列入进一步学*的清单。

注意

正如经常发生的那样,Android 开发者网站上有大量关于处理不同设备布局的详细信息。请访问developer.android.com/guide/practices/screens_support获取更多信息。

配置限定符的限制

以前的应用程序和我们对配置限定符的讨论向我们展示了在许多情况下肯定非常有用。然而,不幸的是,配置限定符和在代码中检测属性只解决了我们 MVC 模式的视图层中的问题。

正如我们讨论过的,我们的应用程序有时需要具有不同的行为,以及布局。这可能意味着我们的 Kotlin 代码在控制器层(在我们以前的应用程序中是MainActivity)中可能有多个分支,并且可能召唤出对每种不同情况具体代码的巨大的ifwhen块的可怕愿景。

幸运的是,这并不是这样做的方式。对于这种情况——事实上,对于大多数应用程序——Android 都有片段

片段

片段很可能会成为您制作的几乎每个应用程序的基本组成部分。它们非常有用,有很多使用它们的理由,而且一旦您*惯了它们,它们就变得非常简单,几乎没有理由不使用它们。

片段是应用程序的可重用元素,就像任何类一样,但正如我们之前提到的,它们具有特殊功能,例如能够加载自己的视图/布局,以及它们自己的生命周期函数,这使它们非常适合实现我们在真实世界应用程序部分讨论的目标。

让我们深入了解片段,一次一个特性。

片段也有生命周期

我们可以通过覆盖适当的生命周期函数来设置和控制片段,就像我们对活动所做的那样。

onCreate 函数

onCreate函数中,我们可以初始化变量并几乎做所有我们通常在Activity onCreate函数中做的事情。这个例外是初始化我们的 UI。

onCreateView 函数

onCreateView函数中,我们将像其名称所示的那样,获取对我们任何 UI 小部件的引用,设置 lambda 以监听点击,以及更多,正如我们很快将看到的那样。

onAttach 和 onDetach 函数

onAttachonDetach函数在Fragment实例被投入使用/停止使用之前调用。

onStart,onPause 和 onStop 函数

onStartonPauseonStop函数中,我们可以执行某些操作,例如创*或删除对象或保存数据,就像我们在它们基于活动的对应函数中所做的那样。

还有其他片段生命周期函数,但我们已经了解足够开始使用片段了。如果您想学*片段生命周期的详细信息,可以在 Android 开发者网站上进行学*developer.android.com/guide/components/fragments

这都很好,但我们需要一种方法来首先创*我们的片段,并配置它们以响应这些函数。

使用 FragmentManager 管理片段

FragmentManager类是Activity类的一部分。我们使用它来初始化Fragment实例,将Fragment实例添加到布局中,并结束Fragment。我们在以前的“Note to self”应用程序中初始化FragmentDialog实例时曾简要看到FragmentManager

学* Android 很难不碰到Fragment类,就像学* Kotlin 很难不断碰到 OOP、类等一样。

以下代码片段中的突出显示的代码是提醒我们如何使用传递给弹出对话框的参数 FragmentManager(它已经是 Activity 类的一部分):

button.setOnClickListener {
   val myDialog = MyDialog()
   myDialog.show(supportFragmentManager, "123")
   // This calls onCreateDialog
   // Don't worry about the strange looking 123
   // We will find out about this in Chapter 18
}

当时,我要求您不要关心函数调用的参数。调用的第二个参数是 Fragment 的 ID。我们很快将看到如何更广泛地使用 FragmentManagerFragment ID。

FragmentManager 正是其名称所暗示的。这里重要的是,一个 Activity 只有一个 FragmentManager,但它可以管理多个 Fragment 实例。这正是我们需要的,以便在单个应用程序中具有多个行为和布局。

FragmentManager 还调用它负责的各个片段的各种生命周期函数。这与 Activity 的生命周期函数是不同的,后者是由 Android 调用的,但它也与 FragmentManager 密切相关,因为 FragmentManager 调用许多 Fragment 生命周期函数是作为对 Activity 生命周期函数的响应。通常情况下,我们不需要太担心它是何时以及如何做到这一点,只要我们在每种情况下做出适当的响应即可。

我们的第一个片段应用

让我们构*一个尽可能简单的片段,以便我们可以理解发生了什么,然后我们开始在各个地方生成真正有用的 Fragment 对象。

提示

我敦促所有读者去完成并构*这个项目。从一个文件跳到另一个文件,仅仅阅读说明就会使它看起来比实际复杂得多。当然,您可以从下载包中复制并粘贴代码,但也请按照步骤进行,并创*自己的项目和类。片段并不太难,但它们的实现,就像它们的名称所暗示的那样,有点分散。

使用 Empty Activity 模板创*一个名为 Simple Fragment 的新项目,并将其余设置保持默认。

请注意,有选项可以创*一个带有片段的项目,但是通过自己从头开始做事情,我们会学到更多。

切换到 activity_main.xml 并删除默认的 Hello world! TextView

现在,通过在 Component tree 窗口中左键单击选择根 ConstraintLayout,然后将其 id 属性更改为 fragmentHolder。现在我们将能够在我们的 Kotlin 代码中引用此布局,并且正如 id 属性所暗示的那样,我们将向其中添加一个片段。

现在,我们将创*一个布局,该布局将定义我们片段的外观。右键单击 layout 文件夹,然后选择 New | Layout resource file。在 File name: 字段中,键入 fragment_layout,然后左键单击 OK。我们刚刚创*了一个 LinearLayout 类型的新布局。

在布局的任何位置添加一个单独的 Button 小部件,并将其 id 属性设置为 button

现在我们有了一个供我们的片段使用的简单布局,让我们编写一些 Kotlin 代码来创*实际的片段。

请注意,您可以通过从调色板中简单地拖放一个 Fragment 实例来创*一个 Fragment 实例,但以这种方式做事情的灵活性和可控性要少得多,而灵活性和可控性是使用片段的重要好处,正如我们将在本章和接下来的三章中看到的那样。通过创*一个扩展 Fragment 的类,我们可以从中制作出许多片段。

在项目资源管理器中,右键单击包含 MainActivity 文件的文件夹。从上下文菜单中,创*一个名为 SimpleFragment 的新 Kotlin 类。

在我们的新 SimpleFragment 类中,将代码更改为继承自 Fragment。在输入代码时,将要求您选择要导入的特定 Fragment 类,如下面的屏幕截图所示:

我们的第一个片段应用

选择顶部选项(如前面的屏幕截图所示),即常规的 Fragment 类。

注意

我们将在这个类中需要以下所有的导入语句:

import android.app.Fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.Toast

此时代码如下所示:

class SimpleFragment: Fragment() {
}

现在,添加一个名为myStringString属性并初始化它,如下面的代码所示:

class SimpleFragment: Fragment() {
    val myString: String = "Hello from SimpleFragment"
}

在使用Fragment时,我们需要在onCreateView函数中处理布局。现在让我们重写它,学*如何设置视图并获取对我们的Button的引用。

将以下代码添加到SimpleFragment类中:

override fun onCreateView(
          inflater: LayoutInflater,
          container: ViewGroup?,
          savedInstanceState: Bundle?)
          : View? {

  val view = inflater.inflate(
              R.layout.fragment_layout,
              container,
              false)  

  return view
}

为了理解上一段代码,我们首先必须查看onCreateView的签名。请注意,在第一个实例中,签名说明它必须返回一个View类型的对象,如下面的代码所示:

…:View?

接下来,我们有三个参数。让我们先看前两个:

(inflater: LayoutInflater, container: ViewGroup?...

我们需要一个LayoutInflater,因为我们不能调用setContentView,因为Fragment没有提供这样的函数。在onCreateView的主体中,我们使用inflaterinflate函数来膨胀我们在fragment_layout.xml中包含的布局,并用结果初始化viewView类型的对象)。

我们在inflate函数中也使用了传入onCreateViewcontainer作为参数。container变量是对activity_main.xml中的布局的引用。

activity_main.xml是包含布局可能看起来很明显,但是正如我们将在本章后面看到的那样,ViewGroup container参数允许任何Activity任何布局成为我们的 fragment 的容器。这是非常灵活的,并且在很大程度上使我们的Fragment代码可重用。

我们传递给inflate的第三个参数是false,这意味着我们不希望我们的布局立即添加到包含的布局中。我们很快将从代码的另一个部分自己完成这个步骤。

onCreateView的第三个参数是Bundle savedInstanceState,它可以帮助我们维护我们的 fragment 持有的数据。

现在我们有了包含在view中的膨胀布局,我们可以使用它来从布局中获取对我们的Button小部件的引用并监听点击。

最后,我们将view用作调用代码的返回值,如下所示:

return view

现在,我们可以按照通常的方式为按钮添加 lambda 来监听点击。在onClick函数中,我们显示一个弹出的Toast消息,以演示一切都按预期工作。

将此代码添加到onCreateView中的return语句之前,如下面的代码所示:

val button = view.findViewById(R.id.button) as Button

button.setOnClickListener(
  {
         Toast.makeText(activity,
               myString, Toast.LENGTH_SHORT).show()
  }
)

提示

请注意,在makeText中使用的activity属性是对包含FragmentActivity的引用。这是为了显示Toast消息而需要的。

我们现在还不能运行我们的应用程序;它不会工作,因为还需要一步。我们需要创*一个SimpleFragment的实例并适当地初始化它。这就是FragmentManager将被介绍的地方。

以下代码使用ActivitysupportFragmentManager属性。它基于我们的SimpleFragment类创*一个新的Fragment,使用findFragmentByID函数,并传入将容纳它的布局(在Activity内部)的 ID。

将此代码添加到MainActivity.ktonCreate函数中,在调用setContentView之后:

// Create a new fragment using the manager
var frag = supportFragmentManager
         .findFragmentById(R.id.fragmentHolder)

// Check the fragment has not already been initialized
if (frag == null) {
   // Initialize the fragment based on our SimpleFragment
   frag = SimpleFragment()
         supportFragmentManager.beginTransaction()
               .add(R.id.fragmentHolder, frag)
               .commit()
}

现在运行应用程序,惊叹于我们可点击的按钮,它显示了一个使用Toast类的消息,并且创*它需要两个布局和两个完整的类:

我们的第一个 fragment 应用

如果你还记得在第二章中以这种方式做过,Kotlin, XML 和 UI 设计师,并且代码要少得多,那么很明显我们需要一个 fragment 现实检查来回答“为什么?”的问题!

Fragment 现实检查

那么,这个 fragment 到底对我们有什么作用呢?如果我们根本不去理会 fragment,我们的第一个 fragment 迷你应用程序的外观和功能将是一样的。

实际上,使用片段使整个事情变得更加复杂!为什么我们要这样做呢?

我们有点知道这个问题的答案;只是根据我们目前所见,它并不是特别清楚。我们知道一个片段或多个片段可以添加到活动的布局中。

我们知道一个片段不仅包含自己的布局(视图),还包含自己的代码(控制器),虽然由一个活动托管,但实际上是相对独立的。

我们的快速应用程序只显示了一个片段的操作,但我们可以有一个托管两个或更多片段的活动。然后我们在单个屏幕上有效地显示了两个几乎独立的控制器。这听起来可能很有用。

然而,最有用的是,当活动启动时,我们可以检测我们的应用程序运行的设备的属性,也许是手机或*板电脑,是纵向还是横向模式。然后我们可以使用这些信息来决定同时显示一个或两个片段。

这不仅帮助我们实现了我们在本章开头讨论的真实应用部分中讨论的功能,而且还允许我们在两种可能的情况下使用完全相同的片段代码!

这确实是片段的本质。我们通过将功能(控制器)和外观(视图)配对成一堆片段来创*一个完整的应用程序,我们可以以几乎不用担心的方式以不同的方式重复使用它们。

缺失的环节是,如果所有这些片段都是完全功能的独立控制器,那么我们需要更多地了解如何实现我们的模型层。

当然,可以预见到一些障碍,所以看一下以下经常问的问题。

经常问的问题

Q)如果我们只有一个ArrayList,就像我们在“Note to self”应用程序中一样,它将去哪里?我们如何在片段之间共享它(假设所有片段都需要访问相同的数据)?

A)我们可以使用一种更加优雅的解决方案来创*一个模型层(数据本身和维护数据的代码)。当我们探索NavigationDrawer时,我们将看到这一点第二十六章,“使用导航抽屉和片段的高级 UI”,以及 Android 数据库第二十七章,“Android 数据库”。

总结

现在我们对片段的用途有了广泛的了解,以及如何开始使用它们,我们可以开始深入了解它们的使用。在下一章中,我们将制作一些以不同方式使用多个片段的应用程序。

第二十五章:使用分页和滑动的高级 UI

分页是从一页到另一页的行为,在 Android 上,我们通过在屏幕上滑动手指来实现这一点。当前页面会根据手指的移动方向和速度进行过渡。这是一个有用和实用的应用程序导航方式,但也许更重要的是,它是一种极其令用户满意的视觉效果。此外,就像RecyclerView一样,我们可以选择性地仅加载当前页面所需的数据,也许还有前后页面的数据。

正如您所期望的那样,Android API 有一些简单的解决方案来实现分页。

在本章中,我们将学*以下内容:

  • 实现像照片库应用程序中可能找到的图像一样的分页和滑动

  • 使用基于Fragment的布局实现分页和滑动,为用户提供通过滑动浏览整个用户界面的可能性

首先,让我们看一个滑动的例子。

愤怒的小鸟经典滑动菜单

在这里,我们可以看到著名的愤怒的小鸟关卡选择菜单展示了滑动/分页的功能:

愤怒的小鸟经典滑动菜单

让我们构*两个分页应用程序:一个带有图像,一个带有Fragment实例。

构*图库/滑块应用程序

在 Android Studio 中创*一个名为Image Pager的新项目。使用空活动模板,并将其余设置保持默认。

这些图像位于下载包中的Chapter25/Image Pager/drawable文件夹中。以下图表显示它们在 Windows 资源管理器中的位置:

构*图库/滑块应用程序

将图像添加到项目资源管理器中的drawable文件夹中,当然,您也可以添加更有趣的图像,也许是您拍摄的一些照片。

实现布局

对于一个简单的图像分页应用程序,我们使用PagerAdapter类。我们可以将其视为像RecyclerApater一样用于图像,因为它将处理在ViewPager小部件中显示图像数组。这与RecyclerAdapter非常相似,后者处理在RecyclerView中显示ArrayList的内容。我们只需要重写适当的函数。

要使用PagerAdapter实现图像库,我们首先需要在主布局中添加一个ViewPager小部件。因此,您可以清楚地看到所需的内容;以下是activity_main.xml的实际 XML 代码。编辑layout_main.xml使其看起来完全像这样:

<RelativeLayout xmlns:android=
   "http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/pager"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

略微不寻常命名的类androidx.ViewPager.widget.ViewPager是在发布ViewPager之前的 Android 版本中提供此功能的类。

接下来,就像我们需要一个布局来表示列表项一样,我们需要一个布局来表示ViewPager小部件中的项目,这种情况下是一个图像。以通常的方式创*一个新的布局文件,并将其命名为pager_item.xml。它将包含一个带有id属性为imageViewImageView

使用可视化设计工具来实现这一点,或者将以下 XML 复制到pager_item.xml中:

<RelativeLayout xmlns:android=
   "http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/pager"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

现在,我们可以开始编写我们的PagerAdapter类。

编写 PagerAdapter 类

接下来,我们需要继承自PagerAdapter来处理图像。创*一个名为ImagePagerAdapter的新类,并使其继承自PagerAdapter。此时代码应该如下所示:

class ImagePagerAdapter: PagerAdapter() {
}

将以下导入添加到ImagePagerAdapter类的顶部。通常我们依靠使用快捷键Alt + Enter来添加导入。这次我们做法略有不同,因为 Android API 中有一些非常相似的类,它们不适合我们的目标。

将以下导入添加到ImagePagerAdapter类中:

import android.content.Context
import android.view.LayoutInflater
import android.view.Vie
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RelativeLayout

import androidx.viewpager.widget.PagerAdapter
import androidx.viewpager.widget.ViewPager

接下来,在类中添加一个构造函数,以便在创*实例时从MainActivity获取Context对象和一个Int数组(指向图像资源 ID):

class ImagePagerAdapter(
        var context: Context,
 private var images: IntArray)
        : PagerAdapter() {

}

现在,我们必须重写PagerAdapter的必需函数。在ImagePagerAdapter类的主体内,添加重写的getCount函数,它简单地返回数组中图像 ID 的数量。该函数是该类内部使用的:

override fun getCount(): Int {
   return images.size
}

现在,我们必须重写isViewFromObject函数,它根据当前View是否与作为参数传入的当前Object相同或关联来返回Boolean。再次强调,这是该类内部使用的函数。在上一个代码之后,添加以下重写函数:

override fun isViewFromObject(
         view: View, `object`: Any)
       : Boolean {
   return view === `object`
}

现在,我们必须重写instantiateItem函数,这是我们大部分关注的工作所在。首先,我们声明一个新的ImageView对象,然后初始化一个LayoutInflater。接下来,我们使用LayoutInflater从我们的pager_item.xml布局文件中声明和初始化一个新的View

在此之后,我们获取pager_item.xml布局内的ImageView的引用。现在,根据instantiateItem函数的position参数和images数组的适当 ID,我们可以将适当的图像添加为ImageView小部件的内容。

最后,我们使用addView将布局添加到PagerAdapter中,并从函数返回。

现在,添加我们刚刚讨论的代码:

override fun instantiateItem(
         container: ViewGroup,
         position: Int)
         : View {

  val image: ImageView
  val inflater: LayoutInflater =
        context.getSystemService(
        Context.LAYOUT_INFLATER_SERVICE)
        as LayoutInflater

  val itemView =
        inflater.inflate(
              R.layout.pager_item, container,
              false)

     // get reference to imageView in pager_item layout
     image = itemView.findViewById<View>(
           R.id.imageView) as ImageView

  // Set an image to the ImageView
  image.setImageResource(images[position])

  // Add pager_item layout as 
  // the current page to the ViewPager
  (container as ViewPager).addView(itemView)

  return itemView
}

我们必须重写的最后一个函数是destroyItem,当类需要根据position参数的值移除适当的项时,可以调用该函数。

在上一个代码之后,在ImagePagerAdapter类的闭合大括号之前添加destroyItem函数:

override fun destroyItem(
  container: ViewGroup, 
  position: Int, 
  `object`: Any) {

  // Remove pager_item layout from ViewPager
  (container as ViewPager).
        removeView(`object` as RelativeLayout)
}

正如我们在编写ImagePagerAdapter时所看到的,这里几乎没有什么。只是正确实现ImagePagerAdapter类用于在幕后顺利运行的重写函数。

现在,我们可以编写MainActivity类,它将使用ImagePagerAdapter

编写 MainActivity 类

最后,我们可以编写我们的MainActivity类。与ImagePagerAdapter类一样,为了清晰起见,在类声明之前手动添加以下导入语句,如下面的代码所示:

import android.view.View
import androidx.viewpager.widget.ViewPager
import androidx.viewpager.widget.PagerAdapter

所有代码都放在onCreate函数中。我们使用drawable-xhdpi文件夹中添加的每个图像来初始化我们的Int数组。

我们以通常的方式使用findViewByID函数初始化ViewPager小部件。我们还通过传递MainActivity的引用和images数组来初始化我们的ImagePagerAdapter实例,这是我们之前编写的构造函数所要求的。最后,我们使用setAdapter将适配器绑定到 pager。

onCreate函数编码为与以下代码完全相同的样式:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   setContentView(R.layout.activity_main)

   // Grab all the images and stuff them in our array
   val images: IntArray = intArrayOf(
         R.drawable.image1,
         R.drawable.image2,
         R.drawable.image3,
         R.drawable.image4,
         R.drawable.image5,
         R.drawable.image6)

  // get a reference to the ViewPager in the layout
  val viewPager: ViewPager =
        findViewById<View>(R.id.pager) as ViewPager

  // Initialize our adapter
  val adapter: PagerAdapter =
        ImagePagerAdapter(this, images)

  // Binds the Adapter to the ViewPager
  viewPager.adapter = adapter

}

现在,我们准备运行应用程序。

运行画廊应用程序

在这里,我们可以看到我们int数组中的第一个图像:

运行画廊应用程序

向左和向右滑动一点,看到图像*稳过渡的愉悦方式:

运行画廊应用程序

现在,我们将构*一个具有几乎相同功能的应用程序,只是 pager 中的每个页面将是Fragment实例,它可以具有常规Fragment可以具有的任何功能,因为它们是常规Fragments

在我们实现这之前,让我们学*一些有助于我们实现这一目标的 Kotlin 知识。

Kotlin 伴生对象

伴生对象在语法上类似于内部类,因为我们将其声明在一个常规类内部,但请注意我们将其称为对象,而不是类。这意味着它本身是一个实例,而不是一个实例的蓝图。这正是它的作用。当我们在一个类内部声明一个伴生对象时,它的属性和函数将被所有常规类的实例共享。当我们想要一组常规类共享一组相关数据时,它非常完美。我们将在下一个应用程序中看到伴生对象的作用,也将在倒数第二章的 Age 数据库应用程序中看到它的作用。

构*一个 Fragment Pager/滑块应用程序

我们可以将整个Fragment实例作为PagerAdapter中的页面。这是非常强大的,因为我们知道,Fragment实例可以具有大量的功能 - 甚至是一个完整的 UI。

为了保持代码简洁和直观,我们将在每个Fragment布局中添加一个TextView,以演示滑块的工作原理。然而,当我们看到如何轻松地获取对TextView的引用时,我们应该很容易地添加我们迄今为止学到的任何布局,然后让用户与之交互。

注意

在下一个项目中,我们将看到另一种显示多个Fragment实例的方法,NavigationView,并且我们将实际实现多个编码的Fragment实例。

我们将首先构*滑块的内容。在这种情况下,内容当然是Fragment的一个实例。我们将构*一个简单的名为SimpleFragment的类,和一个简单的名为fragment_layout的布局。

你可能会认为这意味着每个幻灯片在外观上都是相同的,但我们将使用在实例化时由FragmentManager传入的 ID 作为TextView的文本。这样,当我们翻转/滑动Fragment实例时,每个实例都是一个新的不同实例。

当我们看到从列表中加载Fragment实例的代码时,很容易设计完全不同的Fragment类,就像我们以前做过的那样,并且可以为一些或所有幻灯片使用这些不同的类。当然,这些类中的每一个也可以使用不同的布局。

编写 SimpleFragment 类

与 Image Pager 应用程序一样,很难确定 Android Studio 需要自动导入哪些类。我们使用这些类是因为它们彼此兼容,如果让 Android Studio *议导入哪些类,可能会出现错误。项目文件位于Chapter25/Fragment Pager文件夹中。

使用空活动模板创*一个名为Fragment Slider的新项目,并将所有设置保持默认设置。

现在,创*一个名为SimpleFragment的新类,继承自Fragment,并添加import语句,如下所示的代码:

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView

import androidx.fragment.app.Fragment

class SimpleFragment: Fragment() {
}

我们必须添加两个函数,第一个是newInstance,将包含在一个伴生对象中,我们将从MainActivity中调用它来设置并返回对Fragment的引用。以下代码创*了一个类的新实例,但它还将一个String放入Bundle对象中,最终将从onCreateView函数中读取。添加到Bundle中的String作为newInstance函数的唯一参数传入。

SimpleFragment类的伴生对象中添加newInstance函数,如下所示:

class SimpleFragment: Fragment() {
// Our companion object which
// we call to make a new Fragment

companion object {
   // Holds the fragment id passed in when created
   val messageID = "messageID"

   fun newInstance(message: String)
               : SimpleFragment {
        // Create the fragment
        val fragment = SimpleFragment()

        // Create a bundle for our message/id
        val bundle = Bundle(1)
        // Load up the Bundle
        bundle.putString(messageID, message)
        fragment.arguments = bundle
        return fragment
   }
}

我们的SimpleFragment类的最终函数需要覆盖onCreateView,在这里,我们将像往常一样获取传入的布局的引用,并将我们的fragment_layout XML 文件加载为布局。

然后,第一行代码使用getArguments.getString和键值对的MESSAGE标识符从Bundle中解包String

添加我们刚刚讨论过的onCreateView函数:

override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
         savedInstanceState: Bundle?)
         : View? {

  // Get the id from the Bundle
  val message = arguments!!.getString(messageID)

  // Inflate the view as normal
  val view = inflater.inflate(
              R.layout.fragment_layout,
              container,
              false)

  // Get a reference to textView
  val messageTextView = view
        .findViewById<View>(R.id.textView) 
              as TextView

  // Display the id in the TextView
  messageTextView.text = message

  // We could also handle any UI
  // of any complexity in the usual way
  // And we will over the next two chapters
  // ..
  // ..

  return view
}

让我们也为Fragment制作一个超级简单的布局,当然,它将包含我们刚刚使用的TextView

fragment_layout

fragment_layout是我们制作过的最简单的布局。右键单击layout文件夹,选择新* | 资源布局文件。将文件命名为fragment_layout,然后单击确定。现在,添加一个单独的TextView并将其id属性设置为textView

现在我们可以编写MainActivity类,它处理FragmentPager并使我们的SimpleFragment实例活起来。

编写 MainActivity 类

这个类由两个主要部分组成;首先,我们将对重写的onCreate函数进行更改,其次,我们将实现一个内部类及其重写的FragmentPagerAdapter函数。

首先,添加以下导入:

import java.util.ArrayList

import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.viewpager.widget.ViewPager

接下来,在onCreate函数中,我们创*一个Fragment实例的ArrayList,然后创*并添加三个SimpleFragment实例,传入一个数字标识符以打包到Bundle中。

然后,我们初始化SimpleFragmentPagerAdapter(我们很快将编写),传入我们的片段列表。

我们使用findViewByID获取对ViewPager的引用,并使用setAdapter将适配器绑定到它。

将以下代码添加到MainActivityonCreate函数中:

public override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)

  // Initialize a list of three fragments
  val fragmentList = ArrayList<Fragment>()

  // Add three new Fragments to the list
  fragmentList.add(SimpleFragment.newInstance("1"))
  fragmentList.add(SimpleFragment.newInstance("2"))
  fragmentList.add(SimpleFragment.newInstance("3"))

  val pageAdapter = SimpleFragmentPagerAdapter(
              supportFragmentManager, fragmentList)

  val pager = findViewById<View>(R.id.pager) as ViewPager
  pager.adapter = pageAdapter

}

现在,我们将添加我们的innerSimpleFragmentPagerAdapter。我们所做的就是在构造函数中添加一个Fragment实例的ArrayList,并用传入的列表进行初始化。

然后,我们重写getItemgetCount函数,这些函数在内部使用,方式与上一个项目中所做的方式相同。将我们刚讨论过的以下inner类添加到MainActivity类中:

private inner class SimpleFragmentPagerAdapter
   // A constructor to receive a fragment manager
   (fm: FragmentManager,
   // An ArrayList to hold our fragments
   private val fragments: ArrayList<Fragment>)
   : FragmentPagerAdapter(fm) {

   // Just two methods to override to get the current
   // position of the adapter and the size of the List
   override fun getItem(position: Int): Fragment {
          return this.fragments[position]
   }

  override fun getCount(): Int {
          return this.fragments.size
  }
}

我们需要做的最后一件事是为MainActivity添加布局。

activity_main 布局

通过复制以下代码来实现activity_main布局。它包含一个小部件,一个ViewPager,很重要的是它来自正确的层次结构,以便与我们在此项目中使用的其他类兼容。

修改我们刚刚讨论的layout_main.xml文件中的代码:

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

      android:layout_width="match_parent"
      android:layout_height="match_parent"  
      tools:context=".MainActivity">

      <androidx.viewpager.widget.ViewPager
      android:id="@+id/pager"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content" />

</RelativeLayout>

让我们看看我们的片段滑块在运行中的样子。

运行片段滑块应用程序

运行应用程序,然后您可以通过滑动左或右来浏览滑块中的片段。以下截图显示了当用户尝试在List中的最后一个Fragment之外滑动时,FragmentPagerAdapter产生的视觉效果:

运行片段滑块应用程序

摘要

在本章中,我们看到我们可以使用分页器来制作简单的图像库,或者通过整个 UI 的复杂页面进行滑动,尽管我们通过一个非常简单的TextView来演示这一点。

在下一章中,我们将看到另一个非常酷的 UI 元素,它在许多最新的 Android 应用程序中使用,可能是因为它看起来很棒,而且非常实用。让我们来看看NavigationView

第二十六章:使用导航抽屉和 Fragment 的高级 UI

在本章中,我们将看到(可以说是)最先进的 UI。NavigationView或导航抽屉(因为它滑出内容的方式),可以通过在创*新项目时选择它作为模板来简单创*。我们将这样做,然后我们将检查自动生成的代码并学*如何与其交互。然后,我们将使用我们对Fragment类的所有了解来填充每个“抽屉”具有不同行为和视图。然后,在下一章中,我们将学*数据库,为每个Fragment添加一些新功能。

在本章中,将涵盖以下主题:

  • 引入NavigationView小部件

  • 开始使用年龄数据库应用

  • 使用项目模板实现NavigationView

  • NavigationView添加多个Fragment实例和布局

让我们来看看这个非常酷的 UI 模式。

引入 NavigationView

NavigationView有什么好处?可能首先吸引您注意的是它可以看起来非常时尚。看看下面的截图,展示了 Google Play 应用中NavigationView的运行情况:

引入 NavigationView

老实说,从一开始,我们的应用不会像 Google Play 应用中的那样花哨。但是我们的应用中将具有相同的功能。

这个 UI 的另一个亮点是它在需要时滑动隐藏或显示自己的方式。正是因为这种行为,它可以是一个相当大的尺寸,使得它在添加选项时非常灵活,并且当用户完成后,它会完全消失,就像一个抽屉一样。

提示

如果您还没有尝试过,我*议现在尝试一下 Google Play 应用,看看它是如何工作的。

您可以从屏幕的左边缘滑动拇指或手指,抽屉将慢慢滑出。当然,您也可以向相反方向再次滑动它。

导航抽屉打开时,屏幕的其余部分会略微变暗(如前一个截图所示),帮助用户专注于提供的导航选项。

在抽屉打开时,您还可以点击抽屉之外的任何地方,它将自行滑开,为布局的其余部分留出整个屏幕。

也可以通过点击左上角的菜单图标打开抽屉。

我们还可以调整和完善导航抽屉的行为,这将在本章末尾看到。

检查年龄数据库应用

在本章中,我们将专注于创*NavigationView并用四个Fragment类及其各自的布局填充它。在下一章中,我们将学*并实现数据库功能。

这是我们NavigationView的全貌。请注意,当使用NavigationView活动模板时,默认情况下提供了许多选项和大部分外观和装饰:

检查年龄数据库应用

四个主要选项是我们将添加到 UI 中的。它们是InsertDeleteSearchResults。接下来将展示布局并描述它们的目的。

插入

第一个屏幕允许用户将一个人的姓名和相关年龄插入到数据库中:

插入

这个简单的布局有两个EditText小部件和一个按钮。用户将输入姓名和年龄,然后点击INSERT按钮将它们添加到数据库中。

删除

这个屏幕甚至更简单。用户将在EditText小部件中输入姓名,然后点击按钮:

删除

如果输入的姓名存在于数据库中,则该条目(姓名和年龄)将被删除。

搜索

这个布局与上一个布局大致相同,但目的不同:

搜索

用户将在EditText中输入姓名,然后点击按钮。如果数据库中存在该姓名,那么它将显示出来,并显示匹配的年龄。

结果

这个屏幕显示了整个数据库中的所有条目:

结果

让我们开始使用应用和导航抽屉。

启动 Age Database 项目

在 Android Studio 中创*一个新项目。将其命名为Age Database,使用Navigation Drawer Activity模板,并将所有其他设置保持与本书中一致。在做其他任何事情之前,值得在模拟器上运行应用程序,看看作为模板的一部分自动生成了多少,如下面的截图所示:

启动 Age Database 项目

乍一看,它只是一个普通的布局,带有一个TextView。但是,从左边缘滑动,或者按菜单按钮,导航抽屉就会显示出来。

启动 Age Database 项目

现在,我们可以修改选项,并为每个选项插入一个Fragment实例(带有布局)。要理解它是如何工作的,让我们来看看自动生成的代码。

探索自动生成的代码和资源

drawable文件夹中,有一些图标,如下面的截图所示:

探索自动生成的代码和资源

这些是通常的图标,也是出现在导航抽屉菜单中的图标。我们不会费心去更改它们,但如果你想个性化你的应用中的图标,通过本次探索最终应该清楚如何做到。

接下来,打开res/menu文件夹。请注意,那里有一个额外的文件,名为activity_main_drawer.xml。下面的代码是从这个文件中摘录出来的,所以我们可以讨论它的内容:

<group android:checkableBehavior="single">
   <item
         android:id="@+id/nav_camera"
         android:icon="@drawable/ic_menu_camera"
         android:title="Import" />
   <item
         android:id="@+id/nav_gallery"
         android:icon="@drawable/ic_menu_gallery"
         android:title="Gallery" />
   <item
         android:id="@+id/nav_slideshow"
         android:icon="@drawable/ic_menu_slideshow"
         android:title="Slideshow" />
   <item
         android:id="@+id/nav_manage"
         android:icon="@drawable/ic_menu_manage"
         android:title="Tools" />
</group>

请注意,在group标签中有四个item标签。现在,请注意从上到下的title标签(ImportGallerySlideshowTools)与自动生成的导航抽屉菜单中的前四个文本选项完全对应。另外,请注意在每个item标签中都有一个id标签,这样我们就可以在 Kotlin 代码中引用它们,还有一个icon标签,对应着我们刚刚看到的drawable文件夹中的图标之一。

另外,请查看layout文件夹中的nav_header_main.xml文件,其中包含了抽屉的头部布局。

其余的文件都如我们所料,但在 Kotlin 代码中还有一些要注意的关键点。这些都在MainActivity.kt文件中。现在打开它,我们来看看它们。

首先是onCreate函数中处理我们 UI 各个方面的额外代码。看看这段额外的代码,然后我们可以讨论它:

val toggle = ActionBarDrawerToggle(
         this, drawer_layout, 
         toolbar, 
         R.string.navigation_drawer_open, 
         R.string.navigation_drawer_close)

drawer_layout.addDrawerListener(toggle)

toggle.syncState()

nav_view.setNavigationItemSelectedListener(this)

代码获取了一个DrawerLayout的引用,它对应着我们刚刚看到的布局。代码还创*了一个ActionBarDrawerToggle的新实例,它允许控制或切换抽屉。代码的最后一行在NavigationView上设置了一个监听器。现在,每当用户与导航抽屉交互时,Android 都会调用一个特殊的函数。我所指的这个特殊函数是onNavigationItemSelected。我们将在一分钟内看到这个自动生成的函数。

接下来,看一下onBackPressed函数:

override fun onBackPressed() {
   if (drawer_layout.isDrawerOpen(GravityCompat.START)) {
         drawer_layout.closeDrawer(GravityCompat.START)
   } else {
         super.onBackPressed()
  }
}

这是Activity类的一个重写函数,它处理用户在设备上按返回按钮时发生的情况。如果抽屉打开,代码会关闭它;如果抽屉没有打开,代码会简单地调用super.onBackPressed。这意味着如果抽屉打开,按返回按钮会关闭抽屉;如果抽屉已经关闭,会使用默认行为。

现在,看一下onNavigationItemSelected函数,这对应着应用功能的关键部分:

override fun onNavigationItemSelected(
   item: MenuItem)
   : Boolean {

   // Handle navigation view item clicks here.
   when (item.itemId) {
         R.id.nav_camera -> {
               // Handle the camera action
         }
         R.id.nav_gallery -> {

         }
         R.id.nav_slideshow -> {

         }
         R.id.nav_manage -> {

         }
         R.id.nav_share -> {

         }
         R.id.nav_send -> {

         }
   }

   drawer_layout.closeDrawer(GravityCompat.START)
   return true
}

请注意,when块分支对应于activity_main_drawer.xml文件中包含的id值。这是我们将响应用户在导航抽屉菜单中选择选项的地方。目前,when代码什么也不做。我们将更改它以加载特定的Fragment以及其相关的布局到主视图中。这意味着我们的应用将根据用户从菜单中的选择具有完全不同的功能和独立的 UI,就像我们在第二十四章中讨论 MVC 模式时所描述的那样,设计模式、多个布局和片段

让我们编写Fragment类和它们的布局,然后我们可以回来编写代码,使用它们在onNavigationItemSelected函数中。

编写片段类及其布局

我们将创*四个类,包括加载布局的代码以及实际的布局,但是在学*了 Android 数据库之后,我们不会将任何数据库功能放入 Kotlin 代码中。

当我们有了四个类和它们的布局后,我们将学*如何从导航抽屉菜单中加载它们。在本章结束时,我们将拥有一个完全可用的导航抽屉,让用户在片段之间切换,但是在下一章之前,这些片段不会有任何功能。

为类和布局创*空文件

通过右键单击layout文件夹并选择新* | 布局资源文件来创*四个布局文件,它们的父视图都是垂直的LinearLayout。将第一个文件命名为content_insert,第二个为content_delete,第三个为content_search,第四个为content_results。其他选项可以保持默认值。

现在你应该有四个新的布局文件,其中包含LinearLayout父视图,如下截图所示:

为类和布局创*空文件

让我们编写 Kotlin 类。

编写类

通过右键单击包含MainActivity.kt文件的文件夹并选择新* | Kotlin 文件/类来创*四个新类。将它们命名为InsertFragmentDeleteFragmentSearchFragmentResultsFragment。从名称上就可以看出哪些片段将显示哪些布局。

接下来,让我们为每个类添加一些代码,使这些类从Fragment继承并加载它们相关的布局。

打开InsertFragment.kt并编辑它,使其包含以下代码:

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

import androidx.fragment.app.Fragment

class InsertFragment : Fragment() {
    override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
         savedInstanceState: Bundle?)
         : View? {

         val view = inflater.inflate(
                R.layout.content_insert,
                container,
                false)

        // Database and UI code goes here in next chapter

        return view
    }
}

打开DeleteFragment.kt并编辑它,使其包含以下代码:

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

import androidx.fragment.app.Fragment

class DeleteFragment : Fragment() {
    override fun onCreateView(
         inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState:
        Bundle?)
        : View? {

        val view = inflater.inflate(
               R.layout.content_delete,
               container,
               false)

        // Database and UI code goes here in next chapter

        return view
    }
}

打开SearchFragment.kt并编辑它,使其包含以下代码:

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

import androidx.fragment.app.Fragment

class SearchFragment : Fragment() {
    override fun onCreateView(
         inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?)
        : View? {

         val view = inflater.inflate(
               R.layout.content_search,
               container,
               false)

        // Database and UI code goes here in next chapter

        return view
    }
}

打开ResultsFragment.kt并编辑它,使其包含以下代码:

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

import androidx.fragment.app.Fragment

class ResultsFragment : Fragment() {

    override fun onCreateView(
         inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?)
         : View? {

        val view = inflater.inflate(
               R.layout.content_results,
               container,
               false)

        // Database and UI code goes here in next chapter

        return inflater.inflate(R.layout.content_results,
                container,
                false)
    }
}

每个类完全没有功能,除了在onCreateView函数中,从相关的布局文件加载适当的布局。

让我们为之前创*的布局文件添加 UI。

设计布局

正如我们在本章开头所看到的,所有的布局都很简单。让你的布局与我的完全相同并不是必要的,但是,如同往常一样,id属性值必须相同,否则我们在下一章中编写的 Kotlin 代码将无法工作。

设计 content_insert.xml

文本类别的调色板中拖动两个纯文本小部件到布局中。记住纯文本小部件是EditText实例。现在,在两个EditText/纯文本小部件之后,拖动一个按钮到布局中。

根据这个表格配置小部件:

小部件 属性和值
顶部编辑文本 id = editName
顶部编辑文本 文本 = 姓名
第二个编辑文本 id = editAge
第二个编辑文本 文本 = 年龄
按钮 id = btnInsert
按钮 文本 = 插入

这是你的布局在 Android Studio 的设计视图中应该是这样的:

设计 content_insert.xml

设计 content_delete.xml

普通文本/EditText小部件拖放到布局上方,下方是一个按钮。根据以下表格配置小部件:

小部件 属性值
EditText id = editDelete
EditText 文本 = 名称
按钮 id = btnDelete
按钮 文本 = 删除

这是您在 Android Studio 的设计视图中的布局应该看起来的样子:

设计 content_delete.xml

设计 content_search.xml

普通文本/EditText小部件拖放到布局上方,然后是一个按钮,然后是一个常规的TextView,然后根据以下表格配置小部件:

小部件 属性值
EditText id = editSearch
EditText 文本 = 名称
按钮 id = btnSearch
按钮 文本 = 搜索
TextView id = textResult

这是您在 Android Studio 的设计视图中的布局应该看起来的样子:

设计 content_search.xml

设计 content_results.xml

将单个TextView(这次不是普通文本/EditText)拖放到布局中。在下一章中,我们将看到如何将整个列表添加到这个单个TextView中。

根据以下表格配置小部件:

小部件 属性值
TextView id = textResults

这是您在 Android Studio 的设计视图中的布局应该看起来的样子:

设计 content_results.xml

现在,我们可以使用基于Fragment类和它们的布局的类。

使用Fragment类及其布局

这个阶段有三个步骤。首先,我们需要编辑导航抽屉的菜单,以反映用户的选项。接下来,我们需要在布局中添加一个View实例,以容纳当前活动的Fragment实例,最后,我们需要在MainActivity.kt中添加代码,以在用户点击导航抽屉菜单时在不同的Fragment实例之间切换。

编辑导航抽屉菜单

在项目资源管理器的res/menu文件夹中打开activity_main_drawer.xml文件。编辑group标签中的代码,以反映我们的插入删除搜索结果菜单选项:

<group android:checkableBehavior="single">
   <item
         android:id="@+id/nav_insert"
         android:icon="@drawable/ic_menu_camera"
         android:title="Insert" />
   <item
         android:id="@+id/nav_delete"
         android:icon="@drawable/ic_menu_gallery"
         android:title="Delete" />
   <item
         android:id="@+id/nav_search"
         android:icon="@drawable/ic_menu_slideshow"
         android:title="Search" />
   <item
         android:id="@+id/nav_results"
         android:icon="@drawable/ic_menu_manage"
         android:title="Results" />
</group>

提示

现在是一个很好的时机,可以向drawable文件夹添加新的图标,并编辑前面的代码以引用它们,如果您想使用自己的图标。

向主布局添加一个占位符

在布局文件夹中打开content_main.xml文件,并在ConstraintLayout的闭合标签之前添加以下突出显示的 XML 代码:

<FrameLayout
 android:id="@+id/fragmentHolder"
 android:layout_width="0dp"
 android:layout_height="0dp"
 app:layout_constraintBottom_toBottomOf="parent"
 app:layout_constraintEnd_toEndOf="parent"
 app:layout_constraintStart_toStartOf="parent"
 app:layout_constraintTop_toTopOf="parent"> 
</FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

现在,我们有一个id属性为fragmentHolderFrameLayout,我们可以引用并将所有Fragment实例的布局加载到其中。

编写 MainActivity.kt 文件

打开MainActivity文件,并编辑onNavigationItemSelected函数,以处理用户可以选择的所有不同菜单选项:

override fun onNavigationItemSelected(
  item: MenuItem): 
  Boolean {

  // Create a transaction
  val transaction = 
        supportFragmentManager.beginTransaction()

  // Handle navigation view item clicks here.
  when (item.itemId) {
        R.id.nav_insert -> {
              // Create a new fragment of the appropriate type
              val fragment = InsertFragment()
              // What to do and where to do it
              transaction.replace(R.id.fragmentHolder, fragment)
    }
    R.id.nav_search -> {
              val fragment = SearchFragment()
              transaction.replace(R.id.fragmentHolder, fragment)
    }
    R.id.nav_delete -> {
              val fragment = DeleteFragment()
              transaction.replace(R.id.fragmentHolder, fragment)
    }
    R.id.nav_results -> {
      val fragment = ResultsFragment()
      transaction.replace(R.id.fragmentHolder, fragment)
    }

  }

   // Ask Android to remember which
   // menu options the user has chosen
   transaction.addToBackStack(null);

  // Implement the change
  transaction.commit();

   drawer_layout.closeDrawer(GravityCompat.START)
   return true
}

让我们来看一下我们刚刚添加的代码。大部分代码应该看起来很熟悉。对于我们的每个菜单选项,我们创*一个相应类型的新Fragment实例,并将其插入到id值为fragmentHolderFrameLayout中。

transaction.addToBackStack函数调用意味着所选的Fragment将按顺序与其他Fragment一起被记住。这样做的结果是,如果用户选择插入片段,然后选择结果片段,然后点击返回按钮,应用程序将把用户返回到插入片段。

现在可以运行应用程序,并使用导航抽屉菜单在所有不同的Fragment实例之间切换。它们看起来就像本章开头的图片一样,但它们还没有任何功能。

摘要

在本章中,我们看到了拥有一个吸引人和令人愉悦的 UI 是多么简单,尽管我们的Fragment实例还没有任何功能,但一旦我们学会了数据库,它们就已经准备就绪了。

在下一章中,我们将学*关于数据库的一般知识,以及 Android 应用程序可以使用的特定数据库,在我们为Fragment类添加功能之前。

第二十七章:Android 数据库

如果我们要制作具有重要功能的应用程序,几乎可以肯定我们需要一种方式来管理、存储和过滤大量数据。

使用 JSON 可以高效地存储大量数据,但当我们需要有选择地使用数据而不仅仅限制在“保存所有”和“加载所有”的选项时,我们需要考虑其他可用的选项。

一个好的计算机科学课程可能会教授处理数据排序和过滤所需的算法,但涉及的工作量会相当大,我们能否想出与为我们提供 Android API 的人一样好的解决方案呢?

因此,通常情况下,使用 Android API 中提供的解决方案是有意义的。正如我们所见,JSONSharedPreferences类有其用途,但在某个时候,我们需要转向使用真正的数据库来解决现实世界的问题。Android 使用 SQLite 数据库管理系统,而且如你所期望的那样,有一个 API 可以尽可能地简化它。

在本章中,我们将做以下事情:

  • 确切地了解什么是数据库

  • 学* SQL 和 SQLite 是什么

  • 学* SQL 语言的基础知识

  • 查看 Android SQLite API

  • 编写上一章开始的 Age Database 应用程序

数据库 101

让我们回答一大堆与数据库相关的问题,然后我们就可以开始制作使用 SQLite 的应用程序。

那么,什么是数据库?

什么是数据库?

数据库既是一个存储的地方,也是检索、存储和操作数据的手段。在学*如何使用数据库之前,能够形象地想象一个数据库是很有帮助的。实际上,数据库内部的结构因所涉及的数据库而异。SQLite 实际上将所有数据存储在一个单独的文件中。

然而,如果我们将数据想象成电子表格,有时是多个电子表格,我们的理解将会大大有助。我们的数据库,就像电子表格一样,将被划分为代表不同类型数据的多个列,以及代表数据库条目的行。

想象一个包含姓名和考试成绩的数据库。看一下这个数据的可视化表示,我们可以想象它在数据库中的样子:

什么是数据库?

然而请注意,这里有一列额外的数据——一个ID列。随着我们的学*,我们将会更多地讨论这个。这种类似电子表格的结构被称为。如前所述,数据库中可能有多个表。表的每一列都有一个名称,可以在与数据库交互时引用。当我们向数据库提问时,我们说我们正在查询数据库。

什么是 SQL?

SQL代表结构化查询语言。这是用于与数据库进行交互的语法。

什么是 SQLite?

SQLite 是 Android 所青睐的整个数据库系统的名称,它有自己的 SQL 版本。SQLite 版本的 SQL 需要稍微不同是因为数据库具有不同的特性。

接下来的 SQL 语法基础知识将专注于 SQLite 版本。

SQL 语法基础知识

在学*如何在 Android 中使用 SQLite 之前,我们需要首先学*如何在通用*台上使用 SQLite 的基础知识。

让我们看一些示例 SQL 代码,可以直接在 SQLite 数据库上使用,而不需要任何 Kotlin 或 Android 类,然后我们可以更容易地理解我们的 Kotlin 代码在后续的操作中在做什么。

SQLite 示例代码

SQL 有关键字,就像 Kotlin 一样,会引发一些事件发生。以下是我们很快将要使用的一些 SQL 关键字的示例:

  • INSERT:允许我们向数据库中添加数据

  • DELETE:允许我们从数据库中删除数据

  • SELECT:允许我们从数据库中读取数据

  • WHERE:允许我们指定与我们想要从中INSERTDELETESELECT的特定条件匹配的数据库部分

  • FROM:用于指定数据库中的表或列名

注意

SQLite 关键字远不止这些,要获得全面的列表,请查看此链接:sqlite.org/lang_keywords.html

除了关键字,SQL 还有类型。一些 SQL 类型的示例如下:

  • integer:正是我们存储整数所需的

  • text:非常适合存储简单的名称或地址

  • real:用于大型浮点数

注意

SQLite 类型远不止这些,要获得全面的列表,请查看此链接:www.sqlite.org/datatype3.html

让我们看看如何结合这些类型和关键字来创*表,并使用完整的 SQLite 语句添加、删除、修改和读取数据。

创*表

一个完全合理的问题是为什么我们不先创*一个新的数据库。原因是每个应用程序默认都可以访问 SQLite 数据库。该数据库对该应用程序是私有的。以下是我们将使用的语句在该数据库中创*表。我已经突出显示了一些部分,以使语句更清晰:

create table StudentsAndGrades 
   _ID integer primary key autoincrement not null,
   name text not null,
 score int;

上面的代码创*了一个名为StudentsAndGrades的表,其中有一个integerid,每次添加一行数据时都会自动增加(递增)。

该表还将有一个name列,类型为text,不能是空的(not null)。

它还将有一个score列,类型为int。另外,请注意语句以分号结束。

向数据库插入数据

以下是我们如何向该数据库插入新的数据行:

INSERT INTO StudentsAndGrades
   (name, score)
   VALUES
   ("Bart", 23);

上面的代码向数据库添加了一行。在前面的语句之后,数据库将有一个条目,其值为(1、"Bart"、23),对应列为(_ID、name 和 score)。

以下是我们如何向该数据库插入另一行新数据:

INSERT INTO StudentsAndGrades
   (name, score)
   VALUES
   ("Lisa", 100);

上面的代码为列(_ID、name 和 score)添加了新的数据行(2、"Lisa"、100)。

我们类似电子表格的结构现在看起来像下面的图表:

向数据库插入数据

从数据库中检索数据

以下是我们如何从数据库中访问所有行和列:

SELECT * FROM StudentsAndGrades;

上面的代码要求每一行和每一列。*符号可以读作all

我们也可以更加选择性,如下面的代码所示:

SELECT score FROM StudentsAndGrades
 where name = "Lisa";

上面的代码只会返回100,当然,这是与名称 Lisa 相关联的分数。

更新数据库结构

我们甚至可以在创*表并添加数据后添加新列。就 SQL 而言,这很简单,但可能会对已发布的应用程序中用户的数据造成一些问题。下一个语句添加了一个名为age的新列,类型为int

ALTER TABLE StudentsAndGrades
            ADD 
     age int;

数据类型、关键字和使用它们的方式远不止我们迄今所见。接下来,让我们看看 Android SQLite API,我们将开始看到如何使用我们的新的 SQLite 技能。

Android SQLite API

Android API 以多种方式使我们相当容易地使用应用程序的数据库。我们需要熟悉的第一个类是SQLiteOpenHelper

SQLiteOpenHelper 和 SQLiteDatabase

SQLiteDatabase类是表示实际数据库的类。然而,SQLiteOpenHelper类是大部分操作发生的地方。这个类将使我们能够访问数据库并初始化SQLiteDatabase的实例。

此外,我们将从中继承的SQLiteOpenHelper类在Age database应用程序中有两个要重写的函数。首先,它有一个onCreate函数,第一次使用数据库时会调用,因此我们会在其中加入我们的 SQL 以创*表结构是有意义的。

我们必须重写的另一个函数是onUpgrade,您可能已经猜到,当我们升级数据库(ALTER其结构)时会调用它。

构*和执行查询

随着数据库结构变得更加复杂和我们的 SQL 知识的增长,我们的 SQL 语句会变得相当长和笨拙。语法错误或拼写错误的可能性很高。

我们将帮助克服这种复杂性的方法是将查询从各个部分构*成一个String。然后我们可以将该String传递给执行查询的函数(我们很快就会看到)。

此外,我们将使用String实例来表示表和列名称,因此我们不会与它们混淆。

例如,我们可以在companion对象中声明以下String实例,它们将代表之前虚构示例中的表名和列名。请注意,我们还将为数据库本身命名,并为其也有一个String

companion object {
   /*
   Next, we have a const string for
   each row/table that we need to refer to both
   inside and outside this class
   */

   const val DB_NAME = "MyCollegeDB";
   const val TABLE_S_AND_G = "StudentsAndGrades";

   const val TABLE_ROW_ID = "_id";
   const val TABLE_ROW_NAME = "name";
   const val TABLE_ROW_SCORE = "score";

}

然后我们可以在下一个示例中构*这样的查询。以下示例向我们的假想数据库添加了一个新条目,并将 Kotlin 变量合并到了 SQL 语句中:

val name = "Smit";
val score = 95;

// Add all the details to the table
val query = "INSERT INTO " + TABLE_S_AND_G + " (" +
         TABLE_ROW_NAME + ", " +
         TABLE_ROW_SCORE +
         ") " +
         "VALUES (" +
         "'" + name + "'" + ", " +
         score +
         ");"

请注意,在前面的代码中,常规的 Kotlin 变量namescore被突出显示。之前的名为queryString现在是 SQL 语句,与此完全相同:

INSERT INTO StudentsAndGrades (
   name, score)
   VALUES ('Smit',95);

提示

要继续学* Android 编程,不一定要完全掌握前两个代码块。但是,如果您想构*自己的应用程序并构造完全符合您需求的 SQL 语句,那么理解这两个代码块将有所帮助。为什么不学*前两个代码块,以便分辨出与 SQL 语法相关的String部分之间用单引号'连接的差异呢?

在输入查询时,Android Studio 会提示我们变量的名称,这样错误的可能性就会大大降低,尽管它比简单输入查询更冗长。

现在,我们可以使用之前介绍的类来执行查询:

// This is the actual database
private val db: SQLiteDatabase

// Create an instance of our internal CustomSQLiteOpenHelper class
val helper = CustomSQLiteOpenHelper(context)

// Get a writable database
db = helper.writableDatabase

// Run the query
db.execSQL(query)

在向数据库添加数据时,我们将使用execSQL,就像之前的代码中一样,而在从数据库获取数据时,我们将使用rawQuery函数,如下所示:

Cursor c = db.rawQuery(query, null)

注意rawQuery函数返回Cursor类型的对象。

提示

与 SQLite 交互的方式有几种,它们各自都有优缺点。我们选择使用原始 SQL 语句,因为这样做完全透明,同时加强了我们对 SQL 语言的了解。

数据库游标

除了让我们访问数据库的类和允许我们执行查询的函数之外,还有一个问题,那就是我们从查询中得到的结果的格式。

幸运的是,有Cursor类。我们所有的数据库查询都将返回Cursor类型的对象。我们可以使用Cursor类的函数有选择地访问从查询返回的数据,就像下面的代码一样:

Log.i(c.getString(1), c.getString(2))

先前的代码将在 logcat 窗口中输出查询返回结果的前两列中存储的两个值。确定我们当前正在读取的返回数据的哪一行是Cursor对象本身确定的。

我们可以访问Cursor对象的各种函数,包括moveToNext函数,它会将Cursor移动到下一行以便读取:

c.moveToNext()

/*
   This same code now outputs the data in the
   first and second column of the returned 
   data but from the SECOND row.
*/

Log.i(c.getString(1), c.getString(2))

在某些情况下,我们将能够将Cursor绑定到我们 UI 的一部分(例如RecyclerView),就像我们在Note to self应用程序中使用ArrayList一样,并且只需将一切交给 Android API。

Cursor类中还有许多有用的函数,其中一些我们很快就会看到。

提示

这篇介绍 Android SQLite API 实际上只是触及了它的能力表面。随着我们进一步进行,我们会遇到更多的函数和类。然而,如果您的应用想法需要复杂的数据管理,进一步研究是值得的。

现在,我们可以看到所有这些理论是如何结合在一起的,以及我们将如何在 Age 数据库应用程序中构*我们的数据库代码的结构。

编写数据库类

在这里,我们将把我们迄今学到的一切付诸实践,并完成编写 Age 数据库应用程序。在我们之前的部分的Fragment类可以与共享数据库进行交互之前,我们需要一个处理与数据库的交互和创*的类。

我们将创*一个通过实现SQLiteOpenHelper来管理我们的数据库的类。它还将在companion object中定义一些String变量,以表示表的名称和其列。此外,它将提供一堆辅助函数,我们可以调用这些函数来执行所有必要的查询。在必要时,这些辅助函数将返回一个Cursor对象,我们可以用来显示我们检索到的数据。如果我们的应用需要发展,添加新的辅助函数将是微不足道的:

创*一个名为DataManager的新类,并添加companion object、构造函数和init块:

提示

我们在第二十五章中讨论了companion object使用分页和滑动的高级 UI

class DataManager(context: Context) {

    // This is the actual database
    private val db: SQLiteDatabase

    init {
        // Create an instance of our internal
        // CustomSQLiteOpenHelper class
        val helper = CustomSQLiteOpenHelper(context)
        // Get a writable database
        db = helper.writableDatabase
    }

    companion object {
        /*
        Next, we have a const string for
        each row/table that we need to refer to both
        inside and outside this class
        */

        const val TABLE_ROW_ID = "_id"
        const val TABLE_ROW_NAME = "name"
        const val TABLE_ROW_AGE = "age"

        /*
        Next, we have a private const strings for
        each row/table that we need to refer to just
        inside this class
        */

        private const val DB_NAME = "address_book_db"
        private const val DB_VERSION = 1
        private const val TABLE_N_AND_A = "names_and_addresses"
    }
}

注意

我将数据库和表命名为可能会发展成一个地址簿应用,还可以跟踪年龄和生日。

前面的代码为我们提供了构*查询的所有方便的String实例,并声明和初始化了我们的数据库和辅助类。

现在,我们可以添加我们将从我们的Fragment类中访问的辅助函数;首先是insert函数,它根据传入函数的nameage参数执行INSERT SQL 查询。

insert函数添加到DataManager类中:

// Insert a record
fun insert(name: String, age: String) {
  // Add all the details to the table
  val query = "INSERT INTO " + TABLE_N_AND_A + " (" +
               TABLE_ROW_NAME + ", " +
               TABLE_ROW_AGE +
               ") " +
               "VALUES (" +
               "'" + name + "'" + ", " +
               "'" + age + "'" +
               ");"

  Log.i("insert() = ", query)

  db.execSQL(query)
}

下一个名为delete的函数将从数据库中删除记录,如果它在name列中具有与传入的name参数相匹配的值。它使用 SQL 的DELETE关键字来实现这一点。

delete函数添加到DataManager类中:

// Delete a record
fun delete(name: String) {

   // Delete the details from the table
   // if already exists
   val query = "DELETE FROM " + TABLE_N_AND_A +
               " WHERE " + TABLE_ROW_NAME +
               " = '" + name + "';"

   Log.i("delete() = ", query)

   db.execSQL(query)

}

接下来,我们有selectAll函数,它也如其名称所示。它使用SELECT查询来实现这一点,使用*参数,这相当于单独指定所有列。另外,请注意该函数返回一个Cursor,我们将在一些Fragment类中使用。

selectAll函数添加到DataManager类中,如下所示:

// Get all the records
fun selectAll(): Cursor {
   return db.rawQuery("SELECT *" + " from " +
               TABLE_N_AND_A, null)
}

现在,我们添加一个searchName函数,它具有一个String参数,用于用户想要搜索的名称。它还返回一个Cursor对象,其中包含找到的所有条目。请注意,SQL 语句使用SELECTFROMWHERE来实现这一点:

// Find a specific record
fun searchName(name: String): Cursor {
    val query = "SELECT " +
                 TABLE_ROW_ID + ", " +
                 TABLE_ROW_NAME +
                 ", " + TABLE_ROW_AGE +
                 " from " +
                 TABLE_N_AND_A + " WHERE " +
                 TABLE_ROW_NAME + " = '" + name + "';"

   Log.i("searchName() = ", query)

   return db.rawQuery(query, null)
}

最后,对于DataManager类,我们创*一个inner类,它将是我们的SQLiteOpenHelper的实现。这是一个最基本的实现。

我们有一个构造函数,接收一个Context对象、数据库名称和数据库版本。

我们还重写了onCreate函数,其中包含创*具有_IDnameage列的数据库表的 SQL 语句。

onUpgrade函数在这个应用程序中被故意留空,但仍然需要存在,因为当我们从SQLiteOpenHelper继承时,它是合同的一部分。

将内部的CustomSQLiteOpenHelper类添加到DataManager类中:

// This class is created when 
// our DataManager class is instantiated
private inner class CustomSQLiteOpenHelper(
         context: Context)
     : SQLiteOpenHelper(
           context, DB_NAME,
           null, DB_VERSION) {

  // This function only runs the first
  // time the database is created
  override fun onCreate(db: SQLiteDatabase) {

        // Create a table for photos and all their details
        val newTableQueryString = ("create table "
              + TABLE_N_AND_A + " ("
              + TABLE_ROW_ID
              + " integer primary key autoincrement not null,"
              + TABLE_ROW_NAME
              + " text not null,"
              + TABLE_ROW_AGE
              + " text not null);")

        db.execSQL(newTableQueryString)
  }

  // This function only runs when we increment DB_VERSION
  override fun onUpgrade(db: SQLiteDatabase,
                     oldVersion: Int,
                     newVersion: Int) {

  }

}

现在,我们可以在我们的Fragment类中添加代码来使用我们的新DataManager类。

编写 Fragment 类以使用 DataManager 类

将此突出显示的代码添加到InsertFragment类中,以更新onCreateView函数,如下所示:

val view = inflater.inflate(
         R.layout.content_insert,
         container,
         false)

// Database and UI code goes here in next chapter
val dm = DataManager(activity!!)

val btnInsert = view.findViewById(R.id.btnInsert) as Button
val editName = view.findViewById(R.id.editName) as EditText
val editAge = view.findViewById(R.id.editAge) as EditText

btnInsert.setOnClickListener(
            {
 dm.insert(editName.text.toString(),
 editAge.text.toString())
            }
)

return view

在代码中,我们获取了DataManager类的一个实例和每个 UI 小部件的引用。然后,在按钮的onClick函数中,我们使用insert函数向数据库添加新的名称和年龄。要插入的值来自两个EditText小部件。

将此突出显示的代码添加到DeleteFragment类中,以更新onCreateView函数:

val view = inflater.inflate(
         R.layout.content_delete,
         container,
         false)

// Database and UI code goes here in next chapter
val dm = DataManager(activity!!)

val btnDelete = 
 view.findViewById(R.id.btnDelete) as Button
val editDelete = 
 view.findViewById(R.id.editDelete) as EditText

btnDelete.setOnClickListener(
 {
 dm.delete(editDelete.text.toString())
 }
)

return view

DeleteFragment类中,我们创*了DataManager类的一个实例,然后从布局中获取了EditTextButton的引用。当点击按钮时,将调用delete函数,传入EditText中的任何文本值。删除函数会在我们的数据库中搜索匹配项,如果找到,则删除它。

将此突出显示的代码添加到SearchFragment类中,以更新onCreateView函数:

val view = inflater.inflate(R.layout.content_search,
         container,
         false)

// Database and UI code goes here in next chapter
val btnSearch = view.findViewById(R.id.btnSearch) as Button
val editSearch = view.findViewById(R.id.editSearch) as EditText
val textResult = view.findViewById(R.id.textResult) as TextView

// This is our DataManager instance
val dm = DataManager(activity!!)

btnSearch.setOnClickListener(
 {
 val c = dm.searchName(editSearch.text.toString())

 // Make sure a result was found 
 // before using the Cursor
 if (c.count > 0) {
 c.moveToNext()
 textResult.text =
 "Result = ${c.getString(1)} - ${c.getString(2)}"
 }
 }
)

return view

与我们为所有不同的Fragment类所做的一样,我们创*了DataManager的一个实例,并获取了布局中所有不同 UI 小部件的引用。在按钮的onClick函数中,使用searchName函数,传入EditText中的值。如果数据库在Cursor中返回结果,那么TextView使用其text属性来输出结果。

将此突出显示的代码添加到ResultsFragment类中,以更新onCreateView函数:

val view = inflater.inflate(R.layout.content_results,
         container,
         false)

// Database and UI code goes here in next chapter
// Create an instance of our DataManager
val dm = DataManager(activity!!)

// Get a reference to the TextView 
// to show the results in
val textResults = 
 view.findViewById(R.id.textResults) as TextView

// Create and initialize a Cursor 
// with all the results in
val c = dm.selectAll()

// A String to hold all the text
var list = ""

// Loop through the results in the Cursor
while (c.moveToNext()) {
 // Add the results to the String
 // with a little formatting
 list += c.getString(1) + " - " + c.getString(2) + "\n"
}

// Display the String in the TextView
textResults.text = list

return view

在这个类中,在任何交互发生之前,使用selectAll函数将Cursor对象加载了数据。然后,通过连接结果将Cursor的内容输出到TextView中。连接中的\n是在Cursor中的每个结果之间创*新行的内容。

运行年龄数据库应用程序

让我们运行一些我们的应用程序功能,以确保它按预期工作。首先,我使用插入菜单选项向数据库添加了一个新名称:

运行年龄数据库应用程序

然后,我通过查看结果选项确认它确实存在:

运行年龄数据库应用程序

然后,我使用删除菜单选项,再次查看结果选项,以检查我选择的名称是否确实被删除了:

运行年龄数据库应用程序

接下来,我搜索了一个我知道存在的名称来测试搜索功能:

运行年龄数据库应用程序

让我们回顾一下本章中我们所做的事情。

总结

在本章中,我们涵盖了很多内容。我们学*了关于数据库,特别是 Android 应用程序的数据库 SQLite。我们练*了使用 SQL 语言与数据库进行通信和查询的基础知识。

我们已经看到了 Android API 如何帮助我们使用 SQLite 数据库,并实现了我们的第一个具有数据库的工作应用程序。

就是这样了,但请看接下来的简短最终章节。

第二十八章:在你离开之前快速交谈

我们的旅程就快结束了。这一章只是一些你可能在匆忙制作自己的应用程序之前想要看看的想法和指针:

  • 发布

  • 制作你的第一个应用程序

  • 继续学*

  • 谢谢

发布

你已经足够了解如何设计你自己的应用程序。你甚至可以对书中的应用程序进行一些修改,并添加许多新功能。

我决定不提供关于在 Google Play 商店发布的逐步指南,因为这些步骤并不复杂。然而,它们相当深入和有点费力。大部分步骤涉及输入关于你和你的应用的个人信息和图片。这样的教程可能会读起来像下面这样:

  1. 填写这个文本框。

  2. 现在,填写那个文本框。

  3. 上传这张图片。

  4. 等等。

不太有趣或有用。

要开始,你只需要访问play.google.com/apps/publish并支付一笔适度的费用(大约 25 美元),具体金额取决于你所在地区的货币。这样你就可以终身发布应用程序了。

提示

如果你想要一个发布的清单,请查看以下网址:developer.android.com/distribute/best-practices/launch/launch-checklist.html。你会发现这个过程很直观(尽管非常冗长)。

制作应用程序!

如果你只是把这一件事付诸实践,你就可以忽略本章中的其他内容:

提示

不要等到你成为专家才开始制作应用程序!

开始制作你的梦想应用程序,那个拥有所有功能的应用程序,将会在 Google Play 上风靡一时。然而,一个简单的*议是:先做一些规划!不要太多,然后开始。

在一旁有一些更小、更容易实现的项目;你可以向朋友和家人展示这些项目,并探索对你来说是新的 Android 领域。如果你对这些应用程序有信心,你可以将它们上传到 Google Play。如果你担心它们会受到评论者的评价,那就让它们免费,并在描述中加上“只是一个原型”之类的说明。

如果你的经历和我的一样,你会发现当你阅读、学*和制作应用程序时,你会发现你的梦想应用程序可以在许多方面得到改进,你可能会被启发去重新设计它,甚至重新开始。

当你这样做时,我可以保证,下一次你构*它时,你会用一半的时间做到两倍好,至少!

继续学*

如果你觉得自己已经走了很长的路,你是对的。然而,总是有更多的东西可以学*。

继续阅读

你会发现,当你制作你的第一个应用程序时,你会突然意识到你的知识中存在一个需要填补的空白,以使某些功能得以实现。这是正常的,也是可以预料的;不要让它吓到你。想想如何描述这个问题,并在谷歌上搜索解决方案。

你可能会发现项目中的特定类会超出实际可维护的大小。这表明有更好的方式来构*事物,而且可能已经有现成的设计模式可以让你的生活更轻松。

为了预防这种几乎不可避免的情况,为什么不立即学*一些模式呢?一个很好的来源是proandroiddev.com/kotlin-design-patterns-8e152540ee2c

GitHub

GitHub 允许你搜索和浏览其他人编写的代码,并查看他们是如何解决问题的。这很有用,因为看到类的文件结构,然后深入其中,通常会显示如何从一开始规划你的应用程序,并防止你走上错误的道路。你甚至可以获得一个 GitHub 应用程序,让你可以在手机或*板电脑上舒适地做到这一点。

你甚至可以配置安卓工作室来保存和分享你的项目到 GitHub。例如,在主页www.github.com上搜索“Android fragment”,你会看到超过 1000 个相关项目,你可以浏览,如下所示:

GitHub

StackOverflow

如果遇到困难,出现奇怪的错误,或者莫名其妙的崩溃,通常最好的去处是谷歌。这样做,你会惊讶地发现 StackOverflow 似乎经常出现在搜索结果中;而且理由充分。

StackOverflow 允许用户发布问题描述和示例代码,社区可以回答。然而根据我的经验,很少需要发布问题,因为几乎总会有人遇到完全相同的问题。

StackOverflow 对于最前沿的问题特别有用。如果新的 Android Studio 版本有 bug,或者新的 Android API 版本似乎没有按照预期运行,那么你几乎可以肯定全球数千名开发者也遇到了同样的问题。然后,一些聪明的编程人员,通常来自安卓开发团队,会给出答案。

StackOverflow 也适合轻松阅读。打开www.stackoverflow.com首页,在搜索框中输入“Android”,你会看到 StackOverflow 社区最新的问题列表:

StackOverflow

我并不是*议你立即开始尝试回答所有问题,但阅读问题和*议会教会你很多,你可能会发现,往往你有解决方案,或者至少有解决方案的想法。

安卓用户论坛

此外,值得注册一些安卓论坛,偶尔访问,了解用户的热门话题和趋势。我没有列出任何论坛,因为只需要快速搜索即可。

如果你是认真的,你可以参加一些安卓会议,与成千上万的其他开发者交流并参加讲座。如果你感兴趣,可以搜索 Droidcon、Android developer Days 或 GDG DevFest。

更高层次的学*

你现在可以阅读更多其他安卓书籍。我在本书开头提到,几乎没有一本书可以教会没有 Kotlin 经验的读者安卓编程。这就是我写这本书的原因。

现在你对面向对象编程和 Kotlin 有了很好的理解,还有对应用设计和安卓 API 的简要介绍,你可以阅读安卓“初学者”书籍,这些书籍适合已经知道如何在 Kotlin 中编程的人,就像你现在一样。

这些书中充满了很多好例子,你可以构*或者仅仅阅读,以加强你在本书中学到的知识,以不同的方式使用你的知识,当然,也学到一些全新的东西。

也许值得阅读一些纯 Kotlin 书籍。也许很难相信,在浏览了大约 750 页之后,Kotlin 还有很多内容没有时间在这里覆盖。

我可以列举一些书名,但在亚马逊上有最多积极评价的书籍往往值得探索。

我的其他渠道

请保持联系:

再见,谢谢

写这本书让我很开心。我知道这是陈词滥调,但也是真的。然而最重要的是,我希望你能从中获得一些收获,并将其作为你未来编程之路的一个跳板。

也许你正在阅读这本书是为了好玩,或者为了发布一个应用程序而感到自豪,或者是作为编程工作的一个跳板,或者你真的会开发那个风靡谷歌应用商店的应用程序。

无论如何,非常感谢你购买这本书,我祝愿你在未来的努力中一切顺利。

我认为每个人都有一个应用程序隐藏在内心深处,你所需要做的就是努力工作,把它释放出来。

附录 A. 您可能喜欢的其他书籍

如果您喜欢这本书,您可能会对 Packt 的其他书感兴趣:

您可能喜欢的其他书籍

Android 9 开发食谱-第三版

Rick Boyer

ISBN:978-1-78899-121-6

  • 使用最新的 Android 框架开发应用程序,同时保持与支持库的向后兼容性

  • 使用从图形,动画和多媒体配方中获得的知识创*引人入胜的应用程序

  • 通过简洁的步骤解决特定问题,帮助您更快地完成项目

  • 使用最新的 Google Play 服务 API 示例为您自己的应用程序添加位置感知

  • 为您的应用程序利用 Google 语音识别 API

您可能喜欢的其他书籍

Kotlin 编程食谱

Aanand Shekhar Roy,Rashi Karanpuria

ISBN:978-1-78847-214-2

  • 了解 Kotlin 编程的基础知识和面向对象的概念

  • 发掘 Kotlin 集合框架的全部潜力

  • 在 Android 中使用 SQLite 数据库,进行网络调用并在网络上获取数据

  • 使用 Kotlin 的 Anko 库进行高效快速的 Android 开发

  • 发现 Kotlin 的一些最佳功能:Lambda 和委托

  • 设置 Web 服务开发环境,编写 Servlet 并使用 Kotlin 构* RESTful 服务

  • 学*如何编写单元测试,集成测试和仪器/验收测试

留下评论-让其他读者知道您的想法

请通过在购买书籍的网站上留下评论与其他人分享您对这本书的想法。如果您从亚马逊购买了这本书,请在该书的亚马逊页面上留下诚实的评论。这对其他潜在读者来说非常重要,他们可以看到并使用您的公正意见来做出购买决策,我们可以了解我们的客户对我们的产品的看法,我们的作者可以看到您对他们与 Packt 合作创*的标题的反馈。这只需要花费您几分钟的时间,但对其他潜在客户,我们的作者和 Packt 都是有价值的。谢谢!

posted @ 2024-05-22 15:08  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报