Siri快捷指令

1,437 阅读7分钟

前言

关于Siri Shortcuts集成, 网络上资料大多比较有年代感或者杂乱, 接下来做个大致梳理.

根据业务需求不同, 实现方式可以分为在App内执行(需要启动App)和App外部执行(不需要启动App).

同样的, 与之对应的也就有两种方案, NSUserActivity+intentintent.

注意: 适用iOS 12.0及以上版本

(一). Intents扩展文件配置

  1. TARGETS中创建一个Intent扩展程序

  1. 选择激活, 然后在宿主App中生成下面文件

  1. 接着在扩展程序文件夹下创建一个Siri意图文件

  2. 然后根据需要配置项目Siri的意图

(二). NSUserActivity+intent方式

按方案分析, Siri响应后在App内处理事件; 这种场景的创建也在快捷指令内部添加, 像支付宝的扫一扫

  1. 首先创建一个依赖App启动响应的自定义意图.

  1. 当新的意图创建后, 系统会默认在宿主App中的info.plist文件生成相关配置.

  1. 同时也会生成一个 HBEventIntent.swift 文件, 此文件主要包括下面四个东西.
public class HBEventIntentINIntent {}
public protocol HBEventIntentHandlingNSObjectProtocol {}
@objc public enum HBEventIntentResponseCodeInt {}
public class HBEventIntentResponseINIntentResponse {}
  1. 接着我们挨着IntentHandler.swift创建自定义意图的处理类HBEventHandler.swift.
// HBEventHandler.swift
import Intents
import IntentsUI
​
// MARK: - main class
class HBEventHandlerINExtensionHBEventIntentHandling {
   func handle(intentHBEventIntentcompletion@escaping (HBEventIntentResponse) ->Void) {
       let userActivity = NSUserActivity(activityType:NSStringFromClass(HBEventIntent.self))
       let response = HBEventIntentResponse.init(code: .success, userActivity:userActivity)
       completion(response)
   }
}
  1. 当然, IntentHandler.swift入口处代码也必须补充.
// IntentHandler.swift
import Intents
class IntentHandlerINExtension {
   // 这是默认实现。 如果您希望不同的对象处理不同的意图,
   // 你可以覆盖它并返回你想要的特定意图的处理程序。
   // 是整个 Intents Extension 的入口,当 siri 通过语音指令匹配到对于的Intent,该方法就会被执行。
   override func handler(for intentINIntent) -> Any {
       // 这里我 return 我创建一个 HBEventIntent 类,该类准守 INExtension, HBEventIntentHandling协议。
       // 用来处理匹配到 Intent 后的 UI 显示以及后续操作
       if intent is HBEventIntent {
           return HBEventHandler()
       }
       return self
   }
}
  1. 另外的, 对于此应用内执行方式; 那么接下来就可以在AppDelegate中, 添加自定义的相关业务.
// MARK: - Intents
extension AppDelegate {
   /// 处理外部意图
   // func application(_ application: UIApplication, didFailToContinueUserActivityWithType userActivityType: String, error: Error) {}
   func application(_ applicationUIApplicationwillContinueUserActivityWithTypeuserActivityTypeString) -> Bool {
       /// 捷径, 打开App跳转到需要的业务控制器页
       if userActivityType == NSStringFromClass(HBEventIntent.self) {
           print("HBEventIntent---do something")
           StackTopViewController()?.navigationController?.pushViewController(BlueToothController(),animated: true)
       }
       return true
   }
}
  1. 下面即是生成的Siri建议样式, 快捷指令添加如同前面支付宝扫一扫一致.

  1. 这里有个问题先说明一下, 似乎必须要付费账号才可以在项目接入Siri, 即Signing &Capablilties中添加Siri, 这样就可以通过"嗨, Siri " 去执行快捷指令. 否则仅能点击快捷触发.

(三). custom intent方式

intent方式常用场景大体都是和宿主内相关业务做一些数据绑定, 然后拿到外部可以独立执行处理事务. 类似的如米家的智能场景绑定执行

  1. 由于外部执行依赖宿主App 的相关业务数据, 这务必会产生宿主APP和Extension的数据共享问题, 这点后面一段再说. 因业务依赖较重, 接下来做大致介绍.

  1. 与内部执行方式相同的, 也需要添加处理类. 这里添加 HBSiriHandler,
// HBSiriHandler.swift
import Intents
​
// MARK: - main class
class HBSiriHandlerINExtensionHBSiriIntentHandling {
   func handle(intentHBSiriIntentcompletion@escaping (HBSiriIntentResponse) ->Void) {
       self.exeuteFast(intent.sid) { response in
           completion(response)
       }
   }
   // func handle(intent: HBSiriIntent) async -> HBSiriIntentResponse {}
}
​
// MARK: - private mothods
extension HBSiriHandler {
   
