WWDC Quick Look 💓 By SwiftGGTeam
Dive into App Intents

Dive into App Intents

元の動画を見る

ハイライト

iOS 10 で導入された SiriKit Intents は Objective-C ベースで、別 Extension が必要であり統合コストが高かった。App Intents はこの仕組みを完全に再設計し、App 本体ターゲットで Swift から直接 Intent を定義でき、Extension も追加 framework target も不要です。


主要内容

SiriKit Intents は App を Siri に接続できますが、主にメッセージ、フィットネス、支払いなど固定のシステム領域を中心に動作します。開発者が任意の App 機能を Siri、Spotlight、Shortcuts に渡したいとき、追加定義ファイル、Extension、共有 framework の維持が必要になることが多いです。

App Intents はこれを Swift コードに変えます。Intent は App がシステムに公開する単一アクションです。Entity は App 内オブジェクトを表します。App Shortcut は Intent を発見可能で音声起動できる入口に包みます。

講演は図書館 App を例にします。ユーザーは「読書中」棚をよく開くため、最初の Intent は1つだけ。あの棚を開くことです。その後、任意の棚を開く、本を開く、本を追加する、属性で本を検索する、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 は UI を開く 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 用アイコンを提供します。

パラメータで1つの Intent が複数ターゲットをカバーする

07:11)「読書中」しか開けないと Intent が狭すぎます。棚は固定集合なので enum が向きます。enum を 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 は各棚に安定した raw value を与えます。
  • AppEnum で enum を Intent パラメータにできます。
  • typeDisplayRepresentation は enum 型自体のユーザー向け名称です。
  • caseDisplayRepresentations は Shortcuts 表示用の各 case タイトルです。
  • 辞書リテラルはビルド時にコンパイラが読み、Intent メタデータ生成に使われます。

07:49)Intent は @Parameter で enum を受け取り、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 に公開した enum を使います。
  • Navigator.shared.openShelf(shelf) で同一 Intent が異なる棚を開けます。
  • parameterSummary は Shortcuts エディタでの文形式表示を決めます。
  • Summary("Open \(\.$shelf)") はパラメータを要約に埋め込み、表形式より自然な UI にします。
  • 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)最も基本的な Query は識別子でエンティティを取り戻すだけです。

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 で本を選択・検索させるには Query を 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"
            }
        }
    }
}

キーポイント:

  • 3つの @Parameter で書名、著者、推薦者を収集します。
  • authorNamerecommendedBy はオプションパラメータです。
  • 戻り型は IntentResultReturnsValue<BookEntity>OpensIntent を組み合わせます。
  • BooksAPI.shared.findBooks は async/await で本を検索します。
  • 見つからないときは Error.notFound を throw します。
  • 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 は未読了の本向けにオプションです。
  • @Property のない recommendedBy はクエリ可能属性として公開されません。
  • 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 を使います。
  • 例はデータ層が Core Data のため NSPredicate をマッピング型に使います。
  • entities(matching:mode:sortedBy:limit:) は比較条件、AND/OR モード、ソート、件数制限を受け取ります。
  • Database.shared.findBooks(...) はシステム生成クエリを App のデータクエリへ落とし込みます。

具体的な結果として、ユーザーは Shortcuts で20世紀初頭出版の本を探したり、特定著者の本を絞ったり、今年読んだ本を CSV へエクスポートしたり、10年分の読書量をチャート 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 を throw し、システムにユーザーへ追問させます。

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?") で著者の補足を求めます。
  • エラーとして throw され、システムがユーザーに促し、新パラメータで再実行します。

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 は注文に必要な2パラメータです。
  • OrderEntity(book:count:) で確認待ち結果を先に構築します。
  • requestConfirmation(output:) で注文確認を求めます。
  • 確認要求に OrderPreview が含まれ、注文前プレビューを見せられます。
  • ユーザーがキャンセルすると requestConfirmation はエラーになります。
  • 最後の .result は確認後の注文、感謝文、確認ビューを返します。

App 内実装か Extension かを選ぶ

27:09)App Intents には2つの配置方法があります。最も簡単なのは 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 型は framework ではなく App target または Extension target に直接置くべきです。関連ローカライズ文字列も同じ bundle の strings ファイルに置きます。


重要ポイント

1. 高頻度ページに Siri 入口を追加する

  • 何をするか: ユーザーが毎日開くページを AppIntent にする。例:「今日のタスクを開く」「現在の注文を開く」「読書中を開く」。
  • なぜ価値があるか: 講演の OpenCurrentlyReading は数行で Shortcuts に入り、App Shortcut 経由で Siri と Spotlight に現れます。
  • 始め方: AppIntent 準拠 struct を定義し、perform() で既存ナビゲーションを呼ぶ。UI を開くなら openAppWhenRuntrue に。

2. App 内オブジェクトを Entity として公開する

  • 何をするか: Shortcut でモデルオブジェクト(プロジェクト、文書、連絡先、プレイリストなど)を選べるようにする。
  • なぜ価値があるか: BookEntity でシステムはオブジェクト識別子を保存し、Shortcut 実行時に BookQuery で取り戻せます。
  • 始め方: モデルまたはラッパーを AppEntityIdentifiable に準拠させ、displayRepresentationtypeDisplayRepresentationdefaultQuery を提供する。

3. 大規模リスト向け検索型パラメータ選択器を作る

  • 何をするか: オブジェクト選択時に固定候補だけでなくデータベース全体を検索できるようにする。
  • なぜ価値があるか: EntityStringQuery で本選択器は提案を先に表示し、ユーザー入力で全書庫を検索できます。
  • 始め方: Query で suggestedEntities()entities(matching:) を実装。提案は常用オブジェクト、検索はより大きなデータセットを対象に。

4. フィルターとソートを Shortcuts 組み合わせに任せる

  • 何をするか: Shortcuts で「条件に合うオブジェクトをすべて探す」アクションを構築させる。例: 期限切れタスク、今年の写真、今月の注文エクスポート。
  • なぜ価値があるか: EntityPropertyQuery でシステムが Find と Filter を生成し、App は比較条件を DB クエリへマッピングするだけです。
  • 始め方: @Property でクエリ可能フィールドを公開し、QueryPropertiesSortingOptionsentities(matching:mode:sortedBy:limit:) を実装する。

5. 音声操作に確認とフィードバックを補う

  • 何をするか: 追加、購入、削除、送信アクションを Siri で追問、曖昧さ解消、確認、結果フィードバックできるようにする。
  • なぜ価値があるか: 講演は requestValuerequestDisambiguationrequestConfirmationProvidesDialogShowsSnippetView を示し、音声対話の主要状態をカバーします。
  • 始め方: 欠落パラメータは $parameter.requestValue()、複数候補は $parameter.requestDisambiguation(...)、取引や破壊的操作は requestConfirmation、最後に dialog または SwiftUI snippet で結果を返す。

関連セッション

  • Implement App Shortcuts with App Intents — App Shortcut 実装パスを扱い、AppIntent 習得後にアクションを Siri、Spotlight、Shortcuts へ自動表示する学習に適しています。
  • Design App Shortcuts — フレーズ、ビジュアル、情報収集フローの設計を扱い、本文のコード実装寄りの内容を補います。
  • Meet Focus filters — Focus filters での App Intents 利用を示し、同一フレームワークのシステム級状態切り替え応用を学べます。

コメント

GitHub Issues · utterances