[iOS 12]Siri Shortcutsの最小実装 - NSUserActivity編


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

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

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

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

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

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

実装方法は2通り

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

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

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

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

3ステップ

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

  1. ショートカットを定義する
  2. ショートカットを提供する(donate) 2
  3. ショートカットをハンドルする

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

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

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

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

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

Intentsをインポートして、

import Intents

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

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にセットする実装も入っていますが、

省略可
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を行うだけであれば省略可能でした3。検索窓(Spotlight)からのテキストによる検索にも対応させたい場合に必要なのではないでしょうか。

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

userActivity = NSUserActivity.myActivity

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

省略可
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)がある場合に実装すべきものと思われます。

💡NSUserActivityCSSearchableItemAttributeSetが初見の方へ

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

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

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

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まで自動的に遷移しました。

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

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

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

結果 → 動作しました。

良かったです。

次回

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

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

おまけ

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

#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

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

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

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