   func exeuteFast(_ sidString?, completeHandle@escaping ((_ response:HBSiriIntentResponse) -> Void)) {
       // 此处为业务逻辑代码, 根据业务需求而定
       let errorCode = "0"
       if errorCode == "0" {
           let rsp = self.setUpSiriIntentResponse(title: "场景执行成功!", code: .success)
           completeHandle(rsp)
       } else { // "{"errorCode":"701","errorMsg":"该情景不存在","transNo":"0df0b55de76dbd00"}"
           let rsp = self.setUpSiriIntentResponse(title: "执行失败: #errorMsg#", code:.failure)
           completeHandle(rsp)
       }
   }
   
   /// 构建返回意图响应
   /// - Parameters:
   ///   - title: 标题
   ///   - code: 状态码
   /// - Returns: response
   func setUpSiriIntentResponse(titleString?, codeHBSiriIntentResponseCode) ->HBSiriIntentResponse {
       let response = HBSiriIntentResponse(code: code, userActivity: nil)
       response.title = title
       return response
   }
}
  1. 同样的入口方法handler(for :) 内补充
if intent is HBSiriIntent {
    return HBSiriIntent()
}
  1. 关于指令与业务的绑定, 在宿主App必然会有相关的指令获取, 添加以及更新和删除操作, 这里提供一个自己封装的工具类.
// VoiceShortcutManager.swift
import IntentsUI
​
// MARK: - main class
class VoiceShortcutManagerNSObject {
   typealias T = INIntent
​
   // 操作方式
   enum IntentAction {
       case add, edit, delete, cancel
   }
​
   static let shared = VoiceShortcutManager()
   // 指令更新回调
   var shortcutsUpdateBlock: ((_ iAction: IntentAction_ voiceShortcut:INVoiceShortcut?) -> Void)?
   // 匹配的全量指令
   var allShortcuts: [INVoiceShortcut= []
}
​
// MARK: - private mothods
extension VoiceShortcutManager {
   
   /// 获取所有匹配意图的快捷指令
   /// - Parameters:
   ///   - targetIntent: 指定匹配意图
   ///   - completeHandle: 异步回调快捷指令数组
   func getAllVoiceShortcuts<T>(targetIntentT.TypecompleteHandle@escaping (_shortcuts: [INVoiceShortcut]?_ error: Error?) -> Void) {
       INVoiceShortcutCenter.shared.getAllVoiceShortcuts {[weak self] voiceShortcuts,error in
           guard let self = self, error == nil else {
               completeHandle(nil, error)
               return
           }
           self.allShortcuts.removeAll()
           DispatchQueue.main.async {
               if let voiceShortcuts = voiceShortcuts {
                   for shortcut in voiceShortcuts where shortcut.shortcut.intent is T {
                       self.allShortcuts.append(shortcut)
                   }
               }
               completeHandle(self.allShortcuts, nil)
           }
       }
   }
​
   /// 添加指令
   /// - Parameter shortcut: 构建的快捷指令数据
   func addShortcut(_ shortcutINShortcut) {
       let addShortcutVc = INUIAddVoiceShortcutViewController(shortcut: shortcut)
       addShortcutVc.delegate = self
       addShortcutVc.modalPresentationStyle = .fullScreen
       StackTopViewController()?.present(addShortcutVc, animated: true)
   }
   
   /// 编辑指令
   /// - Parameter vShortcut: 选中的快捷指令
   func editShortcut(_ vShortcutINVoiceShortcut) {
       let editShortcutVc = INUIEditVoiceShortcutViewController.init(voiceShortcut:vShortcut)
       editShortcutVc.delegate = self
       editShortcutVc.modalPresentationStyle = .fullScreen
       StackTopViewController()?.present(editShortcutVc, animated: true)
   }
}
​
// MARK: INUIAddVoiceShortcutViewControllerDelegate
extension VoiceShortcutManagerINUIAddVoiceShortcutViewControllerDelegate,INUIEditVoiceShortcutViewControllerDelegate {
   
   // MARK: INUIAddVoiceShortcutViewControllerDelegate
   /// 增加
   func addVoiceShortcutViewController(_ controllerINUIAddVoiceShortcutViewController,didFinishWith voiceShortcutINVoiceShortcut?, errorError?) {
       if let tVoiceShortcut = voiceShortcut, let phrase =voiceShortcut?.invocationPhrase {
           print("Add=>voiceShortcut:(phrase)")
           allShortcuts.append(tVoiceShortcut)
       }
       controller.dismiss(animated: true) {
           self.shortcutsUpdateBlock?(.add, voiceShortcut)
       }
   }
   
