ハイライト
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()
}
キーポイント:
AppEntityでBookEntityが 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で書名、著者、推薦者を収集します。 authorNameとrecommendedByはオプションパラメータです。- 戻り型は
IntentResult、ReturnsValue<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 に公開します。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を使います。 - 例はデータ層が 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)
}
}
}
キーポイント:
bookとcountは注文に必要な2パラメータです。OrderEntity(book:count:)で確認待ち結果を先に構築します。requestConfirmation(output:)で注文確認を求めます。- 確認要求に
OrderPreviewが含まれ、注文前プレビューを見せられます。 - ユーザーがキャンセルすると
requestConfirmationはエラーになります。 - 最後の
.resultは確認後の注文、感謝文、確認ビューを返します。
App 内実装か Extension かを選ぶ
(27:09)App Intents には2つの配置方法があります。最も簡単なのは 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 型は framework ではなく App target または Extension target に直接置くべきです。関連ローカライズ文字列も同じ bundle の strings ファイルに置きます。
重要ポイント
1. 高頻度ページに Siri 入口を追加する
- 何をするか: ユーザーが毎日開くページを
AppIntentにする。例:「今日のタスクを開く」「現在の注文を開く」「読書中を開く」。 - なぜ価値があるか: 講演の
OpenCurrentlyReadingは数行で Shortcuts に入り、App Shortcut 経由で Siri と Spotlight に現れます。 - 始め方:
AppIntent準拠 struct を定義し、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 は比較条件を DB クエリへマッピングするだけです。 - 始め方:
@Propertyでクエリ可能フィールドを公開し、QueryProperties、SortingOptions、entities(matching:mode:sortedBy:limit:)を実装する。
5. 音声操作に確認とフィードバックを補う
- 何をするか: 追加、購入、削除、送信アクションを Siri で追問、曖昧さ解消、確認、結果フィードバックできるようにする。
- なぜ価値があるか: 講演は
requestValue、requestDisambiguation、requestConfirmation、ProvidesDialog、ShowsSnippetViewを示し、音声対話の主要状態をカバーします。 - 始め方: 欠落パラメータは
$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