Oredev jack cover

iOS 系统搜索集成

在 iOS 9 中,Apple 向开发者们开放了 iOS 系统的搜索 API,在应用中实现此功能简单得超乎你的想象。利用 NSUserActivity 和 Core Spotlight 所提供的功能,你可以轻松地实现搜索功能,并且还可以从主屏幕中直接搜索出你应用中的数据。搜索功能能够极大地增加您应用的曝光度,所以立即开始实现它吧!🔍


介绍 (0:00)

大家好,我是 Jack。今天我演讲的主题是 iOS 中的系统搜索功能,我尤其会着重讲解如何在应用中加入 iOS 9 中新引入的全局搜索功能 (global search functionality)。大家都知道,iOS 的系统搜索功能已经存在很久了。你可以借助这项功能检索已安装的应用、邮件、联系人、日历等多类信息,就我个人而言,我更喜欢用这项功能来启动应用,而不是从主屏幕中启动应用。

在 iOS 9 中,这项功能更加有用了,因为你的应用可以提供相关的内容,并让它们能够被 iOS 系统搜索到。这项功能不仅对设备上的本地数据有效,而且还对你应用中所显示的网络数据有效。你可以展示你从网络中获取的数据,当数据读取完毕后用户就可以搜索到它,随后用户单击这项内容后,就会打开应用,在本地显示这些数据。这使得 iOS 系统搜索功能不仅仅只用于搜索已安装的应用,还可以实现更多的功能。

在本次讲座的安排中,我们将会讨论几种在 iOS 9 中为应用添加系统搜索功能的方式,它们所涉及到的技术非常相似。其中之一就是 NSUserActivity,它的用处基本上是对私有内容和用户可见的公开内容建立索引。此外还有一个在 iOS 9 中新引入的 Core Spotlight,它能够使用设备上的私有内容来实现本地搜索。使用这两种方式的好处就是,它们实际上都允许你能够以一种更好的方式在搜索结果中显示诸如图片之类的数据。

此外我们还将探讨 Web 标记,它允许你在所展示的内容中,查找拥有本地组件 (native component) 和 Web 组件的相同内容。你可能会希望你的本地应用能够有这样的功能:无论你所拥有的 Web 账户存放在何处,你都能够更新你的 Web 应用数据,并且还能决定所能够搜索到的数据。最后,我会快速演示一个我创建的简单代码库,在其中我实现了某些例子。我所有的演示示例代码都放在了 GitHub 上。每一步我都建立了不同的分支,以便为大家提供方便。

NSUserActivity 概览 (3:15)

首先,NSUserActivity 实际上是为支持 Handoff 而引进的一项技术,它允许你创建能够在多台 iOS、Mac 设备之间协同工作的应用。其基本思路是在一个特定的时间点保存当前应用的工作状态,这样其他相关联的应用就可以使用这个保存的工作状态,从而实现再另一台设备中继续使用。

在 iOS 9 中,Apple 实际上使用了同样的技术来实现系统搜索功能。当用户在你的应用中浏览时,你可以通过 ID 将用户浏览的地方标记上不同的状态,这将允许你在之后重新恢复到这些状态。为了让这个功能更容易使用,Apple 对此进行了一点小小的调整,无论你保存了什么样的用户活动 (user activity) ,它都将自动建立索引,从而可以被搜索到。

你所能做的就是,决定是否将这些活动标记为公开索引。一个被标记为公开可见的页面,那么就说明任何使用这个应用的人都可以看到这个页面。一旦这些公开可搜索项 (searchable item) 中的用户点击率达到一定程度,那么 Apple 就会在他们的公开搜索索引中显示这些搜索项,从而让它们能够显示在其他用户的 iOS 9 搜索结果中,即使这些用户并没有安装过你的应用。这在很大程度上增强了你应用程序的曝光度,因为人们很有可能会去搜索一些他们所不知道的热门应用。

func application(application: UIApplication, 
  continueUserActivity userActivity: NSUserActivity,
  restorationHandler: ([AnyObject]?) -> Void) -> Bool

Receive news and updates from Realm straight to your inbox

除了实际创建这些 NSUserActivity 对象之外,你还需要实现一些功能来让系统知道如何对其进行处理。上面这段代码是一个应用委托方法,它会在合适的时候被调用。在 iOS 9 中,它会当用户搜索到你的公开项并点击的时候调用。你可以将用户直接带到相应的页面上。这个方法实际上也是用以实现 Handoff 的方法。因此,如果你已经实现了 Handoff 功能,那么实际上你已经完成了绝大部分的工作了。

