Одна из полезных (по моему мнению) фич iOS 12, представленных на WWDC 2018Siri Shortcuts.


Шорткат (англ. shortcut) — быстрая команда, короткий способ совершить какое-либо действие в обход стандартному сценарию.


В своих приложениях вы можете привязывать шорткаты для некоторых действий. Обучаясь на том, как и когда пользователь их выполняет, Siri начинает по-умному, в нужное время и место, предлагать ему эти шорткаты и, что самое крутое, пользователь сможет их вызывать фразами, которые сам к ним привяжет! Под катом подробнее.


Как это работает


Мы используем приложения, которые при определенных действиях, создают и доставляют шорткаты в систему.


Посмотреть эти шорткаты можно в "Настройки > Siri и поиск".

На скриншоте выше отображаются последние три шортката, которые словила система с разных приложений. Если мы нажмем на кнопку “More shortcuts”, то увидим все шорткаты, доставленные в систему каждым приложением.


При определенных настройках в коде создания шортката, Siri будет предлагать эти шорткаты пользователю на заблокированном экране, в центре уведомлений и поиске, ориентируясь на то, как часто мы пользуемся этими действиями, в какое время, по каким дням недели, где и на другие факторы.


Например, если в пятницу вечером вы обычно ищите банкоматы, то обучившись, по вечерам пятницы Siri будет предлагать вам шорткат с этим действием.



К каждому шорткату мы можем добавить свою голосовую команду, если нажмем на иконку "+".


Произносим голосовую команду, нажимаем "Done", и теперь мы можем выполнять действие, стоящее за шорткатом, с помощью голоса через Siri. Получается, что чать функциональности вашего приложения пользователь сможет выполнять через Siri, не открывая само приложение. Шорткат с фразой сохранился в "My shortcuts".


Создание шорткатов


Для разработки нам понадобятся XCode 10 и iOS 12. На момент написания статьи оба они на стадии Beta.


Шорткат можно создать либо через NSUserActivity, либо через Intent.


Первый случай:


Пользователь нажимает на шорткат, который передает команду с параметрами (NSUserActivity) нашему приложению, а оно само решает, как эту команду следует обработать (открыть окно текущего курса USD, или окно заказа нашей любимой пиццы). Это старый-добрый Spotlight shortcut, который мы все знаем, но по-умному предлагаемый Siri.


Второй случай:


Шорткаты, созданные через Intent интереснее — они позволяют выполнить команду сразу в интерфейсе Siri, не запуская вашего приложения. Раньше набор Intent'ов был жестко задан Apple: перевод денег, отправка сообщений, и прочих. Теперь же, у нас, разработчиков, появилась возможность создавать свои Intent'ы!


Независимо от того, как создавался шорткат — он проходит 3 стадии жизненного цикла:


  1. Объявление (Define)
  2. Доставка в систему (Donate)
  3. Обработка приложением (Handle)


Мое исследование показало, что одно приложение может доставить в систему не более 20 шорткатов.


Дальше рассмотрим, как одарить наше приложение способностью создавать шоркаты и как с ними работать внутри него.


Создание шорткатов через NSUserActivity


Разберем первый, простой тип шорткатов, которые открываются через NSUserActivity.


Например, в приложение мобильного банка у нас есть экран поиска банкоматов и я часто их ищу. Для того чтобы попасть на экран с картой банкоматов, мне приходится запустить приложение, перейти на таб "Еще" в таббаре, выбрать раздел "Инфо" и уже там нажать на кнопку "Банкоматы".
Если мы создадим шорткат, который сразу ведет на этот экран — пользователь сможет попасть в него в одно касание, когда Siri предложит ему его, например, на заблокированном экране.


Объявляем шорткат (Declare)


Первым шагом будет объявление типа нашей NSUserActivity (можно сказать, что это ее идентификатор) в info.playlist:


<key>NSUserActivityTypes</key>
<array>
    <string>ru.tinkoff.demo.show-cashMachine</string>
