NFC

近场通信 (NFC) 是一种用于短距离无线设备与其他设备共享数据或触发这些设备上的操作的技术。它使用射频场构建,允许没有任何电源的设备存储小块数据,同时还使其他用电设备能够读取该数据。

iOS和watchOS设备已经内置了NFC硬件好几年了。事实上,Apple Pay使用这项技术与商店的支付终端进行交互。但是,开发人员直到iOS 11才能够使用NFC硬件。

苹果通过引入Core NFC在iOS 13中提升了其NFC游戏。借助这项新技术,我们可以对 iOS 设备进行编程,以新的方式与周围的互联世界进行交互。本教程将向我们展示一些使用此技术的方法。在此过程中,我们将学习如何:

重要说明:若要执行本教程中的所有步骤,需要以下各项:

开始

要开始使用,请使用本教程顶部或底部的“下载材料”按钮下载教程项目,然后在初学者文件夹中打开初学者项目。使用工程应用程序,我们将学习如何:

构建并运行。我们将看到以下内容:

写入第一个标签

若要开始,请在项目导航器中选择 NeatoCache 项目。然后,转到“签名和功能”并选择“+ 功能”。从列表中选择近场通信标签读取。

这将确保应用的预配配置文件设置为使用 NFC。

接下来,打开我们的 Info.plist 并添加以下条目:

我们需要此条目来向用户传达我们使用 NFC 功能的目的,并符合 Apple 关于在应用程序中使用 NFC 的要求。

接下来,我们将添加一个函数,该函数可以执行我们的应用程序将处理的各种 NFC 任务。打开 NFCUtility.swift并将以下导入和类型别名添加到文件顶部:

import CoreNFC

typealias NFCReadingCompletion = (Result<NFCNDEFMessage?, Error>) -> Void
typealias LocationReadingCompletion = (Result<Location, Error>) -> Void

我们需要导入 CoreNFC 才能使用 NFC。类型别名提供以下功能:

// 1
private var session: NFCNDEFReaderSession?
private var completion: LocationReadingCompletion?

// 2
static func performAction(
  _ action: NFCAction,
  completion: LocationReadingCompletion? = nil
) {
  // 3
  guard NFCNDEFReaderSession.readingAvailable else {
    completion?(.failure(NFCError.unavailable))
    print("NFC is not available on this device")
    return
  }

  shared.action = action
  shared.completion = completion
  // 4
  shared.session = NFCNDEFReaderSession(
    delegate: shared.self,
    queue: nil,
    invalidateAfterFirstRead: false)
  // 5
  shared.session?.alertMessage = action.alertMessage
  // 6
  shared.session?.begin()
}

如果此时由于不符合NFCNDEFReaderSessionDelegate而出现编译错误,请不要担心,我们将立即解决此问题。

这是你刚刚做的:

1: 我们可以添加会话和完成属性以存储活动的 NFC 读取会话及其完成块。

2: 添加静态函数作为 NFC 读取和写入任务的入口点。通常,我们将使用此函数和 NFCUtility 的单一实例样式访问。

3: 确保设备支持 NFC 读取。否则,将出现错误。

4: 创建一个 NFCNDEFReaderSession,它表示活动的读取会话。我们还可以设置要通知 NFC 读取会话的各种事件的委托。

5: 在会话上设置 alertMessage 属性,以便它在 NFC 模式中向用户显示该文本。

6: 开始阅读会话。调用时,模式将向用户显示我们在上一步中设置的任何说明。

了解 NDEF

请注意,上面的代码引入了另一个首字母缩略词 NDEF,它代表 NFC 数据交换格式。它是写入或读取 NFC 设备的标准化格式。我们将使用的两部分 NDEF 是:

检测标签

现在我们已经设置了 NFCReaderSession,是时候将 NFCUtility 作为其委托了,这样我们就可以收到阅读会话期间发生的各种事件的通知。

将以下代码添加到 NFCUtility.swift 的底部:

// MARK: - NFC NDEF Reader Session Delegate
extension NFCUtility: NFCNDEFReaderSessionDelegate {
  func readerSession(
    _ session: NFCNDEFReaderSession,
    didDetectNDEFs messages: [NFCNDEFMessage]
  ) {
    // Not used
  }
}

我们将在一秒钟内向此扩展添加更多内容,但请注意,我们不会对 readerSession(_:didDetectNDEFs:) 执行任何操作。在本教程中。我们只是在此处添加它,因为它必须符合委托协议。

我们与NFC技术的交互越多,我们就越会看到在读写过程的各个阶段遇到错误的可能性。将以下方法添加到新扩展以捕获这些错误:

private func handleError(_ error: Error) {
  session?.alertMessage = error.localizedDescription
  session?.invalidate()
}

第一行代码看起来很熟悉。它将在 NFC 模式视图中向用户显示错误消息。如果发生错误,我们还将使会话失效以终止会话,并允许用户再次与应用交互。

接下来,将以下方法添加到扩展中以处理 NFC 读取会话中的错误:

func readerSession(
  _ session: NFCNDEFReaderSession,
  didInvalidateWithError error: Error
) {
  if let error = error as? NFCReaderError,
    error.code != .readerSessionInvalidationErrorFirstNDEFTagRead &&
      error.code != .readerSessionInvalidationErrorUserCanceled {
    completion?(.failure(NFCError.invalidated(message: 
      error.localizedDescription)))
  }

  self.session = nil
  completion = nil
}

添加此委托方法将清除到目前为止遇到的任何编译错误。

最后,将最后一种方法添加到扩展中,以处理 NFC 标签的可能检测:

func readerSession(
  _ session: NFCNDEFReaderSession,
  didDetect tags: [NFCNDEFTag]
) {
  guard 
    let tag = tags.first,
    tags.count == 1 
    else {
      session.alertMessage = """
        There are too many tags present. Remove all and then try again.
        """
      DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(500)) {
        session.restartPolling()
      }
      return
  }
}

在这里,我们将实现在会话检测到我们扫描了标记时将调用的方法。

通常,我们希望用户只有一个标签足够靠近他们的手机,但我们应该考虑多个靠近的标签。如果检测到此问题,将停止扫描并提醒用户。显示消息后,我们将重新启动阅读会话并让用户重试。

处理标签

一旦你知道你有一个标签,你可能想用它做点什么。在 readerSession(_:didDetect:) 中的 guard 语句之后添加以下代码:

// 1
session.connect(to: tag) { error in
  if let error = error {
    self.handleError(error)
    return
  }

  // 2
  tag.queryNDEFStatus { status, _, error in
    if let error = error {
      self.handleError(error)
      return
    }

    // 3
    switch (status, self.action) {
    case (.notSupported, _):
      session.alertMessage = "Unsupported tag."
      session.invalidate()
    case (.readOnly, _):
      session.alertMessage = "Unable to write to tag."
      session.invalidate()
    case (.readWrite, .setupLocation(let locationName)):
      self.createLocation(name: locationName, with: tag)
    case (.readWrite, .readLocation):
      return
    default:
      return
    }
  }
}

以下是我们在上面的代码中所做的操作:

1: 使用当前的 NCFNDEFReaderSession 连接到检测到的标记。我们需要执行此步骤才能对标记执行任何读取或写入。连接后,它将调用其完成处理程序,并可能出现任何错误。

2: 查询标记的 NDEF 状态,以查看 NFC 设备是否受支持。对于 NeatoCache 应用,状态必须为读写。

3: 切换状态和 NFC 操作,并根据其值确定应执行的操作。在这里,我们尝试使用 createLocation(name:with:) 将标记设置为具有位置名称,该名称尚不存在,因此会遇到编译错误。不过不用担心,我们稍后会添加它。同样,readLocation 操作也尚未处理。

创建有效负载

到目前为止,我们已经处理了查找标记、连接到它并查询其状态的工作。要完成对标记的写入设置,请将以下代码块添加到 NFCUtility.swift 的末尾:

// MARK: - Utilities
extension NFCUtility {
  func createLocation(name: String, with tag: NFCNDEFTag) {
    // 1
    guard let payload = NFCNDEFPayload
      .wellKnownTypeTextPayload(string: name, locale: Locale.current) 
      else {
        handleError(NFCError.invalidated(message: "Could not create payload"))
        return
    }

    // 2
    let message = NFCNDEFMessage(records: [payload])

    // 3
    tag.writeNDEF(message) { error in
      if let error = error {
        self.handleError(error)
        return
      }

      self.session?.alertMessage = "Wrote location data."
      self.session?.invalidate()
      self.completion?(.success(Location(name: name)))
    }
  }
}

以下是我们在上面的代码中所做的操作:

1: 创建一个文本 NFCNDEFPayload。如前所述,这类似于 NDEF 记录。

2: 使用有效负载创建新的 NFCNDEFMessage,以便我们可以将其保存到 NFC 设备。

3: 最后,将消息写入标记。

使用 NDEF 有效负载类型

NFCNDEFPayload支持几种不同类型的数据。在此示例中,我们使用的是 wellKnownTypeTextPayload(string:locale:)。这是一种相当简单的数据类型,它使用字符串和设备的当前区域设置。其他一些数据类型包含更复杂的信息。以下是完整列表:

注意:本教程涵盖Well-Known和Unknown。若要了解其他类型,请查看本教程末尾列出的链接。
另请注意,类型可以具有子类型。Well-known. 例如,众所周知的具有文本和 URI 的子类型。

你离得很近了!剩下的就是将 UI 连接到新代码。转到 AdminView.swift并替换以下代码:

Button(action: {
}) {
  Text("Save Location…")
}
.disabled(locationName.isEmpty)

使用如下代码:

Button(action: {
  NFCUtility.performAction(.setupLocation(locationName: self.locationName)) { _ in
    self.locationName = ""
  }
}) {
  Text("Save Location…")
}
.disabled(locationName.isEmpty)

这将调用以使用文本字段中找到的文本设置我们的位置。

生成并运行,切换到应用的“管理员”选项卡,输入名称并选择“保存位置...”。

我们将看到以下内容:

注意:请记住,我们需要使用物理设备并拥有支持写入功能的 NFC 标签。

将手机放在 NFC 标签上后,我们会看到一条消息,表明我们的位置已成功保存。

读取标签

伟大!现在,你已拥有一个可以将字符串写入标记的应用,你已准备好构建对读取它的支持。回到 NFCUtility.swift并在 readerSession(_:didDetect:) 中找到以下代码。

case (.readWrite, .readLocation):
  return

现在,将其替换为以下内容:

case (.readWrite, .readLocation):
  self.readLocation(from: tag)

实现该读取位置的时间(来自:)方法。将以下内容添加到包含 createLocation(name:with:) 的实用程序扩展:

func readLocation(from tag: NFCNDEFTag) {
  // 1
  tag.readNDEF { message, error in
    if let error = error {
      self.handleError(error)
      return
    }
    // 2
    guard 
      let message = message,
      let location = Location(message: message) 
      else {
        self.session?.alertMessage = "Could not read tag data."
        self.session?.invalidate()
        return
    }
    self.completion?(.success(location))
    self.session?.alertMessage = "Read tag."
    self.session?.invalidate()
  }
}

这个添加看起来应该有点熟悉,因为它与你写到标签的方式非常相似。

首先,我们开始读取标签。如果可以读取,它将返回找到的任何消息。
接下来,我们尝试从消息数据中创建一个位置(如果有)。这使用一个自定义初始值设定项,该初始值设定项接受 NFCNDEFMessage 并从中提取名称。如果你好奇,可以在 LocationModel.swift 中找到该初始值设定项。
最后,打开 VisitorView.swift,并在 scanSection 中替换以下代码:

Button(action: {
}) {
  Text("Scan Location Tag…")
}

