原生应用开发指南-全-

原生应用开发指南(全)

原文:zh.annas-archive.org/md5/652893d42c7710bced389242dfa5f5ab

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

为什么写这本书

这本书是 iOS 和 Android 本地开发的实用交叉参考。通过“本地”,我们指的是每个平台的原始提供的工具集——对于 iOS 是 Swift 和 Cocoa,对于 Android 是 Java 或 Kotlin 与 Android 开源项目(AOSP)软件开发工具包(SDK)。

我们写这本书是出于共同的需求。两位作者都有两个平台的经验,但专注于其中一个。我们团队的成员(包括我们自己)会发现自己在一个平台上遇到的问题,确定了该平台上的解决方案,然后需要与在另一个平台上工作的团队成员分享这个解决方案。

通常情况下,这些任务是一些常见的操作,比如从数据库或文件中读取或写入数据,建立网络连接,或以用户熟悉的方式显示用户反馈。当我们开始系统化和记录这些任务时,在两个平台上很快就显现出一个非常大的应用程序代码大多数属于这些分类之一的事实,而且为所有这些任务提供参考来源对于那些具有类似编码结构的团队、正在向另一个平台过渡的团队或者甚至希望同时学习两个平台的开发人员都是有价值的。

我们希望这本书能为读者提供关于最常见的移动开发任务的资源。

我们有一个移动开发团队,成员拥有不同水平的跨平台经验,我们能够很好地管理这些,但是经常提到的一个想法是跨参考,然而目前确实没有什么能够提供我们认为是必要的广度。我们只能想象处于同样境地的单个开发人员可能会遇到的挫折;这本书使任何人都能够学习应用程序开发中大多数常见任务的基本方法。每个任务章节展示了在 Android 和 iOS 中完成任务的整个过程,并使用可读的代码示例进行逐步讲解。我们认为这些示例涵盖了开始应用程序开发所需的基本知识的 80%;当然,读者应该保持并更新他们的知识,并阅读我们没有专门涵盖的文档,但我们也包括了一个从头到尾的应用程序演示。这个示例应用程序演示了如何成功完成现代应用程序所需的几乎所有操作,使用之前提供的任务交叉参考。

由于所有 Android 代码示例都提供了 Java 和 Kotlin 两种版本,这本书还具有一个愉快的副作用,让 Android 开发人员像跨平台开发人员一样享受在 Java 和 Kotlin 之间的交叉参考。

代码示例不是伪代码;它们是用建议的语言编写的,并且应该按描述的方式编译和运行。

这本书适合谁

本书适用于任何专门使用单个平台或同时使用两个平台的本地程序员,或者熟悉其中一个但需要掌握另一个的程序员。我们假设您至少对某种编程语言有一定了解。您不必成为 Java 或 Swift 的专家,但具有一些 UI 编程背景会有所帮助。

您可能需要参考 Objective-C、Swift、Java 或 Kotlin 的官方文档,以理解本书中引用的一些基本语言机制。

来自一个框架(iOS 或 Android 本地开发)的程序员应该特别容易消化所提供的信息,因为几乎所有代码示例都以互补框架(Android 用于 iOS,反之亦然)的功能等效性呈现。

本书的组织方式

本书分为两部分,不包括前言和后记。第一部分是常见的与平台无关的任务列表,如将文件写入本地存储或创建 HTTP 请求。第二部分指导您在每个平台上创建一个基本应用程序,利用来自第一部分的技术。

本书中使用的约定

本书使用以下印刷约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽斜体

显示应由用户提供值或由上下文确定值的文本。

提示

这个元素表示提示或建议。

注意

这个元素表示一般提示。

警告

这个元素表示警告或注意事项。

使用代码示例

可以从https://github.com/oreillymedia/native-mobile-development下载补充材料(代码示例、练习等)。

本书旨在帮助您完成工作。一般而言,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您要复制本书的大部分代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O’Reilly 书籍的示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢您,但不要求您进行归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Native Mobile Development by Shaun Lewis and Mike Dunn (O’Reilly). Copyright 2020 Shaun Lewis and Mike Dunn, 978-1-492-05287-6.”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media一直提供技术和业务培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台让您可以按需访问现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • 加利福尼亚州,Sebastopol,95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书设有一个网页,列出勘误、示例和任何其他信息。您可以访问此页面https://oreil.ly/native-mobile-dev

通过电子邮件bookquestions@oreilly.com评论或提出有关本书的技术问题。

有关我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

我们要感谢以下以字母顺序列出的个人进行技术审查:Paris Buttfield-Addison、Ian Darwyn、Dawn Griffiths、Ben Kreeger、Pierre-Olivier Laurence、Shane Staples 和 Subathra Thanabalan。

第一部分:任务与操作

在本部分中,我们提供了执行移动应用程序常见任务的基础代码,如显示 UI、传递数据、发送和接收事件、进行网络请求和处理网络响应、访问和操作文件系统,以及读取或写入到偏好或数据库等持久存储。

关于移动开发现状的一点说明

在撰写本文时,Android 和 iOS 开发的状态非常动态、高度分散和高度有争议。已添加了用作现有 API 替代品的新库,但其中一些发布并非必然是社区共识的产物。此外,几乎每个替代 API 都比其替代品复杂得多。在决定在本书中包含哪些信息时,包括像库和 API 这样的基础设施,我们决定应尽可能为尽可能多的人和项目提供最大的帮助。大多数开发工作是功能开发和维护,而不是全新的开发项目。考虑到这一点,在几乎所有情况下,我们默认选择一个现有的、经过验证的库,而不是仅有相对较短时间(大约一年是我们决定的任意门槛)可用的新库或做法。

另外,我们发现许多替代 API 使得框架与现有技术中的模式更为远离。例如,Android 上的新的Navigation组件集可以用来管理 UI,以与本书中展示的基本方式(Fragment 实例)相同,但实现方式却大相径庭。考虑到所需的基础设施数量,与我们认为代表 Android 开发现状的经过验证的模式相比,包含额外的复制和代码来介绍这些模式,可能会弊大于利。另一个很好的例子是数据库技术:最近,Google 推荐 Android 开发使用其 Room 库,但 SQLite API 已包含在 AOSP 的标准库中。没有人会认为 SQLite 没有限制,并且 Room 可能是更现代的方法,但本书的主要目标之一是提供源材料,使通常掌握一般编程的人能够迅速达到高效的流利水平,而 SQL 通常是世界上最常见、最成熟和最广泛应用的技术之一。Room 则不是;因此,我们的数据持久化参考使用 SQLite API。如果您选择使用 Room,那很好!我们鼓励您查看这些更新的推荐和工具,并在适当的时候提及它们,但我们使用FragmentManager而不是使用Navigation API 所需的组件列表,这显然是一个故意的决定,我们相信这是目前的正确选择。

同样地,为了充分利用您作为读者可能具有的现有领域知识,我们可能会使用一种效率较低但更易读或更常见的模式或方法。例如,在 Android 上从流中读取时,我们经常只使用InputStream.read而不使用缓冲区;尽管缓冲区显然是适当的,但我们不仅可以实现非常小且易于消化的示例代码块,还可以免去解释缓冲区工作方式(输入和输出两端)、在各种情况下选择何种大小的缓冲区以及预分配单个缓冲区可能比每次读取或写入操作都创建一个新的缓冲区更有效等问题。流缓冲区可能看起来是一个非常简单的概念,但要正确且完整地解释它并不是件容易的事情。出于类似的原因,Java 的现代版本提供了针对Closable操作的 try-with-resources,但在这种情况下,熟悉其他语言(例如 JavaScript)中 try-catch 的人会立即识别标准语法,并能够集中精力在我们描述的任务上,而不需要停下来处理 try-with-resources 的替代语法,也不需要阅读一两段与完成任务目标无关的内容。当然,try-with-resources 在很多情况下都非常适用,我们鼓励每个人在自己的时间里尽可能多地了解每种语言和框架,但本书的目的是让程序员编程,而不是掌握我们在本书中涉及的每一项技术。

感谢您的倾听,并感谢所有表达意见的人,这些意见促成了这篇笔记,我们真诚地感激您的体贴。

第一章:UI 控制器

用户界面(UI)控制器用作连接您的 UI 与控制或被该 UI 控制的应用程序中的任何业务逻辑的桥梁。

如果你的应用程序是在某个精美的老式剧院上演的莎士比亚戏剧,UI 控制器将扮演舞台经理的角色。它将引导演员走上舞台,听取导演的指令,并帮助过渡场景。

在应用中显示图像、列表或文本时,都需要一个用户界面(UI)。UI 的呈现——如何在屏幕上渲染——通常由布局指令(通常是标记语言,如 XML 或 HTML)控制;UI 控制器充当输入命令、数据库查询、IPC 请求、消息等的桥梁。从某种意义上说,它是任何应用的核心。

所有这些操控需要一个非常复杂的事件系列,一种技术建立在另一种技术之上,协同操作。幸运的是,Android 和 iOS 都提供了一些共同的工具和抽象来处理这个过程的重活。让我们了解一些对两个平台都至关重要的核心任务。

任务

在这一章中,你将学习:

  1. 如何创建应用程序的起始 UI 控制器。

  2. 如何更改活动的 UI 控制器。

  3. UI 控制器生命周期。

Android

不到一年前,Google 宣布其推荐的导航风格是应用程序使用单个 Activity 实例,并在该单个 Activity 中使用 Fragment 类实例来表示操作并管理视图。Jetpack 套件中发布的新 Navigation 组件应用于管理片段之间的交互和显示历史记录。

注意,这与 Android 推出十多年前提供的推荐实践相悖,当时推荐任何“活动”(大致相当于“屏幕”或单个网页)使用一个 Activity,而间歇性地不推荐嵌套 Fragment。事实上,即使在今天,Android 开发文档在 Activity 章节中也是这样开始的:

活动(Activity)是用户可以执行的单一、专注的事情。

对于双方都有有效的论点,但由于 Google 是 Android 的维护者,我们认为我们需要接受其未来的建议。话虽如此,我们知道野外有许多不使用该模式的传统应用程序,并且不打算重新架构数年的工作来符合它。我们不会站在任何一边,所以我们将展示两种方法的基础知识。在疑惑时,我们将遵循普遍存在的模式——启动新的 Activity 实例,将数据作为 Bundle 实例传递给原始信息,并使用 Fragment 实例和 Activity 控制器方法管理模块化内容,而不是使用较新的 Navigation 架构组件及其衍生物。

如何创建您的应用程序的起始 UI 控制器

让我们马上开始吧。当您的应用程序启动时,它将执行一些初始化逻辑,在此期间您将看到“窗口背景”(通常只是一个纯色,具体取决于您的屏幕,但可以设置为任何有效的Drawable实例)。此工作发生在主线程上,不能被抢占或中断——它就会发生。请注意,如果为您的应用程序提供了自定义的Application类,onCreate方法中的任何内容都将在此时发生。再次强调,这是在主(UI)线程上进行的,因此这将阻塞其他任何操作。但是,在此时您可以在自己的后台线程上执行异步工作。

一旦应用程序初始化完成,应用程序将启动一个您在应用程序清单中定义的Activity类的单个实例,其类别节点具有值android.intent.category.LAUNCHER。此Activity条目还应包括一个action名称等于android.intent.action.MAIN,这应该存在于您应用程序的任何入口点(例如启动器图标、深链接、系统广播等)。

提示

请记住,您只需提供类的规范名称,实例化、引用和设置将在后台自动完成(这意味着这个过程对我们开发者或用户来说完全不透明)。

<intent-filter>
  <action android:name="android.intent.action.MAIN" />
  <category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

在完整的清单中,前述内容可能如下所示:

<?xml version="1.0" encoding="utf-8"?>
<manifest package="org.oreilly.nmd"
          xmlns:android="http://schemas.android.com/apk/res/android">

  <application
      android:allowBackup="false"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/AppTheme">
    <activity android:name=".MainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:name=".BrowseContentActivity" />
    <activity android:name=".BookDetailActivity" />
    <activity android:name=".SearchResultsActivity" />
  </application>

</manifest>
注意

请注意,您打算在应用程序中使用的任何Activity都必须在您的ApplicationManifest.xml中注册为application节点的子节点(manifestapplication → 所有活动节点)。请查看此备注后紧随其后的代码块。

<activity android:name=".MyActivity" />

当您与 Android 应用程序交互时,始终被视为在一个Activity中(除非涉及与Service一样的远程操作,例如状态栏交互,但这对于本章来说有点复杂)。您永远不会有一个可用的 UI 部分不在Activity内(唯一的例外是RemoteViews类——View类的一个小而简单的子集,可在通知窗口中使用)。

请注意,您不能嵌套Activity实例。一般来说,单个Activity在任何时候都占据整个屏幕(或者至少是您的应用程序分配的屏幕部分)。

如上所述,请记住我们不是创建一个Activity的新实例;我们只是提供我们想要启动的Activity类。在幕后,Android 框架将生成实例并执行基础设施任务,然后显示给用户。此外,这是一个异步操作,系统会决定何时启动新的Activity

这也很重要,因为在您的清单文件中为Activity类分配了各种启动模式。特定的启动模式可能允许任意数量的特定Activity类在任何时候存在。例如,您可能希望允许用户在单个任务堆栈中拥有任意数量的ComposeEmailActivity实例。但是,您可能希望对其他类型的Activity类施加限制,例如仅允许一个LoginActivity的实例,它可能会将上次使用的LoginActivity带到任务堆栈的顶部,或者可能销毁当前Activity和上次使用的LoginActivity之间的所有内容,具体取决于启动模式。我们不会在这里深入研究启动模式,但如果您感兴趣,绝对可以查看开发人员文档。

那么,我们成功启动了一个Activity,为什么屏幕上什么都没有显示?因为Activity是一个控制器级别的类,本身不是一个视图。为了在屏幕上呈现元素,它至少需要一个View实例,可能还需要几个(作为用作Activity根的单个View的子级)。通常使用setContentView方法并传入 XML 布局资源来实现这一点。请参阅第二章,我们在那里讨论视图。

如何更改活动的 UI 控制器

一旦您的初始(“启动”)Activity呈现给用户,您可以通过从任何Context实例调用startActivity(Intent intent)方法来启动任何其他ActivityActivity类继承自Context,因此它与Context具有“is-a”关系——Activity实例Context实例)。Intent还需要一个Context实例作为第一个参数,并引用要启动的Activity类:

警告

非常重要的是要理解,系统将处理您向用户显示的Activity类的实例化、初始化和配置,它们不能使用new关键字实例化,也不能在启动时配置或以其他方式修改。我们向系统发送一个指示要向用户呈现哪个ActivityIntent,系统会处理剩下的事情。因此,Activity实例在启动时不能分配变量或直接调用方法(使用标准库方法)。

所以,如果我们不能在Activity实例上修改变量或直接调用Activity上的方法来启动它,那么我们如何向其传递信息呢?在许多 UI 框架中,您可以创建一个新的视图控制器类实例,为其分配一些数据,并允许其呈现该数据。

在 Android 框架中,您的选择要少得多。经典方法是将原始值附加到Intent对象,如下所示:

启动ActivityIntent实例可以通过getIntent方法获得:

这种方法完全适合传递小型的、原始的数据,比如标识符或 URL,但不适合大型数据(如序列化的 Java 类或甚至大型的Strings,如表示复杂类实例的 JSON)。这些数据包含在一个特定的系统级数据存储中,其大小限制为 1 MB,并且可以在设备上的任何进程之间共享。从Bundle API 的文档中可以看到:

Binder 事务缓冲区具有固定的有限大小,当前为 1MB,该大小由进程中所有正在进行的事务共享。由于此限制是在进程级别而不是每个活动级别上,因此这些事务包括应用中的所有 Binder 事务,如 onSaveInstanceState、startActivity 以及与系统的任何交互。

要将复杂信息传递给新创建的Activity,要么是在启动新Activity之前将该信息保存到磁盘上,然后在创建该Activity后读取出来,要么是传递一个“全局可达”的数据结构的引用。通常这是一个类级变量(static),但在这种情况下使用static变量存在一些缺点。Android 工程师以前曾推荐在实用类的静态成员上使用Map of WeakReferences,或者您可能会发现Application实例(始终可以通过Context.getApplicationContext从任何Context实例访问)更清晰一些。重要的是要注意,只要您的应用程序在运行,Application实例就是可达的,这意味着它永远不会符合内存泄漏的传统定义。在 Kotlin 中,全局上下文处理方式略有不同,但通常仍适用于传递信息的警告。

片段

在 Android 框架术语中,Fragment可以看作是一种轻量级的Activity;它可以被视为视图的控制器,而不是视图本身,但它必须具有根视图委托(在 Android 中,“视图”模式实现者的角色来自 Model-View-Presenter [MVP]、Model-View-Controller [MVC]、Model-View-ViewModel [MVVM]等,通常由View类来填充,它通常是原子视觉元素,如文本片段、图像或其他View实例的容器;详细讨论视图的章节见第二章)。

Activities相比,Fragments的好处在于我们可以直接使用自定义构造函数签名、配置、成员和方法访问等来实例化它们,就像在Java中实例化任何其他类实例一样。此外,与Activities不同,Fragments可以嵌套——但是,历史上在此方面有一些不可靠性,尤其是在生命周期回调周围,但这也超出了本章的范围。谷歌“android fragment controversy”即可找到大量相关资料。再次强调,本书选择在这场毫无意义的激烈争论中保持中立。

因此,您可以像创建任何其他内容一样创建Fragment

理想情况下,您可以像处理任何View一样将Fragment添加到您的布局 XML 中:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment android:name=".ListFragment"
            android:layout_width="200dp"
            android:layout_height="match_parent" />
    <fragment android:name=".DetailFragment"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="match_parent" />
</LinearLayout>

然而,我们再次面临系统级的不透明实例化。为了以编程方式配置自定义Fragment类,您需要使用new关键字实例化它,并使用FragmentManagerFragmentTransaction将其添加到现有的视图层次结构中。

请注意,如果愿意,您可以在此处使用具有配置参数的自定义构造函数,尽管在重新构造Fragment时,它将丢失构造函数参数,因此 Android 建议开发人员使用无参数构造函数,并假设Fragment实例可以使用Class.newInstance方法创建。

从这一点来看,由于Fragment本身不是View,而是视图或 UI 控制器,必须指示其使用特定的ViewView树进行渲染。通常使用单个空容器ViewGroup,如FrameLayout,来容纳代表Fragment实例的View实例是很常见的。

FragmentTransaction能够为您引用的任何Fragment实例执行各种更新任务。通常情况下,打开一个事务,进行您想要的所有原子更改,然后提交事务:

Activity不同,Fragment类不继承Context,因此失去了许多 API 的直接访问权限;然而,Fragment实例既有getContext方法也有getActivity方法,因此在大多数情况下,您只需查找一次即可。

警告

截至目前,虽然Navigation组件是稳定的,但某些相关功能(如导航编辑器 UI)并不稳定。关于包括 UI 代码生成工具作为未来 Android 工具存在一些争议。尽管如此,Navigation组件能够处理类似前面的Fragment操作,而无需传统的FragmentTransactionFragmentManager

理解 UI 控制器生命周期

由于 UI 控制器从创建到终止经历各种状态,会调用许多生命周期回调,并且可以成为挂钩应用程序事件的绝佳地点。ActivityFragment类都具有生命周期事件(实际上,View实例也有生命周期事件,但这些事件相对有限,超出了本章的范围)。

有一个著名的图表描述了 Activity 生命周期,有着极其详细的解释,但我们现在将重点介绍关键点。

图 1-1 将该图表作为基线呈现。

Activity 首次创建时,将调用 onCreate 方法。

理解 onCreate 也是Activity 被重新创建时调用至关重要的。偶尔,应用程序的资源将被系统回收以供其他用途;在这种情况下,您的应用程序在幕后完全被销毁,当前状态的一些原始值保存在本地磁盘上。

Activity 首次创建时,单个方法参数——一个 Bundle——将为 null。如果它在资源被回收后重新创建(如在“配置更改”期间发生,比如旋转设备或插入新显示器),传递给 onCreate 方法的值将是一个非空的 Bundle 实例。

onStartActivity 变得对用户可见时被调用,之前不可见(例如,被另一个 Activity 遮挡)后。onStart 总是在 onCreate 之后调用,但并非所有 onStart 事件都是由 onCreate 事件引发的。

onResumeActivity 重新获得焦点时被调用。如果包含应用程序被最小化,或者其他任何东西占据前台,无论是另一个应用程序、电话呼叫,甚至是覆盖 Activity 内容的 Dialog,导致 Activity 失去焦点,当焦点重新获得时——关闭其他应用程序、挂断电话呼叫或关闭 Dialog——onResume 将被触发。onResume 总是在 onStart 之后调用,但并非所有 onResume 事件都是由 onStart 事件引发的。

Activity 生命周期

图 1-1. Activity 生命周期

现在让我们开始另一条路,走向毁灭。

onPauseActivity 失去焦点时被调用(参见 onResume)。

onStop 是一个棘手的事件,经常在随意对话中被错误解释。onStopActivity 被销毁时有效调用,但可以重新创建——例如,如果系统回收您应用程序的资源。onStop 将被 onDestroy 事件(见下文)或 onRestart 事件跟随,这意味着 Activity 在停止后正在从保存的“提示”中重建。所有 onStop 事件都是由 onPause 引发的,但并非所有 onPause 事件都会被 onStop 跟随。如果您感兴趣,请参阅关于此特定事件的文档。以下是直接从该来源中提取的相关内容:

当您的活动对用户不再可见时,它已进入已停止状态,系统会调用 onStop() 回调。例如,当新启动的活动覆盖整个屏幕时,可能会发生这种情况。当活动完成运行并即将终止时,系统也可能调用 onStop()

onDestroyActivity即将(优雅地)终止且无法重新创建时触发。如果您从一个Activity中返回,则会调用onDestroy。这是进行清理的绝佳时机。所有的onDestroy事件都在onStop之前发生,但并非所有的onStop事件都会被onDestroy跟随。

文档明确指出,不能指望onDestroy来清理大对象或异步操作。尽管如此,人们经常理解为可以依赖于onStoponPause,这也不完全正确。想象一下,您的设备被卡车压过(或者更可能的是电池没电了)。您的应用将立即关闭,没有任何机会触发回调或执行清理操作。在onPause中进行这类工作并不比在onDestroy中更安全。尽管如此,由于onDestroy通常意味着Activity将变得不可访问并且有资格进行垃圾回收,因此通常没有关系——您不需要担心清理即将被销毁的对象。

Fragment的生命周期非常相似,但包括onCreateView的回调(这是关键的一步——此方法的返回必须返回一个View实例,以便Fragment有一个可见的 UI),以及onDestroyView的回调。还有一个onActivityCreated的回调,以及在使用FragmentTransaction方法将Fragment添加(onAttached)到 UI 或从 UI 移除(onDetached)时触发的回调。

请注意,在操作系统发布之间,FragmentFragmentManagerFragmentTransaction类已经发生了变化。为了保持一致性,并确保您始终使用最新的发布版本,我们建议使用支持库中的类。对于大多数用途来说,它们可以互换使用——只需导入android.support.v4.app.Fragment而不是android.app.Fragment;当您调用new Fragment();时,您将得到支持库包中的Fragment。类似地,使用android.support.v7.app.AppCompatActivity而不是android.app.Activity,它将具有getSupportFragmentManager方法,该方法将为支持库Fragments提供更新的 API。

此外,AndroidX 版本的相同类(以及一些新类)也可用,但实际上即使过了一年也还不完全稳定(尽管已发布多个标记为“稳定”的版本)。Jetpack 库可以完成许多相同的功能,在新项目中 Google 鼓励尽可能使用它们,但是让我们记住,绿地开发比维护要少得多。请随时探索这些替代方案,并找出对您和您的团队最合适的解决方案;我们(作者)选择使用目前支持大多数功能的库和工具集。这种情况肯定会随着时间而改变,就像任何技术一样,跟进最佳和推荐做法几乎是全职工作。

iOS

UIKit,几乎所有 iOS 应用程序依赖的 UI 框架,根植于 MVC 架构。在 iOS 中,这个框架的 UI 控制器部分更具体地指的是UIViewController。在典型的应用程序中,有许多链接在一起以管理它们控制的对象(视图)的UIViewController实例和子类。

如何创建您应用程序的起始 UI 控制器

在我们实际创建应用程序的初始 UI 控制器的详细信息之前,我们需要讨论视图、窗口、控制器及其与我们即将涵盖的功能相关的关系。

视图和 UI 控制器

在 iOS 中,视图和UIViewController紧密相连,因此讨论一个必然需要涉及另一个。现在,视图在第二章中有更深入的讲解,但在这里需要注意的是,应用程序的视图控制器层次结构的根始于专用视图的一个属性:应用程序的窗口,即UIWindow的一个实例。每个 iOS 应用程序都有一个UIWindow实例,由UIApplication呈现。根视图控制器所在的属性被恰当地命名为rootViewController。将UIWindowrootViewController设置为定义的视图控制器可以在一行内完成:

window.rootViewController = viewController

当以这种方式设置根视图控制器时,几乎总是在应用程序启动时进行,通常是在application(_:didFinishLaunchingWithOptions:)中。但是,进入 Xcode 并创建一个新的 Single View Application 项目将创建一个应用程序委托,该委托在同一方法中具有以下代码:

func application(_ application: UIApplication,
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) ->
  Bool {
    // Override point for customization after application launch
    return true
}

注意,在该方法体中没有设置rootViewController属性。事实上,甚至没有提到UIWindow,只有一个true返回值。然而,应用程序启动并显示了一个在故事板中创建的视图控制器,似乎从未链接或设置过。多么神秘啊。

Xcode 并非魔术,那么这里到底发生了什么?好吧,如果你更仔细地查看一些这个示例 Xcode 项目中的其他重要文件,谜团很快就会显现出来。

猎手的追踪开始了

首先,让我们从项目中名为Info.plist的文件开始我们的侦探工作。这是在 Xcode 项目设置中设置的特殊文件。它通过已知的 XML 键为我们的应用程序提供配置值。在此文件中,有一个定义如下的属性值:

<key>UIMainStoryboardFile</key>
<string>Main</string>

该属性的keyUIMainStoryboardFile,指示应用程序在启动时应使用的故事板文件名。赋予此属性的值是Main,这恰好映射到此示例项目中名为Main.storyboard的文件。让我们继续以这个文件为线索进行寻找。

如果我们在 Xcode 的可视化编辑器中打开 Main.storyboard,我们会看到一个单一的场景,其中有一个大箭头指向它。故事板中的每个场景都映射到一个 UIViewController,在屏幕右侧的标识检查器中设置。默认情况下,这只是一个标准的 UIViewController 实例,但通过检查器可以将其设置为自定义子类,方法是在 Class 字段中输入子类的名称。我们的示例项目将其自定义类设置为 “ViewController”,这是项目中 ViewController.swift 中定义的子类(图 1-2)。

现在,关于视图控制器场景左侧的大箭头:这恰好是我们搜索根视图控制器的 “关键”。在 Xcode 的属性检查器中,有一个名为 “Is Initial View Controller” 的复选框,当前在我们的视图控制器场景上选中了此复选框。取消选中此框并构建并运行应用程序,你将收到一些警告和以下错误在 Xcode 的控制台中显示:

Failed to instantiate the default view controller
    for UIMainStoryboardFile 'Main' - perhaps the designated entry point is not
    set?

成功!我们找到了根视图控制器的来源。但是,如何将所有这些内容串联起来,以将根视图控制器添加到应用程序的窗口中呢?

好吧,在启动时,应用程序会在其 Info.plist 文件中查找 UIMainStoryboardFile 键。在主故事板文件中,通过我们的复选框设置为初始视图控制器的视图控制器场景被实例化为给定的子类。因为它是主故事板中的初始视图控制器,所以应用程序将此视图控制器添加到应用程序窗口的 rootViewController 属性中,完成!现在应用程序有了一个显示且活动的根视图控制器。

Xcode 中的故事板编辑器

图 1-2. Xcode 中的故事板编辑器

如果你愿意,你可以通过在应用程序委托中使用以下代码来实现相同的结果:

func application(_ application: UIApplication,
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) ->
  Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).
                                   instantiateInitialViewController()
    window?.makeKeyAndVisible()
    return true
}

让我们详细了解一下。

首先,我们将 window 变量设置为 UIApplicationDelegate 协议的一部分,并将其设置为与设备的主要屏幕大小相同的 UIWindow 实例,通常是唯一的屏幕,通过 UIScreen.main.bounds。接下来,在我们的窗口对象上设置根视图控制器为一个视图控制器。这可以是任何我们有的视图控制器,但在我们的示例中,我们使用了 Main.storyboard 文件中定义的初始视图控制器;这是通过在我们的 UIStoryboard 对象上调用 instantiateInitialViewController() 方法完成的。

最后,我们通过调用 makeKeyAndVisible() 方法来显示这个窗口。该方法将窗口对象作为应用程序的主窗口,替换当前显示的任何其他窗口。

注意

一般来说,iOS 应用程序一次只显示一个窗口,但这并不总是如此。需要将视频输出到另一个屏幕的应用程序可能需要多个窗口;类似 Keynote 的应用程序就是这种情况的一个很好的例子。然而,要把这种情况视为例外而不是默认情况。

代码与故事板

现在,对于任何简单的应用程序,推荐的方法是坚持通过Info.plist和前面详细介绍的主故事板进行配置。然而,随着应用程序变得越来越复杂,直接深入代码可能会变得必要或方便。你可能也更喜欢代码库而不是故事板配置。设置应用程序的起始 UI 控制器并没有真正的“正确”方式;这将取决于个人偏好和项目的要求。

然而,一个只有单个 UI 控制器的应用程序将变得非常有限或非常复杂。让我们看看 UI 控制器如何切换当前显示的视图并为应用程序提供更丰富的体验。

如何更改活动 UI 控制器

在 iOS 中有许多不同的方法可以切换活动 UI 控制器,有些直接在代码中,有些是通过故事板编辑器中的“segues”进行无代码转换。很可能你会在同一个代码库中遇到这两种方法。让我们先从代码开始,因为这将有助于理解幕后发生的事情,并为理解 segues 的魔力提供更好的背景。

表演时间到!

假设我们有两个视图控制器:一个名为primaryViewController,另一个名为secondaryViewController。在这个例子中,我们当前活动的视图控制器是primaryViewController。要向用户呈现secondaryViewController,最简单的方法是在UIViewController上继承的一个名为show(_:sender:)的方法。让我们在接下来的代码中这样做:

// Create the view controllers
let primaryViewController = ...
let secondaryViewController = ...

// Present the secondary view controller as the active view controller
primaryViewController.show(secondaryViewController, sender: nil)

在这个简单的例子中,调用show(_:sender:)方法可能会导致secondaryViewController以模态方式从屏幕底部显示在primaryViewController的前面。然而,上述句子中的一个关键词是“可能”。没有更多的上下文,我们不能百分之百确定—show(_:sender:)将呈现视图控制器的过程与调用呈现的视图控制器分离。这是强大的,并且大多数情况下会导致更简单的逻辑。例如,考虑以下不使用show(_:sender:)的代码:

let primaryViewController = UIViewController(nibName: nil, bundle: nil)
let secondaryViewController = UIViewController(nibName: nil, bundle: nil)

// Add the primary view controller to a navigation controller
let _ = UINavigationController(rootViewController: primaryViewController)

...

// Check if the view controller is part of a navigation controller
if let navigationController = primaryViewController.navigationController {
    // Push the view controller onto the navigation stack
    navigationController.pushViewController(secondaryViewController, animated: true)
} else {
    // Present the view controller modally because no navigation stack exists
    primaryViewController.present(secondaryViewController, animated: true, completion: nil)
}

你可能注意到的第一件事是,我们引入了一个新的类:UINavigationController。这是 iOS 中常见的一个类,用于管理一堆视图控制器;通常在应用程序中,从右侧或左侧推入或弹出导航控制器的堆栈会展示出一个侧向过渡。这可以说是 iOS 中最常见的活动视图控制器过渡类型,可能仅次于标签栏控制器。在我们之前的示例中,primaryViewController 在实例化时被添加到 UINavigationController 的导航堆栈的根部。

如我们在没有 show 的示例中所示,假设我们想要将一个新的视图控制器添加到视图控制器堆栈中并使其成为活动视图控制器。首先,我们需要检查 primaryViewController 上的 navigationController 属性是否为 nil。如果不是,那么该视图控制器是导航控制器层次结构的一部分,因此我们可以继续通过捕获 navigationController 属性的值并在其上调用 push(_:animated:completion:) 方法来将新的视图控制器,例如本示例中的 secondaryViewController 推入堆栈。然而,如果进行呈现的视图控制器不在导航控制器的堆栈上,我们需要以另一种方式呈现该视图控制器。在本示例中,我们使用了一种更直接、更古老的呈现方式,通过调用 present(_:animated:completion:)

在刚才展示的代码中有更多的控制,但它要复杂得多——这只是一个简单的示例!此外,show(_:sender:) 允许对视图控制器的呈现进行一些自定义,如下所示:

let primaryViewController = UIViewController(nibName: nil, bundle: nil)
let secondaryViewController = UIViewController(nibName: nil, bundle: nil)

// Change the presentation style and the transition style
secondaryViewController.modalPresentationStyle = .formSheet
secondaryViewController.modalTransitionStyle = .flipHorizontal

// Change the active UI controller
primaryViewController.show(secondaryViewController, sender: nil)

这里 modalPresentationStyle 改变了视图控制器显示的状态,而 modalTransitionStyle 改变了发生的过渡以使该视图控制器达到该状态。在本示例中,演示样式是 Form Sheet,这是 iPad 的一种特殊格式的显示模式,只占据屏幕的一部分。过渡样式是水平翻转,将视图翻转以展示自身。

注意

在 iPhone 或其他 .compact 大小类中,演示样式 .formSheet 会被忽略,UIKit 会将样式调整为全屏视图。在较大的 iPhone 上,如 iPhone XS Max 或 iPhone 8 Plus,在横向模式下,Form Sheet 的显示方式与平板电脑上的显示方式相同,因为这些设备在横向模式下具有 .regular 大小类;在纵向方向上,这些设备具有 .compact 大小类,Form Sheet 会像在较小的手机上一样显示为全屏视图。我们指出这一点是因为总会有例外和边缘情况。在各种模拟器或设备上进行广泛测试非常重要。

我们只是浅尝辄止地通过编程方式切换活动视图控制器。在我们进一步探讨之前,我们应该讨论一种(在某种程度上)无需代码的 iOS 选项,称为 segues。

Segues

一切在代码中展示的内容都可以在 Storyboard 中使用转场以某种形式完成。转场是两个视图控制器之间的过渡;它们用于在应用程序中呈现视图控制器。在 Xcode 的 Storyboard 编辑器中最容易创建它们。

要创建新的转场,首先必须有两个视图控制器场景之间的过渡。在 Storyboard 编辑器中,控制点击源视图控制器场景,并将鼠标拖动到目标视图控制器上。这将使整个场景变为蓝色以指示你正在目标鼠标的场景。释放鼠标将导致弹出窗口显示,允许你选择转场类型。所提供的选项与迄今为止显示的选项对应:在幕后使用show(_:sender:),让 UIKit 找出最佳的转场或显式地使用模态转场,以及其他选项。

创建转场后,如果是从一个视图控制器到另一个视图控制器,你需要一种方法来通过编程方式调用转场。点击转场本身(例如,在 Storyboard 中连接场景的线,作为场景本身的一部分列出,像一个对象),打开属性检查器,添加一个唯一标识符。对于我们的示例,让我们使用名为 ExampleSegue 的名称。

小贴士

用于转场的标识符需要在包含视图控制器的 Storyboard 中保持唯一。

调用转场的方法如下:

primaryViewController.performSegue(withIdentifier: "ExampleSegue", sender: nil)

performSegue(withIdentifier:sender:)方法接受一个字符串(之前的 ExampleSegue)和一个 sender,它可以是任何对象。如果转场是通过按钮按下触发的,通常会传递一个对按钮的引用,但在我们的示例中传递nil也是可以接受的。

还可以将按钮或其他控件连接起来,显式触发转场。这通过在 Storyboard 编辑器中使用相同的控制点击机制完成,但不是在整个场景上点击和拖动,而是在源视图控制器内的特定按钮上点击和拖动。这样做很容易,因为转场不需要像以前那样通过performSegue(withIdentifier:sender:)来以编程方式调用,以使视图控制器之间的过渡发生。

有时在视图控制器之间进行转场时需要提供额外的数据。每当执行转场时,源视图控制器和目标视图控制器之间会调用方法,允许你传递数据或状态,帮助设置目标视图控制器或执行操作。以下是一个示例,展示了如何使用之前定义的 ExampleSegue 呈现另一个视图控制器:

class ViewController: UIViewController {

    func buttonPressed(button: UIButton) {
        // Code to trigger the segue. This could also be done directly
        // on the button itself within the storyboard editor
        performSegue(withIdentifier: "ExampleSegue", sender: button)
    }

    override func shouldPerformSegue(withIdentifier identifier: String,
      sender: Any?) -> Bool {
        // This is an optional method that returns true by default
        // Returing false would cancel the segue
        return true
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // This is the destination view controller in the segue
        let destinationViewController = segue.destination

        // Let's pass some data to the destination segue
        ...
    }
}

在这个示例的 UIViewController 子类中,有一个名为 buttonPressed(_:) 的方法,每当按钮被按下时触发。此代码使用 performSegue(withIdentifier:sender:) 来触发 segue。(这也可以通过直接在故事板编辑器中链接按钮来实现,但此处展示了正在该类中发生的事情。)

现在,在 segue 开始之前,将调用方法 shouldPerformSegue(withIdentifier:sender:)。这是视图控制器中的一个可选方法,可以重写以在关于是否执行 segue 的决定方面提供一些定制。默认返回值为 true。在调用此方法之前,目标视图控制器尚未被创建。返回 false 将导致取消 segue,并且不会发生任何进一步的操作。通常不会使用 shouldPerformSegue(withIdentifier:sender:) 来取消 segues;然而,有时它是一个有用的集成点。

最后,在事件链中,prepare(for:sender:) 是最后发生的。此时,目标视图控制器现已被实例化,并且离被呈现仅一步之遥。这是源视图控制器在 segue 期间或之后传递一些状态或上下文信息的最后机会。

我们知道如何在应用程序中创建和设置初始视图控制器,并且知道如何在活动视图控制器之间进行过渡。让我们退一步,确保理解 iOS 中视图控制器的生命周期。

理解控制器生命周期

要在 iOS 中创建 UI 控制器,您可以使用多种方法,但最常见的方法是使用故事板来设计和定义应用程序的 UI 控制器。

从故事板创建 UI 控制器

要从故事板创建视图控制器,首先必须在故事板中创建一个视图控制器场景。您可以在 Xcode 中通过将视图控制器添加到编辑阶段来完成此操作。完成后,请确保打开“Identity inspector”并在“Class”字段中添加任何自定义子类。另外,给视图控制器指定一个特定的 Storyboard ID。此标识符用于在从故事板程序化地创建视图控制器时识别特定的视图控制器场景。通常,标识符就是类的名称,如下所示:

let viewController = UIStoryboard(name: "Main", bundle: nil).
                    instantiateViewController(withIdentifier: "ExampleViewController")
提示

虽然字符串易于使用,但事情可能会迅速失控。最好将故事板标识符单独存储在常量 structenum 或通过其他抽象化方式中,以确保编译时安全性,并预防未来维护困难。

当视图控制器通过 Storyboard 创建时,UIKit 使用一个特殊的方法,可以在类中被重写以帮助初始化。这个方法,init(coder:),是在视图加载到类中之前以及在它被放置在视图控制器层级之前执行任何设置或定制的好地方。重写这个方法的方式如下:

class ViewController: UIViewController {

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        // Perform some customization
    }

}
警告

虽然init(coder:)很容易重写,但是你不能在方法本身中使用自定义参数。通过对象的构造函数在初始化时向视图控制器注入属性比在 iOS 中使用 Storyboard 更容易。通常,通过直接设置属性或在视图控制器已经实例化后调用设置方法来完成值的注入。每种方法都有其自己的权衡,通常在项目中会同时使用这两种模式。

UI 控制器的生命周期与其控制的视图的生命周期密切相关。除了从视图和其他控制它的对象接收的初始化器之外,还有一组事件可以帮助更轻松地管理视图和其他依赖对象。让我们来谈谈其中的几个。

viewDidLoad

这种方法在 UI 控制器的视图加载后被调用。它在视图控制器的生命周期中只被调用一次,是进行任何视图设置的地方。在 Storyboard 中设置的所有输出和操作都在这一点被连接并准备好使用。通常情况下,在这个方法中完成设置视图背景颜色、标签上的字体和其他样式操作。偶尔会在这里设置通知(见第十一章)。如果是这种情况,请确保在deinit或其他方法中取消订阅通知,以防止崩溃或内存泄漏。

viewWillAppear 和 viewDidAppear

在视图呈现在形成视图层次结构的视图树之前和之后,这组方法被调用。此时,视图通常具有已知的大小(但并不总是—模态窗口直到viewDidAppear时才确定视图大小),这可以用于一些最后时刻的大小调整。这也是打开内存或 CPU 密集型事物(如 GPS 跟踪或加速计事件)的好地方。

viewWillDisappear 和 viewDidDisappear

这些方法类似于viewWillAppearviewDidAppear,但它们在视图即将从视图层次结构中移除或已经移除时被触发。这是一个很好的地方来禁用前一组方法中启用的东西。

提示

用户使用的交互式滑动返回手势不会调用viewDidDisappear。请确保通过点击操作系统提供的返回按钮和通过滑动从屏幕上弹出视图进行测试。

didReceiveMemoryWarning

处理 iOS 内存警告非常重要,因为移动设备上的内存有时非常有限。清理不必要的资源缓存,清理从故事板创建的输出等。如果应用程序无法从中恢复,最终将关闭并终止应用程序。

这里是一个处理所有这些方法的类的示例:

class ViewController: UIViewController {
    var hugeDataFile: Any?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        // Set up operations not dependent on a view
        // For example, setting up that hugeDataFile object in the background
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // The view has been loaded from the storyboard
        title = "Awesome View Controller Example"
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // The view is about to be displayed on the screen
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // The view has been displayed on the screen
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // The view is about to disappear from the screen
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // The view has disappeared from the screen
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Uh-oh! Better clear out that huge data file we were holding on to
        hugeDataFile = nil
    }
}

注意,所有方法都是override方法,在方法体中调用它们的super等效方法。这一点非常重要,否则视图控制器层次结构中的后续视图控制器将不会接收任何缺失调用的调用。为什么这不是像retainrelease调用那样由编译器处理的问题超出了本书的范围。只需不要忘记在您的重写中包含这些方法调用即可!

警告

Android 和 iOS 都支持使用 MVC 架构。这种架构有时被贬低地称为“Massive View Controller”,因为缺乏纪律,它倾向于将所有控制逻辑倾入数千行长的类中。尽可能保持类的单一用途责任并适当使用容器视图非常重要。

导航控制器,选项卡栏和分割视图控制器

在 iOS 中有特殊的类,具有专门用于管理视图控制器的特殊行为。您可能会遇到的三个类是导航控制器(UINavigationController),选项卡控制器(UITabBarController)和分割视图控制器(UISplitViewController)。

导航控制器用于处理视图控制器的堆栈,并使它们之间的过渡一致且易于在视觉上进行空间导航和推理,而不是一系列模态视图控制器在视觉上堆叠在一起。

选项卡控制器是一种特殊的类,用于管理带有底部锚定选项卡栏的活动视图控制器。这是在应用程序中分割不同部分的常见方法(例如,购物应用程序中的搜索,结账和订单选项卡)。

分割视图控制器起源于 iPad,但已迁移到 iPhone。它们用于显示一组数据的主数据,通常以列表形式,然后在选择项目时提供该数据的详细视图。

showDetail(_:sender:)

如果您使用UISplitController,可以使用showDetail(_:sender:)来代替show(_:sender:)来呈现详细视图控制器。当设备上没有UISplitController时(例如在小型 iPhone 等.compact尺寸类设备上),这将适应全屏模态视图。

我们学到了什么

在本章中,我们涵盖了关于 UI 控制器的大量信息:

  • 我们讨论了 Android 和 iOS 中存在的不同架构,并展示了Activity如何与UIViewController对比。

  • 演示了应用程序启动逻辑在两个平台上如何在 UI 控制器的指导下在屏幕上显示视图。在 Android 上,相比 iOS 的更加基于约定的方法,有更多的配置。

  • 我们涵盖了场景过渡和更改活动视图,以及 Android 中的一些工具,比如Fragment对象,使得控制这些视图变得更加简单。

  • 我们讨论了作为 Android 和 iOS UI 控制器一部分称为的各种方法。

  • 我们介绍了 iOS 中的故事板(storyboards)及其在连接不同场景中的作用。

令人惊讶的是,即使有了这么广泛的知识基础,仍有大量信息没有涵盖。我们在第二章中更多地讨论了一些视图细节,超出了 UI 控制器的上下文。在第 II 部分,我们还介绍了构建两个平台的示例应用程序时的额外信息。

然而,如果你现在准备学习视图方面的内容,请前往下一章进行一次精彩的比较!

第二章:视图

在大多数图形用户界面(GUI)框架中,屏幕上的视觉元素通常被称为架构术语中的“视图”。在 Web 应用程序中,视图可能是 HTML 元素,但在某些 Web 框架中,视图可能是整个网页或页面片段。Java Swing 和许多其他框架使用“组件”来表示应用程序的视图部分。

在原生移动开发中,视图确实就是这样——View(Android)或UIView(iOS)子类的实例。

视图可以是原子元素,表示屏幕上的单个视觉元素,如文本片段或图像,也可以是用于布局的更复杂的视图层次结构,如一系列行或更复杂的,如具有内置复杂行为的日历小部件。

视图还必须用于接收用户输入。按钮、开关、复选框、选择框和文本输入框都是使用户能够与应用程序交互的视图的示例。

任务

在本章中,你将学习:

  1. 创建一个新视图。

  2. 嵌套视图。

  3. 更新视图的状态。

Android

在 Android 中,基类是 View,它不是 abstract——你可以在需要时实例化一个简单的 View,尽管这可能不太常见。一个例子是有时简单的 View 实例被用作线条或形状,只需提供尺寸和背景颜色,或作为没有可视化表示的点击区域。一些实际的 Android View 子类的常见示例包括 TextViewImageViewButtonEditText

基本的 View 不能包含另一个 View,但 ViewGroup 可以(ViewGroupView 的子类,因此它本身也是 View)。在框架库中有许多 ViewGroup 类专门用于布局:LinearLayoutFrameLayoutConstraintLayout 等等。大多数布局需求可以通过这些预制的 ViewGroup 子类来实现,但由于这些类是纯 Java 的,你可以自由地继承 ViewGroup 并定义自己的逻辑;我经常这样做。

其他不严格用于布局的 ViewGroup 子类包括 ScrollViewRecyclerViewSpinnerViewPager。每个组件提供了除了显示和布局内容之外的大量功能,例如,RecyclerView 管理一个滚动的项目列表,当它们移出屏幕时(和内存中时)被移除,并在新项目滚动到视图中时“回收”(因此得名)。在 Android 生态系统中,这些通常称为“小部件”(不要与设备主屏幕的小部件混淆)。

创建新视图

View 类可以像其他对象实例一样通过在 Java 中使用 new 关键字和 View 子类的构造函数进行构造,或者在使用 Kotlin 时仅调用类的构造函数。在大多数情况下,这将至少需要一个 Context 参数,可能还需要更多:

创建 View 和通常是整个嵌套 ViewViewGroup 层次结构的更常见的方法是充气 XML “布局”。 “充气” 简单地意味着解析 XML 及其指令,并将该视图树添加到现有 UI 中或简单返回它。

Android 中的 XML 布局遵循传统的 XML 规则,并有一些你应该了解的约定。

XML 声明是可选的,但建议使用:

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

之后,XML 必须有一个 单一 根节点。这几乎总是一个 ViewGroup(对于视图树)或表示布局整体内容的单个 View。我说“几乎”是因为还有像 merge 标签这样的高级机制;这在技术上不是一个 View,但提供一个指令来返回节点的内容。

例如,你可能会有这样的视图树:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout...>
  <android.support.design.widget.AppBarLayout...>
    <android.support.v7.widget.Toolbar... />
  </android.support.design.widget.AppBarLayout>
  <FrameLayout...>
  </FrameLayout>
</LinearLayout>

或者,你的布局可能是一个单独的 View,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<TextView... />

布局文件存储在 res/layout/ 文件夹中,并且必须遵循标准的 Android 资源命名方案(只能是字母数字字符和下划线)。这个文件夹在编译时处理,以使每个布局的引用成为指向数字 ID 的符号。现在不必完全理解这是如何发生的;只需知道,如果你将布局文件保存为 res/layout/my_activity.xml,它将作为 R 静态配置对象上的 R.layout.my_activity 可用,这是适当的,并且可以传递给任何期望资源 ID 的方法。

回到我们之前提到的约定。Android 在膨胀过程中捆绑了大量功能,但你需要使用它们的命名空间来利用这些功能。这很简单:在根节点上包含一个指向 Android 架构的命名空间属性:http://schemas.android.com/apk/res/android。这个命名空间可以是任何你喜欢的,但通常称之为“android”,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  ...>

一旦建立了这个,你可以通过附加该命名空间的属性来访问 Android 框架属性,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  ...>
  <TextView
    android:text="Hello world!"
    ...>

设置了这个命名空间后,当框架充气你的 XML 来创建一个带有 TextViewLinearLayout 时,TextViewtext 属性将设置为“Hello World!”(将在系统的字体、颜色和大小下显示在屏幕上)。

请注意,你可能永远不会看到它,但使用自己的命名空间名称是完全可以接受的,只需在布局文件中指定即可:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
  xmlns:bob="http://schemas.android.com/apk/res/android"
  bob:layout_width="match_parent"
  bob:layout_height="wrap_content">

  <TextView
    bob:text="Hello world!"
    bob:layout_width="wrap_content"
    bob:layout_height="wrap_content" />

</LinearLayout>

另一个重要的要包含的命名空间是“auto”命名空间,当使用自定义组件或特定支持组件时是必需的。这必须指向 http://schemas.android.com/apk/res-auto,通常命名为“app”(但同样,任何遵循 XML 命名空间名称格式的名称都是可以接受的):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  ...>

有了这两个命名空间在你的根节点上,你应该准备好处理几乎所有的 XML 布局功能。

在编写时,您可能会注意到所有 Android Views都需要layout_widthlayout_height属性(在您的命名空间 XML 中,看起来像android:layout_width="100dp")。这对于以编程方式实例化的Views并不需要,它们将始终使用常量WRAP_CONTENT标志,以指示它们应该消耗显示其内容所需的任何大小。

可接受的值是任何尺寸值(100dp100px100sp)或几个预定义的常量:LayoutParams.WRAP_CONTENTLayoutParams.MATCH_PARENT。前者相当明显——如果您有一个TextView,内容为“Hello World”,并且两个尺寸设置为WRAP_CONTENT,那么TextView将占用所需的空间来呈现这些字符。MATCH_PARENT表示View将尝试填充其父级内所有可用的空间,沿着该维度。

因此,最终的可用布局文件可能如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

  <android.support.design.widget.AppBarLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.v7.widget.Toolbar
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      app:navigationIcon="?attr/homeAsUpIndicator" />

  </android.support.design.widget.AppBarLayout>

  <FrameLayout
      android:layout_width="match_parent"
      android:layout_height="0dp"
      android:layout_weight="1">
  </FrameLayout>

</LinearLayout>

关于此代码的一个快速说明:通常希望一个元素(在本例中是包含ToolbarAppBarLayout)占据所需的所有空间,并且另一个元素填充其余空间(也许是LinearLayoutScrollView)。在这种情况下,作为动态添加和删除Views的容器的FrameLayout将占用其父级中可用的任何空间。

您可能认为在FrameLayout上使用MATCH_PARENT,但父级实际上与屏幕一样高(假设这是根视图),因此FrameLayout将通过等于Toolbar高度的底部剪切。这种使用LinearLayout的技巧解决了这个常见问题:将变大小的View的尺寸设置为WRAP_CONTENT;然后对于应该扩展以填充父级中余下空间的View,将该尺寸设置为0dp,但添加layout_weight1(或任何值)。布局权重告诉LinearLayout应该将多少可用空间分配给View

我们最终在资源文件中有一个可用的布局,预编译并准备就绪。我们如何使用它?有几种方法,其中一些我们在第一章中已经涉及到 UI 控制器。现在,让我们专注于几种常见方法,并探索一些不太常见的方法。

一种策略是将布局设置为你的Activity的根视图。只需调用Activity.setContentView并传递布局的资源 ID。在这个例子中:

就是这样!当MyActivity启动时,您将立即看到在布局 XML 文件中描述的视图树。

另一种策略是将内容添加到现有的视图树中。如果您已经以其他方式设置了视图树,您可以使用 ViewGroup 方法相当容易地添加或删除 View 实例,例如 ViewGroup.addViewViewGroup.removeView。这对于以编程方式创建的视图非常有效,但我们也可以明确地访问布局膨胀。有一个系统服务可以为我们做到这一点,有两种相同的方法可以获取对该服务的引用:

或者:

一旦您拥有一个 inflater 实例的引用,可以直接调用 inflate 方法。其中一种选项是将膨胀的 Views 追加到作为参数传递的另一个 ViewGroup 中:

这个签名将自动将膨胀的视图树添加到第二个参数 someViewGroup,并返回相同的根 (someViewGroup)。或者,您可以在没有根的情况下调用它:

这个版本的好处在于,膨胀的视图树直接返回,无需立即添加到现有的视图树中。

此外,在使用 Fragment(也在 第一章 中涵盖)时,您的 Fragment 实例将希望从其 onCreateView 回调中返回一个 View 实例(在 Fragment 创建时自动调用)。此外,像 RecyclerViewViewPager 这样的视图管理器类将提供挂钩,应根据显示需求(通过滚动或分页)返回(或装饰)一个 View

嵌套视图

任何 ViewGroup 可以拥有任意数量的子 View 实例。这些子项将显示在包含的 ViewGroup 内,因此如果容器被移除或隐藏,则子项也将被移除或隐藏。

子视图将根据 ViewGroup 的显式布局逻辑在父 ViewGroup 内定位。例如,LinearLayout 将根据每个子项的大小以垂直或水平顺序排列其子项,而 FrameLayout 使用绝对像素定位。

View 可以使用任何 ViewGroup.addView 方法添加到 ViewGroup 中。存在签名以适应插入索引和布局指令。可以使用 ViewGroup.removeView 方法删除 Views。请记住,ViewGroup 实例继承自 View,因此一个 ViewGroup 可以添加或删除另一个。

这在 XML 布局中已为您处理。当 Views 在 XML 布局文件中表示时,XML 节点的父子关系也反映在视图树中;也就是说,一个具有两个 TextView 子节点的 LinearLayout 节点将被膨胀为一个 LinearLayout,作为一个具有两个 TextView 实例的子项的 ViewGroup 实例。但是,在膨胀布局后仍然可以使用 addViewremoveView 更改这些关系。

更新视图的状态

View API 提供了多种方法来修改 View 的可见属性。例如,View.setLeft 将更新 View 相对于其容器的位置,而 View.setAlpha 将调整 View 的透明度。非常常见的是,您会使用 setVisibility 来显示或隐藏 View。在修改位置时,通常建议使用 setTranslationXsetTranslationY 而不是 setLeftsetTopsetXsetY。基于平移的属性是一种“偏移量”,计算在适当的 View 正常位置之上。例如,如果您有一个包含多行缩略图和标签的 LinearLayout,并且在第二行调用了 setTop,那么整个行列表的流程可能会受到影响。另一方面,如果您想暂时向下滑动以显示其下的一些 UI,则可以安全地使用 setTranslationY 而不影响容器的整体布局数学计算。

View 子类是一个混合包,通常具有特定的 API。例如,TextView 具有诸如 setTextsetTextSize 的方法,而 ImageView 则具有 setImageBitmap 来更新显示的图像。

虽然大多数 View 属性可以在 XML 中最初设置,但您几乎总是需要通过编程方式进行更新。Android 框架几乎完全使用 getter 和 setter 方法而不是直接属性赋值。您始终应该使用 myView.setVisibility(View.GONE); 而不是使用 myView.visibility = View.GONE;。这在特定的 Java 社区和更广泛的任何具有访问修饰符(private versus public)的技术中有着相当长的历史(以及同样长的争议)。简而言之,一群聪明的人很久以前聚在一起,基本上说,几乎不要(或至少很少)使用直接可赋值的属性;使用 getter 和 setter 方法,以便 API 的作者和使用者可以拦截这些“事件”,在读取或写入数据之前或之后添加逻辑来改变程序的状态。

话虽如此,Kotlin 确实 允许直接属性赋值的出现:

myView.visibility = View.GONE

但在幕后,调用了 setter 方法;确实,如果您在 Kotlin 中为 setter 方法添加逻辑,然后像在前面的代码块中所示直接为属性赋值,setter 中的逻辑 被调用。

iOS

在 iOS 中,“view” 一词通常指 UIView 的一个实例或其子类。视图可以是显示在屏幕上的任何东西——标签、图像、地图、内联 Web 浏览器等等!话虽如此,所有视图在其最简单的形式中都是设备屏幕上的一个简单矩形,位于一组坐标中。

所有 iOS 应用程序开始时的基础视图是UIWindow的一个实例。每个UIViewController都有一个view属性,其中包含一个UIView的实例。应用程序的窗口在其中嵌套其根视图控制器的视图。通过 segues 和调用show(_:sender:)来改变顶部呈现的视图控制器,在其最基本的形式中,只是在屏幕上简单地交换一个由视图控制器管理的UIView与另一个由另一个视图控制器管理的UIView

鉴于UIView在 iOS 中的重要性,让我们来看看如何处理这个类。

创建一个新视图

您可能想要做的第一件事是创建一个新的视图。在 iOS 中,通过调用UIView的初始化程序并传递一个框架来完成这一点,这个视图将会显示在屏幕上,如下所示:

let aView = UIView(frame: CGRect(x: 10.0, y: 30.0, width: 100.0, height: 50.0))

上述代码创建了一个宽度为 100pt、高度为 50pt 的视图;它将放置在其包含视图的左侧 10pt 处和顶部 30pt 处。这是视图的frame

有时,当创建视图时,您可能不知道应该将视图放置在屏幕上的位置。事实上,可以通过传入一个所有值均设置为0CGRect来实例化一个没有已知框架大小的视图。这种情况非常普遍,事实上,CGRect有一个静态变量,输出一个零值矩形。您可以使用它来实例化一个视图,如下所示:

let aView = UIView(frame: .zero)

框架与边界

长时间使用UIView,最终会遇到bounds属性。这也是一个CGRect,与视图的frame非常相似,但有一个重要的区别:视图的bounds属性是一个表达其位置相对于其自己坐标系的矩形,而视图的frame属性是一个表达其位置相对于其包含视图(或“父视图”)的矩形。例如,我们第一个示例中的视图将为其boundsframe输出以下内容:

let aView = UIView(frame: CGRect(x: 10.0, y: 30.0, width: 100.0, height: 50.0))

print(aView.bounds) // Outputs x: 0.0, y: 0.0, width: 100.0, height: 50.0

print(aView.frame) // Outputs x: 10.0, y: 30.0, width: 100.0, height: 50.0

请注意,在此示例中,每个属性的widthheight是相同的。区别在于,frame包含视图在其父视图中的位置信息,而bounds则没有。

Storyboards 和 XIBs

到目前为止,我们已经展示了如何仅通过编程方式初始化视图。然而,视图通常在幕后创建,之前在 Xcode 中定义在 Storyboard 或 XML 界面构建器(XIB)中。

基于 Storyboard 的视图是直接在界面构建器中的视图控制器内定义的。视图控制器的场景包含一个或多个嵌套和一起显示的视图。将视图连接到视图控制器是通过特殊的编译器标志@IBOutlet完成的。此标志指示类中类型为UIView的属性可以连接到包含在 XIB 或 Storyboard 中的视图。例如,要创建一个具有蓝色矩形子视图的视图控制器,您首先要创建一个视图控制器,并在其内部创建一个带有IBOutlet标志的属性,如下所示:

class ExampleViewController: UIViewController {
	@IBOutlet var blueRectangle: UIView!

	...
}

接下来,要将视图控制器与视图链接起来,您需要执行以下操作:

  1. 创建一个新的视图控制器场景。

  2. 通过身份检查器将视图控制器的自定义类更改为“ExampleViewController”。

  3. 在主视图内的场景中添加一个视图,并通过属性检查器将其背景颜色更改为蓝色。

  4. 在视图控制器上控制单击,然后从那里拖动到蓝色矩形。

  5. 您应该看到一个弹出窗口,其中列出了视图控制器中的符合条件的出口。 选择blueRectangle,然后您在界面生成器中显示的蓝色矩形视图现在直接链接到ExampleViewController类中的blueRectangle属性。

XIB 的功能非常类似。 XIB 本质上相当于故事板中的单个视图控制器场景,尽管这是一个过度简化的说法。 实际上,它们是基于 XML 的格式(与故事板类似),用于存储关于视图的信息,以便可以通过 GUI 创建视图,而不仅仅通过代码。 它们比故事板早出现,并且越来越少见,但仍然相对常见。

从 XIB 创建视图类似于故事板; 通过界面生成器完成配置视图的工作,例如,但是 XIB 的设置和实例化方式稍有不同。 要创建基于 XIB 的视图,您需要完成以下步骤:

  1. 创建一个名为“CustomView”的 UIView 子类。

  2. 向 iOS 项目添加一个新的 XIB(CustomView.xib),并通过身份检查器设置其自定义类为我们刚刚创建的类:CustomView

  3. 在视图控制器或其他对象内实例化对象。

如何实例化对象? 您必须引用 XIB,然后像这样使用它来实例化对象本身:

let nib = UINib(nibName: "CustomView", bundle: nil)
let view = nib.instantiate(withOwner: nil, options: nil).first as? CustomView

不幸的是,instantiate(withOwner:options:) 创建一个通用的 UIView 实例,因此您必须将其转换为预期的子类。

我们已经学会了如何创建视图,但 UI 的真正力量在于嵌套和组合视图在一起。 让我们看看如何将一个视图嵌套在另一个视图中。

嵌套视图

可以创建并添加到其他视图中的视图,这些视图可以容纳其他视图,这些视图可以容纳其他视图,依此类推——这确实是一种UIView的方式! 让我们创建一个视图,并将其作为另一个视图的子视图添加。

以下代码块创建了一个父视图和一个子视图,然后调用 addSubview(_:) 将子视图添加到父视图的子视图数组中:

let parentView = UIView(frame: .zero)
let childView = UIView(frame: .zero)

parentView.addSubview(childView)

我们已经添加了一个视图,现在让我们将其删除! 使用相同的示例,我们可以让子视图从其父视图中移除自己,如下所示:

childView.removeFromSuperview()

约束

最终,您会遇到一个实例,希望视图自动调整大小。 您可以通过为视图提供与另一个视图相关的一组约束来实现这一点。 假设我们有一个按钮,这是一种特殊类型的视图,可以接收事件,我们希望它距离屏幕边缘的每一侧都有 16pt,并且距离屏幕顶部有 100pt。 我们可以通过代码中的约束来实现这种布局,如下所示:

class ExampleViewController: UIViewController {

    // Set up the button whenever the view is loaded
    override func viewDidLoad() {
        super.viewDidLoad()
        setupButton()
    }

    // Method that does the actual button setup
    func setupButton() {
        // Create a button
        let button = UIButton(frame: .zero)
        button.translatesAutoresizingMaskIntoConstraints = false

        // Add background color to the button
        button.backgroundColor = .blue

        // Add it to the view controller's view
        view.addSubview(button)

        // Add the spacing from the top of the view
        button.topAnchor.constraint(equalTo: view.topAnchor, constant: 100.0).isActive =
        true

        // Add the spacing from the left edge of the view
        button.leadingAnchor.constraint(equalTo: view.leadingAnchor,
           constant: 16.0).isActive = true

        // Add the spacing from the right edge of the view
        button.trailingAnchor.constraint(equalTo: view.trailingAnchor,
            constant: -16.0).isActive = true
    }
}

让我们来看看这里发生了什么。首先,我们定义了一个视图控制器来容纳一个名为 ExampleViewController 的按钮;它是 UIViewController 的子类,并且像每个视图控制器一样,有一个它管理的子视图叫做 view。在这个类中,我们有一个 setupButton() 方法,在视图加载后调用该方法来创建按钮并在视图内部进行布局。接下来,在 setupButton() 内部,我们用空框架实例化一个按钮,并将 translatesAutoresizingMaskIntoConstraints 设置为 false

看到我们设置的约束条件,你会发现我们引用了按钮的顶部锚点(即按钮的顶部),并将其设置为距包含视图的顶部锚点 100pt。我们在左侧锚点(即左侧)和右侧锚点(即右侧)也是如此,但值为 16.0

运行此代码将显示一个类似于 图 2-1 的视图。

具有约束的按钮

图 2-1. iPhone X 上带有约束的按钮
警告

如果您以编程方式创建视图并添加约束,应将 translatesAutoresizingMaskIntoConstraints 设置为 false。此属性禁用系统创建的自动调整大小约束,并允许我们直接指定自己的约束。实际上,如果您完全通过编程方式创建视图,将其设置为 false 是一个好习惯。不将其设置为 false 往往会导致令人沮丧和神秘的视图布局问题。

约束的力量在于,该视图完全可以根据定义的约束进行调整大小。因此,如果你旋转设备,按钮将保持其相对于视图的顶部、左侧和右侧锚点的间距,就像在 图 2-2 中一样。

适应 iPhone X 横向方向的约束按钮

图 2-2. 适应 iPhone X 横向方向的约束按钮

接口生成器来拯救

尽管可以完全通过代码创建约束,但当视图具有多个关系和基于设备大小类的行为时,这很快变得复杂。更常见的做法是从 Interface Builder 中创建约束。为了重新创建相同的按钮,我们可能会遵循类似以下的过程:

  1. 打开一个故事板并添加一个新的视图控制器场景。

  2. 在“Identity inspector”中更改视图控制器的自定义类为“ExampleViewController”。

  3. 向视图控制器的视图添加一个按钮。

  4. 点击故事板编辑器右下角的“添加新约束”按钮。

  5. 在弹出窗口中设置我们期望的边距值。在这种情况下,顶部值为 100pt,左右值为 16pt。

运行代码会创建一个看起来完全与代码中定义的相同的视图,但不包含任何代码。

自动布局

约束是 iOS 中称为自动布局的技术的一部分。它是一个非常强大且复杂的工具,如果用得当,可以减少制作复杂且响应灵活的用户界面所需的工作量。自动布局的范围对于本节来说太大了,请查阅文档以获取更多关于如何在应用程序中有效使用它的信息。

更新视图的状态

创建一个视图并将其添加到父视图只能做到这一步。所有视图都有大量属性可用来使它们的显示更加动态化,并将它们样式化为超越基本白色矩形的状态。让我们看看如何更新一些常用属性。

Alpha

可以按以下方式更新视图的 alpha 值或透明度:

myView.alpha = 0.5

这将值更改为 50%的透明度。如果想要完全隐藏视图,可以使用以下代码片段:

myView.alpha = 0.0

隐藏视图

将视图的alpha设置为0.0实际上并不会从屏幕上移除视图。它仍然会响应触摸事件,并且如果它覆盖在其他视图之上,会阻止其他视图。为了真正隐藏视图,请使用以下代码:

myView.isHidden = true // Hides the view
myView.isHidden = false // Un-hides the view

背景颜色

所有视图都有背景颜色。iOS 上视图的默认背景颜色是白色。这可以用UIColor对象来表达,更具体地说是UIColor.white。你可以像这样将背景颜色设置为蓝色:

myView.backgroundColor = UIColor.blue

或者,如果你更喜欢将其设置为自定义颜色,可以这样做:

myView.backgroundColor = UIColor(red: 223.0, green: 23.0, blue: 0.0, alpha: 1.0)

UIColor中的alpha值仅影响背景颜色。这确实使得有可能创建一个半透明的视图,而不像直接设置视图的alpha属性那样影响视图的内容。换句话说:

// Changes a view's background color to a semi-transparent color
myView.backgroundColor = UIColor(red: 223.0, green: 23.0, blue: 0.0, alpha: 0.5)

// Changes the entire view to be semi-transparent
myView.alpha = 0.5

在 iOS 中,一个重要的事情是,“clear”本身也是一种颜色。例如,要创建一个没有任何背景颜色的视图,你需要将视图的背景颜色设置为 clear,如下所示:

// Give the view a fully transparent background
myView.backgroundColor = UIColor.clear

修改位置

想要修改视图的位置吗?你有两个选项,选择基于是否使用了约束。如果没有使用约束,可以通过其frame与视图进行交互。例如,要通过 frame 改变视图的位置,可以像下面这样做:

// Create a 100x100 view at (0,0)
let view = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0))

// Resize the view to 50x50 and move it to (130, 55)
view.frame = CGRect(x: 130.0, y: 55.0, width: 50.0, height: 50.0)

如果使用约束来定位视图,则需要直接更新约束。这可以通过在界面构建器中创建约束,并将其分配给在类中创建的@IBOutlet来以编程方式完成。例如,如果在界面构建器中定义了一个宽度约束为100.0,则可以在代码中将其更改为50.0,如下所示:

class ExampleViewController: UIViewController {

    @IBOutlet var widthConstraint: NSLayoutConstraint!

    ...

    func resizeWidth() {
        widthConstraint.constant = 50.0
    }
}

你也可以避免在代码中设置值,并直接向对象添加两个约束,在一个约束被关闭的情况下。然后,在合适的时机,你可以像这样切换约束:

class ExampleViewController: UIViewController {

    @IBOutlet var widthConstraint: NSLayoutConstraint!
    @IBOutlet var otherWidthConstraint: NSLayoutConstraint!

    /// ...

    func resizeWidth() {
        widthConstraint.isActive = false
        otherWidthConstraint.isActive = true
    }
}

这些是调整约束的最常见方式,但说实话,有许多不同的方法可以改变与对象连接的约束。这就是 Auto Layout 的力量和复杂性。

其他属性

这只是触及了视图中可用属性数量的表面。其他视图,如UILabel,有可用的字体选项。像UIImageView这样的视图可以分配UIImage。像WKWebView这样的视图,在 iOS 提供的内置 Web 视图中,有更复杂的状态可以启用和切换。最好查看 Apple 文档网站上特定类的文档,以全面了解可用于控制的属性。

核心动画

本章没有讨论的一件事是核心动画层(Core Animation layers)。这是一种完全不同且互补的方式,用于改变和修改视图的外观。像borderRadiusmask这样的东西使得创建能够进行动画的复杂用户界面成为可能。

SwiftUI

SwiftUI 作为 UIKit 的后继者已经到来。这是一个新的库,用于在 Swift 中构建用户界面。它是一种声明性语法,对于下一代 UI 开发有很大的潜力。现在它还比较早期,并且在某些方面还有些粗糙,但考虑到 Apple 在这项技术上的投入,可以肯定它将在未来几年内变得更加重要,如果 Apple 按照其计划进行的话,可能会成为构建应用程序的主要方式。

我们学到了什么

Android 和 iOS 在显示视图在设备屏幕上的机制上有着非常不同的方式,但基本上它们有着相同的目标:向用户显示内容以进行交互。为了实现这一目标,我们已经涵盖了以下内容:

  • 如何在两个平台上创建新视图以显示在屏幕上

  • 在 Android 中使用原始 XML 创建视图和在 iOS 中使用 storyboards 创建视图的区别,最终都使用 XML 作为底层文件结构进行表示。

  • 如何通过使用约束来处理 iOS 中不同设备尺寸的变化

  • 视图可以轻松嵌套和更新的便利性

这些信息为我们提供了足够的基础知识,以理解接下来关于自定义视图的章节。让我们开始吧!

第三章:自定义组件

尽管 Android 和 iOS 都提供了大量开箱即用的小部件和组件,但并不总是一致,并且很可能您希望在某些时候创建自己的组件。您可能希望拥有具有自定义外观或行为的日期或颜色选择器,带有内置标签的开关或切换按钮,数据可视化组件如图表和图形,或者简单地适合您的应用程序的可配置标签和图标。

无论需求如何,您很可能可以在任何一个框架中创建它——但这个过程非常不同,并且可能会出人意料地神秘。

任务

在本章中,您将学习:

  1. 如何创建自定义视图。

  2. 如何使用自定义视图。

Android

由于大多数 Android 应用程序中的布局是使用 XML 数据创建的,使您的自定义组件能够接受和适当地对任意属性作出反应是您可能希望提供的功能。例如,如果您要创建一个自定义颜色选择器,您可能希望提供一个默认的起始颜色,或者甚至是特定的颜色空间,如 HSL 或 RGB。

由于 XML 资源是编译的,您需要确保系统识别出您的组件上允许的额外属性以及哪些值是有效的。例如,如果您添加了一个color属性,您不希望接受尺寸值——您只希望接受有效的颜色,或者可能是颜色资源 ID。如果用户尝试输入无效的值格式,则程序将无法编译,Android Studio 将通知用户,正如我们所期望的那样。

如何创建自定义视图

自定义View类的基本前提可能与您期望的非常接近:子类化ViewViewGroup,或现有的子类,并根据需要提供自己的功能。自定义View类有些受限——它实际上只能修改其绘制的内容(文本、颜色、形状)或其报告的大小。前者可以通过ViewonDraw方法实现。该方法接受一个参数,一个Canvas实例,该实例将始终为您填充,并具有与View本身相同的尺寸。查看关于Canvas对象的开发文档,但实际上您在这里可以做任何您想做的事情。有用于绘制文本和形状的方法。您可以使用像RectPath这样的几何类来绘制更复杂的结构,以及Paint对象来自定义颜色、填充或纹理。

例如,以下简单的View类将绘制一个红色的圆(或椭圆),填充View的框架:

正如您所见,您可以提供设置器来自定义颜色或提供一个自定义的Paint来平铺位图,创建阴影层或混合渐变。

同样地,您可以对TextView进行子类化,以便它始终沿底部边缘具有单个边框,可能用于LinearLayoutRecyclerView中:

您可能会添加一个新的方法来更改边框的颜色并重新绘制它:

另一方面,自定义的ViewGroup可能代表一种新的布局策略,或者涵盖一组子ViewViewGroup实例,组成类似日期选择器或媒体播放器的复杂组件。以这种方式使用ViewGroup通常称为创建自定义“组件”,需要更深入的了解。我们会在这里简单提及,但请务必查阅文档以获取更详尽的介绍。

考虑的主要要点可以总结为两个方法:onMeasureonLayoutonMeasure告诉父组件自定义组件需要多少空间。有时这是所有可用空间;有时只是其内容所需的空间,或者可能是两者的组合。

onMeasure方法传入两个int参数:widthMeasureSpecheightMeasureSpec。这些值包括指示“模式”的位(如指示尺寸应与其父视图相同的标志,称为MATCH_PARENT),以及像素维度,可能还有其他的。

你可以使用MeasureSpec.getModeMeasureSpec.getSize从遮罩测量规范中读取这些值。

第二个需要了解的方法是onLayout。各种事件会触发对视图树进行布局,比如父视图或子视图的大小改变,或者添加、删除或重新排序子视图时。此外,作为开发者,你可以使用View.requestLayout显式请求新的布局。

onLayout的默认实现什么也不做(尽管像FrameLayoutLinearLayout这样的具体ViewGroup子类确实定义了此方法)。你需要定义ViewGroup如何布局其子项。例如,纵向定向的LinearLayout首先测量其所有子项(在onMeasure中);然后在onLayout中,它将第一个子项定位在顶部,第二个子项位于其下方,第三个子项位于第二个子项下方,依此类推。它报告所需的垂直空间将是这些高度的总和。FrameLayout是一个更简单(也更高效)的机制:所有子项都独立布局,每个子项的LayoutParams提供了明确的边距值,指示子项位置的顶部和左侧值。

例如,以下代码将使用FrameLayout作为Activity的内容视图,并将TextView定位在距离顶部和左侧一百像素处。将其他子View实例添加到该容器中不会影响TextView的位置,因为FrameLayout在执行其onLayout操作时仅检查子项的LayoutParams的边距值:

下面是一个onLayout的实现示例,它会水平按顺序定位其子项,直到没有更多的可用空间,此时会换行到下一行。有时这被称为“FlowLayout”:

除此之外,随意添加自己的方法和属性,以创建自定义组件所需的任何功能。

如何使用自定义视图

到目前为止,我们已经介绍了很多关于自定义Views和组件的信息,但大部分情况下,这些信息都是有序且合乎逻辑的。接下来的内容大多是特定于框架的机制,可能不会立即让人理解。要让应用层发挥魔力,我们需要跳过一些障碍——如果你一时无法理解(或者永远无法理解),不要感到难过;我们有时也会发现自己需要查阅这些内容。

首先,让我们做一些假设。我们有了前面示例中的TextView子类:BottomBorderTextView。它将像任何TextView一样在 XML 中呈现,具有android命名空间属性,如android:textandroid:textSize。但现在我们想要添加一个属性:borderColor

这个新属性不是android命名空间的一部分,而是您的应用程序的 XML 命名空间(http://schemas.android.com/apk/res/com.yourapp)的一部分。幸运的是,在前一章关于Views中,我们看到我们可以使用“auto”命名空间http://schemas.android.com/apk/res/auto,并且可以直接映射到当前应用程序的命名空间。

首先,我们必须将此自定义属性定义为已编译的values值。传统上,这会放在res/values/attrs.xml中,但实际上可以放在任何res/values子目录中。

属性必须位于declare-styleable节点内,该节点应具有与资源节点内自定义组件的简单名称相等的name属性。每个属性(在本例中,只有单个borderColor属性)都表示为具有与属性名称相等的attr节点,并且具有表示可接受数据类型的格式(colorbooleandimensioninteger等等)。

总的来说,我们的示例会是这样的:

<resources>
   <declare-styleable name="BottomBorderTextView">
       <attr name="borderColor" format="color" />
   </declare-styleable>
</resources>

然后,您可以在自定义组件的 XML 布局中引用自定义属性:

<com.myapp.BottomBorderTextView
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:text="Hello world!"
  app:borderColor="#FFFF9900" />

接下来,您必须在View的构造过程中提取自定义属性的值。这需要一些非常不直观的代码。让我们让代码示例解释英语无法解释的内容:

R.styleable.BottomBorderTextView 是从哪里来的?R.styleable.BottomBorderTextView_borderColor 又是什么?答案是:魔术。系统在幕后进行了一些神奇的事情,但可以大致保证,通过先前创建的 resource XML,这些值已添加到全局 R 实例中。declare-styleable 节点的名称生成带有下划线附加的属性名称,然后将 attr 节点的名称附加到其后。TypedArray 本身也有些神奇,我们强烈怀疑你会在其他场景中使用 context.getTheme().obtainStyledAttributes。一如既往,我们鼓励您阅读开发文档,甚至查看源代码,但在这种情况下(通常在框架级编译操作中频繁发生),您可能只想相信我们。

警告

你可能想要在一个 initialize 方法中封装自定义属性逻辑,并为你自定义视图的每个构造函数签名调用它,或者你可以使用每个构造函数调用默认或 null 值的便利技巧。

iOS

在 iOS 上,你可以配置一个自定义视图,并在 Xcode 中的故事板或直接在 XIB 文件中构建场景。不幸的是,由于重用这样的视图的复杂性,这种方法很快就会变得不可持续。通常,在 iOS 中提到“自定义视图”时,指的是一个从 iOS 根视图类继承的自定义类。这些视图通常可重复使用,并且通常包含比直接将功能塞入视图控制器中更多的功能。让我们更多地了解在 iOS 和 UIKit 中创建自定义视图的一些最佳实践。

如何创建自定义视图

在 iOS 上,视图基本上是一个 UIView 实例。这可以是直接的 UIView 实例,也可以是 UIView 的子类。为了创建一个自定义视图,仅需像这样子类化 UIView

class SomeView: UIView {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    // Customization goes here
}

如果你计划添加更多属性并希望对象在初始化时准备就绪,那么需要存在两个初始化器:init(coder:)init(frame:)。你可以在这些方法内部添加任何必要的设置代码,以准备对象供使用。

例如,如果我们想要一个红色背景的视图,其中包含一个标有“Click Me!”的按钮,我们可以像这样设置一个对象:

class SomeView: UIView {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    private func setupView() {
        backgroundColor = .red

        let button = UIButton(type: .custom)
        button.titleLabel?.text = "Click Me!"

        addSubview(button)
    }
}

setupView 方法在两个初始化器之间共享,并且在创建视图时调用。这允许一个一致的设置过程发生。在 setupView 内部,视图的 backgroundColor 被设置为 .redUIColor 实例,并创建一个新的按钮实例并将其添加为子视图。

还可以向我们的视图添加属性。例如,让我们使按钮内部的文本可配置,如下所示:

class SomeView: UIView {
    var buttonText: String = "Click Me!" {
        didSet {
            button.titleLabel?.text = self.buttonText
        }
    }
    lazy var button: UIButton = {
        let button = UIButton(type: .custom)
        addSubview(button)
        return button
    }()

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    convenience init(frame: CGRect, buttonText: String) {
        self.init(frame: frame)
        self.buttonText = buttonText
    }

    private func setupView() {
        backgroundColor = .red
        button.titleLabel?.text = buttonText
    }
}

let noClicky = SomeView(frame: CGRect.zero, buttonText: "Don't click me!")

我们添加了一个新的属性buttonText,用于在变量中存储按钮的标题文本,以便我们可以使用它来填充按钮。这将允许我们初始化按钮并同时传递正确的文本。此外,我们稍微改进了我们的setupView方法:我们将按钮初始化从中删除,并添加到一个lazy属性中存储,这样我们可以稍后更改它,而不必将其作为新按钮再次添加到视图中。

让我们看看如何使用我们的新自定义视图。

如何使用自定义视图

可以简单到如下程度:

class SomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let clickMeButton =
          SomeView(frame: CGRect(x: 0.0, y: 0.0, width: 100.0, height: 50.0),
          buttonText: "Click Me!")
        view.addSubview(clickMeButton)
    }
}

此代码创建一个新的自定义视图控制器,并实例化视图的一个实例。然后,将其添加到视图控制器的视图中。非常简单!

然而,我们可以使用界面生成器直接添加按钮的实例。在 Storyboard 或 XIB 文件中,从库中添加一个新的视图对象。然后,使用身份检查器将对象的自定义类设置为“SomeView”,以更改映射到视图的类。

这很好,但是我们可以通过一些与界面生成器相关的自定义标志@IBInspectable@IBDesignable做得更好。这些标志使界面生成器尽可能地配置和显示视图,就像它将在运行的应用程序中显示的那样。

要使用它们,请在代码中像这样装饰自定义视图:

@IBDesignable class SomeView: UIView {
    @IBInspectable var buttonText: String = "Click Me!" {
        didSet {
            button.titleLabel?.text = self.buttonText
        }
    }
    lazy var button: UIButton = {
        let button = UIButton(type: .custom)
        addSubview(button)
        return button
    }()

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    convenience init(frame: CGRect, buttonText: String) {
        self.init(frame: frame)
        self.buttonText = buttonText
    }

    private func setupView() {
        backgroundColor = .red
        button.titleLabel?.text = buttonText
    }
}

现在,如果你回到 Storyboard 或视图中,在视图的属性检查器中,现在有一个名为“Button Text”的新字段,你可以在这里设置按钮的文本。如果你更改它,界面生成器将更新屏幕上显示的按钮文本(见图 3-1)!

界面生成器、UIKit 和 Xcode 是强大的组合。你可以真正创建任意数量的不同视图和变体——唯一的限制是你的想象力。可用的选项是广泛的,不可能在一本书的单一章节中逐一列出。有关更多信息,请务必查阅苹果的UIView 开发者文档

在界面生成器中具有可编辑字段的自定义视图

图 3-1. 在界面生成器中具有可编辑字段的自定义视图

我们学到了什么

本章总结了我们对 UI 控制器(第 1 章)、视图(第 2 章)和自定义视图的涵盖。我们已经涵盖了很多内容。

  • 我们展示了在 Android 和 iOS 中创建自定义视图需要一些子类化和定制。

  • 在配置视图、设置和实例化它们以供使用时存在差异。

  • 我们讨论了使用自定义视图来构建自定义界面的方法和方式。

  • 两个平台都有强大而广泛的工具集,用于构建可以让用户喜悦和惊讶的界面。

第四章:用户输入

数十年来,与计算机交互的主要手段是键盘和鼠标。用户与使用的设备是紧密相连的。唯一的工作方式是坐在工作站前开始工作。最终,笔记本电脑和便携电脑增加了更多的灵活性,但输入机制基本保持不变。

然后是触摸。

如今,安卓和 iOS 设备已不再与用户保持一段距离。它们与用户保持亲密的物理接触。当用户按下按钮时,从用户的角度来看,它们直接被轻点,而不是通过触摸板或键盘快捷键。这使得输入成为将任何旧应用程序转变为理解用户的动态艺术品的关键要素之一。

输入可以采用多种形式和方式:在 Web 视图中点击链接,输入登录表单中的密码,或在屏幕上滑动以查看面孔,以了解是否与另一个孤独的灵魂有情感上的联系,或者最终可能演变成爱情。风险很高,但平台提供了一套强大的工具来获取用户的原始输入并将其转换为用户可以看到、听到或触摸到的操作结果。

任务

在本章中,你将学习:

  1. 接收并响应点击。

  2. 接收并响应键盘输入。

  3. 处理复合手势。

安卓

虽然安卓手势 API 可能有点繁琐,但它们相当透明,作为开发者,你将拥有满足最苛刻的触摸密集型应用程序所需的所有信息和访问权限。

接收并响应点击

在大多数现代移动应用程序中,点击或许是最常见的用户输入形式。无论是点击按钮提交表单,点击输入文本字段将焦点设置到它上面,长按以显示上下文选项,还是双击缩放地图以放大或缩小,此事件都是选择和接受的直观表达。

因此,安卓框架让捕捉点击变得简单且高效可用。

提示

基于传统原因,安卓框架在某些情况下仍然使用术语“点击”。在大多数触摸屏框架中,“点击”与“轻点”是同义词。

所有的View实例(包括ViewGroups)都可以接受View.OnClickListener作为一个可设置的属性(通过setOnClickListener)。一旦设置,框架会处理底层复杂性,当任何手势符合框架的条件时,监听器的onClick方法将被触发。要移除对给定视图的点击操作,只需将监听器设置为 null:myView.setOnClickListener(null);

注意,View.OnClickListener 是一个简单的函数接口,只有一个方法:onClick(View view)。这是根据源代码在撰写时的直接复制粘贴:

public interface OnClickListener {
  void onClick(View v);
}

这种架构意味着界面可以在几乎任何级别实现——在像 ActivityFragment 这样的控制器上,或者直接在 View 实例上,或者在匿名类、lambda 或方法引用上。此外,点击监听器可以在 XML 布局中分配。我们将逐一查看这些方法。

使用控制器实现 View.OnClickListener

使用控制器实现方法引用:

使用 lambda 表达式:

使用匿名类实例:

在总是具有相同点击行为的 View 子类上:

最后,您可以在布局的 XML 中使用方法名(作为 String)来分配点击监听器。包含的 Activity 必须具有与 View.OnClickListener.onClick 签名匹配的公共方法:

<!-- contents of res/layout/myactivity_layout.xml -->
<Button xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Click me!"
    android:onClick="myClickHandler" />

注意,Activity 将自动接管关系并创建绑定逻辑,无需明确引用方法或 View

public class MyActivity extends Activity {

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

   public void myClickHandler(View view) {
    Log.d("MyTag", "View was clicked " + view.toString());
   }

}

请注意,一个 View 最多只能同时设置一个 OnClickListener。要有多个点击监听器,您可以更新监听器以调用其他监听器,或者创建一个支持多个监听器的小框架。例如,您可以使用以下方式在单个监听器中管理回调列表:

可以如下使用:

尽管处理轻触事件的选项似乎很广泛,但实际上这只是冰山一角。Android 框架提供了多个级别的触摸事件访问,如果需要,您可以自行实现轻触逻辑——例如,您可能希望仅在一段延迟后触发轻触,或者您可能希望有一个更宽容(或更保守)的“漫游”区域(原始触摸事件在不再被视为轻触之前可以漫游多远)。幸运的是,您可能永远不需要这样做,但我们稍后将深入研究手势管理。

接收并响应键盘输入

Android 框架处理键事件的方式与您可能处理过的其他 UI 框架有很大不同。即使是 KeyEvent —— 这可能是您期望直接处理的 API —— 也很少被开发人员直接访问。请注意,即使是当前的文档也指出:

由于软输入方法可以使用多种创新方式输入文本,不能保证软键盘上的任何按键都会生成键事件:这取决于输入法编辑器的决定,事实上,不鼓励发送此类事件。您不应依赖于接收软输入法上任何按键的键事件。

这仅仅说明了来自“软件”(屏幕上的)键盘的键事件不能保证。对于“硬件”键盘(物理键盘,如您在少数现代智能手机或通过蓝牙或 USB 连接的便携式键盘上找到的那种),它们是有保证的;然而,这并不是非常有帮助,因为您将要对其做出反应的大多数键输入事件将来自软键盘。此外,即使是钩住这些事件,也需要一些相当复杂的设置,包括绑定到“IME”(输入法)、注册焦点、根据需要扩展和收缩键盘等。

在深入研究开发人员文档时,我们找到了一个名为“处理键盘操作”的部分。听起来很有前途,但我们立刻又看到了一个引人注目的横幅:

当使用 KeyEvent 类和相关 API 处理键盘事件时,您应该期望这些键盘事件仅来自硬件键盘。您不应该依赖于从软输入法(屏幕键盘)接收任何键的键事件。

所以我们该怎么办?我们有几个策略…

首先,更常见的是,我们实际上可能更感兴趣的是在编辑文本更改其值时触发的更改事件,而不是实际的KeyEvent。在这些情况下,我们可以访问TextWatcher接口,该接口需要实现三个方法:

  • onTextChanged

  • beforeTextChanged

  • afterTextChanged

TextWatchers可以监听TextView实例(包括EditText)上的文本更改事件,使用addTextChangedListener监听器。

这是少数几个允许附加多个监听器的监听器 API 之一。为了支持这一点,还有一个对应的removeTextChangedListener方法。

使用TextWatcher,我们可以检测输入文本字段的值何时发生更改,这通常正是我们在监听键事件时要做的事情。虽然TextWatcher接口的方法签名可能会有很大不同,但每个方法都提供对已更改的文本的访问,可以作为Editable实例或CharSequence实例:

除了文本更改之外,假设我们的用户只很少使用外部物理键盘,我们需要承认我们主要感兴趣的是软键盘行为,并稍微了解“IME”的概念。“IME”代表“输入法编辑器”,在技术上是指任何可以处理来自硬件组件的事件的东西,但实际上几乎完全是指通过TextView管理软键盘的内容,通常通过EditText实例,这是TextView的子类,具有内置的编辑功能。

像大多数View配置一样,IME 通常可以通过 XML 指令或编程语句来处理。最常见的 IME API 是“IME options”:即android:imeOptionsTextView.setImeOptions,接受表示各种 IME 标志的整数,例如“go”、“next”、“previous”、“search”、“done”和“send”(还有其他)。虽然选项的语义有时会随行为表达,但并非总是如此。例如,虽然“next”和“previous”将改变屏幕的焦点,“go”、“done”和“send”可能没有明确的不同,但应该向附加的监听器传递不同的值。

例如,你可以使用android:imeOptions="actionSend"创建一个EditText。当该EditText获得焦点时,屏幕上将会打开软键盘,并且会有一个专门用于“发送”操作的按钮(通常这个按钮会显示为键盘上标记为“发送”的按钮,使用设备的本地语言)。点击这个按钮将会触发注册的TextView.OnEditorActionListener来执行其onEditorAction事件(稍后我们会详细介绍)。

类似地,你可能会使用android:imeOptions="actionNext",这表示软键盘会呈现一个带有“下一个”表示的按钮(通常是一个向右指向的箭头)。点击这个按钮通常会将焦点发送到视图树中的下一个可用 IME(可能是一个EditText)。

如果你想对 IME 按钮的行为有更具体的控制,可以使用TextView.OnEditorActionListener。你可以使用setOnEditorActionListener方法将这个监听器的实例分配给 IME(如一个EditText),就像分配任何监听器一样(类似地,将此值设置为null以删除先前附加的监听器)。

OnEditorActionListener实例实现了一个方法:public boolean onEditorAction(TextView view, int actionId, KeyEvent event)。请随意使用传递给监听器的任何参数,但通常actionId标志最有趣。在上一个例子中,当点击右指向按钮时,任何附加的OnEditActionListener实例将触发它们的onEditAction方法,参数包括:打开键盘的View实例,一个等于EditorInfo.IME_ACTION_NEXT的整数常量,以及描述“下一个”按键事件KeyEvent

处理复合手势

如果你需要超出预设功能的手势功能,你有几种可用的机制。我们认为最直接的方法是简单地重写ViewGroup(或Activity!)的onTouchEvent方法,并以任何适合你需求的方式处理每个事件。每个运动事件都有一个类型标志(例如,一个手指开始一个手势[ACTION_DOWN],在屏幕上移动[ACTION_MOVE],结束一个手势[ACTION_UP],或其他类似的方法用于多点触控)。有了这些信息和恰当使用时间戳,你可以实现你的应用可能需要的任何自定义行为。

在编写自定义手势功能时,还有其他可用的 API 可以使复杂任务变得更加容易,例如Scroller,尽管其名称并不实际执行任何滚动运动,但确实具有一些非常方便的惯性滚动衰减或抛物线计算方法。VelocityTracker用于记录运动事件并提供关于任一轴上的速度和加速度的信息。

如果这些还不够或者你的需求不需要如此精细的控制,一个简单的访问手势的方法是使用GestureDetector(或来自支持库的GestureDetectorCompat)。GestureDetector实例可以传递一个GestureListener,并提供触摸事件,以返回常见的回调,包括:

  • onDown

  • onFling

  • onLongPress

  • onScroll(可以将其视为“拖动”)

  • onShowPress

  • onSingleTapUp

要实现这一点,你需要一个GestureDetector实例,它需要一个Context实例和一个GestureListener实例:

GestureDetector实例负责大部分核算;它将使用系统提供的值,如重力和触摸误差,因此你可以确信你的应用将在与ScrollViewRecyclerView相同的条件下启动一个抛掷动作。

当一个父ViewGroup包含能够消耗触摸事件的View子项(甚至通过简单的View.onClickListener),一个已经复杂的手势管理系统很快就会变得难以管理。通常情况下,你可以结合使用onInterceptTouchEventonTouchEvent(参见开发者文档关于前者);在这两者之间,你几乎可以至少访问到在任何容器内发生的触摸事件。

View类实例可用的其他事件回调包括:^(1)

  • onKeyDown(int, KeyEvent): 当新的按键事件发生时调用。

  • onKeyUp(int, KeyEvent): 当按键弹起事件发生时调用。

  • onTrackballEvent(MotionEvent): 当轨迹球事件发生时调用。

  • onTouchEvent(MotionEvent): 当触摸屏幕动作事件发生时调用。

  • onFocusChanged(boolean, int, Rect): 当视图获得或失去焦点时调用。

要了解更多关于手势检测的信息,请查阅Android 的优秀指南

iOS

2007 年,苹果推出了 iPhone,随之而来的是多点触控技术的诞生。尽管现在它已经无处不在,但在当时,能够在玻璃屏幕上使用多个手指是一个革命,并改变了用户界面。触摸目前是与智能手机交互的主要方式,但绝不是唯一的方式。本章涵盖了两种最常见的输入方法:触摸和键盘。让我们深入探讨一下。

接收并响应轻击

iOS 中可用的触摸事件 API 可以说是行业最佳。它们随时间略有变化,但自 iOS 4 以来引入手势识别器后基本保持不变。这绝对是拦截触摸事件最简单的方法。以下是如何在视图控制器内监听图像视图上的单次轻击的示例:

class SomeViewController: UIViewController {
    var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        imageView = UIImageView(image: ...)
        let gestureRecognizer =
          UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
        gestureRecognizer.numberOfTapsRequired = 1
        imageView.addGestureRecognizer(gestureRecognizer)
    }

    @objc func handleTap(_ gestureRecognizer: UIGestureRecognizer) {
        print("Image tapped!")
    }
}

我们从声明我们的UIViewController子类SomeViewController开始。在这个类中,大部分操作发生在viewDidLoad()中。这是 iOS 中视图生命周期的一部分,通常在这里可以对视图控制器的视图进行设置。查看第二章获取关于视图的更多信息。

在这个方法中,类的图像视图imageView被设置。在下一行,我们声明了一个类型为UITapGestureRecognizer的手势识别器,它通过self指向这个类,并提供了handleTap(_:)方法作为触发此手势识别器时要调用的函数。

在将手势识别器的numberOfTapsRequired属性设置为1后,表示它是一个单击识别器,我们将手势识别器添加到之前定义的图像视图上。将手势识别器附加到视图是必需的,以使该识别器触发。在我们的示例中,这意味着每当触摸或点击图像视图时,它将遍历与之关联的识别器列表,并尝试解析哪些触摸有效地触发特定的手势识别器。

假设一个触摸被我们的手势识别器注册,识别器本身将调用handleTap(_:),这是我们刚刚定义的动作。

注意

注意handleTap(_:)是一个@objc方法。这是因为UIGestureRecognizer和其子类在激活手势识别器时需要传入#selector(...)作为触发的动作。

我们的示例中有一些样板代码,但基本上归结为两行:

let gestureRecognizer = UITapGestureRecognizer(target: self, action:
#selector(handleTap(_:)))
imageView.addGestureRecognizer(gestureRecognizer)

我们声明手势识别器并将其附加到一个视图上。

手势识别器非常强大。我们稍后将在本章讨论它们。现在,让我们把注意力转向 iOS 上的另一个主要输入源:键盘。

接收并响应键盘输入

与 Android 不同,从未有过带有物理键盘的 iPhone 或 iPad。从理论上讲,这种情况可能会在未来发生变化,但考虑到苹果过去的立场,这种可能性非常小。iPad 有外部键盘(包括苹果制造的一种外壳),当然也可以连接蓝牙键盘作为屏幕键盘的替代品。尽管如此,在如此依赖“软键盘”的生态系统中,UIKit 中的键盘和文本字段库却令人沮丧地复杂——而且令人震惊,考虑到 UIKit 的其他一些区域使用起来是多么简单。

例如,在 iOS 上编辑文本的主要方式是通过UITextFieldUITextView。这些用户界面控件每个都有单独的委托协议,它们在功能上略有不同,但主要是名称上的区别。尽管每个委托协议都很强大,但没有一个专门的方法可以在文本字段更改时获取更新。

还有其他考虑的方法。例如,可以将文本字段连接到编辑事件处理程序,如下所示:

class SomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        textField = UITextField(frame: ...)
        textField.addTarget(self, action: #selector(textFieldDidChange(_:)),
        for: .editingChanged)
    }

    @objc func textFieldDidChange(_ textField: UITextField) {
        print(textField.text)
    }
}

在示例中,在名为SomeViewController的视图控制器中,我们定义了一个名为textFieldUITextField,在.editingChanged事件上添加了一个目标动作textFieldDidChange(_:)。每当用户在文本字段中编辑文本时,textFieldDidChange(_:)方法将会为每个添加或更新的字符调用一次;在我们的示例中,我们通过print(textField.text)打印出文本字段的文本。

大多数情况下这很有效,直到文本字段通过程序编辑时。然后,我们的textFieldDidChange(_:)方法会变得静默,并且我们的文本更改会在没有通知的情况下悄悄地进行。

捕获文本字段编辑的更可靠方法是通过添加类似以下方式的通知观察者:

class SomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        textField = UITextField(frame: ...)
        NotificationCenter.default
            .addObserver(self, selector: #selector(textFieldDidChange(_:)),
            name: UITextField.textDidChangeNotification, object: textField)
    }

    @objc func textFieldDidChange(_ notification: Notification) {
        let textField = notification.object as! UITextField
        print(textField.text)
    }
}

此示例与上一个示例类似,但有几点不同之处。首先,在定义我们的UITextField后,我们不再监听.editingChanged事件;现在我们监听UITextField.textDidChangeNotification。我们之前的同名方法textFieldDidChange(_:)在每次通知观察者触发时被调用;但是,为了针对文本字段,我们需要将notification.object强制转换为UITextField,以便在后续的print(textField.text)行中读取text值。

到目前为止,我们只对UITextField进行操作。当您需要观察多个文本输入以及混合使用UITextFieldUITextView对象时会发生什么?您的代码很快可能会变成这样:

class SomeViewController: UIViewController {
    var textField1: UITextField!
    var textField2: UITextField!
    var textField3: UITextField!
    var textView1: UITextView!
    var textView2: UITextView!
    var textView3: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()
        NotificationCenter.default
            .addObserver(self, selector: #selector(textFieldDidChange(_:)),
            name: UITextField.textDidChangeNotification, object: nil)
        NotificationCenter.default
            .addObserver(self, selector: #selector(textViewDidChange(_:)),
            name: UITextView.textDidChangeNotification, object: nil)
    }

    @objc func textFieldDidChange(_ notification: Notification) {
        let textField = notification.object as! UITextField
        doSomething(textField.text!)
    }

    @objc func textViewDidChange(_ notification: Notification) {
        let textView = notification.object as! UITextView
        doSomething(textView.text!)
    }

    private func doSomething(for text: String?) {
        print(text)
    }
}

伤心。

但是让我们将这些忧郁的想法和不完整的框架放在一边,专注于不同的事物。让我们重新回到触摸输入,并讨论更复杂的手势识别器。这是 UIKit 的一个领域,它可以从简单的逻辑成功扩展到复杂的体验,而不会给开发者带来太多负担。

处理复合手势

手势识别器非常适合用于单指简单的轻击手势。但是它们对于复杂的交互链也非常有用。让我们来看一个示例:

let doubleTapRecognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(_:)))
doubleTapRecognizer.numberOfTapsRequired = 2

这段代码与我们之前为单击手势识别器编写的代码类似。然而,通过简单地更改一个属性值,我们可以将其转换为双击手势识别器。

UIKit 中预先构建了其他手势识别器。如果您希望识别三指滑动手势,可以使用以下代码创建一个:

let panGestureRecognizer = UIPanGestureRecognizer(target: self,
action: #selector(handlePan(_:)))
panGestureRecognizer.minimumNumberOfTouches = 3

或者,如果您更愿意监听一些需要超出我们能够触及的物理动作,我们介绍五指三次轻击手势:

let fiveFingerTapRecognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(_:)))
fiveFingerTapRecognizer.numberOfTapsRequired = 3
fiveFingerTapRecognizer.numberOfTouchesRequired = 5

你可能不太可能在许多发布的应用程序中看到这种情况。然而,在触摸界面中的一个常见问题是,您通常会在一个视图上监听多个触摸事件。如果没有意外地先触发单击手势识别器,那么您如何监听单击手势双击手势呢?以下是可能的解决方法:

// Create a double tap recognizer
let doubleTapRecognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(_:)))
doubleTapRecognizer.numberOfTapsRequired = 2

// Create a single tap recognizer
let singleTapRecognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(_:)))
singleTapRecognizer.numberOfTapsRequired = 1
singleTapRecognizer.require(toFail: doubleTapRecognizer)

首先,我们创建一个名为doubleTapRecognizer的双击手势识别器。我们将numberOfTapRequired设置为2。接下来,我们创建一个名为singleTapRecognizer的单击手势识别器。我们将轻击次数设置为1,然后调用一个单独的方法require(toFail:),并传入之前的双击手势识别器。

require(toFail:) 方法是所有手势识别器都具有的方法,允许它们仅在另一个已识别的手势识别器首次失败时才触发。以这种方式连接识别器使得单击识别器等待双击手势识别器失败后才调用其处理程序。不连接这两个手势识别器意味着单击识别器将在双击手势识别器的第一次和第二次轻击时触发。

理想情况下,这使得可以轻松看到如何连接多个定义了执行优先级的复合手势。您可以创建的手势识别器组合数量基本上是无限的;它超出了本书范围以列出它们所有,但如果您有兴趣了解更多手势类型,请查看 Apple 开发者文档中关于UIGestureRecognizer的内容。

触摸事件 API

iOS 响应链的一个特性是面向所有响应者(例如视图和视图控制器)的细粒度触摸事件 API。这是一组非常强大的方法,每当触摸开始、移动、结束或取消时都会触发。然而,考虑到手势识别器的简单性和强大功能,除了在特定情况下需要更精细的触摸交互的自定义用户界面外,它们几乎总是首选的方法。对于这些情况,请查看UIResponder对象可用的触摸事件。

我们学到了什么

在本章中,我们看到了 Android 和 iOS 中监听和接收用户输入的相似之处和差异。用户输入可以是简单的触摸、复杂手势,或者是屏幕上和外部键盘的输入。

  • 两个平台都有类似的机制来监听和响应简单的触摸事件。

  • Android 和 iOS 都可以从各种来源接收文本输入,但由于接收此类输入的模式略显复杂,iOS 需要一些辅助。

  • 操作系统中内置了检测和响应复杂手势的方法,但两个平台都有不常用于另一个平台的手势。

触摸输入使得 Android 和 iOS 设备如此直观和亲密。了解如何处理和构建能够接收输入的应用程序是非常重要的。

在下一章中,我们将更深入地探讨那些对象和模式,它们并非像前面讨论的直接面向用户。让我们开始吧!

^(1) 来自 Android 开发者文档

第五章:消息传递

消息传递在计算机科学中是一个非常广泛且有时具有争议性的主题,有许多已经出现或存在的模式和系统,如“发布/订阅”、“事件分派器”、“回调”、“观察者”、“消息队列”等。事实上,这些通常非常相似,您很难定义它们之间的实际差异。但无论如何,在任何应用程序中,这都是一个关键的功能,在移动应用程序开发中有一些共识策略,我们将在这里解释。

任务

在本章中,您将学习:

  1. 使用回调以响应操作。

  2. 将消息分派给任何感兴趣的订阅者。

  3. 监听并响应系统内分派的消息。

Android

在 Android 中,您通常使用回调进行直接的消息传递,并使用统计可用和线程安全的LocalBroadcastManager分派事件。请注意,LocalBroadcastManager发送Intent实例以供BroadcastReceiver实例接收——这与系统范围消息使用的机制相同,许多是由其他应用程序或操作系统组件提供的;然而,LocalBroadcastManager只通知您的应用程序中的BroadcastReceiver,这既是一件好事(安全),也稍微有些局限性——如果您希望在应用程序之间通信或与底层框架通信,则必须超越LocalBroadcastManager提供的简洁和简单 API。

使用回调以响应操作

在 Java 和 Android 中,传递消息的最直接方式是将回调与操作一起发送。回调是专门创建的对象实例,仅用于接收结果并对其进行操作,通常是功能接口的简单匿名实例。这里有一个例子:

某人可能会使用Callback接口的完整实例、lambda 表达式或方法引用来调用它:

Java 然后 Kotlin 中的 lambda 表达式

Callbacks.requestData(data -> Log.d("MyApp", "result received: " + data));

Callbacks.requestData { data-> Log.d("MyApp", "result received: $data") }

Java 然后 Kotlin 中的方法引用

private void handleResult(Object data) {
  Log.d("MyApp", "result received: " + data);
}
...
Callbacks.requestData(this::handleResult);

private fun handleResult(data: Any?) {
  Log.d("MyApp", "result received: $data")
}
...
Callbacks.requestData(::handleResult)

仅在 Java 中使用实例

Callbacks.requestData(new Callback(Object data) {
  Log.d("MyApp", "result received: " + data);
});

在 Kotlin 中没有直接的等价物,因为函数参数被定义为 lambda 而不是接口。请改用 lambda 的语法,并参阅Kotlin 文档了解有关高阶函数的信息。

正如您所见,这是一条非常直接的路径,并允许您作为开发人员定义您的程序对操作结果的反应,具有极大的灵活性。

这里有一个简单但功能性的例子。这次我们同时使用回调来处理成功的操作和失败的结果:

您可以像这样使用它:

在其他地方调用它,如下所示:

更实际的例子可能是这样的:你有许多可能抛出异常的操作,比如需要身份验证并返回必须成功解析的 JSON 响应的网络请求,然后是本地磁盘或数据库操作来保存结果。你可能有一个处理所有这些逻辑的Activity,并希望有通用的失败处理逻辑(例如,向用户显示一个包含描述失败的友好文本的Snackbar,但不中断用户体验或使应用崩溃)。类似这样的类可能会很有帮助:

你可以在类似这样的控制器中使用这些实用程序:

这些是使用回调的基础!这是一个非常直接的模式,你可能已经见过或者使用过类似的东西。

向任何感兴趣的订阅者发送消息

一个不太熟悉的 API 是 Android 特有的,不在框架外部可用的LocalBroadcastManager。让我们直接开始吧。

LocalBroadcastManager是一个单例——整个应用程序中只使用和管理一个实例。你可以通过将任何Context实例传递给静态的getInstance方法来访问单例实例:LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);。你永远不需要担心构造函数或配置。此外,它在开箱即用时是线程安全的!

这个类作为单例的真正好处在于,你能够在两个互不了解的对象之间发送和接收消息。例如,你可以从管理应用程序中多个不同RecyclerViewAdapter类广播消息,而不知道当接收到该消息时会发生什么(如果有的话)。在一个列表视图中,你可能希望根据特定类型的消息更新列表;在另一个列表中,你可能希望有非常不同的反应——甚至在相同的消息中finishActivityAdapter不需要知道RecyclerView的实例或包含它们的各种Activity实例,反之亦然——LocalBroadcastManager发出警报,任何感兴趣的方可以根据需要做出反应。

LocalBroadcastManager实例有两个我们经常使用的方法:它们是sendBroadcastregisterReceiver。正如你可能已经猜到的那样,sendBroadcast发送消息并通知任何已注册接收该类型消息的其他类。第三个方法unregisterReceiver也很重要,这样我们就可以在应用程序中删除侦听器时停止侦听消息(从而防止内存泄漏!)。

这样的消息由Intent类表示,与用于启动新Activities和描述系统范围派发的相同类。Intent实例有一个“action”属性,在构造函数中可以通过传递一个String来设置;甚至可以使用addAction方法后续添加更多操作。

LocalBroadastManager使用sendBroadcast方法来发送Intent

一旦调用这个方法,之前在同一个全局LocalBroadcastManager实例上注册过这个动作的任何BroadcastReceiver都会调用它们的onReceive方法,并且第二个参数将是您发送的Intent实例。请注意,只有在IntentFilter包含与Intent实例的动作匹配的动作的BroadcastReceiver实例才会被触发。因此,在前面的例子中,如果您创建一个使用不同动作构造的Intent,如Intent intent = new Intent("user-login");,则本例中的BroadcastReceiver将不会触发,因为动作不匹配。

监听和响应系统内分发的消息

让我们看看如何注册以便在广播时收到通知。

首先,我们需要一个BroadcastReceiver实例,这是一个扩展基础BroadcastReceiver类的对象实例,它将会对我们感兴趣的消息做出响应:

接下来,我们需要一个IntentFilter实例,它实际上只是我们感兴趣的一系列字符串动作类型的列表:

注意

IntentFilter提供了其他功能,但最常见的是基本的String动作过滤器。

现在我们可以将这些传递给registerReceiver方法,这样每次使用“data-received”动作发送广播时,我们都会收到通知:

请注意,LocalBroadcastReceiver是线程安全的,因此您可以在后台线程发送广播,并在 UI 线程安全地监听。但广播本身是异步的,这意味着它不一定按照程序中语句的顺序触发。然而,有一个broadcastSync方法可以立即和串行地发送广播。

还非常重要的是记得注销不再使用的BroadcastReceivers,比如在已经finishedActivity中定义的那些。否则可能会发生内存泄漏。

在 Java 和 Android 中有许多第三方消息系统,包括 Square 的 Otto 和 GreenRobot 的 EventBus。还有非常流行的 RxJava Android 库使用的Observable API,但在我看来,观察者模式与传统的发布-订阅模式有足够的不同,值得单独讨论。

最后,在 Java 中编写自己的事件总线(一个完整的事件分发和接收系统)非常简单;我们已经在大约 40 行代码中编写了一个。

iOS

在 iOS 中有几种处理消息传递的方式。与 Android 类似,其中一种常见的模式是通过回调和通知来实现。让我们来分析这些模式之间的区别,并确定何时使用其中一种成熟的模式。

使用回调来响应操作

Swift 和 iOS 中最常见的消息传递形式是通过闭包实现的。

闭包

闭包是作为对象而存在的独立函数。它们可以作为另一个对象的属性,并作为方法参数传递或存储以供将来使用。在其最基本的形式中,闭包可能看起来像这样:

var someClosure = { print("I'm a closure!") }
someClosure() // Executes our closure, which prints out a message to the console

本质上,闭包是可以随时调用的弹簧加载的代码片段。它们的另一个名称是“匿名函数”,它们非常有用!

在异步情况下,闭包作为回调函数的使用是最强大且最合适的,比如等待网络调用完成时。以下是一个假设的 API 客户端如何利用闭包与服务器通信而不会导致整个应用程序在等待服务器响应时无响应的示例:

class NetworkService {
	var completion: ((Bool, Error?) -> ())?

	func fetchData(for url: URL) {
		...
	}
	func onSuccess() {
		completion?(true, nil)
	}
	func onError(error: Error) {
		completion?(false, error)
	}
}

let api = NetworkService()
api.completion = { success, error in
	if success {
		print("Success!")
	} else {
		print("Uh-oh!")
	}
}
api.fetchData()

让我们逐步了解这段代码。

首先,completion属性是一个存储在类中可以稍后使用的闭包实例。需要注意的是,这是一个可为空的属性,如果我们不想在网络调用完成时执行任何操作,它可以是nil

接下来,fetchData是一个占位方法,当我们的假设 API 接收到响应时最终调用onSuccessonError

现在,这是重要的部分。每当调用onSuccessonError时,它们都会调用存储的闭包,并通过方法参数传递一些数据。闭包可以传递任何数据,但在这个例子中,传递给闭包的是一个布尔值,指示操作是否成功,以及一个可选的错误(如果发生错误)。

移动到这个对象的使用方法,我们可以看到该类被实例化为api,并且从之前的completion变量中传递了一个闭包,用于将操作成功或失败的信息打印到控制台。这个闭包在NetworkService类中的onSuccessonError中被引用和调用。

最后,我们调用从我们创建的对象中调用fetchData,以便启动网络调用并从 API 接收响应。

逃逸闭包和非逃逸闭包

闭包理解中较难的一个方面是内存管理。每当创建一个闭包时,其中包含的变量和实例被“闭合”,并且创建这些对象的强引用。其结果是,即使这些对象超出作用域,它们仍然存在以便闭包使用。以下是一个例子:

class Incrementor {
    var count = 0

    func increment() {
        count += 1
        print(count)
    }
}

let incrementor = Incrementor()
let closure = {
    incrementor.increment() // 1
    incrementor.increment() // 2
}
closure() // Prints "1\n2"

首先,我们创建一个对象,每次调用increment时自增一次。我们实例化了这种类型的对象。然后,我们将该对象传递给一个闭包,以便对它进行强引用。这允许该对象在通常会超出作用域时继续存在。这样做的最终结果是,当调用closure()时,对象仍然存在,并且能够在每次调用时将我们的计数增加一次(在本例中,两次)。

这很重要,因为它涉及到所创建对象的内存管理。Swift 及其前身 Objective-C 容易受到称为保留周期的错误影响。当一个对象因其他对象持有其引用而无法退出作用域并从内存中清除时,就会发生这种情况。由于闭包对其中包含的对象创建了强引用,它们实质上是在“计分板”上为拥有该对象的其他对象增加了一个记号。这告诉编译器不要清除它,以便其他对象可以使用它。在前面的例子中,对于闭包来说,这是有帮助的,因为incrementor保持在作用域内,并且可以在稍后调用闭包时调用。

当闭包作为 Swift 的参数传递时,如果闭包不超出被调用的函数的生存期,那么该闭包被称为nonescaping闭包。默认情况下,对于每个作为参数传递的闭包,都会隐式声明为这种类型的闭包。一个非逃逸闭包的示例如下:

class Incrementor {
    var count = 0

    func increment(with closure: () -> ()) {
        count += 1
        closure()
    }
}

let incrementor = Incrementor()
let printCount = {
    print(incrementor.count)
}

incrementor.increment(with: printCount) // 1
incrementor.increment(with: printCount) // 2

我们已经修改了Incrementor类的increment()方法,现在它接受一个闭包作为方法参数,因此方法签名现在是increment(with:)。当count变量增加一个单位时,立即调用此闭包。在我们的示例中,我们传递了一个闭包,它只是直接从incrementor对象中调用print打印count变量。我们直接引用了incrementor,这意味着编译器创建了一个强引用,并且incrementor在我们的应用程序完成之前不会从内存中释放。

在我们的例子中,传递给increment(with:)的参数closure是一个非逃逸闭包。它永远不会超出被调用的函数的生存期。让我们看看逃逸闭包的示例是什么样子:

class Incrementor {
    var count = 0
    var printingMethod: (() -> ())?

    func increment(with closure: () -> ()) {
        if printingMethod == nil {
            printingMethod = closure
        }
        count += 1
        printingMethod?()
    }
}

let incrementor = Incrementor()
let printCount = {
    print(incrementor.count)
}

incrementor.increment(with: printCount)
incrementor.increment(with: printCount)

正如您所看到的,我们添加了一个新的printingMethod来存储我们传入的闭包。然后,在increment(with:)内部,我们将传入的闭包赋值给printingMethod变量。如果您在代码编辑器中尝试此操作,当您尝试为printingMethod分配一个值时,您将收到一个编译器错误,因为您没有指示此闭包是一个逃逸闭包。这是一个简单的修复方法。只需在传递给increment(with:)方法的闭包中添加一个escaping关键字,如下所示:

func increment(with closure: @escaping () -> ()) {
	if printingMethod == nil {
		printingMethod = closure
	}
	count += 1
	printingMethod?()
}

现在完整的示例看起来是这样的:

class Incrementor {
    var count = 0
    var printingMethod: (() -> ())?

    func increment(with closure: @escaping () -> ()) {
        if printingMethod == nil {
            printingMethod = closure
        }
        count += 1
        printingMethod?()
    }
}

let incrementor = Incrementor()
let printCount = {
    print(incrementor.count)
}

incrementor.increment(with: printCount)
incrementor.increment(with: printCount)

这解决了编译器错误。但是我们创建了一个讨厌的 bug,称为保留周期。它们很容易创建,但很难调试。在我们的小例子中并不太明显,但如果你有一个大对象或者一个被多次创建的对象,你可能会很快耗尽应用程序的可用内存,或者遇到意外的副作用和不期而遇的问题。那么我们的 bug 在哪里呢?

记得我们提到过创建闭包会创建一个强引用,或者给一个对象添加一个“记分牌”,这样它就不会在所有引用都消失之前被清除出内存?在我们的例子中,我们在printCount闭包中创建了一个对incrementor的强引用。但是,当我们将其存储为increment(with:)方法内部的printingMethod时,我们创建了对闭包的强引用。对象正在存储对自身的强引用,以便该对象永远不会超出作用域并从内存中清除!

幸运的是,Swift 有一种方法可以通过捕获列表将强引用转换为所谓的弱引用。让我们在我们传递给increment(with:)方法的闭包内部将incrementor声明为弱引用,像这样:

let printCount = { [weak incrementor] in
    guard let incrementor = incrementor else { return }
    print(incrementor.count)
}

注意,我们使用了[weak incrementor]来指示incrementor应该是一个弱引用而不是一个强引用。我们还在闭包中添加了一个guard语句,以确保在尝试访问之前incrementor不为nil。这意味着我们不再有保留周期,因为在存储increment(with:)方法内部的闭包到printingMethod时,Incrementor没有存储自身的强引用;它存储的是一个弱引用版本。因此,当最后一个incrementor被调用时,虚拟记分牌可以归零,并且对象可以从内存中释放。

现在,闭包显然是在对象之间传递消息的更现代方法,但有时它们可能有点过度。它们也容易出现内存引用错误,正如刚刚展示的那样,并且在不仔细注意和故意处理的情况下容易出现线程错误。在 Cocoa Touch 中,人们发现它们并不是对象传递消息的唯一方式。让我们来看看代理与基于闭包的回调函数有何不同。

代理

自从它的创立以来,代理已经成为 Cocoa Touch 的一部分。它们提供了逻辑简单性,但通常比闭包更冗长。这是我们之前使用委托而不是闭包编写的相同NetworkService类:

protocol NetworkServiceDelegate: class {
	func fetchDidComplete(success: Bool, with error: Error?)
}

class NetworkService {
	weak var delegate: NetworkServiceDelegate?

	func fetchData(for url: URL) {
		...
	}
	func onSuccess() {
		delegate?.fetchDidComplete(success: true, with: nil)
	}
	func onError(error: Error) {
		delegate?.fetchDidComplete(success: false, with: error)
	}
}

class APIClient {
	init() {
		let api = NetworkService()
		api.delegate = self
		api.fetchData()
	}
}
extension APIClient: NetworkServiceDelegate {
	func fetchDidComplete(success: Bool, with error: Error?) {
		if success {
			print("Success!")
		} else {
			print("Uh-oh!")
		}
	}
}

在这段代码中,我们可以看到一个名为NetworkServiceDelegate的协议被定义了。我们的回调方法的方法签名与我们的completion闭包类似,带有BoolError参数,但是使用的是命名而不是匿名。

接下来,我们在NetworkService中提供了一个地方来存储我们的委托,这个属性被适当命名为delegate。这个属性被标记为weak,以防止委托存储其父对象的引用循环;这将防止任一对象被从内存中释放,并且很容易被忽略。

你可以看到很多代码是相同的,但是不是通过在NetworkService中调用闭包,而是直接通过delegate?.fetchDidComplete(success:with:)调用委托。

要调用我们的 API,我们需要创建一个对象,实例化NetworkService,将自身设置为代理,并调用fetchData()从网络获取数据。在这个示例中,这就是APIClient类的目的。

最后,在示例开始时我们NetworkServiceDelegate协议所需的代理方法实际实现写成了APIClient类的扩展。每当我们调用fetchData方法时,将调用fetchDidComplete(success:with:)方法,并将成功或失败的消息打印到控制台。

正如您很可能看到的那样,这比闭包稍微啰嗦一些,但调用非常直接。现在,它可能很快变得难以控制,但考虑到这种回调模式在 Cocoa Touch 中的深入根植,您最终将不可避免地遇到它。一个好的经验法则是,在任何需要异步性或有一个闭包可能会被调用的路径时使用闭包,并在您(或多个对象)想要同步调用有关是否应完成操作或调用状态的情况下使用委托。

最终,闭包和委托可能不符合必要的要求,而需要额外且不必要的复杂性。当需要多个闭包并行执行或多个委托接收相同消息时,很可能是考虑NotificationCenter和相关内容的时候了。

分发消息给任何感兴趣的订阅者

通知是向应用程序中的其他部分发送消息的一种内置和方便的方式,以实现组件之间的解耦。为了接收正在发送的通知,一个对象会“监听”一个共享对象,以便发布具有特定名称的消息。该消息可以附带数据,并传播到订阅的任何对象中。Cocoa Touch 将这些称为“通知”,在其他语言和框架中,这种模式有时被称为“发布-订阅”或“观察者”。在 Android 上的等效物是LocalBroadcastManager

注意

当我们说“通知”时,我们指的是一个Notification对象,而不是推送通知。并且,为了使事情更加复杂,对于 Android 来说,“通知”意味着系统托盘中的事件。

让我们从发布通知开始。通知名称本质上是字符串,因此您可以像这样使用原始字符串发布通知:

NotificationCenter.default.post(name: Notification.Name("didFinish"), object: nil)

在这段代码块中,共享的NotificationCenter.default对象用于发布名称为didFinish的通知。现在,这是一个很好的入门方式,但不适合包含在发送到 App Store 的成品应用程序中。问题在于使用原始字符串时,这种实现非常脆弱。希望监听您的通知的对象将使用通知的名称来做出反应。例如,如果您决定将通知名称更改为其他名称,例如didFinishDownload,那么任何订阅didFinish的对象都将不会收到您的通知。

修复这个问题并不难。您会注意到post方法并不直接接收原始字符串,而是将其包装在名为Notification.Name的枚举中。为了解决这个问题,我们可以在类或结构体上创建一个此枚举类型的属性,并使用它来存储原始字符串,而不是直接传递和监听它。这种改变允许任何对象针对该属性进行目标设置,因此属性值的任何更改(例如通知名称)都将自动被我们的监听器捕获。以下是这种做法的示例:

class SomeObject {
	public static let didFinishNotification = Notification.Name("didFinish")
}

NotificationCenter.default.post(name: SomeObject. didFinishNotification, object: nil)

在我们的示例中,类SomeObject定义了一个名为didFinishNotification的新静态属性。这个属性包含我们在第一个示例中在调用站点定义的枚举的值。稍后,在通知发布方法中,我们使用这个属性而不是自己声明值。这样做可以让我们将通知的名称从“didFinish”更改为更符合最佳实践、防止名称冲突的名称,例如“SomeObjectDidFinishNotification”。

也可以通过使用稍有不同的post方法,在通知中附加数据,这是一种相当常见的做法。以下是一个示例:

NotificationCenter.default
    .post(name: SomeObject. didFinishNotification, object: nil, userInfo:
    ["downloadCount": 3])

在这个示例中,我们传入了一个包含键名为“downloadCount”、值为3的字典。只要符合Hashable协议,我们可以传递各种数据,例如对象。如果我们想要传递一个对象,我们可以使用以下代码:

let anObject = SomeObject()
let count = 3

NotificationCenter.default
    .post(name: SomeObject. didFinishNotification, object: nil, userInfo:
    ["someObject": anObject, "downloadCount": count])

您会注意到在我们的示例中,我们再次使用预定义的原始字符串。这对于演示示例很容易,但在成品代码中最好避免使用,因为它会创建脆弱的连接。我们可以通过在类上包含与此键对应的属性来类似解决通知名称的问题,如下所示:

class SomeObject {
	...
}

// Use an extension to encapsulate your notification code
extension SomeObject {
	public static let didFinishNotification =
      Notification.Name("SomeObjectDidFinishNotification")
	public static let didFinishNotificationObjectKey = "someObjectKey"
	public static let didFinishNotificationDownloadCountKey = "downloadCount"
}

NotificationCenter.default
    .post(name: SomeObject. didFinishNotification, object: nil,
    userInfo: [SomeObject. didFinishNotificationObjectKey: anObject,
    SomeObject. didFinishNotificationDownloadCountKey: count])

通过为字典键使用静态属性,您可以为通知添加一些编译时安全性,使您的代码更加稳定。当我们的通知属性开始超出一个或两个通知名称时,将代码拆分成扩展是标准做法,如前面所示。

我们正在发布通知并传递数据,但还没有人在听我们说什么。让我们开始订阅这些通知,看看是什么样子。

监听并对系统中分派的消息做出反应

在 Swift 中订阅通知有两种方式:一种是使用选择器(@objc 方法),另一种是使用闭包。两者在本质上非常相似,但在代码和逻辑上有轻微的差异。以下是使用选择器的示例(这种方式在某种程度上更为常见):

class SomeObject {
	func listenForNotifications() {
		// Subscribe to the notification
		NotificationCenter.default
        .addObserver(self, selector: #selector(didFinishDownload(_:)),
        name: SomeObject.didFinishNotification, object: nil)
	}

	@objc func didFinishDownload(_ notification: Notification) {
		// Get the notification payload
		let downloadCount =
       notification.userInfo?[SomeObject.didFinishNotificationDownloadCountKey]
		print(downloadCount)
	}
}

让我们来分解这段代码。

首先,我们在示例类中添加了一个 listenForNotifications 方法。在这个方法中,我们将自己添加为先前创建的通知 SomeObject.didFinishNotification 的观察者。我们将任何对象作为观察者,作为我们控制的一个子对象的示例,但通过将 self 作为第一个参数传递,对象直接进行了分配。我们的目标是 didFinishDownload(_:) 选择器,在此之后使用 @objc 关键字定义了这个方法。

现在,在 didFinishDownload(_:) 方法的一部分中,传递了一个 Notification 对象。这个对象包含一个 userInfo 属性。这是一个可选的 Dictionary,我们可以在其中访问我们在上一个示例中传递的通知载荷。使用我们之前定义的通知载荷键 SomeObject.didFinishNotificationDownloadCountKey,我们可以访问通知中的数据,然后在下一行将其打印到控制台。

使用闭包而不是选择器

使用闭包来观察通知与之前的选择器样式观察非常相似。以下是与之前示例中的选择器不同的闭包形式的相同通知观察:

class SomeObject {
	private var observer: AnyObject?

	func listenForNotifications() {
		// Subscribe to the notification
		self.observer = NotificationCenter.default
      .addObserver(forName: SomeObject.didFinishNotification, object: nil,
      queue: nil) { notification in
  let downloadCount =
    notification.userInfo?[SomeObject.didFinishNotificationDownloadCountKey]
    print(downloadCount)
		}
	}
}

首先,重要的是指出选择器和闭包观察之间的主要区别:观察者存储为名为 observer 的变量。这个对象是在 listenForNotifications() 方法内部调用 addObserver(forName:object:queue:using:) 方法的结果。我们需要存储对这个对象的引用,否则我们的通知实际上会被释放,从而永远不会被调用。稍后,一旦我们取消订阅通知,我们将将此属性设置为 nil,以允许从内存中释放所有内容。

传递进来的闭包体与我们之前的 didFinishDownload(_:) 选择器相同;它获取作为通知一部分传递的载荷,然后将值打印到控制台。

停止监听通知

我们已经展示了如何发布通知和如何监听通知。这个过程的最后一步是展示如何停止监听通知。考虑到发布通知和监听通知所需的所有设备,从观察者中移除自己实际上非常简单。

// Selector style
NotificationCenter.default.removeObserver(self)

// Closure style
NotificationCenter.default.removeObserver(observer)
self.observer = nil

这会取消对象对其可能正在监听的所有通知的订阅,对于选择器样式的通知来说,这有点像是使用锤子,但通常是断开与 NotificationCenter 的关系的简洁方式。对于选择器样式的通知订阅,最好的做法是按名称取消订阅通知。方法调用如下所示:

NotificationCenter.default
    .removeObserver(self, name: SomeObject.didFinishNotification, object: nil)

此代码仅取消订阅 SomeObject.didFinishNotification 通知,并保留所有其他订阅。

警告

删除对象作为观察者从通知中移除的重要性可能并不显而易见。通过移除对象,实际上是在减少该对象的引用计数,如果没有其他对象引用它,它将被释放出内存。内存泄漏是一个常见且容易犯的错误,特别是在基于闭包的通知观察中。始终将自己从观察者中移除,并不要忘记为基于闭包的观察对象设置为 nil

针对特定对象的通知

虽然之前没有提到,但在观察通知时可以针对特定对象,这样你就只会接收到该对象发布的通知。可以通过在 addObserver 方法中传递对象作为参数来实现:

let objectToObserve = SomeObject()

// You will only receive notifications that objectToObserve posts
NotificationCenter.default
    .addObserver(self, selector: #selector(didFinishDownload(_:)),
    name: SomeObject.didFinishNotification, object: objectToObserve)

There a few different types of combinations available, for example:

// Receive all notifications from specificed object
NotificationCenter.default
    .addObserver(self, selector: #selector(didFinishDownload(_:)),
    name: nil, object: objectToObserve)

// Receive all didFinishNotification notifications from all objects
NotificationCenter.default
    .addObserver(self, selector: #selector(didFinishDownload(_:)),
    name: SomeObject.didFinishNotification, object: nil)

// Receive all notifications from all objects (don't do this)
NotificationCenter.default
    .addObserver(self, selector: #selector(didFinishDownload(_:)),
    name: nil, object: nil)

线程

通知是在发布它们的同一线程上接收的。这意味着,为了在后台线程接收到通知时更新用户界面,使用诸如 DispatchQueue 的内容将其调度到主线程非常重要。可以通过像这样用 DispatchQueue.main.async { ... } 包裹需要在主线程上运行的代码块来完成这一点:

class SomeObject {
	...

	@objc func listenForNotifications(_ notification: Notification) {
		DispatchQueue.main.async {
			updateUI()
		}
	}
}

键-值观察

Cocoa Touch 有一个完整的消息传递系统,由于主题的复杂性而未触及:键-值观察(KVO)。可以观察对象的单个属性,并在该属性的值更新时接收更新。不幸的是,它是一个设计不佳且较老的 API;只能在继承自 NSObject 的对象上使用。即使小心使用,也很容易出错。甚至在作者看来,前面提到的方法通常也不是更合适的选项之一。

然而,如果您感兴趣,可以从苹果的键-值观察编程指南获取有关 KVO 的信息。

我们学到了什么

在本章中我们覆盖了很多内容。你已经看到了:

  • 如何在 iOS 和 Android 中以各种方式传递消息。有直接回调,这是一种非常一对一的消息传递方式。

  • 在 Android (LocalBroadcastManager) 和 iOS (NotificationCenter) 中处理解耦消息传递的方式。

在你的应用程序中传递消息有许多方法。根据情况的不同,界限可能变得模糊,但通过这里描述的方法,你可以开始使应用程序的不同部分彼此交流。这对于创建可维护和解耦的架构至关重要。

第六章:文件

作为开发者,您可能因为各种原因想要读取、写入或检查文件。例如,您可能想要修改图像并将其保存到磁盘,将视频下载到用户的 SD 卡(在确定可用后),或者使用简单的索引 JSON 平面文件数据库。文件处理逻辑在应用程序开发中经常出现,因此大多数开发者都需要对基础知识有很好的掌握。

任务

在本章中,您将学习到:

  1. 从文件获取诸如磁盘上大小或最后修改日期等属性。

  2. 从文件读取和写入数据。

  3. 将数据从一个文件复制到另一个文件。

Android

在 Android 中,开发者可能会查询设备上外部 SD 卡的位置,压缩一组文件以进行上传,将 XML 写入以跟踪用户首选项,从资产获取位图,读取资源文件,或以持久方式记录事件。即使是 SQLite 数据库(由 AOSP 框架提供的数据库)也存在为单个文件,因此您可以使用相同的逻辑确定其大小,或者创建其导出的副本。

在 Java 中读写文件已经发展了很长一段时间,现代版本的语言包括流式 API,更新的 java.nio.file 包(“nio”表示“新输入/输出”),以及像 FilesPaths 这样的便利的帮助类,提供了多种文件系统访问方法。不幸的是,这些对大多数 Android 开发者并不可用。截至撰写本文时,只有最近的两个 Android OS 版本(大约占所有 Android 安装的 21%)支持 java.nio.file 包。流式 API 和 Files 帮助类在任何 Android 版本中目前都不可用。

但不要担心!通过一点创造力,我们可以利用现有的框架和标准库 API 来完成几乎我们需要做的一切。我们将主要使用的包是 java.io(您猜对了...“输入和输出”),并且我们将经常使用的类之一是 java.io.FileFile 实例是本地文件系统上位置的抽象表示。请注意,File 实例表示文件或目录;存在像 isDirectoryisFile 这样的 API 来区分它们。

要获取对现有文件的引用,可以将路径传递给 File 构造函数:File file = new File("path/to/file.ext");。在 Android 应用程序中,文件系统可能会受到限制,并且您的应用程序将被分配一个特殊的目录在设备上,您将获得对其读写权限——您可以通过在任何 Context 实例上调用 getFilesDir() 来获取该目录,这将返回一个表示系统为您的应用程序创建的该目录的已构造的 File 实例。因此,在 Android 应用程序中,您可以通过将该目录作为根传递来创建一个新文件:

File file = new File(context.getFilesDir(), "path/to/file.ext");

如果文件不存在,您需要创建它:file.createNewFile();。确保路径也存在;您可以通过调用file.getParentFile().mkdirs()来实现这一点,这将在根目录和路径中的最高级目录之间创建任意数量的目录。也就是说,使用前述示例,file.mkdirs()将创建一个名为“path”的文件夹,然后在其中再创建一个名为“to”的文件夹。请注意复数形式;file.mkdir()只会创建由File实例指定的单个目录。

获取文件的属性,比如大小或最后修改日期。

所以,现在您拥有了一个File实例的句柄,您可以从中读取数据,向其中写入数据,或检查其属性,比如大小或修改日期:

从文件中读取和写入数据。

当您立即考虑如何将String写入File时,这实际上不是 Java 和 Android 中的默认操作 —— 考虑到像图像、音频和视频这样的二进制文件,以及像 ZIP 和 TAR 这样的压缩文件。虽然有用于从File实例读取和写入Strings目录的 API,如FileReaderFileWriter,它们提供了简化的String读取和写入操作,但我们将集中在 Java 在读取和写入数据时的基本模式上:字节流。依赖于字节级数据的好处在于,相同的操作可以用于任何事物:写文本文件、流式传输多媒体、下载图像等。

不要感到害怕!您的程序中的每样东西都已经在某个层面上以字节的形式表示了,我们可以通过标准库中的现有 API 轻松地访问这些信息。例如,我们可以通过在String实例上调用getBytes()来从String中获取字节 —— 这一点很简单。

话虽如此,您可能会惊讶地发现,在java.io.File类上没有特定的writeread方法,事实上,读取和写入的过程需要另一个中介类:流(即InputStreamOutputStream的实例,或者适当的子类)。在 Java 中,流的概念简单地说就是一段可以按顺序读取的数据,从头到尾。这种方法的优点在于,我们不需要一次性将所有信息都存储在内存中。事实上,对象本身甚至可能没有明显的结束!例如,我们可能希望从实时源播放视频;使用流,我们只需按照它们的到达顺序读取字节,并根据需要缓冲或显示它们到我们的程序中。

但让我们回到我们的实际例子。我们将从写入文件开始。毫不意外,您需要一个FileOutputStream的实例来完成这项任务。您可以通过调用构造函数并传递您已经引用的File实例来获得FileOutputStream的实例:OutputStream outputStream = new FileOutputStream(file);FileOutputStreamwrite方法有几种不同的签名,但让我们继续使用到目前为止我们对话的模式中的一个:write(byte[] bytes)。只需将一个字节数组作为唯一参数传递,这些字节将被写入由FileOutputStream引用的File实例。

因此,如果我们想要向文件写入一些文本,我们可以这样做:

哇!您刚刚向文件写入了“Hello world!”当然,就像大多数 Java 和 Android 一样,这个相对简单的概念有一些陷阱。例如,FileOutputStream构造函数抛出一个FileNotFoundExceptionwrite方法抛出一个IOException。此外,我们必须确保在完成时关闭流,这本身会抛出一个IOException。虽然完整表达所有这些工作的块可能有点难以理解,但我们可以将其全部封装在一个带有检查异常的方法中,并减少一些代码行数:

您可以像这样使用它:

从文件中读取数据遵循非常类似的模式。正如你可能已经猜到的那样,我们将使用FileInputStream而不是FileOutputStream

再次,由于我们是按照二进制数据来思考的,我们需要做更多的工作才能将文件内容转换为人类可读的文本。如果您经常读写纯文本文件,有更简单的方法,如FileWriterFileReader,但如前所述,使用字节流是一种通用的解决方案,而将其转换为String则是微不足道的。

类似于FileOutputStreamFileInputStream构造函数也将接受一个File参数:InputStream inputStream = new FileInputStream(file);。一旦实例化了,您可以使用普通的read()签名读取单个字节,或者通过传递字节数组缓冲区来读取大块数据。现在我们将讨论单字节简单的read()方法,但请记住,使用缓冲区通常更高效,特别是对于大文件。

read方法返回一个int,它表示读取的字节数,或者返回-1 表示文件(和流)的结尾。您可以将其转换为char以构建文件内容的String表示:

// inputStream is a valid instance of a FileInputStream
StringBuilder builder = new StringBuilder();
int byte = inputStream.read();
while (byte != -1) {
  builder.append((char) byte);
  byte = inputStream.read();
}
String message = builder.toString();

再次,其中几种方法会抛出检查异常,而且我们再次需要在完成时关闭流,因此将所有内容封装在一个单独的检查方法中可能是有用的:

除了FileInputStream之外,还有其他流源,您可能考虑将转换流到字符串的方法抽象化,如下所示:

复制数据从一个文件到另一个文件。

您可以看到我们如何轻松地将这两个操作结合起来,复制任何文件,无论是简单的文本文件还是价值一千兆字节的视频!由于我们既不知道也不关心它是文本文件还是二进制文件,我们不必围绕将Character实例转换或从String中提取字节而进行任何复杂操作——通过保持我们的逻辑不可知,您可以看到这个操作实际上是最可读的之一:

警告

我们在这些示例中使用了InputStream.read方法以确保清晰。该方法每次调用返回一个字节。你可以通过将字节块写入通常称为“缓冲区”的大小字节数组来获得更好的性能。InputStream有用于这些缓冲区的read方法,OutputStream也有用于它们的write方法。

就是这样!现在您已经拥有在 Android 应用程序中读写文件所需的工具。务必查看开发文档中的FileAPI 以获取更多信息!

注意

Apache Commons Java 库有一个众所周知且备受推崇的文件和输入/输出模块apache.commons.io。特别有用的是IOUtilsFileUtils辅助类。

iOS

在 iOS 中,文件操作驱动了一些非常强大技术的基础。最终,在任何足够复杂的应用程序中,都会出现需要在文件系统上读写文件的用例。由于 iOS 的沙盒性质,有一些关于数据组织方式的先决知识是必要的,才能开始处理文件。

从文件中获取大小或上次修改日期等属性

实际上,文件访问允许的主要领域有两个:应用程序包容器和应用程序数据容器。让我们从应用程序包容器开始。

应用程序包

应用程序包包括应用程序二进制文件以及与应用程序一起编译和交付的所有资源。在安装时,该目录经过代码签名以防止篡改。因此,不可能更改应用程序包内的文件或写入该目录(后文详述)。

通过 Swift 中的Bundle类提供对应用程序包中文件的访问。一个应用程序可以通过框架拥有多个包,因此通过main类变量来定位应用程序包。为了访问名为image.png的文件,需要创建文件 URL,如下所示:

 let file = Bundle.main.url(forResource: "image", withExtension: "png")

如果该文件位于名为sample-images的子目录中,则变量将被实例化如下:

 let file =
   Bundle.main.url(forResource: "image", withExtension: "png", subdirectory:
   "sample-images")

数据(和文档)

从应用程序包中使用静态且不可更改的文件很快变得受限。最终,将会出现需要读取和写入用户生成的文档和数据的需求。为每个安装在 iOS 中的应用程序创建了一组三个目录:

  • 文档

  • 临时

每个目录都有特定的用途,如以下各节所述。

文档

用户生成的内容和需要用户访问的文件应存放在这里。这些文件默认会通过 iTunes 和 iCloud 进行备份。应用程序还可以启用文件共享,让用户直接与这些文件交互。

Library

在应用程序的Library文件夹中,通常有一些预定义的目录,用于放置额外的文件。其中两个最重要的目录是:Application SupportCaches。需要持久保存以备将来使用但不向用户显示的数据应存储在Application Support中。缓存数据应存储在Caches中。

tmp

这个目录用于写入和读取临时文件。应用程序有责任通过删除未使用的文件来清理此目录。但是,在应用程序不使用时,系统偶尔可能会清理此目录。

访问目录

几乎不可能直接构建指向应用程序数据容器内文件或目录的 URL,因为这些文件的路径复杂且不透明。这是苹果故意设计的,需要开发者使用称为FileManager的通用文件操作类。

例如,要访问应用程序的Documents目录中名为image.png的文件,可以构造如下的 URL:

let file = try? FileManager.default
  .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create:
  false).appendingPathComponent("image.png")

在模拟器上,访问此文件的直接路径是~/Library/Developer/CoreSimulator/Devices/CF5BCBA7-C7CA-4484-AB54-7BE938D67ECB/data/Containers/Data/Application/313B2DDD-ABDD-4D14-B6CD-85847F29EF2C/Documents/image.png

注意使用.documentDirectory枚举值来定位应用程序数据容器内特定目录的直接路径。使用FileManager已为您完成了查找所有父目录的工作。此类还提供了几个其他预定义键。例如,存储在应用程序的Application Support目录中名为data.json的平面文件的路径如下所示:

let jsonFile = try? FileManager.default
    .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor:
    nil, create: false).appendingPathComponent("data.json")

这里展示了如何创建临时目录中稍后访问的文件download.dat的路径:

let tempFile =
  try? FileManager.default.temporaryDirectory.appendingPathComponent("download.dat")

文件属性

现在已经介绍了应用程序文件结构的基础知识,让我们来看看文件操作本身。值得注意的是,在 Swift 标准库中,这些操作非常简单,通常只需要几行代码即可完成。

使用image.png作为应用程序主包中包含的图像的示例,可以直接在访问文件的URL对象上获取文件大小,如下所示:

let url = Bundle.main.url(forResource: "image", withExtension: "png")
if let resourceValues = try? url.resourceValues(forKeys: [.fileSizeKey]) {
	print(resourceValues.fileSize)
}

变量url是一个指向应用程序包中文件名为image且文件扩展名为png(即image.png)的URL对象。有一个在URL上的同步方法可以提取指定键的“资源值”(其他操作系统可能称为“文件属性”)。在这个例子中,我们提供了.fileSizeKey键,它对应文件在磁盘上的大小。

通过包含除文件大小键外的其他键,可以获取附加的文件属性。例如,要获取文件的最后修改日期,可以提供.contentModificationDateKey键,而不是.fileSizeKey。此外,您可以像这个示例中一样同时获取这两个属性:

let url = Bundle.main.url(forResource: "image", withExtension: "png")
if let resourceValues = try? url.resourceValues(forKeys: [.fileSizeKey,
. contentModificationDateKey]) {
	print(resourceValues.fileSize)
	print(resourceValues.contentModificationDate)
}

从文件读取和写入数据

Foundation 框架和 Swift 标准库中提供了几个对象的简单便利方法来读取和写入文件,尤其是StringData。例如,要从应用程序的Documents目录中读取名为file.txt的文本文件并将其转换为字符串对象,请使用以下代码:

let file =
  try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask,
  appropriateFor: nil, create: false).appendingPathComponent("file.txt")

// Read the file into a string
let contents = try? String(contentsOf: file, encoding: .utf8)

更新文件类似,需要调用write(to:atomically:encoding:),如下所示:

let file =
  try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask,
  appropriateFor: nil, create: false).appendingPathComponent("file.txt")

// Read the file from the Documents directory
var contents = try? String(contentsOf: file, encoding: .utf8)

...

// Write the string back to the same file
try? contents.write(to: file, atomically: false, encoding: .utf8)

Data对象并没有太大区别。这些对象旨在提供与 C 风格字节数组分离的原始字节数据访问。例如,假设您有一个图像文件的 URL;您可以将图像读入内存,并像此处所示将图像写入磁盘:

// Read the file's data into a Data object
var data = try? Data(contentsOf: imageFileUrl)

...

// Write the data back to the same file
try? data?.write(to: imageFileUrl)

所有的道路都通向文件管理器。

最终,在编写 iOS 应用程序和执行文件操作的过程中,将需要更复杂的操作。在这些情况下,有一个名为FileManager的多用途类可提供一个共享的、线程安全的实例,非常适用于复杂的文件操作。

使用FileManager读取相同的file.txt文件需要一些额外的逻辑:

// Provide a file to read in the user's Documents directory
let file =
  try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask,
  appropriateFor: nil, create: false).appendingPathComponent("file.txt")

// Get the contents of the file as a Data object
if let contents = FileManager.default.contents(atPath: file.path) {
	// Create a String from the raw data
	let contentsString = String(data: contents, encoding: .utf8)!
	print(contentsString)
}

首先要注意的一点是使用file.path。这将 URL 对象转换为文件系统中文件的绝对路径的字符串表示;这是必需的,因为FileManager对 URL 的支持不完整。接下来,if let语句将文件的原始内容读入数据对象中。如果FileManager无法找到或访问所请求的文件,则提供了一些防止空值的安全性。最后,在本示例中,假设文件是可访问的,使用指定的编码(UTF-8)从数据对象中实例化字符串。

写入文件类似。以下是如何直接使用FileManager将字符串写入文件的示例:

let example = "I love tacos."

// Convert the string to a Data object
let exampleData = example.data(using: .utf8)

// Create the file using the preceding data object (and overwrite any existing files)
FileManager.default.createFile(
    atPath: sharedFile.path, contents: exampleData, attributes: nil) // returns a Bool

文件操作的成功与否通过createFile(atPath:contents:attributes:)返回布尔值 true 或 false 来表示。这与使用StringData直接使用的更现代的 Swift 操作符throws形成对比。

那么为什么在看起来不像是现代 API(它位于旧版 Foundation 框架而不是 Swift 标准库中)且稍微比DataString实例方法更繁琐时,还要使用FileManager呢?答案在于FileManager的预期用途。当需要完成与文件和目录的更复杂交互时,FileManager非常出色。FileManager擅长检查目录层次结构,提供有关文件存在性的反馈,删除文件,覆盖文件,更新文件属性等等。但在简单的数据读写方面则表现得不如其他 API。

将数据从一个文件复制到另一个文件

使用仅提供String方法复制文件将需要将文件读入内存中的String实例,然后使用write(to:atomically:encoding:)将该字符串写入到另一个文件中。这不是复制文件的最有效方式,对于超过设备可用内存量的较大文件可能不容易实现。

使用以下代码片段可以在FileManager中完成文件复制:

// Provide an original file location
let originalFile = try? FileManager.default
    .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
    .appendingPathComponent("file.txt")

// Provide a location where the copied file should go
let copiedFile = try? FileManager.default
    .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
    .appendingPathComponent("newFile.txt")

// Copy the file
try? FileManager.default.copyItem(at: originalFile, to: copiedfile)

与使用String的第一个例子相比,这要高效得多,因为FileManager在复制文件之前不需要将文件先读入内存。此外,FileManager利用了 Apple File System (APFS),这是苹果的专有文件系统,通过制作对象的克隆来进行高效的复制过程。

FileManager中还有更多等待发现的秘密。不过,这已足以让你开始 iOS 中的基本文件操作。请随时查阅苹果提供的文档,了解所有可能的操作。

URL 与字符串的比较

本章中的代码示例中,使用 URL 来提供文件路径信息,而不是String对象。这是因为 URL 对象通常更适合内部高效地存储路径信息。类似地,它们在通过添加目录、更改名称等方式改变文件路径表示时通常更高效。

一些由FileManager和其他基于 Foundation 的 API 提供的方法使用字符串而不是 URL。使用 URL 对象的路径变量可以将其转换为字符串路径。例如:

let fileURL = Bundle.main.url(forResource: "file", withExtension: "txt")!
let filePath = fileURL.path

iCloud 和 iTunes 备份

我们尚未涵盖的一件事是 iCloud 文件同步。iCloud 中的文件与用户Documents目录中的普通文件类似;然而,使用不同的容器来直接定位文件。用户DocumentsApplication Support目录中的所有文件都会自动备份到 iCloud 和 iTunes 中。

可以并且建议从 iTunes 备份和 iCloud 同步中排除某些文件。例如,可以通过以下代码直接从 URL 设置文件的isExcludedFromBackupKey来排除未来可以重新下载的来自网络服务的大型视频文件:

var values = URLResourceValues()
values.isExcludedFromBackup = true
try? fileUrl.setResourceValues(values)

我们学到了什么

正如你可能已经注意到的,文件在 Android 和 iOS 之间是一个有趣的交集。它们同时又是截然不同但又根本相同的。归根结底,我们处理的是比特和字节,但数据访问的方式代表了两种非常不同的架构范式。

在 Android 中,使用流来读写数据与 iOS 更加程序化和内置的方法形成鲜明对比。此外,iOS 中还残留着 UNIX 的痕迹,贯穿其整个文件结构。

希望这两种方法的概述为你提供了一个起点,让你开始理解如何处理两个平台的文件系统操作。在下一章中,我们将讨论如何将数据持久化超越文件系统,进入数据库和对象图的领域。

第七章:持久性

数据的持久性允许应用程序形成一个看似是内存的东西。在移动软件开发中有许多实现这一点的方法,但最常见的是通过使用关系数据库。从一开始,Android 和 iOS 就具有连接、读取和写入数据库的能力。

这样做的结果是,用户在应用程序中的会话不再是短暂的存在,而是与时间点相关联的存在。数据可以被存储,信息可以被保存,状态可以被恢复。通过非常不同的架构和方法,Android 和 iOS 都具有一组共享的功能,以一种使其足够相似以便进行并行讨论的方式来驱动它们。

任务

在本章中,您将学习:

  1. 建立数据库连接。

  2. 创建数据库表或持久对象。

  3. 将数据写入该表或持久对象。

  4. 从该表或持久对象中读取数据。

Android

在 Android 中,框架提供的数据库管理系统是 SQLite。确切的版本取决于 Android 操作系统的级别,但在撰写本文时,它的版本范围从 Android 1 的 3.4 到 Android 27 的 3.19。查看Android 的开发者文档获取最新信息。

SQLite 是一种基于 SQL 的关系型数据库管理系统(DBMS),非常类似于 MySQL 和 PostgreSQL。在数据类型、函数和实现细节(如ALTER TABLE)方面存在差异,但它们仍然非常相似,如果您有任何 SQL 数据库的经验,您将很快熟悉 SQLite。

建立数据库连接。

在 Android 中(但不是传统的 Java),您将使用的主要类是SqliteOpenHelper,但它是abstract的,因此必须是一个子类。这里是一个简化的示例:

onUpgrade方法在更改数据库架构时添加或迁移数据非常有用。当您的数据库版本号(助手构造函数的第四个参数)发生变化时,此方法仅运行一次。实际上,我们应该说“增加”而不是“改变”,因为在设备上运行低于存在的数据库版本将立即崩溃。

请注意,约定是使用公共常量作为表和列名称;这将使在其他类中从表中读取或写入数据变得更容易。

要从此帮助类获取数据库实例,我们调用getWritableDatabasegetReadableDatabase方法,取决于我们的需求。可写的执行所有可读的操作,因此如果您计划进行读取和写入操作,请使用前者。唯一需要可读版本的情况是确保在执行操作时底层数据不会发生更改。

这是如何从您的帮助类中获取可写数据库实例的方法:

从那时起,你就可以访问一个 SqliteDatabase 实例,它具有用于常见操作如查询和插入的 API,以及用于执行任意 SQL 字符串的更广泛的 API,如 execSql

创建数据库表或持久对象

在子类中,你将有机会重写 onCreateonUpgrade 方法;现在让我们专注于前者,它将在帮助程序第一次创建时触发一次(一般来说,这将在帮助程序第一次实例化时发生,并且是一个持久值,因此这将在应用程序第一次安装后不久发生)。后续启动不会触发 onCreate

onCreate 是创建初始模式的绝佳机会。例如,假设我们想要创建一个只有一个表(暂时)USAGE_EVENTS 的数据库,其中包含一个标识符、一个事件名称和事件发生时的时间戳。我们将使用标准 SQL 在我们的帮助程序的 onCreate 方法中创建数据库;让我们扩展之前显示的简单示例,但实际上我们只是添加一些 String 常量来组成一个 SQL create table 语句并在 onCreate 中执行它:

将数据写入该表或持久对象

框架提供了 SQLiteDatabase API,正如我们已经看到的,它完全能够执行任意的 SQL 命令,但也提供了用于 CRUD 函数的方法,包括插入。此外,API 支持数据库事务,我们可以利用它来额外控制数据库写入。在以下简单示例中,可能看起来事务对我们没有太多帮助,但实际上它将捕获错误并给我们一个机会来对其做出反应;此外,也许更关键的是,如果插入操作失败——或者如果我们在 try 块中添加更多失败的数据库操作——事务将 不会 被设置为成功,并且会悄悄地回滚 try 部分的所有语句块。

尽管这个示例看起来很简单,但它展示了你可能想要用于任何或所有数据库事务的模式——只需在 try 块中添加更多操作,并在 catch 块中执行任何额外操作。(记住,抛出一个 Exception 将导致事务隐式回滚;没有显式的“回滚”方法。)这种模式适用于更复杂的修改操作:

从该表或持久对象中读取数据

SqliteDatabase 方法 query 是从数据库中获取信息的最直接方式。话虽如此,你可能会想要使用的签名需要八个参数!让我们假装一切都很好,直接开始吧。

这是最基本的版本(只有七个参数!)。这将从表中获取所有内容——所有行填充了所有列——并返回一个 Cursor 实例来浏览该数据集。这实际上是 "SELECT * FROM EVENTS"

让我们来看一个更现实的查询:

在大多数 SQL 语言中,上述内容实际上是SELECT ID FROM EVENTS WHERE NAME = \"Request Data\" LIMIT 1。占位符是一个常见的概念,可以帮助保持一致性,并提供一些防止 SQL 注入的识别。

现在我们成功执行了查询,那么我们的数据在哪里?它在一个内存数据集中,我们可以使用Cursor API 访问;query方法返回一个Cursor实例。这里是一个例子:

您可以看到我们使用游标通过其索引访问每一列,但也有一个Cursor方法通过名称查找该索引的getColumnByIndex,因此您可能会这样做:

就是这样!还有大量其他可用的 API(和第三方对象关系映射器[ORM]),以及您可以使用原始 SQL 做的事情几乎是无限的。如果您正在开发一个经常使用数据库读取的应用程序,您应该查看compileStatement方法,该方法编译一个 SQL 字符串,并允许您在每次调用时重新定义值。这种方法非常快速,可以在处理大型数据集时真正提高感知性能。

在撰写本文时,Google 建议我们使用 Room(来自Android 开发文档):

尽管这些 API(SQLiteDatabase 等)功能强大,但它们相当低级,并且需要大量的时间和精力来使用:

  • 没有原始 SQL 查询的编译时验证。随着数据图的变化,您需要手动更新受影响的 SQL 查询。这个过程可能耗时且容易出错。
  • 您需要使用大量样板代码在 SQL 查询和数据对象之间进行转换。

出于这些原因,我们强烈建议使用 Room 持久性库作为访问应用程序 SQLite 数据库中信息的抽象层。

为什么选择 SQLite?为什么不用 Room?为什么不用 realm?为什么不用<插入我喜欢的 ORM 名称>?

我们(作者)之所以选择 SQLite 作为我们的工作示例,有几个原因:首先,与 Android 生态系统中的其他模式和工具集一样,这比 Room 早了一段距离。其次,SQL 是开发人员从几乎任何语言都可能具有一些了解并能够在平台之间传递的东西。

iOS

iOS 和 Android 之间数据持久性的主要区别与所使用的技术有关。在 Android 中,如您所见,SQLite 数据库是一种常见的方法,有直接与数据库进行交互的类和对象,通过数据库连接和 SQL。而 iOS 则将数据库层技术抽象化,有一个用于数据持久性的第一方对象图称为 Core Data。虽然 Core Data 可以使用 SQLite 数据库(通常是这样)来持久化数据,但它可以完全在内存中作为临时存储,或输出到 XML 文件。然后,数据库成为开发人员可以大部分忽略的实现细节。

设置并连接到持久性层

尽管开发人员应该忘记 Core Data 对象图与原始 SQLite 数据库之间的差异,但两者之间存在等价物,我们将利用这些等价物来指导本章关于数据持久化的内容,以与 Android 进行比较和对比。建立数据库连接 并不 是您需要使用 Core Data 进行的操作;原始的 SQL 查询和连接由 Core Data 自身处理。然而,设置的一个等效部分是启动 Core Data 的“堆栈”。

设置 Core Data 堆栈

在新项目或现有项目中开始使用 Core Data 并不太困难,但首先需要明确 Core Data 到底是什么。让我们从持久化层开始。对于我们的目的,我们假设我们处理的是一个以 SQLite 数据库为根的 Core Data 堆栈。这可以说是 Core Data 开发中最常见的方法。

在 SQLite 数据库和持久存储之上是一个称为 NSPersistentStoreCoordinator 的对象。此对象处理数据库、托管对象模型和托管对象上下文之间的通信。它使用定义的托管对象模型,将这些对象和关系转换为数据库表和 SQL。它还优化了大部分交付的对象,并且真正是内置于 Core Data 中的许多魔法和优化背后的“大脑”。您可以将其视为 SQLite 和应用程序其余部分之间的交通协调员。

每个项目都使用一个 .xcdatamodeld 文件定义托管对象模型,或者 NSManagedObjectModel。这是持久化存储协调器用于确定所有结构的方式。开发人员使用此文件添加新的实体、关系和属性;这本质上是创建和定义项目中的数据模型的方式。作为其一部分创建的对象称为“托管对象”,它们继承自 NSManagedObject,这是 Core Data 框架的一部分。在此级别及我们需要讨论的下一个类别中,大多数与维护和处理 Core Data 相关的工作都是在此完成:托管对象上下文。

您可以将托管对象上下文,或者 NSManagedObjectContext,看作是一种草稿本,您可以在其中更改数据模型对象实例中包含的数据,然后将其持久化到数据库中。托管对象上下文是您将用于为 Core Data 提供对象创建和更新上下文的对象。在 Core Data 应用程序中通常有两种类型的上下文,视图上下文(在主线程上运行)和后台或私有上下文,用于保存对象,有效地将它们从 NSManagedObject 实例翻译成数据库表行 INSERTUPDATE 命令。

为了使用核心数据,这个堆栈必须在需要持久性之前设置并正常运行。通常,这意味着当应用程序启动时。事实上,大多数设置都是在应用程序委托内的application(_:didFinishLaunchingWithOptions:)方法中完成的。

所有这些设置过去都很繁琐,但现在核心数据有一个方便的类来初始化我们的核心数据堆栈,称为NSPersistentContainer。持久容器处理了为托管对象模型文件所需的初始化,并具有一个方便的方法,可以异步加载持久存储。以下是它的使用示例:

let persistentContainer = NSPersistentContainer(name: "MyModel")
persistentContainer.loadPersistentStores { (description, error) in
    // Completion handler
}

这将创建一个针对名为“MyModel”的托管对象模型的容器,并将其存储在名为persistentContainer的变量中。下一行加载持久存储,最终在我们的示例中是 SQLite 数据库,从磁盘加载,并使用完成处理程序检查任何错误并在之后执行代码。如果将此代码放在应用程序委托的启动代码中,应用程序将在应用程序包中查找名为MyModel.xcdatamodeld的托管对象模型文件,并使用它来创建或加载名为MyModel.sqlite的 SQLite 数据库文件,存储在Application Support目录中。

注意

您可以自定义数据库文件存放的位置。历史上文档目录一直被用作存储 SQLite 数据库的地方。您的需求会有所不同,但您可以在创建NSPersistentContainer并将其添加到persistentStoreDescriptions后添加NSPersistentStoreDescription来实现这一点。还有其他选项可用于描述持久容器,但这些选项超出了本章的范围。

在完成闭包内部,您应该检查错误,然后更新任何可能正在等待持久存储加载的用户界面。一个更完整的持久容器加载示例可能如下所示:

let persistentContainer = NSPersistentContainer(name: "MyModel")
persistentContainer.loadPersistentStores { (description, error) in
    guard let error = error else {
        // Display error to user and/or attempt to do this again
        return
    }

    // Update the user interface and/or start using Core Data
}

现在我们的核心数据堆栈已经运行起来了,让我们看看如何定义我们的对象。

定义并创建数据库表或持久对象

我们的模型对象是在 Xcode 中创建的托管对象模型文件中定义的。要添加到现有项目中,请转到文件 > 新建 > 文件,并在核心数据部分下选择数据模型。文件的名称是您在初始化期间将传递给持久容器的名称。

在 Xcode 中,您可以通过单击“添加实体”按钮来编辑此文件,以创建一个新的托管对象。在实体描述中,将有属性、关系和获取的属性的区域。您可以向实体添加单个属性,更改实体的名称,并在此编辑器内为此实体分配一个支持类。

个别属性被添加为属性。这些对应于在托管对象模型中为每个实体生成(或手动提供)的原始数据类型,如 Swift 类中的StringInt。重要的是要记住,托管对象模型仅定义实体的描述,实际上根本不与数据库交互。它只是提供了从持久化存储协调器可以理解的 Swift 代码的映射。

要添加新属性,请单击编辑器的属性区域下面的“+”按钮。如果你要在名为MyEntity的实体上添加一个名为title且类型为String的新属性,Xcode 在编译期间会为你自动生成一个隐藏在 Xcode 项目中的类。该类可能看起来像这样:

import Foundation
import CoreData

@objc(MyEntity)
public class MyEntity: NSManagedObject {

}

extension MyEntity {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<MyEntity> {
        return NSFetchRequest<MyEntity>(entityName: "MyEntity")
    }

    @NSManaged public var title: String?

}

类本身继承自NSManagedObject,与我们的实体描述MyEntity同名。但实际上,它可以是任何我们想要的名字。这是一个重要的区别:支持模型对象的类可以与实体描述分开命名。还要注意,@NSManaged属性表示 Core Data 管理该属性的存储。这意味着这不是一个普通的存储属性。事实上,每当写入属性时,它实际上是由setValue:支持的。

这种灵活性使我们能够向托管对象添加不写入数据库的属性。从根本上讲,这意味着我们可以做一些事情,比如为根本不必与数据库交互的托管对象添加作为便捷属性的计算属性。例如,我们可以定义一个名为Person的托管对象,它在 Core Data 中有firstNamelastName属性,但提供了一个fullName属性,它只是组合了这些属性,而不在数据库中重复数据:

public class Person: NSManagedObject {
	@NSManaged public var firstName: String
	@NSManaged public var lastName: String
	public var fullName: String {
		return "\(firstName) \(lastName)"
	}
}

以我们的Person托管对象为例(在托管对象模型中创建了相应的实体描述之后),让我们继续看看如何持久化一些数据。

写入和持久化数据到 SQLite

关于在 Core Data 中写入数据的第一点非常重要的事情是,你不希望在主线程上进行写入。事实上,我们的持久化容器中实际上有一个方便的方法,让我们可以快速轻松地切换到后台线程来写入一些数据,如下所示:

persistentContainer.performBackgroundTask { (managedObjectContext) in
	// Some operation...
}

现在,正在执行的操作可能是读取。它也可能是写入。我们现在将专注于写入这一方面。实际上,让我们看看我们之前的Person托管对象,看看我们如何创建一个新对象并将其添加到数据存储中。

在 Core Data 中创建和更新对象时,所有操作首先在托管对象上下文中进行。我们可以在这个上下文中随意修改对象,而不会影响数据库,直到我们明确保存更改为止。因此,我们还需要一个上下文来创建新对象。以下是如何创建一个新的Person实例并为其分配一些属性的示例:

persistentContainer.performBackgroundTask { (managedObjectContext) in
    let person = Person(context: managedObjectContext)
    person.firstName = "Mike"
    person.lastName = "Dunn"
}

实际上,这很简单。我们在传递给我们的闭包的托管对象上下文中创建了一个名为person的新Person。然后,我们为firstNamelastName赋值。

然而,这个对象还没有被持久化。事实上,这个对象将存在,直到闭包完成,然后它将与托管对象上下文一起被销毁。幸运的是,持久化数据也并不困难。只需一行代码,加上一些错误检查的dotry块,就像这样:

persistentContainer.performBackgroundTask { (managedObjectContext) in
    let person = Person(context: managedObjectContext)
    person.firstName = "Mike"
    person.lastName = "Dunn"

    // Save the context
    do {
        try managedObjectContext.save()
    } catch {
        print("Error during save. \(error)")
    }
}

你会注意到,我们只保存上下文,而不是对象本身。这个操作保存了所有在该上下文中的更改。所以,如果我们创建了一百万个Person,它们都会同时保存。如果我们创建了不同的对象类型,并在其他对象中更改了属性,它们也将同时持久化。保存整个上下文而不是单个对象可能会导致性能问题。因此,建议使用大数据集进行分析和测试,以防止出现这类问题。

要更新一个对象,你只需在托管对象上下文中获取它,更新所需的属性,然后保存上下文即可。让我们看看如何从已保存的 SQLite 存储中获取和读取数据。

从 SQLite 读取数据

尽管本节标题称“从 SQLite 读取数据”,但实际上我们将通过托管对象上下文读取数据。如果选择*返回到 SQLite 存储,它将联系持久化存储协调器寻求帮助。如我们稍后展示的使用NSFetchRequest将导致访问持久存储,可能会成为一个非常缓慢的路径。然而,托管对象上下文通常在内存中缓存了大量数据以提升性能,并且在获取后获取的数据可以像普通对象一样被利用和操作。

要查找一个对象,我们必须创建一个获取请求,并像这样对托管对象上下文执行它:

let managedObjectContext = persistentContainer.viewContext
let fetchRequest = NSFetchRequest<Person>(entityName: "Person")
do {
	let persons = try managedObjectContext.fetch(fetchRequest)
} catch {
	fatalError("Fetch could not be completed.")
}

让我们一起看看这里发生了什么。

首先,我们通过viewContext属性从持久化容器中获取一个只读的托管对象上下文。这允许我们查看对象,但不能保存更改。接下来,我们为托管对象模型中名为Person的实体创建一个类型为Person的获取请求。然后,我们使用该获取请求从我们的托管对象上下文中进行fetch(_:)操作。这个调用可能会throw异常,因此它被包裹在一个docatch块中。如果无法完成获取操作,我们将抛出一个致命错误,错误消息为“无法完成获取操作”。

此时,我们应该有一个填充了的persons对象,我们可以用它来迭代读取我们已经持久化的所有人的列表。

Core Data 还有很多功能。它是每个 iOS 开发者工具包中非常强大的工具,可以用来创建性能优化的对象图,以便轻松持久化数据。它的最大缺点包括初始时较为冗长的文本和大量的设置工作。然而,项目开始时的一些准备工作可能会避免您编写复杂且脆弱的 SQL 语句,而是获得一些属性名称和值的编译时检查。

提示

当为表视图获取结果时,可以使用名为NSFetchedResultsController的对象,它接受结果和一个NSFetchRequest,并在数据变化时自动更新表格。这使得与 Core Data 的交互更容易,例如对于UITableViewUICollectionView

我们学到了什么

Android 和 iOS 在许多方面都很相似,正如您在本书中已经看到并将继续看到的那样。然而,本章确实表明,有时它们可能大相径庭。

  • Android 的原始 SQLite 交互方法提供了在 iOS 上使用 Core Data 时所不具备的架构简单性,允许开发人员利用他们可能已经具备的服务器端语言知识跳入其中。

  • Core Data 不是一个数据库。它是一个复杂的对象图框架,非常强大,并且在某种程度上与 SQLite 本身解耦,试图隐藏其复杂性。

  • 这两种方法都允许数据的持久化,并且在应用的未来版本中需要进行充分的规划和维护。

一旦开始讨论对象持久化,下一个逻辑步骤就是并发,这恰好是下一章要讨论的内容。让我们排队并立即前往那里!

第八章:并发性(多线程)

并发性,有时也称为并行性或多线程,是同时执行多个任务的概念,将计算机资源划分为不同的实体,这些实体非常快地在彼此之间交替,直到任何实体完成其整个工作负载,此时它将从此过程中移除。计算在不同进程(小写 p)之间跳转的行为被称为“上下文切换”,在计算机科学中具有更常见且非常不同的定义(将你的大脑资源从一个领域切换到另一个领域)。

任务

在本章中,您将学习:

  1. 在后台线程执行任务。

  2. 在主线程上对后台线程执行的工作结果进行操作。

Android

在 Java 中,处理上下文被称为 ThreadThread 是一个对象实例,并且被创建正如你所期望的那样:

一个 Thread 实例被传递给一个 Runnable 实例。一旦线程启动(通过调用 start 方法),线程将调用该 Runnablerun 方法。一旦该方法完成(通过返回或抛出异常),线程终止。

在 Android 中,默认情况下有一个 Thread 实例,所有工作都在其上执行,包括并尤其是 UI 工作。这非常重要——事实上,这个概念有时被称为“UI 线程”,但更常被称为“主”线程。从 Android 首次发布到本文撰写时,主线程每秒绘制屏幕 60 次——大约每 16 毫秒一次,并称为“帧”。这是一个极其消耗资源的过程,我们绝对必须允许系统尽可能地获得足够的资源来完成这项工作。因此,极其消耗计算资源的任务应该在“非”主线程上完成(意味着在任何其他线程上;像这样一个负责 UI 或程序原始运行的线程有时被称为“后台线程”)。任何 I/O 工作,如文件系统访问或数据库事务,都应该在后台线程中完成,而像网络请求这样的长时间操作也应该在主线程之外完成。事实上,如果在主线程上执行网络请求等明显的操作,您可能会看到错误或警告。此外,当主线程执行过多工作时,您会在 UI 中看到“卡顿”。例如,RecyclerView 可能会不均匀滚动,或者屏幕可能会冻结一段时间。Android Studio 很友好地在 Logcat 中警告我们;您经常会看到如下消息:

I/Choreographer(1234): 跳过了 20 帧!应用程序可能在其主线程上执行了过多的工作。

这意味着系统尝试二十次绘制您的 UI,但由于缺乏必要的资源而无法执行。

在后台线程执行任务

那么我们如何在主线程之外进行工作呢?实际上很简单。正如我们所描述的,创建一个新的Thread实例,在传递给构造函数的Runnable实例的run方法中执行一些工作,并通过调用start来启动它:

就是这样!还有一些帮助管理Thread的类,包括ThreadPoolExecutorExecutors静态帮助函数(通常返回预配置的ThreadPoolExecutor实例)。线程池的好处在于你可以精确控制在任何时间后台发生的工作量,并且可以在任务请求和完成时排队和删除任务。查看ThreadPoolExecutorExecutors的开发者文档获取更多信息。

你可能听说过并发是计算机科学中最难掌握的东西之一,我们不会假装这里没有一些非常复杂的概念存在,但大多数情况下这些概念都是在幕后由系统提供的,或者可以轻松避免的。迈克曾经说过,“线程很容易;同步很难。” 这意味着,正如我们所看到的,通过后台线程启动一些工作是相当简单的,但在这个陈述中有大约 3500 个星号附加(请原谅这种夸张)。例如,在 Android 框架中,除了在主线程上,你不能访问任何View实例。这是一件大事!在前面示例中传递的Runnable.run方法中,你甚至不能引用 View 实例,否则会抛出RuntimeException。此外,你无法保证多个后台线程的解决顺序;这就是我们开始深入讨论并发问题的地方——你的 Java 生成的字节码并不总是完全像你编写的 Java 代码一样。

典型的“不安全”线程示例可能如下:

根据你的硬件和环境,确切的并发任务数量可能需要更高(或更低)以看到效果,但玩弄基本任务,迟早会看到一个不是 9,999 的最终日志。虽然没有花费大量时间在线程安全性的细节上,但我们知道递增运算符的字节码在转换为字节码时可能看起来像这样:

  1. 从内存中获取mCounter的值。

  2. mCounter的值加 1。

  3. mCounter的值写回内存。

由于并发线程快速切换以模拟同时操作(这称为“上下文切换”),线程 #391 可能在线程 #390 执行步骤 1 和 2 之间读取值。这个具体的例子被称为竞态条件,这个问题通常被称为线程干扰或线程交错,并且是线程安全缺失的一个例子。这远非对并发编程危险的全面考察 - Java 的一些实现允许单个线程复制变量以避免昂贵的 IPC(进程间通信)调用,因此当多个线程变异单个对象实例时,状态不能被保证。

有一些机制可以避免这种情况,例如 synchronizedvolatile 关键字,以及 Atomic 类,由于数据结构特别容易受到影响(想象一个线程在循环遍历数据结构时,另一个线程在添加或删除项目),因此存在多种线程安全(但性能较低)版本的几种数据结构或辅助方法,以减轻这种威胁。

话虽如此,在本章的范围之外,这是一个良好的多线程编程的领域。即使在单个技术栈中,掌握多线程编程可能需要多年时间,有些开发人员甚至永远无法真正理解底层发生了什么 - 在许多情况下,这是完全合适的!出色的 UI 程序员可能会以非常不同的方式使用并发编程,与处理大数据的程序员完全不同。在 Android 框架中的一个简单技巧是在后台线程上执行计算,然后将这些结果通过 Activity.runOnUiThreadView.postHandler.post 发送回主线程,从而确保串行执行并免于其他进程的竞争。

在主线程上执行后台线程中执行的工作结果

正如刚才提到的,每当您更新用户界面以反映某些后台线程操作的结果时(这是非常常见的情况 - 您可能需要从本地数据库、远程服务器、文件系统等获取数据),您需要在引用任何 View 实例之前返回到主/UI 线程。甚至在 View 类之外,更新常见对象到单个线程(主线程)上也很有帮助,以避免前面描述的同步问题。

有三种主要方法可以从后台线程发送消息到主线程:

  1. View.post 方法将接受一个 Runnable 实例,并在主线程上调用该实例的 run 方法。

  2. Activity.runOnUiThread 函数几乎与 View.post 函数相同,但首先会检查调用是否已经在主线程上进行 - 如果是,则会立即调用 run 方法,而不是将其发布到消息队列的末尾(请参见下面的代码)。

  3. Handler实例将向其关联的Looperpost消息,因此使用new Handler(Looper.getMainLooper())创建的Handler将在主线程上发布消息。同样,该方法接受一个Runnable实例,并调用该实例的run方法。请注意,除非直接从 UI 线程调用Activity.runOnUiThread,否则这两个前述操作实际上都会在此处结束。

下面是一个简单的示例:

终止线程

再次强调,这是一个有争议的话题,也许缺乏共识。通常,线程将在完成其工作(当Runnablerun方法通常返回时)时终止。然而,经常希望过早停止后台线程。例如,如果用户开始下载一个可能有几百兆字节的大视频剪辑,几分钟后可能会决定不值得占用带宽或空间并终止下载。

如果您在自己的线程中执行相同的过程并使用某些第三方下载器库,其中有一个原子的download方法,将无法中断:

Thread类还有一个被废弃的方法叫做stop,但不建议使用,将来可能会从标准库中删除。

join方法经常吸引那些学习Thread API 的人,但它常常被误解。截至本文撰写时,Java 8 文档解释了join方法如下:

这个实现使用了一个循环的this.wait调用,条件是this.isAlive。当线程终止时,将调用this.notifyAll方法。建议应用程序不要在Thread实例上使用waitnotifynotifyAll

这可能会看起来像线程功能终止了,特别是在处理“主”线程或 UI 线程时,但实际上它仍然在内存中,并继续保持所有引用可达,直到调用该方法的线程本身终止。因此,在使用 UI 线程作为默认上下文的 Android 中,如果启动一个“后台”线程并对其调用join,它会存在,但只是在主线程终止(通常是应用程序的结尾或资源回收)之前继续旋转。由于这通常涉及整个进程的崩溃,您可能甚至注意不到后台线程,但要知道它确实还在那里,保持引用(内存泄漏)并旋转直到程序本身退出。

还有另一个非常被误解的Thread类方法叫做interrupt,但这并不是大多数人所认为的。它只是在线程上设置一个布尔标志;在调用interrupt之前,调用isInterrupted将返回false。在调用interrupt之后,调用将返回true仅仅通过interrupt无法停止Thread的操作

这个 API 几乎是按照一个约定来创建的;库的编写者应定期检查isInterrupted,并在其为真时throw一个Exception或立即返回,但这并不是任何方式的保证。我们希望Downloader库的作者这样做了,并且这样做是有意义的,但我们不能确定,这可能意味着您需要自己处理这些复杂的任务。例如,再次以下载为例,您可能希望在每次InputStream.read/OutputStream.write循环中检查isInterrupted。例如,让我们参考 Networking 中的简单下载方法,并修改以支持取消:

在执行downloadBinaryData操作的try/catch块中,您可能希望删除部分写入的文件,并可能更新 UI 以隐藏进度或向用户显示状态。您可能还希望有一个cancel方法,该方法会在后台线程上调用Thread.interrupt()。类似这样(修改为适用于 Android 并稍作修饰)可能会起到作用:

RxJava库提供了一套强大的 API 来处理并发工作,通过抽象层分离开发代码与低级线程不安全性的风险。这可以是一个很好的工具,帮助您的团队快速掌握,并共享一个通用的代码库,非常适合许多人。不过,与大多数抽象一样,您将在标准库类(如Thread)中找到最精细的控制。

Android 框架中的AsyncTask类提供了一种在后台执行工作并将结果返回到主线程的方式。然而,需要注意的是,对该类存在有效的批评意见。首先,最早的版本使用了ThreadPoolExecutor中的多个线程,但后来改为使用单个线程,而后又在后续版本中进行了反转。同样地,在AsyncTask的工作方法中无法中断任务的情况下,也没有办法中断任务。社区普遍认为,AsyncTask并没有提供足够的价值来弥补其缺点——作者们认为,在几乎所有情况下,前面描述的简单Threadpost方式更加简单、可靠和可控。

此章节中涉及的许多高级主题在安德斯·戈兰松(O’Reilly)的《高效 Android 多线程》中有详细介绍。当然,约书亚·布洛赫(O’Reilly)的《高效 Java》对各种专业水平的程序员都很有帮助。

iOS

iOS 中的并发最好可以描述为根植于 C 语言的古典时代,同时通过 Grand Central Dispatch 的巧妙机制进行现代化。在应用程序中,开发者可能会看到主要有三种选项来在后台完成工作:DispatchQueueOperationThread

有时可以同时使用两者,但在本章节中,我们将专注于 Grand Central Dispatch 和DispatchQueue;尽管它们在 iOS 中略显低级,但它们由于简单和易于创建而被广泛使用。

在后台线程执行任务

那么在 iOS 中如何在后台执行工作呢?以下是一个简单的例子:

DispatchQueue.global().async {
    print("Do something")
}

第一行访问了 Grand Central Dispatch 中的全局后台工作队列,或者“GCD”。我们取得这个全局队列并在一个传入的闭包上调用async。第二行是该闭包的主体,仅在我们的简单例子中print了一行文本。

在 GCD 中完成操作有两个选项:异步和同步。同步调用可以使用sync()方法而不是async(),它们保证按照调用顺序执行。异步调用将在未来某个时候执行,对其具体时间几乎没有可见性。

说实话,前面的代码对大多数调用已足够,但通常需要对特定后台操作的优先级有更多控制。这可以通过类似以下的代码实现:

DispatchQueue.global(qos: .userInitiated).async {
    print("Do something")
}

新的参数被传递给之前的global()方法,命名为qos。这个参数是一个枚举,映射到 iOS 内的一组执行优先级。可用的不同选项包括:

userInteractive

最高优先级,用于与用户交互的操作。这里运行缓慢的操作会导致用户界面冻结。

userInitiated

优先级略低,并且比userInteractive更常用于后台工作。官方上,这是用户启动的操作,需要快速得到结果。

default

执行没有服务质量(QoS)的工作的默认级别。不建议开发者直接指定它。

utility

低于default的优先级,用于某些仍面向用户的实用类型操作,比如在应用程序中下载书籍或视频。

background

最低优先级的操作。这项工作对用户不可见。

unspecified

系统意图推断 QoS 应该是什么。很可能,你不应该使用这个。

注意

这些质量级别也与性能和能效水平相关联。在userInteractive级别执行的工作性能更高,但能效较低。在background服务级别执行的工作性能不如前者,但更节能。

在我们之前的例子中,我们传入了userInitiated,表示操作应该在全局队列中以较高优先级在主线程外执行。

质量服务规则是定制全局调度队列可用的唯一方式之一。但是,还有另一种定制级别:

let queue = DispatchQueue(
    label: "com.oreilly.nativeappdevelopment", qos: .background, attributes: [.concurrent])
queue.async {
    print("Do something")
}

在此示例中,我们首先创建了自己的 DispatchQueue 实例。每个调度队列都需要一个唯一的标签。苹果建议默认使用反向 DNS 风格的标签,但只要提供的标签是唯一的,那就足够了。在我们的示例中,我们传入了一个 qos 值为 background,这意味着此队列中的操作将以最低优先级运行。

此外,我们已在 attributes 参数内传入了 .concurrent 选项。这样做意味着我们传递到队列中的每个操作或闭包都将并发运行。全局调度队列的默认值是 .concurrent。而自定义调度队列则相反,它们默认不会并发运行。

我们将这个新的调度队列存储在 queue 变量中。接下来的一行代码将使用 queue 对象调用与前面示例中相同的 async(_:) 方法,来执行一些代码。

在后台线程上完成的工作结果,在主线程上执行操作

现在,我们展示了如何在后台线程上执行操作。但有时您需要回到主线程执行一些重要的工作。在 iOS 上,每当 UI 需要更新时,通常会看到这种情况,因为所有 UI 工作都发生在主线程中。

在 iOS 上很容易返回到主线程:

DispatchQueue.main.async {
    // Update the UI
}

注意 DispatchQueue 上的 main 属性。这是一个共享队列,在运行循环中所有 UI 对象操作的地方。UI 的任何更新都需要像这样调用。例如,使用 URLSessionDataTask 进行网络调用后,可能需要向 UI 提供一些更新的信息。以下代码完成了这个任务:

let url = URL(string: "https://www.example.com")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
    // Handle errors, etc.

    guard let data = data, let string = String(data: data, encoding: .utf8) else {
        print("No data returned.")
        return
    }

    DispatchQueue.main.async {
		// Update UI
    }
}
task.resume()

不过,在不深入解释 URLSession 在 iOS 中的工作原理的情况下(请参阅第九章),请注意完成处理程序的主体中的 DispatchQueue.main.async(_:) 调用。这可以用于更新标签中的文本,更改 UI,或者只是为了简单的数据同步而跳转到主线程。

注意

取消 GCD 操作并不是一件容易的事情。通常建议,如果您需要可取消的操作,请切换到使用带有 OperationQueueOperation 对象。但这超出了本章的范围。可以查阅苹果关于 OperationOperationQueue 的开发者文档。

我们所学到的

并发操作可能因并发编程而变得异常复杂,远远超出了简短的示例代码的范围。由于并发编程而存在的错误类别。

  • Android 有几种方法可以运行并发操作,但 Thread 是其中最常用和标准的方法之一,用于控制正在运行的操作。

  • GCD 通过一个方便的调度库提供了一种调度方式,通过它,iOS(和 macOS)应用程序可以利用并发编程。

  • 在 Android 和 iOS 平台上,确保主线程不被阻塞是非常重要的。如果阻塞主线程,用户界面会出现卡顿或看起来像是锁死,这会给用户带来负面体验。

我们还发现在代码中有一些地方后台线程是非常有用的,比如网络代码。既然我们已经学会如何在应用程序的后台启动操作,那么在下一章中让我们更深入地了解 Android 和 iOS 中的网络通信。

第九章:网络

尽管自包含应用程序完全可能——也许它只是使用内置的、自动更新的 Play 更新基础设施进行 HTTP 请求来更新其版本——在我们相互连接的世界中却很少见。无论您需要对用户进行身份验证、将使用情况指标发布到分析帐户、读取内容或媒体、下载或上传文件、侦听推送通知,还是只需与时间服务器同步,您都需要知道如何发出 HTTP 请求并理解 HTTP 响应。

幸运的是,HTTP 规范具有非常简单的规则集,大多数人发现它易于理解和学习。冒着过于简化的风险,想象一下 HTTP 事务由两部分组成:请求和响应。两者都以简单(通常是人类可读的)文本文件表示。每个文件的第一行描述了关于文件的一些基本属性(例如请求的 URL 和响应的状态)。接下来的行是传统的键值对语法的头信息:header-name: Header Value。然后是一个“空白”(空)行,然后就是主体。就是这样!要了解更多信息,请查看“HTTP Made Really Easy”

从高级认证协议到在后台流式传输加密视频数据到上传手机上的每张图片,所有这些操作都使用了前面描述的相同基本原则。

任务

在本章中,您将学习:

  1. 读取并从远程服务器打印文本文件。

  2. 发出 HTTP POST 请求。

  3. 下载二进制文件。

Android

借助 Android 开发的开放特性,一些非常出色的第三方库已经涌现,围绕着像网络这样潜在复杂的任务。其中最受欢迎的可能是 OkHttp,由著名且值得信赖的 Android 开发者 Jake Wharton 开发,他在 Square 公司工作时编写了大量优秀的开源软件,并继续在 Google 直接贡献。如果您检查本 Android 部分末尾的注释块中的链接,您会发现大量链接来自 Square 的 GitHub 帐户。

记住,除了主存储器(RAM)以外的任何时候,你都应该考虑使用后台线程。读取本地文件、查询数据库,甚至保存偏好设置都应该在主 UI 线程之外进行。处理网络请求和响应时尤为重要。想象一下,一个小文件可能只需几毫秒就能触及、打开、读取、关闭并转换为String值,但即使这几毫秒也足以使RecyclerView的滑动或NavigationDrawer的打开显得有些卡顿。现在,想象一下进行网络请求的情况。我确信我们都见过需要 10 秒、20 秒甚至更长时间来处理的请求——有些服务器甚至不会在一分钟内超时!想象一下,在绘图线程上整个时间都在一个旋转的for循环中等待数据。几乎所有情况下,在执行网络操作时,都应该使用后台线程

在远程服务器上读取和打印文本文件

话虽如此,使用纯 Java 发起网络请求并不复杂——以下六行(仅计算语句)的神奇代码将打印出任何公共可访问的远程文件:

让我们逐行分解这个过程。

首先,我们需要一个URL实例。URL实例代表一个资源,而不是资源的位置,尽管名称可能会让人误解。位置最准确地由传递给URL构造函数的String表示。

通过将我们的操作从String升级到URL,我们可以在开箱即用的标准库中获得大量好用的功能——例如通过openConnection方法获取HttpUrlConnection。虽然连接可以做很多事情,但我们可以在接下来的几行中做的最简单的事情是——获取其InputStream并读取出来!这看起来很像我们在第六章中处理流数据的方式——可以查看以获取复习。

就是这样!相当简单,对吧?好吧,让我们稍微加工一下。

发起 HTTP POST 请求

我们将利用公开可用的jsonplaceholder.typicode.com免费服务来模拟 POST 数据,这样我们可以看到我们的代码确实发送了我们稍后可以读取的数据。我们将使用创建资源端点。

让我们用一个简单的空 POST 来修改我们的读取示例:

如果你仔细观察,你会发现只有一些非常细微的变化:

  1. 我们将openConnection的返回强制转换为HttpsUrlConnection,而不是HttpUrlConnection,因为该位置使用了 HTTPS 方案。

  2. 我们将连接的请求方法设置为 POST,而不是默认的 GET。

虽然这确实有效,并且您的输出应该给您创建的对象提供一个新的 ID,但没有太多其他的事情发生。在一个稍微接近真实生活的例子中,我们可能希望在请求中添加一些数据。这可能以查询参数、头信息或者 POST 主体的形式进行。对于这个服务,我们将使用 JSON POST 主体。

就像连接对象有一个内置的InputStream用于读取一样,它也方便地具有一个OutputStream用于写入。但是,这需要一些额外的管理 - 我们必须调用connection.setDoOutput(true)才能写入那个OutputStream

让我们再次更新例子:

现在我们正在发送一些数据,服务器可以根据需要处理。同样,对于每个功能扩展,都需要更多的工作和更多的考虑,但如果从基础(第一个例子)开始,您可以对整个工作原理有一个相当好的理解。

开发者会发现,在使用网络库时,很多工作是非常相似的,即使这些库在幕后使用的操作可能完全不同。当我为当前雇主编写我们的二进制下载器时,我使用了所有标准库。后来,我们想要为所有网络请求(图片、REST、下载等)使用单一的 HTTP 客户端,所以我坐下来喝了一杯咖啡,计划在下午花点时间来重新调整我们的下载器,使其能够与 OkHttp 一起工作。我不记得这花了多长时间,或者改变了多少行,但我会说,午餐前我就在写测试了,而在 QA 之前已经完成(即:这是一个简单的任务)。

下载二进制文件

我们将结合到目前为止所呈现的信息,并结合我们在第六章中学到的一些内容来下载一个二进制文件。别担心,这与我们已经做过的事情并没有太大不同。

这是我在个人网站上使用的标题图片的 URL:http://moagrius.com/assets/images/hero-trips.png。它只是一张图片,但它是二进制数据 - 相同的逻辑也适用于视频、可执行文件,甚至只是几位任意的二进制数据。

让我们考虑一下需要做些什么不同的事情。我们知道我们将得到一个包含图片字节的InputStream,那我们该怎么处理呢?嗯,如果我们记得第六章关于文件的教训,我们知道我们会想要一个FileOutputStream来将这些字节保存到我们的本地设备上。那真的就是唯一的区别!让我们试试吧:

运行此方法后,您应该会在我的网站上找到一个完全解码的、视觉上准确的文件副本 - 祝贺您!

注意

网络编程确实涵盖了比我们在本章中能够涵盖的更多内容,但我们将指导您正确的方向:

  • OkHttpVolley都提供了额外的网络 API。

  • PicassoGlideVolley 都提供了非常简单的 API,用于加载和显示远程图像,结合了网络和解码层。

  • GsonJackson 是用于从 REST API 读取的优秀 JSON 解析器。

  • Retrofit 结合了 OkHttp 进行网络请求和 Gson 进行 JSON 解析,以允许一些简单的 API 读取器。

iOS

网络通信一直是 macOS 和 iOS 的一个优势。系统提供的专门构建和提供的对象套件既全面又深思熟虑。事实上,大多数第三方网络库只是轻量级地放在操作系统提供的类之上。有一些非常强大的选择可供选择,例如 Alamofire,但对于大多数应用程序来说,内置工具是最佳选择。因此,在本章中,我们将使用这些工具来演示 iOS 中直接在您指尖可用的强大工具。

让我们深入了解!

在远程服务器上读取和打印文本文件

从全球某个 Web 服务器读取数据,在其根本上是您可以通过网络执行的最简单的操作。在 iOS 中,您可以通过以下简单示例向服务器请求信息:

let url = URL(string: "https://www.oreilly.com")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
    guard let data = data else { return }
    let string = String(data: data, encoding: .utf8)!
    print(string)
}
task.resume()

让我们逐步了解这里发生了什么。

首先,我们创建一个指向特定文件、网站或 API 的URL——以此示例中的 O’Reilly Media 主页为例。接下来,我们使用共享的URLSession对象为我们的 URL 生成一个URLSessionDataTask,并在其后添加一个完成处理程序作为尾随闭包。在完成处理程序内部,我们将原始数据(作为Data对象传入)转换为一个String实例,然后print出该数据,这恰好是通常由 Web 浏览器显示的 HTML。最后,我们调用创建的任务上的resume()来启动整个过程。

警告

您可能会注意到在示例代码中,我们使用的是 HTTPS URL。自 iOS 9 以来,除非在应用程序的 Info.plist 文件中通过NSAppTransportSecurity键明确允许(或完全禁用),否则所有 HTTP 请求都需要是 HTTPS。

此示例中出现了一些新的类。其中第一个新类是URLSession。这是驱动 iOS 中顶层网络 API 大部分功能的类。在其最基本的功能中,每个URLSession都是不同网络任务的协调器;在创建期间,任务直接与URLSession对象关联。会话对象本身可以通过在初始化器中传递URLSessionConfiguration对象来配置其行为。您可以创建多个会话,或者对于简单的需求,只需使用先前使用的名为URLSession.shared的共享会话属性。

提示

URLSession 实例可以以多种方式进行配置。URLSessionConfiguration 也提供了一些标准配置以便于使用,包括 defaultephemeralbackground(with:)。查看苹果开发者文档获取更多信息。

URLSession 实例也是任务的发起者。将网络任务看作操作是很容易的;它们提供了网络请求和响应的所有实际功能。与操作类似,它们通常也以异步方式运行。

有三种类型的任务:

  • URLSessionDataTask,用于从服务器接收数据(以 Data 对象返回)

  • URLSessionDownloadTask,主要用于从服务器检索文件,并可以暂停和恢复,同时提供下载进度更新(将在本章节的后面介绍)

  • URLSessionUploadTask,用于向服务器发送数据或文件(将在本章节的下一部分中介绍)

知道何时使用一种任务而不是另一种任务是很重要的。开发者很少会使用数据任务来下载大文件。此外,使用下载任务来接收简单的 JSON 响应也是不明智的(而且相当繁琐)。任务专门用于使开发者的工作更轻松,逻辑更简单。

现在,前面的示例有些简单和方法有些幼稚。以下是一个更完整和全面的示例,检查客户端错误、有效的服务器响应状态码以及空数据:

let url = URL(string: "https://www.oreilly.com")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
    if let error = error {
        print(error.localizedDescription)
        return
    }

    guard let response = response as? HTTPURLResponse, response.statusCode < 300 else {
        print("A server error occured.")
        return
    }

    guard let data = data, let string = String(data: data, encoding: .utf8) else {
        print("No data returned.")
        return
    }

    print(string)
}
task.resume()

接收来自服务器的数据很有用,而且绝大多数网络调用都是用于请求数据。但是,如果你花时间使用 REST API,你很快就会需要学习如何发送数据以及接收数据。

让我们看看它在 iOS 中是如何工作的。

发起一个 HTTP POST 请求

向服务器发送数据的最简单方法是发送一小段文本到一个 URL。可以使用以下代码完成:

let data = "text to send".data(using: .utf8)
let url = URL(string: "https://www.oreilly.com")!

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = data

let task =
  URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
	...
}
task.resume()

这只是一个简单的示例,但通常我们想要发送到服务器的数据并不是无结构的文本字符串。通常是 JSON 或键值对形式的数据。幸运的是,有一个有用的对象可以使用,称为 URLComponents,用于从一组值创建一个百分比编码的键值对数据字符串。你可以像这样使用它:

var components = URLComponents()
components.queryItems = [
    URLQueryItem(name: "name", value: "O'Reilly"),
    URLQueryItem(name: "isAwesome", value: "true")
]

let url = URL(string: "https://www.oreilly.com")!

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = components.query?.data(using: .utf8)

let task =
  URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
	...
}
task.resume()

首先,我们创建一个 URLComponents 的实例。接下来,我们向 queryItems 属性添加查询项,这些项是 URLQueryItem 类型的键值对象。每当将 httpBody 属性赋给创建的 URLRequest 时,这些对象就会被转换为一个百分比编码的文本字符串。

然而,通常我们可能想发送的不仅仅是键值对数据。以下是如何上传文件到服务器的示例:

let data = "file data".data(using: .utf8)!

let url = URL(string: "http://www.example.com")!
var request = URLRequest(url: url)
request.httpMethod = "POST"

let task = URLSession.shared.uploadTask(with: request, from: data)
task.resume()

在此代码中进行漫步时,我们首先创建一个表示我们模拟文件“文件数据”的数据字符串,该字符串将转换为准备稍后发送的Data对象。接下来,我们定义了一个指向http://www.example.com的 URL 作为占位符。然后,将其传递给我们在简单的POST示例中使用的新对象类型URLRequestURLRequest是我们正在进行的请求的抽象。这样做是为了我们可以指定一些更改,以更改我们向服务器发送此请求的方式。在前面的示例中,我们指定了使用的 HTTP 方法:POST。这直接映射到可用的标准 REST 动词中,默认为GET

最后,在此示例中,类似于前一节中的示例,我们正在使用共享 URL 会话创建一个URLSessionTask;但在这种情况下,我们正在使用uploadTask方法创建一个URLSessionUploadTask。为了启动任务并执行请求,我们调用resume()将传递的data对象发送到服务器。

这只是一个简单的示例。服务器可能会返回有关发送的请求的数据,包括结果是成功还是失败的信息。但是,可以通过使用uploadTask的重载方法,并像这样传递完成处理程序来保持此示例的简单性:

...

let task =
  URLSession.shared.uploadTask(with: request, from: data) { (data, response, error) in

    // Handle any client errors with error

    // Handle any server errors with response

    // Use the data object returned
}

大多数现代 API 将结构化数据(如 JSON)作为输入并返回 JSON 作为输出。我们简单的数据字符串示例实际上可以轻松扩展,几乎不需要更改,只需创建一个结构以提供我们数据的结构即可。这里是如何向端点发送 JSON 并处理响应的完整示例:

// Define an object to hold our data
struct Book: Codable {
    let title: String
    let isbn: String
}

// Populate our data
let book = Book(title: "Native Application Development", isbn: "this ISBN")

// Encode that object as a raw Data object to use in our request
let data = try! JSONEncoder().encode(book)

// Create the request
let url = URL(string: "http://www.example.com")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Tye")

// Create and perform the task
let task =
  URLSession.shared.uploadTask(with: request, from: data) { (data, response, error) in
    // Handle client errors
    if let error = error {
        print(error.localizedDescription)
        return
    }

    // Handle server errors
    guard let response =
      response as? HTTPURLResponse, response.statusCode < 300 else {
        print("A server error occured.")
        return
    }

    // Check if data exists
    guard let data = data else {
        print("No data returned.")
        return
    }

    // Decode the JSON returned into a Book instance
    do {
        let book = try JSONDecoder().decode(Book.self, from: data)
        print("The book's title is \(book.title) and the ISBN is \(book.isbn)")
    } catch {
        print("Could not decode response to JSON")
    }
}
task.resume()

这是一个大的示例,但是如果您仔细观察,实际上只有几行发生了较大变化。首先,在示例顶部,我们正在定义一个用于保存数据的结构。之后不久,我们使用JSONEncoder将此对象转换为原始的Data对象。JSONEncoder是 Swift 标准库的一部分,提供了一种易于使用的方法来序列化和反序列化 JSON 对象。

提示

有一整章专门讲解像 JSON 这样的“传输数据”。您可以查看了解有关功能和可用方法的更多信息。

下一个需要注意的重要变化是以下一行:

...
request.setValue("application/json", forHTTPHeaderField: "Content-Tye")

此命令将 HTTP 头部Content-Type设置为application/json。您可以以这种方式设置其他 HTTP 头部,但对于大多数 API,如果您要使用 JSON 作为发送数据的传输结构,则需要明确指定。

与其他示例相比,前面示例的其余部分可能看起来很熟悉,直到进入使用的完成处理程序。我们对客户端错误、服务器错误或缺少数据执行一些验证检查。然后,我们使用一个新的对象类型JSONDecoder,将返回的原始data对象(实际上只是 JSON)直接反序列化为Book实例。从那里,我们做一些像print书的titleisbn属性到命令行之类的事情。

URLSession对象库中的大多数网络操作中存在相似性和模式。但是,从服务器下载文件需要额外的逻辑来处理稍微不同的一组要求。让我们看看在下一节关于下载二进制文件中的表现如何。

下载二进制文件

下载文件在代码中类似于从服务器请求数据。以下是执行从服务器下载请求以检索文件的示例:

let url = URL(string: "https://www.example.com/file.zip")!
let task =
  URLSession.shared.downloadTask(with: url) { (fileUrl, response, error) in
    // Check for client errors
    if let error = error {
        print(error.localizedDescription)
        return
    }

    // Check for server errors
    guard let response =
      response as? HTTPURLResponse, response.statusCode < 300 else {
        print("A server error occured.")
        return
    }

    // Check for a downloaded file
    guard let tempFileUrl = fileUrl else { return }
    print(tempFileUrl.path)
}
task.resume()

在前面的示例中,我们请求了一个特定 URL 的文件。一个主要的区别是,我们没有使用dataTask(with:)来创建我们的请求,而是使用了downloadTask(with:)。完成处理程序具有稍微不同的签名——不再传递Data实例,而是传递我们在示例中命名为fileUrlUrl实例。

下载任务与数据任务的不同之处在于,数据任务将请求内容存储在内存中,而下载任务将响应的缓冲区存储到临时文件中,随着下载的完成而完成。对于短时间的下载,这可能是瞬间完成的,但对于较大的下载,任务会逐步写入文件。

我们的完成处理程序在文件下载完成时执行,并包含该文件被写入的临时位置。这些文件是短暂的,将会被删除。

警告

记住将文件移动到其他地方,例如文档目录非常重要。我们之前的示例只是打印文件的路径。查看第六章有关如何复制文件的文件基础知识。

对于较大的下载,通常最好向用户呈现某种类型的用户界面,指示已下载文件的百分比和剩余量。我们简单的完成处理程序示例没有提供这样的机制。然而,在URLSession子系统中,有一种处理方式:URLSessionDownloadDelegate协议。

URLSessionDownloadDelegate

URLSessionDownloadDelegate包含一些可选方法,供对象在请求下载事件时实现。以下是如何实现委托、分配给URLSession并创建带有进度更新的下载任务的示例:

class NetworkClient: NSObject {
    // ...
}
extension NetworkClient: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {

        // Check for a server error
        guard let response =
          downloadTask.response as? HTTPURLResponse, response.statusCode < 300 else {
          return }

        // Prints the temporary file location
        print(location.path)
    }

    func urlSession(_ session: URLSession, task: URLSessionTask,
      didCompleteWithError error: Error?) {
        if let error = error {
            print(error.localizedDescription)
        }
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        let percent = (totalBytesWritten/totalBytesExpectedToWrite) * 100
        print(percent)
    }
}

let url = URL(string: "https://www.example.com/file.zip")!

let client = NetworkClient()
let urlSession = URLSession(configuration: .default, delegate: client, delegateQueue: nil)
let task = urlSession.downloadTask(with: url)
task.resume()

让我们走过这段代码。

首先,我们创建了一个名为NetworkClient的新类,它继承自NSObject,这是 Objective-C 对象的基类。该类有一个扩展,实现了URLSessionDownloadDelegate协议。第一个方法,urlSession(_:downloadTask:didFinishDownloadingTo:),是必需的。这是因为下载任务将它们下载的文件作为临时文件存储在文件系统中;每当文件下载完成,委托对象上的此方法被调用以告知您访问下载文件的位置。我们还可以通过检查downloadTaskresponse对象的状态码来在此方法体中处理服务器错误。

接下来的方法,urlSession(_:task:didCompleteWithError:),是可选的,但在生产应用中应该实现。您可以在这里处理客户端错误,并检查是否出现了导致文件无法成功下载的问题。在我们的示例中,我们将错误的描述打印到控制台。

想知道下载进度如何通知应用程序?扩展中的最后一个方法就是查看的地方:urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)。此方法会定期调用,间隔由 iOS 中的网络子系统决定。我们可以使用totalBytesWrittentotalBytesExpectedToWrite的值来计算一个percent值。该值可用于更新从文本标签到自定义绘图例程到UIProgressView等一切。在我们的示例中,我们将该值打印到控制台,但请发挥想象力,看看可能的用途!

我们创建的这个类在稍后被实例化,并作为delegate参数传递给自定义的URLSession初始化程序。重要的是注意以下两行代码的发生位置:

...

let urlSession =
  URLSession(configuration: .default, delegate: client, delegateQueue: nil)
let task = urlSession.downloadTask(with: url)
task.resume()

首先,我们创建URLSession实例。在下一行,我们使用该对象为给定的 URL 创建downloadTask。我们使用以前示例中使用的URLSession.shared实例。这是因为我们需要在初始化程序中设置委托的地方,在URLSession中,那就是在初始化器中。

暂停和恢复

对于长时间运行的下载,进度更新并非您的用户可能需要的唯一内容。部分完成的下载可能会失败。或者,也许您的用户想要在中途暂停下载,并在切换到 WiFi 网络后恢复下载。无论情况如何,使用URLSession暂停和恢复是可能的,但实现起来有些奇怪。

让我们来看一个示例,然后讨论发生了什么:

class PauseableClient: NSObject {
    let url = URL(string: "https://www.example.com/file.zip")!
    var resumeData: Data?

    func startDownload() -> URLSessionDownloadTask? {
        let task = URLSession.shared.downloadTask(with: url)
        task.resume()
        return task
    }

    func pauseDownload(for task: URLSessionDownloadTask?) {
        guard let task = task else { return }
        task.cancel { (resumeData) in
            self.resumeData = resumeData
        }
    }

    func resumeDownload() -> URLSessionDownloadTask? {
        guard let resumeData = resumeData else {
            print("Download can't be resumed!")
            return nil
        }

        let task = URLSession.shared.downloadTask(withResumeData: resumeData)
        task.resume()
        return task
    }

}

let client = PauseableClient()
var task = client.startDownload()
client.pauseDownload(for: task)
task = client.resumeDownload()

在这个示例中,我们有一个名为PauseableClient的类,其中有三个方法:startDownload()pauseDownload()resumeDownload()。在这些方法的第一个startDownload()中,我们使用一个 URL 启动下载任务。可以假设这是一个长时间运行的下载。我们还返回了稍后使用的task对象。在屏幕的稍后位置,我们调用startDownload(),并调用客户端的pauseDownload(for:)方法,将task传递进去。

pauseDownload()方法内部,我们获取传入的task对象,并调用cancel(byProducingResumeData:)。这个方法接受一个闭包作为它唯一的参数。一旦任务被取消,闭包就会被调用,并且会传入一个作为Data实例给定的令牌,用于恢复下载。将此令牌存储以备将来使用,允许代码在未来可能再次恢复下载。

注意

如果在urlSession(_:task:didCompleteWithError:)委托方法中报告了客户端错误,你可以潜在地存储恢复数据。检查传递的error对象中的userInfo[NSURLSessionDownloadTaskResumeData]。现在,有关恢复数据生成的限制有所限制。有关更多信息,请参考Apple 的开发文档中关于URLSessionTask的部分。

就所有意图和目的而言,我们创建的下载文件任务在这一点上已经消失了。调用cancel并不会实际暂停下载——它取消了下载。话虽如此,我们有一种方法可以在不必重新下载已收到的数据的情况下重新启动此请求。

在我们的示例中,这是在resumeDownload()方法中完成的。此方法检查恢复数据,然后调用URLSession通过方法downloadTask(withResumeData:)创建一个新的URLSessionDownloadTask。传入的恢复数据与之前任务中由pauseDownload(for:)保存的resumeData相同。

最终,一旦这个任务是从URLSession创建的,调用resume()来重新开始下载,希望能在我们暂停(或取消)之前的完成状态下再次开始下载。

委托

URLSession子系统中有比本章节中代码中展示的更多委托和委托方法。对象还应实现URLSessionTaskDelegateURLSessionDataDelegateURLSessionDownloadDelegate协议中的一些或所有方法来处理任务级事件。这些事件包括像开始和结束单个请求以及从数据或下载任务中周期性地更新进度,类似于前面展示的内容。

后台线程和更新 UI

URLSessionTask 在自己的后台线程上异步操作,无需阻塞当前线程或特别指定任何内容。然而,闭包被调用时不会返回到主线程,因此重要的是在主线程上调度任何 UI 更新或模型同步代码(或者在应用程序的其他部分使用的同步方法)。

应用程序传输安全性

要调用非安全的 URL,如以http://开头的 URL,需要在应用程序的Info.plist文件中为NSAppTransportSecurity键指定特殊配置。建议通过使用NSExceptionDomains键来指定某些域是不安全但可信任的方式来排除它们。可以通过将其NSAllowsArbitraryLoads键设置为true来完全关闭 App Transport Security,但这不是推荐的方法,除非绝对必要。

我们学到了什么

我们学到了关于如何在 Android 和 iOS 中的网络功能的许多知识:

  • 我们学会了如何在两个平台上发出简单的请求。两者的过程相似,但在 Android 上使用流略有不同。

  • 我们学会了如何以开发人员完全控制请求和响应的方式将数据发送到服务器。

  • 我们讨论了下载二进制文件的性质以及存储它们到文件系统所需的处理方式。

  • 嵌入到 iOS 中的安全控制要求默认使用 HTTPS。这与 Android 相反,后者在网络安全默认值方面要稍微灵活、开放,并且不那么限制性。

通常网络不可用或请求失败。在下一章中,我们将讨论当问题出现或者您只想告知用户某些其他操作正在进行时,如何为用户提供反馈。让我们继续前进吧!

第十章:用户反馈

在任何程序中,能够在短暂的 UI 中向用户提供信息非常重要。特别是在移动开发中,如果每个警告、提示或通知都出现在 UI 中,我们很快就会用完可用的空间。这两个框架都提供了多种功能来实现这一点。

任务

在本章中,您将学习:

  1. 使用框架提供的工具在各种情况下向用户显示反馈。

  2. 更新状态栏。

Android

Android 框架提供了几个 API,用于显示关于事件、错误或状态变化的即时反馈。有些只显示文本,但其他一些则允许各种程度的定制和交互。我们将检查三个主要的反馈类,并向您展示如何编程以满足您的特定需求。

使用框架提供的工具向用户显示反馈

虽然您可以使用标准布局和绘图方法以任何您想要的方式向用户显示信息,但 Android 框架提供了三个主要的 API 来图形化地向用户提供反馈:

  • Toast

  • Snackbar

  • Dialog

Toast 类早于 Snackbar,虽然基本用法非常相似,但可能更简单一些。另一方面,如果正确使用,Snackbar 可以提供更好的用户体验,并具有 Toast 消息无法实现的灵活性。对话框是支持各种元素的模态接口,包括用于向用户显示信息并从用户那里接收信息的元素。

让我们开始吧。

提示

提示消息是与实现相关的。某个 Android 操作系统的版本可能会选择以不同的方式实现细节,或者在 Android 操作系统标准较为宽松的较旧设备上,甚至可能会因设备的品牌和型号而有所不同。

一般来说,提示消息是显示在现有内容上的简短、短暂的消息。来自 Android 开发者文档

提示(Toast)在小弹出窗口中提供有关操作的简单反馈。它仅填充消息所需的空间,当前活动保持可见和交互。提示会在超时后自动消失。

要显示提示消息,请使用以下方法:

就是这样!我们有一个 Context 实例、要显示的消息以及持续时间常量(LENGTH_SHORTLENGTH_LONG)。

还有其他可用的 API,比如设置 Toast 在屏幕上显示的位置(其“重力”),甚至提供具有自定义 View 树的 Toast 弹出窗口,但大多数情况下,makeTextshow 就足够了。

Snackbar

正如之前提到的,ToastSnackbar 的基础非常相似——都在现有 UI 的顶部显示短暂的消息。实际上,如果考虑到与之前显示的基本 Toast 调用相比,基本的 Snackbar 调用几乎是相同的:

Snackbar.make 方法的第一个参数不是 Context 实例,而是一个 View 实例,并且方法的命名略有不同(Toast.makeTextSnackbar.make),但它们本质上是相同的。

在用户体验方面的主要差异在于:

  1. Snackbar 通常从屏幕底部滑出,而 Toast 则通常从屏幕中心淡入。

  2. 通过单个方法调用 setActionSnackbar 可以在消息右侧提供一个简单的操作按钮。使用 Toast 也可以做到类似的功能,但需要进行更多的自定义编码。

还需要注意,根据 Android 开发者文档,开发者现在被鼓励优先选择 Snackbar 而不是 Toast

Snackbar 类取代了 Toast。尽管目前仍支持 Toast,但 Snackbar 现在是向用户显示简短临时消息的首选方式。

然而,SnackbarToast 的主要区别可以在将 Snackbar 附加到 CoordinatorLayout 上时看出,CoordinatorLayout 应该是作为 make 方法的第一个参数传递的 View 实例的根视图。通过这样做,Snackbar 将获得一些额外功能,比如可以通过滑动屏幕来关闭它,还能感知到 CoordinatorLayout 管理的其他组件。例如,当 Snackbar 滑入时,FloatingActionButton 将向上移动并躲开。详细信息请参见 Android 开发者文档

对话框

Dialog 及其子类比之前的 ToastSnackbar 类更为强大和灵活,但也可能需要更多的注意、维护和配置。

来自 开发者文档

对话框是一个小窗口,提示用户做出决定或输入额外信息。对话框不会填满屏幕,通常用于需要用户在继续之前采取行动的模态事件。

与前面讨论过的类一样,Dialog UI 可以完全自定义;还有单选或多选反馈的功能,添加用户响应功能也相对简单。

要创建并显示一个带有标题、消息和“接受”或“取消”操作按钮的 Dialog,您可以像这样操作:

欲了解更多关于 Dialog 的信息,请参阅 开发者文档

更新状态栏

在 Android 中,状态栏是屏幕的最顶部区域,您可以在那里找到像时间、电池电量、网络状态等系统信息。这种类型的系统级数据通常显示在顶部栏的右半部分,而应用级信息通常以非常小的图标形式显示在左侧边缘。这些图标称为“通知”图标,通常对应一个Notification实例,它是一个存在于您的应用程序之外的 UI,通常包含一个简短的消息,但也可能包含一些可操作的项目。例如,当下载完成时,您的应用程序可以发布一个Notification,简要向用户显示一条消息,并在状态栏中保留一个图标,以便用户稍后再次访问该消息。该消息可能还附带一些操作,比如打开您的应用程序到下载项目的详细屏幕,或者一个下载列表。Notifications API 功能强大,但可能不像前面讨论的一些用户反馈功能那样直观。

还要注意,随着 AOSP 的成熟,Notifications API 已经有了多次重大变化。Android 操作系统的版本 5 和版本 8 引入了重大变化。我们在这里只涉及基础知识,但像往常一样,请查看开发者文档以深入了解。

在最基本的层次上,这里是如何使用compat库显示Notification(确保你有依赖com.android.support:support-compat:xx.xx.xx):

当用户点击Notification时启动一个Activity,使用PendingIntent类:

在调用build之前将其添加到构建器中:

欲深入了解,请参阅开发者文档

注意

这些内置的用户反馈机制可以为您的用户提供一致的体验。例如,使用带有接受和取消按钮的内置对话框系统可能更加符合用户的习惯,因此如果通过传统的、框架提供的 UI 来提供这些功能,用户可能更愿意订阅您的通讯(或给您的应用评分)。尽管如此,如果您的要求超出了这些 API 所允许的范围,您可以自由创建(或修改)更适合您需求的自定义 UI 组件。

iOS

如果你已经阅读了本章的 Android 部分,你会很快发现这是 iOS 和 Android 差异最大的领域之一。Android 和 iOS 在显示用户反馈的可用方法中差异尤为显著。让我们看看有哪些选择,以及它们在哪些地方表现最为突出。

使用框架提供的工具来显示用户反馈

在 iOS 上向用户显示反馈有两种方式:警告视图和操作表。它们都是由同一个类UIAlertController创建,唯一的区别是在初始化警告视图时传入.alert,在操作表中使用.actionSheet

提示

iOS 中默认情况下不可能显示类似 Android 中的Snackbar风格通知。但是可以使用第三方库来实现类似的效果。然而,这超出了本书的范围。

Apple 没有绝对明确的指导意见,关于何时使用操作表和何时使用警告,但对大多数应用程序来说,已经形成的标准是在需要即时反馈且可能是意外的情况下使用警告视图;在需要用户选择并且有操作上下文的情况下使用操作表。

这里展示了一个警告视图示例:

let viewController = UIViewController(nibName: ..., bundle: ...)

let alert = UIAlertController(
    title: "Title", message: "This is the message", preferredStyle: .alert)
alert.addAction(
  UIAlertAction(title: "OK", style: .default, handler: { (action) in
    print("OK button pressed!")
}))

viewController.present(alert, animated: true, completion: nil)

让我们一起浏览代码。

首先,我们创建一个视图控制器,稍后将用来呈现我们的警告。自 iOS 8 以来,警告视图实际上是可以由另一个视图控制器呈现的UIViewController实例,就像任何其他视图控制器一样。接下来,我们通过UIAlertController的初始化方法实例化一个警告。这个类有一个标题、一条消息和一个preferredStyle参数。我们传入.alert表示我们正在创建的是一个警告视图;使用.actionSheet来创建操作表。

下一行包含一个名为addAction(_:)的方法,用于向警告视图添加动作或按钮。我们直接实例化一个UIAlertAction实例,并将其带有标题、样式(本例中为.default)和按钮处理程序传递到这个方法中。在按钮处理程序内部,每当按下按钮时,我们向控制台输出“OK button pressed!”。

最后,我们使用代码顶部创建的视图控制器来显示我们的警告视图,覆盖在视图堆栈的其余部分之上。

这个过程的核心是在警告本身调用的addAction(_:)方法。这是我们添加所有按钮到我们的警告或者可能的操作表中的地方。

现在,可以创建一个带有多个按钮的警告。这里有一个带有三个按钮的示例:

let viewController = UIViewController(nibName: nil, bundle: nil)

let alert = UIAlertController(title: "Terms & Conditions",
    message: "Hit Agree to agree to terms and conditions.", preferredStyle: .alert)

let agreeAction = UIAlertAction(title: "Agree", style: .default) { (action) in
    print("Terms and conditions accepted.")
}

let viewAction =
  UIAlertAction(title: "View Terms & Conditions", style: .default) { (action) in
    print("Blah blah blah.")
}

let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { (action) in
    print("I disagree with terms and conditions.")
}

// Add all the actions
alert.addAction(agreeAction)
alert.addAction(viewAction)
alert.addAction(cancelAction)

viewController.present(alert, animated: true, completion: nil)

在这里,我们使用几乎相同的逻辑来创建和显示我们的警告视图。为了更易于阅读的一个关键区别是,我们将动作单独创建然后直接添加到警告中,而不是在调用addAction(_:)时直接实例化它们。

另一个轻微的区别是我们使用 .cancel 样式来创建 cancelAction。可用的操作样式集合有限。.default 样式显示一个在两个按钮配置中加粗显示的基本按钮。.cancel 样式显示一个在取消按钮位置上具有指定标签的按钮。剩余的样式 .destructive 显示一个通常带有红色文本颜色以指示其具有破坏性潜力的按钮。

这是使用 .destructive 样式创建的操作:

let deleteAction =
  UIAlertAction(title: "Yes, Delete", style: .destructive) { (action) in
    print("All your photos were deleted. What were you thinking?")
}

如果有任何关于提示视图和操作表的指导意见,那就是只有在必要时才显示提示视图。它们会打断用户在应用程序中的流程,如果过度使用,很快就会变得令人厌烦并被忽视。伟大的力量需要伟大的责任。如果您同意,请点击“是”。

更新状态栏

在 Android 上的 Notifications API 允许开发者自定义状态栏中的项目,并以这种方式向用户提供反馈。在 iOS 上,开发者唯一可以使用状态栏进行反馈的方式是通过网络状态栏活动指示器。

尽管在外观上很小,但这个指示器对用户来说可能非常有帮助。它可以帮助用户诊断应用程序可能变慢的原因,并指示数据仍在加载。诸如此类的小细节可以决定一个应用程序是感觉未完成还是感觉完整和精致的区别。

以下是操作方法:

// Display the indicator
UIApplication.shared.isNetworkActivityIndicatorVisible = true

// Hide the indicator
UIApplication.shared.isNetworkActivityIndicatorVisible = false

更新显示并隐藏指示器非常容易。大多数第三方网络库都有自己的方法来完成,但几乎所有这些方法最终都会包装成如下代码:

class NetworkClient {
    func startDownload() {
        ...

        UIApplication.shared.isNetworkActivityIndicatorVisible = true
    }

    func downloadCompleted() {
        ...

        UIApplication.shared.isNetworkActivityIndicatorVisible = false
    }
}

它可能没有 Android 那么全面,但对用户仍然非常有帮助。

提示视图中的文本字段

不仅可以用提示视图来提供反馈,还可以从用户那里获取输入。提供了一个便利的方法来添加文本字段并进行样式化,如下所示:

alert.addTextField { (textField) in
	textField.placeholder = "Enter your comment"
}

您可以稍后在 UIAlertAction 中访问此文本字段的值:

let action = UIAlertAction(title: "Save", style: .default) { (action) in
    guard let textField = alert.textFields?[0] else { return }
    print(textField.text ?? "")
}

触觉反馈

可以通过触觉反馈以非视觉方式向手机用户提供反馈。有三种用于不同类型反馈的类别:

  • UINotificationFeedbackGenerator

  • UIImpactFeedbackGenerator

  • UISelectionFeedbackGenerator

举个快速的例子,为了向用户提供关于下载失败的触觉反馈,您可以使用以下代码片段:

let hapticFeedbackGenerator = UINotificationFeedbackGenerator()
hapticFeedbackGenerator.notificationOccurred(.error)

这将生成一个由 Apple 设计的感觉消极和紧急的轻敲。请谨慎使用,并记住,在发布时并非所有设备类别,例如 iPad,都支持触觉反馈。

我们学到了什么

这似乎是在开发 Android 和 iOS 应用程序时介绍了两种技术堆栈之间存在的最大差异之一的领域:

  • Android 有多种方式向用户提供反馈,比如 ToastSnackbarDialog。同时,也可以通过警报更新 Android 状态栏来提示用户。

  • 在 iOS 中,用户反馈方面的控制选项较为有限。尽管如此,这些选项使用起来简单,并遵循 UIKit 的标准 UI 约定。

  • 在 iOS 中,更新状态栏实际上是不太可能的。这突显了 Android 和 iOS 之间的一个核心差异:开发者对设备的控制程度。在 iOS 中,苹果公司对此有着更为严格的控制。开发者只能指示网络活动。

通过本章我们学到了让我们的应用与用户进行交流的许多方法。在下一章中,我们将讨论提供一种比整个数据库持久化层稍微简单一些的存储信息方式。

第十一章:用户偏好设置

允许个性化应用是帮助用户体验的绝佳方式,并为用户提供了一种根据需求定制应用程序的途径。Android 和 iOS 提供了一套框架以及一套模式,以实现此目标。当然,对于更复杂的场景,开发者可能需要使用笨重和繁琐的技术。然而,大多数开发者可以通过简单且开箱即用的方法来读取和写入用户偏好设置。

任务

在本章中,你将学习以下内容:

  1. 写入用户偏好设置。

  2. 读取用户偏好设置。

  3. 在多用户应用中处理用户偏好设置。

Android

在 Android 中,如果你喜欢自己实现,也可以使用文件系统或数据库来存储用户偏好设置,但 Android 提供了开箱即用的 SharedPreferences API。虽然通常鼓励使用此 API 以保持一致性,但并不严格要求,也不一定适用于所有场景,如果你发现其他方法更适合你的需求,可以随意选择。

来自 Android 开发者文档

如果不需要存储大量数据且不需要结构化数据,应使用 SharedPreferences。SharedPreferences API 允许读取和写入原始数据类型的持久化键值对:布尔值、浮点数、整数、长整数和字符串。

默认情况下,SharedPreferences 并不安全——值存储在应用程序文件目录中的 XML 文件中。框架提供的 KeyStore API 提供了一些安全性,但在较旧的操作系统上可能存在问题。有第三方库声称提供与 SharedPreferences 类似的 API,并具有一定级别的安全性。

尽管有其局限性,SharedPreferences API 简单易用,并提供了内置的后台线程处理,所以不妨一试!

写入用户偏好设置

要写入键值对,我们首先需要一个 SharedPreferences 对象实例——Android 通过从任何 Context 实例调用 getSharedPreferences(String fileName, Context.MODE_PRIVATE); 方法提供了一个预配置的实例。或者,Activity 实例具有一个 getPreferences 方法,返回默认的偏好设置文件,并允许省略第一个参数(fileName)。

从那里,你将需要一个 Editor 实例,可以通过调用 edit 方法从 SharedPreferences 实例中获取:

在此时,你可以使用诸如 putBoolean(String key, boolean value)putString(String key, String value) 等方法放置键值对。完成后,你可以在 Editor 实例上调用 commit 方法以同步保存更改,或者调用 apply 方法以异步保存它们:

如前所述,SharedPreferences 用于简单的原始值,只能接受基本数据类型,如booleanintlongfloatString,尽管它也可以管理 Set<String>

读取用户偏好

SharedPreferences中读取用户偏好比写入更容易——你不需要Editor实例或者担心线程安全,因为数据的副本保存在内存中(这也使得它非常快)。

要读取之前示例中保存的布尔值,只需这样简单:

就是这样!

在多用户应用程序中处理用户偏好

所以这变得有点棘手。技术上讲,SharedPreferences是为整个应用程序而设计的——一个文件,被任何使用该应用程序的人共享。然而,为不同用户使用不同的SharedPreferences文件已经成为一种相对普遍的做法——你只需给获取器提供唯一的文件名即可。如何确定这个文件名完全取决于你,但你可能只是获取他们用户 ID 的sha

iOS

iOS 中有多种存储用户数据的方式:用户偏好,文件系统,Core Data 或 Keychain。通常,应用程序需要持久化特定用户唯一但不一定私密或安全的信息。像这样的数据最适合存储在用户偏好中——非常适合存储用户的语言环境,UI 样式偏好或者关于显示数据的测量单位选择。

幸运的是,iOS(和 macOS)提供了一个经过测试的方法来在应用级别存储这些数据:UserDefaults。让我们深入了解一下!

写入用户偏好

在我们能够读取数据之前,首先学会存储数据是很重要的。通过UserDefaults持久化用户偏好非常简单。下面是一个存储基本字符串值的简单示例:

let defaults = UserDefaults.standard
defaults.set("some string value", forKey: "someKey")

首先,我们获取共享的UserDefaults实例,然后我们将一个字符串“some string value”与名为someKey的键配对。这个键是我们查找数据的关键,如本章后面所示。

发生在幕后的事情

每个 iOS 应用程序的应用文件夹中都有一个Library文件夹,其中包含一个Preferences文件夹。在幕后,iOS 在应用程序向UserDefaults.standard写入值时创建或更新属性列表文件。存储的各个类型都符合NSCoding协议,允许它们被序列化和反序列化。也可以使自定义类符合这个协议——稍后详细介绍!

警告

这个属性列表文件由UserDefaults更新和管理。应该将其视为实现细节,由底层子系统处理,因为可能会在未来的 iOS 版本中发生变化。

数据类型

UserDefaults能够存储多种类型的数据,包括布尔值,数字,字符串,URL,字典,数组和自定义对象。这里有一个更复杂的示例,展示了更广泛的用法:

let defaults = UserDefaults.standard

// Boolean
defaults.set(true, forKey: "nightMode")

// Number
defaults.set(2.0, forKey: "playbackSpeed")

// String
defaults.set("en-US", forKey: "locale")

// URL
let url = URL(string: "https://www.example.com/api")
defaults.set(url, forKey: "apiURL")

Swift 提供了许多方便的方法,可以将特定数据类型值传递给UserDefault。每个值都映射到一个String键。大多数情况下,使用标准的 Swift 对象就足够了。但是当您想要持久化自定义类时呢?

幸运的是,有一个解决方案:NSCoding

NSCoding 遵循

希望在UserDefault中持久化的对象需要符合NSCoding协议。该协议包含两个方法:init(coder:)encode(with:)。这两个方法在存储对象的编码和解码功能中起着重要作用。让我们看一个简单的示例。

@objc(SomeObject)
class SomeObject: NSObject, NSCoding {
    let someProperty: String

    init(someProperty: String) {
        self.someProperty = someProperty
    }

    // MARK: NSCoding protocol conformance

    required convenience init?(coder aDecoder: NSCoder) {
        guard let someProperty = aDecoder.decodeObject(forKey:
        "someProperty") as? String else {
            return nil
        }
        self.init(someProperty: someProperty)
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(someProperty, forKey: "someProperty")
    }
}

let someObject = SomeObject(someProperty: "some value")

let defaults = UserDefaults.standard
defaults.set(someObject, forKey: "myObject")

首先,我们为一个类声明了显式的@objc名称。这是因为类名在解档对象时非常重要。我们还声明该类符合NSCoding协议。接下来,我们有一个名为someProperty的字符串属性,在类的初始化器中设置该属性。在类的更深层部分,在encode(with:)方法中,我们使用传入该方法的给定NSCoder来为键someProperty编码someProperty的值。这是在我们在UserDefaults上调用set(value:forKey:)时在幕后调用的方法。

稍后,每当我们需要解码该对象时,UserDefaults会通过调用init?(coder:)初始化器来实例化该对象。在该方法内部,我们尝试设置每个已编码属性,首先解码给定键的属性,然后将其传递给我们的默认对象初始化器。因为我们手动决定哪些属性进行编码和解码,所以可以排除某些根据不同数据初始化或由其他对象设置的属性。

警告

这是一个“字符串类型”的示例,如果键名拼写错误或在应用程序版本之间更改,则很容易出错。有方法可以绕过这些问题,例如使用枚举和一些内置的NSKeyedUnarchiver功能,如NSKeyedUnarchiver.setClass(SomeObject.self, forClassName: "SomeObject")

使用 Codable 而不是 NSCoding

对于需要在UserDefaults中持久保存的自定义对象,可以跳过NSCoding遵循,只需使用Codable遵循来对对象进行 JSON 编码和解码。使用Codable而不是NSCoding的一个好处是,您可以跳过整个 Objective-C 运行时。以下是一个示例:

struct SomeObject: Codable {
    let someProperty: String
}

let someObject = SomeObject(someProperty: "some value")

// Store the object
let defaults = UserDefaults.standard
if let json = try? JSONEncoder().encode(someObject) {
    defaults.set(json, forKey: "myObject")
}

对于符合Codable协议的给定类型,我们可以使用JSONEncoder对其进行编码,然后将生成的Data直接设置为一个键(在我们的示例中是myObject)。

解码 JSON 很简单。可以通过以下代码实现:

// Read the value
let json = defaults.value(forKey: "myObject") as! Data
let someObject = try? JSONDecoder().decode(SomeObject.self, from: json)

首先,我们从UserDefaults中获取对象作为Data(强制解包以保持示例简单)。然后,我们使用JSONDecoder对其进行解码,并显式声明其类型为SomeObject

删除键

可能您创建了一个键,然后决定在应用程序的将来版本中不再需要该键。在UserDefaults中删除键只是一个如下的调用:

let defaults = UserDefaults.standard
defaults.removeObject(forKey: "someKey")

现在我们已经写入(和删除)了数据,让我们来看看如何读取它,以便我们可以在我们的应用程序中使用它!

读取用户偏好设置

可以通过以下方式调用从UserDefaults读取数据:

let defaults = UserDefaults.standard
let someValue = defaults.value(forKey: "someKey")

这将创建一个名为someValue的对象,用于保存我们的数据。不幸的是,UserDefaults并不明确知道我们的数据被解码为什么类型,因此默认为Any?。但是,可以通过稍微更改我们调用以获取数据的方法来改变这种情况,使用一些专门为一组常见类型构建的方法。下面的代码展示了其中一些方法的示例:

let defaults = UserDefaults.standard

// Boolean
let nightMode = defaults.bool(forKey: "nightMode") // true

// Number
let playbackSpeed = defaults.double(forKey: "playbackSpeed") // 2.0

// String
let locale = defaults.string(forKey: "locale") // "en-US"

// URL
let apiURL = defaults.url(forKey: "apiURL") // https://www.example.com/api

遍历此处调用的方法列表,我们可以看到bool(forKey:)返回Boolean类型,double(forKey:)返回Doublestring(forKey:)返回String,而url(forKey:)返回一个 URL 实例。还有其他可用的类型,比如其他数字类型如IntFloat。查看Apple 开发者文档以获取有关UserDefaults解码可用类型的更多信息。

话虽如此,有一种类型显然缺失:我们在本章前面声明的SomeObject类类型!为了返回一个SomeObject,我们需要像这样使用object(forKey:)方法:

// SomeObject NSCoding example
let someObject = defaults.object(forKey: "someObject") as? SomeObject

注意,我们显式地将由UserDefaults返回的对象转换为SomeObject。如果没有显式类型要求,而Any?足够的话,可以跳过此步骤。

到目前为止,我们只讨论了如何为单个用户存储数据。让我们讨论一下在您的应用程序中使用多个用户以及我们如何在UserDefaults中管理它们。

在多用户应用程序中处理用户偏好设置

不幸的是,尽管 macOS 提供了一些诱人的接近功能,iOS 并未提供此类开箱即用的功能。话虽如此,可以通过将用户偏好设置存储在单独的文件中,并在必要时恢复来解决这个问题。以下是如何完成这一点的示例:

let defaults = UserDefaults.standard

// Get a dictionary representation of the current UserDefaults
let dictionary = defaults.dictionaryRepresentation()

// Store the dictionary to disk
let oldData = try! NSKeyedArchiver.archivedData(
    withRootObject: dictionary, requiringSecureCoding: true)
try! oldData.write(to: URL(fileURLWithPath: "user1.plist"))

// Remove all the data
dictionary.keys.forEach { key in
    defaults.removeObject(forKey: key)
}

// Get the new user preferences
let newData = try! Data(contentsOf: URL(fileURLWithPath: "user2.plist"))
if let newDictionary =
  try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(newData) as? [String: Any] {
    // Update UserDefaults with the new data
    newDictionary.forEach { (keyValue) in
        let (key, value) = keyValue
        defaults.set(value, forKey: key)
    }
}

让我们一起看看正在发生什么。

首先,我们获得我们的UserDefaults数据的Dictionary表示。这用于备份我们现有用户的数据。我们称这个字典,并使用NSKeyedArchiver将该字典转换为一个Data实例,然后可以写入到应用程序的共享区域,最有可能是在应用程序的Library/Preferences文件夹内,但这留给读者去练习。在我们的示例中,我们将其存储到一个文件路径user1.plist中。

在代码中进一步进行时,我们遍历字典中的每个键,然后在UserDefaults上调用removeObject(forKey:)来清除所有这些数据。

最后,是时候开始使用新用户的数据了;让我们称呼这位用户为“用户 2”。用户 2 在user2.plist中有一个偏好文件,因此我们将该文件的Data表示形式传递给NSKeyedUnarchiver,并解码为一个字典对象[String: Any],这与最初写入的类型相同。要将数据添加到UserDefaults中,我们遍历新字典中的每个键,并直接将其手动设置到UserDefaults中。

这并不是最干净简单的过程,但它有效。iOS 的未来版本可能会支持多个用户账户。目前,这个解决方案可行。可能需要一些时间来写入大量用户偏好列表。幸运的是,UserDefaults是线程安全的,所以所有这些工作可以轻松地在后台线程中完成。

我们学到了什么

从本章中可以得出几个要点:

  • 使用操作系统提供的开箱即用技术是开始存储用户偏好的好方法。实际上,可以仅使用这种方法构建强大的系统和流程,应用程序可以遵循这种方法。

  • Android 和 iOS 都有类似的方法来存储和读取用户偏好。Android 使用SharedPreferences,iOS 使用UserDefaults。它们都提供了一个键值存储,用于在会话之间保存数据。

我们主要讨论了关于用户偏好的设备内数据存储。还有其他标准格式,如 XML 和 JSON,在这两个平台上都有很好的支持。让我们在下一章中学习更高级的数据序列化和传输。

第十二章:序列化和传输

为了序列化一个对象,我们将抽象概念(“模型”)转换为可传输的实体,通常是模型的字符串表示,比如 XML 或 JSON,或者字节。

反序列化数据意味着将其从一系列实体转换为程序所识别的对象。例如,你可能有一个带有 name 属性的 Author 实体。

任务

在本章中,你将学习:

  1. 序列化和反序列化对象实例。

Android

Android 内部大量使用 XML,但在现实世界中,JSON 仍然是主要的序列化机制(尽管拥有大量工程资源的大型组织已经开始采用“protobuf”,或 Protocol Buffers)。这是一个巧妙的概念并且高性能,但超出了我们对框架和标准 API 检查的范围。

序列化和反序列化对象实例

在 Java 和 Android 中,反序列化可能从数据模型开始,就像这样:

反序列化为 JSON,调用 getName 返回 “Mike” 的 Author 实例可能看起来像这样:

{ name : "Mike" }

一旦它以 JSON 格式存在,就可以随网络请求传输,写入磁盘,甚至传递给另一个采用完全不同技术的程序;因为 JSON 格式具有已建立的规范,我们可以信任 Android 应用使用的 JSON 规则与 iOS 应用或甚至 Windows 或 Unix 程序使用的 JSON 规则相似。

Android 框架中实际上有三种主要的序列化模式:

  • JSON

  • XML

  • Java 序列化

这些大致按受欢迎程度排序。虽然 Java 标准库或 Android 框架都提供了各种程度的支持,我们还将看看像 Gson 这样的第三方库。虽然 Gson 是 Google 产品,但也有一些非常流行的替代品——如果你不认为 Gson 或 org.json 有吸引力,或者你的应用程序不使用大量 JSON,请快速搜索一下。

org.json

参见 此包的开发者文档 以供参考。

使用 org.json 包进行基本序列化和反序列化非常简单。让我们考虑前面使用的 Author 类。要将其序列化,我们可以实例化一个 JSONObject 实例,将我们的属性复制到其中,然后调用 toString 方法:

这将打印出 {"name":"Mike"}

反序列化更加自动化:

这将记录 Mike

数组(列表)也有类似的功能,但通过这种方式处理大对象并处理检查异常会有相当多的转换,而且不像 Gson 那样常见。我们来看看这个。

使用 Gson,序列化工作方式非常类似,但你不需要中介包装器:

Author author = new Author();
author.setName("Mike");
Log.d("MyTag", new Gson().toJson(author));

这将输出 {"mName":"Mike"}

你注意到了吗?mName,而不是 name。Gson 默认使用属性名,而不是 getter 或 setter 方法名。

这种类型的注释——使用m前缀成员变量和s前缀静态变量——被称为匈牙利命名法。你会看到 AOSP 本身完全使用匈牙利命名法,因此许多 Android 开发者也开始使用这种风格。正如接下来将要看到的,这有一个简单的修复方法,但请注意,匈牙利命名法在 Gson(或类似库)中确实存在问题。请注意,匈牙利(或任何任意的)命名法的问题在 Kotlin 中并不存在。如果你的Author类如 Kotlin 示例所示定义,输出将如你所料:{"name": "Mike"}

回到 Java 示例。这个问题很常见,但修改起来也很简单——你可以使用@SerializedName注解任何属性以使用其他名称。例如,假设Author类看起来像这样:

public class Author {
  @SerializedName("name")
  private String mName;
  public String getName() {
    return mName;
  }
  public void setName(String name) {
    mName = name;
  }
}

然后输出将如你所料:{"name":"Mike"}。请注意,这两者皆可,并且它将从注解值反序列化。让我们假设我们已经更新了Author类,用于下一个示例,反序列化:

String json = "{name:'Mike'}";
Author author = new Gson().fromJson(json, Author.class);
Log.d("MyTag", author.getName());

这将输出Mike,就像你所期望的那样。

Gson 的最大好处在于它将以递归策略处理此问题,而org.json类通常不会。

org.xmlpull

另一个常见的传输方式是 XML。如果你曾经为网络编写过代码,肯定见过 XML 或其某个变种——如果你看到充斥着尖括号的数据,那很可能是 XML 或者其衍生物。

虽然 Android 框架中的 Java 标准库没有提供任何用于处理 XML 的内置 API,但 Android 确实提供了一个第三方包,该包是经过维护和审查的:org.xmlpull。该包提供了称为“XML pull 解析器”的对象实例,我们可以用它们来读取和遍历 XML 数据。“Pull 解析器”仅仅是从流中请求标记的解析器之一——相反的“推送解析器”在遇到标记时将标记发送到解析器,而不是在请求时发送。这实际上是一个语义上的细节,对于你实际使用这些类的方式并不重要。

无论如何,我们将提供尽可能基本的 XML 解析示例,但即使这个小样本,你也会注意到它冗长而不容易阅读。如果你从 XML 获取数据并需要解析它,请深入研究org.xml类,或找一个良好的第三方替代方案。以下是基本操作:

欲深入了解org.xml包,请参阅Android 开发者文档

Java 序列化

Java 序列化也许是最容易理解和从远处看似乎是一个很好的策略。然而,在实践中,存在许多弊端——我们将简要地触及它们,但这并不意味着 Java 序列化从来没有适合的情况。

基本前提很简单:将对象实例转换为字节。然后,您可以将这些字节保存到磁盘或通过网络发送,并根据需要重新组装原始对象。然而,这要求任何试图反序列化以这种方式序列化的对象的程序都必须具有对象的确切 Java 定义——Java 虚拟机(JVM)必须具有与对象的完全限定类名匹配的类,并且如果任何方法或属性在任一方面被访问且不匹配,那么您基本上已经到了尽头。

举个例子,想象一下,您从第三方承包商那里继承了您的应用程序代码。他们使用了一个名为Chapter的类,该类具有多种对于表示该章节的对象有用的属性和方法。然而,包名(因此完全限定类名或“规范”名称)包括承包商的域,当您接管应用程序源代码时,您不再拥有匹配的包名。通常情况下,这没有问题,您可以自己定义一个名为Chapter的类,该类可能具有与旧Chapter类相似的名称或功能的方法和属性,也可能没有。然而,承包商违反了社区共识:他们通过将Chapter实例序列化为字节并将该 blob 保存到本地 SQLite 数据库中,将Chapter实例存储在用户的收藏列表中。这意味着您必须维护一个包名与先前的规范名称完全匹配,包括承包商的域。当然,当您想要定义在您自己的应用程序中如何工作的Chapter实例时,这个中间类只存在足够长的时间来从数据库反序列化,转换为Chapter类的更新版本,并将其属性保存到我们更新的数据库中。这不仅令人困惑,因为我们必须向每位新开发者讲述这个故事,而且每次打开 Android Studio 时,未使用的承包商包都位于您的源代码顶部,满是仅仅为了使以前版本的用户可以将其内容迁移到新应用程序而存在的骨架类。

话虽如此,让我们深入探讨一下,如果您决定需要的话,如何使用 Java 序列化。

您需要了解的第一件事是,要使用内置的 Java 序列化,被序列化的类必须实现Serializable接口。现在,与该接口没有约定(没有您必须实现的方法);它只是必须实现接口本身。

您还应提供一个Long序列化 ID,格式为private static final long serialVersionUID = 12345467890;。这里允许使用任何有效的Long,并且它应该在您的可序列化类中是唯一的。大多数 Java 或 Android-aware IDE 可以为您生成正确名称和值的这个值。

接下来,剩下的不多了。你将使用我们在 第六章 学到的 InputStreamOutputStream 类的子类将对象写入文件。让我们看看如何序列化一个简单的类——事实上,我们将使用之前展示过的相同的 Author 类:

现在,你有一个名为“data.obj”的文件,其中包含表示您对象实例的字节。请注意,这是一个完整的复制,可以在反序列化时将其还原为序列化时的状态,只要类文件在您希望反序列化时位于 CLASSPATH 上。读取它也非常熟悉:

就是这样!如前所述,社区的大部分已经将 JSON 视为网络计算的标准序列化策略,但正如您所见,还有很多选择。要更有趣,快速搜索一下“protobuffs”——这是一种新的、谷歌式的数据传输方式。

iOS

有多种方法可以在数据和传输实体之间进行转换。通常情况下,在网络请求期间完成这些转换,最常见的两种数据传输格式是 JSON 和 XML,近年来 JSON 比 XML 更受欢迎。在 Swift 对象实例之间进行这两种格式之间的转换过程非常不同。还有一种几乎完全由 Apple 使用的格式叫做属性列表。它们是专用的 XML,iOS(和 macOS)应用程序可以用来读取和写入数据。

Swift 的更新使得解析数据变得更加直接和不易出错,但仍然存在相当的复杂性。换句话说,我们有很多内容要涵盖,让我们深入了解一下。

序列化和反序列化对象实例

从历史上看,在 Objective-C 的早期日子里,将对象解析为 JSON 是不必要复杂或需要第三方库的。事实上,即使是 Swift 的早期日子也充满了复杂性和困扰。幸运的是,Apple 关注了开发者的需求,并在 Swift 3 中带来了一种新的现代化的 JSON 序列化和反序列化方法。

JSON

让我们从一个看起来像这样的 Swift 对象开始:

struct Author {
	let name: String
}

JSON 表示可能看起来像这样:

{ "name": "Mike" }

在与服务器通信时,通常需要发送和接收类似这样的 JSON 并将其转换为纯 Swift 对象。将应用程序需要后续读取的数据(本章中涵盖的任何其他格式)作为 JSON 保存在本地也很常见。使用之前定义的 Author 结构,将该对象的实例转换为先前显示的 JSON 将看起来类似于这样:

struct Author: Codable {
    let name: String
}

let author = Author(name: "Mike")
let rawData = try? JSONEncoder().encode(author)

通过这个例子,你可以看到我们为我们的对象添加了一个叫做 Codable 的协议。这个协议是两个其他协议 EncodableDecodable 的组合。它们通过一些语法糖和预期值为 Swift 编译器提供了一组功能。

实现 Codable 的对象必须实现 encode(to:)init(from:)。如果没有提供实现,Swift 编译器不会报错,而是通过查看对象的属性并修改名为 CodingKeys 的特殊嵌套枚举类型来自动生成适当的实现。使用这个枚举,编译器可以创建正确的 encode(to:)init(from:) 实现。

你可以称其为魔法,但实际上是编译器在对代码做出实用决策。

在我们的示例中,通过 JSONEncoder 能够将 author 编码为一个 Data 对象,可以用于本地存储在设备上或通过网络发送到服务器。

Codable 还帮助将我们的对象从 JSON 转换(或“反序列化”)回一个普通的 Swift 对象。以下是一个示例:

let rawJson = String("{\"name\":\"Mike\"}").data(using: .utf8)!
let author = try? JSONDecoder().decode(Author.self, from: rawJson)

首先,我们定义了一个名为 rawJsonData 对象,其中包含我们可能从服务器收到的 JSON。接下来,我们将该 JSON 传递给 JSONDecoder 的实例,并在 decode(_:from:) 方法中进行解码。这个方法还需要一个对象类型来尝试将数据转换为;在本例中,我们传递了 Author.self,这是前面示例中定义的 Author 结构体。解码器将对象解码回一个名为 author 的本地 Swift 对象。

注意

现在,如果我们给 JSONDecoder 提供了一个无法将数据解码回的对象类型,它将会产生一个错误。我们在对此方法的调用中标记了 try?,这对于本示例已足够,但在一个发布的应用程序中,你可能需要通过一些用户反馈或日志处理这个问题。

Codable 中还包含更多功能,但超出了本章的范围。现在,让我们来谈谈另一种流行的传输格式:XML。

XML

XML 是比 JSON 更古老的格式。XML 有各种标准,包括一些非常具体的传输类型,如简单对象访问协议(SOAP),但为了我们的示例,我们将使用稍微修改过的 JSON 版本来保持简单。让我们从这个 XML 开始:

<?xml version="1.0" encoding="UTF-8"?>
<author type="human">
	<name>Mike</name>
</author>

这个 XML 描述了一个名为“Mike”的作者,碰巧是人类(也是这本书的作者)。

如果你期望 Codable 提供与 JSONEncoderJSONDecoder 一样简单的简洁性,我恐怕要给你些坏消息:iOS 中的 XMLParser 要复杂得多。

让我们从相同的 XML 开始,并创建一个开始解析的对象:

class SomeObject: NSObject {
    func parseSomeXML() {
        let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
 <author type=\"human\"><name>Mike</name></author>"
        let rawData = xml.data(using: .utf8)!

        let parser = XMLParser(data: rawData)
        parser.delegate = self;
        parser.parse()
    }
}

我们有一个名为 SomeObjectNSObject,它有一个名为 parseSomeXml 的方法,我们已经创建了这个方法。这个方法有一个名为 xml 的字符串变量,其中包含我们之前编码的 XML。在下一行中,我们将它转换为一个使用 UTF-8 编码的 Data 对象。接下来,我们使用 rawData 对象实例化一个 XMLParser。然后,我们将自己设置为处理解析的代理。最后,我们调用 parse() 来开始解析。

如果你现在尝试运行这段代码,会出现错误,因为 SomeObject 目前没有实现它应该实现的 XMLParserDelegate 协议来正确处理解析操作。这是驱动解析的核心,所以让我们深入了解我们正在实现的每个协议方法的内容。

XML 解析是同步的。文档逐个元素地扫描和遍历。在我们的 XML 解析代理中,我们将使用四种方法。它们是:

  1. parser(_:didStartElement:namespaceURI:qualifiedName:attributes:)

  2. parser(_:foundCharacters:)

  3. parser(_:didEndElement:namespaceURI:qualifiedName:)

  4. parserDidEndDocument(_:)

让我们来看看列表中的第一个方法:

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
  qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
    if elementName == "author" {
        author = Author()
        if let type = attributeDict["type"] {
            author?.type = type
        }
    }
}

当我们遍历文档并找到新的 XML 元素时,此方法被调用。在我们之前的示例 XML 中,唯一有效的元素是 authorname。如果我们找到一个新的 author 元素,我们会在 SomeObject 类中新增一个名为 author 的属性,创建一个新的 Author 实例。这作为一个临时存储,用于在解析该元素时存储所找到的数据。

发现的第一段数据是属性 type。如果你回忆一下,我们的作者有一个类型,在我们特定的 XML 元素中,对于 Mike 作者,这是 human。我们将临时 Author 实例的 type 属性设置为此值。

随着文档的解析,我们将找到的下一个元素是 name。一旦该元素开始,我们无需执行任何特殊操作,但是我们需要捕获 <name></name> 标签之间的数据。我们列表中的下一个方法 parser(_:foundCharacters:) 允许我们像这样执行:

func parser(_ parser: XMLParser, foundCharacters string: String) {
    characters += string
}

在这个方法的主体内部,我们将找到的任何字符存储在另一个新增的属性 characters 中,该属性添加到我们的 SomeObject 实例中。这个属性充当了一个临时缓冲区,用于在进程的下一步中保留字符,该步骤在找到此标签的闭合元素时发生:

func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
  qualifiedName qName: String?) {
    if elementName == "name" {
        author?.name = characters
    }
    characters = ""
}

当解析器找到元素的闭合标签时,将调用此委托方法。在我们的情况下,这是 </name>。我们将之前的字符串缓冲区 characters 添加到正在构建的 author 对象的 name 属性中。

最后,我们将创建的内容通过 print() 语句显示在 XML 文档的末尾:

func parserDidEndDocument(_ parser: XMLParser) {
    print(author.name)
    print(author.type)
}
注意

如果在 XML 文档中有更多项,我们将继续构建更多的作者实例,并且我们需要在开始新元素并创建临时对象时保存它们。

这是完整示例:

struct Author {
    var name: String?
    var type: String?
}

class SomeObject: NSObject {
    var author: Author?
    var characters: String = ""

    func parseSomeXML() {
        let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
 <author type=\"human\"><name>Mike</name></author>"
        let rawData = xml.data(using: .utf8)!

        let parser = XMLParser(data: rawData)
        parser.delegate = self;
        parser.parse()
    }
}
extension SomeObject: XMLParserDelegate {
    func parser(_ parser: XMLParser, didStartElement elementName: String,
      namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict:
      [String : String] = [:]) {
        if elementName == "author" {
            author = Author()
            if let type = attributeDict["type"] {
                author?.type = type
            }
        }
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        characters += string
    }

    func parser(_ parser: XMLParser, didEndElement elementName: String,
      namespaceURI: String?,
      qualifiedName qName: String?) {
        if elementName == "name" {
            author?.name = characters
        }
        characters = ""
    }

    func parserDidEndDocument(_ parser: XMLParser) {
        print(author?.name)
        print(author?.type)
    }
}

我们已经仔细查看了直接的 XML,但是对于基于 XML 的内容,稍微少一些冗长的呢?

属性列表

属性列表或 plist 文件是一种基于 XML 的格式,传统上用于应用程序中的数据序列化和反序列化。让我们看看如何从文件系统中读取 plist 文件:

// Our .plist file on the filesystem
let plistURL = URL(...)!
guard let plistData = Data(contentsOf: plistURL) else { return }

let object = PropertyListDecoder().decode(Author.self, from: plistData)

这看起来与我们的 JSON 解码示例非常相似。请注意 PropertyListDecoder 的使用,它在功能上类似于 JSONDecoder,但处理的是 plist 数据而不是 JSON 数据。

写入 plist 文件与 JSON 示例类似:

let author = Author(name: "Mike")
let data = try? PropertyListEncoder().encode(author)
data?.write(to: ...) // save the plist file to the device

这将以 XML 格式输出 plist 文件。对于导出属性列表文件,还有其他可用的选项。实际上,有三种常见类型可以导出:XML、二进制或 Open Step。

这里是如何导出为二进制 plist 文件的示例:

let author = Author(name: "Mike")

let encoder = PropertyListEncoder()
encoder.outputFormat = .binary // set the output type to binary data
let data = try? encoder.encode(author)

data?.write(to: ...) // save the plist file to the device

iOS 笔记

尽管 Codable 看起来像是魔法,但在编译器层面确实发生了一些非常有趣的事情,可以选择覆盖。例如,要在 JSON 中使用与 Swift 定义的 Author 结构中声明的名称不同的属性名称,以下代码可以起作用:

struct Author: Codable {
    let name: String

    private enum CodingKeys: String, CodingKey {
        case name = "something"
    }
}

let author = Author(name: "Mike")
let rawData = try? JSONEncoder().encode(author)

此示例将 JSON 输出中的 name 属性更改为 something。运行此代码将输出以下 JSON:

{ "something" : "Mike" }

此外,可以为完全手动的序列化和反序列化提供自己单独的 EncodableDecodable 方法 encode(to:)init(from:) 的实现。

我们学到了什么

Android 和 iOS 在序列化和反序列化诸如 XML 和 JSON 等数据方面有着非常相似的方法。同样,还有一条类似的路径,可以将 Java、Kotlin 或 Swift 对象序列化为本地平台格式。尽管语言和框架存在差异,但这是两个平台都让跨平台操作变得更加简单的领域。

在接下来的章节中,我们将解释如何扩展基础框架对象(以及其他对象),以添加一些功能。让我们来看看!

第十三章:扩展

有时候,Android 和 iOS 提供的功能并不足够,有时候,第三方库或内部对象提供的功能也不够。当然,您可以创建子类,但这并不总是可行的。

幸运的是,这两个平台都有增强现有对象的方法。

任务

在本章中,您将学习如何为现有的 API 添加功能。

Android

虽然本书的大部分内容都以 Java 作为 Android 开发的语言,默认使用 Java,而且大部分 Android 开发确实都是使用 Java,但我们现在必须提到,在 Android 框架中,只有 Kotlin 能够支持扩展。这是什么意思?我们来定义一下“扩展”这个词的含义:“修改或添加类的功能”。然而,在带有 Kotlin 扩展的项目中,Java 类也可以利用扩展功能!

为现有的 API 添加功能

在 Java 中,如果导入一个名为 CalendarPicker 的第三方库,您基本上只能使用该库提供的公共 API。当然,您可以修改源代码,但此时您已经创建了自己的 CalendarPicker,它不会是与原始类相同的实例。您还可以创建 CalendarPicker 的子类,但同样的注意事项适用。不可能添加或更改现有的 CalendarPicker API 的功能。

在 Kotlin 中,这不再成立。不仅可以向 CalendarPicker 添加一个方法,使得所有 CalendarPicker 实例都可以访问,而且实际上可以向标准库类如 LinkedList,甚至带有泛型类型如 HashSet<String> 的类添加方法。事实上,Kotlin 中甚至有一个 Any 类,可以扩展以向所有类实例添加功能。Kotlin 扩展的经典示例正是这样做的:

fun Any?.toString(): String {
    if (this == null) {
      return "null"
    }
    return toString()
}

这将 Kotlin 的 ? 空安全操作符添加到通用 Object.toString 方法的主体中。通过添加前述代码,您可以安全地调用任何对象的 toString 方法;如果对象为 null,则不会抛出 NullPointerException,而是会返回字符串 "null"。相当强大!

您可以将扩展添加到任何您喜欢的包或目录结构中(只要它是 Kotlin 文件,在src目录中)。例如,在项目中右键单击任何包,选择“New > Kotlin File/Class”,然后随便取个名字——比如现在叫“extensions”(Android Studio 将附加“.kt”扩展名并显示不同的图标,表示它是一个 Kotlin 文件)。

您可以向此文件或任何文件添加多个扩展函数,并且所有这些函数都可以从项目中的任何 Kotlin 类访问。假设您添加了以下的 String 辅助函数:

fun String.from(start:String): String {
  val index = indexOf(start) + start.length
  return substring(index)
}

然后在某个 Kotlin 类中,您可以调用类似这样的内容并获得预期的结果:

val original = "Hello world!"
val modified = original.from("Hello")
Log.d("MyTag", modified)

查看开发者文档以获取有关 Kotlin 扩展的更多信息。

iOS

Swift 的一个核心语言特性是通过扩展为类型添加新功能。事实上,这也是 Objective-C 语言运行时的一个重要部分,尽管名称为“category”而不是“extension”。这种能力在 Swift 中得到了很好的实现,实际上在实践中非常普遍。

添加功能到现有 API

这里有一个简单的例子:

class ExtendableObect {
    // ...
}

extension ExtendableObect {
    func helloTacos() {
        print("Hello, tacos!")
    }
}

首先,我们定义一个名为ExtendableObject的类。在我们的例子中,该类没有主体,因为它对示例并不重要。真正的示例核心在于接下来我们定义扩展的部分。

我们在ExtendableObject上使用extension ExtendableObject { ... }进行声明。在该定义中,我们声明和定义一个名为helloTacos()的新方法,方便地将字符串Hello, tacos!打印到控制台。我们可以通过实例化对象并像调用对象的任何其他方法一样调用helloTacos()来使用我们新定义的方法,如下所示:

let object = ExtendableObject()
object.helloTacos()
注意

有一件非常重要的事情需要注意。您可以向现有对象类型添加新方法和函数,但无法添加新的存储属性,除非导入 Objective-C 运行时并使用关联值。一般情况下不建议这样做,除非是非常罕见的情况。

用于代码组织的扩展

由于扩展在 Swift 语言中是如此重要的一部分,因此在 Swift 项目中,通常会使用扩展来组织代码。通常,您会看到在类的底部将协议的实现放在扩展中。

这里有一个例子:

protocol AwesomeProtocol {
    func beAwesome()
}

class ExtendableObect { /* ... */ }
extension ExtendableObect: AwesomeProtocol {
    func beAwesome() {
        print("You are awesome!")
    }
}

首先,我们声明一个名为AwesomeProtocol的新协议,其中包含必需的方法beAwesome()。接下来,我们声明ExtendableObject。可以直接通过将其作为ExtendableObject定义的一部分来实现该协议:

class ExtendableObject: AwesomeProtocol { /*  ... */}

然而,由于 Swift 的协议导向性质,随着对象必须实现的协议数量增加以及应用程序复杂性的增加,这种方法可能变得难以控制和维护。我们之前的例子中,通过分离扩展,使得我们可以将协议符合性放在一起,便于将来查找和维护。

这完全是一种主观的代码组织方式,但在 Swift 社区中算是一种事实上的标准。随意选择使用或不使用!

谈到 Swift 中的协议——也可以对其进行扩展!让我们来看看。

扩展协议

在 Swift 中,扩展类和结构与扩展协议略有不同。语法类似,但背后的机制不同。以下是如何在 Swift 中扩展协议的方法:

protocol ExtandableProtocol {
    func doSomething()
}
extension ExtendableProtocol {
    func printSomething() {
        print("Something :)")
    }
}

class SomeObject: ExtendableProtocol {
    func doSomething() {
		// ...
    }
}
注意

我们正在扩展的协议不是在 Swift 中编写的 Objective-C 协议——它是一个纯粹的 Swift 对象。这很重要,因为只有纯粹的 Swift 协议可以进行扩展。也不可能扩展以@objc为前缀的方法。

本例创建了一个名为ExtendableProtocol的协议,其中声明了一个doSomething()方法。接下来,协议的扩展添加了一个名为printSomething的新方法,并将其声明和定义为扩展的一部分。最后,定义了一个类SomeObject来实现ExtendableProtocol协议。

调用SomeObject的实例可能看起来像这样:

let object = SomeObject()
object.doSomething()
object.printSomething()

相当简单,与类和结构扩展并没有太大的区别,对吧?

协议扩展的强大之处在于当您需要跨不同基类的对象共享通用功能时,它变得显而易见。看看这个例子:

protocol Typeable { /* ... */ }
extension Typeable {
    func printType() {
        let objectType = String(describing: type(of: self))
        print("This object is a type of \(objectType)")
    }
}

class BaseClassA { }
class BaseClassB { }
class BaseClassC { }

class TacoTruck: BaseClassA, Typeable { }
class Dog: BaseClassB, Typeable { }
class Cat: BaseClassC, Typeable { }

let tacoTruck = TacoTruck()
let dog = Dog()
let cat = Cat()

tacoTruck.printType()
dog.printType()
cat.printType()

首先,我们声明一个协议Typeable,并在该协议上声明一个扩展,输出对象的类型。接下来,声明三个完全不同且没有任何关联的基类。然后,声明三个更多的子对象,每个都继承自不同的父类,但都实现了之前的协议Typeable。然后,我们实例化每个类。最后,我们调用同一个方法printType,这个方法只在一个地方声明,但对于每个单独的对象都能完全相同地工作。

协议扩展是 Swift 中的一个强大补充,它使代码更清晰、更可维护、更具架构性。

我们学到了什么

在 Java 中不容易添加功能,但 Kotlin 和 Swift 都提供了扩展现有对象的内置机制。这两种方法都有局限性,但两种语言和平台都能提供的功能确实有明确的用例。

在我们关于任务的最后一章中,我们的注意力转向了不是向建设方向,而是通过测试来维护我们已经构建的内容。两个平台都围绕测试提供了优秀的工具。让我们深入了解一下可用的内容!

第十四章:测试

开发人员编写代码。通常他们会写很多代码。对象和其他服务之间的依赖关系网经常复杂且难以辨别;对象依赖于其他对象以正确运行。即使在相对简单的代码库中的一个地方进行更改,也可能导致另一个代码库的错误或崩溃。您的应用程序这座纸牌屋可能会很快倒塌。

测试在这一切中的位置在哪里?测试是为开发人员提供信心的一种方式,确保所做的更改不会无意中影响应用程序的其他部分。理想情况下,这些测试应该是自动化和确定性的,不受人类不可预测的主观影响,因为错误和判断不佳经常出现。幸运的是,大多数现代平台都内建了测试功能。Android 和 iOS 都有功能完备且非常强大的测试工具,可用于辅助开发代码。让我们看看这些工具是如何工作的。

任务

在本章中,您将学习:

  1. 设置并运行单元测试。

  2. 设置并运行集成测试。

Android

AOSP 定义并区分了几种不同类型的测试:

单元测试

这些是高度专注的测试,运行在单个类上,通常是该类中的一个单独方法。如果一个单元测试失败,您应该清楚地知道问题出现在代码的哪个位置。它们的精度较低,因为在现实世界中,您的应用程序远不止执行一个方法或类那么简单。它们应该足够快速,以便在每次更改代码时都能运行。

集成测试

这些测试用例测试多个类之间的交互,以确保它们在一起使用时的预期行为。一种组织集成测试的方法是让它们测试单一功能,比如保存任务的能力。它们测试的代码范围比单元测试大,但仍然优化以保证快速执行而不失真实性。

端到端测试

测试多个功能组合在一起工作的能力。它们执行速度较慢,因为它们测试应用程序的大部分并且紧密模拟真实使用情况。它们具有最高的准确性,并告诉您您的应用程序实际上能正常运行。

在 Android 中进行测试可能与您之前遇到的测试有所不同。术语和分类有一些重叠,并且并不总是使用您在其他计算机科学领域可能找到的相同语义。

让我们从单元测试开始。Android 中的单元测试与您在任何其他框架或语言中遇到的单元测试非常相似。通常我们使用一个叫做 JUnit 的框架,但即使没有也没有严格的要求。单元测试应该非常专注,并且识别代码中的一个非常具体的点,因此,如果一个单元测试失败,修复起来应该很简单。例如,如果您有一个方法来计算两个数的乘积并返回结果,一个单元测试可能会用一些预先确定的数值调用这个方法,以确保输出符合预期。假设您的类看起来像这样:

你可能会从一个极其简单的测试开始,比如:

请记住,像这样的本地测试放在 IDE 生成的src父目录下的test文件夹中。仪器化测试——在实际或模拟设备上运行的测试——则放在androidTest子文件夹中。查看图 14-1 及其相应文本以进一步了解。

尽管这个测试看起来很简单,但如果开发人员后来对类或方法进行了重构,则这是至关重要的。然而,很明显,像这样非常基本的测试的实用性是有限的。即使是像Maths.multiply这样简单的方法,也要考虑可能产生有趣结果的所有可能情况。

Java 中的静态类型可以通过一个非常出色的方式减少这些异常值——由于参数的数据类型都是int,我们从不需要担心null值,或者比 32 位有符号数更大或更小的值。

使用 Android 测试指南,单元测试也被称为“小测试”,框架建议你的测试中 70% 应该是小/单元测试。

向上移动测试金字塔,我们发现中等和大型测试。中等测试通常与所谓的集成测试相关联——这些测试依赖于多个逻辑块、类或方法来生成某些预期结果。Android 测试指南建议你的测试中有 20% 应该是中等测试。例如,一个中等的集成测试可能检查登录凭据。提供的字符串是否为空?它们是否符合预期的模式,比如电子邮件格式或密码掩码?如果用户数据位于设备本地,集成测试可能会检查认证是否成功,而使用远程认证的应用程序可能仅需检查方法是否成功发出登录 HTTP 请求,并可能测试该请求的适当头部、正文、加密和目标。

一些集成测试可能是有仪器的。仪器化测试是在真实设备或模拟器上运行的测试,它接收手势事件,依赖系统时钟,并将像素绘制到屏幕上。

Robolectric 库在 Android 开发者中非常受欢迎,是支持集成测试的一个很好的选择。截至 2018 年的 Google I/O,Robolectric 4 与 AndroidX 测试一同发布,取代了大部分 Robolectric 库。这对用户来说大部分是不透明的,并且不应该对你现有的测试代码造成太大的干扰。如果你想完整了解 AndroidX 与 Robolectric,它们如何共同工作,以及如何在你的特定情况下最大化使用它们,请查阅AndroidX 文档

我们建议您使用 Robolectric 4,并确保您使用的文档适用于该版本。Robolectric 和 AndroidX 测试为您完成了大部分样板代码,包括对 Android 框架类的一些模拟、Activity 生命周期仿真以及其他支持功能,如Shadows系统,它允许您轻松地模拟任何现有类并重新分配逻辑。例如,您可以创建一个ShadowThread类,在调用start时只在主线程上运行自身,而不是启动一个新线程,或者创建一个ShadowThreadPoolExecutor,在调用时立即同步和串行地运行其队列,这两者都可以显著降低测试异步应用程序的难度。

使用 AndroidX 和 Robolectric 进行集成测试需要你熟悉一些额外的库。我们将提供一些示例,但是完整的测试教程超出了本章的范围。如果想深入了解 Android 测试,请确保访问Android 开发者文档

最后我们有大测试,也称为端到端测试。端到端测试通常是仪器化的,可能涵盖整个“活动”。例如,考虑一个具有明信片功能的应用程序,用户可以使用设备的摄像头捕捉图像;应用程序用其他图形或图形信息装饰图像,将其转换为流,并上传图像数据到服务器,用户经过身份验证后保存图像以便检索或分享。端到端测试可以启动此过程,并测试图像是否响应,并且仅可用于分享操作。

仪器化测试有时被称为“UI 测试”,但实际上,在仪器化环境之外运行的集成测试也可能符合条件。然而,当测试被仪器化时,你将需要使用另一个工具集:Espresso。Espresso 具有流畅的语法和功能式范式。Espresso UI 测试库可以模拟点击、滚动和文本输入,甚至允许你记录用户输入以运行并比较 UI 输出与你的预期。如果想了解更多关于 Espresso UI 测试的信息,请访问文档

在接下来的内容中,您将了解如何编写和执行单元测试和集成测试。

创建项目后,在 Android Studio 中立即发现测试目录可用。查看和交互这些目录的最简单方法是使用项目显示下拉菜单的 Android 视图。

打开您的应用程序模块(通常只命名为“app”),然后进入“java”子目录。在其中,您会找到至少三个目录。首先看到的是您的主要源代码,它的名称与您的包名称相同(例如“my.site.appname”)。在其下会有两个目录,名称相同,但是以一个淡色标签在括号中显示,“(test)”或“(androidTest)”(图 14-1)。

Android Studio 测试目录

图 14-1. Android Studio 测试目录

第一个文件夹将包含所有您的源代码。带有“(test)”标签的文件夹将包含您的单元和集成测试。最后带有“(androidTest)”标签的文件夹是用于 Espresso UI 测试,本章我们不会涉及。

无论您将初始项目配置设置为默认的 Java 还是 Kotlin,您都会看到相同的结构。如果稍后添加相反类型的文件(如果您的项目已设置为 Kotlin,但添加了 Java 文件,反之亦然),您将会发现这些目录被复制了一次——每种语言一个用于源代码、单元和集成测试,以及 UI 测试。

设置和运行单元测试

基于 JUnit 和 Robolectric 的测试都将放在标记为“(test)”的目录中,从这里开始我们将简称为“测试目录”。

如果您打开此文件夹,您会发现已经创建了一个示例测试类,通常命名为“ExampleUnitTest”。它还会导入基本的 JUnit assert 静态方法。它可能已经包含了一些简单的内容,例如:

这是一个单元测试的最基本实现。单元测试验证代码的一个功能单元。上面的例子并不是非常有用,因为所有语句都在测试本身中,但让我们考虑一个稍微更有用的例子:

要测试这个简单的类,我们可以像这样编写一个单元测试:

注意 assert 方法期望一个“实际”值(由您的逻辑生成)和一个“预期”值(该逻辑的结果应该产生什么)。这些值会进行比较,如果它们不匹配(或者不匹配特定 assert 方法如 assertTrueassertFalse 隐含的结果),它们会简单地抛出一个 Exception;当命题失败时,这些 Exception 实例会被测试框架“捕获”,以便组织和跟踪哪些测试通过或失败。

对于过程式代码,在相同的输入总是产生相同输出的情况下,单元测试几乎总是合适且通常很有帮助。对于基于状态的环境(如 Java 和 Kotlin 中的面向对象编程),情况可能会变得有点棘手。

当你将你的代码集成到像 AOSP 这样的框架中时,你将依赖某些框架特性、类和值,并希望“集成”你的逻辑。这些测试可能会变得更加困难,特别是处理像Activity交互和生命周期这样的事物时,从头开始管理可能会变得极其困难。正因如此,Robolectric 库被创建出来——它提供了一种简单的方式来与像前述的框架特性交互。

注意,Robolectric 正在转变为成为整体“Android 测试”框架的一部分,而在本书出版之前的最后一次 Google I/O 活动上,关于 Robolectric 和 AndroidX 测试的发言人似乎表明,Robolectric 部分很快将被移除,以支持 Android 特定的 API,与它们合并,或者用新的 AndroidX 包中的相同 API 替换。到目前为止,使用 AndroidX 测试似乎确实有一些真正的价值。使用 Espresso 语法进行本地和仪器化 UI 测试对任何团队来说可能是一个巨大的收益,但同时,即使一年后,我们发现一些可能正在解决但阻止我们团队完全迁移的非常规问题。

当测试必须与其他代码集成,特别是像 Android 使用的用于在屏幕上绘制像素的不透明工具集时,你可能会在不仅有帮助而且可行的测试上受到严重限制。在编写测试时,请考虑投资回报率(ROI)。你经常会听到术语“覆盖率”;这通常表示你的代码被测试了多少,但实际上有多种计算覆盖率的方式——有些人使用被引用的总行数,其他人考虑语句甚至逻辑分支。如果一个团队中只有一两个人,一个需要功能性工作以成功的新产品需要 100%的覆盖率,你(和你的团队)可能需要确定针对你特定需求的测试的最佳和最现实的方法。根据你对覆盖率的定义,可能不可能达到 100%的覆盖率。在前面的Calculator.add测试中,我们可以假设类似示例中的一个(或几个)测试足够了,我们认为没有人会建议测试任意两个整数相加的每个可能值,但是你可能想测试当在系统上超出最大整数值时添加两个整数会发生什么,或者当传入负值或空值时会发生什么。正如我们所说,由你和你的团队来定义测试范围、要求、覆盖率和最佳实践,这些在组织之间甚至在组织内的团队或个人之间都可能差异巨大。

设置并运行集成测试

正如之前提到的,集成测试证明了你的逻辑作为逻辑流的表现如何,而不是单个单元。让我们用一个例子来介绍主要内容;这里使用了 Robolectric 的影子和注解,以及 AndroidX ActivityScenario 用于 Context 引用和生命周期:

让我们来分解这段代码:

  1. @RunWith 注解简单地告诉测试系统我们正在使用 JUnit4 运行器。

  2. @Config 注解可用于多种配置设置,但在本例中,我们指定要使用 ShadowAsyncTask 类。假设这个类重写了 AsyncTask 的默认行为,将作业提交到现有线程上而不是新线程。这意味着我们使用 AsyncTask 进行的任何异步操作现在都变成了同步和串行,测试行为变得更加可预测和可控。

  3. @Before 注解表示一个在调用任何测试方法之前触发的设置方法。在我们的设置方法中,我们初始化 AndroidX 的 Intents 类,以便我们可以检查或拦截待处理或接收的意图,以此来确定按钮点击是否发送了启动某个 Activity 的意图。

  4. @After 注解是 @Before 的相反,并在每个测试方法返回后执行。在这里,我们简单释放了 Intents 功能并取消了成员级别的 Activity 引用。

  5. 每个测试方法都应该标记为 @Test。方法的命名可能看起来笨拙,但遵循了“给定-当-然后”的约定,因此我们可以区分可能乍一看非常相似的功能部分。在这些具体的测试中,我们使用了 Espresso API 流畅的风格来检查几个 View 实例是否可见,以接收和提交用户输入的凭据。

请注意,在上述例子中,我们在本地测试中使用 Espresso API,而不是仪器化测试。在 AndroidX 之前,这是不可能的,所有 Espresso 代码只能在设备或模拟设备环境中运行。

iOS

Xcode 内置了一些很棒的工具来促进 iOS 内的测试。主要的测试分为单元测试和 UI 测试,UI 测试有点类似于自动化集成测试,具体取决于测试的运行方式和设置。话不多说,让我们开始编写一些单元测试。

设置并运行单元测试。

为了为 iOS 应用程序编写和运行单元测试,首先将新的目标添加到 Xcode 项目中非常重要。您可以通过在应用程序菜单栏中转到 File > New > Target 来完成此操作。从那里,您可以添加一个新的 iOS Unit Testing Bundle 以创建 Xcode 可以构建和运行的单元测试目标。按照提示(通常可以使用默认值)并单击 Finish 以将目标添加到项目中。这将创建一个新文件夹以包含单元测试本身,并且—根据项目的设置方式—左侧 Xcode 文件树中Products文件夹中将添加一个新的带有扩展名.xctest的目标。这是构建的捆绑包,其中包含所有单元测试和库;可以像您的应用程序捆绑包一样对其进行定位,这意味着可以添加特定于测试捆绑包的库,例如使编写测试更容易或具有不同功能的第三方库。

一旦添加了测试目标,您可以通过转到 File > New > File,选择 Unit Test Case Class,并为类和文件命名来向项目添加一组新的测试套件。在 Xcode 中命名文件的惯例方法是根据被测试对象命名文件和类。这不是一成不变的规则,但通常在大多数项目中都会遵循。例如,一个名为Calculator的类可能会有一个相应的名为CalculatorTests的测试套件,针对它运行不同的测试。

惯例优于配置是在 Xcode 中进行测试的关键,你会一次又一次地看到这一点,特别是在添加要运行的测试时。让我们看看这是什么样子。

在我们假设的CalculatorTests类中,我们可以通过打开CalculatorTests.swift并向该类添加一个以test开头的新方法来添加一个新的测试。例如:

func testExample() {
	...
}

惯例的部分是,Xcode 通过方法名称以“test”开头来识别测试用例文件中的每个测试。如果您通过转到 Product > Test 来运行测试套件,那么该测试将运行,并且在 Xcode 中它旁边将会有一个绿色的勾号,表示测试成功。

让我们来看看整个测试用例类,而不是单独的测试。例如,目前,CalculatorTests看起来像这样:

class CalculatorTests: XCTestCase {

    override func setUp() {
    }

    override func tearDown() {
    }

    func testExample() {
    }
}

这里有几件事情需要注意。首先,CalculatorTests继承自XCTestCase,这是所有单独测试作为测试用例方法存在的地方。我们还在文件底部有我们的testExample测试。这是一个将运行并需要某种断言来检查成功或失败的测试。

有许多可能的断言,例如:

  • XCTAssert(),接受返回布尔值的表达式

  • XCTAssertFalse()XCTAssertTrue(),用于检查特定的布尔值

  • XCTAssertEquals()XCTAssertNotEqual(),用于检查对象之间的相等性

  • XCTAssertNil()XCTAssertNotNil(),用于检查nil条件

要使用这些断言宏,只需像这样将它们添加到方法体中:

func testExample() {
	let success = false
	XCTAssertTrue(success)
}

这将生成一个失败的测试,因为在前一行上设置了successfalse。当验证有效性并且为true时,该测试失败。断言使用起来相当简单,你可以在一个测试中串联多个断言。

在测试用例中,还有setUp()tearDown()方法。这些是标准方法,在此类中运行每个测试时调用,用于设置测试和测试主体,以及在测试结束后进行清理。例如,我们可以创建正在测试的Calculator的实例,并将其存储在一个sut变量下,以便我们可以轻松地引用它,并在完成后进行清理,这样我们的测试方法就不会被构造逻辑所混淆。这里有一个例子:

class CalculatorTests: XCTestCase {
    var sut: Calculator!

    override func setUp() {
        self.setUp()
        sut = Calculator()
    }

    override func tearDown() {
        sut = nil
        self.tearDown()
    }

    func testTwoPlusTwoEqualsFour() {
        let result = sut.enter(2).add(2)
        XCTAssertEqual(result, 4)
    }
}

有时需要在异步功能上运行测试。这很困难,因为测试方法将在异步代码中实际运行之前返回。这在调试和解决问题时可能非常困难。然而,通过测试框架中的测试期望,这并不太难。这里有一个例子:

func testAsynchronousCode() {
	let expectation = XCTestExpectation(description:
	  "Asynchronous code will return true.")

	sut.enter(2).add(2).shareToTwitter { (success) in
		if success {
			expectation.fulfill()
		} else {
			XCTFail("Sharing to Twitter did not work.")
		}
	}
	waitForExpectations(timeout: 2.0) { (error) in
		XCTFail("Test failed.")
	}
}

在这个例子中,我们首先创建了一个在执行的异步代码中要满足的期望。在我们假的shareToTwitter(:)方法中,我们传递了一个闭包,在分享我们计算器的结果到 Twitter 之后执行。我们检查success是否为true,如果是,我们调用期望的fulfill()方法来告诉 Xcode 测试通过,或者我们调用XCTFail()来指示测试失败。最后,我们调用waitForExpectations(timeout::)来指示我们正在等待期望以两秒的超时值完成。如果在此期间我们的期望没有完成,我们也会调用XCTFail()来指示测试失败。

希望您现在明白单元测试并不像您可能以前在 Xcode 和 iOS 中所想象的那么可怕。让我们来看看另一个被驯服的怪物:通过 iOS 中的 UI 测试来进行集成测试。

我们学到了什么

我们已经看过了 Android 和 iOS 中测试是如何作为完整系统相互配合来测试断言的,这种方式一致、快速且无痛。编写单元测试通常被忽视,但随着时间的推移,您会发现,对应用程序变更的稳定性与现有行为期望的测试初始投入会带来未来的回报。

现在我们已经介绍了 Android 和 iOS 不同技术如何相互交互,让我们做些有趣的事情。让我们构建一个应用程序。查看第二部分开始吧!

第二部分:示例应用

本书的第二部分从这里开始,我们将使用第一部分的任务,在两个平台上创建功能性应用。在这里,您将看到第一部分的理论代码如何实际运用。

虽然我们遵循最佳实践和推荐模式,但请记住,这本书主要是一个交叉参考。为了展示相似之处,我们可能选择遵循比某个平台上通常更通用的模式。我们并不主张这里呈现的应用一定是编程效率或效果最高的,但我们希望它们展示了我们在这项工作中要强调的通用实践。

祝愉快编码!

第十五章:欢迎和环境设置

比较原生开发和跨平台工具

让我们列出一些事实并得出一些初步的结论,以便清楚地理解。

当我们说“原生开发”时,我们指的是针对每个平台使用由平台维护者支持和推广的语言、框架和 IDE 进行编程。这些平台包括 Android 和 iOS。

对于 Android,维护者是 Google。我们使用“维护者”而不是“所有者”,因为它真的是开源的——事实上,亚马逊的 FireOS 是 Android OS 的一个分支。有时,我们用 AOSP 这个缩写来指代 Android 开源项目。编程语言是 Java、Kotlin 或者两者混合(Kotlin 生成 Java 字节码,在运行时没有区别)。你理论上可以用任何生成 Java 字节码的语言来创建 Android 应用——事实上,我们的一个朋友曾完全用 Scala 编写过 Android 应用。不过,在本书中我们只使用 Java 和 Kotlin。这个框架没有一个特定的名称,通常被称为“Android 框架”。

当我们说“原生开发”时,我们并不是指在框架内部看到的原生,这可能表明系统提供的功能,例如系统时钟(非常重要)、线程或文件系统实现等。我们也不是指 Android 原生开发工具包(也称为 Android NDK),它允许您从ActivityService中调用用 C 或 C++编写的代码。

因此,既然我们已经明确了这一点,让我们来比较一些跨平台工具。有许多不同的产品可用于跨平台开发,但实际上只有少数几种方法。

Web-Based

像 PhoneGap 这样的产品允许开发者使用 HTML、CSS 和 JavaScript 编写网页,这些网页在每个平台上都使用基于 Web 的视图组件进行渲染。我们发现,虽然从理论上讲这样做更简单,因为只有一个代码库,并且这些技术和语言都是众所周知和广泛支持的,但实际上很少能够仅用一个代码库解决问题——每个平台的特异性往往需要大量的条件逻辑处理。同时,跟进每个原生开发框架的更新或添加也可能会很困难。

其他

还有许多旨在简化跨平台开发的其他产品。Google 有一个名为 Flutter 的工具,使用 Dart 语言和其自己的内核从同一代码库创建 Android 和 iOS 应用程序,并且由于不依赖现有框架,能够以双倍于标准帧率的速度运行应用程序,从 60 帧到 120 帧每秒。在足够强大支持的设备上,滚动可以感觉几乎物理——就像你真的在平滑表面上拖动一张纸一样。Flutter 的缺点是几乎不可能真正拥有一个适用于两个平台的统一代码库——Android 用户期望不同的小部件、对输入的反应、过渡等等,与 iOS 用户相反。同样地,由于不利用设备上已有的 Android 或 Cocoa 安装,Flutter 应用程序所需的大小几乎总是会比本地开发的应用程序大得多。

另一种方法——这是 Facebook 率先采用的——是 React Native。React Native 使用 Facebook 的专有 JavaScript 版本从单一代码库中创建两个平台的应用程序。除了前面提到的一些缺点外,你将一个很大的部分控制权交给了一个完全不相关的第三方(Facebook)。如果 Facebook 想要开始透明地完全抓取开发者的指标,你几乎无能为力。我绝不是在暗示它这样做,或者将来会这样做;这只是跨平台工具所带来的一种可能不会立即显现的成本的一个极端例子。

有其他工具,作为作者,我们不想浪费时间,也不想浪费你的时间批评其他人做的真正了不起的工作来填补这个空白。我们只希望你理解我们为什么真诚地认为在每个平台上并行开发——使用每个平台的语言、框架和工具集等等——不仅能带来巨大的好处,而且在我们看来,并不会使成本、时间甚至关注的投入翻倍。正如我们在本书中所展示的,这两个平台之间确实有很多相似之处,使用各自的本地工具为每个平台编写单一应用程序并不需要像你可能想的那样繁琐的任务。

环境设置

为了与 Android 和 iOS 一起工作,我们首先需要设置好我们的环境。值得庆幸的是,没有什么能阻止在同一台机器上安装 Android Studio 和 Xcode。Android Studio 可以安装在多个平台上,但因为我们使用的是 Xcode,苹果的 iOS 开发 IDE,我们需要使用一台能够运行 macOS 的机器来使用 Xcode。让我们首先为 Android 开发设置好环境。

Android 设置

在 Android 开发的这一阶段,我们几乎将所有过程迁移到了一个单一的集成开发环境:Android Studio。这是由 Google 支持和推荐的,Android 的维护者。从技术上讲,当然可以不使用 Android Studio,但那是一个相当高级的话题,可能只在非常特定的场景下使用,我们确实见过(这样做很混乱)。现在,让我们假设您将继续使用 Android Studio 开始工作。

看看如何开始。对于 Android,安装直接转入新项目创建,因此我们将整个过程组合为一组指令。

设置 Android Studio

一度,设置 Android 开发环境相当复杂且涉及多个组件。随着 Android Studio 的出现和成熟(Android 开发推荐的 IDE),这一切变得几乎是轻而易举的。它甚至自带了嵌入式 JDK(Java 开发工具包),因此只需一次下载和初始设置。

在撰写本文时,Android Studio 可以从 https://developer.android.com/studio 下载,但如果您是一个远在未来的读者,可以随意在网络上搜索“下载 Android Studio”,假设 URL 被机器人取代。

一旦下载了 Android Studio,立即启动它!我们将详细介绍从下载到首次运行的过程,此文撰写时为 2019 年夏季。未来这个序列可能会有所变化,但基本前提应该保持不变,并且通常提供不会直接影响开发的选项。如果您的屏幕显示与此步骤不符,请自行判断。

程序可能会要求您从先前的安装中导入设置;如果这是您首次安装,请跳过导入步骤,继续到 图 15-1。

导入设置屏幕

图 15-1. 导入设置屏幕

现在应该启动 Android Studio 的初始化过程。观察加载进度条。完成后,您可能会看到欢迎屏幕,如 图 15-2 所示。

欢迎屏幕

图 15-2. 欢迎屏幕

然后您可以选择安装类型,如 图 15-3 所示。

安装类型

图 15-3. 安装类型

现在只需选择标准。稍后可以进行自定义设置。

可能会提示您为 IDE 选择浅色或深色主题,如 图 15-4 所示。

选择主题

图 15-4. 选择主题

现在理想情况下,您只需验证之前的选择,如 图 15-5 所示。

验证设置

图 15-5. 验证设置

如果满意,请点击“完成”,你将看到 Android Studio 联系各个服务器以更新其组件,如 图 15-6 所示。

安装组件

图 15-6. 安装组件

要启用本书中使用的 Java 8 特性,请在你的模块级 build.gradle 文件中添加以下内容(Android 开发者文档中的代码块):

android {
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
  // For Kotlin projects
  kotlinOptions {
    jvmTarget = "1.8"
  }
}

完成后,你将立即进入新项目流程。从这里开始,你将在创建下一个及后续项目时使用相同的流程,因此我们将在 第十六章 中深入探讨这个工作流程。

目前为止,你已经设置好了开发环境,准备好开始了!

iOS 设置

Xcode 是由苹果创建和维护的开发环境,用于构建 iOS(和 macOS)应用程序、库和项目。它是一个非常功能齐全的应用程序,在不同形式中已经存在了很长时间。从 PowerPC 桌面到中期 Intel-based 处理器时代再到从 iPhone 发布开始的 ARM-based iOS 设备,Xcode 一直在那里;它伴随着 Objective-C 时代和 Swift 的早期阶段。它是一款令人惊叹的软件,会让你感到喜悦和沮丧。

安装 Xcode 是那些令人愉悦的时刻之一,感谢天地。

设置和安装 Xcode

要安装 Xcode,请打开默认安装在每台 macOS 机器上的 macOS App Store。搜索“Xcode”,一旦找到由苹果提供的正确应用程序,请点击“安装”将其安装到你的计算机上。

本书是基于 Xcode 11 和 Swift 5 编写的。Xcode 11 是在 2019 年 9 月发布的。总体而言,Xcode 在不同版本之间变化不大,所以如果你使用的是旧版或新版 Xcode,可能需要寻找一些命令的名称变化,但它们本质上仍然是相同的操作。

当你第一次打开 Xcode 时,系统会提示你安装额外的组件,这些组件会向你的环境添加命令行工具。我们强烈建议你安装这些工具,这样未来的生活会变得更加轻松。

苹果开发者账户

如果你愿意,你可以创建一个账户并支付 99 美元成为苹果开发者计划的一部分。不过,只有在你打算发布你的应用时才是必需的。如果你只是想练习构建应用程序,那么目前还不需要支付这笔费用。你可以连接你的开发设备到电脑上,仍然可以构建并安装你的应用。

我们学到了什么

在这个介绍性的章节中,我们涵盖了很多内容。我们了解到,我们相信真正的本地开发胜过跨平台应用的原因。开发共享代码所节省的时间往往会因为在解决不完整的供应商功能、为不同的用户界面行为编写分开的代码以及等待第三方库支持新平台功能的机会成本而丢失。

我们还花了一些时间设置每个环境并为开发做好准备。我们在我们的机器上安装了 Android Studio 和 Xcode。现在,看起来我们已经准备好开始构建我们的第一个应用了。但是首先,让我们先了解一下我们将在下一章中构建的内容。

第十六章:构建一个应用程序

想象一下自己走进邓恩与刘易斯纪念图书馆宽阔而磨损的橡木门,以寻找知识。当你进入时,你会看到一个似乎没有尽头的木制书架大海,与亚历山大图书馆不相上下。你蹒跚地从一架架书架走向另一架书架,但不知道哪些书可供阅读,它们位于哪里。感到沮丧而孤独时,你正准备永远离开图书馆,这时一位老图书管理员示意你靠近。

图书管理员身上散发着古老书籍和红木的阴郁却熟悉的气味。你走近一些,但在你能走得更远之前,他指向墙上贴着的传单,预示着你的拯救:可以下载一个应用程序来帮助你找到所需的书籍。你漫游的日子结束了!

但是,这个应用程序在哪里?你无法下载它。你是说它还不存在?图书管理员低声喃喃道:“如果你建造它,他们将下载它”,然后消失在走廊的黑暗中。

我们将要构建这个应用程序。

现在,暂且忘记我们那位不祥和神奇的图书管理员,在第十五章中,我们向你展示了如何设置环境并创建可能的最简单、最基础的应用程序。但是,在现实中,应用程序要复杂得多。首先,它们通常不只是一个屏幕。为了真正学习一个平台,需要构建一个足够复杂的东西——超出基本的“Hello World”示例——以便你能够理解使用的技术的边界和细微差别。为了给我们提供足够复杂的东西来工作,我们将为邓恩与刘易斯纪念图书馆构建一个应用程序——是的,正是我们的图书管理员指引的同一个应用程序——帮助图书馆的读者找到他们需要的书籍。

在本章中我们将:

  • 为我们的应用程序创建一个新项目。

  • 给出我们正在构建的应用程序的简要概述。

  • 添加一个简单的欢迎屏幕。

让我们在血月升起之前开始吧,否则我们的项目注定失败!

创建一个新项目

理想情况下,你已经设置好了你的环境。如果没有,请移步到第二部分的第一章,并花些时间准备好 Android 和 iOS 进行开发。一旦准备就绪,我们首先来看 Android。

Android Studio

如果你不是直接从安装流程来到新项目流程,你可以通过选择 Android Studio 工具栏中的“文件”,然后选择“新建项目”来到这里。

Android Studio 将允许你从一些基本的项目模板中进行选择,比如基本活动或空活动,或者更高级的东西,如 Java 库(.jar,纯 Java)或 Android 库(.aar,Java 加资源和其他特定于 Android 的文件)。你可能稍后想尝试一些其他选项,但现在让我们选择空活动,如图 16-1 所示。

创建项目流程的第一步是配置您的项目。在这里,您将确定应用程序在磁盘上的位置、命名空间以及应用程序将支持的最低 API。这最后一点实际上非常重要。您可以随时查看各种Android 版本的分布数据

选择项目

Figure 16-1. 选择项目

在撰写本文时,看起来选择 OS 19,代号 KitKat,版本 4.4 是相当安全的选择。这覆盖了除了少数几个安装以外的所有情况。然而,如果您的应用程序可能面向更技术精通的用户或富裕国家,您可能想选择 OS 20,代号 Lollipop,版本 5.0。这将使您损失大约 7%的全球市场份额,但会使开发工作变得更加轻松。Android 5 在 Android 开发中迈出了重要一步,并且是许多现代 API 的分水岭。这是您的决定,但对于这个简单的项目,我们将选择 KitKat,如图 16-2 所示。

配置项目

Figure 16-2. 配置项目

就是这样了!从工具栏快捷方式运行您的项目(看起来像一个绿色的播放按钮)或按下 control/command + R 键或从“运行”中选择“运行应用程序”或从子菜单中选择“运行”。

首次进行此操作时,您需要连接设备或创建并启动模拟器,如图 16-3 所示。

创建模拟器

Figure 16-3. 创建模拟器并选择模拟器或设备

现在,您应该看到“Hello World!”打印到屏幕上,如图 16-4 所示。

Hello, World!

Figure 16-4. Hello World!

您还应该看到一个带有“我的应用程序”(或您在项目配置步骤中提供的应用程序)的工具栏。它是如何知道要显示“Hello World!”的?嗯,空白活动项目模板并不真正为空——如果您打开 MainActivity.java,您会看到对布局文件 R.layout.activity_main 的引用。在 res/layout 中找到该文件,或者在代码编辑器中直接按 control/command 键单击该行。您可能会看到一个 ConstrainLayout,其中包含一个 TextView 子元素。请注意 TextViewandroid:text 属性设置为字符串值“Hello World!”

让我们快速编辑一下。将TextView的文本值改为“iOS 真棒!”“iOS?!”你惊呼——是的,iOS……让我们把整个竞争无意义的事情放在一边。这两个平台都非常出色。也许您更喜欢其中一个平台的某个功能或语法转折,但让我们面对现实——两者都能出色地帮助我们表达我们的想法。

无论如何,再次运行您的应用程序。现在,您应该看到一些略有不同的东西,如图 16-5 所示。

iOS 真棒!

Figure 16-5. iOS 真棒!

就这样!您已经下载并安装了 Android Studio,创建了一个基本的应用程序,并更改了一些视觉值。虽然这只是一个非常基本的示例,但不用担心——在接下来的几章中,我们将带您逐步完成使用本书第一部分中所有任务创建一个功能齐全的应用程序。

Xcode

在 Xcode 中设置 iOS 项目的过程类似于 Android Studio,但是需要遵循更多的软件向导流程。要开始,请转到“应用程序”并双击“Xcode”应用程序以启动 Xcode。当 Xcode 启动时,您将看到一个类似于 图 16-6 的屏幕。

Xcode 启动画面

图 16-6. Xcode 启动画面

单击“创建新的 Xcode 项目”按钮以启动您的项目。将打开一个新的 Xcode 窗口,并显示一个模板列表,为项目提供一些视图和样板代码,以便快速启动。有许多选项可用于构建应用程序和库,但我们将专注于可用的应用程序选项。我们的特定项目将具有多个屏幕,但现在我们将使用“单视图应用”作为一种快速启动和减少开销的方式。选择该选项并在模板选择器中点击“下一步”。

提示

如果您未看到 Xcode 启动画面,请不要担心!转到菜单栏,选择“文件” > “新建” > “项目”来开始。

接下来,我们有多种选项可用于启动项目。这些选项中大多数都可以使用默认设置。稍后我们可以在屏幕上更改所有内容,但提前设置一些选项是有帮助的。我们应该首先填写的选项是产品名称。这是 iOS 在内部使用的应用程序名称的一部分,与组织标识符一起。默认情况下,它也是在设备启动屏幕上应用程序图标下显示给用户的名称。让我们将我们的应用程序命名为“图书馆小伙伴”。

组织标识符字段通常是公司或组织(或个人!)的反向域样式标识符。随意使用您喜欢的标识符,但出于本书的目的,我们将使用“com.oreilly”作为我们的标识符。

确保选择的语言是 Swift,并且未选中 Core Data 或单元和 UI 测试的任何复选标记。点击“下一步”继续。选择要放置项目的文件位置,并点击“创建”按钮创建项目。项目创建完成后,您将看到项目在 Xcode 窗口中打开,并且项目文件显示在左侧。

点击项目窗口左上角的“构建和运行”按钮(看起来像一个播放按钮)。这将构建项目,在 iOS 模拟器上打开并运行应用程序。当项目构建并运行时,您应该看到类似于图 16-7 在桌面上的 iOS 设备内运行的内容。

警告

如果在之前描述的“构建和运行”按钮附近的下拉菜单中默认没有选择 iOS 模拟器,则需要使用它来进行选择。如果没有列出任何模拟器,请前往菜单栏,选择“窗口” > “设备与模拟器”,以打开设备组织器。在窗口顶部选择“模拟器”,然后单击屏幕左下角的“+”按钮以添加一个新的模拟器用于开发。

我们在 Xcode 中 iOS 模拟器内运行的单视图应用程序

图 16-7. 在 Xcode 中 iOS 模拟器中运行的“单视图应用程序”

为了与我们的 Android 示例公平对待,并展示不对任何一个平台有偏见或偏好,让我们继续为我们应用程序的屏幕添加一个赞扬 Android 的标签。在文件列表中,点击 Main.storyboard,在窗口右上角点击“+”按钮,然后将一个标签对象拖动到空白的白色画布上。双击标签以更改文本为“Android 很棒!”将标签拖动到视图中心,并像之前一样构建和运行应用程序,您将看到一个类似于图 16-8 的屏幕。

Android 很棒!

图 16-8. Android 很棒!

好的,我们已经创建了我们的项目,并且(理想情况下)可以在 iOS 模拟器上构建和运行。在我们进一步之前,让我们谈谈我们要构建的内容。

应用架构

在不深入细节的情况下,接下来几章中我们正在构建的应用程序将具有多个独特的屏幕,显示不同类型的数据。每当应用程序启动时,我们将有一个欢迎屏幕,以及三个按钮。这三个按钮将带您进入应用程序的不同部分:所有可用书籍的列表;用户保存的所有书籍的列表;以及一个搜索屏幕,用户可以在其中搜索特定的标题或作者。

从这些屏幕中,我们还将构建一个单独的可重复使用的屏幕,每个屏幕都将使用它来列出关于特定书籍的所有信息。

Android 和 iOS 的好处之一是,您在如何构建应用程序结构方面并不被限制于特定选择。有许多可用的选项,但两个平台似乎更倾向于 MVC 或 MVVM 风格的应用程序开发。因此,我们将采用这种方法来构建我们的应用程序。

模型-视图-控制器

模型-视图-控制器(MVC)可以说是应用程序开发中最常见的方法。基本上,它是一种指导构成应用程序的对象代码结构的架构模式。MVC 中的“模型”是应用程序需要的数据的表示。这可以是持久化数据(例如稍后保存的书籍)或从网络请求接收到的瞬时数据。该数据与应用程序包含的视图之间存在分离;控制器是促进数据模型和视图之间通信的对象。

通常,控制器负责从数据库或网络资源获取数据,并将该数据传递给视图或视图模型以供显示。还有一些特殊的控制器负责直接显示视图。在 Android 中,这些是Activity对象,在 iOS 中,它们是UIViewController

MVC 架构的主要目标是利用和尊重对象中的固有边界,以防止对象之间的紧耦合。这样做可以更容易进行维护,并提供了一种明确定义、直观的编码方式。

以 MVC 架构为基础,让我们看看如何创建用户打开应用时看到的第一个屏幕:欢迎屏幕。

构建我们的第一个屏幕

如果您还记得我们之前的示例,Android 模拟器和 iOS 模拟器中的应用程序屏幕非常基础,没有任何设计或数据。我们应该解决这个问题。

注意

请注意,无论是 Android 还是 iOS 都使用“启动屏幕”的概念。这是在应用程序本身执行设置操作时显示的静态图像。请注意,除了可绘制对象外,几乎无法对其进行装饰,像交互式 UI 元素或网络请求这样的东西要么不可能,要么不建议。

Android

在 Android 框架中,启动屏幕发生在应用初始化期间,并且仅显示 XML drawable。这意味着没有逻辑,甚至没有Drawable类的实例可用(尽管在 API 26 之后,允许使用自定义 XML drawable,它可以引用回Drawable子类)。还要注意,此过程发生得非常早,因此框架无法访问许多我们通常利用的值,如 API 版本,因此尝试为不同版本提供不同的 drawable 文件将失败。我们将在下一节详细介绍如何设置这一点。

启动屏幕

在 Android 框架中,应用初始化时显示的启动屏幕与主题的窗口背景完全相同。这可以是任何Drawable实例,这意味着它可以是一组在单个实例中分组的绘图操作。在我们的示例中,让我们使用黑色背景并居中我们的 logo,使用layer-list XML Drawable。我们将文件命名为launch_drawable.xml并保存在res/drawable中:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
  <item>
    <color android:color="#FF000000" />
  </item>
  <item>
    <bitmap
        android:gravity="center"
        android:src="@drawable/dlml_logo"
        android:tileMode="disabled" />
  </item>
</layer-list>

您会注意到有一个编译的位图资源的引用。在您最喜欢的照片编辑软件中,生成应用程序名称的某些风格化版本、库名称、可能是符号表示或只是首字母缩写——无论如何,由您决定。与 Android 中的所有编译资源一样,文件名必须全部小写,单词用下划线分隔,只能是字母数字字符:a-z 和 0-9。让我们将图像文件命名为dlml_logo.xml并保存在我们的res/drawable目录中。系统将使此成为全局R类的常量值,格式如下:R.{resource_tye}.{file_name_minus_ext},因此在这种情况下,R.drawable.dlml_logo将是我们标志的位图资源的整数标识符。

如果你愿意,你可以创建一个双倍大小的图像,并将其添加到/res/drawable/xhdpi目录中。关于特定密度图像的详细信息,请参阅前面的注释。

离开一般资源并回到我们主题实现的检查,让我们在值文件中(让我们使用 Android Studio 应该已经为您创建的res/values/styles.xml文件)用我们自己的简单主题替换项目默认主题:

<?xml version="1.0" encoding="utf-8"?>
<style name="DlmlTheme" parent="Theme.AppCompat.Light.NoActionBar">
  <item name="android:windowBackground">@drawable/launch_drawable</item>
</style>

显然,我们可以而且可能应该设置许多其他特定于主题的值,如颜色、操作栏和协调布局支持等,但是出于本示例的目的,我们将保持简单。

要将此主题注册到您的应用程序中,您将使用应用程序的清单文件AndroidManifest.xml。配置应用程序时,您将多次使用此清单文件,但我们一次只做一步。现在,让我们只是注册我们的主题:

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.dlml"
          xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

  <application
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/DlmlTheme" />

</manifest>

就这样!现在,在用户可以交互之前,当你的应用启动时,他们将看到我们在launch_drawable.xml中提供的可绘制内容。一开始,当您的应用程序还很年轻、无辜和轻便时,这可能只会短暂闪现,或者启动可能如此快速,用户根本看不到它,但随着添加更多活动、权限、资产、资源、外部库和构建配置,初始化时间将会增加,因此让用户知道正在启动哪个应用程序并且他们不仅仅是卡住了,而是正在手工打造独特的体验通常是一个好主意。

iOS

在您在 Android 上添加屏幕之后,您会发现在 iOS 上并不完全不同。我们将从 Xcode 的 Storyboard 编辑器开始。在屏幕左侧的项目导航器中点击我们应用程序的主 Storyboard,Main.storyboard。这将打开 Storyboard 编辑器。

提示

还有LaunchScreen.storyboard。这是用于在应用程序启动时呈现设计,但在其激活之前。

故事板是一种格式,用于在 Xcode 项目中组织视图(称为“场景”)及其之间的过渡。它们可以并且应该包含多个通过转场连接的场景,以及场景本身的简单视图,如按钮、标签等。故事板甚至可以链接到其他故事板!

当我们打开Main.storyboard时,您应该在左侧的文档大纲中看到一个名为“View Controller Scene”的场景。这个场景是我们在之前创建的单视图应用程序的样板代码中自动创建的。我们可以重用这个场景并重新命名它,但我们将创建一个新的场景。

首先,点击库按钮,即项目窗口右上角最左边的按钮。这将打开一个浮动窗口,您可以在其中将视图和组件拖放到故事板编辑器中。向下滚动,直到在结果列表中找到“View Controller”对象,或者如下所示搜索“view controller”。

接下来,从窗口中将“View Controller”对象拖动到故事板编辑器画布上。将新的视图控制器场景放置在画布上任意位置。此外,您还可以双击“View Controller”对象,画布中将放置一个新的场景。

现在,让我们向这个视图添加一些文本。我们可以再次使用库窗口,找到标签对象。将标签拖动到我们的新视图控制器场景中。当你悬停在场景上时,它会变成蓝色高亮,表示这是标签将嵌入的场景。一旦你在正确的场景上,释放标签,它将出现在屏幕上被选中。你还会注意到它显示在编辑器左侧的新场景文档大纲中。

让我们更改标签的文本以显示其他内容。在故事板编辑器中,右侧有许多检查器可以切换。这些检查器根据所选对象改变上下文。仍然选择标签,或者点击它来选择它,点击属性检查器图标,即最左边的第四个按钮。您应该看到一个类似于图 16-9 所示的屏幕。

Xcode 中的属性检查器

图 16-9. Xcode 中的属性检查器

属性检查器——以及随后的大小和连接检查器——是 Xcode 中完成视图本身大部分配置的地方。在名为“标签”的子部分中,您可以看到与我们添加到场景中的标签对象设置对应的许多选项。有一个文本字段,其值设为“标签”。更改该值将更改标签当前显示的文本。让我们将其更新为“欢迎”。如果需要,我们还可以在此检查器中更改标签的外观或字体。

一旦您对标签的外观感到满意,您可以通过点击项目窗口顶部附近的“构建和运行”按钮来构建和运行应用程序,就像我们在本章早些时候所做的那样。然而,这不会让您有多远。在应用程序的角度来看,没有任何变化,部分原因是由于指向项目模板中包含的原始视图控制器场景左侧的巨大箭头。该箭头表示这是从 Storyboard 中显示的初始视图控制器。

更改这个操作就像选中一个复选框一样简单。

选择我们的新视图控制器场景,而不是其中的个别视图,可以通过直接点击屏幕左侧的文档大纲中的场景,或者点击模拟手机屏幕上方的白色矩形来实现。如果属性检查器未激活,请在选择视图控制器场景后再次点击它。在“视图控制器”子部分下,有一个名为“是否初始视图控制器”的复选框应取消选中状态。选中此复选框后,巨大的、神奇的箭头应该会移到您的新视图控制器旁边。

让我们构建和运行应用程序,看看会发生什么。

您应该在模拟器中看到应用程序已启动并使用我们的新场景作为显示的默认场景。万岁!

不过,还不要太快庆祝。

尽管我们已经向我们的应用程序添加了一个新屏幕,但我们可能希望拥有比仅显示静态屏幕更多的功能。实际上,我们可能想要在代码中更改和引用屏幕上的一些视图,使它们变得更加动态。我们一直在使用 Storyboard 编辑器来创建我们的视图。现在让我们来看看在 iOS 中显示视图的另一面:视图控制器。

添加一个视图控制器

要在 Xcode 中创建一个新的视图控制器,用于控制我们的欢迎场景,我们需要添加一个新文件。为此,请在 Xcode 屏幕左下角点击“+”按钮,然后选择“新建文件…”或者在菜单栏中选择“文件” > “新建” > “文件”。点击任一选项都会弹出同一个模态窗口,以选择要添加到我们项目中的文件类型。

对于几乎任何代码文件,我们可以选择 Swift 文件作为空的 Swift 文件,但对于我们正在创建的这种特定类型的对象文件,请选择 Cocoa Touch Class,因为视图控制器是 Cocoa Touch 的一部分——这是驱动 iOS 的框架——然后点击“下一步”。

在这个屏幕中,将子类设置为 UIViewController,名称设置为 WelcomeViewController。确保“同时创建 XIB 文件”未被选中,并且语言设置为 Swift,然后点击“下一步”和“创建”按钮以使用默认位置。现在你应该能看到在项目导航器左侧屏幕上添加了一个名为 WelcomeViewController.swift 的新文件。

提示

有一个命名约定,即将视图控制器命名为 ViewController 后缀。这是标准做法,并鼓励使用此方式,而不是像 WelcomeControllerWelcomeScene 这样命名您的视图控制器以符合约定。

此文件包含一些看起来像这样的样板代码:

import UIKit

class WelcomeViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    /*
 // MARK: - Navigation

 // In a storyboard-based application, you will often want to do a
 // little preparation before navigation
 override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
 // Get the new view controller using segue.destination
 // Pass the selected object to the new view controller
 }
 */

}

此时这个文件还相当空,目前只声明了一个名为 WelcomeViewController 的对象,它继承自 UIViewController。如果你还记得本章早些时候的内容,UIViewController 是视图控制器继承的基类;它类似于前面 Android 部分中的 Activity,但不完全相同。

现在我们已经创建了我们的视图控制器,让我们将其连接到之前的视图控制器场景。

通过点击 Xcode 左侧项目导航器中的 Main.storyboard,返回到我们的 storyboard 编辑器。一旦你在编辑器内部,点击我们的新视图控制器场景,即我们之前设置为应用程序初始视图控制器的场景,以在编辑器中选择它。点击屏幕右侧第三个按钮,显示身份检查器。在“自定义类”子部分下,有一个名为“类”的字段,当前显示为灰色的 UIViewController。这是拥有此视图的对象的类或类型。将此字段设置为 WelcomeViewController

你可能注意到的第一件事是,在左侧的文档大纲中,我们场景的名称已更改。之前它读作“View Controller Scene”,现在它读作“Welcome View Controller Scene”。让我们继续删除项目模板中附带的原始视图控制器场景,方法是点击文档大纲中的场景标题,然后点击删除按钮。

我们视图的 outlet

记住我们之前提到过,我们想要能够从视图控制器内部控制视图吗?现在我们将使用 outlet 来实现这一点。

Outlet 是将特定视图绑定到视图控制器的一种方式。然后,通过代码可以配置和传递此视图的引用。因此,最好从这里开始。返回到我们的 WelcomeViewController.swift 文件。让我们首先删除注释掉的样板代码,以便得到一个看起来像这样的类:

import UIKit

class WelcomeViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view
    }
}

class 声明的下方,添加以下行:

@IBOutlet weak var headerLabel: UILabel!

这将在类中创建一个名为headerLabel的属性,其类型为UILabel,这恰好是我们视图控制器场景中标签的对象类型。现在,让我们让我们的欢迎视图控制器在场景加载时更改标签的颜色,方法是在我们已经存在的viewDidLoad()方法的末尾添加以下行:

headerLabel.textColor = .red

这将在视图加载后将标签的textColor属性设置为红色。整个WelcomeViewController.swift文件现在应该如下所示:

import UIKit

class WelcomeViewController: UIViewController {
    @IBOutlet weak var headerLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        headerLabel.textColor = .red
    }
}

我们向我们的视图控制器类添加了一个输出,但是现在构建和运行应用程序不会改变任何东西。我们离成功如此之近!我们还需要在我们的 Storyboard 中连接欢迎标签。

连接一切

返回到 Storyboard 编辑器。按住 Control 键并在场景上方的浮动矩形中按住鼠标按钮或触控板按钮,覆盖黄色的“Welcome View Controller”图标。将鼠标光标拖动到我们创建的 welcome 标签上,两者之间应该出现一条连接两者的蓝线。释放鼠标按钮,会出现一个标有 Outlets 的浮动窗口;我们在WelcomeViewController上创建的headerLabel属性应该被列出。点击headerLabel,窗口应该消失。

在屏幕右侧的连接检查器中,您可以确认headerLabel现在已经连接到我们场景中的Header Label

通过点击项目窗口左上角的“Build and Run”按钮来构建和运行应用程序,现在你应该可以在模拟器屏幕上看到一个显示单词“Welcome”的红色文本标签。

我们学到了什么

让我们谈谈你在这一章学到了什么。

首先,我们学习了如何在 Android Studio 和 Xcode 中创建一个新项目。接下来,我们简要介绍了模型-视图-控制器(MVC)架构模式。我们还讨论了我们正在构建的应用程序的概述。最后,我们步骤步骤地学习了如何向我们正在构建的图书馆应用程序添加一个新的屏幕——在我们的案例中是一个简单的欢迎屏幕。我们还学习了如何创建视图的视觉组件,并且如何在代码中连接并操作它。

哇。这是一章很长,我们刚刚开始建立一些有用的东西。让我们在下一章深入探讨我们的应用程序,并学习如何在应用程序中显示一些数据列表,并向我们的应用程序添加更多的样式!

第十七章:在应用程序中列出数据

在上一章中,我们讲解了在 Android Studio 和 Xcode 中启动新项目的基础知识。我们还接到一个神秘图书管理员的委托,建立一个可能具有魔力的图书馆的应用程序。这只是移动应用程序开发人员生活中的一个典型日子。

在本章中,我们将为我们的应用程序添加一些更多的结构和支架。更具体地说,我们将学习:

  • 如何自定义和调整视图

  • 如何从按钮触发操作

  • 如何显示数据列表

  • 如何在两个屏幕之间进行过渡

这是一个很长的章节,让我们开始吧。我们未来的图书馆用户正在等待。

美化视图

如果你看看当前状态下的 Android 和 iOS 应用程序,它们都相当基本和不邀人入目。我们可以做得比一个只显示“欢迎”的基本标签在空白背景上更好。事实上,这两个平台都有一套相当强大的内置工具,用于样式化我们的应用程序,使其更具展示性和吸引力。我们不打算深入探讨这些工具——工具非常深入,你可以用它们创造的可能性几乎无限——但现在至少让我们稍微美化一下这些应用程序。

Android

回顾第十五章,当我们选择项目类型并能够更新文本时,我们从一个简单的Activity开始。让我们使用相同的Activity和 XML 布局来更新我们的欢迎界面。对于这个简单版本,我们不会使用任何形式的登录或认证,因此我们的欢迎界面可能只有一个标志,一小段介绍库的文本,以及可能的接受条款标签和复选框。让我们还在屏幕底部包括一个按钮,用于浏览库的语料库,该按钮在勾选接受条款复选框之前将被禁用。一旦启用,点击此按钮将启动我们的一个主要 UI 控制器之一,暂时命名为BrowseLibraryActivity

还要记住,我们无法确定某位用户在设备上运行我们的应用程序时屏幕的大小,无论是来自 2010 年的设备还是 2024 年的设备。我们也不能确定屏幕的方向(横向或纵向),如果我们遵循无障碍建议,甚至我们的字体大小也可能因用户或设备设置而大幅变化。总之,我们需要所有内容都可以滚动,以确保用户可以阅读我们呈现的所有信息,并选择(或不选择)我们设定的条款,并能够点击浏览按钮开始浏览我们的虚拟书库。

让我们确认一些事实。在 Android 中,像字符串和可绘制资源这样的资源通常以系统生成的整数标识的 XML 结构中保存,就像我们在创建和引用我们的标志图像时早些时候描述的那样。继续阅读获取更多细节。

添加字符串和可绘制资源

因此,我们需要几段复制内容:一个关于应用程序和库的介绍,用户数字同意的版权材料,以及按钮标签。您会记得,我们通过将其放置在适当的资源目录中,创建了我们的 logo 文件并注册到系统。对于字符串值,我们将需要不同的资源类型:values。传统格式为/res/values/strings.xml,每个字符串都是一个带有name属性的string节点,在编译期间用于标识它;字符串值是节点的文本内容。

对于 strings.xml,让我们添加一些节点来表示我们的复制:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">DunnAndLewisMemorial</string>
  <string name="introduction">Welcome to the app for the Dunn &amp;
    Lewis Memorial Library! This library includes books of all
    stripes, from code books on Java 8 to code books on Java 9!
    </string>
  <string name="terms">All books in the Dunn &amp; Lewis Memorial
    Library are copyrighted by their respective authors, and all
    state and federal copyright laws apply in full effect
    and will be enforced by the library.</string>
  <string name="terms_accept_label">Do you accept these terms?</string>
  <string name="browse_button_label">Browser</string>
</resources>

好了。现在我们不仅拥有我们的 logo 位图作为已编译资源,还有我们欢迎屏幕的所有复制内容。

让我们为这个Activity创建一个新的布局文件:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white">

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

    <ImageView
        android:id="@+id/logo"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:src="@drawable/dlml_logo" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/introduction" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/terms" />

    <CheckBox
        android:id="@+id/terms_checkbox"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/terms_accept_label" />

    <Button
        android:id="@+id/browse_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:enabled="false"
        android:text="@string/browse_button_label" />

  </LinearLayout>

</ScrollView>

那这是怎么回事呢?

让我们一步步来看:

  1. 我们布局的根节点是一个ScrollView,默认情况下垂直滚动内容,根据需要。在大多数屏幕上,这么少的内容可能根本不需要滚动,但在小屏幕或低密度屏幕上,在具有显著文本放大设置的设备上,甚至在标准设备的横向模式下,这可能起作用。重要的是,所有交互式 UI 元素,甚至只是为用户提供关键信息的元素,都可以以某种方式达到,传统用户体验通过滚动来实现。

  2. 我们知道,所有其余的组件都将垂直堆叠,每个后续元素都位于上一个元素的正下方。这是几乎每个现代 UI 引擎中的传统流程,从传统的 HTML 网页,到 PDF 文档,到像 Pages 或 MS Word,Markdown,AsciiDoc(tor),troff,(La)TeX 等编辑器创建的文档。框架提供的LinearLayout正是这样做的。请注意,LinearLayout的默认方向是水平的,因此每个元素都放置在前面元素的右侧;我们必须将orientation属性设置为vertical的值,以获取我们想要的堆叠界面。

  3. 接下来是我们的 logo。我们希望这个图像填充页面的宽度,但在垂直方向保持纵横比;因此,我们使用以下布局属性:

    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    
  4. 一个简单的文本块,描述应用程序和库。

  5. 另一个文本块,这次提供一些最小的法律术语来保护我们作者的版权。

  6. 带有标签的CheckBox实例。当这个CheckBox未被选中时,我们将提供逻辑,以使其后的按钮保持禁用状态。

  7. 一个简单的Button实例,当启用并点击时,将带用户到一个屏幕,让他们开始探索库。

就这样!由于我们的MainActivity已经调用了setContentView并传入了此布局文件的已编译标识符,因此初始欢迎屏幕现在将显示描述的 UI 元素,而不是之前简单的“Hello World!”。

由于前面的示例布局明确是裸骨的,只是为了演示您绝对需要显示 UI,让我们添加一些围绕空白和元素重力的简短说明来稍作装饰:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

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

    <ImageView
        android:id="@+id/logo"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center|top"
        android:layout_marginBottom="16dp"
        android:adjustViewBounds="true"
        android:src="@drawable/dlml_logo" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:fontFamily="serif"
        android:lineSpacingMultiplier="1.3"
        android:text="@string/introduction"
        android:textColor="@color/colorPrimary"
        android:textSize="14sp" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:fontFamily="serif"
        android:lineSpacingMultiplier="1.3"
        android:text="@string/terms"
        android:textColor="@color/colorPrimary"
        android:textSize="14sp" />

    <CheckBox
        android:id="@+id/terms_checkbox"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:fontFamily="serif"
        android:lineSpacingMultiplier="1.3"
        android:text="@string/terms_accept_label"
        android:textColor="@color/colorPrimary"
        android:textSize="14sp" />

    <Button
        android:id="@+id/browse_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:enabled="false"
        android:text="@string/browse_button_label" />

  </LinearLayout>

</ScrollView>

除了添加边距、字体规格和重力之外,您还可以注意到我们向代表 logo 的ImageView添加了属性adjustViewBounds。虽然您可以看到我们目前并没有对View进行广泛的操作,但我觉得这个属性值得一个快速的跳转,因为它经常出现,并且即使是经验丰富的开发人员在处理时也会感到困惑,因为其行为与我们的布局或绘图框架相比非常不标准。

开发者文档中,我们找到了这个定义:

如果您希望 ImageView 根据其可绘制对象保持其纵横比调整其边界,则将其设置为 true。

简单来说,如果我们不将这个属性设置为true,那么ImageView可能会将空白区域视为其绘图区的一部分,这可能会显示为未指定的边距或填充。当不确定时,请为任何您不具有专门不同行为保留的ImageView设置此属性为 true。这可能看起来不起眼,但如果您需要使您的图像与屏幕或其他元素完全匹配,这是您需要了解的一个属性。我们继续前进!

运行您的应用程序,您应该看到类似于图 17-1 的内容。

由于我们的可见 UI 已经处于良好的状态,我们将希望为我们拥有的CheckBoxButton实例中的一些行为连接起来。

我们希望将提交按钮放置在屏幕底部,以启动一个允许用户浏览我们书籍的 UI;该按钮应该在用户表明接受我们条款后启用(基本上,只是提醒我们的用户,我们的图书在数字呈现时仍然具有可执行的版权)。让我们看看如何在代码中实现这一点。

我们新欢迎界面的截图

图 17-1. 欢迎界面

首先,让我们打开我们的MainActivity.java文件,这个文件负责控制欢迎界面。你会注意到这一行setContentView(R.layout.main),它会膨胀前一章的 XML 视图节点并将它们绘制在屏幕上。目前,它看起来像这样:

相当直接,对吧?那么,我们需要做更多工作才能获得刚刚描述的特殊行为。

接下来,我们知道我们需要对复选框和按钮执行操作,所以让我们使用findViewById方法获取对它们的引用,这个方法对ActivityView实例都可用。我们还将声明成员变量来保存这些引用。您的活动代码现在应该看起来像这样:

在 Kotlin 版本的前述代码中,你会注意到一个很棒的事情——这一切都已经为你完成了!你将可以通过terms_checkbox获得一个TextView实例,并且通过browse_button获得一个Button实例,而无需编写任何额外的代码。在 Kotlin 中,Activity实例以及任何实现LayoutContainer的类将自动将布局中带有 ID 的任何View读取为一个名称等于 ID 的成员变量。自己看一看:

太棒了!我们有一个可见的用户界面,并且我们有一些 UI 元素的内存引用,我们将在其上执行一些逻辑。让我们考虑一下我们之前描述的规范:

  1. 浏览按钮应该启动一个新的Activity,以允许用户探索库的语料库。

  2. 只要用户没有勾选“接受条款”CheckBox元素,浏览按钮应该是禁用状态。

对于第一个需求,我们需要将一个View.OnClickListener实例附加到按钮上。虽然有几种方法可以做到这一点,在第四章关于用户输入中有描述,我们将使用方法引用来保持代码简洁而易读。我们的View.OnClickListener实现只需要是一个返回void并接受一个View参数的方法。

让我们试试:

接下来我们附加了方法browseContent作为按钮的点击监听器。你可能会注意到 lint 可能会抱怨BrowseContentActivity不存在——现在我们先把它打桩出来。创建一个最小的Activity文件,如下所示:

不要忘记在清单中注册它!

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.dlml"
          xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

  <application
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/DlmlTheme">

      <activity android:name=".MainActivity">
        <intent-filter>
          <action android:name="android.intent.action.MAIN" />
          <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
      </activity>

      <activity android:name=".BrowseContentActivity" />

  </application>

</manifest>

接下来,我们希望确保按钮在未勾选接受条款CheckBox时处于禁用状态。让我们从 XML 布局中开始使用enabled="false",然后随着CheckBox的检查状态更新它。为此,我们将需要一个OnCheckChangedListener,我们将再次使用方法引用。OnCheckChangedListener的契约要求一个返回void并接受两个参数的方法:一个是可以是CheckBoxSwitch或类似 UI 组件的复合Button实例,另一个是切换的布尔状态。下面定义的onCheckChanged方法满足这些条件;让我们将其附加到之前获取的CheckBox引用上,然后我们就可以开始了:

如果你再次运行应用程序,你会注意到按钮的颜色变暗,并且它不接受点击操作。然而,如果你点击复选框(或其附加标签)以切换其状态,你会发现Button也会在启用和禁用之间切换状态。在它启用时,点击按钮将启动新的BrowseContentActivity——尽管它目前是空白的,但你可以看到用户交互和Activity实例在 Android 框架中构成了应用程序导航的重要部分。

现在我们已经看到了在 Android 上的实现方式,让我们在 iOS 上做类似的事情。

iOS

为了使这个应用程序更容易使用,我们首先需要调整其结构和样式。让我们从结构开始。

结构

在 iOS 上我们首先要做的是为这个应用程序提供一些结构以提高其可用性。打开 Xcode 并导航到Main.storyboard文件。在 storyboard 编辑器中,点击项目窗口右上角按钮组中的“Library”按钮(我们在第十六章中从中拖出标签的同一个按钮);这将呈现模态窗口,我们可以从可用的 UIKit 对象列表中选择并拖放到我们的 storyboard 中。

为了提供我们所需的结构并遵循系统范围内的外观和感觉,我们将把我们的欢迎视图包装在一个导航控制器中。在 Library 中搜索“Navigation Controller”并将其拖到我们当前的欢迎视图控制器附近,就像在图 17-2 中一样。

向我们的 storyboard 添加一个导航控制器

图 17-2. 向我们的 storyboard 添加一个导航控制器

导航控制器是 UIKit 中的内置对象,该框架在 iOS 中处理所有 UI 的重活。它们用于使应用程序导航更加简单,并处理在视图之间转换的状态,这对于稍后将会很重要。然而,此刻更重要的是,它们提供了一个持久的导航栏,帮助用户在应用程序中进行空间推理。

现在,我们的导航控制器将永远不会被看到。让我们来解决这个问题。

点击 Xcode 自动添加的“Root View Controller”。这个视图控制器是我们添加的导航控制器显示的第一个视图控制器。通过按下删除按钮来删除它。每个导航控制器实际上都是一个UINavigationController。这个类有一个正在应用程序中显示的子视图控制器堆栈。根视图控制器可以是任何UIViewController对象——它本质上只是堆栈的底部。幸运的是,我们有一个完美的候选人作为新的根视图控制器:我们的欢迎屏幕!

为了将这两个视图连接起来,我们需要像我们在第二章中连接页眉标签一样连接它们。在导航控制器上控制点击并将连接拖动到欢迎视图控制器上。在弹出的模态对话框中,选择“Relationship Segue”下的“root view controller”。

不幸的是,如果您在模拟器中运行应用程序,您会注意到没有太多变化。原因是因为我们的欢迎视图控制器仍然被设置为此 storyboard 的初始视图控制器。这意味着 iOS 只会创建一个WelcomeViewController的实例,并告诉它将其视图作为窗口的根视图呈现出来。

我们可以通过在故事板编辑器中点击导航控制器场景,突出显示属性检查器,并在检查器窗格中选中标记为“是初始视图控制器”的复选框来修复此问题。你会注意到大浮动箭头从欢迎视图控制器移动到导航控制器的左侧,表示切换。构建并运行应用程序,你会看到我们创建的导航控制器场景内显示的欢迎视图控制器场景。

我们已经搞清楚了结构;现在看看能否增加一些样式。

样式

我们可以改进欢迎屏幕的多种方式。最简单且可以说是最好的选择是去掉之前创建的标题标签,并让导航控制器为我们显示我们正在查看的屏幕名称。

首先,点击欢迎场景中的标题标签,然后通过按下删除键将其删除。你会注意到它不仅从屏幕上消失了,而且从画布左侧的文档大纲中也消失了。

接下来,让我们为导航控制器设置屏幕标题。我们可以在程序中进行此操作,但既然我们已经在故事板编辑器中,那就在这里操作吧。在左侧的文档大纲中,点击“欢迎视图控制器场景”内的“导航项”对象。在屏幕右侧打开属性检查器,并在标题字段中输入“欢迎”。

另外,如果你愿意,可以将“大标题”从自动设置为始终。这将使我们的导航控制器标题更大更易读。

注意

我们也可以通过编程方式设置导航项属性。在UIViewController类中,有navigationItem.titlenavigationItem.largeTitleDisplayMode属性,分别用于在故事板编辑器中设置的标题和大标题属性。

继续在 iOS 模拟器上运行应用程序。

哎呀。我们搞砸了。发生了什么?

错误,错误,还有更多错误!

首先,欢迎来到运行时错误的美妙世界!正如你所注意到的,我们的应用程序编译并运行,但在运行时崩溃了。这是由于 UIKit 的动态特性,它有着 Objective-C 历史的根基。如果你打开我们的WelcomeViewController类,你会看到我们有一个叫做headerLabel的属性仍然存在于类中。

也许你会说:“但是我记得我们几段前删除了标题标签?”你说得对。我们确实删除了。但是,我们没有从类中删除它。现在,有几种方法可以防止类似情况发生在未来。一种选择是将我们的类型从UILabel!更改为可选属性——一种可能为nil的类型。

你看,iOS 期望在我们在viewDidLoad()方法中与其交互时该对象有一个值。因为我们在 Storyboard 编辑器中删除了场景中的标签,所以我们在第二章中与这个类连接的连接从未有机会完成。编译器并不知道发生了这种情况,因为所有的连接都是在运行时而不是编译时进行的。所以考虑以下代码:

headerLabel.textColor = .red

每当应用程序调用此处的代码时,系统都不知道该怎么做,因为本应设置一个标题标签,但实际上没有设置。

如果你读取在 Xcode 中弹出的控制台右下角窗口生成的错误,就可以知道这种情况发生了。

Fatal error: Unexpectedly found nil while
    implicitly unwrapping an Optional value

headerLabel属性是一个不应为nil值的东西。

如果我们将headerLabel设置为UILabel?类型,应用程序将能够正确编译和运行。然而,我认为这通常不是正确的解决方案,因为代码仍然会存在于类中。我们仍然会尝试将headerLabel设置为红色,但因为headerLabelnil和一个 Optional 类型,这会导致短路并静默失败。稍后,当我们去编辑类时,我们可能不会很快记得我们从视图中删除了标题标签,从而花费时间尝试弄清楚这行代码的作用。在我看来,最好将@IBOutlet留作隐式解包的可选项,并让应用程序在运行时失败,以提醒我们在删除连接的视图时删除这段代码。

现在,让我们从WelcomeViewController类中移除此属性。我们还将添加一个快速的代码片段,以便在此视图控制器的导航栏中显示我们在 Storyboard 编辑器中设置的大标题(默认情况下未启用)。我们的类现在应该像这样:

class WelcomeViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.navigationBar.prefersLargeTitles = true
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        navigationController?.navigationBar.prefersLargeTitles = false
    }
}

点击项目窗口左上角“Build and Run”按钮旁边的停止按钮来停止当前正在运行的应用程序。然后,点击播放按钮来构建和运行项目。我们现在应该看到类似于图 17-3 的内容。

两个应用程序看起来不错!我们可以在欢迎界面的背景颜色和其他属性上进一步进行设置,但我们将把这留给读者作为练习。

我们在应用程序的欢迎界面花了很多时间,并为更多的互动设置了基础工作。最终,我们在本章末尾的目标是制作一个列出一些数据的应用程序,所以让我们通过简单地按下按钮来尽可能简单地实现这一目标。

我们应用程序欢迎界面应用的第一个样式

图 17-3。我们应用程序欢迎界面应用的第一个样式

添加一个按钮

不幸的是,我们并不是说你,这个了不起的应用程序的开发者,可以只需按下一个按钮就让一切发生得像魔术一样;我们的意思是让我们为我们的应用程序添加一个按钮,以便用户可以按下来列出一些书籍。这个特定的按钮将标记为“目录”,并在屏幕上的列表视图中显示整个图书馆目录。

我们可以回顾一下安卓是如何处理的;现在让我们看看在 iOS 上怎么做同样的事情。

iOS

要在我们的视图中添加一个按钮,请在故事板编辑器中打开Main.storyboard并单击库按钮,以显示我们的 UI 对象库。接下来,搜索“按钮”并将按钮对象拖动到编辑器中的欢迎屏幕上。您应该在画布上看到一个标记为“按钮”的占位符按钮。

现在它可能在你放置按钮的画布上的任何地方,但理想情况下,我们希望这个按钮目前显示在屏幕中央。针对一个设备来说这很容易,但是如果你回想起来,有很多运行 iOS 的设备具有不同的屏幕大小和形状。幸运的是,iOS 拥有一个名为自动布局的强大框架,可以自动布局视图。

自动布局使用预定义的约束在场景中定位视图。在第二章中有一个关于自动布局的很好的描述。它描述了如何使用 Xcode 中的故事板编辑器来通过编程方式设置约束。目前,我们将使用编辑器,因为这通常更容易。

选择按钮后,在故事板编辑器画布底部的第三个“对齐”按钮,勾选“水平居中于容器”和“垂直居中于容器”复选框。点击“添加 2 个约束”以将约束添加到按钮上。

您应该看到按钮自动移动到视图的中心。在这种情况下,我们告诉 iOS 这个对象在其容器中水平和垂直居中。这一部分可能是显而易见的,但容器是什么?好吧,容器是对象所在的根视图。在这种情况下,它恰好是整个场景嵌入的根视图,这也恰好是WelcomeViewControllerview属性。例如,如果此按钮嵌套在另一个视图中,则它将在该视图内居中,而不是视图控制器的根视图。

现在它已经正确放置了,让我们完成样式化按钮。单击按钮并在属性检查器中更改按钮的标题为“目录”。构建并运行应用程序,您应该在屏幕中央看到一个按钮。

最终,这个按钮将显示我们的图书馆目录。但是,在显示之前,我们必须先构建它!

列表,列表,还有更多列表!

移动开发中最常见的任务之一是显示数据列表。有些应用程序专门用于列出数据。书目录不就是一大堆书的列表吗?幸运的是,无论是 Android 还是 iOS 都有一些出色的核心库用于处理项目列表。在 Android 上,使用的是RecyclerView,它是支持库的一部分,但在撰写本文时正逐步迁移到androidx包;你可以选择使用哪一个。支持库版本在这一点上已经经过了充分测试,虽然我们可能期望一些性能改进或技术升级,但我认为其可用性和公共 API 主要仍然保持不变在androidx中。

注意,Android 在这种类型的组件周围已经经历了一些公共演变。最初,我们有ListView,它很好地管理了滚动和项目呈现,但用户需要手动回收视图(这有助于内存管理)。还有一个叫做GridView的组件,正如你可能猜到的那样,它允许将Views表达为水平行和垂直列,而不仅仅是ListView所支持的垂直堆叠。但是在这一点上,两者都应被视为已弃用,而RecyclerView实例使用布局管理器来提供列表或网格布局(或者你能构思到的任何其他布局)。在 iOS 上,此功能要求可以通过UITableViewUICollectionView来满足。

添加一个新的目录视图

我们首先会添加一个新的 UI 控制器,并在其上添加一个空列表。

在 Android 上,我们将为BrowseContentActivity创建一个新的布局文件,其中仅包含一个RecyclerView。我们称之为res/layout/activity_browse.xml。我们可能会添加一个工具栏用于导航和上下文菜单,或者添加一个协调布局来管理Snackbar动画或FloatingActionButton的位置,但现在让我们保持简单:

<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/browse_content_recyclerview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white" />

你应该将此设置为BrowseContentActivity类中的内容视图,在super被调用后的onCreate方法中:

之后我们需要在代码中做一些事情,以提供数据源并指定布局行为和效果,但现在这就是我们所需的所有布局。

对于 iOS,我们可以在故事板编辑器中完成这个工作。对于这个目录,我们将使用UITableView,因为它最初的工作起来更简单。首先,打开 Xcode 中的Main.storyboard文件,然后在项目窗口中单击“库”按钮,以打开我们的对象库。接下来,在画布中拖动一个新的视图控制器。然后,再次使用库,将一个表视图拖到刚创建的新视图控制器屏幕上。最后,选择表视图,在故事板编辑器画布的右下角点击“添加新约束”按钮。将所有边距设置为0,然后点击“添加 4 个约束”按钮;这将使表视图在所有维度周围都有0的边距,有效地将其扩展到屏幕的宽度和高度。

最后一步,点击我们创建的视图控制器场景。在属性检查器中,在标题字段下输入“目录”以命名我们的视图控制器。

我们在两个应用程序中创建了列表视图,但目前无法访问。让我们修复一下!

连接按钮

没有操作的按钮真的算是按钮吗?

暂且将这个短暂的存在危机放一放,让我们专注于欢迎屏幕上目前没有目的的目录按钮。这个按钮的目标是显示图书馆中的图书目录。但现在它什么也没做。

对于 iOS,让我们用一个 segue 连接这个按钮。Segue 在本书的第一章中有详细解释。它们简单地说,是两个视图之间的过渡。Segue 在故事板中定义,并可以通过故事板连接或以编程方式触发。对于这个例子,因为它的简单性,我们可以在故事板编辑器中把所有东西都连起来。

首先,在欢迎屏幕上的目录按钮上按住 Control 键并单击。将连接拖动到我们的新目录场景上。现在,在呈现的模态对话框中,在动作 Segue 部分选择“显示”。

就这样。每当有人点击目录按钮时,这将创建一个 Segue 触发器,显示目录视图。构建和运行应用程序,你应该在点击目录按钮时看到一个类似于图 17-4 的列表视图。

一排排空空如也的数据,这是人生的隐喻吗?不,只是一个空的表视图

图 17-4。一排排空空如也的数据,这是人生的隐喻吗?不,只是一个空的表视图。

好了,我们的按钮已经连接好了。我们有我们的列表视图。但现在它们是空的。现在终于是时候谈论我们的数据了。

图书

如果你在我们正在创建的应用程序的图书馆周围看看,你会注意到一些事情:这里有很多书。有非虚构书籍。有有插图的书籍。有没有插图的书籍。有小书,大书,旧书,可以在海滩上读的书,可以一边在浴缸里喝着红酒一边读的书——各种各样的书籍!

那么我们怎么知道要显示什么?

幸运的是,有一些信息是所有图书共享的。图书的一些共同属性包括:

  • 标题

  • 作者

  • ISBN

  • 页数

  • 小说还是非小说?

为了使用我们的列表视图,我们需要保持这些信息有序。我们可以直接将它作为属性传递给我们的列表视图行,并配置每一行,但更常见的做法是定义一个共享模型来保持数据的完整性和更易于维护;将一个新字段添加到单一类型要比添加到处理图书元数据的应用程序中的每个方法要容易得多。

让我们定义一个书籍对象,这样我们就可以开始使用它来填充我们的列表视图。那么,在 Java 和 Swift 中,这个对象是什么样子呢?很明显,在这些类中看起来非常相似。

在 Android 中,我们将使用标准的 Java 类定义来表示我们的书籍。有时这些被称为“模型”或“数据类”,而后者在 Kotlin 中有特殊的指定(我们稍后会提到),但实际上,在模型和其他任何类之间没有功能上的区别。一些开发者更喜欢使用“POJOs”(“Plain Old Java Objects”),这只是不扩展另一个类和不实现接口的类,但这只是一种偏好。模型类通常排除除了 getter 和 setter 之外的逻辑操作(方法),尽管这在你的团队上可能有道理甚至被强制执行,但从技术上讲,这在语言和框架的限制下是可选的。

考虑到 Android 建议我们不要使用公共成员,我们将为一些字段分配适当的 getter 和 setter,所以我们的Book类可能是这样的:

public class Book {

  String mTitle;
  String[] mAuthors;
  String mIsbn;
  int mPageCount;
  boolean mIsFiction;

  public String getTitle() {
    return mTitle;
  }

  public void setTitle(String title) {
    mTitle = title;
  }

  public String[] getAuthors() {
    return mAuthors;
  }

  public void setAuthors(String[] authors) {
    mAuthors = authors;
  }

  public String getIsbn() {
    return mIsbn;
  }

  public void setIsbn(String isbn) {
    mIsbn = isbn;
  }

  public int getPageCount() {
    return mPageCount;
  }

  public void setPageCount(int pageCount) {
    mPageCount = pageCount;
  }

  public boolean isFiction() {
    return mIsFiction;
  }

  public void setFiction(boolean fiction) {
    mIsFiction = fiction;
  }
}

尽管这可能看起来有点冗长,但你会发现 Android Studio 很乐意从你的属性生成 getter 和 setter 方法,并且甚至可以配置为正确处理匈牙利命名约定(所以mName变成getNamesetName,而不是getMname)。

话虽如此,你可以看到 Kotlin 在这个特定的示例中表现得非常出色,Book实例的数据类定义要小得多:

data class Book (
  var title: String,
  var authors: List<String>,
  var isbn: String,
  var pageCount: Int,
  var isFiction: Boolean
)

我们可以包含一个构造函数,允许我们进行无参实例化:

data class Book (
  var title: String,
  var authors: List<String>,
  var isbn: String,
  var pageCount: Int,
  var isFiction: Boolean
) {

   constructor() : this("", mutableListOf<String>(), "", 0, false) {
     // no op
   }

}

这不仅包括一个接受所有字段的默认构造函数,而且将该构造函数与定义本身结合起来。此外,虽然所有字段都是val,因此是不可变的(在 Java 中,这将被视为final),但你会看到我们不必为实例化设置默认值以更新——通过在构造函数中定义这些值,我们可以确保实例化为成员提供单一、不可变的值。

现在,在 Xcode 中,添加一个名为Book.swift的新 Swift 文件(File > New > File)到项目中。我们可以在这里使用一个class,但是我们将使用一个struct。Swift 中的类是引用对象;换句话说,你传递的是对象在内存中的地址。而结构体是值对象,类似于像字符串这样的基本数据类型。这使得在多线程情况下复制操作更加安全和简单。你不必担心另一个对象改变对象中的数据,因为对象本身将被复制,而不仅仅是对象的地址。

这就是我们的Book对象类型的样子:

struct Book {
    let title: String
    let authors: [String]
    let isbn: String
    let pageCount: Int
    let fiction: Bool
}

我们已经定义了我们的数据模型,创建了我们的目录视图,并将我们的目录按钮连接起来。几乎所有的拼图都已经摆好了,可以开始显示一些数据了!让我们回到我们的新列表视图。

填充列表视图

我们目前没有从网络服务获取任何数据,也没有从文件系统中读取数据。(剧透!我们将在后面的章节中讨论这两者!)暂时,让我们使用类内静态数据来演示如何在 Android 和 iOS 中填充列表视图。让我们从 Android 开始。

Android

因此,我们已经实例化了我们的 RecyclerView 并准备好去做一些事情,但是如果没有数据,我们将看不到太多内容。让我们来解决这个问题。暂时,我们将在我们的 Book 模型中硬编码一些数据作为 static 类变量,但是请不要在生产环境中这样做,为了所有良好的事情,请不要这样做。并且请放心,我们稍后会解决这个问题。

所以,暂时,这里是一些示例书籍——我们很快将在 iOS 中使用非常类似的结构:

所以这就是我们的数据。考虑到列表视图的数据源可能在应用的生命周期内发生变化——我们可能最初从远程服务器获取数据,但随后显示来自本地数据库的缓存版本。我们可能有一个搜索服务,该服务具有各种排序或过滤选项。因此,Android 中数据源并不直接附加到 RecyclerView,而是由 UI 和数据之间的桥梁管理,其语义上和规范上均称为 AdapterAdapter 模式在 Android 中很常见,用于像 ViewPagersSpinnersGalleries 等组件;RecyclerView 使用的具体 Adapter 类只是 RecyclerView.Adapter

Adapter 有几个合同需要履行。我们将使用 Book.SAMPLE_DATA 数组作为数据源提供一个工作示例,然后深入探讨每个所需方法:

如果这看起来对你来说混乱或不透明,那么你是对的——它确实如此!事实上,RecyclerView 的概念最初仅仅是一个约定。我们(Android 开发者)曾经使用过现在已经弃用的 ListView 小部件,随着时间的推移,我们作为一个社区逐渐接受使用某些模式可以节省内存并提高性能。如果没有回收模式,一个简单的搜索可能返回几千个项,可以轻易地使用掉应用可用的更多资源,特别是在当时的设备上。

RecyclerView 首次在原始支持库中发布时(在我们现在使用的更加花哨的 androidx 包之前),这种约定变得正式化,但是如果没有理解这段历史或没有参与其中,这些模式和合同似乎是任意的和不直观的——主要是因为它们确实如此!

无论如何,在 Android 开发的这一点上,我们拥有一个功能丰富、经过深思熟虑和高性能的小部件,所以让我们花时间来学习它。实际上,让我们从最底层开始:静态ViewHolder类。超类RecyclerView.ViewHolder是抽象的,所以即使我们不需要任何扩展行为,您仍然必须对其进行子类化。实际上,如果超类不是抽象的,我们可以在onBindViewHolder方法中将itemView成员简单地转换为TextView实例,我们甚至不需要自定义子类。真是一口气!让我们放慢脚步,退一步。

在其最简单的形式中,ViewHolder只是View引用的一个袋子,表示每一行上的子View。例如,如果您的列表每一行都有一个ImageView来表示缩略图图像,一个TextView来指示标题,另一个用于显示连接的作者列表,那么您的ViewHolder可能会像这样引用这三个View

你可能会想知道这些View实例来自哪里 — 我们将暂时离开ViewHolder,来谈一谈onCreateViewHolder方法。在我们前面的简单示例中,我们只是传递了一个TextView,但在我们用来解释ViewHolder的更复杂的 UI 中,让我们想象一下,你想要一个视图树,可能想要像你所有的布局一样来表示 — 通过 XML。每一行可能像这样:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

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

      <LinearLayout
        android:orientation="vertical"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content">

        <TextView
          android:id="@+id/row_title"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content" />

        <TextView
          android:id="@+id/row_authors"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content" />

      </LinearLayout>

</LinearLayout>

注意使用weight属性的线性布局技巧,如第二章所述。

让我们称这个文件为complex_book_row.xml并保存在res/layout中。

所以,对于这更复杂的行,使用我们更健壮的ViewHolder类,我们的onCreateViewHolder方法可能看起来像这样:

现在你可能开始看到这是如何结合在一起的 — 如果没有,请想象一下RecyclerView如何进行回收利用...

当首次布局时,RecyclerView创建足够填充其可见区域的行,使用onCreateViewHolder方法以及一些内部不透明的功能。当用户向下滚动到足够远,以使第一行在可见视口之外时,整个行的视图树都被回收 — 它从窗口中分离并添加到池中以供稍后使用,当由于滚动或调整RecyclerView的大小而使尚未渲染的行变为可见时,将调用RecyclerView.AdapteronBindViewHolder方法,传入位置(通常作为针对列表样式数据源的索引位置)并填充最近退役但现在重新雇用的视图树。在我们最复杂的示例中,onBindViewHolder方法可能如下所示:

现在你可以看到我们的简单示例是如何工作的,通过将其与同一事物的更详尽版本进行比较。

所有剩下的就是把这一切连接起来。此外,我们需要让RecyclerView知道如何定位其子项。如前所述,为垂直列表、水平列表和网格视图提供了多个布局管理器,您可以使用相同的模板创建自己的LayoutManager子类以实现自定义行为。

在选择并实例化LayoutManager后,将其分配给RecyclerView

最后,使用setAdapterAdapter附加到RecyclerView上:

您的工作浏览Activity的第一个草稿可能是这样的:

如果您现在运行您的应用程序并点击BrowseBooksActivity,您应该能够看到每本书的标题以垂直堆栈的方式列出。如果您的字体大小足够大或屏幕足够小(随意调整TextView上的填充或边距以查看其效果),您会发现此列表可以滚动,并且似乎可以随时显示整个数据集——这就是RecyclerView的魔力所在。

在 Android 上,我们已经有一个可以工作的书籍列表。现在让我们来做 iOS 版本吧!

iOS

如您之前所述,在 iOS 上,我们使用UITableView来显示目录中的数据。如果我们要进行任何特殊逻辑,首先要做的是将我们的普通UIViewController切换为 Storyboard 编辑器中的自定义视图控制器。在 Xcode 中创建一个名为CatalogViewController的新 Cocoa Touch 类(File > New > File),并将其添加到项目中。暂时保留此文件,现在点击Main.storyboard以在 Storyboard 编辑器中打开它。

在 Storyboard 编辑器中,点击 Catalog 场景。在 Identity 检查器中将 Class 值设置为CatalogViewController,这是我们刚刚创建的类的名称。

如果您查看场景内的表视图,您可能会注意到它目前是空的。如果我们尝试使用表视图,我们将无法使用,因为当前没有原型单元格。原型单元格是在 Storyboard 中定义的模板,由表视图按需创建以显示内容行。每一行可以是不同的单元格类型,但更常见的是定义并使用一些(甚至只有一个!)单元格原型。

为了添加一个原型单元格,选择场景中的表视图,在属性检查器中打开并增加 Prototype Cells 下的值。这将在 Storyboard 编辑器中显示的示例表视图中添加一个新行。然而,要能够使用此单元格类型,我们必须能够在代码中针对它进行目标化。这通过在编辑器中选择表视图单元格并将其标识符(也称为重用标识符)设置为唯一值来完成。现在,让我们将值设置为CatalogTableViewCell

我们前面提到过,本章的数据是静态数据。让我们使用与 Android 部分相同的标题来填充列表视图。我们可以修改我们的 Book 结构,包括一个便利方法的扩展来列出我们的书籍列表,如下所示:

struct Book {
    let title: String
    let authors: [String]
    let isbn: String
    let pageCount: Int
    let fiction: Bool
}

extension Book {
    static let sampleData: [Book] = [
        Book(title: "Fight Club", authors: ["Chuck Palahniuk"], isbn: "978-0393039764",
          pageCount: 208, fiction: true),
        Book(title: "2001: A Space Odyssey", authors: ["Arthur C. Clarke"],
          isbn: "978-0451457998", pageCount: 296, fiction: true),
        Book(title: "Ulysses", authors: ["James Joyce"], isbn: "978-1420953961",
          pageCount: 682, fiction: true),
        Book(title: "Catch-22", authors: ["Joseph Heller"], isbn: "978-1451626650",
          pageCount: 544, fiction: true),
        Book(title: "The Stand", authors: ["Stephen King"], isbn: "978-0307947307",
          pageCount: 1200, fiction: true),
        Book(title: "On The Road", authors: ["Jack Kerouac"], isbn: "978-0143105466",
          pageCount: 416, fiction: true),
        Book(title: "Heart of Darkness", authors: ["Joseph Conrad"], isbn:
          "978-1503275928", pageCount: 78, fiction: true),
        Book(title: "A Brief History of Time", authors: ["Stephen Hawking"],
          isbn: "978-0553380163", pageCount: 212, fiction: false),
        Book(title: "Dispatches", authors: ["Michael Herr"], isbn: "978-0679735250",
          pageCount: 272, fiction: false),
        Book(title: "Harry Potter and Prisoner of Azkaban", authors: ["J.K. Rowling"],
          isbn: "978-0439136365", pageCount: 448, fiction: true),
        Book(title: "Dragons Love Tacos", authors: ["Adam Rubin", "Daniel Salmieri"],
          isbn: "978-0803736801", pageCount: 40, fiction: true),
    ]
}

现在可能看起来相反,但我们不会立即将所有这些数据传递给我们的表视图。UITableViewUICollectionView 的一个令人惊奇的地方是它们能够在大量数据的情况下保持性能。它们通过仅与数据的子集交互来实现这一点,这与 Android 的 RecyclerView 非常类似。

为了填充表视图,我们需要为表视图创建一个数据源。这是通过将一个符合表视图对象本身的 UITableViewDataSource 协议的对象分配给它来完成的。在这个例子中,为了简单起见,我们将让 CatalogViewController 自己符合这个协议,而不是创建一个单独的对象。

警告

UIViewController 自己实现数据源(和代理)协议以供表视图使用是一种相当普遍的做法。对于简单的应用程序来说这没问题,但它确实违反了更严格的 MVC 架构所提供的分离性。这也会导致视图控制器的膨胀,这是一个常见的问题。开发者要小心!

添加一个扩展到 CatalogViewController 以符合 UITableViewDataSource 如下所示:

class CatalogViewController: UIViewController {
    ...
}
extension CatalogViewController: UITableViewDataSource {

}

现在,您可以符合数据源协议的许多可选方法,但您必须实现两个必需的方法来填充表视图:tableView(_:numberOfRowsInSection:)tableView(_:cellForRowAt:)。让我们先看看第一个方法。

tableView(_:numberOfRowsInSection:) 中,我们告诉表视图每个数据 section 中有多少行。我们的表视图只有一个 section,所以我们可以直接返回我们之前定义的静态数据项的数量,如下所示:

func tableView(_ tableView: UITableView,
    numberOfRowsInSection section: Int) -> Int {
	return Book.sampleData.count
}
注意

表视图的默认 section 数量是 1。为了在表视图中定义多个带有 section 头的 section,请实现方法 numberOfSections(in:)

我们已经告诉表视图将有多少行。现在让我们实现我们的下一个方法 tableView(_:cellForRowAt:),为如何填充单元格提供逻辑。

此方法的主体需要做两件事情:实例化一个表视图单元格并用正确的书籍填充该表视图单元格。我们可以使用以下代码来完成这个操作:

    func tableView(_ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
	// Dequeue a table view cell
	let cell =
    tableView.dequeueReusableCell(withIdentifier: "CatalogTableViewCell", for: indexPath)

	// Find the correct book based on the row being populated
	let book = Book.sampleData[indexPath.row]

	// Populate the table view cell title label with the book title
	cell.textLabel?.text = book.title

	return cell
}

首先,我们通过使用我们在创建原型表格单元时分配的标识符(CatalogTableViewCell)来获取表视图单元的可重用实例。接下来,根据indexPath.row属性获取当前行的书籍。这种方法用于显示每个单元格,因此如果我们获取第三行,此属性的值将为2,因此我们可以从数组中获取第三个项目(即,它是基于零的行计数以便使用)。然后,我们将该单元格的文本标签设置为书籍的标题。最后,我们返回单元格,以便表视图可以在屏幕上显示它。

我们还有一步就能将所有内容连接起来:我们必须告诉表视图,它可以将此视图控制器作为其数据源使用。

跳回到Main.storyboard在 Storyboard 编辑器中。在画布左侧的文档大纲中,从目录场景中的表视图上控制点击并拖动到Catalog对象本身。在弹出的对话框中,选择dataSource作为 Outlets 以将表视图连接到视图控制器作为其数据源。

点击“构建并运行”,然后您应该能够点击我们的目录按钮,查看我们用于填充列表的示例数据列表。

我们学到了什么

让我们回顾一下本章我们学到了什么。

首先,我们学会了如何为我们的屏幕添加一些结构和样式。接下来,我们学会了如何通过按钮触发的过渡来连接视图。我们讨论了数据模型,并展示了它们在不同平台之间的相似之处。我们还创建了一个用于图书的基本模型对象,我们将在后续章节中使用和改进。最后,我们将这些数据显示在图书馆书目的列表视图中。

我们做到了!我们成功让我们的应用程序显示一些书名,尽管只使用了静态示例数据。在视图和控制器层面度过的所有时间都很有趣,但下一章我们将更多时间处理我们的模型。让我们来看看吧!

第十八章:建模我们的图书馆

到目前为止,我们在图书馆应用程序上已经取得了相当大的进展;我们已经为应用程序放置了一些基本的结构元素,我们正在一些屏幕之间过渡,并列出一些数据。不幸的是,这些数据是静态的、硬编码的数据——这不会让我们在很长时间内走得很远。为了继续进行应用程序的其余部分,我们需要查看我们的数据模型。现在注意它将使我们能够制作出更易于维护和更健壮的东西。

现在,静态数据本身并不是一件可怕的事情。事实上,在我们谈论到网络在第九章之前,我们将继续在本教程中使用“静态”数据。目前我们提供图书列表的方式存在的一个很大问题是它不太适用于应用程序内部的其他地方。

目前,我们仅在应用程序的目录视图中显示图书。然而,稍后我们将搜索图书、保存图书等。让我们找出一种更为适应未来的前进方式。

列表视图中的动态数据

我们的图书列表视图是开始寻找使数据显示更动态化的好地方。如果您还记得,图书列表当前作为主对象类型Book的临时属性sampleData存储,以便更容易找到和使用。

然而,随着我们向应用程序添加显示数据的更多视图,我们会发现这有些重复和繁琐。让我们通过在 UI 控制器和列表视图之间添加一层来使其更加健壮。

Android

“数据源”的概念在 Android 中存在,并且在某些组件中(如下一代媒体播放器ExoPlayer)只是存在。此外,Android 还有一个“内容提供者”的概念,可以为您的应用程序管理手机上的联系人提供信息。但是,使用“适配器”模式的组件往往会将数据源保留为任意的。您可以拥有对象的编译列表,设备上的 XML 文件,在内存中的 JSON 字符串或通过网络与 REST Web 服务通信。

对于我们的第一个草稿,我们只是将所有对象添加到静态的array对象中。虽然这样做是可以的,但显然不容易维护——每次将书籍添加到我们的图书馆时,我们都必须更新该数据结构并发布一个新版本的应用程序,并希望所有用户都能勤奋地更新。长远来看,我们可能希望使用能够由图书管理员更新并偶尔调用适当服务端点(URL)更新我们的书籍列表的 REST Web 服务。我们肯定希望将数据持久化到设备上,并且如果我们希望能够使用广为人知和高度审查的 SQL 进行排序或过滤,我们可能希望将这些 Web 服务的 JSON 响应转换为数据库行。

现在,让我们简单地创建一个接口,提供给我们的Adapter所需的信息,并更新Adapter以适应这个契约,然后随着我们的需求和能力的发展进行更改。

让我们再次查看我们的Adapter,看看如何以可扩展、动态的方式满足它的需求:

嗯。看起来我们引用了几次我们静态的书籍数据源 —— 一次是在视图被回收时,我们想要更新行的视觉属性(目前是书籍的标题),另一次是确定我们数据源中书籍的总数,以便RecyclerView知道何时停止滚动。听起来很简单的组合:

现在让我们更新Adapter,使其接受任何实现该接口的类:

简单!如果我们想继续使用静态数组,我们可以创建一个简单的类来实现BookDataSource,但是非常容易使用硬编码的语料库:

你可能没有注意到,但是这个BookDataSource接口契约被所有List<Book>的实现隐式满足了 —— 你可以提供一个如下简单的数据源:

有了这种类型的数据源,你可以隐式地使用所有奇妙的List API,比如addaddAllsetremovesubList等。

因此,虽然我们现在仍然使用硬编码的书籍列表,但除了用新的数据源更新Adapter之外,我们不必对我们的RecyclerViewAdapter进行任何更改 —— 也许是从 Web 服务器或本地数据库读取的数据源。太好了!即使使用与以前相同的数据,我们的列表视图现在也更加动态化。稍后在本章中,我们将进一步删除我们的静态Book数组。现在,让我们看看如何在 iOS 上实现同样的功能。

iOS

就像我们在 Android 中使用Adapter模式一样,我们需要在控制器层和原始数据之间创建一些分离。我们可以通过添加一个新对象来直接与控制器进行接口交互来实现这一点。如果你还记得在CatalogViewController中,我们正在让视图控制器遵循UITableViewDataSource协议来为表视图提供数据。

这个方法一开始运行得很好,但是为什么不创建一个单独的对象直接为表视图提供数据呢?控制器可以管理该数据源对象,也可以管理表视图对象。这样就在控制器、视图(在这种情况下是表视图)和数据层之间建立了分离。

让我们通过点击应用程序菜单中的 文件 > 新建 > 文件 来向项目中添加一个名为 ListDataSource 的新文件。这个新对象将作为我们表视图的数据源。现在,这个文件会很基础。以下是一个示例实现:

import UIKit

class ListDataSource: NSObject {

}
extension ListDataSource: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return Book.sampleData.count
    }

    func tableView(_ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Dequeue a table view cell
        let cell =
          tableView.dequeueReusableCell(withIdentifier: "CatalogTableViewCell",
          for: indexPath)

        // Find the correct book based on the row being populated
        let book = Book.sampleData[indexPath.row]

        // Populate the table view cell title label with the book title
        cell.textLabel?.text = book.title

        return cell
    }
}

你可能会认出一些这段代码——或者,如果你一直在注意,会认出全部!这段代码与我们在CatalogViewController中用来显示书籍目录列表的代码是一样的。事实上,通过将这段代码放在一个单独的文件中,我们可以移除当前存放在CatalogViewController中的所有UITableViewDataSource代码。现在,CatalogViewController文件看起来是这样的:

class CatalogViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view
    }
}

如果你构建并运行我们的应用程序,你可能会发现我们已经走得太远了。CatalogViewController没有必要的方法来填充表视图作为数据源,但它仍然链接在Main.Storyboard中。这会导致SIGABORT,从而使应用程序崩溃。

糟糕。让我们看看能否回去修复这个问题。事实上,在我们修复问题的同时,让我们看看是否可以通过让CatalogViewController中的表视图直接使用我们的新ListDataSource作为其数据源来使事情变得更好。

首先,我们必须移除Main.storyboard中的关联。打开 storyboard 并点击表视图。在 Xcode 右侧窗格中,点击 Connections inspector。在里面,你会看到已经为dataSource建立的连接。点击“x”按钮从 storyboard 中移除该连接。

现在,让我们将表视图切换到使用一个新的、独立的数据源对象。为了做到这一点,我们需要将表视图暴露给视图控制器。为此,我们将使用 Xcode 中的 Assistant editor 添加一个新的连接。点击项目窗口右上角的 Assistant editor 按钮。这将自动打开相应的CatalogViewController用于该场景——有些人可能会说“神奇”。

一旦视图控制器在两个显示器中都激活了,就在表视图上按住控制键并拖动连接到代码窗口,类似于我们在前面章节中连接浏览按钮的方式。你将把连接直接拖到这里CatalogViewController类定义的下方:

class CatalogViewController: UIViewController {

    // Drag the connection to this line

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view
    }
}

会弹出一个模态框询问你是否正在创建一个Outlet,这是从视图控制器到特定视图的连接,或者你是否正在创建一个Action,这是一个控制事件(比如每当按下按钮时)。保持所有设置不变,将我们的新 outlet 命名为tableView

此时,CatalogViewController应该看起来像这样:

class CatalogViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view
    }
}

我们已经从 storyboard 到视图控制器中建立了一个连接,直接将我们的表视图暴露给视图控制器。建立这个连接的原因是使我们能够在视图控制器类中设置表视图的数据源。看看这是如何完成的:

class CatalogViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    lazy var dataSource: ListDataSource = {
        return ListDataSource()
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = dataSource
    }
}

让我们来看看代码的步骤。

首先要注意的是,我们在CatalogViewController内部添加了一个名为dataSource的新的lazy属性。lazy运算符使得此对象在访问属性时才被实例化。这通常被认为是数据源的良好实践,因为偶尔需要启动对象所需的时间。在我们的情况下,我们的数据源相当轻量级,但养成这样的良好习惯也是好的。它还允许我们保持初始化代码的封装性。在这种情况下,我们只是简单地创建了一个ListDataSource的新实例。

接下来,在viewDidLoad内部,我们添加了一些新代码。这个方法是视图生命周期的一部分;当它被调用时,我们可以安全地假设我们的输出和操作已经连接,视图控制器的view已加载。这个方法在视图生命周期中只被调用一次,但没问题,因为我们只需要一次。

viewDidLoad内部,我们将表视图的dataSource属性设置为我们在视图控制器内创建的数据源。如果您构建并运行应用程序,您会注意到目录加载与之前相同。

在这一点上,您可能会问自己:为什么这很重要呢?创建一个完整的数据层似乎比让视图控制器直接提供它们的数据更复杂。

公平的观点。

然而,如果您还记得,我们的应用程序仍在使用静态的、硬编码的数据。让我们看看当我们过渡到更便携的东西时会发生什么。您将看到将数据抽象出视图层的全部威力。让我们开始吧!

是时候让我们的模型对象真实起来了

好吧。我们的Book对象有一些属性。但是,说实话,大多数书籍的元数据远不止我们到目前为止展示的这些。此外,如果我们想要改变我们的数据,我们必须发布一个新版本的应用程序。并且,这些应用程序必须保持同步。随着将书籍保存到以后和应用程序复杂度增加,这变得越来越困难。

正如 Dr. Phil(是的,那个 Dr. Phil)会说的,“现在是我们的模型对象真实起来的时候了。”

幸运的是,我们可以用两种方法解决这个问题:

  1. 通过从静态、硬编码的数据切换到像 JSON 这样的可移植格式。

  2. 通过从静态的、硬编码的数据源切换到通过服务器提供的东西,或者是应用程序之外的另一个真实数据源。

正如本章前面提到的那样,我们将在本书的第六章中解决第 2 点。然而,让我们先看看第 1 点:转向 JSON。

为一而设 JSON,为所有设 JSON

我们决定使用 JSON。以下是我们的Book对象在 JSON 中的一个示例:

{
    "title": "...",
    "authors": ["..."],
    "isbn": "...",
    "pageCount": 0,
    "fiction": true
}

事实上,如果我们把我们之前添加到Book对象的sampleData属性中的数据转换为 JSON,并将其保存为一个名为catalog.json的新文件,我们最终得到一个如下所示的文件:

[
	{
		"title": "Fight Club",
		"authors": ["Chuck Palahniuk"],
		"isbn": "978-0393039764",
		"pageCount": 208,
		"fiction": true
	},
	{
		"title": "2001: A Space Odyssey",
		"authors": ["Arthur C. Clarke"],
		"isbn": "978-0451457998",
		"pageCount": 296,
		"fiction": true
	},
	{
		"title": "Ulysses",
		"authors": ["James Joyce"],
		"isbn": "978-1420953961",
		"pageCount": 682,
		"fiction": true
	},
	{
		"title": "Catch-22",
		"authors": ["Joseph Heller"],
		"isbn": "978-1451626650",
		"pageCount": 544,
		"fiction": true
	},
	{
		"title": "The Stand",
		"authors": ["Stephen King"],
		"isbn": "978-0307947307",
		"pageCount": 1200,
		"fiction": true
	},
	{
		"title": "On The Road",
		"authors": ["Jack Kerouac"],
		"isbn": "978-0143105466",
		"pageCount": 416,
		"fiction": true
	},
	{
		"title": "Heart of Darkness",
		"authors": ["Joseph Conrad"],
		"isbn": "978-1503275928",
		"pageCount": 78,
		"fiction": true
	},
	{
		"title": "A Brief History of Time",
		"authors": ["Stephen Hawking"],
		"isbn": "978-0553380163",
		"pageCount": 212,
		"fiction": false
	},
	{
		"title": "Dispatches",
		"authors": ["Michael Herr"],
		"isbn": "978-0679735250",
		"pageCount": 272,
		"fiction": false
	},
	{
		"title": "Harry Potter and Prisoner of Azkaban",
		"authors": ["J.K. Rowling"],
		"isbn": "978-0439136365",
		"pageCount": 448,
		"fiction": true
	},
	{
		"title": "Dragons Love Tacos",
		"authors": ["Adam Rubin", "Daniel Salmieri"],
		"isbn": "978-0803736801",
		"pageCount": 40,
		"fiction": true
	}
]

随意添加额外的书籍;现在是个人喜好和私人爱好的时候。

将模型层切换为 JSON

如果您回想一下,到目前为止,我们在数据层代码中仍在使用Book对象提供的sampleData。让我们在两个项目中都移除它,并切换到直接使用我们的 JSON。

Android

现在,我们有一个名为 catalog.json 的文件,其中包含所有 JSON Book 表示,保存在磁盘上。假设在 Android 应用中,它被保存为一个资产,在项目级别的 /assets/ 文件夹中。这个文件夹具有特殊属性和 API,使得像我们描述的这样的操作更容易使用,并且可能不会立即可用——如果在项目源中看不到名为“assets”的文件夹,请确保您在左侧窗格中的文件列表上方的下拉菜单中选择了“Android”项目视图,然后在 Android Studio 中右键单击项目名称,选择新建,然后文件夹,然后资产文件夹。您可以在此目录中创建一个新的文本文件,复制上述 JSON,并将文件保存为 catalog.json

让我们将我们的 BookDataSource 转换为使用它。首先,我们将在 UI 线程上读取文件,但这只是第一步——任何时候涉及到磁盘或网络传输时,请确保您在后台线程中工作。

如果我们回顾一下第 Chapter 6 中的示例代码,您可能还记得如何从磁盘读取文件。如果您回想起 Chapter 12 中的练习,您可能还记得几种将 JSON 解析为有效的 Java 对象实例的方法。我们将同时使用这两种技术。

让我们的数据源类提供此功能,尽管随着时间的推移,您可能会发现将一些逻辑分离出来更合适。现在,让我们从一个基本的 List<Book> 数据源开始,但我们会装扮它,以便在实例化时,它读取 catalog.json,将每个 JSON 对象转换为一个 Java Book 实例,并将所有这些 Book 添加到自身(一个 ArrayList)中。

如果您运行它,您将获得与 catalog.json 中的 JSON 对象数量相等的 Book 实例数组;然而,如果您使用了 Java 实现,这些实例将是裸的。每个属性将是默认的。这是因为我们在定义 Book 属性时使用了匈牙利命名法:

public class Book {
  private final String mTitle;
  private final String[] mAuthors;
  private final String mIsbn;
  private final int mPageCount;
  private final boolean mIsFiction;
  ...
}

当 Gson 查看 Book 类并尝试从它手头的 JSON 中找到要映射的属性时,它会发现 Book 类实例想要一个“mTitle”,而不关心“title”——另一方面,JSON 中没有“mTitle”、“mIsbn”或“mAnything”的参考——它使用人类可读的键名。幸运的是,这是 Android 中的一个常见问题,使用 Gson 的 SerializedName 注解可以轻松解决。提供一个 String 给这个注解,Gson 将检查该 String 和实际的属性名。进行以下更新并再次运行。在 Java 中:

public class Book {

  public static final Book[] SAMPLE_DATA = {
      new Book("Fight Club", new String[]{"Chuck Palahniuk"}, "978-0393039764", 208, true),
      new Book("2001: A Space Odyssey", new String[]{"Arthur C. Clarke"}, "978-0451457998",
        296, true),
      new Book("Ulysses", new String[]{"James Joyce"}, "978-1420953961", 682, true),
      new Book("Catch-22", new String[]{"Joseph Heller"}, "978-1451626650", 544, true),
      new Book("The Stand", new String[]{"Stephen King"}, "978-0307947307", 1200, true),
      new Book("On The Road", new String[]{"Jack Kerouac"}, "978-0143105466", 416, true),
      new Book("Heart of Darkness", new String[]{"Joseph Conrad"}, "978-1503275928", 78,
      true),
      new Book("A Brief History of Time", new String[]{"Stephen Hawking"}, "978-0553380163",
        212, false),
      new Book("Dispatches", new String[]{"Michael Herr"}, "978-0679735250", 272, false),
      new Book("Harry Potter and Prisoner of Azkaban", new String[]{"J.K. Rowling"},
      "978-0439136365", 448, true),
      new Book("Dragons Love Tacos", new String[]{"Adam Rubin", "Daniel Salmieri"},
      "978-0803736801", 40, true)
  };

  @SerializedName("title")
  private String mTitle;
  @SerializedName("authors")
  private String[] mAuthors;
  @SerializedName("isbn")
  private String mIsbn;
  @SerializedName("pageCount")
  private int mPageCount;
  @SerializedName("fiction")
  private boolean mIsFiction;

  public Book(String title, String[] authors, String isbn, int pageCount,
  boolean isFiction) {
    mTitle = title;
    mAuthors = authors;
    mIsbn = isbn;
    mPageCount = pageCount;
    mIsFiction = isFiction;
  }

  public String getTitle() {
    return mTitle;
  }

  public void setTitle(String title) {
    mTitle = title;
  }

  public String[] getAuthors() {
    return mAuthors;
  }

  public void setAuthors(String[] authors) {
    mAuthors = authors;
  }

  public String getIsbn() {
    return mIsbn;
  }

  public void setIsbn(String isbn) {
    mIsbn = isbn;
  }

  public int getPageCount() {
    return mPageCount;
  }

  public void setPageCount(int pageCount) {
    mPageCount = pageCount;
  }

  public boolean isFiction() {
    return mIsFiction;
  }

  public void setFiction(boolean fiction) {
    mIsFiction = fiction;
  }

}

这次,你的Book实例应该被完全和适当地填充。就这样!你有一个工作的序列化协议。让我们检查一下可能在你的视线之外的一些功能。首先,你可能会注意到前面的代码中这个难以阅读的繁琐:

Gson可以很好地将单个Book实例转换为 JSON 字符串,反之亦然,但是当我们开始处理集合时,情况变得不那么简单。一个选择是创建一个具有泛型的类:

这可以使用传统的 Gson 方法fromJson来调用:

String json = // ... some string represetning multiple Book JSON objects
Books books = new Gson().fromJson(json, Books.class);

但是,如果失败了,我们可以使用TypeToken类来动态生成泛型。你正在创建一个TypeToken子类的实例,然后调用它的getType方法,因此语法实际上是这样的:

new TypeToken<GENERICIZED_DATA_TYPE>() {
  // no op
}.getType();

在那个例子中,“GENERICIZED_DATA_TYPE”可以是包括任意数量泛型的数据类型;它可能像new TypeToken<Date>...那样简单,或者像new TypeToken<List<Map<String, List<Date>>>...那样有几层嵌套。

回到手头的例子。使用这个新数据源将自动更新你的AdapterRecyclerView,而且你甚至不需要改变你的适配器代码;只需确保在向Adapter类提供数据源时,构造函数包含一个Context参数,以便我们可以获取正确的文件目录:

这里的另一个有趣的技巧是,Books本身是Book实例的List,所以不必像前面的代码中看到的更新数据源那样,可以使用实用方法读取文件并直接将该字符串转换为数据源。假设我们恢复到没有方法的原始Books类:

假设我们还可以访问 第六章 中的方法。我们可以在ActivityonCreate方法中做类似这样的事情:

因为Books也是一个ArrayList,它将由catalog.json中的每本书填充,并能够成功地满足BookDataSourcegetsize合约要求。

现在我们已经设置好了,不再需要Book类上的静态数据结构SAMPLE_DATA,可以安全地将其删除了。Android Studio 的一个方便功能是“重构”选项。除了有重命名项目中所有出现的方法或变量的功能外,它还可以运行“安全删除”,仅在项目中不再使用变量时才删除它。在更新前的更新后,在Book.SAMPLE_DATA上尝试这个选项,你应该可以安全地删除它。

iOS

在 iOS 中,通过使用内置的Codable协议,开始使用 JSON 是非常容易的。这是一个由EncodableDecodable协议组成的协议。它们为 Swift 编译器提供了一些必要的理解来推断代码。

想要了解更多关于Codable的详细信息,请阅读第十二章。不过,现在让我们看一个例子,说明Codable如何让我们在应用程序中轻松地利用 JSON。

让我们在Book结构体内部添加对Codable的支持:

struct Book: Codable {
    let title: String
    let authors: [String]
    let isbn: String
    let pageCount: Int
    let fiction: Bool
}

就是这样。你可能注意到,我们还从对象中移除了sampleData属性。这是有意为之的,因为我们现在要切换ListDataSource,从使用sampleData改为使用我们新创建的catalog.json,其中包含我们图书馆全部图书的目录。

让我们来看看ListDataSource

class ListDataSource: NSObject {
    lazy var data: [Book] = {
        do {
            guard let rawCatalogData =
                try? Data(contentsOf:
                Bundle.main.bundleURL.appendingPathComponent("catalog.json")) else {
                return []
            }
            return try JSONDecoder().decode([Book].self, from: rawCatalogData)
        } catch {
            print("Catalog.json was not found or is not decodable.")
        }
        return []
    }()

}
extension ListDataSource: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }

    func tableView(_ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Dequeue a table view cell
        let cell =
          tableView.dequeueReusableCell(withIdentifier: "CatalogTableViewCell",
          for: indexPath)

        // Find the correct book based on the row being populated
        let book = data[indexPath.row]

        // Populate the table view cell title label with the book title
        cell.textLabel?.text = book.title

        return cell
    }
}

我们添加了一个名为data的新lazy属性。这个属性是一个Book数组。每当首次访问该属性时,会读取catalog.json文件到一个名为rawCatalogDataData类型对象中。然后,这个属性会被传递给一个JSONDecoder对象,以解码为[Book]类型的对象。

注意这里是[Book],而不是原始的Book。声明如此的原因是因为 JSON 实际上包含了一个 JSON 对象数组。Swift 编译器聪明地在调用站点动态将catalog.json转换为 Swift 内部对象时解释了我们的声明。如果找不到我们的 JSON 文件——或者 Swift 编译器无法将其解码为任何东西——我们将返回一个空数组[]

现在,我们还更新了用于驱动我们表视图的代码,在遵循UITableViewDataSource协议时。需要注意的代码行是:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	return data.count
}

还要注意方法tableView(_:cellForRowAt:)中的data[indexPath.row]

构建并运行应用程序,你会发现它与之前的功能一样——它显示了我们图书馆中所有书籍的列表——但现在书籍是通过应用程序捆绑包中的 JSON 文件动态提供给表视图的。

我们学到了什么

在这一章的短时间内,我们学到了:

  • 如何将紧密耦合的代码从视图层分离到一个新的数据层

  • 如何创建一个专门为向视图提供数据的离散对象

  • 如何切换到使用 JSON 文件作为应用程序数据源

在下一章中,我们将进一步探讨;我们将使用我们的新数据层,为我们喜欢的书籍提供一些额外的保存功能。请继续阅读!

第十九章:然而,我们坚持下去

如果我们对我们的应用程序目前的进展进行盘点,可以说进展非常顺利。我们拥有了一些可靠的功能。数据是通过可移植的格式支持的,具体来说是 JSON,而且它在功能上是完好的。这是我们进一步开发应用程序的一个很好的起点。

详述我们的书籍

首先,我们应该添加一个新的屏幕来显示关于一本书的更多信息。目前,除了标题之外,我们实际上没有办法查看关于我们的书籍的任何重要信息。你猜怎么着?书籍包含大量关于它们的信息。从标题、作者、ISBN 等等,不胜枚举!实际上,关于一本书的一些最有用的信息都包含在这些信息中。

我们知道我们将显示一本书。具体来说,我们当前的书籍模型对象Book中的信息如下:

  • title

  • authors

  • isbn

  • pageCount

  • fiction

虽然这不是大量的数据,但已经足够了。此外,我们稍后将在 Android 和 iOS 上扩展此屏幕,但现在让我们快速添加一些内容。

Android

如果你回想一下第二章和第十五章,接下来的内容应该非常熟悉。

首先,让我们使用 XML 定义我们的布局。我们知道我们想要以垂直方式显示每个Book实例的属性,所以让我们列出它们,稍后我们会通过编程方式装饰它们。同样,我们希望确保我们的LinearLayout被包裹在ScrollView中,这样无论屏幕大小、设备密度还是大字体渲染等可访问设置,我们都可以显示所有的信息。

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white">

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

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

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

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

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

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

  </LinearLayout>

</ScrollView>

让我们将前面的代码保存为res/layout/activity_detail.xml

因为我们希望这些TextViews显示完整的标签,所以我们将在strings.xml文件中引入占位符Strings的概念。占位符字符串只接受特殊的格式化字符,并允许用变量替换它们。详细了解 Java 的String.format方法以获取详细信息。

strings.xml中,让我们添加一些占位符:

<string name="detail_title">Book Title: %s</string>
<string name="detail_authors">Book Authors: %s</string>
<string name="detail_isbn">Book ISBN: %s</string>
<string name="detail_pagecount">Book Page Count: %d</string>
<string name="detail_isfiction">Book : %b</string>

接下来,我们需要一个 UI 控制器来显示、控制和修改布局,以及提供行为指令。在我们的情况下,是一个Activity

我们希望向 UI 控制器传递关于Book的一些信息,在 Android 中,这就是事情可能会变得争议的地方。一个Activity是以编程方式和不透明方式创建的,但正如在第一章中描述的那样,我们可以通过使用Intent启动控制器并通过Bundle实例传递一些基本数据。

对于这样的例子,你会发现有两种思路。有时候,一种思路比另一种更合理,但大多数情况下,这取决于你和你的团队。此外,除了这些细节,你可以随意尝试其他方法——事实上,我们当前的团队使用了一种非常不同、非常定制的方法来解决这个问题,这超出了本章的范围。

因此,第一种方法:您可以将整个对象序列化并作为String(或byte[])传递,并在新的控制器中反序列化它。这使得事情变得非常简单,但请记住,Bundle的大小限制为 1 MB,并且在任意数量的操作之间共享,可能还包括您没有知识的操作。

第二种方法是传递某种唯一标识符,比如一个 ID 号或者 URI,然后从另一个来源(比如本地数据库、JSON 存储或者远程服务器)完整地检索信息。

对于这个例子来说,为了简单起见,目前我们将使用第一种方法。这个Activity将期望在其Intent对象的“extra”中传递一个表示序列化Book对象的 JSON String——让我们称这个String额外信息为“BOOK_JSON”并将其保存在一个常量中。我们将在onCreate回调期间对其进行反序列化,并用这些属性装饰我们的视图。

当然,我们不要忘记在我们的清单中注册这个新的Activity

<activity android:name=".BookDetailActivity" />

太棒了!我们有一个带有 UI 的Activity,将显示我们对Book实例的所有详细信息。让我们从第十七章跳回到列表视图,并连接一个点击事件,以传递选定的Book实例给我们的新BookDetailActivity。首先,我们需要确保我们的行(目前只是一个TextView)有一个View.OnClickListener来捆绑其关联的Book实例并启动BookDetailActivity。我们可以在创建阶段(onCreateViewHolder方法)完成这一操作。由于这些Views是可回收的,我们只需要确保我们更新与适当书籍相关联的关系,这可以在绑定和更新周期中完成,由onBindViewHolder方法表示。

你的新的BrowseBooksAdapter应该是这样的:

如果现在运行该应用程序,每当您点击列表视图中的一行时,您应该会看到一个书籍详细信息屏幕显示出我们对该书籍的所有详细信息。恭喜您,您刚刚掌握了 Android 特定和 UI 编程通用使用的一种广泛使用的模式!请花一点时间给自己一个鼓励。

iOS

让我们从打开Main.storyboard开始。从库中拖动一个新的 View Controller 对象到画布上,就像我们添加其他屏幕一样。这个屏幕当前与应用程序的其余部分隔离开来,所以让我们通过一个 segue 连接它,以便稍后可以过渡到它。

这是 storyboards 擅长的地方。如果您在 Storyboard 编辑器的文档大纲中单击目录场景内的表视图单元格——我们在第四章,用户输入中创建的表视图单元格——您可以同时按住 Control 拖动,然后您将能够直接连接到表视图单元格本身的 segue。

在呈现的模态内部,选择Show作为过渡类型。如果构建并运行应用程序,您将看到在目录中点击书籍会将一个新的视图控制器(目前是空白的)推送到视图堆栈上。然而,目前存在一些 UI 上的怪异需要修复。如果返回目录视图,您会注意到我们的表视图单元格仍然处于选中状态。另外,我们的视图目前非常基础——只是一个白色屏幕。让我们来修复它吧!

添加一些详细信息到您的详细信息,以便您可以详细说明

要修复这两个问题,我们需要为新场景创建一个自定义视图控制器。在项目中添加一个名为DetailViewController的新文件。它应该像这样继承自UIViewController

import UIKit

class DetailViewController: UIViewController {

}

记住我们之前的清单和我们的 Android 应用程序当前拥有的内容,我们可以回想起我们将要向我们的视图控制器中添加一些视图。这些都将是标签,所以让我们现在添加一些视图输出;我们可以稍后在 Storyboard 编辑器中将它们连接起来。

添加视图输出后,您的视图控制器现在应该如下所示:

import UIKit

class DetailViewController: UIViewController {
    @IBOutlet var titleLabel: UILabel!
    @IBOutlet var authorsLabel: UILabel!
    @IBOutlet var isbnLabel: UILabel!
    @IBOutlet var pageCountLabel: UILabel!
    @IBOutlet var fictionLabel: UILabel!
}

然而,我们确实有一个问题。我们如何填充这些属性?

我们知道它们将来自一本书。因此,让我们创建一个名为populate(from:)的方法,它接受一个Book参数,我们可以用它来设置标签的文本。我们将在我们的目录视图控制器中使用这个方法在过渡时传递一个Book对象。最终带有这个方法的类应该如下所示:

import UIKit

class DetailViewController: UIViewController {
    @IBOutlet var titleLabel: UILabel!
    @IBOutlet var authorsLabel: UILabel!
    @IBOutlet var isbnLabel: UILabel!
    @IBOutlet var pageCountLabel: UILabel!
    @IBOutlet var fictionLabel: UILabel!

    func populate(from book: Book) {
        titleLabel.text = book.title

        // Flatten our authors array to a string separated by commas
        authorsLabel.text = book.authors.joined(separator: ", ")

        isbnLabel.text = book.isbn
        pageCountLabel.text = book.pageCount.description

        // Use our Bool value to display what kind of book it is
        fictionLabel.text = book.fiction ? "Fiction" : "Nonfiction"
    }
}

让我们返回到CatalogViewController并连接这个连接。如果您回忆起来,我们的过渡是自动触发的,因为我们将它连接到了表视图单元格本身。过渡的准备工作发生在触发过渡的视图控制器中。我们可以重写一个特殊的方法来添加我们的自定义准备代码:prepare(for:sender:)。它将UISegue对象作为第一个参数;这个对象包含目标视图控制器,它恰好是我们之前的DetailViewController

不过,在使用这种方法之前,我们需要从数据源中获取被点击的单元格所对应的书籍实例。为此,我们需要在ListDataSource上添加一个名为book(for:)的新方法,该方法将接受来自表视图的索引路径。将以下方法添加到ListDataSource中:

func book(for indexPath: IndexPath) -> Book {
	return data[indexPath.row]
}

返回到CatalogViewController并重写prepare(for:sender:)以在我们的过渡期间填充我们的目标视图控制器,像这样:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
	if let detailViewController = segue.destination as? DetailViewController,
	   let indexPath = tableView.indexPathForSelectedRow {
		detailViewController.populate(from: dataSource.book(for: indexPath))
	}
}

这种方法并不复杂,但理解其中的过程非常重要。首先,我们检查目标视图控制器类型是否为DetailViewController。如果是,我们继续进行if条件检查,并确保当前在表视图中有选定的行。这两个检查非常重要,因为此方法会针对从该视图控制器触发的每个转场调用。如果两个条件都为真,则我们获取DetailViewController实例,并调用我们之前创建的方法来填充书籍实例。

现在,在我们离开此文件并返回到我们的故事板编辑器以将事物连接起来之前,让我们添加一个修复:让我们清除表行选择。这不会自动发生,因为我们使用的是标准的UIViewController作为我们的基类,而不是UITableViewController。我们希望这在视图出现时发生,这将为我们的用户提供一个漂亮的淡出动画,以提供他们所选择的行的上下文信息。因此,我们将在viewDidAppear(_:)方法中执行此操作,这是我们视图控制器生命周期的一部分。

我们最终的CatalogViewController类应该如下所示:

import UIKit

class CatalogViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    lazy var dataSource: ListDataSource = {
        return ListDataSource()
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = dataSource
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let indexPath = tableView.indexPathForSelectedRow {
            tableView.deselectRow(at: indexPath, animated: true)
        }
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let detailViewController = segue.destination as? DetailViewController,
           let indexPath = tableView.indexPathForSelectedRow {
            detailViewController.populate(from: dataSource.book(for: indexPath))
        }
    }
}

添加此视图的最后部分非常简单,类似于我们过去所做的事情。我们需要在DetailViewController中为新的输出添加标签到场景中。请随意安排它们的位置。发挥创意!如果您想查看我们 GitHub 存储库中提供的示例项目,可以查看。在其中,我们使用堆栈视图(从我们的库选择器中)创建了一个视图,它根据屏幕的大小自动扩展和收缩。但是,只需将标签拖放到视图中,并添加一些自动布局约束即可。您可能最终会得到类似如下的东西,看起来像图 19-1。

您可能会注意到视图之间的一些拉伸 这是完全正常的

图 19-1。您可能会注意到视图之间的一些拉伸(这是完全正常的!)

让我们通过点击以显示编辑器右侧的 Identity 检查器,并从UIViewController切换到DetailViewController来将此场景切换为使用我们的自定义类DetailViewController。现在,从文档大纲中的视图控制器对象控件上进行 Control-drag,并选择我们之前编码的相应视图输出。

如果您构建并运行项目,您将看到在目录中点击项目后,该项目将填充书籍详细信息屏幕。太棒了!

保存书籍以便以后阅读

现在我们可以看到关于我们的书籍更多信息了,如果我们找到一本特别有趣的书想稍后保存,会发生什么呢?当然,我们可以将其保存在应用程序内存中,但这不会在应用程序重新启动之间保持。我们可以使用 JSON 将其保存到文件系统中,但如果我们要保存大量书籍,你会发现这很快会变得麻烦且性能不佳。别忘了:我们这个示例应用使用的数据集很小,但我们要为这个应用程序构建的图书馆拥有大量的书籍可供选择。

另一个重要的考虑因素是操作这些信息;像排序、过滤和分页这样的事情在大多数持久化引擎(如 Core Data,或任何 flavor 的 SQL,或者 Realm 或 Room 等)中很容易实现。但对于一个存储 JSON 对象的平面文件存储来说就不同了。同样地,当每个文件必须重新建立结构、顺序和键名时,你会注意到大小开始成为一个问题。对于小型或一次性数据对象,偶尔使用一个 JSON 文件可能不仅可以,甚至更可取,但对于我们希望用户浏览和策展的书籍库来说,我们需要更稳健一点的东西:一个数据库。

当我们在考虑最佳可用方法时(提示:本章节是关于本地持久化),让我们继续添加一个按钮以切换书籍为用户喜爱,我们稍后会在本章节中进行相关设置。

Android

由于我们的示例应用中未使用 ActionBar,因此我们会稍微偏离 iOS 的做法,将我们的保存按钮放在信息性 TextViews 相同的 UI 中。只需在 LinearLayout 的末尾添加一个 XML Button,并给它一个 ID 为 button_save。如果你想在视觉上进行区分,可以使用 android:layout_gravity="center",如下所示:

<Button
  android:id="@+id/button_save"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:layout_gravity="center"
  android:text="Mark as Favorite" />

新的详细布局应该如下所示:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

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

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

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

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

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

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

    <Button
        android:id="@+id/button_save"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Mark as Favorite" />

  </LinearLayout>

</ScrollView>

我们稍后会在建立持久化层之后处理保存详细信息的点击行为。请保持关注。

这样就很简单了。现在让我们在 iOS 上做同样的事情,尽管稍微有些痛苦。

iOS

让我们将按钮添加到导航栏中。这是放置此类操作的常见地方。如果你还没有进入详细场景,请打开 Main.storyboard,然后转到详细场景。在库中搜索“Navigation Item”,将其拖动到场景中。这将创建一个与视图控制器导航栏关联的导航项,我们可以在 storyboard 编辑器中编辑它。在编辑器的文档大纲中点击导航项(标记为“Title”)。显示属性检查器并清除标题字段中的值,以使其为空白。这将导致对象在文档大纲中更改为导航项。

如果你选择Push作为 segue 类型,这将由 Xcode 自动为你添加,因为Push是导航控制器的 segue 类型。然而,该 segue 类型已被弃用,这要求我们在创建场景时提供更多手动上下文,以保持事物在故事板编辑器内部。然而,我们也可以在代码中完成所有这些操作,但在故事板编辑器中进行操作可能更容易。至少,它帮助我们将 UI 设置集中在一个地方,而不是在代码文件和故事板中不必要地分散。

现在,在“库”中搜索“Bar Button Item”,并将该项拖放到场景中导航栏的右侧。在该项目的属性检查器中,将标题字段从“Item”更改为“保存书籍”。你的场景应该看起来像图 19-2。

保存书籍按钮添加到导航栏

图 19-2. “保存书籍”按钮添加到导航栏

让我们为这个新按钮创建一个操作。

打开助理编辑器,它会将代码和界面并排显示。在新按钮上进行控件拖拽,并将操作放置在我们populate(for:)方法声明的视图控制器下面的最底部。这将在DetailViewController内创建一个新的方法,看起来像这样:

@IBAction func saveBook(_ sender: Any) {
}

如果你构建并运行项目,你会在书籍详细信息屏幕的右上角看到按钮,但是目前点击它不会有任何反应。现在让我们解决这个问题。

为以后存储书籍

正如前面提到的,我们需要为以后存储我们的书籍。由于我们的图书馆规模很大,我们需要一些高性能的东西,这些东西可以在启动之间持久存在,并且可以让我们读取和写入数据。这开始听起来像是一个数据持久化层。

哇!

如果你正在构建跨平台的应用程序集合,就像我们在这本书中所做的那样,你会发现这是一个原生组件分歧的主要领域。我们可以使用像 Realm 这样的跨平台解决方案,但为了保持对每个平台的忠实,我们将使用 Android 和 iOS 中最常见和支持的选项。

首先,在我们分别讨论 Android 和 iOS 之前,让我们谈谈我们与数据持久化层的共同目标。我们有一些要求。期望的功能如下:

  • 点击“保存书籍”将会保存一本书的标识符到某种存储器中。

  • 这个数据存储将能够显示我们保存的返回标识符列表中的书籍。

  • 详细屏幕将指示书籍是否已保存。

  • 我们将能够从我们保存的列表中删除书籍。

Android 和 iOS 在这个列表中的前两个项目是它们最不同的方法。然而,由于我们使用的是 MVC 架构,很多代码差异将被限制在 Model 层。View 和 Controller 层(即需求列表中的最后两个项目)在架构上仍然有类似的代码。

因此,没有更多拖延,让我们构建一个持久化层。

Android

如前面几次提到的那样,使用 Android 框架持久化数据有多种方式,在撰写本文时,Google 建议我们使用 Room 库。然而,出于几个原因,我们将使用 SQLite,其中最重要的原因是 SQL 是非常成熟、经过充分验证,并被数据库社区公认为黄金标准。我们只能访问 SQLite 进行本地设备持久化,但所有主要 SQL 数据库(如 PostgreSql、MySql 和 MSSql)都适用相同的基本语法、规则和操作。当然,您可以尝试其他方法,比如 Room。Realm 是另一个流行的选择,另一个优势是它跨平台。

无论如何,让我们看看如何使用 SQLite 构建持久化层。首先,与所有关系型数据库管理系统一样,我们至少需要一张表。在我们的示例中,我们将使用三张表:一张用于 Book 实例,一张用于作者(表示为 Strings),以及一张表来关联这两者。关系表有时被称为 through 表、bridge 表、pivot 表或 join 表,但这是一个非常常见的范例,我们认为这是一个使用 SQL 的很好但简单的示例。

我们将使用 SQL 标准的“create table”语法。关键字的具体细节,请参阅开发文档,但我们会呈现足够多的内容让您立即上手。

CREATE TABLE IF NOT EXISTS BOOKS (
 ID INTEGER PRIMARY KEY AUTOINCREMENT,
 TITLE TEXT,
 ISBN TEXT,
 PAGECOUNT INTEGER,
 IS_FICTION INTEGER);
CREATE TABLE IF NOT EXISTS AUTHORS (
 ID INTEGER PRIMARY KEY AUTOINCREMENT,
 NAME TEXT);
CREATE TABLE IF NOT EXISTS BOOK_AUTHORS (
 BOOK_ID INTEGER REFERENCES BOOKS(ID),
 AUTHOR_ID INTEGER REFERENCES AUTHORS(ID))

您希望在应用程序首次启动时创建每张表,以便立即访问它们。如果您回忆起第七章中关于 SqliteOpenHelper 的细节,您可能记得可以通过重写的 onCreate 方法来实现这一点。请记住,这只会在第一次使用子类获取数据库实例时触发,使用 getReadableDatabasegetWritableDatabase 之一。对于我们的示例,我们总是更喜欢后者,这样我们可以更新我们的数据存储。

这是它可能看起来的样子:

现在,我们拥有了一个漂亮、闪亮的新数据库,但它是空的——我们所有的数据仍然在 catalog.json 中。让我们来解决这个问题。首先,我们知道我们需要方法来读取和写入 Book 实例到数据库中,所以让我们设置一下。也许像这样:

现在我们已经有了用于读写Book数据的帮助程序,让我们更新我们的DbHelper类,在单个onCreate调用期间读取catalog.json并将这些条目写入数据库!

现在,当你从你的DbHelper类获取一个SqliteDatabase实例时,onCreate将会运行,并且你将从assets特殊目录中安装的catalog.json文件中填充你的数据库!不错!

我们花了很多时间来构建我们的持久层。我们现在准备显示我们保存的书籍,但在继续之前,让我们先看看 iOS 上的情况。

iOS

对于 Android,我们使用了一个带有原始 SQL 调用的数据库来获取我们的数据。在 iOS 上,我们有另一种选项,允许我们直接在 Swift 中工作,而不必处理原始 SQL:Core Data。

Core Data

让我们先搞清楚一件事:Core Data 不是数据库。Core Data 是一个对象图,只是偶尔使用数据库作为其后备存储。它是为 iOS(以及其他 Apple 平台)提供的一个框架,旨在使与数据模型层及其持久性的工作无缝(而且相对)容易。

它可能非常复杂,但对于 90%的使用情况来说,这是应用程序的一个很好的选择。它非常适合我们的应用。为什么不将它添加到我们的项目中呢?

要将 Core Data 支持添加到现有项目中,你需要做一些事情:

  1. 数据模型文件和定义的任何实体

  2. 处理设置 Core Data 堆栈的数据控制器

  3. 在你应用程序的启动逻辑中进行初始化逻辑

开始使用模型文件

列表上的第一项,数据模型文件,是最容易完成的。在 Xcode 中,转到 文件 > 新建 > 文件,然后向下滚动到 Core Data 部分。在那里,选择数据模型然后点击下一步。让我们简单地命名我们的模型为“LibraryModel”,以保持事情简单,但实际上你可以按照自己的意愿命名这个模型。你应该在项目文件中看到一个名为LibraryModel.xcdatamodel的文件。

Core Data 使用具有关联属性的实体作为后备对象。如果你点击LibraryModel.xcdatamodel,你将看到一个空的实体列表。

我们在这里处于一个转折点。

我们可以继续使用我们已经在使用的现有Book.swift结构,并创建一个单独的实体,专门用于保存书籍,其中仅包含某种标识符(例如唯一字符串)。这样做的好处是保持我们的 Core Data 堆栈轻量级。然而,这种做法将 Core Data 视为数据库,限制了它在管理我们模型层整体时的真正潜力。

相反,我们将继续使用我们现有的catalog.json文件作为我们图书馆数据的来源,但只在应用程序首次启动时。这使我们能够将 Core Data 用作一种缓存,这样我们就不必在每次查看图书馆目录时将整个文件读入内存。

要向 Core Data 添加实体,请单击屏幕底部的“添加实体”按钮。您将看到一个名为“Entity”的实体在实体列表中创建。单击以显示屏幕右侧的实体检查器。将实体名称从“Entity”更改为“Book”,并在类下更改名称为BookManagedObject,以将我们的托管对象的实际文件名与实体名称分开。这样做的原因是在处理BookManagedObject类型的对象时可以识别我们正在处理的是 Core Data 管理的对象。

Book.swift文件中现有的属性需要作为Book实体内的属性重新创建。通过单击属性下的加号符号并为我们预先存在的Book结构中的每个属性和其关联类型(例如StringIntBool等)添加。

警告

对于authors属性,您需要将类型指定为“Transformable”。然后,在数据模型检查器中选择该属性,在生成的文件中设置自定义类类型为[String],以指示它是一个String数组。

现在我们已经定义了Book实体,让我们让 Xcode 为我们生成文件。在编辑器中选择书籍实体。显示数据模型检查器,在代码生成下拉菜单中将其从类定义更改为类别/扩展。单击菜单栏中的编辑器 > 创建 NSManagedObject 子类,然后按几次“下一步”使用默认值。

一旦生成完成,您会注意到创建了两个新文件:BookManagedObject+CoreDataClass.swiftBookManagedObject+CoreDataProperties.swift。第一个文件从技术上讲是不必要的,所以请将其拖到垃圾箱中。第二个文件包含了我们添加的所有 Core Data 属性,这些属性被装饰为带有@NSManaged的 Swift 属性。这是一些语法糖,让编译器知道 Core Data 正在管理这个特定的属性。

我们最终将使用这个扩展来为我们的模型添加一些功能。但是现在,让我们继续完成 Core Data 堆栈的其余设置。

初始化我们的堆栈

Core Data 堆栈有三个部分:

  1. 刚刚创建的数据模型文件,用于保存我们的实体描述。

  2. 持久存储容器,它为我们的应用程序和持久存储提供链接。在这种情况下,它将由 SQLite 数据库支持,但也可以是 XML 或内存存储。

  3. 托管对象上下文,这是我们所有BookManagedObject实例活动时所在的“草稿本”;这实际上是持久存储到数据库的内容。

我们已经处理了 (1) LibraryModel.xcdatamodel 是我们的数据模型文件。但是,我们仍然需要设置 (2) 和 (3)。通常,我们的 Core Data 堆栈在应用启动时初始化。我们将所有内容封装在一个DataController对象中,以便稍后更轻松地处理事务。

在 Xcode 中,创建一个名为 DataController 的新 Swift 文件,并将其添加到您的项目中。DataController 应该如下所示:

import Foundation
import CoreData

class DataController {
    var persistentContainer: NSPersistentContainer

    init(completion: @escaping () -> ()) {
        persistentContainer = NSPersistentContainer(name: "LibraryModel")
        persistentContainer.loadPersistentStores { (description, error) in
            if let error = error {
                fatalError("Core Data stack could not be loaded. \(error)")
            }

            // Called once initialization of Core Data stack is complete
            DispatchQueue.main.async {
                completion()
            }
        }
    }
}

让我们来看看这里发生了什么。首先,我们在 init(completion:) 方法中使用完成处理程序初始化我们的 DataController。此完成处理程序将在所有设置完成后调用,允许应用程序启动继续。

注意

存储将在调用它的任何线程上串行加载。我们可以通过在引导时添加一个新的描述来异步添加存储,该描述在启动时设置了属性 shouldAddStoreAsynchronouslytrue。然而,为了简化 Core Data 栈的加载,本例中我们没有使用此属性。

在初始化器内部,我们首先将对象的实例变量 persistentContainer 分配给一个新的 NSPersistentContainer 实例。这个容器做了很多工作,将数据模型文件和持久性存储协调器链接在一起。它是这两个对象之间的“粘合剂”。为了创建它,我们传入我们的数据模型文件的名称,去掉扩展名。一旦创建完成,我们获取持久化容器对象,并在其上调用 loadPersistentStores(_:) 方法,并传入一个带有一些错误检查的闭包。如果无法连接到数据库,无法读取模型文件或任何错误,我们调用 fatalError 杀死应用程序。最后,如果存储正确加载,我们调用 completion(),这是在 DataController 初始化期间传入的原始闭包。

我们已经设置好了我们的持久化存储,并且通过持久化容器设置了我们的托管对象上下文作为属性。稍后我们将深入讨论如何使用它。现在,让我们完成设置我们的栈,并使用刚刚创建的 DataController 对象。

打开 AppDelegate.swift。添加一个名为 dataController 类型为 DataController! 的新属性。在 application(_:didFinishLaunchingWithOptions:) 方法中,在底部的 return true 语句上面插入以下行:

// Initialize the Core Data stack
dataController = DataController() {
	// Override point to update user interface that initialization has completed
}

你的 AppDelegate 应该大致如下所示:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var dataController: DataController!

    func application(_ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey:
        Any]?) -> Bool {

        // Initialize the Core Data stack
        dataController = DataController() {
            print("Core Data stack has been initialized.")
        }

        return true
    }

    ...

}

如果您构建并运行应用程序,您将在 Xcode 控制台中看到以下文本:Core Data stack has been initialized.

是的!我们的 Core Data 栈已经运行起来了,但是现在实际上还没有任何内容是由 Core Data 驱动的。它仍然由 catalog.json 驱动。让我们从在我们的 ListDataSource 中使用原始 JSON 文件切换到直接使用 Core Data。

从 JSON 切换到 Core Data

我们可以通过首先在 BookManagedObject 上创建一个新的扩展来完成这个。您可以在一个新文件中完成这个操作。Swift 扩展的常规做法是将文件命名为类似 BookManagedObject+Extensions.swift。在这个文件中,我们将创建一个新的 fetched results controller,它本质上是一个控制 Core Data 获取结果的对象。以下是我们的扩展应包含的内容:

import Foundation
import CoreData

extension BookManagedObject {
    class func newCatalogResultsController(with dataController: DataController,
      delegate: NSFetchedResultsControllerDelegate?) ->
      NSFetchedResultsController<BookManagedObject> {
        let request = NSFetchRequest<BookManagedObject>(entityName: "Book")

        // Add a sort by title description to the fetch request
        let titleSort = NSSortDescriptor(key: "title", ascending: true)
        request.sortDescriptors = [titleSort]

        let context = dataController.persistentContainer.viewContext
        let fetchedResultsController =
          NSFetchedResultsController(fetchRequest: request, managedObjectContext: context,
          sectionNameKeyPath: nil, cacheName: "catalog.cache")

        // Assign the delegate to handle updates
        fetchedResultsController.delegate = delegate

        do {
            try fetchedResultsController.performFetch()
        } catch {
            fatalError("Catalog fetch could not be completed. \(error)")
        }

        return fetchedResultsController
    }
}

这里发生了很多事情,但真正重要的只有几件事情。首先,我们正在创建一个新的获取请求。获取请求是我们查询 Core Data 模型对象的方法。我们通过 title 对请求进行排序,这似乎是对书籍列表进行排序的最合适方式。接下来,我们获取 viewContext 作为我们将在此获取操作中操作的托管对象上下文。viewContext 是在主线程上操作的上下文。在主线程上查看托管对象是完全可以接受的,也是推荐的。然而,如果我们正在编写对象,我们将在后台线程上执行我们的操作。

在获取我们的上下文之后,我们创建了一个类型为 NSFetchedResultsController 的获取结果控制器对象。这是一个专为 Core Data 设计的对象,用于为类似于表视图数据源或集合视图数据源的数据源提供获取对象并将其供应给它们。最终,我们将让 ListDataSource 遵循我们在此处分配的代理协议 NSFetchedResultsControllerDelegate,以便它可以接收更新并填充表视图,但目前,我们只是将属性设置为传入的 fetchedResultsController

最后,在 Core Data 中使用 performFetch() 方法执行实际的获取操作。这个方法可能会 throw 异常,所以它被包裹在 try 块中以捕获任何错误。对于示例应用程序,我们只是调用 fatalError,但在生产应用程序中,捕获错误并适当处理或向用户显示消息是非常重要的。

让我们使用这个扩展并连接我们的数据源。

转到 ListDataSource。我们将对此文件进行一些更新。首先要更新的是删除我们一直在使用的用于延迟加载 JSON 文件的 data 属性。用一个新的 fetchedResultsController 属性来替换它,如下所示:

var fetchedResultsController: NSFetchedResultsController<BookManagedObject>?

接下来,我们需要向 ListDataSource 添加一个名为 fetchCatalogResults 的新方法,该方法创建我们刚刚创建的目录获取结果控制器。我们将这个存储在我们的新属性中:

func fetchCatalogResults(with dataController: DataController) {
	fetchedResultsController =
    BookManagedObject.newCatalogResultsController(with: dataController, delegate: nil)
}

接下来,我们需要向 ListDataSource 添加一个名为 fetchCatalogResults 的新方法,该方法初始化我们之前创建的获取结果控制器的实例。然后,我们将该实例存储在我们的新属性中,如下所示:

import UIKit
import CoreData

class ListDataSource: NSObject {
    var fetchedResultsController: NSFetchedResultsController<BookManagedObject>?

    func fetchCatalogResults(with dataController: DataController,
      delegate: NSFetchedResultsControllerDelegate?) {
        fetchedResultsController =
          BookManagedObject.newCatalogResultsController(with: dataController, delegate:
          delegate)
    }

    func book(for indexPath: IndexPath) -> Book? {
        return fetchedResultsController?.object(at: indexPath).book
    }
}
extension ListDataSource: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return fetchedResultsController?.sections?[section].numberOfObjects ?? 0
    }

    func tableView(_ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Dequeue a table view cell
        let cell =
          tableView.dequeueReusableCell(withIdentifier: "CatalogTableViewCell", for:
          indexPath)

        // Find the correct book based on the row being populated
        guard let book = fetchedResultsController?.object(at: indexPath) else {
            fatalError("Could not retrieve book instance.")
        }

        // Populate the table view cell title label with the book title
        cell.textLabel?.text = book.title

        return cell
    }
}

我们已经使用 fetchedResultsController 来尽可能地提供结果。需要注意的唯一一点是,在方法 book(for:) 中,我们获取了一个 BookManagedObject,然后使用我们早些时候创建的扩展方法将其转换为 Book 实例。在应用程序的视图控制器和视图层中,我们将继续使用 Book 实例而不是直接传递托管对象实例。

唯一缺少的部分是将 CatalogViewController 切换到使用新的数据源方法。我们通过在懒加载属性 dataSource 中调用 dataSource.fetchCatalogResults(with:delegate:) 来实现这一点,如下所示:

lazy var dataSource: ListDataSource = {
	let listDataSource = ListDataSource()
	let dataController = (UIApplication.shared.delegate as?
	AppDelegate)!.dataController!
	listDataSource.fetchCatalogResults(with: dataController, delegate: nil)
	return listDataSource
}()

构建并运行应用程序,你会看到……在目录结果中什么也没有。

如果你还记得,我们创建了 Core Data 对象,但是没有填充它们的数据。iOS 应用程序的常见方法是将预先填充的数据库或某种数据捆绑到应用程序中,通过 Web 或应用程序包内的方式启动应用程序。在这种情况下,我们将使用我们的catalog.json来预填充数据库(如果在创建之前不存在)。

种子数据库

打开我们之前创建的DataController。将以下代码添加到init(with:)方法的顶部,同时添加一个名为shouldSeedDatabase的新变量:

var shouldSeedDatabase: Bool = false

init(completion: @escaping () -> ()) {
	// Check if the database exists
	do {
		let databaseUrl =
      try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
      appropriateFor: nil, create: false).appendingPathComponent("LibraryModel.sqlite")
		shouldSeedDatabase = !FileManager.default.fileExists(atPath:
	databaseUrl.path)
	} catch {
		shouldSeedDatabase = true
	}

	...
}

首先,我们获取预期数据库路径。然后,如果数据库尚不存在,我们将变量shouldSeedDatabase设置为true。这是一个简单的逻辑,但非常有用。现在,让我们创建实际种子数据库的方法。我们将称之为seedData()

private func seedData() {
	do {
		guard let rawCatalogData =
      try? Data(contentsOf:
      Bundle.main.bundleURL.appendingPathComponent("catalog.json")) else {
			return
		}
		let books = try JSONDecoder().decode([Book].self, from: rawCatalogData)

		persistentContainer.performBackgroundTask { (managedObjectContext) in
			// Loop through the books in the JSON and add to the database
			books.forEach { (book) in
				let bookManagedObject =
          BookManagedObject(context: managedObjectContext)
				bookManagedObject.title = book.title
				bookManagedObject.authors = book.authors
				bookManagedObject.isbn = book.isbn
				bookManagedObject.pageCount = Int16(book.pageCount)
				bookManagedObject.fiction = book.fiction
			}
			do {
				try managedObjectContext.save()
			} catch {
				print("Could not save managed object context. \(error)")
			}
		}

	} catch {
		print("Catalog.json was not found or is not decodable.")
	}
}

让我们详细了解一下这段代码的功能。

你可能会认出这行。首先,我们获取应用程序包中存在的catalog.json文件。接下来,我们将其从 JSON 解码为一个数组,其中包含Book对象。下一行代码,从persistentContainer.performBackgroundTask开始,使用我们在初始化 Core Data 堆栈时创建的NSPersistentContainer在后台队列中执行任务。这很重要,因为这是一个写入持久存储的任务,最终会阻塞主线程直到完成。我们通过调用此方法获得一个NSManagedObjectContext,其反过来用于在遍历 JSON 中的books数组时创建一个新的BookManagedObject

每个BookManagedObject都是在后台管理对象上下文中创建的。重要的是要注意,实际上它只存在于内存中。直到我们在管理对象上下文上实际调用save()方法,对象和更改才会通过持久存储协调器持久化,并传递到数据库(如果需要的话)。

但是,运行应用程序不会填充数据库。这是因为数据库已经存在,并且此代码在填充数据库之前检查确保它不存在。解决方案是:从 iOS 模拟器中删除应用程序,然后再次构建和运行应用程序。如果一切顺利,你将在我们的目录视图中看到与之前相同的书籍列表,按title排序。

哦,终于完成了。松了口气。我们就像在我们的 Android 应用程序中一样使用数据库。但是,我们进一步利用了 Core Data 的全部优势,而不仅仅是直接使用 SQLite 数据库。但是,我们还没有完成!我们需要将这个数据库用于我们最初的目的:保存书籍以便以后使用。让我们回到项目的这一部分吧!

保存书籍

因此,我们的持久化层保存了我们的书籍。如果以后我们提供了一个远程服务,可以从图书馆更新更多书籍到我们的数据库中,我们已经准备好了基础设施和管道来完成这项工作。那还剩下什么呢?还记得我们之前添加的按钮吗,用于将Book实例标记为当前用户的收藏书籍?现在让我们来连接它。

Android

正如我们多次提到的,有几种方法可以实现这一点,可以合理假设有人可能希望向数据库的BOOKS表中添加一列,并将一个INTEGER值设置为 0(false)或 1(true),以便在切换开关时进行操作(与大多数 SQL RDBMS 不同,SQLite 没有布尔数据类型,通常在模式中使用整数)。事实上,在长期来看,这可能是我会做的方式。然而,对于本例子,我们将使用另一种方法,首先是为了简单起见,同时也为了展示 Android 框架中内置的另一个常见且便利的特性,在第一部分,任务和操作,在第十一章中有所讨论。

如前所述,SharedPreferences接受Set类型的Strings。这似乎是这种工作的理想数据结构——我们希望通过它可以轻松地识别书籍的唯一、无序集合(通过书籍的 ISBN 号码),从而确定书籍是否是用户的收藏。如果切换为“打开”,我们将 ISBN 添加到Set中;如果切换为“关闭”,我们则移除它。简单!在某些时候,您可能想要添加一个自定义 UI,例如一个从金色变灰色的星星,或者在列表中进行特殊样式设置,但目前我们只会更新按钮的文本来标识当前状态。

那么首先,让我们创建一个方法来在SharedPreferences中切换Set<String>中的Book。假设我们将在BookDetailActivity类中定义此功能,并因此可以本地访问Context对象(即活动本身)及其所有访问器:

我们可以在点击监听器中为按钮添加该方法,如下所示:

就这样!运行应用程序,从列表中选择一本书,在详细页面中切换收藏设置。由于此值存储在持久结构中,因此该值将在应用程序启动和电源循环之间保持不变。

我们学到了什么

恭喜!我们的应用程序拥有多样化但基本的功能集。该应用程序在 Android 和 iOS 之间的使用方式是一致的。在本章中,我们学习了数据持久化以及它在两个平台上的不同之处。Android 有一种更加可接近的数据库方法来存储和访问数据,而 iOS 采用了一种更抽象的方法,但提供了更多的免费功能,尽管增加了相当多的复杂性。

在我们的应用程序的日落之前,我们还需要再看一个方面:网络连接。让我们在下一章中详细介绍如何向此应用程序添加一些轻量级的连接,以及其中的陷阱和最佳实践。

第二十章:网络和我们的应用

哇。我们的应用已经走了很长一段路了。上一章处理数据库,确实让人费了一番周折。现在我们有了一个很棒的应用来浏览我们的图书目录。我们甚至可以将一些书籍标记为喜爱,以便稍后再查看。太棒了!也许现在是时候把这个应用搁置一下,开始着手另一个项目了。

但等等!之前那位图书馆员,我们的八旬导游和灵感来源,从你正在工作的地方的阴影中走了出来,告诉你一个让你毛骨悚然的秘密:这不是唯一的这种类型的图书馆。在全球各地不同的国家散布着多个位置,它们都必须在这个应用程序中显示,以帮助像你这样的读者解锁其中蕴含的知识。

看起来我们还没有完成这个项目。

现在,我们如何支持这个新功能呢?我们可能可以像之前处理我们的目录一样:将位置列表与应用捆绑在一起。然而,这种方法似乎有点静态和不变。如果一个地方关闭了,一个疲惫的旅行者徒劳地搜索人类的知识,那该怎么办呢?也许最好的方法是,利用这个机会终于打破这个应用周围的障碍,让它通过互联网与外界通信!这将使我们的数据像周围的世界一样动态和变化。

所以准备好了。让我们调整设置,开始搜索吧!

探索世界

从根本上讲,与搜索端点的交互在 Android 和 iOS 上都是相同的。它包括以下步骤:

  1. 一个按钮或搜索栏来启动搜索。

  2. 一串文本,传递到某个网络服务的地方。

  3. 一个包含我们查询结果的响应。

  4. 显示我们结果的屏幕——很可能是一个列表或表视图。

我们首先需要一种在 UI 中启动搜索的方法。在 Android 上,因为我们再次省略了ActionBar,我们将在BrowseContentActivity的顶部添加一个名为SearchView的 AOSP 提供的组件。当输入字符串并且可操作(按钮按下、Enter 键、IME 动作)时,我们将把该值传递给一个 Web 服务,在成功返回时将结果显示在一个新的Activity中。

现在,让我们修改res/layout/activity_browse.xml的布局,以包含一个SearchView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

  <SearchView
      android:id="@+id/search_locations"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:background="#FFFFFFFF" />

  <androidx.recyclerview.widget.RecyclerView
      android:id="@+id/browse_content_recyclerview"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:background="#FFFFFFFF" />

</LinearLayout>

现在,让我们在现有的Activity中连接一些基本功能:

  1. 在清单中添加新的搜索活动。

  2. 显示搜索活动和布局。

  3. 更新此日志语句,以调用本地项目中的搜索方法。

  4. 修复表情符号。

与此同时,在 iOS 上,我们将在目录屏幕顶部添加一个搜索按钮。这将显示一个UISearchController,其中包含一个UISearchBar,我们将用它来捕获搜索用户正在搜索的文本;我们将用它来请求我们的搜索端点。

让我们从 UI 开始吧!

Android

让我们准备一个简单的布局和Activity来显示我们的搜索结果:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFFFF">

  <androidx.recyclerview.widget.RecyclerView
      android:id="@+id/search_results_recyclerview"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:visibility="gone"
      android:background="#FFFFFFFF" />

  <ProgressBar
      android:id="@+id/search_progress"
      android:indeterminate="true"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_gravity="center" />

</FrameLayout>

这将提供一个旋转器,向用户显示正在发生的事情,同时等待来自服务器的结果。

我们的Activity应该期望从SearchView传递一个String额外参数作为查询,我们将使用它来执行从 Web 服务获取数据并根据需要操作 UI。

让我们在这里做一些假设。假设我们的 URL 是magic://my.app/search(我们将使用方案“magic://”,以便不会忘记缺失的部分——大多数 Java HTTP 客户端将拒绝具有友好描述错误的此方案),并接受名为queryGET查询参数。它将返回一个 JSON 响应,其中包含一个Location对象数组,如果没有匹配项则返回一个空数组。

假设 JSON 看起来像这样:

[
    {
        "street_address": "123 Eiffel Tower Street",
        "city": "Paris",
        "country": "France",
        "emoji": "",
        "hours": "8am–7pm"
    },
    {
        "street_address": "86 Libery Boulevard",
        "city": "New York",
        "country": "America",
        "emoji": "",
        "hours": "6am–10pm"
    }
]

我们的数据结构将看起来像这样:

我们已经有了布局、模型和一般策略,剩下的是实际实现我们的逻辑。我们将在下一节深入探讨。

iOS

我们在 iOS 上的方法非常相似,但略有不同。在 iOS 上,通常在导航栏中有一个按钮来启动搜索过程。首先,打开Main.storyboard并转到欢迎场景。从库中拖动一个条形按钮项目到导航栏的右侧。应该会出现一个轮廓,您可以在其中放置条形按钮项目。使用属性检查器,将系统项的值设置为“搜索”。注意,这将从标有“项目”的普通按钮更改为带有放大镜图标的按钮。

现在,欢迎屏幕将作为我们的起点。我们将 segue 到另一个视图控制器来处理显示搜索栏和搜索结果。为了执行此操作,在画布的欢迎场景附近从库中拖动一个新的表视图控制器对象。然后,在表视图场景中,展开表视图并选择文档大纲中的表视图单元对象。在屏幕右侧的属性检查器中,将重用标识符的值设置为LocationCell。然后,如果您愿意,在身份检查器中将标题值设置为“位置”,以便每当显示时,屏幕上会显示此标题。

最后,回到欢迎屏幕,控制点击并从我们之前放置的搜索按钮拖动到新的位置场景,设置类型为“显示”。这将连接一个 segue,每当我们点击欢迎屏幕上的放大镜按钮时,将呈现表视图场景。

构建并运行应用程序,您将看到屏幕右上方的新搜索按钮。点击图标,应该会呈现一个空白的表视图,不显示任何结果。

不幸的是,当涉及到搜索时,我们在 storyboard 编辑器中的旅程就到此为止了。我们还有一个步骤,那就是为结果视图控制器创建一个自定义类,以便我们能够初始化搜索。通过在菜单栏中选择 File > New > File,添加一个名为 LocationsTableViewController 的新 Cocoa Touch 类,它继承自 UITableViewController。要将我们的表视图控制器切换到这个类,选择 Locations 场景,在 Identity inspector 中将 Custom Class 的值设置为 LocationsTableViewController

控制我们的搜索

在 iOS 中有一个方便的类叫做 UISearchController,可以很简单地更新 UI 来进行搜索。然而,在 storyboard 编辑器或 Interface Builder 中无法使用这个类。我们必须手动重新创建它。

打开 LocationsTableViewController.swift。我们要做的第一件事是删除从 UITableViewController 继承时 Xcode 给出的所有样板代码。删除你看到的一些现有方法,以便最终得到一个看起来像这样的视图控制器:

import UIKit

class LocationsTableViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    // MARK: - Table view data source

    override func numberOfSections(in tableView: UITableView) ->
    Int {
        // #warning Incomplete implementation, return the number of sections
        return 0
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section:
    Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
        return 0
    }
}

我们要做的第一件事是在我们的初始视图控制器 LocationsTableViewController 加载其视图时实例化并保存我们的搜索控制器以供使用。如果你还记得我们在 第二章 中讨论过的视图控制器的生命周期,这是通过在视图控制器上覆盖 viewDidLoad 方法来完成的。在这个示例中,我们将 UITableViewController 作为父类使用;然而,UITableViewController 继承自 UIViewController,因此它遵循相同的一组方法。

viewDidLoad 方法的方法体中添加以下代码:

override func viewDidLoad() {
	super.viewDidLoad()

	let searchController = UISearchController(searchResultsController: nil)
	searchController.searchBar.delegate = self
	searchController.searchBar.placeholder = "Search Locations by Country"
	searchController.obscuresBackgroundDuringPresentation = false
	definesPresentationContext = true

	navigationItem.searchController = searchController
	navigationItem.hidesSearchBarWhenScrolling = false
}

这段代码创建了一个新的 UISearchController 对象,并将其赋给变量 searchController。然后,我们设置了搜索栏的 delegate,以便在用户输入搜索内容后进行响应。我们马上就会遵循这个协议,所以让我们继续——当然,现在先忽略编译器错误。接下来,我们为创建的搜索栏设置了一些占位文本。在这个示例中,它只是“Search Locations by Country”,但实际上可以是任何我们希望为用户提供搜索指导的文本。

下面两行对于显示搜索结果非常重要。UISearchController用于在 iOS 中提供一致的搜索体验。有一些预定义的行为,我们希望配置以使搜索体验尽可能好。第一个属性,obscuresBackgroundDuringPresentation,防止搜索控制器将当前视图控制器调暗以显示搜索结果。这一点很重要,因为当我们初始化搜索控制器时,我们并没有直接提供一个新的searchResultsViewController,所以我们的搜索结果将显示在这个视图控制器中。如果这个视图控制器变暗,用户体验可能会有些不对劲。

因此,我们还需要确保将definesPresentationContext设置为true,这样当UISearchController显示其结果视图时,我们所在的视图控制器就是提供结果显示和搜索栏显示上下文的视图控制器。基本上意味着该视图控制器将防止搜索栏在导航到另一个视图时仍然保留在屏幕上。

我们做的最后一件事是将刚刚创建的searchController设置为导航项的搜索控制器。这允许控制此视图的父导航控制器在其导航栏中显示搜索栏(以及其他内容)。我们还设置了一个名为hidesSearchBarWhenScrolling的属性,以确保搜索栏始终可见。

现在,我们需要让我们的视图控制器遵循UISearchBarDelegate。这是更新显示搜索结果的表视图所需的协议。每当键盘上的搜索按钮被点击(或者按下 Return 键),就会调用此方法。这个方法的一个简单但临时不完整的版本可以作为一个扩展添加到LocationsTableViewController上,像这样:

extension LocationsTableViewController: UISearchBarDelegate {
    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        // TODO: Update the table view from the search results
    }
}

如果你构建并运行项目,每当你点击放大镜查看位置搜索屏幕时,你会看到类似于这样的内容。

我们离填充这个表视图非常近了。现在是开始与网络通信的时候了!

构建搜索端点

现在,你可能已经注意到,这并不是一本关于网络服务的书籍。这让我们陷入了一些困境,因为为了与网络服务通信,我们需要有……一个网络服务。有几种方法可以解决这个问题。如果你愿意,你可以只是在代码中跟着进行,而不实际访问网络服务。幸运的是,应用程序应该仍然可以正常运行,只是没有任何结果。接下来,我们将看一下我们的应用程序将要消耗的库位置 JSON 文件是什么样子,这样你可以将其放在本地某个地方,并让应用程序消耗该文件。

警告

如果您在本地添加文件,您需要确保您指定内容的 Content-Typeapplication/json。一些服务(如 Google Drive)支持这一点,但这超出了本书的范围。

完全可行的一个选项是编写一个非常快速和简单的 node.js 服务来处理提供内容。实际上,作者已经写了这样一个服务。它使用 Express 让事情变得简单。如果你对 Node 有所了解,那么在本地工作时非常容易处理服务。

安装 Node 和 Express

下面是最快的“快速启动和运行”网络服务的方法。如果您已经熟悉 Node,您可能可以跳过这部分。或者,如果您不想安装和使用 Node,您也可以安全地跳过这部分。这些说明大部分来源于 Node 和 Express 的官方网站。让我们开始吧!

  1. 转到 https://nodejs.org 并安装最新版本的 Node。

  2. 打开终端并创建一个名为 library-node-service 的目录来存放你的项目。

    $ mkdir library-node-service
    $ cd library-node-service
    
  3. 通过在提示符号处调用 npm init 来初始化一个新的 Node 项目。会有一些选项,你可以安全地按 Enter 键跳过所有选项并使用默认值。

  4. 运行命令 npm install express --save 来将 Express 安装到项目中。Express 是一个轻量级框架,非常适合快速编写 Node 服务。非常适合我们的使用场景。

  5. 如果 index.js 文件不存在,请创建一个名为 index.js 的文件,并将以下代码放入该文件中:

    const express = require('express')
    const PORT = process.env.PORT || 3000
    
    express()
      .get('/catalog', (req, res) => res.json(catalog(req)))
      .get('/locations', (req, res) => res.json(locations(req)))
      .listen(PORT, () => console.log(`Listening on ${ PORT }`))
    
    catalog = (req) => {
    	var fs = require('fs');
    	var json = JSON.parse(fs.readFileSync('catalog.json', 'utf8'));
    	const query = (req.query.q || "").toLowerCase()
    	return json.filter(book => book.title.toLowerCase().startsWith(query))
    }
    
    locations = (req) => {
    	var fs = require('fs');
    	var json = JSON.parse(fs.readFileSync('locations.json', 'utf8'));
    	const query = (req.query.country || "").toLowerCase()
    	return json.filter(loc => loc.country.toLowerCase().startsWith(query))
    }
    
  6. 从 Xcode 或者 Android Studio 项目中复制 catalog.json 的版本,并将其放在与 index.js 相同的目录中。(当我们马上创建 locations.json 时,也是同样的操作。)

  7. 要在本地运行应用程序,请在命令提示符中输入 node index.js。你的应用现在应该可以通过 http://localhost:3000 访问。你可以通过访问 http://localhost:3000/catalog 来检查,你应该能看到我们 catalog.json 文件中所有书籍的列表。

位置 JSON 文件

我们将再次使用 JSON 作为从网络服务接收到的数据的传输结构。以下是我们的 locations.json 文件应该如何看起来的示例:

[
    {
        "street_address": "123 Eiffel Tower Street",
        "city": "Paris",
        "country": "France",
        "emoji": "",
        "hours": "8am–7pm"
    },
    {
        "street_address": "86 Libery Boulevard",
        "city": "New York",
        "country": "America",
        "emoji": "",
        "hours": "6am–10pm"
    },
    {
        "street_address": "49 Lombard Street",
        "city": "San Francisco",
        "country": "America",
        "emoji": "",
        "hours": "8am–8pm"
    },
    {
        "street_address": "1901 Aussie Way",
        "city": "Melbourne",
        "country": "Australia",
        "emoji": "",
        "hours": "7am–8pm"
    },
    {
        "street_address": "302 Deutsch Avenue",
        "city": "Berlin",
        "country": "Germany",
        "emoji": "",
        "hours": "9am–6pm"
    }
]

你可能已经注意到,这里包含一些位置信息。这些只是示例位置(实际并不存在),用来展示我们数据的结构。随意添加更多位置,以您认为合适的方式。这是一个发挥创造力的机会!

调用我们的服务

为了向我们的服务发送请求,在 Android 和 iOS 中,将一个对象建立为处理通信的责任对象,而不是直接在我们的视图层中处理 API 调用,这是一个良好的做法。我们将创建的对象称为 LocationsController,它将作为我们搜索用户界面和网络服务之间的中介。一旦创建了对象,我们将一切连接起来。

首先,让我们看看在 Android 上如何完成这个。

Android

所以,虽然有很多服务可以帮助与 RESTful 网络服务交互,但我们将再次依赖标准库(大部分时间)来发起网络请求,如第九章所示。因为我们假设结果将作为 JSON 字符串返回,我们将再次依赖在第十二章讨论过的 Gson 库。由于我们在应用程序的发展中已经看到了这么多,比如解析 JSON,使用RecyclerViewAdapter机制,以及通过Intents传递数据,让我们直接开始吧。接下来是一个单独的Activity,其中包含所有相关的搜索和 UI 代码封装。如果这个应用程序要变得更大或需要更多功能的灵活性,你可能会想要立即将逻辑块分离到适当的类和接口中,但既然我们已经看到了几乎所有的内容,让我们快速地生成一个功能齐全的文件并交给我们的客户(他们以对代码的优雅和耐心而闻名):

(记得将这个Activity添加到你的AndroidManifest.xml中!)

非常长!那么里面发生了什么?以前,我们逐行分解操作并精确解释每个语句或表达式的目的。随着我们作为开发人员的成熟,让我们离开这一点一点——我们将尽力像对经验丰富的同事解释这里发生的事情,可能是要审查代码或运行一些 QA,甚至只是了解可能扩展的功能。

显然,我们有一个Activity,一个专门的屏幕信息。Activity期望立即传递一个String,代表搜索查询,它从BrowseContentActivity中的SearchView获取。

Activity的布局最初显示一个在屏幕中心旋转的ProgressBar,以显示我们正在后台工作。直到我们成功获取内容之前,RecyclerView不会被渲染。

在创建周期中,我们将获取该搜索词并将其附加到我们搜索 web 服务的已知 URL 上。我们将使用像URLHttpConnection这样的标准库类来获取服务器的响应作为字节并将其读入String中。

让我们确保在后台Thread中执行此操作,以免在网络请求发生时阻塞 UI;我们都看到即使简单的响应有时也需要几秒钟(或更多)才能解析——如果我们的 UI 在整个时间内都被冻结那就不好了。

既然我们知道我们正在处理传统的 RESTful JSON 响应,我们将使用 Gson 将其解析为我们期望的内容:一组Location对象。假设这是成功的,让我们隐藏ProgressBar,显示RecyclerView,然后将这些Location实例传递给自定义的Adapter并附加它。如果网络调用失败或反序列化操作失败,我们只是记录失败—在一个生产就绪的应用程序中,你可能希望向用户显示某种 UI 来报告错误,或者甚至重试操作。

现在我们的RecyclerView具有准备好并填充好的Adapter后,我们应该看到从我们搜索中返回的所有Location对象列表,呈现为表示图书馆物理位置的任意String

即使设计不佳、代码行数众多,只要我们知道如何使用给定的工具,也可以变得非常简单。

让我们看看我们的 iOS 朋友是如何处理这个的…

iOS

在 Xcode 中,向项目中添加一个名为LocationsController的新 Swift 文件。如果你还记得,我们的 UI 的主要目的是从给定国家获取额外图书馆位置的列表。我们构建的服务有一个端点专门用于此目的。如果你访问 http://localhost:3000/locations?country=<country name>,你将看到针对给定国家的过滤位置列表。因此,我们有一个名为country的参数,可以传递我们的国家名称。

在代码中,我们可以将LocationsController视为具有一个方法来获取给定国家的位置信息。我们可以用以下方法表达:

func fetchLocations(for country: String) -> [Location] {

}

我们还需要定义Location。我们知道我们将使用 JSON,因此我们可以利用类似我们之前在 catalog.json 文件中为Book所做的方式,创建一个结构和Codable。参考我们的 locations.json 文件,我们得到一个看起来像这样的Location对象:

struct Location: Codable {
    let streetAddress: String
    let city: String
    let country: String
    let emoji: String
    let hours: String

    private enum CodingKeys: String, CodingKey {
        case streetAddress = "the_address"
        case city
        case country
        case emoji
        case hours
    }
}
注意

注意streetAddress属性。在我们的 JSON 中,该属性被命名为the_address。由于这些不匹配,已创建了一个名为CodingKeys的私有枚举,它提供了 JSON 值和结构值之间的映射。不幸的是,当一个属性不匹配时,你必须指定在 JSON 中被编码和解码的所有值,因此这使得Codable有些手动化,但仍然大多自动化。

话虽如此,JSONDecoder在某些情况下可以帮助你,比如将 JSON 中的蛇形命名转换为属性中的驼峰命名,如下所示:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
...

现在,如果我们使用刚刚声明的fetchLocations(for:)方法,在快速连接上将很好地工作,但存在一个可能阻碍其性能的主要问题,即它是同步的,而且可能全部在主线程上运行。这意味着如果我们从主线程调用它,我们需要等待这个过程完成,然后才能继续其他操作;我们的应用将会无响应,并且看起来会冻结。

这并不是一个很好的体验。

幸运的是,使用闭包修复问题非常容易。网络操作的常见 Swift 模式是提供一个完成处理程序,该处理程序在操作完成时执行。记住这一点,我们将调整方法签名,以便返回的 [Location] 数组在完成处理程序中返回。我们还将使用错误处理程序,以提供一个闭包来执行操作失败时的代码。最终看起来是这样的:

func fetchLocations(for country: String,
  completionHandler: @escaping ([Location]) -> (), errorHandler: @escaping (Error?) -> ()) {

}

如果我们包括我们的 Location 结构体,我们将得到一个看起来像这样的文件:

import Foundation

class LocationsController {
    func fetchLocations(for country: String,
      completionHandler: @escaping ([Location]) -> (), errorHandler: @escaping (Error?) ->
      ()) {

    }
}

struct Location: Codable {
    let streetAddress: String
    let city: String
    let country: String
    let emoji: String
    let hours: String

    private enum CodingKeys: String, CodingKey {
        case streetAddress = "street_address"
        case city
        case country
        case emoji
        case hours
    }
}

URLSession 及其相关

让我们来看看我们的 fetchLocations(for:completionHandler:errorHandler:) 方法,定义它所提供的功能。为此,我们将深入研究 URLSession 库,具体使用 URLSessionTask 的一种特定类型——即 URLSessionDataTask

与 web 互动的方式有很多种,但 URLSession 将这些交互分解为三种类型,每种类型都有三种不同的 URLSessionTask 实现——基本上是一个网络请求——以使事情变得更容易使用:这些任务类型是 URLSessionDataTaskURLSessionDownloadTaskURLSessionUploadTask

每个都是专为特定目的而构建的,提供了独特于该目的的功能。数据任务或 URLSessionDataTask,我们将使用它来从 URL 中检索数据,换句话说,我们的使用案例。要更深入地了解这些对象,请查看 第九章。

让我们看看如何在我们的代码中使用 URLSessionDataTask

let url = URL(string: "http://localhost:3000/locations?country=\(country)")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in

}
task.resume()

我们要做的第一件事是创建一个 URL,指向我们的位置搜索服务所在的位置。如果你跟随本章节的 Node 部分,它目前位于 http://localhost:3000/locations,但如果没有,你需要使用适当的 URL。URL(string:) 初始化器生成一个可空的 URL 类型,因此我们还使用了 ! 进行强制解包,因为在这种情况下,我们知道它不会是 nil。(如果是,我们可能希望通过应用程序崩溃来知道!)

接下来,我们使用 URLSessionshared 类属性来获取共享会话。会话本身通过 dataTask(with:completionHandler:) 方法生成我们的 URLSessionDataTask;我们从未直接创建和实例化 URLSessionDataTask。这个任务保存在 task 变量中。

传递的完成处理程序有三个参数:dataresponseerrordata 参数是一个 Any? 类型的对象,包含响应检索的数据。response 参数包含接收到的原始 URLResponseerror 属性是一个 Error? 对象,用于验证响应的成功或失败。

现在,将这段代码添加到我们的fetchLocations(for:completionHandler:errorHandler)方法中会调用我们的服务,但由于传入的完成闭包体为空,所以什么也不会发生。让我们修复这个问题。这是我们方法整体应该看起来的样子:

func fetchLocations(for country: String, completionHandler: @escaping ([Location]) -> (),
  errorHandler: @escaping (Error?) -> ()) {
	let url = URL(string: "http://localhost:3000/locations?country=\(country)")!
	let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
		if let error = error {
			// Server error encountered
			errorHandler(error)
			return
		}

		guard let response = response as? HTTPURLResponse,
      response.statusCode < 300 else {
			// Client error encountered
			errorHandler(nil)
			return
		}

		guard let data = data else {
			// No valid data
			errorHandler(nil)
			return
		}

		// Take our data and convert it to a [Location] object
	}
	task.resume()
}

dataTask(with:completionHandler:)的主体内,我们传入一个尾随闭包,执行以下操作:

  1. 它通过传入方法的error参数检查服务器错误。如果发生错误,我们调用传入的errorHandler闭包然后return

  2. 如果服务器端一切正常(即errornil),然后我们继续检查确保我们的响应是有效的 HTTP 状态码,即任何小于300的情况。如果响应无效,我们也调用errorHandler,但没有服务器错误传递,所以我们只传递nil

  3. 如果服务器端和客户端没有错误,我们检查返回的数据。data对象不应该为空,并且我们期望一个Data对象,稍后我们将使用它来解码。如果数据为空,我们再次调用errorHandler,并且传递一个nil错误,以便我们可以执行我们的错误处理代码。

让我们通过将我们的data对象解码为一个Location数组来结束这个对象。添加完成后,我们在LocationsController.swift文件中的代码如下:

import Foundation

class LocationsController {
    func fetchLocations(for country: String, completionHandler: @escaping ([Location]) ->
    (),
      errorHandler: @escaping (Error?) -> ()) {
        let url = URL(string: "http://localhost:3000/locations?country=\(country)")!
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let error = error {
                errorHandler(error)
                return
            }

            guard let response = response as? HTTPURLResponse, response.statusCode <
            300 else {
                errorHandler(nil)
                return
            }

            guard let data = data, let locations =
            try? JSONDecoder().decode([Location].self,
              from: data) else {
                errorHandler(nil)
                return
            }

            // Call our completion handler with our locations
            completionHandler(locations)
        }
        task.resume()
    }
}

struct Location: Codable {
    let streetAddress: String
    let city: String
    let country: String
    let emoji: String
    let hours: String
}

好了。我们有一个可以用来搜索位置的工作网络客户端。我们几乎完成了,但这个的最后一个缺失的部分是我们需要将其添加到我们创建的原始 UI 中。让我们回到LocationsTableViewController,并将以下内容添加到我们的UISearchBarDelegate方法searchBarTextDidEndEditing(_:)中:

func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
	let country = searchBar.text ?? ""
	locationsController.fetchLocations(for: country, completionHandler:
	{ (locations) in
		DispatchQueue.main.async {
			self.locations = locations
			self.tableView.reloadData()
		}
	}) { (error) in
		DispatchQueue.main.async {
			self.locations = []
			self.tableView.reloadData()
		}
	}
}

让我们来分析这段代码。

首先,我们从搜索栏的text属性中获取我们正在搜索的country。这是一个String类型,所以它可能为nil;我们使用??操作符修复这个问题,意思是“如果前面的东西是nil,就使用该操作符后面的东西作为值”。在我们的情况下,我们将其设置为空字符串。之后,我们调用我们刚刚创建的LocationsController类中的新属性上的fetchLocations。我们传入一个completionHandler和一个errorHandler闭包,设置一个包含返回的位置并重新加载包含在这个视图中的表视图的本地属性。

注意,我们为了从我们的闭包中更新表视图而分派到主线程。我们不知道这个调用来自哪个线程,任何 UI 更新必须在主线程上完成。

在我们测试之前,我们需要在这个类中调整的另一件事是,我们需要让我们的表视图从我们现在存储在类中的locations数组中获取数据。幸运的是,如果你还记得,这个类是表视图的dataSource,所以更新UITableViewDataSource协议方法定义如下就很容易了:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) ->
Int {
	return locations.count
}

override func tableView(_ tableView: UITableView,
    cellForRowAt indexPath: IndexPath) -> UITableViewCell {
	// Dequeue a table view cell
	let cell = tableView.dequeueReusableCell(withIdentifier: "LocationCell", for:
	indexPath)

	// Find the correct location based on the row being populated
	let location = locations[indexPath.row]

	// Style the cell
	cell.textLabel?.text = location.emoji
	cell.detailTextLabel?.text =
    "\(location.streetAddress)\n\(location.city),
    \(location.country)\nHours: \(location.hours)"

	return cell
}

现在完整的LocationsViewController类看起来是这样的:

import UIKit

class LocationsTableViewController: UITableViewController {

    let locationsController = LocationsController()
    var locations: [Location] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        let searchController = UISearchController(searchResultsController: nil)
        searchController.searchBar.delegate = self
        searchController.searchBar.placeholder = "Search Locations by Country"
        searchController.obscuresBackgroundDuringPresentation = false
        definesPresentationContext = true

        navigationItem.searchController = searchController
        navigationItem.hidesSearchBarWhenScrolling = false
    }

    // MARK: - Table view data source
    override func tableView(_ tableView: UITableView,
        numberOfRowsInSection section: Int) -> Int {
        return locations.count
    }

    override func tableView(_ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Dequeue a table view cell
        let cell = tableView.dequeueReusableCell(withIdentifier: "LocationCell", for:
        indexPath)

        // Find the correct location based on the row being populated
        let location = locations[indexPath.row]

        // Style the cell
        cell.textLabel?.text =
          "\(location.streetAddress)\n\(location.city), \(location.country)\nHours:
          \(location.hours)"
        cell.detailTextLabel?.text = location.emoji

        return cell
    }
}

extension LocationsTableViewController: UISearchBarDelegate {
    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        let country = searchBar.text ?? ""
        guard country != "" else {
            self.locations = []
            self.tableView.reloadData()
            return
        }
        locationsController.fetchLocations(for: country, completionHandler:
        { (locations) in
            DispatchQueue.main.async {
                self.locations = locations
                self.tableView.reloadData()
            }
        }) { (error) in
            DispatchQueue.main.async {
                self.locations = []
                self.tableView.reloadData()
            }
        }
    }

    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        self.locations = []
        self.tableView.reloadData()
    }
}

构建并运行应用程序,点击放大镜,搜索一个国家,你会得到……什么也没有。为什么?

嗯,如果你查看控制台,你会注意到以下错误:

App Transport Security((("App Transport Security"))) has blocked a cleartext HTTP (http://)
    resource load since it is insecure. Temporary exceptions can be configured via your
    app's((("Info.plist file"))) Info.plist file.

这是因为苹果的安全实践要求使用https:// URL,而我们正在使用http:// URL。这个问题的修复很容易,并且实际上在错误消息中已经提到了。你需要在你的应用程序的Info.plist文件中添加一个例外。这个文件是用来存放应用程序级可配置值的地方。

要在您的域中添加例外情况,请转到Info.plist并在显示的最后一行数据中点击“+”按钮以添加一个新属性。从下拉菜单中选择“App Transport Security Settings”。然后,添加一个名为 Allow Arbitrary Loads 的新子属性,并将其值从 NO 设置为 YES。这样,应用程序可以使用任何非安全 URL。如果你现在构建并运行应用程序,并搜索“France”,你应该在模拟器中看到图 20-1。

Vive lé Simulator!

图 20-1. Vive lé Simulator!
警告

注意!通过允许任意加载,你使你的应用程序不够安全。出于这个例子的目的,我们使用了这种技术。实际上,你应该使用一个 HTTPS URL 来与你的服务通信。除非你知道自己在做什么,否则不要在生产环境中启用此选项。另一个可用的选项是在你的应用程序的Info.plist中设置NSAllowsLocalNetworkingYES,这允许本地文件加载。

如果你提交了一个启用了允许任意加载的应用程序,请做好准备:你将需要在应用审核过程中为这个决定辩护!

还有一件事我们可以做来显示网络调用正在发生。我们可以在我们的LocationsController中创建URLSessionDataTask之前调用UIApplication.shared.isNetworkActivityIndicatorVisible来激活和停用状态栏中的网络活动指示器。就像这样:

func fetchLocations(for country: String, completionHandler: @escaping ([Location]) -> (),
  errorHandler: @escaping (Error?) -> ()) {

	...

	DispatchQueue.main.async {
		UIApplication.shared.isNetworkActivityIndicatorVisible = true
	}
	task.resume()
}

然后,在完成处理程序中,我们可以将UIApplication.shared.isNetworkActivityIndicatorVisible设置为false,每当它完成时。

现在我们暂时只讨论 iOS 的网络问题到这里。

我们学到了什么

我们已经看到了如何在 Android 和 iOS 中与互联网通信。虽然有相似之处,但每个操作系统处理与 Web 服务通信的方式也各不相同。有很多复杂的部分,但我们已经看到了如何:

  1. 添加一个处理搜索的新屏幕

  2. 创建一个网络客户端来与 API 通信

  3. 使用网络客户端连接搜索界面

Android 和 iOS 中包含了更多的功能,我们只是触及了表面。查看第九章以获取有关如何将网络功能添加到您的应用程序的更多详细信息和示例。

posted @ 2025-11-24 09:15  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报