WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Make your app available to Siri

Code-along: Make your app available to Siri

观看原视频

Highlight

通过 App Intents 框架和 App Schemas,开发者可以让应用的内容和操作通过 Siri 访问,用户可以用自然语言查询日历事件、修改时间、发送消息,无需编写自定义自然语言处理代码。

核心内容

以前让应用与 Siri 配合需要大量工作。要定义意图、编写训练短语、处理自然语言解析,每个新功能都要重复这些步骤。

Apple 推出了 App Schemas(应用架构),用预定义的结构描述应用的内容和操作。开发者不需要处理自然语言,只需告诉系统”这是一个日历事件”或”这是一个日程操作”,Siri 就能理解。

这个 code-along 视频用 CometCal 项目演示完整流程。CometCal 是一个 SwiftUI 日历应用,支持显示今日事件、查看编辑、创建新事件、管理不同日历。视频一步步把它从只能触摸操作的应用,变成完全支持 Siri 语音控制的版本。

关键是三个步骤:让 Siri 理解应用内容(实体)、让 Siri 能执行操作(意图)、让 Siri 知道屏幕上显示什么(屏幕感知)。

详细内容

第一步:定义应用实体 (03:12)

App Schemas 将内容组织成域(Domain)。日历域(Calendar Domain)涵盖所有与日程相关的内容:事件、日历、参与者,以及操作它们的动作。

第一个实体是 CalendarEntity,代表日历本身。创建方式很直接:

import AppIntents

struct CalendarEntity: calendar_calendar {
    @AppEntity(title: "Calendar")
    static var entityQuery = CalendarEntityQuery()

    var id: UUID
    var title: String

    // DisplayRepresentation 和其他必需属性
}

关键点:

  • calendar_calendar 是日历域中的日历架构,Xcode 会自动补全所有可用架构
  • @AppEntity 宏标记这是一个应用实体,Siri 可以对其进行推理
  • IndexedEntity 协议让实体可以通过 Spotlight 索引捐赠,获得语义理解能力

为了让 Siri 能找到这些实体,需要实现 EntityQuery 协议:

struct CalendarEntityQuery: EntityQuery {
    @Dependency private var calendarManager: CalendarManager

    func entities(for identifiers: [UUID]) async throws -> [CalendarEntity] {
        // 通过 ID 获取日历
        return try await calendarManager.calendars(for: identifiers)
            .map { $0.entity }
    }

    func allEntities() async throws -> [CalendarEntity] {
        // 返回所有日历,供 Siri 作为选项提供
        return try await calendarManager.allCalendars()
            .map { $0.entity }
    }
}

关键点:

  • @Dependency 属性包装器注入共享资源,App Intents 会提供注册过的同一实例
  • allEntities() 让 Siri 知道有哪些日历可用,创建事件时可以提供选项

实体定义完成后,需要将其捐赠给系统索引。在 CalendarManager 中:

func createCalendar(_ title: String) throws -> CalendarModel {
    let calendar = CalendarModel(title: title)
    modelContext.insert(calendar)

    // 捐赠实体到 Spotlight 索引
    searchableIndex.indexAppEntities(
        identifiers: [calendar.id],
        type: CalendarEntity.self
    )

    return calendar
}

func updateCalendar(_ calendar: CalendarModel) throws {
    // 更新后重新索引
    searchableIndex.indexAppEntities(
        identifiers: [calendar.id],
        type: CalendarEntity.self
    )
}

func deleteCalendar(_ calendar: CalendarModel) throws {
    modelContext.delete(calendar)

    // 从索引中删除
    searchableIndex.deleteAppEntities(
        identifiers: [calendar.id],
        type: CalendarEntity.self
    )
}

第二个实体:参与者 (07:59)

AttendeeEntity 代表事件中的参与者,但它使用 TransientAppEntity 协议而非 IndexedEntity

struct AttendeeEntity: calendar_attendee {
    var person: IntentPerson
    var status: AttendeeStatusEntity
    var type: AttendeeTypeEntity
    var isOptional: Bool
}

关键点:

  • TransientAppEntity 表示临时实体,不需要唯一标识符,不用于独立查询
  • 参与者依附于事件存在,同一人可能参加多个事件,分别索引会造成重复结果
  • IntentPerson 是系统标准类型,包含姓名和联系人信息,便于跨应用共享

参与者使用两个 @AppEnum 类型:

