Highlight
iOS 10 引入的 SiriKit Intents 基于 Objective-C,需要创建单独的 Extension,适配成本高。App Intents 完全重新设计了这套体系:直接在 App 主工程里用 Swift 定义 Intent,不需要 Extension,不需要额外的 framework target。
核心内容
SiriKit Intents 让 App 可以接入 Siri,但它主要围绕固定系统领域工作,比如消息、健身和支付。开发者想把自己的任意功能交给 Siri、Spotlight 和 Shortcuts,往往要维护额外定义文件、Extension 和共享 framework。
App Intents 把这件事改成 Swift 代码。一个 Intent 是 App 暴露给系统的单个动作。Entity 表示 App 里的对象。App Shortcut 把 Intent 包装成可发现、可语音触发的入口。
演讲用一个图书馆 App 做例子。用户经常打开“正在阅读”的书架,所以第一个 Intent 只做一件事:打开这个书架。之后,代码逐步扩展到打开任意书架、打开一本书、添加一本书、按属性查找书、给 Siri 返回文本和 SwiftUI 片段。
详细内容
用 Swift 写一个 Intent
(05:33)Intent 的最小形态是一个遵守 AppIntent 的 Swift 类型。它包含标题、perform() 方法,以及可选的运行行为。
struct OpenCurrentlyReading: AppIntent {
static var title: LocalizedStringResource = "Open Currently Reading"
@MainActor
func perform() async throws -> some IntentResult {
Navigator.shared.openShelf(.currentlyReading)
return .result()
}
static var openAppWhenRun: Bool = true
}
关键点:
struct OpenCurrentlyReading: AppIntent定义一个系统可运行的动作。title是用户在 Shortcuts 编辑器里看到的本地化标题。perform()是 Intent 被执行时真正运行的代码。@MainActor让导航代码在主线程执行,因为示例里的Navigator需要主线程。Navigator.shared.openShelf(.currentlyReading)复用 App 内已有的导航逻辑。return .result()告诉系统动作执行完成。openAppWhenRun = true让这个会打开界面的 Intent 在运行时拉起 App。
(06:42)如果希望用户不用手动创建 Shortcut,就能在 Siri 和 Spotlight 里发现这个动作,需要再提供 AppShortcutsProvider。
struct LibraryAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OpenCurrentlyReading(),
phrases: ["Open Currently Reading in \(.applicationName)"],
systemImageName: "books.vertical.fill"
)
}
}
关键点:
LibraryAppShortcuts声明 App 提供的快捷动作集合。AppShortcut把一个 Intent 变成可被系统展示和调用的入口。intent: OpenCurrentlyReading()绑定刚才定义的动作。phrases定义用户可以对 Siri 说的短语。\(.applicationName)把 App 名称放进语音短语。systemImageName给 Shortcuts 和 Spotlight 提供图标。
参数让一个 Intent 覆盖多个目标
(07:11)如果只能打开“正在阅读”,这个 Intent 太窄。书架数量固定,适合用枚举。要把枚举作为 Intent 参数,需要遵守 AppEnum。
enum Shelf: String {
case currentlyReading
case wantToRead
case read
}
extension Shelf: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Shelf"
static var caseDisplayRepresentations: [Shelf: DisplayRepresentation] = [
.currentlyReading: "Currently Reading",
.wantToRead: "Want to Read",
.read: "Read",
]
}
关键点:
Shelf: String给每个书架提供稳定的原始值。AppEnum让枚举可作为 Intent 参数。typeDisplayRepresentation是枚举类型本身的用户可见名称。caseDisplayRepresentations为每个枚举值提供 Shortcuts 中显示的标题。- 字典字面量会被编译器在构建时读取,用于生成 Intent 元数据。
(07:49)Intent 用 @Parameter 接收这个枚举,然后在 perform() 中使用参数。
struct OpenShelf: AppIntent {
static var title: LocalizedStringResource = "Open Shelf"
@Parameter(title: "Shelf")
var shelf: Shelf
@MainActor
func perform() async throws -> some IntentResult {
Navigator.shared.openShelf(shelf)
return .result()
}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$shelf)")
}
static var openAppWhenRun: Bool = true
}
关键点:
@Parameter(title: "Shelf")声明用户可配置的输入。var shelf: Shelf使用刚才暴露给 App Intents 的枚举。Navigator.shared.openShelf(shelf)让同一个 Intent 打开不同书架。parameterSummary决定它在 Shortcuts 编辑器里的句子式展示。Summary("Open \(\.$shelf)")把参数嵌入摘要,界面比表格行更自然。openAppWhenRun = true适合这种需要展示 App UI 的动作。
Entity 表示动态对象
(10:56)书架是固定集合,图书不是。用户可以不断添加书。动态、用户定义的值应该用 Entity 表示。
struct BookEntity: AppEntity, Identifiable {
var id: UUID
var displayRepresentation: DisplayRepresentation { "\(title)" }
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
}
关键点:
AppEntity让BookEntity成为 App Intents 可以传递的对象。Identifiable提供稳定标识符,系统会把这个标识符保存进 Shortcut。id: UUID是图书实体的持久身份。displayRepresentation是用户在选择器和结果里看到的文本。typeDisplayRepresentation是实体类型名。defaultQuery告诉系统如何根据保存的标识符重新找到实体。
(12:29)最基础的查询只需要根据标识符取回实体。
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
}
关键点:
EntityQuery是系统取回 Entity 的入口。entities(for:)接收一组持久标识符。Database.shared.book(for:)从 App 自己的数据库查找图书。compactMap丢弃已经不存在或查不到的图书。- 返回的
[BookEntity]会交给 Intent 参数、Shortcuts 选择器或下游动作使用。
(13:40)为了让用户在 Shortcuts 里选择和搜索图书,查询还可以升级成 EntityStringQuery。
struct BookQuery: EntityStringQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.books
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
}
关键点:
EntityStringQuery在标识符查询之外增加文本搜索能力。suggestedEntities()给选择器提供默认候选列表。entities(matching:)根据用户输入的搜索词返回实体。- 示例用标题做大小写不敏感匹配。
- 如果对象很多,可以只在
suggestedEntities()返回常用项,把完整搜索放在entities(matching:)。
Intent 可以返回值并接上另一个 Intent
(15:11)打开图书只是读取已有数据。添加图书会修改模型,而且添加完成后,常见需求是立即打开这本书。App Intents 用返回值和 openIntent 表达这个流程。
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & OpensIntent {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(
value: book,
openIntent: OpenBook(book: book)
)
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
关键点:
- 三个
@Parameter收集书名、作者和推荐人。 authorName与recommendedBy是可选参数。- 返回类型组合了
IntentResult、ReturnsValue<BookEntity>和OpensIntent。 BooksAPI.shared.findBooks用 async/await 查找图书。- 查不到书时抛出
Error.notFound。 CustomLocalizedStringResourceConvertible让错误可以本地化显示。Database.shared.add(book:)把结果写入 App 数据库。.result(value: book, openIntent: OpenBook(book: book))把新图书作为结果返回,并提供后续打开动作。
这个设计让用户在 Shortcuts 里自由组合:可以只添加书,也可以打开“Open When Run”开关,在添加后自动进入 App 的图书页面。
Property Query 把 App 数据变成 Shortcuts 的筛选器
(18:21)如果 Entity 只有标题,Shortcuts 只能选择和传递它。加上 @Property 后,用户可以在 Shortcut 里读取这些字段。
struct BookEntity: AppEntity, Identifiable {
var id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Publishing Date")
var datePublished: Date
@Property(title: "Read Date")
var dateRead: Date?
var recommendedBy: String?
var displayRepresentation: DisplayRepresentation { "\(title)" }
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
init(id: UUID) {
self.id = id
}
init(id: UUID, title: String) {
self.id = id
self.title = title
}
}
关键点:
@Property(title: "Title")把标题暴露给 Shortcuts。datePublished和dateRead让 Shortcut 可以读取日期信息。dateRead是可选值,适合还没读完的书。- 没有
@Property的recommendedBy不会作为可查询属性暴露。 displayRepresentation仍然控制实体本身的展示文本。- 初始化方法展示了 Entity 可以按不同场景构造。
(20:59)EntityPropertyQuery 进一步把属性变成 Shortcuts 里的“Find”和“Filter”动作。系统负责编辑器 UI,App 负责把比较条件映射到自己的数据层。
struct BookQuery: EntityPropertyQuery {
static var sortingOptions = SortingOptions {
SortableBy(\BookEntity.$title)
SortableBy(\BookEntity.$dateRead)
SortableBy(\BookEntity.$datePublished)
}
static var properties = QueryProperties {
Property(\BookEntity.$title) {
EqualToComparator { NSPredicate(format: "title = %@", $0) }
ContainsComparator { NSPredicate(format: "title CONTAINS %@", $0) }
}
Property(\BookEntity.$datePublished) {
LessThanComparator { NSPredicate(format: "datePublished < %@", $0 as NSDate) }
GreaterThanComparator { NSPredicate(format: "datePublished > %@", $0 as NSDate) }
}
Property(\BookEntity.$dateRead) {
LessThanComparator { NSPredicate(format: "dateRead < %@", $0 as NSDate) }
GreaterThanComparator { NSPredicate(format: "dateRead > %@", $0 as NSDate) }
}
}
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
func suggestedEntities() async throws -> [BookEntity] {
Model.shared.library.books.map { BookEntity(id: $0.id, title: $0.title) }
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
func entities(
matching comparators: [NSPredicate],
mode: ComparatorMode,
sortedBy: [Sort<BookEntity>],
limit: Int?
) async throws -> [BookEntity] {
Database.shared.findBooks(matching: comparators, matchAll: mode == .and, sorts: sortedBy.map { (keyPath: $0.by, ascending: $0.order == .ascending) })
}
}
关键点:
sortingOptions列出 Shortcuts 可以排序的字段。SortableBy(\BookEntity.$title)允许按标题排序。QueryProperties声明哪些属性可以被查询。EqualToComparator和ContainsComparator映射标题比较条件。- 日期属性使用
LessThanComparator和GreaterThanComparator。 - 示例用
NSPredicate作为比较条件的映射类型,因为数据层是 Core Data。 entities(matching:mode:sortedBy:limit:)接收比较条件、AND/OR 模式、排序和数量限制。Database.shared.findBooks(...)把系统生成的查询条件落到 App 自己的数据查询。
这带来的结果很具体:用户可以在 Shortcuts 中找出 20 世纪早期出版的书、筛出某个作者的书、把今年读过的书导出到 CSV,或者交给图表 App 统计十年的阅读数量。
对话、片段和澄清让 Intent 适合语音场景
(24:10)Siri 运行 Intent 时,用户需要听到或看到结果。ProvidesDialog 用文本或语音反馈说明动作完成了什么。
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & ProvidesDialog {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(
value: book,
dialog:"Added \(book) to Library!"
)
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
关键点:
- 返回类型增加
ProvidesDialog。 .result(value:dialog:)同时返回实体和反馈文案。dialog会在 Siri 或 Shortcuts 支持的界面中读出或显示。- 语音体验里的 Intent 应提供这种反馈,否则用户不知道动作是否完成。
(24:25)视觉反馈用 snippet。它本质上是一个 SwiftUI 视图,作为结果的一部分返回。
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ShowsSnippetView {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(value: book) {
CoverView(book: book)
}
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
关键点:
ShowsSnippetView表示 Intent 会返回可视化片段。.result(value:) { ... }的尾随闭包提供 SwiftUI 视图。CoverView(book: book)可以展示刚添加图书的封面。- 这个视图会被归档并发送到 Siri 或 Shortcuts 展示。
(24:50)当参数缺失或不够精确时,可以抛出 requestValue,让系统向用户追问。
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult {
let books = await BooksAPI.shared.findBooks(named: title, author: authorName)
guard !books.isEmpty else {
throw Error.notFound
}
if books.count > 1 && authorName == nil {
throw $authorName.requestValue("Who wrote the book?")
}
return .result()
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
关键点:
BooksAPI.shared.findBooks先按已有参数查候选书。guard !books.isEmpty处理完全找不到的情况。books.count > 1 && authorName == nil表示书名不够区分结果。$authorName.requestValue("Who wrote the book?")请求用户补充作者。- 这个调用以错误形式抛出,系统会提示用户,然后带着新参数重新运行动作。
(25:22)如果系统已经有多个候选值,可以用 requestDisambiguation 让用户从列表中选择。
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult {
let books = await BooksAPI.shared.findBooks(named: title, author: authorName)
guard !books.isEmpty else {
throw Error.notFound
}
if books.count > 1 {
let chosenAuthor = try await $authorName.requestDisambiguation(among: books.map { $0.authorName }, dialog: "Which author?")
}
return .result()
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
关键点:
books.map { $0.authorName }从候选书中提取可选作者列表。$authorName.requestDisambiguation(...)把列表交给系统展示。dialog: "Which author?"提供询问文案。try await表示等待用户选择,取消时可能抛错。chosenAuthor是用户选中的值,后续可以用于继续查找或写入。
(26:26)交易类动作还应该确认结果。示例用买书订单展示了结果确认和确认后的结果片段。
struct BuyBook: AppIntent {
@Parameter(title: "Book")
var book: BookEntity
@Parameter(title: "Count")
var count: Int
static var title: LocalizedStringResource = "Buy Book"
func perform() async throws -> some IntentResult & ShowsSnippetView & ProvidesDialog {
let order = OrderEntity(book: book, count: count)
try await requestConfirmation(output: .result(value: order, dialog: "Are you ready to order?") {
OrderPreview(order: order)
})
return .result(value: order, dialog: "Thank you for your order!") {
OrderConfirmation(order: order)
}
}
}
关键点:
book和count是下单需要的两个参数。OrderEntity(book:count:)先构造待确认结果。requestConfirmation(output:)请求用户确认这个订单。- 确认请求里带有
OrderPreview,用户能看到下单前预览。 - 用户取消时
requestConfirmation会抛错。 - 最后的
.result返回确认后的订单、感谢文案和确认视图。
选择 App 内实现还是 Extension
(27:09)App Intents 有两种部署方式。最简单的是直接放在 App 里。这样不需要 framework,不需要复制代码,也不用跨进程协调。它还有更高的内存限制,并能做一些 Extension 不适合做的工作,比如播放音频。
如果 openAppWhenRun 为 true,App 会进入前台。否则 App 会在后台以特殊模式启动,不带起 scene,以提高性能。演讲中特别建议:如果在 App 内实现后台 App Intents,也要实现 scene support。
(28:05)Extension 更轻量,因为进程只处理 App Intents,不需要启动整个 App。处理 Focus intents 时,Focus 变化可以立即在 Extension 中执行,不要求 App 先在前台运行。代价是需要新 target、把共享代码移入 framework,并处理 App 与 Extension 的协调。
(28:52)App Intents 的元数据来自构建期静态提取。Xcode 会在构建时生成 metadata 文件,里面包含编译器从 Intent、Entity、Query 和 Parameter 代码中读出的信息。为了让提取工作正常,App Intents 类型应该直接放在 App target 或 Extension target 中,不要放在 framework。相关本地化字符串也应该位于同一个 bundle 的 strings 文件里。
核心启发
1. 给高频页面加一个 Siri 入口
- 做什么:把用户每天都会打开的页面做成
AppIntent,例如“打开今日任务”“打开当前订单”“打开正在阅读”。 - 为什么值得做:演讲里的
OpenCurrentlyReading只用几行代码,就能进入 Shortcuts,并可通过 App Shortcut 出现在 Siri 和 Spotlight。 - 怎么开始:定义一个遵守
AppIntent的结构体,在perform()里调用已有导航代码;如果会打开 UI,把openAppWhenRun设为true。
2. 把 App 内对象暴露成 Entity
- 做什么:让用户在 Shortcut 里选择你的模型对象,例如项目、文档、联系人、播放列表。
- 为什么值得做:
BookEntity让系统可以保存对象标识符,并在运行 Shortcut 时通过BookQuery找回对象。 - 怎么开始:让模型或包装类型遵守
AppEntity和Identifiable,提供displayRepresentation、typeDisplayRepresentation和defaultQuery。
3. 给大数据列表做搜索型参数选择器
- 做什么:当用户选择对象时,支持搜索完整数据库,而不是只显示固定候选。
- 为什么值得做:
EntityStringQuery让书籍选择器可以先显示建议项,再按用户输入搜索完整书库。 - 怎么开始:在 Query 中实现
suggestedEntities()和entities(matching:);建议项返回常用对象,搜索方法查询更大的数据集。
4. 把筛选和排序交给 Shortcuts 组合
- 做什么:让用户在 Shortcuts 里构建“查找所有符合条件的对象”动作,例如查找逾期任务、筛选今年照片、导出本月订单。
- 为什么值得做:
EntityPropertyQuery会让系统生成 Find 和 Filter 动作,App 只需要把比较条件映射到自己的数据库查询。 - 怎么开始:用
@Property暴露可查询字段,再实现QueryProperties、SortingOptions和entities(matching:mode:sortedBy:limit:)。
5. 为语音操作补上确认和反馈
- 做什么:让添加、购买、删除、发送这类动作在 Siri 中能追问、消歧、确认并反馈结果。
- 为什么值得做:演讲展示了
requestValue、requestDisambiguation、requestConfirmation、ProvidesDialog和ShowsSnippetView,它们覆盖语音交互的关键状态。 - 怎么开始:对缺失参数抛出
$parameter.requestValue(),对多个候选抛出$parameter.requestDisambiguation(...),对交易或破坏性动作调用requestConfirmation,最后用 dialog 或 SwiftUI snippet 返回结果。
关联 Session
- Implement App Shortcuts with App Intents — 讲 App Shortcut 的实现路径,适合在掌握
AppIntent后继续学习如何让动作自动出现在 Siri、Spotlight 和 Shortcuts。 - Design App Shortcuts — 讲如何设计短语、视觉和信息收集流程,补足本文偏代码实现的部分。
- Meet Focus filters — 展示 App Intents 在 Focus filters 中的使用方式,适合了解同一框架在系统级状态切换里的应用。
评论
GitHub Issues · utterances