剑客
关注科技互联网

iOS 10 by Tutorials 笔记(六)

继续没事翻翻书,做做笔记,因为整本书都还在 Early Access 的状态,出来哪章写哪章,稍后再调整顺序吧。

Chapter 6: SiriKit

苹果从 iOS 5 开始就提供了 Siri 这个功能,经过这么多年的改进,终于在 iOS 10 上开放了部分 API,这样我们自己开发的应用也能获得 Siri 的支持了。

应该对 Siri 的支持是通过 app extension 实现的,而且调试需要有付费的开发者帐号和真机,模拟器是不能调试 Siri 的。

在开始本章任务前,需要了解一下 Siri 的工作原理,Siri 在底层使用了一组 domains,每个 domain 都代表了一种相关功能(比如 Messaging)

也可以说每个 domain 都是一组意图/目的(intents)的集合,意图说白了就是我们使用 siri 具体要完成的任务。比如 Messaging domain 就是一种用来发送信息、搜索信息,设置信息属性的 intent

每一种 intent 都是 INIntent 的子类,而且它都配备相应的 handler protocl 和一个 INIntentResponse 子类用来和 SiriKit 交互。

所以在应用中的 SiriKit 对语言处理可以转变为意图的判断,然后开发者的代码来检查意图是否明晰合理,是否能够完成,如果可以就去执行任务。

关于 SiriKit 支持 Intents 的种类,可以去查看官方文档

Would you like to ride in my beautiful balloon?

本章我们要开发一个热气球应用,里面的气球运行的逻辑已经提前写好了,核心代码被放在一个单独的 FrameWork 中,这样不管是主程序还是 extension 都能很方便地访问

核心代码过了一遍,写得很不错,虽然不是本章必须,但还是记录下思想

  • WenderLoonSimulator 类用来集中协调所有资源的使用
  • Models
    • Driver.swift 司机
    • Balloon.swift 气球相关的一些属性和方法
    • Motion.swift 运行的核心逻辑

运行的核心逻辑都封装在 Motion.swift 中,比如定义气球的五种状态:

  1. moving 移动状态
  2. waitingForRequest 等待请求
  3. withPassengers 接上乘客
  4. enRouteToCollection 接乘客的路上
  5. completed 完成

还定义了两种类 HoldingPattern 和 Journey 分别对应了待命模式和旅行模式,这两种模式下分别根据当前状态和目标状态进行一系列的更新。具体代码可以自行去研究下,写的很好。

真机上跑一下,可以看到热气球分散在伦敦的上空,并且还是实时随机移动着。

iOS 10 by Tutorials 笔记(六)

现在开始增加对 Siri 的支持,选择 File/New/Target 下的 iOS/Application Extension/Intents Extension ,不要勾选 Include UI Extension

iOS 10 by Tutorials 笔记(六)

这样创建了一个新的 target 和一组文件(Info.plist 和 IntentHandler.swift),在工程中找到 RideRequestExtension Group 下的 IntentHandler.swift ,把里面的样本代码删干净

import Intents

class IntentHandler: INExtension {

}

INExtension是 Intents extension 的入口,它只做一件事情:为 intent 提供相应的 handle object

既然如此,我们可以理解为每个 intent 都有一个相关的 handle protocl 来处理意图,为了逻辑清晰,我们新建了一个 RideRequestHandler.swift 来实现相关的 handle protocl:

import Intents

class RideRequestHandler:  
  NSObject, INRequestRideIntentHandling {
}

INRequestRideIntentHandling协议用来处理打车请求的 intent ,它有一个必须实现的方法

func handle(requestRide intent: INRequestRideIntent,  
            completion: @escaping (INRequestRideIntentResponse) -> Void)
{
  let response = INRequestRideIntentResponse(
    code: .failureRequiringAppLaunchNoServiceInArea,
    userActivity: .none)
  completion(response)
}

我们姑且先返回一个失败的请求

回到 IntentHandler.swift,因为之前说过,这是一个入口文件,能处理很多类型的 Intent,因此需要先判断 Intent 的类型