   func addVoiceShortcutViewControllerDidCancel(_ controller:INUIAddVoiceShortcutViewController) {
       controller.dismiss(animated: true) {
           self.shortcutsUpdateBlock?(.cancel, nil)
       }
   }
   
   // MARK: INUIEditVoiceShortcutViewControllerDelegate
   /// 更新
   func editVoiceShortcutViewController(_ controller:INUIEditVoiceShortcutViewControllerdidUpdate voiceShortcutINVoiceShortcut?, error:Error?) {
       if let tVoiceShortcut = voiceShortcut, let phrase =voiceShortcut?.invocationPhrase {
           print("Edit=>voiceShortcut:(phrase)")
           for (offset, element) in allShortcuts.enumerated() where element.identifier== tVoiceShortcut.identifier {
               allShortcuts[offset] = tVoiceShortcut
               break
           }
       }
       controller.dismiss(animated: true) {
           self.shortcutsUpdateBlock?(.edit, voiceShortcut)
       }
   }
   
   /// 删除
   func editVoiceShortcutViewController(_ controller:INUIEditVoiceShortcutViewControllerdidDeleteVoiceShortcutWithIdentifierdeletedVoiceShortcutIdentifierUUID) {
       print("Delete=>identifier:(deletedVoiceShortcutIdentifier)")
       allShortcuts = allShortcuts.filter({ $0.identifier !=deletedVoiceShortcutIdentifier })
       controller.dismiss(animated: true) {
           self.shortcutsUpdateBlock?(.delete, nil)
       }
   }
   
   func editVoiceShortcutViewControllerDidCancel(_ controller:INUIEditVoiceShortcutViewController) {
       controller.dismiss(animated: true) {
           self.shortcutsUpdateBlock?(.cancel, nil)
       }
   }
}
​
  1. 使用场景大致可以如下
   /// 若使用PromiseKit, 获取指定intens方式可以如下,
   func allShortcutIntents() -> Promise<[HBSiriIntent]> {
       return Promise<[HBSiriIntent]>.init { resolver in
           VoiceShortcutManager.shared.getAllVoiceShortcuts(targetIntent:HBSiriIntent.self) { shorcut, error in
               if let error = error {
                   resolver.reject(error)
                   return
               }
               if let siriIntents = shorcut?.compactMap({ $0.shortcut.intent as?HBSiriIntent }) {
                   resolver.fulfill(siriIntents)
               }
           }
       }
   }
   
