［iOS 12］Siri Shortcutsの最小実装 - NSUserActivity編

先日のWWDC18で、iOS 12の新機能 "Siri Shortcuts"が発表されました。

<img width="782" alt="siri.png" src="https://qiita-image-store.s3.amazonaws.com/0/3180/755a2049-a019-bcce-3d78-00498ce03a12.png">


「よくやる手順を声で呼び出せる」というのはもちろん嬉しいことですが、何よりも、

**「ロックスクリーンから呼び出せる」**

というのは、プラットフォームの制約の中で取捨選択するしかないいちアプリ開発者にとっては圧倒的魅力に感じます。


「何がどこまでできるのか、どう実装するのか」を掴むべく、まずはその**最小実装**を確認してみました。

ちなみに参考資料はWWDC 2018のセッション番号211「Introduction to Siri Shortcuts」とサンプルコード「SoupChef」です。[^nda]

[^nda]: 本記事はNDAに配慮し、Xcode 10やiOS 12のスクショは使わず、公開情報のみで構成しています。

## 実装方法は2通り

Siri Shortcutsの実装方法としては、以下の2種類があります。

- NSUserActivityを利用する方法
- Intentsを利用する方法

本記事では、タイトル通り、`NSUserActivity`を用いたSiri Shortcutsの最小実装**だけ**を紹介します。

Appleのサンプル「SoupChef」は**両方の実装**が入っており、それはそれでありがたいのですが、初めて挑む人にはどのコードがどっち用なのか、たとえばIntentsを使わない場合はどれを省けるのかといったことがわかりづらいと思うので、そのあたりを本記事で紐解ければと。


## 3ステップ

`NSUserActivity`を用いたSiri Shortcutsの実装は、次の3つの手順で行います。

1. ショートカットを定義する
2. ショートカットを提供する（donate） [^donate]
3. ショートカットをハンドルする

「アプリの画面Bを開く」というショートカットをSiri Shortcutsを実現してみます。

[^donate]: 「機能を提供する」ということをiOSで表現する場合にはよく"provide"という動詞が使われることが多い気がします。ここでAppleがあえて「寄付する」「寄贈する」といった意味の"donate"を利用しているのはどういう気持ちがこもっているのでしょう？


## 1. ショートカットを定義する

Info.plistに次のように`NSUserActivityTypes`を定義します。

```xml
<key>NSUserActivityTypes</key>
<array>
	<string>com.myapp.name.my-activity-type</string>
</array>
```

## 2. ショートカットを提供する

`Intents`をインポートして、

```
import Intents
```

次のように`NSUserActivity`を用意します。

```swift
extension NSUserActivity {
    
    public static let myActivityType = "com.myapp.name.my-activity-type"

    public static var myActivity: NSUserActivity {
        let userActivity = NSUserActivity(activityType: myActivityType)
        
        userActivity.isEligibleForSearch = true
        userActivity.isEligibleForPrediction = true
        userActivity.title = "My First Activity"
        userActivity.suggestedInvocationPhrase = "Let's do it"
                
        return userActivity
    }
}
```

一見複雑に見えますが、Siri Shortcutsに関係するポイントとしては、

- `isEligibleForPrediction`プロパティに`true`をセットする
- `suggestedInvocationPhrase`プロパティをセットする
  - ここにセットしたフレーズがショートカットの音声コマンドをユーザーに録音してもらう画面でサジェストされる

これぐらいです。

Appleのサンプルには、次のように`CSSearchableItemAttributeSet`を作成して`contentAttributeSet`にセットする実装も入っていますが、

```swift:省略可
let attributes = CSSearchableItemAttributeSet(itemContentType: "hoge")
attributes.thumbnailData = UIImage(named: "filename")!.pngData()
attributes.keywords = ["foo", "bar"]
attributes.displayName = "My First Activity"
attributes.contentDescription = "Subtitle"
userActivity.contentAttributeSet = attributes
```

私が試したところではSiri Shortcutsを行うだけであれば**省略可能**でした[^cache]。検索窓（Spotlight）からのテキストによる検索にも対応させたい場合に必要なのではないでしょうか。