override func handler(for intent: INIntent) -> Any? {  
  if intent is INRequestRideIntent {
    return RideRequestHandler()
  }
  return .none
}

除此之外还需在 Info.plist 设置,这样 Siri 才能只知道你的 App 有能力处理这种意图 Intent,打开 RideRequestExtension 中的 Info.list 做如下设置

iOS 10 by Tutorials 笔记(六)

下一步是请求用户授权,来到主程序的 AppDelegate.swift 中,添加鉴权代码

import UIKit  
import Intents

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
    requestAuthorisation()
    return true
  }

  fileprivate func requestAuthorisation() {
    INPreferences.requestSiriAuthorization { status in
      if status == .authorized {
        print("Hey, Siri!")
      } else {
        print("Nay, Siri!")
      }
    }
  }
}

打开 主程序组Info.list ,添加 Privacy – Siri Usage Description ,显示给用户的描述按照需求写就好 iOS 10 by Tutorials 笔记(六) 最后一步在工程 target 的 Capabilities 中打开 Siri(付费的开发者帐号才有这一选项) iOS 10 by Tutorials 笔记(六)

再总结下所有步骤:

  1. 添加一个 Intents extension
  2. 创建相关 handler objects
  3. 在 INExtension 的子类中返回第二步创建的 handler objects
  4. 在 extension 的 Info.plist 里声明支持的 intents 类型(可以有多个)
  5. 向用户请求使用 Siri 的权限
  6. 在主程序的 Info.plist 中添加向用户请求权限时的说明
  7. 添加 Siri entitlement 到应用(开启 Siri Capabilities)

此时选择主程序 WenderLoon Scheme 运行,就会弹出鉴权窗口,点击 OK iOS 10 by Tutorials 笔记(六)

切到 RideRequestExtension Scheme 运行,弹出的窗口选择 Siri 应用,Siri 就会启动,我们就能对着 Siri 说话了。尝试说一句:"Book a ride using WenderLoon from Heathrow airport"

如果你发音标准,Siri 是可以理解的,不过现在还没有提供服务(还记得之前设置的 INRequestRideIntentResponse 吗)

iOS 10 by Tutorials 笔记(六)

99 (passengers in) red balloons

处理一个 intent 意图分三步,第一步确认所有必需的信息是否提供,如果有缺失,Siri 会再次询问用户。对于打车请求,可以有以下信息:

  • 接乘客的地点
  • 目的地
  • 乘客数量
  • 乘车选项
  • 付款方式

所有的这些参数都来自于 INRequestRideIntentHandling 协议中的相关方法,你可以从协议方法的参数中获取你感兴趣的信息,然后通过 completion block 传入相关参数与 Siri 进行交流。每一类信息都对应了一个协议方法。

Siri 处理的整个流程是这样的

iOS 10 by Tutorials 笔记(六)

既然每一种信息都对应了一个协议方法,我们先来实现『乘客乘车点』的协议,打开 RideRequestHandler.swift 添加如下方法:

func resolvePickupLocation(forRequestRide intent: INRequestRideIntent,  
with completion: @escaping (INPlacemarkResolutionResult) -> Void) {  
  if let pickup = intent.pickupLocation {
    completion(.success(with: pickup))
} else {
    completion(.needsValue())
  }
}

如果第一次没有告诉 Siri 乘车点,Siri 会让我们补充更多的信息 iOS 10 by Tutorials 笔记(六)

这里要注意,每次与 Siri 的交互都会启动单独的 handler object (RideRequestHandler),所以在处理 intents 时不能使用状态信息。

返回 Xcode 接着添加目的地的 protocol

func resolveDropOffLocation(forRequestRide intent: INRequestRideIntent,  
with completion: @escaping (INPlacemarkResolutionResult) -> Void) {  
  if let dropOff = intent.dropOffLocation {
    completion(.success(with: dropOff))
} else {
    completion(.notRequired())
  }
}

接下来要处理的 PartySize,即从 Siri 获取到乘客数量,然后判断我们的热气球能否坐得下。这个判断的逻辑需要最开始提到的 WenderLoonSimulator 类来实现。

