iOS 10 by Tutorials 笔记(十三)

Chapter 13: What’s New with Search

自 iOS 10 开始,Core Spotlight Search 新的 API 可以搜索我们的 apps,当我们在 Spotlight 搜索某个关键词时,可以进入相关 App 继续进行搜索工作。

另一个新特性是你可以在 user activities 上添加位置信息,iOS 系统内置的很多系统级应用如 QuickType,Siri,Maps 都能直接访问到,当使用到时会以某种形式进行推荐展示

好酒也怕巷子深,Apple 也在不遗余力地曝光你的 App 呢。如果你之前已经索引过 Core Spotlight 或 NSUserActivity items,那么这些新特性实现起来几乎没什么门槛。

本章我们来完成一个展示绿色食品的 App,绝大多数功能已经实现,就剩下添加这些 Search 的新特性了。 先来一睹程序结构:

  • AppDelegate.swift 做两件事情
    • application(_:didFinishLaunchingWithOptions:) 中调用 dataStore?.indexContent() 索引 Core Spotlight 里的全部蔬菜
    • 当搜索结果匹配时,点击进入 App,application(_:continue:restorationHandler:) 方法中设置恢复状态
  • Product.swift 蔬菜的 model 对象
  • SearchableExtensions.swift 用于生成 CSSearchableItem 和 CSSearchableItemAttributeSet 对象以便索引 Core Spotlight 时使用
  • ProductTableViewController.swift Products tab 下的 Root Controller,用于以列表的形式显示蔬菜,以及过滤的内容
  • ProductViewController.swift 将展示用户选中某行蔬菜的详情页面
  • StoreViewController.swift Store tab 包含水果店的信息,包括用地图的方式展示位置,给用户一些积极的建议等

绿色食品这一应用通过 Core Spotlight 和 NSUserActivity indexing 已经启用了 Spotlight search,在本章,你将实现三项新功能:

  1. Spotlight 搜索某个食品的过程将传递给我们的绿色食品应用
  2. 重构现有的搜索 API,将它们替换为 Core Spotlight Search API
  3. 修改 StoreViewController,使其能根据位置信息向用户提供有用的建议。

Enabling search continuation

Spotlight search 已经足够强大了,但如果用户想要更多信息,就需要跳转到 App 中去查看,换句话就是 App 要接过 Spotlight search 搜索结果的接力棒。首先打开 Info.plist,添加 CoreSpotlightContinuation 并设置为 YES

这个 key 告诉 Spotlight 在右上角展示一个在应用内搜索的的链接

添加完 key,可能要重启设备或模拟器才能看到

现在点击此链接还没有反应,下面我们来实现一下

Implementing search continuation

AppDelegate.swift 中载入头文件

import CoreSpotlight  

当用户选中 Spotlight 里的一个活跃的索引,application(:_continue:restorationHandler:) 方法是一个可以用来向应用传递选中状态的一个地方,首先判断 activity 类型是否是继续搜索或查询

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Swift.Void) -> Bool {  
    if userActivity.activityType == CSQueryContinuationActionType {
      // 1 确保是字符串类型
      guard let searchQuery =
        userActivity.userInfo?[CSSearchQueryString]
          as? String else {
            return false
      }
      // 2 进行类型转换
      guard let rootVC = window?.rootViewController,
        let tabBarViewController = rootVC as? TabBarViewController
        else {
          return false
      }
      tabBarViewController.selectedIndex = 0
      // 3 确保回到 ProductTableViewController 页面
      guard let navController =
        tabBarViewController.selectedViewController as?
        UINavigationController else {
          return false
      }
      navController.popViewController(animated: false)
      if let productTableVC = navController.topViewController as?
        ProductTableViewController {
        //4 开始搜索
        productTableVC.search(with: searchQuery)
        return true
      }
    } else if let rootVC = window?.rootViewController,
      let restorable = rootVC as? RestorableActivity
      , restorable.restorableActivities.contains(userActivity.activityType) {
      restorationHandler([rootVC])
      return true
    }
    return false
  }
}

再次运行搜索 Apple,这次点击右上角 Search in App 标识就能顺利跳转到 App 中了

只搜一个 Apple 没什么太大用处,只有搜索的结果有多个,甚至多到 Spotlight 都显示不下了,这个特性还有点用处,试着搜索 fruit 试试

Spotlight 页面成功展示了搜索结果,但在应用中打开什么都没有,我们的匹配结果呢?

很显然 fruit 是被索引进元数据的(meta-data),但问题显然出现在我们自己实现的搜索中,打开 ProductTableViewController.swift 的搜索方法 filterContentForSearchText(searchText:)

filteredProducts = dataStore.products.filter { product in  
  return product.name.lowercased().contains(searchText.lowercased())
}

我们这里仅仅使用了一个大小写不敏感、按名字匹配的过滤方法,显然 fruit 并不属于某种蔬菜食品的名字。我们可以仿照 Spotlight 的过滤方式来实现一遍,但为什么不就地取材,利用 Spotlight 现有的搜索算法来替代自己的实现呢?

iOS 10 我们终于可以利用新的 Core Spotlight Search API 来获取相关搜索结果了,即不用自己再去设计算法实现了。