</array>

Объявили.


Доставляем шорткат в систему (Donate)


После объявления мы можем создать NSUserActivity в коде нашего приложения с типом, который мы задали выше в info.playlist:


let activity = NSUserActivity(activityType: "ru.tinkoff.demo.show-cashMachine")

Чтобы активити попала в список шорткатов системы, ей необходимо задать title, и выставить свойство isEligibleForSearch в true. Другие свойства не являются необходимыми для добавления в шорткаты, но их присутствие делает шорткат более читаемым и дружелюбным для пользователя.


 // Заголовок шортката (Необходим чтобы доставить активити в систему, чтобы создался шорткат)
activity.title = "Найти банкоматы"

if #available(iOS 12.0, *) {
    // Фраза, которая будет предложена пользователю когда он захочет приязать голосовую команду к шорткату
    activity.suggestedInvocationPhrase = "Покажи банкоматы Тинькофф"

    // Сири будет обучаться и предлагать шорткат на базе этой активити
    activity.isEligibleForPrediction = true

    // (Необходим чтобы доставить активити в систему, чтобы создался шорткат)
    activity.isEligibleForSearch = true
}

// Атрибуты отвечают за настройку отображения шортката
let attributes = CSSearchableItemAttributeSet(itemContentType: "NSUserActivity.searchableItemContentType")

/// Иконка для шортката
if let image = UIImage(named: "siriAtmIcon") {
    attributes.thumbnailData = UIImagePNGRepresentation(image)
}

/// Описание шортката
attributes.contentDescription = "Открыть карту с ближайшими банкоматами Тинькофф"

/// Выставляем атрибуты на активити
activity.contentAttributeSet = attributes

Огонь! NSUserActivity есть, чтобы доставить ее в систему, осталось сделать последний шаг.


У ViewConroller'а есть свойство userActivity, которому нам надо присвоить созданную выше activity:


self.userActivity = activity

Как только эта строка выполнится, из этой активити создастся шорткат. Он доставится в систему и отобразится в настройках Siri (Настройки > Siri и поиск). После чего Siri сможет предлагать его пользователю, а пользователь сможет назначить ему свою голосовую команду.


Примечание: в документации Apple сказано, что вместо присваивания активити вьюконтроллеру, достаточно вызвать у активити метод becomeCurrent(). Однако у меня это действие не доставило активити в систему и шорткат не появился в списке


Next, call the becomeCurrent() method on the user activity object to mark it as current, which donates the activity to Siri. Alternatively, you can attach the object to a UIViewController or UIResponder object, which also marks the activity as current.

Чтобы проверить, что все сработало, открываем Настройки > Siri и поиск — шорткат на базе нашей активити должен быть в списке.


Обработка шортката приложением (Handle)


Когда пользователь перешел по шорткату из центра уведомлений или активировал его голосом, запускается приложение, и мы должны обработать этот шорткат.


activity пробросится нам в методе AppDelegate'a:


func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    if userActivity.activityType == "ru.tinkoff.demo.show-cashMachine" {
        // Обрабатываем событие так, как нам это надо
        handleShowCashMachineActivity()
        return true
    }
    return false
}

Итого


Шорткат на базе NSUserActivity создается следующим образом:


  1. Объявляем тип(идентификатор) NSUserActivity в info.plist.
  2. Создаем NSUserActivity в коде и настраиваем
  3. Назначаем активити viewController'у.

Создание голосовых команд из приложения


Итак, если пользователь откроет Настройки > Siri и поиск, то увидит список своих шорткатов, который создавался различиными приложениями, и нашим в том числе. Нажав на "+", пользователь сможет создать любую голосовую команду и связать ее с выбранным шорткатом. Однако каждый раз заходить в настройки неудобно для пользователя, многие даже не догадываются о такой возможности.


Круто, что можно привязать голосовую команду к определенному действию прям внутри приложения.