userActivity.eligibleForPublicIndexing

我提到过,用户活动可以设定为公开的,也可以设定为私有的。如果你将其标记为公有的话,那么你就可以增加应用的曝光率,让那些没听说你应用的人有更多机会了解到你的应用,并且实现起来非常简单,它只是一个在 userActivity 中的一个布尔属性而已。你只需要给这个属性设为 true,就万事大吉了。

Core Spotlight 概览 (6:48)

另外一个我想讨论的重要技术就是 Core Spotlight 了。这同样也是一个用于系统搜索的技术,并且它在 OS X 上已经存在了很长时间了。Core Spotlight 对于所需要显示的可搜索项,提供了多种预定义好的类型,比如说图像、音频等等。这允许你将应用内容变为可搜索到的,并且可以以更丰富的方式来展现这些内容,因为你不仅可以往搜索内容中添加图像,还可以给出指定文本的上下文内容等等。Core Spotlight API 与索引数据库建立了映射,这样你就可以在合适的时候执行更新、删除之类的操作。它使用的是私有的用户数据,因此这些数据只能够在本机上显示。在 Core Spotlight 中建立索引的数据是无法被别的机器发现的。

NSUserActivity 和 Core Spotlight 的功能极其相似,但是某些地方也是有所不同的。好处就是在同一个应用中同时实现两种技术是完全可行的,并且实现难度不是很高,我会在下面进行展示。它们使用的都是同一个应用委托方法,当系统想要展示搜索结果的时候,这个方法就会被调用。不过 Core Spotlight 搜索方式和其他搜索方式还是有不同之处的,因此需要稍微处理一下这些不同的地方。用户活动中可能会包含有稍微不一样的东西,这取决于它是被标记为 NSUserActivity 项还是被标记为 Core Spotlight 项,我们接下来也会看到它们是如何进行工作的。

为 Web 内容建立索引 (8:52)

为 Web 内容建立索引的主要内容是关于如何正确地在你的网页上显示元数据 (metadata) 的。我们再强调一遍,如果你的应用程序非常依赖于 Web 上的数据,那么建立索引应当是你首先需要做的事情。比如说你有一个和 Airbnb 类似的应用。用户可以在应用内置的 Web 页上进行搜索,也可以在手机提供的搜索中进行搜索,这两种搜索方式都应该要显示出相同的结果,因此你想要将它们集成到一起,减少代码复用。你所要做的就是进行一些设置,让网络搜索返回一些和你应用相关联的元数据。

为了实现这个功能,你所需要做的其实并不多。首先,你需要使用智能广告条 (Smart Banners) 或者通用链接 (Universal Links) 来创建深度链接 (deep linking) 的元数据。Apple 实际上内置了许多不同种类的深度链接,比如说 Twitter 或者 Facebook 应用链接等等。因此如果你当前的 Web 应用中通过放置了正确的链接实现了这种功能,那么 Apple 就可以找到这些元数据,从而可以在iOS 9 搜索功能中,通过 Web 搜索到这些内容,然后启动你的应用。Apple 或许会在这个功能更流行之后为其提供更多的支���技术。

另一个内容是结构化数据 (structured data)。这是一个可选的功能,但实际上这也非常炫酷,因为 Apple 支持很多不同的结构化数据模式,从而让你可以为搜索结果提供额外的数据。其中某些模式可以直接在 iOS 9 搜索结果中直接执行某些操作,甚至无需启动你的应用就可以完成。例如,假设你正从一个类似于 Airbnb 的应用中寻找一个正在出租房屋的房东的电话号码。你可以用一个已确定的 meta 标签对这个联系人进行标记,随后他的电话号码就会以按钮的形式出现在 iOS 搜索结果中,点击这个号码就可以直接拨打他的电话了。

当你要提交应用的时候,你同样可以帮助 Apple 对你的搜索结果建立索引,比如说你的营销网站、官方网站等等。如果这些网站都链接到了你的主页,并且这个主页导向的是有用的深度链接的话,那么 Apple 就可以对其建立索引,将这个网站和你的应用关联起来。我们重复一遍,这个功能不是强制的,但是却十分有用。