但在开始重构代码之前,先来熟悉下 Core Spotlight 的查询语法。

Core Spotlight Search API

Spotlight 用来索引你的数据并提供了一套强大的查询语言来加速搜索引擎,如果 Core Spotlight 已经索引了你的内容,使用 Search API 意味着你在应用内搜索和在 Spotlight 中搜索的结果会保持一致。也能从一定程度上保护用户的隐私(App 只能搜索自己的数据)

要完成搜索,首先要创建 CSSearchQuery 来定义你想如何搜索,初始化需要两个参数:

  • queryString 一个格式化的字符串定义了搜索的方式
  • attributes 一个字符串数组对应着 CSSearchableItemAttributeSet 类中的属性名称

掌握如何格式化查询字符串是实现 Core Spotlight 查询的第一项挑战,最基本的查询格式如下:

attributeName operator value[modifiers]  
  1. attributeName 包含在 attributes 数组中,用来说明对象某个具体的属性,比如标题 Title
  2. operator 下列运算符之一:==, !=, <, <=, >, >=
  3. value 你所比较的字面量值,比如上面提到的 fruit
  4. modifiers 由四个不同的值组成,对比较过程的进行设置
title == "*apple*"c  

表示不分大小写的比较,apple 的前后可以有额外的字母

title == "apple"wc  

表示不分大小写,但把 apple 必须是完整的一个单词,前后可以有额外的单词,比如 "Fuji Apple","Red Delicious Apple",但不能是 "Pineapple","Snapple"(第一种查询方式可以)

Spotlight 查询语言也提供了区间查询

InRange(attributeName, minValue, maxValue)  

查询落在最大最小值范围内的 attributeName,日期对象 Dates 显然适用,对于日期对象其使用的是浮点数来表示(距 2001 年 1 月 1 日的秒数),更常见的做法是使用 $time

$time 有一些类似于 now 和 today 表示特定时间的属性,它允许根据当前时间去计算一些时间,比如 now(NUMBER) 表示距离查询时刻起之后的一个特定时间。

title == "apple"wc && InRange(metadataModificationDate,$time.today(-5),  
$time.today)

metadataModificationDate 用来表示 5 天前到现在这一段时间,整个查询是指寻找包含 apple 单词的标题,并且在过去 5 天内修改过。

关于 $time 更多属性,请查看官方文档

Migrating to Core Spotlight Search API

下面我们来迁移到使用 Core Spotlight Search API,打开 ProductTableViewController.swift 添加一个属性

var searchQuery: CSSearchQuery?  

我们将使用它来管理你发起的 CSSearchQuery 请求状态,现在将注意力集中在 filterContentForSearchText(searchText:) 方法上,他负责根据搜索字符串来更新要显示的内容。当前我们还在 dataStore 中根据名称(name)来过滤数据内容。是时候轮到 Core Spotlight Search 大显身手了。

删掉之前写的过滤匹配算法:

filteredProducts = dataStore.products.filter { product in  
  return product.name.lowercased().contains(searchText.lowercased())
}
tableView.reloadData()  

替换成 Core Spotlight Search API 的实现

// 1 每次搜索前先取消掉上一次的搜索
searchQuery?.cancel()  
// 2 仿照 Spotlight 的查询行为,设置了查询字符串(不分大小写的字符串包含匹配)
let queryString = "title=='*\(searchText)*'c"  
// 3 创建了一个 CSSearchQuery 对象,并将 newQuery 指向 searchQuery,这样你在
// 开头就能取消它了
let newQuery = CSSearchQuery(queryString: queryString, attributes: [])  
searchQuery = newQuery  
// 4 涉及与 CSSearchQuery 相关联的一些操作
//TODO: add found items handler
//TODO: add completion handler
// 5 使用 filteredProducts 作为 tableView 的 data source
// 每次开始查询前都要先清除上次的结果
filteredProducts.removeAll(keepingCapacity: true)  
newQuery.start()  

现在还没有任何行为来监听搜索的返回结果,我们来把上面的 TODO: add found items handler 语句替换为下面的代码:

newQuery.foundItemsHandler = {  
  (items: [CSSearchableItem]) -> Void in
  for item in items {
    if let filteredProduct = dataStore.product(withId:
      item.uniqueIdentifier) {
      self.filteredProducts.append(filteredProduct)
    }
  } 
}

我们的食品 product 信息以 plist 的形式存储在本地,它的结构为

typealias ProductID = UUID