   /// 构建快捷指令, 
   func setUpShortcut(_ modelIntentModel) -> INShortcut? {
       let siriIntent = HBSiriIntent()
       siriIntent.title = "执行场景" (model.sceneName ?? "")""
       siriIntent.sid = "(model.sceneId ?? -1)"
       siriIntent.suggestedInvocationPhrase = "" // 此处为对Siri说的建议短语, 我们建议为空, 最后由添加编辑时给定
       return INShortcut(intent: siriIntent)
   }
​
   /// 更新快捷指令与业务的绑定关系
   func updateSiriDatas() {
       let vShortcuts = VoiceShortcutManager.shared.allShortcuts
       guard vShortcuts.count > 0 else {
           self.listView.reloadData()
           return
       }
       // 指令短语是唯一的
       // 建立 [id: 短语] 对应关系
       var intentMaps: [StringString= [:]
       vShortcuts.forEach { vShortcut in
           if let intent = vShortcut.shortcut.intent as? HBSiriIntentlet sid =intent.sid {
               intentMaps[sid] = vShortcut.invocationPhrase
           }
       }
       let sids = intentMaps.keys.compactMap({ Int($0) })
       // 遍历业务模型数组, 更新与快捷指令的关系(包括是否有绑定, 短语是什么,, 这个取决于业务内容)
       self.fastModels.forEach { fModel in
           if let sid = fModel.sceneId {
               let isContains = sids.contains(sid)
               fModel.isIntent = isContains
               fModel.phrase = nil
               if let phrase = intentMaps.value(forKey: "(sid)"as? String, isContains{
                   fModel.phrase = phrase
               }
           }
       }
       self.listView.reloadData()
   }
​
  // callBack, 根据操作类型, 决定是否要去更新数据
  // 若是修改 短语, 以场景id去匹配调整
  VoiceShortcutManager.shared.shortcutsUpdateBlock = {[weak self] (iAction, _in
      if iAction != .cancel {
          self?.updateSiriDatas()
      }
  }
​
  1. 关于如何默认Siri短语; 打开Extension的 Edit Scheme -> Run -> Siri intent Query, 输入短语即可

  1. 关于如何Debug调试Extension部分

在前面说的intent中勾选了User confirmation required, 这个时候当我们点击快捷指令时, 会有弹框提示是否需要运行; 这个时候先不用点执行. 这个时候, 先在Extension中设置断点, 然后进入Xcode -> Debug -> Attach to Process 选择与宿主App同级下的intent即可

  1. 另外在额外补充另一种自定义意图的方法

如图所示, 这种捐赠意图的方式

// 添加时 传入一个自定义意图, 然后进行捐赠, 结果可以接收回调
func addDonateShortcut(intent: INIntent, completion: ((_ error: Error?) -> Void )? = nil) {
    let interaction = INInteraction(intent: intent, response: nil)
    interaction.donate(completion: completion)
}
// 删除方式 有多种, 可以全部删除, 或者按照UUID单个删除, 或是删除UUID数组
INInteraction.deleteAll()
INInteraction.delete(with: <#T##[String]#>, completion: <#T##((Error?) -> Void)?##((Error?) -> Void)?##(Error?) -> Void#>)
// 使用方式示例
let intent = HBDonateIntent()
intent.title = "捐赠快捷指令"
intent.sid = "12345"
intent.suggestedInvocationPhrase = "测试捐赠指令"
VoiceShortcutManager.shared.addDonateShortcut(intent: intent) { error in
    if let err = error {
        print(err.localizedDescription)
    }
}

注意 此种方式添加后, 在快捷指令 类别及App分栏 内部都可以找到捐赠的意图. 同时需要关注一下入口以及自建处理类.

(四). 宿主APP和Extension的数据共享

这里介绍下App Groups.

比如 宿主App添加了一个App Groups, 设定为 group.com.hb.hbswiftkit.hbintent, 那么与之共享数据的Extension也必须勾选此App Groups

使用方式 UserDefaults(suiteName:) 如下.

// MARK: 项目内标识信息
enum ApplicationKeys {
    /// 意图数据共享标识
    case intentGroup
    case intentKey
    /// 是否开启快捷指令
    case intentEnable
    
    var identity: String {
        switch self {
        case .intentGroup:
            return "group.com.hb.hbswiftkit.hbintent"
        case .intentKey:
            return "shared_accessToken"
        case .intentEnable:
            return "intentEnable"
        }
    }
}

/// 添加共享数据
let userDefault = UserDefaults.init(suiteName: ApplicationKeys.intentGroup.identity)
userDefault?.set(accessToken, forKey: ApplicationKeys.intentKey.identity)

// 取出共享数据
if let accessToken = UserDefaults(suiteName: ApplicationKeys.intentGroup.identity)?.value(forKey: ApplicationKeys.intentKey.identity) as? String {
    request.addValue(accessToken, forHTTPHeaderField: "accessToken")
}

(五). 带Extension的应用CocoaPods如何配置

  1. 如果是宿主App和Extension为同平台下共享部分三方库
platform :ios,'10.0'
use_frameworks!

def shared_pods
  pod 'Moya', '~> 15.0.0'
  pod 'ObjectMapper', '~> 4.2.0'
  pod 'Kingfisher', '~> 6.3.1'
  pod 'PromiseKit'
end

target 'App' do
  shared_pods
  pod 'RxSwift', '~> 6.2.0'
  pod 'SnapKit', '~> 5.0.1'
  pod 'SwiftLint', :configurations => ['Debug']
end

target 'Extension' do
	shared_pods
end
  1. 如果是不同平台下共享部分三方库
use_frameworks!

def shared_pods
  pod 'Moya', '~> 15.0.0'
  pod 'ObjectMapper', '~> 4.2.0'
  pod 'Kingfisher', '~> 6.3.1'
  pod 'PromiseKit'
end

target 'App' do
  platform :ios,'10.0'
  shared_pods
  pod 'RxSwift', '~> 6.2.0'
  pod 'SnapKit', '~> 5.0.1'
  pod 'SwiftLint', :configurations => ['Debug']
end

target 'WatchKit Extension' do
  platform :watchos, '7.0'
	shared_pods
end

相关文件补充 HBSwiftKit_Example -> shortcuts

(六). 参考

iOS Siri Shortcuts 集成初探 (Objective-C)

iOS12 Siri Shortcuts(二)

Siri Shortcuts intent 扩展开发

SiriKit框架详细解析(七) —— 构建Siri Shortcuts简单示例(一)

iOS开发之App Extension(应用扩展)之 -- Today Extension

APP Extension 与 APP之间的数据共享