使用如下代码:

Button(action: {
  NFCUtility.performAction(.readLocation) { location in
    self.locationModel = try? location.get()
  }
}) {
  Text("Scan Location Tag…")
}

我们已准备好从代码中读取数据。构建并运行。

在访客选项卡上,点击扫描位置标记...。你将看到以下内容,以及你的位置名称现在在 UI 中:

编写不同的数据类型

虽然写入字符串可能非常适合某些用例,但我们可能会发现我们希望将其他类型的数据写入标签。

为此做好准备,请在实用程序扩展中将以下内容添加到 NFCUtility.swift:

private func read(
  tag: NFCNDEFTag,
  alertMessage: String = "Tag Read",
  readCompletion: NFCReadingCompletion? = nil
) {
  tag.readNDEF { message, error in
    if let error = error {
      self.handleError(error)
      return
    }

    // 1
    if let readCompletion = readCompletion,
       let message = message {
      readCompletion(.success(message))
    } else if 
      let message = message,
      let record = message.records.first,
      let location = try? JSONDecoder()
        .decode(Location.self, from: record.payload) {
      // 2
      self.completion?(.success(location))
      self.session?.alertMessage = alertMessage
      self.session?.invalidate()
    } else {
      self.session?.alertMessage = "Could not decode tag data."
      self.session?.invalidate()
    }
  }
}

从现在开始,这种读取标签的新方法将成为我们大多数活动的切入点。如我们所见,它仍然像以前一样读取标签。但是,一旦它读取了标记,它将执行以下两项操作之一:

1: 调用完成处理程序并将消息传递给它。这对于将多个NFC任务链接在一起非常有用。

2: 解码有效负载,以便可以解析标记的记录。你一会儿会回到这个问题。

写入自定义数据而不是字符串

此时,你已准备好将应用从将字符串写入标记转换为将自定义数据写入标记。将以下内容添加到实用程序扩展:

private func createLocation(_ location: Location, tag: NFCNDEFTag) {
  read(tag: tag) { _ in
    self.updateLocation(location, tag: tag)
  }
}

这是用于创建带有位置的标记的新功能。你可以看到它使用新的read(tag:alsertMessage:readCompletion:)启动该过程并调用一个新函数来更新标签上的位置,以及一个新的 updateLocation(_:tag:)我们将立即实现的方法。

由于我们要替换将位置信息写入标记的方式,因此请删除 createLocation(name:with:)在 NFCUtility 扩展的开头,因为它不再需要。另外,在 readerSession(_:didDetect:) 中更新我们的代码由此:

case (.readWrite, .setupLocation(let locationName)):
  self.createLocation(name: locationName, with: tag)

到如下代码:

case (.readWrite, .setupLocation(let locationName)):
  self.createLocation(Location(name: locationName), tag: tag)

接下来,在创建位置(_:标签:)之后添加此方法:

private func updateLocation(
  _ location: Location,
  withVisitor visitor: Visitor? = nil,
  tag: NFCNDEFTag
) {
  // 1
  var alertMessage = "Successfully setup location."
  var tempLocation = location
  
  // 2
  let jsonEncoder = JSONEncoder()
  guard let customData = try? jsonEncoder.encode(tempLocation) else {
    self.handleError(NFCError.invalidated(message: "Bad data"))
    return
  }
  // 3
  let payload = NFCNDEFPayload(
    format: .unknown,
    type: Data(),
    identifier: Data(),
    payload: customData)
  // 4
  let message = NFCNDEFMessage(records: [payload])
}

以下是我们在上面的代码中所做的操作:

1: 创建默认警报消息和临时位置。我们稍后会回到这些。

2: 对传入函数的位置结构进行编码。这会将模型转换为原始数据。这很重要,因为这是我们将任何自定义类型写入 NFC 标签的方式。

3: 创建可处理数据的有效负载。但是,我们现在使用未知作为格式。执行此操作时,必须将类型和标识符设置为空数据,而有效负载参数承载实际解码的模型。