final class Product {  
  let id: ProductID
  let name: String
  let price: Int
  let details: String
  let photoName: String

foundItemsHandler 方法找出了所有匹配的结果(放到 [CSSearchableItem] 数组中),然后遍历,通过每个 id 的 uuidString 过滤出 dataSource 中的 product 食品,最后将结果放入 filteredProducts 数组。

得到过滤后的数据源 filteredProducts,我们再来完成第二条 //TODO: add completion handler,增加一个 completionHandler

newQuery.completionHandler = { [weak self] (err) -> Void in  
  guard let strongSelf = self else {
    return
  }
  strongSelf.filteredProducts = strongSelf.filteredProducts.sorted
    { return $0.name < $1.name }
  DispatchQueue.main.async {
    strongSelf.tableView.reloadData()
  }
}

之前添加数据源 filteredProducts 中的 product 是无序的,我们来排个序,然后在主线程中刷新 tableView

再次运行,在 Products 界面测试过滤关键字,我们用 Ap 来过滤出了包含此关键字的 Apple 和 Grapes

到此位置我们的查询类似于 Spotlight,但还有个缺点,只能搜索标题,然而 Spotlight 却会去检查所有的元数据(metadata)

问题出在我们之前写的查询语句中只有标题匹配:

let queryString = "title=='*\(searchText)*'c"  

修改为:

let queryString = "**=='*\(searchText)*'cd"  

这里使用 ** 替换了 title,因此搜索的范围不局限于标题了,更加广泛。添加的 d 表示忽略变音符。再次运行,输入 fruit 这次有结果了,就和你在 Spotlight 搜索时一样了。

更实际点的例子,你通过『钾』关键字来查询含钾的食品,结果会出现香蕉

Proactive suggestions for location

我们还可以通过 NSUserActivity 为搜索实现类似 Handoff 和上下文提醒的特性。为索引的 NSUserActivity 对象增加位置信息,意味着瞬间支持了 Maps, QuickType, Siri 等原生应用。

查看下 Store tab 模式下的视图内容,与水果摊的位置有关:

如果我们切换到 Messages,Emporium 的地址出现在快速输入内容里;而切换到 Mpas,它的地址出现在最近访问过的位置,然后通过 Siri 可以让它给出路线导航图

对于那些已经索引了 NSUserActivity 对象的 App 来说,这很容易实现。首先需要针对位置有关的活动最小化设置一个新的 thoroughfare 和 postalCode,这二者都算是 CSSearchableItemAttributeSet 的属性。用于显示目的以及帮助位置服务找出地址。

当然你也可以添加下面的选项来提供搜索结果质量:

  • namedLocation
  • city
  • stateOrProvince
  • stateOrProvince

如果想更精确些还应该加上 latitude 和 longitude 属性。如果你使用 MapKit,你可以将 MKMpaItem 指向一个 NSUserActivity,它会为你构成所有的位置信息。幸运的是我们的绿色食品 App 已经准备好了,设置起来就是小菜一碟。

做这个试验需要真机设备,毕竟模拟器不支持位置服务,在物理设备上打开应用,导航到 Stroe 栏目并记住 Mulberry Street 的店名地址。先在两个 Tab 之间来回切换几次,确保 NSUserActivity 索引已经开始工作。

现在按 Home 键切回到主屏,使用 Spotlight 搜索 Mulberry Street,滚动浏览所有结果,发现没有相关匹配的绿色食品店铺。

打开 StoreViewController.swift 看一眼,你会发现一个 MKMapItem 带一个店铺地址,以及一个 CSSearchableItemAttributeSet 其中包含店铺的经度(longitude)和纬度(latitude)

supportsNavigation 属性也设置为 true,允许来自 Spotlight 的导航使用坐标。但是当前 Spotlight 对地址内容一无所知,因此 Mulberry Street 并没有与之相匹配的结果。

但是,我们通过一行代码就能为 NSUserActivity 提供其所需要的地址信息,并且开启主动的位置建议。

在 StoreViewController.swift 中找到 prepareUserActivity(),该方法会在 store view 载入时调用,并且为视图创建一个带搜索功能的 NSUserActivity。

prepareUserActivity() 方法内部返回前添加:

activity.mapItem = mapItem()  

mapItem() 返回一个 MKMapItem 表示商店的位置,设置给 activity 的 mapItem 属性将解锁位置建议功能,此外还会利用位置信息填充 CSSearchableItemAttributeSet,包括街道名

尽管当前使用了 MKMapItem 来设置 CSSearchableItemAttributeSet 属性,但我们现有的代码还存在一些小问题,会覆盖掉这些位置信息。

找到 updateUserActivityState(_:) 方法中的下面这行代码

let attributeSet = CSSearchableItemAttributeSet(itemContentType:  
kUTTypeContact as String)  

这里我们重新创建了一个 CSSearchableItemAttributeSet 然后分配给 NSUserActivity,这样就会覆盖掉之前 MKMapItem 所提供的 CSSearchableItemAttributeSet。

我们来修正下:

let attributeSet = activity.contentAttributeSet ??  
  CSSearchableItemAttributeSet(itemContentType: kUTTypeContact as String)

再次运行,在两个 Tab 间多切换几次,确保 NSUserActivity 的改变已经完全索引,然后双击 Home 键来到应用切换界面,在底部可以看到相关建议选项(包括应用名和地址),点击建议选项可以打开地图并显示出该位置。

同样打开 Message 输入 Meet me at... 试验一下,你会看到一个 QuickType 的输入建议,包括 Ray’s store

最后再次回到 Spotlight 中搜索 Mulberry,Ray’s Fruit Emporium 的结果赫然在列,意味着 MKMapItem 已经成功地将地理位置信息添加到了 CSSearchableItemAttributeSet 之中。


-EOF-

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!