我们在 IntentHandler 中初始化一个 WenderLoonSimulator 的实例,然后作为初始化参数传入 RideRequestHandler

import Intents  
import WenderLoonCore

class IntentHandler: INExtension {

  let simulator = WenderLoonSimulator(renderer: nil)

  override func handler(for intent: INIntent) -> Any? {
    if intent is INRequestRideIntent {
      return RideRequestHandler(simulator: simulator)
    }
    return .none
  }
}

为此添加下 RideRequestHandler 的初始化方法

let simulator: WenderLoonSimulator  
init(simulator: WenderLoonSimulator) {  
  self.simulator = simulator
  super.init()
}

现在我们就能用来处理乘客数量的逻辑了,从协议参数中获取乘客数量,然后与载客量比较,最后将结果反馈给 completion block

func resolvePartySize(forRequestRide intent: INRequestRideIntent, with  
completion: @escaping (INIntegerResolutionResult) -> Void) {  
  switch intent.partySize {
  case .none:
    completion(.needsValue())
  case let .some(p) where simulator.checkNumberOfPassengers(p):
    completion(.success(with: p))
  default:
    completion(.unsupported())
  }
}

我们气球可以坐四个人,你对着 Siri 说有 12 个乘客,返回如下结果 iOS 10 by Tutorials 笔记(六)

最后一个协议方法 confirm 当所有参数信息都满足时调用

func confirm(requestRide intent: INRequestRideIntent, completion:  
@escaping (INRequestRideIntentResponse) -> Void) {
  let responseCode: INRequestRideIntentResponseCode
  if let location = intent.pickupLocation?.location,
    simulator.pickupWithinRange(location) {
    responseCode = .ready
  } else {
    responseCode = .failureRequiringAppLaunchNoServiceInArea
  }
  let response = INRequestRideIntentResponse(code: responseCode,
userActivity: nil)  
  completion(response)
}

在其中,我们判断了乘客附近一定距离内有没有可供搭乘的热气球

以上仅仅是处理一个 intent 意图的前两步(索取必需的信息,确认信息),这一切完成后,最后一步才是真正接受了这个请求,根据提供的信息来处理请求。

You can’t handle the truth

还记得最初我们在 RideRequestHandler 里的 handle 方法中只返回了一个失败的 code 吗?类似于说本地区不提供服务。现在我们可以根据 Siri 提供的意图信息来完成实际工作。

当用户看到确认对话框并请求乘坐热气球后,Siri 会显示另一个对话框展示预订的细节。不同类型的 intent 所展示的对话框细节不同,展示这种细节需要 intent 自己的数据模型,而且每一种类型的 intent 都有自己的数据模型子集,所以你要把原有的数据模型转换成 Intent 的数据模型。

其实就是我们通过设置 INRequestRideIntentResponserideStatus 属性,来让 Siri 向用户展示预订细节,而设置 rideStatus 属性需要 intent 自己的数据模型。

切换到 WenderLoonCore 框架,添加新模型 IntentsModels.swift

import Intents  
// 1
public extension UIImage {  
  public var inImage: INImage {
    return INImage(imageData: UIImagePNGRepresentation(self)!)
  }
}
// 2
public extension Driver {  
  public var rideIntentDriver: INRideDriver {
    return INRideDriver(
      personHandle: INPersonHandle(value: name, type: .unknown),
      nameComponents: .none,
      displayName: name,
      image: picture.inImage,
      rating: rating.toString,
      phoneNumber: .none)
  } 
}

上面代码创建了 Intent 版本的 image 和 driver

不幸的是并没有一种 INBalloon 类型,Intents framework 提供了 INRideVehicle,我们可以拿来模拟一下热气球

public extension Balloon {  
  public var rideIntentVehicle: INRideVehicle {
    let vehicle = INRideVehicle()
    vehicle.location = location
    vehicle.manufacturer = "Hot Air Balloon"
    vehicle.registrationPlate = "B4LL 00N"
    vehicle.mapAnnotationImage = image.inImage
    return vehicle
  } 
}