enum AttendeeStatusEntity: calendar_attendeeStatus {
    case accepted
    case declined
    case tentative
    case pending
}

enum AttendeeTypeEntity: calendar_attendeeType {
    case person
}

关键点:

  • App Schema 定义可能的枚举值,应用选择适用的那些
  • 如果应用使用不同术语,只需将现有模型映射到架构的枚举值

第三个实体:事件 (10:22)

EventEntity 是最核心的实体,同样使用 IndexedEntity

struct EventEntity: calendar_event {
    var id: UUID
    var title: String
    var startDate: Date
    var endDate: Date
    var calendar: CalendarEntity
    var attendees: [AttendeeEntity]
    var location: EventLocation?
    var note: String?
    var recurrence: Calendar.RecurrenceRule?
    var alarms: [EventAlarm]?
    var status: EventStatusEntity
}

关键点:

  • 属性很多,但模式与之前相同
  • 必需属性如 titlestartDate 直接连接
  • 不用的可选属性如 travelTime 可以留空
  • 非架构属性如 isFavorite 也可以添加

EventEntity 组合了其他实体:

var calendar: CalendarEntity  // 所属日历
var attendees: [AttendeeEntity]  // 参与者列表

Siri 通过 App Schemas 理解这些关系。

重复事件使用 Foundation 的 Calendar.RecurrenceRule

var recurrence: Calendar.RecurrenceRule?

// 转换逻辑
recurrence: eventModel.frequency.map { frequency in
    Calendar.RecurrenceRule(
        frequency: frequency.toRecurrenceFrequency(),
        interval: 1
    )
}

位置和告警是联合值(Union Value),可以存储多种类型之一:

typealias EventLocation = @CalendarEventLocation.PlaceDescriptorOrString
typealias EventAlarm = @CalendarEventAlarm.DateOrDuration

location: location.map { .placeDescriptor($0) }
alarms: alarms.map { alarm in
    switch alarm {
    case .date(let date): return .date(date)
    case .duration(let duration): return .duration(duration)
    }
}

打开特定事件 (14:40)

定义实体后,Siri 可以查询内容,但点击结果只打开应用主页。需要 OpenEventIntent 来导航到具体事件:

struct OpenEventIntent: system_open {
    static var title: LocalizedStringResource = "Open Event"
    static var openProviderStyle: OpenProviderStyle = .siriAndSpotlight

    @Parameter(title: "Event")
    var target: EventEntity?

    @Dependency private var navigationManager: NavigationManager

    func perform() async throws -> some IntentResult {
        guard let event = target else { return .result() }
        navigationManager.open(event.id)
        return .result()
    }
}

关键点:

  • system.open 是系统架构,用于打开实体
  • openProviderStyle 指定在 Siri 和 Spotlight 中可用
  • 点击事件结果时调用此意图,导航到详情视图

屏幕感知 (15:42)

用户可能不想说完整事件标题,而是说”这个事件”或”第三个事件”。需要两个视图修饰符:

在列表视图中:

List(events) { event in
    EventRow(event: event)
        .appEntityIdentifier(event.entityIdentifier)
}

在详情视图中:

EventDetail(event: event)
    .userActivity(.entityIdentifier(event.entityIdentifier))

关键点:

  • .appEntityIdentifier 让系统知道列表中显示哪些事件
  • .userActivity 告诉系统当前聚焦的具体事件
  • 只需两个修饰符,Siri 就能理解”这个事件”指的是哪个

创建事件意图 (17:16)

让 Siri 能执行操作,使用 calendar_createEvent 架构:

struct CreateEventIntent: calendar_createEvent {
    static var title: LocalizedStringResource = "Create Event"
    static var openAppWhenRun: Bool = false

    @Parameter(title: "Title")
    var title: String

    @Parameter(title: "Start Date")
    var startDate: Date

    @Parameter(title: "End Date")
    var endDate: Date

    @Parameter(title: "Calendar")
    var calendar: CalendarEntity?

    @Parameter(title: "Location")
    var location: EventLocation?

    @Parameter(title: "Note")
    var note: String?

    @Dependency private var calendarManager: CalendarManager

    @MainActor
    func perform() async throws -> some IntentResult & ReturnsValue<EventEntity> {
        // 解析参数
        let resolvedLocation: String? = try location?.resolve()

        // 执行创建
        let event = try await calendarManager.createEvent(
            title: title,
            startDate: startDate,
            endDate: endDate,
            calendarId: calendar?.id,
            location: resolvedLocation,
            note: note
        )

        return .result(with: event.entity)
    }
}