Допустим, пользователь совершил какое-то действие, оно доставилось в систему, он хочет его сохранить. Мы можем добавить кнопку "добавить действие в Siri"(назвать и нарисовать кнопку можете как угодно) на экран нашего приложения, тогда пользователь, нажав на нее, сможет связать это действие с голосовой командой изнутри приложения, не заходя в настройки.


По нажатию на кнопку следует модально открыть экран добавления голосовой команды шорткату в Siri INUIAddVoiceShortcutViewController, или экран редактирования голосовой команды INUIEditVoiceShortcutViewController, если такой уже создан. Незарефакторенный action такой кнопки будет примерно следующим:


    @IBAction func addToSiriAction() {
        // 1. Смотрим на шорткаты, которые пользователь уже сохранил с фразами
        INVoiceShortcutCenter.shared.getAllVoiceShortcuts { (shortcuts, error) in
            guard error == nil, let shortcuts = shortcuts else {
                // TODO: Handle error
                return
            }

            // 2. Среди этих шорткатов ищем тот, который мы собираемся добавить сейчас
            let donatedShortcut: INVoiceShortcut? = shortcuts.first(where: { (shorcut) -> Bool in
                return shorcut.__shortcut.userActivity?.activityType == "com.ba"
            })

            if let shortcut = donatedShortcut {
                // 3. Если такой шорткат найден - открываем системный экран редактирования шортката.
                // На нем пользователь сможет обновить фразу голосовой команды
                let editVoiceShortcutViewController = INUIEditVoiceShortcutViewController(voiceShortcut: shortcut)
                editVoiceShortcutViewController.delegate = self
                self.present(editVoiceShortcutViewController, animated: true, completion: nil)
            } else {
                // 4. Открываем системный экран добавления голосовой фразы к шорткату
                let shortcut = INShortcut(userActivity: self.userActivity!)
                let addVoiceShortcutViewController = INUIAddVoiceShortcutViewController(shortcut: shortcut)
                addVoiceShortcutViewController.delegate = self
            }
        }
    }

Так, выглядят экраны добавления и редактирования голосовой команды для шортката Siri:



Также мы должны реализовать делегатные методы этих viewController'ов, в которых их нужно спрятать dismiss(animated: true, completion: nil) и, если необходимо, обновить текущий экран. Например, если раньше на экране была кнопка "добавить голосовую команду", то после добавления голосовой команды эта кнопка должна либо исчезнуть либо измениться на "редактировать голосовую команду".


Шорткаты, созданные при помощи Intent


До сих пор мы говорили только про шорткаты, которые открывают приложение, и передают туда опредленные данные в NSUserActivity.


Но вернемся к шорткатам, созданным через Intent, которые позволяют совершить некоторые действия не открывая приложение. Тут начинается самое интересное.


Представим, пользователь заказывает свою любимую пиццу. Он будет заказывать ее много раз, когда захочет, и он даже добавил голосовую команду на шорткат этой пиццы — и это упрощает его жизнь. Но мы можем сделать для него больше — мы можем сделать так, чтобы подав голосовую команду Siri, система не кидала его в приложение, а показала информацию о заказе и заказала пиццу сразу в интерфейсе Siri! Это как раз тот случай, когда пользователю не понадобится открывать само приложение, чтобы совершить какое-то действие.


Сначала заходим в настройки проекта, выбираем главный таргет, вкладку Capabilities и включаем доступ к Siri.


Наше приложение может взаимодействовать c Siri, но происходит это не в основном коде приложения, а в отдельном таргете-расширении Intents Extensions


Для начала этот таргет необходимо создать: File > New > Target, выбираем Intents Extensions. XCode предложит создать еще target-расширение для окошка отображения вашего действия в Siri, если есть в этом потребность, то соглашаемся.



Объявляем шорткат (Declare)


Основное нововведение SiriKit'a в iOS 12 — возможность создавать свои Inetnts, к тем, которые уже были раньше.