模型都转换完毕,现在来重新实现一下 RideRequestHandlerhandle(intent:completion:) 方法

// 1 理论上不提供乘车点到不了这步,但是以防万一, Siri 嘛你懂得
guard let pickup = intent.pickupLocation?.location else {  
  let response = INRequestRideIntentResponse(code: .failure,
    userActivity: .none)
  completion(response)
  return
}
// 2 虽然不提供目的地热气球就随机跑,但是 balloon simulator 还是需要的
let dropoff = intent.dropOffLocation?.location ??  
  pickup.randomPointWithin(radius: 10_000)
// 3 用来封装有关交通工具的信息
let response: INRequestRideIntentResponse  
// 4 检测附近是否有气球可用
if let balloon = simulator.requestRide(pickup: pickup, dropoff: dropoff)  
{
// 5 INRideStatus 包含了乘坐工具的一些细节信息,我们设置好了通过
 INRequestRideIntentResponse 返回给用户
  let status = INRideStatus()
  status.rideIdentifier = balloon.driver.name
  status.phase = .confirmed
  status.vehicle = balloon.rideIntentVehicle
  status.driver = balloon.driver.rideIntentDriver
  status.estimatedPickupDate = balloon.etaAtNextDestination
  status.pickupLocation = intent.pickupLocation
  status.dropOffLocation = intent.dropOffLocation
  response = INRequestRideIntentResponse(code: .success,
userActivity: .none)  
  response.rideStatus = status
} else {
  response =
INRequestRideIntentResponse(code: .failureRequiringAppLaunchNoServiceInAr  
ea, userActivity: .none)  
}
completion(response)

status.rideIdentifier 应该是唯一的,这里我们使用了司机名,实际工程中可以自行设置

运行一下

iOS 10 by Tutorials 笔记(六)

Making a balloon animal, er, UI

我们也可以为 Siri 的展示窗口自定义的 UI,你需要添加另一个 extension,这一次在 File/New/Target 中选择 Intents UI Extension ,命名为 LoonUIExtension

这次的 extension 包含一个 View Controller,一个 Storyboard,以及一个 Info.plist

同样打开 Info.plist ,修改 NSExtension/NSExtensionAttributes/IntentsSupported 数组的元素项为 INRequestRideIntent

打开 Storyboard,设置 UI 如下 iOS 10 by Tutorials 笔记(六)

然后在 View Controller 中设置好 IBOutlet,该 VC 遵循了 INUIHostedViewControlling 协议,提供了一个 configure(with: context: completion:) 方法,用于设置 VC 内容是调用,所以我们在此方法中来配置我们的界面元素

// Prepare your view controller for the interaction to handle.
  func configure(with interaction: INInteraction!, context: 
INUIHostedViewContext, completion: ((CGSize) -> Void)!) {  
    // Do configuration here, including preparing views and calculating a desired size for presentation.
    guard let response = interaction.intentResponse as? INRequestRideIntentResponse else {
      driverImageView.image = nil
      balloonImageView.image = nil
      subtitleLabel.text = ""
      completion(self.desiredSize)
      return
    }
    // 这个 extension 会被调用两遍,一次是请求,一次是确认请求,
    // 第二次应该会有司机信息
    if let driver = response.rideStatus?.driver {
      let name = driver.displayName
      driverImageView.image = WenderLoonSimulator.imageForDriver(name: name)
      balloonImageView.image = WenderLoonSimulator.imageForBallon(driverName: name)
      subtitleLabel.text = "/(name) will arrive soon!"
    } else {
      driverImageView.image = nil
      balloonImageView.image = nil
      subtitleLabel.text = "Preparing..."
    }
    // 传入了最大允许的尺寸
    completion(self.desiredSize)
  }

  var desiredSize: CGSize {
    return self.extensionContext!.hostedViewMaximumAllowedSize
  }

最后运行一下看下效果 iOS 10 by Tutorials 笔记(六)


-EOF-

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址