关键点:

  • 架构定义参数列表,直接使用
  • @MainActor 确保在主线程执行
  • 返回 EventEntity 让 Siri 知道创建结果
  • Siri 自动处理语言理解、询问澄清、确认细节

更新事件意图 (20:17)

更新意图使用 calendar_updateEvent 架构:

struct UpdateEventIntent: calendar_updateEvent {
    @Parameter(title: "Event")
    var event: EventEntity?

    @Parameter(title: "Start Date")
    var startDate: Date?

    @Parameter(title: "End Date")
    var endDate: Date?

    @Parameter(title: "Location")
    var location: EventLocation?

    // ... 其他可选参数

    @MainActor
    func perform() async throws -> some IntentResult & ReturnsValue<EventEntity> & ShowsSnippetView {
        guard let event = event else { return .result() }

        var updates: EventUpdates = [:]

        // 处理可选参数
        if let startDate = startDate {
            updates[.startDate] = startDate
        }

        // 处理可能被清除的参数
        switch location.valueState {
        case .set(let value):
            updates[.location] = value
        case .set(nil):
            updates[.location] = nil  // 明确清除
        case .unset:
            break  // 不更新
        }

        let updated = try await calendarManager.updateEvent(event.id, updates: updates)
        return .result(with: updated.entity, snippet: EventSnippetView(event: updated.entity))
    }
}

关键点:

  • 大多数参数是可选的,用户可能只改一两项
  • valueState 区分三种情况:设置新值、明确清除为 nil、完全未设置
  • ShowsSnippetView 返回自定义 SwiftUI 视图而非默认卡片

删除事件意图 (22:30)

删除是最简单的操作意图:

struct DeleteEventIntent: calendar_deleteEvent {
    @Parameter(title: "Event")
    var event: EventEntity?

    @Parameter(title: "Span")
    var span: EventSpan?

    @MainActor
    func perform() async throws -> IntentResult {
        guard let event = event else { return .result() }

        if let span = span {
            switch span {
            case .thisEvent:
                try calendarManager.deleteEvent(event.id)
            case .futureEvents:
                try calendarManager.deleteRecurringEvent(from: event.id, future: true)
            }
        }

        return .result()
    }
}

关键点:

  • Siri 自动显示确认对话框
  • 重复事件支持删除单次或所有未来事件
  • 多个匹配时自动消歧

自定义结果视图 (21:21)

默认结果卡片来自 DisplayRepresentation。可以自定义:

struct EventSnippetView: SnippetView {
    var event: EventEntity

    var body: some Snippet {
        Snippet(
            image: event.icon,
            title: event.title,
            subtitle: formattedDateRange(event.startDate, event.endDate)
        ) {
            // 自定义布局
        }
    }
}

在意图中返回:

func perform() async throws -> some IntentResult & ReturnsValue<EventEntity> & ShowsSnippetView {
    // ... 执行逻辑
    return .result(with: updated.entity, snippet: EventSnippetView(event: updated.entity))
}

关键点:

  • SwiftUI 视图保持简单轻量
  • 体现应用个性,如渐变、图标、颜色
  • 适用于所有返回结果的意图

核心启发

  1. 智能日历助手:基于 EventEntity 的语义索引,用户可以问”下周有哪些与客户相关的会议?”、“本季度有多少次团队会议?“,Siri 直接从事件标题和备注中搜索答案。

  2. 语音快速创建:创建意图让用户可以快速说”创建明天下午 3 点和 Alex 的咖啡会议”,无需打开应用填写表单。结合位置解析,可以自动选择附近咖啡店。

  3. 批量修改助手:更新意图支持自然语言批量修改,如”把这个月所有周二的双周会改到周三”、“把这周所有线上会议改成线下”。利用重复规则和参数状态判断。

  4. 智能提醒整合:告警联合值可以与系统提醒整合,用户说”提前 30 分钟提醒我准备会议材料”,自动在事件中添加告警。

  5. 屏幕上下文操作:用户在查看事件详情时可以直接说”给参与者发消息确认出席”、“把地点分享给某人”。屏幕感知让操作更自然。

关联 Session

评论

GitHub Issues · utterances