[^cache]: このへん、キャッシュが残るような挙動があり、動作検証結果に100%確信がありません。


作成した`NSUserActivity`オブジェクトを、ショートカットを提供する`UIViewControlller`の`userActivity`プロパティ（正確には`UIResponder`のプロパティ）にセットします。

```swift
userActivity = NSUserActivity.myActivity
```

ここでもAppleのサンプルでは次のように`updateUserActivityState(_)`をオーバーライドして`NSUserActivity`の`addUserInfoEntries(from:)`メソッドで`userInfo`のデータを供給する実装が入っていましたが、これもなくても動作しました。

```swift:省略可
override func updateUserActivityState(_ activity: NSUserActivity) {
    let userInfo: [String: Any] =  [NSUserActivity.ActivityKeys.menuItems: menuItems.map { $0.itemNameKey },
                                         NSUserActivity.ActivityKeys.segueId: "Soup Menu"]

    activity.addUserInfoEntries(from: userInfo)
}
```

ショートカットでアプリが起動する際に、アプリの状態を復元するために必要なデータ（`userInfo`）がある場合に実装すべきものと思われます。

#### 💡`NSUserActivity`や`CSSearchableItemAttributeSet`が初見の方へ

手前味噌ですが、「[iOS 9 の新機能のサンプルコード集](https://qiita.com/shu223/items/467dc48b9624d3195be2)」の"Search APIs"サンプルを実行してみつつコードを読んでみると非常にわかりやすいかと思います。NSUserActivity を使うものと、Core Spotlight を使うものの2種類が実装してあり、コードがシンプルなのでどのプロパティが何に対応してるのかが明確です。


## 3. ショートカットをハンドルする

ショートカットが呼び出されてアプリが起動する際、`UIApplicationDelegate`の`application(_:continue:restorationHandler:)`が呼び出されるので、そこで渡されてくるアクティビティ（`NSUserActivity`）をハンドルします。

```swift
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    
    if userActivity.activityType == NSUserActivity.myActivityType {
        // Restore state for userActivity and userInfo
        guard let window = window,
            let rootViewController = window.rootViewController as? UINavigationController,
            let vc = rootViewController.viewControllers.first as? ViewController else {
                os_log("Failed to access ViewController.")
                return false
        }
        vc.performSegue(withIdentifier: "B", sender: nil)
        return true
    }
    
    return false
}
```

ここでは`activityType`でどのアクティビティかを判定し、あとは愚直に`performSegue`で画面Bに遷移させているだけです。


## 完成品の挙動

NDA期間中のため完成品の挙動をキャプチャしてアップすることはしませんが、

- 設定から当該Siri Shortcutを登録
- ロックスクリーンから登録したフレーズで呼び出す

これでアプリが起動し、画面Bまで自動的に遷移しました。


## アプリはバックグラウンドで生きている必要があるのか？

気になったのが、`UIViewController`の`userActivity`プロパティに当該`NSUserActivity`オブジェクトをセットした点です。これってこのView Controllerがショートカットのdonatorであり、アプリが生きてないと呼び出せないのか？と。

というわけで**アプリをkillしても`NSUserActivity`ベースのSiri Shortcutは動作するか**試してみました。

**結果 → 動作しました。**

良かったです。

## 次回

- Intentsを用いる場合の最小実装
- 両者をどう使い分けるか
- Siri Shortcutsで呼び出せない機能はたとえばどんなものがある？

といったあたりについて書きます。（書けたらいいなと思っています）

## おまけ

Objective-Cのコードも書く機会があったので、残しておきます。

```objc
#import "NSUserActivity+SiriShortcuts.h"
@import Intents;

NSString *kMyActivityType = @"com.myapp.name.my-activity-type";

@implementation NSUserActivity (SiriShortcuts)

+ (NSUserActivity *)myActivity {
    if (@available(iOS 12.0, *)) {
        NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:kMyActivityType];
        userActivity.eligibleForSearch = YES;
        userActivity.eligibleForPrediction = YES;
        userActivity.title = @"My Activity";
        userActivity.suggestedInvocationPhrase = @"Let's do it";
        return userActivity;
    } else {
        return nil;
    }
}

@end
```