4: 将有效负载添加到新创建的消息中。

总的来说,这似乎与将字符串保存到标签时没有太大区别——你只是添加一个额外的步骤来将 Swift 数据类型转换为标签可以理解的内容。

检查标签容量

要完成将数据写入标记,请在 updateLocation(_:withVisitor:tag) 中添加下一个代码块:

tag.queryNDEFStatus { _, capacity, _ in
  // 1
  guard message.length <= capacity else {
    self.handleError(NFCError.invalidPayloadSize)
    return
  }

  // 2
  tag.writeNDEF(message) { error in
    if let error = error {
      self.handleError(error)
      return
    }
    
    if self.completion != nil {
      self.read(tag: tag, alertMessage: alertMessage)
    }
  }
}

上面的闭包尝试查询当前的 NDEF 状态,然后:

1: 确保设备有足够的存储空间来存储位置。请记住,与我们可能熟悉的设备相比,NFC 标签的存储容量通常非常有限。
将消息写入标记。

2: 生成并运行并设置位置,如上所示。如果我们愿意,我们可以使用以前的相同标记,因为我们的新代码将覆盖以前保存的任何数据。

读取自定义数据

此时,如果我们尝试读取代码,则会收到错误消息。保存的数据不再是众所周知的类型。若要解决此问题,请替换 readerSession(_:didDetect:) 中的以下代码:

case (.readWrite, .readLocation):
  self.readLocation(from: tag)

使用如下代码:

case (.readWrite, .readLocation):
  self.read(tag: tag)

构建、运行和扫描代码。因为你正在调用 read(tag:alertMessage:readCompletion:)在没有任何完成块的情况下,它将解码在消息的第一条记录中找到的数据。

修改内容

此应用程序的最后一个要求是保存访问过此位置的人员的日志。你的应用在 UI 中已存在一项未使用的功能,允许用户输入其名称并将其添加到标记中。到目前为止,我们所做的工作将使其余的设置变得微不足道。我们已经可以在标签中读取和写入数据,因此修改它对我们来说应该是轻而易举的。

在 NFCUtility.swift 中,将此代码添加到 updateLocation(_:withVisitor:tag:)创建临时位置后:

if let visitor = visitor {
  tempLocation.visitors.append(visitor)
  alertMessage = "Successfully added visitor."
}

在上面的代码中,我们检查是否提供了访问者。如果是这样,请将其添加到位置的访问者数组中。

接下来,将以下方法添加到实用工具扩展:

private func addVisitor(_ visitor: Visitor, tag: NFCNDEFTag) {
  read(tag: tag) { message in
    guard 
      let message = try? message.get(),
      let record = message.records.first,
      let location = try? JSONDecoder()
        .decode(Location.self, from: record.payload) 
      else {
        return
    }

    self.updateLocation(location, withVisitor: visitor, tag: tag)
  }
}

这个新方法将读取一个标记,从中获取消息,并尝试解码其上的位置。

接下来,在 readerSession(_:didDetect:) 中,将一个新案例添加到我们的 switch 语句中:

case (.readWrite, .addVisitor(let visitorName)):
  self.addVisitor(Visitor(name: visitorName), tag: tag)

如果用户特别想要添加访问者,我们将调用在上一步中添加的函数。

剩下的就是更新VisitorView.swift。在“访问者部分”中,替换以下代码:

Button(action: {
}) {
  Text("Add To Tag…")
}
.disabled(visitorName.isEmpty)

使用如下代码:

Button(action: {
  NFCUtility
    .performAction(.addVisitor(visitorName: self.visitorName)) { location in
      self.locationModel = try? location.get()
      self.visitorName = ""
    }
}) {
  Text("Add To Tag…")
}
.disabled(visitorName.isEmpty)

生成并运行,然后转到“访问者”选项卡。输入我们的姓名,然后选择“添加到标记...”。扫描后,我们将看到更新的位置,并在标签上找到访问者列表。