Для этого нужно создать новый файл: File > New > File, выбрав из секции Resource тип SiriKit Intent Definition File.



В итоге, появится файл с расширением .intentdefinition, в котором можно создавать свои Intents. Открываем файл, и там где написано "No Intents" внизу есть иконка "+" — нажимаем на нее. "New Intent". В списке появится интент, которому можно добавить параметры. В случае действия с заказом пиццы, в качестве параметров можно добавить количество пицц, и вид пиццы для заказа. Для количества выберем тип Integer, а для вида пиццы выберем тип Custom, что в коде будет представлено классом INObject.


Теперь пару строк фрустрации:


Пользователь не сможет передавать одной и той же сохраненной голосовой команде разные параметры. Увы!



Для чего параметры:


Допустим, вы создаете сущность "Покажи курс %currency", где currency — это параметр сущности. Это не значит, что пользователь может говорить фразы "Покажи курс доллара", "Покажи курс биткойна" и т.д. Из коробки это не будет работать так. Но это означает, что если пользователь просмотрел курс доллара — то создался шорткат "Покажи курс USD", потом, когда он просмотри курс биткойна, создается шорткат "Покажи курс BTC" и т.д. Иными словами у него может быть несколько шоркатов, которые базируются на одной и той же intent, но с разными параметрами. Каждому из шорткатов пользователь сможет задать свою голосовую команду.


Хорошо, создав интент в файле .intentdefinition, XCode автоматически сгенерирует класс для этого интента (прим.: он не отобразится в файлах проекта, но будет доступен для использования) Этот автосгенерированный файл будет только в тех таргетах, которым принадлежит .intentdefinition файл.


После создания интента в файле .intentdefinition мы можем создавать наши интенты в коде.


let intent = OrderPizzaIntent()

Доставляем шорткат в систему (Donate)


Для того, что бы эта сущность попала в список шорткатов — нужно ее задонатить. Для этого создается INInteraction объект с экземпляром вашего интента, и у этого интеракшна вызывается метод .donate


let intent = OrderPizzaIntentf()
// ... Настройка сущности

let interaction = INInteraction(intent: intent, response: nil)

interaction.donate { (error) in
    // ... Обработка ошибки и/или успеха
}

После выполнения этого кода шорткат на базе интента доставится в систему и отобразится в Настройках Siri.


Обрабатываем шорткат приложением (Handle)


Следующий шаг — это обработать интент, когда пользователь нажмет на него в садджестах сири или вызовет его голосовой командой.


