WWDC Quick Look 💓 By SwiftGGTeam
Dive into App Intents

Dive into App Intents

观看原视频

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()
}

关键点:

  • AppEntityBookEntity 成为 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 收集书名、作者和推荐人。
  • authorNamerecommendedBy 是可选参数。
  • 返回类型组合了 IntentResultReturnsValue<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。
  • datePublisheddateRead 让 Shortcut 可以读取日期信息。
  • dateRead 是可选值,适合还没读完的书。
  • 没有 @PropertyrecommendedBy 不会作为可查询属性暴露。
  • displayRepresentation 仍然控制实体本身的展示文本。
  • 初始化方法展示了 Entity 可以按不同场景构造。

20:59EntityPropertyQuery 进一步把属性变成 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 声明哪些属性可以被查询。
  • EqualToComparatorContainsComparator 映射标题比较条件。
  • 日期属性使用 LessThanComparatorGreaterThanComparator
  • 示例用 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)
        }
    }
}

关键点:

  • bookcount 是下单需要的两个参数。
  • OrderEntity(book:count:) 先构造待确认结果。
  • requestConfirmation(output:) 请求用户确认这个订单。
  • 确认请求里带有 OrderPreview,用户能看到下单前预览。
  • 用户取消时 requestConfirmation 会抛错。
  • 最后的 .result 返回确认后的订单、感谢文案和确认视图。

选择 App 内实现还是 Extension

27:09)App Intents 有两种部署方式。最简单的是直接放在 App 里。这样不需要 framework,不需要复制代码,也不用跨进程协调。它还有更高的内存限制,并能做一些 Extension 不适合做的工作,比如播放音频。

如果 openAppWhenRuntrue,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 找回对象。
  • 怎么开始:让模型或包装类型遵守 AppEntityIdentifiable,提供 displayRepresentationtypeDisplayRepresentationdefaultQuery

3. 给大数据列表做搜索型参数选择器

  • 做什么:当用户选择对象时,支持搜索完整数据库,而不是只显示固定候选。
  • 为什么值得做EntityStringQuery 让书籍选择器可以先显示建议项,再按用户输入搜索完整书库。
  • 怎么开始:在 Query 中实现 suggestedEntities()entities(matching:);建议项返回常用对象,搜索方法查询更大的数据集。

4. 把筛选和排序交给 Shortcuts 组合

  • 做什么:让用户在 Shortcuts 里构建“查找所有符合条件的对象”动作,例如查找逾期任务、筛选今年照片、导出本月订单。
  • 为什么值得做EntityPropertyQuery 会让系统生成 Find 和 Filter 动作,App 只需要把比较条件映射到自己的数据库查询。
  • 怎么开始:用 @Property 暴露可查询字段,再实现 QueryPropertiesSortingOptionsentities(matching:mode:sortedBy:limit:)

5. 为语音操作补上确认和反馈

  • 做什么:让添加、购买、删除、发送这类动作在 Siri 中能追问、消歧、确认并反馈结果。
  • 为什么值得做:演讲展示了 requestValuerequestDisambiguationrequestConfirmationProvidesDialogShowsSnippetView,它们覆盖语音交互的关键状态。
  • 怎么开始:对缺失参数抛出 $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