除了 Web 元数据之外,剩下你所需要实现的功能就是在你的 iOS 应用中,为其添加能够通过一个给定的 URL 启动应用的能力。这与 Core Spotlight 和 NSUserActivity 所使用的委托方法不同,这里用的是 func application(app:openURL:options:)。在这个方法中你可以获取一个 URL,然后尝试打开这个链接,然后根据用户的请求来显示正确的应用内容。点击搜索结果将会跳转到你的原生应用中应当成为一个常识,因为 Apple 会向你的应用发送这些信息。因此,如果你的应用需要登录或者注册才能够允许用户访问里面的内容的话,你需要考虑当用户从搜索结果中跳转到应用当中的时候,是否需要延迟强制登录的提示。

Apple 对于搜索、Web 内容元数据、所支持的模式等还提供了更详细的说明,你也可以在开发者中心找到网站验证工具,验证你的网站是否符合要求。

回顾 - 如何实现上述的功能 (14:05)

完整的示例代码都可以在这里找到,每个分支对应一个内容。

示例应用配置 (14:46)

为了演示如何实现搜索功能,我创建了一个非常简单的应用,它提供了一个关于老式计算机数据的数据库,并且可以在全局 iOS 搜索中查询到这些数据。这个数据库非常简单,它只包含有两个数据。也就是 Apple 2 和 Atari 400。同样地,代码也是简单得超乎你们的想象。详情视图控制器将会展示 Computer 结构体类型的详细信息,同时还包括了其中每个计算机中的一些数据。我同样还写了一个名为 ComputerDataSource 的类,它将 plist 文件转为 Computer 类型。不要在实际中使用这种糟糕的强制解包方法,因为我们只是为了演示,因此简单一点就好。总之,目前为止这个应用程序是非常简单的。

创建可搜索的 NSUserActivity 对象 (18:06)

接下来的 Step 2 分支中,我添加了 NSUserActivity 的基本实现。这其实非常简单。当用户准备浏览某个电脑的详情视图时,我在 prepareForSegue() 中调用相关方法创建并保存这些 NSUserActivity 对象。

func updateIndexForComputer(computer: Computer) -> NSUserActivity {
  let activity = NSUserActivity(activityType: "com.thoughtbot.retro.computer.show")
  activity.userInfo = ["name": computer.shortDescription]
  activity.title = computer.shortDescription
  activity.keywords = [computer.model, computer.company, computer.cpu]
  activity.eligibleForHandoff = false
  activity.eligibleForSearch = true
  
  activity.becomeCurrent()
  return activity
}

我创建了一个 NSUserActivity 对象,并为其分配了一个 activityType。这个活动类型应该是你可以识别的类型,命名风格是一个典型的 Apple 命名风格,也就是反向 DNS 风格。随后我会声明,这个活动类型将显示我模型中的某一个项目,然后它会根据我给定的活动类型进行命名。我同样还添加了一个用户信息,这是一个字典,其中包含了足够的信息以便能够唯一确定它实际要展示的内容。在这个例子中,由于现在我的电脑对象并没有任何类型的标识符,因此我就只好使用 “shortDescription” 来作为标识符,这个是一个关于计算机制造商以及计算机架构的数据,我希望这不会出问题。接下来还有一些事情,我设置了标题和关键词,这些都是能够被搜索到的数据。因此,当用户前往 iOS 9 的搜索界面搜索这些标题或者关键词的时候,所有信息就会展示出来。

我同样还将 eligibleForHandoff 属性设为了 false,因为目前我们并不对使用 Handoff 感兴趣。再次强调,NSUserActivity 拥有双重职能。它不仅用于搜索,还可用于 Handoff。由于这里我们并不会实现 Handoff 功能,只需要它的搜索功能,因此最后我们就将 Handoff 支持取消即可。在这种情况下 becomeCurrent() 将会更新 iOS 9 的搜索索引,以便让这些项目变为可搜索的。最后我们所要做的就是将这个活动返回即可。

latestActivity = updateIndexForComputer(object)

在主视图控制器中,我用一个称之为 latestActivity 的属性来保存这个返回值。这个属性我实际上并没有对其进行读取,但是我发现如果你不将这个返回值保存下来的话,有时就会出现 NSUserActivity 没有建立索引的情况,因为这时候这个活动被释放掉了,因此就没有任何机会在搜索中现身了,这确实有点烦人。