Мы уже создали таргет-расширение для Siri и в нем есть предсозданный класс IntentHandler, у которого есть один единственный метод — `handle(for intent)``


 class IntentHandler: INExtension {
    override func handler(for intent: INIntent) -> Any {
        guard intent is OrderPizzaIntent else {
            fatalError("Unhandled intent type: \(intent)")
        }
        return OrderPizzaIntentHandler()
    }
}

Прим.: Если компилятор не видит класс вашего интента, значит вы не добавили файл .intentdefinition таргет-расширение для Siri.

В этом методе мы определяем тип входящего интента и для каждого типа создаем объект-обработчик, который будет обрабатывать этот интент. Создадим обработчик для нашей OrderPizzaIntent, и реализуем в нем протокол OrderPizzaIntentHandling, который уже автосгенерирован после создания вашей Intent в .intentdefinition.


Протокол содержит два метода confirm и handle. Сначала вызывается confirm где происходит проверка всех данных и проверяется доступность выполнения действия. Затем сработает handle в коротом действие надо выполнить.


public class OrderPizzaIntentHandler: NSObject, OrderPizzaIntentHandling {

    public func confirm(intent: OrderPizzaIntent, completion: @escaping (OrderPizzaIntentResponse) -> Void) {
        // Различные проверки на доступность действия
        // ...
        completion(OrderPizzaIntentResponse(code: OrderPizzaIntentResponseCode.ready, userActivity: nil))
    }

    public func handle(intent: OrderPizzaIntent, completion: @escaping (OrderPizzaIntentResponse) -> Void) {
        // Код с выполнением действия 
        // ...
        completion(OrderPizzaIntentResponse(code: OrderPizzaIntentResponseCode.success, userActivity: nil))
    }
}

Оба этих метода обязательно должны вызвать completion c ответом OrderPizzaIntentResponse(он тоже автосгенерирован), иначе Siri просто будет долго ждать после чего выдаст ошибку.


Более подробные ответы от Siri


Есть стандартный, автосгенерированный набор кодов ответа — enum OrderPizzaIntentResponseCode, но для дружелюбного интерфейса их может оказаться недостаточно. Например на этапе confirm может возникнуть несколько разных ошибок — кончилась пицца, пиццерия не работает в это время и т.д. и пользователю следует узнать о данных фактах, вместо стандартного сообщения "Ошибка в приложении". Помните мы создали Intent в файле .intentdefinition? Вместе с самим интентом создался еще и его Response в котором можно добавить свои варианты ошибок и успешных ответов, и настроить их параметрами:



Теперь мы можем сообщать пользователю более информативные ошибки и ответы:


public func confirm(intent: OrderPizzaIntent, completion: @escaping (OrderPizzaIntentResponse) -> Void) {

    guard let pizzaKindId = intent.kind?.identifier else {
        // Если вдруг мы неверно настроили интент для шортката - показываем ошибку по умолчанию
        completion(OrderPizzaIntentResponse(code: .failure, userActivity: nil))
        return
    }

    if pizzeriaManager.isPizzeriaClosed == true {
        /// Если пиццерия уже закрыта - говорим об этом пользователю
        completion(OrderPizzaIntentResponse(code: .failurePizzeriaClosed, userActivity: nil))
        return
    } else if pizzeriaManager.menu.isPizzaUnavailable(identifier: pizzaKindId) {
        /// Если пиццы нет в наличии - говорим об этом пользователю
        completion(OrderPizzaIntentResponse(code: .failurePizzaUnavailable(kind: intent.kind), userActivity: nil))
        return
    }

    // Раз не возникло ошибок - можем подтвердить действие
    completion(OrderPizzaIntentResponse(code: .ready, userActivity: nil))
}

Отрисовка Intent


Если мы создали таргет-расширение Intent Extension UI то мы можем нарисовать кастомную вьюшку в Siri для нужных нам интентов. У нас есть MainInterface.storyboard и IntentViewController, в которых мы можем набросать их дизайн. Этот вью контроллер реалзиует протокол INUIHostedViewControlling и конфигурация вьюшки происходит в методе configureView


// Prepare your view controller for the interaction to handle.
    func configureView(for parameters: Set<INParameter>,
                       of interaction: INInteraction,
                       interactiveBehavior: INUIInteractiveBehavior,
                       context: INUIHostedViewContext,
                       completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void) {

        // Do configuration here, including preparing views and calculating a desired size for presentation.
        completion(true, parameters, self.desiredSize)
    }

    var desiredSize: CGSize {
        return self.extensionContext!.hostedViewMaximumAllowedSize
    }

Что бы этот метод вызвался, необходимо в info.plist, который относится к таргету-расширению Intents UI, добавить название нашего интента в массив NSExtension->NSExtensionAttributes->IntentsSupported


<key>NSExtension</key>
    <dict>
        <key>NSExtensionAttributes</key>
        <dict>
            <key>IntentsSupported</key>
            <array>
                <string>OrderPizzaIntent</string>
            </array>
        </dict>

В зависимости от дизайна вашей вьюшки в Siri и от interaction.intent'а, который попал в метод, вы можете нарисовать эту вьюшку так, как хотите. Ниже скришоты, как выглядит наш интент в Siri, в поиске и на заблокированном экране.



Стоит учесть, что пользователь не сможет взаимодействовать с кнопками, скроллингом и другими контролами на вашей вьюшке, поскольку метод вызовется с параметром interactiveBehavior = .none, это безусловно накладывает ряд ограничений.


Итого


Шорткат на базе Intent может отрисовать в интерфейсе сири или в центре уведомлений и выполнить действие не открывая приложение. Чтобы его создать нужно:


  1. Включаем в Capabilities возможность использовать Siri
  2. Создать таргеты-расширения Intents Extensions и Intents Extensions UI
  3. Создаем SiriKit Intent Definition File
  4. Создаем свой Intent в этом файле и прописываем ему параметры.
  5. Создаем IntentHandler в котором реализуем методы confirm и hanlde

Рекомендации


Общий код в таргете-расширении Siri и в основном приложении


Если у вас есть код, который используется и в таргете для Siri и в таргете основного проекта — есть 2 способа решить этот вопрос:


  1. Выделить общие классы добавить их к обоим таргетам. ( View > Utilites > Show File Inspector'e, в секции Target Membership добавить галочки к таргетам, которым нужен доступ к выделенному файлу )
  2. Создать один или несколько таргетов-framework'ов и унести общий код туда.

Предпочтительнее последний способ, потому что эти фреймворки вы потом сможете использовать в других расширениях и проектах. Также стоит отметить, что у этих фреймворков желательно выставить флаг Allow app extension API only, тогда, разрабатывая фреймворк, компилятор будет ругаться, если вы попытаетесь использовать API недопустимое в разработке расширений (например UIApplication).


Общие ресурсы можно шарить между таргетами через App Groups


Отладка


Тестировать шорткаты помогут помочь:


  1. Настройки телефона Настройки > Developer: переключатели Display Recent Shortcuts и Display Donations on Lock Screen:


  1. Для тестирования Intens можно сразу запускать таргет-расширение, задав в XCode'е фразу, с которой откроется Siri. Для этого нужно выбрать схему для таргета-расширение Siri


Нажать на этот таргет, нажать Edit Scheme...



В поле Siri Intent Query ввести фразу, с которой Siri уже запустится, как будто вы уже сказали ее.


Итого


Предлагаю остановиться и резюмировать, что у нас получилось:


  1. Шорткаты можно создать через NSUserActivity, или через INIntent
  2. Шорткаты нужно объявить(declare), сообщить системе(donate), и обработать(handle).
  3. В приложение можно добавить кнопку "Add to Siri", нажав на которую пользователь сможет добавить фразу для действия и в дальнейшем вызывать его голосом.
  4. Можно создавать свои Intents в добавок ко встроенным.
  5. Через шорткаты на базе Intents можно создавать действия, которые будут выполняться через интерфейс Siri (либо на заблокированном экране или в поиске) без потребности открывать само приложение.

В документации Apple есть ссылка на Demo проект, который полезно скачать и ориентироваться на него при разработке.


Хотелось бы подчеркнуть, что на момент написания статьи это API на стадии beta. И я часто ловлю проблемы и баги. Во время работы мне периодически попадались следующие:


  • Голосовые команды, открывающие Intent в Siri, не открываются.
  • Просмотр предложений Siri не работает с экрана блокировки.
  • Проблема с асинхронными операциями в таргетах для Siri.

Ссылки


  1. WWDC 2018, session 211: Introduction to Siri Shortcuts
  2. WWDC 2018, session 214: Building for Voice with Siri Shortcuts
  3. Apple Developer: SiriKit
  4. Apple Developer: INUIHostedViewControlling
  5. Demo проект Soup Chef от Apple

Комментарии (2)


  1. BigD
    22.06.2018 12:09

    А что за biilt-in VoIP calling intent?


    1. Argas Автор
      22.06.2018 13:40

      Возможность совершать звонки и просматривать историю звонков пользователя через Siri, если верить доке.