Antigravity に「壁紙アプリの App Intent で、コレクションを指定して開けるようにして」と頼んだとき、返ってきた Swift は素直に動きました。AppEnum に代表的なコレクション名を並べ、パラメータの候補として提示する実装です。ショートカットアプリで試すとちゃんと候補が出る。ここで満足して push していたら、数週間後に静かに壊れていたはずです。
問題は、私が個人開発で運営している壁紙アプリのコレクションが、毎日のように増減することです。AppEnum はコンパイル時に選択肢が確定している前提の型で、実行時に増えるカタログには使えません。エージェントが書いたコードが悪かったわけではなく、「このカタログは動的だ」という前提がプロンプトに乗っていなかっただけです。ここでは AppEnum が破綻する具体的な条件と、そこから AppEntity + EntityQuery へ移す設計を、識別子の安定化まで含めてご紹介します。
AppEnum は「固定メニュー」であって「カタログ」ではありません
App Intents でパラメータの候補を提示する方法は主に2つあります。AppEnum と AppEntity です。両者は似た見た目のコードになりますが、想定している世界がまったく違います。
観点 AppEnum AppEntity
選択肢の決まり方 コンパイル時に固定 実行時にクエリで解決
向くもの 並び順・テーマ・画質など有限の設定値 記事・コレクション・商品など増減するデータ
件数 数個〜十数個 数百〜数千でも可
検索 不可(全件提示のみ) 文字列一致で絞り込み可
Spotlight 露出 不可 IndexedEntity で可能
つまり「壁紙の画質(標準・高・最高)」のような固定値は AppEnum が正解で、「ユーザーのコレクション」のように増える対象は AppEntity を使うべきです。この線引きを最初に誤ると、カタログが小さいうちは動くために、テストをすり抜けて本番で静かに劣化します。私が最初に受け取ったコードは、まさにこの取り違えでした。
判断の軸は単純です。その選択肢は次のアプリ更新を待たずに増えるか 。増えるなら AppEntity です。
AppEntity は「データ」、EntityQuery は「その探し方」
AppEntity は Siri やショートカットに見せる1件分のデータを表す型です。カタログの1コレクションを、こう表現します。
import AppIntents
struct WallpaperCollection : AppEntity {
// 安定した識別子。配列の添字ではなく、データ側の永続 ID を使う
let id: String
let name: String
let itemCount: Int
static var typeDisplayRepresentation: TypeDisplayRepresentation {
TypeDisplayRepresentation ( name : "壁紙コレクション" )
}
// Siri・ショートカット上での1件の見た目
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation (
title : " \( name ) " ,
subtitle : " \( itemCount ) 枚"
)
}
static var defaultQuery = WallpaperCollectionQuery ()
}
AppEntity 単体では「どうやってその1件を見つけるか」を知りません。それを担うのが EntityQuery です。ここが AppEnum との最大の違いで、実装量の大半はクエリ側に集まります。
struct WallpaperCollectionQuery : EntityQuery {
// (1) 識別子から実体を復元する。ショートカット保存後の再解決で必ず呼ばれる
func entities ( for identifiers: [ String ]) async throws -> [WallpaperCollection] {
let all = await CollectionStore.shared. load ()
let map = Dictionary ( uniqueKeysWithValues : all. map { ( $0 .id, $0 ) })
return identifiers. compactMap { map[ $0 ] }
}
// (2) パラメータ編集画面で最初に見せる候補
func suggestedEntities () async throws -> [WallpaperCollection] {
let all = await CollectionStore.shared. load ()
// 全件返さず、最近使った上位だけに絞る
return Array (all. sorted { $0 .lastUsedAt > $1 .lastUsedAt }. prefix ( 10 ))
}
}
entities(for:) を軽視しないでください。これはユーザーがショートカットを保存した後、後日そのショートカットを実行するたびに呼ばれます。ここで識別子から実体を復元できないと、保存済みのショートカットが「見つかりません」で壊れます。AppEnum にはこの復元の概念がないため、動的データに使うと保存の永続性そのものが成立しないのです。
文字列検索を足さないと、大きなカタログは使い物になりません
suggestedEntities() は最初に見せる候補にすぎません。コレクションが数百件あるとき、ユーザーは名前で絞り込みたくなります。Siri に「『夜の京都』のコレクションを開いて」と話しかけたときにマッチさせるには、EntityStringQuery を実装します。
struct WallpaperCollectionQuery : EntityStringQuery {
func entities ( for identifiers: [ String ]) async throws -> [WallpaperCollection] {
let all = await CollectionStore.shared. load ()
let map = Dictionary ( uniqueKeysWithValues : all. map { ( $0 .id, $0 ) })
return identifiers. compactMap { map[ $0 ] }
}
func suggestedEntities () async throws -> [WallpaperCollection] {
let all = await CollectionStore.shared. load ()
return Array (all. sorted { $0 .lastUsedAt > $1 .lastUsedAt }. prefix ( 10 ))
}
// (3) Siri が聞き取った語句での絞り込み。大文字小文字・全半角を吸収する
func entities ( matching string: String ) async throws -> [WallpaperCollection] {
let all = await CollectionStore.shared. load ()
let needle = string. lowercased ()
return all. filter { $0 .name. lowercased (). contains (needle) }
}
}
ここで私が実際につまずいたのは、entities(matching:) を実装しないまま suggestedEntities() だけで済ませていたことでした。候補の先頭10件に入っていないコレクションは、名前を正確に言っても選べません。音声で「昨日追加したあれ」を開こうとしても、候補外なら永遠に出てこない。動的カタログでは、提示(suggested)と検索(matching)は別の責務として両方いる、というのが今の私の結論です。
Intent 本体はクエリに乗るだけで済みます
ここまで設計できていれば、Intent 本体は驚くほど薄くなります。@Parameter に AppEntity を指定するだけで、候補提示も検索も OS 側が EntityQuery を通じて呼び出してくれます。
struct OpenCollectionIntent : AppIntent {
static var title: LocalizedStringResource = "コレクションを開く"
static var openAppWhenRun = true // 表示系はアプリに引き渡す
@Parameter (title : "コレクション" )
var collection: WallpaperCollection
@MainActor
func perform () async throws -> some IntentResult {
AppRouter.shared. open ( collectionID : collection.id)
return . result ()
}
}
AppEnum を使っていたときは選択肢を増やすたびに列挙子を書き換えていましたが、この形にしてからはデータストアに1件足すだけで、Siri とショートアプリ両方の候補が自動で追随するようになりました。Intent のコードは一度書けば触りません。増えるのはデータだけ、という状態に持っていけたのが一番の収穫です。
識別子に配列インデックスを使うと、静かに事故ります
AppEntity の設計で最も事故が多い落とし穴が id の付け方です。ここは実装前に必ず押さえておきたい注意点です。Antigravity に限らず、生成コードは手近な値を識別子にしがちで、配列の添字やループのカウンタが混ざることがあります。これは動的カタログでは致命的です。
コレクションの並び順が変わったり、途中の1件が削除されたりすると、添字ベースの ID は指す先がずれます。ユーザーが「京都の夜」を指定して保存したショートカットが、翌日には別のコレクションを開く。しかもクラッシュしないので気づきにくい。entities(for:) が「たまたま別の実体」を返してしまうためです。
対策は単純で、データ側が持つ永続 ID を必ず使う ことです。私の壁紙アプリでは各コレクションに作成時点で発番した安定スラッグを持たせています。並び替えても削除しても、生きているコレクションの ID は変わりません。生成コードを受け取ったら、まず id が何に由来しているかだけは自分の目で確かめる価値があります。
// NG: 並びが変わると指す先がずれる
let entities = collections. enumerated (). map {
WallpaperCollection ( id : " \( $0 . offset ) " , name : $0 .element.name, itemCount : $0 .element. count )
}
// OK: データ由来の安定 ID を使う
let entities = collections. map {
WallpaperCollection ( id : $0 .stableSlug, name : $0 .name, itemCount : $0 . count )
}
エージェント運用でこの種の取り違えを push 前に弾くなら、エージェントが書いた App Intent を確認ゲートで塞ぐ設計 で紹介した静的チェックと同じ発想が使えます。生成物を信用せず、識別子の由来を機械的に確認する一手間を挟むだけで、この手の再解決事故はかなり防げます。
Spotlight に載せるなら IndexedEntity を足す
新しいインテリジェンス・フレームワークでアシスタントからの露出が広がったことで、コレクションを Spotlight 検索からも開けるようにしたくなります。AppEntity を IndexedEntity に準拠させると、エンティティがそのまま Spotlight のインデックス対象になります。
import CoreSpotlight
extension WallpaperCollection : IndexedEntity {
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet ( contentType : .content)
attrs.title = name
attrs.contentDescription = " \( itemCount ) 枚のコレクション"
return attrs
}
}
ここで注意したいのは、インデックスの更新責任がアプリ側に残る点です。コレクションを増やした・消したタイミングで CSSearchableIndex を更新しないと、Spotlight の検索結果が実データと食い違います。App Intents の導入で「OS が勝手にやってくれる」感覚になりがちですが、動的カタログの鮮度はこれまで通り自分で保つ必要があります。私自身、ここを自動化するまでは削除済みコレクションが検索に残る不整合を何度か出しました。
App Intents の基礎的な導入手順そのものはAntigravity で iOS の App Intents を実装する手順 にまとめてあります。本稿はその先の、カタログが動的な場合に固有の設計に絞って書きました。
迷ったときの判断ステップ
最後に、実装に入る前の判断を手順の形に整理しておきます。私自身、新しいアプリにこの仕組みを足すときは毎回この順で考えています。
そのパラメータの候補は、アプリを更新せずに増えるかを言葉にする。増えないなら AppEnum、増えるなら AppEntity へ進む。
AppEntity を選んだら、entities(for:)(再解決)・suggestedEntities()(提示)・entities(matching:)(検索)の3つを別々の責務として埋める。名前で選ばせたいなら EntityStringQuery まで実装する。
識別子はデータ由来の安定値だけを使い、Spotlight に載せるなら IndexedEntity とインデックス更新をセットで持つ。
固定の設定値なら私は迷わず AppEnum を推奨します。一方で、ユーザーやサーバー側で増減するデータを扱う場合は、たとえ最初の件数が少なくても私は AppEntity を採用することをお勧めします。小さいうちに動いてしまうがゆえに、後から移行するほうが高くつくからです。
まずは自分のアプリのパラメータを1つだけ選んで、それが固定メニューなのか動的カタログなのかを言葉にしてみてください。そこがはっきりすれば、どの型を選ぶかは自然に決まります。お読みいただきありがとうございました。