Spotlight 検索に iOS アプリのコンテンツを表示させる

 こんにちは。検索・編成部の中村です。いよいよ来月は WWDC 2016 が開催されますね。どんな発表があるか今から楽しみです。本エントリでは、Core Spotlight APIs を使用してアプリ内のコンテンツを Spotlight 検索に表示させる方法について解説していきます。

Spotlight 検索

 Spotlight 検索はホーム画面を右や下にスワイプして表示します。画面上部の検索窓からアプリ内のコンテンツを検索でき、ヒットした項目をタップするとアプリが起動して目的のコンテンツが表示されます。この仕組を利用してユーザーは素早く目的を達成できます。数多くアプリをインストールしているユーザーは、アプリの検索に利用しているのではないでしょうか。

f:id:nkmrh:20160520094836p:plain

 iOS クックパッドアプリ(v16.3.0.0 以降)では、特売情報を掲載している店舗が Spotlight 検索にヒットします。店舗をタップすると、アプリが起動して目的の店舗ページが表示されます。また、右端の矢印をタップすると地図アプリが起動し店舗の位置を表示します。

f:id:nkmrh:20160520094824p:plain

f:id:nkmrh:20160520094843p:plain

 このように Spotlight 検索に表示するには、コンテンツのメタデータをあらかじめ OS にインデックスさせておく必要があります。以降はその具体的な実装方法を紹介します。

Search API

 Spotlight 検索にコンテンツをインデックスさせるには3つのアプローチがあります。

  • NSUserActivity

NSUserActivity を使用する方法です。NSUserActivity は Handoff の実装にも使用するクラスです。ユーザーが閲覧したコンテンツをインデックスする際はこのクラスを使用します。パブリックコンテンツの場合は、メタデータを Apple のサーバに送信することで、検索結果ランキングを向上させることもできるようです。

  • Core Spotlight APIs

Core Spotlight Framework を使用する方法です。アプリに保存されているコンテンツをインデックスする際に使用します。

  • Web markup

同じコンテンツがWebサイトにある場合、ウェブページにコンテンツのメタデータをマークアップしておきます。Apple のウェブクローラにインデックスさせることで Spotlight 検索や Safari の検索結果に表示されます。

Core Spotlight APIs In Batch Mode

 冒頭で紹介した店舗情報の例で店舗情報はアプリに保存されているため、Core Spotlight APIs を使用しています。以降は Core Spotlight APIs を使用したバッチインデックスの実装を紹介します。

// 1
let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeContent as String)

if let image = UIImage(named: "thumb") {
    let thumbnailData = UIImagePNGRepresentation(image)
    // 2
    attributeSet.thumbnailData = thumbnailData
}

// 3
attributeSet.title = "title (best to limit your title to 90 characters)"

// 4
attributeSet.contentDescription = "content description (best to limit your description to 300 characters)"

// 5
attributeSet.keywords = [title, contentDescription]

attributeSet.identifier = "1234"

// 6
attributeSet.contentURL = NSURL(string: @"http://sample/1234")

// 7
attributeSet.latitude = xx.xxxxxx
attributeSet.longitude = xx.xxxxxx
attributeSet.supportsNavigation = 1

// 8
attributeSet.phoneNumbers = ["xxxxxxxxxxx"]
attributeSet.supportsPhoneCall = 1

// 9
attributeSet.languages = ["ja", "en"]

// 10
let domainIdentifier = "com.core-spotlight-search-sample"
let searchableItem = CSSearchableItem(uniqueIdentifier: attributeSet.identifier, domainIdentifier: domainIdentifier, attributeSet: attributeSet)

let searchableIndex = CSSearchableIndex(name: "com.core-spotlight-search-sample-searchable-index")

// 11
searchableIndex.fetchLastClientStateWithCompletionHandler({ (clientState, error) in
    searchableIndex.beginIndexBatch()
    
    // 12
    searchableIndex.indexSearchableItems(searchableItems, completionHandler: nil)
    
    // 13
    let clientState = NSKeyedArchiver.archivedDataWithRootObject(NSDate())
    searchableIndex.endIndexBatchWithClientState(clientState, completionHandler: nil)
})
  1. 引数の itemContentType にはコンテンツの種類に応じた Uniform Type Identifier を指定します。Uniform Type Identifier については Uniform Type Identifier Overview を参照してください。
  2. コンテンツに関連したサムネイル画像を指定します。アプリのアイコン画像は避けてください。(指定しない場合は自動的にアプリのアイコン画像が表示されます)画像が正方形であれば 180 x 180 pixels を用意します。横長の画像は横 180 pixels に調整され、縦長の画像は縦 270 pixels に調整されます。詳細はこちらの Provide a thumbnail image that captures the item in a relevant and appealing way. に記載されています。
  3. タイトルはデバイスの横幅より長い場合はトランケートされます。90 文字以内に収めるといいようです。
  4. 説明も長いものはトランケートされます。300 文字以内に収めるといいようです。(タイトルは最大1行、説明は2行表示できます)
  5. 検索キーワードを指定します。コンテンツに直接関係のないものは避けてください。
  6. 対応するWebサイトがある場合はそのURLを指定します。
  7. 緯度・経度を指定し supportsNavigation を有効にすると、矢印アイコンが表示されます。タップすると地図アプリが起動します。
  8. 電話番号を設定し supportsPhoneCall を有効にすると、電話アイコンが表示されます。タップすると電話をかけることができます。(表示できるのは地図・電話のどちらか1つです)
  9. コンテンツに含まれている言語を指定します。
  10. コンテンツのドメインIDを設定します。ドメインIDを指定してまとめて削除する API も用意されています。
  11. 前回実行したバッチインデックスの状態を取得できます。ここで取得した情報をもとに、次にインデックスするものを決めることができます。
  12. アイテムをインデックスします。
  13. 引数の clientState には 250 バイトまでの情報が保存できます。今回の例では日付を保存しています。

 以上が Core spotlight APIs を使用したバッチインデックスの実装です。

Batch Index On Background Task

 このバッチインデックスはバックグランドタスクと組み合わせて使用すると、ユーザーのタスクを邪魔をせずにアプリ内のコンテンツをインデックスさせることができます。Github にサンプルプロジェクトがありますので、参考にしていただけると幸いです。

終わりに

 いかがでしたでしょうか。Spotlight 検索を実装してみると、アプリの種類やインデックスするコンテンツの内容によっては思いのほか有用な機能を作ることができるかもしれません。是非試してみてください。

参考

App Search Programming Guide

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/