嗨翻安卓开发第三版-早期发布--全-
嗨翻安卓开发第三版(早期发布)(全)
原文:
zh.annas-archive.org/md5/d915569931268616359b6ace32976efe译者:飞龙
第零章:如何使用这本书:简介

注意
在这一部分,我们回答了这个关键问题:“那么为什么他们在一本关于 Android 的书里加入这个?”
这本书适合谁?
如果你可以回答所有这些问题:
-
你已经掌握了 Kotlin、Java 或其他面向对象的编程语言吗?
-
你想精通 Android 应用开发,创造下一个大软件,赚一小笔钱,然后退休到你自己的私人岛屿吗?
注意
好吧,也许这有点牵强。但是,你得从某个地方开始,对吧?
-
你更喜欢实际做事、应用你所学的东西,而不是听某人在讲座中喋喋不休几个小时?
这本书适合你。
谁应该远离这本书?
如果你可以回答“是”任何一个问题:
-
你是在寻找一本快速入门或参考书籍来开发 Android 应用程序吗?
-
你宁愿被 15 只尖叫的猴子拔掉脚趾甲,也不愿意学习新东西吗?你认为一本 Android 书籍应该涵盖一切,特别是那些你永远不会用到的隐晦内容,如果这些内容在过程中令读者厌烦到流泪,那就更好了?
这本书不适合你。

注意
[市场部的注释:这本书适合任何持有信用卡或 PayPal 账户的人]
我们知道你在想什么
“这怎么可能是一本严肃的关于开发 Android 应用的书籍?”
“所有这些图形是什么鬼?”
“我真的可以用这种方式学习吗?”
我们知道你的大脑在想什么
你的大脑渴望新奇。它总是搜索、扫描,等待一些不寻常的东西。它就是这样构建的,并且帮助你保持生命。
那么你的大脑对你遇到的所有例行、普通、正常的事情会做什么呢?它尽其所能阻止它们干扰大脑的真正工作——记录重要的事情。它不费力保存无聊的事情;它们从未通过“这显然不重要”的过滤器。

你的大脑如何知道什么是重要的?假设你出去远足一天,一只老虎跳到你面前——你的头脑和身体会发生什么?
神经元激活。情绪激增。化学物质激增。
这就是你的大脑知道的方法……
这一定很重要!不要忘记它!
但想象一下,你在家里或图书馆。这是一个安全、温暖的、没有老虎的地方。你正在学习。准备考试。或者尝试学习你的老板认为需要一周、十天最多的艰难技术主题。
只有一个问题。你的大脑试图为你做一件大事。它试图确保这个显然不重要的内容不会占用有限的资源。这些资源最好用来存储真正重要的事情。像老虎一样。像火灾的危险一样。像你不应该把那些派对照片发布到 Instagram 上一样。但并没有简单的方法告诉你的大脑,“嘿,大脑,非常感谢你,但无论这本书有多么乏味,我现在情绪上几乎没有感受,我真的希望你能记住这些东西。”

元认知:思考思维的过程
如果你真的想学习,并且想更快、更深入地学习,就要注意你的注意力是如何被吸引的。思考你的思维方式。学习你的学习方法。
大多数人在成长过程中没有学习元认知或学习理论的课程。我们期望学习,但很少教会如何学习。

但我们假设如果你拿着这本书,你真的想学习如何开发 Android 应用。你可能不想花很多时间。如果你想要应用这本书中的内容,你需要记住你所读的内容。为此,你必须理解它。要从这本书中,或者任何一本书或学习经验中获取最大的收获,就要对你的大脑负责。你的大脑在这个内容上。
关键是让你的大脑把你正在学习的新材料看作是非常重要的。对你的健康至关重要。就像一只老虎一样重要。否则,你将不断与你的大脑进行斗争,让它尽力确保新内容不会留下。
那么,你如何让你的大脑把 Android 开发看作是一只饥饿的老虎呢?
有缓慢、乏味的方式,也有更快、更有效的方式。缓慢的方式是纯粹的重复。你显然知道,即使是最乏味的话题,只要你不断地向大脑灌输同样的内容,你是可以学习和记住的。通过足够的重复,你的大脑会说,“对他来说这不感觉重要,但他不断看着同样的东西一遍又一遍又一遍,所以我想这一定很重要。”
更快的方法是去做任何增加大脑活动的事情,特别是不同种类的大脑活动。前面页面上的内容是解决方案的重要部分,它们都经过证明有助于你的大脑更有效地工作。例如,研究表明,将单词与它们描述的图片放在一起(而不是放在页面的其他位置,如标题或正文中),会使你的大脑尝试理解单词和图片之间的关系,这会导致更多的神经元激活。更多的神经元激活 = 大脑更有机会意识到这是值得关注的事情,并可能记录下来。
会话式的风格有助于提高注意力,因为人们倾向于在感觉自己在进行对话时更加专注,因为他们期望自己能跟上并参与其中。令人惊奇的是,你的大脑并不一定在意这个“对话”是你和一本书之间的交流!另一方面,如果写作风格正式干燥,你的大脑会像你在一群被动听众中被讲授时一样对待它。没必要保持清醒。
但图片和会话式风格仅仅是个开始...
这就是我们所做的
我们使用了图片,因为你的大脑适应视觉,而不是文本。对于你的大脑来说,一张图片真的就像一千个字。而当文本和图片共同工作时,我们将文本嵌入到图片中,因为当文本位于它所指的事物内部时,你的大脑的工作效果更好,而不是在某个标题或正文中被埋没。
我们使用了冗余性,用不同的方式和不同的媒体类型表达相同的内容,并且利用多个感官,以增加内容被编码到你大脑多个区域的机会。
我们以意外的方式使用概念和图片,因为你的大脑适应新奇,我们利用至少某些带有情感内容的图片和想法,因为你的大脑更倾向于注意情感的生物化学。那些让你感觉到某种情绪的事物更容易被记住,即使这种感觉只是一点幽默、惊讶或兴趣。
我们采用了一种个性化的、会话式的风格,因为你的大脑在认为自己处于对话中时会更加专注,而不是认为你在被动听一个演示。即使你在读的时候,你的大脑也是这样做的。
我们包含了活动,因为你的大脑更倾向于在做事情而不是读事情时学习和记忆更多内容。而且我们设计的练习是具有挑战性但可行的,因为这是大多数人偏爱的方式。
我们运用了多种学习风格,因为你可能更喜欢逐步操作,而其他人可能更希望先了解整体图景,还有人可能只想看一个例子。但无论你的学习偏好如何,每个人都会从以多种方式表达的相同内容中受益。
我们为你大脑的两侧提供了内容,因为你能够激活更多的大脑区域,你就更有可能学习和记忆,也能保持更长时间的注意力。由于激活大脑的一侧通常意味着给另一侧一个休息的机会,所以你在学习上能更加高效并保持更长的时间。
我们包含了故事和练习,呈现了多个观点,因为当你的大脑被迫进行评估和判断时,它会更深入地学习。
我们包含了挑战,通过练习和提出问题,这些问题并不总是有一个直接的答案,因为当你的大脑需要努力时,它就会调整学习和记忆。想想看——你不能仅仅通过看健身房里的人来让你的身体变得健康。但我们尽力确保当你努力工作时,你在做的是正确的事情。确保你不会浪费额外的树突来处理难以理解的例子,或解析困难、术语繁多或过于简洁的文本。
我们使用了人。在故事、例子、图片等中,因为,嗯,你是一个人。你的大脑对人比对事物更关注。
这是你可以做的让你的大脑屈服的方法

注意
剪下这部分并贴在你的冰箱上。
所以,我们做了我们的部分。剩下的取决于你。这些建议是一个起点;倾听你的大脑,找出对你有效的和无效的方法。尝试新的事物。
-
放慢速度。你理解得越多,就需要记忆的就越少。
不要只是阅读。停下来思考。当书问你一个问题时,不要直接跳到答案。想象有人真的在问这个问题。你强迫大脑深入思考的程度越深,你学习和记忆的机会就越大。
-
做练习。写下你自己的笔记。
我们把它们放进去了,但如果我们为你做了这些,那就像让别人为你做锻炼一样。不要只是看练习。使用铅笔。有足够的证据表明,在学习过程中进行体力活动可以增加学习效果。
-
阅读“没有愚蠢的问题”。
这意味着所有这些。它们不是可选的侧边栏,它们是核心内容的一部分! 不要跳过它们。
-
让这成为你睡前读的最后一件事。或者至少是最后一个具有挑战性的事情。
学习的一部分(尤其是转移到长期记忆)发生在你放下书后。你的大脑需要自己的时间来进行更多的处理。如果你在处理时间内加入了新的东西,你刚刚学到的一部分将会丢失。
-
大声说出来。
说话会激活大脑的不同部分。如果你试图理解某事,或增加记忆它的机会,大声说出来。更好的是,尝试向别人大声解释。你会学得更快,而且在阅读时可能会发现你之前不知道的想法。
-
喝水。大量的水。
你的大脑在液体中的良好环境中工作得最好。脱水(可能在你感到口渴之前就会发生)会降低认知功能。
-
倾听你的大脑。
注意你的大脑是否过载。如果你发现自己开始浅尝辄止或忘记刚刚读过的内容,那么是休息的时候了。一旦你超过了某个点,通过试图塞更多东西进去来学习得更快,甚至可能会伤害这个过程。
-
感受一些东西。
你的大脑需要知道这件事很重要。参与到故事中去。为照片编写你自己的标题。就算是一个糟糕的笑话也比什么感觉都没有好。
-
多写代码!
学习开发 Android 应用只有一条路:多写代码。这本书将贯穿始终地让你实践。编程是一种技能,唯一的提高方式就是不断练习。每章都包含练习题,这些题目会让你解决问题。不要轻易跳过它们——大部分的学习过程发生在解决问题的时候。每个练习题都有答案,如果遇到困难,可以毫不犹豫地偷看答案!(有时候小问题会让人卡住。)但在查看答案之前,务必尝试自己解决问题。确保在继续学习书中的下一部分之前,已经让代码运行起来了。
阅读我
这是一个学习经验,而不是参考书。我们有意删除了可能妨碍学习的所有内容。第一遍阅读时,你需要从头开始,因为本书对你之前看过和学到的内容有所假设。
我们假设你是 Android 新手,但不是 Kotlin 新手。
你将学习如何使用 Kotlin 和 XML 结合构建 Android 应用。我们假设你熟悉 Kotlin 编程语言,或者其他面向对象的语言如 Java。如果你完全没有接触过 Kotlin 编程,那么在开始本书之前,你可能需要阅读《Head First Kotlin》。
我们从第一章开始构建一个应用程序。
信不信由你,即使你以前从未为 Android 开发过,也可以立即着手构建应用。在此过程中,你将了解 Android Studio,Android 开发的官方 IDE。
示例设计用于学习。
随着你逐步阅读本书,你将构建多个不同的应用程序。其中一些非常小,让你专注于 Android 的特定部分。其他应用程序则更大,让你看到不同组件如何配合。我们不会完全完成每个应用程序的每个部分,但你可以自由地尝试并完成它们。这都是学习过程的一部分。
我们展示代码的上下文。
我们知道,单独展示代码片段而不解释其工作原理或如何在你自己的项目中使用,会让人感到沮丧。在本书中,我们将展示小段代码,并解释每个部分的作用及其原因。然后我们会展示代码在完整项目中的运作方式。你可以从这里下载本书的所有源代码:tinyurl.com/hfad3。
活动不可选。
这些练习和活动不是附加内容;它们是书籍核心内容的一部分。它们中的一些是为了帮助记忆,一些是为了理解,一些将帮助你应用你学到的知识。不要跳过这些练习。
冗余性是故意的,也很重要。
Head First 书籍的一个显著区别在于我们希望你真正理解它。我们希望你完成书籍时记住你学到的东西。大多数参考书并不以保留和回忆为目标,但本书关注学习,因此你会看到一些相同的概念多次出现,以不同的方式。
大脑力量练习没有答案。
对于其中一些问题,没有正确答案,对于其他问题,大脑力量活动的学习经验部分是让你决定你的答案是否正确的过程。在一些大脑力量练习中,你会找到一些提示,指引你朝正确的方向前进。
非常棒的技术审阅团队

Jacqui Cope

Ken Kousen

Ingo Krotzky

Ash Tappin
技术评审人员:
Jacqui Cope 开始编程是为了避免学校的网球练习。从那时起,她积累了大量的经验,与各种金融和教育系统打交道,从使用 COBOL 编码到测试管理。她后来获得了计算机安全硕士学位,并进入高等教育领域的质量保证工作。在业余时间,Jacqui 喜欢烹饪、在乡村散步,并从沙发后面观看Doctor Who。
Ingo Krotzky 在医疗保健行业的多种角色中工作,主要为进行临床试验的合同研究组织——系统架构师、数据库管理员和数据库程序员、软件开发人员和数据工程师。在业余时间,他喜欢生活在乡村(大多数时候是与不太野生的野生动物为伴),与松鼠、松鸦和雀鸟一起进行对坐编程,并探索最热门的新移动框架。
Ken Kousen 是 Java Champion、Oracle Groundbreaker Ambassador 和 Grails Rock Star。他是 Pragmatic Library 书籍Help Your Boss Help You的作者,O’Reilly 书籍Kotlin Cookbook、Modern Java Recipes和Gradle Recipes for Android的作者,以及 Manning 书籍Making Java Groovy的作者。他已经为 O’Reilly Learning Platform 录制了十多门视频课程,涵盖与 Android、Spring、Java、Groovy、Grails 和 Gradle 相关的主题。他还是多次获得 JavaOne Rockstar 奖的获奖者。他的学术背景包括麻省理工学院的机械工程和数学学士学位,普林斯顿大学的航空航天工程硕士和博士学位,以及 RPI 的计算机科学硕士学位。目前,他是位于康涅狄格州的 Kousen IT 公司的总裁。
阿什·塔平是一位精通多种编程语言的软件开发者,主要从事 Web 应用程序开发。他热衷于创建能够为人们生活带来便利的新事物。在不编程的时候,他喜欢练习吉他、听音乐、跑步、骑自行车、探索户外和园艺。
致谢
我们的编辑:
我们非常感谢我们出色的编辑弗吉尼亚·威尔逊在第三版书籍中的辛勤工作。与她合作非常愉快,她给了我们宝贵的反馈和见解。她惊人的组织和引导能力帮助我们保持书籍的进度,并且她努力确保我们在需要的时候拥有一切所需。我们真心感激她的辛勤工作和支持。

O’Reilly 团队:
特别感谢赞·麦克奎德邀请我们撰写这本书的第三版,并且对我们的全力支持;希拉·埃文斯和尼可尔·塔奇为我们提供额外的编辑反馈;设计团队为我们提供了惊人的新艺术作品,并帮助我们替换了旧元素;凯蒂·托泽尔和克里斯汀·布朗使书籍的早期版本能够提前发布。最后,感谢制作团队在幕后专业地推动了书籍的制作过程,并且为此付出了很多努力。

家人、朋友和同事们:
编写 Head First 书籍总是一场充满波澜的旅程,这本书的第三版也不例外。在这条路上,我们真心感激家人和朋友们的善意和支持。特别感谢妈妈、爸爸、罗布、洛林、马克、劳拉、安迪、艾莎、安迪、马蒂、伊恩、瓦内萨、唐恩、威廉和西蒙。
无法忘怀的名单:
我们出色的技术审查团队努力给我们提供了他们对这本书的看法,帮助我们保持正确的方向,并确保我们所覆盖的内容完全正确。我们也感谢所有在这本书的早期版本和前两版中给我们反馈的人们。由于他们的贡献,我们认为这本书变得更加出色。
最后,我们要感谢凯西·西埃拉和伯特·贝茨创作了这一非凡的书籍系列,教会我们抛弃旧的规则,让我们进入他们的思想世界。
O’Reilly 在线学习

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助企业取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问oreilly.com。
第一章:入门:深入了解

Android 是全球最受欢迎的移动操作系统。
而全球有数十亿的 Android 用户,都在等待下载您的下一个伟大创意。在本章中,您将了解如何通过构建基本的 Android 应用并更新它来将您的想法变为现实。您将学习如何在物理和虚拟设备上运行它。在此过程中,您将遇到所有 Android 应用的两个核心组件:活动(activities)和布局(layouts)。让我们开始吧…
欢迎来到 Androidville
Android 是全球最受欢迎的移动平台。据最新统计,全球有超过三十亿活跃的 Android 设备,而这个数字还在不断增长。
Android 是一个基于 Linux 的全面开源平台,由 Google 领导。它是一个强大的开发框架,包含了构建优秀应用所需的一切。更重要的是,它使您能够将这些应用部署到各种设备上——手机、平板电脑等。
那么,一个典型的 Android 应用由什么组成?
我们将使用 Kotlin 和 XML 构建 Android 应用。我们会在过程中解释事情,但您需要一些 Kotlin 的理解才能充分利用本书。
活动定义应用程序的功能
每个 Android 应用包含一个或多个活动(activities)。活动是一种特殊的类——通常用 Kotlin 编写——它控制应用的行为,并决定如何响应用户。例如,如果应用程序包含按钮,则您可以在活动中添加代码以定义用户点击按钮时应执行的操作。

布局定义了每个屏幕的外观
典型的 Android 应用由一个或多个屏幕组成。您可以使用布局(layout)文件或更多活动代码定义每个屏幕的外观。布局通常使用 XML 定义,每个屏幕可以包含按钮、文本和图像等组件。

也许还会有额外的文件
除了活动和布局之外,Android 应用通常还需要额外的资源,如图像文件和应用程序数据。您可以向应用程序添加所需的任何额外文件。

Android 应用实际上只是特定目录中的一堆文件。构建应用程序时,这些文件会被捆绑在一起,从而形成可以在设备上运行的应用程序。
活动和布局构成应用程序的主干
在典型的应用程序中,活动和布局共同定义了应用程序的用户界面。布局告诉 Android 如何排列不同的屏幕元素,活动控制应用程序的行为。例如,如果应用程序包含按钮,则布局指定其位置,活动控制用户点击按钮时应执行的操作。

当您在设备上运行应用程序时,活动和布局如何协同工作:

-
Android 启动应用程序的主活动。
-
活动指示 Android 使用特定的布局。
-
布局将显示在设备上。
-
用户与布局进行交互。
-
活动响应这些交互,并更新显示…
-
…用户在设备上看到的内容。
现在您已经了解了 Android 应用程序的构建方式,让我们继续构建一个基本的 Android 应用程序。
这是我们将要做的事情
现在让我们深入了解并创建一个 Android 应用程序。我们只需要完成以下几个步骤:
-
设置开发环境。
我们需要安装 Android Studio,它包含了开发 Android 应用程序所需的所有工具。
-
构建一个基本的应用程序。
我们将使用 Android Studio 构建一个简单的应用程序,在屏幕上显示一些示例文本。
-
运行应用程序。
我们将在物理设备和虚拟设备上运行应用程序,以便查看其运行情况。
-
更改应用程序。
最后,我们将调整所创建的应用程序,并再次运行它。

Android Studio:您的开发环境

开发 Android 应用程序的最佳方法是使用Android Studio,这是官方的 Android 应用程序开发 IDE。
Android Studio 基于您可能已经熟悉的 IntelliJ IDEA。它包括一组代码编辑器、UI 工具和模板,所有这些都旨在使您在 Android 开发中更加轻松。
它还包括Android SDK(Android 软件开发工具包),这是所有 Android 应用程序开发所必需的。Android SDK 包括 Android 源文件和一个用于将您的代码编译成 Android 格式的编译器。
这是 Android SDK 的一些主要组件:

您需要安装 Android Studio
由于 Android Studio 包含了开发 Android 应用程序所需的所有工具和功能,因此我们将使用它来构建本书中所有展示的应用程序。
在继续之前,您需要在计算机上安装 Android Studio。关于如何进行安装的详细信息,请参阅下一页。
安装 Android Studio
为了从本书中获得最大的收益,您需要安装 Android Studio。我们在这里没有包括完整的安装说明,因为这些信息可能会很快过时,但如果您按照在线说明操作,应该没有问题。
首先,请查看 Android Studio 的系统要求:
developer.android.com/studio#Requirements
注意
这些网址有时会变动。如果不能访问,请搜索 Android Studio,您应该能找到合适的页面。
然后从这里下载 Android Studio:
注意
这些网址有时会变动。如果不能访问,请搜索 Android Studio,您应该能找到合适的页面。
并按照安装说明进行操作
安装完 Android Studio 后,打开它并按照说明添加最新的 SDK 工具和支持库。
在本书中,我们使用的是 Android Studio 2020.3.1(称为北极狐)。请确保至少安装此版本。
如果您之前安装过早期版本的 Android Studio,我们建议您恢复 IDE 的默认设置。 要执行此操作,请转到“文件”菜单,选择“管理 IDE 设置”,然后选择“恢复默认设置”选项。
注意
这将重置 Android Studio 可能保留的任何旧设置,这可能会阻止您的代码运行。
完成后,您应该会看到类似这样的 Android Studio 欢迎屏幕:

让我们构建一个基本的应用程序。

现在您已设置好开发环境,可以开始创建您的第一个 Android 应用程序了。以下是它的外观:

让我们继续构建应用程序。
如何构建应用程序
每当您创建新应用程序时,都需要为其创建一个新项目。确保您已打开 Android Studio,并按照我们的说明进行操作。
1. 创建新项目
Android Studio 的欢迎屏幕提供了多个选项。我们希望创建一个新项目,因此请确保选择了“项目”选项,然后点击“新建项目”。

2. 选择项目模板
接下来,您需要指定要创建的 Android Studio 项目的类型。我们将创建一个在手机或平板电脑上运行的空活动应用程序,因此请确保选择了“手机和平板电脑”选项,并选择“空活动”选项。稍后的几页将介绍“空活动”选项的更多信息,但现在请点击“下一步”按钮进入下一步。

3. 配置您的项目
现在,您需要通过指定应用程序名称、包名称和保存位置来配置项目。输入“我的第一个应用程序”作为名称,输入“com.hfad.myfirstapp”作为包名称,并接受默认保存位置。
您还需要告诉 Android Studio 您要使用哪种编程语言,并指定一个最低 SDK 版本。这指的是应用程序将支持的 Android 最低版本:关于 SDK 级别的更多信息请参阅下一页。
选择 Kotlin 作为语言,并选择 API 21 作为最低 SDK 版本,以便应用程序在大多数设备上运行。点击“完成”按钮后,Android Studio 将创建项目。我们将在接下来的几页中看看这背后的内容。

您已创建了您的第一个 Android 项目
完成新项目向导后,Android Studio 需要大约一分钟来创建项目。在此期间,它执行以下操作:
-
它根据您的规格配置项目。Android Studio 查看您希望应用程序支持的最低 SDK,并包含所需的所有文件和文件夹以生成基本的有效应用程序。它还设置包结构并命名应用程序。
-
它添加了一些模板代码。模板代码包括使用 XML 编写的布局和使用 Kotlin 编写的活动。在本章的后续部分将详细介绍这些内容。
当 Android Studio 完成项目创建后,它会自动为您打开该项目。
这是我们项目的外观(如果看起来复杂,不要担心——我们将在接下来的几页中逐步分解它):

解剖你的新项目
Android 应用程序实际上只是位于特定文件夹结构中的一堆有效文件,当您创建新应用程序时,Android Studio 会为您设置所有这些内容。查看这个文件夹结构最简单的方法是使用 Android Studio 最左侧列中的资源管理器。
文件夹结构包括不同类型的文件
资源管理器包含当前打开的所有项目。这里,我们只有一个名为 MyFirstApp 的项目,这是我们刚刚创建的项目。
如果您浏览资源管理器中的各种文件夹,您会看到向您创建了各种类型的文件和文件夹,例如:
-
Kotlin 和 XML 源文件Android Studio 自动创建了一个名为 MainActivity.kt 的活动文件和一个名为 activity_main.xml 的布局。
-
资源文件这些文件包括默认的图像文件、应用程序可能使用的主题以及应用程序使用的任何常见
String值。 -
Android 库在向导中,您指定了希望应用程序兼容的最低 SDK 版本。Android Studio 确保应用程序包含该版本的相关 Android 库。
-
配置文件配置文件告诉 Android 应用程序包含什么内容以及应用程序应如何运行。

让我们仔细看看项目中一些关键的文件和文件夹。
介绍项目中的关键文件
Android Studio 项目使用 Gradle 构建系统来编译和部署应用程序,Gradle 项目具有标准结构。以下是您将要使用的该结构中的一些关键文件和文件夹。
要查看此文件夹结构视图,请在 Android Studio 的资源管理器中将视图从 Android 更改为 项目。方法是单击资源管理器窗格顶部的箭头,然后选择项目选项。


使用 Android Studio 编辑器编辑代码
使用 Android Studio 编辑器查看和编辑文件。双击要处理的文件,文件内容将显示在 Android Studio 窗口的中间位置。
代码编辑器
大多数文件显示在代码编辑器中,它类似于文本编辑器,但具有额外的功能,如语法高亮和代码检查。
设计编辑器
如果您正在编辑布局(例如activity_main.xml),您有另外一个选择。与其编辑代码,您可以使用设计编辑器,它允许您将 GUI 组件拖放到布局中,并按您的意愿排列它们。代码编辑器和设计编辑器展示了同一文件的不同视图,因此您可以在两者之间切换。

到目前为止的故事
到目前为止,我们已经完成了两件事:
-
我们设置了开发环境。
我们正在使用 Android Studio 开发 Android 应用程序,因此您需要在计算机上安装它。
-
我们已经构建了一个基本的应用程序。
我们使用 Android Studio 创建了一个新的 Android 项目。
您已经在 Android Studio 中看到了应用程序的样子,并对它的运行方式有了一定了解。但是您真正想要的是看到它运行起来,对吧?
Android Studio 允许您以两种方式运行应用程序:在物理 Android 设备上和在虚拟设备上。我们将在接下来的几页中展示每种方法。
如何在物理设备上运行应用程序

如果您使用的是运行 Lollipop 或更高版本的 Android 设备,则可以使用它来运行我们刚刚创建的应用程序。
注意
您可以通过查看设备设置中的 Android 版本来检查此问题。Lollipop 是版本 5.0,因此需要达到或高于 5.0 版本。
运行应用程序在物理设备上的步骤如下:
1. 在您的设备上启用 USB 调试
要允许 Android Studio 在您的设备上运行应用程序,您需要启用 USB 调试。此功能在“开发者选项”设置中可用,默认情况下处于禁用状态。
在您的设备上,转到设置 → 关于手机,点击版本号七次。这将启用开发者选项。然后,转到设置 → 系统 → 高级 → 开发者选项,并启用 USB 调试。
注意
是的,说真的。

2. 配置计算机以检测设备
如果您使用的是 Mac,您可以跳过此步骤。
如果您使用的是 Windows,如果尚未安装 USB 驱动程序,则需要安装一个 USB 驱动程序。最新的说明在这里:
developer.android.com/studio/run/oem-usb
如果您使用的是 Ubuntu Linux,您需要创建一个udev规则文件。如何执行此操作的最新说明在这里:
developer.android.com/studio/run/device#setting-up
3. 使用 USB 电缆将您的设备连接到计算机
您可能会被要求是否允许 USB 调试。如果是,请选中“始终允许此计算机”,然后选择“确定”。
创建应用程序时,我们指定了最低 API 级别为 21(Lollipop)。您的设备需要 Android 版本达到或高于此才能运行该应用程序。
4. 运行应用程序
最后,在 Android Studio 顶部工具栏的设备列表中选择设备(如果不存在,请在您的设备上转到“设置”→“连接的设备”,选择 USB,并选择“文件传输”选项)。然后通过在“运行”菜单中选择“运行应用程序”来运行应用程序。Android Studio 将构建项目,安装应用程序到您的设备上,并启动它。

我们会在前几页查看应用程序,之后我们会看到如何在虚拟设备上运行它。
如何在虚拟设备上运行应用程序
如果您手头没有 Android 设备,或者它没有正确版本的 Android,则可以在虚拟设备上运行应用程序。在虚拟设备上运行应用程序对于想要查看其外观在自己不拥有的设备类型上或测试其在不同 Android 版本上行为的用户很有用。
Android SDK 提供了一个内置的模拟器,您可以使用它来设置一个或多个 Android 虚拟设备(AVD)。一旦您设置了 AVD,您就可以在其上运行应用程序,就像它在物理设备上运行一样。
注意
您可以在此处找到使用模拟器的系统要求:developer.android.com/studio/run/emulator#requirements
模拟器重新创建 Android 设备的精确硬件环境:从其 CPU 和内存到声音芯片和视频显示器。模拟器建立在一个现有的名为 QEMU(发音为“queue em you”)的模拟器上,类似于您可能使用过的其他虚拟机应用程序,如 VirtualBox 或 VMWare。
AVD 的确切外观和行为取决于您如何设置它。例如,如果您创建一个基于运行 Android 11 的 Pixel 3 的 AVD,它将看起来和行为就像在您的计算机上运行此版本 Android 的 Pixel 3 一样。

让我们设置一个 AVD,这样你就可以在模拟器中看到应用程序运行的情况。
创建 Android 虚拟设备(AVD)
在 Android Studio 中设置 AVD 需要经历几个步骤。我们将设置一个运行 API 级别 30(Android 11)的 Pixel 3 AVD,这样您就可以看到应用程序在此类设备上运行的外观和行为。无论您想设置何种类型的虚拟设备,这些步骤基本相同。
打开 Android 虚拟设备管理器
AVD 管理器允许您设置新的 AVD,并查看和编辑您已创建的 AVD。通过在工具菜单中选择 AVD 管理器来打开它。
如果您还没有设置 AVD,则会看到一个屏幕提示您创建一个。点击“创建虚拟设备”按钮。

选择硬件
在下一个屏幕上,您将被提示选择设备定义。这是您的 AVD 将模拟的设备类型。您可以选择各种手机、平板电脑、穿戴设备或电视设备。
我们将看到应用程序在 Pixel 3 手机上运行的外观。从类别菜单中选择手机,并从列表中选择 Pixel 3。然后点击“下一步”。

选择系统镜像
接下来,您需要选择一个系统镜像。这指定了您希望在 AVD 上运行的 Android 版本。
你需要选择一个与你正在构建的应用兼容的 Android 版本。它必须至少是应用支持的最低 SDK 版本。
当你创建 Android 项目时,你指定了最低 SDK 版本是 API 等级 21。这意味着你需要选择一个适用于 API 等级 21(棒棒糖)或更高版本的系统镜像。如果你选择的是比这更旧的 Android 版本,应用将无法在设备上运行。
在这里,我们将看到应用程序在一个相对新的 Android 版本上的外观,因此选择带有版本名称 R 和目标为 Android 11.0(API 等级 30)的系统镜像。然后点击“下一步”。

验证 AVD 配置
在下一个屏幕上,您将被要求验证配置。此屏幕总结了您在过去几个屏幕上选择的选项,并给您更改它们的选项。接受这些选项,并单击“完成”按钮。

虚拟设备正在创建中
单击“完成”按钮时,设备管理器会为您创建虚拟设备,并在 AVD 管理器的虚拟设备列表中显示如下:

检查新的 AVD 是否已列出,然后关闭 AVD 管理器。
在 AVD 上运行应用程序
一旦创建了 AVD,您可以在其上运行应用程序。
要运行应用程序,请确保在 Android Studio 顶部工具栏的设备列表中选择了虚拟设备,然后通过选择“Run ‘app’”命令从运行菜单运行应用程序。
AVD 可能需要一些时间来加载,因此在等待时,让我们看看在使用“Run”命令时背后发生了什么。

编译,打包,部署,运行
“Run”命令不仅运行您的应用程序,还处理所有必要的预备任务,使应用程序能够运行。
这里是发生的概述:
APK 文件是 Android 应用程序包。它就像 Android 应用程序的 ZIP 或 JAR 文件。

-
Kotlin 源文件会被编译成字节码。
-
一个 Android 应用程序包(APK)被创建。
APK 文件包括编译的 Kotlin 文件,以及应用程序所需的任何库和资源。
-
APK 已安装到设备上。
如果设备是虚拟的,Android Studio 将启动模拟器,并等待 AVD 激活后再安装 APK。
如果设备是物理设备,它只是安装 APK。
-
设备启动应用程序的主活动。
应用程序显示在设备屏幕上,并准备好供您使用。
现在您知道在使用“Run”命令时会发生什么了,让我们看看我们构建的应用程序的外观。
测试驾驶
确保通过从运行菜单中选择“运行‘应用程序’”命令在物理或虚拟设备上运行应用程序。
Android Studio 将应用程序加载到设备上并启动它。屏幕顶部显示应用程序名称“My First App”,中心显示文本“Hello World!”。

让我们回顾刚才发生的事情。
刚才发生了什么?
让我们分解运行应用程序时发生的事情:
-
Android Studio 将应用程序安装在设备上。
如果设备是虚拟的,在安装应用程序之前会等待模拟器启动。
![图片]()
-
Android 启动应用程序的主活动。
它使用MainActivity.kt中的代码(Android Studio 自动包含在项目中)来创建一个
MainActivity对象。![图片]()
-
MainActivity 指定使用布局 activity_main.xml。
![图片]()
-
布局显示在屏幕上。
文本“Hello World!”显示在屏幕中央。
![图片]()
让我们优化应用程序

到目前为止,在本章中,您已经构建了一个基本的 Android 应用程序,并在物理或虚拟设备上看到它运行。接下来,我们将对应用程序进行优化。
目前,应用程序显示向导放置的示例文本“Hello World!”作为占位符。您将更改该文本以显示其他内容。那么我们需要更改什么才能实现这一点呢?
要回答这个问题,让我们退一步看看应用程序当前是如何构建的。

应用程序有一个活动和一个布局
当我们构建应用程序时,我们告诉 Android Studio 如何配置它,向导完成了其余工作。向导为我们创建了一个活动,还创建了一个默认布局。
活动控制应用程序的功能
Android Studio 为我们创建了一个名为MainActivity.kt的活动。该活动指定了应用程序的功能以及如何响应用户。
布局控制应用程序的外观
MainActivity.kt使用 Android Studio 为我们创建的名为activity_main.xml的布局。布局指定了应用程序的外观。

我们希望通过更新显示的文本来更改应用程序的外观。这意味着我们需要更新控制应用程序外观的文件,因此我们需要更仔细地查看布局。
布局中有什么?
我们想要更改 Android Studio 为我们创建的示例“Hello World!”文本,因此让我们从布局文件activity_main.xml开始。如果尚未打开,请在资源管理器中的app/src/main/res/layout文件夹中找到该文件并双击打开。

设计编辑器
正如您之前了解的,Android Studio 有两种查看和编辑布局文件的方式:通过设计编辑器和代码编辑器。
当您选择设计选项时,可以看到示例文本“Hello World!”如预期出现在布局中。但底层的 XML 内容是什么呢?
让我们切换到代码编辑器看看。

代码编辑器
点击编辑器顶部的“Code”选项切换到代码编辑器。这将显示布局的底层 XML。
让我们仔细看看代码。
activity_main.xml 包含两个元素
下面是 Android Studio 为我们生成的 activity_main.xml 代码。我们略去了一些你现在不需要考虑的细节;这些将在本书的后续部分详细介绍。
这是代码:


正如你所见,代码包含两个元素。
第一个是 <...ConstraintLayout> 元素。这是一种布局元素类型,告诉 Android 如何在设备屏幕上显示组件。您可以使用各种类型的布局,后面的章节将详细介绍这些内容。
目前最重要的元素是第二个元素,即 <TextView>。此元素用于向用户显示文本,在本例中显示的是文本 “Hello World!”
<TextView> 元素中的关键部分是以 android:text 开头的行。这是描述应显示的文本的 text 属性:

在你完成以下练习后,我们将把文本更改为其他内容。
更新布局中显示的文本
我们想要更改 activity_main.xml 中的文本,以便在运行应用程序时显示除 “Hello World!” 以外的其他内容。我们可以通过更改布局中 <TextView> 元素的 text 属性来实现这一点:

text 属性在 <TextView> 元素内部使用 android:text 代码进行定义。它指定了应显示的文本内容,在本例中是 “Hello World!”

要更新布局中显示的文本,只需将 <TextView> 元素的 text 属性值从 "Hello World!" 更改为其他文本,例如 "Pow!"。新的 <TextView> 元素的代码应如下所示:

这是更新文本所需的唯一更改。让我们看看代码运行时会发生什么。
代码的功能
在进行应用程序测试之前,让我们先了解一下代码的功能。
-
Android 使用 MainActivity.kt 创建 MainActivity 活动对象。
![image]()
-
MainActivity 指定使用布局 activity_main.xml。
![image]()
-
布局在设备上的中心显示文本 “Pow!”。
![image]()
测试驾驶
编辑完文件后,尝试再次在模拟器中运行应用程序,方法是从运行菜单中选择“运行‘app’”命令,或者点击运行按钮。您应该看到应用程序现在显示“Pow!”而不是“Hello World!”

恭喜!您现在已经构建并更新了您的第一个应用程序,并在此过程中了解了 Android 应用程序是如何组合在一起的。在下一章中,我们将进一步构建一个您可以与之交互的应用程序。
您的 Android 工具箱

您已经掌握了第一章,现在您已经将 Android 基本概念添加到您的工具箱中。

第二章:构建交互式应用程序:做一些事情的应用程序

大多数应用程序需要以某种方式响应用户。
在本章中,您将看到如何使您的应用程序更具交互性。您将了解如何向活动代码添加OnClickListener,以便您的应用程序监听用户的操作,并做出适当的响应。您将更多了解如何设计布局,并了解您添加到布局的每个 UI 组件是如何派生自一个共同的 View 祖先。在此过程中,您将发现为什么字符串资源对于灵活、设计良好的应用程序如此重要。
让我们构建一个 Beer Adviser 应用程序
当您创建 Android 应用程序时,通常希望它做一些事情。
在本章中,我们将向您展示如何创建用户可以与之交互的应用程序。我们将创建一个 Beer Adviser 应用程序,用户可以选择他们最喜欢的啤酒颜色,点击按钮,然后获得一系列值得尝试的美味啤酒。

这是应用程序的结构:
-
布局 activity_main.xml 指定应用程序的外观。
它包括三个 UI 组件:
-
一个名为 spinner 的值下拉列表,允许用户选择他们想要的啤酒颜色
-
一个按钮,当点击时,将返回一系列啤酒
-
一个显示啤酒的文本视图
-
-
文件 strings.xml 包含布局所需的任何字符串资源。
例如,按钮的标签和啤酒颜色。
-
活动 MainActivity 指定应用程序应如何与用户交互。
它获取用户选择的啤酒颜色,并使用此信息显示用户可能感兴趣的啤酒列表。
这是我们要做的事情
让我们开始工作吧。构建 Beer Adviser 应用程序需要您经历一些步骤(我们将在本章的其余部分解决这些问题):
-
创建一个项目。
您正在创建一个全新的应用程序,因此您需要创建一个包含空活动和布局的新项目。
![图片]()
-
更新布局。
一旦您设置好项目,您需要修改布局,以便包含应用程序所需的所有 UI 组件。
![图片]()
-
添加字符串资源。
我们将用
String资源替换任何硬编码文本,以便应用程序使用的所有文本都保存在单个文件中。![图片]()
-
使按钮响应点击。
布局仅指定视觉效果。要使按钮在点击时执行某些操作,您需要编写一些活动代码。
![图片]()
-
编写应用程序逻辑。
您将向活动添加一个新方法,并使用它确保用户根据其选择获得正确的啤酒。
让我们从创建项目开始。
创建项目

创建新项目的步骤几乎与我们在上一章中使用的步骤相同:
-
打开 Android Studio,关闭任何已打开的项目,并从欢迎屏幕选择“新建项目”。这将启动你在第一章中看到的向导。
-
确保选择了“手机和平板”选项,并选择“空活动”选项。
-
输入“Beer Adviser”作为名称,包名称为“com.hfad.beeradviser”,并接受默认保存位置。确保语言设置为 Kotlin,并且最低 SDK 为 API 21,这样可以在大多数 Android 设备上运行。然后点击完成按钮。

我们已创建了一个默认的活动和布局

当你点击完成按钮时,Android Studio 将创建一个新项目,包含名为 MainActivity.kt 的活动和名为 activity_main.xml 的布局,就像我们在第一章创建的项目一样。我们需要修改这些文件以使应用程序看起来和行为符合我们的期望。
我们将从更新布局文件 activity_main.xml 开始修改应用的外观。我们将在接下来的几页逐步构建布局,但现在,请切换到 Android Studio 资源管理器的项目视图,进入 app/src/main/res/layout 文件夹,打开 activity_main.xml 文件。然后切换到代码编辑器,并用以下内容替换 activity_main.xml 中的整个代码:


上面的代码包含一个线性布局(由 <LinearLayout> 元素表示)和一个文本视图(由 <TextView> 元素表示)。稍后你会了解更多关于这些元素的信息,但现在你只需知道线性布局用于在垂直列中排列 UI 组件,而文本视图显示的文本是“Beer types”。
对布局的 XML 所做的任何更改都会反映在设计编辑器中。现在通过点击编辑器窗格顶部的“设计”选项切换到该视图。

更详细查看设计编辑器
正如你在第一章学到的,设计编辑器为你提供了一种比编辑 XML 更直观的方式来编辑布局代码。它展示了布局设计的两种不同视图。一种显示布局在实际设备上的外观,另一种显示其结构的蓝图:


设计编辑器的左侧是一个包含可拖动到布局中的组件的调色板。你将在下一页使用它来向布局添加按钮,在本章后面将用它来更新应用中显示的文本。

使用设计编辑器添加按钮
要向布局中添加按钮,请在工具栏中找到按钮组件,点击它,然后将其拖到设计编辑器中,使其位于文本视图上方。按钮将出现在布局的设计中:

设计编辑器中的更改会反映在 XML 中
拖动 UI 组件到布局中像这样是更新布局的一种便捷方式。如果您切换到代码编辑器,您会看到通过设计编辑器添加按钮已经向底层 XML 添加了更多代码:

activity_main.xml 中有一个新的按钮
正如您刚刚看到的,设计编辑器已经向 activity_main.xml 添加了一个新的 <Button> 元素。其代码如下:
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button" />
Androidville 中的按钮是用户可以点击以触发操作的 UI 组件。<Button> 元素包括控制其大小和外观的属性。这些属性并不只属于按钮——其他如文本视图的 UI 组件也有它们。
按钮和文本视图都是 Android View 类的子类
按钮和文本视图之间具有共同属性的一个很好的原因是它们都继承自相同的 Android View 类。在本书中您会进一步了解更多,但现在,这里是一些最常见的属性:
android:id
这给组件一个标识名称,以便活动代码可以访问它并控制其行为:
android:id="@+id/button"
android:layout_width, android:layout_height
这些属性指定了组件的宽度和高度。"wrap_content" 表示它应该只大到足够容纳内容,"match_parent" 表示它应该与包含它的布局一样宽:
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text
这告诉 Android 应该显示什么文本作为组件,比如出现在按钮上的文本:
android:text="Button"

更仔细查看布局代码
让我们更仔细地查看布局代码,并将其分解,以便您可以看到它实际上在做什么(如果您的代码看起来有些不同,不要担心,只需跟着操作即可):

<LinearLayout> 元素
布局代码中的第一个元素是 <LinearLayout>。此元素告诉 Android 布局中的不同 UI 组件应该在单行或单列中依次显示。
您可以使用 android:orientation 属性来指定方向。在这个例子中,我们使用的是:
android:orientation="vertical"
因此,UI 组件显示在单个垂直列中。
<LinearLayout> 元素(在前一页中)包含两个进一步的元素:一个 <Button> 和一个 <TextView>。
<Button> 元素
第一个元素是 <Button>:
...
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button" />
...
使用线性布局意味着 UI 组件显示在垂直列或水平行中。
由于这是 <LinearLayout> 中的第一个元素,它在布局中首先显示(在屏幕顶部)。它的 layout_width 是 "match_parent",使其宽度与其父元素 <LinearLayout> 一样宽。它的 layout_height 是 "wrap_content",这意味着它应该只足够高以显示其文本内容。
<TextView> 元素
<LinearLayout> 中的最后一个元素是 <TextView>:
...
<TextView
android:id="@+id/brands"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Beer types" />
...

由于这是第二个元素,并且我们将<LinearLayout>元素的方向设置为"vertical",它显示在按钮(第一个元素)的下方。它的layout_width和layout_height属性都设置为"wrap_content",以便它只占用足够的空间来容纳其文本。
您已经看到将组件添加到设计编辑器会将它们添加到布局 XML 中。相反,对布局 XML 进行的任何更改也会应用到设计中。让我们看看这个过程。
让我们更新布局 XML
我们将通过添加一个新的旋转器组件来更新布局,并微调已经存在的按钮和文本视图组件。旋转器是一个值的下拉列表。当您点击它时,它会展开以显示列表,这样您就可以选择一个单一的值。
使用以下更改更新activity_main.xml代码(用粗体标出):

设计编辑器中反映了 XML 的更改
一旦您更改了布局 XML,切换到设计编辑器。设计编辑器现在显示一个水平居中的单列,其中包含一个旋转器、按钮和文本视图,而不是显示一个按钮下面有一个文本视图的布局,就像这样:

旋转器提供一个值的下拉列表。它允许您从一组值中选择一个单一的值。
您添加到布局文件中的所有 UI 组件(如按钮、旋转器和文本视图)使用相同或类似的属性,因为它们都是 View 类型。在幕后,它们都继承自相同的 Android View 类。
注意
这些 UI 组件通常被称为视图,因为它们都继承自相同的 View 类。
我们现在已经向activity_main.xml添加了 Beer Adviser 应用程序布局所需的所有组件。我们还有更多工作要做,但让我们先测试一下应用程序,看看它在设备上的效果。
测试驱动
通过从运行菜单中选择“运行‘app’”命令或单击运行按钮来运行应用程序,并耐心等待应用程序加载。
当应用程序出现在您的设备上时,请注意它以单列显示一个空的旋转器、按钮和文本视图。

布局中有警告...

当您在 Android Studio 中开发布局时,IDE 会自动检查您的代码是否有错误,并提醒您可能的改进。查看任何警告或建议的简单方法是切换到布局的设计编辑器视图,并查看组件树面板。该面板通常位于调色板下方,并显示布局中组件的分层树。
如果 Android Studio 有任何改进代码的建议,你会看到相关组件右侧的徽章或图标。例如,在find_beer和brands组件旁边有警告徽章。如果我们将鼠标悬停在每个徽章上,就能看到有关硬编码文本的警告信息:


…因为有硬编码文本
当我们定义布局时,我们使用如下的代码来硬编码需要在文本视图和按钮组件中显示的文本:

当你正在学习时,这种方法是可以接受的,但在布局中硬编码文本并不是最佳做法。
假设你已经创建了一个在本地 Google Play 商店非常受欢迎的应用。你不想仅限于一个国家或语言——你希望能够国际化并支持不同的语言。但如果你在布局文件中硬编码了所有的文本,那么将应用推广到全球将会很困难。
这也使得对应用中的文本进行全局更改变得更加困难。想象一下,如果你的老板要求你因为公司更改了名称而修改应用中的措辞。如果你把所有文本都硬编码了,这意味着你可能需要编辑大量文件才能修改文本。
那么,有什么替代方法呢?
将文本放入字符串资源文件中
更好的方法是将文本值放入**String** 资源文件中。这样做可以更轻松地对应用中使用的文本进行全局更改。你不再需要在各种不同的活动和布局文件中更改硬编码的文本值,只需编辑资源文件中的文本即可。
这种方法也使得对应用进行本地化变得更加容易。你不再需要在一个语言中硬编码文本,而是可以为每种想要支持的语言提供单独的String资源文件。这使得应用可以根据设备的语言环境切换所使用的语言。
Android Studio 帮助你提取字符串资源
如果你的布局中包含硬编码文本,Android Studio 提供了一种简单的方法来提取文本并将其添加到字符串资源文件中。只需点击(或双击)每个警告你有关硬编码文本的徽章,然后点击“修复”按钮来解决问题。
注意
你可能需要向下滚动才能看到这个按钮。
让我们尝试这个方法来处理布局中的一个组件。确保你正在使用activity_main.xml的设计视图,并点击find_beer组件旁边的警告徽章。
关于为什么硬编码文本是个问题,你将会看到一个解释。请滚动到这个解释的末尾,然后点击“修复”按钮:

提取字符串资源
当你点击“修复”按钮时,“提取资源”窗口会出现。这允许你指定String资源的名称、值以及String资源文件的名称。确保资源名称为“find_beer”,文件名为“strings.xml”,源集为“main”,并检查值目录。然后点击 OK 按钮。
当你点击 OK 按钮时,Android Studio 会将find_beer组件的硬编码文本添加到名为strings.xml的String资源文件中,并更改布局的 XML 以使用String资源。我们将查看这两个更改,首先是String资源文件。

已将一个 String 资源添加到 strings.xml 中。
strings.xml是应用的默认String资源文件,当你创建新项目时,Android Studio 会自动为你创建这个文件。现在通过 Android Studio 的资源管理器打开strings.xml:你会在app/src/main/res/values文件夹中找到strings.xml。
文件内容应该类似于这样:

<resources>
<string name="app_name">Beer Adviser</string>
<string name="find_beer">Find Beer</string>
</resources>
上面的代码描述了两个String资源,每个资源都是一个名称/值对。第一个资源名为app_name,值为“Beer Adviser”,而第二个名为find_beer,值为“Find Beer”。第二个资源是我们为find_beer组件提取硬编码文本时添加的:

我们将在接下来的几页详细讨论String资源,但现在让我们看看对activity_main.xml进行了什么更改。
activity_main.xml 使用了字符串资源。
当我们告诉 Android Studio 提取硬编码文本时,Android Studio 会自动更新activity_main.xml中的find_beer按钮,以便使用提取的String资源。
这是按钮的更新代码:


正如你所看到的,find_beer按钮的text属性已更改为"@string/find_beer"。那这意味着什么呢?
让我们从@string的第一部分开始。这只是一种告诉 Android 从String资源文件中查找文本值的方法。在这里,它就是你之前看到的strings.xml文件。

第二部分,find_beer,告诉 Android 查找名为find_beer的资源的值。所以"@string/find_beer" 就像是说“查找名为find_beer的String资源,并使用相关联的文本值。”

你也可以手动提取字符串资源。
现在你已经学会了当你要求 Android Studio 将硬编码文本提取到String资源中时,Android Studio 对代码所做的更改。你也可以通过直接更新strings.xml和activity_main.xml中的代码来进行这些更改。
让我们看看如何通过更改brands组件的text属性中使用的硬编码文本“Beer types”,使其使用一个String资源。
注意
在您自己的项目中,可能只需使用向导。我们向您展示如何手动编辑 XML,因为我们需要确保您创建的代码与我们的代码匹配,并且更新 XML 是实现此目的的最佳方法。
添加并使用新的 String 资源
我们将从创建名为brands的新String资源开始。打开app/src/main/res/values文件夹中的strings.xml,并添加一个新行以包含此处显示的更改(用粗体标出):

添加了String资源后,打开activity_main.xml并更新brands文本视图的代码,使其使用新资源。更新代码以包含此处显示的更改(用粗体标出):

下一页有关使用String资源的摘要。之后,我们将对应用程序进行测试。
测试驱动
现在我们已更新布局以使用String资源而不是硬编码文本值,让我们运行应用程序看看效果。选择运行菜单中的“Run 'app'”命令,如前所述。
运行应用程序后,按钮和文本视图中显示的文本已更新,以使用我们添加到strings.xml的String值:

向下拉列表框添加值
当前应用程序的布局中包含一个下拉列表框,但是当我们点击它时,它是空的。这是因为我们还没有告诉下拉列表框应显示哪些值。每次在布局代码中使用下拉列表框时,都必须指定相关联的值列表,否则它将不包含任何值,并且 Android Studio 可能会显示警告消息。
你可以像指定按钮或文本视图中显示的文本一样指定下拉列表框的值列表:通过向strings.xml添加资源并在布局中引用该资源。但是,你不是在一个String资源中指定单个值,而是向array资源添加多个String,并将此数组用于下拉列表框的值列表。
资源是应用程序使用的非代码资产,例如图像或字符串。
添加一个String数组资源类似于添加一个 String
正如您已经了解的那样,可以使用以下方式向strings.xml添加String资源:
<string name="string_name">string_value</string>
其中string_name是String的名称,而string_value是在应用中显示的值。
要向String资源文件添加一个String数组,可以使用以下语法:

其中string_array_name是数组的名称,而string_value1、string_value2和string_value3是构成数组的各个String值。
在此应用中,我们希望添加一个String数组资源,数组中的每个项都是一种啤酒颜色。然后,我们将此数组附加到下拉列表框,以便用户单击下拉列表框时显示啤酒颜色。
让我们添加新的String数组。
向strings.xml添加String数组
要添加 String 数组,请打开 strings.xml,并添加以下代码(用粗体标出)。这将添加一个名为 beer_colors 的 string-array 资源,我们将其附加到 spinner 上:

获取 spinner 显示数组的值
布局引用 String 数组资源的语法与检索 String 资源的值类似。而不是使用:
您可以使用以下语法:

where array_name 是数组的名称。
让我们在布局中使用它。转到布局文件 activity_main.xml,并像这样为 spinner 添加一个 entries 属性(用粗体标出):

我们将在下一页展示 activity_main.xml 的完整代码。
activity_main.xml 的完整代码如下
下面是 activity_main.xml 的全部代码。确保此文件的代码包含此处显示的所有代码。

让我们来测试一下这个应用。
测试驱动
让我们看看这些更改对应用程序产生了什么影响。运行应用程序,您应该得到类似于这样的结果:

到目前为止,我们已经创建了一个布局(activity_main.xml),其中包括一个 spinner、一个按钮和一个文本视图。这些视图使用一个 String 资源文件(strings.xml)来获取它们的字符串和数组值。
我们接下来需要做的是每次用户点击按钮时,让应用程序更新 brands 文本视图。
我们需要使应用程序具有交互性

Beer Adviser 应用程序具有正确的外观,并包含我们需要的所有视图,但目前还没有提供任何啤酒推荐。为了使应用程序具有交互性,我们需要在用户点击 find_beer 按钮时让应用程序执行某些操作。我们希望应用程序的行为类似于这样:
-
用户从 spinner 中选择啤酒颜色并点击按钮。
-
MainActivity 响应按钮点击的代码。
-
MainActivity 将选定的啤酒颜色传递给我们将创建的名为 getBeers 的方法。
getBeers()方法找到与啤酒颜色匹配的品牌。 -
MainActivity 更新品牌文本视图,以在设备上显示推荐啤酒的列表。

要使应用程序以这种方式响应用户,我们需要更新 MainActivity.kt 中的代码,因为这段代码负责应用程序的行为。当我们创建项目时,Android Studio 为我们创建了这个文件,所以让我们看看当前的代码。
MainActivity 代码示例
当我们创建项目时,Android Studio 为我们创建了 MainActivity.kt。如果尚未打开,请转到 app/src/main/java 文件夹,双击打开该文件。
这是 Android Studio 为我们创建的 MainActivity.kt 的代码:

上面的代码就是创建基本活动所需的全部内容。如您所见,它是一个扩展了AppCompatActivity并重写了其onCreate()方法的类。
所有活动(不仅仅是这一个)都必须扩展一个活动类,比如AppCompatActivity。有关此内容的更多信息,请参见第五章,但现在您只需知道,当一个类扩展AppCompatActivity时,它将您的普通 Kotlin 类转换为一个完整的、持卡的 Android 活动。

所有活动还必须实现onCreate()方法。当活动对象创建时,此方法将被调用,并用于执行基本设置,例如活动关联的布局是什么。这是通过调用setContentView()完成的。在上面的示例中,代码如下:
setContentView(R.layout.activity_main)
告诉 Android 此活动使用 activity_main.xml 作为其布局。

您现在知道当前MainActivity代码的作用了。那么当用户点击find_beer按钮时,我们如何让它做出响应呢?
按钮可以监听点击事件…
每当用户在您的应用程序中执行操作时,称为事件。Androidville 中有许多不同的事件类型,例如点击按钮、滑动屏幕或按设备上的硬件键。
在这个应用程序中,我们希望知道用户何时点击find_beer按钮,以便我们能够对其做出响应。我们可以让应用程序监听按钮的点击事件,以便每次事件发生时,我们可以更新brands文本视图中的文本。
您可以通过向按钮添加一个 OnClickListener 来使按钮响应点击事件。
…使用 OnClickListener
您可以通过向按钮添加一个**OnClickListener**来让应用程序监听按钮的点击事件。每次点击按钮时,OnClickListener会“听到”点击事件并做出响应。

通过传递一个代码块(lambda)来指定OnClickListener应该执行的操作,您可以指定当按钮被点击时应该发生什么。
注意
如果您对 Kotlin lambda 的了解感觉有点生疏,我们建议您参考《Head First Kotlin》。
在这里,我们希望每次点击find_beer按钮时更新brands文本视图。这意味着我们需要向find_beer按钮添加一个OnClickListener,并传递一个 lambda 表达式来告诉它如何更新文本视图。
让我们找出如何做到这一点。
获取按钮的引用…
要向find_beer按钮添加一个OnClickListener,您首先需要在活动代码中获取按钮的引用。您可以使用一个名为**findViewById**的方法来实现这一点。
findViewById方法允许您获取布局中具有特定 ID 的任何视图的引用。只需指定视图的类型和 ID,该方法就会返回对它的引用。
在这个应用程序中,我们希望获取 ID 为“find_beer”的按钮的引用,因此我们使用以下代码:

…并调用它的setOnClickListener方法
一旦你有了按钮的引用,你可以通过调用其setOnClickListener()方法来为其添加OnClickListener,例如以下代码:

请注意,我们将OnClickListener代码添加到MainActivity的onCreate()方法中。onCreate()在活动创建时运行,因此在这里添加setOnClickListener()调用意味着find_beer按钮将尽快开始响应点击。
现在我们已经将OnClickListener添加到按钮上了,让我们在按钮被点击时做一些事情。
将 lambda 传递给setOnClickListener方法
通过将 lambda 传递给其setOnClickListener()方法,使按钮在点击时执行某些操作。lambda 指定每次点击按钮时要执行的操作。

因此,如果你希望按钮更新一些文本或执行其他操作,你可以将代码放在 lambda 中来实现这一点:

最终,我们希望活动显示一系列啤酒推荐,但现在让我们通过按钮更新brands文本视图显示在beer_color下拉列表框中选择的值。在专注于获取一些真正的啤酒建议之前,这将允许我们测试按钮的OnClickListener是否有效。
为了实现这一点,我们还需要了解两件事:如何编辑文本视图中的文本以及如何获取下拉列表框中选择的值。
如何编辑文本视图的文本
正如你已经学到的那样,通过更新布局 XML 中的text属性来改变文本视图中显示的文本。要使用活动代码更新文本,我们可以获取文本视图的引用并更新其text属性。
要编辑在brands文本视图中显示的文本以显示“Gottle of geer”,例如,你可以使用以下代码:

在 Beer Adviser 应用程序中,我们希望更新文本视图的text属性,以显示用户在beer_color下拉列表框中选择的值。为此,我们需要找出如何获取此值。
如何获取下拉列表框的值
你可以使用其**selectedItem**属性获取下拉列表框中的当前值。例如,要从beer_color下拉列表框中获取当前值,你可以使用以下代码:

selectedItem属性可以保存任何类型的值,而不仅仅是String,因此在使用之前需要将其值转换为相应的类型。
例如,在 Beer Adviser 应用程序中,我们知道beer_color下拉列表框保存了一个String值的数组,因此用户选择的项目必须是一个String。要使用此值,我们因此需要使用以下代码将其转换为String:

你也可以在String模板中使用以下代码:
**val color = "${beerColor.selectedItem}"**
现在你已经了解如何让find_beer按钮获取beer_color下拉列表的值,并在brands文本视图中显示它。在我们展示完整代码之前,看看你是否可以自己拼凑它,尝试以下练习。
池谜题

你的任务是从池中提取代码片段,并将它们放入MainActivity的onCreate()方法中的空白行中。你不能多次使用相同的代码片段,也不需要使用所有的片段。你的目标是使find_beer按钮在点击时响应,通过更新brands文本视图显示在beer_color下拉列表中选择的值。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val findBeer = findViewById<Button>(R.id.find_beer)
findBeer............................{
val beerColor = findViewById<Spinner>(....................)
val color = beerColor.
val brands = findViewById<............>(R.id.brands)
brands.........= "Beer color is........................."
}
}

注意
注意:池中的每个项目只能使用一次!
池谜题解决方案

你的任务是从池中提取代码片段,并将它们放入MainActivity的onCreate()方法中的空白行中。你不能多次使用相同的代码片段,也不需要使用所有的片段。你的目标是使find_beer按钮在点击时响应,通过更新brands文本视图显示在beer_color下拉列表中选择的值。


更新后的 MainActivity.kt 代码
我们希望更新MainActivity,使find_beer按钮响应点击。每次点击按钮时,我们希望更新在brands文本视图中显示的文本,以包括用户在下拉列表中选择的啤酒颜色。
下面是更新后的MainActivity.kt代码,包含你在上一个练习中拼凑在一起的代码。更新MainActivity.kt以包含这些更改(用粗体表示):

在我们测试代码之前,让我们看看代码运行时的操作。
运行代码时会发生什么
运行应用程序时,选择一种啤酒颜色,然后单击“查找啤酒”按钮,会发生以下事情:
-
用户从下拉列表中选择一种啤酒颜色,然后单击“查找啤酒”按钮。
![image]()
-
按钮的 OnClickListener 监听到被点击的事件。
![image]()
-
MainActivity 中的 OnClickListener 代码检索下拉列表当前选择的值(在本例中为 Amber)。
![image]()
-
然后更新文本视图的文本属性,以反映用户在下拉列表中选择了 Amber。
![image]()
测试驱动
确保你已经更新了MainActivity.kt,然后运行应用程序。
当我们从下拉列表中选择一种啤酒颜色,然后单击“查找啤酒”按钮时,我们选择的值将显示在文本视图中。

添加getBeers()方法

现在我们知道find_beer按钮可以响应点击事件,让我们改变它的行为,使得每次用户点击按钮时,根据下拉框中选择的值提供一些真实的啤酒建议。我们将在MainActivity.kt中添加一个新的getBeers()方法,然后在按钮的OnClickListener代码中调用它。
getBeers()方法是纯 Kotlin 代码。它有一个String参数用于啤酒颜色,并返回一个啤酒建议的List<String>。在MainActivity.kt中添加以下粗体显示的getBeers()方法:

接下来,我们需要更新传递给find_beer按钮的setOnClickListener()方法的 lambda 表达式。我们将使其将从下拉框中选择的啤酒颜色传递给getBeers()方法,并更新brands文本视图以显示其返回值。
看看你是否能通过尝试以下练习来拼凑代码。
活动磁贴

有人用冰箱贴完成了MainActivity的代码,但是一场厨房旋风把一些磁贴吹散了。你能把代码再拼回来吗?
代码需要调用getBeers()方法,并将其返回的每个项目显示在brands文本视图中。每个项目应显示在新行上。

活动磁贴解决方案

有人用冰箱贴完成了MainActivity的代码,但是一场厨房旋风把一些磁贴吹散了。你能把代码再拼回来吗?
代码需要调用getBeers()方法,并将其返回的每个项目显示在brands文本视图中。每个项目应显示在新行上。

MainActivity.kt 的完整代码
下面是MainActivity的完整代码。应用粗体显示的更改到MainActivity.kt。

在我们进行最终测试前,让我们先了解一下代码在运行时的作用。
当你运行代码时会发生什么?
应用运行时发生以下几件事情:
-
当用户点击查找啤酒按钮时,按钮的 OnClickListener 监听到了点击事件。
![image]()
-
MainActivity 中的 OnClickListener 代码调用 getBeers()方法,传入从下拉框中选择的啤酒颜色。
getBeers()方法返回一个啤酒列表,MainActivity将其保存在一个单独的变量中。![image]()
-
MainActivity 格式化啤酒列表,并用它设置文本视图的文本属性。
![image]()
让我们来测试一下这个应用。
测试驱动
一旦你对应用做出了更改,就可以运行它了。
当我们尝试选择不同类型的啤酒并点击“查找啤酒”按钮时,应用程序会显示一系列合适的啤酒。当我们选择“浅色”选项时,会得到一组啤酒,选择“琥珀”选项时会得到另一组啤酒。

恭喜!你现在已经完成了你的第一个交互式安卓应用程序的编写,并学会了如何使布局的视图响应用户。
你的安卓工具箱

你已经掌握了第二章,现在你的工具箱中又增加了构建交互式安卓应用。

第三章:布局:成为一个布局

我们仅仅触及到了布局的表面。
到目前为止,你已经看到如何在一个简单的线性布局中排列视图,但是布局还能做更多。在本章中,我们将深入一点,向你展示布局的真正工作方式。你将学会如何微调你的线性布局。你将了解如何使用帧布局和滚动视图。到本章结束时,你会发现,尽管它们可能看起来有点不同,但所有的布局及其添加的视图有更多的共同点。
一切从布局开始
正如你已经知道的,布局文件是用 XML 编写的,它们让你定义应用的外观。
每次你编写布局,你需要做三件事:
-
指定布局类型。
你通过指定一种布局类型来告诉 Android 如何排列任何视图(如按钮和文本视图)。例如,线性布局将视图按照线性列或行依次排列。
<LinearLayout ...> </LinearLayout> -
指定视图。
每个布局包含一个或多个视图,你的应用程序用它们来显示信息或与用户交互。
<Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Click me" /> -
告诉活动使用布局。
通过向活动添加类似这样的 Kotlin 代码,告诉 Android 哪个活动使用你刚刚定义的布局。
setContentView(R.layout.activity_main)

Android 有不同类型的布局
Android 有不同类型的布局,每种布局对其视图的排列方式都有自己的规则。例如,线性布局总是按照线性行或列排列视图,而帧布局则将其视图堆叠在一起。你使用哪种类型的布局取决于你想要在设备屏幕上如何排列视图。

为你的屏幕设计选择最佳布局
到目前为止,你所见过的所有应用程序都使用线性布局将视图按单列排列。在本章中,我们将深入研究线性布局,并向你介绍另外两种类型:帧布局和滚动视图。
让我们从线性布局开始。
让我们构建一个线性布局

我们将使用线性布局来构建下面显示的布局。在这里选择线性布局是个不错的选择,因为视图是在单列中排列的。正如你已经知道的,线性布局将视图按照垂直列或水平行依次排列。
布局由两个可编辑的文本视图(允许输入文本的文本视图)和一个按钮组成。这是我们想要布局看起来的样子:

创建一个新项目
我们将使用一个新的 Android Studio 项目来创建线性布局应用。
使用与前几章相同的步骤创建新项目。选择空活动选项,输入名称“线性布局示例”和包名称“com.hfad.linearlayoutexample”,并接受默认保存位置。确保语言设置为 Kotlin,并且最低 SDK 为 API 21,以便在大多数 Android 设备上运行。
如何定义线性布局
正如您所知,使用<LinearLayout>元素来定义线性布局。代码如下所示:

<LinearLayout>元素包含各种不同的属性,用于指定其外观和行为。
第一个是**xmlns:android**。这定义了一个名为android的命名空间,其值需要设置为"[schemas.android.com/apk/res/android](http://schemas.android.com/apk/res/android)",如上所述。定义此命名空间使您的布局能够访问其需要的元素和属性,并且您需要在创建的每个布局文件中定义它。
接下来的两个属性是**android:layout_width**和**android:layout_height**,用于指定布局的宽度和高度。这些属性对于所有类型的布局和视图都是必需的。
您可以将android:layout_width和android:layout_height设置为"wrap_content"、"match_parent"或特定大小,如 8dp——即 8 个密度无关像素。"wrap_content"表示您希望布局仅大到足以容纳其中的所有视图,而"match_parent"表示您希望布局与其父级一样大——在本例中,就是设备屏幕的大小减去任何填充(关于填充的更多信息请参阅几页后面的内容)。通常将布局的宽度和高度设置为"match_parent"。
接下来的属性设置了线性布局的方向。接下来我们将看看此属性的选项。
方向可以是垂直或水平的
您可以使用**android:orientation**属性指定希望排列视图的方向。
您可以使用以下方式将视图垂直排列在单列中:
android:orientation="vertical"
您可以使用以下内容将视图水平排列在单行中:
android:orientation="horizontal"
如果方向是水平的,视图的排列顺序取决于设备的语言设置。
如果设备的语言设置为从左到右阅读的语言,比如英语,视图将水平排列,从左到右显示,如下所示:


如果设备的语言设置为从右到左阅读的语言,比如阿拉伯语,您可以选择从右到左显示视图,使第一个视图出现在布局的最右边。您可以通过在名为AndroidManifest.xml文件中包含名为**supportsRtl**的属性,并将其设置为"true"来启用此功能:
注意
supportsRtl 意味着“支持从右到左”。

在查看线性布局可以使用的其他属性之前,让我们快速了解一下AndroidManifest.xml的更多信息。
使用填充来增加布局边缘的空间
一旦您指定了要使用的布局类型,您可以选择使用一个或多个padding属性来在布局边缘和其内容之间添加额外的空间。例如,以下代码使用android:padding属性为每个边缘添加了 16dp:

如果您想要在不同的边缘添加不同量的填充,您可以单独指定这些边缘。例如,以下代码在布局顶部添加了 32dp 的填充,并在其他边缘添加了 16dp:

android:paddingStart属性向布局的起始边缘添加填充。对于从左到右的语言(如英语),起始边缘在左侧;而如果设备语言设置为从右到左阅读,并且应用程序支持从右到左的语言,则起始边缘在右侧。
android:paddingEnd属性向布局的结束边缘添加填充。对于从左到右的语言,此处为右侧;对于从右到左的语言(如果应用程序支持此功能),此处为左侧。
如果您想要在水平或垂直边缘上应用相同数量的填充,您也可以使用android:paddingHorizontal和android:paddingVertical。这些属性分别在布局的水平和垂直边缘添加填充。

现在您已经学会了如何为线性布局添加填充,请为我们正在构建的布局添加一些填充。
到目前为止的布局代码
在应用程序中,我们将使用一个垂直方向的线性布局,并在每个边缘添加 16dp 的填充,以便在布局的边缘和其内容之间留有一些空间。
打开activity_main.xml,并替换其代码以匹配下面的代码:

我们已经定义了一个空的线性布局;现在让我们继续为其添加一些视图。
填充在布局的边缘和其内容之间增加了额外的空间。
您还可以在视图中使用填充。这将在视图边缘和其内容之间添加额外的空间
可编辑文本框让您输入文本
线性布局需要显示一个按钮和两个可编辑文本视图(用于输入文本)。您已经知道如何使用按钮,在我们更新布局之前,让我们先了解如何包含可编辑文本视图。

可编辑文本视图是一种允许您输入文本的文本视图类型。您可以使用**<EditText>**元素将其添加到布局中,代码如下:
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="To"
android:inputType="text" />

上述代码创建了一个宽度与其父级相同、仅高到足以容纳其内容的可编辑文本视图。
**android:hint** 属性用于定义提示文本。当可编辑文本视图为空时显示这些文本,并向用户提供输入的提示。在上述示例中,我们已经硬编码了提示文本,但在实际应用中,你应该将其作为字符串资源包含。
android:inputType 属性指定用户预计输入的数据类型,以便安卓可以提供正确的键盘类型。在上述示例中,我们使用了:
android:inputType="text"
允许用户输入单行文本。以下是您可能想要使用的一些更有用的输入类型:
| 值 | 作用 |
|---|---|
| 文本 | 允许用户输入单行文本。 |
| 多行文本 | 允许用户输入多行文本。 |
| 电话 | 提供电话号码键盘。 |
| 密码文本 | 显示文本输入键盘,并且您的输入内容是隐藏的。
你可以在在线的安卓开发者文档中找到更多信息,网址是developer.android.com/training/keyboard-input/style。
| 首字母大写 | 将句子的第一个字母大写。 |
|---|
现在你已经学会了如何使用可编辑文本视图,让我们将视图添加到我们正在构建的应用程序中的布局代码中。
在布局的 XML 文件中添加视图。
当你定义线性布局时,你按照希望它们显示的顺序列出布局中的视图。
在我们正在构建的应用中,我们想要显示两个可编辑文本视图,下面是一个按钮。布局代码如下,因此请更新 activity_main.xml 的代码以包含这些更改(用粗体标出):

这些就是布局所需的所有视图。接下来做什么呢?
通过增加权重使视图拉伸
当前布局中的所有视图都只占用它们内容所需的垂直空间。但是我们真正想要的是使消息编辑框拉伸,占用布局中未被其他视图使用的任何垂直空间,就像这样:

为了做到这一点,我们需要为“消息区域”分配一些权重。为视图分配权重是告诉它在布局中拉伸以占用额外空间的一种方法。
你可以使用以下方式为视图分配权重:
android:layout_weight="number"
其中 number 是一个大于 0 的数字。
当你为一个视图分配权重时,布局首先确保每个视图有足够的空间来容纳其内容:每个按钮有足够的空间来显示其文本,每个编辑框有足够的空间来显示其提示文本,依此类推。完成这些操作后,布局会将额外的空间按权重大于等于 1 的视图进行比例分配。
现在我们来看看如何将其应用到正在构建的布局中。
如何为一个视图增加权重
我们需要 Message 编辑文本占用布局中其他两个视图未使用的任何额外空间。为此,我们将其 android:layout_weight 属性设置为 1。因为这是布局中唯一具有权重值的视图,这将使文本字段在垂直方向上拉伸以填充屏幕的剩余部分。
这里是代码;更新 activity_main.xml 以包含更改(加粗部分):

给 Message 编辑文本设置权重为 1 意味着它会占用布局中其他视图未使用的所有额外空间。这是因为布局 XML 中的其他两个视图均未分配权重。

在这个例子中,我们只需要为单个视图分配权重。在进一步更新布局之前,让我们看看当需要为多个视图分配权重时会发生什么。
如何为多个视图添加权重
当你为多个视图分配权重时,线性布局使用你为每个视图分配的权重来确定每个视图应占用的剩余空间的比例。
举例来说,假设我们给 To 编辑文本设置权重为 1,并给 Message 编辑文本设置权重为 2,如下所示:
<LinearLayout ... >
<EditText
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:hint="To"
android:inputType="text" />
<EditText
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:hint="Message"
android:inputType="textMultiLine" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Send" />
</LinearLayout>
线性布局中,To 和 Message 可编辑文本视图具有权重,并使用它们来确定每个视图应占用的空间量。
它首先将每个视图的 android:layout_weight 属性相加。在这个例子中,To 和 Message 视图的权重分别为 1 和 2,总计为 3。
每个视图占用的额外空间比例是视图的权重除以总权重。To 视图的权重为 1,这意味着它将占用布局中剩余空间的 1/3。Message 视图的权重为 2,因此它将占用剩余空间的 2/3。
现在你已经学会如何使用权重,让我们继续更新布局。

gravity 属性控制视图内容的位置
接下来我们将移动显示在 Message 编辑文本内的提示文本。目前,它在视图内垂直居中显示。我们想要改变它,使文本显示在编辑文本字段的顶部,我们可以使用 **android:gravity** 属性来实现这一点。
android:gravity 属性允许你指定如何在视图内部定位视图内容,例如如何在文本视图内部定位文本。如果你希望视图内容显示在视图顶部,就像我们在这里做的一样,我们可以将其 android:gravity 属性设置为 "top",如下所示:
android:gravity="top"
我们将为 Message 编辑文本添加一个 android:gravity 属性,以便提示文本移动到视图顶部。这里是代码;更新 activity_main.xml 以包含更改(加粗部分):



您将在下一页上找到可以在 android:gravity 属性中使用的其他值的列表。
您可以在 android:gravity 属性中使用的值
下面是您可以在 android:gravity 属性中使用的更多值。将属性添加到您的视图中,并将其值设置为以下值之一:
android:gravity="value"
top |
将视图内容放在视图的顶部。 |
|---|---|
bottom |
将视图内容放在视图的底部。 |
start |
将视图内容放在视图的开头。 |
end |
将视图内容放在视图的末尾。 |
center_vertical |
将视图内容垂直居中。 |
center_horizontal |
将视图内容水平居中。 |
center |
将视图内容垂直和水平居中。 |
fill_vertical |
使视图内容垂直填充视图。 |
fill_horizontal |
使视图内容水平填充视图。 |
fill |
使视图内容在垂直和水平方向填充视图。 |
您还可以通过用“|”分隔每个值来将多个重力应用于视图。例如,要将视图内容沉入底端角落,您可以使用:
android:gravity="bottom|end"
现在你已经知道如何使用重力来定位视图的内容,请尝试下一页的练习。
android:gravity 可让你指定视图内容在视图内的位置。
布局磁铁

有人用冰箱磁铁创建了一个线性布局,运行时生成下面的输出。不幸的是,一场过境的鲨龙飓风使一些磁铁脱落了。你能把代码重新拼凑起来吗?
注意
提示:你不需要使用所有的磁铁。

布局磁铁解决方案

有人用冰箱磁铁创建了一个线性布局,运行时生成下面的输出。不幸的是,一场过境的鲨龙飓风使一些磁铁脱落了。你能把代码重新拼凑起来吗?

到目前为止的故事
到目前为止,我们已经向线性布局添加了三个视图,并通过将 layout_weight 和 gravity 属性添加到消息编辑文本来调整它们的位置。这些属性意味着编辑文本会使用未被其他视图使用的任何额外空间,并且其提示文本显示在视图的顶部:

我们的线性布局几乎完成了,但我们还要做两个更改。
-
将发送按钮移动到末端边缘。
对于从左到右的语言,这将使按钮移到右侧。
-
在消息编辑文本和按钮顶部之间添加更多的空间。
让我们看看如何做到这一点,首先将按钮移动到末尾。
布局重力控制视图在布局内的位置
要将按钮移到布局的末端,我们将在按钮上添加一个 **android:layout_gravity** 属性。
android:layout_gravity 属性允许你指定线性布局中视图的位置,使其出现在其封闭空间的右侧,例如,或者在水平方向上居中视图。
要将发送按钮移到布局的末尾边缘,我们将使用以下代码将 android:layout_gravity 属性设置为"end":

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="Send" />
线性布局有两个听起来相似的属性,gravity 和 layout_gravity。
之前,我们使用 android:gravity 属性来定位编辑文本内部的消息提示文本。这是因为 android:gravity 属性让你指定视图的内容出现在哪里。
android:layout_gravity 处理视图本身的放置,让你控制视图出现在它们可用空间的位置。在我们的情况下,我们希望视图移到其可用空间的末尾,所以我们正在使用:

android:layout_gravity="end"
看看下一页,你会看到一些你可以与 android:layout_gravity 属性一起使用的其他值的列表。
你可以使用 android:layout-gravity 属性的更多值
这里是你可以与 android:layout_gravity 属性一起使用的一些值。将该属性添加到你的视图中,并将其值设置为以下值之一:
android:layout_gravity="value"
| Value | 它的作用 |
|---|---|
top, bottom, start, end |
将视图放置在其可用空间的顶部、底部、开始或结束位置。 |
center_vertical, center_horizontal |
在其可用空间中垂直或水平居中视图。 |
center |
在其可用空间中垂直和水平居中视图。 |
fill_vertical, fill_horizontal |
使视图增长,以便在其垂直或水平的可用空间中填满。 |
fill |
使视图增长以充满其垂直和水平的可用空间。 |
你可以通过用“|”分隔每个值来为视图的 android:layout_gravity 属性指定多个值。例如,要将视图移动到其可用空间的底端结束角,你可以使用以下代码:
android:layout_gravity="bottom|end"
现在你知道如何使用 android:layout_gravity 属性改变视图的位置了,接下来让我们看看如何在视图之间添加更多的空间。
android:layout_gravity 让你指定你希望视图出现在它们可用空间中的位置。
android:layout_gravity 处理视图本身的放置,而 android:gravity 控制视图的内容。
使用 margins 来在视图之间添加空间
当你使用线性布局来定位视图时,布局不会在它们之间留下太多空间。通过给视图添加一个或多个margins,你可以增加视图周围的空间。
假设你在一个线性布局中有两个视图——一个位于按钮上方的编辑文本。如果你想增加两个视图之间的空间,你可以使用android:layout_marginTop 属性给按钮顶部添加 40dp 的 margin,就像这样:

这里列出了你可以使用的边距类型,以给视图添加额外空间。将属性添加到视图中,并将其值设置为你想要的边距大小:
android:attribute="8dp"
| 属性 | 作用 |
|---|---|
layout_marginTop |
在视图顶部添加额外空间。 |
layout_marginBottom |
在视图底部添加额外空间。 |
layout_marginStart |
在视图的开始位置添加额外空间。 |
layout_marginEnd |
在视图的结束位置添加额外空间。 |
layout_margin |
在视图的每一侧添加相等的空间。 |
layout_marginVertical, layout_marginHorizontal |
在视图的垂直(顶部和底部)或水平(开始和结束)边缘添加相等的空间。 |
完整的线性布局代码
现在你已经了解了视图的layout_gravity和margin属性,让我们在线性布局代码中使用它们来重新定位按钮并在其顶部边缘添加一些空间。以下是activity_main.xml的完整代码;更新代码以包含以下更改(用粗体标出):

我们已经完成了线性布局代码的编写,现在让我们来测试一下。
测试驾驶
当你对应用进行更改后,继续运行它。
该应用显示了一个线性布局,包含三个视图:两个可编辑文本视图和一个按钮。这些视图以垂直列的方式显示。To 视图显示在顶部,Send 按钮显示在底部的结束角,Message 视图占据任何额外空间,允许按钮顶部边缘有 40dp 的边距。
现在你已经知道如何创建线性布局,并控制其视图的显示方式,试试下一页的练习吧。

成为布局

下面的代码描述了一个完整的线性布局。你的任务是扮演布局,说出当运行时该布局会产生哪个屏幕(A 或 B)。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">
<Button
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center_horizontal"
android:text="Hello!" />
<Button
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center_horizontal"
android:text="Goodbye!" />
</LinearLayout>


成为布局解决方案

下面的代码描述了一个完整的线性布局。你的任务是扮演布局,说出当运行时该布局会产生哪个屏幕(A 或 B)。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">
<Button
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center_horizontal"
android:text="Hello!" />
<Button
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center_horizontal"
android:text="Goodbye!" />
</LinearLayout>


你的活动代码告诉 Android 使用哪个布局
到目前为止,在本章中,你已经学会了如何使用线性布局,并微调其视图的显示方式。在介绍另一种布局类型之前,让我们来看看应用运行时布局发生了什么。
正如你已经知道的,当 Android 运行一个应用时,它会启动应用的主活动。在当前应用中,这是一个名为MainActivity的活动。
当活动启动时,它的onCreate()方法运行。该方法包括以下代码,指定活动应该使用哪个布局:

正如你可以看到的,上述代码通过将布局的名称传递给名为setContentView()的方法来告诉 Android 使用哪个布局。然后,该方法在屏幕上显示布局。
布局的视图被膨胀为对象
除了在设备屏幕上显示布局之外,setContentView()方法还将布局中的视图转换为对象。这个过程称为布局膨胀,因为它膨胀每个视图为一个对象:

布局膨胀非常重要,因为它允许您的活动代码操作布局中的视图。在幕后,每个视图都被渲染为一个对象,您可以使用活动代码与之交互。
让我们看看布局膨胀如何与我们构建的线性布局代码一起工作。
当你运行应用程序时,Android 通过将布局中的每个项目转换为对象来实例化布局 XML。这称为布局膨胀。
布局膨胀:一个示例
正如你所知,线性布局代码在线性布局内显示了两个可编辑文本视图和一个按钮:

当应用程序运行时,布局中的视图被膨胀为对象。线性布局被膨胀为LinearLayout对象,可编辑文本视图被膨胀为EditText对象,按钮被膨胀为Button:


现在您了解了布局膨胀的工作原理,让我们看看如何使用一种新类型的布局:帧布局。
帧布局堆叠其视图

正如你所知,线性布局将其视图排列在单行或单列中。每个视图在屏幕上都被分配了自己的空间,它们不会彼此重叠。
然而,有时候你希望你的视图重叠。例如,假设你想显示一张图片,并在其上覆盖一些文本。你不能仅仅使用线性布局来实现这一点。
如果你想要一个视图可以重叠的布局,一个简单的选择是使用帧布局。它不是将其视图显示在单行或单列中,而是将它们堆叠在一起,一层叠一层地。通常用于仅包含单个视图的情况。
我们将通过创建一个显示一些文本覆盖在鸭子图片上的有用应用程序来研究帧布局的工作原理。我们将从创建一个新项目开始。

创建一个新项目
使用与之前相同的步骤创建一个新的 Android Studio 项目。选择空活动选项,输入名称“Frame Layout Example”和包名称“com.hfad.framelayoutexample”,接受默认保存位置。确保语言设置为 Kotlin,并且最低 SDK 为 API 21,以便它能在大多数 Android 设备上运行。
现在我们已经创建了项目,让我们来定义一个帧布局。
如何定义一个帧布局
您可以使用<FrameLayout>元素来定义一个帧布局,如下所示:

就像线性布局和任何其他类型的视图或视图组一样,android:layout_width和android:layout_height属性指定布局的宽度和高度,是必需的。您也可以选择添加padding属性,但在这里我们没有这样做。
上面的代码创建了一个空的帧布局,让我们继续向其中添加一只鸭子图像。
将图像添加到您的项目
我们将在帧布局中显示一个名为duck.webp的图像,但首先需要将文件添加到项目中。
要完成此操作,您需要创建一个drawable资源文件夹(如果 Android Studio 尚未为您创建)。这是存储应用中图像资源的默认文件夹。切换到 Android Studio 资源管理器的项目视图,选择app/src/main/res文件夹,转到文件菜单,选择“新建…”选项,然后点击创建新的 Android 资源目录选项。当提示时,选择资源类型为“drawable”,命名文件夹为“drawable”,然后点击确定。
接下来,从tinyurl.com/hfad3下载文件duck.webp,然后将其添加到app/src/main/res/drawable文件夹中。
我们将在图像视图中显示duck.webp(显示图像的视图),这将添加到帧布局中。图像视图的定义如下,使用<ImageView>元素:

<ImageView>元素包括android:layout_width和android:layout_height属性,这些您已经很熟悉,还有三个新属性。
android:src属性指定图像视图中应显示的图像。我们将其设置为"@drawable/duck",以便使用drawable文件夹中的duck.webp。
android:contentDescription属性为无障碍提供了图像的文本描述。
最后,android:scaleType属性描述了您希望如何缩放图像。我们使用了"centerCrop",它裁剪图像的边缘。
这就是您在帧布局中显示鸭子图像所需的全部信息。在弄清如何在其上显示文本之前,让我们更仔细地看看如何使用图像——或drawable资源。
帧布局按照在布局 XML 中出现的顺序堆叠视图
定义帧布局时,按您希望它们叠放的顺序列出视图。首先显示第一个视图,然后将第二个视图叠加在其上,依此类推。
在这里,我们将在图像视图的顶部显示一个文本视图,因此我们将在 XML 中的图像视图下方添加它。更新activity_main.xml代码,使其与此处显示的代码匹配:

测试驾驶
当我们运行应用时,设备上将显示一只鸭子的图像,并在左上角显示“这是一只鸭子!”的文本。

所有布局都是 ViewGroup 的一种类型...
尽管它们以不同的方式显示它们的视图,但您可能已经注意到线性布局和帧布局在很多方面都有相似之处。例如,它们都可以容纳视图,并且它们还有自己的策略来确定如何显示视图。
这种共性背后有一个很好的原因。幕后,所有布局——包括线性布局和帧布局——都是 android.view.ViewGroup 超类的子类:

…而 ViewGroup 是 View 的一种类型
ViewGroup 类是 View 的一个子类,可以容纳其他视图。在幕后,每个布局都是 ViewGroup 的一个子类,这意味着 每个布局也是 **View** 的一种类型。
这个类层次结构意味着所有视图和布局都共享公共属性和行为。例如,它们都可以显示在屏幕上,并且您可以指定它们的高度或宽度。这就是为什么您需要为每个视图和布局的 android:layout_height 和 android:layout_width 属性指定值的原因。这些属性对所有视图都是强制性的,因为布局也是视图的一种类型,所以对所有布局也是强制性的。
您向布局添加的每个 UI 组件都是一种 View 类型:一种占据屏幕空间的对象。
每个布局都是 ViewGroup 的一种类型:一种可以容纳其他视图的 View 类型。
她说的对。
正如您已经了解的那样,布局是一种可以容纳其他视图的视图类型。由于每个布局也是视图的一种类型,这意味着 布局可以容纳其他布局。
能够将布局嵌套在其他布局中是有用的,因为它允许您设计更复杂的用户界面。例如,您可以通过将水平线性布局嵌套在根线性布局中,在垂直线性布局的顶部嵌套一个水平行,如果您想要在图像的顶部垂直排列文本,您可以通过在帧布局中嵌套一个垂直线性布局来实现。
让我们更详细地看看这是如何通过一种新类型的布局——滚动视图来实现的。

滚动视图插入了一个垂直滚动条

滚动视图是一种带有垂直滚动条的帧布局。它对于比设备显示区域大的布局很有用,因为这样你可以使用它滚动到屏幕上放不下的任何视图。

我们将创建一个使用滚动视图的应用程序。创建一个名为“滚动视图示例”的新项目,选择空活动选项。确保语言设置为 Kotlin,并且最低 SDK 版本是 API 21,以便在大多数 Android 设备上运行。然后用下面的代码替换 activity_main.xml 中的代码:

如您所见,上述布局包含一个简单的线性布局,其中包含消息编辑文本和发送按钮。
尝试运行应用程序,并在编辑框中输入大量文本。视图会扩展以适应内容,并最终将发送按钮推到屏幕边缘之外。由于没有滚动条,你无法再访问按钮。
为了解决这个问题,我们将向布局中添加一个滚动视图。这将为我们提供一个滚动条,以便在屏幕上看不到发送按钮时可以使用它。

如何添加滚动视图
使用 **<ScrollView>** 元素向布局中添加滚动视图。<ScrollView> 元素的使用方式与 <FrameLayout> 相同,只是它包含一个额外的属性,**fillViewport**,用于指定滚动视图是否应填充设备屏幕。
注意
ScrollView 是 FrameLayout 的一个子类,这意味着它可以做 FrameLayout 能做的一切,而且还能做更多。
在下面的代码中,我们已经向布局代码中添加了滚动视图,以便它包围原始的线性布局。更新 activity_main.xml 代码以包含这里显示的更改(用粗体显示):

在你完成以下练习后,我们将对代码进行测试。
测试驾驶
一旦你在布局代码中添加了滚动视图,就可以运行应用程序。
当你在消息编辑框中输入大量文本时,发送按钮会像之前一样被推到屏幕边缘之外。但是这次,我们可以滚动设备屏幕以达到按钮。
恭喜!现在你已经学会了如何使用不同类型的布局来控制你的应用界面的外观。在下一章中,你将在这些知识的基础上继续学习。

所有布局(包括线性布局、帧布局和滚动视图)都可以容纳其他布局。
你可以通过将一个类型的布局嵌套到另一个布局中来构建复杂的布局。
你的 Android 工具箱

你已经掌握了第三章,现在你已经将构建布局加入到你的工具箱中。

第四章: 约束布局:绘制蓝图

没有蓝图,你不会建造房子。
有些布局使用蓝图确保它们看起来完全符合您的要求。在本章中,我们将向您介绍 Android 的约束布局:一种灵活的设计更复杂用户界面的方式。您将了解到如何使用约束和偏置来定位和调整视图的大小,无论屏幕尺寸和方向如何。您将了解如何通过指南线和障碍物来保持视图的位置。最后,您将学习如何使用链条和流来打包或展开视图。让我们开始设计吧…
重新审视嵌套布局
在上一章中,您学习到可以嵌套布局来构建更复杂的屏幕。例如,以下代码使用嵌套线性布局在水平行中显示文本视图和编辑文本,并在它们下方显示编辑文本:

嵌套布局是有代价的
嵌套布局的缺点在于,以这种方式构建复杂布局可能效率低下,使您的代码难以阅读和维护,还可能导致应用程序变慢。
当 Android 在设备屏幕上显示布局时,它首先检查布局文件的结构,然后使用此结构构建视图层次结构。例如,对于上一页显示的嵌套布局,它构建了包含两个线性布局、两个编辑文本和一个文本视图的视图层次结构:

Android 使用视图层次结构来帮助确定每个视图在设备屏幕上的位置。每个视图都需要测量、布局和绘制在屏幕上,并且 Android 需要确保每个视图有足够的空间容纳其内容,并考虑任何权重。
如果布局包含嵌套布局,则视图层次结构更复杂,Android 可能需要进行多次处理以确定如何排列视图。如果布局深度嵌套,这可能导致代码中的瓶颈,并使您面对难以阅读和维护的大量代码。
如果您有像这样更复杂的用户界面,可以选择使用约束布局而不是嵌套布局。
布局中的每个视图都需要初始化、测量、布局和绘制。在深度嵌套的布局中,这可能会使您的应用程序变慢。
介绍约束布局
约束布局比线性布局或帧布局更复杂,但更加灵活。对于复杂的用户界面来说,它更有效率,因为它提供了一个更扁平的视图层次结构,这意味着 Android 在运行时需要处理的内容更少。
您可以视觉化设计约束布局
使用约束布局的另一个好处是,它们专门设计用于与 Android Studio 的设计编辑器配合使用。与线性布局和帧布局不同,你通常需要直接在 XML 中进行调整,你可以在设计编辑器的蓝图中通过拖放视图来构建约束布局。并为每个视图提供显示指令:
使用约束布局构建灵活的 UI,无需嵌套布局。

与线性布局和帧布局不同,约束布局是 Android Jetpack 套件中的一部分。你可能已经听说过 Jetpack,但它是什么?
约束布局是 Android Jetpack 的一部分
Android Jetpack 是一个库集合,帮助你遵循最佳实践,减少样板代码,使编码更轻松。它包括约束布局、导航、Room 持久化库(帮助你构建数据库)等等。
这里是我们喜欢的一些 Jetpack 组件;你将在后面的章节中学习如何使用它们:

使用 Jetpack 的另一个优点是,它使你能够编写能够在新旧 Android 版本上一致运行的代码。这对你的用户来说是个好消息,因为这意味着你可以包含在较老设备上也能正常工作的新功能。
一个例子是AppCompatActivity,你已经在使用它来编写活动代码。我们之前没有提到,但AppCompatActivity是 Android Jetpack 的一部分。它为新旧版本的 Android 添加了新功能,而无需担心向后兼容性。
注意
是的!你在不知不觉中已经在使用 Android Jetpack 的一部分。在本书的其余部分中,你将学习更多关于如何使用 Jetpack 的内容。
在本章中,你将学习如何使用约束布局。让我们看看我们要做什么。
我们要做什么
我们将学习约束布局的两个主要部分:
-
如何定位和调整单个视图的大小。
你将学习如何使用约束和偏差来控制单个视图在其布局中显示的位置和方式。
![image]()
-
如何定位和调整多个视图的大小。
然后,你将应用你的知识到多个视图上,并学习使用准则、障碍物、链条和流的高级技术。
![image]()
创建一个新项目
我们将为即将构建的应用程序使用一个新项目,因此现在按照前几章的步骤创建一个。选择"空活动"选项,输入名称为“My Constraint Layout”,包名为“com.hfad.myconstraintlayout”,接受默认的保存位置。确保语言设置为 Kotlin,最低 SDK 版本为 API 21,以便在大多数 Android 设备上运行。
现在我们已经创建了项目,让我们确保已经设置好使用约束布局。
使用 Gradle 包括 Jetpack 库。

为了确保所有 Jetpack 库(包括约束布局)能在所有 Android 版本上正常工作,它们不包含在主 Android SDK 中。相反,你必须使用 Gradle 添加你需要的任何库。这是一个用于编译代码、配置应用程序并获取项目所需任何额外库的构建工具。
每次创建新项目时,Android Studio 会创建两个名为 build.gradle 的 Gradle 文件。
build.gradle 的第一个版本位于 project 文件夹中,并指定你的应用的基本设置,如要使用的 Gradle 插件的版本。
build.gradle 的第二个版本位于项目的 app 文件夹中。这是设置应用程序大部分属性的地方,如 API 级别。
在幕后,每个 Android Studio 项目都使用 Gradle 作为其构建工具。
项目的 build.gradle 需要添加 Google 存储库行。
每个项目都需要知道在哪里找到它所需的任何额外 Jetpack 库,这是通过在 项目 的 build.gradle 文件中添加对 Google 存储库的引用来完成的。Android Studio 通常会为您完成此操作,但您可以通过打开文件 MyConstraintLayout/build.gradle,并在 allprojects 下的 repositories 部分中查找以下行(加粗)来确保它已经存在:

应用的 build.gradle 包括约束布局的库。
要使用约束布局,需要在 应用 的 build.gradle 文件中包含对其库的引用。Android Studio 应该已经为您添加了这个,但您可以通过打开文件 MyConstraintLayout/app/build.gradle,并在 dependencies 部分中查找以下行(加粗)来双重检查:

如果文件不包含此行,请立即添加,并点击代码编辑器中出现的“立即同步”选项。这将同步你所做的任何更改与项目的其余部分,并添加库。
让我们向 activity_main.xml 添加一个约束布局。
现在你的项目已经设置好使用约束布局,让我们开始使用一个。
你可以使用 <androidx.constraintlayout.widget.ConstraintLayout> 元素将约束布局添加到布局文件中。我们将在 app/src/main/res/layout 文件夹中的布局文件 activity_main.xml 中使用它,确保其代码如下所示:

在蓝图中展示布局。
我们将使用设计编辑器的蓝图添加视图到布局。通过单击 Design 选项切换到设计编辑器,单击编辑器工具栏中的选择设计表面按钮,并选择蓝图选项。这将显示类似以下的布局蓝图:


向蓝图添加一个按钮。
我们将在布局中添加一个按钮。要做到这一点,转到设计编辑器的工具栏,找到按钮组件(通常在“常用”部分),并将其拖动到蓝图中。您可以将按钮放置在蓝图的任何位置,只要它出现在其主要区域即可,就像这样:

使用约束来定位视图
使用约束布局时,不需要在蓝图上的特定位置放置视图,而是通过定义约束来指定位置。约束是告诉布局视图应该放置在哪里的连接或附件。例如,你可以使用约束将视图附加到布局的起始边缘或另一个视图的下方。
我们将为按钮添加一个水平约束
为了看到效果,让我们添加一个约束,将按钮附加到布局的左边缘。
首先,确保通过点击选中按钮。选中视图后,会在其周围绘制边界框,并添加到其角落和边缘的控制手柄。角落的手柄可用于调整视图的大小,边缘上的手柄可用于添加约束:

要添加约束,点击视图的约束手柄之一,并将其拖动到要附加到的位置。在这种情况下,我们将按钮的左约束手柄拖动到布局的左边缘:

这将添加约束,并将按钮拉到左侧:

这就是添加水平约束的方法。接下来我们看看添加垂直约束会发生什么。
也添加垂直约束
我们将使用垂直约束将按钮附加到布局的顶部。要做到这一点,点击按钮的顶部约束手柄,并将其拖动到蓝图的顶部。这将添加垂直约束,将按钮向上拉动。

使用对称约束来居中视图
如同您所学的那样,您可以使用约束将视图附加到蓝图的边缘。每个约束都像拉伸视图到蓝图边缘的弹簧一样工作。
如果要将视图定位在蓝图的中心位置,可以通过向视图的相对边缘添加约束来实现。例如,要水平居中一个按钮,可以添加一个将视图向左拉的约束,以及另一个将其向右拉的约束,如下所示:

两个约束将按钮朝相反方向拉动,使其水平居中,如下所示:

您还可以通过将约束添加到其顶部和底部边缘来垂直居中视图。如果要水平和垂直居中视图,则需要添加到所有四个边缘的约束,如下所示:

您可以删除不再需要的约束
你可以通过在蓝图中选择不再需要的任何约束条件,然后删除它们来删除任何不再需要的约束条件。例如,如果你有一个位于蓝图中央的按钮,你可以删除连接到其底部边缘的约束条件:

删除这个约束条件意味着按钮不再被拉向蓝图底部。顶部约束将按钮拉向顶部,使其仅在水平方向居中,而不是垂直方向:

另一种删除不再需要的约束条件的方法是使用约束小部件工具。让我们看看这是如何工作的。
使用约束小部件删除约束条件
约束小部件显示在设计编辑器侧边的属性面板中。当你选择一个视图时,它会出现,并显示一个包含视图约束和任何边距大小的图表。
注意
你将在几页后了解有关属性面板的更多信息。
要删除约束条件,请在约束小部件中选择要从蓝图中删除约束条件的视图,然后单击约束小部件中约束条件的手柄。约束条件被移除,视图在蓝图中重新定位。

你也可以使用它来添加边距
你可能已经注意到约束小部件中的每个约束条件旁边都有一个数字。这用于设置视图边缘的边距大小,以便在视图和布局边缘之间留有空间。例如,要将视图的左边和顶部边距大小更改为 24dp,你需要在图表中更新它们的值为 24:


你可以使用设计编辑器工具栏中的“默认边距”按钮为任何新边距设置默认大小。例如,将其设置为 24dp,这意味着任何添加的新约束条件将自动包含 24dp 的边距。

对蓝图的更改会反映在 XML 中
当你向蓝图添加视图并指定约束和边距时,它们会被添加到布局的底层 XML 中。要查看这一点,请切换到布局的代码视图。你的代码应该看起来像这样(但如果稍有不同也不用担心):

正如你所看到的,XML 现在包含了一个按钮。它的代码看起来是否让你感到熟悉?如果是的话,很好,它包含了你在第三章中学到的属性。
按钮的宽度、高度和边距的指定方式与以前完全相同,如果你愿意,你可以在 XML 中更改它们的值,而不是使用设计编辑器。
唯一陌生的代码是指定视图在其开始和顶部边缘上的约束条件的两行代码:

如果你向按钮的其余边缘添加约束条件,将生成类似的代码。
现在你已经一窥约束布局 XML 的样子,切换回设计编辑器,我们来看看更多可以用来定位视图的技巧。
视图可以有偏差
正如你之前学到的,你可以在视图的相对两侧添加约束。这默认将视图居中,但你也可以通过改变其偏差来控制相对每一侧的位置。这告诉 Android 每个约束在视图每侧的比例长度。
要看到效果,请将按钮的水平偏差更改,使其位置偏离中心。首先确保按钮的左右两侧都包含约束,如下所示:

然后选择按钮,这样约束部件就会显示出来。
在视图的图表下方,你应该看到一个带有数字的滑块。这是视图水平偏差的百分比。

要改变偏差,只需移动滑块。例如,如果你将滑块向左移动,使数字变为 30,则蓝图中的按钮也向左移动:

视图在不考虑屏幕大小和方向的情况下保持相对位置。让我们通过测试应用程序来试一试。
测试驾驶
当我们运行应用程序时,按钮会出现在屏幕顶部偏离中心的位置。旋转设备后,它保持相同的相对位置。

现在你已经学会了各种控制视图在屏幕上位置的技巧。接下来,我们来看看如何改变它的大小。
你可以改变视图的大小
如你所料,你可以通过更新其layout_width和layout_height属性来改变约束布局中视图的大小。你可以在布局的 XML 中或设计编辑器的属性面板中完成这些操作。
属性面板显示在蓝图的一侧。当你选择一个视图时,它会显示已声明的所有属性(如layout_width和layout_height),并允许你设置尚未声明的属性。
使视图刚好足够大
就像线性布局和帧布局一样,通过将layout_width和layout_height属性设置为wrap_content,可以使视图大小刚好足够显示其内容。例如,如果视图是一个按钮,则使按钮刚好足以容纳其文本:

匹配视图的约束
如果你已经在视图的相对两侧添加了约束,你可以使视图与其约束的大小相匹配。方法是将其layout_width和/或layout_height设置为 0dp:将layout_width设置为 0dp 以使视图与其水平约束匹配,并将layout_height设置为 0dp 以使其与垂直约束匹配。
在下面的示例中,我们将按钮的layout_width设置为 0dp,使按钮与其水平约束匹配:

现在你已经看到如何调整视图大小,尝试使用不同的技术,然后在下一页的练习中尝试一下。
成为约束

你的工作是扮演你是约束布局,绘制出每个布局所需的约束。你还需要为每个视图指定 layout_width、layout_height 和偏置(在需要时)。我们已经为你完成了第一个。


成为约束解决方案

你的工作是扮演你是约束布局,绘制出每个布局所需的约束。你还需要为每个视图指定 layout_width、layout_height 和偏置(在需要时)。我们已经为你完成了第一个。


大多数布局需要多个视图

到目前为止,你已经看到如何在约束布局中定位和调整一个视图的大小。然而,大多数时候,你的布局需要包含多个相互布局的视图。
为了了解这一点是如何工作的,首先确保你的约束布局中包含一个按钮,并且有两个约束:一个将其顶部边缘连接到蓝图的顶部,另一个将其左边缘连接到蓝图的左侧。其 layout_width 和 layout_height 属性应设置为 wrap_content,这些边缘的间距应该设置为 24dp。
在你做了这些更改之后,按钮应该像这样定位在蓝图的左上角:

向蓝图添加第二个按钮
接下来,通过从调色板中拖动一个按钮,并将其放置在第一个按钮的下方,如下所示,向蓝图添加第二个按钮:

蓝图现在包含两个按钮。让我们找出如何相互定位它们。
你可以将视图连接到其他视图
正如你所知道的,约束可以让你将视图附加到其蓝图的边缘。你也可以使用约束将两个视图连接在一起,这用于指定它们之间的显示方式。
为了了解这一点,选择蓝图中的第二个按钮,然后绘制一个约束,从第二个按钮的顶部边缘到第一个按钮的底部边缘,如下所示:

当添加约束时,它会将按钮拉起,使其连接到第一个按钮,并显示在其下方:

该约束意味着第二个按钮将始终位于第一个按钮下方,无论第一个按钮在设备屏幕上的位置如何。
一旦你以这种方式定位了两个视图,接下来你可能想要做的是确保它们对齐。让我们找出如何做到这一点。
你也可以对视图进行对齐
对齐两个视图最简单的方法是使用设计编辑器工具栏中的对齐按钮。
要了解这是如何工作的,请将蓝图中的两个按钮左对齐,使它们的左边缘对齐。首先,通过按住 Shift 键选择两个按钮。然后点击对齐按钮,打开一组对齐选项,就像这样:

点击“左对齐”选项,将两个按钮左对齐。这样会在蓝图中添加一个约束,将它们的左边缘连接在一起,就像这样:

使用准线对齐视图
另一种对齐视图的技术是准线。这是在蓝图中添加的固定线,可以用来约束视图。它在设计编辑器中可见,用户运行应用时看不到。
让我们通过向蓝图添加一个准线来探讨准线的工作方式。点击设计编辑器工具栏中的准线按钮,并选择添加垂直准线选项。这会在蓝图中放置一个垂直准线:

添加准线后,您可以通过拖动它将其移动到其他位置。您可以将其设置为距离蓝图边缘的固定距离,或者固定百分比:

然后,您可以使用约束将视图附加到准线,就像这样:

准线有固定的位置
准线可以位于蓝图边缘的固定距离,或两者之间的固定百分比。它们在应用运行时保持在那个位置,因此它们是对齐视图的有用方式。
在某些情况下,您需要更灵活的东西。例如,假设您有一个包含两个并排多行编辑文本和一个按钮的布局,就像这样:

随着用户输入文本,编辑文本会垂直扩展。您希望按钮随着视图的大小变化而移动,以便始终位于它们的下方,就像这样:

那么,您如何构建这种布局呢?
创建一个可移动的屏障
要创建这样的布局,您可以使用屏障。这类似于准线,但它没有固定位置。相反,它形成对视图的屏障,并在它们改变大小时移动。这会重新定位任何被约束到屏障的视图。
在上一页的示例中,两个编辑文本放置在一个水平屏障上方,按钮被约束在其下方。随着编辑文本的扩展,屏障移动并重新定位按钮:

让我们构建一个使用屏障的布局
要了解屏障的工作原理,请创建此示例。
首先,删除任何视图,并确保蓝图包含一个垂直准线,位置位于 50%。然后从工具栏拖动两个多行编辑文本,并将它们放置在准线的两侧。
注意
您通常可以在调色板的“文本”部分找到它们,列为“多行文本”。
接下来,添加垂直约束以将每个视图约束到蓝图的顶部,并添加水平约束以将每个视图放置在蓝图边缘和指导线之间。
最后,将每个编辑文本的layout_width更改为 0dp,以匹配其水平约束,并将它们的layout_height设置为“wrap_content”,以便视图可以扩展。
完成后,蓝图应该是这个样子:

添加一个水平屏障
我们需要在蓝图中添加一个水平屏障。要做到这一点,请点击设计编辑器工具栏中的“指南线”按钮,并选择添加水平屏障的选项:

这样就创建了水平屏障。
将屏障放置在视图下方
我们希望屏障随着两个编辑文本视图的扩展而向下移动。为此,请转到布局的组件树面板,将两个编辑文本组件拖放到屏障上:

这不会改变蓝图中编辑文本视图的位置,而是告诉屏障它需要随这些视图移动。
接下来,我们需要将屏障定位到两个视图的底部。在组件树中选择屏障,并使用属性面板将其barrierDirection属性更改为“bottom”。这将使屏障位于两个编辑文本的下方,使蓝图看起来像这样:

将一个按钮约束在屏障下方
现在布局的屏障已经就位,让我们添加按钮,并将其约束到屏障上,以便在编辑文本视图扩展时向下移动。
首先,从调色板中拖动一个按钮到蓝图中,并将其放置在屏障下方的某个位置。然后通过添加两个水平约束,将按钮的两侧连接到蓝图的边缘,使其水平居中,操作如下:

接下来,您需要将按钮的顶部连接到屏障。您可以尝试直接在蓝图中绘制约束来完成此操作。如果像我们一样觉得这有点麻烦,请选择按钮,在属性面板中搜索其layout_constraintTop_toBottomOf属性,并将其值更改为屏障的 ID(在我们的情况下,这是@id/barrier)。

完成这些更改后,蓝图应该看起来像这样:

由于刚开始使用屏障可能会有些棘手,我们将在接下来的几页中展示我们的完整 XML,并进行应用程序的测试。
activity_main.xml的完整代码如下:
这是我们的activity_main.xml的完整代码;如果你希望你的蓝图看起来像我们的一样,请替换此文件的内容,使其与此处显示的代码匹配:


Test Drive
当我们运行应用程序时,按钮显示在两个编辑文本视图下方。当我们在每个编辑文本中输入内容时,按钮会向下移动,并且视图会扩展。
正如您所见,添加屏障比绘制约束和对齐视图更复杂,但我们认为这是值得额外努力的。
接下来做什么?

使用链条来控制一组线性视图
您现在已经学会了如何连接和对齐视图,并使用指南和约束。但是如果您想创建一行或一列视图,并将它们均匀间隔开来,该怎么办呢?
在这种情况下,您可以使用链条。这是一组线性视图,通过双向约束连接在一起。链条控制每个视图的位置,因此您可以使用它来均匀间隔视图或将它们打包在蓝图的中心。
我们将创建一个水平链条
要了解其工作原理,我们将创建一个链条,以控制三个按钮的位置。这些按钮将水平排列,并在蓝图的两侧均匀间隔,如下所示:

当应用程序运行时,按钮将保持它们的相对位置,无论屏幕大小或方向如何:

让我们看看如何创建链条。
链条将使用三个按钮
在创建链条之前,首先移除到目前为止已添加到蓝图中的所有约束。最快的方法是使用设计编辑器工具栏中的“清除所有约束”按钮,所以现在点击此按钮。

您还需要清除任何指南、屏障和编辑文本视图。通过选择每个视图并将其删除来完成此操作。
然后在蓝图中添加两个额外的按钮,以确保总共有三个按钮,并使用设计编辑器工具栏中的“预览方向”按钮将蓝图的方向更改为横向。这将使链条更容易看到。

当您添加了按钮后,蓝图应该看起来像这样:

对我们打算链条的视图进行对齐
链条在视图对齐时效果最佳。首先,添加一个约束将第一个按钮连接到蓝图的顶部,并将其边距设置为 64 像素。然后选择所有三个按钮,并在设计编辑器工具栏中使用对齐按钮将它们的顶部对齐。蓝图应该看起来像这样。

现在按钮已经很好地对齐,让我们继续创建链条。
创建水平链条
要创建链条,请选择所有三个按钮,然后右键单击其中一个。在出现的菜单中,选择“Chains”选项,然后选择“Create Horizontal Chain”。

创建水平链条后,它将连接按钮,并将第一个和最后一个视图固定在蓝图的垂直边缘上。链条应该看起来像这样:

默认情况下,链中的视图在蓝图的边缘之间均匀分布。您可以通过右键单击链中的一个视图,从显示的菜单中选择链选项,然后选择水平链样式来更改此行为。
可能的链样式选项包括 spread、spread inside 和 packed。通过尝试以下练习,看看您是否能理解这些选项的作用。
有不同风格的链
正如您发现的那样,您可以选择不同的链样式来更改链如何安排其视图。
Spread 将视图间隔开来,直到达到蓝图的边缘
默认样式是spread。这用于在蓝图的边缘之间均匀分布视图,如此所示:

Spread inside 将第一个和最后一个视图移到边缘
spread inside风格类似于 spread,但它将第一个和最后一个视图移到蓝图的边缘。然后均匀地间隔任何剩余的视图,如下所示:

Packed 将视图移动到一起
packed样式用于将视图打包在一起。然后将整个视图组居中,如此所示:

现在您已经看到这些选项的作用,请让我们测试一下应用程序。
测试驾驶
当我们使用链的 spread 样式并运行应用程序时,按钮在设备屏幕上均匀分布。这与屏幕方向无关。

始终在各种设备大小和方向上测试布局,以确保其外观和行为符合预期。
她是对的。
约束布局可以包括水平和垂直链,并且单个视图可以属于两种类型。您可以使用这个功能来安排视图在网格中。
例如,下面的蓝图显示了一个网格中排列的六个按钮。每一行都是水平链,而最左边的按钮形成了垂直链:

另一种创建网格的方法是使用flow。让我们找出这是什么,以及如何使用它。
flow 类似于多行链
flow 类似于一个可以跨越多行的链。当您想在一行中显示大量视图,但是在某些屏幕大小或方向上可能不适合时,这将非常有价值。
例如,假设你有一个链,它在水平排列中显示六个按钮。当方向是横向时,它们显示如下:

但是当方向改变为纵向时,没有足够的空间来显示所有的视图:

如果您用 flow 替换链,任何无法适合第一行的视图将流到第二行,如此所示:

让我们通过构建上面的布局来看一下 flows 是如何工作的。
如何添加一个 flow
首先,在设计编辑器工具栏中点击“清除所有约束”按钮来移除所有约束。然后向蓝图添加额外的按钮,总共六个,就像这样:

接下来,选择所有按钮,在设计编辑器工具栏中点击“指南线”按钮,选择“流”选项。这将添加流组件。
现在我们需要调整流组件的设置,使其按我们希望的方式运行。为此,请在组件树中选择流,然后使用蓝图或约束部件将其边缘和顶部连接起来添加约束。将其layout_width属性设置为“0dp”,以便与其约束匹配。最后,在属性面板中搜索其flow_wrapMode属性,并将其设置为“chain”。
当你完成所有这些更改后,蓝图在横屏时应该看起来像这样:

如果将方向改为竖屏,则蓝图应该如下所示:

创建流后,你可以调整它显示视图的方式。让我们看看如何操作。
你可以控制流的外观
你可以通过其**flow_wrapMode**属性来更改流的外观。
使用“chain”创建多行链条
如果将flow_wrapMode属性设置为chain,则流将像灵活的链条一样运行,允许其视图流入额外的行。
使用此选项,可以通过更改其flow_horizontalStyle属性的值来进一步调整流的外观。此属性的可能选项有 spread、spread inside 和 packed。例如,packed 选项会将视图紧密打包在一起,如此显示:

使用“aligned”来对齐视图
如果将flow_wrapMode属性设置为aligned,则视图将流入额外的行,并且会像这样对齐排列:

你还可以将flow_wrapMode属性设置为none或者不设置。这使得流行为正常链条,以便其视图不会流入第二行。
activity_main.xml 的完整代码
有时流可能会有点棘手,所以这是我们activity_main.xml的完整代码;如果需要,请使用此处显示的代码替换该文件的代码:


让我们来测试一下这个应用程序。
测试驾驶
当我们使用链条风格的流并运行应用程序时,在横屏方向上,按钮在设备屏幕上均匀分布。
当我们将方向改为竖屏时,无法放在第一行的按钮会流入第二行。

恭喜!你现在已经学会了如何使用约束布局设计超级灵活的屏幕。它们不仅能够按照你的意愿外观和行为,而且不使用嵌套布局,因此非常高效。
在你进入下一章之前,为什么不将你的新技能付诸实践,并尝试一些你学到的技术呢?
你的 Android 工具箱

你已经掌握了第四章,并且现在已经将约束布局添加到你的工具箱中。

第四章:约束布局:绘制蓝图。

没有蓝图就不要建房子。
有些布局使用蓝图来确保它们看起来完全符合您的要求。在本章中,我们将向您介绍 Android 的约束布局:一种设计更复杂 UI 的灵活方式。您将了解如何使用约束和偏置来定位和调整视图的大小,无论屏幕尺寸和方向如何。您将了解如何使用指南线和障碍物来保持视图的位置。最后,您将学习如何使用链和流来排列或展开视图。让我们开始设计吧……
重新审视嵌套布局。
在前一章中,您了解到可以嵌套布局以构建更复杂的屏幕。例如,以下代码使用嵌套线性布局以水平排列显示文本视图和编辑文本,在它们下方是一个编辑文本:

嵌套布局是有代价的。
嵌套布局的缺点在于,以这种方式构建复杂布局可能效率低下,使您的代码更难阅读和维护,并且还可能减慢应用程序的运行速度。
当 Android 在设备屏幕上显示布局时,它首先检查布局文件的结构,并使用此来构建视图的层次结构。例如,在上一页显示的嵌套布局中,它构建了一个包含两个线性布局、两个编辑文本和一个文本视图的视图层次结构:

Android 使用视图层次结构来帮助确定每个视图应该放置在设备屏幕的位置。每个视图都需要进行测量、布局和绘制在屏幕上,并且 Android 需要确保每个视图有足够的空间容纳其内容,并考虑任何权重。
如果布局包含嵌套布局,则视图层次结构更加复杂,Android 可能需要进行多次处理以确定如何排列视图。如果布局嵌套深度很深,这可能导致代码中的瓶颈,并且可能使您的代码难以阅读和维护。
如果您有像这样更复杂的 UI,使用约束布局是避免使用嵌套布局的一种替代方法。
布局中的每个视图都需要进行初始化、测量、布局和绘制。在深度嵌套布局中,这可能会减慢您的应用程序。
介绍约束布局
约束布局比线性布局或帧布局复杂,但更加灵活。对于复杂的 UI 来说,它也更有效率,因为它提供了一个更扁平的视图层次结构,这意味着 Android 在运行时需要做更少的处理。
您可以视觉化地设计约束布局。
使用约束布局的另一个优势是,它专门设计用于与 Android Studio 的设计编辑器配合使用。与通常需要直接在 XML 中进行操作的线性布局和帧布局不同,你可以在设计编辑器的蓝图中视觉化地构建约束布局。你可以将视图拖放到设计编辑器中,并为每个视图指定显示方式的指令:
使用约束布局构建灵活的 UI,而无需嵌套布局。

与线性布局和帧布局不同,约束布局是称为 Android Jetpack 的一套库中的一部分。你可能已经听说过 Jetpack 的一些内容,但它究竟是什么?
约束布局是 Android Jetpack 的一部分
Android Jetpack 是一组库,帮助你遵循最佳实践,减少样板代码,并简化编码生活。它包括约束布局、导航、Room 持久性库(帮助你构建数据库)等等。
这里是我们喜爱的一些 Jetpack 组件;你将在后面的章节中学习如何使用它们:

Jetpack 的另一个优点是,它可以让你编写在新旧版本的 Android 上都一致工作的代码。这对你的用户来说是个好消息,因为这意味着你可以在旧设备上包含令人兴奋的新 Android 功能。
例如 AppCompatActivity,你已经在使用它来编写活动代码。我们之前没有提到,但 AppCompatActivity 是 Android Jetpack 的一部分。它为新旧版本的 Android 添加了新功能,而你无需担心向后兼容性问题。
注意
是的!在不知不觉中,你已经在使用 Android Jetpack 的一部分。通过本书的其余部分,你将更多地了解如何使用 Jetpack。
在本章中,你将学习如何使用约束布局。让我们先了解一下接下来要做什么。
这是我们将要做的事情
我们将把学习约束布局分成两个主要部分:
-
如何定位和调整单个视图的位置和大小。
你将学习如何使用约束和偏差来控制单个视图在其布局中的显示位置和方式。
![image]()
-
如何定位和调整多个视图的位置和大小。
然后,你将应用你的知识到多个视图,并学习使用指南、障碍物、链和流的更高级技术。
![image]()
创建一个新项目
我们将使用一个新项目来构建我们的应用程序,因此现在按照前几章节的步骤创建一个项目。选择空活动选项,输入名称“我的约束布局”,包名为“com.hfad.myconstraintlayout”,并接受默认的保存位置。确保语言设置为 Kotlin,最低 SDK 版本为 API 21,这样它就可以运行在大多数 Android 设备上。
现在我们已经创建了项目,让我们确保它已设置为使用约束布局。
使用 Gradle 包含 Jetpack 库

为了确保所有 Jetpack 库(包括约束布局)在所有 Android 版本上都能正常工作,它们不包含在主 Android SDK 中。相反,您必须使用Gradle添加您需要的任何库。这是一个用于编译代码、配置应用程序并获取项目所需的任何额外库的构建工具。
每次创建新项目时,Android Studio 都会创建两个名为build.gradle的 Gradle 文件。
build.gradle的第一个版本位于project文件夹中,并指定应用程序的基本设置,例如要使用的 Gradle 插件的版本。
build.gradle的第二个版本位于项目的app文件夹中。这是设置大多数应用程序属性的地方,例如 API 级别。
在幕后,每个 Android Studio 项目都使用 Gradle 作为其构建工具。
项目 build.gradle 需要一个 Google 存储库行
每个项目都需要知道在哪里找到所需的额外 Jetpack 库,这是通过在项目的build.gradle文件中添加对 Google 存储库的引用来完成的。Android Studio 通常会为您执行此操作,但您可以通过打开文件MyConstraintLayout/build.gradle,并在allprojects下的repositories部分查找以下行(加粗)来确保它存在:

应用程序 build.gradle 包含约束布局的库
要使用约束布局,需要在app的build.gradle文件中包含对其库的引用。Android Studio 应该已经为您添加了这个,但您可以通过打开文件MyConstraintLayout/app/build.gradle,并在dependencies部分查找以下行(加粗)来进行双重检查:

如果文件中不包含此行,请立即添加,并单击代码编辑器中出现的“立即同步”选项。这将使您所做的任何更改与项目的其余部分同步,并添加库。
让我们将约束布局添加到 activity_main.xml 中
现在,您的项目已经准备好使用约束布局,让我们开始使用它。
您可以使用**<androidx.constraintlayout.widget.ConstraintLayout>**元素向布局文件添加约束布局。我们将在app/src/main/res/layout文件夹中的布局文件activity_main.xml中使用一个,因此打开此文件,并确保其代码如下所示:

在蓝图中显示布局
我们将使用设计编辑器的蓝图向布局添加视图。通过单击 Design 选项切换到设计编辑器,单击编辑器工具栏中的选择设计表面按钮,并选择蓝图选项。这将向您显示布局的蓝图,如下所示:


向蓝图添加一个按钮
我们将在布局中添加一个按钮。要做到这一点,进入设计编辑器的工具栏,找到按钮组件(通常在常见部分),并将其拖动到蓝图中。你可以把按钮放在蓝图的任何位置,只要它出现在其主要区域中,就像这样:

使用约束来定位视图
在约束布局中,你不是通过将视图放置在蓝图的特定位置来指定视图应该放置在哪里。而是通过定义约束来指定位置。约束是一种连接或附件,告诉布局视图应该放在哪里。例如,你可以使用约束将视图附加到布局的起始边缘,或者在另一个视图的下方。
我们将给按钮添加一个水平约束
看看这是如何工作的,我们来添加一个约束,将按钮附加到布局的左边缘。
首先,确保通过点击按钮选择它。当你选择一个视图时,会在其周围绘制一个边界框,并在其角落和边缘添加手柄。角落的手柄允许你调整视图的大小,而边缘上的手柄则允许你添加约束:

要添加约束,请点击视图的约束手柄之一,并将其拖动到要附加到的位置。在这种情况下,我们将按钮的左边缘附加到布局的左边缘,因此点击左侧约束手柄并将其拖动到蓝图的左边缘:

这样添加了约束,并将按钮拉到了左边:

这就是添加水平约束的方式。我们来看看当你添加垂直约束时会发生什么。
也可以添加垂直约束
我们将使用垂直约束将按钮附加到布局的顶部。要做到这一点,请点击按钮的顶部约束手柄,并将其拖动到蓝图的顶部。这将添加垂直约束,将按钮向上拉动。

使用对立约束来使视图居中
如你所学,你可以使用约束将视图附加到蓝图的边缘。每个约束都像一个弹簧,将视图拉到蓝图的边缘。
如果你想要将视图定位在蓝图的中心,可以通过添加约束到视图的相对边缘来实现。例如,要水平居中一个按钮,你可以添加一个约束将视图向左拉,另一个约束将其向右拉,就像这样:

这两个约束朝相反方向拉动按钮,使其水平居中,就像这样:

你还可以通过给视图的顶部和底部边缘添加约束来将其垂直居中。如果你想要水平和垂直居中它,你需要像这样添加四个边缘的约束:

你可以删除不再需要的约束
您可以通过在蓝图中选择它们并删除它们来移除任何不再需要的约束。例如,如果您有一个按钮位于蓝图中央,您可以删除附加到其底部边缘的约束:

删除此约束意味着按钮不再被拉向蓝图底部。顶部约束将按钮拉到顶部,使其仅在水平方向上居中,而不是在垂直方向上:

另一种移除不再需要的约束的方法是使用约束部件工具。让我们看看它是如何工作的。
使用约束部件删除约束
约束部件显示在设计编辑器侧边的属性面板中。选择一个视图时,它会显示包含视图约束和任何边距大小的图表。
注意
几页后您将更多地了解属性面板。
要删除约束部件中的约束,请在蓝图中选择要从中移除约束的视图,然后单击约束部件中约束的手柄。约束将被移除,并且视图将在蓝图中重新定位。

你可以用它来添加边距
您可能已经注意到约束部件中的每个约束旁边都有一个数字。这用于设置视图与布局边缘之间的边距大小,以便在图表中将视图的左侧和顶部边距大小更改为 24dp,例如,您会更新它们的值到 24。


您可以通过设计编辑器工具栏中的默认边距按钮设置任何新边距的默认大小。例如,将其设置为 24dp,这意味着任何添加的新约束都将自动包含 24dp 的边距。

蓝图的更改会在 XML 中显示
当您向蓝图添加视图并指定约束和边距时,它们将添加到布局的底层 XML 中。要查看此内容,请切换到布局的代码视图。您的代码应该看起来像这样(但如果略有不同也不用担心):

正如你所见,XML 现在包含一个按钮。它的代码看起来熟悉吗?如果是的话,很好——它包含了你在第三章学到的属性。
按钮的宽度、高度和边距的指定方式与之前完全相同,如果愿意,你可以在 XML 中更改它们的值,而不使用设计编辑器。
唯一不熟悉的代码是指定视图在其开始和顶部边缘上的约束的两行:

如果您添加约束到按钮的其余边缘,将生成类似的代码。
现在,您已经简要了解了约束布局 XML 的外观,请切换回设计编辑器,我们将查看更多可以用来定位视图的技术。
视图可以具有偏差
正如您之前学到的,您可以向视图的相对侧添加约束条件。这会默认将视图居中,但您也可以通过更改其偏差来控制其相对于每个侧面的位置。这告诉 Android 每个约束条件在视图每侧的比例长度应该是多少。
要看到这一过程,让我们将按钮的水平偏差更改,使其偏离中心位置。首先确保按钮包括其左右两侧的约束条件,如下所示:

然后选择按钮,以显示约束小部件。
在小部件视图的图表下方,您会看到一个带有数字的滑块。这是视图水平偏差的百分比。

要更改偏差,只需移动滑块。例如,如果将滑块向左移动,使数字更改为 30,则蓝图中的按钮也会向左移动:

无论屏幕大小和方向如何,视图都会保持这种相对位置。让我们通过对应用程序进行测试驾驶来尝试一下。
测试驾驶
当我们运行应用程序时,按钮出现在屏幕顶部的偏离中心位置。当我们旋转设备时,它保持相同的相对位置。

您现在已经学会了多种技术来控制视图在屏幕上的位置。接下来,我们来看看如何更改其大小。
您可以更改视图的大小
正如您可能期望的那样,您可以通过更新其layout_width和layout_height属性来更改约束布局中视图的大小。您可以在布局的 XML 中执行此操作,或者在设计编辑器的属性面板中执行此操作。
属性面板显示在蓝图的侧边。当您选择一个视图时,它会显示已经声明的所有属性(例如layout_width和layout_height),并允许您设置尚未设置的属性。
使视图足够大
就像线性布局和帧布局一样,通过将其layout_width和layout_height属性设置为wrap_content,您可以使视图足够大以显示其内容。例如,如果视图是按钮,则使按钮足够大以容纳其文本:

符合视图的约束条件
如果您已经向视图的相对侧添加了约束条件,可以使视图匹配其约束条件的大小。您可以通过将其layout_width和/或layout_height设置为 0dp 来实现这一点:将layout_width设置为 0dp 以使视图匹配其水平约束条件,并将layout_height设置为 0dp 以使其匹配其垂直约束条件。
在下面的示例中,我们已将按钮的layout_width设置为 0dp,以使按钮与其水平约束匹配:

现在你已经学会了如何调整视图的大小,请尝试使用不同的技巧进行实验,然后尝试下一页上的练习。
成为约束

你的任务是像约束布局一样进行操作,并绘制生成每个布局所需的约束。还需要为每个视图指定layout_width、layout_height和偏差(如果需要)。我们已经为第一个示例完成了。


成为约束解决方案

你的任务是像约束布局一样进行操作,并绘制生成每个布局所需的约束。还需要为每个视图指定layout_width、layout_height和偏差(如果需要)。我们已经为第一个示例完成了。


大多数布局需要多个视图

到目前为止,你已经学会了如何在约束布局中定位和调整单个视图的大小。然而,大多数情况下,你的布局需要包含多个相对定位的视图。
要了解其工作原理,请确保你的约束布局包含一个单独的按钮,并具有两个约束:一个将其顶部边缘连接到蓝图的顶部,另一个将其左边缘连接到蓝图的左侧。其layout_width和layout_height属性应设置为wrap_content,这些边缘的边距应设置为 24dp。
在完成这些更改后,按钮应该位于蓝图的左上角,如下所示:

在蓝图中添加第二个按钮
接下来,从调色板中拖动一个第二个按钮添加到蓝图中,并将其放置在第一个按钮的下方某处,如下所示:

现在蓝图中包含两个按钮。让我们看看如何相对定位它们。
你可以将视图连接到其他视图
如你所知,约束允许你将视图附加到其蓝图的边缘。你还可以使用约束将两个视图连接在一起,这用于指定它们相对于彼此的显示方式。
要了解其工作原理,请在蓝图中选择第二个按钮,然后绘制一个约束,将第二个按钮的顶部边缘连接到第一个按钮的底部边缘,如下所示:

添加约束后,将按钮上移,使其连接到第一个按钮并显示在其下方:

约束条件意味着无论第一个按钮在设备屏幕上的位置如何,第二个按钮始终位于第一个按钮的下方。
通过这种方式定位了两个视图后,接下来可能想要做的是确保它们对齐。我们来看看如何做到这一点。
你也可以对齐视图
将两个视图对齐的最简单方法是使用设计编辑器工具栏中的对齐按钮。
为了看看这是如何工作的,让我们将蓝图中的两个按钮左对齐,使它们的左边缘对齐。首先,通过按住 Shift 键并单击每个按钮来选择两个按钮。然后点击对齐按钮以打开一组对齐选项,如下所示:

点击“左边缘”选项以左对齐两个按钮。这将向蓝图添加一个约束,将它们的左边缘连接在一起,如下所示:

使用指南线对齐视图
另一种你可以用来对齐视图的技术是指南线。这是你添加到蓝图中的固定线条,可用于限制视图。在设计编辑器中可见,但用户在运行应用时看不到它。
让我们通过在蓝图中添加一条指南线来探索指南线的工作原理。点击设计编辑器工具栏中的指南线按钮,并选择添加垂直指南线的选项。这将在蓝图中放置一条垂直指南线:

一旦添加了指南线,您可以通过拖动来移动它。您可以设置它要么是距离蓝图边缘的固定距离,要么是固定百分比:

然后您可以使用约束将视图附加到指南线,如下所示:

指南线有固定位置
指南线要么位于距离蓝图边缘的固定距离处,要么位于两者之间的固定百分比处。它们在应用运行时保持在该位置,因此是对齐视图的有用方式。
在某些情况下,您需要更灵活的东西。例如,假设您有一个包括两个并排的多行编辑文本和一个按钮的布局,如下所示:

随着用户输入文本,编辑文本垂直扩展。您希望按钮在视图大小变化时移动,以便始终位于它们的下方,如下所示:

那么您如何构建这种布局呢?
创建一个可移动的屏障
要创建这样的布局,您可以使用屏障。这类似于指南线,但它没有固定位置。相反,它形成一个对视图的屏障,并在它们大小变化时移动。这将重新定位任何与屏障约束的视图。
在前一页的示例中,两个编辑文本位于水平屏障上方,并且按钮在其下方约束。随着编辑文本的扩展,屏障移动并重新定位按钮:

让我们建立一个使用屏障的布局
要了解屏障的工作原理,让我们创建这个示例。
首先,删除任何视图,并确保蓝图包含在 50%处定位的垂直指南线。然后,从工具栏拖动两个多行编辑文本,并将它们定位在指南线的两侧。
注意
您通常可以在调色板的“文本”部分找到这些,列为“多行文本”。
接下来,添加垂直约束,将每个视图约束到蓝图的顶部,并添加水平约束,使每个视图位于蓝图边缘和指导线之间。
最后,将每个编辑文本的layout_width更改为 0dp,以匹配其水平约束,并将它们的layout_height设置为“wrap_content”,以便视图可以展开。
完成后,蓝图应该看起来像这样:

添加水平障碍
我们需要在蓝图中添加一个水平障碍物。要做到这一点,请在设计编辑器工具栏中点击“指南线”按钮,并选择添加水平障碍的选项:

这样就创建了水平障碍。
将障碍物放置在视图下方
我们希望随着两个编辑文本视图的展开,障碍物向下移动。为此,请转到布局的组件树面板,将两个编辑文本组件拖到障碍物上:

这不会改变蓝图中编辑文本视图的位置,而是告诉障碍物它需要随这些视图移动。
接下来,我们需要将障碍物定位在两个视图的底部。在组件树中选择障碍物,并使用属性面板将其barrierDirection属性更改为“bottom”。这将障碍物放置在两个编辑文本视图的下方,使蓝图看起来像这样:

将按钮约束在障碍下方
现在,布局的障碍物已经就位,让我们添加按钮,并将其约束到障碍物上,以便随着编辑文本视图的展开而向下移动。
首先从调色板拖动一个按钮到蓝图上,并将其放置在障碍物的下方某处。然后通过添加两个水平约束将其水平居中,这些约束将按钮的边缘连接到蓝图的边缘,如下所示:

接下来,您需要将按钮的顶部连接到障碍物。您可以尝试在蓝图中直接绘制约束来完成此操作。如果像我们一样觉得这有点麻烦,请选择按钮,然后在属性面板中搜索其layout_constraintTop_toBottomOf属性,并将其值更改为障碍物的 ID(在我们的情况下,这是@id/barrier)。

完成此更改后,蓝图应该看起来像这样:

由于一开始使用障碍物可能会有些棘手,我们将在接下来的几页中展示完整的 XML,并进行应用程序测试驾驶。
activity_main.xml的完整代码
这是我们的activity_main.xml的完整代码;如果您希望您的蓝图看起来像我们的一样,请替换此文件的内容,以便与此处显示的代码匹配。


测试驾驶
当我们运行应用程序时,一个按钮显示在两个编辑文本视图的下方。当我们在每个编辑文本中输入时,按钮向下移动,视图会扩展。
如您所见,添加屏障比绘制约束和对齐视图复杂些,但我们认为额外的努力是值得的。
下一步是什么?

使用链条控制线性视图组
现在您已经学会了如何连接和对齐视图,以及使用指南和约束。但如果您想创建一行或一列视图,并均匀间隔它们呢?
在这种情况下,您可以使用链条。这是一组线性视图,通过双向约束链接在一起。链条控制每个视图的位置,因此您可以使用它来均匀间隔视图,或将它们打包在蓝图的中心。
我们将创建一个水平链
为了看看这是如何工作的,我们将创建一个控制三个按钮位置的链条。按钮将排成水平行,并在蓝图的两侧均匀间隔,如下所示:

当应用运行时,按钮将保持它们的相对位置,无论屏幕大小或方向如何:

让我们看看如何创建一个链条。
链条将使用三个按钮
在创建链条之前,请先删除到目前为止已添加到蓝图的所有约束。最快的方法是使用设计编辑器工具栏中的“清除所有约束”按钮,现在点击此按钮。

您还需要摆脱任何指南、屏障和编辑文本视图。通过选择每个视图,然后将其删除来完成此操作。
然后向蓝图添加两个按钮,使总数达到三个,并使用设计编辑器工具栏中的“预览方向”按钮将蓝图的方向更改为横向。这样可以更容易地看到链条。

当您添加完按钮后,蓝图应该看起来像这样:

对齐我们将要链条的视图
当视图对齐时,链条效果最佳。首先添加将第一个按钮连接到蓝图顶部的约束,并将其边距设置为 64。然后选择所有三个按钮,并使用设计编辑器工具栏中的“对齐”按钮将它们的顶部边缘对齐。蓝图应该如此。

现在按钮已经很好地对齐,让我们继续创建链条。
创建水平链
要创建链条,请选择所有三个按钮,然后右键单击其中一个。从出现的菜单中选择“链条”选项,然后选择“创建水平链条”。

创建水平链时,它会将按钮连接起来,并将第一个和最后一个视图固定在蓝图的垂直边缘。链条应该看起来像这样:

默认情况下,链中的视图在蓝图的边缘之间均匀分布。您可以通过右键单击链中的一个视图,在出现的菜单中选择"Chains"选项,然后选择"水平链样式"来更改此行为。
可能的链式风格选项包括展开、展开内部和紧凑。通过尝试以下练习,看看你能否弄清楚这些选项的作用。
有不同风格的链
正如您所发现的,您可以选择不同的链式风格来改变链如何排列其视图。
展开将视图间隔开到蓝图的边缘
默认样式是展开。这用于在蓝图的边缘之间均匀分布视图,如下所示:

把第一和最后视图移到边缘
展开内部风格类似于展开,但它将第一和最后视图移动到蓝图的边缘。然后均匀间隔任何剩余的视图,如下所示:

紧凑将视图移到一起
紧凑风格用于紧密排列视图。它像这样将整组视图居中:

现在您已经了解了这些选项的作用,请让我们对这款应用进行测试驾驶。
测试驾驶
当我们使用展开链的样式并运行应用程序时,按钮在设备屏幕上均匀分布。这与屏幕方向无关。

始终在各种设备尺寸和方向上测试布局,以确保其外观和行为符合预期。
她是对的。
约束布局可以包括水平链和垂直链,一个单独的视图可以同时属于这两种类型。您可以使用这种方法来在网格中排列视图。
例如下面的蓝图显示了以网格排列的六个按钮。每一行都是水平链,最左边的按钮形成了垂直链:

另一种创建网格的方法是使用流。让我们找出这是什么,以及如何使用它。
流就像是一条多行链
一个流就像一条可以跨越多行的链条。例如,当你想在一行中显示许多视图,但由于某些屏幕尺寸或方向的原因它们可能不适合屏幕时,它就非常有价值。
例如,假设您有一个链,它在水平行中显示六个按钮。当方向是横向时,它们显示如下:

但当方向更改为纵向时,没有足够的空间来显示所有视图:

如果用流替换链,任何不能适合第一行的视图将流到第二行,就像这样:

让我们看看流是如何工作的,通过构建上述布局。
如何添加一个流
首先,在设计编辑器工具栏上点击“清除所有约束”按钮以移除任何约束。然后向蓝图添加额外的按钮,使总数达到六个,就像这样:

接下来,选择所有按钮,点击设计编辑器工具栏中的“指南线”按钮,然后选择“流程”选项。这将添加流程组件。
现在我们需要调整流程组件的设置,使其表现出我们想要的行为。为此,选择组件树中的流程,然后使用蓝图或约束小部件添加约束,连接其侧面和顶部到蓝图的边缘。将其layout_width属性更改为“0dp”,使其与其约束匹配。最后,在属性面板中搜索其flow_wrapMode属性,并将其设置为“chain”。
当您完成所有这些更改时,当方向为横向时,蓝图应该看起来像这样:

如果将方向更改为纵向,则蓝图应该如下所示:

创建流程后,您可以调整其显示视图的方式。让我们看看如何操作。
您可以控制流程的外观
您可以通过**flow_wrapMode**属性来改变流程的外观。
使用“chain”来创建多行链
如果将flow_wrapMode属性设置为chain,则流程将表现得像一个灵活的链条,允许其视图流到额外的行。
使用这个选项,您可以通过更改其flow_horizontalStyle属性的值进一步改变流程的外观。该属性的可能选项有 spread、spread inside 和 packed。例如,packed 选项会将视图紧密打包在一起,像这样:

使用“aligned”来对齐视图
如果将flow_wrapMode属性设置为aligned,视图会流到额外的行,并且会像这样对齐:

您还可以设置flow_wrapMode属性为none或者不设置。这将使流程表现得像普通的链条,使其视图不会流到第二行。
activity_main.xml 的完整代码
流程有时可能会有些难以正确配置,因此这是我们完整的activity_main.xml代码;如果您愿意,可以替换此文件的代码以匹配此处显示的代码:


让我们来测试一下这个应用程序。
测试驾驶
当我们使用链式流程并运行应用程序时,按钮在横向方向时均匀分布在设备屏幕上。
当我们将方向更改为纵向时,任何不能适应第一行的按钮会流动到第二行。

恭喜!你现在已经学会了如何使用约束布局设计超级灵活的屏幕。它们不仅可以按照你的期望显示和行为,而且不使用嵌套布局,因此非常高效。
在你继续下一章之前,为什么不将新学到的技能付诸实践,尝试一些你学到的技术呢?
你的 Android 工具箱

你已经掌握了第四章,现在将约束布局添加到你的工具箱中。

第六章:片段和导航:找到你的方式

大多数应用程序需要多个屏幕。
到目前为止,我们只看了如何创建单屏应用程序,这对于简单的应用程序来说是可以的。但是如果你有更复杂的需求呢?在本章中,你将学习如何使用片段和导航组件来构建多屏应用程序。你将了解到片段就像是具有自己方法的子活动。你将了解如何设计有效的导航图。最后,你将会遇到导航主机和导航控制器,并学习它们如何帮助你从一个地方导航到另一个地方。
大多数应用程序需要多个屏幕
到目前为止,您建立的所有应用程序都有一个共同点:它们每个都有一个单屏幕。每个应用程序都有一个对应的布局的单个活动,该布局定义了应用程序的外观和用户的交互方式。
然而,大多数应用程序包含多个屏幕。例如,电子邮件应用程序可能有一个屏幕用于撰写电子邮件,另一个用于显示收到的电子邮件列表。日历应用程序可能在一个屏幕上显示事件列表,在另一个屏幕上显示特定事件的详细信息。
我们将向您展示如何通过创建秘密消息应用程序来构建多屏应用程序。该应用程序包括一个欢迎屏幕,一个允许用户输入消息的第二屏幕,以及显示消息加密版本的第三屏幕。
这是应用程序的外观:

每个屏幕都是一个片段
秘密消息应用程序有三个不同的屏幕,并且我们将每个屏幕作为一个单独的片段来构建。片段类似于显示在活动布局内部的一种子活动。它具有控制其行为的 Kotlin 代码,并且有一个关联的布局来定义其外观。
这是应用程序将使用的三个片段:
一个片段有控制其行为的 Kotlin 代码和指定其外观的布局。
WelcomeFragment
这是应用程序的主屏幕。它需要显示一些介绍性文本和一个按钮。该按钮将用于导航到下一个屏幕——MessageFragment。
MessageFragment
这个屏幕将允许用户在编辑文本中输入消息。当用户点击按钮时,应用程序将导航到EncryptFragment。
EncryptFragment
这是最终的屏幕。它加密用户的消息,并显示结果。

正如您所看到的,用户需要能够在所有三个片段之间导航。那么这将如何工作呢?
使用导航组件在屏幕之间导航
在片段之间导航的最佳方式是使用 Android 的导航组件。导航组件是 Android Jetpack 的一部分,它帮助你以标准方式实现导航。

要在“秘密消息”应用程序中使用导航组件,我们将包含一个名为MainActivity的单一活动。当用户通过应用程序导航时,该活动将依次显示每个片段,如下所示:

当我们构建“秘密消息”应用程序时,您将进一步了解片段和导航组件。让我们按照创建它的步骤进行。
我们将要做的事情
我们将在本章开始构建“秘密消息”应用程序,并在接下来完成它。
本章中我们将做以下事情:
-
创建并显示 WelcomeFragment。
在这一步中,我们将创建
WelcomeFragment,它将是应用程序的第一个屏幕。我们将在MainActivity的布局中显示此片段,以便用户在启动应用程序时看到它。![image]()
-
导航至 MessageFragment。
我们将创建一个名为
MessageFragment的第二个片段,并在用户点击WelcomeFragment布局中的按钮时导航到它。我们将使用 Android 的导航组件实现导航。![image]()
让我们从创建应用程序的新项目开始。
创建一个新项目。

我们将使用一个新项目来创建“秘密消息”应用程序,因此现在使用与前几章相同的步骤创建一个项目。选择空活动选项,输入名称“秘密消息”和包名称“com.hfad.secretmessage”,并接受默认保存位置。确保语言设置为 Kotlin,最小 SDK 为 API 21,以便在大多数 Android 设备上运行。
添加一些 String 资源
在创建任何片段之前,我们将向项目中添加一些字符串资源。我们将在片段的布局中使用这些资源来显示文本,例如按钮的标签和第一个屏幕上的欢迎消息。
要添加字符串,请在SecretMessage/app/src/main/res/values文件夹中打开strings.xml文件,然后向文件中添加以下资源(加粗部分):

将 WelcomeFragment 添加到项目中
我们将向项目中添加名为WelcomeFragment的片段。WelcomeFragment将是用户打开应用程序时看到的第一个屏幕,我们将在此屏幕上显示一些关于应用程序的介绍性文本和一个按钮。
要添加片段,请在项目资源管理器中突出显示app/src/main/java文件夹中的com.hfad.secretmessage包,转到“文件”菜单,然后选择新建→片段→片段(空白)。
您将被问及如何配置新片段。将片段命名为“WelcomeFragment”,并为其布局命名为“fragment_welcome”。然后确保语言设置为 Kotlin,然后单击“完成”按钮。


单击“完成”按钮时,Android Studio 会创建新的片段并将其添加到项目中。
片段代码的外观
创建新片段时,Android Studio 会向您的项目添加两个文件:一个控制片段行为的 Kotlin 文件,和一个描述片段外观的布局文件。
我们首先来看 Kotlin 代码。转到app/src/main/java文件夹中的com.hfad.secretmessage包,并打开WelcomeFragment.kt文件。然后用下面的代码替换 Android Studio 生成的代码:

片段代码看起来类似于活动代码
上述代码定义了一个基本片段。正如您所见,片段的代码看起来与活动代码非常相似。但是,它不是扩展AppCompatActivity,而是扩展了Fragment。
androidx.fragment.app.Fragment类是 Android Jetpack 的一部分,用于定义基本片段。它包括最新的片段功能,并与较旧版本的 Android 向后兼容。
片段覆盖了onCreateView()方法,该方法在 Android 需要片段布局时立即调用。几乎每个片段都会覆盖此方法,因此让我们更详细地看一下它。

片段的onCreateView()方法
onCreateView()方法在 Android 需要访问片段布局时调用。虽然覆盖此方法是可选的,但由于您需要为几乎每个创建的片段定义一个布局而覆盖它,因此几乎每个片段都会覆盖它。
该方法接受三个参数:

第一个参数是LayoutInflater,用于扩展片段的布局。正如您在第三章中学到的那样,扩展布局会将其 XML 视图转换为对象。
第二个参数是ViewGroup?。这是在活动布局中显示片段的ViewGroup。
注意
您将在几页后了解更多信息。
最后一个参数是Bundle?。如果您之前保存了片段的状态并希望重新启用它,则会使用这个参数。它的工作方式类似于传递给活动的onCreate()方法的Bundle?参数。
扩展片段的布局,并返回它
onCreateView()方法返回一个View?,这是片段布局的扩展版本。

使用LayoutInflater的inflate()方法来扩展布局,示例如下:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_welcome, container, false)
}
上述代码是调用活动的setContentView()方法的片段等效代码,因为它用于将WelcomeFragment的布局fragment_welcome.xml扩展为View对象的层次结构。
一旦片段布局被扩展,View层次结构将被插入到活动的布局中并显示。
现在您已经看到WelcomeFragment的 Kotlin 代码,让我们来看一下它的布局。

片段布局代码看起来像活动布局代码
正如我们之前所说,片段使用布局文件描述其外观。活动布局和片段布局代码之间没有区别,因此 你可以在片段布局代码中使用任何你已经熟悉的视图和视图组。
我们将用一个线性布局来替换 Android Studio 为我们生成的默认布局代码,其中包含一个文本视图,显示应用程序的简要描述,以及一个按钮,我们将在本章后面用来导航到不同的片段。
打开 app/src/main/res/layout 文件夹中的 fragment_welcome.xml 文件,并用以下代码替换其内容:

这是我们现在所需要的 WelcomeFragment(及其布局)的所有代码,因此让我们看看如何在应用程序中显示它。
在 FragmentContainerView 中显示一个片段。
要显示一个片段,你需要将它添加到活动的布局中。例如,在这个应用程序中,我们将通过将 WelcomeFragment 添加到 MainActivity 的布局文件 activity_main.xml 中来显示它。
使用 **FragmentContainerView** 将片段添加到布局中。这是一种用于显示片段的 FrameLayout 类型,并且你可以使用以下代码将其添加到布局文件中:

通过将 FragmentContainerView 的 android:name 属性设置为完全限定的片段名称(包括其包名),来指定要显示的片段。在 Secret Message 应用中,我们希望显示位于 com.hfad.secretmessage 包中的名为 WelcomeFragment 的片段,因此我们使用以下方式设置 android:name 属性:
android:name="com.hfad.secretmessage.WelcomeFragment"

当 Android 创建活动的布局时,它使用片段的 onCreateView() 方法返回的 View 对象填充 FragmentContainerView。这个 View 是片段的用户界面,因此你可以将 FragmentContainerView 视为插入片段布局的位置的占位符:

现在你知道如何将片段添加到布局中了,让我们将 WelcomeFragment 添加到 MainActivity 的布局中。
更新 activity_main.xml 代码
我们希望 MainActivity 显示 WelcomeFragment,这意味着我们需要将一个 FragmentContainerView 添加到其布局中。
这是 activity_main.xml 的完整代码:替换代码以包含此处显示的更改:

MainActivity.kt 的完整代码
我们无需添加任何额外的 Kotlin 代码到 MainActivity 中来使其显示一个片段,因为布局中的 FragmentContainerView 处理了一切。你只需要确保 MainActivity.kt 中的代码看起来像这样:

让我们看看应用程序运行时会发生什么。
代码的作用
当应用程序运行时发生以下事情:
-
当应用程序启动时,MainActivity 被创建。
![image]()
-
MainActivity 的 onCreate() 方法运行。
onCreate()方法指定MainActivity的布局应使用 activity_main.xml。![图片]()
-
activity_main.xml 包含一个 FragmentContainerView。
它的
android:name属性指定需要显示WelcomeFragment。![图片]()
-
调用了
WelcomeFragment的onCreateView()方法,该方法膨胀了它的布局。WelcomeFragment的膨胀视图层次结构添加到MainActivity的布局中的FragmentContainerView中。![图片]()
-
最后,MainActivity 显示在设备上。
由于
FragmentContainerView包含WelcomeFragment,因此该片段显示在屏幕上。![图片]()
现在您已经看到代码运行时发生的情况,让我们来测试一下这个应用程序吧。
测试驾驶
运行秘密消息应用程序时,会启动 MainActivity。MainActivity 的布局中的 FragmentContainerView 包含 WelcomeFragment,因此该片段的布局会显示在设备上。

现在您已经学会了如何创建和显示片段。在我们构建第二个片段并学习如何导航到它之前,请尝试以下练习。
池子谜题

你的 任务 是从池中获取代码片段,并将它们放置到线性布局的空白行中。你不能多次使用同一个代码片段,并且不需要使用所有的代码片段。你的 目标 是使线性布局显示两个片段,AFragment 和 BFragment,从而在右侧显示的屏幕。提示:两个片段都位于名为 com.hfad.exercise 的包中。


注意
注意:每个来自池中的元素只能使用一次!
池子谜题解答

你的 任务 是从池中获取代码片段,并将它们放置到线性布局的空白行中。你不能多次使用同一个代码片段,并且不需要使用所有的代码片段。你的 目标 是使线性布局显示两个片段,AFragment 和 BFragment,从而在右侧显示的屏幕。提示:两个片段都位于名为 com.hfad.exercise 的包中。


创建 MessageFragment

到目前为止,我们创建了一个名为 WelcomeFragment 的片段,它显示在 MainActivity 的布局中。接下来,我们将创建一个名为 MessageFragment 的新片段,用户点击 WelcomeFragment 的“开始”按钮时会导航到该片段。
我们将以与添加WelcomeFragment相同的方式添加MessageFragment。在项目资源管理器中突出显示app/src/main/java文件夹中的com.hfad.secretmessage包,转到文件菜单,选择 New→Fragment→Fragment(Blank)。将片段命名为MessageFragment,其布局命名为“fragment_message”,并确保语言设置为 Kotlin。然后点击完成按钮,将片段及其布局添加到项目中。


更新 MessageFragment 的布局
创建MessageFragment时,Android Studio 会向您的项目添加两个新文件:MessageFragment.kt(指定片段行为)和fragment_message.xml(定义其外观)。我们将更新这两个文件,从布局开始。
片段需要有一个编辑文本框,让用户输入消息,以及一个按钮,稍后将用于导航。您已经熟悉添加这些视图的代码,因此更新fragment_message.xml中的代码,使其与此处显示的代码匹配:

这是MessageFragment布局所需的所有代码,让我们继续更新其 Kotlin 代码。
更新 MessageFragment.kt
MessageFragment的 Kotlin 代码定义了片段的行为。目前,我们只需确保 Android Studio 未向其添加任何可能导致其无法按照我们想要的方式工作的不必要额外代码。
转到app/src/main/java文件夹中的com.hfad.secretmessage包,并打开文件MessageFragment.kt。然后用下面的代码替换 Android Studio 生成的代码:

上面的代码是MessageFragment.kt定义基本片段所需的所有内容。就像您在WelcomeFragment中看到的代码一样,它扩展了Fragment类,并覆盖了其onCreateView()方法。此方法会填充片段的布局,并返回其根视图。每当应用程序需要显示片段时,它都会被调用。
我们已经完成了MessageFragment所需的所有布局和 Kotlin 代码的编写。接下来,我们需要让WelcomeFragment导航到它。那么如何实现呢?

使用导航组件在片段之间导航
正如本章前面所述,导航到片段的标准方法是使用 Android 的导航组件。
导航组件是 Android Jetpack 的一部分,是您添加到项目中的一套库、插件和工具。它非常灵活,简化了许多片段导航的复杂性,例如片段事务和返回堆栈操作,这些以前实现起来更加困难。
在片段之间导航由三个主要部分组成:
-
一个导航图导航图包含了应用程序所需的所有与导航相关的信息,并描述了用户在导航应用时可能采用的路径。
导航图是一个 XML 资源,但通常您会使用可视化设计编辑器来编辑它。
![图片]()
-
导航宿主导航宿主是一个空容器,用于显示您导航到的片段。您将导航宿主添加到活动的布局中。
![图片]()
-
导航控制器导航控制器控制着在用户导航应用程序时在导航宿主中显示哪个片段。您可以使用 Kotlin 代码与导航控制器交互。
![图片]()
我们将使用这三个元素来实现 Secret Message 应用中的导航功能。首先,让我们向项目中添加导航组件的库。
使用 Gradle 将导航组件添加到您的项目中
正如你在第四章中学到的,通过对build.gradle文件进行更改,你可以为应用添加任何额外的库、工具和插件。当你创建一个新项目时,Android Studio 会自动为你包含这两个文件:一个是项目的,另一个是应用的。
要添加导航组件,您需要编辑build.gradle的两个版本。让我们从更新项目版本开始。
向项目build.gradle文件添加版本号
我们将从向项目的build.gradle文件中添加一个新变量开始,指定我们将使用的导航组件的版本。使用变量来表示版本号意味着,如果我们添加了任何额外的导航组件库(我们将在下一章中完成),每个库的版本号都将保持一致。
要添加这个变量,请打开文件SecretMessage/build.gradle,并在buildscript部分添加以下行(用粗体标出):

向应用的build.gradle文件添加一个依赖项
接下来,您需要向应用程序版本的build.gradle文件中添加一个库依赖。
打开文件SecretMessage/app/build.gradle,并在dependencies部分添加以下行(用粗体标出):

完成这些更改后,单击代码编辑器顶部显示的“立即同步”选项。这将同步您所做的更改与项目的其余部分,并添加库。
创建一个导航图
现在我们已将导航组件的主库添加到 Secret Message 项目中,我们可以实现导航了。
首先,我们将向项目添加一个导航图。在项目资源管理器中选择SecretMessage/app/src/main/res文件夹,然后选择文件→新建→Android 资源文件。在提示时,输入文件名“nav_graph”,选择资源类型“Navigation”,然后点击“确定”按钮。这将在SecretMessage/app/src/main/res/navigation文件夹中添加一个名为nav_graph.xml的空导航图文件。

创建新的导航图后,通过双击项目资源管理器中的nav_graph.xml文件来打开它(如果尚未打开)。该文件应该在导航图设计编辑器中打开,看起来像这样:

向导航图中添加片段
我们希望用户能够从WelcomeFragment导航到MessageFragment,因此我们需要将这些片段添加到导航图中作为目的地。目的地是应用程序中的一个屏幕—通常是一个片段—用户可以导航到该屏幕。
首先,我们将首先添加WelcomeFragment,因为这是我们希望用户在应用程序启动时看到的第一个屏幕。在设计编辑器顶部点击“新目的地”按钮,然后在提示时选择“fragment_welcome”(WelcomeFragment的布局)选项。这将WelcomeFragment添加到导航图中,使其看起来像这样:

接下来,通过点击“新目的地”按钮并选择“fragment_message”选项,将MessageFragment添加到导航图中。这样就向导航图中添加了第二个片段,如下所示:

使用操作连接片段
接下来,我们需要指定用户可以从WelcomeFragment导航到MessageFragment,这是通过操作完成的。操作用于连接导航图中的目的地,并定义用户在应用程序中导航时可以采取的可能路径。
我们将添加一个从WelcomeFragment到MessageFragment的操作,因为这是我们希望用户在应用程序中导航的方向。将鼠标指针悬停在设计编辑器中的WelcomeFragment上,然后单击出现在其右侧的圆圈,并将其拖动到MessageFragment。这样在两个片段之间画出一个箭头—即操作:

每个操作都需要一个唯一的 ID
每个操作必须有一个唯一的 ID。Android 使用此 ID 来确定用户在应用程序中导航时需要显示哪个目的地。
每次创建操作时,Android Studio 都会为其分配一个默认的 ID。您可以使用导航图右侧的属性面板编辑此 ID—以及操作的任何其他属性。
你希望刚刚创建的操作的 ID 为“action_welcomeFragment_to_messageFragment”,以便与本章中的代码匹配。请在设计编辑器中选择操作(箭头),并检查其在属性面板中的id属性值,确保是这样。稍后几页将会用到这个 ID。

导航图是 XML 资源。
就像布局一样,导航图实际上只是一堆 XML 代码。要查看代码,请点击设计编辑器顶部的“Code”按钮。
下面是秘密消息应用程序导航图 nav_graph.xml 的底层 XML 代码:

正如你所见,nav_graph.xml 有一个根元素 <navigation>,包含两个 <fragment> 元素:一个是 WelcomeFragment,另一个是 MessageFragment。WelcomeFragment 的 <fragment> 元素包含一个额外的 <action> 元素,表示我们刚刚添加的操作。
现在我们已经创建了导航图,让我们继续 Navigation 组件的下一部分。
通常使用设计编辑器编辑导航图,但检查 XML 仍然很有用。
使用 FragmentContainerView 向布局添加导航主机。
正如我们之前提到的,Navigation 组件由三个主要部分组成:定义可能导航路径的导航图、显示目的地的导航主机和控制显示哪个目的地的导航控制器。我们刚刚创建了一个导航图,所以下一步我们将添加导航主机。
通过在活动的布局中包含导航主机来添加导航主机。好消息是 Navigation 组件自带一个名为 **NavHostFragment** 的内置导航主机,所以你不必自己编写一个。它是实现了 NavHost 接口的 Fragment 子类。
由于 NavHostFragment 是一种片段类型,你可以使用 FragmentContainerView 将其添加到布局文件中。代码如下:


上面的代码类似于你之前看到的 FragmentContainerView 代码,但包含两个额外的属性:**app:navGraph** 和 **app:defaultNavHost**。
app:navGraph 属性告诉导航主机使用哪个导航图,在本例中为 nav_graph.xml。导航图指定了首个要显示的片段(其起始目的地),并允许用户在其目的地之间导航。
app:defaultNavHost 属性允许导航主机与设备的返回按钮交互:关于此内容,你将在下一章中了解更多。

在 activity_main.xml 中添加一个 NavHostFragment。
我们将在 MainActivity 的布局中添加一个使用我们创建的导航图的导航主机。要做到这一点,请更新 activity_main.xml 中的代码,包括下面的更改(用粗体标出)。

我们需要在片段之间导航。
我们现在已经创建了一个导航图,并将其链接到 MainActivity 布局中的 FragmentContainerView 中保存的导航主机。应用程序运行时,导航图的起始目标 WelcomeFragment 将被显示。
本章的最后一件事是在用户点击 WelcomeFragment 布局中的开始按钮时从 WelcomeFragment 导航到 MessageFragment。让我们看看如何实现这一点。

向按钮添加 OnClickListener
要从 WelcomeFragment 导航到 MessageFragment,我们首先需要使 WelcomeFragment 的开始按钮响应点击。我们将通过向其添加 OnClickListener 来实现这一点。
之前,你通过使用 findViewById() 获取对活动按钮的引用,然后调用其 setOnClickListener 方法来向活动按钮添加 OnClickListener。由于活动在其布局中首次访问视图时,你将此代码包含在活动的 onCreate() 方法中。
然而,当你想向片段按钮添加 OnClickListener 时,情况略有不同。

片段 OnClickListener 代码略有不同
第一个区别是,你在片段的 **onCreateView()** *方法中向片段的按钮添加了一个 OnClickListener,而不是 在 onCreate() 中。这是因为片段在 onCreateView() 中首次访问其视图,因此这是设置任何 OnClickListener 的最佳位置。
第二个区别是 **Fragment** 类不包括 **findViewById()** 方法,因此你不能直接调用它来获取任何视图的引用。不过,你可以在片段的根视图上调用findViewById()。
下面是在片段代码中向视图添加 OnClickListener 的代码示例:我们将在几页后的 WelcomeFragment 中添加这个。


现在你知道如何向片段的按钮添加 OnClickListener,让它在点击时导航。
Fragment 类不是 Activity 的子类。
尽管片段与活动有很多共同点,但 Fragment 类不扩展 Activity,因此不会继承其任何方法。
相反,Fragment 类定义了自己的一套方法集。虽然其中许多方法看起来与活动继承的方法相同,但它不包括像 findViewById() 这样的方法。
当我们继续阅读本书时,你将进一步了解片段及其方法。现在,让我们看看需要添加到 WelcomeFragment 的代码,以便在其按钮被点击时导航到 MessageFragment。

获取导航控制器
每当您想要导航到新的片段时,您首先需要获取一个导航控制器的引用。您可以通过调用其根View对象上的**findNavController()**方法来实现这一点。例如,以下代码获取与名为view的根视图对象关联的导航控制器的引用:
val navController = view.findNavController()
使用一个动作告诉它要导航到哪里
一旦您拥有了导航控制器,您可以通过调用其**navigate()**方法向其请求导航到新的目标。此方法接受一个参数:导航动作 ID。
正如您可能还记得的,当我们创建导航图时,我们包含了一个从WelcomeFragment到MessageFragment的动作。我们给这个动作取了 ID“action_welcomeFragment_to_messageFragment”。
如果我们将此 ID 传递给导航控制器的navigate()方法,控制器将看到操作是从WelcomeFragment到MessageFragment,并用它来导航到新的片段。
这是代码的样子:


当用户点击WelcomeFragment的 Start 按钮时,我们希望导航到MessageFragment。因此,我们将在 Start 按钮的OnClickListener中添加以下代码:

我们将在下一页上展示完整的WelcomeFragment代码。
WelcomeFragment.kt 的完整代码
下面是WelcomeFragment的完整代码;更新WelcomeFragment.kt以包含以下更改(用粗体标出):

这是使WelcomeFragment导航到MessageFragment所需的全部代码。我们来看看代码运行时的操作,然后测试应用程序。
应用程序运行时发生的事情
当应用程序运行时会发生以下事情:
-
应用程序启动并创建 MainActivity。
![image]()
-
MainActivity 的布局 activity_main.xml 包含一个 FragmentContainerView,指定了导航主机和导航图。
![image]()
-
导航图中的起始目标是 WelcomeFragment,因此将此片段添加到导航主机并显示在设备屏幕上。
![image]()
-
用户在 WelcomeFragment 的布局中点击 Start 按钮。
![image]()
-
Start 按钮的 OnClickListener 代码查找导航控制器,并调用其 navigate()方法。
它传递了从
WelcomeFragment到MessageFragment导航使用的动作。![image]()
-
导航控制器在导航图中查找具有此 ID 的操作。
这段代码表明操作从
WelcomeFragment到MessageFragment。![image]()
-
导航控制器在导航主机中用 MessageFragment 替换 WelcomeFragment,并在设备屏幕上显示 MessageFragment。
![image]()
测试驾驶
当我们运行应用时,MainActivity 被启动,WelcomeFragment 如前所示显示。
当我们点击“开始”按钮时,应用程序导航到 MessageFragment,并在设备上显示此片段。

祝贺!您现在已经学会了如何构建一个可以让您从一个屏幕导航到另一个屏幕的多屏应用程序。
在下一章中,当我们完成秘密消息应用程序的构建时,您将继续增加这些知识。
你的 Android 工具箱

你已经掌握了第六章,现在将片段和导航添加到你的工具箱中。

第七章:安全参数:传递信息

有时片段需要额外的信息才能正常工作。
如果一个片段显示联系人的详细信息,例如需要显示哪个联系人。但是,如果这些信息来自另一个片段,它可能需要额外的信息才能正常工作。在这一章中,你将通过学习如何在片段之间传递数据,进一步建立你的导航技能。你将了解如何向导航目标添加参数,以便它们可以接收所需的信息。你将遇到安全参数插件,并学习如何使用它来编写类型安全的代码。最后,你将发现如何操作返回栈,并控制返回按钮的行为。继续阅读,一切不可逆转...
秘密消息应用程序在片段之间导航
在上一章中,您已经学会如何使用导航组件在两个片段之间导航。您将此知识用于构建秘密消息应用程序的第一部分:一个接收用户输入的消息并对其进行加密的应用程序。
应用程序的当前版本使用一个名为WelcomeFragment的片段来显示一些简介文字。当用户点击其开始按钮时,该片段导航到名为MessageFragment的第二个片段。
MessageFragment包含一个编辑文本框,让用户输入她的消息。它还包括一个 Next 按钮,用户点击该按钮以加密消息。
这是当前应用的外观提醒:

在应用程序的当前版本中,当用户点击MessageFragment的 Next 按钮时,什么也不会发生。在本章中,我们将完成应用程序的构建,以便用户的加密消息在一个新的片段中显示。
MessageFragment 需要将消息传递给新的片段
我们将向应用程序添加一个名为EncryptFragment的新片段。该片段将显示用户加密的消息,并且外观将如下所示:

EncryptFragment将从MessageFragment获取用户的消息。当用户点击MessageFragment的 Next 按钮时,应用程序将导航到EncryptFragment并将文本传递给它。然后,该片段将加密文本并显示结果:

要使这个工作,我们需要能够在片段之间传递数据。最佳方法是使用名为Safe Args的 Gradle 插件,它是导航组件的额外部分。它提供了一种类型安全地在片段之间传递数据的方法。这可以防止您意外传递错误类型的数据,从而可能导致运行时错误。
随着我们继续构建秘密消息应用程序,您将了解更多有关使用 Safe Args 的信息。现在,让我们看看我们将要经历的步骤。
我们要做的事情如下
以下是我们将完成秘密消息应用程序后半部分的步骤:
-
创建和显示 EncryptFragment。
我们将创建一个名为
EncryptFragment的新片段,并在用户在MessageFragment布局中点击“下一步”按钮时导航到它。![图片]()
-
将用户的消息传递给
EncryptFragment。我们将使用 Safe Args 将用户的消息从
MessageFragment传递到EncryptFragment。然后,EncryptFragment将显示加密后的消息。![图片]()
-
修改应用程序的返回按钮行为。
最后,我们将更新应用程序,以便用户在显示
EncryptFragment时按设备返回按钮时,应用程序返回到WelcomeFragment。![图片]()

让我们开始吧。
创建EncryptFragment…

我们将使用EncryptFragment来显示用户消息的加密版本。通过在app/src/main/java文件夹中突出显示com.hfad.secretmessage包,转到“文件”菜单,选择“新建→片段→空白片段”。将片段命名为EncryptFragment,布局命名为“fragment_encrypt”。确保语言设置为 Kotlin,然后单击“完成”按钮。
…并更新其布局
我们将更新EncryptFragment的布局,以包含两个文本视图。第一个将显示名为encrypt_text的String资源(我们在前一章节中已将其添加到strings.xml),第二个将显示加密后的消息。
打开布局文件fragment_encrypt.xml并更新其内容,使其与下面的代码匹配:

更新EncryptFragment.kt
我们还需要更新EncryptFragment的 Kotlin 代码,以确保 Android Studio 没有添加任何可能阻止其按预期工作的不必要额外代码。
转到app/src/main/java文件夹中的com.hfad.secretmessage包,并打开文件EncryptFragment.kt。然后用下面显示的代码替换 Android Studio 生成的代码:

上述代码是EncryptFragment.kt需要定义基本片段的全部内容。就像你看到的其他片段代码一样,它扩展了Fragment类,并覆盖了其onCreateView()方法。此方法将充气片段的布局,并返回其根视图。
我们现在已经完成了EncryptFragment所需的所有布局和 Kotlin 代码编写。接下来,我们将确保MessageFragment能够通过将其添加到导航图中进行导航。

将EncryptFragment添加到导航图
正如您已经知道的那样,导航图保存了应用程序目标的详细信息,以及导航到这些目标的可能路径。
要将EncryptFragment添加到导航图中,请打开nav_graph.xml,在设计编辑器中单击“新目标”按钮,然后在提示时选择“fragment_encrypt”选项。这将添加该片段。

我们还需要添加一个新的操作,以便MessageFragment可以导航到EncryptFragment。请将鼠标指针悬停在MessageFragment上,并从其右边缘绘制一个动作箭头,指向EncryptFragment。确保该操作具有 ID “action_messageFragment_to_encryptFragment”,以便与本书中的代码匹配。
注意
如果你觉得这有点复杂,不要担心。我们将在下一页显示完整的代码,这样你就可以选择更新它。
在你做出这些更改后,导航图应该看起来像这样:

让我们看看底层 XML 的样子。
更新后的 nav_graph.xml 代码
每次更新导航图时,任何更改都会添加到底层的 XML 中。新代码看起来像这样(更改部分用粗体表示):

让我们使用新的操作导航到EncryptFragment。
MessageFragment 需要导航到 EncryptFragment
当用户点击MessageFragment的“下一步”按钮时,我们需要使其导航到EncryptFragment。为此,我们将向按钮添加一个OnClickListener,其中包含导航代码。

正如你在前一章中学到的那样,通过获取导航控制器并传递导航操作,可以从一个片段导航到另一个片段。导航控制器使用此操作来显示正确的片段。
你已经熟悉完成这一点所需的所有代码,因此更新 MessageFragment.kt,以包括此处显示的更改(用粗体显示):

让我们进行一次应用程序测试,确保它可以正常工作。
测试驾驶
当我们运行应用程序时,启动MainActivity,并显示WelcomeFragment。当我们点击其“开始”按钮时,应用程序像以前一样导航到MessageFragment。
当我们在MessageFragment的编辑文本视图中输入消息并点击其“下一步”按钮时,应用程序将导航到EncryptFragment。它不会处理我们输入的消息。

由于我们目前编写的代码仅导航到此片段,没有将消息传递给新片段,因此EncryptFragment不会显示加密消息,因为它无法处理该消息。
为了将消息从MessageFragment传递到EncryptFragment,我们将使用 Safe Args。正如你之前学到的,这是导航组件的额外部分,允许你以类型安全的方式向目标传递参数。
让我们找出如何做到这一点。
将 Safe Args 添加到 build.gradle 文件

在开始使用 Safe Args 之前,我们需要更新项目和应用程序的 build.gradle 文件。现在让我们来做这件事。
在 project build.gradle 文件中添加一个类路径
首先需要在项目的 build.gradle 文件中添加一个新的类路径,指定你想要使用 Safe Args 插件。该类路径包括一个版本号,需要与主导航组件库的版本号匹配。
在 Secret Message 应用中,我们使用一个名为 nav_version 的变量来确保这些版本号匹配,因此我们将在类路径中引用它。
打开 SecretMessage/build.gradle 文件,并在 dependencies 部分添加粗体字的类路径:

将插件添加到应用的 build.gradle 文件中
接下来,你需要告诉 Gradle 你正在使用 Safe Args 插件,方法是在应用的 build.gradle 文件的 plug-ins 部分添加一行粗体字:

完成这些更改后,点击代码编辑器顶部出现的“立即同步”选项。这将把你所做的更改与项目的其余部分同步。
注意
确保每次更新 build.gradle 文件后同步你的更改。
现在应用包含了 Safe Args,我们可以使用它来从 MessageFragment 传递消息到 EncryptFragment。我们接下来就要做这个。
EncryptFragment 需要接受一个 String 参数
我们首先需要指定 EncryptFragment 可以接受用户的消息。我们将在导航图中为此片段添加一个 String 参数;然后 MessageFragment 将使用此参数将用户的消息(一个 String)传递给 EncryptFragment。

要添加参数,请打开 app/src/main/res/nav_graph.xml 中的文件 nav_graph.xml。然后在导航图设计编辑器中选择 EncryptFragment,转到属性面板,并点击出现在参数部分旁边的“+”按钮:

当你点击“+”按钮时,会出现“添加参数”窗口,用于添加关于参数的详细信息。在这里,我们希望 EncryptFragment 接受一个 String 类型的参数作为用户的消息,所以将参数命名为“message”,选择类型为String,然后点击“添加”按钮。这将创建新的参数,并将其添加到属性面板的参数部分:

更新后的 nav_graph.xml 代码
当你向导航图添加一个参数时,会在底层 XML 中添加一个新的 <argument> 元素。以下是 nav_graph.xml 的更新代码(新增的代码用粗体标出):

MessageFragment 需要将消息传递给 EncryptFragment
现在我们在 EncryptFragment 中添加了一个 String 参数,MessageFragment 可以在导航到该片段时使用它传递用户的消息。
正如您已经知道的那样,您通过将导航操作传递给导航控制器从一个目的地导航到另一个目的地。例如,当其按钮被点击时,MessageFragment使用以下代码导航到EncryptFragment:

您可以向导航操作传递参数
如果您想向目的地传递参数,只需将其值传递给导航操作即可。
当导航控制器接收到包含参数的操作时,它将导航到适当的片段,并传递参数的值。例如,在秘密消息应用中,我们可以通过将用户的消息包含在导航操作中,使MessageFragment传递给EncryptMessage(通过导航控制器)。它的工作原理如下:

您可以使用Directions类向导航操作添加参数。让我们来看看这是什么。
Safe Args 会生成 Directions 类
Directions类用于向目的地传递参数。当您启用 Safe Args 插件时,Android Studio 会使用它为您可以从中导航的每个片段生成一个Directions类(译者注:保留原文示例的内容,不要重复输出)。

每个片段都使用自己的Directions类进行导航。例如,如果MessageFragment要导航到其他位置并传递参数,则需要使用MessageFragmentDirections类。
使用 Directions 类向操作添加参数
每个Directions类都包含片段操作的生成方法。例如,MessageFragment具有 ID 为“action_messageFragment_to_encryptFragment”的操作,因此MessageFragmentDirections类包含一个名为actionMessageFragmentToEncryptFragment()的相应方法。由于EncryptFragment接受一个String,生成的方法包括一个String参数。

您可以使用生成的方法导航到目的地。例如,要从MessageFragment导航到EncryptFragment并传递String消息,您可以将以下代码添加到MessageFragment中:

现在让我们添加这段代码。
更新 MessageFragment.kt 代码
这是更新后的MessageFragment代码;确保将更改(用粗体显示)添加到MessageFragment.kt中:

这就是MessageFragment需要的一切,以便将用户的消息传递给EncryptFragment。接下来,我们需要让EncryptFragment接收并使用它。
EncryptFragment 需要获取参数的值
如您所知,MessageFragment 使用 String 参数将用户的消息传递给 EncryptFragment。EncryptFragment 需要检索此值,以便显示其加密版本。
Fragments 可以使用 **Args** 类检索参数。启用 Safe Args 插件后,Android Studio 用于为接受参数的每个片段生成 Args 类。例如,在秘密消息应用程序中,EncryptFragment 接受一个 String 参数,因此 Safe Args 插件会为其生成一个名为 EncryptFragmentArgs 的 Args 类:

Safe Args 插件生成 Directions 和 Args 类。使用 Directions 类向目标传递参数,使用 Args 类检索它们。
使用 Args 类来检索参数。
每个 Args 类都包含一个 **fromBundle()** 方法,用于检索传递给片段的任何参数。例如,在秘密消息应用程序中,EncryptFragment 接受名为 message 的 String 参数,因此您可以使用以下方式获取此参数的值:


这将把一个 String 对象(参数的值)赋给 message。
我们需要加密消息。
现在我们知道如何让 EncryptFragment 检索用户的消息,我们可以对其进行加密并显示结果。
我们将使用 Kotlin 的 reversed() 方法来加密消息,该方法简单地颠倒字符串的字母顺序。代码如下所示:
注意
在实际应用中,您可能想要使用更先进的加密技术。这只是一个示例。
val encryptedView = view.findViewById<TextView>(R.id.encrypted_message)
encryptedView.text = message.reversed()
我们将在下一页展示完整的 EncryptFragment 代码。
EncryptFragment.kt 的完整代码
这是 EncryptFragment.kt 的完整代码;更新其代码以包括以下更改:

这就是我们需要显示加密消息的所有内容。我们将在下一页详细介绍应用程序运行时发生的事情。
应用程序运行时发生了什么
应用程序运行时会发生以下事情:
-
应用程序启动并创建 MainActivity。
WelcomeFragment被添加到导航宿主并显示在设备屏幕上。![image]()
-
用户点击“开始”按钮时,其 OnClickListener 代码将操作传递给导航控制器的 navigate() 方法。
该操作描述了从
WelcomeFragment到MessageFragment的导航路径。![image]()
-
导航控制器将 MessageFragment 放入导航宿主,以便在设备屏幕上显示它。
![image]()
-
用户输入消息并点击“下一步”按钮。
![image]()
-
下一步按钮的 OnClickListener 使用 MessageFragmentDirections 类将消息附加到操作上。
将操作(包括消息)传递给导航控制器。
![image]()
-
导航控制器将消息传递给
EncryptFragment。EncryptFragment使用EncryptFragmentArgs类检索其值。![image]()
-
导航控制器在 MainActivity 的导航主机中用 EncryptFragment 替换 MessageFragment。
设备屏幕上显示
EncryptFragment,并显示加密消息。![image]()
Test Drive
运行应用程序时,WelcomeFragment将显示,点击“开始”按钮后,我们可以像以前一样导航到MessageFragment。
输入消息并点击MessageFragment的“下一步”按钮时,将显示EncryptFragment。它显示加密消息。

应用程序正如我们所希望的那样运行。
我们还想对其进行一些更改。在此之前,试着完成以下练习。
导航磁铁

下面的导航图代码定义了两个片段之间的导航路径:ChooseTypeFragment和DrinksFragment。
ChooseTypeFragment包含一个 ID 为choose的下拉框和一个 ID 为next的按钮。当用户点击按钮时,ChooseTypeFragment需要导航到DrinksFragment,并将来自下拉框的用户选择的值传递给它。
当显示DrinksFragment时,它需要在布局中的文本视图choice中显示所选的值。
有人试图使用冰箱磁铁编写ChooseTypeFragment和DrinksFragment的代码,但当我们关上冰箱门太快时,有些磁铁掉了。你能把代码重新拼起来吗?
导航图:
<?xml version="1.0" encoding="utf-8"?>
<navigation
android:id="@+id/nav_graph"
app:startDestination="@id/chooseTypeFragment">
<fragment
android:id="@+id/chooseTypeFragment"
android:name="com.hfad.drinksapp.ChooseTypeFragment"
android:label="fragment_choose_type"
tools:layout="@layout/fragment_choose_type" >
<action
android:id="@+id/action_chooseTypeFragment_to_drinksFragment"
app:destination="@id/drinksFragment" />
</fragment>
<fragment
android:id="@+id/drinksFragment"
android:name="com.hfad.drinksapp.DrinksFragment"
android:label="fragment_drinks"
tools:layout="@layout/fragment_drinks" >
<argument
android:name="drinkType"
app:argType="string" />
</fragment>
</navigation>
ChooseTypeFragment:
class ChooseTypeFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_choose_type, container, false)
val choice = view.findViewById<Spinner>(R.id.choose).selectedItem.toString()
val nextButton = view.findViewById<Button>(R.id.next)
nextButton.setOnClickListener {
val action = .....................................................
.....................................................(choice)
view.findNavController().navigate(action)
}
return view
}
}
DrinksFragment:
class DrinksFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_drinks, container, false)
val choice = ...................................................................
val choiceView = view.findViewById<TextView>(R.id.choice)
choiceView.text = choice
return view
}
}

导航磁铁解决方案

下面的导航图代码定义了两个片段之间的导航路径:ChooseTypeFragment和DrinksFragment。
ChooseTypeFragment包含一个 ID 为choose的下拉框和一个 ID 为next的按钮。当用户点击按钮时,ChooseTypeFragment需要导航到DrinksFragment,并将来自下拉框的用户选择的值传递给它。
当显示DrinksFragment时,它需要在布局中的文本视图choice中显示所选的值。
有人试图使用冰箱磁铁编写ChooseTypeFragment和DrinksFragment的代码,但当我们关上冰箱门太快时,有些磁铁掉了。你能把代码重新拼起来吗?
导航图:
<?xml version="1.0" encoding="utf-8"?>
<navigation
android:id="@+id/nav_graph"
app:startDestination="@id/chooseTypeFragment">
<fragment
android:id="@+id/chooseTypeFragment"
android:name="com.hfad.drinksapp.ChooseTypeFragment"
android:label="fragment_choose_type"
tools:layout="@layout/fragment_choose_type" >
<action
android:id="@+id/action_chooseTypeFragment_to_drinksFragment"
app:destination="@id/drinksFragment" />
</fragment>
<fragment
android:id="@+id/drinksFragment"
android:name="com.hfad.drinksapp.DrinksFragment"
android:label="fragment_drinks"
tools:layout="@layout/fragment_drinks" >
<argument
android:name="drinkType"
app:argType="string" />
</fragment>
</navigation>
ChooseTypeFragment:

DrinksFragment:


如果用户想要返回怎么办?

在秘密消息应用中,还有一件事情需要考虑:当用户尝试通过应用程序的屏幕返回时会发生什么。
正如您所知,您可以使用设备的返回按钮或向后手势在 Androidville 中返回,并且这将带您返回显示过的任何屏幕。

例如,假设用户启动秘密消息应用程序,并像这样从WelcomeFragment导航到MessageFragment,然后到EncryptFragment:

如果她在显示EncryptFragment时点击返回按钮,则应用程序返回到上一个片段—MessageFragment—如下所示:

但是如果您希望按下返回按钮将用户带回早期片段呢?
我们可以改变返回行为
当用户点击返回按钮时,不是返回到MessageFragment,直接返回到WelcomeFragment可能更好。这样用户可以从应用程序的开头重新开始,如下所示:

要了解如何控制这种行为,请让我们来看看 Android 返回堆栈的工作原理。
欢迎来到返回堆栈
当您在应用程序中从一个目标导航到另一个目标时,Android 通过将其添加到返回堆栈来跟踪您访问的每个位置。返回堆栈是应用程序中您访问过的所有位置的日志。每次导航到一个目标时,Android 都将其添加到返回堆栈顶部,当您按下返回按钮时,它将弹出堆栈中最近的目标,并显示其下方的目标。
返回堆栈场景
-
当您启动秘密消息应用程序时,WelcomeFragment 将显示。
Android 将
WelcomeFragment添加到返回堆栈。![image]()
-
您导航到 MessageFragment。
此目标被添加到返回堆栈顶部,高于
WelcomeFragment。![image]()
-
然后导航到 EncryptFragment。
EncryptFragment被添加到返回堆栈顶部。![image]()
-
您点击返回按钮,EncryptFragment 从返回堆栈中弹出。
MessageFragment显示在返回堆栈的顶部。![image]()
-
您再次点击返回按钮。
MessageFragment从返回堆栈顶部弹出,并显示WelcomeFragment。![image]()
使用导航图来弹出返回堆栈中的片段
您刚刚看到默认情况下返回堆栈和返回按钮的操作方式,但是如果您愿意,您可以在用户浏览应用程序时从返回堆栈中弹出目标。您可以在导航图中指定弹出行为来实现这一点。
要查看其工作原理,请更新秘密消息应用程序中的导航图,以便在应用程序从MessageFragment导航到EncryptFragment时,将MessageFragment从返回堆栈中弹出。这意味着当用户在显示EncryptFragment时点击返回按钮时,将显示WelcomeFragment,而不是MessageFragment。

打开导航图 nav_graph.xml(如果尚未打开),并切换到设计编辑器。选择连接 MessageFragment 到 EncryptFragment 的操作。然后在属性面板的“Pop Behavior”部分,将 popUpTo 属性的值更改为“welcomeFragment”。这告诉 Android 在到达 WelcomeFragment 前弹出后退栈中的片段:

在我们进行应用程序测试驾驶之前,让我们看看底层 XML 的内容。
更新后的 nav_graph.xml 代码
当您为操作添加弹出行为时,会在底层 XML 中添加 <popUpTo> 元素。这指定了应弹出后退栈中的片段到哪一步。
这是 nav_graph.xml 的更新代码:

测试驾驶
当我们运行应用时,显示 WelcomeFragment。我们可以像以前一样导航到 MessageFragment 和 EncryptFragment:

当我们点击返回按钮时,应用程序返回到 WelcomeFragment,并跳过 MessageFragment:

恭喜!你现在已经学会了如何构建一个可以在多个屏幕之间传递数据并与后退栈交互的应用程序。这些是构建现代 Android 应用程序的核心技能。
在下一章节中,您将发现更多使用导航组件的方法。
BE the Safe Args Plug-in

以下代码显示了 Starbuzz 应用的导航图。你的任务是像 Safe Args 插件一样,说明哪些 Directions 和 Args 类将从这段代码生成。
<?xml version="1.0" encoding="utf-8"?>
<navigation
android:id="@+id/nav_graph"
app:startDestination="@id/selectCoffeeFragment">
<fragment
android:id="@+id/selectCoffeeFragment"
android:name="com.hfad.starbuzz.SelectCoffeeFragment"
android:label="fragment_select_coffee"
tools:layout="@layout/fragment_select_coffee" >
<action
android:id="@+id/action_selectCoffeeFragment_to_coffeeFragment"
app:destination="@id/coffeeFragment" />
</fragment>
<fragment
android:id="@+id/coffeeFragment"
android:name="com.hfad.starbuzz.CoffeeFragment"
android:label="fragment_coffee"
tools:layout="@layout/fragment_coffee" >
<argument
android:name="coffee"
app:argType="string" />
</fragment>
</navigation>
答案在 “BE the Safe Args Plug-in Solution” 中。
BE the Safe Args Plug-in 解决方案

以下代码显示了 Starbuzz 应用的导航图。你的任务是像 Safe Args 插件一样,说明哪些 Directions 和 Args 类将从这段代码生成。

您的 Android 工具箱

你已经掌握了第七章的内容,并且现在已将 Safe Args 插件和后退栈操作添加到你的工具箱中。

第八章:导航 UI:前往各处

大多数应用程序需要能够在目的地之间进行导航。
并且通过 Android 的导航组件,构建这种 UI 变得更加简单。在这里,您将学习如何使用一些 Android 的导航 UI 组件,以便 使您的用户更轻松地导航您的应用程序。您将了解如何使用 主题,并将应用程序的默认应用程序栏替换为 工具栏,您将学习如何添加 用于导航的菜单项。您将发现如何实现 底部导航栏导航。最后,您将创建一个时髦的 导航抽屉:这是一个从活动侧边滑出的面板。
不同的应用程序,不同的结构
在前两章中,您学习了如何使用 Android 的导航组件通过点击按钮从片段导航到片段。这种方法在秘密消息应用程序中效果很好,因为我们需要以线性方式从一个目的地导航到另一个目的地:

但并非所有应用程序都遵循这种结构。许多应用程序有您需要能够从应用程序的任何位置导航到的屏幕。例如,电子邮件应用可能具有必须在整个应用程序中可用的收件箱、已发送项目和帮助屏幕:

当您有一个像这样导航结构较为松散的应用程序时,如何确保您始终可以导航到每个屏幕?
Android 包括导航 UI 组件
当你有希望在应用程序中任何地方访问的屏幕时,你可能希望使用 Android 的导航 UI 组件之一。它们包括:
应用程序栏
这是显示在屏幕顶部的条形区域,Android Studio 通常默认为您包含一个。您可以向应用程序栏中添加项目,当点击时,导航到目的地。

底部导航栏
这出现在屏幕底部。它包含少量项目,可用于导航。

导航抽屉
这是从屏幕边缘滑出的抽屉。许多应用程序包括其中一个,因为它非常灵活。

在本章中,我们将通过构建名为 CatChat 的原型电子邮件应用程序来发现如何实现这三种类型的导航 UI。
CatChat 应用程序将如何工作
CatChat 应用程序将包含一个名为 MainActivity 的单一活动,并且三个片段:InboxFragment、SentItemsFragment 和 HelpFragment。每个片段在用户导航到它们时将在 MainActivity 中显示。

应用程序将包括一个应用程序栏,其中包含一个帮助菜单项。当用户点击此菜单项时,应用程序将通过在 MainActivity 中显示它来导航到 HelpFragment。

该应用程序还将具有一个导航抽屉,用于在所有三个片段之间进行导航。抽屉将包括每个片段的项目,点击每个项目将显示正确的片段:

让我们来看看我们将如何创建 CatChat 应用的步骤。
这是我们要做的事情。
我们将执行四个主要步骤来创建 CatChat 应用:
-
用工具栏替换默认的应用栏。
工具栏看起来像一个应用程序的默认应用栏,但它为您提供了更多的灵活性,并包括最新的应用栏功能。在此过程中,您将学习如何应用主题和样式。
![图片]()
-
在工具栏中添加帮助菜单项。
点击时,帮助菜单项将导航到
HelpFragment。![图片]()
-
实现底部栏导航。
我们将向应用程序添加底部栏,以便我们可以在每个应用程序的片段之间导航。
![图片]()
-
创建导航抽屉。
最后,我们将用导航抽屉替换底部导航栏。抽屉将包括一个头部图像,并为应用程序的每个片段分组成部分的项目。
![图片]()
我们将从创建 CatChat 应用的新项目开始。
创建一个新项目。

我们将使用一个新的项目来创建 CatChat 应用程序,所以请使用前几章中使用的相同步骤来创建一个新的 Android Studio 项目。选择空活动选项,输入名称“CatChat”和包名称“com.hfad.catchat”,接受默认保存位置。确保语言设置为 Kotlin,最低 SDK 为 API 21,以便它可以在大多数 Android 设备上运行。
默认的应用栏被添加了…
当您使用空活动创建新项目时,Android Studio 通常会包含一个显示应用程序名称的应用栏。当您运行应用程序时,可以在屏幕顶部看到此应用栏:

拥有应用栏对于许多原因都很有用:
-
它使关键操作更加突出,以一种可预测的方式,例如共享内容或执行搜索。 -
它可以帮助用户知道他们在应用程序中的位置,显示应用程序名称或当前屏幕的标签。 -
您可以使用它导航到不同的目的地。
但是应用栏是如何添加的呢?
…通过应用主题
通过应用主题将默认的应用栏添加到应用程序中。主题使您的应用程序在多个屏幕上具有一致的外观和感觉。它控制应用程序的一般外观,以及是否包含应用栏。
Android 提供了许多可以在应用程序中使用的主题,默认情况下,Android Studio 应用了一个包含应用栏的主题。
CatChat 应用程序将使用 Material 主题。
我们将为 CatChat 应用程序应用使用Material Design的主题。
Material 是由 Google 开发的设计系统,帮助您构建具有统一外观和感觉的高质量应用程序和网站。其背后的理念是,用户可以从像 Play Store 这样的 Google 应用切换到第三方开发者设计的应用程序,并立即感到舒适并知道该如何操作。
Material 最初受到印刷设计原则和物体(如索引卡和纸片)的运动影响,反映了它们的外观和行为。其最新演变被称为 Material You,它为用户提供了更流畅的体验和个性化的配色方案。
应用的 build.gradle 文件需要一个 Material 库依赖项。
Material 主题保存在一个单独的库中,您需要将其包含在应用中。您可以通过在应用的 build.gradle 文件中添加依赖项到 com.google.android.material 库来实现这一点。
由于我们希望在 CatChat 应用中使用 Material 主题,因此我们需要确保它包含了这个库。打开文件 CatChat/app/build.gradle,确保包含以下行(用粗体标出):

Android Studio 可能已经为您在文件中添加了这个依赖项。如果没有,您需要自己添加,并点击代码编辑器顶部出现的 Sync Now 选项,将更改与项目的其余部分同步。
我们希望在 CatChat 应用中应用 Material 主题来控制其应用栏。让我们看看如何实现这一点。
在 AndroidManifest.xml 中应用主题
您可以在应用的 AndroidManifest.xml 文件中应用一个主题。正如您在 第三章 中所学到的,这个文件提供了关于应用程序配置的信息。它包含一些属性,包括主题,这些属性对应用栏有直接影响。
这是 Android Studio 在 CatChat 项目中为我们创建的 AndroidManifest.xml 代码(我们已将关键部分用粗体标出):

**android:label** 属性指的是显示在应用栏中的文本,并且它是您在创建应用时指定的名称。在上面的代码中,它使用了一个名为 app_name 的 String 资源,Android Studio 已经添加到 strings.xml 中。
**android:theme** 属性指定了主题。在上面的代码中,它是使用以下方式设置的:
android:theme="@style/Theme.CatChat">
这指定主题被定义为一个名为 Theme.CatChat 的样式资源(由 @style 表示)。
在样式资源文件中定义样式
样式资源用于描述应用程序需要使用的任何主题和样式,并保存在一个或多个样式资源文件中。
当我们创建 CatChat 应用时,Android Studio 为我们创建了两个样式资源文件。这两个文件名都是 themes.xml,分别位于 app/src/main/res/values 和 app/src/main/res/values-night 文件夹中。values 文件夹中的文件是应用的默认样式资源文件,而values-night 文件夹中的文件用于夜间模式。
注意
有些版本的 Android Studio 将这个文件命名为 styles.xml。
values 文件夹中的 themes.xml 定义了如下的样式代码:

**<style>** 元素告诉 Android 它是一个样式资源。
每个样式必须有一个名称以标识它,您可以使用 **name** 属性来定义,如下所示:

AndroidManifest.xml 的 theme 属性使用此名称来设置应用程序的主题,例如 "@style/Theme.CatChat"。
样式还包括一个 **parent** 属性,指定样式应基于哪个基础主题。上述代码使用:

这是一个带有深色应用栏的主题,允许应用程序在白天和夜间主题之间过渡。白天时,它使用 values 文件夹中定义的样式资源文件,夜间则切换到 values-night 文件夹中的样式资源文件:
上述样式还包括 <item> 元素,用于覆盖一些主题的颜色。让我们更详细地看看这一点。
样式可以覆盖主题颜色
如果您想要覆盖父主题中的任何属性,例如其颜色方案,可以通过向样式添加 <item> 元素来执行此操作。例如,CatChat 应用程序包括覆盖主要和次要品牌颜色的项目。主要颜色是应用程序的主要颜色,用于例如应用栏。次要颜色提供对比度,并用于某些视图。
您可以使用多个样式资源文件在不同情况下应用不同的颜色方案。例如,CatChat 应用程序在 values 文件夹中包含一个样式资源文件,在 values-night 中包含另一个样式资源文件。这种安排——以及每个样式所基于的 DayNight 主题——允许您在白天和夜间应用不同的颜色方案。
这里是 CatChat 应用程序使用的代码,用于覆盖其 themes.xml 文件中 values 文件夹中的主要颜色:
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
如您所见,每个项目都有一个名称,并使用 @color 来引用颜色资源文件中的颜色。
颜色资源文件定义了一组颜色。
创建新项目时,Android Studio 通常会包含一个名为 colors.xml 的默认颜色资源文件。它位于 app/src/main/res/values 文件夹中,其颜色可在整个应用程序中使用。
典型的颜色资源文件如下所示:

要更改应用程序的颜色方案,只需将所需的颜色添加到颜色资源文件中,并在其样式资源文件中引用它们。
用工具栏替换默认应用栏
到目前为止,您已经看到 Android Studio 通过应用主题向应用程序添加了默认应用栏。以这种方式添加应用栏很容易,但更灵活的方法是用工具栏替换它。
基本的工具栏看起来与您已经看到的默认应用栏非常相似,但它更加灵活。例如,您可以更改其高度,在接下来的章节中,您将学习如何使其在用户滚动设备屏幕时展开或折叠。它还包含所有最新的应用栏功能,因此使用工具栏可以更轻松地将这些功能包含到您的应用中。
要移除默认应用栏,您需要删除原始的应用栏,并在布局中包含一个工具栏,然后告知活动将该工具栏作为其应用栏使用。接下来的几页我们将向您展示如何操作。

使用主题来移除默认的应用栏
通过将不包含应用栏的主题应用到应用程序,您可以移除默认应用栏。
例如,当前版本的 CatChat 应用使用主题 Theme.MaterialComponents.DayNight.DarkActionBar。我们将通过将其替换为 Theme.MaterialComponents.DayNight.NoActionBar 来移除默认应用栏。这两个主题基本外观相同,只是第二个没有应用栏。
现在通过更新 values 和 values-night 文件夹中的 themes.xml 文件中的代码,使其包含如下更改(加粗部分)来更改主题:


这就是我们需要做的一切,移除默认应用栏。接下来,我们将讲解如何添加工具栏。
工具栏是一种视图类型
与默认应用栏不同,工具栏是一种您可以添加到布局中的视图类型。因为它是一个视图,这意味着您可以完全控制其大小和位置。
提供了不同类型的工具栏,在 CatChat 应用中,我们将使用Material 工具栏。这是一种与 Material 主题非常匹配的工具栏,正如我们在这个应用中使用的那种主题。
您可以使用如下代码添加一个 Material 工具栏:

您可以通过以下方式开始定义工具栏:

其中 com.google.android.material.appbar.MaterialToolbar 是 MaterialToolbar 类的完全限定路径。然后,您可以使用其他视图属性为它指定 ID 并定义其外观。例如,要使工具栏与其父视图同宽并与底层主题的默认应用栏一样高,您可以使用:
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
上述代码中的 **?attr** 前缀表示您希望使用当前主题中的属性。在这种情况下,我们使用 ?attr/actionBarSize 来引用主题默认应用栏的高度。
您还可以为工具栏设置使用应用的主色调。这可以通过以下方式实现:

现在您已经了解了工具栏代码的外观,让我们将其添加到 MainActivity 的布局中。
将工具栏添加到 activity_main.xml
我们将在 MainActivity 的布局中添加一个工具栏,使其显示在屏幕顶部。请更新 activity_main.xml 中的代码,以使其与下面的代码匹配:

应用名称去哪里了?
上述代码向MainActivity的布局添加了一个工具栏,但尚未包含任何应用栏功能。如果您此时运行应用程序,例如,您会发现与之前默认应用栏相比,工具栏中没有显示应用程序名称:

要使工具栏表现得像一个合适的应用栏,我们需要对活动的 Kotlin 代码进行一些更改。
将工具栏设置为 MainActivity 的应用栏
要使工具栏表现得像一个合适的应用栏,您需要在活动的 Kotlin 代码中调用**setSupportActionBar()**方法,并像这样传递对工具栏的引用:

我们将把以下代码添加到MainActivity的onCreate()方法中,以便在活动创建后立即运行。更新 MainActivity.kt 的代码,包括下面显示的更改(用粗体显示):

这就是我们需要用工具栏替换应用程序默认应用栏的所有内容。让我们启动应用程序进行测试,并确保它显示正常。
测试驾驶
当我们运行应用时,MainActivity在屏幕顶部显示一个工具栏。工具栏包括应用程序的名称。
现在我们已经用工具栏替换了默认应用栏,让我们为其添加更多功能。

让我们使用工具栏进行导航

在本章开头,我们说过我们希望能够使用 Android 的导航 UI 组件在 CatChat 应用中导航到不同的屏幕。现在我们已经为应用添加了一个工具栏,让我们创建几个屏幕,并使用工具栏在它们之间进行导航。
工具栏导航如何工作
我们将首先创建两个新的片段——InboxFragment和HelpFragment——当用户导航到每个片段时将在MainActivity中显示。应用启动时将显示InboxFragment,导航到HelpFragment时将显示。
注意
我们不会向 InboxFragment 和 HelpFragment 添加任何电子邮件或帮助功能。我们将只在每个片段中显示一段文本,以便知道显示的是哪个片段。
我们将通过向工具栏添加“帮助”项目来导航到HelpFragment,效果如下:

我们将使用菜单添加“帮助”项目;您将在几页之后了解如何操作。
当我们点击“帮助”项目时,应用将导航到HelpFragment。我们还将更新工具栏的标题以指示当前屏幕,并提供“向上”按钮,以便轻松返回InboxFragment。

这就是工具栏导航的工作方式。让我们开始通过向应用添加InboxFragment和HelpFragment来实现它。
创建 InboxFragment
我们希望在应用程序启动时在MainActivity中显示InboxFragment。在app/src/main/java文件夹中突出显示com.hfad.catchat包,然后转到文件→新建→片段→空白片段。将片段命名为InboxFragment,命名其布局为“fragment_inbox”,并确保语言设置为 Kotlin。然后更新InboxFragment.kt的代码以匹配下面的代码:


这是fragment_inbox.xml的代码(也更新此文件的代码):

创建 HelpFragment
当用户在工具栏上单击帮助项时,我们将在MainActivity中显示HelpFragment。在app/src/main/java文件夹中突出显示com.hfad.catchat包,然后转到文件→新建→片段→空白片段。将片段命名为HelpFragment,命名其布局为“fragment_help”,并确保语言设置为 Kotlin。然后确保HelpFragment.kt的代码与下面的代码匹配:


然后确保fragment_help.xml的代码与下面的布局匹配:

我们将使用导航组件导航到 HelpFragment
在第六章中,您学习了如何使用 Android 的导航组件在片段之间导航。即使我们现在将使用工具栏导航到新的目的地,我们仍然可以使用导航组件来满足我们所有的导航需求。
我们首先需要使用 Gradle 将导航组件添加到 CatChat 项目中。
在项目 build.gradle 文件中添加版本号
就像以前一样,您需要向项目的build.gradle文件中添加一个新变量,指定应添加到应用程序中的导航组件的版本。
打开文件CatChat/build.gradle,并在buildscript部分添加以下行(粗体):

在应用程序 build.gradle 文件中添加依赖项
接下来,您需要在应用程序版本的build.gradle文件中添加两个导航组件依赖项。
打开文件CatChat/app/build.gradle,并在dependencies部分添加以下两行(粗体):

完成这些更改后,单击代码编辑器顶部显示的“立即同步”选项,以将更改与项目的其余部分同步。
现在您已经将导航组件添加到项目中,请在以下练习后使用它创建导航图。
将片段添加到导航图中
正如您已经了解的那样,您的应用程序导航图包含应用程序中目的地的详细信息以及导航到它们的可能路径。由于InboxFragment和HelpFragment是 CatChat 应用程序中的可能目的地,我们将创建一个新的导航图,并将这两个片段添加到其中。
要创建导航图,请在项目资源管理器中选择CatChat/app/src/main/res文件夹,然后选择“文件”→“新建”→“Android 资源文件”。在提示时,输入文件名“nav_graph”,选择资源类型“Navigation”,然后点击“确定”按钮。

接下来,打开导航图(文件nav_graph.xml),切换到代码视图,并更新文件,使其与下面的代码匹配:

上面的代码将InboxFragment和HelpFragment添加到导航图中,并为每个片段分配了用户友好的标签。
我们需要对导航图做出的所有更改都在这里了。接下来,让我们更新MainActivity的布局,以便在导航到每个片段时能够显示它。
在activity_main.xml中添加一个导航主机
正如你在第六章中学到的那样,通过将导航主机添加到活动的布局中,你可以显示导航到的每个目的地。你可以使用FragmentContainerView来做到这一点,指定你想要使用的导航主机类型和导航图的名称。
在 CatChat 应用中,执行此操作的代码与我们在第六章中向 Secret Message 应用程序添加的代码几乎相同。更新activity_main.xml的代码以包含下面的更改(用粗体标记出来):


现在我们已经添加了导航主机,让我们向工具栏添加一个项目,用于导航到HelpFragment。
使用菜单资源文件指定工具栏中的项目
通过定义菜单告诉 Android 应该在工具栏上显示哪些项目。每个菜单在一个 XML 菜单资源文件中定义,指定你希望显示的项目。
我们将创建一个名为menu_toolbar.xml的新菜单资源文件,用于将帮助项目添加到工具栏。在项目资源管理器中选择app/src/main/res文件夹,然后选择“文件”菜单,选择“新建”,然后选择创建新的 Android 资源文件。在提示时,将其命名为“menu_toolbar”,指定资源类型为“Menu”,并确保目录名称为menu。单击“确定”按钮后,Android Studio 将为您创建此文件,并将其添加到app/src/main/res/menu文件夹中。
就像导航图和布局文件一样,你可以通过更新 XML 代码或使用内置的设计编辑器来编辑菜单资源文件。设计编辑器的外观如下:

让我们在菜单中添加一个帮助项目
我们希望能够从工具栏导航到HelpFragment,因此我们将在菜单资源文件中添加一个帮助项。我们将直接通过编辑 XML 代码来实现这一点。
切换到menu_toolbar.xml的代码视图,然后更新代码,使其与下面的代码匹配(需要添加的行用粗体标记):

每个菜单资源文件,包括上述文件,都有一个**<menu>**根元素。这是告诉 Android 它定义一个菜单的内容。
在<menu>元素内部,通常有多个<item>元素,每个元素描述一个单独的项目。在这个特定案例中,有一个标题为“Help”的单个帮助项目。
<item>元素具有多个属性,可以用来控制项目的外观。
android:id属性为项目分配一个 ID。此 ID 由导航组件用于导航到目标,它必须与导航图中要导航到的目标具有相同的 ID。您将在接下来的几页中了解其工作原理。
android:icon属性指定应为项目显示哪个图标(如果有)。Android 有许多内置图标,当您开始键入图标名称时,IDE 会向您展示可用的列表。
android:title属性定义项目的文本。
app:showAsAction属性指定您希望项目在工具栏中如何显示。将其设置为“always”意味着它应始终显示在工具栏的主区域中。
onCreateOptionsMenu() 将菜单项添加到工具栏
定义菜单资源文件后,通过在活动代码中实现**onCreateOptionsMenu()**方法将其项目添加到工具栏。此方法在活动准备好向工具栏添加项目时被调用。它会将菜单资源文件填充,并将其描述的每个项目添加到工具栏。
在 CatChat 应用程序中,我们希望将menu_toolbar.xml中定义的项目添加到MainActivity的工具栏。下面以粗体显示的代码将添加此项目(您将在几页后更新您的代码):

这一行:
menuInflater.inflate(R.menu.menu_toolbar, menu)
填充菜单资源文件。在幕后,它创建一个代表菜单资源文件的Menu对象,菜单资源文件包含的任何项目都被转换为MenuItem对象。然后将它们添加到工具栏。
这就是向工具栏添加菜单所需的一切。接下来,我们将使MainActivity在用户单击帮助项目时导航到HelpFragment。

使用 onOptionsItemSelected()响应菜单项点击
一旦使用onCreateOptionsMenu()方法将菜单项添加到工具栏,您可以通过实现**onOptionsItemSelected()**使它们响应点击。每当工具栏中的项目被点击时,此方法都会运行:

在 CatChat 应用程序中,当用户单击帮助菜单项时,我们希望导航到HelpFragment,我们可以通过导航组件来实现这一点。
下面是代码的样子;我们将在几页后将这些更改添加到MainActivity.kt:

每次单击“帮助”菜单项时,导航控制器获取其 ID 并在导航图中查找匹配的 ID,然后将带有此 ID 的目的地传递给导航宿主,以便在设备屏幕上显示。
我们需要配置工具栏
现在我们已经涵盖了将“帮助”菜单项添加到工具栏并使其导航到HelpFragment所需的所有内容。然而,在我们进行应用程序测试之前,还有一个改变我们将要进行。
当我们在本章之前定义导航图nav_graph.xml时,我们使用如下代码为每个目的地添加了标签:

为了清楚地显示正在显示的屏幕,我们将配置工具栏,以便每次导航到新目的地时,在工具栏中显示其标签。我们还将向工具栏添加一个向上按钮,以便用户在导航到HelpFragment时,提供一个快速返回InboxFragment的方法:

向上导航听起来可能与使用设备上的返回按钮相同,但稍有不同。返回按钮允许用户通过回退栈(访问过的屏幕的历史记录)逐步“返回”。另一方面,向上按钮基于导航图层次结构。它提供了快速上移此层次结构的方式。
我们可以使用导航组件配置工具栏以更新文本并包含一个向上按钮。让我们找出如何做到这一点。
使用 AppBarConfiguration 配置工具栏

我们想要配置工具栏,以便它显示对应于导航图中当前目的地标签的文本,并包含一个向上按钮。我们可以通过构建基于导航图的**AppBarConfiguration**对象并将其链接到工具栏来实现这一点。AppBarConfiguration类是导航组件的一部分,用于使应用栏和工具栏与导航控制器良好地协作。
下面是构建AppBarConfiguration并将其链接到工具栏的代码(加粗部分):

以上代码首先从导航宿主中使用以下代码获取导航控制器的引用:

每当您需要从活动的onCreate()方法获取导航控制器的引用时,就需要像这样的代码。
然后,该代码构建了一个将工具栏链接到导航图的配置,并将其应用于工具栏。
当代码运行时,它使用导航图中的信息来显示当前目的地的标签。它还为除导航图的起始目的地之外的所有目的地的工具栏添加一个向上按钮,在这种情况下是InboxFragment。
这就是您实现工具栏导航所需了解的所有内容。我们将在下一页上展示完整的MainActivity代码。
MainActivity.kt 的完整代码
这是MainActivity.kt的完整代码;更新你的代码以包含以下更改(用粗体标出):

应用程序运行时会发生什么
当应用程序运行时会发生以下事情:
-
应用程序启动并创建 MainActivity。
InboxFragment被添加到导航主机并显示在设备屏幕上。![image]()
-
MainActivity 的 onCreateOptionsMenu 方法运行。
它将在工具栏中添加在menu_toolbar.xml中定义的帮助菜单项。
![image]()
-
用户在工具栏中点击帮助项。
![image]()
-
MainActivity 的 onOptionsItemSelected()方法运行。
它将帮助项的导航传递给导航控制器。
![image]()
-
导航控制器在导航图中查找帮助项的 ID。
![image]()
-
导航控制器将 InboxFragment 替换为 HelpFragment 在导航主机中。
![image]()
测试驾驶

当我们运行 CatChat 应用程序时,MainActivity被启动,并且InboxFragment显示在MainActivity的布局中。
在MainActivity的工具栏中显示一个帮助项。当我们点击它时,应用程序导航到HelpFragment。工具栏文本更改为“帮助”,并出现一个向上按钮。
当我们点击“向上”按钮时,应用程序导航到InboxFragment。向上按钮消失,工具栏显示“收件箱”文本。

干得好!你现在已经学会了如何添加工具栏并将其用于导航。
扮演菜单

下面显示了一个菜单资源文件和导航图。菜单添加到工具栏。你的任务是扮演菜单,说出每个菜单项被点击时导航到哪个片段。

扮演菜单解决方案

下面显示了一个菜单资源文件和导航图。菜单添加到工具栏。你的任务是扮演菜单,说出每个菜单项被点击时导航到哪个片段。

大多数类型的 UI 导航与导航组件一起工作

到目前为止,你已经学会了如何通过定义菜单资源文件启用工具栏导航,使用导航组件在片段之间导航,并配置工具栏以在应用程序导航时更改外观。
令人振奋的消息是,其他类型的 UI 导航,如底部导航栏和导航抽屉,工作方式类似。即使它们看起来彼此不同,你也可以将学到的工具栏导航技巧应用到其他类型的导航上。
为了看看这是如何工作的,我们将向 CatChat 应用程序的MainActivity添加一个底部导航栏。
底部导航栏最多可以容纳五个项目。它显示在屏幕底部。
底部导航栏的工作原理
顾名思义,底部导航栏是一种位于设备屏幕底部的导航栏类型。您可以使用它导航到最多五个目的地。
CatChat 应用程序的底部导航栏将如下所示:

正如您所见,该栏包含三个项目:收件箱、已发送项目和帮助。当我们点击每个项目时,应用程序将导航到与其关联的片段。例如,当我们点击帮助项目时,应用程序将导航到HelpFragment,当我们点击已发送项目时,它将导航到一个新的片段(我们需要创建的)名为SentItemsFragment。
我们将在接下来的几页中实现底部导航栏导航,并且我们将从创建新的片段SentItemsFragment开始。
创建 SentItemsFragment
我们需要创建一个名为SentItemsFragment的新片段。
在app/src/main/java文件夹中突出显示com.hfad.catchat包,然后转到文件→新建→Fragment→Fragment(空白)。将片段命名为“SentItemsFragment”,命名其布局为“fragment_sent_items”,并确保语言设置为 Kotlin。然后更新SentItemsFragment.kt的代码以匹配下面的代码:


这是fragment_sent_items.xml的代码(也更新您的代码版本):

将 SentItemsFragment 添加到导航图中
我们希望能够通过导航组件从底部导航栏导航到SentItemsFragment。为此,我们需要将该片段作为新目标添加到导航图中。
打开导航图文件nav_graph.xml(如果尚未打开),然后更新其代码,使其与下面的代码匹配(我们的更改已加粗):

正如您所见,新目的地的 ID 为sentItemsFragment,标签为“已发送项目”。我们将在接下来创建的底部导航栏菜单资源文件中使用此 ID。
底部导航栏需要一个新的菜单资源文件
在本章的早些时候,我们创建了一个名为menu_toolbar.xml的菜单资源文件,以在工具栏中添加帮助项。虽然不同的导航 UI 组件可以共享相同的菜单,但我们需要为底部导航栏创建一个新的菜单,因为它显示了两个额外的项目:收件箱和已发送项目。

要创建新的菜单资源文件,请选择app/src/main/res文件夹,转到文件菜单,选择新建,然后选择创建新的 Android 资源文件。在提示时,将其命名为“menu_main”,指定资源类型为“Menu”,并确保目录名称为menu。单击“确定”按钮后,Android Studio 将为您创建该文件,并将其添加到app/src/main/res/menu文件夹中。
接下来,打开menu_main.xml文件(如果还没有打开),并更新其代码以为InboxFragment、SentItemsFragment和HelpFragment添加菜单项(如下所示,用粗体标出):

现在我们已经创建了新的菜单资源文件,让我们向MainActivity添加一个底部导航栏。
底部导航栏是一种视图类型
就像工具栏一样,底部导航栏是一种你可以添加到布局中的视图类型。添加底部导航栏的代码如下所示:

你首先通过以下方式定义底部导航栏:
<com.google.android.material.bottomnavigation.BottomNavigationView
... />
这里com.google.android.material.bottomnavigation.BottomNavigationView是BottomNavigationView类的完全限定路径:定义该导航栏的类。然后使用额外的属性为它指定一个 ID,并指定其外观。
不像工具栏,你不需要编写 Kotlin 代码来将菜单项添加到底部导航栏。你只需使用 app:menu 属性指定要添加到导航栏的菜单资源文件,像这样:
app:menu="@menu/menu_main"
上述代码将menu_main.xml菜单资源文件附加到底部导航栏,并在运行时将其项目添加到栏中,如下所示:

现在你已经看到了底部导航栏的代码,让我们将其添加到MainActivity的布局中。
activity_main.xml的完整代码
这里是activity_main.xml的完整代码:更新你的代码以包含以下更改(用粗体标出):

现在我们已经将底部导航栏添加到MainActivity的布局中,让我们使其在不同目的地之间导航。
将底部导航栏链接到导航控制器
使底部导航栏在目的地之间导航的代码比实现工具栏导航所需的代码简单得多。你只需获取指定导航栏的BottomNavigationView引用,并调用其setupWithNavController()方法即可。
代码如下所示:

每次点击导航栏中的项目时,导航控制器都会获取其 ID,并在导航图中查找具有匹配 ID 的目的地。然后将此目的地传递给导航主机,以便在设备屏幕上显示它。

让我们将底部导航栏的代码添加到MainActivity.kt中,并测试这个应用程序。
MainActivity.kt的更新代码
这是MainActivity.kt的更新代码:更新代码以包含以下更改(用粗体标出):

让我们测试这个应用程序。
测试驾驶
当我们运行 CatChat 应用程序时,MainActivity被启动,并在MainActivity的布局中显示InboxFragment。显示一个包含三个项目的底部导航栏。
当我们点击底部导航栏中的每个项目时,应用程序会导航到相应的屏幕。例如,当我们点击 Sent Items 时,在MainActivity中会显示SentItemsFragment,当我们点击 Help 项目时,将显示HelpFragment。

底部导航栏正按照我们的期望工作。
导航抽屉可以让您显示许多导航项目

正如您所了解的,如果您有少量导航项目,底部导航栏是一个不错的选择,因为它允许您显示最多五个这样的项目。但是如果您有更多的菜单项目呢?
如果您希望用户能够浏览大量选项,一个更好的选择可能是导航抽屉。这是一个可滑动的面板,包含指向应用程序其他部分的链接,您可以选择将其分组到不同的部分中。
导航抽屉在 Android 应用程序中被广泛使用。例如,Gmail 应用程序使用一个导航抽屉,让您可以导航到应用程序中的不同屏幕,并包含诸如电子邮件类别、最近标签和所有标签等部分:

让我们用导航抽屉替换底部导航栏
我们将用导航抽屉替换 CatChat 应用程序中添加的底部导航栏。导航抽屉将包含一个标题图像和一组选项。主要选项将允许用户导航到InboxFragment和SentItemsFragment,我们将在一个名为 Support 的单独部分中放置一个HelpFragment项目。抽屉将如下所示:

导航抽屉由几个不同的组件组成。我们将在下一页详细介绍这些组件。
导航抽屉解构
通过将抽屉布局添加到活动布局的根部来实现导航抽屉。抽屉布局包含两个视图:
-
用于屏幕主要内容的视图
这通常是一个包含工具栏和导航主机的布局,用于显示片段。
-
用于抽屉内容的导航视图
导航视图是一种用于显示导航菜单的帧布局类型。在这个应用中,它还会显示一个抽屉标题。
当抽屉关闭时,抽屉布局看起来就像一个普通的活动,只是工具栏包含一个用于打开抽屉的抽屉图标:

当您打开抽屉时,导航视图会滑动到活动的主要内容上,以显示抽屉的内容。当您点击一个项目时,它会使用导航组件来显示相关的目的地,然后抽屉会关闭:

抽屉从菜单中获取其项目
就像工具栏和底部导航栏一样,导航抽屉从菜单资源文件中获取需要显示的项目。
不要为抽屉创建新的菜单资源文件,我们将重用menu_main.xml:这个文件我们之前用于底部导航栏。这里是menu_main.xml中当前代码的提醒:

将上述菜单资源文件添加到导航抽屉中,会生成一个带有每个项目图标的项目列表。我们将调整菜单以在用户选择的项目上添加额外的突出显示,并将菜单分成几个部分:

让我们找出如何做到这一点。
添加支持部分…
我们将首先添加支持标题,通过定义一个新项目来完成。因为它只是一个标题,我们只需要给它一个标题即可:它没有图标,也不需要 ID,因为我们不会用它导航到任何地方。
这是添加支持标题的代码:

…作为独立的子菜单
我们希望帮助项目出现在支持标题下,形成一个单独的部分。为此,我们将在支持项目内定义一个子菜单,由<menu>元素指定。我们将在这个子菜单中添加帮助项目。
这是添加子菜单的代码,其中包含一个帮助项目;我们将在稍后的几页更新menu_main.xml的代码:


上述子菜单只包含一个项目。如果您希望它包含多个项目,只需在子菜单中包含每个项目即可。
这就是我们需要添加支持部分到抽屉菜单的全部代码。接下来,让我们看看如何在用户选择的项目上添加额外的突出显示。
使用组突出显示选定的项目
当前的菜单会生成一个导航抽屉,会改变当前选定项目的文本颜色。您可以通过添加额外的突出显示来更清楚地告知用户哪个项目已被选中:

通过使用**<group>**元素将项目组合到一起,您可以添加额外的突出显示。然后,您可以使用名为android:checkableBehavior的属性来指定项目被选中时组的行为。
这是执行此操作的代码(我们将在下一页更新menu_main.xml):

如您所见,上述代码将android:checkableBehavior属性设置为"single"。这个选项意味着在组中一次只有一个项目会被突出显示——用户选择的选项。
这是我们使菜单在导航抽屉中使用时看起来和行为符合我们期望的所有代码。接下来的页面上我们会展示完整的代码。
menu_main.xml 的完整代码
我们将更新导航抽屉的菜单,以便在支持部分显示帮助菜单。我们还将使用组在用户选择的项目上添加额外的突出显示。
这是menu_main.xml的完整代码;更新其代码以包括所有更改(加粗部分):

菜单已整理好。接下来,我们将创建导航抽屉的页眉。
创建导航抽屉的头部
导航抽屉的头部是一个简单的布局,我们将其添加到名为 nav_header.xml 的新布局文件中。
在 Android Studio 中选择 app/src/main/res/layout 文件夹,通过 File→New→Layout Resource File 创建此文件。当提示时,命名布局为“nav_header”,如果需要,选择布局资源类型。
添加图片文件…
布局由一个单独的图片组成,需要将其添加到 app/src/main/res/drawable 文件夹中。当您创建项目时,Android Studio 可能已经为您创建了此文件夹。如果没有,请通过选择 app/src/main/res 文件夹,转到文件菜单,选择新建选项,然后单击创建新的 Android 资源目录选项来创建它。在提示时,选择资源类型为 Drawable,命名为“drawable”,然后单击确定。

创建完 drawable 文件夹后,从 tinyurl.com/hfad3 下载 kitten_small.webp 文件,并将其添加到 drawable 文件夹中。
…并更新 nav_header.xml 代码
我们将使用 <ImageView> 元素将图片添加到 nav_header.xml 中。您已经知道如何使用此元素,因此请更新 nav_header.xml 的代码以匹配以下代码:

现在我们有了导航抽屉的头部,让我们添加抽屉本身。
如何创建导航抽屉
要创建导航抽屉,请将 **DrawerLayout** 添加到活动的布局中作为根元素。 DrawerLayout 需要包含两个内容:作为其第一个元素的活动内容的视图或视图组,以及作为其第二个元素定义抽屉内容的导航视图。
典型的 DrawerLayout 代码如下:

有两个关键的 <NavigationView> 属性可用于控制抽屉的外观:**app:headerLayout** 和 **app:menu**。
app:headerLayout 属性指定应用于导航抽屉头部的布局(在此例中是 nav_header.xml)。此属性是可选的。
app:menu 属性指定了包含抽屉选项的菜单资源文件(在这种情况下是 menu_main.xml)。如果不包括此属性,您的导航抽屉将不包含任何项目。
activity_main.xml 的完整代码
我们希望用导航抽屉替换 MainActivity 的底部导航栏。以下是执行此操作的代码;更新文件 activity_main.xml 来包含以下更改(用粗体表示):


我们已将导航抽屉添加到布局中。
我们现在用导航抽屉替换了MainActivity布局中的底部导航栏。它在头部显示了一张图片,并包括了 menu_main.xml 中指定的所有项目。
然而,在运行应用程序之前,我们需要将导航抽屉链接到导航控制器,以便单击项目时导航到正确的片段。

我们还需要配置工具栏,以便包括一个抽屉图标,当点击时,将打开或关闭导航抽屉:

通过调整MainActivity.kt中的代码来进行这两个更改。让我们看看如何做到这一点。
配置工具栏的抽屉图标…
在本章早些时候,我们配置了工具栏以显示当前目标的标签并包括一个向上按钮。我们通过构建一个AppBarConfiguration对象,并将其链接到工具栏来实现这一点。
我们现在希望工具栏包括一个抽屉图标,我们可以通过将导航抽屉添加到AppBarConfiguration来添加一个。这里是实现这一目标的代码:

上述代码将抽屉布局添加到AppBarConfiguration对象中。这允许工具栏与抽屉进行交互,通过在没有向上按钮的每个屏幕上包括抽屉图标。
…并链接抽屉到导航控制器
最后,我们需要让抽屉在用户每次点击其项目时导航到正确的目标。就像底部导航栏一样,我们通过使用导航控制器设置导航抽屉来完成这个任务。
这里是实现这一目标的代码:
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
val navView = findViewById<NavigationView>(R.id.nav_view)
NavigationUI.setupWithNavController(navView, navController)

每当用户在导航抽屉中点击项目时,导航控制器会从菜单资源文件中获取其 ID,并在导航图中查找匹配的 ID,然后导航到具有该 ID 的目标。
这就是控制导航抽屉行为所需的所有代码。让我们将其添加到MainActivity.kt中,并进行应用程序测试。
MainActivity.kt 的完整代码
我们需要更新MainActivity以用导航抽屉的代码替换底部导航代码。使用以下更改更新MainActivity.kt(用粗体标记):


这就是我们为导航抽屉所需的所有代码。让我们来测试一下。
测试驾驶

当我们运行应用程序时,工具栏中会显示一个抽屉图标。单击此图标会打开导航抽屉。当我们在抽屉中点击其中一个项目时,该选项的片段会显示在MainActivity中,并且抽屉关闭。

恭喜!现在您已经学会了如何创建一个完全可操作的导航抽屉。
布局磁铁

某人使用冰箱磁铁编写了导航抽屉的布局代码,但是一些磁铁在夜间掉落了。您能重新组合代码吗?
活动使用线性布局作为其主要内容(我们略去了大部分代码)。抽屉需要显示一个头部,定义在名为 header.xml 的布局文件中。其菜单项在名为 menu_drawer.xml 的菜单资源文件中指定。

布局磁铁解决方案

有人用冰箱磁铁编写了导航抽屉的布局代码,但是有些磁铁在夜间掉了下来。你能把代码重新整理好吗?
活动使用线性布局作为其主要内容(我们略去了大部分代码)。抽屉需要显示一个头部,定义在名为 header.xml 的布局文件中。其菜单项在名为 menu_drawer.xml 的菜单资源文件中指定。

你的 Android 工具箱

你已经掌握了 第八章 并且现在你已经将导航 UI 添加到你的工具箱中。

第九章:材料视图:一个物质世界

大多数应用程序需要一个响应用户操作的流畅用户界面。
到目前为止,您已经学会了如何使用文本视图、按钮和下拉框,并使用 Material 主题来对应用程序的外观和感觉进行全面更改。但是,您可以做的远不止这些。在这里,您将学习如何通过协调布局使您的用户界面更具响应性。您将创建可以随意折叠或滚动的工具栏。您将发现新的令人兴奋的视图,如复选框、单选按钮、标签和浮动操作按钮。最后,您将了解如何使用吐司和 Snackbar 显示友好的弹出消息。继续阅读以了解更多信息。
Material 在整个 Androidville 中被广泛使用。
在前一章中,您学会了如何使用工具栏、底部导航栏和导航抽屉来帮助用户导航您的应用程序,并使用 Material 库中的主题对它们进行了样式化。正如您所知,Material 是一个设计系统,帮助您在所有屏幕上构建具有一致外观和感觉的应用程序。
Material 不仅限于工具栏、导航抽屉和底部导航栏;它还为应用程序中的每个视图(从按钮到文本视图)提供了样式。
这里还有一些使用 Material 的组件和功能的示例:
-
滚动和折叠工具栏用户滚动内容时,您可以使工具栏滚动到屏幕外,或者收起。
![图片]()
-
单选按钮、复选框和标签这些可以让用户选择选项。
![图片]()
-
浮动操作按钮(FAB)FAB(Floating Action Button)是一种特殊的按钮,浮在主屏幕上方。
![图片]()
-
Snackbar(消息栏)这些是您可以与之交互的弹出消息。
![图片]()
我们将向您展示如何通过构建一个新应用程序来使用这些视图和功能。
Bits and Pizzas 应用程序
我们将要构建一个名为“Bits and Pizzas”的新应用程序。我们将专注于其“创建订单”屏幕,用户可以在此屏幕上下单订购披萨。
屏幕看起来像这样:

该应用程序由一个名为MainActivity的活动组成,显示一个名为OrderFragment的片段。该片段定义了“创建订单”屏幕的外观和功能。
让我们逐步了解构建该应用程序所需的步骤。
这是我们将要做的事情
我们将通过以下步骤构建该应用程序:
-
添加一个可以滚动的工具栏。
我们将创建
OrderFragment,并在其布局中添加一个工具栏,当用户向上滚动屏幕时,工具栏将滚动到屏幕外,并在向下滚动时重新出现。![图片]()
-
实现可折叠的工具栏。
完成工具栏的滚动后,我们将在其上添加一个图像,并使其在用户滚动屏幕时收缩和展开。
![图片]()
-
添加视图。
用户需要能够下订单。我们将通过向
OrderFragment的布局中添加单选按钮、芯片和浮动操作按钮来实现这一点。![image]()
-
使 FAB 响应点击。
当用户点击 FAB 时,我们将显示一个弹出消息,提供订单的详细信息。
![image]()
创建 Bits and Pizzas 项目

我们将为 Bits and Pizzas 应用创建一个新项目,您需要使用前几章相同的步骤创建一个。选择“空活动”选项,输入名称“Bits and Pizzas”和包名称“com.hfad.bitsandpizzas”,并接受默认保存位置。确保语言设置为 Kotlin,最小 SDK 为 API 21,以便在大多数 Android 设备上运行。
向应用的 build.gradle 文件添加 Material 库依赖项
在本章中,我们将使用 Material 库中的主题、视图和特性,因此需要确保应用的build.gradle文件包含它作为依赖项。
打开文件BitsandPizzas/app/build.gradle,确保其dependencies部分包含以下行(用粗体标出):

Android Studio 可能已经为您添加了此依赖项到文件中。如果没有,您需要自己添加,并点击代码编辑器顶部出现的“立即同步”选项以将更改与项目的其余部分同步。
现在您已经确保应用包含 Material 库,让我们去创建OrderFragment。
创建 OrderFragment
OrderFragment是 Bits and Pizzas 应用的主屏幕,用户将用它来下订单。
要创建片段,请在app/src/main/java文件夹中突出显示com.hfad.bitsandpizzas包,然后转到 文件→新建→片段→片段(空白)。将片段命名为“OrderFragment”,其布局命名为“fragment_order”,并确保语言设置为 Kotlin。然后更新OrderFragment.kt的代码以匹配下面的代码:

然后更新其布局文件fragment_order.xml的代码,使其包含如下的帧布局:

我们将在本章的其余部分更新OrderFragment。首先,让我们在MainActivity的布局中显示它。
在 MainActivity 的布局中显示 OrderFragment
我们将使用FragmentContainerView将OrderFragment添加到MainActivity的布局中,指定片段的名称。
这是要实现此功能的代码;打开activity_main.xml并更新其代码以包含以下更改:

最后,打开MainActivity.kt,确保其代码与下面显示的代码匹配:

现在我们已经让MainActivity显示OrderFragment,让我们找出如何使应用栏响应滚动。
用工具栏替换默认应用栏
我们将使“Bits and Pizzas”应用栏在用户滚动时做出响应。暂时,当用户向上滚动屏幕时,我们将其滚动出屏幕,并在用户向下滚动时重新显示。

要做到这一点,我们首先需要用工具栏替换默认的应用栏。这是因为默认的应用栏固定在屏幕顶部,无法滚动。而工具栏则灵活得多。
要做到这一点,我们首先需要将应用的主题更改为没有应用栏的主题。打开位于app/src/main/res/values文件夹中的themes.xml文件,并更新其代码以包含以下粗体样式:

如果您的项目在values-night文件夹中包含一个themes.xml文件,您也需要对该文件应用上述更改。
一旦完成此更改,请通过更新fragment_order.xml代码将工具栏添加到FragmentOrder。

片段没有setSupportActionBar()方法
现在我们已经添加了工具栏,我们需要使其表现得像一个正常的应用栏,显示应用的名称。如前一章所述,可以通过调用活动的setSupportActionBar()方法来实现这一点。
在这里,我们已经向片段添加了工具栏,但片段不包括**setSupportActionBar()** 方法。为了解决这个问题,我们将获取显示片段的活动的引用(使用**activity**),将其强制转换为AppCompatActivity以反映其类型,并调用其setSupportActionBar()方法。
这是完成此操作的代码:

下面是OrderFragment.kt的完整代码;请更新您的代码以包含以下更改(用粗体标出):

我们已经向片段添加了工具栏…接下来怎么做?
现在我们已经向OrderFragment的布局中添加了一个工具栏,因此当应用运行时,工具栏将显示在屏幕顶部:

但是,如果我们尝试滚动屏幕,工具栏不会移动。为了使其响应滚动,我们需要进行一些额外的更改。
我们希望工具栏可以响应滚动
要使工具栏移动,我们需要在片段布局中添加更多视图。布局需要遵循以下结构:

布局需要包括三个组件:一个协调布局,一个应用栏布局和一个嵌套滚动视图。它们共同使得工具栏可以在用户滚动屏幕时响应。
让我们从协调布局开始了解每个组件的功能。
协调布局用于协调不同视图之间的动画
协调布局就像是一个增强版的帧布局,用于协调不同视图之间的动画。例如,它可以协调用户滚动布局的主内容与工具栏移出屏幕的动作。
您可以像这样向布局代码中添加协调布局:

你需要在协调布局中包含任何你想要协调动画的视图。例如,在 Bits 和 Pizzas 应用中,我们想要协调两件事情:用户滚动布局的主要内容,以及工具栏滚动到屏幕外。这意味着工具栏和屏幕的主要内容需要包含在协调布局中:

CoordinatorLayout 允许一个视图的行为影响另一个视图的行为。
CoordinatorLayout 通常是布局的根元素。
现在我们知道协调布局的作用是什么,让我们继续看看应用栏布局。
应用栏布局使工具栏动画化
应用栏布局是一种设计用于与应用栏一起工作的垂直线性布局类型。它与协调布局一起工作,实现工具栏动画。
你可以像这样在代码中添加一个应用栏布局:

正如你所看到的,上面的应用栏布局代码包括一个像这样的android:theme属性:
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
这将应用指定的样式到应用栏布局及其所有视图。在这个例子中,这意味着工具栏——以及我们添加到应用栏布局的任何其他内容——将使用 Material 主题的应用栏属性进行样式设置,包括其背景和文本的颜色。
太棒了!现在你的工具栏是动画的,并且可以响应滚动事件。但这还不是故事的结束:你还需要指定它应该如何响应。让我们看看如何做到这一点。
告诉工具栏如何响应滚动事件
一旦你添加了一个应用栏布局,你可以通过向工具栏添加一个app:layout_scrollFlags属性并为其分配一个值来告诉工具栏如何响应滚动。该值指定工具栏应如何响应滚动事件。
在 Bits 和 Pizzas 应用中,当用户向上滚动时,我们希望工具栏向上滚动到屏幕外,并在用户向下滚动时快速返回到原始位置。我们可以通过将工具栏的app:layout_scrollFlags属性设置为"scroll|enterAlways"来实现这一点,代码如下:
工具栏必须位于应用栏布局内才能滚动。应用栏布局和协调布局共同工作,实现滚动。

这行代码:
app:layout_scrollFlags="scroll|enterAlways"
指定了两种行为:scroll和enterAlways。
scroll值表示当用户向上滚动时,视图可以从屏幕顶部滚动出去。如果没有这个值,工具栏将保持固定在屏幕顶部,无法滚动。
enterAlways值表示当用户向下滚动时,工具栏会快速滚动回原始位置。没有这个值,工具栏仍然会向下滚动,但速度会慢得多。
你已经快完成了!在我们能让OrderFragment工具栏滚动之前,你只需要做最后一步:嵌套滚动视图。
嵌套滚动视图使布局内容可滚动
现在,你需要添加一个嵌套滚动视图,以便布局的主内容可以滚动。这种视图的工作方式就像普通的滚动视图,但它支持嵌套滚动。这很重要,因为协调布局只监听嵌套滚动事件。如果在你的布局中使用普通的滚动视图,工具栏将无法在用户滚动屏幕时响应。
注意
另一个支持嵌套滚动的视图是 RecyclerView。你将在第十四章学习到关于这个视图的内容。
你可以像这样使用代码向布局中添加一个嵌套滚动视图:

在上述示例中,嵌套滚动视图包含了一个额外的属性,名为app:layout_behavior,设置为内置的字符串值"@string/appbar_scrolling_view_behavior"。这确保了嵌套滚动视图的内容在应用栏布局下方排列,并且随着滚动而移动。
注意嵌套滚动视图只能有一个直接子元素,在上面的示例中是一个文本视图。如果你想要将多个视图添加到嵌套滚动视图中,你必须首先将它们添加到一个视图组中,比如线性布局,然后再将视图组添加到嵌套滚动视图中。
注意
你将在本章的后面看到一个例子。
这就是我们需要知道的关于如何使 Bits 和 Pizzas 工具栏滚动的全部内容,所以让我们继续更新OrderFragment的布局。
fragment_order.xml 的完整代码
下面是fragment_order.xml的完整代码;请更新代码以包含以下更改(用粗体标出):

测试驾驶

当我们运行应用时,OrderFragment显示在MainActivity的布局中。屏幕顶部显示了一个工具栏。
当我们向上滚动屏幕时,工具栏向上滚动并离开屏幕顶部。

当我们向下滚动主内容时,工具栏重新出现。

恭喜!你现在已经学会了如何创建一个能够响应滚动的工具栏。
在下面的练习之后,我们将学习如何将滚动工具栏转换为随着用户滚动屏幕而展开和折叠的工具栏。
BE the Layout

以下是名为 MyFragment 的片段的布局文件。你的任务是像是布局一样,并更改代码,使得当用户向上滚动屏幕时,工具栏从屏幕上滚动出去,当他们向下滚动时,它迅速重新出现。

BE the Layout Solution

以下是名为 MyFragment 的片段的布局文件。你的任务是像是布局一样,并更改代码,使得当用户向上滚动屏幕时,工具栏从屏幕上滚动出去,当他们向下滚动时,它迅速重新出现。

让我们创建一个折叠式工具栏

现在你知道如何使工具栏滚动到屏幕之外后,让我们用一个稍微不同的工具栏替换它:一个可折叠工具栏。
可折叠工具栏是一种工具栏,初始时较大,在用户向上滚动屏幕时会收缩,并在用户向下滚动屏幕时再次扩展。你甚至可以向其添加一个图像,当工具栏达到最小高度时图像消失,并在工具栏扩展时再次可见:

接下来几页,我们将学习如何通过在OrderFragment的布局中添加一个简单的工具栏来将普通工具栏转换为可折叠工具栏。我们将首先创建一个简单的可折叠工具栏,然后创建一个包含图像的工具栏。
让我们开始吧。
如何创建简单的可折叠工具栏
将滚动工具栏转换为可折叠工具栏相对简单。你只需将工具栏包裹在可折叠工具栏布局中,并调整工具栏的属性。基本的代码结构如下:

如你所见,可折叠工具栏布局使用<...CollapsingToolbarLayout>元素定义,它是com.google.android.material库的一部分。你可以使用其layout_height属性指定其最大高度,而以下这行:

告诉它在用户向上滚动时折叠,直到没有更多内容可折叠,当用户向下滚动时扩展至其全高度。
我们还需要确保当工具栏折叠时,显示在工具栏上的任何内容,如返回按钮和任何菜单项都保持在屏幕上显示。通过向Toolbar元素添加以下属性实现:

如何将图像添加到可折叠工具栏
创建简单的可折叠工具栏后,你可以通过向可折叠工具栏布局添加一个<ImageView>,指定要使用的图像来添加图像。代码遵循以下结构:

下面这行:
app:contentScrim="?attr/colorPrimary"
添加到<...CollapsingToolbarLayout>的这一行使工具栏在折叠时具有纯色背景。我们还使用以下代码为图像添加了视差动画:
app:layout_collapseMode="parallax"
此属性是可选的:它使图像的滚动速度与工具栏的其余部分不同步。
现在你已经了解了可折叠工具栏,让我们将一个添加到OrderFragment的布局中。
添加餐厅图像资源
我们希望OrderFragment的可折叠工具栏包含一个餐厅图像,因此让我们首先将其添加到项目中。
确保你的项目中包含名为app/src/main/res/drawable的文件夹,然后从tinyurl.com/hfad3下载restaurant.webp文件,并将其添加到drawable文件夹中。这将把图像作为可绘制资源添加到你的项目中。
接下来,让我们添加可折叠工具栏。

fragment_order.xml 的完整代码
下面的代码将折叠工具栏添加到OrderFragment的布局中。更新你的fragment_order.xml代码以包含以下更改(加粗部分):


这些是我们需要创建折叠工具栏的所有更改。让我们来测试这个应用程序,看看它的样子。
测试驾驶

当我们运行应用程序时,显示OrderFragment。它包括一个带有图像的折叠工具栏。
当我们向上滚动时,工具栏会折叠,图像会淡化,工具栏的背景会变成应用程序的主要颜色。当我们向下滚动时,工具栏会展开,图像会重新出现。

布局磁铁

有人在冰箱门上摆放了一些磁铁,以展示如何结构化实现带有图像的折叠工具栏的布局文件。不幸的是,当一只大翼龙飞过寻找食物时,磁铁掉了下来。
看看你能不能把磁铁按正确的顺序放回去。

答案在“Layout Magnets Solution”中。
我们需要构建 OrderFragment 的主要内容
现在我们已经在OrderFragment的布局中添加了一个折叠工具栏,我们需要添加一些更多的视图。这些将允许用户选择她想要订购的比萨类型,添加任何额外的选项,如帕尔马干酪或辣椒油,并在她点击按钮时显示一条消息。屏幕需要看起来像这样:

正如你所看到的,OrderFragment的布局包括一些我们还没有学会如何使用的额外视图。在构建布局之前,让我们更多地了解这些视图。
使用单选按钮选择比萨类型

我们将使用的第一个视图是一组单选按钮,以便用户可以选择她想要的比萨类型。单选按钮允许您从多个选项中进行选择,因此它们是这种情况下的一个很好的选择。
你可以使用两个元素将单选按钮添加到布局中:<RadioButton>和<RadioGroup>。使用<RadioButton>元素定义每个单选按钮,通过将它们放置在<RadioGroup>元素内进行分组。以这种方式在单选按钮中放置单选按钮意味着一次只能选择一个单选按钮。

在 Bits and Pizzas 应用程序中,我们希望为 Diavolo 和 Funghi 按钮显示单选按钮。代码如下所示:

一旦你定义了单选按钮组和单选按钮,你可以编写 Kotlin 代码来查找哪个单选按钮已被选中,使用单选按钮组的checkedRadioButtonId属性。其值是所选单选按钮的 ID,如果没有选中单选按钮则为-1:

RadioGroup 是 LinearLayout 的子类,因此您可以像线性布局一样使用相同的属性与单选组。
单选按钮是一种复合按钮
在幕后,单选按钮继承自名为 CompoundButton 的类:Button 的子类。复合按钮是具有两种状态的按钮:选中和未选中,或者开和关。
Android 包含其他类型的复合按钮(除了单选按钮),例如复选框、开关和切换按钮。如果您希望向用户提供是/否选择,比如“是否要辣椒油?”或“您是否想要额外的帕尔玛干酪?”,这些视图就非常有用。

使用以下代码向您的布局中添加复选框、开关和切换按钮:

然后,您可以在 Kotlin 代码中使用每个视图的 isChecked 属性来查找它是否已被选中,就像这里的示例代码一样:

芯片是一种灵活的复合按钮类型
到目前为止,您已经了解了如何使用不同类型的复合按钮,例如单选按钮、复选框和开关。更灵活的一种复合按钮是芯片。这是一种材料视图,在使用来自 Material 库的主题时可用,例如 Theme.MaterialComponents.DayNight.NoActionBar。它用于像其他类型的复合按钮一样进行是/否选择,但它还有其他用途:还可以用于用户输入、数据过滤和执行操作。

在 Bits and Pizzas 应用程序中,我们将使用芯片来让用户选择是否想要在她的比萨上加入额外的帕尔玛干酪或辣椒油。
使用以下代码将芯片添加到您的布局中:

芯片代码的关键部分是 style 属性,因为它控制芯片的外观。代码:
style="@style/Widget.MaterialComponents.Chip.Choice"
在上面的示例中,样式将芯片设为选择项,因此在选择时其颜色会发生变化。其他选项包括输入(允许您使用芯片进行数据输入)、过滤器(用于过滤内容的芯片)和操作(类似按钮的芯片)。
下面是输入、过滤器和操作芯片的样子:

除了将单个芯片添加到布局中,您还可以将多个芯片组合在一起。让我们看看如何做到这一点。
将多个芯片添加到芯片组中

如果您希望布局包含多个分组在一起的芯片,您可以将它们添加到芯片组中。芯片组是一种类型的视图组,专门设计用于整齐地排列多个芯片。
在 Bits and Pizzas 应用程序中,我们希望使用两个芯片:一个用于帕尔玛干酪,另一个用于辣椒油。因此,我们将它们组合在一个芯片组中,使用以下代码:

使用 isChecked 来查找芯片是否被选中
一旦您向布局添加了标签,就可以使用每个标签的isChecked属性查找是否已选择标签,就像您可以使用其他类型的复合按钮(如开关、切换按钮和复选框)一样。例如,以下代码检查是否已选择parmesan标签:
val parmesan = view.findViewById<Chip>(R.id.parmesan)
if (parmesan.isChecked) {
//do something
}
FAB 是一个浮动操作按钮
在完成OrderFragment的布局之前,我们需要了解一个FAB。
FAB 或浮动操作按钮是一个在用户界面上方漂浮的圆形按钮。它用于吸引注意力以执行常见或重要操作,就像普通按钮一样,您可以通过在 Kotlin 代码中为其分配OnClickListener来使 FAB 响应点击。

您可以使用以下代码向布局中添加一个 FAB:

上面的代码使用layout_gravity属性将 FAB 锚定在设备屏幕的底端角,间距为 16dp。
这行代码:
android:src="@android:drawable/ic_menu_send"
向 FAB 添加图标。在上面的示例中,它显示了一个名为ic_menu_send的 Android 内置图标,但只要它适合 FAB,您可以使用任何类型的可绘制对象。
注意
当您向 FAB 添加 src 属性时,代码编辑器会让您浏览 Android 的内置图标。如果您不喜欢任何图标,您可以在这里找到更多:material.io/resources/icons
您通常在协调布局中使用 FAB,以便您可以协调布局中不同视图之间的移动。让我们看一个例子。
您可以将 FAB 锚定到折叠工具栏
FAB 通常位于屏幕底端角,但您还可以将其锚定到另一个视图,如折叠工具栏。这样做时,FAB 随着折叠工具栏的展开和折叠而移动:

下面显示了此布局代码。正如您所见,它使用了 FAB 的app:layout_anchor和app:layout_anchorGravity属性,将 FAB 锚定在折叠工具栏的底部末端:

现在您已经了解了单选按钮、标签、FAB 等视图,让我们构建OrderFragment布局的主内容。
我们需要构建 OrderFragment 的布局
我们需要向OrderFragment的布局中添加视图,以便用户可以订购比萨类型,并请求任何额外内容。布局需要看起来像这样:

您已经熟悉创建此布局所需的所有代码,现在让我们来更新fragment_order.xml。我们将在接下来的几页中展示完整的代码。
fragment_order.xml 的完整代码
这是OrderFragment的完整布局代码。更新文件fragment_order.xml的代码以包括以下更改(用粗体标记):



这就是我们所需的所有布局代码,让我们进行应用程序的测试驱动,看看它的外观。
Test Drive

运行应用程序时,OrderFragment将显示在MainActivity中。与以前一样,它包括一个折叠的工具栏,但这次主要内容包含文本视图、单选按钮、标签和 FAB。
当我们滚动设备屏幕时,主要内容向上滚动,工具栏会折叠。FAB 保持固定在屏幕的右下角。

让我们让 FAB 响应点击

我们现在已经构建了OrderFragment的布局,但是当用户点击 FAB 时什么都不会发生。让我们更新片段的 Kotlin 代码,使 FAB 响应点击。
我们让 FAB 执行两项任务:
-
如果尚未选择比萨,请显示一条消息。
我们需要用户选择她想要的比萨种类。如果她在未选择任何选项的情况下点击 FAB,我们将显示一个名为toast的弹出消息提示她选择比萨。
![image]()
-
在单独的消息中显示它们的顺序。
如果用户已选择比萨种类,我们将显示一条消息告诉她她已下单。我们将使用一种名为snackbar的不同类型的弹出消息。
注意
在现实世界中,您可能希望在用户点击 FAB 时下订单。在这里,我们只是想让 FAB 做一些事情(同时也是教授您弹出消息的好借口)。
![image]()
让我们开始让 FAB 响应点击。
给 FAB 添加 OnClickListener

正如我们之前所说,你可以让 FAB 响应点击,方法与其他任何类型的按钮相同:将OnClickListener附加到 FAB 上。
这是在OrderFragment中为 FAB 添加OnClickListener的代码;如你所见,它与你用于普通按钮的代码看起来一样:

接下来,让我们获取OnClickListener,如果用户尚未选择比萨种类,则显示一条消息。
查看用户是否已选择比萨种类
我们可以使用pizzas_group单选组的checkedRadioButtonId属性来查看用户是否已选择比萨种类。该属性的值是所选单选按钮的 ID(如果已选择),如果用户尚未做出选择,则为-1。
这是用来检查用户是否在未选择她想要的比萨种类的情况下点击 FAB 的代码:

如果用户尚未选择比萨种类,我们希望在名为toast的弹出消息中显示一条消息。让我们找出如何做到这一点。
一个 toast 是一个简单的弹出消息
如果用户在未选择披萨类型的情况下点击了 FAB,我们将在设备屏幕上显示一个 toast。Toast 是一种简单的弹出消息,向用户提供信息,并在超时时自动消失:

调用 Toast.makeText() 来创建一个 toast。makeText 方法接受三个参数:Context(通常是 this 或 activity,取决于您是从活动还是片段调用 toast),要显示的消息 CharSequence!,以及持续时间。然后调用 toast 的 show() 方法来显示它。
这是一些示例代码,用于在屏幕上显示一个短暂出现的 toast 消息:

将 toast 添加到 FAB 的 OnClickListener
如果用户在未选择披萨类型的情况下点击 FAB,我们将显示一个 toast。以下是实现此功能的代码:

如果用户尚未选择披萨类型,这是我们需要做的所有事情。接下来,让我们编写代码来显示他们的订单。
在 snackbar 中显示披萨订单
如果用户已选择了披萨类型,当她点击 FAB 时,我们将在一个称为snackbar的弹出消息中显示他们的订单。Snackbar 类似于 toast,但更具交互性。例如,您可以滑动 snackbar 将其移除,或者在点击时执行某些操作。
调用 Snackbar.make() 来创建一个 snackbar。make 方法接受三个参数:触发 snackbar 的 View(在本例中是 FAB),要显示的文本 CharSequence!,以及持续时间。然后调用 show() 方法显示 snackbar。

这是一些示例 snackbar 代码,用于在屏幕上显示短时间的消息:

在上述代码中,我们使用了 LENGTH_SHORT 来短暂显示 snackbar。其他选项包括 LENGTH_LONG(长时间显示)和 LENGTH_INDEFINITE(无限期显示)。
Snackbars 可以包含操作
如果希望,您可以在 snackbar 中添加一个操作,以便用户可以撤消刚刚执行的操作。在调用 show() 之前,通过调用 snackbar 的 setAction() 方法来实现这一点。setAction 接受两个参数:操作的文本以及用户点击操作时运行的 lambda 表达式。
这是一个包含操作的 snackbar 代码示例:

Snackbars 通常显示在屏幕底部,但您可以使用 snackbar 的 setAnchorView() 方法覆盖此设置。这将 snackbar 锚定到特定视图,使其显示在该视图上方。例如,如果您希望 snackbar 出现在底部导航栏上方,这将非常有用:

用于披萨订单的 snackbar 代码
现在你知道如何创建 Snackbar 了,让我们写代码来显示用户的披萨订单。我们将显示一个 Snackbar,显示用户选择的披萨类型,以及如帕尔马干酪或辣椒油等任何额外内容。

这是显示 Snackbar 的代码:

这是 Bits 和 Pizzas 应用所需的所有 Kotlin 代码。让我们看看完整的 OrderFragment.kt 代码,并测试一下这个应用。
完整的 OrderFragment.kt 代码如下
这是 OrderFragment.kt 的完整代码;更新代码以包含以下更改(以粗体显示):


测试驱动
当我们点击 FAB 而没有选择披萨类型时,会显示一个提示框,要求我们选择披萨。
当我们选择披萨类型并再次点击 FAB 时,屏幕底部会出现一个 Snackbar,显示我们订单的详细信息。

恭喜!你现在学会了通过显示弹出消息来响应 FAB 的点击。
池谜题

你的目标是完成下面片段代码,这样当点击 ID 为 fab 的 FAB 时,它会显示一个 Snackbar。Snackbar 应包含一个“撤销”操作,点击后会显示一个提示框。从池中选取代码片段填入空白行中。每个片段只能使用一次,并且不需要使用所有片段。
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_order, container, false)
val fab = view.findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
Snackbar..........(fab, "Your order has been updated",......................)
.setAction("Undo") {
Toast..........(........., "Undone!",......................)
..........
}
..........
}
return view
}

注意
注意:每个池中的内容只能使用一次!
池谜题解答

你的目标是完成下面片段代码,这样当点击 ID 为 fab 的 FAB 时,它会显示一个 Snackbar。Snackbar 应包含一个“撤销”操作,点击后会显示一个提示框。从池中选取代码片段填入空白行中。每个片段只能使用一次,并且不需要使用所有片段。


布局磁铁解答

有人在冰箱门上排列了一些磁铁,展示了如何制作一个带有图片的折叠工具栏布局文件。不幸的是,当一只大翼龙飞过寻找食物时,磁铁掉了下来。
看看你能不能把磁铁放回正确的顺序。

你的 Android 工具箱

你已经掌握了第九章,现在你已经为工具箱增加了更多视图和组件。

第十章:视图绑定:紧密联系在一起

是时候告别 findViewById() 了。
正如你可能已经注意到的,你的应用程序中的视图越多,交互性越强,调用 findViewById() 的次数就越多。如果你厌倦了每次想要操作视图时都要输入此方法的代码,你并不孤单。在本章中,你将了解到如何通过实施 视图绑定,让 findViewById() 成为过去的事情。你将了解到如何将这一技术应用到 活动和片段代码 中,以及为什么这种方法是访问布局视图的 更安全、更高效 的方式。让我们开始吧…
findViewById() 的幕后运行原理
正如你所知,每次在活动或片段代码中与视图交互时,都需要先调用findViewById()来获取对它的引用。例如,下面的活动代码获取了一个名为 start_button 的 Button 的引用,以便能够响应点击事件:

但实际调用findViewById()时会发生什么?
findViewById() 在视图层次结构中查找视图
当上述代码运行时,会发生以下事情:
-
MainActivity 的布局文件 (activity_main.xml) 被填充为视图对象的层次结构。
如果文件描述了包含文本视图和按钮的线性布局,则布局会被填充为
LinearLayout、TextView和Button对象。LinearLayout是此层次结构的根视图。![image]()
-
Android 在视图层次结构中搜索具有匹配 ID 的视图。
我们使用
findViewById<Button>(R.id.start_button),所以 Android 会搜索视图层次结构,找到一个 ID 为start_button的视图。![image]()
-
Android 返回层次结构中具有此 ID 的顶级视图,并将其转换为调用
findViewById()中指定的类型。在这里,
findViewById<Button>(R.id.start_button)会找到层次结构中第一个 ID 为start_button的视图,并将其转换为Button。MainActivity现在可以与它交互了。![image]()
因此,每次使用findViewById()时,Android 都会在布局的层次结构中搜索具有匹配 ID 的 View,并将其转换为指定的类型。
findViewById() 也有其缺点
尽管 findViewById() 是获取视图引用的有用方法,但它也有几个缺点。
-
它会使你的代码变得更长。需要与更多视图交互时,就需要进行更多的调用。这可能导致代码变得更长,更难阅读。
-
这是低效的。每次调用
findViewById()时,Android 都需要在布局的层次结构中搜索具有匹配 ID 的视图。这种方法效率低下,特别是如果你的布局有许多视图且层次深度很深时。![image]()
-
这不是空安全的。findViewById()用于在运行时搜索视图,这意味着编译器无法检查常见的错误。例如,可能会将无效的 ID(即在布局中不存在的 ID)传递给
findViewById()。如果尝试使用:val message = view.findViewById<EditText>(R.id.message)如果布局中不包含 ID 为
message的View,将会抛出空指针异常,并导致应用程序崩溃。 -
它不是类型安全的。另一个问题是编译器无法检查您是否正确指定了
View的类型,这可能导致类转换异常。假设您有一个名为
pizza_group的单选按钮组,并尝试使用以下代码获取对它的引用:val pizzaGroup = view.findViewById<ChipGroup>(R.id.pizza_group)尽管指定了
ChipGroup类型而不是RadioGroup,但代码仍然可以编译。编译器在构建代码时不会检查类型是否正确。当应用程序运行时,它会抛出类转换异常,并导致应用程序崩溃。

因此,如果findViewById()有这些缺点,那么有什么替代方法呢?
视图绑定解决方案
每次需要View引用时调用findViewById()的替代方法是使用视图绑定。通过视图绑定,您设置一个绑定对象(稍后会详细介绍),并使用它来访问每个视图。

例如,假设您的布局包含一个 ID 为start_button的按钮。如果您希望在单击时执行某些操作,您可以像这样使用findViewById()获取对它的引用:

使用视图绑定,您不再需要调用findViewById()来获取对按钮的引用。相反,您只需使用以下代码:

这段代码执行相同的操作,但编写起来更简单,可以使您的代码更短、更易读。
视图绑定比findViewById()更安全、更高效。
当您使用视图绑定时,Android 不再需要搜索布局的视图层次结构以寻找匹配的View:它只需使用绑定对象来访问它。这比使用findViewById()更高效。
另一个优点是编译器在编译时防止空指针和类转换异常。当您使用绑定对象访问视图时,编译器知道哪些视图可用以及它们的类型。它不会让您引用不存在的视图,并且您也不再需要将其强制转换为特定类型,因为编译器已经知道这是什么。因此,您的代码变得更安全。
现在您已经了解使用视图绑定的好处,让我们看看如何使用它。
视图绑定是调用 findViewById()的类型安全、更高效的替代方法。
下面是我们将使用视图绑定的方式
视图绑定代码在活动和片段中略有不同,因此在本章中,我们将展示两者的代码。以下是我们将要介绍的步骤:
-
向秒表应用程序的活动代码中添加视图绑定。
在第五章中,我们构建了一个 Stopwatch 应用,以教授您关于 Android 活动生命周期方法的知识。我们将重新访问此应用程序,并更新其活动代码,使其使用视图绑定。
![image]()
-
为 Bits and Pizzas 应用的片段添加视图绑定。
然后,我们将回到我们在前一章创建的 Bits and Pizzas 应用中,并向您展示如何在其片段代码中实现视图绑定。
![image]()
让我们开始吧。
重新审视 Stopwatch 应用

我们将从修改您在第五章中创建的 Stopwatch 应用开始,因此现在打开此应用的项目。
正如您可能记得的那样,Stopwatch 应用显示一个简单的秒表,您可以通过三个按钮启动、暂停和重置。它的外观如下:

该应用程序使用单一活动——MainActivity,其具有名为 activity_main.xml 的布局文件。

每当需要与其视图之一交互时,它都会调用 findViewById() 来获取对其的引用。例如,为了使其“开始”按钮响应点击事件,它使用类似于以下的代码:
val startButton = findViewById<Button>(R.id.start_button)
startButton.setOnClickListener {
//Code that runs when the button’s clicked
}
让我们找出如何更新应用程序,以便使用视图绑定。
在应用的 build.gradle 文件中启用视图绑定。

要使用视图绑定,您首先需要在应用的 build.gradle 文件的 android 部分启用它。启用视图绑定的代码如下所示:

我们将在 Stopwatch 应用中使用视图绑定,所以确保将上述更改添加到文件 Stopwatch/app/build.gradle。然后选择“立即同步”选项,以将此更改与项目的其余部分同步。
注意
记得同步此更改,否则在尝试更新活动代码时将会出现错误。
启用视图绑定会为每个布局生成代码
启用视图绑定时,它会自动为应用程序中每个布局文件创建一个绑定类。例如,Stopwatch 应用包含一个名为 activity_main.xml 的布局文件,因此启用视图绑定时,它会自动生成一个名为 ActivityMainBinding 的绑定类:

每个绑定类都包含布局中具有 ID 的每个视图的属性。例如,布局 activity_main.xml 包含一个 ID 为 start_button 的按钮,因此绑定类 ActivityMainBinding 包含一个名为 startButton 的属性,其类型为 Button。
绑定类非常重要,因为布局的视图与绑定类的属性绑定。您无需每次需要视图引用时都调用 findViewById(),而是直接与绑定类中的该视图属性交互。
现在我们已经在 Stopwatch 应用中启用了视图绑定,让我们看看如何在 MainActivity 的代码中使用它。

如何将视图绑定添加到活动
使活动使用视图绑定的代码对于每个创建的活动几乎完全相同。代码如下所示:

上述代码声明了一个名为binding的属性,其类型为ActivityMainBinding。此属性在活动的onCreate()方法中设置,使用以下代码:

这调用了ActivityMainBinding的inflate()方法,创建了一个与活动布局关联的ActivityMainBinding对象。
代码:
val view = binding.root
setContentView(view)

获取binding对象的根视图的引用,并使用setContentView()方法将其显示出来。
一旦以这种方式为活动添加了视图绑定,就可以使用binding属性与布局的视图进行交互。现在让我们这样做。
使用binding属性与视图进行交互

MainActivity当前的代码与其视图交互,以控制秒表并使其按钮响应点击事件。我们可以更新此代码,以便不再调用findViewById(),而是使用活动的binding属性访问视图。
为了看看这是如何工作的,让我们以MainActivity的“开始”按钮为例。
布局代码
“开始”按钮在activity_main.xml中定义,使用以下代码:

如您所见,它的 ID 是start_button。
活动代码
MainActivity使用findViewById()使按钮响应点击事件,代码如下:
val startButton = findViewById<Button>(R.id.start_button)
startButton.setOnClickListener {
//Code that runs when the button’s clicked
}
通过视图绑定,我们可以更改代码为:

该代码与原始代码执行相同的操作,但使用MainActivity的binding属性与按钮进行交互。
让我们更新MainActivity的完整代码,以便使用视图绑定。
MainActivity.kt 的完整代码
下面是更新后的MainActivity代码;请更新MainActivity.kt的代码,以包括以下更改(加粗部分):



这些是我们需要对秒表应用进行的所有更改,以使其使用视图绑定。让我们详细了解代码运行时发生的情况,并进行测试。
代码的功能
应用运行时发生以下事情:
-
应用启动时,MainActivity 被创建。
它包括一个名为
binding的ActivityMainBinding属性。![image]()
-
当 MainActivity 的 onCreate()方法运行时,它将一个 ActivityMainBinding 对象赋给 binding 属性。
binding属性在onCreate()中设置,因为这是MainActivity首次访问视图时的时机。![image]()
-
ActivityMainBinding 对象包括布局中每个具有 ID 的视图的属性。
布局文件 activity_main.xml 包含一个 ID 为
start_button的按钮,例如,ActivityMainBinding对象包含一个名为startButton的Button属性,用于访问此视图。![image]()
-
MainActivity 使用绑定属性访问其视图。
它指定了点击“开始”按钮应如何响应,例如通过调用
startButton属性的setOnClickListener()方法。![image]()
让我们来测试一下这个应用程序。
测试驱动
当我们运行应用程序时,它的工作方式与以前相同。当我们点击“开始”按钮时,秒表开始计时;当我们点击“暂停”和“重置”按钮时,它会暂停并重置。

然而,不同的是,MainActivity 现在使用视图绑定来与其视图交互,而不是调用 findViewById()。
活动磁铁

名为 MainActivity 的活动使用文件 activity_main.xml 作为其布局。布局包括一个 ID 为 pow_button 的 Button 和一个 ID 为 pow_text 的 TextView。点击按钮时,按钮需要使 TextView 显示文本“Pow!”
有人用冰箱磁铁写了 MainActivity 的代码,但是一场怪异的厨房沙尘暴使一些磁铁脱落了。你能重新拼凑这段代码吗?

片段也可以使用视图绑定(但代码略有不同)

现在您已经学会了如何在活动代码中实现视图绑定,让我们看看如何在片段中使用它。正如我们之前所说,片段的代码与活动的代码略有不同。
让我们在 Bits 和 Pizzas 应用程序中实现视图绑定
我们将通过修改您在 第九章 中创建的 Bits 和 Pizzas 应用程序来探讨如何为片段实现视图绑定。
正如您可能记得的那样,Bits 和 Pizzas 应用程序使用 Material 设计视图,如可折叠工具栏和 FAB(浮动操作按钮),为比萨应用程序提供交互式用户界面。该界面由名为 FragmentOrder 的片段定义。

FragmentOrder 的代码包含许多对 findViewById() 的调用。我们来看看如何使用视图绑定来替换这些调用。

为 Bits 和 Pizzas 启用视图绑定
与以往一样,在 Bits 和 Pizzas 项目中使用视图绑定,首先需要在应用程序的 build.gradle 文件中启用它。
打开文件 BitsandPizzas/app/build.gradle,并在 android 部分添加以下行:

然后选择“立即同步”选项,将更改与项目的其余部分同步。
为每个布局生成绑定类
就像秒表应用程序一样,在“Bits and Pizzas”应用程序中启用视图绑定会自动为每个应用程序的布局文件创建一个绑定类。该应用程序包含两个布局文件——activity_main.xml和fragment_order.xml,因此生成两个绑定类:ActivityMainBinding和FragmentOrderBinding。

如前所述,每个绑定类都包含其布局中每个视图的属性及其 ID。例如,布局fragment_order.xml包含一个 ID 为pizza_group的单选组,因此绑定类FragmentOrderBinding包含一个名为pizzaGroup的属性,其类型为RadioGroup。
在“Bits and Pizzas”应用程序中,唯一与视图交互的代码位于OrderFragment.kt中,因此我们只需更新此文件以使用视图绑定。

片段视图绑定代码有点不同

正如我们之前所说,您在片段中使用的视图绑定代码与在活动中使用的代码略有不同。在展示片段视图绑定代码之前,我们将深入挖掘一下,并向您展示代码之所以不同的原因。
活动可以从onCreate()开始访问视图
正如您已经知道的那样,活动在其onCreate()方法运行时首次访问其布局。这是活动生命周期中的第一个方法,用于填充布局(或使用视图绑定绑定到它)并执行任何初始设置。例如,如果一个按钮需要响应点击事件,活动会在其onCreate()方法中使用代码分配一个OnClickListener给它。
活动在其onDestroy()方法运行之前可以继续访问其布局。这是活动生命周期中的最后一个方法,活动在此方法运行完毕后被销毁。
因为活动在其从onCreate()到onDestroy()期间可以访问其布局中的视图,所以它可以在其任何方法中与它们进行交互:

这就是活动的情况,但对于片段,情况略有不同。让我们看看具体是如何的。
片段可以从onCreateView()到onDestroyView()访问视图。
正如您所知,片段在其onCreateView()方法运行时首次访问其布局。当活动需要访问片段的布局时,会调用此方法,因此它用于填充布局并执行任何初始设置,如设置OnClickListener。
onCreateView(),然而,并不是片段生命周期中的第一个方法。还有其他方法,比如onCreate(),在onCreateView()之前运行,因此它们不能与片段的视图进行交互。
在其onCreateView()方法运行后,片段将继续访问其视图,直到其onDestroyView()方法运行完毕。当活动不再需要片段的布局时(可能是因为活动需要导航到不同的片段或者活动被销毁),会调用此方法。
然而,onDestroyView() 并不是片段生命周期中的最后一个方法。还有其他方法,例如 onDestroy(),在 onDestroyView() 执行后运行,因此这些方法也不能与片段的视图交互:
活动可以在其生命周期的
onCreate()到onDestroy()期间与其视图交互。片段只能在其
onCreateView()到onDestroyView()期间与其视图交互。

我们将在下一页上向您展示片段生命周期的概述,然后看看片段视图绑定代码的样子。
片段视图绑定代码的样子
您现在已经准备好看看片段视图绑定代码是什么样子了。就像活动一样,您通过让它们使用一个绑定属性来启用片段的视图绑定,但实现方式略有不同。
这里是代码:

正如您所看到的,上述代码定义了两个额外的属性:binding 和 _binding。让我们更仔细地看看这两个属性的作用。
_binding 指的是绑定对象…
正如您刚刚看到的,片段的视图绑定代码定义了一个 _binding 属性,如下所示:

它的类型是 FragmentOrderBinding?,并且初始化为 null。
_binding 属性在片段的 onCreateView() 方法中被设置为 FragmentOrderBinding 的一个实例,使用以下代码:

之所以在这个方法中设置它,是因为这时片段首次可以访问其视图。
_binding 属性在片段的 onDestroyView() 方法中被设置为 null:

这是因为片段在其 onDestroyView() 方法被调用后无法再访问其视图。
...而 binding 属性提供了对其的非空访问。
binding 属性使用 getter 返回 _binding 的非空版本,并在 _binding 为 null 时抛出空指针异常:
private val binding get() = _binding!!
这意味着您可以使用 binding 属性与片段的视图交互,而无需执行大量混乱的空安全检查。例如,要使片段的 FAB 响应点击,只需使用:
binding.fab.setOnClickListener {
//Code that does something
}

活动和片段在它们的生命周期不同的阶段获取对它们的视图的访问权限。
活动 可以在它被创建并且其 onCreate() 方法被调用时与其视图交互。它会一直保持对这些视图的访问权限,直到活动被销毁,即在其生命周期结束时。
片段 只能在其 onCreateView() 方法被调用时与其视图交互。这意味着 _binding 属性只能在此方法中设置为视图绑定对象。
片段在其 onDestroyView() 方法运行之前可以继续访问其视图。在这一点上,片段的布局被丢弃,所以 _binding 在不能再与视图交互时需要设置为 null。这可以防止片段在无法与视图交互时尝试使用视图绑定对象。
现在您已经掌握了使用视图绑定与片段的所有必要知识,让我们更新 Bits and Pizzas 应用程序中OrderFragment的代码。

OrderFragment.kt 的完整代码
这是让OrderFragment使用视图绑定的代码;更新OrderFragment.kt的代码,以包含这里显示的更改(用粗体标出):


这就是我们需要让OrderFragment使用视图绑定的所有代码。让我们来测试一下应用程序,确保它仍然可以正常工作。
测试驾驶
运行应用程序时,它的工作方式与以前相同。
当我们点击 FAB 而没有选择披萨类型时,会弹出一个提示框要求我们选择披萨。
当我们选择披萨类型并再次点击 FAB 时,屏幕底部会出现一个 Snackbar,显示我们订单的详细信息。

恭喜!您现在知道如何在活动和片段代码中实现视图绑定。我们将在本书的其余部分继续使用视图绑定。
活动磁铁解决方案

名为MainActivity的活动使用文件activity_main.xml作为其布局。布局包括一个Button(ID 为pow_button)和一个TextView(ID 为pow_text)。点击按钮时,按钮需要使TextView显示文本“Pow!”
有人用冰箱磁铁编写了MainActivity的代码,但一个怪异的厨房沙尘暴使一些磁铁脱落了。你能把代码重新拼凑在一起吗?

您的 Android 工具箱

您已经掌握了第十章,现在您已经将视图绑定添加到您的工具箱中。

第十一章:视图模型:模型行为

随着应用程序变得更加复杂,片段需要处理的任务变得更多。
如果不小心处理不当,这可能导致试图做所有事情的臃肿代码。业务逻辑、导航、控制 UI、处理配置更改……所有这些都在其中。在本章中,您将学习如何使用视图模型来处理这种情况。您将发现它们如何简化您的活动和片段代码。您将了解它们如何在配置更改时保持存活状态,确保应用程序的状态安全和完整。最后,我们将向您展示如何构建视图模型工厂,以及何时可能需要它。
重新讨论配置更改
正如您在第五章中学到的,当您在运行应用程序时旋转屏幕,可能会发生一些问题。更改屏幕方向是一种配置更改,这会导致 Android 销毁并重新创建当前活动。因此,视图和属性可能会丢失其状态并重新设置:

在第五章中,您学习了如何通过活动的onSaveInstanceState方法来处理此问题。此方法在活动被销毁之前触发,并用于保存可能在Bundle中丢失的任何值。当活动重新创建时,您可以使用这些保存的值来恢复活动视图和属性的状态。
注意
片段也有一个onSaveInstanceState方法。尽管我们只向您展示了如何在活动中使用此方法,但它也适用于片段。

使用Bundle来保存状态对于相对简单的应用程序效果很好,但对于更复杂的应用程序来说并不是理想的解决方案。这是因为Bundle只能保存少量数据,并且仅适用于有限数量的类型。
还有其他问题
应用程序可能面临的另一个问题是,活动和片段代码可能会迅速变得臃肿。代码可能需要控制导航、更新 UI、保存状态,并包括更一般的业务逻辑以控制应用程序的行为。将所有这些内容放在一个地方会使代码变得更长,这样就更难阅读和维护。
在本章中,我们将学习如何使用视图模型来解决所有这些问题。
引入视图模型
视图模型是一个独立的类,与活动或片段代码并列。它负责屏幕上显示的所有数据,以及任何业务逻辑。每当片段需要更新其布局时,它会请求视图模型提供最新的需要显示的值,如果需要访问一些业务逻辑,则调用视图模型中的方法。

为什么要使用视图模型?
您可能希望使用视图模型的原因有几个。
使用视图模型简化您的活动或片段代码。您的片段不再需要包含与应用程序业务逻辑相关的代码,因为这些代码都保存在一个独立的类中。相反,它可以专注于更新屏幕或导航等事项。
另一个原因是视图模型可以在配置更改时保持存活。当用户旋转设备屏幕时,它不会被销毁,因此任何变量的状态不会丢失。这样可以在无需将值存储在Bundle中的情况下恢复应用程序的状态。

我们将通过构建一个猜字游戏来了解如何使用视图模型。在我们开始编写代码之前,让我们先了解游戏的运作方式。
猜字游戏的功能
猜字游戏应用程序的目标是让用户尝试猜出一个秘密单词。
游戏开始时,它从一个数组中随机选择一个单词,并显示每个字母的空白:

用户建议一个她认为在秘密单词中的字母。如果她猜对了,游戏会显示字母在秘密单词中的位置。如果她猜错了,游戏会显示错误的猜测,并且她会失去一条生命。

用户将继续猜测,直到找到所有的字母或耗尽生命。当这种情况发生时,一个新的屏幕将出现,告诉她秘密单词是什么,以及她是否赢得了游戏。她可以选择开始另一场游戏。

应用程序的结构
应用程序将包含一个名为MainActivity的单一活动,用于显示游戏的片段:GameFragment和ResultFragment。
GameFragment是游戏的主要屏幕。它将显示秘密单词的空白,让用户进行猜测,并显示任何错误的选择和剩余的生命次数。
当游戏结束时,GameFragment将使用导航组件导航到ResultFragment,并向其传递一个结果的String。ResultFragment将显示该String和一个按钮,让用户开始新游戏。

我们将在构建应用程序时详细介绍其结构。首先,让我们逐步完成我们将采取的步骤。
这是我们要做的事情
这是我们将用来编写应用程序的步骤:
-
编写基本游戏。
我们将创建
GameFragment和ResultFragment,编写游戏逻辑,并使用导航组件在这两个片段之间进行导航。![image]()
-
为 GameFragment 添加一个视图模型。
我们将创建一个视图模型,名为
GameViewModel,用于保存GameFragment的游戏逻辑和数据。这将简化GameFragment的代码,并确保游戏在配置更改时仍然存在。![image]()
-
为 ResultFragment 添加一个视图模型。
我们将为
ResultFragment添加第二个视图模型ResultViewModel。这个视图模型将保存用户刚刚玩过的游戏的结果。![图片]()
创建猜谜游戏项目
我们将为猜谜游戏应用使用一个新项目,因此现在使用与前几章相同的步骤创建一个。选择“空活动”选项,输入名称“猜谜游戏”和包名称“com.hfad.guessinggame”,接受默认保存位置。确保语言设置为 Kotlin,最低 SDK 为 API 21,以便在大多数 Android 设备上运行。
猜谜游戏应用将使用视图绑定来引用其视图,并使用导航组件在其片段之间导航。让我们更新项目和应用的build.gradle文件以包含这些库。
更新项目的 build.gradle 文件…

我们将在项目的build.gradle文件中添加一个新变量,以指定我们将使用的导航组件的版本,以及一个 Safe Args 类路径。
打开文件GuessingGame/build.gradle,并将以下行(用粗体表示)添加到所示的部分:

…并更新应用的 build.gradle 文件
在应用的build.gradle文件中,我们需要启用视图绑定,添加导航组件库的依赖项,并应用 Safe Args 插件,以便我们可以向片段传递参数。
打开文件GuessingGame/app/build.gradle,并将以下行(用粗体表示)添加到相应的部分:

完成这些更改后,点击“立即同步”选项,将您所做的更改与项目的其余部分同步。
接下来,我们将创建应用的片段。
猜谜游戏应用有两个片段
猜谜游戏应用需要两个片段:GameFragment 和 ResultFragment。GameFragment 是应用的主屏幕,而 ResultFragment 将用于显示结果:

让我们继续向项目添加这两个片段。
创建 GameFragment…
我们将首先向项目添加GameFragment。
在app/src/main/java文件夹中突出显示com.hfad.guessinggame包,然后转到文件→新建→片段→片段(空白)。将片段命名为“GameFragment”,将其布局命名为“fragment_game”,并确保语言设置为 Kotlin。

注意
暂时不用担心这些片段的代码。在将它们添加到导航图后,我们将对其进行更新。
…然后创建 ResultFragment
接下来,通过再次在app/src/main/java文件夹中突出显示com.hfad.guessinggame包,并选择文件→新建→片段→片段(空白)来添加ResultFragment。这次,将片段命名为ResultFragment,将其布局命名为“fragment_result”,并确保语言设置为 Kotlin。

注意
暂时不用担心这些片段的代码。在将它们添加到导航图后,我们会更新它们。
在接下来的几页中,我们将更新这两个片段的代码。在此之前,我们将在项目中添加一个导航图,告诉应用如何在这两个片段之间导航。
导航工作原理
你知道的,导航图告诉 Android 应用可能的目标位置以及如何导航到它们。
在猜谜游戏应用中,我们希望导航按以下方式工作:
-
当应用启动时,显示 GameFragment。
![image]()
-
当游戏赢了或输了时,GameFragment 导航到 ResultFragment,并向其传递结果。
![image]()
-
当用户点击“New Game”按钮时,导航到 GameFragment。
![image]()
为了实现这一点,我们将把 GameFragment 和 ResultFragment 添加到一个新的导航图中(我们将创建),并指定每个片段可以导航到另一个片段。
我们还将说,GameFragment 将向 ResultFragment 传递一个 String 参数,指定一个消息,指示用户是赢了还是输了他们刚刚玩的游戏。当导航到 ResultFragment 时,将显示此消息。
创建导航图
要创建导航图,选择项目资源管理器中的 GuessingGame/app/src/main/res 文件夹,然后选择 文件→新建→Android 资源文件。在提示时,输入文件名“nav_graph”,选择资源类型“导航”,然后点击确定。这将创建一个名为 nav_graph.xml 的导航图。
我们需要更新导航图,包括 GameFragment 和 ResultFragment,以及每个片段的导航操作。接下来我们将完成这部分工作。
导航图包含了所有应用可能的目标位置的详细信息,以及如何到达它们。
更新导航图
要更新导航图,打开文件 nav_graph.xml(如果尚未打开),切换到代码视图,并更新其代码以包含这里显示的更改(用粗体标出):

现在你已经更新了导航图,让我们将其链接到 MainActivity,以便在导航到每个片段时显示它。
在 MainActivity 的布局中显示当前片段
要显示每个片段,我们需要向 MainActivity 的布局添加一个与我们刚刚创建的导航图链接的导航宿主。我们将使用 FragmentContainerView 来实现,就像我们在前几章中所做的那样。
你已经熟悉如何完成这项任务的代码,所以请更新 activity_main.xml,使其与下面的代码匹配:

当你更新了布局后,打开 MainActivity.kt 并确保其与这里显示的代码匹配:

这就是 MainActivity 需要的一切。接下来,让我们从两个片段的代码开始更新,首先是 GameFragment。
更新 GameFragment 的布局
GameFragment是应用程序的主屏幕,用户将使用它来玩猜字游戏。我们需要向其布局中添加几个视图以实现这一点:三个文本视图用于猜测的单词、剩余生命值和已经猜错的猜测,一个用于输入猜测的编辑文本,以及一个用于进行猜测的按钮。
通过更新fragment_game.xml中的代码,将这些视图添加到布局中,使其与下面显示的代码匹配:


这就是我们需要包含在GameFragment布局中的所有内容。接下来,让我们通过更新其 Kotlin 代码告诉游戏如何行为。
GameFragment需要做什么
在应用程序的第一个版本中,GameFragment.kt需要包含所有玩游戏所需的代码,以及更新屏幕和启用导航。它需要:
-
随机选择一个单词。我们将为其提供一个可供选择的可能单词列表。
-
让用户猜一个字母。 -
响应猜测。如果用户猜对了,
GameFragment需要将该字母添加到正确猜测列表中,并显示该字母在单词中的位置。如果猜测错误,它需要将猜测添加到错误猜测的String中,并减少剩余生命值的数量。 -
在游戏结束时导航到 ResultFragment。
所有这些代码都是纯 Kotlin,或者是您之前见过的 Android 代码。我们将在接下来的几页中展示完整的代码。
GameFragment.kt代码
这是GameFragment的代码;更新GameFragment.kt中的代码,使其与下面显示的代码匹配:



这就是我们需要的所有GameFragment的代码。接下来,让我们编写ResultFragment的代码。
更新 ResultFragment 的布局
ResultFragment使用文本视图告诉用户她刚刚玩的游戏是赢了还是输了,并使用按钮让她开始另一场游戏。我们需要将这两个视图都添加到片段的布局中。
你已经熟悉如何做到这一点的代码,所以打开fragment_result.xml,并更新它,使其与下面显示的代码匹配:

我们还需要更新ResultFragment.kt
一旦我们将文本视图和按钮添加到ResultFragment的布局中,我们需要在片段的 Kotlin 代码中指定它们的行为。我们将更新文本视图的文本为结果,并且当点击按钮时,还将使其导航回GameFragment。
我们将在下一页上展示ResultFragment.kt的代码。
ResultFragment.kt代码
这是ResultFragment的代码;更新ResultFragment.kt,使其与下面的代码匹配:

这就是我们这个版本的猜字游戏应用程序所需的所有代码。让我们看看代码运行时会发生什么,并测试一下这个应用程序。
应用程序运行时会发生什么
当应用程序运行时发生以下事情:
-
应用程序启动并在 MainActivity 中显示 GameFragment。
GameFragment将livesLeft设置为 8,correctGuesses和incorrectGuesses设置为"",secretWord设置为随机选择的单词,并将secretWordDisplay设置为deriveSecretWordDisplay()的值。然后调用其updateScreen()方法,显示livesLeft,incorrectGuesses和secretWordDisplay的值。![图片]()
-
当用户猜测时,GameFragment 调用其 makeGuess()方法。
该方法检查
secretWord是否包含用户猜测的字母。如果包含,makeGuess()将该字母添加到correctGuesses并更新secretWordDisplay。如果不包含,该方法将字母添加到incorrectGuesses,并从livesLeft中减去 1。然后再次调用updateScreen(),以便显示新值。![图片]()
-
每次猜测后,GameFragment 检查 isWon()或 isLost()是否为 true。
这些方法检查用户是否猜出了单词中的所有字母,或者是否已经用完了生命。如果任一方法返回true,
GameFragment将结果传递给ResultFragment,后者显示结果。![图片]()
测试驾驶
运行应用程序时,会显示GameFragment。它显示秘密单词有多少个字母,以及我们有多少生命。
我们可以通过在编辑文本中输入一个字母并点击按钮来猜测。如果我们猜对了,它会将字母放入秘密单词中,但如果我们猜错了,我们就会失去一条生命。
如果我们猜出所有字母或用完生命,将在ResultFragment中显示告诉我们是否赢了或输了的消息。

游戏似乎可以运行,但如果我们旋转屏幕会发生什么?
屏幕旋转时游戏会丢失状态
然而,游戏存在一个问题。如果我们在游戏进行中旋转屏幕,应用程序会丢失状态,游戏将从头开始。

屏幕旋转会改变应用程序的配置,因此游戏会丢失状态,因此 Android 会销毁活动(以及显示的片段)并立即重新创建。这会重置游戏的视图和属性。
我们可以使用片段的onSaveInstanceState方法来保存任何属性的状态,就像我们在本书中之前所做的那样。然而,这一次,我们将通过实现视图模型来解决这个问题。
视图模型保存业务逻辑

正如我们之前所说,视图模型是一个与您的活动或片段代码并列的独立类。它负责屏幕上需要显示的数据以及任何业务逻辑。在我们的 Guessing Game 应用中,例如,这意味着视图模型需要保存游戏的属性——例如用户需要猜测的秘密单词和剩余生命值——以及控制游戏如何进行的任何方法。
当你实现一个视图模型时,所有与应用程序数据或业务逻辑相关的代码都会移出活动或片段,并进入视图模型。任何控制 UI 的代码——例如显示文本或获取用户输入——都留在活动或片段代码中。
这种 Android 应用程序的架构方式遵循一个被称为关注点分离的设计原则。应用程序被分割成不同的类,每个类处理一个单独的关注点。UI 控制器——活动或片段代码——负责 UI,而视图模型负责业务逻辑和数据:
使用视图模型简化你的活动和片段代码,并保存任何属性的状态,以便它们在配置更改后保持不变。

使用这种类型的应用程序架构有两个关键优势。
-
它简化了你的活动和片段代码。将应用的数据和业务逻辑移动到视图模型中意味着你需要维护的活动和片段代码更少。
-
你的应用在配置更改后依然存在。视图模型是一个独立的类,与您的活动或片段代码并列。当您旋转设备屏幕时,它不会被销毁,因此视图模型中保存的任何属性的状态会在配置更改时保持不变,而不需要将它们的值添加到
Bundle中。
现在您已经了解了使用视图模型的好处,让我们看看如何将其添加到 Guessing Game 应用程序中。
在应用的 build.gradle 文件中添加视图模型依赖项…
视图模型库是 Android Jetpack 的一部分,因此您需要更新应用的build.gradle文件,以将其包含为一个依赖项。
打开文件GuessingGame/app/build.gradle,并在依赖项部分添加以下行(用粗体标注):

在提示时,同步您的更改。现在您已经准备好继续并创建一个视图模型了。
…并创建一个视图模型
我们将创建一个名为GameViewModel的视图模型,GameFragment将用它来处理游戏逻辑和数据。
在app/src/main/java文件夹中选择com.hfad.guessinggame包,然后转到文件→新建→Kotlin 类/文件。将文件命名为“GameViewModel”,并选择创建一个类的选项。
创建完GameViewModel.kt文件后,更新其代码以匹配以下代码(更改部分已用粗体标注):

如你所见,GameViewModel 类继承自 androidx.lifecycle.ViewModel。ViewModel 是一个抽象类,用于将普通类转变为一个具备身份的视图模型。
现在我们已经将 GameViewModel 添加到猜谜游戏项目并将其转变为视图模型,让我们编写其余代码。
GameViewModel.kt 的完整代码如下:
正如你已经学到的,视图模型负责活动或片段的业务逻辑和数据。对于猜谜游戏应用程序,这意味着我们需要将所有与游戏进行相关的 GameFragment 的属性和方法移动到 GameViewModel 中。我们将任何与导航或 UI 相关的代码留在 GameFragment 中。
这是 GameViewModel.kt 的完整代码;请更新代码以包含以下更改(加粗显示):


这就是我们需要的关于 GameViewModel 的所有内容。接下来,让我们将其链接到 GameFragment,并更新该片段的代码。
创建 GameViewModel 对象。
要将视图模型链接到活动或片段,您需要在代码中添加 ViewModel 属性,并用需要创建的 ViewModel 对象进行初始化。代码如下所示:

如你所见,上述代码使用 视图模型提供程序 创建 ViewModel 对象。那么,使用这个类有什么作用呢?
使用 ViewModelProvider 创建视图模型。
如其名称所示,ViewModelProvider 是一个特殊的类,其工作是为活动和片段提供视图模型。它确保 只有在不存在视图模型对象时才会创建一个新的视图模型对象。
如你已了解的,当屏幕旋转时,显示在屏幕上的任何片段都会被销毁并重新创建。在此过程中,视图模型提供程序确保继续使用相同的视图模型对象。视图模型保持其状态,因此片段使用的任何属性都不会被重置。
视图模型提供程序在活动或片段保持活跃时保持视图模型。例如,当片段被分离或从其活动中移除时,视图模型提供程序释放片段的视图模型。下次要求提供视图模型对象时,它会创建一个新的对象。

现在你已经知道如何将视图模型链接到片段,让我们更新 GameFragment 的代码。
GameFragment.kt 的更新代码如下:
我们需要从 GameFragment 中移除已经转移到 GameViewModel 中的属性和方法,并确保片段使用视图模型。
这里是更新后的 GameFragment 代码;确保文件 GameFragment.kt 包含以下所示的更改(加粗显示):


这些是我们需要对 GameFragment 进行的所有更改。让我们仔细分析代码在运行时的行为,并进行测试。
应用程序运行时会发生什么
应用程序运行时发生以下事件:
-
GameFragment 向 ViewModelProvider 类请求 GameViewModel 的实例。
视图模型提供程序发现该片段尚未关联到现有的
GameViewModel对象,因此创建一个新对象。![image]()
-
GameViewModel 对象被初始化。
将
livesLeft设置为 8,将correctGuesses和incorrectGuesses设置为空字符串,将secretWord设置为随机选择的单词,并将secretWordDisplay设置为deriveSecretWordDisplay()。![image]()
-
GameFragment 调用其 updateScreen()方法。
该方法访问
GameViewModel对象的secretWordDisplay、livesLeft和incorrectGueses属性,并在屏幕上显示它们。![image]()
-
当用户猜测时,GameFragment 调用 GameViewModel 对象的 makeGuess()方法。
该方法检查
secretWord是否包含用户猜测的字母。如果是,将字母添加到correctGuesses并更新secretWordDisplay;如果不是,将其添加到incorrectGuesses,并从livesLeft中减去 1。![image]()
-
GameFragment 再次调用其 updateScreen()方法。
该方法从
GameViewModel对象获取更新的属性值,并更新屏幕。![image]()
-
每次猜测后,GameFragment 检查视图模型的 isWon()或 isLost()方法是否返回 true。
如果任一方法返回true,
GameFragment将结果传递给ResultFragment,后者显示结果。![image]()
Test Drive

当我们运行应用程序时,GameFragment会像以前一样显示。如果我们开始玩游戏并旋转屏幕,则游戏保留其状态。

现在,您已经学会了如何向您的应用程序添加视图模型,并使用它来避免用户旋转设备屏幕时可能出现的问题。在继续之前,让我们更深入地了解视图模型。
BE the View Model

以下代码描述了名为 MyViewModel 的视图模型类。你的任务是扮演视图模型,指出此代码存在的问题以及如何修复。
package com.hfad.myapp
import androidx.lifecycle.ViewModel
import android.util.Log
import android.widget.TextView
class MyViewModel {
val num = 2
init {
Log.i("MyViewModel", "ViewModel created")
}
override fun onCleared() {
Log.i("MyViewModel", "ViewModel cleared")
}
fun calculation(val1: Int, val2: Int): Int {
Log.i("MyViewModel", "Called Calculation")
return (val1 + val2) * num
}
fun joinTogether(text1: TextView, text2: TextView): String {
Log.i("MyViewModel", "Called JoinTogether")
return ("${text1.text} ${text2.text}")
}
}
答案在 “BE the View Model Solution”。
我们已为 GameFragment 添加了一个视图模型

到目前为止,我们已更新了猜字游戏应用程序,使其使用名为GameViewModel的视图模型,该模型负责所有片段的业务逻辑和数据。这种方式使用视图模型简化了GameFragment.kt中的代码,并意味着当屏幕旋转时,应用程序不会丢失其状态。

ResultFragment也需要一个视图模型
您创建的每个视图模型都与单个 UI 控制器(活动或片段)相关联。这意味着,如果我们希望ResultFragment也使用视图模型,我们需要为此片段创建一个新的视图模型。

现在我们开始吧。在app/src/main/java文件夹中突出显示com.hfad.guessinggame包,然后转到文件→新建→Kotlin 类/文件。命名文件为“ResultViewModel”,并选择创建类的选项。
创建ResultViewModel.kt文件后,请更新其代码以匹配下面的代码(我们的更改用粗体标出):

这是定义视图模型所需的基本代码。那么我们需要添加哪些其他代码呢?
ResultViewModel 需要保存结果
正如您可能还记得的那样,ResultFragment在其布局中显示一条消息,告诉用户他们刚刚玩的游戏是赢了还是输了。当游戏结束时,GameFragment会将此消息传递给ResultFragment。
在新版本的应用程序中,ResultViewModel负责ResultFragment的游戏逻辑和数据,因此我们需要向ResultViewModel添加一个属性来存储结果。我们还将使用一个String构造函数,以确保此属性在创建ResultViewModel时立即设置。
这是ResultViewModel.kt的完整代码;更新其代码以包括以下更改(以粗体显示):

接下来,我们将更新ResultFragment,以便它使用新的视图模型。
我们需要将 ResultViewModel 链接到 ResultFragment
早些时候,我们能够使用以下代码将GameViewModel引用添加到GameFragment:

这告诉视图模型提供者获取与片段链接的GameViewModel对象,或者如果不存在则创建一个新的对象。
然而,上述方法不能用于向ResultFragment添加ResultViewModel的引用。这是因为它仅适用于没有参数构造函数的视图模型。
该代码适用于GameViewModel,因为我们可以在不传递任何参数的情况下构造它。但是,ResultViewModel类的构造函数需要一个String,因此上述代码不起作用。
视图模型工厂创建视图模型
创建视图模型的另一种方法是将视图模型提供者传递给视图模型工厂:这是一个单独的类,其唯一目的是创建和初始化视图模型。这种方法意味着视图模型提供者无需自己创建视图模型。相反,它使用视图模型工厂。
虽然视图模型工厂可用于任何类型的视图模型,但它们主要用于需要参数的视图模型。这是因为视图模型提供者无法自己传递参数到构造函数:它需要视图模型工厂来完成。
下面是在猜谜游戏应用程序中使用视图模型工厂的方法:
使用视图模型工厂创建一个没有无参构造函数的视图模型。
-
我们将定义一个名为
ResultViewModelFactory的类,ResultFragment将用它来创建一个工厂对象。![image]()
-
ResultFragment将告诉视图模型提供者使用工厂对象。![image]()
-
当视图模型提供者需要一个新的
ResultViewModel对象时,它将使用ResultViewModelFactory。![image]()
让我们继续在猜谜游戏应用中添加一个工厂类。
创建ResultViewModelFactory类
我们将在猜谜游戏应用中添加一个名为ResultViewModelFactory的视图模型工厂类。该类将被视图模型提供者用于创建ResultViewModel对象。
要创建该类,请在app/src/main/java文件夹中突出显示com.hfad.guessinggame包,然后转到“文件”→“新建”→“Kotlin 类/文件”。将文件命名为“ResultViewModelFactory”,并选择创建类。
当ResultViewModelFactory.kt文件被创建后,请更新其代码以匹配我们下面展示的(我们的更改用粗体标注):

正如您所见,ResultViewModelFactory类实现了一个名为ViewModelProvider.Factory的接口,并重写了其create()方法。这将该类转换为视图模型工厂,视图模型提供者可以使用它来创建ResultViewModel对象。
上述代码是我们需要的所有ResultViewModelFactory。让我们看看如何在ResultFragment代码中使用它。
使用工厂创建视图模型
正如我们之前所说,您可以通过将工厂传递给视图模型提供者来使用视图模型工厂来创建视图模型。视图模型提供者决定何时需要一个新的视图模型对象,并在必要时使用工厂来创建一个。
为了让视图模型提供者使用视图模型工厂,创建每个想要的视图模型几乎完全相同的代码。看起来是这样的:

正如您所见,上述代码定义了两个属性:viewModel和viewModelFactory。这些属性在片段的onCreateView()方法中设置。
在onCreateView()中,代码使用从GameFragment传递给它的结果String来创建一个新的ResultViewModelFactory对象。它将工厂传递给视图模型提供者,后者使用它来获取ResultViewModel对象。
现在您知道如何使用工厂将视图模型与片段链接后,让我们更新ResultFragment的代码。

ResultFragment.kt的更新代码
这是ResultFragment的完整代码,请更新ResultFragment.kt文件中的代码,包括下面显示的更改(用粗体标注):


这就是我们需要对ResultFragment进行的所有更改。经过一些问题后,我们将详细说明应用程序运行时发生的情况。
应用程序运行时发生的情况
当应用程序运行时,会发生以下事情:
-
GameFragment 请求 ViewModelProvider 类获取 GameViewModel 的实例。
GameViewModel对象被初始化,并随机选择一个单词。![图片]()
-
GameFragment 与 GameViewModel 对象交互。
GameViewModel对象记录用户做出的任何猜测,并跟踪剩余生命次数。![图片]()
-
每次猜测后,GameFragment 检查视图模型的 isWon()或 isLost()方法是否返回 true。
如果任一方法为true,
GameFragment将导航到ResultFragment,并传递结果。![图片]()
-
ResultFragment 创建 ResultViewModelFactory 对象,并传递结果字符串。
![图片]()
-
ResultFragment 请求 ViewModelProvider 类获取 ResultViewModel 的实例。
ViewModelProvider类发现没有现有的ResultViewModel对象,因此使用ResultViewModelFactory创建一个。ResultViewModel对象的result属性初始化为结果String。![图片]()
-
ResultFragment 从 ResultViewModel 对象获取结果字符串,并在屏幕上显示。
![图片]()
让我们来测试一下这个应用程序。
测试驾驶

运行应用程序时,GameFragment像以前一样显示。
如果我们猜对所有字母或失去所有生命,应用程序将导航到ResultFragment。会显示一条消息告诉我们是否赢了或输了,以及秘密单词是什么。

游戏的行为方式与以前相同,但在幕后,现在使用视图模型处理游戏逻辑和数据。
视图模型磁铁

这是定义名为GiftViewModel的视图模型的代码:
import androidx.lifecycle.ViewModel
class GiftViewModel(budgetFrom: Int, budgetTo: Int) : ViewModel(){
val from = budgetFrom
val to = budgetTo
}
看看你能否组合出用于创建GiftViewModel对象的视图模型工厂类的代码。

视图模型磁铁解决方案

这是定义名为GiftViewModel的视图模型的代码:
import androidx.lifecycle.ViewModel
class GiftViewModel(budgetFrom: Int, budgetTo: Int) : ViewModel(){
val from = budgetFrom
val to = budgetTo
}
看看你能否组合出用于创建GiftViewModel对象的视图模型工厂类的代码。


BE the View Model 解决方案

下面的代码描述了一个名为 MyViewModel 的视图模型类。您的工作是扮演视图模型的角色,并指出代码中存在的问题以及如何修复它们。

您的 Android 工具箱

您已经掌握了第十一章,现在将视图模型添加到您的工具箱中。

第十二章: LiveData:跃入行动

您的代码经常需要对属性值更改做出反应。
例如,如果视图模型属性更改值,片段可能需要做出响应,更新其视图或导航到其他位置。 但是片段如何知道何时更新属性? 在这里,我们将向您介绍LiveData:一种告知感兴趣方何时发生更改的方法。 您将了解有关MutableLiveData的所有信息,以及如何使您的片段观察此类型的属性。 您将发现LiveData 类型如何帮助维护应用程序的完整性。 很快,您将编写比以往更响应更快的应用程序...
重新审视 Guessing Game 应用程序
在上一章中,我们构建了一个 Guessing Game 应用程序,让用户猜测秘密单词中包含哪些字母。 当用户猜对所有字母或生命次数用尽时,游戏结束。
为了防止片段代码变得过于臃肿,并在用户旋转设备屏幕时保持应用程序的状态,我们为应用程序的游戏逻辑和数据使用了视图模型。 GameFragment使用GameViewModel进行逻辑和数据处理,ResultViewModel保存ResultFragment所需的游戏结果:

每个片段显示时,或用户猜测时,片段从其视图模型获取最新值并在屏幕上显示它们。
尽管这种方法有效,但也存在一些缺点。
片段决定何时更新视图
这种方法的缺点是每个片段决定何时从视图模型获取最新的属性值并更新其视图。 有时,这些值不会发生变化。 例如,如果用户猜对了,GameFragment会更新显示的剩余生命次数和错误猜测的文本,即使这些值没有发生变化。

让视图模型在值更改时发出信号
另一种方法是让GameViewModel告知GameFragment其每个属性何时已被更新。 如果片段收到这些更改的通知,它将不再需要自行决定何时从视图模型获取最新的属性值并更新其视图。 相反,它只需要在告知底层属性已被更新后更新其视图。

我们将使用 Android 的LiveData库来实现 Guessing Game 应用程序中的这一更改:这是 Android Jetpack 的一部分。 LiveData 允许视图模型告知感兴趣的各方(如片段和活动),其属性值已被更新。 然后,它们可以通过更新视图或调用其他方法来对这些更改做出反应。

您将通过本章的其余部分了解如何使用 LiveData。 首先,让我们看看我们将采取的更新应用程序的步骤。
这是我们将要做的事情:

以下是我们将要完成的应用程序的步骤:
-
使猜谜游戏应用程序使用实时数据。
我们将更新
GameViewModel,使livesLeft、incorrectGuesses和secretWordDisplay属性使用实时数据。然后,当这些属性的值更改时,我们将使GameFragment更新其视图。![image]()
-
保护 GameViewModel 的属性和方法。
我们将限制对
GameViewModel的属性的访问,以便只有GameViewModel可以更新它们。我们还将确保GameFragment只能访问其工作所需的方法。![image]()
-
添加一个 gameOver 属性。
我们将使
GameViewModel使用一个新的gameOver属性来决定每个游戏何时结束。当此属性的值更改时,GameFragment将导航到ResultFragment。![image]()
在应用程序的build.gradle文件中添加实时数据依赖项
因为我们将使用实时数据,所以我们将首先在应用程序的build.gradle文件中添加实时数据依赖项。
打开 Guessing Game 应用的项目(如果尚未打开),打开文件GuessingGame/app/build.gradle,并在dependencies部分添加以下行(加粗):

在提示时,同步您的更改。
GameViewModel 和 GameFragment 需要使用实时数据
我们希望在猜谜游戏应用程序中使用实时数据,以便当GameViewModel的属性值更改时,通知GameFragment。然后,GameFragment将对这些更改做出反应。
我们将分两个阶段解决这个问题:
-
指定 GameViewModel 属性更改 GameFragment 需要知道的内容。
-
告诉 GameFragment 如何响应每个更改。
现在,我们将专注于GameViewModel的代码更改。
哪些视图模型属性应该使用实时数据?
GameViewModel包括三个属性——secretWordDisplay、incorrectGuesses和livesLeft——这些属性由GameFragment用于更新其视图。我们将指定这三个属性使用实时数据,以便在它们的值更改时通知GameFragment。
你可以通过将其类型更改为**MutableLiveData<Type>**来指定属性使用实时数据,其中Type是属性应该保存的数据类型。例如,当前livesLeft属性被定义为Int类型的代码如下:
var livesLeft = 8
要使属性使用实时数据,您可以将其类型更改为MutableLiveData<Int>,使其看起来像这样:


这指定了livesLeft现在是一个MutableLiveData<Int>,其初始值为 8。
类似地,我们可以使用以下代码定义incorrectGuesses和secretWordDisplay属性

在这里,每个属性的类型都设置为MutableLiveData<String>。例如,incorrectGuesses的值设置为"",而secretWordDisplay的值将在GameViewModel的init块中设置。
这就是您如何定义一个 LiveData 属性。接下来是如何更新其值。
实时数据对象使用一个值属性
当您使用 MutableLiveData 属性时,您使用名为 **value** 的属性来更新它们的值。例如,要使用 deriveSecretWordDisplay() 方法的返回值更新 secretWordDisplay 属性,您不使用以下代码:
secretWordDisplay = deriveSecretWordDisplay()
就像之前做的一样。您可以改用以下代码:
secretWordDisplay.value = deriveSecretWordDisplay()
以这种方式更改 value 属性非常重要,因为这是任何感兴趣的方——在本例中为 GameFragment ——收到任何更改通知的方式。每次更新 secretWordDisplay 的 value 属性时,都会通知 GameFragment,因此它可以通过更新其视图来做出响应。

值属性可能为 null
当您使用实时数据时,还有一件额外的事情要注意:value的类型是可空。这意味着当您在代码中使用实时数据值时,您需要执行空安全检查,否则您的代码将无法编译。
例如,livesLeft 属性是使用以下代码定义的:

此属性的类型是 MutableLiveData<Int>,因此其 value 属性可以接受一个 Int,或者为 null。
由于 value 属性可能为 null,因此我们不能使用以下代码从其值中减去 1:
livesLeft.value--
相反,我们需要使用:

减去 1,直到它不是null为止。
类似地,以下 isLost() 方法不会编译,因为 liveLeft.value 可能为 null:
fun isLost() = livesLeft.value <= 0
我们可以使用 Kotlin 的 Elvis 运算符进行更改,像这样:


使用 val 可以定义 LiveData 属性。
如您已知,您在 Kotlin 中使用 val 和 var 来指定属性是否可以引用新对象。
当我们首次定义每个属性时,我们使用 var 以便可以更新它。例如,我们使用以下代码定义了 livesLeft 属性:
var livesLeft = 8
它初始化 livesLeft,其值为 8 的 Int 对象。每当用户猜测不正确时,我们就从 livesLeft 减去 1,这使它引用一个新的 Int 对象:

使用 LiveData,您更新现有对象的 value 属性,而不是将其替换为另一个对象,并通知感兴趣的方进行此更改。由于对象不再被替换,因此您可以像这样使用 val 而不是 var 来定义属性:

GameViewModel.kt 的完整代码
现在您已经了解了实时数据的工作原理,请更新 GameViewModel,以便其 livesLeft、incorrectGuesses 和 secretWordDisplay 属性都使用实时数据。
这里是完整的代码GameViewModel.kt; 更新代码以包括更改(加粗部分):


这是我们需要为GameViewModel做的一切。接下来,让我们让GameFragment在更新livesLeft、incorrectGuesses和secretWordDisplay属性时作出响应。
该片段观察视图模型属性并对变化作出反应。
通过调用属性的**observe()**方法,你可以使一个片段响应视图模型的MutableLiveData属性中的value变化。例如,如果将以下代码添加到GameFragment中,它将观察视图模型的livesLeft属性,并在其变化时采取行动:

正如你所见,上述代码向observe()方法传递了viewLifecycleOwner和Observer参数。
viewLifecycleOwner指的是片段视图的生命周期。它绑定于片段从其onCreateView()方法创建时开始,到其销毁并调用onDestroyView()为止。

Observer是一个可以接收 LiveData 的类。它与viewLifecycleOwner绑定,因此只有在片段可以访问其视图时才活动,并能接收 LiveData 通知。如果在片段无法访问其 UI 时 LiveData 属性的值发生变化,观察者不会收到通知,因此片段不会作出响应。这样可以防止片段在视图不可用时尝试更新视图,从而导致应用程序崩溃。
注意
它还可以使你的编码生活更轻松,因为你不需要自己检查视图是否可用。使用 LiveData 可以为你处理所有这些。
Observer类接受一个 lambda 参数,该参数指定如何使用属性的新值。例如,在 Guessing Game 应用程序中,我们希望GameFragment每当视图模型的livesLeft属性更新时更新其lives文本,我们可以使用以下代码实现:

这是我们需要知道的一切,以便在视图模型属性值变化时使GameFragment更新其视图。让我们更新它的代码。
GameFragment.kt的完整代码
这是更新后的GameFragment代码;确保GameFragment.kt文件包含下面显示的更改(加粗):


这些是我们需要对GameFragment进行的所有更改,以便它在观察到incorrectGuesses、livesLeft和secretWordDisplay属性的变化时更新其视图。
我们已经在 Guessing Game 应用程序中实现了 LiveData。让我们看看应用程序运行时会发生什么。

ResultViewModel 不需要使用 LiveData,因此我们不需要更新它。
正如你可能记得的,ResultViewModel有一个属性(名为result),当视图模型创建时设置该属性。代码如下所示:
class ResultViewModel(finalResult: String) : ViewModel(){
val result = finalResult
}
正如你所见,result 是用 val 定义的,因此一旦初始化,就不能更新为其他值。 ResultFragment 不需要在 result 更改时被通知,因为一旦设置, **result** 就不能改变。不需要使 ResultFragment 响应任何更改,因为不会有任何更改发生。
让我们一起看看代码运行时会发生什么,然后测试一下这个应用程序。
应用程序运行时会发生什么
应用程序运行时会发生以下事情:
-
GameFragment 请求
ViewModelProvider类获取GameViewModel的实例。初始化
GameViewModel对象,并设置其三个MutableLiveData属性的值 —livesLeft、incorrectGuesses和secretWordDisplay。![图片]()
-
GameFragment 观察
GameViewModel中的livesLeft、incorrectGuesses和secretWordDisplay属性。![图片]()
-
GameFragment 使用其正在观察的属性的值更新其视图。
![图片]()
-
当用户猜对时,
secretWordDisplay的值会更新,并将新值传递给GameFragment。GameFragment通过更新其在屏幕上显示的word视图来响应。![图片]()
-
当用户猜错时,会更新
incorrectGuesses和livesLeft的值,并传递给GameFragment。GameFragment通过更新其视图来响应。![图片]()
-
当
isWon()或isLost()返回true时,GameFragment导航到ResultFragment,并传递结果。ResultFragment显示结果。![图片]()
让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用时,GameFragment与以前一样显示。
当我们猜对时,秘密单词显示会更新。当我们猜错时,生命剩余数会更新,并将我们的猜测添加到错误猜测显示中。
如果我们猜对所有字母或失去所有生命,应用程序会导航到 ResultFragment,显示结果。

游戏的行为方式与以往相同,但在幕后,它使用实时数据做出响应。
Fragments 可以更新 GameViewModel 的属性
到目前为止,我们已经更新了 GameViewModel 和 GameFragment 中的代码,以便使用实时数据。每当 GameViewModel 中的 MutableLiveData 属性的值更新时,GameFragment 会响应并更新其视图。
然而,代码中存在一个小问题。 GameFragment 可以完全访问 GameViewModel 的属性和方法,因此如果希望,它可以不恰当地使用它们。没有阻止 fragment,比如更新 livesLeft 属性为 100,以便用户每次玩游戏时都能做更多猜测并赢得游戏。
为了解决这个问题,我们将限制直接访问 GameViewModel 的属性,以便只有视图模型中的方法才能更新它们。
您可以通过标记为 private 限制对视图模型属性的直接访问,并通过另一个属性的 getter 提供只读访问。
保持私有性
为了保护 GameViewModel 的属性,我们将每个属性标记为 private,以便只有 GameViewModel 中的代码才能更新它们的值。然后,我们将为 GameFragment 需要观察的每个 MutableLiveData 属性暴露一个只读版本。而不是像这样定义 livesLeft 属性的代码:
val livesLeft = MutableLiveData<Int>(8)
我们将使用以下内容:

在这里,_livesLeft 属性保存对 MutableLiveData 对象的引用。GameFragment 无法访问此属性,因为它标记为 private。
GameFragment 可以通过 livesLeft 的 getter 访问该属性的值。livesLeft 的类型是 **LiveData**,类似于 MutableLiveData,但不能用于更新底层对象的 value 属性:GameFragment 可以读取该值,但不能更新它。
当你以这种方式结构化你的代码时,私有属性有时被称为 backing property。它保存了对其他类仅通过另一个属性才能访问的对象的引用。
让我们更新 GameViewModel 代码。

GameViewModel.kt 的完整代码

我们将更新 GameViewModel 的代码,使其使用后备属性限制对其 LiveData 属性的直接访问。我们还将标记为 private GameFragment 不需要使用的任何属性和方法。
这是 GameViewModel.kt 的完整代码;更新代码以包含我们的更改(加粗部分):


让我们来看看代码运行时会发生什么。
应用程序运行时会发生什么
应用程序运行时会发生以下事情:
-
GameFragment 请求 ViewModelProvider 类的一个 GameViewModel 实例。
![image]()
-
GameViewModel 的属性已初始化。
livesLeft、incorrectGuesses和secretWordDisplay是LiveData属性,它们引用与其MutableLiveData后备属性相同的底层对象。![image]()
-
GameFragment 观察 livesLeft、incorrectGuesses 和 secretWordDisplay 属性。
GameFragment无法更新这些属性中的任何一个,但当GameViewModel更新任何后备属性时,它会做出响应,因为它们引用相同的底层对象。![image]()
-
GameFragment 继续响应值更改,直到 isWon() 或 isLost() 返回 true。
GameFragment导航到ResultFragment,将结果传递给它。ResultFragment显示结果。![image]()
让我们测试应用程序。
测试驾驶

当我们运行应用程序时,它的工作方式与以前相同。但是,这一次,我们通过限制GameFragment对GameViewModel的MutableLiveData属性的访问来保护它们。

我们几乎完成了更新猜谜游戏应用程序。只剩下一件事需要改变...
GameFragment 仍包含游戏逻辑

在应用程序的当前版本中,GameFragment通过在用户每次猜测后调用GameViewModel的isWon()和isLost()方法来决定游戏何时结束。如果其中任何一个返回true,GameFragment将导航到ResultFragment,并将结果传递给它。
这是当前的代码:

这种方法的问题在于GameFragment决定游戏何时结束,而不是GameViewModel。确定游戏何时结束是一个游戏决策,这是GameViewModel应该负责的,而不是GameFragment。
让 GameViewModel 决定游戏何时结束
为了解决这个问题,我们将向GameViewModel添加一个名为_gameOver的MutableLiveData<Boolean>属性,我们将使用名为gameOver的LiveData属性公开其值。当用户赢得或输掉游戏时,我们将将此属性设置为true。当这种情况发生时,GameFragment将通过导航到ResultFragment做出响应。

你已经熟悉了必须进行的更改,因此在向你展示代码之前,请尝试以下练习。
池谜题

你的目标是向GameViewModel添加一个gameOver属性(带有一个_gameOver支持属性),以便GameFragment在其值(一个Boolean)更新时做出响应。该属性应初始化为false。从池中提取代码片段,并将它们放入代码中的空白行中。你不能多次使用相同的片段,也不需要使用所有的片段。
package com.hfad.guessinggame
import androidx.lifecycle.ViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LiveData
class GameViewModel : ViewModel() {
...
............................................................................................
............................................................................................
............................................................................................
...
}

注意
注意:池中的每个元素只能使用一次!
池谜题解决方案

你的目标是向GameViewModel添加一个gameOver属性(带有一个_gameOver支持属性),以便GameFragment在其值(一个Boolean)更新时做出响应。该属性应初始化为false。从池中提取代码片段,并将它们放入代码中的空白行中。你不能多次使用相同的片段,也不需要使用所有的片段。


GameViewModel.kt 的完整代码
我们需要在GameViewModel中添加一个gameOver属性,以及一个_gameOver支持属性。当用户猜对了秘密单词中的所有字母,或者耗尽生命时,我们将让makeGuess()方法将其设置为true。
这里是GameViewModel的完整代码;更新GameViewModel.kt中的代码,以包含下面显示的更改(用粗体标出):


让GameFragment观察新属性
现在我们已经添加了gameOver属性到GameViewModel,我们需要让GameFragment响应其更新。我们将使碎片观察该属性,因此当其值更改为true时,该碎片将导航到ResultFragment。
这里是GameFragment的代码;更新GameFragment.kt中的代码,以包含以下更改(用粗体标出):


就是这样!让我们看看运行时会发生什么。
应用程序运行时会发生什么?
应用程序运行时会发生以下事情:
-
GameFragment 请求 ViewModelProvider 类的一个 GameViewModel 实例。
![image]()
-
初始化了
GameViewModel的属性。_gameOver和gameOver属性引用的是一个MutableLiveData<Boolean>对象,其值被设置为false。![image]()
-
GameFragment 观察 GameViewModel 的 gameOver 属性。
GameFragment无法更新gameOver属性引用的MutableLiveData对象,但它可以响应其值的变化。![image]()
-
每次调用 GameViewModel 的 makeGuess()方法时,它都会检查 isWon()或 isLost()是否返回 true。
如果任何一个是true,它将其
_gameOver属性的值设置为true。![image]()
-
GameFragment 通过 GameViewModel 的 gameOver 属性观察到值已更新为 true。
新值将传递给
GameFragment。![image]()
-
GameFragment 响应并导航到 ResultFragment,并将结果传递给它。
![image]()
在以下练习后,我们将带着这个应用程序试驾。
碎片磁铁

有人在冰箱门上写了一个名为LotteryFragment的碎片的代码,但是一场疯狂的厨房暴风雪把一些代码吹走了。你能把它重新拼起来吗?
该碎片需要观察LotteryViewModel的winningNumbers属性,其定义如下:
private val _winningNumbers = MutableLiveData<String>()
val winningNumbers: LiveData<String>
get() = _winningNumbers
当winningNumbers发生变化时,LotteryFragment需要使用新值更新其numbers视图。

碎片磁铁解决方案

有人在冰箱门上写了一个名为LotteryFragment的碎片的代码,但是一场疯狂的厨房暴风雪把一些代码吹走了。你能把它重新拼起来吗?
该碎片需要观察LotteryViewModel的winningNumbers属性,其定义如下:
private val _winningNumbers = MutableLiveData<String>()
val winningNumbers: LiveData<String>
get() = _winningNumbers
当winningNumbers发生变化时,LotteryFragment需要使用新值更新其numbers视图。

试驾

当我们运行应用程序时,它的工作方式与以前相同。然而,这一次,GameViewModel决定游戏何时结束,而不是GameFragment。片段只需观察视图模型的gameOver属性,并在其更改为true时导航到ResultFragment。

恭喜!你现在已经构建了一个应用程序,可以使用实时数据来响应随时发生的更改。在下一章中,我们将进一步利用一种称为数据绑定的新技术。
你的安卓工具箱

你已经掌握了第十二章,现在你已经将实时数据添加到你的工具箱中。

第十二章:LiveData:行动的跃进

您的代码经常需要对属性值变化作出反应。
例如,如果视图模型属性的值发生变化,片段可能需要响应通过更新其视图或导航到其他位置。但是,片段如何知道属性何时被更新?在这里,我们将向您介绍LiveData:一种告知感兴趣方何时发生变化的方式。您将了解有关MutableLiveData的所有内容,以及如何使您的片段观察此类属性。您将发现LiveData类型如何帮助维护应用程序的完整性。很快,您将编写比以往更具响应性的应用程序...
重新审视猜字游戏应用
在上一章中,我们构建了一个猜字游戏应用,让用户猜测哪些字母包含在一个秘密单词中。当用户猜测所有字母或生命用尽时,游戏结束。
为了防止片段代码变得过于臃肿,并在用户旋转设备屏幕时保持应用程序状态,我们为应用程序的游戏逻辑和数据使用了视图模型。GameFragment使用GameViewModel处理其逻辑和数据,而ResultViewModel则保存了ResultFragment所需的游戏结果:

每当显示每个片段或用户进行猜测时,片段从其视图模型获取最新值并将其显示在屏幕上。
虽然这种方法有效,但也存在一些缺点。
片段决定何时更新视图
这种方法的缺点在于,每个片段决定何时从视图模型获取最新的属性值并更新其视图。而有时这些值并没有改变。例如,如果用户猜对了,GameFragment会更新显示剩余生命和错误猜测的文本,即使这些值并没有改变。

让视图模型在数值变更时发出通知
另一种方法是让GameViewModel告诉GameFragment每个属性何时被更新。如果片段收到这些更改的通知,它将不再需要自行决定何时从视图模型获取最新的属性值并更新其视图。相反,它只需在告知底层属性已更新后更新其视图。

我们将使用 Android 的LiveData库在猜字游戏应用中实现这一变更:它是 Android Jetpack 的一部分。LiveData 允许视图模型通知感兴趣的方(如片段和活动),其属性值已被更新。它们可以通过更新视图或调用其他方法来对这些更改作出响应。

您将在本章的其余部分了解如何使用 LiveData。首先,让我们浏览一下更新应用程序的步骤。
下面是我们要做的事情

以下是我们将执行的应用程序编写步骤:
-
使猜谜游戏应用程序使用 LiveData。
我们将更新
GameViewModel,使livesLeft、incorrectGuesses和secretWordDisplay属性使用 LiveData。然后,当这些属性的值更改时,我们将使GameFragment更新其视图。![image]()
-
保护 GameViewModel 的属性和方法。
我们将限制对
GameViewModel属性的访问,以便只有GameViewModel可以更新它们。我们还将确保GameFragment只能访问其执行工作所需的方法。![image]()
-
添加一个 gameOver 属性。
我们将使
GameViewModel使用一个新的gameOver属性来决定每场游戏何时结束。当此属性的值更改时,GameFragment将导航到ResultFragment。![image]()
向应用的 build.gradle 文件添加 LiveData 依赖项
因为我们将使用 LiveData,所以我们将首先向应用程序的 build.gradle 文件添加 LiveData 依赖项。
打开猜谜游戏应用程序的项目(如果尚未打开),打开文件 GuessingGame/app/build.gradle,并在 dependencies 部分添加以下行(加粗):

在提示时,同步您的更改。
GameViewModel 和 GameFragment 需要使用 LiveData
我们希望在猜谜游戏应用程序中使用 LiveData,这样 GameViewModel 在其属性值更改时会通知 GameFragment。然后,GameFragment 将对这些更改做出反应。
我们将分两个阶段来解决这个问题:
-
指定 GameFragment 需要了解哪些 GameViewModel 属性更改。
-
告诉 GameFragment 如何响应每个更改。
现在,我们将专注于 GameViewModel 的代码更改。
哪些视图模型属性应该使用 LiveData?
GameViewModel 包括三个属性——secretWordDisplay、incorrectGuesses 和 livesLeft——GameFragment 使用这些属性来更新其视图。我们将指定这三个属性使用 LiveData,以便在它们的值更改时通知 GameFragment。
您通过将其类型更改为 **MutableLiveData<Type>** 来指定属性使用 LiveData,其中 Type 是属性应持有的数据类型。例如,livesLeft 属性当前使用以下代码定义为 Int:
<data>
<variable
name="resultViewModel"
type="com.hfad.guessinggame.ResultViewModel" />
</data>
要使属性使用 LiveData,您需要将其类型更改为 MutableLiveData<Int>,使其看起来像这样:


这指定了 livesLeft 现在是具有初始值 8 的 MutableLiveData<Int>。
同样,我们可以使用以下代码定义 incorrectGuesses 和 secretWordDisplay 属性

在这里,每个属性的类型都设置为 MutableLiveData<String>。incorrectGuesses 的值设置为 "",而 secretWordDisplay 的值将在 GameViewModel 的 init 块中设置。
这就是您如何定义实时数据属性。接下来,我们来看如何更新其值。
实时数据对象使用一个 value 属性
当您使用MutableLiveData属性时,您使用名为**value**的属性来更新它们的值。例如,要使用deriveSecretWordDisplay()方法的返回值更新secretWordDisplay属性,您不使用以下代码:
<string name="lives_left">You have %d lives left</string>
就像以前一样。您改用以下代码:
<string name="incorrect_guesses">Incorrect guesses: %s</string>
以这种方式更改value属性很重要,因为这是任何感兴趣的方面(在本例中是GameFragment)被通知任何更改的方式。每当secretWordDisplay的value属性更新时,GameFragment都会被通知,以便它可以通过更新其视图来做出响应。

value 属性可以为 null
当您使用实时数据时,还有一件额外的事情需要注意:value的类型是可空的。这意味着当您在代码中使用实时数据值时,您需要执行空安全检查,否则您的代码将无法编译。
例如,livesLeft属性是使用以下代码定义的:

该属性的类型是MutableLiveData<Int>,因此其value属性可以接受一个Int,或者为null。
由于value属性可能为null,我们不能使用以下代码从其值中减去 1:
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
相反,我们需要使用:

它从其值中减去 1,只要它不为null。
类似地,以下isLost()方法不会编译,因为liveLeft.value可能为null:
binding.gameViewModel = viewModel
但是,我们可以将其更改为使用 Kotlin 的 Elvis 运算符,如下所示:


实时数据属性可以使用 val 来定义。
正如您已经知道的,您在 Kotlin 中使用val和var来指定属性是否可以引用新对象。
当我们首次定义每个属性时,我们使用var以便可以更新它。例如,我们使用以下方式定义livesLeft属性:
viewModel.incorrectGuesses.observe(viewLifecycleOwner, Observer { newValue ->
binding.incorrectGuesses.text = "Incorrect guesses: $newValue"
})
它使用一个值为 8 的Int对象初始化了livesLeft。每当用户猜错时,我们从livesLeft中减去 1,这使其引用了一个新的Int对象:

使用实时数据,您更新现有对象的value属性,而不是用另一个对象替换它,并且感兴趣的方面会被通知到这一更改。由于对象不再被替换,您可以像这样使用val而不是var来定义属性:

GameViewModel.kt 的完整代码
现在您知道实时数据是如何工作的,让我们更新GameViewModel,使其livesLeft、incorrectGuesses和secretWordDisplay属性都使用实时数据。
这是GameViewModel.kt的完整代码;更新代码以包含更改(用粗体标出):


这就是我们需要为 GameViewModel 做的一切。接下来,让我们在 GameFragment 中响应 livesLeft、incorrectGuesses 和 secretWordDisplay 属性更新时的情况。
片段观察视图模型属性并对更改作出反应。
通过调用属性的 **observe()** 方法,可以使片段响应视图模型的 MutableLiveData 属性中 value 的更改。例如,如果将以下代码添加到 GameFragment 中,它将观察视图模型的 livesLeft 属性,并在其更改时采取行动:

正如您所见,上述代码将 viewLifecycleOwner 和 Observer 参数传递给 observe() 方法。
viewLifecycleOwner 引用片段视图的生命周期。它与片段访问其 UI 的时间相关联:从在片段的 onCreateView() 方法中创建时,到销毁时调用 onDestroyView() 方法。

Observer 是一个能够接收实时数据的类。它与 viewLifecycleOwner 相关联,因此仅在片段可以访问其视图时才活跃,并能接收实时数据通知。如果在片段无法访问其 UI 时实时数据属性的值发生更改,则不会通知观察者,因此片段不会做出响应。这可以防止应用在试图更新不可用视图时崩溃。
注意
它还可以让您的编码生活更轻松,因为您无需自行检查视图是否可用。使用实时数据,这一切都由它处理。
Observer 类接受一个 lambda 参数,指定属性的新值应如何使用。例如,在猜词游戏应用中,我们希望 GameFragment 每当视图模型的 livesLeft 属性更新时更新其 lives 文本,我们可以通过以下代码实现:

这就是我们需要知道的一切,以便在视图模型属性值更改时使 GameFragment 更新其视图。让我们更新其代码。
GameFragment.kt 的完整代码
这是 GameFragment 的更新代码;确保 GameFragment.kt 文件包含下面显示的更改(用粗体显示):


这些是我们需要对 GameFragment 进行的所有更改,以便在观察到 incorrectGuesses、livesLeft 和 secretWordDisplay 属性更改时更新其视图。
现在我们已经在猜词游戏应用中实现了实时数据,让我们看看应用运行时会发生什么。

ResultViewModel 不需要使用实时数据,因此我们无需更新它。
如您可能记得的那样,ResultViewModel 有一个属性(名为 result),该属性在视图模型创建时设置。代码如下所示:
binding.lifecycleOwner = viewLifecycleOwner
正如您所见,结果是使用 val 定义的,因此一旦初始化,就无法将其更新为另一个值。ResultFragment 在 result 更改时无需通知,因为 一旦设置,**result** 就无法更改。无需使 ResultFragment 响应任何更改,因为不会有任何更改。
让我们逐步了解代码运行时发生了什么,然后进行一次应用程序的测试驾驶。
应用程序运行时会发生什么
应用程序运行时会发生以下情况:
-
GameFragment 请求
ViewModelProvider类获取GameViewModel的实例。GameViewModel对象被初始化,并设置了其三个MutableLiveData属性的值 —livesLeft、incorrectGuesses和secretWordDisplay。![image]()
-
GameFragment 观察
GameViewModel中的livesLeft、incorrectGuesses和secretWordDisplay属性。![image]()
-
GameFragment 使用其观察的属性的值更新其视图。
![image]()
-
当用户猜对时,将更新
secretWordDisplay的值,并将新值传递给GameFragment。GameFragment通过更新其在屏幕上显示的word视图做出响应。![image]()
-
当用户猜错时,更新
incorrectGuesses和livesLeft的值,并传递给GameFragment。GameFragment通过更新其视图做出响应。![image]()
-
当
isWon()或isLost()返回 true 时,GameFragment导航到ResultFragment,并将结果传递给它。ResultFragment显示结果。![image]()
让我们带着这个应用程序来进行一次测试驾驶。
测试驾驶
运行应用程序时,GameFragment 如以前一样显示。
当我们猜对时,秘密单词显示会更新。当我们猜错时,生命剩余将更新,并且我们的猜测将添加到错误的猜测显示中。
如果我们猜中所有字母或失去所有生命,应用程序将导航到 ResultFragment,显示结果。

游戏的行为方式与以往相同,但在幕后,它使用实时数据做出响应。
Fragment 可以更新 GameViewModel 的属性。
到目前为止,我们已经更新了 GameViewModel 和 GameFragment 中的代码,以便它使用实时数据。每当 GameViewModel 中的 MutableLiveData 属性的值更新时,GameFragment 通过更新其视图做出响应。
但代码中存在一个小问题。GameFragment 具有对 GameViewModel 属性和方法的完全访问权限,因此如果它愿意,它可以不适当地使用它们。没有什么可以阻止 fragment,例如将 livesLeft 属性更新为 100,以便用户可以每次玩游戏时都进行许多猜测并赢得游戏。
为了解决这个问题,我们将限制对 GameViewModel 属性的直接访问,使得只有视图模型中的方法可以更新它们。
您可以通过将视图模型的属性标记为私有,并通过另一个属性的 getter 提供只读访问来限制对视图模型属性的直接访问。
保持私密内容私密
为了保护 GameViewModel 的属性,我们将每个属性标记为 private,以便只有 GameViewModel 中的代码可以更新它们的值。然后,我们将暴露每个 MutableLiveData 属性的只读版本,供 GameFragment 观察。而不是像这样定义 livesLeft 属性的代码:
android:onClick="@{() -> gameViewModel.finishGame()}"
我们将使用以下内容:

在这里,_livesLeft 属性持有对 MutableLiveData 对象的引用。 GameFragment 无法访问此属性,因为它标记为 private。
但是,GameFragment 可以通过 livesLeft 的 getter 访问此属性的值。 livesLeft 的类型是 **LiveData**,类似于 MutableLiveData,但无法用于更新底层对象的 value 属性:GamesFragment 可以读取值,但无法更新它。
当您以这种方式组织代码时,私有属性有时称为支持属性。它持有一个对象的引用,其他类只能通过另一个属性访问该对象。
让我们更新 GameViewModel 的代码。

GameViewModel.kt 的完整代码如下:

我们将更新 GameViewModel 的代码,以使用支持属性来限制直接访问其 LiveData 属性。我们还将将 GameFragment 不需要使用的属性和方法标记为私有。
GameViewModel.kt 的完整代码如下;更新代码以包括我们的更改(用粗体标出):


让我们逐步了解代码运行时发生了什么。
当应用程序运行时会发生什么
当应用程序运行时会发生以下事情:
-
GameFragment 请求
ViewModelProvider类获取GameViewModel实例。![image]()
-
GameViewModel 的属性已初始化。
livesLeft、incorrectGuesses和secretWordDisplay是指向与其MutableLiveData支持属性相同底层对象的LiveData属性。![image]()
-
GameFragment 观察
livesLeft、incorrectGuesses和secretWordDisplay属性。GameFragment无法更新任何这些属性,但在GameViewModel更新任何支持属性时会作出响应,因为它们引用相同的底层对象。![image]()
-
GameFragment 在
isWon()或isLost()返回 true 之前继续响应值的更改。GameFragment导航到ResultFragment,并将结果传递给它。ResultFragment显示结果。![image]()
让我们来测试这个应用程序。
测试驾驶

当我们运行应用程序时,它的工作方式与以前相同。但是,这一次我们通过限制 GameFragment 对 GameViewModel 的 MutableLiveData 属性的访问来保护了它们。

我们几乎完成了更新猜词游戏应用程序的工作。只剩下一件事需要改变…
GameFragment 仍然包含游戏逻辑

在应用的当前版本中,GameFragment 在用户每次猜测后调用 GameViewModel 的 isWon() 和 isLost() 方法来决定游戏是否结束。如果其中任何一个返回 true,GameFragment 将导航到 ResultFragment,并将结果传递给它。
下面是当前的代码:

这种方法的问题在于 GameFragment 决定游戏何时结束,而不是 GameViewModel。确定游戏何时结束是一项游戏决策,这是 GameViewModel 应该负责的,而不是 GameFragment。
让 GameViewModel 决定游戏何时结束
为了解决这个问题,我们将在 GameViewModel 中添加一个名为 _gameOver 的 MutableLiveData<Boolean> 属性,并将其值通过名为 gameOver 的 LiveData 属性暴露出来。当用户赢得或输掉游戏时,我们将设置此属性为 true。在这种情况下,GameFragment 将响应并导航到 ResultFragment。

您已经熟悉所需的更改,因此在向您显示代码之前,请尝试以下练习。
池塘难题

您的目标是向 GameViewModel 添加一个名为 gameOver 的属性(带有 _gameOver 支持属性),以便 GameFragment 在其值(一个 Boolean)更新时作出响应。该属性应初始化为 false。从池中获取代码片段并将其放入代码中的空白行。您不可以多次使用同一代码片段,并且不需要使用所有代码片段。
binding.finishGameButton.setOnClickListener() {
viewModel.finishGame()
}

注意
注意:每个池中的事物只能使用一次!
池塘难题解答

您的目标是向 GameViewModel 添加一个名为 gameOver 的属性(带有 _gameOver 支持属性),以便 GameFragment 在其值(一个 Boolean)更新时作出响应。该属性应初始化为 false。从池中获取代码片段并将其放入代码中的空白行。您不可以多次使用同一代码片段,并且不需要使用所有代码片段。


GameViewModel.kt 的完整代码
我们需要在 GameViewModel 中添加一个名为 gameOver 的属性,以及一个名为 _gameOver 的支持属性。当用户猜对了秘密单词中的所有字母或者耗尽生命时,我们将让 makeGuess() 方法将其设置为 true。
这是GameViewModel的完整代码;更新GameViewModel.kt中的代码以包括下面显示的更改(用粗体表示):


使 GameFragment 观察新属性
现在我们已经将gameOver属性添加到GameViewModel中,我们需要使GameFragment响应其更新。我们将使片段观察该属性,因此当其值更改为true时,片段将导航到ResultFragment。
这是GameFragment的代码;更新GameFragment.kt中的代码,以包括以下更改(用粗体表示):


就是这样!让我们来看看运行时会发生什么。
应用程序运行时发生了什么
应用程序运行时会发生以下事情:
-
GameFragment 请求 ViewModelProvider 类提供 GameViewModel 的实例。
![image]()
-
GameViewModel 的属性已初始化。
_gameOver和gameOver属性指向一个MutableLiveData<Boolean>对象,其值设置为false。![image]()
-
GameFragment 观察 GameViewModel 的 gameOver 属性。
GameFragment无法更新gameOver属性引用的MutableLiveData对象,但可以响应其值变化。![image]()
-
每次调用 GameViewModel 的 makeGuess()方法时,它都会检查 isWon()或 isLost()是否返回 true。
如果其中任何一个true,它将其
_gameOver属性的值设置为true。![image]()
-
GameFragment 观察到通过 GameViewModel 的 gameOver 属性,值已更新为 true。
新值传递给
GameFragment。![image]()
-
GameFragment 响应并导航到 ResultFragment,并将结果传递给它。
![image]()
在进行以下练习后,我们将测试驾驶这个应用程序。
片段磁铁

有人在冰箱门上为名为LotteryFragment的片段编写了代码,但一个怪异的厨房暴风雪把一些代码吹走了。你能把它重新拼起来吗?
片段需要观察LotteryViewModel的winningNumbers属性,其定义如下:
android:onClick="@{() -> gameViewModel.finishGame()}"
当winningNumbers变化时,LotteryFragment需要使用新值更新其numbers视图。

片段磁铁解决方案

有人在冰箱门上为名为LotteryFragment的片段编写了代码,但一个怪异的厨房暴风雪把一些代码吹走了。你能把它重新拼起来吗?
片段需要观察LotteryViewModel的winningNumbers属性,其定义如下:
"@{() -> gameViewModel.finishGame()}"
当winningNumbers变化时,LotteryFragment需要使用新值更新其numbers视图。

测试驾驶

当我们运行应用时,它的工作方式与以往相同。然而,这一次,GameViewModel决定游戏何时结束,而不是GameFragment。该片段只需观察视图模型的gameOver属性,并在其更改为true时导航到ResultFragment。

恭喜!您现在已经构建了一个应用程序,使用实时数据在发生更改时进行响应。在下一章中,我们将进一步使用一种称为数据绑定的新技术。
您的 Android 工具箱

您已经掌握了第十二章,现在您的工具箱中添加了实时数据。

附录 A. 遗留问题:十大未覆盖的事项

即使做了这些,还有更多内容。
我们认为还有一些事情是您需要了解的。如果我们忽略了它们,我们会觉得不对,我们真的希望给您一本书,您无需在当地健身房接受大量培训就可以抬起它。在您放下书之前,请仔细阅读这些信息。
1. 与其他应用程序共享数据
如果您想要与另一个应用程序共享简单数据,可以使用一个 **Intent**。您可以将 Intent 理解为“执行某项操作的意图”。它是一种消息类型,允许您在运行时将数据发送到另一个对象(例如一个活动)。
使用 Android 意图解析器共享数据
如果您想将文本传递给另一个活动,可以使用以下代码:
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, "This is some text.")
type = "text/plain"
}
startActivity(sendIntent)
代码首先创建一个名为 sendIntent 的 Intent。它使用 type 属性来指定正在发送的数据类型(在本例中是纯文本),并使用 putExtra() 来附加数据(这里是一些文本)。

action 属性告诉 Android 应用程序可以接收的活动类型。在这里,它使用以下设置:
action = Intent.ACTION_SEND
这意味着只有能够发送消息的活动才能接收到该意图。
通过以下行发送意图:
startActivity(sendIntent)
在幕后,Android 搜索所有设备应用程序中可以接受具有指定动作和类型的意图的活动。如果找到多个这样的活动,它会显示一个 意图解析器 屏幕,让用户选择要与之共享数据的应用程序。

然后 Android 启动该活动,并将数据传递给它。
使用 Android Sharesheet 进行数据共享
大多数情况下,您将希望使用 Android Sharesheet 来共享数据。这样可以指定您希望与谁以及如何共享数据,并且代码看起来像这样:

正如您所见,代码包含对 Intent 的 createChooser() 方法的额外调用。运行此方法时,它会显示 Android Sharesheet,如下所示:

想要了解如何与其他应用程序共享数据,并使您的应用程序接收数据,请参阅:
developer.android.com/training/sharing
2. WorkManager
有时您希望您的应用程序在后台处理数据。这可能是因为它需要访问存储空间,例如,或者它需要下载一个大文件。
正如您在第十四章中学到的,您可以使用 Kotlin 协程来执行需要立即执行的任务。但是如果您希望推迟任务执行或者有一个需要长时间运行的任务,即使设备重新启动也要继续运行,该怎么办?
使用 WorkManager 安排可推迟的任务
如果您想要安排后台运行的任务,可以使用 Android 的WorkManager API。它是 Android Jetpack 的一部分,专为即使用户退出应用程序或重新启动设备也保证运行的可能长时间运行的任务而设计。
甚至可以使用 WorkManager 在满足某些约束条件时运行任务,例如在 WiFi 可用时,或将复杂任务链接在一起。

你可以在这里找到更多关于 WorkManager 及其使用方法的信息:
developer.android.com/topic/libraries/architecture/workmanager
3. 对话框和通知
在第九章中,您学习了如何使用 toast 和 snackbar 向用户显示简单的弹出消息。这些对于在应用程序中显示低优先级消息并且不需要任何用户操作的消息非常有用。
然而,在需要显示提示用户做出决定或显示在应用程序 UI 之外的消息的时候,您可以使用对话框和通知。
使用对话框提示用户做决定
对话框是出现在屏幕中间的小窗口。它们通常用于用户必须在应用程序可以继续之前做出决定的情况:

您可以在这里找到如何创建和使用对话框的信息:
developer.android.com/guide/topics/ui/dialogs
通知出现在应用程序 UI 之外
如果您希望提醒用户做某事,或者告诉他们何时收到消息,可以使用通知。通知会出现在设备状态栏和通知抽屉上,也可能出现在设备锁定屏幕上:

您可以在这里找到如何创建和使用通知的信息:
developer.android.com/guide/topics/ui/notifiers/notifications
4. 自动化测试
如果您创建的应用程序预计将被数千甚至数百万人使用,如果应用程序不稳定或频繁崩溃,您将很快失去用户。但是,您可以使用自动化测试来预防许多这些问题。
两个流行的测试框架是 JUnit 和 Espresso。创建新的 Android 项目时,Android Studio 通常会在应用程序的build.gradle文件中包含这些依赖项。
自动化测试通常分为两类:单元测试和仪器化测试。
单元测试
单元测试在开发机器上运行,它们检查代码的各个部分或单元。它们位于项目的app/src/test文件夹中,看起来像这样:
package com.hfad.myapp
import org.junit.Test
import org.junit.Assert.*
class ExampleUnitTest {
@Test
fun additionIsCorrect() {
assertEquals(6, 3 + 3)
}
}
仪器化测试
仪器化测试或设备上测试在模拟器或物理设备内运行,并检查完全组装的应用程序。它们位于项目的app/src/androidTest文件夹中。
我们将在下一页上向您展示一个仪表化测试的示例。
仪表化测试示例
这里是一个仪表化测试的示例,检查一个可组合对象是否显示正确的文本:
package com.hfad.myapp
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HelloTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldShowHello() {
composeTestRule.setContent {
MaterialTheme {
Surface {
Hello("Fred")
}
}
}
composeTestRule.onNodeWithText("Hello Fred!").assertExists()
}
}
点击这里了解有关在 Android 应用程序中使用自动化测试的更多信息:
developer.android.com/training/testing
5. 支持不同的屏幕尺寸
Android 应用程序具有各种形状和大小,您希望您的应用程序在所有这些设备上都看起来很好。您可以使用几种技术,包括约束布局(您在第四章中了解过的内容)、提供备用布局和滑动窗格布局。
提供备用布局
Android 应用程序可以具有多个版本的同一布局文件,以适应不同的屏幕规格。通过创建多个具有适当名称的布局文件夹,并向每个文件夹添加单独的布局文件来实现此目的。
要为宽度达到 600dp 或以上的设备(例如 7 英寸平板电脑)设计一个布局,并为较小的设备设计另一个布局,请在app/src/main/res文件夹中添加一个名为layout-sw600dp的额外文件夹。然后,在layout-sw600dp文件夹中放置一个供较宽设备使用的布局,在layout文件夹中放置一个供较小设备使用的布局。
当应用程序在手机上运行时,它将像往常一样使用layout文件夹中的布局,并在更宽的设备上使用layout-sw600dp文件夹中的布局。

您可以在这里了解更多有关使用宽度限定符的信息:
developer.android.com/training/multiscreen/screensizes#alternative-layouts
使用 SlidingPaneLayout
一些布局包括一个可导航的记录列表,点击其中一项会显示其详细信息。在小型设备上,您可能希望详细信息显示在单独的屏幕上,但在大型设备上,您可能更喜欢并排显示列表和详细信息。
要处理这种情况,您可以使用SlidingPaneLayout定义列表和详细信息的分开窗格。代码如下所示:

布局使用每个窗格的layout-width属性来确定它们是否能够在设备上并排显示。如果设备足够宽,布局会将它们显示在一起,否则会在不同屏幕上显示它们。
您可以在这里找到更多信息:
developer.android.com/reference/androidx/slidingpanelayout/widget/SlidingPaneLayout
6. 更多 Compose 功能
在第十八章和第十九章中,我们向您介绍了使用 Compose 构建 UI。我们认为它有很大的发展前景,但由于其某些库在撰写时尚未稳定,因此有点新。
我们不想在不给您展示一些我们最期待的库和功能的情况下离开。
Compose 视图模型库
在第十九章中,我们将现有的视图模型传递给可组合项,以便它可以访问视图模型的属性和方法。如果使用 Compose 视图模型库,则不再需要此操作;可组合项可以自行获取视图模型。
将以下依赖项添加到应用的build.gradle文件中:
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07"
然后在可组合项中添加如下代码:

Compose 约束布局库
在第十八章中,我们向您展示了如何在行和列中排列可组合项。但是,如果您需要更灵活的内容,还有 Compose 约束布局库。正如您可能期望的那样,此库允许您使用约束来定位可组合项。
您可以在这里找到有关此库及其使用方法的更多信息:
developer.android.com/jetpack/compose/layouts/constraintlayout
Compose 导航组件
正如您已经知道的那样,在基于View的 UI 中使用导航组件进行屏幕之间的导航。您为每个屏幕定义一个单独的片段,导航组件决定显示哪个片段。

当您使用 Compose 导航组件时,不再需要使用片段。相反,您为每个屏幕定义一个可组合项,导航组件决定显示哪一个。
要使用 Compose 导航组件,请将以下依赖项添加到应用的build.gradle文件中:
implementation("androidx.navigation:navigation-compose:2.4.0-alpha08")
然后为您的活动使用以下代码:


当应用程序运行时,看起来像这样:

您可以在这里跟上 Compose 的最新动态:
developer.android.com/jetpack/compose
7. Retrofit
在第十四章中,您学习了如何在设备上持久保存数据在 Room 数据库中。但如果您的数据是存储在远程位置呢?
在这种情况下,您可以使用 Retrofit。这是一个第三方的 Android、Java 和 Kotlin REST 客户端,允许您发出网络请求,与 REST API 交互,并下载 JSON 或 XML 数据。
您可以在这里了解更多有关 Retrofit 的信息:
您还可以在这里找到有关如何在应用程序架构中包含 Retrofit 的更多信息:
developer.android.com/jetpack/guide
8. Android 游戏开发工具包
如果您对开发 Android 应用程序的游戏感兴趣,我们建议您查看 Android 游戏开发工具包(AGDK)。这是一组库和工具,让您可以开发、优化和交付 Android 游戏。
Android 游戏开发工具包包括用于 C/C++游戏开发的游戏库。例如,有一个GameActivity,它继承自AppCompatActivity并使用 C API。还有一种方法可以在 C 中使用软件键盘,并处理来自游戏控制器的输入。
注意
[市场营销提示:确保提及您的书《Head First C》。]
您可以在此处阅读更多关于 Android 游戏开发工具包的信息:
developer.android.com/games/agdk
9. CameraX
如果您希望在应用程序中使用设备摄像头,有一个 Jetpack 库可供您使用,称为 CameraX。这为您提供了一个在大多数 Android 设备上都能工作的一致 API,并解决了设备兼容性问题。您可以使用它来预览、捕获和分析图像。甚至还有一个扩展插件,让您可以访问设备随附的原生相机应用程序使用的功能。
阅读更多关于 CameraX 的信息:
developer.android.com/training/camerax
10. 发布您的应用程序
一旦您开发了您的应用程序,您可能希望将其提供给其他用户。您可能希望通过应用市场如 Google Play 发布您的应用程序。
此过程分为两个阶段:准备发布您的应用程序,然后发布它。
准备发布您的应用程序
在您发布应用程序之前,您需要配置、构建和测试其发布版本。这包括任务如为您的应用程序决定一个图标、移除任何日志代码以及修改AndroidManifest.xml,以便只有您想让其运行您的应用程序的设备可以下载它。
在发布您的应用程序之前,请确保您在至少一台平板电脑和一部手机上进行测试,以确保其外观符合您的期望并且其性能可接受。
您可以在此处找到关于如何准备发布您的应用程序的进一步详细信息:
developer.android.com/tools/publishing/preparing.html
发布您的应用程序
此阶段包括发布您的应用程序,销售它以及宣传它。
在您发布应用程序之前,我们建议您访问此处:
developer.android.com/distribute/best-practices/launch
它包括清单和提示,帮助您发布和管理您的应用程序。
对于如何最好地将您的应用程序定位于用户并建立关于它的兴趣的想法,我们建议您探索此处的文档:
并且在这里:
第十四章:Room 数据库:带视图的 Room

大多数应用程序需要持久化的数据。
但是,如果你不采取措施将这些数据存储在某个地方,它将永远丢失,一旦关闭应用程序。通常,你可以通过将数据存储在数据库中来在 Android 应用程序中保护数据安全,因此在本章中,我们将向你介绍Room 持久化库。你将学习如何使用注解类和接口来构建数据库、创建表和定义数据访问方法。你将了解如何使用协程在后台运行数据库代码。在此过程中,你将学习如何通过一点帮助从Transformations.map()来立即转换你的实时数据,每当它发生变化时。
大多数应用程序需要存储数据。
到目前为止,你编写的几乎所有应用程序都使用了少量静态数据来进行功能操作。例如,在第十一章到第十三章中构建的猜谜游戏应用程序,通过在其视图模型中保存一个字符串数组,游戏可以随机选择一个字符串让你来猜测。
然而,在现实世界中,大多数应用程序需要的不仅仅是静态数据;它们需要能够保存可以更改的数据,以防用户关闭应用程序时数据丢失。例如,音乐应用可能需要存储播放列表,游戏可能需要记录用户的进度,以便用户可以回到上次离开的地方。
应用程序可以使用数据库来持久化数据。
在大多数情况下,持久化用户数据的最佳方法是使用数据库。因此,在本章中,你将通过构建一个 Tasks 应用程序来学习如何使用数据库。该应用程序允许用户将任务添加到数据库,并显示已输入的所有任务列表。
以下是应用程序的外观:

在我们开始构建应用程序之前,让我们先了解其结构。
应用程序的结构
应用程序将包含一个名为MainActivity的单个活动,该活动将用于显示名为TasksFragment的片段。
TasksFragment是应用程序的主屏幕。它的布局文件(fragment_tasks.xml)将包括一个编辑文本和一个按钮,用户可以在其中输入任务名称并将其插入到数据库中。它还将包括一个文本视图,用于显示已输入到数据库中的所有任务:

我们还将向应用程序添加一个视图模型(名为TasksViewModel),该模型将被TasksFragment用于业务逻辑。它将包括片段用来与应用程序数据库交互的属性和方法。我们还将启用数据绑定,以便TasksFragment的布局可以直接访问视图模型。
这些组件将如何互动:

要创建数据库,我们将使用一个名为Room的 Android 库。那么,Room 是什么?
Room 是建立在 SQLite 之上的数据库库。

大多数 Android 数据库在后台使用 SQLite。SQLite 轻量级、稳定、快速,并且针对单用户进行了优化,这些特性使其成为 Android 应用程序的良好选择。然而,编写用于创建、管理和与 SQLite 数据库交互的代码可能会有些棘手。
为了简化操作,Android Jetpack 包含一个名为 Room 的持久化库,它建立在 SQLite 之上。使用 Room,您可以获得使用 SQLite 的所有优点,但使用更简单的代码。例如,它提供了方便的注释,让您可以快速地编写数据库代码,减少了重复性和错误。
MVVM 是一种用于结构化应用程序的架构设计模式。它代表 Model-View-ViewModel。
Room 应用程序通常使用 MVVM 结构。
使用 Room 的应用程序(包括 Tasks 应用程序)通常使用一种称为 MVVM 的架构设计模式。该结构如下所示:

这个结构类似于我们用于构建“猜谜游戏”应用程序的结构,不同之处在于多了一个用于数据库的 Model 层。这意味着活动和片段的 UI 代码与保存在视图模型中的业务逻辑分离清晰,视图模型与支持数据库的任何代码也是分离的。
在构建 Tasks 应用程序过程中,您将学习更多关于如何使用 MVVM 结构的信息。
我们将要做的事情如下:
这些是构建 Tasks 应用程序的步骤:
-
设置基本应用。
我们将创建应用程序,更新其 build.gradle 文件以使用所需的库,并创建基本的活动、片段和布局代码。
![image]()
-
编写数据库代码。
在这一步中,我们将添加代码以创建带有表的数据库,并提供与表数据交互所需的数据访问方法。
![image]()
-
插入任务记录。
我们将创建一个视图模型,并更新应用程序的片段,以便可以使用应用程序来插入记录。
![image]()
-
显示任务记录列表。
最后,我们将更新视图模型和片段代码,使应用程序显示数据库中保存的所有任务记录的列表。
![image]()
创建 Tasks 项目
我们将使用一个新项目来开发 Tasks 应用程序,因此请按照与之前章节相同的步骤创建项目。选择“空活动”选项,输入名称“Tasks”和包名“com.hfad.tasks”,接受默认保存位置。确保语言设置为 Kotlin,最低 SDK 版本为 API 21,以便在大多数 Android 设备上运行。
接下来,我们将更新项目的 build.gradle 文件,以包括所有需要的功能和依赖项。
在项目的 build.gradle 文件中添加一个变量...

在本章中,我们将使用两个 Room 库,因此我们将在项目的 build.gradle 文件中添加一个新变量,以指定我们将使用的版本,并保持一致性。为此,请打开文件 Tasks/build.gradle,并将以下行(用加粗标记的)添加到 buildscript 部分:

…并且也要更新应用的 build.gradle 文件
在应用的 build.gradle 文件中,我们需要启用数据绑定,并添加 view model、live data 和 Room 库的依赖项。
打开文件 Tasks/app/build.gradle,并将以下行(用加粗标记的)添加到适当的部分:

然后点击“立即同步”选项,将您所做的更改与项目的其余部分同步。
创建 TasksFragment
该应用程序将包含一个名为 TasksFragment 的单个片段,我们将使用它来显示数据库中所有任务的列表,并插入新任务。
要创建 TasksFragment,请在 app/src/main/java 文件夹中突出显示 com.hfad.tasks 包,然后转到 文件→新建→Fragment→Fragment(空白)。将 fragment 命名为TasksFragment,命名其布局为“fragment_tasks”,并确保语言设置为 Kotlin。

更新 TasksFragment.kt
一旦您将 TasksFragment 添加到项目中,请确保 TasksFragment.kt 与此处显示的代码匹配:

更新 fragment_tasks.xml
我们还需要更新 TasksFragment 的布局,以便使用数据绑定,并包含视图以允许我们输入新任务并显示现有任务的列表。
打开文件 fragment_tasks.xml,并更新它,以便其与此处显示的代码匹配:

在 MainActivity 的布局中显示 TasksFragment…
要使用 TasksFragment,我们需要将它添加到 MainActivity 的布局中的 FragmentContainerView 中。
更新文件 activity_main.xml,使其与此处显示的代码匹配:

…并检查 MainActivity.kt 代码
更新布局后,打开 MainActivity.kt 并确保其代码如下所示:

现在我们已经更新了 fragment 和 activity 代码,让我们开始处理 Room 数据库。
Room 数据库的创建方式

Room 使用一组带注解的类和接口来为您的应用程序创建和配置 SQLite 数据库。它需要三个主要的东西:
1. 数据库类
这定义了数据库,包括其名称和版本号。用于获取数据库实例。

2. 表的数据类
数据库中的所有数据都存储在表中。您可以使用数据类定义每个表,其中包括注解来指定表的名称和列。
注意
Kotlin 数据类允许您创建主要用于保存数据的对象。

3. 数据访问的接口
您可以使用接口与每个表进行交互,接口指定应用程序需要的数据访问方法。例如,如果需要插入记录,则可以在接口中添加一个insert()方法;如果需要获取所有记录,则可以添加一个getAll()方法。

Room 使用这三个元素生成应用程序需要创建 SQLite 数据库、其表格和任何数据访问方法的所有代码。
接下来的几页中,我们将展示如何通过定义 Tasks 应用的数据库来编写这三个组件的代码。我们将从定义其表格开始。
我们将在表格中存储任务数据
正如前文所述,数据库中的所有数据都存储在一个或多个表中。每个表由行和列组成,每行是一条记录,每列保存一个数据片段,如数字或文本。
每种数据类型都需创建单独的表。例如,日历应用可能有一个用于记录事件的表,而天气应用可能包含一个用于位置的表。
我们希望在 Tasks 应用中存储任务记录,因此我们将创建一个名为“task_table”的表。表格将如下所示:

使用带注解的数据类定义表格
如果想要数据库包含某个表格,需为每个表格定义一个数据类。数据类需要包含表的每个列的属性,并使用注解告诉 Room 如何配置表格。

为了演示其工作原理,我们将定义一个名为Task的数据类,在 Tasks 应用的数据库中创建表。
创建 Task 数据类
我们将从创建数据类开始。在app/src/main/java文件夹中的com.hfad.tasks包中突出显示,然后转到 File→New→Kotlin Class/File。将文件命名为“Task”并选择 Class 选项。
创建文件后,请更新其代码,使其看起来像这样:

使用 @Entity 指定表名
现在我们已经创建了Task数据类,接下来需要添加注解来告诉 Room 如何配置表格。我们将从表名开始。
通过在数据类中添加@Entity注解并指定表名来命名表格。我们希望在 Tasks 应用中为表格命名为“task_table”,相应的代码如下所示:

使用 @PrimaryKey 指定主键
接下来,我们将指定表的主键。这用于唯一标识单个记录,不能包含任何重复值。
在 Tasks 应用中,我们将使用taskId属性作为task_table的主键,并使表格自动生成其值以保证其唯一性。这通过类似以下方式的@PrimaryKey注解完成:

使用 @ColumnInfo 指定列名
我们最后要做的是为 taskName 和 taskDone 属性指定列名。这可以通过如下方式使用 **@ColumnInfo** 注解完成:

注意,只有在希望列名与属性名不同的情况下才需要使用 @ColumnInfo 注解。如果省略注解,Room 将使用属性名作为列名。
这是我们完成Task数据类所需了解的一切。我们将在下一页上展示完整的代码。
Task.kt 的完整代码
这是 Task 数据类的完整代码;更新 Task.kt 的代码,以包括所示的更改(用粗体标记):

Room 使用此文件创建名为 task_table 的表,其中包含一个自动生成的名为 taskId 的主键,以及名为 task_name 和 task_done 的两列。表的样子如下所示:

当我们在几页后编写数据库类时,您将了解到 Room 如何将此表添加到数据库中。首先,我们将定义一些数据库访问方法,以便应用程序可以插入、读取、更新和删除表的数据。
使用接口指定数据操作
通过创建带有注解的接口来指定应用程序访问表格数据的方式。此接口定义了一个 DAO——或数据访问对象——其中包括应用程序需要的插入、读取、更新和删除数据的所有方法。
要了解其工作原理,我们将创建一个名为 TaskDao 的新接口,Tasks 应用程序将使用它与 task_table 的数据进行交互。
创建 TaskDao 接口
我们将从创建接口开始。在 app/src/main/java 文件夹中突出显示 com.hfad.tasks 包,然后转到 文件→新建→Kotlin 类/文件。将文件命名为 “TaskDao”,选择接口选项。

创建文件后,请确保其代码如下所示:

使用 @Dao 标记接口以标记数据访问
接下来,我们需要告诉 Room,TaskDao 接口定义了数据访问方法。这可以通过像这样使用 **@Dao** 注解来完成:

一旦您使用 @Dao 注释了接口,您可以添加应用程序用于与数据交互的带注释方法。例如,如果要应用程序插入记录,您需要向接口添加一个方法;如果要获取记录,您也需要添加此方法。
好消息是,Room 提供了四个注解——@Insert、@Update、@Delete 和 @Query——使添加这些方法变得轻而易举。我们通过向 TaskDao 添加一些数据访问方法来了解它们的用法。
使用 @Insert 插入记录
我们首先定义的第一个数据访问方法是一个insert()方法,该应用程序将使用它将任务插入到task_table中。该方法将有一个参数——一个Task对象,表示我们要插入的任务。我们还会用**@Insert**注解标记该方法,告诉 Room 该方法用于插入记录。
以下是insert()方法的完整代码:

当 Room 看到@Insert注解时,它会自动生成应用程序需要将记录插入表中的所有代码,因此您无需自己编写。例如,对于上面的insert()方法,它生成将Task对象的数据插入到task_table中所需的所有代码,如下所示:

任何标记有@Insert的方法都可以接受一个或多个实体对象作为参数——对象的类型标记为@Entity。@Insert方法也可以接受实体对象的集合。例如,下面的方法将插入包含在List<Task>参数中的所有任务:

使用 @Update 更新记录
Room 还可以生成更新表中一个或多个现有记录所需的所有代码。这通过向 DAO 接口添加一个标记有**@Update**的方法来实现。例如,下面的update()方法生成更新现有任务记录所需的所有代码:

当调用此方法时,它会更新具有匹配taskId的记录,使其数据与Task对象的属性值匹配。
使用 @Delete 删除记录
还有一个**@Delete**注解,用于标记需要从表中删除特定记录的任何方法。例如,要删除单个任务记录,可以使用以下定义的方法:

Room 为此方法生成的代码将删除具有匹配taskId的记录:

用 @Query 处理其他所有情况
其他任何数据访问方法都标记有@Query。此注解允许您定义一个 SQL 语句(使用SELECT、INSERT、UPDATE或DELETE),在调用方法时将运行该语句。
注意
我们不打算在本书中教授如何使用 SQL,但如果您想了解更多信息,建议阅读 Lynn Beighley 的《Head First SQL》。
例如,在 Tasks 应用程序中,我们可以使用以下代码定义一个名为get()的方法,以返回具有匹配taskId的 LiveData Task:

我们还可以定义一个getAll()方法,它将返回一个 LiveData List,其中包含表中保存的所有记录:

由于这些方法返回 LiveData 对象,应用程序可以使用它们在数据发生更改时收到通知。我们稍后在本章中将使用此功能来保持在TasksFragment中显示的任务记录列表的最新状态。
现在你已经了解了完成 TaskDao 代码所需的所有内容。我们将在下一页展示完整代码。
TaskDao.kt 的完整代码

这是 TaskDao 接口的完整代码;更新 TaskDao.kt 的代码,使其包含所示的更改(加粗部分):

我们已经编写了 Task 数据类(定义了一个表)和 TaskDao 接口(指定了数据访问方法)的代码。接下来,我们将学习如何定义实际的数据库。
创建一个名为 TaskDatabase 的抽象类
通过创建一个抽象类来定义应用程序的数据库。抽象类指定了数据库名称和版本号,以及定义表和数据访问方法的任何类或接口。
在 Tasks 应用程序中,我们将使用名为 TaskDatabase 的抽象类来定义数据库。在 app/src/main/java 文件夹中突出显示 com.hfad.tasks 包,然后转到 文件→新建→Kotlin 类/文件。将文件命名为 TaskDatabase 并选择“类”选项。
TaskDatabase 类需要扩展 RoomDatabase,因此更新 TaskDatabase.kt 的代码,使其看起来像这样:

使用 @Database 对类进行注解
接下来,我们需要使用 **@Database** 标记该类,告诉 Room 这定义了一个数据库。以下是如何编写此代码的示例:

正如你所见,**@Database** 注解包括三个属性:**entities**、**version** 和 **exportSchema**。
entities 指定了任何标有 @Entity 的类,这些类定义了要添加到数据库中的表。对于 Tasks 应用程序,这是 Task 数据类。
version 是一个 Int,指定数据库版本。在这种情况下,这是数据库的第一个版本,因此为 1。
最后,exportSchema 告诉 Room 是否将数据库架构导出到文件夹中,以记录其版本历史。在这里,我们将其设置为 false。
为任何 DAO 接口添加属性
接下来,我们需要指定任何数据访问接口(标记为@Dao)。为此,需要为每个接口添加一个属性。
例如,在 Tasks 应用程序中,我们已定义了一个名为 TaskDao 的单个 DAO 接口,因此需要向 TaskDatabase 代码添加一个新的 taskDao 属性,如下所示:

创建并返回数据库的实例
我们需要的最后一件事是一个 getInstance() 方法,用于创建数据库并返回其实例。代码如下所示:

这就是我们对 TaskDatabase 类所需的一切。我们将在下一页展示完整代码。
TaskDatabase.kt 的完整代码
这是 TaskDatabase 抽象类的完整代码;更新 TaskDatabase.kt 的代码,使其包含所示的更改(加粗部分):

我们已经编写了 Tasks 应用程序所需的所有数据库代码。在构建应用程序的其余部分之前,请尝试以下练习。
MVVM 再探讨

在本章的早些时候,我们说过要使用 MVVM(或模型-视图-视图模型)架构模式来构建 Tasks 应用程序。以下是此类结构的提醒:

我们已经完成了所有的模型代码…
到目前为止,我们通过创建实体、DAO 和数据库定义文件(Task、TaskDao 和 TaskDatabase)编写了应用程序所需的所有数据库代码。编写所有数据库代码意味着我们已经完成了应用程序架构的模型部分。
…所以让我们继续进行 ViewModel
我们接下来要做的是 ViewModel 部分。为此,我们将创建一个名为 TasksViewModel 的视图模型,它将包含 TasksFragment 的业务逻辑。视图模型将包括使用 TaskDao 插入记录到数据库的方法。
注意
我们还希望显示数据库中保存的任务列表,但现在我们将专注于插入记录。
让我们继续创建 TasksViewModel。
创建 TasksViewModel
要创建 TasksViewModel,请在 app/src/main/java 文件夹中突出显示 com.hfad.tasks 包,然后转到 文件→新建→Kotlin 类/文件。将文件命名为 TasksViewModel,选择类选项。
我们将更新视图模型代码,以便 TasksFragment 可以使用它来插入新的任务记录。为此,代码需要三件事:
-
对 TaskDao 对象的引用
TasksViewModel将使用此对象与数据库交互,因此我们将在其构造函数中将其传递给视图模型。 -
一个字符串属性,保存新任务的名称
当用户输入新任务名称时,
TasksFragment将更新该属性的值。 -
一个
addTask()方法,TasksFragment将调用该方法该方法将创建一个新的
Task对象,设置其名称,并通过调用TaskDao的insert()方法将其插入到数据库中。
这三件事的基本视图模型代码看起来像这样;更新 TasksViewModel.kt 的代码,使其与下面的代码匹配:

然而,在 TasksFragment 能够调用 addTask() 方法之前,我们还需要做一些修改。
数据库操作可能运行得很慢…
Androidville 中的某些任务,例如将记录插入到数据库中,可能非常耗时。因此,Room 持久性库坚持要求 任何数据访问操作都必须在后台线程上执行,以免阻塞 Android 的主线程并影响 UI。
注意
从技术上讲,有一个设置可以用来覆盖这一点,但最好还是在后台线程上运行诸如此类的任务,以免阻塞应用程序的其余部分。
当我们使用 Kotlin 开发 Android 应用程序时,我们将使用 协程(coroutines) 来确保所有 TaskDao 的数据访问方法在后台运行。
我们将使用协程来在后台运行数据访问代码
正如您可能已经了解的那样,协程类似于轻量级线程,可以让您异步运行多个代码片段。使用协程意味着您可以启动后台作业(例如向数据库插入记录),而无需其余代码等待其完成。这使得用户体验更加流畅,不会像在感恩节时试图观看 YouTube 那样。
修改您的数据访问代码以使用协程非常简单。您只需做以下两个更改:
协程是一段可挂起的代码,可以在后台运行。
-
将 DAO 的每个数据访问方法标记为 suspend。
这将每个方法转换为可以在后台运行并且可以挂起的协程,例如:
![image]()
-
在后台启动 DAO 的协程。
例如,要从
TasksViewModel调用TaskDao的insert()方法,您可以使用以下代码:![image]()
这些更改适用于除了返回 LiveData 的方法外的所有数据访问方法。 Room 已经为返回 LiveData 对象的方法使用后台线程,这意味着您无需对代码做任何额外的更改。
让我们更新 TaskDao 和 TasksViewModel 的代码,使它们使用协程。
1. 将 TaskDao 的方法标记为 suspend
我们需要做的第一件事是将所有不使用 LiveData 的 TaskDao 数据访问方法标记为 suspend。这意味着我们需要将这些更改应用于除了 get() 和 getAll() 外的所有方法。
这里是 TaskDao.kt 的完整代码;更新代码以包含这里显示的更改(用粗体显示):


将方法标记为 suspend 可以将每个方法转换为可挂起的协程。
这是我们需要将 TaskDao 的方法转换为可以在后台运行的协程所需的所有代码。
2. 在后台启动 insert() 方法
接下来,我们需要更新 TasksViewModel 的 addTask() 方法,以便它作为协程启动 TaskDao 的 insert() 方法。以下是更新 TasksViewModel.kt 的代码(用粗体显示):

这一更改意味着每次调用 addTask() 方法时,它将使用 TaskDao 的 insert() 方法(一个协程)在后台插入记录。
现在,我们已经编写了任务应用程序视图模型所需的所有代码。接下来,我们将在 TasksFragment 中添加一个 TasksViewModel 对象,以便它可以访问视图模型的属性和方法,并允许用户插入任务记录。
TasksViewModel 需要一个视图模型工厂
正如您在 第十一章 中学到的,通过请求视图模型提供程序提供一个视图模型,您可以将视图模型添加到片段代码中。如果存在,则视图模型提供程序将返回片段当前的视图模型对象;如果不存在,则创建一个新的视图模型对象。
如果视图模型包含无参数构造函数,则视图模型提供程序可以无需额外帮助创建其实例。但是,如果构造函数具有参数,则它需要视图模型工厂的帮助。
在 Tasks 应用程序中,我们需要视图模型提供程序来获取 TasksViewModel 对象。由于 TasksViewModel 的构造函数需要一个 TaskDao 参数,因此我们必须首先定义一个 TasksViewModelFactory 类。

创建 TasksViewModelFactory。
要创建工厂,请在 app/src/main/java 文件夹中突出显示 com.hfad.tasks 包,然后转到 文件→新建→Kotlin Class/File。将文件命名为 “TasksViewModelFactory” 并选择“Class”选项。
TasksViewModelFactory 代码几乎与您在 第十一章 中编写的视图模型工厂代码相同,因此请更新 TasksViewModelFactory.kt,使其看起来像这样:

现在我们已经编写了视图模型工厂,让我们使用它来向 TasksFragment 添加一个 TasksViewModel 对象。
TasksViewModelFactory 需要一个 TaskDao。
要向 TasksFragment 添加 TasksViewModel,我们需要创建一个 TasksViewModelFactory 对象,并将其传递给视图模型提供程序。然后提供程序将使用工厂来创建视图模型。
但是存在一个问题:TasksViewModelFactory 的构造函数需要一个 TaskDao 参数,因此我们需要在创建 TasksViewModelFactory 对象之前获取一个 TaskDao 对象。然而,TaskDao 是一个接口,而不是具体的类,那么我们如何获取 TaskDao 对象呢?
TaskDatabase 代码具有 TaskDao 属性。
当我们编写 TaskDatabase 的代码时,我们包括了两个关键内容:一个名为 taskDao 的 TaskDao 属性和一个 getInstance() 方法以返回数据库的实例。这里是那段代码的提醒:

要在 TasksFragment 代码中获取对 TaskDao 对象的引用,因此我们可以调用 TaskDatabase 的 getInstance() 方法,并访问其 taskDao 属性。这样做的代码如下所示:

上述代码获取当前应用的引用,如果数据库不存在则构建数据库,并返回其实例。然后将其 TaskDao 对象赋给名为 dao 的本地变量。
现在我们知道如何获取对 TaskDao 对象的引用后,我们可以更新 TasksFragment 代码以创建 TasksViewModelFactory 对象,然后使用它来获取 TasksViewModel。让我们看看代码是什么样子的。
TasksFragment.kt 的更新代码。
下面显示了更新后的 TasksViewModel 代码。您可以看到,它现在包括获取 TaskDao 对象和创建 TasksViewModelFactory 的代码。然后,代码将工厂传递给视图模型提供程序,后者使用它来获取 TasksViewModel 的实例。
更新 TasksFragment.kt 中的代码,以包含这些更改(用粗体标出):

TasksFragment 可以使用数据绑定
现在我们已经向 TasksFragment 添加了一个 TasksViewModel 对象,我们可以使片段使用其属性和方法来将记录插入到数据库中。我们将使用数据绑定来实现这一点,这将使布局直接访问视图模型的属性和方法。
要设置数据绑定,我们首先需要将数据绑定变量添加到片段的布局中,因此我们将向 fragment_tasks.xml 的 <data> 部分添加以下代码(用粗体标出):

然后,我们将通过向 TasksFragment 的 onCreateView() 方法添加下面显示的行(用粗体标出)来将片段的 viewModel 属性分配给数据绑定变量:

我们将在后面的几页上为两个文件显示完整的代码。首先,让我们更新 fragment_tasks.xml,以便使用其 viewModel 变量将记录插入到数据库中。
我们将使用数据绑定来插入记录
要将新任务记录插入到数据库中,我们需要做两件事:将 TasksViewModel 的 newTaskName 属性设置为新任务的名称,并调用其 addTask() 方法。我们可以在 TasksFragment 的布局中使用数据绑定来完成这两件事情。
设置 TasksViewModel 的 newTaskName 属性
要设置视图模型的 newTaskName 属性,我们将其绑定到片段布局中的 task_name 编辑文本。要执行此操作的代码如下:

请注意,我们使用了 **@=** 将属性绑定到编辑文本上,而不是 @。@= 意味着编辑文本可以更新其绑定的属性:在这种情况下是 newTaskName。
调用 TasksViewModel 的 addTask() 方法
要插入任务,我们将使用数据绑定使布局的保存任务按钮在点击时调用视图模型的 addTask() 方法。您已经熟悉如何做到这一点,所以这里是代码:

这些是我们需要对 fragment_tasks.xml 进行的所有代码更改,以便将记录插入到数据库中。让我们看看完整的代码是什么样的。
完整的 fragment_tasks.xml 代码
这是 TasksFragment 布局的完整代码;确保 fragment_tasks.xml 中的代码包含以下更改(用粗体标出):

完整的 TasksFragment.kt 代码

在运行应用程序之前,我们还需要确保 TasksFragment 包含设置布局的数据绑定变量所需的所有代码。以下是完整的代码;更新 TasksFragment.kt(如果尚未这样做)以包含以下更改(用粗体标出):

让我们来看看代码运行时会发生什么。
代码运行时会发生什么
应用程序运行时会发生以下事情:
-
TasksFragment 调用 TaskDatabase.getInstance(),如果数据库不存在则构建数据库。
它将数据库命名为
tasks_database,并使用Task数据类创建名为task_table的表。然后返回数据库的实例。![图片]()
-
TasksFragment 获取数据库实例的 TaskDao 对象,并使用它创建一个 TasksViewModelFactory。
视图模型提供程序使用新创建的
TasksViewModelFactory对象来创建TasksViewModel。![图片]()
-
TasksFragment 将布局的 viewModel 数据绑定变量设置为 TasksViewModel 对象。
![图片]()
-
用户在布局的编辑文本视图中输入任务名称。
布局使用数据绑定将视图模型的
newTaskName属性设置为此值。![图片]()
-
用户单击“保存任务”按钮。
这使用数据绑定调用
TaskViewModel对象的addTask()方法。![图片]()
-
addTask() 方法创建一个新的 Task 对象,并将其 taskName 属性设置为 newTaskName 的值。
![图片]()
-
addTask() 方法调用 TaskDao 的 insert() 方法。
这将
Task对象的数据插入到数据库表中。表会为taskId主键自动生成一个值。![图片]()
让我们带着这个应用程序去测试驾驶。
测试驾驶
当我们运行应用程序时,TasksFragment 显示在 MainActivity 中。当我们尝试输入两个新任务时,单击“保存任务”按钮时似乎没有任何反应。

单击按钮已将新任务记录添加到数据库,但我们还不能看到它们。要在应用程序中查看这些记录,我们需要进一步进行更改。
一群穿着整齐的组件正在玩一个派对游戏,“我是谁?”他们会给你一个线索——你根据他们说的话猜测他们是谁。假设他们总是诚实地告诉自己的事情。填写右边的空白来识别出参与者。另外,对于每个参与者,请写下组件是否属于应用程序架构的 Model、View 或 ViewModel 层。

今晚的参与者:

| 名称 | Model、View 或 ViewModel? | |
|---|---|---|
| 我负责应用程序的屏幕外观。 | ______________ | ______________ |
| 我在应用程序关闭后持久化数据。 | ______________ | ______________ |
| 我有一个生命周期,但我不能单独存在。 | ______________ | ______________ |
| 我用于业务逻辑。 | ______________ | ______________ |
| Room 使用我来创建表格。 | ______________ | ______________ |
| 我有一个生命周期,我是一种上下文类型。 | ______________ | ______________ |
| 我帮助您与数据库中的数据进行交互。 | ______________ | ______________ |
答案在“中。
TasksFragment 需要显示记录

您现在已经学会了如何构建一个将记录插入到 Room 数据库中的应用程序。接下来,我们将更新应用程序,以便TasksFragment显示已插入的所有记录的列表。
这是新版本的TasksFragment将会是什么样子:

为此,我们将使用TaskDao的getAll()方法从数据库中获取所有任务记录。然后,我们将通过将它们格式化为单个String来在TasksFragment的文本视图中显示它们。
让我们开始吧。
使用 getAll()从数据库中获取所有任务
我们将首先向TasksViewModel添加一个名为tasks的属性,它将保存数据库中所有任务的列表。我们将通过将其设置为TaskDao的getAll()方法来将任务添加到属性中,就像这样:

正如你可能记得的,我们在TaskDao.kt中使用以下代码定义了getAll()方法:

如你所见,该方法返回一个LiveData<List<Task>>对象:一个包含Task对象的实时数据列表。这意味着TasksViewModel的tasks属性也具有LiveData<List<Task>>类型:

由于tasks属性使用实时数据,它始终包含用户的最新更改。例如,如果向task_table添加了一个新任务,tasks属性的值会自动更新以包含新记录。
这种方式使用实时数据对 Tasks 应用程序来说是个好消息,因为我们可以使用它来在TasksFragment中显示一个始终保持最新的任务列表。然而,在我们能够做到这一点之前,还有一件事情我们需要了解。
一个LiveData<List<Task>>是一个更复杂的类型

当我们向您介绍数据绑定时,我们向您展示了如何将视图绑定到String或数字,包括实时数据值。我们之所以能够做到这一点,是因为String和数字是可以轻松在文本视图中显示的简单对象。
然而,这一次情况有所不同。tasks属性的类型是LiveData<List<Task>>,比如说LiveData<String>要复杂得多。因此,我们不能简单地将布局的文本视图直接绑定到tasks属性,因为文本视图不知道如何显示它。
那么解决方案是什么呢?

使用 Transformations.map()来转换实时数据对象
在我们可以使用数据绑定来显示用户的任务之前,我们首先需要将LiveData<List<Task>>转换为一些更简单的东西,以便文本视图知道如何显示。为此,我们将创建一个名为tasksString的新属性,它将保存tasks属性的LiveData<String>版本。
我们将使用 **Transformations.map()** 方法来创建 LiveData<String>。该方法接受一个 LiveData 参数和一个指定 LiveData 对象应如何转换的 lambda 表达式。然后返回一个新的 LiveData 对象。
例如,要将 tasks 属性转换为 LiveData<String>,我们可以使用以下代码:
Transformations.map() 观察一个 LiveData 对象,并将其转换为另一种类型的 LiveData 对象。
val tasksString = Transformations.map(tasks) {
tasks -> formatTasks(tasks)
}
其中 formatTasks() 是一个方法(我们需要编写),用于将 tasks 属性的任务列表格式化为 String。然后 Transformations.map() 方法将这个 String 包装在一个 LiveData 对象中,以便返回 LiveData<String>。
Transformations.map() 方法观察传递给它的 LiveData 对象,并在每次接收到更改通知时执行 lambda 表达式。这意味着在上述代码中,tasksString 将自动包含用户输入的任何新任务记录。
在接下来的几页中,我们将向 TasksViewModel 中添加 tasksString 属性,然后使用数据绑定将其值显示在 TasksFragment 的布局中。这种方法意味着我们可以在一个始终保持更新的文本视图中显示用户的所有任务。

让我们更新 TasksViewModel 代码
我们将首先更新 TasksViewModel 代码,以便包含新的 tasks 和 tasksString 属性。我们还将添加两个方法 — formatTasks() 和 formatTask() — 来帮助我们将 LiveData<List<Task>> 转换为 LiveData<String>。
这是 TasksViewModel 的完整代码,因此请更新 TasksViewModel.kt,以包含下面的更改(用粗体标出):

我们将把 tasksString 属性绑定到布局的文本视图上
接下来,我们将使用数据绑定将 TasksFragment 布局中的 tasks 文本视图绑定到 TasksViewModel 中的 tasksString 属性。以下是执行此操作的代码;请更新 fragment_tasks.xml 以包括这些更改(用粗体标出):


这是我们需要对 fragment_tasks.xml 做的唯一更改,以便它能够显示用户任务的 String。然而,在我们进行应用程序测试之前,我们还需要进行一次微调。
我们需要使布局响应 LiveData 的更新
正如你已经了解的那样,tasksString 属性是一个 LiveData<String>,这意味着它会自动包含对数据库中记录的任何更新。例如,如果用户插入了一个新的记录,其数据将添加到 tasksString 的值中。
由于我们使用数据绑定来显示 tasksString 属性的值在 tasks 文本视图中,我们需要确保每当 tasksString 属性的值更新时,布局都会收到通知。这样做意味着文本视图将会在插入新记录时显示任何新的记录。
您已经学会如何使布局响应实时数据更新:通过在片段代码中设置布局的生命周期所有者,如下所示:
binding.lifecycleOwner = viewLifecycleOwner
因此,我们需要更新TasksFragment的代码,以便包含这行。我们将在下一页上显示完整的代码,然后讨论应用程序运行时发生的情况。
完整的TasksFragment.kt代码
这是TasksFragment的完整代码;要包含额外的行(加粗部分):

代码运行时发生了什么
应用程序运行时发生以下情况:
-
TasksFragment创建一个TasksViewModelFactory对象,视图模型提供程序用于创建TasksViewModel。![图片]()
-
TasksViewModel的tasks属性设置为TaskDao的getAll()方法,该方法返回一个LiveData<List<Task>>。它保存来自数据库的所有任务记录的实时数据列表。
![图片]()
-
TasksViewModel的tasksString属性使用Transformations.map()方法将tasks属性的值转换为LiveData<String>。它以单个
String格式返回tasks属性的记录。![图片]()
-
布局的文本视图使用数据绑定来显示
tasksString的值。![图片]()
-
用户将新任务记录输入到数据库中。
使用实时数据,
TasksViewModel的tasks属性自动更新,包括新记录。![图片]()
-
tasksString属性响应于tasks属性的更新。由于使用了实时数据,它会自动将新记录包含在其
String中。![图片]()
-
布局响应于
tasksString属性的更新。用户刚刚输入的记录包含在文本视图中。
![图片]()
试驾
当我们运行应用程序时,TasksFragment与以前一样显示,但这次它显示了我们之前输入的任务。
当我们输入新任务时,只要点击“保存任务”按钮,它就会立即添加到任务列表中。

恭喜!您现在已经学会如何构建一个使用 MVVM 模式与 Room 数据库交互的应用程序。用户的记录保存到数据库中,这样当应用程序关闭时它们就会持久化,并且应用程序在插入新记录时会立即显示它们。
混合消息
下面列出了一些视图模型代码,但refresh()方法的代码缺失。您的挑战是将候选代码块(左侧)与result属性的最终值匹配,假设将候选代码添加到refresh()方法中并调用一次。并非所有的值都会被使用,有些值可能会被多次使用。


混合消息解决方案
下面列出了一些视图模型代码,但refresh()方法的代码缺失。你的挑战是将每个候选代码块(在左侧)与refresh()方法中添加候选代码后result属性的最终值进行匹配,并且该方法被调用一次。并非所有值都会被使用,有些值可能会被多次使用。


一群穿着全副武装的组件正在玩一个派对游戏,“我是谁?” 他们会给你一个线索,你根据他们说的话来猜出他们是谁。假设他们总是对自己说实话。在右边的空白处填写以识别出与会者。此外,对于每个与会者,请写下该组件是否属于应用程序架构的 Model、View 或 ViewModel 层。


你的 Android 工具箱

你已经掌握了第十四章,现在你已经将 Room 持久性库添加到你的工具箱中。

第十五章:可循环视图:减少、重复、回收

数据列表是大多数应用的关键部分。
在本章中,我们将向您展示如何使用可循环视图创建一个超灵活的可滚动列表。您将学习如何为列表创建灵活的布局,包括文本视图、复选框等。您将了解如何创建适配器,以您选择的任何方式将数据压缩到可循环视图中。您将发现如何使用卡片视图为您的数据赋予3D 材料外观。最后,我们将向您展示如何使用布局管理器仅凭一两行代码完全改变列表的外观。让我们开始循环使用...
当前任务应用的外观
在上一章中,我们构建了一个任务应用,允许用户将任务记录输入到 Room 数据库中。该应用将记录列表显示为格式化的String,并且看起来像这样:

我们决定使用格式化的String显示任务记录,因为这是查看已添加到数据库中的记录的相对快速和基本的方法。
但是列表看起来有点单调。那么我们如何改进它呢?
我们可以将列表转换为可循环视图
我们可以改变方式显示任务列表,而不是将其显示为格式化的String,使其看起来像这样:

正如您所看到的,每个任务记录都使用文本视图和复选框来显示其数据,而不是普通文本。这些项目还按照卡片的方式排列在可滚动的网格中。
这种类型的列表是使用可循环视图创建的。那么它是什么?
为什么要使用可循环视图?
可循环视图是显示数据列表的更高级和灵活的方法,而不是使用简单的格式化String。它为您带来以下好处:

-
列表项的丰富用户界面。每个项目都显示在布局中,因此您可以使用文本视图、图像视图和复选框来显示其数据。
-
一种灵活的定位项目的方法。可循环视图配备布局管理器,允许您在垂直或水平列表、网格或不等高度的交错网格中定位视图。
-
您可以将其用于导航。您可以使项目可点击,以便在单击时导航到另一个片段。
-
这是显示大数据集的高效方式。可循环视图使用少量视图来呈现大量视图的外观,这些视图超出屏幕范围。当每个项目滚动到屏幕外时,它会重新使用或回收其视图,以用于滚动到屏幕上的项目。
可循环视图从适配器获取其数据
每个您创建的回收视图都使用适配器来显示其数据。适配器使用数据源(如数据库)的数据,并将其绑定到项目布局中的视图上。然后,回收视图将这些项目作为可滚动列表显示在设备屏幕上。
数据源、适配器和回收视图的关系如下:

我们将向 Tasks 应用程序添加一个回收视图。让我们一起看看我们将采取的步骤。
这是我们要做的事情
我们将按照以下步骤向 Tasks 应用程序添加回收视图:
-
创建一个回收视图来显示任务名称列表。
我们将从创建一个基本的回收视图开始,它只显示每个任务的名称。通过保持第一个版本相对简单,可以更容易地了解回收视图的各个部分如何构建以及它们如何配合。
![image]()
-
更新回收视图以显示网格卡片。
在我们完成了基本的回收视图之后,我们将对其进行更改,以便以网格卡片的形式显示每个任务的名称和完成情况。
![image]()
在应用的 build.gradle 文件中添加回收视图依赖项
在我们开始构建回收视图之前,我们需要将回收视图库的依赖项添加到应用的build.gradle文件中。打开 Tasks 应用程序,然后打开文件Tasks/app/build.gradle,并将以下行(加粗)添加到dependencies部分中:

在提示时,请确保将此更改与应用程序的其余部分同步。
现在我们已经添加了这个依赖项,让我们开始构建回收视图。

告诉回收视图如何显示每个项目…

首先,我们要做的是告诉回收视图如何显示每个任务记录。
对于应用的第一个版本,我们希望在回收视图中显示每个任务的名称,使其看起来像这样:

那么我们该怎么做呢?
…通过定义布局文件
您可以使用布局文件指定回收视图中每个项目的布局方式。当回收视图需要显示每个项目时,它将使用此布局文件。例如,如果布局文件由单个文本视图组成,则回收视图的列表中将显示一个文本视图。
要创建布局文件,请在项目资源管理器中突出显示Tasks/app/src/main/res/layout文件夹,然后选择“文件→新建→布局资源文件”。在提示时,输入文件名“task_item”,然后点击“确定”。

对于应用的第一个版本,我们希望在单个文本视图中显示回收视图中每个任务的名称,因此我们将在刚刚创建的布局文件task_item.xml中添加一个文本视图。为此,请更新task_item.xml的代码,使其与下面的代码匹配:

这就是我们需要告诉回收视图如何布局每个项目的所有代码。接下来,我们将创建回收视图的适配器。
适配器向回收视图添加数据。
正如我们之前所说,当您在应用程序中使用回收视图时,需要为其创建一个适配器。
回收视图的适配器有两个主要作用:创建回收视图中可见的每个视图,并在其中显示一个数据片段。对于 Tasks 应用程序,我们需要定义一个适配器,使用task_item.xml来创建一堆文本视图(每个显示的任务记录一个),并在每个文本视图中放置一个任务名称。
适配器充当数据源和回收视图之间的桥梁。
我们将在接下来的几页中构建适配器。以下是我们将要执行的步骤。
-
指定适配器应处理的数据类型。
我们希望适配器使用
Task数据,因此我们将指定它使用List<Task>。 -
定义适配器的视图持有者。
这控制每个项目布局中的每个视图应如何填充。
-
膨胀每个项目的布局。
当回收视图需要显示每个项目时,我们将为该项目膨胀task_item.xml的一个实例。
-
在布局中显示每个项目的数据。
我们将通过将每个
Task的taskName属性值添加到布局的文本视图中来执行此操作。
创建适配器文件。
创建适配器文件。
我们将为回收视图创建一个名为TaskItemAdapter的适配器。为此,请在app/src/main/java文件夹中突出显示com.hfad.tasks包,然后转到文件→新建→Kotlin Class/File。将文件命名为TaskItemAdapter,选择类选项。
创建文件后,更新其代码,使其扩展RecyclerView.Adapter类,如下所示:

这将该类转换为可由回收视图使用的适配器。
告诉适配器应该处理哪些数据
当您定义回收视图适配器时,您需要告诉它应添加到回收视图中的数据类型。我们将通过向适配器添加一个属性来指定数据类型来完成此操作。
对于任务应用程序,我们希望回收视图显示任务记录的列表,因此我们将向适配器添加一个名为data的List<Task>属性。我们还将包括一个自定义的 setter,如果属性更新,则调用notifyDataSetChanged();这会告诉回收视图数据已更改,因此它可以重新绘制自己。
这是更新后的TaskItemAdapter代码;请更新TaskItemAdapter.kt以包括此更改(加粗部分)。

重写getItemCount()方法
接下来,我们需要重写适配器的**getItemCount()**方法。这告诉适配器有多少数据项,以便回收视图知道要显示多少个。
在TaskItemAdapter代码中,我们使用名为data的List<Task>属性作为回收视图的数据项,因此我们可以使用data.size来指定有多少项目。
以下是 getItemCount() 方法(用粗体标出),您可以将其添加到 TaskItemAdapter.kt 中:

现在我们已经指定了适配器处理的数据类型,我们将使用它来填充布局的文本视图。我们将通过定义适配器的 视图持有者 来实现这一点。
定义适配器的视图持有者
视图持有者包含了有关项目布局中视图应如何显示以及其在回收视图中的位置的信息。您可以将其视为项目布局根视图的持有者——指定回收视图应如何显示每个项目的布局。
在任务应用程序中,我们希望回收视图使用布局文件 task_item.xml 来显示任务记录。此布局的根视图是 TextView,因此我们需要定义一个与文本视图一起工作的视图持有者。

通过向适配器文件添加一个扩展 RecyclerView.ViewHolder 的内部类来定义视图持有者。它包括一个指定布局根视图类型(在本例中为 TextView)的构造函数。适配器的类定义也需要更新以指定适配器的类名。
以下是更新后的 TaskItemAdapter 代码;请更新 TaskItemAdapter.kt 以包含以下更改(用粗体标出):

现在我们已经定义了一个视图持有者,我们需要通过重写适配器的 onCreateViewHolder() 方法来指定它使用的布局。
覆盖 onCreateViewHolder() 方法
每当回收视图需要新的视图持有者时,适配器的 **onCreateViewHolder()** 方法会被调用。当首次构建时,回收视图会重复调用该方法以构建将显示在屏幕上的视图持有者集合。
onCreateViewHolder() 方法需要执行两件事:膨胀用于每个项目的布局(在本例中为 task_item.xml),并返回一个视图持有者。以下是完成此操作的代码示例,因此请更新 TaskItemAdapter.kt 以包含以下更改(用粗体标出):

正如您所见,我们将代码放在 TaskItemViewHolder 中的新 inflateFrom() 方法中来膨胀 task_item.xml,适配器的 onCreateViewHolder() 方法使用以下方式调用它:
TaskItemViewHolder.inflateFrom(parent)
这种方法将布局的视图持有者布局的责任传递给视图持有者,而不是在适配器代码的主体中膨胀布局。
向布局的视图添加数据
我们需要添加到适配器的最后一个细节是如何在视图持有者的布局中显示任务记录。我们将通过重写适配器的 **onBindViewHolder()** 方法来实现这一点,每当回收视图需要显示数据时就会调用该方法。它接受两个参数:需要绑定数据的视图持有者和数据在数据集中的位置。

在 Tasks 应用中,我们希望获取适配器的 data 属性(一个 List<Task>)中特定位置的 Task 对象,并在视图持有者的布局中显示其 taskName。以下是为 TaskItemAdapter 编写此功能的代码,因此请更新 TaskItemAdapter.kt 的代码以包括以下显示的更改:

正如您所见,我们在一个新的 bind() 方法中设置布局的文本视图文本,我们已将其添加到 TaskItemViewHolder 中。每次适配器的 onBindViewHolder() 方法运行时,它都会调用此方法。我们之所以采用这种方法,是因为它使视图持有者负责填充其布局,而不是适配器。
这就是我们需要为 TaskItemAdapter 及其 TaskItemViewHolder 内部类编写的所有代码。让我们查看完整的代码。
TaskItemAdapter.kt 的完整代码
这是 TaskItemAdapter 的完整代码;确保 TaskItemAdapter.kt 的代码包括这里显示的所有内容:

适配器代码已完成
我们已经完成了所有需要的 TaskItemAdapter 代码编写。它执行以下四项任务:
-
指定其与 Task 数据的工作方式
我们通过定义名为
data的List<Task>属性来实现这一点。 -
使用名为 TaskItemViewHolder 的视图持有者
我们将
TaskItemViewHolder添加到TaskItemAdapter中作为内部类。 -
为每个项目填充布局
它在其
onCreateViewHolder()方法被调用时执行此操作。 -
在布局中显示每个项目的数据
它通过其
onBindViewHolder()方法来执行此操作。
正如我们之前所说,适配器充当数据源和 RecyclerView 之间的桥梁。数据源、适配器和 RecyclerView 之间的关系如下所示:

现在我们已完成适配器代码的编写,让我们继续进行此模型的RecyclerView部分。
我们需要显示 RecyclerView

我们接下来需要在 TasksFragment(Tasks 应用的主屏幕)中显示一个 RecyclerView,并使其使用我们刚刚创建的适配器。
这是 RecyclerView 应该如何显示的一个提醒:

如何向布局添加 RecyclerView
通过向片段的布局文件添加 <androidx.recyclerview.widget.RecyclerView> 元素,您可以显示一个 RecyclerView。代码如下所示:

下面这行:
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
指定 RecyclerView 使用的布局管理器,它确定 RecyclerView 如何定位其项目。在这里,我们使用线性布局管理器,这意味着 RecyclerView 将以垂直列表形式显示其项目,每行都是全长的。
注意
您将在本章后面了解更多有关布局管理器的信息。
这就是您需要了解的有关向 TasksFragment 布局添加 RecyclerView 的所有代码。让我们看看代码是什么样子的。
fragment_tasks.xml 的完整代码
下面是fragment_tasks.xml(TasksFragment的布局)的完整代码。正如您所看到的,我们已经用回收视图替换了文本视图,因此请更新fragment_tasks.xml的代码以包含这些更改(加粗部分):


这就是我们需要编写的所有代码,以便将回收视图添加到TasksFragment的布局中。接下来,我们将告诉回收视图使用我们创建的适配器。
告诉回收视图使用适配器。
创建适配器实例,并将其附加到回收视图,使回收视图使用适配器。这是在片段的 Kotlin 代码中完成的。
在我们的情况下,我们希望使回收视图使用TaskItemAdapter。这通过向TasksFragment的onCreateView()方法添加以下代码(加粗部分)完成:

我们将在下一页将此代码添加到TasksFragment中。
更新后的 TasksFragment.kt 代码
这是TasksFragment的代码;更新TasksFragment.kt以包含所有显示的更改(加粗部分):

接下来是什么?
我们已将回收视图添加到 TasksFragment 的布局中
我们现在已经编写了所有必须的代码,用于在TasksFragment的布局中显示回收视图,并告诉它使用TaskItemAdapter作为其适配器。但是还有一件事情我们需要做:连接适配器到数据源。
正如您之前学到的那样,适配器使用来自数据源(例如数据库)的数据,并将其绑定到项目布局中的视图上。然后,回收视图将这些项目显示在设备屏幕上。
数据源、适配器和回收视图的关系如下所示:

因此,为了在 Tasks 应用程序的回收视图中显示数据,我们需要告诉 TaskItemAdapter 使用哪些任务数据。
我们将让 TasksFragment 向 TaskItemAdapter 添加任务数据
我们将告诉TaskItemAdapter使用哪些任务数据,方法是让TasksFragment更新其data属性,其中包含List<Task>。TasksFragment将从TasksViewModel的tasks属性中获取此列表:

要做到这一点,我们首先需要让TasksFragment能够访问TasksViewModel的tasks属性。
更新 TasksViewModel.kt 代码
您可能还记得,TasksViewModel的tasks属性目前标记为私有。我们需要移除这个修饰符,以便TasksFragment可以获取属性的值。
我们还将删除我们在上一章中添加的代码,以将任务数据转换为格式化的String:现在我们使用回收视图来显示List<Task>,因此不再需要此代码。
这是更新后的TasksViewModel代码;请更新TasksViewModel.kt以包含这些更改:

现在TasksFragment可以访问tasks属性了,让我们让它将属性的List<Task>传递给TaskItemAdapter。
TasksFragment 需要更新 TaskItemAdapter 的数据属性
如你所知,TasksViewModel 的 tasks 属性保存着一个任务列表的 LiveData,它通过以下代码从数据库获取:

由于此属性使用 LiveData,我们可以使 TasksFragment 观察它,以便每当其值更改时,片段将收到通知。然后,TasksFragment 将能够将列表的最新版本分配给适配器的 data 属性,确保 RecyclerView 中显示的数据始终是最新的。

你已经熟悉观察 LiveData 属性的代码,所以这里是我们需要添加到 TasksFragment 的代码(加粗部分):

让我们更新 TasksFragment.kt 以包括这个变更。
TasksFragment.kt 的完整代码
这是 TasksFragment 的更新代码;请更新 TasksFragment.kt 以包含所示的所有更改(加粗部分)


我们已经完成了所有 RecyclerView 代码的编写
花了一些时间,但我们现在已经完成了所有需要在 RecyclerView 中显示任务名称列表的代码。我们通过以下方式实现了这一点:
-
创建名为 TaskItemAdapter 的适配器
适配器充当 RecyclerView 和其数据源之间的桥梁。在 Tasks 应用程序中,数据源是包含任务记录的 Room 数据库。
-
将 TaskItemAdapter 附加到 RecyclerView
我们在
TasksFragment的布局中添加了一个 RecyclerView,并在其 Kotlin 代码中告知它使用TaskItemAdapter。 -
传递最新的 List
到 TaskItemAdapter 我们通过让
TasksFragment每次更新TasksViewModel的任务 LiveData 列表时,设置TaskItemAdapter的 data 属性来完成了这一点。
在我们测试应用程序并查看 RecyclerView 的外观之前,让我们来看看代码运行时会发生什么。
代码运行时会发生什么
应用程序运行时会发生以下情况:
-
当应用程序启动时,MainActivity 显示 TasksFragment。
TasksFragment使用TasksViewModel作为其视图模型。![image]()
-
TasksFragment 创建了一个 TaskItemAdapter 对象,并将其分配给 RecyclerView 作为其适配器。
![image]()
-
TasksFragment 观察 TasksViewModel 的 tasks 属性。
此属性是
LiveData<List<Task>>,它保存来自数据库的最新记录列表。![image]()
-
TasksFragment 将 TaskItemAdapter 的 data 属性设置为 List
。 ![image]()
-
TaskItemAdapter 的 onCreateViewHolder() 方法会为需要在 RecyclerView 中显示的每个项调用。
这为每个项创建了一个
TaskItemViewHolder。每个视图持有者的布局由 task_item.xml 定义。![image]()
-
TaskItemAdapter 的 onBindViewHolder() 方法会为每个 TaskItemViewHolder 调用。
这将数据绑定到每个视图持有者布局中的文本视图。
![image]()
-
每当
TasksViewModel的tasks属性更新时,TasksFragment将更新后的List<Task>传递给TaskItemAdapter。步骤 5 到 6 被重复执行,以保持可循环视图的更新。
![image]()
让我们进行应用程序的测试驾驶。
测试驾驶
当我们运行应用时,TasksFragment 在可循环视图中显示每个任务的名称。
当我们输入新任务时,这些任务将添加到可循环视图的列表中。应用程序按计划运行。

您现在已经学会了如何创建一个基本的可循环视图。在我们调整它以不同方式显示任务记录之前,请尝试以下练习。
适配器磁铁

Bits and Pizzas 应用程序包括一个 Pizza 数据类,如下所示:
package com.hfad.bitsandpizzas
data class Pizza(
var pizzaId: Long = 0L,
var pizzaName: String = "",
var pizzaDescription: String = "",
var pizzaImageId: Int = 0
)
应用程序需要包含一个可循环视图,以在以下布局(名为 pizza_item.xml)中显示每个 Pizza 项的 pizzaName:
<?xml version="1.0" encoding="utf-8"?>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
看看你是否能完成可循环视图的适配器代码(如下)。
package com.hfad.bitsandpizzas
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class PizzaAdapter : RecyclerView.Adapter<...........................................>() {
var pizzas = listOf<..............>()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() =.............................................
override fun............................(parent: ViewGroup, viewType: Int)
: PizzaViewHolder = PizzaViewHolder.inflateFrom(parent)
override fun onBindViewHolder(.................................. , position: Int) {
val item = .......... [position]
holder.bind(item)
}
class PizzaViewHolder(val rootView: TextView)
: ................................... (rootView) {
companion object {
fun inflateFrom(parent: ViewGroup): PizzaViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val view = layoutInflater
.inflate(R.layout.pizza_item, parent, false) .....................................
return PizzaViewHolder(view)
}
}
fun bind(item: .......... ) {
rootView.text = .......................................
}
}
}

适配器磁铁解决方案

Bits and Pizzas 应用程序包括一个 Pizza 数据类,如下所示:
package com.hfad.bitsandpizzas
data class Pizza(
var pizzaId: Long = 0L,
var pizzaName: String = "",
var pizzaDescription: String = "",
var pizzaImageId: Int = 0
)
应用程序需要包含一个可循环视图,以在以下线性布局(名为 pizza_item.xml)中显示每个 Pizza 项的 pizzaName 和 PizzaDescription 属性:
<?xml version="1.0" encoding="utf-8"?>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
看看你是否能完成可循环视图的适配器代码(如下)。


可循环视图非常灵活

到目前为止,在本章中,您已经学习了如何构建一个显示任务名称列表的基本可循环视图。您通过创建一个布局来为列表中的每个项目使用,并定义一个适配器来填充数据,并将可循环视图添加到 TaskFragment 的布局中完成了这一点。

虽然可循环视图可能看起来过于复杂,但它们非常灵活。
在这个应用中,我们创建了一个可循环视图来显示简单的任务名称列表,但这只是为了让您开始。您还可以使用可循环视图来做其他事情,比如:
-
通过向项的布局添加图像视图来显示图像列表 -
使用不同的布局管理器来以网格形式而不是垂直列表显示项目 -
使其响应点击,以便您可以用它进行导航
为了向您展示可循环视图可以有多么灵活,我们将改变刚刚创建的可循环视图,使其显示关于每个任务的更多信息。
让我们看看新版本的可循环视图将会是什么样子。
可循环视图 2.0
我们将更新可循环视图,以便在文本视图中显示每个任务的名称,并在复选框中显示每个任务是否已完成。我们将在网格中显示每个任务记录,看起来像是稍微抬高的卡片。
这是新版本的 RecyclerView 的外观:

我们将通过更改task_item.xml(RecyclerView 项目使用的布局)来创建此版本的 RecyclerView,以便使用卡片视图。这是一种带有圆角和阴影的框架布局,使其看起来好像悬浮在其背景之上。

将卡片视图依赖项添加到app build.gradle 文件
要使用卡片视图,我们首先需要向应用程序的build.gradle文件中的dependencies部分添加其库的依赖项。打开文件Tasks/app/build.gradle,并添加以下行(用粗体标出):

确保将此更改与应用程序的其余部分同步。
如何创建卡片视图
我们将在task_item.xml中使用一个包含文本视图和复选框的卡片视图。
通过在布局代码中添加<androidx.cardview.widget.CardView>元素来创建卡片视图。典型卡片视图的代码如下所示:


正如您所看到的,上述代码包括额外的命名空间:
This lets you add attributes that give the card rounded corners and a drop shadow to make it look higher than its background. You add rounded corners using the `app:cardCornerRadius` attribute, and the `app:cardElevation` attribute sets its elevation and adds drop shadows:
###### Note
There’s also an app:cardBackgroundColor attribute that changes the card’s background color.

Once you’ve defined the card view, you add any views to it that you want it to include. In the Tasks app, we want to add a text view and a checkbox to the card view to display the name of each task and whether it’s been completed. Let’s see what the code for this looks like.
task_item.xml的完整代码
这是task_item.xml的更新代码;更新此文件的代码,使其包含以下更改(用粗体标出):

接下来,我们需要更新适配器的视图持有者,以使其适用于新的布局,并填充卡片的视图。
适配器的视图持有者需要与新的布局代码配合工作。
当我们定义TaskItemAdapter(RecyclerView 的适配器)时,我们包含了一个TaskItemViewHolder内部类。我们用它来填充与 RecyclerView 中每个条目相关联的布局(一个文本视图),并将其填充为任务的名称。
这是我们用于原始内部类的代码的提醒:

现在我们已经更改了task_item.xml,我们需要更新TaskItemViewHolder,以使其适用于新的布局。为此,我们需要进行三项更改:
-
更新视图持有者的构造函数,以便使用
CardView而不是TextView。 -
更改
inflateFrom()方法,以便将每个条目的布局作为 CardView 进行填充。 -
更新
bind()方法,以便用条目的taskName和taskDone属性值填充布局的文本视图和复选框。
你已经熟悉了需要进行这些更改的代码,因此我们将在下一页上为您展示更新后的TaskItemAdapter和其TaskItemViewHolder内部类的代码。
TaskItemAdapter.kt的完整代码
这是更新后的TaskItemAdapter代码,适用于新的布局代码;更新TaskItemAdapter.kt的代码,使其包含以下更改(用粗体标出):


到目前为止,RecyclerView 的外观如下所示:
如果我们在更新了 task_item.xml 和 TaskItemAdapter.kt 的代码后运行应用程序,我们会看到 RecyclerView 以垂直列表的卡片视图形式显示任务:

RecyclerView 之所以以这种方式排列卡片,是因为我们在 fragment_tasks.xml 中指定它必须使用类似这样的线性布局管理器:

默认情况下,这个布局管理器以垂直列表形式排列项目,每行都是整行。但是,你可以选择额外的选项,或者使用不同类型的布局管理器来改变项目显示的方式。
让我们看看一些可能的选项。
布局管理器库
以下是您可能希望在 RecyclerView 中安排项目的其他方式,以及如何创建每种方式。
在水平行中显示项目
默认情况下,线性布局管理器以垂直列表形式显示项目。但是,您可以将方向更改为水平,以便改为水平行显示项目:

<androidx.recyclerview.widget.RecyclerView ...
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="horizontal" />
使用 GridLayoutManager 在网格中显示项目
如果您希望以网格形式排列项目,请尝试使用 GridLayoutManager。使用 app:spanCount 来指定网格应该有多少列:
<androidx.recyclerview.widget.RecyclerView ...
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2" />

在交错网格中排列项目
如果你的项目大小不均,可以使用 StaggeredGridLayoutManager,如下所示:
<androidx.recyclerview.widget.RecyclerView ...
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:spanCount="2" />

让我们将其中一种样式应用到 Tasks 应用程序的 RecyclerView 中,并查看应用程序运行时会发生什么。
更新 fragment_tasks.xml 以在网格中排列项目
我们将更新 RecyclerView,使其以两列的网格形式排列项目。此更改的更新布局代码如下所示;请更新 fragment_tasks.xml 以包含这些更改(用粗体标记):

代码运行时会发生什么
应用程序运行时会发生以下事情:
-
TasksFragment 创建了一个 TaskItemAdapter 对象,并将其分配给 RecyclerView 作为其适配器。
![image]()
-
TasksFragment 将 TaskItemAdapter 的数据属性设置为 List
。 TasksFragment通过观察TasksViewModel的tasks属性来获取这个List<Task>。![image]()
-
TaskItemAdapter 的 onCreateViewHolder() 方法将为需要在 RecyclerView 中显示的每个项目调用。
这为每个项目创建一个
TaskItemViewHolder。每个视图持有者都会膨胀为一个布局(由 task_item.xml 定义)。![image]()
-
TaskItemAdapter 的 onBindViewHolder() 方法将为每个 TaskItemViewHolder 调用。
这将数据绑定到每个视图持有者布局中的视图。
![image]()
-
RecyclerView 使用其布局管理器来排列其项目。
因为 RecyclerView 使用了
GridLayoutManager,并且spanCount设置为 2,它会以两列的网格形式排列项目。![image]()
-
每当
TasksViewModel的tasks属性更新时,TasksFragment将更新后的List<Task>传递给TaskItemAdapter。步骤 3 到 5 被重复执行,以确保回收视图保持最新状态。
![图片]()
让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用程序时,TasksFragment的回收视图会显示一个网格,每个卡片显示一个任务名称以及任务是否已完成。
当我们输入新任务时,只要点击“保存任务”按钮,这些任务就会立即添加到回收视图中。

恭喜!你现在学会了如何通过布局管理器控制回收视图的外观,并在可滚动的卡片网格中显示数据。
在下一章中,我们将继续基于这些知识来进一步改进回收视图。
池谜题

你的目标是更新下面的布局代码,以便包括一个回收视图,以三列交错网格形式显示其项目。从池中获取代码片段并放入代码的空行中。每个片段只能使用一次,且不需要使用所有片段。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<..................................................
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
...............................................................................
................../>
</LinearLayout>

注意
注意:池中的每个内容只能使用一次!
在 “Pool Puzzle Solution” 中找到答案。
池谜题解决方案

你的目标是更新下面的布局代码,以便包括一个回收视图,以三列交错网格形式显示其项目。从池中获取代码片段并放入代码的空行中。每个片段只能使用一次,且不需要使用所有片段。


你的安卓工具箱

你已经掌握了第十四章,现在还增加了回收视图到你的工具箱中。

第十六章:Diffutil 和数据绑定:快车道上的生活

您的应用程序需要尽可能平稳和高效地运行。
但如果不小心,大型或复杂的数据集可能会导致回收视图出现故障。在本章中,我们将向您介绍DiffUtil:这是一个实用类,为您的回收视图增加额外的智能。您将了解如何使用它来进行高效的更新您的回收视图。您还将发现ListAdapters如何让使用DiffUtil变得轻而易举。在此过程中,您将学习如何通过在您的回收视图代码中实现数据绑定,彻底摆脱findViewById()**。
回收视图正确显示任务数据…
在前一章中,我们向任务应用程序添加了一个回收视图,以完全符合我们想要的方式显示其数据。每个任务显示在单独的卡片中,每个卡片显示任务名称及其是否已完成。然后将这些卡片按照两列的网格布局如下显示:

…但在更新数据时,回收视图会跳动
每次添加新任务时,回收视图会重新绘制,以包括新记录并保持更新。然而,在这个过程中,回收视图会跳动,显示效果不太平滑。
每次需要更新回收视图时,它的整个列表都会重新绘制。没有流畅的过渡来显示发生了什么变化,如果列表非常长,用户可能会失去自己的位置。对于大型数据集,这也是低效的,并可能导致性能问题。
在解决这些问题之前,让我们快速回顾一下任务应用程序的结构。
重新审视的任务应用程序
正如您肯定记得的那样,任务应用程序允许用户输入任务记录,这些记录存储在 Room 数据库中。它包括一个回收视图,显示已输入的所有记录。
应用程序的主屏幕由名为TasksFragment的片段定义,该片段使用名为TasksViewModel的视图模型。其布局—fragment_tasks.xml—包括一个回收视图,显示任务的网格。回收视图使用名为TaskItemAdapter的适配器,其项目使用task_item.xml布局文件排列。
以下是应用程序的各个部分是如何配合的:

我们需要修复TasksFragment的回收视图,使其在添加新记录时不再跳动。为了做到这一点,让我们重新审视回收视图如何设置其数据。
回收视图如何获取其数据
每次需要更新回收视图的数据时,都会发生以下事情:
-
当数据库添加记录时,TasksFragment 会收到通知。
发生这种情况是因为它观察
TasksViewModel的tasks属性:一个LiveData<List<Task>>,它从数据库获取其数据。![图片]()
-
TasksFragment 设置 TaskItemAdapter 的 data 属性,该属性保存回收视图的数据。
它将其设置为从
tasks属性获取的新List<Task>(包括最新的记录更改)。![image]()
-
TaskItemAdapter 告知回收视图数据已更改。
当回收视图响应时,它会重新绘制和重新绑定列表中的每个项目。
![image]()
data属性的 setter 调用了notifyDataSetChanged()
回收视图由于 setter 而重新绘制和重新绑定其整个列表,我们将其添加到了TaskItemAdapter的data属性中。以下是代码的提醒:

每次需要更新data属性时都会调用其 setter。如您所见,它将data属性设置为新值,然后调用notifyDataSetChanged()。此方法告知包括回收视图在内的任何观察者数据集已更改,因此回收视图会重新绘制以包括最新更改。
notifyDataSetChanged()重新绘制整个列表
然而,使用notifyDataSetChanged()存在问题。每次调用它时,都会告诉回收视图data属性在某种方式上过时,但未指明如何过时。由于回收视图不知道发生了什么变化,它会响应性地重新绑定和重新绘制列表中的每个项目。
当回收视图以此方式重新绑定和重新绘制其项目时,会丢失用户在列表中的位置跟踪。如果列表包含超过几条记录,这可能导致列表跳动。
对于大数据集也不高效。如果回收视图包含许多项目,重新绑定和重新绘制每个项目都是大量不必要的工作,可能会导致性能问题。
每次调用 notifyDataSetChanged()时,回收视图都会重新绑定和重新绘制其整个列表。这对于大数据集尤其低效。
告知回收视图需要做出的变更
调用notifyDataSetChanged()的更有效替代方法是告知回收视图列表中哪些项目已更改,以便仅更新这些项目。例如,如果向数据库添加了新任务记录,回收视图只需添加该项目,而不是重新绑定和重新绘制整个列表。
手动计算这些差异可能会很棘手且需要大量代码。不过好消息是,回收视图库包含一个名为**DiffUtil**的实用类,它会为您处理所有这些繁重的工作。
DiffUtil 用于计算列表之间的差异
DiffUtil类专门用于查找两个列表之间的差异,从而避免手动处理这些差异。
每次适配器接收到列表的新版本时,其回收视图会使用DiffUtil将其与旧版本进行比较。它找出哪些项目已添加、删除或更新,然后以最有效的方式告诉回收视图需要进行哪些变更:
注意
准确地说,它利用 Eugene W. Myers 的巧妙差异算法计算变更是什么。

由于回收视图不再需要重绘和重新绑定其整个列表,使用 DiffUtil 是更新回收视图数据的一种更高效的方式。这还意味着用户不会在列表中丢失她的位置,回收视图甚至可以提供平滑的过渡动画,以清楚地显示发生了哪些变化。

这是我们将要做的事情
在本章中,我们将改进 Tasks 应用程序的回收视图,使其使用 DiffUtil 并使用数据绑定填充其视图。这些更改将使回收视图更加高效,并且还将改善用户的使用体验。
这是我们将采取的步骤:
-
使回收视图使用 DiffUtil。
我们将创建一个名为
TaskDiffItemCallback的新类,该类使用 DiffUtil 来比较列表中的项。然后,我们将更新TaskItemAdapter的代码,使其使用这个新类。这些更改将使回收视图更加高效,并为用户在使用时提供更流畅的体验。![image]()
-
在回收视图的布局中实现数据绑定。
在
TaskItemAdapter代码中,我们将删除对findViewById()的调用,并使用数据绑定填充每个项的视图。![image]()
我们将首先使回收视图使用DiffUtil。

我们需要实现DiffUtil.ItemCallback。

为了在 Tasks 应用的回收视图中使用 DiffUtil,我们需要创建一个新类(我们将其命名为TaskDiffItemCallback),该类实现了**DiffUtil.ItemCallback**抽象类。该类用于计算列表中两个非空项之间的差异,将有助于提高回收视图的效率。
当你实现DiffUtil.ItemCallback时,首先需要指定它处理的对象类型。使用泛型来完成,如下所示:

您还需要重写两个方法:areItemsTheSame()和areContentsTheSame()。
**areItemsTheSame()**用于检查传递给它的两个对象是否指的是同一个项目。我们将使用以下方法实现它:

因此,如果两个对象都具有相同的taskId,那么它们指的是同一个项目,该方法返回true。
**areContentsTheSame()**用于检查两个对象是否具有相同的内容,仅在areItemsTheSame()为true时调用。由于Task是一个数据类,我们可以使用以下方法实现此方法:

创建 TaskDiffItemCallback.kt
要创建新类,请在app/src/main/java文件夹中突出显示com.hfad.tasks包,然后转到“文件”→“新建”→“Kotlin 类/文件”。将文件命名为“TaskDiffItemCallback”,选择“类”选项。
创建文件后,请更新其代码,使其看起来像这样:

ListAdapter 接受 DiffUtil.ItemCallback 参数
现在我们已定义了TaskDiffItemCallback,我们需要在适配器代码中使用它。为此,我们将更新TaskItemAdapter,使其扩展ListAdapter类而不是RecyclerView.Adapter。
ListAdapter是一种设计用于处理列表的RecyclerView.Adapter类型。它提供自己的后备列表,因此您不必定义自己的列表,并且在其构造函数中接受DiffUtil.ItemCallback。
我们将指定TaskItemAdapter是ListAdapter的一种类型,它提供自己的List<Task>,并且我们将向其传递一个TaskDiffItemCallback的实例。以下是执行此操作的代码:
ListAdapter 是一种 RecyclerView.Adapter 的类型,它提供自己的后备列表。它与 DiffUtil 非常配合。

我们可以简化 TaskItemAdapter 的其余代码
一旦TaskItemAdapter已更改为扩展ListAdapter,我们可以删除其List<Task> data属性以及其 setter。因为ListAdapter具有自己的后备列表,所以不再需要此属性。
我们还可以删除TaskItemAdapter的getItemCount()方法。当适配器扩展RecyclerView.Adapter时,这是必需的,但ListAdapter提供了自己的实现,因此不再需要。
最后,我们需要更新适配器的onBindViewHolder()方法,使其不再使用:
val item = data[position]
要获取data属性中特定位置的项目,它使用:
val item = getItem(position)
这获取适配器后备列表中指定位置的项目。
我们将在下一页上向您展示所有这些代码。
更新后的TaskItemAdapter.kt的代码
这是更新后的TaskItemAdapter代码;请更新TaskItemAdapter.kt中的代码,以包含此处显示的所有更改:

填充 ListAdapter 的列表…
我们最后需要做的是将一组Task记录传递给TaskItemAdapter的后备列表。
以前,我们通过使TasksFragment观察TasksViewModel的tasks属性来完成此操作。每次属性更改时,片段都会将TaskItemAdapter的data属性更新为tasks属性的新值。
这是我们用来做这件事的代码的提醒:

现在适配器使用后备列表而不是data属性,因此我们需要使用稍微不同的方法。

…使用 submitList()
要将任务列表传递给TaskItemAdapter的后备列表,我们将使用一个名为submitList()的方法。此方法用于使用新的List对象更新ListAdapter的后备列表,因此非常适合这种情况。
这是我们需要添加到TasksFragment(粗体部分)的新代码,我们将在下一页上添加:

当适配器接收到新列表时,它使用TaskDiffItemCallback类将其与旧版本进行比较。 然后,它通过更新差异而不是替换整个列表来更新回收视图。 这种方法更有效率,可以带来更流畅的用户体验。
让我们看看更新后的TasksFragment代码是什么样子的。
TasksFragment.kt 的更新代码
这是更新的TasksFragment代码;请在TasksFragment.kt中包括这里显示的所有更改(用粗体表示):

让我们来看看应用程序运行时发生了什么。
代码运行时发生了什么
应用程序运行时发生以下事情:
-
应用程序启动时,MainActivity 显示 TasksFragment。
TasksFragment使用TasksViewModel作为其视图模型。![图像]()
-
TasksFragment 创建一个 TaskItemAdapter 对象,并将其分配给回收视图作为其适配器。
![图像]()
-
TasksFragment 观察 TasksViewModel 的 tasks 属性。
这个属性是一个
LiveData<List<Task>>,包含来自数据库的最新记录列表。![图像]()
-
每当 tasks 属性获得新值时,TasksFragment 将其 List
提交给 TaskItemAdapter。 ![图像]()
-
TaskItemAdapter 使用 TaskDiffItemCallback 来比较其旧数据和新数据。
它使用
TaskDiffItemCallback的areItemsTheSame()和areContentsTheSame()方法来找出发生了什么变化。![图像]()
-
TaskItemAdapter 告诉回收视图发生了什么变化。
回收视图重新绑定并重绘必要的项目。
![图像]()
测试驾驶
当我们运行应用程序时,TasksFragment像以前一样在回收视图中显示卡片的网格。
当我们输入一个新任务名称并点击按钮时,新的任务卡片将添加到回收视图中,现有的卡片将移动以适应它。

回收视图之所以表现出这种方式,是因为我们使用DiffUtil来提交变更给它,而不是替换整个列表。
成为 ListAdapter

回收视图的 ListAdapter 类具有 Drinks 的后备列表。 它使用右侧显示的 Drink 类。 你的任务是扮演像 ListAdapter 一样,并说如果给定一个新列表时,下面的 ItemCallback 类是否能正确地检测到任何变化。 为什么? 为什么不?


在“成为 ListAdapter 的解决方案”中的答案。
回收视图可以使用数据绑定

我们改进 Tasks 应用程序的回收视图的另一种方式是使其使用数据绑定。
正如您可能记得的那样,TaskItemAdapter 的 TaskItemViewHolder 内部类使用 findViewById() 来获取 RecyclerView 中每个项的视图引用。然后,视图持有者的 bind() 方法使用这些引用向每个视图添加数据。
这是这段代码的一个提醒:

如果我们更改 RecyclerView 以使用数据绑定,我们可以移除对 findViewById() 的调用,并使每个视图获取自己的数据。
我们将如何实现数据绑定
我们将使 RecyclerView 类似于 Fragment 中实现数据绑定的方式来使用数据绑定。我们将按以下步骤进行:
-
向 task_item.xml 添加一个数据绑定变量。
我们将在布局的根部添加一个
<layout>元素,并创建一个名为task的数据绑定变量,其类型为Task。这将生成一个名为TaskItemBinding的绑定类。 -
在 TaskItemAdapter 中设置数据绑定变量。
我们将使用
TaskItemBinding来膨胀每个项目的布局,并将其数据绑定变量设置为该项的Task对象。 -
使用数据绑定变量来设置视图数据。
最后,我们将更新 task_item.xml,使每个视图从布局的
Task对象中获取其数据。

让我们开始定义数据绑定变量。
向 task_item.xml 添加一个数据绑定变量
我们将从在 task_item.xml 的根元素添加一个 <layout> 元素开始,并指定一个数据绑定变量。然而,我们不会将其用于将视图绑定到视图模型,而是会指定其类型为 Task。
这是实现此操作的代码:更新 task_item.xml 以包含这些更改(用粗体标记):

将 task_item.xml 的根元素设为 <layout> 会告诉 Android 您希望使用数据绑定,因此它会生成一个名为 TaskItemBinding 的新绑定类。我们将使用这个类来膨胀上述布局,并将其 task 数据绑定变量设置为一个 Task 对象。
布局在适配器的视图持有者代码中膨胀
当我们首次创建 RecyclerView 时,我们在 TaskItemAdapter 的 TaskItemViewHolder 内部类中膨胀了布局文件 task_item.xml。现在我们需要修改此代码,以便与绑定类 TaskItemBinding 一起使用。在我们开始之前,这里是当前代码的一个提醒。

使用绑定类来膨胀布局
我们将要对 TaskItemViewHolder 进行的第一个更改是使用 TaskItemBinding 类来膨胀 task_item.xml。我们将在视图持有者的 inflateFrom() 方法中这样做:

请注意,现在我们将 binding 变量(TaskItemBinding 对象)传递给 TaskItemViewHolder 的构造函数。这意味着我们还需要更新 TaskItemViewHolder 的类定义,使其看起来像这样:

将布局的数据绑定变量设置为 Task
现在我们已经使用TaskItemBinding类来填充task_item.xml,我们可以使用它来设置task数据绑定变量。为此,我们将更改TaskItemViewHolder的bind()方法,使其将task设置为当前Task项目的 recycler view,如下所示:

我们已经删除了设置布局的task_name和task_done视图的行,因为使用数据绑定,这些不再需要。这意味着我们还可以从视图持有者中删除taskName和taskDone属性。

我们将在下一页上展示TaskItemAdapter的完整代码(包括其TaskItemViewHolder内部类)。
TaskItemAdapter.kt 的完整代码
这是更新后的TaskItemAdapter代码;确保TaskItemAdapter.kt中的代码包含这里显示的所有更改(用粗体标出):


使用数据绑定来设置布局的视图
现在我们已经将task_item.xml的task数据绑定变量设置为视图持有者的Task项目,我们可以使用数据绑定来填充布局的视图。

您已经熟悉执行此操作的代码。例如,要将task_name视图的文本设置为任务的名称,我们可以使用:

要设置task_done复选框,我们可以使用:

我们将在下一页上展示完整代码。
task_item.xml 的完整代码
这是task_item.xml的更新代码;更新此文件的代码,以包含这里显示的所有更改(用粗体标出):


让我们看看代码运行时会发生什么,并进行测试。
代码运行时会发生什么
当应用程序运行时,会发生以下事情:
-
task_item.xml 定义了一个名为 task 的 Task 数据绑定变量。
由于task_item.xml在其根部有一个
<layout>元素,因此为此布局生成了一个名为TaskItemBinding的绑定类。![image]()
-
TasksFragment 创建了一个 TaskItemAdapter 对象,并将其分配给 recycler view 作为其适配器。
![image]()
-
TasksFragment 向 TaskItemAdapter 提交了一个 List
。 List<Task>包含来自数据库的最新记录列表。![image]()
-
TaskItemAdapter 的 onCreateViewHolder()方法用于为 recycler view 中需要显示的每个项目调用。
onCreateViewHolder()调用TaskItemViewHolder.inflateFrom(),它创建一个TaskItemBinding对象。它填充对象的布局,并使用它来创建一个TaskItemViewHolder。![image]()
-
TaskItemAdapter 的 onBindViewHolder()方法用于为每个 TaskItemViewHolder 调用。
这调用了
TaskItemViewHolder的bind()方法,该方法使用TaskItemBinding对象将布局的task变量设置为项目的Task。![image]()
-
task_item.xml 中的数据绑定代码使用 task 属性来设置每个项目的视图。
task_name视图的text属性设置为task.taskName,task_done视图的checked属性设置为task.taskDone。![image]()
测试驱动
当我们运行应用时,TasksFragment在回收视图中显示一个卡片网格。它的行为与以前相同,但这次我们使用数据绑定。

恭喜!你现在学会了如何在回收视图中实现数据绑定,以及如何利用DiffUtil。
在下一章中,我们将进一步利用这些知识使回收视图导航到单个记录。
适配器磁铁

Bits and Pizzas 应用程序包括一个使用名为pizza_item.xml的布局的回收视图用于其项目。布局定义了一个数据绑定变量(命名为pizza),如下所示:
<layout...>
<data>
<variable
name="pizza"
type="com.hfad.bitsandpizzas.Pizza" />
</data>
...
</layout>
回收视图使用一个名为PizzaAdapter的适配器,如下所示。看看你能否完成这个适配器的代码,以便设置布局的pizza数据绑定变量。
package com.hfad.bitsandpizzas
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.hfad.bitsandpizzas.databinding. ...........................................
class PizzaAdapter
: ListAdapter<Pizza, PizzaAdapter.PizzaViewHolder>(PizzaDiffItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: PizzaViewHolder = PizzaViewHolder.inflateFrom(parent)
override fun onBindViewHolder(holder: PizzaViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
class PizzaViewHolder(val binding:...............................)
: RecyclerView.ViewHolder(binding.root) {
companion object {
fun inflateFrom(parent: ViewGroup): PizzaViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ..................................
.inflate(.................................., parent, false)
return PizzaViewHolder(binding)
}
}
fun bind(item: Pizza) {
..................................= ..................................
}
}
}

适配器磁铁解决方案

Bits and Pizzas 应用程序包括一个使用名为pizza_item.xml的布局的回收视图用于其项目。布局定义了一个数据绑定变量(命名为pizza),如下所示:
<layout...>
<data>
<variable
name="pizza"
type="com.hfad.bitsandpizzas.Pizza" />
</data>
...
</layout>
回收视图使用一个名为PizzaAdapter的适配器,如下所示。看看你能否完成这个适配器的代码,以便设置布局的pizza数据绑定变量。


成为 ListAdapter 解决方案

回收视图的ListAdapter类有一个名为 Drinks 的后备列表。它使用右侧显示的 Drink 类。你的任务是像ListAdapter一样操作,并说出当给定一个新列表时,下面的 ItemCallback 类是否能正确检测到任何更改。为什么?为什么不?


你的 Android 工具箱

你已经掌握了第十六章,现在你已经将 DiffUtil 和回收视图数据绑定添加到了你的工具箱中。

第十七章:回收视图导航:挑选一张卡片

一些应用依赖于用户从列表中选择项目。
并且在本章中,您将学习如何使回收视图成为应用设计的核心部分,通过使它们的项目可点击。您将了解如何通过使应用程序在每次用户点击记录时导航到新屏幕来实现回收视图导航。您将了解如何向用户显示关于其选择记录的额外信息,并在数据库中更新它。到本章结束时,您将拥有将您的出色想法转变为梦想应用程序所需的所有工具...
回收视图可用于导航
在前两章中,您学习了如何构建一个显示可滚动数据列表的回收视图,并使用DiffUtil使其更高效。但这并不是全部故事的结束。
回收视图是许多 Android 应用程序的重要组成部分,因为除了显示数据列表外,您还可以使用它们浏览应用程序。当用户在回收视图中点击项目时,您可以使应用程序导航到一个新的片段,显示该记录的更多详细信息。
看看这是如何工作的,我们将改变任务应用,这样当用户点击其回收视图中的一个任务时,它将导航到一个新的片段。这个片段将显示所选择的记录,并允许用户更新或删除它:

当前任务应用的结构
在看看我们需要如何改变任务应用之前,让我们回顾一下它当前的结构。
应用程序由一个活动(MainActivity)组成,显示一个名为TasksFragment的片段。这个片段是应用程序的主屏幕,其布局包括一个回收视图,显示任务的网格。回收视图使用一个名为TaskItemAdapter的适配器,其项目使用布局文件进行排列。
TasksFragment使用一个名为TasksViewModel的视图模型。视图模型负责片段的业务逻辑,并使用名为TaskDao的接口从 Room 数据库获取数据。
这些组件如何配合:

我们将使回收视图导航到一个新的片段
我们将更新任务应用程序,这样当用户点击回收视图中的任务时,它将显示一个名为EditTaskFragment的片段。下面是新片段的样子:

正如您所见,EditTaskFragment包括一个编辑文本和一个复选框,让用户编辑任务。编辑文本显示任务的名称,复选框显示任务是否已完成。
片段还包括一个更新任务按钮,点击后更新数据库中的记录,和一个删除任务按钮,点击后删除记录。当点击其中任何一个按钮时,应用程序导航回TasksFragment,在其可回收视图中显示更新后的任务列表:

我们打算做什么
我们将按以下三个阶段构建应用的新版本:
-
使可回收视图中的项目响应点击。
我们将更新应用程序,以便在点击可回收视图中的任务时,显示其 ID 的提示。
![image]()
-
点击项目时导航到 EditTaskFragment。
我们将创建
EditTaskFragment,并使用导航组件在用户点击任务记录时导航到它。我们将在新的片段中显示任务的 ID。![image]()
-
在 EditTaskFragment 中显示任务记录,并允许用户更新或删除记录。
我们将为
EditTaskFragment创建一个视图模型,该视图模型将使用TaskDao接口与数据库交互。![image]()

我们将从使可回收视图响应点击开始。
使每个项目可点击

我们将首先对 Tasks 应用进行的更改是,在可回收视图中的项目被点击时显示一个提示。
我们可以通过为每个项目的根视图添加OnClickListener使每个项目响应点击。为此,我们将在每个项目的数据添加到其布局后立即调用每个项目的setOnClickListener()方法。
最佳位置以添加每个OnClickListener是在TaskItemViewHolder的bind()方法中,因为这是布局的数据绑定变量设置为Task项目的地方。您可能还记得,bind()方法由TaskItemAdapter的onBindViewHolder()方法调用,后者每次需要显示项目数据时都会触发。
以下是向每个项目布局的根视图添加OnClickListener的代码;我们将在稍后几页中的TaskItemAdapter.kt中添加这些代码:


现在您知道如何为每个项目添加OnClickListener后,让我们在点击项目时显示一个提示。
我们应该在哪里创建提示框?
每当点击项目时显示提示,我们可以简单地将以下代码(加粗)添加到视图持有者的setOnClickListener()方法中:

然而,这种方法意味着我们会将描述应用行为的代码放入视图持有者代码中。此代码负责将数据绑定到每个项目的布局,因此这不是我们放置这种代码的合适位置。
将提示代码添加到视图持有者将使视图持有者代码不太灵活。这意味着每次用户点击项目时,只能显示一个提示,并且不能在其他地方重复使用。
那么替代方案是什么?
我们将让 TasksFragment 通过 lambda 传递 toast 代码。
另一种方法是在 TasksFragment 中定义每个项需要执行的代码,并通过 lambda 将其传递给 TaskItemViewHolder—通过 TaskItemAdapter。这样做意味着fragment控制项被点击时发生的事情,而不是视图持有者。
在我们查看代码之前,让我们看一下它将如何工作。
代码如何运行
这是我们将要编写的代码将会做的事情:
-
TasksFragment 将会在 TaskItemAdapter 的构造函数中传递一个 lambda。
Lambda 包含显示 toast 的代码。
![image]()
-
当调用 TaskItemAdapter 的 onBindViewHolder() 方法时,它调用 TaskItemViewHolder 的 bind() 方法,并将 lambda 传递给它。
![image]()
-
TaskItemViewHolder 将 lambda 添加到每个项的 OnClickListener 中。
当用户点击每个项(一个
CardView)时,它执行 lambda 并显示 toast。![image]()
这样代码就会运行。
要实现这一点,我们需要更新 TasksFragment、TaskItemAdapter 和 TaskItemViewHolder 的代码。我们将从更新 TaskItemAdapter 和 TaskItemViewHolder 的代码开始,以便适配器能够接受 lambda,并将其传递给视图持有者。
我们将在下一页展示这段代码。
TaskItemAdapter.kt 的完整代码
这是 TaskItemAdapter 和 TaskItemViewHolder 的代码;请更新 TaskItemAdapter.kt,确保包含这里显示的所有更改(用粗体表示):

我们将在 TaskItemAdapter 中传递一个 lambda
现在 TaskItemAdapter 在其构造函数中包含了一个 lambda 参数,我们需要在创建它的 TasksFragment 代码中传递一个 lambda。
正如您可能还记得的那样,TasksFragment 在其 onCreateView() 方法中创建了一个 TaskItemAdapter 对象,并将其分配给 RecyclerView,如下所示:

因为我们希望每次在 RecyclerView 中的项被点击时显示 toast,我们可以更新这段代码,以便将以下 lambda 传递给 TaskItemAdapter 的构造函数:

适配器将 lambda 传递给 TaskItemViewHolder 的 bind() 方法,后者在分配给每个项根视图的 OnClickListener 代码中使用 lambda。当用户点击 RecyclerView 中的项时,lambda 就会执行。
现在你已经学会了如何使 RecyclerView 中的项在被点击时显示 toast。让我们看看 TasksFragment 的完整代码是什么样的。

TasksFragment.kt 的完整代码。

这是更新后的 TasksFragment 代码;确保 TasksFragment.kt 中的代码包括这里显示的所有更改(用粗体表示):


让我们看看代码运行时会发生什么。
代码运行时会发生什么。
应用程序运行时发生以下事情:
-
TasksFragment创建一个TaskItemAdapter对象,并将其分配给 RecyclerView 作为其适配器。片段将一个 lambda(名为
clickListener)传递给适配器,告诉它在执行时显示一个提示。![image]()
-
TasksFragment向TaskItemAdapter提交一个List<Task>。List<Task>包含来自数据库的最新记录列表。![image]()
-
对每个需要显示在 RecyclerView 中的项目,
TaskItemAdapter的onCreateViewHolder()方法都会被调用。这创建了一组
TaskItemViewHolder。![image]()
-
对每个
TaskItemViewHolder调用TaskItemAdapter的onBindViewHolder()方法。这调用了
TaskItemViewHolder的bind()方法,将被点击的项目和clickListenerlambda 传递给它。![image]()
-
TaskItemViewHolder的bind()方法将一个 OnClickListener 添加到每个视图持有者布局的根视图上。在这个示例中,根视图是一个
CardView。![image]()
-
当用户点击 RecyclerView 中的项目时,OnClickListener 注册了点击事件。
执行
clickListenerlambda,显示一个提示。![image]()
让我们带着这个应用程序来试驾一下。
测试驾驶
当我们运行应用程序时,TasksFragment如前所述在 RecyclerView 中显示一组卡片。如果我们点击其中一个任务,应用程序会显示其 ID 的提示。

现在你已经学会了如何使 RecyclerView 的项目响应点击事件。我们将使用这些知识使应用程序在点击项目时导航到一个新的片段。
在我们这样做之前,试试以下练习。
适配器磁铁

Bits and Pizzas 应用程序包含一个使用名为pizza_item.xml的布局来显示Pizza对象的 RecyclerView。Pizza数据类如下所示:
package com.hfad.bitsandpizzas
data class Pizza(
var pizzaId: Long = 0L,
var pizzaName: String = "",
var pizzaDescription: String = "",
var pizzaImageId: Int = 0
)
RecyclerView 使用一个名为PizzaAdapter的适配器,如下所示。看看你能否完成这个适配器的代码,以便当其项目之一被点击时,执行一个传递给适配器构造函数的 lambda。
提示:lambda 应该接受一个pizzaId参数,并返回Unit。
package com.hfad.bitsandpizzas
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.hfad.bitsandpizzas.databinding.PizzaItemBinding
class PizzaAdapter(val clickListener:..................................................)
: ListAdapter<Pizza, PizzaAdapter.PizzaViewHolder>(PizzaDiffItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: PizzaViewHolder = PizzaViewHolder.inflateFrom(parent)
override fun onBindViewHolder(holder: PizzaViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item,........................)
}
class PizzaViewHolder(val binding: PizzaItemBinding)
: RecyclerView.ViewHolder(binding.root) {
companion object {
fun inflateFrom(parent: ViewGroup): PizzaViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = PizzaItemBinding.inflate(layoutInflater, parent, false)
return PizzaViewHolder(binding)
}
}
fun bind(item: Pizza,
................................................................) {
binding.pizza = item
binding.root..............................
........................................
.......................
}
}
}

适配器磁铁解决方案

Bits and Pizzas 应用程序包含一个使用名为pizza_item.xml的布局来显示Pizza对象的 RecyclerView。Pizza数据类如下所示:
package com.hfad.bitsandpizzas
data class Pizza(
var pizzaId: Long = 0L,
var pizzaName: String = "",
var pizzaDescription: String = "",
var pizzaImageId: Int = 0
)
RecyclerView 使用一个名为PizzaAdapter的适配器,如下所示。看看你能否完成这个适配器的代码,以便当其项目之一被点击时,执行一个传递给适配器构造函数的 lambda。
提示:lambda 应该接受一个pizzaId参数,并返回Unit。


我们希望使用 RecyclerView 导航到一个新的片段

到目前为止,您已经学会了如何使 RecyclerView 中的项目响应点击事件。例如,当用户点击 Tasks 应用程序的 RecyclerView 中的任务时,它会显示一个 Toast。
接下来,我们将更改此行为,以便用户单击项目时,应用程序导航到一个新的片段(我们将创建),并显示任务的 ID。下面是应用程序的新版本的样子:

要使其工作,我们将使用导航组件导航到新的片段,并使用安全参数插件传递任务的 ID。这意味着我们需要更新项目和应用的build.gradle文件以包含这些组件。
更新项目的 build.gradle 文件……
我们将首先更新项目的build.gradle文件,以便指定我们要使用的导航组件版本,并为 Safe Args 插件添加一个类路径。
要做到这一点,打开文件Tasks/build.gradle,并在相关部分添加以下行(用粗体标出):

……然后更新应用的 build.gradle 文件
我们还需要将 Safe Args 插件添加到应用的build.gradle文件中,并添加导航组件的依赖项。
打开文件Tasks/app/build.gradle,并在适当的部分添加以下行(用粗体标出)。

完成这些更改后,点击“立即同步”选项,将所做的更改与项目的其余部分同步。
现在我们已经启用了导航组件和 Safe Args 插件,让我们创建新的片段,使应用程序能够导航到该片段。
创建 EditTaskFragment……
我们将创建一个名为EditTaskFragment的新片段,当用户点击 RecyclerView 中的项目时,应用程序将导航到该片段。
要做到这一点,请在app/src/main/java文件夹中的com.hfad.tasks包中突出显示,然后选择文件→新建→Fragment→Fragment(空白)。将片段命名为“EditTaskFragment”,命名其布局为“fragment_edit_task”,并确保语言设置为 Kotlin。

我们将更新EditTaskFragment的代码及其布局,这在接下来的几页中。在此之前,让我们创建一个导航图,告诉应用程序如何在其片段之间导航。
…并创建导航图
我们将在项目中添加一个导航图,就像我们为其他创建的应用程序一样。
在项目资源管理器中选择Tasks/app/src/main/res文件夹,然后选择文件→新建→Android 资源文件。当提示时,输入文件名“nav_graph”,选择资源类型“Navigation”,然后点击 OK 按钮。这将创建一个名为nav_graph.xml的导航图。

导航图需要描述用户如何在TasksFragment和EditTaskFragment之间导航。以下是导航需要工作的方式:
-
应用程序显示 TasksFragment。
这是用户需要看到的第一个片段,因此它需要成为导航图的起始目的地。
![image]()
-
当用户在 TasksFragment 的回收视图中点击项目时,应用程序将导航到 EditTaskFragment。
TasksFragment将向EditTaskFragment传递一个包含点击项目的任务 ID 的Long参数。 -
当用户在 EditTaskFragment 中点击按钮(稍后在本章中添加到该片段),应用程序将导航回 TasksFragment。
![image]()
我们将在下一页展示此操作的完整代码。
更新导航图
这里是导航图的完整代码;请更新nav_graph.xml,以包含这里显示的更改(用粗体标出):

接下来,让我们将导航图链接到MainActivity,以便在导航到各个片段时显示它。
在 MainActivity 的布局中添加 NavHostFragment
要将我们刚刚创建的导航图链接到MainActivity,我们需要向其布局添加导航宿主,并告诉它使用nav_graph.xml作为其导航图。这将允许MainActivity在用户通过应用程序导航时显示正确的片段。

我们将以与前几章相同的方式向布局中添加导航宿主:通过将NavHostFragment添加到activity_main.xml的FragmentContainerView。更新activity_main.xml,使其包含下面的更改(用粗体标出):

这就是我们需要更改MainActivity布局的所有代码。接下来,让我们在用户点击其回收视图中的项目时,使TasksFragment导航到EditTaskFragment。
使TasksFragment导航到EditTaskFragment
每次用户点击TasksFragment回收视图中的项目时,我们希望应用程序导航到EditTaskFragment,并向其传递被点击的Task的 ID。

一种做法是更新TasksFragment传递给其TaskItemAdapter的 lambda,使其包含所有必要的导航代码,如下所示:

如您所见,这里使用了TasksFragmentDirections类(由 Safe Args 插件生成),将项目的任务 ID 传递给EditTaskFragment,并导航到该片段。
然而,这种方法意味着我们正在向片段代码中添加业务逻辑——决定TasksFragment何时导航到EditTaskFragment,而不是将其添加到TasksViewModel中。正如您在第十三章中学到的,视图模型代码应该做出这种决定,而不是片段。
为了解决这个问题,我们将使用类似于 第十三章 中猜数字游戏应用的方法。我们将向 TasksViewModel 添加一个新的 LiveData 属性,用于存储用户点击任务时的任务 ID。当此属性的值发生变化时,TasksFragment 将响应并导航到 EditTaskFragment,并将任务 ID 传递给它。

为 TasksViewModel 添加一个新属性
我们将首先向 TasksViewModel 添加一个新的 LiveData 属性,指定 TasksFragment 需要传递给 EditTaskFragment 的任务 ID。我们将命名这个属性为 navigateToTask,并使用以下代码定义它(我们将在 TasksViewModel.kt 的下一页中添加这段代码):

正如您所看到的,navigateToTask 属性使用一个标记为私有的可变后备属性,这意味着只有 TasksViewModel 可以设置它。这样可以防止其他类对属性进行不必要的更新。
添加方法来更新新属性
每当用户在 RecyclerView 中点击任务时,我们希望 TasksFragment 导航到 EditTaskFragment,并将任务的 ID 传递给它。
为了实现这一点,我们将在 TasksViewModel 中添加两个方法——onTaskClicked() 和 onTaskNavigated()——用于设置 navigateToTask 的后备属性的值。onTaskClicked() 将把属性设置为任务的 ID,而 onTaskNavigated() 将把它设置为 null。

这里是两个方法的代码:

这些是我们需要对 TasksViewModel 进行的所有更改。我们将在下一页上展示完整的代码。
TasksViewModel.kt 的完整代码
这里是更新后的 TasksViewModel 代码;确保 TasksViewModel.kt 中的代码包含这里显示的所有更改(用粗体标记):

现在我们已经更新了 TasksViewModel,让我们看看需要对 TasksFragment 代码进行哪些更改。
使 TasksFragment 导航到 EditTaskFragment
我们需要更新 TasksFragment 的代码,以便当用户点击任务时,它导航到 EditTaskFragment,并将任务的 ID 传递给它。
为了实现这一点,当用户点击任务时,我们将调用 TasksViewModel 的 onTaskClicked() 方法,并在其 navigateToTask 属性更新为新的任务 ID 时导航到 EditTaskFragment。
当用户点击任务时调用 onTaskClicked() 方法
要调用 onTaskClicked() 方法,我们将将下面的 lambda(用粗体标记)传递给 TaskItemAdapter 的构造函数:

每当用户点击任务时,lambda 将被执行:它将调用 TasksViewModel 的 onTaskClicked() 方法,将 navigateToTask 属性设置为任务的 ID。

当 navigateToTask 更新时导航到 EditTaskFragment
要使TasksFragment导航到EditTaskFragment,我们将使其观察TaskViewModel的navigateToTask属性。当该属性设置为Long类型的任务 ID 时,片段将导航到EditTaskFragment,并将 ID 传递给它。然后,我们通过调用视图模型的onTaskNavigated()方法将navigateToTask属性设置回 null。
这里是执行此操作的代码:

让我们更新TasksFragment的代码。
TasksFragment.kt的完整代码如下

这里是更新后的TasksFragment代码;确保代码包含所有这里显示的更改(用粗体标出):


我们现在已经更新了TasksViewModel和TasksFragment的代码,以便当用户在recyclerView中点击任务时,TasksFragment导航到EditTaskFragment并将任务的 ID 传递给它。
我们需要做的下一件事是在EditTaskFragment的布局中显示任务 ID。现在让我们来做这件事。
使EditTaskFragment显示任务 ID
我们将使EditTaskFragment通过更新片段的布局和 Kotlin 代码显示任务 ID。我们将在布局中添加一个文本视图,然后使用 Kotlin 代码检索任务的 ID 并将其添加到文本视图中。
我们将从在片段的布局中添加文本视图开始;更新fragment_edit_task.xml以使其与此处显示的代码匹配:

我们还需要更新EditTaskFragment.kt。
现在我们已经在EditTaskFragment的布局中添加了一个文本视图,我们需要设置其文本为任务 ID。为了做到这一点,我们将以下代码添加到片段的onCreateView()方法中:

正如你所看到的,这里使用了EditTaskFragmentArgs类(由 Safe Args 插件生成)来获取传递给EditTaskFragment的taskId参数的值。然后,它使用这个值来设置文本视图的文本。
让我们看看EditTaskFragment的完整代码是什么样子的。

EditTaskFragment.kt的完整代码如下
这里是EditTaskFragment的完整代码;请将EditTaskFragment.kt中的代码替换为此处显示的代码:

让我们来看看代码运行时发生了什么。
代码运行时发生了什么
当应用程序运行时,会发生以下事情:
-
TasksFragment创建了一个TaskItemAdapter对象,并将其分配给了recyclerView作为其适配器。片段将一个
clickListenerlambda 传递给适配器,在执行时告诉它调用TasksViewModel的onTaskClicked()方法。![image]()
-
TasksFragment向TaskItemAdapter提交了一个List<Task>。List<Task>包含来自数据库的最新记录列表。![image]()
-
TaskItemAdapter创建了一组TaskItemViewHolder,并为每个视图持有者的根视图设置了一个OnClickListener。在这个例子中,根视图是一个
CardView。![image]()
-
当用户在 RecyclerView 中点击任务时,OnClickListener 注册点击事件,并执行 lambda 表达式。
它调用
TasksViewModel的onTaskClicked()方法,该方法将其_navigateToTask属性设置为被点击任务的 ID。![图片]()
-
TasksFragment 得知 TasksViewModel 的
navigateToTask属性已更新,该属性使用 _navigateToTask 作为其后备属性。导航到
EditTaskFragment,传递navigateToTask属性的值。![图片]()
-
EditTaskFragment 获取传递给它的 taskId 的值,并在其布局中显示它。
![图片]()
让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用程序时,TasksFragment 如以前一样在 RecyclerView 中显示卡片网格。
当我们点击其中一个任务时,应用程序导航到 EditTaskFragment,该片段显示任务的 ID。

您现在学会了如何使用 RecyclerView 导航到新的片段,并告诉它点击了哪个项目。
接下来,我们将更新 Tasks 应用程序,以便当用户点击项目时,EditTaskFragment 显示任务的完整详情,并允许她在数据库中更新或删除记录。
我们希望使用 EditTaskFragment 更新任务记录

我们现在已经更新了 Tasks 应用程序,以便当用户在 RecyclerView 中点击任务时,它导航到 EditTaskFragment,该片段显示任务的 ID。
但我们真正想要的是,EditTaskFragment 显示完整的任务记录,并允许用户在数据库中更新或删除它。
为此,我们将更新 EditTaskFragment,使其如下所示:

片段的新版本将如下工作:
-
当用户在 RecyclerView 中点击任务时,EditTaskFragment 显示其详细信息。
它从数据库获取任务记录,并显示任务名称及其完成状态。
-
当用户更新任务的详细信息并点击“更新任务”按钮时,更改将被保存。
它会更新数据库中的记录,并导航回到
TasksFragment。 -
当用户点击“删除任务”按钮时,任务将被删除。
它从数据库中删除任务记录,并导航到
TasksFragment。
对于这些操作中的每一个,片段需要与应用程序的 Room 数据库进行交互,这意味着它需要使用我们在 第十四章 中定义的 TaskDao 接口。在我们更新应用程序之前,让我们快速回顾一下 TaskDao 的功能。
使用 TaskDao 与数据库记录交互
正如您在 第十四章 中学到的,可以使用 DAO 接口与 Room 数据库中的记录交互。例如,Tasks 应用程序包括一个名为 TaskDao 的 DAO,允许我们与任务记录进行交互。
这是TaskDao代码的提醒:

如您所见,接口包括从数据库获取一个或多个记录的方法,以及在后台线程中插入、更新和删除记录的可暂停协程。
我们将创建一个视图模型来访问TaskDao的方法
我们需要使用TaskDao的方法,让EditTaskFragment从数据库获取任务记录,更新其详细信息或删除它。我们不会把这些代码添加到EditTaskFragment中,而是会创建一个新的视图模型(命名为EditTaskViewModel),它将处理片段的业务逻辑和数据。EditTaskViewModel将访问TaskDao的方法,并将结果传递给EditTaskFragment。
让我们去创建EditTaskViewModel。

创建 EditTaskViewModel
要创建EditTaskViewModel,请在app/src/main/java文件夹中突出显示com.hfad.tasks包,然后转到 File→New→Kotlin Class/File。将文件命名为EditTaskViewModel,选择 Class 选项。
视图模型需要获取一个任务记录…
EditTaskViewModel首先需要做的是从应用程序的数据库获取一个任务记录,以便可以在EditTaskFragment的布局中显示它。为此,我们将在其构造函数中传递两件事给视图模型:一个任务 ID 来告诉它获取哪个任务,以及一个TaskDao对象,它将用于与数据库交互。

我们还将在视图模型中添加一个LiveData<Task>属性(命名为task),我们将使用TaskDao的get()方法设置它。这将把属性设置为用户想要查看的任务记录。
以下是完成此操作的代码;我们将在后面的几页上展示完整的EditTaskViewModel代码:

…并包括更新和删除任务的方法
我们还将在EditTaskViewModel中添加updateTask()和deleteTask()方法,EditTaskFragment将使用它们来更新或删除任务记录。这些方法将像这样调用TaskDao的update()和delete()协程:

在我们将此代码添加到EditTaskViewModel之前,让我们看看视图模型代码还需要做什么。
EditTaskViewModel会告诉EditTaskFragment何时导航
EditTaskViewModel最后需要做的一件事是告诉EditTaskFragment何时应该导航回到TasksFragment。为此,我们将在视图模型中添加一个新的LiveData<Boolean>属性(命名为navigateToList),以及一个名为_navigateToList的支持属性。EditTaskFragment将观察navigateToList,因此当其值变为true时,它将导航到TasksFragment。
我们将在视图模型的updateTask()和deleteTask()方法中将_navigateToList设置为true。这意味着一旦任务记录已更新或删除,应用程序将导航到TasksFragment。

这些方法的更新代码如下:

我们还将向 EditTaskViewModel 添加一个名为 onNavigatedToList() 的新方法,该方法将 _navigateToList 设置回 false。
这是此方法的代码:

我们将在下一页上展示 EditTaskViewModel 的完整代码。
EditTaskViewModel.kt 的完整代码
这是完整的 EditTaskViewModel 代码;确保 EditTaskViewModel.kt 中的代码包含这里显示的所有更改(用粗体表示):

这就是我们需要的整个 EditTaskViewModel 的代码。接下来是什么?
EditTaskViewModel 需要一个视图模型工厂

接下来要做的是定义一个名为 EditTaskViewModelFactory 的视图模型工厂,EditTaskFragment 将使用它来创建 EditTaskViewModel 的实例。正如你在第十一章中学到的,对于所有像 EditTaskViewModel 这样没有无参数构造函数的视图模型,都需要一个视图模型工厂。

创建 EditTaskViewModelFactory
要创建工厂,请在 app/src/main/java 文件夹中的 com.hfad.tasks 包中突出显示,然后转到 文件→新建→Kotlin Class/File。将文件命名为“EditTaskViewModelFactory”,选择“类”选项。
创建完文件后,更新 EditTaskViewModelFactory.kt 中的代码,使其看起来像这样:

现在我们已经编写了 EditTaskViewModel 及其工厂的代码,让我们更新 EditTaskFragment 和其布局的代码。我们将从布局开始。
fragment_edit_task.xml 需要显示任务
我们将更新 fragment_edit_task.xml,以包含编辑文本和复选框,这些将用于显示 Task 数据。我们将使用数据绑定将这些视图绑定到 EditTaskViewModel 的 task 中的 taskName 和 taskDone 属性。
我们还将向布局添加两个按钮,这些按钮将调用 EditTaskViewModel 的 deleteTask() 和 updateTask() 方法,并允许用户更新或删除任务记录。
这是布局的更新代码;更新 fragment_edit_task.xml 的代码,以包含这些更改(用粗体表示):


我们也需要更新 EditTaskFragment.kt
我们需要对 Tasks 应用程序进行的最后更改是更新 EditTaskFragment 的 Kotlin 代码。代码需要执行三个操作:
-
设置布局的 viewModel 数据绑定变量。
我们将其设置为
EditTaskViewModel的一个实例,该实例将由片段创建。 -
设置布局的生命周期所有者。
这样可以使布局能够与实时数据属性进行交互。
-
观察视图模型的 navigateToList 属性。
当这个条件变为 true 时,片段将导航到
TasksFragment,并调用EditTaskViewModel的onNavigatedToList()方法。
由于你已经熟悉如何完成所有这些代码,我们将在下一页上展示 EditTaskFragment 的更新代码。
EditTaskFragment.kt 的完整代码
这是完整的EditTaskFragment代码:确保EditTaskFragment.kt文件中的代码包含此处显示的所有更改(加粗部分)。


这就是我们需要让EditTaskFragment在其布局中显示一个Task并允许用户更新或删除它的所有内容。让我们来看看代码在运行时做了什么。
代码运行时会发生什么
应用程序运行时会发生以下事情:
-
当用户点击任务时,TasksFragment 导航到 EditTaskFragment,并传递任务 ID。![图片]()
-
EditTaskFragment 获取其 EditTaskViewModel 对象的引用。![图片]()
-
EditTaskViewModel 调用 TaskDao 对象的 get()方法,传递任务 ID。![图片]()
-
TaskDao 的 get()方法返回一个 LiveData,赋给 EditTaskViewModel 的 task 属性。 ![图片]()
-
当用户点击更新任务按钮时,它调用 EditTaskViewModel 的 updateTask()方法。此方法使用
TaskDao的update()方法来更新数据库中的记录。![图片]()
-
当用户点击删除任务按钮时,它调用 EditTaskViewModel 的 deleteTask()方法。此方法使用
TaskDao的delete()方法来删除记录。![图片]()
-
应用程序导航到 TasksFragment。对任务记录进行的任何更改都会在循环视图中反映出来。
![图片]()
让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用程序时,TasksFragment像以前一样在循环视图中显示一组卡片。
当我们点击其中一个任务时,应用程序导航到EditTaskFragment,显示任务的记录。
当我们对任务进行更改并点击更新任务按钮时,更改将保存到数据库,并在TasksFragment的循环视图中显示。

当我们尝试更新任务时会发生什么?删除任务时呢?
当我们点击一个任务时,应用程序导航到EditTaskFragment,并像以前一样显示任务记录。
当我们点击删除任务按钮时,记录会从数据库中删除。当应用程序导航到TasksFragment时,该记录将不再出现在循环视图中。

恭喜!现在你已经学会了如何构建一个应用程序,使用循环视图导航到记录,然后可以更新或删除它们。这种技术为你在应用程序中组织数据提供了一种强大而灵活的方式。
你的安卓工具箱

你已经掌握了第十七章,现在你已经将循环视图导航添加到你的工具箱中。

第十八章:Jetpack Compose: 自我组合

到目前为止,你构建的所有 UI 都使用了视图和布局文件。
但是,使用Jetpack Compose,这并不是唯一的选择。在本章中,我们将前往Composeville,了解如何使用 Compose 组件(称为composables)构建 UI。你将学习如何使用内置的 composables,如Text、Image、TextField和Button。你将探索如何将它们排列在Rows和Columns中,并使用主题进行样式化。你将编写并预览自己的composable 函数。甚至,你将了解如何使用MutableState对象来管理 composable 的状态。翻页,让我们开始 Compose 吧…
UI 组件不必是 Views
到目前为止,在这本书中,你已经学会了如何使用布局文件和视图来构建时髦且交互式的 UI。但是尽管我们专注于这种方法,这并不是你唯一的选择。
另一个选择是使用Jetpack Compose构建你的 UI。Compose 是 Android Jetpack 的一部分;它是一个完整的工具包,包含了库、工具和 API,旨在帮助你使用纯 Kotlin 代码构建原生 UI。
令人兴奋的消息是,使用Jetpack Compose可以在现有的 Android 知识基础上构建应用。例如,你可以在 Compose 中使用视图模型和 LiveData,甚至将 Compose 组件添加到现有的 UI 中。
注意
在下一章中,你将进一步了解这一点。
我们将构建一个 Compose 应用
在本章中,我们将通过构建一个新的温度转换器应用来向你介绍 Compose,该应用可以将摄氏度转换为华氏度。这是应用程序的预览:

正如你所见,这个应用使用了几个看起来很熟悉的组件。主要区别在于它是使用 Compose 编写的。
在我们开始构建应用之前,试试看能否通过尝试以下练习来理解 Compose 代码的作用。
这是我们将要做的事情
现在你已经初步了解了一些 Compose 代码并了解了它的作用,让我们看看本章中我们将要做什么。
-
创建一个在列中显示两个文本项的应用。
你将创建一个新项目,使用 Compose 显示一些硬编码文本。然后将代码转换为 composable 函数,并学习如何预览它。
![image]()
-
使应用程序能够将摄氏度转换为华氏度。
在此步骤中,你将构建一个 UI,允许你输入摄氏度温度。当你点击按钮时,它会将温度转换为华氏度。
![image]()
-
修改应用的外观。
最后,你将学习如何居中组件,使用填充并应用主题。
![image]()
让我们开始创建这个应用的新项目。
创建一个新的 Compose 项目

我们将创建一个新的 Android Studio 项目,该项目将使用 Compose 进行 UI 设计。现在通过选择空 Compose 活动选项创建此项目:

选择此选项会向您的项目添加一堆 Compose 库和代码。如果您想要使用 Compose UI 从头开始构建 Android 应用程序,这是最好的项目类型。
一旦选择了“空 Compose 活动”选项,请点击“下一步”按钮配置项目。
配置项目
下一个屏幕应该对您来说很熟悉,因为它包含您在本书中用来配置项目的相同选项。
输入名称为“温度转换器”,包名称为“com.hfad.temperatureconverter”,并接受默认保存位置。
注意,语言设置为 Kotlin,不能更改。Compose 应用只能使用 Kotlin 创建,因此无法选择其他语言。
选择 API 21 作为最低 SDK,以便应用程序在大多数 Android 设备上运行。这是这种类型项目可用的最旧 SDK,因为 Compose 仅与 API 21 及以上版本兼容。
一旦选择了这些选项,点击“完成”按钮。
Compose UI 只能使用 Kotlin 创建。

Compose 项目没有布局文件
当您使用“空 Compose 活动”选项创建项目时,Android Studio 会为您创建文件夹结构,并填充新项目所需的所有文件。文件夹结构如下所示:

许多文件和文件夹应该对您来说很熟悉,因为它们与不使用 Compose 的项目生成的相同。例如,它包括一个名为MainActivity.kt的活动文件,以及一个名为strings.xml的String资源文件。
最大的区别在于Android Studio 不会为您生成任何布局文件。这是因为 Compose 项目使用活动代码来定义屏幕的外观,而不是布局。
Compose 活动代码的外观
当您使用 Compose 时,活动代码负责应用程序的行为和外观。因此,它看起来与您习惯的活动代码略有不同。
让我们看看基本的 Compose 活动代码是什么样的。在app/src/main/java文件夹中打开包com.hfad.temperatureconverter,并打开文件MainActivity.kt(如果尚未打开)。然后用这里显示的代码替换Android Studio 生成的代码:

Compose 活动扩展 ComponentActivity
正如你所见,上述活动不是扩展AppCompatActivity,而是使用**ComponentActivity**。 androidx.activity.ComponentActivity是Activity的子类,用于定义一个基本活动,该活动使用 Compose 来进行 UI 设计,而不是使用布局文件。
就像您看到的所有其他活动一样,该活动重写了 onCreate() 方法。但是,与调用 setContentView() 来填充活动的布局不同,它使用了 **setContent()**。这是一个扩展函数,用于向活动的 UI 添加 Compose 组件—称为可组合—以便它们在活动创建时运行。
让我们看看通过使用 Compose 向活动的 UI 添加一些文本是如何工作的。
使用 Text 可组合显示文本
我们将通过向 setContent() 调用添加一个 **Text** 可组合来使 MainActivity 显示一些文本。您可以将 Text 视为文本视图的 Compose 等效项。只需指定要显示的文本,活动就会显示它。
下面是向 MainActivity 添加一些文本的代码;更新 MainActivity.kt 中的代码以包含以下更改(用粗体标出):

当此代码运行时,会在屏幕顶部显示文本,如下所示:

现在您已经学会了如何使用 Compose 显示一些硬编码文本,让我们通过将 Text 添加到可组合函数中使其更加灵活。
在可组合函数中使用可组合
可组合函数是使用一个或多个可组合函数来定义 UI 的函数。
为了看到这是如何工作的,我们将定义一个名为 Hello 的可组合函数,它接受一个 String 参数作为用户的姓名。当函数运行—或被组合—时,它将将 String 添加到一个 Text 可组合中,该可组合在 UI 中显示文本。
新的 Hello 函数的代码如下:

如您所见,该函数带有 **@Composable** 注释。此注释对于所有可组合函数都是必需的。如果省略注释,代码将无法编译。
从 setContent() 调用 Hello 可组合
使用 @Composable 标记函数不仅指定它使用可组合;它使函数本身成为一个可组合,您可以像任何其他类型的可组合一样在代码中使用它。
在我们正在构建的应用程序中,我们希望在 MainActivity 的 UI 中显示 Hello 文本。我们可以通过像这样从 setContent() 调用 Hello 来实现:

当此代码运行时,会显示如下文本:

大多数 UI 都有多个可组合
到目前为止,您已经看到如何运行单个可组合,但大多数情况下,您将希望在 UI 中使用多个可组合,或多次调用相同的可组合。例如,如果您希望应用程序说两次 hello,您可以像这样两次运行 Hello 可组合函数,使用不同的参数:
Hello("friend")
Hello("everyone")
当您的 UI 包含多个可组合时,您需要指定它们应该如何排列。如果不这样做,Compose 将像这样将可组合堆叠在一起:

那么如何排列可组合?
您可以使用 Row 或 Column 排列可组合。
大多数情况下,您可能希望将可组合项排列为行或列,Compose 包括**Row**和**Column**可组合项,让您可以这样做。例如,要将两个Hello可组合项排列在列中,您只需将它们添加到Column可组合项中,如下所示:
Column {
Hello("friend")
Hello("everyone")
}
当代码运行时,可组合项按以下方式排列在列中:

让我们更新MainActivity,以便它生成这个用户界面。
MainActivity.kt的完整代码
我们将向MainActivity.kt添加Hello可组合函数,运行它两次,并将结果排列在列中。
这是文件的完整代码;更新MainActivity.kt以包含以下更改(加粗部分):

让我们试驾这个应用程序,看看它的外观。
测试驾驶
当我们运行应用程序时,将显示MainActivity。它包括排列在单列中的两个Hello可组合项。

恭喜!您现在已经学会了使用 Compose 创建MainActivity的用户界面,而不是将视图添加到布局文件中。
您可以预览可组合函数
使用可组合函数的另一个功能是,您可以在 Android Studio 中预览它们,而无需将应用加载到设备上。只要可组合函数没有任何参数,您就可以预览任何可组合函数,并且甚至可以使用此技术来预览整个组合—由可组合项构成的用户界面。
组合是由可组合项构成的用户界面。
通过使用**@Preview**对其进行注释,您可以预览可组合函数。例如,以下代码指定了一个名为PreviewMainActivity的可组合函数,允许您预览排列在列中的两个Hello可组合项:

在MainActivity.kt中添加 MainActivityPreview
要了解预览的工作原理,请将MainActivityPreview可组合函数添加到MainActivity.kt中。更新文件,使其包含此处显示的更改:

我们将向您展示如何在下一页上查看预览。
使用设计或拆分选项预览可组合项
通过选择活动文件的拆分或设计视图,您可以预览标有@Preview的任何可组合函数。选择拆分选项让您同时看到代码和预览,选择设计选项则仅显示预览。
当我们选择拆分选项时,MainActivityPreview的外观如下所示:

如果您更改正在预览的可组合函数,需要刷新预览才能看到其效果。只需单击预览顶部菜单中的“构建刷新”按钮,即可看到更改的效果。
现在您已经学会了如何预览可组合函数,请尝试以下练习。
您可以预览任何没有参数的可组合函数。
池子难题

您的任务是从池中提取代码片段,并将其放入下面代码中的空白行中。您不能多次使用同一个片段,也不需要使用所有片段。您的目标是创建两个可组合函数:一个名为 TeamHello,接受一个姓名列表并向每个人打招呼;另一个名为 HelloPreview,预览 TeamHello 并将其文本排列在一列中。

..............
fun TeamHello(names: List<String>) {
for (name in names) {
..............("Hello $name!")
}
}
..............(showBackground = true)
..............
fun HelloPreview() {
..............{
TeamHello(listOf("Virginia", "Zan", "Katie"))
}
}

注意
注意:每个池子中的东西只能使用一次!
池子谜题解决方案

您的任务是从池中提取代码片段,并将其放入下面代码中的空白行中。您不能多次使用同一个片段,也不需要使用所有片段。您的目标是创建两个可组合函数:一个名为 TeamHello,接受一个姓名列表并向每个人打招呼;另一个名为 HelloPreview,预览 TeamHello 并将其文本排列在一列中。



让我们开始让应用程序转换温度

到目前为止,您已经使用 Compose 显示文本,编写了一些可组合函数,并学习了如何预览它们。但这还不是全部。
在本章的其余部分中,我们将通过将刚刚构建的应用程序改为一个将温度从摄氏度转换为华氏度的应用程序,来深入了解 Compose。它不再只是打招呼,而是在用户在按钮上点击时,询问用户摄氏度温度并进行转换。
这是应用程序的外观;如您所见,它包括一个图像、一个允许您输入数据的文本字段、一个按钮和一些文本:

您还在等什么?让我们开始组合吧。
添加一个 MainActivityContent 可组合函数
我们将首先向 MainActivity.kt 添加一个名为 MainActivityContent 的新可组合函数,该函数将用作活动的主要内容。我们将添加所有 MainActivity UI 所需的可组合函数到这个函数中,并从 setContent() 和 PreviewMainActivity 中调用它。这种方法意味着当我们运行应用程序时,将显示活动的组合,同时也会在预览中显示。
我们还将从代码中删除 Hello 可组合函数,因为这不再需要。
这是更新后的 MainActivity.kt 代码;请更新文件以包含这些更改(用粗体标出):

显示标题图像…
我们将首先向 MainActivityContent 添加的组件是出现在屏幕顶部的图像。
首先确保您的项目包含文件夹 app/src/main/res/drawable(如果不存在,您需要创建它)。然后从 tinyurl.com/hfad3 下载 sunrise.webp 文件,并将其添加到 drawable 文件夹中。

…与一个 Image 可组合
使用**Image**组合在 Compose 中显示图像。基本代码如下:

Image组合需要两个参数:painter和contentDescription。
painter参数指定应该显示的图像。这里,它使用painterResource(R.drawable.sunrise)来显示sunrise.webp可绘制资源。
contentDescription参数是用于辅助功能的图像描述。
你可以使用许多其他可选参数来控制图像的外观和显示方式。例如,以下代码将图像高度设置为 180dp,使其填充可用宽度,并缩放图像:

现在你已经看到如何使用 Compose 添加图像,让我们在MainActivity中添加一个。
在 MainActivity.kt 中添加一个图像

我们将通过在MainActivity.kt中定义一个新的组合函数(名为Header)来向MainActivity添加一个图像。我们将从MainActivityContent组合函数中运行这个函数,以便将图像添加到 UI 和预览中。
这是MainActivity.kt的更新代码;更新这个文件以包含下面的更改(用粗体标出):

这就是我们需要在组合中显示图像的所有内容,让我们继续下一个组件。
让我们显示温度文本

接下来我们将包括一个组合函数(名为TemperatureText),它将摄氏温度转换为华氏温度,并显示结果。我们将从MainActivityContent中调用这个函数,以便它包含在 UI 和预览中。
你已经熟悉如何做到这一点了,所以更新MainActivity.kt以包含下面的更改(用粗体标出):

让我们来测试一下这个应用程序。
测试驾驶
当我们运行(或预览)应用程序时,它会在列中显示一个Image和Text。Text正确显示了 0°摄氏度对应的华氏度值。
现在我们已经确保TemperatureText函数可以使用一个硬编码的温度,让我们在用户点击按钮时将其更新为一个新的温度。

使用 Button 组合添加一个按钮
使用**Button**组合添加一个按钮到 Compose。Button的代码如下:

当使用Button组合时,需要指定两件事:其点击行为和按钮上应该显示什么。
使用Button的onClick参数来指定其点击行为。这个参数接受一个 lambda,每次用户点击按钮时都会运行。
通过一个单独的 lambda 来指定Button上应该显示什么。当代码运行时,它会将组合添加到Button中。例如,上面的代码将一个Text组合传递给Button,因此它创建了一个带有文本的按钮。
让我们编写一个 ConvertButton 组合函数
我们将在温度转换应用程序中添加一个Button组合,当点击时,将更改TemperatureText转换为华氏度的温度。为此,我们将编写一个新的组合函数(名为ConvertButton),用于显示一个Button。我们还会指定它接受一个 lambda 参数,该函数将用于Button的点击行为。
这是ConvertButton组合函数的代码,我们将在稍后的几页中将其添加到MainActivity.kt*:

现在我们已经编写了ConvertButton函数,让我们将其添加到MainActivityContent中,以便将Button组合添加到 UI 中。
我们需要传递一个 lambda 给 ConvertButton

要从MainActivityContent中运行ConvertButton,我们需要传递一个 lambda,指定点击时应该发生什么。代码应该如下所示:
@Composable
fun MainActivityContent() {
...
ConvertButton {
//Code that runs when the button is clicked
}
...
}
当点击ConvertButton时,我们希望它更新TemperatureText显示的文本。如果TemperatureText是视图,我们可以使用如下代码更新其文本:
binding.textView.text = "This is the new text"
尽管此方法适用于视图,但对于组合则不适用。对于组合,您需要采用不同的方法。

组合和视图的工作方式不同。
虽然视图和组合允许您显示类似的组件(例如文本和按钮),但它们的实现方式不同。组合不是View的一种类型,而View也不是组合的一种类型,因此要与组合交互,您需要以稍有不同的方式进行操作。
要了解这是如何工作的,请让我们在合成期间逐步了解 UI 的组成过程。
我们需要更改TemperatureText参数的值
正如您刚刚看到的那样,当它们依赖的值更新时,组合会重新组合。这意味着,如果我们希望TemperatureText在用户点击ConvertButton按钮时显示不同的文本,我们传递给ConvertButton的 lambda 需要更新TemperatureText参数的值。
为此,我们将在MainActivityContent中添加一个新的celsius变量,并将其值传递给TemperatureText。当用户点击ConvertButton组合时,我们将其更新为celsius的值,以便重新组合TemperatureText。
当其任何输入值发生更改时,组合将重新组合。
使用remember将celsius存储在内存中
我们将通过将以下代码添加到MainActivityContent来定义celsius变量:

这将创建一个类型为**MutableState**的对象,将其值设置为0,并将其存储在内存中。您可以将celsius看作是类似实时数据对象的工作方式。每次设置为新值时,使用它的任何组合都会收到通知并重新组合。
使用**remember**将对象存储在内存中。remember在首次组合调用它的组合内容(在本例中为MainActivityContent)时存储对象,并在从 UI 中移除组合内容时将其删除。这可能发生在用户旋转设备时,活动(包括其 UI)被销毁和重新创建。
就像MutableLiveData对象一样,您通过更新其**value**属性来设置MutableState对象的值。例如,要在用户点击ConvertButton组合时将celsius的值设置为 20,您可以使用以下代码:

ConvertButton { celsius.value = 20 }
为了使TemperatureText响应此值的更改,其参数需要设置为celsius.value,如下所示:
TemperatureText(celsius.value)
每次更新celsius.value时,TemperatureText都会重新组合以新值,这会改变显示的文本。
我们将在接下来的几页中展示完整的代码。
MainActivity.kt 的完整代码
到目前为止,MainActivity.kt 的完整代码如下;请更新文件以包含以下更改(用粗体标记):


让我们来看看代码运行时会发生什么,并且让应用程序试运行一下。
应用程序运行时会发生什么
当应用程序运行时会发生以下事情:
-
MainActivity 启动,并运行其 onCreate()方法。
它调用
setContent(),运行MainActivityContent组合函数。![image]()
-
MainActivityContent 创建一个名为 celsius 的 MutableState
变量,将其值设为 0,并将其存储在内存中。 ![image]()
-
MainActivityContent 运行 Header、ConvertButton 和 TemperatureText 组合函数。
它将
celsius的值传递给TemperatureText组合函数,该函数将其转换为华氏度。![image]()
-
Header、ConvertButton 和 TemperatureText 组合函数向 UI 添加了一个图像、一个按钮和一些文本。
![image]()
-
用户在 UI 中点击 ConvertButton 组合。
这将
celsius的值设置为 20。![image]()
-
TemperatureText 重新组合。
它将
celsius的新值转换为华氏度,并显示结果。![image]()
让我们来试运行应用程序。
试驾
运行应用程序时,会显示MainActivity。它包括一个图像、一个按钮和一些文本,显示了 0°摄氏度对应的华氏度值。
当我们点击 Convert 按钮时,文本会更新,显示 20°摄氏度对应的华氏度值。

我们需要做的下一件事是让用户输入自己的温度。我们将在下一页的练习后进行此操作。
组合磁铁

有人使用冰箱磁铁创建了一个名为ChangeHello的新组合函数。该函数显示一些文本,上面写着“Hello friend”,还有一个按钮,当点击时,将文本更改为“Hello everyone”。
不幸的是,一只流浪猫跑进了厨房,弄掉了一些磁铁。你能把它们重新组合起来吗?


组合磁铁解决方案

有人使用冰箱磁铁创建了一个名为ChangeHello的新组合函数。该函数显示一些文本,上面写着“Hello friend”,还有一个按钮,当点击时,将文本更改为“Hello everyone”。
不幸的是,一只流浪猫跑进了厨房,弄掉了一些磁铁。你能把它们重新组合起来吗?


让用户输入温度

到目前为止,您已经构建了一个将硬编码的摄氏温度转换为华氏温度的版本的温度转换器应用程序。当用户点击按钮时,它会更改正在转换的温度为另一个值。
我们真正想做的是让用户转换自己的温度,所以下一步是向 UI 添加一个文本字段。用户将把摄氏温度输入到文本字段中,当她点击按钮时,应用程序将其转换为华氏度并显示结果:
我们将使用一个TextField组合。
我们将使用TextField组合将文本字段添加到 UI 中。您可以将这种组合类型视为EditText的 Compose 等效物。
您可以使用以下代码添加一个TextField:


value属性用于TextField的值,在本例中,它使用一个名为text的MutableState变量将此值存储在内存中。
onValueChange属性使用一个 lambda 表达式来在用户输入文本时更新text变量的值。
label属性提供了TextField的标签。当TextField为空时,它显示在文本区域中,并在用户输入文本时移开。
现在您已经看到文本字段代码的样子了,让我们将其添加到应用程序中。

向组合函数添加一个 TextField
我们将通过创建一个名为EnterTemperature的新组合函数将TextField添加到 UI 中,并从MainActivityContent中调用它。
这是EnterTemperature函数的代码:

如您所见,该函数接受两个参数:用于TextField值的String和指定用户输入新值时应发生的操作的 lambda 表达式。
在 MainActivityContent 中调用函数
MainActivityContent在运行函数时需要向EnterTemperature传递这两个参数,因此我们将使用一个名为newCelsius的新MutableState对象来存储其值,并在用户输入文本时更新它。
我们还将更改传递给ConvertButton的 lambda,以便在用户输入有效的Int时更新celsius的值;这将在用户输入新温度时重新组合TemperatureText。
这是代码的样子;我们将在下一页更新MainActivity.kt:


MainActivity.kt 的完整代码
这是MainActivity.kt的代码;更新文件以包含以下更改(用粗体标记):


让我们看看代码运行时会发生什么。
应用程序运行时会发生什么
当应用程序运行时,发生了以下几件事情:
-
当 MainActivityContent 运行时,它创建了一个名为 celsius 的 MutableState
变量和一个名为 newCelsius 的 MutableState 变量。 它将
celsius设为0,将newCelsius设为"",并将两者存储在内存中。![image]()
-
MainActivityContent 运行 EnterTemperature、Header、ConvertButton 和 TemperatureText 可组合函数。
它将
celsius的值传递给TemperatureText可组合函数,后者将其转换为华氏度。![image]()
-
Header、EnterTemperature、ConvertButton 和 TemperatureText 可组合函数向 UI 添加了图像、文本字段、按钮和一些文本。
![image]()
-
用户在 EnterTemperature 中输入一个新值(在此示例中为“25”)。
EnterTemperature将newCelsius设置为此值。![image]()
-
用户点击 ConvertButton 可组合函数。
它将
newCelsius的值转换为Int,并将其赋给celsius.value。![image]()
-
TemperatureText 被重新组合。
它将
celsius的新值转换为华氏度,并显示结果。![image]()
让我们来测试一下这个应用程序。
Test Drive
当我们运行应用程序时,MainActivity包括一个文本字段。
当我们输入温度并点击转换按钮时,应用程序会将温度转换为华氏度并更新温度文本。

现在应用程序的功能完全符合我们的预期。现在我们只需要对其外观进行一些额外的调整。
我们将调整应用程序的外观

我们将更改应用程序,以便在屏幕边缘和 UI 组件之间增加间隔,并使按钮水平居中。我们还将样式化可组合函数,以使用默认的 Material 主题。
这是应用程序新版本的外观:

我们将在接下来的几页中进行这些调整。首先我们将在 UI 的边缘添加一些空间。
为 Column 可组合函数添加填充
我们希望在屏幕边缘和应用程序组件之间添加一些间隔,使其看起来像这样:

我们将通过向Column组合件添加一些padding来实现这一点。向组合件应用填充效果类似于向视图应用填充效果;它会在组件的边缘周围添加额外的空间。
您可以使用**Modifier**向组合件添加填充。Modifier允许您装饰或为组合件添加额外行为。例如,要向Column组合件添加 16dp 的填充,您可以使用以下代码:
Column(modifier = Modifier.padding(16.dp)) {
...
}
Modifiers 非常灵活。例如,当我们编写Header组合函数时,我们使用Modifier来设置Image的高度和宽度,如下所示:

Image(
...
modifier = Modifier.height(180.dp).fillMaxWidth()
)
我们将在几页之后向应用的Column组合件添加填充。在这之前,让我们找出如何将Button居中。
您可以在列或行中将组合件居中显示
如果您想要居中一个或多个组合件,例如Button或Text,可以使用Column和Row。根据您的需求,有几种方法可以采用。
居中列的所有内容
如果您想要将Column中的组合件对齐,使它们水平居中,可以通过将Column设置为尽可能宽,然后设置其**horizontalAlignment**参数来实现。
例如,要使 Temperature Converter 应用程序中所有组合件水平居中,我们将在Column组合件中添加以下代码(加粗部分):

这样可以使应用程序看起来像这样:

但是如果您只想居中一个组合件怎么办?
将单个Row的内容居中显示
如果您想要水平居中单个组合件,可以将其放置在Row中。只需修改Row以使其尽可能宽,然后设置其**horizontalArrangement**属性。
例如,要水平居中ConvertButton组合件,我们可以将其放置在Row中,并像这样居中:

这样可以将按钮居中显示:

这就是您如何使用Column和Row来对齐和排列组合件。在我们更新MainActivity.kt之前,还有一件事情需要讨论:如何将主题应用到组合件。
应用主题:重新审视
正如您在第八章中学到的,主题可以让您的应用程序具有统一的外观和感觉。例如,您可以使用主题来删除默认的应用栏或更改应用程序的颜色。
您已经知道可以通过在应用程序的样式资源文件中定义主题来应用主题,然后在AndroidManifest.xml中引用它。这样做会将主题应用于应用程序,包括任何视图。但是,这并不会将主题应用于任何组合件。要对这些组合件进行样式设置,您需要采用不同的方法。
如何将主题应用到组合件
如果要将主题应用于组合项,需要使用 Kotlin 代码进行操作。例如,以下代码使用MaterialTheme和Surface组件将主题应用于MainActivityContent:

**MaterialTheme**用于将默认的 Material 主题应用于组合项。在这种情况下,它应用于MainActivityContent以及此函数调用的其他组合项,如TemperatureText和ConvertButton。
上述代码还包括一个**Surface**组合项,用于设置表面的样式。它用于应用 3D 效果和阴影等内容。
如果要覆盖默认的MaterialTheme,可以通过在 Kotlin 代码中定义新主题来实现。当您选择使用 Compose 活动创建新项目时,Android Studio 通常会为您添加额外的主题代码,因此让我们来看看这段代码并了解它的作用。
Android Studio 包含额外的主题代码
创建温度转换器项目时,Android Studio 添加了额外的 Kotlin 文件来定义新主题。这些文件分别为Color.kt、Shape.kt、Theme.kt和Type.kt,位于app/src/main/java文件夹中的com.hfad.temperatureconverter.ui.theme包内。
主要文件是Theme.kt,它定义了应用程序的新主题。该主题名为TemperatureConverterTheme,其代码如下:



如您所见,主题覆盖了MaterialTheme的颜色、字体和形状。如果要微调其中任何内容,可以更新.ui.theme包中的文件。
如何应用主题
一旦定义了主题,就可以将其应用于应用程序的组合项。例如,要将TemperatureConverterTheme应用于MainActivityContent,可以使用以下代码:

MainActivity.kt 的完整代码
您现在已经掌握了调整 UI 外观所需的所有内容,并使其看起来正如我们所希望的那样。
下面是MainActivity.kt的完整代码;请更新文件以包括以下更改(用粗体标出):



让我们来测试一下这款应用。
测试驾驶
运行应用程序时,会显示MainActivity,用户界面看起来正是我们想要的样子。界面与屏幕边缘之间有间隙,按钮水平居中,并使用默认的 Material 主题。

恭喜!您现在已经学会了如何使用 Jetpack Compose 构建用户界面,而不是使用视图。
在下一章中,您将进一步学习如何将组合项集成到现有的基于View的用户界面中。
BE Compose

下面的可组合函数包括一个 TextField、一个 Button 和一个 Text。当用户在 TextField 中输入一个名称并点击按钮时,新名称应该显示在 Text 中。你的任务是像使用 Compose 一样,看看这个函数是否按预期工作。如果不是,你会如何更改它?
@Composable
fun ChangeHello() {
val name = mutableStateOf("friend")
val newName = mutableStateOf("")
TextField(
value = newName.value,
label = { Text("Enter your name") },
onValueChange = { name.value = it }
)
Button(
onClick = { name = newName }
) {
Text("Update Hello")
}
Text("Hello ${name.value}")
}
BE 组合解决方案

下面的可组合函数包括一个 TextField、一个 Button 和一个 Text。当用户在 TextField 中输入一个名称并点击按钮时,新名称应该显示在 Text 中。你的任务是像使用 Compose 一样,看看这个函数是否按预期工作。如果不是,你会如何更改它?
注意
需要更改代码才能使函数按预期工作。我们已将这些更改添加到下面的代码中。

你的安卓工具箱

你已经掌握了第十八章,现在你已经将 Jetpack Compose 添加到了你的工具箱中。

第十九章:将 Compose 与视图集成:完美的和谐

当事物协同工作时,你会得到最好的结果。
到目前为止,你已经学会了如何使用视图或可组合项构建 UI。但如果你想同时使用 两者 呢?在本章中,你将了解如何通过 向基于视图的 UI 添加可组合项 来 兼顾两者 的最佳实践。你将探索使 可组合项与视图模型协同工作 的技术。你甚至会了解到如何使它们响应 LiveData 更新。通过本章的学习,你将掌握使用可组合项与视图的所有技能,甚至可以 完全迁移到 Compose UI。
你可以将可组合项添加到基于视图的 UI 中
在上一章中,你学习了如何通过构建全新的温度转换器应用程序来实现 Compose UI。你没有向布局文件中添加视图,而是通过在活动的 Kotlin 代码中调用可组合项来创建 UI。
然而,有时你可能希望在同一个 UI 中同时使用视图 和 可组合项。这可能是因为你希望使用仅作为视图或可组合项提供的组件,或者你希望将应用的部分迁移到 Compose 中。
令人兴奋的消息是,你可以将可组合项添加到在布局文件中定义的 UI 中。我们将向你展示如何通过将我们之前在本书中创建的猜词游戏应用迁移到 Compose 来实现这一点。
重新审视猜词游戏应用
你应该记得,猜词游戏应用允许用户猜测哪些字母包含在秘密单词中。如果她猜对所有字母,则赢得游戏;如果耗尽生命,则输掉游戏。
目前游戏的外观如下:

在我们开始用可组合项替换应用的视图之前,让我们快速回顾一下应用的构造。
猜词游戏应用的结构
猜词游戏应用使用两个片段作为其 UI:GameFragment 和 ResultFragment。
GameFragment 是应用的主屏幕,用户通过它与游戏进行交互。它显示信息,例如剩余生命和用户的任何错误猜测,并允许用户进行猜测。它还包括一个按钮,用户点击它可以立即结束游戏,而不再进行任何猜测。
ResultFragment 游戏结束时显示,向用户展示她是否赢得游戏以及秘密单词。
应用还包括两个视图模型——GameViewModel 和 ResultViewModel——它们保存应用的游戏逻辑和数据,并在应用旋转时维护其状态。GameFragment 使用 GameViewModel,而 ResultFragment 使用 ResultViewModel:

让我们来看看我们将更新应用程序的步骤。
我们将要做的事情
我们将分两个主要步骤用可组合项替换猜词游戏应用的视图:
-
用可组合项替换 ResultFragment 的视图。
我们将向应用的 build.gradle 文件中添加 Compose 库,并且向
ResultFragment的布局中添加组合物以复制当前的视图。当我们满意组合物的表现时,将从其 UI 中删除视图。![image]()
-
用组合物替换 GameFragment 的视图。
然后我们会对
GameFragment进行类似的操作。我们将向其布局中添加组合物以重现其当前的视图,并且当我们确信它们按我们想要的方式工作时,我们将从其 UI 中删除视图。![image]()
让我们首先将 Compose 库添加到应用的 build.gradle 文件中。

更新项目的 build.gradle 文件…

我们首先需要在项目的 build.gradle 文件中添加一个新变量,以指定我们将使用的 Compose 版本。打开文件 GuessingGame/build.gradle,在 buildscript 部分添加以下行(加粗部分):

…并且也更新应用的 build.gradle 文件
在应用的 build.gradle 文件中,我们需要添加一堆 Compose 选项和库,并确保最低 SDK 版本是 21。打开文件 GuessingGame/app/build.gradle,在适当的部分添加以下行(加粗部分):

完成这些更改后,点击“立即同步”选项。
我们将用组合物替换 ResultFragment 的视图

现在我们已经更新了 build.gradle 文件以包含 Compose,我们可以开始用组合物替换应用的视图。我们将从 ResultFragment 开始,因为这是最简单的片段。
正如你可能记得的那样,ResultFragment 的布局包括一个 TextView 来显示游戏的结果,以及一个 Button 让用户开始新游戏。它看起来像这样:

我们可以通过使用 Text 组合物而不是 TextView,以及使用 Button 组合物而不是 Button 视图来用组合物替换这些视图。这是新 UI 的样子:
我们将逐步构建新的 UI,所以一开始,ResultFragment 将同时使用视图和组合物。让我们先找出如何将组合物添加到布局文件中。
一个 ComposeView 允许你将组合物添加到布局中
如果你想向基于 View 的 UI 添加组合物,可以通过向布局文件添加一个 **ComposeView** 元素来实现。这是一种可以显示组合物的视图类型,代码如下:

你可以将 ComposeView 理解为一种视图类型,用作在 Kotlin 代码中向 UI 添加任何组合物的占位符。当应用运行时,它会显示布局的视图,并填充 ComposeView 中的组合物。
ComposeView 是一个作为组合物占位符的视图。它让你在基于 View 的 UI 中使用 Compose。
我们将向 fragment_result.xml 添加一个 ComposeView
我们希望向 ResultFragment 的 UI 添加 Text 和 Button 可组合项,因此我们需要在其布局文件中添加一个 ComposeView。
这是 fragment_result.xml 的更新代码;请更新文件以包括下面的更改(用粗体标记):

现在我们已经添加了 ComposeView,让我们向其添加一些可组合项。
使用 Kotlin 代码添加组件

一旦布局包含 ComposeView,您可以在片段的 onCreateView() 方法中使用如下代码向其添加可组合项:

该代码在布局的 ComposeView 上调用 setContent(),告诉它需要包含哪些可组合项。然后将其应用于片段的膨胀布局。例如,如果您想向 ResultFragment 的布局添加一个 Text 可组合项,您可以使用以下代码:

运行此代码将在布局的 ComposeView 中显示 Text 可组合项,如下所示:

为片段内容添加一个可组合函数

现在您已经学会了如何将可组合项添加到 ComposeView,让我们使用已添加到 fragment_result.xml 的可组合项来复制其现有视图。
我们将在 ResultFragment.kt 中添加一个名为 ResultFragmentContent 的新可组合函数,用于片段的 UI。我们将从 setContent() 中调用它,以便任何添加到其中的可组合项在显示 ResultFragment 时运行。
这是新代码的样子;我们将在 ResultFragment.kt 中添加它,距离前几页:

重现开始新游戏按钮
现在我们已经将 ResultFragmentContent 可组合函数添加到 ResultFragment.kt 中,我们可以使用它来向 UI 添加可组合项。我们将首先添加一个按钮。

ResultFragment 当前包含一个名为“开始新游戏”的按钮 View,点击时使用以下 OnClickListener 导航到 GameFragment:

我们可以通过创建一个名为 NewGameButton 的新可组合函数在 ResultFragmentContent 中重现 Compose 中的按钮。我们将为 NewGameButton 添加一个 lambda 参数,以便 ResultFragmentContent 在其被点击时告诉它要做什么。
这是新代码的样子;我们将在 ResultFragment.kt 中添加它,距离前几页:

按钮已经设置好了。布局的文本呢?
重现 ResultFragment 的 TextView
正如您可能记得的那样,ResultFragment 在其布局中使用 TextView 来显示游戏的结果。它使用以下代码定义:

不再使用 TextView 显示文本,我们可以定义一个新的 ResultText 可组合函数,用 Text 可组合项显示文本。我们将向 ResultText 添加一个 String 参数,以便 ResultFragmentContent 将文本传递给它。
这是新代码;我们将在下一页上将其添加到ResultFragment.kt中:

这是我们需要用组合重现所有ResultFragment视图的所有代码。让我们看看代码是什么样子的。
ResultFragment.kt 的更新代码
这是ResultFragment.kt的更新代码;请更新文件以包含以下更改(用粗体标出):


让我们来试驾这个应用程序。
试驾
当我们运行应用程序时,将显示GameFragment。当我们点击其“完成游戏”按钮时,它将导航到ResultFragment。
ResultFragment显示了原始视图和我们刚刚添加的组合视图。Text组合显示了正确的文本,而Button组合在点击时导航到GameFragment。

我们添加到ResultFragment的组合视图的工作方式正是我们想要的,因此我们接下来要做的就是移除视图。
我们需要移除 ResultFragment 的视图...
您可以通过以下几种方式之一移除片段或活动的视图:
-
通过从布局文件中移除您不需要的任何视图
如果 UI 包含混合视图和组合,则此方法非常有用。
-
通过删除整个布局文件
如果 UI仅包含组合,则可以删除布局文件,并在活动或片段的 Kotlin 代码中删除对其的任何引用。
在猜谜游戏应用程序中,我们使用组合重现了ResultFragment的所有视图,因此我们不再需要任何原始视图。这意味着我们可以删除其布局文件fragment_result.xml,以便 UI 仅包含组合。
...并更新 ResultFragment.kt
在删除布局文件之前,我们首先需要从ResultFragment.kt中删除对其视图的任何引用,并停止使用视图绑定。
注意
视图绑定为您的活动和片段代码提供了一种更容易访问布局文件视图的方式。由于我们正在删除 ResultFragment 的布局文件,因此它不再需要使用视图绑定。
我们还需要调整片段的onCreateView()方法,以便不再膨胀布局文件,而是将组合添加到片段的 UI 中。
正如您在第十八章中学到的,您可以通过简单调用其onCreate()方法中的setContent()来对活动执行此操作,如下所示:

然而,当您处理片段时,您需要采用稍微不同的方法。要找出这是什么以及为什么,请让我们重新审视片段的onCreateView()方法。
onCreateView()返回 UI 的根视图
如您所知,当活动需要显示片段的 UI 时,片段的onCreateView()方法会被调用。
当使用布局文件定义 UI 时,onCreateView()方法中的代码会将布局膨胀为视图层次结构,并返回根视图。根视图会添加到活动的布局中,从而显示片段的 UI。
但如果没有布局文件怎么办?
为 Compose UI 返回一个 ComposeView
如果片段的 UI 仅由组件组成且没有布局文件,**onCreateView()** 方法仍必须返回一个**View?**,否则代码无法编译**。
通过使方法返回一个 ComposeView,该方法包含所有 UI 的组件,来处理此问题。下面是这段代码的样子:

当活动需要显示片段的 UI 时,它像以前一样调用片段的 onCreateView() 方法。该方法返回一个 ComposeView,其中包含片段的组件,然后活动显示这些组件。
这就是你完成 ResultFragment.kt 代码所需知道的所有内容。接下来的几页我们会展示完整的代码。
ResultFragment.kt 的完整代码
ResultFragment.kt 的完整代码如下;请更新文件以包含以下更改(用粗体标出):



我们可以删除 fragment_result.xml
现在 ResultFragment 不再使用其布局文件,我们将从导航图中删除对 fragment_result.xml 的任何引用,并删除布局文件。
首先,打开 app/src/main/res/navigation 文件夹中的 nav_graph.xml,并从 ResultFragment 部分中移除对 "@layout/fragment_result" 的引用,就像这样:

然后在资源管理器中右键单击 fragment_result.xml,选择“重构”,然后选择“安全删除”选项。点击“确定”按钮并选择重构选项,文件就会被删除。
让我们看看运行应用程序时会发生什么。
应用程序运行时会发生什么

应用程序运行时会发生以下事情:
-
MainActivity 启动并在其布局中显示 GameFragment。
![image]()
-
当用户点击完成游戏按钮,或者赢得或输掉游戏时,应用程序导航到 ResultFragment 并调用其 onCreateView() 方法。
![image]()
-
onCreateView() 方法创建一个 ComposeView。
![image]()
-
onCreateView() 方法将 ComposeView 的内容设置为 ResultFragmentContent。
![image]()
-
当 ResultFragmentContent 运行时,它会调用 ResultText 和 NewGameButton 组件。
组件添加到
ComposeView中。![image]()
-
onCreateView() 方法将 ComposeView 返回给 MainActivity。
![image]()
-
MainActivity 在其布局中显示 ComposeView。
ComposeView的组件显示在设备上。![image]()
让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用程序并点击 GameFragment 的“完成游戏”按钮时,它会像以前一样导航到 ResultFragment。但这次,UI 只由组件组成。

这就是我们让ResultFragment使用 Compose UI 所需的一切。在我们开始GameFragment之前,先试一试下一页上的练习。
池谜题

你的任务是从池中取出代码片段,并将其放入下面代码中的空白行中。你不能多次使用同一片段,并且不需要使用所有片段。你的目标是为名为MusicFragment的片段编写代码,该片段没有布局文件,并使用名为MusicFragmentContent的组合函数进行其 UI 显示。需要将名为MusicTheme的主题应用到 UI 中,包括任何表面。
class MusicFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
return ............................................................
...............................................................
..........................................................
.......................................................
MusicFragmentContent()
}
}
}
}
}
}

注意
注意:池中的每个物品只能使用一次!
答案请参阅“Pool Puzzle Solution”。
接下来我们将让GameFragment也使用组合部件

现在我们已经让ResultFragment使用组合部件而不是视图,我们可以对GameFragment做类似的事情。
正如你可能记得的那样,GameFragment的布局包括TextView、Button和一个EditText,让用户进行猜谜游戏。这是其外观的提醒:

在接下来的几页中,我们将用组合部件替换这些视图。完成后,新的 UI 将如下所示:

与之前一样,我们将首先向GameFragment的 UI 添加新的组合部件,这意味着我们需要在其布局中添加一个ComposeView。我们将在下一页展示此代码。
我们将向 fragment_game.xml 添加一个 ComposeView

这是将ComposeView添加到GameFragment的布局中的代码;更新文件fragment_game.xml以包含以下更改(用粗体标出):

现在我们已经更新了布局文件,让我们开始向其添加组合部件。
为 GameFragment 的内容添加一个组合函数

就像我们在ResultFragment.kt中所做的那样,我们将向GameFragment.kt添加一个新的组合函数,用于片段的 UI。我们将命名该函数为GameFragmentContent,并从setContent()中调用它,以便我们添加到其中的任何组合部件在显示GameFragment时运行。
这是新代码的样子;我们将在几页后将其添加到GameFragment.kt中:

复制完成游戏按钮
正如我们在ResultFragment中所做的那样,我们将向GameFragmentContent的组合函数添加组合部件,以便它们显示在GameFragment的 UI 中。我们将从复制完成游戏按钮开始。
结束游戏按钮在片段布局中的定义如下所示:

正如你所见,当按钮被点击时,它会调用GameViewModel的finishGame()方法。
我们可以通过创建一个名为FinishGameButton的新组合函数来在 Compose 中复制这个功能,我们将从GameFragmentContent运行它。这是新代码的样子;我们将在接下来的GameFragment.kt几页上添加它:

复制带有 TextField 的 EditText
接下来我们将复制的视图是允许用户输入字母的EditText。我们将创建一个名为EnterGuess的新组合函数,它使用一个TextField,并接受两个参数:一个用于用户猜测的String,以及一个指定值更改时应发生的操作的 lambda 函数。

我们将从GameFragmentContent运行EnterGuess函数,以便将其添加到片段的 UI 中。我们还将在GameFragmentContent中添加一个名为guess的MutableState对象,我们将用它来管理TextField的状态。

这是新代码的样子;我们将它添加到稍后的GameFragment.kt页面上:

复制猜测按钮
现在我们在 UI 中添加了一个允许用户输入字母的TextField,我们将添加一个Button组合项,将字母传递给视图模型的makeGuess()方法。

我们将使用一个名为GuessButton的新组合函数添加Button,我们将从GameFragmentContent运行。这是新代码的样子;我们将在接下来的GameFragment.kt页面上添加它:

我们现在已经用组合项复制了GameFragment的三个视图。在我们处理剩余部分之前,让我们更新GameFragment.kt并进行测试。
对于 GameFragment.kt 的更新代码
到目前为止GameFragment.kt的代码;更新文件以包括以下更改(用粗体表示):


让我们来测试一下这个应用程序。
测试驱动
当我们运行应用程序时,显示GameFragment。它包括所有原始视图,以及三个额外的组合项,让我们可以猜测和完成游戏。
当我们使用EnterGuess和GuessButton组合项猜测秘密单词中包含的字母时,应用程序会注册每次猜测。如果我们猜对了,该字母将被添加到秘密单词显示中;如果我们猜错了,剩余生命次数将被更新,并且该字母将被添加到错误猜测列表中。

现在我们知道了我们添加的三个组合项有效的方法,让我们来处理剩下的部分。
我们将在 Text 组合中显示错误的猜测
我们将复制的下一个视图是一个使用 LiveData 来显示用户错误猜测的TextView。每次用户猜错时,它会将其添加到视图模型的incorrectGuesses属性中,而TextView则通过更新其显示的文本来响应。
这是TextView的代码的提醒:

我们可以用Text可组合项替换TextView,以便显示相同的文本,但如何确保在incorrectGuesses属性值更改时更新文本?
使用observeAsState()响应LiveData
正如您在第十八章中学到的那样,当依赖于State或MutableState对象的可组合项获得新值时,它们会被重新组合。然而,对于LiveData对象,却不会发生这种情况——比如视图模型的incorrectGuesses属性。如果尝试在可组合项中使用LiveData对象的值,当其值更改时不会重新绘制;它将继续使用对象的初始值,因此不会保持最新状态。
如果您想让可组合项响应LiveData更新,可以使用**observeAsState()**函数实现。此函数返回LiveData对象的State版本,因此任何依赖于它的可组合项在其值更改时都会重新组合。
您可以使用以下代码使用observeAsState()函数:

这定义了一个变量(其类型为State),该变量观察视图模型的incorrectGuesses LiveData属性。这意味着当其值更改时,可组合项可以做出响应。
让我们通过在 Compose 中复制GameFragment的错误猜测TextView来将其付诸实践。
创建一个名为IncorrectGuessesText的可组合函数
要复制错误猜测的TextView,我们将定义一个名为IncorrectGuessesText的新可组合函数。该函数将接受一个GameViewModel参数,观察其incorrectGuesses属性,并使用Text可组合项在 UI 中显示其值。每次属性的值更新时,Text将重新组合并显示更新的文本。
这就是IncorrectGuessesText函数的样子:

如您所见,上述代码中的Text可组合项使用了一个名为stringResource()的函数来设置其文本。该函数允许您在可组合项中使用字符串资源,并向其传递参数。
从GameFragmentContent中运行IncorrectGuessesText
就像我们创建的其他可组合函数一样,通过从GameFragmentContent可组合函数运行IncorrectGuessesText,我们将它添加到GameFragment的 UI 中。新代码如下所示:


我们将在稍后的几页更新GameFragment.kt。首先,请尝试通过以下练习来组合剩余两个可组合项的代码。
组合磁铁

有人使用冰箱磁铁创建了两个新的可组合函数(名为SecretWordDisplay和LivesLeftText),用来复制GameFragment的两个剩余视图。不幸的是,当有人释放厨房的海怪时,其中一些磁铁掉落了。您能够重新组合代码吗?
SecretWordDisplay函数需要显示来自GameViewModel的secretWordDisplay LiveData<String>属性。LivesLeftText函数需要显示lives_left String资源,并传递给GameViewModel的livesLeft LiveData<Int>属性。这两个函数都需要能够响应实时数据更新。
@Composable
fun SecretWordDisplay(viewModel: GameViewModel) {
val display = viewModel.secretWordDisplay..............................
display..............................{
Text(.......................................)
}
}
@Composable
fun LivesLeftText(viewModel: GameViewModel) {..............................
val livesLeft = viewModel.livesLeft
livesLeft..............................{
Text(.....................................................................)
}
}

组合磁铁解决方案

有人使用冰箱磁铁创建了两个新的可组合函数(名为SecretWordDisplay和LivesLeftText),复制了GameFragment的两个剩余视图。不幸的是,当有人释放厨房大章鱼时,一些磁铁掉了下来。你能把代码拼回去吗?
SecretWordDisplay函数需要显示来自GameViewModel的secretWordDisplay LiveData<String>属性。LivesLeftText函数需要显示lives_left String资源,并传递给GameViewModel的livesLeft LiveData<Int>属性。这两个函数都需要能够响应实时数据更新。

GameFragment.kt 的更新代码
现在你知道如何用可组合部分重现所有GameFragment的视图了,让我们将它们添加到GameFragment.kt中。更新文件以包含下面显示的所有更改(粗体部分)。


让我们来测试一下这个应用程序。
测试驾驶
当我们运行应用程序时,GameFragment包括所有原始视图及其 Compose 等效部分。
当我们尝试猜测秘密单词包含的字母时,SecretWordDisplay、LivesLeftText和IncorrectGuessesText中的文本可组合部分会自动更新。

确保现在所有GameFragment的可组合部分都按我们的意愿工作。剩下的就是删除片段的视图。
从 GameFragment.kt 中删除视图
就像我们处理ResultFragment一样,我们将通过删除其布局文件来删除GameFragment的视图。但在此之前,我们需要从GameFragment.kt中移除对视图的任何引用。由于不再需要数据绑定,我们也将停止使用它。
这是文件的完整代码;更新GameFragment.kt以包含下面显示的所有更改(粗体部分):





这些都是我们需要对GameFragment.kt进行的更改,以便它不再填充其布局或引用任何视图。
由于该片段不再需要有布局文件,我们可以在从导航图中删除引用之后立即删除它。我们将在下一页上执行此操作。
删除 fragment_game.xml
导航图包括对布局文件fragment_game.xml的引用。在删除布局文件之前,我们需要移除这个引用。
打开app/src/main/res/navigation文件夹中的nav_graph.xml,并从GameFragment部分中删除引用"@layout/fragment_game"的行,如下所示:

然后在资源管理器中右键单击fragment_game.xml,选择重构,然后选择安全删除选项。 单击“确定”按钮并选择重构选项后,文件将被删除。
我们还可以关闭数据绑定
我们还可以对猜谜游戏应用程序进行最后一次更改,即禁用数据绑定。 您可能还记得,最初我们启用了数据绑定,以便fragment_game.xml和fragment_result.xml中的视图可以与每个片段的视图模型交互。 现在我们已经删除了布局文件,因此不再需要数据绑定。
要关闭数据绑定,请打开文件GuessingGame/app/build.gradle,并从buildFeatures部分中删除数据绑定行,如下所示:

完成后,点击“立即同步”选项,将更改与项目的其余部分同步。
让我们对应用程序进行最后一次测试。
测试驾驶
运行应用程序时,显示GameFragment。 这次,它的用户界面仅由可组合项组成。 它们的功能正是我们想要的。

恭喜! 您现在已经学会如何向现有的基于View的 UI 添加可组合项,甚至用仅使用 Compose 的 UI 替换您的 UI。
我们认为 Compose 的未来一片光明,您可以在附录中了解更多信息。与此同时,为什么不尝试将可组合项添加到您在本书中构建的其他一些应用程序中呢?
池谜题解

您的任务是从池中获取代码片段,并将它们放入下面代码中的空白行中。 您不能多次使用相同的代码片段,并且不需要使用所有的片段。 您的目标是编写一个名为MusicFragment的片段代码,该片段没有布局文件,并使用名为MusicFragmentContent的可组合函数来构建其用户界面。 UI 还需要应用名为MusicTheme的主题,包括任何表面。


您的 Android 工具箱

您已掌握第十九章,现在还添加了将 Jetpack Compose 集成到您的工具箱中。

离开城市

在 Androidville 的这段时间真是太棒了
我们很难过看到您离开,但没有什么比学到的东西付诸实践更好了。 本书的尾声还有一些宝贵的信息和一个便捷的索引供您学习,然后就是将所有这些新想法付诸实践的时候了。 一路顺风!


它根据您的规格配置项目。
测试驾驶
















































































































































混合消息



















































当用户点击任务时,TasksFragment 导航到 EditTaskFragment,并传递任务 ID。
EditTaskFragment 获取其 EditTaskViewModel 对象的引用。
EditTaskViewModel 调用 TaskDao 对象的 get()方法,传递任务 ID。
TaskDao 的 get()方法返回一个 LiveData
当用户点击更新任务按钮时,它调用 EditTaskViewModel 的 updateTask()方法。
当用户点击删除任务按钮时,它调用 EditTaskViewModel 的 deleteTask()方法。
应用程序导航到 TasksFragment。
























浙公网安备 33010602011771号