完成之后,我们就可以让这个内容能够在 iOS 中搜索到了,但是我们目前还没有处理当用户点击查询结果的时候所出现的情况。

从搜索结果中恢复应用状态 (22:22)

在 Step 3 分支中,我添加了下面这个委托方法:

func application(application: UIApplication, continueUserActivity userActivity: NSUserActivity, restorationHandler: ([AnyObject]?) -> Void) -> Bool {
  let splitController = self.window?.rootViewController as! UISplitViewController
  let navigationController = splitController.viewControllers.first as! UINavigationController
  navigationController.viewControllers[0].restoreUserActivityState(userActivity)
  // navigationController.topViewController?.restoreUserActivityState(userActivity)
  return true
}

当有人单击搜索结果时这个方法就会被调用,以处理所点击的用户活动。我通过 Split 控制器和导航控制器做了一些疯狂的事情,这是因为在默认的有多个导航控制器的项目中 Apple 所做的那些奇怪的配置。关键是我实际上只想抵达显示对象列表的根视图控制器而已。要获取我们的主视图控制器,我们需要获取到第一个导航栏控制器,然后从这个导航栏控制器中获取第一个视图控制器。随后我让其调用 restoreUserActivityState() 方法。这个方法是定义在 UIViewController 中的,但是亦可以对其进行重写从而实现一些有趣的功能。在这里我只是委托这个主视图控制器负责做它所需要做的事情就可以了。

override func restoreUserActivityState(activity: NSUserActivity) {
  guard let name = activity.userInfo?["name"] as? String else {
    NSLog("I can't restore from this activity: \(activity)")
    return
  }
  
  let matches = ComputerDataSource().computers.filter {
    $0.shortDescription == name
  }
  
  if matches.count == 0 {
    NSLog("No computer matches name \(name)")
    return
  }
  self.computerToRestore = matches[0]
  self.performSegueWithIdentifier("showDetail", sender: self)
}

这里我实现了这个 restoreUserActivityState() 方法。我首先会检查 name 是否存在,随后从数据源中抓取计算机清单,使用 filter 方法获取 shortDescription 相同的计算机,以确保我能够找到该活动对应的数据,如果找到的话将其存储到名为 computerToRestore 的属性中,然而调用 performSegueWithIdentifier() 方法。

主控制器拥有一个前往详情控制器的 segue,通过这个功能能够轻松定义详情控制器的启动行为,因此我采用这个功能。prepareForSegue() 的最初版本中将会获取表视图当前所选行的 indexPath,随后基于此进行其他设置就可以。下面是更新后的版本:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  if segue.identifier == "showDetail" {
    if let computer = computerToRestore {
      let controller = (segue.destinationViewController as! UINavigationController).topViewController as! DetailViewController
      controller.detailItem = computer
      controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem()
      controller.navigationItem.leftItemsSupplementBackButton = true
    computerToRestore = nil
    }
    else if let indexPath = self.tableView.indexPathForSelectedRow {
      ...
    }
  }
}

我设置了一个前提条件,我们会首先查看,computerToRestore 这个属性是否有值。如果有值,我们就和之前所做的那样,将 computerToRestore 传递给下一层控制器。到目前为止,我们就可以点击搜索结果后成功地进入到对应的页面当中了,但是如果我们想给搜索结果增加点东西怎么办呢?

使用 Core Spotlight 来丰富搜索结果 (27:47)

为了丰富搜索结果的显示,而不仅仅只是显示应用图标,我们将使用 Core Spotlight。

func updateIndexForComputer(computer: Computer) -> NSUserActivity {
  // ... setting up activity
  
  let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeImage as String)
  attributeSet.title = computer.shortDescription
  attributeSet.contentDescription = "\(computer.shortDescription)\n\(computer.cpuDescription)\n\(computer.productionStartYear)"
  attributeSet.thumbnailData = UIImagePNGRepresentation(computer.image)
  
  let item = CSSearchableItem(uniqueIdentifier: computer.shortDescription, domainIdentifier: "retro-computer", attributeSet: attributeSet)
  
  CSSearchableIndex.defaultSearchableIndex().indexSearchableItems([item]) { error in
    if let error = error {
      NSLog("indexing error: \(error)")
    }
  }
  
  //  activity.becomeCurrent()
  return activity
}

首先我想指出的是,我注释掉了最底下的 activity.becomeCurrent() 语句。我想要确保我们用 Core Spotlight 所做的东西是唯一发挥作用的。Core Spotlight 和 Apple 大多数核心框架类似,它大部分是由 C 编写而成的,而不是用 Objective-C 或者 Swift。因此,我创建了一个 attributeSet 属性集实例,给定一个内容类型,这个内容类型是框架中预定义好的字符串列表。因为我们在这里声明的是图像类型,因此我们想可以在搜索结果中显示一个实际的图片。

接着我还需要做其他操作,给定 ID 以及包装有图像的 NSData 类型。唯一标识符 (unique ID) 和域标识符 (Domain ID) 都十分有用,域标识符允许你通过分组 (group) 功能做一些非常赞的功能,但是这里我们就只设定一个 “retro-computer”。接着我将属性集传递进去,最后向 CSSearchableIndex 申请一个默认的索引,然后对我们创建的可搜索项建立索引(我们现在只创建了一项)。在模拟器中,我们现在可以看到,在我删除掉应用并重启模拟器之后,基于 Core Spotlight 功能的搜索结果看起来就更丰富了,因为它包含了一个图片和描述信息。

Core Spotlight 和 NSUserActivity 都可以用来相同的功能,但是它们各自都使用着不同的类。很多时候你可能总想将它们一同实现,不过我并不是很确定为什么 Apple 不将它们整合到一个 API 当中,这样就可以一次性完成这两个功能了。大家感兴趣的话可以去试一试解决这个问题,因为这个项目非常有意思,并且也不是很难。

使用 Core Spotlight 恢复应用状态 (33:12)

为了让 Core Spotlight 的搜索结果能够将用户带到应用中的合适位置,我在 Step 5 中对主视图控制器做了一些改动。现在它会检查传入的活动对象类型。如果传递进来的是 Spotlight 对象的话,,那么活动类型就会被设置为 CSSearchableItemActionType。这只是一个用来让你知道结果被点击的标记而已,因此你可以获取到这个 Spotlight 项,然后以合适的方式对其进行处理。因此,在这种情况下我会以这种特别的方式获取 name 信息,这也是我所返回的名字信息。如果不是 Spotlight 搜索项的话,那么获得的就是 NSUserActivity 搜索项,原有的方法仍然适用。最后,我们就和此前一样恢复应用状态即可。

两全其美 (35:36)

为了完整期间,我们现在需要实现一种两全其美的方式,在 Step 6 中,最好还是把 NSUserActivity 带回来。我重新启用了 becomeCurrent() 函数,同样我还添加了 activity.contentAttributeSet = attributeSet 语句。现在我们在这里为 Core Spotlight 所创建的属性集就和活动建立起了同样的联系,这样这两种方式都有着相同的信息了。因此,当 Core Spotlight 或者 NSUserActivity 的信息展示的时候,iOS 会很智能地依据唯一标志符和使用的活动信息来将它们区分开来。这对 Web 也是同样适用的,我们建议使用网页的 URL 作为唯一ID。

现在,我们所实现的功能非常令人激动,通过它就可以让 iOS 系统能够搜索你应用中的内容了!

问与答 (38:24)

问:是否可以在应用中创建一个搜索函数,并利用此函数实现相同索引内容的检索呢?

Jack:我觉得 Core Spotlight 可以在应用中实现自定义搜索功能。虽然我并没有详细了解这方面的内容,但是由于在 OS X 上可以这么做,因此我觉得这应该是可行的。

问:这个用来搜索的数据库中能放入多少数据?

Jack:当数据太多的时候 iOS 会通知你得,这个时候你就需要删除一些信息,最好的办法就是使用我之前提到的域标识符来完成这种操作了。同样,你通过 Core Spotlight 创建的索引大小会影响整个应用中的数据存储大小。这里并没有所谓的理论限制,不过实际上如果你的应用大小比下载时要大得多的话,这可能会有实际限制存在,不过这基本上是很难发生的。因为索引通常情况下是非常小的。

About the content

This talk was delivered live in November 2015 at Øredev. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Jack Nutting

Jack has been building apps with Cocoa (and its predecessors) for over 20 years. While he considers Objective-C his mother tongue, he was more than ready for the great new features Apple introduced with Swift in 2014. Jack has co-authored several iOS and OS X programming books, including the best-selling ‘Beginning iPhone Development with Swift.’

4 design patterns for a RESTless mobile integration »

close